diff --git a/src/disc/extract.rs b/src/disc/extract.rs index e70c6d9..fe99a17 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -832,8 +832,11 @@ mod tests { struct MemDisc { sectors: HashMap, - /// Absolute LBAs that fail to read (bad-sector fixture). + /// Absolute LBAs that fail to read (bad-sector fixture → DiscRead). bad: std::collections::HashSet, + /// Absolute LBAs whose read fails to DECRYPT (no/wrong key fixture → + /// DecryptFailed), exercising the undecryptable-unit loss path. + decrypt_fail: std::collections::HashSet, } 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() { diff --git a/src/disc/hddvd.rs b/src/disc/hddvd.rs index 065ec1c..e570c94 100644 --- a/src/disc/hddvd.rs +++ b/src/disc/hddvd.rs @@ -152,7 +152,9 @@ fn sniff_video_codec(es: &[u8]) -> Option { _ if (code & 0x9F) == 0x07 => saw_h264_sps = true, _ => {} } - i += 3; + // Skip the whole consumed `00 00 01 ` 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 } diff --git a/src/mux/codec/dts.rs b/src/mux/codec/dts.rs index ee74a03..d484ba6 100644 --- a/src/mux/codec/dts.rs +++ b/src/mux/codec/dts.rs @@ -918,6 +918,37 @@ mod tests { 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] fn new_pes_rebases_to_its_own_pts_no_drift() { // Regression for the drift bug: a global running clock overshot a