Compare commits

..
131 Commits
Author SHA1 Message Date
Matthew Jackson 796dd41b94 docs: 1.8.2 release notes 2026-10-07 09:59:54 -07:00
Matthew Jackson 3965b16740 v1.8.1: bump version (unified release) 2026-10-07 03:56:36 +00:00
Matthew Jackson 22431979a2 docs: 1.8.1 release notes 2026-10-06 20:19:11 -07:00
Matthew Jackson bead4869a9 Read Renesas VID directly from hardware registers 2026-10-05 22:28:46 -07:00
Matthew Jackson d64c4b3e55 Move tests to side files 2026-10-05 20:22:40 -07:00
Matthew Jackson 1970113a82 feat(renesas): discover optional VID slot and harden OEM unlock 2026-10-05 20:08:04 -07:00
Matthew Jackson 6008d2daa7 fix(renesas): read VID from vendor memory slot 2026-10-05 19:07:21 -07:00
Matthew Jackson 5b690bf34f pioneer-optical 0.10 (0.7 yanked); vendor CDB bytes unchanged and pinned by a test 2026-10-04 23:33:12 -07:00
Matthew Jackson 04cddb11f6 v1.8.0: bump version (unified release) 2026-10-05 02:16:57 +00:00
Matthew Jackson 800c78f86b renesas: vendor CDBs from pioneer-optical
The identity read (3C 02 F1), the two gated memory reads (3C 02 B0 at 0x04
and 0x500000, 0xA4 bytes) and the read-unlock knock (3B 02 41 A5 AA AA) come
from the crate's constructors; the bytes on the wire are unchanged and the
knock test still pins them. The crate's flash::read_memory is not used: it
knocks before every read and stops on a refused knock, where this sequence
reads first and tries the second window even when the knock is refused.
2026-10-04 18:42:19 -07:00
Matthew Jackson 17e33f89cf changelog: 1.8.0 release notes 2026-10-04 18:28:23 -07:00
Matthew Jackson 11d6e19ae2 ci: consumers path-patch; monthly grouped dependabot; actions/checkout 7
The consumers job path-patches this commit into libfreemkv and bdemu.
Dependabot runs monthly with grouped version updates.
actions/checkout 5 -> 7 in ci and qa (#15).
2026-10-03 11:15:58 -07:00
Matthew Jackson 2ee8c0b0a6 stop: Stop-aware unlock operations
ScsiTransport gains Stop methods; AGIDs are handed off by AgidGuard,
CSS is defused on Stop and MT1959 critical spans are guarded, and the
post-upload readiness poll waits via pause so a Stop ends it.
A test guards the op paths against bare sleeps.
2026-09-28 08:42:29 -07:00
Matthew Jackson 8473c89201 mt1959: run_init confirms the unlock, then waits for media ready
run_init returns Ok once load_firmware confirms the unlock, then polls
for media ready, bounded by stalls rather than a total. The poll never
undoes a confirmed unlock and treats MMC-6 terminal states as final.
2026-09-28 00:01:08 -07:00
Matthew Jackson 5c4a6c4094 firmware: gate recipes and freemkv unlock on a supported IDENTITY
Firmware recipes and the freemkv unlock run only for a supported
IDENTITY, with an accurate fw 0.7/0.8 rationale and reply shape.
css and renesas share is_dead_bus; no P-256 retry is claimed for 0x11
drive certs; handshake diagnostics log below WARN; tests pin the error
variants, the v2 cert gate and the 132-byte v2 boundary.
CI: doc-links, doctests, a consumers job, read-only token; rand 0.10.3.
2026-09-27 17:50:56 -07:00
Matthew Jackson 0a37381a63 aacs, ld, css: tighter drive-cert handling and exact product matching
The AACS cert loop classifies failures more tightly, carries forward only
a recognised drive-cert failure and gates the read data key on BEC.
ld: an exact product_id match wins across all profile sections.
css: crypt_key bounds tests assert the panic message.
2026-09-27 00:28:28 -07:00
Matthew Jackson 1d20be4a41 Prepare 1.7.7 with concise comments and shared CI policy 2026-09-26 11:46:30 -07:00
Matthew Jackson 965c1f984c v1.7.6: bump version (unified release) 2026-09-26 04:05:33 +00:00
Matthew Jackson e51fda9bcd docs: move drive/firmware internals notes to the private repo; drop their pointers [skip-media] 2026-09-25 16:48:30 -07:00
Matthew Jackson 35cb630af9 docs(changelog): 1.7.6 entry names the Feature::Bd retirement + Uhd→Unrestricted rename [skip-media] 2026-09-25 12:59:30 -07:00
Matthew Jackson e6786692b3 chore: absorb qa into dev for lockstep promote [skip-media] 2026-09-25 12:25:42 -07:00
Matthew Jackson 8caf9e7b69 v1.7.6: bump version (unified release) 2026-09-25 05:31:54 +00:00
Matthew Jackson b77e7e8aac chore(qa): gate 1.7.6 promote — media matrix not needed [skip-media]
The 1.7.6 unified release ships no rip-path change (Linux desktop shell,
notifications, menu_layout refactor, XDG paths). Real-media QA on EC2
adds ~2h + real money to the release cycle to prove code that never
enters the rip pipeline. This empty commit puts `[skip-media]` in qa's
HEAD so the media matrix in qa.yml auto-skips per the documented bypass;
the full-platform builds/tests still run.
2026-09-24 21:37:24 -07:00
Matthew Jackson 94471641e3 chore(qa-sync): absorb qa's post-1.7.5 release commits into dev
Release-orchestrate committed to qa (v1.7.5 version bump, etc.) but did
not push those commits back to dev, so promote's fast-forward gate
refused (qa was diverged from dev). This is an ours-merge: the commit
records qa as an ancestor of dev without applying its content, since
dev has already moved past 1.7.5 to 1.7.6 with the equivalent
information in place.
2026-09-24 21:21:10 -07:00
Matthew Jackson c7e7aa63a0 unlock: trim Unrestricted doc + Recipe NOTE to satisfy comment-guard 2026-09-24 20:43:05 -07:00
Matthew Jackson 89861e6f40 unlock: drop Feature::Bd, rename Uhd→Unrestricted (mirror fw 0.9.2 ABI) 2026-09-24 20:40:56 -07:00
Matthew Jackson eef8e958fc chore(release): bump to 1.7.6, follow the CI-required release-notes shape
Bumps Cargo.toml + Cargo.lock to 1.7.6. Also fixes a CI-shape defect the
previous release left behind: dev's CHANGELOG still said
`## [1.7.5] — UNRELEASED` after 1.7.5 shipped (release-orchestrate dated
main but did not push a fresh UNRELEASED heading back to dev). Corrects
that here so the changelog job accepts 1.7.6 on the way to qa:

  ## [1.7.6] — UNRELEASED
    - Version aligned; unified release driven by freemkv 1.7.6.
  ## [1.7.5] — 2026-09-23
    - Version aligned (or actual content, per the shipped main entry).

The driver for 1.7.6 is the freemkv Linux desktop shell (GTK4 +
libadwaita) + rip-finished desktop notification (issue #56).
2026-09-24 20:23:08 -07:00
Matthew Jackson d6607ba963 v1.7.5: bump version (unified release) 2026-09-23 04:40:48 +00:00
Matthew Jackson 89f202edbc style: trim three comment blocks under the central guard caps 2026-09-22 20:39:41 -07:00
Matthew Jackson e107c699d1 firmware: retire Ake+Bus into one Encryption lever (fw 0.9.0 ABI)
Firmware 0.9.0 consolidates the Ake (0x06) and Bus (0x07) features into a
single Encryption (0x06) lever; wire id 0x07 no longer exists. The separate
Bus lever was proven inert on BU40N/MT1959 — the one Encryption lever
de-busses on its own — so the unlock recipe now sets one flag where it used
to set two, and the trailing best-effort Set(Bus, off) is gone.

Set(Encryption, STATE_OFF) is the load-bearing step: the drive acts
pre-authenticated, so a bare 0xAD returns the VID with no cert and no AKE,
and content comes back de-bussed. Failure still maps to VidUnavailable.

Hardware-validated against fw 0.9.0 on BU40N 1.00 with a UHD disc: every
sampled unit returns de-bussed and opens with the AACS unit key resolved
from the live keyserver.
2026-09-22 20:32:22 -07:00
Matthew Jackson d257d40de9 v1.7.4: bump version (unified release) 2026-09-21 23:03:29 +00:00
Matthew Jackson 56c69e8877 docs: mark 1.7.4 changelog heading UNRELEASED (release workflow dates it) 2026-09-21 15:07:05 -07:00
Matthew Jackson 2c9d76235d docs: 1.7.4 changelog + consumer release notes 2026-09-21 11:47:20 -07:00
Matthew Jackson 1a916f0855 docs/ci: drop stale local-precommit/release.sh references — CI is the only gate 2026-09-21 09:11:34 -07:00
Matthew Jackson 609f3bc0bc ci: adopt central reusable guards + top-level concurrency
Replace comment/leak/identity guard workflows with thin callers into
freemkv/.github reusable guards (guard-comment/leak/identity @v1); fork
PRs still run, same-repo pushes aren't double-run. Delete now-unused
local ci/comment-guard.py and ci/leak-guard.sh. Add top-level
concurrency (cancel-in-progress) to ci.yml and qa.yml and standardize
actions/checkout to @v5.
2026-09-21 08:47:07 -07:00
Matthew Jackson 58c667314c freemkv-unlock 1.7.4: Hrl=off in arm_bypass recipes + version
arm_bypass_bd/arm_bypass_uhd now also Set(Hrl, off) (revocation skip) so a
revoked/absent cert never trips the HRL check on a keyless bypass rip; tests
updated for the new SET/verify order.
2026-09-19 14:00:25 -07:00
Matthew Jackson 98ef26f78c v1.7.3: bump version (unified release) 2026-09-19 05:31:07 +00:00
Matthew Jackson 93d9517964 merge: record v1.7.2 release into dev (-s ours; keep path deps + dev's next-cycle work) 2026-09-18 21:39:15 -07:00
Matthew Jackson 64a53b8349 firmware: mirror fw 0.8.3 RESET ABI — RESET_TO_DEFAULTS (0x01) + reset_to_defaults(), true-OEM/marker-gated docs 2026-09-18 21:23:07 -07:00
Matthew Jackson f434298e0d v1.7.2: bump version (unified release) 2026-09-18 18:37:18 +00:00
Matthew Jackson a18bc90164 freemkv: source the SET data-in buffer from MIN_ALLOC_LEN (single source for the transfer length) 2026-09-18 09:52:27 -07:00
Matthew Jackson 79f3324488 changelog: 1.7.2 — record the SET data-phase unlock fix + READ CAPACITY/empty-ISO hardening 2026-09-18 06:54:58 -07:00
Matthew Jackson 0842babe2d freemkv: SET must issue the data-in phase the firmware requires
FreemkvUnlocker::set sent the vendor SET with DataDirection::None + an
empty buffer, but the SET CDB advertises a MIN_ALLOC_LEN (64B) data-in
allocation and the drive ABORTS a no-data transfer (CHECK CONDITION /
Aborted Command — HW-confirmed on BU40N fw 0.8.1). Every Set(feature)
failed, so the load-bearing Set(Ake,off) failed and the whole freemkv
unlock aborted, silently falling back to the AACS/online key route
(autorip showed 'Unlockers — freemkv: no'). Mirror the working
FirmwareControl::set (exec_in): read the state table back over a
FromDevice phase and ignore it. Adds a regression test modelling the
drive's abort-without-data-phase contract.
2026-09-18 06:19:49 -07:00
Matthew Jackson 638b86acb6 merge: record v1.7.1 main into dev (dev already at 1.7.1; keep path deps) 2026-09-17 23:08:04 -07:00
Matthew Jackson 3c0c335840 deps: sha1 0.10→0.11 (RustCrypto 0.11 wave; MSRV-safe, 1.85) 2026-09-17 22:18:07 -07:00
Matthew Jackson f97ebb450b changelog: open 1.7.2 (firmware ABI v2); comment-guard: trim inline blocks to cap (comments only) 2026-09-17 14:35:43 -07:00
Matthew Jackson 47bb6a7ac3 firmware: mirror ABI v2 — Save + RESET modes, region/speed encodings, HRL/AKE/BUS off=unlock; flip full_unlock default profile
Mirrors freemkv-fw/src/abi.rs; drift-guard pins updated. 310 tests green.
2026-09-16 11:18:44 -07:00
Matthew Jackson eeb36bf389 expose read_aacs_vid — public bare VID read for the hw-tester no-cert-sent assert 2026-09-15 20:41:44 -07:00
Matthew Jackson 4459af4e2f firmware: SET/RESET need a data-in phase (drive aborts no-data verbs); IDENTITY is a version banner, not a binary state table 2026-09-15 18:02:22 -07:00
Matthew Jackson f1801351e2 firmware: floor vendor-command allocation to MIN_ALLOC_LEN — drive aborts sub-16-byte data-in (HW-confirmed) 2026-09-15 17:20:51 -07:00
Matthew Jackson 7da4b3bba4 freemkv-unlock: de-expose HRL wipe-once — deferred, removed from public ABI
Remove the HRL_WIPE_ONCE const, the wipe_hrl_once() setter, and their tests/doc references from the host library so wipe-once is no longer part of the host-facing firmware ABI. HRL exposes only skip (0x01).
2026-09-15 17:06:14 -07:00
Matthew Jackson b4e5d90c81 firmware: mirror BD-disable wire change to STATE_BD_DISABLE (0x02)
Lockstep with freemkv-fw BD-refuse stealth fix: the BD force-disable wire
value moves from STATE_OFF (0x00, the SRAM boot value) to a distinct
sentinel STATE_BD_DISABLE (0x02), so an unarmed firmware image stays
OEM-identical. disable_bd() now sends 0x02; add the const and align the
Feature::Bd / Feature::Uhd ABI-mirror docs (enable dir / STATE_OFF are
OEM no-ops).
2026-09-15 14:15:39 -07:00
Matthew Jackson bce5f36ef2 aacs: honor allow_p256 in up-front cert-skip gate + zeroize key material
Fix two re-audit findings:

1. LOW (correctness): run_cert_handshake_with_anchors computed the
   up-front cert-skip has_v2 gate WITHOUT allow_p256, unlike
   attempt_one_cert. In production (allow_p256=false) a dead-v1 cert
   carrying v2 creds was not skipped up front, reached the drive, failed
   the step-7 self-verify, and consumed a MAX_CERT_ATTEMPTS slot -- so
   [dead+v2 x3, valid] exhausted the cap and never tried the valid cert.
   Mirror attempt_one_cert's gate exactly. Add a regression test.

2. LOW (key hygiene): add the zeroize crate and wipe sensitive key
   material on drop -- HostCert private keys, AacsAuth/CertHandshake
   derived bus_key/volume_id/read_data_key (ZeroizeOnDrop, non-secret
   fields skipped), the ephemeral AKE scalars and raw private-scalar byte
   buffers (Zeroizing). BigUint scalars/shared-x are a documented
   best-effort residual (num-bigint exposes no zeroization). Debug
   redaction and the EC math are untouched.
2026-09-15 14:02:45 -07:00
Matthew Jackson 8d60463539 aacs+css: fix v1.6.0→dev audit findings
MEDIUM (coupled):
- aacs/handshake: on the AACS 1.0 path a type-0x11 drive cert is now
  REJECTED (verify_cert + step-6 drive-key signature always run) instead
  of accepted unverified — the old 0x11-skip did neither yet still ran
  ECDH, an attacker-chosen-bus-key hole. run_cert_handshake routes a
  genuine 0x11 drive to the native P-256 AKE via the v2-cred fallback.
- aacs/handshake: a post-auth VID/MAC failure (AacsVidMac) with v2 creds
  present now falls through to aacs2_authenticate_p256 instead of a
  terminal VidUnavailable, so a backward-compat 2.0 drive that fails the
  1.0 VID MAC still reaches the P-256 path.

LOW:
- Gate the native AACS 2.0 (P-256) path behind AACS2_P256_EXPERIMENTAL
  (default off) at the run_cert_handshake boundary: its cert offsets are
  provisional, so production can't silently mis-verify a real disc.
- Release the AGID on the fully-successful handshake too (was leaked,
  slowly draining the drive's 4-AGID pool).
- Rejection-sample the ephemeral ECDH private key (drop the modulo bias
  of raw % n) in both generate_host_key_pair{,_p256}.
- Guard shared.infinity in compute_bus_key{,_p256} (return None).
- Remove the dead, truncating AacsAuth.drive_cert field.
- Wire UnlockError::NoUsableHostCert for the all-certs-skipped / empty
  path (more accurate than HandshakeRejected).
- css: promote crypt_key's debug_assert! bounds checks to real assert!.

Handshake tests reworked onto an anchor-parametrised seam
(run_cert_handshake_with_anchors / *_with_anchor) so the DriveEmu drives
a genuine LA-signed 1.0 AKE under a self-generated test anchor; adds the
0x11-rejection, VID-MAC→P-256 fallback (HybridDrive), AGID-release,
infinity-guard, NoUsableHostCert and crypt_key-assert tests.
2026-09-15 10:31:26 -07:00
Matthew Jackson a7622678f3 firmware: typed FirmwareControl API mirroring the abi.rs vendor grammar
Add src/firmware: a hand-kept mirror of freemkv-fw/src/abi.rs (Verb/Feature
enums with pinned repr(u8) values, state constants, CDB builders) plus an
ergonomic FirmwareControl<&mut dyn ScsiTransport> with identity/get/set/reset/
dump, typed setters, states(), and the named recipes (arm_oem_bd, arm_oem_uhd,
arm_bypass_bd, arm_bypass_uhd, arm_stealth_oem). A drift test pins every wire
value against abi.rs.

Migrate FreemkvUnlocker off the old SubFn frame onto the new verb/feature
grammar via the shared builders (region-free=Set(Region,on),
riplock=Set(Speed,max), transport unlock=Set(Ake,null), bus-off=Set(Bus,off)).

Wire opt-in arming into AacsUnlocker::arm_before_unlock (OFF by default): when
set, unlock() detects freemkv via IDENTITY and applies a recipe before the AKE;
a non-freemkv drive or a refused recipe is left untouched.
2026-09-15 00:02:47 -07:00
Matthew Jackson ef3a32eca2 aacs: host-cert keypair-validity check + cert-selection fallback
Add `aacs1_keypair_matches(private_key, cert)` — true iff a stored AACS 1.0
host private key matches its certificate's public key (`priv·G ==
cert_pub_key`), reusing the live handshake's own curve math. A genuine,
LA-signed cert can still be paired in a keydb with the wrong private key,
which never establishes a bus key ("KEY NOT ESTABLISHED"); this primitive
distinguishes that DEAD pairing from a live one. Exposed for the key
service's host-cert health gate. Add `AacsHostSignVerify` (code 7016) for a
host-side self-verify guard on our own produced signature.

Also rework `run_cert_handshake` cert selection: on a rejected/revoked cert
the handshake now backs off and tries the next candidate instead of failing,
with a `cert_host_id_hex` helper and DriveEmu extensions + tests covering
single-valid, back-off-to-second, and roll-past-revoked paths.
2026-09-14 23:25:39 -07:00
Matthew Jackson edb8be09ec v1.7.1: bump version (unified release) 2026-09-14 03:34:43 +00:00
Matthew Jackson 32a41fe939 freemkv: trim bus-off comment blocks to comment-guard caps 2026-09-13 20:21:59 -07:00
Matthew Jackson 8533038e62 freemkv: issue trailing best-effort Raw Read 04 03 (bus-off) in full_unlock; add set_bus_off 2026-09-13 20:20:38 -07:00
Matthew Jackson 6b6c3be1dc docs(changelog): 1.7.1 release notes (LibreDrive unlock fix) 2026-09-13 20:01:00 -07:00
Matthew Jackson 422147f6ad ci: bump actions/checkout v4->v7 in identity-guard (dependabot) 2026-09-13 18:30:58 -07:00
Matthew Jackson 72d5b8fb3e ld: trim profile-match comments to satisfy comment-guard caps 2026-09-13 18:24:32 -07:00
Matthew Jackson 407c7f59e3 fix(ld): bind drives whose reported product_id differs from the generic catalog id
find_by_drive_id gated the product-id-blind four-field fallback behind an empty
product_id in 1.7.0, so a drive reporting a specific product_id (e.g. an LG BU40N
reporting "BD-RE BU40N") no longer matched its catalog entry (generic "BD-RE"),
silently skipping the LibreDrive firmware unlocker — the rip fell through to the
AACS host-cert path (LD: no, AACS: yes).

Run the four-field fallback (vendor/revision/vendor_specific/firmware_date)
unconditionally but ambiguity-guarded: bind a UNIQUE catalog match regardless of
the reported product_id (the real-drive case), and require the exact product_id
only when catalogued siblings genuinely share the four-field identity — preserving
the anti-mis-bind protection the 1.7.0 gate intended. Regression test added.

Bump to 1.7.1.
2026-09-13 18:09:50 -07:00
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
59 changed files with 14645 additions and 3134 deletions
+33
View File
@@ -0,0 +1,33 @@
# Version updates are monthly and grouped into one PR per ecosystem.
# Security updates are separate and run whenever an advisory is published.
version: 2
updates:
- package-ecosystem: cargo
directory: /
target-branch: dev
schedule:
interval: monthly
open-pull-requests-limit: 5
groups:
all:
patterns:
- '*'
ignore:
- dependency-name: freemkv-unlock
- dependency-name: libfreemkv
- dependency-name: freemkv-keysources
- dependency-name: freemkv-i18n
- dependency-name: freemkv-engine
- package-ecosystem: github-actions
directory: /
target-branch: dev
schedule:
interval: monthly
open-pull-requests-limit: 5
groups:
all:
patterns:
- '*'
ignore:
- dependency-name: dtolnay/rust-toolchain
- dependency-name: freemkv/.github*
+191 -17
View File
@@ -2,12 +2,23 @@ name: CI
on:
push:
# `dev` is where work lands and where CI must be green. `main` only ever
# moves at release time, to the tagged commit, so a push to it is the
# release validation run rather than day-to-day feedback.
branches: [main, dev]
# 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:
# Rapid dev pushes supersede their own in-progress runs instead of stacking.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Least privilege: no job here writes to the repo.
permissions:
contents: read
# 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
@@ -15,29 +26,88 @@ on:
# seven others down with it, which is why it gets the full OS matrix rather
# than a token Linux run.
#
# Cargo.lock is gitignored (library convention), so `--locked` is deliberately
# NOT used: with no committed lockfile it fails rather than resolves.
# 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:
lint:
# ── 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@v5
- uses: dtolnay/rust-toolchain@1.97.0
- 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, rustfmt
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 clippy --all-targets -- -D warnings
- run: cargo +stable clippy --all-targets -- -D warnings
test:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.97.0
- 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
@@ -45,25 +115,129 @@ jobs:
# `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
# `--tests` skips doctests; compile/run them so doc examples can't rot.
- run: cargo test --doc --all-features
# This crate's breakage takes every dependent down, so build the two direct
# consumers against THIS commit: both dep freemkv-unlock by version, so each
# gets a path patch (bdemu also patches libfreemkv to the sibling).
consumers:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
path: freemkv-unlock
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
with:
checkout: libfreemkv bdemu
patch: libfreemkv freemkv-unlock
self: bdemu
- name: Point libfreemkv at THIS commit
run: |
mkdir -p libfreemkv/.cargo
printf '[patch.crates-io]\nfreemkv-unlock = { path = "../freemkv-unlock" }\n' > libfreemkv/.cargo/config.toml
# Dependents track current Rust, not this crate's MSRV.
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: |
libfreemkv
bdemu
- run: cargo +stable build --all-targets
working-directory: libfreemkv
- run: cargo +stable build --all-targets
working-directory: bdemu
# 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: a never-scheduled job leaves the run pending forever, so
# the branch's required checks never report complete.
needs: resolve-msrv
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: macos-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.97.0
- 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: a never-scheduled job leaves the run pending forever, so
# the branch's required checks never report complete.
needs: resolve-msrv
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: windows-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.97.0
- 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
+12
View File
@@ -0,0 +1,12 @@
name: comment-guard
# Thin caller → the canonical reusable guard in freemkv/.github (one definition
# for every repo). Fork PRs still run it; same-repo pushes aren't double-run.
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
comment-guard:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: freemkv/.github/.github/workflows/guard-comment.yml@v2
+16
View File
@@ -0,0 +1,16 @@
name: doc-links
# Thin caller -> the canonical reusable doc-links gate in freemkv/.github.
# freemkv-unlock is the leaf crate: no siblings to check out, no [patch].
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
doc-links:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: freemkv/.github/.github/workflows/guard-doclinks.yml@v1
with:
self: freemkv-unlock
+14
View File
@@ -0,0 +1,14 @@
name: identity-guard
# Thin caller → the canonical reusable guard in freemkv/.github.
on:
push:
branches: [dev, qa, main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
identity:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: freemkv/.github/.github/workflows/guard-identity.yml@v1
+6 -30
View File
@@ -1,35 +1,11 @@
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.
# Thin caller → the canonical reusable guard in freemkv/.github.
on: [push, pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
leak-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
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 }}"
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: freemkv/.github/.github/workflows/guard-leak.yml@v1
+147
View File
@@ -0,0 +1,147 @@
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. That coverage
# lives in freemkv's qa.yml (the real-media EC2 matrix), not in this file.
on:
push:
branches: [qa]
workflow_dispatch:
# A re-push to qa supersedes its own in-progress run instead of stacking.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Least privilege by default; only rc-tag escalates (job-level contents: write).
permissions:
contents: read
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
+276
View File
@@ -1,5 +1,281 @@
# Changelog
## [1.8.2] — Unreleased
### Changed
- Released with freemkv 1.8.2 so every component reports the same version. No changes.
## [1.8.1] — 2026-10-07
### Fixed
- Renesas drives: more reliable VID reading from the drive, including drives with an optional VID slot, and a hardened OEM unlock.
### Maintenance
- `pioneer-optical` 0.10 (the 0.7 line is yanked). The vendor CDB bytes are unchanged, and a test pins them.
## [1.8.0] — 2026-10-05
### Changed
- The Renesas/Pioneer unlocker builds its vendor CDBs (identity read, gated memory reads, read-unlock knock) with the `pioneer-optical` crate instead of hard-coded bytes; the commands sent are unchanged.
- AACS handshake diagnostics that a scan now reports itself log below WARN: unsupported or unverifiable drive certs, VID read failures and transport faults at DEBUG, `read_data_key_dropped` at INFO.
## [1.7.7] — 2026-09-26
### Maintenance
- Replace comment-overflow documentation with concise source contracts and README instructions; enforce the shared comment policy in CI.
## [1.7.6] — 2026-09-26
### Changed
- firmware: mirror the fw 0.9.2 ABI — `Feature::Bd` (wire id `0x04`) is retired, and `Feature::Uhd` is renamed to `Feature::Unrestricted` (wire id `0x03`). `ALL_FEATURES` shrinks from 6 to 5. Required to talk to firmware 0.9.2. **Breaking**: any downstream code naming `Feature::Bd` or `Feature::Uhd` must be updated.
- Version aligned to 1.7.6 for the unified release alongside the freemkv 1.7.6 Linux desktop shell (issue #56).
## [1.7.5] — 2026-09-23
### Changed
- firmware: mirror the fw 0.9.0 ABI — `Ake` (`0x06`) and `Bus` (`0x07`) are retired into a single `Encryption` (`0x06`) lever, so the unlock recipe now sets one flag instead of two and wire id `0x07` is gone. Required to talk to firmware 0.9.0.
## [1.7.4] — 2026-09-21
### Maintenance
- CI consolidated to the central reusable workflows; no functional changes.
## [1.7.3] — 2026-09-19
### Changed
- firmware: mirror the fw 0.8.3 RESET ABI — added `RESET_TO_DEFAULTS` (`0x01`, restore the baked create-time defaults into RAM) and `FirmwareControl::reset_to_defaults()`; `RESET_TO_OEM` (`0xFF`) is now documented as TRUE OEM (RAM passthrough **and** the NV block blanked to all-`0xFF`, so the drive is traceless), and `RESET_TO_FLASH` as marker-gated (a never-saved drive loads the defaults). Wire values unchanged.
## [1.7.2] — UNRELEASED
### Fixed
- `FreemkvUnlocker::set` now issues the vendor SET over the required 64-byte data-in phase (mirroring `FirmwareControl::set`). freemkv firmware 0.8.x aborts a SET sent with no data phase (CHECK CONDITION / Aborted Command), so every drive-feature SET failed — including the load-bearing `Set(Ake, off)` — and the freemkv firmware unlock silently fell back to the AACS/online-key route. Drives on freemkv firmware now unlock via the freemkv route again (hardware-confirmed on BU40N fw 0.8.1).
### Changed
- firmware: mirror ABI v2 — `Save` + `RESET`-to-flash/OEM modes, the region/speed value encodings, and the HRL/AKE/BUS `off = unlock` (`0x00`) polarity; `full_unlock`'s default profile flipped to match. Aligns `FirmwareControl` with freemkv firmware 0.8.0.
## [1.7.1] — 2026-09-14
### Added
- freemkv firmware: `full_unlock` now issues a trailing best-effort Raw Read `04 03` (data-clear / bus-off) after the VID read, so an unlocked drive is placed in bus-off mode and subsequent content `READ(10)`s return AACS-at-rest bytes without the in-transit AACS bus wrap. A no-op on firmware without the `04 03` lever and never discards an obtained unlock, so it is inert until the firmware side ships. Also exposed as `set_bus_off`.
### Fixed
- `ld/profile`: `find_by_drive_id` now binds a UNIQUE four-field identity match even when the drive reports a specific `product_id` the catalog stores generically (drive reports "BD-RE BU40N", catalog "BD-RE"). The 1.7.0 gating on an empty product id broke LibreDrive unlock on UHD-capable LG BU40N drives; this restores it.
## [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.
+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
+416
View File
@@ -0,0 +1,416 @@
# 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",
]
[[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.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",
"rand_core",
]
[[package]]
name = "cipher"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common",
"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.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[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.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
]
[[package]]
name = "freemkv-unlock"
version = "1.8.0"
dependencies = [
"aes",
"base64",
"num-bigint",
"num-traits",
"pioneer-optical",
"rand",
"serde",
"serde_json",
"sha1",
"sha2",
"tracing",
"zeroize",
]
[[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 = "pioneer-optical"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf5074a27cc39fb664d0b994814ffe8530e3e8590aff0d104723332dbcb1abc4"
[[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.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
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.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[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 = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[patch.unused]]
name = "libfreemkv"
version = "1.7.7"
+17 -8
View File
@@ -1,8 +1,8 @@
[package]
name = "freemkv-unlock"
version = "1.6.0"
version = "1.8.1"
edition = "2024"
rust-version = "1.97"
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"
@@ -18,11 +18,20 @@ 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"
sha1 = "0.11"
sha2 = "0.11"
aes = "0.9"
rand = "0.10"
# Wipes host private keys, ephemeral scalars, and derived bus/volume/data-key
# material on drop so it doesn't linger in freed memory (defence in depth atop
# the redacting Debug impls). `derive` gives ZeroizeOnDrop for the key structs.
zeroize = { version = "1", features = ["derive"] }
# Pioneer/Renesas vendor CDB constructors (identity read, gated memory read, knock).
pioneer-optical = "0.10"
[lints.clippy]
uninlined_format_args = "allow"
+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/.
+14 -1
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.
@@ -32,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: MIT.
## 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.
-120
View File
@@ -1,120 +0,0 @@
#!/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"]
+22
View File
@@ -20,11 +20,26 @@ pub enum Error {
AacsKeyRead,
AacsKeyRejected,
AacsKeyVerify,
/// Our OWN just-produced host signature failed to verify against our host
/// certificate's public key (libaacs-style self-verify guard). This means
/// the loaded host cert's private key does not match its certificate — a
/// mispaired keydb entry — so shipping the signature would only earn an
/// opaque "KEY NOT ESTABLISHED" from the drive. Fail fast, host-side.
AacsHostSignVerify,
AacsNoKeys,
AacsVidMac,
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`].
@@ -49,16 +64,19 @@ impl Error {
Error::AacsKeyRead => 7007,
Error::AacsKeyRejected => 7008,
Error::AacsKeyVerify => 7009,
Error::AacsHostSignVerify => 7016,
Error::AacsNoKeys => 7010,
Error::AacsVidMac => 7011,
Error::AacsVidRead => 7012,
Error::HandshakeRejected => 7013,
Error::VidUnavailable => 7014,
Error::ShortTransfer { .. } => 7015,
Error::Scsi { .. } => 7099,
}
}
/// The parsed sense for a CHECK CONDITION SCSI error, else `None`.
#[cfg(test)]
pub fn scsi_sense(&self) -> Option<ScsiSense> {
match self {
Error::Scsi { sense, .. } => *sense,
@@ -86,3 +104,7 @@ impl From<crate::scsi::ScsiError> for Error {
}
}
}
#[cfg(test)]
#[path = "error_tests.rs"]
mod tests;
+138
View File
@@ -0,0 +1,138 @@
use super::*;
/// Every variant has its own stable numeric code. Constructing all 17
/// 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::AacsHostSignVerify.code(), 7016);
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"),
}
}
+774 -995
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+164 -92
View File
@@ -8,125 +8,197 @@
mod error;
mod handshake;
use aes::Aes128;
use aes::cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray};
/// Keypair-validity check for a stored AACS 1.0 host cert (`priv·G ==
/// cert_pub_key`). Exposed for the key service, which must refuse to serve a
/// cert whose stored private key doesn't match its certificate.
pub use handshake::aacs1_keypair_matches;
use aes::Aes128;
use aes::cipher::{Array, BlockCipherDecrypt, KeyInit};
use crate::firmware::{ArmRecipe, FirmwareControl, FirmwareError};
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>,
/// Opt-in: a freemkv firmware recipe to ARM the drive with before the cert
/// AKE. `None` (the default) keeps behaviour byte-identical to a stock host
/// — the drive is never touched with a vendor command. See
/// [`AacsUnlocker::arm_before_unlock`].
arm: Option<ArmRecipe>,
/// TEST-ONLY seam: a self-generated AACS 1.0 LA anchor to run the cert AKE
/// under. Production verifies against the real compiled-in anchor, which no
/// synthetic drive emulator can sign for, so the end-to-end unlock tests
/// drive the handshake under a test anchor instead. `None` in every real
/// build (the field does not exist off `cfg(test)`).
#[cfg(test)]
test_v1_anchor: Option<([u8; 20], [u8; 20])>,
}
impl AacsCert {
pub fn new() -> Self {
AacsCert
impl AacsUnlocker {
pub fn new(host_certs: Vec<HostCert>) -> Self {
AacsUnlocker {
host_certs,
arm: None,
#[cfg(test)]
test_v1_anchor: None,
}
}
/// TEST-ONLY: run the cert AKE under a self-generated AACS 1.0 LA anchor
/// (see [`AacsUnlocker::test_v1_anchor`]).
#[cfg(test)]
pub(crate) fn with_test_v1_anchor(mut self, la_x: [u8; 20], la_y: [u8; 20]) -> Self {
self.test_v1_anchor = Some((la_x, la_y));
self
}
/// Run the cert handshake against the drive. Production threads the real LA
/// anchors via [`handshake::run_cert_handshake`]; a test may override the
/// AACS 1.0 anchor so a synthetic drive can complete the AKE.
fn run_handshake(
&self,
scsi: &mut dyn ScsiTransport,
) -> std::result::Result<handshake::CertHandshake, UnlockError> {
#[cfg(test)]
if let Some((ax, ay)) = self.test_v1_anchor {
// v2 anchor is unused here: allow_p256 = false keeps the native 2.0
// path off, so a zeroed placeholder is never read.
let dummy_v2 = [0u8; 32];
return handshake::run_cert_handshake_with_anchors(
scsi,
&self.host_certs,
(&ax, &ay),
(&dummy_v2, &dummy_v2),
false,
);
}
handshake::run_cert_handshake(scsi, &self.host_certs)
}
/// Opt IN (off by default) to arming a freemkv drive with `recipe` via
/// [`FirmwareControl::arm`] before the cert AKE; non-freemkv or unsupported
/// firmware gets only IDENTITY. An `Encryption=off` recipe (`BypassBd`,
/// `BypassUhd`, `OemUhd`) pre-authenticates and de-busses the drive, so no
/// cert is needed and `unlock()` returns the bare-read VID with no bus key.
/// [`ArmRecipe::OemBd`] (`Hrl=off`) lets a revoked cert through the AKE. A
/// recipe refused midway is not rolled back: its earlier SETs stay in RAM.
pub fn arm_before_unlock(mut self, recipe: ArmRecipe) -> Self {
self.arm = Some(recipe);
self
}
/// If arming is opted-in, apply the recipe. `Ok(true)` only when an
/// `Encryption=off` recipe armed (drive pre-authenticated + de-bussed).
/// Unsupported firmware or a refused recipe is `Ok(false)` (a refusal may
/// leave earlier SETs applied, so it logs at warn); a dead bus aborts.
fn maybe_arm(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
let Some(recipe) = self.arm else {
return Ok(false);
};
match FirmwareControl::new(scsi).arm(recipe) {
Ok(()) => {
tracing::debug!(
target: "freemkv::disc",
phase = "aacs_arm_applied",
recipe = ?recipe,
"freemkv firmware armed before the cert AKE"
);
Ok(recipe.disables_encryption())
}
Err(FirmwareError::Transport) => Err(UnlockError::Transport),
Err(FirmwareError::UnsupportedFirmware) => {
tracing::debug!(
target: "freemkv::disc",
phase = "aacs_arm_unsupported",
recipe = ?recipe,
"not supported freemkv firmware; plain cert AKE"
);
Ok(false)
}
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
phase = "aacs_arm_recipe_refused",
recipe = ?recipe,
error = ?e,
"arm recipe refused (earlier SETs stay applied); plain cert AKE"
);
Ok(false)
}
}
}
}
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), or
/// after an opted-in `Encryption=off` arm (bare VID, no bus key);
/// `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> {
) -> std::result::Result<Option<Unlocked>, UnlockError> {
if ctx.kind != DiscKind::Aacs {
return Err(UnlockError::NotApplicable);
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);
// Opt-in arm (no-op by default). Encryption=off leaves the drive
// pre-authenticated and de-bussed: no cert AKE, and no bus key to apply.
if self.maybe_arm(scsi)? {
let vid = crate::vid::read_aacs_vid(scsi)?;
return Ok(Some(Unlocked { vid, bus_key: None }));
}
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,
})
if self.host_certs.is_empty() {
// No host cert to authenticate with — fall through.
return Ok(None);
}
crate::fallthrough(self.run_handshake(scsi).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::info!(
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,
}
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
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.
#[test]
fn unlock_bus_declines_non_aacs_kinds() {
struct DeadTransport;
impl ScsiTransport for DeadTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: crate::scsi::DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
panic!("transport must not be touched on a non-AACS disc");
}
}
let id = id();
let 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:?}");
}
}
/// With no host certs there is nothing to authenticate with → NoUsableHostCert,
/// and the transport is never touched.
#[test]
fn no_host_certs_is_no_usable_host_cert() {
struct DeadTransport;
impl ScsiTransport for DeadTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: crate::scsi::DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
panic!("transport must not be touched with no host certs");
}
}
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);
}
}
#[path = "mod_tests.rs"]
mod tests;
+460
View File
@@ -0,0 +1,460 @@
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()
}
/// 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 declines_non_aacs_kinds() {
struct DeadTransport;
impl ScsiTransport for DeadTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: crate::scsi::DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
panic!("transport must not be touched on a non-AACS disc");
}
}
let id = id();
let mut t = DeadTransport;
for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Css] {
assert!(
AacsUnlocker::new(vec![host_cert()])
.unlock(&mut t, &UnlockCtx::new(&id, k))
.expect("declines")
.is_none(),
"declines {k:?}"
);
}
}
// 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 (lax, lay) = (t.la_x, t.la_y);
let id = id();
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
let out = AacsUnlocker::new(vec![host_cert()])
.with_test_v1_anchor(lax, lay)
.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());
}
// The `read_data_key_dropped` path: auth + VID succeed but the drive serves
// no bus key (non-transport). `unlock` still reports Unlocked (unlocked via
// the cert) with `vid: Some` but `bus_key: None` — surfaced, not hidden.
#[test]
fn unlock_without_a_served_read_data_key_yields_vid_but_no_bus_key() {
let mut t = handshake::tests::DriveEmu::new();
t.serve_zero_data_keys = true; // GOOD status, all-zero key block
let (lax, lay) = (t.la_x, t.la_y);
let id = id();
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
let out = AacsUnlocker::new(vec![host_cert()])
.with_test_v1_anchor(lax, lay)
.unlock(&mut t, &ctx)
.expect("auth + VID succeed even when the bus key is refused")
.expect("the cert route still unlocks the drive");
assert_eq!(out.vid, Some([0x5Au8; 16]), "the VID was learned");
assert!(
out.bus_key.is_none(),
"no read_data_key served ⇒ bus_key: None (reported, not faked)"
);
}
fn host_cert() -> crate::HostCert {
handshake::tests::dummy_cert()
}
// ── Opt-in arm-before-unlock ──────────────────────────────────────────────
/// The default (no arm opted-in) NEVER touches the transport for arming.
#[test]
fn no_arm_by_default_does_not_probe_firmware() {
struct PanicTransport;
impl ScsiTransport for PanicTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: crate::scsi::DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
panic!("default AacsUnlocker must not issue a firmware knock");
}
}
let u = AacsUnlocker::new(vec![host_cert()]);
assert!(u.arm.is_none());
u.maybe_arm(&mut PanicTransport).expect("no-op");
}
/// Opting in on a NON-freemkv drive probes once (IDENTITY) and then leaves
/// the drive untouched — no recipe SETs are sent.
#[test]
fn arm_on_non_freemkv_drive_is_a_noop_after_the_probe() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::illegal_request());
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
u.maybe_arm(&mut t).expect("no-op on non-freemkv");
// Exactly one CDB: the IDENTITY probe. No recipe SETs followed.
assert_eq!(t.cdbs.len(), 1);
assert_eq!(t.cdbs[0][4], crate::firmware::Verb::Identity as u8);
}
/// A dead bus on the arm probe aborts the whole unlock.
#[test]
fn arm_probe_transport_fault_aborts() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::TransportFault);
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
assert_eq!(u.maybe_arm(&mut t).unwrap_err(), UnlockError::Transport);
}
/// A freemkv fw front-end: answers the vendor knock (IDENTITY / SET / GET)
/// like fw `version` does, and delegates every other CDB to `inner`.
struct FwFront<T> {
inner: T,
version: &'static str,
/// Feature flag table indexed by wire id (slot 0 unused).
flags: [u8; 7],
refuse_sets: bool,
dead_after_identity: bool,
cdbs: Vec<Vec<u8>>,
}
impl<T> FwFront<T> {
fn new(inner: T) -> Self {
FwFront {
inner,
version: "0.9.2",
flags: [crate::firmware::STATE_PASSTHROUGH; 7],
refuse_sets: false,
dead_after_identity: false,
cdbs: Vec::new(),
}
}
/// Whether any cert-AKE command (REPORT KEY / SEND KEY) was issued.
fn ran_cert_ake(&self) -> bool {
self.cdbs
.iter()
.any(|c| c[0] == crate::scsi::SCSI_REPORT_KEY || c[0] == crate::scsi::SCSI_SEND_KEY)
}
fn sent_a_set(&self) -> bool {
use crate::firmware::{CDB_VERB, READ_BUFFER_OPCODE, Verb};
self.cdbs
.iter()
.any(|c| c[0] == READ_BUFFER_OPCODE && c[CDB_VERB] == Verb::Set as u8)
}
}
impl<T: ScsiTransport> ScsiTransport for FwFront<T> {
fn execute(
&mut self,
cdb: &[u8],
dir: crate::scsi::DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
use crate::firmware::{CDB_FEATURE, CDB_STATE, CDB_VERB, READ_BUFFER_OPCODE, Verb};
self.cdbs.push(cdb.to_vec());
if cdb[0] != READ_BUFFER_OPCODE {
return self.inner.execute(cdb, dir, data, timeout_ms);
}
let status = |status: u8, n: usize| {
Ok(crate::scsi::ScsiResult {
status,
bytes_transferred: n,
sense: [0u8; 32],
})
};
let verb = cdb[CDB_VERB];
let f = (cdb[CDB_FEATURE] as usize).min(6);
if verb == Verb::Identity as u8 {
let mut flags = [0u8; 6];
flags.copy_from_slice(&self.flags[1..]);
let mut p = crate::firmware::identity_reply(self.version, flags);
p.resize(data.len(), 0);
data.copy_from_slice(&p[..data.len()]);
return status(0, data.len());
}
if self.dead_after_identity {
return Err(crate::scsi::ScsiError {
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
}
if verb == Verb::Set as u8 {
if self.refuse_sets {
return status(crate::scsi::SCSI_STATUS_CHECK_CONDITION, 0);
}
self.flags[f] = cdb[CDB_STATE];
} else if verb == Verb::Get as u8 {
data.fill(0);
data[0] = self.flags[f];
}
status(0, data.len())
}
// Forward the Stop methods (stop-design-v5 §2.3): the defaults would
// swallow them, so wrapped tests would stop exercising cancellation.
fn pause(&mut self, d: std::time::Duration) -> crate::scsi::Result<()> {
self.inner.pause(d)
}
fn begin_critical(&mut self) -> crate::scsi::Result<()> {
self.inner.begin_critical()
}
fn end_critical(&mut self) {
self.inner.end_critical()
}
fn execute_cleanup(
&mut self,
cdb: &[u8],
dir: crate::scsi::DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
self.cdbs.push(cdb.to_vec());
self.inner.execute_cleanup(cdb, dir, data, timeout_ms)
}
}
// UT6 (stop-design-v5 §5.2; §2.3 "The test-only `FwFront<T>` … forwards all
// four"): otherwise every FwFront-wrapped test silently stops exercising Stop.
#[test]
fn fwfront_forwards_new_methods() {
use crate::scsi::DataDirection;
use crate::scsi::mock::{Ev, MockTransport, Reply, StopFake};
let inner = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 2])));
let mut t = FwFront::new(inner);
let d = std::time::Duration::from_millis(1);
t.pause(d).expect("forwarded pause");
t.begin_critical().expect("forwarded begin");
t.end_critical();
let rel = [0xA4, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0x3F, 0];
t.execute_cleanup(&rel, DataDirection::FromDevice, &mut [0u8; 2], 5_000)
.expect("forwarded cleanup");
assert_eq!(
t.inner.log,
vec![Ev::Pause(d), Ev::Begin, Ev::End, Ev::Cleanup(rel.to_vec())],
"all four reach the wrapped transport"
);
t.inner.cancelled = true;
assert!(t.pause(d).is_err(), "a cancel reaches through the wrapper");
assert!(t.begin_critical().is_err());
}
fn vid_reply(vid: [u8; 16]) -> crate::scsi::mock::MockTransport {
use crate::scsi::mock::{MockTransport, Reply};
let mut p = vec![0u8; 36];
p[4..20].copy_from_slice(&vid);
MockTransport::always(Reply::good(p))
}
fn aacs_ctx(id: &crate::DriveId) -> UnlockCtx<'_> {
UnlockCtx::new(id, DiscKind::Aacs)
}
/// Opting in on a freemkv drive applies the recipe: BypassBd sends
/// Set(Hrl, off) and Set(Encryption, off), each verified, after the
/// IDENTITY gate and BEFORE the handshake would run.
#[test]
fn arm_on_freemkv_drive_applies_the_recipe() {
use crate::firmware::{CDB_VERB, Feature, STATE_OFF, Verb, build_set_cdb};
let mut t = FwFront::new(vid_reply([0x42; 16]));
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
assert!(u.maybe_arm(&mut t).expect("armed"));
assert_eq!(t.flags[Feature::Hrl as usize], STATE_OFF);
assert_eq!(t.flags[Feature::Encryption as usize], STATE_OFF);
assert_eq!(t.cdbs.len(), 5);
assert_eq!(t.cdbs[0][CDB_VERB], Verb::Identity as u8);
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(t.cdbs[2][CDB_VERB], Verb::Get as u8);
assert_eq!(t.cdbs[3], build_set_cdb(Feature::Encryption, STATE_OFF));
assert_eq!(t.cdbs[4][CDB_VERB], Verb::Get as u8);
}
/// Regression: an Encryption=off recipe needs NO host cert. With an empty
/// cert list the drive is still armed and the unlock is the bare VID read —
/// no cert AKE, no bus key (content already comes back de-bussed).
#[test]
fn encryption_off_recipes_unlock_without_certs_via_the_bare_vid() {
for recipe in [ArmRecipe::BypassBd, ArmRecipe::BypassUhd, ArmRecipe::OemUhd] {
let mut t = FwFront::new(vid_reply([0x42; 16]));
let id = id();
let out = AacsUnlocker::new(vec![])
.arm_before_unlock(recipe)
.unlock(&mut t, &aacs_ctx(&id))
.expect("no fault")
.unwrap_or_else(|| panic!("{recipe:?}: armed drive is unlocked"));
assert_eq!(out.vid, Some([0x42; 16]), "{recipe:?}");
assert_eq!(out.bus_key, None, "{recipe:?}: no double bus-decrypt");
assert!(!t.ran_cert_ake(), "{recipe:?}");
}
}
/// Armed Encryption=off, then the bare VID read: a dead bus there aborts
/// with Transport; a drive rejection is still an unlock, just without a VID.
#[test]
fn encryption_off_arm_then_bare_vid_failure() {
use crate::scsi::mock::{MockTransport, Reply};
for (reply, want) in [
(Reply::TransportFault, Err(UnlockError::Transport)),
(Reply::illegal_request(), Ok(Some((None, None)))),
(Reply::illegal_request_as_err(), Ok(Some((None, None)))),
] {
let mut t = FwFront::new(MockTransport::always(reply));
let id = id();
let got = AacsUnlocker::new(vec![host_cert()])
.arm_before_unlock(ArmRecipe::BypassBd)
.unlock(&mut t, &aacs_ctx(&id))
.map(|o| o.map(|u| (u.vid, u.bus_key)));
assert_eq!(got, want);
assert!(t.sent_a_set(), "the drive was armed first");
assert!(!t.ran_cert_ake());
}
}
/// Regression: with certs present, an Encryption=off arm still skips the
/// cert AKE and never reports a bus key.
#[test]
fn encryption_off_recipe_with_certs_skips_the_cert_ake() {
let mut t = FwFront::new(vid_reply([0x42; 16]));
let id = id();
let out = AacsUnlocker::new(vec![host_cert()])
.arm_before_unlock(ArmRecipe::BypassBd)
.unlock(&mut t, &aacs_ctx(&id))
.expect("no fault")
.expect("unlocked");
assert_eq!(out.bus_key, None);
assert!(!t.ran_cert_ake());
}
/// A refused recipe (and OemBd, which leaves encryption on) proceeds to the
/// plain cert AKE, which still unlocks the drive and learns the bus key.
#[test]
fn refused_or_cert_recipes_run_the_cert_ake() {
for (recipe, refuse) in [(ArmRecipe::BypassBd, true), (ArmRecipe::OemBd, false)] {
let mut emu = handshake::tests::DriveEmu::new();
emu.serve_data_keys = true;
let (lax, lay) = (emu.la_x, emu.la_y);
let mut t = FwFront::new(emu);
t.refuse_sets = refuse;
let id = id();
let out = AacsUnlocker::new(vec![host_cert()])
.with_test_v1_anchor(lax, lay)
.arm_before_unlock(recipe)
.unlock(&mut t, &aacs_ctx(&id))
.expect("no fault")
.expect("the cert route unlocks");
assert_eq!(out.vid, Some([0x5Au8; 16]), "{recipe:?}");
assert!(out.bus_key.is_some(), "{recipe:?}");
assert!(t.ran_cert_ake(), "{recipe:?}");
}
}
/// Regression: fw 0.8.x is below MIN_FW_VERSION, so the recipe is never
/// sent and the plain cert route runs.
#[test]
fn unsupported_firmware_is_not_armed() {
let mut emu = handshake::tests::DriveEmu::new();
emu.serve_data_keys = true;
let (lax, lay) = (emu.la_x, emu.la_y);
let mut t = FwFront::new(emu);
t.version = "0.8.3";
let id = id();
let out = AacsUnlocker::new(vec![host_cert()])
.with_test_v1_anchor(lax, lay)
.arm_before_unlock(ArmRecipe::BypassBd)
.unlock(&mut t, &aacs_ctx(&id))
.expect("no fault")
.expect("the cert route unlocks");
assert!(out.bus_key.is_some());
assert!(!t.sent_a_set(), "no recipe SET on unsupported firmware");
}
/// A dead bus mid-arm aborts the whole unlock, with or without certs.
#[test]
fn dead_bus_during_arm_aborts_the_unlock() {
for certs in [vec![host_cert()], vec![]] {
let mut t = FwFront::new(vid_reply([0x42; 16]));
t.dead_after_identity = true;
let id = id();
let n = certs.len();
let err = AacsUnlocker::new(certs)
.arm_before_unlock(ArmRecipe::BypassBd)
.unlock(&mut t, &aacs_ctx(&id))
.unwrap_err();
assert_eq!(err, UnlockError::Transport, "{n} certs");
assert!(!t.ran_cert_ake());
}
}
/// With no host certs there is nothing to authenticate with → `Ok(false)`,
/// and the transport is never touched.
#[test]
fn no_host_certs_declines() {
struct DeadTransport;
impl ScsiTransport for DeadTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: crate::scsi::DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
panic!("transport must not be touched with no host certs");
}
}
let id = id();
let mut t = DeadTransport;
assert!(
AacsUnlocker::new(vec![])
.unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs))
.expect("no certs declines")
.is_none()
);
}
+29 -5
View File
@@ -19,6 +19,27 @@ 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 crate::scsi::is_dead_bus(s))
}
}
/// 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 +52,14 @@ 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 {
match e {
Error::Scsi(s) if s.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE => {
crate::UnlockError::Transport
}
_ => crate::UnlockError::NotApplicable,
if e.is_transport_failure() {
crate::UnlockError::Transport
} else {
crate::UnlockError::NotApplicable
}
}
}
#[cfg(test)]
#[path = "error_tests.rs"]
mod tests;
+79
View File
@@ -0,0 +1,79 @@
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(inner) => {
assert_eq!(inner.status, SCSI_STATUS_CHECK_CONDITION);
assert_eq!(inner.sense, Some([1u8; 32]));
}
_ => panic!("expected Error::Scsi"),
}
}
+186 -587
View File
@@ -4,13 +4,47 @@
//! CSS bus-auth handshake has set its Authentication Success Flag (ASF=1).
//! [`unlock_css_reads`] runs that bus-auth challenge-response (which is what
//! actually opens scrambled-sector reads), then a best-effort, non-fatal
//! disc-key REPORT KEY. The bytes are NOT used as keys: the descramble title
//! key is recovered keylessly by the Stevenson known-plaintext attack (see
//! [`super::crack_key`]).
//! disc-key REPORT KEY. The bytes are NOT used as keys: descrambling here is
//! keyless — the descramble title key is recovered directly from the stream,
//! so no device or player key is derived or needed.
mod error;
use crate::css::error::{Error, Result};
use crate::scsi::ScsiTransport;
use crate::css::error::{Error, Result, step_err};
use crate::scsi::{AgidGuard, DataDirection, ScsiTransport};
// Issue ONE CSS bus-auth CDB, honouring the transport contract: treats a
// non-zero status as CssAuthFailed and checks bytes_transferred >= min_bytes
// rather than trusting an unfilled buffer.
fn css_scsi(
scsi: &mut dyn ScsiTransport,
cdb: &[u8],
dir: DataDirection,
buf: &mut [u8],
min_bytes: usize,
) -> Result<()> {
let r = scsi.execute(cdb, dir, buf, 5_000).map_err(step_err)?;
if r.status != 0 {
tracing::debug!(
target: "freemkv::css",
phase = "css_step_check_condition",
opcode = cdb.first().copied().unwrap_or(0),
status = r.status,
"CSS bus-auth step returned a drive sense"
);
return Err(Error::CssAuthFailed);
}
if r.bytes_transferred < min_bytes {
tracing::debug!(
target: "freemkv::css",
phase = "css_step_short_response",
opcode = cdb.first().copied().unwrap_or(0),
bytes_transferred = r.bytes_transferred,
"CSS bus-auth step returned a short response"
);
return Err(Error::CssAuthFailed);
}
Ok(())
}
// ── CryptKey tables ───────────────────────────────────────────────────────
@@ -123,9 +157,10 @@ const PERM_VARIANT: [[u8; 32]; 2] = [
/// Runs the bus-auth challenge-response (which sets the drive's ASF=1 and is
/// what actually unlocks scrambled-sector reads), then a best-effort,
/// non-fatal disc-key REPORT KEY. The title-key REPORT KEY is NOT issued: it
/// is unnecessary (the descramble key is recovered keylessly by the Stevenson
/// attack in [`super::crack_key`]) and its hard failure on some USB bridges
/// used to abort the whole unlock (the 7014 bug). The bytes are discarded.
/// is unnecessary (descrambling is keyless — the key is recovered directly
/// from the data, so no device key is derived) and its hard failure on some
/// USB bridges used to abort the whole unlock (the 7014 bug). The bytes are
/// discarded.
pub fn unlock_css_reads(scsi: &mut dyn ScsiTransport, lba: u32) -> Result<()> {
let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::css", phase = "unlock_css_reads", lba, "begin");
@@ -144,8 +179,8 @@ pub fn unlock_css_reads(scsi: &mut dyn ScsiTransport, lba: u32) -> Result<()> {
/// The DVD unlocker (registry name `"DVD"`) — the DVD peer of the firmware and
/// AACS-cert unlockers in the uniform [`crate::Unlocker`] registry. It removes
/// the DVD scrambled-read barrier (drive ASF=1) via bus-auth and learns no VID
/// or bus key — the descramble key is recovered keylessly downstream (the
/// Stevenson attack). Named for the medium it unlocks (DVD), not the CSS
/// or bus key — descrambling is keyless: the key is recovered directly from
/// the data downstream. Named for the medium it unlocks (DVD), not the CSS
/// scheme: the bus-auth is required to read a CSS-protected DVD at all, whether
/// or not any given sector turns out to be scrambled. Lives in the `css` module
/// beside the CSS-scheme primitives it drives.
@@ -165,48 +200,50 @@ impl Default for DvdUnlocker {
impl crate::Unlocker for DvdUnlocker {
fn name(&self) -> &'static str {
// User-facing unlocker label. This unlocker's job is the DVD
// read-enablement bus-auth (it clears the drive's scrambled-read
// barrier and learns no key) — a property of the DVD medium, NOT of
// whether the content happens to be CSS-scrambled. Reporting it as
// "DVD" is honest: on any DVD the bus-auth ran; the CSS descramble
// itself is keyless and handled downstream, so it is not a separate
// "did an unlocker run" signal.
// "DVD" names the medium this bus-auth unlocks, not the CSS scheme:
// the bus-auth runs on any DVD regardless of whether the content is
// actually scrambled.
"DVD"
}
/// CSS removes the scrambled-sector barrier (a bus-level concern); it
/// provides no drive features. Self-guards against the hardware (below), so
/// it declines cleanly when the consumer iterates it on a non-DVD.
fn unlock_bus(
/// CSS removes the scrambled-sector barrier via bus-auth. Self-guards
/// against the hardware (below), so it declines cleanly (`Ok(None)`) when the
/// consumer iterates it on a non-DVD. `Some` once the bus-auth runs;
/// `Err(Transport)` on a dead bus. Yields no VID and no bus key (descrambling
/// is keyless — the returned `Unlocked` is empty).
fn unlock(
&self,
scsi: &mut dyn ScsiTransport,
_ctx: &crate::UnlockCtx,
) -> std::result::Result<crate::Unlocked, crate::UnlockError> {
// Self-guard against the hardware — do NOT trust the caller-declared
// DiscKind alone. If the drive does not report a DVD profile, refuse
// (NotApplicable) WITHOUT issuing any CSS CDB, so a mis-routed
// Blu-ray/UHD is never sent CSS bus-auth.
if !mounted_disc_is_dvd(scsi) {
) -> std::result::Result<Option<crate::Unlocked>, crate::UnlockError> {
// Self-guard against the hardware, not just the caller-declared
// DiscKind: decline without issuing any CSS CDB when the drive doesn't
// report a DVD profile.
if !mounted_disc_is_dvd(scsi)? {
tracing::debug!(
target: "freemkv::css",
phase = "dvd_unlocker_not_dvd",
"DvdUnlocker invoked on a non-DVD profile; refusing (NotApplicable)"
"DvdUnlocker invoked on a non-DVD profile; declining"
);
return Err(crate::UnlockError::NotApplicable);
return Ok(None);
}
// The bus-auth handshake is what unlocks scrambled-sector reads; the lba
// is not consumed by the unlock primitive (the disc-key REPORT KEY is
// best-effort). CSS yields neither a Volume ID nor an AACS bus key.
unlock_css_reads(scsi, 0)?;
Ok(crate::Unlocked::default())
// Bus-auth unlocks scrambled-sector reads (the lba isn't consumed here;
// the disc-key REPORT KEY is best-effort). A refused handshake falls
// through (`fallthrough` → `Ok(None)`); only a dead bus is an `Err`.
crate::fallthrough(
unlock_css_reads(scsi, 0)
.map(|()| crate::Unlocked::default())
.map_err(crate::UnlockError::from),
)
}
}
/// Transport-level "is the mounted disc a DVD?" probe (GET CONFIGURATION
/// current-profile, DVD family `0x0010..=0x001F`). Lets the DvdUnlocker
/// self-verify against the drive instead of trusting the caller's DiscKind.
fn mounted_disc_is_dvd(scsi: &mut dyn ScsiTransport) -> bool {
// Probes GET CONFIGURATION current-profile (DVD family 0x0010..=0x001F) so
// DvdUnlocker can self-verify against the drive rather than trust the
// caller's DiscKind.
fn mounted_disc_is_dvd(
scsi: &mut dyn ScsiTransport,
) -> std::result::Result<bool, crate::UnlockError> {
// RT=0: the 8-byte feature header carries the Current Profile in bytes 6-7.
let cdb = [
crate::scsi::SCSI_GET_CONFIGURATION,
@@ -221,35 +258,48 @@ fn mounted_disc_is_dvd(scsi: &mut dyn ScsiTransport) -> bool {
0x00,
];
let mut buf = [0u8; 8];
match scsi.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
) {
Ok(r) if r.bytes_transferred >= 8 => {
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
Ok(r) if r.status == 0 && r.bytes_transferred >= 8 => {
let profile = ((buf[6] as u16) << 8) | buf[7] as u16;
(0x0010..=0x001F).contains(&profile)
Ok((0x0010..=0x001F).contains(&profile))
}
// A drive sense or a short reply genuinely means "can't tell / not a
// DVD" — decline, but say so.
Ok(r) => {
tracing::debug!(
target: "freemkv::css",
phase = "dvd_profile_probe_inconclusive",
status = r.status,
bytes_transferred = r.bytes_transferred,
"GET CONFIGURATION did not report a current profile"
);
Ok(false)
}
Err(e) => {
let err = crate::css::error::step_err(e);
tracing::warn!(
target: "freemkv::css",
phase = "dvd_profile_probe_failed",
error_code = err.code(),
transport_failure = err.is_transport_failure(),
"GET CONFIGURATION failed while probing for a DVD"
);
Err(crate::UnlockError::from(err))
}
_ => false,
}
}
fn unlock_css_reads_inner(scsi: &mut dyn ScsiTransport, _lba: u32) -> Result<()> {
tracing::debug!(target: "freemkv::css", "css unlock: begin");
// The bus-auth challenge-response sets the drive's Authentication Success
// Flag (ASF=1), which is what opens scrambled-sector reads. This is the
// ONLY step required to unlock reads; a failure here is fatal — we
// genuinely cannot read scrambled sectors.
// The bus-auth challenge-response sets ASF=1, which is what opens
// scrambled-sector reads. It's the only required step, so a failure
// here is fatal.
let agid = establish_authenticated_session(scsi).inspect_err(|e| {
tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: bus authentication failed");
})?;
tracing::debug!(target: "freemkv::css", agid, "css unlock: bus authentication ok");
// Disc-key REPORT KEY: issued BEST-EFFORT for any firmware that ties part
// of its read-unlock to it. The bytes are unused (the descramble key is
// recovered keylessly) and a failure is NON-FATAL — the gate is already
// open from bus-auth. This replaces the title-key REPORT KEY, whose hard
// failure used to abort the whole unlock (the 7014 bug on USB bridges).
// Disc-key REPORT KEY: best-effort only, for firmware that ties part of
// its read-unlock to it; bytes are unused and failure is non-fatal.
if let Err(e) = read_disc_key(scsi, agid) {
tracing::debug!(target: "freemkv::css", error_code = e.code(), "css unlock: disc-key REPORT KEY skipped (non-fatal)");
}
@@ -259,48 +309,66 @@ fn unlock_css_reads_inner(scsi: &mut dyn ScsiTransport, _lba: u32) -> Result<()>
// ── Step 1: Bus Authentication ────────────────────────────────────────────
/// Run the CSS bus-authentication challenge-response (invalidate AGIDs →
/// allocate AGID → host challenge → brute-force the variant → drive challenge →
/// send host key). Completing the handshake sets the drive's Authentication
/// Success Flag (ASF=1) — which is the ENTIRE purpose: it unlocks
/// scrambled-sector reads. Returns the negotiated AGID (the caller needs it for
/// the best-effort disc-key REPORT KEY). The CSS bus key is intentionally NOT
/// derived: descrambling is keyless (the Stevenson known-plaintext attack), so
/// the bus key has no consumer.
// Runs the CSS bus-auth handshake to set ASF=1 (invalidate AGIDs, allocate,
// challenge/response). Returns the AGID, still held (D9); no bus key is derived.
fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result<u8> {
// Invalidate all AGIDs via REPORT KEY format 0x3F
// Invalidate all AGIDs via REPORT KEY format 0x3F. This is also what makes
// an abandoned AGID self-heal — which is not a reason to abandon one. Plain
// `execute`: nothing is held yet, so a Stop refuses these (stop-design-v5 §2.3).
for agid in 0..4u8 {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
// alloc_len = 0 (no data transfer)
cdb[10] = (agid << 6) | 0x3F;
let mut buf = [0u8; 8];
let _ = scsi.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
);
invalidate_agid(scsi, agid);
}
// Allocate AGID
let mut buf = [0u8; 8];
scsi.execute(
css_scsi(
scsi,
&report_key_cdb(0, 0x00, 8),
crate::scsi::DataDirection::FromDevice,
DataDirection::FromDevice,
&mut buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
8,
)?;
let agid = (buf[7] >> 6) & 0x03;
// From here on we hold the AGID: a failure drops the guard, which releases
// it even after a Stop (SS-7, evidence: libaacs mmc.c AGID invalidation).
let mut guard = AgidGuard::new(scsi, agid, agid_release_cdb(agid));
authenticate_with_agid(&mut *guard, agid)?;
// D9 (stop-design-v5 §2.3 "defused on success, so it is still held for
// `read_disc_key`"): hand the AGID over unreleased.
Ok(guard.defuse())
}
/// REPORT KEY key format 0x3F for `agid` (invalidate / release it).
fn agid_release_cdb(agid: u8) -> [u8; 12] {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
cdb[10] = (agid << 6) | 0x3F;
cdb
}
/// Best-effort invalidate of an AGID this session does not hold (the
/// pre-allocation sweep).
fn invalidate_agid(scsi: &mut dyn ScsiTransport, agid: u8) {
let mut buf = [0u8; 8];
let _ = scsi.execute(
&agid_release_cdb(agid),
DataDirection::FromDevice,
&mut buf,
5_000,
);
}
/// The challenge-response half of [`establish_authenticated_session`], with the
/// AGID already allocated and held by the caller's [`AgidGuard`].
fn authenticate_with_agid(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> {
// Host sends challenge. The spec wants a fresh per-session random nonce,
// not a fixed constant — a predictable challenge weakens the bus-auth
// handshake.
let mut host_challenge = [0u8; 10];
{
use rand::RngCore;
rand::thread_rng().fill_bytes(&mut host_challenge);
use rand::Rng;
rand::rng().fill_bytes(&mut host_challenge);
}
let mut hc_buf = [0u8; 16];
hc_buf[0] = 0x00;
@@ -308,23 +376,23 @@ fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result<u8> {
for i in 0..10 {
hc_buf[4 + i] = host_challenge[9 - i];
}
scsi.execute(
css_scsi(
scsi,
&send_key_cdb(agid, 0x01, 16),
crate::scsi::DataDirection::ToDevice,
DataDirection::ToDevice,
&mut hc_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
0,
)?;
// Get Key1 from drive
let mut dk_buf = [0u8; 12];
scsi.execute(
css_scsi(
scsi,
&report_key_cdb(agid, 0x02, 12),
crate::scsi::DataDirection::FromDevice,
DataDirection::FromDevice,
&mut dk_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
12,
)?;
let mut key1 = [0u8; 5];
for i in 0..5 {
key1[i] = dk_buf[4 + (4 - i)];
@@ -342,13 +410,13 @@ fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result<u8> {
// Get drive challenge
let mut dc_buf = [0u8; 16];
scsi.execute(
css_scsi(
scsi,
&report_key_cdb(agid, 0x01, 16),
crate::scsi::DataDirection::FromDevice,
DataDirection::FromDevice,
&mut dc_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
16,
)?;
let mut drive_challenge = [0u8; 10];
for i in 0..10 {
drive_challenge[i] = dc_buf[4 + (9 - i)];
@@ -362,29 +430,24 @@ fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result<u8> {
for i in 0..5 {
hk_buf[4 + i] = key2[4 - i];
}
scsi.execute(
css_scsi(
scsi,
&send_key_cdb(agid, 0x03, 12),
crate::scsi::DataDirection::ToDevice,
DataDirection::ToDevice,
&mut hk_buf,
5_000,
)
.map_err(|_| Error::CssAuthFailed)?;
0,
)?;
// The authenticated session (ASF=1) is now established — scrambled-sector
// reads are unlocked, which is the only thing we needed. The CSS bus key
// would be CryptKey(2, variant, key1 || key2), but it has no consumer
// (descrambling is keyless via the Stevenson attack), so it is not derived.
Ok(agid)
// reads are unlocked. The CSS bus key would be CryptKey(2, variant,
// key1 || key2), but has no consumer (descrambling is keyless).
Ok(())
}
// ── Step 2: Disc Key ──────────────────────────────────────────────────────
/// Issue READ DVD STRUCTURE format 0x02 (Copyright Information — opcode 0xAD,
/// NOT the REPORT KEY 0xA4 disc-key block) purely for the bus-auth unlock side
/// effect. The returned block contents are not used — the descramble title key
/// is recovered keylessly elsewhere, so the genuine disc-key REPORT KEY is
/// intentionally skipped. (If a drive is ever found where bus-auth alone does
/// not open scrambled reads, a real REPORT KEY format 0x02 belongs here.)
// Issues READ DVD STRUCTURE format 0x02 (opcode 0xAD) purely for its
// bus-auth side effect; returned bytes are unused.
fn read_disc_key(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> {
// READ DVD STRUCTURE, format 0x02 (disc key), 2048+4 bytes
let alloc_len: u16 = 2048 + 4;
@@ -397,14 +460,10 @@ fn read_disc_key(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> {
cdb[9] = alloc_len as u8;
cdb[10] = agid << 6;
// Best-effort by design (the caller logs and continues), but a transport
// fault still has to be distinguishable from a drive that declined.
let mut buf = vec![0u8; alloc_len as usize];
let dvd_result = scsi.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
);
dvd_result.map_err(|_| Error::CssAuthFailed)?;
css_scsi(scsi, &cdb, DataDirection::FromDevice, &mut buf, 0)?;
Ok(())
}
@@ -412,13 +471,11 @@ fn read_disc_key(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> {
// ── CSSCryptKey ───────────────────────────────────────────────────────────
fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
// key_type indexes PERM_CHALLENGE ([_;3]); variant indexes
// VARIANTS/PERM_VARIANT ([_;32]). All internal callers pass key_type in
// 0..3 and variant in 0..32; the asserts document the contract for the
// pub(crate) test entry point test_crypt_key and turn a would-be
// out-of-bounds panic into an explicit precondition violation.
debug_assert!(key_type < 3, "crypt_key: key_type out of range");
debug_assert!((variant as usize) < 32, "crypt_key: variant out of range");
// key_type indexes PERM_CHALLENGE ([_;3]); variant indexes VARIANTS/
// PERM_VARIANT ([_;32]). Real (not debug-only) asserts catch an OOB index even
// in release; cost is nil on this once-per-disc path.
assert!(key_type < 3, "crypt_key: key_type out of range");
assert!((variant as usize) < 32, "crypt_key: variant out of range");
let perm = &PERM_CHALLENGE[key_type];
let mut scratch = [0u8; 10];
for i in 0..10 {
@@ -567,463 +624,5 @@ fn send_key_cdb(agid: u8, format: u8, param_len: u16) -> [u8; 12] {
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// SECURITY REGRESSION GUARD: no instrumentation in libfreemkv may emit
/// raw key material. Scan every source file for a `tracing` field that
/// binds a forbidden key name to a value-producing expression (`= expr`
/// or `%expr` / `?expr`). The only allowed forms are a string literal
/// (e.g. `disc_key = "<redacted>"`) or a `_fp` fingerprint field.
///
/// This is a source-scan test (not a runtime capture) so it stays cheap
/// and catches re-introductions at compile/CI time.
#[test]
fn no_key_bytes_in_instrumentation() {
use std::path::Path;
// Forbidden field names whose VALUES must never be logged.
const FORBIDDEN: &[&str] = &[
"title_key",
"disc_key",
"unit_key",
"vuk",
"player_key",
"bus_key",
];
fn scan_dir(dir: &Path, forbidden: &[&str], violations: &mut Vec<String>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
scan_dir(&path, forbidden, violations);
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let src = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => continue,
};
for (lineno, line) in src.lines().enumerate() {
let trimmed = line.trim_start();
// Only inspect tracing instrumentation lines.
if !(trimmed.contains("tracing::")
|| trimmed.starts_with("debug!")
|| trimmed.starts_with("info!")
|| trimmed.starts_with("warn!")
|| trimmed.starts_with("trace!")
|| trimmed.starts_with("error!"))
{
continue;
}
// This guard test itself contains the forbidden names.
if path.file_name().and_then(|n| n.to_str()) == Some("auth.rs")
&& line.contains("FORBIDDEN")
{
continue;
}
for &name in forbidden {
// A fingerprint field (`<name>_fp = ...`) is allowed.
// Match `<name>` followed by optional fingerprint
// suffix then `=` and a value that is NOT a string
// literal redaction marker.
if let Some(idx) = line.find(name) {
let after = &line[idx + name.len()..];
let after = after.trim_start();
// `<name>_fp` / `<name>_id` etc. are safe.
if after.starts_with('_') {
continue;
}
// Must be a field binding `name = ...`.
let Some(rest) = after.strip_prefix('=') else {
continue;
};
let rest = rest.trim_start();
// Redaction string literal is the only allowed value.
if rest.starts_with('"') {
continue;
}
// Anything else (`%expr`, `?expr`, bare expr) leaks bytes.
violations.push(format!(
"{}:{}: forbidden key field `{}` logged with a value: {}",
path.display(),
lineno + 1,
name,
line.trim()
));
}
}
}
}
}
// Scan this crate's `src` plus the sibling workspace crates so the
// key-material logging guard covers every crate that can reach the
// CSS/AACS internals, not just libfreemkv. Missing sibling dirs (e.g.
// when building the crate standalone) are simply skipped.
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace = manifest.parent().unwrap_or(manifest);
let mut violations = Vec::new();
scan_dir(&manifest.join("src"), FORBIDDEN, &mut violations);
for sibling in ["autorip", "freemkv", "freemkv-keysources"] {
let dir = workspace.join(sibling).join("src");
if dir.is_dir() {
scan_dir(&dir, FORBIDDEN, &mut violations);
}
}
assert!(
violations.is_empty(),
"key material logged in instrumentation:\n{}",
violations.join("\n")
);
}
#[test]
fn crypt_key_is_deterministic() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for v in 0..32u8 {
let r1 = crypt_key(0, v, &challenge);
let r2 = crypt_key(0, v, &challenge);
assert_eq!(r1, r2);
}
}
#[test]
fn crypt_key_varies_by_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
}
#[test]
fn crypt_key_varies_by_type() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
assert_ne!(crypt_key(0, 5, &challenge), crypt_key(1, 5, &challenge));
}
#[test]
fn crypt_key_nonzero() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for v in 0..32u8 {
assert_ne!(crypt_key(0, v, &challenge), [0u8; 5]);
}
}
// ── CSS constant-table integrity ───────────────────────────────────────
/// Each PERM_CHALLENGE row is a permutation of indices 0..10 (it reorders
/// the 10 challenge bytes). A non-permutation would drop/duplicate
/// challenge bytes, weakening or corrupting the bus key derivation.
///
/// Grounding: crypt_key does `scratch[i] = challenge[perm[i]]` for i in
/// 0..10 — perm must be a bijection on 0..10 to use every challenge byte
/// exactly once.
/// Mutation: change PERM_CHALLENGE[0] entry `9` to `8` (duplicate) -> the
/// "covers 0..10" assert fires.
#[test]
fn perm_challenge_rows_are_permutations() {
for (row, perm) in PERM_CHALLENGE.iter().enumerate() {
let mut seen = [false; 10];
for &idx in perm.iter() {
assert!(idx < 10, "PERM_CHALLENGE[{row}] index {idx} out of range");
assert!(!seen[idx], "PERM_CHALLENGE[{row}] duplicates index {idx}");
seen[idx] = true;
}
assert!(
seen.iter().all(|&b| b),
"PERM_CHALLENGE[{row}] misses an index"
);
}
}
/// Each PERM_VARIANT row maps the 32 variants to 32 distinct 5-bit values
/// (it is a permutation of 0..32). key_type 1 uses PERM_VARIANT[0],
/// key_type 2 uses PERM_VARIANT[1] to pick the css_variant; a collision
/// would make two variants indistinguishable.
///
/// Grounding: `css_variant = PERM_VARIANT[k][variant]` then indexes
/// VARIANTS[css_variant] (0..32).
/// Mutation: set PERM_VARIANT[0][1] = PERM_VARIANT[0][0] -> duplicate
/// assert fires; also any value >= 32 would later index VARIANTS OOB.
#[test]
fn perm_variant_rows_are_permutations_of_0_31() {
for (row, perm) in PERM_VARIANT.iter().enumerate() {
let mut seen = [false; 32];
for &v in perm.iter() {
let v = v as usize;
assert!(v < 32, "PERM_VARIANT[{row}] value {v} out of 0..32");
assert!(!seen[v], "PERM_VARIANT[{row}] duplicates {v}");
seen[v] = true;
}
assert!(
seen.iter().all(|&b| b),
"PERM_VARIANT[{row}] misses a value"
);
}
}
// ── crypt_key behaviour ────────────────────────────────────────────────
/// crypt_key result depends on every challenge byte. The challenge is
/// permuted into `scratch` and folded through the LFSR seeding and the 6
/// XOR rounds. Flipping any single challenge byte must change the output.
///
/// Grounding: scratch[i]=challenge[perm[i]] for all 10 i, and scratch
/// seeds both LFSRs (bytes 5..10 via tmp1) and the round terms (bytes
/// 0..5).
/// Mutation: in `scratch[i] = challenge[perm[i]]` replace with
/// `challenge[i]` for a perm that drops a byte — or hardcode one scratch
/// entry — and some challenge byte stops mattering; this fails.
#[test]
fn crypt_key_depends_on_every_challenge_byte() {
let base: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let base_out = crypt_key(0, 5, &base);
for i in 0..10 {
let mut c = base;
c[i] ^= 0x55;
assert_ne!(
crypt_key(0, 5, &c),
base_out,
"flipping challenge byte {i} did not change the bus-key derivation"
);
}
}
/// crypt_key(0, v, ..) must produce a DISTINCT result for each of the 32
/// variants on a fixed challenge. bus_auth brute-forces the variant by
/// matching crypt_key(0, v, host_challenge) == key1; if two variants
/// collided, the wrong variant could be selected and the whole auth
/// derail.
///
/// Grounding: variant selects css_variant -> VARIANTS[css_variant] -> cse,
/// which feeds every round; distinct variants give distinct cse-driven
/// keys in practice.
/// Mutation: make `cse` ignore the variant (e.g. `let cse = 0`) -> all 32
/// outputs collapse to one value; the distinctness assert fires.
#[test]
fn crypt_key_type0_distinct_per_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let mut outs = Vec::new();
for v in 0..32u8 {
let k = crypt_key(0, v, &challenge);
assert!(
!outs.contains(&k),
"variant {v} collides with an earlier variant"
);
outs.push(k);
}
}
/// crypt_key enforces its documented precondition `key_type < 3` via
/// debug_assert (active in test builds). A key_type of 3 would index
/// PERM_CHALLENGE (len 3) out of bounds; the assert turns that into an
/// explicit precondition panic.
///
/// Grounding: `debug_assert!(key_type < 3, ...)`; PERM_CHALLENGE has 3
/// rows (indices 0,1,2).
/// Mutation: delete the debug_assert AND the match-arm guard — but the
/// match `_ =>` arm would then index PERM_CHALLENGE[3] OOB and panic
/// differently; with the assert in place this test pins the contract.
#[test]
#[should_panic]
fn crypt_key_rejects_out_of_range_key_type() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let _ = crypt_key(3, 0, &challenge);
}
/// crypt_key enforces `variant < 32` via debug_assert. A variant of 32
/// would index VARIANTS / PERM_VARIANT (len 32) out of bounds.
///
/// Grounding: `debug_assert!((variant as usize) < 32, ...)`.
/// Mutation: removing the assert makes this index VARIANTS[32] (still a
/// panic, but unguarded); the assert documents/enforces the contract.
#[test]
#[should_panic]
fn crypt_key_rejects_out_of_range_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let _ = crypt_key(0, 32, &challenge);
}
// ── SCSI CDB builders (MMC REPORT KEY / SEND KEY layout) ───────────────
/// report_key_cdb encodes a 12-byte MMC REPORT KEY (opcode 0xA4) CDB:
/// byte 0 = operation code 0xA4
/// bytes 8-9 = allocation length, big-endian
/// byte 10 = (AGID << 6) | (key_format & 0x3F)
/// All other bytes are zero.
///
/// Grounding: MMC REPORT KEY CDB; the AGID is the top 2 bits of byte 10,
/// key format the low 6 bits.
/// Mutation: change `(alloc_len >> 8)` to `alloc_len` for byte 8 (lose the
/// big-endian split) -> byte 8/9 assert fails. Change `agid << 6` to
/// `agid << 5` -> the AGID-position assert fails.
#[test]
fn report_key_cdb_matches_mmc_layout() {
let cdb = report_key_cdb(0b10, 0x04, 0x010C); // AGID=2, format=0x04, len=268
assert_eq!(cdb[0], 0xA4, "REPORT KEY opcode");
assert_eq!(cdb[8], 0x01, "alloc_len high byte (big-endian)");
assert_eq!(cdb[9], 0x0C, "alloc_len low byte");
assert_eq!(
cdb[10],
(0b10 << 6) | 0x04,
"AGID in bits 6-7, format in bits 0-5"
);
// Every other byte must be zero.
for (i, &b) in cdb.iter().enumerate() {
if ![0, 8, 9, 10].contains(&i) {
assert_eq!(b, 0, "CDB byte {i} must be zero");
}
}
assert_eq!(cdb.len(), 12, "REPORT KEY is a 12-byte CDB");
}
/// The key format field is masked to 6 bits: a format with high bits set
/// must not corrupt the AGID. report_key_cdb(0, 0xFF, _) -> byte 10 low 6
/// bits = 0x3F, AGID = 0.
///
/// Grounding: `(agid << 6) | (format & 0x3F)`.
/// Mutation: drop the `& 0x3F` mask -> 0xFF would overwrite the AGID bits;
/// byte 10 would be 0xFF not 0x3F, this fails.
#[test]
fn report_key_cdb_masks_format_to_6_bits() {
let cdb = report_key_cdb(0, 0xFF, 8);
assert_eq!(cdb[10], 0x3F, "format masked to 6 bits, AGID stays 0");
}
/// send_key_cdb encodes a 12-byte MMC SEND KEY (opcode 0xA3) CDB with the
/// parameter-list length at bytes 8-9 (big-endian) and AGID/format at byte
/// 10.
///
/// Grounding: MMC SEND KEY CDB layout.
/// Mutation: change opcode to SCSI_REPORT_KEY -> opcode assert fails;
/// swap bytes 8/9 -> length assert fails.
#[test]
fn send_key_cdb_matches_mmc_layout() {
let cdb = send_key_cdb(0b11, 0x03, 0x000C); // AGID=3, format=3, param_len=12
assert_eq!(cdb[0], 0xA3, "SEND KEY opcode");
assert_eq!(cdb[8], 0x00, "param_len high byte");
assert_eq!(cdb[9], 0x0C, "param_len low byte");
assert_eq!(
cdb[10],
(0b11 << 6) | 0x03,
"AGID bits 6-7, format bits 0-5"
);
assert_eq!(cdb.len(), 12);
}
/// Allocation length larger than 255 must split across bytes 8 (high) and
/// 9 (low) — a 16-bit big-endian field. report_key_cdb with alloc_len
/// 0x0804 (2052, the disc-key block size used in read_disc_key) -> byte 8
/// = 0x08, byte 9 = 0x04.
///
/// Grounding: read_disc_key uses `alloc_len = 2048 + 4 = 2052 = 0x0804`
/// and writes `cdb[8] = (alloc_len >> 8); cdb[9] = alloc_len`.
/// Mutation: write only byte 9 (`cdb[9] = alloc_len as u8`) without byte 8
/// -> the drive sees a 4-byte transfer, truncating the disc-key block;
/// this asserts the high byte is present.
#[test]
fn report_key_cdb_alloc_len_is_16bit_big_endian() {
let cdb = report_key_cdb(0, 0x00, 0x0804);
assert_eq!(cdb[8], 0x08, "high byte of 2052-byte transfer");
assert_eq!(cdb[9], 0x04, "low byte of 2052-byte transfer");
}
/// The unlocker's user-facing name is "DVD" (the medium it read-unlocks),
/// not "CSS" (the scheme). Apps render the unlocker report from this name,
/// so it is a stable contract — the bus-auth ran on any DVD, encrypted or
/// not, and the report must say so rather than conflate it with a CSS crack.
#[test]
fn dvd_unlocker_is_named_dvd() {
use crate::Unlocker;
assert_eq!(DvdUnlocker::new().name(), "DVD");
}
/// DvdUnlocker provides bus removal only — it never provides drive features.
#[test]
fn dvd_unlocker_provides_no_features() {
use crate::scsi::{DataDirection, ScsiResult};
use crate::{DiscKind, DriveId, UnlockCtx, UnlockError, Unlocker};
struct DeadTransport;
impl ScsiTransport for DeadTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
panic!("unlock_features must not touch the transport");
}
}
let id = DriveId::default();
let mut t = DeadTransport;
let r =
DvdUnlocker::new().unlock_features(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[]));
assert_eq!(r.unwrap_err(), UnlockError::NotApplicable);
}
/// Defense in depth: even when the caller declares `DiscKind::Css`, the
/// DvdUnlocker self-verifies against the drive's GET CONFIGURATION profile.
/// A drive reporting a Blu-ray profile → `NotApplicable`, and NOT a single
/// CSS CDB is issued (no bus-auth fired at a BD).
#[test]
fn dvd_unlocker_self_guards_against_non_dvd() {
use crate::scsi::{DataDirection, ScsiResult};
use crate::{DiscKind, DriveId, UnlockCtx, UnlockError, Unlocker};
/// Reports a BD-ROM profile (0x0040) to GET CONFIGURATION and counts any
/// other CDB (i.e. CSS bus-auth activity).
struct BdTransport {
non_config_cdbs: usize,
}
impl ScsiTransport for BdTransport {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
if cdb[0] == crate::scsi::SCSI_GET_CONFIGURATION {
if data.len() >= 8 {
data[6] = 0x00;
data[7] = 0x40; // BD-ROM current profile
}
return Ok(ScsiResult {
status: 0,
bytes_transferred: 8,
sense: [0u8; 32],
});
}
self.non_config_cdbs += 1;
Ok(ScsiResult {
status: 0,
bytes_transferred: 0,
sense: [0u8; 32],
})
}
}
let id = DriveId {
vendor_id: "FAKEVNDR".to_string(),
..Default::default()
};
let mut t = BdTransport { non_config_cdbs: 0 };
let r = DvdUnlocker::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[]));
assert_eq!(
r.unwrap_err(),
UnlockError::NotApplicable,
"a BD-profile drive must be refused"
);
assert_eq!(
t.non_config_cdbs, 0,
"no CSS CDB may be issued at a non-DVD drive"
);
}
}
#[path = "mod_tests.rs"]
mod tests;
+905
View File
@@ -0,0 +1,905 @@
use super::*;
// SECURITY REGRESSION GUARD: scans source files for a `tracing` field
// binding a forbidden key name to a value expression (only a string
// literal or `_fp` field is allowed).
#[test]
fn no_key_bytes_in_instrumentation() {
use std::path::Path;
// Forbidden field names whose VALUES must never be logged.
const FORBIDDEN: &[&str] = &[
"title_key",
"disc_key",
"unit_key",
"vuk",
"player_key",
"bus_key",
];
fn scan_dir(dir: &Path, forbidden: &[&str], violations: &mut Vec<String>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
scan_dir(&path, forbidden, violations);
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let src = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => continue,
};
for (lineno, line) in src.lines().enumerate() {
let trimmed = line.trim_start();
// Only inspect tracing instrumentation lines.
if !(trimmed.contains("tracing::")
|| trimmed.starts_with("debug!")
|| trimmed.starts_with("info!")
|| trimmed.starts_with("warn!")
|| trimmed.starts_with("trace!")
|| trimmed.starts_with("error!"))
{
continue;
}
// This guard test itself contains the forbidden names.
if path.file_name().and_then(|n| n.to_str()) == Some("auth.rs")
&& line.contains("FORBIDDEN")
{
continue;
}
for &name in forbidden {
// A fingerprint field (`<name>_fp = ...`) is allowed;
// match `<name>` then `=` with a value that is not a
// string-literal redaction marker.
if let Some(idx) = line.find(name) {
let after = &line[idx + name.len()..];
let after = after.trim_start();
// `<name>_fp` / `<name>_id` etc. are safe.
if after.starts_with('_') {
continue;
}
// Must be a field binding `name = ...`.
let Some(rest) = after.strip_prefix('=') else {
continue;
};
let rest = rest.trim_start();
// Redaction string literal is the only allowed value.
if rest.starts_with('"') {
continue;
}
// Anything else (`%expr`, `?expr`, bare expr) leaks bytes.
violations.push(format!(
"{}:{}: forbidden key field `{}` logged with a value: {}",
path.display(),
lineno + 1,
name,
line.trim()
));
}
}
}
}
}
// Scan this crate's `src` plus sibling workspace crates so the
// guard covers everything that can reach CSS/AACS internals.
// Missing sibling dirs (standalone builds) are simply skipped.
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace = manifest.parent().unwrap_or(manifest);
let mut violations = Vec::new();
scan_dir(&manifest.join("src"), FORBIDDEN, &mut violations);
for sibling in ["autorip", "freemkv", "freemkv-keysources"] {
let dir = workspace.join(sibling).join("src");
if dir.is_dir() {
scan_dir(&dir, FORBIDDEN, &mut violations);
}
}
assert!(
violations.is_empty(),
"key material logged in instrumentation:\n{}",
violations.join("\n")
);
}
#[test]
fn crypt_key_is_deterministic() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for v in 0..32u8 {
let r1 = crypt_key(0, v, &challenge);
let r2 = crypt_key(0, v, &challenge);
assert_eq!(r1, r2);
}
}
#[test]
fn crypt_key_varies_by_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
}
#[test]
fn crypt_key_varies_by_type() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
assert_ne!(crypt_key(0, 5, &challenge), crypt_key(1, 5, &challenge));
}
#[test]
fn crypt_key_nonzero() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for v in 0..32u8 {
assert_ne!(crypt_key(0, v, &challenge), [0u8; 5]);
}
}
// ── CSS constant-table integrity ───────────────────────────────────────
// Each PERM_CHALLENGE row must be a permutation of indices 0..10; a
// non-permutation would drop/duplicate bytes.
#[test]
fn perm_challenge_rows_are_permutations() {
for (row, perm) in PERM_CHALLENGE.iter().enumerate() {
let mut seen = [false; 10];
for &idx in perm.iter() {
assert!(idx < 10, "PERM_CHALLENGE[{row}] index {idx} out of range");
assert!(!seen[idx], "PERM_CHALLENGE[{row}] duplicates index {idx}");
seen[idx] = true;
}
assert!(
seen.iter().all(|&b| b),
"PERM_CHALLENGE[{row}] misses an index"
);
}
}
// Each PERM_VARIANT row must map the 32 variants to 32 distinct 5-bit
// values; a collision would make two variants indistinguishable.
#[test]
fn perm_variant_rows_are_permutations_of_0_31() {
for (row, perm) in PERM_VARIANT.iter().enumerate() {
let mut seen = [false; 32];
for &v in perm.iter() {
let v = v as usize;
assert!(v < 32, "PERM_VARIANT[{row}] value {v} out of 0..32");
assert!(!seen[v], "PERM_VARIANT[{row}] duplicates {v}");
seen[v] = true;
}
assert!(
seen.iter().all(|&b| b),
"PERM_VARIANT[{row}] misses a value"
);
}
}
// ── crypt_key behaviour ────────────────────────────────────────────────
// crypt_key's result must depend on every challenge byte: flipping any
// single byte must change the output.
#[test]
fn crypt_key_depends_on_every_challenge_byte() {
let base: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let base_out = crypt_key(0, 5, &base);
for i in 0..10 {
let mut c = base;
c[i] ^= 0x55;
assert_ne!(
crypt_key(0, 5, &c),
base_out,
"flipping challenge byte {i} did not change the bus-key derivation"
);
}
}
// crypt_key(0, v, ..) must be distinct for each of the 32 variants:
// bus-auth brute-forces the variant by matching against key1, so a
// collision could select the wrong one.
#[test]
fn crypt_key_type0_distinct_per_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let mut outs = Vec::new();
for v in 0..32u8 {
let k = crypt_key(0, v, &challenge);
assert!(
!outs.contains(&k),
"variant {v} collides with an earlier variant"
);
outs.push(k);
}
}
// crypt_key's `key_type < 3` assert! (not debug_assert!) must fire in
// every profile; `expected` rejects the index-OOB panic that would follow.
#[test]
#[should_panic(expected = "crypt_key: key_type out of range")]
fn crypt_key_rejects_out_of_range_key_type() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let _ = crypt_key(3, 0, &challenge);
}
// crypt_key's `variant < 32` assert! must fire in every profile, not
// the VARIANTS/PERM_VARIANT index-OOB panic that would follow it.
#[test]
#[should_panic(expected = "crypt_key: variant out of range")]
fn crypt_key_rejects_out_of_range_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let _ = crypt_key(0, 32, &challenge);
}
// ── SCSI CDB builders (MMC REPORT KEY / SEND KEY layout) ───────────────
// report_key_cdb encodes a 12-byte MMC REPORT KEY (opcode 0xA4) CDB:
// byte 0=0xA4, bytes 8-9=big-endian len, byte 10=(AGID<<6)|(format&0x3F).
#[test]
fn report_key_cdb_matches_mmc_layout() {
let cdb = report_key_cdb(0b10, 0x04, 0x010C); // AGID=2, format=0x04, len=268
assert_eq!(cdb[0], 0xA4, "REPORT KEY opcode");
assert_eq!(cdb[8], 0x01, "alloc_len high byte (big-endian)");
assert_eq!(cdb[9], 0x0C, "alloc_len low byte");
assert_eq!(
cdb[10],
(0b10 << 6) | 0x04,
"AGID in bits 6-7, format in bits 0-5"
);
// Every other byte must be zero.
for (i, &b) in cdb.iter().enumerate() {
if ![0, 8, 9, 10].contains(&i) {
assert_eq!(b, 0, "CDB byte {i} must be zero");
}
}
assert_eq!(cdb.len(), 12, "REPORT KEY is a 12-byte CDB");
}
// The key-format field is masked to 6 bits so a format with high bits
// set (e.g. 0xFF) cannot corrupt the AGID bits of byte 10.
#[test]
fn report_key_cdb_masks_format_to_6_bits() {
let cdb = report_key_cdb(0, 0xFF, 8);
assert_eq!(cdb[10], 0x3F, "format masked to 6 bits, AGID stays 0");
}
// send_key_cdb encodes a 12-byte MMC SEND KEY (opcode 0xA3) CDB with the
// parameter-list length at bytes 8-9 (big-endian) and AGID/format at
// byte 10.
#[test]
fn send_key_cdb_matches_mmc_layout() {
let cdb = send_key_cdb(0b11, 0x03, 0x000C); // AGID=3, format=3, param_len=12
assert_eq!(cdb[0], 0xA3, "SEND KEY opcode");
assert_eq!(cdb[8], 0x00, "param_len high byte");
assert_eq!(cdb[9], 0x0C, "param_len low byte");
assert_eq!(
cdb[10],
(0b11 << 6) | 0x03,
"AGID bits 6-7, format bits 0-5"
);
assert_eq!(cdb.len(), 12);
}
// Allocation length > 255 must split across bytes 8 (high) and 9 (low)
// as a 16-bit big-endian field, e.g. 0x0804 (the disc-key block size).
#[test]
fn report_key_cdb_alloc_len_is_16bit_big_endian() {
let cdb = report_key_cdb(0, 0x00, 0x0804);
assert_eq!(cdb[8], 0x08, "high byte of 2052-byte transfer");
assert_eq!(cdb[9], 0x04, "low byte of 2052-byte transfer");
}
// The unlocker's user-facing name is "DVD" (the medium), not "CSS" (the
// scheme); apps render the unlocker report from this name, so it is a
// stable contract.
#[test]
fn dvd_unlocker_is_named_dvd() {
use crate::Unlocker;
assert_eq!(DvdUnlocker::new().name(), "DVD");
}
/// `Default` must delegate to `new()` — there is only one way to build a
/// `DvdUnlocker` (it is a unit struct), so this pins the two never drift.
#[test]
#[allow(clippy::default_constructed_unit_structs)]
fn default_matches_new() {
let _ = DvdUnlocker::default();
let _ = DvdUnlocker::new();
}
/// DvdUnlocker provides bus removal only — it never provides drive features.
// Defense in depth: even when the caller declares `DiscKind::Css`,
// DvdUnlocker self-verifies against GET CONFIGURATION; a BD profile
// must yield NotApplicable with no CSS CDB issued.
#[test]
fn dvd_unlocker_self_guards_against_non_dvd() {
use crate::scsi::{DataDirection, ScsiResult};
use crate::{DiscKind, DriveId, UnlockCtx, Unlocker};
/// Reports a BD-ROM profile (0x0040) to GET CONFIGURATION and counts any
/// other CDB (i.e. CSS bus-auth activity).
struct BdTransport {
non_config_cdbs: usize,
}
impl ScsiTransport for BdTransport {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
if cdb[0] == crate::scsi::SCSI_GET_CONFIGURATION {
if data.len() >= 8 {
data[6] = 0x00;
data[7] = 0x40; // BD-ROM current profile
}
return Ok(ScsiResult {
status: 0,
bytes_transferred: 8,
sense: [0u8; 32],
});
}
self.non_config_cdbs += 1;
Ok(ScsiResult {
status: 0,
bytes_transferred: 0,
sense: [0u8; 32],
})
}
}
let id = DriveId {
vendor_id: "FAKEVNDR".to_string(),
..Default::default()
};
let mut t = BdTransport { non_config_cdbs: 0 };
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
assert!(
r.expect("BD profile declines, not a hard error").is_none(),
"a BD-profile drive must be refused"
);
assert_eq!(
t.non_config_cdbs, 0,
"no CSS CDB may be issued at a non-DVD drive"
);
}
// ── Transport-contract tests ────────────────────────────────────────────
use crate::scsi::mock::{MockTransport, Reply};
use crate::{DiscKind, DriveId, UnlockCtx, UnlockError, Unlocker};
// Defect-7 regression: a transport fault on the first probe command
// must abort as Transport, not fall through to NotApplicable (which
// let the consumer keep probing a dead bus).
#[test]
fn transport_fault_probing_for_a_dvd_aborts() {
let id = DriveId::default();
let mut t = MockTransport::always(Reply::TransportFault);
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
assert_eq!(r.unwrap_err(), UnlockError::Transport);
assert_eq!(
t.calls(),
1,
"must abort on the first command, not probe on"
);
}
/// A drive that REFUSES GET CONFIGURATION (CHECK CONDITION, delivered as
/// `Ok` per the contract) is inconclusive, not a dead bus → decline. Guards
/// against over-correcting defect 7 into "any probe failure aborts the rip".
#[test]
fn check_condition_probing_for_a_dvd_declines() {
let id = DriveId::default();
let mut t = MockTransport::always(Reply::illegal_request());
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
assert!(r.expect("inconclusive probe declines").is_none());
}
// Defect-2 regression: a DVD is mounted, then the bus dies mid
// bus-auth — must abort as Transport, not collapse to CssAuthFailed /
// NotApplicable.
#[test]
fn transport_fault_during_bus_auth_aborts() {
let id = DriveId::default();
// GET CONFIGURATION reports a DVD-ROM profile (0x0010), then the bus dies.
let mut config = vec![0u8; 8];
config[6] = 0x00;
config[7] = 0x10;
let mut t = MockTransport::scripted(vec![Reply::good(config)], Reply::TransportFault);
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
assert_eq!(r.unwrap_err(), UnlockError::Transport);
}
/// The same shape with the drive REFUSING the bus-auth commands: a CSS
/// auth failure is a fall-through, not an abort.
#[test]
fn drive_refusing_bus_auth_is_not_applicable() {
let id = DriveId::default();
let mut config = vec![0u8; 8];
config[7] = 0x10;
let mut t = MockTransport::scripted(vec![Reply::good(config)], Reply::illegal_request());
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
assert!(r.expect("a refused bus-auth declines").is_none());
}
// A CHECK CONDITION on AGID allocation must not let the handshake carry
// on off the caller's own zero-filled buffer; catches a dropped
// `status` check in `css_scsi`.
#[test]
fn agid_allocation_check_condition_fails_the_auth() {
// 4 AGID invalidations are best-effort; the 5th command is the alloc.
let mut t = MockTransport::scripted(
vec![
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
],
Reply::illegal_request(),
);
let e = establish_authenticated_session(&mut t).expect_err("refused alloc");
assert!(matches!(e, Error::CssAuthFailed));
}
// Defect 18: an AGID lost to a failed challenge must be RELEASED
// (REPORT KEY format 0x3F), not abandoned, even though a drive's four
// AGIDs self-heal on the next session's invalidation pass.
#[test]
fn a_failed_handshake_releases_the_agid_it_allocated() {
let mut t = MockTransport::scripted(
vec![
Reply::good(vec![0u8; 8]), // 4 × AGID invalidate
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]), // AGID allocated
],
Reply::illegal_request(), // every challenge step is refused
);
establish_authenticated_session(&mut t).expect_err("refused challenge");
let last = t.cdbs.last().expect("commands were issued");
assert_eq!(last[0], crate::scsi::SCSI_REPORT_KEY);
assert_eq!(last[10] & 0x3F, 0x3F, "AGID released on the failure path");
}
/// A short AGID-allocation response is equally unusable — the AGID would be
/// read out of bytes the drive never sent.
#[test]
fn short_agid_allocation_fails_the_auth() {
let mut t = MockTransport::scripted(
vec![
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
Reply::good(vec![0u8; 8]),
],
Reply::short(vec![0u8; 8], 3),
);
let e = establish_authenticated_session(&mut t).expect_err("short alloc");
assert!(matches!(e, Error::CssAuthFailed));
}
// ── authenticate_with_agid step failures ────────────────────────────────
/// The drive refuses the host-challenge SEND KEY (step 1) — the very first
/// command `authenticate_with_agid` issues.
#[test]
fn host_challenge_send_failure_fails_the_auth() {
let mut t = MockTransport::scripted(vec![Reply::illegal_request()], Reply::illegal_request());
let e = authenticate_with_agid(&mut t, 0).expect_err("refused host challenge");
assert!(matches!(e, Error::CssAuthFailed));
assert_eq!(t.calls(), 1, "must not proceed past the first refused step");
}
/// The host challenge SEND KEY succeeds but the Key1 REPORT KEY (step 2) is
/// refused.
#[test]
fn key1_report_failure_fails_the_auth() {
let mut t = MockTransport::scripted(
vec![Reply::good(vec![]), Reply::illegal_request()],
Reply::illegal_request(),
);
let e = authenticate_with_agid(&mut t, 0).expect_err("refused Key1 report");
assert!(matches!(e, Error::CssAuthFailed));
assert_eq!(t.calls(), 2);
}
/// Both SCSI steps succeed but the drive's Key1 does not match any of the
/// 32 CryptKey variants for the (randomly generated) host challenge — the
/// brute-force loop exhausts and `variant.ok_or(CssAuthFailed)` fires.
#[test]
fn key1_matching_no_variant_fails_the_auth() {
let mut t = MockTransport::scripted(
vec![Reply::good(vec![]), Reply::good(vec![0xABu8; 12])],
Reply::illegal_request(),
);
let e = authenticate_with_agid(&mut t, 0).expect_err("no variant matches");
assert!(matches!(e, Error::CssAuthFailed));
assert_eq!(
t.calls(),
2,
"the brute-force loop issues no further SCSI commands"
);
}
// Host challenge + Key1 succeed but the drive-challenge REPORT KEY
// (step 3) is refused; distinct from the step-2 and step-4 failure
// tests, each pinning a different `css_scsi` call site's `?`.
#[test]
fn drive_challenge_report_failure_fails_the_auth() {
struct FailAtDriveChallenge(FakeDvdDrive);
impl ScsiTransport for FailAtDriveChallenge {
fn execute(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
if cdb[0] == crate::scsi::SCSI_REPORT_KEY && cdb[10] & 0x3F == 0x01 {
return Ok(crate::scsi::ScsiResult {
status: 0x02,
bytes_transferred: 0,
sense: [0u8; 32],
});
}
self.0.execute(cdb, dir, data, timeout_ms)
}
}
let mut t = FailAtDriveChallenge(FakeDvdDrive {
variant: 4,
host_challenge: [0u8; 10],
});
let e = authenticate_with_agid(&mut t, 0).expect_err("refused drive challenge");
assert!(matches!(e, Error::CssAuthFailed));
}
/// Host challenge, Key1, and the drive challenge all succeed, but the
/// Key2 SEND KEY (step 4, the last command in the handshake) is refused.
#[test]
fn key2_send_failure_fails_the_auth() {
struct FailAtKey2Send(FakeDvdDrive);
impl ScsiTransport for FailAtKey2Send {
fn execute(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
if cdb[0] == crate::scsi::SCSI_SEND_KEY && cdb[10] & 0x3F == 0x03 {
return Ok(crate::scsi::ScsiResult {
status: 0x02,
bytes_transferred: 0,
sense: [0u8; 32],
});
}
self.0.execute(cdb, dir, data, timeout_ms)
}
}
let mut t = FailAtKey2Send(FakeDvdDrive {
variant: 4,
host_challenge: [0u8; 10],
});
let e = authenticate_with_agid(&mut t, 0).expect_err("refused Key2 send");
assert!(matches!(e, Error::CssAuthFailed));
}
// ── read_disc_key ────────────────────────────────────────────────────────
/// The best-effort disc-key REPORT KEY is refused — `read_disc_key` must
/// surface the failure to its caller (who treats it as non-fatal), not
/// silently return `Ok`.
#[test]
fn read_disc_key_refused_is_an_error() {
let mut t = MockTransport::always(Reply::illegal_request());
let e = read_disc_key(&mut t, 0).expect_err("drive refused disc-key read");
assert!(matches!(e, Error::CssAuthFailed));
}
/// A transport fault reading the disc key must classify as a transport
/// failure, not a generic auth failure.
#[test]
fn read_disc_key_transport_fault_is_transport_failure() {
let mut t = MockTransport::always(Reply::TransportFault);
let e = read_disc_key(&mut t, 0).expect_err("dead bus");
assert!(e.is_transport_failure());
}
// ── Full happy-path bus-auth ────────────────────────────────────────────
// A fake drive that plays its half of the CSS handshake for real:
// answers Key1 honestly, and DVD/disc-key probes as success.
struct FakeDvdDrive {
variant: u8,
host_challenge: [u8; 10],
}
impl ScsiTransport for FakeDvdDrive {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
use crate::scsi::ScsiResult;
let ok = |bytes_transferred: usize| ScsiResult {
status: 0,
bytes_transferred,
sense: [0u8; 32],
};
match cdb[0] {
crate::scsi::SCSI_GET_CONFIGURATION => {
data[6] = 0x00;
data[7] = 0x10; // DVD-ROM current profile
Ok(ok(8))
}
crate::scsi::SCSI_SEND_KEY => {
let format = cdb[10] & 0x3F;
if format == 0x01 {
// Host challenge: capture it for the Key1 answer.
for i in 0..10 {
self.host_challenge[i] = data[4 + (9 - i)];
}
}
// format 0x03 (Key2) is accepted unconditionally: the
// real handshake never verifies it drive-side in this
// primitive (ASF=1 is set on the drive's own say-so).
Ok(ok(0))
}
crate::scsi::SCSI_REPORT_KEY => {
match cdb[10] & 0x3F {
0x00 => {
// Allocate AGID 0.
data[7] = 0x00;
Ok(ok(8))
}
0x02 => {
// Key1, honestly derived from the captured challenge.
let key1 = crypt_key(0, self.variant, &self.host_challenge);
for i in 0..5 {
data[4 + (4 - i)] = key1[i];
}
Ok(ok(12))
}
0x01 => {
// Drive challenge (arbitrary, fixed).
let drive_challenge: [u8; 10] = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0];
for i in 0..10 {
data[4 + (9 - i)] = drive_challenge[i];
}
Ok(ok(16))
}
_ => Ok(ok(0)), // 0x3F release, best-effort
}
}
crate::scsi::SCSI_READ_DISC_STRUCTURE => Ok(ok(2048 + 4)),
_ => Ok(ok(0)),
}
}
}
// End-to-end happy path for the bus-auth handshake (AGID, challenge,
// Key1/Key2, disc-key REPORT KEY), exercising the `Ok` returns that
// the failure-path tests above never reach.
#[test]
fn full_bus_auth_and_disc_key_succeed() {
let mut t = FakeDvdDrive {
variant: 7,
host_challenge: [0u8; 10],
};
let agid = establish_authenticated_session(&mut t).expect("full bus-auth handshake succeeds");
assert_eq!(agid, 0);
read_disc_key(&mut t, agid).expect("disc-key REPORT KEY succeeds");
}
// The same happy path through the public `unlock` entry point, proving it
// returns `Some(Unlocked::default())` on success — the path every
// failure-injection test above deliberately avoids.
#[test]
fn dvd_unlocker_succeeds_end_to_end() {
let id = DriveId::default();
let mut t = FakeDvdDrive {
variant: 3,
host_challenge: [0u8; 10],
};
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
let unlocked = r
.expect("full unlock succeeds")
.expect("DVD bus-auth unlocks the drive");
assert!(unlocked.vid.is_none(), "CSS yields no Volume ID");
assert!(unlocked.bus_key.is_none(), "CSS yields no AACS bus key");
}
/// `unlock_css_reads` (the crate-level public entry point, distinct from
/// the `DvdUnlocker` wrapper) also succeeds end to end.
#[test]
fn unlock_css_reads_succeeds() {
let mut t = FakeDvdDrive {
variant: 11,
host_challenge: [0u8; 10],
};
// unlock_css_reads doesn't probe GET CONFIGURATION itself (that's
// DvdUnlocker's job); it goes straight to bus-auth.
unlock_css_reads(&mut t, 0).expect("css bus-auth + best-effort disc key succeed");
}
// A drive whose bus-auth succeeds but refuses the best-effort disc-key
// REPORT KEY; `unlock_css_reads_inner` must swallow that failure since
// the read barrier is already open from bus-auth.
struct DiscKeyRefusingDrive(FakeDvdDrive);
impl ScsiTransport for DiscKeyRefusingDrive {
fn execute(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
if cdb[0] == crate::scsi::SCSI_READ_DISC_STRUCTURE {
return Ok(crate::scsi::ScsiResult {
status: 0x02,
bytes_transferred: 0,
sense: [0u8; 32],
});
}
self.0.execute(cdb, dir, data, timeout_ms)
}
}
#[test]
fn disc_key_refusal_is_non_fatal_to_the_overall_unlock() {
let mut t = DiscKeyRefusingDrive(FakeDvdDrive {
variant: 5,
host_challenge: [0u8; 10],
});
unlock_css_reads(&mut t, 0)
.expect("bus-auth succeeded; a refused best-effort disc-key read must not fail it");
}
// `crypt_key`'s key_type==2 arm (`PERM_VARIANT[1]`) has no production
// caller (only 0 and 1 are invoked from `authenticate_with_agid`) but
// is a pure, directly-testable function.
#[test]
fn crypt_key_type2_is_deterministic_and_distinct_from_type1() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
let a = crypt_key(2, 9, &challenge);
let b = crypt_key(2, 9, &challenge);
assert_eq!(a, b, "crypt_key(2, ..) must be deterministic");
assert_ne!(
crypt_key(1, 9, &challenge),
crypt_key(2, 9, &challenge),
"key_type 1 and 2 must diverge (distinct PERM_VARIANT rows)"
);
}
// ── Stop (stop-design-v5 §2.3, ST-U1) ───────────────────────────────────
/// A drive that completes the CSS bus-auth for real: it answers Key1 for the
/// host challenge under `variant`, serves a challenge, and allocates AGID 1.
struct CssEmu {
variant: u8,
host_challenge: [u8; 10],
/// Answer Key1 with zeros, which matches no variant → CssAuthFailed.
bad_key1: bool,
}
impl ScsiTransport for CssEmu {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
let mut r = vec![0u8; data.len()];
match (cdb[0], cdb[10] & 0x3F) {
(crate::scsi::SCSI_REPORT_KEY, 0x00) => r[7] = 1 << 6, // AGID 1
(crate::scsi::SCSI_REPORT_KEY, 0x02) if !self.bad_key1 => {
let k = crypt_key(0, self.variant, &self.host_challenge);
(0..5).for_each(|j| r[4 + j] = k[4 - j]);
}
(crate::scsi::SCSI_SEND_KEY, 0x01) => {
(0..10).for_each(|i| self.host_challenge[i] = data[4 + 9 - i]);
}
_ => {}
}
if cdb[0] != crate::scsi::SCSI_SEND_KEY {
data.copy_from_slice(&r);
}
Ok(crate::scsi::ScsiResult {
status: 0,
bytes_transferred: data.len(),
sense: [0u8; 32],
})
}
}
fn css_emu() -> CssEmu {
CssEmu {
variant: 7,
host_challenge: [0u8; 10],
bad_key1: false,
}
}
// UT4 / G2 (stop-design-v5 §5.2, D9 "Keep holding it"): on success the AGID is
// `defuse`d, so it is still held for `read_disc_key` and never released.
// Per design D9; do not change without a design citation proving otherwise.
#[test]
fn css_success_keeps_agid() {
use crate::scsi::mock::{StopFake, agid_ledger, is_agid_release};
let mut t = StopFake::new(css_emu());
unlock_css_reads(&mut t, 0).expect("the bus-auth completes");
let (allocs, held) = agid_ledger(&t.log);
assert_eq!(
(allocs, held),
(1, true),
"one AGID, still held after success"
);
assert!(t.cleanups().is_empty(), "no release on success");
let execs = t.execs();
let alloc = execs.iter().position(|c| c[0] == 0xA4 && c[10] & 0x3F == 0);
let after = &execs[alloc.expect("allocated") + 1..];
assert!(
!after.iter().any(|c| is_agid_release(c)),
"no 0x3F after success"
);
let disc_key = after.last().expect("the disc-key read follows");
assert_eq!(
(disc_key[0], disc_key[7]),
(0xAD, 0x02),
"READ DVD STRUCTURE fmt 2"
);
assert_eq!(disc_key[10], 1 << 6, "issued under the still-held AGID 1");
}
// SS-7 (evidence): a CSS challenge that fails after allocation releases the
// AGID exactly once, through `execute_cleanup`, even when a Stop caused it.
#[test]
fn css_failed_challenge_releases_agid_once_via_cleanup() {
use crate::scsi::mock::{StopFake, agid_ledger};
let stop_at_challenge: fn(&[u8]) -> bool =
|c| c[0] == crate::scsi::SCSI_SEND_KEY && c[10] & 0x3F == 0x01;
for cancel in [false, true] {
let mut emu = css_emu();
if !cancel {
emu.bad_key1 = true;
}
let mut t = StopFake::new(emu);
if cancel {
t.cancel_after = Some(stop_at_challenge);
}
let e = establish_authenticated_session(&mut t).expect_err("auth fails");
assert_eq!(e.is_transport_failure(), cancel, "a Stop is a refusal");
assert_eq!(agid_ledger(&t.log), (1, false), "cancel={cancel}");
assert_eq!(
t.cleanups().len(),
1,
"exactly one release, cancel={cancel}"
);
}
}
// UT5 (stop-design-v5 §5.2): "zero invalidate CDBs after a cancel" — the
// pre-allocation invalidate loop uses `execute`, so a Stop refuses it.
#[test]
fn invalidate_loops_refused_after_cancel() {
use crate::scsi::mock::StopFake;
let mut t = StopFake::new(css_emu());
t.cancelled = true;
let e = establish_authenticated_session(&mut t).expect_err("cancelled");
assert!(e.is_transport_failure());
assert!(
t.execs().is_empty(),
"no CDB reached the drive: {:?}",
t.log
);
assert!(
t.cleanups().is_empty(),
"nothing allocated, nothing released"
);
}
+866
View File
@@ -0,0 +1,866 @@
//! Typed mirror of the freemkv firmware vendor-command ABI.
//!
//! Numeric values must match `freemkv-firmware/crates/freemkv-fw/src/abi.rs`.
//! Tests pin the verbs, features, state bytes and CDB offsets.
//!
//! ```text
//! cdb[0] = 0x3C (READ BUFFER) ← standard opcode; bridge-safe
//! cdb[1] = 0x0E (KNOCK_MODE) ← OEM's jump table rejects modes >= 0x0E
//! cdb[2..4] = 0xC0 0xDE (KNOCK) ← defence-in-depth signature
//! cdb[4] = Verb ← IDENTITY / SET / GET / RESET / DUMPALL
//! cdb[5] = Feature id ← SET/GET only (else 0)
//! cdb[6] = State byte ← SET only (else 0)
//! cdb[7..9] = allocation length (16-bit big-endian) ← data-returning verbs
//! cdb[9] = control (0)
//! ```
//!
//! DumpAll instead encodes a 32-bit address in `cdb[5..9]` and returns MEMREAD_LEN bytes.
//! Features default to passthrough. Reset selects OEM, built-in defaults or saved flash state.
use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus};
// ── Wire constants (mirror of abi.rs) ────────────────────────────────────────
/// Standard SCSI `READ BUFFER` opcode — the command freemkv hijacks (`cdb[0]`).
pub const READ_BUFFER_OPCODE: u8 = 0x3C;
/// The freemkv sub-command mode at `cdb[1]`; OEM rejects modes `>= 0x0E`.
pub const KNOCK_MODE: u8 = 0x0E;
/// Two-byte knock at `cdb[2..4]` ("C0DE") — the *safe* knock carried by every
/// durable verb (Identity/Set/Get/Reset/DumpAll/Save/FlashWrite).
pub const KNOCK: [u8; 2] = [0xC0, 0xDE];
/// Two-byte knock at `cdb[2..4]` — the *debug* knock, distinct from [`KNOCK`]
/// by construction. The only frame the fw honours for the debug-only verbs
/// ([`Verb::Call`], [`Verb::Poke`], [`Verb::Reboot`]).
pub const DEBUG_KNOCK: [u8; 2] = [0xDE, 0xB9];
/// Response-framing magic leading the [`Verb::Identity`] reply (`b"freemkv"`).
pub const RESP_MAGIC: &[u8] = b"freemkv";
/// Oldest firmware `(major, minor)` this mirror arms: 0.9 made wire id `0x06`
/// the consolidated `Encryption` lever. Released 0.8.x (`0x06` = `Ake`, separate
/// `0x07 Bus`) de-busses via `0x06` alone only as a hardware-dependent side
/// effect (not validated), and 0.7.x uses a sub-function grammar: both refused.
pub const MIN_FW_VERSION: (u32, u32) = (0, 9);
/// Length of a freemkv (READ BUFFER) CDB.
pub const CDB_LEN: usize = 10;
/// Offset of the opcode byte (`cdb[0]`).
pub const CDB_OPCODE: usize = 0;
/// Offset of the mode/knock byte (`cdb[1]`).
pub const CDB_MODE: usize = 1;
/// Offset of the first knock byte (`cdb[2]`, `cdb[3]`).
pub const CDB_KNOCK: usize = 2;
/// Offset of the verb byte (`cdb[4]`).
pub const CDB_VERB: usize = 4;
/// Offset of the feature-id byte (`cdb[5]`) — SET/GET only.
pub const CDB_FEATURE: usize = 5;
/// Offset of the state byte (`cdb[6]`) — SET only.
pub const CDB_STATE: usize = 6;
/// Offset of the 16-bit big-endian allocation length (`cdb[7..9]`).
pub const CDB_ALLOC_LEN: usize = 7;
/// Bytes returned by one [`Verb::DumpAll`] read (fixed 64-byte window).
pub const MEMREAD_LEN: usize = 64;
/// Minimum data-in allocation length any freemkv vendor command may request.
///
/// **Hardware-confirmed (LG BU40N on freemkv firmware):** the drive's `READ
/// BUFFER` hijack ABORTS (Check Condition, sense key Aborted Command) any vendor
/// command whose data-in allocation length is under ~16 bytes (0/1/2 all abort;
/// 16 and 64 both succeed). Every builder floors its allocation at `64` — it
/// matches [`MEMREAD_LEN`] and is safely above the minimum. The verb/feature/
/// state ride in the CDB, so a larger data-in is harmless; a [`Verb::Get`] still
/// reads its state byte from data offset 0. Mirrors firmware ABI `MIN_ALLOC_LEN`.
pub const MIN_ALLOC_LEN: u16 = 64;
/// Feature state: **passthrough** — firmware does not touch this subsystem
/// (OEM behaviour). The boot default of every feature and what [`Verb::Reset`]
/// restores. `0xFF`.
pub const STATE_PASSTHROUGH: u8 = 0xFF;
/// Feature state: explicit **off / disabled** (`0x00`).
pub const STATE_OFF: u8 = 0x00;
/// Feature state: explicit **on / enabled** (`0x01`).
pub const STATE_ON: u8 = 0x01;
/// [`Feature::Speed`] state: speed control OFF — the read-speed cap is lifted and
/// the drive runs uncapped at maximum throughput. This is the [`STATE_OFF`]
/// (`0x00`) leg of Speed: "off" is the *limiter* being off, i.e. maximum speed.
/// `0x01..=0xFE` are explicit caps and `0xFF` is the OEM ramp.
pub const SPEED_MAX: u8 = 0x00;
/// [`Feature::Region`] state: force BD region A (`0x0A`). See [`BdRegion`]. BD
/// regions A/B/C share the low-nibble block with the DVD `0x0N` scheme
/// ([`REGION_DVD_BASE`]) and [`REGION_FREE`] (`0x0F`).
pub const REGION_BD_A: u8 = 0x0A;
/// [`Feature::Region`] state: force BD region B (`0x0B`).
pub const REGION_BD_B: u8 = 0x0B;
/// [`Feature::Region`] state: force BD region C (`0x0C`).
pub const REGION_BD_C: u8 = 0x0C;
/// [`Feature::Region`] state base for "force DVD region N": `REGION_DVD_BASE + N`,
/// so regions 1..=8 are `0x01..=0x08`. `0x00` itself is region-locked (nothing
/// plays).
pub const REGION_DVD_BASE: u8 = 0x00;
/// [`Feature::Region`] state: region-free — any disc plays regardless of its
/// region code. Sits in the region low-nibble block above the DVD (`0x01..=0x08`)
/// and BD (`0x0A..=0x0C`) values.
pub const REGION_FREE: u8 = 0x0F;
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): reload the saved flash
/// config block into the RAM feature-state table, discarding un-saved RAM changes
/// — the non-destructive "revert to last SAVE". Marker-gated in firmware ≥0.8.3: a
/// never-saved drive loads the baked create-time defaults instead.
pub const RESET_TO_FLASH: u8 = 0x00;
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): restore the baked
/// per-create **defaults** (the create-time flags, e.g. UHD/BD on) into the RAM
/// feature-state table. RAM-only — does not touch flash. This is what a never-saved
/// drive boots to. Firmware ≥0.8.3.
pub const RESET_TO_DEFAULTS: u8 = 0x01;
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): TRUE OEM. Forces every
/// feature to [`STATE_PASSTHROUGH`] in RAM **and** (firmware ≥0.8.3) writes the NV
/// config block all-`0xFF` — marker included — so the drive is byte-for-byte like a
/// never-saved/never-configured drive (no trace it was ever set). Because the marker
/// is then `0xFF`, the NEXT boot loads the baked defaults, exactly like a fresh drive.
pub const RESET_TO_OEM: u8 = 0xFF;
/// The verb selector in `cdb[4]`. These numeric values ARE the wire protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Verb {
/// Status/ping — returns `freemkv <version>` (no NUL), then the raw
/// feature-flag bytes `flag[0x01..=0x06]`, zero-padded to the window.
Identity = 0x01,
/// Set one feature (`cdb[5]`) to a state (`cdb[6]`).
Set = 0x02,
/// Read one feature's (`cdb[5]`) current state back in the data-in payload.
Get = 0x03,
/// Restore the RAM feature-state table. The mode rides in `cdb[6]` (the state
/// slot): [`RESET_TO_FLASH`] (`0x00`) reloads the saved flash config block back
/// into RAM (discarding un-saved changes; marker-gated in fw ≥0.8.3),
/// [`RESET_TO_DEFAULTS`] (`0x01`) restores the baked create-time defaults, and
/// [`RESET_TO_OEM`] (`0xFF`) forces every feature to [`STATE_PASSTHROUGH`] AND
/// blanks the NV block (traceless, fw ≥0.8.3). Ignores feature.
Reset = 0x04,
/// Diagnostic RAM peek: [`MEMREAD_LEN`] bytes at the 32-bit address in
/// `cdb[5..9]` (big-endian).
DumpAll = 0x09,
/// **TEMPORARY** diagnostic probe: program a single byte to an allowlisted
/// flash offset via the OEM PROGRAM routine. `CDB[5..9]` = 32-bit flash offset
/// (big-endian); `CDB[9]` = value byte. The firmware refuses any offset outside
/// the compile-time allowlist window (`0x1EA000..0x1EB000` — the corpus-proven-
/// unlocked NV/SAVE block) with a distinct `"REFU"` status word (PROGRAM not
/// called). Reply: 4-byte offset echo (BE) + 4-byte PROGRAM/REFU status (BE).
/// Not part of the durable feature grammar; subsumed by [`Verb::Save`].
FlashWrite = 0x0A,
/// Persist the whole RAM feature-state table to the flash config block. This is
/// the ONLY verb that writes the config to flash: [`Verb::Set`] and
/// [`Verb::Reset`] touch RAM only, so a host's changes stay volatile until a
/// `Save` commits them (and survive a power cycle only once saved). Ignores
/// feature/state.
Save = 0x0B,
/// **Debug-knock only.** Interactive fw-exploration `blx target` primitive.
/// `CDB[5..9]` = 32-bit target VA (big-endian); `CDB[9]` = single u8 that
/// is loaded into r0 as the sole register argument. Refused under the safe
/// [`KNOCK`]; only executed under [`DEBUG_KNOCK`]. See [`build_call_cdb`].
Call = 0x0C,
/// **Debug-knock only.** Poke a single byte to an arbitrary address.
/// `CDB[5..9]` = 32-bit target address (big-endian, RAM or MMIO);
/// `CDB[9]` = byte value to store. Refused under the safe [`KNOCK`]; only
/// executed under [`DEBUG_KNOCK`]. See [`build_poke_cdb`].
Poke = 0x0D,
/// **Debug-knock only.** Invokes the firmware's boot function entry with
/// r0=4 to force the cold path (BSS clear + C-runtime data init + full
/// post-init), recovering a wedged drive without a power cycle. The target
/// briefly returns Aborted Command mid-reboot, then comes back ~5s later
/// with the safe-knock verb chain re-armed to its power-on defaults. The
/// target VA is baked into the handler at build time, so no CDB arguments
/// are carried beyond the verb byte. Refused under the safe [`KNOCK`];
/// only executed under [`DEBUG_KNOCK`]. See [`build_reboot_cdb`].
Reboot = 0x0F,
}
/// The feature selector in `cdb[5]` for [`Verb::Set`] / [`Verb::Get`]. These
/// numeric values ARE the wire protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Feature {
/// Read-speed / riplock ceiling. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM ramp;
/// [`STATE_OFF`] (`0x00`) = speed control off, i.e. the cap is lifted and the
/// drive runs uncapped at maximum throughput (see [`SPEED_MAX`]); `0x01..=0xFE`
/// = an explicit speed cap (the byte IS the cap, lower is slower). Note the OFF
/// direction: "off" means the *limiter* is off, so OFF is the fastest state.
Speed = 0x01,
/// Region (RPC) control. [`STATE_PASSTHROUGH`] (`0xFF`) = drive's own region
/// logic; [`STATE_OFF`] (`0x00`) = region-locked (nothing plays);
/// [`REGION_DVD_BASE`]` + N` (`0x01..=0x08`) = force DVD region 1..8;
/// [`REGION_BD_A`]/`_B`/`_C` (`0x0A`/`0x0B`/`0x0C`) = force BD region A/B/C;
/// [`REGION_FREE`] (`0x0F`) = region-free (any disc plays).
Region = 0x02,
/// Unrestricted (BD/UHD) capability widen gate. [`STATE_PASSTHROUGH`]
/// (`0xFF`) = OEM; [`STATE_OFF`] (`0x00`) = No (currently no-op on
/// byte-extraction classifiers like BU40N, pending RE); [`STATE_ON`] (`0x01`)
/// = Yes. fw 0.9.2 folds BD into this gate by name only: its BD-refuse detour
/// still reads wire id `0x04` (`Bd`, deprecated), which this mirror does not
/// expose, so on 0.9.2 this lever governs UHD acceptance.
Unrestricted = 0x03,
/// Host Revocation List handling on the cert path. [`STATE_PASSTHROUGH`]
/// (`0xFF`) = OEM enforce; [`STATE_OFF`] (`0x00`) = off (skip the HRL lookup —
/// revoked certs accepted, non-destructive, the unlock direction); [`STATE_ON`]
/// (`0x01`) = on (enforce the HRL).
Hrl = 0x05,
/// The one master encryption/cert bypass. [`STATE_OFF`] (`0x00`) = every
/// drive-side encryption/cert requirement gone (cert-AKE relaxed, bus-wrap
/// off — what used to need a cert now doesn't). [`STATE_PASSTHROUGH`] (`0xFF`)
/// = OEM behavior (real handshake, bus-encrypted content). [`STATE_ON`]
/// (`0x01`) = on (require the real handshake). Wire id `0x07` is retired
/// (was `Bus`, empirically proved inert as a separate datapath lever on
/// BU40N/MT1959 — the single Encryption lever de-busses on its own).
Encryption = 0x06,
}
/// Every exposed feature, in wire-id order (the order [`FirmwareControl::states`]
/// probes). The IDENTITY flag table also carries the unexposed `0x04` slot.
pub const ALL_FEATURES: [Feature; 5] = [
Feature::Speed,
Feature::Region,
Feature::Unrestricted,
Feature::Hrl,
Feature::Encryption,
];
/// A Blu-ray region for [`FirmwareControl::force_region_bd`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BdRegion {
/// Region A (`0x0A`).
A,
/// Region B (`0x0B`).
B,
/// Region C (`0x0C`).
C,
}
impl BdRegion {
/// The `Feature::Region` state byte that forces this BD region.
pub fn state(self) -> u8 {
match self {
BdRegion::A => REGION_BD_A,
BdRegion::B => REGION_BD_B,
BdRegion::C => REGION_BD_C,
}
}
}
// ── CDB builders (mirror of abi.rs) ──────────────────────────────────────────
/// Build a 10-byte host CDB for a verb over the `3C 0E C0 DE …` frame.
///
/// `feature`/`state` land at `cdb[5]`/`cdb[6]` (0 when the verb ignores them);
/// `alloc_len` is the data-in buffer size, 16-bit big-endian at `cdb[7..9]`.
/// Use [`build_memread_cdb`] for [`Verb::DumpAll`] (different field layout).
pub fn build_cdb(
verb: Verb,
feature: Option<Feature>,
state: Option<u8>,
alloc_len: u16,
) -> [u8; CDB_LEN] {
let mut cdb = [0u8; CDB_LEN];
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
cdb[CDB_MODE] = KNOCK_MODE;
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&KNOCK);
cdb[CDB_VERB] = verb as u8;
cdb[CDB_FEATURE] = feature.map(|f| f as u8).unwrap_or(0);
cdb[CDB_STATE] = state.unwrap_or(0);
cdb[CDB_ALLOC_LEN] = (alloc_len >> 8) as u8;
cdb[CDB_ALLOC_LEN + 1] = alloc_len as u8;
cdb
}
/// Build a `SET feature = state` CDB. Requests a [`MIN_ALLOC_LEN`]-byte data-in
/// (the drive aborts sub-16-byte transfers — HW-confirmed, see [`MIN_ALLOC_LEN`]);
/// the feature/state ride in the CDB, so the returned payload is ignored.
pub fn build_set_cdb(feature: Feature, state: u8) -> [u8; CDB_LEN] {
build_cdb(Verb::Set, Some(feature), Some(state), MIN_ALLOC_LEN)
}
/// Build a `GET feature` CDB. Requests a [`MIN_ALLOC_LEN`]-byte data-in (the
/// drive aborts a 1-byte transfer — HW-confirmed, see [`MIN_ALLOC_LEN`]); the
/// current state byte is read back from data offset 0.
pub fn build_get_cdb(feature: Feature) -> [u8; CDB_LEN] {
build_cdb(Verb::Get, Some(feature), None, MIN_ALLOC_LEN)
}
/// Build a `RESET` CDB. `mode` rides in the state slot (`cdb[6]`):
/// [`RESET_TO_FLASH`] (`0x00`) reloads the saved flash config into RAM,
/// [`RESET_TO_DEFAULTS`] (`0x01`) restores the baked create-time defaults, and
/// [`RESET_TO_OEM`] (`0xFF`) forces every feature to passthrough (and blanks NV).
/// Floors its data-in at [`MIN_ALLOC_LEN`] for the same HW min-transfer reason as
/// [`build_set_cdb`].
pub fn build_reset_cdb(mode: u8) -> [u8; CDB_LEN] {
build_cdb(Verb::Reset, None, Some(mode), MIN_ALLOC_LEN)
}
/// Build a `SAVE` CDB (persist the RAM feature-state table to the flash config
/// block — the only verb that writes config to flash). Floors its data-in at
/// [`MIN_ALLOC_LEN`] for the same HW min-transfer reason as [`build_reset_cdb`].
pub fn build_save_cdb() -> [u8; CDB_LEN] {
build_cdb(Verb::Save, None, None, MIN_ALLOC_LEN)
}
/// Build an `IDENTITY` CDB. `alloc_len` sizes the magic+version+state reply.
pub fn build_identity_cdb(alloc_len: u16) -> [u8; CDB_LEN] {
build_cdb(Verb::Identity, None, None, alloc_len)
}
/// Build a [`Verb::DumpAll`] CDB: read [`MEMREAD_LEN`] bytes at the 32-bit
/// `addr`, packed big-endian into `cdb[5..9]`.
pub fn build_memread_cdb(addr: u32) -> [u8; CDB_LEN] {
let mut cdb = [0u8; CDB_LEN];
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
cdb[CDB_MODE] = KNOCK_MODE;
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&KNOCK);
cdb[CDB_VERB] = Verb::DumpAll as u8;
cdb[5] = (addr >> 24) as u8;
cdb[6] = (addr >> 16) as u8;
cdb[7] = (addr >> 8) as u8;
cdb[8] = addr as u8;
cdb
}
/// Build a 10-byte CDB for [`Verb::Call`]: `blx target(r0)` where `target` is
/// packed big-endian in `cdb[5..9]` and the u8 `r0` register argument rides in
/// `cdb[9]`. Carries the [`DEBUG_KNOCK`] at `cdb[2..4]` — the ONLY frame the
/// fw honours for Call. The handler ORs the thumb bit into the address at
/// runtime. r1..r3 are undefined at callee entry.
pub fn build_call_cdb(target: u32, r0: u8) -> [u8; CDB_LEN] {
let mut cdb = [0u8; CDB_LEN];
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
cdb[CDB_MODE] = KNOCK_MODE;
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&DEBUG_KNOCK);
cdb[CDB_VERB] = Verb::Call as u8;
cdb[5] = (target >> 24) as u8;
cdb[6] = (target >> 16) as u8;
cdb[7] = (target >> 8) as u8;
cdb[8] = target as u8;
cdb[9] = r0;
cdb
}
/// Build a 10-byte CDB for [`Verb::Poke`]: write a single byte `val` to the
/// arbitrary 32-bit address `target` (RAM or MMIO). Target is packed
/// big-endian in `cdb[5..9]` and `val` rides in `cdb[9]`. Carries the
/// [`DEBUG_KNOCK`] at `cdb[2..4]`. NO bounds check on the address — this is a
/// diagnostic primitive for on-drive state discovery, not a durable verb.
pub fn build_poke_cdb(target: u32, val: u8) -> [u8; CDB_LEN] {
let mut cdb = [0u8; CDB_LEN];
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
cdb[CDB_MODE] = KNOCK_MODE;
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&DEBUG_KNOCK);
cdb[CDB_VERB] = Verb::Poke as u8;
cdb[5] = (target >> 24) as u8;
cdb[6] = (target >> 16) as u8;
cdb[7] = (target >> 8) as u8;
cdb[8] = target as u8;
cdb[9] = val;
cdb
}
/// Build a 10-byte CDB for [`Verb::Reboot`]: force the firmware boot function's
/// cold path (soft-reboot the controller). Carries the [`DEBUG_KNOCK`] at
/// `cdb[2..4]`. NO arguments are transmitted on the wire — the target VA is
/// baked into the emitted handler at build time (per-image, resolved from the
/// boot-init signature). Requests a [`MIN_ALLOC_LEN`]-byte data-in like every
/// other durable-shape verb: the drive returns Aborted Command mid-reboot
/// anyway, so callers should tolerate a rejection on this send and re-probe
/// identity after a short delay.
pub fn build_reboot_cdb() -> [u8; CDB_LEN] {
let mut cdb = [0u8; CDB_LEN];
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
cdb[CDB_MODE] = KNOCK_MODE;
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&DEBUG_KNOCK);
cdb[CDB_VERB] = Verb::Reboot as u8;
cdb[CDB_ALLOC_LEN] = (MIN_ALLOC_LEN >> 8) as u8;
cdb[CDB_ALLOC_LEN + 1] = MIN_ALLOC_LEN as u8;
cdb
}
/// Whether a device data response leads with [`RESP_MAGIC`].
pub fn verify_response(bytes: &[u8]) -> bool {
bytes.starts_with(RESP_MAGIC)
}
// ── Errors ───────────────────────────────────────────────────────────────────
/// Why a firmware command did not succeed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FirmwareError {
/// The drive rejected the command (CHECK CONDITION / vendor refusal). Not a
/// freemkv drive, or the feature/state is unsupported.
Rejected,
/// IDENTITY did not report freemkv firmware at or above [`MIN_FW_VERSION`];
/// a recipe was refused before any SET/RESET was sent.
UnsupportedFirmware,
/// A caller argument was out of range; nothing was sent to the drive.
InvalidArgument,
/// A verify-after-set (`SET` then `GET`) read back a state that did not
/// match what was written. Carries `(feature, wanted, got)`.
VerifyFailed {
feature: Feature,
wanted: u8,
got: u8,
},
/// A genuine SCSI transport fault (bus dead). Callers must abort.
Transport,
}
/// Result of a firmware command.
pub type Result<T> = std::result::Result<T, FirmwareError>;
// ── Parsed replies ───────────────────────────────────────────────────────────
/// The current state of every feature, indexed by [`Feature`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FeatureStates {
/// [`Feature::Speed`] state.
pub speed: u8,
/// [`Feature::Region`] state.
pub region: u8,
/// [`Feature::Unrestricted`] state.
pub unrestricted: u8,
/// [`Feature::Hrl`] state.
pub hrl: u8,
/// [`Feature::Encryption`] state (the consolidated cert/bus bypass).
pub encryption: u8,
}
impl FeatureStates {
/// All features at [`STATE_PASSTHROUGH`] (the OEM / freshly-reset drive).
pub fn all_passthrough() -> Self {
FeatureStates {
speed: STATE_PASSTHROUGH,
region: STATE_PASSTHROUGH,
unrestricted: STATE_PASSTHROUGH,
hrl: STATE_PASSTHROUGH,
encryption: STATE_PASSTHROUGH,
}
}
/// The state byte for one feature.
pub fn get(&self, feature: Feature) -> u8 {
match feature {
Feature::Speed => self.speed,
Feature::Region => self.region,
Feature::Unrestricted => self.unrestricted,
Feature::Hrl => self.hrl,
Feature::Encryption => self.encryption,
}
}
fn set(&mut self, feature: Feature, state: u8) {
match feature {
Feature::Speed => self.speed = state,
Feature::Region => self.region = state,
Feature::Unrestricted => self.unrestricted = state,
Feature::Hrl => self.hrl = state,
Feature::Encryption => self.encryption = state,
}
}
}
/// A parsed [`Verb::Identity`] reply. Only present when the reply led with
/// [`RESP_MAGIC`]. The reply is `freemkv <version>` with no terminator, then the
/// raw feature-flag bytes; only the version is parsed — read the live states
/// via [`FirmwareControl::states`]/[`get`](FirmwareControl::get).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FirmwareIdentity {
/// The version token after `freemkv ` (printable non-space ASCII), e.g.
/// `"0.9.2"`; empty if no `freemkv ` token follows the magic. A Speed cap in the
/// printable range can append one character after the patch number.
pub version: String,
}
impl FirmwareIdentity {
/// Parse an IDENTITY data-in payload. `None` unless it leads with
/// [`RESP_MAGIC`] (i.e. not freemkv firmware).
pub fn parse(bytes: &[u8]) -> Option<Self> {
if !verify_response(bytes) {
return None;
}
let version = match bytes[RESP_MAGIC.len()..].split_first() {
Some((b' ', rest)) => rest
.iter()
.take_while(|b| b.is_ascii_graphic())
.map(|&b| char::from(b))
.collect(),
_ => String::new(),
};
Some(FirmwareIdentity { version })
}
/// Whether this firmware speaks the grammar this mirror sends (version at
/// or above [`MIN_FW_VERSION`]). `false` for an unparseable version.
pub fn is_supported(&self) -> bool {
self.major_minor().is_some_and(|v| v >= MIN_FW_VERSION)
}
fn major_minor(&self) -> Option<(u32, u32)> {
let mut parts = self.version.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?;
let digits = minor
.find(|c: char| !c.is_ascii_digit())
.map_or(minor, |end| &minor[..end]);
Some((major, digits.parse().ok()?))
}
}
/// A named arming recipe (see [`FirmwareControl`] `arm_*` helpers). Lets a
/// caller pick a mode by value (e.g. from a CLI flag or a config field) and
/// apply it uniformly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArmRecipe {
/// [`FirmwareControl::arm_oem_bd`].
OemBd,
/// [`FirmwareControl::arm_oem_uhd`].
OemUhd,
/// [`FirmwareControl::arm_bypass_bd`].
BypassBd,
/// [`FirmwareControl::arm_bypass_uhd`].
BypassUhd,
// NOTE: Feature::Unrestricted unifies former BD+UHD gates (fw 0.9.2); recipe
// names above name the disc TYPE being ripped, not the retired per-format
// feature — see `arm_oem_uhd`/`arm_bypass_uhd`.
/// [`FirmwareControl::arm_stealth_oem`].
StealthOem,
}
impl ArmRecipe {
/// Whether the recipe sets `Encryption = off`: the drive then acts
/// pre-authenticated and serves de-bussed content (no cert AKE needed).
pub(crate) fn disables_encryption(self) -> bool {
matches!(
self,
ArmRecipe::OemUhd | ArmRecipe::BypassBd | ArmRecipe::BypassUhd
)
}
}
// ── FirmwareControl ──────────────────────────────────────────────────────────
/// A timeout every firmware command uses (ms). Matches the wider unlocker.
const CMD_TIMEOUT_MS: u32 = 5_000;
/// An ergonomic, typed driver for the freemkv vendor-command grammar over any
/// [`ScsiTransport`]. Borrows the transport for the lifetime of the control.
///
/// ```no_run
/// # use freemkv_unlock::firmware::{FirmwareControl, FirmwareError};
/// # fn demo(scsi: &mut dyn freemkv_unlock::scsi::ScsiTransport) -> Result<(), FirmwareError> {
/// let mut fw = FirmwareControl::new(scsi);
/// // Encryption off on a UHD disc, revocation ignored. Refused with
/// // `UnsupportedFirmware` unless IDENTITY reports a supported freemkv drive;
/// // `Transport` (dead bus) must abort the caller.
/// fw.arm_oem_uhd()?;
/// # Ok(())
/// # }
/// ```
pub struct FirmwareControl<'a> {
scsi: &'a mut dyn ScsiTransport,
}
impl<'a> FirmwareControl<'a> {
/// Wrap a transport.
pub fn new(scsi: &'a mut dyn ScsiTransport) -> Self {
FirmwareControl { scsi }
}
/// Issue a CDB with a data-in phase, returning the bytes transferred into a
/// fixed [`MEMREAD_LEN`]-byte buffer.
fn exec_in(&mut self, cdb: &[u8; CDB_LEN]) -> Result<([u8; MEMREAD_LEN], usize)> {
let mut buf = [0u8; MEMREAD_LEN];
match self
.scsi
.execute(cdb, DataDirection::FromDevice, &mut buf, CMD_TIMEOUT_MS)
{
Ok(r) if r.status == 0 => Ok((buf, r.bytes_transferred)),
Ok(_) => Err(FirmwareError::Rejected),
Err(e) => Err(if is_dead_bus(&e) {
FirmwareError::Transport
} else {
FirmwareError::Rejected
}),
}
}
/// Send IDENTITY and parse the magic + version banner.
/// `Ok(None)` if the reply lacked [`RESP_MAGIC`] or the drive refused the
/// command (not freemkv firmware); `Err(Transport)` only on a dead bus.
pub fn identity(&mut self) -> Result<Option<FirmwareIdentity>> {
let cdb = build_identity_cdb(MEMREAD_LEN as u16);
match self.exec_in(&cdb) {
Ok((buf, n)) => Ok(FirmwareIdentity::parse(&buf[..n.min(buf.len())])),
// A drive that rejects the knock is simply not freemkv firmware.
Err(FirmwareError::Rejected) => Ok(None),
Err(e) => Err(e),
}
}
/// Convenience: is this a freemkv-firmware drive? `false` on a rejection or a
/// no-magic reply; `Err(Transport)` on a dead bus.
pub fn is_freemkv(&mut self) -> Result<bool> {
Ok(self.identity()?.is_some())
}
/// Read one feature's current state byte (GET).
pub fn get(&mut self, feature: Feature) -> Result<u8> {
let cdb = build_get_cdb(feature);
let (buf, n) = self.exec_in(&cdb)?;
if n == 0 {
return Err(FirmwareError::Rejected);
}
Ok(buf[0])
}
/// Set one feature to an explicit state byte (SET).
///
/// Issued with a data-in phase (`exec_in`), not `exec_none`: the drive's
/// READ BUFFER hijack aborts ANY vendor verb that carries no data-in
/// transfer — the `MIN_ALLOC_LEN` floor in the CDB is only honored when the
/// host actually reads that many bytes back. The returned bytes are the
/// echoed state frame; we discard them (GET is the read path).
pub fn set(&mut self, feature: Feature, state: u8) -> Result<()> {
let cdb = build_set_cdb(feature, state);
self.exec_in(&cdb).map(|_| ())
}
/// Restore every feature to [`STATE_PASSTHROUGH`] (RESET with [`RESET_TO_OEM`])
/// — TRUE OEM: firmware ≥0.8.3 also blanks the NV block so the drive is
/// traceless. Data-in phase for the same reason as [`set`](Self::set) — a no-data
/// RESET is aborted.
pub fn reset(&mut self) -> Result<()> {
let cdb = build_reset_cdb(RESET_TO_OEM);
self.exec_in(&cdb).map(|_| ())
}
/// Restore the baked create-time defaults into the RAM feature-state table
/// (RESET with [`RESET_TO_DEFAULTS`]) — what a never-saved drive boots to; does
/// not touch flash. Firmware ≥0.8.3. Data-in phase for the same reason as
/// [`reset`](Self::reset).
pub fn reset_to_defaults(&mut self) -> Result<()> {
let cdb = build_reset_cdb(RESET_TO_DEFAULTS);
self.exec_in(&cdb).map(|_| ())
}
/// Reload the saved flash config block back into the RAM feature-state table
/// (RESET with [`RESET_TO_FLASH`]), discarding any un-saved RAM changes — the
/// non-destructive "revert to last SAVE". Data-in phase for the same reason as
/// [`reset`](Self::reset).
pub fn reset_to_flash(&mut self) -> Result<()> {
let cdb = build_reset_cdb(RESET_TO_FLASH);
self.exec_in(&cdb).map(|_| ())
}
/// Persist the whole RAM feature-state table to the flash config block (SAVE)
/// — the only verb that writes config to flash, so a host's SET changes survive
/// a power cycle only once saved. Data-in phase for the same reason as
/// [`reset`](Self::reset).
pub fn save(&mut self) -> Result<()> {
let cdb = build_save_cdb();
self.exec_in(&cdb).map(|_| ())
}
/// SET a feature, then GET it back and confirm the state stuck. Blind spot:
/// fw answers a refused/out-of-range GET with GOOD + zeros, which reads as
/// [`STATE_OFF`]; the recipes gate on [`Self::require_supported`] first.
fn set_verify(&mut self, feature: Feature, state: u8) -> Result<()> {
self.set(feature, state)?;
let got = self.get(feature)?;
if got == state {
Ok(())
} else {
Err(FirmwareError::VerifyFailed {
feature,
wanted: state,
got,
})
}
}
/// Refuse (`UnsupportedFirmware`) unless IDENTITY reports a supported
/// freemkv firmware; every recipe calls this before its first SET/RESET.
fn require_supported(&mut self) -> Result<()> {
match self.identity()? {
Some(id) if id.is_supported() => Ok(()),
_ => Err(FirmwareError::UnsupportedFirmware),
}
}
/// Read the current state of every feature via repeated GET.
pub fn states(&mut self) -> Result<FeatureStates> {
let mut s = FeatureStates::all_passthrough();
for feature in ALL_FEATURES {
s.set(feature, self.get(feature)?);
}
Ok(s)
}
/// Diagnostic RAM peek: [`MEMREAD_LEN`] bytes at `addr` (DUMPALL).
pub fn dump(&mut self, addr: u32) -> Result<[u8; MEMREAD_LEN]> {
let cdb = build_memread_cdb(addr);
let (buf, n) = self.exec_in(&cdb)?;
if n < MEMREAD_LEN {
return Err(FirmwareError::Rejected);
}
Ok(buf)
}
// ── Typed setters ────────────────────────────────────────────────────────
/// Force the Unrestricted (BD/UHD) capability gate on (drive engages both
/// BD and UHD discs). fw 0.9.2 unified the former separate UHD/BD gates
/// into this single lever.
pub fn enable_unrestricted(&mut self) -> Result<()> {
self.set(Feature::Unrestricted, STATE_ON)
}
/// Force the Unrestricted (BD/UHD) capability gate off.
pub fn disable_unrestricted(&mut self) -> Result<()> {
self.set(Feature::Unrestricted, STATE_OFF)
}
/// Skip the HRL lookup (revoked certs accepted; non-destructive). The unlock
/// direction is now [`STATE_OFF`] (`0x00` = HRL enforcement off).
pub fn skip_hrl(&mut self) -> Result<()> {
self.set(Feature::Hrl, STATE_OFF)
}
/// Disable every drive-side encryption/cert requirement in one shot: the
/// drive acts pre-authenticated (no handshake) AND content returns de-bussed
/// (no in-transit bus encryption). The consolidated `Encryption` lever
/// replaces the old separate `Ake`/`Bus` levers — wire id `0x07` (`Bus`) is
/// retired. The unlock direction is [`STATE_OFF`] (`0x00`).
pub fn disable_encryption(&mut self) -> Result<()> {
self.set(Feature::Encryption, STATE_OFF)
}
/// Region-free (RPC-1). Sends [`REGION_FREE`] (`0x0F`) — under the migrated
/// region scheme `0x01` now means "force DVD region 1", not region-free.
pub fn region_free(&mut self) -> Result<()> {
self.set(Feature::Region, REGION_FREE)
}
/// Force a specific Blu-ray region.
pub fn force_region_bd(&mut self, region: BdRegion) -> Result<()> {
self.set(Feature::Region, region.state())
}
/// Force a specific DVD region (1..=8). Returns
/// [`FirmwareError::InvalidArgument`] for an out-of-range region without
/// touching the drive.
pub fn force_region_dvd(&mut self, region: u8) -> Result<()> {
if !(1..=8).contains(&region) {
return Err(FirmwareError::InvalidArgument);
}
self.set(Feature::Region, REGION_DVD_BASE + region)
}
/// Lift the read-speed / riplock ceiling to maximum.
pub fn unlock_speed(&mut self) -> Result<()> {
self.set(Feature::Speed, SPEED_MAX)
}
// ── Named recipes (the "modes" table) ─────────────────────────────────────
// Each recipe first issues IDENTITY and refuses unsupported firmware. A
// recipe that fails midway leaves its earlier SETs applied (no rollback).
/// **arm_oem_bd** — OEM-style Blu-ray (AACS 1.0) rip with a REAL AKE, only
/// revocation disabled.
///
/// Sets: `Hrl = skip` (revoked host certs accepted; non-destructive).
/// Leaves everything else at passthrough — the drive still runs the real
/// host-cert handshake and returns bus-encrypted content, so the host
/// performs the AKE and de-busses. Rips: BD with an otherwise-revoked cert.
pub fn arm_oem_bd(&mut self) -> Result<()> {
self.require_supported()?;
self.set_verify(Feature::Hrl, STATE_OFF)
}
/// **arm_oem_uhd** — OEM-style UHD (AACS 2.0) rip.
///
/// Sets: `Unrestricted = on` (mode-gate neutralized so the drive engages
/// the UHD disc), `Hrl = skip` (revocation off), `Encryption = off`
/// (content de-bussed — the consolidated cert/bus bypass). Rips: UHD via
/// the OEM path.
pub fn arm_oem_uhd(&mut self) -> Result<()> {
self.require_supported()?;
self.set_verify(Feature::Unrestricted, STATE_ON)?;
self.set_verify(Feature::Hrl, STATE_OFF)?;
self.set_verify(Feature::Encryption, STATE_OFF)
}
/// **arm_bypass_bd** — full-bypass Blu-ray rip (NO host cert needed).
///
/// Sets: `Encryption = off` (the consolidated cert/bus bypass — drive acts
/// pre-authenticated, no handshake, content de-bussed) and `Hrl = skip`
/// (revocation off). Rips: BD with no cert.
pub fn arm_bypass_bd(&mut self) -> Result<()> {
self.require_supported()?;
self.set_verify(Feature::Hrl, STATE_OFF)?;
self.set_verify(Feature::Encryption, STATE_OFF)
}
/// **arm_bypass_uhd** — full-bypass UHD rip (NO host cert needed).
///
/// Sets: `Unrestricted = on` (engage the UHD disc), `Encryption = off`
/// (the consolidated cert/bus bypass), and `Hrl = skip` (revocation off).
/// Rips: UHD with no cert.
pub fn arm_bypass_uhd(&mut self) -> Result<()> {
self.require_supported()?;
self.set_verify(Feature::Unrestricted, STATE_ON)?;
self.set_verify(Feature::Hrl, STATE_OFF)?;
self.set_verify(Feature::Encryption, STATE_OFF)
}
/// **arm_stealth_oem** — return the drive to byte-for-byte OEM behaviour.
///
/// Issues RESET (every feature → passthrough) and verifies each feature read
/// back as [`STATE_PASSTHROUGH`]. Rips: nothing — this DISARMS the drive.
/// Needs fw >= [`MIN_FW_VERSION`]; raw [`reset`](Self::reset) has no gate.
pub fn arm_stealth_oem(&mut self) -> Result<()> {
self.require_supported()?;
self.reset()?;
for feature in ALL_FEATURES {
let got = self.get(feature)?;
if got != STATE_PASSTHROUGH {
return Err(FirmwareError::VerifyFailed {
feature,
wanted: STATE_PASSTHROUGH,
got,
});
}
}
Ok(())
}
/// Apply a named [`ArmRecipe`].
pub fn arm(&mut self, recipe: ArmRecipe) -> Result<()> {
match recipe {
ArmRecipe::OemBd => self.arm_oem_bd(),
ArmRecipe::OemUhd => self.arm_oem_uhd(),
ArmRecipe::BypassBd => self.arm_bypass_bd(),
ArmRecipe::BypassUhd => self.arm_bypass_uhd(),
ArmRecipe::StealthOem => self.arm_stealth_oem(),
}
}
}
/// Test fixture: the fw 0.9.x IDENTITY wire shape (core.rs `identity_blob` +
/// handler): `freemkv <ver>` with NO NUL, then the 6 raw flag bytes
/// `flag[0x01..=0x06]`, then zero padding to the 64-byte window.
#[cfg(test)]
pub(crate) fn identity_reply(version: &str, flags: [u8; 6]) -> Vec<u8> {
let mut p = RESP_MAGIC.to_vec();
p.push(b' ');
p.extend_from_slice(version.as_bytes());
p.extend_from_slice(&flags);
p.resize(MEMREAD_LEN, 0);
p
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
+837
View File
@@ -0,0 +1,837 @@
//! Tests for the firmware vendor-command mirror + [`FirmwareControl`].
use super::*;
use crate::scsi::mock::{MockTransport, Reply};
use crate::scsi::{DataDirection, Result as ScsiTResult, ScsiResult, ScsiTransport};
// ── Pinned-value drift guards (vs. freemkv-fw/src/abi.rs) ────────────────────
#[test]
fn verb_values_match_abi() {
assert_eq!(Verb::Identity as u8, 0x01);
assert_eq!(Verb::Set as u8, 0x02);
assert_eq!(Verb::Get as u8, 0x03);
assert_eq!(Verb::Reset as u8, 0x04);
assert_eq!(Verb::DumpAll as u8, 0x09);
assert_eq!(Verb::FlashWrite as u8, 0x0A);
assert_eq!(Verb::Save as u8, 0x0B);
assert_eq!(Verb::Call as u8, 0x0C);
assert_eq!(Verb::Poke as u8, 0x0D);
assert_eq!(Verb::Reboot as u8, 0x0F);
}
#[test]
fn feature_values_match_abi() {
assert_eq!(Feature::Speed as u8, 0x01);
assert_eq!(Feature::Region as u8, 0x02);
assert_eq!(Feature::Unrestricted as u8, 0x03);
assert_eq!(Feature::Hrl as u8, 0x05);
assert_eq!(Feature::Encryption as u8, 0x06);
// Wire id 0x04 (`Bd`, deprecated in fw 0.9.2) is deliberately not exposed.
// Wire id 0x07 (was `Bus`) is retired — proven inert on BU40N/MT1959.
assert_eq!(ALL_FEATURES.len(), 5);
}
#[test]
fn debug_knock_distinct_from_safe_knock() {
// The fw dispatches Call/Poke ONLY under DEBUG_KNOCK; a typo of a safe verb
// vs a debug verb under the wrong knock never crosses the line.
assert_ne!(KNOCK, DEBUG_KNOCK);
assert_eq!(DEBUG_KNOCK, [0xDE, 0xB9]);
}
#[test]
fn state_and_frame_constants_match_abi() {
assert_eq!(READ_BUFFER_OPCODE, 0x3C);
assert_eq!(KNOCK_MODE, 0x0E);
assert_eq!(KNOCK, [0xC0, 0xDE]);
assert_eq!(RESP_MAGIC, b"freemkv");
assert_eq!(CDB_LEN, 10);
assert_eq!(MEMREAD_LEN, 64);
assert_eq!(MIN_ALLOC_LEN, 64);
assert_eq!(STATE_PASSTHROUGH, 0xFF);
assert_eq!(STATE_OFF, 0x00);
assert_eq!(STATE_ON, 0x01);
assert_eq!(SPEED_MAX, 0x00);
assert_eq!([REGION_BD_A, REGION_BD_B, REGION_BD_C], [0x0A, 0x0B, 0x0C]);
assert_eq!(REGION_DVD_BASE, 0x00);
assert_eq!(REGION_FREE, 0x0F);
assert_eq!(RESET_TO_FLASH, 0x00);
assert_eq!(RESET_TO_DEFAULTS, 0x01);
assert_eq!(RESET_TO_OEM, 0xFF);
}
// ── CDB builders ─────────────────────────────────────────────────────────────
#[test]
fn build_set_cdb_exact_bytes() {
// SET Encryption = ON: verb 02, feature 06, state 01, alloc MIN_ALLOC_LEN
// (0x0040) at cdb[7..9] big-endian — the drive aborts a sub-16-byte data-in.
assert_eq!(
build_set_cdb(Feature::Encryption, STATE_ON),
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x06, 0x01, 0x00, 0x40, 0x00]
);
}
#[test]
fn build_get_cdb_exact_bytes() {
// GET Encryption: verb 03, feature 06, alloc MIN_ALLOC_LEN (0x0040) at
// cdb[7..9] big-endian — the drive aborts a 1-byte data-in; state read
// from offset 0.
assert_eq!(
build_get_cdb(Feature::Encryption),
[0x3C, 0x0E, 0xC0, 0xDE, 0x03, 0x06, 0x00, 0x00, 0x40, 0x00]
);
}
#[test]
fn vendor_commands_floor_alloc_len_at_min_for_hw() {
// HW-confirmed: the drive aborts a vendor command with a sub-16-byte data-in
// allocation, so SET/GET/RESET all request at least MIN_ALLOC_LEN (= 64,
// comfortably above the ~16-byte hardware floor).
assert_eq!(MIN_ALLOC_LEN, 64);
let alloc =
|cdb: &[u8; CDB_LEN]| u16::from_be_bytes([cdb[CDB_ALLOC_LEN], cdb[CDB_ALLOC_LEN + 1]]);
assert_eq!(
alloc(&build_set_cdb(Feature::Encryption, STATE_ON)),
MIN_ALLOC_LEN
);
assert_eq!(alloc(&build_get_cdb(Feature::Encryption)), MIN_ALLOC_LEN);
assert_eq!(alloc(&build_reset_cdb(RESET_TO_OEM)), MIN_ALLOC_LEN);
assert_eq!(alloc(&build_save_cdb()), MIN_ALLOC_LEN);
}
#[test]
fn build_call_and_poke_cdb_exact_bytes() {
// Call: verb 0x0C at cdb[4], DEBUG_KNOCK at cdb[2..4], target big-endian
// in cdb[5..9], r0 in cdb[9].
assert_eq!(
build_call_cdb(0x0102_0304, 0xAB),
[0x3C, 0x0E, 0xDE, 0xB9, 0x0C, 0x01, 0x02, 0x03, 0x04, 0xAB]
);
// Poke: verb 0x0D at cdb[4], DEBUG_KNOCK at cdb[2..4], target big-endian
// in cdb[5..9], value in cdb[9].
assert_eq!(
build_poke_cdb(0x0200_0E40, 0xAA),
[0x3C, 0x0E, 0xDE, 0xB9, 0x0D, 0x02, 0x00, 0x0E, 0x40, 0xAA]
);
// Reboot: verb 0x0F at cdb[4], DEBUG_KNOCK at cdb[2..4], no on-wire target
// (the boot-entry VA is baked into the handler at build time). Alloc floors
// at MIN_ALLOC_LEN (0x0040 at cdb[7..9]) so the data-in is not aborted.
assert_eq!(
build_reboot_cdb(),
[0x3C, 0x0E, 0xDE, 0xB9, 0x0F, 0x00, 0x00, 0x00, 0x40, 0x00]
);
}
#[test]
fn build_reset_and_identity_cdb_exact_bytes() {
// RESET carries its mode in the state slot (cdb[6]) and floors its data-in at
// MIN_ALLOC_LEN (0x0040) for the same HW reason: to-OEM = 0xFF, to-flash = 0x00.
assert_eq!(
build_reset_cdb(RESET_TO_OEM),
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x00, 0xFF, 0x00, 0x40, 0x00]
);
assert_eq!(
build_reset_cdb(RESET_TO_FLASH),
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x00, 0x00, 0x00, 0x40, 0x00]
);
// SAVE takes no feature/state and floors its data-in at MIN_ALLOC_LEN.
assert_eq!(
build_save_cdb(),
[0x3C, 0x0E, 0xC0, 0xDE, 0x0B, 0x00, 0x00, 0x00, 0x40, 0x00]
);
// IDENTITY with a 64-byte allocation (0x0040 big-endian at cdb[7..9]).
assert_eq!(
build_identity_cdb(MEMREAD_LEN as u16),
[0x3C, 0x0E, 0xC0, 0xDE, 0x01, 0x00, 0x00, 0x00, 0x40, 0x00]
);
}
#[test]
fn build_cdb_encodes_alloc_len_16bit_big_endian() {
let cdb = build_cdb(Verb::Identity, None, None, 0x1234);
assert_eq!([cdb[CDB_ALLOC_LEN], cdb[CDB_ALLOC_LEN + 1]], [0x12, 0x34]);
}
#[test]
fn build_memread_cdb_packs_address_big_endian_at_5_to_9() {
let cdb = build_memread_cdb(0xDEAD_BEEF);
assert_eq!(cdb[CDB_VERB], Verb::DumpAll as u8);
assert_eq!([cdb[5], cdb[6], cdb[7], cdb[8]], [0xDE, 0xAD, 0xBE, 0xEF]);
assert_eq!(cdb[9], 0x00);
}
#[test]
fn region_state_bytes() {
assert_eq!(BdRegion::A.state(), 0x0A);
assert_eq!(BdRegion::B.state(), 0x0B);
assert_eq!(BdRegion::C.state(), 0x0C);
}
// ── Identity / states parsing ────────────────────────────────────────────────
#[test]
fn identity_parse_reads_version_banner() {
let payload = identity_reply("0.9.2", [0x00; 6]);
let id = FirmwareIdentity::parse(&payload).expect("has magic");
assert_eq!(id.version, "0.9.2");
}
/// Regression: the appended flag table (fresh-boot DEFAULT_FLAGS here) must not
/// leak into the version, whatever the live feature states are.
#[test]
fn identity_parse_ignores_the_appended_flag_table() {
for flags in [
[0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF],
[0x0F, 0x0F, 0x01, 0xFF, 0x00, 0x00],
] {
let id = FirmwareIdentity::parse(&identity_reply("0.9.2", flags)).expect("has magic");
assert_eq!(id.version, "0.9.2", "flags {flags:02X?}");
}
}
/// Released fw 0.7.1 answers `freemkv 0.7.1` + zero padding: parsed, but
/// below MIN_FW_VERSION.
#[test]
fn identity_parse_legacy_07_is_unsupported() {
let mut payload = b"freemkv 0.7.1".to_vec();
payload.resize(MEMREAD_LEN, 0);
let id = FirmwareIdentity::parse(&payload).expect("has magic");
assert_eq!(id.version, "0.7.1");
assert!(!id.is_supported());
}
/// Magic with no ` <version>` token parses as freemkv with an empty version.
#[test]
fn identity_parse_without_a_token_has_an_empty_version() {
let mut payload = RESP_MAGIC.to_vec();
payload.push(0x01);
payload.resize(MEMREAD_LEN, 0);
let id = FirmwareIdentity::parse(&payload).expect("has magic");
assert_eq!(id.version, "");
assert!(!id.is_supported());
}
/// The supported-grammar predicate: 0.9+ only; 0.8.x, 0.7.x, an empty token
/// and garbage are refused. A trailing flag char never moves minor.
#[test]
fn identity_is_supported_only_from_min_fw_version() {
let id = |v: &str| FirmwareIdentity {
version: v.to_string(),
};
for v in ["0.9.0", "0.9.2", "0.9.2-rc1", "0.9.22", "0.10.0", "1.0.0"] {
assert!(id(v).is_supported(), "{v}");
}
for v in ["0.8.3", "0.8.14", "0.7.1", "", "0", "0.", "x.9.0", "0.x"] {
assert!(!id(v).is_supported(), "{v}");
}
assert_eq!(MIN_FW_VERSION, (0, 9));
}
#[test]
fn identity_parse_none_without_magic() {
assert!(FirmwareIdentity::parse(&[0u8; 64]).is_none());
}
// ── A stateful firmware mock ─────────────────────────────────────────────────
/// A mock freemkv drive: answers IDENTITY with the real fw shape (banner + flag
/// table, see [`identity_reply`]), GET with the requested feature's state,
/// applies SET, and models the three RESET modes. Records every CDB.
///
/// It also models the real hardware constraint: any vendor verb executed with
/// [`DataDirection::None`] (no data-in phase) is REJECTED with a non-zero
/// status, exactly as the drive's READ BUFFER hijack aborts a no-data verb.
struct FwMock {
version: &'static str,
states: FeatureStates,
/// The baked create-time defaults (fw `DEFAULT_FLAGS`: Unrestricted on).
defaults: FeatureStates,
/// The saved flash config block (`None` = never saved: RESET_TO_FLASH
/// then loads `defaults`, as the marker-gated fw >= 0.8.3 does).
saved: Option<FeatureStates>,
is_freemkv: bool,
/// Models a drive that ACKs RESET but leaves its RAM flags unchanged.
ignore_reset: bool,
cdbs: Vec<Vec<u8>>,
}
impl FwMock {
fn new() -> Self {
let defaults = FeatureStates {
unrestricted: STATE_ON,
..FeatureStates::all_passthrough()
};
FwMock {
version: "0.9.2",
states: FeatureStates::all_passthrough(),
defaults,
saved: None,
is_freemkv: true,
ignore_reset: false,
cdbs: Vec::new(),
}
}
fn feature_of(id: u8) -> Feature {
*ALL_FEATURES
.iter()
.find(|f| **f as u8 == id)
.expect("known feature id")
}
/// The verbs issued, in order (cdb[4] of each recorded CDB).
fn verbs(&self) -> Vec<u8> {
self.cdbs.iter().map(|c| c[CDB_VERB]).collect()
}
/// The verbs issued after the leading IDENTITY gate of an arm recipe.
fn verbs_after_identity(&self) -> Vec<u8> {
assert_eq!(self.cdbs[0][CDB_VERB], Verb::Identity as u8);
self.verbs()[1..].to_vec()
}
}
impl ScsiTransport for FwMock {
fn execute(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> ScsiTResult<ScsiResult> {
self.cdbs.push(cdb.to_vec());
// Model the hardware: a vendor verb with no data-in phase is aborted.
// This is what makes the old `exec_none` SET/RESET path a test failure.
let is_vendor = cdb[CDB_OPCODE] == READ_BUFFER_OPCODE && cdb[CDB_MODE] == KNOCK_MODE;
if is_vendor && matches!(dir, DataDirection::None) {
return Ok(ScsiResult {
status: 0x02, // CHECK CONDITION — rejected
bytes_transferred: 0,
sense: [0u8; 32],
});
}
let mut transferred = 0usize;
match cdb[CDB_VERB] {
v if v == Verb::Identity as u8 => {
// Non-freemkv: GOOD status but no magic (buffer stays zero).
if self.is_freemkv {
let s = self.states;
// Slot 0x04 (`Bd`, unexposed) at its DEFAULT_FLAGS value.
let flags = [
s.speed,
s.region,
s.unrestricted,
STATE_ON,
s.hrl,
s.encryption,
];
let resp = identity_reply(self.version, flags);
let n = resp.len().min(data.len());
data[..n].copy_from_slice(&resp[..n]);
}
transferred = data.len();
}
v if v == Verb::Get as u8 => {
let f = Self::feature_of(cdb[CDB_FEATURE]);
if !data.is_empty() {
data[0] = self.states.get(f);
transferred = 1;
}
}
v if v == Verb::Set as u8 => {
let f = Self::feature_of(cdb[CDB_FEATURE]);
self.states.set(f, cdb[CDB_STATE]);
}
v if v == Verb::Reset as u8 && !self.ignore_reset => {
// The mode rides in the state slot (cdb[6]).
self.states = match cdb[CDB_STATE] {
RESET_TO_FLASH => self.saved.unwrap_or(self.defaults),
RESET_TO_DEFAULTS => self.defaults,
_ => FeatureStates::all_passthrough(),
};
}
v if v == Verb::Save as u8 => {
self.saved = Some(self.states);
}
v if v == Verb::DumpAll as u8 => {
for (i, b) in data.iter_mut().enumerate() {
*b = i as u8;
}
transferred = data.len();
}
_ => {}
}
Ok(ScsiResult {
status: 0,
bytes_transferred: transferred,
sense: [0u8; 32],
})
}
}
// ── FirmwareControl behaviour ────────────────────────────────────────────────
#[test]
fn identity_detects_freemkv_and_issues_identity_cdb() {
let mut m = FwMock::new();
m.states.encryption = STATE_ON;
{
let mut fw = FirmwareControl::new(&mut m);
let id = fw.identity().expect("no fault").expect("is freemkv");
assert_eq!(id.version, "0.9.2");
// The appended flag table is not parsed; states are read via GET.
assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_ON);
assert!(fw.is_freemkv().expect("no fault"));
}
assert_eq!(m.cdbs[0], build_identity_cdb(MEMREAD_LEN as u16));
}
#[test]
fn identity_none_on_non_freemkv() {
let mut m = FwMock::new();
m.is_freemkv = false;
let mut fw = FirmwareControl::new(&mut m);
assert!(fw.identity().expect("no fault").is_none());
assert!(!fw.is_freemkv().expect("no fault"));
}
#[test]
fn identity_none_when_drive_rejects() {
let mut t = MockTransport::always(Reply::illegal_request());
let mut fw = FirmwareControl::new(&mut t);
assert!(fw.identity().expect("rejection is not a fault").is_none());
}
/// A refusal carrying a full magic-bearing buffer must still be `None`: the
/// status guard, not the empty transfer, decides.
#[test]
fn identity_none_when_status_is_not_good_despite_a_magic_buffer() {
struct CheckConditionWithData;
impl ScsiTransport for CheckConditionWithData {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> ScsiTResult<ScsiResult> {
let resp = identity_reply("0.9.2", [0xFF; 6]);
data.copy_from_slice(&resp[..data.len()]);
Ok(ScsiResult {
status: 0x02,
bytes_transferred: data.len(),
sense: [0u8; 32],
})
}
}
let mut t = CheckConditionWithData;
assert!(
FirmwareControl::new(&mut t)
.identity()
.expect("not a fault")
.is_none()
);
}
#[test]
fn identity_transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
let mut fw = FirmwareControl::new(&mut t);
assert_eq!(fw.identity().unwrap_err(), FirmwareError::Transport);
}
/// Every data-in verb maps a drive refusal to `Rejected`, whether the transport
/// reports it as `Ok` + CHECK CONDITION or as `Err` carrying a sense.
#[test]
fn every_verb_maps_both_rejection_shapes_to_rejected() {
for reply in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
let mut t = MockTransport::always(reply.clone());
let mut fw = FirmwareControl::new(&mut t);
let r = FirmwareError::Rejected;
assert_eq!(fw.set(Feature::Encryption, STATE_OFF), Err(r.clone()));
assert_eq!(fw.get(Feature::Encryption), Err(r.clone()));
assert_eq!(fw.reset(), Err(r.clone()));
assert_eq!(fw.reset_to_defaults(), Err(r.clone()));
assert_eq!(fw.reset_to_flash(), Err(r.clone()));
assert_eq!(fw.save(), Err(r.clone()));
assert_eq!(fw.dump(0), Err(r), "{reply:?}");
}
}
/// Untrusted lengths: a GOOD-status zero-byte GET is not state 0x00, and a
/// short DUMPALL is not a RAM window padded with zeros.
#[test]
fn get_and_dump_reject_short_transfers() {
let mut t = MockTransport::always(Reply::zero_transfer(MEMREAD_LEN));
assert_eq!(
FirmwareControl::new(&mut t).get(Feature::Encryption),
Err(FirmwareError::Rejected)
);
let mut t = MockTransport::always(Reply::short(vec![0xAB; MEMREAD_LEN], MEMREAD_LEN - 1));
assert_eq!(
FirmwareControl::new(&mut t).dump(0),
Err(FirmwareError::Rejected)
);
}
#[test]
fn get_and_set_roundtrip_issue_the_right_cdbs() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_PASSTHROUGH);
fw.set(Feature::Encryption, STATE_ON).expect("set");
assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_ON);
}
assert_eq!(m.cdbs[0], build_get_cdb(Feature::Encryption));
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Encryption, STATE_ON));
assert_eq!(m.cdbs[2], build_get_cdb(Feature::Encryption));
}
#[test]
fn reset_sends_reset_cdb_and_clears_state() {
let mut m = FwMock::new();
m.states.encryption = STATE_ON;
{
let mut fw = FirmwareControl::new(&mut m);
fw.reset().expect("reset");
}
assert_eq!(m.cdbs[0], build_reset_cdb(RESET_TO_OEM));
assert_eq!(m.states, FeatureStates::all_passthrough());
}
/// RESET_TO_DEFAULTS restores the baked create-time defaults, which are NOT
/// all-passthrough (Unrestricted ships on).
#[test]
fn reset_to_defaults_restores_the_baked_defaults() {
let mut m = FwMock::new();
m.states.encryption = STATE_OFF;
let states;
{
let mut fw = FirmwareControl::new(&mut m);
fw.reset_to_defaults().expect("reset to defaults");
states = fw.states().expect("states");
}
assert_eq!(m.cdbs[0], build_reset_cdb(RESET_TO_DEFAULTS));
assert_eq!(states.unrestricted, STATE_ON);
assert_eq!(states.encryption, STATE_PASSTHROUGH);
assert_ne!(states, FeatureStates::all_passthrough());
}
#[test]
fn save_then_reset_to_flash_restores_the_saved_value() {
// SET a feature, SAVE it, force all-passthrough with RESET(to-OEM), then
// RESET(to-flash) must reload the saved value (not passthrough).
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.set(Feature::Encryption, STATE_OFF).expect("set");
fw.save().expect("save");
fw.reset().expect("reset to OEM");
assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_PASSTHROUGH);
fw.reset_to_flash().expect("reset to flash");
assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_OFF);
}
// The verbs issued, in order: SET, SAVE, RESET, GET, RESET, GET.
assert_eq!(
m.verbs(),
vec![
Verb::Set as u8,
Verb::Save as u8,
Verb::Reset as u8,
Verb::Get as u8,
Verb::Reset as u8,
Verb::Get as u8,
]
);
assert_eq!(m.cdbs[1], build_save_cdb());
assert_eq!(m.cdbs[2], build_reset_cdb(RESET_TO_OEM));
assert_eq!(m.cdbs[4], build_reset_cdb(RESET_TO_FLASH));
}
#[test]
fn states_probes_every_feature_in_order() {
let mut m = FwMock::new();
m.states.speed = SPEED_MAX;
m.states.hrl = STATE_ON;
let states;
{
let mut fw = FirmwareControl::new(&mut m);
states = fw.states().expect("states");
}
assert_eq!(states.speed, SPEED_MAX);
assert_eq!(states.hrl, STATE_ON);
// Five GETs, one per exposed feature, in wire-id order (0x04 `Bd` is not
// exposed; 0x07 `Bus` is retired).
assert_eq!(m.cdbs.len(), 5);
let features: Vec<u8> = m.cdbs.iter().map(|c| c[CDB_FEATURE]).collect();
assert_eq!(features, vec![0x01, 0x02, 0x03, 0x05, 0x06]);
assert!(m.verbs().iter().all(|&v| v == Verb::Get as u8));
}
#[test]
fn dump_issues_memread_and_returns_the_window() {
let mut m = FwMock::new();
let got;
{
let mut fw = FirmwareControl::new(&mut m);
got = fw.dump(0x0102_0304).expect("dump");
}
assert_eq!(got[0], 0x00);
assert_eq!(got[63], 63);
assert_eq!(m.cdbs[0], build_memread_cdb(0x0102_0304));
}
// ── Typed setters ────────────────────────────────────────────────────────────
#[test]
fn typed_setters_issue_expected_set_cdbs() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.enable_unrestricted().unwrap();
fw.skip_hrl().unwrap();
fw.disable_encryption().unwrap();
fw.region_free().unwrap();
fw.force_region_bd(BdRegion::B).unwrap();
fw.force_region_dvd(2).unwrap();
fw.unlock_speed().unwrap();
}
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Unrestricted, STATE_ON));
// The HRL/Encryption unlock direction is STATE_OFF (0x00), not STATE_ON.
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Encryption, STATE_OFF));
// Region-free is REGION_FREE (0x0F) — 0x01 now forces DVD region 1.
assert_eq!(m.cdbs[3], build_set_cdb(Feature::Region, REGION_FREE));
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Region, REGION_BD_B));
// force_region_dvd(2) = REGION_DVD_BASE + 2 = 0x02 under the new base.
assert_eq!(m.cdbs[5], build_set_cdb(Feature::Region, 0x02));
assert_eq!(m.cdbs[6], build_set_cdb(Feature::Speed, SPEED_MAX));
}
/// Regression: a bad caller argument is not a drive refusal, and the bus is
/// never touched.
#[test]
fn force_region_dvd_rejects_out_of_range_as_caller_error() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
for bad in [0u8, 9] {
let err = fw.force_region_dvd(bad).unwrap_err();
assert_eq!(err, FirmwareError::InvalidArgument, "region {bad}");
}
}
assert!(m.cdbs.is_empty());
}
// ── Recipes ──────────────────────────────────────────────────────────────────
#[test]
fn arm_oem_bd_sets_and_verifies_hrl_skip() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.arm_oem_bd().expect("armed");
}
// IDENTITY gate, then SET Hrl=OFF and GET Hrl to verify.
assert_eq!(m.cdbs[0], build_identity_cdb(MEMREAD_LEN as u16));
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(m.cdbs[2], build_get_cdb(Feature::Hrl));
assert_eq!(m.states.hrl, STATE_OFF);
}
#[test]
fn arm_oem_uhd_sets_uhd_hrl_encryption_each_verified() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.arm_oem_uhd().expect("armed");
}
// set,get, set,get, set,get
assert_eq!(
m.verbs_after_identity(),
vec![0x02, 0x03, 0x02, 0x03, 0x02, 0x03],
"each set is verified by a get"
);
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Unrestricted, STATE_ON));
assert_eq!(m.cdbs[3], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(m.cdbs[5], build_set_cdb(Feature::Encryption, STATE_OFF));
assert_eq!(m.states.unrestricted, STATE_ON);
assert_eq!(m.states.hrl, STATE_OFF);
assert_eq!(m.states.encryption, STATE_OFF);
}
#[test]
fn arm_bypass_bd_disables_encryption() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.arm_bypass_bd().expect("armed");
}
// Hrl=off (revocation skip) is set+verified FIRST, then Encryption=off.
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(m.cdbs[2], build_get_cdb(Feature::Hrl));
assert_eq!(m.cdbs[3], build_set_cdb(Feature::Encryption, STATE_OFF));
assert_eq!(m.cdbs[4], build_get_cdb(Feature::Encryption));
assert_eq!(m.states.hrl, STATE_OFF);
assert_eq!(m.states.encryption, STATE_OFF);
}
#[test]
fn arm_bypass_uhd_sets_uhd_hrl_encryption() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.arm_bypass_uhd().expect("armed");
}
// Recipe order: Unrestricted=on, Hrl=off, Encryption=off (each SET then verifying GET).
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Unrestricted, STATE_ON));
assert_eq!(m.cdbs[3], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(m.cdbs[5], build_set_cdb(Feature::Encryption, STATE_OFF));
assert_eq!(m.states.unrestricted, STATE_ON);
assert_eq!(m.states.hrl, STATE_OFF);
assert_eq!(m.states.encryption, STATE_OFF);
}
#[test]
fn arm_stealth_oem_resets_then_verifies_all_passthrough() {
let mut m = FwMock::new();
m.states.encryption = STATE_ON;
m.states.unrestricted = STATE_ON;
{
let mut fw = FirmwareControl::new(&mut m);
fw.arm_stealth_oem().expect("disarmed");
}
assert_eq!(m.cdbs[1], build_reset_cdb(RESET_TO_OEM));
// then a GET of every feature
assert_eq!(m.cdbs.len(), 2 + ALL_FEATURES.len());
assert!(m.cdbs[2..].iter().all(|c| c[CDB_VERB] == Verb::Get as u8));
assert_eq!(m.states, FeatureStates::all_passthrough());
}
/// A drive that ACKs RESET but stays armed must not be reported as OEM.
#[test]
fn arm_stealth_oem_reports_a_feature_that_did_not_reset() {
let mut m = FwMock::new();
m.states.encryption = STATE_OFF;
m.ignore_reset = true;
let mut fw = FirmwareControl::new(&mut m);
assert_eq!(
fw.arm_stealth_oem().unwrap_err(),
FirmwareError::VerifyFailed {
feature: Feature::Encryption,
wanted: STATE_PASSTHROUGH,
got: STATE_OFF,
}
);
}
#[test]
fn arm_by_recipe_enum_dispatches() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.arm(ArmRecipe::BypassBd).expect("armed");
}
assert_eq!(m.states.encryption, STATE_OFF);
}
/// Regression: every recipe refuses firmware below MIN_FW_VERSION (fw 0.8.x)
/// and a non-freemkv drive — before sending any SET/RESET.
#[test]
fn arm_refuses_unsupported_firmware_before_any_set() {
let recipes = [
ArmRecipe::OemBd,
ArmRecipe::OemUhd,
ArmRecipe::BypassBd,
ArmRecipe::BypassUhd,
ArmRecipe::StealthOem,
];
for recipe in recipes {
for (version, freemkv) in [("0.8.3", true), ("0.9.2", false)] {
let mut m = FwMock::new();
m.version = version;
m.is_freemkv = freemkv;
let r = FirmwareControl::new(&mut m).arm(recipe);
assert_eq!(
r,
Err(FirmwareError::UnsupportedFirmware),
"{recipe:?} on {version}/{freemkv}"
);
assert_eq!(m.verbs(), vec![Verb::Identity as u8], "{recipe:?}");
}
}
}
/// Released fw 0.7.1: same knock, `freemkv 0.7.1` reply, old sub-function
/// grammar (0x02 = Speed). Refused, with no SET sent.
#[test]
fn arm_refuses_legacy_07_firmware() {
let mut legacy = b"freemkv 0.7.1".to_vec();
legacy.resize(MEMREAD_LEN, 0);
let mut t = MockTransport::always(Reply::good(legacy));
let r = FirmwareControl::new(&mut t).arm(ArmRecipe::BypassBd);
assert_eq!(r, Err(FirmwareError::UnsupportedFirmware));
assert_eq!(t.calls(), 1);
}
#[test]
fn set_verify_reports_mismatch() {
// A drive that ACKs the SET but never actually changes the state: GET reads
// back the old value, so the verify fails.
struct StuckDrive;
impl ScsiTransport for StuckDrive {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> ScsiTResult<ScsiResult> {
let mut n = 0;
if cdb[CDB_VERB] == Verb::Identity as u8 {
let resp = identity_reply("0.9.2", [STATE_PASSTHROUGH; 6]);
data.copy_from_slice(&resp[..data.len()]);
n = data.len();
} else if cdb[CDB_VERB] == Verb::Get as u8 && !data.is_empty() {
data[0] = STATE_PASSTHROUGH; // never changes
n = 1;
}
Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
})
}
}
let mut d = StuckDrive;
let mut fw = FirmwareControl::new(&mut d);
let err = fw.arm_bypass_bd().unwrap_err();
// arm_bypass_bd now verifies Hrl=off FIRST, so the stuck drive trips on Hrl.
assert_eq!(
err,
FirmwareError::VerifyFailed {
feature: Feature::Hrl,
wanted: STATE_OFF,
got: STATE_PASSTHROUGH,
}
);
}
#[test]
fn recipe_transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
let mut fw = FirmwareControl::new(&mut t);
assert_eq!(fw.arm_oem_bd().unwrap_err(), FirmwareError::Transport);
}
/// A dead bus AFTER the IDENTITY gate (mid-recipe) still aborts.
#[test]
fn recipe_transport_fault_after_identity_propagates() {
let mut t = MockTransport::scripted(
vec![Reply::good(identity_reply("0.9.2", [0xFF; 6]))],
Reply::TransportFault,
);
let mut fw = FirmwareControl::new(&mut t);
assert_eq!(fw.arm_bypass_uhd().unwrap_err(), FirmwareError::Transport);
assert_eq!(t.calls(), 2);
}
+237
View File
@@ -0,0 +1,237 @@
//! Unlock drives that identify themselves as freemkv firmware.
//!
//! The vendor IDENTITY response must parse as a [`FirmwareIdentity`] on the
//! supported grammar ([`FirmwareIdentity::is_supported`]). Commands use the
//! builders in [`crate::firmware`], which mirror the firmware ABI.
//! Unlocking sets Region to free, Speed to max, Unrestricted on, and Encryption
//! to [`crate::firmware::STATE_OFF`].
//! Encryption-off disables the certificate/bus barrier; the retired Bus feature
//! must not be used as a substitute.
use crate::firmware::{
Feature, FirmwareIdentity, MEMREAD_LEN, MIN_ALLOC_LEN, REGION_FREE, SPEED_MAX, STATE_OFF,
STATE_ON, build_identity_cdb, build_memread_cdb, build_set_cdb,
};
use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus};
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
/// Allocation the IDENTITY / DUMPALL data-in phase reads back (a fixed 64-byte
/// window, matching [`MEMREAD_LEN`]).
const RESP_LEN: usize = MEMREAD_LEN;
// The freemkv custom-firmware unlocker. Detection: IDENTITY on a supported
// firmware version — 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 IDENTITY: Ok(true) only for freemkv firmware on the supported
// grammar; Ok(false) if rejected, not freemkv, or below MIN_FW_VERSION (older
// fw answers SETs with GOOD; unlock not validated). Dead bus: Err(Transport).
fn identify(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
let cdb = build_identity_cdb(RESP_LEN as u16);
let mut buf = [0u8; RESP_LEN];
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
Ok(r) => {
let n = r.bytes_transferred.min(buf.len());
let id = (r.status == 0)
.then(|| FirmwareIdentity::parse(&buf[..n]))
.flatten();
let supported = id.as_ref().is_some_and(FirmwareIdentity::is_supported);
if !supported {
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_identity_no_match",
version = id.as_ref().map(|i| i.version.as_str()),
"IDENTITY probe did not report supported freemkv firmware"
);
}
Ok(supported)
}
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 `Set(feature, state)` over the required data-in phase. Ok(()) on GOOD status;
// NotApplicable if rejected; Transport only on a dead bus.
fn set(
&self,
scsi: &mut dyn ScsiTransport,
feature: Feature,
state: u8,
) -> std::result::Result<(), UnlockError> {
let cdb = build_set_cdb(feature, state);
// SET needs the data-in phase its CDB advertises — a no-data SET is aborted
// (CHECK CONDITION, HW-confirmed on BU40N fw 0.8.1). Buffer sized from the SAME
// const the CDB advertises (MIN_ALLOC_LEN) so the transfer length has one source.
let mut buf = [0u8; MIN_ALLOC_LEN as usize];
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_set_rejected",
feature = ?feature,
state,
status = r.status,
"freemkv SET rejected by the drive"
);
Err(UnlockError::NotApplicable)
}
Err(e) => {
if is_dead_bus(&e) {
tracing::warn!(
target: "freemkv::disc",
phase = "freemkv_set_transport_fault",
feature = ?feature,
"transport fault on a freemkv SET; aborting"
);
return Err(UnlockError::Transport);
}
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_set_rejected_as_err",
feature = ?feature,
status = e.status,
"freemkv SET rejected (via Err)"
);
Err(UnlockError::NotApplicable)
}
}
}
/// DumpAll diagnostic RAM read (DUMPALL): 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: IDENTITY (hard gate) → Set(Region,free) →
// Set(Speed,max) → Set(Unrestricted,on) (best-effort) → Set(Encryption,off)
// (LOAD-BEARING — consolidated cert/bus bypass) → bare 0xAD VID (best-effort).
fn full_unlock(
&self,
scsi: &mut dyn ScsiTransport,
) -> std::result::Result<Unlocked, UnlockError> {
// IDENTITY: must be a freemkv drive.
if !self.identify(scsi)? {
return Err(UnlockError::NotApplicable);
}
// Best-effort feature set: 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(())
}
}
};
// Region-free + riplock lift (best-effort features).
best_effort(self.set(scsi, Feature::Region, REGION_FREE), "region")?;
best_effort(self.set(scsi, Feature::Speed, SPEED_MAX), "speed")?;
// ABI full-bypass recipe: engage BD/UHD discs rather than rely on the
// RAM defaults (RESET_TO_OEM or a saved config may leave it off).
best_effort(
self.set(scsi, Feature::Unrestricted, STATE_ON),
"unrestricted",
)?;
// Encryption = off (LOAD-BEARING): the consolidated cert/bus bypass —
// drive acts pre-authenticated (bare 0xAD returns the VID with no
// cert/AKE) AND content returns de-bussed. No fallback.
match self.set(scsi, Feature::Encryption, STATE_OFF) {
Ok(()) => {}
Err(UnlockError::Transport) => return Err(UnlockError::Transport),
Err(_) => {
tracing::debug!(
target: "freemkv::disc",
phase = "freemkv_disable_encryption_rejected",
"Set(Encryption, off) rejected — cannot unlock this drive"
);
return Err(UnlockError::VidUnavailable);
}
}
// Bare 0xAD VID read — the shared BEST-EFFORT reader (identical to the
// LD/Renesas routes). Encryption=off 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 (Encryption=off — consolidated cert/bus bypass)"
);
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 and open
/// Unrestricted (best-effort), set Encryption=off (the actual unlock), and
/// read the Volume ID with a bare `0xAD` (best-effort). `Some` when the
/// Encryption=off set succeeded — the drive is unlocked whether or not the
/// VID read did; `None` if it isn't supported freemkv firmware;
/// `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)]
#[path = "mod_tests.rs"]
mod tests;
+344
View File
@@ -0,0 +1,344 @@
use super::*;
use crate::DiscKind;
use crate::firmware::{REGION_FREE, STATE_OFF, build_identity_cdb, build_memread_cdb};
use crate::scsi::mock::{MockTransport, Reply};
use crate::scsi::{DataDirection, Result, ScsiResult, ScsiTransport};
fn ctx(id: &crate::DriveId) -> UnlockCtx<'_> {
UnlockCtx::new(id, DiscKind::Unknown)
}
/// The fw IDENTITY wire shape: `freemkv <ver>` (no NUL), the raw flag
/// table, then zero padding to the 64-byte window.
fn identity_payload(version: &str, flags: &[u8]) -> Vec<u8> {
let mut p = b"freemkv ".to_vec();
p.extend_from_slice(version.as_bytes());
p.extend_from_slice(flags);
p.resize(RESP_LEN, 0);
p
}
/// A fresh-boot fw 0.9.2 drive (DEFAULT_FLAGS: Unrestricted + Bd on).
fn freemkv_identity_payload() -> Vec<u8> {
identity_payload("0.9.2", &[0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF])
}
/// 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
}
fn unlock(t: &mut MockTransport) -> std::result::Result<Option<Unlocked>, UnlockError> {
let id = crate::DriveId::default();
FreemkvUnlocker::new().unlock(t, &ctx(&id))
}
/// Regression (BU40N fw 0.8.1): a SET issued WITHOUT the data-in phase its
/// CDB advertises is aborted by the drive (CHECK CONDITION / Aborted Command),
/// so the unlocker's `set()` MUST read the MIN_ALLOC_LEN table back like
/// `FirmwareControl::set` — not send `DataDirection::None`. Before the fix
/// the whole freemkv unlock failed at the first SET and fell back to AACS.
#[test]
fn set_issues_the_required_data_in_phase() {
struct DataInContract;
impl ScsiTransport for DataInContract {
fn execute(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
assert_eq!(cdb[4], 0x02, "test exercises only the SET verb");
// Model the drive: a SET with no from-device phase is aborted.
if !matches!(dir, DataDirection::FromDevice) || data.is_empty() {
return Ok(ScsiResult {
status: 2,
bytes_transferred: 0,
sense: [0u8; 32],
});
}
Ok(ScsiResult {
status: 0,
bytes_transferred: data.len(),
sense: [0u8; 32],
})
}
}
FreemkvUnlocker::new()
.set(&mut DataInContract, Feature::Region, REGION_FREE)
.expect("SET must succeed by issuing the data-in phase the firmware requires");
}
// ── Detection ────────────────────────────────────────────────────────
#[test]
fn identify_true_on_supported_firmware_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_identity_cdb(RESP_LEN as u16));
}
#[test]
fn identify_false_on_non_matching_payload() {
let mut t = MockTransport::always(Reply::good(vec![0u8; 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"));
}
/// The status guard decides: a CHECK CONDITION that nonetheless reports a
/// full magic-bearing transfer (residual/echoed buffer) is not freemkv.
#[test]
fn identify_false_on_check_condition_with_a_magic_buffer() {
struct CheckConditionWithData;
impl ScsiTransport for CheckConditionWithData {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
let p = freemkv_identity_payload();
data.copy_from_slice(&p[..data.len()]);
Ok(ScsiResult {
status: 2,
bytes_transferred: data.len(),
sense: [0u8; 32],
})
}
}
assert!(
!FreemkvUnlocker::new()
.identify(&mut CheckConditionWithData)
.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
);
}
/// Regression: released fw 0.7.x (`freemkv 0.7.1` reply, old sub-function
/// grammar where 0x02 = Speed) answers GOOD to every SET. The unlocker must
/// decline it, not claim a drive it never unlocked.
#[test]
fn declines_legacy_07_firmware_without_sending_a_set() {
let mut t = MockTransport::always(Reply::good(identity_payload("0.7.1", &[])));
assert!(unlock(&mut t).expect("no fault").is_none());
assert_eq!(t.calls(), 1, "only the IDENTITY probe");
}
/// Regression: fw 0.8.x (0x06 = `Ake`, separate `Bus`): de-bus via 0x06
/// alone is not validated (hardware-dependent), so decline.
#[test]
fn declines_08_firmware_without_sending_a_set() {
let flags = [0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF, 0xFF];
let mut t = MockTransport::always(Reply::good(identity_payload("0.8.3", &flags)));
assert!(unlock(&mut t).expect("no fault").is_none());
assert_eq!(t.calls(), 1, "only the IDENTITY probe");
}
// ── Feature sets ──────────────────────────────────────────────────────
#[test]
fn set_drive_rejection_is_not_applicable() {
for reply in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
let mut t = MockTransport::always(reply.clone());
let err = FreemkvUnlocker::new()
.set(&mut t, Feature::Encryption, STATE_OFF)
.unwrap_err();
assert_eq!(err, UnlockError::NotApplicable, "{reply:?}");
}
}
#[test]
fn set_transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
let err = FreemkvUnlocker::new()
.set(&mut t, Feature::Speed, SPEED_MAX)
.unwrap_err();
assert_eq!(err, UnlockError::Transport);
}
// ── full_unlock / unlock ─────────────────────────────────────────────
/// The full unlock runs IDENTITY → Set(Region) → Set(Speed) →
/// Set(Unrestricted) → Set(Encryption) → bare VID, with the exact wire
/// bytes for every vendor SET.
#[test]
fn full_unlock_issues_identity_region_speed_unrestricted_encryption_then_bare_vid() {
let vid = [0x7Cu8; 16];
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()), // IDENTITY
Reply::good(vec![]), // Set Region
Reply::good(vec![]), // Set Speed
Reply::good(vec![]), // Set Unrestricted
Reply::good(vec![]), // Set Encryption (load-bearing)
Reply::good(vid_ds_response(vid)), // 0xAD bare VID
],
Reply::TransportFault,
);
let out = unlock(&mut t)
.expect("no fault")
.expect("encryption off ⇒ unlocked");
assert_eq!(out.vid, Some(vid));
assert_eq!(out.bus_key, None);
assert_eq!(t.cdbs.len(), 6);
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
let set = |f: u8, s: u8| vec![0x3C, 0x0E, 0xC0, 0xDE, 0x02, f, s, 0x00, 0x40, 0x00];
assert_eq!(t.cdbs[1], set(0x02, 0x0F), "Region = free");
assert_eq!(t.cdbs[2], set(0x01, 0x00), "Speed = max");
assert_eq!(t.cdbs[3], set(0x03, 0x01), "Unrestricted = on");
assert_eq!(t.cdbs[4], set(0x06, 0x00), "Encryption = off");
assert_eq!(t.cdbs[5][0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
}
/// Missing region/speed/unrestricted features do not fail the unlock
/// (best-effort), whichever rejection shape the transport uses.
#[test]
fn full_unlock_tolerates_missing_best_effort_features() {
let vid = [0x3Cu8; 16];
for reject in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()),
reject.clone(), // region
reject.clone(), // speed
reject.clone(), // unrestricted
Reply::good(vec![]),
Reply::good(vid_ds_response(vid)),
],
Reply::TransportFault,
);
let out = unlock(&mut t).expect("no fault").expect("unlocked");
assert_eq!(out.vid, Some(vid), "{reject:?}");
}
}
/// Set(Encryption, off) is LOAD-BEARING: if the drive rejects it (either
/// shape), `unlock()` declines (`None`) and falls through.
#[test]
fn declines_when_disable_encryption_rejected() {
for reject in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()),
Reply::good(vec![]),
Reply::good(vec![]),
Reply::good(vec![]),
reject.clone(), // Set(Encryption,off) REJECTED
],
Reply::TransportFault,
);
assert!(unlock(&mut t).expect("no fault").is_none(), "{reject:?}");
assert_eq!(t.calls(), 5, "no VID read after the refusal");
}
}
/// A dead bus at ANY step after IDENTITY aborts with `Transport` (never a
/// fall-through): each best-effort SET, the load-bearing SET, the VID read.
#[test]
fn dead_bus_after_identity_aborts_at_every_step() {
for good_before_fault in 0..5 {
let mut script = vec![Reply::good(freemkv_identity_payload())];
script.extend(std::iter::repeat_n(Reply::good(vec![]), good_before_fault));
let mut t = MockTransport::scripted(script, Reply::TransportFault);
assert_eq!(
unlock(&mut t).unwrap_err(),
UnlockError::Transport,
"fault after {good_before_fault} SETs"
);
assert_eq!(t.calls(), good_before_fault + 2, "aborted at the fault");
}
}
/// Best-effort VID: Set(Encryption,off) succeeded (drive unlocked), but the
/// bare VID read was rejected — `unlock()` still returns `Some`, with
/// `vid: None`.
#[test]
fn unlocks_without_vid_when_bare_read_fails() {
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()),
Reply::good(vec![]),
Reply::good(vec![]),
Reply::good(vec![]),
Reply::good(vec![]), // Set(Encryption,off)
Reply::illegal_request(), // bare VID: no medium
],
Reply::TransportFault,
);
let out = unlock(&mut t)
.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());
assert!(unlock(&mut t).expect("no fault").is_none());
}
#[test]
fn transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
assert_eq!(unlock(&mut t).unwrap_err(), UnlockError::Transport);
}
// ── DumpAll ──────────────────────────────────────────────────────────
#[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], build_memread_cdb(0x01F8_1234).to_vec());
}
/// A GOOD-status short DUMPALL is not a zero-padded RAM window.
#[test]
fn dump_ram_rejects_a_short_transfer() {
let mut t = MockTransport::always(Reply::short(vec![0xAB; MEMREAD_LEN], MEMREAD_LEN - 1));
assert_eq!(
FreemkvUnlocker::new().dump_ram(&mut t, 0).unwrap_err(),
UnlockError::NotApplicable
);
}
#[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/ .
+10 -6
View File
@@ -1,6 +1,6 @@
# ld — the LibreDrive unlocker
# ld — the drive bus-unlock module
The **LibreDrive** unlocker module for freemkv-unlock.
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
@@ -20,8 +20,12 @@ sets up lives in drive RAM and is gone on power cycle. The one-time, permanent
drive preparation is the drive owner's own manual step and is **never**
automated here.
## Credits
## Third-party microcode (`profiles.json`)
LibreDrive was created by **Mike Chen** and the **MakeMKV team**. This module
builds on their work — our thanks and full credit to them for the LibreDrive
capabilities.
`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/).
+4 -32
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 /
@@ -25,33 +25,5 @@ pub fn is_unlock_read_buffer(mode: u8, buf_id: u8) -> bool {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn marker_is_four_bytes() {
assert_eq!(UNLOCK_MARKER, b"MMkv");
assert_eq!(UNLOCK_MARKER.len(), 4);
}
#[test]
fn unlock_variants_match() {
// Variant A and variant B.
assert!(is_unlock_read_buffer(1, 0x44));
assert!(is_unlock_read_buffer(2, 0x77));
}
#[test]
fn non_unlock_cdbs_do_not_match() {
// Right mode, wrong buffer id.
assert!(!is_unlock_read_buffer(1, 0x77));
assert!(!is_unlock_read_buffer(2, 0x44));
// Wrong mode, right buffer id.
assert!(!is_unlock_read_buffer(0, 0x77));
assert!(!is_unlock_read_buffer(0, 0x44));
assert!(!is_unlock_read_buffer(3, 0x77));
// Ordinary data reads.
assert!(!is_unlock_read_buffer(2, 0x00));
assert!(!is_unlock_read_buffer(0, 0x00));
}
}
#[path = "cdb_tests.rs"]
mod tests;
+28
View File
@@ -0,0 +1,28 @@
use super::*;
#[test]
fn marker_is_four_bytes() {
assert_eq!(UNLOCK_MARKER, b"MMkv");
assert_eq!(UNLOCK_MARKER.len(), 4);
}
#[test]
fn unlock_variants_match() {
// Variant A and variant B.
assert!(is_unlock_read_buffer(1, 0x44));
assert!(is_unlock_read_buffer(2, 0x77));
}
#[test]
fn non_unlock_cdbs_do_not_match() {
// Right mode, wrong buffer id.
assert!(!is_unlock_read_buffer(1, 0x77));
assert!(!is_unlock_read_buffer(2, 0x44));
// Wrong mode, right buffer id.
assert!(!is_unlock_read_buffer(0, 0x77));
assert!(!is_unlock_read_buffer(0, 0x44));
assert!(!is_unlock_read_buffer(3, 0x77));
// Ordinary data reads.
assert!(!is_unlock_read_buffer(2, 0x00));
assert!(!is_unlock_read_buffer(0, 0x00));
}
+15
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,7 @@ impl From<Error> for crate::UnlockError {
}
}
}
#[cfg(test)]
#[path = "error_tests.rs"]
mod tests;
+113
View File
@@ -0,0 +1,113 @@
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]);
}
+91 -206
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.
#[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.
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.
fn firmware_unlock(
&self,
scsi: &mut dyn ScsiTransport,
@@ -141,179 +138,67 @@ 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.
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.
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))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DiscKind;
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
/// 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, &[])
}
/// A fake transport that fills the response buffer from a fixed payload and
/// reports a configurable transferred-byte count.
struct FakeTransport {
payload: Vec<u8>,
bytes_transferred: usize,
}
impl ScsiTransport for FakeTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
let n = self.payload.len().min(data.len());
data[..n].copy_from_slice(&self.payload[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: self.bytes_transferred,
sense: [0u8; 32],
})
}
}
/// A DriveId for the bundled HL-DT-ST profile that carries a real
/// `read_vid_cdb`, so `read_oem_vid` finds a profile and issues the CDB.
fn known_vid_drive_id() -> DriveId {
make_drive_id("HL-DT-ST", "1.01", "NM00100", "211711202000")
}
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
DriveId {
vendor_id: vendor.to_string(),
product_id: String::new(),
product_revision: rev.to_string(),
vendor_specific: vs.to_string(),
firmware_date: date.to_string(),
}
}
/// 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 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"
);
let mut payload = vec![0u8; 36];
payload[0..3].copy_from_slice(&[0x00, 0x22, 0x00]);
let vid = [0x3Cu8; 16];
payload[4..20].copy_from_slice(&vid);
let mut t = FakeTransport {
payload,
bytes_transferred: 36,
};
let got = LibreDrive::new()
.read_oem_vid(&mut t, &known_vid_drive_id())
.expect("parse ok");
assert_eq!(got, Some(vid), "VID parsed from [4..20]");
}
/// A short response → `Ok(None)` (drive unlocked, just no readable VID).
#[test]
fn read_oem_vid_short_response_is_none() {
let mut t = FakeTransport {
payload: vec![0u8; 36],
bytes_transferred: 20,
};
let got = LibreDrive::new()
.read_oem_vid(&mut t, &known_vid_drive_id())
.expect("short response is Ok(None)");
assert_eq!(got, None);
}
/// A response whose 3-byte signature isn't `00 22 00` → `Ok(None)`.
#[test]
fn read_oem_vid_bad_header_is_none() {
let mut payload = vec![0u8; 36];
payload[0..3].copy_from_slice(&[0xDE, 0xAD, 0xBE]);
let mut t = FakeTransport {
payload,
bytes_transferred: 36,
};
let got = LibreDrive::new()
.read_oem_vid(&mut t, &known_vid_drive_id())
.expect("bad header is Ok(None)");
assert_eq!(got, None);
}
/// A drive with no matching profile → `read_oem_vid` is `Ok(None)`.
#[test]
fn read_oem_vid_no_profile_is_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)");
assert_eq!(got, None);
}
/// `unlock_features` on a drive with no matching profile → `NotApplicable`
/// (fall through), short-circuiting before any firmware handshake.
#[test]
fn unlock_no_profile_is_not_applicable() {
let mut t = FakeTransport {
payload: vec![0u8; 36],
bytes_transferred: 36,
};
let err = LibreDrive::new()
.unlock_features(
&mut t,
&ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")),
)
.expect_err("no profile → NotApplicable");
assert_eq!(err, UnlockError::NotApplicable);
}
}
#[path = "mod_tests.rs"]
mod tests;
+429
View File
@@ -0,0 +1,429 @@
use super::*;
use crate::DiscKind;
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
/// 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)
}
/// A fake transport that fills the response buffer from a fixed payload and
/// reports a configurable transferred-byte count.
struct FakeTransport {
payload: Vec<u8>,
bytes_transferred: usize,
}
impl ScsiTransport for FakeTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
let n = self.payload.len().min(data.len());
data[..n].copy_from_slice(&self.payload[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: self.bytes_transferred,
sense: [0u8; 32],
})
}
}
/// A DriveId for the bundled HL-DT-ST profile that carries a real
/// `read_vid_cdb`, so `read_oem_vid` finds a profile and issues the CDB.
fn known_vid_drive_id() -> DriveId {
make_drive_id("HL-DT-ST", "1.01", "NM00100", "211711202000")
}
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
DriveId {
vendor_id: vendor.to_string(),
product_id: String::new(),
product_revision: rev.to_string(),
vendor_specific: vs.to_string(),
firmware_date: date.to_string(),
}
}
/// 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];
payload[4..20].copy_from_slice(&vid);
let mut t = FakeTransport {
payload,
bytes_transferred: 36,
};
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]");
}
/// A short response → `Ok(None)` (drive unlocked, just no readable VID).
#[test]
fn read_oem_vid_short_response_is_none() {
let mut t = FakeTransport {
payload: vec![0u8; 36],
bytes_transferred: 20,
};
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &known_vid_profile())
.expect("short response is Ok(None)");
assert_eq!(got, None);
}
/// A response whose 3-byte signature isn't `00 22 00` → `Ok(None)`.
#[test]
fn read_oem_vid_bad_header_is_none() {
let mut payload = vec![0u8; 36];
payload[0..3].copy_from_slice(&[0xDE, 0xAD, 0xBE]);
let mut t = FakeTransport {
payload,
bytes_transferred: 36,
};
let got = LdUnlocker::new()
.read_oem_vid(&mut t, &known_vid_profile())
.expect("bad header is Ok(None)");
assert_eq!(got, 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_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 = 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]
fn unlock_no_profile_is_not_applicable() {
let mut t = FakeTransport {
payload: vec![0u8; 36],
bytes_transferred: 36,
};
let unlocked = LdUnlocker::new()
.unlock(
&mut t,
&ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")),
)
.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.
#[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.
// Review of ST-D10: after a confirmed firmware upload, a drive whose media never
// becomes ready (the same 02/04/01 for 60 s, T6) must still report the unlock,
// and probe_disc + the VID read must still run (not `NotApplicable`).
#[test]
fn readiness_stall_after_upload_keeps_the_unlock() {
use crate::scsi::{DataDirection, ScsiResult};
use platform::mt1959::tests::{FakeClock, TEST_READY_CLOCK};
struct UploadThenNotReady {
sig: [u8; 4],
unlock_calls: usize,
cdbs: Vec<Vec<u8>>,
}
impl ScsiTransport for UploadThenNotReady {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
self.cdbs.push(cdb.to_vec());
let unlock = cdb[0] == 0x3C && matches!(cdb[2], 0x44 | 0x77) && cdb[3] == 0;
let (status, n) = if cdb[0] == 0x00 {
let mut sense = [0u8; 32];
(sense[2], sense[12], sense[13]) = (0x02, 0x04, 0x01);
return Ok(ScsiResult {
status: 0x02,
bytes_transferred: 0,
sense,
});
} else if unlock && cdb[8] == 64 {
self.unlock_calls += 1;
data.fill(0);
data[0..4].copy_from_slice(&self.sig);
data[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]);
data[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]);
// The first unlock fails short, forcing the firmware upload.
(0, if self.unlock_calls == 1 { 10 } else { 64 })
} else {
data.fill(0);
(0, data.len())
};
Ok(ScsiResult {
status,
bytes_transferred: n,
sense: [0u8; 32],
})
}
}
let id = known_vid_drive_id();
let sig = profile::find_bundled(&id)
.expect("profile")
.profile
.signature;
let mut t = UploadThenNotReady {
sig,
unlock_calls: 0,
cdbs: Vec::new(),
};
TEST_READY_CLOCK.set(Some(|| Box::new(FakeClock::new())));
let r = LdUnlocker::new().unlock(&mut t, &ctx(&id));
TEST_READY_CLOCK.set(None);
let unlocked = r.expect("no fault");
assert!(
unlocked.is_some(),
"the confirmed unlock stands despite the stall"
);
let uploaded = t.cdbs.iter().any(|c| matches!(c[0], 0x3B | 0x55));
assert!(uploaded, "the firmware upload ran");
let last_tur = t
.cdbs
.iter()
.rposition(|c| c[0] == 0x00)
.expect("polled TUR");
let turs = t.cdbs.iter().filter(|c| c[0] == 0x00).count();
assert_eq!(turs, 121, "polled until the 60 s stall (virtual clock)");
let capacity = t
.cdbs
.iter()
.rposition(|c| c[0] == 0x25)
.expect("probe_disc ran");
assert!(capacity > last_tur, "probe_disc runs after the poll");
}
#[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);
}
+9 -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,15 @@ 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)]
#[path = "mod_tests.rs"]
mod tests;
+32
View File
@@ -0,0 +1,32 @@
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());
}
+402 -286
View File
@@ -6,7 +6,8 @@ mod variant_b;
use super::PlatformDriver;
use crate::ld::error::{Error, Result};
use crate::ld::profile::DriveProfile;
use crate::scsi::{self, DataDirection, ScsiTransport};
use crate::scsi::{self, DataDirection, ScsiSense, ScsiTransport};
use std::time::Duration;
// ── Variant constants ──────────────────────────────────────────────────
// Every vendor command: 3C [mode] [buffer_id] [sub_cmd] [addr] ...
@@ -18,6 +19,7 @@ const BUFFER_ID_B: u8 = 0x77;
// ── SCSI opcodes ──────────────────────────────────────────────────────
const SCSI_READ_BUFFER: u8 = 0x3C;
const SCSI_READ_CAPACITY: u8 = 0x25;
const SCSI_TEST_UNIT_READY: u8 = 0x00;
/// Shared by both firmware-upload variants (see `variant_a` / `variant_b`).
pub(super) const SCSI_WRITE_BUFFER: u8 = 0x3B;
@@ -37,6 +39,23 @@ const FIRMWARE_ACTIVE_SIG: [u8; 4] = [0x4D, 0x4D, 0x6B, 0x76];
/// response, not a stale image's residual buffer.
const FIRMWARE_MODE_OFFSET: usize = 16;
const FIRMWARE_MODE_SIG: [u8; 4] = [0x4C, 0x62, 0x44, 0x72];
/// Fewest bytes an unlock response must carry before ANY of its three checks
/// mean anything — through the secondary marker at [16..20].
const MIN_UNLOCK_RESPONSE: usize = FIRMWARE_MODE_OFFSET + 4;
// ── Post-upload readiness poll (T6, stop-design-v5 §2.11 / §3.1) ──────
/// Between TEST UNIT READY polls; libfreemkv `wait_ready` polls at 500 ms too.
const READY_POLL: Duration = Duration::from_millis(500);
/// T6: fail only after this long with no progress in the not-ready answers.
const READY_STALL: Duration = Duration::from_secs(60);
const TUR_TIMEOUT_MS: u32 = 5_000;
/// Consecutive 02/3A answers, with no 02/04/01 seen, that mean an empty drive
/// (~5 s); libfreemkv `wait_ready`'s `WAIT_READY_MAX_EMPTY_POLLS`.
const READY_MAX_EMPTY_POLLS: u32 = 10;
/// START STOP UNIT (1Bh), LoEj 0 / Start 1 (MMC-6 Table 631, byte 4 bit 0).
const START_UNIT: [u8; 6] = [0x1B, 0x00, 0x00, 0x00, 0x01, 0x00];
/// libfreemkv's START UNIT timeout (T4).
const START_UNIT_TIMEOUT_MS: u32 = 30_000;
// ── Init address (per disc type) ──────────────────────────────────────
const INIT_ADDR_BD: u16 = 0x0100;
@@ -114,20 +133,22 @@ impl Mt1959 {
expected: usize,
) -> Result<usize> {
// The READ_BUFFER CDB transfer-length is a single byte; an
// `expected` above 255 cannot be expressed and would silently
// truncate. All in-crate callers pass small fixed sizes (4); guard
// the invariant rather than emit a malformed CDB.
// `expected` above 255 would silently truncate. Guard the
// invariant rather than emit a malformed CDB.
debug_assert!(
expected <= u8::MAX as usize,
"read_buffer_probe expected exceeds 1-byte CDB length field"
);
let cdb = self.read_buffer_sub(sub_cmd, address, expected as u8);
let result = scsi.execute(&cdb, DataDirection::FromDevice, buf, 5_000)?;
if result.bytes_transferred != expected {
// A drive sense arrives as `Ok` with a non-zero status; a merely
// SHORT response is the drive answering badly — neither is a dead
// bus.
if result.status != 0 || result.bytes_transferred != expected {
return Err(Error::Scsi {
opcode: SCSI_READ_BUFFER,
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
status: result.status,
sense: Some(result.sense),
});
}
Ok(result.bytes_transferred)
@@ -158,38 +179,51 @@ impl Mt1959 {
let mut response = vec![0u8; UNLOCK_RESPONSE_SIZE as usize];
let result = scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
// `response` is a fixed 64-byte buffer, so `response.len()` is
// always >= every offset below — the meaningful bound is how many
// bytes the drive actually delivered. Validate against
// `bytes_transferred` so a short/partial transfer (stale trailing
// zeros) can't be read as if the drive sent real marker bytes.
let n = result.bytes_transferred.min(response.len());
// A drive sense arrives as `Ok` with a non-zero status; treating it as
// a successful unlock would validate the caller's own zero fill.
if result.status != 0 {
tracing::debug!(
target: "freemkv::disc",
phase = "mt1959_unlock_check_condition",
status = result.status,
"unlock READ_BUFFER returned a drive sense"
);
return Err(Error::Scsi {
opcode: SCSI_READ_BUFFER,
status: result.status,
sense: Some(result.sense),
});
}
if n >= 4 && response[0..4] != self.profile.signature {
// `response` is a fixed 64-byte buffer; the meaningful bound is how
// many bytes the drive actually delivered. Require enough bytes to
// validate before checking markers, up front.
let n = result.bytes_transferred.min(response.len());
if n < MIN_UNLOCK_RESPONSE {
tracing::debug!(
target: "freemkv::disc",
phase = "mt1959_unlock_short_response",
bytes_transferred = result.bytes_transferred,
"unlock response too short to validate"
);
return Err(Error::UnlockFailed);
}
if response[0..4] != self.profile.signature {
return Err(Error::SignatureMismatch {
expected: self.profile.signature,
got: response[0..4].try_into().unwrap_or([0; 4]),
});
}
if n >= FIRMWARE_ACTIVE_OFFSET + 4
&& response[FIRMWARE_ACTIVE_OFFSET..FIRMWARE_ACTIVE_OFFSET + 4] != FIRMWARE_ACTIVE_SIG
{
if response[FIRMWARE_ACTIVE_OFFSET..FIRMWARE_ACTIVE_OFFSET + 4] != FIRMWARE_ACTIVE_SIG {
return Err(Error::UnlockFailed);
}
// Extended-access state is active when BOTH the per-drive
// signature matched AND the response carries the secondary
// marker at offset 16 (repeated through bytes 16..64) AND the
// primary mode marker at [12..16] is present. The active-mode
// marker at [12..16] is the primary gate; the [16..20] marker
// is the redundant confirmation the firmware writes through
// the rest of the response. Requiring both before we tell the
// upper layer "OEM path is live" keeps any partial / corrupted
// response from steering us off the cert-auth fallback.
self.unlocked = n >= FIRMWARE_MODE_OFFSET + 4
&& response[FIRMWARE_ACTIVE_OFFSET..FIRMWARE_ACTIVE_OFFSET + 4] == FIRMWARE_ACTIVE_SIG
&& response[FIRMWARE_MODE_OFFSET..FIRMWARE_MODE_OFFSET + 4] == FIRMWARE_MODE_SIG;
// Extended-access state requires the signature match AND both the
// primary marker at [12..16] AND the secondary marker at [16..20].
self.unlocked =
response[FIRMWARE_MODE_OFFSET..FIRMWARE_MODE_OFFSET + 4] == FIRMWARE_MODE_SIG;
self.init_complete = true;
Ok(response)
@@ -210,52 +244,132 @@ impl Mt1959 {
0x00,
];
let mut resp = [0u8; 4];
if scsi
.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000)
.is_ok()
{
return Ok(());
match scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000) {
// A drive sense arrives as `Ok`; only a GOOD status is a pass.
Ok(r) if r.status == 0 => return Ok(()),
Ok(r) => {
tracing::debug!(
target: "freemkv::disc",
phase = "mt1959_validate_check_condition",
status = r.status,
"validate READ_BUFFER returned a drive sense"
);
}
// A dead bus will not recover across five more retries, and
// labelling every other failure "transport" made the consumer
// abort rips it could have completed. Propagate the real fault.
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
phase = "mt1959_validate_transport_fault",
"transport fault during validate; aborting"
);
return Err(Error::from(e));
}
}
}
Err(Error::Scsi {
opcode: SCSI_READ_BUFFER,
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
})
Err(Error::UnlockFailed)
}
// ── Init (unlock + firmware) ───────────────────────────────────────
fn run_init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
let mut succeeded = false;
for _attempt in 0..3 {
let mut last_err = Error::UnlockFailed;
for attempt in 0..3 {
match self.do_unlock(scsi) {
Ok(_) => {
succeeded = true;
break;
tracing::debug!(
target: "freemkv::disc",
phase = "mt1959_unlock_ok",
attempt,
unlocked = self.unlocked,
"MT1959 unlock handshake completed"
);
return Ok(());
}
Err(Error::SignatureMismatch { .. }) => {
tracing::debug!(
target: "freemkv::disc",
phase = "mt1959_signature_mismatch",
attempt,
"unlock response carried another drive's signature"
);
return Err(Error::UnlockFailed);
}
Err(_) => {
// A dead bus must abort here, not be retried three times as
// firmware reloads.
Err(e) if e.is_transport_failure() => {
tracing::warn!(
target: "freemkv::disc",
phase = "mt1959_transport_fault",
attempt,
"transport fault during unlock; aborting"
);
return Err(e);
}
// Every exit below returns or overwrites `last_err` with the reload's error.
Err(e) => {
tracing::debug!(
target: "freemkv::disc",
phase = "mt1959_unlock_failed_reloading",
attempt,
error = %e,
"unlock failed; reloading firmware"
);
let loaded = if self.mode == MODE_A {
variant_a::load_firmware(self, scsi).is_ok()
variant_a::load_firmware(self, scsi)
} else {
variant_b::load_firmware(self, scsi).is_ok()
variant_b::load_firmware(self, scsi)
};
if !loaded {
continue;
match loaded {
// Same rule on the upload path: a dead bus aborts.
Err(e) if e.is_transport_failure() => {
tracing::warn!(
target: "freemkv::disc",
phase = "mt1959_transport_fault",
attempt,
"transport fault during firmware upload; aborting"
);
return Err(e);
}
Err(e) => {
tracing::debug!(
target: "freemkv::disc",
phase = "mt1959_firmware_upload_failed",
attempt,
error = %e,
"firmware upload failed; retrying unlock"
);
last_err = e;
continue;
}
// D10 (stop-design-v5 §1): "`run_init` must `return Ok(())` as soon as
// `load_firmware` confirms the unlock" — its own do_unlock already did.
// Looping re-ran it after a 10 s settle and discarded it on attempt 2.
Ok(()) => {
tracing::debug!(
target: "freemkv::disc",
phase = "mt1959_unlock_ok_after_upload",
attempt,
unlocked = self.unlocked,
"MT1959 unlock confirmed by the firmware upload"
);
// The upload reset the drive; the unlock proves only the vendor
// path answers. Hand it back once the media is ready (T6). Any
// readiness outcome keeps the unlock; only a dead bus / Stop fails.
await_media_ready(scsi, &mut *ready_clock())?;
return Ok(());
}
}
// Firmware upload resets the drive. Give it time to
// fully recover before retrying unlock.
std::thread::sleep(std::time::Duration::from_secs(10));
}
}
}
if !succeeded {
return Err(Error::UnlockFailed);
}
Ok(())
tracing::warn!(
target: "freemkv::disc",
phase = "mt1959_unlock_exhausted",
"MT1959 unlock did not succeed after 3 attempts"
);
Err(last_err)
}
// ── Probe disc ─────────────────────────────────────────────────────
@@ -268,10 +382,8 @@ impl Mt1959 {
self.do_unlock(scsi)?;
}
// Detect disc type from capacity to select probe mode.
// BD: 3C 01 44 12 01 00 00 00 04 00 (init_addr = 0x0100)
// UHD: 3C 01 44 12 02 00 00 00 04 00 (init_addr = 0x0200)
// Empirically verified via SCSI capture: BD and UHD use different init addresses.
// Detect disc type from capacity to select probe mode (BD vs UHD
// init address).
let cap_cdb = [
SCSI_READ_CAPACITY,
0x00,
@@ -285,14 +397,12 @@ impl Mt1959 {
0x00,
];
let mut cap_buf = [0u8; READ_CAPACITY_RESPONSE_SIZE];
let disc_sectors = if scsi
.execute(&cap_cdb, DataDirection::FromDevice, &mut cap_buf, 5_000)
.is_ok()
{
// last_lba + 1 = sector count. A 0xFFFFFFFF last-LBA is the
// READ CAPACITY(10) "capacity exceeds 32 bits" sentinel; saturate
// rather than wrap to 0 (which would misclassify a huge disc as
// BD). A saturated count stays above the UHD threshold -> UHD.
// A transport fault here is a dead bus: propagate it. A drive
// sense or short reply just means "capacity unknown" -> assume BD.
let cap = scsi.execute(&cap_cdb, DataDirection::FromDevice, &mut cap_buf, 5_000)?;
let disc_sectors = if cap.status == 0 && cap.bytes_transferred >= 4 {
// last_lba + 1 = sector count; saturate on the 32-bit-overflow
// sentinel rather than wrap to 0.
u32::from_be_bytes([cap_buf[0], cap_buf[1], cap_buf[2], cap_buf[3]]).saturating_add(1)
} else {
0
@@ -317,7 +427,7 @@ impl Mt1959 {
let mut addr: u16 = 0;
while addr < PROBE_COARSE_END {
let mut resp = [0u8; PROBE_RESPONSE_SIZE as usize];
if self
if let Err(e) = self
.read_buffer_probe(
scsi,
SUB_CMD_PROBE,
@@ -325,31 +435,57 @@ impl Mt1959 {
&mut resp,
PROBE_RESPONSE_SIZE as usize,
)
.is_err()
.inspect_err(|e| {
tracing::debug!(
target: "freemkv::disc",
phase = "mt1959_probe_failed",
addr,
error = %e,
"coarse speed probe failed"
);
})
{
return Err(Error::Scsi {
opcode: SCSI_READ_BUFFER,
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
// A dead bus MUST keep its transport classification; a
// short/rejected reply is the drive's own failure and stays
// `UnlockFailed`.
if e.is_transport_failure() {
return Err(e);
}
return Err(Error::UnlockFailed);
}
addr = addr.wrapping_add(PROBE_STEP);
}
// Pass 2: fine scan
let mut addr: u32 = 0;
// Pass 2: continue past the coarse range (do not restart at 0).
let mut addr: u32 = PROBE_COARSE_END as u32;
while addr < PROBE_FINE_END {
let mut resp = [0u8; PROBE_RESPONSE_SIZE as usize];
if self
.read_buffer_probe(
scsi,
SUB_CMD_PROBE,
addr as u16,
&mut resp,
PROBE_RESPONSE_SIZE as usize,
)
.is_err()
{
if let Err(e) = self.read_buffer_probe(
scsi,
SUB_CMD_PROBE,
addr as u16,
&mut resp,
PROBE_RESPONSE_SIZE as usize,
) {
// Fine probing is best-effort — a short/rejected reply just ends
// the sweep early, but a transport fault must keep its
// classification and abort.
if e.is_transport_failure() {
tracing::warn!(
target: "freemkv::disc",
phase = "mt1959_probe_transport_fault",
addr,
"transport fault during fine speed probe; aborting"
);
return Err(e);
}
tracing::debug!(
target: "freemkv::disc",
phase = "mt1959_probe_failed",
addr,
error = %e,
"fine speed probe ended early"
);
break;
}
addr += PROBE_STEP as u32;
@@ -363,6 +499,184 @@ impl Mt1959 {
}
}
/// Time and waiting for [`await_media_ready`]: a seam so tests run the 60 s
/// stall window on a virtual clock.
pub(crate) trait ReadyClock {
/// Time since the poll began.
fn elapsed(&self) -> Duration;
/// Wait `d` before the next poll.
fn wait(&mut self, scsi: &mut dyn ScsiTransport, d: Duration) -> scsi::Result<()>;
}
/// The poll's clock: [`WallClock`], or a test's virtual clock (a per-thread seam,
/// so an [`crate::LdUnlocker`] test can stall the poll without waiting 60 s).
fn ready_clock() -> Box<dyn ReadyClock> {
#[cfg(test)]
if let Some(make) = tests::TEST_READY_CLOCK.get() {
return make();
}
Box::new(WallClock::start())
}
/// The production clock: real time, and the transport's cancellable `pause`
/// between polls (stop-design-v5 §2.3), so a Stop ends the wait at once.
struct WallClock(std::time::Instant);
impl WallClock {
fn start() -> Self {
WallClock(std::time::Instant::now())
}
}
impl ReadyClock for WallClock {
fn elapsed(&self) -> Duration {
self.0.elapsed()
}
fn wait(&mut self, scsi: &mut dyn ScsiTransport, d: Duration) -> scsi::Result<()> {
scsi.pause(d)
}
}
/// How the post-upload readiness poll ended. Every outcome keeps the unlock; the
/// non-`Ready` ones are logged at warn and left to the caller's next command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MediaReady {
Ready,
/// 10 consecutive 02/3A/xx with no 02/04/01 first: an empty drive.
NoMedium,
/// 02/30/xx: a medium that will never become ready.
Incompatible {
asc: u8,
ascq: u8,
},
/// Sense key 3, 4 or 5: not a readiness answer.
DriveError {
sense_key: u8,
asc: u8,
ascq: u8,
},
/// T6: no progress for [`READY_STALL`]; carries the last answer.
Stalled {
sense_key: u8,
asc: u8,
ascq: u8,
},
}
/// Poll TEST UNIT READY until the drive answers ready, following libfreemkv
/// `wait_ready`'s terminal states but always keeping the unlock (it succeeded).
/// T6 (stop-design-v5 §2.11): progress is an answer (sense key, ASC, ASCQ) not
/// yet seen in this poll, or the one START STOP UNIT; [`READY_STALL`] without
/// progress ends it. No total cap. `Err` only for a dead bus or a Stop.
fn await_media_ready(
scsi: &mut dyn ScsiTransport,
clock: &mut dyn ReadyClock,
) -> Result<MediaReady> {
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
let mut seen: Vec<(u8, u8, u8)> = Vec::new();
let mut last_progress = clock.elapsed();
let (mut start_sent, mut empty_run, mut becoming_ready_seen) = (false, 0u32, false);
for polls in 1u64.. {
// A drive answer arrives as `Ok` + status, or (libfreemkv's adapter) as an
// `Err` carrying the sense; only a senseless `Err` is a dead bus or a Stop.
let sense = match scsi.execute(&tur, DataDirection::None, &mut [], TUR_TIMEOUT_MS) {
Ok(r) if r.status == 0 => {
tracing::debug!(
target: "freemkv::disc",
phase = "mt1959_media_ready",
polls,
elapsed_ms = clock.elapsed().as_millis() as u64,
"drive ready after the firmware upload"
);
return Ok(MediaReady::Ready);
}
Ok(r) => r.sense,
Err(e) => match e.sense {
Some(sense) => sense,
None => return Err(Error::from(e)),
},
};
let s = ScsiSense::from_buf(&sense);
let answer = (s.sense_key, s.asc, s.ascq);
// MMC-6 Table F.3: "2 3A 00 MEDIUM NOT PRESENT"; "2 04 01 LOGICAL UNIT IS IN
// PROCESS OF BECOMING READY" first means the 3A run is not final.
empty_run = if (s.sense_key, s.asc) == (0x02, 0x3A) {
empty_run + 1
} else {
0
};
becoming_ready_seen |= answer == (0x02, 0x04, 0x01);
let outcome = match answer {
_ if !becoming_ready_seen && empty_run >= READY_MAX_EMPTY_POLLS => {
Some(MediaReady::NoMedium)
}
// Table F.3: "2 30 00 INCOMPATIBLE MEDIUM INSTALLED" (30/xx never ready).
(0x02, 0x30, ascq) => Some(MediaReady::Incompatible { asc: 0x30, ascq }),
// MEDIUM ERROR; HARDWARE ERROR (F.3.8 "reported when SK = HARDWARE ERROR");
// ILLEGAL REQUEST (F.3.2 Table F.2): no readiness to wait for.
(sense_key @ 0x03..=0x05, asc, ascq) => Some(MediaReady::DriveError {
sense_key,
asc,
ascq,
}),
_ => None,
};
if let Some(outcome) = outcome {
tracing::warn!(
target: "freemkv::disc",
phase = "mt1959_media_not_ready",
polls,
outcome = ?outcome,
"drive not ready after the firmware upload; keeping the unlock"
);
return Ok(outcome);
}
// Table F.3: "2 04 02 LOGICAL UNIT NOT READY, INITIALIZING CMD. REQUIRED" →
// one START STOP UNIT, Table 633 LoEj 0 Start 1: "Start the disc and make
// ready for access". Counted as progress.
if answer == (0x02, 0x04, 0x02) && !start_sent {
start_sent = true;
last_progress = clock.elapsed();
let r = scsi.execute(
&START_UNIT,
DataDirection::None,
&mut [],
START_UNIT_TIMEOUT_MS,
);
if let Err(e @ crate::scsi::ScsiError { sense: None, .. }) = r {
return Err(Error::from(e));
}
}
// Everything else (04/xx, 06/29/00 and 06/28/00 UNIT ATTENTION after the
// upload's reset, …) keeps polling under the T6 rule.
if !seen.contains(&answer) {
// TODO(T6 part 2, stop-design-v5 §2.11): also count a rising SKSV progress
// indicator (sense bytes 15-17) as progress, once SS-1 is quoted here.
seen.push(answer);
last_progress = clock.elapsed();
} else if clock.elapsed().saturating_sub(last_progress) >= READY_STALL {
let (sense_key, asc, ascq) = answer;
tracing::warn!(
target: "freemkv::disc",
phase = "mt1959_media_ready_stalled",
polls,
sense_key,
asc,
ascq,
"drive not ready after the firmware upload, no progress for 60 s; \
keeping the unlock"
);
return Ok(MediaReady::Stalled {
sense_key,
asc,
ascq,
});
}
clock.wait(scsi, READY_POLL)?;
}
unreachable!("the poll returns from inside the loop")
}
// ── PlatformDriver trait ───────────────────────────────────────────────
impl PlatformDriver for Mt1959 {
@@ -395,203 +709,5 @@ impl PlatformDriver for Mt1959 {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ld::profile::{DriveProfile, Identity};
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
/// Minimal mock transport that returns a scripted response to the
/// next `execute()` call. Only used for verifying that `do_unlock`
/// classifies the response correctly — no general SCSI coverage.
struct ScriptedTransport {
response: Vec<u8>,
}
impl ScsiTransport for ScriptedTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
let n = self.response.len().min(data.len());
data[..n].copy_from_slice(&self.response[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
})
}
}
/// Records every CDB issued, so a test can assert which bytes hit the wire.
struct RecordingTransport {
cdbs: Vec<Vec<u8>>,
}
impl ScsiTransport for RecordingTransport {
fn execute(
&mut self,
cdb: &[u8],
_dir: DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
self.cdbs.push(cdb.to_vec());
// Empty response → do_unlock's signature check fails, so the unlock
// loop exhausts — but the firmware-load CDBs (incl. the F1 verify)
// are already recorded by then.
Ok(ScsiResult {
status: 0,
bytes_transferred: 0,
sense: [0u8; 32],
})
}
}
/// variant_b must issue the PROFILE's per-drive `fw_verify_cdb`, not the
/// hardcoded fallback const — the bug that broke ~139 of 140 B drives.
#[test]
fn variant_b_issues_profile_fw_verify_cdb_not_const() {
let drive_f1 = [0xF1, 0x01, 0x02, 0x00, 0x0C, 0xF0, 0x01, 0xFB, 0xC9, 0x93];
// variant_b's hardcoded fallback const (a different drive's token).
let fallback_f1 = [0xF1, 0x01, 0x02, 0x00, 0x0D, 0x30, 0x01, 0xF3, 0xAD, 0x23];
let mut profile = fixture_profile([0x9a, 0xa9, 0x3a, 0xe2]);
profile.firmware = vec![0u8; 2208]; // a real per-drive length, != old 0x9C0
profile.fw_verify_cdb = Some(drive_f1);
let mut mt = Mt1959::new(profile, true);
let mut t = RecordingTransport { cdbs: Vec::new() };
let _ = variant_b::load_firmware(&mut mt, &mut t);
assert!(
t.cdbs.iter().any(|c| c.as_slice() == drive_f1),
"must send the profile's F1 verify CDB"
);
assert!(
!t.cdbs.iter().any(|c| c.as_slice() == fallback_f1),
"must NOT send the hardcoded fallback const when the profile has its own"
);
// MODE SELECT must encode the real per-drive length (2208), not 0x9C0.
let ms = t
.cdbs
.iter()
.find(|c| c.first() == Some(&0x55))
.expect("MODE SELECT issued");
let len = ((ms[7] as usize) << 8) | ms[8] as usize;
assert_eq!(len, 2208, "MODE SELECT length = firmware.len(), not 2496");
}
fn fixture_profile(signature: [u8; 4]) -> DriveProfile {
DriveProfile {
identity: Identity {
vendor_id: "TEST".into(),
product_id: String::new(),
product_revision: String::new(),
vendor_specific: String::new(),
firmware_date: String::new(),
},
signature,
firmware: Vec::new(),
unlock_init_value: 0,
unlock_response_size: 0,
read_vid_cdb: None,
read_disc_keys_cdb: None,
drive_nominal_speed_cdb: None,
set_speed_max_cdb: None,
read10_raw_2sec_cdb: None,
read10_raw_1sec_cdb: None,
read_buffer_verify_cdb: None,
write_buffer_cdb: None,
read_buffer_unlock_cdb: None,
fw_verify_cdb: None,
speed_zone_table: None,
speed_calc_table: None,
}
}
/// Build a synthetic 64-byte unlock response.
///
/// `mode_marker`: bytes [12..16]. Pass `FIRMWARE_ACTIVE_SIG` for the
/// active-mode primary marker.
/// `id_marker`: bytes [16..20] (and repeated through [20..64] in
/// real responses; only [16..20] is checked).
fn build_response(signature: [u8; 4], mode_marker: [u8; 4], id_marker: [u8; 4]) -> Vec<u8> {
let mut r = vec![0u8; 64];
r[0..4].copy_from_slice(&signature);
// bytes [4..12] left as zeros (version + reserved per format)
r[12..16].copy_from_slice(&mode_marker);
// Real firmware repeats the secondary marker through [16..64];
// the parser only checks [16..20], so we just write the marker
// once.
r[16..20].copy_from_slice(&id_marker);
r
}
#[test]
fn do_unlock_sets_unlocked_when_both_markers_present() {
let sig = [0x99, 0x9E, 0xC3, 0x75];
let response = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG);
let mut transport = ScriptedTransport { response };
let mut mt = Mt1959::new(fixture_profile(sig), false);
let raw = mt.do_unlock(&mut transport).expect("unlock should succeed");
assert_eq!(raw.len(), 64);
assert!(mt.init_complete, "init_complete set after success");
assert!(
mt.is_unlocked(),
"both markers present -> extended-access state"
);
}
#[test]
fn do_unlock_init_complete_but_not_unlocked_when_id_marker_missing() {
// Primary mode marker present (so init passes) but the
// secondary marker is replaced with zeros — drive isn't in
// extended-access state.
let sig = [0x99, 0x9E, 0xC3, 0x75];
let response = build_response(sig, FIRMWARE_ACTIVE_SIG, [0u8; 4]);
let mut transport = ScriptedTransport { response };
let mut mt = Mt1959::new(fixture_profile(sig), false);
mt.do_unlock(&mut transport).expect("unlock should succeed");
assert!(mt.init_complete);
assert!(
!mt.is_unlocked(),
"missing secondary marker -> not in extended-access state"
);
}
#[test]
fn do_unlock_rejects_signature_mismatch() {
let response = build_response(
[0xAA, 0xBB, 0xCC, 0xDD],
FIRMWARE_ACTIVE_SIG,
FIRMWARE_MODE_SIG,
);
let mut transport = ScriptedTransport { response };
let mut mt = Mt1959::new(fixture_profile([0x99, 0x9E, 0xC3, 0x75]), false);
let err = mt.do_unlock(&mut transport).unwrap_err();
assert!(matches!(err, Error::SignatureMismatch { .. }));
assert!(!mt.init_complete);
assert!(!mt.is_unlocked());
}
#[test]
fn do_unlock_rejects_inactive_mode_marker() {
// Signature matches but the primary marker at [12..16] is
// missing -> drive is not in active mode; init_complete and the
// unlocked flag must both stay false.
let sig = [0x99, 0x9E, 0xC3, 0x75];
let response = build_response(sig, [0u8; 4], FIRMWARE_MODE_SIG);
let mut transport = ScriptedTransport { response };
let mut mt = Mt1959::new(fixture_profile(sig), false);
let err = mt.do_unlock(&mut transport).unwrap_err();
assert!(matches!(err, Error::UnlockFailed));
assert!(!mt.init_complete);
assert!(!mt.is_unlocked());
}
}
#[path = "mod_tests.rs"]
pub(crate) mod tests;
File diff suppressed because it is too large Load Diff
+48 -12
View File
@@ -4,7 +4,7 @@
use super::Mt1959;
use crate::ld::error::Result;
use crate::scsi::{DataDirection, ScsiTransport};
use crate::scsi::{CriticalGuard, DataDirection, ScsiTransport};
use super::SCSI_WRITE_BUFFER;
@@ -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);
@@ -41,9 +40,14 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
0x00,
];
let mut data = firmware.clone();
scsi.execute(&cdb, DataDirection::ToDevice, &mut data, 30_000)?;
// D8 (stop-design-v5 §2.3): "the WRITE BUFFER at 30 s … and its verify run inside
// a `CriticalGuard`. C_max = 35 s" — a Stop mid-upload waits for both CDBs.
let mut span = CriticalGuard::enter(&mut *scsi)?;
span.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 +61,49 @@ 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 span.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.
drop(span); // the span ends here; the unlock below stays cancellable
// 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(())
+75 -27
View File
@@ -4,7 +4,7 @@
use super::{Mt1959, SCSI_READ_BUFFER, SCSI_WRITE_BUFFER};
use crate::ld::error::Result;
use crate::scsi::{DataDirection, ScsiTransport};
use crate::scsi::{CriticalGuard, DataDirection, ScsiTransport};
const SCSI_MODE_SELECT: u8 = 0x55;
const FIRMWARE_EXTRA: [u8; 16] = [0; 16];
@@ -13,18 +13,53 @@ 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];
// 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.
// 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);
@@ -42,7 +77,10 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
0x00,
];
let mut data = firmware[..write_len].to_vec();
scsi.execute(&mode_select_cdb, DataDirection::ToDevice, &mut data, 30_000)?;
// stop-design-v5 §2.3: "MODE SELECT at 30 s …, READ BUFFER, WRITE BUFFER and F1 run
// inside a `CriticalGuard`. C_max = 45 s" — a Stop mid-upload waits for all four.
let mut span = CriticalGuard::enter(&mut *scsi)?;
span.execute(&mode_select_cdb, DataDirection::ToDevice, &mut data, 30_000)?;
// Step 2: Read firmware metadata (READ_BUFFER mode 6, offset 0x3000)
let read_meta_cdb = [
@@ -58,12 +96,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",
span.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 +120,31 @@ 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",
span.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.
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",
span.execute(&verify_cdb, DataDirection::None, &mut dummy, 5_000),
)?;
drop(span); // the span ends here; the unlock retries below stay cancellable
// 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.
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)?;
+103 -427
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,12 @@ 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.
#[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 +58,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 +148,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().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 +197,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 +276,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()
}
@@ -283,15 +306,14 @@ fn load_from_str(data: &str) -> Result<Profiles> {
serde_json::from_str(data).map_err(|_| Error::ProfileParse)
}
/// Find a profile matching a drive's INQUIRY fields.
/// Find a profile matching a drive's INQUIRY fields, whitespace-trimmed.
///
/// 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.
/// Over ALL platform sections (MT1959-A, -B, Renesas, in order): (1) exact
/// match including `product_id` when the drive reports one; then (2) a
/// product-id-blind fallback on vendor/revision/vendor_specific/firmware_date.
/// Catalogs store a GENERIC product_id ("BD-RE") vs a drive's specific one
/// ("BD-RE BU40N"), so pass 1 misses; pass 2 binds a four-field match UNIQUE
/// across all sections — a shared tuple binds only with no product_id.
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();
@@ -299,393 +321,47 @@ pub fn find_by_drive_id(profiles: &Profiles, drive_id: &crate::DriveId) -> Optio
let vs = drive_id.vendor_specific.trim();
let date = drive_id.firmware_date.trim();
for (platform, list) in [
let sections = [
(Platform::Mt1959A, &profiles.mt1959_a),
(Platform::Mt1959B, &profiles.mt1959_b),
(Platform::Renesas, &profiles.renesas),
] {
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,
});
}
];
let four_field = |p: &DriveProfile| {
p.identity.vendor_id.trim() == v
&& p.identity.product_revision.trim() == r
&& p.identity.vendor_specific.trim() == vs
&& p.identity.firmware_date.trim() == date
};
let all = || {
sections
.iter()
.flat_map(|(platform, list)| list.iter().map(move |p| (*platform, p)))
};
if let Some(p) = list.iter().find(|p| {
p.identity.vendor_id.trim() == v
&& p.identity.product_revision.trim() == r
&& p.identity.vendor_specific.trim() == vs
&& p.identity.firmware_date.trim() == date
}) {
return Some(ProfileMatch {
profile: p.clone(),
platform,
});
}
if !prod.is_empty()
&& let Some((platform, p)) =
all().find(|(_, p)| four_field(p) && p.identity.product_id.trim() == prod)
{
return Some(ProfileMatch {
profile: p.clone(),
platform,
});
}
None
// Unique across all sections: bind regardless of the reported product_id
// (the common real-drive case a UHD LG BU40N relies on). A shared tuple
// binds only with NO product_id; otherwise it's an uncataloged variant.
let mut matches = all().filter(|(_, p)| four_field(p));
let (platform, first) = matches.next()?;
if matches.next().is_some() && !prod.is_empty() {
return None;
}
Some(ProfileMatch {
profile: first.clone(),
platform,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DriveId;
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
DriveId {
vendor_id: vendor.to_string(),
product_id: String::new(),
product_revision: rev.to_string(),
vendor_specific: vs.to_string(),
firmware_date: date.to_string(),
}
}
/// When two profiles share vendor/rev/vs/date and differ only in product_id,
/// the full pass binds to the one whose product matches; the looser passes
/// would return the first regardless.
#[test]
fn find_by_drive_id_product_id_breaks_a_tie() {
use serde_json::json;
let profiles: Profiles = serde_json::from_str(
&json!({
"mt1959_a": [
{"identity": {"vendor_id":"TIE","product_id":"MODEL-A",
"product_revision":"1.00","vendor_specific":"XX00000",
"firmware_date":"200001010000"},
"signature":"aaaaaaaa","firmware":""},
{"identity": {"vendor_id":"TIE","product_id":"MODEL-B",
"product_revision":"1.00","vendor_specific":"XX00000",
"firmware_date":"200001010000"},
"signature":"bbbbbbbb","firmware":""}
]
})
.to_string(),
)
.unwrap();
let mut id = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
id.product_id = "MODEL-B".to_string();
let m = find_by_drive_id(&profiles, &id).unwrap();
assert_eq!(
m.profile.signature,
[0xbb, 0xbb, 0xbb, 0xbb],
"product_id must select MODEL-B over the first entry"
);
// No product_id → falls back to the 4-field pass → first entry.
let id0 = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
let m0 = find_by_drive_id(&profiles, &id0).unwrap();
assert_eq!(m0.profile.signature, [0xaa, 0xaa, 0xaa, 0xaa]);
}
#[test]
fn test_find_known_drive() {
let profiles = load_bundled().unwrap();
let id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
let m = find_by_drive_id(&profiles, &id).unwrap();
assert_eq!(m.profile.identity.vendor_id.trim(), "HL-DT-ST");
assert_eq!(m.platform, Platform::Mt1959A);
}
#[test]
fn test_find_unknown_drive() {
let profiles = load_bundled().unwrap();
let id = make_drive_id("FAKE-VND", "9.99", "XX12345", "");
assert!(find_by_drive_id(&profiles, &id).is_none());
}
#[test]
fn decode_hex_rejects_non_ascii_without_panic() {
// A multi-byte char of even byte-length must not slice inside a
// char boundary; it must decode-fail gracefully.
assert!(decode_hex("中中").is_err()); // 6 bytes, none hex
assert!(parse_hex4("中中").is_err()); // 6 bytes != 8 anyway
// An 8-byte non-ASCII string (two 4-byte chars) hits the exact-len
// path of parse_hex4; must still error, not panic.
assert!(parse_hex4("𝕏𝕏").is_err());
}
#[test]
fn decode_hex_roundtrips_valid_hex() {
assert_eq!(decode_hex("00ff10").unwrap(), vec![0x00, 0xff, 0x10]);
assert_eq!(parse_hex4("deadbeef").unwrap(), [0xde, 0xad, 0xbe, 0xef]);
assert!(decode_hex("abc").is_err()); // odd length
assert!(decode_hex("zz").is_err()); // non-hex
}
#[test]
fn bundled_is_cached_and_matches_fresh_parse() {
let cached = bundled().expect("bundled profiles parse");
let fresh = load_bundled().unwrap();
// Same data either way (compare section sizes — Profiles isn't Eq).
assert_eq!(cached.mt1959_a.len(), fresh.mt1959_a.len());
// Cached accessor returns a stable address across calls.
let a = bundled().unwrap() as *const Profiles;
let b = bundled().unwrap() as *const Profiles;
assert_eq!(a, b);
}
#[test]
fn find_bundled_matches_known_drive() {
let id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
let m = find_bundled(&id).unwrap();
assert_eq!(m.platform, Platform::Mt1959A);
}
// ── New comprehensive tests ────────────────────────────────────────────────
/// decode_hex accepts empty string → empty Vec.
/// Mutation: returning an error on empty input breaks empty-field handling.
#[test]
fn decode_hex_accepts_empty_string() {
assert_eq!(decode_hex("").unwrap(), Vec::<u8>::new());
}
/// decode_hex handles all valid hex digit characters (0-9, a-f, A-F).
/// Mutation: not supporting uppercase A-F means uppercase-encoded profiles fail.
#[test]
fn decode_hex_handles_upper_and_lower_case() {
assert_eq!(
decode_hex("DEADBEEF").unwrap(),
vec![0xDE, 0xAD, 0xBE, 0xEF]
);
assert_eq!(
decode_hex("deadbeef").unwrap(),
vec![0xDE, 0xAD, 0xBE, 0xEF]
);
assert_eq!(
decode_hex("DeAdBeEf").unwrap(),
vec![0xDE, 0xAD, 0xBE, 0xEF]
);
}
/// parse_hex4 rejects an 8-hex-char string (4 bytes) correctly.
/// Spec: the signature field is exactly 4 bytes = 8 hex chars.
/// Mutation: accepting 6 hex chars (3 bytes) would pass a wrong-length signature.
#[test]
fn parse_hex4_rejects_wrong_byte_length() {
// 6 hex chars = 3 bytes ≠ 4.
assert!(
parse_hex4("aabbcc").is_err(),
"3 bytes must be rejected for 4-byte field"
);
// 10 hex chars = 5 bytes ≠ 4.
assert!(
parse_hex4("aabbccddee").is_err(),
"5 bytes must be rejected for 4-byte field"
);
// Exactly 8 hex chars = 4 bytes: must succeed.
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.
#[test]
fn platform_name_is_stable() {
// The exact strings are part of the public stable API (logged/keyed).
assert_eq!(Platform::Mt1959A.name(), "MediaTek MT1959-A");
assert_eq!(Platform::Mt1959B.name(), "MediaTek MT1959-B");
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.
#[test]
fn find_by_drive_id_exact_date_wins_over_loose() {
use serde_json::json;
// Use an 8-char vendor_id (padded with a trailing space so `trim()` strips
// the pad, matching the same trimmed form the JSON profile stores).
// "TESTDRV " fills INQUIRY [8..16] exactly; `ascii_field.trim()` → "TESTDRV".
let profiles_json = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "TESTDRV",
"product_revision": "1.00",
"vendor_specific": "XX00000",
"firmware_date": "200001010000"
},
"signature": "aabbccdd",
"firmware": ""
},
{
"identity": {
"vendor_id": "TESTDRV",
"product_revision": "1.00",
"vendor_specific": "XX00000",
"firmware_date": "200006150000"
},
"signature": "11223344",
"firmware": ""
}
]
})
.to_string();
let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap();
// "TESTDRV " (with space) fills 8 bytes; trim() → "TESTDRV" on both sides.
let id_date1 = make_drive_id("TESTDRV ", "1.00", "XX00000", "200001010000");
let id_date2 = make_drive_id("TESTDRV ", "1.00", "XX00000", "200006150000");
let m1 = find_by_drive_id(&profiles, &id_date1).unwrap();
let m2 = find_by_drive_id(&profiles, &id_date2).unwrap();
// Each must bind to its own profile by exact date match.
assert_eq!(
m1.profile.signature,
[0xaa, 0xbb, 0xcc, 0xdd],
"id_date1 must match first profile"
);
assert_eq!(
m2.profile.signature,
[0x11, 0x22, 0x33, 0x44],
"id_date2 must match second profile"
);
}
/// find_by_drive_id: a drive whose firmware_date does NOT match any profile
/// gets no match — there is no loose vendor/rev/vs fallback that could bind
/// the wrong same-model variant.
#[test]
fn find_by_drive_id_no_match_when_date_differs() {
use serde_json::json;
let profiles_json = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "LOOSEDR",
"product_revision": "2.00",
"vendor_specific": "YY11111",
"firmware_date": "210101010000"
},
"signature": "deadbeef",
"firmware": ""
}
]
})
.to_string();
let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap();
// Same vendor/rev/vs but a different date — must NOT match.
let id = make_drive_id("LOOSEDR ", "2.00", "YY11111", "000000000000");
assert!(find_by_drive_id(&profiles, &id).is_none());
}
/// load_from_str (via load_bundled) returns ProfileParse on invalid JSON.
/// Mutation: returning an empty Profiles instead of an error silently
/// leaves the drive-profile database empty.
#[test]
fn load_from_str_returns_profile_parse_on_bad_json() {
let result: Result<Profiles> =
serde_json::from_str("not valid json {{{{").map_err(|_| Error::ProfileParse);
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.
#[test]
fn bundled_profiles_has_entries() {
let profiles = load_bundled().unwrap();
assert!(
!profiles.mt1959_a.is_empty(),
"bundled profiles must have at least one mt1959_a entry"
);
}
/// 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.
#[test]
fn profile_optional_cdb_fields_default_to_none() {
use serde_json::json;
let json_str = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "TEST",
"product_revision": "1.00",
"vendor_specific": "000000",
"firmware_date": ""
},
"signature": "00000000",
"firmware": ""
}
]
})
.to_string();
let profiles: Profiles = serde_json::from_str(&json_str).unwrap();
let p = &profiles.mt1959_a[0]; // DriveProfile directly
// All optional CDB fields must be None when absent from JSON.
assert!(
p.read_vid_cdb.is_none(),
"read_vid_cdb must default to None"
);
assert!(
p.read_disc_keys_cdb.is_none(),
"read_disc_keys_cdb must default to None"
);
assert!(
p.drive_nominal_speed_cdb.is_none(),
"drive_nominal_speed_cdb must default to None"
);
assert!(
p.set_speed_max_cdb.is_none(),
"set_speed_max_cdb must default to None"
);
assert!(
p.speed_zone_table.is_none(),
"speed_zone_table must default to None"
);
assert!(
p.speed_calc_table.is_none(),
"speed_calc_table must default to None"
);
}
/// 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.
#[test]
fn profile_empty_signature_deserialises_as_zeroes() {
use serde_json::json;
let json_str = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "TEST",
"product_revision": "1.00",
"vendor_specific": "000000",
"firmware_date": ""
},
"signature": "",
"firmware": ""
}
]
})
.to_string();
let profiles: Profiles = serde_json::from_str(&json_str).unwrap();
assert_eq!(
profiles.mt1959_a[0].signature, [0u8; 4],
"empty signature must deserialise as [0;4]"
);
}
}
#[path = "profile_tests.rs"]
mod tests;
+445
View File
@@ -0,0 +1,445 @@
use super::*;
use crate::DriveId;
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
DriveId {
vendor_id: vendor.to_string(),
product_id: String::new(),
product_revision: rev.to_string(),
vendor_specific: vs.to_string(),
firmware_date: date.to_string(),
}
}
/// When two profiles share vendor/rev/vs/date and differ only in product_id,
/// the full pass binds to the one whose product matches; the looser passes
/// would return the first regardless.
#[test]
fn find_by_drive_id_product_id_breaks_a_tie() {
use serde_json::json;
let profiles: Profiles = serde_json::from_str(
&json!({
"mt1959_a": [
{"identity": {"vendor_id":"TIE","product_id":"MODEL-A",
"product_revision":"1.00","vendor_specific":"XX00000",
"firmware_date":"200001010000"},
"signature":"aaaaaaaa","firmware":""},
{"identity": {"vendor_id":"TIE","product_id":"MODEL-B",
"product_revision":"1.00","vendor_specific":"XX00000",
"firmware_date":"200001010000"},
"signature":"bbbbbbbb","firmware":""}
]
})
.to_string(),
)
.unwrap();
let mut id = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
id.product_id = "MODEL-B".to_string();
let m = find_by_drive_id(&profiles, &id).unwrap();
assert_eq!(
m.profile.signature,
[0xbb, 0xbb, 0xbb, 0xbb],
"product_id must select MODEL-B over the first entry"
);
// No product_id → falls back to the 4-field pass → first entry.
let id0 = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
let m0 = find_by_drive_id(&profiles, &id0).unwrap();
assert_eq!(m0.profile.signature, [0xaa, 0xaa, 0xaa, 0xaa]);
// 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",
);
}
/// REGRESSION (1.7.0): a real drive reports a SPECIFIC product_id (e.g.
/// "BD-RE BU40N") while the catalog stores a GENERIC one ("BD-RE"), so the
/// exact pass misses. When the four-field identity is UNIQUE it must still
/// bind — the pre-1.7.0 behaviour a UHD LG BU40N relies on for LibreDrive
/// unlock. 1.7.0 gated the four-field pass behind an empty product_id, so it
/// silently stopped matching and the firmware unlocker was skipped entirely.
#[test]
fn find_by_drive_id_specific_product_id_binds_unique_four_field() {
use serde_json::json;
let profiles: Profiles = serde_json::from_str(
&json!({
"mt1959_a": [
{"identity": {"vendor_id":"HL-DT-ST","product_id":"BD-RE",
"product_revision":"1.03","vendor_specific":"NM00000",
"firmware_date":"211810241934"},
"signature":"12345678","firmware":""}
]
})
.to_string(),
)
.unwrap();
// Drive reports the specific marketing product_id; catalog has "BD-RE".
let mut id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
id.product_id = "BD-RE BU40N".to_string();
let m = find_by_drive_id(&profiles, &id)
.expect("unique four-field identity must match despite the product_id mismatch");
assert_eq!(m.profile.signature, [0x12, 0x34, 0x56, 0x78]);
assert_eq!(m.platform, Platform::Mt1959A);
}
/// An exact product_id match in a LATER section must beat a four-field
/// fallback in an earlier one; fallback uniqueness spans all sections.
#[test]
fn find_by_drive_id_exact_match_beats_earlier_section_fallback() {
use serde_json::json;
let entry = |prod: &str, sig: &str| {
json!({"identity": {"vendor_id":"HL-DT-ST","product_id":prod,
"product_revision":"1.03","vendor_specific":"NM00000",
"firmware_date":"211810241934"},
"signature":sig,"firmware":""})
};
let profiles: Profiles = serde_json::from_value(json!({
"mt1959_a": [entry("BD-RE", "aaaaaaaa")],
"mt1959_b": [entry("BD-RE BU40N", "bbbbbbbb")],
}))
.unwrap();
let mut id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
id.product_id = "BD-RE BU40N".to_string();
let m = find_by_drive_id(&profiles, &id).expect("B's exact match");
assert_eq!(m.platform, Platform::Mt1959B);
assert_eq!(m.profile.signature, [0xbb, 0xbb, 0xbb, 0xbb]);
// Tuple shared across sections + uncataloged product_id: no match.
id.product_id = "BD-RE WH16NS".to_string();
assert!(find_by_drive_id(&profiles, &id).is_none());
// No product_id: shared tuple binds the first section's entry.
id.product_id.clear();
let m0 = find_by_drive_id(&profiles, &id).expect("empty product_id binds");
assert_eq!(m0.platform, Platform::Mt1959A);
}
#[test]
fn test_find_known_drive() {
let profiles = load_bundled().unwrap();
let id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
let m = find_by_drive_id(&profiles, &id).unwrap();
assert_eq!(m.profile.identity.vendor_id.trim(), "HL-DT-ST");
assert_eq!(m.platform, Platform::Mt1959A);
}
#[test]
fn test_find_unknown_drive() {
let profiles = load_bundled().unwrap();
let id = make_drive_id("FAKE-VND", "9.99", "XX12345", "");
assert!(find_by_drive_id(&profiles, &id).is_none());
}
#[test]
fn decode_hex_rejects_non_ascii_without_panic() {
// A multi-byte char of even byte-length must not slice inside a
// char boundary; it must decode-fail gracefully.
assert!(decode_hex("中中").is_err()); // 6 bytes, none hex
assert!(parse_hex4("中中").is_err()); // 6 bytes != 8 anyway
// An 8-byte non-ASCII string (two 4-byte chars) hits the exact-len
// path of parse_hex4; must still error, not panic.
assert!(parse_hex4("𝕏𝕏").is_err());
}
#[test]
fn decode_hex_roundtrips_valid_hex() {
assert_eq!(decode_hex("00ff10").unwrap(), vec![0x00, 0xff, 0x10]);
assert_eq!(parse_hex4("deadbeef").unwrap(), [0xde, 0xad, 0xbe, 0xef]);
assert!(decode_hex("abc").is_err()); // odd length
assert!(decode_hex("zz").is_err()); // non-hex
}
#[test]
fn bundled_is_cached_and_matches_fresh_parse() {
let cached = bundled().expect("bundled profiles parse");
let fresh = load_bundled().unwrap();
// Same data either way (compare section sizes — Profiles isn't Eq).
assert_eq!(cached.mt1959_a.len(), fresh.mt1959_a.len());
// Cached accessor returns a stable address across calls.
let a = bundled().unwrap() as *const Profiles;
let b = bundled().unwrap() as *const Profiles;
assert_eq!(a, b);
}
#[test]
fn find_bundled_matches_known_drive() {
let id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
let m = find_bundled(&id).unwrap();
assert_eq!(m.platform, Platform::Mt1959A);
}
// ── New comprehensive tests ────────────────────────────────────────────────
/// decode_hex accepts empty string → empty Vec.
/// Mutation: returning an error on empty input breaks empty-field handling.
#[test]
fn decode_hex_accepts_empty_string() {
assert_eq!(decode_hex("").unwrap(), Vec::<u8>::new());
}
/// decode_hex handles all valid hex digit characters (0-9, a-f, A-F).
/// Mutation: not supporting uppercase A-F means uppercase-encoded profiles fail.
#[test]
fn decode_hex_handles_upper_and_lower_case() {
assert_eq!(
decode_hex("DEADBEEF").unwrap(),
vec![0xDE, 0xAD, 0xBE, 0xEF]
);
assert_eq!(
decode_hex("deadbeef").unwrap(),
vec![0xDE, 0xAD, 0xBE, 0xEF]
);
assert_eq!(
decode_hex("DeAdBeEf").unwrap(),
vec![0xDE, 0xAD, 0xBE, 0xEF]
);
}
/// parse_hex4 rejects an 8-hex-char string (4 bytes) correctly.
/// Spec: the signature field is exactly 4 bytes = 8 hex chars.
/// Mutation: accepting 6 hex chars (3 bytes) would pass a wrong-length signature.
#[test]
fn parse_hex4_rejects_wrong_byte_length() {
// 6 hex chars = 3 bytes ≠ 4.
assert!(
parse_hex4("aabbcc").is_err(),
"3 bytes must be rejected for 4-byte field"
);
// 10 hex chars = 5 bytes ≠ 4.
assert!(
parse_hex4("aabbccddee").is_err(),
"5 bytes must be rejected for 4-byte field"
);
// Exactly 8 hex chars = 4 bytes: must succeed.
assert_eq!(parse_hex4("aabbccdd").unwrap(), [0xaa, 0xbb, 0xcc, 0xdd]);
}
// 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).
assert_eq!(Platform::Mt1959A.name(), "MediaTek MT1959-A");
assert_eq!(Platform::Mt1959B.name(), "MediaTek MT1959-B");
assert_eq!(Platform::Renesas.name(), "Renesas");
}
// 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;
// Use an 8-char vendor_id (padded with a trailing space so `trim()` strips
// the pad, matching the same trimmed form the JSON profile stores).
// "TESTDRV " fills INQUIRY [8..16] exactly; `ascii_field.trim()` → "TESTDRV".
let profiles_json = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "TESTDRV",
"product_revision": "1.00",
"vendor_specific": "XX00000",
"firmware_date": "200001010000"
},
"signature": "aabbccdd",
"firmware": ""
},
{
"identity": {
"vendor_id": "TESTDRV",
"product_revision": "1.00",
"vendor_specific": "XX00000",
"firmware_date": "200006150000"
},
"signature": "11223344",
"firmware": ""
}
]
})
.to_string();
let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap();
// "TESTDRV " (with space) fills 8 bytes; trim() → "TESTDRV" on both sides.
let id_date1 = make_drive_id("TESTDRV ", "1.00", "XX00000", "200001010000");
let id_date2 = make_drive_id("TESTDRV ", "1.00", "XX00000", "200006150000");
let m1 = find_by_drive_id(&profiles, &id_date1).unwrap();
let m2 = find_by_drive_id(&profiles, &id_date2).unwrap();
// Each must bind to its own profile by exact date match.
assert_eq!(
m1.profile.signature,
[0xaa, 0xbb, 0xcc, 0xdd],
"id_date1 must match first profile"
);
assert_eq!(
m2.profile.signature,
[0x11, 0x22, 0x33, 0x44],
"id_date2 must match second profile"
);
}
/// find_by_drive_id: a drive whose firmware_date does NOT match any profile
/// gets no match — there is no loose vendor/rev/vs fallback that could bind
/// the wrong same-model variant.
#[test]
fn find_by_drive_id_no_match_when_date_differs() {
use serde_json::json;
let profiles_json = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "LOOSEDR",
"product_revision": "2.00",
"vendor_specific": "YY11111",
"firmware_date": "210101010000"
},
"signature": "deadbeef",
"firmware": ""
}
]
})
.to_string();
let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap();
// Same vendor/rev/vs but a different date — must NOT match.
let id = make_drive_id("LOOSEDR ", "2.00", "YY11111", "000000000000");
assert!(find_by_drive_id(&profiles, &id).is_none());
}
/// load_from_str (via load_bundled) returns ProfileParse on invalid JSON.
/// Mutation: returning an empty Profiles instead of an error silently
/// leaves the drive-profile database empty.
#[test]
fn load_from_str_returns_profile_parse_on_bad_json() {
let result: Result<Profiles> =
serde_json::from_str("not valid json {{{{").map_err(|_| Error::ProfileParse);
assert!(matches!(result, Err(Error::ProfileParse)));
}
// Pins the embedded JSON: if profiles.json is emptied/truncated, this
// goes red.
#[test]
fn bundled_profiles_has_entries() {
let profiles = load_bundled().unwrap();
assert!(
!profiles.mt1959_a.is_empty(),
"bundled profiles must have at least one mt1959_a entry"
);
}
// 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;
let json_str = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "TEST",
"product_revision": "1.00",
"vendor_specific": "000000",
"firmware_date": ""
},
"signature": "00000000",
"firmware": ""
}
]
})
.to_string();
let profiles: Profiles = serde_json::from_str(&json_str).unwrap();
let p = &profiles.mt1959_a[0]; // DriveProfile directly
// All optional CDB fields must be None when absent from JSON.
assert!(
p.read_vid_cdb.is_none(),
"read_vid_cdb must default to None"
);
assert!(
p.read_disc_keys_cdb.is_none(),
"read_disc_keys_cdb must default to None"
);
assert!(
p.drive_nominal_speed_cdb.is_none(),
"drive_nominal_speed_cdb must default to None"
);
assert!(
p.set_speed_max_cdb.is_none(),
"set_speed_max_cdb must default to None"
);
assert!(
p.speed_zone_table.is_none(),
"speed_zone_table must default to None"
);
assert!(
p.speed_calc_table.is_none(),
"speed_calc_table must default to None"
);
}
// `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;
let json_str = json!({
"mt1959_a": [
{
"identity": {
"vendor_id": "TEST",
"product_revision": "1.00",
"vendor_specific": "000000",
"firmware_date": ""
},
"signature": "",
"firmware": ""
}
]
})
.to_string();
let profiles: Profiles = serde_json::from_str(&json_str).unwrap();
assert_eq!(
profiles.mt1959_a[0].signature, [0u8; 4],
"empty signature must deserialise as [0;4]"
);
}
+123 -77
View File
@@ -1,32 +1,58 @@
//! 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;
// `firmware` is the ergonomic, typed mirror of the freemkv vendor-command ABI
// (`freemkv-fw/src/abi.rs`) + the `FirmwareControl` driver. Public so host
// tooling (freemkv-drivetest) can arm/inspect a freemkv drive directly.
pub mod firmware;
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;
// The bare best-effort AACS Volume ID read (`0xAD` fmt `0x80`) — public so a
// harness armed `Encryption=off` can read the VID with NO cert AKE and prove no
// SEND KEY crossed the bus.
pub use vid::read_aacs_vid;
// `ld` is public only for its drive-profile catalog + (under `emulation`) the handshake wire
// format bdemu needs; the unlocker impl stays `pub(crate)`.
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)`.
pub mod renesas;
// `freemkv` self-identifies rather than matching a bundled profile, so it stays fully private.
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;
// Keypair-validity check for a stored AACS 1.0 host cert (`priv·G ==
// cert_pub_key`), for the key service's host-cert health gate.
pub use aacs::aacs1_keypair_matches;
pub use css::DvdUnlocker;
// The ergonomic firmware-control surface (typed grammar + recipes).
pub use firmware::{
ArmRecipe, BdRegion, Feature, FeatureStates, FirmwareControl, FirmwareError, FirmwareIdentity,
Verb,
};
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,44 +75,72 @@ 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)]
// `ZeroizeOnDrop` wipes the two host PRIVATE keys on drop so they don't linger in
// freed memory. The certificates are public material (sent to the drive in the
// clear), so they are `#[zeroize(skip)]` — nothing secret, cert bytes untouched.
#[derive(Clone, zeroize::ZeroizeOnDrop)]
pub struct HostCert {
/// AACS 1.0 host private key (20 bytes).
pub private_key: [u8; 20],
/// AACS 1.0 host certificate (92 bytes).
#[zeroize(skip)]
pub certificate: Vec<u8>,
/// AACS 2.0 host private key (P-256, 32 bytes). `None` for AACS 1.0 only.
pub private_key_v2: Option<[u8; 32]>,
/// AACS 2.0 host certificate (type 0x11). `None` for AACS 1.0 only.
#[zeroize(skip)]
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.
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. Both fields are wiped on drop.
#[derive(Clone, Default, zeroize::ZeroizeOnDrop)]
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.
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 +161,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",
/// "DVD", "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 +213,6 @@ 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::DvdUnlocker::new()),
]
}
#[cfg(test)]
#[path = "lib_tests.rs"]
mod tests;
+99
View File
@@ -0,0 +1,99 @@
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}");
}
// `vid`/`bus_key` leave the crate in `Unlocked`; wipe them (and every
// clone) on drop. Fails to compile if the derive is removed.
#[test]
fn unlocked_zeroizes_on_drop() {
fn assert_zeroize_on_drop<T: zeroize::ZeroizeOnDrop>() {}
assert_zeroize_on_drop::<Unlocked>();
}
// `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}"
);
}
+161
View File
@@ -0,0 +1,161 @@
//! 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`]). Renesas drives are
//! OEM-unlocked: identity establishes unlocked status without a host certificate.
//! Vendor register access is used only for optional VID retrieval; a read
//! miss preserves unlocked status, while transport faults and cancellation abort.
use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus};
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
/// The Renesas vendor identity block length (READ_BUFFER 0x02/0xF1).
const RB_F1_LEN: usize = pioneer_optical::IDENTITY_LEN;
/// 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.
pub fn is_renesas(scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
let mut buf = [0u8; RB_F1_LEN];
let cdb = pioneer_optical::cdb::vendor_identity();
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
Ok(r) => {
let end = RENESAS_MARKER_OFFSET + RENESAS_MARKER.len();
Ok(r.status == 0
&& (end..=buf.len()).contains(&r.bytes_transferred)
&& &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 is_dead_bus(&e) {
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)
}
}
}
/// Recognizes OEM-unlocked Renesas drives and optionally reads their VID from
/// hardware registers. This does not enable standard AACS VID reads.
#[derive(Default)]
pub struct Renesas;
impl Renesas {
pub fn new() -> Self {
Renesas
}
}
// Selector 0x92 maps offset - 0x2000 to high CPU memory; no AAAA gate.
const VID_STATUS_CDB: [u8; 10] = [0x3c, 2, 0x92, 0, 0x0d, 0x3c, 0, 0, 1, 0];
const VID_CDB: [u8; 10] = [0x3c, 2, 0x92, 0, 0x0d, 0x20, 0, 0, 16, 0];
fn get_vid(scsi: &mut dyn ScsiTransport) -> std::result::Result<Option<[u8; 16]>, UnlockError> {
// Mirror the OEM hardware-copy gate. Other status bits are not interpreted.
let mut status = [0];
let result = scsi.execute(
&VID_STATUS_CDB,
DataDirection::FromDevice,
&mut status,
5_000,
);
tracing::debug!(target: "freemkv::disc", phase = "renesas_vid_status",
cdb = ?VID_STATUS_CDB, requested = 1, ?result, payload = ?status,
"Reading Renesas VID hardware status");
match result {
Ok(r) if r.status == 0 && r.bytes_transferred == 1 && status[0] & 2 != 0 => {}
Err(e) if is_dead_bus(&e) => return Err(UnlockError::Transport),
_ => return Ok(None),
}
let mut vid = [0u8; 16];
let cdb = VID_CDB;
let address = 0xffff_ed20u32;
tracing::debug!(target: "freemkv::disc", phase = "renesas_vid_request",
address = format_args!("{address:#x}"), cdb = format_args!("{cdb:02x?}"), requested = vid.len(), "Reading VID from Renesas hardware registers");
let result = match scsi.execute(&cdb, DataDirection::FromDevice, &mut vid, 5_000) {
Ok(r) => r,
Err(e) => {
tracing::debug!(target: "freemkv::disc", phase = "renesas_vid_error",
address = format_args!("{address:#x}"), status = e.status, sense = ?e.sense,
dead_bus = is_dead_bus(&e),
"Renesas hardware VID read failed");
return if is_dead_bus(&e) {
Err(UnlockError::Transport)
} else {
Ok(None)
};
}
};
let rejection = if result.status != 0 {
Some("scsi_status")
} else if result.bytes_transferred != vid.len() {
Some("transfer_length")
} else if vid.iter().all(|&b| b == 0) {
Some("all_zero")
} else if vid.iter().all(|&b| b == 0xff) {
Some("all_ff")
} else {
None
};
let valid = rejection.is_none();
// Only show bytes the transport reports receiving, never buffer padding.
let payload = &vid[..result.bytes_transferred.min(vid.len())];
tracing::debug!(target: "freemkv::disc", phase = "renesas_vid_result",
address = format_args!("{address:#x}"), status = result.status,
bytes_transferred = result.bytes_transferred, sense = ?result.sense,
payload = format_args!("{payload:02x?}"), valid, rejection,
"Renesas hardware VID response");
Ok(valid.then_some(vid))
}
impl Unlocker for Renesas {
fn name(&self) -> &'static str {
"Renesas"
}
/// SAT identity establishes OEM-unlocked status. Hardware VID reads are
/// best-effort; a missing VID preserves success, dead buses abort.
fn unlock(
&self,
scsi: &mut dyn ScsiTransport,
_ctx: &UnlockCtx,
) -> std::result::Result<Option<Unlocked>, UnlockError> {
if !is_renesas(scsi)? {
return Ok(None);
}
let vid = get_vid(scsi)?;
tracing::debug!(
target: "freemkv::disc",
phase = "renesas_opened",
has_vid = vid.is_some(),
"Renesas VID discovery completed"
);
Ok(Some(Unlocked { vid, bus_key: None }))
}
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod mod_tests;
+346
View File
@@ -0,0 +1,346 @@
use super::*;
mod tests {
use super::*;
use crate::DiscKind;
use crate::scsi::{DataDirection, Result, ScsiError, ScsiResult, ScsiTransport};
#[test]
fn memory_vid_rejects_invalid_responses_and_propagates_dead_bus() {
use crate::scsi::mock::{MockTransport, Reply};
for reply in [
Reply::zero_transfer(16),
Reply::short(vec![0x25; 16], 15),
Reply::good(vec![0; 16]),
Reply::good(vec![0xff; 16]),
Reply::illegal_request(),
Reply::illegal_request_as_err(),
] {
let mut t =
MockTransport::scripted(vec![Reply::good(vec![2]), reply], Reply::TransportFault);
assert_eq!(get_vid(&mut t).unwrap(), None);
}
let mut t = MockTransport::always(Reply::TransportFault);
assert_eq!(get_vid(&mut t).unwrap_err(), UnlockError::Transport);
}
#[test]
fn memory_vid_rejects_failed_status_with_payload_and_impossible_length() {
struct Response {
status: u8,
transferred: usize,
}
impl ScsiTransport for Response {
fn execute(
&mut self,
_: &[u8],
dir: DataDirection,
data: &mut [u8],
timeout: u32,
) -> Result<ScsiResult> {
assert_eq!(dir, DataDirection::FromDevice);
if data.len() == 1 {
data[0] = 2;
return Ok(ScsiResult {
status: 0,
bytes_transferred: 1,
sense: [0; 32],
});
}
assert_eq!(data.len(), 16);
assert_eq!(timeout, 5_000);
data.fill(0x25);
Ok(ScsiResult {
status: self.status,
bytes_transferred: self.transferred,
sense: [0; 32],
})
}
}
for (status, transferred) in [(2, 16), (0, 17), (0, usize::MAX)] {
assert_eq!(
get_vid(&mut Response {
status,
transferred
})
.unwrap(),
None
);
}
}
#[test]
fn cancellation_after_identity_aborts_before_register_read() {
use crate::scsi::mock::{MockTransport, Reply, StopFake};
let mut t = StopFake::new(MockTransport::always(Reply::good(renesas_payload())));
t.cancel_after = Some(|cdb| cdb == pioneer_optical::cdb::vendor_identity());
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
assert_eq!(
Renesas::new().unlock(&mut t, &ctx).unwrap_err(),
UnlockError::Transport
);
assert_eq!(t.inner.calls(), 1);
}
#[test]
fn vendor_cdbs_are_the_wire_bytes() {
assert_eq!(
pioneer_optical::cdb::vendor_identity(),
[0x3C, 0x02, 0xF1, 0, 0, 0, 0, 0, 0x30, 0]
);
assert_eq!(
pioneer_optical::cdb::read_memory(0x50_0000, 0x10),
[0x3C, 0x02, 0xB0, 0x50, 0, 0, 0, 0, 0x10, 0]
);
assert_eq!(
pioneer_optical::cdb::knock(),
[0x3B, 0x02, 0x41, 0xA5, 0xAA, 0xAA, 0, 0, 0, 0]
);
assert_eq!(RB_F1_LEN, 48);
}
/// 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.
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, but the hardware status bit is clear.
let out = Renesas::new()
.unlock(&mut t, &ctx)
.expect("no fault")
.expect("renesas → unlocked");
assert!(
out.vid.is_none(),
"clear hardware status must not fabricate a VID"
);
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.
#[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
);
}
}
mod hardware_tests {
use super::*;
use crate::scsi::mock::{MockTransport, Reply};
#[test]
fn reads_fresh_vid_with_status_mask_and_exact_wire_commands() {
let mut t = MockTransport::scripted(
vec![
Reply::good(vec![3]),
Reply::good(vec![0x25; 16]),
Reply::good(vec![7]),
Reply::good(vec![0x42; 16]),
],
Reply::TransportFault,
);
assert_eq!(get_vid(&mut t).unwrap(), Some([0x25; 16]));
assert_eq!(get_vid(&mut t).unwrap(), Some([0x42; 16]));
assert_eq!(
t.cdbs,
vec![
vec![0x3c, 2, 0x92, 0, 0x0d, 0x3c, 0, 0, 1, 0],
vec![0x3c, 2, 0x92, 0, 0x0d, 0x20, 0, 0, 16, 0],
VID_STATUS_CDB.to_vec(),
VID_CDB.to_vec(),
]
);
}
#[test]
fn invalid_status_stops_before_vid() {
for reply in [
Reply::good(vec![0]),
Reply::good(vec![1]),
Reply::zero_transfer(1),
Reply::short(vec![2], 2),
Reply::illegal_request(),
Reply::illegal_request_as_err(),
] {
let mut t = MockTransport::scripted(vec![reply], Reply::TransportFault);
assert_eq!(get_vid(&mut t).unwrap(), None);
assert_eq!(t.calls(), 1);
}
}
#[test]
fn unlock_preserves_oem_success_on_rejections_and_aborts_transport_faults() {
let mut identity = vec![0; 48];
identity[16..19].copy_from_slice(b"SAT");
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, crate::DiscKind::Unknown);
for step in 1..=2 {
for reply in [
Reply::illegal_request(),
Reply::illegal_request_as_err(),
Reply::TransportFault,
] {
let fault = matches!(reply, Reply::TransportFault);
let mut replies = vec![
Reply::good(identity.clone()),
Reply::good(vec![2]),
Reply::good(vec![0x25; 16]),
];
replies[step] = reply;
let mut t = MockTransport::scripted(replies, Reply::TransportFault);
let result = Renesas::new().unlock(&mut t, &ctx);
if fault {
assert_eq!(result.unwrap_err(), UnlockError::Transport);
} else {
let out = result.unwrap().unwrap();
assert_eq!(out.vid, None);
assert_eq!(out.bus_key, None);
}
assert_eq!(t.calls(), step + 1);
}
}
let mut t = MockTransport::scripted(
vec![
Reply::good(identity),
Reply::good(vec![2]),
Reply::good(vec![0x25; 16]),
],
Reply::TransportFault,
);
assert_eq!(
Renesas::new().unlock(&mut t, &ctx).unwrap().unwrap().vid,
Some([0x25; 16])
);
assert_eq!(t.calls(), 3);
assert!(t.cdbs.iter().all(|cdb| cdb[0] == 0x3c));
}
#[test]
fn cancellation_after_status_prevents_vid_command() {
use crate::scsi::mock::StopFake;
let mut t = StopFake::new(MockTransport::always(Reply::good(vec![2])));
t.cancel_after = Some(|cdb| cdb == VID_STATUS_CDB);
assert_eq!(get_vid(&mut t).unwrap_err(), UnlockError::Transport);
assert_eq!(t.inner.calls(), 1);
}
}
-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);
}
}
+184
View File
@@ -31,6 +31,13 @@ pub type Result<T> = std::result::Result<T, ScsiError>;
/// The one capability an unlocker needs from the host: run a raw CDB. `Ok` even
/// on a SCSI sense (inspect `status`); `Err` only on a transport-layer fault.
///
/// **Stop (cancellation).** The host may cancel the operation. After a cancel,
/// [`execute`](Self::execute) refuses with a dead-bus [`ScsiError`] (status 0xFF,
/// no sense), so every unlocker aborts through its existing transport-fault path.
/// The four defaulted methods let a cancellable host keep the drive safe. A host
/// with no cancellation keeps the defaults: plain sleep, no-op critical spans, and
/// cleanup CDBs that run as ordinary CDBs.
pub trait ScsiTransport {
fn execute(
&mut self,
@@ -39,6 +46,165 @@ pub trait ScsiTransport {
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult>;
/// Wait `d` between drive attempts. A cancellable host returns `Err` (dead
/// bus) as soon as the operation is cancelled, instead of finishing the wait.
fn pause(&mut self, d: std::time::Duration) -> Result<()> {
std::thread::sleep(d);
Ok(())
}
/// Open a critical span (see [`CriticalGuard`]): until the matching
/// [`end_critical`](Self::end_critical), `execute` must run even if the
/// operation is cancelled meanwhile. `Err` if it is already cancelled.
fn begin_critical(&mut self) -> Result<()> {
Ok(())
}
/// Close the span opened by [`begin_critical`](Self::begin_critical).
fn end_critical(&mut self) {}
/// Run a clean-up CDB that must reach the drive even after a cancel (the
/// host allows only an AGID release it recorded, never an arbitrary CDB).
fn execute_cleanup(
&mut self,
cdb: &[u8],
direction: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
self.execute(cdb, direction, data, timeout_ms)
}
}
/// A critical span on a transport: [`CriticalGuard::enter`] calls
/// [`ScsiTransport::begin_critical`] and `Drop` calls
/// [`ScsiTransport::end_critical`], on every exit including unwind. Used where
/// abandoning a CDB sequence midway could leave the drive unusable (a firmware
/// upload): a cancel that lands inside the span takes effect when it closes.
pub struct CriticalGuard<'a> {
t: &'a mut (dyn ScsiTransport + 'a),
}
impl<'a> CriticalGuard<'a> {
/// Open the span; `Err` (and no span) if the operation is already cancelled.
pub fn enter(t: &'a mut (dyn ScsiTransport + 'a)) -> Result<Self> {
t.begin_critical()?;
Ok(CriticalGuard { t })
}
}
impl Drop for CriticalGuard<'_> {
fn drop(&mut self) {
self.t.end_critical();
}
}
impl<'a> std::ops::Deref for CriticalGuard<'a> {
type Target = dyn ScsiTransport + 'a;
fn deref(&self) -> &Self::Target {
&*self.t
}
}
impl<'a> std::ops::DerefMut for CriticalGuard<'a> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut *self.t
}
}
/// Timeout for the AGID release CDB (the 5 s every AACS/CSS key CDB uses).
const AGID_RELEASE_TIMEOUT_MS: u32 = 5_000;
/// An allocated AGID (Authentication Grant ID) on a transport. `Drop` releases it
/// with its REPORT KEY key-format 3Fh CDB, sent through
/// [`ScsiTransport::execute_cleanup`] so a cancelled operation still frees it.
/// [`AgidGuard::defuse`] instead hands the still-held AGID to the caller.
/// Exactly one release per allocation: the guard is the only releaser.
pub struct AgidGuard<'a> {
t: &'a mut (dyn ScsiTransport + 'a),
agid: u8,
release: [u8; 12],
armed: bool,
}
impl<'a> AgidGuard<'a> {
/// Guard `agid`. `release` is its REPORT KEY (0xA4) key-format 3Fh CDB, built
/// by the caller so each scheme (AACS key class 2, CSS) keeps its exact bytes.
pub fn new(t: &'a mut (dyn ScsiTransport + 'a), agid: u8, release: [u8; 12]) -> Self {
// SS-7 (evidence, not spec): REPORT KEY key format 3Fh invalidates the AGID
// carried in CDB byte 10 bits 7-6 (libaacs mmc.c; the unlock AGID tests).
debug_assert!(
release[0] == SCSI_REPORT_KEY && release[10] == ((agid & 0x03) << 6) | 0x3F,
"AgidGuard needs this AGID's REPORT KEY format-3Fh CDB"
);
AgidGuard {
t,
agid,
release,
armed: true,
}
}
/// The guarded AGID.
pub fn agid(&self) -> u8 {
self.agid
}
/// Hand the AGID over to the caller, still allocated: nothing is released.
pub fn defuse(mut self) -> u8 {
self.armed = false;
self.agid
}
}
// Manual Debug: the transport is not `Debug`; the AGID and whether it is still
// armed (will be released on drop) are what a failure message needs.
impl std::fmt::Debug for AgidGuard<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AgidGuard")
.field("agid", &self.agid)
.field("armed", &self.armed)
.finish()
}
}
impl Drop for AgidGuard<'_> {
fn drop(&mut self) {
if !self.armed {
return;
}
// Buffer = the CDB's allocation length (AACS: 2), or 8 when it names none (CSS).
let alloc = ((self.release[8] as usize) << 8) | self.release[9] as usize;
let mut buf = vec![0u8; if alloc == 0 { 8 } else { alloc }];
// Best-effort: a failed release is not a failure of the operation.
let r = self.t.execute_cleanup(
&self.release,
DataDirection::FromDevice,
&mut buf,
AGID_RELEASE_TIMEOUT_MS,
);
tracing::debug!(
target: "freemkv::disc",
phase = "agid_released",
agid = self.agid,
ok = r.as_ref().is_ok_and(|r| r.status == 0),
"AGID release (REPORT KEY format 3Fh) sent"
);
}
}
impl<'a> std::ops::Deref for AgidGuard<'a> {
type Target = dyn ScsiTransport + 'a;
fn deref(&self) -> &Self::Target {
&*self.t
}
}
impl<'a> std::ops::DerefMut for AgidGuard<'a> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut *self.t
}
}
/// Parsed SCSI sense (the diagnostic an unlocker reads off a failed command).
@@ -66,6 +232,13 @@ impl ScsiSense {
/// SCSI status byte for a transport-layer failure (bridge crash / disconnect).
pub(crate) const SCSI_STATUS_TRANSPORT_FAILURE: u8 = 0xFF;
/// 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).
pub(crate) fn is_dead_bus(e: &ScsiError) -> bool {
e.status == SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
}
/// SCSI status byte CHECK CONDITION (a drive sense is available). Part of the
/// status contract; currently referenced only by tests asserting the
/// transport-vs-check-condition distinction.
@@ -98,3 +271,14 @@ 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.
#[cfg(test)]
#[allow(dead_code)] // a fixture: each helper is used by a subset of the modules
#[path = "scsi_mock_tests.rs"]
pub(crate) mod mock;
#[cfg(test)]
#[path = "scsi_tests.rs"]
mod tests;
+339
View File
@@ -0,0 +1,339 @@
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)),
}),
}
}
}
/// One transport call as seen by [`StopFake`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Ev {
/// `execute` reached the drive.
Exec(Vec<u8>),
/// `execute` refused because the operation was cancelled.
Refused(Vec<u8>),
/// `execute_cleanup` reached the drive.
Cleanup(Vec<u8>),
Pause(std::time::Duration),
PauseRefused(std::time::Duration),
Begin,
BeginRefused,
End,
}
/// A cancellable host, as libfreemkv's adapter behaves (stop-design-v5 §2.3,
/// §2.4): after a cancel, `execute` refuses with 0xFF / no sense unless a
/// critical span opened BEFORE the cancel is still open; `begin_critical` and
/// `pause` fail; `execute_cleanup` still runs. Wraps a drive emulator `inner`.
pub(crate) struct StopFake<T> {
pub(crate) inner: T,
pub(crate) cancelled: bool,
critical: u32,
/// Cancel right after a CDB matching this predicate reaches the drive
/// (a Stop that lands while that CDB is in flight).
pub(crate) cancel_after: Option<fn(&[u8]) -> bool>,
/// Cancel when the next `pause` starts (a Stop during the wait).
pub(crate) cancel_on_pause: bool,
pub(crate) log: Vec<Ev>,
}
fn refused() -> ScsiError {
ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
}
}
impl<T> StopFake<T> {
pub(crate) fn new(inner: T) -> Self {
StopFake {
inner,
cancelled: false,
critical: 0,
cancel_after: None,
cancel_on_pause: false,
log: Vec::new(),
}
}
/// CDBs that reached the drive through `execute`.
pub(crate) fn execs(&self) -> Vec<&Vec<u8>> {
self.log
.iter()
.filter_map(|e| match e {
Ev::Exec(c) => Some(c),
_ => None,
})
.collect()
}
/// CDBs that reached the drive through `execute_cleanup`.
pub(crate) fn cleanups(&self) -> Vec<&Vec<u8>> {
self.log
.iter()
.filter_map(|e| match e {
Ev::Cleanup(c) => Some(c),
_ => None,
})
.collect()
}
}
impl<T: ScsiTransport> ScsiTransport for StopFake<T> {
fn execute(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
if self.cancelled && self.critical == 0 {
self.log.push(Ev::Refused(cdb.to_vec()));
return Err(refused());
}
self.log.push(Ev::Exec(cdb.to_vec()));
let r = self.inner.execute(cdb, dir, data, timeout_ms);
if self.cancel_after.is_some_and(|p| p(cdb)) {
self.cancelled = true;
}
r
}
fn pause(&mut self, d: std::time::Duration) -> Result<()> {
if self.cancel_on_pause {
self.cancelled = true;
}
if self.cancelled {
self.log.push(Ev::PauseRefused(d));
return Err(refused());
}
self.log.push(Ev::Pause(d)); // no real sleep: tests run in microseconds
Ok(())
}
fn begin_critical(&mut self) -> Result<()> {
if self.cancelled {
self.log.push(Ev::BeginRefused);
return Err(refused());
}
self.critical += 1;
self.log.push(Ev::Begin);
Ok(())
}
fn end_critical(&mut self) {
self.critical -= 1;
self.log.push(Ev::End);
}
fn execute_cleanup(
&mut self,
cdb: &[u8],
dir: DataDirection,
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
self.log.push(Ev::Cleanup(cdb.to_vec()));
self.inner.execute(cdb, dir, data, timeout_ms)
}
}
/// REPORT KEY (0xA4) key format 0x3F (AGID invalidate / release).
pub(crate) fn is_agid_release(cdb: &[u8]) -> bool {
cdb.first() == Some(&SCSI_REPORT_KEY) && cdb.get(10).is_some_and(|b| b & 0x3F == 0x3F)
}
/// REPORT KEY (0xA4) key format 0x00 (AGID allocation).
pub(crate) fn is_agid_alloc(cdb: &[u8]) -> bool {
cdb.first() == Some(&SCSI_REPORT_KEY) && cdb.get(10).is_some_and(|b| b & 0x3F == 0x00)
}
/// SS-7 (evidence): every allocated AGID is released exactly once, through
/// `execute_cleanup`, before the next allocation; `execute` never releases a
/// held AGID (it only invalidates beforehand). Returns (allocations, held at end).
pub(crate) fn agid_ledger(log: &[Ev]) -> (usize, bool) {
let (mut allocs, mut held) = (0usize, false);
for (i, e) in log.iter().enumerate() {
match e {
Ev::Exec(c) if is_agid_alloc(c) => {
assert!(!held, "event {i}: allocated while still holding an AGID");
held = true;
allocs += 1;
}
Ev::Exec(c) if is_agid_release(c) => {
assert!(!held, "event {i}: a held AGID was released via execute");
}
Ev::Cleanup(c) => {
assert!(
is_agid_release(c),
"event {i}: cleanup of a non-release CDB"
);
assert!(held, "event {i}: released an AGID that was not held");
held = false;
}
_ => {}
}
}
(allocs, held)
}
+142
View File
@@ -0,0 +1,142 @@
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);
}
// UT1 (stop-design-v5 §5.2; §2.3 "`enter` calls `begin_critical()?`. `Drop` calls
// `end_critical`"): refused after a cancel; closed on drop AND on unwind.
#[test]
fn critical_guard_refused_after_cancel_and_ends_on_unwind() {
use mock::{Ev, StopFake};
let mut t = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 4])));
{
let mut g = CriticalGuard::enter(&mut t).expect("not cancelled: opens");
g.execute(&[0x3B], DataDirection::ToDevice, &mut [0u8; 4], 0)
.expect("inside the span");
}
assert_eq!(t.log[0], Ev::Begin);
assert_eq!(t.log.last(), Some(&Ev::End), "closed on drop");
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _g = CriticalGuard::enter(&mut t).expect("opens");
panic!("unwind inside the span");
}));
assert!(r.is_err());
assert_eq!(t.log.last(), Some(&Ev::End), "closed on unwind");
t.cancelled = true;
let e = CriticalGuard::enter(&mut t)
.err()
.expect("refused after a cancel");
assert!(is_dead_bus(&e), "a refusal is the dead-bus shape");
assert_eq!(t.log.last(), Some(&Ev::BeginRefused), "no span, so no End");
let begins = t.log.iter().filter(|e| **e == Ev::Begin).count();
let ends = t.log.iter().filter(|e| **e == Ev::End).count();
assert_eq!(begins, ends, "every opened span closed exactly once");
}
// The defaults keep all 32 existing transports compiling and behaving as
// before: pause sleeps, spans are no-ops, cleanup is a plain execute.
#[test]
fn default_stop_methods_preserve_plain_transport_behaviour() {
let mut t = MockTransport::always(Reply::good(vec![0u8; 2]));
let t0 = std::time::Instant::now();
t.pause(std::time::Duration::from_millis(20))
.expect("plain sleep");
assert!(t0.elapsed() >= std::time::Duration::from_millis(20));
t.begin_critical().expect("no-op");
t.end_critical();
t.execute_cleanup(&[0xA4], DataDirection::FromDevice, &mut [0u8; 2], 0)
.expect("plain execute");
assert_eq!(t.cdbs, vec![vec![0xA4]], "cleanup reached the drive once");
}
// SS-7 (evidence): the guard's drop sends its REPORT KEY 3Fh CDB once, via
// execute_cleanup, even after a cancel; `defuse` sends nothing.
#[test]
fn agid_guard_releases_once_via_cleanup_and_defuse_keeps_it() {
use mock::{Ev, StopFake};
let mut rel = [0u8; 12];
rel[0] = SCSI_REPORT_KEY;
rel[10] = (2 << 6) | 0x3F;
let mut t = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 8])));
t.cancelled = true;
drop(AgidGuard::new(&mut t, 2, rel));
assert_eq!(t.log, vec![Ev::Cleanup(rel.to_vec())]);
t.log.clear();
let g = AgidGuard::new(&mut t, 2, rel);
assert_eq!(g.agid(), 2);
assert_eq!(g.defuse(), 2);
assert!(t.log.is_empty(), "defuse hands the AGID over unreleased");
}
+99
View File
@@ -0,0 +1,99 @@
//! 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 compatible firmware unlock (freemkv Raw Read or
//! MT1959). Renesas uses its separate vendor-memory VID reader.
//!
//! 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).
use crate::UnlockError;
use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus};
/// 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
}
/// 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 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)]
#[path = "vid_tests.rs"]
mod tests;
+86
View File
@@ -0,0 +1,86 @@
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);
}
+144
View File
@@ -0,0 +1,144 @@
//! Op-path sleep guard (Stop design v5 §5.8): "Ban `std::thread::sleep` on op
//! paths: unlock `src/{aacs,css,ld}` ... Use `Halt::wait` / `pause` instead."
//!
//! Every production (non-`#[cfg(test)]`) line under `src/aacs`, `src/css` and
//! `src/ld` is scanned for the substring `thread::sleep(` (catching both
//! `std::thread::sleep(` and an already-imported `thread::sleep(`). A raw sleep
//! on these op paths can't be cancelled; [`ScsiTransport::pause`] (which a
//! cancellable host wires to `Halt::wait`) is the sanctioned replacement.
//! `clippy.toml`'s `disallowed-methods` can't scope to a subdirectory, hence
//! this grep test instead of a lint config entry.
use std::path::{Path, PathBuf};
/// The banned substring: matches `std::thread::sleep(` and `thread::sleep(`.
const BANNED: &str = "thread::sleep(";
/// Every `.rs` file under `dir`, recursively.
fn rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
rs_files(&p, out);
} else if p.extension().is_some_and(|x| x == "rs") {
out.push(p);
}
}
}
/// 1-based line numbers of every `thread::sleep(` call in `src` that is NOT
/// inside a `#[cfg(test)]` item or block. Tracks brace depth: a `#[cfg(test)]`
/// attribute starts a "pending" item; if that item opens a `{`, everything
/// until the matching `}` is skipped; if it's a single-line item ending in
/// `,`/`;` with no `{` (e.g. a struct field), only that line is skipped.
fn op_path_hits(src: &str) -> Vec<usize> {
let mut hits = Vec::new();
let mut depth: i32 = 0;
let mut paren_depth: i32 = 0;
let mut pending_cfg_test = false;
let mut skip_until_depth: Option<i32> = None;
for (idx, line) in src.lines().enumerate() {
let lineno = idx + 1;
let trimmed = line.trim();
let opens = line.matches('{').count() as i32;
let closes = line.matches('}').count() as i32;
if let Some(d) = skip_until_depth {
depth += opens - closes;
if depth <= d {
skip_until_depth = None;
}
continue;
}
if trimmed.starts_with("#[cfg(test)]") {
pending_cfg_test = true;
continue;
}
if pending_cfg_test {
paren_depth += line.matches('(').count() as i32 - line.matches(')').count() as i32;
if opens > 0 {
let before = depth;
depth += opens - closes;
skip_until_depth = Some(before);
} else if paren_depth <= 0 && (trimmed.ends_with(',') || trimmed.ends_with(';')) {
pending_cfg_test = false;
}
// Multi-line signature before `{` (or an open param list): stay
// pending, keep scanning.
continue;
}
depth += opens - closes;
if line.contains(BANNED) {
hits.push(lineno);
}
}
hits
}
/// Guard: no raw `thread::sleep(` in production code under `src/aacs`,
/// `src/css` or `src/ld` (Stop design v5 §5.8). Use `pause` / `Halt::wait`.
#[test]
fn no_raw_sleep_on_unlock_op_paths() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let mut files = Vec::new();
for sub in ["src/aacs", "src/css", "src/ld"] {
rs_files(&root.join(sub), &mut files);
}
assert!(
files.len() >= 3,
"the scan must reach real files (found {})",
files.len()
);
let mut hits = Vec::new();
for f in &files {
let src = std::fs::read_to_string(f).expect("read source");
for line in op_path_hits(&src) {
hits.push(format!(
"{}:{line}",
f.strip_prefix(root).unwrap().display()
));
}
}
assert!(
hits.is_empty(),
"raw thread::sleep on an unlock op path (use pause / Halt::wait instead):\n{}",
hits.join("\n")
);
}
/// Self-test: the guard skips `#[cfg(test)]` items/blocks and catches a
/// production-code sleep.
#[test]
fn the_guard_skips_cfg_test_and_catches_production_sleeps() {
let clean = [
// A `mod tests { ... }` block, sleep inside it ignored.
"fn f() {}\n#[cfg(test)]\nmod tests {\n fn g() {\n std::thread::sleep(D);\n }\n}\n",
// A single test-only fn (one-liner body).
"#[cfg(test)]\nfn helper() { std::thread::sleep(D); }\n",
// A cfg(test)-gated struct field (single line, ends in `,`).
"struct S {\n #[cfg(test)]\n x: Option<u8>,\n}\n",
// A cfg(test)-gated statement inside a normal (production) fn.
"fn real() -> X {\n #[cfg(test)]\n if let Some(m) = HOOK.get() {\n thread::sleep(D);\n return m();\n }\n Y\n}\n",
// A multi-line test-only fn signature before the brace.
"#[cfg(test)]\nfn helper(\n a: u8,\n) -> Bar {\n thread::sleep(D);\n Bar\n}\n",
];
for s in &clean {
assert!(op_path_hits(s).is_empty(), "false positive: {s}");
}
let dirty = [
"fn real() {\n std::thread::sleep(D);\n}\n",
"fn real() {\n thread::sleep(D);\n}\n",
];
for s in &dirty {
assert_eq!(op_path_hits(s), vec![2], "missed hit: {s}");
}
}