unlock/patch: preserve SCSI sense across the bridge; init aborts on dead bus; slow-retry first patch failure

Audit fixes (v1.1.0..HEAD regressions in the unlock migration + adaptive patch
speed):

- unlock_bridge ScsiAdapter: libfreemkv's transport returns Err on ANY non-zero
  SCSI status (a normal CHECK CONDITION), not only transport faults. The adapter
  was collapsing every such Err to { status: 0xFF, sense: None }, which discarded
  the parsed sense and defeated the AACS handshake's ILLEGAL_REQUEST wedge guard
  (so it kept hammering the drive — hard-rule #2) and inverted its
  transport-vs-rejection diagnosis. Now reconstruct status + the 32-byte sense
  buffer (sense_key@2, asc@12, ascq@13) and only emit 0xFF/None for a genuine
  transport fault.

- Drive::init: a genuine transport fault during the drive-prep unlock means the
  bus is dead — propagate it (the v1.1.0 invariant) instead of silently
  swallowing it via `if let Ok`. Other errors (no matching unlocker) still fall
  through to stock mode. SET CD SPEED max now runs only when the bus is alive.

- disc::patch: on the first read failure in a range, drop to slow recovery speed
  and RE-ATTEMPT the same position at slow speed before marking it. A
  single-sector range's first failing sector was being marked from a MAX-speed
  read it never got to recover.

- docs: lib.rs architecture diagram (handshake → host_certs) and README (stale
  pluggable-unlock-seam / register-unlocker / crates.io / docs.rs references).
This commit is contained in:
Matthew Jackson
2026-06-29 22:16:28 -07:00
parent b36896564f
commit 8c38cb0918
5 changed files with 83 additions and 37 deletions
+9 -7
View File
@@ -6,19 +6,21 @@ Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scann
DVDs (CSS) decrypt out of the box. Blu-ray and UHD (AACS) require a `keydb.cfg` (default `~/.config/freemkv/keydb.cfg`) supplying disc-specific volume unique keys; no AACS key material is compiled in.
**12+ MB/s** sustained read speeds on BD. Drive prep routes through the pluggable unlock seam — register an unlocker and `init()` drives it; with none registered the library rips via the host-certificate AACS handshake.
**12+ MB/s** sustained read speeds on BD. Drive prep (`init()`) handles unlocking internally via the `freemkv-unlock` crate — clients never see it; when no drive unlock applies, the library rips via the host-certificate AACS handshake.
Multi-lingual by design — the library outputs structured data and numeric error codes, never English text. Build any UI or localization on top.
**[API Documentation](https://docs.rs/libfreemkv)** · **[Technical Docs](docs/)**
**[Source & API](https://github.com/freemkv/libfreemkv)** · **[Technical Docs](docs/)**
Part of the [freemkv](https://github.com/freemkv) project.
## Install
Consumed by git tag (not published to crates.io):
```toml
[dependencies]
libfreemkv = "1.0.0-rc.1"
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "vX.Y.Z" }
```
## Quick Start
@@ -30,7 +32,7 @@ use std::path::Path;
// Open drive — identified via INQUIRY
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
drive.wait_ready()?; // wait for disc
drive.init()?; // route through the unlock seam (if an unlocker is registered)
drive.init()?; // unlock + prep (handled internally)
drive.probe_disc()?; // probe disc surface for optimal speeds
// Scan disc — UDF, playlists, streams, AACS (all automatic)
@@ -98,7 +100,7 @@ loop {
## What It Does
- **Drive access** — open, identify, pluggable unlock seam, speed control, eject
- **Drive access** — open, identify, internal unlock + prep, speed control, eject
- **12+ MB/s reads** — auto-detects kernel transfer limits, sustained full speed
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
@@ -132,8 +134,8 @@ Blu-rays and UHD (AACS) require a `keydb.cfg` at `~/.config/freemkv/keydb.cfg` (
```text
Drive — open, identify, init, single-shot read
├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows)
└── Unlocker seam — pluggable trait + registry; concrete unlockers
live in the separate freemkv-unlock repo
└── unlock_bridge — private seam to the freemkv-unlock crate
(firmware / AACS cert / CSS bus-auth unlockers)
Disc — scan titles, streams, AACS/CSS state
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
+17 -5
View File
@@ -2085,20 +2085,32 @@ impl Disc {
}
Err(err) => {
// First failure in this range: the fast-batched pass over
// the clean overshoot is done; drop to the slow recovery
// speed for the rest of the range and arm the cooldown.
// Idempotent — only the first failure issues SET CD SPEED.
if !range_slowed {
// the clean overshoot is done. A genuine transport fault
// (bridge crash) is NOT a recoverable bad sector — let
// handle_read_failure abort the pass immediately rather
// than burn a slow re-read on a wedged bus.
if !range_slowed && !err.is_scsi_transport_failure() {
// Drop to the slow recovery speed, arm the cooldown,
// and RE-ATTEMPT the same position at slow speed before
// marking it. The drive's deep recovery (long re-reads
// / ECC) only engages at the slow speed; the failure so
// far is a fast-read miss. Hold the cursor (don't
// advance, don't count damage) and retry — only a
// slow-speed result reaches handle_read_failure below.
// Without this, a single-sector range's first failing
// sector was marked from a MAX-speed read it never got
// to recover. range_slowed gates this to once per range.
reader.set_speed(0x0000);
tracing::info!(
target: "freemkv::disc",
phase = "patch_speed",
lba,
speed = "0x0000",
"patch: range dropped to slow recovery speed on first read failure"
"patch: range dropped to slow recovery speed; retrying the failing read at slow speed before marking"
);
range_slowed = true;
cooldown_pending = true;
continue;
}
match handle_read_failure(
+31 -19
View File
@@ -413,33 +413,45 @@ impl Drive {
// the kind is Unknown — only an identity-keyed unlocker can match here.
// The first matching unlocker runs; none matching leaves the drive in
// stock mode so the host-cert AACS handshake (the OEM route) carries the
// disc. An `Err` return means "nothing applied" — not a hard error; fall
// through. (A transport fault during unlock is swallowed by the bridge
// today, mirroring the old no-match fall-through.)
// disc. A genuine transport fault means the bus is dead — abort init
// (the v1.1.0 invariant; `if let Ok` was silently swallowing it). Every
// other error (NotApplicable / no match) is "nothing applied" — fall
// through to stock mode.
self.init_ran = true;
if let Ok(unlocked) = crate::unlock_bridge::run_unlockers(
let r: Result<()> = match crate::unlock_bridge::run_unlockers(
self.scsi.as_mut(),
&self.drive_id,
freemkv_unlock::DiscKind::Unknown,
&[],
) {
self.unlocker_name =
crate::unlock_bridge::unlocker_name(&self.drive_id).map(str::to_string);
// Stash the OEM Volume ID the unlocker returned for the AACS handshake
// phase (do_handshake reads it via `oem_vid()`). A drive-prep unlocker
// always carries a VID; guard anyway.
if let Some(vid) = unlocked.vid {
self.oem_vid = Some(vid);
Ok(unlocked) => {
self.unlocker_name =
crate::unlock_bridge::unlocker_name(&self.drive_id).map(str::to_string);
// Stash the OEM Volume ID the unlocker returned for the AACS
// handshake phase (do_handshake reads it via `oem_vid()`). A
// drive-prep unlocker always carries a VID; guard anyway.
if let Some(vid) = unlocked.vid {
self.oem_vid = Some(vid);
}
Ok(())
}
}
Err(freemkv_unlock::UnlockError::Transport) => Err(Error::ScsiError {
opcode: 0,
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
sense: None,
}),
Err(_) => Ok(()),
};
// Raise the drive to its maximum read speed with a generic SET CD SPEED —
// UNCONDITIONALLY, whether or not an unlocker matched. A stock-mode BD/UHD
// drive (no firmware unlocker) still wants max speed; gating this on an
// unlocker match left such drives riplocked. (DVD returns earlier in
// stock mode; its sweep sets DVD speed separately.) Best-effort: a
// failure here must NOT fail the rip — a slow drive still rips.
self.set_speed(crate::speed::DriveSpeed::Max.to_kbps());
let r: Result<()> = Ok(());
// UNCONDITIONALLY whenever the bus is alive (init didn't transport-fault),
// whether or not an unlocker matched. A stock-mode BD/UHD drive (no
// firmware unlocker) still wants max speed; gating this on an unlocker
// match left such drives riplocked. (DVD returns earlier in stock mode;
// its sweep sets DVD speed separately.) Best-effort: a failure here must
// NOT fail the rip — a slow drive still rips.
if r.is_ok() {
self.set_speed(crate::speed::DriveSpeed::Max.to_kbps());
}
tracing::info!(
target: "freemkv::drive",
phase = "init",
+1 -1
View File
@@ -58,7 +58,7 @@
//! ├── JAR parser -- BD-J audio track labels
//! └── AACS -- encryption: key resolution + content decrypt
//! ├── aacs -- KEYDB, VUK, MKB, unit decrypt
//! └── handshake -- SCSI auth, ECDH, bus key
//! └── host_certs -- collect host certs (cert handshake lives in freemkv-unlock)
//! ```
//!
//! # AACS Encryption
+25 -5
View File
@@ -44,11 +44,31 @@ impl fu::scsi::ScsiTransport for ScsiAdapter<'_> {
bytes_transferred: r.bytes_transferred,
sense: r.sense,
}),
// libfreemkv's transport returns Err only on a transport-layer fault.
Err(_) => Err(fu::scsi::ScsiError {
status: 0xFF,
sense: None,
}),
// libfreemkv's transport returns Err for ANY non-zero SCSI status —
// i.e. a normal drive CHECK CONDITION (ILLEGAL_REQUEST, etc.), NOT
// only a transport-layer fault. Preserve the real status AND the
// parsed sense across the seam: the AACS handshake's wedge guard
// bails on an ILLEGAL_REQUEST sense (so it stops hammering the drive),
// and its diagnosis distinguishes a cert rejection from a dead bus by
// the same status/sense. Collapsing everything to 0xFF/None defeated
// both. Reconstruct the 32-byte sense buffer at the offsets the
// unlock crate reads (sense_key@2 low-nibble, asc@12, ascq@13); a
// genuine transport fault (status 0xFF, no sense) maps through
// unchanged.
Err(e) => {
let (status, sense) = crate::drive::extract_scsi_context(&e);
let sense_buf = sense.map(|s| {
let mut b = [0u8; 32];
b[2] = s.sense_key & 0x0F;
b[12] = s.asc;
b[13] = s.ascq;
b
});
Err(fu::scsi::ScsiError {
status,
sense: sense_buf,
})
}
}
}
}