Compare commits

...
10 Commits
Author SHA1 Message Date
Matthew Jackson f2747d5a72 v1.4.0: bump version (unified release) 2026-07-13 19:28:44 -07:00
Matthew Jackson 2877ccd022 Changelog: 1.4.0 (Blu-ray 3D / MVC) 2026-07-13 12:47:03 -07:00
Matthew Jackson af49583865 ld: restore module README with LibreDrive credit to Mike Chen + the MakeMKV team 2026-07-10 14:35:44 -07:00
Matthew Jackson ef432b43ca 1.3.2: version sync
Inherits libfreemkv 1.3.2.
2026-07-10 14:00:35 -07:00
Matthew Jackson fe23557555 1.3.1: relicense to MIT
Relicensed from AGPL-3.0 to MIT, effective 1.3.1 (<=1.3.0 remain AGPL).
Version sync to 1.3.1.
2026-07-10 12:31:19 -07:00
Matthew Jackson 336e44f7a9 docs: add 1.3.0 changelog 2026-07-10 08:19:22 -07:00
Matthew Jackson e9cd114489 renesis: detection-only stub; drop unverified vendor command 2026-07-09 16:36:47 -07:00
Matthew Jackson 2d7c6b3cec release: bump to 1.3.0 2026-07-08 14:48:50 -07:00
Matthew Jackson 6279fa7fec 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.
2026-07-07 16:35:57 -07:00
Matthew Jackson 67919326f1 v1.2.2: bump version (unified release) 2026-07-03 20:50:05 -07:00
13 changed files with 689 additions and 112 deletions
+30
View File
@@ -0,0 +1,30 @@
# Changelog
## [1.4.0] — 2026-07-13
Version sync with the workspace; inherits libfreemkv 1.4.0.
## [1.3.2] — 2026-07-10
Version sync with the workspace; inherits libfreemkv 1.3.2.
## [1.3.1] — 2026-07-10
### Licensing
- **Relicensed to the MIT License, from 1.3.1 onwards** (releases up to and
including 1.3.0 remain under AGPL-3.0).
Version sync with the workspace; inherits libfreemkv 1.3.1.
## [1.3.0] — 2026-07-08
### Changed
- **`Unlocker` is now two capability methods.** `unlock_features` (drive
riplock / speed / OEM VID at drive-prep) and `unlock_bus` (bus-encryption
removal for the mounted disc) replace the single `matches()` + `unlock()`
contract. Each defaults to `NotApplicable`, so an unlocker implements only the
capabilities it actually provides.
- **`DriveId` gains a `product_id` field** carrying the SCSI INQUIRY product
string, so consumers can match on it.
+2 -2
View File
@@ -1,9 +1,9 @@
[package]
name = "freemkv-unlock"
version = "1.2.1"
version = "1.4.0"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
license = "MIT"
description = "Unlock layer for the freemkv toolchain: the Unlocker contract + self-contained firmware/AACS/CSS unlocker modules. libfreemkv depends on this and dispatches via all_unlockers()."
repository = "https://github.com/freemkv/freemkv-unlock"
# NEVER crates.io: this crate carries drive firmware. Consumed only by git tag.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Matthew Jackson & Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -1
View File
@@ -24,4 +24,4 @@ for u in freemkv_unlock::all_unlockers() {
To remove an unlocker, delete its module directory and its one line in
`all_unlockers()` — nothing else changes.
License: AGPL-3.0-only.
License: MIT.
+27 -14
View File
@@ -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
View File
@@ -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,
+27
View File
@@ -0,0 +1,27 @@
# ld — the LibreDrive unlocker
The **LibreDrive** unlocker module for freemkv-unlock.
freemkv-unlock defines the generic `Unlocker` contract; this module is one
implementation of it. It recognizes a bundled catalog of supported drives
(`profiles.json`) and, for a matching drive, lifts the drive-level
bus-encryption barrier so the drive serves readable sectors. Content-key
decryption is a separate concern, handled by the consumer.
Clients never name this module directly —
[libfreemkv](https://github.com/freemkv/libfreemkv) dispatches through
`freemkv_unlock::all_unlockers()`, and this module answers `matches()` /
`unlock()` when the drive identity is one it supports.
## Scope: non-persistent unlock only
This module performs only the **non-persistent** unlock — the access state it
sets up lives in drive RAM and is gone on power cycle. The one-time, permanent
drive preparation is the drive owner's own manual step and is **never**
automated here.
## Credits
LibreDrive was created by **Mike Chen** and the **MakeMKV team**. This module
builds on their work — our thanks and full credit to them for the LibreDrive
capabilities.
+44 -27
View File
@@ -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", "")),
)
+1
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+33 -11
View File
@@ -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()),
]
+202
View File
@@ -0,0 +1,202 @@
//! renesis — Renesas-platform detection (Pioneer + HL-DT-ST Renesas drives).
//!
//! Optical drives split into two controller families: MediaTek (handled by
//! [`crate::ld`]) and Renesas. This module identifies the Renesas side via a
//! single vendor identity probe (see [`is_renesas`]) and reports the match so a
//! Renesas drive is named honestly. It does not modify drive state; AACS bus
//! decryption is handled by the host cert.
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;
/// 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"
}
/// Report whether the drive is a Renesas platform. On a match, returns `Ok`
/// with `drive_unlocked: false` — the drive is recognized but its state is
/// not modified here (AACS bus decryption is handled by the host cert). 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);
}
tracing::debug!(
target: "freemkv::disc",
phase = "renesas_recognized",
"Renesas drive recognized; bus handled by 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);
}
}