mirror of
https://github.com/freemkv/freemkv-unlock.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
796dd41b94 | ||
|
|
3965b16740 | ||
|
|
22431979a2 | ||
|
|
bead4869a9 | ||
|
|
d64c4b3e55 | ||
|
|
1970113a82 | ||
|
|
6008d2daa7 | ||
|
|
5b690bf34f | ||
|
|
04cddb11f6 | ||
|
|
800c78f86b | ||
|
|
17e33f89cf | ||
|
|
11d6e19ae2 | ||
|
|
2ee8c0b0a6 | ||
|
|
8473c89201 | ||
|
|
5c4a6c4094 | ||
|
|
0a37381a63 | ||
|
|
1d20be4a41 | ||
|
|
965c1f984c | ||
|
|
e51fda9bcd | ||
|
|
35cb630af9 | ||
|
|
e6786692b3 | ||
|
|
8caf9e7b69 | ||
|
|
b77e7e8aac | ||
|
|
94471641e3 | ||
|
|
c7e7aa63a0 | ||
|
|
89861e6f40 | ||
|
|
eef8e958fc | ||
|
|
d6607ba963 | ||
|
|
89f202edbc | ||
|
|
e107c699d1 | ||
|
|
d257d40de9 | ||
|
|
56c69e8877 | ||
|
|
2c9d76235d | ||
|
|
1a916f0855 | ||
|
|
609f3bc0bc | ||
|
|
58c667314c | ||
|
|
98ef26f78c | ||
|
|
93d9517964 | ||
|
|
64a53b8349 | ||
|
|
f434298e0d | ||
|
|
a18bc90164 | ||
|
|
79f3324488 | ||
|
|
0842babe2d | ||
|
|
638b86acb6 | ||
|
|
3c0c335840 | ||
|
|
f97ebb450b | ||
|
|
47bb6a7ac3 | ||
|
|
eeb36bf389 | ||
|
|
4459af4e2f | ||
|
|
f1801351e2 | ||
|
|
7da4b3bba4 | ||
|
|
b4e5d90c81 | ||
|
|
bce5f36ef2 | ||
|
|
8d60463539 | ||
|
|
a7622678f3 | ||
|
|
ef3a32eca2 | ||
|
|
edb8be09ec | ||
|
|
32a41fe939 | ||
|
|
8533038e62 | ||
|
|
6b6c3be1dc | ||
|
|
422147f6ad | ||
|
|
72d5b8fb3e | ||
|
|
407c7f59e3 | ||
|
|
199a3ffee6 | ||
|
|
aa70a3707d | ||
|
|
cb1235cca4 | ||
|
|
150e91b0ab | ||
|
|
3989100820 | ||
|
|
c6d6a5a247 | ||
|
|
09dd8ef14c | ||
|
|
417d4598ca | ||
|
|
74936ad892 | ||
|
|
9b9f142313 | ||
|
|
7eee823db7 | ||
|
|
d8f1d2f3a7 | ||
|
|
56688764ea | ||
|
|
e7107040bf | ||
|
|
b88feff027 | ||
|
|
1016a294db | ||
|
|
958b15f806 | ||
|
|
508b533f78 | ||
|
|
052ab804e2 | ||
|
|
0964097e78 | ||
|
|
3b8bcc2164 | ||
|
|
7128fdf71f | ||
|
|
9ad1eb9811 | ||
|
|
7ea1a4eea7 | ||
|
|
b3d1b99460 | ||
|
|
781b498e4e | ||
|
|
bdd2129932 | ||
|
|
bb313c7ece | ||
|
|
985b400d70 | ||
|
|
dd73c5d3a1 | ||
|
|
9cfcb5830c | ||
|
|
2228703250 | ||
|
|
c9e94b6bd6 | ||
|
|
7b03c0dc65 | ||
|
|
a03cb454f6 | ||
|
|
367faf6726 | ||
|
|
57637a22a3 | ||
|
|
991ce55042 | ||
|
|
78d26cf8e0 | ||
|
|
c12e8ef748 | ||
|
|
1f644afe80 | ||
|
|
423366308b | ||
|
|
759390af96 | ||
|
|
7bbcc46f12 | ||
|
|
eaa158c379 | ||
|
|
74fe85cdd2 | ||
|
|
f675823488 | ||
|
|
50205a0286 | ||
|
|
274e9c66f5 | ||
|
|
d05b9bce18 | ||
|
|
f9df7e0fb3 | ||
|
|
9a4f29b04c | ||
|
|
6a8253f889 | ||
|
|
2684f5f28e | ||
|
|
f0d936f4b6 | ||
|
|
5fd090fc8e | ||
|
|
44bd80e8df | ||
|
|
fb3d045509 | ||
|
|
f9182df204 | ||
|
|
53d9e15d3d | ||
|
|
65e02f4b79 | ||
|
|
10126cdbf6 | ||
|
|
887d154014 | ||
|
|
e9ed2ffa85 | ||
|
|
3a5f92da91 | ||
|
|
58dc46037e | ||
|
|
0ed613d7a9 | ||
|
|
4663cdf2bf |
@@ -0,0 +1,33 @@
|
||||
# Version updates are monthly and grouped into one PR per ecosystem.
|
||||
# Security updates are separate and run whenever an advisory is published.
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- '*'
|
||||
ignore:
|
||||
- dependency-name: freemkv-unlock
|
||||
- dependency-name: libfreemkv
|
||||
- dependency-name: freemkv-keysources
|
||||
- dependency-name: freemkv-i18n
|
||||
- dependency-name: freemkv-engine
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- '*'
|
||||
ignore:
|
||||
- dependency-name: dtolnay/rust-toolchain
|
||||
- dependency-name: freemkv/.github*
|
||||
+191
-17
@@ -2,12 +2,23 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
# `dev` is where work lands and where CI must be green. `main` only ever
|
||||
# moves at release time, to the tagged commit, so a push to it is the
|
||||
# release validation run rather than day-to-day feedback.
|
||||
branches: [main, dev]
|
||||
# dev -> qa -> main. `dev` is where work lands and is meant to be pushed
|
||||
# to often: these are the FAST checks, so a mistake surfaces in minutes.
|
||||
# `qa` is the release candidate — it runs these too, plus the expensive
|
||||
# suite in qa.yml. `main` only ever moves at release time, to a tagged
|
||||
# commit that was already green on qa.
|
||||
branches: [main, dev, qa]
|
||||
pull_request:
|
||||
|
||||
# Rapid dev pushes supersede their own in-progress runs instead of stacking.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Least privilege: no job here writes to the repo.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# freemkv-unlock is the BASE of the whole ecosystem: libfreemkv path-deps it,
|
||||
# bdemu path-deps it with the `emulation` feature, and every other crate pulls
|
||||
# it in transitively. It has no freemkv dependencies of its own, so no sibling
|
||||
@@ -15,29 +26,88 @@ on:
|
||||
# seven others down with it, which is why it gets the full OS matrix rather
|
||||
# than a token Linux run.
|
||||
#
|
||||
# Cargo.lock is gitignored (library convention), so `--locked` is deliberately
|
||||
# NOT used: with no committed lockfile it fails rather than resolves.
|
||||
# Cargo.lock is now committed (issue #15), so the resolved dependency graph is
|
||||
# recorded and the `deny` job audits it. `--locked` is still deliberately NOT
|
||||
# used, matching the binary crates' ci.yml: CI's sibling path-patch layout
|
||||
# resolves the freemkv crates as path packages, which `--locked` can't satisfy.
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
# ── Single source of truth for the MSRV ─────────────────────────────────────
|
||||
#
|
||||
# `rust-version` in Cargo.toml is the ONE place the MSRV is declared. This job
|
||||
# reads it; every job that must build/test AT the MSRV consumes
|
||||
# `needs.resolve-msrv.outputs.v` instead of a hardcoded pin, so the floor
|
||||
# cannot drift out of sync across workflow files. The `test` job building
|
||||
# green at this value IS the verification that the declared MSRV is real.
|
||||
# clippy deliberately does NOT use it — it runs on `stable` (lint debt is
|
||||
# caught against current Rust).
|
||||
resolve-msrv:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
v: ${{ steps.msrv.outputs.v }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- id: msrv
|
||||
shell: bash
|
||||
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# CI owns the changelog: main may never carry a released version without a
|
||||
# CHANGELOG.md entry for it. The release orchestrator dates an existing
|
||||
# `## [X.Y.Z]` heading — it does NOT invent one — so a version bump that
|
||||
# forgot its changelog entry must fail here rather than ship a blank release.
|
||||
# Hard-fails on main (and PRs targeting main); warns elsewhere so an
|
||||
# in-progress dev/qa branch whose entry isn't written yet is not blocked.
|
||||
changelog:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: actions/checkout@v7
|
||||
- name: Require a CHANGELOG entry for the crate version on main
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
if grep -qE "^## \[${version}\]" CHANGELOG.md; then
|
||||
echo "CHANGELOG.md has an entry for ${version}."
|
||||
exit 0
|
||||
fi
|
||||
msg="CHANGELOG.md has no '## [${version}]' entry"
|
||||
if [ "${GITHUB_REF_NAME:-}" = "main" ] || [ "${GITHUB_BASE_REF:-}" = "main" ]; then
|
||||
echo "::error::${msg} — main requires a changelog entry for the released version."
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::${msg} (allowed on ${GITHUB_REF_NAME:-unknown}; required before it can reach main)."
|
||||
|
||||
lint:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# fmt runs on the pinned MSRV toolchain; clippy runs on stable. Linting on
|
||||
# stable makes new-lint debt a BLOCKING gate the pinned version would
|
||||
# otherwise hide. The MSRV pin is installed second so it stays the default
|
||||
# toolchain (fmt uses it implicitly); clippy is invoked with `+stable`.
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
components: clippy
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
components: rustfmt
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: freemkv-unlock-ci
|
||||
- run: cargo fmt --check
|
||||
# --all-targets so the test code is linted too, not just the library.
|
||||
- run: cargo clippy --all-targets -- -D warnings
|
||||
- run: cargo +stable clippy --all-targets -- -D warnings
|
||||
|
||||
test:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: freemkv-unlock-ci
|
||||
@@ -45,25 +115,129 @@ jobs:
|
||||
# `emulation` is off by default and is the face bdemu builds against. A
|
||||
# feature nothing compiles is a feature that breaks at release time.
|
||||
- run: cargo test --tests --features emulation
|
||||
# `--tests` skips doctests; compile/run them so doc examples can't rot.
|
||||
- run: cargo test --doc --all-features
|
||||
|
||||
# This crate's breakage takes every dependent down, so build the two direct
|
||||
# consumers against THIS commit: both dep freemkv-unlock by version, so each
|
||||
# gets a path patch (bdemu also patches libfreemkv to the sibling).
|
||||
consumers:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-unlock
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv bdemu
|
||||
patch: libfreemkv freemkv-unlock
|
||||
self: bdemu
|
||||
- name: Point libfreemkv at THIS commit
|
||||
run: |
|
||||
mkdir -p libfreemkv/.cargo
|
||||
printf '[patch.crates-io]\nfreemkv-unlock = { path = "../freemkv-unlock" }\n' > libfreemkv/.cargo/config.toml
|
||||
# Dependents track current Rust, not this crate's MSRV.
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: |
|
||||
libfreemkv
|
||||
bdemu
|
||||
- run: cargo +stable build --all-targets
|
||||
working-directory: libfreemkv
|
||||
- run: cargo +stable build --all-targets
|
||||
working-directory: bdemu
|
||||
|
||||
# Coverage is additive and NON-REQUIRED: it uploads to Codecov but never
|
||||
# gates a merge (fail_ci_if_error is false, and it is not wired into any
|
||||
# release gate). It mirrors the `test` job's environment — same pinned
|
||||
# toolchain, ubuntu-latest, no `if:` so it runs on every branch and PR just
|
||||
# as `test` does. `--locked` is intentionally omitted to match this crate's
|
||||
# policy (Cargo.lock is committed but --locked can't satisfy the path-patch layout).
|
||||
coverage:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
components: llvm-tools-preview
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: freemkv-unlock-coverage
|
||||
- uses: taiki-e/install-action@cargo-llvm-cov
|
||||
- run: cargo llvm-cov --workspace --ignore-run-fail --lcov --output-path lcov.info
|
||||
- uses: codecov/codecov-action@v7
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: lcov.info
|
||||
slug: freemkv/freemkv-unlock
|
||||
fail_ci_if_error: false
|
||||
|
||||
# cargo-deny gates the RESOLVED dependency graph: RUSTSEC advisories, a
|
||||
# permissive licence allow-list, and source/ban hygiene. It mirrors the
|
||||
# `test` job's checkout (single root checkout, no siblings) and runs on
|
||||
# `stable` — advisory/licence debt is caught against current Rust, not the
|
||||
# MSRV pin — so no `needs: resolve-msrv`.
|
||||
deny:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: EmbarkStudios/cargo-deny-action@v2
|
||||
with:
|
||||
command: check advisories bans licenses sources
|
||||
manifest-path: Cargo.toml
|
||||
|
||||
# The crate ships on macOS and Windows through its consumers, so it has to
|
||||
# compile there. Build the tests (not just `cargo check`) — that forces
|
||||
# codegen and catches errors in test-only code, which `check` skips.
|
||||
check-macos:
|
||||
# dev is the FAST lane: this job still runs, but on the release-candidate
|
||||
# branches rather than on every push to dev. Nothing is deleted and no
|
||||
# platform stops being checked before a release -- qa.yml independently
|
||||
# covers macOS and Windows, and the jobs unique to this file (the Intel
|
||||
# macOS build, the Windows release build) run here on qa and main. A push
|
||||
# to dev is meant to be cheap and frequent; waiting on three runner pools
|
||||
# to agree is what a release candidate is for.
|
||||
#
|
||||
# `if` SKIPS the job (it does not queue). A queued job would be far worse
|
||||
# than a slow one: a never-scheduled job leaves the run pending forever, so
|
||||
# the branch's required checks never report complete.
|
||||
needs: resolve-msrv
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: freemkv-unlock-ci
|
||||
- run: cargo build --tests --all-features
|
||||
|
||||
check-windows:
|
||||
# dev is the FAST lane: this job still runs, but on the release-candidate
|
||||
# branches rather than on every push to dev. Nothing is deleted and no
|
||||
# platform stops being checked before a release -- qa.yml independently
|
||||
# covers macOS and Windows, and the jobs unique to this file (the Intel
|
||||
# macOS build, the Windows release build) run here on qa and main. A push
|
||||
# to dev is meant to be cheap and frequent; waiting on three runner pools
|
||||
# to agree is what a release candidate is for.
|
||||
#
|
||||
# `if` SKIPS the job (it does not queue). A queued job would be far worse
|
||||
# than a slow one: a never-scheduled job leaves the run pending forever, so
|
||||
# the branch's required checks never report complete.
|
||||
needs: resolve-msrv
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.97.0
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: freemkv-unlock-ci
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
name: comment-guard
|
||||
|
||||
# Thin caller → the canonical reusable guard in freemkv/.github (one definition
|
||||
# for every repo). Fork PRs still run it; same-repo pushes aren't double-run.
|
||||
on: [push, pull_request]
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
comment-guard:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||
uses: freemkv/.github/.github/workflows/guard-comment.yml@v2
|
||||
@@ -0,0 +1,16 @@
|
||||
name: doc-links
|
||||
|
||||
# Thin caller -> the canonical reusable doc-links gate in freemkv/.github.
|
||||
# freemkv-unlock is the leaf crate: no siblings to check out, no [patch].
|
||||
on: [push, pull_request]
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
doc-links:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||
uses: freemkv/.github/.github/workflows/guard-doclinks.yml@v1
|
||||
with:
|
||||
self: freemkv-unlock
|
||||
@@ -0,0 +1,14 @@
|
||||
name: identity-guard
|
||||
|
||||
# Thin caller → the canonical reusable guard in freemkv/.github.
|
||||
on:
|
||||
push:
|
||||
branches: [dev, qa, main]
|
||||
pull_request:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
identity:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||
uses: freemkv/.github/.github/workflows/guard-identity.yml@v1
|
||||
@@ -1,35 +1,11 @@
|
||||
name: leak-guard
|
||||
|
||||
# Self-contained public-repo leak gate. Public CI cannot reach the private
|
||||
# tooling, so this encodes only the generic net: internal-infra references,
|
||||
# tracked CLAUDE.md/.claude paths, and AI-attribution in commit messages.
|
||||
# No project-specific reverse-engineering vocabulary lives here.
|
||||
|
||||
# Thin caller → the canonical reusable guard in freemkv/.github.
|
||||
on: [push, pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
leak-guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Compute commit range
|
||||
id: range
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
base="${{ github.event.pull_request.base.sha }}"
|
||||
head="${{ github.event.pull_request.head.sha }}"
|
||||
echo "range=$base..$head" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
before="${{ github.event.before }}"
|
||||
after="${{ github.sha }}"
|
||||
# New branch / first push: github.event.before is all-zeros.
|
||||
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "range=$after" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "range=$before..$after" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
- name: Run leak-guard
|
||||
run: bash ci/leak-guard.sh "${{ steps.range.outputs.range }}"
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||
uses: freemkv/.github/.github/workflows/guard-leak.yml@v1
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
name: qa
|
||||
|
||||
# ── The qa gate: "is this production worth?" ────────────────────────────────
|
||||
#
|
||||
# dev -> qa -> main.
|
||||
#
|
||||
# `dev` is for committing often. ci.yml answers "is it green" in minutes with
|
||||
# fmt, clippy and the unit suite, so a mistake surfaces while it is still cheap
|
||||
# to fix. `qa` is the release-candidate branch, and THIS workflow is the claim
|
||||
# that a commit is production worth: everything expensive that can run without
|
||||
# physical media. `main` only ever receives a qa that went green here.
|
||||
#
|
||||
# Sibling repos are checked out at `qa`, NOT `dev`. A qa run that resolved its
|
||||
# dependencies from dev tips would be validating a combination that is not the
|
||||
# one being released, which is the exact failure this branch exists to prevent.
|
||||
#
|
||||
# What this gate CANNOT cover: `disc://` and real `iso://` need physical media,
|
||||
# and no hosted runner has an optical drive or the image hoard. That coverage
|
||||
# lives in freemkv's qa.yml (the real-media EC2 matrix), not in this file.
|
||||
on:
|
||||
push:
|
||||
branches: [qa]
|
||||
workflow_dispatch:
|
||||
|
||||
# A re-push to qa supersedes its own in-progress run instead of stacking.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Least privilege by default; only rc-tag escalates (job-level contents: write).
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Single source of truth for the MSRV — see ci.yml. Jobs that build/test AT
|
||||
# the MSRV read `needs.resolve-msrv.outputs.v` from Cargo.toml's rust-version;
|
||||
# clippy (cross-lint) runs on stable instead.
|
||||
resolve-msrv:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
v: ${{ steps.msrv.outputs.v }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- id: msrv
|
||||
shell: bash
|
||||
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── Name the candidate ────────────────────────────────────────────────
|
||||
#
|
||||
# Every push to `qa` is a release candidate, so every push gets a tag:
|
||||
# v<version>-rc<N>, N incrementing. That is the answer to "which build is on
|
||||
# qa right now, and is it the one I tested?" — a question that otherwise gets
|
||||
# answered from memory.
|
||||
#
|
||||
# This runs FIRST and does not depend on the gates, deliberately. A red
|
||||
# candidate needs a name more than a green one does: "rc3 failed
|
||||
# release-tests on windows" is a sentence you can act on; "qa is red" is not.
|
||||
# Red on qa is a working gate, not an incident — it is the branch saying this
|
||||
# is not production worth yet. Fix on dev, get dev green, push qa again.
|
||||
#
|
||||
# release.yml excludes v*-rc* so a candidate never publishes a release.
|
||||
rc-tag:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Stamp the next rc
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
|
||||
[ -n "$v" ] || { echo "no version in Cargo.toml" >&2; exit 1; }
|
||||
# Numeric sort on the rc ordinal: -rc10 must beat -rc9, and a plain
|
||||
# lexical sort gets that backwards from the tenth candidate on.
|
||||
n=$(git tag -l "v$v-rc*" | sed "s|^v$v-rc||" | sort -n | tail -1)
|
||||
tag="v$v-rc$(( ${n:-0} + 1 ))"
|
||||
git tag "$tag"
|
||||
git push origin "$tag"
|
||||
echo "### Candidate \`$tag\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The debug suite runs on every dev push. Release is a DIFFERENT build:
|
||||
# overflow checks are off, debug_assert! is compiled out, and inlining
|
||||
# changes what the optimiser can prove. A test that only passes in debug is
|
||||
# a test that never guarded the binary anyone actually ships.
|
||||
release-tests:
|
||||
needs: resolve-msrv
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: ['ubuntu-latest', 'macos-latest']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-unlock
|
||||
- run: cargo test --release --tests
|
||||
working-directory: freemkv-unlock
|
||||
|
||||
# clippy's output is target-dependent: cfg-gated code only gets linted on
|
||||
# the target it compiles for. Linting solely on the dev machine's host
|
||||
# target is how a lint that CI rejects reaches a push.
|
||||
cross-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-unlock
|
||||
# clippy runs on stable (matches ci.yml's lint job); build/test stay on
|
||||
# the MSRV pin. See ci.yml for why the lint toolchain need not be the MSRV.
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-unlock
|
||||
- run: rustup target add x86_64-unknown-linux-gnu
|
||||
- run: cargo clippy --all-targets --target x86_64-unknown-linux-gnu -- -D warnings
|
||||
working-directory: freemkv-unlock
|
||||
|
||||
# Windows compiles the tests but does not run them, matching the policy
|
||||
# ci.yml already set. The value here is codegen: the #[cfg(windows)] halves
|
||||
# of the SCSI transport and platform layers compile on no other runner, so
|
||||
# without this they are first built at release time.
|
||||
windows-build:
|
||||
needs: resolve-msrv
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: freemkv-unlock
|
||||
- run: cargo build --release --tests
|
||||
working-directory: freemkv-unlock
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
.cargo/config.toml
|
||||
NEXT_SESSION.md
|
||||
|
||||
+276
@@ -1,5 +1,281 @@
|
||||
# Changelog
|
||||
|
||||
## [1.8.2] — Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- Released with freemkv 1.8.2 so every component reports the same version. No changes.
|
||||
|
||||
## [1.8.1] — 2026-10-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- Renesas drives: more reliable VID reading from the drive, including drives with an optional VID slot, and a hardened OEM unlock.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- `pioneer-optical` 0.10 (the 0.7 line is yanked). The vendor CDB bytes are unchanged, and a test pins them.
|
||||
|
||||
## [1.8.0] — 2026-10-05
|
||||
|
||||
### Changed
|
||||
|
||||
- The Renesas/Pioneer unlocker builds its vendor CDBs (identity read, gated memory reads, read-unlock knock) with the `pioneer-optical` crate instead of hard-coded bytes; the commands sent are unchanged.
|
||||
- AACS handshake diagnostics that a scan now reports itself log below WARN: unsupported or unverifiable drive certs, VID read failures and transport faults at DEBUG, `read_data_key_dropped` at INFO.
|
||||
|
||||
## [1.7.7] — 2026-09-26
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Replace comment-overflow documentation with concise source contracts and README instructions; enforce the shared comment policy in CI.
|
||||
|
||||
## [1.7.6] — 2026-09-26
|
||||
|
||||
### Changed
|
||||
|
||||
- firmware: mirror the fw 0.9.2 ABI — `Feature::Bd` (wire id `0x04`) is retired, and `Feature::Uhd` is renamed to `Feature::Unrestricted` (wire id `0x03`). `ALL_FEATURES` shrinks from 6 to 5. Required to talk to firmware 0.9.2. **Breaking**: any downstream code naming `Feature::Bd` or `Feature::Uhd` must be updated.
|
||||
- Version aligned to 1.7.6 for the unified release alongside the freemkv 1.7.6 Linux desktop shell (issue #56).
|
||||
|
||||
## [1.7.5] — 2026-09-23
|
||||
|
||||
### Changed
|
||||
|
||||
- firmware: mirror the fw 0.9.0 ABI — `Ake` (`0x06`) and `Bus` (`0x07`) are retired into a single `Encryption` (`0x06`) lever, so the unlock recipe now sets one flag instead of two and wire id `0x07` is gone. Required to talk to firmware 0.9.0.
|
||||
|
||||
## [1.7.4] — 2026-09-21
|
||||
|
||||
### Maintenance
|
||||
|
||||
- CI consolidated to the central reusable workflows; no functional changes.
|
||||
|
||||
## [1.7.3] — 2026-09-19
|
||||
|
||||
### Changed
|
||||
|
||||
- firmware: mirror the fw 0.8.3 RESET ABI — added `RESET_TO_DEFAULTS` (`0x01`, restore the baked create-time defaults into RAM) and `FirmwareControl::reset_to_defaults()`; `RESET_TO_OEM` (`0xFF`) is now documented as TRUE OEM (RAM passthrough **and** the NV block blanked to all-`0xFF`, so the drive is traceless), and `RESET_TO_FLASH` as marker-gated (a never-saved drive loads the defaults). Wire values unchanged.
|
||||
|
||||
## [1.7.2] — UNRELEASED
|
||||
|
||||
### Fixed
|
||||
|
||||
- `FreemkvUnlocker::set` now issues the vendor SET over the required 64-byte data-in phase (mirroring `FirmwareControl::set`). freemkv firmware 0.8.x aborts a SET sent with no data phase (CHECK CONDITION / Aborted Command), so every drive-feature SET failed — including the load-bearing `Set(Ake, off)` — and the freemkv firmware unlock silently fell back to the AACS/online-key route. Drives on freemkv firmware now unlock via the freemkv route again (hardware-confirmed on BU40N fw 0.8.1).
|
||||
|
||||
### Changed
|
||||
|
||||
- firmware: mirror ABI v2 — `Save` + `RESET`-to-flash/OEM modes, the region/speed value encodings, and the HRL/AKE/BUS `off = unlock` (`0x00`) polarity; `full_unlock`'s default profile flipped to match. Aligns `FirmwareControl` with freemkv firmware 0.8.0.
|
||||
|
||||
## [1.7.1] — 2026-09-14
|
||||
|
||||
### Added
|
||||
|
||||
- freemkv firmware: `full_unlock` now issues a trailing best-effort Raw Read `04 03` (data-clear / bus-off) after the VID read, so an unlocked drive is placed in bus-off mode and subsequent content `READ(10)`s return AACS-at-rest bytes without the in-transit AACS bus wrap. A no-op on firmware without the `04 03` lever and never discards an obtained unlock, so it is inert until the firmware side ships. Also exposed as `set_bus_off`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `ld/profile`: `find_by_drive_id` now binds a UNIQUE four-field identity match even when the drive reports a specific `product_id` the catalog stores generically (drive reports "BD-RE BU40N", catalog "BD-RE"). The 1.7.0 gating on an empty product id broke LibreDrive unlock on UHD-capable LG BU40N drives; this restores it.
|
||||
|
||||
## [1.7.0] — 2026-09-02
|
||||
|
||||
### Added
|
||||
|
||||
- Preliminary, beta unlocking support for **Renesas** optical drives and for drives running **freemkv firmware**. Both are early and unvalidated; the established LibreDrive / AACS / CSS unlock routes are unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `ld/profile`: `find_by_drive_id` no longer mis-binds a drive whose reported `product_id` is real but not catalogued to a sibling model's profile. The product-id-blind loose match is now gated on an empty product id, so a wrong profile — and its wrong LibreDrive microcode — can't be selected for a drive that does report a product id.
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.7.0 for the unified release.
|
||||
|
||||
## [1.6.14] — 2026-08-31
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.14 for the unified release.
|
||||
|
||||
## [1.6.13] — 2026-08-28
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.13 for the unified release.
|
||||
|
||||
## [1.6.12] — 2026-08-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.12 for the unified release; no functional changes.
|
||||
|
||||
## [1.6.11] — 2026-08-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.11 for the unified release. No functional changes to
|
||||
this crate; the release was driven by the libfreemkv main-feature selection
|
||||
improvements and the autorip mux-quarantine fix (see the libfreemkv and
|
||||
autorip 1.6.11 notes).
|
||||
|
||||
### Added
|
||||
|
||||
- Codecov coverage reporting and badge.
|
||||
- Substantially expanded unit-test coverage.
|
||||
|
||||
## [1.6.10] — 2026-08-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.10 for the unified release. No functional changes to
|
||||
this crate; the release was driven by libfreemkv (TrueHD/MLP audio now resyncs
|
||||
to the next major-sync access unit after a source transport-stream
|
||||
discontinuity, instead of splicing post-gap audio mid-stream — fixing
|
||||
decoder-choking seams on discs whose stream carries a continuity-counter gap;
|
||||
see the libfreemkv 1.6.10 notes).
|
||||
- MSRV lowered from 1.97 to 1.90. The crate does not use anything newer than
|
||||
1.90 stabilized; CI's pinned toolchain and `rust-version` are updated to
|
||||
match.
|
||||
|
||||
## [1.6.9] — 2026-08-22
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.9 for the unified release. No functional changes to
|
||||
this crate; the release was driven by autorip (automatic per-episode TV
|
||||
ripping — each episode named `S{NN}E{MM}`, with TMDB runtime-aligned episode
|
||||
numbering across multi-disc seasons — a Manual Rename option, and a unified
|
||||
per-disc staging state file — see the autorip 1.6.9 notes).
|
||||
|
||||
## [1.6.8] — 2026-08-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.8 for the unified release. No functional changes to
|
||||
this crate; the release was driven by autorip (webhooks now fire per pipeline
|
||||
stage — Rip / Mux / Move — with the Rip hook firing the moment the drive is
|
||||
free again, plus a Ripper-tab activity-banner fix so it also shows during
|
||||
moves — see the autorip 1.6.8 notes).
|
||||
|
||||
## [1.6.7] — 2026-08-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.7 for the unified release. No functional changes to
|
||||
this crate; the release was driven by autorip (per-webhook event selection,
|
||||
a progress bar per moved artifact, and move-queue / webhook-error fixes —
|
||||
see the autorip 1.6.7 notes).
|
||||
|
||||
## [1.6.6] — 2026-08-20
|
||||
|
||||
### Changed
|
||||
|
||||
- Version aligned to 1.6.6 for the unified release. No functional changes
|
||||
to this crate; the release was driven by autorip (webhooks may now target
|
||||
private/LAN addresses — see the autorip 1.6.6 notes).
|
||||
|
||||
## [1.6.5] — 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
- **AACS 2.0 (P-256) drives now complete a live host-certificate
|
||||
handshake.** The elliptic-curve (P-256) authentication path is wired
|
||||
into the cert handshake for real, not left as dead code. A drive is
|
||||
first tried with the AACS 1.0 certificate (a 2.0 drive accepts it for
|
||||
the Volume ID); if the drive refuses it and the supplied host
|
||||
certificate carries AACS 2.0 credentials, the crate falls back to the
|
||||
native P-256 handshake. It is cert-agnostic — it uses whatever the host
|
||||
certificate carries, with no separate feature gate — and a bus fault on
|
||||
the 1.0 attempt short-circuits the fallback so a dead bus is not
|
||||
hammered. The 2.0 path enforces the same chain-of-trust checks as the
|
||||
1.0 path (drive-cert type gate, fatal certificate verification under the
|
||||
real 2.0 Licensing Administrator key, on-curve validation of every
|
||||
attacker-supplied point, and AGID release on every exit). The 2.0
|
||||
certificate byte layout is documented as provisional pending a genuine
|
||||
drive certificate to pin it against; a wrong layout fails closed and
|
||||
never trusts.
|
||||
|
||||
### Security
|
||||
|
||||
- **A rogue drive or bridge can no longer forge its way to a trusted bus
|
||||
key.** A drive certificate whose type was neither AACS 1.0 nor 2.0 used
|
||||
to skip Licensing Administrator verification yet still have its own key
|
||||
trusted for the bus-key signature check, letting an attacker inject a
|
||||
chosen bus key; unknown cert types are now rejected at the type gate
|
||||
before any key is trusted. ECDSA verification now validates the signer's
|
||||
public key (on-curve, in-field, not the identity point) before use — a
|
||||
key of (0,0) previously collapsed verification to a forgeable form. The
|
||||
compiled-in AACS 1.0 and 2.0 Licensing Administrator anchor keys, which
|
||||
were off-curve, are replaced with the real published keys (both
|
||||
confirmed on-curve; the 1.0 key verifies a genuine LA-signed
|
||||
certificate). The P-256 certificate-length check now matches the real
|
||||
132-byte layout, so AACS 2.0 certificates verify at all.
|
||||
|
||||
- **Sensitive material no longer leaks through debug output.** `Unlocked`
|
||||
(which carries the bus key and Volume ID) and `DriveProfile` (which
|
||||
carries the MediaTek drive firmware image and per-drive vendor SCSI
|
||||
templates) now use hand-written redacting `Debug` implementations
|
||||
instead of printing everything. The AACS 2.0 authentication path now
|
||||
releases the drive AGID on every failure exit, matching the 1.0 path.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A dead or failing drive bus is no longer reported as a successful
|
||||
unlock.** A transport fault during the AACS data-key read, during
|
||||
firmware-unlock disc probing, and in both MediaTek speed-probe passes
|
||||
used to be swallowed or flattened into a generic failure — in the
|
||||
firmware-unlock case surfacing as `drive_unlocked: true` on a bus that
|
||||
had actually died. These paths now abort with a transport error so the
|
||||
fault is visible instead of masquerading as clear content.
|
||||
|
||||
- **Transport faults are no longer misclassified as "wrong drive" or
|
||||
"bad credentials."** A dead bus on the first probe command previously
|
||||
read as "not a Renesas drive" or "not a supported DVD profile"; a bus
|
||||
that died mid-handshake was reported as a credentials rejection after
|
||||
three retries against dead hardware; and CSS bus-auth collapsed every
|
||||
fault into a generic auth failure. Each call site now honours the
|
||||
transport contract — abort on a real bus fault, fall through only on a
|
||||
genuine "not applicable." A merely short probe response is no longer
|
||||
fabricated into a transport failure, so rips that would have succeeded
|
||||
are no longer aborted.
|
||||
|
||||
- **A half-unlocked drive now falls through to certificate
|
||||
authentication instead of claiming the bus is clear.** The firmware
|
||||
unlocker set `drive_unlocked: true` whenever the handshake merely
|
||||
completed, ignoring the computed unlock flag; a drive returning zero
|
||||
bytes could even report a full unlock from a buffer it never wrote.
|
||||
The unlock outcome is now actually consulted, so a partial unlock
|
||||
correctly steers to the cert-auth fallback. Related read paths that
|
||||
parsed the caller's own zero-filled buffer as drive data on a CHECK
|
||||
CONDITION are fixed to inspect status and bytes transferred.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Drive preparation issues 88 fewer SCSI commands per disc.** The
|
||||
probe's two passes previously walked the same 88 addresses twice; the
|
||||
redundant pass is removed.
|
||||
|
||||
## [1.6.4] — 2026-08-15
|
||||
|
||||
### Changed
|
||||
|
||||
- **No functional change.** This crate ships alongside the rest of freemkv at a
|
||||
matching version; its behaviour is untouched.
|
||||
|
||||
## [1.6.3] — 2026-08-10
|
||||
|
||||
### Changed
|
||||
|
||||
- **Housekeeping only — nothing about how a drive is unlocked has changed.** The
|
||||
cryptography and compression crates this crate builds on were moved to their
|
||||
current releases and brought into line with the versions the rest of freemkv
|
||||
uses, so one version of each is built instead of two.
|
||||
|
||||
## [1.6.2] — 2026-08-08
|
||||
|
||||
Version sync with the workspace. No functional change in this crate.
|
||||
|
||||
## [1.6.1] — 2026-08-07
|
||||
|
||||
Version sync with the workspace. No functional change in this crate.
|
||||
|
||||
## [1.6.0] — 2026-08-03
|
||||
|
||||
Version sync with the workspace. No functional change in this crate.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for your interest!
|
||||
|
||||
- **Report a bug** — open an issue
|
||||
- **Fix a bug** — fork, branch, PR
|
||||
- **Suggest a feature** — open an issue first
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
cargo test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Generated
+416
@@ -0,0 +1,416 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"cpubits",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.8.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"pioneer-optical",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"tracing",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pioneer-optical"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf5074a27cc39fb664d0b994814ffe8530e3e8590aff0d104723332dbcb1abc4"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "libfreemkv"
|
||||
version = "1.7.7"
|
||||
+17
-8
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.6.0"
|
||||
version = "1.8.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.97"
|
||||
rust-version = "1.88"
|
||||
license = "MIT"
|
||||
description = "Unlock layer for the freemkv toolchain: the Unlocker contract + self-contained firmware/AACS/CSS unlocker modules. libfreemkv depends on this and dispatches via all_unlockers()."
|
||||
repository = "https://github.com/freemkv/freemkv-unlock"
|
||||
@@ -18,11 +18,20 @@ emulation = []
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
base64 = "0.22.1"
|
||||
base64 = "0.23"
|
||||
tracing = "0.1"
|
||||
num-bigint = "0.4"
|
||||
num-bigint = "0.5"
|
||||
num-traits = "0.2"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
aes = "0.8"
|
||||
rand = "0.8"
|
||||
sha1 = "0.11"
|
||||
sha2 = "0.11"
|
||||
aes = "0.9"
|
||||
rand = "0.10"
|
||||
# Wipes host private keys, ephemeral scalars, and derived bus/volume/data-key
|
||||
# material on drop so it doesn't linger in freed memory (defence in depth atop
|
||||
# the redacting Debug impls). `derive` gives ZeroizeOnDrop for the key structs.
|
||||
zeroize = { version = "1", features = ["derive"] }
|
||||
# Pioneer/Renesas vendor CDB constructors (identity read, gated memory read, knock).
|
||||
pioneer-optical = "0.10"
|
||||
|
||||
[lints.clippy]
|
||||
uninlined_format_args = "allow"
|
||||
|
||||
@@ -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/.
|
||||
@@ -1,3 +1,7 @@
|
||||
[](LICENSE)
|
||||
[](https://github.com/freemkv/freemkv-unlock/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/freemkv/freemkv-unlock)
|
||||
|
||||
# freemkv-unlock
|
||||
|
||||
The unlock layer for the freemkv toolchain.
|
||||
@@ -32,4 +36,13 @@ for u in freemkv_unlock::all_unlockers() {
|
||||
To remove an unlocker, delete its module directory and its one line in
|
||||
`all_unlockers()` — nothing else changes.
|
||||
|
||||
License: MIT.
|
||||
## License
|
||||
|
||||
freemkv-unlock's own source is MIT — see [LICENSE](LICENSE).
|
||||
|
||||
`src/ld/profiles.json` bundles volatile, RAM-only **LibreDrive** microcode
|
||||
authored and owned by the **MakeMKV team** (GuinpinSoft inc.) — *Copyright (c)
|
||||
MakeMKV team, all rights reserved.* That microcode is third-party proprietary
|
||||
material, **not** covered by the MIT license; no ownership is claimed. See
|
||||
[`NOTICE`](NOTICE). With thanks to the MakeMKV team for
|
||||
[LibreDrive](http://www.makemkv.com/libredrive/).
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| 1.6.x | Yes |
|
||||
| < 1.6 | No |
|
||||
|
||||
Only the current 1.6.x line receives security fixes.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Report vulnerabilities privately through GitHub Security Advisories:
|
||||
https://github.com/freemkv/freemkv-unlock/security/advisories/new
|
||||
|
||||
Do not open a public issue for a security report. Include the affected
|
||||
version, steps to reproduce, and the impact you believe the issue has.
|
||||
|
||||
## Response time
|
||||
|
||||
You will get an initial response within 7 days.
|
||||
@@ -1,120 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# leak-guard.sh — self-contained public-repo leak gate.
|
||||
#
|
||||
# This is the LAST line of defense in CI. It is intentionally self-contained:
|
||||
# public CI cannot reach the private tooling, so this script encodes ONLY the
|
||||
# generic net — internal infrastructure references, agent-context files, and
|
||||
# AI-attribution in commit messages. It deliberately contains NO project-
|
||||
# specific reverse-engineering vocabulary (those words would themselves be a
|
||||
# leak). The richer private scanner stays private.
|
||||
#
|
||||
# Fails (exit 1) if any of the following appear in the repo:
|
||||
# 1. a tracked CLAUDE.md or .claude/ path (agent context — never public),
|
||||
# 2. tracked file content matching the internal-infra net,
|
||||
# 3. a commit message (in the given range) with AI attribution.
|
||||
#
|
||||
# Usage:
|
||||
# leak-guard.sh [<commit-range>]
|
||||
# <commit-range> optional git rev-list range to scan commit messages
|
||||
# (e.g. "abc..def"). If omitted, commit-message scan is
|
||||
# skipped (path + content checks always run).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Absolute path to this script, resolved before any cd, so we can exclude it
|
||||
# from the content scan (it necessarily contains the detection patterns).
|
||||
SELF_ABS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
|
||||
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO"
|
||||
|
||||
fail=0
|
||||
note() { printf ' ✗ %s\n' "$1"; fail=1; }
|
||||
|
||||
# Internal-infra net — GENERIC ONLY. This script ships in the public repo, so
|
||||
# the patterns themselves must not name any org-specific identifier (doing so
|
||||
# would itself leak the infra they guard). We catch the leak *class*:
|
||||
# - RFC1918 private IPv4 ranges (10/8, 172.16/12, 192.168/16),
|
||||
# - private/internal/non-routable TLDs (.internal/.local/.lan/.corp/.invalid),
|
||||
# - docker.internal.
|
||||
# The full org-specific net (literal hostnames, service names, repo paths,
|
||||
# vendor tooling, …) lives ONLY in the private scanner and never ships here.
|
||||
INFRA_RE='\b10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|\b172\.(1[6-9]|2[0-9]|3[01])\.[0-9]{1,3}\.[0-9]{1,3}|\b192\.168\.[0-9]{1,3}\.[0-9]{1,3}|\.internal\b|\.local\b|\.lan\b|\.corp\b|\.invalid\b|docker\.internal'
|
||||
# Home-path net — GENERIC ONLY. Catches an absolute developer home path
|
||||
# committed into a tracked file (a macOS /Users/<user>/… or Linux /home/<user>/…
|
||||
# path). This names NO specific user — it matches the leak *class* (any home
|
||||
# path), so the pattern itself reveals nothing org- or person-specific. A real
|
||||
# leak (e.g. /Users/alice/Developer/x slipping into a public RELEASE.md) trips
|
||||
# this regardless of whose machine it came from. The username segment is a
|
||||
# literal-username class ([A-Za-z0-9._-]) so dynamic/templated paths that build
|
||||
# the user at runtime — shell `/home/$USER/`, doc `/home/<rip>/`, Rust
|
||||
# `/home/{user}/` — do NOT false-positive; only a baked-in literal home leaks.
|
||||
HOMEPATH_RE='/Users/[A-Za-z0-9._-]+/|/home/[A-Za-z0-9._-]+/'
|
||||
# AI-attribution net (case-insensitive). "claude" matches only as a standalone
|
||||
# word — NOT preceded by a dot/slash/alnum and NOT followed by .md — so legit
|
||||
# mentions of CLAUDE.md / .claude/ in a commit message don't false-positive.
|
||||
ATTR_RE='co-authored-by|generated with|🤖|(?<![.\/A-Za-z0-9])claude(?!\.md)'
|
||||
|
||||
echo "── leak-guard: tracked agent-context paths ──"
|
||||
while IFS= read -r f; do
|
||||
case "$f" in
|
||||
CLAUDE.md|*/CLAUDE.md|.claude|.claude/*|*/.claude|*/.claude/*)
|
||||
note "tracked agent-context file: $f (CLAUDE.md/.claude must never be tracked in a public repo)" ;;
|
||||
esac
|
||||
done < <(git ls-files)
|
||||
|
||||
# Match a PCRE against a file, emitting "LINE: MATCH". The pattern is passed as
|
||||
# an argument (not interpolated into a //) so metacharacters like the "/" in a
|
||||
# path-style token can't break the regex. Reads raw bytes so non-UTF-8 blobs
|
||||
# don't abort the scan.
|
||||
pcre_matches() {
|
||||
perl -e '
|
||||
my ($file, $re) = @ARGV;
|
||||
open(my $fh, "<:raw", $file) or exit 0;
|
||||
my $rx; eval { $rx = qr/$re/i }; exit 0 if $@;
|
||||
while (my $l = <$fh>) { if ($l =~ /$rx/) { print "$.: $&\n"; } }
|
||||
' "$1" "$2" 2>/dev/null
|
||||
}
|
||||
|
||||
# This script's own source necessarily contains the detection patterns (e.g.
|
||||
# the regex tokens in INFRA_RE), so scanning it would always self-flag. Skip it.
|
||||
SELF="$(git ls-files --full-name -- "$SELF_ABS" 2>/dev/null | head -1)"
|
||||
|
||||
echo "── leak-guard: internal-infra references in tracked files ──"
|
||||
while IFS= read -r f; do
|
||||
case "$f" in *.png|*.jpg|*.jpeg|*.ico|*.gif|*.bin|*.crate|*.gz|*.zip|*.pdf) continue ;; esac
|
||||
[ -n "$SELF" ] && [ "$f" = "$SELF" ] && continue
|
||||
[ -f "$f" ] || continue
|
||||
while IFS= read -r hit; do
|
||||
[ -z "$hit" ] && continue
|
||||
note "internal-infra reference: $f:$hit"
|
||||
done < <(pcre_matches "$f" "$INFRA_RE")
|
||||
while IFS= read -r hit; do
|
||||
[ -z "$hit" ] && continue
|
||||
note "[HOME-PATH] absolute home path: $f:$hit (no local home path may be committed to a public repo)"
|
||||
done < <(pcre_matches "$f" "$HOMEPATH_RE")
|
||||
done < <(git ls-files)
|
||||
|
||||
RANGE="${1:-}"
|
||||
if [ -n "$RANGE" ]; then
|
||||
echo "── leak-guard: AI-attribution in commit messages ($RANGE) ──"
|
||||
while IFS= read -r sha; do
|
||||
[ -z "$sha" ] && continue
|
||||
msg="$(git log -1 --format='%B' "$sha" 2>/dev/null || true)"
|
||||
# Pass the pattern as an argument (not interpolated into a //) so the
|
||||
# lookbehind char class and "/" don't break the regex.
|
||||
hit="$(printf '%s' "$msg" | perl -e '
|
||||
my $re = $ARGV[0]; my $rx = qr/$re/i;
|
||||
while (my $l = <STDIN>) { if ($l =~ /($rx)/) { print "$1\n"; last; } }
|
||||
' "$ATTR_RE" | head -1 || true)"
|
||||
[ -n "$hit" ] && note "commit ${sha:0:12}: message contains \"$hit\" (owner rule: zero AI attribution, ever)"
|
||||
done < <(git rev-list "$RANGE" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "✗ leak-guard: blocking finding(s) above — DO NOT MERGE/PUBLISH"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ leak-guard: clean"
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
informational: true
|
||||
patch:
|
||||
default:
|
||||
informational: true
|
||||
|
||||
comment: false
|
||||
|
||||
ignore:
|
||||
- "tests"
|
||||
- "**/tests"
|
||||
- "benches"
|
||||
- "examples"
|
||||
@@ -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"]
|
||||
@@ -20,11 +20,26 @@ pub enum Error {
|
||||
AacsKeyRead,
|
||||
AacsKeyRejected,
|
||||
AacsKeyVerify,
|
||||
/// Our OWN just-produced host signature failed to verify against our host
|
||||
/// certificate's public key (libaacs-style self-verify guard). This means
|
||||
/// the loaded host cert's private key does not match its certificate — a
|
||||
/// mispaired keydb entry — so shipping the signature would only earn an
|
||||
/// opaque "KEY NOT ESTABLISHED" from the drive. Fail fast, host-side.
|
||||
AacsHostSignVerify,
|
||||
AacsNoKeys,
|
||||
AacsVidMac,
|
||||
AacsVidRead,
|
||||
HandshakeRejected,
|
||||
VidUnavailable,
|
||||
/// A handshake step returned FEWER bytes than the protocol step requires.
|
||||
/// The buffer the step would have parsed is the caller's own zero fill, so
|
||||
/// consuming it would turn a failed command into a "successful" handshake
|
||||
/// step built entirely out of zeros.
|
||||
ShortTransfer {
|
||||
opcode: u8,
|
||||
expected: usize,
|
||||
got: usize,
|
||||
},
|
||||
/// A SCSI command failed. `status == SCSI_STATUS_TRANSPORT_FAILURE` with
|
||||
/// `sense: None` is a transport-layer fault; a CHECK CONDITION carries the
|
||||
/// parsed [`ScsiSense`].
|
||||
@@ -49,16 +64,19 @@ impl Error {
|
||||
Error::AacsKeyRead => 7007,
|
||||
Error::AacsKeyRejected => 7008,
|
||||
Error::AacsKeyVerify => 7009,
|
||||
Error::AacsHostSignVerify => 7016,
|
||||
Error::AacsNoKeys => 7010,
|
||||
Error::AacsVidMac => 7011,
|
||||
Error::AacsVidRead => 7012,
|
||||
Error::HandshakeRejected => 7013,
|
||||
Error::VidUnavailable => 7014,
|
||||
Error::ShortTransfer { .. } => 7015,
|
||||
Error::Scsi { .. } => 7099,
|
||||
}
|
||||
}
|
||||
|
||||
/// The parsed sense for a CHECK CONDITION SCSI error, else `None`.
|
||||
#[cfg(test)]
|
||||
pub fn scsi_sense(&self) -> Option<ScsiSense> {
|
||||
match self {
|
||||
Error::Scsi { sense, .. } => *sense,
|
||||
@@ -86,3 +104,7 @@ impl From<crate::scsi::ScsiError> for Error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "error_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
use super::*;
|
||||
|
||||
/// Every variant has its own stable numeric code. Constructing all 17
|
||||
/// arms also exercises the `#[allow(dead_code)]` variants that the
|
||||
/// wired handshake path never builds.
|
||||
#[test]
|
||||
fn code_is_stable_per_variant() {
|
||||
assert_eq!(Error::AacsAgidAlloc.code(), 7001);
|
||||
assert_eq!(Error::AacsCertRead.code(), 7002);
|
||||
assert_eq!(Error::AacsCertRejected.code(), 7003);
|
||||
assert_eq!(Error::AacsCertShort.code(), 7004);
|
||||
assert_eq!(Error::AacsCertVerify.code(), 7005);
|
||||
assert_eq!(Error::AacsDataKey.code(), 7006);
|
||||
assert_eq!(Error::AacsKeyRead.code(), 7007);
|
||||
assert_eq!(Error::AacsKeyRejected.code(), 7008);
|
||||
assert_eq!(Error::AacsKeyVerify.code(), 7009);
|
||||
assert_eq!(Error::AacsHostSignVerify.code(), 7016);
|
||||
assert_eq!(Error::AacsNoKeys.code(), 7010);
|
||||
assert_eq!(Error::AacsVidMac.code(), 7011);
|
||||
assert_eq!(Error::AacsVidRead.code(), 7012);
|
||||
assert_eq!(Error::HandshakeRejected.code(), 7013);
|
||||
assert_eq!(Error::VidUnavailable.code(), 7014);
|
||||
assert_eq!(
|
||||
Error::ShortTransfer {
|
||||
opcode: 0xA4,
|
||||
expected: 16,
|
||||
got: 4,
|
||||
}
|
||||
.code(),
|
||||
7015
|
||||
);
|
||||
assert_eq!(
|
||||
Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0x02,
|
||||
sense: None,
|
||||
}
|
||||
.code(),
|
||||
7099
|
||||
);
|
||||
}
|
||||
|
||||
/// `scsi_sense` returns the parsed sense for a `Scsi` error carrying one,
|
||||
/// `None` when the `Scsi` error has none, and `None` for every non-`Scsi`
|
||||
/// variant.
|
||||
#[test]
|
||||
fn scsi_sense_returns_the_parsed_sense_only_for_scsi_variant() {
|
||||
let sense = ScsiSense {
|
||||
sense_key: 0x05,
|
||||
asc: 0x20,
|
||||
ascq: 0x00,
|
||||
};
|
||||
let with_sense = Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0x02,
|
||||
sense: Some(sense),
|
||||
};
|
||||
let sc = with_sense.scsi_sense().unwrap();
|
||||
assert_eq!(sc.sense_key, sense.sense_key);
|
||||
assert_eq!(sc.asc, sense.asc);
|
||||
assert_eq!(sc.ascq, sense.ascq);
|
||||
|
||||
let without_sense = Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0xFF,
|
||||
sense: None,
|
||||
};
|
||||
assert!(without_sense.scsi_sense().is_none());
|
||||
|
||||
assert!(Error::AacsNoKeys.scsi_sense().is_none());
|
||||
}
|
||||
|
||||
/// `is_scsi_transport_failure` is true only for a `Scsi` error with the
|
||||
/// transport-failure status AND no sense; a drive CHECK CONDITION and
|
||||
/// every non-`Scsi` variant are false.
|
||||
#[test]
|
||||
fn is_scsi_transport_failure_true_and_false_paths() {
|
||||
let transport = Error::Scsi {
|
||||
opcode: 0,
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
};
|
||||
assert!(transport.is_scsi_transport_failure());
|
||||
|
||||
let check_condition = Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0x02,
|
||||
sense: Some(ScsiSense {
|
||||
sense_key: 0x05,
|
||||
asc: 0x20,
|
||||
ascq: 0x00,
|
||||
}),
|
||||
};
|
||||
assert!(!check_condition.is_scsi_transport_failure());
|
||||
|
||||
assert!(!Error::AacsNoKeys.is_scsi_transport_failure());
|
||||
}
|
||||
|
||||
/// `From<ScsiError>` maps a raw transport-layer error into `Error::Scsi`,
|
||||
/// parsing the sense buffer when present and leaving it `None` otherwise.
|
||||
#[test]
|
||||
fn from_scsi_error_maps_status_and_parses_sense() {
|
||||
let e = Error::from(crate::scsi::ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
match e {
|
||||
Error::Scsi {
|
||||
opcode,
|
||||
status,
|
||||
sense,
|
||||
} => {
|
||||
assert_eq!(opcode, 0);
|
||||
assert_eq!(status, SCSI_STATUS_TRANSPORT_FAILURE);
|
||||
assert!(sense.is_none());
|
||||
}
|
||||
_ => panic!("expected Error::Scsi"),
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 32];
|
||||
buf[2] = 0x05;
|
||||
buf[12] = 0x24;
|
||||
buf[13] = 0x01;
|
||||
let e = Error::from(crate::scsi::ScsiError {
|
||||
status: 0x02,
|
||||
sense: Some(buf),
|
||||
});
|
||||
match e {
|
||||
Error::Scsi { status, sense, .. } => {
|
||||
assert_eq!(status, 0x02);
|
||||
let s = sense.expect("sense parsed");
|
||||
assert_eq!(s.sense_key, 0x05);
|
||||
assert_eq!(s.asc, 0x24);
|
||||
assert_eq!(s.ascq, 0x01);
|
||||
}
|
||||
_ => panic!("expected Error::Scsi"),
|
||||
}
|
||||
}
|
||||
+774
-995
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+164
-92
@@ -8,125 +8,197 @@
|
||||
mod error;
|
||||
mod handshake;
|
||||
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray};
|
||||
/// Keypair-validity check for a stored AACS 1.0 host cert (`priv·G ==
|
||||
/// cert_pub_key`). Exposed for the key service, which must refuse to serve a
|
||||
/// cert whose stored private key doesn't match its certificate.
|
||||
pub use handshake::aacs1_keypair_matches;
|
||||
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{Array, BlockCipherDecrypt, KeyInit};
|
||||
|
||||
use crate::firmware::{ArmRecipe, FirmwareControl, FirmwareError};
|
||||
use crate::scsi::ScsiTransport;
|
||||
use crate::{DiscKind, UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
use crate::{DiscKind, HostCert, UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
/// AES-128-ECB decrypt a single 16-byte block — used to decrypt the bus key /
|
||||
/// read_data_key the drive returns after the handshake.
|
||||
pub(crate) fn aes_ecb_decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
let mut block = GenericArray::clone_from_slice(data);
|
||||
let cipher = Aes128::new(&(*key).into());
|
||||
let mut block: Array<u8, _> = (*data).into();
|
||||
cipher.decrypt_block(&mut block);
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&block);
|
||||
out
|
||||
}
|
||||
|
||||
/// The AACS host-certificate unlocker. Matches a Blu-ray/UHD disc
|
||||
/// (`DiscKind::Aacs`) and runs the cert handshake against the host certs the
|
||||
/// consumer collected (via [`UnlockCtx::host_certs`]), learning the Volume ID
|
||||
/// and — on AACS 2.0 — the bus key.
|
||||
pub struct AacsCert;
|
||||
/// The AACS host-certificate unlocker — the fallback for a drive with NO vendor
|
||||
/// unlock (can't be raw-read-enabled), so the bus is removed by a real host-cert
|
||||
/// AKE + bus key rather than a CDB. The host certs are injected at CONSTRUCTION
|
||||
/// (the one place certs enter the system). Owns its AKE crypto — no reach into
|
||||
/// libfreemkv.
|
||||
pub struct AacsUnlocker {
|
||||
host_certs: Vec<HostCert>,
|
||||
/// Opt-in: a freemkv firmware recipe to ARM the drive with before the cert
|
||||
/// AKE. `None` (the default) keeps behaviour byte-identical to a stock host
|
||||
/// — the drive is never touched with a vendor command. See
|
||||
/// [`AacsUnlocker::arm_before_unlock`].
|
||||
arm: Option<ArmRecipe>,
|
||||
/// TEST-ONLY seam: a self-generated AACS 1.0 LA anchor to run the cert AKE
|
||||
/// under. Production verifies against the real compiled-in anchor, which no
|
||||
/// synthetic drive emulator can sign for, so the end-to-end unlock tests
|
||||
/// drive the handshake under a test anchor instead. `None` in every real
|
||||
/// build (the field does not exist off `cfg(test)`).
|
||||
#[cfg(test)]
|
||||
test_v1_anchor: Option<([u8; 20], [u8; 20])>,
|
||||
}
|
||||
|
||||
impl AacsCert {
|
||||
pub fn new() -> Self {
|
||||
AacsCert
|
||||
impl AacsUnlocker {
|
||||
pub fn new(host_certs: Vec<HostCert>) -> Self {
|
||||
AacsUnlocker {
|
||||
host_certs,
|
||||
arm: None,
|
||||
#[cfg(test)]
|
||||
test_v1_anchor: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// TEST-ONLY: run the cert AKE under a self-generated AACS 1.0 LA anchor
|
||||
/// (see [`AacsUnlocker::test_v1_anchor`]).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_test_v1_anchor(mut self, la_x: [u8; 20], la_y: [u8; 20]) -> Self {
|
||||
self.test_v1_anchor = Some((la_x, la_y));
|
||||
self
|
||||
}
|
||||
|
||||
/// Run the cert handshake against the drive. Production threads the real LA
|
||||
/// anchors via [`handshake::run_cert_handshake`]; a test may override the
|
||||
/// AACS 1.0 anchor so a synthetic drive can complete the AKE.
|
||||
fn run_handshake(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
) -> std::result::Result<handshake::CertHandshake, UnlockError> {
|
||||
#[cfg(test)]
|
||||
if let Some((ax, ay)) = self.test_v1_anchor {
|
||||
// v2 anchor is unused here: allow_p256 = false keeps the native 2.0
|
||||
// path off, so a zeroed placeholder is never read.
|
||||
let dummy_v2 = [0u8; 32];
|
||||
return handshake::run_cert_handshake_with_anchors(
|
||||
scsi,
|
||||
&self.host_certs,
|
||||
(&ax, &ay),
|
||||
(&dummy_v2, &dummy_v2),
|
||||
false,
|
||||
);
|
||||
}
|
||||
handshake::run_cert_handshake(scsi, &self.host_certs)
|
||||
}
|
||||
|
||||
/// Opt IN (off by default) to arming a freemkv drive with `recipe` via
|
||||
/// [`FirmwareControl::arm`] before the cert AKE; non-freemkv or unsupported
|
||||
/// firmware gets only IDENTITY. An `Encryption=off` recipe (`BypassBd`,
|
||||
/// `BypassUhd`, `OemUhd`) pre-authenticates and de-busses the drive, so no
|
||||
/// cert is needed and `unlock()` returns the bare-read VID with no bus key.
|
||||
/// [`ArmRecipe::OemBd`] (`Hrl=off`) lets a revoked cert through the AKE. A
|
||||
/// recipe refused midway is not rolled back: its earlier SETs stay in RAM.
|
||||
pub fn arm_before_unlock(mut self, recipe: ArmRecipe) -> Self {
|
||||
self.arm = Some(recipe);
|
||||
self
|
||||
}
|
||||
|
||||
/// If arming is opted-in, apply the recipe. `Ok(true)` only when an
|
||||
/// `Encryption=off` recipe armed (drive pre-authenticated + de-bussed).
|
||||
/// Unsupported firmware or a refused recipe is `Ok(false)` (a refusal may
|
||||
/// leave earlier SETs applied, so it logs at warn); a dead bus aborts.
|
||||
fn maybe_arm(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
|
||||
let Some(recipe) = self.arm else {
|
||||
return Ok(false);
|
||||
};
|
||||
match FirmwareControl::new(scsi).arm(recipe) {
|
||||
Ok(()) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "aacs_arm_applied",
|
||||
recipe = ?recipe,
|
||||
"freemkv firmware armed before the cert AKE"
|
||||
);
|
||||
Ok(recipe.disables_encryption())
|
||||
}
|
||||
Err(FirmwareError::Transport) => Err(UnlockError::Transport),
|
||||
Err(FirmwareError::UnsupportedFirmware) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "aacs_arm_unsupported",
|
||||
recipe = ?recipe,
|
||||
"not supported freemkv firmware; plain cert AKE"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "aacs_arm_recipe_refused",
|
||||
recipe = ?recipe,
|
||||
error = ?e,
|
||||
"arm recipe refused (earlier SETs stay applied); plain cert AKE"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AacsCert {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for AacsCert {
|
||||
impl Unlocker for AacsUnlocker {
|
||||
fn name(&self) -> &'static str {
|
||||
"AACS"
|
||||
}
|
||||
|
||||
/// AACS removes BUS encryption via the host-cert handshake; it provides no
|
||||
/// drive features. Self-guards on the disc kind (the consumer iterates every
|
||||
/// unlocker's `unlock_bus`, so a non-AACS disc must decline here).
|
||||
fn unlock_bus(
|
||||
/// Remove AACS bus encryption via the host-cert handshake. Like every other
|
||||
/// unlocker this DOES unlock the drive — just with a cert + AKE instead of a
|
||||
/// vendor CDB. `Some` on a successful handshake (VID + bus key learned), or
|
||||
/// after an opted-in `Encryption=off` arm (bare VID, no bus key);
|
||||
/// `None` on a non-AACS disc, no usable cert, or a rejected handshake (fall
|
||||
/// through); `Err(Transport)` on a dead bus.
|
||||
fn unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
) -> std::result::Result<Option<Unlocked>, UnlockError> {
|
||||
if ctx.kind != DiscKind::Aacs {
|
||||
return Err(UnlockError::NotApplicable);
|
||||
return Ok(None);
|
||||
}
|
||||
if ctx.host_certs.is_empty() {
|
||||
// No host cert to authenticate with — the consumer falls back to a
|
||||
// VID-less / keysource path.
|
||||
return Err(UnlockError::NoUsableHostCert);
|
||||
// Opt-in arm (no-op by default). Encryption=off leaves the drive
|
||||
// pre-authenticated and de-bussed: no cert AKE, and no bus key to apply.
|
||||
if self.maybe_arm(scsi)? {
|
||||
let vid = crate::vid::read_aacs_vid(scsi)?;
|
||||
return Ok(Some(Unlocked { vid, bus_key: None }));
|
||||
}
|
||||
let h = handshake::run_cert_handshake(scsi, ctx.host_certs)?;
|
||||
Ok(Unlocked {
|
||||
vid: Some(h.volume_id),
|
||||
// Host-cert AKE path: bus removal depends on the bus key, not a
|
||||
// firmware unlock.
|
||||
bus_key: h.read_data_key,
|
||||
drive_unlocked: false,
|
||||
})
|
||||
if self.host_certs.is_empty() {
|
||||
// No host cert to authenticate with — fall through.
|
||||
return Ok(None);
|
||||
}
|
||||
crate::fallthrough(self.run_handshake(scsi).map(|h| {
|
||||
// A UHD disc whose bus-key fetch the drive refused (non-transport)
|
||||
// returns Ok with read_data_key: None + read_data_key_err: Some —
|
||||
// otherwise indistinguishable from an AACS-1.0 disc. Surface it.
|
||||
if h.read_data_key.is_none()
|
||||
&& let Some(code) = h.read_data_key_err
|
||||
{
|
||||
tracing::info!(
|
||||
target: "freemkv::disc",
|
||||
phase = "read_data_key_dropped",
|
||||
error_code = code,
|
||||
"AACS auth + VID succeeded but the drive served no read_data_key (bus key); \
|
||||
unlock reports bus_key: None"
|
||||
);
|
||||
}
|
||||
Unlocked {
|
||||
vid: Some(h.volume_id),
|
||||
bus_key: h.read_data_key,
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn id() -> crate::DriveId {
|
||||
crate::DriveId::default()
|
||||
}
|
||||
|
||||
/// `unlock_bus` self-guards on the disc kind: on a non-AACS disc it declines
|
||||
/// (`NotApplicable`) WITHOUT touching the transport, so iterating it on a
|
||||
/// CSS/unknown disc is safe.
|
||||
#[test]
|
||||
fn unlock_bus_declines_non_aacs_kinds() {
|
||||
struct DeadTransport;
|
||||
impl ScsiTransport for DeadTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
panic!("transport must not be touched on a non-AACS disc");
|
||||
}
|
||||
}
|
||||
let id = id();
|
||||
let mut t = DeadTransport;
|
||||
for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Css] {
|
||||
let r = AacsCert::new().unlock_bus(&mut t, &UnlockCtx::new(&id, k, &[]));
|
||||
assert_eq!(r.unwrap_err(), UnlockError::NotApplicable, "declines {k:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// With no host certs there is nothing to authenticate with → NoUsableHostCert,
|
||||
/// and the transport is never touched.
|
||||
#[test]
|
||||
fn no_host_certs_is_no_usable_host_cert() {
|
||||
struct DeadTransport;
|
||||
impl ScsiTransport for DeadTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
panic!("transport must not be touched with no host certs");
|
||||
}
|
||||
}
|
||||
let id = id();
|
||||
let mut t = DeadTransport;
|
||||
let r = AacsCert::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs, &[]));
|
||||
assert_eq!(r.unwrap_err(), UnlockError::NoUsableHostCert);
|
||||
}
|
||||
}
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
use super::*;
|
||||
|
||||
/// AES-128-ECB decrypt against the FIPS-197 Appendix B / NIST known-answer
|
||||
/// test vector: decrypting the published ciphertext with the published key
|
||||
/// must recover the published plaintext.
|
||||
#[test]
|
||||
fn aes_ecb_decrypt_matches_fips197_test_vector() {
|
||||
let key: [u8; 16] = [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
|
||||
0x0F,
|
||||
];
|
||||
let ciphertext: [u8; 16] = [
|
||||
0x69, 0xC4, 0xE0, 0xD8, 0x6A, 0x7B, 0x04, 0x30, 0xD8, 0xCD, 0xB7, 0x80, 0x70, 0xB4, 0xC5,
|
||||
0x5A,
|
||||
];
|
||||
let expected_plaintext: [u8; 16] = [
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF,
|
||||
];
|
||||
let out = aes_ecb_decrypt(&key, &ciphertext);
|
||||
assert_eq!(out, expected_plaintext);
|
||||
}
|
||||
|
||||
/// Decrypting a different ciphertext under the same key must not produce
|
||||
/// the same plaintext — pins that the function actually decrypts the
|
||||
/// given block rather than returning a constant.
|
||||
#[test]
|
||||
fn aes_ecb_decrypt_varies_with_input() {
|
||||
let key = [0u8; 16];
|
||||
let a = aes_ecb_decrypt(&key, &[0u8; 16]);
|
||||
let b = aes_ecb_decrypt(&key, &[1u8; 16]);
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
fn id() -> crate::DriveId {
|
||||
crate::DriveId::default()
|
||||
}
|
||||
|
||||
/// Self-guards on the disc kind: on a non-AACS disc `unlock()` declines
|
||||
/// (`Ok(false)`) WITHOUT touching the transport, even WITH a cert present —
|
||||
/// so the reason is the kind, not a missing cert.
|
||||
#[test]
|
||||
fn declines_non_aacs_kinds() {
|
||||
struct DeadTransport;
|
||||
impl ScsiTransport for DeadTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
panic!("transport must not be touched on a non-AACS disc");
|
||||
}
|
||||
}
|
||||
let id = id();
|
||||
let mut t = DeadTransport;
|
||||
for k in [DiscKind::Unknown, DiscKind::Unencrypted, DiscKind::Css] {
|
||||
assert!(
|
||||
AacsUnlocker::new(vec![host_cert()])
|
||||
.unlock(&mut t, &UnlockCtx::new(&id, k))
|
||||
.expect("declines")
|
||||
.is_none(),
|
||||
"declines {k:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Full success path through `Unlocker::unlock`: a self-consistent AACS 1.0
|
||||
// emulator proves the entry point learns `vid`/`bus_key` — and that the cert
|
||||
// route reports `unlock() == true` (it DOES unlock the drive, via the cert).
|
||||
#[test]
|
||||
fn unlock_succeeds_end_to_end() {
|
||||
let mut t = handshake::tests::DriveEmu::new();
|
||||
t.serve_data_keys = true;
|
||||
let (lax, lay) = (t.la_x, t.la_y);
|
||||
let id = id();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.with_test_v1_anchor(lax, lay)
|
||||
.unlock(&mut t, &ctx)
|
||||
.expect("auth + VID + data-key reads all succeed")
|
||||
.expect("the cert route unlocks the drive");
|
||||
assert_eq!(out.vid, Some([0x5Au8; 16]));
|
||||
assert!(out.bus_key.is_some());
|
||||
}
|
||||
|
||||
// The `read_data_key_dropped` path: auth + VID succeed but the drive serves
|
||||
// no bus key (non-transport). `unlock` still reports Unlocked (unlocked via
|
||||
// the cert) with `vid: Some` but `bus_key: None` — surfaced, not hidden.
|
||||
#[test]
|
||||
fn unlock_without_a_served_read_data_key_yields_vid_but_no_bus_key() {
|
||||
let mut t = handshake::tests::DriveEmu::new();
|
||||
t.serve_zero_data_keys = true; // GOOD status, all-zero key block
|
||||
let (lax, lay) = (t.la_x, t.la_y);
|
||||
let id = id();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.with_test_v1_anchor(lax, lay)
|
||||
.unlock(&mut t, &ctx)
|
||||
.expect("auth + VID succeed even when the bus key is refused")
|
||||
.expect("the cert route still unlocks the drive");
|
||||
assert_eq!(out.vid, Some([0x5Au8; 16]), "the VID was learned");
|
||||
assert!(
|
||||
out.bus_key.is_none(),
|
||||
"no read_data_key served ⇒ bus_key: None (reported, not faked)"
|
||||
);
|
||||
}
|
||||
|
||||
fn host_cert() -> crate::HostCert {
|
||||
handshake::tests::dummy_cert()
|
||||
}
|
||||
|
||||
// ── Opt-in arm-before-unlock ──────────────────────────────────────────────
|
||||
|
||||
/// The default (no arm opted-in) NEVER touches the transport for arming.
|
||||
#[test]
|
||||
fn no_arm_by_default_does_not_probe_firmware() {
|
||||
struct PanicTransport;
|
||||
impl ScsiTransport for PanicTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
panic!("default AacsUnlocker must not issue a firmware knock");
|
||||
}
|
||||
}
|
||||
let u = AacsUnlocker::new(vec![host_cert()]);
|
||||
assert!(u.arm.is_none());
|
||||
u.maybe_arm(&mut PanicTransport).expect("no-op");
|
||||
}
|
||||
|
||||
/// Opting in on a NON-freemkv drive probes once (IDENTITY) and then leaves
|
||||
/// the drive untouched — no recipe SETs are sent.
|
||||
#[test]
|
||||
fn arm_on_non_freemkv_drive_is_a_noop_after_the_probe() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
|
||||
u.maybe_arm(&mut t).expect("no-op on non-freemkv");
|
||||
// Exactly one CDB: the IDENTITY probe. No recipe SETs followed.
|
||||
assert_eq!(t.cdbs.len(), 1);
|
||||
assert_eq!(t.cdbs[0][4], crate::firmware::Verb::Identity as u8);
|
||||
}
|
||||
|
||||
/// A dead bus on the arm probe aborts the whole unlock.
|
||||
#[test]
|
||||
fn arm_probe_transport_fault_aborts() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
|
||||
assert_eq!(u.maybe_arm(&mut t).unwrap_err(), UnlockError::Transport);
|
||||
}
|
||||
|
||||
/// A freemkv fw front-end: answers the vendor knock (IDENTITY / SET / GET)
|
||||
/// like fw `version` does, and delegates every other CDB to `inner`.
|
||||
struct FwFront<T> {
|
||||
inner: T,
|
||||
version: &'static str,
|
||||
/// Feature flag table indexed by wire id (slot 0 unused).
|
||||
flags: [u8; 7],
|
||||
refuse_sets: bool,
|
||||
dead_after_identity: bool,
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl<T> FwFront<T> {
|
||||
fn new(inner: T) -> Self {
|
||||
FwFront {
|
||||
inner,
|
||||
version: "0.9.2",
|
||||
flags: [crate::firmware::STATE_PASSTHROUGH; 7],
|
||||
refuse_sets: false,
|
||||
dead_after_identity: false,
|
||||
cdbs: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// Whether any cert-AKE command (REPORT KEY / SEND KEY) was issued.
|
||||
fn ran_cert_ake(&self) -> bool {
|
||||
self.cdbs
|
||||
.iter()
|
||||
.any(|c| c[0] == crate::scsi::SCSI_REPORT_KEY || c[0] == crate::scsi::SCSI_SEND_KEY)
|
||||
}
|
||||
fn sent_a_set(&self) -> bool {
|
||||
use crate::firmware::{CDB_VERB, READ_BUFFER_OPCODE, Verb};
|
||||
self.cdbs
|
||||
.iter()
|
||||
.any(|c| c[0] == READ_BUFFER_OPCODE && c[CDB_VERB] == Verb::Set as u8)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ScsiTransport> ScsiTransport for FwFront<T> {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: crate::scsi::DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
use crate::firmware::{CDB_FEATURE, CDB_STATE, CDB_VERB, READ_BUFFER_OPCODE, Verb};
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
if cdb[0] != READ_BUFFER_OPCODE {
|
||||
return self.inner.execute(cdb, dir, data, timeout_ms);
|
||||
}
|
||||
let status = |status: u8, n: usize| {
|
||||
Ok(crate::scsi::ScsiResult {
|
||||
status,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
};
|
||||
let verb = cdb[CDB_VERB];
|
||||
let f = (cdb[CDB_FEATURE] as usize).min(6);
|
||||
if verb == Verb::Identity as u8 {
|
||||
let mut flags = [0u8; 6];
|
||||
flags.copy_from_slice(&self.flags[1..]);
|
||||
let mut p = crate::firmware::identity_reply(self.version, flags);
|
||||
p.resize(data.len(), 0);
|
||||
data.copy_from_slice(&p[..data.len()]);
|
||||
return status(0, data.len());
|
||||
}
|
||||
if self.dead_after_identity {
|
||||
return Err(crate::scsi::ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
if verb == Verb::Set as u8 {
|
||||
if self.refuse_sets {
|
||||
return status(crate::scsi::SCSI_STATUS_CHECK_CONDITION, 0);
|
||||
}
|
||||
self.flags[f] = cdb[CDB_STATE];
|
||||
} else if verb == Verb::Get as u8 {
|
||||
data.fill(0);
|
||||
data[0] = self.flags[f];
|
||||
}
|
||||
status(0, data.len())
|
||||
}
|
||||
// Forward the Stop methods (stop-design-v5 §2.3): the defaults would
|
||||
// swallow them, so wrapped tests would stop exercising cancellation.
|
||||
fn pause(&mut self, d: std::time::Duration) -> crate::scsi::Result<()> {
|
||||
self.inner.pause(d)
|
||||
}
|
||||
fn begin_critical(&mut self) -> crate::scsi::Result<()> {
|
||||
self.inner.begin_critical()
|
||||
}
|
||||
fn end_critical(&mut self) {
|
||||
self.inner.end_critical()
|
||||
}
|
||||
fn execute_cleanup(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: crate::scsi::DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
self.inner.execute_cleanup(cdb, dir, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
|
||||
// UT6 (stop-design-v5 §5.2; §2.3 "The test-only `FwFront<T>` … forwards all
|
||||
// four"): otherwise every FwFront-wrapped test silently stops exercising Stop.
|
||||
#[test]
|
||||
fn fwfront_forwards_new_methods() {
|
||||
use crate::scsi::DataDirection;
|
||||
use crate::scsi::mock::{Ev, MockTransport, Reply, StopFake};
|
||||
let inner = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 2])));
|
||||
let mut t = FwFront::new(inner);
|
||||
let d = std::time::Duration::from_millis(1);
|
||||
t.pause(d).expect("forwarded pause");
|
||||
t.begin_critical().expect("forwarded begin");
|
||||
t.end_critical();
|
||||
let rel = [0xA4, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0x3F, 0];
|
||||
t.execute_cleanup(&rel, DataDirection::FromDevice, &mut [0u8; 2], 5_000)
|
||||
.expect("forwarded cleanup");
|
||||
assert_eq!(
|
||||
t.inner.log,
|
||||
vec![Ev::Pause(d), Ev::Begin, Ev::End, Ev::Cleanup(rel.to_vec())],
|
||||
"all four reach the wrapped transport"
|
||||
);
|
||||
t.inner.cancelled = true;
|
||||
assert!(t.pause(d).is_err(), "a cancel reaches through the wrapper");
|
||||
assert!(t.begin_critical().is_err());
|
||||
}
|
||||
|
||||
fn vid_reply(vid: [u8; 16]) -> crate::scsi::mock::MockTransport {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut p = vec![0u8; 36];
|
||||
p[4..20].copy_from_slice(&vid);
|
||||
MockTransport::always(Reply::good(p))
|
||||
}
|
||||
|
||||
fn aacs_ctx(id: &crate::DriveId) -> UnlockCtx<'_> {
|
||||
UnlockCtx::new(id, DiscKind::Aacs)
|
||||
}
|
||||
|
||||
/// Opting in on a freemkv drive applies the recipe: BypassBd sends
|
||||
/// Set(Hrl, off) and Set(Encryption, off), each verified, after the
|
||||
/// IDENTITY gate and BEFORE the handshake would run.
|
||||
#[test]
|
||||
fn arm_on_freemkv_drive_applies_the_recipe() {
|
||||
use crate::firmware::{CDB_VERB, Feature, STATE_OFF, Verb, build_set_cdb};
|
||||
let mut t = FwFront::new(vid_reply([0x42; 16]));
|
||||
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
|
||||
assert!(u.maybe_arm(&mut t).expect("armed"));
|
||||
assert_eq!(t.flags[Feature::Hrl as usize], STATE_OFF);
|
||||
assert_eq!(t.flags[Feature::Encryption as usize], STATE_OFF);
|
||||
assert_eq!(t.cdbs.len(), 5);
|
||||
assert_eq!(t.cdbs[0][CDB_VERB], Verb::Identity as u8);
|
||||
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(t.cdbs[2][CDB_VERB], Verb::Get as u8);
|
||||
assert_eq!(t.cdbs[3], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
assert_eq!(t.cdbs[4][CDB_VERB], Verb::Get as u8);
|
||||
}
|
||||
|
||||
/// Regression: an Encryption=off recipe needs NO host cert. With an empty
|
||||
/// cert list the drive is still armed and the unlock is the bare VID read —
|
||||
/// no cert AKE, no bus key (content already comes back de-bussed).
|
||||
#[test]
|
||||
fn encryption_off_recipes_unlock_without_certs_via_the_bare_vid() {
|
||||
for recipe in [ArmRecipe::BypassBd, ArmRecipe::BypassUhd, ArmRecipe::OemUhd] {
|
||||
let mut t = FwFront::new(vid_reply([0x42; 16]));
|
||||
let id = id();
|
||||
let out = AacsUnlocker::new(vec![])
|
||||
.arm_before_unlock(recipe)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.expect("no fault")
|
||||
.unwrap_or_else(|| panic!("{recipe:?}: armed drive is unlocked"));
|
||||
assert_eq!(out.vid, Some([0x42; 16]), "{recipe:?}");
|
||||
assert_eq!(out.bus_key, None, "{recipe:?}: no double bus-decrypt");
|
||||
assert!(!t.ran_cert_ake(), "{recipe:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Armed Encryption=off, then the bare VID read: a dead bus there aborts
|
||||
/// with Transport; a drive rejection is still an unlock, just without a VID.
|
||||
#[test]
|
||||
fn encryption_off_arm_then_bare_vid_failure() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
for (reply, want) in [
|
||||
(Reply::TransportFault, Err(UnlockError::Transport)),
|
||||
(Reply::illegal_request(), Ok(Some((None, None)))),
|
||||
(Reply::illegal_request_as_err(), Ok(Some((None, None)))),
|
||||
] {
|
||||
let mut t = FwFront::new(MockTransport::always(reply));
|
||||
let id = id();
|
||||
let got = AacsUnlocker::new(vec![host_cert()])
|
||||
.arm_before_unlock(ArmRecipe::BypassBd)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.map(|o| o.map(|u| (u.vid, u.bus_key)));
|
||||
assert_eq!(got, want);
|
||||
assert!(t.sent_a_set(), "the drive was armed first");
|
||||
assert!(!t.ran_cert_ake());
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: with certs present, an Encryption=off arm still skips the
|
||||
/// cert AKE and never reports a bus key.
|
||||
#[test]
|
||||
fn encryption_off_recipe_with_certs_skips_the_cert_ake() {
|
||||
let mut t = FwFront::new(vid_reply([0x42; 16]));
|
||||
let id = id();
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.arm_before_unlock(ArmRecipe::BypassBd)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.expect("no fault")
|
||||
.expect("unlocked");
|
||||
assert_eq!(out.bus_key, None);
|
||||
assert!(!t.ran_cert_ake());
|
||||
}
|
||||
|
||||
/// A refused recipe (and OemBd, which leaves encryption on) proceeds to the
|
||||
/// plain cert AKE, which still unlocks the drive and learns the bus key.
|
||||
#[test]
|
||||
fn refused_or_cert_recipes_run_the_cert_ake() {
|
||||
for (recipe, refuse) in [(ArmRecipe::BypassBd, true), (ArmRecipe::OemBd, false)] {
|
||||
let mut emu = handshake::tests::DriveEmu::new();
|
||||
emu.serve_data_keys = true;
|
||||
let (lax, lay) = (emu.la_x, emu.la_y);
|
||||
let mut t = FwFront::new(emu);
|
||||
t.refuse_sets = refuse;
|
||||
let id = id();
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.with_test_v1_anchor(lax, lay)
|
||||
.arm_before_unlock(recipe)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.expect("no fault")
|
||||
.expect("the cert route unlocks");
|
||||
assert_eq!(out.vid, Some([0x5Au8; 16]), "{recipe:?}");
|
||||
assert!(out.bus_key.is_some(), "{recipe:?}");
|
||||
assert!(t.ran_cert_ake(), "{recipe:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: fw 0.8.x is below MIN_FW_VERSION, so the recipe is never
|
||||
/// sent and the plain cert route runs.
|
||||
#[test]
|
||||
fn unsupported_firmware_is_not_armed() {
|
||||
let mut emu = handshake::tests::DriveEmu::new();
|
||||
emu.serve_data_keys = true;
|
||||
let (lax, lay) = (emu.la_x, emu.la_y);
|
||||
let mut t = FwFront::new(emu);
|
||||
t.version = "0.8.3";
|
||||
let id = id();
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.with_test_v1_anchor(lax, lay)
|
||||
.arm_before_unlock(ArmRecipe::BypassBd)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.expect("no fault")
|
||||
.expect("the cert route unlocks");
|
||||
assert!(out.bus_key.is_some());
|
||||
assert!(!t.sent_a_set(), "no recipe SET on unsupported firmware");
|
||||
}
|
||||
|
||||
/// A dead bus mid-arm aborts the whole unlock, with or without certs.
|
||||
#[test]
|
||||
fn dead_bus_during_arm_aborts_the_unlock() {
|
||||
for certs in [vec![host_cert()], vec![]] {
|
||||
let mut t = FwFront::new(vid_reply([0x42; 16]));
|
||||
t.dead_after_identity = true;
|
||||
let id = id();
|
||||
let n = certs.len();
|
||||
let err = AacsUnlocker::new(certs)
|
||||
.arm_before_unlock(ArmRecipe::BypassBd)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.unwrap_err();
|
||||
assert_eq!(err, UnlockError::Transport, "{n} certs");
|
||||
assert!(!t.ran_cert_ake());
|
||||
}
|
||||
}
|
||||
|
||||
/// With no host certs there is nothing to authenticate with → `Ok(false)`,
|
||||
/// and the transport is never touched.
|
||||
#[test]
|
||||
fn no_host_certs_declines() {
|
||||
struct DeadTransport;
|
||||
impl ScsiTransport for DeadTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
panic!("transport must not be touched with no host certs");
|
||||
}
|
||||
}
|
||||
let id = id();
|
||||
let mut t = DeadTransport;
|
||||
assert!(
|
||||
AacsUnlocker::new(vec![])
|
||||
.unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs))
|
||||
.expect("no certs declines")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
+29
-5
@@ -19,6 +19,27 @@ impl Error {
|
||||
Error::Scsi(_) => 7299,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if this is a transport-layer SCSI failure (bus dead).
|
||||
pub fn is_transport_failure(&self) -> bool {
|
||||
matches!(self, Error::Scsi(s) if crate::scsi::is_dead_bus(s))
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapse a SCSI-layer failure from a bus-auth step onto the CSS auth code —
|
||||
/// but NEVER a transport fault.
|
||||
///
|
||||
/// Every step used to do `.map_err(|_| Error::CssAuthFailed)?`, which discarded
|
||||
/// the `ScsiError` whole. That made the `Transport` arm of the conversion below
|
||||
/// UNREACHABLE from any real call site: a dead bus arrived at the consumer as
|
||||
/// `NotApplicable`, so it fell through and kept probing a bus that was gone.
|
||||
pub fn step_err(e: crate::scsi::ScsiError) -> Error {
|
||||
let err = Error::Scsi(e);
|
||||
if err.is_transport_failure() {
|
||||
err
|
||||
} else {
|
||||
Error::CssAuthFailed
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::scsi::ScsiError> for Error {
|
||||
@@ -31,11 +52,14 @@ impl From<crate::scsi::ScsiError> for Error {
|
||||
/// "this unlocker didn't apply" (a transport fault still surfaces as Transport).
|
||||
impl From<Error> for crate::UnlockError {
|
||||
fn from(e: Error) -> Self {
|
||||
match e {
|
||||
Error::Scsi(s) if s.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE => {
|
||||
crate::UnlockError::Transport
|
||||
}
|
||||
_ => crate::UnlockError::NotApplicable,
|
||||
if e.is_transport_failure() {
|
||||
crate::UnlockError::Transport
|
||||
} else {
|
||||
crate::UnlockError::NotApplicable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "error_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
use super::*;
|
||||
use crate::UnlockError;
|
||||
use crate::scsi::{SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE, ScsiError};
|
||||
|
||||
/// Catches restoring the `.map_err(|_| CssAuthFailed)` that made the
|
||||
/// `Transport` arm unreachable: a dead bus must classify as `Transport`
|
||||
/// (consumer aborts), a drive rejection as `NotApplicable` (fall through).
|
||||
#[test]
|
||||
fn transport_fault_survives_the_step_mapping() {
|
||||
let dead = step_err(ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
assert!(dead.is_transport_failure());
|
||||
assert_eq!(UnlockError::from(dead), UnlockError::Transport);
|
||||
|
||||
let refused = step_err(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([0u8; 32]),
|
||||
});
|
||||
assert!(matches!(refused, Error::CssAuthFailed));
|
||||
assert_eq!(UnlockError::from(refused), UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
/// Each variant has its own stable numeric code.
|
||||
#[test]
|
||||
fn code_is_stable_per_variant() {
|
||||
assert_eq!(Error::CssAuthFailed.code(), 7201);
|
||||
assert_eq!(
|
||||
Error::Scsi(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([0u8; 32]),
|
||||
})
|
||||
.code(),
|
||||
7299
|
||||
);
|
||||
}
|
||||
|
||||
// False for `CssAuthFailed` and for a `Scsi` error with a sense (a
|
||||
// drive rejection, not a dead bus) even at transport-failure status;
|
||||
// true only for the genuine transport fault (status + no sense).
|
||||
#[test]
|
||||
fn is_transport_failure_false_paths() {
|
||||
assert!(!Error::CssAuthFailed.is_transport_failure());
|
||||
|
||||
let sensed = Error::Scsi(ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: Some([0u8; 32]),
|
||||
});
|
||||
assert!(!sensed.is_transport_failure());
|
||||
|
||||
let refusal = Error::Scsi(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: None,
|
||||
});
|
||||
assert!(!refusal.is_transport_failure());
|
||||
|
||||
let dead = Error::Scsi(ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
assert!(dead.is_transport_failure());
|
||||
}
|
||||
|
||||
/// `From<ScsiError>` wraps the transport error unchanged into `Error::Scsi`.
|
||||
#[test]
|
||||
fn from_scsi_error_wraps_into_scsi_variant() {
|
||||
let e = Error::from(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([1u8; 32]),
|
||||
});
|
||||
match e {
|
||||
Error::Scsi(inner) => {
|
||||
assert_eq!(inner.status, SCSI_STATUS_CHECK_CONDITION);
|
||||
assert_eq!(inner.sense, Some([1u8; 32]));
|
||||
}
|
||||
_ => panic!("expected Error::Scsi"),
|
||||
}
|
||||
}
|
||||
+186
-587
@@ -4,13 +4,47 @@
|
||||
//! CSS bus-auth handshake has set its Authentication Success Flag (ASF=1).
|
||||
//! [`unlock_css_reads`] runs that bus-auth challenge-response (which is what
|
||||
//! actually opens scrambled-sector reads), then a best-effort, non-fatal
|
||||
//! disc-key REPORT KEY. The bytes are NOT used as keys: the descramble title
|
||||
//! key is recovered keylessly by the Stevenson known-plaintext attack (see
|
||||
//! [`super::crack_key`]).
|
||||
//! disc-key REPORT KEY. The bytes are NOT used as keys: descrambling here is
|
||||
//! keyless — the descramble title key is recovered directly from the stream,
|
||||
//! so no device or player key is derived or needed.
|
||||
|
||||
mod error;
|
||||
use crate::css::error::{Error, Result};
|
||||
use crate::scsi::ScsiTransport;
|
||||
use crate::css::error::{Error, Result, step_err};
|
||||
use crate::scsi::{AgidGuard, DataDirection, ScsiTransport};
|
||||
|
||||
// Issue ONE CSS bus-auth CDB, honouring the transport contract: treats a
|
||||
// non-zero status as CssAuthFailed and checks bytes_transferred >= min_bytes
|
||||
// rather than trusting an unfilled buffer.
|
||||
fn css_scsi(
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
buf: &mut [u8],
|
||||
min_bytes: usize,
|
||||
) -> Result<()> {
|
||||
let r = scsi.execute(cdb, dir, buf, 5_000).map_err(step_err)?;
|
||||
if r.status != 0 {
|
||||
tracing::debug!(
|
||||
target: "freemkv::css",
|
||||
phase = "css_step_check_condition",
|
||||
opcode = cdb.first().copied().unwrap_or(0),
|
||||
status = r.status,
|
||||
"CSS bus-auth step returned a drive sense"
|
||||
);
|
||||
return Err(Error::CssAuthFailed);
|
||||
}
|
||||
if r.bytes_transferred < min_bytes {
|
||||
tracing::debug!(
|
||||
target: "freemkv::css",
|
||||
phase = "css_step_short_response",
|
||||
opcode = cdb.first().copied().unwrap_or(0),
|
||||
bytes_transferred = r.bytes_transferred,
|
||||
"CSS bus-auth step returned a short response"
|
||||
);
|
||||
return Err(Error::CssAuthFailed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── CryptKey tables ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -123,9 +157,10 @@ const PERM_VARIANT: [[u8; 32]; 2] = [
|
||||
/// Runs the bus-auth challenge-response (which sets the drive's ASF=1 and is
|
||||
/// what actually unlocks scrambled-sector reads), then a best-effort,
|
||||
/// non-fatal disc-key REPORT KEY. The title-key REPORT KEY is NOT issued: it
|
||||
/// is unnecessary (the descramble key is recovered keylessly by the Stevenson
|
||||
/// attack in [`super::crack_key`]) and its hard failure on some USB bridges
|
||||
/// used to abort the whole unlock (the 7014 bug). The bytes are discarded.
|
||||
/// is unnecessary (descrambling is keyless — the key is recovered directly
|
||||
/// from the data, so no device key is derived) and its hard failure on some
|
||||
/// USB bridges used to abort the whole unlock (the 7014 bug). The bytes are
|
||||
/// discarded.
|
||||
pub fn unlock_css_reads(scsi: &mut dyn ScsiTransport, lba: u32) -> Result<()> {
|
||||
let t0 = std::time::Instant::now();
|
||||
tracing::info!(target: "freemkv::css", phase = "unlock_css_reads", lba, "begin");
|
||||
@@ -144,8 +179,8 @@ pub fn unlock_css_reads(scsi: &mut dyn ScsiTransport, lba: u32) -> Result<()> {
|
||||
/// The DVD unlocker (registry name `"DVD"`) — the DVD peer of the firmware and
|
||||
/// AACS-cert unlockers in the uniform [`crate::Unlocker`] registry. It removes
|
||||
/// the DVD scrambled-read barrier (drive ASF=1) via bus-auth and learns no VID
|
||||
/// or bus key — the descramble key is recovered keylessly downstream (the
|
||||
/// Stevenson attack). Named for the medium it unlocks (DVD), not the CSS
|
||||
/// or bus key — descrambling is keyless: the key is recovered directly from
|
||||
/// the data downstream. Named for the medium it unlocks (DVD), not the CSS
|
||||
/// scheme: the bus-auth is required to read a CSS-protected DVD at all, whether
|
||||
/// or not any given sector turns out to be scrambled. Lives in the `css` module
|
||||
/// beside the CSS-scheme primitives it drives.
|
||||
@@ -165,48 +200,50 @@ impl Default for DvdUnlocker {
|
||||
|
||||
impl crate::Unlocker for DvdUnlocker {
|
||||
fn name(&self) -> &'static str {
|
||||
// User-facing unlocker label. This unlocker's job is the DVD
|
||||
// read-enablement bus-auth (it clears the drive's scrambled-read
|
||||
// barrier and learns no key) — a property of the DVD medium, NOT of
|
||||
// whether the content happens to be CSS-scrambled. Reporting it as
|
||||
// "DVD" is honest: on any DVD the bus-auth ran; the CSS descramble
|
||||
// itself is keyless and handled downstream, so it is not a separate
|
||||
// "did an unlocker run" signal.
|
||||
// "DVD" names the medium this bus-auth unlocks, not the CSS scheme:
|
||||
// the bus-auth runs on any DVD regardless of whether the content is
|
||||
// actually scrambled.
|
||||
"DVD"
|
||||
}
|
||||
|
||||
/// CSS removes the scrambled-sector barrier (a bus-level concern); it
|
||||
/// provides no drive features. Self-guards against the hardware (below), so
|
||||
/// it declines cleanly when the consumer iterates it on a non-DVD.
|
||||
fn unlock_bus(
|
||||
/// CSS removes the scrambled-sector barrier via bus-auth. Self-guards
|
||||
/// against the hardware (below), so it declines cleanly (`Ok(None)`) when the
|
||||
/// consumer iterates it on a non-DVD. `Some` once the bus-auth runs;
|
||||
/// `Err(Transport)` on a dead bus. Yields no VID and no bus key (descrambling
|
||||
/// is keyless — the returned `Unlocked` is empty).
|
||||
fn unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
_ctx: &crate::UnlockCtx,
|
||||
) -> std::result::Result<crate::Unlocked, crate::UnlockError> {
|
||||
// Self-guard against the hardware — do NOT trust the caller-declared
|
||||
// DiscKind alone. If the drive does not report a DVD profile, refuse
|
||||
// (NotApplicable) WITHOUT issuing any CSS CDB, so a mis-routed
|
||||
// Blu-ray/UHD is never sent CSS bus-auth.
|
||||
if !mounted_disc_is_dvd(scsi) {
|
||||
) -> std::result::Result<Option<crate::Unlocked>, crate::UnlockError> {
|
||||
// Self-guard against the hardware, not just the caller-declared
|
||||
// DiscKind: decline without issuing any CSS CDB when the drive doesn't
|
||||
// report a DVD profile.
|
||||
if !mounted_disc_is_dvd(scsi)? {
|
||||
tracing::debug!(
|
||||
target: "freemkv::css",
|
||||
phase = "dvd_unlocker_not_dvd",
|
||||
"DvdUnlocker invoked on a non-DVD profile; refusing (NotApplicable)"
|
||||
"DvdUnlocker invoked on a non-DVD profile; declining"
|
||||
);
|
||||
return Err(crate::UnlockError::NotApplicable);
|
||||
return Ok(None);
|
||||
}
|
||||
// The bus-auth handshake is what unlocks scrambled-sector reads; the lba
|
||||
// is not consumed by the unlock primitive (the disc-key REPORT KEY is
|
||||
// best-effort). CSS yields neither a Volume ID nor an AACS bus key.
|
||||
unlock_css_reads(scsi, 0)?;
|
||||
Ok(crate::Unlocked::default())
|
||||
// Bus-auth unlocks scrambled-sector reads (the lba isn't consumed here;
|
||||
// the disc-key REPORT KEY is best-effort). A refused handshake falls
|
||||
// through (`fallthrough` → `Ok(None)`); only a dead bus is an `Err`.
|
||||
crate::fallthrough(
|
||||
unlock_css_reads(scsi, 0)
|
||||
.map(|()| crate::Unlocked::default())
|
||||
.map_err(crate::UnlockError::from),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport-level "is the mounted disc a DVD?" probe (GET CONFIGURATION
|
||||
/// current-profile, DVD family `0x0010..=0x001F`). Lets the DvdUnlocker
|
||||
/// self-verify against the drive instead of trusting the caller's DiscKind.
|
||||
fn mounted_disc_is_dvd(scsi: &mut dyn ScsiTransport) -> bool {
|
||||
// Probes GET CONFIGURATION current-profile (DVD family 0x0010..=0x001F) so
|
||||
// DvdUnlocker can self-verify against the drive rather than trust the
|
||||
// caller's DiscKind.
|
||||
fn mounted_disc_is_dvd(
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
) -> std::result::Result<bool, crate::UnlockError> {
|
||||
// RT=0: the 8-byte feature header carries the Current Profile in bytes 6-7.
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_GET_CONFIGURATION,
|
||||
@@ -221,35 +258,48 @@ fn mounted_disc_is_dvd(scsi: &mut dyn ScsiTransport) -> bool {
|
||||
0x00,
|
||||
];
|
||||
let mut buf = [0u8; 8];
|
||||
match scsi.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
) {
|
||||
Ok(r) if r.bytes_transferred >= 8 => {
|
||||
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
Ok(r) if r.status == 0 && r.bytes_transferred >= 8 => {
|
||||
let profile = ((buf[6] as u16) << 8) | buf[7] as u16;
|
||||
(0x0010..=0x001F).contains(&profile)
|
||||
Ok((0x0010..=0x001F).contains(&profile))
|
||||
}
|
||||
// A drive sense or a short reply genuinely means "can't tell / not a
|
||||
// DVD" — decline, but say so.
|
||||
Ok(r) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::css",
|
||||
phase = "dvd_profile_probe_inconclusive",
|
||||
status = r.status,
|
||||
bytes_transferred = r.bytes_transferred,
|
||||
"GET CONFIGURATION did not report a current profile"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
let err = crate::css::error::step_err(e);
|
||||
tracing::warn!(
|
||||
target: "freemkv::css",
|
||||
phase = "dvd_profile_probe_failed",
|
||||
error_code = err.code(),
|
||||
transport_failure = err.is_transport_failure(),
|
||||
"GET CONFIGURATION failed while probing for a DVD"
|
||||
);
|
||||
Err(crate::UnlockError::from(err))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn unlock_css_reads_inner(scsi: &mut dyn ScsiTransport, _lba: u32) -> Result<()> {
|
||||
tracing::debug!(target: "freemkv::css", "css unlock: begin");
|
||||
// The bus-auth challenge-response sets the drive's Authentication Success
|
||||
// Flag (ASF=1), which is what opens scrambled-sector reads. This is the
|
||||
// ONLY step required to unlock reads; a failure here is fatal — we
|
||||
// genuinely cannot read scrambled sectors.
|
||||
// The bus-auth challenge-response sets ASF=1, which is what opens
|
||||
// scrambled-sector reads. It's the only required step, so a failure
|
||||
// here is fatal.
|
||||
let agid = establish_authenticated_session(scsi).inspect_err(|e| {
|
||||
tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: bus authentication failed");
|
||||
})?;
|
||||
tracing::debug!(target: "freemkv::css", agid, "css unlock: bus authentication ok");
|
||||
// Disc-key REPORT KEY: issued BEST-EFFORT for any firmware that ties part
|
||||
// of its read-unlock to it. The bytes are unused (the descramble key is
|
||||
// recovered keylessly) and a failure is NON-FATAL — the gate is already
|
||||
// open from bus-auth. This replaces the title-key REPORT KEY, whose hard
|
||||
// failure used to abort the whole unlock (the 7014 bug on USB bridges).
|
||||
// Disc-key REPORT KEY: best-effort only, for firmware that ties part of
|
||||
// its read-unlock to it; bytes are unused and failure is non-fatal.
|
||||
if let Err(e) = read_disc_key(scsi, agid) {
|
||||
tracing::debug!(target: "freemkv::css", error_code = e.code(), "css unlock: disc-key REPORT KEY skipped (non-fatal)");
|
||||
}
|
||||
@@ -259,48 +309,66 @@ fn unlock_css_reads_inner(scsi: &mut dyn ScsiTransport, _lba: u32) -> Result<()>
|
||||
|
||||
// ── Step 1: Bus Authentication ────────────────────────────────────────────
|
||||
|
||||
/// Run the CSS bus-authentication challenge-response (invalidate AGIDs →
|
||||
/// allocate AGID → host challenge → brute-force the variant → drive challenge →
|
||||
/// send host key). Completing the handshake sets the drive's Authentication
|
||||
/// Success Flag (ASF=1) — which is the ENTIRE purpose: it unlocks
|
||||
/// scrambled-sector reads. Returns the negotiated AGID (the caller needs it for
|
||||
/// the best-effort disc-key REPORT KEY). The CSS bus key is intentionally NOT
|
||||
/// derived: descrambling is keyless (the Stevenson known-plaintext attack), so
|
||||
/// the bus key has no consumer.
|
||||
// Runs the CSS bus-auth handshake to set ASF=1 (invalidate AGIDs, allocate,
|
||||
// challenge/response). Returns the AGID, still held (D9); no bus key is derived.
|
||||
fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result<u8> {
|
||||
// Invalidate all AGIDs via REPORT KEY format 0x3F
|
||||
// Invalidate all AGIDs via REPORT KEY format 0x3F. This is also what makes
|
||||
// an abandoned AGID self-heal — which is not a reason to abandon one. Plain
|
||||
// `execute`: nothing is held yet, so a Stop refuses these (stop-design-v5 §2.3).
|
||||
for agid in 0..4u8 {
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
|
||||
// alloc_len = 0 (no data transfer)
|
||||
cdb[10] = (agid << 6) | 0x3F;
|
||||
let mut buf = [0u8; 8];
|
||||
let _ = scsi.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
);
|
||||
invalidate_agid(scsi, agid);
|
||||
}
|
||||
|
||||
// Allocate AGID
|
||||
let mut buf = [0u8; 8];
|
||||
scsi.execute(
|
||||
css_scsi(
|
||||
scsi,
|
||||
&report_key_cdb(0, 0x00, 8),
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
)
|
||||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
8,
|
||||
)?;
|
||||
let agid = (buf[7] >> 6) & 0x03;
|
||||
|
||||
// From here on we hold the AGID: a failure drops the guard, which releases
|
||||
// it even after a Stop (SS-7, evidence: libaacs mmc.c AGID invalidation).
|
||||
let mut guard = AgidGuard::new(scsi, agid, agid_release_cdb(agid));
|
||||
authenticate_with_agid(&mut *guard, agid)?;
|
||||
// D9 (stop-design-v5 §2.3 "defused on success, so it is still held for
|
||||
// `read_disc_key`"): hand the AGID over unreleased.
|
||||
Ok(guard.defuse())
|
||||
}
|
||||
|
||||
/// REPORT KEY key format 0x3F for `agid` (invalidate / release it).
|
||||
fn agid_release_cdb(agid: u8) -> [u8; 12] {
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
|
||||
cdb[10] = (agid << 6) | 0x3F;
|
||||
cdb
|
||||
}
|
||||
|
||||
/// Best-effort invalidate of an AGID this session does not hold (the
|
||||
/// pre-allocation sweep).
|
||||
fn invalidate_agid(scsi: &mut dyn ScsiTransport, agid: u8) {
|
||||
let mut buf = [0u8; 8];
|
||||
let _ = scsi.execute(
|
||||
&agid_release_cdb(agid),
|
||||
DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
);
|
||||
}
|
||||
|
||||
/// The challenge-response half of [`establish_authenticated_session`], with the
|
||||
/// AGID already allocated and held by the caller's [`AgidGuard`].
|
||||
fn authenticate_with_agid(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> {
|
||||
// Host sends challenge. The spec wants a fresh per-session random nonce,
|
||||
// not a fixed constant — a predictable challenge weakens the bus-auth
|
||||
// handshake.
|
||||
let mut host_challenge = [0u8; 10];
|
||||
{
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut host_challenge);
|
||||
use rand::Rng;
|
||||
rand::rng().fill_bytes(&mut host_challenge);
|
||||
}
|
||||
let mut hc_buf = [0u8; 16];
|
||||
hc_buf[0] = 0x00;
|
||||
@@ -308,23 +376,23 @@ fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result<u8> {
|
||||
for i in 0..10 {
|
||||
hc_buf[4 + i] = host_challenge[9 - i];
|
||||
}
|
||||
scsi.execute(
|
||||
css_scsi(
|
||||
scsi,
|
||||
&send_key_cdb(agid, 0x01, 16),
|
||||
crate::scsi::DataDirection::ToDevice,
|
||||
DataDirection::ToDevice,
|
||||
&mut hc_buf,
|
||||
5_000,
|
||||
)
|
||||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
0,
|
||||
)?;
|
||||
|
||||
// Get Key1 from drive
|
||||
let mut dk_buf = [0u8; 12];
|
||||
scsi.execute(
|
||||
css_scsi(
|
||||
scsi,
|
||||
&report_key_cdb(agid, 0x02, 12),
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
DataDirection::FromDevice,
|
||||
&mut dk_buf,
|
||||
5_000,
|
||||
)
|
||||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
12,
|
||||
)?;
|
||||
let mut key1 = [0u8; 5];
|
||||
for i in 0..5 {
|
||||
key1[i] = dk_buf[4 + (4 - i)];
|
||||
@@ -342,13 +410,13 @@ fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result<u8> {
|
||||
|
||||
// Get drive challenge
|
||||
let mut dc_buf = [0u8; 16];
|
||||
scsi.execute(
|
||||
css_scsi(
|
||||
scsi,
|
||||
&report_key_cdb(agid, 0x01, 16),
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
DataDirection::FromDevice,
|
||||
&mut dc_buf,
|
||||
5_000,
|
||||
)
|
||||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
16,
|
||||
)?;
|
||||
let mut drive_challenge = [0u8; 10];
|
||||
for i in 0..10 {
|
||||
drive_challenge[i] = dc_buf[4 + (9 - i)];
|
||||
@@ -362,29 +430,24 @@ fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result<u8> {
|
||||
for i in 0..5 {
|
||||
hk_buf[4 + i] = key2[4 - i];
|
||||
}
|
||||
scsi.execute(
|
||||
css_scsi(
|
||||
scsi,
|
||||
&send_key_cdb(agid, 0x03, 12),
|
||||
crate::scsi::DataDirection::ToDevice,
|
||||
DataDirection::ToDevice,
|
||||
&mut hk_buf,
|
||||
5_000,
|
||||
)
|
||||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
0,
|
||||
)?;
|
||||
|
||||
// The authenticated session (ASF=1) is now established — scrambled-sector
|
||||
// reads are unlocked, which is the only thing we needed. The CSS bus key
|
||||
// would be CryptKey(2, variant, key1 || key2), but it has no consumer
|
||||
// (descrambling is keyless via the Stevenson attack), so it is not derived.
|
||||
Ok(agid)
|
||||
// reads are unlocked. The CSS bus key would be CryptKey(2, variant,
|
||||
// key1 || key2), but has no consumer (descrambling is keyless).
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Step 2: Disc Key ──────────────────────────────────────────────────────
|
||||
|
||||
/// Issue READ DVD STRUCTURE format 0x02 (Copyright Information — opcode 0xAD,
|
||||
/// NOT the REPORT KEY 0xA4 disc-key block) purely for the bus-auth unlock side
|
||||
/// effect. The returned block contents are not used — the descramble title key
|
||||
/// is recovered keylessly elsewhere, so the genuine disc-key REPORT KEY is
|
||||
/// intentionally skipped. (If a drive is ever found where bus-auth alone does
|
||||
/// not open scrambled reads, a real REPORT KEY format 0x02 belongs here.)
|
||||
// Issues READ DVD STRUCTURE format 0x02 (opcode 0xAD) purely for its
|
||||
// bus-auth side effect; returned bytes are unused.
|
||||
fn read_disc_key(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> {
|
||||
// READ DVD STRUCTURE, format 0x02 (disc key), 2048+4 bytes
|
||||
let alloc_len: u16 = 2048 + 4;
|
||||
@@ -397,14 +460,10 @@ fn read_disc_key(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> {
|
||||
cdb[9] = alloc_len as u8;
|
||||
cdb[10] = agid << 6;
|
||||
|
||||
// Best-effort by design (the caller logs and continues), but a transport
|
||||
// fault still has to be distinguishable from a drive that declined.
|
||||
let mut buf = vec![0u8; alloc_len as usize];
|
||||
let dvd_result = scsi.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
);
|
||||
dvd_result.map_err(|_| Error::CssAuthFailed)?;
|
||||
css_scsi(scsi, &cdb, DataDirection::FromDevice, &mut buf, 0)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -412,13 +471,11 @@ fn read_disc_key(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> {
|
||||
// ── CSSCryptKey ───────────────────────────────────────────────────────────
|
||||
|
||||
fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
|
||||
// key_type indexes PERM_CHALLENGE ([_;3]); variant indexes
|
||||
// VARIANTS/PERM_VARIANT ([_;32]). All internal callers pass key_type in
|
||||
// 0..3 and variant in 0..32; the asserts document the contract for the
|
||||
// pub(crate) test entry point test_crypt_key and turn a would-be
|
||||
// out-of-bounds panic into an explicit precondition violation.
|
||||
debug_assert!(key_type < 3, "crypt_key: key_type out of range");
|
||||
debug_assert!((variant as usize) < 32, "crypt_key: variant out of range");
|
||||
// key_type indexes PERM_CHALLENGE ([_;3]); variant indexes VARIANTS/
|
||||
// PERM_VARIANT ([_;32]). Real (not debug-only) asserts catch an OOB index even
|
||||
// in release; cost is nil on this once-per-disc path.
|
||||
assert!(key_type < 3, "crypt_key: key_type out of range");
|
||||
assert!((variant as usize) < 32, "crypt_key: variant out of range");
|
||||
let perm = &PERM_CHALLENGE[key_type];
|
||||
let mut scratch = [0u8; 10];
|
||||
for i in 0..10 {
|
||||
@@ -567,463 +624,5 @@ fn send_key_cdb(agid: u8, format: u8, param_len: u16) -> [u8; 12] {
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// SECURITY REGRESSION GUARD: no instrumentation in libfreemkv may emit
|
||||
/// raw key material. Scan every source file for a `tracing` field that
|
||||
/// binds a forbidden key name to a value-producing expression (`= expr`
|
||||
/// or `%expr` / `?expr`). The only allowed forms are a string literal
|
||||
/// (e.g. `disc_key = "<redacted>"`) or a `_fp` fingerprint field.
|
||||
///
|
||||
/// This is a source-scan test (not a runtime capture) so it stays cheap
|
||||
/// and catches re-introductions at compile/CI time.
|
||||
#[test]
|
||||
fn no_key_bytes_in_instrumentation() {
|
||||
use std::path::Path;
|
||||
|
||||
// Forbidden field names whose VALUES must never be logged.
|
||||
const FORBIDDEN: &[&str] = &[
|
||||
"title_key",
|
||||
"disc_key",
|
||||
"unit_key",
|
||||
"vuk",
|
||||
"player_key",
|
||||
"bus_key",
|
||||
];
|
||||
|
||||
fn scan_dir(dir: &Path, forbidden: &[&str], violations: &mut Vec<String>) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
scan_dir(&path, forbidden, violations);
|
||||
continue;
|
||||
}
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
|
||||
continue;
|
||||
}
|
||||
let src = match std::fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for (lineno, line) in src.lines().enumerate() {
|
||||
let trimmed = line.trim_start();
|
||||
// Only inspect tracing instrumentation lines.
|
||||
if !(trimmed.contains("tracing::")
|
||||
|| trimmed.starts_with("debug!")
|
||||
|| trimmed.starts_with("info!")
|
||||
|| trimmed.starts_with("warn!")
|
||||
|| trimmed.starts_with("trace!")
|
||||
|| trimmed.starts_with("error!"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// This guard test itself contains the forbidden names.
|
||||
if path.file_name().and_then(|n| n.to_str()) == Some("auth.rs")
|
||||
&& line.contains("FORBIDDEN")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for &name in forbidden {
|
||||
// A fingerprint field (`<name>_fp = ...`) is allowed.
|
||||
// Match `<name>` followed by optional fingerprint
|
||||
// suffix then `=` and a value that is NOT a string
|
||||
// literal redaction marker.
|
||||
if let Some(idx) = line.find(name) {
|
||||
let after = &line[idx + name.len()..];
|
||||
let after = after.trim_start();
|
||||
// `<name>_fp` / `<name>_id` etc. are safe.
|
||||
if after.starts_with('_') {
|
||||
continue;
|
||||
}
|
||||
// Must be a field binding `name = ...`.
|
||||
let Some(rest) = after.strip_prefix('=') else {
|
||||
continue;
|
||||
};
|
||||
let rest = rest.trim_start();
|
||||
// Redaction string literal is the only allowed value.
|
||||
if rest.starts_with('"') {
|
||||
continue;
|
||||
}
|
||||
// Anything else (`%expr`, `?expr`, bare expr) leaks bytes.
|
||||
violations.push(format!(
|
||||
"{}:{}: forbidden key field `{}` logged with a value: {}",
|
||||
path.display(),
|
||||
lineno + 1,
|
||||
name,
|
||||
line.trim()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan this crate's `src` plus the sibling workspace crates so the
|
||||
// key-material logging guard covers every crate that can reach the
|
||||
// CSS/AACS internals, not just libfreemkv. Missing sibling dirs (e.g.
|
||||
// when building the crate standalone) are simply skipped.
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace = manifest.parent().unwrap_or(manifest);
|
||||
let mut violations = Vec::new();
|
||||
scan_dir(&manifest.join("src"), FORBIDDEN, &mut violations);
|
||||
for sibling in ["autorip", "freemkv", "freemkv-keysources"] {
|
||||
let dir = workspace.join(sibling).join("src");
|
||||
if dir.is_dir() {
|
||||
scan_dir(&dir, FORBIDDEN, &mut violations);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"key material logged in instrumentation:\n{}",
|
||||
violations.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_is_deterministic() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
for v in 0..32u8 {
|
||||
let r1 = crypt_key(0, v, &challenge);
|
||||
let r2 = crypt_key(0, v, &challenge);
|
||||
assert_eq!(r1, r2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_varies_by_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_varies_by_type() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
assert_ne!(crypt_key(0, 5, &challenge), crypt_key(1, 5, &challenge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_nonzero() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
for v in 0..32u8 {
|
||||
assert_ne!(crypt_key(0, v, &challenge), [0u8; 5]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── CSS constant-table integrity ───────────────────────────────────────
|
||||
|
||||
/// Each PERM_CHALLENGE row is a permutation of indices 0..10 (it reorders
|
||||
/// the 10 challenge bytes). A non-permutation would drop/duplicate
|
||||
/// challenge bytes, weakening or corrupting the bus key derivation.
|
||||
///
|
||||
/// Grounding: crypt_key does `scratch[i] = challenge[perm[i]]` for i in
|
||||
/// 0..10 — perm must be a bijection on 0..10 to use every challenge byte
|
||||
/// exactly once.
|
||||
/// Mutation: change PERM_CHALLENGE[0] entry `9` to `8` (duplicate) -> the
|
||||
/// "covers 0..10" assert fires.
|
||||
#[test]
|
||||
fn perm_challenge_rows_are_permutations() {
|
||||
for (row, perm) in PERM_CHALLENGE.iter().enumerate() {
|
||||
let mut seen = [false; 10];
|
||||
for &idx in perm.iter() {
|
||||
assert!(idx < 10, "PERM_CHALLENGE[{row}] index {idx} out of range");
|
||||
assert!(!seen[idx], "PERM_CHALLENGE[{row}] duplicates index {idx}");
|
||||
seen[idx] = true;
|
||||
}
|
||||
assert!(
|
||||
seen.iter().all(|&b| b),
|
||||
"PERM_CHALLENGE[{row}] misses an index"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each PERM_VARIANT row maps the 32 variants to 32 distinct 5-bit values
|
||||
/// (it is a permutation of 0..32). key_type 1 uses PERM_VARIANT[0],
|
||||
/// key_type 2 uses PERM_VARIANT[1] to pick the css_variant; a collision
|
||||
/// would make two variants indistinguishable.
|
||||
///
|
||||
/// Grounding: `css_variant = PERM_VARIANT[k][variant]` then indexes
|
||||
/// VARIANTS[css_variant] (0..32).
|
||||
/// Mutation: set PERM_VARIANT[0][1] = PERM_VARIANT[0][0] -> duplicate
|
||||
/// assert fires; also any value >= 32 would later index VARIANTS OOB.
|
||||
#[test]
|
||||
fn perm_variant_rows_are_permutations_of_0_31() {
|
||||
for (row, perm) in PERM_VARIANT.iter().enumerate() {
|
||||
let mut seen = [false; 32];
|
||||
for &v in perm.iter() {
|
||||
let v = v as usize;
|
||||
assert!(v < 32, "PERM_VARIANT[{row}] value {v} out of 0..32");
|
||||
assert!(!seen[v], "PERM_VARIANT[{row}] duplicates {v}");
|
||||
seen[v] = true;
|
||||
}
|
||||
assert!(
|
||||
seen.iter().all(|&b| b),
|
||||
"PERM_VARIANT[{row}] misses a value"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── crypt_key behaviour ────────────────────────────────────────────────
|
||||
|
||||
/// crypt_key result depends on every challenge byte. The challenge is
|
||||
/// permuted into `scratch` and folded through the LFSR seeding and the 6
|
||||
/// XOR rounds. Flipping any single challenge byte must change the output.
|
||||
///
|
||||
/// Grounding: scratch[i]=challenge[perm[i]] for all 10 i, and scratch
|
||||
/// seeds both LFSRs (bytes 5..10 via tmp1) and the round terms (bytes
|
||||
/// 0..5).
|
||||
/// Mutation: in `scratch[i] = challenge[perm[i]]` replace with
|
||||
/// `challenge[i]` for a perm that drops a byte — or hardcode one scratch
|
||||
/// entry — and some challenge byte stops mattering; this fails.
|
||||
#[test]
|
||||
fn crypt_key_depends_on_every_challenge_byte() {
|
||||
let base: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let base_out = crypt_key(0, 5, &base);
|
||||
for i in 0..10 {
|
||||
let mut c = base;
|
||||
c[i] ^= 0x55;
|
||||
assert_ne!(
|
||||
crypt_key(0, 5, &c),
|
||||
base_out,
|
||||
"flipping challenge byte {i} did not change the bus-key derivation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// crypt_key(0, v, ..) must produce a DISTINCT result for each of the 32
|
||||
/// variants on a fixed challenge. bus_auth brute-forces the variant by
|
||||
/// matching crypt_key(0, v, host_challenge) == key1; if two variants
|
||||
/// collided, the wrong variant could be selected and the whole auth
|
||||
/// derail.
|
||||
///
|
||||
/// Grounding: variant selects css_variant -> VARIANTS[css_variant] -> cse,
|
||||
/// which feeds every round; distinct variants give distinct cse-driven
|
||||
/// keys in practice.
|
||||
/// Mutation: make `cse` ignore the variant (e.g. `let cse = 0`) -> all 32
|
||||
/// outputs collapse to one value; the distinctness assert fires.
|
||||
#[test]
|
||||
fn crypt_key_type0_distinct_per_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let mut outs = Vec::new();
|
||||
for v in 0..32u8 {
|
||||
let k = crypt_key(0, v, &challenge);
|
||||
assert!(
|
||||
!outs.contains(&k),
|
||||
"variant {v} collides with an earlier variant"
|
||||
);
|
||||
outs.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
/// crypt_key enforces its documented precondition `key_type < 3` via
|
||||
/// debug_assert (active in test builds). A key_type of 3 would index
|
||||
/// PERM_CHALLENGE (len 3) out of bounds; the assert turns that into an
|
||||
/// explicit precondition panic.
|
||||
///
|
||||
/// Grounding: `debug_assert!(key_type < 3, ...)`; PERM_CHALLENGE has 3
|
||||
/// rows (indices 0,1,2).
|
||||
/// Mutation: delete the debug_assert AND the match-arm guard — but the
|
||||
/// match `_ =>` arm would then index PERM_CHALLENGE[3] OOB and panic
|
||||
/// differently; with the assert in place this test pins the contract.
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn crypt_key_rejects_out_of_range_key_type() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let _ = crypt_key(3, 0, &challenge);
|
||||
}
|
||||
|
||||
/// crypt_key enforces `variant < 32` via debug_assert. A variant of 32
|
||||
/// would index VARIANTS / PERM_VARIANT (len 32) out of bounds.
|
||||
///
|
||||
/// Grounding: `debug_assert!((variant as usize) < 32, ...)`.
|
||||
/// Mutation: removing the assert makes this index VARIANTS[32] (still a
|
||||
/// panic, but unguarded); the assert documents/enforces the contract.
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn crypt_key_rejects_out_of_range_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let _ = crypt_key(0, 32, &challenge);
|
||||
}
|
||||
|
||||
// ── SCSI CDB builders (MMC REPORT KEY / SEND KEY layout) ───────────────
|
||||
|
||||
/// report_key_cdb encodes a 12-byte MMC REPORT KEY (opcode 0xA4) CDB:
|
||||
/// byte 0 = operation code 0xA4
|
||||
/// bytes 8-9 = allocation length, big-endian
|
||||
/// byte 10 = (AGID << 6) | (key_format & 0x3F)
|
||||
/// All other bytes are zero.
|
||||
///
|
||||
/// Grounding: MMC REPORT KEY CDB; the AGID is the top 2 bits of byte 10,
|
||||
/// key format the low 6 bits.
|
||||
/// Mutation: change `(alloc_len >> 8)` to `alloc_len` for byte 8 (lose the
|
||||
/// big-endian split) -> byte 8/9 assert fails. Change `agid << 6` to
|
||||
/// `agid << 5` -> the AGID-position assert fails.
|
||||
#[test]
|
||||
fn report_key_cdb_matches_mmc_layout() {
|
||||
let cdb = report_key_cdb(0b10, 0x04, 0x010C); // AGID=2, format=0x04, len=268
|
||||
assert_eq!(cdb[0], 0xA4, "REPORT KEY opcode");
|
||||
assert_eq!(cdb[8], 0x01, "alloc_len high byte (big-endian)");
|
||||
assert_eq!(cdb[9], 0x0C, "alloc_len low byte");
|
||||
assert_eq!(
|
||||
cdb[10],
|
||||
(0b10 << 6) | 0x04,
|
||||
"AGID in bits 6-7, format in bits 0-5"
|
||||
);
|
||||
// Every other byte must be zero.
|
||||
for (i, &b) in cdb.iter().enumerate() {
|
||||
if ![0, 8, 9, 10].contains(&i) {
|
||||
assert_eq!(b, 0, "CDB byte {i} must be zero");
|
||||
}
|
||||
}
|
||||
assert_eq!(cdb.len(), 12, "REPORT KEY is a 12-byte CDB");
|
||||
}
|
||||
|
||||
/// The key format field is masked to 6 bits: a format with high bits set
|
||||
/// must not corrupt the AGID. report_key_cdb(0, 0xFF, _) -> byte 10 low 6
|
||||
/// bits = 0x3F, AGID = 0.
|
||||
///
|
||||
/// Grounding: `(agid << 6) | (format & 0x3F)`.
|
||||
/// Mutation: drop the `& 0x3F` mask -> 0xFF would overwrite the AGID bits;
|
||||
/// byte 10 would be 0xFF not 0x3F, this fails.
|
||||
#[test]
|
||||
fn report_key_cdb_masks_format_to_6_bits() {
|
||||
let cdb = report_key_cdb(0, 0xFF, 8);
|
||||
assert_eq!(cdb[10], 0x3F, "format masked to 6 bits, AGID stays 0");
|
||||
}
|
||||
|
||||
/// send_key_cdb encodes a 12-byte MMC SEND KEY (opcode 0xA3) CDB with the
|
||||
/// parameter-list length at bytes 8-9 (big-endian) and AGID/format at byte
|
||||
/// 10.
|
||||
///
|
||||
/// Grounding: MMC SEND KEY CDB layout.
|
||||
/// Mutation: change opcode to SCSI_REPORT_KEY -> opcode assert fails;
|
||||
/// swap bytes 8/9 -> length assert fails.
|
||||
#[test]
|
||||
fn send_key_cdb_matches_mmc_layout() {
|
||||
let cdb = send_key_cdb(0b11, 0x03, 0x000C); // AGID=3, format=3, param_len=12
|
||||
assert_eq!(cdb[0], 0xA3, "SEND KEY opcode");
|
||||
assert_eq!(cdb[8], 0x00, "param_len high byte");
|
||||
assert_eq!(cdb[9], 0x0C, "param_len low byte");
|
||||
assert_eq!(
|
||||
cdb[10],
|
||||
(0b11 << 6) | 0x03,
|
||||
"AGID bits 6-7, format bits 0-5"
|
||||
);
|
||||
assert_eq!(cdb.len(), 12);
|
||||
}
|
||||
|
||||
/// Allocation length larger than 255 must split across bytes 8 (high) and
|
||||
/// 9 (low) — a 16-bit big-endian field. report_key_cdb with alloc_len
|
||||
/// 0x0804 (2052, the disc-key block size used in read_disc_key) -> byte 8
|
||||
/// = 0x08, byte 9 = 0x04.
|
||||
///
|
||||
/// Grounding: read_disc_key uses `alloc_len = 2048 + 4 = 2052 = 0x0804`
|
||||
/// and writes `cdb[8] = (alloc_len >> 8); cdb[9] = alloc_len`.
|
||||
/// Mutation: write only byte 9 (`cdb[9] = alloc_len as u8`) without byte 8
|
||||
/// -> the drive sees a 4-byte transfer, truncating the disc-key block;
|
||||
/// this asserts the high byte is present.
|
||||
#[test]
|
||||
fn report_key_cdb_alloc_len_is_16bit_big_endian() {
|
||||
let cdb = report_key_cdb(0, 0x00, 0x0804);
|
||||
assert_eq!(cdb[8], 0x08, "high byte of 2052-byte transfer");
|
||||
assert_eq!(cdb[9], 0x04, "low byte of 2052-byte transfer");
|
||||
}
|
||||
|
||||
/// The unlocker's user-facing name is "DVD" (the medium it read-unlocks),
|
||||
/// not "CSS" (the scheme). Apps render the unlocker report from this name,
|
||||
/// so it is a stable contract — the bus-auth ran on any DVD, encrypted or
|
||||
/// not, and the report must say so rather than conflate it with a CSS crack.
|
||||
#[test]
|
||||
fn dvd_unlocker_is_named_dvd() {
|
||||
use crate::Unlocker;
|
||||
assert_eq!(DvdUnlocker::new().name(), "DVD");
|
||||
}
|
||||
|
||||
/// DvdUnlocker provides bus removal only — it never provides drive features.
|
||||
#[test]
|
||||
fn dvd_unlocker_provides_no_features() {
|
||||
use crate::scsi::{DataDirection, ScsiResult};
|
||||
use crate::{DiscKind, DriveId, UnlockCtx, UnlockError, Unlocker};
|
||||
struct DeadTransport;
|
||||
impl ScsiTransport for DeadTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
panic!("unlock_features must not touch the transport");
|
||||
}
|
||||
}
|
||||
let id = DriveId::default();
|
||||
let mut t = DeadTransport;
|
||||
let r =
|
||||
DvdUnlocker::new().unlock_features(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[]));
|
||||
assert_eq!(r.unwrap_err(), UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
/// Defense in depth: even when the caller declares `DiscKind::Css`, the
|
||||
/// DvdUnlocker self-verifies against the drive's GET CONFIGURATION profile.
|
||||
/// A drive reporting a Blu-ray profile → `NotApplicable`, and NOT a single
|
||||
/// CSS CDB is issued (no bus-auth fired at a BD).
|
||||
#[test]
|
||||
fn dvd_unlocker_self_guards_against_non_dvd() {
|
||||
use crate::scsi::{DataDirection, ScsiResult};
|
||||
use crate::{DiscKind, DriveId, UnlockCtx, UnlockError, Unlocker};
|
||||
|
||||
/// Reports a BD-ROM profile (0x0040) to GET CONFIGURATION and counts any
|
||||
/// other CDB (i.e. CSS bus-auth activity).
|
||||
struct BdTransport {
|
||||
non_config_cdbs: usize,
|
||||
}
|
||||
impl ScsiTransport for BdTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
if cdb[0] == crate::scsi::SCSI_GET_CONFIGURATION {
|
||||
if data.len() >= 8 {
|
||||
data[6] = 0x00;
|
||||
data[7] = 0x40; // BD-ROM current profile
|
||||
}
|
||||
return Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 8,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
self.non_config_cdbs += 1;
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let id = DriveId {
|
||||
vendor_id: "FAKEVNDR".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut t = BdTransport { non_config_cdbs: 0 };
|
||||
let r = DvdUnlocker::new().unlock_bus(&mut t, &UnlockCtx::new(&id, DiscKind::Css, &[]));
|
||||
assert_eq!(
|
||||
r.unwrap_err(),
|
||||
UnlockError::NotApplicable,
|
||||
"a BD-profile drive must be refused"
|
||||
);
|
||||
assert_eq!(
|
||||
t.non_config_cdbs, 0,
|
||||
"no CSS CDB may be issued at a non-DVD drive"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,905 @@
|
||||
use super::*;
|
||||
|
||||
// SECURITY REGRESSION GUARD: scans source files for a `tracing` field
|
||||
// binding a forbidden key name to a value expression (only a string
|
||||
// literal or `_fp` field is allowed).
|
||||
#[test]
|
||||
fn no_key_bytes_in_instrumentation() {
|
||||
use std::path::Path;
|
||||
|
||||
// Forbidden field names whose VALUES must never be logged.
|
||||
const FORBIDDEN: &[&str] = &[
|
||||
"title_key",
|
||||
"disc_key",
|
||||
"unit_key",
|
||||
"vuk",
|
||||
"player_key",
|
||||
"bus_key",
|
||||
];
|
||||
|
||||
fn scan_dir(dir: &Path, forbidden: &[&str], violations: &mut Vec<String>) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
scan_dir(&path, forbidden, violations);
|
||||
continue;
|
||||
}
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
|
||||
continue;
|
||||
}
|
||||
let src = match std::fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for (lineno, line) in src.lines().enumerate() {
|
||||
let trimmed = line.trim_start();
|
||||
// Only inspect tracing instrumentation lines.
|
||||
if !(trimmed.contains("tracing::")
|
||||
|| trimmed.starts_with("debug!")
|
||||
|| trimmed.starts_with("info!")
|
||||
|| trimmed.starts_with("warn!")
|
||||
|| trimmed.starts_with("trace!")
|
||||
|| trimmed.starts_with("error!"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// This guard test itself contains the forbidden names.
|
||||
if path.file_name().and_then(|n| n.to_str()) == Some("auth.rs")
|
||||
&& line.contains("FORBIDDEN")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for &name in forbidden {
|
||||
// A fingerprint field (`<name>_fp = ...`) is allowed;
|
||||
// match `<name>` then `=` with a value that is not a
|
||||
// string-literal redaction marker.
|
||||
if let Some(idx) = line.find(name) {
|
||||
let after = &line[idx + name.len()..];
|
||||
let after = after.trim_start();
|
||||
// `<name>_fp` / `<name>_id` etc. are safe.
|
||||
if after.starts_with('_') {
|
||||
continue;
|
||||
}
|
||||
// Must be a field binding `name = ...`.
|
||||
let Some(rest) = after.strip_prefix('=') else {
|
||||
continue;
|
||||
};
|
||||
let rest = rest.trim_start();
|
||||
// Redaction string literal is the only allowed value.
|
||||
if rest.starts_with('"') {
|
||||
continue;
|
||||
}
|
||||
// Anything else (`%expr`, `?expr`, bare expr) leaks bytes.
|
||||
violations.push(format!(
|
||||
"{}:{}: forbidden key field `{}` logged with a value: {}",
|
||||
path.display(),
|
||||
lineno + 1,
|
||||
name,
|
||||
line.trim()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan this crate's `src` plus sibling workspace crates so the
|
||||
// guard covers everything that can reach CSS/AACS internals.
|
||||
// Missing sibling dirs (standalone builds) are simply skipped.
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let workspace = manifest.parent().unwrap_or(manifest);
|
||||
let mut violations = Vec::new();
|
||||
scan_dir(&manifest.join("src"), FORBIDDEN, &mut violations);
|
||||
for sibling in ["autorip", "freemkv", "freemkv-keysources"] {
|
||||
let dir = workspace.join(sibling).join("src");
|
||||
if dir.is_dir() {
|
||||
scan_dir(&dir, FORBIDDEN, &mut violations);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"key material logged in instrumentation:\n{}",
|
||||
violations.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_is_deterministic() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
for v in 0..32u8 {
|
||||
let r1 = crypt_key(0, v, &challenge);
|
||||
let r2 = crypt_key(0, v, &challenge);
|
||||
assert_eq!(r1, r2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_varies_by_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_varies_by_type() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
assert_ne!(crypt_key(0, 5, &challenge), crypt_key(1, 5, &challenge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_nonzero() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
for v in 0..32u8 {
|
||||
assert_ne!(crypt_key(0, v, &challenge), [0u8; 5]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── CSS constant-table integrity ───────────────────────────────────────
|
||||
|
||||
// Each PERM_CHALLENGE row must be a permutation of indices 0..10; a
|
||||
// non-permutation would drop/duplicate bytes.
|
||||
#[test]
|
||||
fn perm_challenge_rows_are_permutations() {
|
||||
for (row, perm) in PERM_CHALLENGE.iter().enumerate() {
|
||||
let mut seen = [false; 10];
|
||||
for &idx in perm.iter() {
|
||||
assert!(idx < 10, "PERM_CHALLENGE[{row}] index {idx} out of range");
|
||||
assert!(!seen[idx], "PERM_CHALLENGE[{row}] duplicates index {idx}");
|
||||
seen[idx] = true;
|
||||
}
|
||||
assert!(
|
||||
seen.iter().all(|&b| b),
|
||||
"PERM_CHALLENGE[{row}] misses an index"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Each PERM_VARIANT row must map the 32 variants to 32 distinct 5-bit
|
||||
// values; a collision would make two variants indistinguishable.
|
||||
#[test]
|
||||
fn perm_variant_rows_are_permutations_of_0_31() {
|
||||
for (row, perm) in PERM_VARIANT.iter().enumerate() {
|
||||
let mut seen = [false; 32];
|
||||
for &v in perm.iter() {
|
||||
let v = v as usize;
|
||||
assert!(v < 32, "PERM_VARIANT[{row}] value {v} out of 0..32");
|
||||
assert!(!seen[v], "PERM_VARIANT[{row}] duplicates {v}");
|
||||
seen[v] = true;
|
||||
}
|
||||
assert!(
|
||||
seen.iter().all(|&b| b),
|
||||
"PERM_VARIANT[{row}] misses a value"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── crypt_key behaviour ────────────────────────────────────────────────
|
||||
|
||||
// crypt_key's result must depend on every challenge byte: flipping any
|
||||
// single byte must change the output.
|
||||
#[test]
|
||||
fn crypt_key_depends_on_every_challenge_byte() {
|
||||
let base: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let base_out = crypt_key(0, 5, &base);
|
||||
for i in 0..10 {
|
||||
let mut c = base;
|
||||
c[i] ^= 0x55;
|
||||
assert_ne!(
|
||||
crypt_key(0, 5, &c),
|
||||
base_out,
|
||||
"flipping challenge byte {i} did not change the bus-key derivation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// crypt_key(0, v, ..) must be distinct for each of the 32 variants:
|
||||
// bus-auth brute-forces the variant by matching against key1, so a
|
||||
// collision could select the wrong one.
|
||||
#[test]
|
||||
fn crypt_key_type0_distinct_per_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let mut outs = Vec::new();
|
||||
for v in 0..32u8 {
|
||||
let k = crypt_key(0, v, &challenge);
|
||||
assert!(
|
||||
!outs.contains(&k),
|
||||
"variant {v} collides with an earlier variant"
|
||||
);
|
||||
outs.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
// crypt_key's `key_type < 3` assert! (not debug_assert!) must fire in
|
||||
// every profile; `expected` rejects the index-OOB panic that would follow.
|
||||
#[test]
|
||||
#[should_panic(expected = "crypt_key: key_type out of range")]
|
||||
fn crypt_key_rejects_out_of_range_key_type() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let _ = crypt_key(3, 0, &challenge);
|
||||
}
|
||||
|
||||
// crypt_key's `variant < 32` assert! must fire in every profile, not
|
||||
// the VARIANTS/PERM_VARIANT index-OOB panic that would follow it.
|
||||
#[test]
|
||||
#[should_panic(expected = "crypt_key: variant out of range")]
|
||||
fn crypt_key_rejects_out_of_range_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let _ = crypt_key(0, 32, &challenge);
|
||||
}
|
||||
|
||||
// ── SCSI CDB builders (MMC REPORT KEY / SEND KEY layout) ───────────────
|
||||
|
||||
// report_key_cdb encodes a 12-byte MMC REPORT KEY (opcode 0xA4) CDB:
|
||||
// byte 0=0xA4, bytes 8-9=big-endian len, byte 10=(AGID<<6)|(format&0x3F).
|
||||
#[test]
|
||||
fn report_key_cdb_matches_mmc_layout() {
|
||||
let cdb = report_key_cdb(0b10, 0x04, 0x010C); // AGID=2, format=0x04, len=268
|
||||
assert_eq!(cdb[0], 0xA4, "REPORT KEY opcode");
|
||||
assert_eq!(cdb[8], 0x01, "alloc_len high byte (big-endian)");
|
||||
assert_eq!(cdb[9], 0x0C, "alloc_len low byte");
|
||||
assert_eq!(
|
||||
cdb[10],
|
||||
(0b10 << 6) | 0x04,
|
||||
"AGID in bits 6-7, format in bits 0-5"
|
||||
);
|
||||
// Every other byte must be zero.
|
||||
for (i, &b) in cdb.iter().enumerate() {
|
||||
if ![0, 8, 9, 10].contains(&i) {
|
||||
assert_eq!(b, 0, "CDB byte {i} must be zero");
|
||||
}
|
||||
}
|
||||
assert_eq!(cdb.len(), 12, "REPORT KEY is a 12-byte CDB");
|
||||
}
|
||||
|
||||
// The key-format field is masked to 6 bits so a format with high bits
|
||||
// set (e.g. 0xFF) cannot corrupt the AGID bits of byte 10.
|
||||
#[test]
|
||||
fn report_key_cdb_masks_format_to_6_bits() {
|
||||
let cdb = report_key_cdb(0, 0xFF, 8);
|
||||
assert_eq!(cdb[10], 0x3F, "format masked to 6 bits, AGID stays 0");
|
||||
}
|
||||
|
||||
// send_key_cdb encodes a 12-byte MMC SEND KEY (opcode 0xA3) CDB with the
|
||||
// parameter-list length at bytes 8-9 (big-endian) and AGID/format at
|
||||
// byte 10.
|
||||
#[test]
|
||||
fn send_key_cdb_matches_mmc_layout() {
|
||||
let cdb = send_key_cdb(0b11, 0x03, 0x000C); // AGID=3, format=3, param_len=12
|
||||
assert_eq!(cdb[0], 0xA3, "SEND KEY opcode");
|
||||
assert_eq!(cdb[8], 0x00, "param_len high byte");
|
||||
assert_eq!(cdb[9], 0x0C, "param_len low byte");
|
||||
assert_eq!(
|
||||
cdb[10],
|
||||
(0b11 << 6) | 0x03,
|
||||
"AGID bits 6-7, format bits 0-5"
|
||||
);
|
||||
assert_eq!(cdb.len(), 12);
|
||||
}
|
||||
|
||||
// Allocation length > 255 must split across bytes 8 (high) and 9 (low)
|
||||
// as a 16-bit big-endian field, e.g. 0x0804 (the disc-key block size).
|
||||
#[test]
|
||||
fn report_key_cdb_alloc_len_is_16bit_big_endian() {
|
||||
let cdb = report_key_cdb(0, 0x00, 0x0804);
|
||||
assert_eq!(cdb[8], 0x08, "high byte of 2052-byte transfer");
|
||||
assert_eq!(cdb[9], 0x04, "low byte of 2052-byte transfer");
|
||||
}
|
||||
|
||||
// The unlocker's user-facing name is "DVD" (the medium), not "CSS" (the
|
||||
// scheme); apps render the unlocker report from this name, so it is a
|
||||
// stable contract.
|
||||
#[test]
|
||||
fn dvd_unlocker_is_named_dvd() {
|
||||
use crate::Unlocker;
|
||||
assert_eq!(DvdUnlocker::new().name(), "DVD");
|
||||
}
|
||||
|
||||
/// `Default` must delegate to `new()` — there is only one way to build a
|
||||
/// `DvdUnlocker` (it is a unit struct), so this pins the two never drift.
|
||||
#[test]
|
||||
#[allow(clippy::default_constructed_unit_structs)]
|
||||
fn default_matches_new() {
|
||||
let _ = DvdUnlocker::default();
|
||||
let _ = DvdUnlocker::new();
|
||||
}
|
||||
|
||||
/// DvdUnlocker provides bus removal only — it never provides drive features.
|
||||
// Defense in depth: even when the caller declares `DiscKind::Css`,
|
||||
// DvdUnlocker self-verifies against GET CONFIGURATION; a BD profile
|
||||
// must yield NotApplicable with no CSS CDB issued.
|
||||
#[test]
|
||||
fn dvd_unlocker_self_guards_against_non_dvd() {
|
||||
use crate::scsi::{DataDirection, ScsiResult};
|
||||
use crate::{DiscKind, DriveId, UnlockCtx, Unlocker};
|
||||
|
||||
/// Reports a BD-ROM profile (0x0040) to GET CONFIGURATION and counts any
|
||||
/// other CDB (i.e. CSS bus-auth activity).
|
||||
struct BdTransport {
|
||||
non_config_cdbs: usize,
|
||||
}
|
||||
impl ScsiTransport for BdTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
if cdb[0] == crate::scsi::SCSI_GET_CONFIGURATION {
|
||||
if data.len() >= 8 {
|
||||
data[6] = 0x00;
|
||||
data[7] = 0x40; // BD-ROM current profile
|
||||
}
|
||||
return Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 8,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
self.non_config_cdbs += 1;
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let id = DriveId {
|
||||
vendor_id: "FAKEVNDR".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut t = BdTransport { non_config_cdbs: 0 };
|
||||
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
|
||||
assert!(
|
||||
r.expect("BD profile declines, not a hard error").is_none(),
|
||||
"a BD-profile drive must be refused"
|
||||
);
|
||||
assert_eq!(
|
||||
t.non_config_cdbs, 0,
|
||||
"no CSS CDB may be issued at a non-DVD drive"
|
||||
);
|
||||
}
|
||||
// ── Transport-contract tests ────────────────────────────────────────────
|
||||
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
use crate::{DiscKind, DriveId, UnlockCtx, UnlockError, Unlocker};
|
||||
|
||||
// Defect-7 regression: a transport fault on the first probe command
|
||||
// must abort as Transport, not fall through to NotApplicable (which
|
||||
// let the consumer keep probing a dead bus).
|
||||
#[test]
|
||||
fn transport_fault_probing_for_a_dvd_aborts() {
|
||||
let id = DriveId::default();
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
|
||||
assert_eq!(r.unwrap_err(), UnlockError::Transport);
|
||||
assert_eq!(
|
||||
t.calls(),
|
||||
1,
|
||||
"must abort on the first command, not probe on"
|
||||
);
|
||||
}
|
||||
|
||||
/// A drive that REFUSES GET CONFIGURATION (CHECK CONDITION, delivered as
|
||||
/// `Ok` per the contract) is inconclusive, not a dead bus → decline. Guards
|
||||
/// against over-correcting defect 7 into "any probe failure aborts the rip".
|
||||
#[test]
|
||||
fn check_condition_probing_for_a_dvd_declines() {
|
||||
let id = DriveId::default();
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
|
||||
assert!(r.expect("inconclusive probe declines").is_none());
|
||||
}
|
||||
|
||||
// Defect-2 regression: a DVD is mounted, then the bus dies mid
|
||||
// bus-auth — must abort as Transport, not collapse to CssAuthFailed /
|
||||
// NotApplicable.
|
||||
#[test]
|
||||
fn transport_fault_during_bus_auth_aborts() {
|
||||
let id = DriveId::default();
|
||||
// GET CONFIGURATION reports a DVD-ROM profile (0x0010), then the bus dies.
|
||||
let mut config = vec![0u8; 8];
|
||||
config[6] = 0x00;
|
||||
config[7] = 0x10;
|
||||
let mut t = MockTransport::scripted(vec![Reply::good(config)], Reply::TransportFault);
|
||||
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
|
||||
assert_eq!(r.unwrap_err(), UnlockError::Transport);
|
||||
}
|
||||
|
||||
/// The same shape with the drive REFUSING the bus-auth commands: a CSS
|
||||
/// auth failure is a fall-through, not an abort.
|
||||
#[test]
|
||||
fn drive_refusing_bus_auth_is_not_applicable() {
|
||||
let id = DriveId::default();
|
||||
let mut config = vec![0u8; 8];
|
||||
config[7] = 0x10;
|
||||
let mut t = MockTransport::scripted(vec![Reply::good(config)], Reply::illegal_request());
|
||||
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
|
||||
assert!(r.expect("a refused bus-auth declines").is_none());
|
||||
}
|
||||
|
||||
// A CHECK CONDITION on AGID allocation must not let the handshake carry
|
||||
// on off the caller's own zero-filled buffer; catches a dropped
|
||||
// `status` check in `css_scsi`.
|
||||
#[test]
|
||||
fn agid_allocation_check_condition_fails_the_auth() {
|
||||
// 4 AGID invalidations are best-effort; the 5th command is the alloc.
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
],
|
||||
Reply::illegal_request(),
|
||||
);
|
||||
let e = establish_authenticated_session(&mut t).expect_err("refused alloc");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
}
|
||||
|
||||
// Defect 18: an AGID lost to a failed challenge must be RELEASED
|
||||
// (REPORT KEY format 0x3F), not abandoned, even though a drive's four
|
||||
// AGIDs self-heal on the next session's invalidation pass.
|
||||
#[test]
|
||||
fn a_failed_handshake_releases_the_agid_it_allocated() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(vec![0u8; 8]), // 4 × AGID invalidate
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]), // AGID allocated
|
||||
],
|
||||
Reply::illegal_request(), // every challenge step is refused
|
||||
);
|
||||
establish_authenticated_session(&mut t).expect_err("refused challenge");
|
||||
let last = t.cdbs.last().expect("commands were issued");
|
||||
assert_eq!(last[0], crate::scsi::SCSI_REPORT_KEY);
|
||||
assert_eq!(last[10] & 0x3F, 0x3F, "AGID released on the failure path");
|
||||
}
|
||||
|
||||
/// A short AGID-allocation response is equally unusable — the AGID would be
|
||||
/// read out of bytes the drive never sent.
|
||||
#[test]
|
||||
fn short_agid_allocation_fails_the_auth() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
Reply::good(vec![0u8; 8]),
|
||||
],
|
||||
Reply::short(vec![0u8; 8], 3),
|
||||
);
|
||||
let e = establish_authenticated_session(&mut t).expect_err("short alloc");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
}
|
||||
|
||||
// ── authenticate_with_agid step failures ────────────────────────────────
|
||||
|
||||
/// The drive refuses the host-challenge SEND KEY (step 1) — the very first
|
||||
/// command `authenticate_with_agid` issues.
|
||||
#[test]
|
||||
fn host_challenge_send_failure_fails_the_auth() {
|
||||
let mut t = MockTransport::scripted(vec![Reply::illegal_request()], Reply::illegal_request());
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("refused host challenge");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
assert_eq!(t.calls(), 1, "must not proceed past the first refused step");
|
||||
}
|
||||
|
||||
/// The host challenge SEND KEY succeeds but the Key1 REPORT KEY (step 2) is
|
||||
/// refused.
|
||||
#[test]
|
||||
fn key1_report_failure_fails_the_auth() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![Reply::good(vec![]), Reply::illegal_request()],
|
||||
Reply::illegal_request(),
|
||||
);
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("refused Key1 report");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
assert_eq!(t.calls(), 2);
|
||||
}
|
||||
|
||||
/// Both SCSI steps succeed but the drive's Key1 does not match any of the
|
||||
/// 32 CryptKey variants for the (randomly generated) host challenge — the
|
||||
/// brute-force loop exhausts and `variant.ok_or(CssAuthFailed)` fires.
|
||||
#[test]
|
||||
fn key1_matching_no_variant_fails_the_auth() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![Reply::good(vec![]), Reply::good(vec![0xABu8; 12])],
|
||||
Reply::illegal_request(),
|
||||
);
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("no variant matches");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
assert_eq!(
|
||||
t.calls(),
|
||||
2,
|
||||
"the brute-force loop issues no further SCSI commands"
|
||||
);
|
||||
}
|
||||
|
||||
// Host challenge + Key1 succeed but the drive-challenge REPORT KEY
|
||||
// (step 3) is refused; distinct from the step-2 and step-4 failure
|
||||
// tests, each pinning a different `css_scsi` call site's `?`.
|
||||
#[test]
|
||||
fn drive_challenge_report_failure_fails_the_auth() {
|
||||
struct FailAtDriveChallenge(FakeDvdDrive);
|
||||
impl ScsiTransport for FailAtDriveChallenge {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
if cdb[0] == crate::scsi::SCSI_REPORT_KEY && cdb[10] & 0x3F == 0x01 {
|
||||
return Ok(crate::scsi::ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
self.0.execute(cdb, dir, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
let mut t = FailAtDriveChallenge(FakeDvdDrive {
|
||||
variant: 4,
|
||||
host_challenge: [0u8; 10],
|
||||
});
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("refused drive challenge");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
}
|
||||
|
||||
/// Host challenge, Key1, and the drive challenge all succeed, but the
|
||||
/// Key2 SEND KEY (step 4, the last command in the handshake) is refused.
|
||||
#[test]
|
||||
fn key2_send_failure_fails_the_auth() {
|
||||
struct FailAtKey2Send(FakeDvdDrive);
|
||||
impl ScsiTransport for FailAtKey2Send {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
if cdb[0] == crate::scsi::SCSI_SEND_KEY && cdb[10] & 0x3F == 0x03 {
|
||||
return Ok(crate::scsi::ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
self.0.execute(cdb, dir, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
let mut t = FailAtKey2Send(FakeDvdDrive {
|
||||
variant: 4,
|
||||
host_challenge: [0u8; 10],
|
||||
});
|
||||
let e = authenticate_with_agid(&mut t, 0).expect_err("refused Key2 send");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
}
|
||||
|
||||
// ── read_disc_key ────────────────────────────────────────────────────────
|
||||
|
||||
/// The best-effort disc-key REPORT KEY is refused — `read_disc_key` must
|
||||
/// surface the failure to its caller (who treats it as non-fatal), not
|
||||
/// silently return `Ok`.
|
||||
#[test]
|
||||
fn read_disc_key_refused_is_an_error() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let e = read_disc_key(&mut t, 0).expect_err("drive refused disc-key read");
|
||||
assert!(matches!(e, Error::CssAuthFailed));
|
||||
}
|
||||
|
||||
/// A transport fault reading the disc key must classify as a transport
|
||||
/// failure, not a generic auth failure.
|
||||
#[test]
|
||||
fn read_disc_key_transport_fault_is_transport_failure() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let e = read_disc_key(&mut t, 0).expect_err("dead bus");
|
||||
assert!(e.is_transport_failure());
|
||||
}
|
||||
|
||||
// ── Full happy-path bus-auth ────────────────────────────────────────────
|
||||
|
||||
// A fake drive that plays its half of the CSS handshake for real:
|
||||
// answers Key1 honestly, and DVD/disc-key probes as success.
|
||||
struct FakeDvdDrive {
|
||||
variant: u8,
|
||||
host_challenge: [u8; 10],
|
||||
}
|
||||
|
||||
impl ScsiTransport for FakeDvdDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
use crate::scsi::ScsiResult;
|
||||
let ok = |bytes_transferred: usize| ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred,
|
||||
sense: [0u8; 32],
|
||||
};
|
||||
match cdb[0] {
|
||||
crate::scsi::SCSI_GET_CONFIGURATION => {
|
||||
data[6] = 0x00;
|
||||
data[7] = 0x10; // DVD-ROM current profile
|
||||
Ok(ok(8))
|
||||
}
|
||||
crate::scsi::SCSI_SEND_KEY => {
|
||||
let format = cdb[10] & 0x3F;
|
||||
if format == 0x01 {
|
||||
// Host challenge: capture it for the Key1 answer.
|
||||
for i in 0..10 {
|
||||
self.host_challenge[i] = data[4 + (9 - i)];
|
||||
}
|
||||
}
|
||||
// format 0x03 (Key2) is accepted unconditionally: the
|
||||
// real handshake never verifies it drive-side in this
|
||||
// primitive (ASF=1 is set on the drive's own say-so).
|
||||
Ok(ok(0))
|
||||
}
|
||||
crate::scsi::SCSI_REPORT_KEY => {
|
||||
match cdb[10] & 0x3F {
|
||||
0x00 => {
|
||||
// Allocate AGID 0.
|
||||
data[7] = 0x00;
|
||||
Ok(ok(8))
|
||||
}
|
||||
0x02 => {
|
||||
// Key1, honestly derived from the captured challenge.
|
||||
let key1 = crypt_key(0, self.variant, &self.host_challenge);
|
||||
for i in 0..5 {
|
||||
data[4 + (4 - i)] = key1[i];
|
||||
}
|
||||
Ok(ok(12))
|
||||
}
|
||||
0x01 => {
|
||||
// Drive challenge (arbitrary, fixed).
|
||||
let drive_challenge: [u8; 10] = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0];
|
||||
for i in 0..10 {
|
||||
data[4 + (9 - i)] = drive_challenge[i];
|
||||
}
|
||||
Ok(ok(16))
|
||||
}
|
||||
_ => Ok(ok(0)), // 0x3F release, best-effort
|
||||
}
|
||||
}
|
||||
crate::scsi::SCSI_READ_DISC_STRUCTURE => Ok(ok(2048 + 4)),
|
||||
_ => Ok(ok(0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end happy path for the bus-auth handshake (AGID, challenge,
|
||||
// Key1/Key2, disc-key REPORT KEY), exercising the `Ok` returns that
|
||||
// the failure-path tests above never reach.
|
||||
#[test]
|
||||
fn full_bus_auth_and_disc_key_succeed() {
|
||||
let mut t = FakeDvdDrive {
|
||||
variant: 7,
|
||||
host_challenge: [0u8; 10],
|
||||
};
|
||||
let agid = establish_authenticated_session(&mut t).expect("full bus-auth handshake succeeds");
|
||||
assert_eq!(agid, 0);
|
||||
read_disc_key(&mut t, agid).expect("disc-key REPORT KEY succeeds");
|
||||
}
|
||||
|
||||
// The same happy path through the public `unlock` entry point, proving it
|
||||
// returns `Some(Unlocked::default())` on success — the path every
|
||||
// failure-injection test above deliberately avoids.
|
||||
#[test]
|
||||
fn dvd_unlocker_succeeds_end_to_end() {
|
||||
let id = DriveId::default();
|
||||
let mut t = FakeDvdDrive {
|
||||
variant: 3,
|
||||
host_challenge: [0u8; 10],
|
||||
};
|
||||
let r = DvdUnlocker::new().unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Css));
|
||||
let unlocked = r
|
||||
.expect("full unlock succeeds")
|
||||
.expect("DVD bus-auth unlocks the drive");
|
||||
assert!(unlocked.vid.is_none(), "CSS yields no Volume ID");
|
||||
assert!(unlocked.bus_key.is_none(), "CSS yields no AACS bus key");
|
||||
}
|
||||
|
||||
/// `unlock_css_reads` (the crate-level public entry point, distinct from
|
||||
/// the `DvdUnlocker` wrapper) also succeeds end to end.
|
||||
#[test]
|
||||
fn unlock_css_reads_succeeds() {
|
||||
let mut t = FakeDvdDrive {
|
||||
variant: 11,
|
||||
host_challenge: [0u8; 10],
|
||||
};
|
||||
// unlock_css_reads doesn't probe GET CONFIGURATION itself (that's
|
||||
// DvdUnlocker's job); it goes straight to bus-auth.
|
||||
unlock_css_reads(&mut t, 0).expect("css bus-auth + best-effort disc key succeed");
|
||||
}
|
||||
|
||||
// A drive whose bus-auth succeeds but refuses the best-effort disc-key
|
||||
// REPORT KEY; `unlock_css_reads_inner` must swallow that failure since
|
||||
// the read barrier is already open from bus-auth.
|
||||
struct DiscKeyRefusingDrive(FakeDvdDrive);
|
||||
|
||||
impl ScsiTransport for DiscKeyRefusingDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
if cdb[0] == crate::scsi::SCSI_READ_DISC_STRUCTURE {
|
||||
return Ok(crate::scsi::ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
self.0.execute(cdb, dir, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disc_key_refusal_is_non_fatal_to_the_overall_unlock() {
|
||||
let mut t = DiscKeyRefusingDrive(FakeDvdDrive {
|
||||
variant: 5,
|
||||
host_challenge: [0u8; 10],
|
||||
});
|
||||
unlock_css_reads(&mut t, 0)
|
||||
.expect("bus-auth succeeded; a refused best-effort disc-key read must not fail it");
|
||||
}
|
||||
|
||||
// `crypt_key`'s key_type==2 arm (`PERM_VARIANT[1]`) has no production
|
||||
// caller (only 0 and 1 are invoked from `authenticate_with_agid`) but
|
||||
// is a pure, directly-testable function.
|
||||
#[test]
|
||||
fn crypt_key_type2_is_deterministic_and_distinct_from_type1() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let a = crypt_key(2, 9, &challenge);
|
||||
let b = crypt_key(2, 9, &challenge);
|
||||
assert_eq!(a, b, "crypt_key(2, ..) must be deterministic");
|
||||
assert_ne!(
|
||||
crypt_key(1, 9, &challenge),
|
||||
crypt_key(2, 9, &challenge),
|
||||
"key_type 1 and 2 must diverge (distinct PERM_VARIANT rows)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stop (stop-design-v5 §2.3, ST-U1) ───────────────────────────────────
|
||||
|
||||
/// A drive that completes the CSS bus-auth for real: it answers Key1 for the
|
||||
/// host challenge under `variant`, serves a challenge, and allocates AGID 1.
|
||||
struct CssEmu {
|
||||
variant: u8,
|
||||
host_challenge: [u8; 10],
|
||||
/// Answer Key1 with zeros, which matches no variant → CssAuthFailed.
|
||||
bad_key1: bool,
|
||||
}
|
||||
|
||||
impl ScsiTransport for CssEmu {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
let mut r = vec![0u8; data.len()];
|
||||
match (cdb[0], cdb[10] & 0x3F) {
|
||||
(crate::scsi::SCSI_REPORT_KEY, 0x00) => r[7] = 1 << 6, // AGID 1
|
||||
(crate::scsi::SCSI_REPORT_KEY, 0x02) if !self.bad_key1 => {
|
||||
let k = crypt_key(0, self.variant, &self.host_challenge);
|
||||
(0..5).for_each(|j| r[4 + j] = k[4 - j]);
|
||||
}
|
||||
(crate::scsi::SCSI_SEND_KEY, 0x01) => {
|
||||
(0..10).for_each(|i| self.host_challenge[i] = data[4 + 9 - i]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if cdb[0] != crate::scsi::SCSI_SEND_KEY {
|
||||
data.copy_from_slice(&r);
|
||||
}
|
||||
Ok(crate::scsi::ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn css_emu() -> CssEmu {
|
||||
CssEmu {
|
||||
variant: 7,
|
||||
host_challenge: [0u8; 10],
|
||||
bad_key1: false,
|
||||
}
|
||||
}
|
||||
|
||||
// UT4 / G2 (stop-design-v5 §5.2, D9 "Keep holding it"): on success the AGID is
|
||||
// `defuse`d, so it is still held for `read_disc_key` and never released.
|
||||
// Per design D9; do not change without a design citation proving otherwise.
|
||||
#[test]
|
||||
fn css_success_keeps_agid() {
|
||||
use crate::scsi::mock::{StopFake, agid_ledger, is_agid_release};
|
||||
let mut t = StopFake::new(css_emu());
|
||||
unlock_css_reads(&mut t, 0).expect("the bus-auth completes");
|
||||
let (allocs, held) = agid_ledger(&t.log);
|
||||
assert_eq!(
|
||||
(allocs, held),
|
||||
(1, true),
|
||||
"one AGID, still held after success"
|
||||
);
|
||||
assert!(t.cleanups().is_empty(), "no release on success");
|
||||
let execs = t.execs();
|
||||
let alloc = execs.iter().position(|c| c[0] == 0xA4 && c[10] & 0x3F == 0);
|
||||
let after = &execs[alloc.expect("allocated") + 1..];
|
||||
assert!(
|
||||
!after.iter().any(|c| is_agid_release(c)),
|
||||
"no 0x3F after success"
|
||||
);
|
||||
let disc_key = after.last().expect("the disc-key read follows");
|
||||
assert_eq!(
|
||||
(disc_key[0], disc_key[7]),
|
||||
(0xAD, 0x02),
|
||||
"READ DVD STRUCTURE fmt 2"
|
||||
);
|
||||
assert_eq!(disc_key[10], 1 << 6, "issued under the still-held AGID 1");
|
||||
}
|
||||
|
||||
// SS-7 (evidence): a CSS challenge that fails after allocation releases the
|
||||
// AGID exactly once, through `execute_cleanup`, even when a Stop caused it.
|
||||
#[test]
|
||||
fn css_failed_challenge_releases_agid_once_via_cleanup() {
|
||||
use crate::scsi::mock::{StopFake, agid_ledger};
|
||||
let stop_at_challenge: fn(&[u8]) -> bool =
|
||||
|c| c[0] == crate::scsi::SCSI_SEND_KEY && c[10] & 0x3F == 0x01;
|
||||
for cancel in [false, true] {
|
||||
let mut emu = css_emu();
|
||||
if !cancel {
|
||||
emu.bad_key1 = true;
|
||||
}
|
||||
let mut t = StopFake::new(emu);
|
||||
if cancel {
|
||||
t.cancel_after = Some(stop_at_challenge);
|
||||
}
|
||||
let e = establish_authenticated_session(&mut t).expect_err("auth fails");
|
||||
assert_eq!(e.is_transport_failure(), cancel, "a Stop is a refusal");
|
||||
assert_eq!(agid_ledger(&t.log), (1, false), "cancel={cancel}");
|
||||
assert_eq!(
|
||||
t.cleanups().len(),
|
||||
1,
|
||||
"exactly one release, cancel={cancel}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// UT5 (stop-design-v5 §5.2): "zero invalidate CDBs after a cancel" — the
|
||||
// pre-allocation invalidate loop uses `execute`, so a Stop refuses it.
|
||||
#[test]
|
||||
fn invalidate_loops_refused_after_cancel() {
|
||||
use crate::scsi::mock::StopFake;
|
||||
let mut t = StopFake::new(css_emu());
|
||||
t.cancelled = true;
|
||||
let e = establish_authenticated_session(&mut t).expect_err("cancelled");
|
||||
assert!(e.is_transport_failure());
|
||||
assert!(
|
||||
t.execs().is_empty(),
|
||||
"no CDB reached the drive: {:?}",
|
||||
t.log
|
||||
);
|
||||
assert!(
|
||||
t.cleanups().is_empty(),
|
||||
"nothing allocated, nothing released"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,866 @@
|
||||
//! Typed mirror of the freemkv firmware vendor-command ABI.
|
||||
//!
|
||||
//! Numeric values must match `freemkv-firmware/crates/freemkv-fw/src/abi.rs`.
|
||||
//! Tests pin the verbs, features, state bytes and CDB offsets.
|
||||
//!
|
||||
//! ```text
|
||||
//! cdb[0] = 0x3C (READ BUFFER) ← standard opcode; bridge-safe
|
||||
//! cdb[1] = 0x0E (KNOCK_MODE) ← OEM's jump table rejects modes >= 0x0E
|
||||
//! cdb[2..4] = 0xC0 0xDE (KNOCK) ← defence-in-depth signature
|
||||
//! cdb[4] = Verb ← IDENTITY / SET / GET / RESET / DUMPALL
|
||||
//! cdb[5] = Feature id ← SET/GET only (else 0)
|
||||
//! cdb[6] = State byte ← SET only (else 0)
|
||||
//! cdb[7..9] = allocation length (16-bit big-endian) ← data-returning verbs
|
||||
//! cdb[9] = control (0)
|
||||
//! ```
|
||||
//!
|
||||
//! DumpAll instead encodes a 32-bit address in `cdb[5..9]` and returns MEMREAD_LEN bytes.
|
||||
//! Features default to passthrough. Reset selects OEM, built-in defaults or saved flash state.
|
||||
|
||||
use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus};
|
||||
|
||||
// ── Wire constants (mirror of abi.rs) ────────────────────────────────────────
|
||||
|
||||
/// Standard SCSI `READ BUFFER` opcode — the command freemkv hijacks (`cdb[0]`).
|
||||
pub const READ_BUFFER_OPCODE: u8 = 0x3C;
|
||||
/// The freemkv sub-command mode at `cdb[1]`; OEM rejects modes `>= 0x0E`.
|
||||
pub const KNOCK_MODE: u8 = 0x0E;
|
||||
/// Two-byte knock at `cdb[2..4]` ("C0DE") — the *safe* knock carried by every
|
||||
/// durable verb (Identity/Set/Get/Reset/DumpAll/Save/FlashWrite).
|
||||
pub const KNOCK: [u8; 2] = [0xC0, 0xDE];
|
||||
/// Two-byte knock at `cdb[2..4]` — the *debug* knock, distinct from [`KNOCK`]
|
||||
/// by construction. The only frame the fw honours for the debug-only verbs
|
||||
/// ([`Verb::Call`], [`Verb::Poke`], [`Verb::Reboot`]).
|
||||
pub const DEBUG_KNOCK: [u8; 2] = [0xDE, 0xB9];
|
||||
/// Response-framing magic leading the [`Verb::Identity`] reply (`b"freemkv"`).
|
||||
pub const RESP_MAGIC: &[u8] = b"freemkv";
|
||||
|
||||
/// Oldest firmware `(major, minor)` this mirror arms: 0.9 made wire id `0x06`
|
||||
/// the consolidated `Encryption` lever. Released 0.8.x (`0x06` = `Ake`, separate
|
||||
/// `0x07 Bus`) de-busses via `0x06` alone only as a hardware-dependent side
|
||||
/// effect (not validated), and 0.7.x uses a sub-function grammar: both refused.
|
||||
pub const MIN_FW_VERSION: (u32, u32) = (0, 9);
|
||||
|
||||
/// Length of a freemkv (READ BUFFER) CDB.
|
||||
pub const CDB_LEN: usize = 10;
|
||||
/// Offset of the opcode byte (`cdb[0]`).
|
||||
pub const CDB_OPCODE: usize = 0;
|
||||
/// Offset of the mode/knock byte (`cdb[1]`).
|
||||
pub const CDB_MODE: usize = 1;
|
||||
/// Offset of the first knock byte (`cdb[2]`, `cdb[3]`).
|
||||
pub const CDB_KNOCK: usize = 2;
|
||||
/// Offset of the verb byte (`cdb[4]`).
|
||||
pub const CDB_VERB: usize = 4;
|
||||
/// Offset of the feature-id byte (`cdb[5]`) — SET/GET only.
|
||||
pub const CDB_FEATURE: usize = 5;
|
||||
/// Offset of the state byte (`cdb[6]`) — SET only.
|
||||
pub const CDB_STATE: usize = 6;
|
||||
/// Offset of the 16-bit big-endian allocation length (`cdb[7..9]`).
|
||||
pub const CDB_ALLOC_LEN: usize = 7;
|
||||
|
||||
/// Bytes returned by one [`Verb::DumpAll`] read (fixed 64-byte window).
|
||||
pub const MEMREAD_LEN: usize = 64;
|
||||
|
||||
/// Minimum data-in allocation length any freemkv vendor command may request.
|
||||
///
|
||||
/// **Hardware-confirmed (LG BU40N on freemkv firmware):** the drive's `READ
|
||||
/// BUFFER` hijack ABORTS (Check Condition, sense key Aborted Command) any vendor
|
||||
/// command whose data-in allocation length is under ~16 bytes (0/1/2 all abort;
|
||||
/// 16 and 64 both succeed). Every builder floors its allocation at `64` — it
|
||||
/// matches [`MEMREAD_LEN`] and is safely above the minimum. The verb/feature/
|
||||
/// state ride in the CDB, so a larger data-in is harmless; a [`Verb::Get`] still
|
||||
/// reads its state byte from data offset 0. Mirrors firmware ABI `MIN_ALLOC_LEN`.
|
||||
pub const MIN_ALLOC_LEN: u16 = 64;
|
||||
|
||||
/// Feature state: **passthrough** — firmware does not touch this subsystem
|
||||
/// (OEM behaviour). The boot default of every feature and what [`Verb::Reset`]
|
||||
/// restores. `0xFF`.
|
||||
pub const STATE_PASSTHROUGH: u8 = 0xFF;
|
||||
/// Feature state: explicit **off / disabled** (`0x00`).
|
||||
pub const STATE_OFF: u8 = 0x00;
|
||||
/// Feature state: explicit **on / enabled** (`0x01`).
|
||||
pub const STATE_ON: u8 = 0x01;
|
||||
|
||||
/// [`Feature::Speed`] state: speed control OFF — the read-speed cap is lifted and
|
||||
/// the drive runs uncapped at maximum throughput. This is the [`STATE_OFF`]
|
||||
/// (`0x00`) leg of Speed: "off" is the *limiter* being off, i.e. maximum speed.
|
||||
/// `0x01..=0xFE` are explicit caps and `0xFF` is the OEM ramp.
|
||||
pub const SPEED_MAX: u8 = 0x00;
|
||||
|
||||
/// [`Feature::Region`] state: force BD region A (`0x0A`). See [`BdRegion`]. BD
|
||||
/// regions A/B/C share the low-nibble block with the DVD `0x0N` scheme
|
||||
/// ([`REGION_DVD_BASE`]) and [`REGION_FREE`] (`0x0F`).
|
||||
pub const REGION_BD_A: u8 = 0x0A;
|
||||
/// [`Feature::Region`] state: force BD region B (`0x0B`).
|
||||
pub const REGION_BD_B: u8 = 0x0B;
|
||||
/// [`Feature::Region`] state: force BD region C (`0x0C`).
|
||||
pub const REGION_BD_C: u8 = 0x0C;
|
||||
|
||||
/// [`Feature::Region`] state base for "force DVD region N": `REGION_DVD_BASE + N`,
|
||||
/// so regions 1..=8 are `0x01..=0x08`. `0x00` itself is region-locked (nothing
|
||||
/// plays).
|
||||
pub const REGION_DVD_BASE: u8 = 0x00;
|
||||
|
||||
/// [`Feature::Region`] state: region-free — any disc plays regardless of its
|
||||
/// region code. Sits in the region low-nibble block above the DVD (`0x01..=0x08`)
|
||||
/// and BD (`0x0A..=0x0C`) values.
|
||||
pub const REGION_FREE: u8 = 0x0F;
|
||||
|
||||
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): reload the saved flash
|
||||
/// config block into the RAM feature-state table, discarding un-saved RAM changes
|
||||
/// — the non-destructive "revert to last SAVE". Marker-gated in firmware ≥0.8.3: a
|
||||
/// never-saved drive loads the baked create-time defaults instead.
|
||||
pub const RESET_TO_FLASH: u8 = 0x00;
|
||||
|
||||
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): restore the baked
|
||||
/// per-create **defaults** (the create-time flags, e.g. UHD/BD on) into the RAM
|
||||
/// feature-state table. RAM-only — does not touch flash. This is what a never-saved
|
||||
/// drive boots to. Firmware ≥0.8.3.
|
||||
pub const RESET_TO_DEFAULTS: u8 = 0x01;
|
||||
|
||||
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): TRUE OEM. Forces every
|
||||
/// feature to [`STATE_PASSTHROUGH`] in RAM **and** (firmware ≥0.8.3) writes the NV
|
||||
/// config block all-`0xFF` — marker included — so the drive is byte-for-byte like a
|
||||
/// never-saved/never-configured drive (no trace it was ever set). Because the marker
|
||||
/// is then `0xFF`, the NEXT boot loads the baked defaults, exactly like a fresh drive.
|
||||
pub const RESET_TO_OEM: u8 = 0xFF;
|
||||
|
||||
/// The verb selector in `cdb[4]`. These numeric values ARE the wire protocol.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum Verb {
|
||||
/// Status/ping — returns `freemkv <version>` (no NUL), then the raw
|
||||
/// feature-flag bytes `flag[0x01..=0x06]`, zero-padded to the window.
|
||||
Identity = 0x01,
|
||||
/// Set one feature (`cdb[5]`) to a state (`cdb[6]`).
|
||||
Set = 0x02,
|
||||
/// Read one feature's (`cdb[5]`) current state back in the data-in payload.
|
||||
Get = 0x03,
|
||||
/// Restore the RAM feature-state table. The mode rides in `cdb[6]` (the state
|
||||
/// slot): [`RESET_TO_FLASH`] (`0x00`) reloads the saved flash config block back
|
||||
/// into RAM (discarding un-saved changes; marker-gated in fw ≥0.8.3),
|
||||
/// [`RESET_TO_DEFAULTS`] (`0x01`) restores the baked create-time defaults, and
|
||||
/// [`RESET_TO_OEM`] (`0xFF`) forces every feature to [`STATE_PASSTHROUGH`] AND
|
||||
/// blanks the NV block (traceless, fw ≥0.8.3). Ignores feature.
|
||||
Reset = 0x04,
|
||||
/// Diagnostic RAM peek: [`MEMREAD_LEN`] bytes at the 32-bit address in
|
||||
/// `cdb[5..9]` (big-endian).
|
||||
DumpAll = 0x09,
|
||||
/// **TEMPORARY** diagnostic probe: program a single byte to an allowlisted
|
||||
/// flash offset via the OEM PROGRAM routine. `CDB[5..9]` = 32-bit flash offset
|
||||
/// (big-endian); `CDB[9]` = value byte. The firmware refuses any offset outside
|
||||
/// the compile-time allowlist window (`0x1EA000..0x1EB000` — the corpus-proven-
|
||||
/// unlocked NV/SAVE block) with a distinct `"REFU"` status word (PROGRAM not
|
||||
/// called). Reply: 4-byte offset echo (BE) + 4-byte PROGRAM/REFU status (BE).
|
||||
/// Not part of the durable feature grammar; subsumed by [`Verb::Save`].
|
||||
FlashWrite = 0x0A,
|
||||
/// Persist the whole RAM feature-state table to the flash config block. This is
|
||||
/// the ONLY verb that writes the config to flash: [`Verb::Set`] and
|
||||
/// [`Verb::Reset`] touch RAM only, so a host's changes stay volatile until a
|
||||
/// `Save` commits them (and survive a power cycle only once saved). Ignores
|
||||
/// feature/state.
|
||||
Save = 0x0B,
|
||||
/// **Debug-knock only.** Interactive fw-exploration `blx target` primitive.
|
||||
/// `CDB[5..9]` = 32-bit target VA (big-endian); `CDB[9]` = single u8 that
|
||||
/// is loaded into r0 as the sole register argument. Refused under the safe
|
||||
/// [`KNOCK`]; only executed under [`DEBUG_KNOCK`]. See [`build_call_cdb`].
|
||||
Call = 0x0C,
|
||||
/// **Debug-knock only.** Poke a single byte to an arbitrary address.
|
||||
/// `CDB[5..9]` = 32-bit target address (big-endian, RAM or MMIO);
|
||||
/// `CDB[9]` = byte value to store. Refused under the safe [`KNOCK`]; only
|
||||
/// executed under [`DEBUG_KNOCK`]. See [`build_poke_cdb`].
|
||||
Poke = 0x0D,
|
||||
/// **Debug-knock only.** Invokes the firmware's boot function entry with
|
||||
/// r0=4 to force the cold path (BSS clear + C-runtime data init + full
|
||||
/// post-init), recovering a wedged drive without a power cycle. The target
|
||||
/// briefly returns Aborted Command mid-reboot, then comes back ~5s later
|
||||
/// with the safe-knock verb chain re-armed to its power-on defaults. The
|
||||
/// target VA is baked into the handler at build time, so no CDB arguments
|
||||
/// are carried beyond the verb byte. Refused under the safe [`KNOCK`];
|
||||
/// only executed under [`DEBUG_KNOCK`]. See [`build_reboot_cdb`].
|
||||
Reboot = 0x0F,
|
||||
}
|
||||
|
||||
/// The feature selector in `cdb[5]` for [`Verb::Set`] / [`Verb::Get`]. These
|
||||
/// numeric values ARE the wire protocol.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum Feature {
|
||||
/// Read-speed / riplock ceiling. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM ramp;
|
||||
/// [`STATE_OFF`] (`0x00`) = speed control off, i.e. the cap is lifted and the
|
||||
/// drive runs uncapped at maximum throughput (see [`SPEED_MAX`]); `0x01..=0xFE`
|
||||
/// = an explicit speed cap (the byte IS the cap, lower is slower). Note the OFF
|
||||
/// direction: "off" means the *limiter* is off, so OFF is the fastest state.
|
||||
Speed = 0x01,
|
||||
/// Region (RPC) control. [`STATE_PASSTHROUGH`] (`0xFF`) = drive's own region
|
||||
/// logic; [`STATE_OFF`] (`0x00`) = region-locked (nothing plays);
|
||||
/// [`REGION_DVD_BASE`]` + N` (`0x01..=0x08`) = force DVD region 1..8;
|
||||
/// [`REGION_BD_A`]/`_B`/`_C` (`0x0A`/`0x0B`/`0x0C`) = force BD region A/B/C;
|
||||
/// [`REGION_FREE`] (`0x0F`) = region-free (any disc plays).
|
||||
Region = 0x02,
|
||||
/// Unrestricted (BD/UHD) capability widen gate. [`STATE_PASSTHROUGH`]
|
||||
/// (`0xFF`) = OEM; [`STATE_OFF`] (`0x00`) = No (currently no-op on
|
||||
/// byte-extraction classifiers like BU40N, pending RE); [`STATE_ON`] (`0x01`)
|
||||
/// = Yes. fw 0.9.2 folds BD into this gate by name only: its BD-refuse detour
|
||||
/// still reads wire id `0x04` (`Bd`, deprecated), which this mirror does not
|
||||
/// expose, so on 0.9.2 this lever governs UHD acceptance.
|
||||
Unrestricted = 0x03,
|
||||
/// Host Revocation List handling on the cert path. [`STATE_PASSTHROUGH`]
|
||||
/// (`0xFF`) = OEM enforce; [`STATE_OFF`] (`0x00`) = off (skip the HRL lookup —
|
||||
/// revoked certs accepted, non-destructive, the unlock direction); [`STATE_ON`]
|
||||
/// (`0x01`) = on (enforce the HRL).
|
||||
Hrl = 0x05,
|
||||
/// The one master encryption/cert bypass. [`STATE_OFF`] (`0x00`) = every
|
||||
/// drive-side encryption/cert requirement gone (cert-AKE relaxed, bus-wrap
|
||||
/// off — what used to need a cert now doesn't). [`STATE_PASSTHROUGH`] (`0xFF`)
|
||||
/// = OEM behavior (real handshake, bus-encrypted content). [`STATE_ON`]
|
||||
/// (`0x01`) = on (require the real handshake). Wire id `0x07` is retired
|
||||
/// (was `Bus`, empirically proved inert as a separate datapath lever on
|
||||
/// BU40N/MT1959 — the single Encryption lever de-busses on its own).
|
||||
Encryption = 0x06,
|
||||
}
|
||||
|
||||
/// Every exposed feature, in wire-id order (the order [`FirmwareControl::states`]
|
||||
/// probes). The IDENTITY flag table also carries the unexposed `0x04` slot.
|
||||
pub const ALL_FEATURES: [Feature; 5] = [
|
||||
Feature::Speed,
|
||||
Feature::Region,
|
||||
Feature::Unrestricted,
|
||||
Feature::Hrl,
|
||||
Feature::Encryption,
|
||||
];
|
||||
|
||||
/// A Blu-ray region for [`FirmwareControl::force_region_bd`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BdRegion {
|
||||
/// Region A (`0x0A`).
|
||||
A,
|
||||
/// Region B (`0x0B`).
|
||||
B,
|
||||
/// Region C (`0x0C`).
|
||||
C,
|
||||
}
|
||||
|
||||
impl BdRegion {
|
||||
/// The `Feature::Region` state byte that forces this BD region.
|
||||
pub fn state(self) -> u8 {
|
||||
match self {
|
||||
BdRegion::A => REGION_BD_A,
|
||||
BdRegion::B => REGION_BD_B,
|
||||
BdRegion::C => REGION_BD_C,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── CDB builders (mirror of abi.rs) ──────────────────────────────────────────
|
||||
|
||||
/// Build a 10-byte host CDB for a verb over the `3C 0E C0 DE …` frame.
|
||||
///
|
||||
/// `feature`/`state` land at `cdb[5]`/`cdb[6]` (0 when the verb ignores them);
|
||||
/// `alloc_len` is the data-in buffer size, 16-bit big-endian at `cdb[7..9]`.
|
||||
/// Use [`build_memread_cdb`] for [`Verb::DumpAll`] (different field layout).
|
||||
pub fn build_cdb(
|
||||
verb: Verb,
|
||||
feature: Option<Feature>,
|
||||
state: Option<u8>,
|
||||
alloc_len: u16,
|
||||
) -> [u8; CDB_LEN] {
|
||||
let mut cdb = [0u8; CDB_LEN];
|
||||
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
|
||||
cdb[CDB_MODE] = KNOCK_MODE;
|
||||
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&KNOCK);
|
||||
cdb[CDB_VERB] = verb as u8;
|
||||
cdb[CDB_FEATURE] = feature.map(|f| f as u8).unwrap_or(0);
|
||||
cdb[CDB_STATE] = state.unwrap_or(0);
|
||||
cdb[CDB_ALLOC_LEN] = (alloc_len >> 8) as u8;
|
||||
cdb[CDB_ALLOC_LEN + 1] = alloc_len as u8;
|
||||
cdb
|
||||
}
|
||||
|
||||
/// Build a `SET feature = state` CDB. Requests a [`MIN_ALLOC_LEN`]-byte data-in
|
||||
/// (the drive aborts sub-16-byte transfers — HW-confirmed, see [`MIN_ALLOC_LEN`]);
|
||||
/// the feature/state ride in the CDB, so the returned payload is ignored.
|
||||
pub fn build_set_cdb(feature: Feature, state: u8) -> [u8; CDB_LEN] {
|
||||
build_cdb(Verb::Set, Some(feature), Some(state), MIN_ALLOC_LEN)
|
||||
}
|
||||
|
||||
/// Build a `GET feature` CDB. Requests a [`MIN_ALLOC_LEN`]-byte data-in (the
|
||||
/// drive aborts a 1-byte transfer — HW-confirmed, see [`MIN_ALLOC_LEN`]); the
|
||||
/// current state byte is read back from data offset 0.
|
||||
pub fn build_get_cdb(feature: Feature) -> [u8; CDB_LEN] {
|
||||
build_cdb(Verb::Get, Some(feature), None, MIN_ALLOC_LEN)
|
||||
}
|
||||
|
||||
/// Build a `RESET` CDB. `mode` rides in the state slot (`cdb[6]`):
|
||||
/// [`RESET_TO_FLASH`] (`0x00`) reloads the saved flash config into RAM,
|
||||
/// [`RESET_TO_DEFAULTS`] (`0x01`) restores the baked create-time defaults, and
|
||||
/// [`RESET_TO_OEM`] (`0xFF`) forces every feature to passthrough (and blanks NV).
|
||||
/// Floors its data-in at [`MIN_ALLOC_LEN`] for the same HW min-transfer reason as
|
||||
/// [`build_set_cdb`].
|
||||
pub fn build_reset_cdb(mode: u8) -> [u8; CDB_LEN] {
|
||||
build_cdb(Verb::Reset, None, Some(mode), MIN_ALLOC_LEN)
|
||||
}
|
||||
|
||||
/// Build a `SAVE` CDB (persist the RAM feature-state table to the flash config
|
||||
/// block — the only verb that writes config to flash). Floors its data-in at
|
||||
/// [`MIN_ALLOC_LEN`] for the same HW min-transfer reason as [`build_reset_cdb`].
|
||||
pub fn build_save_cdb() -> [u8; CDB_LEN] {
|
||||
build_cdb(Verb::Save, None, None, MIN_ALLOC_LEN)
|
||||
}
|
||||
|
||||
/// Build an `IDENTITY` CDB. `alloc_len` sizes the magic+version+state reply.
|
||||
pub fn build_identity_cdb(alloc_len: u16) -> [u8; CDB_LEN] {
|
||||
build_cdb(Verb::Identity, None, None, alloc_len)
|
||||
}
|
||||
|
||||
/// Build a [`Verb::DumpAll`] CDB: read [`MEMREAD_LEN`] bytes at the 32-bit
|
||||
/// `addr`, packed big-endian into `cdb[5..9]`.
|
||||
pub fn build_memread_cdb(addr: u32) -> [u8; CDB_LEN] {
|
||||
let mut cdb = [0u8; CDB_LEN];
|
||||
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
|
||||
cdb[CDB_MODE] = KNOCK_MODE;
|
||||
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&KNOCK);
|
||||
cdb[CDB_VERB] = Verb::DumpAll as u8;
|
||||
cdb[5] = (addr >> 24) as u8;
|
||||
cdb[6] = (addr >> 16) as u8;
|
||||
cdb[7] = (addr >> 8) as u8;
|
||||
cdb[8] = addr as u8;
|
||||
cdb
|
||||
}
|
||||
|
||||
/// Build a 10-byte CDB for [`Verb::Call`]: `blx target(r0)` where `target` is
|
||||
/// packed big-endian in `cdb[5..9]` and the u8 `r0` register argument rides in
|
||||
/// `cdb[9]`. Carries the [`DEBUG_KNOCK`] at `cdb[2..4]` — the ONLY frame the
|
||||
/// fw honours for Call. The handler ORs the thumb bit into the address at
|
||||
/// runtime. r1..r3 are undefined at callee entry.
|
||||
pub fn build_call_cdb(target: u32, r0: u8) -> [u8; CDB_LEN] {
|
||||
let mut cdb = [0u8; CDB_LEN];
|
||||
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
|
||||
cdb[CDB_MODE] = KNOCK_MODE;
|
||||
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&DEBUG_KNOCK);
|
||||
cdb[CDB_VERB] = Verb::Call as u8;
|
||||
cdb[5] = (target >> 24) as u8;
|
||||
cdb[6] = (target >> 16) as u8;
|
||||
cdb[7] = (target >> 8) as u8;
|
||||
cdb[8] = target as u8;
|
||||
cdb[9] = r0;
|
||||
cdb
|
||||
}
|
||||
|
||||
/// Build a 10-byte CDB for [`Verb::Poke`]: write a single byte `val` to the
|
||||
/// arbitrary 32-bit address `target` (RAM or MMIO). Target is packed
|
||||
/// big-endian in `cdb[5..9]` and `val` rides in `cdb[9]`. Carries the
|
||||
/// [`DEBUG_KNOCK`] at `cdb[2..4]`. NO bounds check on the address — this is a
|
||||
/// diagnostic primitive for on-drive state discovery, not a durable verb.
|
||||
pub fn build_poke_cdb(target: u32, val: u8) -> [u8; CDB_LEN] {
|
||||
let mut cdb = [0u8; CDB_LEN];
|
||||
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
|
||||
cdb[CDB_MODE] = KNOCK_MODE;
|
||||
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&DEBUG_KNOCK);
|
||||
cdb[CDB_VERB] = Verb::Poke as u8;
|
||||
cdb[5] = (target >> 24) as u8;
|
||||
cdb[6] = (target >> 16) as u8;
|
||||
cdb[7] = (target >> 8) as u8;
|
||||
cdb[8] = target as u8;
|
||||
cdb[9] = val;
|
||||
cdb
|
||||
}
|
||||
|
||||
/// Build a 10-byte CDB for [`Verb::Reboot`]: force the firmware boot function's
|
||||
/// cold path (soft-reboot the controller). Carries the [`DEBUG_KNOCK`] at
|
||||
/// `cdb[2..4]`. NO arguments are transmitted on the wire — the target VA is
|
||||
/// baked into the emitted handler at build time (per-image, resolved from the
|
||||
/// boot-init signature). Requests a [`MIN_ALLOC_LEN`]-byte data-in like every
|
||||
/// other durable-shape verb: the drive returns Aborted Command mid-reboot
|
||||
/// anyway, so callers should tolerate a rejection on this send and re-probe
|
||||
/// identity after a short delay.
|
||||
pub fn build_reboot_cdb() -> [u8; CDB_LEN] {
|
||||
let mut cdb = [0u8; CDB_LEN];
|
||||
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
|
||||
cdb[CDB_MODE] = KNOCK_MODE;
|
||||
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&DEBUG_KNOCK);
|
||||
cdb[CDB_VERB] = Verb::Reboot as u8;
|
||||
cdb[CDB_ALLOC_LEN] = (MIN_ALLOC_LEN >> 8) as u8;
|
||||
cdb[CDB_ALLOC_LEN + 1] = MIN_ALLOC_LEN as u8;
|
||||
cdb
|
||||
}
|
||||
|
||||
/// Whether a device data response leads with [`RESP_MAGIC`].
|
||||
pub fn verify_response(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(RESP_MAGIC)
|
||||
}
|
||||
|
||||
// ── Errors ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Why a firmware command did not succeed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FirmwareError {
|
||||
/// The drive rejected the command (CHECK CONDITION / vendor refusal). Not a
|
||||
/// freemkv drive, or the feature/state is unsupported.
|
||||
Rejected,
|
||||
/// IDENTITY did not report freemkv firmware at or above [`MIN_FW_VERSION`];
|
||||
/// a recipe was refused before any SET/RESET was sent.
|
||||
UnsupportedFirmware,
|
||||
/// A caller argument was out of range; nothing was sent to the drive.
|
||||
InvalidArgument,
|
||||
/// A verify-after-set (`SET` then `GET`) read back a state that did not
|
||||
/// match what was written. Carries `(feature, wanted, got)`.
|
||||
VerifyFailed {
|
||||
feature: Feature,
|
||||
wanted: u8,
|
||||
got: u8,
|
||||
},
|
||||
/// A genuine SCSI transport fault (bus dead). Callers must abort.
|
||||
Transport,
|
||||
}
|
||||
|
||||
/// Result of a firmware command.
|
||||
pub type Result<T> = std::result::Result<T, FirmwareError>;
|
||||
|
||||
// ── Parsed replies ───────────────────────────────────────────────────────────
|
||||
|
||||
/// The current state of every feature, indexed by [`Feature`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct FeatureStates {
|
||||
/// [`Feature::Speed`] state.
|
||||
pub speed: u8,
|
||||
/// [`Feature::Region`] state.
|
||||
pub region: u8,
|
||||
/// [`Feature::Unrestricted`] state.
|
||||
pub unrestricted: u8,
|
||||
/// [`Feature::Hrl`] state.
|
||||
pub hrl: u8,
|
||||
/// [`Feature::Encryption`] state (the consolidated cert/bus bypass).
|
||||
pub encryption: u8,
|
||||
}
|
||||
|
||||
impl FeatureStates {
|
||||
/// All features at [`STATE_PASSTHROUGH`] (the OEM / freshly-reset drive).
|
||||
pub fn all_passthrough() -> Self {
|
||||
FeatureStates {
|
||||
speed: STATE_PASSTHROUGH,
|
||||
region: STATE_PASSTHROUGH,
|
||||
unrestricted: STATE_PASSTHROUGH,
|
||||
hrl: STATE_PASSTHROUGH,
|
||||
encryption: STATE_PASSTHROUGH,
|
||||
}
|
||||
}
|
||||
|
||||
/// The state byte for one feature.
|
||||
pub fn get(&self, feature: Feature) -> u8 {
|
||||
match feature {
|
||||
Feature::Speed => self.speed,
|
||||
Feature::Region => self.region,
|
||||
Feature::Unrestricted => self.unrestricted,
|
||||
Feature::Hrl => self.hrl,
|
||||
Feature::Encryption => self.encryption,
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&mut self, feature: Feature, state: u8) {
|
||||
match feature {
|
||||
Feature::Speed => self.speed = state,
|
||||
Feature::Region => self.region = state,
|
||||
Feature::Unrestricted => self.unrestricted = state,
|
||||
Feature::Hrl => self.hrl = state,
|
||||
Feature::Encryption => self.encryption = state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed [`Verb::Identity`] reply. Only present when the reply led with
|
||||
/// [`RESP_MAGIC`]. The reply is `freemkv <version>` with no terminator, then the
|
||||
/// raw feature-flag bytes; only the version is parsed — read the live states
|
||||
/// via [`FirmwareControl::states`]/[`get`](FirmwareControl::get).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FirmwareIdentity {
|
||||
/// The version token after `freemkv ` (printable non-space ASCII), e.g.
|
||||
/// `"0.9.2"`; empty if no `freemkv ` token follows the magic. A Speed cap in the
|
||||
/// printable range can append one character after the patch number.
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl FirmwareIdentity {
|
||||
/// Parse an IDENTITY data-in payload. `None` unless it leads with
|
||||
/// [`RESP_MAGIC`] (i.e. not freemkv firmware).
|
||||
pub fn parse(bytes: &[u8]) -> Option<Self> {
|
||||
if !verify_response(bytes) {
|
||||
return None;
|
||||
}
|
||||
let version = match bytes[RESP_MAGIC.len()..].split_first() {
|
||||
Some((b' ', rest)) => rest
|
||||
.iter()
|
||||
.take_while(|b| b.is_ascii_graphic())
|
||||
.map(|&b| char::from(b))
|
||||
.collect(),
|
||||
_ => String::new(),
|
||||
};
|
||||
Some(FirmwareIdentity { version })
|
||||
}
|
||||
|
||||
/// Whether this firmware speaks the grammar this mirror sends (version at
|
||||
/// or above [`MIN_FW_VERSION`]). `false` for an unparseable version.
|
||||
pub fn is_supported(&self) -> bool {
|
||||
self.major_minor().is_some_and(|v| v >= MIN_FW_VERSION)
|
||||
}
|
||||
|
||||
fn major_minor(&self) -> Option<(u32, u32)> {
|
||||
let mut parts = self.version.split('.');
|
||||
let major = parts.next()?.parse().ok()?;
|
||||
let minor = parts.next()?;
|
||||
let digits = minor
|
||||
.find(|c: char| !c.is_ascii_digit())
|
||||
.map_or(minor, |end| &minor[..end]);
|
||||
Some((major, digits.parse().ok()?))
|
||||
}
|
||||
}
|
||||
|
||||
/// A named arming recipe (see [`FirmwareControl`] `arm_*` helpers). Lets a
|
||||
/// caller pick a mode by value (e.g. from a CLI flag or a config field) and
|
||||
/// apply it uniformly.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ArmRecipe {
|
||||
/// [`FirmwareControl::arm_oem_bd`].
|
||||
OemBd,
|
||||
/// [`FirmwareControl::arm_oem_uhd`].
|
||||
OemUhd,
|
||||
/// [`FirmwareControl::arm_bypass_bd`].
|
||||
BypassBd,
|
||||
/// [`FirmwareControl::arm_bypass_uhd`].
|
||||
BypassUhd,
|
||||
// NOTE: Feature::Unrestricted unifies former BD+UHD gates (fw 0.9.2); recipe
|
||||
// names above name the disc TYPE being ripped, not the retired per-format
|
||||
// feature — see `arm_oem_uhd`/`arm_bypass_uhd`.
|
||||
/// [`FirmwareControl::arm_stealth_oem`].
|
||||
StealthOem,
|
||||
}
|
||||
|
||||
impl ArmRecipe {
|
||||
/// Whether the recipe sets `Encryption = off`: the drive then acts
|
||||
/// pre-authenticated and serves de-bussed content (no cert AKE needed).
|
||||
pub(crate) fn disables_encryption(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
ArmRecipe::OemUhd | ArmRecipe::BypassBd | ArmRecipe::BypassUhd
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── FirmwareControl ──────────────────────────────────────────────────────────
|
||||
|
||||
/// A timeout every firmware command uses (ms). Matches the wider unlocker.
|
||||
const CMD_TIMEOUT_MS: u32 = 5_000;
|
||||
|
||||
/// An ergonomic, typed driver for the freemkv vendor-command grammar over any
|
||||
/// [`ScsiTransport`]. Borrows the transport for the lifetime of the control.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use freemkv_unlock::firmware::{FirmwareControl, FirmwareError};
|
||||
/// # fn demo(scsi: &mut dyn freemkv_unlock::scsi::ScsiTransport) -> Result<(), FirmwareError> {
|
||||
/// let mut fw = FirmwareControl::new(scsi);
|
||||
/// // Encryption off on a UHD disc, revocation ignored. Refused with
|
||||
/// // `UnsupportedFirmware` unless IDENTITY reports a supported freemkv drive;
|
||||
/// // `Transport` (dead bus) must abort the caller.
|
||||
/// fw.arm_oem_uhd()?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub struct FirmwareControl<'a> {
|
||||
scsi: &'a mut dyn ScsiTransport,
|
||||
}
|
||||
|
||||
impl<'a> FirmwareControl<'a> {
|
||||
/// Wrap a transport.
|
||||
pub fn new(scsi: &'a mut dyn ScsiTransport) -> Self {
|
||||
FirmwareControl { scsi }
|
||||
}
|
||||
|
||||
/// Issue a CDB with a data-in phase, returning the bytes transferred into a
|
||||
/// fixed [`MEMREAD_LEN`]-byte buffer.
|
||||
fn exec_in(&mut self, cdb: &[u8; CDB_LEN]) -> Result<([u8; MEMREAD_LEN], usize)> {
|
||||
let mut buf = [0u8; MEMREAD_LEN];
|
||||
match self
|
||||
.scsi
|
||||
.execute(cdb, DataDirection::FromDevice, &mut buf, CMD_TIMEOUT_MS)
|
||||
{
|
||||
Ok(r) if r.status == 0 => Ok((buf, r.bytes_transferred)),
|
||||
Ok(_) => Err(FirmwareError::Rejected),
|
||||
Err(e) => Err(if is_dead_bus(&e) {
|
||||
FirmwareError::Transport
|
||||
} else {
|
||||
FirmwareError::Rejected
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send IDENTITY and parse the magic + version banner.
|
||||
/// `Ok(None)` if the reply lacked [`RESP_MAGIC`] or the drive refused the
|
||||
/// command (not freemkv firmware); `Err(Transport)` only on a dead bus.
|
||||
pub fn identity(&mut self) -> Result<Option<FirmwareIdentity>> {
|
||||
let cdb = build_identity_cdb(MEMREAD_LEN as u16);
|
||||
match self.exec_in(&cdb) {
|
||||
Ok((buf, n)) => Ok(FirmwareIdentity::parse(&buf[..n.min(buf.len())])),
|
||||
// A drive that rejects the knock is simply not freemkv firmware.
|
||||
Err(FirmwareError::Rejected) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: is this a freemkv-firmware drive? `false` on a rejection or a
|
||||
/// no-magic reply; `Err(Transport)` on a dead bus.
|
||||
pub fn is_freemkv(&mut self) -> Result<bool> {
|
||||
Ok(self.identity()?.is_some())
|
||||
}
|
||||
|
||||
/// Read one feature's current state byte (GET).
|
||||
pub fn get(&mut self, feature: Feature) -> Result<u8> {
|
||||
let cdb = build_get_cdb(feature);
|
||||
let (buf, n) = self.exec_in(&cdb)?;
|
||||
if n == 0 {
|
||||
return Err(FirmwareError::Rejected);
|
||||
}
|
||||
Ok(buf[0])
|
||||
}
|
||||
|
||||
/// Set one feature to an explicit state byte (SET).
|
||||
///
|
||||
/// Issued with a data-in phase (`exec_in`), not `exec_none`: the drive's
|
||||
/// READ BUFFER hijack aborts ANY vendor verb that carries no data-in
|
||||
/// transfer — the `MIN_ALLOC_LEN` floor in the CDB is only honored when the
|
||||
/// host actually reads that many bytes back. The returned bytes are the
|
||||
/// echoed state frame; we discard them (GET is the read path).
|
||||
pub fn set(&mut self, feature: Feature, state: u8) -> Result<()> {
|
||||
let cdb = build_set_cdb(feature, state);
|
||||
self.exec_in(&cdb).map(|_| ())
|
||||
}
|
||||
|
||||
/// Restore every feature to [`STATE_PASSTHROUGH`] (RESET with [`RESET_TO_OEM`])
|
||||
/// — TRUE OEM: firmware ≥0.8.3 also blanks the NV block so the drive is
|
||||
/// traceless. Data-in phase for the same reason as [`set`](Self::set) — a no-data
|
||||
/// RESET is aborted.
|
||||
pub fn reset(&mut self) -> Result<()> {
|
||||
let cdb = build_reset_cdb(RESET_TO_OEM);
|
||||
self.exec_in(&cdb).map(|_| ())
|
||||
}
|
||||
|
||||
/// Restore the baked create-time defaults into the RAM feature-state table
|
||||
/// (RESET with [`RESET_TO_DEFAULTS`]) — what a never-saved drive boots to; does
|
||||
/// not touch flash. Firmware ≥0.8.3. Data-in phase for the same reason as
|
||||
/// [`reset`](Self::reset).
|
||||
pub fn reset_to_defaults(&mut self) -> Result<()> {
|
||||
let cdb = build_reset_cdb(RESET_TO_DEFAULTS);
|
||||
self.exec_in(&cdb).map(|_| ())
|
||||
}
|
||||
|
||||
/// Reload the saved flash config block back into the RAM feature-state table
|
||||
/// (RESET with [`RESET_TO_FLASH`]), discarding any un-saved RAM changes — the
|
||||
/// non-destructive "revert to last SAVE". Data-in phase for the same reason as
|
||||
/// [`reset`](Self::reset).
|
||||
pub fn reset_to_flash(&mut self) -> Result<()> {
|
||||
let cdb = build_reset_cdb(RESET_TO_FLASH);
|
||||
self.exec_in(&cdb).map(|_| ())
|
||||
}
|
||||
|
||||
/// Persist the whole RAM feature-state table to the flash config block (SAVE)
|
||||
/// — the only verb that writes config to flash, so a host's SET changes survive
|
||||
/// a power cycle only once saved. Data-in phase for the same reason as
|
||||
/// [`reset`](Self::reset).
|
||||
pub fn save(&mut self) -> Result<()> {
|
||||
let cdb = build_save_cdb();
|
||||
self.exec_in(&cdb).map(|_| ())
|
||||
}
|
||||
|
||||
/// SET a feature, then GET it back and confirm the state stuck. Blind spot:
|
||||
/// fw answers a refused/out-of-range GET with GOOD + zeros, which reads as
|
||||
/// [`STATE_OFF`]; the recipes gate on [`Self::require_supported`] first.
|
||||
fn set_verify(&mut self, feature: Feature, state: u8) -> Result<()> {
|
||||
self.set(feature, state)?;
|
||||
let got = self.get(feature)?;
|
||||
if got == state {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(FirmwareError::VerifyFailed {
|
||||
feature,
|
||||
wanted: state,
|
||||
got,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse (`UnsupportedFirmware`) unless IDENTITY reports a supported
|
||||
/// freemkv firmware; every recipe calls this before its first SET/RESET.
|
||||
fn require_supported(&mut self) -> Result<()> {
|
||||
match self.identity()? {
|
||||
Some(id) if id.is_supported() => Ok(()),
|
||||
_ => Err(FirmwareError::UnsupportedFirmware),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the current state of every feature via repeated GET.
|
||||
pub fn states(&mut self) -> Result<FeatureStates> {
|
||||
let mut s = FeatureStates::all_passthrough();
|
||||
for feature in ALL_FEATURES {
|
||||
s.set(feature, self.get(feature)?);
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Diagnostic RAM peek: [`MEMREAD_LEN`] bytes at `addr` (DUMPALL).
|
||||
pub fn dump(&mut self, addr: u32) -> Result<[u8; MEMREAD_LEN]> {
|
||||
let cdb = build_memread_cdb(addr);
|
||||
let (buf, n) = self.exec_in(&cdb)?;
|
||||
if n < MEMREAD_LEN {
|
||||
return Err(FirmwareError::Rejected);
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
// ── Typed setters ────────────────────────────────────────────────────────
|
||||
|
||||
/// Force the Unrestricted (BD/UHD) capability gate on (drive engages both
|
||||
/// BD and UHD discs). fw 0.9.2 unified the former separate UHD/BD gates
|
||||
/// into this single lever.
|
||||
pub fn enable_unrestricted(&mut self) -> Result<()> {
|
||||
self.set(Feature::Unrestricted, STATE_ON)
|
||||
}
|
||||
/// Force the Unrestricted (BD/UHD) capability gate off.
|
||||
pub fn disable_unrestricted(&mut self) -> Result<()> {
|
||||
self.set(Feature::Unrestricted, STATE_OFF)
|
||||
}
|
||||
/// Skip the HRL lookup (revoked certs accepted; non-destructive). The unlock
|
||||
/// direction is now [`STATE_OFF`] (`0x00` = HRL enforcement off).
|
||||
pub fn skip_hrl(&mut self) -> Result<()> {
|
||||
self.set(Feature::Hrl, STATE_OFF)
|
||||
}
|
||||
/// Disable every drive-side encryption/cert requirement in one shot: the
|
||||
/// drive acts pre-authenticated (no handshake) AND content returns de-bussed
|
||||
/// (no in-transit bus encryption). The consolidated `Encryption` lever
|
||||
/// replaces the old separate `Ake`/`Bus` levers — wire id `0x07` (`Bus`) is
|
||||
/// retired. The unlock direction is [`STATE_OFF`] (`0x00`).
|
||||
pub fn disable_encryption(&mut self) -> Result<()> {
|
||||
self.set(Feature::Encryption, STATE_OFF)
|
||||
}
|
||||
/// Region-free (RPC-1). Sends [`REGION_FREE`] (`0x0F`) — under the migrated
|
||||
/// region scheme `0x01` now means "force DVD region 1", not region-free.
|
||||
pub fn region_free(&mut self) -> Result<()> {
|
||||
self.set(Feature::Region, REGION_FREE)
|
||||
}
|
||||
/// Force a specific Blu-ray region.
|
||||
pub fn force_region_bd(&mut self, region: BdRegion) -> Result<()> {
|
||||
self.set(Feature::Region, region.state())
|
||||
}
|
||||
/// Force a specific DVD region (1..=8). Returns
|
||||
/// [`FirmwareError::InvalidArgument`] for an out-of-range region without
|
||||
/// touching the drive.
|
||||
pub fn force_region_dvd(&mut self, region: u8) -> Result<()> {
|
||||
if !(1..=8).contains(®ion) {
|
||||
return Err(FirmwareError::InvalidArgument);
|
||||
}
|
||||
self.set(Feature::Region, REGION_DVD_BASE + region)
|
||||
}
|
||||
/// Lift the read-speed / riplock ceiling to maximum.
|
||||
pub fn unlock_speed(&mut self) -> Result<()> {
|
||||
self.set(Feature::Speed, SPEED_MAX)
|
||||
}
|
||||
|
||||
// ── Named recipes (the "modes" table) ─────────────────────────────────────
|
||||
// Each recipe first issues IDENTITY and refuses unsupported firmware. A
|
||||
// recipe that fails midway leaves its earlier SETs applied (no rollback).
|
||||
|
||||
/// **arm_oem_bd** — OEM-style Blu-ray (AACS 1.0) rip with a REAL AKE, only
|
||||
/// revocation disabled.
|
||||
///
|
||||
/// Sets: `Hrl = skip` (revoked host certs accepted; non-destructive).
|
||||
/// Leaves everything else at passthrough — the drive still runs the real
|
||||
/// host-cert handshake and returns bus-encrypted content, so the host
|
||||
/// performs the AKE and de-busses. Rips: BD with an otherwise-revoked cert.
|
||||
pub fn arm_oem_bd(&mut self) -> Result<()> {
|
||||
self.require_supported()?;
|
||||
self.set_verify(Feature::Hrl, STATE_OFF)
|
||||
}
|
||||
|
||||
/// **arm_oem_uhd** — OEM-style UHD (AACS 2.0) rip.
|
||||
///
|
||||
/// Sets: `Unrestricted = on` (mode-gate neutralized so the drive engages
|
||||
/// the UHD disc), `Hrl = skip` (revocation off), `Encryption = off`
|
||||
/// (content de-bussed — the consolidated cert/bus bypass). Rips: UHD via
|
||||
/// the OEM path.
|
||||
pub fn arm_oem_uhd(&mut self) -> Result<()> {
|
||||
self.require_supported()?;
|
||||
self.set_verify(Feature::Unrestricted, STATE_ON)?;
|
||||
self.set_verify(Feature::Hrl, STATE_OFF)?;
|
||||
self.set_verify(Feature::Encryption, STATE_OFF)
|
||||
}
|
||||
|
||||
/// **arm_bypass_bd** — full-bypass Blu-ray rip (NO host cert needed).
|
||||
///
|
||||
/// Sets: `Encryption = off` (the consolidated cert/bus bypass — drive acts
|
||||
/// pre-authenticated, no handshake, content de-bussed) and `Hrl = skip`
|
||||
/// (revocation off). Rips: BD with no cert.
|
||||
pub fn arm_bypass_bd(&mut self) -> Result<()> {
|
||||
self.require_supported()?;
|
||||
self.set_verify(Feature::Hrl, STATE_OFF)?;
|
||||
self.set_verify(Feature::Encryption, STATE_OFF)
|
||||
}
|
||||
|
||||
/// **arm_bypass_uhd** — full-bypass UHD rip (NO host cert needed).
|
||||
///
|
||||
/// Sets: `Unrestricted = on` (engage the UHD disc), `Encryption = off`
|
||||
/// (the consolidated cert/bus bypass), and `Hrl = skip` (revocation off).
|
||||
/// Rips: UHD with no cert.
|
||||
pub fn arm_bypass_uhd(&mut self) -> Result<()> {
|
||||
self.require_supported()?;
|
||||
self.set_verify(Feature::Unrestricted, STATE_ON)?;
|
||||
self.set_verify(Feature::Hrl, STATE_OFF)?;
|
||||
self.set_verify(Feature::Encryption, STATE_OFF)
|
||||
}
|
||||
|
||||
/// **arm_stealth_oem** — return the drive to byte-for-byte OEM behaviour.
|
||||
///
|
||||
/// Issues RESET (every feature → passthrough) and verifies each feature read
|
||||
/// back as [`STATE_PASSTHROUGH`]. Rips: nothing — this DISARMS the drive.
|
||||
/// Needs fw >= [`MIN_FW_VERSION`]; raw [`reset`](Self::reset) has no gate.
|
||||
pub fn arm_stealth_oem(&mut self) -> Result<()> {
|
||||
self.require_supported()?;
|
||||
self.reset()?;
|
||||
for feature in ALL_FEATURES {
|
||||
let got = self.get(feature)?;
|
||||
if got != STATE_PASSTHROUGH {
|
||||
return Err(FirmwareError::VerifyFailed {
|
||||
feature,
|
||||
wanted: STATE_PASSTHROUGH,
|
||||
got,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply a named [`ArmRecipe`].
|
||||
pub fn arm(&mut self, recipe: ArmRecipe) -> Result<()> {
|
||||
match recipe {
|
||||
ArmRecipe::OemBd => self.arm_oem_bd(),
|
||||
ArmRecipe::OemUhd => self.arm_oem_uhd(),
|
||||
ArmRecipe::BypassBd => self.arm_bypass_bd(),
|
||||
ArmRecipe::BypassUhd => self.arm_bypass_uhd(),
|
||||
ArmRecipe::StealthOem => self.arm_stealth_oem(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test fixture: the fw 0.9.x IDENTITY wire shape (core.rs `identity_blob` +
|
||||
/// handler): `freemkv <ver>` with NO NUL, then the 6 raw flag bytes
|
||||
/// `flag[0x01..=0x06]`, then zero padding to the 64-byte window.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn identity_reply(version: &str, flags: [u8; 6]) -> Vec<u8> {
|
||||
let mut p = RESP_MAGIC.to_vec();
|
||||
p.push(b' ');
|
||||
p.extend_from_slice(version.as_bytes());
|
||||
p.extend_from_slice(&flags);
|
||||
p.resize(MEMREAD_LEN, 0);
|
||||
p
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,837 @@
|
||||
//! Tests for the firmware vendor-command mirror + [`FirmwareControl`].
|
||||
|
||||
use super::*;
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
use crate::scsi::{DataDirection, Result as ScsiTResult, ScsiResult, ScsiTransport};
|
||||
|
||||
// ── Pinned-value drift guards (vs. freemkv-fw/src/abi.rs) ────────────────────
|
||||
|
||||
#[test]
|
||||
fn verb_values_match_abi() {
|
||||
assert_eq!(Verb::Identity as u8, 0x01);
|
||||
assert_eq!(Verb::Set as u8, 0x02);
|
||||
assert_eq!(Verb::Get as u8, 0x03);
|
||||
assert_eq!(Verb::Reset as u8, 0x04);
|
||||
assert_eq!(Verb::DumpAll as u8, 0x09);
|
||||
assert_eq!(Verb::FlashWrite as u8, 0x0A);
|
||||
assert_eq!(Verb::Save as u8, 0x0B);
|
||||
assert_eq!(Verb::Call as u8, 0x0C);
|
||||
assert_eq!(Verb::Poke as u8, 0x0D);
|
||||
assert_eq!(Verb::Reboot as u8, 0x0F);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_values_match_abi() {
|
||||
assert_eq!(Feature::Speed as u8, 0x01);
|
||||
assert_eq!(Feature::Region as u8, 0x02);
|
||||
assert_eq!(Feature::Unrestricted as u8, 0x03);
|
||||
assert_eq!(Feature::Hrl as u8, 0x05);
|
||||
assert_eq!(Feature::Encryption as u8, 0x06);
|
||||
// Wire id 0x04 (`Bd`, deprecated in fw 0.9.2) is deliberately not exposed.
|
||||
// Wire id 0x07 (was `Bus`) is retired — proven inert on BU40N/MT1959.
|
||||
assert_eq!(ALL_FEATURES.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_knock_distinct_from_safe_knock() {
|
||||
// The fw dispatches Call/Poke ONLY under DEBUG_KNOCK; a typo of a safe verb
|
||||
// vs a debug verb under the wrong knock never crosses the line.
|
||||
assert_ne!(KNOCK, DEBUG_KNOCK);
|
||||
assert_eq!(DEBUG_KNOCK, [0xDE, 0xB9]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_and_frame_constants_match_abi() {
|
||||
assert_eq!(READ_BUFFER_OPCODE, 0x3C);
|
||||
assert_eq!(KNOCK_MODE, 0x0E);
|
||||
assert_eq!(KNOCK, [0xC0, 0xDE]);
|
||||
assert_eq!(RESP_MAGIC, b"freemkv");
|
||||
assert_eq!(CDB_LEN, 10);
|
||||
assert_eq!(MEMREAD_LEN, 64);
|
||||
assert_eq!(MIN_ALLOC_LEN, 64);
|
||||
assert_eq!(STATE_PASSTHROUGH, 0xFF);
|
||||
assert_eq!(STATE_OFF, 0x00);
|
||||
assert_eq!(STATE_ON, 0x01);
|
||||
assert_eq!(SPEED_MAX, 0x00);
|
||||
assert_eq!([REGION_BD_A, REGION_BD_B, REGION_BD_C], [0x0A, 0x0B, 0x0C]);
|
||||
assert_eq!(REGION_DVD_BASE, 0x00);
|
||||
assert_eq!(REGION_FREE, 0x0F);
|
||||
assert_eq!(RESET_TO_FLASH, 0x00);
|
||||
assert_eq!(RESET_TO_DEFAULTS, 0x01);
|
||||
assert_eq!(RESET_TO_OEM, 0xFF);
|
||||
}
|
||||
|
||||
// ── CDB builders ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn build_set_cdb_exact_bytes() {
|
||||
// SET Encryption = ON: verb 02, feature 06, state 01, alloc MIN_ALLOC_LEN
|
||||
// (0x0040) at cdb[7..9] big-endian — the drive aborts a sub-16-byte data-in.
|
||||
assert_eq!(
|
||||
build_set_cdb(Feature::Encryption, STATE_ON),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x06, 0x01, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_get_cdb_exact_bytes() {
|
||||
// GET Encryption: verb 03, feature 06, alloc MIN_ALLOC_LEN (0x0040) at
|
||||
// cdb[7..9] big-endian — the drive aborts a 1-byte data-in; state read
|
||||
// from offset 0.
|
||||
assert_eq!(
|
||||
build_get_cdb(Feature::Encryption),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x03, 0x06, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendor_commands_floor_alloc_len_at_min_for_hw() {
|
||||
// HW-confirmed: the drive aborts a vendor command with a sub-16-byte data-in
|
||||
// allocation, so SET/GET/RESET all request at least MIN_ALLOC_LEN (= 64,
|
||||
// comfortably above the ~16-byte hardware floor).
|
||||
assert_eq!(MIN_ALLOC_LEN, 64);
|
||||
let alloc =
|
||||
|cdb: &[u8; CDB_LEN]| u16::from_be_bytes([cdb[CDB_ALLOC_LEN], cdb[CDB_ALLOC_LEN + 1]]);
|
||||
assert_eq!(
|
||||
alloc(&build_set_cdb(Feature::Encryption, STATE_ON)),
|
||||
MIN_ALLOC_LEN
|
||||
);
|
||||
assert_eq!(alloc(&build_get_cdb(Feature::Encryption)), MIN_ALLOC_LEN);
|
||||
assert_eq!(alloc(&build_reset_cdb(RESET_TO_OEM)), MIN_ALLOC_LEN);
|
||||
assert_eq!(alloc(&build_save_cdb()), MIN_ALLOC_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_call_and_poke_cdb_exact_bytes() {
|
||||
// Call: verb 0x0C at cdb[4], DEBUG_KNOCK at cdb[2..4], target big-endian
|
||||
// in cdb[5..9], r0 in cdb[9].
|
||||
assert_eq!(
|
||||
build_call_cdb(0x0102_0304, 0xAB),
|
||||
[0x3C, 0x0E, 0xDE, 0xB9, 0x0C, 0x01, 0x02, 0x03, 0x04, 0xAB]
|
||||
);
|
||||
// Poke: verb 0x0D at cdb[4], DEBUG_KNOCK at cdb[2..4], target big-endian
|
||||
// in cdb[5..9], value in cdb[9].
|
||||
assert_eq!(
|
||||
build_poke_cdb(0x0200_0E40, 0xAA),
|
||||
[0x3C, 0x0E, 0xDE, 0xB9, 0x0D, 0x02, 0x00, 0x0E, 0x40, 0xAA]
|
||||
);
|
||||
// Reboot: verb 0x0F at cdb[4], DEBUG_KNOCK at cdb[2..4], no on-wire target
|
||||
// (the boot-entry VA is baked into the handler at build time). Alloc floors
|
||||
// at MIN_ALLOC_LEN (0x0040 at cdb[7..9]) so the data-in is not aborted.
|
||||
assert_eq!(
|
||||
build_reboot_cdb(),
|
||||
[0x3C, 0x0E, 0xDE, 0xB9, 0x0F, 0x00, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_reset_and_identity_cdb_exact_bytes() {
|
||||
// RESET carries its mode in the state slot (cdb[6]) and floors its data-in at
|
||||
// MIN_ALLOC_LEN (0x0040) for the same HW reason: to-OEM = 0xFF, to-flash = 0x00.
|
||||
assert_eq!(
|
||||
build_reset_cdb(RESET_TO_OEM),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x00, 0xFF, 0x00, 0x40, 0x00]
|
||||
);
|
||||
assert_eq!(
|
||||
build_reset_cdb(RESET_TO_FLASH),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x00, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
// SAVE takes no feature/state and floors its data-in at MIN_ALLOC_LEN.
|
||||
assert_eq!(
|
||||
build_save_cdb(),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x0B, 0x00, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
// IDENTITY with a 64-byte allocation (0x0040 big-endian at cdb[7..9]).
|
||||
assert_eq!(
|
||||
build_identity_cdb(MEMREAD_LEN as u16),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x01, 0x00, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_cdb_encodes_alloc_len_16bit_big_endian() {
|
||||
let cdb = build_cdb(Verb::Identity, None, None, 0x1234);
|
||||
assert_eq!([cdb[CDB_ALLOC_LEN], cdb[CDB_ALLOC_LEN + 1]], [0x12, 0x34]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_memread_cdb_packs_address_big_endian_at_5_to_9() {
|
||||
let cdb = build_memread_cdb(0xDEAD_BEEF);
|
||||
assert_eq!(cdb[CDB_VERB], Verb::DumpAll as u8);
|
||||
assert_eq!([cdb[5], cdb[6], cdb[7], cdb[8]], [0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
assert_eq!(cdb[9], 0x00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_state_bytes() {
|
||||
assert_eq!(BdRegion::A.state(), 0x0A);
|
||||
assert_eq!(BdRegion::B.state(), 0x0B);
|
||||
assert_eq!(BdRegion::C.state(), 0x0C);
|
||||
}
|
||||
|
||||
// ── Identity / states parsing ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn identity_parse_reads_version_banner() {
|
||||
let payload = identity_reply("0.9.2", [0x00; 6]);
|
||||
let id = FirmwareIdentity::parse(&payload).expect("has magic");
|
||||
assert_eq!(id.version, "0.9.2");
|
||||
}
|
||||
|
||||
/// Regression: the appended flag table (fresh-boot DEFAULT_FLAGS here) must not
|
||||
/// leak into the version, whatever the live feature states are.
|
||||
#[test]
|
||||
fn identity_parse_ignores_the_appended_flag_table() {
|
||||
for flags in [
|
||||
[0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF],
|
||||
[0x0F, 0x0F, 0x01, 0xFF, 0x00, 0x00],
|
||||
] {
|
||||
let id = FirmwareIdentity::parse(&identity_reply("0.9.2", flags)).expect("has magic");
|
||||
assert_eq!(id.version, "0.9.2", "flags {flags:02X?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Released fw 0.7.1 answers `freemkv 0.7.1` + zero padding: parsed, but
|
||||
/// below MIN_FW_VERSION.
|
||||
#[test]
|
||||
fn identity_parse_legacy_07_is_unsupported() {
|
||||
let mut payload = b"freemkv 0.7.1".to_vec();
|
||||
payload.resize(MEMREAD_LEN, 0);
|
||||
let id = FirmwareIdentity::parse(&payload).expect("has magic");
|
||||
assert_eq!(id.version, "0.7.1");
|
||||
assert!(!id.is_supported());
|
||||
}
|
||||
|
||||
/// Magic with no ` <version>` token parses as freemkv with an empty version.
|
||||
#[test]
|
||||
fn identity_parse_without_a_token_has_an_empty_version() {
|
||||
let mut payload = RESP_MAGIC.to_vec();
|
||||
payload.push(0x01);
|
||||
payload.resize(MEMREAD_LEN, 0);
|
||||
let id = FirmwareIdentity::parse(&payload).expect("has magic");
|
||||
assert_eq!(id.version, "");
|
||||
assert!(!id.is_supported());
|
||||
}
|
||||
|
||||
/// The supported-grammar predicate: 0.9+ only; 0.8.x, 0.7.x, an empty token
|
||||
/// and garbage are refused. A trailing flag char never moves minor.
|
||||
#[test]
|
||||
fn identity_is_supported_only_from_min_fw_version() {
|
||||
let id = |v: &str| FirmwareIdentity {
|
||||
version: v.to_string(),
|
||||
};
|
||||
for v in ["0.9.0", "0.9.2", "0.9.2-rc1", "0.9.22", "0.10.0", "1.0.0"] {
|
||||
assert!(id(v).is_supported(), "{v}");
|
||||
}
|
||||
for v in ["0.8.3", "0.8.14", "0.7.1", "", "0", "0.", "x.9.0", "0.x"] {
|
||||
assert!(!id(v).is_supported(), "{v}");
|
||||
}
|
||||
assert_eq!(MIN_FW_VERSION, (0, 9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_parse_none_without_magic() {
|
||||
assert!(FirmwareIdentity::parse(&[0u8; 64]).is_none());
|
||||
}
|
||||
|
||||
// ── A stateful firmware mock ─────────────────────────────────────────────────
|
||||
|
||||
/// A mock freemkv drive: answers IDENTITY with the real fw shape (banner + flag
|
||||
/// table, see [`identity_reply`]), GET with the requested feature's state,
|
||||
/// applies SET, and models the three RESET modes. Records every CDB.
|
||||
///
|
||||
/// It also models the real hardware constraint: any vendor verb executed with
|
||||
/// [`DataDirection::None`] (no data-in phase) is REJECTED with a non-zero
|
||||
/// status, exactly as the drive's READ BUFFER hijack aborts a no-data verb.
|
||||
struct FwMock {
|
||||
version: &'static str,
|
||||
states: FeatureStates,
|
||||
/// The baked create-time defaults (fw `DEFAULT_FLAGS`: Unrestricted on).
|
||||
defaults: FeatureStates,
|
||||
/// The saved flash config block (`None` = never saved: RESET_TO_FLASH
|
||||
/// then loads `defaults`, as the marker-gated fw >= 0.8.3 does).
|
||||
saved: Option<FeatureStates>,
|
||||
is_freemkv: bool,
|
||||
/// Models a drive that ACKs RESET but leaves its RAM flags unchanged.
|
||||
ignore_reset: bool,
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl FwMock {
|
||||
fn new() -> Self {
|
||||
let defaults = FeatureStates {
|
||||
unrestricted: STATE_ON,
|
||||
..FeatureStates::all_passthrough()
|
||||
};
|
||||
FwMock {
|
||||
version: "0.9.2",
|
||||
states: FeatureStates::all_passthrough(),
|
||||
defaults,
|
||||
saved: None,
|
||||
is_freemkv: true,
|
||||
ignore_reset: false,
|
||||
cdbs: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn feature_of(id: u8) -> Feature {
|
||||
*ALL_FEATURES
|
||||
.iter()
|
||||
.find(|f| **f as u8 == id)
|
||||
.expect("known feature id")
|
||||
}
|
||||
/// The verbs issued, in order (cdb[4] of each recorded CDB).
|
||||
fn verbs(&self) -> Vec<u8> {
|
||||
self.cdbs.iter().map(|c| c[CDB_VERB]).collect()
|
||||
}
|
||||
/// The verbs issued after the leading IDENTITY gate of an arm recipe.
|
||||
fn verbs_after_identity(&self) -> Vec<u8> {
|
||||
assert_eq!(self.cdbs[0][CDB_VERB], Verb::Identity as u8);
|
||||
self.verbs()[1..].to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
impl ScsiTransport for FwMock {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> ScsiTResult<ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
// Model the hardware: a vendor verb with no data-in phase is aborted.
|
||||
// This is what makes the old `exec_none` SET/RESET path a test failure.
|
||||
let is_vendor = cdb[CDB_OPCODE] == READ_BUFFER_OPCODE && cdb[CDB_MODE] == KNOCK_MODE;
|
||||
if is_vendor && matches!(dir, DataDirection::None) {
|
||||
return Ok(ScsiResult {
|
||||
status: 0x02, // CHECK CONDITION — rejected
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
let mut transferred = 0usize;
|
||||
match cdb[CDB_VERB] {
|
||||
v if v == Verb::Identity as u8 => {
|
||||
// Non-freemkv: GOOD status but no magic (buffer stays zero).
|
||||
if self.is_freemkv {
|
||||
let s = self.states;
|
||||
// Slot 0x04 (`Bd`, unexposed) at its DEFAULT_FLAGS value.
|
||||
let flags = [
|
||||
s.speed,
|
||||
s.region,
|
||||
s.unrestricted,
|
||||
STATE_ON,
|
||||
s.hrl,
|
||||
s.encryption,
|
||||
];
|
||||
let resp = identity_reply(self.version, flags);
|
||||
let n = resp.len().min(data.len());
|
||||
data[..n].copy_from_slice(&resp[..n]);
|
||||
}
|
||||
transferred = data.len();
|
||||
}
|
||||
v if v == Verb::Get as u8 => {
|
||||
let f = Self::feature_of(cdb[CDB_FEATURE]);
|
||||
if !data.is_empty() {
|
||||
data[0] = self.states.get(f);
|
||||
transferred = 1;
|
||||
}
|
||||
}
|
||||
v if v == Verb::Set as u8 => {
|
||||
let f = Self::feature_of(cdb[CDB_FEATURE]);
|
||||
self.states.set(f, cdb[CDB_STATE]);
|
||||
}
|
||||
v if v == Verb::Reset as u8 && !self.ignore_reset => {
|
||||
// The mode rides in the state slot (cdb[6]).
|
||||
self.states = match cdb[CDB_STATE] {
|
||||
RESET_TO_FLASH => self.saved.unwrap_or(self.defaults),
|
||||
RESET_TO_DEFAULTS => self.defaults,
|
||||
_ => FeatureStates::all_passthrough(),
|
||||
};
|
||||
}
|
||||
v if v == Verb::Save as u8 => {
|
||||
self.saved = Some(self.states);
|
||||
}
|
||||
v if v == Verb::DumpAll as u8 => {
|
||||
for (i, b) in data.iter_mut().enumerate() {
|
||||
*b = i as u8;
|
||||
}
|
||||
transferred = data.len();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: transferred,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── FirmwareControl behaviour ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn identity_detects_freemkv_and_issues_identity_cdb() {
|
||||
let mut m = FwMock::new();
|
||||
m.states.encryption = STATE_ON;
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
let id = fw.identity().expect("no fault").expect("is freemkv");
|
||||
assert_eq!(id.version, "0.9.2");
|
||||
// The appended flag table is not parsed; states are read via GET.
|
||||
assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_ON);
|
||||
assert!(fw.is_freemkv().expect("no fault"));
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_identity_cdb(MEMREAD_LEN as u16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_none_on_non_freemkv() {
|
||||
let mut m = FwMock::new();
|
||||
m.is_freemkv = false;
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
assert!(fw.identity().expect("no fault").is_none());
|
||||
assert!(!fw.is_freemkv().expect("no fault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_none_when_drive_rejects() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let mut fw = FirmwareControl::new(&mut t);
|
||||
assert!(fw.identity().expect("rejection is not a fault").is_none());
|
||||
}
|
||||
|
||||
/// A refusal carrying a full magic-bearing buffer must still be `None`: the
|
||||
/// status guard, not the empty transfer, decides.
|
||||
#[test]
|
||||
fn identity_none_when_status_is_not_good_despite_a_magic_buffer() {
|
||||
struct CheckConditionWithData;
|
||||
impl ScsiTransport for CheckConditionWithData {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> ScsiTResult<ScsiResult> {
|
||||
let resp = identity_reply("0.9.2", [0xFF; 6]);
|
||||
data.copy_from_slice(&resp[..data.len()]);
|
||||
Ok(ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut t = CheckConditionWithData;
|
||||
assert!(
|
||||
FirmwareControl::new(&mut t)
|
||||
.identity()
|
||||
.expect("not a fault")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_transport_fault_propagates() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let mut fw = FirmwareControl::new(&mut t);
|
||||
assert_eq!(fw.identity().unwrap_err(), FirmwareError::Transport);
|
||||
}
|
||||
|
||||
/// Every data-in verb maps a drive refusal to `Rejected`, whether the transport
|
||||
/// reports it as `Ok` + CHECK CONDITION or as `Err` carrying a sense.
|
||||
#[test]
|
||||
fn every_verb_maps_both_rejection_shapes_to_rejected() {
|
||||
for reply in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
|
||||
let mut t = MockTransport::always(reply.clone());
|
||||
let mut fw = FirmwareControl::new(&mut t);
|
||||
let r = FirmwareError::Rejected;
|
||||
assert_eq!(fw.set(Feature::Encryption, STATE_OFF), Err(r.clone()));
|
||||
assert_eq!(fw.get(Feature::Encryption), Err(r.clone()));
|
||||
assert_eq!(fw.reset(), Err(r.clone()));
|
||||
assert_eq!(fw.reset_to_defaults(), Err(r.clone()));
|
||||
assert_eq!(fw.reset_to_flash(), Err(r.clone()));
|
||||
assert_eq!(fw.save(), Err(r.clone()));
|
||||
assert_eq!(fw.dump(0), Err(r), "{reply:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Untrusted lengths: a GOOD-status zero-byte GET is not state 0x00, and a
|
||||
/// short DUMPALL is not a RAM window padded with zeros.
|
||||
#[test]
|
||||
fn get_and_dump_reject_short_transfers() {
|
||||
let mut t = MockTransport::always(Reply::zero_transfer(MEMREAD_LEN));
|
||||
assert_eq!(
|
||||
FirmwareControl::new(&mut t).get(Feature::Encryption),
|
||||
Err(FirmwareError::Rejected)
|
||||
);
|
||||
let mut t = MockTransport::always(Reply::short(vec![0xAB; MEMREAD_LEN], MEMREAD_LEN - 1));
|
||||
assert_eq!(
|
||||
FirmwareControl::new(&mut t).dump(0),
|
||||
Err(FirmwareError::Rejected)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_and_set_roundtrip_issue_the_right_cdbs() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_PASSTHROUGH);
|
||||
fw.set(Feature::Encryption, STATE_ON).expect("set");
|
||||
assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_ON);
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_get_cdb(Feature::Encryption));
|
||||
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Encryption, STATE_ON));
|
||||
assert_eq!(m.cdbs[2], build_get_cdb(Feature::Encryption));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_sends_reset_cdb_and_clears_state() {
|
||||
let mut m = FwMock::new();
|
||||
m.states.encryption = STATE_ON;
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.reset().expect("reset");
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_reset_cdb(RESET_TO_OEM));
|
||||
assert_eq!(m.states, FeatureStates::all_passthrough());
|
||||
}
|
||||
|
||||
/// RESET_TO_DEFAULTS restores the baked create-time defaults, which are NOT
|
||||
/// all-passthrough (Unrestricted ships on).
|
||||
#[test]
|
||||
fn reset_to_defaults_restores_the_baked_defaults() {
|
||||
let mut m = FwMock::new();
|
||||
m.states.encryption = STATE_OFF;
|
||||
let states;
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.reset_to_defaults().expect("reset to defaults");
|
||||
states = fw.states().expect("states");
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_reset_cdb(RESET_TO_DEFAULTS));
|
||||
assert_eq!(states.unrestricted, STATE_ON);
|
||||
assert_eq!(states.encryption, STATE_PASSTHROUGH);
|
||||
assert_ne!(states, FeatureStates::all_passthrough());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_then_reset_to_flash_restores_the_saved_value() {
|
||||
// SET a feature, SAVE it, force all-passthrough with RESET(to-OEM), then
|
||||
// RESET(to-flash) must reload the saved value (not passthrough).
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.set(Feature::Encryption, STATE_OFF).expect("set");
|
||||
fw.save().expect("save");
|
||||
fw.reset().expect("reset to OEM");
|
||||
assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_PASSTHROUGH);
|
||||
fw.reset_to_flash().expect("reset to flash");
|
||||
assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_OFF);
|
||||
}
|
||||
// The verbs issued, in order: SET, SAVE, RESET, GET, RESET, GET.
|
||||
assert_eq!(
|
||||
m.verbs(),
|
||||
vec![
|
||||
Verb::Set as u8,
|
||||
Verb::Save as u8,
|
||||
Verb::Reset as u8,
|
||||
Verb::Get as u8,
|
||||
Verb::Reset as u8,
|
||||
Verb::Get as u8,
|
||||
]
|
||||
);
|
||||
assert_eq!(m.cdbs[1], build_save_cdb());
|
||||
assert_eq!(m.cdbs[2], build_reset_cdb(RESET_TO_OEM));
|
||||
assert_eq!(m.cdbs[4], build_reset_cdb(RESET_TO_FLASH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn states_probes_every_feature_in_order() {
|
||||
let mut m = FwMock::new();
|
||||
m.states.speed = SPEED_MAX;
|
||||
m.states.hrl = STATE_ON;
|
||||
let states;
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
states = fw.states().expect("states");
|
||||
}
|
||||
assert_eq!(states.speed, SPEED_MAX);
|
||||
assert_eq!(states.hrl, STATE_ON);
|
||||
// Five GETs, one per exposed feature, in wire-id order (0x04 `Bd` is not
|
||||
// exposed; 0x07 `Bus` is retired).
|
||||
assert_eq!(m.cdbs.len(), 5);
|
||||
let features: Vec<u8> = m.cdbs.iter().map(|c| c[CDB_FEATURE]).collect();
|
||||
assert_eq!(features, vec![0x01, 0x02, 0x03, 0x05, 0x06]);
|
||||
assert!(m.verbs().iter().all(|&v| v == Verb::Get as u8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dump_issues_memread_and_returns_the_window() {
|
||||
let mut m = FwMock::new();
|
||||
let got;
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
got = fw.dump(0x0102_0304).expect("dump");
|
||||
}
|
||||
assert_eq!(got[0], 0x00);
|
||||
assert_eq!(got[63], 63);
|
||||
assert_eq!(m.cdbs[0], build_memread_cdb(0x0102_0304));
|
||||
}
|
||||
|
||||
// ── Typed setters ────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn typed_setters_issue_expected_set_cdbs() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.enable_unrestricted().unwrap();
|
||||
fw.skip_hrl().unwrap();
|
||||
fw.disable_encryption().unwrap();
|
||||
fw.region_free().unwrap();
|
||||
fw.force_region_bd(BdRegion::B).unwrap();
|
||||
fw.force_region_dvd(2).unwrap();
|
||||
fw.unlock_speed().unwrap();
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Unrestricted, STATE_ON));
|
||||
// The HRL/Encryption unlock direction is STATE_OFF (0x00), not STATE_ON.
|
||||
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
// Region-free is REGION_FREE (0x0F) — 0x01 now forces DVD region 1.
|
||||
assert_eq!(m.cdbs[3], build_set_cdb(Feature::Region, REGION_FREE));
|
||||
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Region, REGION_BD_B));
|
||||
// force_region_dvd(2) = REGION_DVD_BASE + 2 = 0x02 under the new base.
|
||||
assert_eq!(m.cdbs[5], build_set_cdb(Feature::Region, 0x02));
|
||||
assert_eq!(m.cdbs[6], build_set_cdb(Feature::Speed, SPEED_MAX));
|
||||
}
|
||||
|
||||
/// Regression: a bad caller argument is not a drive refusal, and the bus is
|
||||
/// never touched.
|
||||
#[test]
|
||||
fn force_region_dvd_rejects_out_of_range_as_caller_error() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
for bad in [0u8, 9] {
|
||||
let err = fw.force_region_dvd(bad).unwrap_err();
|
||||
assert_eq!(err, FirmwareError::InvalidArgument, "region {bad}");
|
||||
}
|
||||
}
|
||||
assert!(m.cdbs.is_empty());
|
||||
}
|
||||
|
||||
// ── Recipes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn arm_oem_bd_sets_and_verifies_hrl_skip() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.arm_oem_bd().expect("armed");
|
||||
}
|
||||
// IDENTITY gate, then SET Hrl=OFF and GET Hrl to verify.
|
||||
assert_eq!(m.cdbs[0], build_identity_cdb(MEMREAD_LEN as u16));
|
||||
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(m.cdbs[2], build_get_cdb(Feature::Hrl));
|
||||
assert_eq!(m.states.hrl, STATE_OFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arm_oem_uhd_sets_uhd_hrl_encryption_each_verified() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.arm_oem_uhd().expect("armed");
|
||||
}
|
||||
// set,get, set,get, set,get
|
||||
assert_eq!(
|
||||
m.verbs_after_identity(),
|
||||
vec![0x02, 0x03, 0x02, 0x03, 0x02, 0x03],
|
||||
"each set is verified by a get"
|
||||
);
|
||||
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Unrestricted, STATE_ON));
|
||||
assert_eq!(m.cdbs[3], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(m.cdbs[5], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
assert_eq!(m.states.unrestricted, STATE_ON);
|
||||
assert_eq!(m.states.hrl, STATE_OFF);
|
||||
assert_eq!(m.states.encryption, STATE_OFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arm_bypass_bd_disables_encryption() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.arm_bypass_bd().expect("armed");
|
||||
}
|
||||
// Hrl=off (revocation skip) is set+verified FIRST, then Encryption=off.
|
||||
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(m.cdbs[2], build_get_cdb(Feature::Hrl));
|
||||
assert_eq!(m.cdbs[3], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
assert_eq!(m.cdbs[4], build_get_cdb(Feature::Encryption));
|
||||
assert_eq!(m.states.hrl, STATE_OFF);
|
||||
assert_eq!(m.states.encryption, STATE_OFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arm_bypass_uhd_sets_uhd_hrl_encryption() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.arm_bypass_uhd().expect("armed");
|
||||
}
|
||||
// Recipe order: Unrestricted=on, Hrl=off, Encryption=off (each SET then verifying GET).
|
||||
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Unrestricted, STATE_ON));
|
||||
assert_eq!(m.cdbs[3], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(m.cdbs[5], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
assert_eq!(m.states.unrestricted, STATE_ON);
|
||||
assert_eq!(m.states.hrl, STATE_OFF);
|
||||
assert_eq!(m.states.encryption, STATE_OFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arm_stealth_oem_resets_then_verifies_all_passthrough() {
|
||||
let mut m = FwMock::new();
|
||||
m.states.encryption = STATE_ON;
|
||||
m.states.unrestricted = STATE_ON;
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.arm_stealth_oem().expect("disarmed");
|
||||
}
|
||||
assert_eq!(m.cdbs[1], build_reset_cdb(RESET_TO_OEM));
|
||||
// then a GET of every feature
|
||||
assert_eq!(m.cdbs.len(), 2 + ALL_FEATURES.len());
|
||||
assert!(m.cdbs[2..].iter().all(|c| c[CDB_VERB] == Verb::Get as u8));
|
||||
assert_eq!(m.states, FeatureStates::all_passthrough());
|
||||
}
|
||||
|
||||
/// A drive that ACKs RESET but stays armed must not be reported as OEM.
|
||||
#[test]
|
||||
fn arm_stealth_oem_reports_a_feature_that_did_not_reset() {
|
||||
let mut m = FwMock::new();
|
||||
m.states.encryption = STATE_OFF;
|
||||
m.ignore_reset = true;
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
assert_eq!(
|
||||
fw.arm_stealth_oem().unwrap_err(),
|
||||
FirmwareError::VerifyFailed {
|
||||
feature: Feature::Encryption,
|
||||
wanted: STATE_PASSTHROUGH,
|
||||
got: STATE_OFF,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arm_by_recipe_enum_dispatches() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.arm(ArmRecipe::BypassBd).expect("armed");
|
||||
}
|
||||
assert_eq!(m.states.encryption, STATE_OFF);
|
||||
}
|
||||
|
||||
/// Regression: every recipe refuses firmware below MIN_FW_VERSION (fw 0.8.x)
|
||||
/// and a non-freemkv drive — before sending any SET/RESET.
|
||||
#[test]
|
||||
fn arm_refuses_unsupported_firmware_before_any_set() {
|
||||
let recipes = [
|
||||
ArmRecipe::OemBd,
|
||||
ArmRecipe::OemUhd,
|
||||
ArmRecipe::BypassBd,
|
||||
ArmRecipe::BypassUhd,
|
||||
ArmRecipe::StealthOem,
|
||||
];
|
||||
for recipe in recipes {
|
||||
for (version, freemkv) in [("0.8.3", true), ("0.9.2", false)] {
|
||||
let mut m = FwMock::new();
|
||||
m.version = version;
|
||||
m.is_freemkv = freemkv;
|
||||
let r = FirmwareControl::new(&mut m).arm(recipe);
|
||||
assert_eq!(
|
||||
r,
|
||||
Err(FirmwareError::UnsupportedFirmware),
|
||||
"{recipe:?} on {version}/{freemkv}"
|
||||
);
|
||||
assert_eq!(m.verbs(), vec![Verb::Identity as u8], "{recipe:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Released fw 0.7.1: same knock, `freemkv 0.7.1` reply, old sub-function
|
||||
/// grammar (0x02 = Speed). Refused, with no SET sent.
|
||||
#[test]
|
||||
fn arm_refuses_legacy_07_firmware() {
|
||||
let mut legacy = b"freemkv 0.7.1".to_vec();
|
||||
legacy.resize(MEMREAD_LEN, 0);
|
||||
let mut t = MockTransport::always(Reply::good(legacy));
|
||||
let r = FirmwareControl::new(&mut t).arm(ArmRecipe::BypassBd);
|
||||
assert_eq!(r, Err(FirmwareError::UnsupportedFirmware));
|
||||
assert_eq!(t.calls(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_verify_reports_mismatch() {
|
||||
// A drive that ACKs the SET but never actually changes the state: GET reads
|
||||
// back the old value, so the verify fails.
|
||||
struct StuckDrive;
|
||||
impl ScsiTransport for StuckDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> ScsiTResult<ScsiResult> {
|
||||
let mut n = 0;
|
||||
if cdb[CDB_VERB] == Verb::Identity as u8 {
|
||||
let resp = identity_reply("0.9.2", [STATE_PASSTHROUGH; 6]);
|
||||
data.copy_from_slice(&resp[..data.len()]);
|
||||
n = data.len();
|
||||
} else if cdb[CDB_VERB] == Verb::Get as u8 && !data.is_empty() {
|
||||
data[0] = STATE_PASSTHROUGH; // never changes
|
||||
n = 1;
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut d = StuckDrive;
|
||||
let mut fw = FirmwareControl::new(&mut d);
|
||||
let err = fw.arm_bypass_bd().unwrap_err();
|
||||
// arm_bypass_bd now verifies Hrl=off FIRST, so the stuck drive trips on Hrl.
|
||||
assert_eq!(
|
||||
err,
|
||||
FirmwareError::VerifyFailed {
|
||||
feature: Feature::Hrl,
|
||||
wanted: STATE_OFF,
|
||||
got: STATE_PASSTHROUGH,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipe_transport_fault_propagates() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let mut fw = FirmwareControl::new(&mut t);
|
||||
assert_eq!(fw.arm_oem_bd().unwrap_err(), FirmwareError::Transport);
|
||||
}
|
||||
|
||||
/// A dead bus AFTER the IDENTITY gate (mid-recipe) still aborts.
|
||||
#[test]
|
||||
fn recipe_transport_fault_after_identity_propagates() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![Reply::good(identity_reply("0.9.2", [0xFF; 6]))],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let mut fw = FirmwareControl::new(&mut t);
|
||||
assert_eq!(fw.arm_bypass_uhd().unwrap_err(), FirmwareError::Transport);
|
||||
assert_eq!(t.calls(), 2);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Unlock drives that identify themselves as freemkv firmware.
|
||||
//!
|
||||
//! The vendor IDENTITY response must parse as a [`FirmwareIdentity`] on the
|
||||
//! supported grammar ([`FirmwareIdentity::is_supported`]). Commands use the
|
||||
//! builders in [`crate::firmware`], which mirror the firmware ABI.
|
||||
//! Unlocking sets Region to free, Speed to max, Unrestricted on, and Encryption
|
||||
//! to [`crate::firmware::STATE_OFF`].
|
||||
//! Encryption-off disables the certificate/bus barrier; the retired Bus feature
|
||||
//! must not be used as a substitute.
|
||||
|
||||
use crate::firmware::{
|
||||
Feature, FirmwareIdentity, MEMREAD_LEN, MIN_ALLOC_LEN, REGION_FREE, SPEED_MAX, STATE_OFF,
|
||||
STATE_ON, build_identity_cdb, build_memread_cdb, build_set_cdb,
|
||||
};
|
||||
use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus};
|
||||
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
/// Allocation the IDENTITY / DUMPALL data-in phase reads back (a fixed 64-byte
|
||||
/// window, matching [`MEMREAD_LEN`]).
|
||||
const RESP_LEN: usize = MEMREAD_LEN;
|
||||
|
||||
// The freemkv custom-firmware unlocker. Detection: IDENTITY on a supported
|
||||
// firmware version — no bundled profile catalog needed, unlike
|
||||
// crate::ld::LdUnlocker. Stateless: `unlock()` returns what it learned.
|
||||
#[derive(Default)]
|
||||
pub struct FreemkvUnlocker;
|
||||
|
||||
impl FreemkvUnlocker {
|
||||
pub fn new() -> Self {
|
||||
FreemkvUnlocker
|
||||
}
|
||||
|
||||
// Issue IDENTITY: Ok(true) only for freemkv firmware on the supported
|
||||
// grammar; Ok(false) if rejected, not freemkv, or below MIN_FW_VERSION (older
|
||||
// fw answers SETs with GOOD; unlock not validated). Dead bus: Err(Transport).
|
||||
fn identify(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
|
||||
let cdb = build_identity_cdb(RESP_LEN as u16);
|
||||
let mut buf = [0u8; RESP_LEN];
|
||||
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
Ok(r) => {
|
||||
let n = r.bytes_transferred.min(buf.len());
|
||||
let id = (r.status == 0)
|
||||
.then(|| FirmwareIdentity::parse(&buf[..n]))
|
||||
.flatten();
|
||||
let supported = id.as_ref().is_some_and(FirmwareIdentity::is_supported);
|
||||
if !supported {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_identity_no_match",
|
||||
version = id.as_ref().map(|i| i.version.as_str()),
|
||||
"IDENTITY probe did not report supported freemkv firmware"
|
||||
);
|
||||
}
|
||||
Ok(supported)
|
||||
}
|
||||
Err(e) => {
|
||||
if is_dead_bus(&e) {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_identity_transport_fault",
|
||||
"transport fault on the freemkv IDENTITY probe; aborting"
|
||||
);
|
||||
return Err(UnlockError::Transport);
|
||||
}
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_identity_rejected",
|
||||
status = e.status,
|
||||
"freemkv IDENTITY probe rejected by the drive; not this firmware"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue a `Set(feature, state)` over the required data-in phase. Ok(()) on GOOD status;
|
||||
// NotApplicable if rejected; Transport only on a dead bus.
|
||||
fn set(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
feature: Feature,
|
||||
state: u8,
|
||||
) -> std::result::Result<(), UnlockError> {
|
||||
let cdb = build_set_cdb(feature, state);
|
||||
// SET needs the data-in phase its CDB advertises — a no-data SET is aborted
|
||||
// (CHECK CONDITION, HW-confirmed on BU40N fw 0.8.1). Buffer sized from the SAME
|
||||
// const the CDB advertises (MIN_ALLOC_LEN) so the transfer length has one source.
|
||||
let mut buf = [0u8; MIN_ALLOC_LEN as usize];
|
||||
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
Ok(r) if r.status == 0 => Ok(()),
|
||||
Ok(r) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_set_rejected",
|
||||
feature = ?feature,
|
||||
state,
|
||||
status = r.status,
|
||||
"freemkv SET rejected by the drive"
|
||||
);
|
||||
Err(UnlockError::NotApplicable)
|
||||
}
|
||||
Err(e) => {
|
||||
if is_dead_bus(&e) {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_set_transport_fault",
|
||||
feature = ?feature,
|
||||
"transport fault on a freemkv SET; aborting"
|
||||
);
|
||||
return Err(UnlockError::Transport);
|
||||
}
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_set_rejected_as_err",
|
||||
feature = ?feature,
|
||||
status = e.status,
|
||||
"freemkv SET rejected (via Err)"
|
||||
);
|
||||
Err(UnlockError::NotApplicable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// DumpAll diagnostic RAM read (DUMPALL): return the 64-byte window at
|
||||
/// `addr`. A host-side diagnostic path only — not used by the unlock flow.
|
||||
#[allow(dead_code)]
|
||||
fn dump_ram(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
addr: u32,
|
||||
) -> std::result::Result<[u8; MEMREAD_LEN], UnlockError> {
|
||||
let cdb = build_memread_cdb(addr);
|
||||
let mut buf = [0u8; MEMREAD_LEN];
|
||||
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
Ok(r) if r.status == 0 && r.bytes_transferred >= MEMREAD_LEN => Ok(buf),
|
||||
Ok(_) => Err(UnlockError::NotApplicable),
|
||||
Err(e) => {
|
||||
if is_dead_bus(&e) {
|
||||
Err(UnlockError::Transport)
|
||||
} else {
|
||||
Err(UnlockError::NotApplicable)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Full freemkv unlock: IDENTITY (hard gate) → Set(Region,free) →
|
||||
// Set(Speed,max) → Set(Unrestricted,on) (best-effort) → Set(Encryption,off)
|
||||
// (LOAD-BEARING — consolidated cert/bus bypass) → bare 0xAD VID (best-effort).
|
||||
fn full_unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
// IDENTITY: must be a freemkv drive.
|
||||
if !self.identify(scsi)? {
|
||||
return Err(UnlockError::NotApplicable);
|
||||
}
|
||||
// Best-effort feature set: only a dead bus aborts.
|
||||
let best_effort = |r: std::result::Result<(), UnlockError>,
|
||||
what: &'static str|
|
||||
-> std::result::Result<(), UnlockError> {
|
||||
match r {
|
||||
Ok(()) => Ok(()),
|
||||
Err(UnlockError::Transport) => Err(UnlockError::Transport),
|
||||
Err(_) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_feature_unavailable",
|
||||
feature = what,
|
||||
"feature unavailable; continuing"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
};
|
||||
// Region-free + riplock lift (best-effort features).
|
||||
best_effort(self.set(scsi, Feature::Region, REGION_FREE), "region")?;
|
||||
best_effort(self.set(scsi, Feature::Speed, SPEED_MAX), "speed")?;
|
||||
// ABI full-bypass recipe: engage BD/UHD discs rather than rely on the
|
||||
// RAM defaults (RESET_TO_OEM or a saved config may leave it off).
|
||||
best_effort(
|
||||
self.set(scsi, Feature::Unrestricted, STATE_ON),
|
||||
"unrestricted",
|
||||
)?;
|
||||
// Encryption = off (LOAD-BEARING): the consolidated cert/bus bypass —
|
||||
// drive acts pre-authenticated (bare 0xAD returns the VID with no
|
||||
// cert/AKE) AND content returns de-bussed. No fallback.
|
||||
match self.set(scsi, Feature::Encryption, STATE_OFF) {
|
||||
Ok(()) => {}
|
||||
Err(UnlockError::Transport) => return Err(UnlockError::Transport),
|
||||
Err(_) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_disable_encryption_rejected",
|
||||
"Set(Encryption, off) rejected — cannot unlock this drive"
|
||||
);
|
||||
return Err(UnlockError::VidUnavailable);
|
||||
}
|
||||
}
|
||||
// Bare 0xAD VID read — the shared BEST-EFFORT reader (identical to the
|
||||
// LD/Renesas routes). Encryption=off already unlocked the drive, so a
|
||||
// VID miss must not discard it: only a dead bus propagates (`?`), else `None`.
|
||||
let vid = crate::vid::read_aacs_vid(scsi)?;
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_unlocked",
|
||||
has_vid = vid.is_some(),
|
||||
"freemkv drive unlocked (Encryption=off — consolidated cert/bus bypass)"
|
||||
);
|
||||
Ok(Unlocked { vid, bus_key: None })
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for FreemkvUnlocker {
|
||||
fn name(&self) -> &'static str {
|
||||
"freemkv"
|
||||
}
|
||||
|
||||
/// Recognise the drive by its IDENTITY knock, lift riplock/region and open
|
||||
/// Unrestricted (best-effort), set Encryption=off (the actual unlock), and
|
||||
/// read the Volume ID with a bare `0xAD` (best-effort). `Some` when the
|
||||
/// Encryption=off set succeeded — the drive is unlocked whether or not the
|
||||
/// VID read did; `None` if it isn't supported freemkv firmware;
|
||||
/// `Err(Transport)` on a dead bus. `ctx` is unused: this unlocker
|
||||
/// self-identifies rather than matching on drive identity.
|
||||
fn unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
_ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Option<Unlocked>, UnlockError> {
|
||||
crate::fallthrough(self.full_unlock(scsi))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,344 @@
|
||||
use super::*;
|
||||
use crate::DiscKind;
|
||||
use crate::firmware::{REGION_FREE, STATE_OFF, build_identity_cdb, build_memread_cdb};
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
use crate::scsi::{DataDirection, Result, ScsiResult, ScsiTransport};
|
||||
|
||||
fn ctx(id: &crate::DriveId) -> UnlockCtx<'_> {
|
||||
UnlockCtx::new(id, DiscKind::Unknown)
|
||||
}
|
||||
|
||||
/// The fw IDENTITY wire shape: `freemkv <ver>` (no NUL), the raw flag
|
||||
/// table, then zero padding to the 64-byte window.
|
||||
fn identity_payload(version: &str, flags: &[u8]) -> Vec<u8> {
|
||||
let mut p = b"freemkv ".to_vec();
|
||||
p.extend_from_slice(version.as_bytes());
|
||||
p.extend_from_slice(flags);
|
||||
p.resize(RESP_LEN, 0);
|
||||
p
|
||||
}
|
||||
|
||||
/// A fresh-boot fw 0.9.2 drive (DEFAULT_FLAGS: Unrestricted + Bd on).
|
||||
fn freemkv_identity_payload() -> Vec<u8> {
|
||||
identity_payload("0.9.2", &[0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF])
|
||||
}
|
||||
|
||||
/// A well-formed format-0x80 VID structure: 4-byte header, 16-byte VID at
|
||||
/// offset 4, 16-byte MAC (zeroed on the bare path — it isn't checked). 36 =
|
||||
/// the fixed `0xAD` fmt-0x80 response length (see `crate::vid`).
|
||||
fn vid_ds_response(vid: [u8; 16]) -> Vec<u8> {
|
||||
let mut p = vec![0u8; 36];
|
||||
p[4..20].copy_from_slice(&vid);
|
||||
p
|
||||
}
|
||||
|
||||
fn unlock(t: &mut MockTransport) -> std::result::Result<Option<Unlocked>, UnlockError> {
|
||||
let id = crate::DriveId::default();
|
||||
FreemkvUnlocker::new().unlock(t, &ctx(&id))
|
||||
}
|
||||
|
||||
/// Regression (BU40N fw 0.8.1): a SET issued WITHOUT the data-in phase its
|
||||
/// CDB advertises is aborted by the drive (CHECK CONDITION / Aborted Command),
|
||||
/// so the unlocker's `set()` MUST read the MIN_ALLOC_LEN table back like
|
||||
/// `FirmwareControl::set` — not send `DataDirection::None`. Before the fix
|
||||
/// the whole freemkv unlock failed at the first SET and fell back to AACS.
|
||||
#[test]
|
||||
fn set_issues_the_required_data_in_phase() {
|
||||
struct DataInContract;
|
||||
impl ScsiTransport for DataInContract {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
assert_eq!(cdb[4], 0x02, "test exercises only the SET verb");
|
||||
// Model the drive: a SET with no from-device phase is aborted.
|
||||
if !matches!(dir, DataDirection::FromDevice) || data.is_empty() {
|
||||
return Ok(ScsiResult {
|
||||
status: 2,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
FreemkvUnlocker::new()
|
||||
.set(&mut DataInContract, Feature::Region, REGION_FREE)
|
||||
.expect("SET must succeed by issuing the data-in phase the firmware requires");
|
||||
}
|
||||
|
||||
// ── Detection ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn identify_true_on_supported_firmware_and_issues_identity_cdb() {
|
||||
let mut t = MockTransport::always(Reply::good(freemkv_identity_payload()));
|
||||
assert!(FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_false_on_non_matching_payload() {
|
||||
let mut t = MockTransport::always(Reply::good(vec![0u8; RESP_LEN]));
|
||||
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_false_on_short_response() {
|
||||
let mut t = MockTransport::always(Reply::short(freemkv_identity_payload(), 3));
|
||||
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
}
|
||||
|
||||
/// The status guard decides: a CHECK CONDITION that nonetheless reports a
|
||||
/// full magic-bearing transfer (residual/echoed buffer) is not freemkv.
|
||||
#[test]
|
||||
fn identify_false_on_check_condition_with_a_magic_buffer() {
|
||||
struct CheckConditionWithData;
|
||||
impl ScsiTransport for CheckConditionWithData {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let p = freemkv_identity_payload();
|
||||
data.copy_from_slice(&p[..data.len()]);
|
||||
Ok(ScsiResult {
|
||||
status: 2,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!FreemkvUnlocker::new()
|
||||
.identify(&mut CheckConditionWithData)
|
||||
.expect("no fault")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_false_when_command_rejected() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_false_when_command_rejected_as_err() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request_as_err());
|
||||
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_transport_fault_aborts() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
assert_eq!(
|
||||
FreemkvUnlocker::new().identify(&mut t).unwrap_err(),
|
||||
UnlockError::Transport
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: released fw 0.7.x (`freemkv 0.7.1` reply, old sub-function
|
||||
/// grammar where 0x02 = Speed) answers GOOD to every SET. The unlocker must
|
||||
/// decline it, not claim a drive it never unlocked.
|
||||
#[test]
|
||||
fn declines_legacy_07_firmware_without_sending_a_set() {
|
||||
let mut t = MockTransport::always(Reply::good(identity_payload("0.7.1", &[])));
|
||||
assert!(unlock(&mut t).expect("no fault").is_none());
|
||||
assert_eq!(t.calls(), 1, "only the IDENTITY probe");
|
||||
}
|
||||
|
||||
/// Regression: fw 0.8.x (0x06 = `Ake`, separate `Bus`): de-bus via 0x06
|
||||
/// alone is not validated (hardware-dependent), so decline.
|
||||
#[test]
|
||||
fn declines_08_firmware_without_sending_a_set() {
|
||||
let flags = [0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF, 0xFF];
|
||||
let mut t = MockTransport::always(Reply::good(identity_payload("0.8.3", &flags)));
|
||||
assert!(unlock(&mut t).expect("no fault").is_none());
|
||||
assert_eq!(t.calls(), 1, "only the IDENTITY probe");
|
||||
}
|
||||
|
||||
// ── Feature sets ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn set_drive_rejection_is_not_applicable() {
|
||||
for reply in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
|
||||
let mut t = MockTransport::always(reply.clone());
|
||||
let err = FreemkvUnlocker::new()
|
||||
.set(&mut t, Feature::Encryption, STATE_OFF)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, UnlockError::NotApplicable, "{reply:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_transport_fault_propagates() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let err = FreemkvUnlocker::new()
|
||||
.set(&mut t, Feature::Speed, SPEED_MAX)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, UnlockError::Transport);
|
||||
}
|
||||
|
||||
// ── full_unlock / unlock ─────────────────────────────────────────────
|
||||
|
||||
/// The full unlock runs IDENTITY → Set(Region) → Set(Speed) →
|
||||
/// Set(Unrestricted) → Set(Encryption) → bare VID, with the exact wire
|
||||
/// bytes for every vendor SET.
|
||||
#[test]
|
||||
fn full_unlock_issues_identity_region_speed_unrestricted_encryption_then_bare_vid() {
|
||||
let vid = [0x7Cu8; 16];
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()), // IDENTITY
|
||||
Reply::good(vec![]), // Set Region
|
||||
Reply::good(vec![]), // Set Speed
|
||||
Reply::good(vec![]), // Set Unrestricted
|
||||
Reply::good(vec![]), // Set Encryption (load-bearing)
|
||||
Reply::good(vid_ds_response(vid)), // 0xAD bare VID
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let out = unlock(&mut t)
|
||||
.expect("no fault")
|
||||
.expect("encryption off ⇒ unlocked");
|
||||
assert_eq!(out.vid, Some(vid));
|
||||
assert_eq!(out.bus_key, None);
|
||||
assert_eq!(t.cdbs.len(), 6);
|
||||
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
|
||||
let set = |f: u8, s: u8| vec![0x3C, 0x0E, 0xC0, 0xDE, 0x02, f, s, 0x00, 0x40, 0x00];
|
||||
assert_eq!(t.cdbs[1], set(0x02, 0x0F), "Region = free");
|
||||
assert_eq!(t.cdbs[2], set(0x01, 0x00), "Speed = max");
|
||||
assert_eq!(t.cdbs[3], set(0x03, 0x01), "Unrestricted = on");
|
||||
assert_eq!(t.cdbs[4], set(0x06, 0x00), "Encryption = off");
|
||||
assert_eq!(t.cdbs[5][0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
|
||||
}
|
||||
|
||||
/// Missing region/speed/unrestricted features do not fail the unlock
|
||||
/// (best-effort), whichever rejection shape the transport uses.
|
||||
#[test]
|
||||
fn full_unlock_tolerates_missing_best_effort_features() {
|
||||
let vid = [0x3Cu8; 16];
|
||||
for reject in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()),
|
||||
reject.clone(), // region
|
||||
reject.clone(), // speed
|
||||
reject.clone(), // unrestricted
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vid_ds_response(vid)),
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let out = unlock(&mut t).expect("no fault").expect("unlocked");
|
||||
assert_eq!(out.vid, Some(vid), "{reject:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Set(Encryption, off) is LOAD-BEARING: if the drive rejects it (either
|
||||
/// shape), `unlock()` declines (`None`) and falls through.
|
||||
#[test]
|
||||
fn declines_when_disable_encryption_rejected() {
|
||||
for reject in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]),
|
||||
reject.clone(), // Set(Encryption,off) REJECTED
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
assert!(unlock(&mut t).expect("no fault").is_none(), "{reject:?}");
|
||||
assert_eq!(t.calls(), 5, "no VID read after the refusal");
|
||||
}
|
||||
}
|
||||
|
||||
/// A dead bus at ANY step after IDENTITY aborts with `Transport` (never a
|
||||
/// fall-through): each best-effort SET, the load-bearing SET, the VID read.
|
||||
#[test]
|
||||
fn dead_bus_after_identity_aborts_at_every_step() {
|
||||
for good_before_fault in 0..5 {
|
||||
let mut script = vec![Reply::good(freemkv_identity_payload())];
|
||||
script.extend(std::iter::repeat_n(Reply::good(vec![]), good_before_fault));
|
||||
let mut t = MockTransport::scripted(script, Reply::TransportFault);
|
||||
assert_eq!(
|
||||
unlock(&mut t).unwrap_err(),
|
||||
UnlockError::Transport,
|
||||
"fault after {good_before_fault} SETs"
|
||||
);
|
||||
assert_eq!(t.calls(), good_before_fault + 2, "aborted at the fault");
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort VID: Set(Encryption,off) succeeded (drive unlocked), but the
|
||||
/// bare VID read was rejected — `unlock()` still returns `Some`, with
|
||||
/// `vid: None`.
|
||||
#[test]
|
||||
fn unlocks_without_vid_when_bare_read_fails() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]), // Set(Encryption,off)
|
||||
Reply::illegal_request(), // bare VID: no medium
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let out = unlock(&mut t)
|
||||
.expect("no fault")
|
||||
.expect("unlocked despite no VID");
|
||||
assert_eq!(out.vid, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declines_non_freemkv_drive() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
assert!(unlock(&mut t).expect("no fault").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_fault_propagates() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
assert_eq!(unlock(&mut t).unwrap_err(), UnlockError::Transport);
|
||||
}
|
||||
|
||||
// ── DumpAll ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn dump_ram_builds_memread_cdb_and_returns_the_window() {
|
||||
let mut payload = vec![0xABu8; MEMREAD_LEN];
|
||||
payload[0] = 0xEE;
|
||||
let mut t = MockTransport::always(Reply::good(payload));
|
||||
let got = FreemkvUnlocker::new()
|
||||
.dump_ram(&mut t, 0x01F8_1234)
|
||||
.expect("dump ok");
|
||||
assert_eq!(got[0], 0xEE);
|
||||
assert_eq!(t.cdbs[0], build_memread_cdb(0x01F8_1234).to_vec());
|
||||
}
|
||||
|
||||
/// A GOOD-status short DUMPALL is not a zero-padded RAM window.
|
||||
#[test]
|
||||
fn dump_ram_rejects_a_short_transfer() {
|
||||
let mut t = MockTransport::always(Reply::short(vec![0xAB; MEMREAD_LEN], MEMREAD_LEN - 1));
|
||||
assert_eq!(
|
||||
FreemkvUnlocker::new().dump_ram(&mut t, 0).unwrap_err(),
|
||||
UnlockError::NotApplicable
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_is_freemkv() {
|
||||
assert_eq!(FreemkvUnlocker::new().name(), "freemkv");
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
THIRD-PARTY NOTICE for profiles.json
|
||||
====================================
|
||||
|
||||
`profiles.json` in this directory contains volatile, RAM-only optical-drive
|
||||
microcode ("LibreDrive") authored and owned by the MakeMKV team (GuinpinSoft
|
||||
inc.). Each payload embeds its own notice:
|
||||
|
||||
"This is volatile RAM-only microcode for LibreDrive by MakeMKV -
|
||||
http://www.makemkv.com/libredrive/
|
||||
Copyright (c) MakeMKV team, all rights reserved."
|
||||
|
||||
This microcode is third-party proprietary material — Copyright (c) MakeMKV team,
|
||||
all rights reserved — and is NOT covered by this repository's MIT LICENSE. No
|
||||
ownership is claimed. See the repository-root NOTICE. Thanks to the MakeMKV team
|
||||
for LibreDrive: https://www.makemkv.com/ , http://www.makemkv.com/libredrive/ .
|
||||
+10
-6
@@ -1,6 +1,6 @@
|
||||
# ld — the LibreDrive unlocker
|
||||
# ld — the drive bus-unlock module
|
||||
|
||||
The **LibreDrive** unlocker module for freemkv-unlock.
|
||||
A drive bus-unlock module for freemkv-unlock.
|
||||
|
||||
freemkv-unlock defines the generic `Unlocker` contract; this module is one
|
||||
implementation of it. It recognizes a bundled catalog of supported drives
|
||||
@@ -20,8 +20,12 @@ sets up lives in drive RAM and is gone on power cycle. The one-time, permanent
|
||||
drive preparation is the drive owner's own manual step and is **never**
|
||||
automated here.
|
||||
|
||||
## Credits
|
||||
## Third-party microcode (`profiles.json`)
|
||||
|
||||
LibreDrive was created by **Mike Chen** and the **MakeMKV team**. This module
|
||||
builds on their work — our thanks and full credit to them for the LibreDrive
|
||||
capabilities.
|
||||
`profiles.json` bundles volatile, RAM-only optical-drive microcode
|
||||
("LibreDrive") authored and owned by the **MakeMKV team** (GuinpinSoft inc.).
|
||||
Each payload carries its own notice — *"Copyright (c) MakeMKV team, all rights
|
||||
reserved."* This microcode is **third-party proprietary material and is NOT
|
||||
covered by this repository's MIT license**; no ownership is claimed. See the
|
||||
[`NOTICE`](./NOTICE) in this directory and the repository-root `NOTICE`. With
|
||||
thanks to the MakeMKV team for [LibreDrive](http://www.makemkv.com/libredrive/).
|
||||
|
||||
+4
-32
@@ -1,7 +1,7 @@
|
||||
//! Public unlock-CDB seam.
|
||||
//!
|
||||
//! These items expose the *minimum* an external consumer (e.g. the bdemu
|
||||
//! drive emulator) needs to recognise and answer the LibreDrive unlock
|
||||
//! drive emulator) needs to recognise and answer the LdUnlocker unlock
|
||||
//! READ_BUFFER handshake, without that consumer open-coding the
|
||||
//! handshake internals. The concrete CDB shapes and the verification
|
||||
//! marker are unlock-handshake details and must live ONLY in this crate.
|
||||
@@ -15,7 +15,7 @@ pub const UNLOCK_MARKER: &[u8] = b"MMkv";
|
||||
|
||||
/// Returns `true` when a READ_BUFFER (`0x3C`) CDB with the given mode
|
||||
/// (`cdb[1] & 0x1F`) and buffer id (`cdb[2]`) is an unlock-handshake
|
||||
/// read — i.e. one of the LibreDrive unlock variants.
|
||||
/// read — i.e. one of the LdUnlocker unlock variants.
|
||||
///
|
||||
/// This is the single source of truth for the unlock READ_BUFFER CDB
|
||||
/// shapes; consumers must call it rather than hardcoding the mode /
|
||||
@@ -25,33 +25,5 @@ pub fn is_unlock_read_buffer(mode: u8, buf_id: u8) -> bool {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn marker_is_four_bytes() {
|
||||
assert_eq!(UNLOCK_MARKER, b"MMkv");
|
||||
assert_eq!(UNLOCK_MARKER.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlock_variants_match() {
|
||||
// Variant A and variant B.
|
||||
assert!(is_unlock_read_buffer(1, 0x44));
|
||||
assert!(is_unlock_read_buffer(2, 0x77));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_unlock_cdbs_do_not_match() {
|
||||
// Right mode, wrong buffer id.
|
||||
assert!(!is_unlock_read_buffer(1, 0x77));
|
||||
assert!(!is_unlock_read_buffer(2, 0x44));
|
||||
// Wrong mode, right buffer id.
|
||||
assert!(!is_unlock_read_buffer(0, 0x77));
|
||||
assert!(!is_unlock_read_buffer(0, 0x44));
|
||||
assert!(!is_unlock_read_buffer(3, 0x77));
|
||||
// Ordinary data reads.
|
||||
assert!(!is_unlock_read_buffer(2, 0x00));
|
||||
assert!(!is_unlock_read_buffer(0, 0x00));
|
||||
}
|
||||
}
|
||||
#[path = "cdb_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn marker_is_four_bytes() {
|
||||
assert_eq!(UNLOCK_MARKER, b"MMkv");
|
||||
assert_eq!(UNLOCK_MARKER.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlock_variants_match() {
|
||||
// Variant A and variant B.
|
||||
assert!(is_unlock_read_buffer(1, 0x44));
|
||||
assert!(is_unlock_read_buffer(2, 0x77));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_unlock_cdbs_do_not_match() {
|
||||
// Right mode, wrong buffer id.
|
||||
assert!(!is_unlock_read_buffer(1, 0x77));
|
||||
assert!(!is_unlock_read_buffer(2, 0x44));
|
||||
// Wrong mode, right buffer id.
|
||||
assert!(!is_unlock_read_buffer(0, 0x77));
|
||||
assert!(!is_unlock_read_buffer(0, 0x44));
|
||||
assert!(!is_unlock_read_buffer(3, 0x77));
|
||||
// Ordinary data reads.
|
||||
assert!(!is_unlock_read_buffer(2, 0x00));
|
||||
assert!(!is_unlock_read_buffer(0, 0x00));
|
||||
}
|
||||
@@ -39,6 +39,17 @@ impl std::fmt::Display for Error {
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
impl Error {
|
||||
/// Stable numeric code (logged). Values are local to this crate and share
|
||||
/// the crate-wide space with `aacs` (70xx) and `css` (72xx).
|
||||
pub fn code(&self) -> u16 {
|
||||
match self {
|
||||
Error::ProfileParse => 7101,
|
||||
Error::UnlockFailed => 7102,
|
||||
Error::SignatureMismatch { .. } => 7103,
|
||||
Error::Scsi { .. } => 7199,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if this is a transport-layer SCSI failure (bus dead), as opposed to a
|
||||
/// drive sense or a logical failure.
|
||||
pub(crate) fn is_transport_failure(&self) -> bool {
|
||||
@@ -74,3 +85,7 @@ impl From<Error> for crate::UnlockError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "error_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
use super::*;
|
||||
use crate::UnlockError;
|
||||
use crate::scsi::{SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE, ScsiError};
|
||||
|
||||
/// The abort/fall-through boundary, which had no test at all. Catches
|
||||
/// widening `is_transport_failure` (every failure would abort every rip) or
|
||||
/// narrowing it (a dead bus would be probed forever).
|
||||
#[test]
|
||||
fn only_a_senseless_transport_status_is_a_transport_failure() {
|
||||
let dead_bus = Error::Scsi {
|
||||
opcode: 0x3C,
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
};
|
||||
assert!(dead_bus.is_transport_failure());
|
||||
assert_eq!(UnlockError::from(dead_bus), UnlockError::Transport);
|
||||
|
||||
// A CHECK CONDITION is the DRIVE refusing, not the bus dying.
|
||||
let refused = Error::Scsi {
|
||||
opcode: 0x3C,
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([0u8; 32]),
|
||||
};
|
||||
assert!(!refused.is_transport_failure());
|
||||
assert_eq!(UnlockError::from(refused), UnlockError::NotApplicable);
|
||||
|
||||
// The transport-failure status WITH a sense is a drive that reported
|
||||
// 0xFF, not a bus fault — it must not abort the consumer.
|
||||
let odd = Error::Scsi {
|
||||
opcode: 0x3C,
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: Some([0u8; 32]),
|
||||
};
|
||||
assert!(!odd.is_transport_failure());
|
||||
|
||||
for e in [
|
||||
Error::ProfileParse,
|
||||
Error::UnlockFailed,
|
||||
Error::SignatureMismatch {
|
||||
expected: [0; 4],
|
||||
got: [1; 4],
|
||||
},
|
||||
] {
|
||||
assert!(!e.is_transport_failure(), "{e:?} is not a bus fault");
|
||||
assert_eq!(UnlockError::from(e), UnlockError::NotApplicable);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Display` is what shows up in logs — every variant must render without
|
||||
/// panicking and the SCSI variant must carry the opcode/status through.
|
||||
#[test]
|
||||
fn display_renders_every_variant() {
|
||||
assert_eq!(Error::ProfileParse.to_string(), "drive profile parse error");
|
||||
assert_eq!(Error::UnlockFailed.to_string(), "firmware unlock failed");
|
||||
assert_eq!(
|
||||
Error::SignatureMismatch {
|
||||
expected: [0; 4],
|
||||
got: [1; 4],
|
||||
}
|
||||
.to_string(),
|
||||
"signature mismatch"
|
||||
);
|
||||
assert_eq!(
|
||||
Error::Scsi {
|
||||
opcode: 0x3C,
|
||||
status: 0x02,
|
||||
sense: None,
|
||||
}
|
||||
.to_string(),
|
||||
"SCSI error (opcode 0x3c, status 0x02)"
|
||||
);
|
||||
}
|
||||
|
||||
/// `From<ScsiError>` must carry the status AND sense across the seam —
|
||||
/// collapsing either one destroys the transport-vs-rejection distinction the
|
||||
/// whole abort/fall-through split rests on.
|
||||
#[test]
|
||||
fn scsi_error_conversion_preserves_status_and_sense() {
|
||||
let e = Error::from(ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
assert!(e.is_transport_failure());
|
||||
|
||||
let e = Error::from(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([0u8; 32]),
|
||||
});
|
||||
assert!(!e.is_transport_failure());
|
||||
assert_eq!(UnlockError::from(e), UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
/// Error codes are stable and distinct — they are the numeric contract the
|
||||
/// logs carry instead of English.
|
||||
#[test]
|
||||
fn error_codes_are_distinct_and_stable() {
|
||||
let codes = [
|
||||
Error::ProfileParse.code(),
|
||||
Error::UnlockFailed.code(),
|
||||
Error::SignatureMismatch {
|
||||
expected: [0; 4],
|
||||
got: [0; 4],
|
||||
}
|
||||
.code(),
|
||||
Error::Scsi {
|
||||
opcode: 0,
|
||||
status: 0,
|
||||
sense: None,
|
||||
}
|
||||
.code(),
|
||||
];
|
||||
assert_eq!(codes, [7101, 7102, 7103, 7199]);
|
||||
}
|
||||
+91
-206
@@ -1,4 +1,4 @@
|
||||
//! ld — the LibreDrive firmware unlocker (MediaTek MT1959).
|
||||
//! ld — the MediaTek MT1959 firmware unlocker.
|
||||
//!
|
||||
//! Self-contained module: it owns the bundled drive profiles, firmware blobs,
|
||||
//! the WRITE_BUFFER / MODE SELECT upload, the unlock CDBs, and the variant-A /
|
||||
@@ -6,11 +6,10 @@
|
||||
//! encryption AT THE DRIVE (the unlocked drive serves clear content) and
|
||||
//! reporting the OEM Volume ID.
|
||||
|
||||
// `cdb` carries ONLY the unlock-handshake wire format that the bdemu emulator
|
||||
// needs (the real unlocker drives its CDBs from per-drive profile templates, not
|
||||
// these constants). Compile it only when the `emulation` feature exposes it, so
|
||||
// it never dead-codes in a normal build.
|
||||
#[cfg(feature = "emulation")]
|
||||
// `cdb` is only the bdemu emulator's wire format (real unlocking uses profile
|
||||
// templates), gated behind `emulation`; also compiled under `cfg(test)` so its
|
||||
// wire-format tests run in default-feature CI.
|
||||
#[cfg(any(feature = "emulation", test))]
|
||||
mod cdb;
|
||||
mod error;
|
||||
mod platform;
|
||||
@@ -21,16 +20,12 @@ use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::{DriveId, UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
// ── Public profile catalog ──────────────────────────────────────────────────
|
||||
//
|
||||
// The catalog of drives the LibreDrive unlocker recognizes is the one piece of
|
||||
// ld worth exposing publicly: it answers "is this drive supported?" without
|
||||
// unlocking, and the bdemu test-emulator reads it to impersonate a supported
|
||||
// drive. The unlock *mechanism* (firmware blobs, upload sequence, CDB wire
|
||||
// format) stays private — only the catalog and its match result are public.
|
||||
// Only the catalog is public (supported-drive lookup, used by bdemu); the
|
||||
// unlock mechanism stays private.
|
||||
|
||||
pub use profile::{DriveProfile as Profile, Identity, Platform, ProfileMatch, Profiles};
|
||||
|
||||
/// The bundled LibreDrive profile catalog (parsed once, process-cached), or
|
||||
/// The bundled MT1959 profile catalog (parsed once, process-cached), or
|
||||
/// `None` if the embedded JSON fails to parse (a build-time bug). Pair with
|
||||
/// [`Profiles::get`] to look up a specific drive:
|
||||
/// `freemkv_unlock::ld::profiles().and_then(|p| p.get(&drive_id))`.
|
||||
@@ -51,17 +46,15 @@ pub fn profile(drive_id: &DriveId) -> Option<ProfileMatch> {
|
||||
#[cfg(feature = "emulation")]
|
||||
pub use cdb::{UNLOCK_MARKER, is_unlock_read_buffer};
|
||||
|
||||
/// The LibreDrive unlocker. `pub(crate)` — clients reach it only through
|
||||
/// [`crate::all_unlockers`], never by name (the locked-design contract).
|
||||
///
|
||||
/// Matches a drive against the bundled profile database and, on a hit,
|
||||
/// runs the MediaTek MT1959 firmware-unlock (and disc-speed calibration)
|
||||
/// handshake over the raw SCSI transport.
|
||||
pub(crate) struct LibreDrive;
|
||||
// The MT1959 unlocker. Matches a drive against the bundled profile database and
|
||||
// runs the firmware-unlock + disc-speed-calibration handshake over raw SCSI.
|
||||
// Stateless: `unlock()` returns what it learned.
|
||||
#[derive(Default)]
|
||||
pub struct LdUnlocker;
|
||||
|
||||
impl LibreDrive {
|
||||
pub(crate) fn new() -> Self {
|
||||
LibreDrive
|
||||
impl LdUnlocker {
|
||||
pub fn new() -> Self {
|
||||
LdUnlocker
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,29 +62,39 @@ impl LibreDrive {
|
||||
/// drive-info "is this drive supported?" display), or `None`. A pure profile
|
||||
/// lookup — does NOT touch the drive or unlock anything.
|
||||
pub(crate) fn firmware_name(id: &DriveId) -> Option<&'static str> {
|
||||
profile::find_bundled(id).map(|_| "LibreDrive")
|
||||
profile::find_bundled(id).map(|_| "LD")
|
||||
}
|
||||
|
||||
impl LibreDrive {
|
||||
/// Read the OEM Volume ID via the matched profile's vendor CDB.
|
||||
///
|
||||
/// `Ok(Some(vid))` on a well-formed 36-byte response (signature `00 22 00`,
|
||||
/// VID at `[4..20]`); `Ok(None)` when there is no OEM-VID CDB or the response
|
||||
/// is short / bad-signature (the drive is still unlocked, just no VID); `Err`
|
||||
/// only on a transport fault.
|
||||
fn read_oem_vid(&self, scsi: &mut dyn ScsiTransport, id: &DriveId) -> Result<Option<[u8; 16]>> {
|
||||
impl LdUnlocker {
|
||||
// Read the OEM Volume ID via the matched profile's vendor CDB (profile passed
|
||||
// in to avoid a redundant 206-entry catalog scan). Ok(Some) on a well-formed
|
||||
// response; Ok(None) if unreadable; Err only on a transport fault.
|
||||
fn read_oem_vid(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
profile: &profile::DriveProfile,
|
||||
) -> Result<Option<[u8; 16]>> {
|
||||
const RESPONSE_LEN: usize = 36;
|
||||
const EXPECTED_HEADER: [u8; 3] = [0x00, 0x22, 0x00];
|
||||
|
||||
let Some(m) = profile::find_bundled(id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(cdb) = m.profile.read_vid_cdb else {
|
||||
let Some(cdb) = profile.read_vid_cdb else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut buf = vec![0u8; RESPONSE_LEN];
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000)?;
|
||||
// Per the transport contract a drive sense arrives as `Ok` with non-zero
|
||||
// `status`, not `Err` — without this check a CHECK CONDITION reads as a
|
||||
// successful response and the zero-filled buffer parses as a bogus VID.
|
||||
if result.status != 0 {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "oem_vid_check_condition",
|
||||
status = result.status,
|
||||
"OEM VID CDB returned a drive sense"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
if result.bytes_transferred < RESPONSE_LEN {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
@@ -116,16 +119,10 @@ impl LibreDrive {
|
||||
}
|
||||
}
|
||||
|
||||
impl LibreDrive {
|
||||
/// The MediaTek firmware unlock. Because LibreDrive removes AACS bus
|
||||
/// encryption AT THE DRIVE (the unlocked drive serves CLEAR content), this ONE
|
||||
/// operation satisfies BOTH the drive-features and the bus-removal capability
|
||||
/// — so `unlock_features` and `unlock_bus` both delegate here. The result
|
||||
/// carries `drive_unlocked: true` (no bus key needed) and the OEM Volume ID.
|
||||
///
|
||||
/// A no-firmware-route drive (Renesas / no profile) returns `NotApplicable`; a
|
||||
/// transport fault propagates as `Transport`; any other firmware failure also
|
||||
/// falls through as `NotApplicable`.
|
||||
impl LdUnlocker {
|
||||
// The MediaTek firmware unlock. Since it removes AACS at the drive (clear
|
||||
// content), this one op satisfies both features and bus-removal, so both
|
||||
// trait methods delegate here.
|
||||
fn firmware_unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
@@ -141,179 +138,67 @@ impl LibreDrive {
|
||||
}
|
||||
let is_variant_b = matches!(m.platform, profile::Platform::Mt1959B);
|
||||
use platform::PlatformDriver;
|
||||
let mut mt = platform::mt1959::Mt1959::new(m.profile, is_variant_b);
|
||||
let mut mt = platform::mt1959::Mt1959::new(m.profile.clone(), is_variant_b);
|
||||
// A transport fault → UnlockError::Transport; any other firmware failure
|
||||
// → NotApplicable (via From<error::Error>).
|
||||
mt.init(scsi)?;
|
||||
// Prime the per-region speed table (best-effort — must not fail the unlock).
|
||||
let _ = mt.probe_disc(scsi);
|
||||
let vid = self.read_oem_vid(scsi, id)?;
|
||||
Ok(Unlocked {
|
||||
vid,
|
||||
bus_key: None,
|
||||
drive_unlocked: true,
|
||||
})
|
||||
// `init` only proves the handshake completed, not that the drive reached
|
||||
// extended-access state. Reporting unlocked off `init` alone shipped
|
||||
// ciphertext at rc=0.
|
||||
if !mt.is_unlocked() {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "firmware_unlock_incomplete",
|
||||
"firmware handshake completed but the drive is not in the extended-access state; falling through"
|
||||
);
|
||||
return Err(UnlockError::NotApplicable);
|
||||
}
|
||||
// Prime the per-region speed table. Best-effort (must not fail the unlock)
|
||||
// but not silent — an unlogged `let _ =` made a failed calibration
|
||||
// indistinguishable from success in the rip log.
|
||||
if let Err(e) = mt.probe_disc(scsi) {
|
||||
// A transport fault here is a dead bus, not a calibration miss — most
|
||||
// profiles never touch the bus again, so this was the only dead-bus
|
||||
// signal.
|
||||
if e.is_transport_failure() {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "probe_disc_transport_fault",
|
||||
"transport fault during disc speed calibration; aborting"
|
||||
);
|
||||
return Err(UnlockError::Transport);
|
||||
}
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "probe_disc_failed",
|
||||
transport_failure = false,
|
||||
"disc speed calibration failed; continuing with the drive's default speed table"
|
||||
);
|
||||
}
|
||||
let vid = self.read_oem_vid(scsi, &m.profile)?;
|
||||
Ok(Unlocked { vid, bus_key: None })
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for LibreDrive {
|
||||
impl Unlocker for LdUnlocker {
|
||||
fn name(&self) -> &'static str {
|
||||
"LibreDrive"
|
||||
"LD"
|
||||
}
|
||||
|
||||
/// LibreDrive provides drive features (riplock/speed, OEM VID) — and, because
|
||||
/// its firmware unlock serves clear content, bus removal comes free with it.
|
||||
fn unlock_features(
|
||||
/// Match the drive against the bundled profile database and run the firmware
|
||||
/// unlock — one op that removes bus encryption at the drive (clear content)
|
||||
/// and reads the OEM Volume ID (best-effort). `Some` when the firmware
|
||||
/// handshake reached the extended-access state; `None` for an unknown drive
|
||||
/// or an incomplete handshake; `Err(Transport)` on a dead bus.
|
||||
fn unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
self.firmware_unlock(scsi, ctx)
|
||||
}
|
||||
|
||||
/// Same firmware code as [`unlock_features`]: LibreDrive removes the bus at
|
||||
/// the drive. In practice the consumer skips this because drive-prep already
|
||||
/// set `drive_unlocked`; it's here for completeness / a bus-first call order.
|
||||
fn unlock_bus(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
self.firmware_unlock(scsi, ctx)
|
||||
) -> std::result::Result<Option<Unlocked>, UnlockError> {
|
||||
crate::fallthrough(self.firmware_unlock(scsi, ctx))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::DiscKind;
|
||||
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
|
||||
|
||||
/// Unlock context for a fake drive id (kind/host-certs irrelevant to the
|
||||
/// firmware unlocker — it keys off the drive identity).
|
||||
fn ctx(id: &DriveId) -> UnlockCtx<'_> {
|
||||
UnlockCtx::new(id, DiscKind::Unknown, &[])
|
||||
}
|
||||
|
||||
/// A fake transport that fills the response buffer from a fixed payload and
|
||||
/// reports a configurable transferred-byte count.
|
||||
struct FakeTransport {
|
||||
payload: Vec<u8>,
|
||||
bytes_transferred: usize,
|
||||
}
|
||||
impl ScsiTransport for FakeTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
let n = self.payload.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.payload[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: self.bytes_transferred,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A DriveId for the bundled HL-DT-ST profile that carries a real
|
||||
/// `read_vid_cdb`, so `read_oem_vid` finds a profile and issues the CDB.
|
||||
fn known_vid_drive_id() -> DriveId {
|
||||
make_drive_id("HL-DT-ST", "1.01", "NM00100", "211711202000")
|
||||
}
|
||||
|
||||
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
|
||||
DriveId {
|
||||
vendor_id: vendor.to_string(),
|
||||
product_id: String::new(),
|
||||
product_revision: rev.to_string(),
|
||||
vendor_specific: vs.to_string(),
|
||||
firmware_date: date.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A well-formed 36-byte response (signature 00 22 00, VID at [4..20]) parses
|
||||
/// to `Some(vid)`.
|
||||
#[test]
|
||||
fn read_oem_vid_parses_well_formed_response() {
|
||||
let m = profile::find_bundled(&known_vid_drive_id()).expect("profile match");
|
||||
assert!(
|
||||
m.profile.read_vid_cdb.is_some(),
|
||||
"test fixture drive must carry an OEM VID CDB"
|
||||
);
|
||||
|
||||
let mut payload = vec![0u8; 36];
|
||||
payload[0..3].copy_from_slice(&[0x00, 0x22, 0x00]);
|
||||
let vid = [0x3Cu8; 16];
|
||||
payload[4..20].copy_from_slice(&vid);
|
||||
let mut t = FakeTransport {
|
||||
payload,
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
.read_oem_vid(&mut t, &known_vid_drive_id())
|
||||
.expect("parse ok");
|
||||
assert_eq!(got, Some(vid), "VID parsed from [4..20]");
|
||||
}
|
||||
|
||||
/// A short response → `Ok(None)` (drive unlocked, just no readable VID).
|
||||
#[test]
|
||||
fn read_oem_vid_short_response_is_none() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 20,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
.read_oem_vid(&mut t, &known_vid_drive_id())
|
||||
.expect("short response is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
/// A response whose 3-byte signature isn't `00 22 00` → `Ok(None)`.
|
||||
#[test]
|
||||
fn read_oem_vid_bad_header_is_none() {
|
||||
let mut payload = vec![0u8; 36];
|
||||
payload[0..3].copy_from_slice(&[0xDE, 0xAD, 0xBE]);
|
||||
let mut t = FakeTransport {
|
||||
payload,
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
.read_oem_vid(&mut t, &known_vid_drive_id())
|
||||
.expect("bad header is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
/// A drive with no matching profile → `read_oem_vid` is `Ok(None)`.
|
||||
#[test]
|
||||
fn read_oem_vid_no_profile_is_none() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
.read_oem_vid(&mut t, &make_drive_id("FAKE-VND", "9.99", "XX12345", ""))
|
||||
.expect("no profile is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
/// `unlock_features` on a drive with no matching profile → `NotApplicable`
|
||||
/// (fall through), short-circuiting before any firmware handshake.
|
||||
#[test]
|
||||
fn unlock_no_profile_is_not_applicable() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let err = LibreDrive::new()
|
||||
.unlock_features(
|
||||
&mut t,
|
||||
&ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")),
|
||||
)
|
||||
.expect_err("no profile → NotApplicable");
|
||||
assert_eq!(err, UnlockError::NotApplicable);
|
||||
}
|
||||
}
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
use super::*;
|
||||
use crate::DiscKind;
|
||||
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
|
||||
|
||||
/// Unlock context for a fake drive id (kind/host-certs irrelevant to the
|
||||
/// firmware unlocker — it keys off the drive identity).
|
||||
fn ctx(id: &DriveId) -> UnlockCtx<'_> {
|
||||
UnlockCtx::new(id, DiscKind::Unknown)
|
||||
}
|
||||
|
||||
/// A fake transport that fills the response buffer from a fixed payload and
|
||||
/// reports a configurable transferred-byte count.
|
||||
struct FakeTransport {
|
||||
payload: Vec<u8>,
|
||||
bytes_transferred: usize,
|
||||
}
|
||||
impl ScsiTransport for FakeTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
let n = self.payload.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.payload[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: self.bytes_transferred,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A DriveId for the bundled HL-DT-ST profile that carries a real
|
||||
/// `read_vid_cdb`, so `read_oem_vid` finds a profile and issues the CDB.
|
||||
fn known_vid_drive_id() -> DriveId {
|
||||
make_drive_id("HL-DT-ST", "1.01", "NM00100", "211711202000")
|
||||
}
|
||||
|
||||
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
|
||||
DriveId {
|
||||
vendor_id: vendor.to_string(),
|
||||
product_id: String::new(),
|
||||
product_revision: rev.to_string(),
|
||||
vendor_specific: vs.to_string(),
|
||||
firmware_date: date.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bundled profile of the fixture drive (the one carrying a real
|
||||
/// `read_vid_cdb`).
|
||||
fn known_vid_profile() -> profile::DriveProfile {
|
||||
let m = profile::find_bundled(&known_vid_drive_id()).expect("profile match");
|
||||
assert!(
|
||||
m.profile.read_vid_cdb.is_some(),
|
||||
"test fixture drive must carry an OEM VID CDB"
|
||||
);
|
||||
m.profile
|
||||
}
|
||||
|
||||
/// A well-formed 36-byte response (signature 00 22 00, VID at [4..20]) parses
|
||||
/// to `Some(vid)`.
|
||||
#[test]
|
||||
fn read_oem_vid_parses_well_formed_response() {
|
||||
let mut payload = vec![0u8; 36];
|
||||
payload[0..3].copy_from_slice(&[0x00, 0x22, 0x00]);
|
||||
let vid = [0x3Cu8; 16];
|
||||
payload[4..20].copy_from_slice(&vid);
|
||||
let mut t = FakeTransport {
|
||||
payload,
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LdUnlocker::new()
|
||||
.read_oem_vid(&mut t, &known_vid_profile())
|
||||
.expect("parse ok");
|
||||
assert_eq!(got, Some(vid), "VID parsed from [4..20]");
|
||||
}
|
||||
|
||||
/// A short response → `Ok(None)` (drive unlocked, just no readable VID).
|
||||
#[test]
|
||||
fn read_oem_vid_short_response_is_none() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 20,
|
||||
};
|
||||
let got = LdUnlocker::new()
|
||||
.read_oem_vid(&mut t, &known_vid_profile())
|
||||
.expect("short response is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
/// A response whose 3-byte signature isn't `00 22 00` → `Ok(None)`.
|
||||
#[test]
|
||||
fn read_oem_vid_bad_header_is_none() {
|
||||
let mut payload = vec![0u8; 36];
|
||||
payload[0..3].copy_from_slice(&[0xDE, 0xAD, 0xBE]);
|
||||
let mut t = FakeTransport {
|
||||
payload,
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LdUnlocker::new()
|
||||
.read_oem_vid(&mut t, &known_vid_profile())
|
||||
.expect("bad header is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
/// Catches dropping the `result.status` check: a CHECK CONDITION arrives as
|
||||
/// `Ok` per the transport contract, so without it the caller's zero-filled
|
||||
/// buffer is parsed as a real 36-byte response.
|
||||
#[test]
|
||||
fn read_oem_vid_check_condition_is_none_not_a_vid() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let got = LdUnlocker::new()
|
||||
.read_oem_vid(&mut t, &known_vid_profile())
|
||||
.expect("a drive sense is not a transport fault");
|
||||
assert_eq!(got, None, "a CHECK CONDITION must never yield a VID");
|
||||
}
|
||||
|
||||
/// Catches swallowing a transport fault in the OEM-VID read: a dead bus must
|
||||
/// propagate (→ `UnlockError::Transport`), never become `Ok(None)`.
|
||||
#[test]
|
||||
fn read_oem_vid_transport_fault_propagates() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let err = LdUnlocker::new()
|
||||
.read_oem_vid(&mut t, &known_vid_profile())
|
||||
.expect_err("a dead bus must not be Ok(None)");
|
||||
assert!(err.is_transport_failure());
|
||||
assert_eq!(UnlockError::from(err), UnlockError::Transport);
|
||||
}
|
||||
|
||||
/// A profile with no OEM-VID CDB → `Ok(None)` without touching the drive.
|
||||
#[test]
|
||||
fn read_oem_vid_no_cdb_is_none() {
|
||||
let mut p = known_vid_profile();
|
||||
p.read_vid_cdb = None;
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LdUnlocker::new()
|
||||
.read_oem_vid(&mut t, &p)
|
||||
.expect("no CDB is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
/// Public catalog accessors: `profiles()` returns the bundled catalog and
|
||||
/// `profile()` is `profiles().and_then(get)` for a known drive.
|
||||
#[test]
|
||||
fn public_catalog_accessors_find_the_bundled_fixture_drive() {
|
||||
assert!(super::profiles().is_some(), "bundled catalog must parse");
|
||||
let m = super::profile(&known_vid_drive_id()).expect("known fixture drive matches");
|
||||
assert!(m.profile.read_vid_cdb.is_some());
|
||||
}
|
||||
|
||||
/// `unlock_features` on a drive with no matching profile → `NotApplicable`
|
||||
/// (fall through), short-circuiting before any firmware handshake.
|
||||
#[test]
|
||||
fn unlock_no_profile_is_not_applicable() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let unlocked = LdUnlocker::new()
|
||||
.unlock(
|
||||
&mut t,
|
||||
&ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")),
|
||||
)
|
||||
.expect("no profile → declines, not a hard error");
|
||||
assert!(unlocked.is_none());
|
||||
}
|
||||
|
||||
// THE defect-1 test: response carries the signature + primary marker but not
|
||||
// the secondary one, so init() succeeds but the drive isn't in extended-access
|
||||
// state. Catches removing the `is_unlocked()` gate in `firmware_unlock`.
|
||||
#[test]
|
||||
fn half_unlocked_drive_falls_through_instead_of_claiming_unlocked() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let id = known_vid_drive_id();
|
||||
let sig = profile::find_bundled(&id)
|
||||
.expect("profile")
|
||||
.profile
|
||||
.signature;
|
||||
|
||||
// 64-byte unlock response: signature + primary marker "MMkv" at [12..16],
|
||||
// secondary marker at [16..20] left zeroed.
|
||||
let mut resp = vec![0u8; 64];
|
||||
resp[0..4].copy_from_slice(&sig);
|
||||
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]);
|
||||
|
||||
let mut t = MockTransport::always(Reply::good(resp));
|
||||
let unlocked = LdUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.expect("a half-unlock declines, not a hard error");
|
||||
assert!(
|
||||
unlocked.is_none(),
|
||||
"a half-unlocked drive must fall through to cert-auth"
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole-unlock happy path still reports unlocked when BOTH firmware
|
||||
/// markers are present — the `is_unlocked()` gate must not have made a
|
||||
/// genuinely unlocked drive fall through.
|
||||
#[test]
|
||||
fn fully_unlocked_drive_reports_unlocked() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let id = known_vid_drive_id();
|
||||
let sig = profile::find_bundled(&id)
|
||||
.expect("profile")
|
||||
.profile
|
||||
.signature;
|
||||
|
||||
let mut resp = vec![0u8; 64];
|
||||
resp[0..4].copy_from_slice(&sig);
|
||||
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]);
|
||||
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]);
|
||||
|
||||
let mut t = MockTransport::always(Reply::good(resp));
|
||||
assert!(
|
||||
LdUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.expect("no fault")
|
||||
.is_some(),
|
||||
"both markers → unlocked"
|
||||
);
|
||||
}
|
||||
|
||||
// THE probe-disc dead-bus test: bus dies during speed calibration after a
|
||||
// full unlock; must abort with Transport, not report a successful unlock.
|
||||
#[test]
|
||||
fn transport_fault_during_probe_disc_is_transport_not_a_successful_unlock() {
|
||||
let id = known_vid_drive_id();
|
||||
let sig = profile::find_bundled(&id)
|
||||
.expect("profile")
|
||||
.profile
|
||||
.signature;
|
||||
|
||||
// A drive that unlocks fully but whose bus dies on the speed probe.
|
||||
struct ProbeFaultsDrive {
|
||||
resp: Vec<u8>,
|
||||
}
|
||||
impl ScsiTransport for ProbeFaultsDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
// READ_BUFFER (0x3C) / SUB_CMD_PROBE (0x14) is the speed probe.
|
||||
if cdb.first() == Some(&0x3C) && cdb.get(3) == Some(&0x14) {
|
||||
return Err(crate::scsi::ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
let n = self.resp.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.resp[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let mut resp = vec![0u8; 64];
|
||||
resp[0..4].copy_from_slice(&sig);
|
||||
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]); // primary marker
|
||||
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]); // secondary marker
|
||||
let mut t = ProbeFaultsDrive { resp };
|
||||
|
||||
let err = LdUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.expect_err("a dead bus during probe must abort, not report success");
|
||||
assert_eq!(err, UnlockError::Transport);
|
||||
}
|
||||
|
||||
// A drive-sense (not a dead bus) rejecting the speed probe is a genuine
|
||||
// calibration miss: warn-and-continue on the default speed table, still
|
||||
// reporting a full unlock. Non-transport sibling of the probe-fault test.
|
||||
#[test]
|
||||
fn drive_sense_during_probe_disc_still_reports_a_successful_unlock() {
|
||||
let id = known_vid_drive_id();
|
||||
let sig = profile::find_bundled(&id)
|
||||
.expect("profile")
|
||||
.profile
|
||||
.signature;
|
||||
|
||||
let mut resp = vec![0u8; 64];
|
||||
resp[0..4].copy_from_slice(&sig);
|
||||
resp[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]); // primary marker
|
||||
resp[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]); // secondary marker
|
||||
|
||||
struct ProbeSenseDrive {
|
||||
resp: Vec<u8>,
|
||||
}
|
||||
impl ScsiTransport for ProbeSenseDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
// READ_BUFFER (0x3C) / SUB_CMD_PROBE (0x14) is the speed probe.
|
||||
if cdb.first() == Some(&0x3C) && cdb.get(3) == Some(&0x14) {
|
||||
return Ok(ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
let n = self.resp.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.resp[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut t = ProbeSenseDrive { resp };
|
||||
assert!(
|
||||
LdUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.expect("a calibration miss must not fail the whole unlock")
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
/// Catches classifying a dead bus as "not this unlocker's drive": the very
|
||||
/// first unlock command faulting at the transport layer must abort the
|
||||
/// consumer (`Transport`), not fall through to the next unlocker.
|
||||
// Review of ST-D10: after a confirmed firmware upload, a drive whose media never
|
||||
// becomes ready (the same 02/04/01 for 60 s, T6) must still report the unlock,
|
||||
// and probe_disc + the VID read must still run (not `NotApplicable`).
|
||||
#[test]
|
||||
fn readiness_stall_after_upload_keeps_the_unlock() {
|
||||
use crate::scsi::{DataDirection, ScsiResult};
|
||||
use platform::mt1959::tests::{FakeClock, TEST_READY_CLOCK};
|
||||
struct UploadThenNotReady {
|
||||
sig: [u8; 4],
|
||||
unlock_calls: usize,
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
impl ScsiTransport for UploadThenNotReady {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
let unlock = cdb[0] == 0x3C && matches!(cdb[2], 0x44 | 0x77) && cdb[3] == 0;
|
||||
let (status, n) = if cdb[0] == 0x00 {
|
||||
let mut sense = [0u8; 32];
|
||||
(sense[2], sense[12], sense[13]) = (0x02, 0x04, 0x01);
|
||||
return Ok(ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense,
|
||||
});
|
||||
} else if unlock && cdb[8] == 64 {
|
||||
self.unlock_calls += 1;
|
||||
data.fill(0);
|
||||
data[0..4].copy_from_slice(&self.sig);
|
||||
data[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]);
|
||||
data[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]);
|
||||
// The first unlock fails short, forcing the firmware upload.
|
||||
(0, if self.unlock_calls == 1 { 10 } else { 64 })
|
||||
} else {
|
||||
data.fill(0);
|
||||
(0, data.len())
|
||||
};
|
||||
Ok(ScsiResult {
|
||||
status,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let id = known_vid_drive_id();
|
||||
let sig = profile::find_bundled(&id)
|
||||
.expect("profile")
|
||||
.profile
|
||||
.signature;
|
||||
let mut t = UploadThenNotReady {
|
||||
sig,
|
||||
unlock_calls: 0,
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
TEST_READY_CLOCK.set(Some(|| Box::new(FakeClock::new())));
|
||||
let r = LdUnlocker::new().unlock(&mut t, &ctx(&id));
|
||||
TEST_READY_CLOCK.set(None);
|
||||
let unlocked = r.expect("no fault");
|
||||
assert!(
|
||||
unlocked.is_some(),
|
||||
"the confirmed unlock stands despite the stall"
|
||||
);
|
||||
let uploaded = t.cdbs.iter().any(|c| matches!(c[0], 0x3B | 0x55));
|
||||
assert!(uploaded, "the firmware upload ran");
|
||||
let last_tur = t
|
||||
.cdbs
|
||||
.iter()
|
||||
.rposition(|c| c[0] == 0x00)
|
||||
.expect("polled TUR");
|
||||
let turs = t.cdbs.iter().filter(|c| c[0] == 0x00).count();
|
||||
assert_eq!(turs, 121, "polled until the 60 s stall (virtual clock)");
|
||||
let capacity = t
|
||||
.cdbs
|
||||
.iter()
|
||||
.rposition(|c| c[0] == 0x25)
|
||||
.expect("probe_disc ran");
|
||||
assert!(capacity > last_tur, "probe_disc runs after the poll");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_fault_during_unlock_is_transport_not_not_applicable() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let err = LdUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&known_vid_drive_id()))
|
||||
.expect_err("dead bus");
|
||||
assert_eq!(err, UnlockError::Transport);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Platform-specific drive unlock + disc probing (LibreDrive internals).
|
||||
//! Platform-specific drive unlock + disc probing (LdUnlocker internals).
|
||||
|
||||
pub mod mt1959;
|
||||
|
||||
@@ -17,9 +17,15 @@ pub(crate) trait PlatformDriver: Send {
|
||||
#[allow(dead_code)]
|
||||
fn is_ready(&self) -> bool;
|
||||
|
||||
/// True if the drive is currently in the extended-access state.
|
||||
#[allow(dead_code)]
|
||||
/// True if the drive is currently in the extended-access state. THE gate
|
||||
/// `LdUnlocker::firmware_unlock` reports `drive_unlocked` from — `init()`
|
||||
/// returning `Ok` only means the handshake completed, not that the drive
|
||||
/// serves clear content.
|
||||
fn is_unlocked(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
use super::*;
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
|
||||
// Minimal driver taking the `PlatformDriver` default `is_unlocked`, so
|
||||
// the default's `false` path is exercised (real drivers override it).
|
||||
struct StubDriver;
|
||||
|
||||
impl PlatformDriver for StubDriver {
|
||||
fn init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
scsi.execute(&[0], crate::scsi::DataDirection::None, &mut [], 0)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn probe_disc(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
scsi.execute(&[0], crate::scsi::DataDirection::None, &mut [], 0)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_unlocked_is_false_and_trait_methods_dispatch() {
|
||||
let mut drv = StubDriver;
|
||||
let mut scsi = MockTransport::always(Reply::good(vec![]));
|
||||
assert!(drv.init(&mut scsi).is_ok());
|
||||
assert!(drv.probe_disc(&mut scsi).is_ok());
|
||||
assert!(drv.is_ready());
|
||||
assert!(!drv.is_unlocked());
|
||||
}
|
||||
+402
-286
@@ -6,7 +6,8 @@ mod variant_b;
|
||||
use super::PlatformDriver;
|
||||
use crate::ld::error::{Error, Result};
|
||||
use crate::ld::profile::DriveProfile;
|
||||
use crate::scsi::{self, DataDirection, ScsiTransport};
|
||||
use crate::scsi::{self, DataDirection, ScsiSense, ScsiTransport};
|
||||
use std::time::Duration;
|
||||
|
||||
// ── Variant constants ──────────────────────────────────────────────────
|
||||
// Every vendor command: 3C [mode] [buffer_id] [sub_cmd] [addr] ...
|
||||
@@ -18,6 +19,7 @@ const BUFFER_ID_B: u8 = 0x77;
|
||||
// ── SCSI opcodes ──────────────────────────────────────────────────────
|
||||
const SCSI_READ_BUFFER: u8 = 0x3C;
|
||||
const SCSI_READ_CAPACITY: u8 = 0x25;
|
||||
const SCSI_TEST_UNIT_READY: u8 = 0x00;
|
||||
/// Shared by both firmware-upload variants (see `variant_a` / `variant_b`).
|
||||
pub(super) const SCSI_WRITE_BUFFER: u8 = 0x3B;
|
||||
|
||||
@@ -37,6 +39,23 @@ const FIRMWARE_ACTIVE_SIG: [u8; 4] = [0x4D, 0x4D, 0x6B, 0x76];
|
||||
/// response, not a stale image's residual buffer.
|
||||
const FIRMWARE_MODE_OFFSET: usize = 16;
|
||||
const FIRMWARE_MODE_SIG: [u8; 4] = [0x4C, 0x62, 0x44, 0x72];
|
||||
/// Fewest bytes an unlock response must carry before ANY of its three checks
|
||||
/// mean anything — through the secondary marker at [16..20].
|
||||
const MIN_UNLOCK_RESPONSE: usize = FIRMWARE_MODE_OFFSET + 4;
|
||||
|
||||
// ── Post-upload readiness poll (T6, stop-design-v5 §2.11 / §3.1) ──────
|
||||
/// Between TEST UNIT READY polls; libfreemkv `wait_ready` polls at 500 ms too.
|
||||
const READY_POLL: Duration = Duration::from_millis(500);
|
||||
/// T6: fail only after this long with no progress in the not-ready answers.
|
||||
const READY_STALL: Duration = Duration::from_secs(60);
|
||||
const TUR_TIMEOUT_MS: u32 = 5_000;
|
||||
/// Consecutive 02/3A answers, with no 02/04/01 seen, that mean an empty drive
|
||||
/// (~5 s); libfreemkv `wait_ready`'s `WAIT_READY_MAX_EMPTY_POLLS`.
|
||||
const READY_MAX_EMPTY_POLLS: u32 = 10;
|
||||
/// START STOP UNIT (1Bh), LoEj 0 / Start 1 (MMC-6 Table 631, byte 4 bit 0).
|
||||
const START_UNIT: [u8; 6] = [0x1B, 0x00, 0x00, 0x00, 0x01, 0x00];
|
||||
/// libfreemkv's START UNIT timeout (T4).
|
||||
const START_UNIT_TIMEOUT_MS: u32 = 30_000;
|
||||
|
||||
// ── Init address (per disc type) ──────────────────────────────────────
|
||||
const INIT_ADDR_BD: u16 = 0x0100;
|
||||
@@ -114,20 +133,22 @@ impl Mt1959 {
|
||||
expected: usize,
|
||||
) -> Result<usize> {
|
||||
// The READ_BUFFER CDB transfer-length is a single byte; an
|
||||
// `expected` above 255 cannot be expressed and would silently
|
||||
// truncate. All in-crate callers pass small fixed sizes (4); guard
|
||||
// the invariant rather than emit a malformed CDB.
|
||||
// `expected` above 255 would silently truncate. Guard the
|
||||
// invariant rather than emit a malformed CDB.
|
||||
debug_assert!(
|
||||
expected <= u8::MAX as usize,
|
||||
"read_buffer_probe expected exceeds 1-byte CDB length field"
|
||||
);
|
||||
let cdb = self.read_buffer_sub(sub_cmd, address, expected as u8);
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, buf, 5_000)?;
|
||||
if result.bytes_transferred != expected {
|
||||
// A drive sense arrives as `Ok` with a non-zero status; a merely
|
||||
// SHORT response is the drive answering badly — neither is a dead
|
||||
// bus.
|
||||
if result.status != 0 || result.bytes_transferred != expected {
|
||||
return Err(Error::Scsi {
|
||||
opcode: SCSI_READ_BUFFER,
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
status: result.status,
|
||||
sense: Some(result.sense),
|
||||
});
|
||||
}
|
||||
Ok(result.bytes_transferred)
|
||||
@@ -158,38 +179,51 @@ impl Mt1959 {
|
||||
let mut response = vec![0u8; UNLOCK_RESPONSE_SIZE as usize];
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, &mut response, 30_000)?;
|
||||
|
||||
// `response` is a fixed 64-byte buffer, so `response.len()` is
|
||||
// always >= every offset below — the meaningful bound is how many
|
||||
// bytes the drive actually delivered. Validate against
|
||||
// `bytes_transferred` so a short/partial transfer (stale trailing
|
||||
// zeros) can't be read as if the drive sent real marker bytes.
|
||||
let n = result.bytes_transferred.min(response.len());
|
||||
// A drive sense arrives as `Ok` with a non-zero status; treating it as
|
||||
// a successful unlock would validate the caller's own zero fill.
|
||||
if result.status != 0 {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_unlock_check_condition",
|
||||
status = result.status,
|
||||
"unlock READ_BUFFER returned a drive sense"
|
||||
);
|
||||
return Err(Error::Scsi {
|
||||
opcode: SCSI_READ_BUFFER,
|
||||
status: result.status,
|
||||
sense: Some(result.sense),
|
||||
});
|
||||
}
|
||||
|
||||
if n >= 4 && response[0..4] != self.profile.signature {
|
||||
// `response` is a fixed 64-byte buffer; the meaningful bound is how
|
||||
// many bytes the drive actually delivered. Require enough bytes to
|
||||
// validate before checking markers, up front.
|
||||
let n = result.bytes_transferred.min(response.len());
|
||||
if n < MIN_UNLOCK_RESPONSE {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_unlock_short_response",
|
||||
bytes_transferred = result.bytes_transferred,
|
||||
"unlock response too short to validate"
|
||||
);
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
|
||||
if response[0..4] != self.profile.signature {
|
||||
return Err(Error::SignatureMismatch {
|
||||
expected: self.profile.signature,
|
||||
got: response[0..4].try_into().unwrap_or([0; 4]),
|
||||
});
|
||||
}
|
||||
|
||||
if n >= FIRMWARE_ACTIVE_OFFSET + 4
|
||||
&& response[FIRMWARE_ACTIVE_OFFSET..FIRMWARE_ACTIVE_OFFSET + 4] != FIRMWARE_ACTIVE_SIG
|
||||
{
|
||||
if response[FIRMWARE_ACTIVE_OFFSET..FIRMWARE_ACTIVE_OFFSET + 4] != FIRMWARE_ACTIVE_SIG {
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
|
||||
// Extended-access state is active when BOTH the per-drive
|
||||
// signature matched AND the response carries the secondary
|
||||
// marker at offset 16 (repeated through bytes 16..64) AND the
|
||||
// primary mode marker at [12..16] is present. The active-mode
|
||||
// marker at [12..16] is the primary gate; the [16..20] marker
|
||||
// is the redundant confirmation the firmware writes through
|
||||
// the rest of the response. Requiring both before we tell the
|
||||
// upper layer "OEM path is live" keeps any partial / corrupted
|
||||
// response from steering us off the cert-auth fallback.
|
||||
self.unlocked = n >= FIRMWARE_MODE_OFFSET + 4
|
||||
&& response[FIRMWARE_ACTIVE_OFFSET..FIRMWARE_ACTIVE_OFFSET + 4] == FIRMWARE_ACTIVE_SIG
|
||||
&& response[FIRMWARE_MODE_OFFSET..FIRMWARE_MODE_OFFSET + 4] == FIRMWARE_MODE_SIG;
|
||||
// Extended-access state requires the signature match AND both the
|
||||
// primary marker at [12..16] AND the secondary marker at [16..20].
|
||||
self.unlocked =
|
||||
response[FIRMWARE_MODE_OFFSET..FIRMWARE_MODE_OFFSET + 4] == FIRMWARE_MODE_SIG;
|
||||
|
||||
self.init_complete = true;
|
||||
Ok(response)
|
||||
@@ -210,52 +244,132 @@ impl Mt1959 {
|
||||
0x00,
|
||||
];
|
||||
let mut resp = [0u8; 4];
|
||||
if scsi
|
||||
.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000)
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
match scsi.execute(&cdb, DataDirection::FromDevice, &mut resp, 5_000) {
|
||||
// A drive sense arrives as `Ok`; only a GOOD status is a pass.
|
||||
Ok(r) if r.status == 0 => return Ok(()),
|
||||
Ok(r) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_validate_check_condition",
|
||||
status = r.status,
|
||||
"validate READ_BUFFER returned a drive sense"
|
||||
);
|
||||
}
|
||||
// A dead bus will not recover across five more retries, and
|
||||
// labelling every other failure "transport" made the consumer
|
||||
// abort rips it could have completed. Propagate the real fault.
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_validate_transport_fault",
|
||||
"transport fault during validate; aborting"
|
||||
);
|
||||
return Err(Error::from(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(Error::Scsi {
|
||||
opcode: SCSI_READ_BUFFER,
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
})
|
||||
Err(Error::UnlockFailed)
|
||||
}
|
||||
|
||||
// ── Init (unlock + firmware) ───────────────────────────────────────
|
||||
|
||||
fn run_init(&mut self, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let mut succeeded = false;
|
||||
for _attempt in 0..3 {
|
||||
let mut last_err = Error::UnlockFailed;
|
||||
for attempt in 0..3 {
|
||||
match self.do_unlock(scsi) {
|
||||
Ok(_) => {
|
||||
succeeded = true;
|
||||
break;
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_unlock_ok",
|
||||
attempt,
|
||||
unlocked = self.unlocked,
|
||||
"MT1959 unlock handshake completed"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Err(Error::SignatureMismatch { .. }) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_signature_mismatch",
|
||||
attempt,
|
||||
"unlock response carried another drive's signature"
|
||||
);
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
Err(_) => {
|
||||
// A dead bus must abort here, not be retried three times as
|
||||
// firmware reloads.
|
||||
Err(e) if e.is_transport_failure() => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_transport_fault",
|
||||
attempt,
|
||||
"transport fault during unlock; aborting"
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
// Every exit below returns or overwrites `last_err` with the reload's error.
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_unlock_failed_reloading",
|
||||
attempt,
|
||||
error = %e,
|
||||
"unlock failed; reloading firmware"
|
||||
);
|
||||
let loaded = if self.mode == MODE_A {
|
||||
variant_a::load_firmware(self, scsi).is_ok()
|
||||
variant_a::load_firmware(self, scsi)
|
||||
} else {
|
||||
variant_b::load_firmware(self, scsi).is_ok()
|
||||
variant_b::load_firmware(self, scsi)
|
||||
};
|
||||
if !loaded {
|
||||
continue;
|
||||
match loaded {
|
||||
// Same rule on the upload path: a dead bus aborts.
|
||||
Err(e) if e.is_transport_failure() => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_transport_fault",
|
||||
attempt,
|
||||
"transport fault during firmware upload; aborting"
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_firmware_upload_failed",
|
||||
attempt,
|
||||
error = %e,
|
||||
"firmware upload failed; retrying unlock"
|
||||
);
|
||||
last_err = e;
|
||||
continue;
|
||||
}
|
||||
// D10 (stop-design-v5 §1): "`run_init` must `return Ok(())` as soon as
|
||||
// `load_firmware` confirms the unlock" — its own do_unlock already did.
|
||||
// Looping re-ran it after a 10 s settle and discarded it on attempt 2.
|
||||
Ok(()) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_unlock_ok_after_upload",
|
||||
attempt,
|
||||
unlocked = self.unlocked,
|
||||
"MT1959 unlock confirmed by the firmware upload"
|
||||
);
|
||||
// The upload reset the drive; the unlock proves only the vendor
|
||||
// path answers. Hand it back once the media is ready (T6). Any
|
||||
// readiness outcome keeps the unlock; only a dead bus / Stop fails.
|
||||
await_media_ready(scsi, &mut *ready_clock())?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Firmware upload resets the drive. Give it time to
|
||||
// fully recover before retrying unlock.
|
||||
std::thread::sleep(std::time::Duration::from_secs(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !succeeded {
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
Ok(())
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_unlock_exhausted",
|
||||
"MT1959 unlock did not succeed after 3 attempts"
|
||||
);
|
||||
Err(last_err)
|
||||
}
|
||||
|
||||
// ── Probe disc ─────────────────────────────────────────────────────
|
||||
@@ -268,10 +382,8 @@ impl Mt1959 {
|
||||
self.do_unlock(scsi)?;
|
||||
}
|
||||
|
||||
// Detect disc type from capacity to select probe mode.
|
||||
// BD: 3C 01 44 12 01 00 00 00 04 00 (init_addr = 0x0100)
|
||||
// UHD: 3C 01 44 12 02 00 00 00 04 00 (init_addr = 0x0200)
|
||||
// Empirically verified via SCSI capture: BD and UHD use different init addresses.
|
||||
// Detect disc type from capacity to select probe mode (BD vs UHD
|
||||
// init address).
|
||||
let cap_cdb = [
|
||||
SCSI_READ_CAPACITY,
|
||||
0x00,
|
||||
@@ -285,14 +397,12 @@ impl Mt1959 {
|
||||
0x00,
|
||||
];
|
||||
let mut cap_buf = [0u8; READ_CAPACITY_RESPONSE_SIZE];
|
||||
let disc_sectors = if scsi
|
||||
.execute(&cap_cdb, DataDirection::FromDevice, &mut cap_buf, 5_000)
|
||||
.is_ok()
|
||||
{
|
||||
// last_lba + 1 = sector count. A 0xFFFFFFFF last-LBA is the
|
||||
// READ CAPACITY(10) "capacity exceeds 32 bits" sentinel; saturate
|
||||
// rather than wrap to 0 (which would misclassify a huge disc as
|
||||
// BD). A saturated count stays above the UHD threshold -> UHD.
|
||||
// A transport fault here is a dead bus: propagate it. A drive
|
||||
// sense or short reply just means "capacity unknown" -> assume BD.
|
||||
let cap = scsi.execute(&cap_cdb, DataDirection::FromDevice, &mut cap_buf, 5_000)?;
|
||||
let disc_sectors = if cap.status == 0 && cap.bytes_transferred >= 4 {
|
||||
// last_lba + 1 = sector count; saturate on the 32-bit-overflow
|
||||
// sentinel rather than wrap to 0.
|
||||
u32::from_be_bytes([cap_buf[0], cap_buf[1], cap_buf[2], cap_buf[3]]).saturating_add(1)
|
||||
} else {
|
||||
0
|
||||
@@ -317,7 +427,7 @@ impl Mt1959 {
|
||||
let mut addr: u16 = 0;
|
||||
while addr < PROBE_COARSE_END {
|
||||
let mut resp = [0u8; PROBE_RESPONSE_SIZE as usize];
|
||||
if self
|
||||
if let Err(e) = self
|
||||
.read_buffer_probe(
|
||||
scsi,
|
||||
SUB_CMD_PROBE,
|
||||
@@ -325,31 +435,57 @@ impl Mt1959 {
|
||||
&mut resp,
|
||||
PROBE_RESPONSE_SIZE as usize,
|
||||
)
|
||||
.is_err()
|
||||
.inspect_err(|e| {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_probe_failed",
|
||||
addr,
|
||||
error = %e,
|
||||
"coarse speed probe failed"
|
||||
);
|
||||
})
|
||||
{
|
||||
return Err(Error::Scsi {
|
||||
opcode: SCSI_READ_BUFFER,
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
// A dead bus MUST keep its transport classification; a
|
||||
// short/rejected reply is the drive's own failure and stays
|
||||
// `UnlockFailed`.
|
||||
if e.is_transport_failure() {
|
||||
return Err(e);
|
||||
}
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
addr = addr.wrapping_add(PROBE_STEP);
|
||||
}
|
||||
|
||||
// Pass 2: fine scan
|
||||
let mut addr: u32 = 0;
|
||||
// Pass 2: continue past the coarse range (do not restart at 0).
|
||||
let mut addr: u32 = PROBE_COARSE_END as u32;
|
||||
while addr < PROBE_FINE_END {
|
||||
let mut resp = [0u8; PROBE_RESPONSE_SIZE as usize];
|
||||
if self
|
||||
.read_buffer_probe(
|
||||
scsi,
|
||||
SUB_CMD_PROBE,
|
||||
addr as u16,
|
||||
&mut resp,
|
||||
PROBE_RESPONSE_SIZE as usize,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
if let Err(e) = self.read_buffer_probe(
|
||||
scsi,
|
||||
SUB_CMD_PROBE,
|
||||
addr as u16,
|
||||
&mut resp,
|
||||
PROBE_RESPONSE_SIZE as usize,
|
||||
) {
|
||||
// Fine probing is best-effort — a short/rejected reply just ends
|
||||
// the sweep early, but a transport fault must keep its
|
||||
// classification and abort.
|
||||
if e.is_transport_failure() {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_probe_transport_fault",
|
||||
addr,
|
||||
"transport fault during fine speed probe; aborting"
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_probe_failed",
|
||||
addr,
|
||||
error = %e,
|
||||
"fine speed probe ended early"
|
||||
);
|
||||
break;
|
||||
}
|
||||
addr += PROBE_STEP as u32;
|
||||
@@ -363,6 +499,184 @@ impl Mt1959 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Time and waiting for [`await_media_ready`]: a seam so tests run the 60 s
|
||||
/// stall window on a virtual clock.
|
||||
pub(crate) trait ReadyClock {
|
||||
/// Time since the poll began.
|
||||
fn elapsed(&self) -> Duration;
|
||||
/// Wait `d` before the next poll.
|
||||
fn wait(&mut self, scsi: &mut dyn ScsiTransport, d: Duration) -> scsi::Result<()>;
|
||||
}
|
||||
|
||||
/// The poll's clock: [`WallClock`], or a test's virtual clock (a per-thread seam,
|
||||
/// so an [`crate::LdUnlocker`] test can stall the poll without waiting 60 s).
|
||||
fn ready_clock() -> Box<dyn ReadyClock> {
|
||||
#[cfg(test)]
|
||||
if let Some(make) = tests::TEST_READY_CLOCK.get() {
|
||||
return make();
|
||||
}
|
||||
Box::new(WallClock::start())
|
||||
}
|
||||
|
||||
/// The production clock: real time, and the transport's cancellable `pause`
|
||||
/// between polls (stop-design-v5 §2.3), so a Stop ends the wait at once.
|
||||
struct WallClock(std::time::Instant);
|
||||
|
||||
impl WallClock {
|
||||
fn start() -> Self {
|
||||
WallClock(std::time::Instant::now())
|
||||
}
|
||||
}
|
||||
|
||||
impl ReadyClock for WallClock {
|
||||
fn elapsed(&self) -> Duration {
|
||||
self.0.elapsed()
|
||||
}
|
||||
fn wait(&mut self, scsi: &mut dyn ScsiTransport, d: Duration) -> scsi::Result<()> {
|
||||
scsi.pause(d)
|
||||
}
|
||||
}
|
||||
|
||||
/// How the post-upload readiness poll ended. Every outcome keeps the unlock; the
|
||||
/// non-`Ready` ones are logged at warn and left to the caller's next command.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum MediaReady {
|
||||
Ready,
|
||||
/// 10 consecutive 02/3A/xx with no 02/04/01 first: an empty drive.
|
||||
NoMedium,
|
||||
/// 02/30/xx: a medium that will never become ready.
|
||||
Incompatible {
|
||||
asc: u8,
|
||||
ascq: u8,
|
||||
},
|
||||
/// Sense key 3, 4 or 5: not a readiness answer.
|
||||
DriveError {
|
||||
sense_key: u8,
|
||||
asc: u8,
|
||||
ascq: u8,
|
||||
},
|
||||
/// T6: no progress for [`READY_STALL`]; carries the last answer.
|
||||
Stalled {
|
||||
sense_key: u8,
|
||||
asc: u8,
|
||||
ascq: u8,
|
||||
},
|
||||
}
|
||||
|
||||
/// Poll TEST UNIT READY until the drive answers ready, following libfreemkv
|
||||
/// `wait_ready`'s terminal states but always keeping the unlock (it succeeded).
|
||||
/// T6 (stop-design-v5 §2.11): progress is an answer (sense key, ASC, ASCQ) not
|
||||
/// yet seen in this poll, or the one START STOP UNIT; [`READY_STALL`] without
|
||||
/// progress ends it. No total cap. `Err` only for a dead bus or a Stop.
|
||||
fn await_media_ready(
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
clock: &mut dyn ReadyClock,
|
||||
) -> Result<MediaReady> {
|
||||
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut seen: Vec<(u8, u8, u8)> = Vec::new();
|
||||
let mut last_progress = clock.elapsed();
|
||||
let (mut start_sent, mut empty_run, mut becoming_ready_seen) = (false, 0u32, false);
|
||||
for polls in 1u64.. {
|
||||
// A drive answer arrives as `Ok` + status, or (libfreemkv's adapter) as an
|
||||
// `Err` carrying the sense; only a senseless `Err` is a dead bus or a Stop.
|
||||
let sense = match scsi.execute(&tur, DataDirection::None, &mut [], TUR_TIMEOUT_MS) {
|
||||
Ok(r) if r.status == 0 => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_media_ready",
|
||||
polls,
|
||||
elapsed_ms = clock.elapsed().as_millis() as u64,
|
||||
"drive ready after the firmware upload"
|
||||
);
|
||||
return Ok(MediaReady::Ready);
|
||||
}
|
||||
Ok(r) => r.sense,
|
||||
Err(e) => match e.sense {
|
||||
Some(sense) => sense,
|
||||
None => return Err(Error::from(e)),
|
||||
},
|
||||
};
|
||||
let s = ScsiSense::from_buf(&sense);
|
||||
let answer = (s.sense_key, s.asc, s.ascq);
|
||||
// MMC-6 Table F.3: "2 3A 00 MEDIUM NOT PRESENT"; "2 04 01 LOGICAL UNIT IS IN
|
||||
// PROCESS OF BECOMING READY" first means the 3A run is not final.
|
||||
empty_run = if (s.sense_key, s.asc) == (0x02, 0x3A) {
|
||||
empty_run + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
becoming_ready_seen |= answer == (0x02, 0x04, 0x01);
|
||||
let outcome = match answer {
|
||||
_ if !becoming_ready_seen && empty_run >= READY_MAX_EMPTY_POLLS => {
|
||||
Some(MediaReady::NoMedium)
|
||||
}
|
||||
// Table F.3: "2 30 00 INCOMPATIBLE MEDIUM INSTALLED" (30/xx never ready).
|
||||
(0x02, 0x30, ascq) => Some(MediaReady::Incompatible { asc: 0x30, ascq }),
|
||||
// MEDIUM ERROR; HARDWARE ERROR (F.3.8 "reported when SK = HARDWARE ERROR");
|
||||
// ILLEGAL REQUEST (F.3.2 Table F.2): no readiness to wait for.
|
||||
(sense_key @ 0x03..=0x05, asc, ascq) => Some(MediaReady::DriveError {
|
||||
sense_key,
|
||||
asc,
|
||||
ascq,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(outcome) = outcome {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_media_not_ready",
|
||||
polls,
|
||||
outcome = ?outcome,
|
||||
"drive not ready after the firmware upload; keeping the unlock"
|
||||
);
|
||||
return Ok(outcome);
|
||||
}
|
||||
// Table F.3: "2 04 02 LOGICAL UNIT NOT READY, INITIALIZING CMD. REQUIRED" →
|
||||
// one START STOP UNIT, Table 633 LoEj 0 Start 1: "Start the disc and make
|
||||
// ready for access". Counted as progress.
|
||||
if answer == (0x02, 0x04, 0x02) && !start_sent {
|
||||
start_sent = true;
|
||||
last_progress = clock.elapsed();
|
||||
let r = scsi.execute(
|
||||
&START_UNIT,
|
||||
DataDirection::None,
|
||||
&mut [],
|
||||
START_UNIT_TIMEOUT_MS,
|
||||
);
|
||||
if let Err(e @ crate::scsi::ScsiError { sense: None, .. }) = r {
|
||||
return Err(Error::from(e));
|
||||
}
|
||||
}
|
||||
// Everything else (04/xx, 06/29/00 and 06/28/00 UNIT ATTENTION after the
|
||||
// upload's reset, …) keeps polling under the T6 rule.
|
||||
if !seen.contains(&answer) {
|
||||
// TODO(T6 part 2, stop-design-v5 §2.11): also count a rising SKSV progress
|
||||
// indicator (sense bytes 15-17) as progress, once SS-1 is quoted here.
|
||||
seen.push(answer);
|
||||
last_progress = clock.elapsed();
|
||||
} else if clock.elapsed().saturating_sub(last_progress) >= READY_STALL {
|
||||
let (sense_key, asc, ascq) = answer;
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_media_ready_stalled",
|
||||
polls,
|
||||
sense_key,
|
||||
asc,
|
||||
ascq,
|
||||
"drive not ready after the firmware upload, no progress for 60 s; \
|
||||
keeping the unlock"
|
||||
);
|
||||
return Ok(MediaReady::Stalled {
|
||||
sense_key,
|
||||
asc,
|
||||
ascq,
|
||||
});
|
||||
}
|
||||
clock.wait(scsi, READY_POLL)?;
|
||||
}
|
||||
unreachable!("the poll returns from inside the loop")
|
||||
}
|
||||
|
||||
// ── PlatformDriver trait ───────────────────────────────────────────────
|
||||
|
||||
impl PlatformDriver for Mt1959 {
|
||||
@@ -395,203 +709,5 @@ impl PlatformDriver for Mt1959 {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ld::profile::{DriveProfile, Identity};
|
||||
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
|
||||
|
||||
/// Minimal mock transport that returns a scripted response to the
|
||||
/// next `execute()` call. Only used for verifying that `do_unlock`
|
||||
/// classifies the response correctly — no general SCSI coverage.
|
||||
struct ScriptedTransport {
|
||||
response: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ScsiTransport for ScriptedTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
let n = self.response.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.response[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Records every CDB issued, so a test can assert which bytes hit the wire.
|
||||
struct RecordingTransport {
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ScsiTransport for RecordingTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
// Empty response → do_unlock's signature check fails, so the unlock
|
||||
// loop exhausts — but the firmware-load CDBs (incl. the F1 verify)
|
||||
// are already recorded by then.
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 0,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// variant_b must issue the PROFILE's per-drive `fw_verify_cdb`, not the
|
||||
/// hardcoded fallback const — the bug that broke ~139 of 140 B drives.
|
||||
#[test]
|
||||
fn variant_b_issues_profile_fw_verify_cdb_not_const() {
|
||||
let drive_f1 = [0xF1, 0x01, 0x02, 0x00, 0x0C, 0xF0, 0x01, 0xFB, 0xC9, 0x93];
|
||||
// variant_b's hardcoded fallback const (a different drive's token).
|
||||
let fallback_f1 = [0xF1, 0x01, 0x02, 0x00, 0x0D, 0x30, 0x01, 0xF3, 0xAD, 0x23];
|
||||
let mut profile = fixture_profile([0x9a, 0xa9, 0x3a, 0xe2]);
|
||||
profile.firmware = vec![0u8; 2208]; // a real per-drive length, != old 0x9C0
|
||||
profile.fw_verify_cdb = Some(drive_f1);
|
||||
|
||||
let mut mt = Mt1959::new(profile, true);
|
||||
let mut t = RecordingTransport { cdbs: Vec::new() };
|
||||
let _ = variant_b::load_firmware(&mut mt, &mut t);
|
||||
|
||||
assert!(
|
||||
t.cdbs.iter().any(|c| c.as_slice() == drive_f1),
|
||||
"must send the profile's F1 verify CDB"
|
||||
);
|
||||
assert!(
|
||||
!t.cdbs.iter().any(|c| c.as_slice() == fallback_f1),
|
||||
"must NOT send the hardcoded fallback const when the profile has its own"
|
||||
);
|
||||
// MODE SELECT must encode the real per-drive length (2208), not 0x9C0.
|
||||
let ms = t
|
||||
.cdbs
|
||||
.iter()
|
||||
.find(|c| c.first() == Some(&0x55))
|
||||
.expect("MODE SELECT issued");
|
||||
let len = ((ms[7] as usize) << 8) | ms[8] as usize;
|
||||
assert_eq!(len, 2208, "MODE SELECT length = firmware.len(), not 2496");
|
||||
}
|
||||
|
||||
fn fixture_profile(signature: [u8; 4]) -> DriveProfile {
|
||||
DriveProfile {
|
||||
identity: Identity {
|
||||
vendor_id: "TEST".into(),
|
||||
product_id: String::new(),
|
||||
product_revision: String::new(),
|
||||
vendor_specific: String::new(),
|
||||
firmware_date: String::new(),
|
||||
},
|
||||
signature,
|
||||
firmware: Vec::new(),
|
||||
unlock_init_value: 0,
|
||||
unlock_response_size: 0,
|
||||
read_vid_cdb: None,
|
||||
read_disc_keys_cdb: None,
|
||||
drive_nominal_speed_cdb: None,
|
||||
set_speed_max_cdb: None,
|
||||
read10_raw_2sec_cdb: None,
|
||||
read10_raw_1sec_cdb: None,
|
||||
read_buffer_verify_cdb: None,
|
||||
write_buffer_cdb: None,
|
||||
read_buffer_unlock_cdb: None,
|
||||
fw_verify_cdb: None,
|
||||
speed_zone_table: None,
|
||||
speed_calc_table: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a synthetic 64-byte unlock response.
|
||||
///
|
||||
/// `mode_marker`: bytes [12..16]. Pass `FIRMWARE_ACTIVE_SIG` for the
|
||||
/// active-mode primary marker.
|
||||
/// `id_marker`: bytes [16..20] (and repeated through [20..64] in
|
||||
/// real responses; only [16..20] is checked).
|
||||
fn build_response(signature: [u8; 4], mode_marker: [u8; 4], id_marker: [u8; 4]) -> Vec<u8> {
|
||||
let mut r = vec![0u8; 64];
|
||||
r[0..4].copy_from_slice(&signature);
|
||||
// bytes [4..12] left as zeros (version + reserved per format)
|
||||
r[12..16].copy_from_slice(&mode_marker);
|
||||
// Real firmware repeats the secondary marker through [16..64];
|
||||
// the parser only checks [16..20], so we just write the marker
|
||||
// once.
|
||||
r[16..20].copy_from_slice(&id_marker);
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn do_unlock_sets_unlocked_when_both_markers_present() {
|
||||
let sig = [0x99, 0x9E, 0xC3, 0x75];
|
||||
let response = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG);
|
||||
let mut transport = ScriptedTransport { response };
|
||||
let mut mt = Mt1959::new(fixture_profile(sig), false);
|
||||
|
||||
let raw = mt.do_unlock(&mut transport).expect("unlock should succeed");
|
||||
assert_eq!(raw.len(), 64);
|
||||
assert!(mt.init_complete, "init_complete set after success");
|
||||
assert!(
|
||||
mt.is_unlocked(),
|
||||
"both markers present -> extended-access state"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn do_unlock_init_complete_but_not_unlocked_when_id_marker_missing() {
|
||||
// Primary mode marker present (so init passes) but the
|
||||
// secondary marker is replaced with zeros — drive isn't in
|
||||
// extended-access state.
|
||||
let sig = [0x99, 0x9E, 0xC3, 0x75];
|
||||
let response = build_response(sig, FIRMWARE_ACTIVE_SIG, [0u8; 4]);
|
||||
let mut transport = ScriptedTransport { response };
|
||||
let mut mt = Mt1959::new(fixture_profile(sig), false);
|
||||
|
||||
mt.do_unlock(&mut transport).expect("unlock should succeed");
|
||||
assert!(mt.init_complete);
|
||||
assert!(
|
||||
!mt.is_unlocked(),
|
||||
"missing secondary marker -> not in extended-access state"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn do_unlock_rejects_signature_mismatch() {
|
||||
let response = build_response(
|
||||
[0xAA, 0xBB, 0xCC, 0xDD],
|
||||
FIRMWARE_ACTIVE_SIG,
|
||||
FIRMWARE_MODE_SIG,
|
||||
);
|
||||
let mut transport = ScriptedTransport { response };
|
||||
let mut mt = Mt1959::new(fixture_profile([0x99, 0x9E, 0xC3, 0x75]), false);
|
||||
|
||||
let err = mt.do_unlock(&mut transport).unwrap_err();
|
||||
assert!(matches!(err, Error::SignatureMismatch { .. }));
|
||||
assert!(!mt.init_complete);
|
||||
assert!(!mt.is_unlocked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn do_unlock_rejects_inactive_mode_marker() {
|
||||
// Signature matches but the primary marker at [12..16] is
|
||||
// missing -> drive is not in active mode; init_complete and the
|
||||
// unlocked flag must both stay false.
|
||||
let sig = [0x99, 0x9E, 0xC3, 0x75];
|
||||
let response = build_response(sig, [0u8; 4], FIRMWARE_MODE_SIG);
|
||||
let mut transport = ScriptedTransport { response };
|
||||
let mut mt = Mt1959::new(fixture_profile(sig), false);
|
||||
|
||||
let err = mt.do_unlock(&mut transport).unwrap_err();
|
||||
assert!(matches!(err, Error::UnlockFailed));
|
||||
assert!(!mt.init_complete);
|
||||
assert!(!mt.is_unlocked());
|
||||
}
|
||||
}
|
||||
#[path = "mod_tests.rs"]
|
||||
pub(crate) mod tests;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
|
||||
use super::Mt1959;
|
||||
use crate::ld::error::Result;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::scsi::{CriticalGuard, DataDirection, ScsiTransport};
|
||||
|
||||
use super::SCSI_WRITE_BUFFER;
|
||||
|
||||
@@ -21,9 +21,8 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
}
|
||||
|
||||
// Upload firmware via WRITE_BUFFER. The CDB's length is a 24-bit field;
|
||||
// if the blob exceeds that, the encoded length would silently disagree
|
||||
// with the bytes actually sent (`data`). Reject rather than upload a
|
||||
// length-mismatched command.
|
||||
// reject blobs that would exceed it rather than send a length-mismatched
|
||||
// command.
|
||||
let len = firmware.len();
|
||||
if len > WRITE_BUFFER_MAX_LEN {
|
||||
return Err(crate::ld::error::Error::UnlockFailed);
|
||||
@@ -41,9 +40,14 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
0x00,
|
||||
];
|
||||
let mut data = firmware.clone();
|
||||
scsi.execute(&cdb, DataDirection::ToDevice, &mut data, 30_000)?;
|
||||
// D8 (stop-design-v5 §2.3): "the WRITE BUFFER at 30 s … and its verify run inside
|
||||
// a `CriticalGuard`. C_max = 35 s" — a Stop mid-upload waits for both CDBs.
|
||||
let mut span = CriticalGuard::enter(&mut *scsi)?;
|
||||
span.execute(&cdb, DataDirection::ToDevice, &mut data, 30_000)?;
|
||||
|
||||
// Verify firmware loaded (non-fatal — different buffer_id 0x45)
|
||||
// Verify the firmware loaded (non-fatal — different buffer_id 0x45). No
|
||||
// documented expected payload exists, so the outcome is traced rather
|
||||
// than asserted, instead of silently discarded before the unlock retries.
|
||||
let verify_cdb = [
|
||||
super::SCSI_READ_BUFFER,
|
||||
super::MODE_A,
|
||||
@@ -57,17 +61,49 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
0x00,
|
||||
];
|
||||
let mut verify_resp = [0u8; super::VALIDATE_RESPONSE_SIZE as usize];
|
||||
let _ = scsi.execute(
|
||||
match span.execute(
|
||||
&verify_cdb,
|
||||
DataDirection::FromDevice,
|
||||
&mut verify_resp,
|
||||
5_000,
|
||||
);
|
||||
) {
|
||||
Ok(r) if r.status == 0 && r.bytes_transferred == verify_resp.len() => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959a_fw_verify_ok",
|
||||
"variant-A firmware verify read returned a full response"
|
||||
);
|
||||
}
|
||||
Ok(r) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959a_fw_verify_bad",
|
||||
status = r.status,
|
||||
bytes_transferred = r.bytes_transferred,
|
||||
"variant-A firmware verify read did not return a full good response"
|
||||
);
|
||||
}
|
||||
// A dead bus mid-upload must abort, not be swallowed into the unlock
|
||||
// retries below.
|
||||
Err(e) => {
|
||||
let err = crate::ld::error::Error::from(e);
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959a_fw_verify_failed",
|
||||
transport_failure = err.is_transport_failure(),
|
||||
"variant-A firmware verify read failed"
|
||||
);
|
||||
if err.is_transport_failure() {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Double unlock after firmware upload. The first establishes the
|
||||
// unlock and is fatal on failure; the second is a confirmation pass and
|
||||
// is best-effort (matching variant B), so a benign hiccup on the
|
||||
// redundant call doesn't fail an already-successful unlock.
|
||||
drop(span); // the span ends here; the unlock below stays cancellable
|
||||
|
||||
// Double unlock after firmware upload: first is fatal on failure, second
|
||||
// is a best-effort confirmation pass (matching variant B) so a benign
|
||||
// hiccup there doesn't fail an already-successful unlock.
|
||||
mt.do_unlock(scsi)?;
|
||||
let _ = mt.do_unlock(scsi);
|
||||
Ok(())
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use super::{Mt1959, SCSI_READ_BUFFER, SCSI_WRITE_BUFFER};
|
||||
use crate::ld::error::Result;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::scsi::{CriticalGuard, DataDirection, ScsiTransport};
|
||||
|
||||
const SCSI_MODE_SELECT: u8 = 0x55;
|
||||
const FIRMWARE_EXTRA: [u8; 16] = [0; 16];
|
||||
@@ -13,18 +13,53 @@ const FIRMWARE_EXTRA: [u8; 16] = [0; 16];
|
||||
/// that drive — real profiles must supply their own (see `DriveProfile`).
|
||||
const VENDOR_VERIFY: [u8; 10] = [0xF1, 0x01, 0x02, 0x00, 0x0D, 0x30, 0x01, 0xF3, 0xAD, 0x23];
|
||||
|
||||
// Swallows everything except a transport fault; these steps are advisory,
|
||||
// the unlock retries below are the real gate.
|
||||
fn trace_step(phase: &'static str, r: crate::scsi::Result<crate::scsi::ScsiResult>) -> Result<()> {
|
||||
match r {
|
||||
Ok(res) if res.status == 0 => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase,
|
||||
bytes_transferred = res.bytes_transferred,
|
||||
"firmware upload step ok"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Ok(res) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase,
|
||||
status = res.status,
|
||||
"firmware upload step returned a drive sense"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let err = crate::ld::error::Error::from(e);
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase,
|
||||
transport_failure = err.is_transport_failure(),
|
||||
"firmware upload step failed"
|
||||
);
|
||||
if err.is_transport_failure() {
|
||||
return Err(err);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Result<()> {
|
||||
let firmware = &mt.profile.firmware;
|
||||
if firmware.is_empty() {
|
||||
return Err(crate::ld::error::Error::UnlockFailed);
|
||||
}
|
||||
|
||||
// Step 1: Upload the firmware via MODE SELECT. The profile's `firmware` is
|
||||
// the exact per-drive image — extracted at the drive's own load-CDB length
|
||||
// (2192..2528 bytes; the old fixed 0x9C0 truncated some drives and over-read
|
||||
// others into blob strings). Upload all of it. MODE SELECT(10)'s
|
||||
// parameter-list length is 16-bit, so reject only a blob that can't be
|
||||
// expressed in the CDB.
|
||||
// Step 1: Upload the firmware via MODE SELECT.
|
||||
// MODE SELECT(10)'s parameter-list length is 16-bit, so reject only a
|
||||
// blob that can't be expressed in the CDB.
|
||||
let write_len = firmware.len();
|
||||
if write_len > u16::MAX as usize {
|
||||
return Err(crate::ld::error::Error::UnlockFailed);
|
||||
@@ -42,7 +77,10 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
0x00,
|
||||
];
|
||||
let mut data = firmware[..write_len].to_vec();
|
||||
scsi.execute(&mode_select_cdb, DataDirection::ToDevice, &mut data, 30_000)?;
|
||||
// stop-design-v5 §2.3: "MODE SELECT at 30 s …, READ BUFFER, WRITE BUFFER and F1 run
|
||||
// inside a `CriticalGuard`. C_max = 45 s" — a Stop mid-upload waits for all four.
|
||||
let mut span = CriticalGuard::enter(&mut *scsi)?;
|
||||
span.execute(&mode_select_cdb, DataDirection::ToDevice, &mut data, 30_000)?;
|
||||
|
||||
// Step 2: Read firmware metadata (READ_BUFFER mode 6, offset 0x3000)
|
||||
let read_meta_cdb = [
|
||||
@@ -58,12 +96,15 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
0x00,
|
||||
];
|
||||
let mut meta_resp = [0u8; 16];
|
||||
let _ = scsi.execute(
|
||||
&read_meta_cdb,
|
||||
DataDirection::FromDevice,
|
||||
&mut meta_resp,
|
||||
5_000,
|
||||
);
|
||||
trace_step(
|
||||
"mt1959b_fw_meta",
|
||||
span.execute(
|
||||
&read_meta_cdb,
|
||||
DataDirection::FromDevice,
|
||||
&mut meta_resp,
|
||||
5_000,
|
||||
),
|
||||
)?;
|
||||
|
||||
// Step 3: Write extra firmware data (all zeros)
|
||||
let write_extra_cdb = [
|
||||
@@ -79,24 +120,31 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
0x00,
|
||||
];
|
||||
let mut data2 = FIRMWARE_EXTRA.to_vec();
|
||||
let _ = scsi.execute(&write_extra_cdb, DataDirection::ToDevice, &mut data2, 5_000);
|
||||
trace_step(
|
||||
"mt1959b_fw_extra",
|
||||
span.execute(&write_extra_cdb, DataDirection::ToDevice, &mut data2, 5_000),
|
||||
)?;
|
||||
|
||||
// Step 4: Vendor verify (0xF1 — B-only, not standard SCSI). PER-DRIVE: take
|
||||
// it from the profile (39 distinct values across the 140 B drives). The
|
||||
// const is only a legacy fallback — it carries one drive's token.
|
||||
// Step 4: Vendor verify (0xF1 — B-only, not standard SCSI), per-drive.
|
||||
let verify_cdb = mt.profile.fw_verify_cdb.unwrap_or(VENDOR_VERIFY);
|
||||
let mut dummy = [0u8; 0];
|
||||
let _ = scsi.execute(&verify_cdb, DataDirection::None, &mut dummy, 5_000);
|
||||
trace_step(
|
||||
"mt1959b_fw_verify",
|
||||
span.execute(&verify_cdb, DataDirection::None, &mut dummy, 5_000),
|
||||
)?;
|
||||
drop(span); // the span ends here; the unlock retries below stay cancellable
|
||||
|
||||
// Step 5: Unlock retries (up to 5, then a final fatal attempt). On a
|
||||
// successful unlock we issue one confirmation pass; its result is
|
||||
// intentionally best-effort — the first call already established the
|
||||
// unlock state, so a hiccup on the redundant confirmation must not fail
|
||||
// an otherwise-good unlock.
|
||||
// Step 5: Unlock retries (up to 5, then a final fatal attempt). The
|
||||
// confirmation pass after success is intentionally best-effort.
|
||||
for _attempt in 0..5 {
|
||||
if mt.do_unlock(scsi).is_ok() {
|
||||
let _ = mt.do_unlock(scsi);
|
||||
return Ok(());
|
||||
match mt.do_unlock(scsi) {
|
||||
Ok(_) => {
|
||||
let _ = mt.do_unlock(scsi);
|
||||
return Ok(());
|
||||
}
|
||||
// A dead bus will not recover across five more unlock attempts.
|
||||
Err(e) if e.is_transport_failure() => return Err(e),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
mt.do_unlock(scsi)?;
|
||||
|
||||
+103
-427
@@ -3,7 +3,7 @@
|
||||
use crate::ld::error::{Error, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// The LibreDrive profile catalog — the set of optical drives the firmware
|
||||
/// The LdUnlocker profile catalog — the set of optical drives the firmware
|
||||
/// unlocker recognizes, keyed by chipset + variant. Loaded from the bundled
|
||||
/// JSON; the public entry point is [`crate::ld::profiles`].
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -19,7 +19,7 @@ pub struct Profiles {
|
||||
impl Profiles {
|
||||
/// The profile matching a drive identity, if this catalog supports that
|
||||
/// drive. Two-pass per platform section: exact (incl. firmware date) then a
|
||||
/// looser vendor/revision/vendor-specific match. See [`find_by_drive_id`].
|
||||
/// looser vendor/revision/vendor-specific match. See `find_by_drive_id`.
|
||||
pub fn get(&self, drive_id: &crate::DriveId) -> Option<ProfileMatch> {
|
||||
find_by_drive_id(self, drive_id)
|
||||
}
|
||||
@@ -41,7 +41,12 @@ pub struct Identity {
|
||||
}
|
||||
|
||||
/// Per-drive profile.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
///
|
||||
/// Only `identity` and `signature` are public; everything else (firmware image, per-drive
|
||||
/// vendor CDB templates) is `pub(crate)` unlock mechanism that must stay inside this
|
||||
/// unpublished crate.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct DriveProfile {
|
||||
pub identity: Identity,
|
||||
/// Expected first 4 bytes of the drive's unlock response — the
|
||||
@@ -53,48 +58,62 @@ pub struct DriveProfile {
|
||||
/// firmware-load step). JSON-encoded as standard base64; empty when
|
||||
/// the profile carries no firmware blob.
|
||||
#[serde(default, deserialize_with = "deserialize_base64")]
|
||||
pub firmware: Vec<u8>,
|
||||
pub(crate) firmware: Vec<u8>,
|
||||
|
||||
// ── OEM-extended-access CDB templates ──────────────────────────────
|
||||
//
|
||||
// All optional — older profile blobs that pre-date the CDB capture
|
||||
// pipeline simply omit these fields and decode as `None`. Encoded
|
||||
// in the JSON as lowercase hex strings without separators
|
||||
// (e.g. `"3c014410e29100002400"` for a 10-byte CDB).
|
||||
// All optional (`None` if pre-capture-pipeline). Hex strings, no
|
||||
// separators, e.g. `"3c014410e29100002400"`.
|
||||
#[serde(default)]
|
||||
pub unlock_init_value: u8,
|
||||
pub(crate) unlock_init_value: u8,
|
||||
#[serde(default)]
|
||||
pub unlock_response_size: u8,
|
||||
pub(crate) unlock_response_size: u8,
|
||||
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||
pub read_vid_cdb: Option<[u8; 10]>,
|
||||
pub(crate) read_vid_cdb: Option<[u8; 10]>,
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||
pub read_disc_keys_cdb: Option<[u8; 10]>,
|
||||
pub(crate) read_disc_keys_cdb: Option<[u8; 10]>,
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_12")]
|
||||
pub drive_nominal_speed_cdb: Option<[u8; 12]>,
|
||||
pub(crate) drive_nominal_speed_cdb: Option<[u8; 12]>,
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_12")]
|
||||
pub set_speed_max_cdb: Option<[u8; 12]>,
|
||||
pub(crate) set_speed_max_cdb: Option<[u8; 12]>,
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||
pub read10_raw_2sec_cdb: Option<[u8; 10]>,
|
||||
pub(crate) read10_raw_2sec_cdb: Option<[u8; 10]>,
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||
pub read10_raw_1sec_cdb: Option<[u8; 10]>,
|
||||
pub(crate) read10_raw_1sec_cdb: Option<[u8; 10]>,
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||
pub read_buffer_verify_cdb: Option<[u8; 10]>,
|
||||
pub(crate) read_buffer_verify_cdb: Option<[u8; 10]>,
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||
pub write_buffer_cdb: Option<[u8; 10]>,
|
||||
pub(crate) write_buffer_cdb: Option<[u8; 10]>,
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||
pub read_buffer_unlock_cdb: Option<[u8; 10]>,
|
||||
pub(crate) read_buffer_unlock_cdb: Option<[u8; 10]>,
|
||||
/// Variant-B vendor verify (0xF1) CDB. PER-DRIVE: 39 distinct values across
|
||||
/// the 140 B drives, so it CANNOT be a hardcoded constant. `variant_b`'s old
|
||||
/// `VENDOR_VERIFY` const was one drive's token, wrong for the other ~139.
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes_10")]
|
||||
pub fw_verify_cdb: Option<[u8; 10]>,
|
||||
pub(crate) fw_verify_cdb: Option<[u8; 10]>,
|
||||
|
||||
// Per-drive identifier tables — variable-length hex strings.
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes")]
|
||||
pub speed_zone_table: Option<Vec<u8>>,
|
||||
pub(crate) speed_zone_table: Option<Vec<u8>>,
|
||||
#[serde(default, deserialize_with = "deserialize_opt_hex_bytes")]
|
||||
pub speed_calc_table: Option<Vec<u8>>,
|
||||
pub(crate) speed_calc_table: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
// Hand-written, REDACTING Debug: a derived one would recurse through the
|
||||
// public `Profiles` catalog and print raw firmware + vendor CDB bytes into
|
||||
// logs. Show only identity + signature; collapse firmware to a length.
|
||||
impl std::fmt::Debug for DriveProfile {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DriveProfile")
|
||||
.field("identity", &self.identity)
|
||||
.field("signature", &self.signature)
|
||||
.field(
|
||||
"firmware",
|
||||
&format_args!("[{} bytes redacted]", self.firmware.len()),
|
||||
)
|
||||
.field("cdb_templates", &"[redacted]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Chipset + variant — determined by which section the profile was found in.
|
||||
@@ -129,23 +148,16 @@ pub struct ProfileMatch {
|
||||
pub platform: Platform,
|
||||
}
|
||||
|
||||
// ── Parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Decode an even-length ASCII hex string into bytes.
|
||||
///
|
||||
/// Operates on raw bytes rather than `&str` char-boundary slices: a
|
||||
/// non-ASCII input (e.g. a hand-edited profile with a multi-byte char)
|
||||
/// could otherwise have `&s[i..i+2]` land inside a UTF-8 char boundary and
|
||||
/// panic. Hex is ASCII, so any non-ASCII or non-hex byte simply fails to
|
||||
/// decode. The error is a stable, language-neutral token (`"hex"`), not a
|
||||
/// translatable English message.
|
||||
// ── Parsing: decode hex on raw bytes (not `&str` slices) so non-ASCII
|
||||
// can't land `&s[i..i+2]` inside a UTF-8 char boundary and panic — it
|
||||
// just fails to decode with `"hex"`.
|
||||
fn decode_hex(s: &str) -> std::result::Result<Vec<u8>, &'static str> {
|
||||
let bytes = s.as_bytes();
|
||||
if !bytes.len().is_multiple_of(2) {
|
||||
return Err("hex");
|
||||
}
|
||||
let mut out = Vec::with_capacity(bytes.len() / 2);
|
||||
for pair in bytes.chunks_exact(2) {
|
||||
for pair in bytes.as_chunks::<2>().0.iter() {
|
||||
let hi = (pair[0] as char).to_digit(16).ok_or("hex")?;
|
||||
let lo = (pair[1] as char).to_digit(16).ok_or("hex")?;
|
||||
out.push((hi * 16 + lo) as u8);
|
||||
@@ -185,9 +197,7 @@ where
|
||||
}
|
||||
|
||||
// ── Fixed-length hex deserializers for CDB templates ────────────────────
|
||||
//
|
||||
// Profile JSON encodes CDBs as lowercase hex strings without separators.
|
||||
// An empty string / null / missing field decodes as `None`.
|
||||
// Lowercase hex strings, no separators; empty/null/missing decodes as `None`.
|
||||
|
||||
fn parse_hex_bytes(s: &str) -> std::result::Result<Vec<u8>, &'static str> {
|
||||
decode_hex(s)
|
||||
@@ -266,7 +276,20 @@ pub fn bundled() -> Option<&'static Profiles> {
|
||||
use std::sync::OnceLock;
|
||||
static CACHE: OnceLock<Option<Profiles>> = OnceLock::new();
|
||||
CACHE
|
||||
.get_or_init(|| load_from_str(BUNDLED_PROFILES).ok())
|
||||
.get_or_init(|| match load_from_str(BUNDLED_PROFILES) {
|
||||
Ok(p) => Some(p),
|
||||
// `None` caches permanently; unlogged that's indistinguishable
|
||||
// from a genuinely uncataloged drive, so log once, loudly, here.
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
target: "freemkv::disc",
|
||||
phase = "bundled_profiles_parse_failed",
|
||||
error_code = e.code(),
|
||||
"bundled LdUnlocker profile catalog failed to parse; no drive can match"
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
@@ -283,15 +306,14 @@ fn load_from_str(data: &str) -> Result<Profiles> {
|
||||
serde_json::from_str(data).map_err(|_| Error::ProfileParse)
|
||||
}
|
||||
|
||||
/// Find a profile matching a drive's INQUIRY fields.
|
||||
/// Find a profile matching a drive's INQUIRY fields, whitespace-trimmed.
|
||||
///
|
||||
/// Per platform section (MT1959-A, then MT1959-B, then Renesas), tried in
|
||||
/// order of decreasing specificity: full match including `product_id` and
|
||||
/// `firmware_date` (skipped when the drive reports no product), then the same
|
||||
/// without `product_id`. Both require `firmware_date`; every profile carries a
|
||||
/// unique identity under these fields, so a match is exact — no looser
|
||||
/// vendor/revision fallback that could pick the wrong same-model variant. All
|
||||
/// comparisons are whitespace-trimmed. Returns the first section that matches.
|
||||
/// Over ALL platform sections (MT1959-A, -B, Renesas, in order): (1) exact
|
||||
/// match including `product_id` when the drive reports one; then (2) a
|
||||
/// product-id-blind fallback on vendor/revision/vendor_specific/firmware_date.
|
||||
/// Catalogs store a GENERIC product_id ("BD-RE") vs a drive's specific one
|
||||
/// ("BD-RE BU40N"), so pass 1 misses; pass 2 binds a four-field match UNIQUE
|
||||
/// across all sections — a shared tuple binds only with no product_id.
|
||||
pub fn find_by_drive_id(profiles: &Profiles, drive_id: &crate::DriveId) -> Option<ProfileMatch> {
|
||||
let v = drive_id.vendor_id.trim();
|
||||
let prod = drive_id.product_id.trim();
|
||||
@@ -299,393 +321,47 @@ pub fn find_by_drive_id(profiles: &Profiles, drive_id: &crate::DriveId) -> Optio
|
||||
let vs = drive_id.vendor_specific.trim();
|
||||
let date = drive_id.firmware_date.trim();
|
||||
|
||||
for (platform, list) in [
|
||||
let sections = [
|
||||
(Platform::Mt1959A, &profiles.mt1959_a),
|
||||
(Platform::Mt1959B, &profiles.mt1959_b),
|
||||
(Platform::Renesas, &profiles.renesas),
|
||||
] {
|
||||
if !prod.is_empty()
|
||||
&& let Some(p) = list.iter().find(|p| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_id.trim() == prod
|
||||
&& p.identity.product_revision.trim() == r
|
||||
&& p.identity.vendor_specific.trim() == vs
|
||||
&& p.identity.firmware_date.trim() == date
|
||||
})
|
||||
{
|
||||
return Some(ProfileMatch {
|
||||
profile: p.clone(),
|
||||
platform,
|
||||
});
|
||||
}
|
||||
];
|
||||
let four_field = |p: &DriveProfile| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_revision.trim() == r
|
||||
&& p.identity.vendor_specific.trim() == vs
|
||||
&& p.identity.firmware_date.trim() == date
|
||||
};
|
||||
let all = || {
|
||||
sections
|
||||
.iter()
|
||||
.flat_map(|(platform, list)| list.iter().map(move |p| (*platform, p)))
|
||||
};
|
||||
|
||||
if let Some(p) = list.iter().find(|p| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_revision.trim() == r
|
||||
&& p.identity.vendor_specific.trim() == vs
|
||||
&& p.identity.firmware_date.trim() == date
|
||||
}) {
|
||||
return Some(ProfileMatch {
|
||||
profile: p.clone(),
|
||||
platform,
|
||||
});
|
||||
}
|
||||
if !prod.is_empty()
|
||||
&& let Some((platform, p)) =
|
||||
all().find(|(_, p)| four_field(p) && p.identity.product_id.trim() == prod)
|
||||
{
|
||||
return Some(ProfileMatch {
|
||||
profile: p.clone(),
|
||||
platform,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
// Unique across all sections: bind regardless of the reported product_id
|
||||
// (the common real-drive case a UHD LG BU40N relies on). A shared tuple
|
||||
// binds only with NO product_id; otherwise it's an uncataloged variant.
|
||||
let mut matches = all().filter(|(_, p)| four_field(p));
|
||||
let (platform, first) = matches.next()?;
|
||||
if matches.next().is_some() && !prod.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(ProfileMatch {
|
||||
profile: first.clone(),
|
||||
platform,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::DriveId;
|
||||
|
||||
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
|
||||
DriveId {
|
||||
vendor_id: vendor.to_string(),
|
||||
product_id: String::new(),
|
||||
product_revision: rev.to_string(),
|
||||
vendor_specific: vs.to_string(),
|
||||
firmware_date: date.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// When two profiles share vendor/rev/vs/date and differ only in product_id,
|
||||
/// the full pass binds to the one whose product matches; the looser passes
|
||||
/// would return the first regardless.
|
||||
#[test]
|
||||
fn find_by_drive_id_product_id_breaks_a_tie() {
|
||||
use serde_json::json;
|
||||
let profiles: Profiles = serde_json::from_str(
|
||||
&json!({
|
||||
"mt1959_a": [
|
||||
{"identity": {"vendor_id":"TIE","product_id":"MODEL-A",
|
||||
"product_revision":"1.00","vendor_specific":"XX00000",
|
||||
"firmware_date":"200001010000"},
|
||||
"signature":"aaaaaaaa","firmware":""},
|
||||
{"identity": {"vendor_id":"TIE","product_id":"MODEL-B",
|
||||
"product_revision":"1.00","vendor_specific":"XX00000",
|
||||
"firmware_date":"200001010000"},
|
||||
"signature":"bbbbbbbb","firmware":""}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut id = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
|
||||
id.product_id = "MODEL-B".to_string();
|
||||
let m = find_by_drive_id(&profiles, &id).unwrap();
|
||||
assert_eq!(
|
||||
m.profile.signature,
|
||||
[0xbb, 0xbb, 0xbb, 0xbb],
|
||||
"product_id must select MODEL-B over the first entry"
|
||||
);
|
||||
|
||||
// No product_id → falls back to the 4-field pass → first entry.
|
||||
let id0 = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
|
||||
let m0 = find_by_drive_id(&profiles, &id0).unwrap();
|
||||
assert_eq!(m0.profile.signature, [0xaa, 0xaa, 0xaa, 0xaa]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_known_drive() {
|
||||
let profiles = load_bundled().unwrap();
|
||||
let id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
|
||||
let m = find_by_drive_id(&profiles, &id).unwrap();
|
||||
assert_eq!(m.profile.identity.vendor_id.trim(), "HL-DT-ST");
|
||||
assert_eq!(m.platform, Platform::Mt1959A);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_unknown_drive() {
|
||||
let profiles = load_bundled().unwrap();
|
||||
let id = make_drive_id("FAKE-VND", "9.99", "XX12345", "");
|
||||
assert!(find_by_drive_id(&profiles, &id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_hex_rejects_non_ascii_without_panic() {
|
||||
// A multi-byte char of even byte-length must not slice inside a
|
||||
// char boundary; it must decode-fail gracefully.
|
||||
assert!(decode_hex("中中").is_err()); // 6 bytes, none hex
|
||||
assert!(parse_hex4("中中").is_err()); // 6 bytes != 8 anyway
|
||||
// An 8-byte non-ASCII string (two 4-byte chars) hits the exact-len
|
||||
// path of parse_hex4; must still error, not panic.
|
||||
assert!(parse_hex4("𝕏𝕏").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_hex_roundtrips_valid_hex() {
|
||||
assert_eq!(decode_hex("00ff10").unwrap(), vec![0x00, 0xff, 0x10]);
|
||||
assert_eq!(parse_hex4("deadbeef").unwrap(), [0xde, 0xad, 0xbe, 0xef]);
|
||||
assert!(decode_hex("abc").is_err()); // odd length
|
||||
assert!(decode_hex("zz").is_err()); // non-hex
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_is_cached_and_matches_fresh_parse() {
|
||||
let cached = bundled().expect("bundled profiles parse");
|
||||
let fresh = load_bundled().unwrap();
|
||||
// Same data either way (compare section sizes — Profiles isn't Eq).
|
||||
assert_eq!(cached.mt1959_a.len(), fresh.mt1959_a.len());
|
||||
// Cached accessor returns a stable address across calls.
|
||||
let a = bundled().unwrap() as *const Profiles;
|
||||
let b = bundled().unwrap() as *const Profiles;
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_bundled_matches_known_drive() {
|
||||
let id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
|
||||
let m = find_bundled(&id).unwrap();
|
||||
assert_eq!(m.platform, Platform::Mt1959A);
|
||||
}
|
||||
|
||||
// ── New comprehensive tests ────────────────────────────────────────────────
|
||||
|
||||
/// decode_hex accepts empty string → empty Vec.
|
||||
/// Mutation: returning an error on empty input breaks empty-field handling.
|
||||
#[test]
|
||||
fn decode_hex_accepts_empty_string() {
|
||||
assert_eq!(decode_hex("").unwrap(), Vec::<u8>::new());
|
||||
}
|
||||
|
||||
/// decode_hex handles all valid hex digit characters (0-9, a-f, A-F).
|
||||
/// Mutation: not supporting uppercase A-F means uppercase-encoded profiles fail.
|
||||
#[test]
|
||||
fn decode_hex_handles_upper_and_lower_case() {
|
||||
assert_eq!(
|
||||
decode_hex("DEADBEEF").unwrap(),
|
||||
vec![0xDE, 0xAD, 0xBE, 0xEF]
|
||||
);
|
||||
assert_eq!(
|
||||
decode_hex("deadbeef").unwrap(),
|
||||
vec![0xDE, 0xAD, 0xBE, 0xEF]
|
||||
);
|
||||
assert_eq!(
|
||||
decode_hex("DeAdBeEf").unwrap(),
|
||||
vec![0xDE, 0xAD, 0xBE, 0xEF]
|
||||
);
|
||||
}
|
||||
|
||||
/// parse_hex4 rejects an 8-hex-char string (4 bytes) correctly.
|
||||
/// Spec: the signature field is exactly 4 bytes = 8 hex chars.
|
||||
/// Mutation: accepting 6 hex chars (3 bytes) would pass a wrong-length signature.
|
||||
#[test]
|
||||
fn parse_hex4_rejects_wrong_byte_length() {
|
||||
// 6 hex chars = 3 bytes ≠ 4.
|
||||
assert!(
|
||||
parse_hex4("aabbcc").is_err(),
|
||||
"3 bytes must be rejected for 4-byte field"
|
||||
);
|
||||
// 10 hex chars = 5 bytes ≠ 4.
|
||||
assert!(
|
||||
parse_hex4("aabbccddee").is_err(),
|
||||
"5 bytes must be rejected for 4-byte field"
|
||||
);
|
||||
// Exactly 8 hex chars = 4 bytes: must succeed.
|
||||
assert_eq!(parse_hex4("aabbccdd").unwrap(), [0xaa, 0xbb, 0xcc, 0xdd]);
|
||||
}
|
||||
|
||||
/// Platform::name() returns stable, non-empty, language-neutral identifiers.
|
||||
/// These strings are logged and keyed on in caller code; changing them is a
|
||||
/// breaking change.
|
||||
/// Mutation: swapping Mt1959A and Mt1959B names silently misroutes firmware upload.
|
||||
#[test]
|
||||
fn platform_name_is_stable() {
|
||||
// The exact strings are part of the public stable API (logged/keyed).
|
||||
assert_eq!(Platform::Mt1959A.name(), "MediaTek MT1959-A");
|
||||
assert_eq!(Platform::Mt1959B.name(), "MediaTek MT1959-B");
|
||||
assert_eq!(Platform::Renesas.name(), "Renesas");
|
||||
}
|
||||
|
||||
/// find_by_drive_id: exact match (including firmware_date) wins over loose match.
|
||||
/// Spec: two-pass — first an exact match including firmware_date, then looser.
|
||||
/// Build two synthetic Profiles entries that differ only by firmware_date,
|
||||
/// and verify the correct one is selected.
|
||||
/// Mutation: doing only the loose pass would return the first entry regardless of date.
|
||||
#[test]
|
||||
fn find_by_drive_id_exact_date_wins_over_loose() {
|
||||
use serde_json::json;
|
||||
// Use an 8-char vendor_id (padded with a trailing space so `trim()` strips
|
||||
// the pad, matching the same trimmed form the JSON profile stores).
|
||||
// "TESTDRV " fills INQUIRY [8..16] exactly; `ascii_field.trim()` → "TESTDRV".
|
||||
let profiles_json = json!({
|
||||
"mt1959_a": [
|
||||
{
|
||||
"identity": {
|
||||
"vendor_id": "TESTDRV",
|
||||
"product_revision": "1.00",
|
||||
"vendor_specific": "XX00000",
|
||||
"firmware_date": "200001010000"
|
||||
},
|
||||
"signature": "aabbccdd",
|
||||
"firmware": ""
|
||||
},
|
||||
{
|
||||
"identity": {
|
||||
"vendor_id": "TESTDRV",
|
||||
"product_revision": "1.00",
|
||||
"vendor_specific": "XX00000",
|
||||
"firmware_date": "200006150000"
|
||||
},
|
||||
"signature": "11223344",
|
||||
"firmware": ""
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap();
|
||||
|
||||
// "TESTDRV " (with space) fills 8 bytes; trim() → "TESTDRV" on both sides.
|
||||
let id_date1 = make_drive_id("TESTDRV ", "1.00", "XX00000", "200001010000");
|
||||
let id_date2 = make_drive_id("TESTDRV ", "1.00", "XX00000", "200006150000");
|
||||
|
||||
let m1 = find_by_drive_id(&profiles, &id_date1).unwrap();
|
||||
let m2 = find_by_drive_id(&profiles, &id_date2).unwrap();
|
||||
|
||||
// Each must bind to its own profile by exact date match.
|
||||
assert_eq!(
|
||||
m1.profile.signature,
|
||||
[0xaa, 0xbb, 0xcc, 0xdd],
|
||||
"id_date1 must match first profile"
|
||||
);
|
||||
assert_eq!(
|
||||
m2.profile.signature,
|
||||
[0x11, 0x22, 0x33, 0x44],
|
||||
"id_date2 must match second profile"
|
||||
);
|
||||
}
|
||||
|
||||
/// find_by_drive_id: a drive whose firmware_date does NOT match any profile
|
||||
/// gets no match — there is no loose vendor/rev/vs fallback that could bind
|
||||
/// the wrong same-model variant.
|
||||
#[test]
|
||||
fn find_by_drive_id_no_match_when_date_differs() {
|
||||
use serde_json::json;
|
||||
let profiles_json = json!({
|
||||
"mt1959_a": [
|
||||
{
|
||||
"identity": {
|
||||
"vendor_id": "LOOSEDR",
|
||||
"product_revision": "2.00",
|
||||
"vendor_specific": "YY11111",
|
||||
"firmware_date": "210101010000"
|
||||
},
|
||||
"signature": "deadbeef",
|
||||
"firmware": ""
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap();
|
||||
|
||||
// Same vendor/rev/vs but a different date — must NOT match.
|
||||
let id = make_drive_id("LOOSEDR ", "2.00", "YY11111", "000000000000");
|
||||
assert!(find_by_drive_id(&profiles, &id).is_none());
|
||||
}
|
||||
|
||||
/// load_from_str (via load_bundled) returns ProfileParse on invalid JSON.
|
||||
/// Mutation: returning an empty Profiles instead of an error silently
|
||||
/// leaves the drive-profile database empty.
|
||||
#[test]
|
||||
fn load_from_str_returns_profile_parse_on_bad_json() {
|
||||
let result: Result<Profiles> =
|
||||
serde_json::from_str("not valid json {{{{").map_err(|_| Error::ProfileParse);
|
||||
assert!(matches!(result, Err(Error::ProfileParse)));
|
||||
}
|
||||
|
||||
/// Bundled profiles is non-empty (mt1959_a has at least one entry).
|
||||
/// This pins the embedded JSON: if profiles.json is accidentally emptied
|
||||
/// or truncated, this test goes red.
|
||||
/// Mutation: clearing profiles.json would make this fail.
|
||||
#[test]
|
||||
fn bundled_profiles_has_entries() {
|
||||
let profiles = load_bundled().unwrap();
|
||||
assert!(
|
||||
!profiles.mt1959_a.is_empty(),
|
||||
"bundled profiles must have at least one mt1959_a entry"
|
||||
);
|
||||
}
|
||||
|
||||
/// deserialization of a profile with missing optional CDB fields
|
||||
/// produces None for those fields (not a parse error).
|
||||
/// Spec: all CDB template fields are `#[serde(default)]` — they are optional.
|
||||
/// Mutation: making a CDB field required breaks backward-compat with old blobs.
|
||||
#[test]
|
||||
fn profile_optional_cdb_fields_default_to_none() {
|
||||
use serde_json::json;
|
||||
let json_str = json!({
|
||||
"mt1959_a": [
|
||||
{
|
||||
"identity": {
|
||||
"vendor_id": "TEST",
|
||||
"product_revision": "1.00",
|
||||
"vendor_specific": "000000",
|
||||
"firmware_date": ""
|
||||
},
|
||||
"signature": "00000000",
|
||||
"firmware": ""
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let profiles: Profiles = serde_json::from_str(&json_str).unwrap();
|
||||
let p = &profiles.mt1959_a[0]; // DriveProfile directly
|
||||
// All optional CDB fields must be None when absent from JSON.
|
||||
assert!(
|
||||
p.read_vid_cdb.is_none(),
|
||||
"read_vid_cdb must default to None"
|
||||
);
|
||||
assert!(
|
||||
p.read_disc_keys_cdb.is_none(),
|
||||
"read_disc_keys_cdb must default to None"
|
||||
);
|
||||
assert!(
|
||||
p.drive_nominal_speed_cdb.is_none(),
|
||||
"drive_nominal_speed_cdb must default to None"
|
||||
);
|
||||
assert!(
|
||||
p.set_speed_max_cdb.is_none(),
|
||||
"set_speed_max_cdb must default to None"
|
||||
);
|
||||
assert!(
|
||||
p.speed_zone_table.is_none(),
|
||||
"speed_zone_table must default to None"
|
||||
);
|
||||
assert!(
|
||||
p.speed_calc_table.is_none(),
|
||||
"speed_calc_table must default to None"
|
||||
);
|
||||
}
|
||||
|
||||
/// deserialize_hex4 of an empty string must produce [0;4] without error.
|
||||
/// This matches `deserialize_hex4`'s explicit early-return for empty strings.
|
||||
/// Mutation: treating empty string as an error prevents profiles where signature
|
||||
/// was not captured from loading.
|
||||
#[test]
|
||||
fn profile_empty_signature_deserialises_as_zeroes() {
|
||||
use serde_json::json;
|
||||
let json_str = json!({
|
||||
"mt1959_a": [
|
||||
{
|
||||
"identity": {
|
||||
"vendor_id": "TEST",
|
||||
"product_revision": "1.00",
|
||||
"vendor_specific": "000000",
|
||||
"firmware_date": ""
|
||||
},
|
||||
"signature": "",
|
||||
"firmware": ""
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let profiles: Profiles = serde_json::from_str(&json_str).unwrap();
|
||||
assert_eq!(
|
||||
profiles.mt1959_a[0].signature, [0u8; 4],
|
||||
"empty signature must deserialise as [0;4]"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "profile_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
use super::*;
|
||||
use crate::DriveId;
|
||||
|
||||
fn make_drive_id(vendor: &str, rev: &str, vs: &str, date: &str) -> DriveId {
|
||||
DriveId {
|
||||
vendor_id: vendor.to_string(),
|
||||
product_id: String::new(),
|
||||
product_revision: rev.to_string(),
|
||||
vendor_specific: vs.to_string(),
|
||||
firmware_date: date.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// When two profiles share vendor/rev/vs/date and differ only in product_id,
|
||||
/// the full pass binds to the one whose product matches; the looser passes
|
||||
/// would return the first regardless.
|
||||
#[test]
|
||||
fn find_by_drive_id_product_id_breaks_a_tie() {
|
||||
use serde_json::json;
|
||||
let profiles: Profiles = serde_json::from_str(
|
||||
&json!({
|
||||
"mt1959_a": [
|
||||
{"identity": {"vendor_id":"TIE","product_id":"MODEL-A",
|
||||
"product_revision":"1.00","vendor_specific":"XX00000",
|
||||
"firmware_date":"200001010000"},
|
||||
"signature":"aaaaaaaa","firmware":""},
|
||||
{"identity": {"vendor_id":"TIE","product_id":"MODEL-B",
|
||||
"product_revision":"1.00","vendor_specific":"XX00000",
|
||||
"firmware_date":"200001010000"},
|
||||
"signature":"bbbbbbbb","firmware":""}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut id = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
|
||||
id.product_id = "MODEL-B".to_string();
|
||||
let m = find_by_drive_id(&profiles, &id).unwrap();
|
||||
assert_eq!(
|
||||
m.profile.signature,
|
||||
[0xbb, 0xbb, 0xbb, 0xbb],
|
||||
"product_id must select MODEL-B over the first entry"
|
||||
);
|
||||
|
||||
// No product_id → falls back to the 4-field pass → first entry.
|
||||
let id0 = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
|
||||
let m0 = find_by_drive_id(&profiles, &id0).unwrap();
|
||||
assert_eq!(m0.profile.signature, [0xaa, 0xaa, 0xaa, 0xaa]);
|
||||
|
||||
// A REPORTED product_id that matches no cataloged entry must NOT fall
|
||||
// through to the four-field pass and bind to a sibling (MODEL-A/B);
|
||||
// an uncataloged variant is no match, not a wrong match.
|
||||
let mut idc = make_drive_id("TIE", "1.00", "XX00000", "200001010000");
|
||||
idc.product_id = "MODEL-C".to_string();
|
||||
assert!(
|
||||
find_by_drive_id(&profiles, &idc).is_none(),
|
||||
"an uncataloged product_id must not mis-bind to a same-vendor sibling",
|
||||
);
|
||||
}
|
||||
|
||||
/// REGRESSION (1.7.0): a real drive reports a SPECIFIC product_id (e.g.
|
||||
/// "BD-RE BU40N") while the catalog stores a GENERIC one ("BD-RE"), so the
|
||||
/// exact pass misses. When the four-field identity is UNIQUE it must still
|
||||
/// bind — the pre-1.7.0 behaviour a UHD LG BU40N relies on for LibreDrive
|
||||
/// unlock. 1.7.0 gated the four-field pass behind an empty product_id, so it
|
||||
/// silently stopped matching and the firmware unlocker was skipped entirely.
|
||||
#[test]
|
||||
fn find_by_drive_id_specific_product_id_binds_unique_four_field() {
|
||||
use serde_json::json;
|
||||
let profiles: Profiles = serde_json::from_str(
|
||||
&json!({
|
||||
"mt1959_a": [
|
||||
{"identity": {"vendor_id":"HL-DT-ST","product_id":"BD-RE",
|
||||
"product_revision":"1.03","vendor_specific":"NM00000",
|
||||
"firmware_date":"211810241934"},
|
||||
"signature":"12345678","firmware":""}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Drive reports the specific marketing product_id; catalog has "BD-RE".
|
||||
let mut id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
|
||||
id.product_id = "BD-RE BU40N".to_string();
|
||||
let m = find_by_drive_id(&profiles, &id)
|
||||
.expect("unique four-field identity must match despite the product_id mismatch");
|
||||
assert_eq!(m.profile.signature, [0x12, 0x34, 0x56, 0x78]);
|
||||
assert_eq!(m.platform, Platform::Mt1959A);
|
||||
}
|
||||
|
||||
/// An exact product_id match in a LATER section must beat a four-field
|
||||
/// fallback in an earlier one; fallback uniqueness spans all sections.
|
||||
#[test]
|
||||
fn find_by_drive_id_exact_match_beats_earlier_section_fallback() {
|
||||
use serde_json::json;
|
||||
let entry = |prod: &str, sig: &str| {
|
||||
json!({"identity": {"vendor_id":"HL-DT-ST","product_id":prod,
|
||||
"product_revision":"1.03","vendor_specific":"NM00000",
|
||||
"firmware_date":"211810241934"},
|
||||
"signature":sig,"firmware":""})
|
||||
};
|
||||
let profiles: Profiles = serde_json::from_value(json!({
|
||||
"mt1959_a": [entry("BD-RE", "aaaaaaaa")],
|
||||
"mt1959_b": [entry("BD-RE BU40N", "bbbbbbbb")],
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let mut id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
|
||||
id.product_id = "BD-RE BU40N".to_string();
|
||||
let m = find_by_drive_id(&profiles, &id).expect("B's exact match");
|
||||
assert_eq!(m.platform, Platform::Mt1959B);
|
||||
assert_eq!(m.profile.signature, [0xbb, 0xbb, 0xbb, 0xbb]);
|
||||
|
||||
// Tuple shared across sections + uncataloged product_id: no match.
|
||||
id.product_id = "BD-RE WH16NS".to_string();
|
||||
assert!(find_by_drive_id(&profiles, &id).is_none());
|
||||
|
||||
// No product_id: shared tuple binds the first section's entry.
|
||||
id.product_id.clear();
|
||||
let m0 = find_by_drive_id(&profiles, &id).expect("empty product_id binds");
|
||||
assert_eq!(m0.platform, Platform::Mt1959A);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_known_drive() {
|
||||
let profiles = load_bundled().unwrap();
|
||||
let id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
|
||||
let m = find_by_drive_id(&profiles, &id).unwrap();
|
||||
assert_eq!(m.profile.identity.vendor_id.trim(), "HL-DT-ST");
|
||||
assert_eq!(m.platform, Platform::Mt1959A);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_unknown_drive() {
|
||||
let profiles = load_bundled().unwrap();
|
||||
let id = make_drive_id("FAKE-VND", "9.99", "XX12345", "");
|
||||
assert!(find_by_drive_id(&profiles, &id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_hex_rejects_non_ascii_without_panic() {
|
||||
// A multi-byte char of even byte-length must not slice inside a
|
||||
// char boundary; it must decode-fail gracefully.
|
||||
assert!(decode_hex("中中").is_err()); // 6 bytes, none hex
|
||||
assert!(parse_hex4("中中").is_err()); // 6 bytes != 8 anyway
|
||||
// An 8-byte non-ASCII string (two 4-byte chars) hits the exact-len
|
||||
// path of parse_hex4; must still error, not panic.
|
||||
assert!(parse_hex4("𝕏𝕏").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_hex_roundtrips_valid_hex() {
|
||||
assert_eq!(decode_hex("00ff10").unwrap(), vec![0x00, 0xff, 0x10]);
|
||||
assert_eq!(parse_hex4("deadbeef").unwrap(), [0xde, 0xad, 0xbe, 0xef]);
|
||||
assert!(decode_hex("abc").is_err()); // odd length
|
||||
assert!(decode_hex("zz").is_err()); // non-hex
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_is_cached_and_matches_fresh_parse() {
|
||||
let cached = bundled().expect("bundled profiles parse");
|
||||
let fresh = load_bundled().unwrap();
|
||||
// Same data either way (compare section sizes — Profiles isn't Eq).
|
||||
assert_eq!(cached.mt1959_a.len(), fresh.mt1959_a.len());
|
||||
// Cached accessor returns a stable address across calls.
|
||||
let a = bundled().unwrap() as *const Profiles;
|
||||
let b = bundled().unwrap() as *const Profiles;
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_bundled_matches_known_drive() {
|
||||
let id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
|
||||
let m = find_bundled(&id).unwrap();
|
||||
assert_eq!(m.platform, Platform::Mt1959A);
|
||||
}
|
||||
|
||||
// ── New comprehensive tests ────────────────────────────────────────────────
|
||||
|
||||
/// decode_hex accepts empty string → empty Vec.
|
||||
/// Mutation: returning an error on empty input breaks empty-field handling.
|
||||
#[test]
|
||||
fn decode_hex_accepts_empty_string() {
|
||||
assert_eq!(decode_hex("").unwrap(), Vec::<u8>::new());
|
||||
}
|
||||
|
||||
/// decode_hex handles all valid hex digit characters (0-9, a-f, A-F).
|
||||
/// Mutation: not supporting uppercase A-F means uppercase-encoded profiles fail.
|
||||
#[test]
|
||||
fn decode_hex_handles_upper_and_lower_case() {
|
||||
assert_eq!(
|
||||
decode_hex("DEADBEEF").unwrap(),
|
||||
vec![0xDE, 0xAD, 0xBE, 0xEF]
|
||||
);
|
||||
assert_eq!(
|
||||
decode_hex("deadbeef").unwrap(),
|
||||
vec![0xDE, 0xAD, 0xBE, 0xEF]
|
||||
);
|
||||
assert_eq!(
|
||||
decode_hex("DeAdBeEf").unwrap(),
|
||||
vec![0xDE, 0xAD, 0xBE, 0xEF]
|
||||
);
|
||||
}
|
||||
|
||||
/// parse_hex4 rejects an 8-hex-char string (4 bytes) correctly.
|
||||
/// Spec: the signature field is exactly 4 bytes = 8 hex chars.
|
||||
/// Mutation: accepting 6 hex chars (3 bytes) would pass a wrong-length signature.
|
||||
#[test]
|
||||
fn parse_hex4_rejects_wrong_byte_length() {
|
||||
// 6 hex chars = 3 bytes ≠ 4.
|
||||
assert!(
|
||||
parse_hex4("aabbcc").is_err(),
|
||||
"3 bytes must be rejected for 4-byte field"
|
||||
);
|
||||
// 10 hex chars = 5 bytes ≠ 4.
|
||||
assert!(
|
||||
parse_hex4("aabbccddee").is_err(),
|
||||
"5 bytes must be rejected for 4-byte field"
|
||||
);
|
||||
// Exactly 8 hex chars = 4 bytes: must succeed.
|
||||
assert_eq!(parse_hex4("aabbccdd").unwrap(), [0xaa, 0xbb, 0xcc, 0xdd]);
|
||||
}
|
||||
|
||||
// Platform::name() strings are logged/keyed by callers; changing them is
|
||||
// a breaking change. Mutation: swapping A/B names misroutes firmware upload.
|
||||
#[test]
|
||||
fn platform_name_is_stable() {
|
||||
// The exact strings are part of the public stable API (logged/keyed).
|
||||
assert_eq!(Platform::Mt1959A.name(), "MediaTek MT1959-A");
|
||||
assert_eq!(Platform::Mt1959B.name(), "MediaTek MT1959-B");
|
||||
assert_eq!(Platform::Renesas.name(), "Renesas");
|
||||
}
|
||||
|
||||
// find_by_drive_id: two-pass — exact match (incl. firmware_date) wins
|
||||
// over loose. Mutation: skipping the exact pass returns the first entry
|
||||
// regardless of date.
|
||||
#[test]
|
||||
fn find_by_drive_id_exact_date_wins_over_loose() {
|
||||
use serde_json::json;
|
||||
// Use an 8-char vendor_id (padded with a trailing space so `trim()` strips
|
||||
// the pad, matching the same trimmed form the JSON profile stores).
|
||||
// "TESTDRV " fills INQUIRY [8..16] exactly; `ascii_field.trim()` → "TESTDRV".
|
||||
let profiles_json = json!({
|
||||
"mt1959_a": [
|
||||
{
|
||||
"identity": {
|
||||
"vendor_id": "TESTDRV",
|
||||
"product_revision": "1.00",
|
||||
"vendor_specific": "XX00000",
|
||||
"firmware_date": "200001010000"
|
||||
},
|
||||
"signature": "aabbccdd",
|
||||
"firmware": ""
|
||||
},
|
||||
{
|
||||
"identity": {
|
||||
"vendor_id": "TESTDRV",
|
||||
"product_revision": "1.00",
|
||||
"vendor_specific": "XX00000",
|
||||
"firmware_date": "200006150000"
|
||||
},
|
||||
"signature": "11223344",
|
||||
"firmware": ""
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap();
|
||||
|
||||
// "TESTDRV " (with space) fills 8 bytes; trim() → "TESTDRV" on both sides.
|
||||
let id_date1 = make_drive_id("TESTDRV ", "1.00", "XX00000", "200001010000");
|
||||
let id_date2 = make_drive_id("TESTDRV ", "1.00", "XX00000", "200006150000");
|
||||
|
||||
let m1 = find_by_drive_id(&profiles, &id_date1).unwrap();
|
||||
let m2 = find_by_drive_id(&profiles, &id_date2).unwrap();
|
||||
|
||||
// Each must bind to its own profile by exact date match.
|
||||
assert_eq!(
|
||||
m1.profile.signature,
|
||||
[0xaa, 0xbb, 0xcc, 0xdd],
|
||||
"id_date1 must match first profile"
|
||||
);
|
||||
assert_eq!(
|
||||
m2.profile.signature,
|
||||
[0x11, 0x22, 0x33, 0x44],
|
||||
"id_date2 must match second profile"
|
||||
);
|
||||
}
|
||||
|
||||
/// find_by_drive_id: a drive whose firmware_date does NOT match any profile
|
||||
/// gets no match — there is no loose vendor/rev/vs fallback that could bind
|
||||
/// the wrong same-model variant.
|
||||
#[test]
|
||||
fn find_by_drive_id_no_match_when_date_differs() {
|
||||
use serde_json::json;
|
||||
let profiles_json = json!({
|
||||
"mt1959_a": [
|
||||
{
|
||||
"identity": {
|
||||
"vendor_id": "LOOSEDR",
|
||||
"product_revision": "2.00",
|
||||
"vendor_specific": "YY11111",
|
||||
"firmware_date": "210101010000"
|
||||
},
|
||||
"signature": "deadbeef",
|
||||
"firmware": ""
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let profiles: Profiles = serde_json::from_str(&profiles_json).unwrap();
|
||||
|
||||
// Same vendor/rev/vs but a different date — must NOT match.
|
||||
let id = make_drive_id("LOOSEDR ", "2.00", "YY11111", "000000000000");
|
||||
assert!(find_by_drive_id(&profiles, &id).is_none());
|
||||
}
|
||||
|
||||
/// load_from_str (via load_bundled) returns ProfileParse on invalid JSON.
|
||||
/// Mutation: returning an empty Profiles instead of an error silently
|
||||
/// leaves the drive-profile database empty.
|
||||
#[test]
|
||||
fn load_from_str_returns_profile_parse_on_bad_json() {
|
||||
let result: Result<Profiles> =
|
||||
serde_json::from_str("not valid json {{{{").map_err(|_| Error::ProfileParse);
|
||||
assert!(matches!(result, Err(Error::ProfileParse)));
|
||||
}
|
||||
|
||||
// Pins the embedded JSON: if profiles.json is emptied/truncated, this
|
||||
// goes red.
|
||||
#[test]
|
||||
fn bundled_profiles_has_entries() {
|
||||
let profiles = load_bundled().unwrap();
|
||||
assert!(
|
||||
!profiles.mt1959_a.is_empty(),
|
||||
"bundled profiles must have at least one mt1959_a entry"
|
||||
);
|
||||
}
|
||||
|
||||
// All CDB fields are `#[serde(default)]`. Mutation: making one required
|
||||
// breaks backward-compat with old blobs.
|
||||
#[test]
|
||||
fn profile_optional_cdb_fields_default_to_none() {
|
||||
use serde_json::json;
|
||||
let json_str = json!({
|
||||
"mt1959_a": [
|
||||
{
|
||||
"identity": {
|
||||
"vendor_id": "TEST",
|
||||
"product_revision": "1.00",
|
||||
"vendor_specific": "000000",
|
||||
"firmware_date": ""
|
||||
},
|
||||
"signature": "00000000",
|
||||
"firmware": ""
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let profiles: Profiles = serde_json::from_str(&json_str).unwrap();
|
||||
let p = &profiles.mt1959_a[0]; // DriveProfile directly
|
||||
// All optional CDB fields must be None when absent from JSON.
|
||||
assert!(
|
||||
p.read_vid_cdb.is_none(),
|
||||
"read_vid_cdb must default to None"
|
||||
);
|
||||
assert!(
|
||||
p.read_disc_keys_cdb.is_none(),
|
||||
"read_disc_keys_cdb must default to None"
|
||||
);
|
||||
assert!(
|
||||
p.drive_nominal_speed_cdb.is_none(),
|
||||
"drive_nominal_speed_cdb must default to None"
|
||||
);
|
||||
assert!(
|
||||
p.set_speed_max_cdb.is_none(),
|
||||
"set_speed_max_cdb must default to None"
|
||||
);
|
||||
assert!(
|
||||
p.speed_zone_table.is_none(),
|
||||
"speed_zone_table must default to None"
|
||||
);
|
||||
assert!(
|
||||
p.speed_calc_table.is_none(),
|
||||
"speed_calc_table must default to None"
|
||||
);
|
||||
}
|
||||
|
||||
// `Debug` must not render firmware/CDB bytes. Mutation: restoring
|
||||
// `#[derive(Debug)]` prints them (e.g. the 0xEE marker below), goes red.
|
||||
#[test]
|
||||
fn drive_profile_debug_redacts_firmware_and_cdbs() {
|
||||
use serde_json::json;
|
||||
let profiles: Profiles = serde_json::from_str(
|
||||
&json!({
|
||||
"mt1959_a": [{
|
||||
"identity": {"vendor_id":"VIS","product_revision":"1.00",
|
||||
"vendor_specific":"AA00000","firmware_date":"200001010000"},
|
||||
"signature":"aabbccdd",
|
||||
"firmware":"7u7u7u7u", // base64 → six 0xEE bytes
|
||||
"read_vid_cdb":"3c014410e29100002400"
|
||||
}]
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let s = format!("{:?}", profiles.mt1959_a[0]);
|
||||
// Firmware byte 0xEE renders as `238` under a derived Debug.
|
||||
assert!(!s.contains("238"), "firmware bytes must not appear: {s}");
|
||||
assert!(
|
||||
!s.contains("read_vid_cdb"),
|
||||
"CDB templates must not appear: {s}"
|
||||
);
|
||||
assert!(s.contains("redacted"), "must mark redaction: {s}");
|
||||
// The PUBLIC fields stay visible.
|
||||
assert!(s.contains("VIS"), "identity must remain visible: {s}");
|
||||
}
|
||||
|
||||
// deserialize_hex4("") must produce [0;4]. Mutation: treating empty as
|
||||
// an error blocks profiles with no captured signature from loading.
|
||||
#[test]
|
||||
fn profile_empty_signature_deserialises_as_zeroes() {
|
||||
use serde_json::json;
|
||||
let json_str = json!({
|
||||
"mt1959_a": [
|
||||
{
|
||||
"identity": {
|
||||
"vendor_id": "TEST",
|
||||
"product_revision": "1.00",
|
||||
"vendor_specific": "000000",
|
||||
"firmware_date": ""
|
||||
},
|
||||
"signature": "",
|
||||
"firmware": ""
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string();
|
||||
let profiles: Profiles = serde_json::from_str(&json_str).unwrap();
|
||||
assert_eq!(
|
||||
profiles.mt1959_a[0].signature, [0u8; 4],
|
||||
"empty signature must deserialise as [0;4]"
|
||||
);
|
||||
}
|
||||
+123
-77
@@ -1,32 +1,58 @@
|
||||
//! freemkv-unlock — the unlock layer for the freemkv toolchain.
|
||||
//!
|
||||
//! An **unlocker removes a drive-level bus-encryption barrier** so the drive
|
||||
//! serves readable (de-bus'd / de-scrambled) sectors. Content-key decryption is
|
||||
//! a separate layer — the consumer's (libfreemkv's) job.
|
||||
//! serves readable (de-bus'd / de-scrambled) sectors. Content-key decryption
|
||||
//! is a separate layer — the consumer's (libfreemkv's) job.
|
||||
//!
|
||||
//! This crate defines the [`Unlocker`] contract + the SCSI transport contract,
|
||||
//! and holds the self-contained unlocker modules (firmware / AACS cert / CSS).
|
||||
//! libfreemkv depends on this crate and dispatches via [`all_unlockers`]; it
|
||||
//! never names an individual unlocker. To remove an unlocker, delete its module
|
||||
//! dir and its one line in [`all_unlockers`] — nothing else changes.
|
||||
//! This crate defines the [`Unlocker`] contract + the SCSI transport
|
||||
//! contract, and holds the self-contained unlocker modules (firmware / AACS
|
||||
//! cert / CSS). The consumer (libfreemkv) assembles its own dispatch list from
|
||||
//! the exported unlocker types and drives them all through the same trait.
|
||||
|
||||
pub mod scsi;
|
||||
|
||||
// `firmware` is the ergonomic, typed mirror of the freemkv vendor-command ABI
|
||||
// (`freemkv-fw/src/abi.rs`) + the `FirmwareControl` driver. Public so host
|
||||
// tooling (freemkv-drivetest) can arm/inspect a freemkv drive directly.
|
||||
pub mod firmware;
|
||||
|
||||
mod aacs;
|
||||
mod css;
|
||||
// `ld` is public ONLY for its drive-profile catalog (`ld::profiles` / the
|
||||
// `Profiles` object) and, under the `emulation` feature, the unlock-handshake
|
||||
// wire format the bdemu test-emulator needs. The unlocker impl itself
|
||||
// (`LibreDrive`) is `pub(crate)` — clients still reach unlockers only through
|
||||
// [`all_unlockers`]. `aacs` and `css` carry no such public catalog, so they
|
||||
// stay fully private.
|
||||
// Shared best-effort AACS Volume ID read, used by every route that opens the
|
||||
// drive to raw reads (freemkv / MT1959 / Renesas). See `vid`.
|
||||
mod vid;
|
||||
// The bare best-effort AACS Volume ID read (`0xAD` fmt `0x80`) — public so a
|
||||
// harness armed `Encryption=off` can read the VID with NO cert AKE and prove no
|
||||
// SEND KEY crossed the bus.
|
||||
pub use vid::read_aacs_vid;
|
||||
// `ld` is public only for its drive-profile catalog + (under `emulation`) the handshake wire
|
||||
// format bdemu needs; the unlocker impl stays `pub(crate)`.
|
||||
pub mod ld;
|
||||
// `renesis` is public for its `is_renesas` drive-probe; the unlocker impl
|
||||
// (`Renesis`) is `pub(crate)` — reached only through [`all_unlockers`].
|
||||
pub mod renesis;
|
||||
// `renesas` is public for its `is_renesas` drive-probe (dead bus vs. "not a Renesas drive");
|
||||
// the unlocker impl stays `pub(crate)`.
|
||||
pub mod renesas;
|
||||
// `freemkv` self-identifies rather than matching a bundled profile, so it stays fully private.
|
||||
mod freemkv;
|
||||
|
||||
use scsi::ScsiTransport;
|
||||
|
||||
// The five unlockers, exposed as concrete types so the consumer assembles its
|
||||
// own dispatch list and injects each one's deps at construction (certs → AACS)
|
||||
// — no central factory to thread another unlocker's config through.
|
||||
pub use aacs::AacsUnlocker;
|
||||
// Keypair-validity check for a stored AACS 1.0 host cert (`priv·G ==
|
||||
// cert_pub_key`), for the key service's host-cert health gate.
|
||||
pub use aacs::aacs1_keypair_matches;
|
||||
pub use css::DvdUnlocker;
|
||||
// The ergonomic firmware-control surface (typed grammar + recipes).
|
||||
pub use firmware::{
|
||||
ArmRecipe, BdRegion, Feature, FeatureStates, FirmwareControl, FirmwareError, FirmwareIdentity,
|
||||
Verb,
|
||||
};
|
||||
pub use freemkv::FreemkvUnlocker;
|
||||
pub use ld::LdUnlocker;
|
||||
pub use renesas::Renesas;
|
||||
|
||||
/// Drive identity an unlocker matches against — four raw INQUIRY-derived fields,
|
||||
/// filled by the consumer (this crate parses no INQUIRY itself).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -49,44 +75,72 @@ pub enum DiscKind {
|
||||
|
||||
/// A host certificate for the AACS cert handshake (raw; the consumer collects
|
||||
/// these from its key sources and passes them in).
|
||||
#[derive(Debug, Clone)]
|
||||
// `ZeroizeOnDrop` wipes the two host PRIVATE keys on drop so they don't linger in
|
||||
// freed memory. The certificates are public material (sent to the drive in the
|
||||
// clear), so they are `#[zeroize(skip)]` — nothing secret, cert bytes untouched.
|
||||
#[derive(Clone, zeroize::ZeroizeOnDrop)]
|
||||
pub struct HostCert {
|
||||
/// AACS 1.0 host private key (20 bytes).
|
||||
pub private_key: [u8; 20],
|
||||
/// AACS 1.0 host certificate (92 bytes).
|
||||
#[zeroize(skip)]
|
||||
pub certificate: Vec<u8>,
|
||||
/// AACS 2.0 host private key (P-256, 32 bytes). `None` for AACS 1.0 only.
|
||||
pub private_key_v2: Option<[u8; 32]>,
|
||||
/// AACS 2.0 host certificate (type 0x11). `None` for AACS 1.0 only.
|
||||
#[zeroize(skip)]
|
||||
pub certificate_v2: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Context handed to an unlocker: drive identity, disc kind, and (for the cert
|
||||
/// route) the host certs the consumer collected.
|
||||
pub struct UnlockCtx<'a> {
|
||||
pub drive_id: &'a DriveId,
|
||||
pub kind: DiscKind,
|
||||
pub host_certs: &'a [HostCert],
|
||||
}
|
||||
|
||||
impl<'a> UnlockCtx<'a> {
|
||||
pub fn new(drive_id: &'a DriveId, kind: DiscKind, host_certs: &'a [HostCert]) -> Self {
|
||||
Self {
|
||||
drive_id,
|
||||
kind,
|
||||
host_certs,
|
||||
}
|
||||
// Manual Debug: `private_key`/`private_key_v2` are the raw host private keys —
|
||||
// they must never reach a log in plaintext; presence (Some/None) of the v2 key
|
||||
// stays observable. Mirrors the redacting Debug on AacsAuth/Unlocked.
|
||||
impl std::fmt::Debug for HostCert {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("HostCert")
|
||||
.field("private_key", &"[redacted]")
|
||||
.field("certificate", &self.certificate)
|
||||
.field("private_key_v2", &self.private_key_v2.map(|_| "[redacted]"))
|
||||
.field("certificate_v2", &self.certificate_v2)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// What removing bus encryption yielded. `drive_unlocked` means the drive now
|
||||
/// serves clear content (firmware route) — equivalent, for the gate, to a cert
|
||||
/// `bus_key`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
/// Per-attempt context the consumer hands to EVERY unlocker, uniformly: the drive identity and
|
||||
/// the mounted disc's kind. These are the shared facts the dispatch loop knows; anything an
|
||||
/// individual unlocker needs beyond them (the AACS cert route's host certs) is injected into
|
||||
/// THAT unlocker at construction, so the loop and the other unlockers never see it.
|
||||
pub struct UnlockCtx<'a> {
|
||||
pub drive_id: &'a DriveId,
|
||||
pub kind: DiscKind,
|
||||
}
|
||||
|
||||
impl<'a> UnlockCtx<'a> {
|
||||
pub fn new(drive_id: &'a DriveId, kind: DiscKind) -> Self {
|
||||
Self { drive_id, kind }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a successful unlock captured: the Volume ID (best-effort — may be `None`
|
||||
/// even on success) and, for the cert route, the bus key the read path applies
|
||||
/// to de-bus content. Returned inside the `Some` of [`Unlocker::unlock`]; there
|
||||
/// is no `drive_unlocked` flag — "unlocked" is simply `unlock()` returning
|
||||
/// `Some`, uniformly for every route. Both fields are wiped on drop.
|
||||
#[derive(Clone, Default, zeroize::ZeroizeOnDrop)]
|
||||
pub struct Unlocked {
|
||||
pub vid: Option<[u8; 16]>,
|
||||
pub bus_key: Option<[u8; 16]>,
|
||||
pub drive_unlocked: bool,
|
||||
}
|
||||
|
||||
// Hand-written, REDACTING Debug: `bus_key`/`vid` are key material that must never reach a log
|
||||
// in plaintext; presence (Some/None) stays observable.
|
||||
impl std::fmt::Debug for Unlocked {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Unlocked")
|
||||
.field("vid", &self.vid.map(|_| "[redacted]"))
|
||||
.field("bus_key", &self.bus_key.map(|_| "[redacted]"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an unlock produced no usable result. Only `Transport` is a hard error
|
||||
@@ -107,46 +161,46 @@ pub enum UnlockError {
|
||||
}
|
||||
|
||||
/// An unlocker removes a drive-level bus-encryption barrier. Implementors are
|
||||
/// the self-contained modules in this crate; the consumer only ever sees the
|
||||
/// trait, via [`all_unlockers`]. (Each module owns its own conversion from its
|
||||
/// internal error to [`UnlockError`].)
|
||||
/// the self-contained modules in this crate; the consumer drives them all
|
||||
/// through this trait. (Each module owns its own conversion from its internal
|
||||
/// error to [`UnlockError`].)
|
||||
///
|
||||
/// NOTE: drive tuning (e.g. SET CD SPEED to lift riplock) is deliberately NOT
|
||||
/// here — that is the consumer's concern, not bus removal.
|
||||
pub trait Unlocker: Send + Sync {
|
||||
/// Short, stable identifier for this unlocker (e.g. "LibreDrive", "AACS",
|
||||
/// "DVD", "Renesas"). The ONE place a name lives — apps render the unlocker
|
||||
/// report from [`all_unlockers`], never hardcoding names, so adding/removing
|
||||
/// Short, stable identifier for this unlocker (e.g. "freemkv", "LD",
|
||||
/// "AACS", "DVD", "Renesas"). The ONE place a name lives — apps render the
|
||||
/// unlocker report from `name()`, never hardcoding names, so adding/removing
|
||||
/// an unlocker updates every report with no app change.
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Unlock DRIVE FEATURES — riplock/speed, OEM Volume ID, OEM extended-access
|
||||
/// reads. The consumer runs this at drive-prep, trying each unlocker until
|
||||
/// one handles the drive. `Ok(_)` = this unlocker handled it (LibreDrive also
|
||||
/// removes bus encryption at the drive, so its result carries
|
||||
/// `drive_unlocked: true`); `Err(NotApplicable)` = not this unlocker's drive;
|
||||
/// `Err(Transport)` = dead bus (the consumer aborts). Default: not provided.
|
||||
fn unlock_features(
|
||||
/// Attempt to unlock the drive, whatever the mechanism (vendor CDB, profile
|
||||
/// firmware handshake, AACS cert AKE, CSS auth):
|
||||
/// `Ok(Some(unlocked))` = this one unlocked it (read `vid`/`bus_key`, stop);
|
||||
/// `Ok(None)` = not this unlocker's drive (try the next); `Err(Transport)` =
|
||||
/// dead bus (abort). Only a dead bus is `Err` — a missing VID, rejected
|
||||
/// handshake, missing cert, or wrong disc kind all fall through as `Ok(None)`
|
||||
/// (the VID inside `Unlocked` is best-effort). `&self`: stateless, returns
|
||||
/// what it learned rather than stashing it.
|
||||
fn unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
let _ = (scsi, ctx);
|
||||
Err(UnlockError::NotApplicable)
|
||||
}
|
||||
) -> std::result::Result<Option<Unlocked>, UnlockError>;
|
||||
}
|
||||
|
||||
/// Remove BUS ENCRYPTION for the mounted disc (AACS host-cert handshake, or
|
||||
/// CSS scrambled-sector auth). The consumer runs this only when the bus isn't
|
||||
/// already clear, trying each unlocker until one handles it. Same `Ok` /
|
||||
/// `Err(NotApplicable)` / `Err(Transport)` contract as [`unlock_features`].
|
||||
/// Default: not provided.
|
||||
fn unlock_bus(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
let _ = (scsi, ctx);
|
||||
Err(UnlockError::NotApplicable)
|
||||
/// The shared "only a dead bus is fatal" rule every unlocker uses to turn its
|
||||
/// mechanism's `Result<Unlocked>` into the trait's outcome: success → `Some`, a
|
||||
/// dead bus → `Err(Transport)`, any other failure → `None` (fall through to the
|
||||
/// next unlocker). Written once here so the five unlockers stay identical in
|
||||
/// error handling and differ only in their CDBs and checks.
|
||||
pub(crate) fn fallthrough(
|
||||
r: std::result::Result<Unlocked, UnlockError>,
|
||||
) -> std::result::Result<Option<Unlocked>, UnlockError> {
|
||||
match r {
|
||||
Ok(u) => Ok(Some(u)),
|
||||
Err(UnlockError::Transport) => Err(UnlockError::Transport),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,14 +213,6 @@ pub fn unlocker_name(drive_id: &DriveId) -> Option<&'static str> {
|
||||
ld::firmware_name(drive_id)
|
||||
}
|
||||
|
||||
/// Every unlocker, in dispatch order (firmware → cert → css). This is the ONLY
|
||||
/// place an unlocker is named. Remove one = delete its line here + its module
|
||||
/// dir; the consumer never changes.
|
||||
pub fn all_unlockers() -> Vec<Box<dyn Unlocker>> {
|
||||
vec![
|
||||
Box::new(ld::LibreDrive::new()),
|
||||
Box::new(renesis::Renesis::new()),
|
||||
Box::new(aacs::AacsCert::new()),
|
||||
Box::new(css::DvdUnlocker::new()),
|
||||
]
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[path = "lib_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
use super::*;
|
||||
|
||||
// The canonical dispatch order the consumer assembles (freemkv → firmware →
|
||||
// Renesas → cert → css). It lives in the consumer now (no factory), but the
|
||||
// types are ours, so pin their names here so a rename is caught crate-local.
|
||||
fn canonical_unlockers() -> Vec<Box<dyn Unlocker>> {
|
||||
vec![
|
||||
Box::new(FreemkvUnlocker::new()),
|
||||
Box::new(LdUnlocker::new()),
|
||||
Box::new(Renesas::new()),
|
||||
Box::new(AacsUnlocker::new(Vec::new())),
|
||||
Box::new(DvdUnlocker::new()),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlocker_names_are_stable() {
|
||||
let names: Vec<&'static str> = canonical_unlockers().iter().map(|u| u.name()).collect();
|
||||
assert_eq!(names, vec!["freemkv", "LD", "Renesas", "AACS", "DVD"]);
|
||||
}
|
||||
|
||||
/// The uniform contract every unlocker obeys, whatever its mechanism: on a
|
||||
/// DEAD BUS, `unlock()` either declined before touching it (`Ok(false)`) or
|
||||
/// engaged it and reported the fault (`Err(Transport)`) — but NEVER claims a
|
||||
/// dead bus as unlocked, and NEVER surfaces a non-`Transport` hard error.
|
||||
/// This is the guardrail that was missing: it holds the next new unlocker to
|
||||
/// the same discipline the loop relies on.
|
||||
#[test]
|
||||
fn no_unlocker_claims_a_dead_bus_or_hard_errors() {
|
||||
let id = DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
|
||||
for u in canonical_unlockers() {
|
||||
let name = u.name();
|
||||
let mut t = scsi::mock::MockTransport::always(scsi::mock::Reply::TransportFault);
|
||||
match u.unlock(&mut t, &ctx) {
|
||||
Ok(None) => {} // declined before touching — fine
|
||||
Err(UnlockError::Transport) => {} // engaged, dead bus — fine
|
||||
other => panic!("{name} violated the dead-bus contract: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `unlocker_name` is a PURE lookup — it must answer from the `DriveId`
|
||||
/// alone, and only the identity-keyed unlocker can claim a drive this way.
|
||||
#[test]
|
||||
fn unlocker_name_is_a_pure_identity_lookup() {
|
||||
assert_eq!(unlocker_name(&DriveId::default()), None);
|
||||
}
|
||||
|
||||
// `bus_key`/`vid` are key material; `Debug` must NOT print those bytes.
|
||||
// MUTATION: restoring `#[derive(Debug)]` prints the raw byte arrays, so
|
||||
// the marker bytes appear in the output and this test goes red.
|
||||
#[test]
|
||||
fn unlocked_debug_redacts_key_material() {
|
||||
let u = Unlocked {
|
||||
vid: Some([0xAB; 16]),
|
||||
bus_key: Some([0xCD; 16]),
|
||||
};
|
||||
let s = format!("{u:?}");
|
||||
// A derived Debug renders `[171, 171, ...]` (0xAB) / `[205, ...]` (0xCD).
|
||||
assert!(!s.contains("171"), "vid bytes must not appear: {s}");
|
||||
assert!(!s.contains("205"), "bus_key bytes must not appear: {s}");
|
||||
assert!(s.contains("[redacted]"), "must mark redaction: {s}");
|
||||
// Presence (Some/None) stays observable.
|
||||
assert!(s.contains("Some"), "must still show a key WAS present: {s}");
|
||||
}
|
||||
|
||||
// `vid`/`bus_key` leave the crate in `Unlocked`; wipe them (and every
|
||||
// clone) on drop. Fails to compile if the derive is removed.
|
||||
#[test]
|
||||
fn unlocked_zeroizes_on_drop() {
|
||||
fn assert_zeroize_on_drop<T: zeroize::ZeroizeOnDrop>() {}
|
||||
assert_zeroize_on_drop::<Unlocked>();
|
||||
}
|
||||
|
||||
// `private_key`/`private_key_v2` are the raw host private keys; `Debug` must
|
||||
// NOT print those bytes. Restoring `#[derive(Debug)]` renders the byte
|
||||
// arrays (e.g. 0xAB → 171 / 0xCD → 205), turning this test red.
|
||||
#[test]
|
||||
fn host_cert_debug_redacts_private_keys() {
|
||||
let c = HostCert {
|
||||
private_key: [0xAB; 20],
|
||||
certificate: vec![0x01, 0x02],
|
||||
private_key_v2: Some([0xCD; 32]),
|
||||
certificate_v2: None,
|
||||
};
|
||||
let s = format!("{c:?}");
|
||||
assert!(!s.contains("171"), "private_key bytes must not appear: {s}");
|
||||
assert!(
|
||||
!s.contains("205"),
|
||||
"private_key_v2 bytes must not appear: {s}"
|
||||
);
|
||||
assert!(s.contains("[redacted]"), "must mark redaction: {s}");
|
||||
// Presence of the v2 key stays observable.
|
||||
assert!(
|
||||
s.contains("Some"),
|
||||
"must still show the v2 key WAS present: {s}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! renesas — Renesas-platform detection (Pioneer + HL-DT-ST Renesas drives).
|
||||
//!
|
||||
//! Optical drives split into two controller families: MediaTek (handled by
|
||||
//! [`crate::ld`]) and Renesas. This module identifies the Renesas side via a
|
||||
//! single vendor identity probe (see [`is_renesas`]). Renesas drives are
|
||||
//! OEM-unlocked: identity establishes unlocked status without a host certificate.
|
||||
//! Vendor register access is used only for optional VID retrieval; a read
|
||||
//! miss preserves unlocked status, while transport faults and cancellation abort.
|
||||
|
||||
use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus};
|
||||
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
/// The Renesas vendor identity block length (READ_BUFFER 0x02/0xF1).
|
||||
const RB_F1_LEN: usize = pioneer_optical::IDENTITY_LEN;
|
||||
/// The ASCII interface marker a Renesas controller returns at `[16..19]`.
|
||||
const RENESAS_MARKER: &[u8] = b"SAT";
|
||||
const RENESAS_MARKER_OFFSET: usize = 16;
|
||||
|
||||
/// `Ok(true)` if `scsi` is a Renesas-platform drive (Pioneer or HL-DT-ST
|
||||
/// Renesas).
|
||||
///
|
||||
/// Issues the vendor READ_BUFFER 0x02/0xF1 probe: a Renesas controller serves
|
||||
/// a 48-byte identity block whose bytes `[16..19]` are the ASCII `SAT`
|
||||
/// interface tag. A rejection (CHECK CONDITION or `Err` with a sense) is
|
||||
/// `Ok(false)`: not a Renesas drive.
|
||||
///
|
||||
/// `Err(Transport)` on a dead bus.
|
||||
pub fn is_renesas(scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
|
||||
let mut buf = [0u8; RB_F1_LEN];
|
||||
let cdb = pioneer_optical::cdb::vendor_identity();
|
||||
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
Ok(r) => {
|
||||
let end = RENESAS_MARKER_OFFSET + RENESAS_MARKER.len();
|
||||
Ok(r.status == 0
|
||||
&& (end..=buf.len()).contains(&r.bytes_transferred)
|
||||
&& &buf[RENESAS_MARKER_OFFSET..end] == RENESAS_MARKER)
|
||||
}
|
||||
// Only a senseless transport-failure status is a dead bus; anything
|
||||
// else the transport reports as `Err` is the drive refusing.
|
||||
Err(e) => {
|
||||
if is_dead_bus(&e) {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "renesas_probe_transport_fault",
|
||||
"transport fault on the Renesas identity probe; aborting"
|
||||
);
|
||||
return Err(UnlockError::Transport);
|
||||
}
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "renesas_probe_rejected",
|
||||
status = e.status,
|
||||
"Renesas identity probe rejected by the drive; not a Renesas platform"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognizes OEM-unlocked Renesas drives and optionally reads their VID from
|
||||
/// hardware registers. This does not enable standard AACS VID reads.
|
||||
#[derive(Default)]
|
||||
pub struct Renesas;
|
||||
|
||||
impl Renesas {
|
||||
pub fn new() -> Self {
|
||||
Renesas
|
||||
}
|
||||
}
|
||||
|
||||
// Selector 0x92 maps offset - 0x2000 to high CPU memory; no AAAA gate.
|
||||
const VID_STATUS_CDB: [u8; 10] = [0x3c, 2, 0x92, 0, 0x0d, 0x3c, 0, 0, 1, 0];
|
||||
const VID_CDB: [u8; 10] = [0x3c, 2, 0x92, 0, 0x0d, 0x20, 0, 0, 16, 0];
|
||||
|
||||
fn get_vid(scsi: &mut dyn ScsiTransport) -> std::result::Result<Option<[u8; 16]>, UnlockError> {
|
||||
// Mirror the OEM hardware-copy gate. Other status bits are not interpreted.
|
||||
let mut status = [0];
|
||||
let result = scsi.execute(
|
||||
&VID_STATUS_CDB,
|
||||
DataDirection::FromDevice,
|
||||
&mut status,
|
||||
5_000,
|
||||
);
|
||||
tracing::debug!(target: "freemkv::disc", phase = "renesas_vid_status",
|
||||
cdb = ?VID_STATUS_CDB, requested = 1, ?result, payload = ?status,
|
||||
"Reading Renesas VID hardware status");
|
||||
match result {
|
||||
Ok(r) if r.status == 0 && r.bytes_transferred == 1 && status[0] & 2 != 0 => {}
|
||||
Err(e) if is_dead_bus(&e) => return Err(UnlockError::Transport),
|
||||
_ => return Ok(None),
|
||||
}
|
||||
let mut vid = [0u8; 16];
|
||||
let cdb = VID_CDB;
|
||||
let address = 0xffff_ed20u32;
|
||||
tracing::debug!(target: "freemkv::disc", phase = "renesas_vid_request",
|
||||
address = format_args!("{address:#x}"), cdb = format_args!("{cdb:02x?}"), requested = vid.len(), "Reading VID from Renesas hardware registers");
|
||||
let result = match scsi.execute(&cdb, DataDirection::FromDevice, &mut vid, 5_000) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::debug!(target: "freemkv::disc", phase = "renesas_vid_error",
|
||||
address = format_args!("{address:#x}"), status = e.status, sense = ?e.sense,
|
||||
dead_bus = is_dead_bus(&e),
|
||||
"Renesas hardware VID read failed");
|
||||
return if is_dead_bus(&e) {
|
||||
Err(UnlockError::Transport)
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
}
|
||||
};
|
||||
let rejection = if result.status != 0 {
|
||||
Some("scsi_status")
|
||||
} else if result.bytes_transferred != vid.len() {
|
||||
Some("transfer_length")
|
||||
} else if vid.iter().all(|&b| b == 0) {
|
||||
Some("all_zero")
|
||||
} else if vid.iter().all(|&b| b == 0xff) {
|
||||
Some("all_ff")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let valid = rejection.is_none();
|
||||
// Only show bytes the transport reports receiving, never buffer padding.
|
||||
let payload = &vid[..result.bytes_transferred.min(vid.len())];
|
||||
tracing::debug!(target: "freemkv::disc", phase = "renesas_vid_result",
|
||||
address = format_args!("{address:#x}"), status = result.status,
|
||||
bytes_transferred = result.bytes_transferred, sense = ?result.sense,
|
||||
payload = format_args!("{payload:02x?}"), valid, rejection,
|
||||
"Renesas hardware VID response");
|
||||
Ok(valid.then_some(vid))
|
||||
}
|
||||
|
||||
impl Unlocker for Renesas {
|
||||
fn name(&self) -> &'static str {
|
||||
"Renesas"
|
||||
}
|
||||
|
||||
/// SAT identity establishes OEM-unlocked status. Hardware VID reads are
|
||||
/// best-effort; a missing VID preserves success, dead buses abort.
|
||||
fn unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
_ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Option<Unlocked>, UnlockError> {
|
||||
if !is_renesas(scsi)? {
|
||||
return Ok(None);
|
||||
}
|
||||
let vid = get_vid(scsi)?;
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "renesas_opened",
|
||||
has_vid = vid.is_some(),
|
||||
"Renesas VID discovery completed"
|
||||
);
|
||||
Ok(Some(Unlocked { vid, bus_key: None }))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod mod_tests;
|
||||
@@ -0,0 +1,346 @@
|
||||
use super::*;
|
||||
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::DiscKind;
|
||||
use crate::scsi::{DataDirection, Result, ScsiError, ScsiResult, ScsiTransport};
|
||||
|
||||
#[test]
|
||||
fn memory_vid_rejects_invalid_responses_and_propagates_dead_bus() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
for reply in [
|
||||
Reply::zero_transfer(16),
|
||||
Reply::short(vec![0x25; 16], 15),
|
||||
Reply::good(vec![0; 16]),
|
||||
Reply::good(vec![0xff; 16]),
|
||||
Reply::illegal_request(),
|
||||
Reply::illegal_request_as_err(),
|
||||
] {
|
||||
let mut t =
|
||||
MockTransport::scripted(vec![Reply::good(vec![2]), reply], Reply::TransportFault);
|
||||
assert_eq!(get_vid(&mut t).unwrap(), None);
|
||||
}
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
assert_eq!(get_vid(&mut t).unwrap_err(), UnlockError::Transport);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_vid_rejects_failed_status_with_payload_and_impossible_length() {
|
||||
struct Response {
|
||||
status: u8,
|
||||
transferred: usize,
|
||||
}
|
||||
impl ScsiTransport for Response {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
assert_eq!(dir, DataDirection::FromDevice);
|
||||
if data.len() == 1 {
|
||||
data[0] = 2;
|
||||
return Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: 1,
|
||||
sense: [0; 32],
|
||||
});
|
||||
}
|
||||
assert_eq!(data.len(), 16);
|
||||
assert_eq!(timeout, 5_000);
|
||||
data.fill(0x25);
|
||||
Ok(ScsiResult {
|
||||
status: self.status,
|
||||
bytes_transferred: self.transferred,
|
||||
sense: [0; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
for (status, transferred) in [(2, 16), (0, 17), (0, usize::MAX)] {
|
||||
assert_eq!(
|
||||
get_vid(&mut Response {
|
||||
status,
|
||||
transferred
|
||||
})
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_after_identity_aborts_before_register_read() {
|
||||
use crate::scsi::mock::{MockTransport, Reply, StopFake};
|
||||
let mut t = StopFake::new(MockTransport::always(Reply::good(renesas_payload())));
|
||||
t.cancel_after = Some(|cdb| cdb == pioneer_optical::cdb::vendor_identity());
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
|
||||
assert_eq!(
|
||||
Renesas::new().unlock(&mut t, &ctx).unwrap_err(),
|
||||
UnlockError::Transport
|
||||
);
|
||||
assert_eq!(t.inner.calls(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendor_cdbs_are_the_wire_bytes() {
|
||||
assert_eq!(
|
||||
pioneer_optical::cdb::vendor_identity(),
|
||||
[0x3C, 0x02, 0xF1, 0, 0, 0, 0, 0, 0x30, 0]
|
||||
);
|
||||
assert_eq!(
|
||||
pioneer_optical::cdb::read_memory(0x50_0000, 0x10),
|
||||
[0x3C, 0x02, 0xB0, 0x50, 0, 0, 0, 0, 0x10, 0]
|
||||
);
|
||||
assert_eq!(
|
||||
pioneer_optical::cdb::knock(),
|
||||
[0x3B, 0x02, 0x41, 0xA5, 0xAA, 0xAA, 0, 0, 0, 0]
|
||||
);
|
||||
assert_eq!(RB_F1_LEN, 48);
|
||||
}
|
||||
|
||||
/// Serves a fixed READ_BUFFER payload (Renesas-like) with a Good status.
|
||||
struct RenesasTransport {
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
impl ScsiTransport for RenesasTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let n = self.payload.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.payload[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Rejects like a MediaTek drive: ILLEGAL REQUEST with a sense, which is
|
||||
// what distinguishes this from a dead bus.
|
||||
struct RejectingTransport;
|
||||
impl ScsiTransport for RejectingTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let mut sense = [0u8; 32];
|
||||
sense[2] = 0x05; // ILLEGAL REQUEST
|
||||
sense[12] = 0x20; // invalid command operation code
|
||||
Err(ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some(sense),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn renesas_payload() -> Vec<u8> {
|
||||
// 48-byte RB 0xF1 block with "SAT" at [16..19] (the real S13JX shape).
|
||||
let mut p = vec![0x20u8; 48];
|
||||
p[16..19].copy_from_slice(b"SAT");
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_true_on_sat_marker() {
|
||||
let mut t = RenesasTransport {
|
||||
payload: renesas_payload(),
|
||||
};
|
||||
assert!(is_renesas(&mut t).expect("no transport fault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_false_when_command_rejected() {
|
||||
let mut t = RejectingTransport;
|
||||
assert!(!is_renesas(&mut t).expect("a drive rejection is not a bus fault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_false_on_missing_marker() {
|
||||
// Good status but no "SAT" at [16..19] (e.g. a stray buffer).
|
||||
let mut t = RenesasTransport {
|
||||
payload: vec![0u8; 48],
|
||||
};
|
||||
assert!(!is_renesas(&mut t).expect("no transport fault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_false_on_short_response() {
|
||||
// Fewer than 19 bytes returned — can't carry the marker.
|
||||
let mut t = RenesasTransport {
|
||||
payload: vec![0x20u8; 8],
|
||||
};
|
||||
assert!(!is_renesas(&mut t).expect("no transport fault"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opened_renesas_reports_unlocked_and_reads_vid() {
|
||||
let mut t = RenesasTransport {
|
||||
payload: renesas_payload(),
|
||||
};
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
|
||||
// Recognized, but the hardware status bit is clear.
|
||||
let out = Renesas::new()
|
||||
.unlock(&mut t, &ctx)
|
||||
.expect("no fault")
|
||||
.expect("renesas → unlocked");
|
||||
assert!(
|
||||
out.vid.is_none(),
|
||||
"clear hardware status must not fabricate a VID"
|
||||
);
|
||||
assert_eq!(out.bus_key, None, "raw-read route needs no bus key");
|
||||
}
|
||||
|
||||
/// A drive REJECTION (ILLEGAL REQUEST, with a sense) is "not a Renesas
|
||||
/// drive" → `Ok(false)`, fall through to the next unlocker.
|
||||
#[test]
|
||||
fn declines_non_renesas() {
|
||||
let mut t = RejectingTransport;
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
|
||||
assert!(
|
||||
Renesas::new()
|
||||
.unlock(&mut t, &ctx)
|
||||
.expect("non-renesas declines")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
// Same rejection via a CONFORMING transport (`Ok` + CHECK CONDITION);
|
||||
// must reach the same answer.
|
||||
#[test]
|
||||
fn check_condition_is_not_a_renesas_drive() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
assert!(!is_renesas(&mut t).expect("a drive sense is not a bus fault"));
|
||||
}
|
||||
|
||||
/// THE defect-8 test: a dead bus on the FIRST command the unlocker issues
|
||||
/// must abort the consumer, not be reported as "not a Renesas drive".
|
||||
/// Catches restoring the `Err(_) => false` arm.
|
||||
#[test]
|
||||
fn transport_fault_aborts_instead_of_declining() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
assert_eq!(is_renesas(&mut t).unwrap_err(), UnlockError::Transport);
|
||||
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
|
||||
assert_eq!(
|
||||
Renesas::new().unlock(&mut t, &ctx).unwrap_err(),
|
||||
UnlockError::Transport
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod hardware_tests {
|
||||
use super::*;
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
|
||||
#[test]
|
||||
fn reads_fresh_vid_with_status_mask_and_exact_wire_commands() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(vec![3]),
|
||||
Reply::good(vec![0x25; 16]),
|
||||
Reply::good(vec![7]),
|
||||
Reply::good(vec![0x42; 16]),
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
assert_eq!(get_vid(&mut t).unwrap(), Some([0x25; 16]));
|
||||
assert_eq!(get_vid(&mut t).unwrap(), Some([0x42; 16]));
|
||||
assert_eq!(
|
||||
t.cdbs,
|
||||
vec![
|
||||
vec![0x3c, 2, 0x92, 0, 0x0d, 0x3c, 0, 0, 1, 0],
|
||||
vec![0x3c, 2, 0x92, 0, 0x0d, 0x20, 0, 0, 16, 0],
|
||||
VID_STATUS_CDB.to_vec(),
|
||||
VID_CDB.to_vec(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_status_stops_before_vid() {
|
||||
for reply in [
|
||||
Reply::good(vec![0]),
|
||||
Reply::good(vec![1]),
|
||||
Reply::zero_transfer(1),
|
||||
Reply::short(vec![2], 2),
|
||||
Reply::illegal_request(),
|
||||
Reply::illegal_request_as_err(),
|
||||
] {
|
||||
let mut t = MockTransport::scripted(vec![reply], Reply::TransportFault);
|
||||
assert_eq!(get_vid(&mut t).unwrap(), None);
|
||||
assert_eq!(t.calls(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlock_preserves_oem_success_on_rejections_and_aborts_transport_faults() {
|
||||
let mut identity = vec![0; 48];
|
||||
identity[16..19].copy_from_slice(b"SAT");
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, crate::DiscKind::Unknown);
|
||||
for step in 1..=2 {
|
||||
for reply in [
|
||||
Reply::illegal_request(),
|
||||
Reply::illegal_request_as_err(),
|
||||
Reply::TransportFault,
|
||||
] {
|
||||
let fault = matches!(reply, Reply::TransportFault);
|
||||
let mut replies = vec![
|
||||
Reply::good(identity.clone()),
|
||||
Reply::good(vec![2]),
|
||||
Reply::good(vec![0x25; 16]),
|
||||
];
|
||||
replies[step] = reply;
|
||||
let mut t = MockTransport::scripted(replies, Reply::TransportFault);
|
||||
let result = Renesas::new().unlock(&mut t, &ctx);
|
||||
if fault {
|
||||
assert_eq!(result.unwrap_err(), UnlockError::Transport);
|
||||
} else {
|
||||
let out = result.unwrap().unwrap();
|
||||
assert_eq!(out.vid, None);
|
||||
assert_eq!(out.bus_key, None);
|
||||
}
|
||||
assert_eq!(t.calls(), step + 1);
|
||||
}
|
||||
}
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(identity),
|
||||
Reply::good(vec![2]),
|
||||
Reply::good(vec![0x25; 16]),
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
assert_eq!(
|
||||
Renesas::new().unlock(&mut t, &ctx).unwrap().unwrap().vid,
|
||||
Some([0x25; 16])
|
||||
);
|
||||
assert_eq!(t.calls(), 3);
|
||||
assert!(t.cdbs.iter().all(|cdb| cdb[0] == 0x3c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_after_status_prevents_vid_command() {
|
||||
use crate::scsi::mock::StopFake;
|
||||
let mut t = StopFake::new(MockTransport::always(Reply::good(vec![2])));
|
||||
t.cancel_after = Some(|cdb| cdb == VID_STATUS_CDB);
|
||||
assert_eq!(get_vid(&mut t).unwrap_err(), UnlockError::Transport);
|
||||
assert_eq!(t.inner.calls(), 1);
|
||||
}
|
||||
}
|
||||
-202
@@ -1,202 +0,0 @@
|
||||
//! renesis — Renesas-platform detection (Pioneer + HL-DT-ST Renesas drives).
|
||||
//!
|
||||
//! Optical drives split into two controller families: MediaTek (handled by
|
||||
//! [`crate::ld`]) and Renesas. This module identifies the Renesas side via a
|
||||
//! single vendor identity probe (see [`is_renesas`]) and reports the match so a
|
||||
//! Renesas drive is named honestly. It does not modify drive state; AACS bus
|
||||
//! decryption is handled by the host cert.
|
||||
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
/// READ_BUFFER mode 0x02, buffer 0xF1 — the Renesas vendor identity buffer.
|
||||
const RB_F1_CDB: [u8; 10] = [0x3C, 0x02, 0xF1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00];
|
||||
const RB_F1_LEN: usize = 48;
|
||||
/// The ASCII interface marker a Renesas controller returns at `[16..19]`.
|
||||
const RENESAS_MARKER: &[u8] = b"SAT";
|
||||
const RENESAS_MARKER_OFFSET: usize = 16;
|
||||
|
||||
/// True if `scsi` is a Renesas-platform drive (Pioneer or HL-DT-ST Renesas).
|
||||
///
|
||||
/// Issues the vendor READ_BUFFER 0x02/0xF1 probe: a Renesas controller serves a
|
||||
/// 48-byte identity block whose bytes `[16..19]` are the ASCII `SAT` interface
|
||||
/// tag. A MediaTek drive rejects it (ILLEGAL REQUEST → `Err`), and a transport
|
||||
/// fault also yields `Err`; both return `false`. This is the definitive
|
||||
/// Renesas-vs-MediaTek split.
|
||||
pub fn is_renesas(scsi: &mut dyn ScsiTransport) -> bool {
|
||||
let mut buf = [0u8; RB_F1_LEN];
|
||||
match scsi.execute(&RB_F1_CDB, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
Ok(r) => {
|
||||
let end = RENESAS_MARKER_OFFSET + RENESAS_MARKER.len();
|
||||
r.status == 0
|
||||
&& r.bytes_transferred >= end
|
||||
&& &buf[RENESAS_MARKER_OFFSET..end] == RENESAS_MARKER
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Renesas-platform unlocker. `pub(crate)` — reached only through
|
||||
/// [`crate::all_unlockers`].
|
||||
pub(crate) struct Renesis;
|
||||
|
||||
impl Renesis {
|
||||
pub(crate) fn new() -> Self {
|
||||
Renesis
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for Renesis {
|
||||
fn name(&self) -> &'static str {
|
||||
"Renesas"
|
||||
}
|
||||
|
||||
/// Report whether the drive is a Renesas platform. On a match, returns `Ok`
|
||||
/// with `drive_unlocked: false` — the drive is recognized but its state is
|
||||
/// not modified here (AACS bus decryption is handled by the host cert). A
|
||||
/// non-Renesas drive → `NotApplicable`.
|
||||
fn unlock_features(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
_ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
if !is_renesas(scsi) {
|
||||
return Err(UnlockError::NotApplicable);
|
||||
}
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "renesas_recognized",
|
||||
"Renesas drive recognized; bus handled by cert"
|
||||
);
|
||||
Ok(Unlocked {
|
||||
vid: None,
|
||||
bus_key: None,
|
||||
drive_unlocked: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::DiscKind;
|
||||
use crate::scsi::{DataDirection, Result, ScsiError, ScsiResult, ScsiTransport};
|
||||
|
||||
/// Serves a fixed READ_BUFFER payload (Renesas-like) with a Good status.
|
||||
struct RenesasTransport {
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
impl ScsiTransport for RenesasTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let n = self.payload.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.payload[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Rejects the command (a MediaTek drive → ILLEGAL REQUEST → transport `Err`).
|
||||
struct RejectingTransport;
|
||||
impl ScsiTransport for RejectingTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
Err(ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn renesas_payload() -> Vec<u8> {
|
||||
// 48-byte RB 0xF1 block with "SAT" at [16..19] (the real S13JX shape).
|
||||
let mut p = vec![0x20u8; 48];
|
||||
p[16..19].copy_from_slice(b"SAT");
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_true_on_sat_marker() {
|
||||
let mut t = RenesasTransport {
|
||||
payload: renesas_payload(),
|
||||
};
|
||||
assert!(is_renesas(&mut t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_false_when_command_rejected() {
|
||||
let mut t = RejectingTransport;
|
||||
assert!(!is_renesas(&mut t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_false_on_missing_marker() {
|
||||
// Good status but no "SAT" at [16..19] (e.g. a stray buffer).
|
||||
let mut t = RenesasTransport {
|
||||
payload: vec![0u8; 48],
|
||||
};
|
||||
assert!(!is_renesas(&mut t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_renesas_false_on_short_response() {
|
||||
// Fewer than 19 bytes returned — can't carry the marker.
|
||||
let mut t = RenesasTransport {
|
||||
payload: vec![0x20u8; 8],
|
||||
};
|
||||
assert!(!is_renesas(&mut t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn features_report_match_without_bus_removal_on_renesas() {
|
||||
let mut t = RenesasTransport {
|
||||
payload: renesas_payload(),
|
||||
};
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]);
|
||||
// Recognized → Ok, but a feature-only unlock: bus NOT removed, no VID.
|
||||
let u = Renesis::new()
|
||||
.unlock_features(&mut t, &ctx)
|
||||
.expect("renesas → Ok");
|
||||
assert!(
|
||||
!u.drive_unlocked,
|
||||
"renesis does not remove the bus (cert does)"
|
||||
);
|
||||
assert_eq!(u.vid, None);
|
||||
assert_eq!(u.bus_key, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn features_not_applicable_on_non_renesas() {
|
||||
let mut t = RejectingTransport;
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Unknown, &[]);
|
||||
let err = Renesis::new().unlock_features(&mut t, &ctx).unwrap_err();
|
||||
assert_eq!(err, UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_provide_bus_removal() {
|
||||
// Renesas leaves bus encryption to the cert: unlock_bus is the default.
|
||||
let mut t = RenesasTransport {
|
||||
payload: renesas_payload(),
|
||||
};
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Aacs, &[]);
|
||||
let err = Renesis::new().unlock_bus(&mut t, &ctx).unwrap_err();
|
||||
assert_eq!(err, UnlockError::NotApplicable);
|
||||
}
|
||||
}
|
||||
+184
@@ -31,6 +31,13 @@ pub type Result<T> = std::result::Result<T, ScsiError>;
|
||||
|
||||
/// The one capability an unlocker needs from the host: run a raw CDB. `Ok` even
|
||||
/// on a SCSI sense (inspect `status`); `Err` only on a transport-layer fault.
|
||||
///
|
||||
/// **Stop (cancellation).** The host may cancel the operation. After a cancel,
|
||||
/// [`execute`](Self::execute) refuses with a dead-bus [`ScsiError`] (status 0xFF,
|
||||
/// no sense), so every unlocker aborts through its existing transport-fault path.
|
||||
/// The four defaulted methods let a cancellable host keep the drive safe. A host
|
||||
/// with no cancellation keeps the defaults: plain sleep, no-op critical spans, and
|
||||
/// cleanup CDBs that run as ordinary CDBs.
|
||||
pub trait ScsiTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
@@ -39,6 +46,165 @@ pub trait ScsiTransport {
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult>;
|
||||
|
||||
/// Wait `d` between drive attempts. A cancellable host returns `Err` (dead
|
||||
/// bus) as soon as the operation is cancelled, instead of finishing the wait.
|
||||
fn pause(&mut self, d: std::time::Duration) -> Result<()> {
|
||||
std::thread::sleep(d);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open a critical span (see [`CriticalGuard`]): until the matching
|
||||
/// [`end_critical`](Self::end_critical), `execute` must run even if the
|
||||
/// operation is cancelled meanwhile. `Err` if it is already cancelled.
|
||||
fn begin_critical(&mut self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the span opened by [`begin_critical`](Self::begin_critical).
|
||||
fn end_critical(&mut self) {}
|
||||
|
||||
/// Run a clean-up CDB that must reach the drive even after a cancel (the
|
||||
/// host allows only an AGID release it recorded, never an arbitrary CDB).
|
||||
fn execute_cleanup(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
self.execute(cdb, direction, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
|
||||
/// A critical span on a transport: [`CriticalGuard::enter`] calls
|
||||
/// [`ScsiTransport::begin_critical`] and `Drop` calls
|
||||
/// [`ScsiTransport::end_critical`], on every exit including unwind. Used where
|
||||
/// abandoning a CDB sequence midway could leave the drive unusable (a firmware
|
||||
/// upload): a cancel that lands inside the span takes effect when it closes.
|
||||
pub struct CriticalGuard<'a> {
|
||||
t: &'a mut (dyn ScsiTransport + 'a),
|
||||
}
|
||||
|
||||
impl<'a> CriticalGuard<'a> {
|
||||
/// Open the span; `Err` (and no span) if the operation is already cancelled.
|
||||
pub fn enter(t: &'a mut (dyn ScsiTransport + 'a)) -> Result<Self> {
|
||||
t.begin_critical()?;
|
||||
Ok(CriticalGuard { t })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CriticalGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.t.end_critical();
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> std::ops::Deref for CriticalGuard<'a> {
|
||||
type Target = dyn ScsiTransport + 'a;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&*self.t
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> std::ops::DerefMut for CriticalGuard<'a> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut *self.t
|
||||
}
|
||||
}
|
||||
|
||||
/// Timeout for the AGID release CDB (the 5 s every AACS/CSS key CDB uses).
|
||||
const AGID_RELEASE_TIMEOUT_MS: u32 = 5_000;
|
||||
|
||||
/// An allocated AGID (Authentication Grant ID) on a transport. `Drop` releases it
|
||||
/// with its REPORT KEY key-format 3Fh CDB, sent through
|
||||
/// [`ScsiTransport::execute_cleanup`] so a cancelled operation still frees it.
|
||||
/// [`AgidGuard::defuse`] instead hands the still-held AGID to the caller.
|
||||
/// Exactly one release per allocation: the guard is the only releaser.
|
||||
pub struct AgidGuard<'a> {
|
||||
t: &'a mut (dyn ScsiTransport + 'a),
|
||||
agid: u8,
|
||||
release: [u8; 12],
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl<'a> AgidGuard<'a> {
|
||||
/// Guard `agid`. `release` is its REPORT KEY (0xA4) key-format 3Fh CDB, built
|
||||
/// by the caller so each scheme (AACS key class 2, CSS) keeps its exact bytes.
|
||||
pub fn new(t: &'a mut (dyn ScsiTransport + 'a), agid: u8, release: [u8; 12]) -> Self {
|
||||
// SS-7 (evidence, not spec): REPORT KEY key format 3Fh invalidates the AGID
|
||||
// carried in CDB byte 10 bits 7-6 (libaacs mmc.c; the unlock AGID tests).
|
||||
debug_assert!(
|
||||
release[0] == SCSI_REPORT_KEY && release[10] == ((agid & 0x03) << 6) | 0x3F,
|
||||
"AgidGuard needs this AGID's REPORT KEY format-3Fh CDB"
|
||||
);
|
||||
AgidGuard {
|
||||
t,
|
||||
agid,
|
||||
release,
|
||||
armed: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The guarded AGID.
|
||||
pub fn agid(&self) -> u8 {
|
||||
self.agid
|
||||
}
|
||||
|
||||
/// Hand the AGID over to the caller, still allocated: nothing is released.
|
||||
pub fn defuse(mut self) -> u8 {
|
||||
self.armed = false;
|
||||
self.agid
|
||||
}
|
||||
}
|
||||
|
||||
// Manual Debug: the transport is not `Debug`; the AGID and whether it is still
|
||||
// armed (will be released on drop) are what a failure message needs.
|
||||
impl std::fmt::Debug for AgidGuard<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AgidGuard")
|
||||
.field("agid", &self.agid)
|
||||
.field("armed", &self.armed)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AgidGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
if !self.armed {
|
||||
return;
|
||||
}
|
||||
// Buffer = the CDB's allocation length (AACS: 2), or 8 when it names none (CSS).
|
||||
let alloc = ((self.release[8] as usize) << 8) | self.release[9] as usize;
|
||||
let mut buf = vec![0u8; if alloc == 0 { 8 } else { alloc }];
|
||||
// Best-effort: a failed release is not a failure of the operation.
|
||||
let r = self.t.execute_cleanup(
|
||||
&self.release,
|
||||
DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
AGID_RELEASE_TIMEOUT_MS,
|
||||
);
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "agid_released",
|
||||
agid = self.agid,
|
||||
ok = r.as_ref().is_ok_and(|r| r.status == 0),
|
||||
"AGID release (REPORT KEY format 3Fh) sent"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> std::ops::Deref for AgidGuard<'a> {
|
||||
type Target = dyn ScsiTransport + 'a;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&*self.t
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> std::ops::DerefMut for AgidGuard<'a> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut *self.t
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed SCSI sense (the diagnostic an unlocker reads off a failed command).
|
||||
@@ -66,6 +232,13 @@ impl ScsiSense {
|
||||
|
||||
/// SCSI status byte for a transport-layer failure (bridge crash / disconnect).
|
||||
pub(crate) const SCSI_STATUS_TRANSPORT_FAILURE: u8 = 0xFF;
|
||||
/// Whether a transport error is a genuine dead bus (a senseless transport-failure
|
||||
/// status) rather than a drive rejection surfaced through a non-conforming
|
||||
/// transport (`Err` carrying a sense).
|
||||
pub(crate) fn is_dead_bus(e: &ScsiError) -> bool {
|
||||
e.status == SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
|
||||
}
|
||||
|
||||
/// SCSI status byte CHECK CONDITION (a drive sense is available). Part of the
|
||||
/// status contract; currently referenced only by tests asserting the
|
||||
/// transport-vs-check-condition distinction.
|
||||
@@ -98,3 +271,14 @@ pub(crate) fn build_set_cd_speed(read_speed: u16) -> [u8; 12] {
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
|
||||
// ── Test fixture ──────────────────────────────────────────────────────────── Crate-wide mock
|
||||
// transport, able to express all three transport outcomes.
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code)] // a fixture: each helper is used by a subset of the modules
|
||||
#[path = "scsi_mock_tests.rs"]
|
||||
pub(crate) mod mock;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "scsi_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// One scripted answer to one `execute()` call.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Reply {
|
||||
/// `Ok` + GOOD status. `payload` is copied into the caller's buffer;
|
||||
/// `bytes_transferred` defaults to the number of bytes copied.
|
||||
Data {
|
||||
payload: Vec<u8>,
|
||||
bytes_transferred: Option<usize>,
|
||||
},
|
||||
/// `Ok` + a NON-ZERO SCSI status (CHECK CONDITION) with a drive sense.
|
||||
/// Per the contract at [`ScsiTransport::execute`] this is NOT a
|
||||
/// transport fault — the caller must inspect `status`, and a caller that
|
||||
/// doesn't will consume the caller's zero-filled buffer as drive data.
|
||||
Sense {
|
||||
status: u8,
|
||||
sense_key: u8,
|
||||
asc: u8,
|
||||
ascq: u8,
|
||||
},
|
||||
/// `Err` with the transport-failure status and no sense — a genuine
|
||||
/// transport-layer fault (bridge crash / disconnect). MUST abort.
|
||||
TransportFault,
|
||||
/// `Err` carrying a real status + parsed sense. This is what a
|
||||
/// NON-conforming transport does (libfreemkv's adapter returns `Err` for
|
||||
/// any non-zero SCSI status); classification must still treat it as a
|
||||
/// drive rejection, not a dead bus.
|
||||
ErrWithSense {
|
||||
status: u8,
|
||||
sense_key: u8,
|
||||
asc: u8,
|
||||
ascq: u8,
|
||||
},
|
||||
}
|
||||
|
||||
impl Reply {
|
||||
/// `Ok`, GOOD status, full transfer of `payload`.
|
||||
pub(crate) fn good(payload: Vec<u8>) -> Reply {
|
||||
Reply::Data {
|
||||
payload,
|
||||
bytes_transferred: None,
|
||||
}
|
||||
}
|
||||
/// `Ok`, GOOD status, but the drive only delivered `n` bytes.
|
||||
pub(crate) fn short(payload: Vec<u8>, n: usize) -> Reply {
|
||||
Reply::Data {
|
||||
payload,
|
||||
bytes_transferred: Some(n),
|
||||
}
|
||||
}
|
||||
/// `Ok`, GOOD status, ZERO bytes delivered — the buffer the caller reads
|
||||
/// is entirely its own zero fill.
|
||||
pub(crate) fn zero_transfer(len: usize) -> Reply {
|
||||
Reply::Data {
|
||||
payload: vec![0u8; len],
|
||||
bytes_transferred: Some(0),
|
||||
}
|
||||
}
|
||||
/// `Ok` + CHECK CONDITION / ILLEGAL REQUEST (0x05, ASC 0x20 invalid
|
||||
/// command) — the ordinary way a drive refuses a vendor command.
|
||||
pub(crate) fn illegal_request() -> Reply {
|
||||
Reply::Sense {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense_key: 0x05,
|
||||
asc: 0x20,
|
||||
ascq: 0x00,
|
||||
}
|
||||
}
|
||||
/// `Err` + CHECK CONDITION / ILLEGAL REQUEST — the same drive refusal as
|
||||
/// seen through a non-conforming transport.
|
||||
pub(crate) fn illegal_request_as_err() -> Reply {
|
||||
Reply::ErrWithSense {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense_key: 0x05,
|
||||
asc: 0x20,
|
||||
ascq: 0x00,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sense_buf(sense_key: u8, asc: u8, ascq: u8) -> [u8; 32] {
|
||||
let mut b = [0u8; 32];
|
||||
b[2] = sense_key & 0x0F;
|
||||
b[12] = asc;
|
||||
b[13] = ascq;
|
||||
b
|
||||
}
|
||||
|
||||
/// A scripted transport: answers each `execute()` from `script`, falling
|
||||
/// back to `default` once the script runs out, and records every CDB.
|
||||
pub(crate) struct MockTransport {
|
||||
pub(crate) script: VecDeque<Reply>,
|
||||
pub(crate) default: Reply,
|
||||
pub(crate) cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MockTransport {
|
||||
/// Every command gets the same answer.
|
||||
pub(crate) fn always(reply: Reply) -> Self {
|
||||
MockTransport {
|
||||
script: VecDeque::new(),
|
||||
default: reply,
|
||||
cdbs: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The first N commands follow `script`; the rest get `default`.
|
||||
pub(crate) fn scripted(script: Vec<Reply>, default: Reply) -> Self {
|
||||
MockTransport {
|
||||
script: script.into(),
|
||||
default,
|
||||
cdbs: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// How many CDBs were issued (lets a test assert a dead bus aborted
|
||||
/// instead of retrying).
|
||||
pub(crate) fn calls(&self) -> usize {
|
||||
self.cdbs.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl ScsiTransport for MockTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
let reply = self
|
||||
.script
|
||||
.pop_front()
|
||||
.unwrap_or_else(|| self.default.clone());
|
||||
match reply {
|
||||
Reply::Data {
|
||||
payload,
|
||||
bytes_transferred,
|
||||
} => {
|
||||
let n = payload.len().min(data.len());
|
||||
data[..n].copy_from_slice(&payload[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: bytes_transferred.unwrap_or(n),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
Reply::Sense {
|
||||
status,
|
||||
sense_key,
|
||||
asc,
|
||||
ascq,
|
||||
} => Ok(ScsiResult {
|
||||
status,
|
||||
bytes_transferred: 0,
|
||||
sense: sense_buf(sense_key, asc, ascq),
|
||||
}),
|
||||
Reply::TransportFault => Err(ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
}),
|
||||
Reply::ErrWithSense {
|
||||
status,
|
||||
sense_key,
|
||||
asc,
|
||||
ascq,
|
||||
} => Err(ScsiError {
|
||||
status,
|
||||
sense: Some(sense_buf(sense_key, asc, ascq)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One transport call as seen by [`StopFake`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum Ev {
|
||||
/// `execute` reached the drive.
|
||||
Exec(Vec<u8>),
|
||||
/// `execute` refused because the operation was cancelled.
|
||||
Refused(Vec<u8>),
|
||||
/// `execute_cleanup` reached the drive.
|
||||
Cleanup(Vec<u8>),
|
||||
Pause(std::time::Duration),
|
||||
PauseRefused(std::time::Duration),
|
||||
Begin,
|
||||
BeginRefused,
|
||||
End,
|
||||
}
|
||||
|
||||
/// A cancellable host, as libfreemkv's adapter behaves (stop-design-v5 §2.3,
|
||||
/// §2.4): after a cancel, `execute` refuses with 0xFF / no sense unless a
|
||||
/// critical span opened BEFORE the cancel is still open; `begin_critical` and
|
||||
/// `pause` fail; `execute_cleanup` still runs. Wraps a drive emulator `inner`.
|
||||
pub(crate) struct StopFake<T> {
|
||||
pub(crate) inner: T,
|
||||
pub(crate) cancelled: bool,
|
||||
critical: u32,
|
||||
/// Cancel right after a CDB matching this predicate reaches the drive
|
||||
/// (a Stop that lands while that CDB is in flight).
|
||||
pub(crate) cancel_after: Option<fn(&[u8]) -> bool>,
|
||||
/// Cancel when the next `pause` starts (a Stop during the wait).
|
||||
pub(crate) cancel_on_pause: bool,
|
||||
pub(crate) log: Vec<Ev>,
|
||||
}
|
||||
|
||||
fn refused() -> ScsiError {
|
||||
ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> StopFake<T> {
|
||||
pub(crate) fn new(inner: T) -> Self {
|
||||
StopFake {
|
||||
inner,
|
||||
cancelled: false,
|
||||
critical: 0,
|
||||
cancel_after: None,
|
||||
cancel_on_pause: false,
|
||||
log: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// CDBs that reached the drive through `execute`.
|
||||
pub(crate) fn execs(&self) -> Vec<&Vec<u8>> {
|
||||
self.log
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Ev::Exec(c) => Some(c),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// CDBs that reached the drive through `execute_cleanup`.
|
||||
pub(crate) fn cleanups(&self) -> Vec<&Vec<u8>> {
|
||||
self.log
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Ev::Cleanup(c) => Some(c),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ScsiTransport> ScsiTransport for StopFake<T> {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
if self.cancelled && self.critical == 0 {
|
||||
self.log.push(Ev::Refused(cdb.to_vec()));
|
||||
return Err(refused());
|
||||
}
|
||||
self.log.push(Ev::Exec(cdb.to_vec()));
|
||||
let r = self.inner.execute(cdb, dir, data, timeout_ms);
|
||||
if self.cancel_after.is_some_and(|p| p(cdb)) {
|
||||
self.cancelled = true;
|
||||
}
|
||||
r
|
||||
}
|
||||
fn pause(&mut self, d: std::time::Duration) -> Result<()> {
|
||||
if self.cancel_on_pause {
|
||||
self.cancelled = true;
|
||||
}
|
||||
if self.cancelled {
|
||||
self.log.push(Ev::PauseRefused(d));
|
||||
return Err(refused());
|
||||
}
|
||||
self.log.push(Ev::Pause(d)); // no real sleep: tests run in microseconds
|
||||
Ok(())
|
||||
}
|
||||
fn begin_critical(&mut self) -> Result<()> {
|
||||
if self.cancelled {
|
||||
self.log.push(Ev::BeginRefused);
|
||||
return Err(refused());
|
||||
}
|
||||
self.critical += 1;
|
||||
self.log.push(Ev::Begin);
|
||||
Ok(())
|
||||
}
|
||||
fn end_critical(&mut self) {
|
||||
self.critical -= 1;
|
||||
self.log.push(Ev::End);
|
||||
}
|
||||
fn execute_cleanup(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
self.log.push(Ev::Cleanup(cdb.to_vec()));
|
||||
self.inner.execute(cdb, dir, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
|
||||
/// REPORT KEY (0xA4) key format 0x3F (AGID invalidate / release).
|
||||
pub(crate) fn is_agid_release(cdb: &[u8]) -> bool {
|
||||
cdb.first() == Some(&SCSI_REPORT_KEY) && cdb.get(10).is_some_and(|b| b & 0x3F == 0x3F)
|
||||
}
|
||||
/// REPORT KEY (0xA4) key format 0x00 (AGID allocation).
|
||||
pub(crate) fn is_agid_alloc(cdb: &[u8]) -> bool {
|
||||
cdb.first() == Some(&SCSI_REPORT_KEY) && cdb.get(10).is_some_and(|b| b & 0x3F == 0x00)
|
||||
}
|
||||
|
||||
/// SS-7 (evidence): every allocated AGID is released exactly once, through
|
||||
/// `execute_cleanup`, before the next allocation; `execute` never releases a
|
||||
/// held AGID (it only invalidates beforehand). Returns (allocations, held at end).
|
||||
pub(crate) fn agid_ledger(log: &[Ev]) -> (usize, bool) {
|
||||
let (mut allocs, mut held) = (0usize, false);
|
||||
for (i, e) in log.iter().enumerate() {
|
||||
match e {
|
||||
Ev::Exec(c) if is_agid_alloc(c) => {
|
||||
assert!(!held, "event {i}: allocated while still holding an AGID");
|
||||
held = true;
|
||||
allocs += 1;
|
||||
}
|
||||
Ev::Exec(c) if is_agid_release(c) => {
|
||||
assert!(!held, "event {i}: a held AGID was released via execute");
|
||||
}
|
||||
Ev::Cleanup(c) => {
|
||||
assert!(
|
||||
is_agid_release(c),
|
||||
"event {i}: cleanup of a non-release CDB"
|
||||
);
|
||||
assert!(held, "event {i}: released an AGID that was not held");
|
||||
held = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(allocs, held)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
use super::*;
|
||||
use mock::{MockTransport, Reply};
|
||||
|
||||
/// The fixed-format sense layout the whole crate reads its diagnostics off
|
||||
/// (key at byte 2 low nibble, ASC at 12, ASCQ at 13). This file had no test
|
||||
/// at all, so nothing pinned the offsets.
|
||||
#[test]
|
||||
fn sense_parses_at_the_fixed_format_offsets() {
|
||||
let mut buf = [0u8; 32];
|
||||
buf[2] = 0xF5; // sense key is the LOW nibble only
|
||||
buf[12] = 0x24;
|
||||
buf[13] = 0x01;
|
||||
let s = ScsiSense::from_buf(&buf);
|
||||
assert_eq!(s.sense_key, 0x05);
|
||||
assert_eq!(s.asc, 0x24);
|
||||
assert_eq!(s.ascq, 0x01);
|
||||
assert!(s.is_illegal_request());
|
||||
|
||||
buf[2] = 0x02; // NOT READY
|
||||
assert!(!ScsiSense::from_buf(&buf).is_illegal_request());
|
||||
}
|
||||
|
||||
/// SET CD SPEED encodes the read speed big-endian at bytes 2-3 and leaves
|
||||
/// the write speed at 0xFFFF.
|
||||
#[test]
|
||||
fn set_cd_speed_encodes_the_read_speed_big_endian() {
|
||||
let cdb = build_set_cd_speed(0x1234);
|
||||
assert_eq!(cdb[0], SCSI_SET_CD_SPEED);
|
||||
assert_eq!([cdb[2], cdb[3]], [0x12, 0x34]);
|
||||
assert_eq!([cdb[4], cdb[5]], [0xFF, 0xFF]);
|
||||
assert_eq!(build_set_cd_speed(0xFFFF)[2..4], [0xFF, 0xFF]);
|
||||
}
|
||||
|
||||
// The fixture must honour the contract it tests: a drive sense is `Ok`
|
||||
// with non-zero status, a bus fault is `Err`. If this drifts, every
|
||||
// transport-contract test built on it silently stops testing anything.
|
||||
#[test]
|
||||
fn mock_transport_expresses_the_three_contract_outcomes() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(vec![0xAB; 4]),
|
||||
Reply::illegal_request(),
|
||||
Reply::zero_transfer(4),
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let mut buf = [0u8; 4];
|
||||
|
||||
let r = t
|
||||
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
|
||||
.expect("data reply is Ok");
|
||||
assert_eq!(r.status, 0);
|
||||
assert_eq!(r.bytes_transferred, 4);
|
||||
assert_eq!(buf, [0xAB; 4]);
|
||||
|
||||
let r = t
|
||||
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
|
||||
.expect("A DRIVE SENSE IS Ok, NOT Err — the load-bearing contract");
|
||||
assert_eq!(r.status, SCSI_STATUS_CHECK_CONDITION);
|
||||
assert!(ScsiSense::from_buf(&r.sense).is_illegal_request());
|
||||
|
||||
let r = t
|
||||
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
|
||||
.expect("a zero-length transfer is still Ok");
|
||||
assert_eq!(r.bytes_transferred, 0);
|
||||
|
||||
let e = t
|
||||
.execute(&[0x3C], DataDirection::FromDevice, &mut buf, 0)
|
||||
.expect_err("a transport fault is Err");
|
||||
assert_eq!(e.status, SCSI_STATUS_TRANSPORT_FAILURE);
|
||||
assert!(e.sense.is_none(), "a bus fault carries no drive sense");
|
||||
|
||||
assert_eq!(t.calls(), 4);
|
||||
}
|
||||
|
||||
// UT1 (stop-design-v5 §5.2; §2.3 "`enter` calls `begin_critical()?`. `Drop` calls
|
||||
// `end_critical`"): refused after a cancel; closed on drop AND on unwind.
|
||||
#[test]
|
||||
fn critical_guard_refused_after_cancel_and_ends_on_unwind() {
|
||||
use mock::{Ev, StopFake};
|
||||
let mut t = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 4])));
|
||||
{
|
||||
let mut g = CriticalGuard::enter(&mut t).expect("not cancelled: opens");
|
||||
g.execute(&[0x3B], DataDirection::ToDevice, &mut [0u8; 4], 0)
|
||||
.expect("inside the span");
|
||||
}
|
||||
assert_eq!(t.log[0], Ev::Begin);
|
||||
assert_eq!(t.log.last(), Some(&Ev::End), "closed on drop");
|
||||
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _g = CriticalGuard::enter(&mut t).expect("opens");
|
||||
panic!("unwind inside the span");
|
||||
}));
|
||||
assert!(r.is_err());
|
||||
assert_eq!(t.log.last(), Some(&Ev::End), "closed on unwind");
|
||||
|
||||
t.cancelled = true;
|
||||
let e = CriticalGuard::enter(&mut t)
|
||||
.err()
|
||||
.expect("refused after a cancel");
|
||||
assert!(is_dead_bus(&e), "a refusal is the dead-bus shape");
|
||||
assert_eq!(t.log.last(), Some(&Ev::BeginRefused), "no span, so no End");
|
||||
let begins = t.log.iter().filter(|e| **e == Ev::Begin).count();
|
||||
let ends = t.log.iter().filter(|e| **e == Ev::End).count();
|
||||
assert_eq!(begins, ends, "every opened span closed exactly once");
|
||||
}
|
||||
|
||||
// The defaults keep all 32 existing transports compiling and behaving as
|
||||
// before: pause sleeps, spans are no-ops, cleanup is a plain execute.
|
||||
#[test]
|
||||
fn default_stop_methods_preserve_plain_transport_behaviour() {
|
||||
let mut t = MockTransport::always(Reply::good(vec![0u8; 2]));
|
||||
let t0 = std::time::Instant::now();
|
||||
t.pause(std::time::Duration::from_millis(20))
|
||||
.expect("plain sleep");
|
||||
assert!(t0.elapsed() >= std::time::Duration::from_millis(20));
|
||||
t.begin_critical().expect("no-op");
|
||||
t.end_critical();
|
||||
t.execute_cleanup(&[0xA4], DataDirection::FromDevice, &mut [0u8; 2], 0)
|
||||
.expect("plain execute");
|
||||
assert_eq!(t.cdbs, vec![vec![0xA4]], "cleanup reached the drive once");
|
||||
}
|
||||
|
||||
// SS-7 (evidence): the guard's drop sends its REPORT KEY 3Fh CDB once, via
|
||||
// execute_cleanup, even after a cancel; `defuse` sends nothing.
|
||||
#[test]
|
||||
fn agid_guard_releases_once_via_cleanup_and_defuse_keeps_it() {
|
||||
use mock::{Ev, StopFake};
|
||||
let mut rel = [0u8; 12];
|
||||
rel[0] = SCSI_REPORT_KEY;
|
||||
rel[10] = (2 << 6) | 0x3F;
|
||||
let mut t = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 8])));
|
||||
t.cancelled = true;
|
||||
drop(AgidGuard::new(&mut t, 2, rel));
|
||||
assert_eq!(t.log, vec![Ev::Cleanup(rel.to_vec())]);
|
||||
|
||||
t.log.clear();
|
||||
let g = AgidGuard::new(&mut t, 2, rel);
|
||||
assert_eq!(g.agid(), 2);
|
||||
assert_eq!(g.defuse(), 2);
|
||||
assert!(t.log.is_empty(), "defuse hands the AGID over unreleased");
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
//! Shared AACS Volume ID read — the standard `READ DISC STRUCTURE` (`0xAD`,
|
||||
//! format `0x80`) that returns the VID on a drive whose host-auth / bus has
|
||||
//! already been opened by a compatible firmware unlock (freemkv Raw Read or
|
||||
//! MT1959). Renesas uses its separate vendor-memory VID reader.
|
||||
//!
|
||||
//! BEST-EFFORT: only a dead bus is an `Err(Transport)`. A CHECK CONDITION, a
|
||||
//! short response, or an all-zero VID all yield `Ok(None)` — a VID miss must
|
||||
//! never discard an unlock that already removed the bus (a key source can still
|
||||
//! supply the key).
|
||||
|
||||
use crate::UnlockError;
|
||||
use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus};
|
||||
|
||||
/// The 16-byte AACS Volume ID.
|
||||
const VID_LEN: usize = 16;
|
||||
/// `READ DISC STRUCTURE` format for the Volume ID.
|
||||
const DISC_STRUCT_FMT_VID: u8 = 0x80;
|
||||
/// Response length: a 4-byte header, the 16-byte VID, and a 16-byte MAC. On the
|
||||
/// bare-read path (no bus key) the MAC can't be verified and is ignored.
|
||||
const VID_STRUCT_LEN: u16 = 36;
|
||||
|
||||
/// The standard `0xAD` fmt-`0x80` (Blu-ray, AGID 0) Volume ID CDB — NOT a vendor
|
||||
/// knock; valid once the drive's bus/host-auth is open. Mirrors libfreemkv's
|
||||
/// `read_volume_id`.
|
||||
pub(crate) fn build_vid_cdb() -> [u8; 12] {
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_READ_DISC_STRUCTURE;
|
||||
cdb[1] = 0x01; // Blu-ray
|
||||
cdb[7] = DISC_STRUCT_FMT_VID;
|
||||
cdb[8] = (VID_STRUCT_LEN >> 8) as u8;
|
||||
cdb[9] = (VID_STRUCT_LEN & 0xFF) as u8;
|
||||
// cdb[10] = agid << 6; AGID 0 on the bare path (no AKE) → 0.
|
||||
cdb
|
||||
}
|
||||
|
||||
/// Read the AACS VID with the bare `0xAD` fmt `0x80` (valid only after the drive
|
||||
/// is unlocked). `Ok(Some(vid))` on a well-formed non-zero VID; `Ok(None)` for
|
||||
/// any "no VID" outcome (rejected / short / all-zero); `Err(Transport)` only on
|
||||
/// a dead bus.
|
||||
pub fn read_aacs_vid(
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
) -> std::result::Result<Option<[u8; 16]>, UnlockError> {
|
||||
let cdb = build_vid_cdb();
|
||||
let mut buf = [0u8; VID_STRUCT_LEN as usize];
|
||||
let result = match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
if is_dead_bus(&e) {
|
||||
return Err(UnlockError::Transport);
|
||||
}
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "vid_rejected_as_err",
|
||||
status = e.status,
|
||||
"bare VID read rejected (via Err); no Volume ID"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
// A drive sense arrives as Ok with a non-zero status; without this check a
|
||||
// CHECK CONDITION's zero-filled buffer would parse as a VID.
|
||||
if result.status != 0 {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "vid_check_condition",
|
||||
status = result.status,
|
||||
"bare VID read returned a drive sense (no medium / drive not open)"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
// Need the 4-byte header + the 16-byte VID.
|
||||
if result.bytes_transferred < 4 + VID_LEN {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "vid_short_response",
|
||||
bytes_transferred = result.bytes_transferred,
|
||||
"bare VID response too short"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
let mut vid = [0u8; VID_LEN];
|
||||
vid.copy_from_slice(&buf[4..4 + VID_LEN]);
|
||||
// An all-zero VID is what a permissive stub or an unfilled response leaves
|
||||
// behind — reject it rather than pass a bogus key downstream.
|
||||
if vid.iter().all(|&b| b == 0) {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "vid_all_zero",
|
||||
"bare VID read returned an all-zero Volume ID"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
tracing::debug!(target: "freemkv::disc", phase = "vid_ok", "Volume ID retrieved via bare 0xAD read");
|
||||
Ok(Some(vid))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "vid_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,86 @@
|
||||
use super::*;
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
|
||||
/// A well-formed format-0x80 VID structure: 4-byte header, 16-byte VID at
|
||||
/// offset 4, 16-byte MAC (zeroed on the bare path — it isn't checked).
|
||||
fn vid_ds_response(vid: [u8; 16]) -> Vec<u8> {
|
||||
let mut p = vec![0u8; VID_STRUCT_LEN as usize];
|
||||
p[4..20].copy_from_slice(&vid);
|
||||
p
|
||||
}
|
||||
|
||||
/// The bare VID read is the STANDARD 0xAD READ DISC STRUCTURE (format 0x80,
|
||||
/// Blu-ray, AGID 0, len 36) — NOT a vendor knock.
|
||||
#[test]
|
||||
fn build_vid_cdb_is_standard_0xad_fmt_80() {
|
||||
let cdb = build_vid_cdb();
|
||||
assert_eq!(cdb[0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
|
||||
assert_eq!(cdb[1], 0x01); // Blu-ray
|
||||
assert_eq!(cdb[7], 0x80); // AACS Volume ID
|
||||
assert_eq!([cdb[8], cdb[9]], [0x00, 0x24]); // 36, BE16
|
||||
assert_eq!(cdb[10], 0x00); // AGID 0 (no AKE)
|
||||
}
|
||||
|
||||
/// Parses the VID from response[4..20] and issues exactly one 0xAD read.
|
||||
#[test]
|
||||
fn reads_and_parses_offset_4() {
|
||||
let vid = [0x5Au8; 16];
|
||||
let mut t = MockTransport::always(Reply::good(vid_ds_response(vid)));
|
||||
let got = read_aacs_vid(&mut t).expect("no fault");
|
||||
assert_eq!(got, Some(vid));
|
||||
assert_eq!(t.cdbs.len(), 1, "exactly one command");
|
||||
assert_eq!(t.cdbs[0][0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
|
||||
assert_eq!(t.cdbs[0][7], 0x80);
|
||||
}
|
||||
|
||||
/// The MAC region (bytes 20..36) is ignored on the bare path.
|
||||
#[test]
|
||||
fn ignores_the_unverifiable_mac() {
|
||||
let vid: [u8; 16] = [
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE,
|
||||
0xFF,
|
||||
];
|
||||
let mut t = MockTransport::always(Reply::good(vid_ds_response(vid)));
|
||||
assert_eq!(read_aacs_vid(&mut t).expect("no fault"), Some(vid));
|
||||
}
|
||||
|
||||
/// A response too short to carry header + VID → best-effort `Ok(None)`.
|
||||
#[test]
|
||||
fn short_response_is_none() {
|
||||
let mut t = MockTransport::always(Reply::short(vid_ds_response([0x11; 16]), 19));
|
||||
assert_eq!(read_aacs_vid(&mut t).expect("no fault"), None);
|
||||
}
|
||||
|
||||
/// A CHECK CONDITION (no medium / drive not open) → `Ok(None)`, never a VID
|
||||
/// parsed from the zero fill.
|
||||
#[test]
|
||||
fn check_condition_is_none() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
assert_eq!(read_aacs_vid(&mut t).expect("no fault"), None);
|
||||
}
|
||||
|
||||
/// An all-zero VID (permissive stub / unfilled response) → `Ok(None)`.
|
||||
#[test]
|
||||
fn all_zero_is_none() {
|
||||
let mut t = MockTransport::always(Reply::good(vid_ds_response([0u8; 16])));
|
||||
assert_eq!(read_aacs_vid(&mut t).expect("no fault"), None);
|
||||
}
|
||||
|
||||
/// A rejection surfaced as `Err` WITH a sense (a non-conforming transport)
|
||||
/// is a drive refusal, not a dead bus → `Ok(None)` VID-miss, never
|
||||
/// `Err(Transport)`.
|
||||
#[test]
|
||||
fn err_with_sense_is_a_vid_miss_not_transport() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request_as_err());
|
||||
assert_eq!(
|
||||
read_aacs_vid(&mut t).expect("a sense is not a dead bus"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// Only a dead bus is an error.
|
||||
#[test]
|
||||
fn transport_fault_propagates() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
assert_eq!(read_aacs_vid(&mut t).unwrap_err(), UnlockError::Transport);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Op-path sleep guard (Stop design v5 §5.8): "Ban `std::thread::sleep` on op
|
||||
//! paths: unlock `src/{aacs,css,ld}` ... Use `Halt::wait` / `pause` instead."
|
||||
//!
|
||||
//! Every production (non-`#[cfg(test)]`) line under `src/aacs`, `src/css` and
|
||||
//! `src/ld` is scanned for the substring `thread::sleep(` (catching both
|
||||
//! `std::thread::sleep(` and an already-imported `thread::sleep(`). A raw sleep
|
||||
//! on these op paths can't be cancelled; [`ScsiTransport::pause`] (which a
|
||||
//! cancellable host wires to `Halt::wait`) is the sanctioned replacement.
|
||||
//! `clippy.toml`'s `disallowed-methods` can't scope to a subdirectory, hence
|
||||
//! this grep test instead of a lint config entry.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The banned substring: matches `std::thread::sleep(` and `thread::sleep(`.
|
||||
const BANNED: &str = "thread::sleep(";
|
||||
|
||||
/// Every `.rs` file under `dir`, recursively.
|
||||
fn rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
rs_files(&p, out);
|
||||
} else if p.extension().is_some_and(|x| x == "rs") {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 1-based line numbers of every `thread::sleep(` call in `src` that is NOT
|
||||
/// inside a `#[cfg(test)]` item or block. Tracks brace depth: a `#[cfg(test)]`
|
||||
/// attribute starts a "pending" item; if that item opens a `{`, everything
|
||||
/// until the matching `}` is skipped; if it's a single-line item ending in
|
||||
/// `,`/`;` with no `{` (e.g. a struct field), only that line is skipped.
|
||||
fn op_path_hits(src: &str) -> Vec<usize> {
|
||||
let mut hits = Vec::new();
|
||||
let mut depth: i32 = 0;
|
||||
let mut paren_depth: i32 = 0;
|
||||
let mut pending_cfg_test = false;
|
||||
let mut skip_until_depth: Option<i32> = None;
|
||||
|
||||
for (idx, line) in src.lines().enumerate() {
|
||||
let lineno = idx + 1;
|
||||
let trimmed = line.trim();
|
||||
let opens = line.matches('{').count() as i32;
|
||||
let closes = line.matches('}').count() as i32;
|
||||
|
||||
if let Some(d) = skip_until_depth {
|
||||
depth += opens - closes;
|
||||
if depth <= d {
|
||||
skip_until_depth = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed.starts_with("#[cfg(test)]") {
|
||||
pending_cfg_test = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if pending_cfg_test {
|
||||
paren_depth += line.matches('(').count() as i32 - line.matches(')').count() as i32;
|
||||
if opens > 0 {
|
||||
let before = depth;
|
||||
depth += opens - closes;
|
||||
skip_until_depth = Some(before);
|
||||
} else if paren_depth <= 0 && (trimmed.ends_with(',') || trimmed.ends_with(';')) {
|
||||
pending_cfg_test = false;
|
||||
}
|
||||
// Multi-line signature before `{` (or an open param list): stay
|
||||
// pending, keep scanning.
|
||||
continue;
|
||||
}
|
||||
|
||||
depth += opens - closes;
|
||||
if line.contains(BANNED) {
|
||||
hits.push(lineno);
|
||||
}
|
||||
}
|
||||
hits
|
||||
}
|
||||
|
||||
/// Guard: no raw `thread::sleep(` in production code under `src/aacs`,
|
||||
/// `src/css` or `src/ld` (Stop design v5 §5.8). Use `pause` / `Halt::wait`.
|
||||
#[test]
|
||||
fn no_raw_sleep_on_unlock_op_paths() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let mut files = Vec::new();
|
||||
for sub in ["src/aacs", "src/css", "src/ld"] {
|
||||
rs_files(&root.join(sub), &mut files);
|
||||
}
|
||||
assert!(
|
||||
files.len() >= 3,
|
||||
"the scan must reach real files (found {})",
|
||||
files.len()
|
||||
);
|
||||
|
||||
let mut hits = Vec::new();
|
||||
for f in &files {
|
||||
let src = std::fs::read_to_string(f).expect("read source");
|
||||
for line in op_path_hits(&src) {
|
||||
hits.push(format!(
|
||||
"{}:{line}",
|
||||
f.strip_prefix(root).unwrap().display()
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
hits.is_empty(),
|
||||
"raw thread::sleep on an unlock op path (use pause / Halt::wait instead):\n{}",
|
||||
hits.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// Self-test: the guard skips `#[cfg(test)]` items/blocks and catches a
|
||||
/// production-code sleep.
|
||||
#[test]
|
||||
fn the_guard_skips_cfg_test_and_catches_production_sleeps() {
|
||||
let clean = [
|
||||
// A `mod tests { ... }` block, sleep inside it ignored.
|
||||
"fn f() {}\n#[cfg(test)]\nmod tests {\n fn g() {\n std::thread::sleep(D);\n }\n}\n",
|
||||
// A single test-only fn (one-liner body).
|
||||
"#[cfg(test)]\nfn helper() { std::thread::sleep(D); }\n",
|
||||
// A cfg(test)-gated struct field (single line, ends in `,`).
|
||||
"struct S {\n #[cfg(test)]\n x: Option<u8>,\n}\n",
|
||||
// A cfg(test)-gated statement inside a normal (production) fn.
|
||||
"fn real() -> X {\n #[cfg(test)]\n if let Some(m) = HOOK.get() {\n thread::sleep(D);\n return m();\n }\n Y\n}\n",
|
||||
// A multi-line test-only fn signature before the brace.
|
||||
"#[cfg(test)]\nfn helper(\n a: u8,\n) -> Bar {\n thread::sleep(D);\n Bar\n}\n",
|
||||
];
|
||||
for s in &clean {
|
||||
assert!(op_path_hits(s).is_empty(), "false positive: {s}");
|
||||
}
|
||||
|
||||
let dirty = [
|
||||
"fn real() {\n std::thread::sleep(D);\n}\n",
|
||||
"fn real() {\n thread::sleep(D);\n}\n",
|
||||
];
|
||||
for s in &dirty {
|
||||
assert_eq!(op_path_hits(s), vec![2], "missed hit: {s}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user