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:
@@ -1,5 +1,37 @@
|
|||||||
# Changelog
|
# 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)
|
## 0.18.1 (2026-05-09)
|
||||||
|
|
||||||
### I/O stack redesign — primitives over orchestration
|
### I/O stack redesign — primitives over orchestration
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "libfreemkv"
|
name = "libfreemkv"
|
||||||
version = "0.18.1"
|
version = "0.18.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.86"
|
rust-version = "1.86"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Part of the [freemkv](https://github.com/freemkv) project.
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
libfreemkv = "0.17"
|
libfreemkv = "0.18"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|||||||
+50
-1
@@ -62,7 +62,23 @@ pub fn decrypt_sectors(
|
|||||||
|
|
||||||
for chunk in buf.chunks_mut(unit_len) {
|
for chunk in buf.chunks_mut(unit_len) {
|
||||||
if chunk.len() == unit_len && aacs::is_unit_encrypted(chunk) {
|
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(())
|
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
@@ -1781,9 +1781,18 @@ impl Disc {
|
|||||||
.unwrap_or(0),
|
.unwrap_or(0),
|
||||||
None => 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 {
|
let (bytes_good, bytes_unreadable, bytes_pending) = match &cached_snapshot {
|
||||||
Some(snap) => (
|
Some(snap) => (
|
||||||
snap.stats.bytes_good,
|
snap.stats.bytes_good.max(bytes_done),
|
||||||
snap.stats.bytes_unreadable,
|
snap.stats.bytes_unreadable,
|
||||||
snap.stats.bytes_pending,
|
snap.stats.bytes_pending,
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user