diff --git a/CHANGELOG.md b/CHANGELOG.md index 86b5536..69c2074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,19 @@ consumers are the in-tree toolchain crates. with a per-unit "already-asked-dry" set (still bounded by the fetch budget). - **`verify::push_ranges` uses saturating arithmetic** so a corrupt-disc LBA near `u32::MAX` can't panic (matches `udf::merge_ranges`). +- **Audio no longer corrupts at a stream discontinuity.** At a transport-stream + discontinuity — a continuity-counter break, an adaptation-field + discontinuity_indicator, or a concealed-loss gap — the AC-3 / DTS / TrueHD + parsers held a *truncated* partial access unit and spliced the post-gap bytes + onto it, manufacturing a corrupt frame (ffmpeg "exponent out of range" / + "Failed to decode block code(s)" / "Invalid data found") and, for TrueHD, a + non-monotonic timestamp band on multi-segment titles. The video path already + resynced via the keyframe gate; the audio parsers now do too — on a + discontinuity they drop the un-completable partial and resync on the next + syncword, rebasing the timestamp from the post-gap PES. A discontinuity becomes + a clean single-frame gap instead of a corrupt splice. Audio has no inter-frame + references, so dropping the truncated partial is the complete fix; the approach + matches FFmpeg's parser layer and GStreamer's `tsdemux`. ## [1.1.0] diff --git a/src/aacs/trace.rs b/src/aacs/trace.rs index b5a5943..4699d99 100644 --- a/src/aacs/trace.rs +++ b/src/aacs/trace.rs @@ -31,7 +31,7 @@ impl ResolutionTrace { // ── Unlock phase ──────────────────────────────────────────────────────────── /// One unlocker's contribution to the unlock phase. `who` is the unlocker's -/// `name()` (a stable identifier, e.g. `"LibreDrive"`), carried verbatim. +/// `name()` (a stable, product-neutral identifier), carried verbatim. #[derive(Debug, Clone, PartialEq)] pub struct UnlockStep { pub who: String, diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index 650ff6e..96d35ce 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -17,6 +17,36 @@ pub(super) struct HandshakeResult { /// instead of a bare "unavailable" — the difference between a diagnosable log /// and archaeology. pub read_data_key_err: Option, + /// True when the VID came from a firmware unlocker (`freemkv-unlock-ld` + /// et al.) that unlocked the drive. Such a drive serves CLEAR + /// content, so AACS bus encryption is already removed AT THE DRIVE — the same + /// end state a successful cert handshake's `read_data_key` provides, just via + /// firmware instead of the AKE. The bus-key gate MUST credit this as a valid + /// bus-removal: bus encryption is unremovable only when NEITHER the firmware + /// unlocked the drive NOR the cert handshake yielded a bus key. Without this, + /// a SUCCESSFUL unlock (VID present, `read_data_key: None`) paradoxically trips + /// the gate and blocks ALL key resolution (incl. the online source). + pub drive_unlocked: bool, +} + +/// Single source of truth for "is AACS bus encryption gone for this scan?". The +/// gate asks ONLY this — `if !removed { error }` — never enumerating cases. Bus +/// encryption is gone when ANY of these holds: +/// - the disc never had it (`!bus_encryption`): nothing to remove; +/// - file/ISO reads (`handshake == None`): content is already clear at read time; +/// - a firmware unlocker unlocked the drive (`drive_unlocked`): it serves clear +/// content; +/// - the cert handshake produced the bus key (`read_data_key`). +/// +/// Add a NEW removal mechanism HERE, never in the gate. +fn bus_encryption_removed(bus_encryption: bool, handshake: Option<&HandshakeResult>) -> bool { + if !bus_encryption { + return true; // never had it → nothing to remove + } + match handshake { + None => true, // file/ISO: clear at read time + Some(h) => h.drive_unlocked || h.read_data_key.is_some(), + } } /// In-tree AACS host-certificate cert-auth "unlocker" — the Drive-level peer of @@ -103,21 +133,22 @@ impl AacsCertUnlocker<'_> { return Err(UnlockError::VidUnavailable); } }; - let (read_data_key, read_data_key_err) = - match aacs::handshake::read_data_keys(session, &mut auth) { - Ok((rdk, _)) => (Some(rdk), None), - Err(e) => { - tracing::debug!( - target: "freemkv::disc", - phase = "handshake_read_data_key_failed", - cert_index = idx, - error_code = e.code(), - "auth + VID read OK, but the drive served no read_data_key (bus key); \ - a bus-encrypted disc stays undecryptable until it does" - ); - (None, Some(e.code())) - } - }; + let (read_data_key, read_data_key_err) = match aacs::handshake::read_data_keys( + session, &mut auth, + ) { + Ok((rdk, _)) => (Some(rdk), None), + Err(e) => { + tracing::debug!( + target: "freemkv::disc", + phase = "handshake_read_data_key_failed", + cert_index = idx, + error_code = e.code(), + "auth + VID read OK, but the drive served no read_data_key (bus key); \ + a bus-encrypted disc stays undecryptable until it does" + ); + (None, Some(e.code())) + } + }; tracing::debug!( target: "freemkv::disc", phase = "handshake_ok", @@ -130,6 +161,9 @@ impl AacsCertUnlocker<'_> { volume_id, read_data_key, read_data_key_err, + // Host-cert AKE path: bus removal depends on read_data_key, + // NOT a firmware unlock. + drive_unlocked: false, }); } Err(e) => { @@ -294,6 +328,10 @@ impl Disc { // OEM/VID-only path never attempts the bus-key read — None here // is "not attempted", not "failed". read_data_key_err: None, + // The firmware unlocker stashed this VID at init, which means it + // matched and unlocked the drive — it now serves clear content, + // so bus encryption is removed at the drive. Credit it. + drive_unlocked: true, }), None, ); @@ -360,31 +398,39 @@ impl Disc { .map(|c| c.version.major()) .unwrap_or(aacs::AACS_MAJOR_UHD); - // OEM bus-key gate (wrong-keys guard). A bus-encrypted disc (Content - // Certificate bus-encryption bit set) still carries bus encryption on - // its sectors; descrambling needs the `read_data_key` (bus key), which - // ONLY the AACS host-certificate cert-auth handshake produces. A - // VID-only OEM unlock path returns `read_data_key: None`, and a VID - // alone does NOT remove bus encryption — so if a handshake ran (live - // drive) and yielded a VID but no bus key on a bus-encrypted disc, the - // bytes would decrypt to garbage. Fail loudly here instead. + // Bus-encryption gate (wrong-keys guard). A bus-encrypted disc (Content + // Certificate bus-encryption bit set) carries bus encryption on its + // sectors, which MUST be removed before any AACS key can decrypt them. + // There are TWO ways it gets removed, and bus encryption is unremovable + // only when NEITHER succeeded: + // 1. A firmware unlocker unlocked the drive → it serves + // CLEAR content (`drive_unlocked`). This is the common live-drive + // case and yields no `read_data_key` — it doesn't need one. + // 2. The AACS host-certificate cert-auth handshake produced the bus key + // (`read_data_key`). + // The old gate credited ONLY (2), so a SUCCESSFUL firmware unlock (VID + // present, `read_data_key: None`, `drive_unlocked: true`) tripped it and + // blocked ALL key resolution — including the online source — even though + // the drive was serving clear content. That was the bug. // - // Gated on `handshake.is_some()` so the two preserved cases never - // regress: (1) file-backed/ISO scans reach here with `handshake = None` - // and have already had bus encryption removed at read time; (2) AACS 1.0 - // BD is not bus-encrypted, so `bus_encryption` is false and the gate is - // skipped (its `read_data_key` is legitimately absent). - if bus_encryption && handshake.is_some_and(|h| h.read_data_key.is_none()) { - let h = handshake.expect("is_some_and matched"); + // Also skipped when `handshake = None` (file-backed/ISO scans — bus + // encryption already removed at read time) and when `bus_encryption` is + // false (AACS 1.0 BD is not bus-encrypted). + // ONE question — "is AACS bus encryption gone?" — asked of the single + // `bus_encryption_removed` predicate, which OWNS every case (never had it, + // file/ISO, firmware unlock, cert bus key). The gate enumerates nothing. + if !bus_encryption_removed(bus_encryption, handshake) { + let (rdk_err, has_vid) = handshake + .map(|h| (h.read_data_key_err, h.volume_id != [0u8; 16])) + .unwrap_or((None, false)); tracing::warn!( target: "freemkv::disc", phase = "bus_key_unavailable", - read_data_key_err = ?h.read_data_key_err, - has_volume_id = h.volume_id != [0u8; 16], - "Disc declares bus encryption but the drive served no read_data_key (bus key). \ - read_data_key_err=None ⇒ the unlock path never attempted it (VID-only/OEM); \ - a code ⇒ the bus-key read FAILED. Either way bus encryption can't be removed — \ - refusing to emit a key that would decrypt to garbage." + read_data_key_err = ?rdk_err, + has_volume_id = has_vid, + "Disc declares bus encryption but it could not be removed: no firmware unlocker \ + unlocked the drive AND the cert handshake produced no read_data_key. Refusing to \ + emit a key that would decrypt to garbage." ); return Err(Error::AacsBusKeyUnavailable); } @@ -816,6 +862,7 @@ mod tests { volume_id: vid, read_data_key: Some(rdk), read_data_key_err: None, + drive_unlocked: false, }; let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("state"); assert_eq!(st.volume_id, vid); @@ -861,6 +908,7 @@ mod tests { volume_id: [0x11u8; 16], read_data_key: None, read_data_key_err: None, + drive_unlocked: false, }; let err = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)) .expect_err("bus-encrypted disc with no bus key must hard-error"); @@ -876,6 +924,7 @@ mod tests { volume_id: [0x11u8; 16], read_data_key: Some([0x22u8; 16]), read_data_key_err: None, + drive_unlocked: false, }; let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("bus key present → ok"); assert!(st.bus_encryption); @@ -903,6 +952,7 @@ mod tests { volume_id: [0x11u8; 16], read_data_key: None, read_data_key_err: None, + drive_unlocked: false, }; let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("AACS 1.0 → ok"); assert!(!st.bus_encryption); diff --git a/src/disc/read_error.rs b/src/disc/read_error.rs index 53624b5..6584a17 100644 --- a/src/disc/read_error.rs +++ b/src/disc/read_error.rs @@ -531,13 +531,20 @@ pub fn handle_read_error(err: &Error, ctx: &mut ReadCtx) -> ReadAction { ); if is_wedge_transition { + // NOTE: this is the FIRST escalation into the hardware/illegal-request + // sense family — NOT a confirmed wedge. Drives frequently recover and keep + // reading after one such error (a single bad spot), so calling it a "wedge" + // here over-claims (it sent past investigations chasing a drive ghost). A + // genuine wedge is PERSISTENT — see the `wedge_skip` / WEDGE_ABORT_THRESHOLD + // path below, which only fires after repeated fast-fails with no recovery. tracing::warn!( target: "freemkv::disc", - phase = "wedge_transition", + phase = "fastfail_escalation", errors_in_zone = ctx.total_errors, ms_since_last_success, new_family = ?current_family, - "drive entered wedge / fast-fail family (was returning recoverable medium errors before this)" + "drive escalated into the fast-fail sense family (was returning recoverable medium \ + errors before this) — often transient; only a PERSISTENT run is a real wedge" ); } diff --git a/src/drive/mod.rs b/src/drive/mod.rs index 16fcf1c..3b6873d 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -393,7 +393,7 @@ impl Drive { /// Initialize drive — unlock + firmware upload. /// Optional. Adds features: removes riplock, enables UHD reads, speed control. /// - /// The LibreDrive/OEM firmware unlock is required for BD/UHD (AACS) reads, + /// The firmware/OEM unlock is required for BD/UHD (AACS) reads, /// but it puts the drive in an extended-access state where stock CSS /// authentication no longer works — so a CSS-protected DVD can't be read. /// For a DVD we therefore SKIP the unlock and run the drive in its normal @@ -1245,7 +1245,7 @@ mod command_tests { /// `disc_is_dvd()` must match the DVD profile family (0x0010..=0x001F) /// and ONLY that family. A false positive on a BD/UHD profile (0x0040+) - /// would skip the LibreDrive firmware unlock that UHD reads require; a + /// would skip the firmware unlock that UHD reads require; a /// false negative on a DVD would re-introduce the CSS read failure. The /// Current Profile is bytes 6-7 of the GET CONFIGURATION header. /// Mutation: widening the range to `..=0x0040` makes the BD-ROM assert