diff --git a/README.md b/README.md index dc6e8b5..b2decfb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/disc/patch.rs b/src/disc/patch.rs index c83ca6d..93290c4 100644 --- a/src/disc/patch.rs +++ b/src/disc/patch.rs @@ -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( diff --git a/src/drive/mod.rs b/src/drive/mod.rs index 4bcd92c..c86425e 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -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", diff --git a/src/lib.rs b/src/lib.rs index 1d3abaa..35e9526 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 diff --git a/src/unlock_bridge.rs b/src/unlock_bridge.rs index 912a3a3..2845088 100644 --- a/src/unlock_bridge.rs +++ b/src/unlock_bridge.rs @@ -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, + }) + } } } }