Compare commits

...
89 Commits
Author SHA1 Message Date
Matthew Jackson 199a3ffee6 docs(changelog): note beta Renesas + freemkv-firmware unlock support in 1.7.0
Record the preliminary Renesas / freemkv-firmware unlock routes under 1.7.0
Added, and drop the LibreDrive-microcode NOTICE line from the changelog.
2026-09-05 18:14:36 -07:00
Matthew Jackson aa70a3707d test: pin exact CDB bytes for the vendor-unlock paths 2026-09-05 12:35:10 -07:00
Matthew Jackson cb1235cca4 surface the dropped UHD read-data-key (bus-key) failure 2026-09-05 12:35:10 -07:00
Matthew Jackson 150e91b0ab redact HostCert Debug so AACS host private keys never reach a log 2026-09-05 12:35:10 -07:00
Matthew Jackson 3989100820 freemkv: Identity knock must use the fixed 64-byte data-in allocation
identify() requested a 32-byte data-in allocation while the firmware commits
KNOCK_RESP_LEN (64) bytes on every subfn. The short allocation desyncs the first
command's data-in phase and wedges the drive, so the later bare 0xAD VID read
aborts (CHECK CONDITION) — the "Raw Read GOOD but no VID" failure that left the
keyserver with vid=none. Request KNOCK_RESP_LEN like every other knock; update the
CDB-shape tests. Also sync Cargo.lock to the 1.7.0 manifest.
2026-09-03 07:41:43 -07:00
Matthew Jackson c6d6a5a247 unlock: derive Default for Ld/Renesas unlockers (clippy new_without_default)
Newer stable clippy (CI) flags new() without a Default on the unit-struct
unlockers; the local pinned clippy did not. Unit structs, so derive it.
2026-09-02 18:01:48 -07:00
Matthew Jackson 09dd8ef14c unlock: unify the Unlocker contract across all routes
Every unlocker now implements one method — unlock(&self, scsi, ctx) ->
Result<Option<Unlocked>> — with identical workflow and error handling,
differing only in CDBs/checks. Drops the unlock_features/unlock_bus split,
per-unlocker result state, and the drive_unlocked flag ("unlocked" is just
Some). Only a dead bus is Err; everything else falls through as Ok(None).

- VID read is best-effort everywhere via the shared crate::vid reader: a
  VID miss no longer discards an unlock that already removed the bus — the
  bug that dropped a freemkv-unlocked drive to the cert path and sent the
  keyserver vid=none.
- AACS: host certs injected at construction (AacsUnlocker::new(certs)), not
  passed per-call; a successful cert AKE is a real unlock.
- Renesas/Pioneer: the vendor open enables raw reads (LibreDrive parity),
  then reads the VID via the same 0xAD reader — no host cert, matching the
  firmware routes.
- Rename the MT1959 unlocker to LD (matches the ld/ module); the MediaTek
  MT1959 platform driver keeps its name.
- Consumer assembles its own dispatch list from the exported types; no
  central factory. Adds a uniform dead-bus contract test over all unlockers.
2026-09-02 17:53:17 -07:00
Matthew Jackson 417d4598ca ci: identity-guard — block unrelated-corp / non-canonical committer emails 2026-09-02 14:46:22 -07:00
Matthew Jackson 74936ad892 release: 1.7.0
Fixed
- ld/profile: find_by_drive_id no longer mis-binds a drive whose reported
  product_id is real but uncatalogued to a sibling model's profile (and its wrong
  LibreDrive microcode); the product-id-blind loose match is gated on an empty id.

Docs / CI
- Third-party NOTICE for the LibreDrive microcode bundled in profiles.json;
  cargo-deny gate (#15) and audience-based comment-guard (#17).
2026-09-02 12:27:57 -07:00
Matthew Jackson 9b9f142313 v1.6.14: bump version (unified release) 2026-08-31 23:04:22 +00:00
Matthew Jackson 7eee823db7 docs(changelog): 1.6.14 entry 2026-08-31 15:32:40 -07:00
Matthew Jackson d8f1d2f3a7 refactor(renesas): move renesis.rs into src/renesas/ and normalize spelling
Matches the ld/ platform-folder convention (MediaTek lives in src/ld/); the
module/struct spelling is normalized renesis->renesas to match the drives it
detects. Pure move + identifier rename, no behavior change.
2026-08-31 12:54:30 -07:00
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
Matthew Jackson 3b8bcc2164 v1.6.11: bump version (unified release) 2026-08-25 23:16:54 -07:00
Matthew Jackson 7128fdf71f docs: neutralize external references and named techniques (opsec) 2026-08-25 22:18:31 -07:00
Matthew Jackson 9ad1eb9811 test: round-3 coverage on platform shim, ld/aacs error paths, css/mt1959 branches
Targets the files round-1/2 left out of scope: ld/platform/mod.rs (the
PlatformDriver default trait method, previously 0%), ld/error.rs Display
impl (now 100%), aacs/mod.rs Default + full unlock_bus success path
(exposes handshake::tests::DriveEmu/dummy_cert as pub(crate) for reuse),
css/mod.rs (DvdUnlocker::default, the disc-key-refusal non-fatal path,
drive-challenge/Key2-send failure branches), aacs/handshake.rs
(run_cert_handshake's per-cert backoff sleep + retry), and mt1959
mod.rs/variant_a.rs (MODE_B dispatch, a non-transport pass-1 probe
sense, WRITE_BUFFER's 24-bit length guard, variant_a's full upload+
verify+double-unlock success path).

Total coverage 95.65% -> 96.50% (regions), 95.33% -> 96.61% (lines).
2026-08-25 22:13:19 -07:00
Matthew Jackson 7ea1a4eea7 docs: 1.6.11 changelog 2026-08-25 22:08:48 -07:00
Matthew Jackson b3d1b99460 test: round-2 coverage on css bus-auth, mt1959 retry/probe, aacs handshake
Extends the fake ScsiTransport in css::mod to run the full CSS bus-auth
happy path (variant brute-force, drive-challenge/Key2 exchange, best-effort
disc-key REPORT KEY, DvdUnlocker::unlock_bus success) instead of only its
failure branches.

Covers mt1959's run_init retry loop (signature-mismatch abort, generic
do_unlock failure with firmware-reload fallback, firmware-reload transport
fault), run_probe's disc-type detection (capacity sense fallback, UHD vs BD
init address, pass-2 early-end on a rejected probe), and the PlatformDriver
guard clauses.

