Compare commits

...
11 Commits
Author SHA1 Message Date
Matthew Jackson 065cf5bf2b release: 1.6.15
Lockstep version bump for the unified 1.6.15 release.
2026-09-02 09:37:41 -07:00
Matthew Jackson c7ce119e2d freemkv-unlock: 1.6.15 work-in-progress checkpoint
Unlock refactor across aacs/ (handshake, mod), css/ (error, mod), freemkv/,
ld/ (mod, profile, platform mt1959 variant_a/variant_b, renesas), lib.rs and
scsi.rs, plus module documentation under docs/. Builds clean; 255 tests pass;
fmt clean. Committed to preserve in-progress work; refine before tagging 1.6.15.
2026-09-02 08:36:44 -07:00
Matthew Jackson 6e7c0cda06 docs: third-party NOTICE for LibreDrive microcode in profiles.json
profiles.json bundles volatile RAM-only LibreDrive microcode authored and owned
by the MakeMKV team (GuinpinSoft inc.) — Copyright (c) MakeMKV team, all rights
reserved. Add a repo-root NOTICE, a NOTICE beside profiles.json, an ld/README
section, and a README license section clarifying it is third-party proprietary
material not covered by the MIT license. Fixes #12.
2026-09-02 08:36:28 -07:00
Matthew Jackson 42809d14b4 ci(comment-guard): update workflow header to the intent-based model
//! unbounded, /// 8 prose lines, // 3. (freemkv-unlock: this also adds the
comment-guard workflow, which was present locally but never tracked/pushed.)
2026-09-02 08:23:54 -07:00
Matthew Jackson 647f92e9c1 ci(comment-guard): cap by comment intent, not visibility
//! / /*! module docs are unbounded (one per file, module-scoped, so they
cannot hide per-item rationale). /// item docs cap at 8 prose lines whether
or not the item is pub (fenced examples excluded). // asides cap at 3.
Removes the pub/private heuristic entirely — the real axis is doc intent.
2026-09-02 08:21:08 -07:00
Matthew Jackson 873dcefb3d ci: allow first-party git sources in cargo-deny (#15)
The freemkv siblings are git-tag deps in the committed manifests, so CI
resolves them as git sources (the local path override isn't seen inside
the cargo-deny-action container) and sources check failed. Allow git
from the freemkv org only; unknown-git stays denied otherwise.
2026-09-01 21:10:54 -07:00
Matthew Jackson 772fc4d814 ci: commit Cargo.lock + add cargo-deny dependency-audit gate (#15)
Adds a `deny` job (cargo-deny check advisories/bans/licenses/sources)
reading a new repo-root deny.toml, and commits Cargo.lock (previously
gitignored) so the resolved graph is recorded and audited. Regenerating
the lock fresh cleared a yanked chacha20. --locked stays off the
path-patched CI jobs (same reason the bins omit it); the stale
"Cargo.lock is gitignored" comments are corrected.
2026-09-01 21:06:48 -07:00
Matthew Jackson eddc2839d2 ci: run clippy on stable; single-source the MSRV from Cargo.toml
clippy now runs on @stable (blocking) instead of the pinned MSRV
toolchain, so lint debt against current Rust is caught as it accrues
rather than piling up at the next MSRV bump. Build, test and fmt still
run on the MSRV, but it is now read once from Cargo.toml's rust-version
via a resolve-msrv job instead of a hardcoded @1.88 pin, so the floor
cannot drift across workflow files; the test job building green at that
value verifies the declared MSRV is real. Consumer-build jobs move to
@stable (highest dependent floor). Stale toolchain numbers removed.

Also clears a chunks_exact -> as_chunks lint (src/ld/profile.rs) that
the stable clippy flags.
2026-09-01 20:39:30 -07:00
Matthew Jackson 9b9f142313 v1.6.14: bump version (unified release) 2026-08-31 23:04:22 +00:00
Matthew Jackson 7eee823db7 docs(changelog): 1.6.14 entry 2026-08-31 15:32:40 -07:00
Matthew Jackson d8f1d2f3a7 refactor(renesas): move renesis.rs into src/renesas/ and normalize spelling
Matches the ld/ platform-folder convention (MediaTek lives in src/ld/); the
module/struct spelling is normalized renesis->renesas to match the drives it
detects. Pure move + identifier rename, no behavior change.
2026-08-31 12:54:30 -07:00
32 changed files with 2013 additions and 1902 deletions
+66 -10
View File
@@ -17,10 +17,31 @@ on:
# seven others down with it, which is why it gets the full OS matrix rather
# than a token Linux run.
#
# Cargo.lock is gitignored (library convention), so `--locked` is deliberately
# NOT used: with no committed lockfile it fails rather than resolves.
# Cargo.lock is now committed (issue #15), so the resolved dependency graph is
# recorded and the `deny` job audits it. `--locked` is still deliberately NOT
# used, matching the binary crates' ci.yml: CI's sibling path-patch layout
# resolves the freemkv crates as path packages, which `--locked` can't satisfy.
jobs:
# ── Single source of truth for the MSRV ─────────────────────────────────────
#
# `rust-version` in Cargo.toml is the ONE place the MSRV is declared. This job
# reads it; every job that must build/test AT the MSRV consumes
# `needs.resolve-msrv.outputs.v` instead of a hardcoded pin, so the floor
# cannot drift out of sync across workflow files. The `test` job building
# green at this value IS the verification that the declared MSRV is real.
# clippy deliberately does NOT use it — it runs on `stable` (lint debt is
# caught against current Rust).
resolve-msrv:
runs-on: ubuntu-latest
outputs:
v: ${{ steps.msrv.outputs.v }}
steps:
- uses: actions/checkout@v7
- id: msrv
shell: bash
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
# CI owns the changelog: main may never carry a released version without a
# CHANGELOG.md entry for it. The release orchestrator dates an existing
# `## [X.Y.Z]` heading — it does NOT invent one — so a version bump that
@@ -48,24 +69,36 @@ jobs:
echo "::warning::${msg} (allowed on ${GITHUB_REF_NAME:-unknown}; required before it can reach main)."
lint:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.88.0
# fmt runs on the pinned MSRV toolchain; clippy runs on stable. Linting on
# stable makes new-lint debt a BLOCKING gate the pinned version would
# otherwise hide. The MSRV pin is installed second so it stays the default
# toolchain (fmt uses it implicitly); clippy is invoked with `+stable`.
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
components: clippy
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
components: rustfmt
- uses: Swatinem/rust-cache@v2
with:
shared-key: freemkv-unlock-ci
- run: cargo fmt --check
# --all-targets so the test code is linted too, not just the library.
- run: cargo clippy --all-targets -- -D warnings
- run: cargo +stable clippy --all-targets -- -D warnings
test:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.88.0
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
with:
shared-key: freemkv-unlock-ci
@@ -79,13 +112,15 @@ jobs:
# release gate). It mirrors the `test` job's environment — same pinned
# toolchain, ubuntu-latest, no `if:` so it runs on every branch and PR just
# as `test` does. `--locked` is intentionally omitted to match this crate's
# policy (Cargo.lock is gitignored; --locked would fail without it).
# policy (Cargo.lock is committed but --locked can't satisfy the path-patch layout).
coverage:
needs: resolve-msrv
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.88.0
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
components: llvm-tools-preview
- uses: Swatinem/rust-cache@v2
with:
@@ -99,6 +134,21 @@ jobs:
slug: freemkv/freemkv-unlock
fail_ci_if_error: false
# cargo-deny gates the RESOLVED dependency graph: RUSTSEC advisories, a
# permissive licence allow-list, and source/ban hygiene. It mirrors the
# `test` job's checkout (single root checkout, no siblings) and runs on
# `stable` — advisory/licence debt is caught against current Rust, not the
# MSRV pin — so no `needs: resolve-msrv`.
deny:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check advisories bans licenses sources
manifest-path: Cargo.toml
# The crate ships on macOS and Windows through its consumers, so it has to
# compile there. Build the tests (not just `cargo check`) — that forces
# codegen and catches errors in test-only code, which `check` skips.
@@ -115,11 +165,14 @@ jobs:
# than a slow one: release.sh's CI gate refuses while any run for the
# commit is still in progress, so a never-scheduled job blocks releases
# silently -- see the note on real-media in qa.yml.
needs: resolve-msrv
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.88.0
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
with:
shared-key: freemkv-unlock-ci
@@ -138,11 +191,14 @@ jobs:
# than a slow one: release.sh's CI gate refuses while any run for the
# commit is still in progress, so a never-scheduled job blocks releases
# silently -- see the note on real-media in qa.yml.
needs: resolve-msrv
if: github.ref_name == 'qa' || github.ref_name == 'main'
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.88.0
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
with:
shared-key: freemkv-unlock-ci
+17
View File
@@ -0,0 +1,17 @@
name: comment-guard
# Caps explanatory prose by COMMENT INTENT (not visibility):
# //! / /*! module docs — unbounded (module-scoped, can't hide per-item text)
# /// / /** */ item docs — 8 prose lines (fenced ``` examples excluded)
# // / /* */ asides — 3 lines (single-blank-separated runs count as one)
# Overflow belongs in the module //! or docs/<topic>.md with a one-line // pointer.
on: [push, pull_request]
jobs:
comment-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Run comment-guard
run: python3 ci/comment-guard.py
+24 -3
View File
@@ -24,6 +24,19 @@ on:
workflow_dispatch:
jobs:
# Single source of truth for the MSRV — see ci.yml. Jobs that build/test AT
# the MSRV read `needs.resolve-msrv.outputs.v` from Cargo.toml's rust-version;
# clippy (cross-lint) runs on stable instead.
resolve-msrv:
runs-on: ubuntu-latest
outputs:
v: ${{ steps.msrv.outputs.v }}
steps:
- uses: actions/checkout@v7
- id: msrv
shell: bash
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
# ── Name the candidate ────────────────────────────────────────────────
#
# Every push to `qa` is a release candidate, so every push gets a tag:
@@ -65,6 +78,7 @@ jobs:
# changes what the optimiser can prove. A test that only passes in debug is
# a test that never guarded the binary anyone actually ships.
release-tests:
needs: resolve-msrv
strategy:
fail-fast: false
matrix:
@@ -74,7 +88,9 @@ jobs:
- uses: actions/checkout@v7
with:
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@1.88.0
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-unlock
@@ -90,7 +106,9 @@ jobs:
- uses: actions/checkout@v7
with:
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@1.88.0
# clippy runs on stable (matches ci.yml's lint job); build/test stay on
# the MSRV pin. See ci.yml for why the lint toolchain need not be the MSRV.
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
@@ -105,12 +123,15 @@ jobs:
# of the SCSI transport and platform layers compile on no other runner, so
# without this they are first built at release time.
windows-build:
needs: resolve-msrv
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
with:
path: freemkv-unlock
- uses: dtolnay/rust-toolchain@1.88.0
- uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ needs.resolve-msrv.outputs.v }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: freemkv-unlock
+1 -1
View File
@@ -1,3 +1,3 @@
/target
Cargo.lock
.cargo/config.toml
NEXT_SESSION.md
+16
View File
@@ -1,5 +1,21 @@
# Changelog
## [1.6.15] — 2026-09-02
### Changed
- Version aligned to 1.6.15 for the unified release.
### Docs
- Added a third-party NOTICE clarifying that the LibreDrive microcode bundled in `src/ld/profiles.json` is proprietary MakeMKV material, not covered by this repository's MIT license.
## [1.6.14] — 2026-08-31
### Changed
- Version aligned to 1.6.14 for the unified release.
## [1.6.13] — 2026-08-28
### Changed
Generated
+454
View File
@@ -0,0 +1,454 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aes"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
dependencies = [
"cipher",
"cpubits",
"cpufeatures 0.3.1",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core",
]
[[package]]
name = "cipher"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common 0.2.2",
"inout",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
]
[[package]]
name = "freemkv-unlock"
version = "1.6.14"
dependencies = [
"aes",
"base64",
"num-bigint",
"num-traits",
"rand",
"serde",
"serde_json",
"sha1",
"sha2",
"tracing",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"rand_core",
]
[[package]]
name = "hybrid-array"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
dependencies = [
"typenum",
]
[[package]]
name = "inout"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [
"hybrid-array",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "num-bigint"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
"chacha20",
"getrandom",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest 0.11.3",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[patch.unused]]
name = "freemkv-engine"
version = "1.6.14"
[[patch.unused]]
name = "freemkv-i18n"
version = "1.6.14"
[[patch.unused]]
name = "freemkv-keysources"
version = "1.6.14"
[[patch.unused]]
name = "libfreemkv"
version = "1.6.14"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "freemkv-unlock"
version = "1.6.13"
version = "1.6.15"
edition = "2024"
rust-version = "1.88"
license = "MIT"
+24
View File
@@ -0,0 +1,24 @@
NOTICE — Third-Party Drive Microcode
====================================
freemkv-unlock's own source code is licensed under the MIT License (see LICENSE).
The MIT License does NOT cover the drive-microcode payloads bundled in
`src/ld/profiles.json`.
Those payloads are volatile, RAM-only optical-drive microcode ("LibreDrive")
authored and owned by the MakeMKV team (GuinpinSoft inc.). Each payload embeds
its own copyright notice, e.g.:
"This is volatile RAM-only microcode for LibreDrive by MakeMKV -
http://www.makemkv.com/libredrive/
Copyright (c) MakeMKV team, all rights reserved."
They are included solely to recognize supported drives and perform the
non-persistent, RAM-only unlock in interoperation with MakeMKV's LibreDrive, on
drives their owners already possess. They are third-party proprietary material —
**Copyright (c) MakeMKV team, all rights reserved** — and are **NOT** licensed
under MIT. No ownership is claimed or transferred.
freemkv is an independent project, not affiliated with, endorsed by, or
sponsored by MakeMKV / GuinpinSoft inc. With thanks to the MakeMKV team for
LibreDrive. See https://www.makemkv.com/ and http://www.makemkv.com/libredrive/.
+10 -1
View File
@@ -36,4 +36,13 @@ for u in freemkv_unlock::all_unlockers() {
To remove an unlocker, delete its module directory and its one line in
`all_unlockers()` — nothing else changes.
License: MIT.
## License
freemkv-unlock's own source is MIT — see [LICENSE](LICENSE).
`src/ld/profiles.json` bundles volatile, RAM-only **LibreDrive** microcode
authored and owned by the **MakeMKV team** (GuinpinSoft inc.) — *Copyright (c)
MakeMKV team, all rights reserved.* That microcode is third-party proprietary
material, **not** covered by the MIT license; no ownership is claimed. See
[`NOTICE`](NOTICE). With thanks to the MakeMKV team for
[LibreDrive](http://www.makemkv.com/libredrive/).
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""comment-guard: cap explanatory prose by COMMENT INTENT, not visibility.
Three tiers, keyed on the syntax the author reached for:
* `//!` / `/*!` — a module (or crate) design doc. UNBOUNDED. There is one per
file, at the top, documenting the module as a whole, so it can't be used to
smuggle per-item rationale the way item docs can. Long correctness / design
essays (e.g. a memory-ordering proof) legitimately live here.
* `///` / `/** */` — item documentation. Up to DOC_CAP *prose* lines. Fenced
``` code blocks do NOT count, so real examples aren't punished. This holds
whether or not the item is `pub`: a `pub(crate)` fn deserves a real doc
paragraph just like a `pub` one. Capping `///` here (rather than exempting
it, as the old guard did — issue #17) is what stops rationale being hidden
behind a doc sigil, while still leaving room for a genuine paragraph.
* `//` / `/* */` — a quick internal aside. Up to INLINE_CAP lines. This is the
shape that sprawls into walls, so it's kept tight: stanzas separated by a
single blank line count CUMULATIVELY, so one essay can't be split into runs.
Overflow belongs in the module `//!`, or in docs/<topic>.md with a one-line `//`
pointer — both show up in review.
Scans src/ and tests/ under the given root (default cwd). `--selftest` runs
built-in cases. Exit non-zero if any block exceeds its cap.
"""
import re
import sys
from pathlib import Path
INLINE_CAP = 3
DOC_CAP = 8
_RAW_OPEN = re.compile(r'r(#*)"') # raw string opener: r"…", r#"…"#, br##"…"## …
def _strip_line_comment(s):
"""Return (kind, body) for a whole-line comment, else (None, None).
kind is 'doc' for `///`/`//!`, 'plain' for other `//`. A trailing comment on
a code line is not a whole-line comment and is ignored (single-line, cheap).
"""
if s.startswith("//!"):
return "doc", s[3:]
if s.startswith("///") and not s.startswith("////"):
return "doc", s[3:]
if s.startswith("//"):
return "plain", s[2:]
return None, None
def _fence_toggle(body):
"""True if this doc body line opens/closes a ``` fenced code block."""
return body.strip().startswith("```")
def _classify(lines):
"""Yield per-line dicts: comment kind ('doc'/'plain'/None), doc-ness of
block comments, and code info. Block comments are tracked across lines.
"""
info = []
in_block = False
block_is_doc = False
raw_hashes = None # None = not in a raw string; int N = open, needs '"' + N*'#'
for raw in lines:
s = raw.lstrip()
rec = {"raw": raw, "s": s, "comment": None, "is_doc": False, "code": False}
# Inside a raw string literal (e.g. an embedded HTML/CSS/JS asset): the
# bytes are string data, not Rust comments, so the guard is blind to them
# until the string closes. Prevents `/* */` or `//` lines in an embedded
# page from being counted as comment blocks.
if raw_hashes is not None:
if ('"' + "#" * raw_hashes) in raw:
raw_hashes = None
info.append(rec)
continue
if in_block:
rec["comment"] = "doc" if block_is_doc else "plain"
rec["is_doc"] = block_is_doc
if "*/" in raw:
in_block = False
info.append(rec)
continue
# A block comment only starts a comment-line if it's the first token.
if s.startswith("/*"):
rec["is_doc"] = s.startswith("/**") or s.startswith("/*!")
rec["comment"] = "doc" if rec["is_doc"] else "plain"
if "*/" not in s[2:]:
in_block = True
block_is_doc = rec["is_doc"]
info.append(rec)
continue
kind, _ = _strip_line_comment(s)
if kind is not None:
rec["comment"] = kind
rec["is_doc"] = kind == "doc"
info.append(rec)
continue
# Code (or blank). Blank = empty after strip.
rec["code"] = s != ""
# A code line may OPEN a raw string that spans following lines. Detected
# only on code (comments were handled above), so `// see r"x"` is safe.
m = _RAW_OPEN.search(raw)
if m and ('"' + "#" * len(m.group(1))) not in raw[m.end():]:
raw_hashes = len(m.group(1))
info.append(rec)
return info
def violations(path):
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
info = _classify(lines)
n = len(info)
out = []
i = 0
while i < n:
rec = info[i]
if rec["comment"] is None:
i += 1
continue
start = i
if rec["is_doc"]:
# Doc run: contiguous doc lines. `//!` / `/*!` module docs are
# UNBOUNDED; any other doc run caps PROSE (fenced ``` excluded) at
# DOC_CAP, regardless of the documented item's visibility.
j = i
prose = 0
in_fence = False
module_doc = False
while j < n and info[j]["comment"] == "doc":
s = info[j]["s"]
if s.startswith("//!") or s.startswith("/*!"):
module_doc = True
body = (
s[3:] if (s.startswith("///") or s.startswith("//!")) else s
)
if _fence_toggle(body):
in_fence = not in_fence
elif not in_fence and body.strip() != "":
prose += 1
j += 1
if not module_doc and prose > DOC_CAP:
out.append((start + 1, prose, DOC_CAP, "doc prose"))
i = j
continue
# Internal (plain) run, with single-blank bridging (cumulative).
j = i
count = 0
while j < n:
r = info[j]
if r["comment"] == "plain":
count += 1
j += 1
continue
if r["comment"] == "doc":
break
if r["comment"] is None and not r["code"]:
# blank: bridge only a SINGLE blank, else stop
if j + 1 < n and info[j + 1]["comment"] == "plain":
j += 1
continue
break
break # code
if count > INLINE_CAP:
out.append((start + 1, count, INLINE_CAP, "inline comment"))
i = j
return out
def main(argv):
if "--selftest" in argv:
return _selftest()
root = Path(argv[1]) if len(argv) > 1 else Path.cwd()
files = sorted(p for d in ("src", "tests") for p in (root / d).rglob("*.rs"))
total = 0
for f in files:
for start, length, cap, kind in violations(f):
rel = f.relative_to(root)
print(f"{rel}:{start}: {kind} block is {length} lines (max {cap})")
total += 1
if total:
print(f"\ncomment-guard: {total} comment block(s) exceed their cap.")
print(
f"Inline // asides cap at {INLINE_CAP}; /// item-doc prose caps at "
f"{DOC_CAP} (fenced examples excluded); //! module docs are unbounded. "
"Trim, move the rationale into the module //!, or docs/<topic>.md "
"with a // pointer."
)
return 1
return 0
def _selftest():
import tempfile
cases = [
# (rust source, expected number of violations)
# short /// doc -> ok
("/// short doc\npub fn a() {}\n", 0),
# /// on a PRIVATE item now gets the doc cap (8), so 4 lines is fine
("/// l1\n/// l2\n/// l3\n/// l4\nfn priv_a() {}\n", 0),
# ...but /// is NOT unlimited: 9 prose lines on a private item fails
("".join(f"/// l{k}\n" for k in range(9)) + "fn priv_b() {}\n", 1),
# 8 prose /// on a pub item -> ok (boundary)
("".join(f"/// l{k}\n" for k in range(8)) + "pub fn c() {}\n", 0),
# 9 prose /// on a pub item -> violation
("".join(f"/// l{k}\n" for k in range(9)) + "pub fn d() {}\n", 1),
# /// with a long fenced example -> example excluded, ok
(
"/// summary\n/// # Examples\n/// ```\n"
+ "".join("/// code\n" for _ in range(12))
+ "/// ```\npub fn e() {}\n",
0,
),
# pub(crate) gets the SAME doc cap as pub (no pub/private split anymore)
("".join(f"/// l{k}\n" for k in range(6)) + "pub(crate) fn f() {}\n", 0),
# bare inline 4 lines -> violation
("// a\n// b\n// c\n// d\nlet x = 1;\n", 1),
# blank-split dodge: 3 + blank + 3 inline -> cumulative -> violation
("// a\n// b\n// c\n\n// d\n// e\n// f\nfn z() {}\n", 1),
# plain /* */ block, 5 lines -> inline cap -> violation
("/* one\n two\n three\n four\n five */\nfn q() {}\n", 1),
# comment-like lines INSIDE a raw string are string data, not comments
(
'const H: &str = r##"<style>\n'
+ "".join("/* css note */\n" for _ in range(8))
+ '</style>"##;\nfn h() {}\n',
0,
),
# //! module doc may run long -> EXEMPT (no cap)
("".join(f"//! l{k}\n" for k in range(40)) + "\npub fn m() {}\n", 0),
# /*! block module doc may run long -> EXEMPT
("/*! l0\n" + "".join(f" l{k}\n" for k in range(30)) + "*/\nfn n() {}\n", 0),
]
ok = True
with tempfile.TemporaryDirectory() as d:
for idx, (src, want) in enumerate(cases):
p = Path(d) / f"case{idx}.rs"
p.write_text(src)
got = len(violations(p))
flag = "ok" if got == want else "FAIL"
if got != want:
ok = False
print(f" selftest {idx}: want {want} got {got} [{flag}]")
print("selftest PASSED" if ok else "selftest FAILED")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main(sys.argv))
+42
View File
@@ -0,0 +1,42 @@
# cargo-deny — gates the RESOLVED dependency graph (issue #15).
# Runs in CI against the committed Cargo.lock: RUSTSEC advisories, a permissive
# licence allow-list, and source/ban hygiene. Update the lock (cargo update)
# to clear an advisory rather than adding an `ignore` entry without cause.
[advisories]
yanked = "deny"
# ignore = ["RUSTSEC-0000-0000"] # only with a written justification
[licenses]
# The whole tree is permissive. r-efi's LGPL arm is satisfied by Apache-2.0
# (its expression is "Apache-2.0 OR LGPL-2.1-or-later"), so no copyleft is
# allowed here. Add a licence only when a real dependency introduces it.
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"0BSD",
"Unicode-3.0",
"Unlicense",
"Zlib",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"CDLA-Permissive-2.0",
]
confidence-threshold = 0.9
[bans]
multiple-versions = "warn" # transitive dupes (digest 0.10/0.11 etc.) are noise, not a gate
wildcards = "deny"
# The freemkv siblings resolve as path deps under CI's path-patch layout, which
# reads as a wildcard version req. That is internal, not a supply-chain wildcard.
allow-wildcard-paths = true
[sources]
unknown-registry = "deny"
unknown-git = "deny"
# The freemkv siblings are first-party git-tag deps; allow git sources from
# our own org only (CI resolves them as git, not the local path override).
[sources.allow-org]
github = ["freemkv"]
+184
View File
@@ -0,0 +1,184 @@
# AACS bus authentication handshake
## Flow
1. Invalidate AGIDs → allocate fresh AGID
2. Send host certificate + nonce
3. Receive drive certificate + nonce
4. Receive drive key point + signature, verify
5. Sign host key point, send
6. ECDH: host_priv × drive_key_point → bus key (low 128 bits of x)
7. Read VID or Read Data Keys (encrypted with bus key)
## Why `scsi_read`/`scsi_write` check status at the seam
The contract this crate is built on (`ScsiTransport::execute`) returns `Ok`
on a SCSI sense and `Err` ONLY on a transport-layer fault. These two helpers
used to propagate the `?` and nothing else — so a CHECK CONDITION (`Ok`,
non-zero `status`) handed the caller back its own ZERO-FILLED buffer and
every REPORT/SEND KEY step consumed zeros as valid drive data: a drive
certificate of zeros, a key point of zeros, a Volume ID of zeros, all
reported as success. It has not fired in production only because the sole
transport implementor violates the contract in the opposite direction (it
returns `Err` for any non-zero status). Plugging in a CONFORMING transport
would have turned the whole handshake into a zero-buffer success, so the
status and the transferred length are checked here, at the seam.
## Why `scsi_write` shares this reasoning
Same contract reasoning as `scsi_read`: a drive that REFUSES the host
certificate answers with `Ok` + CHECK CONDITION, and treating that as a
successful send carried the handshake on against a drive that never
accepted it.
## `ec_mul` design notes
**Constant-time tradeoff:** this branches on `scalar.bit(0)` and clones
BigUints per iteration, so its timing is data-dependent on the secret scalar
(the long-term host private key in `ecdsa_sign`, the ephemeral key in ECDH).
This is deliberate: the handshake runs once per disc against a local optical
drive, so throughput and the narrow local-timing surface do not justify
pulling in a vetted constant-time backend. Revisit if this ever signs in a
remote/shared context.
**Cofactor:** both AACS curves used here have cofactor 1, so a point that
lies on the curve is automatically in the prime-order subgroup — no
small-subgroup defense / `n·P == O` check is required for the inputs this is
called with.
## `verify_cert_p256` — provisional AACS 2.0 cert layout
Verifies an AACS 2.0 drive certificate (type 0x11) against an AACS 2.0 LA
key. PROVISIONAL LAYOUT — the byte offsets below are NOT yet confirmed
against a genuine AACS 2.0 cert, and no such cert exists on this machine to
pin them (an exhaustive hunt found only 2.0 *content* certs, a different
format; a real host/drive AKE cert needs a live USB/SCSI capture). What IS
confirmed: first, the P-256/SHA-256 ECDSA primitive and curve constants are
correct (`ecdsa_verify_p256_verifies_a_genuine_aacs2_content_cert` verifies a
real 2.0 content cert under the published CC key); second, the 132-byte
4-byte-header framing MATCHES the step-6 drive-key message (x[4..36] /
y[36..68] / r[68..100] / s[100..132]), the natural 20→32-byte scaling of the
established AACS 1.0 84-byte key-message framing.
What is NOT confirmed: that a *certificate* shares that framing. No known
implementation verifies 2.0 certs against a confirmed layout (the published
spec draft leaves that format ambiguous), and the AACS 2.0 draft architecture
lists the 2.0 host cert (type 0x12) with a 6-byte Host ID + a NEW 4-byte
paired-Device-Key-Set field before the 64-byte public key — implying a
~140–144-byte cert with the pubkey near offset 12–16 and a signed range of
~76–80 bytes, NOT 68. The drive cert (type 0x11) has no paired-DKS field
(~140 bytes, pubkey ~[12..76], signed ~first 76). So the offsets HERE
(pubkey [4..68], signed [..68]) are a best-effort self-consistent
placeholder, chosen to match the confirmed key message; they will almost
certainly move when a real cert lands (see the `#[ignore]` real-cert slot).
This matters for USEFULNESS, not SAFETY: a wrong signed range makes
verification FAIL, and since the caller treats a verify failure as FATAL,
the path fails CLOSED (rejects) rather than trusting anything — never the
reverse.
The `>= 132` guard keeps the `cert[100..132]` slice safe while letting a
full-length cert reach verification (the old `>= 138` guard rejected every
132-byte input unconditionally).
The LA anchor is a PARAMETER, not the compiled-in constant, so a unit test
can drive the whole chain under a test LA keypair (sign a synthetic drive
cert, prove it is accepted / a bad-sig cert is rejected) without the real
anchor's private half, which no one has. Production always threads
`AACS2_LA_PUB_X/Y` from `aacs2_authenticate_p256`.
## Why an unknown drive-cert type must be rejected
This used to be an `if` / `else if` with NO `else`, so an unknown type
(e.g. 0x02) fell straight through: `is_aacs20` stayed false, so the step-6
key-signature check still ran — against `cert_pub_key(drive_cert)`, the
drive's OWN key lifted out of a cert that was never checked against the LA.
A rogue USB bridge could set byte 0 to 0x02, present its own keypair, sign
`host_nonce || drive_key_point`, and the handshake would return Ok with an
attacker-chosen bus key. The chain of trust must not appear to have run
when it did not.
## AACS 2.0 chain-of-trust gate (`aacs2_authenticate_p256_with_agid`)
The SAME shape the AACS 1.0 sibling (`aacs_authenticate_with_agid`)
enforces, now MANDATORY because this path is live. Two distinct rejections,
both BEFORE any drive key is trusted:
**(a) Cert TYPE gate.** An AACS 2.0 drive cert is type 0x11. Anything else
is unexpected on the native P-256 AKE and is REJECTED here, never allowed to
fall through. The earlier `if type==0x11 && !verify` form left NO branch for
a non-0x11 type: a rogue bridge could send a type-0x10 (or any) cert, skip
verification entirely, and still reach the step-6 key check against
`cert_pub_key_p256(drive_cert)` — its OWN key, lifted from a cert the LA
never signed — winning an attacker-chosen bus key. The chain of trust must
not merely LOOK like it ran.
**(b) Cert VERIFY is FATAL.** A type-0x11 cert whose P-256/SHA-256 signature
does not verify under the real 2.0 LA anchor (`AACS2_LA_PUB`, on-curve — see
`la_anchor_keys_are_on_curve`) is REJECTED, not logged-and-continued.
`ecdsa_verify_p256` range/curve-checks every point (`point_on_curve`), so a
forged or off-curve cert key cannot slip past. The previous "non-fatal for
backward compat" behaviour was the exact hole the SECURITY note demanded be
closed before wiring.
## `run_cert_handshake` — cert-agnostic AKE selection
The AACS 1.0 host cert is always present and is tried FIRST: an AACS 2.0
drive accepts a 1.0 host cert for backward compatibility, which is enough to
learn the Volume ID. If the drive REFUSES the 1.0 cert (a plain cert-level
rejection — NOT a dead or wedged bus) and THIS host cert also carries AACS
2.0 (P-256) credentials, fall back to the native P-256 AKE.
This does NOT branch on "do we possess 2.0 certs?" as a feature switch: it
consumes whatever the supplied `HostCert` carries, exactly as the 1.0 path
consumes `hc.certificate`. A host cert without v2 credentials simply has
nothing more to present and keeps its 1.0 error; a host cert WITH them
drives the P-256 AKE the moment the drive asks for it. When a real 2.0 host
cert is supplied at runtime, the P-256 handshake runs — no code change, no
possession gate.
A transport fault on the 1.0 attempt short-circuits the fallback:
re-running the P-256 AKE against a bus that is gone re-issues AGID
invalidation + SEND/REPORT KEY for nothing and buries the real (replug)
cause. The `Err` arm below still classifies it as `Transport`.
## AACS 2.0 (P-256) host-certificate handshake — live-path test plumbing
No genuine 2.0 host cert exists for us to sign with (a side agent is hunting
one; see the `#[ignore]` slot at the end). So the tests below prove the
PLUMBING under a self-generated test LA keypair: a 132-byte drive cert
signed by the test LA is threaded through the SAME code the production
anchor uses, only the anchor differs (a parameter, not a recompile). The
layout mirrored is the code's self-consistent 4-byte-header framing —
header(4)+pub_x(32)+pub_y(32)+sig_r(32)+sig_s(32)=132, LA signs SHA-256 over
cert[..68] — identical to the step-6 drive-key message framing and the
156-byte SEND KEY host-cert payload (4+20 nonce+132 cert).
## `ecdsa_verify_p256_verifies_a_genuine_aacs2_content_cert` — why it matters
SANITY: `ecdsa_verify_p256` + the P-256 curve constants + SHA-256 are correct
against GENUINE AACS 2.0 material. No real 2.0 host/drive AKE cert exists
anywhere on this machine (an exhaustive hunt found none — it needs a live
USB/SCSI AKE capture), but a real AACS 2.0 CONTENT certificate does
(`research/aacs/iso-aacs/CivilWar/Content000.cer`, type 0x10, 232 bytes). It
is signed by the AACS 2.0 *Content Cert* key (P-256/SHA-256, 64-byte
signature over the first `signed_len` bytes) — a DIFFERENT key for a
DIFFERENT purpose than the AKE LA anchor, but it exercises the EXACT same
`ecdsa_verify_p256` primitive `verify_cert_p256` relies on; only the pubkey
and the offsets differ. Proving it verifies here pins that the P-256
ECDSA/SHA-256 math and the `point_on_curve` gate are right, so when a
genuine 2.0 host/drive cert (and its correct signed range) lands, only the
offsets — not the crypto — remain to be fixed.
The CC pubkey is the published AACS 2.0 Content Certificate public key
(P-256). Corrupting any `P256_*` constant, or removing SHA-256, makes this
real-vector verification go red.
## Cert support
- AACS 1.0: custom 160-bit curve, SHA-1, 20-byte keys
- AACS 2.0: an AACS 2.0 drive accepts an AACS 1.0 host cert for backward
compatibility (that yields the Volume ID), and the native P-256/SHA-256
AACS 2.0 AKE is the fallback when the drive refuses the 1.0 cert AND the
supplied host cert carries AACS 2.0 (P-256) credentials. Both paths are
LIVE and share the same chain-of-trust hardening; see
`run_cert_handshake` for the cert-agnostic dispatch.
+16
View File
@@ -0,0 +1,16 @@
# `DriveProfile` field visibility and `allow(dead_code)`
Only `identity` and `signature` are public — they are what the catalog is
public FOR ("is this drive supported?", and the emulator's impersonation).
Everything below them is unlock MECHANISM: the MediaTek firmware image and
the per-drive vendor CDB templates. `Cargo.toml` says this crate is never
published because it carries drive firmware; re-exporting `DriveProfile`
with a `pub firmware: Vec<u8>` handed that firmware, and every vendor CDB,
to any downstream crate that called `ld::profile()`. They are `pub(crate)`
so the mechanism stays inside the unlocker.
`allow(dead_code)`: this is the on-disk catalog schema, and several
captured CDB templates have no in-crate consumer yet. They were `pub`
before, so the compiler saw the (external) use; making them `pub(crate)`
is what exposes them as unread. Deleting them would silently drop fields
from the format the profile-extraction pipeline emits.
+29
View File
@@ -0,0 +1,29 @@
# Module visibility rationale (`src/lib.rs`)
Why each unlocker module is `pub mod` or `mod`, and what's actually public
about it. The consumer never reaches an unlocker impl directly — only through
[`all_unlockers`] — so "public" here means "exposes something other than the
unlocker impl itself."
## `ld`
Public ONLY for its drive-profile catalog (`ld::profiles` / the `Profiles`
object) and, under the `emulation` feature, the unlock-handshake wire format
the bdemu test-emulator needs. The unlocker impl itself (`Mt1959Unlocker`) is
`pub(crate)` — clients still reach unlockers only through `all_unlockers`.
`aacs` and `css` carry no such public catalog, so they stay fully private.
## `renesas`
Public for its `is_renesas` drive-probe, which reports a dead bus as
`Err(UnlockError::Transport)` rather than "not a Renesas drive" — a
distinction the consumer needs before it can trust a negative probe result.
The unlocker impl (`Renesas`) is `pub(crate)` — reached only through
`all_unlockers`.
## `freemkv`
Carries no public catalog (the firmware self-identifies rather than matching
a bundled profile) and no emulation wire format yet, so it stays fully
private — the unlocker impl (`FreemkvUnlocker`) is reached only through
`all_unlockers`.
+9
View File
@@ -0,0 +1,9 @@
# `scsi::mock` — crate-wide mock transport
The crate-wide mock transport. Before this existed, EVERY mock in the crate
returned `Ok` with status 0 and a full `bytes_transferred` — so no test could
ever exercise the three outcomes the contract actually distinguishes, and a
whole class of transport-fault misclassification bugs sat uncaught (a dead bus
read as "this unlocker doesn't apply", a zero-filled buffer read as valid
drive data). This fixture can express all of them, and is the red-before-green
vehicle for the transport-contract fixes across ld / aacs / css / renesas.
+10
View File
@@ -0,0 +1,10 @@
# Why `Unlocked`'s `Debug` impl is hand-written
`Unlocked` is the public carrier that leaves this crate, and both `bus_key`
(the AACS bus key) and `vid` (the Volume ID that feeds VUK derivation) are key
material that must never reach a log or a test-failure message in plaintext.
The `AacsAuth` / `CertHandshake` siblings already redact; a derived `Debug`
here would print the very bytes they hide.
Presence is still observable (`Some`/`None`) so a log can say WHETHER a key
was obtained without revealing WHAT it is.
+197 -544
View File
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -149,11 +149,9 @@ mod tests {
let _ = AacsCert::new();
}
/// THE full success path through `Unlocker::unlock_bus` itself: a
/// self-consistent AACS 1.0 drive emulator carries auth + VID + data-key
/// reads all the way to `Ok(Unlocked { .. })`, so the crate's public entry
/// point — not just `run_cert_handshake` underneath it — is proven to wire
/// `volume_id` and `read_data_key` into `Unlocked` correctly.
// Full success path through `Unlocker::unlock_bus`: a self-consistent
// AACS 1.0 emulator proves the public entry point (not just
// `run_cert_handshake`) wires `volume_id`/`read_data_key` into `Unlocked`.
#[test]
fn unlock_bus_succeeds_end_to_end() {
let mut t = handshake::tests::DriveEmu::new();
+3 -4
View File
@@ -104,10 +104,9 @@ mod tests {
);
}
/// `is_transport_failure` is false for `CssAuthFailed`, false for a
/// `Scsi` error carrying a sense (a drive rejection, not a dead bus) even
/// at the transport-failure status, and true only for the genuine
/// transport fault (status + no sense).
// False for `CssAuthFailed` and for a `Scsi` error with a sense (a
// drive rejection, not a dead bus) even at transport-failure status;
// true only for the genuine transport fault (status + no sense).
#[test]
fn is_transport_failure_false_paths() {
assert!(!Error::CssAuthFailed.is_transport_failure());
+97 -224
View File
@@ -12,18 +12,9 @@ mod error;
use crate::css::error::{Error, Result, step_err};
use crate::scsi::{DataDirection, ScsiTransport};
/// Issue ONE CSS bus-auth CDB, honouring the transport contract.
///
/// The single seam every bus-auth step now goes through. It closes two holes
/// that each step open-coded before:
/// * `Err` was collapsed to `CssAuthFailed`, so a dead bus looked like "this
/// unlocker doesn't apply" and the consumer kept probing it ([`step_err`]);
/// * `Ok` was never inspected, but per the contract a CHECK CONDITION arrives
/// as `Ok` with a non-zero `status` — so a refused command handed the step
/// back its own zero-filled buffer to parse as drive data.
///
/// `min_bytes` is the number of bytes the step must actually receive (0 for a
/// write / no-data command, whose transferred count no transport promises).
// 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.
fn css_scsi(
scsi: &mut dyn ScsiTransport,
cdb: &[u8],
@@ -209,13 +200,9 @@ impl Default for DvdUnlocker {
impl crate::Unlocker for DvdUnlocker {
fn name(&self) -> &'static str {
// User-facing unlocker label. This unlocker's job is the DVD
// read-enablement bus-auth (it clears the drive's scrambled-read
// barrier and learns no key) — a property of the DVD medium, NOT of
// whether the content happens to be CSS-scrambled. Reporting it as
// "DVD" is honest: on any DVD the bus-auth ran; the CSS descramble
// itself is keyless and handled downstream, so it is not a separate
// "did an unlocker run" signal.
// "DVD" names the medium this bus-auth unlocks, not the CSS scheme:
// the bus-auth runs on any DVD regardless of whether the content is
// actually scrambled. See docs/css-mod.md#dvdunlocker-name.
"DVD"
}
@@ -227,10 +214,9 @@ impl crate::Unlocker for DvdUnlocker {
scsi: &mut dyn ScsiTransport,
_ctx: &crate::UnlockCtx,
) -> std::result::Result<crate::Unlocked, crate::UnlockError> {
// Self-guard against the hardware — do NOT trust the caller-declared
// DiscKind alone. If the drive does not report a DVD profile, refuse
// (NotApplicable) WITHOUT issuing any CSS CDB, so a mis-routed
// Blu-ray/UHD is never sent CSS bus-auth.
// Self-guard against the hardware, not just the caller-declared
// DiscKind: refuse (NotApplicable) without issuing any CSS CDB when
// the drive doesn't report a DVD profile.
if !mounted_disc_is_dvd(scsi)? {
tracing::debug!(
target: "freemkv::css",
@@ -247,14 +233,9 @@ impl crate::Unlocker for DvdUnlocker {
}
}
/// Transport-level "is the mounted disc a DVD?" probe (GET CONFIGURATION
/// current-profile, DVD family `0x0010..=0x001F`). Lets the DvdUnlocker
/// self-verify against the drive instead of trusting the caller's DiscKind.
///
/// `Err(Transport)` on a dead bus. This is the FIRST command the DVD unlocker
/// issues, and its match had no `Err` arm at all: a transport fault fell into
/// `_ => false`, which the caller turned into `NotApplicable` — a dead bus read
/// as "not a DVD", with nothing logged anywhere on the path.
// 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.
fn mounted_disc_is_dvd(
scsi: &mut dyn ScsiTransport,
) -> std::result::Result<bool, crate::UnlockError> {
@@ -305,19 +286,16 @@ fn mounted_disc_is_dvd(
fn unlock_css_reads_inner(scsi: &mut dyn ScsiTransport, _lba: u32) -> Result<()> {
tracing::debug!(target: "freemkv::css", "css unlock: begin");
// The bus-auth challenge-response sets the drive's Authentication Success
// Flag (ASF=1), which is what opens scrambled-sector reads. This is the
// ONLY step required to unlock reads; a failure here is fatal — we
// genuinely cannot read scrambled sectors.
// The bus-auth challenge-response sets ASF=1, which is what opens
// scrambled-sector reads. It's the only required step, so a failure
// here is fatal.
let agid = establish_authenticated_session(scsi).inspect_err(|e| {
tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: bus authentication failed");
})?;
tracing::debug!(target: "freemkv::css", agid, "css unlock: bus authentication ok");
// Disc-key REPORT KEY: issued BEST-EFFORT for any firmware that ties part
// of its read-unlock to it. The bytes are unused (the descramble key is
// recovered keylessly) and a failure is NON-FATAL — the gate is already
// open from bus-auth. This replaces the title-key REPORT KEY, whose hard
// failure used to abort the whole unlock (the 7014 bug on USB bridges).
// Disc-key REPORT KEY: best-effort only, for firmware that ties part of
// its read-unlock to it; bytes are unused and failure is non-fatal.
// 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)");
}
@@ -327,14 +305,8 @@ fn unlock_css_reads_inner(scsi: &mut dyn ScsiTransport, _lba: u32) -> Result<()>
// ── Step 1: Bus Authentication ────────────────────────────────────────────
/// Run the CSS bus-authentication challenge-response (invalidate AGIDs →
/// allocate AGID → host challenge → brute-force the variant → drive challenge →
/// send host key). Completing the handshake sets the drive's Authentication
/// Success Flag (ASF=1) — which is the ENTIRE purpose: it unlocks
/// scrambled-sector reads. Returns the negotiated AGID (the caller needs it for
/// the best-effort disc-key REPORT KEY). The CSS bus key is intentionally NOT
/// derived: descrambling is keyless — the key is recovered directly from the
/// data — so the bus key has no consumer.
// Runs the CSS bus-auth handshake to set ASF=1 (invalidate AGIDs, allocate,
// challenge/response). Returns the AGID; no bus key is derived. See docs/css-mod.md.
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.
@@ -453,22 +425,15 @@ fn authenticate_with_agid(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()>
)?;
// The authenticated session (ASF=1) is now established — scrambled-sector
// reads are unlocked, which is the only thing we needed. The CSS bus key
// would be CryptKey(2, variant, key1 || key2), but it has no consumer
// (descrambling is keyless — the key is recovered directly from the
// data), so it is not derived.
// reads are unlocked. The CSS bus key would be CryptKey(2, variant,
// key1 || key2), but has no consumer (descrambling is keyless).
Ok(())
}
// ── Step 2: Disc Key ──────────────────────────────────────────────────────
/// Issue READ DVD STRUCTURE format 0x02 (the Disc Key block — opcode 0xAD, NOT
/// the REPORT KEY 0xA4 disc-key block; format 0x01 is Copyright Information)
/// purely for the bus-auth unlock side
/// effect. The returned block contents are not used — the descramble title key
/// is recovered keylessly elsewhere, so the genuine disc-key REPORT KEY is
/// intentionally skipped. (If a drive is ever found where bus-auth alone does
/// not open scrambled reads, a real REPORT KEY format 0x02 belongs here.)
// Issues READ DVD STRUCTURE format 0x02 (opcode 0xAD) purely for its
// bus-auth side effect; returned bytes are unused. See docs/css-mod.md#read_disc_key.
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;
@@ -493,10 +458,8 @@ 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]). All internal callers pass key_type in
// 0..3 and variant in 0..32; the asserts document the contract for the
// pub(crate) test entry point test_crypt_key and turn a would-be
// out-of-bounds panic into an explicit precondition violation.
// VARIANTS/PERM_VARIANT ([_;32]). Asserts turn an out-of-bounds index
// into an explicit precondition violation. See docs/css-mod.md.
debug_assert!(key_type < 3, "crypt_key: key_type out of range");
debug_assert!((variant as usize) < 32, "crypt_key: variant out of range");
let perm = &PERM_CHALLENGE[key_type];
@@ -650,14 +613,9 @@ fn send_key_cdb(agid: u8, format: u8, param_len: u16) -> [u8; 12] {
mod tests {
use super::*;
/// SECURITY REGRESSION GUARD: no instrumentation in libfreemkv may emit
/// raw key material. Scan every source file for a `tracing` field that
/// binds a forbidden key name to a value-producing expression (`= expr`
/// or `%expr` / `?expr`). The only allowed forms are a string literal
/// (e.g. `disc_key = "<redacted>"`) or a `_fp` fingerprint field.
///
/// This is a source-scan test (not a runtime capture) so it stays cheap
/// and catches re-introductions at compile/CI time.
// 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.
#[test]
fn no_key_bytes_in_instrumentation() {
use std::path::Path;
@@ -709,10 +667,9 @@ mod tests {
continue;
}
for &name in forbidden {
// A fingerprint field (`<name>_fp = ...`) is allowed.
// Match `<name>` followed by optional fingerprint
// suffix then `=` and a value that is NOT a string
// literal redaction marker.
// A fingerprint field (`<name>_fp = ...`) is allowed;
// match `<name>` then `=` with a value that is not a
// string-literal redaction marker.
if let Some(idx) = line.find(name) {
let after = &line[idx + name.len()..];
let after = after.trim_start();
@@ -743,10 +700,9 @@ mod tests {
}
}
// Scan this crate's `src` plus the sibling workspace crates so the
// key-material logging guard covers every crate that can reach the
// CSS/AACS internals, not just libfreemkv. Missing sibling dirs (e.g.
// when building the crate standalone) are simply skipped.
// Scan this crate's `src` plus sibling workspace crates so the
// guard covers everything that can reach CSS/AACS internals.
// Missing sibling dirs (standalone builds) are simply skipped.
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace = manifest.parent().unwrap_or(manifest);
let mut violations = Vec::new();
@@ -796,15 +752,8 @@ mod tests {
// ── CSS constant-table integrity ───────────────────────────────────────
/// Each PERM_CHALLENGE row is a permutation of indices 0..10 (it reorders
/// the 10 challenge bytes). A non-permutation would drop/duplicate
/// challenge bytes, weakening or corrupting the bus key derivation.
///
/// Grounding: crypt_key does `scratch[i] = challenge[perm[i]]` for i in
/// 0..10 — perm must be a bijection on 0..10 to use every challenge byte
/// exactly once.
/// Mutation: change PERM_CHALLENGE[0] entry `9` to `8` (duplicate) -> the
/// "covers 0..10" assert fires.
// 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.
#[test]
fn perm_challenge_rows_are_permutations() {
for (row, perm) in PERM_CHALLENGE.iter().enumerate() {
@@ -821,15 +770,9 @@ mod tests {
}
}
/// Each PERM_VARIANT row maps the 32 variants to 32 distinct 5-bit values
/// (it is a permutation of 0..32). key_type 1 uses PERM_VARIANT[0],
/// key_type 2 uses PERM_VARIANT[1] to pick the css_variant; a collision
/// would make two variants indistinguishable.
///
/// Grounding: `css_variant = PERM_VARIANT[k][variant]` then indexes
/// VARIANTS[css_variant] (0..32).
/// Mutation: set PERM_VARIANT[0][1] = PERM_VARIANT[0][0] -> duplicate
/// assert fires; also any value >= 32 would later index VARIANTS OOB.
// 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() {
@@ -849,16 +792,8 @@ mod tests {
// ── crypt_key behaviour ────────────────────────────────────────────────
/// crypt_key result depends on every challenge byte. The challenge is
/// permuted into `scratch` and folded through the LFSR seeding and the 6
/// XOR rounds. Flipping any single challenge byte must change the output.
///
/// Grounding: scratch[i]=challenge[perm[i]] for all 10 i, and scratch
/// seeds both LFSRs (bytes 5..10 via tmp1) and the round terms (bytes
/// 0..5).
/// Mutation: in `scratch[i] = challenge[perm[i]]` replace with
/// `challenge[i]` for a perm that drops a byte — or hardcode one scratch
/// entry — and some challenge byte stops mattering; this fails.
// 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.
#[test]
fn crypt_key_depends_on_every_challenge_byte() {
let base: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
@@ -874,17 +809,9 @@ mod tests {
}
}
/// crypt_key(0, v, ..) must produce a DISTINCT result for each of the 32
/// variants on a fixed challenge. bus_auth brute-forces the variant by
/// matching crypt_key(0, v, host_challenge) == key1; if two variants
/// collided, the wrong variant could be selected and the whole auth
/// derail.
///
/// Grounding: variant selects css_variant -> VARIANTS[css_variant] -> cse,
/// which feeds every round; distinct variants give distinct cse-driven
/// keys in practice.
/// Mutation: make `cse` ignore the variant (e.g. `let cse = 0`) -> all 32
/// outputs collapse to one value; the distinctness assert fires.
// 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.
#[test]
fn crypt_key_type0_distinct_per_variant() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
@@ -899,16 +826,9 @@ mod tests {
}
}
/// crypt_key enforces its documented precondition `key_type < 3` via
/// debug_assert (active in test builds). A key_type of 3 would index
/// PERM_CHALLENGE (len 3) out of bounds; the assert turns that into an
/// explicit precondition panic.
///
/// Grounding: `debug_assert!(key_type < 3, ...)`; PERM_CHALLENGE has 3
/// rows (indices 0,1,2).
/// Mutation: delete the debug_assert AND the match-arm guard — but the
/// match `_ =>` arm would then index PERM_CHALLENGE[3] OOB and panic
/// differently; with the assert in place this test pins the contract.
// 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.
#[test]
#[should_panic]
fn crypt_key_rejects_out_of_range_key_type() {
@@ -916,12 +836,9 @@ mod tests {
let _ = crypt_key(3, 0, &challenge);
}
/// crypt_key enforces `variant < 32` via debug_assert. A variant of 32
/// would index VARIANTS / PERM_VARIANT (len 32) out of bounds.
///
/// Grounding: `debug_assert!((variant as usize) < 32, ...)`.
/// Mutation: removing the assert makes this index VARIANTS[32] (still a
/// panic, but unguarded); the assert documents/enforces the contract.
// 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.
#[test]
#[should_panic]
fn crypt_key_rejects_out_of_range_variant() {
@@ -931,17 +848,8 @@ mod tests {
// ── SCSI CDB builders (MMC REPORT KEY / SEND KEY layout) ───────────────
/// report_key_cdb encodes a 12-byte MMC REPORT KEY (opcode 0xA4) CDB:
/// byte 0 = operation code 0xA4
/// bytes 8-9 = allocation length, big-endian
/// byte 10 = (AGID << 6) | (key_format & 0x3F)
/// All other bytes are zero.
///
/// Grounding: MMC REPORT KEY CDB; the AGID is the top 2 bits of byte 10,
/// key format the low 6 bits.
/// Mutation: change `(alloc_len >> 8)` to `alloc_len` for byte 8 (lose the
/// big-endian split) -> byte 8/9 assert fails. Change `agid << 6` to
/// `agid << 5` -> the AGID-position assert fails.
// report_key_cdb encodes a 12-byte MMC REPORT KEY (opcode 0xA4) CDB:
// byte 0=0xA4, bytes 8-9=big-endian len, byte 10=(AGID<<6)|(format&0x3F).
#[test]
fn report_key_cdb_matches_mmc_layout() {
let cdb = report_key_cdb(0b10, 0x04, 0x010C); // AGID=2, format=0x04, len=268
@@ -962,26 +870,18 @@ mod tests {
assert_eq!(cdb.len(), 12, "REPORT KEY is a 12-byte CDB");
}
/// The key format field is masked to 6 bits: a format with high bits set
/// must not corrupt the AGID. report_key_cdb(0, 0xFF, _) -> byte 10 low 6
/// bits = 0x3F, AGID = 0.
///
/// Grounding: `(agid << 6) | (format & 0x3F)`.
/// Mutation: drop the `& 0x3F` mask -> 0xFF would overwrite the AGID bits;
/// byte 10 would be 0xFF not 0x3F, this fails.
// 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);
assert_eq!(cdb[10], 0x3F, "format masked to 6 bits, AGID stays 0");
}
/// send_key_cdb encodes a 12-byte MMC SEND KEY (opcode 0xA3) CDB with the
/// parameter-list length at bytes 8-9 (big-endian) and AGID/format at byte
/// 10.
///
/// Grounding: MMC SEND KEY CDB layout.
/// Mutation: change opcode to SCSI_REPORT_KEY -> opcode assert fails;
/// swap bytes 8/9 -> length assert fails.
// 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.
#[test]
fn send_key_cdb_matches_mmc_layout() {
let cdb = send_key_cdb(0b11, 0x03, 0x000C); // AGID=3, format=3, param_len=12
@@ -996,16 +896,9 @@ mod tests {
assert_eq!(cdb.len(), 12);
}
/// Allocation length larger than 255 must split across bytes 8 (high) and
/// 9 (low) — a 16-bit big-endian field. report_key_cdb with alloc_len
/// 0x0804 (2052, the disc-key block size used in read_disc_key) -> byte 8
/// = 0x08, byte 9 = 0x04.
///
/// Grounding: read_disc_key uses `alloc_len = 2048 + 4 = 2052 = 0x0804`
/// and writes `cdb[8] = (alloc_len >> 8); cdb[9] = alloc_len`.
/// Mutation: write only byte 9 (`cdb[9] = alloc_len as u8`) without byte 8
/// -> the drive sees a 4-byte transfer, truncating the disc-key block;
/// this asserts the high byte is present.
// 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);
@@ -1013,10 +906,9 @@ mod tests {
assert_eq!(cdb[9], 0x04, "low byte of 2052-byte transfer");
}
/// The unlocker's user-facing name is "DVD" (the medium it read-unlocks),
/// not "CSS" (the scheme). Apps render the unlocker report from this name,
/// so it is a stable contract — the bus-auth ran on any DVD, encrypted or
/// not, and the report must say so rather than conflate it with a CSS crack.
// The unlocker's user-facing name is "DVD" (the medium), not "CSS" (the
// scheme); apps render the unlocker report from this name, so it is a
// stable contract.
#[test]
fn dvd_unlocker_is_named_dvd() {
use crate::Unlocker;
@@ -1056,10 +948,9 @@ mod tests {
assert_eq!(r.unwrap_err(), UnlockError::NotApplicable);
}
/// Defense in depth: even when the caller declares `DiscKind::Css`, the
/// DvdUnlocker self-verifies against the drive's GET CONFIGURATION profile.
/// A drive reporting a Blu-ray profile → `NotApplicable`, and NOT a single
/// CSS CDB is issued (no bus-auth fired at a BD).
// Defense in depth: even when the caller declares `DiscKind::Css`,
// DvdUnlocker self-verifies against GET CONFIGURATION; a BD profile
// must yield NotApplicable with no CSS CDB issued.
#[test]
fn dvd_unlocker_self_guards_against_non_dvd() {
use crate::scsi::{DataDirection, ScsiResult};
@@ -1120,11 +1011,9 @@ mod tests {
use crate::scsi::mock::{MockTransport, Reply};
use crate::{DiscKind, DriveId, UnlockCtx, UnlockError, Unlocker};
/// THE defect-7 test. `mounted_disc_is_dvd` is the FIRST command the DVD
/// unlocker issues and its match had no `Err` arm: a transport fault fell
/// into `_ => false`, so a dead bus was reported as "not a DVD"
/// (`NotApplicable`) and the consumer kept probing it — with nothing logged
/// anywhere on the path. Catches restoring that catch-all arm.
// 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.
#[test]
fn transport_fault_probing_for_a_dvd_aborts() {
let id = DriveId::default();
@@ -1149,11 +1038,9 @@ mod tests {
assert_eq!(r.unwrap_err(), UnlockError::NotApplicable);
}
/// A DVD is mounted, then the bus dies during bus-auth. THE defect-2 test:
/// every CSS step used to do `.map_err(|_| CssAuthFailed)`, discarding the
/// `ScsiError` and making the `Transport` arm of the error conversion
/// unreachable from any real call site — so a dead bus arrived as
/// `NotApplicable` and the consumer went on probing it.
// 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.
#[test]
fn transport_fault_during_bus_auth_aborts() {
let id = DriveId::default();
@@ -1178,10 +1065,9 @@ mod tests {
assert_eq!(r.unwrap_err(), UnlockError::NotApplicable);
}
/// A conforming transport answering the AGID allocation with a CHECK
/// CONDITION must NOT let the handshake carry on off the caller's own
/// zero-filled buffer (agid 0, a challenge answered by zeros). Catches
/// dropping the `status` check in `css_scsi`.
// A CHECK CONDITION on AGID allocation must not let the handshake carry
// on off the caller's own zero-filled buffer; catches a dropped
// `status` check in `css_scsi`.
#[test]
fn agid_allocation_check_condition_fails_the_auth() {
// 4 AGID invalidations are best-effort; the 5th command is the alloc.
@@ -1198,10 +1084,9 @@ mod tests {
assert!(matches!(e, Error::CssAuthFailed));
}
/// Defect 18: an AGID allocated and then lost to a failed challenge must be
/// RELEASED (REPORT KEY format 0x3F), not abandoned. A drive has four; the
/// leak self-heals because the next session invalidates all of them, but
/// leaving a drive short between attempts is a state we should not create.
// Defect 18: an AGID lost to a failed challenge must be RELEASED
// (REPORT KEY format 0x3F), not abandoned, even though a drive's four
// AGIDs self-heal on the next session's invalidation pass.
#[test]
fn a_failed_handshake_releases_the_agid_it_allocated() {
let mut t = MockTransport::scripted(
@@ -1281,10 +1166,9 @@ mod tests {
);
}
/// Host challenge + Key1 both succeed, but the drive challenge REPORT KEY
/// (step 3) is refused. Distinct from `key1_report_failure_fails_the_auth`
/// (step 2) and `key2_send_failure_fails_the_auth` (step 4) below — each
/// pins a different `css_scsi` call site's `?`.
// Host challenge + Key1 succeed but the drive-challenge REPORT KEY
// (step 3) is refused; distinct from the step-2 and step-4 failure
// tests, each pinning a different `css_scsi` call site's `?`.
#[test]
fn drive_challenge_report_failure_fails_the_auth() {
struct FailAtDriveChallenge(FakeDvdDrive);
@@ -1368,13 +1252,8 @@ mod tests {
// ── Full happy-path bus-auth ────────────────────────────────────────────
/// A fake drive that plays its half of the CSS bus-auth handshake for
/// real: it captures the host's genuinely-random per-session challenge
/// and answers Key1 with the actual `crypt_key(0, variant, ..)` for a
/// fixed `variant`, so the host's brute-force loop matches it honestly
/// (not via a canned constant the loop happens to hit). Also answers
/// GET CONFIGURATION as a DVD and READ DVD STRUCTURE (disc key) as a
/// success, so the full `unlock_bus` path can be driven end to end.
// A fake drive that plays its half of the CSS handshake for real:
// answers Key1 honestly, and DVD/disc-key probes as success.
struct FakeDvdDrive {
variant: u8,
host_challenge: [u8; 10],
@@ -1445,12 +1324,9 @@ mod tests {
}
}
/// End-to-end happy path for the bus-auth handshake: AGID allocation,
/// host challenge, a Key1 that genuinely matches the brute-forced
/// variant, the drive-challenge/Key2 exchange, and the disc-key REPORT
/// KEY all succeed. Exercises `establish_authenticated_session`'s `Ok`
/// return and `read_disc_key`'s success path, neither of which the
/// failure-path tests above reach.
// End-to-end happy path for the bus-auth handshake (AGID, challenge,
// Key1/Key2, disc-key REPORT KEY), exercising the `Ok` returns that
// the failure-path tests above never reach.
#[test]
fn full_bus_auth_and_disc_key_succeed() {
let mut t = FakeDvdDrive {
@@ -1463,10 +1339,9 @@ mod tests {
read_disc_key(&mut t, agid).expect("disc-key REPORT KEY succeeds");
}
/// The same happy path driven through the public `unlock_bus` entry
/// point (DVD self-guard, full bus-auth, best-effort disc key), proving
/// `DvdUnlocker::unlock_bus` returns `Ok(Unlocked::default())` on success
/// — the path every failure-injection test above deliberately avoids.
// The same happy path through the public `unlock_bus` entry point,
// proving it returns `Ok(Unlocked::default())` on success — the path
// every failure-injection test above deliberately avoids.
#[test]
fn dvd_unlocker_succeeds_end_to_end() {
let id = DriveId::default();
@@ -1497,10 +1372,9 @@ mod tests {
unlock_css_reads(&mut t, 0).expect("css bus-auth + best-effort disc key succeed");
}
/// A drive whose bus-auth succeeds but refuses the best-effort disc-key
/// REPORT KEY (CHECK CONDITION). `unlock_css_reads_inner` must swallow
/// that failure — the read barrier is already open from bus-auth, so the
/// whole unlock must still report `Ok`, not propagate the disc-key error.
// A drive whose bus-auth succeeds but refuses the best-effort disc-key
// REPORT KEY; `unlock_css_reads_inner` must swallow that failure since
// the read barrier is already open from bus-auth.
struct DiscKeyRefusingDrive(FakeDvdDrive);
impl ScsiTransport for DiscKeyRefusingDrive {
@@ -1532,10 +1406,9 @@ mod tests {
.expect("bus-auth succeeded; a refused best-effort disc-key read must not fail it");
}
/// `crypt_key`'s `key_type == 2` arm (`PERM_VARIANT[1]`) has no
/// production caller (only key_type 0, the variant brute-force, and 1,
/// the drive-challenge Key2, are ever invoked from `authenticate_with_agid`)
/// but is a pure, directly-testable function of its documented contract.
// `crypt_key`'s key_type==2 arm (`PERM_VARIANT[1]`) has no production
// caller (only 0 and 1 are invoked from `authenticate_with_agid`) but
// is a pure, directly-testable function.
#[test]
fn crypt_key_type2_is_deterministic_and_distinct_from_type1() {
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
+311 -643
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
THIRD-PARTY NOTICE for profiles.json
====================================
`profiles.json` in this directory contains volatile, RAM-only optical-drive
microcode ("LibreDrive") authored and owned by the MakeMKV team (GuinpinSoft
inc.). Each payload embeds its own notice:
"This is volatile RAM-only microcode for LibreDrive by MakeMKV -
http://www.makemkv.com/libredrive/
Copyright (c) MakeMKV team, all rights reserved."
This microcode is third-party proprietary material — Copyright (c) MakeMKV team,
all rights reserved — and is NOT covered by this repository's MIT LICENSE. No
ownership is claimed. See the repository-root NOTICE. Thanks to the MakeMKV team
for LibreDrive: https://www.makemkv.com/ , http://www.makemkv.com/libredrive/ .
+10
View File
@@ -19,3 +19,13 @@ This module performs only the **non-persistent** unlock — the access state it
sets up lives in drive RAM and is gone on power cycle. The one-time, permanent
drive preparation is the drive owner's own manual step and is **never**
automated here.
## Third-party microcode (`profiles.json`)
`profiles.json` bundles volatile, RAM-only optical-drive microcode
("LibreDrive") authored and owned by the **MakeMKV team** (GuinpinSoft inc.).
Each payload carries its own notice — *"Copyright (c) MakeMKV team, all rights
reserved."* This microcode is **third-party proprietary material and is NOT
covered by this repository's MIT license**; no ownership is claimed. See the
[`NOTICE`](./NOTICE) in this directory and the repository-root `NOTICE`. With
thanks to the MakeMKV team for [LibreDrive](http://www.makemkv.com/libredrive/).
+35 -84
View File
@@ -6,13 +6,9 @@
//! encryption AT THE DRIVE (the unlocked drive serves clear content) and
//! reporting the OEM Volume ID.
// `cdb` carries ONLY the unlock-handshake wire format that the bdemu emulator
// needs (the real unlocker drives its CDBs from per-drive profile templates, not
// these constants). Compile it only when the `emulation` feature exposes it, so
// it never dead-codes in a normal build.
// Compiled under `cfg(test)` as well as the feature: its tests pin the unlock
// wire format, and gating the whole module on a non-default feature meant CI
// (which builds with default features) never ran them.
// `cdb` is only the bdemu emulator's wire format (real unlocking uses profile
// templates), gated behind `emulation`; also compiled under `cfg(test)` so its
// wire-format tests run in default-feature CI. See docs/ld-mod.md — cdb gating.
#[cfg(any(feature = "emulation", test))]
mod cdb;
mod error;
@@ -24,12 +20,8 @@ use crate::scsi::{DataDirection, ScsiTransport};
use crate::{DriveId, UnlockCtx, UnlockError, Unlocked, Unlocker};
// ── Public profile catalog ──────────────────────────────────────────────────
//
// The catalog of drives the MT1959 unlocker recognizes is the one piece of
// ld worth exposing publicly: it answers "is this drive supported?" without
// unlocking, and the bdemu test-emulator reads it to impersonate a supported
// drive. The unlock *mechanism* (firmware blobs, upload sequence, CDB wire
// format) stays private — only the catalog and its match result are public.
// Only the catalog is public (supported-drive lookup, used by bdemu); the
// unlock mechanism stays private. See docs/ld-mod.md — public catalog design.
pub use profile::{DriveProfile as Profile, Identity, Platform, ProfileMatch, Profiles};
@@ -54,12 +46,9 @@ pub fn profile(drive_id: &DriveId) -> Option<ProfileMatch> {
#[cfg(feature = "emulation")]
pub use cdb::{UNLOCK_MARKER, is_unlock_read_buffer};
/// The MT1959 unlocker. `pub(crate)` — clients reach it only through
/// [`crate::all_unlockers`], never by name (the locked-design contract).
///
/// Matches a drive against the bundled profile database and, on a hit,
/// runs the MediaTek MT1959 firmware-unlock (and disc-speed calibration)
/// handshake over the raw SCSI transport.
// The MT1959 unlocker, reached only through `crate::all_unlockers` (never by
// name). Matches a drive against the bundled profile database and runs the
// firmware-unlock + disc-speed-calibration handshake over raw SCSI.
pub(crate) struct Mt1959Unlocker;
impl Mt1959Unlocker {
@@ -76,17 +65,9 @@ pub(crate) fn firmware_name(id: &DriveId) -> Option<&'static str> {
}
impl Mt1959Unlocker {
/// Read the OEM Volume ID via the matched profile's vendor CDB.
///
/// Takes the profile the caller ALREADY matched rather than re-running
/// `find_bundled`: the catalog is 206 entries and drive-prep used to scan it
/// twice per drive (once here, once in `firmware_unlock`) for the same
/// answer.
///
/// `Ok(Some(vid))` on a well-formed 36-byte response (signature `00 22 00`,
/// VID at `[4..20]`); `Ok(None)` when there is no OEM-VID CDB or the response
/// is short / bad-signature / drive-rejected (the drive is still unlocked,
/// just no VID); `Err` only on a transport fault.
// Read the OEM Volume ID via the matched profile's vendor CDB (profile passed
// in to avoid a redundant 206-entry catalog scan). Ok(Some) on a well-formed
// response; Ok(None) if unreadable; Err only on a transport fault.
fn read_oem_vid(
&self,
scsi: &mut dyn ScsiTransport,
@@ -101,10 +82,9 @@ impl Mt1959Unlocker {
let mut buf = vec![0u8; RESPONSE_LEN];
let result = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
// Per the transport contract a drive sense arrives as `Ok` with a
// non-zero `status`, NOT as `Err`. Without this check a CHECK CONDITION
// would be read as a successful 36-byte response and the caller's own
// zero fill parsed as a Volume ID.
// Per the transport contract a drive sense arrives as `Ok` with non-zero
// `status`, not `Err` — without this check a CHECK CONDITION reads as a
// successful response and the zero-filled buffer parses as a bogus VID.
if result.status != 0 {
tracing::warn!(
target: "freemkv::disc",
@@ -139,15 +119,9 @@ impl Mt1959Unlocker {
}
impl Mt1959Unlocker {
/// The MediaTek firmware unlock. Because the MT1959 unlocker removes AACS bus
/// encryption AT THE DRIVE (the unlocked drive serves CLEAR content), this ONE
/// operation satisfies BOTH the drive-features and the bus-removal capability
/// — so `unlock_features` and `unlock_bus` both delegate here. The result
/// carries `drive_unlocked: true` (no bus key needed) and the OEM Volume ID.
///
/// A no-firmware-route drive (Renesas / no profile) returns `NotApplicable`; a
/// transport fault propagates as `Transport`; any other firmware failure also
/// falls through as `NotApplicable`.
// The MediaTek firmware unlock. Since it removes AACS at the drive (clear
// content), this one op satisfies both features and bus-removal, so both
// trait methods delegate here. See docs/ld-mod.md — firmware_unlock contract.
fn firmware_unlock(
&self,
scsi: &mut dyn ScsiTransport,
@@ -167,14 +141,9 @@ impl Mt1959Unlocker {
// A transport fault → UnlockError::Transport; any other firmware failure
// → NotApplicable (via From<error::Error>).
mt.init(scsi)?;
// `init` only proves the handshake COMPLETED — `do_unlock` returns Ok on
// a response that carried the per-drive signature but not both firmware
// markers. Only `is_unlocked()` means the drive actually reached the
// extended-access state and serves clear content. Reporting
// `drive_unlocked: true` off `init` alone told the consumer the bus was
// clear on a half-unlocked drive, which suppressed the cert-auth
// fallback and shipped ciphertext at rc=0. A partial unlock must fall
// through to the next unlocker.
// `init` only proves the handshake completed, not that the drive reached
// extended-access state. Reporting unlocked off `init` alone shipped
// ciphertext at rc=0. See docs/ld-mod.md — half-unlock fallback.
if !mt.is_unlocked() {
tracing::warn!(
target: "freemkv::disc",
@@ -183,19 +152,13 @@ impl Mt1959Unlocker {
);
return Err(UnlockError::NotApplicable);
}
// Prime the per-region speed table. Best-effort — it must not fail the
// unlock — but NOT silent: speed calibration can fail completely, and an
// unlogged `let _ =` made a fully-failed calibration indistinguishable
// from a successful one in the rip log.
// Prime the per-region speed table. Best-effort (must not fail the unlock)
// but not silent — an unlogged `let _ =` made a failed calibration
// indistinguishable from success in the rip log.
if let Err(e) = mt.probe_disc(scsi) {
// A transport fault here is a DEAD BUS, not a speed-calibration miss.
// The rest of this path (`read_oem_vid`) is a no-op for the 140/206
// profiles that carry no `read_vid_cdb`, so it never touches the bus
// again — meaning this swallowed fault was the ONLY dead-bus signal,
// and warn-and-continue turned it into `Ok(Unlocked{drive_unlocked:
// true})`: a dead bus rendered as a fully-unlocked drive (the flagship
// failure-that-looks-like-success). A genuine calibration miss (drive
// sense / short reply) still continues on the default speed table.
// A transport fault here is a dead bus, not a calibration miss — most
// profiles never touch the bus again, so this was the only dead-bus
// signal. See docs/ld-mod.md — probe_disc dead-bus classification.
if e.is_transport_failure() {
tracing::warn!(
target: "freemkv::disc",
@@ -423,12 +386,9 @@ mod tests {
assert_eq!(err, UnlockError::NotApplicable);
}
/// THE defect-1 test. The drive answers every unlock READ_BUFFER with a
/// response carrying the per-drive signature and the primary firmware marker
/// but NOT the secondary one — `do_unlock` returns `Ok` and `init()`
/// succeeds, yet the drive is NOT in the extended-access state. Reporting
/// `drive_unlocked: true` here suppresses the cert-auth fallback. Catches
/// removing the `is_unlocked()` gate in `firmware_unlock`.
// THE defect-1 test: response carries the signature + primary marker but not
// the secondary one, so init() succeeds but the drive isn't in extended-access
// state. Catches removing the `is_unlocked()` gate in `firmware_unlock`.
#[test]
fn half_unlocked_drive_falls_through_instead_of_claiming_unlocked() {
use crate::scsi::mock::{MockTransport, Reply};
@@ -475,16 +435,9 @@ mod tests {
assert!(u.drive_unlocked);
}
/// THE probe-disc dead-bus test. The drive unlocks fully (both firmware
/// markers), then the bus DIES during disc-speed calibration. `probe_disc`
/// used to be warn-and-continued regardless of the fault, and — because the
/// 140/206 profiles without a `read_vid_cdb` never touch the bus again — a
/// dead bus was reported as `Ok(Unlocked{drive_unlocked:true})`: a dead bus
/// rendered as a successful unlock (the flagship failure-that-looks-like-
/// success). `firmware_unlock` must now abort with `Transport`.
/// MUTATION: reverting the `if e.is_transport_failure()` return in
/// `firmware_unlock` (warn-and-continue), OR flattening the probe loops'
/// transport classification, makes this go red.
// 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();
@@ -534,11 +487,9 @@ mod tests {
assert_eq!(err, UnlockError::Transport);
}
/// A drive-sense (not a dead bus) rejecting the speed probe is a genuine
/// calibration miss, not a transport fault — `firmware_unlock` must
/// warn-and-continue on the drive's default speed table, still reporting a
/// full unlock. The non-transport sibling of
/// `transport_fault_during_probe_disc_is_transport_not_a_successful_unlock`.
// A drive-sense (not a dead bus) rejecting the speed probe is a genuine
// calibration miss: warn-and-continue on the default speed table, still
// reporting a full unlock. Non-transport sibling of the probe-fault test.
#[test]
fn drive_sense_during_probe_disc_still_reports_a_successful_unlock() {
let id = known_vid_drive_id();
+2 -4
View File
@@ -31,10 +31,8 @@ mod tests {
use super::*;
use crate::scsi::mock::{MockTransport, Reply};
/// A minimal driver that takes every `PlatformDriver` default (`is_unlocked`)
/// so the shim's dispatch and the default's `false` are both exercised —
/// every real driver (`Mt1959`) overrides `is_unlocked`, so without this the
/// default body is dead in coverage even though it is reachable code.
// Minimal driver taking the `PlatformDriver` default `is_unlocked`, so
// the default's `false` path is exercised (real drivers override it).
struct StubDriver;
impl PlatformDriver for StubDriver {
+77 -149
View File
@@ -117,21 +117,17 @@ impl Mt1959 {
expected: usize,
) -> Result<usize> {
// The READ_BUFFER CDB transfer-length is a single byte; an
// `expected` above 255 cannot be expressed and would silently
// truncate. All in-crate callers pass small fixed sizes (4); guard
// the invariant rather than emit a malformed CDB.
// `expected` above 255 would silently truncate. Guard the
// invariant rather than emit a malformed CDB.
debug_assert!(
expected <= u8::MAX as usize,
"read_buffer_probe expected exceeds 1-byte CDB length field"
);
let cdb = self.read_buffer_sub(sub_cmd, address, expected as u8);
let result = scsi.execute(&cdb, DataDirection::FromDevice, buf, 5_000)?;
// A drive sense arrives as `Ok` with a non-zero status (the transport
// contract), and a merely SHORT response is the drive answering badly —
// neither is a dead bus. Fabricating a transport-failure status here
// turned a short probe reply into a hard abort of a rip that would
// otherwise have succeeded; a real transport fault still propagates
// through the `?` above with its own status.
// 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.
if result.status != 0 || result.bytes_transferred != expected {
return Err(Error::Scsi {
opcode: SCSI_READ_BUFFER,
@@ -183,13 +179,9 @@ impl Mt1959 {
});
}
// `response` is a fixed 64-byte buffer, so `response.len()` is
// always >= every offset below — the meaningful bound is how many
// bytes the drive actually delivered. Every marker check MUST be
// reachable: when the checks were each guarded by their own `n >= ..`
// the drive delivering ZERO bytes skipped all three and `do_unlock`
// returned `Ok` on a buffer the drive never wrote. Require enough bytes
// to actually validate, up front.
// `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.
let n = result.bytes_transferred.min(response.len());
if n < MIN_UNLOCK_RESPONSE {
tracing::debug!(
@@ -212,15 +204,9 @@ impl Mt1959 {
return Err(Error::UnlockFailed);
}
// Extended-access state is active when BOTH the per-drive
// signature matched AND the response carries the secondary
// marker at offset 16 (repeated through bytes 16..64) AND the
// primary mode marker at [12..16] is present. The active-mode
// marker at [12..16] is the primary gate; the [16..20] marker
// is the redundant confirmation the firmware writes through
// the rest of the response. Requiring both before we tell the
// upper layer "OEM path is live" keeps any partial / corrupted
// response from steering us off the cert-auth fallback.
// 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;
@@ -295,11 +281,8 @@ impl Mt1959 {
);
return Err(Error::UnlockFailed);
}
// A transport fault means the bus is gone. Re-uploading firmware
// into a dead bus three times accomplishes nothing and the
// generic UnlockFailed it used to end on is classified as
// "unlocker not applicable", so the consumer kept probing a dead
// drive instead of aborting. Surface the fault unchanged.
// A dead bus must abort here, not be retried three times as
// firmware reloads. See docs/mt1959.md — run-init-transport-fault.
Err(e) if e.is_transport_failure() => {
tracing::warn!(
target: "freemkv::disc",
@@ -365,10 +348,8 @@ impl Mt1959 {
self.do_unlock(scsi)?;
}
// Detect disc type from capacity to select probe mode.
// BD: 3C 01 44 12 01 00 00 00 04 00 (init_addr = 0x0100)
// UHD: 3C 01 44 12 02 00 00 00 04 00 (init_addr = 0x0200)
// Empirically verified via SCSI capture: BD and UHD use different init addresses.
// Detect disc type from capacity to select probe mode (BD vs UHD
// init address). See docs/mt1959.md — probe-init-address-detection.
let cap_cdb = [
SCSI_READ_CAPACITY,
0x00,
@@ -382,16 +363,12 @@ impl Mt1959 {
0x00,
];
let mut cap_buf = [0u8; READ_CAPACITY_RESPONSE_SIZE];
// A transport fault here is a dead bus: propagate it (the caller
// classifies it as `Transport` and aborts) instead of silently
// defaulting the disc type. A drive sense (`Ok`, non-zero status) or a
// short reply just means "capacity unknown" -> assume BD.
// A transport fault here is a dead bus: propagate it. A drive
// sense or short reply just means "capacity unknown" -> assume BD.
let cap = scsi.execute(&cap_cdb, DataDirection::FromDevice, &mut cap_buf, 5_000)?;
let disc_sectors = if cap.status == 0 && cap.bytes_transferred >= 4 {
// last_lba + 1 = sector count. A 0xFFFFFFFF last-LBA is the
// READ CAPACITY(10) "capacity exceeds 32 bits" sentinel; saturate
// rather than wrap to 0 (which would misclassify a huge disc as
// BD). A saturated count stays above the UHD threshold -> UHD.
// last_lba + 1 = sector count; saturate on the 32-bit-overflow
// sentinel rather than wrap to 0. See docs/mt1959.md — capacity-saturation.
u32::from_be_bytes([cap_buf[0], cap_buf[1], cap_buf[2], cap_buf[3]]).saturating_add(1)
} else {
0
@@ -434,14 +411,9 @@ impl Mt1959 {
);
})
{
// A dead bus MUST keep its transport classification: this loop is
// reached from `probe_disc`, whose caller aborts the rip only when
// it sees `Transport`. Flattening every failure to `UnlockFailed`
// (which classifies as "not applicable") let a bus that died mid
// speed-probe fall through to `Ok(Unlocked{drive_unlocked:true})`.
// A short / rejected probe reply is still the drive's OWN failure —
// keep it as `UnlockFailed` so it doesn't hard-abort a rip that
// could continue on the default speed table.
// 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.
if e.is_transport_failure() {
return Err(e);
}
@@ -450,10 +422,8 @@ impl Mt1959 {
addr = addr.wrapping_add(PROBE_STEP);
}
// Pass 2: continue past the coarse range. It used to restart at 0 with
// the SAME PROBE_STEP, re-issuing all 88 pass-1 addresses verbatim — 88
// redundant SCSI round-trips per disc, which dominates drive prep at
// real bus latency and teaches the drive's speed table nothing new.
// 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];
@@ -464,12 +434,9 @@ impl Mt1959 {
&mut resp,
PROBE_RESPONSE_SIZE as usize,
) {
// Fine probing is best-effort — a short / rejected reply just ends
// the sweep early. But a bus that DIED here must not be swallowed:
// a bare `break` returned `Ok(())` from `run_probe`, so a dead bus
// mid-pass-2 vanished into `Ok(Unlocked{drive_unlocked:true})` with
// no log at all (the flagship failure-that-looks-like-success). A
// transport fault keeps its classification and aborts.
// 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.
if e.is_transport_failure() {
tracing::warn!(
target: "freemkv::disc",
@@ -619,10 +586,9 @@ mod tests {
assert_eq!(len, 2208, "MODE SELECT length = firmware.len(), not 2496");
}
/// variant_a had no direct test at all (variant_b did). Pins its upload
/// sequence: WRITE_BUFFER carrying the firmware at its real per-drive
/// length in the CDB's 24-bit length field, then the 0x45 verify READ_BUFFER
/// whose result used to be discarded outright.
// Pins variant_a's upload sequence: WRITE_BUFFER at the real per-drive
// length in the CDB's 24-bit length field, then the 0x45 verify
// READ_BUFFER whose result used to be discarded outright.
#[test]
fn variant_a_uploads_at_the_real_length_and_issues_the_verify_read() {
let mut profile = fixture_profile([0x11, 0x22, 0x33, 0x44]);
@@ -660,12 +626,9 @@ mod tests {
assert!(t.cdbs.is_empty(), "no CDB may reach the drive");
}
/// THE defect-15 test. A drive that returns ZERO bytes used to skip the
/// signature check, the firmware-active check AND the mode check — every
/// gate was individually guarded by its own `n >= ..` — so `do_unlock`
/// returned `Ok(vec![0u8; 64])` and (with the old `drive_unlocked: true`)
/// reported a fully unlocked drive. Catches restoring those per-check
/// guards.
// 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.
#[test]
fn do_unlock_rejects_a_zero_length_response() {
let sig = [0x99, 0x9E, 0xC3, 0x75];
@@ -706,10 +669,9 @@ mod tests {
assert!(!e.is_transport_failure(), "a sense is not a dead bus");
}
/// THE defect-16 test (the inverse misclassification). A merely SHORT probe
/// response used to be fabricated into a transport-failure status, which
/// hard-aborts a rip that would otherwise have succeeded. Catches
/// reintroducing the fabricated 0xFF.
// THE defect-16 test: a merely short probe response used to be
// fabricated into a transport-failure status, hard-aborting a rip
// that would otherwise have succeeded.
#[test]
fn short_probe_response_is_not_a_transport_failure() {
let mut t = crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::short(
@@ -744,11 +706,9 @@ mod tests {
assert!(e.is_transport_failure());
}
/// THE defect-4 test. A dead bus must abort `run_init` at once. It used to
/// match `Err(_)` and re-upload firmware into the dead bus on all three
/// attempts, then return the generic `UnlockFailed`, which classifies as
/// `NotApplicable` — so the consumer kept probing. Catches removing the
/// `is_transport_failure` arm.
// 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.
#[test]
fn run_init_aborts_on_a_transport_fault_without_reloading_firmware() {
let mut t =
@@ -823,11 +783,9 @@ mod tests {
}
}
/// A drive SENSE (not a dead bus) during the coarse speed probe is the
/// drive's OWN failure, not a transport fault — `run_probe` must report
/// `UnlockFailed`, not `Transport`. The sibling of
/// `transport_fault_during_pass1_probe_stays_a_transport_failure`, which
/// only exercises the `is_transport_failure()` arm.
// A drive SENSE (not a dead bus) during the coarse probe is the
// drive's own failure — `run_probe` must report `UnlockFailed`, not
// `Transport`. Sibling of the pass-1 transport-fault test below.
#[test]
fn drive_sense_during_pass1_probe_is_unlock_failed_not_transport() {
struct SenseAtFirstProbe;
@@ -866,12 +824,9 @@ mod tests {
assert!(matches!(e, Error::UnlockFailed));
}
/// THE probe pass-1 dead-bus test. A bus that dies during the coarse speed
/// probe must keep its transport classification out of `run_probe` so the
/// caller (`firmware_unlock`) aborts with `Transport` rather than reporting a
/// fully-unlocked drive.
/// MUTATION: flattening the pass-1 error to `Error::UnlockFailed` makes the
/// `is_transport_failure` assert go red (UnlockFailed maps to NotApplicable).
// 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.
#[test]
fn transport_fault_during_pass1_probe_stays_a_transport_failure() {
let mut t = ProbeFaultsTransport { fault_at: 0 };
@@ -884,12 +839,9 @@ mod tests {
assert_eq!(crate::UnlockError::from(e), crate::UnlockError::Transport);
}
/// THE probe pass-2 dead-bus test. Pass 1 completes; the bus dies in the fine
/// probe. The loop used to `break` on ANY error and return `Ok(())`, so a
/// dead bus mid-pass-2 vanished into `Ok`. It must now propagate a transport
/// fault (a short/rejected fine probe still ends the sweep benignly).
/// MUTATION: restoring the bare `break` makes `expect_err` panic (run_probe
/// returns Ok).
// 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.
#[test]
fn transport_fault_during_pass2_probe_stays_a_transport_failure() {
let mut t = ProbeFaultsTransport {
@@ -904,10 +856,8 @@ mod tests {
assert_eq!(crate::UnlockError::from(e), crate::UnlockError::Transport);
}
/// `validate`'s transport-abort branch had no test. A dead bus must propagate
/// as a transport fault on the FIRST attempt, not be retried five times.
/// MUTATION: turning the `Err(e) => return Err(Error::from(e))` arm into a
/// `continue` makes this exhaust to `UnlockFailed` and the assert go red.
// `validate`'s transport-abort branch: a dead bus must propagate on
// the first attempt, not be retried five times.
#[test]
fn validate_propagates_a_transport_fault_without_retrying() {
let mut t =
@@ -932,13 +882,9 @@ mod tests {
assert_eq!(t.calls(), 5, "retries the full five attempts");
}
/// variant_a's firmware-verify-read transport-abort branch (~variant_a.rs:97)
/// had no test. WRITE_BUFFER succeeds, the 0x45 verify read faults: a dead bus
/// must abort THERE, not be swallowed into the unlock retries.
/// MUTATION: dropping the `if err.is_transport_failure() { return Err }` lets
/// execution fall through to `do_unlock` (scripted to answer, so it fails on
/// the signature as `UnlockFailed`), and the `is_transport_failure` assert
/// goes red — a genuine red-before-green, not green-both-ways.
// 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.
#[test]
fn variant_a_verify_read_transport_fault_aborts() {
let mut profile = fixture_profile([0; 4]);
@@ -974,14 +920,9 @@ mod tests {
assert!(t.cdbs.is_empty(), "no CDB may reach the drive");
}
/// THE full success path: WRITE_BUFFER upload, a fully-good verify read
/// (exercising the `Ok(r) if status==0 && bytes_transferred==len` arm no
/// other variant_a test reaches), then BOTH `do_unlock` calls succeed —
/// the first fatally-gated, the second best-effort. Every other
/// variant_a test above deliberately stops short (empty firmware, a
/// dead bus, or `do_unlock` failing against `RecordingTransport`'s empty
/// response) — this is the only one that reaches `load_firmware`'s final
/// `Ok(())`.
// THE full success path: WRITE_BUFFER upload, a fully-good verify
// read, then both `do_unlock` calls succeed. The only variant_a test
// that reaches `load_firmware`'s final `Ok(())`.
#[test]
fn variant_a_load_firmware_succeeds_end_to_end() {
let sig = [0x11, 0x22, 0x33, 0x44];
@@ -1002,12 +943,9 @@ mod tests {
assert!(mt.init_complete, "do_unlock #1 must have set init_complete");
}
/// variant_b had no transport-abort test. A firmware-upload STEP that hits a
/// dead bus (here the metadata read) must abort via `trace_step`, not be
/// swallowed and carried into the unlock retries.
/// MUTATION: dropping `trace_step`'s `if err.is_transport_failure()` return
/// lets execution reach `do_unlock` (scripted good → signature fails as
/// `UnlockFailed`), so the `is_transport_failure` assert goes red.
// 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.
#[test]
fn variant_b_upload_step_transport_fault_aborts() {
let mut profile = fixture_profile([0; 4]);
@@ -1056,12 +994,9 @@ mod tests {
}
}
/// Build a synthetic 64-byte unlock response.
///
/// `mode_marker`: bytes [12..16]. Pass `FIRMWARE_ACTIVE_SIG` for the
/// active-mode primary marker.
/// `id_marker`: bytes [16..20] (and repeated through [20..64] in
/// real responses; only [16..20] is checked).
// Build a synthetic 64-byte unlock response. `mode_marker` fills
// bytes [12..16]; `id_marker` fills bytes [16..20] (repeated through
// [20..64] in real responses; only [16..20] is checked).
fn build_response(signature: [u8; 4], mode_marker: [u8; 4], id_marker: [u8; 4]) -> Vec<u8> {
let mut r = vec![0u8; 64];
r[0..4].copy_from_slice(&signature);
@@ -1144,11 +1079,9 @@ mod tests {
assert_eq!(t.calls(), 1, "must not retry a signature mismatch");
}
/// `run_init`'s retry loop: `do_unlock` fails generically (too short to
/// validate — not a signature mismatch, not a transport fault), the
/// firmware reload it falls back to also fails generically (an empty
/// firmware blob) rather than fatally, so the loop must `continue` and
/// try again — three times — before exhausting to the last error.
// `run_init`'s retry loop: a generic `do_unlock` failure plus a
// generic firmware-reload failure must `continue` and retry three
// times before exhausting to the last error.
#[test]
fn run_init_exhausts_after_three_generic_failures() {
let mut t = crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::short(
@@ -1168,11 +1101,9 @@ mod tests {
);
}
/// Same shape as `run_init_exhausts_after_three_generic_failures`, but for
/// a MODE_B (`is_variant_b = true`) drive — pins that `run_init`'s mode
/// dispatch (`self.mode == MODE_A`) actually reaches `variant_b::load_firmware`
/// and not just its `variant_a` sibling, which every other `run_init` test
/// above exercises exclusively.
// Same shape as the mode-A generic-failure test, but for a MODE_B
// drive — pins that `run_init`'s dispatch actually reaches
// `variant_b::load_firmware`, not just its variant_a sibling.
#[test]
fn run_init_exhausts_after_three_generic_failures_mode_b() {
let mut t = crate::scsi::mock::MockTransport::always(crate::scsi::mock::Reply::short(
@@ -1190,10 +1121,9 @@ mod tests {
);
}
/// Same generic `do_unlock` failure, but this time the firmware-reload
/// fallback itself hits a dead bus. That must abort `run_init`
/// immediately (transport classification preserved), not be folded into
/// `last_err` and retried.
// Same generic `do_unlock` failure, but the firmware-reload fallback
// itself hits a dead bus — must abort `run_init` immediately, not be
// folded into `last_err` and retried.
#[test]
fn run_init_firmware_reload_transport_fault_aborts() {
let mut profile = fixture_profile([0; 4]);
@@ -1232,10 +1162,9 @@ mod tests {
assert!(mt.init_complete, "run_probe must do_unlock itself first");
}
/// A READ CAPACITY drive sense (status != 0) means "capacity unknown" ->
/// `disc_sectors` falls back to 0, which is still below the UHD
/// threshold, so probing continues with the BD init address rather than
/// aborting.
// A READ CAPACITY drive sense means "capacity unknown" -> falls back
// to 0 sectors, which is below the UHD threshold, so probing
// continues with the BD init address rather than aborting.
#[test]
fn run_probe_capacity_check_condition_falls_back_to_zero_sectors() {
struct CapacitySenseTransport;
@@ -1327,10 +1256,9 @@ mod tests {
);
}
/// The fine (pass-2) probe sweep ends early on a merely-rejected probe
/// reply (not a transport fault) — it must `break` out of the sweep and
/// let `run_probe` still return `Ok`, rather than propagating the drive's
/// refusal as a hard failure.
// The fine (pass-2) probe sweep ends early on a merely-rejected
// probe reply (not a transport fault) — it must `break` and let
// `run_probe` still return `Ok`.
#[test]
fn run_probe_pass2_ends_early_on_a_rejected_probe_without_failing() {
struct Pass2RejectsTransport {
+8 -13
View File
@@ -21,9 +21,8 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
}
// Upload firmware via WRITE_BUFFER. The CDB's length is a 24-bit field;
// if the blob exceeds that, the encoded length would silently disagree
// with the bytes actually sent (`data`). Reject rather than upload a
// length-mismatched command.
// reject blobs that would exceed it rather than send a length-mismatched
// command.
let len = firmware.len();
if len > WRITE_BUFFER_MAX_LEN {
return Err(crate::ld::error::Error::UnlockFailed);
@@ -43,12 +42,9 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
let mut data = firmware.clone();
scsi.execute(&cdb, DataDirection::ToDevice, &mut data, 30_000)?;
// Verify the firmware loaded (non-fatal — different buffer_id 0x45). There
// is no documented expected payload to compare against, so the outcome is
// TRACED rather than asserted: discarding it with `let _ =` meant a corrupt
// upload proceeded straight into the do_unlock retries with no diagnostic
// whatsoever, which is exactly the silent-failure shape this crate exists to
// avoid.
// Verify the firmware loaded (non-fatal — different buffer_id 0x45). No
// documented expected payload exists, so the outcome is traced rather
// than asserted, instead of silently discarded before the unlock retries.
let verify_cdb = [
super::SCSI_READ_BUFFER,
super::MODE_A,
@@ -100,10 +96,9 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
}
}
// Double unlock after firmware upload. The first establishes the
// unlock and is fatal on failure; the second is a confirmation pass and
// is best-effort (matching variant B), so a benign hiccup on the
// redundant call doesn't fail an already-successful unlock.
// Double unlock after firmware upload: first is fatal on failure, second
// is a best-effort confirmation pass (matching variant B) so a benign
// hiccup there doesn't fail an already-successful unlock.
mt.do_unlock(scsi)?;
let _ = mt.do_unlock(scsi);
Ok(())
+12 -23
View File
@@ -13,11 +13,9 @@ const FIRMWARE_EXTRA: [u8; 16] = [0; 16];
/// that drive — real profiles must supply their own (see `DriveProfile`).
const VENDOR_VERIFY: [u8; 10] = [0xF1, 0x01, 0x02, 0x00, 0x0D, 0x30, 0x01, 0xF3, 0xAD, 0x23];
/// Trace the outcome of a best-effort firmware-upload step and swallow
/// everything EXCEPT a transport fault. These steps are advisory (the unlock
/// retries below are the real gate) but their results were previously thrown
/// away entirely, so a firmware upload could fail end to end and leave nothing
/// in the log to say so — and a dead bus kept the sequence running.
// See docs/variant-b-firmware-upload.md — trace_step rationale.
// Swallows everything except a transport fault; these steps are advisory,
// the unlock retries below are the real gate.
fn trace_step(phase: &'static str, r: crate::scsi::Result<crate::scsi::ScsiResult>) -> Result<()> {
match r {
Ok(res) if res.status == 0 => {
@@ -60,12 +58,9 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
return Err(crate::ld::error::Error::UnlockFailed);
}
// Step 1: Upload the firmware via MODE SELECT. The profile's `firmware` is
// the exact per-drive image — extracted at the drive's own load-CDB length
// (2192..2528 bytes; the old fixed 0x9C0 truncated some drives and over-read
// others into blob strings). Upload all of it. MODE SELECT(10)'s
// parameter-list length is 16-bit, so reject only a blob that can't be
// expressed in the CDB.
// Step 1: Upload the firmware via MODE SELECT (see docs/variant-b-firmware-upload.md).
// MODE SELECT(10)'s parameter-list length is 16-bit, so reject only a
// blob that can't be expressed in the CDB.
let write_len = firmware.len();
if write_len > u16::MAX as usize {
return Err(crate::ld::error::Error::UnlockFailed);
@@ -128,13 +123,9 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
scsi.execute(&write_extra_cdb, DataDirection::ToDevice, &mut data2, 5_000),
)?;
// Step 4: Vendor verify (0xF1 — B-only, not standard SCSI). PER-DRIVE: take
// it from the profile (39 distinct values across the 140 B drives). The
// const is only a legacy fallback — it carries one drive's token.
// The result used to be discarded with `let _ =` despite the comment calling
// this a verify step: a corrupt upload proceeded straight to the do_unlock
// retries with no diagnostic. There is no documented expected payload, so
// the outcome is TRACED; only a dead bus aborts.
// Step 4: Vendor verify (0xF1 — B-only, not standard SCSI), per-drive.
// 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(
@@ -142,11 +133,9 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
scsi.execute(&verify_cdb, DataDirection::None, &mut dummy, 5_000),
)?;
// Step 5: Unlock retries (up to 5, then a final fatal attempt). On a
// successful unlock we issue one confirmation pass; its result is
// intentionally best-effort — the first call already established the
// unlock state, so a hiccup on the redundant confirmation must not fail
// an otherwise-good unlock.
// Step 5: Unlock retries (up to 5, then a final fatal attempt). The
// confirmation pass after success is intentionally best-effort — see
// docs/variant-b-firmware-upload.md.
for _attempt in 0..5 {
match mt.do_unlock(scsi) {
Ok(_) => {
+29 -74
View File
@@ -42,20 +42,10 @@ pub struct Identity {
/// Per-drive profile.
///
/// VISIBILITY: only `identity` and `signature` are public — they are what the
/// catalog is public FOR ("is this drive supported?", and the emulator's
/// impersonation). Everything below them is unlock MECHANISM: the MediaTek
/// firmware image and the per-drive vendor CDB templates. `Cargo.toml` says this
/// crate is never published because it carries drive firmware; re-exporting
/// `DriveProfile` with a `pub firmware: Vec<u8>` handed that firmware, and every
/// vendor CDB, to any downstream crate that called `ld::profile()`. They are
/// `pub(crate)` so the mechanism stays inside the unlocker.
///
/// `allow(dead_code)`: this is the on-disk catalog schema, and several captured
/// CDB templates have no in-crate consumer yet. They were `pub` before, so the
/// compiler saw the (external) use; making them `pub(crate)` is what exposes
/// them as unread. Deleting them would silently drop fields from the format the
/// profile-extraction pipeline emits.
/// Only `identity` and `signature` are public; everything else (firmware
/// image, per-drive vendor CDB templates) is `pub(crate)` unlock mechanism
/// that must stay inside this unpublished crate.
/// See docs/drive-profile-visibility.md — field-visibility and allow(dead_code) rationale.
#[allow(dead_code)]
#[derive(Clone, Deserialize)]
pub struct DriveProfile {
@@ -72,11 +62,8 @@ pub struct DriveProfile {
pub(crate) firmware: Vec<u8>,
// ── OEM-extended-access CDB templates ──────────────────────────────
//
// All optional — older profile blobs that pre-date the CDB capture
// pipeline simply omit these fields and decode as `None`. Encoded
// in the JSON as lowercase hex strings without separators
// (e.g. `"3c014410e29100002400"` for a 10-byte CDB).
// All optional (`None` if pre-capture-pipeline). Hex strings, no
// separators, e.g. `"3c014410e29100002400"`.
#[serde(default)]
pub(crate) unlock_init_value: u8,
#[serde(default)]
@@ -113,14 +100,9 @@ pub struct DriveProfile {
pub(crate) speed_calc_table: Option<Vec<u8>>,
}
// Hand-written, REDACTING Debug. `DriveProfile` is `pub` and reachable through
// the public `Profiles` catalog (`ld::bundled` / `ld::profiles`), whose own
// derived `Debug` would recurse into this one. `Cargo.toml` says this crate is
// NEVER published because it carries drive firmware; a derived `Debug` would
// render that firmware image AND every captured per-drive vendor CDB (the unlock
// MECHANISM) straight into any log. Show only the PUBLIC identity + signature
// (what the catalog is public FOR); collapse the firmware to a byte length and
// omit the vendor CDB templates entirely so no mechanism bytes reach a log.
// Hand-written, REDACTING Debug: a derived one would recurse through the
// public `Profiles` catalog and print raw firmware + vendor CDB bytes into
// logs. Show only identity + signature; collapse firmware to a length.
impl std::fmt::Debug for DriveProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DriveProfile")
@@ -167,23 +149,16 @@ pub struct ProfileMatch {
pub platform: Platform,
}
// ── Parsing ────────────────────────────────────────────────────────────
/// Decode an even-length ASCII hex string into bytes.
///
/// Operates on raw bytes rather than `&str` char-boundary slices: a
/// non-ASCII input (e.g. a hand-edited profile with a multi-byte char)
/// could otherwise have `&s[i..i+2]` land inside a UTF-8 char boundary and
/// panic. Hex is ASCII, so any non-ASCII or non-hex byte simply fails to
/// decode. The error is a stable, language-neutral token (`"hex"`), not a
/// translatable English message.
// ── Parsing: decode hex on raw bytes (not `&str` slices) so non-ASCII
// can't land `&s[i..i+2]` inside a UTF-8 char boundary and panic — it
// just fails to decode with `"hex"`.
fn decode_hex(s: &str) -> std::result::Result<Vec<u8>, &'static str> {
let bytes = s.as_bytes();
if !bytes.len().is_multiple_of(2) {
return Err("hex");
}
let mut out = Vec::with_capacity(bytes.len() / 2);
for pair in bytes.chunks_exact(2) {
for pair in bytes.as_chunks::<2>().0.iter() {
let hi = (pair[0] as char).to_digit(16).ok_or("hex")?;
let lo = (pair[1] as char).to_digit(16).ok_or("hex")?;
out.push((hi * 16 + lo) as u8);
@@ -223,9 +198,7 @@ where
}
// ── Fixed-length hex deserializers for CDB templates ────────────────────
//
// Profile JSON encodes CDBs as lowercase hex strings without separators.
// An empty string / null / missing field decodes as `None`.
// Lowercase hex strings, no separators; empty/null/missing decodes as `None`.
fn parse_hex_bytes(s: &str) -> std::result::Result<Vec<u8>, &'static str> {
decode_hex(s)
@@ -306,12 +279,8 @@ pub fn bundled() -> Option<&'static Profiles> {
CACHE
.get_or_init(|| match load_from_str(BUNDLED_PROFILES) {
Ok(p) => Some(p),
// The `None` is cached PERMANENTLY (correctly — the blob is fixed at
// compile time), which means a bad shipped profiles.json makes
// Mt1959Unlocker report "drive not supported" for every drive for the
// life of the process. Unlogged, that is indistinguishable from a
// genuinely uncataloged drive; log it once, loudly, at the one place
// the parse happens.
// `None` caches permanently; unlogged that's indistinguishable
// from a genuinely uncataloged drive, so log once, loudly, here.
Err(e) => {
tracing::error!(
target: "freemkv::disc",
@@ -543,10 +512,8 @@ mod tests {
assert_eq!(parse_hex4("aabbccdd").unwrap(), [0xaa, 0xbb, 0xcc, 0xdd]);
}
/// Platform::name() returns stable, non-empty, language-neutral identifiers.
/// These strings are logged and keyed on in caller code; changing them is a
/// breaking change.
/// Mutation: swapping Mt1959A and Mt1959B names silently misroutes firmware upload.
// Platform::name() strings are logged/keyed by callers; changing them is
// a breaking change. Mutation: swapping A/B names misroutes firmware upload.
#[test]
fn platform_name_is_stable() {
// The exact strings are part of the public stable API (logged/keyed).
@@ -555,11 +522,9 @@ mod tests {
assert_eq!(Platform::Renesas.name(), "Renesas");
}
/// find_by_drive_id: exact match (including firmware_date) wins over loose match.
/// Spec: two-pass — first an exact match including firmware_date, then looser.
/// Build two synthetic Profiles entries that differ only by firmware_date,
/// and verify the correct one is selected.
/// Mutation: doing only the loose pass would return the first entry regardless of date.
// find_by_drive_id: two-pass — exact match (incl. firmware_date) wins
// over loose. Mutation: skipping the exact pass returns the first entry
// regardless of date.
#[test]
fn find_by_drive_id_exact_date_wins_over_loose() {
use serde_json::json;
@@ -651,10 +616,8 @@ mod tests {
assert!(matches!(result, Err(Error::ProfileParse)));
}
/// Bundled profiles is non-empty (mt1959_a has at least one entry).
/// This pins the embedded JSON: if profiles.json is accidentally emptied
/// or truncated, this test goes red.
/// Mutation: clearing profiles.json would make this fail.
// Pins the embedded JSON: if profiles.json is emptied/truncated, this
// goes red.
#[test]
fn bundled_profiles_has_entries() {
let profiles = load_bundled().unwrap();
@@ -664,10 +627,8 @@ mod tests {
);
}
/// deserialization of a profile with missing optional CDB fields
/// produces None for those fields (not a parse error).
/// Spec: all CDB template fields are `#[serde(default)]` — they are optional.
/// Mutation: making a CDB field required breaks backward-compat with old blobs.
// All CDB fields are `#[serde(default)]`. Mutation: making one required
// breaks backward-compat with old blobs.
#[test]
fn profile_optional_cdb_fields_default_to_none() {
use serde_json::json;
@@ -715,12 +676,8 @@ mod tests {
);
}
/// `DriveProfile`'s `Debug` must NOT render the firmware image or the
/// per-drive vendor CDB templates — this crate is unpublished precisely
/// because it carries drive firmware, and the profile is reachable through
/// the public `Profiles` catalog.
/// MUTATION: restoring `#[derive(Debug)]` prints the raw firmware bytes
/// (e.g. the 0xEE marker below) and the CDB bytes, so this goes red.
// `Debug` must not render firmware/CDB bytes. Mutation: restoring
// `#[derive(Debug)]` prints them (e.g. the 0xEE marker below), goes red.
#[test]
fn drive_profile_debug_redacts_firmware_and_cdbs() {
use serde_json::json;
@@ -749,10 +706,8 @@ mod tests {
assert!(s.contains("VIS"), "identity must remain visible: {s}");
}
/// deserialize_hex4 of an empty string must produce [0;4] without error.
/// This matches `deserialize_hex4`'s explicit early-return for empty strings.
/// Mutation: treating empty string as an error prevents profiles where signature
/// was not captured from loading.
// deserialize_hex4("") must produce [0;4]. Mutation: treating empty as
// an error blocks profiles with no captured signature from loading.
#[test]
fn profile_empty_signature_deserialises_as_zeroes() {
use serde_json::json;
+25 -38
View File
@@ -1,34 +1,28 @@
//! freemkv-unlock — the unlock layer for the freemkv toolchain.
//!
//! An **unlocker removes a drive-level bus-encryption barrier** so the drive
//! serves readable (de-bus'd / de-scrambled) sectors. Content-key decryption is
//! a separate layer — the consumer's (libfreemkv's) job.
//! serves readable (de-bus'd / de-scrambled) sectors. Content-key decryption
//! is a separate layer — the consumer's (libfreemkv's) job.
//!
//! This crate defines the [`Unlocker`] contract + the SCSI transport contract,
//! and holds the self-contained unlocker modules (firmware / AACS cert / CSS).
//! libfreemkv depends on this crate and dispatches via [`all_unlockers`]; it
//! never names an individual unlocker. To remove an unlocker, delete its module
//! dir and its one line in [`all_unlockers`] — nothing else changes.
//! This crate defines the [`Unlocker`] contract + the SCSI transport
//! contract, and holds the self-contained unlocker modules (firmware / AACS
//! cert / CSS). libfreemkv depends on this crate and dispatches via
//! [`all_unlockers`]; it never names an individual unlocker directly.
pub mod scsi;
mod aacs;
mod css;
// `ld` is public ONLY for its drive-profile catalog (`ld::profiles` / the
// `Profiles` object) and, under the `emulation` feature, the unlock-handshake
// wire format the bdemu test-emulator needs. The unlocker impl itself
// (`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.
// `ld` is public only for its drive-profile catalog + (under `emulation`) the
// handshake wire format bdemu needs; the unlocker impl stays `pub(crate)`.
// See docs/module-visibility.md — module visibility rationale.
pub mod ld;
// `renesis` is public for its `is_renesas` drive-probe (which reports a dead
// bus as `Err(UnlockError::Transport)` rather than "not a Renesas drive"); the unlocker impl
// (`Renesis`) is `pub(crate)` — reached only through [`all_unlockers`].
pub mod renesis;
// `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`].
// `renesas` is public for its `is_renesas` drive-probe (dead bus vs. "not a
// Renesas drive"); the unlocker impl stays `pub(crate)`.
// See docs/module-visibility.md — module visibility rationale.
pub mod renesas;
// `freemkv` self-identifies rather than matching a bundled profile, so it
// stays fully private. See docs/module-visibility.md — module visibility rationale.
mod freemkv;
use scsi::ScsiTransport;
@@ -95,13 +89,9 @@ pub struct Unlocked {
pub drive_unlocked: bool,
}
// Hand-written, REDACTING Debug — `Unlocked` is the public carrier that leaves
// this crate, and both `bus_key` (the AACS bus key) and `vid` (the Volume ID
// that feeds VUK derivation) are key material that must never reach a log or a
// test-failure message in plaintext. The `AacsAuth` / `CertHandshake` siblings
// already redact; a derived `Debug` here would print the very bytes they hide.
// Presence is still observable (Some/None) so a log can say WHETHER a key was
// obtained without revealing WHAT it is.
// Hand-written, REDACTING Debug: `bus_key`/`vid` are key material that must
// never reach a log in plaintext; presence (Some/None) stays observable.
// See docs/unlocked-debug-redaction.md — full rationale.
impl std::fmt::Debug for Unlocked {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Unlocked")
@@ -196,7 +186,7 @@ pub fn all_unlockers() -> Vec<Box<dyn Unlocker>> {
vec![
Box::new(freemkv::FreemkvUnlocker::new()),
Box::new(ld::Mt1959Unlocker::new()),
Box::new(renesis::Renesis::new()),
Box::new(renesas::Renesas::new()),
Box::new(aacs::AacsCert::new()),
Box::new(css::DvdUnlocker::new()),
]
@@ -206,11 +196,9 @@ pub fn all_unlockers() -> Vec<Box<dyn Unlocker>> {
mod tests {
use super::*;
/// The dispatch order is a documented contract (`firmware → cert → css`) and
/// had NO test: `all_unlockers` is the one place an unlocker is named, and
/// the consumer stops at the first that doesn't decline, so reordering this
/// list silently changes which unlocker claims a drive. Catches a reorder,
/// a rename, and an accidental addition/removal.
// Dispatch order is a documented contract (`firmware → cert → css`); the
// consumer stops at the first unlocker that doesn't decline, so reordering
// this list silently changes which unlocker claims a drive.
#[test]
fn all_unlockers_dispatch_order_is_firmware_then_cert_then_css() {
let names: Vec<&'static str> = all_unlockers().iter().map(|u| u.name()).collect();
@@ -265,10 +253,9 @@ mod tests {
assert_eq!(unlocker_name(&DriveId::default()), None);
}
/// `Unlocked` is the public carrier that leaves the crate, and its `bus_key`
/// and `vid` are key material. Its `Debug` must NOT print those bytes.
/// MUTATION: restoring `#[derive(Debug)]` prints the raw byte arrays, so the
/// distinctive marker bytes appear in the output and this goes red.
// `bus_key`/`vid` are key material; `Debug` must NOT print those bytes.
// MUTATION: restoring `#[derive(Debug)]` prints the raw byte arrays, so
// the marker bytes appear in the output and this test goes red.
#[test]
fn unlocked_debug_redacts_key_material() {
let u = Unlocked {
+29 -69
View File
@@ -1,4 +1,4 @@
//! renesis — Renesas-platform detection (Pioneer + HL-DT-ST Renesas drives).
//! renesas — Renesas-platform detection (Pioneer + HL-DT-ST Renesas drives).
//!
//! Optical drives split into two controller families: MediaTek (handled by
//! [`crate::ld`]) and Renesas. This module identifies the Renesas side via a
@@ -19,17 +19,13 @@ const RENESAS_MARKER_OFFSET: usize = 16;
/// `Ok(true)` if `scsi` is a Renesas-platform drive (Pioneer or HL-DT-ST
/// Renesas).
///
/// Issues the vendor READ_BUFFER 0x02/0xF1 probe: a Renesas controller serves a
/// 48-byte identity block whose bytes `[16..19]` are the ASCII `SAT` interface
/// tag. A MediaTek drive rejects it — as a CHECK CONDITION (`Ok` with a
/// non-zero status) or, through a non-conforming transport, as an `Err`
/// carrying a sense — which is `Ok(false)`: not a Renesas drive. This is the
/// definitive Renesas-vs-MediaTek split.
/// Issues the vendor READ_BUFFER 0x02/0xF1 probe: a Renesas controller serves
/// a 48-byte identity block whose bytes `[16..19]` are the ASCII `SAT`
/// interface tag. A rejection (CHECK CONDITION or `Err` with a sense) is
/// `Ok(false)`: not a Renesas drive.
///
/// A TRANSPORT fault is `Err(Transport)`. It used to be folded into the same
/// `false` as a drive rejection, and because this probe is the first SCSI
/// command the unlocker issues, a dead bus read as "not a Renesas drive" — the
/// consumer then walked the remaining unlockers against a bus that was gone.
/// `Err(Transport)` on a dead bus. See docs/renesas-mod.md for why a
/// transport fault must not be folded into `Ok(false)`.
pub fn is_renesas(scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
let mut buf = [0u8; RB_F1_LEN];
match scsi.execute(&RB_F1_CDB, DataDirection::FromDevice, &mut buf, 5_000) {
@@ -63,35 +59,16 @@ pub fn is_renesas(scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, Unl
/// The Renesas-platform unlocker. `pub(crate)` — reached only through
/// [`crate::all_unlockers`].
pub(crate) struct Renesis;
pub(crate) struct Renesas;
impl Renesis {
impl Renesas {
pub(crate) fn new() -> Self {
Renesis
Renesas
}
/// The Pioneer/Renesas vendor "open" — MakeMKV's live sequence, replicated in
/// full: a primary read, and on its failure a payload-less "knock" + a second
/// read at a different window.
///
/// A. `READ_BUFFER 0x02/0xB0 @0x000004` (164 B) — the primary open.
/// B. (only if A refuses) `WRITE_BUFFER 0x02/0x41 @0xA5AAAA` (0-byte knock)
/// then `READ_BUFFER 0x02/0xB0 @0x500000` (164 B).
///
/// Success is by SCSI *status* alone (the 164-byte payload CONTENT is not
/// validated — verified empirically: a real drive's table and an all-zero
/// buffer are accepted identically as long as status is GOOD). We run the
/// whole A→knock→B path rather than bailing after A, because the knock is a
/// per-firmware fallback: some firmware revisions only answer the `0x500000`
/// window *after* the knock (MakeMKV issues the knock exactly when A returns
/// CHECK CONDITION). If NEITHER A nor B returns GOOD, the drive did not open →
/// defer to the next unlocker.
///
/// (There may be more than one knock offset across firmware families; only
/// the observed `0xA5AAAA` is issued here — add variants as they are proven.)
///
/// `Ok(true)` = opened (A or B GOOD). `Ok(false)` = neither opened (CHECK
/// CONDITION). `Err(Transport)` = dead bus.
// MakeMKV's vendor "open" sequence: primary read (A), and on refusal a
// knock + second read (B). See docs/renesas-mod.md for the full sequence
// and why we run A→knock→B rather than bailing after A.
fn vendor_open(scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
const RB_B0_04_CDB: [u8; 10] = [0x3C, 0x02, 0xB0, 0x00, 0x00, 0x04, 0x00, 0x00, 0xA4, 0x00];
const KNOCK_A5AAAA_CDB: [u8; 10] =
@@ -137,22 +114,14 @@ fn read_is_good(
}
}
impl Unlocker for Renesis {
impl Unlocker for Renesas {
fn name(&self) -> &'static str {
"Renesas"
}
/// Recognize and open a Renesas/Pioneer drive the way MakeMKV does:
/// 1. `READ_BUFFER 0xF1` identity gate (the `SAT` marker) — else this is
/// not our drive → `NotApplicable`.
/// 2. the single vendor open read `READ_BUFFER 0xB0 @0x04` — GOOD status
/// confirms the drive is rip-ready. A CHECK CONDITION here is exactly
/// where MakeMKV gives up on the drive, so we defer to the next unlocker
/// (`NotApplicable`) rather than claim a drive we could not open.
///
/// On success returns `drive_unlocked: false`: the vendor open makes the
/// drive readable, but AACS bus decryption is still the host cert's job (the
/// cert unlocker runs next), so we do not assert the bus is handled here.
// Gate on the `0xF1` SAT identity, then the vendor open read `0xB0@0x04`;
// `drive_unlocked: false` since AACS bus decryption is the cert's job.
// See docs/renesas-mod.md for the full MakeMKV-parity rationale.
fn unlock_features(
&self,
scsi: &mut dyn ScsiTransport,
@@ -210,11 +179,8 @@ mod tests {
}
}
/// Rejects the command the way a MediaTek drive does: ILLEGAL REQUEST. The
/// sense is what distinguishes this from a dead bus — the old fixture sent
/// `sense: None`, which is the wire shape of a TRANSPORT fault, and the test
/// built on it asserted `NotApplicable`, pinning exactly the
/// misclassification this file had.
// Rejects like a MediaTek drive: ILLEGAL REQUEST with a sense, which is
// what distinguishes this from a dead bus. See docs/renesas-mod.md.
struct RejectingTransport;
impl ScsiTransport for RejectingTransport {
fn execute(
@@ -281,12 +247,12 @@ mod tests {
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]);
// Recognized → Ok, but a feature-only unlock: bus NOT removed, no VID.
let u = Renesis::new()
let u = Renesas::new()
.unlock_features(&mut t, &ctx)
.expect("renesas → Ok");
assert!(
!u.drive_unlocked,
"renesis does not remove the bus (cert does)"
"renesas does not remove the bus (cert does)"
);
assert_eq!(u.vid, None);
assert_eq!(u.bus_key, None);
@@ -299,14 +265,12 @@ mod tests {
let mut t = RejectingTransport;
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]);
let err = Renesis::new().unlock_features(&mut t, &ctx).unwrap_err();
let err = Renesas::new().unlock_features(&mut t, &ctx).unwrap_err();
assert_eq!(err, UnlockError::NotApplicable);
}
/// The same rejection delivered the way a CONFORMING transport delivers it —
/// `Ok` with a CHECK CONDITION status — must reach the same answer. Catches
/// dropping the `r.status == 0` check, which would read the probe buffer's
/// zero fill as an identity block.
// Same rejection via a CONFORMING transport (`Ok` + CHECK CONDITION);
// must reach the same answer. See docs/renesas-mod.md.
#[test]
fn check_condition_is_not_a_renesas_drive() {
use crate::scsi::mock::{MockTransport, Reply};
@@ -327,17 +291,13 @@ mod tests {
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]);
assert_eq!(
Renesis::new().unlock_features(&mut t, &ctx).unwrap_err(),
Renesas::new().unlock_features(&mut t, &ctx).unwrap_err(),
UnlockError::Transport
);
}
/// A genuine Renesas drive (passes the `0xF1` SAT gate) that REFUSES the
/// vendor open read `RB 0xB0@0x04` with CHECK CONDITION — the exact point
/// where MakeMKV abandons the drive. `unlock_features` must defer to the
/// next unlocker (`NotApplicable`), not claim a drive it could not open.
/// MUTATION: dropping the `vendor_open` gate makes this return `Ok` and
/// wrongly claim the drive.
// A recognized Renesas drive that REFUSES the vendor open read must
// defer to the next unlocker, not claim the drive. See docs/renesas-mod.md.
#[test]
fn open_rejection_defers_to_next_unlocker() {
struct GateOkOpenRefused;
@@ -375,7 +335,7 @@ mod tests {
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]);
assert_eq!(
Renesis::new().unlock_features(&mut t, &ctx).unwrap_err(),
Renesas::new().unlock_features(&mut t, &ctx).unwrap_err(),
UnlockError::NotApplicable
);
}
@@ -388,7 +348,7 @@ mod tests {
};
let id = crate::DriveId::default();
let ctx = UnlockCtx::new(&id, DiscKind::Aacs, &[]);
let err = Renesis::new().unlock_bus(&mut t, &ctx).unwrap_err();
let err = Renesas::new().unlock_bus(&mut t, &ctx).unwrap_err();
assert_eq!(err, UnlockError::NotApplicable);
}
}
+5 -12
View File
@@ -100,14 +100,8 @@ pub(crate) fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {
}
// ── Test fixture ────────────────────────────────────────────────────────────
//
// The crate-wide mock transport. Before this existed, EVERY mock in the crate
// returned `Ok` with status 0 and a full `bytes_transferred` — so no test could
// ever exercise the three outcomes the contract actually distinguishes, and a
// whole class of transport-fault misclassification bugs sat uncaught (a dead bus
// read as "this unlocker doesn't apply", a zero-filled buffer read as valid
// drive data). This fixture can express all of them, and is the red-before-green
// vehicle for the transport-contract fixes across ld / aacs / css / renesis.
// Crate-wide mock transport, able to express all three transport outcomes.
// See docs/scsi-mock-fixture.md — why this fixture exists
#[cfg(test)]
#[allow(dead_code)] // a fixture: each helper is used by a subset of the modules
pub(crate) mod mock {
@@ -322,10 +316,9 @@ mod tests {
assert_eq!(build_set_cd_speed(0xFFFF)[2..4], [0xFF, 0xFF]);
}
/// The fixture itself must honour the contract it exists to test: a drive
/// sense is `Ok` with a non-zero status, a bus fault is `Err`. If this ever
/// drifts, every transport-contract test built on it silently stops testing
/// anything.
// The fixture must honour the contract it tests: a drive sense is `Ok`
// with non-zero status, a bus fault is `Err`. If this drifts, every
// transport-contract test built on it silently stops testing anything.
#[test]
fn mock_transport_expresses_the_three_contract_outcomes() {
let mut t = MockTransport::scripted(