mirror of
https://github.com/freemkv/freemkv-unlock.git
synced 2026-10-07 18:13:11 +00:00
firmware: gate recipes and freemkv unlock on a supported IDENTITY
Firmware recipes and the freemkv unlock run only for a supported IDENTITY, with an accurate fw 0.7/0.8 rationale and reply shape. css and renesas share is_dead_bus; no P-256 retry is claimed for 0x11 drive certs; handshake diagnostics log below WARN; tests pin the error variants, the v2 cert gate and the 132-byte v2 boundary. CI: doc-links, doctests, a consumers job, read-only token; rand 0.10.3.
This commit is contained in:
@@ -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
|
||||
@@ -111,6 +115,34 @@ 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. libfreemkv resolves ../freemkv-unlock by
|
||||
# layout; bdemu (emulation feature) also needs libfreemkv patched to the sibling.
|
||||
consumers:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
path: freemkv-unlock
|
||||
- uses: freemkv/.github/.github/actions/checkout-siblings@v1
|
||||
with:
|
||||
checkout: libfreemkv bdemu
|
||||
patch: libfreemkv
|
||||
self: bdemu
|
||||
# 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
+43
-9
@@ -1080,20 +1080,20 @@ fn aacs_authenticate_with_agid(
|
||||
drive_nonce.copy_from_slice(&response[4..24]);
|
||||
drive_cert.copy_from_slice(&response[24..116]);
|
||||
|
||||
// Verify drive cert against the LA anchor. Only type-0x01 (1.0) is verifiable
|
||||
// here; accepting type-0x11 (2.0) would skip this and the step-6 verify yet run
|
||||
// ECDH (bus-key hole), so reject it — run_cert_handshake routes 0x11 to P-256.
|
||||
// Only a type-0x01 (1.0) cert is verifiable here; accepting 0x11 would skip this and
|
||||
// the step-6 verify yet run ECDH (bus-key hole). 0x11 gets P-256 only when enabled
|
||||
// (AACS2_P256_EXPERIMENTAL, off in production); otherwise the handshake fails.
|
||||
if drive_cert[0] == 0x01 {
|
||||
if !verify_cert_with_anchor(&drive_cert, la_x, la_y) {
|
||||
return Err(V1Fail::DriveCert);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "aacs_cert_unsupported_type",
|
||||
cert_type = drive_cert[0],
|
||||
"drive certificate is not a verifiable AACS 1.0 (type 0x01) cert on the \
|
||||
1.0 path; rejecting (a genuine 0x11 drive is retried on the native P-256 path)"
|
||||
1.0 path; rejecting"
|
||||
);
|
||||
// 0x11 is a real 2.0 drive; any other type (e.g. a zeroed cert) is unproven.
|
||||
return Err(if drive_cert[0] == 0x11 {
|
||||
@@ -1260,7 +1260,7 @@ fn aacs2_authenticate_p256_with_agid(
|
||||
// Chain-of-trust gate, mandatory on this live path: (a) reject any non-0x11 cert type
|
||||
// outright, (b) treat cert verify failure as FATAL, not logged-and-continued.
|
||||
if drive_cert[0] != 0x11 {
|
||||
tracing::warn!(
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "aacs2_cert_unknown_type",
|
||||
cert_type = drive_cert[0],
|
||||
@@ -1269,7 +1269,7 @@ fn aacs2_authenticate_p256_with_agid(
|
||||
return Err(Error::AacsCertVerify);
|
||||
}
|
||||
if !verify_cert_p256(drive_cert, la_x, la_y) {
|
||||
tracing::warn!(
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "aacs2_cert_verify_failed",
|
||||
"AACS 2.0 drive certificate failed P-256 LA verification; rejecting"
|
||||
@@ -1497,7 +1497,7 @@ fn finish_auth(
|
||||
Err(e) => {
|
||||
let transport = e.is_scsi_transport_failure();
|
||||
let vid_mac = matches!(e, Error::AacsVidMac);
|
||||
tracing::warn!(
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_vid_read_failed",
|
||||
cert_index = idx,
|
||||
@@ -1749,7 +1749,7 @@ pub(crate) fn run_cert_handshake_with_anchors(
|
||||
match attempt_one_cert(scsi, hc, idx, la, v1_ok && !v1_drive_dead, has_v2) {
|
||||
CertOutcome::Ok(ch) => return Ok(ch),
|
||||
CertOutcome::Transport => {
|
||||
tracing::warn!(
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_transport_fault",
|
||||
cert_index = idx,
|
||||
@@ -4769,6 +4769,40 @@ pub(crate) mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// With P-256 enabled, a v2 cert shorter than 132 bytes (131 here) is not
|
||||
/// usable v2 creds: with a dead v1 pairing it is skipped up front, burns no
|
||||
/// wedge-guard attempt, and a later valid 132-byte cert is still tried.
|
||||
#[test]
|
||||
fn short_v2_cert_is_skipped_up_front_under_p256() {
|
||||
let short_v2 = || {
|
||||
let mut hc = with_v2_creds(mispaired_host_cert());
|
||||
hc.certificate_v2.as_mut().expect("v2 cert").truncate(131);
|
||||
hc
|
||||
};
|
||||
let mut drive = HybridDrive::new();
|
||||
let (l1x, l1y) = (drive.la1_x, drive.la1_y);
|
||||
let (l2x, l2y) = (drive.la2_x, drive.la2_y);
|
||||
let mut certs = vec![short_v2(), short_v2(), short_v2()];
|
||||
certs.push(with_v2_creds(mispaired_host_cert()));
|
||||
assert_eq!(certs[3].certificate_v2.as_ref().map(Vec::len), Some(132));
|
||||
let ch =
|
||||
run_cert_handshake_with_anchors(&mut drive, &certs, (&l1x, &l1y), (&l2x, &l2y), true)
|
||||
.expect("the 132-byte 4th cert is tried and completes P-256");
|
||||
assert_eq!(ch.volume_id, [0x5Au8; 16]);
|
||||
|
||||
let mut t = MockTransport::always(Reply::illegal_request());
|
||||
let err = run_cert_handshake_with_anchors(
|
||||
&mut t,
|
||||
&[short_v2()],
|
||||
(&l1x, &l1y),
|
||||
(&l2x, &l2y),
|
||||
true,
|
||||
)
|
||||
.expect_err("a short v2 cert cannot authenticate");
|
||||
assert_eq!(err, crate::UnlockError::NoUsableHostCert);
|
||||
assert_eq!(t.calls(), 0);
|
||||
}
|
||||
|
||||
/// A non-Bus-Encryption-Capable drive (drive cert byte 1 bit 0 clear) is
|
||||
/// never asked for the read data key: `read_data_key` is None and, since
|
||||
/// nothing failed, `read_data_key_err` is None too (libaacs parity).
|
||||
|
||||
+255
-92
@@ -93,56 +93,58 @@ 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(()),
|
||||
Ok(recipe.disables_encryption())
|
||||
}
|
||||
Err(FirmwareError::Transport) => Err(UnlockError::Transport),
|
||||
Err(e) => {
|
||||
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,
|
||||
"firmware refused the arm recipe; proceeding with the plain cert AKE"
|
||||
"arm recipe refused (earlier SETs stay applied); plain cert AKE"
|
||||
);
|
||||
Ok(())
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => Ok(()), // not freemkv — leave the drive untouched
|
||||
Err(FirmwareError::Transport) => Err(UnlockError::Transport),
|
||||
Err(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Unlocker for AacsUnlocker {
|
||||
@@ -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,115 @@ 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,
|
||||
/// 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 ScsiTransport for FwDrive {
|
||||
|
||||
impl<T> FwFront<T> {
|
||||
fn new(inner: T) -> Self {
|
||||
FwFront {
|
||||
inner,
|
||||
version: "0.9.2",
|
||||
flags: [crate::firmware::STATE_PASSTHROUGH; 7],
|
||||
refuse_sets: false,
|
||||
dead_after_identity: false,
|
||||
cdbs: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// Whether any cert-AKE command (REPORT KEY / SEND KEY) was issued.
|
||||
fn ran_cert_ake(&self) -> bool {
|
||||
self.cdbs
|
||||
.iter()
|
||||
.any(|c| c[0] == crate::scsi::SCSI_REPORT_KEY || c[0] == crate::scsi::SCSI_SEND_KEY)
|
||||
}
|
||||
fn sent_a_set(&self) -> bool {
|
||||
use crate::firmware::{CDB_VERB, READ_BUFFER_OPCODE, Verb};
|
||||
self.cdbs
|
||||
.iter()
|
||||
.any(|c| c[0] == READ_BUFFER_OPCODE && c[CDB_VERB] == Verb::Set as u8)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ScsiTransport> ScsiTransport for FwFront<T> {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_dir: crate::scsi::DataDirection,
|
||||
dir: crate::scsi::DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
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());
|
||||
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;
|
||||
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 mut t = FwDrive {
|
||||
encryption: STATE_PASSTHROUGH,
|
||||
hrl: STATE_PASSTHROUGH,
|
||||
cdbs: Vec::new(),
|
||||
};
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
u.maybe_arm(&mut t).expect("armed");
|
||||
assert_eq!(t.hrl, STATE_OFF, "the recipe skipped revocation (Hrl=off)");
|
||||
assert_eq!(
|
||||
t.encryption, STATE_OFF,
|
||||
"the recipe disabled encryption (Encryption=off)"
|
||||
);
|
||||
// IDENTITY, then Set(Hrl,off)+verify, then Set(Encryption,off)+verify.
|
||||
assert!(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 +474,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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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;
|
||||
|
||||
+274
-61
@@ -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,22 +312,24 @@ 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();
|
||||
}
|
||||
}
|
||||
v if v == Verb::Get as u8 => {
|
||||
let f = Self::feature_of(cdb[CDB_FEATURE]);
|
||||
if !data.is_empty() {
|
||||
@@ -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);
|
||||
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);
|
||||
}
|
||||
|
||||
+173
-196
@@ -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());
|
||||
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);
|
||||
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];
|
||||
for reject in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
|
||||
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::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 id = crate::DriveId::default();
|
||||
let out = FreemkvUnlocker::new()
|
||||
.unlock(&mut t, &ctx(&id))
|
||||
.expect("no fault")
|
||||
.expect("unlocked");
|
||||
assert_eq!(out.vid, Some(vid));
|
||||
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() {
|
||||
for reject in [Reply::illegal_request(), Reply::illegal_request_as_err()] {
|
||||
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::good(freemkv_identity_payload()),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]),
|
||||
Reply::good(vec![]),
|
||||
reject.clone(), // Set(Encryption,off) REJECTED
|
||||
],
|
||||
Reply::TransportFault,
|
||||
);
|
||||
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(), "{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(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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+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]
|
||||
|
||||
@@ -66,6 +66,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.
|
||||
|
||||
+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
|
||||
|
||||
Reference in New Issue
Block a user