mirror of
https://github.com/freemkv/freemkv-unlock.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
e7a60d461c | ||
|
|
e7490240a1 | ||
|
|
3c535ec874 | ||
|
|
21cfb17d11 | ||
|
|
33e65f739c | ||
|
|
ac94c80163 | ||
|
|
94cad15a98 | ||
|
|
295046054b | ||
|
|
d339108719 | ||
|
|
c1447a3dae | ||
|
|
f073c2c3f9 | ||
|
|
9e84eeb284 | ||
|
|
5aed39a109 | ||
|
|
876943a554 | ||
|
|
7c3a46897f | ||
|
|
b20b38e3d4 | ||
|
|
f2747d5a72 | ||
|
|
2877ccd022 | ||
|
|
af49583865 | ||
|
|
ef432b43ca | ||
|
|
fe23557555 |
@@ -0,0 +1,63 @@
|
||||
version: 2
|
||||
|
||||
# Dependency updates land on `dev`, never on `main`.
|
||||
#
|
||||
# `main` here is a RELEASE POINTER that release.sh moves to each tag. A bot
|
||||
# commit on it would put work there that no tag contains, which is exactly the
|
||||
# state that aborted the 1.6.2 cascade at the last step -- so pointing
|
||||
# Dependabot at main would recreate that failure on a schedule.
|
||||
updates:
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
# One PR per week for the routine bumps instead of one per crate. Eight
|
||||
# repos times a handful of crates is a volume nobody reads, and an
|
||||
# unread PR queue is indistinguishable from no updates at all.
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
ignore:
|
||||
# The freemkv crates depend on each other by GIT TAG, re-pinned by
|
||||
# release.sh as part of the release commit. Dependabot cannot see that
|
||||
# cascade, so a PR bumping one of these would fight the release process
|
||||
# and could pin a version whose tag does not exist yet.
|
||||
- dependency-name: freemkv-unlock
|
||||
- dependency-name: libfreemkv
|
||||
- dependency-name: freemkv-keysources
|
||||
- dependency-name: freemkv-i18n
|
||||
- dependency-name: freemkv-engine
|
||||
|
||||
# The workflows are now real infrastructure -- the release cascade, the
|
||||
# cross-platform hash matrix, the disc gate -- so their actions need the same
|
||||
# attention as the crates.
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
actions:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
ignore:
|
||||
# NOT a dependency: `dtolnay/rust-toolchain` is versioned by the RUST
|
||||
# release it installs, and the tag we pin is the toolchain CI is pinned
|
||||
# to on purpose -- precommit.sh runs the same one locally so a lint that
|
||||
# passes on a developer's newer default cannot pass CI by accident.
|
||||
#
|
||||
# Dependabot reads those tags as semver and proposed 1.97.0 -> 1.100.0,
|
||||
# a Rust version that does not exist. Every such PR 404s on toolchain
|
||||
# download across all eight repos, and they regenerate weekly -- eight
|
||||
# permanently-red PRs that promote.yml then has to special-case when it
|
||||
# decides whether dev is green.
|
||||
#
|
||||
# Bumping the toolchain is a deliberate, all-eight-repos change, made by
|
||||
# hand together with precommit.sh. There is nothing here for a bot.
|
||||
- dependency-name: dtolnay/rust-toolchain
|
||||
@@ -0,0 +1,205 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
# 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:
|
||||
|
||||
# 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
|
||||
# checkout is needed here — but it is also the crate whose breakage takes all
|
||||
# seven others down with it, which is why it gets the full OS matrix rather
|
||||
# than a token Linux run.
|
||||
#
|
||||
# Cargo.lock is now committed (issue #15), so the resolved dependency graph is
|
||||
# recorded and the `deny` job audits it. `--locked` is still deliberately NOT
|
||||
# used, matching the binary crates' ci.yml: CI's sibling path-patch layout
|
||||
# resolves the freemkv crates as path packages, which `--locked` can't satisfy.
|
||||
|
||||
jobs:
|
||||
# ── Single source of truth for the MSRV ─────────────────────────────────────
|
||||
#
|
||||
# `rust-version` in Cargo.toml is the ONE place the MSRV is declared. This job
|
||||
# reads it; every job that must build/test AT the MSRV consumes
|
||||
# `needs.resolve-msrv.outputs.v` instead of a hardcoded pin, so the floor
|
||||
# cannot drift out of sync across workflow files. The `test` job building
|
||||
# green at this value IS the verification that the declared MSRV is real.
|
||||
# clippy deliberately does NOT use it — it runs on `stable` (lint debt is
|
||||
# caught against current Rust).
|
||||
resolve-msrv:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
v: ${{ steps.msrv.outputs.v }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- id: msrv
|
||||
shell: bash
|
||||
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# CI owns the changelog: main may never carry a released version without a
|
||||
# CHANGELOG.md entry for it. The release orchestrator dates an existing
|
||||
# `## [X.Y.Z]` heading — it does NOT invent one — so a version bump that
|
||||
# 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@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
|
||||
- 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 +stable clippy --all-targets -- -D warnings
|
||||
|
||||
test:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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 test --tests
|
||||
# `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
|
||||
|
||||
# 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: release.sh's CI gate refuses while any run for the
|
||||
# commit is still in progress, so a never-scheduled job blocks releases
|
||||
# silently -- see the note on real-media in qa.yml.
|
||||
needs: resolve-msrv
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@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: release.sh's CI gate refuses while any run for the
|
||||
# commit is still in progress, so a never-scheduled job blocks releases
|
||||
# silently -- see the note on real-media in qa.yml.
|
||||
needs: resolve-msrv
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@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
|
||||
@@ -0,0 +1,17 @@
|
||||
name: comment-guard
|
||||
|
||||
# Caps explanatory prose by COMMENT INTENT (not visibility):
|
||||
# //! / /*! module docs — unbounded (module-scoped, can't hide per-item text)
|
||||
# /// / /** */ item docs — 8 prose lines (fenced ``` examples excluded)
|
||||
# // / /* */ asides — 3 lines (single-blank-separated runs count as one)
|
||||
# Overflow belongs in the module //! or docs/<topic>.md with a one-line // pointer.
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
comment-guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Run comment-guard
|
||||
run: python3 ci/comment-guard.py
|
||||
@@ -0,0 +1,41 @@
|
||||
name: identity-guard
|
||||
# Prevention: no commit may carry an unrelated-business or non-canonical author/
|
||||
# committer email. dev@example.com (a dead corp address) once slipped onto the
|
||||
# release cascade + a batch of local commits; this fails the build if it recurs.
|
||||
on:
|
||||
push:
|
||||
branches: [dev, qa, main]
|
||||
pull_request:
|
||||
jobs:
|
||||
identity:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Reject banned author/committer emails
|
||||
env:
|
||||
BEFORE: ${{ github.event.before }}
|
||||
AFTER: ${{ github.sha }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
PR_BASE: ${{ github.event.pull_request.base.sha }}
|
||||
PR_HEAD: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Unrelated business domains + a personal address normalized to the
|
||||
# GitHub noreply. Real external contributors are unaffected.
|
||||
BANNED='@example-a\.invalid|@example-b\.invalid|owner@example\.invalid'
|
||||
if [ "$EVENT" = "pull_request" ]; then
|
||||
range="$PR_BASE..$PR_HEAD"
|
||||
elif [ -n "${BEFORE:-}" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$BEFORE" 2>/dev/null; then
|
||||
range="$BEFORE..$AFTER"
|
||||
else
|
||||
range="$AFTER~1..$AFTER"
|
||||
fi
|
||||
bad=$(git log --format='%an <%ae>%n%cn <%ce>' "$range" 2>/dev/null | sort -u | grep -iE "$BANNED" || true)
|
||||
if [ -n "$bad" ]; then
|
||||
echo "::error::commit(s) carry a banned author/committer identity:"; echo "$bad" | sed 's/^/ /'
|
||||
echo "::error::Commit as your GitHub noreply (1085847+MattJackson@users.noreply.github.com). Fix: git config user.email <noreply>; git commit --amend --reset-author."
|
||||
exit 1
|
||||
fi
|
||||
echo "identity-guard: clean"
|
||||
@@ -0,0 +1,35 @@
|
||||
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.
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
leak-guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Compute commit range
|
||||
id: range
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
base="${{ github.event.pull_request.base.sha }}"
|
||||
head="${{ github.event.pull_request.head.sha }}"
|
||||
echo "range=$base..$head" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
before="${{ github.event.before }}"
|
||||
after="${{ github.sha }}"
|
||||
# New branch / first push: github.event.before is all-zeros.
|
||||
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "range=$after" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "range=$before..$after" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
- name: Run leak-guard
|
||||
run: bash ci/leak-guard.sh "${{ steps.range.outputs.range }}"
|
||||
@@ -0,0 +1,139 @@
|
||||
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. Those run on a
|
||||
# self-hosted runner (see the media job at the end) and are the one leg that
|
||||
# stays on hardware.
|
||||
on:
|
||||
push:
|
||||
branches: [qa]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# Single source of truth for the MSRV — see ci.yml. Jobs that build/test AT
|
||||
# the MSRV read `needs.resolve-msrv.outputs.v` from Cargo.toml's rust-version;
|
||||
# clippy (cross-lint) runs on stable instead.
|
||||
resolve-msrv:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
v: ${{ steps.msrv.outputs.v }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- id: msrv
|
||||
shell: bash
|
||||
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── Name the candidate ────────────────────────────────────────────────
|
||||
#
|
||||
# Every push to `qa` is a release candidate, so every push gets a tag:
|
||||
# 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
|
||||
|
||||
+235
@@ -1,5 +1,240 @@
|
||||
# Changelog
|
||||
|
||||
## [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.
|
||||
|
||||
## [1.5.2] — 2026-07-22
|
||||
|
||||
### Changed
|
||||
|
||||
- The DVD read-unlocker (bus-auth) is renamed `CSS` → `DVD`: it reports the
|
||||
medium it unlocks, not whether a title-key crack ran. The unlocker report now
|
||||
reads `DVD: yes` on any DVD.
|
||||
|
||||
## [1.4.1] — 2026-07-14
|
||||
|
||||
Version sync with the workspace; inherits libfreemkv 1.4.1.
|
||||
|
||||
## [1.4.0] — 2026-07-13
|
||||
|
||||
Version sync with the workspace; inherits libfreemkv 1.4.0.
|
||||
|
||||
## [1.3.2] — 2026-07-10
|
||||
|
||||
Version sync with the workspace; inherits libfreemkv 1.3.2.
|
||||
|
||||
## [1.3.1] — 2026-07-10
|
||||
|
||||
### Licensing
|
||||
|
||||
- **Relicensed to the MIT License, from 1.3.1 onwards** (releases up to and
|
||||
including 1.3.0 remain under AGPL-3.0).
|
||||
|
||||
Version sync with the workspace; inherits libfreemkv 1.3.1.
|
||||
|
||||
## [1.3.0] — 2026-07-08
|
||||
|
||||
### Changed
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ This Code of Conduct applies within all community spaces, and also applies when
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at owner@example.invalid. All complaints will be reviewed and investigated promptly and fairly.
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported privately to the project maintainer via GitHub at https://github.com/MattJackson. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||
|
||||
|
||||
@@ -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
+454
@@ -0,0 +1,454 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
"cpubits",
|
||||
"cpufeatures 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
|
||||
dependencies = [
|
||||
"crypto-common 0.2.2",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "cpubits"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.7.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "freemkv-engine"
|
||||
version = "1.7.0"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "freemkv-i18n"
|
||||
version = "1.7.0"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "freemkv-keysources"
|
||||
version = "1.7.0"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "libfreemkv"
|
||||
version = "1.7.0"
|
||||
+11
-8
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.3.0"
|
||||
version = "1.7.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
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"
|
||||
# NEVER crates.io: this crate carries drive firmware. Consumed only by git tag.
|
||||
@@ -18,11 +18,14 @@ 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"
|
||||
sha2 = "0.11"
|
||||
aes = "0.9"
|
||||
rand = "0.10"
|
||||
|
||||
[lints.clippy]
|
||||
uninlined_format_args = "allow"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Matthew Jackson & Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -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.
|
||||
@@ -14,9 +18,17 @@ clients of libfreemkv are oblivious to unlockers entirely (as they are to the
|
||||
SCSI layer).
|
||||
|
||||
```rust
|
||||
use freemkv_unlock::UnlockError;
|
||||
|
||||
// Drive-prep: try each unlocker's feature unlock until one claims the drive.
|
||||
// `NotApplicable` means "not this unlocker's drive" — move on; a transport
|
||||
// error means a dead bus — abort. `unlock_bus` follows the same contract for
|
||||
// removing per-disc bus encryption.
|
||||
for u in freemkv_unlock::all_unlockers() {
|
||||
if u.matches(&ctx) {
|
||||
return u.unlock(&mut scsi, &ctx);
|
||||
match u.unlock_features(&mut scsi, &ctx) {
|
||||
Ok(unlocked) => return Ok(unlocked),
|
||||
Err(UnlockError::NotApplicable) => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -24,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: AGPL-3.0-only.
|
||||
## 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.
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""comment-guard: cap explanatory prose by COMMENT INTENT, not visibility.
|
||||
|
||||
Three tiers, keyed on the syntax the author reached for:
|
||||
|
||||
* `//!` / `/*!` — a module (or crate) design doc. UNBOUNDED. There is one per
|
||||
file, at the top, documenting the module as a whole, so it can't be used to
|
||||
smuggle per-item rationale the way item docs can. Long correctness / design
|
||||
essays (e.g. a memory-ordering proof) legitimately live here.
|
||||
* `///` / `/** */` — item documentation. Up to DOC_CAP *prose* lines. Fenced
|
||||
``` code blocks do NOT count, so real examples aren't punished. This holds
|
||||
whether or not the item is `pub`: a `pub(crate)` fn deserves a real doc
|
||||
paragraph just like a `pub` one. Capping `///` here (rather than exempting
|
||||
it, as the old guard did — issue #17) is what stops rationale being hidden
|
||||
behind a doc sigil, while still leaving room for a genuine paragraph.
|
||||
* `//` / `/* */` — a quick internal aside. Up to INLINE_CAP lines. This is the
|
||||
shape that sprawls into walls, so it's kept tight: stanzas separated by a
|
||||
single blank line count CUMULATIVELY, so one essay can't be split into runs.
|
||||
|
||||
Overflow belongs in the module `//!`, or in docs/<topic>.md with a one-line `//`
|
||||
pointer — both show up in review.
|
||||
|
||||
Scans src/ and tests/ under the given root (default cwd). `--selftest` runs
|
||||
built-in cases. Exit non-zero if any block exceeds its cap.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
INLINE_CAP = 3
|
||||
DOC_CAP = 8
|
||||
|
||||
_RAW_OPEN = re.compile(r'r(#*)"') # raw string opener: r"…", r#"…"#, br##"…"## …
|
||||
|
||||
|
||||
def _strip_line_comment(s):
|
||||
"""Return (kind, body) for a whole-line comment, else (None, None).
|
||||
|
||||
kind is 'doc' for `///`/`//!`, 'plain' for other `//`. A trailing comment on
|
||||
a code line is not a whole-line comment and is ignored (single-line, cheap).
|
||||
"""
|
||||
if s.startswith("//!"):
|
||||
return "doc", s[3:]
|
||||
if s.startswith("///") and not s.startswith("////"):
|
||||
return "doc", s[3:]
|
||||
if s.startswith("//"):
|
||||
return "plain", s[2:]
|
||||
return None, None
|
||||
|
||||
|
||||
def _fence_toggle(body):
|
||||
"""True if this doc body line opens/closes a ``` fenced code block."""
|
||||
return body.strip().startswith("```")
|
||||
|
||||
|
||||
def _classify(lines):
|
||||
"""Yield per-line dicts: comment kind ('doc'/'plain'/None), doc-ness of
|
||||
block comments, and code info. Block comments are tracked across lines.
|
||||
"""
|
||||
info = []
|
||||
in_block = False
|
||||
block_is_doc = False
|
||||
raw_hashes = None # None = not in a raw string; int N = open, needs '"' + N*'#'
|
||||
for raw in lines:
|
||||
s = raw.lstrip()
|
||||
rec = {"raw": raw, "s": s, "comment": None, "is_doc": False, "code": False}
|
||||
# Inside a raw string literal (e.g. an embedded HTML/CSS/JS asset): the
|
||||
# bytes are string data, not Rust comments, so the guard is blind to them
|
||||
# until the string closes. Prevents `/* */` or `//` lines in an embedded
|
||||
# page from being counted as comment blocks.
|
||||
if raw_hashes is not None:
|
||||
if ('"' + "#" * raw_hashes) in raw:
|
||||
raw_hashes = None
|
||||
info.append(rec)
|
||||
continue
|
||||
if in_block:
|
||||
rec["comment"] = "doc" if block_is_doc else "plain"
|
||||
rec["is_doc"] = block_is_doc
|
||||
if "*/" in raw:
|
||||
in_block = False
|
||||
info.append(rec)
|
||||
continue
|
||||
# A block comment only starts a comment-line if it's the first token.
|
||||
if s.startswith("/*"):
|
||||
rec["is_doc"] = s.startswith("/**") or s.startswith("/*!")
|
||||
rec["comment"] = "doc" if rec["is_doc"] else "plain"
|
||||
if "*/" not in s[2:]:
|
||||
in_block = True
|
||||
block_is_doc = rec["is_doc"]
|
||||
info.append(rec)
|
||||
continue
|
||||
kind, _ = _strip_line_comment(s)
|
||||
if kind is not None:
|
||||
rec["comment"] = kind
|
||||
rec["is_doc"] = kind == "doc"
|
||||
info.append(rec)
|
||||
continue
|
||||
# Code (or blank). Blank = empty after strip.
|
||||
rec["code"] = s != ""
|
||||
# A code line may OPEN a raw string that spans following lines. Detected
|
||||
# only on code (comments were handled above), so `// see r"x"` is safe.
|
||||
m = _RAW_OPEN.search(raw)
|
||||
if m and ('"' + "#" * len(m.group(1))) not in raw[m.end():]:
|
||||
raw_hashes = len(m.group(1))
|
||||
info.append(rec)
|
||||
return info
|
||||
|
||||
|
||||
def violations(path):
|
||||
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
info = _classify(lines)
|
||||
n = len(info)
|
||||
out = []
|
||||
|
||||
i = 0
|
||||
while i < n:
|
||||
rec = info[i]
|
||||
if rec["comment"] is None:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
start = i
|
||||
if rec["is_doc"]:
|
||||
# Doc run: contiguous doc lines. `//!` / `/*!` module docs are
|
||||
# UNBOUNDED; any other doc run caps PROSE (fenced ``` excluded) at
|
||||
# DOC_CAP, regardless of the documented item's visibility.
|
||||
j = i
|
||||
prose = 0
|
||||
in_fence = False
|
||||
module_doc = False
|
||||
while j < n and info[j]["comment"] == "doc":
|
||||
s = info[j]["s"]
|
||||
if s.startswith("//!") or s.startswith("/*!"):
|
||||
module_doc = True
|
||||
body = (
|
||||
s[3:] if (s.startswith("///") or s.startswith("//!")) else s
|
||||
)
|
||||
if _fence_toggle(body):
|
||||
in_fence = not in_fence
|
||||
elif not in_fence and body.strip() != "":
|
||||
prose += 1
|
||||
j += 1
|
||||
if not module_doc and prose > DOC_CAP:
|
||||
out.append((start + 1, prose, DOC_CAP, "doc prose"))
|
||||
i = j
|
||||
continue
|
||||
|
||||
# Internal (plain) run, with single-blank bridging (cumulative).
|
||||
j = i
|
||||
count = 0
|
||||
while j < n:
|
||||
r = info[j]
|
||||
if r["comment"] == "plain":
|
||||
count += 1
|
||||
j += 1
|
||||
continue
|
||||
if r["comment"] == "doc":
|
||||
break
|
||||
if r["comment"] is None and not r["code"]:
|
||||
# blank: bridge only a SINGLE blank, else stop
|
||||
if j + 1 < n and info[j + 1]["comment"] == "plain":
|
||||
j += 1
|
||||
continue
|
||||
break
|
||||
break # code
|
||||
if count > INLINE_CAP:
|
||||
out.append((start + 1, count, INLINE_CAP, "inline comment"))
|
||||
i = j
|
||||
return out
|
||||
|
||||
|
||||
def main(argv):
|
||||
if "--selftest" in argv:
|
||||
return _selftest()
|
||||
root = Path(argv[1]) if len(argv) > 1 else Path.cwd()
|
||||
files = sorted(p for d in ("src", "tests") for p in (root / d).rglob("*.rs"))
|
||||
total = 0
|
||||
for f in files:
|
||||
for start, length, cap, kind in violations(f):
|
||||
rel = f.relative_to(root)
|
||||
print(f"{rel}:{start}: {kind} block is {length} lines (max {cap})")
|
||||
total += 1
|
||||
if total:
|
||||
print(f"\ncomment-guard: {total} comment block(s) exceed their cap.")
|
||||
print(
|
||||
f"Inline // asides cap at {INLINE_CAP}; /// item-doc prose caps at "
|
||||
f"{DOC_CAP} (fenced examples excluded); //! module docs are unbounded. "
|
||||
"Trim, move the rationale into the module //!, or docs/<topic>.md "
|
||||
"with a // pointer."
|
||||
)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _selftest():
|
||||
import tempfile
|
||||
|
||||
cases = [
|
||||
# (rust source, expected number of violations)
|
||||
# short /// doc -> ok
|
||||
("/// short doc\npub fn a() {}\n", 0),
|
||||
# /// on a PRIVATE item now gets the doc cap (8), so 4 lines is fine
|
||||
("/// l1\n/// l2\n/// l3\n/// l4\nfn priv_a() {}\n", 0),
|
||||
# ...but /// is NOT unlimited: 9 prose lines on a private item fails
|
||||
("".join(f"/// l{k}\n" for k in range(9)) + "fn priv_b() {}\n", 1),
|
||||
# 8 prose /// on a pub item -> ok (boundary)
|
||||
("".join(f"/// l{k}\n" for k in range(8)) + "pub fn c() {}\n", 0),
|
||||
# 9 prose /// on a pub item -> violation
|
||||
("".join(f"/// l{k}\n" for k in range(9)) + "pub fn d() {}\n", 1),
|
||||
# /// with a long fenced example -> example excluded, ok
|
||||
(
|
||||
"/// summary\n/// # Examples\n/// ```\n"
|
||||
+ "".join("/// code\n" for _ in range(12))
|
||||
+ "/// ```\npub fn e() {}\n",
|
||||
0,
|
||||
),
|
||||
# pub(crate) gets the SAME doc cap as pub (no pub/private split anymore)
|
||||
("".join(f"/// l{k}\n" for k in range(6)) + "pub(crate) fn f() {}\n", 0),
|
||||
# bare inline 4 lines -> violation
|
||||
("// a\n// b\n// c\n// d\nlet x = 1;\n", 1),
|
||||
# blank-split dodge: 3 + blank + 3 inline -> cumulative -> violation
|
||||
("// a\n// b\n// c\n\n// d\n// e\n// f\nfn z() {}\n", 1),
|
||||
# plain /* */ block, 5 lines -> inline cap -> violation
|
||||
("/* one\n two\n three\n four\n five */\nfn q() {}\n", 1),
|
||||
# comment-like lines INSIDE a raw string are string data, not comments
|
||||
(
|
||||
'const H: &str = r##"<style>\n'
|
||||
+ "".join("/* css note */\n" for _ in range(8))
|
||||
+ '</style>"##;\nfn h() {}\n',
|
||||
0,
|
||||
),
|
||||
# //! module doc may run long -> EXEMPT (no cap)
|
||||
("".join(f"//! l{k}\n" for k in range(40)) + "\npub fn m() {}\n", 0),
|
||||
# /*! block module doc may run long -> EXEMPT
|
||||
("/*! l0\n" + "".join(f" l{k}\n" for k in range(30)) + "*/\nfn n() {}\n", 0),
|
||||
]
|
||||
ok = True
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
for idx, (src, want) in enumerate(cases):
|
||||
p = Path(d) / f"case{idx}.rs"
|
||||
p.write_text(src)
|
||||
got = len(violations(p))
|
||||
flag = "ok" if got == want else "FAIL"
|
||||
if got != want:
|
||||
ok = False
|
||||
print(f" selftest {idx}: want {want} got {got} [{flag}]")
|
||||
print("selftest PASSED" if ok else "selftest FAILED")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/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"]
|
||||
@@ -0,0 +1,184 @@
|
||||
# AACS bus authentication handshake
|
||||
|
||||
## Flow
|
||||
|
||||
1. Invalidate AGIDs → allocate fresh AGID
|
||||
2. Send host certificate + nonce
|
||||
3. Receive drive certificate + nonce
|
||||
4. Receive drive key point + signature, verify
|
||||
5. Sign host key point, send
|
||||
6. ECDH: host_priv × drive_key_point → bus key (low 128 bits of x)
|
||||
7. Read VID or Read Data Keys (encrypted with bus key)
|
||||
|
||||
## Why `scsi_read`/`scsi_write` check status at the seam
|
||||
|
||||
The contract this crate is built on (`ScsiTransport::execute`) returns `Ok`
|
||||
on a SCSI sense and `Err` ONLY on a transport-layer fault. These two helpers
|
||||
used to propagate the `?` and nothing else — so a CHECK CONDITION (`Ok`,
|
||||
non-zero `status`) handed the caller back its own ZERO-FILLED buffer and
|
||||
every REPORT/SEND KEY step consumed zeros as valid drive data: a drive
|
||||
certificate of zeros, a key point of zeros, a Volume ID of zeros, all
|
||||
reported as success. It has not fired in production only because the sole
|
||||
transport implementor violates the contract in the opposite direction (it
|
||||
returns `Err` for any non-zero status). Plugging in a CONFORMING transport
|
||||
would have turned the whole handshake into a zero-buffer success, so the
|
||||
status and the transferred length are checked here, at the seam.
|
||||
|
||||
## Why `scsi_write` shares this reasoning
|
||||
|
||||
Same contract reasoning as `scsi_read`: a drive that REFUSES the host
|
||||
certificate answers with `Ok` + CHECK CONDITION, and treating that as a
|
||||
successful send carried the handshake on against a drive that never
|
||||
accepted it.
|
||||
|
||||
## `ec_mul` design notes
|
||||
|
||||
**Constant-time tradeoff:** this branches on `scalar.bit(0)` and clones
|
||||
BigUints per iteration, so its timing is data-dependent on the secret scalar
|
||||
(the long-term host private key in `ecdsa_sign`, the ephemeral key in ECDH).
|
||||
This is deliberate: the handshake runs once per disc against a local optical
|
||||
drive, so throughput and the narrow local-timing surface do not justify
|
||||
pulling in a vetted constant-time backend. Revisit if this ever signs in a
|
||||
remote/shared context.
|
||||
|
||||
**Cofactor:** both AACS curves used here have cofactor 1, so a point that
|
||||
lies on the curve is automatically in the prime-order subgroup — no
|
||||
small-subgroup defense / `n·P == O` check is required for the inputs this is
|
||||
called with.
|
||||
|
||||
## `verify_cert_p256` — provisional AACS 2.0 cert layout
|
||||
|
||||
Verifies an AACS 2.0 drive certificate (type 0x11) against an AACS 2.0 LA
|
||||
key. PROVISIONAL LAYOUT — the byte offsets below are NOT yet confirmed
|
||||
against a genuine AACS 2.0 cert, and no such cert exists on this machine to
|
||||
pin them (an exhaustive hunt found only 2.0 *content* certs, a different
|
||||
format; a real host/drive AKE cert needs a live USB/SCSI capture). What IS
|
||||
confirmed: first, the P-256/SHA-256 ECDSA primitive and curve constants are
|
||||
correct (`ecdsa_verify_p256_verifies_a_genuine_aacs2_content_cert` verifies a
|
||||
real 2.0 content cert under the published CC key); second, the 132-byte
|
||||
4-byte-header framing MATCHES the step-6 drive-key message (x[4..36] /
|
||||
y[36..68] / r[68..100] / s[100..132]), the natural 20→32-byte scaling of the
|
||||
established AACS 1.0 84-byte key-message framing.
|
||||
|
||||
What is NOT confirmed: that a *certificate* shares that framing. No known
|
||||
implementation verifies 2.0 certs against a confirmed layout (the published
|
||||
spec draft leaves that format ambiguous), and the AACS 2.0 draft architecture
|
||||
lists the 2.0 host cert (type 0x12) with a 6-byte Host ID + a NEW 4-byte
|
||||
paired-Device-Key-Set field before the 64-byte public key — implying a
|
||||
~140–144-byte cert with the pubkey near offset 12–16 and a signed range of
|
||||
~76–80 bytes, NOT 68. The drive cert (type 0x11) has no paired-DKS field
|
||||
(~140 bytes, pubkey ~[12..76], signed ~first 76). So the offsets HERE
|
||||
(pubkey [4..68], signed [..68]) are a best-effort self-consistent
|
||||
placeholder, chosen to match the confirmed key message; they will almost
|
||||
certainly move when a real cert lands (see the `#[ignore]` real-cert slot).
|
||||
This matters for USEFULNESS, not SAFETY: a wrong signed range makes
|
||||
verification FAIL, and since the caller treats a verify failure as FATAL,
|
||||
the path fails CLOSED (rejects) rather than trusting anything — never the
|
||||
reverse.
|
||||
|
||||
The `>= 132` guard keeps the `cert[100..132]` slice safe while letting a
|
||||
full-length cert reach verification (the old `>= 138` guard rejected every
|
||||
132-byte input unconditionally).
|
||||
|
||||
The LA anchor is a PARAMETER, not the compiled-in constant, so a unit test
|
||||
can drive the whole chain under a test LA keypair (sign a synthetic drive
|
||||
cert, prove it is accepted / a bad-sig cert is rejected) without the real
|
||||
anchor's private half, which no one has. Production always threads
|
||||
`AACS2_LA_PUB_X/Y` from `aacs2_authenticate_p256`.
|
||||
|
||||
## Why an unknown drive-cert type must be rejected
|
||||
|
||||
This used to be an `if` / `else if` with NO `else`, so an unknown type
|
||||
(e.g. 0x02) fell straight through: `is_aacs20` stayed false, so the step-6
|
||||
key-signature check still ran — against `cert_pub_key(drive_cert)`, the
|
||||
drive's OWN key lifted out of a cert that was never checked against the LA.
|
||||
A rogue USB bridge could set byte 0 to 0x02, present its own keypair, sign
|
||||
`host_nonce || drive_key_point`, and the handshake would return Ok with an
|
||||
attacker-chosen bus key. The chain of trust must not appear to have run
|
||||
when it did not.
|
||||
|
||||
## AACS 2.0 chain-of-trust gate (`aacs2_authenticate_p256_with_agid`)
|
||||
|
||||
The SAME shape the AACS 1.0 sibling (`aacs_authenticate_with_agid`)
|
||||
enforces, now MANDATORY because this path is live. Two distinct rejections,
|
||||
both BEFORE any drive key is trusted:
|
||||
|
||||
**(a) Cert TYPE gate.** An AACS 2.0 drive cert is type 0x11. Anything else
|
||||
is unexpected on the native P-256 AKE and is REJECTED here, never allowed to
|
||||
fall through. The earlier `if type==0x11 && !verify` form left NO branch for
|
||||
a non-0x11 type: a rogue bridge could send a type-0x10 (or any) cert, skip
|
||||
verification entirely, and still reach the step-6 key check against
|
||||
`cert_pub_key_p256(drive_cert)` — its OWN key, lifted from a cert the LA
|
||||
never signed — winning an attacker-chosen bus key. The chain of trust must
|
||||
not merely LOOK like it ran.
|
||||
|
||||
**(b) Cert VERIFY is FATAL.** A type-0x11 cert whose P-256/SHA-256 signature
|
||||
does not verify under the real 2.0 LA anchor (`AACS2_LA_PUB`, on-curve — see
|
||||
`la_anchor_keys_are_on_curve`) is REJECTED, not logged-and-continued.
|
||||
`ecdsa_verify_p256` range/curve-checks every point (`point_on_curve`), so a
|
||||
forged or off-curve cert key cannot slip past. The previous "non-fatal for
|
||||
backward compat" behaviour was the exact hole the SECURITY note demanded be
|
||||
closed before wiring.
|
||||
|
||||
## `run_cert_handshake` — cert-agnostic AKE selection
|
||||
|
||||
The AACS 1.0 host cert is always present and is tried FIRST: an AACS 2.0
|
||||
drive accepts a 1.0 host cert for backward compatibility, which is enough to
|
||||
learn the Volume ID. If the drive REFUSES the 1.0 cert (a plain cert-level
|
||||
rejection — NOT a dead or wedged bus) and THIS host cert also carries AACS
|
||||
2.0 (P-256) credentials, fall back to the native P-256 AKE.
|
||||
|
||||
This does NOT branch on "do we possess 2.0 certs?" as a feature switch: it
|
||||
consumes whatever the supplied `HostCert` carries, exactly as the 1.0 path
|
||||
consumes `hc.certificate`. A host cert without v2 credentials simply has
|
||||
nothing more to present and keeps its 1.0 error; a host cert WITH them
|
||||
drives the P-256 AKE the moment the drive asks for it. When a real 2.0 host
|
||||
cert is supplied at runtime, the P-256 handshake runs — no code change, no
|
||||
possession gate.
|
||||
|
||||
A transport fault on the 1.0 attempt short-circuits the fallback:
|
||||
re-running the P-256 AKE against a bus that is gone re-issues AGID
|
||||
invalidation + SEND/REPORT KEY for nothing and buries the real (replug)
|
||||
cause. The `Err` arm below still classifies it as `Transport`.
|
||||
|
||||
## AACS 2.0 (P-256) host-certificate handshake — live-path test plumbing
|
||||
|
||||
No genuine 2.0 host cert exists for us to sign with (a side agent is hunting
|
||||
one; see the `#[ignore]` slot at the end). So the tests below prove the
|
||||
PLUMBING under a self-generated test LA keypair: a 132-byte drive cert
|
||||
signed by the test LA is threaded through the SAME code the production
|
||||
anchor uses, only the anchor differs (a parameter, not a recompile). The
|
||||
layout mirrored is the code's self-consistent 4-byte-header framing —
|
||||
header(4)+pub_x(32)+pub_y(32)+sig_r(32)+sig_s(32)=132, LA signs SHA-256 over
|
||||
cert[..68] — identical to the step-6 drive-key message framing and the
|
||||
156-byte SEND KEY host-cert payload (4+20 nonce+132 cert).
|
||||
|
||||
## `ecdsa_verify_p256_verifies_a_genuine_aacs2_content_cert` — why it matters
|
||||
|
||||
SANITY: `ecdsa_verify_p256` + the P-256 curve constants + SHA-256 are correct
|
||||
against GENUINE AACS 2.0 material. No real 2.0 host/drive AKE cert exists
|
||||
anywhere on this machine (an exhaustive hunt found none — it needs a live
|
||||
USB/SCSI AKE capture), but a real AACS 2.0 CONTENT certificate does
|
||||
(`research/aacs/iso-aacs/CivilWar/Content000.cer`, type 0x10, 232 bytes). It
|
||||
is signed by the AACS 2.0 *Content Cert* key (P-256/SHA-256, 64-byte
|
||||
signature over the first `signed_len` bytes) — a DIFFERENT key for a
|
||||
DIFFERENT purpose than the AKE LA anchor, but it exercises the EXACT same
|
||||
`ecdsa_verify_p256` primitive `verify_cert_p256` relies on; only the pubkey
|
||||
and the offsets differ. Proving it verifies here pins that the P-256
|
||||
ECDSA/SHA-256 math and the `point_on_curve` gate are right, so when a
|
||||
genuine 2.0 host/drive cert (and its correct signed range) lands, only the
|
||||
offsets — not the crypto — remain to be fixed.
|
||||
|
||||
The CC pubkey is the published AACS 2.0 Content Certificate public key
|
||||
(P-256). Corrupting any `P256_*` constant, or removing SHA-256, makes this
|
||||
real-vector verification go red.
|
||||
|
||||
## Cert support
|
||||
|
||||
- AACS 1.0: custom 160-bit curve, SHA-1, 20-byte keys
|
||||
- AACS 2.0: an AACS 2.0 drive accepts an AACS 1.0 host cert for backward
|
||||
compatibility (that yields the Volume ID), and the native P-256/SHA-256
|
||||
AACS 2.0 AKE is the fallback when the drive refuses the 1.0 cert AND the
|
||||
supplied host cert carries AACS 2.0 (P-256) credentials. Both paths are
|
||||
LIVE and share the same chain-of-trust hardening; see
|
||||
`run_cert_handshake` for the cert-agnostic dispatch.
|
||||
@@ -0,0 +1,16 @@
|
||||
# `DriveProfile` field visibility and `allow(dead_code)`
|
||||
|
||||
Only `identity` and `signature` are public — they are what the catalog is
|
||||
public FOR ("is this drive supported?", and the emulator's impersonation).
|
||||
Everything below them is unlock MECHANISM: the MediaTek firmware image and
|
||||
the per-drive vendor CDB templates. `Cargo.toml` says this crate is never
|
||||
published because it carries drive firmware; re-exporting `DriveProfile`
|
||||
with a `pub firmware: Vec<u8>` handed that firmware, and every vendor CDB,
|
||||
to any downstream crate that called `ld::profile()`. They are `pub(crate)`
|
||||
so the mechanism stays inside the unlocker.
|
||||
|
||||
`allow(dead_code)`: this is the on-disk catalog schema, and several
|
||||
captured CDB templates have no in-crate consumer yet. They were `pub`
|
||||
before, so the compiler saw the (external) use; making them `pub(crate)`
|
||||
is what exposes them as unread. Deleting them would silently drop fields
|
||||
from the format the profile-extraction pipeline emits.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Module visibility rationale (`src/lib.rs`)
|
||||
|
||||
Why each unlocker module is `pub mod` or `mod`, and what's actually public
|
||||
about it. The consumer never reaches an unlocker impl directly — only through
|
||||
[`all_unlockers`] — so "public" here means "exposes something other than the
|
||||
unlocker impl itself."
|
||||
|
||||
## `ld`
|
||||
|
||||
Public ONLY for its drive-profile catalog (`ld::profiles` / the `Profiles`
|
||||
object) and, under the `emulation` feature, the unlock-handshake wire format
|
||||
the bdemu test-emulator needs. The unlocker impl itself (`Mt1959Unlocker`) is
|
||||
`pub(crate)` — clients still reach unlockers only through `all_unlockers`.
|
||||
`aacs` and `css` carry no such public catalog, so they stay fully private.
|
||||
|
||||
## `renesas`
|
||||
|
||||
Public for its `is_renesas` drive-probe, which reports a dead bus as
|
||||
`Err(UnlockError::Transport)` rather than "not a Renesas drive" — a
|
||||
distinction the consumer needs before it can trust a negative probe result.
|
||||
The unlocker impl (`Renesas`) is `pub(crate)` — reached only through
|
||||
`all_unlockers`.
|
||||
|
||||
## `freemkv`
|
||||
|
||||
Carries no public catalog (the firmware self-identifies rather than matching
|
||||
a bundled profile) and no emulation wire format yet, so it stays fully
|
||||
private — the unlocker impl (`FreemkvUnlocker`) is reached only through
|
||||
`all_unlockers`.
|
||||
@@ -0,0 +1,9 @@
|
||||
# `scsi::mock` — crate-wide mock transport
|
||||
|
||||
The crate-wide mock transport. Before this existed, EVERY mock in the crate
|
||||
returned `Ok` with status 0 and a full `bytes_transferred` — so no test could
|
||||
ever exercise the three outcomes the contract actually distinguishes, and a
|
||||
whole class of transport-fault misclassification bugs sat uncaught (a dead bus
|
||||
read as "this unlocker doesn't apply", a zero-filled buffer read as valid
|
||||
drive data). This fixture can express all of them, and is the red-before-green
|
||||
vehicle for the transport-contract fixes across ld / aacs / css / renesas.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Why `Unlocked`'s `Debug` impl is hand-written
|
||||
|
||||
`Unlocked` is the public carrier that leaves this crate, and both `bus_key`
|
||||
(the AACS bus key) and `vid` (the Volume ID that feeds VUK derivation) are key
|
||||
material that must never reach a log or a test-failure message in plaintext.
|
||||
The `AacsAuth` / `CertHandshake` siblings already redact; a derived `Debug`
|
||||
here would print the very bytes they hide.
|
||||
|
||||
Presence is still observable (`Some`/`None`) so a log can say WHETHER a key
|
||||
was obtained without revealing WHAT it is.
|
||||
@@ -25,6 +25,15 @@ pub enum Error {
|
||||
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`].
|
||||
@@ -54,6 +63,7 @@ impl Error {
|
||||
Error::AacsVidRead => 7012,
|
||||
Error::HandshakeRejected => 7013,
|
||||
Error::VidUnavailable => 7014,
|
||||
Error::ShortTransfer { .. } => 7015,
|
||||
Error::Scsi { .. } => 7099,
|
||||
}
|
||||
}
|
||||
@@ -86,3 +96,144 @@ impl From<crate::scsi::ScsiError> for Error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every variant has its own stable numeric code. Constructing all 16
|
||||
/// arms also exercises the `#[allow(dead_code)]` variants that the
|
||||
/// wired handshake path never builds.
|
||||
#[test]
|
||||
fn code_is_stable_per_variant() {
|
||||
assert_eq!(Error::AacsAgidAlloc.code(), 7001);
|
||||
assert_eq!(Error::AacsCertRead.code(), 7002);
|
||||
assert_eq!(Error::AacsCertRejected.code(), 7003);
|
||||
assert_eq!(Error::AacsCertShort.code(), 7004);
|
||||
assert_eq!(Error::AacsCertVerify.code(), 7005);
|
||||
assert_eq!(Error::AacsDataKey.code(), 7006);
|
||||
assert_eq!(Error::AacsKeyRead.code(), 7007);
|
||||
assert_eq!(Error::AacsKeyRejected.code(), 7008);
|
||||
assert_eq!(Error::AacsKeyVerify.code(), 7009);
|
||||
assert_eq!(Error::AacsNoKeys.code(), 7010);
|
||||
assert_eq!(Error::AacsVidMac.code(), 7011);
|
||||
assert_eq!(Error::AacsVidRead.code(), 7012);
|
||||
assert_eq!(Error::HandshakeRejected.code(), 7013);
|
||||
assert_eq!(Error::VidUnavailable.code(), 7014);
|
||||
assert_eq!(
|
||||
Error::ShortTransfer {
|
||||
opcode: 0xA4,
|
||||
expected: 16,
|
||||
got: 4,
|
||||
}
|
||||
.code(),
|
||||
7015
|
||||
);
|
||||
assert_eq!(
|
||||
Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0x02,
|
||||
sense: None,
|
||||
}
|
||||
.code(),
|
||||
7099
|
||||
);
|
||||
}
|
||||
|
||||
/// `scsi_sense` returns the parsed sense for a `Scsi` error carrying one,
|
||||
/// `None` when the `Scsi` error has none, and `None` for every non-`Scsi`
|
||||
/// variant.
|
||||
#[test]
|
||||
fn scsi_sense_returns_the_parsed_sense_only_for_scsi_variant() {
|
||||
let sense = ScsiSense {
|
||||
sense_key: 0x05,
|
||||
asc: 0x20,
|
||||
ascq: 0x00,
|
||||
};
|
||||
let with_sense = Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0x02,
|
||||
sense: Some(sense),
|
||||
};
|
||||
let sc = with_sense.scsi_sense().unwrap();
|
||||
assert_eq!(sc.sense_key, sense.sense_key);
|
||||
assert_eq!(sc.asc, sense.asc);
|
||||
assert_eq!(sc.ascq, sense.ascq);
|
||||
|
||||
let without_sense = Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0xFF,
|
||||
sense: None,
|
||||
};
|
||||
assert!(without_sense.scsi_sense().is_none());
|
||||
|
||||
assert!(Error::AacsNoKeys.scsi_sense().is_none());
|
||||
}
|
||||
|
||||
/// `is_scsi_transport_failure` is true only for a `Scsi` error with the
|
||||
/// transport-failure status AND no sense; a drive CHECK CONDITION and
|
||||
/// every non-`Scsi` variant are false.
|
||||
#[test]
|
||||
fn is_scsi_transport_failure_true_and_false_paths() {
|
||||
let transport = Error::Scsi {
|
||||
opcode: 0,
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
};
|
||||
assert!(transport.is_scsi_transport_failure());
|
||||
|
||||
let check_condition = Error::Scsi {
|
||||
opcode: 0xA4,
|
||||
status: 0x02,
|
||||
sense: Some(ScsiSense {
|
||||
sense_key: 0x05,
|
||||
asc: 0x20,
|
||||
ascq: 0x00,
|
||||
}),
|
||||
};
|
||||
assert!(!check_condition.is_scsi_transport_failure());
|
||||
|
||||
assert!(!Error::AacsNoKeys.is_scsi_transport_failure());
|
||||
}
|
||||
|
||||
/// `From<ScsiError>` maps a raw transport-layer error into `Error::Scsi`,
|
||||
/// parsing the sense buffer when present and leaving it `None` otherwise.
|
||||
#[test]
|
||||
fn from_scsi_error_maps_status_and_parses_sense() {
|
||||
let e = Error::from(crate::scsi::ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
match e {
|
||||
Error::Scsi {
|
||||
opcode,
|
||||
status,
|
||||
sense,
|
||||
} => {
|
||||
assert_eq!(opcode, 0);
|
||||
assert_eq!(status, SCSI_STATUS_TRANSPORT_FAILURE);
|
||||
assert!(sense.is_none());
|
||||
}
|
||||
_ => panic!("expected Error::Scsi"),
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 32];
|
||||
buf[2] = 0x05;
|
||||
buf[12] = 0x24;
|
||||
buf[13] = 0x01;
|
||||
let e = Error::from(crate::scsi::ScsiError {
|
||||
status: 0x02,
|
||||
sense: Some(buf),
|
||||
});
|
||||
match e {
|
||||
Error::Scsi { status, sense, .. } => {
|
||||
assert_eq!(status, 0x02);
|
||||
let s = sense.expect("sense parsed");
|
||||
assert_eq!(s.sense_key, 0x05);
|
||||
assert_eq!(s.asc, 0x24);
|
||||
assert_eq!(s.ascq, 0x01);
|
||||
}
|
||||
_ => panic!("expected Error::Scsi"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1377
-297
File diff suppressed because it is too large
Load Diff
+121
-49
@@ -9,69 +9,79 @@ mod error;
|
||||
mod handshake;
|
||||
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray};
|
||||
use aes::cipher::{Array, BlockCipherDecrypt, KeyInit};
|
||||
|
||||
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>,
|
||||
}
|
||||
|
||||
impl AacsCert {
|
||||
pub fn new() -> Self {
|
||||
AacsCert
|
||||
impl AacsUnlocker {
|
||||
pub fn new(host_certs: Vec<HostCert>) -> Self {
|
||||
AacsUnlocker { host_certs }
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
/// `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> {
|
||||
if ctx.kind != DiscKind::Aacs {
|
||||
return Err(UnlockError::NotApplicable);
|
||||
) -> std::result::Result<Option<Unlocked>, UnlockError> {
|
||||
if ctx.kind != DiscKind::Aacs || self.host_certs.is_empty() {
|
||||
// Wrong disc kind, or no host cert to authenticate with — the loop
|
||||
// falls through to a VID-less / keysource path.
|
||||
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);
|
||||
}
|
||||
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,
|
||||
})
|
||||
crate::fallthrough(
|
||||
handshake::run_cert_handshake(scsi, &self.host_certs).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::warn!(
|
||||
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,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,15 +89,47 @@ impl Unlocker for AacsCert {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// AES-128-ECB decrypt against the FIPS-197 Appendix B / NIST known-answer
|
||||
/// test vector: decrypting the published ciphertext with the published key
|
||||
/// must recover the published plaintext.
|
||||
#[test]
|
||||
fn aes_ecb_decrypt_matches_fips197_test_vector() {
|
||||
let key: [u8; 16] = [
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
|
||||
0x0E, 0x0F,
|
||||
];
|
||||
let ciphertext: [u8; 16] = [
|
||||
0x69, 0xC4, 0xE0, 0xD8, 0x6A, 0x7B, 0x04, 0x30, 0xD8, 0xCD, 0xB7, 0x80, 0x70, 0xB4,
|
||||
0xC5, 0x5A,
|
||||
];
|
||||
let expected_plaintext: [u8; 16] = [
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD,
|
||||
0xEE, 0xFF,
|
||||
];
|
||||
let out = aes_ecb_decrypt(&key, &ciphertext);
|
||||
assert_eq!(out, expected_plaintext);
|
||||
}
|
||||
|
||||
/// Decrypting a different ciphertext under the same key must not produce
|
||||
/// the same plaintext — pins that the function actually decrypts the
|
||||
/// given block rather than returning a constant.
|
||||
#[test]
|
||||
fn aes_ecb_decrypt_varies_with_input() {
|
||||
let key = [0u8; 16];
|
||||
let a = aes_ecb_decrypt(&key, &[0u8; 16]);
|
||||
let b = aes_ecb_decrypt(&key, &[1u8; 16]);
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
fn id() -> crate::DriveId {
|
||||
crate::DriveId::default()
|
||||
}
|
||||
|
||||
/// `unlock_bus` self-guards on the disc kind: on a non-AACS disc it declines
|
||||
/// (`NotApplicable`) WITHOUT touching the transport, so iterating it on a
|
||||
/// CSS/unknown disc is safe.
|
||||
/// 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 unlock_bus_declines_non_aacs_kinds() {
|
||||
fn declines_non_aacs_kinds() {
|
||||
struct DeadTransport;
|
||||
impl ScsiTransport for DeadTransport {
|
||||
fn execute(
|
||||
@@ -103,15 +145,41 @@ mod tests {
|
||||
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:?}");
|
||||
assert!(
|
||||
AacsUnlocker::new(vec![host_cert()])
|
||||
.unlock(&mut t, &UnlockCtx::new(&id, k))
|
||||
.expect("declines")
|
||||
.is_none(),
|
||||
"declines {k:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// With no host certs there is nothing to authenticate with → NoUsableHostCert,
|
||||
// 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 id = id();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Aacs);
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.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());
|
||||
}
|
||||
|
||||
fn host_cert() -> crate::HostCert {
|
||||
handshake::tests::dummy_cert()
|
||||
}
|
||||
|
||||
/// With no host certs there is nothing to authenticate with → `Ok(false)`,
|
||||
/// and the transport is never touched.
|
||||
#[test]
|
||||
fn no_host_certs_is_no_usable_host_cert() {
|
||||
fn no_host_certs_declines() {
|
||||
struct DeadTransport;
|
||||
impl ScsiTransport for DeadTransport {
|
||||
fn execute(
|
||||
@@ -126,7 +194,11 @@ mod tests {
|
||||
}
|
||||
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);
|
||||
assert!(
|
||||
AacsUnlocker::new(vec![])
|
||||
.unlock(&mut t, &UnlockCtx::new(&id, DiscKind::Aacs))
|
||||
.expect("no certs declines")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+110
-3
@@ -19,6 +19,31 @@ 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 s.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && s.sense.is_none()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 +56,93 @@ 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 {
|
||||
if e.is_transport_failure() {
|
||||
crate::UnlockError::Transport
|
||||
} else {
|
||||
crate::UnlockError::NotApplicable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::UnlockError;
|
||||
use crate::scsi::{SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE, ScsiError};
|
||||
|
||||
/// Catches restoring the `.map_err(|_| CssAuthFailed)` that made the
|
||||
/// `Transport` arm unreachable: a dead bus must classify as `Transport`
|
||||
/// (consumer aborts), a drive rejection as `NotApplicable` (fall through).
|
||||
#[test]
|
||||
fn transport_fault_survives_the_step_mapping() {
|
||||
let dead = step_err(ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
assert!(dead.is_transport_failure());
|
||||
assert_eq!(UnlockError::from(dead), UnlockError::Transport);
|
||||
|
||||
let refused = step_err(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([0u8; 32]),
|
||||
});
|
||||
assert!(matches!(refused, Error::CssAuthFailed));
|
||||
assert_eq!(UnlockError::from(refused), UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
/// Each variant has its own stable numeric code.
|
||||
#[test]
|
||||
fn code_is_stable_per_variant() {
|
||||
assert_eq!(Error::CssAuthFailed.code(), 7201);
|
||||
assert_eq!(
|
||||
Error::Scsi(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([0u8; 32]),
|
||||
})
|
||||
.code(),
|
||||
7299
|
||||
);
|
||||
}
|
||||
|
||||
// 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(s) if s.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE => {
|
||||
crate::UnlockError::Transport
|
||||
Error::Scsi(inner) => {
|
||||
assert_eq!(inner.status, SCSI_STATUS_CHECK_CONDITION);
|
||||
assert_eq!(inner.sense, Some([1u8; 32]));
|
||||
}
|
||||
_ => crate::UnlockError::NotApplicable,
|
||||
_ => panic!("expected Error::Scsi"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+658
-263
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,739 @@
|
||||
//! freemkv — the self-identifying custom-firmware unlocker.
|
||||
//!
|
||||
//! Detects a freemkv-firmware drive by issuing the vendor Identity command
|
||||
//! (sub-function `0x01` of the `0x3C` READ BUFFER "knock" ABI) and checking
|
||||
//! that the response starts `b"freemkv"` — no bundled profile database is
|
||||
//! needed, unlike [`crate::ld`], because the firmware self-identifies.
|
||||
//!
|
||||
//! The full knock CDB layout, sub-function table, fixed-length reply rules,
|
||||
//! and toggle-polarity details are documented in `docs/freemkv-abi.md`.
|
||||
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
/// READ BUFFER (0x3C) opcode every freemkv vendor command hijacks.
|
||||
const KNOCK_OPCODE: u8 = 0x3C;
|
||||
/// Knock-mode byte (CDB byte 1) that distinguishes this ABI from an ordinary
|
||||
/// READ BUFFER mode.
|
||||
const KNOCK_MODE: u8 = 0x0E;
|
||||
/// The `C0 DE` knock (CDB bytes 2-3) that marks a freemkv vendor command.
|
||||
const KNOCK_MAGIC: [u8; 2] = [0xC0, 0xDE];
|
||||
|
||||
/// State byte that selects OEM behaviour (clears the feature's RAM flag). The
|
||||
/// OFF pole of the Region toggle.
|
||||
const STATE_OFF: u8 = 0x00;
|
||||
/// State byte that enables the patched behaviour (sets the feature's RAM flag).
|
||||
/// The ON pole of the Region toggle.
|
||||
const STATE_ON: u8 = 0x01;
|
||||
|
||||
/// Speed sub-function (`0x02`) cap value selecting OEM read-speed behaviour.
|
||||
#[allow(dead_code)]
|
||||
const SPEED_CAP_OEM: u8 = 0x00;
|
||||
/// Speed sub-function (`0x02`) cap value selecting the uncapped / maximum read
|
||||
/// speed (full riplock lift). `0x01`..=`0xFE` select an intermediate ceiling.
|
||||
const SPEED_CAP_MAX: u8 = 0xFF;
|
||||
|
||||
/// Raw Read (`0x04`) mode: OEM cert enforcement.
|
||||
#[allow(dead_code)]
|
||||
const RAW_READ_OFF: u8 = 0x00;
|
||||
/// Raw Read (`0x04`) mode: "cert is valid" — the drive reports host auth as
|
||||
/// already succeeded, so a bare `0xAD` fmt `0x80` yields the VID with NO cert
|
||||
/// and NO AKE. This is the mode this unlocker sets.
|
||||
const RAW_READ_CERT_VALID: u8 = 0x01;
|
||||
/// Raw Read (`0x04`) mode: "accept any host cert, revoked or not" — the host
|
||||
/// still runs the real AKE. Driven by the AACS cert unlocker, not here.
|
||||
#[allow(dead_code)]
|
||||
const RAW_READ_ACCEPT_ANY: u8 = 0x02;
|
||||
|
||||
/// Vendor sub-functions (CDB byte 4). These numeric values ARE the wire
|
||||
/// protocol and match `freemkv-firmware`'s `abi.rs::SubFn`.
|
||||
mod subfn {
|
||||
/// Identity — detection (read; ignores state).
|
||||
pub(super) const IDENTITY: u8 = 0x01;
|
||||
/// Speed / riplock (state byte IS the cap value: `00` = OEM, `01`..=`FF`
|
||||
/// ceiling, `FF` = max).
|
||||
pub(super) const SPEED: u8 = 0x02;
|
||||
/// Region-free (toggle; `01` = RPC region-free).
|
||||
pub(super) const REGION: u8 = 0x03;
|
||||
/// Raw Read (transport unlock; state selects the cert-gate mode).
|
||||
pub(super) const RAW_READ: u8 = 0x04;
|
||||
/// DumpAll diagnostic RAM read (address big-endian in `cdb[5..9]`).
|
||||
pub(super) const DUMP_ALL: u8 = 0x09;
|
||||
}
|
||||
|
||||
/// The ASCII magic that leads the Identity (subfn 01) reply — the `RESP_MAGIC`
|
||||
/// of the canonical `abi.rs`. This is the ENTIRE freemkv-detection mechanism
|
||||
/// (no bundled profile database — the firmware self-identifies).
|
||||
const IDENTITY_MARKER: &[u8] = b"freemkv";
|
||||
|
||||
// Fixed response length for EVERY freemkv knock command; the drive commits this
|
||||
// many bytes on data-in regardless of subfn. A short/zero allocation desyncs the
|
||||
// transfer (ABORTED COMMAND, then a wedged FIFO) — see docs/freemkv-abi.md.
|
||||
const KNOCK_RESP_LEN: usize = 64;
|
||||
|
||||
/// Bytes returned by one DumpAll (subfn 0x09) diagnostic read — the firmware
|
||||
/// always commits a fixed 64-byte window.
|
||||
const MEMREAD_LEN: usize = 64;
|
||||
|
||||
/// Build the 10-byte knock CDB for a freemkv vendor sub-function:
|
||||
/// `3C 0E C0 DE <subfn> <state> <len_hi> <len_mid> <len_lo> 00`.
|
||||
fn build_cdb(subfn: u8, state: u8, alloc_len: u32) -> [u8; 10] {
|
||||
let len = alloc_len.to_be_bytes();
|
||||
[
|
||||
KNOCK_OPCODE,
|
||||
KNOCK_MODE,
|
||||
KNOCK_MAGIC[0],
|
||||
KNOCK_MAGIC[1],
|
||||
subfn,
|
||||
state,
|
||||
len[1],
|
||||
len[2],
|
||||
len[3],
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
|
||||
// Build the DumpAll (subfn 0x09) CDB: addr packed big-endian into cdb[5..9] (the
|
||||
// allocation-length field is reused to carry the address for the fixed 64-byte
|
||||
// window, matching freemkv-firmware's abi.rs::build_memread_cdb) — bypasses build_cdb.
|
||||
fn build_memread_cdb(addr: u32) -> [u8; 10] {
|
||||
let a = addr.to_be_bytes();
|
||||
[
|
||||
KNOCK_OPCODE,
|
||||
KNOCK_MODE,
|
||||
KNOCK_MAGIC[0],
|
||||
KNOCK_MAGIC[1],
|
||||
subfn::DUMP_ALL,
|
||||
a[0],
|
||||
a[1],
|
||||
a[2],
|
||||
a[3],
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether a transport error is a genuine dead bus (a senseless
|
||||
/// transport-failure status) rather than a drive rejection surfaced through a
|
||||
/// non-conforming transport (`Err` carrying a sense).
|
||||
fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
|
||||
e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
|
||||
}
|
||||
|
||||
// The freemkv custom-firmware unlocker. Detection: subfn-01 Identity, checking
|
||||
// the response starts "freemkv" — 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 the subfn-01 Identity command: Ok(true) if response starts "freemkv",
|
||||
// Ok(false) if rejected/mismatched (not this firmware). A dead bus is
|
||||
// Err(Transport) — this is the FIRST command, so a transport fault must abort.
|
||||
fn identify(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
|
||||
// Identity MUST request the fixed KNOCK_RESP_LEN (64) allocation like
|
||||
// every other knock: the firmware commits 64 bytes on every subfn, so a
|
||||
// short 32-byte read desyncs the data-in phase and wedges the VID read.
|
||||
let cdb = build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32);
|
||||
let mut buf = [0u8; KNOCK_RESP_LEN];
|
||||
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
Ok(r) => {
|
||||
let matched = r.status == 0
|
||||
&& r.bytes_transferred >= IDENTITY_MARKER.len()
|
||||
&& buf[..IDENTITY_MARKER.len()] == *IDENTITY_MARKER;
|
||||
if !matched {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_identity_no_match",
|
||||
"Identity probe did not report freemkv firmware"
|
||||
);
|
||||
}
|
||||
Ok(matched)
|
||||
}
|
||||
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 payload-less sub-function carrying an explicit state byte (toggle
|
||||
// pole, Speed cap, or Raw Read mode). Ok(()) on GOOD status; NotApplicable
|
||||
// if rejected; Transport only on a dead bus.
|
||||
fn send_state(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
subfn: u8,
|
||||
state: u8,
|
||||
) -> std::result::Result<(), UnlockError> {
|
||||
// Every knock command returns a fixed KNOCK_RESP_LEN data-in payload; a
|
||||
// toggle reads and DISCARDS it (only the GOOD status matters), but it MUST
|
||||
// set up the data-in phase or the drive aborts + wedges (see KNOCK_RESP_LEN).
|
||||
let cdb = build_cdb(subfn, state, KNOCK_RESP_LEN as u32);
|
||||
let mut buf = [0u8; KNOCK_RESP_LEN];
|
||||
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_subfn_rejected",
|
||||
subfn,
|
||||
state,
|
||||
status = r.status,
|
||||
"freemkv sub-function rejected by the drive"
|
||||
);
|
||||
Err(UnlockError::NotApplicable)
|
||||
}
|
||||
Err(e) => {
|
||||
if is_dead_bus(&e) {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_subfn_transport_fault",
|
||||
subfn,
|
||||
"transport fault on a freemkv sub-function; aborting"
|
||||
);
|
||||
return Err(UnlockError::Transport);
|
||||
}
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_subfn_rejected_as_err",
|
||||
subfn,
|
||||
status = e.status,
|
||||
"freemkv sub-function rejected (via Err)"
|
||||
);
|
||||
Err(UnlockError::NotApplicable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Speed / riplock (subfn 0x02). The state byte IS the cap: `0x00` restores
|
||||
/// OEM behaviour, `0xFF` lifts riplock to full speed, `0x01`..=`0xFE` select
|
||||
/// an intermediate ceiling.
|
||||
fn set_speed(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
cap: u8,
|
||||
) -> std::result::Result<(), UnlockError> {
|
||||
self.send_state(scsi, subfn::SPEED, cap)
|
||||
}
|
||||
|
||||
/// Region toggle (subfn 0x03). `on` = DVD RPC region-free.
|
||||
fn set_region_free(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
on: bool,
|
||||
) -> std::result::Result<(), UnlockError> {
|
||||
let state = if on { STATE_ON } else { STATE_OFF };
|
||||
self.send_state(scsi, subfn::REGION, state)
|
||||
}
|
||||
|
||||
// Raw Read (subfn 0x04) in RAW_READ_CERT_VALID mode: tells the drive host auth
|
||||
// already succeeded so a bare 0xAD fmt 0x80 returns the VID with no cert/AKE.
|
||||
// Load-bearing: if rejected, this firmware can't do the one-command unlock.
|
||||
fn set_raw_read(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<(), UnlockError> {
|
||||
self.send_state(scsi, subfn::RAW_READ, RAW_READ_CERT_VALID)
|
||||
}
|
||||
|
||||
/// DumpAll diagnostic RAM read (subfn 0x09): 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 sequence: 01 Identity (hard gate) → 03 Region → 02 Speed
|
||||
// (best-effort) → 04 01 Raw Read → bare 0xAD VID (both load-bearing, no
|
||||
// fallback). See docs/freemkv-abi.md for full failure-mode semantics.
|
||||
fn full_unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
) -> std::result::Result<Unlocked, UnlockError> {
|
||||
// 01 — Identity: must be a freemkv drive.
|
||||
if !self.identify(scsi)? {
|
||||
return Err(UnlockError::NotApplicable);
|
||||
}
|
||||
// Best-effort feature toggle: 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(())
|
||||
}
|
||||
}
|
||||
};
|
||||
// 03 — Region-free (feature).
|
||||
best_effort(self.set_region_free(scsi, true), "region")?;
|
||||
// 02 — Speed / riplock lift to full (feature).
|
||||
best_effort(self.set_speed(scsi, SPEED_CAP_MAX), "speed")?;
|
||||
// 04 01 — Raw Read "cert valid" (LOAD-BEARING). No fallback: a firmware
|
||||
// that rejects it can't do the one-command unlock.
|
||||
match self.set_raw_read(scsi) {
|
||||
Ok(()) => {}
|
||||
Err(UnlockError::Transport) => return Err(UnlockError::Transport),
|
||||
Err(_) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_raw_read_rejected",
|
||||
"Raw Read (04 01) rejected — cannot unlock this drive"
|
||||
);
|
||||
return Err(UnlockError::VidUnavailable);
|
||||
}
|
||||
}
|
||||
// Bare 0xAD VID read — the shared BEST-EFFORT reader (identical to the
|
||||
// LD/Renesas routes). Raw Read 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 (Raw Read on)"
|
||||
);
|
||||
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
|
||||
/// (best-effort), turn on Raw Read (the actual unlock), and read the Volume
|
||||
/// ID with a bare `0xAD` (best-effort). `Some` when Raw Read succeeded — the
|
||||
/// drive is unlocked whether or not the VID read did; `None` if it isn't a
|
||||
/// freemkv drive; `Err(Transport)` on a dead bus. `ctx` is unused: this
|
||||
/// unlocker self-identifies rather than matching on drive identity.
|
||||
fn unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
_ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Option<Unlocked>, UnlockError> {
|
||||
crate::fallthrough(self.full_unlock(scsi))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::DiscKind;
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
use crate::scsi::{DataDirection, Result, ScsiResult, ScsiTransport};
|
||||
|
||||
fn ctx(id: &crate::DriveId) -> UnlockCtx<'_> {
|
||||
UnlockCtx::new(id, DiscKind::Unknown)
|
||||
}
|
||||
|
||||
/// Serves a fixed payload with a GOOD status (used for the Identity probe).
|
||||
struct FakeTransport {
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
impl ScsiTransport for FakeTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let n = self.payload.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.payload[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn freemkv_identity_payload() -> Vec<u8> {
|
||||
let mut p = vec![0u8; KNOCK_RESP_LEN];
|
||||
let s = b"freemkv 0.6.4";
|
||||
p[..s.len()].copy_from_slice(s);
|
||||
p
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
// ── CDB shape ────────────────────────────────────────────────────────
|
||||
|
||||
/// The 10-byte knock CDB layout is a pinned wire-format contract.
|
||||
#[test]
|
||||
fn build_cdb_encodes_the_knock_shape() {
|
||||
let cdb = build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32);
|
||||
assert_eq!(cdb.len(), 10);
|
||||
assert_eq!(cdb[0], 0x3C);
|
||||
assert_eq!(cdb[1], 0x0E);
|
||||
assert_eq!(cdb[2], 0xC0);
|
||||
assert_eq!(cdb[3], 0xDE);
|
||||
assert_eq!(cdb[4], subfn::IDENTITY);
|
||||
assert_eq!(cdb[5], 0x00);
|
||||
assert_eq!([cdb[6], cdb[7], cdb[8]], [0x00, 0x00, 0x40]); // 64 (KNOCK_RESP_LEN), 24-bit BE
|
||||
assert_eq!(cdb[9], 0x00);
|
||||
}
|
||||
|
||||
/// The 24-bit allocation length is big-endian across bytes 6..8.
|
||||
#[test]
|
||||
fn build_cdb_encodes_alloc_len_24bit_big_endian() {
|
||||
let cdb = build_cdb(subfn::RAW_READ, RAW_READ_CERT_VALID, 0x01_2345);
|
||||
assert_eq!([cdb[6], cdb[7], cdb[8]], [0x01, 0x23, 0x45]);
|
||||
assert_eq!(cdb[4], subfn::RAW_READ);
|
||||
assert_eq!(cdb[5], 0x01);
|
||||
}
|
||||
|
||||
/// The sub-function numbers ARE the wire protocol and match the firmware
|
||||
/// `abi.rs::SubFn`: 01 Identity, 02 Speed, 03 Region, 04 Raw Read, 09 DumpAll.
|
||||
#[test]
|
||||
fn subfn_values_match_firmware_abi() {
|
||||
assert_eq!(subfn::IDENTITY, 0x01);
|
||||
assert_eq!(subfn::SPEED, 0x02);
|
||||
assert_eq!(subfn::REGION, 0x03);
|
||||
assert_eq!(subfn::RAW_READ, 0x04);
|
||||
assert_eq!(subfn::DUMP_ALL, 0x09);
|
||||
}
|
||||
|
||||
/// The Raw Read mode selectors match the firmware: 00 OEM, 01 cert-valid
|
||||
/// (this unlocker), 02 accept-any-cert (the AKE path).
|
||||
#[test]
|
||||
fn raw_read_modes_are_pinned() {
|
||||
assert_eq!(RAW_READ_OFF, 0x00);
|
||||
assert_eq!(RAW_READ_CERT_VALID, 0x01);
|
||||
assert_eq!(RAW_READ_ACCEPT_ANY, 0x02);
|
||||
}
|
||||
|
||||
/// Exact CDB bytes for each vendor sub-function that build_cdb serves.
|
||||
#[test]
|
||||
fn build_cdb_exact_bytes_per_subfn() {
|
||||
assert_eq!(
|
||||
build_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x01, 0x00, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
// Toggles carry the fixed KNOCK_RESP_LEN (64 = 0x40) data-in allocation in
|
||||
// bytes 6..8 — the host must read the response or the drive wedges.
|
||||
assert_eq!(
|
||||
build_cdb(subfn::SPEED, SPEED_CAP_MAX, KNOCK_RESP_LEN as u32),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0xFF, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
assert_eq!(
|
||||
build_cdb(subfn::REGION, STATE_ON, KNOCK_RESP_LEN as u32),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x03, 0x01, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
assert_eq!(
|
||||
build_cdb(subfn::RAW_READ, RAW_READ_CERT_VALID, KNOCK_RESP_LEN as u32),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x01, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
// ── Detection ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn identify_true_on_freemkv_marker_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_cdb(subfn::IDENTITY, STATE_OFF, KNOCK_RESP_LEN as u32)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_false_on_non_matching_payload() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; KNOCK_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"));
|
||||
}
|
||||
|
||||
#[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
|
||||
);
|
||||
}
|
||||
|
||||
// ── Feature toggles ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn set_speed_max_issues_the_cap_ff_cdb() {
|
||||
let mut t = MockTransport::always(Reply::good(vec![]));
|
||||
FreemkvUnlocker::new()
|
||||
.set_speed(&mut t, SPEED_CAP_MAX)
|
||||
.expect("ok");
|
||||
assert_eq!(
|
||||
t.cdbs[0],
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0xFF, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_speed_intermediate_cap_is_placed_in_state_byte() {
|
||||
let mut t = MockTransport::always(Reply::good(vec![]));
|
||||
FreemkvUnlocker::new().set_speed(&mut t, 0x42).expect("ok");
|
||||
assert_eq!(
|
||||
t.cdbs[0],
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x42, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_region_free_on_issues_the_state_01_cdb() {
|
||||
let mut t = MockTransport::always(Reply::good(vec![]));
|
||||
FreemkvUnlocker::new()
|
||||
.set_region_free(&mut t, true)
|
||||
.expect("ok");
|
||||
assert_eq!(
|
||||
t.cdbs[0],
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x03, 0x01, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_raw_read_issues_the_04_01_cdb() {
|
||||
let mut t = MockTransport::always(Reply::good(vec![]));
|
||||
FreemkvUnlocker::new().set_raw_read(&mut t).expect("ok");
|
||||
assert_eq!(
|
||||
t.cdbs[0],
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x01, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_drive_rejection_is_not_applicable() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let err = FreemkvUnlocker::new().set_raw_read(&mut t).unwrap_err();
|
||||
assert_eq!(err, UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_transport_fault_propagates() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let err = FreemkvUnlocker::new()
|
||||
.set_speed(&mut t, SPEED_CAP_MAX)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, UnlockError::Transport);
|
||||
}
|
||||
|
||||
// ── full_unlock / unlock ─────────────────────────────────────────────
|
||||
// The bare 0xAD VID read itself is tested in crate::vid; here we cover the
|
||||
// full unlock sequence end-to-end, including the best-effort VID handling.
|
||||
|
||||
/// The full unlock runs `01→03→02→04→AD` in order: identity, region, speed,
|
||||
/// raw-read, bare VID read. This is the load-bearing sequence test.
|
||||
#[test]
|
||||
fn full_unlock_issues_identity_region_speed_rawread_then_bare_vid() {
|
||||
let vid = [0x7Cu8; 16];
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()), // 01 Identity
|
||||
Reply::good(vec![]), // 03 Region
|
||||
Reply::good(vec![]), // 02 Speed
|
||||
Reply::good(vec![]), // 04 01 Raw Read
|
||||
Reply::good(vid_ds_response(vid)), // 0xAD bare VID
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let id = crate::DriveId::default();
|
||||
let out = FreemkvUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.expect("no fault")
|
||||
.expect("Raw Read on ⇒ unlocked");
|
||||
assert_eq!(out.vid, Some(vid));
|
||||
// Four vendor knocks then the standard 0xAD read.
|
||||
assert_eq!(t.cdbs.len(), 5);
|
||||
let vendor_subfns: Vec<u8> = t.cdbs[..4].iter().map(|c| c[4]).collect();
|
||||
assert_eq!(vendor_subfns, vec![0x01, 0x03, 0x02, 0x04]);
|
||||
assert_eq!(t.cdbs[3][5], RAW_READ_CERT_VALID); // Raw Read state 01
|
||||
assert_eq!(t.cdbs[4][0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
|
||||
}
|
||||
|
||||
/// A missing region/speed feature does not fail the unlock (best-effort).
|
||||
#[test]
|
||||
fn full_unlock_tolerates_missing_region_and_speed() {
|
||||
let vid = [0x3Cu8; 16];
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()), // identity
|
||||
Reply::illegal_request(), // region unsupported
|
||||
Reply::illegal_request(), // speed unsupported
|
||||
Reply::good(vec![]), // raw read
|
||||
Reply::good(vid_ds_response(vid)), // bare VID
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let id = crate::DriveId::default();
|
||||
let out = FreemkvUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.expect("no fault")
|
||||
.expect("unlocked");
|
||||
assert_eq!(out.vid, Some(vid));
|
||||
}
|
||||
|
||||
/// Raw Read is LOAD-BEARING: if the drive rejects it, this isn't an
|
||||
/// unlockable freemkv drive, so `unlock()` declines (`Ok(false)`) and falls
|
||||
/// through — never a hard error.
|
||||
#[test]
|
||||
fn declines_when_raw_read_rejected() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()), // identity
|
||||
Reply::good(vec![]), // region
|
||||
Reply::good(vec![]), // speed
|
||||
Reply::illegal_request(), // raw read REJECTED
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let id = crate::DriveId::default();
|
||||
assert!(
|
||||
FreemkvUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.expect("no fault")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
/// Best-effort VID: Raw Read succeeded (drive unlocked), but the bare VID
|
||||
/// read was rejected — `unlock()` still returns `true`, with `vid() == None`.
|
||||
/// This is the fix: the old load-bearing VID aborted the whole unlock here,
|
||||
/// dropping a genuinely-unlocked drive to the cert path.
|
||||
#[test]
|
||||
fn unlocks_without_vid_when_bare_read_fails() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()), // identity
|
||||
Reply::good(vec![]), // region
|
||||
Reply::good(vec![]), // speed
|
||||
Reply::good(vec![]), // raw read
|
||||
Reply::illegal_request(), // bare VID: no medium
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let id = crate::DriveId::default();
|
||||
let out = FreemkvUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.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());
|
||||
let id = crate::DriveId::default();
|
||||
assert!(
|
||||
FreemkvUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.expect("no fault")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_fault_propagates() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let id = crate::DriveId::default();
|
||||
assert_eq!(
|
||||
FreemkvUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.unwrap_err(),
|
||||
UnlockError::Transport
|
||||
);
|
||||
}
|
||||
|
||||
// ── DumpAll (subfn 0x09) ─────────────────────────────────────────────
|
||||
|
||||
#[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],
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x09, 0x01, 0xF8, 0x12, 0x34, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_memread_cdb_packs_address_big_endian_at_5_to_9() {
|
||||
let cdb = build_memread_cdb(0xDEAD_BEEF);
|
||||
assert_eq!(cdb[4], subfn::DUMP_ALL);
|
||||
assert_eq!([cdb[5], cdb[6], cdb[7], cdb[8]], [0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
assert_eq!(cdb[9], 0x00);
|
||||
}
|
||||
|
||||
#[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/ .
|
||||
@@ -0,0 +1,31 @@
|
||||
# ld — the drive bus-unlock module
|
||||
|
||||
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
|
||||
(`profiles.json`) and, for a matching drive, lifts the drive-level
|
||||
bus-encryption barrier so the drive serves readable sectors. Content-key
|
||||
decryption is a separate concern, handled by the consumer.
|
||||
|
||||
Clients never name this module directly —
|
||||
[libfreemkv](https://github.com/freemkv/libfreemkv) dispatches through
|
||||
`freemkv_unlock::all_unlockers()`, and this module answers `unlock_features()` /
|
||||
`unlock_bus()` when the drive identity is one it supports.
|
||||
|
||||
## Scope: non-persistent unlock only
|
||||
|
||||
This module performs only the **non-persistent** unlock — the access state it
|
||||
sets up lives in drive RAM and is gone on power cycle. The one-time, permanent
|
||||
drive preparation is the drive owner's own manual step and is **never**
|
||||
automated here.
|
||||
|
||||
## Third-party microcode (`profiles.json`)
|
||||
|
||||
`profiles.json` bundles volatile, RAM-only optical-drive microcode
|
||||
("LibreDrive") authored and owned by the **MakeMKV team** (GuinpinSoft inc.).
|
||||
Each payload carries its own notice — *"Copyright (c) MakeMKV team, all rights
|
||||
reserved."* This microcode is **third-party proprietary material and is NOT
|
||||
covered by this repository's MIT license**; no ownership is claimed. See the
|
||||
[`NOTICE`](./NOTICE) in this directory and the repository-root `NOTICE`. With
|
||||
thanks to the MakeMKV team for [LibreDrive](http://www.makemkv.com/libredrive/).
|
||||
+2
-2
@@ -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 /
|
||||
|
||||
+128
@@ -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,120 @@ impl From<Error> for crate::UnlockError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::UnlockError;
|
||||
use crate::scsi::{SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE, ScsiError};
|
||||
|
||||
/// The abort/fall-through boundary, which had no test at all. Catches
|
||||
/// widening `is_transport_failure` (every failure would abort every rip) or
|
||||
/// narrowing it (a dead bus would be probed forever).
|
||||
#[test]
|
||||
fn only_a_senseless_transport_status_is_a_transport_failure() {
|
||||
let dead_bus = Error::Scsi {
|
||||
opcode: 0x3C,
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
};
|
||||
assert!(dead_bus.is_transport_failure());
|
||||
assert_eq!(UnlockError::from(dead_bus), UnlockError::Transport);
|
||||
|
||||
// A CHECK CONDITION is the DRIVE refusing, not the bus dying.
|
||||
let refused = Error::Scsi {
|
||||
opcode: 0x3C,
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([0u8; 32]),
|
||||
};
|
||||
assert!(!refused.is_transport_failure());
|
||||
assert_eq!(UnlockError::from(refused), UnlockError::NotApplicable);
|
||||
|
||||
// The transport-failure status WITH a sense is a drive that reported
|
||||
// 0xFF, not a bus fault — it must not abort the consumer.
|
||||
let odd = Error::Scsi {
|
||||
opcode: 0x3C,
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: Some([0u8; 32]),
|
||||
};
|
||||
assert!(!odd.is_transport_failure());
|
||||
|
||||
for e in [
|
||||
Error::ProfileParse,
|
||||
Error::UnlockFailed,
|
||||
Error::SignatureMismatch {
|
||||
expected: [0; 4],
|
||||
got: [1; 4],
|
||||
},
|
||||
] {
|
||||
assert!(!e.is_transport_failure(), "{e:?} is not a bus fault");
|
||||
assert_eq!(UnlockError::from(e), UnlockError::NotApplicable);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Display` is what shows up in logs — every variant must render without
|
||||
/// panicking and the SCSI variant must carry the opcode/status through.
|
||||
#[test]
|
||||
fn display_renders_every_variant() {
|
||||
assert_eq!(Error::ProfileParse.to_string(), "drive profile parse error");
|
||||
assert_eq!(Error::UnlockFailed.to_string(), "firmware unlock failed");
|
||||
assert_eq!(
|
||||
Error::SignatureMismatch {
|
||||
expected: [0; 4],
|
||||
got: [1; 4],
|
||||
}
|
||||
.to_string(),
|
||||
"signature mismatch"
|
||||
);
|
||||
assert_eq!(
|
||||
Error::Scsi {
|
||||
opcode: 0x3C,
|
||||
status: 0x02,
|
||||
sense: None,
|
||||
}
|
||||
.to_string(),
|
||||
"SCSI error (opcode 0x3c, status 0x02)"
|
||||
);
|
||||
}
|
||||
|
||||
/// `From<ScsiError>` must carry the status AND sense across the seam —
|
||||
/// collapsing either one destroys the transport-vs-rejection distinction the
|
||||
/// whole abort/fall-through split rests on.
|
||||
#[test]
|
||||
fn scsi_error_conversion_preserves_status_and_sense() {
|
||||
let e = Error::from(ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
assert!(e.is_transport_failure());
|
||||
|
||||
let e = Error::from(ScsiError {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some([0u8; 32]),
|
||||
});
|
||||
assert!(!e.is_transport_failure());
|
||||
assert_eq!(UnlockError::from(e), UnlockError::NotApplicable);
|
||||
}
|
||||
|
||||
/// Error codes are stable and distinct — they are the numeric contract the
|
||||
/// logs carry instead of English.
|
||||
#[test]
|
||||
fn error_codes_are_distinct_and_stable() {
|
||||
let codes = [
|
||||
Error::ProfileParse.code(),
|
||||
Error::UnlockFailed.code(),
|
||||
Error::SignatureMismatch {
|
||||
expected: [0; 4],
|
||||
got: [0; 4],
|
||||
}
|
||||
.code(),
|
||||
Error::Scsi {
|
||||
opcode: 0,
|
||||
status: 0,
|
||||
sense: None,
|
||||
}
|
||||
.code(),
|
||||
];
|
||||
assert_eq!(codes, [7101, 7102, 7103, 7199]);
|
||||
}
|
||||
}
|
||||
|
||||
+324
-93
@@ -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. See docs/ld-mod.md — cdb gating.
|
||||
#[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. See docs/ld-mod.md — public catalog design.
|
||||
|
||||
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. See docs/ld-mod.md — firmware_unlock contract.
|
||||
fn firmware_unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
@@ -141,45 +138,64 @@ 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. See docs/ld-mod.md — half-unlock fallback.
|
||||
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. See docs/ld-mod.md — probe_disc dead-bus classification.
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +208,7 @@ mod tests {
|
||||
/// 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, &[])
|
||||
UnlockCtx::new(id, DiscKind::Unknown)
|
||||
}
|
||||
|
||||
/// A fake transport that fills the response buffer from a fixed payload and
|
||||
@@ -235,16 +251,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
/// 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];
|
||||
@@ -253,8 +274,8 @@ mod tests {
|
||||
payload,
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
.read_oem_vid(&mut t, &known_vid_drive_id())
|
||||
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]");
|
||||
}
|
||||
@@ -266,8 +287,8 @@ mod tests {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 20,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
.read_oem_vid(&mut t, &known_vid_drive_id())
|
||||
let got = LdUnlocker::new()
|
||||
.read_oem_vid(&mut t, &known_vid_profile())
|
||||
.expect("short response is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
@@ -281,25 +302,62 @@ mod tests {
|
||||
payload,
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let got = LibreDrive::new()
|
||||
.read_oem_vid(&mut t, &known_vid_drive_id())
|
||||
let got = LdUnlocker::new()
|
||||
.read_oem_vid(&mut t, &known_vid_profile())
|
||||
.expect("bad header is Ok(None)");
|
||||
assert_eq!(got, None);
|
||||
}
|
||||
|
||||
/// A drive with no matching profile → `read_oem_vid` is `Ok(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_no_profile_is_none() {
|
||||
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 = LibreDrive::new()
|
||||
.read_oem_vid(&mut t, &make_drive_id("FAKE-VND", "9.99", "XX12345", ""))
|
||||
.expect("no profile is Ok(None)");
|
||||
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]
|
||||
@@ -308,12 +366,185 @@ mod tests {
|
||||
payload: vec![0u8; 36],
|
||||
bytes_transferred: 36,
|
||||
};
|
||||
let err = LibreDrive::new()
|
||||
.unlock_features(
|
||||
let unlocked = LdUnlocker::new()
|
||||
.unlock(
|
||||
&mut t,
|
||||
&ctx(&make_drive_id("FAKE-VND", "9.99", "XX12345", "")),
|
||||
)
|
||||
.expect_err("no profile → NotApplicable");
|
||||
assert_eq!(err, UnlockError::NotApplicable);
|
||||
.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.
|
||||
// See docs/ld-mod.md — probe_disc dead-bus test / mutation notes.
|
||||
#[test]
|
||||
fn transport_fault_during_probe_disc_is_transport_not_a_successful_unlock() {
|
||||
let id = known_vid_drive_id();
|
||||
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.
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
+41
-3
@@ -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,47 @@ 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)]
|
||||
mod tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
+931
-91
File diff suppressed because it is too large
Load Diff
@@ -21,9 +21,8 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
}
|
||||
|
||||
// Upload firmware via WRITE_BUFFER. The CDB's length is a 24-bit field;
|
||||
// if the blob exceeds that, the encoded length would silently disagree
|
||||
// with the bytes actually sent (`data`). Reject rather than upload a
|
||||
// length-mismatched command.
|
||||
// reject blobs that would exceed it rather than send a length-mismatched
|
||||
// command.
|
||||
let len = firmware.len();
|
||||
if len > WRITE_BUFFER_MAX_LEN {
|
||||
return Err(crate::ld::error::Error::UnlockFailed);
|
||||
@@ -43,7 +42,9 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
let mut data = firmware.clone();
|
||||
scsi.execute(&cdb, DataDirection::ToDevice, &mut data, 30_000)?;
|
||||
|
||||
// Verify 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 +58,47 @@ 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 scsi.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.
|
||||
// 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(())
|
||||
|
||||
@@ -13,18 +13,54 @@ const FIRMWARE_EXTRA: [u8; 16] = [0; 16];
|
||||
/// that drive — real profiles must supply their own (see `DriveProfile`).
|
||||
const VENDOR_VERIFY: [u8; 10] = [0xF1, 0x01, 0x02, 0x00, 0x0D, 0x30, 0x01, 0xF3, 0xAD, 0x23];
|
||||
|
||||
// See docs/variant-b-firmware-upload.md — trace_step rationale.
|
||||
// Swallows everything except a transport fault; these steps are advisory,
|
||||
// the unlock retries below are the real gate.
|
||||
fn trace_step(phase: &'static str, r: crate::scsi::Result<crate::scsi::ScsiResult>) -> Result<()> {
|
||||
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 (see docs/variant-b-firmware-upload.md).
|
||||
// MODE SELECT(10)'s parameter-list length is 16-bit, so reject only a
|
||||
// blob that can't be expressed in the CDB.
|
||||
let write_len = firmware.len();
|
||||
if write_len > u16::MAX as usize {
|
||||
return Err(crate::ld::error::Error::UnlockFailed);
|
||||
@@ -58,12 +94,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",
|
||||
scsi.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 +118,33 @@ 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",
|
||||
scsi.execute(&write_extra_cdb, DataDirection::ToDevice, &mut data2, 5_000),
|
||||
)?;
|
||||
|
||||
// Step 4: Vendor verify (0xF1 — B-only, not standard SCSI). PER-DRIVE: take
|
||||
// it from the profile (39 distinct values across the 140 B drives). The
|
||||
// const is only a legacy fallback — it carries one drive's token.
|
||||
// Step 4: Vendor verify (0xF1 — B-only, not standard SCSI), per-drive.
|
||||
// See docs/variant-b-firmware-upload.md for why the outcome is traced
|
||||
// rather than discarded.
|
||||
let verify_cdb = mt.profile.fw_verify_cdb.unwrap_or(VENDOR_VERIFY);
|
||||
let mut dummy = [0u8; 0];
|
||||
let _ = scsi.execute(&verify_cdb, DataDirection::None, &mut dummy, 5_000);
|
||||
trace_step(
|
||||
"mt1959b_fw_verify",
|
||||
scsi.execute(&verify_cdb, DataDirection::None, &mut dummy, 5_000),
|
||||
)?;
|
||||
|
||||
// Step 5: Unlock retries (up to 5, then a final fatal attempt). On a
|
||||
// successful unlock we issue one confirmation pass; its result is
|
||||
// intentionally best-effort — the first call already established the
|
||||
// unlock state, so a hiccup on the redundant confirmation must not fail
|
||||
// an otherwise-good unlock.
|
||||
// Step 5: Unlock retries (up to 5, then a final fatal attempt). The
|
||||
// confirmation pass after success is intentionally best-effort — see
|
||||
// docs/variant-b-firmware-upload.md.
|
||||
for _attempt in 0..5 {
|
||||
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)?;
|
||||
|
||||
+140
-81
@@ -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,13 @@ 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.
|
||||
/// See docs/drive-profile-visibility.md — field-visibility and allow(dead_code) rationale.
|
||||
#[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 +59,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 +149,16 @@ pub struct ProfileMatch {
|
||||
pub platform: Platform,
|
||||
}
|
||||
|
||||
// ── Parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Decode an even-length ASCII hex string into bytes.
|
||||
///
|
||||
/// Operates on raw bytes rather than `&str` char-boundary slices: a
|
||||
/// non-ASCII input (e.g. a hand-edited profile with a multi-byte char)
|
||||
/// could otherwise have `&s[i..i+2]` land inside a UTF-8 char boundary and
|
||||
/// panic. Hex is ASCII, so any non-ASCII or non-hex byte simply fails to
|
||||
/// decode. The error is a stable, language-neutral token (`"hex"`), not a
|
||||
/// translatable English message.
|
||||
// ── Parsing: decode hex on raw bytes (not `&str` slices) so non-ASCII
|
||||
// can't land `&s[i..i+2]` inside a UTF-8 char boundary and panic — it
|
||||
// just fails to decode with `"hex"`.
|
||||
fn decode_hex(s: &str) -> std::result::Result<Vec<u8>, &'static str> {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() % 2 != 0 {
|
||||
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 +198,7 @@ where
|
||||
}
|
||||
|
||||
// ── Fixed-length hex deserializers for CDB templates ────────────────────
|
||||
//
|
||||
// Profile JSON encodes CDBs as lowercase hex strings without separators.
|
||||
// An empty string / null / missing field decodes as `None`.
|
||||
// Lowercase hex strings, no separators; empty/null/missing decodes as `None`.
|
||||
|
||||
fn parse_hex_bytes(s: &str) -> std::result::Result<Vec<u8>, &'static str> {
|
||||
decode_hex(s)
|
||||
@@ -266,7 +277,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()
|
||||
}
|
||||
|
||||
@@ -285,13 +309,13 @@ fn load_from_str(data: &str) -> Result<Profiles> {
|
||||
|
||||
/// Find a profile matching a drive's INQUIRY fields.
|
||||
///
|
||||
/// 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.
|
||||
/// Per platform section (MT1959-A, then MT1959-B, then Renesas). A drive that
|
||||
/// reports a `product_id` matches ONLY on the full identity including it — if
|
||||
/// no cataloged profile matches exactly, the drive is treated as uncataloged
|
||||
/// (no match), never bound to a same-vendor/revision sibling. The looser
|
||||
/// four-field pass (vendor/revision/vendor_specific/firmware_date) runs only
|
||||
/// when the drive reports no `product_id` at all. All comparisons are
|
||||
/// whitespace-trimmed. Returns the first section that matches.
|
||||
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();
|
||||
@@ -304,27 +328,32 @@ pub fn find_by_drive_id(profiles: &Profiles, drive_id: &crate::DriveId) -> Optio
|
||||
(Platform::Mt1959B, &profiles.mt1959_b),
|
||||
(Platform::Renesas, &profiles.renesas),
|
||||
] {
|
||||
if !prod.is_empty() {
|
||||
if let Some(p) = list.iter().find(|p| {
|
||||
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,
|
||||
});
|
||||
}
|
||||
})
|
||||
{
|
||||
return Some(ProfileMatch {
|
||||
profile: p.clone(),
|
||||
platform,
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
}) {
|
||||
// The product_id-blind pass runs ONLY when the drive reports none: a
|
||||
// drive that reports a product_id but misses the exact match is an
|
||||
// uncataloged variant, else it'd bind to a sibling's wrong firmware.
|
||||
if prod.is_empty()
|
||||
&& 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,
|
||||
@@ -386,6 +415,16 @@ mod tests {
|
||||
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",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -488,10 +527,8 @@ mod tests {
|
||||
assert_eq!(parse_hex4("aabbccdd").unwrap(), [0xaa, 0xbb, 0xcc, 0xdd]);
|
||||
}
|
||||
|
||||
/// Platform::name() returns stable, non-empty, language-neutral identifiers.
|
||||
/// These strings are logged and keyed on in caller code; changing them is a
|
||||
/// breaking change.
|
||||
/// Mutation: swapping Mt1959A and Mt1959B names silently misroutes firmware upload.
|
||||
// Platform::name() strings are logged/keyed by callers; changing them is
|
||||
// a breaking change. Mutation: swapping A/B names misroutes firmware upload.
|
||||
#[test]
|
||||
fn platform_name_is_stable() {
|
||||
// The exact strings are part of the public stable API (logged/keyed).
|
||||
@@ -500,11 +537,9 @@ mod tests {
|
||||
assert_eq!(Platform::Renesas.name(), "Renesas");
|
||||
}
|
||||
|
||||
/// find_by_drive_id: exact match (including firmware_date) wins over loose match.
|
||||
/// Spec: two-pass — first an exact match including firmware_date, then looser.
|
||||
/// Build two synthetic Profiles entries that differ only by firmware_date,
|
||||
/// and verify the correct one is selected.
|
||||
/// Mutation: doing only the loose pass would return the first entry regardless of date.
|
||||
// find_by_drive_id: two-pass — exact match (incl. firmware_date) wins
|
||||
// over loose. Mutation: skipping the exact pass returns the first entry
|
||||
// regardless of date.
|
||||
#[test]
|
||||
fn find_by_drive_id_exact_date_wins_over_loose() {
|
||||
use serde_json::json;
|
||||
@@ -596,10 +631,8 @@ mod tests {
|
||||
assert!(matches!(result, Err(Error::ProfileParse)));
|
||||
}
|
||||
|
||||
/// Bundled profiles is non-empty (mt1959_a has at least one entry).
|
||||
/// This pins the embedded JSON: if profiles.json is accidentally emptied
|
||||
/// or truncated, this test goes red.
|
||||
/// Mutation: clearing profiles.json would make this fail.
|
||||
// Pins the embedded JSON: if profiles.json is emptied/truncated, this
|
||||
// goes red.
|
||||
#[test]
|
||||
fn bundled_profiles_has_entries() {
|
||||
let profiles = load_bundled().unwrap();
|
||||
@@ -609,10 +642,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// deserialization of a profile with missing optional CDB fields
|
||||
/// produces None for those fields (not a parse error).
|
||||
/// Spec: all CDB template fields are `#[serde(default)]` — they are optional.
|
||||
/// Mutation: making a CDB field required breaks backward-compat with old blobs.
|
||||
// All CDB fields are `#[serde(default)]`. Mutation: making one required
|
||||
// breaks backward-compat with old blobs.
|
||||
#[test]
|
||||
fn profile_optional_cdb_fields_default_to_none() {
|
||||
use serde_json::json;
|
||||
@@ -660,10 +691,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
// `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;
|
||||
|
||||
+196
-76
@@ -1,32 +1,44 @@
|
||||
//! 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;
|
||||
|
||||
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;
|
||||
// `ld` is public only for its drive-profile catalog + (under `emulation`) the
|
||||
// handshake wire format bdemu needs; the unlocker impl stays `pub(crate)`.
|
||||
// See docs/module-visibility.md — module visibility rationale.
|
||||
pub mod ld;
|
||||
// `renesis` is public for its `is_renesas` drive-probe; 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)`.
|
||||
// See docs/module-visibility.md — module visibility rationale.
|
||||
pub mod renesas;
|
||||
// `freemkv` self-identifies rather than matching a bundled profile, so it
|
||||
// stays fully private. See docs/module-visibility.md — module visibility rationale.
|
||||
mod freemkv;
|
||||
|
||||
use scsi::ScsiTransport;
|
||||
|
||||
// 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;
|
||||
pub use css::DvdUnlocker;
|
||||
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,7 +61,7 @@ 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)]
|
||||
#[derive(Clone)]
|
||||
pub struct HostCert {
|
||||
/// AACS 1.0 host private key (20 bytes).
|
||||
pub private_key: [u8; 20],
|
||||
@@ -61,32 +73,57 @@ pub struct HostCert {
|
||||
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. See docs/module-visibility.md.
|
||||
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.
|
||||
#[derive(Clone, Default)]
|
||||
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.
|
||||
// See docs/unlocked-debug-redaction.md — full rationale.
|
||||
impl std::fmt::Debug for Unlocked {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Unlocked")
|
||||
.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 +144,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",
|
||||
/// "CSS", "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 +196,97 @@ 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::CssUnlocker::new()),
|
||||
]
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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}");
|
||||
}
|
||||
|
||||
// `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,443 @@
|
||||
//! 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`]) and reports the match so a
|
||||
//! Renesas drive is named honestly. It does not modify drive state; AACS bus
|
||||
//! decryption is handled by the host cert.
|
||||
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
/// READ_BUFFER mode 0x02, buffer 0xF1 — the Renesas vendor identity buffer.
|
||||
const RB_F1_CDB: [u8; 10] = [0x3C, 0x02, 0xF1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00];
|
||||
const RB_F1_LEN: usize = 48;
|
||||
/// The ASCII interface marker a Renesas controller returns at `[16..19]`.
|
||||
const RENESAS_MARKER: &[u8] = b"SAT";
|
||||
const RENESAS_MARKER_OFFSET: usize = 16;
|
||||
|
||||
/// `Ok(true)` if `scsi` is a Renesas-platform drive (Pioneer or HL-DT-ST
|
||||
/// Renesas).
|
||||
///
|
||||
/// Issues the vendor READ_BUFFER 0x02/0xF1 probe: a Renesas controller serves
|
||||
/// a 48-byte identity block whose bytes `[16..19]` are the ASCII `SAT`
|
||||
/// interface tag. A rejection (CHECK CONDITION or `Err` with a sense) is
|
||||
/// `Ok(false)`: not a Renesas drive.
|
||||
///
|
||||
/// `Err(Transport)` on a dead bus. See docs/renesas-mod.md for why a
|
||||
/// transport fault must not be folded into `Ok(false)`.
|
||||
pub fn is_renesas(scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
|
||||
let mut buf = [0u8; RB_F1_LEN];
|
||||
match scsi.execute(&RB_F1_CDB, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
Ok(r) => {
|
||||
let end = RENESAS_MARKER_OFFSET + RENESAS_MARKER.len();
|
||||
Ok(r.status == 0
|
||||
&& r.bytes_transferred >= end
|
||||
&& &buf[RENESAS_MARKER_OFFSET..end] == RENESAS_MARKER)
|
||||
}
|
||||
// Only a senseless transport-failure status is a dead bus; anything
|
||||
// else the transport reports as `Err` is the drive refusing.
|
||||
Err(e) => {
|
||||
if e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none() {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "renesas_probe_transport_fault",
|
||||
"transport fault on the Renesas identity probe; aborting"
|
||||
);
|
||||
return Err(UnlockError::Transport);
|
||||
}
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "renesas_probe_rejected",
|
||||
status = e.status,
|
||||
"Renesas identity probe rejected by the drive; not a Renesas platform"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Renesas/Pioneer-platform unlocker. Its vendor "open" CDB puts the drive
|
||||
/// in the same raw-read / extended-access state the firmware routes reach (this
|
||||
/// is what MakeMKV's LibreDrive does on stock Pioneer firmware), so the Volume
|
||||
/// ID is then read with the SAME standard `0xAD` reader as freemkv/MT1959 — no
|
||||
/// host cert, no AKE. Stateless: `unlock()` returns what it learned.
|
||||
#[derive(Default)]
|
||||
pub struct Renesas;
|
||||
|
||||
impl Renesas {
|
||||
pub fn new() -> Self {
|
||||
Renesas
|
||||
}
|
||||
|
||||
// MakeMKV's vendor "open" sequence: primary read (A), and on refusal a
|
||||
// knock + second read (B). See docs/renesas-mod.md for the full sequence
|
||||
// and why we run A→knock→B rather than bailing after A.
|
||||
fn vendor_open(scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
|
||||
const RB_B0_04_CDB: [u8; 10] = [0x3C, 0x02, 0xB0, 0x00, 0x00, 0x04, 0x00, 0x00, 0xA4, 0x00];
|
||||
const KNOCK_A5AAAA_CDB: [u8; 10] =
|
||||
[0x3B, 0x02, 0x41, 0xA5, 0xAA, 0xAA, 0x00, 0x00, 0x00, 0x00];
|
||||
const RB_B0_500000_CDB: [u8; 10] =
|
||||
[0x3C, 0x02, 0xB0, 0x50, 0x00, 0x00, 0x00, 0x00, 0xA4, 0x00];
|
||||
|
||||
// A: primary open read.
|
||||
if read_is_good(scsi, &RB_B0_04_CDB)? {
|
||||
return Ok(true);
|
||||
}
|
||||
// B: MakeMKV's fallback — knock, then the second-window read. The knock is
|
||||
// fire-and-forget (payload-less); its own status is not the signal.
|
||||
match scsi.execute(&KNOCK_A5AAAA_CDB, DataDirection::None, &mut [], 5_000) {
|
||||
Ok(_) => {}
|
||||
Err(e)
|
||||
if e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none() =>
|
||||
{
|
||||
return Err(UnlockError::Transport);
|
||||
}
|
||||
Err(_) => {} // a drive that refuses the knock still gets the B read tried
|
||||
}
|
||||
read_is_good(scsi, &RB_B0_500000_CDB)
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue a 164-byte vendor READ_BUFFER; `Ok(true)` on GOOD status, `Ok(false)`
|
||||
/// on CHECK CONDITION (drive refused), `Err(Transport)` on a senseless
|
||||
/// transport failure (dead bus).
|
||||
fn read_is_good(
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
cdb: &[u8; 10],
|
||||
) -> std::result::Result<bool, UnlockError> {
|
||||
let mut buf = [0u8; 164];
|
||||
match scsi.execute(cdb, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
Ok(r) => Ok(r.status == 0),
|
||||
Err(e) => {
|
||||
if e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none() {
|
||||
return Err(UnlockError::Transport);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for Renesas {
|
||||
fn name(&self) -> &'static str {
|
||||
"Renesas"
|
||||
}
|
||||
|
||||
/// Gate on the `0xF1` SAT identity, then the vendor open read `0xB0@0x04`
|
||||
/// (LOAD-BEARING — it's the unlock), then read the Volume ID with the shared
|
||||
/// best-effort `0xAD` reader. `Some` once the open succeeds — the drive
|
||||
/// serves clear content whether or not the bonus VID read did; `None` if it
|
||||
/// isn't a Renesas drive or the open is refused; `Err(Transport)` on a dead
|
||||
/// bus. See docs/renesas-mod.md for the MakeMKV-parity rationale.
|
||||
fn unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
_ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Option<Unlocked>, UnlockError> {
|
||||
if !is_renesas(scsi)? {
|
||||
return Ok(None);
|
||||
}
|
||||
if !Self::vendor_open(scsi)? {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "renesas_open_rejected",
|
||||
"Renesas drive recognized but RB 0xB0@0x04 refused; deferring to next unlocker"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
// The open enabled raw reads (extended-access) — read the VID exactly as
|
||||
// the firmware routes do. Best-effort: a VID miss must not discard the
|
||||
// unlock (only a dead bus propagates via `?`).
|
||||
let vid = crate::vid::read_aacs_vid(scsi)?;
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "renesas_opened",
|
||||
has_vid = vid.is_some(),
|
||||
"Renesas drive opened (RB 0xB0@0x04 GOOD, raw reads enabled)"
|
||||
);
|
||||
Ok(Some(Unlocked { vid, bus_key: None }))
|
||||
}
|
||||
}
|
||||
|
||||
#[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 like a MediaTek drive: ILLEGAL REQUEST with a sense, which is
|
||||
// what distinguishes this from a dead bus. See docs/renesas-mod.md.
|
||||
struct RejectingTransport;
|
||||
impl ScsiTransport for RejectingTransport {
|
||||
fn execute(
|
||||
&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 + vendor-open GOOD → unlocked; the open enables raw reads,
|
||||
// so the standard 0xAD VID read returns a (non-zero) VID best-effort.
|
||||
let out = Renesas::new()
|
||||
.unlock(&mut t, &ctx)
|
||||
.expect("no fault")
|
||||
.expect("renesas → unlocked");
|
||||
assert!(out.vid.is_some(), "opened drive yields a VID via 0xAD");
|
||||
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. See docs/renesas-mod.md.
|
||||
#[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
|
||||
);
|
||||
}
|
||||
|
||||
// A recognized Renesas drive whose A read (RB 0xB0@0x04) is REFUSED but
|
||||
// whose B read (RB 0xB0@0x50) SUCCEEDS after the knock must unlock — and the
|
||||
// knock CDB has to be issued BETWEEN A and B. See docs/renesas-mod.md.
|
||||
#[test]
|
||||
fn knock_runs_between_a_and_b_and_b_opens() {
|
||||
// The exact knock CDB the code issues (pinned wire format).
|
||||
const KNOCK_A5AAAA_CDB: [u8; 10] =
|
||||
[0x3B, 0x02, 0x41, 0xA5, 0xAA, 0xAA, 0x00, 0x00, 0x00, 0x00];
|
||||
|
||||
struct AFailsBSucceeds {
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
impl ScsiTransport for AFailsBSucceeds {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
// Gate 0xF1 → SAT identity.
|
||||
if cdb.get(2) == Some(&0xF1) {
|
||||
let p = renesas_payload();
|
||||
let n = p.len().min(data.len());
|
||||
data[..n].copy_from_slice(&p[..n]);
|
||||
return Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
});
|
||||
}
|
||||
// A read (RB 0xB0@0x04) → refuse with a drive sense.
|
||||
if cdb.get(2) == Some(&0xB0) && cdb.get(3) == Some(&0x00) {
|
||||
let mut sense = [0u8; 32];
|
||||
sense[2] = 0x05; // ILLEGAL REQUEST
|
||||
sense[12] = 0x20;
|
||||
return Err(ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some(sense),
|
||||
});
|
||||
}
|
||||
// B read (RB 0xB0@0x50), the knock (0x3B/0x41), and the 0xAD VID
|
||||
// read all return GOOD so the open + VID read succeed.
|
||||
let n = data.len().min(36);
|
||||
if !data.is_empty() {
|
||||
// Non-zero VID so read_aacs_vid yields Some.
|
||||
for b in data.iter_mut().take(n) {
|
||||
*b = 0x5A;
|
||||
}
|
||||
}
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let mut t = AFailsBSucceeds { cdbs: Vec::new() };
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
|
||||
let out = Renesas::new()
|
||||
.unlock(&mut t, &ctx)
|
||||
.expect("no fault")
|
||||
.expect("B read opens the drive → unlocked");
|
||||
assert_eq!(out.bus_key, None);
|
||||
|
||||
// The knock must appear, between the A read (0xB0@0x04) and the B read
|
||||
// (0xB0@0x50).
|
||||
let a = t
|
||||
.cdbs
|
||||
.iter()
|
||||
.position(|c| c.get(2) == Some(&0xB0) && c.get(3) == Some(&0x00))
|
||||
.expect("A read issued");
|
||||
let knock = t
|
||||
.cdbs
|
||||
.iter()
|
||||
.position(|c| c.as_slice() == KNOCK_A5AAAA_CDB)
|
||||
.expect("knock CDB issued");
|
||||
let b = t
|
||||
.cdbs
|
||||
.iter()
|
||||
.position(|c| c.get(2) == Some(&0xB0) && c.get(3) == Some(&0x50))
|
||||
.expect("B read issued");
|
||||
assert!(a < knock && knock < b, "knock must run between A and B");
|
||||
}
|
||||
|
||||
// A recognized Renesas drive that REFUSES the vendor open read must
|
||||
// defer to the next unlocker, not claim the drive. See docs/renesas-mod.md.
|
||||
#[test]
|
||||
fn open_rejection_defers_to_next_unlocker() {
|
||||
struct GateOkOpenRefused;
|
||||
impl ScsiTransport for GateOkOpenRefused {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
if cdb.get(2) == Some(&0xF1) {
|
||||
// Serve the SAT identity so is_renesas() matches.
|
||||
let p = renesas_payload();
|
||||
let n = p.len().min(data.len());
|
||||
data[..n].copy_from_slice(&p[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
} else {
|
||||
// RB 0xB0@0x04 (the open read) → CHECK CONDITION.
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut t = GateOkOpenRefused;
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
|
||||
assert!(
|
||||
Renesas::new()
|
||||
.unlock(&mut t, &ctx)
|
||||
.expect("open refused → declines, not a hard error")
|
||||
.is_none(),
|
||||
"a refused vendor open must defer to the next unlocker"
|
||||
);
|
||||
}
|
||||
}
|
||||
-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);
|
||||
}
|
||||
}
|
||||
+260
@@ -98,3 +98,263 @@ 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.
|
||||
// See docs/scsi-mock-fixture.md — why this fixture exists
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code)] // a fixture: each helper is used by a subset of the modules
|
||||
pub(crate) mod mock {
|
||||
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)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mock::{MockTransport, Reply};
|
||||
|
||||
/// The fixed-format sense layout the whole crate reads its diagnostics off
|
||||
/// (key at byte 2 low nibble, ASC at 12, ASCQ at 13). This file had no test
|
||||
/// at all, so nothing pinned the offsets.
|
||||
#[test]
|
||||
fn sense_parses_at_the_fixed_format_offsets() {
|
||||
let mut buf = [0u8; 32];
|
||||
buf[2] = 0xF5; // sense key is the LOW nibble only
|
||||
buf[12] = 0x24;
|
||||
buf[13] = 0x01;
|
||||
let s = ScsiSense::from_buf(&buf);
|
||||
assert_eq!(s.sense_key, 0x05);
|
||||
assert_eq!(s.asc, 0x24);
|
||||
assert_eq!(s.ascq, 0x01);
|
||||
assert!(s.is_illegal_request());
|
||||
|
||||
buf[2] = 0x02; // NOT READY
|
||||
assert!(!ScsiSense::from_buf(&buf).is_illegal_request());
|
||||
}
|
||||
|
||||
/// SET CD SPEED encodes the read speed big-endian at bytes 2-3 and leaves
|
||||
/// the write speed at 0xFFFF.
|
||||
#[test]
|
||||
fn set_cd_speed_encodes_the_read_speed_big_endian() {
|
||||
let cdb = build_set_cd_speed(0x1234);
|
||||
assert_eq!(cdb[0], SCSI_SET_CD_SPEED);
|
||||
assert_eq!([cdb[2], cdb[3]], [0x12, 0x34]);
|
||||
assert_eq!([cdb[4], cdb[5]], [0xFF, 0xFF]);
|
||||
assert_eq!(build_set_cd_speed(0xFFFF)[2..4], [0xFF, 0xFF]);
|
||||
}
|
||||
|
||||
// The fixture 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);
|
||||
}
|
||||
}
|
||||
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
//! 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 firmware or vendor CDB unlock (freemkv Raw Read,
|
||||
//! MT1959, or a Pioneer/Renesas vendor open). Because those routes put the
|
||||
//! drive in the same raw-read state, the VID read is identical across them — so
|
||||
//! it lives here once.
|
||||
//!
|
||||
//! BEST-EFFORT: only a dead bus is an `Err(Transport)`. A CHECK CONDITION, a
|
||||
//! short response, or an all-zero VID all yield `Ok(None)` — a VID miss must
|
||||
//! never discard an unlock that already removed the bus (a key source can still
|
||||
//! supply the key). See docs/freemkv-abi.md for the fixed-length reply rules.
|
||||
|
||||
use crate::UnlockError;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// A senseless transport-failure status is a genuine dead bus, not a drive
|
||||
/// rejection surfaced through a non-conforming transport (`Err` with a sense).
|
||||
fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
|
||||
e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
|
||||
}
|
||||
|
||||
/// Read the AACS VID with the bare `0xAD` fmt `0x80` (valid only after the drive
|
||||
/// is unlocked). `Ok(Some(vid))` on a well-formed non-zero VID; `Ok(None)` for
|
||||
/// any "no VID" outcome (rejected / short / all-zero); `Err(Transport)` only on
|
||||
/// a dead bus.
|
||||
pub(crate) 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)]
|
||||
mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user