diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ca213e..b5ddad1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## 0.18.2 (2026-05-09) + +### Bug fixes + +- **Nav-file scramble during AACS rip** (`decrypt::decrypt_sectors`). 0.18's + `DecryptingSectorSource` decorator broadened the call surface of + `decrypt_sectors` to every sector flowing through sweep, including UDF + navigation files (MPLS playlists, CLPI clip-info). The byte-0 heuristic + in `aacs::is_unit_encrypted` correctly fires on m2ts source-packet copy + markers but false-positives on any binary file whose first byte happens + to have the top 2 bits set — most notably MPLS files (start with 'M' = + 0x4D) and CLPI files (start with 'H' = 0x48). `decrypt_unit_full` + already self-checks the result via TS-sync verification and returns + `false` on a misfire, but the chunk had been mutated by then; + `decrypt_sectors` discarded the return value, leaving scrambled bytes + in the ISO. Fix: snapshot the chunk before decryption and restore on + verification failure (same pattern `decrypt_unit_try_keys` already used + for multi-key discs). Symptom: `freemkv info iso://UHD.iso` and + `iso:// → mkv://` returned E6009 NoStreams on freshly-ripped UHD ISOs; + affected only the iso-source path, not the disc-source path or the + m2ts video payload itself. Regression test: + `decrypt::tests::nav_file_unit_survives_decrypt_attempt`. + +- **Sweep progress display can regress to zero** (`Disc::sweep`). The + consumer-side `bytes_good` snapshot lags producer-side `bytes_done` + whenever the consumer is behind on draining the work channel. Until + the first snapshot arrived, the placeholder branch reported + `bytes_done` correctly; once a stale snapshot landed, the report + switched to `snap.bytes_good` and could regress below `bytes_done`. + Fix: `bytes_good_total = max(snap.bytes_good, bytes_done)`, so the + user-visible counter never moves backward. + ## 0.18.1 (2026-05-09) ### I/O stack redesign — primitives over orchestration diff --git a/Cargo.toml b/Cargo.toml index 19ebcf7..8e14979 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libfreemkv" -version = "0.18.1" +version = "0.18.2" edition = "2024" rust-version = "1.86" license = "AGPL-3.0-only" diff --git a/README.md b/README.md index cca3067..cbd027d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Part of the [freemkv](https://github.com/freemkv) project. ```toml [dependencies] -libfreemkv = "0.17" +libfreemkv = "0.18" ``` ## Quick Start diff --git a/src/decrypt.rs b/src/decrypt.rs index f8e3b2c..b8131d9 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -62,7 +62,23 @@ pub fn decrypt_sectors( for chunk in buf.chunks_mut(unit_len) { if chunk.len() == unit_len && aacs::is_unit_encrypted(chunk) { - aacs::decrypt_unit_full(chunk, &uk, rdk); + // `is_unit_encrypted` is a byte-0 heuristic: it fires on any + // unit whose first byte has the top 2 bits set, which is + // correct for m2ts source packets (where those bits are the + // copy-control marker) but false-positives on any other binary + // data with similarly-shaped first bytes — notably MPLS/CLPI + // navigation files that begin with ASCII magic ('M', 'H'…) + // and survive sweep mixed in with encrypted m2ts payloads. + // `decrypt_unit_full` self-checks via TS-sync verification and + // returns false on a misfire, but it has already mutated the + // chunk by then. Snapshot and restore on verification failure + // — same pattern `decrypt_unit_try_keys` uses for multi-key + // discs. Real m2ts units verify and stay decrypted; nav-file + // sectors get scrambled briefly and then put back as-was. + let original: Vec = chunk.to_vec(); + if !aacs::decrypt_unit_full(chunk, &uk, rdk) { + chunk.copy_from_slice(&original); + } } } } @@ -74,3 +90,36 @@ pub fn decrypt_sectors( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit whose + /// first byte has the top 2 bits set (here: the ASCII letter 'M' that + /// MPLS files start with, 0x4D = 0b01001101) trips `is_unit_encrypted`, + /// gets AES-decrypted with the unit key, fails the TS-sync verification, + /// and must be restored to its original bytes — not left scrambled. + #[test] + fn nav_file_unit_survives_decrypt_attempt() { + let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN]; + unit[0] = b'M'; + unit[1] = b'P'; + unit[2] = b'L'; + unit[3] = b'S'; + for (i, b) in unit.iter_mut().enumerate().skip(4) { + *b = (i as u8).wrapping_mul(31); + } + let snapshot = unit.clone(); + + let keys = DecryptKeys::Aacs { + unit_keys: vec![(0, [0xAB; 16])], + read_data_key: None, + }; + decrypt_sectors(&mut unit, &keys, 0).unwrap(); + assert_eq!( + unit, snapshot, + "non-m2ts unit must be restored after failed decrypt" + ); + } +} diff --git a/src/disc/mod.rs b/src/disc/mod.rs index f08c701..8cefe11 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -1781,9 +1781,18 @@ impl Disc { .unwrap_or(0), None => 0, }; + // The consumer's snapshot is the source of truth for + // bytes_unreadable / bytes_pending (the producer doesn't + // see them), but its bytes_good lags producer-side + // `bytes_done` whenever the consumer is behind on draining + // the work channel. Take the max so the user-visible + // counter never regresses below what the producer has + // already sent — Anomaly B in the 0.18.1 prod test was + // this regression: a stale early snapshot pinned the + // display to 0 GB while bytes_done was already advancing. let (bytes_good, bytes_unreadable, bytes_pending) = match &cached_snapshot { Some(snap) => ( - snap.stats.bytes_good, + snap.stats.bytes_good.max(bytes_done), snap.stats.bytes_unreadable, snap.stats.bytes_pending, ),