v0.18.2: fix AACS nav-file scramble + sweep progress non-regression

decrypt::decrypt_sectors now restores chunks when decrypt_unit_full's
TS-sync verification fails, preventing 0.18.1's silent corruption of
MPLS/CLPI navigation files when DecryptingSectorSource decorates the
sweep reader. Fixes E6009 NoStreams on info iso:// for AACS-encrypted
UHDs ripped without --raw.

Disc::sweep progress takes max(snapshot.bytes_good, bytes_done) so
the user-visible counter never regresses below what the producer has
already sent.
This commit is contained in:
2026-05-09 17:19:47 -07:00
parent 59014fdba5
commit 4700878b73
5 changed files with 94 additions and 4 deletions
+50 -1
View File
@@ -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<u8> = 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"
);
}
}
+10 -1
View File
@@ -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,
),