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:
@@ -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
@@ -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(
|
||||
|
||||
+25
-13
@@ -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,
|
||||
&[],
|
||||
) {
|
||||
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.
|
||||
// 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.
|
||||
// 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());
|
||||
let r: Result<()> = Ok(());
|
||||
}
|
||||
tracing::info!(
|
||||
target: "freemkv::drive",
|
||||
phase = "init",
|
||||
|
||||
+1
-1
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user