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 {
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>,
/// 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 {
@@ -841,6 +844,7 @@ mod tests {
Self {
sectors: HashMap::new(),
bad: std::collections::HashSet::new(),
decrypt_fail: std::collections::HashSet::new(),
}
}
fn put(&mut self, lba: u32, data: [u8; 2048]) {
@@ -872,6 +876,9 @@ mod tests {
sense: None,
});
}
if self.decrypt_fail.contains(&(lba + i)) {
return Err(Error::DecryptFailed);
}
}
for i in 0..count as u32 {
let off = i as usize * 2048;
@@ -1393,6 +1400,64 @@ mod tests {
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.
#[test]
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,
_ => {}
}
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 {
i += 1;
}
@@ -423,7 +425,7 @@ impl Disc {
content_format: ContentFormat::MpegPs,
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
@@ -461,7 +463,7 @@ impl Disc {
content_format: ContentFormat::MpegPs,
codec_privates: Vec::new(),
});
next_id += 1;
next_id = next_id.saturating_add(1);
}
titles
}