Compare commits

...
8 Commits
Author SHA1 Message Date
Matthew Jackson 56688764ea chore(msrv): floor to 1.88, pin CI on the floor, allow uninlined_format_args
Declared rust-version and the ci.yml/qa.yml toolchain pins both sit at 1.88 (the
edition-2024 floor once zip 8 and let-chains are accounted for), so CI builds on
exactly the MSRV: an accidental bump to a newer toolchain fails CI by design and
moving the floor takes a deliberate pin edit. The uninlined_format_args allow
reconciles 1.88's clippy with the allow-by-default it became in 1.97+.
2026-08-31 11:06:35 -07:00
Matthew Jackson e7107040bf chore: rustfmt the Renesas vendor CDB constants 2026-08-31 08:57:19 -07:00
Matthew Jackson b88feff027 freemkv-unlock: full_unlock orchestration + lean/raw VID + variant harness 2026-08-31 08:56:42 -07:00
Matthew Jackson 1016a294db renesas: Pioneer/Renesas vendor open sequence (read → knock → read) 2026-08-31 08:56:42 -07:00
dependabot[bot] 958b15f806 build(deps): bump codecov/codecov-action from 5 to 7
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5 to 7.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5...v7)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-30 21:52:46 -07:00
Matthew Jackson 508b533f78 Release 1.6.13: version align 2026-08-27 21:22:11 -07:00
Matthew Jackson 052ab804e2 v1.6.12: bump version (unified release) 2026-08-27 22:57:25 +00:00
Matthew Jackson 0964097e78 changelog: 1.6.12 (unreleased) 2026-08-27 12:18:39 -07:00
7 changed files with 1445 additions and 21 deletions
+6 -6
View File
@@ -51,7 +51,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.90.0
- uses: dtolnay/rust-toolchain@1.88.0
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
@@ -65,7 +65,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.90.0
- uses: dtolnay/rust-toolchain@1.88.0
- uses: Swatinem/rust-cache@v2
with:
shared-key: freemkv-unlock-ci
@@ -84,7 +84,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.90.0
- uses: dtolnay/rust-toolchain@1.88.0
with:
components: llvm-tools-preview
- uses: Swatinem/rust-cache@v2
@@ -92,7 +92,7 @@ jobs:
shared-key: freemkv-unlock-coverage
- uses: taiki-e/install-action@cargo-llvm-cov
- run: cargo llvm-cov --workspace --ignore-run-fail --lcov --output-path lcov.info
- uses: codecov/codecov-action@v5
- uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: lcov.info
@@ -119,7 +119,7 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.90.0
- uses: dtolnay/rust-toolchain@1.88.0
- uses: Swatinem/rust-cache@v2
with:
shared-key: freemkv-unlock-ci
@@ -142,7 +142,7 @@ jobs:
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.90.0
- uses: dtolnay/rust-toolchain@1.88.0
- uses: Swatinem/rust-cache@v2
with:
shared-key: freemkv-unlock-ci
+3 -3
View File
@@ -74,7 +74,7 @@ jobs:
- uses: actions/checkout@v7
with:
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@1.90.0
- uses: dtolnay/rust-toolchain@1.88.0
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-unlock
@@ -90,7 +90,7 @@ jobs:
- uses: actions/checkout@v7
with:
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@1.90.0
- uses: dtolnay/rust-toolchain@1.88.0
with:
components: clippy
- uses: Swatinem/rust-cache@v2
@@ -110,7 +110,7 @@ jobs:
- uses: actions/checkout@v7
with:
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@1.90.0
- uses: dtolnay/rust-toolchain@1.88.0
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-unlock
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## [1.6.13] — 2026-08-28
### Changed
- Version aligned to 1.6.13 for the unified release.
## [1.6.12] — 2026-08-27
### Changed
- Version aligned to 1.6.12 for the unified release; no functional changes.
## [1.6.11] — 2026-08-26
### Changed
+5 -2
View File
@@ -1,8 +1,8 @@
[package]
name = "freemkv-unlock"
version = "1.6.11"
version = "1.6.13"
edition = "2024"
rust-version = "1.90"
rust-version = "1.88"
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"
@@ -26,3 +26,6 @@ sha1 = "0.10"
sha2 = "0.11"
aes = "0.9"
rand = "0.10"
[lints.clippy]
uninlined_format_args = "allow"
+1268
View File
File diff suppressed because it is too large Load Diff
+16 -4
View File
@@ -25,6 +25,11 @@ pub mod ld;
// bus as `Err(UnlockError::Transport)` rather than "not a Renesas drive"); the unlocker impl
// (`Renesis`) is `pub(crate)` — reached only through [`all_unlockers`].
pub mod renesis;
// `freemkv` carries no public catalog (the firmware self-identifies rather
// than matching a bundled profile) and no emulation wire format yet, so it
// stays fully private — the unlocker impl (`FreemkvUnlocker`) is reached only
// through [`all_unlockers`].
mod freemkv;
use scsi::ScsiTransport;
@@ -178,11 +183,18 @@ pub fn unlocker_name(drive_id: &DriveId) -> Option<&'static str> {
ld::firmware_name(drive_id)
}
/// Every unlocker, in dispatch order (firmware → cert → css). This is the ONLY
/// place an unlocker is named. Remove one = delete its line here + its module
/// dir; the consumer never changes.
/// Every unlocker, in dispatch order (freemkv → firmware → cert → css). This
/// is the ONLY place an unlocker is named. Remove one = delete its line here +
/// its module dir; the consumer never changes.
///
/// `FreemkvUnlocker` runs FIRST: its detection is a single, unambiguous
/// self-identification probe (the drive's own firmware answers `"freemkv"`)
/// rather than a profile-catalog match, so it is the cheapest and most
/// certain check to run before falling back to the MT1959 profile lookup or
/// the Renesas/cert/css routes.
pub fn all_unlockers() -> Vec<Box<dyn Unlocker>> {
vec![
Box::new(freemkv::FreemkvUnlocker::new()),
Box::new(ld::Mt1959Unlocker::new()),
Box::new(renesis::Renesis::new()),
Box::new(aacs::AacsCert::new()),
@@ -202,7 +214,7 @@ mod tests {
#[test]
fn all_unlockers_dispatch_order_is_firmware_then_cert_then_css() {
let names: Vec<&'static str> = all_unlockers().iter().map(|u| u.name()).collect();
assert_eq!(names, vec!["MT1959", "Renesas", "AACS", "DVD"]);
assert_eq!(names, vec!["freemkv", "MT1959", "Renesas", "AACS", "DVD"]);
}
/// Every unlocker declines by default rather than claiming a capability it
+135 -6
View File
@@ -69,6 +69,72 @@ impl Renesis {
pub(crate) fn new() -> Self {
Renesis
}
/// The Pioneer/Renesas vendor "open" — MakeMKV's live sequence, replicated in
/// full: a primary read, and on its failure a payload-less "knock" + a second
/// read at a different window.
///
/// A. `READ_BUFFER 0x02/0xB0 @0x000004` (164 B) — the primary open.
/// B. (only if A refuses) `WRITE_BUFFER 0x02/0x41 @0xA5AAAA` (0-byte knock)
/// then `READ_BUFFER 0x02/0xB0 @0x500000` (164 B).
///
/// Success is by SCSI *status* alone (the 164-byte payload CONTENT is not
/// validated — verified empirically: a real drive's table and an all-zero
/// buffer are accepted identically as long as status is GOOD). We run the
/// whole A→knock→B path rather than bailing after A, because the knock is a
/// per-firmware fallback: some firmware revisions only answer the `0x500000`
/// window *after* the knock (MakeMKV issues the knock exactly when A returns
/// CHECK CONDITION). If NEITHER A nor B returns GOOD, the drive did not open →
/// defer to the next unlocker.
///
/// (There may be more than one knock offset across firmware families; only
/// the observed `0xA5AAAA` is issued here — add variants as they are proven.)
///
/// `Ok(true)` = opened (A or B GOOD). `Ok(false)` = neither opened (CHECK
/// CONDITION). `Err(Transport)` = dead bus.
fn vendor_open(scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
const RB_B0_04_CDB: [u8; 10] = [0x3C, 0x02, 0xB0, 0x00, 0x00, 0x04, 0x00, 0x00, 0xA4, 0x00];
const KNOCK_A5AAAA_CDB: [u8; 10] =
[0x3B, 0x02, 0x41, 0xA5, 0xAA, 0xAA, 0x00, 0x00, 0x00, 0x00];
const RB_B0_500000_CDB: [u8; 10] =
[0x3C, 0x02, 0xB0, 0x50, 0x00, 0x00, 0x00, 0x00, 0xA4, 0x00];
// A: primary open read.
if read_is_good(scsi, &RB_B0_04_CDB)? {
return Ok(true);
}
// B: MakeMKV's fallback — knock, then the second-window read. The knock is
// fire-and-forget (payload-less); its own status is not the signal.
match scsi.execute(&KNOCK_A5AAAA_CDB, DataDirection::None, &mut [], 5_000) {
Ok(_) => {}
Err(e)
if e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none() =>
{
return Err(UnlockError::Transport);
}
Err(_) => {} // a drive that refuses the knock still gets the B read tried
}
read_is_good(scsi, &RB_B0_500000_CDB)
}
}
/// Issue a 164-byte vendor READ_BUFFER; `Ok(true)` on GOOD status, `Ok(false)`
/// on CHECK CONDITION (drive refused), `Err(Transport)` on a senseless
/// transport failure (dead bus).
fn read_is_good(
scsi: &mut dyn ScsiTransport,
cdb: &[u8; 10],
) -> std::result::Result<bool, UnlockError> {
let mut buf = [0u8; 164];
match scsi.execute(cdb, DataDirection::FromDevice, &mut buf, 5_000) {
Ok(r) => Ok(r.status == 0),
Err(e) => {
if e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none() {
return Err(UnlockError::Transport);
}
Ok(false)
}
}
}
impl Unlocker for Renesis {
@@ -76,10 +142,17 @@ impl Unlocker for Renesis {
"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`.
/// Recognize and open a Renesas/Pioneer drive the way MakeMKV does:
/// 1. `READ_BUFFER 0xF1` identity gate (the `SAT` marker) — else this is
/// not our drive → `NotApplicable`.
/// 2. the single vendor open read `READ_BUFFER 0xB0 @0x04` — GOOD status
/// confirms the drive is rip-ready. A CHECK CONDITION here is exactly
/// where MakeMKV gives up on the drive, so we defer to the next unlocker
/// (`NotApplicable`) rather than claim a drive we could not open.
///
/// On success returns `drive_unlocked: false`: the vendor open makes the
/// drive readable, but AACS bus decryption is still the host cert's job (the
/// cert unlocker runs next), so we do not assert the bus is handled here.
fn unlock_features(
&self,
scsi: &mut dyn ScsiTransport,
@@ -88,10 +161,18 @@ impl Unlocker for Renesis {
if !is_renesas(scsi)? {
return Err(UnlockError::NotApplicable);
}
if !Self::vendor_open(scsi)? {
tracing::debug!(
target: "freemkv::disc",
phase = "renesas_open_rejected",
"Renesas drive recognized but RB 0xB0@0x04 refused; deferring to next unlocker"
);
return Err(UnlockError::NotApplicable);
}
tracing::debug!(
target: "freemkv::disc",
phase = "renesas_recognized",
"Renesas drive recognized; bus handled by cert"
phase = "renesas_opened",
"Renesas drive opened (RB 0xB0@0x04 GOOD); bus handled by cert"
);
Ok(Unlocked {
vid: None,
@@ -251,6 +332,54 @@ mod tests {
);
}
/// A genuine Renesas drive (passes the `0xF1` SAT gate) that REFUSES the
/// vendor open read `RB 0xB0@0x04` with CHECK CONDITION — the exact point
/// where MakeMKV abandons the drive. `unlock_features` must defer to the
/// next unlocker (`NotApplicable`), not claim a drive it could not open.
/// MUTATION: dropping the `vendor_open` gate makes this return `Ok` and
/// wrongly claim the drive.
#[test]
fn open_rejection_defers_to_next_unlocker() {
struct GateOkOpenRefused;
impl ScsiTransport for GateOkOpenRefused {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
if cdb.get(2) == Some(&0xF1) {
// Serve the SAT identity so is_renesas() matches.
let p = renesas_payload();
let n = p.len().min(data.len());
data[..n].copy_from_slice(&p[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
})
} else {
// RB 0xB0@0x04 (the open read) → CHECK CONDITION.
let mut sense = [0u8; 32];
sense[2] = 0x05; // ILLEGAL REQUEST
sense[12] = 0x20; // invalid command operation code
Err(ScsiError {
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: Some(sense),
})
}
}
}
let mut t = GateOkOpenRefused;
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]);
assert_eq!(
Renesis::new().unlock_features(&mut t, &ctx).unwrap_err(),
UnlockError::NotApplicable
);
}
#[test]
fn does_not_provide_bus_removal() {
// Renesas leaves bus encryption to the cert: unlock_bus is the default.