Rename to the generic Unlocker contract; add set_max_read_speed
Track libfreemkv's finalized 3-capability Unlocker trait: - unlock(...) -> unlock_drive(...) - read_vid(...) -> read_volume_id(...) - add set_max_read_speed(...) set_max_read_speed issues the matched profile's set_speed_max_cdb (0xBB SET CD SPEED, to max) over the raw transport; a profile without a set_speed_max_cdb (or no matching profile) is a no-op so the drive stays at its current speed. read_disc_keys_cdb is left untouched — inert profile data, never issued, no trait method. README: document the 3-method contract, the delete-to-comply fallback, and the #2-only boundary (freemkv uploads RAM microcode to an already-bootloader-flashed drive; the permanent bootloader flash #1 is the owner's one-time manual step, never automated). Left a marked MakeMKV attribution placeholder for the maintainer to author. Tests: renamed read_vid tests; added set_max_read_speed tests (issues the CDB when present, no-op when the profile carries none).
This commit is contained in:
@@ -20,4 +20,31 @@ That single line is the whole plug. Any drive whose identity matches a bundled
|
|||||||
profile is firmware-unlocked at drive-prep; everything else falls through to
|
profile is firmware-unlocked at drive-prep; everything else falls through to
|
||||||
libfreemkv's host-certificate AACS handshake.
|
libfreemkv's host-certificate AACS handshake.
|
||||||
|
|
||||||
<!-- TODO(owner): add MakeMKV / LibreDrive attribution. -->
|
## The `Unlocker` contract
|
||||||
|
|
||||||
|
This crate is the LibreDrive unlocker — an implementation of libfreemkv's
|
||||||
|
`Unlocker` trait. The trait is a 3-method capability contract:
|
||||||
|
|
||||||
|
- `unlock_drive` — put the drive into extended-access mode. The one required
|
||||||
|
capability.
|
||||||
|
- `read_volume_id` — read the disc Volume ID directly, bypassing the AACS cert
|
||||||
|
handshake. `None` → libfreemkv falls back to the cert-based read. No-op
|
||||||
|
default.
|
||||||
|
- `set_max_read_speed` — raise the drive to its maximum read speed. No-op
|
||||||
|
default.
|
||||||
|
|
||||||
|
libfreemkv's AACS layer is the always-present baseline; it uses an unlocker's
|
||||||
|
capabilities when one matches, and does the full cert handshake when none do.
|
||||||
|
Remove this crate and libfreemkv still compiles and rips — every capability
|
||||||
|
falls back to the OEM/baseline path.
|
||||||
|
|
||||||
|
## Scope: RAM microcode only (`#2`), never the bootloader flash (`#1`)
|
||||||
|
|
||||||
|
freemkv uploads the RAM microcode to an **already-bootloader-flashed** drive.
|
||||||
|
The permanent bootloader flash (`#1`) is the drive owner's one-time manual
|
||||||
|
step; it is **never** automated by freemkv. This crate only performs the
|
||||||
|
non-persistent `#2` step — the microcode lives in RAM and is gone on power
|
||||||
|
cycle.
|
||||||
|
|
||||||
|
<!-- TODO(owner): MakeMKV attribution + thanks -->
|
||||||
|
|
||||||
|
|||||||
+96
-6
@@ -56,7 +56,7 @@ impl Unlocker for LibreDrive {
|
|||||||
profile::find_bundled(id).is_some()
|
profile::find_bundled(id).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unlock(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<()> {
|
fn unlock_drive(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<()> {
|
||||||
let Some(m) = profile::find_bundled(id) else {
|
let Some(m) = profile::find_bundled(id) else {
|
||||||
// matches() returned true but the profile vanished — treat as
|
// matches() returned true but the profile vanished — treat as
|
||||||
// "nothing to do"; the caller falls back to the cert handshake.
|
// "nothing to do"; the caller falls back to the cert handshake.
|
||||||
@@ -101,7 +101,11 @@ impl Unlocker for LibreDrive {
|
|||||||
/// * `[3]` reserved
|
/// * `[3]` reserved
|
||||||
/// * `[4..20]` 16-byte Volume ID
|
/// * `[4..20]` 16-byte Volume ID
|
||||||
/// * `[20..36]` reserved / per-drive padding
|
/// * `[20..36]` reserved / per-drive padding
|
||||||
fn read_vid(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<Option<[u8; 16]>> {
|
fn read_volume_id(
|
||||||
|
&self,
|
||||||
|
scsi: &mut dyn ScsiTransport,
|
||||||
|
id: &DriveId,
|
||||||
|
) -> Result<Option<[u8; 16]>> {
|
||||||
const RESPONSE_LEN: usize = 36;
|
const RESPONSE_LEN: usize = 36;
|
||||||
const EXPECTED_HEADER: [u8; 3] = [0x00, 0x22, 0x00];
|
const EXPECTED_HEADER: [u8; 3] = [0x00, 0x22, 0x00];
|
||||||
|
|
||||||
@@ -145,6 +149,29 @@ impl Unlocker for LibreDrive {
|
|||||||
);
|
);
|
||||||
Ok(Some(vid))
|
Ok(Some(vid))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Raise the drive to its maximum read speed.
|
||||||
|
///
|
||||||
|
/// Issues the matched profile's `set_speed_max_cdb` (the
|
||||||
|
/// `0xBB SET CD SPEED`-to-max command) over the raw transport. A profile
|
||||||
|
/// without a `set_speed_max_cdb` (or no matching profile at all) is a
|
||||||
|
/// no-op — the drive stays at its current speed.
|
||||||
|
fn set_max_read_speed(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<()> {
|
||||||
|
let Some(m) = profile::find_bundled(id) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(cdb) = m.profile.set_speed_max_cdb else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let mut buf = [0u8; 0];
|
||||||
|
scsi.execute(&cdb, DataDirection::None, &mut buf, 5_000)?;
|
||||||
|
tracing::debug!(
|
||||||
|
target: "freemkv::drive",
|
||||||
|
phase = "set_max_read_speed",
|
||||||
|
"issued SET CD SPEED (max) via unlocker"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -214,7 +241,7 @@ mod tests {
|
|||||||
bytes_transferred: 36,
|
bytes_transferred: 36,
|
||||||
};
|
};
|
||||||
let got = LibreDrive::new()
|
let got = LibreDrive::new()
|
||||||
.read_vid(&mut t, &known_vid_drive_id())
|
.read_volume_id(&mut t, &known_vid_drive_id())
|
||||||
.expect("parse ok");
|
.expect("parse ok");
|
||||||
assert_eq!(got, Some(vid), "VID parsed from [4..20]");
|
assert_eq!(got, Some(vid), "VID parsed from [4..20]");
|
||||||
}
|
}
|
||||||
@@ -227,7 +254,7 @@ mod tests {
|
|||||||
bytes_transferred: 20,
|
bytes_transferred: 20,
|
||||||
};
|
};
|
||||||
let err = LibreDrive::new()
|
let err = LibreDrive::new()
|
||||||
.read_vid(&mut t, &known_vid_drive_id())
|
.read_volume_id(&mut t, &known_vid_drive_id())
|
||||||
.expect_err("short response must error");
|
.expect_err("short response must error");
|
||||||
assert!(matches!(err, Error::AacsVidRead));
|
assert!(matches!(err, Error::AacsVidRead));
|
||||||
}
|
}
|
||||||
@@ -242,7 +269,7 @@ mod tests {
|
|||||||
bytes_transferred: 36,
|
bytes_transferred: 36,
|
||||||
};
|
};
|
||||||
let err = LibreDrive::new()
|
let err = LibreDrive::new()
|
||||||
.read_vid(&mut t, &known_vid_drive_id())
|
.read_volume_id(&mut t, &known_vid_drive_id())
|
||||||
.expect_err("bad header must error");
|
.expect_err("bad header must error");
|
||||||
assert!(matches!(err, Error::AacsVidRead));
|
assert!(matches!(err, Error::AacsVidRead));
|
||||||
}
|
}
|
||||||
@@ -256,8 +283,71 @@ mod tests {
|
|||||||
bytes_transferred: 36,
|
bytes_transferred: 36,
|
||||||
};
|
};
|
||||||
let got = LibreDrive::new()
|
let got = LibreDrive::new()
|
||||||
.read_vid(&mut t, &make_drive_id("FAKE-VND", "9.99", "XX12345", ""))
|
.read_volume_id(&mut t, &make_drive_id("FAKE-VND", "9.99", "XX12345", ""))
|
||||||
.expect("no-profile is Ok(None)");
|
.expect("no-profile is Ok(None)");
|
||||||
assert!(got.is_none(), "no profile → cert fallback");
|
assert!(got.is_none(), "no profile → cert fallback");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A transport that records the CDB issued (and how many CDBs it saw),
|
||||||
|
/// so the speed test can assert the profile's `set_speed_max_cdb` is the
|
||||||
|
/// one sent — or that nothing was sent at all (no-op).
|
||||||
|
struct RecordingTransport {
|
||||||
|
last_cdb: Vec<u8>,
|
||||||
|
calls: usize,
|
||||||
|
}
|
||||||
|
impl ScsiTransport for RecordingTransport {
|
||||||
|
fn execute(
|
||||||
|
&mut self,
|
||||||
|
cdb: &[u8],
|
||||||
|
_dir: DataDirection,
|
||||||
|
_data: &mut [u8],
|
||||||
|
_timeout_ms: u32,
|
||||||
|
) -> Result<ScsiResult> {
|
||||||
|
self.last_cdb = cdb.to_vec();
|
||||||
|
self.calls += 1;
|
||||||
|
Ok(ScsiResult {
|
||||||
|
status: 0,
|
||||||
|
bytes_transferred: 0,
|
||||||
|
sense: [0u8; 32],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A matched drive whose profile carries `set_speed_max_cdb` → that exact
|
||||||
|
/// CDB is issued.
|
||||||
|
#[test]
|
||||||
|
fn set_max_read_speed_issues_profile_cdb() {
|
||||||
|
let m = profile::find_bundled(&known_vid_drive_id()).expect("profile match");
|
||||||
|
let expected = m
|
||||||
|
.profile
|
||||||
|
.set_speed_max_cdb
|
||||||
|
.expect("test fixture drive must carry a set_speed_max_cdb");
|
||||||
|
|
||||||
|
let mut t = RecordingTransport {
|
||||||
|
last_cdb: Vec::new(),
|
||||||
|
calls: 0,
|
||||||
|
};
|
||||||
|
LibreDrive::new()
|
||||||
|
.set_max_read_speed(&mut t, &known_vid_drive_id())
|
||||||
|
.expect("set_max_read_speed ok");
|
||||||
|
assert_eq!(t.calls, 1, "exactly one CDB issued");
|
||||||
|
assert_eq!(
|
||||||
|
t.last_cdb,
|
||||||
|
expected.to_vec(),
|
||||||
|
"the profile's set_speed_max_cdb"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A drive with no matching profile → no-op: no CDB issued, Ok(()).
|
||||||
|
#[test]
|
||||||
|
fn set_max_read_speed_no_profile_is_noop() {
|
||||||
|
let mut t = RecordingTransport {
|
||||||
|
last_cdb: Vec::new(),
|
||||||
|
calls: 0,
|
||||||
|
};
|
||||||
|
LibreDrive::new()
|
||||||
|
.set_max_read_speed(&mut t, &make_drive_id("FAKE-VND", "9.99", "XX12345", ""))
|
||||||
|
.expect("no-profile is a no-op Ok(())");
|
||||||
|
assert_eq!(t.calls, 0, "no profile → no CDB issued");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user