unlock: drive-features capability (stock riplock lift for any disc)
Reframe: an unlocker provides drive-features (speed/riplock — a property of the DRIVE, any disc) AND bus removal (AACS decrypt+VID / CSS), kept separate so a disc can take one without the other. A CSS DVD wants a matched drive's speed but must NOT take its firmware bus-unlock (breaks stock CSS). Add Unlocker::apply_drive_features (default no-op, self-gating). LibreDrive implements it with STOCK MMC commands only — SET STREAMING (0xB6), the modern riplock lift many slot-loading BD combos honor when they ignore the legacy SET CD SPEED (0xBB), then SET CD SPEED as fallback — so it is safe on a non-unlocked drive. New build_set_streaming() + tests.
This commit is contained in:
@@ -125,6 +125,33 @@ impl Unlocker for LibreDrive {
|
||||
ctx.kind == crate::DiscKind::Unknown && profile::find_bundled(ctx.drive_id).is_some()
|
||||
}
|
||||
|
||||
/// Lift riplock on a profiled drive with STOCK MMC commands — no firmware
|
||||
/// unlock, so it is safe for ANY disc including a CSS DVD (which must stay in
|
||||
/// stock mode). We issue SET STREAMING (0xB6) — the modern command a slot-
|
||||
/// loading BD combo honors when it ignores the legacy SET CD SPEED — and then
|
||||
/// SET CD SPEED (0xBB) as a fallback for drives that only take the old one.
|
||||
/// Self-gating: no bundled profile → no-op. Best-effort throughout: a drive
|
||||
/// that rejects either command still rips, just slower, so every error is
|
||||
/// swallowed (including a transport fault — the real reads that follow will
|
||||
/// surface a dead bus).
|
||||
fn apply_drive_features(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<(), UnlockError> {
|
||||
if profile::find_bundled(ctx.drive_id).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
// SET STREAMING (max): CDB + 28-byte performance descriptor data-out.
|
||||
let (cdb, mut descriptor) = crate::scsi::build_set_streaming(0xFFFF_FFFF);
|
||||
let _ = scsi.execute(&cdb, DataDirection::ToDevice, &mut descriptor, 5_000);
|
||||
// SET CD SPEED (max): no data-out.
|
||||
let cdb = crate::scsi::build_set_cd_speed(0xFFFF);
|
||||
let mut empty = [0u8; 0];
|
||||
let _ = scsi.execute(&cdb, DataDirection::None, &mut empty, 5_000);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Firmware-unlock the drive and report its OEM Volume ID. The unlocked drive
|
||||
/// serves CLEAR content, so `drive_unlocked: true` and there is no bus key.
|
||||
///
|
||||
@@ -295,4 +322,60 @@ mod tests {
|
||||
.expect_err("no profile → NotApplicable");
|
||||
assert_eq!(err, UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
/// A transport that records the opcode of every CDB it is handed.
|
||||
struct RecordingTransport {
|
||||
opcodes: Vec<u8>,
|
||||
}
|
||||
impl ScsiTransport for RecordingTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
self.opcodes.push(cdb[0]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A profiled drive gets the stock speed commands (SET STREAMING first, then
|
||||
/// SET CD SPEED) — the riplock lift, for any disc.
|
||||
#[test]
|
||||
fn drive_features_issues_stock_speed_commands_on_profiled_drive() {
|
||||
let mut t = RecordingTransport { opcodes: vec![] };
|
||||
LibreDrive::new()
|
||||
.apply_drive_features(&mut t, &ctx(&known_vid_drive_id()))
|
||||
.expect("best-effort ok");
|
||||
assert!(
|
||||
t.opcodes.contains(&crate::scsi::SCSI_SET_STREAMING),
|
||||
"SET STREAMING (0xB6) issued"
|
||||
);
|
||||
assert!(
|
||||
t.opcodes.contains(&crate::scsi::SCSI_SET_CD_SPEED),
|
||||
"SET CD SPEED (0xBB) issued"
|
||||
);
|
||||
}
|
||||
|
||||
/// An unrecognised drive: drive-features is a no-op (self-gating), so the
|
||||
/// consumer can call it on every unlocker without side effects.
|
||||
#[test]
|
||||
fn drive_features_is_noop_on_unprofiled_drive() {
|
||||
let mut t = RecordingTransport { opcodes: vec![] };
|
||||
LibreDrive::new()
|
||||
.apply_drive_features(
|
||||
&mut t,
|
||||
&ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")),
|
||||
)
|
||||
.expect("best-effort ok");
|
||||
assert!(
|
||||
t.opcodes.is_empty(),
|
||||
"no commands issued for an unrecognised drive"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+30
-7
@@ -102,16 +102,39 @@ pub enum UnlockError {
|
||||
Transport,
|
||||
}
|
||||
|
||||
/// An unlocker removes a drive-level bus-encryption barrier. Implementors are
|
||||
/// the self-contained modules in this crate; the consumer only ever sees the
|
||||
/// trait, via [`all_unlockers`]. (Each module owns its own conversion from its
|
||||
/// internal error to [`UnlockError`].)
|
||||
/// An unlocker provides drive/disc capabilities: **drive features** (speed /
|
||||
/// riplock lift — a property of the DRIVE, applied for any disc) and **bus
|
||||
/// removal** (AACS bus-decrypt + VID, or a CSS handshake — gated on the disc).
|
||||
/// Implementors are the self-contained modules in this crate; the consumer only
|
||||
/// ever sees the trait, via [`all_unlockers`]. (Each module owns its own
|
||||
/// conversion from its internal error to [`UnlockError`].)
|
||||
///
|
||||
/// NOTE: drive tuning (e.g. SET CD SPEED to lift riplock) is deliberately NOT
|
||||
/// here — that is the consumer's concern, not bus removal.
|
||||
/// The two capabilities are independent so a disc can take one without the
|
||||
/// other. A CSS DVD, for example, wants a matched drive's [`apply_drive_features`]
|
||||
/// (speed) but must NOT take its firmware bus-unlock, which would break stock CSS
|
||||
/// auth. Keeping them separate lets the consumer apply only what the current
|
||||
/// (drive, disc) context needs.
|
||||
///
|
||||
/// [`apply_drive_features`]: Unlocker::apply_drive_features
|
||||
pub trait Unlocker: Send + Sync {
|
||||
/// True if this unlocker applies to the given context (drive id + disc kind).
|
||||
/// True if this unlocker's bus-removal [`unlock`](Unlocker::unlock) applies to
|
||||
/// the given context (drive id + disc kind).
|
||||
fn matches(&self, ctx: &UnlockCtx) -> bool;
|
||||
|
||||
/// Apply drive-level feature tuning (max read speed / riplock lift) that this
|
||||
/// unlocker enables purely by virtue of the DRIVE — **safe for any disc,
|
||||
/// touches no bus encryption**. Self-gating: an unlocker that doesn't
|
||||
/// recognise the drive returns `Ok(())` (a no-op), so the consumer can call
|
||||
/// this on every unlocker regardless of disc kind. Best-effort: a rejected
|
||||
/// command must NOT fail the rip (a slow drive still rips). Default: no-op.
|
||||
fn apply_drive_features(
|
||||
&self,
|
||||
_scsi: &mut dyn ScsiTransport,
|
||||
_ctx: &UnlockCtx,
|
||||
) -> std::result::Result<(), UnlockError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the bus-encryption barrier, returning what was learned.
|
||||
fn unlock(
|
||||
&self,
|
||||
|
||||
+81
@@ -74,6 +74,7 @@ pub(crate) const SCSI_STATUS_CHECK_CONDITION: u8 = 0x02;
|
||||
|
||||
// Common opcodes used by the unlocker modules.
|
||||
pub(crate) const SCSI_SET_CD_SPEED: u8 = 0xBB;
|
||||
pub(crate) const SCSI_SET_STREAMING: u8 = 0xB6;
|
||||
pub(crate) const SCSI_SEND_KEY: u8 = 0xA3;
|
||||
pub(crate) const SCSI_REPORT_KEY: u8 = 0xA4;
|
||||
pub(crate) const SCSI_READ_DISC_STRUCTURE: u8 = 0xAD;
|
||||
@@ -98,3 +99,83 @@ pub(crate) fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
|
||||
/// Length of a SET STREAMING Performance Descriptor (MMC-6 §6.42).
|
||||
pub(crate) const SET_STREAMING_DESCRIPTOR_LEN: usize = 28;
|
||||
|
||||
/// Build a SET STREAMING (0xB6) CDB + its 28-byte Performance Descriptor
|
||||
/// requesting maximum read performance across the whole disc. This is the modern
|
||||
/// riplock lift: many drives (notably slot-loading BD combos over a USB bridge)
|
||||
/// ignore the legacy SET CD SPEED (0xBB) but honor SET STREAMING, and unlike a
|
||||
/// firmware unlock it is a STOCK MMC command — safe to issue on a non-unlocked
|
||||
/// drive, so it never disturbs stock CSS auth.
|
||||
///
|
||||
/// `read_kbps` is the requested read size per 1000 ms window; `0xFFFF_FFFF` asks
|
||||
/// the drive for its maximum. Returns `(cdb, descriptor)`; the caller sends the
|
||||
/// descriptor as the CDB's data-out payload. The CDB's Parameter List Length
|
||||
/// (bytes 9–10, big-endian) is the descriptor length.
|
||||
pub(crate) fn build_set_streaming(
|
||||
read_kbps: u32,
|
||||
) -> ([u8; 12], [u8; SET_STREAMING_DESCRIPTOR_LEN]) {
|
||||
let len = SET_STREAMING_DESCRIPTOR_LEN as u16;
|
||||
let cdb = [
|
||||
SCSI_SET_STREAMING,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
(len >> 8) as u8, // Parameter List Length (MSB)
|
||||
len as u8, // Parameter List Length (LSB)
|
||||
0x00,
|
||||
];
|
||||
let mut d = [0u8; SET_STREAMING_DESCRIPTOR_LEN];
|
||||
// byte 0: flags — RDD=0 (SET performance, don't restore defaults), Exact=0,
|
||||
// RA=0. bytes 1–3 reserved.
|
||||
// bytes 4..8: Start LBA = 0.
|
||||
// bytes 8..12: End LBA = 0xFFFFFFFF (apply across the whole disc).
|
||||
d[8..12].copy_from_slice(&0xFFFF_FFFFu32.to_be_bytes());
|
||||
// bytes 12..16: Read Size (kB per Read Time window).
|
||||
d[12..16].copy_from_slice(&read_kbps.to_be_bytes());
|
||||
// bytes 16..20: Read Time = 1000 ms.
|
||||
d[16..20].copy_from_slice(&1000u32.to_be_bytes());
|
||||
// bytes 20..24 / 24..28: Write Size / Write Time (mirror read; unused for a
|
||||
// read-only rip but the descriptor requires them).
|
||||
d[20..24].copy_from_slice(&read_kbps.to_be_bytes());
|
||||
d[24..28].copy_from_slice(&1000u32.to_be_bytes());
|
||||
(cdb, d)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// SET STREAMING (0xB6) wire format: opcode, the 28-byte Parameter List
|
||||
/// Length in CDB bytes 9–10, and the max-speed performance descriptor
|
||||
/// (whole-disc End LBA + the requested read size).
|
||||
#[test]
|
||||
fn set_streaming_cdb_layout() {
|
||||
let (cdb, d) = build_set_streaming(0xFFFF_FFFF);
|
||||
assert_eq!(cdb[0], SCSI_SET_STREAMING);
|
||||
assert_eq!(
|
||||
u16::from_be_bytes([cdb[9], cdb[10]]) as usize,
|
||||
SET_STREAMING_DESCRIPTOR_LEN,
|
||||
"param list length = descriptor length"
|
||||
);
|
||||
assert_eq!(d.len(), SET_STREAMING_DESCRIPTOR_LEN);
|
||||
assert_eq!(&d[8..12], &[0xFF, 0xFF, 0xFF, 0xFF], "whole-disc End LBA");
|
||||
assert_eq!(
|
||||
u32::from_be_bytes([d[12], d[13], d[14], d[15]]),
|
||||
0xFFFF_FFFF,
|
||||
"read size requests max"
|
||||
);
|
||||
assert_eq!(
|
||||
u32::from_be_bytes([d[16], d[17], d[18], d[19]]),
|
||||
1000,
|
||||
"read time window = 1000 ms"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user