mirror of
https://github.com/freemkv/freemkv-unlock.git
synced 2026-10-07 18:13:11 +00:00
Compare commits
28
Commits
dev
...
v1.7.7-rc3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e7645b227 | ||
|
|
acd5be762b | ||
|
|
c19b8cbe48 | ||
|
|
07dc0d82ee | ||
|
|
99898bba62 | ||
|
|
1b01fdbf3b | ||
|
|
a3ff27096e | ||
|
|
abd997316e | ||
|
|
9741d2d8e3 | ||
|
|
40c44fbf29 | ||
|
|
24909ba668 | ||
|
|
e6d1136155 | ||
|
|
712e58d316 | ||
|
|
226be3a9f0 | ||
|
|
6d91b7ce02 | ||
|
|
dfab59ebda | ||
|
|
245c326de9 | ||
|
|
cde7d6e26d | ||
|
|
529e8f69a5 | ||
|
|
7923e34493 | ||
|
|
1bc8e4d6e7 | ||
|
|
1b5462f8b1 | ||
|
|
689219079d | ||
|
|
44c655ad1e | ||
|
|
8a231c4e1a | ||
|
|
33a738f352 | ||
|
|
51437dc4ad | ||
|
|
2e2025e51c |
+31
-61
@@ -1,63 +1,33 @@
|
||||
# Version updates are monthly and grouped into one PR per ecosystem.
|
||||
# Security updates are separate and run whenever an advisory is published.
|
||||
version: 2
|
||||
|
||||
# Dependency updates land on `dev`, never on `main`.
|
||||
#
|
||||
# `main` here is a RELEASE POINTER that release.sh moves to each tag. A bot
|
||||
# commit on it would put work there that no tag contains, which is exactly the
|
||||
# state that aborted the 1.6.2 cascade at the last step -- so pointing
|
||||
# Dependabot at main would recreate that failure on a schedule.
|
||||
updates:
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
# One PR per week for the routine bumps instead of one per crate. Eight
|
||||
# repos times a handful of crates is a volume nobody reads, and an
|
||||
# unread PR queue is indistinguishable from no updates at all.
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
ignore:
|
||||
# The freemkv crates depend on each other by GIT TAG, re-pinned by
|
||||
# release.sh as part of the release commit. Dependabot cannot see that
|
||||
# cascade, so a PR bumping one of these would fight the release process
|
||||
# and could pin a version whose tag does not exist yet.
|
||||
- dependency-name: freemkv-unlock
|
||||
- dependency-name: libfreemkv
|
||||
- dependency-name: freemkv-keysources
|
||||
- dependency-name: freemkv-i18n
|
||||
- dependency-name: freemkv-engine
|
||||
|
||||
# The workflows are now real infrastructure -- the release cascade, the
|
||||
# cross-platform hash matrix, the disc gate -- so their actions need the same
|
||||
# attention as the crates.
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
actions:
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
ignore:
|
||||
# NOT a dependency: `dtolnay/rust-toolchain` is versioned by the RUST
|
||||
# release it installs, and the tag we pin is the toolchain CI is pinned
|
||||
# to on purpose -- precommit.sh runs the same one locally so a lint that
|
||||
# passes on a developer's newer default cannot pass CI by accident.
|
||||
#
|
||||
# Dependabot reads those tags as semver and proposed 1.97.0 -> 1.100.0,
|
||||
# a Rust version that does not exist. Every such PR 404s on toolchain
|
||||
# download across all eight repos, and they regenerate weekly -- eight
|
||||
# permanently-red PRs that promote.yml then has to special-case when it
|
||||
# decides whether dev is green.
|
||||
#
|
||||
# Bumping the toolchain is a deliberate, all-eight-repos change, made by
|
||||
# hand together with precommit.sh. There is nothing here for a bot.
|
||||
- dependency-name: dtolnay/rust-toolchain
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- '*'
|
||||
ignore:
|
||||
- dependency-name: freemkv-unlock
|
||||
- dependency-name: libfreemkv
|
||||
- dependency-name: freemkv-keysources
|
||||
- dependency-name: freemkv-i18n
|
||||
- dependency-name: freemkv-engine
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: dev
|
||||
schedule:
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- '*'
|
||||
ignore:
|
||||
- dependency-name: dtolnay/rust-toolchain
|
||||
- dependency-name: freemkv/.github*
|
||||
|
||||
@@ -15,6 +15,10 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Least privilege: no job here writes to the repo.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# freemkv-unlock is the BASE of the whole ecosystem: libfreemkv path-deps it,
|
||||
# bdemu path-deps it with the `emulation` feature, and every other crate pulls
|
||||
# it in transitively. It has no freemkv dependencies of its own, so no sibling
|
||||
@@ -42,7 +46,7 @@ jobs:
|
||||
outputs:
|
||||
v: ${{ steps.msrv.outputs.v }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- id: msrv
|
||||
shell: bash
|
||||
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
|
||||
@@ -56,7 +60,7 @@ jobs:
|
||||
changelog:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- name: Require a CHANGELOG entry for the crate version on main
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -77,7 +81,7 @@ jobs:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
# fmt runs on the pinned MSRV toolchain; clippy runs on stable. Linting on
|
||||
# stable makes new-lint debt a BLOCKING gate the pinned version would
|
||||
# otherwise hide. The MSRV pin is installed second so it stays the default
|
||||
@@ -100,7 +104,7 @@ jobs:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
@@ -111,6 +115,38 @@ jobs:
|
||||
# `emulation` is off by default and is the face bdemu builds against. A
|
||||
# feature nothing compiles is a feature that breaks at release time.
|
||||
- run: cargo test --tests --features emulation
|
||||
# `--tests` skips doctests; compile/run them so doc examples can't rot.
|
||||
- run: cargo test --doc --all-features
|
||||
|
||||
# This crate's breakage takes every dependent down, so build the two direct
|
||||
# consumers against THIS commit: both dep freemkv-unlock by version, so each
|
||||
# gets a path patch (bdemu also patches libfreemkv to the sibling).
|
||||
consumers:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-unlock
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv bdemu
|
||||
patch: libfreemkv freemkv-unlock
|
||||
self: bdemu
|
||||
- name: Point libfreemkv at THIS commit
|
||||
run: |
|
||||
mkdir -p libfreemkv/.cargo
|
||||
printf '[patch.crates-io]\nfreemkv-unlock = { path = "../freemkv-unlock" }\n' > libfreemkv/.cargo/config.toml
|
||||
# Dependents track current Rust, not this crate's MSRV.
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: |
|
||||
libfreemkv
|
||||
bdemu
|
||||
- run: cargo +stable build --all-targets
|
||||
working-directory: libfreemkv
|
||||
- run: cargo +stable build --all-targets
|
||||
working-directory: bdemu
|
||||
|
||||
# Coverage is additive and NON-REQUIRED: it uploads to Codecov but never
|
||||
# gates a merge (fail_ci_if_error is false, and it is not wired into any
|
||||
@@ -122,7 +158,7 @@ jobs:
|
||||
needs: resolve-msrv
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
@@ -147,7 +183,7 @@ jobs:
|
||||
deny:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: EmbarkStudios/cargo-deny-action@v2
|
||||
with:
|
||||
@@ -173,7 +209,7 @@ jobs:
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
@@ -198,7 +234,7 @@ jobs:
|
||||
if: github.ref_name == 'qa' || github.ref_name == 'main'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: ${{ needs.resolve-msrv.outputs.v }}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
name: doc-links
|
||||
|
||||
# Thin caller -> the canonical reusable doc-links gate in freemkv/.github.
|
||||
# freemkv-unlock is the leaf crate: no siblings to check out, no [patch].
|
||||
on: [push, pull_request]
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
doc-links:
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||
uses: freemkv/.github/.github/workflows/guard-doclinks.yml@v1
|
||||
with:
|
||||
self: freemkv-unlock
|
||||
@@ -27,6 +27,10 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Least privilege by default; only rc-tag escalates (job-level contents: write).
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Single source of truth for the MSRV — see ci.yml. Jobs that build/test AT
|
||||
# the MSRV read `needs.resolve-msrv.outputs.v` from Cargo.toml's rust-version;
|
||||
@@ -36,7 +40,7 @@ jobs:
|
||||
outputs:
|
||||
v: ${{ steps.msrv.outputs.v }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
- id: msrv
|
||||
shell: bash
|
||||
run: echo "v=$(grep -m1 '^rust-version' Cargo.toml | sed -E 's/.*\"([^\"]+)\".*/\1/')" >> "$GITHUB_OUTPUT"
|
||||
@@ -60,7 +64,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Stamp the next rc
|
||||
@@ -89,7 +93,7 @@ jobs:
|
||||
os: ['ubuntu-latest', 'macos-latest']
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
@@ -107,7 +111,7 @@ jobs:
|
||||
cross-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-unlock
|
||||
# clippy runs on stable (matches ci.yml's lint job); build/test stay on
|
||||
@@ -130,7 +134,7 @@ jobs:
|
||||
needs: resolve-msrv
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
path: freemkv-unlock
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- AACS handshake diagnostics that a scan now reports itself log below WARN: unsupported or unverifiable drive certs, VID read failures and transport faults at DEBUG, `read_data_key_dropped` at INFO.
|
||||
|
||||
## [1.7.7] — 2026-09-26
|
||||
|
||||
### Maintenance
|
||||
|
||||
Generated
+2
-2
@@ -233,9 +233,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom",
|
||||
|
||||
@@ -76,6 +76,7 @@ impl Error {
|
||||
}
|
||||
|
||||
/// The parsed sense for a CHECK CONDITION SCSI error, else `None`.
|
||||
#[cfg(test)]
|
||||
pub fn scsi_sense(&self) -> Option<ScsiSense> {
|
||||
match self {
|
||||
Error::Scsi { sense, .. } => *sense,
|
||||
|
||||
+912
-215
File diff suppressed because it is too large
Load Diff
+313
-104
@@ -93,54 +93,56 @@ impl AacsUnlocker {
|
||||
handshake::run_cert_handshake(scsi, &self.host_certs)
|
||||
}
|
||||
|
||||
/// Opt IN to arming a freemkv-firmware drive with a named recipe *before*
|
||||
/// the cert AKE (builder style). OFF by default, so the cert route's default
|
||||
/// behaviour is unchanged and a non-freemkv / stock drive is never sent a
|
||||
/// vendor command. When set, `unlock()` probes via
|
||||
/// [`FirmwareControl::identity`] and applies `recipe` only on a match — e.g.
|
||||
/// [`ArmRecipe::BypassBd`] (`Ake=null`) pre-authenticates the drive so cert
|
||||
/// selection is moot; [`ArmRecipe::OemBd`] (`Hrl=skip`) accepts a revoked
|
||||
/// cert. A non-freemkv drive or a refused recipe is left untouched.
|
||||
/// Opt IN (off by default) to arming a freemkv drive with `recipe` via
|
||||
/// [`FirmwareControl::arm`] before the cert AKE; non-freemkv or unsupported
|
||||
/// firmware gets only IDENTITY. An `Encryption=off` recipe (`BypassBd`,
|
||||
/// `BypassUhd`, `OemUhd`) pre-authenticates and de-busses the drive, so no
|
||||
/// cert is needed and `unlock()` returns the bare-read VID with no bus key.
|
||||
/// [`ArmRecipe::OemBd`] (`Hrl=off`) lets a revoked cert through the AKE. A
|
||||
/// recipe refused midway is not rolled back: its earlier SETs stay in RAM.
|
||||
pub fn arm_before_unlock(mut self, recipe: ArmRecipe) -> Self {
|
||||
self.arm = Some(recipe);
|
||||
self
|
||||
}
|
||||
|
||||
/// If arming is opted-in, detect freemkv firmware and apply the recipe.
|
||||
/// Best-effort: a non-freemkv drive or a refused recipe is a no-op; only a
|
||||
/// dead bus (`FirmwareError::Transport`) aborts the whole unlock.
|
||||
fn maybe_arm(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<(), UnlockError> {
|
||||
/// If arming is opted-in, apply the recipe. `Ok(true)` only when an
|
||||
/// `Encryption=off` recipe armed (drive pre-authenticated + de-bussed).
|
||||
/// Unsupported firmware or a refused recipe is `Ok(false)` (a refusal may
|
||||
/// leave earlier SETs applied, so it logs at warn); a dead bus aborts.
|
||||
fn maybe_arm(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
|
||||
let Some(recipe) = self.arm else {
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
};
|
||||
let mut fw = FirmwareControl::new(scsi);
|
||||
match fw.identity() {
|
||||
Ok(Some(id)) => {
|
||||
match FirmwareControl::new(scsi).arm(recipe) {
|
||||
Ok(()) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "aacs_arm_freemkv_detected",
|
||||
version = %id.version,
|
||||
phase = "aacs_arm_applied",
|
||||
recipe = ?recipe,
|
||||
"freemkv firmware detected; arming before the cert AKE"
|
||||
"freemkv firmware armed before the cert AKE"
|
||||
);
|
||||
match fw.arm(recipe) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(FirmwareError::Transport) => Err(UnlockError::Transport),
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "aacs_arm_recipe_refused",
|
||||
recipe = ?recipe,
|
||||
error = ?e,
|
||||
"firmware refused the arm recipe; proceeding with the plain cert AKE"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Ok(recipe.disables_encryption())
|
||||
}
|
||||
Ok(None) => Ok(()), // not freemkv — leave the drive untouched
|
||||
Err(FirmwareError::Transport) => Err(UnlockError::Transport),
|
||||
Err(_) => Ok(()),
|
||||
Err(FirmwareError::UnsupportedFirmware) => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "aacs_arm_unsupported",
|
||||
recipe = ?recipe,
|
||||
"not supported freemkv firmware; plain cert AKE"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "aacs_arm_recipe_refused",
|
||||
recipe = ?recipe,
|
||||
error = ?e,
|
||||
"arm recipe refused (earlier SETs stay applied); plain cert AKE"
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,7 +154,8 @@ impl Unlocker for AacsUnlocker {
|
||||
|
||||
/// Remove AACS bus encryption via the host-cert handshake. Like every other
|
||||
/// unlocker this DOES unlock the drive — just with a cert + AKE instead of a
|
||||
/// vendor CDB. `Some` on a successful handshake (VID + bus key learned);
|
||||
/// vendor CDB. `Some` on a successful handshake (VID + bus key learned), or
|
||||
/// after an opted-in `Encryption=off` arm (bare VID, no bus key);
|
||||
/// `None` on a non-AACS disc, no usable cert, or a rejected handshake (fall
|
||||
/// through); `Err(Transport)` on a dead bus.
|
||||
fn unlock(
|
||||
@@ -160,14 +163,19 @@ impl Unlocker for AacsUnlocker {
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
ctx: &UnlockCtx,
|
||||
) -> std::result::Result<Option<Unlocked>, UnlockError> {
|
||||
if ctx.kind != DiscKind::Aacs || self.host_certs.is_empty() {
|
||||
// Wrong disc kind, or no host cert to authenticate with — the loop
|
||||
// falls through to a VID-less / keysource path.
|
||||
if ctx.kind != DiscKind::Aacs {
|
||||
return Ok(None);
|
||||
}
|
||||
// Opt-in arm (no-op by default). Encryption=off leaves the drive
|
||||
// pre-authenticated and de-bussed: no cert AKE, and no bus key to apply.
|
||||
if self.maybe_arm(scsi)? {
|
||||
let vid = crate::vid::read_aacs_vid(scsi)?;
|
||||
return Ok(Some(Unlocked { vid, bus_key: None }));
|
||||
}
|
||||
if self.host_certs.is_empty() {
|
||||
// No host cert to authenticate with — fall through.
|
||||
return Ok(None);
|
||||
}
|
||||
// Opt-in: arm a freemkv-firmware drive before the AKE (no-op by default,
|
||||
// and on non-freemkv drives). Only a dead bus aborts here.
|
||||
self.maybe_arm(scsi)?;
|
||||
crate::fallthrough(self.run_handshake(scsi).map(|h| {
|
||||
// A UHD disc whose bus-key fetch the drive refused (non-transport)
|
||||
// returns Ok with read_data_key: None + read_data_key_err: Some —
|
||||
@@ -175,7 +183,7 @@ impl Unlocker for AacsUnlocker {
|
||||
if h.read_data_key.is_none()
|
||||
&& let Some(code) = h.read_data_key_err
|
||||
{
|
||||
tracing::warn!(
|
||||
tracing::info!(
|
||||
target: "freemkv::disc",
|
||||
phase = "read_data_key_dropped",
|
||||
error_code = code,
|
||||
@@ -350,76 +358,161 @@ mod tests {
|
||||
assert_eq!(u.maybe_arm(&mut t).unwrap_err(), UnlockError::Transport);
|
||||
}
|
||||
|
||||
/// Opting in on a freemkv drive applies the recipe: BypassBd sends
|
||||
/// Set(Ake, on) and verifies it, BEFORE the handshake would run.
|
||||
#[test]
|
||||
fn arm_on_freemkv_drive_applies_the_recipe() {
|
||||
use crate::firmware::{
|
||||
ALL_FEATURES, CDB_FEATURE, CDB_STATE, CDB_VERB, Feature, RESP_MAGIC, STATE_OFF,
|
||||
STATE_PASSTHROUGH, Verb, build_set_cdb,
|
||||
};
|
||||
// A minimal freemkv drive: IDENTITY→magic, SET updates state, GET reads it.
|
||||
struct FwDrive {
|
||||
encryption: u8,
|
||||
hrl: u8,
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
/// A freemkv fw front-end: answers the vendor knock (IDENTITY / SET / GET)
|
||||
/// like fw `version` does, and delegates every other CDB to `inner`.
|
||||
struct FwFront<T> {
|
||||
inner: T,
|
||||
version: &'static str,
|
||||
/// Feature flag table indexed by wire id (slot 0 unused).
|
||||
flags: [u8; 7],
|
||||
refuse_sets: bool,
|
||||
dead_after_identity: bool,
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl<T> FwFront<T> {
|
||||
fn new(inner: T) -> Self {
|
||||
FwFront {
|
||||
inner,
|
||||
version: "0.9.2",
|
||||
flags: [crate::firmware::STATE_PASSTHROUGH; 7],
|
||||
refuse_sets: false,
|
||||
dead_after_identity: false,
|
||||
cdbs: Vec::new(),
|
||||
}
|
||||
}
|
||||
impl ScsiTransport for FwDrive {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
let mut n = 0;
|
||||
if cdb[CDB_VERB] == Verb::Identity as u8 {
|
||||
let mut resp = RESP_MAGIC.to_vec();
|
||||
resp.push(0x01);
|
||||
resp.extend(std::iter::repeat_n(STATE_PASSTHROUGH, ALL_FEATURES.len()));
|
||||
n = resp.len().min(data.len());
|
||||
data[..n].copy_from_slice(&resp[..n]);
|
||||
} else if cdb[CDB_VERB] == Verb::Set as u8
|
||||
&& cdb[CDB_FEATURE] == Feature::Encryption as u8
|
||||
{
|
||||
self.encryption = cdb[CDB_STATE];
|
||||
} else if cdb[CDB_VERB] == Verb::Set as u8 && cdb[CDB_FEATURE] == Feature::Hrl as u8
|
||||
{
|
||||
self.hrl = cdb[CDB_STATE];
|
||||
} else if cdb[CDB_VERB] == Verb::Get as u8
|
||||
&& cdb[CDB_FEATURE] == Feature::Encryption as u8
|
||||
&& !data.is_empty()
|
||||
{
|
||||
data[0] = self.encryption;
|
||||
n = 1;
|
||||
} else if cdb[CDB_VERB] == Verb::Get as u8
|
||||
&& cdb[CDB_FEATURE] == Feature::Hrl as u8
|
||||
&& !data.is_empty()
|
||||
{
|
||||
data[0] = self.hrl;
|
||||
n = 1;
|
||||
}
|
||||
/// Whether any cert-AKE command (REPORT KEY / SEND KEY) was issued.
|
||||
fn ran_cert_ake(&self) -> bool {
|
||||
self.cdbs
|
||||
.iter()
|
||||
.any(|c| c[0] == crate::scsi::SCSI_REPORT_KEY || c[0] == crate::scsi::SCSI_SEND_KEY)
|
||||
}
|
||||
fn sent_a_set(&self) -> bool {
|
||||
use crate::firmware::{CDB_VERB, READ_BUFFER_OPCODE, Verb};
|
||||
self.cdbs
|
||||
.iter()
|
||||
.any(|c| c[0] == READ_BUFFER_OPCODE && c[CDB_VERB] == Verb::Set as u8)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ScsiTransport> ScsiTransport for FwFront<T> {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: crate::scsi::DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
use crate::firmware::{CDB_FEATURE, CDB_STATE, CDB_VERB, READ_BUFFER_OPCODE, Verb};
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
if cdb[0] != READ_BUFFER_OPCODE {
|
||||
return self.inner.execute(cdb, dir, data, timeout_ms);
|
||||
}
|
||||
let status = |status: u8, n: usize| {
|
||||
Ok(crate::scsi::ScsiResult {
|
||||
status: 0,
|
||||
status,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
};
|
||||
let verb = cdb[CDB_VERB];
|
||||
let f = (cdb[CDB_FEATURE] as usize).min(6);
|
||||
if verb == Verb::Identity as u8 {
|
||||
let mut flags = [0u8; 6];
|
||||
flags.copy_from_slice(&self.flags[1..]);
|
||||
let mut p = crate::firmware::identity_reply(self.version, flags);
|
||||
p.resize(data.len(), 0);
|
||||
data.copy_from_slice(&p[..data.len()]);
|
||||
return status(0, data.len());
|
||||
}
|
||||
if self.dead_after_identity {
|
||||
return Err(crate::scsi::ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
if verb == Verb::Set as u8 {
|
||||
if self.refuse_sets {
|
||||
return status(crate::scsi::SCSI_STATUS_CHECK_CONDITION, 0);
|
||||
}
|
||||
self.flags[f] = cdb[CDB_STATE];
|
||||
} else if verb == Verb::Get as u8 {
|
||||
data.fill(0);
|
||||
data[0] = self.flags[f];
|
||||
}
|
||||
status(0, data.len())
|
||||
}
|
||||
let mut t = FwDrive {
|
||||
encryption: STATE_PASSTHROUGH,
|
||||
hrl: STATE_PASSTHROUGH,
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
|
||||
u.maybe_arm(&mut t).expect("armed");
|
||||
assert_eq!(t.hrl, STATE_OFF, "the recipe skipped revocation (Hrl=off)");
|
||||
// Forward the Stop methods (stop-design-v5 §2.3): the defaults would
|
||||
// swallow them, so wrapped tests would stop exercising cancellation.
|
||||
fn pause(&mut self, d: std::time::Duration) -> crate::scsi::Result<()> {
|
||||
self.inner.pause(d)
|
||||
}
|
||||
fn begin_critical(&mut self) -> crate::scsi::Result<()> {
|
||||
self.inner.begin_critical()
|
||||
}
|
||||
fn end_critical(&mut self) {
|
||||
self.inner.end_critical()
|
||||
}
|
||||
fn execute_cleanup(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: crate::scsi::DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
self.inner.execute_cleanup(cdb, dir, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
|
||||
// UT6 (stop-design-v5 §5.2; §2.3 "The test-only `FwFront<T>` … forwards all
|
||||
// four"): otherwise every FwFront-wrapped test silently stops exercising Stop.
|
||||
#[test]
|
||||
fn fwfront_forwards_new_methods() {
|
||||
use crate::scsi::DataDirection;
|
||||
use crate::scsi::mock::{Ev, MockTransport, Reply, StopFake};
|
||||
let inner = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 2])));
|
||||
let mut t = FwFront::new(inner);
|
||||
let d = std::time::Duration::from_millis(1);
|
||||
t.pause(d).expect("forwarded pause");
|
||||
t.begin_critical().expect("forwarded begin");
|
||||
t.end_critical();
|
||||
let rel = [0xA4, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0x3F, 0];
|
||||
t.execute_cleanup(&rel, DataDirection::FromDevice, &mut [0u8; 2], 5_000)
|
||||
.expect("forwarded cleanup");
|
||||
assert_eq!(
|
||||
t.encryption, STATE_OFF,
|
||||
"the recipe disabled encryption (Encryption=off)"
|
||||
t.inner.log,
|
||||
vec![Ev::Pause(d), Ev::Begin, Ev::End, Ev::Cleanup(rel.to_vec())],
|
||||
"all four reach the wrapped transport"
|
||||
);
|
||||
// IDENTITY, then Set(Hrl,off)+verify, then Set(Encryption,off)+verify.
|
||||
t.inner.cancelled = true;
|
||||
assert!(t.pause(d).is_err(), "a cancel reaches through the wrapper");
|
||||
assert!(t.begin_critical().is_err());
|
||||
}
|
||||
|
||||
fn vid_reply(vid: [u8; 16]) -> crate::scsi::mock::MockTransport {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut p = vec![0u8; 36];
|
||||
p[4..20].copy_from_slice(&vid);
|
||||
MockTransport::always(Reply::good(p))
|
||||
}
|
||||
|
||||
fn aacs_ctx(id: &crate::DriveId) -> UnlockCtx<'_> {
|
||||
UnlockCtx::new(id, DiscKind::Aacs)
|
||||
}
|
||||
|
||||
/// Opting in on a freemkv drive applies the recipe: BypassBd sends
|
||||
/// Set(Hrl, off) and Set(Encryption, off), each verified, after the
|
||||
/// IDENTITY gate and BEFORE the handshake would run.
|
||||
#[test]
|
||||
fn arm_on_freemkv_drive_applies_the_recipe() {
|
||||
use crate::firmware::{CDB_VERB, Feature, STATE_OFF, Verb, build_set_cdb};
|
||||
let mut t = FwFront::new(vid_reply([0x42; 16]));
|
||||
let u = AacsUnlocker::new(vec![host_cert()]).arm_before_unlock(ArmRecipe::BypassBd);
|
||||
assert!(u.maybe_arm(&mut t).expect("armed"));
|
||||
assert_eq!(t.flags[Feature::Hrl as usize], STATE_OFF);
|
||||
assert_eq!(t.flags[Feature::Encryption as usize], STATE_OFF);
|
||||
assert_eq!(t.cdbs.len(), 5);
|
||||
assert_eq!(t.cdbs[0][CDB_VERB], Verb::Identity as u8);
|
||||
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(t.cdbs[2][CDB_VERB], Verb::Get as u8);
|
||||
@@ -427,6 +520,122 @@ mod tests {
|
||||
assert_eq!(t.cdbs[4][CDB_VERB], Verb::Get as u8);
|
||||
}
|
||||
|
||||
/// Regression: an Encryption=off recipe needs NO host cert. With an empty
|
||||
/// cert list the drive is still armed and the unlock is the bare VID read —
|
||||
/// no cert AKE, no bus key (content already comes back de-bussed).
|
||||
#[test]
|
||||
fn encryption_off_recipes_unlock_without_certs_via_the_bare_vid() {
|
||||
for recipe in [ArmRecipe::BypassBd, ArmRecipe::BypassUhd, ArmRecipe::OemUhd] {
|
||||
let mut t = FwFront::new(vid_reply([0x42; 16]));
|
||||
let id = id();
|
||||
let out = AacsUnlocker::new(vec![])
|
||||
.arm_before_unlock(recipe)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.expect("no fault")
|
||||
.unwrap_or_else(|| panic!("{recipe:?}: armed drive is unlocked"));
|
||||
assert_eq!(out.vid, Some([0x42; 16]), "{recipe:?}");
|
||||
assert_eq!(out.bus_key, None, "{recipe:?}: no double bus-decrypt");
|
||||
assert!(!t.ran_cert_ake(), "{recipe:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Armed Encryption=off, then the bare VID read: a dead bus there aborts
|
||||
/// with Transport; a drive rejection is still an unlock, just without a VID.
|
||||
#[test]
|
||||
fn encryption_off_arm_then_bare_vid_failure() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
for (reply, want) in [
|
||||
(Reply::TransportFault, Err(UnlockError::Transport)),
|
||||
(Reply::illegal_request(), Ok(Some((None, None)))),
|
||||
(Reply::illegal_request_as_err(), Ok(Some((None, None)))),
|
||||
] {
|
||||
let mut t = FwFront::new(MockTransport::always(reply));
|
||||
let id = id();
|
||||
let got = AacsUnlocker::new(vec![host_cert()])
|
||||
.arm_before_unlock(ArmRecipe::BypassBd)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.map(|o| o.map(|u| (u.vid, u.bus_key)));
|
||||
assert_eq!(got, want);
|
||||
assert!(t.sent_a_set(), "the drive was armed first");
|
||||
assert!(!t.ran_cert_ake());
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: with certs present, an Encryption=off arm still skips the
|
||||
/// cert AKE and never reports a bus key.
|
||||
#[test]
|
||||
fn encryption_off_recipe_with_certs_skips_the_cert_ake() {
|
||||
let mut t = FwFront::new(vid_reply([0x42; 16]));
|
||||
let id = id();
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.arm_before_unlock(ArmRecipe::BypassBd)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.expect("no fault")
|
||||
.expect("unlocked");
|
||||
assert_eq!(out.bus_key, None);
|
||||
assert!(!t.ran_cert_ake());
|
||||
}
|
||||
|
||||
/// A refused recipe (and OemBd, which leaves encryption on) proceeds to the
|
||||
/// plain cert AKE, which still unlocks the drive and learns the bus key.
|
||||
#[test]
|
||||
fn refused_or_cert_recipes_run_the_cert_ake() {
|
||||
for (recipe, refuse) in [(ArmRecipe::BypassBd, true), (ArmRecipe::OemBd, false)] {
|
||||
let mut emu = handshake::tests::DriveEmu::new();
|
||||
emu.serve_data_keys = true;
|
||||
let (lax, lay) = (emu.la_x, emu.la_y);
|
||||
let mut t = FwFront::new(emu);
|
||||
t.refuse_sets = refuse;
|
||||
let id = id();
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.with_test_v1_anchor(lax, lay)
|
||||
.arm_before_unlock(recipe)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.expect("no fault")
|
||||
.expect("the cert route unlocks");
|
||||
assert_eq!(out.vid, Some([0x5Au8; 16]), "{recipe:?}");
|
||||
assert!(out.bus_key.is_some(), "{recipe:?}");
|
||||
assert!(t.ran_cert_ake(), "{recipe:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: fw 0.8.x is below MIN_FW_VERSION, so the recipe is never
|
||||
/// sent and the plain cert route runs.
|
||||
#[test]
|
||||
fn unsupported_firmware_is_not_armed() {
|
||||
let mut emu = handshake::tests::DriveEmu::new();
|
||||
emu.serve_data_keys = true;
|
||||
let (lax, lay) = (emu.la_x, emu.la_y);
|
||||
let mut t = FwFront::new(emu);
|
||||
t.version = "0.8.3";
|
||||
let id = id();
|
||||
let out = AacsUnlocker::new(vec![host_cert()])
|
||||
.with_test_v1_anchor(lax, lay)
|
||||
.arm_before_unlock(ArmRecipe::BypassBd)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.expect("no fault")
|
||||
.expect("the cert route unlocks");
|
||||
assert!(out.bus_key.is_some());
|
||||
assert!(!t.sent_a_set(), "no recipe SET on unsupported firmware");
|
||||
}
|
||||
|
||||
/// A dead bus mid-arm aborts the whole unlock, with or without certs.
|
||||
#[test]
|
||||
fn dead_bus_during_arm_aborts_the_unlock() {
|
||||
for certs in [vec![host_cert()], vec![]] {
|
||||
let mut t = FwFront::new(vid_reply([0x42; 16]));
|
||||
t.dead_after_identity = true;
|
||||
let id = id();
|
||||
let n = certs.len();
|
||||
let err = AacsUnlocker::new(certs)
|
||||
.arm_before_unlock(ArmRecipe::BypassBd)
|
||||
.unlock(&mut t, &aacs_ctx(&id))
|
||||
.unwrap_err();
|
||||
assert_eq!(err, UnlockError::Transport, "{n} certs");
|
||||
assert!(!t.ran_cert_ake());
|
||||
}
|
||||
}
|
||||
|
||||
/// With no host certs there is nothing to authenticate with → `Ok(false)`,
|
||||
/// and the transport is never touched.
|
||||
#[test]
|
||||
|
||||
+1
-5
@@ -22,11 +22,7 @@ impl Error {
|
||||
|
||||
/// True if this is a transport-layer SCSI failure (bus dead).
|
||||
pub fn is_transport_failure(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Error::Scsi(s)
|
||||
if s.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && s.sense.is_none()
|
||||
)
|
||||
matches!(self, Error::Scsi(s) if crate::scsi::is_dead_bus(s))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+161
-41
@@ -10,7 +10,7 @@
|
||||
|
||||
mod error;
|
||||
use crate::css::error::{Error, Result, step_err};
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::scsi::{AgidGuard, DataDirection, ScsiTransport};
|
||||
|
||||
// Issue ONE CSS bus-auth CDB, honouring the transport contract: treats a
|
||||
// non-zero status as CssAuthFailed and checks bytes_transferred >= min_bytes
|
||||
@@ -310,12 +310,13 @@ fn unlock_css_reads_inner(scsi: &mut dyn ScsiTransport, _lba: u32) -> Result<()>
|
||||
// ── Step 1: Bus Authentication ────────────────────────────────────────────
|
||||
|
||||
// Runs the CSS bus-auth handshake to set ASF=1 (invalidate AGIDs, allocate,
|
||||
// challenge/response). Returns the AGID; no bus key is derived.
|
||||
// challenge/response). Returns the AGID, still held (D9); no bus key is derived.
|
||||
fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result<u8> {
|
||||
// Invalidate all AGIDs via REPORT KEY format 0x3F. This is also what makes
|
||||
// an abandoned AGID self-heal — which is not a reason to abandon one.
|
||||
// an abandoned AGID self-heal — which is not a reason to abandon one. Plain
|
||||
// `execute`: nothing is held yet, so a Stop refuses these (stop-design-v5 §2.3).
|
||||
for agid in 0..4u8 {
|
||||
release_agid(scsi, agid);
|
||||
invalidate_agid(scsi, agid);
|
||||
}
|
||||
|
||||
// Allocate AGID
|
||||
@@ -329,28 +330,37 @@ fn establish_authenticated_session(scsi: &mut dyn ScsiTransport) -> Result<u8> {
|
||||
)?;
|
||||
let agid = (buf[7] >> 6) & 0x03;
|
||||
|
||||
// From here on we hold the AGID; release it on any failure (see
|
||||
// [`release_agid`]) instead of abandoning it.
|
||||
let r = authenticate_with_agid(scsi, agid);
|
||||
if r.is_err() {
|
||||
release_agid(scsi, agid);
|
||||
}
|
||||
r.map(|()| agid)
|
||||
// From here on we hold the AGID: a failure drops the guard, which releases
|
||||
// it even after a Stop (SS-7, evidence: libaacs mmc.c AGID invalidation).
|
||||
let mut guard = AgidGuard::new(scsi, agid, agid_release_cdb(agid));
|
||||
authenticate_with_agid(&mut *guard, agid)?;
|
||||
// D9 (stop-design-v5 §2.3 "defused on success, so it is still held for
|
||||
// `read_disc_key`"): hand the AGID over unreleased.
|
||||
Ok(guard.defuse())
|
||||
}
|
||||
|
||||
/// Release an AGID (REPORT KEY format 0x3F). Best-effort: a failure to release
|
||||
/// is not a failure of the operation that is already failing.
|
||||
fn release_agid(scsi: &mut dyn ScsiTransport, agid: u8) {
|
||||
/// REPORT KEY key format 0x3F for `agid` (invalidate / release it).
|
||||
fn agid_release_cdb(agid: u8) -> [u8; 12] {
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
|
||||
cdb[10] = (agid << 6) | 0x3F;
|
||||
cdb
|
||||
}
|
||||
|
||||
/// Best-effort invalidate of an AGID this session does not hold (the
|
||||
/// pre-allocation sweep).
|
||||
fn invalidate_agid(scsi: &mut dyn ScsiTransport, agid: u8) {
|
||||
let mut buf = [0u8; 8];
|
||||
let _ = scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000);
|
||||
let _ = scsi.execute(
|
||||
&agid_release_cdb(agid),
|
||||
DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
);
|
||||
}
|
||||
|
||||
/// The challenge-response half of [`establish_authenticated_session`], with the
|
||||
/// AGID already allocated. Split out so its caller can release the AGID on any
|
||||
/// failure without a Drop guard or a release at each early return.
|
||||
/// AGID already allocated and held by the caller's [`AgidGuard`].
|
||||
fn authenticate_with_agid(scsi: &mut dyn ScsiTransport, agid: u8) -> Result<()> {
|
||||
// Host sends challenge. The spec wants a fresh per-session random nonce,
|
||||
// not a fixed constant — a predictable challenge weakens the bus-auth
|
||||
@@ -740,24 +750,6 @@ mod tests {
|
||||
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
|
||||
}
|
||||
|
||||
// Fix 9: the bounds checks are real `assert!`s, not `debug_assert!`s (stripped
|
||||
// in release), so an out-of-range index is a clean panic in EVERY build.
|
||||
// `catch_unwind` holds regardless of the test's build profile.
|
||||
#[test]
|
||||
fn crypt_key_asserts_out_of_range_indices() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
// key_type must be < 3.
|
||||
assert!(
|
||||
std::panic::catch_unwind(|| crypt_key(3, 0, &challenge)).is_err(),
|
||||
"key_type out of range must panic (not index OOB)"
|
||||
);
|
||||
// variant must be < 32.
|
||||
assert!(
|
||||
std::panic::catch_unwind(|| crypt_key(0, 32, &challenge)).is_err(),
|
||||
"variant out of range must panic (not index OOB)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_varies_by_type() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
@@ -847,19 +839,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// crypt_key's `key_type < 3` debug_assert must fire for key_type 3
|
||||
// (which would otherwise index PERM_CHALLENGE out of bounds).
|
||||
// crypt_key's `key_type < 3` assert! (not debug_assert!) must fire in
|
||||
// every profile; `expected` rejects the index-OOB panic that would follow.
|
||||
#[test]
|
||||
#[should_panic]
|
||||
#[should_panic(expected = "crypt_key: key_type out of range")]
|
||||
fn crypt_key_rejects_out_of_range_key_type() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let _ = crypt_key(3, 0, &challenge);
|
||||
}
|
||||
|
||||
// crypt_key's `variant < 32` debug_assert must fire for variant 32
|
||||
// (which would otherwise index VARIANTS/PERM_VARIANT out of bounds).
|
||||
// crypt_key's `variant < 32` assert! must fire in every profile, not
|
||||
// the VARIANTS/PERM_VARIANT index-OOB panic that would follow it.
|
||||
#[test]
|
||||
#[should_panic]
|
||||
#[should_panic(expected = "crypt_key: variant out of range")]
|
||||
fn crypt_key_rejects_out_of_range_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let _ = crypt_key(0, 32, &challenge);
|
||||
@@ -1412,4 +1404,132 @@ mod tests {
|
||||
"key_type 1 and 2 must diverge (distinct PERM_VARIANT rows)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stop (stop-design-v5 §2.3, ST-U1) ───────────────────────────────────
|
||||
|
||||
/// A drive that completes the CSS bus-auth for real: it answers Key1 for the
|
||||
/// host challenge under `variant`, serves a challenge, and allocates AGID 1.
|
||||
struct CssEmu {
|
||||
variant: u8,
|
||||
host_challenge: [u8; 10],
|
||||
/// Answer Key1 with zeros, which matches no variant → CssAuthFailed.
|
||||
bad_key1: bool,
|
||||
}
|
||||
|
||||
impl ScsiTransport for CssEmu {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<crate::scsi::ScsiResult> {
|
||||
let mut r = vec![0u8; data.len()];
|
||||
match (cdb[0], cdb[10] & 0x3F) {
|
||||
(crate::scsi::SCSI_REPORT_KEY, 0x00) => r[7] = 1 << 6, // AGID 1
|
||||
(crate::scsi::SCSI_REPORT_KEY, 0x02) if !self.bad_key1 => {
|
||||
let k = crypt_key(0, self.variant, &self.host_challenge);
|
||||
(0..5).for_each(|j| r[4 + j] = k[4 - j]);
|
||||
}
|
||||
(crate::scsi::SCSI_SEND_KEY, 0x01) => {
|
||||
(0..10).for_each(|i| self.host_challenge[i] = data[4 + 9 - i]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if cdb[0] != crate::scsi::SCSI_SEND_KEY {
|
||||
data.copy_from_slice(&r);
|
||||
}
|
||||
Ok(crate::scsi::ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn css_emu() -> CssEmu {
|
||||
CssEmu {
|
||||
variant: 7,
|
||||
host_challenge: [0u8; 10],
|
||||
bad_key1: false,
|
||||
}
|
||||
}
|
||||
|
||||
// UT4 / G2 (stop-design-v5 §5.2, D9 "Keep holding it"): on success the AGID is
|
||||
// `defuse`d, so it is still held for `read_disc_key` and never released.
|
||||
// Per design D9; do not change without a design citation proving otherwise.
|
||||
#[test]
|
||||
fn css_success_keeps_agid() {
|
||||
use crate::scsi::mock::{StopFake, agid_ledger, is_agid_release};
|
||||
let mut t = StopFake::new(css_emu());
|
||||
unlock_css_reads(&mut t, 0).expect("the bus-auth completes");
|
||||
let (allocs, held) = agid_ledger(&t.log);
|
||||
assert_eq!(
|
||||
(allocs, held),
|
||||
(1, true),
|
||||
"one AGID, still held after success"
|
||||
);
|
||||
assert!(t.cleanups().is_empty(), "no release on success");
|
||||
let execs = t.execs();
|
||||
let alloc = execs.iter().position(|c| c[0] == 0xA4 && c[10] & 0x3F == 0);
|
||||
let after = &execs[alloc.expect("allocated") + 1..];
|
||||
assert!(
|
||||
!after.iter().any(|c| is_agid_release(c)),
|
||||
"no 0x3F after success"
|
||||
);
|
||||
let disc_key = after.last().expect("the disc-key read follows");
|
||||
assert_eq!(
|
||||
(disc_key[0], disc_key[7]),
|
||||
(0xAD, 0x02),
|
||||
"READ DVD STRUCTURE fmt 2"
|
||||
);
|
||||
assert_eq!(disc_key[10], 1 << 6, "issued under the still-held AGID 1");
|
||||
}
|
||||
|
||||
// SS-7 (evidence): a CSS challenge that fails after allocation releases the
|
||||
// AGID exactly once, through `execute_cleanup`, even when a Stop caused it.
|
||||
#[test]
|
||||
fn css_failed_challenge_releases_agid_once_via_cleanup() {
|
||||
use crate::scsi::mock::{StopFake, agid_ledger};
|
||||
let stop_at_challenge: fn(&[u8]) -> bool =
|
||||
|c| c[0] == crate::scsi::SCSI_SEND_KEY && c[10] & 0x3F == 0x01;
|
||||
for cancel in [false, true] {
|
||||
let mut emu = css_emu();
|
||||
if !cancel {
|
||||
emu.bad_key1 = true;
|
||||
}
|
||||
let mut t = StopFake::new(emu);
|
||||
if cancel {
|
||||
t.cancel_after = Some(stop_at_challenge);
|
||||
}
|
||||
let e = establish_authenticated_session(&mut t).expect_err("auth fails");
|
||||
assert_eq!(e.is_transport_failure(), cancel, "a Stop is a refusal");
|
||||
assert_eq!(agid_ledger(&t.log), (1, false), "cancel={cancel}");
|
||||
assert_eq!(
|
||||
t.cleanups().len(),
|
||||
1,
|
||||
"exactly one release, cancel={cancel}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// UT5 (stop-design-v5 §5.2): "zero invalidate CDBs after a cancel" — the
|
||||
// pre-allocation invalidate loop uses `execute`, so a Stop refuses it.
|
||||
#[test]
|
||||
fn invalidate_loops_refused_after_cancel() {
|
||||
use crate::scsi::mock::StopFake;
|
||||
let mut t = StopFake::new(css_emu());
|
||||
t.cancelled = true;
|
||||
let e = establish_authenticated_session(&mut t).expect_err("cancelled");
|
||||
assert!(e.is_transport_failure());
|
||||
assert!(
|
||||
t.execs().is_empty(),
|
||||
"no CDB reached the drive: {:?}",
|
||||
t.log
|
||||
);
|
||||
assert!(
|
||||
t.cleanups().is_empty(),
|
||||
"nothing allocated, nothing released"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+109
-39
@@ -17,7 +17,7 @@
|
||||
//! DumpAll instead encodes a 32-bit address in `cdb[5..9]` and returns MEMREAD_LEN bytes.
|
||||
//! Features default to passthrough. Reset selects OEM, built-in defaults or saved flash state.
|
||||
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus};
|
||||
|
||||
// ── Wire constants (mirror of abi.rs) ────────────────────────────────────────
|
||||
|
||||
@@ -35,6 +35,12 @@ pub const DEBUG_KNOCK: [u8; 2] = [0xDE, 0xB9];
|
||||
/// Response-framing magic leading the [`Verb::Identity`] reply (`b"freemkv"`).
|
||||
pub const RESP_MAGIC: &[u8] = b"freemkv";
|
||||
|
||||
/// Oldest firmware `(major, minor)` this mirror arms: 0.9 made wire id `0x06`
|
||||
/// the consolidated `Encryption` lever. Released 0.8.x (`0x06` = `Ake`, separate
|
||||
/// `0x07 Bus`) de-busses via `0x06` alone only as a hardware-dependent side
|
||||
/// effect (not validated), and 0.7.x uses a sub-function grammar: both refused.
|
||||
pub const MIN_FW_VERSION: (u32, u32) = (0, 9);
|
||||
|
||||
/// Length of a freemkv (READ BUFFER) CDB.
|
||||
pub const CDB_LEN: usize = 10;
|
||||
/// Offset of the opcode byte (`cdb[0]`).
|
||||
@@ -123,7 +129,8 @@ pub const RESET_TO_OEM: u8 = 0xFF;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum Verb {
|
||||
/// Status/ping — returns [`RESP_MAGIC`] + version + the feature-state table.
|
||||
/// Status/ping — returns `freemkv <version>` (no NUL), then the raw
|
||||
/// feature-flag bytes `flag[0x01..=0x06]`, zero-padded to the window.
|
||||
Identity = 0x01,
|
||||
/// Set one feature (`cdb[5]`) to a state (`cdb[6]`).
|
||||
Set = 0x02,
|
||||
@@ -191,11 +198,12 @@ 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,
|
||||
/// 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 (BD/UHD) capability widen gate. [`STATE_PASSTHROUGH`]
|
||||
/// (`0xFF`) = OEM; [`STATE_OFF`] (`0x00`) = No (currently no-op on
|
||||
/// byte-extraction classifiers like BU40N, pending RE); [`STATE_ON`] (`0x01`)
|
||||
/// = Yes. fw 0.9.2 folds BD into this gate by name only: its BD-refuse detour
|
||||
/// still reads wire id `0x04` (`Bd`, deprecated), which this mirror does not
|
||||
/// expose, so on 0.9.2 this lever governs UHD acceptance.
|
||||
Unrestricted = 0x03,
|
||||
/// Host Revocation List handling on the cert path. [`STATE_PASSTHROUGH`]
|
||||
/// (`0xFF`) = OEM enforce; [`STATE_OFF`] (`0x00`) = off (skip the HRL lookup —
|
||||
@@ -212,8 +220,8 @@ pub enum Feature {
|
||||
Encryption = 0x06,
|
||||
}
|
||||
|
||||
/// The set of all features, in the canonical order the IDENTITY feature-state
|
||||
/// table serialises them (and the order [`FirmwareControl::states`] probes).
|
||||
/// Every exposed feature, in wire-id order (the order [`FirmwareControl::states`]
|
||||
/// probes). The IDENTITY flag table also carries the unexposed `0x04` slot.
|
||||
pub const ALL_FEATURES: [Feature; 5] = [
|
||||
Feature::Speed,
|
||||
Feature::Region,
|
||||
@@ -390,6 +398,11 @@ pub enum FirmwareError {
|
||||
/// The drive rejected the command (CHECK CONDITION / vendor refusal). Not a
|
||||
/// freemkv drive, or the feature/state is unsupported.
|
||||
Rejected,
|
||||
/// IDENTITY did not report freemkv firmware at or above [`MIN_FW_VERSION`];
|
||||
/// a recipe was refused before any SET/RESET was sent.
|
||||
UnsupportedFirmware,
|
||||
/// A caller argument was out of range; nothing was sent to the drive.
|
||||
InvalidArgument,
|
||||
/// A verify-after-set (`SET` then `GET`) read back a state that did not
|
||||
/// match what was written. Carries `(feature, wanted, got)`.
|
||||
VerifyFailed {
|
||||
@@ -404,13 +417,6 @@ pub enum FirmwareError {
|
||||
/// Result of a firmware command.
|
||||
pub type Result<T> = std::result::Result<T, FirmwareError>;
|
||||
|
||||
/// Whether a transport error is a genuine dead bus (a senseless
|
||||
/// transport-failure status) rather than a drive rejection surfaced through a
|
||||
/// non-conforming transport (`Err` carrying a sense).
|
||||
fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
|
||||
e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
|
||||
}
|
||||
|
||||
// ── Parsed replies ───────────────────────────────────────────────────────────
|
||||
|
||||
/// The current state of every feature, indexed by [`Feature`].
|
||||
@@ -463,30 +469,50 @@ impl FeatureStates {
|
||||
}
|
||||
|
||||
/// A parsed [`Verb::Identity`] reply. Only present when the reply led with
|
||||
/// [`RESP_MAGIC`]. The firmware answers IDENTITY with a human banner
|
||||
/// `freemkv <version>` (NUL-padded) — there is NO binary state table in the
|
||||
/// reply, so this carries only the version string; read the live feature states
|
||||
/// [`RESP_MAGIC`]. The reply is `freemkv <version>` with no terminator, then the
|
||||
/// raw feature-flag bytes; only the version is parsed — read the live states
|
||||
/// via [`FirmwareControl::states`]/[`get`](FirmwareControl::get).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FirmwareIdentity {
|
||||
/// Firmware version string, the banner text after [`RESP_MAGIC`] up to the
|
||||
/// first NUL, trimmed (e.g. `"0.7.1"`).
|
||||
/// The version token after `freemkv ` (printable non-space ASCII), e.g.
|
||||
/// `"0.9.2"`; empty if no `freemkv ` token follows the magic. A Speed cap in the
|
||||
/// printable range can append one character after the patch number.
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl FirmwareIdentity {
|
||||
/// Parse an IDENTITY data-in payload. `None` unless it leads with
|
||||
/// [`RESP_MAGIC`] (i.e. not freemkv firmware). Layout: `RESP_MAGIC` (7) +
|
||||
/// ASCII version banner, NUL-terminated/padded.
|
||||
/// [`RESP_MAGIC`] (i.e. not freemkv firmware).
|
||||
pub fn parse(bytes: &[u8]) -> Option<Self> {
|
||||
if !verify_response(bytes) {
|
||||
return None;
|
||||
}
|
||||
let tail = &bytes[RESP_MAGIC.len()..];
|
||||
let end = tail.iter().position(|&b| b == 0).unwrap_or(tail.len());
|
||||
let version = String::from_utf8_lossy(&tail[..end]).trim().to_string();
|
||||
let version = match bytes[RESP_MAGIC.len()..].split_first() {
|
||||
Some((b' ', rest)) => rest
|
||||
.iter()
|
||||
.take_while(|b| b.is_ascii_graphic())
|
||||
.map(|&b| char::from(b))
|
||||
.collect(),
|
||||
_ => String::new(),
|
||||
};
|
||||
Some(FirmwareIdentity { version })
|
||||
}
|
||||
|
||||
/// Whether this firmware speaks the grammar this mirror sends (version at
|
||||
/// or above [`MIN_FW_VERSION`]). `false` for an unparseable version.
|
||||
pub fn is_supported(&self) -> bool {
|
||||
self.major_minor().is_some_and(|v| v >= MIN_FW_VERSION)
|
||||
}
|
||||
|
||||
fn major_minor(&self) -> Option<(u32, u32)> {
|
||||
let mut parts = self.version.split('.');
|
||||
let major = parts.next()?.parse().ok()?;
|
||||
let minor = parts.next()?;
|
||||
let digits = minor
|
||||
.find(|c: char| !c.is_ascii_digit())
|
||||
.map_or(minor, |end| &minor[..end]);
|
||||
Some((major, digits.parse().ok()?))
|
||||
}
|
||||
}
|
||||
|
||||
/// A named arming recipe (see [`FirmwareControl`] `arm_*` helpers). Lets a
|
||||
@@ -509,6 +535,17 @@ pub enum ArmRecipe {
|
||||
StealthOem,
|
||||
}
|
||||
|
||||
impl ArmRecipe {
|
||||
/// Whether the recipe sets `Encryption = off`: the drive then acts
|
||||
/// pre-authenticated and serves de-bussed content (no cert AKE needed).
|
||||
pub(crate) fn disables_encryption(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
ArmRecipe::OemUhd | ArmRecipe::BypassBd | ArmRecipe::BypassUhd
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── FirmwareControl ──────────────────────────────────────────────────────────
|
||||
|
||||
/// A timeout every firmware command uses (ms). Matches the wider unlocker.
|
||||
@@ -518,13 +555,14 @@ const CMD_TIMEOUT_MS: u32 = 5_000;
|
||||
/// [`ScsiTransport`]. Borrows the transport for the lifetime of the control.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use freemkv_unlock::firmware::{FirmwareControl, Feature};
|
||||
/// # fn demo(scsi: &mut dyn freemkv_unlock::scsi::ScsiTransport) {
|
||||
/// # use freemkv_unlock::firmware::{FirmwareControl, FirmwareError};
|
||||
/// # fn demo(scsi: &mut dyn freemkv_unlock::scsi::ScsiTransport) -> Result<(), FirmwareError> {
|
||||
/// let mut fw = FirmwareControl::new(scsi);
|
||||
/// if fw.is_freemkv().unwrap_or(false) {
|
||||
/// // Encryption off on a UHD disc, revocation ignored:
|
||||
/// let _ = fw.arm_oem_uhd();
|
||||
/// }
|
||||
/// // Encryption off on a UHD disc, revocation ignored. Refused with
|
||||
/// // `UnsupportedFirmware` unless IDENTITY reports a supported freemkv drive;
|
||||
/// // `Transport` (dead bus) must abort the caller.
|
||||
/// fw.arm_oem_uhd()?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub struct FirmwareControl<'a> {
|
||||
@@ -556,9 +594,8 @@ impl<'a> FirmwareControl<'a> {
|
||||
}
|
||||
|
||||
/// Send IDENTITY and parse the magic + version banner.
|
||||
/// `Ok(None)` if the drive answered but the reply lacked [`RESP_MAGIC`] (not
|
||||
/// freemkv firmware); `Err(Transport)` only on a dead bus, `Err(Rejected)`
|
||||
/// if the drive refused the command outright (also "not freemkv").
|
||||
/// `Ok(None)` if the reply lacked [`RESP_MAGIC`] or the drive refused the
|
||||
/// command (not freemkv firmware); `Err(Transport)` only on a dead bus.
|
||||
pub fn identity(&mut self) -> Result<Option<FirmwareIdentity>> {
|
||||
let cdb = build_identity_cdb(MEMREAD_LEN as u16);
|
||||
match self.exec_in(&cdb) {
|
||||
@@ -633,7 +670,9 @@ impl<'a> FirmwareControl<'a> {
|
||||
self.exec_in(&cdb).map(|_| ())
|
||||
}
|
||||
|
||||
/// SET a feature, then GET it back and confirm the state stuck.
|
||||
/// SET a feature, then GET it back and confirm the state stuck. Blind spot:
|
||||
/// fw answers a refused/out-of-range GET with GOOD + zeros, which reads as
|
||||
/// [`STATE_OFF`]; the recipes gate on [`Self::require_supported`] first.
|
||||
fn set_verify(&mut self, feature: Feature, state: u8) -> Result<()> {
|
||||
self.set(feature, state)?;
|
||||
let got = self.get(feature)?;
|
||||
@@ -648,6 +687,15 @@ impl<'a> FirmwareControl<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse (`UnsupportedFirmware`) unless IDENTITY reports a supported
|
||||
/// freemkv firmware; every recipe calls this before its first SET/RESET.
|
||||
fn require_supported(&mut self) -> Result<()> {
|
||||
match self.identity()? {
|
||||
Some(id) if id.is_supported() => Ok(()),
|
||||
_ => Err(FirmwareError::UnsupportedFirmware),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the current state of every feature via repeated GET.
|
||||
pub fn states(&mut self) -> Result<FeatureStates> {
|
||||
let mut s = FeatureStates::all_passthrough();
|
||||
@@ -701,11 +749,12 @@ impl<'a> FirmwareControl<'a> {
|
||||
pub fn force_region_bd(&mut self, region: BdRegion) -> Result<()> {
|
||||
self.set(Feature::Region, region.state())
|
||||
}
|
||||
/// Force a specific DVD region (1..=8). Returns [`FirmwareError::Rejected`]
|
||||
/// for an out-of-range region rather than sending a malformed state.
|
||||
/// Force a specific DVD region (1..=8). Returns
|
||||
/// [`FirmwareError::InvalidArgument`] for an out-of-range region without
|
||||
/// touching the drive.
|
||||
pub fn force_region_dvd(&mut self, region: u8) -> Result<()> {
|
||||
if !(1..=8).contains(®ion) {
|
||||
return Err(FirmwareError::Rejected);
|
||||
return Err(FirmwareError::InvalidArgument);
|
||||
}
|
||||
self.set(Feature::Region, REGION_DVD_BASE + region)
|
||||
}
|
||||
@@ -715,6 +764,8 @@ impl<'a> FirmwareControl<'a> {
|
||||
}
|
||||
|
||||
// ── Named recipes (the "modes" table) ─────────────────────────────────────
|
||||
// Each recipe first issues IDENTITY and refuses unsupported firmware. A
|
||||
// recipe that fails midway leaves its earlier SETs applied (no rollback).
|
||||
|
||||
/// **arm_oem_bd** — OEM-style Blu-ray (AACS 1.0) rip with a REAL AKE, only
|
||||
/// revocation disabled.
|
||||
@@ -724,6 +775,7 @@ impl<'a> FirmwareControl<'a> {
|
||||
/// host-cert handshake and returns bus-encrypted content, so the host
|
||||
/// performs the AKE and de-busses. Rips: BD with an otherwise-revoked cert.
|
||||
pub fn arm_oem_bd(&mut self) -> Result<()> {
|
||||
self.require_supported()?;
|
||||
self.set_verify(Feature::Hrl, STATE_OFF)
|
||||
}
|
||||
|
||||
@@ -734,6 +786,7 @@ impl<'a> FirmwareControl<'a> {
|
||||
/// (content de-bussed — the consolidated cert/bus bypass). Rips: UHD via
|
||||
/// the OEM path.
|
||||
pub fn arm_oem_uhd(&mut self) -> Result<()> {
|
||||
self.require_supported()?;
|
||||
self.set_verify(Feature::Unrestricted, STATE_ON)?;
|
||||
self.set_verify(Feature::Hrl, STATE_OFF)?;
|
||||
self.set_verify(Feature::Encryption, STATE_OFF)
|
||||
@@ -745,6 +798,7 @@ impl<'a> FirmwareControl<'a> {
|
||||
/// pre-authenticated, no handshake, content de-bussed) and `Hrl = skip`
|
||||
/// (revocation off). Rips: BD with no cert.
|
||||
pub fn arm_bypass_bd(&mut self) -> Result<()> {
|
||||
self.require_supported()?;
|
||||
self.set_verify(Feature::Hrl, STATE_OFF)?;
|
||||
self.set_verify(Feature::Encryption, STATE_OFF)
|
||||
}
|
||||
@@ -755,6 +809,7 @@ impl<'a> FirmwareControl<'a> {
|
||||
/// (the consolidated cert/bus bypass), and `Hrl = skip` (revocation off).
|
||||
/// Rips: UHD with no cert.
|
||||
pub fn arm_bypass_uhd(&mut self) -> Result<()> {
|
||||
self.require_supported()?;
|
||||
self.set_verify(Feature::Unrestricted, STATE_ON)?;
|
||||
self.set_verify(Feature::Hrl, STATE_OFF)?;
|
||||
self.set_verify(Feature::Encryption, STATE_OFF)
|
||||
@@ -764,7 +819,9 @@ impl<'a> FirmwareControl<'a> {
|
||||
///
|
||||
/// Issues RESET (every feature → passthrough) and verifies each feature read
|
||||
/// back as [`STATE_PASSTHROUGH`]. Rips: nothing — this DISARMS the drive.
|
||||
/// Needs fw >= [`MIN_FW_VERSION`]; raw [`reset`](Self::reset) has no gate.
|
||||
pub fn arm_stealth_oem(&mut self) -> Result<()> {
|
||||
self.require_supported()?;
|
||||
self.reset()?;
|
||||
for feature in ALL_FEATURES {
|
||||
let got = self.get(feature)?;
|
||||
@@ -791,6 +848,19 @@ impl<'a> FirmwareControl<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Test fixture: the fw 0.9.x IDENTITY wire shape (core.rs `identity_blob` +
|
||||
/// handler): `freemkv <ver>` with NO NUL, then the 6 raw flag bytes
|
||||
/// `flag[0x01..=0x06]`, then zero padding to the 64-byte window.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn identity_reply(version: &str, flags: [u8; 6]) -> Vec<u8> {
|
||||
let mut p = RESP_MAGIC.to_vec();
|
||||
p.push(b' ');
|
||||
p.extend_from_slice(version.as_bytes());
|
||||
p.extend_from_slice(&flags);
|
||||
p.resize(MEMREAD_LEN, 0);
|
||||
p
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
+276
-63
@@ -27,8 +27,7 @@ fn feature_values_match_abi() {
|
||||
assert_eq!(Feature::Unrestricted as u8, 0x03);
|
||||
assert_eq!(Feature::Hrl as u8, 0x05);
|
||||
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 0x04 (`Bd`, deprecated in fw 0.9.2) is deliberately not exposed.
|
||||
// Wire id 0x07 (was `Bus`) is retired — proven inert on BU40N/MT1959.
|
||||
assert_eq!(ALL_FEATURES.len(), 5);
|
||||
}
|
||||
@@ -174,14 +173,60 @@ fn region_state_bytes() {
|
||||
|
||||
#[test]
|
||||
fn identity_parse_reads_version_banner() {
|
||||
// The firmware answers IDENTITY with the ASCII banner `freemkv <ver>`,
|
||||
// NUL-padded — there is no binary state table.
|
||||
let mut payload = Vec::new();
|
||||
payload.extend_from_slice(RESP_MAGIC);
|
||||
payload.extend_from_slice(b" 0.7.1\0");
|
||||
payload.resize(64, 0); // NUL-pad to the allocation window
|
||||
let payload = identity_reply("0.9.2", [0x00; 6]);
|
||||
let id = FirmwareIdentity::parse(&payload).expect("has magic");
|
||||
assert_eq!(id.version, "0.9.2");
|
||||
}
|
||||
|
||||
/// Regression: the appended flag table (fresh-boot DEFAULT_FLAGS here) must not
|
||||
/// leak into the version, whatever the live feature states are.
|
||||
#[test]
|
||||
fn identity_parse_ignores_the_appended_flag_table() {
|
||||
for flags in [
|
||||
[0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF],
|
||||
[0x0F, 0x0F, 0x01, 0xFF, 0x00, 0x00],
|
||||
] {
|
||||
let id = FirmwareIdentity::parse(&identity_reply("0.9.2", flags)).expect("has magic");
|
||||
assert_eq!(id.version, "0.9.2", "flags {flags:02X?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Released fw 0.7.1 answers `freemkv 0.7.1` + zero padding: parsed, but
|
||||
/// below MIN_FW_VERSION.
|
||||
#[test]
|
||||
fn identity_parse_legacy_07_is_unsupported() {
|
||||
let mut payload = b"freemkv 0.7.1".to_vec();
|
||||
payload.resize(MEMREAD_LEN, 0);
|
||||
let id = FirmwareIdentity::parse(&payload).expect("has magic");
|
||||
assert_eq!(id.version, "0.7.1");
|
||||
assert!(!id.is_supported());
|
||||
}
|
||||
|
||||
/// Magic with no ` <version>` token parses as freemkv with an empty version.
|
||||
#[test]
|
||||
fn identity_parse_without_a_token_has_an_empty_version() {
|
||||
let mut payload = RESP_MAGIC.to_vec();
|
||||
payload.push(0x01);
|
||||
payload.resize(MEMREAD_LEN, 0);
|
||||
let id = FirmwareIdentity::parse(&payload).expect("has magic");
|
||||
assert_eq!(id.version, "");
|
||||
assert!(!id.is_supported());
|
||||
}
|
||||
|
||||
/// The supported-grammar predicate: 0.9+ only; 0.8.x, 0.7.x, an empty token
|
||||
/// and garbage are refused. A trailing flag char never moves minor.
|
||||
#[test]
|
||||
fn identity_is_supported_only_from_min_fw_version() {
|
||||
let id = |v: &str| FirmwareIdentity {
|
||||
version: v.to_string(),
|
||||
};
|
||||
for v in ["0.9.0", "0.9.2", "0.9.2-rc1", "0.9.22", "0.10.0", "1.0.0"] {
|
||||
assert!(id(v).is_supported(), "{v}");
|
||||
}
|
||||
for v in ["0.8.3", "0.8.14", "0.7.1", "", "0", "0.", "x.9.0", "0.x"] {
|
||||
assert!(!id(v).is_supported(), "{v}");
|
||||
}
|
||||
assert_eq!(MIN_FW_VERSION, (0, 9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -191,9 +236,9 @@ fn identity_parse_none_without_magic() {
|
||||
|
||||
// ── A stateful firmware mock ─────────────────────────────────────────────────
|
||||
|
||||
/// A mock freemkv drive: answers IDENTITY with the `freemkv <ver>` banner, GET
|
||||
/// with the requested feature's state, applies SET, and RESET → all-passthrough.
|
||||
/// Records every CDB so tests can assert the exact command order.
|
||||
/// A mock freemkv drive: answers IDENTITY with the real fw shape (banner + flag
|
||||
/// table, see [`identity_reply`]), GET with the requested feature's state,
|
||||
/// applies SET, and models the three RESET modes. Records every CDB.
|
||||
///
|
||||
/// It also models the real hardware constraint: any vendor verb executed with
|
||||
/// [`DataDirection::None`] (no data-in phase) is REJECTED with a non-zero
|
||||
@@ -201,20 +246,30 @@ fn identity_parse_none_without_magic() {
|
||||
struct FwMock {
|
||||
version: &'static str,
|
||||
states: FeatureStates,
|
||||
/// The saved flash config block: SAVE snapshots `states` into it, RESET
|
||||
/// with RESET_TO_FLASH reloads it, RESET_TO_OEM forces all-passthrough.
|
||||
saved: FeatureStates,
|
||||
/// The baked create-time defaults (fw `DEFAULT_FLAGS`: Unrestricted on).
|
||||
defaults: FeatureStates,
|
||||
/// The saved flash config block (`None` = never saved: RESET_TO_FLASH
|
||||
/// then loads `defaults`, as the marker-gated fw >= 0.8.3 does).
|
||||
saved: Option<FeatureStates>,
|
||||
is_freemkv: bool,
|
||||
/// Models a drive that ACKs RESET but leaves its RAM flags unchanged.
|
||||
ignore_reset: bool,
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl FwMock {
|
||||
fn new() -> Self {
|
||||
let defaults = FeatureStates {
|
||||
unrestricted: STATE_ON,
|
||||
..FeatureStates::all_passthrough()
|
||||
};
|
||||
FwMock {
|
||||
version: "0.6.6",
|
||||
version: "0.9.2",
|
||||
states: FeatureStates::all_passthrough(),
|
||||
saved: FeatureStates::all_passthrough(),
|
||||
defaults,
|
||||
saved: None,
|
||||
is_freemkv: true,
|
||||
ignore_reset: false,
|
||||
cdbs: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -228,6 +283,11 @@ impl FwMock {
|
||||
fn verbs(&self) -> Vec<u8> {
|
||||
self.cdbs.iter().map(|c| c[CDB_VERB]).collect()
|
||||
}
|
||||
/// The verbs issued after the leading IDENTITY gate of an arm recipe.
|
||||
fn verbs_after_identity(&self) -> Vec<u8> {
|
||||
assert_eq!(self.cdbs[0][CDB_VERB], Verb::Identity as u8);
|
||||
self.verbs()[1..].to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
impl ScsiTransport for FwMock {
|
||||
@@ -252,21 +312,23 @@ impl ScsiTransport for FwMock {
|
||||
let mut transferred = 0usize;
|
||||
match cdb[CDB_VERB] {
|
||||
v if v == Verb::Identity as u8 => {
|
||||
// Non-freemkv: GOOD status but no magic (buffer stays zero).
|
||||
if self.is_freemkv {
|
||||
// The `freemkv <ver>` banner, NUL-terminated.
|
||||
let mut resp = Vec::new();
|
||||
resp.extend_from_slice(RESP_MAGIC);
|
||||
resp.push(b' ');
|
||||
resp.extend_from_slice(self.version.as_bytes());
|
||||
resp.push(0);
|
||||
let s = self.states;
|
||||
// Slot 0x04 (`Bd`, unexposed) at its DEFAULT_FLAGS value.
|
||||
let flags = [
|
||||
s.speed,
|
||||
s.region,
|
||||
s.unrestricted,
|
||||
STATE_ON,
|
||||
s.hrl,
|
||||
s.encryption,
|
||||
];
|
||||
let resp = identity_reply(self.version, flags);
|
||||
let n = resp.len().min(data.len());
|
||||
data[..n].copy_from_slice(&resp[..n]);
|
||||
transferred = n;
|
||||
}
|
||||
// non-freemkv: GOOD status but no magic (buffer stays zero)
|
||||
if !self.is_freemkv {
|
||||
transferred = data.len();
|
||||
}
|
||||
transferred = data.len();
|
||||
}
|
||||
v if v == Verb::Get as u8 => {
|
||||
let f = Self::feature_of(cdb[CDB_FEATURE]);
|
||||
@@ -279,17 +341,16 @@ impl ScsiTransport for FwMock {
|
||||
let f = Self::feature_of(cdb[CDB_FEATURE]);
|
||||
self.states.set(f, cdb[CDB_STATE]);
|
||||
}
|
||||
v if v == Verb::Reset as u8 => {
|
||||
// The mode rides in the state slot (cdb[6]): to-OEM forces
|
||||
// all-passthrough, to-flash reloads the saved config block.
|
||||
self.states = if cdb[CDB_STATE] == RESET_TO_FLASH {
|
||||
self.saved
|
||||
} else {
|
||||
FeatureStates::all_passthrough()
|
||||
v if v == Verb::Reset as u8 && !self.ignore_reset => {
|
||||
// The mode rides in the state slot (cdb[6]).
|
||||
self.states = match cdb[CDB_STATE] {
|
||||
RESET_TO_FLASH => self.saved.unwrap_or(self.defaults),
|
||||
RESET_TO_DEFAULTS => self.defaults,
|
||||
_ => FeatureStates::all_passthrough(),
|
||||
};
|
||||
}
|
||||
v if v == Verb::Save as u8 => {
|
||||
self.saved = self.states;
|
||||
self.saved = Some(self.states);
|
||||
}
|
||||
v if v == Verb::DumpAll as u8 => {
|
||||
for (i, b) in data.iter_mut().enumerate() {
|
||||
@@ -316,8 +377,8 @@ fn identity_detects_freemkv_and_issues_identity_cdb() {
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
let id = fw.identity().expect("no fault").expect("is freemkv");
|
||||
assert_eq!(id.version, "0.6.6");
|
||||
// States no longer ride in IDENTITY — read them via GET.
|
||||
assert_eq!(id.version, "0.9.2");
|
||||
// The appended flag table is not parsed; states are read via GET.
|
||||
assert_eq!(fw.get(Feature::Encryption).expect("get"), STATE_ON);
|
||||
assert!(fw.is_freemkv().expect("no fault"));
|
||||
}
|
||||
@@ -340,6 +401,37 @@ fn identity_none_when_drive_rejects() {
|
||||
assert!(fw.identity().expect("rejection is not a fault").is_none());
|
||||
}
|
||||
|
||||
/// A refusal carrying a full magic-bearing buffer must still be `None`: the
|
||||
/// status guard, not the empty transfer, decides.
|
||||
#[test]
|
||||
fn identity_none_when_status_is_not_good_despite_a_magic_buffer() {
|
||||
struct CheckConditionWithData;
|
||||
impl ScsiTransport for CheckConditionWithData {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> ScsiTResult<ScsiResult> {
|
||||
let resp = identity_reply("0.9.2", [0xFF; 6]);
|
||||
data.copy_from_slice(&resp[..data.len()]);
|
||||
Ok(ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut t = CheckConditionWithData;
|
||||
assert!(
|
||||
FirmwareControl::new(&mut t)
|
||||
.identity()
|
||||
.expect("not a fault")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_transport_fault_propagates() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
@@ -347,6 +439,40 @@ fn identity_transport_fault_propagates() {
|
||||
assert_eq!(fw.identity().unwrap_err(), FirmwareError::Transport);
|
||||
}
|
||||
|
||||
/// Every data-in verb maps a drive refusal to `Rejected`, whether the transport
|
||||
/// reports it as `Ok` + CHECK CONDITION or as `Err` carrying a sense.
|
||||
#[test]
|
||||
fn every_verb_maps_both_rejection_shapes_to_rejected() {
|
||||
for reply in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
|
||||
let mut t = MockTransport::always(reply.clone());
|
||||
let mut fw = FirmwareControl::new(&mut t);
|
||||
let r = FirmwareError::Rejected;
|
||||
assert_eq!(fw.set(Feature::Encryption, STATE_OFF), Err(r.clone()));
|
||||
assert_eq!(fw.get(Feature::Encryption), Err(r.clone()));
|
||||
assert_eq!(fw.reset(), Err(r.clone()));
|
||||
assert_eq!(fw.reset_to_defaults(), Err(r.clone()));
|
||||
assert_eq!(fw.reset_to_flash(), Err(r.clone()));
|
||||
assert_eq!(fw.save(), Err(r.clone()));
|
||||
assert_eq!(fw.dump(0), Err(r), "{reply:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Untrusted lengths: a GOOD-status zero-byte GET is not state 0x00, and a
|
||||
/// short DUMPALL is not a RAM window padded with zeros.
|
||||
#[test]
|
||||
fn get_and_dump_reject_short_transfers() {
|
||||
let mut t = MockTransport::always(Reply::zero_transfer(MEMREAD_LEN));
|
||||
assert_eq!(
|
||||
FirmwareControl::new(&mut t).get(Feature::Encryption),
|
||||
Err(FirmwareError::Rejected)
|
||||
);
|
||||
let mut t = MockTransport::always(Reply::short(vec![0xAB; MEMREAD_LEN], MEMREAD_LEN - 1));
|
||||
assert_eq!(
|
||||
FirmwareControl::new(&mut t).dump(0),
|
||||
Err(FirmwareError::Rejected)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_and_set_roundtrip_issue_the_right_cdbs() {
|
||||
let mut m = FwMock::new();
|
||||
@@ -373,14 +499,22 @@ fn reset_sends_reset_cdb_and_clears_state() {
|
||||
assert_eq!(m.states, FeatureStates::all_passthrough());
|
||||
}
|
||||
|
||||
/// RESET_TO_DEFAULTS restores the baked create-time defaults, which are NOT
|
||||
/// all-passthrough (Unrestricted ships on).
|
||||
#[test]
|
||||
fn reset_to_defaults_sends_the_defaults_mode_cdb() {
|
||||
fn reset_to_defaults_restores_the_baked_defaults() {
|
||||
let mut m = FwMock::new();
|
||||
m.states.encryption = STATE_OFF;
|
||||
let states;
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.reset_to_defaults().expect("reset to defaults");
|
||||
states = fw.states().expect("states");
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_reset_cdb(RESET_TO_DEFAULTS));
|
||||
assert_eq!(states.unrestricted, STATE_ON);
|
||||
assert_eq!(states.encryption, STATE_PASSTHROUGH);
|
||||
assert_ne!(states, FeatureStates::all_passthrough());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -426,8 +560,8 @@ fn states_probes_every_feature_in_order() {
|
||||
}
|
||||
assert_eq!(states.speed, SPEED_MAX);
|
||||
assert_eq!(states.hrl, STATE_ON);
|
||||
// Five GETs, one per feature, in ALL_FEATURES order (Bd id 0x04 and Bus id
|
||||
// 0x07 both retired).
|
||||
// Five GETs, one per exposed feature, in wire-id order (0x04 `Bd` is not
|
||||
// exposed; 0x07 `Bus` is retired).
|
||||
assert_eq!(m.cdbs.len(), 5);
|
||||
let features: Vec<u8> = m.cdbs.iter().map(|c| c[CDB_FEATURE]).collect();
|
||||
assert_eq!(features, vec![0x01, 0x02, 0x03, 0x05, 0x06]);
|
||||
@@ -474,12 +608,19 @@ fn typed_setters_issue_expected_set_cdbs() {
|
||||
assert_eq!(m.cdbs[6], build_set_cdb(Feature::Speed, SPEED_MAX));
|
||||
}
|
||||
|
||||
/// Regression: a bad caller argument is not a drive refusal, and the bus is
|
||||
/// never touched.
|
||||
#[test]
|
||||
fn force_region_dvd_rejects_out_of_range() {
|
||||
fn force_region_dvd_rejects_out_of_range_as_caller_error() {
|
||||
let mut m = FwMock::new();
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
assert_eq!(fw.force_region_dvd(0).unwrap_err(), FirmwareError::Rejected);
|
||||
assert_eq!(fw.force_region_dvd(9).unwrap_err(), FirmwareError::Rejected);
|
||||
{
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
for bad in [0u8, 9] {
|
||||
let err = fw.force_region_dvd(bad).unwrap_err();
|
||||
assert_eq!(err, FirmwareError::InvalidArgument, "region {bad}");
|
||||
}
|
||||
}
|
||||
assert!(m.cdbs.is_empty());
|
||||
}
|
||||
|
||||
// ── Recipes ──────────────────────────────────────────────────────────────────
|
||||
@@ -491,9 +632,10 @@ fn arm_oem_bd_sets_and_verifies_hrl_skip() {
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.arm_oem_bd().expect("armed");
|
||||
}
|
||||
// SET Hrl=OFF (the migrated skip direction), then GET Hrl to verify.
|
||||
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(m.cdbs[1], build_get_cdb(Feature::Hrl));
|
||||
// IDENTITY gate, then SET Hrl=OFF and GET Hrl to verify.
|
||||
assert_eq!(m.cdbs[0], build_identity_cdb(MEMREAD_LEN as u16));
|
||||
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(m.cdbs[2], build_get_cdb(Feature::Hrl));
|
||||
assert_eq!(m.states.hrl, STATE_OFF);
|
||||
}
|
||||
|
||||
@@ -504,16 +646,15 @@ fn arm_oem_uhd_sets_uhd_hrl_encryption_each_verified() {
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.arm_oem_uhd().expect("armed");
|
||||
}
|
||||
let verbs = m.verbs();
|
||||
// set,get, set,get, set,get
|
||||
assert_eq!(
|
||||
verbs,
|
||||
m.verbs_after_identity(),
|
||||
vec![0x02, 0x03, 0x02, 0x03, 0x02, 0x03],
|
||||
"each set is verified by a get"
|
||||
);
|
||||
assert_eq!(m.cdbs[0], build_set_cdb(Feature::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.cdbs[1], build_set_cdb(Feature::Unrestricted, STATE_ON));
|
||||
assert_eq!(m.cdbs[3], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(m.cdbs[5], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
assert_eq!(m.states.unrestricted, STATE_ON);
|
||||
assert_eq!(m.states.hrl, STATE_OFF);
|
||||
assert_eq!(m.states.encryption, STATE_OFF);
|
||||
@@ -527,10 +668,10 @@ fn arm_bypass_bd_disables_encryption() {
|
||||
fw.arm_bypass_bd().expect("armed");
|
||||
}
|
||||
// Hrl=off (revocation skip) is set+verified FIRST, then Encryption=off.
|
||||
assert_eq!(m.cdbs[0], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(m.cdbs[1], build_get_cdb(Feature::Hrl));
|
||||
assert_eq!(m.cdbs[2], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
assert_eq!(m.cdbs[3], build_get_cdb(Feature::Encryption));
|
||||
assert_eq!(m.cdbs[1], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(m.cdbs[2], build_get_cdb(Feature::Hrl));
|
||||
assert_eq!(m.cdbs[3], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
assert_eq!(m.cdbs[4], build_get_cdb(Feature::Encryption));
|
||||
assert_eq!(m.states.hrl, STATE_OFF);
|
||||
assert_eq!(m.states.encryption, STATE_OFF);
|
||||
}
|
||||
@@ -543,9 +684,9 @@ fn arm_bypass_uhd_sets_uhd_hrl_encryption() {
|
||||
fw.arm_bypass_uhd().expect("armed");
|
||||
}
|
||||
// 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.cdbs[1], build_set_cdb(Feature::Unrestricted, STATE_ON));
|
||||
assert_eq!(m.cdbs[3], build_set_cdb(Feature::Hrl, STATE_OFF));
|
||||
assert_eq!(m.cdbs[5], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
assert_eq!(m.states.unrestricted, STATE_ON);
|
||||
assert_eq!(m.states.hrl, STATE_OFF);
|
||||
assert_eq!(m.states.encryption, STATE_OFF);
|
||||
@@ -560,13 +701,30 @@ fn arm_stealth_oem_resets_then_verifies_all_passthrough() {
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
fw.arm_stealth_oem().expect("disarmed");
|
||||
}
|
||||
assert_eq!(m.cdbs[0], build_reset_cdb(RESET_TO_OEM));
|
||||
assert_eq!(m.cdbs[1], build_reset_cdb(RESET_TO_OEM));
|
||||
// then a GET of every feature
|
||||
assert_eq!(m.cdbs.len(), 1 + ALL_FEATURES.len());
|
||||
assert!(m.cdbs[1..].iter().all(|c| c[CDB_VERB] == Verb::Get as u8));
|
||||
assert_eq!(m.cdbs.len(), 2 + ALL_FEATURES.len());
|
||||
assert!(m.cdbs[2..].iter().all(|c| c[CDB_VERB] == Verb::Get as u8));
|
||||
assert_eq!(m.states, FeatureStates::all_passthrough());
|
||||
}
|
||||
|
||||
/// A drive that ACKs RESET but stays armed must not be reported as OEM.
|
||||
#[test]
|
||||
fn arm_stealth_oem_reports_a_feature_that_did_not_reset() {
|
||||
let mut m = FwMock::new();
|
||||
m.states.encryption = STATE_OFF;
|
||||
m.ignore_reset = true;
|
||||
let mut fw = FirmwareControl::new(&mut m);
|
||||
assert_eq!(
|
||||
fw.arm_stealth_oem().unwrap_err(),
|
||||
FirmwareError::VerifyFailed {
|
||||
feature: Feature::Encryption,
|
||||
wanted: STATE_PASSTHROUGH,
|
||||
got: STATE_OFF,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arm_by_recipe_enum_dispatches() {
|
||||
let mut m = FwMock::new();
|
||||
@@ -577,6 +735,45 @@ fn arm_by_recipe_enum_dispatches() {
|
||||
assert_eq!(m.states.encryption, STATE_OFF);
|
||||
}
|
||||
|
||||
/// Regression: every recipe refuses firmware below MIN_FW_VERSION (fw 0.8.x)
|
||||
/// and a non-freemkv drive — before sending any SET/RESET.
|
||||
#[test]
|
||||
fn arm_refuses_unsupported_firmware_before_any_set() {
|
||||
let recipes = [
|
||||
ArmRecipe::OemBd,
|
||||
ArmRecipe::OemUhd,
|
||||
ArmRecipe::BypassBd,
|
||||
ArmRecipe::BypassUhd,
|
||||
ArmRecipe::StealthOem,
|
||||
];
|
||||
for recipe in recipes {
|
||||
for (version, freemkv) in [("0.8.3", true), ("0.9.2", false)] {
|
||||
let mut m = FwMock::new();
|
||||
m.version = version;
|
||||
m.is_freemkv = freemkv;
|
||||
let r = FirmwareControl::new(&mut m).arm(recipe);
|
||||
assert_eq!(
|
||||
r,
|
||||
Err(FirmwareError::UnsupportedFirmware),
|
||||
"{recipe:?} on {version}/{freemkv}"
|
||||
);
|
||||
assert_eq!(m.verbs(), vec![Verb::Identity as u8], "{recipe:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Released fw 0.7.1: same knock, `freemkv 0.7.1` reply, old sub-function
|
||||
/// grammar (0x02 = Speed). Refused, with no SET sent.
|
||||
#[test]
|
||||
fn arm_refuses_legacy_07_firmware() {
|
||||
let mut legacy = b"freemkv 0.7.1".to_vec();
|
||||
legacy.resize(MEMREAD_LEN, 0);
|
||||
let mut t = MockTransport::always(Reply::good(legacy));
|
||||
let r = FirmwareControl::new(&mut t).arm(ArmRecipe::BypassBd);
|
||||
assert_eq!(r, Err(FirmwareError::UnsupportedFirmware));
|
||||
assert_eq!(t.calls(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_verify_reports_mismatch() {
|
||||
// A drive that ACKs the SET but never actually changes the state: GET reads
|
||||
@@ -591,7 +788,11 @@ fn set_verify_reports_mismatch() {
|
||||
_timeout_ms: u32,
|
||||
) -> ScsiTResult<ScsiResult> {
|
||||
let mut n = 0;
|
||||
if cdb[CDB_VERB] == Verb::Get as u8 && !data.is_empty() {
|
||||
if cdb[CDB_VERB] == Verb::Identity as u8 {
|
||||
let resp = identity_reply("0.9.2", [STATE_PASSTHROUGH; 6]);
|
||||
data.copy_from_slice(&resp[..data.len()]);
|
||||
n = data.len();
|
||||
} else if cdb[CDB_VERB] == Verb::Get as u8 && !data.is_empty() {
|
||||
data[0] = STATE_PASSTHROUGH; // never changes
|
||||
n = 1;
|
||||
}
|
||||
@@ -622,3 +823,15 @@ fn recipe_transport_fault_propagates() {
|
||||
let mut fw = FirmwareControl::new(&mut t);
|
||||
assert_eq!(fw.arm_oem_bd().unwrap_err(), FirmwareError::Transport);
|
||||
}
|
||||
|
||||
/// A dead bus AFTER the IDENTITY gate (mid-recipe) still aborts.
|
||||
#[test]
|
||||
fn recipe_transport_fault_after_identity_propagates() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![Reply::good(identity_reply("0.9.2", [0xFF; 6]))],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let mut fw = FirmwareControl::new(&mut t);
|
||||
assert_eq!(fw.arm_bypass_uhd().unwrap_err(), FirmwareError::Transport);
|
||||
assert_eq!(t.calls(), 2);
|
||||
}
|
||||
|
||||
+189
-212
@@ -1,35 +1,26 @@
|
||||
//! Unlock drives that identify themselves as freemkv firmware.
|
||||
//!
|
||||
//! The vendor IDENTITY response must start with `b"freemkv"`. Commands use the
|
||||
//! The vendor IDENTITY response must parse as a [`FirmwareIdentity`] on the
|
||||
//! supported grammar ([`FirmwareIdentity::is_supported`]). Commands use the
|
||||
//! builders in [`crate::firmware`], which mirror the firmware ABI.
|
||||
//! Unlocking sets Region to free, Speed to max, and Encryption to [`crate::firmware::STATE_OFF`].
|
||||
//! Unlocking sets Region to free, Speed to max, Unrestricted on, and Encryption
|
||||
//! to [`crate::firmware::STATE_OFF`].
|
||||
//! Encryption-off disables the certificate/bus barrier; the retired Bus feature
|
||||
//! must not be used as a substitute.
|
||||
|
||||
use crate::firmware::{
|
||||
Feature, MEMREAD_LEN, MIN_ALLOC_LEN, REGION_FREE, RESP_MAGIC, SPEED_MAX, STATE_OFF,
|
||||
build_identity_cdb, build_memread_cdb, build_set_cdb,
|
||||
Feature, FirmwareIdentity, MEMREAD_LEN, MIN_ALLOC_LEN, REGION_FREE, SPEED_MAX, STATE_OFF,
|
||||
STATE_ON, build_identity_cdb, build_memread_cdb, build_set_cdb,
|
||||
};
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus};
|
||||
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
/// The ASCII magic leading the IDENTITY reply — the ENTIRE freemkv-detection
|
||||
/// mechanism (no bundled profile database; the firmware self-identifies).
|
||||
const IDENTITY_MARKER: &[u8] = RESP_MAGIC;
|
||||
|
||||
/// Allocation the IDENTITY / DUMPALL data-in phase reads back (a fixed 64-byte
|
||||
/// window, matching [`firmware::MEMREAD_LEN`]).
|
||||
/// window, matching [`MEMREAD_LEN`]).
|
||||
const RESP_LEN: usize = MEMREAD_LEN;
|
||||
|
||||
/// Whether a transport error is a genuine dead bus (a senseless
|
||||
/// transport-failure status) rather than a drive rejection surfaced through a
|
||||
/// non-conforming transport (`Err` carrying a sense).
|
||||
fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
|
||||
e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
|
||||
}
|
||||
|
||||
// The freemkv custom-firmware unlocker. Detection: IDENTITY, checking the reply
|
||||
// starts "freemkv" — no bundled profile catalog needed, unlike
|
||||
// The freemkv custom-firmware unlocker. Detection: IDENTITY on a supported
|
||||
// firmware version — no bundled profile catalog needed, unlike
|
||||
// crate::ld::LdUnlocker. Stateless: `unlock()` returns what it learned.
|
||||
#[derive(Default)]
|
||||
pub struct FreemkvUnlocker;
|
||||
@@ -39,25 +30,28 @@ impl FreemkvUnlocker {
|
||||
FreemkvUnlocker
|
||||
}
|
||||
|
||||
// Issue IDENTITY: Ok(true) if the reply starts "freemkv", Ok(false) if
|
||||
// rejected/mismatched (not this firmware). A dead bus is Err(Transport) —
|
||||
// this is the FIRST command, so a transport fault must abort.
|
||||
// Issue IDENTITY: Ok(true) only for freemkv firmware on the supported
|
||||
// grammar; Ok(false) if rejected, not freemkv, or below MIN_FW_VERSION (older
|
||||
// fw answers SETs with GOOD; unlock not validated). Dead bus: Err(Transport).
|
||||
fn identify(&self, scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, UnlockError> {
|
||||
let cdb = build_identity_cdb(RESP_LEN as u16);
|
||||
let mut buf = [0u8; RESP_LEN];
|
||||
match scsi.execute(&cdb, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
Ok(r) => {
|
||||
let matched = r.status == 0
|
||||
&& r.bytes_transferred >= IDENTITY_MARKER.len()
|
||||
&& buf[..IDENTITY_MARKER.len()] == *IDENTITY_MARKER;
|
||||
if !matched {
|
||||
let n = r.bytes_transferred.min(buf.len());
|
||||
let id = (r.status == 0)
|
||||
.then(|| FirmwareIdentity::parse(&buf[..n]))
|
||||
.flatten();
|
||||
let supported = id.as_ref().is_some_and(FirmwareIdentity::is_supported);
|
||||
if !supported {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "freemkv_identity_no_match",
|
||||
"IDENTITY probe did not report freemkv firmware"
|
||||
version = id.as_ref().map(|i| i.version.as_str()),
|
||||
"IDENTITY probe did not report supported freemkv firmware"
|
||||
);
|
||||
}
|
||||
Ok(matched)
|
||||
Ok(supported)
|
||||
}
|
||||
Err(e) => {
|
||||
if is_dead_bus(&e) {
|
||||
@@ -151,8 +145,8 @@ impl FreemkvUnlocker {
|
||||
}
|
||||
|
||||
// Full freemkv unlock: IDENTITY (hard gate) → Set(Region,free) →
|
||||
// Set(Speed,max) (best-effort) → Set(Encryption,off) (LOAD-BEARING —
|
||||
// consolidated cert/bus bypass) → bare 0xAD VID (best-effort).
|
||||
// Set(Speed,max) → Set(Unrestricted,on) (best-effort) → Set(Encryption,off)
|
||||
// (LOAD-BEARING — consolidated cert/bus bypass) → bare 0xAD VID (best-effort).
|
||||
fn full_unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
@@ -182,6 +176,12 @@ impl FreemkvUnlocker {
|
||||
// Region-free + riplock lift (best-effort features).
|
||||
best_effort(self.set(scsi, Feature::Region, REGION_FREE), "region")?;
|
||||
best_effort(self.set(scsi, Feature::Speed, SPEED_MAX), "speed")?;
|
||||
// ABI full-bypass recipe: engage BD/UHD discs rather than rely on the
|
||||
// RAM defaults (RESET_TO_OEM or a saved config may leave it off).
|
||||
best_effort(
|
||||
self.set(scsi, Feature::Unrestricted, STATE_ON),
|
||||
"unrestricted",
|
||||
)?;
|
||||
// Encryption = off (LOAD-BEARING): the consolidated cert/bus bypass —
|
||||
// drive acts pre-authenticated (bare 0xAD returns the VID with no
|
||||
// cert/AKE) AND content returns de-bussed. No fallback.
|
||||
@@ -216,12 +216,13 @@ impl Unlocker for FreemkvUnlocker {
|
||||
"freemkv"
|
||||
}
|
||||
|
||||
/// Recognise the drive by its IDENTITY knock, lift riplock/region
|
||||
/// (best-effort), null the AKE (the actual unlock), and read the Volume ID
|
||||
/// with a bare `0xAD` (best-effort). `Some` when the null-AKE set succeeded —
|
||||
/// the drive is unlocked whether or not the VID read did; `None` if it isn't
|
||||
/// a freemkv drive; `Err(Transport)` on a dead bus. `ctx` is unused: this
|
||||
/// unlocker self-identifies rather than matching on drive identity.
|
||||
/// Recognise the drive by its IDENTITY knock, lift riplock/region and open
|
||||
/// Unrestricted (best-effort), set Encryption=off (the actual unlock), and
|
||||
/// read the Volume ID with a bare `0xAD` (best-effort). `Some` when the
|
||||
/// Encryption=off set succeeded — the drive is unlocked whether or not the
|
||||
/// VID read did; `None` if it isn't supported freemkv firmware;
|
||||
/// `Err(Transport)` on a dead bus. `ctx` is unused: this unlocker
|
||||
/// self-identifies rather than matching on drive identity.
|
||||
fn unlock(
|
||||
&self,
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
@@ -235,10 +236,7 @@ impl Unlocker for FreemkvUnlocker {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::DiscKind;
|
||||
use crate::firmware::{
|
||||
REGION_FREE, RESET_TO_OEM, STATE_OFF, build_identity_cdb, build_memread_cdb,
|
||||
build_reset_cdb, build_set_cdb,
|
||||
};
|
||||
use crate::firmware::{REGION_FREE, STATE_OFF, build_identity_cdb, build_memread_cdb};
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
use crate::scsi::{DataDirection, Result, ScsiResult, ScsiTransport};
|
||||
|
||||
@@ -246,34 +244,19 @@ mod tests {
|
||||
UnlockCtx::new(id, DiscKind::Unknown)
|
||||
}
|
||||
|
||||
/// Serves a fixed payload with a GOOD status (used for the Identity probe).
|
||||
struct FakeTransport {
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
impl ScsiTransport for FakeTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let n = self.payload.len().min(data.len());
|
||||
data[..n].copy_from_slice(&self.payload[..n]);
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
/// The fw IDENTITY wire shape: `freemkv <ver>` (no NUL), the raw flag
|
||||
/// table, then zero padding to the 64-byte window.
|
||||
fn identity_payload(version: &str, flags: &[u8]) -> Vec<u8> {
|
||||
let mut p = b"freemkv ".to_vec();
|
||||
p.extend_from_slice(version.as_bytes());
|
||||
p.extend_from_slice(flags);
|
||||
p.resize(RESP_LEN, 0);
|
||||
p
|
||||
}
|
||||
|
||||
/// A fresh-boot fw 0.9.2 drive (DEFAULT_FLAGS: Unrestricted + Bd on).
|
||||
fn freemkv_identity_payload() -> Vec<u8> {
|
||||
let mut p = vec![0u8; RESP_LEN];
|
||||
let s = b"freemkv";
|
||||
p[..s.len()].copy_from_slice(s);
|
||||
p[s.len()] = 0x42; // version
|
||||
p
|
||||
identity_payload("0.9.2", &[0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF])
|
||||
}
|
||||
|
||||
/// A well-formed format-0x80 VID structure: 4-byte header, 16-byte VID at
|
||||
@@ -285,22 +268,16 @@ mod tests {
|
||||
p
|
||||
}
|
||||
|
||||
// ── CDB shape ────────────────────────────────────────────────────────
|
||||
|
||||
/// IDENTITY builds the pinned knock frame with a 64-byte allocation.
|
||||
#[test]
|
||||
fn identity_cdb_is_the_knock_shape() {
|
||||
assert_eq!(
|
||||
build_identity_cdb(RESP_LEN as u16),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x01, 0x00, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
fn unlock(t: &mut MockTransport) -> std::result::Result<Option<Unlocked>, UnlockError> {
|
||||
let id = crate::DriveId::default();
|
||||
FreemkvUnlocker::new().unlock(t, &ctx(&id))
|
||||
}
|
||||
|
||||
/// Regression (BU40N fw 0.8.1): a SET issued WITHOUT the data-in phase its
|
||||
/// CDB advertises is aborted by the drive (CHECK CONDITION / Aborted Command),
|
||||
/// so the unlocker's `set()` MUST read the MIN_ALLOC_LEN table back like
|
||||
/// `FirmwareControl::set` — not send `DataDirection::None`. Before the fix
|
||||
/// the whole freemkv unlock failed at `Set(Ake)` and fell back to AACS.
|
||||
/// the whole freemkv unlock failed at the first SET and fell back to AACS.
|
||||
#[test]
|
||||
fn set_issues_the_required_data_in_phase() {
|
||||
struct DataInContract;
|
||||
@@ -333,33 +310,10 @@ mod tests {
|
||||
.expect("SET must succeed by issuing the data-in phase the firmware requires");
|
||||
}
|
||||
|
||||
/// Each unlock SET is `Verb::Set` (02) on the right feature id (cdb[5]) with
|
||||
/// the state at cdb[6]; SET floors its data-in allocation at MIN_ALLOC_LEN
|
||||
/// (0x0040 at cdb[7..9]) — the drive aborts a sub-16-byte transfer (HW-confirmed).
|
||||
#[test]
|
||||
fn unlock_set_cdbs_have_the_new_grammar_shape() {
|
||||
// Region-free is REGION_FREE (0x0F) at cdb[6] — 0x01 now forces DVD region 1.
|
||||
assert_eq!(
|
||||
build_set_cdb(Feature::Region, REGION_FREE),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x02, 0x0F, 0x00, 0x40, 0x00]
|
||||
);
|
||||
// Speed max is SPEED_MAX (0x00) — the limiter-off leg of Speed.
|
||||
assert_eq!(
|
||||
build_set_cdb(Feature::Speed, SPEED_MAX),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x01, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
// Encryption unlock direction is STATE_OFF (0x00) at cdb[6]; wire id
|
||||
// 0x07 (was `Bus`) is retired.
|
||||
assert_eq!(
|
||||
build_set_cdb(Feature::Encryption, STATE_OFF),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x02, 0x06, 0x00, 0x00, 0x40, 0x00]
|
||||
);
|
||||
}
|
||||
|
||||
// ── Detection ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn identify_true_on_freemkv_marker_and_issues_identity_cdb() {
|
||||
fn identify_true_on_supported_firmware_and_issues_identity_cdb() {
|
||||
let mut t = MockTransport::always(Reply::good(freemkv_identity_payload()));
|
||||
assert!(FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
|
||||
@@ -367,9 +321,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn identify_false_on_non_matching_payload() {
|
||||
let mut t = FakeTransport {
|
||||
payload: vec![0u8; RESP_LEN],
|
||||
};
|
||||
let mut t = MockTransport::always(Reply::good(vec![0u8; RESP_LEN]));
|
||||
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
}
|
||||
|
||||
@@ -379,6 +331,35 @@ mod tests {
|
||||
assert!(!FreemkvUnlocker::new().identify(&mut t).expect("no fault"));
|
||||
}
|
||||
|
||||
/// The status guard decides: a CHECK CONDITION that nonetheless reports a
|
||||
/// full magic-bearing transfer (residual/echoed buffer) is not freemkv.
|
||||
#[test]
|
||||
fn identify_false_on_check_condition_with_a_magic_buffer() {
|
||||
struct CheckConditionWithData;
|
||||
impl ScsiTransport for CheckConditionWithData {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let p = freemkv_identity_payload();
|
||||
data.copy_from_slice(&p[..data.len()]);
|
||||
Ok(ScsiResult {
|
||||
status: 2,
|
||||
bytes_transferred: data.len(),
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!FreemkvUnlocker::new()
|
||||
.identify(&mut CheckConditionWithData)
|
||||
.expect("no fault")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identify_false_when_command_rejected() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
@@ -400,33 +381,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: released fw 0.7.x (`freemkv 0.7.1` reply, old sub-function
|
||||
/// grammar where 0x02 = Speed) answers GOOD to every SET. The unlocker must
|
||||
/// decline it, not claim a drive it never unlocked.
|
||||
#[test]
|
||||
fn declines_legacy_07_firmware_without_sending_a_set() {
|
||||
let mut t = MockTransport::always(Reply::good(identity_payload("0.7.1", &[])));
|
||||
assert!(unlock(&mut t).expect("no fault").is_none());
|
||||
assert_eq!(t.calls(), 1, "only the IDENTITY probe");
|
||||
}
|
||||
|
||||
/// Regression: fw 0.8.x (0x06 = `Ake`, separate `Bus`): de-bus via 0x06
|
||||
/// alone is not validated (hardware-dependent), so decline.
|
||||
#[test]
|
||||
fn declines_08_firmware_without_sending_a_set() {
|
||||
let flags = [0xFF, 0xFF, 0x01, 0x01, 0xFF, 0xFF, 0xFF];
|
||||
let mut t = MockTransport::always(Reply::good(identity_payload("0.8.3", &flags)));
|
||||
assert!(unlock(&mut t).expect("no fault").is_none());
|
||||
assert_eq!(t.calls(), 1, "only the IDENTITY probe");
|
||||
}
|
||||
|
||||
// ── Feature sets ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn set_region_free_issues_the_set_region_on_cdb() {
|
||||
let mut t = MockTransport::always(Reply::good(vec![]));
|
||||
FreemkvUnlocker::new()
|
||||
.set(&mut t, Feature::Region, REGION_FREE)
|
||||
.expect("ok");
|
||||
assert_eq!(t.cdbs[0], build_set_cdb(Feature::Region, REGION_FREE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_encryption_off_issues_the_set_encryption_cdb() {
|
||||
let mut t = MockTransport::always(Reply::good(vec![]));
|
||||
FreemkvUnlocker::new()
|
||||
.set(&mut t, Feature::Encryption, STATE_OFF)
|
||||
.expect("ok");
|
||||
assert_eq!(t.cdbs[0], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_drive_rejection_is_not_applicable() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let err = FreemkvUnlocker::new()
|
||||
.set(&mut t, Feature::Encryption, STATE_OFF)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, UnlockError::NotApplicable);
|
||||
for reply in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
|
||||
let mut t = MockTransport::always(reply.clone());
|
||||
let err = FreemkvUnlocker::new()
|
||||
.set(&mut t, Feature::Encryption, STATE_OFF)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, UnlockError::NotApplicable, "{reply:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -441,78 +426,94 @@ mod tests {
|
||||
// ── full_unlock / unlock ─────────────────────────────────────────────
|
||||
|
||||
/// The full unlock runs IDENTITY → Set(Region) → Set(Speed) →
|
||||
/// Set(Encryption) → bare VID in order.
|
||||
/// Set(Unrestricted) → Set(Encryption) → bare VID, with the exact wire
|
||||
/// bytes for every vendor SET.
|
||||
#[test]
|
||||
fn full_unlock_issues_identity_region_speed_encryption_then_bare_vid() {
|
||||
fn full_unlock_issues_identity_region_speed_unrestricted_encryption_then_bare_vid() {
|
||||
let vid = [0x7Cu8; 16];
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()), // IDENTITY
|
||||
Reply::good(vec![]), // Set Region
|
||||
Reply::good(vec![]), // Set Speed
|
||||
Reply::good(vec![]), // Set Unrestricted
|
||||
Reply::good(vec![]), // Set Encryption (load-bearing)
|
||||
Reply::good(vid_ds_response(vid)), // 0xAD bare VID
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let id = crate::DriveId::default();
|
||||
let out = FreemkvUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
let out = unlock(&mut t)
|
||||
.expect("no fault")
|
||||
.expect("encryption off ⇒ unlocked");
|
||||
assert_eq!(out.vid, Some(vid));
|
||||
assert_eq!(t.cdbs.len(), 5);
|
||||
// The three vendor SETs land on the right features, in order.
|
||||
assert_eq!(out.bus_key, None);
|
||||
assert_eq!(t.cdbs.len(), 6);
|
||||
assert_eq!(t.cdbs[0], build_identity_cdb(RESP_LEN as u16));
|
||||
assert_eq!(t.cdbs[1], build_set_cdb(Feature::Region, REGION_FREE));
|
||||
assert_eq!(t.cdbs[2], build_set_cdb(Feature::Speed, SPEED_MAX));
|
||||
assert_eq!(t.cdbs[3], build_set_cdb(Feature::Encryption, STATE_OFF));
|
||||
assert_eq!(t.cdbs[4][0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
|
||||
let set = |f: u8, s: u8| vec![0x3C, 0x0E, 0xC0, 0xDE, 0x02, f, s, 0x00, 0x40, 0x00];
|
||||
assert_eq!(t.cdbs[1], set(0x02, 0x0F), "Region = free");
|
||||
assert_eq!(t.cdbs[2], set(0x01, 0x00), "Speed = max");
|
||||
assert_eq!(t.cdbs[3], set(0x03, 0x01), "Unrestricted = on");
|
||||
assert_eq!(t.cdbs[4], set(0x06, 0x00), "Encryption = off");
|
||||
assert_eq!(t.cdbs[5][0], crate::scsi::SCSI_READ_DISC_STRUCTURE);
|
||||
}
|
||||
|
||||
/// A missing region/speed feature does not fail the unlock (best-effort).
|
||||
/// Missing region/speed/unrestricted features do not fail the unlock
|
||||
/// (best-effort), whichever rejection shape the transport uses.
|
||||
#[test]
|
||||
fn full_unlock_tolerates_missing_region_and_speed() {
|
||||
fn full_unlock_tolerates_missing_best_effort_features() {
|
||||
let vid = [0x3Cu8; 16];
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()), // identity
|
||||
Reply::illegal_request(), // region unsupported
|
||||
Reply::illegal_request(), // speed unsupported
|
||||
Reply::good(vec![]), // null ake
|
||||
Reply::good(vid_ds_response(vid)), // bare VID
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let id = crate::DriveId::default();
|
||||
let out = FreemkvUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.expect("no fault")
|
||||
.expect("unlocked");
|
||||
assert_eq!(out.vid, Some(vid));
|
||||
for reject in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()),
|
||||
reject.clone(), // region
|
||||
reject.clone(), // speed
|
||||
reject.clone(), // unrestricted
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vid_ds_response(vid)),
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let out = unlock(&mut t).expect("no fault").expect("unlocked");
|
||||
assert_eq!(out.vid, Some(vid), "{reject:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Set(Encryption, off) is LOAD-BEARING: if the drive rejects it, this
|
||||
/// isn't an unlockable freemkv drive, so `unlock()` declines (`None`) and
|
||||
/// falls through — never a hard error.
|
||||
/// Set(Encryption, off) is LOAD-BEARING: if the drive rejects it (either
|
||||
/// shape), `unlock()` declines (`None`) and falls through.
|
||||
#[test]
|
||||
fn declines_when_disable_encryption_rejected() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()), // identity
|
||||
Reply::good(vec![]), // region
|
||||
Reply::good(vec![]), // speed
|
||||
Reply::illegal_request(), // Set(Encryption,off) REJECTED
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let id = crate::DriveId::default();
|
||||
assert!(
|
||||
FreemkvUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.expect("no fault")
|
||||
.is_none()
|
||||
);
|
||||
for reject in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]),
|
||||
reject.clone(), // Set(Encryption,off) REJECTED
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
assert!(unlock(&mut t).expect("no fault").is_none(), "{reject:?}");
|
||||
assert_eq!(t.calls(), 5, "no VID read after the refusal");
|
||||
}
|
||||
}
|
||||
|
||||
/// A dead bus at ANY step after IDENTITY aborts with `Transport` (never a
|
||||
/// fall-through): each best-effort SET, the load-bearing SET, the VID read.
|
||||
#[test]
|
||||
fn dead_bus_after_identity_aborts_at_every_step() {
|
||||
for good_before_fault in 0..5 {
|
||||
let mut script = vec![Reply::good(freemkv_identity_payload())];
|
||||
script.extend(std::iter::repeat_n(Reply::good(vec![]), good_before_fault));
|
||||
let mut t = MockTransport::scripted(script, Reply::TransportFault);
|
||||
assert_eq!(
|
||||
unlock(&mut t).unwrap_err(),
|
||||
UnlockError::Transport,
|
||||
"fault after {good_before_fault} SETs"
|
||||
);
|
||||
assert_eq!(t.calls(), good_before_fault + 2, "aborted at the fault");
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort VID: Set(Encryption,off) succeeded (drive unlocked), but the
|
||||
@@ -522,17 +523,16 @@ mod tests {
|
||||
fn unlocks_without_vid_when_bare_read_fails() {
|
||||
let mut t = MockTransport::scripted(
|
||||
vec![
|
||||
Reply::good(freemkv_identity_payload()), // identity
|
||||
Reply::good(vec![]), // region
|
||||
Reply::good(vec![]), // speed
|
||||
Reply::good(vec![]), // Set(Encryption,off)
|
||||
Reply::illegal_request(), // bare VID: no medium
|
||||
Reply::good(freemkv_identity_payload()),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]), // Set(Encryption,off)
|
||||
Reply::illegal_request(), // bare VID: no medium
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
let id = crate::DriveId::default();
|
||||
let out = FreemkvUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
let out = unlock(&mut t)
|
||||
.expect("no fault")
|
||||
.expect("unlocked despite no VID");
|
||||
assert_eq!(out.vid, None);
|
||||
@@ -541,25 +541,13 @@ mod tests {
|
||||
#[test]
|
||||
fn declines_non_freemkv_drive() {
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let id = crate::DriveId::default();
|
||||
assert!(
|
||||
FreemkvUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.expect("no fault")
|
||||
.is_none()
|
||||
);
|
||||
assert!(unlock(&mut t).expect("no fault").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_fault_propagates() {
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let id = crate::DriveId::default();
|
||||
assert_eq!(
|
||||
FreemkvUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.unwrap_err(),
|
||||
UnlockError::Transport
|
||||
);
|
||||
assert_eq!(unlock(&mut t).unwrap_err(), UnlockError::Transport);
|
||||
}
|
||||
|
||||
// ── DumpAll ──────────────────────────────────────────────────────────
|
||||
@@ -573,27 +561,16 @@ mod tests {
|
||||
.dump_ram(&mut t, 0x01F8_1234)
|
||||
.expect("dump ok");
|
||||
assert_eq!(got[0], 0xEE);
|
||||
assert_eq!(
|
||||
t.cdbs[0],
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x09, 0x01, 0xF8, 0x12, 0x34, 0x00]
|
||||
);
|
||||
assert_eq!(t.cdbs[0], build_memread_cdb(0x01F8_1234).to_vec());
|
||||
}
|
||||
|
||||
/// A GOOD-status short DUMPALL is not a zero-padded RAM window.
|
||||
#[test]
|
||||
fn build_memread_cdb_packs_address_big_endian_at_5_to_9() {
|
||||
let cdb = build_memread_cdb(0xDEAD_BEEF);
|
||||
assert_eq!(cdb[4], crate::firmware::Verb::DumpAll as u8);
|
||||
assert_eq!([cdb[5], cdb[6], cdb[7], cdb[8]], [0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
assert_eq!(cdb[9], 0x00);
|
||||
}
|
||||
|
||||
/// RESET builds the pinned all-features-passthrough frame (not issued by the
|
||||
/// unlock flow, but pinned here so the shared builder stays honest).
|
||||
#[test]
|
||||
fn reset_cdb_shape() {
|
||||
fn dump_ram_rejects_a_short_transfer() {
|
||||
let mut t = MockTransport::always(Reply::short(vec![0xAB; MEMREAD_LEN], MEMREAD_LEN - 1));
|
||||
assert_eq!(
|
||||
build_reset_cdb(RESET_TO_OEM),
|
||||
[0x3C, 0x0E, 0xC0, 0xDE, 0x04, 0x00, 0xFF, 0x00, 0x40, 0x00]
|
||||
FreemkvUnlocker::new().dump_ram(&mut t, 0).unwrap_err(),
|
||||
UnlockError::NotApplicable
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -537,6 +537,90 @@ mod tests {
|
||||
/// Catches classifying a dead bus as "not this unlocker's drive": the very
|
||||
/// first unlock command faulting at the transport layer must abort the
|
||||
/// consumer (`Transport`), not fall through to the next unlocker.
|
||||
// Review of ST-D10: after a confirmed firmware upload, a drive whose media never
|
||||
// becomes ready (the same 02/04/01 for 60 s, T6) must still report the unlock,
|
||||
// and probe_disc + the VID read must still run (not `NotApplicable`).
|
||||
#[test]
|
||||
fn readiness_stall_after_upload_keeps_the_unlock() {
|
||||
use crate::scsi::{DataDirection, ScsiResult};
|
||||
use platform::mt1959::tests::{FakeClock, TEST_READY_CLOCK};
|
||||
struct UploadThenNotReady {
|
||||
sig: [u8; 4],
|
||||
unlock_calls: usize,
|
||||
cdbs: Vec<Vec<u8>>,
|
||||
}
|
||||
impl ScsiTransport for UploadThenNotReady {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
self.cdbs.push(cdb.to_vec());
|
||||
let unlock = cdb[0] == 0x3C && matches!(cdb[2], 0x44 | 0x77) && cdb[3] == 0;
|
||||
let (status, n) = if cdb[0] == 0x00 {
|
||||
let mut sense = [0u8; 32];
|
||||
(sense[2], sense[12], sense[13]) = (0x02, 0x04, 0x01);
|
||||
return Ok(ScsiResult {
|
||||
status: 0x02,
|
||||
bytes_transferred: 0,
|
||||
sense,
|
||||
});
|
||||
} else if unlock && cdb[8] == 64 {
|
||||
self.unlock_calls += 1;
|
||||
data.fill(0);
|
||||
data[0..4].copy_from_slice(&self.sig);
|
||||
data[12..16].copy_from_slice(&[0x4D, 0x4D, 0x6B, 0x76]);
|
||||
data[16..20].copy_from_slice(&[0x4C, 0x62, 0x44, 0x72]);
|
||||
// The first unlock fails short, forcing the firmware upload.
|
||||
(0, if self.unlock_calls == 1 { 10 } else { 64 })
|
||||
} else {
|
||||
data.fill(0);
|
||||
(0, data.len())
|
||||
};
|
||||
Ok(ScsiResult {
|
||||
status,
|
||||
bytes_transferred: n,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
}
|
||||
let id = known_vid_drive_id();
|
||||
let sig = profile::find_bundled(&id)
|
||||
.expect("profile")
|
||||
.profile
|
||||
.signature;
|
||||
let mut t = UploadThenNotReady {
|
||||
sig,
|
||||
unlock_calls: 0,
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
TEST_READY_CLOCK.set(Some(|| Box::new(FakeClock::new())));
|
||||
let r = LdUnlocker::new().unlock(&mut t, &ctx(&id));
|
||||
TEST_READY_CLOCK.set(None);
|
||||
let unlocked = r.expect("no fault");
|
||||
assert!(
|
||||
unlocked.is_some(),
|
||||
"the confirmed unlock stands despite the stall"
|
||||
);
|
||||
let uploaded = t.cdbs.iter().any(|c| matches!(c[0], 0x3B | 0x55));
|
||||
assert!(uploaded, "the firmware upload ran");
|
||||
let last_tur = t
|
||||
.cdbs
|
||||
.iter()
|
||||
.rposition(|c| c[0] == 0x00)
|
||||
.expect("polled TUR");
|
||||
let turs = t.cdbs.iter().filter(|c| c[0] == 0x00).count();
|
||||
assert_eq!(turs, 121, "polled until the 60 s stall (virtual clock)");
|
||||
let capacity = t
|
||||
.cdbs
|
||||
.iter()
|
||||
.rposition(|c| c[0] == 0x25)
|
||||
.expect("probe_disc ran");
|
||||
assert!(capacity > last_tur, "probe_disc runs after the poll");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_fault_during_unlock_is_transport_not_not_applicable() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
|
||||
@@ -6,7 +6,8 @@ mod variant_b;
|
||||
use super::PlatformDriver;
|
||||
use crate::ld::error::{Error, Result};
|
||||
use crate::ld::profile::DriveProfile;
|
||||
use crate::scsi::{self, DataDirection, ScsiTransport};
|
||||
use crate::scsi::{self, DataDirection, ScsiSense, ScsiTransport};
|
||||
use std::time::Duration;
|
||||
|
||||
// ── Variant constants ──────────────────────────────────────────────────
|
||||
// Every vendor command: 3C [mode] [buffer_id] [sub_cmd] [addr] ...
|
||||
@@ -18,6 +19,7 @@ const BUFFER_ID_B: u8 = 0x77;
|
||||
// ── SCSI opcodes ──────────────────────────────────────────────────────
|
||||
const SCSI_READ_BUFFER: u8 = 0x3C;
|
||||
const SCSI_READ_CAPACITY: u8 = 0x25;
|
||||
const SCSI_TEST_UNIT_READY: u8 = 0x00;
|
||||
/// Shared by both firmware-upload variants (see `variant_a` / `variant_b`).
|
||||
pub(super) const SCSI_WRITE_BUFFER: u8 = 0x3B;
|
||||
|
||||
@@ -41,6 +43,20 @@ const FIRMWARE_MODE_SIG: [u8; 4] = [0x4C, 0x62, 0x44, 0x72];
|
||||
/// mean anything — through the secondary marker at [16..20].
|
||||
const MIN_UNLOCK_RESPONSE: usize = FIRMWARE_MODE_OFFSET + 4;
|
||||
|
||||
// ── Post-upload readiness poll (T6, stop-design-v5 §2.11 / §3.1) ──────
|
||||
/// Between TEST UNIT READY polls; libfreemkv `wait_ready` polls at 500 ms too.
|
||||
const READY_POLL: Duration = Duration::from_millis(500);
|
||||
/// T6: fail only after this long with no progress in the not-ready answers.
|
||||
const READY_STALL: Duration = Duration::from_secs(60);
|
||||
const TUR_TIMEOUT_MS: u32 = 5_000;
|
||||
/// Consecutive 02/3A answers, with no 02/04/01 seen, that mean an empty drive
|
||||
/// (~5 s); libfreemkv `wait_ready`'s `WAIT_READY_MAX_EMPTY_POLLS`.
|
||||
const READY_MAX_EMPTY_POLLS: u32 = 10;
|
||||
/// START STOP UNIT (1Bh), LoEj 0 / Start 1 (MMC-6 Table 631, byte 4 bit 0).
|
||||
const START_UNIT: [u8; 6] = [0x1B, 0x00, 0x00, 0x00, 0x01, 0x00];
|
||||
/// libfreemkv's START UNIT timeout (T4).
|
||||
const START_UNIT_TIMEOUT_MS: u32 = 30_000;
|
||||
|
||||
// ── Init address (per disc type) ──────────────────────────────────────
|
||||
const INIT_ADDR_BD: u16 = 0x0100;
|
||||
const INIT_ADDR_UHD: u16 = 0x0200;
|
||||
@@ -291,8 +307,15 @@ impl Mt1959 {
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
// Every exit below returns or overwrites `last_err` with the reload's error.
|
||||
Err(e) => {
|
||||
last_err = e;
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_unlock_failed_reloading",
|
||||
attempt,
|
||||
error = %e,
|
||||
"unlock failed; reloading firmware"
|
||||
);
|
||||
let loaded = if self.mode == MODE_A {
|
||||
variant_a::load_firmware(self, scsi)
|
||||
} else {
|
||||
@@ -320,10 +343,22 @@ impl Mt1959 {
|
||||
last_err = e;
|
||||
continue;
|
||||
}
|
||||
// D10 (stop-design-v5 §1): "`run_init` must `return Ok(())` as soon as
|
||||
// `load_firmware` confirms the unlock" — its own do_unlock already did.
|
||||
// Looping re-ran it after a 10 s settle and discarded it on attempt 2.
|
||||
Ok(()) => {
|
||||
// Firmware upload resets the drive. Give it time to
|
||||
// fully recover before retrying unlock.
|
||||
std::thread::sleep(std::time::Duration::from_secs(10));
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_unlock_ok_after_upload",
|
||||
attempt,
|
||||
unlocked = self.unlocked,
|
||||
"MT1959 unlock confirmed by the firmware upload"
|
||||
);
|
||||
// The upload reset the drive; the unlock proves only the vendor
|
||||
// path answers. Hand it back once the media is ready (T6). Any
|
||||
// readiness outcome keeps the unlock; only a dead bus / Stop fails.
|
||||
await_media_ready(scsi, &mut *ready_clock())?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -464,6 +499,184 @@ impl Mt1959 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Time and waiting for [`await_media_ready`]: a seam so tests run the 60 s
|
||||
/// stall window on a virtual clock.
|
||||
pub(crate) trait ReadyClock {
|
||||
/// Time since the poll began.
|
||||
fn elapsed(&self) -> Duration;
|
||||
/// Wait `d` before the next poll.
|
||||
fn wait(&mut self, scsi: &mut dyn ScsiTransport, d: Duration) -> scsi::Result<()>;
|
||||
}
|
||||
|
||||
/// The poll's clock: [`WallClock`], or a test's virtual clock (a per-thread seam,
|
||||
/// so an [`crate::LdUnlocker`] test can stall the poll without waiting 60 s).
|
||||
fn ready_clock() -> Box<dyn ReadyClock> {
|
||||
#[cfg(test)]
|
||||
if let Some(make) = tests::TEST_READY_CLOCK.get() {
|
||||
return make();
|
||||
}
|
||||
Box::new(WallClock::start())
|
||||
}
|
||||
|
||||
/// The production clock: real time, and the transport's cancellable `pause`
|
||||
/// between polls (stop-design-v5 §2.3), so a Stop ends the wait at once.
|
||||
struct WallClock(std::time::Instant);
|
||||
|
||||
impl WallClock {
|
||||
fn start() -> Self {
|
||||
WallClock(std::time::Instant::now())
|
||||
}
|
||||
}
|
||||
|
||||
impl ReadyClock for WallClock {
|
||||
fn elapsed(&self) -> Duration {
|
||||
self.0.elapsed()
|
||||
}
|
||||
fn wait(&mut self, scsi: &mut dyn ScsiTransport, d: Duration) -> scsi::Result<()> {
|
||||
scsi.pause(d)
|
||||
}
|
||||
}
|
||||
|
||||
/// How the post-upload readiness poll ended. Every outcome keeps the unlock; the
|
||||
/// non-`Ready` ones are logged at warn and left to the caller's next command.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum MediaReady {
|
||||
Ready,
|
||||
/// 10 consecutive 02/3A/xx with no 02/04/01 first: an empty drive.
|
||||
NoMedium,
|
||||
/// 02/30/xx: a medium that will never become ready.
|
||||
Incompatible {
|
||||
asc: u8,
|
||||
ascq: u8,
|
||||
},
|
||||
/// Sense key 3, 4 or 5: not a readiness answer.
|
||||
DriveError {
|
||||
sense_key: u8,
|
||||
asc: u8,
|
||||
ascq: u8,
|
||||
},
|
||||
/// T6: no progress for [`READY_STALL`]; carries the last answer.
|
||||
Stalled {
|
||||
sense_key: u8,
|
||||
asc: u8,
|
||||
ascq: u8,
|
||||
},
|
||||
}
|
||||
|
||||
/// Poll TEST UNIT READY until the drive answers ready, following libfreemkv
|
||||
/// `wait_ready`'s terminal states but always keeping the unlock (it succeeded).
|
||||
/// T6 (stop-design-v5 §2.11): progress is an answer (sense key, ASC, ASCQ) not
|
||||
/// yet seen in this poll, or the one START STOP UNIT; [`READY_STALL`] without
|
||||
/// progress ends it. No total cap. `Err` only for a dead bus or a Stop.
|
||||
fn await_media_ready(
|
||||
scsi: &mut dyn ScsiTransport,
|
||||
clock: &mut dyn ReadyClock,
|
||||
) -> Result<MediaReady> {
|
||||
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut seen: Vec<(u8, u8, u8)> = Vec::new();
|
||||
let mut last_progress = clock.elapsed();
|
||||
let (mut start_sent, mut empty_run, mut becoming_ready_seen) = (false, 0u32, false);
|
||||
for polls in 1u64.. {
|
||||
// A drive answer arrives as `Ok` + status, or (libfreemkv's adapter) as an
|
||||
// `Err` carrying the sense; only a senseless `Err` is a dead bus or a Stop.
|
||||
let sense = match scsi.execute(&tur, DataDirection::None, &mut [], TUR_TIMEOUT_MS) {
|
||||
Ok(r) if r.status == 0 => {
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_media_ready",
|
||||
polls,
|
||||
elapsed_ms = clock.elapsed().as_millis() as u64,
|
||||
"drive ready after the firmware upload"
|
||||
);
|
||||
return Ok(MediaReady::Ready);
|
||||
}
|
||||
Ok(r) => r.sense,
|
||||
Err(e) => match e.sense {
|
||||
Some(sense) => sense,
|
||||
None => return Err(Error::from(e)),
|
||||
},
|
||||
};
|
||||
let s = ScsiSense::from_buf(&sense);
|
||||
let answer = (s.sense_key, s.asc, s.ascq);
|
||||
// MMC-6 Table F.3: "2 3A 00 MEDIUM NOT PRESENT"; "2 04 01 LOGICAL UNIT IS IN
|
||||
// PROCESS OF BECOMING READY" first means the 3A run is not final.
|
||||
empty_run = if (s.sense_key, s.asc) == (0x02, 0x3A) {
|
||||
empty_run + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
becoming_ready_seen |= answer == (0x02, 0x04, 0x01);
|
||||
let outcome = match answer {
|
||||
_ if !becoming_ready_seen && empty_run >= READY_MAX_EMPTY_POLLS => {
|
||||
Some(MediaReady::NoMedium)
|
||||
}
|
||||
// Table F.3: "2 30 00 INCOMPATIBLE MEDIUM INSTALLED" (30/xx never ready).
|
||||
(0x02, 0x30, ascq) => Some(MediaReady::Incompatible { asc: 0x30, ascq }),
|
||||
// MEDIUM ERROR; HARDWARE ERROR (F.3.8 "reported when SK = HARDWARE ERROR");
|
||||
// ILLEGAL REQUEST (F.3.2 Table F.2): no readiness to wait for.
|
||||
(sense_key @ 0x03..=0x05, asc, ascq) => Some(MediaReady::DriveError {
|
||||
sense_key,
|
||||
asc,
|
||||
ascq,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(outcome) = outcome {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_media_not_ready",
|
||||
polls,
|
||||
outcome = ?outcome,
|
||||
"drive not ready after the firmware upload; keeping the unlock"
|
||||
);
|
||||
return Ok(outcome);
|
||||
}
|
||||
// Table F.3: "2 04 02 LOGICAL UNIT NOT READY, INITIALIZING CMD. REQUIRED" →
|
||||
// one START STOP UNIT, Table 633 LoEj 0 Start 1: "Start the disc and make
|
||||
// ready for access". Counted as progress.
|
||||
if answer == (0x02, 0x04, 0x02) && !start_sent {
|
||||
start_sent = true;
|
||||
last_progress = clock.elapsed();
|
||||
let r = scsi.execute(
|
||||
&START_UNIT,
|
||||
DataDirection::None,
|
||||
&mut [],
|
||||
START_UNIT_TIMEOUT_MS,
|
||||
);
|
||||
if let Err(e @ crate::scsi::ScsiError { sense: None, .. }) = r {
|
||||
return Err(Error::from(e));
|
||||
}
|
||||
}
|
||||
// Everything else (04/xx, 06/29/00 and 06/28/00 UNIT ATTENTION after the
|
||||
// upload's reset, …) keeps polling under the T6 rule.
|
||||
if !seen.contains(&answer) {
|
||||
// TODO(T6 part 2, stop-design-v5 §2.11): also count a rising SKSV progress
|
||||
// indicator (sense bytes 15-17) as progress, once SS-1 is quoted here.
|
||||
seen.push(answer);
|
||||
last_progress = clock.elapsed();
|
||||
} else if clock.elapsed().saturating_sub(last_progress) >= READY_STALL {
|
||||
let (sense_key, asc, ascq) = answer;
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mt1959_media_ready_stalled",
|
||||
polls,
|
||||
sense_key,
|
||||
asc,
|
||||
ascq,
|
||||
"drive not ready after the firmware upload, no progress for 60 s; \
|
||||
keeping the unlock"
|
||||
);
|
||||
return Ok(MediaReady::Stalled {
|
||||
sense_key,
|
||||
asc,
|
||||
ascq,
|
||||
});
|
||||
}
|
||||
clock.wait(scsi, READY_POLL)?;
|
||||
}
|
||||
unreachable!("the poll returns from inside the loop")
|
||||
}
|
||||
|
||||
// ── PlatformDriver trait ───────────────────────────────────────────────
|
||||
|
||||
impl PlatformDriver for Mt1959 {
|
||||
@@ -496,8 +709,16 @@ impl PlatformDriver for Mt1959 {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
pub(crate) type ClockFactory = fn() -> Box<dyn ReadyClock>;
|
||||
|
||||
thread_local! {
|
||||
/// Per-thread override of the poll's clock (see [`ready_clock`]).
|
||||
pub(crate) static TEST_READY_CLOCK: std::cell::Cell<Option<ClockFactory>> =
|
||||
const { std::cell::Cell::new(None) };
|
||||
}
|
||||
use crate::ld::profile::{DriveProfile, Identity};
|
||||
use crate::scsi::{DataDirection, ScsiResult, ScsiTransport};
|
||||
|
||||
@@ -1211,6 +1432,504 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// UT10 (stop-design-v5 §5.2, D10): "`run_init` must `return Ok(())` as soon as
|
||||
// `load_firmware` confirms the unlock. The final upload is kept." GUARD G3 too;
|
||||
// per design D10, do not change without a design citation proving otherwise.
|
||||
#[test]
|
||||
fn run_init_returns_ok_once_load_firmware_confirms() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let sig = [0x11, 0x22, 0x33, 0x44];
|
||||
let unlock_ok = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG);
|
||||
let short = || Reply::short(vec![0u8; 64], 10); // a generic do_unlock failure
|
||||
let upload_ok = || {
|
||||
vec![
|
||||
Reply::good(vec![]), // WRITE_BUFFER
|
||||
Reply::good(vec![0u8; VALIDATE_RESPONSE_SIZE as usize]), // 0x45 verify
|
||||
]
|
||||
};
|
||||
// Case 1: attempt 0 reloads and load_firmware's own do_unlock confirms.
|
||||
// Case 2: attempts 0-1 reload but fail; attempt 2 (the LAST) confirms,
|
||||
// which the retry loop used to discard as `Err(last_err)`.
|
||||
for failed_reloads in [0usize, 2] {
|
||||
let mut script = Vec::new();
|
||||
for _ in 0..failed_reloads {
|
||||
script.push(short()); // run_init's do_unlock
|
||||
script.extend(upload_ok());
|
||||
script.push(short()); // load_firmware's do_unlock fails
|
||||
}
|
||||
script.push(short()); // run_init's do_unlock
|
||||
script.extend(upload_ok());
|
||||
script.push(Reply::good(unlock_ok.clone())); // load_firmware do_unlock #1
|
||||
script.push(Reply::good(unlock_ok.clone())); // do_unlock #2 (best-effort)
|
||||
script.push(Reply::good(vec![])); // TEST UNIT READY: ready at once
|
||||
let expected_calls = script.len();
|
||||
// Anything past the script is a further retry, which must not happen.
|
||||
let mut t = MockTransport::scripted(script, Reply::TransportFault);
|
||||
let mut profile = fixture_profile(sig);
|
||||
profile.firmware = vec![0u8; 64];
|
||||
let mut mt = Mt1959::new(profile, false);
|
||||
let t0 = std::time::Instant::now();
|
||||
mt.init(&mut t)
|
||||
.unwrap_or_else(|e| panic!("{failed_reloads} failed reloads: {e:?}"));
|
||||
assert_eq!(
|
||||
t.calls(),
|
||||
expected_calls,
|
||||
"no retry after a confirmed unlock"
|
||||
);
|
||||
assert!(mt.is_ready() && mt.is_unlocked(), "the unlock is kept");
|
||||
let uploads = t.cdbs.iter().filter(|c| c[0] == SCSI_WRITE_BUFFER).count();
|
||||
assert_eq!(
|
||||
uploads,
|
||||
failed_reloads + 1,
|
||||
"the final upload is still issued"
|
||||
);
|
||||
assert_eq!(t.cdbs.last().map(|c| c[0]), Some(SCSI_TEST_UNIT_READY));
|
||||
assert!(
|
||||
t0.elapsed() < std::time::Duration::from_secs(5),
|
||||
"no fixed 10 s settle: a ready drive returns at once"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The upload reset the drive: a confirmed unlock does not prove the media is
|
||||
// ready, so run_init polls TEST UNIT READY until it is (coordinator review of
|
||||
// ST-D10; the T6 rule, stop-design-v5 §2.11) before handing the drive back.
|
||||
#[test]
|
||||
fn run_init_waits_for_media_ready_after_upload() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let sig = [0x11, 0x22, 0x33, 0x44];
|
||||
let unlock_ok = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG);
|
||||
let becoming_ready = || Reply::Sense {
|
||||
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
sense_key: 0x02,
|
||||
asc: 0x04,
|
||||
ascq: 0x01,
|
||||
};
|
||||
let script = vec![
|
||||
Reply::short(vec![0u8; 64], 10), // run_init's do_unlock
|
||||
Reply::good(vec![]), // WRITE_BUFFER
|
||||
Reply::good(vec![0u8; VALIDATE_RESPONSE_SIZE as usize]), // 0x45 verify
|
||||
Reply::good(unlock_ok.clone()), // do_unlock #1
|
||||
Reply::good(unlock_ok), // do_unlock #2
|
||||
becoming_ready(), // TUR: 02/04/01
|
||||
becoming_ready(), // TUR: 02/04/01
|
||||
Reply::good(vec![]), // TUR: ready
|
||||
];
|
||||
let expected = script.len();
|
||||
let mut t = MockTransport::scripted(script, Reply::TransportFault);
|
||||
let mut profile = fixture_profile(sig);
|
||||
profile.firmware = vec![0u8; 64];
|
||||
let mut mt = Mt1959::new(profile, false);
|
||||
mt.init(&mut t).expect("ready after two not-ready polls");
|
||||
assert_eq!(t.calls(), expected, "polled until ready, then stopped");
|
||||
let turs = t.cdbs.iter().filter(|c| c[0] == SCSI_TEST_UNIT_READY);
|
||||
assert_eq!(turs.count(), 3, "three TEST UNIT READY polls");
|
||||
}
|
||||
|
||||
/// A virtual clock: `wait` advances time instantly and counts the waits.
|
||||
pub(crate) struct FakeClock {
|
||||
now: Duration,
|
||||
waits: usize,
|
||||
}
|
||||
|
||||
impl FakeClock {
|
||||
pub(crate) fn new() -> Self {
|
||||
FakeClock {
|
||||
now: Duration::ZERO,
|
||||
waits: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ReadyClock for FakeClock {
|
||||
fn elapsed(&self) -> Duration {
|
||||
self.now
|
||||
}
|
||||
fn wait(&mut self, _scsi: &mut dyn ScsiTransport, d: Duration) -> crate::scsi::Result<()> {
|
||||
self.now += d;
|
||||
self.waits += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A NOT READY TEST UNIT READY answer (status CHECK CONDITION).
|
||||
fn not_ready(asc: u8, ascq: u8) -> crate::scsi::mock::Reply {
|
||||
crate::scsi::mock::Reply::Sense {
|
||||
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
sense_key: 0x02,
|
||||
asc,
|
||||
ascq,
|
||||
}
|
||||
}
|
||||
|
||||
// T6 (stop-design-v5 §2.11): NOT READY "becoming ready" for a few polls, then
|
||||
// ready → Ok, having waited only between polls (no fixed settle).
|
||||
#[test]
|
||||
fn media_ready_poll_returns_once_ready() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let script = vec![
|
||||
not_ready(0x04, 0x01),
|
||||
not_ready(0x04, 0x01),
|
||||
not_ready(0x04, 0x01),
|
||||
];
|
||||
let mut t = MockTransport::scripted(script, Reply::good(vec![]));
|
||||
let mut clock = FakeClock::new();
|
||||
let r = await_media_ready(&mut t, &mut clock).expect("ready on the 4th poll");
|
||||
assert_eq!(r, MediaReady::Ready);
|
||||
assert_eq!(t.calls(), 4);
|
||||
assert!(t.cdbs.iter().all(|c| c.as_slice() == [0u8; 6]), "TUR only");
|
||||
assert_eq!((clock.waits, clock.now), (3, READY_POLL * 3));
|
||||
}
|
||||
|
||||
// Review of ST-D10: a stall after a confirmed upload must not undo the unlock.
|
||||
// The SAME not-ready answer for 60 s (T6) → warn and return Ok, exactly at 60 s.
|
||||
#[test]
|
||||
fn media_ready_poll_stall_keeps_the_unlock() {
|
||||
use crate::scsi::mock::MockTransport;
|
||||
let mut t = MockTransport::always(not_ready(0x04, 0x01));
|
||||
let mut clock = FakeClock::new();
|
||||
let r = await_media_ready(&mut t, &mut clock).expect("a stall keeps the unlock");
|
||||
let want = MediaReady::Stalled {
|
||||
sense_key: 0x02,
|
||||
asc: 0x04,
|
||||
ascq: 0x01,
|
||||
};
|
||||
assert_eq!(r, want);
|
||||
assert_eq!(
|
||||
clock.now, READY_STALL,
|
||||
"gives up exactly at 60 s without progress"
|
||||
);
|
||||
assert_eq!(
|
||||
t.calls(),
|
||||
121,
|
||||
"one poll per 500 ms, then the 61st second's poll"
|
||||
);
|
||||
}
|
||||
|
||||
// MMC-6 Table F.3 "2 3A 00 MEDIUM NOT PRESENT": 10 consecutive answers (~5 s,
|
||||
// libfreemkv WAIT_READY_MAX_EMPTY_POLLS) with no 04/01 → Ok (empty drive).
|
||||
#[test]
|
||||
fn media_ready_poll_no_medium_returns_after_the_grace() {
|
||||
use crate::scsi::mock::MockTransport;
|
||||
let mut t = MockTransport::always(not_ready(0x3A, 0x00));
|
||||
let mut clock = FakeClock::new();
|
||||
let r = await_media_ready(&mut t, &mut clock).expect("empty drive");
|
||||
assert_eq!(r, MediaReady::NoMedium);
|
||||
assert_eq!(
|
||||
(t.calls(), clock.waits),
|
||||
(10, 9),
|
||||
"10 × 3A, ~4.5 s of waits"
|
||||
);
|
||||
}
|
||||
|
||||
// Table F.3 "2 04 01 LOGICAL UNIT IS IN PROCESS OF BECOMING READY" seen first:
|
||||
// a later 3A run is not final, so the poll keeps going past 10 × 3A.
|
||||
#[test]
|
||||
fn media_ready_poll_keeps_polling_3a_after_04_01() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut script = vec![not_ready(0x04, 0x01)];
|
||||
script.extend((0..20).map(|_| not_ready(0x3A, 0x00)));
|
||||
let mut t = MockTransport::scripted(script, Reply::good(vec![]));
|
||||
let mut clock = FakeClock::new();
|
||||
let r = await_media_ready(&mut t, &mut clock).expect("ready");
|
||||
assert_eq!(r, MediaReady::Ready, "not cut off as an empty drive");
|
||||
assert_eq!(t.calls(), 22, "polled through 20 × 3A to ready");
|
||||
}
|
||||
|
||||
// Table F.3 "2 04 02 LOGICAL UNIT NOT READY, INITIALIZING CMD. REQUIRED" →
|
||||
// exactly one START STOP UNIT, Table 633 "0 1 Start the disc and make ready".
|
||||
#[test]
|
||||
fn media_ready_poll_04_02_sends_one_start_unit() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let script = vec![
|
||||
not_ready(0x04, 0x02),
|
||||
not_ready(0x04, 0x02),
|
||||
not_ready(0x04, 0x02),
|
||||
];
|
||||
let mut t = MockTransport::scripted(script, Reply::good(vec![]));
|
||||
let mut clock = FakeClock::new();
|
||||
let r = await_media_ready(&mut t, &mut clock).expect("ready");
|
||||
assert_eq!(r, MediaReady::Ready);
|
||||
let starts: Vec<_> = t.cdbs.iter().filter(|c| c[0] == 0x1B).collect();
|
||||
assert_eq!(
|
||||
starts,
|
||||
vec![&vec![0x1B, 0x00, 0x00, 0x00, 0x01, 0x00]],
|
||||
"one START UNIT"
|
||||
);
|
||||
assert_eq!(t.cdbs[1][0], 0x1B, "sent right after the first 04/02");
|
||||
}
|
||||
|
||||
// Table F.3 "2 30 00 INCOMPATIBLE MEDIUM INSTALLED" never becomes ready:
|
||||
// return Ok at once (the unlock stands), with no wait.
|
||||
#[test]
|
||||
fn media_ready_poll_incompatible_medium_returns_at_once() {
|
||||
use crate::scsi::mock::MockTransport;
|
||||
let mut t = MockTransport::always(not_ready(0x30, 0x00));
|
||||
let mut clock = FakeClock::new();
|
||||
let r = await_media_ready(&mut t, &mut clock).expect("unlock kept");
|
||||
assert_eq!(
|
||||
r,
|
||||
MediaReady::Incompatible {
|
||||
asc: 0x30,
|
||||
ascq: 0x00
|
||||
}
|
||||
);
|
||||
assert_eq!((t.calls(), clock.waits), (1, 0));
|
||||
}
|
||||
|
||||
// Table F.1: "6 29 00 POWER ON, RESET, OR BUS DEVICE RESET OCCURRED" and
|
||||
// "6 28 00 NOT READY TO READY CHANGE…" after the upload's reset: keep polling.
|
||||
#[test]
|
||||
fn media_ready_poll_unit_attention_then_ready() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let ua = |asc| Reply::Sense {
|
||||
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
sense_key: 0x06,
|
||||
asc,
|
||||
ascq: 0x00,
|
||||
};
|
||||
let mut t = MockTransport::scripted(vec![ua(0x29), ua(0x28)], Reply::good(vec![]));
|
||||
let mut clock = FakeClock::new();
|
||||
let r = await_media_ready(&mut t, &mut clock).expect("ready");
|
||||
assert_eq!(r, MediaReady::Ready);
|
||||
assert_eq!((t.calls(), clock.waits), (3, 2));
|
||||
}
|
||||
|
||||
// MEDIUM ERROR (3), HARDWARE ERROR (4, MMC-6 F.3.8 "reported when SK = HARDWARE
|
||||
// ERROR"), ILLEGAL REQUEST (5, F.3.2) are not readiness answers: Ok at once.
|
||||
#[test]
|
||||
fn media_ready_poll_error_sense_keys_return_at_once() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
for sense_key in [0x03, 0x04, 0x05] {
|
||||
let mut t = MockTransport::always(Reply::Sense {
|
||||
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
sense_key,
|
||||
asc: 0x00,
|
||||
ascq: 0x00,
|
||||
});
|
||||
let mut clock = FakeClock::new();
|
||||
let r = await_media_ready(&mut t, &mut clock).expect("unlock kept");
|
||||
let want = MediaReady::DriveError {
|
||||
sense_key,
|
||||
asc: 0,
|
||||
ascq: 0,
|
||||
};
|
||||
assert_eq!(r, want, "SK {sense_key}");
|
||||
assert_eq!((t.calls(), clock.waits), (1, 0), "SK {sense_key}");
|
||||
}
|
||||
}
|
||||
|
||||
// libfreemkv's adapter returns `Err` carrying the sense for a non-zero status:
|
||||
// that is a drive answer, not a dead bus, so the poll must keep going.
|
||||
#[test]
|
||||
fn media_ready_poll_err_with_sense_is_an_answer() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let err_nr = || Reply::ErrWithSense {
|
||||
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
sense_key: 0x02,
|
||||
asc: 0x04,
|
||||
ascq: 0x01,
|
||||
};
|
||||
let mut t = MockTransport::scripted(vec![err_nr(), err_nr()], Reply::good(vec![]));
|
||||
let mut clock = FakeClock::new();
|
||||
let r = await_media_ready(&mut t, &mut clock).expect("an answer, not a dead bus");
|
||||
assert_eq!(r, MediaReady::Ready);
|
||||
assert_eq!(t.calls(), 3);
|
||||
}
|
||||
|
||||
// T6: a NEW answer is progress and re-arms the window, so a drive that keeps
|
||||
// moving through new states is never cut off by a total (150 s here).
|
||||
#[test]
|
||||
fn media_ready_poll_new_answers_rearm_the_window() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut script = Vec::new();
|
||||
for ascq in [0x01, 0x04, 0x07] {
|
||||
script.extend((0..100).map(|_| not_ready(0x04, ascq))); // 50 s each
|
||||
}
|
||||
let mut t = MockTransport::scripted(script, Reply::good(vec![]));
|
||||
let mut clock = FakeClock::new();
|
||||
let r = await_media_ready(&mut t, &mut clock).expect("progressing drive");
|
||||
assert_eq!(r, MediaReady::Ready);
|
||||
assert_eq!(clock.now, READY_POLL * 300, "waited 150 s in total");
|
||||
}
|
||||
|
||||
// T6 stricter reading (§2.11): two KNOWN answers alternating is not progress,
|
||||
// so a flapping drive stalls 60 s after its last new answer (unlock kept).
|
||||
#[test]
|
||||
fn media_ready_poll_flapping_answers_still_stall() {
|
||||
struct Flap(usize);
|
||||
impl ScsiTransport for Flap {
|
||||
fn execute(
|
||||
&mut self,
|
||||
_cdb: &[u8],
|
||||
_dir: DataDirection,
|
||||
_data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> crate::scsi::Result<ScsiResult> {
|
||||
self.0 += 1;
|
||||
let mut sense = [0u8; 32];
|
||||
sense[2] = 0x02;
|
||||
sense[12] = 0x04;
|
||||
sense[13] = if self.0.is_multiple_of(2) { 0x01 } else { 0x04 };
|
||||
Ok(ScsiResult {
|
||||
status: crate::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
bytes_transferred: 0,
|
||||
sense,
|
||||
})
|
||||
}
|
||||
}
|
||||
let mut clock = FakeClock::new();
|
||||
let r = await_media_ready(&mut Flap(0), &mut clock).expect("unlock kept");
|
||||
assert!(matches!(r, MediaReady::Stalled { .. }), "{r:?}");
|
||||
assert_eq!(
|
||||
clock.now,
|
||||
READY_POLL + READY_STALL,
|
||||
"60 s after the 2nd answer"
|
||||
);
|
||||
}
|
||||
|
||||
// A dead bus on TEST UNIT READY aborts at once: no wait, no retry.
|
||||
#[test]
|
||||
fn media_ready_poll_aborts_on_a_dead_bus() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let mut t = MockTransport::always(Reply::TransportFault);
|
||||
let mut clock = FakeClock::new();
|
||||
let e = await_media_ready(&mut t, &mut clock).expect_err("dead bus");
|
||||
assert!(e.is_transport_failure());
|
||||
assert_eq!((t.calls(), clock.waits), (1, 0));
|
||||
}
|
||||
|
||||
// A Stop during the post-upload readiness poll ends it at the next wait:
|
||||
// the production clock waits via the transport's cancellable `pause`, and
|
||||
// the refusal is the dead-bus shape libfreemkv reclassifies as Halted (§2.3).
|
||||
#[test]
|
||||
fn media_ready_poll_stop_during_wait_ends_it() {
|
||||
use crate::scsi::mock::{Ev, MockTransport, StopFake};
|
||||
let mut t = StopFake::new(MockTransport::always(not_ready(0x04, 0x01)));
|
||||
t.cancel_after = Some(|c| c[0] == SCSI_TEST_UNIT_READY);
|
||||
let t0 = std::time::Instant::now();
|
||||
let e = await_media_ready(&mut t, &mut WallClock::start()).expect_err("stopped");
|
||||
assert!(
|
||||
t0.elapsed() < Duration::from_millis(100),
|
||||
"no real sleep after a Stop"
|
||||
);
|
||||
assert!(
|
||||
e.is_transport_failure(),
|
||||
"the refusal shape (Halted in libfreemkv)"
|
||||
);
|
||||
assert_eq!(
|
||||
t.log,
|
||||
vec![Ev::Exec(vec![0u8; 6]), Ev::PauseRefused(READY_POLL)],
|
||||
"the wait itself observed the Stop; no further TUR"
|
||||
);
|
||||
}
|
||||
|
||||
/// A Stop that lands during the upload, through `run_init` on `mode`: the
|
||||
/// first do_unlock fails generically (so the firmware reloads), and the
|
||||
/// cancel arrives once a CDB matching `stop_at` reaches the drive.
|
||||
fn stop_during_upload(
|
||||
is_variant_b: bool,
|
||||
stop_at: fn(&[u8]) -> bool,
|
||||
) -> (Result<()>, Vec<crate::scsi::mock::Ev>) {
|
||||
use crate::scsi::mock::{MockTransport, Reply, StopFake};
|
||||
let sig = [0x11, 0x22, 0x33, 0x44];
|
||||
let mut profile = fixture_profile(sig);
|
||||
profile.firmware = vec![0u8; 64];
|
||||
let unlock_ok = build_response(sig, FIRMWARE_ACTIVE_SIG, FIRMWARE_MODE_SIG);
|
||||
let inner = MockTransport::scripted(
|
||||
vec![Reply::short(vec![0u8; 64], 10)], // run_init's do_unlock
|
||||
Reply::good(unlock_ok),
|
||||
);
|
||||
let mut t = StopFake::new(inner);
|
||||
t.cancel_after = Some(stop_at);
|
||||
let mut mt = Mt1959::new(profile, is_variant_b);
|
||||
let r = mt.init(&mut t);
|
||||
(r, t.log)
|
||||
}
|
||||
|
||||
fn is_unlock_cdb(c: &[u8]) -> bool {
|
||||
c[0] == SCSI_READ_BUFFER && c[2] == BUFFER_ID_A && c[3] == SUB_CMD_UNLOCK
|
||||
|| c[0] == SCSI_READ_BUFFER && c[2] == BUFFER_ID_B && c[3] == SUB_CMD_UNLOCK
|
||||
}
|
||||
|
||||
// UT7 (stop-design-v5 §5.2; §2.3 D8 "the WRITE BUFFER at 30 s … and its verify
|
||||
// run inside a `CriticalGuard`. C_max = 35 s"): the verify still executes
|
||||
// after a Stop mid-upload, and the next do_unlock is refused.
|
||||
#[test]
|
||||
fn mt1959_a_upload_completes_after_cancel_then_stops() {
|
||||
use crate::scsi::mock::Ev;
|
||||
let (r, log) = stop_during_upload(false, |c| c[0] == SCSI_WRITE_BUFFER);
|
||||
let e = r.expect_err("the Stop ends the init");
|
||||
assert!(e.is_transport_failure(), "a refusal is the dead-bus shape");
|
||||
let shape: Vec<String> = log
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
Ev::Exec(c) if is_unlock_cdb(c) => "unlock".into(),
|
||||
Ev::Refused(c) if is_unlock_cdb(c) => "unlock refused".into(),
|
||||
Ev::Exec(c) => format!("{:02X}/{:02X}", c[0], c[2]),
|
||||
other => format!("{other:?}"),
|
||||
})
|
||||
.collect();
|
||||
let want = ["unlock", "Begin", "3B/00", "3C/45", "End", "unlock refused"];
|
||||
assert_eq!(shape, want, "upload + verify complete; then stopped");
|
||||
}
|
||||
|
||||
// UT7, cancel before the span: no WRITE BUFFER is ever started.
|
||||
#[test]
|
||||
fn mt1959_a_stop_before_upload_never_starts_it() {
|
||||
use crate::scsi::mock::Ev;
|
||||
let (r, log) = stop_during_upload(false, is_unlock_cdb);
|
||||
assert!(r.expect_err("stopped").is_transport_failure());
|
||||
assert_eq!(log.len(), 2, "{log:?}");
|
||||
assert_eq!(
|
||||
log[1],
|
||||
Ev::BeginRefused,
|
||||
"the span refuses to open after a Stop"
|
||||
);
|
||||
}
|
||||
|
||||
// UT8 (stop-design-v5 §5.2; §2.3 "MODE SELECT at 30 s …, READ BUFFER, WRITE
|
||||
// BUFFER and F1 run inside a `CriticalGuard`. C_max = 45 s"), as UT7 for B.
|
||||
#[test]
|
||||
fn mt1959_b_upload_completes_after_cancel_then_stops() {
|
||||
use crate::scsi::mock::Ev;
|
||||
let (r, log) = stop_during_upload(true, |c| c[0] == 0x55);
|
||||
assert!(r.expect_err("stopped").is_transport_failure());
|
||||
let shape: Vec<String> = log
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
Ev::Exec(c) if is_unlock_cdb(c) => "unlock".into(),
|
||||
Ev::Refused(c) if is_unlock_cdb(c) => "unlock refused".into(),
|
||||
Ev::Exec(c) => format!("{:02X}", c[0]),
|
||||
other => format!("{other:?}"),
|
||||
})
|
||||
.collect();
|
||||
let want = [
|
||||
"unlock",
|
||||
"Begin",
|
||||
"55",
|
||||
"3C",
|
||||
"3B",
|
||||
"F1",
|
||||
"End",
|
||||
"unlock refused",
|
||||
];
|
||||
assert_eq!(
|
||||
shape, want,
|
||||
"MODE SELECT, READ/WRITE BUFFER, F1; then stopped"
|
||||
);
|
||||
}
|
||||
|
||||
// UT8, cancel before the span: no MODE SELECT is ever started.
|
||||
#[test]
|
||||
fn mt1959_b_stop_before_upload_never_starts_it() {
|
||||
use crate::scsi::mock::Ev;
|
||||
let (r, log) = stop_during_upload(true, is_unlock_cdb);
|
||||
assert!(r.expect_err("stopped").is_transport_failure());
|
||||
assert_eq!(log.len(), 2, "{log:?}");
|
||||
assert_eq!(log[1], Ev::BeginRefused);
|
||||
}
|
||||
|
||||
// ── run_probe entry / disc-type detection ───────────────────────────────
|
||||
|
||||
/// `run_probe` called before `init_complete` runs `do_unlock` itself
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use super::Mt1959;
|
||||
use crate::ld::error::Result;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::scsi::{CriticalGuard, DataDirection, ScsiTransport};
|
||||
|
||||
use super::SCSI_WRITE_BUFFER;
|
||||
|
||||
@@ -40,7 +40,10 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
0x00,
|
||||
];
|
||||
let mut data = firmware.clone();
|
||||
scsi.execute(&cdb, DataDirection::ToDevice, &mut data, 30_000)?;
|
||||
// D8 (stop-design-v5 §2.3): "the WRITE BUFFER at 30 s … and its verify run inside
|
||||
// a `CriticalGuard`. C_max = 35 s" — a Stop mid-upload waits for both CDBs.
|
||||
let mut span = CriticalGuard::enter(&mut *scsi)?;
|
||||
span.execute(&cdb, DataDirection::ToDevice, &mut data, 30_000)?;
|
||||
|
||||
// Verify the firmware loaded (non-fatal — different buffer_id 0x45). No
|
||||
// documented expected payload exists, so the outcome is traced rather
|
||||
@@ -58,7 +61,7 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
0x00,
|
||||
];
|
||||
let mut verify_resp = [0u8; super::VALIDATE_RESPONSE_SIZE as usize];
|
||||
match scsi.execute(
|
||||
match span.execute(
|
||||
&verify_cdb,
|
||||
DataDirection::FromDevice,
|
||||
&mut verify_resp,
|
||||
@@ -96,6 +99,8 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
}
|
||||
}
|
||||
|
||||
drop(span); // the span ends here; the unlock below stays cancellable
|
||||
|
||||
// Double unlock after firmware upload: first is fatal on failure, second
|
||||
// is a best-effort confirmation pass (matching variant B) so a benign
|
||||
// hiccup there doesn't fail an already-successful unlock.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use super::{Mt1959, SCSI_READ_BUFFER, SCSI_WRITE_BUFFER};
|
||||
use crate::ld::error::Result;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::scsi::{CriticalGuard, DataDirection, ScsiTransport};
|
||||
|
||||
const SCSI_MODE_SELECT: u8 = 0x55;
|
||||
const FIRMWARE_EXTRA: [u8; 16] = [0; 16];
|
||||
@@ -77,7 +77,10 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
0x00,
|
||||
];
|
||||
let mut data = firmware[..write_len].to_vec();
|
||||
scsi.execute(&mode_select_cdb, DataDirection::ToDevice, &mut data, 30_000)?;
|
||||
// stop-design-v5 §2.3: "MODE SELECT at 30 s …, READ BUFFER, WRITE BUFFER and F1 run
|
||||
// inside a `CriticalGuard`. C_max = 45 s" — a Stop mid-upload waits for all four.
|
||||
let mut span = CriticalGuard::enter(&mut *scsi)?;
|
||||
span.execute(&mode_select_cdb, DataDirection::ToDevice, &mut data, 30_000)?;
|
||||
|
||||
// Step 2: Read firmware metadata (READ_BUFFER mode 6, offset 0x3000)
|
||||
let read_meta_cdb = [
|
||||
@@ -95,7 +98,7 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
let mut meta_resp = [0u8; 16];
|
||||
trace_step(
|
||||
"mt1959b_fw_meta",
|
||||
scsi.execute(
|
||||
span.execute(
|
||||
&read_meta_cdb,
|
||||
DataDirection::FromDevice,
|
||||
&mut meta_resp,
|
||||
@@ -119,7 +122,7 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
let mut data2 = FIRMWARE_EXTRA.to_vec();
|
||||
trace_step(
|
||||
"mt1959b_fw_extra",
|
||||
scsi.execute(&write_extra_cdb, DataDirection::ToDevice, &mut data2, 5_000),
|
||||
span.execute(&write_extra_cdb, DataDirection::ToDevice, &mut data2, 5_000),
|
||||
)?;
|
||||
|
||||
// Step 4: Vendor verify (0xF1 — B-only, not standard SCSI), per-drive.
|
||||
@@ -127,8 +130,9 @@ pub(super) fn load_firmware(mt: &mut Mt1959, scsi: &mut dyn ScsiTransport) -> Re
|
||||
let mut dummy = [0u8; 0];
|
||||
trace_step(
|
||||
"mt1959b_fw_verify",
|
||||
scsi.execute(&verify_cdb, DataDirection::None, &mut dummy, 5_000),
|
||||
span.execute(&verify_cdb, DataDirection::None, &mut dummy, 5_000),
|
||||
)?;
|
||||
drop(span); // the span ends here; the unlock retries below stay cancellable
|
||||
|
||||
// Step 5: Unlock retries (up to 5, then a final fatal attempt). The
|
||||
// confirmation pass after success is intentionally best-effort.
|
||||
|
||||
+72
-56
@@ -308,12 +308,12 @@ fn load_from_str(data: &str) -> Result<Profiles> {
|
||||
|
||||
/// Find a profile matching a drive's INQUIRY fields, whitespace-trimmed.
|
||||
///
|
||||
/// Per platform section (MT1959-A, -B, Renesas): (1) exact match including
|
||||
/// `product_id` when the drive reports one; (2) product-id-blind fallback on
|
||||
/// the four-field identity (vendor/revision/vendor_specific/firmware_date).
|
||||
/// Catalogs store a GENERIC product_id (e.g. "BD-RE") vs a drive's specific one
|
||||
/// ("BD-RE BU40N"), so pass 1 misses; pass 2 binds a UNIQUE four-field match —
|
||||
/// a shared four-tuple binds only when the drive reported no product_id.
|
||||
/// Over ALL platform sections (MT1959-A, -B, Renesas, in order): (1) exact
|
||||
/// match including `product_id` when the drive reports one; then (2) a
|
||||
/// product-id-blind fallback on vendor/revision/vendor_specific/firmware_date.
|
||||
/// Catalogs store a GENERIC product_id ("BD-RE") vs a drive's specific one
|
||||
/// ("BD-RE BU40N"), so pass 1 misses; pass 2 binds a four-field match UNIQUE
|
||||
/// across all sections — a shared tuple binds only with no product_id.
|
||||
pub fn find_by_drive_id(profiles: &Profiles, drive_id: &crate::DriveId) -> Option<ProfileMatch> {
|
||||
let v = drive_id.vendor_id.trim();
|
||||
let prod = drive_id.product_id.trim();
|
||||
@@ -321,62 +321,45 @@ pub fn find_by_drive_id(profiles: &Profiles, drive_id: &crate::DriveId) -> Optio
|
||||
let vs = drive_id.vendor_specific.trim();
|
||||
let date = drive_id.firmware_date.trim();
|
||||
|
||||
for (platform, list) in [
|
||||
let sections = [
|
||||
(Platform::Mt1959A, &profiles.mt1959_a),
|
||||
(Platform::Mt1959B, &profiles.mt1959_b),
|
||||
(Platform::Renesas, &profiles.renesas),
|
||||
] {
|
||||
if !prod.is_empty()
|
||||
&& let Some(p) = list.iter().find(|p| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_id.trim() == prod
|
||||
&& p.identity.product_revision.trim() == r
|
||||
&& p.identity.vendor_specific.trim() == vs
|
||||
&& p.identity.firmware_date.trim() == date
|
||||
})
|
||||
{
|
||||
return Some(ProfileMatch {
|
||||
profile: p.clone(),
|
||||
platform,
|
||||
});
|
||||
}
|
||||
|
||||
// Product-id-blind fallback: catalogs store a GENERIC product_id while
|
||||
// drives report a specific one, so the exact pass misses. Match the
|
||||
// four-field identity instead; guard mis-binding on shared tuples below.
|
||||
let four_field: Vec<&DriveProfile> = list
|
||||
];
|
||||
let four_field = |p: &DriveProfile| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_revision.trim() == r
|
||||
&& p.identity.vendor_specific.trim() == vs
|
||||
&& p.identity.firmware_date.trim() == date
|
||||
};
|
||||
let all = || {
|
||||
sections
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.identity.vendor_id.trim() == v
|
||||
&& p.identity.product_revision.trim() == r
|
||||
&& p.identity.vendor_specific.trim() == vs
|
||||
&& p.identity.firmware_date.trim() == date
|
||||
})
|
||||
.collect();
|
||||
match four_field.as_slice() {
|
||||
// Exactly one four-field match — unambiguous; bind regardless of
|
||||
// the reported product_id (the common real-drive case, and the
|
||||
// pre-1.7.0 behaviour a UHD-capable LG BU40N relied on).
|
||||
[only] => {
|
||||
return Some(ProfileMatch {
|
||||
profile: (*only).clone(),
|
||||
platform,
|
||||
});
|
||||
}
|
||||
// Shared four-tuple: bind only when the drive reported NO
|
||||
// product_id. A non-empty one that missed the exact pass is an
|
||||
// uncataloged variant and must not mis-bind to a sibling.
|
||||
[first, ..] if prod.is_empty() => {
|
||||
return Some(ProfileMatch {
|
||||
profile: (*first).clone(),
|
||||
platform,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
.flat_map(|(platform, list)| list.iter().map(move |p| (*platform, p)))
|
||||
};
|
||||
|
||||
if !prod.is_empty()
|
||||
&& let Some((platform, p)) =
|
||||
all().find(|(_, p)| four_field(p) && p.identity.product_id.trim() == prod)
|
||||
{
|
||||
return Some(ProfileMatch {
|
||||
profile: p.clone(),
|
||||
platform,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
// Unique across all sections: bind regardless of the reported product_id
|
||||
// (the common real-drive case a UHD LG BU40N relies on). A shared tuple
|
||||
// binds only with NO product_id; otherwise it's an uncataloged variant.
|
||||
let mut matches = all().filter(|(_, p)| four_field(p));
|
||||
let (platform, first) = matches.next()?;
|
||||
if matches.next().is_some() && !prod.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(ProfileMatch {
|
||||
profile: first.clone(),
|
||||
platform,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -473,6 +456,39 @@ mod tests {
|
||||
assert_eq!(m.platform, Platform::Mt1959A);
|
||||
}
|
||||
|
||||
/// An exact product_id match in a LATER section must beat a four-field
|
||||
/// fallback in an earlier one; fallback uniqueness spans all sections.
|
||||
#[test]
|
||||
fn find_by_drive_id_exact_match_beats_earlier_section_fallback() {
|
||||
use serde_json::json;
|
||||
let entry = |prod: &str, sig: &str| {
|
||||
json!({"identity": {"vendor_id":"HL-DT-ST","product_id":prod,
|
||||
"product_revision":"1.03","vendor_specific":"NM00000",
|
||||
"firmware_date":"211810241934"},
|
||||
"signature":sig,"firmware":""})
|
||||
};
|
||||
let profiles: Profiles = serde_json::from_value(json!({
|
||||
"mt1959_a": [entry("BD-RE", "aaaaaaaa")],
|
||||
"mt1959_b": [entry("BD-RE BU40N", "bbbbbbbb")],
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let mut id = make_drive_id("HL-DT-ST", "1.03", "NM00000", "211810241934");
|
||||
id.product_id = "BD-RE BU40N".to_string();
|
||||
let m = find_by_drive_id(&profiles, &id).expect("B's exact match");
|
||||
assert_eq!(m.platform, Platform::Mt1959B);
|
||||
assert_eq!(m.profile.signature, [0xbb, 0xbb, 0xbb, 0xbb]);
|
||||
|
||||
// Tuple shared across sections + uncataloged product_id: no match.
|
||||
id.product_id = "BD-RE WH16NS".to_string();
|
||||
assert!(find_by_drive_id(&profiles, &id).is_none());
|
||||
|
||||
// No product_id: shared tuple binds the first section's entry.
|
||||
id.product_id.clear();
|
||||
let m0 = find_by_drive_id(&profiles, &id).expect("empty product_id binds");
|
||||
assert_eq!(m0.platform, Platform::Mt1959A);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_known_drive() {
|
||||
let profiles = load_bundled().unwrap();
|
||||
|
||||
+11
-3
@@ -22,7 +22,7 @@ mod css;
|
||||
// drive to raw reads (freemkv / MT1959 / Renesas). See `vid`.
|
||||
mod vid;
|
||||
// The bare best-effort AACS Volume ID read (`0xAD` fmt `0x80`) — public so a
|
||||
// harness armed `Ake=null` can read the VID with NO cert AKE and prove no
|
||||
// harness armed `Encryption=off` can read the VID with NO cert AKE and prove no
|
||||
// SEND KEY crossed the bus.
|
||||
pub use vid::read_aacs_vid;
|
||||
// `ld` is public only for its drive-profile catalog + (under `emulation`) the handshake wire
|
||||
@@ -125,8 +125,8 @@ impl<'a> UnlockCtx<'a> {
|
||||
/// even on success) and, for the cert route, the bus key the read path applies
|
||||
/// to de-bus content. Returned inside the `Some` of [`Unlocker::unlock`]; there
|
||||
/// is no `drive_unlocked` flag — "unlocked" is simply `unlock()` returning
|
||||
/// `Some`, uniformly for every route.
|
||||
#[derive(Clone, Default)]
|
||||
/// `Some`, uniformly for every route. Both fields are wiped on drop.
|
||||
#[derive(Clone, Default, zeroize::ZeroizeOnDrop)]
|
||||
pub struct Unlocked {
|
||||
pub vid: Option<[u8; 16]>,
|
||||
pub bus_key: Option<[u8; 16]>,
|
||||
@@ -282,6 +282,14 @@ mod tests {
|
||||
assert!(s.contains("Some"), "must still show a key WAS present: {s}");
|
||||
}
|
||||
|
||||
// `vid`/`bus_key` leave the crate in `Unlocked`; wipe them (and every
|
||||
// clone) on drop. Fails to compile if the derive is removed.
|
||||
#[test]
|
||||
fn unlocked_zeroizes_on_drop() {
|
||||
fn assert_zeroize_on_drop<T: zeroize::ZeroizeOnDrop>() {}
|
||||
assert_zeroize_on_drop::<Unlocked>();
|
||||
}
|
||||
|
||||
// `private_key`/`private_key_v2` are the raw host private keys; `Debug` must
|
||||
// NOT print those bytes. Restoring `#[derive(Debug)]` renders the byte
|
||||
// arrays (e.g. 0xAB → 171 / 0xCD → 205), turning this test red.
|
||||
|
||||
+24
-8
@@ -6,7 +6,7 @@
|
||||
//! Renesas drive is named honestly. It does not modify drive state; AACS bus
|
||||
//! decryption is handled by the host cert.
|
||||
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus};
|
||||
use crate::{UnlockCtx, UnlockError, Unlocked, Unlocker};
|
||||
|
||||
/// READ_BUFFER mode 0x02, buffer 0xF1 — the Renesas vendor identity buffer.
|
||||
@@ -37,7 +37,7 @@ pub fn is_renesas(scsi: &mut dyn ScsiTransport) -> std::result::Result<bool, Unl
|
||||
// Only a senseless transport-failure status is a dead bus; anything
|
||||
// else the transport reports as `Err` is the drive refusing.
|
||||
Err(e) => {
|
||||
if e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none() {
|
||||
if is_dead_bus(&e) {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "renesas_probe_transport_fault",
|
||||
@@ -86,11 +86,7 @@ impl Renesas {
|
||||
// fire-and-forget (payload-less); its own status is not the signal.
|
||||
match scsi.execute(&KNOCK_A5AAAA_CDB, DataDirection::None, &mut [], 5_000) {
|
||||
Ok(_) => {}
|
||||
Err(e)
|
||||
if e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none() =>
|
||||
{
|
||||
return Err(UnlockError::Transport);
|
||||
}
|
||||
Err(e) if is_dead_bus(&e) => return Err(UnlockError::Transport),
|
||||
Err(_) => {} // a drive that refuses the knock still gets the B read tried
|
||||
}
|
||||
read_is_good(scsi, &RB_B0_500000_CDB)
|
||||
@@ -108,7 +104,7 @@ fn read_is_good(
|
||||
match scsi.execute(cdb, DataDirection::FromDevice, &mut buf, 5_000) {
|
||||
Ok(r) => Ok(r.status == 0),
|
||||
Err(e) => {
|
||||
if e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none() {
|
||||
if is_dead_bus(&e) {
|
||||
return Err(UnlockError::Transport);
|
||||
}
|
||||
Ok(false)
|
||||
@@ -392,6 +388,26 @@ mod tests {
|
||||
assert!(a < knock && knock < b, "knock must run between A and B");
|
||||
}
|
||||
|
||||
/// A dead bus only at the knock (recovered afterwards) still aborts with
|
||||
/// Transport: the B read must never be sent.
|
||||
#[test]
|
||||
fn dead_bus_at_the_knock_aborts_before_the_b_read() {
|
||||
use crate::scsi::mock::{MockTransport, Reply};
|
||||
let script = vec![
|
||||
Reply::good(renesas_payload()),
|
||||
Reply::illegal_request(),
|
||||
Reply::TransportFault,
|
||||
];
|
||||
let mut t = MockTransport::scripted(script, Reply::good(vec![0u8; 164]));
|
||||
let id = crate::DriveId::default();
|
||||
let ctx = UnlockCtx::new(&id, DiscKind::Unknown);
|
||||
assert_eq!(
|
||||
Renesas::new().unlock(&mut t, &ctx).unwrap_err(),
|
||||
UnlockError::Transport
|
||||
);
|
||||
assert_eq!(t.calls(), 3, "no B read after the dead knock");
|
||||
}
|
||||
|
||||
// A recognized Renesas drive that REFUSES the vendor open read must
|
||||
// defer to the next unlocker, not claim the drive.
|
||||
#[test]
|
||||
|
||||
+406
@@ -31,6 +31,13 @@ pub type Result<T> = std::result::Result<T, ScsiError>;
|
||||
|
||||
/// The one capability an unlocker needs from the host: run a raw CDB. `Ok` even
|
||||
/// on a SCSI sense (inspect `status`); `Err` only on a transport-layer fault.
|
||||
///
|
||||
/// **Stop (cancellation).** The host may cancel the operation. After a cancel,
|
||||
/// [`execute`](Self::execute) refuses with a dead-bus [`ScsiError`] (status 0xFF,
|
||||
/// no sense), so every unlocker aborts through its existing transport-fault path.
|
||||
/// The four defaulted methods let a cancellable host keep the drive safe. A host
|
||||
/// with no cancellation keeps the defaults: plain sleep, no-op critical spans, and
|
||||
/// cleanup CDBs that run as ordinary CDBs.
|
||||
pub trait ScsiTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
@@ -39,6 +46,165 @@ pub trait ScsiTransport {
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult>;
|
||||
|
||||
/// Wait `d` between drive attempts. A cancellable host returns `Err` (dead
|
||||
/// bus) as soon as the operation is cancelled, instead of finishing the wait.
|
||||
fn pause(&mut self, d: std::time::Duration) -> Result<()> {
|
||||
std::thread::sleep(d);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open a critical span (see [`CriticalGuard`]): until the matching
|
||||
/// [`end_critical`](Self::end_critical), `execute` must run even if the
|
||||
/// operation is cancelled meanwhile. `Err` if it is already cancelled.
|
||||
fn begin_critical(&mut self) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the span opened by [`begin_critical`](Self::begin_critical).
|
||||
fn end_critical(&mut self) {}
|
||||
|
||||
/// Run a clean-up CDB that must reach the drive even after a cancel (the
|
||||
/// host allows only an AGID release it recorded, never an arbitrary CDB).
|
||||
fn execute_cleanup(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
self.execute(cdb, direction, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
|
||||
/// A critical span on a transport: [`CriticalGuard::enter`] calls
|
||||
/// [`ScsiTransport::begin_critical`] and `Drop` calls
|
||||
/// [`ScsiTransport::end_critical`], on every exit including unwind. Used where
|
||||
/// abandoning a CDB sequence midway could leave the drive unusable (a firmware
|
||||
/// upload): a cancel that lands inside the span takes effect when it closes.
|
||||
pub struct CriticalGuard<'a> {
|
||||
t: &'a mut (dyn ScsiTransport + 'a),
|
||||
}
|
||||
|
||||
impl<'a> CriticalGuard<'a> {
|
||||
/// Open the span; `Err` (and no span) if the operation is already cancelled.
|
||||
pub fn enter(t: &'a mut (dyn ScsiTransport + 'a)) -> Result<Self> {
|
||||
t.begin_critical()?;
|
||||
Ok(CriticalGuard { t })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CriticalGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.t.end_critical();
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> std::ops::Deref for CriticalGuard<'a> {
|
||||
type Target = dyn ScsiTransport + 'a;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&*self.t
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> std::ops::DerefMut for CriticalGuard<'a> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut *self.t
|
||||
}
|
||||
}
|
||||
|
||||
/// Timeout for the AGID release CDB (the 5 s every AACS/CSS key CDB uses).
|
||||
const AGID_RELEASE_TIMEOUT_MS: u32 = 5_000;
|
||||
|
||||
/// An allocated AGID (Authentication Grant ID) on a transport. `Drop` releases it
|
||||
/// with its REPORT KEY key-format 3Fh CDB, sent through
|
||||
/// [`ScsiTransport::execute_cleanup`] so a cancelled operation still frees it.
|
||||
/// [`AgidGuard::defuse`] instead hands the still-held AGID to the caller.
|
||||
/// Exactly one release per allocation: the guard is the only releaser.
|
||||
pub struct AgidGuard<'a> {
|
||||
t: &'a mut (dyn ScsiTransport + 'a),
|
||||
agid: u8,
|
||||
release: [u8; 12],
|
||||
armed: bool,
|
||||
}
|
||||
|
||||
impl<'a> AgidGuard<'a> {
|
||||
/// Guard `agid`. `release` is its REPORT KEY (0xA4) key-format 3Fh CDB, built
|
||||
/// by the caller so each scheme (AACS key class 2, CSS) keeps its exact bytes.
|
||||
pub fn new(t: &'a mut (dyn ScsiTransport + 'a), agid: u8, release: [u8; 12]) -> Self {
|
||||
// SS-7 (evidence, not spec): REPORT KEY key format 3Fh invalidates the AGID
|
||||
// carried in CDB byte 10 bits 7-6 (libaacs mmc.c; the unlock AGID tests).
|
||||
debug_assert!(
|
||||
release[0] == SCSI_REPORT_KEY && release[10] == ((agid & 0x03) << 6) | 0x3F,
|
||||
"AgidGuard needs this AGID's REPORT KEY format-3Fh CDB"
|
||||
);
|
||||
AgidGuard {
|
||||
t,
|
||||
agid,
|
||||
release,
|
||||
armed: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The guarded AGID.
|
||||
pub fn agid(&self) -> u8 {
|
||||
self.agid
|
||||
}
|
||||
|
||||
/// Hand the AGID over to the caller, still allocated: nothing is released.
|
||||
pub fn defuse(mut self) -> u8 {
|
||||
self.armed = false;
|
||||
self.agid
|
||||
}
|
||||
}
|
||||
|
||||
// Manual Debug: the transport is not `Debug`; the AGID and whether it is still
|
||||
// armed (will be released on drop) are what a failure message needs.
|
||||
impl std::fmt::Debug for AgidGuard<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AgidGuard")
|
||||
.field("agid", &self.agid)
|
||||
.field("armed", &self.armed)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AgidGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
if !self.armed {
|
||||
return;
|
||||
}
|
||||
// Buffer = the CDB's allocation length (AACS: 2), or 8 when it names none (CSS).
|
||||
let alloc = ((self.release[8] as usize) << 8) | self.release[9] as usize;
|
||||
let mut buf = vec![0u8; if alloc == 0 { 8 } else { alloc }];
|
||||
// Best-effort: a failed release is not a failure of the operation.
|
||||
let r = self.t.execute_cleanup(
|
||||
&self.release,
|
||||
DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
AGID_RELEASE_TIMEOUT_MS,
|
||||
);
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "agid_released",
|
||||
agid = self.agid,
|
||||
ok = r.as_ref().is_ok_and(|r| r.status == 0),
|
||||
"AGID release (REPORT KEY format 3Fh) sent"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> std::ops::Deref for AgidGuard<'a> {
|
||||
type Target = dyn ScsiTransport + 'a;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&*self.t
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> std::ops::DerefMut for AgidGuard<'a> {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut *self.t
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed SCSI sense (the diagnostic an unlocker reads off a failed command).
|
||||
@@ -66,6 +232,13 @@ impl ScsiSense {
|
||||
|
||||
/// SCSI status byte for a transport-layer failure (bridge crash / disconnect).
|
||||
pub(crate) const SCSI_STATUS_TRANSPORT_FAILURE: u8 = 0xFF;
|
||||
/// Whether a transport error is a genuine dead bus (a senseless transport-failure
|
||||
/// status) rather than a drive rejection surfaced through a non-conforming
|
||||
/// transport (`Err` carrying a sense).
|
||||
pub(crate) fn is_dead_bus(e: &ScsiError) -> bool {
|
||||
e.status == SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
|
||||
}
|
||||
|
||||
/// SCSI status byte CHECK CONDITION (a drive sense is available). Part of the
|
||||
/// status contract; currently referenced only by tests asserting the
|
||||
/// transport-vs-check-condition distinction.
|
||||
@@ -278,6 +451,171 @@ pub(crate) mod mock {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One transport call as seen by [`StopFake`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum Ev {
|
||||
/// `execute` reached the drive.
|
||||
Exec(Vec<u8>),
|
||||
/// `execute` refused because the operation was cancelled.
|
||||
Refused(Vec<u8>),
|
||||
/// `execute_cleanup` reached the drive.
|
||||
Cleanup(Vec<u8>),
|
||||
Pause(std::time::Duration),
|
||||
PauseRefused(std::time::Duration),
|
||||
Begin,
|
||||
BeginRefused,
|
||||
End,
|
||||
}
|
||||
|
||||
/// A cancellable host, as libfreemkv's adapter behaves (stop-design-v5 §2.3,
|
||||
/// §2.4): after a cancel, `execute` refuses with 0xFF / no sense unless a
|
||||
/// critical span opened BEFORE the cancel is still open; `begin_critical` and
|
||||
/// `pause` fail; `execute_cleanup` still runs. Wraps a drive emulator `inner`.
|
||||
pub(crate) struct StopFake<T> {
|
||||
pub(crate) inner: T,
|
||||
pub(crate) cancelled: bool,
|
||||
critical: u32,
|
||||
/// Cancel right after a CDB matching this predicate reaches the drive
|
||||
/// (a Stop that lands while that CDB is in flight).
|
||||
pub(crate) cancel_after: Option<fn(&[u8]) -> bool>,
|
||||
/// Cancel when the next `pause` starts (a Stop during the wait).
|
||||
pub(crate) cancel_on_pause: bool,
|
||||
pub(crate) log: Vec<Ev>,
|
||||
}
|
||||
|
||||
fn refused() -> ScsiError {
|
||||
ScsiError {
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> StopFake<T> {
|
||||
pub(crate) fn new(inner: T) -> Self {
|
||||
StopFake {
|
||||
inner,
|
||||
cancelled: false,
|
||||
critical: 0,
|
||||
cancel_after: None,
|
||||
cancel_on_pause: false,
|
||||
log: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// CDBs that reached the drive through `execute`.
|
||||
pub(crate) fn execs(&self) -> Vec<&Vec<u8>> {
|
||||
self.log
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Ev::Exec(c) => Some(c),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// CDBs that reached the drive through `execute_cleanup`.
|
||||
pub(crate) fn cleanups(&self) -> Vec<&Vec<u8>> {
|
||||
self.log
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Ev::Cleanup(c) => Some(c),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ScsiTransport> ScsiTransport for StopFake<T> {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
if self.cancelled && self.critical == 0 {
|
||||
self.log.push(Ev::Refused(cdb.to_vec()));
|
||||
return Err(refused());
|
||||
}
|
||||
self.log.push(Ev::Exec(cdb.to_vec()));
|
||||
let r = self.inner.execute(cdb, dir, data, timeout_ms);
|
||||
if self.cancel_after.is_some_and(|p| p(cdb)) {
|
||||
self.cancelled = true;
|
||||
}
|
||||
r
|
||||
}
|
||||
fn pause(&mut self, d: std::time::Duration) -> Result<()> {
|
||||
if self.cancel_on_pause {
|
||||
self.cancelled = true;
|
||||
}
|
||||
if self.cancelled {
|
||||
self.log.push(Ev::PauseRefused(d));
|
||||
return Err(refused());
|
||||
}
|
||||
self.log.push(Ev::Pause(d)); // no real sleep: tests run in microseconds
|
||||
Ok(())
|
||||
}
|
||||
fn begin_critical(&mut self) -> Result<()> {
|
||||
if self.cancelled {
|
||||
self.log.push(Ev::BeginRefused);
|
||||
return Err(refused());
|
||||
}
|
||||
self.critical += 1;
|
||||
self.log.push(Ev::Begin);
|
||||
Ok(())
|
||||
}
|
||||
fn end_critical(&mut self) {
|
||||
self.critical -= 1;
|
||||
self.log.push(Ev::End);
|
||||
}
|
||||
fn execute_cleanup(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
self.log.push(Ev::Cleanup(cdb.to_vec()));
|
||||
self.inner.execute(cdb, dir, data, timeout_ms)
|
||||
}
|
||||
}
|
||||
|
||||
/// REPORT KEY (0xA4) key format 0x3F (AGID invalidate / release).
|
||||
pub(crate) fn is_agid_release(cdb: &[u8]) -> bool {
|
||||
cdb.first() == Some(&SCSI_REPORT_KEY) && cdb.get(10).is_some_and(|b| b & 0x3F == 0x3F)
|
||||
}
|
||||
/// REPORT KEY (0xA4) key format 0x00 (AGID allocation).
|
||||
pub(crate) fn is_agid_alloc(cdb: &[u8]) -> bool {
|
||||
cdb.first() == Some(&SCSI_REPORT_KEY) && cdb.get(10).is_some_and(|b| b & 0x3F == 0x00)
|
||||
}
|
||||
|
||||
/// SS-7 (evidence): every allocated AGID is released exactly once, through
|
||||
/// `execute_cleanup`, before the next allocation; `execute` never releases a
|
||||
/// held AGID (it only invalidates beforehand). Returns (allocations, held at end).
|
||||
pub(crate) fn agid_ledger(log: &[Ev]) -> (usize, bool) {
|
||||
let (mut allocs, mut held) = (0usize, false);
|
||||
for (i, e) in log.iter().enumerate() {
|
||||
match e {
|
||||
Ev::Exec(c) if is_agid_alloc(c) => {
|
||||
assert!(!held, "event {i}: allocated while still holding an AGID");
|
||||
held = true;
|
||||
allocs += 1;
|
||||
}
|
||||
Ev::Exec(c) if is_agid_release(c) => {
|
||||
assert!(!held, "event {i}: a held AGID was released via execute");
|
||||
}
|
||||
Ev::Cleanup(c) => {
|
||||
assert!(
|
||||
is_agid_release(c),
|
||||
"event {i}: cleanup of a non-release CDB"
|
||||
);
|
||||
assert!(held, "event {i}: released an AGID that was not held");
|
||||
held = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(allocs, held)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -356,4 +694,72 @@ mod tests {
|
||||
|
||||
assert_eq!(t.calls(), 4);
|
||||
}
|
||||
|
||||
// UT1 (stop-design-v5 §5.2; §2.3 "`enter` calls `begin_critical()?`. `Drop` calls
|
||||
// `end_critical`"): refused after a cancel; closed on drop AND on unwind.
|
||||
#[test]
|
||||
fn critical_guard_refused_after_cancel_and_ends_on_unwind() {
|
||||
use mock::{Ev, StopFake};
|
||||
let mut t = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 4])));
|
||||
{
|
||||
let mut g = CriticalGuard::enter(&mut t).expect("not cancelled: opens");
|
||||
g.execute(&[0x3B], DataDirection::ToDevice, &mut [0u8; 4], 0)
|
||||
.expect("inside the span");
|
||||
}
|
||||
assert_eq!(t.log[0], Ev::Begin);
|
||||
assert_eq!(t.log.last(), Some(&Ev::End), "closed on drop");
|
||||
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _g = CriticalGuard::enter(&mut t).expect("opens");
|
||||
panic!("unwind inside the span");
|
||||
}));
|
||||
assert!(r.is_err());
|
||||
assert_eq!(t.log.last(), Some(&Ev::End), "closed on unwind");
|
||||
|
||||
t.cancelled = true;
|
||||
let e = CriticalGuard::enter(&mut t)
|
||||
.err()
|
||||
.expect("refused after a cancel");
|
||||
assert!(is_dead_bus(&e), "a refusal is the dead-bus shape");
|
||||
assert_eq!(t.log.last(), Some(&Ev::BeginRefused), "no span, so no End");
|
||||
let begins = t.log.iter().filter(|e| **e == Ev::Begin).count();
|
||||
let ends = t.log.iter().filter(|e| **e == Ev::End).count();
|
||||
assert_eq!(begins, ends, "every opened span closed exactly once");
|
||||
}
|
||||
|
||||
// The defaults keep all 32 existing transports compiling and behaving as
|
||||
// before: pause sleeps, spans are no-ops, cleanup is a plain execute.
|
||||
#[test]
|
||||
fn default_stop_methods_preserve_plain_transport_behaviour() {
|
||||
let mut t = MockTransport::always(Reply::good(vec![0u8; 2]));
|
||||
let t0 = std::time::Instant::now();
|
||||
t.pause(std::time::Duration::from_millis(20))
|
||||
.expect("plain sleep");
|
||||
assert!(t0.elapsed() >= std::time::Duration::from_millis(20));
|
||||
t.begin_critical().expect("no-op");
|
||||
t.end_critical();
|
||||
t.execute_cleanup(&[0xA4], DataDirection::FromDevice, &mut [0u8; 2], 0)
|
||||
.expect("plain execute");
|
||||
assert_eq!(t.cdbs, vec![vec![0xA4]], "cleanup reached the drive once");
|
||||
}
|
||||
|
||||
// SS-7 (evidence): the guard's drop sends its REPORT KEY 3Fh CDB once, via
|
||||
// execute_cleanup, even after a cancel; `defuse` sends nothing.
|
||||
#[test]
|
||||
fn agid_guard_releases_once_via_cleanup_and_defuse_keeps_it() {
|
||||
use mock::{Ev, StopFake};
|
||||
let mut rel = [0u8; 12];
|
||||
rel[0] = SCSI_REPORT_KEY;
|
||||
rel[10] = (2 << 6) | 0x3F;
|
||||
let mut t = StopFake::new(MockTransport::always(Reply::good(vec![0u8; 8])));
|
||||
t.cancelled = true;
|
||||
drop(AgidGuard::new(&mut t, 2, rel));
|
||||
assert_eq!(t.log, vec![Ev::Cleanup(rel.to_vec())]);
|
||||
|
||||
t.log.clear();
|
||||
let g = AgidGuard::new(&mut t, 2, rel);
|
||||
assert_eq!(g.agid(), 2);
|
||||
assert_eq!(g.defuse(), 2);
|
||||
assert!(t.log.is_empty(), "defuse hands the AGID over unreleased");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-7
@@ -11,7 +11,7 @@
|
||||
//! supply the key).
|
||||
|
||||
use crate::UnlockError;
|
||||
use crate::scsi::{DataDirection, ScsiTransport};
|
||||
use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus};
|
||||
|
||||
/// The 16-byte AACS Volume ID.
|
||||
const VID_LEN: usize = 16;
|
||||
@@ -35,12 +35,6 @@ pub(crate) fn build_vid_cdb() -> [u8; 12] {
|
||||
cdb
|
||||
}
|
||||
|
||||
/// A senseless transport-failure status is a genuine dead bus, not a drive
|
||||
/// rejection surfaced through a non-conforming transport (`Err` with a sense).
|
||||
fn is_dead_bus(e: &crate::scsi::ScsiError) -> bool {
|
||||
e.status == crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE && e.sense.is_none()
|
||||
}
|
||||
|
||||
/// Read the AACS VID with the bare `0xAD` fmt `0x80` (valid only after the drive
|
||||
/// is unlocked). `Ok(Some(vid))` on a well-formed non-zero VID; `Ok(None)` for
|
||||
/// any "no VID" outcome (rejected / short / all-zero); `Err(Transport)` only on
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Op-path sleep guard (Stop design v5 §5.8): "Ban `std::thread::sleep` on op
|
||||
//! paths: unlock `src/{aacs,css,ld}` ... Use `Halt::wait` / `pause` instead."
|
||||
//!
|
||||
//! Every production (non-`#[cfg(test)]`) line under `src/aacs`, `src/css` and
|
||||
//! `src/ld` is scanned for the substring `thread::sleep(` (catching both
|
||||
//! `std::thread::sleep(` and an already-imported `thread::sleep(`). A raw sleep
|
||||
//! on these op paths can't be cancelled; [`ScsiTransport::pause`] (which a
|
||||
//! cancellable host wires to `Halt::wait`) is the sanctioned replacement.
|
||||
//! `clippy.toml`'s `disallowed-methods` can't scope to a subdirectory, hence
|
||||
//! this grep test instead of a lint config entry.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The banned substring: matches `std::thread::sleep(` and `thread::sleep(`.
|
||||
const BANNED: &str = "thread::sleep(";
|
||||
|
||||
/// Every `.rs` file under `dir`, recursively.
|
||||
fn rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
rs_files(&p, out);
|
||||
} else if p.extension().is_some_and(|x| x == "rs") {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 1-based line numbers of every `thread::sleep(` call in `src` that is NOT
|
||||
/// inside a `#[cfg(test)]` item or block. Tracks brace depth: a `#[cfg(test)]`
|
||||
/// attribute starts a "pending" item; if that item opens a `{`, everything
|
||||
/// until the matching `}` is skipped; if it's a single-line item ending in
|
||||
/// `,`/`;` with no `{` (e.g. a struct field), only that line is skipped.
|
||||
fn op_path_hits(src: &str) -> Vec<usize> {
|
||||
let mut hits = Vec::new();
|
||||
let mut depth: i32 = 0;
|
||||
let mut paren_depth: i32 = 0;
|
||||
let mut pending_cfg_test = false;
|
||||
let mut skip_until_depth: Option<i32> = None;
|
||||
|
||||
for (idx, line) in src.lines().enumerate() {
|
||||
let lineno = idx + 1;
|
||||
let trimmed = line.trim();
|
||||
let opens = line.matches('{').count() as i32;
|
||||
let closes = line.matches('}').count() as i32;
|
||||
|
||||
if let Some(d) = skip_until_depth {
|
||||
depth += opens - closes;
|
||||
if depth <= d {
|
||||
skip_until_depth = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed.starts_with("#[cfg(test)]") {
|
||||
pending_cfg_test = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if pending_cfg_test {
|
||||
paren_depth += line.matches('(').count() as i32 - line.matches(')').count() as i32;
|
||||
if opens > 0 {
|
||||
let before = depth;
|
||||
depth += opens - closes;
|
||||
skip_until_depth = Some(before);
|
||||
} else if paren_depth <= 0 && (trimmed.ends_with(',') || trimmed.ends_with(';')) {
|
||||
pending_cfg_test = false;
|
||||
}
|
||||
// Multi-line signature before `{` (or an open param list): stay
|
||||
// pending, keep scanning.
|
||||
continue;
|
||||
}
|
||||
|
||||
depth += opens - closes;
|
||||
if line.contains(BANNED) {
|
||||
hits.push(lineno);
|
||||
}
|
||||
}
|
||||
hits
|
||||
}
|
||||
|
||||
/// Guard: no raw `thread::sleep(` in production code under `src/aacs`,
|
||||
/// `src/css` or `src/ld` (Stop design v5 §5.8). Use `pause` / `Halt::wait`.
|
||||
#[test]
|
||||
fn no_raw_sleep_on_unlock_op_paths() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let mut files = Vec::new();
|
||||
for sub in ["src/aacs", "src/css", "src/ld"] {
|
||||
rs_files(&root.join(sub), &mut files);
|
||||
}
|
||||
assert!(
|
||||
files.len() >= 3,
|
||||
"the scan must reach real files (found {})",
|
||||
files.len()
|
||||
);
|
||||
|
||||
let mut hits = Vec::new();
|
||||
for f in &files {
|
||||
let src = std::fs::read_to_string(f).expect("read source");
|
||||
for line in op_path_hits(&src) {
|
||||
hits.push(format!(
|
||||
"{}:{line}",
|
||||
f.strip_prefix(root).unwrap().display()
|
||||
));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
hits.is_empty(),
|
||||
"raw thread::sleep on an unlock op path (use pause / Halt::wait instead):\n{}",
|
||||
hits.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// Self-test: the guard skips `#[cfg(test)]` items/blocks and catches a
|
||||
/// production-code sleep.
|
||||
#[test]
|
||||
fn the_guard_skips_cfg_test_and_catches_production_sleeps() {
|
||||
let clean = [
|
||||
// A `mod tests { ... }` block, sleep inside it ignored.
|
||||
"fn f() {}\n#[cfg(test)]\nmod tests {\n fn g() {\n std::thread::sleep(D);\n }\n}\n",
|
||||
// A single test-only fn (one-liner body).
|
||||
"#[cfg(test)]\nfn helper() { std::thread::sleep(D); }\n",
|
||||
// A cfg(test)-gated struct field (single line, ends in `,`).
|
||||
"struct S {\n #[cfg(test)]\n x: Option<u8>,\n}\n",
|
||||
// A cfg(test)-gated statement inside a normal (production) fn.
|
||||
"fn real() -> X {\n #[cfg(test)]\n if let Some(m) = HOOK.get() {\n thread::sleep(D);\n return m();\n }\n Y\n}\n",
|
||||
// A multi-line test-only fn signature before the brace.
|
||||
"#[cfg(test)]\nfn helper(\n a: u8,\n) -> Bar {\n thread::sleep(D);\n Bar\n}\n",
|
||||
];
|
||||
for s in &clean {
|
||||
assert!(op_path_hits(s).is_empty(), "false positive: {s}");
|
||||
}
|
||||
|
||||
let dirty = [
|
||||
"fn real() {\n std::thread::sleep(D);\n}\n",
|
||||
"fn real() {\n thread::sleep(D);\n}\n",
|
||||
];
|
||||
for s in &dirty {
|
||||
assert_eq!(op_path_hits(s), vec![2], "missed hit: {s}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user