Files
freemkv-unlock/src/firmware/mod.rs
T
Matthew Jackson 5c4a6c4094 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.
2026-09-27 17:50:56 -07:00

867 lines
38 KiB
Rust

//! Typed mirror of the freemkv firmware vendor-command ABI.
//!
//! Numeric values must match `freemkv-firmware/crates/freemkv-fw/src/abi.rs`.
//! Tests pin the verbs, features, state bytes and CDB offsets.
//!
//! ```text
//! cdb[0] = 0x3C (READ BUFFER) ← standard opcode; bridge-safe
//! cdb[1] = 0x0E (KNOCK_MODE) ← OEM's jump table rejects modes >= 0x0E
//! cdb[2..4] = 0xC0 0xDE (KNOCK) ← defence-in-depth signature
//! cdb[4] = Verb ← IDENTITY / SET / GET / RESET / DUMPALL
//! cdb[5] = Feature id ← SET/GET only (else 0)
//! cdb[6] = State byte ← SET only (else 0)
//! cdb[7..9] = allocation length (16-bit big-endian) ← data-returning verbs
//! cdb[9] = control (0)
//! ```
//!
//! DumpAll instead encodes a 32-bit address in `cdb[5..9]` and returns MEMREAD_LEN bytes.
//! Features default to passthrough. Reset selects OEM, built-in defaults or saved flash state.
use crate::scsi::{DataDirection, ScsiTransport, is_dead_bus};
// ── Wire constants (mirror of abi.rs) ────────────────────────────────────────
/// Standard SCSI `READ BUFFER` opcode — the command freemkv hijacks (`cdb[0]`).
pub const READ_BUFFER_OPCODE: u8 = 0x3C;
/// The freemkv sub-command mode at `cdb[1]`; OEM rejects modes `>= 0x0E`.
pub const KNOCK_MODE: u8 = 0x0E;
/// Two-byte knock at `cdb[2..4]` ("C0DE") — the *safe* knock carried by every
/// durable verb (Identity/Set/Get/Reset/DumpAll/Save/FlashWrite).
pub const KNOCK: [u8; 2] = [0xC0, 0xDE];
/// Two-byte knock at `cdb[2..4]` — the *debug* knock, distinct from [`KNOCK`]
/// by construction. The only frame the fw honours for the debug-only verbs
/// ([`Verb::Call`], [`Verb::Poke`], [`Verb::Reboot`]).
pub const DEBUG_KNOCK: [u8; 2] = [0xDE, 0xB9];
/// Response-framing magic leading the [`Verb::Identity`] reply (`b"freemkv"`).
pub const RESP_MAGIC: &[u8] = b"freemkv";
/// Oldest firmware `(major, minor)` this mirror arms: 0.9 made wire id `0x06`
/// the consolidated `Encryption` lever. Released 0.8.x (`0x06` = `Ake`, separate
/// `0x07 Bus`) de-busses via `0x06` alone only as a hardware-dependent side
/// effect (not validated), and 0.7.x uses a sub-function grammar: both refused.
pub const MIN_FW_VERSION: (u32, u32) = (0, 9);
/// Length of a freemkv (READ BUFFER) CDB.
pub const CDB_LEN: usize = 10;
/// Offset of the opcode byte (`cdb[0]`).
pub const CDB_OPCODE: usize = 0;
/// Offset of the mode/knock byte (`cdb[1]`).
pub const CDB_MODE: usize = 1;
/// Offset of the first knock byte (`cdb[2]`, `cdb[3]`).
pub const CDB_KNOCK: usize = 2;
/// Offset of the verb byte (`cdb[4]`).
pub const CDB_VERB: usize = 4;
/// Offset of the feature-id byte (`cdb[5]`) — SET/GET only.
pub const CDB_FEATURE: usize = 5;
/// Offset of the state byte (`cdb[6]`) — SET only.
pub const CDB_STATE: usize = 6;
/// Offset of the 16-bit big-endian allocation length (`cdb[7..9]`).
pub const CDB_ALLOC_LEN: usize = 7;
/// Bytes returned by one [`Verb::DumpAll`] read (fixed 64-byte window).
pub const MEMREAD_LEN: usize = 64;
/// Minimum data-in allocation length any freemkv vendor command may request.
///
/// **Hardware-confirmed (LG BU40N on freemkv firmware):** the drive's `READ
/// BUFFER` hijack ABORTS (Check Condition, sense key Aborted Command) any vendor
/// command whose data-in allocation length is under ~16 bytes (0/1/2 all abort;
/// 16 and 64 both succeed). Every builder floors its allocation at `64` — it
/// matches [`MEMREAD_LEN`] and is safely above the minimum. The verb/feature/
/// state ride in the CDB, so a larger data-in is harmless; a [`Verb::Get`] still
/// reads its state byte from data offset 0. Mirrors firmware ABI `MIN_ALLOC_LEN`.
pub const MIN_ALLOC_LEN: u16 = 64;
/// Feature state: **passthrough** — firmware does not touch this subsystem
/// (OEM behaviour). The boot default of every feature and what [`Verb::Reset`]
/// restores. `0xFF`.
pub const STATE_PASSTHROUGH: u8 = 0xFF;
/// Feature state: explicit **off / disabled** (`0x00`).
pub const STATE_OFF: u8 = 0x00;
/// Feature state: explicit **on / enabled** (`0x01`).
pub const STATE_ON: u8 = 0x01;
/// [`Feature::Speed`] state: speed control OFF — the read-speed cap is lifted and
/// the drive runs uncapped at maximum throughput. This is the [`STATE_OFF`]
/// (`0x00`) leg of Speed: "off" is the *limiter* being off, i.e. maximum speed.
/// `0x01..=0xFE` are explicit caps and `0xFF` is the OEM ramp.
pub const SPEED_MAX: u8 = 0x00;
/// [`Feature::Region`] state: force BD region A (`0x0A`). See [`BdRegion`]. BD
/// regions A/B/C share the low-nibble block with the DVD `0x0N` scheme
/// ([`REGION_DVD_BASE`]) and [`REGION_FREE`] (`0x0F`).
pub const REGION_BD_A: u8 = 0x0A;
/// [`Feature::Region`] state: force BD region B (`0x0B`).
pub const REGION_BD_B: u8 = 0x0B;
/// [`Feature::Region`] state: force BD region C (`0x0C`).
pub const REGION_BD_C: u8 = 0x0C;
/// [`Feature::Region`] state base for "force DVD region N": `REGION_DVD_BASE + N`,
/// so regions 1..=8 are `0x01..=0x08`. `0x00` itself is region-locked (nothing
/// plays).
pub const REGION_DVD_BASE: u8 = 0x00;
/// [`Feature::Region`] state: region-free — any disc plays regardless of its
/// region code. Sits in the region low-nibble block above the DVD (`0x01..=0x08`)
/// and BD (`0x0A..=0x0C`) values.
pub const REGION_FREE: u8 = 0x0F;
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): reload the saved flash
/// config block into the RAM feature-state table, discarding un-saved RAM changes
/// — the non-destructive "revert to last SAVE". Marker-gated in firmware ≥0.8.3: a
/// never-saved drive loads the baked create-time defaults instead.
pub const RESET_TO_FLASH: u8 = 0x00;
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): restore the baked
/// per-create **defaults** (the create-time flags, e.g. UHD/BD on) into the RAM
/// feature-state table. RAM-only — does not touch flash. This is what a never-saved
/// drive boots to. Firmware ≥0.8.3.
pub const RESET_TO_DEFAULTS: u8 = 0x01;
/// [`Verb::Reset`] mode (rides in the state slot `cdb[6]`): TRUE OEM. Forces every
/// feature to [`STATE_PASSTHROUGH`] in RAM **and** (firmware ≥0.8.3) writes the NV
/// config block all-`0xFF` — marker included — so the drive is byte-for-byte like a
/// never-saved/never-configured drive (no trace it was ever set). Because the marker
/// is then `0xFF`, the NEXT boot loads the baked defaults, exactly like a fresh drive.
pub const RESET_TO_OEM: u8 = 0xFF;
/// The verb selector in `cdb[4]`. These numeric values ARE the wire protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Verb {
/// Status/ping — returns `freemkv <version>` (no NUL), then the raw
/// feature-flag bytes `flag[0x01..=0x06]`, zero-padded to the window.
Identity = 0x01,
/// Set one feature (`cdb[5]`) to a state (`cdb[6]`).
Set = 0x02,
/// Read one feature's (`cdb[5]`) current state back in the data-in payload.
Get = 0x03,
/// Restore the RAM feature-state table. The mode rides in `cdb[6]` (the state
/// slot): [`RESET_TO_FLASH`] (`0x00`) reloads the saved flash config block back
/// into RAM (discarding un-saved changes; marker-gated in fw ≥0.8.3),
/// [`RESET_TO_DEFAULTS`] (`0x01`) restores the baked create-time defaults, and
/// [`RESET_TO_OEM`] (`0xFF`) forces every feature to [`STATE_PASSTHROUGH`] AND
/// blanks the NV block (traceless, fw ≥0.8.3). Ignores feature.
Reset = 0x04,
/// Diagnostic RAM peek: [`MEMREAD_LEN`] bytes at the 32-bit address in
/// `cdb[5..9]` (big-endian).
DumpAll = 0x09,
/// **TEMPORARY** diagnostic probe: program a single byte to an allowlisted
/// flash offset via the OEM PROGRAM routine. `CDB[5..9]` = 32-bit flash offset
/// (big-endian); `CDB[9]` = value byte. The firmware refuses any offset outside
/// the compile-time allowlist window (`0x1EA000..0x1EB000` — the corpus-proven-
/// unlocked NV/SAVE block) with a distinct `"REFU"` status word (PROGRAM not
/// called). Reply: 4-byte offset echo (BE) + 4-byte PROGRAM/REFU status (BE).
/// Not part of the durable feature grammar; subsumed by [`Verb::Save`].
FlashWrite = 0x0A,
/// Persist the whole RAM feature-state table to the flash config block. This is
/// the ONLY verb that writes the config to flash: [`Verb::Set`] and
/// [`Verb::Reset`] touch RAM only, so a host's changes stay volatile until a
/// `Save` commits them (and survive a power cycle only once saved). Ignores
/// feature/state.
Save = 0x0B,
/// **Debug-knock only.** Interactive fw-exploration `blx target` primitive.
/// `CDB[5..9]` = 32-bit target VA (big-endian); `CDB[9]` = single u8 that
/// is loaded into r0 as the sole register argument. Refused under the safe
/// [`KNOCK`]; only executed under [`DEBUG_KNOCK`]. See [`build_call_cdb`].
Call = 0x0C,
/// **Debug-knock only.** Poke a single byte to an arbitrary address.
/// `CDB[5..9]` = 32-bit target address (big-endian, RAM or MMIO);
/// `CDB[9]` = byte value to store. Refused under the safe [`KNOCK`]; only
/// executed under [`DEBUG_KNOCK`]. See [`build_poke_cdb`].
Poke = 0x0D,
/// **Debug-knock only.** Invokes the firmware's boot function entry with
/// r0=4 to force the cold path (BSS clear + C-runtime data init + full
/// post-init), recovering a wedged drive without a power cycle. The target
/// briefly returns Aborted Command mid-reboot, then comes back ~5s later
/// with the safe-knock verb chain re-armed to its power-on defaults. The
/// target VA is baked into the handler at build time, so no CDB arguments
/// are carried beyond the verb byte. Refused under the safe [`KNOCK`];
/// only executed under [`DEBUG_KNOCK`]. See [`build_reboot_cdb`].
Reboot = 0x0F,
}
/// The feature selector in `cdb[5]` for [`Verb::Set`] / [`Verb::Get`]. These
/// numeric values ARE the wire protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Feature {
/// Read-speed / riplock ceiling. [`STATE_PASSTHROUGH`] (`0xFF`) = OEM ramp;
/// [`STATE_OFF`] (`0x00`) = speed control off, i.e. the cap is lifted and the
/// drive runs uncapped at maximum throughput (see [`SPEED_MAX`]); `0x01..=0xFE`
/// = an explicit speed cap (the byte IS the cap, lower is slower). Note the OFF
/// direction: "off" means the *limiter* is off, so OFF is the fastest state.
Speed = 0x01,
/// Region (RPC) control. [`STATE_PASSTHROUGH`] (`0xFF`) = drive's own region
/// logic; [`STATE_OFF`] (`0x00`) = region-locked (nothing plays);
/// [`REGION_DVD_BASE`]` + N` (`0x01..=0x08`) = force DVD region 1..8;
/// [`REGION_BD_A`]/`_B`/`_C` (`0x0A`/`0x0B`/`0x0C`) = force BD region A/B/C;
/// [`REGION_FREE`] (`0x0F`) = region-free (any disc plays).
Region = 0x02,
/// Unrestricted (BD/UHD) capability widen gate. [`STATE_PASSTHROUGH`]
/// (`0xFF`) = OEM; [`STATE_OFF`] (`0x00`) = No (currently no-op on
/// byte-extraction classifiers like BU40N, pending RE); [`STATE_ON`] (`0x01`)
/// = Yes. fw 0.9.2 folds BD into this gate by name only: its BD-refuse detour
/// still reads wire id `0x04` (`Bd`, deprecated), which this mirror does not
/// expose, so on 0.9.2 this lever governs UHD acceptance.
Unrestricted = 0x03,
/// Host Revocation List handling on the cert path. [`STATE_PASSTHROUGH`]
/// (`0xFF`) = OEM enforce; [`STATE_OFF`] (`0x00`) = off (skip the HRL lookup —
/// revoked certs accepted, non-destructive, the unlock direction); [`STATE_ON`]
/// (`0x01`) = on (enforce the HRL).
Hrl = 0x05,
/// The one master encryption/cert bypass. [`STATE_OFF`] (`0x00`) = every
/// drive-side encryption/cert requirement gone (cert-AKE relaxed, bus-wrap
/// off — what used to need a cert now doesn't). [`STATE_PASSTHROUGH`] (`0xFF`)
/// = OEM behavior (real handshake, bus-encrypted content). [`STATE_ON`]
/// (`0x01`) = on (require the real handshake). Wire id `0x07` is retired
/// (was `Bus`, empirically proved inert as a separate datapath lever on
/// BU40N/MT1959 — the single Encryption lever de-busses on its own).
Encryption = 0x06,
}
/// Every exposed feature, in wire-id order (the order [`FirmwareControl::states`]
/// probes). The IDENTITY flag table also carries the unexposed `0x04` slot.
pub const ALL_FEATURES: [Feature; 5] = [
Feature::Speed,
Feature::Region,
Feature::Unrestricted,
Feature::Hrl,
Feature::Encryption,
];
/// A Blu-ray region for [`FirmwareControl::force_region_bd`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BdRegion {
/// Region A (`0x0A`).
A,
/// Region B (`0x0B`).
B,
/// Region C (`0x0C`).
C,
}
impl BdRegion {
/// The `Feature::Region` state byte that forces this BD region.
pub fn state(self) -> u8 {
match self {
BdRegion::A => REGION_BD_A,
BdRegion::B => REGION_BD_B,
BdRegion::C => REGION_BD_C,
}
}
}
// ── CDB builders (mirror of abi.rs) ──────────────────────────────────────────
/// Build a 10-byte host CDB for a verb over the `3C 0E C0 DE …` frame.
///
/// `feature`/`state` land at `cdb[5]`/`cdb[6]` (0 when the verb ignores them);
/// `alloc_len` is the data-in buffer size, 16-bit big-endian at `cdb[7..9]`.
/// Use [`build_memread_cdb`] for [`Verb::DumpAll`] (different field layout).
pub fn build_cdb(
verb: Verb,
feature: Option<Feature>,
state: Option<u8>,
alloc_len: u16,
) -> [u8; CDB_LEN] {
let mut cdb = [0u8; CDB_LEN];
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
cdb[CDB_MODE] = KNOCK_MODE;
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&KNOCK);
cdb[CDB_VERB] = verb as u8;
cdb[CDB_FEATURE] = feature.map(|f| f as u8).unwrap_or(0);
cdb[CDB_STATE] = state.unwrap_or(0);
cdb[CDB_ALLOC_LEN] = (alloc_len >> 8) as u8;
cdb[CDB_ALLOC_LEN + 1] = alloc_len as u8;
cdb
}
/// Build a `SET feature = state` CDB. Requests a [`MIN_ALLOC_LEN`]-byte data-in
/// (the drive aborts sub-16-byte transfers — HW-confirmed, see [`MIN_ALLOC_LEN`]);
/// the feature/state ride in the CDB, so the returned payload is ignored.
pub fn build_set_cdb(feature: Feature, state: u8) -> [u8; CDB_LEN] {
build_cdb(Verb::Set, Some(feature), Some(state), MIN_ALLOC_LEN)
}
/// Build a `GET feature` CDB. Requests a [`MIN_ALLOC_LEN`]-byte data-in (the
/// drive aborts a 1-byte transfer — HW-confirmed, see [`MIN_ALLOC_LEN`]); the
/// current state byte is read back from data offset 0.
pub fn build_get_cdb(feature: Feature) -> [u8; CDB_LEN] {
build_cdb(Verb::Get, Some(feature), None, MIN_ALLOC_LEN)
}
/// Build a `RESET` CDB. `mode` rides in the state slot (`cdb[6]`):
/// [`RESET_TO_FLASH`] (`0x00`) reloads the saved flash config into RAM,
/// [`RESET_TO_DEFAULTS`] (`0x01`) restores the baked create-time defaults, and
/// [`RESET_TO_OEM`] (`0xFF`) forces every feature to passthrough (and blanks NV).
/// Floors its data-in at [`MIN_ALLOC_LEN`] for the same HW min-transfer reason as
/// [`build_set_cdb`].
pub fn build_reset_cdb(mode: u8) -> [u8; CDB_LEN] {
build_cdb(Verb::Reset, None, Some(mode), MIN_ALLOC_LEN)
}
/// Build a `SAVE` CDB (persist the RAM feature-state table to the flash config
/// block — the only verb that writes config to flash). Floors its data-in at
/// [`MIN_ALLOC_LEN`] for the same HW min-transfer reason as [`build_reset_cdb`].
pub fn build_save_cdb() -> [u8; CDB_LEN] {
build_cdb(Verb::Save, None, None, MIN_ALLOC_LEN)
}
/// Build an `IDENTITY` CDB. `alloc_len` sizes the magic+version+state reply.
pub fn build_identity_cdb(alloc_len: u16) -> [u8; CDB_LEN] {
build_cdb(Verb::Identity, None, None, alloc_len)
}
/// Build a [`Verb::DumpAll`] CDB: read [`MEMREAD_LEN`] bytes at the 32-bit
/// `addr`, packed big-endian into `cdb[5..9]`.
pub fn build_memread_cdb(addr: u32) -> [u8; CDB_LEN] {
let mut cdb = [0u8; CDB_LEN];
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
cdb[CDB_MODE] = KNOCK_MODE;
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&KNOCK);
cdb[CDB_VERB] = Verb::DumpAll as u8;
cdb[5] = (addr >> 24) as u8;
cdb[6] = (addr >> 16) as u8;
cdb[7] = (addr >> 8) as u8;
cdb[8] = addr as u8;
cdb
}
/// Build a 10-byte CDB for [`Verb::Call`]: `blx target(r0)` where `target` is
/// packed big-endian in `cdb[5..9]` and the u8 `r0` register argument rides in
/// `cdb[9]`. Carries the [`DEBUG_KNOCK`] at `cdb[2..4]` — the ONLY frame the
/// fw honours for Call. The handler ORs the thumb bit into the address at
/// runtime. r1..r3 are undefined at callee entry.
pub fn build_call_cdb(target: u32, r0: u8) -> [u8; CDB_LEN] {
let mut cdb = [0u8; CDB_LEN];
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
cdb[CDB_MODE] = KNOCK_MODE;
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&DEBUG_KNOCK);
cdb[CDB_VERB] = Verb::Call as u8;
cdb[5] = (target >> 24) as u8;
cdb[6] = (target >> 16) as u8;
cdb[7] = (target >> 8) as u8;
cdb[8] = target as u8;
cdb[9] = r0;
cdb
}
/// Build a 10-byte CDB for [`Verb::Poke`]: write a single byte `val` to the
/// arbitrary 32-bit address `target` (RAM or MMIO). Target is packed
/// big-endian in `cdb[5..9]` and `val` rides in `cdb[9]`. Carries the
/// [`DEBUG_KNOCK`] at `cdb[2..4]`. NO bounds check on the address — this is a
/// diagnostic primitive for on-drive state discovery, not a durable verb.
pub fn build_poke_cdb(target: u32, val: u8) -> [u8; CDB_LEN] {
let mut cdb = [0u8; CDB_LEN];
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
cdb[CDB_MODE] = KNOCK_MODE;
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&DEBUG_KNOCK);
cdb[CDB_VERB] = Verb::Poke as u8;
cdb[5] = (target >> 24) as u8;
cdb[6] = (target >> 16) as u8;
cdb[7] = (target >> 8) as u8;
cdb[8] = target as u8;
cdb[9] = val;
cdb
}
/// Build a 10-byte CDB for [`Verb::Reboot`]: force the firmware boot function's
/// cold path (soft-reboot the controller). Carries the [`DEBUG_KNOCK`] at
/// `cdb[2..4]`. NO arguments are transmitted on the wire — the target VA is
/// baked into the emitted handler at build time (per-image, resolved from the
/// boot-init signature). Requests a [`MIN_ALLOC_LEN`]-byte data-in like every
/// other durable-shape verb: the drive returns Aborted Command mid-reboot
/// anyway, so callers should tolerate a rejection on this send and re-probe
/// identity after a short delay.
pub fn build_reboot_cdb() -> [u8; CDB_LEN] {
let mut cdb = [0u8; CDB_LEN];
cdb[CDB_OPCODE] = READ_BUFFER_OPCODE;
cdb[CDB_MODE] = KNOCK_MODE;
cdb[CDB_KNOCK..CDB_KNOCK + 2].copy_from_slice(&DEBUG_KNOCK);
cdb[CDB_VERB] = Verb::Reboot as u8;
cdb[CDB_ALLOC_LEN] = (MIN_ALLOC_LEN >> 8) as u8;
cdb[CDB_ALLOC_LEN + 1] = MIN_ALLOC_LEN as u8;
cdb
}
/// Whether a device data response leads with [`RESP_MAGIC`].
pub fn verify_response(bytes: &[u8]) -> bool {
bytes.starts_with(RESP_MAGIC)
}
// ── Errors ───────────────────────────────────────────────────────────────────
/// Why a firmware command did not succeed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FirmwareError {
/// The drive rejected the command (CHECK CONDITION / vendor refusal). Not a
/// freemkv drive, or the feature/state is unsupported.
Rejected,
/// IDENTITY did not report freemkv firmware at or above [`MIN_FW_VERSION`];
/// a recipe was refused before any SET/RESET was sent.
UnsupportedFirmware,
/// A caller argument was out of range; nothing was sent to the drive.
InvalidArgument,
/// A verify-after-set (`SET` then `GET`) read back a state that did not
/// match what was written. Carries `(feature, wanted, got)`.
VerifyFailed {
feature: Feature,
wanted: u8,
got: u8,
},
/// A genuine SCSI transport fault (bus dead). Callers must abort.
Transport,
}
/// Result of a firmware command.
pub type Result<T> = std::result::Result<T, FirmwareError>;
// ── Parsed replies ───────────────────────────────────────────────────────────
/// The current state of every feature, indexed by [`Feature`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FeatureStates {
/// [`Feature::Speed`] state.
pub speed: u8,
/// [`Feature::Region`] state.
pub region: u8,
/// [`Feature::Unrestricted`] state.
pub unrestricted: u8,
/// [`Feature::Hrl`] state.
pub hrl: u8,
/// [`Feature::Encryption`] state (the consolidated cert/bus bypass).
pub encryption: u8,
}
impl FeatureStates {
/// All features at [`STATE_PASSTHROUGH`] (the OEM / freshly-reset drive).
pub fn all_passthrough() -> Self {
FeatureStates {
speed: STATE_PASSTHROUGH,
region: STATE_PASSTHROUGH,
unrestricted: STATE_PASSTHROUGH,
hrl: STATE_PASSTHROUGH,
encryption: STATE_PASSTHROUGH,
}
}
/// The state byte for one feature.
pub fn get(&self, feature: Feature) -> u8 {
match feature {
Feature::Speed => self.speed,
Feature::Region => self.region,
Feature::Unrestricted => self.unrestricted,
Feature::Hrl => self.hrl,
Feature::Encryption => self.encryption,
}
}
fn set(&mut self, feature: Feature, state: u8) {
match feature {
Feature::Speed => self.speed = state,
Feature::Region => self.region = state,
Feature::Unrestricted => self.unrestricted = state,
Feature::Hrl => self.hrl = state,
Feature::Encryption => self.encryption = state,
}
}
}
/// A parsed [`Verb::Identity`] reply. Only present when the reply led with
/// [`RESP_MAGIC`]. The reply is `freemkv <version>` with no terminator, then the
/// raw feature-flag bytes; only the version is parsed — read the live states
/// via [`FirmwareControl::states`]/[`get`](FirmwareControl::get).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FirmwareIdentity {
/// The version token after `freemkv ` (printable non-space ASCII), e.g.
/// `"0.9.2"`; empty if no `freemkv ` token follows the magic. A Speed cap in the
/// printable range can append one character after the patch number.
pub version: String,
}
impl FirmwareIdentity {
/// Parse an IDENTITY data-in payload. `None` unless it leads with
/// [`RESP_MAGIC`] (i.e. not freemkv firmware).
pub fn parse(bytes: &[u8]) -> Option<Self> {
if !verify_response(bytes) {
return None;
}
let version = match bytes[RESP_MAGIC.len()..].split_first() {
Some((b' ', rest)) => rest
.iter()
.take_while(|b| b.is_ascii_graphic())
.map(|&b| char::from(b))
.collect(),
_ => String::new(),
};
Some(FirmwareIdentity { version })
}
/// Whether this firmware speaks the grammar this mirror sends (version at
/// or above [`MIN_FW_VERSION`]). `false` for an unparseable version.
pub fn is_supported(&self) -> bool {
self.major_minor().is_some_and(|v| v >= MIN_FW_VERSION)
}
fn major_minor(&self) -> Option<(u32, u32)> {
let mut parts = self.version.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?;
let digits = minor
.find(|c: char| !c.is_ascii_digit())
.map_or(minor, |end| &minor[..end]);
Some((major, digits.parse().ok()?))
}
}
/// A named arming recipe (see [`FirmwareControl`] `arm_*` helpers). Lets a
/// caller pick a mode by value (e.g. from a CLI flag or a config field) and
/// apply it uniformly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArmRecipe {
/// [`FirmwareControl::arm_oem_bd`].
OemBd,
/// [`FirmwareControl::arm_oem_uhd`].
OemUhd,
/// [`FirmwareControl::arm_bypass_bd`].
BypassBd,
/// [`FirmwareControl::arm_bypass_uhd`].
BypassUhd,
// NOTE: Feature::Unrestricted unifies former BD+UHD gates (fw 0.9.2); recipe
// names above name the disc TYPE being ripped, not the retired per-format
// feature — see `arm_oem_uhd`/`arm_bypass_uhd`.
/// [`FirmwareControl::arm_stealth_oem`].
StealthOem,
}
impl ArmRecipe {
/// Whether the recipe sets `Encryption = off`: the drive then acts
/// pre-authenticated and serves de-bussed content (no cert AKE needed).
pub(crate) fn disables_encryption(self) -> bool {
matches!(
self,
ArmRecipe::OemUhd | ArmRecipe::BypassBd | ArmRecipe::BypassUhd
)
}
}
// ── FirmwareControl ──────────────────────────────────────────────────────────
/// A timeout every firmware command uses (ms). Matches the wider unlocker.
const CMD_TIMEOUT_MS: u32 = 5_000;
/// An ergonomic, typed driver for the freemkv vendor-command grammar over any
/// [`ScsiTransport`]. Borrows the transport for the lifetime of the control.
///
/// ```no_run
/// # use freemkv_unlock::firmware::{FirmwareControl, FirmwareError};
/// # fn demo(scsi: &mut dyn freemkv_unlock::scsi::ScsiTransport) -> Result<(), FirmwareError> {
/// let mut fw = FirmwareControl::new(scsi);
/// // Encryption off on a UHD disc, revocation ignored. Refused with
/// // `UnsupportedFirmware` unless IDENTITY reports a supported freemkv drive;
/// // `Transport` (dead bus) must abort the caller.
/// fw.arm_oem_uhd()?;
/// # Ok(())
/// # }
/// ```
pub struct FirmwareControl<'a> {
scsi: &'a mut dyn ScsiTransport,
}
impl<'a> FirmwareControl<'a> {
/// Wrap a transport.
pub fn new(scsi: &'a mut dyn ScsiTransport) -> Self {
FirmwareControl { scsi }
}
/// Issue a CDB with a data-in phase, returning the bytes transferred into a
/// fixed [`MEMREAD_LEN`]-byte buffer.
fn exec_in(&mut self, cdb: &[u8; CDB_LEN]) -> Result<([u8; MEMREAD_LEN], usize)> {
let mut buf = [0u8; MEMREAD_LEN];
match self
.scsi
.execute(cdb, DataDirection::FromDevice, &mut buf, CMD_TIMEOUT_MS)
{
Ok(r) if r.status == 0 => Ok((buf, r.bytes_transferred)),
Ok(_) => Err(FirmwareError::Rejected),
Err(e) => Err(if is_dead_bus(&e) {
FirmwareError::Transport
} else {
FirmwareError::Rejected
}),
}
}
/// Send IDENTITY and parse the magic + version banner.
/// `Ok(None)` if the reply lacked [`RESP_MAGIC`] or the drive refused the
/// command (not freemkv firmware); `Err(Transport)` only on a dead bus.
pub fn identity(&mut self) -> Result<Option<FirmwareIdentity>> {
let cdb = build_identity_cdb(MEMREAD_LEN as u16);
match self.exec_in(&cdb) {
Ok((buf, n)) => Ok(FirmwareIdentity::parse(&buf[..n.min(buf.len())])),
// A drive that rejects the knock is simply not freemkv firmware.
Err(FirmwareError::Rejected) => Ok(None),
Err(e) => Err(e),
}
}
/// Convenience: is this a freemkv-firmware drive? `false` on a rejection or a
/// no-magic reply; `Err(Transport)` on a dead bus.
pub fn is_freemkv(&mut self) -> Result<bool> {
Ok(self.identity()?.is_some())
}
/// Read one feature's current state byte (GET).
pub fn get(&mut self, feature: Feature) -> Result<u8> {
let cdb = build_get_cdb(feature);
let (buf, n) = self.exec_in(&cdb)?;
if n == 0 {
return Err(FirmwareError::Rejected);
}
Ok(buf[0])
}
/// Set one feature to an explicit state byte (SET).
///
/// Issued with a data-in phase (`exec_in`), not `exec_none`: the drive's
/// READ BUFFER hijack aborts ANY vendor verb that carries no data-in
/// transfer — the `MIN_ALLOC_LEN` floor in the CDB is only honored when the
/// host actually reads that many bytes back. The returned bytes are the
/// echoed state frame; we discard them (GET is the read path).
pub fn set(&mut self, feature: Feature, state: u8) -> Result<()> {
let cdb = build_set_cdb(feature, state);
self.exec_in(&cdb).map(|_| ())
}
/// Restore every feature to [`STATE_PASSTHROUGH`] (RESET with [`RESET_TO_OEM`])
/// — TRUE OEM: firmware ≥0.8.3 also blanks the NV block so the drive is
/// traceless. Data-in phase for the same reason as [`set`](Self::set) — a no-data
/// RESET is aborted.
pub fn reset(&mut self) -> Result<()> {
let cdb = build_reset_cdb(RESET_TO_OEM);
self.exec_in(&cdb).map(|_| ())
}
/// Restore the baked create-time defaults into the RAM feature-state table
/// (RESET with [`RESET_TO_DEFAULTS`]) — what a never-saved drive boots to; does
/// not touch flash. Firmware ≥0.8.3. Data-in phase for the same reason as
/// [`reset`](Self::reset).
pub fn reset_to_defaults(&mut self) -> Result<()> {
let cdb = build_reset_cdb(RESET_TO_DEFAULTS);
self.exec_in(&cdb).map(|_| ())
}
/// Reload the saved flash config block back into the RAM feature-state table
/// (RESET with [`RESET_TO_FLASH`]), discarding any un-saved RAM changes — the
/// non-destructive "revert to last SAVE". Data-in phase for the same reason as
/// [`reset`](Self::reset).
pub fn reset_to_flash(&mut self) -> Result<()> {
let cdb = build_reset_cdb(RESET_TO_FLASH);
self.exec_in(&cdb).map(|_| ())
}
/// Persist the whole RAM feature-state table to the flash config block (SAVE)
/// — the only verb that writes config to flash, so a host's SET changes survive
/// a power cycle only once saved. Data-in phase for the same reason as
/// [`reset`](Self::reset).
pub fn save(&mut self) -> Result<()> {
let cdb = build_save_cdb();
self.exec_in(&cdb).map(|_| ())
}
/// SET a feature, then GET it back and confirm the state stuck. Blind spot:
/// fw answers a refused/out-of-range GET with GOOD + zeros, which reads as
/// [`STATE_OFF`]; the recipes gate on [`Self::require_supported`] first.
fn set_verify(&mut self, feature: Feature, state: u8) -> Result<()> {
self.set(feature, state)?;
let got = self.get(feature)?;
if got == state {
Ok(())
} else {
Err(FirmwareError::VerifyFailed {
feature,
wanted: state,
got,
})
}
}
/// Refuse (`UnsupportedFirmware`) unless IDENTITY reports a supported
/// freemkv firmware; every recipe calls this before its first SET/RESET.
fn require_supported(&mut self) -> Result<()> {
match self.identity()? {
Some(id) if id.is_supported() => Ok(()),
_ => Err(FirmwareError::UnsupportedFirmware),
}
}
/// Read the current state of every feature via repeated GET.
pub fn states(&mut self) -> Result<FeatureStates> {
let mut s = FeatureStates::all_passthrough();
for feature in ALL_FEATURES {
s.set(feature, self.get(feature)?);
}
Ok(s)
}
/// Diagnostic RAM peek: [`MEMREAD_LEN`] bytes at `addr` (DUMPALL).
pub fn dump(&mut self, addr: u32) -> Result<[u8; MEMREAD_LEN]> {
let cdb = build_memread_cdb(addr);
let (buf, n) = self.exec_in(&cdb)?;
if n < MEMREAD_LEN {
return Err(FirmwareError::Rejected);
}
Ok(buf)
}
// ── Typed setters ────────────────────────────────────────────────────────
/// Force the Unrestricted (BD/UHD) capability gate on (drive engages both
/// BD and UHD discs). fw 0.9.2 unified the former separate UHD/BD gates
/// into this single lever.
pub fn enable_unrestricted(&mut self) -> Result<()> {
self.set(Feature::Unrestricted, STATE_ON)
}
/// Force the Unrestricted (BD/UHD) capability gate off.
pub fn disable_unrestricted(&mut self) -> Result<()> {
self.set(Feature::Unrestricted, STATE_OFF)
}
/// Skip the HRL lookup (revoked certs accepted; non-destructive). The unlock
/// direction is now [`STATE_OFF`] (`0x00` = HRL enforcement off).
pub fn skip_hrl(&mut self) -> Result<()> {
self.set(Feature::Hrl, STATE_OFF)
}
/// Disable every drive-side encryption/cert requirement in one shot: the
/// drive acts pre-authenticated (no handshake) AND content returns de-bussed
/// (no in-transit bus encryption). The consolidated `Encryption` lever
/// replaces the old separate `Ake`/`Bus` levers — wire id `0x07` (`Bus`) is
/// retired. The unlock direction is [`STATE_OFF`] (`0x00`).
pub fn disable_encryption(&mut self) -> Result<()> {
self.set(Feature::Encryption, STATE_OFF)
}
/// Region-free (RPC-1). Sends [`REGION_FREE`] (`0x0F`) — under the migrated
/// region scheme `0x01` now means "force DVD region 1", not region-free.
pub fn region_free(&mut self) -> Result<()> {
self.set(Feature::Region, REGION_FREE)
}
/// Force a specific Blu-ray region.
pub fn force_region_bd(&mut self, region: BdRegion) -> Result<()> {
self.set(Feature::Region, region.state())
}
/// Force a specific DVD region (1..=8). Returns
/// [`FirmwareError::InvalidArgument`] for an out-of-range region without
/// touching the drive.
pub fn force_region_dvd(&mut self, region: u8) -> Result<()> {
if !(1..=8).contains(&region) {
return Err(FirmwareError::InvalidArgument);
}
self.set(Feature::Region, REGION_DVD_BASE + region)
}
/// Lift the read-speed / riplock ceiling to maximum.
pub fn unlock_speed(&mut self) -> Result<()> {
self.set(Feature::Speed, SPEED_MAX)
}
// ── Named recipes (the "modes" table) ─────────────────────────────────────
// Each recipe first issues IDENTITY and refuses unsupported firmware. A
// recipe that fails midway leaves its earlier SETs applied (no rollback).
/// **arm_oem_bd** — OEM-style Blu-ray (AACS 1.0) rip with a REAL AKE, only
/// revocation disabled.
///
/// Sets: `Hrl = skip` (revoked host certs accepted; non-destructive).
/// Leaves everything else at passthrough — the drive still runs the real
/// host-cert handshake and returns bus-encrypted content, so the host
/// performs the AKE and de-busses. Rips: BD with an otherwise-revoked cert.
pub fn arm_oem_bd(&mut self) -> Result<()> {
self.require_supported()?;
self.set_verify(Feature::Hrl, STATE_OFF)
}
/// **arm_oem_uhd** — OEM-style UHD (AACS 2.0) rip.
///
/// Sets: `Unrestricted = on` (mode-gate neutralized so the drive engages
/// the UHD disc), `Hrl = skip` (revocation off), `Encryption = off`
/// (content de-bussed — the consolidated cert/bus bypass). Rips: UHD via
/// the OEM path.
pub fn arm_oem_uhd(&mut self) -> Result<()> {
self.require_supported()?;
self.set_verify(Feature::Unrestricted, STATE_ON)?;
self.set_verify(Feature::Hrl, STATE_OFF)?;
self.set_verify(Feature::Encryption, STATE_OFF)
}
/// **arm_bypass_bd** — full-bypass Blu-ray rip (NO host cert needed).
///
/// Sets: `Encryption = off` (the consolidated cert/bus bypass — drive acts
/// pre-authenticated, no handshake, content de-bussed) and `Hrl = skip`
/// (revocation off). Rips: BD with no cert.
pub fn arm_bypass_bd(&mut self) -> Result<()> {
self.require_supported()?;
self.set_verify(Feature::Hrl, STATE_OFF)?;
self.set_verify(Feature::Encryption, STATE_OFF)
}
/// **arm_bypass_uhd** — full-bypass UHD rip (NO host cert needed).
///
/// Sets: `Unrestricted = on` (engage the UHD disc), `Encryption = off`
/// (the consolidated cert/bus bypass), and `Hrl = skip` (revocation off).
/// Rips: UHD with no cert.
pub fn arm_bypass_uhd(&mut self) -> Result<()> {
self.require_supported()?;
self.set_verify(Feature::Unrestricted, STATE_ON)?;
self.set_verify(Feature::Hrl, STATE_OFF)?;
self.set_verify(Feature::Encryption, STATE_OFF)
}
/// **arm_stealth_oem** — return the drive to byte-for-byte OEM behaviour.
///
/// Issues RESET (every feature → passthrough) and verifies each feature read
/// back as [`STATE_PASSTHROUGH`]. Rips: nothing — this DISARMS the drive.
/// Needs fw >= [`MIN_FW_VERSION`]; raw [`reset`](Self::reset) has no gate.
pub fn arm_stealth_oem(&mut self) -> Result<()> {
self.require_supported()?;
self.reset()?;
for feature in ALL_FEATURES {
let got = self.get(feature)?;
if got != STATE_PASSTHROUGH {
return Err(FirmwareError::VerifyFailed {
feature,
wanted: STATE_PASSTHROUGH,
got,
});
}
}
Ok(())
}
/// Apply a named [`ArmRecipe`].
pub fn arm(&mut self, recipe: ArmRecipe) -> Result<()> {
match recipe {
ArmRecipe::OemBd => self.arm_oem_bd(),
ArmRecipe::OemUhd => self.arm_oem_uhd(),
ArmRecipe::BypassBd => self.arm_bypass_bd(),
ArmRecipe::BypassUhd => self.arm_bypass_uhd(),
ArmRecipe::StealthOem => self.arm_stealth_oem(),
}
}
}
/// Test fixture: the fw 0.9.x IDENTITY wire shape (core.rs `identity_blob` +
/// handler): `freemkv <ver>` with NO NUL, then the 6 raw flag bytes
/// `flag[0x01..=0x06]`, then zero padding to the 64-byte window.
#[cfg(test)]
pub(crate) fn identity_reply(version: &str, flags: [u8; 6]) -> Vec<u8> {
let mut p = RESP_MAGIC.to_vec();
p.push(b' ');
p.extend_from_slice(version.as_bytes());
p.extend_from_slice(&flags);
p.resize(MEMREAD_LEN, 0);
p
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;