Compare commits

...
84 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
56 changed files with 13564 additions and 6750 deletions
+31 -61
View File
@@ -1,63 +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
# Dependency updates land on `dev`, never on `main`.
#
# `main` here is a RELEASE POINTER that release.sh moves to each tag. A bot
# commit on it would put work there that no tag contains, which is exactly the
# state that aborted the 1.6.2 cascade at the last step -- so pointing
# Dependabot at main would recreate that failure on a schedule.
updates:
- package-ecosystem: cargo
directory: /
target-branch: dev
schedule:
interval: weekly
open-pull-requests-limit: 5
# One PR per week for the routine bumps instead of one per crate. Eight
# repos times a handful of crates is a volume nobody reads, and an
# unread PR queue is indistinguishable from no updates at all.
groups:
minor-and-patch:
update-types:
- minor
- patch
ignore:
# The freemkv crates depend on each other by GIT TAG, re-pinned by
# release.sh as part of the release commit. Dependabot cannot see that
# cascade, so a PR bumping one of these would fight the release process
# and could pin a version whose tag does not exist yet.
- dependency-name: freemkv-unlock
- dependency-name: libfreemkv
- dependency-name: freemkv-keysources
- dependency-name: freemkv-i18n
- dependency-name: freemkv-engine
# The workflows are now real infrastructure -- the release cascade, the
# cross-platform hash matrix, the disc gate -- so their actions need the same
# attention as the crates.
- package-ecosystem: github-actions
directory: /
target-branch: dev
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
actions:
update-types:
- minor
- patch
ignore:
# NOT a dependency: `dtolnay/rust-toolchain` is versioned by the RUST
# release it installs, and the tag we pin is the toolchain CI is pinned
# to on purpose -- precommit.sh runs the same one locally so a lint that
# passes on a developer's newer default cannot pass CI by accident.
#
# Dependabot reads those tags as semver and proposed 1.97.0 -> 1.100.0,
# a Rust version that does not exist. Every such PR 404s on toolchain
# download across all eight repos, and they regenerate weekly -- eight
# permanently-red PRs that promote.yml then has to special-case when it
# decides whether dev is green.
#
# Bumping the toolchain is a deliberate, all-eight-repos change, made by
# hand together with precommit.sh. There is nothing here for a bot.
- dependency-name: dtolnay/rust-toolchain
- 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*
+112 -17
View File
@@ -10,6 +10,15 @@ on:
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
@@ -17,10 +26,31 @@ 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:
# ── 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
@@ -48,24 +78,36 @@ jobs:
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
- uses: dtolnay/rust-toolchain@1.90.0
# 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@v7
- uses: dtolnay/rust-toolchain@1.90.0
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
with:
shared-key: freemkv-unlock-ci
@@ -73,32 +115,81 @@ 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 gitignored; --locked would fail without it).
# 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@1.90.0
- 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@v5
- 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.
@@ -112,14 +203,16 @@ jobs:
# to agree is what a release candidate is for.
#
# `if` SKIPS the job (it does not queue). A queued job would be far worse
# than a slow one: release.sh's CI gate refuses while any run for the
# commit is still in progress, so a never-scheduled job blocks releases
# silently -- see the note on real-media in qa.yml.
# 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@v7
- uses: dtolnay/rust-toolchain@1.90.0
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
with:
shared-key: freemkv-unlock-ci
@@ -135,14 +228,16 @@ jobs:
# to agree is what a release candidate is for.
#
# `if` SKIPS the job (it does not queue). A queued job would be far worse
# than a slow one: release.sh's CI gate refuses while any run for the
# commit is still in progress, so a never-scheduled job blocks releases
# silently -- see the note on real-media in qa.yml.
# 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@v7
- uses: dtolnay/rust-toolchain@1.90.0
- 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@v7
with:
fetch-depth: 0
- name: Compute commit range
id: range
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
head="${{ github.event.pull_request.head.sha }}"
echo "range=$base..$head" >> "$GITHUB_OUTPUT"
else
before="${{ github.event.before }}"
after="${{ github.sha }}"
# New branch / first push: github.event.before is all-zeros.
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then
echo "range=$after" >> "$GITHUB_OUTPUT"
else
echo "range=$before..$after" >> "$GITHUB_OUTPUT"
fi
fi
- name: Run leak-guard
run: bash ci/leak-guard.sh "${{ steps.range.outputs.range }}"
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: freemkv/.github/.github/workflows/guard-leak.yml@v1
+35 -6
View File
@@ -15,15 +15,36 @@ name: qa
# one being released, which is the exact failure this branch exists to prevent.
#
# What this gate CANNOT cover: `disc://` and real `iso://` need physical media,
# and no hosted runner has an optical drive or the image hoard. Those run on a
# self-hosted runner (see the media job at the end) and are the one leg that
# stays on hardware.
# 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:
@@ -65,6 +86,7 @@ jobs:
# 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:
@@ -74,7 +96,9 @@ jobs:
- uses: actions/checkout@v7
with:
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@1.90.0
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-unlock
@@ -90,7 +114,9 @@ jobs:
- uses: actions/checkout@v7
with:
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@1.90.0
# 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
@@ -105,12 +131,15 @@ jobs:
# 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@1.90.0
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-unlock
+1 -1
View File
@@ -1,3 +1,3 @@
/target
Cargo.lock
.cargo/config.toml
NEXT_SESSION.md
+106
View File
@@ -1,5 +1,111 @@
# 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
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"
+12 -3
View File
@@ -1,8 +1,8 @@
[package]
name = "freemkv-unlock"
version = "1.6.9"
version = "1.8.1"
edition = "2024"
rust-version = "1.90"
rust-version = "1.88"
license = "MIT"
description = "Unlock layer for the freemkv toolchain: the Unlocker contract + self-contained firmware/AACS/CSS unlocker modules. libfreemkv depends on this and dispatches via all_unlockers()."
repository = "https://github.com/freemkv/freemkv-unlock"
@@ -22,7 +22,16 @@ base64 = "0.23"
tracing = "0.1"
num-bigint = "0.5"
num-traits = "0.2"
sha1 = "0.10"
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/.
+10 -1
View File
@@ -36,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/).
-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"
+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"]
+10 -139
View File
@@ -20,6 +20,12 @@ 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,
@@ -58,6 +64,7 @@ impl Error {
Error::AacsKeyRead => 7007,
Error::AacsKeyRejected => 7008,
Error::AacsKeyVerify => 7009,
Error::AacsHostSignVerify => 7016,
Error::AacsNoKeys => 7010,
Error::AacsVidMac => 7011,
Error::AacsVidRead => 7012,
@@ -69,6 +76,7 @@ impl Error {
}
/// 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,
@@ -98,142 +106,5 @@ impl From<crate::scsi::ScsiError> for Error {
}
#[cfg(test)]
mod tests {
use super::*;
/// Every variant has its own stable numeric code. Constructing all 16
/// arms also exercises the `#[allow(dead_code)]` variants that the
/// wired handshake path never builds.
#[test]
fn code_is_stable_per_variant() {
assert_eq!(Error::AacsAgidAlloc.code(), 7001);
assert_eq!(Error::AacsCertRead.code(), 7002);
assert_eq!(Error::AacsCertRejected.code(), 7003);
assert_eq!(Error::AacsCertShort.code(), 7004);
assert_eq!(Error::AacsCertVerify.code(), 7005);
assert_eq!(Error::AacsDataKey.code(), 7006);
assert_eq!(Error::AacsKeyRead.code(), 7007);
assert_eq!(Error::AacsKeyRejected.code(), 7008);
assert_eq!(Error::AacsKeyVerify.code(), 7009);
assert_eq!(Error::AacsNoKeys.code(), 7010);
assert_eq!(Error::AacsVidMac.code(), 7011);
assert_eq!(Error::AacsVidRead.code(), 7012);
assert_eq!(Error::HandshakeRejected.code(), 7013);
assert_eq!(Error::VidUnavailable.code(), 7014);
assert_eq!(
Error::ShortTransfer {
opcode: 0xA4,
expected: 16,
got: 4,
}
.code(),
7015
);
assert_eq!(
Error::Scsi {
opcode: 0xA4,
status: 0x02,
sense: None,
}
.code(),
7099
);
}
/// `scsi_sense` returns the parsed sense for a `Scsi` error carrying one,
/// `None` when the `Scsi` error has none, and `None` for every non-`Scsi`
/// variant.
#[test]
fn scsi_sense_returns_the_parsed_sense_only_for_scsi_variant() {
let sense = ScsiSense {
sense_key: 0x05,
asc: 0x20,
ascq: 0x00,
};
let with_sense = Error::Scsi {
opcode: 0xA4,
status: 0x02,
sense: Some(sense),
};
let sc = with_sense.scsi_sense().unwrap();
assert_eq!(sc.sense_key, sense.sense_key);
assert_eq!(sc.asc, sense.asc);
assert_eq!(sc.ascq, sense.ascq);
let without_sense = Error::Scsi {
opcode: 0xA4,
status: 0xFF,
sense: None,
};
assert!(without_sense.scsi_sense().is_none());
assert!(Error::AacsNoKeys.scsi_sense().is_none());
}
/// `is_scsi_transport_failure` is true only for a `Scsi` error with the
/// transport-failure status AND no sense; a drive CHECK CONDITION and
/// every non-`Scsi` variant are false.
#[test]
fn is_scsi_transport_failure_true_and_false_paths() {
let transport = Error::Scsi {
opcode: 0,
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
};
assert!(transport.is_scsi_transport_failure());
let check_condition = Error::Scsi {
opcode: 0xA4,
status: 0x02,
sense: Some(ScsiSense {
sense_key: 0x05,
asc: 0x20,
ascq: 0x00,
}),
};
assert!(!check_condition.is_scsi_transport_failure());
assert!(!Error::AacsNoKeys.is_scsi_transport_failure());
}
/// `From<ScsiError>` maps a raw transport-layer error into `Error::Scsi`,
/// parsing the sense buffer when present and leaving it `None` otherwise.
#[test]
fn from_scsi_error_maps_status_and_parses_sense() {
let e = Error::from(crate::scsi::ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
match e {
Error::Scsi {
opcode,
status,
sense,
} => {
assert_eq!(opcode, 0);
assert_eq!(status, SCSI_STATUS_TRANSPORT_FAILURE);
assert!(sense.is_none());
}
_ => panic!("expected Error::Scsi"),
}
let mut buf = [0u8; 32];
buf[2] = 0x05;
buf[12] = 0x24;
buf[13] = 0x01;
let e = Error::from(crate::scsi::ScsiError {
status: 0x02,
sense: Some(buf),
});
match e {
Error::Scsi { status, sense, .. } => {
assert_eq!(status, 0x02);
let s = sense.expect("sense parsed");
assert_eq!(s.sense_key, 0x05);
assert_eq!(s.asc, 0x24);
assert_eq!(s.ascq, 0x01);
}
_ => panic!("expected Error::Scsi"),
}
}
}
#[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"),
}
}
+644 -2292
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+160 -156
View File
@@ -8,11 +8,17 @@
mod error;
mod handshake;
/// 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.
@@ -25,176 +31,174 @@ pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
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::*;
/// AES-128-ECB decrypt against the FIPS-197 Appendix B / NIST known-answer
/// test vector: decrypting the published ciphertext with the published key
/// must recover the published plaintext.
#[test]
fn aes_ecb_decrypt_matches_fips197_test_vector() {
let key: [u8; 16] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
0x0E, 0x0F,
];
let ciphertext: [u8; 16] = [
0x69, 0xC4, 0xE0, 0xD8, 0x6A, 0x7B, 0x04, 0x30, 0xD8, 0xCD, 0xB7, 0x80, 0x70, 0xB4,
0xC5, 0x5A,
];
let expected_plaintext: [u8; 16] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD,
0xEE, 0xFF,
];
let out = aes_ecb_decrypt(&key, &ciphertext);
assert_eq!(out, expected_plaintext);
}
/// Decrypting a different ciphertext under the same key must not produce
/// the same plaintext — pins that the function actually decrypts the
/// given block rather than returning a constant.
#[test]
fn aes_ecb_decrypt_varies_with_input() {
let key = [0u8; 16];
let a = aes_ecb_decrypt(&key, &[0u8; 16]);
let b = aes_ecb_decrypt(&key, &[1u8; 16]);
assert_ne!(a, b);
}
fn id() -> crate::DriveId {
crate::DriveId::default()
}
/// `unlock_bus` self-guards on the disc kind: on a non-AACS disc it declines
/// (`NotApplicable`) WITHOUT touching the transport, so iterating it on a
/// CSS/unknown disc is safe.
#[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:?}");
}
}
/// `Default` must delegate to `new()` (there is only one way to build an
/// `AacsCert`, so this pins the two constructors never drift apart).
#[test]
#[allow(clippy::default_constructed_unit_structs)]
fn default_matches_new() {
let _ = AacsCert::default();
let _ = AacsCert::new();
}
/// THE full success path through `Unlocker::unlock_bus` itself: a
/// self-consistent AACS 1.0 drive emulator carries auth + VID + data-key
/// reads all the way to `Ok(Unlocked { .. })`, so the crate's public entry
/// point — not just `run_cert_handshake` underneath it — is proven to wire
/// `volume_id` and `read_data_key` into `Unlocked` correctly.
#[test]
fn unlock_bus_succeeds_end_to_end() {
let mut t = handshake::tests::DriveEmu::new();
t.serve_data_keys = true;
let id = id();
let certs = [host_cert()];
let ctx = UnlockCtx::new(&id, DiscKind::Aacs, &certs);
let unlocked = AacsCert::new()
.unlock_bus(&mut t, &ctx)
.expect("auth + VID + data-key reads all succeed");
assert_eq!(unlocked.vid, Some([0x5Au8; 16]));
assert!(unlocked.bus_key.is_some());
assert!(
!unlocked.drive_unlocked,
"cert path never sets drive_unlocked"
);
}
fn host_cert() -> crate::HostCert {
handshake::tests::dummy_cert()
}
/// 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()
);
}
+3 -87
View File
@@ -22,11 +22,7 @@ impl Error {
/// True if this is a transport-layer SCSI failure (bus dead).
pub fn is_transport_failure(&self) -> bool {
matches!(
self,
Error::Scsi(s)
if s.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && s.sense.is_none()
)
matches!(self, Error::Scsi(s) if crate::scsi::is_dead_bus(s))
}
}
@@ -65,85 +61,5 @@ impl From<Error> for crate::UnlockError {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::UnlockError;
use crate::scsi::{SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE, ScsiError};
/// 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
);
}
/// `is_transport_failure` is false for `CssAuthFailed`, false for a
/// `Scsi` error carrying a sense (a drive rejection, not a dead bus) even
/// at the transport-failure status, and 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"),
}
}
}
#[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"),
}
}
+73 -996
View File
File diff suppressed because it is too large Load Diff
+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
View File
@@ -19,3 +19,13 @@ This module performs only the **non-persistent** unlock — the access state it
sets up lives in drive RAM and is gone on power cycle. The one-time, permanent
drive preparation is the drive owner's own manual step and is **never**
automated here.
## Third-party microcode (`profiles.json`)
`profiles.json` bundles volatile, RAM-only optical-drive microcode
("LibreDrive") authored and owned by the **MakeMKV team** (GuinpinSoft inc.).
Each payload carries its own notice — *"Copyright (c) MakeMKV team, all rights
reserved."* This microcode is **third-party proprietary material and is NOT
covered by this repository's MIT license**; no ownership is claimed. See the
[`NOTICE`](./NOTICE) in this directory and the repository-root `NOTICE`. With
thanks to the MakeMKV team for [LibreDrive](http://www.makemkv.com/libredrive/).
+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 Mt1959Unlocker 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 Mt1959Unlocker 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));
}
+2 -115
View File
@@ -87,118 +87,5 @@ impl From<Error> for crate::UnlockError {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::UnlockError;
use crate::scsi::{SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE, ScsiError};
/// The abort/fall-through boundary, which had no test at all. Catches
/// widening `is_transport_failure` (every failure would abort every rip) or
/// narrowing it (a dead bus would be probed forever).
#[test]
fn only_a_senseless_transport_status_is_a_transport_failure() {
let dead_bus = Error::Scsi {
opcode: 0x3C,
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
};
assert!(dead_bus.is_transport_failure());
assert_eq!(UnlockError::from(dead_bus), UnlockError::Transport);
// A CHECK CONDITION is the DRIVE refusing, not the bus dying.
let refused = Error::Scsi {
opcode: 0x3C,
status: SCSI_STATUS_CHECK_CONDITION,
sense: Some([0u8; 32]),
};
assert!(!refused.is_transport_failure());
assert_eq!(UnlockError::from(refused), UnlockError::NotApplicable);
// The transport-failure status WITH a sense is a drive that reported
// 0xFF, not a bus fault — it must not abort the consumer.
let odd = Error::Scsi {
opcode: 0x3C,
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: Some([0u8; 32]),
};
assert!(!odd.is_transport_failure());
for e in [
Error::ProfileParse,
Error::UnlockFailed,
Error::SignatureMismatch {
expected: [0; 4],
got: [1; 4],
},
] {
assert!(!e.is_transport_failure(), "{e:?} is not a bus fault");
assert_eq!(UnlockError::from(e), UnlockError::NotApplicable);
}
}
/// `Display` is what shows up in logs — every variant must render without
/// panicking and the SCSI variant must carry the opcode/status through.
#[test]
fn display_renders_every_variant() {
assert_eq!(Error::ProfileParse.to_string(), "drive profile parse error");
assert_eq!(Error::UnlockFailed.to_string(), "firmware unlock failed");
assert_eq!(
Error::SignatureMismatch {
expected: [0; 4],
got: [1; 4],
}
.to_string(),
"signature mismatch"
);
assert_eq!(
Error::Scsi {
opcode: 0x3C,
status: 0x02,
sense: None,
}
.to_string(),
"SCSI error (opcode 0x3c, status 0x02)"
);
}
/// `From<ScsiError>` must carry the status AND sense across the seam —
/// collapsing either one destroys the transport-vs-rejection distinction the
/// whole abort/fall-through split rests on.
#[test]
fn scsi_error_conversion_preserves_status_and_sense() {
let e = Error::from(ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
});
assert!(e.is_transport_failure());
let e = Error::from(ScsiError {
status: SCSI_STATUS_CHECK_CONDITION,
sense: Some([0u8; 32]),
});
assert!(!e.is_transport_failure());
assert_eq!(UnlockError::from(e), UnlockError::NotApplicable);
}
/// Error codes are stable and distinct — they are the numeric contract the
/// logs carry instead of English.
#[test]
fn error_codes_are_distinct_and_stable() {
let codes = [
Error::ProfileParse.code(),
Error::UnlockFailed.code(),
Error::SignatureMismatch {
expected: [0; 4],
got: [0; 4],
}
.code(),
Error::Scsi {
opcode: 0,
status: 0,
sense: None,
}
.code(),
];
assert_eq!(codes, [7101, 7102, 7103, 7199]);
}
}
#[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]);
}
+47 -469
View File
@@ -6,13 +6,9 @@
//! 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.
// Compiled under `cfg(test)` as well as the feature: its tests pin the unlock
// wire format, and gating the whole module on a non-default feature meant CI
// (which builds with default features) never ran them.
// `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;
@@ -24,12 +20,8 @@ use crate::scsi::{DataDirection, ScsiTransport};
use crate::{DriveId, UnlockCtx, UnlockError, Unlocked, Unlocker};
// ── Public profile catalog ──────────────────────────────────────────────────
//
// The catalog of drives the MT1959 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};
@@ -54,17 +46,15 @@ pub fn profile(drive_id: &DriveId) -> Option<ProfileMatch> {
#[cfg(feature = "emulation")]
pub use cdb::{UNLOCK_MARKER, is_unlock_read_buffer};
/// The MT1959 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 Mt1959Unlocker;
// 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 Mt1959Unlocker {
pub(crate) fn new() -> Self {
Mt1959Unlocker
impl LdUnlocker {
pub fn new() -> Self {
LdUnlocker
}
}
@@ -72,21 +62,13 @@ impl Mt1959Unlocker {
/// 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(|_| "MT1959")
profile::find_bundled(id).map(|_| "LD")
}
impl Mt1959Unlocker {
/// Read the OEM Volume ID via the matched profile's vendor CDB.
///
/// Takes the profile the caller ALREADY matched rather than re-running
/// `find_bundled`: the catalog is 206 entries and drive-prep used to scan it
/// twice per drive (once here, once in `firmware_unlock`) for the same
/// answer.
///
/// `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 / drive-rejected (the drive is still unlocked,
/// just no VID); `Err` only on a transport fault.
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,
@@ -101,10 +83,9 @@ impl Mt1959Unlocker {
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 a
// non-zero `status`, NOT as `Err`. Without this check a CHECK CONDITION
// would be read as a successful 36-byte response and the caller's own
// zero fill parsed as a Volume ID.
// 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",
@@ -138,16 +119,10 @@ impl Mt1959Unlocker {
}
}
impl Mt1959Unlocker {
/// The MediaTek firmware unlock. Because the MT1959 unlocker 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,
@@ -167,14 +142,9 @@ impl Mt1959Unlocker {
// A transport fault → UnlockError::Transport; any other firmware failure
// → NotApplicable (via From<error::Error>).
mt.init(scsi)?;
// `init` only proves the handshake COMPLETED — `do_unlock` returns Ok on
// a response that carried the per-drive signature but not both firmware
// markers. Only `is_unlocked()` means the drive actually reached the
// extended-access state and serves clear content. Reporting
// `drive_unlocked: true` off `init` alone told the consumer the bus was
// clear on a half-unlocked drive, which suppressed the cert-auth
// fallback and shipped ciphertext at rc=0. A partial unlock must fall
// through to the next unlocker.
// `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",
@@ -183,19 +153,13 @@ impl Mt1959Unlocker {
);
return Err(UnlockError::NotApplicable);
}
// Prime the per-region speed table. Best-effort — it must not fail the
// unlock — but NOT silent: speed calibration can fail completely, and an
// unlogged `let _ =` made a fully-failed calibration indistinguishable
// from a successful one in the rip log.
// 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 speed-calibration miss.
// The rest of this path (`read_oem_vid`) is a no-op for the 140/206
// profiles that carry no `read_vid_cdb`, so it never touches the bus
// again — meaning this swallowed fault was the ONLY dead-bus signal,
// and warn-and-continue turned it into `Ok(Unlocked{drive_unlocked:
// true})`: a dead bus rendered as a fully-unlocked drive (the flagship
// failure-that-looks-like-success). A genuine calibration miss (drive
// sense / short reply) still continues on the default speed table.
// 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",
@@ -212,415 +176,29 @@ impl Mt1959Unlocker {
);
}
let vid = self.read_oem_vid(scsi, &m.profile)?;
Ok(Unlocked {
vid,
bus_key: None,
drive_unlocked: true,
})
Ok(Unlocked { vid, bus_key: None })
}
}
impl Unlocker for Mt1959Unlocker {
impl Unlocker for LdUnlocker {
fn name(&self) -> &'static str {
"MT1959"
"LD"
}
/// Mt1959Unlocker 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`]: Mt1959Unlocker 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(),
}
}
/// 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 = Mt1959Unlocker::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 = Mt1959Unlocker::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 = Mt1959Unlocker::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 = Mt1959Unlocker::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 = Mt1959Unlocker::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 = Mt1959Unlocker::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 err = Mt1959Unlocker::new()
.unlock_features(
&mut t,
&ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")),
)
.expect_err("no profile → NotApplicable");
assert_eq!(err, UnlockError::NotApplicable);
}
/// THE defect-1 test. The drive answers every unlock READ_BUFFER with a
/// response carrying the per-drive signature and the primary firmware marker
/// but NOT the secondary one — `do_unlock` returns `Ok` and `init()`
/// succeeds, yet the drive is NOT in the extended-access state. Reporting
/// `drive_unlocked: true` here suppresses the cert-auth fallback. 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 err = Mt1959Unlocker::new()
.unlock_features(&mut t, &ctx(&id))
.expect_err("a half-unlocked drive must fall through to cert-auth");
assert_eq!(err, UnlockError::NotApplicable);
}
/// The whole-unlock happy path still reports `drive_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_drive_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));
let u = Mt1959Unlocker::new()
.unlock_features(&mut t, &ctx(&id))
.expect("both markers → unlocked");
assert!(u.drive_unlocked);
}
/// THE probe-disc dead-bus test. The drive unlocks fully (both firmware
/// markers), then the bus DIES during disc-speed calibration. `probe_disc`
/// used to be warn-and-continued regardless of the fault, and — because the
/// 140/206 profiles without a `read_vid_cdb` never touch the bus again — a
/// dead bus was reported as `Ok(Unlocked{drive_unlocked:true})`: a dead bus
/// rendered as a successful unlock (the flagship failure-that-looks-like-
/// success). `firmware_unlock` must now abort with `Transport`.
/// MUTATION: reverting the `if e.is_transport_failure()` return in
/// `firmware_unlock` (warn-and-continue), OR flattening the probe loops'
/// transport classification, makes this go red.
#[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 = Mt1959Unlocker::new()
.unlock_features(&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, not a transport fault — `firmware_unlock` must
/// warn-and-continue on the drive's default speed table, still reporting a
/// full unlock. The non-transport sibling of
/// `transport_fault_during_probe_disc_is_transport_not_a_successful_unlock`.
#[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 };
let u = Mt1959Unlocker::new()
.unlock_features(&mut t, &ctx(&id))
.expect("a calibration miss must not fail the whole unlock");
assert!(u.drive_unlocked);
}
/// `unlock_bus` is the same firmware code as `unlock_features` (bus removal
/// comes free with the drive unlock) — pins that the trait method actually
/// delegates rather than being an accidental no-op stub.
#[test]
fn unlock_bus_delegates_to_the_same_firmware_unlock() {
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));
let u = Mt1959Unlocker::new()
.unlock_bus(&mut t, &ctx(&id))
.expect("unlock_bus runs the same firmware handshake");
assert!(u.drive_unlocked);
}
/// Catches classifying a dead bus as "not this unlocker's drive": the very
/// first unlock command faulting at the transport layer must abort the
/// consumer (`Transport`), not fall through to the next unlocker.
#[test]
fn transport_fault_during_unlock_is_transport_not_not_applicable() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::TransportFault);
let err = Mt1959Unlocker::new()
.unlock_features(&mut t, &ctx(&known_vid_drive_id()))
.expect_err("dead bus");
assert_eq!(err, UnlockError::Transport);
}
}
#[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);
}
+4 -38
View File
@@ -1,4 +1,4 @@
//! Platform-specific drive unlock + disc probing (Mt1959Unlocker internals).
//! Platform-specific drive unlock + disc probing (LdUnlocker internals).
pub mod mt1959;
@@ -18,7 +18,7 @@ pub(crate) trait PlatformDriver: Send {
fn is_ready(&self) -> bool;
/// True if the drive is currently in the extended-access state. THE gate
/// `Mt1959Unlocker::firmware_unlock` reports `drive_unlocked` from — `init()`
/// `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 {
@@ -27,39 +27,5 @@ pub(crate) trait PlatformDriver: Send {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scsi::mock::{MockTransport, Reply};
/// A minimal driver that takes every `PlatformDriver` default (`is_unlocked`)
/// so the shim's dispatch and the default's `false` are both exercised —
/// every real driver (`Mt1959`) overrides `is_unlocked`, so without this the
/// default body is dead in coverage even though it is reachable code.
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());
}
}
#[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());
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16 -16
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,14 +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 the firmware loaded (non-fatal — different buffer_id 0x45). There
// is no documented expected payload to compare against, so the outcome is
// TRACED rather than asserted: discarding it with `let _ =` meant a corrupt
// upload proceeded straight into the do_unlock retries with no diagnostic
// whatsoever, which is exactly the silent-failure shape this crate exists to
// avoid.
// 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,
@@ -62,7 +61,7 @@ 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];
match scsi.execute(
match span.execute(
&verify_cdb,
DataDirection::FromDevice,
&mut verify_resp,
@@ -100,10 +99,11 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
}
}
// 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(())
+17 -28
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,11 +13,8 @@ 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];
/// Trace the outcome of a best-effort firmware-upload step and swallow
/// everything EXCEPT a transport fault. These steps are advisory (the unlock
/// retries below are the real gate) but their results were previously thrown
/// away entirely, so a firmware upload could fail end to end and leave nothing
/// in the log to say so — and a dead bus kept the sequence running.
// 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 => {
@@ -60,12 +57,9 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
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);
@@ -83,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 = [
@@ -101,7 +98,7 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
let mut meta_resp = [0u8; 16];
trace_step(
"mt1959b_fw_meta",
scsi.execute(
span.execute(
&read_meta_cdb,
DataDirection::FromDevice,
&mut meta_resp,
@@ -125,28 +122,20 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
let mut data2 = FIRMWARE_EXTRA.to_vec();
trace_step(
"mt1959b_fw_extra",
scsi.execute(&write_extra_cdb, DataDirection::ToDevice, &mut data2, 5_000),
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.
// The result used to be discarded with `let _ =` despite the comment calling
// this a verify step: a corrupt upload proceeded straight to the do_unlock
// retries with no diagnostic. There is no documented expected payload, so
// the outcome is TRACED; only a dead bus aborts.
// 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];
trace_step(
"mt1959b_fw_verify",
scsi.execute(&verify_cdb, DataDirection::None, &mut dummy, 5_000),
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 {
match mt.do_unlock(scsi) {
Ok(_) => {
+59 -472
View File
@@ -3,7 +3,7 @@
use crate::ld::error::{Error, Result};
use serde::Deserialize;
/// The Mt1959Unlocker 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)]
@@ -42,20 +42,9 @@ pub struct Identity {
/// Per-drive profile.
///
/// VISIBILITY: only `identity` and `signature` are public — they are what the
/// catalog is public FOR ("is this drive supported?", and the emulator's
/// impersonation). Everything below them is unlock MECHANISM: the MediaTek
/// firmware image and the per-drive vendor CDB templates. `Cargo.toml` says this
/// crate is never published because it carries drive firmware; re-exporting
/// `DriveProfile` with a `pub firmware: Vec<u8>` handed that firmware, and every
/// vendor CDB, to any downstream crate that called `ld::profile()`. They are
/// `pub(crate)` so the mechanism stays inside the unlocker.
///
/// `allow(dead_code)`: this is the on-disk catalog schema, and several captured
/// CDB templates have no in-crate consumer yet. They were `pub` before, so the
/// compiler saw the (external) use; making them `pub(crate)` is what exposes
/// them as unread. Deleting them would silently drop fields from the format the
/// profile-extraction pipeline emits.
/// 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 {
@@ -72,11 +61,8 @@ pub struct DriveProfile {
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(crate) unlock_init_value: u8,
#[serde(default)]
@@ -113,14 +99,9 @@ pub struct DriveProfile {
pub(crate) speed_calc_table: Option<Vec<u8>>,
}
// Hand-written, REDACTING Debug. `DriveProfile` is `pub` and reachable through
// the public `Profiles` catalog (`ld::bundled` / `ld::profiles`), whose own
// derived `Debug` would recurse into this one. `Cargo.toml` says this crate is
// NEVER published because it carries drive firmware; a derived `Debug` would
// render that firmware image AND every captured per-drive vendor CDB (the unlock
// MECHANISM) straight into any log. Show only the PUBLIC identity + signature
// (what the catalog is public FOR); collapse the firmware to a byte length and
// omit the vendor CDB templates entirely so no mechanism bytes reach a log.
// 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")
@@ -167,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);
@@ -223,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)
@@ -306,18 +278,14 @@ pub fn bundled() -> Option<&'static Profiles> {
CACHE
.get_or_init(|| match load_from_str(BUNDLED_PROFILES) {
Ok(p) => Some(p),
// The `None` is cached PERMANENTLY (correctly — the blob is fixed at
// compile time), which means a bad shipped profiles.json makes
// Mt1959Unlocker report "drive not supported" for every drive for the
// life of the process. Unlogged, that is indistinguishable from a
// genuinely uncataloged drive; log it once, loudly, at the one place
// the parse happens.
// `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 Mt1959Unlocker profile catalog failed to parse; no drive can match"
"bundled LdUnlocker profile catalog failed to parse; no drive can match"
);
None
}
@@ -338,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();
@@ -354,427 +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"
);
}
/// `DriveProfile`'s `Debug` must NOT render the firmware image or the
/// per-drive vendor CDB templates — this crate is unpublished precisely
/// because it carries drive firmware, and the profile is reachable through
/// the public `Profiles` catalog.
/// MUTATION: restoring `#[derive(Debug)]` prints the raw firmware bytes
/// (e.g. the 0xEE marker below) and the CDB bytes, so this 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 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]"
);
}
+113 -171
View File
@@ -1,33 +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
// (`Mt1959Unlocker`) is `pub(crate)` — clients still reach unlockers only through
// [`all_unlockers`]. `aacs` and `css` carry no such public catalog, so they
// stay fully private.
// 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 (which reports a dead
// bus as `Err(UnlockError::Transport)` rather than "not a Renesas drive"); the unlocker impl
// (`Renesis`) is `pub(crate)` — reached only through [`all_unlockers`].
pub mod renesis;
// `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)]
@@ -50,59 +75,70 @@ 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(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 — `Unlocked` is the public carrier that leaves
// this crate, and both `bus_key` (the AACS bus key) and `vid` (the Volume ID
// that feeds VUK derivation) are key material that must never reach a log or a
// test-failure message in plaintext. The `AacsAuth` / `CertHandshake` siblings
// already redact; a derived `Debug` here would print the very bytes they hide.
// Presence is still observable (Some/None) so a log can say WHETHER a key was
// obtained without revealing WHAT it is.
// 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]"))
.field("drive_unlocked", &self.drive_unlocked)
.finish()
}
}
@@ -125,47 +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. "MT1959", "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 (Mt1959Unlocker 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
/// [`Unlocker::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),
}
}
@@ -178,99 +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::Mt1959Unlocker::new()),
Box::new(renesis::Renesis::new()),
Box::new(aacs::AacsCert::new()),
Box::new(css::DvdUnlocker::new()),
]
}
#[cfg(test)]
mod tests {
use super::*;
/// The dispatch order is a documented contract (`firmware → cert → css`) and
/// had NO test: `all_unlockers` is the one place an unlocker is named, and
/// the consumer stops at the first that doesn't decline, so reordering this
/// list silently changes which unlocker claims a drive. Catches a reorder,
/// a rename, and an accidental addition/removal.
#[test]
fn all_unlockers_dispatch_order_is_firmware_then_cert_then_css() {
let names: Vec<&'static str> = all_unlockers().iter().map(|u| u.name()).collect();
assert_eq!(names, vec!["MT1959", "Renesas", "AACS", "DVD"]);
}
/// Every unlocker declines by default rather than claiming a capability it
/// does not provide — and declines WITHOUT touching the transport, so the
/// consumer can iterate the whole registry on any drive.
#[test]
fn unlockers_decline_capabilities_they_do_not_provide() {
struct DeadTransport;
impl scsi::ScsiTransport for DeadTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: scsi::DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> scsi::Result<scsi::ScsiResult> {
panic!("a declining unlocker must not issue a CDB");
}
}
let id = DriveId::default(); // matches no bundled profile
let ctx = UnlockCtx::new(&id, DiscKind::Unencrypted, &[]);
let mut t = DeadTransport;
for u in all_unlockers() {
// AACS/DVD provide no drive features; Mt1959Unlocker/Renesas are the
// feature unlockers but decline an unknown drive identity.
let name = u.name();
if name == "AACS" || name == "DVD" {
assert_eq!(
u.unlock_features(&mut t, &ctx).unwrap_err(),
UnlockError::NotApplicable,
"{name} must not claim drive features"
);
}
if name == "Renesas" {
assert_eq!(
u.unlock_bus(&mut t, &ctx).unwrap_err(),
UnlockError::NotApplicable,
"{name} leaves bus removal to the cert unlocker"
);
}
}
}
/// `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);
}
/// `Unlocked` is the public carrier that leaves the crate, and its `bus_key`
/// and `vid` are key material. Its `Debug` must NOT print those bytes.
/// MUTATION: restoring `#[derive(Debug)]` prints the raw byte arrays, so the
/// distinctive marker bytes appear in the output and this goes red.
#[test]
fn unlocked_debug_redacts_key_material() {
let u = Unlocked {
vid: Some([0xAB; 16]),
bus_key: Some([0xCD; 16]),
drive_unlocked: true,
};
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}");
assert!(s.contains("drive_unlocked: true"));
}
}
#[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);
}
}
-265
View File
@@ -1,265 +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;
/// `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 MediaTek drive rejects it — as a CHECK CONDITION (`Ok` with a
/// non-zero status) or, through a non-conforming transport, as an `Err`
/// carrying a sense — which is `Ok(false)`: not a Renesas drive. This is the
/// definitive Renesas-vs-MediaTek split.
///
/// A TRANSPORT fault is `Err(Transport)`. It used to be folded into the same
/// `false` as a drive rejection, and because this probe is the first SCSI
/// command the unlocker issues, a dead bus read as "not a Renesas drive" — the
/// consumer then walked the remaining unlockers against a bus that was gone.
pub fn is_renesas(scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
let mut buf = [0u8; RB_F1_LEN];
match scsi.execute(&RB_F1_CDB, DataDirection::FromDevice, &mut buf, 5_000) {
Ok(r) => {
let end = RENESAS_MARKER_OFFSET + RENESAS_MARKER.len();
Ok(r.status == 0
&& r.bytes_transferred >= end
&& &buf[RENESAS_MARKER_OFFSET..end] == RENESAS_MARKER)
}
// Only a senseless transport-failure status is a dead bus; anything
// else the transport reports as `Err` is the drive refusing.
Err(e) => {
if e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none() {
tracing::warn!(
target: "freemkv::disc",
phase = "renesas_probe_transport_fault",
"transport fault on the Renesas identity probe; aborting"
);
return Err(UnlockError::Transport);
}
tracing::debug!(
target: "freemkv::disc",
phase = "renesas_probe_rejected",
status = e.status,
"Renesas identity probe rejected by the drive; not a Renesas platform"
);
Ok(false)
}
}
}
/// The Renesas-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 the way a MediaTek drive does: ILLEGAL REQUEST. The
/// sense is what distinguishes this from a dead bus — the old fixture sent
/// `sense: None`, which is the wire shape of a TRANSPORT fault, and the test
/// built on it asserted `NotApplicable`, pinning exactly the
/// misclassification this file had.
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 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);
}
/// A drive REJECTION (ILLEGAL REQUEST, with a sense) is "not a Renesas
/// drive" → fall through to the next unlocker.
#[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);
}
/// The same rejection delivered the way a CONFORMING transport delivers it —
/// `Ok` with a CHECK CONDITION status — must reach the same answer. Catches
/// dropping the `r.status == 0` check, which would read the probe buffer's
/// zero fill as an identity block.
#[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!(
Renesis::new().unlock_features(&mut t, &ctx).unwrap_err(),
UnlockError::Transport
);
}
#[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);
}
}
+179 -262
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.
@@ -99,269 +272,13 @@ pub(crate) fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {
]
}
// ── Test fixture ────────────────────────────────────────────────────────────
//
// The crate-wide mock transport. Before this existed, EVERY mock in the crate
// returned `Ok` with status 0 and a full `bytes_transferred` — so no test could
// ever exercise the three outcomes the contract actually distinguishes, and a
// whole class of transport-fault misclassification bugs sat uncaught (a dead bus
// read as "this unlocker doesn't apply", a zero-filled buffer read as valid
// drive data). This fixture can express all of them, and is the red-before-green
// vehicle for the transport-contract fixes across ld / aacs / css / renesis.
// ── 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
pub(crate) mod mock {
use super::*;
use std::collections::VecDeque;
/// One scripted answer to one `execute()` call.
#[derive(Debug, Clone)]
pub(crate) enum Reply {
/// `Ok` + GOOD status. `payload` is copied into the caller's buffer;
/// `bytes_transferred` defaults to the number of bytes copied.
Data {
payload: Vec<u8>,
bytes_transferred: Option<usize>,
},
/// `Ok` + a NON-ZERO SCSI status (CHECK CONDITION) with a drive sense.
/// Per the contract at [`ScsiTransport::execute`] this is NOT a
/// transport fault — the caller must inspect `status`, and a caller that
/// doesn't will consume the caller's zero-filled buffer as drive data.
Sense {
status: u8,
sense_key: u8,
asc: u8,
ascq: u8,
},
/// `Err` with the transport-failure status and no sense — a genuine
/// transport-layer fault (bridge crash / disconnect). MUST abort.
TransportFault,
/// `Err` carrying a real status + parsed sense. This is what a
/// NON-conforming transport does (libfreemkv's adapter returns `Err` for
/// any non-zero SCSI status); classification must still treat it as a
/// drive rejection, not a dead bus.
ErrWithSense {
status: u8,
sense_key: u8,
asc: u8,
ascq: u8,
},
}
impl Reply {
/// `Ok`, GOOD status, full transfer of `payload`.
pub(crate) fn good(payload: Vec<u8>) -> Reply {
Reply::Data {
payload,
bytes_transferred: None,
}
}
/// `Ok`, GOOD status, but the drive only delivered `n` bytes.
pub(crate) fn short(payload: Vec<u8>, n: usize) -> Reply {
Reply::Data {
payload,
bytes_transferred: Some(n),
}
}
/// `Ok`, GOOD status, ZERO bytes delivered — the buffer the caller reads
/// is entirely its own zero fill.
pub(crate) fn zero_transfer(len: usize) -> Reply {
Reply::Data {
payload: vec![0u8; len],
bytes_transferred: Some(0),
}
}
/// `Ok` + CHECK CONDITION / ILLEGAL REQUEST (0x05, ASC 0x20 invalid
/// command) — the ordinary way a drive refuses a vendor command.
pub(crate) fn illegal_request() -> Reply {
Reply::Sense {
status: SCSI_STATUS_CHECK_CONDITION,
sense_key: 0x05,
asc: 0x20,
ascq: 0x00,
}
}
/// `Err` + CHECK CONDITION / ILLEGAL REQUEST — the same drive refusal as
/// seen through a non-conforming transport.
pub(crate) fn illegal_request_as_err() -> Reply {
Reply::ErrWithSense {
status: SCSI_STATUS_CHECK_CONDITION,
sense_key: 0x05,
asc: 0x20,
ascq: 0x00,
}
}
}
fn sense_buf(sense_key: u8, asc: u8, ascq: u8) -> [u8; 32] {
let mut b = [0u8; 32];
b[2] = sense_key & 0x0F;
b[12] = asc;
b[13] = ascq;
b
}
/// A scripted transport: answers each `execute()` from `script`, falling
/// back to `default` once the script runs out, and records every CDB.
pub(crate) struct MockTransport {
pub(crate) script: VecDeque<Reply>,
pub(crate) default: Reply,
pub(crate) cdbs: Vec<Vec<u8>>,
}
impl MockTransport {
/// Every command gets the same answer.
pub(crate) fn always(reply: Reply) -> Self {
MockTransport {
script: VecDeque::new(),
default: reply,
cdbs: Vec::new(),
}
}
/// The first N commands follow `script`; the rest get `default`.
pub(crate) fn scripted(script: Vec<Reply>, default: Reply) -> Self {
MockTransport {
script: script.into(),
default,
cdbs: Vec::new(),
}
}
/// How many CDBs were issued (lets a test assert a dead bus aborted
/// instead of retrying).
pub(crate) fn calls(&self) -> usize {
self.cdbs.len()
}
}
impl ScsiTransport for MockTransport {
fn execute(
&mut self,
cdb: &[u8],
_direction: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
self.cdbs.push(cdb.to_vec());
let reply = self
.script
.pop_front()
.unwrap_or_else(|| self.default.clone());
match reply {
Reply::Data {
payload,
bytes_transferred,
} => {
let n = payload.len().min(data.len());
data[..n].copy_from_slice(&payload[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: bytes_transferred.unwrap_or(n),
sense: [0u8; 32],
})
}
Reply::Sense {
status,
sense_key,
asc,
ascq,
} => Ok(ScsiResult {
status,
bytes_transferred: 0,
sense: sense_buf(sense_key, asc, ascq),
}),
Reply::TransportFault => Err(ScsiError {
status: SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
}),
Reply::ErrWithSense {
status,
sense_key,
asc,
ascq,
} => Err(ScsiError {
status,
sense: Some(sense_buf(sense_key, asc, ascq)),
}),
}
}
}
}
#[path = "scsi_mock_tests.rs"]
pub(crate) mod mock;
#[cfg(test)]
mod tests {
use super::*;
use mock::{MockTransport, Reply};
/// The fixed-format sense layout the whole crate reads its diagnostics off
/// (key at byte 2 low nibble, ASC at 12, ASCQ at 13). This file had no test
/// at all, so nothing pinned the offsets.
#[test]
fn sense_parses_at_the_fixed_format_offsets() {
let mut buf = [0u8; 32];
buf[2] = 0xF5; // sense key is the LOW nibble only
buf[12] = 0x24;
buf[13] = 0x01;
let s = ScsiSense::from_buf(&buf);
assert_eq!(s.sense_key, 0x05);
assert_eq!(s.asc, 0x24);
assert_eq!(s.ascq, 0x01);
assert!(s.is_illegal_request());
buf[2] = 0x02; // NOT READY
assert!(!ScsiSense::from_buf(&buf).is_illegal_request());
}
/// SET CD SPEED encodes the read speed big-endian at bytes 2-3 and leaves
/// the write speed at 0xFFFF.
#[test]
fn set_cd_speed_encodes_the_read_speed_big_endian() {
let cdb = build_set_cd_speed(0x1234);
assert_eq!(cdb[0], SCSI_SET_CD_SPEED);
assert_eq!([cdb[2], cdb[3]], [0x12, 0x34]);
assert_eq!([cdb[4], cdb[5]], [0xFF, 0xFF]);
assert_eq!(build_set_cd_speed(0xFFFF)[2..4], [0xFF, 0xFF]);
}
/// The fixture itself must honour the contract it exists to test: a drive
/// sense is `Ok` with a non-zero status, a bus fault is `Err`. If this ever
/// 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);
}
}
#[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}");
}
}