mirror of
https://github.com/freemkv/freemkv-unlock.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
25
Commits
v1.7.2-rc2
...
v1.7.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
965c1f984c | ||
|
|
e51fda9bcd | ||
|
|
35cb630af9 | ||
|
|
e6786692b3 | ||
|
|
8caf9e7b69 | ||
|
|
b77e7e8aac | ||
|
|
94471641e3 | ||
|
|
c7e7aa63a0 | ||
|
|
89861e6f40 | ||
|
|
eef8e958fc | ||
|
|
d6607ba963 | ||
|
|
89f202edbc | ||
|
|
e107c699d1 | ||
|
|
d257d40de9 | ||
|
|
56c69e8877 | ||
|
|
2c9d76235d | ||
|
|
1a916f0855 | ||
|
|
609f3bc0bc | ||
|
|
58c667314c | ||
|
|
98ef26f78c | ||
|
|
93d9517964 | ||
|
|
64a53b8349 | ||
|
|
f434298e0d | ||
|
|
a18bc90164 | ||
|
|
79f3324488 |
+17
-14
@@ -10,6 +10,11 @@ on:
|
||||
branches: [main, dev, qa]
|
||||
pull_request:
|
||||
|
||||
# Rapid dev pushes supersede their own in-progress runs instead of stacking.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# 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
|
||||
@@ -37,7 +42,7 @@ jobs:
|
||||
outputs:
|
||||
v: ${{ steps.msrv.outputs.v }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v5
|
||||
- id: msrv
|
||||
shell: bash
|
||||
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
|
||||
@@ -51,7 +56,7 @@ jobs:
|
||||
changelog:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v5
|
||||
- name: Require a CHANGELOG entry for the crate version on main
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -72,7 +77,7 @@ jobs:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v5
|
||||
# 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
|
||||
@@ -95,7 +100,7 @@ jobs:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
@@ -117,7 +122,7 @@ jobs:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
@@ -142,7 +147,7 @@ jobs:
|
||||
deny:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: EmbarkStudios/cargo-deny-action@v2
|
||||
with:
|
||||
@@ -162,14 +167,13 @@ jobs:
|
||||
# to agree is what a release candidate is for.
|
||||
#
|
||||
# `if` SKIPS the job (it does not queue). A queued job would be far worse
|
||||
# than a slow one: release.sh's CI gate refuses while any run for the
|
||||
# commit is still in progress, so a never-scheduled job blocks releases
|
||||
# silently -- see the note on real-media in qa.yml.
|
||||
# than a slow one: a never-scheduled job leaves the run pending forever, so
|
||||
# the branch's required checks never report complete.
|
||||
needs: resolve-msrv
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
@@ -188,14 +192,13 @@ jobs:
|
||||
# to agree is what a release candidate is for.
|
||||
#
|
||||
# `if` SKIPS the job (it does not queue). A queued job would be far worse
|
||||
# than a slow one: release.sh's CI gate refuses while any run for the
|
||||
# commit is still in progress, so a never-scheduled job blocks releases
|
||||
# silently -- see the note on real-media in qa.yml.
|
||||
# than a slow one: a never-scheduled job leaves the run pending forever, so
|
||||
# the branch's required checks never report complete.
|
||||
needs: resolve-msrv
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
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.
|
||||
|
||||
# Thin caller → the canonical reusable guard in freemkv/.github (one definition
|
||||
# for every repo). Fork PRs still run it; same-repo pushes aren't double-run.
|
||||
on: [push, pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
comment-guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Run comment-guard
|
||||
run: python3 ci/comment-guard.py
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||
uses: freemkv/.github/.github/workflows/guard-comment.yml@v1
|
||||
|
||||
@@ -1,41 +1,14 @@
|
||||
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.
|
||||
|
||||
# Thin caller → the canonical reusable guard in freemkv/.github.
|
||||
on:
|
||||
push:
|
||||
branches: [dev, qa, main]
|
||||
pull_request:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
identity:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
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"
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||
uses: freemkv/.github/.github/workflows/guard-identity.yml@v1
|
||||
|
||||
@@ -1,35 +1,11 @@
|
||||
name: leak-guard
|
||||
|
||||
# Self-contained public-repo leak gate. Public CI cannot reach the private
|
||||
# tooling, so this encodes only the generic net: internal-infra references,
|
||||
# tracked CLAUDE.md/.claude paths, and AI-attribution in commit messages.
|
||||
# No project-specific reverse-engineering vocabulary lives here.
|
||||
|
||||
# Thin caller → the canonical reusable guard in freemkv/.github.
|
||||
on: [push, pull_request]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
leak-guard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Compute commit range
|
||||
id: range
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
base="${{ github.event.pull_request.base.sha }}"
|
||||
head="${{ github.event.pull_request.head.sha }}"
|
||||
echo "range=$base..$head" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
before="${{ github.event.before }}"
|
||||
after="${{ github.sha }}"
|
||||
# New branch / first push: github.event.before is all-zeros.
|
||||
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "range=$after" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "range=$before..$after" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
- name: Run leak-guard
|
||||
run: bash ci/leak-guard.sh "${{ steps.range.outputs.range }}"
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||
uses: freemkv/.github/.github/workflows/guard-leak.yml@v1
|
||||
|
||||
@@ -15,14 +15,18 @@ name: qa
|
||||
# one being released, which is the exact failure this branch exists to prevent.
|
||||
#
|
||||
# What this gate CANNOT cover: `disc://` and real `iso://` need physical media,
|
||||
# and no hosted runner has an optical drive or the image hoard. Those run on a
|
||||
# self-hosted runner (see the media job at the end) and are the one leg that
|
||||
# stays on hardware.
|
||||
# and no hosted runner has an optical drive or the image hoard. That coverage
|
||||
# lives in freemkv's qa.yml (the real-media EC2 matrix), not in this file.
|
||||
on:
|
||||
push:
|
||||
branches: [qa]
|
||||
workflow_dispatch:
|
||||
|
||||
# A re-push to qa supersedes its own in-progress run instead of stacking.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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;
|
||||
@@ -32,7 +36,7 @@ jobs:
|
||||
outputs:
|
||||
v: ${{ steps.msrv.outputs.v }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v5
|
||||
- id: msrv
|
||||
shell: bash
|
||||
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
|
||||
@@ -56,7 +60,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Stamp the next rc
|
||||
@@ -85,7 +89,7 @@ jobs:
|
||||
os: ['ubuntu-latest', 'macos-latest']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
@@ -103,7 +107,7 @@ jobs:
|
||||
cross-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
path: freemkv-unlock
|
||||
# clippy runs on stable (matches ci.yml's lint job); build/test stay on
|
||||
@@ -126,7 +130,7 @@ jobs:
|
||||
needs: resolve-msrv
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
|
||||
@@ -1,7 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## [1.7.6] — 2026-09-26
|
||||
|
||||
### Changed
|
||||
|
||||
- firmware: mirror the fw 0.9.2 ABI — `Feature::Bd` (wire id `0x04`) is retired, and `Feature::Uhd` is renamed to `Feature::Unrestricted` (wire id `0x03`). `ALL_FEATURES` shrinks from 6 to 5. Required to talk to firmware 0.9.2. **Breaking**: any downstream code naming `Feature::Bd` or `Feature::Uhd` must be updated.
|
||||
- Version aligned to 1.7.6 for the unified release alongside the freemkv 1.7.6 Linux desktop shell (issue #56).
|
||||
|
||||
## [1.7.5] — 2026-09-23
|
||||
|
||||
### Changed
|
||||
|
||||
- firmware: mirror the fw 0.9.0 ABI — `Ake` (`0x06`) and `Bus` (`0x07`) are retired into a single `Encryption` (`0x06`) lever, so the unlock recipe now sets one flag instead of two and wire id `0x07` is gone. Required to talk to firmware 0.9.0.
|
||||
|
||||
## [1.7.4] — 2026-09-21
|
||||
|
||||
### Maintenance
|
||||
|
||||
- CI consolidated to the central reusable workflows; no functional changes.
|
||||
|
||||
## [1.7.3] — 2026-09-19
|
||||
|
||||
### Changed
|
||||
|
||||
- firmware: mirror the fw 0.8.3 RESET ABI — added `RESET_TO_DEFAULTS` (`0x01`, restore the baked create-time defaults into RAM) and `FirmwareControl::reset_to_defaults()`; `RESET_TO_OEM` (`0xFF`) is now documented as TRUE OEM (RAM passthrough **and** the NV block blanked to all-`0xFF`, so the drive is traceless), and `RESET_TO_FLASH` as marker-gated (a never-saved drive loads the defaults). Wire values unchanged.
|
||||
|
||||
## [1.7.2] — UNRELEASED
|
||||
|
||||
### Fixed
|
||||
|
||||
- `FreemkvUnlocker::set` now issues the vendor SET over the required 64-byte data-in phase (mirroring `FirmwareControl::set`). freemkv firmware 0.8.x aborts a SET sent with no data phase (CHECK CONDITION / Aborted Command), so every drive-feature SET failed — including the load-bearing `Set(Ake, off)` — and the freemkv firmware unlock silently fell back to the AACS/online-key route. Drives on freemkv firmware now unlock via the freemkv route again (hardware-confirmed on BU40N fw 0.8.1).
|
||||
|
||||
### Changed
|
||||
|
||||
- firmware: mirror ABI v2 — `Save` + `RESET`-to-flash/OEM modes, the region/speed value encodings, and the HRL/AKE/BUS `off = unlock` (`0x00`) polarity; `full_unlock`'s default profile flipped to match. Aligns `FirmwareControl` with freemkv firmware 0.8.0.
|
||||
|
||||
Generated
+5
-5
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.7.1"
|
||||
version = "1.7.6"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
@@ -406,16 +406,16 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "freemkv-engine"
|
||||
version = "1.7.1"
|
||||
version = "1.7.6"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "freemkv-i18n"
|
||||
version = "1.7.1"
|
||||
version = "1.7.6"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "freemkv-keysources"
|
||||
version = "1.7.1"
|
||||
version = "1.7.6"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "libfreemkv"
|
||||
version = "1.7.1"
|
||||
version = "1.7.6"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "freemkv-unlock"
|
||||
version = "1.7.1"
|
||||
version = "1.7.6"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
#!/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))
|
||||
@@ -1,120 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# leak-guard.sh — self-contained public-repo leak gate.
|
||||
#
|
||||
# This is the LAST line of defense in CI. It is intentionally self-contained:
|
||||
# public CI cannot reach the private tooling, so this script encodes ONLY the
|
||||
# generic net — internal infrastructure references, agent-context files, and
|
||||
# AI-attribution in commit messages. It deliberately contains NO project-
|
||||
# specific reverse-engineering vocabulary (those words would themselves be a
|
||||
# leak). The richer private scanner stays private.
|
||||
#
|
||||
# Fails (exit 1) if any of the following appear in the repo:
|
||||
# 1. a tracked CLAUDE.md or .claude/ path (agent context — never public),
|
||||
# 2. tracked file content matching the internal-infra net,
|
||||
# 3. a commit message (in the given range) with AI attribution.
|
||||
#
|
||||
# Usage:
|
||||
# leak-guard.sh [<commit-range>]
|
||||
# <commit-range> optional git rev-list range to scan commit messages
|
||||
# (e.g. "abc..def"). If omitted, commit-message scan is
|
||||
# skipped (path + content checks always run).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Absolute path to this script, resolved before any cd, so we can exclude it
|
||||
# from the content scan (it necessarily contains the detection patterns).
|
||||
SELF_ABS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
|
||||
|
||||
REPO="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO"
|
||||
|
||||
fail=0
|
||||
note() { printf ' ✗ %s\n' "$1"; fail=1; }
|
||||
|
||||
# Internal-infra net — GENERIC ONLY. This script ships in the public repo, so
|
||||
# the patterns themselves must not name any org-specific identifier (doing so
|
||||
# would itself leak the infra they guard). We catch the leak *class*:
|
||||
# - RFC1918 private IPv4 ranges (10/8, 172.16/12, 192.168/16),
|
||||
# - private/internal/non-routable TLDs (.internal/.local/.lan/.corp/.invalid),
|
||||
# - docker.internal.
|
||||
# The full org-specific net (literal hostnames, service names, repo paths,
|
||||
# vendor tooling, …) lives ONLY in the private scanner and never ships here.
|
||||
INFRA_RE='\b10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|\b172\.(1[6-9]|2[0-9]|3[01])\.[0-9]{1,3}\.[0-9]{1,3}|\b192\.168\.[0-9]{1,3}\.[0-9]{1,3}|\.internal\b|\.local\b|\.lan\b|\.corp\b|\.invalid\b|docker\.internal'
|
||||
# Home-path net — GENERIC ONLY. Catches an absolute developer home path
|
||||
# committed into a tracked file (a macOS /Users/<user>/… or Linux /home/<user>/…
|
||||
# path). This names NO specific user — it matches the leak *class* (any home
|
||||
# path), so the pattern itself reveals nothing org- or person-specific. A real
|
||||
# leak (e.g. /Users/alice/Developer/x slipping into a public RELEASE.md) trips
|
||||
# this regardless of whose machine it came from. The username segment is a
|
||||
# literal-username class ([A-Za-z0-9._-]) so dynamic/templated paths that build
|
||||
# the user at runtime — shell `/home/$USER/`, doc `/home/<rip>/`, Rust
|
||||
# `/home/{user}/` — do NOT false-positive; only a baked-in literal home leaks.
|
||||
HOMEPATH_RE='/Users/[A-Za-z0-9._-]+/|/home/[A-Za-z0-9._-]+/'
|
||||
# AI-attribution net (case-insensitive). "claude" matches only as a standalone
|
||||
# word — NOT preceded by a dot/slash/alnum and NOT followed by .md — so legit
|
||||
# mentions of CLAUDE.md / .claude/ in a commit message don't false-positive.
|
||||
ATTR_RE='co-authored-by|generated with|🤖|(?<![.\/A-Za-z0-9])claude(?!\.md)'
|
||||
|
||||
echo "── leak-guard: tracked agent-context paths ──"
|
||||
while IFS= read -r f; do
|
||||
case "$f" in
|
||||
CLAUDE.md|*/CLAUDE.md|.claude|.claude/*|*/.claude|*/.claude/*)
|
||||
note "tracked agent-context file: $f (CLAUDE.md/.claude must never be tracked in a public repo)" ;;
|
||||
esac
|
||||
done < <(git ls-files)
|
||||
|
||||
# Match a PCRE against a file, emitting "LINE: MATCH". The pattern is passed as
|
||||
# an argument (not interpolated into a //) so metacharacters like the "/" in a
|
||||
# path-style token can't break the regex. Reads raw bytes so non-UTF-8 blobs
|
||||
# don't abort the scan.
|
||||
pcre_matches() {
|
||||
perl -e '
|
||||
my ($file, $re) = @ARGV;
|
||||
open(my $fh, "<:raw", $file) or exit 0;
|
||||
my $rx; eval { $rx = qr/$re/i }; exit 0 if $@;
|
||||
while (my $l = <$fh>) { if ($l =~ /$rx/) { print "$.: $&\n"; } }
|
||||
' "$1" "$2" 2>/dev/null
|
||||
}
|
||||
|
||||
# This script's own source necessarily contains the detection patterns (e.g.
|
||||
# the regex tokens in INFRA_RE), so scanning it would always self-flag. Skip it.
|
||||
SELF="$(git ls-files --full-name -- "$SELF_ABS" 2>/dev/null | head -1)"
|
||||
|
||||
echo "── leak-guard: internal-infra references in tracked files ──"
|
||||
while IFS= read -r f; do
|
||||
case "$f" in *.png|*.jpg|*.jpeg|*.ico|*.gif|*.bin|*.crate|*.gz|*.zip|*.pdf) continue ;; esac
|
||||
[ -n "$SELF" ] && [ "$f" = "$SELF" ] && continue
|
||||
[ -f "$f" ] || continue
|
||||
while IFS= read -r hit; do
|
||||
[ -z "$hit" ] && continue
|
||||
note "internal-infra reference: $f:$hit"
|
||||
done < <(pcre_matches "$f" "$INFRA_RE")
|
||||
while IFS= read -r hit; do
|
||||
[ -z "$hit" ] && continue
|
||||
note "[HOME-PATH] absolute home path: $f:$hit (no local home path may be committed to a public repo)"
|
||||
done < <(pcre_matches "$f" "$HOMEPATH_RE")
|
||||
done < <(git ls-files)
|
||||
|
||||
RANGE="${1:-}"
|
||||
if [ -n "$RANGE" ]; then
|
||||
echo "── leak-guard: AI-attribution in commit messages ($RANGE) ──"
|
||||
while IFS= read -r sha; do
|
||||
[ -z "$sha" ] && continue
|
||||
msg="$(git log -1 --format='%B' "$sha" 2>/dev/null || true)"
|
||||
# Pass the pattern as an argument (not interpolated into a //) so the
|
||||
# lookbehind char class and "/" don't break the regex.
|
||||
hit="$(printf '%s' "$msg" | perl -e '
|
||||
my $re = $ARGV[0]; my $rx = qr/$re/i;
|
||||
while (my $l = <STDIN>) { if ($l =~ /($rx)/) { print "$1\n"; last; } }
|
||||
' "$ATTR_RE" | head -1 || true)"
|
||||
[ -n "$hit" ] && note "commit ${sha:0:12}: message contains \"$hit\" (owner rule: zero AI attribution, ever)"
|
||||
done < <(git rev-list "$RANGE" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "✗ leak-guard: blocking finding(s) above — DO NOT MERGE/PUBLISH"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ leak-guard: clean"
|
||||
+27
-9
@@ -360,7 +360,8 @@ mod tests {
|
||||
};
|
||||
// A minimal freemkv drive: IDENTITY→magic, SET updates state, GET reads it.
|
||||
struct FwDrive {
|
||||
ake: u8,
|
||||
encryption: u8,
|
||||
hrl: u8,
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
impl ScsiTransport for FwDrive {
|
||||
@@ -379,14 +380,24 @@ mod tests {
|
||||
resp.extend(std::iter::repeat_n(STATE_PASSTHROUGH, ALL_FEATURES.len()));
|
||||
n = resp.len().min(data.len());
|
||||
data[..n].copy_from_slice(&resp[..n]);
|
||||
} else if cdb[CDB_VERB] == Verb::Set as u8 && cdb[CDB_FEATURE] == Feature::Ake as u8
|
||||
} else if cdb[CDB_VERB] == Verb::Set as u8
|
||||
&& cdb[CDB_FEATURE] == Feature::Encryption as u8
|
||||
{
|
||||
self.ake = cdb[CDB_STATE];
|
||||
self.encryption = cdb[CDB_STATE];
|
||||
} else if cdb[CDB_VERB] == Verb::Set as u8 && cdb[CDB_FEATURE] == Feature::Hrl as u8
|
||||
{
|
||||
self.hrl = cdb[CDB_STATE];
|
||||
} else if cdb[CDB_VERB] == Verb::Get as u8
|
||||
&& cdb[CDB_FEATURE] == Feature::Ake as u8
|
||||
&& cdb[CDB_FEATURE] == Feature::Encryption as u8
|
||||
&& !data.is_empty()
|
||||
{
|
||||
data[0] = self.ake;
|
||||
data[0] = self.encryption;
|
||||
n = 1;
|
||||
} else if cdb[CDB_VERB] == Verb::Get as u8
|
||||
&& cdb[CDB_FEATURE] == Feature::Hrl as u8
|
||||
&& !data.is_empty()
|
||||
{
|
||||
data[0] = self.hrl;
|
||||
n = 1;
|
||||
}
|
||||
Ok(crate::scsi::ScsiResult {
|
||||
@@ -397,16 +408,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let mut t = FwDrive {
|
||||
ake: STATE_PASSTHROUGH,
|
||||
encryption: STATE_PASSTHROUGH,
|
||||
hrl: STATE_PASSTHROUGH,
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
|
||||
u.maybe_arm(&mut t).expect("armed");
|
||||
assert_eq!(t.ake, STATE_OFF, "the recipe nulled the AKE (Ake=off)");
|
||||
// IDENTITY, then Set(Ake,off), then a verifying Get(Ake).
|
||||
assert_eq!(t.hrl, STATE_OFF, "the recipe skipped revocation (Hrl=off)");
|
||||
assert_eq!(
|
||||
t.encryption, STATE_OFF,
|
||||
"the recipe disabled encryption (Encryption=off)"
|
||||
);
|
||||
// IDENTITY, then Set(Hrl,off)+verify, then Set(Encryption,off)+verify.
|
||||
assert_eq!(t.cdbs[0][CDB_VERB], Verb::Identity as u8);
|
||||
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Ake, STATE_OFF));
|
||||
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(t.cdbs[2][CDB_VERB], Verb::Get as u8);
|
||||
assert_eq!(t.cdbs[3], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
assert_eq!(t.cdbs[4][CDB_VERB], Verb::Get as u8);
|
||||
}
|
||||
|
||||
/// With no host certs there is nothing to authenticate with → `Ok(false)`,
|
||||
|
||||
+13
-19
@@ -14,7 +14,7 @@ use crate::scsi::{DataDirection, ScsiTransport};
|
||||
|
||||
// Issue ONE CSS bus-auth CDB, honouring the transport contract: treats a
|
||||
// non-zero status as CssAuthFailed and checks bytes_transferred >= min_bytes
|
||||
// rather than trusting an unfilled buffer. See docs/css-mod.md#css_scsi.
|
||||
// rather than trusting an unfilled buffer.
|
||||
fn css_scsi(
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
cdb: &[u8],
|
||||
@@ -202,7 +202,7 @@ impl crate::Unlocker for DvdUnlocker {
|
||||
fn name(&self) -> &'static str {
|
||||
// "DVD" names the medium this bus-auth unlocks, not the CSS scheme:
|
||||
// the bus-auth runs on any DVD regardless of whether the content is
|
||||
// actually scrambled. See docs/css-mod.md#dvdunlocker-name.
|
||||
// actually scrambled.
|
||||
"DVD"
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ impl crate::Unlocker for DvdUnlocker {
|
||||
|
||||
// Probes GET CONFIGURATION current-profile (DVD family 0x0010..=0x001F) so
|
||||
// DvdUnlocker can self-verify against the drive rather than trust the
|
||||
// caller's DiscKind. See docs/css-mod.md#mounted_disc_is_dvd.
|
||||
// caller's DiscKind.
|
||||
fn mounted_disc_is_dvd(
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
) -> std::result::Result<bool, crate::UnlockError> {
|
||||
@@ -300,7 +300,6 @@ fn unlock_css_reads_inner(scsi: &mut dyn ScsiTransport, _lba: u32) -> Result<()>
|
||||
tracing::debug!(target: "freemkv::css", agid, "css unlock: bus authentication ok");
|
||||
// Disc-key REPORT KEY: best-effort only, for firmware that ties part of
|
||||
// its read-unlock to it; bytes are unused and failure is non-fatal.
|
||||
// See docs/css-mod.md#disc-key-report-key (7014 bug history).
|
||||
if let Err(e) = read_disc_key(scsi, agid) {
|
||||
tracing::debug!(target: "freemkv::css", error_code = e.code(), "css unlock: disc-key REPORT KEY skipped (non-fatal)");
|
||||
}
|
||||
@@ -311,7 +310,7 @@ fn unlock_css_reads_inner(scsi: &mut dyn ScsiTransport, _lba: u32) -> Result<()>
|
||||
// ── Step 1: Bus Authentication ────────────────────────────────────────────
|
||||
|
||||
// Runs the CSS bus-auth handshake to set ASF=1 (invalidate AGIDs, allocate,
|
||||
// challenge/response). Returns the AGID; no bus key is derived. See docs/css-mod.md.
|
||||
// challenge/response). Returns the AGID; no bus key is derived.
|
||||
fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result<u8> {
|
||||
// Invalidate all AGIDs via REPORT KEY format 0x3F. This is also what makes
|
||||
// an abandoned AGID self-heal — which is not a reason to abandon one.
|
||||
@@ -438,7 +437,7 @@ fn authenticate_with_agid(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()>
|
||||
// ── Step 2: Disc Key ──────────────────────────────────────────────────────
|
||||
|
||||
// Issues READ DVD STRUCTURE format 0x02 (opcode 0xAD) purely for its
|
||||
// bus-auth side effect; returned bytes are unused. See docs/css-mod.md#read_disc_key.
|
||||
// bus-auth side effect; returned bytes are unused.
|
||||
fn read_disc_key(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> {
|
||||
// READ DVD STRUCTURE, format 0x02 (disc key), 2048+4 bytes
|
||||
let alloc_len: u16 = 2048 + 4;
|
||||
@@ -464,7 +463,7 @@ fn read_disc_key(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> {
|
||||
fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
|
||||
// key_type indexes PERM_CHALLENGE ([_;3]); variant indexes VARIANTS/
|
||||
// PERM_VARIANT ([_;32]). Real (not debug-only) asserts catch an OOB index even
|
||||
// in release; cost is nil on this once-per-disc path. See docs/css-mod.md.
|
||||
// in release; cost is nil on this once-per-disc path.
|
||||
assert!(key_type < 3, "crypt_key: key_type out of range");
|
||||
assert!((variant as usize) < 32, "crypt_key: variant out of range");
|
||||
let perm = &PERM_CHALLENGE[key_type];
|
||||
@@ -620,7 +619,7 @@ mod tests {
|
||||
|
||||
// SECURITY REGRESSION GUARD: scans source files for a `tracing` field
|
||||
// binding a forbidden key name to a value expression (only a string
|
||||
// literal or `_fp` field is allowed). See docs/css-mod.md#key-guard.
|
||||
// literal or `_fp` field is allowed).
|
||||
#[test]
|
||||
fn no_key_bytes_in_instrumentation() {
|
||||
use std::path::Path;
|
||||
@@ -776,7 +775,7 @@ mod tests {
|
||||
// ── CSS constant-table integrity ───────────────────────────────────────
|
||||
|
||||
// Each PERM_CHALLENGE row must be a permutation of indices 0..10; a
|
||||
// non-permutation would drop/duplicate bytes. See docs/css-mod.md#perm-challenge-rows.
|
||||
// non-permutation would drop/duplicate bytes.
|
||||
#[test]
|
||||
fn perm_challenge_rows_are_permutations() {
|
||||
for (row, perm) in PERM_CHALLENGE.iter().enumerate() {
|
||||
@@ -795,7 +794,6 @@ mod tests {
|
||||
|
||||
// Each PERM_VARIANT row must map the 32 variants to 32 distinct 5-bit
|
||||
// values; a collision would make two variants indistinguishable.
|
||||
// See docs/css-mod.md#perm-variant-rows.
|
||||
#[test]
|
||||
fn perm_variant_rows_are_permutations_of_0_31() {
|
||||
for (row, perm) in PERM_VARIANT.iter().enumerate() {
|
||||
@@ -816,7 +814,7 @@ mod tests {
|
||||
// ── crypt_key behaviour ────────────────────────────────────────────────
|
||||
|
||||
// crypt_key's result must depend on every challenge byte: flipping any
|
||||
// single byte must change the output. See docs/css-mod.md#crypt-key-byte-dependence.
|
||||
// single byte must change the output.
|
||||
#[test]
|
||||
fn crypt_key_depends_on_every_challenge_byte() {
|
||||
let base: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
@@ -834,7 +832,7 @@ mod tests {
|
||||
|
||||
// crypt_key(0, v, ..) must be distinct for each of the 32 variants:
|
||||
// bus-auth brute-forces the variant by matching against key1, so a
|
||||
// collision could select the wrong one. See docs/css-mod.md.
|
||||
// collision could select the wrong one.
|
||||
#[test]
|
||||
fn crypt_key_type0_distinct_per_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
@@ -851,7 +849,6 @@ mod tests {
|
||||
|
||||
// crypt_key's `key_type < 3` debug_assert must fire for key_type 3
|
||||
// (which would otherwise index PERM_CHALLENGE out of bounds).
|
||||
// See docs/css-mod.md#crypt-key-preconditions.
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn crypt_key_rejects_out_of_range_key_type() {
|
||||
@@ -861,7 +858,6 @@ mod tests {
|
||||
|
||||
// crypt_key's `variant < 32` debug_assert must fire for variant 32
|
||||
// (which would otherwise index VARIANTS/PERM_VARIANT out of bounds).
|
||||
// See docs/css-mod.md#crypt-key-preconditions.
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn crypt_key_rejects_out_of_range_variant() {
|
||||
@@ -895,7 +891,6 @@ mod tests {
|
||||
|
||||
// The key-format field is masked to 6 bits so a format with high bits
|
||||
// set (e.g. 0xFF) cannot corrupt the AGID bits of byte 10.
|
||||
// See docs/css-mod.md#cdb-builders.
|
||||
#[test]
|
||||
fn report_key_cdb_masks_format_to_6_bits() {
|
||||
let cdb = report_key_cdb(0, 0xFF, 8);
|
||||
@@ -904,7 +899,7 @@ mod tests {
|
||||
|
||||
// send_key_cdb encodes a 12-byte MMC SEND KEY (opcode 0xA3) CDB with the
|
||||
// parameter-list length at bytes 8-9 (big-endian) and AGID/format at
|
||||
// byte 10. See docs/css-mod.md#cdb-builders.
|
||||
// byte 10.
|
||||
#[test]
|
||||
fn send_key_cdb_matches_mmc_layout() {
|
||||
let cdb = send_key_cdb(0b11, 0x03, 0x000C); // AGID=3, format=3, param_len=12
|
||||
@@ -921,7 +916,6 @@ mod tests {
|
||||
|
||||
// Allocation length > 255 must split across bytes 8 (high) and 9 (low)
|
||||
// as a 16-bit big-endian field, e.g. 0x0804 (the disc-key block size).
|
||||
// See docs/css-mod.md#cdb-builders.
|
||||
#[test]
|
||||
fn report_key_cdb_alloc_len_is_16bit_big_endian() {
|
||||
let cdb = report_key_cdb(0, 0x00, 0x0804);
|
||||
@@ -1012,7 +1006,7 @@ mod tests {
|
||||
|
||||
// Defect-7 regression: a transport fault on the first probe command
|
||||
// must abort as Transport, not fall through to NotApplicable (which
|
||||
// let the consumer keep probing a dead bus). See docs/css-mod.md.
|
||||
// let the consumer keep probing a dead bus).
|
||||
#[test]
|
||||
fn transport_fault_probing_for_a_dvd_aborts() {
|
||||
let id = DriveId::default();
|
||||
@@ -1039,7 +1033,7 @@ mod tests {
|
||||
|
||||
// Defect-2 regression: a DVD is mounted, then the bus dies mid
|
||||
// bus-auth — must abort as Transport, not collapse to CssAuthFailed /
|
||||
// NotApplicable. See docs/css-mod.md.
|
||||
// NotApplicable.
|
||||
#[test]
|
||||
fn transport_fault_during_bus_auth_aborts() {
|
||||
let id = DriveId::default();
|
||||
|
||||
+190
-99
@@ -31,7 +31,9 @@
|
||||
//!
|
||||
//! Every feature defaults to [`STATE_PASSTHROUGH`] (firmware does not touch the
|
||||
//! subsystem → byte-identical to OEM); [`Verb::Reset`] with [`RESET_TO_OEM`]
|
||||
//! returns every feature to passthrough, while [`RESET_TO_FLASH`] reloads the last
|
||||
//! returns every feature to passthrough (and, firmware ≥0.8.3, blanks the NV block
|
||||
//! so the drive is traceless), [`RESET_TO_DEFAULTS`] restores the baked create-time
|
||||
//! defaults, while [`RESET_TO_FLASH`] reloads the last
|
||||
//! [`Verb::Save`]d config block.
|
||||
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
@@ -42,8 +44,13 @@ use crate::scsi::{DataDirection, ScsiTransport};
|
||||
pub const READ_BUFFER_OPCODE: u8 = 0x3C;
|
||||
/// The freemkv sub-command mode at `cdb[1]`; OEM rejects modes `>= 0x0E`.
|
||||
pub const KNOCK_MODE: u8 = 0x0E;
|
||||
/// Two-byte knock at `cdb[2..4]` ("C0DE").
|
||||
/// Two-byte knock at `cdb[2..4]` ("C0DE") — the *safe* knock carried by every
|
||||
/// durable verb (Identity/Set/Get/Reset/DumpAll/Save/FlashWrite).
|
||||
pub const KNOCK: [u8; 2] = [0xC0, 0xDE];
|
||||
/// Two-byte knock at `cdb[2..4]` — the *debug* knock, distinct from [`KNOCK`]
|
||||
/// by construction. The only frame the fw honours for the debug-only verbs
|
||||
/// ([`Verb::Call`], [`Verb::Poke`], [`Verb::Reboot`]).
|
||||
pub const DEBUG_KNOCK: [u8; 2] = [0xDE, 0xB9];
|
||||
/// Response-framing magic leading the [`Verb::Identity`] reply (`b"freemkv"`).
|
||||
pub const RESP_MAGIC: &[u8] = b"freemkv";
|
||||
|
||||
@@ -114,11 +121,21 @@ pub const REGION_FREE: u8 = 0x0F;
|
||||
|
||||
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): reload the saved flash
|
||||
/// config block into the RAM feature-state table, discarding un-saved RAM changes
|
||||
/// — the non-destructive "revert to last SAVE".
|
||||
/// — the non-destructive "revert to last SAVE". Marker-gated in firmware ≥0.8.3: a
|
||||
/// never-saved drive loads the baked create-time defaults instead.
|
||||
pub const RESET_TO_FLASH: u8 = 0x00;
|
||||
|
||||
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): force every feature to
|
||||
/// [`STATE_PASSTHROUGH`] (OEM behaviour everywhere), regardless of the saved config.
|
||||
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): restore the baked
|
||||
/// per-create **defaults** (the create-time flags, e.g. UHD/BD on) into the RAM
|
||||
/// feature-state table. RAM-only — does not touch flash. This is what a never-saved
|
||||
/// drive boots to. Firmware ≥0.8.3.
|
||||
pub const RESET_TO_DEFAULTS: u8 = 0x01;
|
||||
|
||||
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): TRUE OEM. Forces every
|
||||
/// feature to [`STATE_PASSTHROUGH`] in RAM **and** (firmware ≥0.8.3) writes the NV
|
||||
/// config block all-`0xFF` — marker included — so the drive is byte-for-byte like a
|
||||
/// never-saved/never-configured drive (no trace it was ever set). Because the marker
|
||||
/// is then `0xFF`, the NEXT boot loads the baked defaults, exactly like a fresh drive.
|
||||
pub const RESET_TO_OEM: u8 = 0xFF;
|
||||
|
||||
/// The verb selector in `cdb[4]`. These numeric values ARE the wire protocol.
|
||||
@@ -133,18 +150,47 @@ pub enum Verb {
|
||||
Get = 0x03,
|
||||
/// Restore the RAM feature-state table. The mode rides in `cdb[6]` (the state
|
||||
/// slot): [`RESET_TO_FLASH`] (`0x00`) reloads the saved flash config block back
|
||||
/// into RAM (discarding un-saved changes), [`RESET_TO_OEM`] (`0xFF`) forces every
|
||||
/// feature to [`STATE_PASSTHROUGH`] (out-of-the-box behaviour). Ignores feature.
|
||||
/// into RAM (discarding un-saved changes; marker-gated in fw ≥0.8.3),
|
||||
/// [`RESET_TO_DEFAULTS`] (`0x01`) restores the baked create-time defaults, and
|
||||
/// [`RESET_TO_OEM`] (`0xFF`) forces every feature to [`STATE_PASSTHROUGH`] AND
|
||||
/// blanks the NV block (traceless, fw ≥0.8.3). Ignores feature.
|
||||
Reset = 0x04,
|
||||
/// Diagnostic RAM peek: [`MEMREAD_LEN`] bytes at the 32-bit address in
|
||||
/// `cdb[5..9]` (big-endian).
|
||||
DumpAll = 0x09,
|
||||
/// **TEMPORARY** diagnostic probe: program a single byte to an allowlisted
|
||||
/// flash offset via the OEM PROGRAM routine. `CDB[5..9]` = 32-bit flash offset
|
||||
/// (big-endian); `CDB[9]` = value byte. The firmware refuses any offset outside
|
||||
/// the compile-time allowlist window (`0x1EA000..0x1EB000` — the corpus-proven-
|
||||
/// unlocked NV/SAVE block) with a distinct `"REFU"` status word (PROGRAM not
|
||||
/// called). Reply: 4-byte offset echo (BE) + 4-byte PROGRAM/REFU status (BE).
|
||||
/// Not part of the durable feature grammar; subsumed by [`Verb::Save`].
|
||||
FlashWrite = 0x0A,
|
||||
/// Persist the whole RAM feature-state table to the flash config block. This is
|
||||
/// the ONLY verb that writes the config to flash: [`Verb::Set`] and
|
||||
/// [`Verb::Reset`] touch RAM only, so a host's changes stay volatile until a
|
||||
/// `Save` commits them (and survive a power cycle only once saved). Ignores
|
||||
/// feature/state.
|
||||
Save = 0x0B,
|
||||
/// **Debug-knock only.** Interactive fw-exploration `blx target` primitive.
|
||||
/// `CDB[5..9]` = 32-bit target VA (big-endian); `CDB[9]` = single u8 that
|
||||
/// is loaded into r0 as the sole register argument. Refused under the safe
|
||||
/// [`KNOCK`]; only executed under [`DEBUG_KNOCK`]. See [`build_call_cdb`].
|
||||
Call = 0x0C,
|
||||
/// **Debug-knock only.** Poke a single byte to an arbitrary address.
|
||||
/// `CDB[5..9]` = 32-bit target address (big-endian, RAM or MMIO);
|
||||
/// `CDB[9]` = byte value to store. Refused under the safe [`KNOCK`]; only
|
||||
/// executed under [`DEBUG_KNOCK`]. See [`build_poke_cdb`].
|
||||
Poke = 0x0D,
|
||||
/// **Debug-knock only.** Invokes the firmware's boot function entry with
|
||||
/// r0=4 to force the cold path (BSS clear + C-runtime data init + full
|
||||
/// post-init), recovering a wedged drive without a power cycle. The target
|
||||
/// briefly returns Aborted Command mid-reboot, then comes back ~5s later
|
||||
/// with the safe-knock verb chain re-armed to its power-on defaults. The
|
||||
/// target VA is baked into the handler at build time, so no CDB arguments
|
||||
/// are carried beyond the verb byte. Refused under the safe [`KNOCK`];
|
||||
/// only executed under [`DEBUG_KNOCK`]. See [`build_reboot_cdb`].
|
||||
Reboot = 0x0F,
|
||||
}
|
||||
|
||||
/// The feature selector in `cdb[5]` for [`Verb::Set`] / [`Verb::Get`]. These
|
||||
@@ -164,44 +210,35 @@ pub enum Feature {
|
||||
/// [`REGION_BD_A`]/`_B`/`_C` (`0x0A`/`0x0B`/`0x0C`) = force BD region A/B/C;
|
||||
/// [`REGION_FREE`] (`0x0F`) = region-free (any disc plays).
|
||||
Region = 0x02,
|
||||
/// UHD (AACS 2.0) capability gate. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM (as
|
||||
/// shipped); [`STATE_OFF`] (`0x00`) = No (refuse UHD discs); [`STATE_ON`]
|
||||
/// (`0x01`) = Yes (accept UHD — the mode gate is neutralized so the drive
|
||||
/// engages UHD discs). On the byte-extraction classifier (e.g. BU40N) the No
|
||||
/// (OFF) leg currently reads back but behaves == OEM (boot-safe no-op, pending
|
||||
/// deeper RE) — do NOT treat OFF as a proven UHD disable there.
|
||||
Uhd = 0x03,
|
||||
/// Blu-ray (AACS 1.0) capability gate. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM (BD
|
||||
/// engaged as shipped); [`STATE_OFF`] (`0x00`) = No (force-refuse BD discs — the
|
||||
/// drive raises its own `6F` refusal sense); [`STATE_ON`] (`0x01`) = Yes (accept
|
||||
/// BD — an enable direction, not a no-op).
|
||||
Bd = 0x04,
|
||||
/// Unrestricted (BD/UHD) capability widen gate — fw 0.9.2 unified the former
|
||||
/// separate BD/UHD accept gates. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM;
|
||||
/// [`STATE_OFF`] (`0x00`) = No (currently no-op on byte-extraction classifiers
|
||||
/// like BU40N, pending RE); [`STATE_ON`] (`0x01`) = Yes (accept BD/UHD).
|
||||
/// Wire id `0x04` (was `Bd`) is retired: unused/reserved, fw treats as no-op.
|
||||
Unrestricted = 0x03,
|
||||
/// Host Revocation List handling on the cert path. [`STATE_PASSTHROUGH`]
|
||||
/// (`0xFF`) = OEM enforce; [`STATE_OFF`] (`0x00`) = off (skip the HRL lookup —
|
||||
/// revoked certs accepted, non-destructive, the unlock direction); [`STATE_ON`]
|
||||
/// (`0x01`) = on (enforce the HRL).
|
||||
Hrl = 0x05,
|
||||
/// Drive-host AKE. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM real handshake;
|
||||
/// [`STATE_OFF`] (`0x00`) = off (null/bypass — the drive acts pre-authenticated,
|
||||
/// the unlock direction); [`STATE_ON`] (`0x01`) = on (require the real handshake).
|
||||
Ake = 0x06,
|
||||
/// In-transit AACS bus encryption. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM (bus
|
||||
/// encryption on); [`STATE_OFF`] (`0x00`) = off (de-bussed — content returned
|
||||
/// with no bus encryption, the unlock direction); [`STATE_ON`] (`0x01`) = on
|
||||
/// (bus encryption).
|
||||
Bus = 0x07,
|
||||
/// The one master encryption/cert bypass. [`STATE_OFF`] (`0x00`) = every
|
||||
/// drive-side encryption/cert requirement gone (cert-AKE relaxed, bus-wrap
|
||||
/// off — what used to need a cert now doesn't). [`STATE_PASSTHROUGH`] (`0xFF`)
|
||||
/// = OEM behavior (real handshake, bus-encrypted content). [`STATE_ON`]
|
||||
/// (`0x01`) = on (require the real handshake). Wire id `0x07` is retired
|
||||
/// (was `Bus`, empirically proved inert as a separate datapath lever on
|
||||
/// BU40N/MT1959 — the single Encryption lever de-busses on its own).
|
||||
Encryption = 0x06,
|
||||
}
|
||||
|
||||
/// The set of all features, in the canonical order the IDENTITY feature-state
|
||||
/// table serialises them (and the order [`FirmwareControl::states`] probes).
|
||||
pub const ALL_FEATURES: [Feature; 7] = [
|
||||
pub const ALL_FEATURES: [Feature; 5] = [
|
||||
Feature::Speed,
|
||||
Feature::Region,
|
||||
Feature::Uhd,
|
||||
Feature::Bd,
|
||||
Feature::Unrestricted,
|
||||
Feature::Hrl,
|
||||
Feature::Ake,
|
||||
Feature::Bus,
|
||||
Feature::Encryption,
|
||||
];
|
||||
|
||||
/// A Blu-ray region for [`FirmwareControl::force_region_bd`].
|
||||
@@ -267,8 +304,9 @@ pub fn build_get_cdb(feature: Feature) -> [u8; CDB_LEN] {
|
||||
|
||||
/// Build a `RESET` CDB. `mode` rides in the state slot (`cdb[6]`):
|
||||
/// [`RESET_TO_FLASH`] (`0x00`) reloads the saved flash config into RAM,
|
||||
/// [`RESET_TO_OEM`] (`0xFF`) forces every feature to passthrough. Floors its
|
||||
/// data-in at [`MIN_ALLOC_LEN`] for the same HW min-transfer reason as
|
||||
/// [`RESET_TO_DEFAULTS`] (`0x01`) restores the baked create-time defaults, and
|
||||
/// [`RESET_TO_OEM`] (`0xFF`) forces every feature to passthrough (and blanks NV).
|
||||
/// Floors its data-in at [`MIN_ALLOC_LEN`] for the same HW min-transfer reason as
|
||||
/// [`build_set_cdb`].
|
||||
pub fn build_reset_cdb(mode: u8) -> [u8; CDB_LEN] {
|
||||
build_cdb(Verb::Reset, None, Some(mode), MIN_ALLOC_LEN)
|
||||
@@ -301,6 +339,63 @@ pub fn build_memread_cdb(addr: u32) -> [u8; CDB_LEN] {
|
||||
cdb
|
||||
}
|
||||
|
||||
/// Build a 10-byte CDB for [`Verb::Call`]: `blx target(r0)` where `target` is
|
||||
/// packed big-endian in `cdb[5..9]` and the u8 `r0` register argument rides in
|
||||
/// `cdb[9]`. Carries the [`DEBUG_KNOCK`] at `cdb[2..4]` — the ONLY frame the
|
||||
/// fw honours for Call. The handler ORs the thumb bit into the address at
|
||||
/// runtime. r1..r3 are undefined at callee entry.
|
||||
pub fn build_call_cdb(target: u32, r0: u8) -> [u8; CDB_LEN] {
|
||||
let mut cdb = [0u8; CDB_LEN];
|
||||
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
|
||||
cdb[CDB_MODE] = KNOCK_MODE;
|
||||
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&DEBUG_KNOCK);
|
||||
cdb[CDB_VERB] = Verb::Call as u8;
|
||||
cdb[5] = (target >> 24) as u8;
|
||||
cdb[6] = (target >> 16) as u8;
|
||||
cdb[7] = (target >> 8) as u8;
|
||||
cdb[8] = target as u8;
|
||||
cdb[9] = r0;
|
||||
cdb
|
||||
}
|
||||
|
||||
/// Build a 10-byte CDB for [`Verb::Poke`]: write a single byte `val` to the
|
||||
/// arbitrary 32-bit address `target` (RAM or MMIO). Target is packed
|
||||
/// big-endian in `cdb[5..9]` and `val` rides in `cdb[9]`. Carries the
|
||||
/// [`DEBUG_KNOCK`] at `cdb[2..4]`. NO bounds check on the address — this is a
|
||||
/// diagnostic primitive for on-drive state discovery, not a durable verb.
|
||||
pub fn build_poke_cdb(target: u32, val: u8) -> [u8; CDB_LEN] {
|
||||
let mut cdb = [0u8; CDB_LEN];
|
||||
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
|
||||
cdb[CDB_MODE] = KNOCK_MODE;
|
||||
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&DEBUG_KNOCK);
|
||||
cdb[CDB_VERB] = Verb::Poke as u8;
|
||||
cdb[5] = (target >> 24) as u8;
|
||||
cdb[6] = (target >> 16) as u8;
|
||||
cdb[7] = (target >> 8) as u8;
|
||||
cdb[8] = target as u8;
|
||||
cdb[9] = val;
|
||||
cdb
|
||||
}
|
||||
|
||||
/// Build a 10-byte CDB for [`Verb::Reboot`]: force the firmware boot function's
|
||||
/// cold path (soft-reboot the controller). Carries the [`DEBUG_KNOCK`] at
|
||||
/// `cdb[2..4]`. NO arguments are transmitted on the wire — the target VA is
|
||||
/// baked into the emitted handler at build time (per-image, resolved from the
|
||||
/// boot-init signature). Requests a [`MIN_ALLOC_LEN`]-byte data-in like every
|
||||
/// other durable-shape verb: the drive returns Aborted Command mid-reboot
|
||||
/// anyway, so callers should tolerate a rejection on this send and re-probe
|
||||
/// identity after a short delay.
|
||||
pub fn build_reboot_cdb() -> [u8; CDB_LEN] {
|
||||
let mut cdb = [0u8; CDB_LEN];
|
||||
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
|
||||
cdb[CDB_MODE] = KNOCK_MODE;
|
||||
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&DEBUG_KNOCK);
|
||||
cdb[CDB_VERB] = Verb::Reboot as u8;
|
||||
cdb[CDB_ALLOC_LEN] = (MIN_ALLOC_LEN >> 8) as u8;
|
||||
cdb[CDB_ALLOC_LEN + 1] = MIN_ALLOC_LEN as u8;
|
||||
cdb
|
||||
}
|
||||
|
||||
/// Whether a device data response leads with [`RESP_MAGIC`].
|
||||
pub fn verify_response(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(RESP_MAGIC)
|
||||
@@ -344,16 +439,12 @@ pub struct FeatureStates {
|
||||
pub speed: u8,
|
||||
/// [`Feature::Region`] state.
|
||||
pub region: u8,
|
||||
/// [`Feature::Uhd`] state.
|
||||
pub uhd: u8,
|
||||
/// [`Feature::Bd`] state.
|
||||
pub bd: u8,
|
||||
/// [`Feature::Unrestricted`] state.
|
||||
pub unrestricted: u8,
|
||||
/// [`Feature::Hrl`] state.
|
||||
pub hrl: u8,
|
||||
/// [`Feature::Ake`] state.
|
||||
pub ake: u8,
|
||||
/// [`Feature::Bus`] state.
|
||||
pub bus: u8,
|
||||
/// [`Feature::Encryption`] state (the consolidated cert/bus bypass).
|
||||
pub encryption: u8,
|
||||
}
|
||||
|
||||
impl FeatureStates {
|
||||
@@ -362,11 +453,9 @@ impl FeatureStates {
|
||||
FeatureStates {
|
||||
speed: STATE_PASSTHROUGH,
|
||||
region: STATE_PASSTHROUGH,
|
||||
uhd: STATE_PASSTHROUGH,
|
||||
bd: STATE_PASSTHROUGH,
|
||||
unrestricted: STATE_PASSTHROUGH,
|
||||
hrl: STATE_PASSTHROUGH,
|
||||
ake: STATE_PASSTHROUGH,
|
||||
bus: STATE_PASSTHROUGH,
|
||||
encryption: STATE_PASSTHROUGH,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,11 +464,9 @@ impl FeatureStates {
|
||||
match feature {
|
||||
Feature::Speed => self.speed,
|
||||
Feature::Region => self.region,
|
||||
Feature::Uhd => self.uhd,
|
||||
Feature::Bd => self.bd,
|
||||
Feature::Unrestricted => self.unrestricted,
|
||||
Feature::Hrl => self.hrl,
|
||||
Feature::Ake => self.ake,
|
||||
Feature::Bus => self.bus,
|
||||
Feature::Encryption => self.encryption,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,11 +474,9 @@ impl FeatureStates {
|
||||
match feature {
|
||||
Feature::Speed => self.speed = state,
|
||||
Feature::Region => self.region = state,
|
||||
Feature::Uhd => self.uhd = state,
|
||||
Feature::Bd => self.bd = state,
|
||||
Feature::Unrestricted => self.unrestricted = state,
|
||||
Feature::Hrl => self.hrl = state,
|
||||
Feature::Ake => self.ake = state,
|
||||
Feature::Bus => self.bus = state,
|
||||
Feature::Encryption => self.encryption = state,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -436,6 +521,9 @@ pub enum ArmRecipe {
|
||||
BypassBd,
|
||||
/// [`FirmwareControl::arm_bypass_uhd`].
|
||||
BypassUhd,
|
||||
// NOTE: Feature::Unrestricted unifies former BD+UHD gates (fw 0.9.2); recipe
|
||||
// names above name the disc TYPE being ripped, not the retired per-format
|
||||
// feature — see `arm_oem_uhd`/`arm_bypass_uhd`.
|
||||
/// [`FirmwareControl::arm_stealth_oem`].
|
||||
StealthOem,
|
||||
}
|
||||
@@ -453,7 +541,7 @@ const CMD_TIMEOUT_MS: u32 = 5_000;
|
||||
/// # fn demo(scsi: &mut dyn freemkv_unlock::scsi::ScsiTransport) {
|
||||
/// let mut fw = FirmwareControl::new(scsi);
|
||||
/// if fw.is_freemkv().unwrap_or(false) {
|
||||
/// // Real AKE on a UHD disc, revocation ignored, bus de-encrypted:
|
||||
/// // Encryption off on a UHD disc, revocation ignored:
|
||||
/// let _ = fw.arm_oem_uhd();
|
||||
/// }
|
||||
/// # }
|
||||
@@ -528,14 +616,24 @@ impl<'a> FirmwareControl<'a> {
|
||||
self.exec_in(&cdb).map(|_| ())
|
||||
}
|
||||
|
||||
/// Restore every feature to [`STATE_PASSTHROUGH`] (RESET with
|
||||
/// [`RESET_TO_OEM`]). Data-in phase for the same reason as [`set`](Self::set) —
|
||||
/// a no-data RESET is aborted.
|
||||
/// Restore every feature to [`STATE_PASSTHROUGH`] (RESET with [`RESET_TO_OEM`])
|
||||
/// — TRUE OEM: firmware ≥0.8.3 also blanks the NV block so the drive is
|
||||
/// traceless. Data-in phase for the same reason as [`set`](Self::set) — a no-data
|
||||
/// RESET is aborted.
|
||||
pub fn reset(&mut self) -> Result<()> {
|
||||
let cdb = build_reset_cdb(RESET_TO_OEM);
|
||||
self.exec_in(&cdb).map(|_| ())
|
||||
}
|
||||
|
||||
/// Restore the baked create-time defaults into the RAM feature-state table
|
||||
/// (RESET with [`RESET_TO_DEFAULTS`]) — what a never-saved drive boots to; does
|
||||
/// not touch flash. Firmware ≥0.8.3. Data-in phase for the same reason as
|
||||
/// [`reset`](Self::reset).
|
||||
pub fn reset_to_defaults(&mut self) -> Result<()> {
|
||||
let cdb = build_reset_cdb(RESET_TO_DEFAULTS);
|
||||
self.exec_in(&cdb).map(|_| ())
|
||||
}
|
||||
|
||||
/// Reload the saved flash config block back into the RAM feature-state table
|
||||
/// (RESET with [`RESET_TO_FLASH`]), discarding any un-saved RAM changes — the
|
||||
/// non-destructive "revert to last SAVE". Data-in phase for the same reason as
|
||||
@@ -590,39 +688,28 @@ impl<'a> FirmwareControl<'a> {
|
||||
|
||||
// ── Typed setters ────────────────────────────────────────────────────────
|
||||
|
||||
/// Force the UHD (AACS 2.0) capability gate on (drive engages UHD discs).
|
||||
pub fn enable_uhd(&mut self) -> Result<()> {
|
||||
self.set(Feature::Uhd, STATE_ON)
|
||||
/// Force the Unrestricted (BD/UHD) capability gate on (drive engages both
|
||||
/// BD and UHD discs). fw 0.9.2 unified the former separate UHD/BD gates
|
||||
/// into this single lever.
|
||||
pub fn enable_unrestricted(&mut self) -> Result<()> {
|
||||
self.set(Feature::Unrestricted, STATE_ON)
|
||||
}
|
||||
/// Force the UHD capability gate off.
|
||||
pub fn disable_uhd(&mut self) -> Result<()> {
|
||||
self.set(Feature::Uhd, STATE_OFF)
|
||||
}
|
||||
/// Force the Blu-ray (AACS 1.0) capability gate on.
|
||||
pub fn enable_bd(&mut self) -> Result<()> {
|
||||
self.set(Feature::Bd, STATE_ON)
|
||||
}
|
||||
/// Force the Blu-ray capability gate off (drive refuses BD discs). Sends the
|
||||
/// uniform [`STATE_OFF`] (`0x00`) — an unarmed image boots to [`STATE_PASSTHROUGH`]
|
||||
/// (`0xFF`, OEM) via the firmware power-on hook, so `0x00` no longer collides
|
||||
/// with the boot value (the retired `0x02` sentinel is gone).
|
||||
pub fn disable_bd(&mut self) -> Result<()> {
|
||||
self.set(Feature::Bd, STATE_OFF)
|
||||
/// Force the Unrestricted (BD/UHD) capability gate off.
|
||||
pub fn disable_unrestricted(&mut self) -> Result<()> {
|
||||
self.set(Feature::Unrestricted, STATE_OFF)
|
||||
}
|
||||
/// Skip the HRL lookup (revoked certs accepted; non-destructive). The unlock
|
||||
/// direction is now [`STATE_OFF`] (`0x00` = HRL enforcement off).
|
||||
pub fn skip_hrl(&mut self) -> Result<()> {
|
||||
self.set(Feature::Hrl, STATE_OFF)
|
||||
}
|
||||
/// Null the drive-host AKE (drive acts pre-authenticated). The unlock direction
|
||||
/// is now [`STATE_OFF`] (`0x00` = handshake off/bypassed).
|
||||
pub fn null_ake(&mut self) -> Result<()> {
|
||||
self.set(Feature::Ake, STATE_OFF)
|
||||
}
|
||||
/// Turn AACS in-transit bus encryption off (content returned de-bussed). The
|
||||
/// unlock direction is now [`STATE_OFF`] (`0x00` = bus encryption off).
|
||||
pub fn bus_off(&mut self) -> Result<()> {
|
||||
self.set(Feature::Bus, STATE_OFF)
|
||||
/// Disable every drive-side encryption/cert requirement in one shot: the
|
||||
/// drive acts pre-authenticated (no handshake) AND content returns de-bussed
|
||||
/// (no in-transit bus encryption). The consolidated `Encryption` lever
|
||||
/// replaces the old separate `Ake`/`Bus` levers — wire id `0x07` (`Bus`) is
|
||||
/// retired. The unlock direction is [`STATE_OFF`] (`0x00`).
|
||||
pub fn disable_encryption(&mut self) -> Result<()> {
|
||||
self.set(Feature::Encryption, STATE_OFF)
|
||||
}
|
||||
/// Region-free (RPC-1). Sends [`REGION_FREE`] (`0x0F`) — under the migrated
|
||||
/// region scheme `0x01` now means "force DVD region 1", not region-free.
|
||||
@@ -659,33 +746,37 @@ impl<'a> FirmwareControl<'a> {
|
||||
self.set_verify(Feature::Hrl, STATE_OFF)
|
||||
}
|
||||
|
||||
/// **arm_oem_uhd** — OEM-style UHD (AACS 2.0) rip with a REAL AKE.
|
||||
/// **arm_oem_uhd** — OEM-style UHD (AACS 2.0) rip.
|
||||
///
|
||||
/// Sets: `Uhd = on` (mode-gate neutralized so the drive engages the UHD
|
||||
/// disc), `Hrl = skip` (revocation off), `Bus = off` (content de-bussed).
|
||||
/// The host still runs the real AKE. Rips: UHD via the OEM cert path.
|
||||
/// Sets: `Unrestricted = on` (mode-gate neutralized so the drive engages
|
||||
/// the UHD disc), `Hrl = skip` (revocation off), `Encryption = off`
|
||||
/// (content de-bussed — the consolidated cert/bus bypass). Rips: UHD via
|
||||
/// the OEM path.
|
||||
pub fn arm_oem_uhd(&mut self) -> Result<()> {
|
||||
self.set_verify(Feature::Uhd, STATE_ON)?;
|
||||
self.set_verify(Feature::Unrestricted, STATE_ON)?;
|
||||
self.set_verify(Feature::Hrl, STATE_OFF)?;
|
||||
self.set_verify(Feature::Bus, STATE_OFF)
|
||||
self.set_verify(Feature::Encryption, STATE_OFF)
|
||||
}
|
||||
|
||||
/// **arm_bypass_bd** — full-bypass Blu-ray rip (NO host cert needed).
|
||||
///
|
||||
/// Sets: `Ake = null` (drive acts pre-authenticated, so a bare VID read
|
||||
/// returns the volume ID with no cert and no AKE). Rips: BD with no cert.
|
||||
/// Sets: `Encryption = off` (the consolidated cert/bus bypass — drive acts
|
||||
/// pre-authenticated, no handshake, content de-bussed) and `Hrl = skip`
|
||||
/// (revocation off). Rips: BD with no cert.
|
||||
pub fn arm_bypass_bd(&mut self) -> Result<()> {
|
||||
self.set_verify(Feature::Ake, STATE_OFF)
|
||||
self.set_verify(Feature::Hrl, STATE_OFF)?;
|
||||
self.set_verify(Feature::Encryption, STATE_OFF)
|
||||
}
|
||||
|
||||
/// **arm_bypass_uhd** — full-bypass UHD rip (NO host cert needed).
|
||||
///
|
||||
/// Sets: `Uhd = on` (engage the UHD disc), `Ake = null` (skip the
|
||||
/// handshake), `Bus = off` (content de-bussed). Rips: UHD with no cert.
|
||||
/// Sets: `Unrestricted = on` (engage the UHD disc), `Encryption = off`
|
||||
/// (the consolidated cert/bus bypass), and `Hrl = skip` (revocation off).
|
||||
/// Rips: UHD with no cert.
|
||||
pub fn arm_bypass_uhd(&mut self) -> Result<()> {
|
||||
self.set_verify(Feature::Uhd, STATE_ON)?;
|
||||
self.set_verify(Feature::Ake, STATE_OFF)?;
|
||||
self.set_verify(Feature::Bus, STATE_OFF)
|
||||
self.set_verify(Feature::Unrestricted, STATE_ON)?;
|
||||
self.set_verify(Feature::Hrl, STATE_OFF)?;
|
||||
self.set_verify(Feature::Encryption, STATE_OFF)
|
||||
}
|
||||
|
||||
/// **arm_stealth_oem** — return the drive to byte-for-byte OEM behaviour.
|
||||
|
||||
+117
-63
@@ -13,18 +13,32 @@ fn verb_values_match_abi() {
|
||||
assert_eq!(Verb::Get as u8, 0x03);
|
||||
assert_eq!(Verb::Reset as u8, 0x04);
|
||||
assert_eq!(Verb::DumpAll as u8, 0x09);
|
||||
assert_eq!(Verb::FlashWrite as u8, 0x0A);
|
||||
assert_eq!(Verb::Save as u8, 0x0B);
|
||||
assert_eq!(Verb::Call as u8, 0x0C);
|
||||
assert_eq!(Verb::Poke as u8, 0x0D);
|
||||
assert_eq!(Verb::Reboot as u8, 0x0F);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_values_match_abi() {
|
||||
assert_eq!(Feature::Speed as u8, 0x01);
|
||||
assert_eq!(Feature::Region as u8, 0x02);
|
||||
assert_eq!(Feature::Uhd as u8, 0x03);
|
||||
assert_eq!(Feature::Bd as u8, 0x04);
|
||||
assert_eq!(Feature::Unrestricted as u8, 0x03);
|
||||
assert_eq!(Feature::Hrl as u8, 0x05);
|
||||
assert_eq!(Feature::Ake as u8, 0x06);
|
||||
assert_eq!(Feature::Bus as u8, 0x07);
|
||||
assert_eq!(Feature::Encryption as u8, 0x06);
|
||||
// Wire id 0x04 (was `Bd`) is retired — fw 0.9.2 unified BD+UHD into
|
||||
// Unrestricted; the BD slot is now unused/reserved (fw no-ops it).
|
||||
// Wire id 0x07 (was `Bus`) is retired — proven inert on BU40N/MT1959.
|
||||
assert_eq!(ALL_FEATURES.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_knock_distinct_from_safe_knock() {
|
||||
// The fw dispatches Call/Poke ONLY under DEBUG_KNOCK; a typo of a safe verb
|
||||
// vs a debug verb under the wrong knock never crosses the line.
|
||||
assert_ne!(KNOCK, DEBUG_KNOCK);
|
||||
assert_eq!(DEBUG_KNOCK, [0xDE, 0xB9]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -44,6 +58,7 @@ fn state_and_frame_constants_match_abi() {
|
||||
assert_eq!(REGION_DVD_BASE, 0x00);
|
||||
assert_eq!(REGION_FREE, 0x0F);
|
||||
assert_eq!(RESET_TO_FLASH, 0x00);
|
||||
assert_eq!(RESET_TO_DEFAULTS, 0x01);
|
||||
assert_eq!(RESET_TO_OEM, 0xFF);
|
||||
}
|
||||
|
||||
@@ -51,21 +66,22 @@ fn state_and_frame_constants_match_abi() {
|
||||
|
||||
#[test]
|
||||
fn build_set_cdb_exact_bytes() {
|
||||
// SET Ake = ON: verb 02, feature 06, state 01, alloc MIN_ALLOC_LEN (0x0040)
|
||||
// at cdb[7..9] big-endian — the drive aborts a sub-16-byte data-in.
|
||||
// SET Encryption = ON: verb 02, feature 06, state 01, alloc MIN_ALLOC_LEN
|
||||
// (0x0040) at cdb[7..9] big-endian — the drive aborts a sub-16-byte data-in.
|
||||
assert_eq!(
|
||||
build_set_cdb(Feature::Ake, STATE_ON),
|
||||
build_set_cdb(Feature::Encryption, STATE_ON),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x06, 0x01, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_get_cdb_exact_bytes() {
|
||||
// GET Bus: verb 03, feature 07, alloc MIN_ALLOC_LEN (0x0040) at cdb[7..9]
|
||||
// big-endian — the drive aborts a 1-byte data-in; state read from offset 0.
|
||||
// GET Encryption: verb 03, feature 06, alloc MIN_ALLOC_LEN (0x0040) at
|
||||
// cdb[7..9] big-endian — the drive aborts a 1-byte data-in; state read
|
||||
// from offset 0.
|
||||
assert_eq!(
|
||||
build_get_cdb(Feature::Bus),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x03, 0x07, 0x00, 0x00, 0x40, 0x00]
|
||||
build_get_cdb(Feature::Encryption),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x03, 0x06, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,12 +93,38 @@ fn vendor_commands_floor_alloc_len_at_min_for_hw() {
|
||||
assert_eq!(MIN_ALLOC_LEN, 64);
|
||||
let alloc =
|
||||
|cdb: &[u8; CDB_LEN]| u16::from_be_bytes([cdb[CDB_ALLOC_LEN], cdb[CDB_ALLOC_LEN + 1]]);
|
||||
assert_eq!(alloc(&build_set_cdb(Feature::Ake, STATE_ON)), MIN_ALLOC_LEN);
|
||||
assert_eq!(alloc(&build_get_cdb(Feature::Ake)), MIN_ALLOC_LEN);
|
||||
assert_eq!(
|
||||
alloc(&build_set_cdb(Feature::Encryption, STATE_ON)),
|
||||
MIN_ALLOC_LEN
|
||||
);
|
||||
assert_eq!(alloc(&build_get_cdb(Feature::Encryption)), MIN_ALLOC_LEN);
|
||||
assert_eq!(alloc(&build_reset_cdb(RESET_TO_OEM)), MIN_ALLOC_LEN);
|
||||
assert_eq!(alloc(&build_save_cdb()), MIN_ALLOC_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_call_and_poke_cdb_exact_bytes() {
|
||||
// Call: verb 0x0C at cdb[4], DEBUG_KNOCK at cdb[2..4], target big-endian
|
||||
// in cdb[5..9], r0 in cdb[9].
|
||||
assert_eq!(
|
||||
build_call_cdb(0x0102_0304, 0xAB),
|
||||
[0x3C, 0x0E, 0xDE, 0xB9, 0x0C, 0x01, 0x02, 0x03, 0x04, 0xAB]
|
||||
);
|
||||
// Poke: verb 0x0D at cdb[4], DEBUG_KNOCK at cdb[2..4], target big-endian
|
||||
// in cdb[5..9], value in cdb[9].
|
||||
assert_eq!(
|
||||
build_poke_cdb(0x0200_0E40, 0xAA),
|
||||
[0x3C, 0x0E, 0xDE, 0xB9, 0x0D, 0x02, 0x00, 0x0E, 0x40, 0xAA]
|
||||
);
|
||||
// Reboot: verb 0x0F at cdb[4], DEBUG_KNOCK at cdb[2..4], no on-wire target
|
||||
// (the boot-entry VA is baked into the handler at build time). Alloc floors
|
||||
// at MIN_ALLOC_LEN (0x0040 at cdb[7..9]) so the data-in is not aborted.
|
||||
assert_eq!(
|
||||
build_reboot_cdb(),
|
||||
[0x3C, 0x0E, 0xDE, 0xB9, 0x0F, 0x00, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_reset_and_identity_cdb_exact_bytes() {
|
||||
// RESET carries its mode in the state slot (cdb[6]) and floors its data-in at
|
||||
@@ -270,13 +312,13 @@ impl ScsiTransport for FwMock {
|
||||
#[test]
|
||||
fn identity_detects_freemkv_and_issues_identity_cdb() {
|
||||
let mut m = FwMock::new();
|
||||
m.states.ake = STATE_ON;
|
||||
m.states.encryption = STATE_ON;
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
let id = fw.identity().expect("no fault").expect("is freemkv");
|
||||
assert_eq!(id.version, "0.6.6");
|
||||
// States no longer ride in IDENTITY — read them via GET.
|
||||
assert_eq!(fw.get(Feature::Ake).expect("get"), STATE_ON);
|
||||
assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_ON);
|
||||
assert!(fw.is_freemkv().expect("no fault"));
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_identity_cdb(MEMREAD_LEN as u16));
|
||||
@@ -310,20 +352,19 @@ fn get_and_set_roundtrip_issue_the_right_cdbs() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
assert_eq!(fw.get(Feature::Bus).expect("get"), STATE_PASSTHROUGH);
|
||||
fw.set(Feature::Bus, STATE_ON).expect("set");
|
||||
assert_eq!(fw.get(Feature::Bus).expect("get"), STATE_ON);
|
||||
assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_PASSTHROUGH);
|
||||
fw.set(Feature::Encryption, STATE_ON).expect("set");
|
||||
assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_ON);
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_get_cdb(Feature::Bus));
|
||||
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Bus, STATE_ON));
|
||||
assert_eq!(m.cdbs[2], build_get_cdb(Feature::Bus));
|
||||
assert_eq!(m.cdbs[0], build_get_cdb(Feature::Encryption));
|
||||
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Encryption, STATE_ON));
|
||||
assert_eq!(m.cdbs[2], build_get_cdb(Feature::Encryption));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_sends_reset_cdb_and_clears_state() {
|
||||
let mut m = FwMock::new();
|
||||
m.states.ake = STATE_ON;
|
||||
m.states.bus = STATE_ON;
|
||||
m.states.encryption = STATE_ON;
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.reset().expect("reset");
|
||||
@@ -332,6 +373,16 @@ fn reset_sends_reset_cdb_and_clears_state() {
|
||||
assert_eq!(m.states, FeatureStates::all_passthrough());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_to_defaults_sends_the_defaults_mode_cdb() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.reset_to_defaults().expect("reset to defaults");
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_reset_cdb(RESET_TO_DEFAULTS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_then_reset_to_flash_restores_the_saved_value() {
|
||||
// SET a feature, SAVE it, force all-passthrough with RESET(to-OEM), then
|
||||
@@ -339,12 +390,12 @@ fn save_then_reset_to_flash_restores_the_saved_value() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.set(Feature::Bus, STATE_OFF).expect("set");
|
||||
fw.set(Feature::Encryption, STATE_OFF).expect("set");
|
||||
fw.save().expect("save");
|
||||
fw.reset().expect("reset to OEM");
|
||||
assert_eq!(fw.get(Feature::Bus).expect("get"), STATE_PASSTHROUGH);
|
||||
assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_PASSTHROUGH);
|
||||
fw.reset_to_flash().expect("reset to flash");
|
||||
assert_eq!(fw.get(Feature::Bus).expect("get"), STATE_OFF);
|
||||
assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_OFF);
|
||||
}
|
||||
// The verbs issued, in order: SET, SAVE, RESET, GET, RESET, GET.
|
||||
assert_eq!(
|
||||
@@ -375,10 +426,11 @@ fn states_probes_every_feature_in_order() {
|
||||
}
|
||||
assert_eq!(states.speed, SPEED_MAX);
|
||||
assert_eq!(states.hrl, STATE_ON);
|
||||
// Seven GETs, one per feature, in ALL_FEATURES order.
|
||||
assert_eq!(m.cdbs.len(), 7);
|
||||
// Five GETs, one per feature, in ALL_FEATURES order (Bd id 0x04 and Bus id
|
||||
// 0x07 both retired).
|
||||
assert_eq!(m.cdbs.len(), 5);
|
||||
let features: Vec<u8> = m.cdbs.iter().map(|c| c[CDB_FEATURE]).collect();
|
||||
assert_eq!(features, vec![0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]);
|
||||
assert_eq!(features, vec![0x01, 0x02, 0x03, 0x05, 0x06]);
|
||||
assert!(m.verbs().iter().all(|&v| v == Verb::Get as u8));
|
||||
}
|
||||
|
||||
@@ -402,28 +454,24 @@ fn typed_setters_issue_expected_set_cdbs() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.enable_uhd().unwrap();
|
||||
fw.disable_bd().unwrap();
|
||||
fw.enable_unrestricted().unwrap();
|
||||
fw.skip_hrl().unwrap();
|
||||
fw.null_ake().unwrap();
|
||||
fw.bus_off().unwrap();
|
||||
fw.disable_encryption().unwrap();
|
||||
fw.region_free().unwrap();
|
||||
fw.force_region_bd(BdRegion::B).unwrap();
|
||||
fw.force_region_dvd(2).unwrap();
|
||||
fw.unlock_speed().unwrap();
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Uhd, STATE_ON));
|
||||
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Bd, STATE_OFF));
|
||||
// The HRL/AKE/BUS unlock direction is now STATE_OFF (0x00), not STATE_ON.
|
||||
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(m.cdbs[3], build_set_cdb(Feature::Ake, STATE_OFF));
|
||||
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Bus, STATE_OFF));
|
||||
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Unrestricted, STATE_ON));
|
||||
// The HRL/Encryption unlock direction is STATE_OFF (0x00), not STATE_ON.
|
||||
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
// Region-free is REGION_FREE (0x0F) — 0x01 now forces DVD region 1.
|
||||
assert_eq!(m.cdbs[5], build_set_cdb(Feature::Region, REGION_FREE));
|
||||
assert_eq!(m.cdbs[6], build_set_cdb(Feature::Region, REGION_BD_B));
|
||||
assert_eq!(m.cdbs[3], build_set_cdb(Feature::Region, REGION_FREE));
|
||||
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Region, REGION_BD_B));
|
||||
// force_region_dvd(2) = REGION_DVD_BASE + 2 = 0x02 under the new base.
|
||||
assert_eq!(m.cdbs[7], build_set_cdb(Feature::Region, 0x02));
|
||||
assert_eq!(m.cdbs[8], build_set_cdb(Feature::Speed, SPEED_MAX));
|
||||
assert_eq!(m.cdbs[5], build_set_cdb(Feature::Region, 0x02));
|
||||
assert_eq!(m.cdbs[6], build_set_cdb(Feature::Speed, SPEED_MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -450,7 +498,7 @@ fn arm_oem_bd_sets_and_verifies_hrl_skip() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arm_oem_uhd_sets_uhd_hrl_bus_each_verified() {
|
||||
fn arm_oem_uhd_sets_uhd_hrl_encryption_each_verified() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
@@ -463,46 +511,51 @@ fn arm_oem_uhd_sets_uhd_hrl_bus_each_verified() {
|
||||
vec![0x02, 0x03, 0x02, 0x03, 0x02, 0x03],
|
||||
"each set is verified by a get"
|
||||
);
|
||||
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Uhd, STATE_ON));
|
||||
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Unrestricted, STATE_ON));
|
||||
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Bus, STATE_OFF));
|
||||
assert_eq!(m.states.uhd, STATE_ON);
|
||||
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
assert_eq!(m.states.unrestricted, STATE_ON);
|
||||
assert_eq!(m.states.hrl, STATE_OFF);
|
||||
assert_eq!(m.states.bus, STATE_OFF);
|
||||
assert_eq!(m.states.encryption, STATE_OFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arm_bypass_bd_nulls_ake() {
|
||||
fn arm_bypass_bd_disables_encryption() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.arm_bypass_bd().expect("armed");
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Ake, STATE_OFF));
|
||||
assert_eq!(m.cdbs[1], build_get_cdb(Feature::Ake));
|
||||
assert_eq!(m.states.ake, STATE_OFF);
|
||||
// Hrl=off (revocation skip) is set+verified FIRST, then Encryption=off.
|
||||
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(m.cdbs[1], build_get_cdb(Feature::Hrl));
|
||||
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
assert_eq!(m.cdbs[3], build_get_cdb(Feature::Encryption));
|
||||
assert_eq!(m.states.hrl, STATE_OFF);
|
||||
assert_eq!(m.states.encryption, STATE_OFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arm_bypass_uhd_sets_uhd_ake_bus() {
|
||||
fn arm_bypass_uhd_sets_uhd_hrl_encryption() {
|
||||
let mut m = FwMock::new();
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.arm_bypass_uhd().expect("armed");
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Uhd, STATE_ON));
|
||||
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Ake, STATE_OFF));
|
||||
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Bus, STATE_OFF));
|
||||
assert_eq!(m.states.uhd, STATE_ON);
|
||||
assert_eq!(m.states.ake, STATE_OFF);
|
||||
assert_eq!(m.states.bus, STATE_OFF);
|
||||
// Recipe order: Unrestricted=on, Hrl=off, Encryption=off (each SET then verifying GET).
|
||||
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Unrestricted, STATE_ON));
|
||||
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(m.cdbs[4], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
assert_eq!(m.states.unrestricted, STATE_ON);
|
||||
assert_eq!(m.states.hrl, STATE_OFF);
|
||||
assert_eq!(m.states.encryption, STATE_OFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arm_stealth_oem_resets_then_verifies_all_passthrough() {
|
||||
let mut m = FwMock::new();
|
||||
m.states.ake = STATE_ON;
|
||||
m.states.uhd = STATE_ON;
|
||||
m.states.encryption = STATE_ON;
|
||||
m.states.unrestricted = STATE_ON;
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.arm_stealth_oem().expect("disarmed");
|
||||
@@ -521,7 +574,7 @@ fn arm_by_recipe_enum_dispatches() {
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.arm(ArmRecipe::BypassBd).expect("armed");
|
||||
}
|
||||
assert_eq!(m.states.ake, STATE_OFF);
|
||||
assert_eq!(m.states.encryption, STATE_OFF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -552,10 +605,11 @@ fn set_verify_reports_mismatch() {
|
||||
let mut d = StuckDrive;
|
||||
let mut fw = FirmwareControl::new(&mut d);
|
||||
let err = fw.arm_bypass_bd().unwrap_err();
|
||||
// arm_bypass_bd now verifies Hrl=off FIRST, so the stuck drive trips on Hrl.
|
||||
assert_eq!(
|
||||
err,
|
||||
FirmwareError::VerifyFailed {
|
||||
feature: Feature::Ake,
|
||||
feature: Feature::Hrl,
|
||||
wanted: STATE_OFF,
|
||||
got: STATE_PASSTHROUGH,
|
||||
}
|
||||
|
||||
+49
-61
@@ -11,15 +11,18 @@
|
||||
//! module's CDB builders so the wire framing never drifts.
|
||||
//!
|
||||
//! Unlock mapping onto the grammar: region-free = `Set(Region, free)`
|
||||
//! (`REGION_FREE`), riplock lift = `Set(Speed, max)` (`SPEED_MAX`), the
|
||||
//! load-bearing transport unlock = `Set(Ake, off)` (`STATE_OFF` — drive acts
|
||||
//! pre-authenticated → a bare `0xAD` returns the VID with no cert and no AKE), and
|
||||
//! the trailing bus-off = `Set(Bus, off)` (`STATE_OFF`, content de-bussed). Under
|
||||
//! the migrated spec the HRL/AKE/BUS unlock direction is `STATE_OFF`, not `0x01`.
|
||||
//! (`REGION_FREE`), riplock lift = `Set(Speed, max)` (`SPEED_MAX`), and the
|
||||
//! load-bearing transport unlock = `Set(Encryption, off)` (`STATE_OFF` — the
|
||||
//! consolidated cert/bus bypass: drive acts pre-authenticated → a bare `0xAD`
|
||||
//! returns the VID with no cert and no AKE, and content returns de-bussed).
|
||||
//! The old separate `Bus` lever (wire id `0x07`) is retired — it was proven
|
||||
//! inert on BU40N/MT1959, and the single `Encryption` lever de-busses on its
|
||||
//! own. Under the migrated spec the HRL/Encryption unlock direction is
|
||||
//! `STATE_OFF`, not `0x01`.
|
||||
|
||||
use crate::firmware::{
|
||||
Feature, MEMREAD_LEN, REGION_FREE, RESP_MAGIC, SPEED_MAX, STATE_OFF, build_identity_cdb,
|
||||
build_memread_cdb, build_set_cdb,
|
||||
Feature, MEMREAD_LEN, MIN_ALLOC_LEN, REGION_FREE, RESP_MAGIC, SPEED_MAX, STATE_OFF,
|
||||
build_identity_cdb, build_memread_cdb, build_set_cdb,
|
||||
};
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
@@ -99,10 +102,10 @@ impl FreemkvUnlocker {
|
||||
state: u8,
|
||||
) -> std::result::Result<(), UnlockError> {
|
||||
let cdb = build_set_cdb(feature, state);
|
||||
// SET needs the 64-byte data-in phase its CDB advertises — the drive aborts
|
||||
// a no-data SET (CHECK CONDITION / Aborted Command, HW-confirmed on BU40N fw
|
||||
// 0.8.1). Mirror FirmwareControl::set (exec_in): read the table back, ignore it.
|
||||
let mut buf = [0u8; RESP_LEN];
|
||||
// SET needs the data-in phase its CDB advertises — a no-data SET is aborted
|
||||
// (CHECK CONDITION, HW-confirmed on BU40N fw 0.8.1). Buffer sized from the SAME
|
||||
// const the CDB advertises (MIN_ALLOC_LEN) so the transfer length has one source.
|
||||
let mut buf = [0u8; MIN_ALLOC_LEN as usize];
|
||||
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
Ok(r) if r.status == 0 => Ok(()),
|
||||
Ok(r) => {
|
||||
@@ -162,8 +165,8 @@ impl FreemkvUnlocker {
|
||||
}
|
||||
|
||||
// Full freemkv unlock: IDENTITY (hard gate) → Set(Region,free) →
|
||||
// Set(Speed,max) (best-effort) → Set(Ake,off) (LOAD-BEARING) → bare 0xAD VID
|
||||
// (best-effort) → Set(Bus,off) (trailing, best-effort).
|
||||
// Set(Speed,max) (best-effort) → Set(Encryption,off) (LOAD-BEARING —
|
||||
// consolidated cert/bus bypass) → bare 0xAD VID (best-effort).
|
||||
fn full_unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
@@ -193,41 +196,30 @@ impl FreemkvUnlocker {
|
||||
// Region-free + riplock lift (best-effort features).
|
||||
best_effort(self.set(scsi, Feature::Region, REGION_FREE), "region")?;
|
||||
best_effort(self.set(scsi, Feature::Speed, SPEED_MAX), "speed")?;
|
||||
// Ake = off/null (LOAD-BEARING): drive acts pre-authenticated, so a bare
|
||||
// 0xAD returns the VID with no cert/AKE. No fallback. The migrated spec
|
||||
// puts the null/bypass direction on STATE_OFF (0x00), not 0x01.
|
||||
match self.set(scsi, Feature::Ake, STATE_OFF) {
|
||||
// Encryption = off (LOAD-BEARING): the consolidated cert/bus bypass —
|
||||
// drive acts pre-authenticated (bare 0xAD returns the VID with no
|
||||
// cert/AKE) AND content returns de-bussed. No fallback.
|
||||
match self.set(scsi, Feature::Encryption, STATE_OFF) {
|
||||
Ok(()) => {}
|
||||
Err(UnlockError::Transport) => return Err(UnlockError::Transport),
|
||||
Err(_) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_null_ake_rejected",
|
||||
"Set(Ake, null) rejected — cannot unlock this drive"
|
||||
phase = "freemkv_disable_encryption_rejected",
|
||||
"Set(Encryption, off) rejected — cannot unlock this drive"
|
||||
);
|
||||
return Err(UnlockError::VidUnavailable);
|
||||
}
|
||||
}
|
||||
// Bare 0xAD VID read — the shared BEST-EFFORT reader (identical to the
|
||||
// LD/Renesas routes). The null AKE already unlocked the drive, so a VID
|
||||
// miss must not discard it: only a dead bus propagates (`?`), else `None`.
|
||||
// LD/Renesas routes). Encryption=off already unlocked the drive, so a
|
||||
// VID miss must not discard it: only a dead bus propagates (`?`), else `None`.
|
||||
let vid = crate::vid::read_aacs_vid(scsi)?;
|
||||
// Trailing Set(Bus, off): remove in-transit bus encryption (STATE_OFF is
|
||||
// the de-bussed direction). FULLY best-effort — inert without the lever,
|
||||
// and a failure here never discards an already-obtained unlock.
|
||||
if let Err(e) = self.set(scsi, Feature::Bus, STATE_OFF) {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_bus_off_unavailable",
|
||||
?e,
|
||||
"bus-off (Set Bus) not applied; continuing"
|
||||
);
|
||||
}
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_unlocked",
|
||||
has_vid = vid.is_some(),
|
||||
"freemkv drive unlocked (null AKE — Ake=off)"
|
||||
"freemkv drive unlocked (Encryption=off — consolidated cert/bus bypass)"
|
||||
);
|
||||
Ok(Unlocked { vid, bus_key: None })
|
||||
}
|
||||
@@ -370,15 +362,12 @@ mod tests {
|
||||
build_set_cdb(Feature::Speed, SPEED_MAX),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x01, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
// Ake/Bus unlock direction is now STATE_OFF (0x00) at cdb[6], not 0x01.
|
||||
// Encryption unlock direction is STATE_OFF (0x00) at cdb[6]; wire id
|
||||
// 0x07 (was `Bus`) is retired.
|
||||
assert_eq!(
|
||||
build_set_cdb(Feature::Ake, STATE_OFF),
|
||||
build_set_cdb(Feature::Encryption, STATE_OFF),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x06, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
assert_eq!(
|
||||
build_set_cdb(Feature::Bus, STATE_OFF),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x07, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
// ── Detection ────────────────────────────────────────────────────────
|
||||
@@ -437,19 +426,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_ake_null_issues_the_set_ake_on_cdb() {
|
||||
fn set_encryption_off_issues_the_set_encryption_cdb() {
|
||||
let mut t = MockTransport::always(Reply::good(vec![]));
|
||||
FreemkvUnlocker::new()
|
||||
.set(&mut t, Feature::Ake, STATE_OFF)
|
||||
.set(&mut t, Feature::Encryption, STATE_OFF)
|
||||
.expect("ok");
|
||||
assert_eq!(t.cdbs[0], build_set_cdb(Feature::Ake, STATE_OFF));
|
||||
assert_eq!(t.cdbs[0], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_drive_rejection_is_not_applicable() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let err = FreemkvUnlocker::new()
|
||||
.set(&mut t, Feature::Ake, STATE_OFF)
|
||||
.set(&mut t, Feature::Encryption, STATE_OFF)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, UnlockError::NotApplicable);
|
||||
}
|
||||
@@ -465,19 +454,18 @@ mod tests {
|
||||
|
||||
// ── full_unlock / unlock ─────────────────────────────────────────────
|
||||
|
||||
/// The full unlock runs IDENTITY → Set(Region) → Set(Speed) → Set(Ake) →
|
||||
/// bare VID → Set(Bus) in order.
|
||||
/// The full unlock runs IDENTITY → Set(Region) → Set(Speed) →
|
||||
/// Set(Encryption) → bare VID in order.
|
||||
#[test]
|
||||
fn full_unlock_issues_identity_region_speed_ake_then_bare_vid_then_bus() {
|
||||
fn full_unlock_issues_identity_region_speed_encryption_then_bare_vid() {
|
||||
let vid = [0x7Cu8; 16];
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()), // IDENTITY
|
||||
Reply::good(vec![]), // Set Region
|
||||
Reply::good(vec![]), // Set Speed
|
||||
Reply::good(vec![]), // Set Ake (load-bearing)
|
||||
Reply::good(vec![]), // Set Encryption (load-bearing)
|
||||
Reply::good(vid_ds_response(vid)), // 0xAD bare VID
|
||||
Reply::good(vec![]), // Set Bus (trailing)
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
@@ -485,16 +473,15 @@ mod tests {
|
||||
let out = FreemkvUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.expect("no fault")
|
||||
.expect("null AKE ⇒ unlocked");
|
||||
.expect("encryption off ⇒ unlocked");
|
||||
assert_eq!(out.vid, Some(vid));
|
||||
assert_eq!(t.cdbs.len(), 6);
|
||||
// The four vendor SETs land on the right features, in order.
|
||||
assert_eq!(t.cdbs.len(), 5);
|
||||
// The three vendor SETs land on the right features, in order.
|
||||
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
|
||||
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Region, REGION_FREE));
|
||||
assert_eq!(t.cdbs[2], build_set_cdb(Feature::Speed, SPEED_MAX));
|
||||
assert_eq!(t.cdbs[3], build_set_cdb(Feature::Ake, STATE_OFF));
|
||||
assert_eq!(t.cdbs[3], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
assert_eq!(t.cdbs[4][0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
|
||||
assert_eq!(t.cdbs[5], build_set_cdb(Feature::Bus, STATE_OFF));
|
||||
}
|
||||
|
||||
/// A missing region/speed feature does not fail the unlock (best-effort).
|
||||
@@ -519,17 +506,17 @@ mod tests {
|
||||
assert_eq!(out.vid, Some(vid));
|
||||
}
|
||||
|
||||
/// The null AKE is LOAD-BEARING: if the drive rejects it, this isn't an
|
||||
/// unlockable freemkv drive, so `unlock()` declines (`None`) and falls
|
||||
/// through — never a hard error.
|
||||
/// Set(Encryption, off) is LOAD-BEARING: if the drive rejects it, this
|
||||
/// isn't an unlockable freemkv drive, so `unlock()` declines (`None`) and
|
||||
/// falls through — never a hard error.
|
||||
#[test]
|
||||
fn declines_when_null_ake_rejected() {
|
||||
fn declines_when_disable_encryption_rejected() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()), // identity
|
||||
Reply::good(vec![]), // region
|
||||
Reply::good(vec![]), // speed
|
||||
Reply::illegal_request(), // null ake REJECTED
|
||||
Reply::illegal_request(), // Set(Encryption,off) REJECTED
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
@@ -542,8 +529,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Best-effort VID: the null AKE succeeded (drive unlocked), but the bare
|
||||
/// VID read was rejected — `unlock()` still returns `Some`, with `vid: None`.
|
||||
/// Best-effort VID: Set(Encryption,off) succeeded (drive unlocked), but the
|
||||
/// bare VID read was rejected — `unlock()` still returns `Some`, with
|
||||
/// `vid: None`.
|
||||
#[test]
|
||||
fn unlocks_without_vid_when_bare_read_fails() {
|
||||
let mut t = MockTransport::scripted(
|
||||
@@ -551,7 +539,7 @@ mod tests {
|
||||
Reply::good(freemkv_identity_payload()), // identity
|
||||
Reply::good(vec![]), // region
|
||||
Reply::good(vec![]), // speed
|
||||
Reply::good(vec![]), // null ake
|
||||
Reply::good(vec![]), // Set(Encryption,off)
|
||||
Reply::illegal_request(), // bare VID: no medium
|
||||
],
|
||||
Reply::TransportFault,
|
||||
|
||||
+5
-6
@@ -8,7 +8,7 @@
|
||||
|
||||
// `cdb` is only the bdemu emulator's wire format (real unlocking uses profile
|
||||
// templates), gated behind `emulation`; also compiled under `cfg(test)` so its
|
||||
// wire-format tests run in default-feature CI. See docs/ld-mod.md — cdb gating.
|
||||
// wire-format tests run in default-feature CI.
|
||||
#[cfg(any(feature = "emulation", test))]
|
||||
mod cdb;
|
||||
mod error;
|
||||
@@ -21,7 +21,7 @@ use crate::{DriveId, UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
// ── Public profile catalog ──────────────────────────────────────────────────
|
||||
// Only the catalog is public (supported-drive lookup, used by bdemu); the
|
||||
// unlock mechanism stays private. See docs/ld-mod.md — public catalog design.
|
||||
// unlock mechanism stays private.
|
||||
|
||||
pub use profile::{DriveProfile as Profile, Identity, Platform, ProfileMatch, Profiles};
|
||||
|
||||
@@ -122,7 +122,7 @@ impl LdUnlocker {
|
||||
impl LdUnlocker {
|
||||
// The MediaTek firmware unlock. Since it removes AACS at the drive (clear
|
||||
// content), this one op satisfies both features and bus-removal, so both
|
||||
// trait methods delegate here. See docs/ld-mod.md — firmware_unlock contract.
|
||||
// trait methods delegate here.
|
||||
fn firmware_unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
@@ -144,7 +144,7 @@ impl LdUnlocker {
|
||||
mt.init(scsi)?;
|
||||
// `init` only proves the handshake completed, not that the drive reached
|
||||
// extended-access state. Reporting unlocked off `init` alone shipped
|
||||
// ciphertext at rc=0. See docs/ld-mod.md — half-unlock fallback.
|
||||
// ciphertext at rc=0.
|
||||
if !mt.is_unlocked() {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
@@ -159,7 +159,7 @@ impl LdUnlocker {
|
||||
if let Err(e) = mt.probe_disc(scsi) {
|
||||
// A transport fault here is a dead bus, not a calibration miss — most
|
||||
// profiles never touch the bus again, so this was the only dead-bus
|
||||
// signal. See docs/ld-mod.md — probe_disc dead-bus classification.
|
||||
// signal.
|
||||
if e.is_transport_failure() {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
@@ -432,7 +432,6 @@ mod tests {
|
||||
|
||||
// THE probe-disc dead-bus test: bus dies during speed calibration after a
|
||||
// full unlock; must abort with Transport, not report a successful unlock.
|
||||
// See docs/ld-mod.md — probe_disc dead-bus test / mutation notes.
|
||||
#[test]
|
||||
fn transport_fault_during_probe_disc_is_transport_not_a_successful_unlock() {
|
||||
let id = known_vid_drive_id();
|
||||
|
||||
@@ -127,7 +127,7 @@ impl Mt1959 {
|
||||
let result = scsi.execute(&cdb, DataDirection::FromDevice, buf, 5_000)?;
|
||||
// A drive sense arrives as `Ok` with a non-zero status; a merely
|
||||
// SHORT response is the drive answering badly — neither is a dead
|
||||
// bus. See docs/mt1959.md — short-probe-not-transport-failure.
|
||||
// bus.
|
||||
if result.status != 0 || result.bytes_transferred != expected {
|
||||
return Err(Error::Scsi {
|
||||
opcode: SCSI_READ_BUFFER,
|
||||
@@ -181,7 +181,7 @@ impl Mt1959 {
|
||||
|
||||
// `response` is a fixed 64-byte buffer; the meaningful bound is how
|
||||
// many bytes the drive actually delivered. Require enough bytes to
|
||||
// validate before checking markers, up front. See docs/mt1959.md — min-unlock-response-length.
|
||||
// validate before checking markers, up front.
|
||||
let n = result.bytes_transferred.min(response.len());
|
||||
if n < MIN_UNLOCK_RESPONSE {
|
||||
tracing::debug!(
|
||||
@@ -206,7 +206,6 @@ impl Mt1959 {
|
||||
|
||||
// Extended-access state requires the signature match AND both the
|
||||
// primary marker at [12..16] AND the secondary marker at [16..20].
|
||||
// See docs/mt1959.md — unlock-marker-gating.
|
||||
self.unlocked =
|
||||
response[FIRMWARE_MODE_OFFSET..FIRMWARE_MODE_OFFSET + 4] == FIRMWARE_MODE_SIG;
|
||||
|
||||
@@ -282,7 +281,7 @@ impl Mt1959 {
|
||||
return Err(Error::UnlockFailed);
|
||||
}
|
||||
// A dead bus must abort here, not be retried three times as
|
||||
// firmware reloads. See docs/mt1959.md — run-init-transport-fault.
|
||||
// firmware reloads.
|
||||
Err(e) if e.is_transport_failure() => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
@@ -349,7 +348,7 @@ impl Mt1959 {
|
||||
}
|
||||
|
||||
// Detect disc type from capacity to select probe mode (BD vs UHD
|
||||
// init address). See docs/mt1959.md — probe-init-address-detection.
|
||||
// init address).
|
||||
let cap_cdb = [
|
||||
SCSI_READ_CAPACITY,
|
||||
0x00,
|
||||
@@ -368,7 +367,7 @@ impl Mt1959 {
|
||||
let cap = scsi.execute(&cap_cdb, DataDirection::FromDevice, &mut cap_buf, 5_000)?;
|
||||
let disc_sectors = if cap.status == 0 && cap.bytes_transferred >= 4 {
|
||||
// last_lba + 1 = sector count; saturate on the 32-bit-overflow
|
||||
// sentinel rather than wrap to 0. See docs/mt1959.md — capacity-saturation.
|
||||
// sentinel rather than wrap to 0.
|
||||
u32::from_be_bytes([cap_buf[0], cap_buf[1], cap_buf[2], cap_buf[3]]).saturating_add(1)
|
||||
} else {
|
||||
0
|
||||
@@ -413,7 +412,7 @@ impl Mt1959 {
|
||||
{
|
||||
// A dead bus MUST keep its transport classification; a
|
||||
// short/rejected reply is the drive's own failure and stays
|
||||
// `UnlockFailed`. See docs/mt1959.md — probe-failure-classification.
|
||||
// `UnlockFailed`.
|
||||
if e.is_transport_failure() {
|
||||
return Err(e);
|
||||
}
|
||||
@@ -423,7 +422,6 @@ impl Mt1959 {
|
||||
}
|
||||
|
||||
// Pass 2: continue past the coarse range (do not restart at 0).
|
||||
// See docs/mt1959.md — probe-pass-overlap-fix.
|
||||
let mut addr: u32 = PROBE_COARSE_END as u32;
|
||||
while addr < PROBE_FINE_END {
|
||||
let mut resp = [0u8; PROBE_RESPONSE_SIZE as usize];
|
||||
@@ -436,7 +434,7 @@ impl Mt1959 {
|
||||
) {
|
||||
// Fine probing is best-effort — a short/rejected reply just ends
|
||||
// the sweep early, but a transport fault must keep its
|
||||
// classification and abort. See docs/mt1959.md — probe-pass2-transport-fault.
|
||||
// classification and abort.
|
||||
if e.is_transport_failure() {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
@@ -696,7 +694,7 @@ mod tests {
|
||||
|
||||
// THE defect-15 test: a zero-byte response used to skip every marker
|
||||
// check (each individually `n >= ..` guarded) and report a fully
|
||||
// unlocked drive. See docs/mt1959.md — defect-15.
|
||||
// unlocked drive.
|
||||
#[test]
|
||||
fn do_unlock_rejects_a_zero_length_response() {
|
||||
let sig = [0x99, 0x9E, 0xC3, 0x75];
|
||||
@@ -776,7 +774,7 @@ mod tests {
|
||||
|
||||
// THE defect-4 test: a dead bus must abort `run_init` at once, not
|
||||
// re-upload firmware on all three attempts and return the generic
|
||||
// `UnlockFailed`. See docs/mt1959.md — defect-4.
|
||||
// `UnlockFailed`.
|
||||
#[test]
|
||||
fn run_init_aborts_on_a_transport_fault_without_reloading_firmware() {
|
||||
let mut t =
|
||||
@@ -894,7 +892,7 @@ mod tests {
|
||||
|
||||
// THE probe pass-1 dead-bus test: a bus that dies during the coarse
|
||||
// probe must keep its transport classification so the caller aborts
|
||||
// with `Transport`. See docs/mt1959.md — pass1-dead-bus-mutation.
|
||||
// with `Transport`.
|
||||
#[test]
|
||||
fn transport_fault_during_pass1_probe_stays_a_transport_failure() {
|
||||
let mut t = ProbeFaultsTransport { fault_at: 0 };
|
||||
@@ -909,7 +907,7 @@ mod tests {
|
||||
|
||||
// THE probe pass-2 dead-bus test: the loop used to `break` on ANY
|
||||
// error and return `Ok(())`, swallowing a dead bus mid-pass-2. It
|
||||
// must now propagate. See docs/mt1959.md — pass2-dead-bus-mutation.
|
||||
// must now propagate.
|
||||
#[test]
|
||||
fn transport_fault_during_pass2_probe_stays_a_transport_failure() {
|
||||
let mut t = ProbeFaultsTransport {
|
||||
@@ -952,7 +950,7 @@ mod tests {
|
||||
|
||||
// variant_a's firmware-verify-read transport-abort branch: WRITE_BUFFER
|
||||
// succeeds, the 0x45 verify read faults — a dead bus must abort there,
|
||||
// not be swallowed into the unlock retries. See docs/mt1959.md — variant-a-verify-read-transport-abort.
|
||||
// not be swallowed into the unlock retries.
|
||||
#[test]
|
||||
fn variant_a_verify_read_transport_fault_aborts() {
|
||||
let mut profile = fixture_profile([0; 4]);
|
||||
@@ -1013,7 +1011,7 @@ mod tests {
|
||||
|
||||
// variant_b's firmware-upload step (here the metadata read) hitting a
|
||||
// dead bus must abort via `trace_step`, not be swallowed into the
|
||||
// unlock retries. See docs/mt1959.md — variant-b-upload-step-transport-abort.
|
||||
// unlock retries.
|
||||
#[test]
|
||||
fn variant_b_upload_step_transport_fault_aborts() {
|
||||
let mut profile = fixture_profile([0; 4]);
|
||||
|
||||
@@ -13,7 +13,6 @@ const FIRMWARE_EXTRA: [u8; 16] = [0; 16];
|
||||
/// that drive — real profiles must supply their own (see `DriveProfile`).
|
||||
const VENDOR_VERIFY: [u8; 10] = [0xF1, 0x01, 0x02, 0x00, 0x0D, 0x30, 0x01, 0xF3, 0xAD, 0x23];
|
||||
|
||||
// See docs/variant-b-firmware-upload.md — trace_step rationale.
|
||||
// Swallows everything except a transport fault; these steps are advisory,
|
||||
// the unlock retries below are the real gate.
|
||||
fn trace_step(phase: &'static str, r: crate::scsi::Result<crate::scsi::ScsiResult>) -> Result<()> {
|
||||
@@ -58,7 +57,7 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
return Err(crate::ld::error::Error::UnlockFailed);
|
||||
}
|
||||
|
||||
// Step 1: Upload the firmware via MODE SELECT (see docs/variant-b-firmware-upload.md).
|
||||
// Step 1: Upload the firmware via MODE SELECT.
|
||||
// MODE SELECT(10)'s parameter-list length is 16-bit, so reject only a
|
||||
// blob that can't be expressed in the CDB.
|
||||
let write_len = firmware.len();
|
||||
@@ -124,8 +123,6 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
)?;
|
||||
|
||||
// Step 4: Vendor verify (0xF1 — B-only, not standard SCSI), per-drive.
|
||||
// See docs/variant-b-firmware-upload.md for why the outcome is traced
|
||||
// rather than discarded.
|
||||
let verify_cdb = mt.profile.fw_verify_cdb.unwrap_or(VENDOR_VERIFY);
|
||||
let mut dummy = [0u8; 0];
|
||||
trace_step(
|
||||
@@ -134,8 +131,7 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
)?;
|
||||
|
||||
// Step 5: Unlock retries (up to 5, then a final fatal attempt). The
|
||||
// confirmation pass after success is intentionally best-effort — see
|
||||
// docs/variant-b-firmware-upload.md.
|
||||
// confirmation pass after success is intentionally best-effort.
|
||||
for _attempt in 0..5 {
|
||||
match mt.do_unlock(scsi) {
|
||||
Ok(_) => {
|
||||
|
||||
+7
-9
@@ -24,8 +24,7 @@ const RENESAS_MARKER_OFFSET: usize = 16;
|
||||
/// interface tag. A rejection (CHECK CONDITION or `Err` with a sense) is
|
||||
/// `Ok(false)`: not a Renesas drive.
|
||||
///
|
||||
/// `Err(Transport)` on a dead bus. See docs/renesas-mod.md for why a
|
||||
/// transport fault must not be folded into `Ok(false)`.
|
||||
/// `Err(Transport)` on a dead bus.
|
||||
pub fn is_renesas(scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
|
||||
let mut buf = [0u8; RB_F1_LEN];
|
||||
match scsi.execute(&RB_F1_CDB, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
@@ -71,8 +70,7 @@ impl Renesas {
|
||||
}
|
||||
|
||||
// MakeMKV's vendor "open" sequence: primary read (A), and on refusal a
|
||||
// knock + second read (B). See docs/renesas-mod.md for the full sequence
|
||||
// and why we run A→knock→B rather than bailing after A.
|
||||
// knock + second read (B).
|
||||
fn vendor_open(scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
|
||||
const RB_B0_04_CDB: [u8; 10] = [0x3C, 0x02, 0xB0, 0x00, 0x00, 0x04, 0x00, 0x00, 0xA4, 0x00];
|
||||
const KNOCK_A5AAAA_CDB: [u8; 10] =
|
||||
@@ -128,7 +126,7 @@ impl Unlocker for Renesas {
|
||||
/// best-effort `0xAD` reader. `Some` once the open succeeds — the drive
|
||||
/// serves clear content whether or not the bonus VID read did; `None` if it
|
||||
/// isn't a Renesas drive or the open is refused; `Err(Transport)` on a dead
|
||||
/// bus. See docs/renesas-mod.md for the MakeMKV-parity rationale.
|
||||
/// bus.
|
||||
fn unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
@@ -188,7 +186,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// Rejects like a MediaTek drive: ILLEGAL REQUEST with a sense, which is
|
||||
// what distinguishes this from a dead bus. See docs/renesas-mod.md.
|
||||
// what distinguishes this from a dead bus.
|
||||
struct RejectingTransport;
|
||||
impl ScsiTransport for RejectingTransport {
|
||||
fn execute(
|
||||
@@ -280,7 +278,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// Same rejection via a CONFORMING transport (`Ok` + CHECK CONDITION);
|
||||
// must reach the same answer. See docs/renesas-mod.md.
|
||||
// must reach the same answer.
|
||||
#[test]
|
||||
fn check_condition_is_not_a_renesas_drive() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
@@ -308,7 +306,7 @@ mod tests {
|
||||
|
||||
// A recognized Renesas drive whose A read (RB 0xB0@0x04) is REFUSED but
|
||||
// whose B read (RB 0xB0@0x50) SUCCEEDS after the knock must unlock — and the
|
||||
// knock CDB has to be issued BETWEEN A and B. See docs/renesas-mod.md.
|
||||
// knock CDB has to be issued BETWEEN A and B.
|
||||
#[test]
|
||||
fn knock_runs_between_a_and_b_and_b_opens() {
|
||||
// The exact knock CDB the code issues (pinned wire format).
|
||||
@@ -395,7 +393,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// A recognized Renesas drive that REFUSES the vendor open read must
|
||||
// defer to the next unlocker, not claim the drive. See docs/renesas-mod.md.
|
||||
// defer to the next unlocker, not claim the drive.
|
||||
#[test]
|
||||
fn open_rejection_defers_to_next_unlocker() {
|
||||
struct GateOkOpenRefused;
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
//! BEST-EFFORT: only a dead bus is an `Err(Transport)`. A CHECK CONDITION, a
|
||||
//! short response, or an all-zero VID all yield `Ok(None)` — a VID miss must
|
||||
//! never discard an unlock that already removed the bus (a key source can still
|
||||
//! supply the key). See docs/freemkv-abi.md for the fixed-length reply rules.
|
||||
//! supply the key).
|
||||
|
||||
use crate::UnlockError;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
|
||||
Reference in New Issue
Block a user