Compare commits

...
16 Commits
Author SHA1 Message Date
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
31 changed files with 3700 additions and 1309 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*
+44 -8
View File
@@ -15,6 +15,10 @@ 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
@@ -42,7 +46,7 @@ jobs:
outputs:
v: ${{ steps.msrv.outputs.v }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- id: msrv
shell: bash
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
@@ -56,7 +60,7 @@ jobs:
changelog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Require a CHANGELOG entry for the crate version on main
shell: bash
run: |
@@ -77,7 +81,7 @@ jobs:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
# fmt runs on the pinned MSRV toolchain; clippy runs on stable. Linting on
# stable makes new-lint debt a BLOCKING gate the pinned version would
# otherwise hide. The MSRV pin is installed second so it stays the default
@@ -100,7 +104,7 @@ jobs:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
@@ -111,6 +115,38 @@ 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
@@ -122,7 +158,7 @@ jobs:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
@@ -147,7 +183,7 @@ jobs:
deny:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: EmbarkStudios/cargo-deny-action@v2
with:
@@ -173,7 +209,7 @@ jobs:
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: macos-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
@@ -198,7 +234,7 @@ jobs:
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: windows-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
+1 -1
View File
@@ -9,4 +9,4 @@ concurrency:
jobs:
comment-guard:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
uses: freemkv/.github/.github/workflows/guard-comment.yml@v1
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
+9 -5
View File
@@ -27,6 +27,10 @@ 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;
@@ -36,7 +40,7 @@ jobs:
outputs:
v: ${{ steps.msrv.outputs.v }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- id: msrv
shell: bash
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
@@ -60,7 +64,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Stamp the next rc
@@ -89,7 +93,7 @@ jobs:
os: ['ubuntu-latest', 'macos-latest']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@master
@@ -107,7 +111,7 @@ jobs:
cross-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
path: freemkv-unlock
# clippy runs on stable (matches ci.yml's lint job); build/test stay on
@@ -130,7 +134,7 @@ jobs:
needs: resolve-msrv
runs-on: windows-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
with:
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@master
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## [Unreleased]
### Changed
- 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
Generated
+7 -7
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "freemkv-unlock"
version = "1.7.4"
version = "1.7.7"
dependencies = [
"aes",
"base64",
@@ -233,9 +233,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.10.2"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom",
@@ -406,16 +406,16 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[patch.unused]]
name = "freemkv-engine"
version = "1.7.4"
version = "1.7.7"
[[patch.unused]]
name = "freemkv-i18n"
version = "1.7.4"
version = "1.7.7"
[[patch.unused]]
name = "freemkv-keysources"
version = "1.7.4"
version = "1.7.7"
[[patch.unused]]
name = "libfreemkv"
version = "1.7.4"
version = "1.7.7"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "freemkv-unlock"
version = "1.7.5"
version = "1.7.7"
edition = "2024"
rust-version = "1.88"
license = "MIT"
-184
View File
@@ -1,184 +0,0 @@
# AACS bus authentication handshake
## Flow
1. Invalidate AGIDs → allocate fresh AGID
2. Send host certificate + nonce
3. Receive drive certificate + nonce
4. Receive drive key point + signature, verify
5. Sign host key point, send
6. ECDH: host_priv × drive_key_point → bus key (low 128 bits of x)
7. Read VID or Read Data Keys (encrypted with bus key)
## Why `scsi_read`/`scsi_write` check status at the seam
The contract this crate is built on (`ScsiTransport::execute`) returns `Ok`
on a SCSI sense and `Err` ONLY on a transport-layer fault. These two helpers
used to propagate the `?` and nothing else — so a CHECK CONDITION (`Ok`,
non-zero `status`) handed the caller back its own ZERO-FILLED buffer and
every REPORT/SEND KEY step consumed zeros as valid drive data: a drive
certificate of zeros, a key point of zeros, a Volume ID of zeros, all
reported as success. It has not fired in production only because the sole
transport implementor violates the contract in the opposite direction (it
returns `Err` for any non-zero status). Plugging in a CONFORMING transport
would have turned the whole handshake into a zero-buffer success, so the
status and the transferred length are checked here, at the seam.
## Why `scsi_write` shares this reasoning
Same contract reasoning as `scsi_read`: a drive that REFUSES the host
certificate answers with `Ok` + CHECK CONDITION, and treating that as a
successful send carried the handshake on against a drive that never
accepted it.
## `ec_mul` design notes
**Constant-time tradeoff:** this branches on `scalar.bit(0)` and clones
BigUints per iteration, so its timing is data-dependent on the secret scalar
(the long-term host private key in `ecdsa_sign`, the ephemeral key in ECDH).
This is deliberate: the handshake runs once per disc against a local optical
drive, so throughput and the narrow local-timing surface do not justify
pulling in a vetted constant-time backend. Revisit if this ever signs in a
remote/shared context.
**Cofactor:** both AACS curves used here have cofactor 1, so a point that
lies on the curve is automatically in the prime-order subgroup — no
small-subgroup defense / `n·P == O` check is required for the inputs this is
called with.
## `verify_cert_p256` — provisional AACS 2.0 cert layout
Verifies an AACS 2.0 drive certificate (type 0x11) against an AACS 2.0 LA
key. PROVISIONAL LAYOUT — the byte offsets below are NOT yet confirmed
against a genuine AACS 2.0 cert, and no such cert exists on this machine to
pin them (an exhaustive hunt found only 2.0 *content* certs, a different
format; a real host/drive AKE cert needs a live USB/SCSI capture). What IS
confirmed: first, the P-256/SHA-256 ECDSA primitive and curve constants are
correct (`ecdsa_verify_p256_verifies_a_genuine_aacs2_content_cert` verifies a
real 2.0 content cert under the published CC key); second, the 132-byte
4-byte-header framing MATCHES the step-6 drive-key message (x[4..36] /
y[36..68] / r[68..100] / s[100..132]), the natural 20→32-byte scaling of the
established AACS 1.0 84-byte key-message framing.
What is NOT confirmed: that a *certificate* shares that framing. No known
implementation verifies 2.0 certs against a confirmed layout (the published
spec draft leaves that format ambiguous), and the AACS 2.0 draft architecture
lists the 2.0 host cert (type 0x12) with a 6-byte Host ID + a NEW 4-byte
paired-Device-Key-Set field before the 64-byte public key — implying a
~140–144-byte cert with the pubkey near offset 12–16 and a signed range of
~76–80 bytes, NOT 68. The drive cert (type 0x11) has no paired-DKS field
(~140 bytes, pubkey ~[12..76], signed ~first 76). So the offsets HERE
(pubkey [4..68], signed [..68]) are a best-effort self-consistent
placeholder, chosen to match the confirmed key message; they will almost
certainly move when a real cert lands (see the `#[ignore]` real-cert slot).
This matters for USEFULNESS, not SAFETY: a wrong signed range makes
verification FAIL, and since the caller treats a verify failure as FATAL,
the path fails CLOSED (rejects) rather than trusting anything — never the
reverse.
The `>= 132` guard keeps the `cert[100..132]` slice safe while letting a
full-length cert reach verification (the old `>= 138` guard rejected every
132-byte input unconditionally).
The LA anchor is a PARAMETER, not the compiled-in constant, so a unit test
can drive the whole chain under a test LA keypair (sign a synthetic drive
cert, prove it is accepted / a bad-sig cert is rejected) without the real
anchor's private half, which no one has. Production always threads
`AACS2_LA_PUB_X/Y` from `aacs2_authenticate_p256`.
## Why an unknown drive-cert type must be rejected
This used to be an `if` / `else if` with NO `else`, so an unknown type
(e.g. 0x02) fell straight through: `is_aacs20` stayed false, so the step-6
key-signature check still ran — against `cert_pub_key(drive_cert)`, the
drive's OWN key lifted out of a cert that was never checked against the LA.
A rogue USB bridge could set byte 0 to 0x02, present its own keypair, sign
`host_nonce || drive_key_point`, and the handshake would return Ok with an
attacker-chosen bus key. The chain of trust must not appear to have run
when it did not.
## AACS 2.0 chain-of-trust gate (`aacs2_authenticate_p256_with_agid`)
The SAME shape the AACS 1.0 sibling (`aacs_authenticate_with_agid`)
enforces, now MANDATORY because this path is live. Two distinct rejections,
both BEFORE any drive key is trusted:
**(a) Cert TYPE gate.** An AACS 2.0 drive cert is type 0x11. Anything else
is unexpected on the native P-256 AKE and is REJECTED here, never allowed to
fall through. The earlier `if type==0x11 && !verify` form left NO branch for
a non-0x11 type: a rogue bridge could send a type-0x10 (or any) cert, skip
verification entirely, and still reach the step-6 key check against
`cert_pub_key_p256(drive_cert)` — its OWN key, lifted from a cert the LA
never signed — winning an attacker-chosen bus key. The chain of trust must
not merely LOOK like it ran.
**(b) Cert VERIFY is FATAL.** A type-0x11 cert whose P-256/SHA-256 signature
does not verify under the real 2.0 LA anchor (`AACS2_LA_PUB`, on-curve — see
`la_anchor_keys_are_on_curve`) is REJECTED, not logged-and-continued.
`ecdsa_verify_p256` range/curve-checks every point (`point_on_curve`), so a
forged or off-curve cert key cannot slip past. The previous "non-fatal for
backward compat" behaviour was the exact hole the SECURITY note demanded be
closed before wiring.
## `run_cert_handshake` — cert-agnostic AKE selection
The AACS 1.0 host cert is always present and is tried FIRST: an AACS 2.0
drive accepts a 1.0 host cert for backward compatibility, which is enough to
learn the Volume ID. If the drive REFUSES the 1.0 cert (a plain cert-level
rejection — NOT a dead or wedged bus) and THIS host cert also carries AACS
2.0 (P-256) credentials, fall back to the native P-256 AKE.
This does NOT branch on "do we possess 2.0 certs?" as a feature switch: it
consumes whatever the supplied `HostCert` carries, exactly as the 1.0 path
consumes `hc.certificate`. A host cert without v2 credentials simply has
nothing more to present and keeps its 1.0 error; a host cert WITH them
drives the P-256 AKE the moment the drive asks for it. When a real 2.0 host
cert is supplied at runtime, the P-256 handshake runs — no code change, no
possession gate.
A transport fault on the 1.0 attempt short-circuits the fallback:
re-running the P-256 AKE against a bus that is gone re-issues AGID
invalidation + SEND/REPORT KEY for nothing and buries the real (replug)
cause. The `Err` arm below still classifies it as `Transport`.
## AACS 2.0 (P-256) host-certificate handshake — live-path test plumbing
No genuine 2.0 host cert exists for us to sign with (a side agent is hunting
one; see the `#[ignore]` slot at the end). So the tests below prove the
PLUMBING under a self-generated test LA keypair: a 132-byte drive cert
signed by the test LA is threaded through the SAME code the production
anchor uses, only the anchor differs (a parameter, not a recompile). The
layout mirrored is the code's self-consistent 4-byte-header framing —
header(4)+pub_x(32)+pub_y(32)+sig_r(32)+sig_s(32)=132, LA signs SHA-256 over
cert[..68] — identical to the step-6 drive-key message framing and the
156-byte SEND KEY host-cert payload (4+20 nonce+132 cert).
## `ecdsa_verify_p256_verifies_a_genuine_aacs2_content_cert` — why it matters
SANITY: `ecdsa_verify_p256` + the P-256 curve constants + SHA-256 are correct
against GENUINE AACS 2.0 material. No real 2.0 host/drive AKE cert exists
anywhere on this machine (an exhaustive hunt found none — it needs a live
USB/SCSI AKE capture), but a real AACS 2.0 CONTENT certificate does
(`research/aacs/iso-aacs/CivilWar/Content000.cer`, type 0x10, 232 bytes). It
is signed by the AACS 2.0 *Content Cert* key (P-256/SHA-256, 64-byte
signature over the first `signed_len` bytes) — a DIFFERENT key for a
DIFFERENT purpose than the AKE LA anchor, but it exercises the EXACT same
`ecdsa_verify_p256` primitive `verify_cert_p256` relies on; only the pubkey
and the offsets differ. Proving it verifies here pins that the P-256
ECDSA/SHA-256 math and the `point_on_curve` gate are right, so when a
genuine 2.0 host/drive cert (and its correct signed range) lands, only the
offsets — not the crypto — remain to be fixed.
The CC pubkey is the published AACS 2.0 Content Certificate public key
(P-256). Corrupting any `P256_*` constant, or removing SHA-256, makes this
real-vector verification go red.
## Cert support
- AACS 1.0: custom 160-bit curve, SHA-1, 20-byte keys
- AACS 2.0: an AACS 2.0 drive accepts an AACS 1.0 host cert for backward
compatibility (that yields the Volume ID), and the native P-256/SHA-256
AACS 2.0 AKE is the fallback when the drive refuses the 1.0 cert AND the
supplied host cert carries AACS 2.0 (P-256) credentials. Both paths are
LIVE and share the same chain-of-trust hardening; see
`run_cert_handshake` for the cert-agnostic dispatch.
-16
View File
@@ -1,16 +0,0 @@
# `DriveProfile` field visibility and `allow(dead_code)`
Only `identity` and `signature` are public — they are what the catalog is
public FOR ("is this drive supported?", and the emulator's impersonation).
Everything below them is unlock MECHANISM: the MediaTek firmware image and
the per-drive vendor CDB templates. `Cargo.toml` says this crate is never
published because it carries drive firmware; re-exporting `DriveProfile`
with a `pub firmware: Vec<u8>` handed that firmware, and every vendor CDB,
to any downstream crate that called `ld::profile()`. They are `pub(crate)`
so the mechanism stays inside the unlocker.
`allow(dead_code)`: this is the on-disk catalog schema, and several
captured CDB templates have no in-crate consumer yet. They were `pub`
before, so the compiler saw the (external) use; making them `pub(crate)`
is what exposes them as unread. Deleting them would silently drop fields
from the format the profile-extraction pipeline emits.
-29
View File
@@ -1,29 +0,0 @@
# Module visibility rationale (`src/lib.rs`)
Why each unlocker module is `pub mod` or `mod`, and what's actually public
about it. The consumer never reaches an unlocker impl directly — only through
[`all_unlockers`] — so "public" here means "exposes something other than the
unlocker impl itself."
## `ld`
Public ONLY for its drive-profile catalog (`ld::profiles` / the `Profiles`
object) and, under the `emulation` feature, the unlock-handshake wire format
the bdemu test-emulator needs. The unlocker impl itself (`Mt1959Unlocker`) is
`pub(crate)` — clients still reach unlockers only through `all_unlockers`.
`aacs` and `css` carry no such public catalog, so they stay fully private.
## `renesas`
Public for its `is_renesas` drive-probe, which reports a dead bus as
`Err(UnlockError::Transport)` rather than "not a Renesas drive" — a
distinction the consumer needs before it can trust a negative probe result.
The unlocker impl (`Renesas`) is `pub(crate)` — reached only through
`all_unlockers`.
## `freemkv`
Carries no public catalog (the firmware self-identifies rather than matching
a bundled profile) and no emulation wire format yet, so it stays fully
private — the unlocker impl (`FreemkvUnlocker`) is reached only through
`all_unlockers`.
-9
View File
@@ -1,9 +0,0 @@
# `scsi::mock` — crate-wide mock transport
The crate-wide mock transport. Before this existed, EVERY mock in the crate
returned `Ok` with status 0 and a full `bytes_transferred` — so no test could
ever exercise the three outcomes the contract actually distinguishes, and a
whole class of transport-fault misclassification bugs sat uncaught (a dead bus
read as "this unlocker doesn't apply", a zero-filled buffer read as valid
drive data). This fixture can express all of them, and is the red-before-green
vehicle for the transport-contract fixes across ld / aacs / css / renesas.
-10
View File
@@ -1,10 +0,0 @@
# Why `Unlocked`'s `Debug` impl is hand-written
`Unlocked` is the public carrier that leaves this crate, and both `bus_key`
(the AACS bus key) and `vid` (the Volume ID that feeds VUK derivation) are key
material that must never reach a log or a test-failure message in plaintext.
The `AacsAuth` / `CertHandshake` siblings already redact; a derived `Debug`
here would print the very bytes they hide.
Presence is still observable (`Some`/`None`) so a log can say WHETHER a key
was obtained without revealing WHAT it is.
+1
View File
@@ -76,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,
+930 -245
View File
File diff suppressed because it is too large Load Diff
+313 -104
View File
@@ -93,54 +93,56 @@ impl AacsUnlocker {
handshake::run_cert_handshake(scsi, &self.host_certs)
}
/// Opt IN to arming a freemkv-firmware drive with a named recipe *before*
/// the cert AKE (builder style). OFF by default, so the cert route's default
/// behaviour is unchanged and a non-freemkv / stock drive is never sent a
/// vendor command. When set, `unlock()` probes via
/// [`FirmwareControl::identity`] and applies `recipe` only on a match — e.g.
/// [`ArmRecipe::BypassBd`] (`Ake=null`) pre-authenticates the drive so cert
/// selection is moot; [`ArmRecipe::OemBd`] (`Hrl=skip`) accepts a revoked
/// cert. A non-freemkv drive or a refused recipe is left untouched.
/// 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, detect freemkv firmware and apply the recipe.
/// Best-effort: a non-freemkv drive or a refused recipe is a no-op; only a
/// dead bus (`FirmwareError::Transport`) aborts the whole unlock.
fn maybe_arm(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<(), UnlockError> {
/// 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(());
return Ok(false);
};
let mut fw = FirmwareControl::new(scsi);
match fw.identity() {
Ok(Some(id)) => {
match FirmwareControl::new(scsi).arm(recipe) {
Ok(()) => {
tracing::debug!(
target: "freemkv::disc",
phase = "aacs_arm_freemkv_detected",
version = %id.version,
phase = "aacs_arm_applied",
recipe = ?recipe,
"freemkv firmware detected; arming before the cert AKE"
"freemkv firmware armed before the cert AKE"
);
match fw.arm(recipe) {
Ok(()) => Ok(()),
Err(FirmwareError::Transport) => Err(UnlockError::Transport),
Err(e) => {
tracing::debug!(
target: "freemkv::disc",
phase = "aacs_arm_recipe_refused",
recipe = ?recipe,
error = ?e,
"firmware refused the arm recipe; proceeding with the plain cert AKE"
);
Ok(())
}
}
Ok(recipe.disables_encryption())
}
Ok(None) => Ok(()), // not freemkv — leave the drive untouched
Err(FirmwareError::Transport) => Err(UnlockError::Transport),
Err(_) => Ok(()),
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)
}
}
}
}
@@ -152,7 +154,8 @@ impl Unlocker for AacsUnlocker {
/// 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);
/// 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(
@@ -160,14 +163,19 @@ impl Unlocker for AacsUnlocker {
scsi: &mut dyn ScsiTransport,
ctx: &UnlockCtx,
) -> std::result::Result<Option<Unlocked>, UnlockError> {
if ctx.kind != DiscKind::Aacs || self.host_certs.is_empty() {
// Wrong disc kind, or no host cert to authenticate with — the loop
// falls through to a VID-less / keysource path.
if ctx.kind != DiscKind::Aacs {
return Ok(None);
}
// 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 }));
}
if self.host_certs.is_empty() {
// No host cert to authenticate with — fall through.
return Ok(None);
}
// Opt-in: arm a freemkv-firmware drive before the AKE (no-op by default,
// and on non-freemkv drives). Only a dead bus aborts here.
self.maybe_arm(scsi)?;
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 —
@@ -175,7 +183,7 @@ impl Unlocker for AacsUnlocker {
if h.read_data_key.is_none()
&& let Some(code) = h.read_data_key_err
{
tracing::warn!(
tracing::info!(
target: "freemkv::disc",
phase = "read_data_key_dropped",
error_code = code,
@@ -350,76 +358,161 @@ mod tests {
assert_eq!(u.maybe_arm(&mut t).unwrap_err(), UnlockError::Transport);
}
/// Opting in on a freemkv drive applies the recipe: BypassBd sends
/// Set(Ake, on) and verifies it, BEFORE the handshake would run.
#[test]
fn arm_on_freemkv_drive_applies_the_recipe() {
use crate::firmware::{
ALL_FEATURES, CDB_FEATURE, CDB_STATE, CDB_VERB, Feature, RESP_MAGIC, STATE_OFF,
STATE_PASSTHROUGH, Verb, build_set_cdb,
};
// A minimal freemkv drive: IDENTITY→magic, SET updates state, GET reads it.
struct FwDrive {
encryption: u8,
hrl: u8,
cdbs: Vec<Vec<u8>>,
/// 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(),
}
}
impl ScsiTransport for FwDrive {
fn execute(
&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());
let mut n = 0;
if cdb[CDB_VERB] == Verb::Identity as u8 {
let mut resp = RESP_MAGIC.to_vec();
resp.push(0x01);
resp.extend(std::iter::repeat_n(STATE_PASSTHROUGH, ALL_FEATURES.len()));
n = resp.len().min(data.len());
data[..n].copy_from_slice(&resp[..n]);
} else if cdb[CDB_VERB] == Verb::Set as u8
&& cdb[CDB_FEATURE] == Feature::Encryption as u8
{
self.encryption = cdb[CDB_STATE];
} else if cdb[CDB_VERB] == Verb::Set as u8 && cdb[CDB_FEATURE] == Feature::Hrl as u8
{
self.hrl = cdb[CDB_STATE];
} else if cdb[CDB_VERB] == Verb::Get as u8
&& cdb[CDB_FEATURE] == Feature::Encryption as u8
&& !data.is_empty()
{
data[0] = self.encryption;
n = 1;
} else if cdb[CDB_VERB] == Verb::Get as u8
&& cdb[CDB_FEATURE] == Feature::Hrl as u8
&& !data.is_empty()
{
data[0] = self.hrl;
n = 1;
}
/// 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: 0,
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())
}
let mut t = FwDrive {
encryption: STATE_PASSTHROUGH,
hrl: STATE_PASSTHROUGH,
cdbs: Vec::new(),
};
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
u.maybe_arm(&mut t).expect("armed");
assert_eq!(t.hrl, STATE_OFF, "the recipe skipped revocation (Hrl=off)");
// 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.encryption, STATE_OFF,
"the recipe disabled encryption (Encryption=off)"
t.inner.log,
vec![Ev::Pause(d), Ev::Begin, Ev::End, Ev::Cleanup(rel.to_vec())],
"all four reach the wrapped transport"
);
// IDENTITY, then Set(Hrl,off)+verify, then Set(Encryption,off)+verify.
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);
@@ -427,6 +520,122 @@ mod tests {
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]
+1 -5
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))
}
}
+173 -59
View File
@@ -10,11 +10,11 @@
mod error;
use crate::css::error::{Error, Result, step_err};
use crate::scsi::{DataDirection, ScsiTransport};
use crate::scsi::{AgidGuard, DataDirection, ScsiTransport};
// Issue ONE CSS bus-auth CDB, honouring the transport contract: treats a
// non-zero status as CssAuthFailed and checks bytes_transferred >= min_bytes
// rather than trusting an unfilled buffer. See docs/css-mod.md#css_scsi.
// rather than trusting an unfilled buffer.
fn css_scsi(
scsi: &mut dyn ScsiTransport,
cdb: &[u8],
@@ -202,7 +202,7 @@ impl crate::Unlocker for DvdUnlocker {
fn name(&self) -> &'static str {
// "DVD" names the medium this bus-auth unlocks, not the CSS scheme:
// the bus-auth runs on any DVD regardless of whether the content is
// actually scrambled. See docs/css-mod.md#dvdunlocker-name.
// actually scrambled.
"DVD"
}
@@ -240,7 +240,7 @@ impl crate::Unlocker for DvdUnlocker {
// Probes GET CONFIGURATION current-profile (DVD family 0x0010..=0x001F) so
// DvdUnlocker can self-verify against the drive rather than trust the
// caller's DiscKind. See docs/css-mod.md#mounted_disc_is_dvd.
// caller's DiscKind.
fn mounted_disc_is_dvd(
scsi: &mut dyn ScsiTransport,
) -> std::result::Result<bool, crate::UnlockError> {
@@ -300,7 +300,6 @@ fn unlock_css_reads_inner(scsi: &mut dyn ScsiTransport, _lba: u32) -> Result<()>
tracing::debug!(target: "freemkv::css", agid, "css unlock: bus authentication ok");
// Disc-key REPORT KEY: best-effort only, for firmware that ties part of
// its read-unlock to it; bytes are unused and failure is non-fatal.
// See docs/css-mod.md#disc-key-report-key (7014 bug history).
if let Err(e) = read_disc_key(scsi, agid) {
tracing::debug!(target: "freemkv::css", error_code = e.code(), "css unlock: disc-key REPORT KEY skipped (non-fatal)");
}
@@ -311,12 +310,13 @@ fn unlock_css_reads_inner(scsi: &mut dyn ScsiTransport, _lba: u32) -> Result<()>
// ── Step 1: Bus Authentication ────────────────────────────────────────────
// Runs the CSS bus-auth handshake to set ASF=1 (invalidate AGIDs, allocate,
// challenge/response). Returns the AGID; no bus key is derived. See docs/css-mod.md.
// challenge/response). Returns the AGID, still held (D9); no bus key is derived.
fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result<u8> {
// Invalidate all AGIDs via REPORT KEY format 0x3F. This is also what makes
// an abandoned AGID self-heal — which is not a reason to abandon one.
// an abandoned AGID self-heal — which is not a reason to abandon one. Plain
// `execute`: nothing is held yet, so a Stop refuses these (stop-design-v5 §2.3).
for agid in 0..4u8 {
release_agid(scsi, agid);
invalidate_agid(scsi, agid);
}
// Allocate AGID
@@ -330,28 +330,37 @@ fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result<u8> {
)?;
let agid = (buf[7] >> 6) & 0x03;
// From here on we hold the AGID; release it on any failure (see
// [`release_agid`]) instead of abandoning it.
let r = authenticate_with_agid(scsi, agid);
if r.is_err() {
release_agid(scsi, agid);
}
r.map(|()| agid)
// From here on we hold the AGID: a failure drops the guard, which releases
// it even after a Stop (SS-7, evidence: libaacs mmc.c AGID invalidation).
let mut guard = AgidGuard::new(scsi, agid, agid_release_cdb(agid));
authenticate_with_agid(&mut *guard, agid)?;
// D9 (stop-design-v5 §2.3 "defused on success, so it is still held for
// `read_disc_key`"): hand the AGID over unreleased.
Ok(guard.defuse())
}
/// Release an AGID (REPORT KEY format 0x3F). Best-effort: a failure to release
/// is not a failure of the operation that is already failing.
fn release_agid(scsi: &mut dyn ScsiTransport, agid: u8) {
/// REPORT KEY key format 0x3F for `agid` (invalidate / release it).
fn agid_release_cdb(agid: u8) -> [u8; 12] {
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
cdb[10] = (agid << 6) | 0x3F;
cdb
}
/// Best-effort invalidate of an AGID this session does not hold (the
/// pre-allocation sweep).
fn invalidate_agid(scsi: &mut dyn ScsiTransport, agid: u8) {
let mut buf = [0u8; 8];
let _ = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000);
let _ = scsi.execute(
&agid_release_cdb(agid),
DataDirection::FromDevice,
&mut buf,
5_000,
);
}
/// The challenge-response half of [`establish_authenticated_session`], with the
/// AGID already allocated. Split out so its caller can release the AGID on any
/// failure without a Drop guard or a release at each early return.
/// AGID already allocated and held by the caller's [`AgidGuard`].
fn authenticate_with_agid(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> {
// Host sends challenge. The spec wants a fresh per-session random nonce,
// not a fixed constant — a predictable challenge weakens the bus-auth
@@ -438,7 +447,7 @@ fn authenticate_with_agid(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()>
// ── Step 2: Disc Key ──────────────────────────────────────────────────────
// Issues READ DVD STRUCTURE format 0x02 (opcode 0xAD) purely for its
// bus-auth side effect; returned bytes are unused. See docs/css-mod.md#read_disc_key.
// bus-auth side effect; returned bytes are unused.
fn read_disc_key(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> {
// READ DVD STRUCTURE, format 0x02 (disc key), 2048+4 bytes
let alloc_len: u16 = 2048 + 4;
@@ -464,7 +473,7 @@ fn read_disc_key(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> {
fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
// key_type indexes PERM_CHALLENGE ([_;3]); variant indexes VARIANTS/
// PERM_VARIANT ([_;32]). Real (not debug-only) asserts catch an OOB index even
// in release; cost is nil on this once-per-disc path. See docs/css-mod.md.
// in release; cost is nil on this once-per-disc path.
assert!(key_type < 3, "crypt_key: key_type out of range");
assert!((variant as usize) < 32, "crypt_key: variant out of range");
let perm = &PERM_CHALLENGE[key_type];
@@ -620,7 +629,7 @@ mod tests {
// 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). See docs/css-mod.md#key-guard.
// literal or `_fp` field is allowed).
#[test]
fn no_key_bytes_in_instrumentation() {
use std::path::Path;
@@ -741,24 +750,6 @@ mod tests {
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
}
// Fix 9: the bounds checks are real `assert!`s, not `debug_assert!`s (stripped
// in release), so an out-of-range index is a clean panic in EVERY build.
// `catch_unwind` holds regardless of the test's build profile.
#[test]
fn crypt_key_asserts_out_of_range_indices() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
// key_type must be < 3.
assert!(
std::panic::catch_unwind(|| crypt_key(3, 0, &challenge)).is_err(),
"key_type out of range must panic (not index OOB)"
);
// variant must be < 32.
assert!(
std::panic::catch_unwind(|| crypt_key(0, 32, &challenge)).is_err(),
"variant out of range must panic (not index OOB)"
);
}
#[test]
fn crypt_key_varies_by_type() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
@@ -776,7 +767,7 @@ mod tests {
// ── CSS constant-table integrity ───────────────────────────────────────
// Each PERM_CHALLENGE row must be a permutation of indices 0..10; a
// non-permutation would drop/duplicate bytes. See docs/css-mod.md#perm-challenge-rows.
// non-permutation would drop/duplicate bytes.
#[test]
fn perm_challenge_rows_are_permutations() {
for (row, perm) in PERM_CHALLENGE.iter().enumerate() {
@@ -795,7 +786,6 @@ mod tests {
// Each PERM_VARIANT row must map the 32 variants to 32 distinct 5-bit
// values; a collision would make two variants indistinguishable.
// See docs/css-mod.md#perm-variant-rows.
#[test]
fn perm_variant_rows_are_permutations_of_0_31() {
for (row, perm) in PERM_VARIANT.iter().enumerate() {
@@ -816,7 +806,7 @@ mod tests {
// ── crypt_key behaviour ────────────────────────────────────────────────
// crypt_key's result must depend on every challenge byte: flipping any
// single byte must change the output. See docs/css-mod.md#crypt-key-byte-dependence.
// 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];
@@ -834,7 +824,7 @@ mod tests {
// 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. See docs/css-mod.md.
// 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];
@@ -849,21 +839,19 @@ mod tests {
}
}
// crypt_key's `key_type < 3` debug_assert must fire for key_type 3
// (which would otherwise index PERM_CHALLENGE out of bounds).
// See docs/css-mod.md#crypt-key-preconditions.
// 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]
#[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` debug_assert must fire for variant 32
// (which would otherwise index VARIANTS/PERM_VARIANT out of bounds).
// See docs/css-mod.md#crypt-key-preconditions.
// 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]
#[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);
@@ -895,7 +883,6 @@ mod tests {
// 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.
// See docs/css-mod.md#cdb-builders.
#[test]
fn report_key_cdb_masks_format_to_6_bits() {
let cdb = report_key_cdb(0, 0xFF, 8);
@@ -904,7 +891,7 @@ mod tests {
// 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. See docs/css-mod.md#cdb-builders.
// 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
@@ -921,7 +908,6 @@ mod tests {
// 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).
// See docs/css-mod.md#cdb-builders.
#[test]
fn report_key_cdb_alloc_len_is_16bit_big_endian() {
let cdb = report_key_cdb(0, 0x00, 0x0804);
@@ -1012,7 +998,7 @@ mod tests {
// 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). See docs/css-mod.md.
// let the consumer keep probing a dead bus).
#[test]
fn transport_fault_probing_for_a_dvd_aborts() {
let id = DriveId::default();
@@ -1039,7 +1025,7 @@ mod tests {
// Defect-2 regression: a DVD is mounted, then the bus dies mid
// bus-auth — must abort as Transport, not collapse to CssAuthFailed /
// NotApplicable. See docs/css-mod.md.
// NotApplicable.
#[test]
fn transport_fault_during_bus_auth_aborts() {
let id = DriveId::default();
@@ -1418,4 +1404,132 @@ mod tests {
"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"
);
}
}
+141 -108
View File
@@ -1,19 +1,7 @@
//! firmware — an ergonomic, typed Rust API for the freemkv-firmware
//! vendor-command grammar.
//! Typed mirror of the freemkv firmware vendor-command ABI.
//!
//! # This file is a MIRROR of the wire contract
//!
//! The single source of truth for the freemkv vendor-command ABI is
//! `freemkv-firmware/crates/freemkv-fw/src/abi.rs`. The firmware tool and this
//! host crate share **no code** — this module is a hand-kept mirror, so the
//! numeric values below (verbs, features, state bytes, CDB field offsets) ARE
//! the contract and MUST NOT drift. [`tests`] pins every pinned value so a drift
//! against `abi.rs` is caught at `cargo test` time.
//!
//! # Grammar: `verb [feature] [state]`
//!
//! A freemkv command hijacks the standard SCSI `READ BUFFER` (`0x3C`) opcode via
//! an OEM-unused mode plus a two-byte knock, then carries a small verb grammar:
//! 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
@@ -26,17 +14,10 @@
//! cdb[9] = control (0)
//! ```
//!
//! [`Verb::DumpAll`] is the exception: it packs a 32-bit RAM address big-endian
//! into `cdb[5..9]` and always returns a fixed [`MEMREAD_LEN`]-byte window.
//!
//! Every feature defaults to [`STATE_PASSTHROUGH`] (firmware does not touch the
//! subsystem → byte-identical to OEM); [`Verb::Reset`] with [`RESET_TO_OEM`]
//! returns every feature to passthrough (and, firmware ≥0.8.3, blanks the NV block
//! so the drive is traceless), [`RESET_TO_DEFAULTS`] restores the baked create-time
//! defaults, while [`RESET_TO_FLASH`] reloads the last
//! [`Verb::Save`]d config block.
//! 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};
use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus};
// ── Wire constants (mirror of abi.rs) ────────────────────────────────────────
@@ -54,6 +35,12 @@ 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]`).
@@ -142,7 +129,8 @@ pub const RESET_TO_OEM: u8 = 0xFF;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Verb {
/// Status/ping — returns [`RESP_MAGIC`] + version + the feature-state table.
/// 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,
@@ -210,18 +198,13 @@ pub enum Feature {
/// [`REGION_BD_A`]/`_B`/`_C` (`0x0A`/`0x0B`/`0x0C`) = force BD region A/B/C;
/// [`REGION_FREE`] (`0x0F`) = region-free (any disc plays).
Region = 0x02,
/// UHD (AACS 2.0) capability gate. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM (as
/// shipped); [`STATE_OFF`] (`0x00`) = No (refuse UHD discs); [`STATE_ON`]
/// (`0x01`) = Yes (accept UHD — the mode gate is neutralized so the drive
/// engages UHD discs). On the byte-extraction classifier (e.g. BU40N) the No
/// (OFF) leg currently reads back but behaves == OEM (boot-safe no-op, pending
/// deeper RE) — do NOT treat OFF as a proven UHD disable there.
Uhd = 0x03,
/// Blu-ray (AACS 1.0) capability gate. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM (BD
/// engaged as shipped); [`STATE_OFF`] (`0x00`) = No (force-refuse BD discs — the
/// drive raises its own `6F` refusal sense); [`STATE_ON`] (`0x01`) = Yes (accept
/// BD — an enable direction, not a no-op).
Bd = 0x04,
/// 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`]
@@ -237,13 +220,12 @@ pub enum Feature {
Encryption = 0x06,
}
/// The set of all features, in the canonical order the IDENTITY feature-state
/// table serialises them (and the order [`FirmwareControl::states`] probes).
pub const ALL_FEATURES: [Feature; 6] = [
/// 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::Uhd,
Feature::Bd,
Feature::Unrestricted,
Feature::Hrl,
Feature::Encryption,
];
@@ -416,6 +398,11 @@ 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 {
@@ -430,13 +417,6 @@ pub enum FirmwareError {
/// Result of a firmware command.
pub type Result<T> = std::result::Result<T, FirmwareError>;
/// Whether a transport error is a genuine dead bus (a senseless
/// transport-failure status) rather than a drive rejection surfaced through a
/// non-conforming transport (`Err` carrying a sense).
fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
}
// ── Parsed replies ───────────────────────────────────────────────────────────
/// The current state of every feature, indexed by [`Feature`].
@@ -446,10 +426,8 @@ pub struct FeatureStates {
pub speed: u8,
/// [`Feature::Region`] state.
pub region: u8,
/// [`Feature::Uhd`] state.
pub uhd: u8,
/// [`Feature::Bd`] state.
pub bd: u8,
/// [`Feature::Unrestricted`] state.
pub unrestricted: u8,
/// [`Feature::Hrl`] state.
pub hrl: u8,
/// [`Feature::Encryption`] state (the consolidated cert/bus bypass).
@@ -462,8 +440,7 @@ impl FeatureStates {
FeatureStates {
speed: STATE_PASSTHROUGH,
region: STATE_PASSTHROUGH,
uhd: STATE_PASSTHROUGH,
bd: STATE_PASSTHROUGH,
unrestricted: STATE_PASSTHROUGH,
hrl: STATE_PASSTHROUGH,
encryption: STATE_PASSTHROUGH,
}
@@ -474,8 +451,7 @@ impl FeatureStates {
match feature {
Feature::Speed => self.speed,
Feature::Region => self.region,
Feature::Uhd => self.uhd,
Feature::Bd => self.bd,
Feature::Unrestricted => self.unrestricted,
Feature::Hrl => self.hrl,
Feature::Encryption => self.encryption,
}
@@ -485,8 +461,7 @@ impl FeatureStates {
match feature {
Feature::Speed => self.speed = state,
Feature::Region => self.region = state,
Feature::Uhd => self.uhd = state,
Feature::Bd => self.bd = state,
Feature::Unrestricted => self.unrestricted = state,
Feature::Hrl => self.hrl = state,
Feature::Encryption => self.encryption = state,
}
@@ -494,30 +469,50 @@ impl FeatureStates {
}
/// A parsed [`Verb::Identity`] reply. Only present when the reply led with
/// [`RESP_MAGIC`]. The firmware answers IDENTITY with a human banner
/// `freemkv <version>` (NUL-padded) — there is NO binary state table in the
/// reply, so this carries only the version string; read the live feature states
/// [`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 {
/// Firmware version string, the banner text after [`RESP_MAGIC`] up to the
/// first NUL, trimmed (e.g. `"0.7.1"`).
/// 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). Layout: `RESP_MAGIC` (7) +
/// ASCII version banner, NUL-terminated/padded.
/// [`RESP_MAGIC`] (i.e. not freemkv firmware).
pub fn parse(bytes: &[u8]) -> Option<Self> {
if !verify_response(bytes) {
return None;
}
let tail = &bytes[RESP_MAGIC.len()..];
let end = tail.iter().position(|&b| b == 0).unwrap_or(tail.len());
let version = String::from_utf8_lossy(&tail[..end]).trim().to_string();
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
@@ -533,10 +528,24 @@ pub enum ArmRecipe {
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.
@@ -546,13 +555,14 @@ const CMD_TIMEOUT_MS: u32 = 5_000;
/// [`ScsiTransport`]. Borrows the transport for the lifetime of the control.
///
/// ```no_run
/// # use freemkv_unlock::firmware::{FirmwareControl, Feature};
/// # fn demo(scsi: &mut dyn freemkv_unlock::scsi::ScsiTransport) {
/// # use freemkv_unlock::firmware::{FirmwareControl, FirmwareError};
/// # fn demo(scsi: &mut dyn freemkv_unlock::scsi::ScsiTransport) -> Result<(), FirmwareError> {
/// let mut fw = FirmwareControl::new(scsi);
/// if fw.is_freemkv().unwrap_or(false) {
/// // Encryption off on a UHD disc, revocation ignored:
/// let _ = fw.arm_oem_uhd();
/// }
/// // 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> {
@@ -584,9 +594,8 @@ impl<'a> FirmwareControl<'a> {
}
/// Send IDENTITY and parse the magic + version banner.
/// `Ok(None)` if the drive answered but the reply lacked [`RESP_MAGIC`] (not
/// freemkv firmware); `Err(Transport)` only on a dead bus, `Err(Rejected)`
/// if the drive refused the command outright (also "not freemkv").
/// `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) {
@@ -661,7 +670,9 @@ impl<'a> FirmwareControl<'a> {
self.exec_in(&cdb).map(|_| ())
}
/// SET a feature, then GET it back and confirm the state stuck.
/// 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)?;
@@ -676,6 +687,15 @@ impl<'a> FirmwareControl<'a> {
}
}
/// 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();
@@ -697,24 +717,15 @@ impl<'a> FirmwareControl<'a> {
// ── Typed setters ────────────────────────────────────────────────────────
/// Force the UHD (AACS 2.0) capability gate on (drive engages UHD discs).
pub fn enable_uhd(&mut self) -> Result<()> {
self.set(Feature::Uhd, STATE_ON)
/// 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 UHD capability gate off.
pub fn disable_uhd(&mut self) -> Result<()> {
self.set(Feature::Uhd, STATE_OFF)
}
/// Force the Blu-ray (AACS 1.0) capability gate on.
pub fn enable_bd(&mut self) -> Result<()> {
self.set(Feature::Bd, STATE_ON)
}
/// Force the Blu-ray capability gate off (drive refuses BD discs). Sends the
/// uniform [`STATE_OFF`] (`0x00`) — an unarmed image boots to [`STATE_PASSTHROUGH`]
/// (`0xFF`, OEM) via the firmware power-on hook, so `0x00` no longer collides
/// with the boot value (the retired `0x02` sentinel is gone).
pub fn disable_bd(&mut self) -> Result<()> {
self.set(Feature::Bd, STATE_OFF)
/// 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).
@@ -738,11 +749,12 @@ impl<'a> FirmwareControl<'a> {
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::Rejected`]
/// for an out-of-range region rather than sending a malformed 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::Rejected);
return Err(FirmwareError::InvalidArgument);
}
self.set(Feature::Region, REGION_DVD_BASE + region)
}
@@ -752,6 +764,8 @@ impl<'a> FirmwareControl<'a> {
}
// ── 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.
@@ -761,17 +775,19 @@ impl<'a> FirmwareControl<'a> {
/// 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: `Uhd = 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.
/// 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.set_verify(Feature::Uhd, STATE_ON)?;
self.require_supported()?;
self.set_verify(Feature::Unrestricted, STATE_ON)?;
self.set_verify(Feature::Hrl, STATE_OFF)?;
self.set_verify(Feature::Encryption, STATE_OFF)
}
@@ -782,17 +798,19 @@ impl<'a> FirmwareControl<'a> {
/// 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: `Uhd = on` (engage the UHD disc), `Encryption = off` (the
/// consolidated cert/bus bypass), and `Hrl = skip` (revocation off).
/// 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.set_verify(Feature::Uhd, STATE_ON)?;
self.require_supported()?;
self.set_verify(Feature::Unrestricted, STATE_ON)?;
self.set_verify(Feature::Hrl, STATE_OFF)?;
self.set_verify(Feature::Encryption, STATE_OFF)
}
@@ -801,7 +819,9 @@ impl<'a> FirmwareControl<'a> {
///
/// 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)?;
@@ -828,6 +848,19 @@ impl<'a> FirmwareControl<'a> {
}
}
/// 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;
+292 -79
View File
@@ -24,12 +24,12 @@ fn verb_values_match_abi() {
fn feature_values_match_abi() {
assert_eq!(Feature::Speed as u8, 0x01);
assert_eq!(Feature::Region as u8, 0x02);
assert_eq!(Feature::Uhd as u8, 0x03);
assert_eq!(Feature::Bd as u8, 0x04);
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(), 6);
assert_eq!(ALL_FEATURES.len(), 5);
}
#[test]
@@ -173,14 +173,60 @@ fn region_state_bytes() {
#[test]
fn identity_parse_reads_version_banner() {
// The firmware answers IDENTITY with the ASCII banner `freemkv <ver>`,
// NUL-padded — there is no binary state table.
let mut payload = Vec::new();
payload.extend_from_slice(RESP_MAGIC);
payload.extend_from_slice(b" 0.7.1\0");
payload.resize(64, 0); // NUL-pad to the allocation window
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]
@@ -190,9 +236,9 @@ fn identity_parse_none_without_magic() {
// ── A stateful firmware mock ─────────────────────────────────────────────────
/// A mock freemkv drive: answers IDENTITY with the `freemkv <ver>` banner, GET
/// with the requested feature's state, applies SET, and RESET → all-passthrough.
/// Records every CDB so tests can assert the exact command order.
/// 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
@@ -200,20 +246,30 @@ fn identity_parse_none_without_magic() {
struct FwMock {
version: &'static str,
states: FeatureStates,
/// The saved flash config block: SAVE snapshots `states` into it, RESET
/// with RESET_TO_FLASH reloads it, RESET_TO_OEM forces all-passthrough.
saved: 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.6.6",
version: "0.9.2",
states: FeatureStates::all_passthrough(),
saved: FeatureStates::all_passthrough(),
defaults,
saved: None,
is_freemkv: true,
ignore_reset: false,
cdbs: Vec::new(),
}
}
@@ -227,6 +283,11 @@ impl FwMock {
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 {
@@ -251,21 +312,23 @@ impl ScsiTransport for FwMock {
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 {
// The `freemkv <ver>` banner, NUL-terminated.
let mut resp = Vec::new();
resp.extend_from_slice(RESP_MAGIC);
resp.push(b' ');
resp.extend_from_slice(self.version.as_bytes());
resp.push(0);
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 = n;
}
// non-freemkv: GOOD status but no magic (buffer stays zero)
if !self.is_freemkv {
transferred = data.len();
}
transferred = data.len();
}
v if v == Verb::Get as u8 => {
let f = Self::feature_of(cdb[CDB_FEATURE]);
@@ -278,17 +341,16 @@ impl ScsiTransport for FwMock {
let f = Self::feature_of(cdb[CDB_FEATURE]);
self.states.set(f, cdb[CDB_STATE]);
}
v if v == Verb::Reset as u8 => {
// The mode rides in the state slot (cdb[6]): to-OEM forces
// all-passthrough, to-flash reloads the saved config block.
self.states = if cdb[CDB_STATE] == RESET_TO_FLASH {
self.saved
} else {
FeatureStates::all_passthrough()
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 = self.states;
self.saved = Some(self.states);
}
v if v == Verb::DumpAll as u8 => {
for (i, b) in data.iter_mut().enumerate() {
@@ -315,8 +377,8 @@ fn identity_detects_freemkv_and_issues_identity_cdb() {
{
let mut fw = FirmwareControl::new(&mut m);
let id = fw.identity().expect("no fault").expect("is freemkv");
assert_eq!(id.version, "0.6.6");
// States no longer ride in IDENTITY — read them via GET.
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"));
}
@@ -339,6 +401,37 @@ fn identity_none_when_drive_rejects() {
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);
@@ -346,6 +439,40 @@ fn identity_transport_fault_propagates() {
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();
@@ -372,14 +499,22 @@ fn reset_sends_reset_cdb_and_clears_state() {
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_sends_the_defaults_mode_cdb() {
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]
@@ -425,10 +560,11 @@ fn states_probes_every_feature_in_order() {
}
assert_eq!(states.speed, SPEED_MAX);
assert_eq!(states.hrl, STATE_ON);
// Six GETs, one per feature, in ALL_FEATURES order (Bus id 0x07 retired).
assert_eq!(m.cdbs.len(), 6);
// 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, 0x04, 0x05, 0x06]);
assert_eq!(features, vec![0x01, 0x02, 0x03, 0x05, 0x06]);
assert!(m.verbs().iter().all(|&v| v == Verb::Get as u8));
}
@@ -452,8 +588,7 @@ fn typed_setters_issue_expected_set_cdbs() {
let mut m = FwMock::new();
{
let mut fw = FirmwareControl::new(&mut m);
fw.enable_uhd().unwrap();
fw.disable_bd().unwrap();
fw.enable_unrestricted().unwrap();
fw.skip_hrl().unwrap();
fw.disable_encryption().unwrap();
fw.region_free().unwrap();
@@ -461,25 +596,31 @@ fn typed_setters_issue_expected_set_cdbs() {
fw.force_region_dvd(2).unwrap();
fw.unlock_speed().unwrap();
}
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Uhd, STATE_ON));
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Bd, STATE_OFF));
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[2], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(m.cdbs[3], build_set_cdb(Feature::Encryption, STATE_OFF));
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[4], build_set_cdb(Feature::Region, REGION_FREE));
assert_eq!(m.cdbs[5], build_set_cdb(Feature::Region, REGION_BD_B));
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[6], build_set_cdb(Feature::Region, 0x02));
assert_eq!(m.cdbs[7], build_set_cdb(Feature::Speed, SPEED_MAX));
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() {
fn force_region_dvd_rejects_out_of_range_as_caller_error() {
let mut m = FwMock::new();
let mut fw = FirmwareControl::new(&mut m);
assert_eq!(fw.force_region_dvd(0).unwrap_err(), FirmwareError::Rejected);
assert_eq!(fw.force_region_dvd(9).unwrap_err(), FirmwareError::Rejected);
{
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 ──────────────────────────────────────────────────────────────────
@@ -491,9 +632,10 @@ fn arm_oem_bd_sets_and_verifies_hrl_skip() {
let mut fw = FirmwareControl::new(&mut m);
fw.arm_oem_bd().expect("armed");
}
// SET Hrl=OFF (the migrated skip direction), then GET Hrl to verify.
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(m.cdbs[1], build_get_cdb(Feature::Hrl));
// 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);
}
@@ -504,17 +646,16 @@ fn arm_oem_uhd_sets_uhd_hrl_encryption_each_verified() {
let mut fw = FirmwareControl::new(&mut m);
fw.arm_oem_uhd().expect("armed");
}
let verbs = m.verbs();
// set,get, set,get, set,get
assert_eq!(
verbs,
m.verbs_after_identity(),
vec![0x02, 0x03, 0x02, 0x03, 0x02, 0x03],
"each set is verified by a get"
);
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Uhd, STATE_ON));
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Encryption, STATE_OFF));
assert_eq!(m.states.uhd, STATE_ON);
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);
}
@@ -527,10 +668,10 @@ fn arm_bypass_bd_disables_encryption() {
fw.arm_bypass_bd().expect("armed");
}
// Hrl=off (revocation skip) is set+verified FIRST, then Encryption=off.
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(m.cdbs[1], build_get_cdb(Feature::Hrl));
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Encryption, STATE_OFF));
assert_eq!(m.cdbs[3], build_get_cdb(Feature::Encryption));
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);
}
@@ -542,11 +683,11 @@ fn arm_bypass_uhd_sets_uhd_hrl_encryption() {
let mut fw = FirmwareControl::new(&mut m);
fw.arm_bypass_uhd().expect("armed");
}
// Recipe order: Uhd=on, Hrl=off, Encryption=off (each SET then verifying GET).
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Uhd, STATE_ON));
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Hrl, STATE_OFF));
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Encryption, STATE_OFF));
assert_eq!(m.states.uhd, STATE_ON);
// 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);
}
@@ -555,18 +696,35 @@ fn arm_bypass_uhd_sets_uhd_hrl_encryption() {
fn arm_stealth_oem_resets_then_verifies_all_passthrough() {
let mut m = FwMock::new();
m.states.encryption = STATE_ON;
m.states.uhd = STATE_ON;
m.states.unrestricted = STATE_ON;
{
let mut fw = FirmwareControl::new(&mut m);
fw.arm_stealth_oem().expect("disarmed");
}
assert_eq!(m.cdbs[0], build_reset_cdb(RESET_TO_OEM));
assert_eq!(m.cdbs[1], build_reset_cdb(RESET_TO_OEM));
// then a GET of every feature
assert_eq!(m.cdbs.len(), 1 + ALL_FEATURES.len());
assert!(m.cdbs[1..].iter().all(|c| c[CDB_VERB] == Verb::Get as u8));
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();
@@ -577,6 +735,45 @@ fn arm_by_recipe_enum_dispatches() {
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
@@ -591,7 +788,11 @@ fn set_verify_reports_mismatch() {
_timeout_ms: u32,
) -> ScsiTResult<ScsiResult> {
let mut n = 0;
if cdb[CDB_VERB] == Verb::Get as u8 && !data.is_empty() {
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;
}
@@ -622,3 +823,15 @@ fn recipe_transport_fault_propagates() {
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);
}
+193 -230
View File
@@ -1,49 +1,26 @@
//! freemkv — the self-identifying custom-firmware unlocker.
//! Unlock drives that identify themselves as freemkv firmware.
//!
//! Detects a freemkv-firmware drive by issuing the vendor IDENTITY command
//! (`3C 0E C0 DE 01 …`) and checking the reply starts `b"freemkv"` — no bundled
//! profile database is needed, unlike [`crate::ld`], because the firmware
//! self-identifies.
//!
//! The wire grammar (verbs/features/states, CDB layout) lives in
//! [`crate::firmware`], the typed mirror of `freemkv-fw/src/abi.rs`. This module
//! only sequences the vendor commands into an unlock; it reuses the firmware
//! module's CDB builders so the wire framing never drifts.
//!
//! Unlock mapping onto the grammar: region-free = `Set(Region, free)`
//! (`REGION_FREE`), riplock lift = `Set(Speed, max)` (`SPEED_MAX`), and the
//! load-bearing transport unlock = `Set(Encryption, off)` (`STATE_OFF` — the
//! consolidated cert/bus bypass: drive acts pre-authenticated → a bare `0xAD`
//! returns the VID with no cert and no AKE, and content returns de-bussed).
//! The old separate `Bus` lever (wire id `0x07`) is retired — it was proven
//! inert on BU40N/MT1959, and the single `Encryption` lever de-busses on its
//! own. Under the migrated spec the HRL/Encryption unlock direction is
//! `STATE_OFF`, not `0x01`.
//! 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, MEMREAD_LEN, MIN_ALLOC_LEN, REGION_FREE, RESP_MAGIC, SPEED_MAX, STATE_OFF,
build_identity_cdb, build_memread_cdb, build_set_cdb,
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};
use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus};
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
/// The ASCII magic leading the IDENTITY reply — the ENTIRE freemkv-detection
/// mechanism (no bundled profile database; the firmware self-identifies).
const IDENTITY_MARKER: &[u8] = RESP_MAGIC;
/// Allocation the IDENTITY / DUMPALL data-in phase reads back (a fixed 64-byte
/// window, matching [`firmware::MEMREAD_LEN`]).
/// window, matching [`MEMREAD_LEN`]).
const RESP_LEN: usize = MEMREAD_LEN;
/// Whether a transport error is a genuine dead bus (a senseless
/// transport-failure status) rather than a drive rejection surfaced through a
/// non-conforming transport (`Err` carrying a sense).
fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
}
// The freemkv custom-firmware unlocker. Detection: IDENTITY, checking the reply
// starts "freemkv" — no bundled profile catalog needed, unlike
// 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;
@@ -53,25 +30,28 @@ impl FreemkvUnlocker {
FreemkvUnlocker
}
// Issue IDENTITY: Ok(true) if the reply starts "freemkv", Ok(false) if
// rejected/mismatched (not this firmware). A dead bus is Err(Transport) —
// this is the FIRST command, so a transport fault must abort.
// 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 matched = r.status == 0
&& r.bytes_transferred >= IDENTITY_MARKER.len()
&& buf[..IDENTITY_MARKER.len()] == *IDENTITY_MARKER;
if !matched {
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",
"IDENTITY probe did not report freemkv firmware"
version = id.as_ref().map(|i| i.version.as_str()),
"IDENTITY probe did not report supported freemkv firmware"
);
}
Ok(matched)
Ok(supported)
}
Err(e) => {
if is_dead_bus(&e) {
@@ -165,8 +145,8 @@ impl FreemkvUnlocker {
}
// Full freemkv unlock: IDENTITY (hard gate) → Set(Region,free) →
// Set(Speed,max) (best-effort) → Set(Encryption,off) (LOAD-BEARING —
// consolidated cert/bus bypass) → bare 0xAD VID (best-effort).
// 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,
@@ -196,6 +176,12 @@ impl FreemkvUnlocker {
// 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.
@@ -230,12 +216,13 @@ impl Unlocker for FreemkvUnlocker {
"freemkv"
}
/// Recognise the drive by its IDENTITY knock, lift riplock/region
/// (best-effort), null the AKE (the actual unlock), and read the Volume ID
/// with a bare `0xAD` (best-effort). `Some` when the null-AKE set succeeded —
/// the drive is unlocked whether or not the VID read did; `None` if it isn't
/// a freemkv drive; `Err(Transport)` on a dead bus. `ctx` is unused: this
/// unlocker self-identifies rather than matching on drive identity.
/// 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,
@@ -249,10 +236,7 @@ impl Unlocker for FreemkvUnlocker {
mod tests {
use super::*;
use crate::DiscKind;
use crate::firmware::{
REGION_FREE, RESET_TO_OEM, STATE_OFF, build_identity_cdb, build_memread_cdb,
build_reset_cdb, build_set_cdb,
};
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};
@@ -260,34 +244,19 @@ mod tests {
UnlockCtx::new(id, DiscKind::Unknown)
}
/// Serves a fixed payload with a GOOD status (used for the Identity probe).
struct FakeTransport {
payload: Vec<u8>,
}
impl ScsiTransport for FakeTransport {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
data: &mut [u8],
_timeout_ms: u32,
) -> Result<ScsiResult> {
let n = self.payload.len().min(data.len());
data[..n].copy_from_slice(&self.payload[..n]);
Ok(ScsiResult {
status: 0,
bytes_transferred: n,
sense: [0u8; 32],
})
}
/// 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> {
let mut p = vec![0u8; RESP_LEN];
let s = b"freemkv";
p[..s.len()].copy_from_slice(s);
p[s.len()] = 0x42; // version
p
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
@@ -299,22 +268,16 @@ mod tests {
p
}
// ── CDB shape ────────────────────────────────────────────────────────
/// IDENTITY builds the pinned knock frame with a 64-byte allocation.
#[test]
fn identity_cdb_is_the_knock_shape() {
assert_eq!(
build_identity_cdb(RESP_LEN as u16),
[0x3C, 0x0E, 0xC0, 0xDE, 0x01, 0x00, 0x00, 0x00, 0x40, 0x00]
);
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 `Set(Ake)` and fell back to AACS.
/// 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;
@@ -347,33 +310,10 @@ mod tests {
.expect("SET must succeed by issuing the data-in phase the firmware requires");
}
/// Each unlock SET is `Verb::Set` (02) on the right feature id (cdb[5]) with
/// the state at cdb[6]; SET floors its data-in allocation at MIN_ALLOC_LEN
/// (0x0040 at cdb[7..9]) — the drive aborts a sub-16-byte transfer (HW-confirmed).
#[test]
fn unlock_set_cdbs_have_the_new_grammar_shape() {
// Region-free is REGION_FREE (0x0F) at cdb[6] — 0x01 now forces DVD region 1.
assert_eq!(
build_set_cdb(Feature::Region, REGION_FREE),
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x02, 0x0F, 0x00, 0x40, 0x00]
);
// Speed max is SPEED_MAX (0x00) — the limiter-off leg of Speed.
assert_eq!(
build_set_cdb(Feature::Speed, SPEED_MAX),
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x01, 0x00, 0x00, 0x40, 0x00]
);
// Encryption unlock direction is STATE_OFF (0x00) at cdb[6]; wire id
// 0x07 (was `Bus`) is retired.
assert_eq!(
build_set_cdb(Feature::Encryption, STATE_OFF),
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x06, 0x00, 0x00, 0x40, 0x00]
);
}
// ── Detection ────────────────────────────────────────────────────────
#[test]
fn identify_true_on_freemkv_marker_and_issues_identity_cdb() {
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));
@@ -381,9 +321,7 @@ mod tests {
#[test]
fn identify_false_on_non_matching_payload() {
let mut t = FakeTransport {
payload: vec![0u8; RESP_LEN],
};
let mut t = MockTransport::always(Reply::good(vec![0u8; RESP_LEN]));
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
}
@@ -393,6 +331,35 @@ mod tests {
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());
@@ -414,33 +381,37 @@ mod tests {
);
}
/// 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_region_free_issues_the_set_region_on_cdb() {
let mut t = MockTransport::always(Reply::good(vec![]));
FreemkvUnlocker::new()
.set(&mut t, Feature::Region, REGION_FREE)
.expect("ok");
assert_eq!(t.cdbs[0], build_set_cdb(Feature::Region, REGION_FREE));
}
#[test]
fn set_encryption_off_issues_the_set_encryption_cdb() {
let mut t = MockTransport::always(Reply::good(vec![]));
FreemkvUnlocker::new()
.set(&mut t, Feature::Encryption, STATE_OFF)
.expect("ok");
assert_eq!(t.cdbs[0], build_set_cdb(Feature::Encryption, STATE_OFF));
}
#[test]
fn set_drive_rejection_is_not_applicable() {
let mut t = MockTransport::always(Reply::illegal_request());
let err = FreemkvUnlocker::new()
.set(&mut t, Feature::Encryption, STATE_OFF)
.unwrap_err();
assert_eq!(err, UnlockError::NotApplicable);
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]
@@ -455,78 +426,94 @@ mod tests {
// ── full_unlock / unlock ─────────────────────────────────────────────
/// The full unlock runs IDENTITY → Set(Region) → Set(Speed) →
/// Set(Encryption) → bare VID in order.
/// Set(Unrestricted) → Set(Encryption) → bare VID, with the exact wire
/// bytes for every vendor SET.
#[test]
fn full_unlock_issues_identity_region_speed_encryption_then_bare_vid() {
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 id = crate::DriveId::default();
let out = FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
let out = unlock(&mut t)
.expect("no fault")
.expect("encryption off ⇒ unlocked");
assert_eq!(out.vid, Some(vid));
assert_eq!(t.cdbs.len(), 5);
// The three vendor SETs land on the right features, in order.
assert_eq!(out.bus_key, None);
assert_eq!(t.cdbs.len(), 6);
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Region, REGION_FREE));
assert_eq!(t.cdbs[2], build_set_cdb(Feature::Speed, SPEED_MAX));
assert_eq!(t.cdbs[3], build_set_cdb(Feature::Encryption, STATE_OFF));
assert_eq!(t.cdbs[4][0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
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);
}
/// A missing region/speed feature does not fail the unlock (best-effort).
/// Missing region/speed/unrestricted features do not fail the unlock
/// (best-effort), whichever rejection shape the transport uses.
#[test]
fn full_unlock_tolerates_missing_region_and_speed() {
fn full_unlock_tolerates_missing_best_effort_features() {
let vid = [0x3Cu8; 16];
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()), // identity
Reply::illegal_request(), // region unsupported
Reply::illegal_request(), // speed unsupported
Reply::good(vec![]), // null ake
Reply::good(vid_ds_response(vid)), // bare VID
],
Reply::TransportFault,
);
let id = crate::DriveId::default();
let out = FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("no fault")
.expect("unlocked");
assert_eq!(out.vid, Some(vid));
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, this
/// isn't an unlockable freemkv drive, so `unlock()` declines (`None`) and
/// falls through — never a hard error.
/// 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() {
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()), // identity
Reply::good(vec![]), // region
Reply::good(vec![]), // speed
Reply::illegal_request(), // Set(Encryption,off) REJECTED
],
Reply::TransportFault,
);
let id = crate::DriveId::default();
assert!(
FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("no fault")
.is_none()
);
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
@@ -536,17 +523,16 @@ mod tests {
fn unlocks_without_vid_when_bare_read_fails() {
let mut t = MockTransport::scripted(
vec![
Reply::good(freemkv_identity_payload()), // identity
Reply::good(vec![]), // region
Reply::good(vec![]), // speed
Reply::good(vec![]), // Set(Encryption,off)
Reply::illegal_request(), // bare VID: no medium
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 id = crate::DriveId::default();
let out = FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
let out = unlock(&mut t)
.expect("no fault")
.expect("unlocked despite no VID");
assert_eq!(out.vid, None);
@@ -555,25 +541,13 @@ mod tests {
#[test]
fn declines_non_freemkv_drive() {
let mut t = MockTransport::always(Reply::illegal_request());
let id = crate::DriveId::default();
assert!(
FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
.expect("no fault")
.is_none()
);
assert!(unlock(&mut t).expect("no fault").is_none());
}
#[test]
fn transport_fault_propagates() {
let mut t = MockTransport::always(Reply::TransportFault);
let id = crate::DriveId::default();
assert_eq!(
FreemkvUnlocker::new()
.unlock(&mut t, &ctx(&id))
.unwrap_err(),
UnlockError::Transport
);
assert_eq!(unlock(&mut t).unwrap_err(), UnlockError::Transport);
}
// ── DumpAll ──────────────────────────────────────────────────────────
@@ -587,27 +561,16 @@ mod tests {
.dump_ram(&mut t, 0x01F8_1234)
.expect("dump ok");
assert_eq!(got[0], 0xEE);
assert_eq!(
t.cdbs[0],
[0x3C, 0x0E, 0xC0, 0xDE, 0x09, 0x01, 0xF8, 0x12, 0x34, 0x00]
);
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 build_memread_cdb_packs_address_big_endian_at_5_to_9() {
let cdb = build_memread_cdb(0xDEAD_BEEF);
assert_eq!(cdb[4], crate::firmware::Verb::DumpAll as u8);
assert_eq!([cdb[5], cdb[6], cdb[7], cdb[8]], [0xDE, 0xAD, 0xBE, 0xEF]);
assert_eq!(cdb[9], 0x00);
}
/// RESET builds the pinned all-features-passthrough frame (not issued by the
/// unlock flow, but pinned here so the shared builder stays honest).
#[test]
fn reset_cdb_shape() {
fn dump_ram_rejects_a_short_transfer() {
let mut t = MockTransport::always(Reply::short(vec![0xAB; MEMREAD_LEN], MEMREAD_LEN - 1));
assert_eq!(
build_reset_cdb(RESET_TO_OEM),
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x00, 0xFF, 0x00, 0x40, 0x00]
FreemkvUnlocker::new().dump_ram(&mut t, 0).unwrap_err(),
UnlockError::NotApplicable
);
}
+89 -6
View File
@@ -8,7 +8,7 @@
// `cdb` is only the bdemu emulator's wire format (real unlocking uses profile
// templates), gated behind `emulation`; also compiled under `cfg(test)` so its
// wire-format tests run in default-feature CI. See docs/ld-mod.md — cdb gating.
// wire-format tests run in default-feature CI.
#[cfg(any(feature = "emulation", test))]
mod cdb;
mod error;
@@ -21,7 +21,7 @@ use crate::{DriveId, UnlockCtx, UnlockError, Unlocked, Unlocker};
// ── Public profile catalog ──────────────────────────────────────────────────
// Only the catalog is public (supported-drive lookup, used by bdemu); the
// unlock mechanism stays private. See docs/ld-mod.md — public catalog design.
// unlock mechanism stays private.
pub use profile::{DriveProfile as Profile, Identity, Platform, ProfileMatch, Profiles};
@@ -122,7 +122,7 @@ impl LdUnlocker {
impl LdUnlocker {
// The MediaTek firmware unlock. Since it removes AACS at the drive (clear
// content), this one op satisfies both features and bus-removal, so both
// trait methods delegate here. See docs/ld-mod.md — firmware_unlock contract.
// trait methods delegate here.
fn firmware_unlock(
&self,
scsi: &mut dyn ScsiTransport,
@@ -144,7 +144,7 @@ impl LdUnlocker {
mt.init(scsi)?;
// `init` only proves the handshake completed, not that the drive reached
// extended-access state. Reporting unlocked off `init` alone shipped
// ciphertext at rc=0. See docs/ld-mod.md — half-unlock fallback.
// ciphertext at rc=0.
if !mt.is_unlocked() {
tracing::warn!(
target: "freemkv::disc",
@@ -159,7 +159,7 @@ impl LdUnlocker {
if let Err(e) = mt.probe_disc(scsi) {
// A transport fault here is a dead bus, not a calibration miss — most
// profiles never touch the bus again, so this was the only dead-bus
// signal. See docs/ld-mod.md — probe_disc dead-bus classification.
// signal.
if e.is_transport_failure() {
tracing::warn!(
target: "freemkv::disc",
@@ -432,7 +432,6 @@ mod tests {
// THE probe-disc dead-bus test: bus dies during speed calibration after a
// full unlock; must abort with Transport, not report a successful unlock.
// See docs/ld-mod.md — probe_disc dead-bus test / mutation notes.
#[test]
fn transport_fault_during_probe_disc_is_transport_not_a_successful_unlock() {
let id = known_vid_drive_id();
@@ -538,6 +537,90 @@ mod tests {
/// 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};
+738 -21
View File
@@ -6,7 +6,8 @@ mod variant_b;
use super::PlatformDriver;
use crate::ld::error::{Error, Result};
use crate::ld::profile::DriveProfile;
use crate::scsi::{self, DataDirection, ScsiTransport};
use crate::scsi::{self, DataDirection, ScsiSense, ScsiTransport};
use std::time::Duration;
// ── Variant constants ──────────────────────────────────────────────────
// Every vendor command: 3C [mode] [buffer_id] [sub_cmd] [addr] ...
@@ -18,6 +19,7 @@ const BUFFER_ID_B: u8 = 0x77;
// ── SCSI opcodes ──────────────────────────────────────────────────────
const SCSI_READ_BUFFER: u8 = 0x3C;
const SCSI_READ_CAPACITY: u8 = 0x25;
const SCSI_TEST_UNIT_READY: u8 = 0x00;
/// Shared by both firmware-upload variants (see `variant_a` / `variant_b`).
pub(super) const SCSI_WRITE_BUFFER: u8 = 0x3B;
@@ -41,6 +43,20 @@ const FIRMWARE_MODE_SIG: [u8; 4] = [0x4C, 0x62, 0x44, 0x72];
/// mean anything — through the secondary marker at [16..20].
const MIN_UNLOCK_RESPONSE: usize = FIRMWARE_MODE_OFFSET + 4;
// ── Post-upload readiness poll (T6, stop-design-v5 §2.11 / §3.1) ──────
/// Between TEST UNIT READY polls; libfreemkv `wait_ready` polls at 500 ms too.
const READY_POLL: Duration = Duration::from_millis(500);
/// T6: fail only after this long with no progress in the not-ready answers.
const READY_STALL: Duration = Duration::from_secs(60);
const TUR_TIMEOUT_MS: u32 = 5_000;
/// Consecutive 02/3A answers, with no 02/04/01 seen, that mean an empty drive
/// (~5 s); libfreemkv `wait_ready`'s `WAIT_READY_MAX_EMPTY_POLLS`.
const READY_MAX_EMPTY_POLLS: u32 = 10;
/// START STOP UNIT (1Bh), LoEj 0 / Start 1 (MMC-6 Table 631, byte 4 bit 0).
const START_UNIT: [u8; 6] = [0x1B, 0x00, 0x00, 0x00, 0x01, 0x00];
/// libfreemkv's START UNIT timeout (T4).
const START_UNIT_TIMEOUT_MS: u32 = 30_000;
// ── Init address (per disc type) ──────────────────────────────────────
const INIT_ADDR_BD: u16 = 0x0100;
const INIT_ADDR_UHD: u16 = 0x0200;
@@ -127,7 +143,7 @@ impl Mt1959 {
let result = scsi.execute(&cdb, DataDirection::FromDevice, buf, 5_000)?;
// A drive sense arrives as `Ok` with a non-zero status; a merely
// SHORT response is the drive answering badly — neither is a dead
// bus. See docs/mt1959.md — short-probe-not-transport-failure.
// bus.
if result.status != 0 || result.bytes_transferred != expected {
return Err(Error::Scsi {
opcode: SCSI_READ_BUFFER,
@@ -181,7 +197,7 @@ impl Mt1959 {
// `response` is a fixed 64-byte buffer; the meaningful bound is how
// many bytes the drive actually delivered. Require enough bytes to
// validate before checking markers, up front. See docs/mt1959.md — min-unlock-response-length.
// validate before checking markers, up front.
let n = result.bytes_transferred.min(response.len());
if n < MIN_UNLOCK_RESPONSE {
tracing::debug!(
@@ -206,7 +222,6 @@ impl Mt1959 {
// Extended-access state requires the signature match AND both the
// primary marker at [12..16] AND the secondary marker at [16..20].
// See docs/mt1959.md — unlock-marker-gating.
self.unlocked =
response[FIRMWARE_MODE_OFFSET..FIRMWARE_MODE_OFFSET + 4] == FIRMWARE_MODE_SIG;
@@ -282,7 +297,7 @@ impl Mt1959 {
return Err(Error::UnlockFailed);
}
// A dead bus must abort here, not be retried three times as
// firmware reloads. See docs/mt1959.md — run-init-transport-fault.
// firmware reloads.
Err(e) if e.is_transport_failure() => {
tracing::warn!(
target: "freemkv::disc",
@@ -292,8 +307,15 @@ impl Mt1959 {
);
return Err(e);
}
// Every exit below returns or overwrites `last_err` with the reload's error.
Err(e) => {
last_err = e;
tracing::debug!(
target: "freemkv::disc",
phase = "mt1959_unlock_failed_reloading",
attempt,
error = %e,
"unlock failed; reloading firmware"
);
let loaded = if self.mode == MODE_A {
variant_a::load_firmware(self, scsi)
} else {
@@ -321,10 +343,22 @@ impl Mt1959 {
last_err = e;
continue;
}
// D10 (stop-design-v5 §1): "`run_init` must `return Ok(())` as soon as
// `load_firmware` confirms the unlock" — its own do_unlock already did.
// Looping re-ran it after a 10 s settle and discarded it on attempt 2.
Ok(()) => {
// Firmware upload resets the drive. Give it time to
// fully recover before retrying unlock.
std::thread::sleep(std::time::Duration::from_secs(10));
tracing::debug!(
target: "freemkv::disc",
phase = "mt1959_unlock_ok_after_upload",
attempt,
unlocked = self.unlocked,
"MT1959 unlock confirmed by the firmware upload"
);
// The upload reset the drive; the unlock proves only the vendor
// path answers. Hand it back once the media is ready (T6). Any
// readiness outcome keeps the unlock; only a dead bus / Stop fails.
await_media_ready(scsi, &mut *ready_clock())?;
return Ok(());
}
}
}
@@ -349,7 +383,7 @@ impl Mt1959 {
}
// Detect disc type from capacity to select probe mode (BD vs UHD
// init address). See docs/mt1959.md — probe-init-address-detection.
// init address).
let cap_cdb = [
SCSI_READ_CAPACITY,
0x00,
@@ -368,7 +402,7 @@ impl Mt1959 {
let cap = scsi.execute(&cap_cdb, DataDirection::FromDevice, &mut cap_buf, 5_000)?;
let disc_sectors = if cap.status == 0 && cap.bytes_transferred >= 4 {
// last_lba + 1 = sector count; saturate on the 32-bit-overflow
// sentinel rather than wrap to 0. See docs/mt1959.md — capacity-saturation.
// sentinel rather than wrap to 0.
u32::from_be_bytes([cap_buf[0], cap_buf[1], cap_buf[2], cap_buf[3]]).saturating_add(1)
} else {
0
@@ -413,7 +447,7 @@ impl Mt1959 {
{
// A dead bus MUST keep its transport classification; a
// short/rejected reply is the drive's own failure and stays
// `UnlockFailed`. See docs/mt1959.md — probe-failure-classification.
// `UnlockFailed`.
if e.is_transport_failure() {
return Err(e);
}
@@ -423,7 +457,6 @@ impl Mt1959 {
}
// Pass 2: continue past the coarse range (do not restart at 0).
// See docs/mt1959.md — probe-pass-overlap-fix.
let mut addr: u32 = PROBE_COARSE_END as u32;
while addr < PROBE_FINE_END {
let mut resp = [0u8; PROBE_RESPONSE_SIZE as usize];
@@ -436,7 +469,7 @@ impl Mt1959 {
) {
// Fine probing is best-effort — a short/rejected reply just ends
// the sweep early, but a transport fault must keep its
// classification and abort. See docs/mt1959.md — probe-pass2-transport-fault.
// classification and abort.
if e.is_transport_failure() {
tracing::warn!(
target: "freemkv::disc",
@@ -466,6 +499,184 @@ impl Mt1959 {
}
}
/// Time and waiting for [`await_media_ready`]: a seam so tests run the 60 s
/// stall window on a virtual clock.
pub(crate) trait ReadyClock {
/// Time since the poll began.
fn elapsed(&self) -> Duration;
/// Wait `d` before the next poll.
fn wait(&mut self, scsi: &mut dyn ScsiTransport, d: Duration) -> scsi::Result<()>;
}
/// The poll's clock: [`WallClock`], or a test's virtual clock (a per-thread seam,
/// so an [`crate::LdUnlocker`] test can stall the poll without waiting 60 s).
fn ready_clock() -> Box<dyn ReadyClock> {
#[cfg(test)]
if let Some(make) = tests::TEST_READY_CLOCK.get() {
return make();
}
Box::new(WallClock::start())
}
/// The production clock: real time, and the transport's cancellable `pause`
/// between polls (stop-design-v5 §2.3), so a Stop ends the wait at once.
struct WallClock(std::time::Instant);
impl WallClock {
fn start() -> Self {
WallClock(std::time::Instant::now())
}
}
impl ReadyClock for WallClock {
fn elapsed(&self) -> Duration {
self.0.elapsed()
}
fn wait(&mut self, scsi: &mut dyn ScsiTransport, d: Duration) -> scsi::Result<()> {
scsi.pause(d)
}
}
/// How the post-upload readiness poll ended. Every outcome keeps the unlock; the
/// non-`Ready` ones are logged at warn and left to the caller's next command.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MediaReady {
Ready,
/// 10 consecutive 02/3A/xx with no 02/04/01 first: an empty drive.
NoMedium,
/// 02/30/xx: a medium that will never become ready.
Incompatible {
asc: u8,
ascq: u8,
},
/// Sense key 3, 4 or 5: not a readiness answer.
DriveError {
sense_key: u8,
asc: u8,
ascq: u8,
},
/// T6: no progress for [`READY_STALL`]; carries the last answer.
Stalled {
sense_key: u8,
asc: u8,
ascq: u8,
},
}
/// Poll TEST UNIT READY until the drive answers ready, following libfreemkv
/// `wait_ready`'s terminal states but always keeping the unlock (it succeeded).
/// T6 (stop-design-v5 §2.11): progress is an answer (sense key, ASC, ASCQ) not
/// yet seen in this poll, or the one START STOP UNIT; [`READY_STALL`] without
/// progress ends it. No total cap. `Err` only for a dead bus or a Stop.
fn await_media_ready(
scsi: &mut dyn ScsiTransport,
clock: &mut dyn ReadyClock,
) -> Result<MediaReady> {
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
let mut seen: Vec<(u8, u8, u8)> = Vec::new();
let mut last_progress = clock.elapsed();
let (mut start_sent, mut empty_run, mut becoming_ready_seen) = (false, 0u32, false);
for polls in 1u64.. {
// A drive answer arrives as `Ok` + status, or (libfreemkv's adapter) as an
// `Err` carrying the sense; only a senseless `Err` is a dead bus or a Stop.
let sense = match scsi.execute(&tur, DataDirection::None, &mut [], TUR_TIMEOUT_MS) {
Ok(r) if r.status == 0 => {
tracing::debug!(
target: "freemkv::disc",
phase = "mt1959_media_ready",
polls,
elapsed_ms = clock.elapsed().as_millis() as u64,
"drive ready after the firmware upload"
);
return Ok(MediaReady::Ready);
}
Ok(r) => r.sense,
Err(e) => match e.sense {
Some(sense) => sense,
None => return Err(Error::from(e)),
},
};
let s = ScsiSense::from_buf(&sense);
let answer = (s.sense_key, s.asc, s.ascq);
// MMC-6 Table F.3: "2 3A 00 MEDIUM NOT PRESENT"; "2 04 01 LOGICAL UNIT IS IN
// PROCESS OF BECOMING READY" first means the 3A run is not final.
empty_run = if (s.sense_key, s.asc) == (0x02, 0x3A) {
empty_run + 1
} else {
0
};
becoming_ready_seen |= answer == (0x02, 0x04, 0x01);
let outcome = match answer {
_ if !becoming_ready_seen && empty_run >= READY_MAX_EMPTY_POLLS => {
Some(MediaReady::NoMedium)
}
// Table F.3: "2 30 00 INCOMPATIBLE MEDIUM INSTALLED" (30/xx never ready).
(0x02, 0x30, ascq) => Some(MediaReady::Incompatible { asc: 0x30, ascq }),
// MEDIUM ERROR; HARDWARE ERROR (F.3.8 "reported when SK = HARDWARE ERROR");
// ILLEGAL REQUEST (F.3.2 Table F.2): no readiness to wait for.
(sense_key @ 0x03..=0x05, asc, ascq) => Some(MediaReady::DriveError {
sense_key,
asc,
ascq,
}),
_ => None,
};
if let Some(outcome) = outcome {
tracing::warn!(
target: "freemkv::disc",
phase = "mt1959_media_not_ready",
polls,
outcome = ?outcome,
"drive not ready after the firmware upload; keeping the unlock"
);
return Ok(outcome);
}
// Table F.3: "2 04 02 LOGICAL UNIT NOT READY, INITIALIZING CMD. REQUIRED" →
// one START STOP UNIT, Table 633 LoEj 0 Start 1: "Start the disc and make
// ready for access". Counted as progress.
if answer == (0x02, 0x04, 0x02) && !start_sent {
start_sent = true;
last_progress = clock.elapsed();
let r = scsi.execute(
&START_UNIT,
DataDirection::None,
&mut [],
START_UNIT_TIMEOUT_MS,
);
if let Err(e @ crate::scsi::ScsiError { sense: None, .. }) = r {
return Err(Error::from(e));
}
}
// Everything else (04/xx, 06/29/00 and 06/28/00 UNIT ATTENTION after the
// upload's reset, …) keeps polling under the T6 rule.
if !seen.contains(&answer) {
// TODO(T6 part 2, stop-design-v5 §2.11): also count a rising SKSV progress
// indicator (sense bytes 15-17) as progress, once SS-1 is quoted here.
seen.push(answer);
last_progress = clock.elapsed();
} else if clock.elapsed().saturating_sub(last_progress) >= READY_STALL {
let (sense_key, asc, ascq) = answer;
tracing::warn!(
target: "freemkv::disc",
phase = "mt1959_media_ready_stalled",
polls,
sense_key,
asc,
ascq,
"drive not ready after the firmware upload, no progress for 60 s; \
keeping the unlock"
);
return Ok(MediaReady::Stalled {
sense_key,
asc,
ascq,
});
}
clock.wait(scsi, READY_POLL)?;
}
unreachable!("the poll returns from inside the loop")
}
// ── PlatformDriver trait ───────────────────────────────────────────────
impl PlatformDriver for Mt1959 {
@@ -498,8 +709,16 @@ impl PlatformDriver for Mt1959 {
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
pub(crate) type ClockFactory = fn() -> Box<dyn ReadyClock>;
thread_local! {
/// Per-thread override of the poll's clock (see [`ready_clock`]).
pub(crate) static TEST_READY_CLOCK: std::cell::Cell<Option<ClockFactory>> =
const { std::cell::Cell::new(None) };
}
use crate::ld::profile::{DriveProfile, Identity};
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
@@ -696,7 +915,7 @@ mod tests {
// THE defect-15 test: a zero-byte response used to skip every marker
// check (each individually `n >= ..` guarded) and report a fully
// unlocked drive. See docs/mt1959.md — defect-15.
// unlocked drive.
#[test]
fn do_unlock_rejects_a_zero_length_response() {
let sig = [0x99, 0x9E, 0xC3, 0x75];
@@ -776,7 +995,7 @@ mod tests {
// THE defect-4 test: a dead bus must abort `run_init` at once, not
// re-upload firmware on all three attempts and return the generic
// `UnlockFailed`. See docs/mt1959.md — defect-4.
// `UnlockFailed`.
#[test]
fn run_init_aborts_on_a_transport_fault_without_reloading_firmware() {
let mut t =
@@ -894,7 +1113,7 @@ mod tests {
// THE probe pass-1 dead-bus test: a bus that dies during the coarse
// probe must keep its transport classification so the caller aborts
// with `Transport`. See docs/mt1959.md — pass1-dead-bus-mutation.
// with `Transport`.
#[test]
fn transport_fault_during_pass1_probe_stays_a_transport_failure() {
let mut t = ProbeFaultsTransport { fault_at: 0 };
@@ -909,7 +1128,7 @@ mod tests {
// THE probe pass-2 dead-bus test: the loop used to `break` on ANY
// error and return `Ok(())`, swallowing a dead bus mid-pass-2. It
// must now propagate. See docs/mt1959.md — pass2-dead-bus-mutation.
// must now propagate.
#[test]
fn transport_fault_during_pass2_probe_stays_a_transport_failure() {
let mut t = ProbeFaultsTransport {
@@ -952,7 +1171,7 @@ mod tests {
// variant_a's firmware-verify-read transport-abort branch: WRITE_BUFFER
// succeeds, the 0x45 verify read faults — a dead bus must abort there,
// not be swallowed into the unlock retries. See docs/mt1959.md — variant-a-verify-read-transport-abort.
// not be swallowed into the unlock retries.
#[test]
fn variant_a_verify_read_transport_fault_aborts() {
let mut profile = fixture_profile([0; 4]);
@@ -1013,7 +1232,7 @@ mod tests {
// variant_b's firmware-upload step (here the metadata read) hitting a
// dead bus must abort via `trace_step`, not be swallowed into the
// unlock retries. See docs/mt1959.md — variant-b-upload-step-transport-abort.
// unlock retries.
#[test]
fn variant_b_upload_step_transport_fault_aborts() {
let mut profile = fixture_profile([0; 4]);
@@ -1213,6 +1432,504 @@ mod tests {
);
}
// UT10 (stop-design-v5 §5.2, D10): "`run_init` must `return Ok(())` as soon as
// `load_firmware` confirms the unlock. The final upload is kept." GUARD G3 too;
// per design D10, do not change without a design citation proving otherwise.
#[test]
fn run_init_returns_ok_once_load_firmware_confirms() {
use crate::scsi::mock::{MockTransport, Reply};
let sig = [0x11, 0x22, 0x33, 0x44];
let unlock_ok = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG);
let short = || Reply::short(vec![0u8; 64], 10); // a generic do_unlock failure
let upload_ok = || {
vec![
Reply::good(vec![]), // WRITE_BUFFER
Reply::good(vec![0u8; VALIDATE_RESPONSE_SIZE as usize]), // 0x45 verify
]
};
// Case 1: attempt 0 reloads and load_firmware's own do_unlock confirms.
// Case 2: attempts 0-1 reload but fail; attempt 2 (the LAST) confirms,
// which the retry loop used to discard as `Err(last_err)`.
for failed_reloads in [0usize, 2] {
let mut script = Vec::new();
for _ in 0..failed_reloads {
script.push(short()); // run_init's do_unlock
script.extend(upload_ok());
script.push(short()); // load_firmware's do_unlock fails
}
script.push(short()); // run_init's do_unlock
script.extend(upload_ok());
script.push(Reply::good(unlock_ok.clone())); // load_firmware do_unlock #1
script.push(Reply::good(unlock_ok.clone())); // do_unlock #2 (best-effort)
script.push(Reply::good(vec![])); // TEST UNIT READY: ready at once
let expected_calls = script.len();
// Anything past the script is a further retry, which must not happen.
let mut t = MockTransport::scripted(script, Reply::TransportFault);
let mut profile = fixture_profile(sig);
profile.firmware = vec![0u8; 64];
let mut mt = Mt1959::new(profile, false);
let t0 = std::time::Instant::now();
mt.init(&mut t)
.unwrap_or_else(|e| panic!("{failed_reloads} failed reloads: {e:?}"));
assert_eq!(
t.calls(),
expected_calls,
"no retry after a confirmed unlock"
);
assert!(mt.is_ready() && mt.is_unlocked(), "the unlock is kept");
let uploads = t.cdbs.iter().filter(|c| c[0] == SCSI_WRITE_BUFFER).count();
assert_eq!(
uploads,
failed_reloads + 1,
"the final upload is still issued"
);
assert_eq!(t.cdbs.last().map(|c| c[0]), Some(SCSI_TEST_UNIT_READY));
assert!(
t0.elapsed() < std::time::Duration::from_secs(5),
"no fixed 10 s settle: a ready drive returns at once"
);
}
}
// The upload reset the drive: a confirmed unlock does not prove the media is
// ready, so run_init polls TEST UNIT READY until it is (coordinator review of
// ST-D10; the T6 rule, stop-design-v5 §2.11) before handing the drive back.
#[test]
fn run_init_waits_for_media_ready_after_upload() {
use crate::scsi::mock::{MockTransport, Reply};
let sig = [0x11, 0x22, 0x33, 0x44];
let unlock_ok = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG);
let becoming_ready = || Reply::Sense {
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense_key: 0x02,
asc: 0x04,
ascq: 0x01,
};
let script = vec![
Reply::short(vec![0u8; 64], 10), // run_init's do_unlock
Reply::good(vec![]), // WRITE_BUFFER
Reply::good(vec![0u8; VALIDATE_RESPONSE_SIZE as usize]), // 0x45 verify
Reply::good(unlock_ok.clone()), // do_unlock #1
Reply::good(unlock_ok), // do_unlock #2
becoming_ready(), // TUR: 02/04/01
becoming_ready(), // TUR: 02/04/01
Reply::good(vec![]), // TUR: ready
];
let expected = script.len();
let mut t = MockTransport::scripted(script, Reply::TransportFault);
let mut profile = fixture_profile(sig);
profile.firmware = vec![0u8; 64];
let mut mt = Mt1959::new(profile, false);
mt.init(&mut t).expect("ready after two not-ready polls");
assert_eq!(t.calls(), expected, "polled until ready, then stopped");
let turs = t.cdbs.iter().filter(|c| c[0] == SCSI_TEST_UNIT_READY);
assert_eq!(turs.count(), 3, "three TEST UNIT READY polls");
}
/// A virtual clock: `wait` advances time instantly and counts the waits.
pub(crate) struct FakeClock {
now: Duration,
waits: usize,
}
impl FakeClock {
pub(crate) fn new() -> Self {
FakeClock {
now: Duration::ZERO,
waits: 0,
}
}
}
impl ReadyClock for FakeClock {
fn elapsed(&self) -> Duration {
self.now
}
fn wait(&mut self, _scsi: &mut dyn ScsiTransport, d: Duration) -> crate::scsi::Result<()> {
self.now += d;
self.waits += 1;
Ok(())
}
}
/// A NOT READY TEST UNIT READY answer (status CHECK CONDITION).
fn not_ready(asc: u8, ascq: u8) -> crate::scsi::mock::Reply {
crate::scsi::mock::Reply::Sense {
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense_key: 0x02,
asc,
ascq,
}
}
// T6 (stop-design-v5 §2.11): NOT READY "becoming ready" for a few polls, then
// ready → Ok, having waited only between polls (no fixed settle).
#[test]
fn media_ready_poll_returns_once_ready() {
use crate::scsi::mock::{MockTransport, Reply};
let script = vec![
not_ready(0x04, 0x01),
not_ready(0x04, 0x01),
not_ready(0x04, 0x01),
];
let mut t = MockTransport::scripted(script, Reply::good(vec![]));
let mut clock = FakeClock::new();
let r = await_media_ready(&mut t, &mut clock).expect("ready on the 4th poll");
assert_eq!(r, MediaReady::Ready);
assert_eq!(t.calls(), 4);
assert!(t.cdbs.iter().all(|c| c.as_slice() == [0u8; 6]), "TUR only");
assert_eq!((clock.waits, clock.now), (3, READY_POLL * 3));
}
// Review of ST-D10: a stall after a confirmed upload must not undo the unlock.
// The SAME not-ready answer for 60 s (T6) → warn and return Ok, exactly at 60 s.
#[test]
fn media_ready_poll_stall_keeps_the_unlock() {
use crate::scsi::mock::MockTransport;
let mut t = MockTransport::always(not_ready(0x04, 0x01));
let mut clock = FakeClock::new();
let r = await_media_ready(&mut t, &mut clock).expect("a stall keeps the unlock");
let want = MediaReady::Stalled {
sense_key: 0x02,
asc: 0x04,
ascq: 0x01,
};
assert_eq!(r, want);
assert_eq!(
clock.now, READY_STALL,
"gives up exactly at 60 s without progress"
);
assert_eq!(
t.calls(),
121,
"one poll per 500 ms, then the 61st second's poll"
);
}
// MMC-6 Table F.3 "2 3A 00 MEDIUM NOT PRESENT": 10 consecutive answers (~5 s,
// libfreemkv WAIT_READY_MAX_EMPTY_POLLS) with no 04/01 → Ok (empty drive).
#[test]
fn media_ready_poll_no_medium_returns_after_the_grace() {
use crate::scsi::mock::MockTransport;
let mut t = MockTransport::always(not_ready(0x3A, 0x00));
let mut clock = FakeClock::new();
let r = await_media_ready(&mut t, &mut clock).expect("empty drive");
assert_eq!(r, MediaReady::NoMedium);
assert_eq!(
(t.calls(), clock.waits),
(10, 9),
"10 × 3A, ~4.5 s of waits"
);
}
// Table F.3 "2 04 01 LOGICAL UNIT IS IN PROCESS OF BECOMING READY" seen first:
// a later 3A run is not final, so the poll keeps going past 10 × 3A.
#[test]
fn media_ready_poll_keeps_polling_3a_after_04_01() {
use crate::scsi::mock::{MockTransport, Reply};
let mut script = vec![not_ready(0x04, 0x01)];
script.extend((0..20).map(|_| not_ready(0x3A, 0x00)));
let mut t = MockTransport::scripted(script, Reply::good(vec![]));
let mut clock = FakeClock::new();
let r = await_media_ready(&mut t, &mut clock).expect("ready");
assert_eq!(r, MediaReady::Ready, "not cut off as an empty drive");
assert_eq!(t.calls(), 22, "polled through 20 × 3A to ready");
}
// Table F.3 "2 04 02 LOGICAL UNIT NOT READY, INITIALIZING CMD. REQUIRED" →
// exactly one START STOP UNIT, Table 633 "0 1 Start the disc and make ready".
#[test]
fn media_ready_poll_04_02_sends_one_start_unit() {
use crate::scsi::mock::{MockTransport, Reply};
let script = vec![
not_ready(0x04, 0x02),
not_ready(0x04, 0x02),
not_ready(0x04, 0x02),
];
let mut t = MockTransport::scripted(script, Reply::good(vec![]));
let mut clock = FakeClock::new();
let r = await_media_ready(&mut t, &mut clock).expect("ready");
assert_eq!(r, MediaReady::Ready);
let starts: Vec<_> = t.cdbs.iter().filter(|c| c[0] == 0x1B).collect();
assert_eq!(
starts,
vec![&vec![0x1B, 0x00, 0x00, 0x00, 0x01, 0x00]],
"one START UNIT"
);
assert_eq!(t.cdbs[1][0], 0x1B, "sent right after the first 04/02");
}
// Table F.3 "2 30 00 INCOMPATIBLE MEDIUM INSTALLED" never becomes ready:
// return Ok at once (the unlock stands), with no wait.
#[test]
fn media_ready_poll_incompatible_medium_returns_at_once() {
use crate::scsi::mock::MockTransport;
let mut t = MockTransport::always(not_ready(0x30, 0x00));
let mut clock = FakeClock::new();
let r = await_media_ready(&mut t, &mut clock).expect("unlock kept");
assert_eq!(
r,
MediaReady::Incompatible {
asc: 0x30,
ascq: 0x00
}
);
assert_eq!((t.calls(), clock.waits), (1, 0));
}
// Table F.1: "6 29 00 POWER ON, RESET, OR BUS DEVICE RESET OCCURRED" and
// "6 28 00 NOT READY TO READY CHANGE…" after the upload's reset: keep polling.
#[test]
fn media_ready_poll_unit_attention_then_ready() {
use crate::scsi::mock::{MockTransport, Reply};
let ua = |asc| Reply::Sense {
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense_key: 0x06,
asc,
ascq: 0x00,
};
let mut t = MockTransport::scripted(vec![ua(0x29), ua(0x28)], Reply::good(vec![]));
let mut clock = FakeClock::new();
let r = await_media_ready(&mut t, &mut clock).expect("ready");
assert_eq!(r, MediaReady::Ready);
assert_eq!((t.calls(), clock.waits), (3, 2));
}
// MEDIUM ERROR (3), HARDWARE ERROR (4, MMC-6 F.3.8 "reported when SK = HARDWARE
// ERROR"), ILLEGAL REQUEST (5, F.3.2) are not readiness answers: Ok at once.
#[test]
fn media_ready_poll_error_sense_keys_return_at_once() {
use crate::scsi::mock::{MockTransport, Reply};
for sense_key in [0x03, 0x04, 0x05] {
let mut t = MockTransport::always(Reply::Sense {
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense_key,
asc: 0x00,
ascq: 0x00,
});
let mut clock = FakeClock::new();
let r = await_media_ready(&mut t, &mut clock).expect("unlock kept");
let want = MediaReady::DriveError {
sense_key,
asc: 0,
ascq: 0,
};
assert_eq!(r, want, "SK {sense_key}");
assert_eq!((t.calls(), clock.waits), (1, 0), "SK {sense_key}");
}
}
// libfreemkv's adapter returns `Err` carrying the sense for a non-zero status:
// that is a drive answer, not a dead bus, so the poll must keep going.
#[test]
fn media_ready_poll_err_with_sense_is_an_answer() {
use crate::scsi::mock::{MockTransport, Reply};
let err_nr = || Reply::ErrWithSense {
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
sense_key: 0x02,
asc: 0x04,
ascq: 0x01,
};
let mut t = MockTransport::scripted(vec![err_nr(), err_nr()], Reply::good(vec![]));
let mut clock = FakeClock::new();
let r = await_media_ready(&mut t, &mut clock).expect("an answer, not a dead bus");
assert_eq!(r, MediaReady::Ready);
assert_eq!(t.calls(), 3);
}
// T6: a NEW answer is progress and re-arms the window, so a drive that keeps
// moving through new states is never cut off by a total (150 s here).
#[test]
fn media_ready_poll_new_answers_rearm_the_window() {
use crate::scsi::mock::{MockTransport, Reply};
let mut script = Vec::new();
for ascq in [0x01, 0x04, 0x07] {
script.extend((0..100).map(|_| not_ready(0x04, ascq))); // 50 s each
}
let mut t = MockTransport::scripted(script, Reply::good(vec![]));
let mut clock = FakeClock::new();
let r = await_media_ready(&mut t, &mut clock).expect("progressing drive");
assert_eq!(r, MediaReady::Ready);
assert_eq!(clock.now, READY_POLL * 300, "waited 150 s in total");
}
// T6 stricter reading (§2.11): two KNOWN answers alternating is not progress,
// so a flapping drive stalls 60 s after its last new answer (unlock kept).
#[test]
fn media_ready_poll_flapping_answers_still_stall() {
struct Flap(usize);
impl ScsiTransport for Flap {
fn execute(
&mut self,
_cdb: &[u8],
_dir: DataDirection,
_data: &mut [u8],
_timeout_ms: u32,
) -> crate::scsi::Result<ScsiResult> {
self.0 += 1;
let mut sense = [0u8; 32];
sense[2] = 0x02;
sense[12] = 0x04;
sense[13] = if self.0.is_multiple_of(2) { 0x01 } else { 0x04 };
Ok(ScsiResult {
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
bytes_transferred: 0,
sense,
})
}
}
let mut clock = FakeClock::new();
let r = await_media_ready(&mut Flap(0), &mut clock).expect("unlock kept");
assert!(matches!(r, MediaReady::Stalled { .. }), "{r:?}");
assert_eq!(
clock.now,
READY_POLL + READY_STALL,
"60 s after the 2nd answer"
);
}
// A dead bus on TEST UNIT READY aborts at once: no wait, no retry.
#[test]
fn media_ready_poll_aborts_on_a_dead_bus() {
use crate::scsi::mock::{MockTransport, Reply};
let mut t = MockTransport::always(Reply::TransportFault);
let mut clock = FakeClock::new();
let e = await_media_ready(&mut t, &mut clock).expect_err("dead bus");
assert!(e.is_transport_failure());
assert_eq!((t.calls(), clock.waits), (1, 0));
}
// A Stop during the post-upload readiness poll ends it at the next wait:
// the production clock waits via the transport's cancellable `pause`, and
// the refusal is the dead-bus shape libfreemkv reclassifies as Halted (§2.3).
#[test]
fn media_ready_poll_stop_during_wait_ends_it() {
use crate::scsi::mock::{Ev, MockTransport, StopFake};
let mut t = StopFake::new(MockTransport::always(not_ready(0x04, 0x01)));
t.cancel_after = Some(|c| c[0] == SCSI_TEST_UNIT_READY);
let t0 = std::time::Instant::now();
let e = await_media_ready(&mut t, &mut WallClock::start()).expect_err("stopped");
assert!(
t0.elapsed() < Duration::from_millis(100),
"no real sleep after a Stop"
);
assert!(
e.is_transport_failure(),
"the refusal shape (Halted in libfreemkv)"
);
assert_eq!(
t.log,
vec![Ev::Exec(vec![0u8; 6]), Ev::PauseRefused(READY_POLL)],
"the wait itself observed the Stop; no further TUR"
);
}
/// A Stop that lands during the upload, through `run_init` on `mode`: the
/// first do_unlock fails generically (so the firmware reloads), and the
/// cancel arrives once a CDB matching `stop_at` reaches the drive.
fn stop_during_upload(
is_variant_b: bool,
stop_at: fn(&[u8]) -> bool,
) -> (Result<()>, Vec<crate::scsi::mock::Ev>) {
use crate::scsi::mock::{MockTransport, Reply, StopFake};
let sig = [0x11, 0x22, 0x33, 0x44];
let mut profile = fixture_profile(sig);
profile.firmware = vec![0u8; 64];
let unlock_ok = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG);
let inner = MockTransport::scripted(
vec![Reply::short(vec![0u8; 64], 10)], // run_init's do_unlock
Reply::good(unlock_ok),
);
let mut t = StopFake::new(inner);
t.cancel_after = Some(stop_at);
let mut mt = Mt1959::new(profile, is_variant_b);
let r = mt.init(&mut t);
(r, t.log)
}
fn is_unlock_cdb(c: &[u8]) -> bool {
c[0] == SCSI_READ_BUFFER && c[2] == BUFFER_ID_A && c[3] == SUB_CMD_UNLOCK
|| c[0] == SCSI_READ_BUFFER && c[2] == BUFFER_ID_B && c[3] == SUB_CMD_UNLOCK
}
// UT7 (stop-design-v5 §5.2; §2.3 D8 "the WRITE BUFFER at 30 s … and its verify
// run inside a `CriticalGuard`. C_max = 35 s"): the verify still executes
// after a Stop mid-upload, and the next do_unlock is refused.
#[test]
fn mt1959_a_upload_completes_after_cancel_then_stops() {
use crate::scsi::mock::Ev;
let (r, log) = stop_during_upload(false, |c| c[0] == SCSI_WRITE_BUFFER);
let e = r.expect_err("the Stop ends the init");
assert!(e.is_transport_failure(), "a refusal is the dead-bus shape");
let shape: Vec<String> = log
.iter()
.map(|e| match e {
Ev::Exec(c) if is_unlock_cdb(c) => "unlock".into(),
Ev::Refused(c) if is_unlock_cdb(c) => "unlock refused".into(),
Ev::Exec(c) => format!("{:02X}/{:02X}", c[0], c[2]),
other => format!("{other:?}"),
})
.collect();
let want = ["unlock", "Begin", "3B/00", "3C/45", "End", "unlock refused"];
assert_eq!(shape, want, "upload + verify complete; then stopped");
}
// UT7, cancel before the span: no WRITE BUFFER is ever started.
#[test]
fn mt1959_a_stop_before_upload_never_starts_it() {
use crate::scsi::mock::Ev;
let (r, log) = stop_during_upload(false, is_unlock_cdb);
assert!(r.expect_err("stopped").is_transport_failure());
assert_eq!(log.len(), 2, "{log:?}");
assert_eq!(
log[1],
Ev::BeginRefused,
"the span refuses to open after a Stop"
);
}
// UT8 (stop-design-v5 §5.2; §2.3 "MODE SELECT at 30 s …, READ BUFFER, WRITE
// BUFFER and F1 run inside a `CriticalGuard`. C_max = 45 s"), as UT7 for B.
#[test]
fn mt1959_b_upload_completes_after_cancel_then_stops() {
use crate::scsi::mock::Ev;
let (r, log) = stop_during_upload(true, |c| c[0] == 0x55);
assert!(r.expect_err("stopped").is_transport_failure());
let shape: Vec<String> = log
.iter()
.map(|e| match e {
Ev::Exec(c) if is_unlock_cdb(c) => "unlock".into(),
Ev::Refused(c) if is_unlock_cdb(c) => "unlock refused".into(),
Ev::Exec(c) => format!("{:02X}", c[0]),
other => format!("{other:?}"),
})
.collect();
let want = [
"unlock",
"Begin",
"55",
"3C",
"3B",
"F1",
"End",
"unlock refused",
];
assert_eq!(
shape, want,
"MODE SELECT, READ/WRITE BUFFER, F1; then stopped"
);
}
// UT8, cancel before the span: no MODE SELECT is ever started.
#[test]
fn mt1959_b_stop_before_upload_never_starts_it() {
use crate::scsi::mock::Ev;
let (r, log) = stop_during_upload(true, is_unlock_cdb);
assert!(r.expect_err("stopped").is_transport_failure());
assert_eq!(log.len(), 2, "{log:?}");
assert_eq!(log[1], Ev::BeginRefused);
}
// ── run_probe entry / disc-type detection ───────────────────────────────
/// `run_probe` called before `init_complete` runs `do_unlock` itself
+8 -3
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;
@@ -40,7 +40,10 @@ 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). No
// documented expected payload exists, so the outcome is traced rather
@@ -58,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,
@@ -96,6 +99,8 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
}
}
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.
+11 -11
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,7 +13,6 @@ const FIRMWARE_EXTRA: [u8; 16] = [0; 16];
/// that drive — real profiles must supply their own (see `DriveProfile`).
const VENDOR_VERIFY: [u8; 10] = [0xF1, 0x01, 0x02, 0x00, 0x0D, 0x30, 0x01, 0xF3, 0xAD, 0x23];
// See docs/variant-b-firmware-upload.md — trace_step rationale.
// Swallows everything except a transport fault; these steps are advisory,
// the unlock retries below are the real gate.
fn trace_step(phase: &'static str, r: crate::scsi::Result<crate::scsi::ScsiResult>) -> Result<()> {
@@ -58,7 +57,7 @@ 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 (see docs/variant-b-firmware-upload.md).
// 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();
@@ -78,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 = [
@@ -96,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,
@@ -120,22 +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.
// See docs/variant-b-firmware-upload.md for why the outcome is traced
// rather than discarded.
let verify_cdb = mt.profile.fw_verify_cdb.unwrap_or(VENDOR_VERIFY);
let mut dummy = [0u8; 0];
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). The
// confirmation pass after success is intentionally best-effort — see
// docs/variant-b-firmware-upload.md.
// confirmation pass after success is intentionally best-effort.
for _attempt in 0..5 {
match mt.do_unlock(scsi) {
Ok(_) => {
+75 -60
View File
@@ -42,10 +42,9 @@ pub struct Identity {
/// Per-drive profile.
///
/// Only `identity` and `signature` are public; everything else (firmware
/// image, per-drive vendor CDB templates) is `pub(crate)` unlock mechanism
/// that must stay inside this unpublished crate.
/// See docs/drive-profile-visibility.md — field-visibility and allow(dead_code) rationale.
/// 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 {
@@ -309,12 +308,12 @@ fn load_from_str(data: &str) -> Result<Profiles> {
/// Find a profile matching a drive's INQUIRY fields, whitespace-trimmed.
///
/// Per platform section (MT1959-A, -B, Renesas): (1) exact match including
/// `product_id` when the drive reports one; (2) product-id-blind fallback on
/// the four-field identity (vendor/revision/vendor_specific/firmware_date).
/// Catalogs store a GENERIC product_id (e.g. "BD-RE") vs a drive's specific one
/// ("BD-RE BU40N"), so pass 1 misses; pass 2 binds a UNIQUE four-field match —
/// a shared four-tuple binds only when the drive reported no product_id.
/// 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();
@@ -322,62 +321,45 @@ 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,
});
}
// Product-id-blind fallback: catalogs store a GENERIC product_id while
// drives report a specific one, so the exact pass misses. Match the
// four-field identity instead; guard mis-binding on shared tuples below.
let four_field: Vec<&DriveProfile> = list
];
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()
.filter(|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
})
.collect();
match four_field.as_slice() {
// Exactly one four-field match — unambiguous; bind regardless of
// the reported product_id (the common real-drive case, and the
// pre-1.7.0 behaviour a UHD-capable LG BU40N relied on).
[only] => {
return Some(ProfileMatch {
profile: (*only).clone(),
platform,
});
}
// Shared four-tuple: bind only when the drive reported NO
// product_id. A non-empty one that missed the exact pass is an
// uncataloged variant and must not mis-bind to a sibling.
[first, ..] if prod.is_empty() => {
return Some(ProfileMatch {
profile: (*first).clone(),
platform,
});
}
_ => {}
}
.flat_map(|(platform, list)| list.iter().map(move |p| (*platform, p)))
};
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)]
@@ -474,6 +456,39 @@ mod tests {
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();
+22 -19
View File
@@ -22,19 +22,16 @@ mod css;
// 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 `Ake=null` can read the VID with NO cert AKE and prove no
// 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)`.
// See docs/module-visibility.md — module visibility rationale.
// `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;
// `renesas` is public for its `is_renesas` drive-probe (dead bus vs. "not a
// Renesas drive"); the unlocker impl stays `pub(crate)`.
// See docs/module-visibility.md — module visibility rationale.
// `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. See docs/module-visibility.md — module visibility rationale.
// `freemkv` self-identifies rather than matching a bundled profile, so it stays fully private.
mod freemkv;
use scsi::ScsiTransport;
@@ -109,11 +106,10 @@ impl std::fmt::Debug for HostCert {
}
}
/// Per-attempt context the consumer hands to EVERY unlocker, uniformly: the
/// drive identity and the mounted disc's kind. These are the shared facts the
/// dispatch loop knows; anything an individual unlocker needs beyond them (the
/// AACS cert route's host certs) is injected into THAT unlocker at construction,
/// so the loop and the other unlockers never see it. See docs/module-visibility.md.
/// 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,
@@ -129,16 +125,15 @@ impl<'a> UnlockCtx<'a> {
/// even on success) and, for the cert route, the bus key the read path applies
/// to de-bus content. Returned inside the `Some` of [`Unlocker::unlock`]; there
/// is no `drive_unlocked` flag — "unlocked" is simply `unlock()` returning
/// `Some`, uniformly for every route.
#[derive(Clone, Default)]
/// `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]>,
}
// Hand-written, REDACTING Debug: `bus_key`/`vid` are key material that must
// never reach a log in plaintext; presence (Some/None) stays observable.
// See docs/unlocked-debug-redaction.md — full rationale.
// 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")
@@ -287,6 +282,14 @@ mod tests {
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.
+31 -17
View File
@@ -6,7 +6,7 @@
//! 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::scsi::{DataDirection, ScsiTransport, is_dead_bus};
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
/// READ_BUFFER mode 0x02, buffer 0xF1 — the Renesas vendor identity buffer.
@@ -24,8 +24,7 @@ const RENESAS_MARKER_OFFSET: usize = 16;
/// interface tag. A rejection (CHECK CONDITION or `Err` with a sense) is
/// `Ok(false)`: not a Renesas drive.
///
/// `Err(Transport)` on a dead bus. See docs/renesas-mod.md for why a
/// transport fault must not be folded into `Ok(false)`.
/// `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];
match scsi.execute(&RB_F1_CDB, DataDirection::FromDevice, &mut buf, 5_000) {
@@ -38,7 +37,7 @@ pub fn is_renesas(scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, Unl
// 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() {
if is_dead_bus(&e) {
tracing::warn!(
target: "freemkv::disc",
phase = "renesas_probe_transport_fault",
@@ -71,8 +70,7 @@ impl Renesas {
}
// MakeMKV's vendor "open" sequence: primary read (A), and on refusal a
// knock + second read (B). See docs/renesas-mod.md for the full sequence
// and why we run A→knock→B rather than bailing after A.
// knock + second read (B).
fn vendor_open(scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
const RB_B0_04_CDB: [u8; 10] = [0x3C, 0x02, 0xB0, 0x00, 0x00, 0x04, 0x00, 0x00, 0xA4, 0x00];
const KNOCK_A5AAAA_CDB: [u8; 10] =
@@ -88,11 +86,7 @@ impl Renesas {
// fire-and-forget (payload-less); its own status is not the signal.
match scsi.execute(&KNOCK_A5AAAA_CDB, DataDirection::None, &mut [], 5_000) {
Ok(_) => {}
Err(e)
if e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none() =>
{
return Err(UnlockError::Transport);
}
Err(e) if is_dead_bus(&e) => return Err(UnlockError::Transport),
Err(_) => {} // a drive that refuses the knock still gets the B read tried
}
read_is_good(scsi, &RB_B0_500000_CDB)
@@ -110,7 +104,7 @@ fn read_is_good(
match scsi.execute(cdb, DataDirection::FromDevice, &mut buf, 5_000) {
Ok(r) => Ok(r.status == 0),
Err(e) => {
if e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none() {
if is_dead_bus(&e) {
return Err(UnlockError::Transport);
}
Ok(false)
@@ -128,7 +122,7 @@ impl Unlocker for Renesas {
/// best-effort `0xAD` reader. `Some` once the open succeeds — the drive
/// serves clear content whether or not the bonus VID read did; `None` if it
/// isn't a Renesas drive or the open is refused; `Err(Transport)` on a dead
/// bus. See docs/renesas-mod.md for the MakeMKV-parity rationale.
/// bus.
fn unlock(
&self,
scsi: &mut dyn ScsiTransport,
@@ -188,7 +182,7 @@ mod tests {
}
// Rejects like a MediaTek drive: ILLEGAL REQUEST with a sense, which is
// what distinguishes this from a dead bus. See docs/renesas-mod.md.
// what distinguishes this from a dead bus.
struct RejectingTransport;
impl ScsiTransport for RejectingTransport {
fn execute(
@@ -280,7 +274,7 @@ mod tests {
}
// Same rejection via a CONFORMING transport (`Ok` + CHECK CONDITION);
// must reach the same answer. See docs/renesas-mod.md.
// must reach the same answer.
#[test]
fn check_condition_is_not_a_renesas_drive() {
use crate::scsi::mock::{MockTransport, Reply};
@@ -308,7 +302,7 @@ mod tests {
// A recognized Renesas drive whose A read (RB 0xB0@0x04) is REFUSED but
// whose B read (RB 0xB0@0x50) SUCCEEDS after the knock must unlock — and the
// knock CDB has to be issued BETWEEN A and B. See docs/renesas-mod.md.
// knock CDB has to be issued BETWEEN A and B.
#[test]
fn knock_runs_between_a_and_b_and_b_opens() {
// The exact knock CDB the code issues (pinned wire format).
@@ -394,8 +388,28 @@ mod tests {
assert!(a < knock && knock < b, "knock must run between A and B");
}
/// A dead bus only at the knock (recovered afterwards) still aborts with
/// Transport: the B read must never be sent.
#[test]
fn dead_bus_at_the_knock_aborts_before_the_b_read() {
use crate::scsi::mock::{MockTransport, Reply};
let script = vec![
Reply::good(renesas_payload()),
Reply::illegal_request(),
Reply::TransportFault,
];
let mut t = MockTransport::scripted(script, Reply::good(vec![0u8; 164]));
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.calls(), 3, "no B read after the dead knock");
}
// A recognized Renesas drive that REFUSES the vendor open read must
// defer to the next unlocker, not claim the drive. See docs/renesas-mod.md.
// defer to the next unlocker, not claim the drive.
#[test]
fn open_rejection_defers_to_next_unlocker() {
struct GateOkOpenRefused;
+408 -3
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,9 +272,8 @@ pub(crate) fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {
]
}
// ── Test fixture ────────────────────────────────────────────────────────────
// Crate-wide mock transport, able to express all three transport outcomes.
// See docs/scsi-mock-fixture.md — why this fixture exists
// ── 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 {
@@ -279,6 +451,171 @@ pub(crate) mod mock {
}
}
}
/// 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)
}
}
#[cfg(test)]
@@ -357,4 +694,72 @@ mod tests {
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");
}
}
+2 -8
View File
@@ -8,10 +8,10 @@
//! BEST-EFFORT: only a dead bus is an `Err(Transport)`. A CHECK CONDITION, a
//! short response, or an all-zero VID all yield `Ok(None)` — a VID miss must
//! never discard an unlock that already removed the bus (a key source can still
//! supply the key). See docs/freemkv-abi.md for the fixed-length reply rules.
//! supply the key).
use crate::UnlockError;
use crate::scsi::{DataDirection, ScsiTransport};
use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus};
/// The 16-byte AACS Volume ID.
const VID_LEN: usize = 16;
@@ -35,12 +35,6 @@ pub(crate) fn build_vid_cdb() -> [u8; 12] {
cdb
}
/// A senseless transport-failure status is a genuine dead bus, not a drive
/// rejection surfaced through a non-conforming transport (`Err` with a sense).
fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
}
/// Read the AACS VID with the bare `0xAD` fmt `0x80` (valid only after the drive
/// is unlocked). `Ok(Some(vid))` on a well-formed non-zero VID; `Ok(None)` for
/// any "no VID" outcome (rejected / short / all-zero); `Err(Transport)` only on
+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}");
}
}