Covers aacs/handshake's EC math edge cases (mod_inv/ec_add/ec_double/ec_mul
defensive branches, off-curve pubkey rejection), the Debug redaction impls,
the AACS 1.0 type-0x01 cert-verify gate, the P-256 thin wrapper + short-cert
guard + step-6 bad-signature rejection, and drives run_cert_handshake to its
full Ok(CertHandshake) success path via an extended drive emulator.
2026-08-25 21:48:47 -07:00
Matthew Jackson 781b498e4e test: cover aacs/css error paths + aes block
Adds unit tests for aacs::error::Error and css::error::Error (code(),
scsi_sense/is_transport_failure branches, From<ScsiError> conversions),
a FIPS-197 known-answer test for aacs::aes_ecb_decrypt, and scripted
fake-transport tests covering the authenticate_with_agid / read_disc_key
error branches in css::mod. Raises line coverage 91.16% -> 92.59%.
2026-08-25 20:52:50 -07:00
Matthew Jackson bdd2129932 docs: standardize README badges (License, CI, codecov) 2026-08-25 20:32:32 -07:00
Matthew Jackson bb313c7ece ci(coverage): authenticate Codecov upload with org CODECOV_TOKEN 2026-08-25 19:33:00 -07:00
Matthew Jackson 985b400d70 ci(coverage): add --ignore-run-fail so instrumentation flake never blocks upload 2026-08-25 19:05:06 -07:00
Matthew Jackson dd73c5d3a1 ci: add non-required Codecov coverage job
Add an additive, non-required coverage job to CI that runs cargo
llvm-cov and uploads lcov to Codecov (tokenless, fail_ci_if_error
false). Mirrors the test job's environment and pinned 1.90.0 toolchain;
--locked is omitted to match this crate's gitignored-lockfile policy.
Adds codecov.yml (informational, comment off) and a README badge.
2026-08-25 18:54:57 -07:00
Matthew Jackson 9cfcb5830c lower MSRV to 1.90, trim two stray comments in aacs handshake
Bisected the true floor: 1.85 fails to compile (needs is_multiple_of,
stabilized 1.87, and let-chains, stabilized 1.88), 1.90 builds/clippy/tests
clean. Repin Cargo.toml rust-version and every CI dtolnay/rust-toolchain to
1.90.0, and note the change in CHANGELOG.md. Also removed a redundant
"// Hash the data" comment and a stale duplicate doc line on
generate_host_key_pair_p256 found during a pass over the crate's comments.
2026-08-25 10:14:16 -07:00
dependabot[bot] 2228703250 build(deps): bump actions/checkout from 5 to 7 (#10)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 10:04:30 -07:00
Matthew Jackson c9e94b6bd6 CHANGELOG: 1.6.10 (unified release; driven by libfreemkv TrueHD discontinuity fix) 2026-08-23 16:30:29 -07:00
Matthew Jackson 7b03c0dc65 v1.6.9: bump version (unified release) 2026-08-22 08:34:13 -07:00
Matthew Jackson a03cb454f6 changelog: 1.6.9 unified-release entry 2026-08-22 08:24:57 -07:00
Matthew Jackson 367faf6726 v1.6.8: bump version (unified release) 2026-08-21 12:54:35 -07:00
Matthew Jackson 57637a22a3 docs: 1.6.8 changelog (unified release) 2026-08-21 12:47:34 -07:00
Matthew Jackson 991ce55042 ci: require a CHANGELOG entry for the crate version on main
Adds a 'changelog' job that fails when Cargo.toml's version has no matching
'## [X.Y.Z]' heading in CHANGELOG.md, hard on main and PRs into main, warning
on dev/qa — so the unified release can't ship a crate with a blank changelog.
2026-08-21 11:23:43 -07:00
Matthew Jackson 78d26cf8e0 v1.6.7: bump version (unified release) 2026-08-21 09:31:17 -07:00
Matthew Jackson c12e8ef748 docs: 1.6.7 changelog (unified release, autorip-driven) 2026-08-21 09:23:55 -07:00
Matthew Jackson 1f644afe80 v1.6.6: bump version (unified release) 2026-08-20 20:03:31 -07:00
Matthew Jackson 423366308b docs: 1.6.6 changelog 2026-08-20 19:56:40 -07:00
Matthew Jackson 759390af96 v1.6.5: bump version (unified release) 2026-08-20 06:34:47 +00:00
Matthew Jackson 7bbcc46f12 docs: 1.6.5 changelog 2026-08-19 21:20:54 -07:00
Matthew Jackson eaa158c379 Wire the AACS 2.0 (P-256) host-cert handshake: live and cert-agnostic
The 2.0 host-cert handshake is now live and cert-agnostic (works when a
cert is supplied), with the same chain-of-trust hardening as 1.0; the
SECURITY note is resolved by fixing, not by leaving dead.

- Remove the module-level #[allow(dead_code)]; the P-256/v2 AKE is now
  reachable from run_cert_handshake.
- run_cert_handshake selects the P-256 AKE per host cert: the 1.0 cert is
  tried first (an AACS 2.0 drive accepts it for the VID); if the drive
  refuses it AND the supplied HostCert carries AACS 2.0 (P-256)
  credentials, fall back to the native P-256 AKE. No possession/feature
  gate — it consumes whatever the HostCert carries, exactly as the 1.0
  path consumes hc.certificate. A 1.0-attempt transport fault
  short-circuits the fallback so a dead bus is not hammered.
- Chain-of-trust hardening on the now-live v2 path, mirroring 1.0:
  drive-cert TYPE gate (only 0x11 accepted, else rejected), cert verify
  is FATAL (verify_cert_p256 under the real 2.0 LA anchor), ECDSA-P256
  point validation on every attacker-supplied point (point_on_curve in
  ecdsa_verify_p256 + compute_bus_key_p256), and AGID release on every
  exit.
- The 2.0 cert byte layout/signed-range is documented as PROVISIONAL: no
  genuine 2.0 host/drive AKE cert exists to pin it, and the draft spec
  implies extra Host-ID/paired-DKS fields. A wrong signed range fails
  CLOSED (fatal verify rejects), never trusts. An #[ignore] slot awaits a
  real cert.
- Tests: synthetic test-LA-signed 2.0 certs prove accept + bus-key
  derivation, bad-signature fatal reject, unknown-type reject, and
  off-curve/identity rejection (each red-before-green per guard); a
  genuine AACS 2.0 content cert verifies under the published CC key,
  proving the P-256/SHA-256 primitive against real material.
2026-08-18 23:21:15 -07:00
Matthew Jackson 74fe85cdd2 harden AACS/firmware unlock against forged trust + dead-bus-as-success, and land the real on-curve AACS LA anchors
Two coupled classes. The chain-of-trust hardening makes a genuine AACS
LA public key load-bearing (the new ECDSA public-key validation rejects
every off-curve key), so the anchor replacement ships in the same commit
— with the old off-curve constants and the new guard, verify_cert would
reject every certificate.

=== Chain of trust (forged-trust class) ===
- aacs_authenticate: reject an unknown drive-cert type instead of falling
  through. The if/else-if had no else, so a type that was neither 0x01
  (AACS 1.0) nor 0x11 (AACS 2.0) skipped LA verification yet still ran the
  step-6 key-signature check against the drive's OWN key lifted from a cert
  that was never LA-verified — a rogue bridge could inject an attacker-chosen
  bus key. Reject at the cert-type gate, before any key is trusted.
- ecdsa_verify / ecdsa_verify_p256: validate the public key Q (on-curve,
  in-field, not the identity) before use. A Q of (0,0) collapsed verification
  to a form forgeable with no private key.
- verify_cert_p256: guard on the real 132-byte P-256 cert length (was 138,
  which the 132-byte caller never satisfied, so it returned false for 100%
  of AACS 2.0 drives) and read the signature at the real offsets.

=== Failure-that-looks-like-success class (dead bus reported as unlocked) ===
- run_cert_handshake read_data_keys arm: classify a transport fault like its
  read_volume_id sibling. A bus that died during the data-key read returned
  Ok(volume_id: Some, read_data_key: None) — a dead bus rendered as success.
- LibreDrive::firmware_unlock: a transport fault in probe_disc now aborts with
  Transport. For the 140/206 profiles without a read_vid_cdb the bus is never
  touched again, so this swallowed fault was the only dead-bus signal and
  warn-and-continue reported Ok(drive_unlocked: true) on a dead bus.
- mt1959 run_probe pass-1 and pass-2 speed-probe loops: preserve the transport
  classification instead of flattening to UnlockFailed (pass-1) or silently
  returning Ok(()) (pass-2 bare break).

=== AGID + key-material redaction ===
- aacs2_authenticate_p256: release the AGID on every fallible exit (the v1
  twin was fixed; the v2 path still leaked).
- Unlocked, DriveProfile: hand-written redacting Debug. Unlocked is the public
  carrier for bus_key/vid; DriveProfile (reachable via the public catalog)
  carried the drive firmware image and per-drive vendor CDBs — a derived Debug
  printed all of it. This crate is unpublished precisely because of that
  firmware.

=== Validated AACS Licensing Administrator anchors ===
Replace the compiled-in AACS 1.0 and 2.0 LA public keys, which were
OFF-CURVE (so the new Q-validation rejected every cert signed by them),
with the real published keys. Both confirmed on-curve; the 1.0 key also
verifies a genuine LA-signed host certificate. The native P-256 handshake
stays #[allow(dead_code)] and is NOT wired — a security note documents the
cert-type gate + fatal verification that must be added first, once the 2.0
wire layout is confirmed against a real drive cert.

Tests: every behavioural fix is pinned with a red-before-green test naming
its mutation, including transport-abort coverage for validate, both firmware
upload variants, both probe passes, and the probe-disc dead-bus path; the
anchor landing is pinned by an on-curve assertion and a genuine-cert verify
(both go red if an anchor reverts to the old off-curve value).
2026-08-18 22:56:30 -07:00
Matthew Jackson f675823488 fix(unlock): stop misclassifying transport faults and reading an unlock flag nobody consulted
Two failure CLASSES ran through this crate, and both had the same
shape: an outcome that means "stop" was recorded and then not acted on.

1. TRANSPORT-FAULT MISCLASSIFICATION.

`ScsiTransport::execute` is the load-bearing contract here: `Ok` even
on a SCSI sense (inspect `status`), `Err` ONLY on a transport-layer
fault. That split is the abort/fall-through boundary — a dead bus must
abort the consumer, everything else may fall through to the next
unlocker. Almost every call site got one half of it wrong.

Discarding the `Err`: `Err(_) => false` in the Renesas identity probe
(the FIRST command the unlocker issues, so a dead bus read as "not a
Renesas drive"); `_ => false` with no `Err` arm at all in the DVD
profile probe; `.map_err(|_| CssAuthFailed)` at every CSS bus-auth step,
which made the `Transport` arm of css's own error conversion
unreachable from any real call site; `Err(_)` in the MT1959 init retry
loop, which re-uploaded firmware into a dead bus three times and then
returned a generic `UnlockFailed` that classifies as "not applicable";
`continue` in the AACS cert loop, which ran three certs with a 1 s
backoff against a dead bus and reported HandshakeRejected — a
credentials problem — for what is a replug; and an unconditional
`VidUnavailable` after a successful authentication.

Ignoring the `Ok`: the AACS handshake's two SCSI helpers inspected
neither `status` nor `bytes_transferred`, so a CHECK CONDITION — which
per the contract arrives as `Ok` — handed each step back the caller's
own ZERO-FILLED buffer to parse as drive data. That is latent only
because the single transport implementor violates the contract in the
opposite direction; a conforming transport would have turned the whole
handshake into a zero-buffer success. Same hole in the MT1959 unlock
and validate reads, the OEM Volume ID read, and the CSS steps.

And once, the inverse: a merely SHORT probe response was fabricated
into a transport-failure status, hard-aborting a rip that would have
succeeded.

2. A COMPUTED-BUT-UNREAD UNLOCK FLAG.

`Mt1959::do_unlock` computes `unlocked` from three conditions, with a
comment explaining that requiring all of them "keeps any partial /
corrupted response from steering us off the cert-auth fallback". Nothing
outside the tests ever read it. `firmware_unlock` set
`drive_unlocked: true` whenever `init()` returned `Ok` — and `init()`
returning `Ok` only means the handshake completed. A half-unlocked drive
was reported to the consumer as serving clear content, which suppressed
the cert-auth fallback. Worse, each of `do_unlock`'s three checks was
individually guarded by its own length test, so a drive delivering ZERO
bytes skipped all three and returned `Ok` on a 64-byte buffer it had
never written: a fully unlocked drive, out of nothing.

Also fixed: `read_data_keys` returned AES-decrypted garbage as
`Ok((key, key))` from an all-zero response with nothing to authenticate
it; the probe's two passes walked the same 88 addresses twice per disc;
a bad bundled profiles.json cached `None` forever unlogged, making every
drive look unsupported; `DriveProfile` re-exported the MediaTek firmware
blob and the vendor CDB templates to any downstream crate; firmware
verify reads were discarded with `let _ =` despite being called verify
steps; AGIDs leaked on error paths; the VID MAC compare short-circuited;
`Copyright Information` labelled the Disc Key block; and two intra-doc
links pointed at items in another crate.

THE TEST GAP THAT HID ALL OF IT.

No mock in this crate could return `Err`, a non-zero status, or a short
transfer, and nothing drove the AACS or CSS handshakes through a
transport at all — `aacs/handshake.rs`'s 41 tests were pure math. One
test actively PINNED the bug (it fed a transport `Err` and asserted
NotApplicable). A crate-wide mock transport that expresses all three
contract outcomes now lives in `scsi.rs`; it is the red-before-green
vehicle for every fix above. `lib.rs`, `scsi.rs` and `ld/error.rs` had
zero tests and now cover the dispatch order, the sense layout and the
abort/fall-through boundary itself; `ld/cdb.rs`'s tests compiled only
under a non-default feature and now run in CI.

Behaviour changes worth knowing: a half-unlocked drive now falls through
to cert-auth instead of claiming the bus is clear; a dead bus now aborts
from the Renesas probe, the DVD probe, CSS bus-auth, MT1959 init and the
AACS cert loop instead of falling through; and drive prep issues 88
fewer SCSI commands per disc.
2026-08-18 14:30:40 -07:00
Matthew Jackson 50205a0286 ci: stop Dependabot proposing Rust versions that do not exist
`dtolnay/rust-toolchain` is not a dependency in the sense Dependabot
means: it is versioned by the Rust release it installs, and the tag we
pin is the toolchain CI is pinned to on purpose — precommit.sh runs the
same one locally, so a lint that passes on a developer's newer default
cannot pass CI by accident.

Read as semver, those tags produced a 1.97.0 -> 1.100.0 proposal, a Rust
version that does not exist. Every such PR 404s on toolchain download
across all eight repos and regenerates weekly: eight permanently-red PRs
that promote.yml then has to special-case when it decides whether dev is
green.

freemkv already carries this ignore; this is the same block in the other
seven, so the fleet stays uniform. Bumping the toolchain stays a
deliberate, all-eight-repos change made by hand together with
precommit.sh.
2026-08-18 12:14:58 -07:00
Matthew Jackson 274e9c66f5 v1.6.4: bump version (unified release) 2026-08-15 09:13:15 -07:00
Matthew Jackson d05b9bce18 Version sync to 1.6.4 2026-08-15 08:14:31 -07:00
Matthew Jackson f9df7e0fb3 Carry the same licence and community files as the other repos
These are eight public repos that ship one product on one version, and
they had drifted: freemkv-engine carried no LICENSE at all (GitHub
reported its licence as none), two crates had no code of conduct, four
had no contributing guide, and none had a security policy — so there was
no private route to report a vulnerability in a disc-decryption tool.

SECURITY.md names GitHub Security Advisories on each repo rather than an
address, so there is nothing to keep in sync and no inbox to go stale.
2026-08-09 22:08:22 -07:00
Matthew Jackson 9a4f29b04c Keep the cross-platform jobs for the release candidate
dev is where work lands and is meant to be pushed to often, so what runs
there should be the cheap answer to "did I break it": lint, tests and the
Linux build. The macOS and Windows jobs now run on qa and main instead of
on every push to dev.

Nothing is deleted and no platform goes unchecked before a release. qa.yml
already covers macOS and Windows independently, and the jobs that live only
here -- the Intel macOS build, the Windows release build -- still run, on
the branches where a cross-platform break is worth blocking on.

They are SKIPPED on dev via `if`, not left unscheduled. A queued job would
be worse than a slow one: release.sh's CI gate refuses while any run for
the commit is still in progress, so a job that never gets a runner blocks
releases silently, with no error anywhere. That is the failure the
real-media note in qa.yml describes, and it is why this is an `if` on the
job rather than a narrower set of trigger branches.
2026-08-09 20:24:05 -07:00
Matthew Jackson 6a8253f889 Release 1.6.3
Version bump and changelog for the 1.6.3 sync. All eight crates ship the
same version, so the crates with no functional change this cycle say so
rather than carrying an empty section.
2026-08-09 20:01:33 -07:00
Matthew Jackson 2684f5f28e base64: match the workspace on 0.23
Every other crate declares 0.23; this one alone said 0.23.1. Cargo
unifies them, so this is uniformity rather than a fix -- but a lone
pinned patch version is the kind of thing that quietly becomes a real
split at the next major.
2026-08-09 18:43:09 -07:00
Matthew Jackson f0d936f4b6 Move to rand 0.10 and aes 0.9
Both are API renames rather than behaviour changes, but this is the AACS
and CSS crypto path, so it is verified by output rather than by the fact
that it compiles.

rand: `RngCore` no longer sits at the crate root -- `fill_bytes` moved to
the re-exported `Rng` trait -- and `thread_rng()` is now `rng()`.

aes: the cipher traits gained their `BlockCipher` prefix and
`generic_array::GenericArray` became `Array`. The obvious translation
uses `Array::from_slice`, which is deprecated in the new version and
would fail clippy's `-D warnings`; these use the `From<[T; N]>`
conversion the crate points at instead.

The proof this is byte-exact is `test_aes_cmac_nist_kat_full_block`,
which checks the exact MAC from NIST SP 800-38B Appendix D.1 -- a wrong
byte order or a mis-sized array cannot produce that value. All 95 tests
pass in both debug and release.
2026-08-09 17:50:01 -07:00
dependabot[bot] 5fd090fc8e build(deps): update sha2 requirement from 0.10 to 0.11 (#5)
Updates the requirements on [sha2](https://github.com/RustCrypto/hashes) to permit the latest version.
- [Commits](https://github.com/RustCrypto/hashes/compare/groestl-v0.10.0...sha2-v0.11.0)

---
updated-dependencies:
- dependency-name: sha2
  dependency-version: 0.11.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-09 16:58:59 -07:00
dependabot[bot] 44bd80e8df build(deps): update num-bigint requirement from 0.4 to 0.5 (#3)
Updates the requirements on [num-bigint](https://github.com/rust-num/num-bigint) to permit the latest version.
- [Changelog](https://github.com/rust-num/num-bigint/blob/main/RELEASES.md)
- [Commits](https://github.com/rust-num/num-bigint/compare/num-bigint-0.4.0...num-bigint-0.5.1)

---
updated-dependencies:
- dependency-name: num-bigint
  dependency-version: 0.5.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-09 16:58:55 -07:00
dependabot[bot] fb3d045509 build(deps): update base64 requirement from 0.22.1 to 0.23.1 (#4)
Updates the requirements on [base64](https://github.com/marshallpierce/rust-base64) to permit the latest version.
- [Changelog](https://github.com/marshallpierce/rust-base64/blob/master/RELEASE-NOTES.md)
- [Commits](https://github.com/marshallpierce/rust-base64/compare/v0.22.1...v0.23.1)

---
updated-dependencies:
- dependency-name: base64
  dependency-version: 0.23.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-09 16:58:52 -07:00
dependabot[bot] f9182df204 build(deps): bump actions/checkout from 5 to 7 (#7)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-09 16:58:24 -07:00
Matthew Jackson 53d9e15d3d Have Dependabot watch the dependencies, and land its PRs on dev
Version updates were never configured here -- only security alerts, which
report but never open a pull request. So the absence of Dependabot PRs was
not "nothing to update", it was "nothing was checking".

Updates target `dev`, never `main`. main is a release pointer that
release.sh moves to each tag, so a bot commit on it would put work there
that no tag contains -- the exact state that aborted the 1.6.2 cascade at
its last step, and pointing a scheduled bot at main would recreate it
weekly.

Minor and patch bumps are grouped into one PR rather than one per crate:
eight repos times a handful of dependencies is a volume nobody reads, and
an unread PR queue is indistinguishable from no updates at all.

The freemkv crates are excluded. They depend on each other by git tag,
re-pinned by release.sh inside the release commit, and Dependabot cannot
see that cascade -- a PR bumping one could pin a version whose tag does
not exist yet.
2026-08-09 16:48:29 -07:00
Matthew Jackson 65e02f4b79 v1.6.2: bump version (unified release) 2026-08-08 22:18:13 -07:00
Matthew Jackson 10126cdbf6 changelog: 1.6.2 2026-08-08 08:53:11 -07:00
Matthew Jackson 887d154014 v1.6.1: bump version (unified release) 2026-08-07 19:08:30 -07:00
Matthew Jackson e9ed2ffa85 changelog: mark 1.6.1 UNRELEASED so release.sh can date it
release.sh dates the heading when it cuts the tag, and refuses a bare
'## [1.6.1]' because it cannot tell an undated heading from one it has
already stamped. Shipping a tag whose public changelog says UNRELEASED is
permanent and cannot be corrected inside that tag, so the check is right
to stop rather than guess.
2026-08-07 17:57:22 -07:00
Matthew Jackson 3a5f92da91 Scope the qa matrix to the platforms each crate actually supports
The first cut ran release-profile tests on all three platforms everywhere,
which invented coverage no crate had ever claimed. bdemu is Linux-only — its
ci.yml has no macOS or Windows job at all, and its tests call
ExitStatus::signal(), which does not exist on Windows, so they do not compile
there let alone run. The rest follow the policy ci.yml already set: tests
execute on Linux and macOS, Windows compiles them so the cfg(windows) halves
still get codegen before release time.

That policy is now explicit in qa.yml as a windows-build job rather than
implied by which jobs happen to exist.

Recorded, not papered over: running the suites on Windows DOES find real
defects — autorip has five Windows path-handling failures in mover.rs and
freemkv-engine has three tests hardcoding /dev/null. freemkv ships a Windows
GUI, so they matter. They are pre-existing rather than 1.6.1 regressions, and
fixing them is its own piece of work.

Also build the binary before cli-parity.sh. cli-integration.sh builds its own;
cli-parity.sh only checks for one and exits 2 with "build it first", so both
legs died before running a single case.
2026-08-05 21:22:30 -07:00
Matthew Jackson 58dc46037e Stamp every qa push as a release candidate
Every push to qa now tags v<version>-rc<N>, N incrementing, before the gates
run. That answers "which build is on qa, and is it the one I tested?" without
anyone having to remember it.

The tag lands whether the run goes green or red, deliberately. A red candidate
needs a name more than a green one does: "rc3 failed release-tests on windows"
is a sentence you can act on, "qa is red" is not. Red on qa is the gate doing
its job — the branch saying this is not production worth yet.

release.yml now excludes v*-rc*. Its trigger was v*, which matches the
candidate tags, so without this every push to qa would have built and PUBLISHED
a GitHub release — including for the candidates that failed.
2026-08-05 21:03:01 -07:00
Matthew Jackson 0ed613d7a9 Add the qa gate: dev for speed, qa for proof
dev -> qa -> main, across every public repo.

dev is where work lands and is meant to be pushed to often, so ci.yml stays
the fast answer: fmt, clippy, unit tests, leak-guard. qa is the release
candidate, and qa.yml is the claim that a commit is production worth —
release-profile tests on all three platforms, and the Linux cross-target
clippy that the local precommit gate has always run but CI never did.

Release profile matters as its own gate: overflow checks are off, debug_assert
is compiled out, and inlining changes what the optimiser can prove. A test
that only passes in debug never guarded the binary anyone ships.

qa.yml checks siblings out at qa rather than dev. This is not a monorepo, and
a qa run resolving its dependencies from unreleased dev tips would go green on
a combination that is not the one shipping — the exact mismatch the branch
model exists to prevent. ci.yml now tracks whichever branch triggered it for
the same reason, since it also fires on qa.

The consequence is that repos move to qa together, in dependency order. qa is
backfilled from main so an unchanged crate still presents working code to the
crates built on it.
2026-08-05 20:59:34 -07:00
Matthew Jackson 4663cdf2bf changelog: 1.6.1 section 2026-08-05 13:59:41 -07:00
Matthew Jackson e7a60d461c v1.6.0: bump version (unified release) 2026-08-03 05:57:16 -07:00
Matthew Jackson e7490240a1 Add CI to the crate everything else depends on
freemkv-unlock had no automated checks at all: no lint, no tests, no
build on any OS but the one a developer happened to be sitting at. It is
the base of the dependency graph — libfreemkv path-deps it, bdemu
path-deps it with the emulation feature, and the rest pull it in
transitively — so a break here takes all seven other repos down, and
until now the first machine to notice would have been the release
workflow.

Full OS matrix, matching libfreemkv's: lint and test on Linux, plus
macOS and Windows building the tests (not `cargo check`, which skips
codegen and never compiles test-only code).

The `emulation` feature gets its own test run. It is off by default and
it is the face bdemu builds against, so nothing was compiling it.

Also adds the leak-guard workflow the other public repos already carry.
2026-07-31 13:46:04 -07:00
Matthew Jackson 3c535ec874 Sweep the pinned toolchain to Rust 1.97
The Windows UI needs current winsafe, whose real minimum is 1.89 (its manifest
under-declares 1.87 while it uses NonNull::from_ref). Rather than stop at the
minimum, this goes to current stable and fixes what that costs.

The counter-intuitive result: 1.97 is CHEAPER than 1.89. libfreemkv had 54
clippy errors at 1.89 and 6 at 1.97, because clippy tightened the noisy
collapsible_if lint in between. Stopping at the minimum would have been the most
expensive choice available.

Roughly 47 lints across the eight repos, the large majority auto-fixed:
libfreemkv 6, freemkv-engine 14, bdemu 8, freemkv-keysources 7, autorip 6,
freemkv-unlock 3, freemkv-i18n 3. The hand-fixed ones are a descending sort to
sort_by_key(Reverse), four manual checked-division sites, a loop counter replaced
by enumerate, and a loop whose first let-else became a while-let.

Worth recording for whoever bumps next: clippy is MSRV-AWARE. Those 54 lints only
appear once the crate DECLARES 1.89 or later, because let-chains become
available. A bare `cargo +1.89 clippy` against a manifest still pinned at 1.87
reports clean and is meaningless — gate with the real precommit script, which is
also the only thing that covers build scripts.

The pin still sits below the Mac default, so it keeps doing its job: catching
lint drift locally before CI sees it.
2026-07-29 21:00:55 -07:00
Matthew Jackson 21cfb17d11 Pin the toolchain to Rust 1.87
The Windows UI needs winsafe, whose current release requires rustc 1.87. The
alternative was pinning winsafe back to an older release, which would bake a
stale API surface into a brand-new UI permanently to dodge one minor version.

The pin's purpose is to sit BELOW the Mac default so clippy drift is caught
locally before CI, not to stay on 1.86 specifically, so 1.87 preserves the
discipline exactly.

Verified before moving anything, not after: `cargo +1.87 clippy -- -D warnings`
and `cargo +1.87 fmt --check` are clean across all eight repos, and the full
precommit gate (fmt + clippy + tests) passes on libfreemkv, autorip, bdemu,
freemkv-engine and freemkv-keysources. Zero new lints, zero formatting drift.
2026-07-29 20:02:02 -07:00
Matthew Jackson 33e65f739c Route conduct reports through GitHub instead of a private address
The enforcement contact was an address on a domain used for internal
infrastructure, which does not belong in a public repository. GitHub needs no
mailbox to exist and is reachable for anyone who can read this file.
2026-07-29 16:07:14 -07:00
Matthew Jackson ac94c80163 1.6.0: version sync + fix stale docs
Bump to 1.6.0 (workspace sync, no functional change). Fix the README
example that called the removed matches()/unlock() API — rewrite to the
real unlock_features()/unlock_bus() contract (+ ld/README).
2026-07-28 15:35:19 -07:00
Matthew Jackson 94cad15a98 changelog: date the released 1.5.x sections (were stale UNRELEASED) 2026-07-28 13:55:23 -07:00
Matthew Jackson 295046054b Rename the DVD read-unlocker: CSS -> DVD
The unlocker performs the DVD bus-auth that clears the drive's
scrambled-read barrier, letting a protected DVD be read at all. That is
a property of the DVD medium, not of the CSS scheme — the descramble key
is recovered keylessly downstream — so naming it "CSS" conflated the
read-enable with the encryption. Rename the struct to DvdUnlocker, its
registry name to "DVD", and the tests to match. Bump 1.5.2.
2026-07-22 13:48:56 -07:00
Matthew Jackson d339108719 v1.5.1: bump version (unified release) 2026-07-20 17:02:02 -07:00
Matthew Jackson c1447a3dae v1.5.0: bump version (unified release) 2026-07-19 15:37:17 -07:00
Matthew Jackson f073c2c3f9 v1.4.5: bump version (unified release) 2026-07-18 16:07:53 -07:00
Matthew Jackson 9e84eeb284 v1.4.4: bump version (unified release) 2026-07-16 21:43:57 -07:00
Matthew Jackson 5aed39a109 v1.4.3: bump version (unified release) 2026-07-16 21:05:53 -07:00
Matthew Jackson 876943a554 v1.4.2: bump version (unified release) 2026-07-15 19:37:43 -07:00
Matthew Jackson 7c3a46897f v1.4.1: bump version (unified release) 2026-07-14 14:45:21 -07:00
Matthew Jackson b20b38e3d4 Changelog for 1.4.1 2026-07-14 14:43:14 -07:00
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
46 changed files with 7963 additions and 1208 deletions
+63
View File
@@ -0,0 +1,63 @@
version: 2
# Dependency updates land on `dev`, never on `main`.
#
# `main` here is a RELEASE POINTER that release.sh moves to each tag. A bot
# commit on it would put work there that no tag contains, which is exactly the
# state that aborted the 1.6.2 cascade at the last step -- so pointing
# Dependabot at main would recreate that failure on a schedule.
updates:
- package-ecosystem: cargo
directory: /
target-branch: dev
schedule:
interval: weekly
open-pull-requests-limit: 5
# One PR per week for the routine bumps instead of one per crate. Eight
# repos times a handful of crates is a volume nobody reads, and an
# unread PR queue is indistinguishable from no updates at all.
groups:
minor-and-patch:
update-types:
- minor
- patch
ignore:
# The freemkv crates depend on each other by GIT TAG, re-pinned by
# release.sh as part of the release commit. Dependabot cannot see that
# cascade, so a PR bumping one of these would fight the release process
# and could pin a version whose tag does not exist yet.
- dependency-name: freemkv-unlock
- dependency-name: libfreemkv
- dependency-name: freemkv-keysources
- dependency-name: freemkv-i18n
- dependency-name: freemkv-engine
# The workflows are now real infrastructure -- the release cascade, the
# cross-platform hash matrix, the disc gate -- so their actions need the same
# attention as the crates.
- package-ecosystem: github-actions
directory: /
target-branch: dev
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
actions:
update-types:
- minor
- patch
ignore:
# NOT a dependency: `dtolnay/rust-toolchain` is versioned by the RUST
# release it installs, and the tag we pin is the toolchain CI is pinned
# to on purpose -- precommit.sh runs the same one locally so a lint that
# passes on a developer's newer default cannot pass CI by accident.
#
# Dependabot reads those tags as semver and proposed 1.97.0 -> 1.100.0,
# a Rust version that does not exist. Every such PR 404s on toolchain
# download across all eight repos, and they regenerate weekly -- eight
# permanently-red PRs that promote.yml then has to special-case when it
# decides whether dev is green.
#
# Bumping the toolchain is a deliberate, all-eight-repos change, made by
# hand together with precommit.sh. There is nothing here for a bot.
- dependency-name: dtolnay/rust-toolchain
+205
View File
@@ -0,0 +1,205 @@
name: CI
on:
push:
# dev -> qa -> main. `dev` is where work lands and is meant to be pushed
# to often: these are the FAST checks, so a mistake surfaces in minutes.
# `qa` is the release candidate — it runs these too, plus the expensive
# suite in qa.yml. `main` only ever moves at release time, to a tagged
# commit that was already green on qa.
branches: [main, dev, qa]
pull_request:
# freemkv-unlock is the BASE of the whole ecosystem: libfreemkv path-deps it,
# bdemu path-deps it with the `emulation` feature, and every other crate pulls
# it in transitively. It has no freemkv dependencies of its own, so no sibling
# checkout is needed here — but it is also the crate whose breakage takes all
# seven others down with it, which is why it gets the full OS matrix rather
# than a token Linux run.
#
# Cargo.lock is now committed (issue #15), so the resolved dependency graph is
# recorded and the `deny` job audits it. `--locked` is still deliberately NOT
# used, matching the binary crates' ci.yml: CI's sibling path-patch layout
# resolves the freemkv crates as path packages, which `--locked` can't satisfy.
jobs:
# ── Single source of truth for the MSRV ─────────────────────────────────────
#
# `rust-version` in Cargo.toml is the ONE place the MSRV is declared. This job
# reads it; every job that must build/test AT the MSRV consumes
# `needs.resolve-msrv.outputs.v` instead of a hardcoded pin, so the floor
# cannot drift out of sync across workflow files. The `test` job building
# green at this value IS the verification that the declared MSRV is real.
# clippy deliberately does NOT use it — it runs on `stable` (lint debt is
# caught against current Rust).
resolve-msrv:
runs-on: ubuntu-latest
outputs:
v: ${{ steps.msrv.outputs.v }}
steps:
- uses: actions/checkout@v7
- id: msrv
shell: bash
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
# CI owns the changelog: main may never carry a released version without a
# CHANGELOG.md entry for it. The release orchestrator dates an existing
# `## [X.Y.Z]` heading — it does NOT invent one — so a version bump that
# forgot its changelog entry must fail here rather than ship a blank release.
# Hard-fails on main (and PRs targeting main); warns elsewhere so an
# in-progress dev/qa branch whose entry isn't written yet is not blocked.
changelog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Require a CHANGELOG entry for the crate version on main
shell: bash
run: |
set -euo pipefail
version=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
if grep -qE "^## \[${version}\]" CHANGELOG.md; then
echo "CHANGELOG.md has an entry for ${version}."
exit 0
fi
msg="CHANGELOG.md has no '## [${version}]' entry"
if [ "${GITHUB_REF_NAME:-}" = "main" ] || [ "${GITHUB_BASE_REF:-}" = "main" ]; then
echo "::error::${msg} — main requires a changelog entry for the released version."
exit 1
fi
echo "::warning::${msg} (allowed on ${GITHUB_REF_NAME:-unknown}; required before it can reach main)."
lint:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# fmt runs on the pinned MSRV toolchain; clippy runs on stable. Linting on
# stable makes new-lint debt a BLOCKING gate the pinned version would
# otherwise hide. The MSRV pin is installed second so it stays the default
# toolchain (fmt uses it implicitly); clippy is invoked with `+stable`.
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
components: rustfmt
- uses: Swatinem/rust-cache@v2
with:
shared-key: freemkv-unlock-ci
- run: cargo fmt --check
# --all-targets so the test code is linted too, not just the library.
- run: cargo +stable clippy --all-targets -- -D warnings
test:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
with:
shared-key: freemkv-unlock-ci
- run: cargo test --tests
# `emulation` is off by default and is the face bdemu builds against. A
# feature nothing compiles is a feature that breaks at release time.
- run: cargo test --tests --features emulation
# Coverage is additive and NON-REQUIRED: it uploads to Codecov but never
# gates a merge (fail_ci_if_error is false, and it is not wired into any
# release gate). It mirrors the `test` job's environment — same pinned
# toolchain, ubuntu-latest, no `if:` so it runs on every branch and PR just
# as `test` does. `--locked` is intentionally omitted to match this crate's
# policy (Cargo.lock is committed but --locked can't satisfy the path-patch layout).
coverage:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
components: llvm-tools-preview
- uses: Swatinem/rust-cache@v2
with:
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@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: lcov.info
slug: freemkv/freemkv-unlock
fail_ci_if_error: false
# cargo-deny gates the RESOLVED dependency graph: RUSTSEC advisories, a
# permissive licence allow-list, and source/ban hygiene. It mirrors the
# `test` job's checkout (single root checkout, no siblings) and runs on
# `stable` — advisory/licence debt is caught against current Rust, not the
# MSRV pin — so no `needs: resolve-msrv`.
deny:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check advisories bans licenses sources
manifest-path: Cargo.toml
# The crate ships on macOS and Windows through its consumers, so it has to
# compile there. Build the tests (not just `cargo check`) — that forces
# codegen and catches errors in test-only code, which `check` skips.
check-macos:
# dev is the FAST lane: this job still runs, but on the release-candidate
# branches rather than on every push to dev. Nothing is deleted and no
# platform stops being checked before a release -- qa.yml independently
# covers macOS and Windows, and the jobs unique to this file (the Intel
# macOS build, the Windows release build) run here on qa and main. A push
# to dev is meant to be cheap and frequent; waiting on three runner pools
# to agree is what a release candidate is for.
#
# `if` SKIPS the job (it does not queue). A queued job would be far worse
# than a slow one: release.sh's CI gate refuses while any run for the
# commit is still in progress, so a never-scheduled job blocks releases
# silently -- see the note on real-media in qa.yml.
needs: resolve-msrv
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
with:
shared-key: freemkv-unlock-ci
- run: cargo build --tests --all-features
check-windows:
# dev is the FAST lane: this job still runs, but on the release-candidate
# branches rather than on every push to dev. Nothing is deleted and no
# platform stops being checked before a release -- qa.yml independently
# covers macOS and Windows, and the jobs unique to this file (the Intel
# macOS build, the Windows release build) run here on qa and main. A push
# to dev is meant to be cheap and frequent; waiting on three runner pools
# to agree is what a release candidate is for.
#
# `if` SKIPS the job (it does not queue). A queued job would be far worse
# than a slow one: release.sh's CI gate refuses while any run for the
# commit is still in progress, so a never-scheduled job blocks releases
# silently -- see the note on real-media in qa.yml.
needs: resolve-msrv
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
with:
shared-key: freemkv-unlock-ci
- run: cargo build --tests --all-features
+17
View File
@@ -0,0 +1,17 @@
name: comment-guard
# Caps explanatory prose by COMMENT INTENT (not visibility):
# //! / /*! module docs — unbounded (module-scoped, can't hide per-item text)
# /// / /** */ item docs — 8 prose lines (fenced ``` examples excluded)
# // / /* */ asides — 3 lines (single-blank-separated runs count as one)
# Overflow belongs in the module //! or docs/<topic>.md with a one-line // pointer.
on: [push, pull_request]
jobs:
comment-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Run comment-guard
run: python3 ci/comment-guard.py
+41
View File
@@ -0,0 +1,41 @@
name: identity-guard
# Prevention: no commit may carry an unrelated-business or non-canonical author/
# committer email. dev@example.com (a dead corp address) once slipped onto the
# release cascade + a batch of local commits; this fails the build if it recurs.
on:
push:
branches: [dev, qa, main]
pull_request:
jobs:
identity:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Reject banned author/committer emails
env:
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.sha }}
EVENT: ${{ github.event_name }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
PR_HEAD: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
# Unrelated business domains + a personal address normalized to the
# GitHub noreply. Real external contributors are unaffected.
BANNED='@example-a\.invalid|@example-b\.invalid|owner@example\.invalid'
if [ "$EVENT" = "pull_request" ]; then
range="$PR_BASE..$PR_HEAD"
elif [ -n "${BEFORE:-}" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$BEFORE" 2>/dev/null; then
range="$BEFORE..$AFTER"
else
range="$AFTER~1..$AFTER"
fi
bad=$(git log --format='%an <%ae>%n%cn <%ce>' "$range" 2>/dev/null | sort -u | grep -iE "$BANNED" || true)
if [ -n "$bad" ]; then
echo "::error::commit(s) carry a banned author/committer identity:"; echo "$bad" | sed 's/^/ /'
echo "::error::Commit as your GitHub noreply (1085847+MattJackson@users.noreply.github.com). Fix: git config user.email <noreply>; git commit --amend --reset-author."
exit 1
fi
echo "identity-guard: clean"
+35
View File
@@ -0,0 +1,35 @@
name: leak-guard
# Self-contained public-repo leak gate. Public CI cannot reach the private
# tooling, so this encodes only the generic net: internal-infra references,
# tracked CLAUDE.md/.claude paths, and AI-attribution in commit messages.
# No project-specific reverse-engineering vocabulary lives here.
on: [push, pull_request]
jobs:
leak-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Compute commit range
id: range
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
echo "range=$base..$head" >> "$GITHUB_OUTPUT"
else
before="${{ github.event.before }}"
after="${{ github.sha }}"
# New branch / first push: github.event.before is all-zeros.
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then
echo "range=$after" >> "$GITHUB_OUTPUT"
else
echo "range=$before..$after" >> "$GITHUB_OUTPUT"
fi
fi
- name: Run leak-guard
run: bash ci/leak-guard.sh "${{ steps.range.outputs.range }}"
+139
View File
@@ -0,0 +1,139 @@
name: qa
# ── The qa gate: "is this production worth?" ────────────────────────────────
#
# dev -> qa -> main.
#
# `dev` is for committing often. ci.yml answers "is it green" in minutes with
# fmt, clippy and the unit suite, so a mistake surfaces while it is still cheap
# to fix. `qa` is the release-candidate branch, and THIS workflow is the claim
# that a commit is production worth: everything expensive that can run without
# physical media. `main` only ever receives a qa that went green here.
#
# Sibling repos are checked out at `qa`, NOT `dev`. A qa run that resolved its
# dependencies from dev tips would be validating a combination that is not the
# one being released, which is the exact failure this branch exists to prevent.
#
# What this gate CANNOT cover: `disc://` and real `iso://` need physical media,
# and no hosted runner has an optical drive or the image hoard. Those run on a
# self-hosted runner (see the media job at the end) and are the one leg that
# stays on hardware.
on:
push:
branches: [qa]
workflow_dispatch:
jobs:
# Single source of truth for the MSRV — see ci.yml. Jobs that build/test AT
# the MSRV read `needs.resolve-msrv.outputs.v` from Cargo.toml's rust-version;
# clippy (cross-lint) runs on stable instead.
resolve-msrv:
runs-on: ubuntu-latest
outputs:
v: ${{ steps.msrv.outputs.v }}
steps:
- uses: actions/checkout@v7
- id: msrv
shell: bash
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
# ── Name the candidate ────────────────────────────────────────────────
#
# Every push to `qa` is a release candidate, so every push gets a tag:
# v<version>-rc<N>, N incrementing. That is the answer to "which build is on
# qa right now, and is it the one I tested?" — a question that otherwise gets
# answered from memory.
#
# This runs FIRST and does not depend on the gates, deliberately. A red
# candidate needs a name more than a green one does: "rc3 failed
# release-tests on windows" is a sentence you can act on; "qa is red" is not.
# Red on qa is a working gate, not an incident — it is the branch saying this
# is not production worth yet. Fix on dev, get dev green, push qa again.
#
# release.yml excludes v*-rc* so a candidate never publishes a release.
rc-tag:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Stamp the next rc
shell: bash
run: |
set -euo pipefail
v=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
[ -n "$v" ] || { echo "no version in Cargo.toml" >&2; exit 1; }
# Numeric sort on the rc ordinal: -rc10 must beat -rc9, and a plain
# lexical sort gets that backwards from the tenth candidate on.
n=$(git tag -l "v$v-rc*" | sed "s|^v$v-rc||" | sort -n | tail -1)
tag="v$v-rc$(( ${n:-0} + 1 ))"
git tag "$tag"
git push origin "$tag"
echo "### Candidate \`$tag\`" >> "$GITHUB_STEP_SUMMARY"
# The debug suite runs on every dev push. Release is a DIFFERENT build:
# overflow checks are off, debug_assert! is compiled out, and inlining
# changes what the optimiser can prove. A test that only passes in debug is
# a test that never guarded the binary anyone actually ships.
release-tests:
needs: resolve-msrv
strategy:
fail-fast: false
matrix:
os: ['ubuntu-latest', 'macos-latest']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
with:
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-unlock
- run: cargo test --release --tests
working-directory: freemkv-unlock
# clippy's output is target-dependent: cfg-gated code only gets linted on
# the target it compiles for. Linting solely on the dev machine's host
# target is how a lint that CI rejects reaches a push.
cross-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
path: freemkv-unlock
# clippy runs on stable (matches ci.yml's lint job); build/test stay on
# the MSRV pin. See ci.yml for why the lint toolchain need not be the MSRV.
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-unlock
- run: rustup target add x86_64-unknown-linux-gnu
- run: cargo clippy --all-targets --target x86_64-unknown-linux-gnu -- -D warnings
working-directory: freemkv-unlock
# Windows compiles the tests but does not run them, matching the policy
# ci.yml already set. The value here is codegen: the #[cfg(windows)] halves
# of the SCSI transport and platform layers compile on no other runner, so
# without this they are first built at release time.
windows-build:
needs: resolve-msrv
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
with:
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-unlock
- run: cargo build --release --tests
working-directory: freemkv-unlock
+1 -1
View File
@@ -1,3 +1,3 @@
/target
Cargo.lock
.cargo/config.toml
NEXT_SESSION.md
+235
View File
@@ -1,5 +1,240 @@
# Changelog
## [1.7.0] — 2026-09-02
### Added
- Preliminary, beta unlocking support for **Renesas** optical drives and for drives running **freemkv firmware**. Both are early and unvalidated; the established LibreDrive / AACS / CSS unlock routes are unchanged.
### Fixed
- `ld/profile`: `find_by_drive_id` no longer mis-binds a drive whose reported `product_id` is real but not catalogued to a sibling model's profile. The product-id-blind loose match is now gated on an empty product id, so a wrong profile — and its wrong LibreDrive microcode — can't be selected for a drive that does report a product id.
### Changed
- Version aligned to 1.7.0 for the unified release.
## [1.6.14] — 2026-08-31
### Changed
- Version aligned to 1.6.14 for the unified release.
## [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
- Version aligned to 1.6.11 for the unified release. No functional changes to
this crate; the release was driven by the libfreemkv main-feature selection
improvements and the autorip mux-quarantine fix (see the libfreemkv and
autorip 1.6.11 notes).
### Added
- Codecov coverage reporting and badge.
- Substantially expanded unit-test coverage.
## [1.6.10] — 2026-08-23
### Changed
- Version aligned to 1.6.10 for the unified release. No functional changes to
this crate; the release was driven by libfreemkv (TrueHD/MLP audio now resyncs
to the next major-sync access unit after a source transport-stream
discontinuity, instead of splicing post-gap audio mid-stream — fixing
decoder-choking seams on discs whose stream carries a continuity-counter gap;
see the libfreemkv 1.6.10 notes).
- MSRV lowered from 1.97 to 1.90. The crate does not use anything newer than
1.90 stabilized; CI's pinned toolchain and `rust-version` are updated to
match.
## [1.6.9] — 2026-08-22
### Changed
- Version aligned to 1.6.9 for the unified release. No functional changes to
this crate; the release was driven by autorip (automatic per-episode TV
ripping — each episode named `S{NN}E{MM}`, with TMDB runtime-aligned episode
numbering across multi-disc seasons — a Manual Rename option, and a unified
per-disc staging state file — see the autorip 1.6.9 notes).
## [1.6.8] — 2026-08-21
### Changed
- Version aligned to 1.6.8 for the unified release. No functional changes to
this crate; the release was driven by autorip (webhooks now fire per pipeline
stage — Rip / Mux / Move — with the Rip hook firing the moment the drive is
free again, plus a Ripper-tab activity-banner fix so it also shows during
moves — see the autorip 1.6.8 notes).
## [1.6.7] — 2026-08-21
### Changed
- Version aligned to 1.6.7 for the unified release. No functional changes to
this crate; the release was driven by autorip (per-webhook event selection,
a progress bar per moved artifact, and move-queue / webhook-error fixes —
see the autorip 1.6.7 notes).
## [1.6.6] — 2026-08-20
### Changed
- Version aligned to 1.6.6 for the unified release. No functional changes
to this crate; the release was driven by autorip (webhooks may now target
private/LAN addresses — see the autorip 1.6.6 notes).
## [1.6.5] — 2026-08-20
### Added
- **AACS 2.0 (P-256) drives now complete a live host-certificate
handshake.** The elliptic-curve (P-256) authentication path is wired
into the cert handshake for real, not left as dead code. A drive is
first tried with the AACS 1.0 certificate (a 2.0 drive accepts it for
the Volume ID); if the drive refuses it and the supplied host
certificate carries AACS 2.0 credentials, the crate falls back to the
native P-256 handshake. It is cert-agnostic — it uses whatever the host
certificate carries, with no separate feature gate — and a bus fault on
the 1.0 attempt short-circuits the fallback so a dead bus is not
hammered. The 2.0 path enforces the same chain-of-trust checks as the
1.0 path (drive-cert type gate, fatal certificate verification under the
real 2.0 Licensing Administrator key, on-curve validation of every
attacker-supplied point, and AGID release on every exit). The 2.0
certificate byte layout is documented as provisional pending a genuine
drive certificate to pin it against; a wrong layout fails closed and
never trusts.
### Security
- **A rogue drive or bridge can no longer forge its way to a trusted bus
key.** A drive certificate whose type was neither AACS 1.0 nor 2.0 used
to skip Licensing Administrator verification yet still have its own key
trusted for the bus-key signature check, letting an attacker inject a
chosen bus key; unknown cert types are now rejected at the type gate
before any key is trusted. ECDSA verification now validates the signer's
public key (on-curve, in-field, not the identity point) before use — a
key of (0,0) previously collapsed verification to a forgeable form. The
compiled-in AACS 1.0 and 2.0 Licensing Administrator anchor keys, which
were off-curve, are replaced with the real published keys (both
confirmed on-curve; the 1.0 key verifies a genuine LA-signed
certificate). The P-256 certificate-length check now matches the real
132-byte layout, so AACS 2.0 certificates verify at all.
- **Sensitive material no longer leaks through debug output.** `Unlocked`
(which carries the bus key and Volume ID) and `DriveProfile` (which
carries the MediaTek drive firmware image and per-drive vendor SCSI
templates) now use hand-written redacting `Debug` implementations
instead of printing everything. The AACS 2.0 authentication path now
releases the drive AGID on every failure exit, matching the 1.0 path.
### Fixed
- **A dead or failing drive bus is no longer reported as a successful
unlock.** A transport fault during the AACS data-key read, during
firmware-unlock disc probing, and in both MediaTek speed-probe passes
used to be swallowed or flattened into a generic failure — in the
firmware-unlock case surfacing as `drive_unlocked: true` on a bus that
had actually died. These paths now abort with a transport error so the
fault is visible instead of masquerading as clear content.
- **Transport faults are no longer misclassified as "wrong drive" or
"bad credentials."** A dead bus on the first probe command previously
read as "not a Renesas drive" or "not a supported DVD profile"; a bus
that died mid-handshake was reported as a credentials rejection after
three retries against dead hardware; and CSS bus-auth collapsed every
fault into a generic auth failure. Each call site now honours the
transport contract — abort on a real bus fault, fall through only on a
genuine "not applicable." A merely short probe response is no longer
fabricated into a transport failure, so rips that would have succeeded
are no longer aborted.
- **A half-unlocked drive now falls through to certificate
authentication instead of claiming the bus is clear.** The firmware
unlocker set `drive_unlocked: true` whenever the handshake merely
completed, ignoring the computed unlock flag; a drive returning zero
bytes could even report a full unlock from a buffer it never wrote.
The unlock outcome is now actually consulted, so a partial unlock
correctly steers to the cert-auth fallback. Related read paths that
parsed the caller's own zero-filled buffer as drive data on a CHECK
CONDITION are fixed to inspect status and bytes transferred.
### Changed
- **Drive preparation issues 88 fewer SCSI commands per disc.** The
probe's two passes previously walked the same 88 addresses twice; the
redundant pass is removed.
## [1.6.4] — 2026-08-15
### Changed
- **No functional change.** This crate ships alongside the rest of freemkv at a
matching version; its behaviour is untouched.
## [1.6.3] — 2026-08-10
### Changed
- **Housekeeping only — nothing about how a drive is unlocked has changed.** The
cryptography and compression crates this crate builds on were moved to their
current releases and brought into line with the versions the rest of freemkv
uses, so one version of each is built instead of two.
## [1.6.2] — 2026-08-08
Version sync with the workspace. No functional change in this crate.
## [1.6.1] — 2026-08-07
Version sync with the workspace. No functional change in this crate.
## [1.6.0] — 2026-08-03
Version sync with the workspace. No functional change in this crate.
## [1.5.2] — 2026-07-22
### Changed
- The DVD read-unlocker (bus-auth) is renamed `CSS` → `DVD`: it reports the
medium it unlocks, not whether a title-key crack ran. The unlocker report now
reads `DVD: yes` on any DVD.
## [1.4.1] — 2026-07-14
Version sync with the workspace; inherits libfreemkv 1.4.1.
## [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
+1 -1
View File
@@ -36,7 +36,7 @@ This Code of Conduct applies within all community spaces, and also applies when
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at owner@example.invalid. All complaints will be reviewed and investigated promptly and fairly.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported privately to the project maintainer via GitHub at https://github.com/MattJackson. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
+18
View File
@@ -0,0 +1,18 @@
# Contributing
Thanks for your interest!
- **Report a bug** — open an issue
- **Fix a bug** — fork, branch, PR
- **Suggest a feature** — open an issue first
## Development
```bash
cargo build
cargo test
```
## License
MIT
Generated
+454
View File
@@ -0,0 +1,454 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aes"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
dependencies = [
"cipher",
"cpubits",
"cpufeatures 0.3.1",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core",
]
[[package]]
name = "cipher"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common 0.2.2",
"inout",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
]
[[package]]
name = "freemkv-unlock"
version = "1.7.0"
dependencies = [
"aes",
"base64",
"num-bigint",
"num-traits",
"rand",
"serde",
"serde_json",
"sha1",
"sha2",
"tracing",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"rand_core",
]
[[package]]
name = "hybrid-array"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
dependencies = [
"typenum",
]
[[package]]
name = "inout"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [
"hybrid-array",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "num-bigint"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest 0.11.3",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[patch.unused]]
name = "freemkv-engine"
version = "1.7.0"
[[patch.unused]]
name = "freemkv-i18n"
version = "1.7.0"
[[patch.unused]]
name = "freemkv-keysources"
version = "1.7.0"
[[patch.unused]]
name = "libfreemkv"
version = "1.7.0"
+11 -8
View File
@@ -1,9 +1,9 @@
[package]
name = "freemkv-unlock"
version = "1.3.0"
version = "1.7.0"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
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"
# NEVER crates.io: this crate carries drive firmware. Consumed only by git tag.
@@ -18,11 +18,14 @@ emulation = []
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22.1"
base64 = "0.23"
tracing = "0.1"
num-bigint = "0.4"
num-bigint = "0.5"
num-traits = "0.2"
sha1 = "0.10"
sha2 = "0.10"
aes = "0.8"
rand = "0.8"
sha2 = "0.11"
aes = "0.9"
rand = "0.10"
[lints.clippy]
uninlined_format_args = "allow"
+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.
+24
View File
@@ -0,0 +1,24 @@
NOTICE — Third-Party Drive Microcode
====================================
freemkv-unlock's own source code is licensed under the MIT License (see LICENSE).
The MIT License does NOT cover the drive-microcode payloads bundled in
`src/ld/profiles.json`.
Those payloads are volatile, RAM-only optical-drive microcode ("LibreDrive")
authored and owned by the MakeMKV team (GuinpinSoft inc.). Each payload embeds
its own copyright notice, e.g.:
"This is volatile RAM-only microcode for LibreDrive by MakeMKV -
http://www.makemkv.com/libredrive/
Copyright (c) MakeMKV team, all rights reserved."
They are included solely to recognize supported drives and perform the
non-persistent, RAM-only unlock in interoperation with MakeMKV's LibreDrive, on
drives their owners already possess. They are third-party proprietary material —
**Copyright (c) MakeMKV team, all rights reserved** — and are **NOT** licensed
under MIT. No ownership is claimed or transferred.
freemkv is an independent project, not affiliated with, endorsed by, or
sponsored by MakeMKV / GuinpinSoft inc. With thanks to the MakeMKV team for
LibreDrive. See https://www.makemkv.com/ and http://www.makemkv.com/libredrive/.
+24 -3
View File
@@ -1,3 +1,7 @@
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
[![CI](https://github.com/freemkv/freemkv-unlock/actions/workflows/ci.yml/badge.svg)](https://github.com/freemkv/freemkv-unlock/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/freemkv/freemkv-unlock/branch/dev/graph/badge.svg)](https://codecov.io/gh/freemkv/freemkv-unlock)
# freemkv-unlock
The unlock layer for the freemkv toolchain.
@@ -14,9 +18,17 @@ clients of libfreemkv are oblivious to unlockers entirely (as they are to the
SCSI layer).
```rust
use freemkv_unlock::UnlockError;
// Drive-prep: try each unlocker's feature unlock until one claims the drive.
// `NotApplicable` means "not this unlocker's drive" — move on; a transport
// error means a dead bus — abort. `unlock_bus` follows the same contract for
// removing per-disc bus encryption.
for u in freemkv_unlock::all_unlockers() {
if u.matches(&ctx) {
return u.unlock(&mut scsi, &ctx);
match u.unlock_features(&mut scsi, &ctx) {
Ok(unlocked) => return Ok(unlocked),
Err(UnlockError::NotApplicable) => continue,
Err(e) => return Err(e),
}
}
```
@@ -24,4 +36,13 @@ 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
freemkv-unlock's own source is MIT — see [LICENSE](LICENSE).
`src/ld/profiles.json` bundles volatile, RAM-only **LibreDrive** microcode
authored and owned by the **MakeMKV team** (GuinpinSoft inc.) — *Copyright (c)
MakeMKV team, all rights reserved.* That microcode is third-party proprietary
material, **not** covered by the MIT license; no ownership is claimed. See
[`NOTICE`](NOTICE). With thanks to the MakeMKV team for
[LibreDrive](http://www.makemkv.com/libredrive/).
+22
View File
@@ -0,0 +1,22 @@
# Security Policy
## Supported versions
| Version | Supported |
| ------- | --------- |
| 1.6.x | Yes |
| < 1.6 | No |
Only the current 1.6.x line receives security fixes.
## Reporting a vulnerability
Report vulnerabilities privately through GitHub Security Advisories:
https://github.com/freemkv/freemkv-unlock/security/advisories/new
Do not open a public issue for a security report. Include the affected
version, steps to reproduce, and the impact you believe the issue has.
## Response time
You will get an initial response within 7 days.
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""comment-guard: cap explanatory prose by COMMENT INTENT, not visibility.
Three tiers, keyed on the syntax the author reached for:
* `//!` / `/*!` — a module (or crate) design doc. UNBOUNDED. There is one per
file, at the top, documenting the module as a whole, so it can't be used to
smuggle per-item rationale the way item docs can. Long correctness / design
essays (e.g. a memory-ordering proof) legitimately live here.
* `///` / `/** */` — item documentation. Up to DOC_CAP *prose* lines. Fenced
``` code blocks do NOT count, so real examples aren't punished. This holds
whether or not the item is `pub`: a `pub(crate)` fn deserves a real doc
paragraph just like a `pub` one. Capping `///` here (rather than exempting
it, as the old guard did — issue #17) is what stops rationale being hidden
behind a doc sigil, while still leaving room for a genuine paragraph.
* `//` / `/* */` — a quick internal aside. Up to INLINE_CAP lines. This is the
shape that sprawls into walls, so it's kept tight: stanzas separated by a
single blank line count CUMULATIVELY, so one essay can't be split into runs.
Overflow belongs in the module `//!`, or in docs/<topic>.md with a one-line `//`
pointer — both show up in review.
Scans src/ and tests/ under the given root (default cwd). `--selftest` runs
built-in cases. Exit non-zero if any block exceeds its cap.
"""
import re
import sys
from pathlib import Path
INLINE_CAP = 3
DOC_CAP = 8
_RAW_OPEN = re.compile(r'r(#*)"') # raw string opener: r"…", r#"…"#, br##"…"## …
def _strip_line_comment(s):
"""Return (kind, body) for a whole-line comment, else (None, None).
kind is 'doc' for `///`/`//!`, 'plain' for other `//`. A trailing comment on
a code line is not a whole-line comment and is ignored (single-line, cheap).
"""
if s.startswith("//!"):
return "doc", s[3:]
if s.startswith("///") and not s.startswith("////"):
return "doc", s[3:]
if s.startswith("//"):
return "plain", s[2:]
return None, None
def _fence_toggle(body):
"""True if this doc body line opens/closes a ``` fenced code block."""
return body.strip().startswith("```")
def _classify(lines):
"""Yield per-line dicts: comment kind ('doc'/'plain'/None), doc-ness of
block comments, and code info. Block comments are tracked across lines.
"""
info = []
in_block = False
block_is_doc = False
raw_hashes = None # None = not in a raw string; int N = open, needs '"' + N*'#'
for raw in lines:
s = raw.lstrip()
rec = {"raw": raw, "s": s, "comment": None, "is_doc": False, "code": False}
# Inside a raw string literal (e.g. an embedded HTML/CSS/JS asset): the
# bytes are string data, not Rust comments, so the guard is blind to them
# until the string closes. Prevents `/* */` or `//` lines in an embedded
# page from being counted as comment blocks.
if raw_hashes is not None:
if ('"' + "#" * raw_hashes) in raw:
raw_hashes = None
info.append(rec)
continue
if in_block:
rec["comment"] = "doc" if block_is_doc else "plain"
rec["is_doc"] = block_is_doc
if "*/" in raw:
in_block = False
info.append(rec)
continue
# A block comment only starts a comment-line if it's the first token.
if s.startswith("/*"):
rec["is_doc"] = s.startswith("/**") or s.startswith("/*!")
rec["comment"] = "doc" if rec["is_doc"] else "plain"
if "*/" not in s[2:]:
in_block = True
block_is_doc = rec["is_doc"]
info.append(rec)
continue
kind, _ = _strip_line_comment(s)
if kind is not None:
rec["comment"] = kind
rec["is_doc"] = kind == "doc"
info.append(rec)
continue
# Code (or blank). Blank = empty after strip.
rec["code"] = s != ""
# A code line may OPEN a raw string that spans following lines. Detected
# only on code (comments were handled above), so `// see r"x"` is safe.
m = _RAW_OPEN.search(raw)
if m and ('"' + "#" * len(m.group(1))) not in raw[m.end():]:
raw_hashes = len(m.group(1))
info.append(rec)
return info
def violations(path):
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
info = _classify(lines)
n = len(info)
out = []
i = 0
while i < n:
rec = info[i]
if rec["comment"] is None:
i += 1
continue
start = i
if rec["is_doc"]:
# Doc run: contiguous doc lines. `//!` / `/*!` module docs are
# UNBOUNDED; any other doc run caps PROSE (fenced ``` excluded) at
# DOC_CAP, regardless of the documented item's visibility.
j = i
prose = 0
in_fence = False
module_doc = False
while j < n and info[j]["comment"] == "doc":
s = info[j]["s"]
if s.startswith("//!") or s.startswith("/*!"):
module_doc = True
body = (
s[3:] if (s.startswith("///") or s.startswith("//!")) else s
)
if _fence_toggle(body):
in_fence = not in_fence
elif not in_fence and body.strip() != "":
prose += 1
j += 1
if not module_doc and prose > DOC_CAP:
out.append((start + 1, prose, DOC_CAP, "doc prose"))
i = j
continue
# Internal (plain) run, with single-blank bridging (cumulative).
j = i
count = 0
while j < n:
r = info[j]
if r["comment"] == "plain":
count += 1
j += 1
continue
if r["comment"] == "doc":
break
if r["comment"] is None and not r["code"]:
# blank: bridge only a SINGLE blank, else stop
if j + 1 < n and info[j + 1]["comment"] == "plain":
j += 1
continue
break
break # code
if count > INLINE_CAP:
out.append((start + 1, count, INLINE_CAP, "inline comment"))
i = j
return out
def main(argv):
if "--selftest" in argv:
return _selftest()
root = Path(argv[1]) if len(argv) > 1 else Path.cwd()
files = sorted(p for d in ("src", "tests") for p in (root / d).rglob("*.rs"))
total = 0
for f in files:
for start, length, cap, kind in violations(f):
rel = f.relative_to(root)
print(f"{rel}:{start}: {kind} block is {length} lines (max {cap})")
total += 1
if total:
print(f"\ncomment-guard: {total} comment block(s) exceed their cap.")
print(
f"Inline // asides cap at {INLINE_CAP}; /// item-doc prose caps at "
f"{DOC_CAP} (fenced examples excluded); //! module docs are unbounded. "
"Trim, move the rationale into the module //!, or docs/<topic>.md "
"with a // pointer."
)
return 1
return 0
def _selftest():
import tempfile
cases = [
# (rust source, expected number of violations)
# short /// doc -> ok
("/// short doc\npub fn a() {}\n", 0),
# /// on a PRIVATE item now gets the doc cap (8), so 4 lines is fine
("/// l1\n/// l2\n/// l3\n/// l4\nfn priv_a() {}\n", 0),
# ...but /// is NOT unlimited: 9 prose lines on a private item fails
("".join(f"/// l{k}\n" for k in range(9)) + "fn priv_b() {}\n", 1),
# 8 prose /// on a pub item -> ok (boundary)
("".join(f"/// l{k}\n" for k in range(8)) + "pub fn c() {}\n", 0),
# 9 prose /// on a pub item -> violation
("".join(f"/// l{k}\n" for k in range(9)) + "pub fn d() {}\n", 1),
# /// with a long fenced example -> example excluded, ok
(
"/// summary\n/// # Examples\n/// ```\n"
+ "".join("/// code\n" for _ in range(12))
+ "/// ```\npub fn e() {}\n",
0,
),
# pub(crate) gets the SAME doc cap as pub (no pub/private split anymore)
("".join(f"/// l{k}\n" for k in range(6)) + "pub(crate) fn f() {}\n", 0),
# bare inline 4 lines -> violation
("// a\n// b\n// c\n// d\nlet x = 1;\n", 1),
# blank-split dodge: 3 + blank + 3 inline -> cumulative -> violation
("// a\n// b\n// c\n\n// d\n// e\n// f\nfn z() {}\n", 1),
# plain /* */ block, 5 lines -> inline cap -> violation
("/* one\n two\n three\n four\n five */\nfn q() {}\n", 1),
# comment-like lines INSIDE a raw string are string data, not comments
(
'const H: &str = r##"<style>\n'
+ "".join("/* css note */\n" for _ in range(8))
+ '</style>"##;\nfn h() {}\n',
0,
),
# //! module doc may run long -> EXEMPT (no cap)
("".join(f"//! l{k}\n" for k in range(40)) + "\npub fn m() {}\n", 0),
# /*! block module doc may run long -> EXEMPT
("/*! l0\n" + "".join(f" l{k}\n" for k in range(30)) + "*/\nfn n() {}\n", 0),
]
ok = True
with tempfile.TemporaryDirectory() as d:
for idx, (src, want) in enumerate(cases):
p = Path(d) / f"case{idx}.rs"
p.write_text(src)
got = len(violations(p))
flag = "ok" if got == want else "FAIL"
if got != want:
ok = False
print(f" selftest {idx}: want {want} got {got} [{flag}]")
print("selftest PASSED" if ok else "selftest FAILED")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main(sys.argv))
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
#
# leak-guard.sh — self-contained public-repo leak gate.
#
# This is the LAST line of defense in CI. It is intentionally self-contained:
# public CI cannot reach the private tooling, so this script encodes ONLY the
# generic net — internal infrastructure references, agent-context files, and
# AI-attribution in commit messages. It deliberately contains NO project-
# specific reverse-engineering vocabulary (those words would themselves be a
# leak). The richer private scanner stays private.
#
# Fails (exit 1) if any of the following appear in the repo:
# 1. a tracked CLAUDE.md or .claude/ path (agent context — never public),
# 2. tracked file content matching the internal-infra net,
# 3. a commit message (in the given range) with AI attribution.
#
# Usage:
# leak-guard.sh [<commit-range>]
# <commit-range> optional git rev-list range to scan commit messages
# (e.g. "abc..def"). If omitted, commit-message scan is
# skipped (path + content checks always run).
set -euo pipefail
# Absolute path to this script, resolved before any cd, so we can exclude it
# from the content scan (it necessarily contains the detection patterns).
SELF_ABS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
REPO="$(git rev-parse --show-toplevel)"
cd "$REPO"
fail=0
note() { printf ' ✗ %s\n' "$1"; fail=1; }
# Internal-infra net — GENERIC ONLY. This script ships in the public repo, so
# the patterns themselves must not name any org-specific identifier (doing so
# would itself leak the infra they guard). We catch the leak *class*:
# - RFC1918 private IPv4 ranges (10/8, 172.16/12, 192.168/16),
# - private/internal/non-routable TLDs (.internal/.local/.lan/.corp/.invalid),
# - docker.internal.
# The full org-specific net (literal hostnames, service names, repo paths,
# vendor tooling, …) lives ONLY in the private scanner and never ships here.
INFRA_RE='\b10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|\b172\.(1[6-9]|2[0-9]|3[01])\.[0-9]{1,3}\.[0-9]{1,3}|\b192\.168\.[0-9]{1,3}\.[0-9]{1,3}|\.internal\b|\.local\b|\.lan\b|\.corp\b|\.invalid\b|docker\.internal'
# Home-path net — GENERIC ONLY. Catches an absolute developer home path
# committed into a tracked file (a macOS /Users/<user>/… or Linux /home/<user>/…
# path). This names NO specific user — it matches the leak *class* (any home
# path), so the pattern itself reveals nothing org- or person-specific. A real
# leak (e.g. /Users/alice/Developer/x slipping into a public RELEASE.md) trips
# this regardless of whose machine it came from. The username segment is a
# literal-username class ([A-Za-z0-9._-]) so dynamic/templated paths that build
# the user at runtime — shell `/home/$USER/`, doc `/home/<rip>/`, Rust
# `/home/{user}/` — do NOT false-positive; only a baked-in literal home leaks.
HOMEPATH_RE='/Users/[A-Za-z0-9._-]+/|/home/[A-Za-z0-9._-]+/'
# AI-attribution net (case-insensitive). "claude" matches only as a standalone
# word — NOT preceded by a dot/slash/alnum and NOT followed by .md — so legit
# mentions of CLAUDE.md / .claude/ in a commit message don't false-positive.
ATTR_RE='co-authored-by|generated with|🤖|(?<![.\/A-Za-z0-9])claude(?!\.md)'
echo "── leak-guard: tracked agent-context paths ──"
while IFS= read -r f; do
case "$f" in
CLAUDE.md|*/CLAUDE.md|.claude|.claude/*|*/.claude|*/.claude/*)
note "tracked agent-context file: $f (CLAUDE.md/.claude must never be tracked in a public repo)" ;;
esac
done < <(git ls-files)
# Match a PCRE against a file, emitting "LINE: MATCH". The pattern is passed as
# an argument (not interpolated into a //) so metacharacters like the "/" in a
# path-style token can't break the regex. Reads raw bytes so non-UTF-8 blobs
# don't abort the scan.
pcre_matches() {
perl -e '
my ($file, $re) = @ARGV;
open(my $fh, "<:raw", $file) or exit 0;
my $rx; eval { $rx = qr/$re/i }; exit 0 if $@;
while (my $l = <$fh>) { if ($l =~ /$rx/) { print "$.: $&\n"; } }
' "$1" "$2" 2>/dev/null
}
# This script's own source necessarily contains the detection patterns (e.g.
# the regex tokens in INFRA_RE), so scanning it would always self-flag. Skip it.
SELF="$(git ls-files --full-name -- "$SELF_ABS" 2>/dev/null | head -1)"
echo "── leak-guard: internal-infra references in tracked files ──"
while IFS= read -r f; do
case "$f" in *.png|*.jpg|*.jpeg|*.ico|*.gif|*.bin|*.crate|*.gz|*.zip|*.pdf) continue ;; esac
[ -n "$SELF" ] && [ "$f" = "$SELF" ] && continue
[ -f "$f" ] || continue
while IFS= read -r hit; do
[ -z "$hit" ] && continue
note "internal-infra reference: $f:$hit"
done < <(pcre_matches "$f" "$INFRA_RE")
while IFS= read -r hit; do
[ -z "$hit" ] && continue
note "[HOME-PATH] absolute home path: $f:$hit (no local home path may be committed to a public repo)"
done < <(pcre_matches "$f" "$HOMEPATH_RE")
done < <(git ls-files)
RANGE="${1:-}"
if [ -n "$RANGE" ]; then
echo "── leak-guard: AI-attribution in commit messages ($RANGE) ──"
while IFS= read -r sha; do
[ -z "$sha" ] && continue
msg="$(git log -1 --format='%B' "$sha" 2>/dev/null || true)"
# Pass the pattern as an argument (not interpolated into a //) so the
# lookbehind char class and "/" don't break the regex.
hit="$(printf '%s' "$msg" | perl -e '
my $re = $ARGV[0]; my $rx = qr/$re/i;
while (my $l = <STDIN>) { if ($l =~ /($rx)/) { print "$1\n"; last; } }
' "$ATTR_RE" | head -1 || true)"
[ -n "$hit" ] && note "commit ${sha:0:12}: message contains \"$hit\" (owner rule: zero AI attribution, ever)"
done < <(git rev-list "$RANGE" 2>/dev/null || true)
fi
echo
if [ "$fail" -ne 0 ]; then
echo "✗ leak-guard: blocking finding(s) above — DO NOT MERGE/PUBLISH"
exit 1
fi
echo "✓ leak-guard: clean"
+16
View File
@@ -0,0 +1,16 @@
coverage:
status:
project:
default:
informational: true
patch:
default:
informational: true
comment: false
ignore:
- "tests"
- "**/tests"
- "benches"
- "examples"
+42
View File
@@ -0,0 +1,42 @@
# cargo-deny — gates the RESOLVED dependency graph (issue #15).
# Runs in CI against the committed Cargo.lock: RUSTSEC advisories, a permissive
# licence allow-list, and source/ban hygiene. Update the lock (cargo update)
# to clear an advisory rather than adding an `ignore` entry without cause.
[advisories]
yanked = "deny"
# ignore = ["RUSTSEC-0000-0000"] # only with a written justification
[licenses]
# The whole tree is permissive. r-efi's LGPL arm is satisfied by Apache-2.0
# (its expression is "Apache-2.0 OR LGPL-2.1-or-later"), so no copyleft is
# allowed here. Add a licence only when a real dependency introduces it.
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"0BSD",
"Unicode-3.0",
"Unlicense",
"Zlib",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"CDLA-Permissive-2.0",
]
confidence-threshold = 0.9
[bans]
multiple-versions = "warn" # transitive dupes (digest 0.10/0.11 etc.) are noise, not a gate
wildcards = "deny"
# The freemkv siblings resolve as path deps under CI's path-patch layout, which
# reads as a wildcard version req. That is internal, not a supply-chain wildcard.
allow-wildcard-paths = true
[sources]
unknown-registry = "deny"
unknown-git = "deny"
# The freemkv siblings are first-party git-tag deps; allow git sources from
# our own org only (CI resolves them as git, not the local path override).
[sources.allow-org]
github = ["freemkv"]
+184
View File
@@ -0,0 +1,184 @@
# AACS bus authentication handshake
## Flow
1. Invalidate AGIDs → allocate fresh AGID
2. Send host certificate + nonce
3. Receive drive certificate + nonce
4. Receive drive key point + signature, verify
5. Sign host key point, send
6. ECDH: host_priv × drive_key_point → bus key (low 128 bits of x)
7. Read VID or Read Data Keys (encrypted with bus key)
## Why `scsi_read`/`scsi_write` check status at the seam
The contract this crate is built on (`ScsiTransport::execute`) returns `Ok`
on a SCSI sense and `Err` ONLY on a transport-layer fault. These two helpers
used to propagate the `?` and nothing else — so a CHECK CONDITION (`Ok`,
non-zero `status`) handed the caller back its own ZERO-FILLED buffer and
every REPORT/SEND KEY step consumed zeros as valid drive data: a drive
certificate of zeros, a key point of zeros, a Volume ID of zeros, all
reported as success. It has not fired in production only because the sole
transport implementor violates the contract in the opposite direction (it
returns `Err` for any non-zero status). Plugging in a CONFORMING transport
would have turned the whole handshake into a zero-buffer success, so the
status and the transferred length are checked here, at the seam.
## Why `scsi_write` shares this reasoning
Same contract reasoning as `scsi_read`: a drive that REFUSES the host
certificate answers with `Ok` + CHECK CONDITION, and treating that as a
successful send carried the handshake on against a drive that never
accepted it.
## `ec_mul` design notes
**Constant-time tradeoff:** this branches on `scalar.bit(0)` and clones
BigUints per iteration, so its timing is data-dependent on the secret scalar
(the long-term host private key in `ecdsa_sign`, the ephemeral key in ECDH).
This is deliberate: the handshake runs once per disc against a local optical
drive, so throughput and the narrow local-timing surface do not justify
pulling in a vetted constant-time backend. Revisit if this ever signs in a
remote/shared context.
**Cofactor:** both AACS curves used here have cofactor 1, so a point that
lies on the curve is automatically in the prime-order subgroup — no
small-subgroup defense / `n·P == O` check is required for the inputs this is
called with.
## `verify_cert_p256` — provisional AACS 2.0 cert layout
Verifies an AACS 2.0 drive certificate (type 0x11) against an AACS 2.0 LA
key. PROVISIONAL LAYOUT — the byte offsets below are NOT yet confirmed
against a genuine AACS 2.0 cert, and no such cert exists on this machine to
pin them (an exhaustive hunt found only 2.0 *content* certs, a different
format; a real host/drive AKE cert needs a live USB/SCSI capture). What IS
confirmed: first, the P-256/SHA-256 ECDSA primitive and curve constants are
correct (`ecdsa_verify_p256_verifies_a_genuine_aacs2_content_cert` verifies a
real 2.0 content cert under the published CC key); second, the 132-byte
4-byte-header framing MATCHES the step-6 drive-key message (x[4..36] /
y[36..68] / r[68..100] / s[100..132]), the natural 20→32-byte scaling of the
established AACS 1.0 84-byte key-message framing.
What is NOT confirmed: that a *certificate* shares that framing. No known
implementation verifies 2.0 certs against a confirmed layout (the published
spec draft leaves that format ambiguous), and the AACS 2.0 draft architecture
lists the 2.0 host cert (type 0x12) with a 6-byte Host ID + a NEW 4-byte
paired-Device-Key-Set field before the 64-byte public key — implying a
~140–144-byte cert with the pubkey near offset 12–16 and a signed range of
~76–80 bytes, NOT 68. The drive cert (type 0x11) has no paired-DKS field
(~140 bytes, pubkey ~[12..76], signed ~first 76). So the offsets HERE
(pubkey [4..68], signed [..68]) are a best-effort self-consistent
placeholder, chosen to match the confirmed key message; they will almost
certainly move when a real cert lands (see the `#[ignore]` real-cert slot).
This matters for USEFULNESS, not SAFETY: a wrong signed range makes
verification FAIL, and since the caller treats a verify failure as FATAL,
the path fails CLOSED (rejects) rather than trusting anything — never the
reverse.
The `>= 132` guard keeps the `cert[100..132]` slice safe while letting a
full-length cert reach verification (the old `>= 138` guard rejected every
132-byte input unconditionally).
The LA anchor is a PARAMETER, not the compiled-in constant, so a unit test
can drive the whole chain under a test LA keypair (sign a synthetic drive
cert, prove it is accepted / a bad-sig cert is rejected) without the real
anchor's private half, which no one has. Production always threads
`AACS2_LA_PUB_X/Y` from `aacs2_authenticate_p256`.
## Why an unknown drive-cert type must be rejected
This used to be an `if` / `else if` with NO `else`, so an unknown type
(e.g. 0x02) fell straight through: `is_aacs20` stayed false, so the step-6
key-signature check still ran — against `cert_pub_key(drive_cert)`, the
drive's OWN key lifted out of a cert that was never checked against the LA.
A rogue USB bridge could set byte 0 to 0x02, present its own keypair, sign
`host_nonce || drive_key_point`, and the handshake would return Ok with an
attacker-chosen bus key. The chain of trust must not appear to have run
when it did not.
## AACS 2.0 chain-of-trust gate (`aacs2_authenticate_p256_with_agid`)
The SAME shape the AACS 1.0 sibling (`aacs_authenticate_with_agid`)
enforces, now MANDATORY because this path is live. Two distinct rejections,
both BEFORE any drive key is trusted:
**(a) Cert TYPE gate.** An AACS 2.0 drive cert is type 0x11. Anything else
is unexpected on the native P-256 AKE and is REJECTED here, never allowed to
fall through. The earlier `if type==0x11 && !verify` form left NO branch for
a non-0x11 type: a rogue bridge could send a type-0x10 (or any) cert, skip
verification entirely, and still reach the step-6 key check against
`cert_pub_key_p256(drive_cert)` — its OWN key, lifted from a cert the LA
never signed — winning an attacker-chosen bus key. The chain of trust must
not merely LOOK like it ran.
**(b) Cert VERIFY is FATAL.** A type-0x11 cert whose P-256/SHA-256 signature
does not verify under the real 2.0 LA anchor (`AACS2_LA_PUB`, on-curve — see
`la_anchor_keys_are_on_curve`) is REJECTED, not logged-and-continued.
`ecdsa_verify_p256` range/curve-checks every point (`point_on_curve`), so a
forged or off-curve cert key cannot slip past. The previous "non-fatal for
backward compat" behaviour was the exact hole the SECURITY note demanded be
closed before wiring.
## `run_cert_handshake` — cert-agnostic AKE selection
The AACS 1.0 host cert is always present and is tried FIRST: an AACS 2.0
drive accepts a 1.0 host cert for backward compatibility, which is enough to
learn the Volume ID. If the drive REFUSES the 1.0 cert (a plain cert-level
rejection — NOT a dead or wedged bus) and THIS host cert also carries AACS
2.0 (P-256) credentials, fall back to the native P-256 AKE.
This does NOT branch on "do we possess 2.0 certs?" as a feature switch: it
consumes whatever the supplied `HostCert` carries, exactly as the 1.0 path
consumes `hc.certificate`. A host cert without v2 credentials simply has
nothing more to present and keeps its 1.0 error; a host cert WITH them
drives the P-256 AKE the moment the drive asks for it. When a real 2.0 host
cert is supplied at runtime, the P-256 handshake runs — no code change, no
possession gate.
A transport fault on the 1.0 attempt short-circuits the fallback:
re-running the P-256 AKE against a bus that is gone re-issues AGID
invalidation + SEND/REPORT KEY for nothing and buries the real (replug)
cause. The `Err` arm below still classifies it as `Transport`.
## AACS 2.0 (P-256) host-certificate handshake — live-path test plumbing
No genuine 2.0 host cert exists for us to sign with (a side agent is hunting
one; see the `#[ignore]` slot at the end). So the tests below prove the
PLUMBING under a self-generated test LA keypair: a 132-byte drive cert
signed by the test LA is threaded through the SAME code the production
anchor uses, only the anchor differs (a parameter, not a recompile). The
layout mirrored is the code's self-consistent 4-byte-header framing —
header(4)+pub_x(32)+pub_y(32)+sig_r(32)+sig_s(32)=132, LA signs SHA-256 over
cert[..68] — identical to the step-6 drive-key message framing and the
156-byte SEND KEY host-cert payload (4+20 nonce+132 cert).
## `ecdsa_verify_p256_verifies_a_genuine_aacs2_content_cert` — why it matters
SANITY: `ecdsa_verify_p256` + the P-256 curve constants + SHA-256 are correct
against GENUINE AACS 2.0 material. No real 2.0 host/drive AKE cert exists
anywhere on this machine (an exhaustive hunt found none — it needs a live
USB/SCSI AKE capture), but a real AACS 2.0 CONTENT certificate does
(`research/aacs/iso-aacs/CivilWar/Content000.cer`, type 0x10, 232 bytes). It
is signed by the AACS 2.0 *Content Cert* key (P-256/SHA-256, 64-byte
signature over the first `signed_len` bytes) — a DIFFERENT key for a
DIFFERENT purpose than the AKE LA anchor, but it exercises the EXACT same
`ecdsa_verify_p256` primitive `verify_cert_p256` relies on; only the pubkey
and the offsets differ. Proving it verifies here pins that the P-256
ECDSA/SHA-256 math and the `point_on_curve` gate are right, so when a
genuine 2.0 host/drive cert (and its correct signed range) lands, only the
offsets — not the crypto — remain to be fixed.
The CC pubkey is the published AACS 2.0 Content Certificate public key
(P-256). Corrupting any `P256_*` constant, or removing SHA-256, makes this
real-vector verification go red.
## Cert support
- AACS 1.0: custom 160-bit curve, SHA-1, 20-byte keys
- AACS 2.0: an AACS 2.0 drive accepts an AACS 1.0 host cert for backward
compatibility (that yields the Volume ID), and the native P-256/SHA-256
AACS 2.0 AKE is the fallback when the drive refuses the 1.0 cert AND the
supplied host cert carries AACS 2.0 (P-256) credentials. Both paths are
LIVE and share the same chain-of-trust hardening; see
`run_cert_handshake` for the cert-agnostic dispatch.
+16
View File
@@ -0,0 +1,16 @@
# `DriveProfile` field visibility and `allow(dead_code)`
Only `identity` and `signature` are public — they are what the catalog is
public FOR ("is this drive supported?", and the emulator's impersonation).
Everything below them is unlock MECHANISM: the MediaTek firmware image and
the per-drive vendor CDB templates. `Cargo.toml` says this crate is never
published because it carries drive firmware; re-exporting `DriveProfile`
with a `pub firmware: Vec<u8>` handed that firmware, and every vendor CDB,
to any downstream crate that called `ld::profile()`. They are `pub(crate)`
so the mechanism stays inside the unlocker.
`allow(dead_code)`: this is the on-disk catalog schema, and several
captured CDB templates have no in-crate consumer yet. They were `pub`
before, so the compiler saw the (external) use; making them `pub(crate)`
is what exposes them as unread. Deleting them would silently drop fields
from the format the profile-extraction pipeline emits.
+29
View File
@@ -0,0 +1,29 @@
# Module visibility rationale (`src/lib.rs`)
Why each unlocker module is `pub mod` or `mod`, and what's actually public
about it. The consumer never reaches an unlocker impl directly — only through
[`all_unlockers`] — so "public" here means "exposes something other than the
unlocker impl itself."
## `ld`
Public ONLY for its drive-profile catalog (`ld::profiles` / the `Profiles`
object) and, under the `emulation` feature, the unlock-handshake wire format
the bdemu test-emulator needs. The unlocker impl itself (`Mt1959Unlocker`) is
`pub(crate)` — clients still reach unlockers only through `all_unlockers`.
`aacs` and `css` carry no such public catalog, so they stay fully private.
## `renesas`
Public for its `is_renesas` drive-probe, which reports a dead bus as
`Err(UnlockError::Transport)` rather than "not a Renesas drive" — a
distinction the consumer needs before it can trust a negative probe result.
The unlocker impl (`Renesas`) is `pub(crate)` — reached only through
`all_unlockers`.
## `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`.
+9
View File
@@ -0,0 +1,9 @@
# `scsi::mock` — crate-wide mock transport
The crate-wide mock transport. Before this existed, EVERY mock in the crate
returned `Ok` with status 0 and a full `bytes_transferred` — so no test could
ever exercise the three outcomes the contract actually distinguishes, and a
whole class of transport-fault misclassification bugs sat uncaught (a dead bus
read as "this unlocker doesn't apply", a zero-filled buffer read as valid
drive data). This fixture can express all of them, and is the red-before-green
vehicle for the transport-contract fixes across ld / aacs / css / renesas.
+10
View File
@@ -0,0 +1,10 @@
# Why `Unlocked`'s `Debug` impl is hand-written
`Unlocked` is the public carrier that leaves this crate, and both `bus_key`
(the AACS bus key) and `vid` (the Volume ID that feeds VUK derivation) are key
material that must never reach a log or a test-failure message in plaintext.
The `AacsAuth` / `CertHandshake` siblings already redact; a derived `Debug`
here would print the very bytes they hide.
Presence is still observable (`Some`/`None`) so a log can say WHETHER a key
was obtained without revealing WHAT it is.
+151
View File
@@ -25,6 +25,15 @@ pub enum Error {
AacsVidRead,
HandshakeRejected,
VidUnavailable,
/// A handshake step returned FEWER bytes than the protocol step requires.
/// The buffer the step would have parsed is the caller's own zero fill, so
/// consuming it would turn a failed command into a "successful" handshake
/// step built entirely out of zeros.
ShortTransfer {
opcode: u8,
expected: usize,
got: usize,
},
/// A SCSI command failed. `status == SCSI_STATUS_TRANSPORT_FAILURE` with
/// `sense: None` is a transport-layer fault; a CHECK CONDITION carries the
/// parsed [`ScsiSense`].
@@ -54,6 +63,7 @@ impl Error {
Error::AacsVidRead => 7012,
Error::HandshakeRejected => 7013,
Error::VidUnavailable => 7014,
Error::ShortTransfer { .. } => 7015,
Error::Scsi { .. } => 7099,
}
}
@@ -86,3 +96,144 @@ impl From<crate::scsi::ScsiError> for Error {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Every variant has its own stable numeric code. Constructing all 16
/// arms also exercises the `#[allow(dead_code)]` variants that the
/// wired handshake path never builds.
#[test]
fn code_is_stable_per_variant() {
assert_eq!(Error::AacsAgidAlloc.code(), 7001);
assert_eq!(Error::AacsCertRead.code(), 7002);
assert_eq!(Error::AacsCertRejected.code(), 7003);
assert_eq!(Error::AacsCertShort.code(), 7004);
assert_eq!(Error::AacsCertVerify.code(), 7005);
assert_eq!(Error::AacsDataKey.code(), 7006);
assert_eq!(Error::AacsKeyRead.code(), 7007);
assert_eq!(Error::AacsKeyRejected.code(), 7008);
assert_eq!(Error::AacsKeyVerify.code(), 7009);
assert_eq!(Error::AacsNoKeys.code(), 7010);
assert_eq!(Error::AacsVidMac.code(), 7011);
assert_eq!(Error::AacsVidRead.code(), 7012);
assert_eq!(Error::HandshakeRejected.code(), 7013);
assert_eq!(Error::VidUnavailable.code(), 7014);
assert_eq!(
Error::ShortTransfer {
opcode: 0xA4,
expected: 16,
got: 4,
}
.code(),
7015
);
assert_eq!(
Error::Scsi {
opcode: 0xA4,
status: 0x02,
sense: None,
}
.code(),
7099
);
}
/// `scsi_sense` returns the parsed sense for a `Scsi` error carrying one,
/// `None` when the `Scsi` error has none, and `None` for every non-`Scsi`
/// variant.
#[test]
fn scsi_sense_returns_the_parsed_sense_only_for_scsi_variant() {
let sense = ScsiSense {
sense_key: 0x05,
asc: 0x20,
ascq: 0x00,
};
let with_sense = Error::Scsi {
opcode: 0xA4,
status: 0x02,
sense: Some(sense),
};
let sc = with_sense.scsi_sense().unwrap();
assert_eq!(sc.sense_key, sense.sense_key);
assert_eq!(sc.asc, sense.asc);
assert_eq!(sc.ascq, sense.ascq);
let without_sense = Error::Scsi {
opcode: 0xA4,
status: 0xFF,
sense: None,
};
assert!(without_sense.scsi_sense().is_none());
assert!(Error::AacsNoKeys.scsi_sense().is_none());
}
/// `is_scsi_transport_failure` is true only for a `Scsi` error with the
/// transport-failure status AND no sense; a drive CHECK CONDITION and
/// every non-`Scsi` variant are false.
#[test]
fn is_scsi_transport_failure_true_and_false_paths() {
let transport = Error::Scsi {
opcode: 0,
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
};
assert!(transport.is_scsi_transport_failure());
let check_condition = Error::Scsi {
opcode: 0xA4,
status: 0x02,
sense: Some(ScsiSense {
sense_key: 0x05,
asc: 0x20,
ascq: 0x00,
}),
};
assert!(!check_condition.is_scsi_transport_failure());
assert!(!Error::AacsNoKeys.is_scsi_transport_failure());
}
/// `From<ScsiError>` maps a raw transport-layer error into `Error::Scsi`,
/// parsing the sense buffer when present and leaving it `None` otherwise.
#[test]
fn from_scsi_error_maps_status_and_parses_sense() {
let e = Error::from(crate::scsi::ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
match e {
Error::Scsi {
opcode,
status,
sense,
} => {
assert_eq!(opcode, 0);
assert_eq!(status, SCSI_STATUS_TRANSPORT_FAILURE);
assert!(sense.is_none());
}
_ => panic!("expected Error::Scsi"),
}
let mut buf = [0u8; 32];
buf[2] = 0x05;
buf[12] = 0x24;
buf[13] = 0x01;
let e = Error::from(crate::scsi::ScsiError {
status: 0x02,
sense: Some(buf),
});
match e {
Error::Scsi { status, sense, .. } => {
assert_eq!(status, 0x02);
let s = sense.expect("sense parsed");
assert_eq!(s.sense_key, 0x05);
assert_eq!(s.asc, 0x24);
assert_eq!(s.ascq, 0x01);
}
_ => panic!("expected Error::Scsi"),
}
}
}
+1377 -297
View File
File diff suppressed because it is too large Load Diff
+121 -49
View File
@@ -9,69 +9,79 @@ mod error;
mod handshake;
use aes::Aes128;
use aes::cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray};
use aes::cipher::{Array, BlockCipherDecrypt, KeyInit};
use crate::scsi::ScsiTransport;
use crate::{DiscKind, UnlockCtx, UnlockError, Unlocked, Unlocker};
use crate::{DiscKind, HostCert, UnlockCtx, UnlockError, Unlocked, Unlocker};
/// AES-128-ECB decrypt a single 16-byte block — used to decrypt the bus key /
/// read_data_key the drive returns after the handshake.
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
let cipher = Aes128::new(GenericArray::from_slice(key));
let mut block = GenericArray::clone_from_slice(data);
let cipher = Aes128::new(&(*key).into());
let mut block: Array<u8, _> = (*data).into();
cipher.decrypt_block(&mut block);
let mut out = [0u8; 16];
out.copy_from_slice(&block);
out
}
/// The AACS host-certificate unlocker. Matches a Blu-ray/UHD disc
/// (`DiscKind::Aacs`) and runs the cert handshake against the host certs the
/// consumer collected (via [`UnlockCtx::host_certs`]), learning the Volume ID
/// and — on AACS 2.0 — the bus key.
pub struct AacsCert;
/// The AACS host-certificate unlocker — the fallback for a drive with NO vendor
/// unlock (can't be raw-read-enabled), so the bus is removed by a real host-cert
/// AKE + bus key rather than a CDB. The host certs are injected at CONSTRUCTION
/// (the one place certs enter the system). Owns its AKE crypto — no reach into
/// libfreemkv.
pub struct AacsUnlocker {
host_certs: Vec<HostCert>,
}
impl AacsCert {
pub fn new() -> Self {
AacsCert
impl AacsUnlocker {
pub fn new(host_certs: Vec<HostCert>) -> Self {
AacsUnlocker { host_certs }
}
}
impl Default for AacsCert {
fn default() -> Self {
Self::new()
}
}
impl Unlocker for AacsCert {
impl Unlocker for AacsUnlocker {
fn name(&self) -> &'static str {
"AACS"
}
/// 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(
/// Remove AACS bus encryption via the host-cert handshake. Like every other
/// unlocker this DOES unlock the drive — just with a cert + AKE instead of a
/// vendor CDB. `Some` on a successful handshake (VID + bus key learned);
/// `None` on a non-AACS disc, no usable cert, or a rejected handshake (fall
/// through); `Err(Transport)` on a dead bus.
fn unlock(
&self,
scsi: &mut dyn ScsiTransport,
ctx: &UnlockCtx,
) -> std::result::Result<Unlocked, UnlockError> {
if ctx.kind != DiscKind::Aacs {
return Err(UnlockError::NotApplicable);
) -> std::result::Result<Option<Unlocked>, UnlockError> {
if ctx.kind != DiscKind::Aacs || self.host_certs.is_empty() {
// Wrong disc kind, or no host cert to authenticate with — the loop
// falls through to a VID-less / keysource path.
return Ok(None);
}
if ctx.host_certs.is_empty() {
// No host cert to authenticate with — the consumer falls back to a
// VID-less / keysource path.
return Err(UnlockError::NoUsableHostCert);
}
let h = handshake::run_cert_handshake(scsi, ctx.host_certs)?;
Ok(Unlocked {
vid: Some(h.volume_id),
// Host-cert AKE path: bus removal depends on the bus key, not a
// firmware unlock.
bus_key: h.read_data_key,
drive_unlocked: false,
})
crate::fallthrough(
handshake::run_cert_handshake(scsi, &self.host_certs).map(|h| {
// A UHD disc whose bus-key fetch the drive refused (non-transport)
// returns Ok with read_data_key: None + read_data_key_err: Some —
// otherwise indistinguishable from an AACS-1.0 disc. Surface it.
if h.read_data_key.is_none()
&& let Some(code) = h.read_data_key_err
{
tracing::warn!(
target: "freemkv::disc",
phase = "read_data_key_dropped",
error_code = code,
"AACS auth + VID succeeded but the drive served no read_data_key (bus key); \
unlock reports bus_key: None"
);
}
Unlocked {
vid: Some(h.volume_id),
bus_key: h.read_data_key,
}
}),
)
}
}
@@ -79,15 +89,47 @@ impl Unlocker for AacsCert {
mod tests {
use super::*;
/// AES-128-ECB decrypt against the FIPS-197 Appendix B / NIST known-answer
/// test vector: decrypting the published ciphertext with the published key
/// must recover the published plaintext.
#[test]
fn aes_ecb_decrypt_matches_fips197_test_vector() {
let key: [u8; 16] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
0x0E, 0x0F,
];
let ciphertext: [u8; 16] = [
0x69, 0xC4, 0xE0, 0xD8, 0x6A, 0x7B, 0x04, 0x30, 0xD8, 0xCD, 0xB7, 0x80, 0x70, 0xB4,
0xC5, 0x5A,
];
let expected_plaintext: [u8; 16] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD,
0xEE, 0xFF,
];
let out = aes_ecb_decrypt(&key, &ciphertext);
assert_eq!(out, expected_plaintext);
}
/// Decrypting a different ciphertext under the same key must not produce
/// the same plaintext — pins that the function actually decrypts the
/// given block rather than returning a constant.
#[test]
fn aes_ecb_decrypt_varies_with_input() {
let key = [0u8; 16];
let a = aes_ecb_decrypt(&key, &[0u8; 16]);
let b = aes_ecb_decrypt(&key, &[1u8; 16]);
assert_ne!(a, b);
}
fn id() -> crate::DriveId {
crate::DriveId::default()
}
/// `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.
/// Self-guards on the disc kind: on a non-AACS disc `unlock()` declines
/// (`Ok(false)`) WITHOUT touching the transport, even WITH a cert present —
/// so the reason is the kind, not a missing cert.
#[test]
fn unlock_bus_declines_non_aacs_kinds() {
fn declines_non_aacs_kinds() {
struct DeadTransport;
impl ScsiTransport for DeadTransport {
fn execute(
@@ -103,15 +145,41 @@ mod tests {
let id = id();
let mut t = DeadTransport;
for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Css] {
let r = AacsCert::new().unlock_bus(&mut t, &UnlockCtx::new(&id, k, &[]));
assert_eq!(r.unwrap_err(), UnlockError::NotApplicable, "declines {k:?}");
assert!(
AacsUnlocker::new(vec![host_cert()])
.unlock(&mut t, &UnlockCtx::new(&id, k))
.expect("declines")
.is_none(),
"declines {k:?}"
);
}
}
/// With no host certs there is nothing to authenticate with → NoUsableHostCert,
// Full success path through `Unlocker::unlock`: a self-consistent AACS 1.0
// emulator proves the entry point learns `vid`/`bus_key` — and that the cert
// route reports `unlock() == true` (it DOES unlock the drive, via the cert).
#[test]
fn unlock_succeeds_end_to_end() {
let mut t = handshake::tests::DriveEmu::new();
t.serve_data_keys = true;
let id = id();
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
let out = AacsUnlocker::new(vec![host_cert()])
.unlock(&mut t, &ctx)
.expect("auth + VID + data-key reads all succeed")
.expect("the cert route unlocks the drive");
assert_eq!(out.vid, Some([0x5Au8; 16]));
assert!(out.bus_key.is_some());
}
fn host_cert() -> crate::HostCert {
handshake::tests::dummy_cert()
}
/// With no host certs there is nothing to authenticate with → `Ok(false)`,
/// and the transport is never touched.
#[test]
fn no_host_certs_is_no_usable_host_cert() {
fn no_host_certs_declines() {
struct DeadTransport;
impl ScsiTransport for DeadTransport {
fn execute(
@@ -126,7 +194,11 @@ mod tests {
}
let id = id();
let mut t = DeadTransport;
let r = AacsCert::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs, &[]));
assert_eq!(r.unwrap_err(), UnlockError::NoUsableHostCert);
assert!(
AacsUnlocker::new(vec![])
.unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs))
.expect("no certs declines")
.is_none()
);
}
}
+110 -3
View File
@@ -19,6 +19,31 @@ impl Error {
Error::Scsi(_) => 7299,
}
}
/// True if this is a transport-layer SCSI failure (bus dead).
pub fn is_transport_failure(&self) -> bool {
matches!(
self,
Error::Scsi(s)
if s.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && s.sense.is_none()
)
}
}
/// Collapse a SCSI-layer failure from a bus-auth step onto the CSS auth code —
/// but NEVER a transport fault.
///
/// Every step used to do `.map_err(|_| Error::CssAuthFailed)?`, which discarded
/// the `ScsiError` whole. That made the `Transport` arm of the conversion below
/// UNREACHABLE from any real call site: a dead bus arrived at the consumer as
/// `NotApplicable`, so it fell through and kept probing a bus that was gone.
pub fn step_err(e: crate::scsi::ScsiError) -> Error {
let err = Error::Scsi(e);
if err.is_transport_failure() {
err
} else {
Error::CssAuthFailed
}
}
impl From<crate::scsi::ScsiError> for Error {
@@ -31,11 +56,93 @@ impl From<crate::scsi::ScsiError> for Error {
/// "this unlocker didn't apply" (a transport fault still surfaces as Transport).
impl From<Error> for crate::UnlockError {
fn from(e: Error) -> Self {
if e.is_transport_failure() {
crate::UnlockError::Transport
} else {
crate::UnlockError::NotApplicable
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::UnlockError;
use crate::scsi::{SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE, ScsiError};
/// Catches restoring the `.map_err(|_| CssAuthFailed)` that made the
/// `Transport` arm unreachable: a dead bus must classify as `Transport`
/// (consumer aborts), a drive rejection as `NotApplicable` (fall through).
#[test]
fn transport_fault_survives_the_step_mapping() {
let dead = step_err(ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
assert!(dead.is_transport_failure());
assert_eq!(UnlockError::from(dead), UnlockError::Transport);
let refused = step_err(ScsiError {
status: SCSI_STATUS_CHECK_CONDITION,
sense: Some([0u8; 32]),
});
assert!(matches!(refused, Error::CssAuthFailed));
assert_eq!(UnlockError::from(refused), UnlockError::NotApplicable);
}
/// Each variant has its own stable numeric code.
#[test]
fn code_is_stable_per_variant() {
assert_eq!(Error::CssAuthFailed.code(), 7201);
assert_eq!(
Error::Scsi(ScsiError {
status: SCSI_STATUS_CHECK_CONDITION,
sense: Some([0u8; 32]),
})
.code(),
7299
);
}
// False for `CssAuthFailed` and for a `Scsi` error with a sense (a
// drive rejection, not a dead bus) even at transport-failure status;
// true only for the genuine transport fault (status + no sense).
#[test]
fn is_transport_failure_false_paths() {
assert!(!Error::CssAuthFailed.is_transport_failure());
let sensed = Error::Scsi(ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: Some([0u8; 32]),
});
assert!(!sensed.is_transport_failure());
let refusal = Error::Scsi(ScsiError {
status: SCSI_STATUS_CHECK_CONDITION,
sense: None,
});
assert!(!refusal.is_transport_failure());
let dead = Error::Scsi(ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
assert!(dead.is_transport_failure());
}
/// `From<ScsiError>` wraps the transport error unchanged into `Error::Scsi`.
#[test]
fn from_scsi_error_wraps_into_scsi_variant() {
let e = Error::from(ScsiError {
status: SCSI_STATUS_CHECK_CONDITION,
sense: Some([1u8; 32]),
});
match e {
Error::Scsi(s) if s.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE => {
crate::UnlockError::Transport
Error::Scsi(inner) => {
assert_eq!(inner.status, SCSI_STATUS_CHECK_CONDITION);
assert_eq!(inner.sense, Some([1u8; 32]));
}
_ => crate::UnlockError::NotApplicable,
_ => panic!("expected Error::Scsi"),
}
}
}
+658 -263
View File
File diff suppressed because it is too large Load Diff
+739
View File
@@ -0,0 +1,739 @@
//! freemkv — the self-identifying custom-firmware unlocker.
//!
//! Detects a freemkv-firmware drive by issuing the vendor Identity command
//! (sub-function `0x01` of the `0x3C` READ BUFFER "knock" ABI) and checking
//! that the response starts `b"freemkv"` — no bundled profile database is
//! needed, unlike [`crate::ld`], because the firmware self-identifies.
//!
//! The full knock CDB layout, sub-function table, fixed-length reply rules,
//! and toggle-polarity details are documented in `docs/freemkv-abi.md`.
use crate::scsi::{DataDirection, ScsiTransport};
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
/// READ BUFFER (0x3C) opcode every freemkv vendor command hijacks.
const KNOCK_OPCODE: u8 = 0x3C;
/// Knock-mode byte (CDB byte 1) that distinguishes this ABI from an ordinary
/// READ BUFFER mode.
const KNOCK_MODE: u8 = 0x0E;
/// The `C0 DE` knock (CDB bytes 2-3) that marks a freemkv vendor command.
const KNOCK_MAGIC: [u8; 2] = [0xC0, 0xDE];
/// State byte that selects OEM behaviour (clears the feature's RAM flag). The
/// OFF pole of the Region toggle.
const STATE_OFF: u8 = 0x00;
/// State byte that enables the patched behaviour (sets the feature's RAM flag).
/// The ON pole of the Region toggle.
const STATE_ON: u8 = 0x01;
/// Speed sub-function (`0x02`) cap value selecting OEM read-speed behaviour.
#[allow(dead_code)]
const SPEED_CAP_OEM: u8 = 0x00;
/// Speed sub-function (`0x02`) cap value selecting the uncapped / maximum read
/// speed (full riplock lift). `0x01`..=`0xFE` select an intermediate ceiling.
const SPEED_CAP_MAX: u8 = 0xFF;
/// Raw Read (`0x04`) mode: OEM cert enforcement.
#[allow(dead_code)]
const RAW_READ_OFF: u8 = 0x00;
/// Raw Read (`0x04`) mode: "cert is valid" — the drive reports host auth as
/// already succeeded, so a bare `0xAD` fmt `0x80` yields the VID with NO cert
/// and NO AKE. This is the mode this unlocker sets.
const RAW_READ_CERT_VALID: u8 = 0x01;
/// Raw Read (`0x04`) mode: "accept any host cert, revoked or not" — the host
/// still runs the real AKE. Driven by the AACS cert unlocker, not here.
#[allow(dead_code)]
const RAW_READ_ACCEPT_ANY: u8 = 0x02;
/// Vendor sub-functions (CDB byte 4). These numeric values ARE the wire
/// protocol and match `freemkv-firmware`'s `abi.rs::SubFn`.
mod subfn {
/// Identity — detection (read; ignores state).
pub(super) const IDENTITY: u8 = 0x01;
/// Speed / riplock (state byte IS the cap value: `00` = OEM, `01`..=`FF`
/// ceiling, `FF` = max).
pub(super) const SPEED: u8 = 0x02;
/// Region-free (toggle; `01` = RPC region-free).
pub(super) const REGION: u8 = 0x03;
/// Raw Read (transport unlock; state selects the cert-gate mode).
pub(super) const RAW_READ: u8 = 0x04;
/// DumpAll diagnostic RAM read (address big-endian in `cdb[5..9]`).
pub(super) const DUMP_ALL: u8 = 0x09;
}
/// The ASCII magic that leads the Identity (subfn 01) reply — the `RESP_MAGIC`
/// of the canonical `abi.rs`. This is the ENTIRE freemkv-detection mechanism
/// (no bundled profile database — the firmware self-identifies).
const IDENTITY_MARKER: &[u8] = b"freemkv";
// Fixed response length for EVERY freemkv knock command; the drive commits this
// many bytes on data-in regardless of subfn. A short/zero allocation desyncs the
// transfer (ABORTED COMMAND, then a wedged FIFO) — see docs/freemkv-abi.md.
const KNOCK_RESP_LEN: usize = 64;
/// Bytes returned by one DumpAll (subfn 0x09) diagnostic read — the firmware
/// always commits a fixed 64-byte window.
const MEMREAD_LEN: usize = 64;
/// Build the 10-byte knock CDB for a freemkv vendor sub-function:
/// `3C 0E C0 DE <subfn> <state> <len_hi> <len_mid> <len_lo> 00`.
fn build_cdb(subfn: u8, state: u8, alloc_len: u32) -> [u8; 10] {
let len = alloc_len.to_be_bytes();
[
KNOCK_OPCODE,
KNOCK_MODE,
KNOCK_MAGIC[0],
KNOCK_MAGIC[1],
subfn,
state,
len[1],
len[2],
len[3],
0x00,
]
}
// Build the DumpAll (subfn 0x09) CDB: addr packed big-endian into cdb[5..9] (the
// allocation-length field is reused to carry the address for the fixed 64-byte
// window, matching freemkv-firmware's abi.rs::build_memread_cdb) — bypasses build_cdb.
fn build_memread_cdb(addr: u32) -> [u8; 10] {
let a = addr.to_be_bytes();
[
KNOCK_OPCODE,
KNOCK_MODE,
KNOCK_MAGIC[0],
KNOCK_MAGIC[1],
subfn::DUMP_ALL,
a[0],
a[1],
a[2],
a[3],
0x00,
]
}
/// Whether a transport error is a genuine dead bus (a senseless
/// transport-failure status) rather than a drive rejection surfaced through a
/// non-conforming transport (`Err` carrying a sense).
fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
}
// The freemkv custom-firmware unlocker. Detection: subfn-01 Identity, checking
// the response starts "freemkv" — no bundled profile catalog needed, unlike
// crate::ld::LdUnlocker. Stateless: `unlock()` returns what it learned.
#[derive(Default)]
pub struct FreemkvUnlocker;
impl FreemkvUnlocker {
pub fn new() -> Self {
FreemkvUnlocker
}
// Issue the subfn-01 Identity command: Ok(true) if response starts "freemkv",
// Ok(false) if rejected/mismatched (not this firmware). A dead bus is
// Err(Transport) — this is the FIRST command, so a transport fault must abort.
fn identify(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
// Identity MUST request the fixed KNOCK_RESP_LEN (64) allocation like
// every other knock: the firmware commits 64 bytes on every subfn, so a
// short 32-byte read desyncs the data-in phase and wedges the VID read.
let cdb = build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32);
let mut buf = [0u8; KNOCK_RESP_LEN];
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
Ok(r) => {
let matched = r.status == 0
&& r.bytes_transferred >= IDENTITY_MARKER.len()
&& buf[..IDENTITY_MARKER.len()] == *IDENTITY_MARKER;
if !matched {
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_identity_no_match",
"Identity probe did not report freemkv firmware"
);
}
Ok(matched)
}
Err(e) => {
if is_dead_bus(&e) {
tracing::warn!(
target: "freemkv::disc",
phase = "freemkv_identity_transport_fault",
"transport fault on the freemkv Identity probe; aborting"
);
return Err(UnlockError::Transport);
}
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_identity_rejected",
status = e.status,
"freemkv Identity probe rejected by the drive; not this firmware"
);
Ok(false)
}
}
}
// Issue a payload-less sub-function carrying an explicit state byte (toggle
// pole, Speed cap, or Raw Read mode). Ok(()) on GOOD status; NotApplicable
// if rejected; Transport only on a dead bus.
fn send_state(
&self,
scsi: &mut dyn ScsiTransport,
subfn: u8,
state: u8,
) -> std::result::Result<(), UnlockError> {
// Every knock command returns a fixed KNOCK_RESP_LEN data-in payload; a
// toggle reads and DISCARDS it (only the GOOD status matters), but it MUST
// set up the data-in phase or the drive aborts + wedges (see KNOCK_RESP_LEN).
let cdb = build_cdb(subfn, state, KNOCK_RESP_LEN as u32);
let mut buf = [0u8; KNOCK_RESP_LEN];
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
Ok(r) if r.status == 0 => Ok(()),
Ok(r) => {
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_subfn_rejected",
subfn,
state,
status = r.status,
"freemkv sub-function rejected by the drive"
);
Err(UnlockError::NotApplicable)
}
Err(e) => {
if is_dead_bus(&e) {
tracing::warn!(
target: "freemkv::disc",
phase = "freemkv_subfn_transport_fault",
subfn,
"transport fault on a freemkv sub-function; aborting"
);
return Err(UnlockError::Transport);
}
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_subfn_rejected_as_err",
subfn,
status = e.status,
"freemkv sub-function rejected (via Err)"
);
Err(UnlockError::NotApplicable)
}
}
}
/// Speed / riplock (subfn 0x02). The state byte IS the cap: `0x00` restores
/// OEM behaviour, `0xFF` lifts riplock to full speed, `0x01`..=`0xFE` select
/// an intermediate ceiling.
fn set_speed(
&self,
scsi: &mut dyn ScsiTransport,
cap: u8,
) -> std::result::Result<(), UnlockError> {
self.send_state(scsi, subfn::SPEED, cap)
}
/// Region toggle (subfn 0x03). `on` = DVD RPC region-free.
fn set_region_free(
&self,
scsi: &mut dyn ScsiTransport,
on: bool,
) -> std::result::Result<(), UnlockError> {
let state = if on { STATE_ON } else { STATE_OFF };
self.send_state(scsi, subfn::REGION, state)
}
// Raw Read (subfn 0x04) in RAW_READ_CERT_VALID mode: tells the drive host auth
// already succeeded so a bare 0xAD fmt 0x80 returns the VID with no cert/AKE.
// Load-bearing: if rejected, this firmware can't do the one-command unlock.
fn set_raw_read(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<(), UnlockError> {
self.send_state(scsi, subfn::RAW_READ, RAW_READ_CERT_VALID)
}
/// DumpAll diagnostic RAM read (subfn 0x09): return the 64-byte window at
/// `addr`. A host-side diagnostic path only — not used by the unlock flow.
#[allow(dead_code)]
fn dump_ram(
&self,
scsi: &mut dyn ScsiTransport,
addr: u32,
) -> std::result::Result<[u8; MEMREAD_LEN], UnlockError> {
let cdb = build_memread_cdb(addr);
let mut buf = [0u8; MEMREAD_LEN];
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
Ok(r) if r.status == 0 && r.bytes_transferred >= MEMREAD_LEN => Ok(buf),
Ok(_) => Err(UnlockError::NotApplicable),
Err(e) => {
if is_dead_bus(&e) {
Err(UnlockError::Transport)
} else {
Err(UnlockError::NotApplicable)
}
}
}
}
// Full freemkv unlock sequence: 01 Identity (hard gate) → 03 Region → 02 Speed
// (best-effort) → 04 01 Raw Read → bare 0xAD VID (both load-bearing, no
// fallback). See docs/freemkv-abi.md for full failure-mode semantics.
fn full_unlock(
&self,
scsi: &mut dyn ScsiTransport,
) -> std::result::Result<Unlocked, UnlockError> {
// 01 — Identity: must be a freemkv drive.
if !self.identify(scsi)? {
return Err(UnlockError::NotApplicable);
}
// Best-effort feature toggle: only a dead bus aborts.
let best_effort = |r: std::result::Result<(), UnlockError>,
what: &'static str|
-> std::result::Result<(), UnlockError> {
match r {
Ok(()) => Ok(()),
Err(UnlockError::Transport) => Err(UnlockError::Transport),
Err(_) => {
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_feature_unavailable",
feature = what,
"feature unavailable; continuing"
);
Ok(())
}
}
};
// 03 — Region-free (feature).
best_effort(self.set_region_free(scsi, true), "region")?;
// 02 — Speed / riplock lift to full (feature).
best_effort(self.set_speed(scsi, SPEED_CAP_MAX), "speed")?;
// 04 01 — Raw Read "cert valid" (LOAD-BEARING). No fallback: a firmware
// that rejects it can't do the one-command unlock.
match self.set_raw_read(scsi) {
Ok(()) => {}
Err(UnlockError::Transport) => return Err(UnlockError::Transport),
Err(_) => {
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_raw_read_rejected",
"Raw Read (04 01) rejected — cannot unlock this drive"
);
return Err(UnlockError::VidUnavailable);
}
}
// Bare 0xAD VID read — the shared BEST-EFFORT reader (identical to the
// LD/Renesas routes). Raw Read already unlocked the drive, so a VID miss
// must not discard it: only a dead bus propagates (`?`), else `None`.
let vid = crate::vid::read_aacs_vid(scsi)?;
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_unlocked",
has_vid = vid.is_some(),
"freemkv drive unlocked (Raw Read on)"
);
Ok(Unlocked { vid, bus_key: None })
}
}
impl Unlocker for FreemkvUnlocker {
fn name(&self) -> &'static str {
"freemkv"
}
/// Recognise the drive by its Identity knock, lift riplock/region
/// (best-effort), turn on Raw Read (the actual unlock), and read the Volume
/// ID with a bare `0xAD` (best-effort). `Some` when Raw Read succeeded — the
/// drive is unlocked whether or not the VID read did; `None` if it isn't a
/// freemkv drive; `Err(Transport)` on a dead bus. `ctx` is unused: this
/// unlocker self-identifies rather than matching on drive identity.
fn unlock(
&self,
scsi: &mut dyn ScsiTransport,
_ctx: &UnlockCtx,
) -> std::result::Result<Option<Unlocked>, UnlockError> {
crate::fallthrough(self.full_unlock(scsi))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DiscKind;
use crate::scsi::mock::{MockTransport, Reply};
use crate::scsi::{DataDirection, Result, ScsiResult, ScsiTransport};
fn ctx(id: &crate::DriveId) -> UnlockCtx<'_> {
UnlockCtx::new(id, DiscKind::Unknown)
}
/// Serves a fixed payload with a GOOD status (used for the Identity probe).
struct FakeTransport {
payload: Vec<u8>,
}
impl ScsiTransport for FakeTransport {
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],
})
}
}
fn freemkv_identity_payload() -> Vec<u8> {
let mut p = vec![0u8; KNOCK_RESP_LEN];
let s = b"freemkv 0.6.4";
p[..s.len()].copy_from_slice(s);
p
}
/// A well-formed format-0x80 VID structure: 4-byte header, 16-byte VID at
/// offset 4, 16-byte MAC (zeroed on the bare path — it isn't checked). 36 =
/// the fixed `0xAD` fmt-0x80 response length (see `crate::vid`).
fn vid_ds_response(vid: [u8; 16]) -> Vec<u8> {
let mut p = vec![0u8; 36];
p[4..20].copy_from_slice(&vid);
p
}
// ── CDB shape ────────────────────────────────────────────────────────
/// The 10-byte knock CDB layout is a pinned wire-format contract.
#[test]
fn build_cdb_encodes_the_knock_shape() {
let cdb = build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32);
assert_eq!(cdb.len(), 10);
assert_eq!(cdb[0], 0x3C);
assert_eq!(cdb[1], 0x0E);
assert_eq!(cdb[2], 0xC0);
assert_eq!(cdb[3], 0xDE);
assert_eq!(cdb[4], subfn::IDENTITY);
assert_eq!(cdb[5], 0x00);
assert_eq!([cdb[6], cdb[7], cdb[8]], [0x00, 0x00, 0x40]); // 64 (KNOCK_RESP_LEN), 24-bit BE
assert_eq!(cdb[9], 0x00);
}
/// The 24-bit allocation length is big-endian across bytes 6..8.
#[test]
fn build_cdb_encodes_alloc_len_24bit_big_endian() {
let cdb = build_cdb(subfn::RAW_READ, RAW_READ_CERT_VALID, 0x01_2345);
assert_eq!([cdb[6], cdb[7], cdb[8]], [0x01, 0x23, 0x45]);
assert_eq!(cdb[4], subfn::RAW_READ);
assert_eq!(cdb[5], 0x01);
}
/// The sub-function numbers ARE the wire protocol and match the firmware
/// `abi.rs::SubFn`: 01 Identity, 02 Speed, 03 Region, 04 Raw Read, 09 DumpAll.
#[test]
fn subfn_values_match_firmware_abi() {
assert_eq!(subfn::IDENTITY, 0x01);
assert_eq!(subfn::SPEED, 0x02);
assert_eq!(subfn::REGION, 0x03);
assert_eq!(subfn::RAW_READ, 0x04);
assert_eq!(subfn::DUMP_ALL, 0x09);
}
/// The Raw Read mode selectors match the firmware: 00 OEM, 01 cert-valid
/// (this unlocker), 02 accept-any-cert (the AKE path).
#[test]
fn raw_read_modes_are_pinned() {
assert_eq!(RAW_READ_OFF, 0x00);
assert_eq!(RAW_READ_CERT_VALID, 0x01);
assert_eq!(RAW_READ_ACCEPT_ANY, 0x02);
}
/// Exact CDB bytes for each vendor sub-function that build_cdb serves.
#[test]
fn build_cdb_exact_bytes_per_subfn() {
assert_eq!(
build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32),
[0x3C, 0x0E, 0xC0, 0xDE, 0x01, 0x00, 0x00, 0x00, 0x40, 0x00]
);
// Toggles carry the fixed KNOCK_RESP_LEN (64 = 0x40) data-in allocation in
// bytes 6..8 — the host must read the response or the drive wedges.
assert_eq!(
build_cdb(subfn::SPEED, SPEED_CAP_MAX, KNOCK_RESP_LEN as u32),
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0xFF, 0x00, 0x00, 0x40, 0x00]
);
assert_eq!(
build_cdb(subfn::REGION, STATE_ON, KNOCK_RESP_LEN as u32),
[0x3C, 0x0E, 0xC0, 0xDE, 0x03, 0x01, 0x00, 0x00, 0x40, 0x00]
);
assert_eq!(
build_cdb(subfn::RAW_READ, RAW_READ_CERT_VALID, KNOCK_RESP_LEN as u32),
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x01, 0x00, 0x00, 0x40, 0x00]
);
}
// ── Detection ────────────────────────────────────────────────────────
#[test]
fn identify_true_on_freemkv_marker_and_issues_identity_cdb() {
let mut t = MockTransport::always(Reply::good(freemkv_identity_payload()));
assert!(FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
assert_eq!(
t.cdbs[0],
build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32)
);
}
#[test]
fn identify_false_on_non_matching_payload() {
let mut t = FakeTransport {
payload: vec![0u8; KNOCK_RESP_LEN],
};
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
}
#[test]
fn identify_false_on_short_response() {
let mut t = MockTransport::always(Reply::short(freemkv_identity_payload(), 3));
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
}
#[test]
fn identify_false_when_command_rejected() {
let mut t = MockTransport::always(Reply::illegal_request());
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
}
#[test]
fn identify_false_when_command_rejected_as_err() {
let mut t = MockTransport::always(Reply::illegal_request_as_err());
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
}
#[test]
fn identify_transport_fault_aborts() {
let mut t = MockTransport::always(Reply::TransportFault);
assert_eq!(
FreemkvUnlocker::new().identify(&mut t).unwrap_err(),
UnlockError::Transport
);
}
// ── Feature toggles ──────────────────────────────────────────────────
#[test]
fn set_speed_max_issues_the_cap_ff_cdb() {
let mut t = MockTransport::always(Reply::good(vec![]));
FreemkvUnlocker::new()
.set_speed(&mut t, SPEED_CAP_MAX)
.expect("ok");
assert_eq!(
t.cdbs[0],
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0xFF, 0x00, 0x00, 0x40, 0x00]
);
}
#[test]
fn set_speed_intermediate_cap_is_placed_in_state_byte() {
let mut t = MockTransport::always(Reply::good(vec![]));
FreemkvUnlocker::new().set_speed(&mut t, 0x42).expect("ok");
assert_eq!(
t.cdbs[0],
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x42, 0x00, 0x00, 0x40, 0x00]
);
}
#[test]
fn set_region_free_on_issues_the_state_01_cdb() {
let mut t = MockTransport::always(Reply::good(vec![]));
FreemkvUnlocker::new()
.set_region_free(&mut t, true)
.expect("ok");
assert_eq!(
t.cdbs[0],
[0x3C, 0x0E, 0xC0, 0xDE, 0x03, 0x01, 0x00, 0x00, 0x40, 0x00]
);
}
#[test]
fn set_raw_read_issues_the_04_01_cdb() {
let mut t = MockTransport::always(Reply::good(vec![]));
FreemkvUnlocker::new().set_raw_read(&mut t).expect("ok");
assert_eq!(
t.cdbs[0],
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x01, 0x00, 0x00, 0x40, 0x00]
);
}
#[test]
fn toggle_drive_rejection_is_not_applicable() {
let mut t = MockTransport::always(Reply::illegal_request());
let err = FreemkvUnlocker::new().set_raw_read(&mut t).unwrap_err();
assert_eq!(err, UnlockError::NotApplicable);
}
#[test]
fn toggle_transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
let err = FreemkvUnlocker::new()
.set_speed(&mut t, SPEED_CAP_MAX)
.unwrap_err();
assert_eq!(err, UnlockError::Transport);
}
// ── full_unlock / unlock ─────────────────────────────────────────────
// The bare 0xAD VID read itself is tested in crate::vid; here we cover the
// full unlock sequence end-to-end, including the best-effort VID handling.
/// The full unlock runs `01→03→02→04→AD` in order: identity, region, speed,
/// raw-read, bare VID read. This is the load-bearing sequence test.
#[test]
fn full_unlock_issues_identity_region_speed_rawread_then_bare_vid() {
let vid = [0x7Cu8; 16];
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()), // 01 Identity
Reply::good(vec![]), // 03 Region
Reply::good(vec![]), // 02 Speed
Reply::good(vec![]), // 04 01 Raw Read
Reply::good(vid_ds_response(vid)), // 0xAD bare VID
],
Reply::TransportFault,
);
let id = crate::DriveId::default();
let out = FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("no fault")
.expect("Raw Read on ⇒ unlocked");
assert_eq!(out.vid, Some(vid));
// Four vendor knocks then the standard 0xAD read.
assert_eq!(t.cdbs.len(), 5);
let vendor_subfns: Vec<u8> = t.cdbs[..4].iter().map(|c| c[4]).collect();
assert_eq!(vendor_subfns, vec![0x01, 0x03, 0x02, 0x04]);
assert_eq!(t.cdbs[3][5], RAW_READ_CERT_VALID); // Raw Read state 01
assert_eq!(t.cdbs[4][0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
}
/// A missing region/speed feature does not fail the unlock (best-effort).
#[test]
fn full_unlock_tolerates_missing_region_and_speed() {
let vid = [0x3Cu8; 16];
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()), // identity
Reply::illegal_request(), // region unsupported
Reply::illegal_request(), // speed unsupported
Reply::good(vec![]), // raw read
Reply::good(vid_ds_response(vid)), // bare VID
],
Reply::TransportFault,
);
let id = crate::DriveId::default();
let out = FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("no fault")
.expect("unlocked");
assert_eq!(out.vid, Some(vid));
}
/// Raw Read is LOAD-BEARING: if the drive rejects it, this isn't an
/// unlockable freemkv drive, so `unlock()` declines (`Ok(false)`) and falls
/// through — never a hard error.
#[test]
fn declines_when_raw_read_rejected() {
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()), // identity
Reply::good(vec![]), // region
Reply::good(vec![]), // speed
Reply::illegal_request(), // raw read REJECTED
],
Reply::TransportFault,
);
let id = crate::DriveId::default();
assert!(
FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("no fault")
.is_none()
);
}
/// Best-effort VID: Raw Read succeeded (drive unlocked), but the bare VID
/// read was rejected — `unlock()` still returns `true`, with `vid() == None`.
/// This is the fix: the old load-bearing VID aborted the whole unlock here,
/// dropping a genuinely-unlocked drive to the cert path.
#[test]
fn unlocks_without_vid_when_bare_read_fails() {
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()), // identity
Reply::good(vec![]), // region
Reply::good(vec![]), // speed
Reply::good(vec![]), // raw read
Reply::illegal_request(), // bare VID: no medium
],
Reply::TransportFault,
);
let id = crate::DriveId::default();
let out = FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("no fault")
.expect("unlocked despite no VID");
assert_eq!(out.vid, None);
}
#[test]
fn declines_non_freemkv_drive() {
let mut t = MockTransport::always(Reply::illegal_request());
let id = crate::DriveId::default();
assert!(
FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("no fault")
.is_none()
);
}
#[test]
fn transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
let id = crate::DriveId::default();
assert_eq!(
FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
.unwrap_err(),
UnlockError::Transport
);
}
// ── DumpAll (subfn 0x09) ─────────────────────────────────────────────
#[test]
fn dump_ram_builds_memread_cdb_and_returns_the_window() {
let mut payload = vec![0xABu8; MEMREAD_LEN];
payload[0] = 0xEE;
let mut t = MockTransport::always(Reply::good(payload));
let got = FreemkvUnlocker::new()
.dump_ram(&mut t, 0x01F8_1234)
.expect("dump ok");
assert_eq!(got[0], 0xEE);
assert_eq!(
t.cdbs[0],
[0x3C, 0x0E, 0xC0, 0xDE, 0x09, 0x01, 0xF8, 0x12, 0x34, 0x00]
);
}
#[test]
fn build_memread_cdb_packs_address_big_endian_at_5_to_9() {
let cdb = build_memread_cdb(0xDEAD_BEEF);
assert_eq!(cdb[4], subfn::DUMP_ALL);
assert_eq!([cdb[5], cdb[6], cdb[7], cdb[8]], [0xDE, 0xAD, 0xBE, 0xEF]);
assert_eq!(cdb[9], 0x00);
}
#[test]
fn name_is_freemkv() {
assert_eq!(FreemkvUnlocker::new().name(), "freemkv");
}
}
+15
View File
@@ -0,0 +1,15 @@
THIRD-PARTY NOTICE for profiles.json
====================================
`profiles.json` in this directory contains volatile, RAM-only optical-drive
microcode ("LibreDrive") authored and owned by the MakeMKV team (GuinpinSoft
inc.). Each payload embeds its own notice:
"This is volatile RAM-only microcode for LibreDrive by MakeMKV -
http://www.makemkv.com/libredrive/
Copyright (c) MakeMKV team, all rights reserved."
This microcode is third-party proprietary material — Copyright (c) MakeMKV team,
all rights reserved — and is NOT covered by this repository's MIT LICENSE. No
ownership is claimed. See the repository-root NOTICE. Thanks to the MakeMKV team
for LibreDrive: https://www.makemkv.com/ , http://www.makemkv.com/libredrive/ .
+31
View File
@@ -0,0 +1,31 @@
# ld — the drive bus-unlock module
A drive bus-unlock 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 `unlock_features()` /
`unlock_bus()` 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.
## Third-party microcode (`profiles.json`)
`profiles.json` bundles volatile, RAM-only optical-drive microcode
("LibreDrive") authored and owned by the **MakeMKV team** (GuinpinSoft inc.).
Each payload carries its own notice — *"Copyright (c) MakeMKV team, all rights
reserved."* This microcode is **third-party proprietary material and is NOT
covered by this repository's MIT license**; no ownership is claimed. See the
[`NOTICE`](./NOTICE) in this directory and the repository-root `NOTICE`. With
thanks to the MakeMKV team for [LibreDrive](http://www.makemkv.com/libredrive/).
+2 -2
View File
@@ -1,7 +1,7 @@
//! Public unlock-CDB seam.
//!
//! These items expose the *minimum* an external consumer (e.g. the bdemu
//! drive emulator) needs to recognise and answer the LibreDrive unlock
//! drive emulator) needs to recognise and answer the LdUnlocker unlock
//! READ_BUFFER handshake, without that consumer open-coding the
//! handshake internals. The concrete CDB shapes and the verification
//! marker are unlock-handshake details and must live ONLY in this crate.
@@ -15,7 +15,7 @@ pub const UNLOCK_MARKER: &[u8] = b"MMkv";
/// Returns `true` when a READ_BUFFER (`0x3C`) CDB with the given mode
/// (`cdb[1] & 0x1F`) and buffer id (`cdb[2]`) is an unlock-handshake
/// read — i.e. one of the LibreDrive unlock variants.
/// read — i.e. one of the LdUnlocker unlock variants.
///
/// This is the single source of truth for the unlock READ_BUFFER CDB
/// shapes; consumers must call it rather than hardcoding the mode /
+128
View File
@@ -39,6 +39,17 @@ impl std::fmt::Display for Error {
impl std::error::Error for Error {}
impl Error {
/// Stable numeric code (logged). Values are local to this crate and share
/// the crate-wide space with `aacs` (70xx) and `css` (72xx).
pub fn code(&self) -> u16 {
match self {
Error::ProfileParse => 7101,
Error::UnlockFailed => 7102,
Error::SignatureMismatch { .. } => 7103,
Error::Scsi { .. } => 7199,
}
}
/// True if this is a transport-layer SCSI failure (bus dead), as opposed to a
/// drive sense or a logical failure.
pub(crate) fn is_transport_failure(&self) -> bool {
@@ -74,3 +85,120 @@ impl From<Error> for crate::UnlockError {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::UnlockError;
use crate::scsi::{SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE, ScsiError};
/// The abort/fall-through boundary, which had no test at all. Catches
/// widening `is_transport_failure` (every failure would abort every rip) or
/// narrowing it (a dead bus would be probed forever).
#[test]
fn only_a_senseless_transport_status_is_a_transport_failure() {
let dead_bus = Error::Scsi {
opcode: 0x3C,
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
};
assert!(dead_bus.is_transport_failure());
assert_eq!(UnlockError::from(dead_bus), UnlockError::Transport);
// A CHECK CONDITION is the DRIVE refusing, not the bus dying.
let refused = Error::Scsi {
opcode: 0x3C,
status: SCSI_STATUS_CHECK_CONDITION,
sense: Some([0u8; 32]),
};
assert!(!refused.is_transport_failure());
assert_eq!(UnlockError::from(refused), UnlockError::NotApplicable);
// The transport-failure status WITH a sense is a drive that reported
// 0xFF, not a bus fault — it must not abort the consumer.
let odd = Error::Scsi {
opcode: 0x3C,
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: Some([0u8; 32]),
};
assert!(!odd.is_transport_failure());
for e in [
Error::ProfileParse,
Error::UnlockFailed,
Error::SignatureMismatch {
expected: [0; 4],
got: [1; 4],
},
] {
assert!(!e.is_transport_failure(), "{e:?} is not a bus fault");
assert_eq!(UnlockError::from(e), UnlockError::NotApplicable);
}
}
/// `Display` is what shows up in logs — every variant must render without
/// panicking and the SCSI variant must carry the opcode/status through.
#[test]
fn display_renders_every_variant() {
assert_eq!(Error::ProfileParse.to_string(), "drive profile parse error");
assert_eq!(Error::UnlockFailed.to_string(), "firmware unlock failed");
assert_eq!(
Error::SignatureMismatch {
expected: [0; 4],
got: [1; 4],
}
.to_string(),
"signature mismatch"
);
assert_eq!(
Error::Scsi {
opcode: 0x3C,
status: 0x02,
sense: None,
}
.to_string(),
"SCSI error (opcode 0x3c, status 0x02)"
);
}
/// `From<ScsiError>` must carry the status AND sense across the seam —
/// collapsing either one destroys the transport-vs-rejection distinction the
/// whole abort/fall-through split rests on.
#[test]
fn scsi_error_conversion_preserves_status_and_sense() {
let e = Error::from(ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
assert!(e.is_transport_failure());
let e = Error::from(ScsiError {
status: SCSI_STATUS_CHECK_CONDITION,
sense: Some([0u8; 32]),
});
assert!(!e.is_transport_failure());
assert_eq!(UnlockError::from(e), UnlockError::NotApplicable);
}
/// Error codes are stable and distinct — they are the numeric contract the
/// logs carry instead of English.
#[test]
fn error_codes_are_distinct_and_stable() {
let codes = [
Error::ProfileParse.code(),
Error::UnlockFailed.code(),
Error::SignatureMismatch {
expected: [0; 4],
got: [0; 4],
}
.code(),
Error::Scsi {
opcode: 0,
status: 0,
sense: None,
}
.code(),
];
assert_eq!(codes, [7101, 7102, 7103, 7199]);
}
}
+324 -93
View File
@@ -1,4 +1,4 @@
//! ld — the LibreDrive firmware unlocker (MediaTek MT1959).
//! ld — the MediaTek MT1959 firmware unlocker.
//!
//! Self-contained module: it owns the bundled drive profiles, firmware blobs,
//! the WRITE_BUFFER / MODE SELECT upload, the unlock CDBs, and the variant-A /
@@ -6,11 +6,10 @@
//! encryption AT THE DRIVE (the unlocked drive serves clear content) and
//! reporting the OEM Volume ID.
// `cdb` carries ONLY the unlock-handshake wire format that the bdemu emulator
// needs (the real unlocker drives its CDBs from per-drive profile templates, not
// these constants). Compile it only when the `emulation` feature exposes it, so
// it never dead-codes in a normal build.
#[cfg(feature = "emulation")]
// `cdb` is only the bdemu emulator's wire format (real unlocking uses profile
// templates), gated behind `emulation`; also compiled under `cfg(test)` so its
// wire-format tests run in default-feature CI. See docs/ld-mod.md — cdb gating.
#[cfg(any(feature = "emulation", test))]
mod cdb;
mod error;
mod platform;
@@ -21,16 +20,12 @@ use crate::scsi::{DataDirection, ScsiTransport};
use crate::{DriveId, UnlockCtx, UnlockError, Unlocked, Unlocker};
// ── Public profile catalog ──────────────────────────────────────────────────
//
// The catalog of drives the LibreDrive unlocker recognizes is the one piece of
// ld worth exposing publicly: it answers "is this drive supported?" without
// unlocking, and the bdemu test-emulator reads it to impersonate a supported
// drive. The unlock *mechanism* (firmware blobs, upload sequence, CDB wire
// format) stays private — only the catalog and its match result are public.
// Only the catalog is public (supported-drive lookup, used by bdemu); the
// unlock mechanism stays private. See docs/ld-mod.md — public catalog design.
pub use profile::{DriveProfile as Profile, Identity, Platform, ProfileMatch, Profiles};
/// The bundled LibreDrive profile catalog (parsed once, process-cached), or
/// The bundled MT1959 profile catalog (parsed once, process-cached), or
/// `None` if the embedded JSON fails to parse (a build-time bug). Pair with
/// [`Profiles::get`] to look up a specific drive:
/// `freemkv_unlock::ld::profiles().and_then(|p| p.get(&drive_id))`.
@@ -51,17 +46,15 @@ pub fn profile(drive_id: &DriveId) -> Option<ProfileMatch> {
#[cfg(feature = "emulation")]
pub use cdb::{UNLOCK_MARKER, is_unlock_read_buffer};
/// The LibreDrive unlocker. `pub(crate)` — clients reach it only through
/// [`crate::all_unlockers`], never by name (the locked-design contract).
///
/// Matches a drive against the bundled profile database and, on a hit,
/// runs the MediaTek MT1959 firmware-unlock (and disc-speed calibration)
/// handshake over the raw SCSI transport.
pub(crate) struct LibreDrive;
// The MT1959 unlocker. Matches a drive against the bundled profile database and
// runs the firmware-unlock + disc-speed-calibration handshake over raw SCSI.
// Stateless: `unlock()` returns what it learned.
#[derive(Default)]
pub struct LdUnlocker;
impl LibreDrive {
pub(crate) fn new() -> Self {
LibreDrive
impl LdUnlocker {
pub fn new() -> Self {
LdUnlocker
}
}
@@ -69,29 +62,39 @@ impl LibreDrive {
/// drive-info "is this drive supported?" display), or `None`. A pure profile
/// lookup — does NOT touch the drive or unlock anything.
pub(crate) fn firmware_name(id: &DriveId) -> Option<&'static str> {
profile::find_bundled(id).map(|_| "LibreDrive")
profile::find_bundled(id).map(|_| "LD")
}
impl LibreDrive {
/// Read the OEM Volume ID via the matched profile's vendor CDB.
///
/// `Ok(Some(vid))` on a well-formed 36-byte response (signature `00 22 00`,
/// VID at `[4..20]`); `Ok(None)` when there is no OEM-VID CDB or the response
/// is short / bad-signature (the drive is still unlocked, just no VID); `Err`
/// only on a transport fault.
fn read_oem_vid(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<Option<[u8; 16]>> {
impl LdUnlocker {
// Read the OEM Volume ID via the matched profile's vendor CDB (profile passed
// in to avoid a redundant 206-entry catalog scan). Ok(Some) on a well-formed
// response; Ok(None) if unreadable; Err only on a transport fault.
fn read_oem_vid(
&self,
scsi: &mut dyn ScsiTransport,
profile: &profile::DriveProfile,
) -> Result<Option<[u8; 16]>> {
const RESPONSE_LEN: usize = 36;
const EXPECTED_HEADER: [u8; 3] = [0x00, 0x22, 0x00];
let Some(m) = profile::find_bundled(id) else {
return Ok(None);
};
let Some(cdb) = m.profile.read_vid_cdb else {
let Some(cdb) = profile.read_vid_cdb else {
return Ok(None);
};
let mut buf = vec![0u8; RESPONSE_LEN];
let result = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
// Per the transport contract a drive sense arrives as `Ok` with non-zero
// `status`, not `Err` — without this check a CHECK CONDITION reads as a
// successful response and the zero-filled buffer parses as a bogus VID.
if result.status != 0 {
tracing::warn!(
target: "freemkv::disc",
phase = "oem_vid_check_condition",
status = result.status,
"OEM VID CDB returned a drive sense"
);
return Ok(None);
}
if result.bytes_transferred < RESPONSE_LEN {
tracing::warn!(
target: "freemkv::disc",
@@ -116,16 +119,10 @@ impl LibreDrive {
}
}
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 / no profile) returns `NotApplicable`; a
/// transport fault propagates as `Transport`; any other firmware failure also
/// falls through as `NotApplicable`.
impl LdUnlocker {
// The MediaTek firmware unlock. Since it removes AACS at the drive (clear
// content), this one op satisfies both features and bus-removal, so both
// trait methods delegate here. See docs/ld-mod.md — firmware_unlock contract.
fn firmware_unlock(
&self,
scsi: &mut dyn ScsiTransport,
@@ -141,45 +138,64 @@ impl LibreDrive {
}
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);
let mut mt = platform::mt1959::Mt1959::new(m.profile.clone(), is_variant_b);
// 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);
let vid = self.read_oem_vid(scsi, id)?;
Ok(Unlocked {
vid,
bus_key: None,
drive_unlocked: true,
})
// `init` only proves the handshake completed, not that the drive reached
// extended-access state. Reporting unlocked off `init` alone shipped
// ciphertext at rc=0. See docs/ld-mod.md — half-unlock fallback.
if !mt.is_unlocked() {
tracing::warn!(
target: "freemkv::disc",
phase = "firmware_unlock_incomplete",
"firmware handshake completed but the drive is not in the extended-access state; falling through"
);
return Err(UnlockError::NotApplicable);
}
// Prime the per-region speed table. Best-effort (must not fail the unlock)
// but not silent — an unlogged `let _ =` made a failed calibration
// indistinguishable from success in the rip log.
if let Err(e) = mt.probe_disc(scsi) {
// A transport fault here is a dead bus, not a calibration miss — most
// profiles never touch the bus again, so this was the only dead-bus
// signal. See docs/ld-mod.md — probe_disc dead-bus classification.
if e.is_transport_failure() {
tracing::warn!(
target: "freemkv::disc",
phase = "probe_disc_transport_fault",
"transport fault during disc speed calibration; aborting"
);
return Err(UnlockError::Transport);
}
tracing::warn!(
target: "freemkv::disc",
phase = "probe_disc_failed",
transport_failure = false,
"disc speed calibration failed; continuing with the drive's default speed table"
);
}
let vid = self.read_oem_vid(scsi, &m.profile)?;
Ok(Unlocked { vid, bus_key: None })
}
}
impl Unlocker for LibreDrive {
impl Unlocker for LdUnlocker {
fn name(&self) -> &'static str {
"LibreDrive"
"LD"
}
/// 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(
/// Match the drive against the bundled profile database and run the firmware
/// unlock — one op that removes bus encryption at the drive (clear content)
/// and reads the OEM Volume ID (best-effort). `Some` when the firmware
/// handshake reached the extended-access state; `None` for an unknown drive
/// or an incomplete handshake; `Err(Transport)` on a dead bus.
fn unlock(
&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)
) -> std::result::Result<Option<Unlocked>, UnlockError> {
crate::fallthrough(self.firmware_unlock(scsi, ctx))
}
}
@@ -192,7 +208,7 @@ mod tests {
/// Unlock context for a fake drive id (kind/host-certs irrelevant to the
/// firmware unlocker — it keys off the drive identity).
fn ctx(id: &DriveId) -> UnlockCtx<'_> {
UnlockCtx::new(id, DiscKind::Unknown, &[])
UnlockCtx::new(id, DiscKind::Unknown)
}
/// A fake transport that fills the response buffer from a fixed payload and
@@ -235,16 +251,21 @@ mod tests {
}
}
/// A well-formed 36-byte response (signature 00 22 00, VID at [4..20]) parses
/// to `Some(vid)`.
#[test]
fn read_oem_vid_parses_well_formed_response() {
/// The bundled profile of the fixture drive (the one carrying a real
/// `read_vid_cdb`).
fn known_vid_profile() -> profile::DriveProfile {
let m = profile::find_bundled(&known_vid_drive_id()).expect("profile match");
assert!(
m.profile.read_vid_cdb.is_some(),
"test fixture drive must carry an OEM VID CDB"
);
m.profile
}
/// A well-formed 36-byte response (signature 00 22 00, VID at [4..20]) parses
/// to `Some(vid)`.
#[test]
fn read_oem_vid_parses_well_formed_response() {
let mut payload = vec![0u8; 36];
payload[0..3].copy_from_slice(&[0x00, 0x22, 0x00]);
let vid = [0x3Cu8; 16];
@@ -253,8 +274,8 @@ mod tests {
payload,
bytes_transferred: 36,
};
let got = LibreDrive::new()
.read_oem_vid(&mut t, &known_vid_drive_id())
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &known_vid_profile())
.expect("parse ok");
assert_eq!(got, Some(vid), "VID parsed from [4..20]");
}
@@ -266,8 +287,8 @@ mod tests {
payload: vec![0u8; 36],
bytes_transferred: 20,
};
let got = LibreDrive::new()
.read_oem_vid(&mut t, &known_vid_drive_id())
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &known_vid_profile())
.expect("short response is Ok(None)");
assert_eq!(got, None);
}
@@ -281,25 +302,62 @@ mod tests {
payload,
bytes_transferred: 36,
};
let got = LibreDrive::new()
.read_oem_vid(&mut t, &known_vid_drive_id())
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &known_vid_profile())
.expect("bad header is Ok(None)");
assert_eq!(got, None);
}
/// A drive with no matching profile → `read_oem_vid` is `Ok(None)`.
/// Catches dropping the `result.status` check: a CHECK CONDITION arrives as
/// `Ok` per the transport contract, so without it the caller's zero-filled
/// buffer is parsed as a real 36-byte response.
#[test]
fn read_oem_vid_no_profile_is_none() {
fn read_oem_vid_check_condition_is_none_not_a_vid() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::illegal_request());
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &known_vid_profile())
.expect("a drive sense is not a transport fault");
assert_eq!(got, None, "a CHECK CONDITION must never yield a VID");
}
/// Catches swallowing a transport fault in the OEM-VID read: a dead bus must
/// propagate (→ `UnlockError::Transport`), never become `Ok(None)`.
#[test]
fn read_oem_vid_transport_fault_propagates() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::TransportFault);
let err = LdUnlocker::new()
.read_oem_vid(&mut t, &known_vid_profile())
.expect_err("a dead bus must not be Ok(None)");
assert!(err.is_transport_failure());
assert_eq!(UnlockError::from(err), UnlockError::Transport);
}
/// A profile with no OEM-VID CDB → `Ok(None)` without touching the drive.
#[test]
fn read_oem_vid_no_cdb_is_none() {
let mut p = known_vid_profile();
p.read_vid_cdb = None;
let mut t = FakeTransport {
payload: vec![0u8; 36],
bytes_transferred: 36,
};
let got = LibreDrive::new()
.read_oem_vid(&mut t, &make_drive_id("FAKE-VND", "9.99", "XX12345", ""))
.expect("no profile is Ok(None)");
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &p)
.expect("no CDB is Ok(None)");
assert_eq!(got, None);
}
/// Public catalog accessors: `profiles()` returns the bundled catalog and
/// `profile()` is `profiles().and_then(get)` for a known drive.
#[test]
fn public_catalog_accessors_find_the_bundled_fixture_drive() {
assert!(super::profiles().is_some(), "bundled catalog must parse");
let m = super::profile(&known_vid_drive_id()).expect("known fixture drive matches");
assert!(m.profile.read_vid_cdb.is_some());
}
/// `unlock_features` on a drive with no matching profile → `NotApplicable`
/// (fall through), short-circuiting before any firmware handshake.
#[test]
@@ -308,12 +366,185 @@ mod tests {
payload: vec![0u8; 36],
bytes_transferred: 36,
};
let err = LibreDrive::new()
.unlock_features(
let unlocked = LdUnlocker::new()
.unlock(
&mut t,
&ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")),
)
.expect_err("no profile → NotApplicable");
assert_eq!(err, UnlockError::NotApplicable);
.expect("no profile → declines, not a hard error");
assert!(unlocked.is_none());
}
// THE defect-1 test: response carries the signature + primary marker but not
// the secondary one, so init() succeeds but the drive isn't in extended-access
// state. Catches removing the `is_unlocked()` gate in `firmware_unlock`.
#[test]
fn half_unlocked_drive_falls_through_instead_of_claiming_unlocked() {
use crate::scsi::mock::{MockTransport, Reply};
let id = known_vid_drive_id();
let sig = profile::find_bundled(&id)
.expect("profile")
.profile
.signature;
// 64-byte unlock response: signature + primary marker "MMkv" at [12..16],
// secondary marker at [16..20] left zeroed.
let mut resp = vec![0u8; 64];
resp[0..4].copy_from_slice(&sig);
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]);
let mut t = MockTransport::always(Reply::good(resp));
let unlocked = LdUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("a half-unlock declines, not a hard error");
assert!(
unlocked.is_none(),
"a half-unlocked drive must fall through to cert-auth"
);
}
/// The whole-unlock happy path still reports unlocked when BOTH firmware
/// markers are present — the `is_unlocked()` gate must not have made a
/// genuinely unlocked drive fall through.
#[test]
fn fully_unlocked_drive_reports_unlocked() {
use crate::scsi::mock::{MockTransport, Reply};
let id = known_vid_drive_id();
let sig = profile::find_bundled(&id)
.expect("profile")
.profile
.signature;
let mut resp = vec![0u8; 64];
resp[0..4].copy_from_slice(&sig);
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]);
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]);
let mut t = MockTransport::always(Reply::good(resp));
assert!(
LdUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("no fault")
.is_some(),
"both markers → unlocked"
);
}
// THE probe-disc dead-bus test: bus dies during speed calibration after a
// full unlock; must abort with Transport, not report a successful unlock.
// See docs/ld-mod.md — probe_disc dead-bus test / mutation notes.
#[test]
fn transport_fault_during_probe_disc_is_transport_not_a_successful_unlock() {
let id = known_vid_drive_id();
let sig = profile::find_bundled(&id)
.expect("profile")
.profile
.signature;
// A drive that unlocks fully but whose bus dies on the speed probe.
struct ProbeFaultsDrive {
resp: Vec<u8>,
}
impl ScsiTransport for ProbeFaultsDrive {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
// READ_BUFFER (0x3C) / SUB_CMD_PROBE (0x14) is the speed probe.
if cdb.first() == Some(&0x3C) && cdb.get(3) == Some(&0x14) {
return Err(crate::scsi::ScsiError {
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
}
let n = self.resp.len().min(data.len());
data[..n].copy_from_slice(&self.resp[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
})
}
}
let mut resp = vec![0u8; 64];
resp[0..4].copy_from_slice(&sig);
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]); // primary marker
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]); // secondary marker
let mut t = ProbeFaultsDrive { resp };
let err = LdUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect_err("a dead bus during probe must abort, not report success");
assert_eq!(err, UnlockError::Transport);
}
// A drive-sense (not a dead bus) rejecting the speed probe is a genuine
// calibration miss: warn-and-continue on the default speed table, still
// reporting a full unlock. Non-transport sibling of the probe-fault test.
#[test]
fn drive_sense_during_probe_disc_still_reports_a_successful_unlock() {
let id = known_vid_drive_id();
let sig = profile::find_bundled(&id)
.expect("profile")
.profile
.signature;
let mut resp = vec![0u8; 64];
resp[0..4].copy_from_slice(&sig);
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]); // primary marker
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]); // secondary marker
struct ProbeSenseDrive {
resp: Vec<u8>,
}
impl ScsiTransport for ProbeSenseDrive {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
// READ_BUFFER (0x3C) / SUB_CMD_PROBE (0x14) is the speed probe.
if cdb.first() == Some(&0x3C) && cdb.get(3) == Some(&0x14) {
return Ok(ScsiResult {
status: 0x02,
bytes_transferred: 0,
sense: [0u8; 32],
});
}
let n = self.resp.len().min(data.len());
data[..n].copy_from_slice(&self.resp[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
})
}
}
let mut t = ProbeSenseDrive { resp };
assert!(
LdUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("a calibration miss must not fail the whole unlock")
.is_some()
);
}
/// Catches classifying a dead bus as "not this unlocker's drive": the very
/// first unlock command faulting at the transport layer must abort the
/// consumer (`Transport`), not fall through to the next unlocker.
#[test]
fn transport_fault_during_unlock_is_transport_not_not_applicable() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::TransportFault);
let err = LdUnlocker::new()
.unlock(&mut t, &ctx(&known_vid_drive_id()))
.expect_err("dead bus");
assert_eq!(err, UnlockError::Transport);
}
}
+41 -3
View File
@@ -1,4 +1,4 @@
//! Platform-specific drive unlock + disc probing (LibreDrive internals).
//! Platform-specific drive unlock + disc probing (LdUnlocker internals).
pub mod mt1959;
@@ -17,9 +17,47 @@ pub(crate) trait PlatformDriver: Send {
#[allow(dead_code)]
fn is_ready(&self) -> bool;
/// True if the drive is currently in the extended-access state.
#[allow(dead_code)]
/// True if the drive is currently in the extended-access state. THE gate
/// `LdUnlocker::firmware_unlock` reports `drive_unlocked` from — `init()`
/// returning `Ok` only means the handshake completed, not that the drive
/// serves clear content.
fn is_unlocked(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scsi::mock::{MockTransport, Reply};
// Minimal driver taking the `PlatformDriver` default `is_unlocked`, so
// the default's `false` path is exercised (real drivers override it).
struct StubDriver;
impl PlatformDriver for StubDriver {
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
scsi.execute(&[0], crate::scsi::DataDirection::None, &mut [], 0)?;
Ok(())
}
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
scsi.execute(&[0], crate::scsi::DataDirection::None, &mut [], 0)?;
Ok(())
}
fn is_ready(&self) -> bool {
true
}
}
#[test]
fn default_is_unlocked_is_false_and_trait_methods_dispatch() {
let mut drv = StubDriver;
let mut scsi = MockTransport::always(Reply::good(vec![]));
assert!(drv.init(&mut scsi).is_ok());
assert!(drv.probe_disc(&mut scsi).is_ok());
assert!(drv.is_ready());
assert!(!drv.is_unlocked());
}
}
File diff suppressed because it is too large Load Diff
+41 -10
View File
@@ -21,9 +21,8 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
}
// Upload firmware via WRITE_BUFFER. The CDB's length is a 24-bit field;
// if the blob exceeds that, the encoded length would silently disagree
// with the bytes actually sent (`data`). Reject rather than upload a
// length-mismatched command.
// reject blobs that would exceed it rather than send a length-mismatched
// command.
let len = firmware.len();
if len > WRITE_BUFFER_MAX_LEN {
return Err(crate::ld::error::Error::UnlockFailed);
@@ -43,7 +42,9 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
let mut data = firmware.clone();
scsi.execute(&cdb, DataDirection::ToDevice, &mut data, 30_000)?;
// Verify firmware loaded (non-fatal — different buffer_id 0x45)
// Verify the firmware loaded (non-fatal — different buffer_id 0x45). No
// documented expected payload exists, so the outcome is traced rather
// than asserted, instead of silently discarded before the unlock retries.
let verify_cdb = [
super::SCSI_READ_BUFFER,
super::MODE_A,
@@ -57,17 +58,47 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
0x00,
];
let mut verify_resp = [0u8; super::VALIDATE_RESPONSE_SIZE as usize];
let _ = scsi.execute(
match scsi.execute(
&verify_cdb,
DataDirection::FromDevice,
&mut verify_resp,
5_000,
);
) {
Ok(r) if r.status == 0 && r.bytes_transferred == verify_resp.len() => {
tracing::debug!(
target: "freemkv::disc",
phase = "mt1959a_fw_verify_ok",
"variant-A firmware verify read returned a full response"
);
}
Ok(r) => {
tracing::warn!(
target: "freemkv::disc",
phase = "mt1959a_fw_verify_bad",
status = r.status,
bytes_transferred = r.bytes_transferred,
"variant-A firmware verify read did not return a full good response"
);
}
// A dead bus mid-upload must abort, not be swallowed into the unlock
// retries below.
Err(e) => {
let err = crate::ld::error::Error::from(e);
tracing::warn!(
target: "freemkv::disc",
phase = "mt1959a_fw_verify_failed",
transport_failure = err.is_transport_failure(),
"variant-A firmware verify read failed"
);
if err.is_transport_failure() {
return Err(err);
}
}
}
// Double unlock after firmware upload. The first establishes the
// unlock and is fatal on failure; the second is a confirmation pass and
// is best-effort (matching variant B), so a benign hiccup on the
// redundant call doesn't fail an already-successful unlock.
// Double unlock after firmware upload: first is fatal on failure, second
// is a best-effort confirmation pass (matching variant B) so a benign
// hiccup there doesn't fail an already-successful unlock.
mt.do_unlock(scsi)?;
let _ = mt.do_unlock(scsi);
Ok(())
+73 -25
View File
@@ -13,18 +13,54 @@ const FIRMWARE_EXTRA: [u8; 16] = [0; 16];
/// that drive — real profiles must supply their own (see `DriveProfile`).
const VENDOR_VERIFY: [u8; 10] = [0xF1, 0x01, 0x02, 0x00, 0x0D, 0x30, 0x01, 0xF3, 0xAD, 0x23];
// See docs/variant-b-firmware-upload.md — trace_step rationale.
// Swallows everything except a transport fault; these steps are advisory,
// the unlock retries below are the real gate.
fn trace_step(phase: &'static str, r: crate::scsi::Result<crate::scsi::ScsiResult>) -> Result<()> {
match r {
Ok(res) if res.status == 0 => {
tracing::debug!(
target: "freemkv::disc",
phase,
bytes_transferred = res.bytes_transferred,
"firmware upload step ok"
);
Ok(())
}
Ok(res) => {
tracing::warn!(
target: "freemkv::disc",
phase,
status = res.status,
"firmware upload step returned a drive sense"
);
Ok(())
}
Err(e) => {
let err = crate::ld::error::Error::from(e);
tracing::warn!(
target: "freemkv::disc",
phase,
transport_failure = err.is_transport_failure(),
"firmware upload step failed"
);
if err.is_transport_failure() {
return Err(err);
}
Ok(())
}
}
}
pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Result<()> {
let firmware = &mt.profile.firmware;
if firmware.is_empty() {
return Err(crate::ld::error::Error::UnlockFailed);
}
// Step 1: Upload the firmware via MODE SELECT. The profile's `firmware` is
// the exact per-drive image — extracted at the drive's own load-CDB length
// (2192..2528 bytes; the old fixed 0x9C0 truncated some drives and over-read
// others into blob strings). Upload all of it. MODE SELECT(10)'s
// parameter-list length is 16-bit, so reject only a blob that can't be
// expressed in the CDB.
// Step 1: Upload the firmware via MODE SELECT (see docs/variant-b-firmware-upload.md).
// MODE SELECT(10)'s parameter-list length is 16-bit, so reject only a
// blob that can't be expressed in the CDB.
let write_len = firmware.len();
if write_len > u16::MAX as usize {
return Err(crate::ld::error::Error::UnlockFailed);
@@ -58,12 +94,15 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
0x00,
];
let mut meta_resp = [0u8; 16];
let _ = scsi.execute(
&read_meta_cdb,
DataDirection::FromDevice,
&mut meta_resp,
5_000,
);
trace_step(
"mt1959b_fw_meta",
scsi.execute(
&read_meta_cdb,
DataDirection::FromDevice,
&mut meta_resp,
5_000,
),
)?;
// Step 3: Write extra firmware data (all zeros)
let write_extra_cdb = [
@@ -79,24 +118,33 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
0x00,
];
let mut data2 = FIRMWARE_EXTRA.to_vec();
let _ = scsi.execute(&write_extra_cdb, DataDirection::ToDevice, &mut data2, 5_000);
trace_step(
"mt1959b_fw_extra",
scsi.execute(&write_extra_cdb, DataDirection::ToDevice, &mut data2, 5_000),
)?;
// Step 4: Vendor verify (0xF1 — B-only, not standard SCSI). PER-DRIVE: take
// it from the profile (39 distinct values across the 140 B drives). The
// const is only a legacy fallback — it carries one drive's token.
// Step 4: Vendor verify (0xF1 — B-only, not standard SCSI), per-drive.
// See docs/variant-b-firmware-upload.md for why the outcome is traced
// rather than discarded.
let verify_cdb = mt.profile.fw_verify_cdb.unwrap_or(VENDOR_VERIFY);
let mut dummy = [0u8; 0];
let _ = scsi.execute(&verify_cdb, DataDirection::None, &mut dummy, 5_000);
trace_step(
"mt1959b_fw_verify",
scsi.execute(&verify_cdb, DataDirection::None, &mut dummy, 5_000),
)?;
// Step 5: Unlock retries (up to 5, then a final fatal attempt). On a
// successful unlock we issue one confirmation pass; its result is
// intentionally best-effort — the first call already established the
// unlock state, so a hiccup on the redundant confirmation must not fail
// an otherwise-good unlock.
// Step 5: Unlock retries (up to 5, then a final fatal attempt). The
// confirmation pass after success is intentionally best-effort — see
// docs/variant-b-firmware-upload.md.
for _attempt in 0..5 {
if mt.do_unlock(scsi).is_ok() {
let _ = mt.do_unlock(scsi);
return Ok(());
match mt.do_unlock(scsi) {
Ok(_) => {
let _ = mt.do_unlock(scsi);
return Ok(());
}
// A dead bus will not recover across five more unlock attempts.
Err(e) if e.is_transport_failure() => return Err(e),
Err(_) => {}
}
}
mt.do_unlock(scsi)?;
+140 -81
View File
@@ -3,7 +3,7 @@
use crate::ld::error::{Error, Result};
use serde::Deserialize;
/// The LibreDrive profile catalog — the set of optical drives the firmware
/// The LdUnlocker profile catalog — the set of optical drives the firmware
/// unlocker recognizes, keyed by chipset + variant. Loaded from the bundled
/// JSON; the public entry point is [`crate::ld::profiles`].
#[derive(Debug, Deserialize)]
@@ -19,7 +19,7 @@ pub struct Profiles {
impl Profiles {
/// The profile matching a drive identity, if this catalog supports that
/// drive. Two-pass per platform section: exact (incl. firmware date) then a
/// looser vendor/revision/vendor-specific match. See [`find_by_drive_id`].
/// looser vendor/revision/vendor-specific match. See `find_by_drive_id`.
pub fn get(&self, drive_id: &crate::DriveId) -> Option<ProfileMatch> {
find_by_drive_id(self, drive_id)
}
@@ -41,7 +41,13 @@ pub struct Identity {
}
/// Per-drive profile.
#[derive(Debug, Clone, Deserialize)]
///
/// Only `identity` and `signature` are public; everything else (firmware
/// image, per-drive vendor CDB templates) is `pub(crate)` unlock mechanism
/// that must stay inside this unpublished crate.
/// See docs/drive-profile-visibility.md — field-visibility and allow(dead_code) rationale.
#[allow(dead_code)]
#[derive(Clone, Deserialize)]
pub struct DriveProfile {
pub identity: Identity,
/// Expected first 4 bytes of the drive's unlock response — the
@@ -53,48 +59,62 @@ pub struct DriveProfile {
/// firmware-load step). JSON-encoded as standard base64; empty when
/// the profile carries no firmware blob.
#[serde(default, deserialize_with = "deserialize_base64")]
pub firmware: Vec<u8>,
pub(crate) firmware: Vec<u8>,
// ── OEM-extended-access CDB templates ──────────────────────────────
//
// All optional — older profile blobs that pre-date the CDB capture
// pipeline simply omit these fields and decode as `None`. Encoded
// in the JSON as lowercase hex strings without separators
// (e.g. `"3c014410e29100002400"` for a 10-byte CDB).
// All optional (`None` if pre-capture-pipeline). Hex strings, no
// separators, e.g. `"3c014410e29100002400"`.
#[serde(default)]
pub unlock_init_value: u8,
pub(crate) unlock_init_value: u8,
#[serde(default)]
pub unlock_response_size: u8,
pub(crate) unlock_response_size: u8,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read_vid_cdb: Option<[u8; 10]>,
pub(crate) read_vid_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read_disc_keys_cdb: Option<[u8; 10]>,
pub(crate) read_disc_keys_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_12")]
pub drive_nominal_speed_cdb: Option<[u8; 12]>,
pub(crate) drive_nominal_speed_cdb: Option<[u8; 12]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_12")]
pub set_speed_max_cdb: Option<[u8; 12]>,
pub(crate) set_speed_max_cdb: Option<[u8; 12]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read10_raw_2sec_cdb: Option<[u8; 10]>,
pub(crate) read10_raw_2sec_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read10_raw_1sec_cdb: Option<[u8; 10]>,
pub(crate) read10_raw_1sec_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read_buffer_verify_cdb: Option<[u8; 10]>,
pub(crate) read_buffer_verify_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub write_buffer_cdb: Option<[u8; 10]>,
pub(crate) write_buffer_cdb: Option<[u8; 10]>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub read_buffer_unlock_cdb: Option<[u8; 10]>,
pub(crate) read_buffer_unlock_cdb: Option<[u8; 10]>,
/// Variant-B vendor verify (0xF1) CDB. PER-DRIVE: 39 distinct values across
/// the 140 B drives, so it CANNOT be a hardcoded constant. `variant_b`'s old
/// `VENDOR_VERIFY` const was one drive's token, wrong for the other ~139.
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
pub fw_verify_cdb: Option<[u8; 10]>,
pub(crate) fw_verify_cdb: Option<[u8; 10]>,
// Per-drive identifier tables — variable-length hex strings.
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes")]
pub speed_zone_table: Option<Vec<u8>>,
pub(crate) speed_zone_table: Option<Vec<u8>>,
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes")]
pub speed_calc_table: Option<Vec<u8>>,
pub(crate) speed_calc_table: Option<Vec<u8>>,
}
// Hand-written, REDACTING Debug: a derived one would recurse through the
// public `Profiles` catalog and print raw firmware + vendor CDB bytes into
// logs. Show only identity + signature; collapse firmware to a length.
impl std::fmt::Debug for DriveProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DriveProfile")
.field("identity", &self.identity)
.field("signature", &self.signature)
.field(
"firmware",
&format_args!("[{} bytes redacted]", self.firmware.len()),
)
.field("cdb_templates", &"[redacted]")
.finish()
}
}
/// Chipset + variant — determined by which section the profile was found in.
@@ -129,23 +149,16 @@ pub struct ProfileMatch {
pub platform: Platform,
}
// ── Parsing ────────────────────────────────────────────────────────────
/// Decode an even-length ASCII hex string into bytes.
///
/// Operates on raw bytes rather than `&str` char-boundary slices: a
/// non-ASCII input (e.g. a hand-edited profile with a multi-byte char)
/// could otherwise have `&s[i..i+2]` land inside a UTF-8 char boundary and
/// panic. Hex is ASCII, so any non-ASCII or non-hex byte simply fails to
/// decode. The error is a stable, language-neutral token (`"hex"`), not a
/// translatable English message.
// ── Parsing: decode hex on raw bytes (not `&str` slices) so non-ASCII
// can't land `&s[i..i+2]` inside a UTF-8 char boundary and panic — it
// just fails to decode with `"hex"`.
fn decode_hex(s: &str) -> std::result::Result<Vec<u8>, &'static str> {
let bytes = s.as_bytes();
if bytes.len() % 2 != 0 {
if !bytes.len().is_multiple_of(2) {
return Err("hex");
}
let mut out = Vec::with_capacity(bytes.len() / 2);
for pair in bytes.chunks_exact(2) {
for pair in bytes.as_chunks::<2>().0.iter() {
let hi = (pair[0] as char).to_digit(16).ok_or("hex")?;
let lo = (pair[1] as char).to_digit(16).ok_or("hex")?;
out.push((hi * 16 + lo) as u8);
@@ -185,9 +198,7 @@ where
}
// ── Fixed-length hex deserializers for CDB templates ────────────────────
//
// Profile JSON encodes CDBs as lowercase hex strings without separators.
// An empty string / null / missing field decodes as `None`.
// Lowercase hex strings, no separators; empty/null/missing decodes as `None`.
fn parse_hex_bytes(s: &str) -> std::result::Result<Vec<u8>, &'static str> {
decode_hex(s)
@@ -266,7 +277,20 @@ pub fn bundled() -> Option<&'static Profiles> {
use std::sync::OnceLock;
static CACHE: OnceLock<Option<Profiles>> = OnceLock::new();
CACHE
.get_or_init(|| load_from_str(BUNDLED_PROFILES).ok())
.get_or_init(|| match load_from_str(BUNDLED_PROFILES) {
Ok(p) => Some(p),
// `None` caches permanently; unlogged that's indistinguishable
// from a genuinely uncataloged drive, so log once, loudly, here.
Err(e) => {
tracing::error!(
target: "freemkv::disc",
phase = "bundled_profiles_parse_failed",
error_code = e.code(),
"bundled LdUnlocker profile catalog failed to parse; no drive can match"
);
None
}
})
.as_ref()
}
@@ -285,13 +309,13 @@ fn load_from_str(data: &str) -> Result<Profiles> {
/// Find a profile matching a drive's INQUIRY fields.
///
/// 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.
/// Per platform section (MT1959-A, then MT1959-B, then Renesas). A drive that
/// reports a `product_id` matches ONLY on the full identity including it — if
/// no cataloged profile matches exactly, the drive is treated as uncataloged
/// (no match), never bound to a same-vendor/revision sibling. The looser
/// four-field pass (vendor/revision/vendor_specific/firmware_date) runs only
/// when the drive reports no `product_id` at all. 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();
@@ -304,27 +328,32 @@ pub fn find_by_drive_id(profiles: &Profiles, drive_id: &crate::DriveId) -> Optio
(Platform::Mt1959B, &profiles.mt1959_b),
(Platform::Renesas, &profiles.renesas),
] {
if !prod.is_empty() {
if let Some(p) = list.iter().find(|p| {
if !prod.is_empty()
&& 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,
});
}
})
{
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
}) {
// The product_id-blind pass runs ONLY when the drive reports none: a
// drive that reports a product_id but misses the exact match is an
// uncataloged variant, else it'd bind to a sibling's wrong firmware.
if prod.is_empty()
&& 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,
@@ -386,6 +415,16 @@ mod tests {
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]);
// A REPORTED product_id that matches no cataloged entry must NOT fall
// through to the four-field pass and bind to a sibling (MODEL-A/B);
// an uncataloged variant is no match, not a wrong match.
let mut idc = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
idc.product_id = "MODEL-C".to_string();
assert!(
find_by_drive_id(&profiles, &idc).is_none(),
"an uncataloged product_id must not mis-bind to a same-vendor sibling",
);
}
#[test]
@@ -488,10 +527,8 @@ mod tests {
assert_eq!(parse_hex4("aabbccdd").unwrap(), [0xaa, 0xbb, 0xcc, 0xdd]);
}
/// Platform::name() returns stable, non-empty, language-neutral identifiers.
/// These strings are logged and keyed on in caller code; changing them is a
/// breaking change.
/// Mutation: swapping Mt1959A and Mt1959B names silently misroutes firmware upload.
// Platform::name() strings are logged/keyed by callers; changing them is
// a breaking change. Mutation: swapping A/B names misroutes firmware upload.
#[test]
fn platform_name_is_stable() {
// The exact strings are part of the public stable API (logged/keyed).
@@ -500,11 +537,9 @@ mod tests {
assert_eq!(Platform::Renesas.name(), "Renesas");
}
/// find_by_drive_id: exact match (including firmware_date) wins over loose match.
/// Spec: two-pass — first an exact match including firmware_date, then looser.
/// Build two synthetic Profiles entries that differ only by firmware_date,
/// and verify the correct one is selected.
/// Mutation: doing only the loose pass would return the first entry regardless of date.
// find_by_drive_id: two-pass — exact match (incl. firmware_date) wins
// over loose. Mutation: skipping the exact pass returns the first entry
// regardless of date.
#[test]
fn find_by_drive_id_exact_date_wins_over_loose() {
use serde_json::json;
@@ -596,10 +631,8 @@ mod tests {
assert!(matches!(result, Err(Error::ProfileParse)));
}
/// Bundled profiles is non-empty (mt1959_a has at least one entry).
/// This pins the embedded JSON: if profiles.json is accidentally emptied
/// or truncated, this test goes red.
/// Mutation: clearing profiles.json would make this fail.
// Pins the embedded JSON: if profiles.json is emptied/truncated, this
// goes red.
#[test]
fn bundled_profiles_has_entries() {
let profiles = load_bundled().unwrap();
@@ -609,10 +642,8 @@ mod tests {
);
}
/// deserialization of a profile with missing optional CDB fields
/// produces None for those fields (not a parse error).
/// Spec: all CDB template fields are `#[serde(default)]` — they are optional.
/// Mutation: making a CDB field required breaks backward-compat with old blobs.
// All CDB fields are `#[serde(default)]`. Mutation: making one required
// breaks backward-compat with old blobs.
#[test]
fn profile_optional_cdb_fields_default_to_none() {
use serde_json::json;
@@ -660,10 +691,38 @@ mod tests {
);
}
/// deserialize_hex4 of an empty string must produce [0;4] without error.
/// This matches `deserialize_hex4`'s explicit early-return for empty strings.
/// Mutation: treating empty string as an error prevents profiles where signature
/// was not captured from loading.
// `Debug` must not render firmware/CDB bytes. Mutation: restoring
// `#[derive(Debug)]` prints them (e.g. the 0xEE marker below), goes red.
#[test]
fn drive_profile_debug_redacts_firmware_and_cdbs() {
use serde_json::json;
let profiles: Profiles = serde_json::from_str(
&json!({
"mt1959_a": [{
"identity": {"vendor_id":"VIS","product_revision":"1.00",
"vendor_specific":"AA00000","firmware_date":"200001010000"},
"signature":"aabbccdd",
"firmware":"7u7u7u7u", // base64 → six 0xEE bytes
"read_vid_cdb":"3c014410e29100002400"
}]
})
.to_string(),
)
.unwrap();
let s = format!("{:?}", profiles.mt1959_a[0]);
// Firmware byte 0xEE renders as `238` under a derived Debug.
assert!(!s.contains("238"), "firmware bytes must not appear: {s}");
assert!(
!s.contains("read_vid_cdb"),
"CDB templates must not appear: {s}"
);
assert!(s.contains("redacted"), "must mark redaction: {s}");
// The PUBLIC fields stay visible.
assert!(s.contains("VIS"), "identity must remain visible: {s}");
}
// deserialize_hex4("") must produce [0;4]. Mutation: treating empty as
// an error blocks profiles with no captured signature from loading.
#[test]
fn profile_empty_signature_deserialises_as_zeroes() {
use serde_json::json;
+196 -76
View File
@@ -1,32 +1,44 @@
//! freemkv-unlock — the unlock layer for the freemkv toolchain.
//!
//! An **unlocker removes a drive-level bus-encryption barrier** so the drive
//! serves readable (de-bus'd / de-scrambled) sectors. Content-key decryption is
//! a separate layer — the consumer's (libfreemkv's) job.
//! serves readable (de-bus'd / de-scrambled) sectors. Content-key decryption
//! is a separate layer — the consumer's (libfreemkv's) job.
//!
//! This crate defines the [`Unlocker`] contract + the SCSI transport contract,
//! and holds the self-contained unlocker modules (firmware / AACS cert / CSS).
//! libfreemkv depends on this crate and dispatches via [`all_unlockers`]; it
//! never names an individual unlocker. To remove an unlocker, delete its module
//! dir and its one line in [`all_unlockers`] — nothing else changes.
//! This crate defines the [`Unlocker`] contract + the SCSI transport
//! contract, and holds the self-contained unlocker modules (firmware / AACS
//! cert / CSS). The consumer (libfreemkv) assembles its own dispatch list from
//! the exported unlocker types and drives them all through the same trait.
pub mod scsi;
mod aacs;
mod css;
// `ld` is public ONLY for its drive-profile catalog (`ld::profiles` / the
// `Profiles` object) and, under the `emulation` feature, the unlock-handshake
// wire format the bdemu test-emulator needs. The unlocker impl itself
// (`LibreDrive`) is `pub(crate)` — clients still reach unlockers only through
// [`all_unlockers`]. `aacs` and `css` carry no such public catalog, so they
// stay fully private.
// Shared best-effort AACS Volume ID read, used by every route that opens the
// drive to raw reads (freemkv / MT1959 / Renesas). See `vid`.
mod vid;
// `ld` is public only for its drive-profile catalog + (under `emulation`) the
// handshake wire format bdemu needs; the unlocker impl stays `pub(crate)`.
// See docs/module-visibility.md — module visibility rationale.
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;
// `renesas` is public for its `is_renesas` drive-probe (dead bus vs. "not a
// Renesas drive"); the unlocker impl stays `pub(crate)`.
// See docs/module-visibility.md — module visibility rationale.
pub mod renesas;
// `freemkv` self-identifies rather than matching a bundled profile, so it
// stays fully private. See docs/module-visibility.md — module visibility rationale.
mod freemkv;
use scsi::ScsiTransport;
// The five unlockers, exposed as concrete types so the consumer assembles its
// own dispatch list and injects each one's deps at construction (certs → AACS)
// — no central factory to thread another unlocker's config through.
pub use aacs::AacsUnlocker;
pub use css::DvdUnlocker;
pub use freemkv::FreemkvUnlocker;
pub use ld::LdUnlocker;
pub use renesas::Renesas;
/// Drive identity an unlocker matches against — four raw INQUIRY-derived fields,
/// filled by the consumer (this crate parses no INQUIRY itself).
#[derive(Debug, Clone, Default)]
@@ -49,7 +61,7 @@ pub enum DiscKind {
/// A host certificate for the AACS cert handshake (raw; the consumer collects
/// these from its key sources and passes them in).
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct HostCert {
/// AACS 1.0 host private key (20 bytes).
pub private_key: [u8; 20],
@@ -61,32 +73,57 @@ pub struct HostCert {
pub certificate_v2: Option<Vec<u8>>,
}
/// Context handed to an unlocker: drive identity, disc kind, and (for the cert
/// route) the host certs the consumer collected.
pub struct UnlockCtx<'a> {
pub drive_id: &'a DriveId,
pub kind: DiscKind,
pub host_certs: &'a [HostCert],
}
impl<'a> UnlockCtx<'a> {
pub fn new(drive_id: &'a DriveId, kind: DiscKind, host_certs: &'a [HostCert]) -> Self {
Self {
drive_id,
kind,
host_certs,
}
// Manual Debug: `private_key`/`private_key_v2` are the raw host private keys —
// they must never reach a log in plaintext; presence (Some/None) of the v2 key
// stays observable. Mirrors the redacting Debug on AacsAuth/Unlocked.
impl std::fmt::Debug for HostCert {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HostCert")
.field("private_key", &"[redacted]")
.field("certificate", &self.certificate)
.field("private_key_v2", &self.private_key_v2.map(|_| "[redacted]"))
.field("certificate_v2", &self.certificate_v2)
.finish()
}
}
/// What removing bus encryption yielded. `drive_unlocked` means the drive now
/// serves clear content (firmware route) — equivalent, for the gate, to a cert
/// `bus_key`.
#[derive(Debug, Clone, Default)]
/// Per-attempt context the consumer hands to EVERY unlocker, uniformly: the
/// drive identity and the mounted disc's kind. These are the shared facts the
/// dispatch loop knows; anything an individual unlocker needs beyond them (the
/// AACS cert route's host certs) is injected into THAT unlocker at construction,
/// so the loop and the other unlockers never see it. See docs/module-visibility.md.
pub struct UnlockCtx<'a> {
pub drive_id: &'a DriveId,
pub kind: DiscKind,
}
impl<'a> UnlockCtx<'a> {
pub fn new(drive_id: &'a DriveId, kind: DiscKind) -> Self {
Self { drive_id, kind }
}
}
/// What a successful unlock captured: the Volume ID (best-effort — may be `None`
/// even on success) and, for the cert route, the bus key the read path applies
/// to de-bus content. Returned inside the `Some` of [`Unlocker::unlock`]; there
/// is no `drive_unlocked` flag — "unlocked" is simply `unlock()` returning
/// `Some`, uniformly for every route.
#[derive(Clone, Default)]
pub struct Unlocked {
pub vid: Option<[u8; 16]>,
pub bus_key: Option<[u8; 16]>,
pub drive_unlocked: bool,
}
// Hand-written, REDACTING Debug: `bus_key`/`vid` are key material that must
// never reach a log in plaintext; presence (Some/None) stays observable.
// See docs/unlocked-debug-redaction.md — full rationale.
impl std::fmt::Debug for Unlocked {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Unlocked")
.field("vid", &self.vid.map(|_| "[redacted]"))
.field("bus_key", &self.bus_key.map(|_| "[redacted]"))
.finish()
}
}
/// Why an unlock produced no usable result. Only `Transport` is a hard error
@@ -107,46 +144,46 @@ pub enum UnlockError {
}
/// 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`].)
/// the self-contained modules in this crate; the consumer drives them all
/// through this trait. (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.
pub trait Unlocker: Send + Sync {
/// Short, stable identifier for this unlocker (e.g. "LibreDrive", "AACS",
/// "CSS", "Renesas"). The ONE place a name lives — apps render the unlocker
/// report from [`all_unlockers`], never hardcoding names, so adding/removing
/// Short, stable identifier for this unlocker (e.g. "freemkv", "LD",
/// "AACS", "DVD", "Renesas"). The ONE place a name lives — apps render the
/// unlocker report from `name()`, never hardcoding names, so adding/removing
/// an unlocker updates every report with no app change.
fn name(&self) -> &'static str;
/// 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(
/// Attempt to unlock the drive, whatever the mechanism (vendor CDB, profile
/// firmware handshake, AACS cert AKE, CSS auth):
/// `Ok(Some(unlocked))` = this one unlocked it (read `vid`/`bus_key`, stop);
/// `Ok(None)` = not this unlocker's drive (try the next); `Err(Transport)` =
/// dead bus (abort). Only a dead bus is `Err` — a missing VID, rejected
/// handshake, missing cert, or wrong disc kind all fall through as `Ok(None)`
/// (the VID inside `Unlocked` is best-effort). `&self`: stateless, returns
/// what it learned rather than stashing it.
fn unlock(
&self,
scsi: &mut dyn ScsiTransport,
ctx: &UnlockCtx,
) -> std::result::Result<Unlocked, UnlockError> {
let _ = (scsi, ctx);
Err(UnlockError::NotApplicable)
}
) -> std::result::Result<Option<Unlocked>, UnlockError>;
}
/// 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)
/// The shared "only a dead bus is fatal" rule every unlocker uses to turn its
/// mechanism's `Result<Unlocked>` into the trait's outcome: success → `Some`, a
/// dead bus → `Err(Transport)`, any other failure → `None` (fall through to the
/// next unlocker). Written once here so the five unlockers stay identical in
/// error handling and differ only in their CDBs and checks.
pub(crate) fn fallthrough(
r: std::result::Result<Unlocked, UnlockError>,
) -> std::result::Result<Option<Unlocked>, UnlockError> {
match r {
Ok(u) => Ok(Some(u)),
Err(UnlockError::Transport) => Err(UnlockError::Transport),
Err(_) => Ok(None),
}
}
@@ -159,14 +196,97 @@ 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.
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()),
]
#[cfg(test)]
mod tests {
use super::*;
// The canonical dispatch order the consumer assembles (freemkv → firmware →
// Renesas → cert → css). It lives in the consumer now (no factory), but the
// types are ours, so pin their names here so a rename is caught crate-local.
fn canonical_unlockers() -> Vec<Box<dyn Unlocker>> {
vec![
Box::new(FreemkvUnlocker::new()),
Box::new(LdUnlocker::new()),
Box::new(Renesas::new()),
Box::new(AacsUnlocker::new(Vec::new())),
Box::new(DvdUnlocker::new()),
]
}
#[test]
fn unlocker_names_are_stable() {
let names: Vec<&'static str> = canonical_unlockers().iter().map(|u| u.name()).collect();
assert_eq!(names, vec!["freemkv", "LD", "Renesas", "AACS", "DVD"]);
}
/// The uniform contract every unlocker obeys, whatever its mechanism: on a
/// DEAD BUS, `unlock()` either declined before touching it (`Ok(false)`) or
/// engaged it and reported the fault (`Err(Transport)`) — but NEVER claims a
/// dead bus as unlocked, and NEVER surfaces a non-`Transport` hard error.
/// This is the guardrail that was missing: it holds the next new unlocker to
/// the same discipline the loop relies on.
#[test]
fn no_unlocker_claims_a_dead_bus_or_hard_errors() {
let id = DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
for u in canonical_unlockers() {
let name = u.name();
let mut t = scsi::mock::MockTransport::always(scsi::mock::Reply::TransportFault);
match u.unlock(&mut t, &ctx) {
Ok(None) => {} // declined before touching — fine
Err(UnlockError::Transport) => {} // engaged, dead bus — fine
other => panic!("{name} violated the dead-bus contract: {other:?}"),
}
}
}
/// `unlocker_name` is a PURE lookup — it must answer from the `DriveId`
/// alone, and only the identity-keyed unlocker can claim a drive this way.
#[test]
fn unlocker_name_is_a_pure_identity_lookup() {
assert_eq!(unlocker_name(&DriveId::default()), None);
}
// `bus_key`/`vid` are key material; `Debug` must NOT print those bytes.
// MUTATION: restoring `#[derive(Debug)]` prints the raw byte arrays, so
// the marker bytes appear in the output and this test goes red.
#[test]
fn unlocked_debug_redacts_key_material() {
let u = Unlocked {
vid: Some([0xAB; 16]),
bus_key: Some([0xCD; 16]),
};
let s = format!("{u:?}");
// A derived Debug renders `[171, 171, ...]` (0xAB) / `[205, ...]` (0xCD).
assert!(!s.contains("171"), "vid bytes must not appear: {s}");
assert!(!s.contains("205"), "bus_key bytes must not appear: {s}");
assert!(s.contains("[redacted]"), "must mark redaction: {s}");
// Presence (Some/None) stays observable.
assert!(s.contains("Some"), "must still show a key WAS present: {s}");
}
// `private_key`/`private_key_v2` are the raw host private keys; `Debug` must
// NOT print those bytes. Restoring `#[derive(Debug)]` renders the byte
// arrays (e.g. 0xAB → 171 / 0xCD → 205), turning this test red.
#[test]
fn host_cert_debug_redacts_private_keys() {
let c = HostCert {
private_key: [0xAB; 20],
certificate: vec![0x01, 0x02],
private_key_v2: Some([0xCD; 32]),
certificate_v2: None,
};
let s = format!("{c:?}");
assert!(!s.contains("171"), "private_key bytes must not appear: {s}");
assert!(
!s.contains("205"),
"private_key_v2 bytes must not appear: {s}"
);
assert!(s.contains("[redacted]"), "must mark redaction: {s}");
// Presence of the v2 key stays observable.
assert!(
s.contains("Some"),
"must still show the v2 key WAS present: {s}"
);
}
}
+443
View File
@@ -0,0 +1,443 @@
//! renesas — 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;
/// `Ok(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 rejection (CHECK CONDITION or `Err` with a sense) is
/// `Ok(false)`: not a Renesas drive.
///
/// `Err(Transport)` on a dead bus. See docs/renesas-mod.md for why a
/// transport fault must not be folded into `Ok(false)`.
pub fn is_renesas(scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
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();
Ok(r.status == 0
&& r.bytes_transferred >= end
&& &buf[RENESAS_MARKER_OFFSET..end] == RENESAS_MARKER)
}
// Only a senseless transport-failure status is a dead bus; anything
// else the transport reports as `Err` is the drive refusing.
Err(e) => {
if e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none() {
tracing::warn!(
target: "freemkv::disc",
phase = "renesas_probe_transport_fault",
"transport fault on the Renesas identity probe; aborting"
);
return Err(UnlockError::Transport);
}
tracing::debug!(
target: "freemkv::disc",
phase = "renesas_probe_rejected",
status = e.status,
"Renesas identity probe rejected by the drive; not a Renesas platform"
);
Ok(false)
}
}
}
/// The Renesas/Pioneer-platform unlocker. Its vendor "open" CDB puts the drive
/// in the same raw-read / extended-access state the firmware routes reach (this
/// is what MakeMKV's LibreDrive does on stock Pioneer firmware), so the Volume
/// ID is then read with the SAME standard `0xAD` reader as freemkv/MT1959 — no
/// host cert, no AKE. Stateless: `unlock()` returns what it learned.
#[derive(Default)]
pub struct Renesas;
impl Renesas {
pub fn new() -> Self {
Renesas
}
// MakeMKV's vendor "open" sequence: primary read (A), and on refusal a
// knock + second read (B). See docs/renesas-mod.md for the full sequence
// and why we run A→knock→B rather than bailing after A.
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 Renesas {
fn name(&self) -> &'static str {
"Renesas"
}
/// Gate on the `0xF1` SAT identity, then the vendor open read `0xB0@0x04`
/// (LOAD-BEARING — it's the unlock), then read the Volume ID with the shared
/// best-effort `0xAD` reader. `Some` once the open succeeds — the drive
/// serves clear content whether or not the bonus VID read did; `None` if it
/// isn't a Renesas drive or the open is refused; `Err(Transport)` on a dead
/// bus. See docs/renesas-mod.md for the MakeMKV-parity rationale.
fn unlock(
&self,
scsi: &mut dyn ScsiTransport,
_ctx: &UnlockCtx,
) -> std::result::Result<Option<Unlocked>, UnlockError> {
if !is_renesas(scsi)? {
return Ok(None);
}
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 Ok(None);
}
// The open enabled raw reads (extended-access) — read the VID exactly as
// the firmware routes do. Best-effort: a VID miss must not discard the
// unlock (only a dead bus propagates via `?`).
let vid = crate::vid::read_aacs_vid(scsi)?;
tracing::debug!(
target: "freemkv::disc",
phase = "renesas_opened",
has_vid = vid.is_some(),
"Renesas drive opened (RB 0xB0@0x04 GOOD, raw reads enabled)"
);
Ok(Some(Unlocked { vid, bus_key: None }))
}
}
#[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 like a MediaTek drive: ILLEGAL REQUEST with a sense, which is
// what distinguishes this from a dead bus. See docs/renesas-mod.md.
struct RejectingTransport;
impl ScsiTransport for RejectingTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
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),
})
}
}
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).expect("no transport fault"));
}
#[test]
fn is_renesas_false_when_command_rejected() {
let mut t = RejectingTransport;
assert!(!is_renesas(&mut t).expect("a drive rejection is not a bus fault"));
}
#[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).expect("no transport fault"));
}
#[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).expect("no transport fault"));
}
#[test]
fn opened_renesas_reports_unlocked_and_reads_vid() {
let mut t = RenesasTransport {
payload: renesas_payload(),
};
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
// Recognized + vendor-open GOOD → unlocked; the open enables raw reads,
// so the standard 0xAD VID read returns a (non-zero) VID best-effort.
let out = Renesas::new()
.unlock(&mut t, &ctx)
.expect("no fault")
.expect("renesas → unlocked");
assert!(out.vid.is_some(), "opened drive yields a VID via 0xAD");
assert_eq!(out.bus_key, None, "raw-read route needs no bus key");
}
/// A drive REJECTION (ILLEGAL REQUEST, with a sense) is "not a Renesas
/// drive" → `Ok(false)`, fall through to the next unlocker.
#[test]
fn declines_non_renesas() {
let mut t = RejectingTransport;
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
assert!(
Renesas::new()
.unlock(&mut t, &ctx)
.expect("non-renesas declines")
.is_none()
);
}
// Same rejection via a CONFORMING transport (`Ok` + CHECK CONDITION);
// must reach the same answer. See docs/renesas-mod.md.
#[test]
fn check_condition_is_not_a_renesas_drive() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::illegal_request());
assert!(!is_renesas(&mut t).expect("a drive sense is not a bus fault"));
}
/// THE defect-8 test: a dead bus on the FIRST command the unlocker issues
/// must abort the consumer, not be reported as "not a Renesas drive".
/// Catches restoring the `Err(_) => false` arm.
#[test]
fn transport_fault_aborts_instead_of_declining() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::TransportFault);
assert_eq!(is_renesas(&mut t).unwrap_err(), UnlockError::Transport);
let mut t = MockTransport::always(Reply::TransportFault);
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
assert_eq!(
Renesas::new().unlock(&mut t, &ctx).unwrap_err(),
UnlockError::Transport
);
}
// A recognized Renesas drive whose A read (RB 0xB0@0x04) is REFUSED but
// whose B read (RB 0xB0@0x50) SUCCEEDS after the knock must unlock — and the
// knock CDB has to be issued BETWEEN A and B. See docs/renesas-mod.md.
#[test]
fn knock_runs_between_a_and_b_and_b_opens() {
// The exact knock CDB the code issues (pinned wire format).
const KNOCK_A5AAAA_CDB: [u8; 10] =
[0x3B, 0x02, 0x41, 0xA5, 0xAA, 0xAA, 0x00, 0x00, 0x00, 0x00];
struct AFailsBSucceeds {
cdbs: Vec<Vec<u8>>,
}
impl ScsiTransport for AFailsBSucceeds {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
self.cdbs.push(cdb.to_vec());
// Gate 0xF1 → SAT identity.
if cdb.get(2) == Some(&0xF1) {
let p = renesas_payload();
let n = p.len().min(data.len());
data[..n].copy_from_slice(&p[..n]);
return Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
});
}
// A read (RB 0xB0@0x04) → refuse with a drive sense.
if cdb.get(2) == Some(&0xB0) && cdb.get(3) == Some(&0x00) {
let mut sense = [0u8; 32];
sense[2] = 0x05; // ILLEGAL REQUEST
sense[12] = 0x20;
return Err(ScsiError {
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense: Some(sense),
});
}
// B read (RB 0xB0@0x50), the knock (0x3B/0x41), and the 0xAD VID
// read all return GOOD so the open + VID read succeed.
let n = data.len().min(36);
if !data.is_empty() {
// Non-zero VID so read_aacs_vid yields Some.
for b in data.iter_mut().take(n) {
*b = 0x5A;
}
}
Ok(ScsiResult {
status: 0,
bytes_transferred: data.len(),
sense: [0u8; 32],
})
}
}
let mut t = AFailsBSucceeds { cdbs: Vec::new() };
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
let out = Renesas::new()
.unlock(&mut t, &ctx)
.expect("no fault")
.expect("B read opens the drive → unlocked");
assert_eq!(out.bus_key, None);
// The knock must appear, between the A read (0xB0@0x04) and the B read
// (0xB0@0x50).
let a = t
.cdbs
.iter()
.position(|c| c.get(2) == Some(&0xB0) && c.get(3) == Some(&0x00))
.expect("A read issued");
let knock = t
.cdbs
.iter()
.position(|c| c.as_slice() == KNOCK_A5AAAA_CDB)
.expect("knock CDB issued");
let b = t
.cdbs
.iter()
.position(|c| c.get(2) == Some(&0xB0) && c.get(3) == Some(&0x50))
.expect("B read issued");
assert!(a < knock && knock < b, "knock must run between A and B");
}
// A recognized Renesas drive that REFUSES the vendor open read must
// defer to the next unlocker, not claim the drive. See docs/renesas-mod.md.
#[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!(
Renesas::new()
.unlock(&mut t, &ctx)
.expect("open refused → declines, not a hard error")
.is_none(),
"a refused vendor open must defer to the next unlocker"
);
}
}
-202
View File
@@ -1,202 +0,0 @@
//! 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);
}
}
+260
View File
@@ -98,3 +98,263 @@ pub(crate) fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {
0x00,
]
}
// ── Test fixture ────────────────────────────────────────────────────────────
// Crate-wide mock transport, able to express all three transport outcomes.
// See docs/scsi-mock-fixture.md — why this fixture exists
#[cfg(test)]
#[allow(dead_code)] // a fixture: each helper is used by a subset of the modules
pub(crate) mod mock {
use super::*;
use std::collections::VecDeque;
/// One scripted answer to one `execute()` call.
#[derive(Debug, Clone)]
pub(crate) enum Reply {
/// `Ok` + GOOD status. `payload` is copied into the caller's buffer;
/// `bytes_transferred` defaults to the number of bytes copied.
Data {
payload: Vec<u8>,
bytes_transferred: Option<usize>,
},
/// `Ok` + a NON-ZERO SCSI status (CHECK CONDITION) with a drive sense.
/// Per the contract at [`ScsiTransport::execute`] this is NOT a
/// transport fault — the caller must inspect `status`, and a caller that
/// doesn't will consume the caller's zero-filled buffer as drive data.
Sense {
status: u8,
sense_key: u8,
asc: u8,
ascq: u8,
},
/// `Err` with the transport-failure status and no sense — a genuine
/// transport-layer fault (bridge crash / disconnect). MUST abort.
TransportFault,
/// `Err` carrying a real status + parsed sense. This is what a
/// NON-conforming transport does (libfreemkv's adapter returns `Err` for
/// any non-zero SCSI status); classification must still treat it as a
/// drive rejection, not a dead bus.
ErrWithSense {
status: u8,
sense_key: u8,
asc: u8,
ascq: u8,
},
}
impl Reply {
/// `Ok`, GOOD status, full transfer of `payload`.
pub(crate) fn good(payload: Vec<u8>) -> Reply {
Reply::Data {
payload,
bytes_transferred: None,
}
}
/// `Ok`, GOOD status, but the drive only delivered `n` bytes.
pub(crate) fn short(payload: Vec<u8>, n: usize) -> Reply {
Reply::Data {
payload,
bytes_transferred: Some(n),
}
}
/// `Ok`, GOOD status, ZERO bytes delivered — the buffer the caller reads
/// is entirely its own zero fill.
pub(crate) fn zero_transfer(len: usize) -> Reply {
Reply::Data {
payload: vec![0u8; len],
bytes_transferred: Some(0),
}
}
/// `Ok` + CHECK CONDITION / ILLEGAL REQUEST (0x05, ASC 0x20 invalid
/// command) — the ordinary way a drive refuses a vendor command.
pub(crate) fn illegal_request() -> Reply {
Reply::Sense {
status: SCSI_STATUS_CHECK_CONDITION,
sense_key: 0x05,
asc: 0x20,
ascq: 0x00,
}
}
/// `Err` + CHECK CONDITION / ILLEGAL REQUEST — the same drive refusal as
/// seen through a non-conforming transport.
pub(crate) fn illegal_request_as_err() -> Reply {
Reply::ErrWithSense {
status: SCSI_STATUS_CHECK_CONDITION,
sense_key: 0x05,
asc: 0x20,
ascq: 0x00,
}
}
}
fn sense_buf(sense_key: u8, asc: u8, ascq: u8) -> [u8; 32] {
let mut b = [0u8; 32];
b[2] = sense_key & 0x0F;
b[12] = asc;
b[13] = ascq;
b
}
/// A scripted transport: answers each `execute()` from `script`, falling
/// back to `default` once the script runs out, and records every CDB.
pub(crate) struct MockTransport {
pub(crate) script: VecDeque<Reply>,
pub(crate) default: Reply,
pub(crate) cdbs: Vec<Vec<u8>>,
}
impl MockTransport {
/// Every command gets the same answer.
pub(crate) fn always(reply: Reply) -> Self {
MockTransport {
script: VecDeque::new(),
default: reply,
cdbs: Vec::new(),
}
}
/// The first N commands follow `script`; the rest get `default`.
pub(crate) fn scripted(script: Vec<Reply>, default: Reply) -> Self {
MockTransport {
script: script.into(),
default,
cdbs: Vec::new(),
}
}
/// How many CDBs were issued (lets a test assert a dead bus aborted
/// instead of retrying).
pub(crate) fn calls(&self) -> usize {
self.cdbs.len()
}
}
impl ScsiTransport for MockTransport {
fn execute(
&mut self,
cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
self.cdbs.push(cdb.to_vec());
let reply = self
.script
.pop_front()
.unwrap_or_else(|| self.default.clone());
match reply {
Reply::Data {
payload,
bytes_transferred,
} => {
let n = payload.len().min(data.len());
data[..n].copy_from_slice(&payload[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: bytes_transferred.unwrap_or(n),
sense: [0u8; 32],
})
}
Reply::Sense {
status,
sense_key,
asc,
ascq,
} => Ok(ScsiResult {
status,
bytes_transferred: 0,
sense: sense_buf(sense_key, asc, ascq),
}),
Reply::TransportFault => Err(ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
}),
Reply::ErrWithSense {
status,
sense_key,
asc,
ascq,
} => Err(ScsiError {
status,
sense: Some(sense_buf(sense_key, asc, ascq)),
}),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use mock::{MockTransport, Reply};
/// The fixed-format sense layout the whole crate reads its diagnostics off
/// (key at byte 2 low nibble, ASC at 12, ASCQ at 13). This file had no test
/// at all, so nothing pinned the offsets.
#[test]
fn sense_parses_at_the_fixed_format_offsets() {
let mut buf = [0u8; 32];
buf[2] = 0xF5; // sense key is the LOW nibble only
buf[12] = 0x24;
buf[13] = 0x01;
let s = ScsiSense::from_buf(&buf);
assert_eq!(s.sense_key, 0x05);
assert_eq!(s.asc, 0x24);
assert_eq!(s.ascq, 0x01);
assert!(s.is_illegal_request());
buf[2] = 0x02; // NOT READY
assert!(!ScsiSense::from_buf(&buf).is_illegal_request());
}
/// SET CD SPEED encodes the read speed big-endian at bytes 2-3 and leaves
/// the write speed at 0xFFFF.
#[test]
fn set_cd_speed_encodes_the_read_speed_big_endian() {
let cdb = build_set_cd_speed(0x1234);
assert_eq!(cdb[0], SCSI_SET_CD_SPEED);
assert_eq!([cdb[2], cdb[3]], [0x12, 0x34]);
assert_eq!([cdb[4], cdb[5]], [0xFF, 0xFF]);
assert_eq!(build_set_cd_speed(0xFFFF)[2..4], [0xFF, 0xFF]);
}
// The fixture must honour the contract it tests: a drive sense is `Ok`
// with non-zero status, a bus fault is `Err`. If this drifts, every
// transport-contract test built on it silently stops testing anything.
#[test]
fn mock_transport_expresses_the_three_contract_outcomes() {
let mut t = MockTransport::scripted(
vec![
Reply::good(vec![0xAB; 4]),
Reply::illegal_request(),
Reply::zero_transfer(4),
],
Reply::TransportFault,
);
let mut buf = [0u8; 4];
let r = t
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
.expect("data reply is Ok");
assert_eq!(r.status, 0);
assert_eq!(r.bytes_transferred, 4);
assert_eq!(buf, [0xAB; 4]);
let r = t
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
.expect("A DRIVE SENSE IS Ok, NOT Err — the load-bearing contract");
assert_eq!(r.status, SCSI_STATUS_CHECK_CONDITION);
assert!(ScsiSense::from_buf(&r.sense).is_illegal_request());
let r = t
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
.expect("a zero-length transfer is still Ok");
assert_eq!(r.bytes_transferred, 0);
let e = t
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
.expect_err("a transport fault is Err");
assert_eq!(e.status, SCSI_STATUS_TRANSPORT_FAILURE);
assert!(e.sense.is_none(), "a bus fault carries no drive sense");
assert_eq!(t.calls(), 4);
}
}
+193
View File
@@ -0,0 +1,193 @@
//! Shared AACS Volume ID read — the standard `READ DISC STRUCTURE` (`0xAD`,
//! format `0x80`) that returns the VID on a drive whose host-auth / bus has
//! already been opened by a firmware or vendor CDB unlock (freemkv Raw Read,
//! MT1959, or a Pioneer/Renesas vendor open). Because those routes put the
//! drive in the same raw-read state, the VID read is identical across them — so
//! it lives here once.
//!
//! BEST-EFFORT: only a dead bus is an `Err(Transport)`. A CHECK CONDITION, a
//! short response, or an all-zero VID all yield `Ok(None)` — a VID miss must
//! never discard an unlock that already removed the bus (a key source can still
//! supply the key). See docs/freemkv-abi.md for the fixed-length reply rules.
use crate::UnlockError;
use crate::scsi::{DataDirection, ScsiTransport};
/// The 16-byte AACS Volume ID.
const VID_LEN: usize = 16;
/// `READ DISC STRUCTURE` format for the Volume ID.
const DISC_STRUCT_FMT_VID: u8 = 0x80;
/// Response length: a 4-byte header, the 16-byte VID, and a 16-byte MAC. On the
/// bare-read path (no bus key) the MAC can't be verified and is ignored.
const VID_STRUCT_LEN: u16 = 36;
/// The standard `0xAD` fmt-`0x80` (Blu-ray, AGID 0) Volume ID CDB — NOT a vendor
/// knock; valid once the drive's bus/host-auth is open. Mirrors libfreemkv's
/// `read_volume_id`.
pub(crate) fn build_vid_cdb() -> [u8; 12] {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE;
cdb[1] = 0x01; // Blu-ray
cdb[7] = DISC_STRUCT_FMT_VID;
cdb[8] = (VID_STRUCT_LEN >> 8) as u8;
cdb[9] = (VID_STRUCT_LEN & 0xFF) as u8;
// cdb[10] = agid << 6; AGID 0 on the bare path (no AKE) → 0.
cdb
}
/// A senseless transport-failure status is a genuine dead bus, not a drive
/// rejection surfaced through a non-conforming transport (`Err` with a sense).
fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
}
/// Read the AACS VID with the bare `0xAD` fmt `0x80` (valid only after the drive
/// is unlocked). `Ok(Some(vid))` on a well-formed non-zero VID; `Ok(None)` for
/// any "no VID" outcome (rejected / short / all-zero); `Err(Transport)` only on
/// a dead bus.
pub(crate) fn read_aacs_vid(
scsi: &mut dyn ScsiTransport,
) -> std::result::Result<Option<[u8; 16]>, UnlockError> {
let cdb = build_vid_cdb();
let mut buf = [0u8; VID_STRUCT_LEN as usize];
let result = match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
Ok(r) => r,
Err(e) => {
if is_dead_bus(&e) {
return Err(UnlockError::Transport);
}
tracing::debug!(
target: "freemkv::disc",
phase = "vid_rejected_as_err",
status = e.status,
"bare VID read rejected (via Err); no Volume ID"
);
return Ok(None);
}
};
// A drive sense arrives as Ok with a non-zero status; without this check a
// CHECK CONDITION's zero-filled buffer would parse as a VID.
if result.status != 0 {
tracing::debug!(
target: "freemkv::disc",
phase = "vid_check_condition",
status = result.status,
"bare VID read returned a drive sense (no medium / drive not open)"
);
return Ok(None);
}
// Need the 4-byte header + the 16-byte VID.
if result.bytes_transferred < 4 + VID_LEN {
tracing::debug!(
target: "freemkv::disc",
phase = "vid_short_response",
bytes_transferred = result.bytes_transferred,
"bare VID response too short"
);
return Ok(None);
}
let mut vid = [0u8; VID_LEN];
vid.copy_from_slice(&buf[4..4 + VID_LEN]);
// An all-zero VID is what a permissive stub or an unfilled response leaves
// behind — reject it rather than pass a bogus key downstream.
if vid.iter().all(|&b| b == 0) {
tracing::debug!(
target: "freemkv::disc",
phase = "vid_all_zero",
"bare VID read returned an all-zero Volume ID"
);
return Ok(None);
}
tracing::debug!(target: "freemkv::disc", phase = "vid_ok", "Volume ID retrieved via bare 0xAD read");
Ok(Some(vid))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scsi::mock::{MockTransport, Reply};
/// A well-formed format-0x80 VID structure: 4-byte header, 16-byte VID at
/// offset 4, 16-byte MAC (zeroed on the bare path — it isn't checked).
fn vid_ds_response(vid: [u8; 16]) -> Vec<u8> {
let mut p = vec![0u8; VID_STRUCT_LEN as usize];
p[4..20].copy_from_slice(&vid);
p
}
/// The bare VID read is the STANDARD 0xAD READ DISC STRUCTURE (format 0x80,
/// Blu-ray, AGID 0, len 36) — NOT a vendor knock.
#[test]
fn build_vid_cdb_is_standard_0xad_fmt_80() {
let cdb = build_vid_cdb();
assert_eq!(cdb[0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
assert_eq!(cdb[1], 0x01); // Blu-ray
assert_eq!(cdb[7], 0x80); // AACS Volume ID
assert_eq!([cdb[8], cdb[9]], [0x00, 0x24]); // 36, BE16
assert_eq!(cdb[10], 0x00); // AGID 0 (no AKE)
}
/// Parses the VID from response[4..20] and issues exactly one 0xAD read.
#[test]
fn reads_and_parses_offset_4() {
let vid = [0x5Au8; 16];
let mut t = MockTransport::always(Reply::good(vid_ds_response(vid)));
let got = read_aacs_vid(&mut t).expect("no fault");
assert_eq!(got, Some(vid));
assert_eq!(t.cdbs.len(), 1, "exactly one command");
assert_eq!(t.cdbs[0][0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
assert_eq!(t.cdbs[0][7], 0x80);
}
/// The MAC region (bytes 20..36) is ignored on the bare path.
#[test]
fn ignores_the_unverifiable_mac() {
let vid: [u8; 16] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD,
0xEE, 0xFF,
];
let mut t = MockTransport::always(Reply::good(vid_ds_response(vid)));
assert_eq!(read_aacs_vid(&mut t).expect("no fault"), Some(vid));
}
/// A response too short to carry header + VID → best-effort `Ok(None)`.
#[test]
fn short_response_is_none() {
let mut t = MockTransport::always(Reply::short(vid_ds_response([0x11; 16]), 19));
assert_eq!(read_aacs_vid(&mut t).expect("no fault"), None);
}
/// A CHECK CONDITION (no medium / drive not open) → `Ok(None)`, never a VID
/// parsed from the zero fill.
#[test]
fn check_condition_is_none() {
let mut t = MockTransport::always(Reply::illegal_request());
assert_eq!(read_aacs_vid(&mut t).expect("no fault"), None);
}
/// An all-zero VID (permissive stub / unfilled response) → `Ok(None)`.
#[test]
fn all_zero_is_none() {
let mut t = MockTransport::always(Reply::good(vid_ds_response([0u8; 16])));
assert_eq!(read_aacs_vid(&mut t).expect("no fault"), None);
}
/// A rejection surfaced as `Err` WITH a sense (a non-conforming transport)
/// is a drive refusal, not a dead bus → `Ok(None)` VID-miss, never
/// `Err(Transport)`.
#[test]
fn err_with_sense_is_a_vid_miss_not_transport() {
let mut t = MockTransport::always(Reply::illegal_request_as_err());
assert_eq!(
read_aacs_vid(&mut t).expect("a sense is not a dead bus"),
None
);
}
/// Only a dead bus is an error.
#[test]
fn transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
assert_eq!(read_aacs_vid(&mut t).unwrap_err(), UnlockError::Transport);
}
}