unlock: split Unlocker into unlock_features/unlock_bus; add Renesas unlocker
Replace the single matches()+unlock() contract with two capability methods — unlock_features (drive riplock/speed/OEM VID at drive-prep) and unlock_bus (AACS/CSS bus-encryption removal for the mounted disc) — each defaulting to NotApplicable so an unlocker implements only what it does. Add the renesis module: the Renesas-platform unlocker (Pioneer + HL-DT-ST Renesas), detected via the READ_BUFFER 0x02/0xF1 identity probe (ASCII "SAT" marker). Features only; the cert handles the bus. Add product_id to DriveId. Bump to 1.2.3.
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.2.2"
|
||||
version = "1.2.3"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
+27
-14
@@ -48,15 +48,17 @@ impl Unlocker for AacsCert {
|
||||
"AACS"
|
||||
}
|
||||
|
||||
fn matches(&self, ctx: &UnlockCtx) -> bool {
|
||||
ctx.kind == DiscKind::Aacs
|
||||
}
|
||||
|
||||
fn unlock(
|
||||
/// AACS removes BUS encryption via the host-cert handshake; it provides no
|
||||
/// drive features. Self-guards on the disc kind (the consumer iterates every
|
||||
/// unlocker's `unlock_bus`, so a non-AACS disc must decline here).
|
||||
fn unlock_bus(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
if ctx.kind != DiscKind::Aacs {
|
||||
return Err(UnlockError::NotApplicable);
|
||||
}
|
||||
if ctx.host_certs.is_empty() {
|
||||
// No host cert to authenticate with — the consumer falls back to a
|
||||
// VID-less / keysource path.
|
||||
@@ -81,18 +83,29 @@ mod tests {
|
||||
crate::DriveId::default()
|
||||
}
|
||||
|
||||
/// AacsCert matches only `DiscKind::Aacs`.
|
||||
/// `unlock_bus` self-guards on the disc kind: on a non-AACS disc it declines
|
||||
/// (`NotApplicable`) WITHOUT touching the transport, so iterating it on a
|
||||
/// CSS/unknown disc is safe.
|
||||
#[test]
|
||||
fn matches_only_aacs_kind() {
|
||||
fn unlock_bus_declines_non_aacs_kinds() {
|
||||
struct DeadTransport;
|
||||
impl ScsiTransport for DeadTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
panic!("transport must not be touched on a non-AACS disc");
|
||||
}
|
||||
}
|
||||
let id = id();
|
||||
let u = AacsCert::new();
|
||||
let mut t = DeadTransport;
|
||||
for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Css] {
|
||||
assert!(
|
||||
!u.matches(&UnlockCtx::new(&id, k, &[])),
|
||||
"must not match {k:?}"
|
||||
);
|
||||
let r = AacsCert::new().unlock_bus(&mut t, &UnlockCtx::new(&id, k, &[]));
|
||||
assert_eq!(r.unwrap_err(), UnlockError::NotApplicable, "declines {k:?}");
|
||||
}
|
||||
assert!(u.matches(&UnlockCtx::new(&id, DiscKind::Aacs, &[])));
|
||||
}
|
||||
|
||||
/// With no host certs there is nothing to authenticate with → NoUsableHostCert,
|
||||
@@ -113,7 +126,7 @@ mod tests {
|
||||
}
|
||||
let id = id();
|
||||
let mut t = DeadTransport;
|
||||
let r = AacsCert::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs, &[]));
|
||||
let r = AacsCert::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs, &[]));
|
||||
assert_eq!(r.unwrap_err(), UnlockError::NoUsableHostCert);
|
||||
}
|
||||
}
|
||||
|
||||
+25
-25
@@ -164,11 +164,10 @@ impl crate::Unlocker for CssUnlocker {
|
||||
"CSS"
|
||||
}
|
||||
|
||||
fn matches(&self, ctx: &crate::UnlockCtx) -> bool {
|
||||
ctx.kind == crate::DiscKind::Css
|
||||
}
|
||||
|
||||
fn unlock(
|
||||
/// CSS removes the scrambled-sector barrier (a bus-level concern); it
|
||||
/// provides no drive features. Self-guards against the hardware (below), so
|
||||
/// it declines cleanly when the consumer iterates it on a non-DVD.
|
||||
fn unlock_bus(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
_ctx: &crate::UnlockCtx,
|
||||
@@ -923,27 +922,28 @@ mod tests {
|
||||
assert_eq!(cdb[9], 0x04, "low byte of 2052-byte transfer");
|
||||
}
|
||||
|
||||
/// The CssUnlocker matches ONLY `DiscKind::Css` (never fires during
|
||||
/// drive-prep or on a Blu-ray).
|
||||
/// CssUnlocker provides bus removal only — it never provides drive features.
|
||||
#[test]
|
||||
fn css_unlocker_matches_only_css_kind() {
|
||||
use crate::{DiscKind, DriveId, UnlockCtx, Unlocker};
|
||||
let id = DriveId {
|
||||
vendor_id: "FAKEVNDR".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let u = CssUnlocker::new();
|
||||
assert!(
|
||||
u.matches(&UnlockCtx::new(&id, DiscKind::Css, &[])),
|
||||
"matches a CSS DVD"
|
||||
);
|
||||
for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Aacs] {
|
||||
assert!(
|
||||
!u.matches(&UnlockCtx::new(&id, k, &[])),
|
||||
"CssUnlocker must not match {k:?}"
|
||||
);
|
||||
fn css_unlocker_provides_no_features() {
|
||||
use crate::scsi::{DataDirection, ScsiResult};
|
||||
use crate::{DiscKind, DriveId, UnlockCtx, UnlockError, Unlocker};
|
||||
struct DeadTransport;
|
||||
impl ScsiTransport for DeadTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
panic!("unlock_features must not touch the transport");
|
||||
}
|
||||
}
|
||||
let id = DriveId::default();
|
||||
let mut t = DeadTransport;
|
||||
let r =
|
||||
CssUnlocker::new().unlock_features(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[]));
|
||||
assert_eq!(r.unwrap_err(), UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
/// Defense in depth: even when the caller declares `DiscKind::Css`, the
|
||||
@@ -994,7 +994,7 @@ mod tests {
|
||||
};
|
||||
|
||||
let mut t = BdTransport { non_config_cdbs: 0 };
|
||||
let r = CssUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[]));
|
||||
let r = CssUnlocker::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[]));
|
||||
assert_eq!(
|
||||
r.unwrap_err(),
|
||||
UnlockError::NotApplicable,
|
||||
|
||||
+44
-27
@@ -116,26 +116,17 @@ impl LibreDrive {
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for LibreDrive {
|
||||
/// Firmware unlock is a DRIVE-PREP concern: it runs before the disc kind is
|
||||
/// probed (`kind == Unknown`) and keys off the drive identity. It must NOT
|
||||
/// fire during the later content-keyed dispatch (Aacs/Css), or a DVD/Blu-ray
|
||||
/// in a profiled drive would be re-firmware-unlocked.
|
||||
fn name(&self) -> &'static str {
|
||||
"LibreDrive"
|
||||
}
|
||||
|
||||
fn matches(&self, ctx: &UnlockCtx) -> bool {
|
||||
ctx.kind == crate::DiscKind::Unknown && profile::find_bundled(ctx.drive_id).is_some()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
impl LibreDrive {
|
||||
/// The MediaTek firmware unlock. Because LibreDrive removes AACS bus
|
||||
/// encryption AT THE DRIVE (the unlocked drive serves CLEAR content), this ONE
|
||||
/// operation satisfies BOTH the drive-features and the bus-removal capability
|
||||
/// — so `unlock_features` and `unlock_bus` both delegate here. The result
|
||||
/// carries `drive_unlocked: true` (no bus key needed) and the OEM Volume ID.
|
||||
///
|
||||
/// A no-firmware-route drive (Renesas) returns `NotApplicable` (fall through);
|
||||
/// a transport fault propagates as `Transport`; a firmware failure that isn't
|
||||
/// a dead bus also falls through (`NotApplicable`).
|
||||
fn unlock(
|
||||
/// A no-firmware-route drive (Renesas / no profile) returns `NotApplicable`; a
|
||||
/// transport fault propagates as `Transport`; any other firmware failure also
|
||||
/// falls through as `NotApplicable`.
|
||||
fn firmware_unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
@@ -145,19 +136,17 @@ impl Unlocker for LibreDrive {
|
||||
return Err(UnlockError::NotApplicable);
|
||||
};
|
||||
if matches!(m.platform, profile::Platform::Renesas) {
|
||||
// Renesas firmware unlock is not implemented — fall through to cert.
|
||||
// Renesas is a different platform (handled by the Renesas unlocker).
|
||||
return Err(UnlockError::NotApplicable);
|
||||
}
|
||||
let is_variant_b = matches!(m.platform, profile::Platform::Mt1959B);
|
||||
use platform::PlatformDriver;
|
||||
let mut mt = platform::mt1959::Mt1959::new(m.profile, is_variant_b);
|
||||
// Firmware unlock. A transport fault → UnlockError::Transport; any other
|
||||
// firmware failure → NotApplicable (via From<error::Error>).
|
||||
// A transport fault → UnlockError::Transport; any other firmware failure
|
||||
// → NotApplicable (via From<error::Error>).
|
||||
mt.init(scsi)?;
|
||||
// Prime the per-region speed table (best-effort — must not fail the unlock).
|
||||
let _ = mt.probe_disc(scsi);
|
||||
// The unlocked drive hands back its Volume ID; firmware serves clear
|
||||
// content, so no bus key and drive_unlocked = true.
|
||||
let vid = self.read_oem_vid(scsi, id)?;
|
||||
Ok(Unlocked {
|
||||
vid,
|
||||
@@ -167,6 +156,33 @@ impl Unlocker for LibreDrive {
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for LibreDrive {
|
||||
fn name(&self) -> &'static str {
|
||||
"LibreDrive"
|
||||
}
|
||||
|
||||
/// LibreDrive provides drive features (riplock/speed, OEM VID) — and, because
|
||||
/// its firmware unlock serves clear content, bus removal comes free with it.
|
||||
fn unlock_features(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
self.firmware_unlock(scsi, ctx)
|
||||
}
|
||||
|
||||
/// Same firmware code as [`unlock_features`]: LibreDrive removes the bus at
|
||||
/// the drive. In practice the consumer skips this because drive-prep already
|
||||
/// set `drive_unlocked`; it's here for completeness / a bus-first call order.
|
||||
fn unlock_bus(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
self.firmware_unlock(scsi, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -212,6 +228,7 @@ mod tests {
|
||||
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
|
||||
DriveId {
|
||||
vendor_id: vendor.to_string(),
|
||||
product_id: String::new(),
|
||||
product_revision: rev.to_string(),
|
||||
vendor_specific: vs.to_string(),
|
||||
firmware_date: date.to_string(),
|
||||
@@ -283,8 +300,8 @@ mod tests {
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
/// `unlock` on a drive with no matching profile → `NotApplicable` (fall
|
||||
/// through), short-circuiting before any firmware handshake.
|
||||
/// `unlock_features` on a drive with no matching profile → `NotApplicable`
|
||||
/// (fall through), short-circuiting before any firmware handshake.
|
||||
#[test]
|
||||
fn unlock_no_profile_is_not_applicable() {
|
||||
let mut t = FakeTransport {
|
||||
@@ -292,7 +309,7 @@ mod tests {
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let err = LibreDrive::new()
|
||||
.unlock(
|
||||
.unlock_features(
|
||||
&mut t,
|
||||
&ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")),
|
||||
)
|
||||
|
||||
@@ -487,6 +487,7 @@ mod tests {
|
||||
DriveProfile {
|
||||
identity: Identity {
|
||||
vendor_id: "TEST".into(),
|
||||
product_id: String::new(),
|
||||
product_revision: String::new(),
|
||||
vendor_specific: String::new(),
|
||||
firmware_date: String::new(),
|
||||
|
||||
+69
-31
@@ -31,6 +31,8 @@ pub struct Identity {
|
||||
#[serde(default)]
|
||||
pub vendor_id: String,
|
||||
#[serde(default)]
|
||||
pub product_id: String,
|
||||
#[serde(default)]
|
||||
pub product_revision: String,
|
||||
#[serde(default)]
|
||||
pub vendor_specific: String,
|
||||
@@ -283,15 +285,16 @@ fn load_from_str(data: &str) -> Result<Profiles> {
|
||||
|
||||
/// Find a profile matching a drive's INQUIRY fields.
|
||||
///
|
||||
/// Two-pass per platform section (MT1959-A, then MT1959-B, then Renesas):
|
||||
/// first an exact match including `firmware_date`, then — if none — a
|
||||
/// looser match on vendor / revision / vendor-specific only. The exact
|
||||
/// pass wins so a drive with a known firmware date binds to its precise
|
||||
/// profile; the looser pass lets a drive whose firmware date we don't have
|
||||
/// on file still match a same-model profile. All comparisons are
|
||||
/// whitespace-trimmed. Returns the first section that yields a match.
|
||||
/// Per platform section (MT1959-A, then MT1959-B, then Renesas), tried in
|
||||
/// order of decreasing specificity: full match including `product_id` and
|
||||
/// `firmware_date` (skipped when the drive reports no product), then the same
|
||||
/// without `product_id`. Both require `firmware_date`; every profile carries a
|
||||
/// unique identity under these fields, so a match is exact — no looser
|
||||
/// vendor/revision fallback that could pick the wrong same-model variant. All
|
||||
/// comparisons are whitespace-trimmed. Returns the first section that matches.
|
||||
pub fn find_by_drive_id(profiles: &Profiles, drive_id: &crate::DriveId) -> Option<ProfileMatch> {
|
||||
let v = drive_id.vendor_id.trim();
|
||||
let prod = drive_id.product_id.trim();
|
||||
let r = drive_id.product_revision.trim();
|
||||
let vs = drive_id.vendor_specific.trim();
|
||||
let date = drive_id.firmware_date.trim();
|
||||
@@ -301,22 +304,26 @@ pub fn find_by_drive_id(profiles: &Profiles, drive_id: &crate::DriveId) -> Optio
|
||||
(Platform::Mt1959B, &profiles.mt1959_b),
|
||||
(Platform::Renesas, &profiles.renesas),
|
||||
] {
|
||||
if let Some(p) = list.iter().find(|p| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_revision.trim() == r
|
||||
&& p.identity.vendor_specific.trim() == vs
|
||||
&& p.identity.firmware_date.trim() == date
|
||||
}) {
|
||||
return Some(ProfileMatch {
|
||||
profile: p.clone(),
|
||||
platform,
|
||||
});
|
||||
if !prod.is_empty() {
|
||||
if let Some(p) = list.iter().find(|p| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_id.trim() == prod
|
||||
&& p.identity.product_revision.trim() == r
|
||||
&& p.identity.vendor_specific.trim() == vs
|
||||
&& p.identity.firmware_date.trim() == date
|
||||
}) {
|
||||
return Some(ProfileMatch {
|
||||
profile: p.clone(),
|
||||
platform,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(p) = list.iter().find(|p| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_revision.trim() == r
|
||||
&& p.identity.vendor_specific.trim() == vs
|
||||
&& p.identity.firmware_date.trim() == date
|
||||
}) {
|
||||
return Some(ProfileMatch {
|
||||
profile: p.clone(),
|
||||
@@ -336,12 +343,51 @@ mod tests {
|
||||
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
|
||||
DriveId {
|
||||
vendor_id: vendor.to_string(),
|
||||
product_id: String::new(),
|
||||
product_revision: rev.to_string(),
|
||||
vendor_specific: vs.to_string(),
|
||||
firmware_date: date.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// When two profiles share vendor/rev/vs/date and differ only in product_id,
|
||||
/// the full pass binds to the one whose product matches; the looser passes
|
||||
/// would return the first regardless.
|
||||
#[test]
|
||||
fn find_by_drive_id_product_id_breaks_a_tie() {
|
||||
use serde_json::json;
|
||||
let profiles: Profiles = serde_json::from_str(
|
||||
&json!({
|
||||
"mt1959_a": [
|
||||
{"identity": {"vendor_id":"TIE","product_id":"MODEL-A",
|
||||
"product_revision":"1.00","vendor_specific":"XX00000",
|
||||
"firmware_date":"200001010000"},
|
||||
"signature":"aaaaaaaa","firmware":""},
|
||||
{"identity": {"vendor_id":"TIE","product_id":"MODEL-B",
|
||||
"product_revision":"1.00","vendor_specific":"XX00000",
|
||||
"firmware_date":"200001010000"},
|
||||
"signature":"bbbbbbbb","firmware":""}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut id = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
|
||||
id.product_id = "MODEL-B".to_string();
|
||||
let m = find_by_drive_id(&profiles, &id).unwrap();
|
||||
assert_eq!(
|
||||
m.profile.signature,
|
||||
[0xbb, 0xbb, 0xbb, 0xbb],
|
||||
"product_id must select MODEL-B over the first entry"
|
||||
);
|
||||
|
||||
// No product_id → falls back to the 4-field pass → first entry.
|
||||
let id0 = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
|
||||
let m0 = find_by_drive_id(&profiles, &id0).unwrap();
|
||||
assert_eq!(m0.profile.signature, [0xaa, 0xaa, 0xaa, 0xaa]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_known_drive() {
|
||||
let profiles = load_bundled().unwrap();
|
||||
@@ -512,14 +558,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// find_by_drive_id: loose match (no date) still works when an entry has
|
||||
/// the same vendor/revision/vs but an unknown firmware_date.
|
||||
/// Mutation: making the loose pass require a date match means "no date" drives
|
||||
/// always return None even though a same-model profile exists.
|
||||
/// find_by_drive_id: a drive whose firmware_date does NOT match any profile
|
||||
/// gets no match — there is no loose vendor/rev/vs fallback that could bind
|
||||
/// the wrong same-model variant.
|
||||
#[test]
|
||||
fn find_by_drive_id_loose_match_when_date_unknown() {
|
||||
fn find_by_drive_id_no_match_when_date_differs() {
|
||||
use serde_json::json;
|
||||
// "LOOSEDR " fills 8 bytes; trim() → "LOOSEDR".
|
||||
let profiles_json = json!({
|
||||
"mt1959_a": [
|
||||
{
|
||||
@@ -537,15 +581,9 @@ mod tests {
|
||||
.to_string();
|
||||
let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap();
|
||||
|
||||
// Drive with an unknown firmware date — no exact match, loose match should work.
|
||||
// "LOOSEDR " fills 8 bytes; "000000000000" is the unknown date.
|
||||
// Same vendor/rev/vs but a different date — must NOT match.
|
||||
let id = make_drive_id("LOOSEDR ", "2.00", "YY11111", "000000000000");
|
||||
let m = find_by_drive_id(&profiles, &id).unwrap();
|
||||
assert_eq!(
|
||||
m.profile.signature,
|
||||
[0xde, 0xad, 0xbe, 0xef],
|
||||
"loose match must bind the same-model profile when date differs"
|
||||
);
|
||||
assert!(find_by_drive_id(&profiles, &id).is_none());
|
||||
}
|
||||
|
||||
/// load_from_str (via load_bundled) returns ProfileParse on invalid JSON.
|
||||
|
||||
+207
-1
File diff suppressed because it is too large
Load Diff
+33
-11
@@ -21,6 +21,9 @@ mod css;
|
||||
// [`all_unlockers`]. `aacs` and `css` carry no such public catalog, so they
|
||||
// stay fully private.
|
||||
pub mod ld;
|
||||
// `renesis` is public for its `is_renesas` drive-probe; the unlocker impl
|
||||
// (`Renesis`) is `pub(crate)` — reached only through [`all_unlockers`].
|
||||
pub mod renesis;
|
||||
|
||||
use scsi::ScsiTransport;
|
||||
|
||||
@@ -29,6 +32,7 @@ use scsi::ScsiTransport;
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DriveId {
|
||||
pub vendor_id: String,
|
||||
pub product_id: String,
|
||||
pub product_revision: String,
|
||||
pub vendor_specific: String,
|
||||
pub firmware_date: String,
|
||||
@@ -111,22 +115,39 @@ pub enum UnlockError {
|
||||
/// here — that is the consumer's concern, not bus removal.
|
||||
pub trait Unlocker: Send + Sync {
|
||||
/// Short, stable identifier for this unlocker (e.g. "LibreDrive", "AACS",
|
||||
/// "CSS"). The ONE place a name lives — apps render the unlocker report from
|
||||
/// [`all_unlockers`], never hardcoding names, so adding/removing an unlocker
|
||||
/// updates every report with no app change.
|
||||
/// "CSS", "Renesas"). The ONE place a name lives — apps render the unlocker
|
||||
/// report from [`all_unlockers`], never hardcoding names, so adding/removing
|
||||
/// an unlocker updates every report with no app change.
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// True if this unlocker applies to the given context (drive id + disc kind)
|
||||
/// during live dispatch — the gate [`crate::all_unlockers`] uses to decide
|
||||
/// whether to RUN it.
|
||||
fn matches(&self, ctx: &UnlockCtx) -> bool;
|
||||
|
||||
/// Remove the bus-encryption barrier, returning what was learned.
|
||||
fn unlock(
|
||||
/// Unlock DRIVE FEATURES — riplock/speed, OEM Volume ID, OEM extended-access
|
||||
/// reads. The consumer runs this at drive-prep, trying each unlocker until
|
||||
/// one handles the drive. `Ok(_)` = this unlocker handled it (LibreDrive also
|
||||
/// removes bus encryption at the drive, so its result carries
|
||||
/// `drive_unlocked: true`); `Err(NotApplicable)` = not this unlocker's drive;
|
||||
/// `Err(Transport)` = dead bus (the consumer aborts). Default: not provided.
|
||||
fn unlock_features(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError>;
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
let _ = (scsi, ctx);
|
||||
Err(UnlockError::NotApplicable)
|
||||
}
|
||||
|
||||
/// Remove BUS ENCRYPTION for the mounted disc (AACS host-cert handshake, or
|
||||
/// CSS scrambled-sector auth). The consumer runs this only when the bus isn't
|
||||
/// already clear, trying each unlocker until one handles it. Same `Ok` /
|
||||
/// `Err(NotApplicable)` / `Err(Transport)` contract as [`unlock_features`].
|
||||
/// Default: not provided.
|
||||
fn unlock_bus(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
let _ = (scsi, ctx);
|
||||
Err(UnlockError::NotApplicable)
|
||||
}
|
||||
}
|
||||
|
||||
/// Name of the unlocker that claims this drive by identity (for drive-info "is
|
||||
@@ -144,6 +165,7 @@ pub fn unlocker_name(drive_id: &DriveId) -> Option<&'static str> {
|
||||
pub fn all_unlockers() -> Vec<Box<dyn Unlocker>> {
|
||||
vec![
|
||||
Box::new(ld::LibreDrive::new()),
|
||||
Box::new(renesis::Renesis::new()),
|
||||
Box::new(aacs::AacsCert::new()),
|
||||
Box::new(css::CssUnlocker::new()),
|
||||
]
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
//! renesis — the Renesas-platform unlocker (Pioneer + HL-DT-ST Renesas drives).
|
||||
//!
|
||||
//! Optical drives split into two controller families: MediaTek (handled by
|
||||
//! [`crate::ld`], per-drive firmware) and Renesas. This module owns the Renesas
|
||||
//! side. Detection is a single vendor probe — a Renesas controller serves the
|
||||
//! READ_BUFFER 0x02/0xF1 identity block (ASCII `SAT` interface marker at
|
||||
//! `[16..19]`); a MediaTek drive rejects the command with ILLEGAL REQUEST. See
|
||||
//! [`is_renesas`].
|
||||
//!
|
||||
//! renesis provides the drive-FEATURES capability ([`Renesis::unlock_features`])
|
||||
//! and NOT bus removal (the cert handles the bus). The feature unlock is a no-op
|
||||
//! for now — it recognizes the drive and reports the match, deferring the bus to
|
||||
//! the cert stage.
|
||||
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
/// READ_BUFFER mode 0x02, buffer 0xF1 — the Renesas vendor identity buffer.
|
||||
const RB_F1_CDB: [u8; 10] = [0x3C, 0x02, 0xF1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00];
|
||||
const RB_F1_LEN: usize = 48;
|
||||
/// The ASCII interface marker a Renesas controller returns at `[16..19]`.
|
||||
const RENESAS_MARKER: &[u8] = b"SAT";
|
||||
const RENESAS_MARKER_OFFSET: usize = 16;
|
||||
|
||||
/// Renesas feature-unlock command (RS8xxx+ platforms). A single fixed vendor
|
||||
/// WRITE BUFFER (opcode `0x3B`, mode `0x02`, buffer id `0x41`): issuing it
|
||||
/// authenticates the host and enables the drive's extended feature set.
|
||||
///
|
||||
/// Platform-invariant — the same command unlocks every supported Renesas
|
||||
/// firmware; it does not vary per drive or per firmware revision. Sent by
|
||||
/// [`Renesis::unlock_features`]. (Verify on hardware before relying on it.)
|
||||
#[allow(dead_code)]
|
||||
const RENESAS_CHALLENGE_CDB: [u8; 10] =
|
||||
[0x3B, 0x02, 0x41, 0xA5, 0xAA, 0xAA, 0x00, 0x00, 0x00, 0x00];
|
||||
|
||||
/// True if `scsi` is a Renesas-platform drive (Pioneer or HL-DT-ST Renesas).
|
||||
///
|
||||
/// Issues the vendor READ_BUFFER 0x02/0xF1 probe: a Renesas controller serves a
|
||||
/// 48-byte identity block whose bytes `[16..19]` are the ASCII `SAT` interface
|
||||
/// tag. A MediaTek drive rejects it (ILLEGAL REQUEST → `Err`), and a transport
|
||||
/// fault also yields `Err`; both return `false`. This is the definitive
|
||||
/// Renesas-vs-MediaTek split.
|
||||
pub fn is_renesas(scsi: &mut dyn ScsiTransport) -> bool {
|
||||
let mut buf = [0u8; RB_F1_LEN];
|
||||
match scsi.execute(&RB_F1_CDB, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
Ok(r) => {
|
||||
let end = RENESAS_MARKER_OFFSET + RENESAS_MARKER.len();
|
||||
r.status == 0
|
||||
&& r.bytes_transferred >= end
|
||||
&& &buf[RENESAS_MARKER_OFFSET..end] == RENESAS_MARKER
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Renesas-platform unlocker. `pub(crate)` — reached only through
|
||||
/// [`crate::all_unlockers`].
|
||||
pub(crate) struct Renesis;
|
||||
|
||||
impl Renesis {
|
||||
pub(crate) fn new() -> Self {
|
||||
Renesis
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for Renesis {
|
||||
fn name(&self) -> &'static str {
|
||||
"Renesas"
|
||||
}
|
||||
|
||||
/// `if is_renesas() { recognized }`. Renesas is a distinct platform from
|
||||
/// LibreDrive (MediaTek): it provides DRIVE FEATURES only — it does NOT remove
|
||||
/// AACS bus encryption (`unlock_bus` is left at the default, so the cert stage
|
||||
/// handles the bus). The feature unlock itself is not implemented yet (no-op),
|
||||
/// but the match IS reported (`Ok`, `drive_unlocked: false`) so the drive is
|
||||
/// recognized as Renesas. A non-Renesas drive → `NotApplicable`.
|
||||
fn unlock_features(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
_ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
if !is_renesas(scsi) {
|
||||
return Err(UnlockError::NotApplicable);
|
||||
}
|
||||
// Recognized Renesas drive (Pioneer / HL-DT-ST Renesas). Feature unlock:
|
||||
// TODO. `drive_unlocked: false` → bus encryption is left for the cert.
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "renesas_recognized",
|
||||
"Renesas drive recognized; feature unlock TODO, bus deferred to cert"
|
||||
);
|
||||
Ok(Unlocked {
|
||||
vid: None,
|
||||
bus_key: None,
|
||||
drive_unlocked: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::DiscKind;
|
||||
use crate::scsi::{DataDirection, Result, ScsiError, ScsiResult, ScsiTransport};
|
||||
|
||||
/// Serves a fixed READ_BUFFER payload (Renesas-like) with a Good status.
|
||||
struct RenesasTransport {
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
impl ScsiTransport for RenesasTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let n = self.payload.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.payload[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Rejects the command (a MediaTek drive → ILLEGAL REQUEST → transport `Err`).
|
||||
struct RejectingTransport;
|
||||
impl ScsiTransport for RejectingTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
Err(ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn renesas_payload() -> Vec<u8> {
|
||||
// 48-byte RB 0xF1 block with "SAT" at [16..19] (the real S13JX shape).
|
||||
let mut p = vec![0x20u8; 48];
|
||||
p[16..19].copy_from_slice(b"SAT");
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_true_on_sat_marker() {
|
||||
let mut t = RenesasTransport {
|
||||
payload: renesas_payload(),
|
||||
};
|
||||
assert!(is_renesas(&mut t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_false_when_command_rejected() {
|
||||
let mut t = RejectingTransport;
|
||||
assert!(!is_renesas(&mut t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_false_on_missing_marker() {
|
||||
// Good status but no "SAT" at [16..19] (e.g. a stray buffer).
|
||||
let mut t = RenesasTransport {
|
||||
payload: vec![0u8; 48],
|
||||
};
|
||||
assert!(!is_renesas(&mut t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_false_on_short_response() {
|
||||
// Fewer than 19 bytes returned — can't carry the marker.
|
||||
let mut t = RenesasTransport {
|
||||
payload: vec![0x20u8; 8],
|
||||
};
|
||||
assert!(!is_renesas(&mut t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn features_report_match_without_bus_removal_on_renesas() {
|
||||
let mut t = RenesasTransport {
|
||||
payload: renesas_payload(),
|
||||
};
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]);
|
||||
// Recognized → Ok, but a feature-only unlock: bus NOT removed, no VID.
|
||||
let u = Renesis::new()
|
||||
.unlock_features(&mut t, &ctx)
|
||||
.expect("renesas → Ok");
|
||||
assert!(
|
||||
!u.drive_unlocked,
|
||||
"renesis does not remove the bus (cert does)"
|
||||
);
|
||||
assert_eq!(u.vid, None);
|
||||
assert_eq!(u.bus_key, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn features_not_applicable_on_non_renesas() {
|
||||
let mut t = RejectingTransport;
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]);
|
||||
let err = Renesis::new().unlock_features(&mut t, &ctx).unwrap_err();
|
||||
assert_eq!(err, UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_provide_bus_removal() {
|
||||
// Renesas leaves bus encryption to the cert: unlock_bus is the default.
|
||||
let mut t = RenesasTransport {
|
||||
payload: renesas_payload(),
|
||||
};
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Aacs, &[]);
|
||||
let err = Renesis::new().unlock_bus(&mut t, &ctx).unwrap_err();
|
||||
assert_eq!(err, UnlockError::NotApplicable);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user