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.
|
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.
|
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.
|
Part of the [freemkv](https://github.com/freemkv) project.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
Consumed by git tag (not published to crates.io):
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
libfreemkv = "1.0.0-rc.1"
|
libfreemkv = { git = "https://github.com/freemkv/libfreemkv", tag = "vX.Y.Z" }
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -30,7 +32,7 @@ use std::path::Path;
|
|||||||
// Open drive — identified via INQUIRY
|
// Open drive — identified via INQUIRY
|
||||||
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||||
drive.wait_ready()?; // wait for disc
|
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
|
drive.probe_disc()?; // probe disc surface for optimal speeds
|
||||||
|
|
||||||
// Scan disc — UDF, playlists, streams, AACS (all automatic)
|
// Scan disc — UDF, playlists, streams, AACS (all automatic)
|
||||||
@@ -98,7 +100,7 @@ loop {
|
|||||||
|
|
||||||
## What It Does
|
## 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
|
- **12+ MB/s reads** — auto-detects kernel transfer limits, sustained full speed
|
||||||
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
|
- **Disc scanning** — UDF 2.50 filesystem, MPLS playlists, CLPI clip info
|
||||||
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
- **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
|
```text
|
||||||
Drive — open, identify, init, single-shot read
|
Drive — open, identify, init, single-shot read
|
||||||
├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows)
|
├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows)
|
||||||
└── Unlocker seam — pluggable trait + registry; concrete unlockers
|
└── unlock_bridge — private seam to the freemkv-unlock crate
|
||||||
live in the separate freemkv-unlock repo
|
(firmware / AACS cert / CSS bus-auth unlockers)
|
||||||
|
|
||||||
Disc — scan titles, streams, AACS/CSS state
|
Disc — scan titles, streams, AACS/CSS state
|
||||||
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
|
├── UDF reader — Blu-ray UDF 2.50 with metadata partitions
|
||||||
|
|||||||
+17
-5
@@ -2085,20 +2085,32 @@ impl Disc {
|
|||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
// First failure in this range: the fast-batched pass over
|
// First failure in this range: the fast-batched pass over
|
||||||
// the clean overshoot is done; drop to the slow recovery
|
// the clean overshoot is done. A genuine transport fault
|
||||||
// speed for the rest of the range and arm the cooldown.
|
// (bridge crash) is NOT a recoverable bad sector — let
|
||||||
// Idempotent — only the first failure issues SET CD SPEED.
|
// handle_read_failure abort the pass immediately rather
|
||||||
if !range_slowed {
|
// 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);
|
reader.set_speed(0x0000);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target: "freemkv::disc",
|
target: "freemkv::disc",
|
||||||
phase = "patch_speed",
|
phase = "patch_speed",
|
||||||
lba,
|
lba,
|
||||||
speed = "0x0000",
|
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;
|
range_slowed = true;
|
||||||
cooldown_pending = true;
|
cooldown_pending = true;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
match handle_read_failure(
|
match handle_read_failure(
|
||||||
|
|||||||
+31
-19
@@ -413,33 +413,45 @@ impl Drive {
|
|||||||
// the kind is Unknown — only an identity-keyed unlocker can match here.
|
// the kind is Unknown — only an identity-keyed unlocker can match here.
|
||||||
// The first matching unlocker runs; none matching leaves the drive in
|
// The first matching unlocker runs; none matching leaves the drive in
|
||||||
// stock mode so the host-cert AACS handshake (the OEM route) carries the
|
// 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
|
// disc. A genuine transport fault means the bus is dead — abort init
|
||||||
// through. (A transport fault during unlock is swallowed by the bridge
|
// (the v1.1.0 invariant; `if let Ok` was silently swallowing it). Every
|
||||||
// today, mirroring the old no-match fall-through.)
|
// other error (NotApplicable / no match) is "nothing applied" — fall
|
||||||
|
// through to stock mode.
|
||||||
self.init_ran = true;
|
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.scsi.as_mut(),
|
||||||
&self.drive_id,
|
&self.drive_id,
|
||||||
freemkv_unlock::DiscKind::Unknown,
|
freemkv_unlock::DiscKind::Unknown,
|
||||||
&[],
|
&[],
|
||||||
) {
|
) {
|
||||||
self.unlocker_name =
|
Ok(unlocked) => {
|
||||||
crate::unlock_bridge::unlocker_name(&self.drive_id).map(str::to_string);
|
self.unlocker_name =
|
||||||
// Stash the OEM Volume ID the unlocker returned for the AACS handshake
|
crate::unlock_bridge::unlocker_name(&self.drive_id).map(str::to_string);
|
||||||
// phase (do_handshake reads it via `oem_vid()`). A drive-prep unlocker
|
// Stash the OEM Volume ID the unlocker returned for the AACS
|
||||||
// always carries a VID; guard anyway.
|
// handshake phase (do_handshake reads it via `oem_vid()`). A
|
||||||
if let Some(vid) = unlocked.vid {
|
// drive-prep unlocker always carries a VID; guard anyway.
|
||||||
self.oem_vid = Some(vid);
|
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 —
|
// 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
|
// UNCONDITIONALLY whenever the bus is alive (init didn't transport-fault),
|
||||||
// drive (no firmware unlocker) still wants max speed; gating this on an
|
// whether or not an unlocker matched. A stock-mode BD/UHD drive (no
|
||||||
// unlocker match left such drives riplocked. (DVD returns earlier in
|
// firmware unlocker) still wants max speed; gating this on an unlocker
|
||||||
// stock mode; its sweep sets DVD speed separately.) Best-effort: a
|
// match left such drives riplocked. (DVD returns earlier in stock mode;
|
||||||
// failure here must NOT fail the rip — a slow drive still rips.
|
// its sweep sets DVD speed separately.) Best-effort: a failure here must
|
||||||
self.set_speed(crate::speed::DriveSpeed::Max.to_kbps());
|
// NOT fail the rip — a slow drive still rips.
|
||||||
let r: Result<()> = Ok(());
|
if r.is_ok() {
|
||||||
|
self.set_speed(crate::speed::DriveSpeed::Max.to_kbps());
|
||||||
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
target: "freemkv::drive",
|
target: "freemkv::drive",
|
||||||
phase = "init",
|
phase = "init",
|
||||||
|
|||||||
+1
-1
@@ -58,7 +58,7 @@
|
|||||||
//! ├── JAR parser -- BD-J audio track labels
|
//! ├── JAR parser -- BD-J audio track labels
|
||||||
//! └── AACS -- encryption: key resolution + content decrypt
|
//! └── AACS -- encryption: key resolution + content decrypt
|
||||||
//! ├── aacs -- KEYDB, VUK, MKB, unit 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
|
//! # AACS Encryption
|
||||||
|
|||||||
+25
-5
@@ -44,11 +44,31 @@ impl fu::scsi::ScsiTransport for ScsiAdapter<'_> {
|
|||||||
bytes_transferred: r.bytes_transferred,
|
bytes_transferred: r.bytes_transferred,
|
||||||
sense: r.sense,
|
sense: r.sense,
|
||||||
}),
|
}),
|
||||||
// libfreemkv's transport returns Err only on a transport-layer fault.
|
// libfreemkv's transport returns Err for ANY non-zero SCSI status —
|
||||||
Err(_) => Err(fu::scsi::ScsiError {
|
// i.e. a normal drive CHECK CONDITION (ILLEGAL_REQUEST, etc.), NOT
|
||||||
status: 0xFF,
|
// only a transport-layer fault. Preserve the real status AND the
|
||||||
sense: None,
|
// 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