audit: lock DTS rate table, fix sniff overflow-scan, cover decrypt loss

Round-10 findings from the 10-phase release audit:

- A finder claimed the DTS SFREQ→rate table was wrong at 11/12; verified
  it against ffmpeg's avpriv_dca_sample_rates (12k/24k/48k/96k/192k at
  11-15) — the table is CORRECT. Added a test that locks the full table so
  it can't be mis-"fixed".
- sniff_video_codec advanced 3 bytes after a matched start code, re-reading
  the code byte as an overlapping start code; skip the full 4-byte marker.
- Guard the HD-DVD next_id title counter with saturating_add so a crafted
  disc with >65536 clips can't overflow (panic in debug).
- Add a test that an undecryptable unit (DecryptFailed) is zero-filled and
  counted as loss through ExtractResult (complete=false, bytes_lost>0) —
  the recovery-seam consolidation folded that bucket into bytes_unreadable.
This commit is contained in:
Matthew Jackson
2026-07-09 20:17:12 -07:00
parent 270f9d88b3
commit 640502d5a8
3 changed files with 102 additions and 4 deletions
+66 -1
View File
@@ -832,8 +832,11 @@ mod tests {
struct MemDisc { struct MemDisc {
sectors: HashMap<u32, [u8; 2048]>, sectors: HashMap<u32, [u8; 2048]>,
/// Absolute LBAs that fail to read (bad-sector fixture). /// Absolute LBAs that fail to read (bad-sector fixture → DiscRead).
bad: std::collections::HashSet<u32>, bad: std::collections::HashSet<u32>,
/// Absolute LBAs whose read fails to DECRYPT (no/wrong key fixture →
/// DecryptFailed), exercising the undecryptable-unit loss path.
decrypt_fail: std::collections::HashSet<u32>,
} }
impl MemDisc { impl MemDisc {
@@ -841,6 +844,7 @@ mod tests {
Self { Self {
sectors: HashMap::new(), sectors: HashMap::new(),
bad: std::collections::HashSet::new(), bad: std::collections::HashSet::new(),
decrypt_fail: std::collections::HashSet::new(),
} }
} }
fn put(&mut self, lba: u32, data: [u8; 2048]) { fn put(&mut self, lba: u32, data: [u8; 2048]) {
@@ -872,6 +876,9 @@ mod tests {
sense: None, sense: None,
}); });
} }
if self.decrypt_fail.contains(&(lba + i)) {
return Err(Error::DecryptFailed);
}
} }
for i in 0..count as u32 { for i in 0..count as u32 {
let off = i as usize * 2048; let off = i as usize * 2048;
@@ -1393,6 +1400,64 @@ mod tests {
assert_eq!(res.files[0].bytes_unreadable, good.len() as u64); assert_eq!(res.files[0].bytes_unreadable, good.len() as u64);
} }
/// An UNDECRYPTABLE unit (DecryptFailed — wrong/missing key) is zero-filled
/// and counted as loss through the public API exactly like a bad sector:
/// the recovery-seam consolidation folded the old bytes_undecryptable bucket
/// into bytes_unreadable, and the run must still report complete == false and
/// bytes_lost() > 0 (this gates the CLI exit code / multipass re-run).
#[test]
fn undecryptable_unit_holes_file_and_accounts_loss() {
let good = vec![0x55u8; 4 * 2048];
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "BDMV".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: Vec::new(),
subdirs: vec![DirSpec {
name: "STREAM".to_string(),
icb_lba: 22,
dir_data_lba: 23,
files: vec![file("00001.m2ts", 24, 5000, good.clone(), true)],
subdirs: vec![],
}],
}],
};
let mut disc = build_disc(root);
// The whole extent fails to decrypt (no/wrong key) rather than to read.
for i in 0..4u32 {
disc.decrypt_fail.insert(PART_START + 5000 + i);
}
let out = TmpDir::new("decryptfail");
let res = clear_disc()
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
.expect("extract does not abort on an undecryptable unit");
let got = read_out(out.path(), "BDMV/STREAM/00001.m2ts").expect("file written");
assert_eq!(
got.len(),
good.len(),
"holed file still sized to declared size"
);
assert!(
got.iter().all(|&b| b == 0),
"undecryptable range zero-filled"
);
assert!(
!res.complete,
"an undecryptable unit makes the rip incomplete"
);
assert!(
res.bytes_lost() > 0,
"decrypt loss counted, not reported clean"
);
assert_eq!(res.bytes_unreadable, good.len() as u64);
assert_eq!(res.files[0].bytes_unreadable, good.len() as u64);
}
/// Path sanitization rejects a host-illegal component in a disc file name. /// Path sanitization rejects a host-illegal component in a disc file name.
#[test] #[test]
fn sanitize_rejects_illegal_component() { fn sanitize_rejects_illegal_component() {
+5 -3
View File
@@ -152,7 +152,9 @@ fn sniff_video_codec(es: &[u8]) -> Option<Codec> {
_ if (code & 0x9F) == 0x07 => saw_h264_sps = true, _ if (code & 0x9F) == 0x07 => saw_h264_sps = true,
_ => {} _ => {}
} }
i += 3; // Skip the whole consumed `00 00 01 <code>` marker (4 bytes) so the
// code byte isn't re-read as the start of an overlapping start code.
i += 4;
} else { } else {
i += 1; i += 1;
} }
@@ -423,7 +425,7 @@ impl Disc {
content_format: ContentFormat::MpegPs, content_format: ContentFormat::MpegPs,
codec_privates: Vec::new(), codec_privates: Vec::new(),
}); });
next_id += 1; next_id = next_id.saturating_add(1);
} }
// Every remaining clip is its own title (unchanged behaviour). Iterated in // Every remaining clip is its own title (unchanged behaviour). Iterated in
@@ -461,7 +463,7 @@ impl Disc {
content_format: ContentFormat::MpegPs, content_format: ContentFormat::MpegPs,
codec_privates: Vec::new(), codec_privates: Vec::new(),
}); });
next_id += 1; next_id = next_id.saturating_add(1);
} }
titles titles
} }
+31
View File
@@ -918,6 +918,37 @@ mod tests {
assert_eq!(dts_core_sample_rate(&core), 48_000); assert_eq!(dts_core_sample_rate(&core), 48_000);
} }
#[test]
fn dts_core_sfreq_table_matches_the_dca_spec() {
// Lock the SFREQ → sample-rate table to ffmpeg's authoritative
// `avpriv_dca_sample_rates` (ETSI TS 102 114 Table 6-4). The high-rate
// triad in particular — 48 k / 96 k / 192 k at indices 13/14/15 — must not
// be shifted; a wrong entry would compute an N× frame duration and
// reintroduce PTS drift on a 96/192 kHz DTS stream.
let mut core = make_dts_core(512);
let set_sfreq = |c: &mut [u8], idx: u8| c[8] = (c[8] & !0x3C) | ((idx & 0x0F) << 2);
for (idx, want) in [
(1u8, 8_000u32),
(2, 16_000),
(3, 32_000),
(6, 11_025),
(7, 22_050),
(8, 44_100),
(11, 12_000),
(12, 24_000),
(13, 48_000),
(14, 96_000),
(15, 192_000),
] {
set_sfreq(&mut core, idx);
assert_eq!(
dts_core_sample_rate(&core),
want,
"SFREQ {idx} must be {want} Hz"
);
}
}
#[test] #[test]
fn new_pes_rebases_to_its_own_pts_no_drift() { fn new_pes_rebases_to_its_own_pts_no_drift() {
// Regression for the drift bug: a global running clock overshot a // Regression for the drift bug: a global running clock overshot a