diff --git a/src/disc/bluray.rs b/src/disc/bluray.rs index d725463..cbaa8f5 100644 --- a/src/disc/bluray.rs +++ b/src/disc/bluray.rs @@ -441,6 +441,18 @@ mod tests { out.extend_from_slice(&attrs); out } + /// HEVC video stream entry carrying the third (HDR) attribute byte: + /// high nibble = dynamic_range, low nibble = color_space (mpls.rs only + /// parses this byte for coding_type == HEVC and sa.len() > 2). + fn se_video_hevc(pid: u16, dynamic_range: u8, color_space: u8) -> Vec { + let mut out = vec![3u8, 0x01]; + out.extend_from_slice(&pid.to_be_bytes()); + let hdr_byte = (dynamic_range << 4) | color_space; + let attrs = vec![0x24u8, 0x10, hdr_byte]; // coding_type = HEVC + out.push(attrs.len() as u8); + out.extend_from_slice(&attrs); + out + } /// Build an MPLS playlist. `stn_counts` = (video, audio, pg, ig, /// sec_audio, sec_video, pip_pg, dv); `stream_entries` are appended on @@ -698,6 +710,77 @@ mod tests { udf::read_filesystem(disc).expect("fs") } + /// Full BDMV with a real Blu-ray 3D layout: `.ssif` files under + /// `BDMV/STREAM/SSIF/.ssif` (note the SSIF subdirectory, unlike + /// [`make_bdmv_fs_ext`]) plus a matching `.clpi` in CLIPINF. Resolving + /// the SSIF is what latches `is_3d = true` in `parse_playlist`. + fn make_bdmv_fs_ssif( + disc: &mut MemDisc, + clips: &[( + &str, + u32, /*sectors*/ + u32, /*packets*/ + u32, /*data_lba*/ + )], + ) -> udf::UdfFs { + let mut ssif_files = Vec::new(); + let mut clipinf_files = Vec::new(); + let mut icb = 200u32; + for (name, sectors, packets, data_lba) in clips { + let ssif = format!("{name}.ssif"); + let size = sectors * 2048; + ssif_files.push(file(&ssif, icb, *data_lba, size, true)); + icb += 1; + let clpi = format!("{name}.clpi"); + clipinf_files.push(file_with( + &clpi, + icb, + *data_lba + 1000, + build_clpi(*packets), + false, + )); + icb += 1; + } + let bdmv = DirSpec { + name: "BDMV".to_string(), + icb_lba: 40, + dir_data_lba: 41, + files: Vec::new(), + subdirs: vec![ + DirSpec { + name: "STREAM".to_string(), + icb_lba: 42, + dir_data_lba: 43, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "SSIF".to_string(), + icb_lba: 44, + dir_data_lba: 45, + files: ssif_files, + subdirs: vec![], + }], + }, + DirSpec { + name: "CLIPINF".to_string(), + icb_lba: 46, + dir_data_lba: 47, + files: clipinf_files, + subdirs: vec![], + }, + ], + }; + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![bdmv], + }; + build_udf_skeleton(disc, 10); + lay_dir(disc, &root); + udf::read_filesystem(disc).expect("fs") + } + /// Single-clip playlist: size_bytes = source_packets * 192 and the /// physical extent is pulled from the m2ts Long-AD ICB. Per bluray.rs: /// `total_size += pkt_count * 192`; extents from file_extents. @@ -727,6 +810,33 @@ mod tests { assert_eq!(t.clips[0].source_packets, 4000); } + /// Each Clip's `duration_secs` is `(out_time - in_time) / 45000` (the BD + /// 45kHz playback clock). Uses a duration (75s) whose ticks are not a + /// multiple of any small constant, so a `*` or `%` in place of `/` would + /// produce a wildly different (or non-matching) value instead of 75.0. + #[test] + fn parse_playlist_clip_duration_secs_computed_from_ticks() { + let mut disc = MemDisc::new(); + let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]); + let mpls = build_mpls( + &[PiSpec { + clip_id: *b"00001", + in_time: 45000, + out_time: 45000 + 75 * 45000, // 75s clip + }], + (0, 0, 0, 0, 0, 0, 0, 0), + &[], + &[], + ); + let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title"); + assert_eq!(t.clips.len(), 1); + assert!( + (t.clips[0].duration_secs - 75.0).abs() < 1e-6, + "clip duration_secs must be ticks/45000 seconds, got {}", + t.clips[0].duration_secs + ); + } + /// AACS 2.1: the feature clip is `00001.fmts`, NOT `.m2ts`. The /// [`CLIP_STREAM_EXTS`] fallback in `parse_playlist` must still resolve the /// physical extent — before the fix the hard-coded `.m2ts` path errored, @@ -995,6 +1105,66 @@ mod tests { assert_eq!(videos[0].codec, Codec::Hevc); } + /// HEVC HDR byte (sa[2]): high nibble = dynamic_range, low nibble = + /// color_space. dynamic_range 1 -> HDR10, color_space 2 -> BT.2020 + /// (bluray.rs `match s.dynamic_range { 1 => Hdr10, ... }` / + /// `match s.color_space { 2 => Bt2020, ... }`). + #[test] + fn parse_playlist_maps_hdr10_bt2020_from_hevc_nibbles() { + let mut disc = MemDisc::new(); + let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]); + let mpls = build_mpls( + &[PiSpec { + clip_id: *b"00001", + in_time: 0, + out_time: 60 * 45000, + }], + (1, 0, 0, 0, 0, 0, 0, 0), + &[se_video_hevc(0x1011, 1, 2)], + &[], + ); + let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title"); + let v = t + .streams + .iter() + .find_map(|s| match s { + Stream::Video(v) => Some(v), + _ => None, + }) + .expect("video stream"); + assert_eq!(v.hdr, HdrFormat::Hdr10); + assert_eq!(v.color_space, ColorSpace::Bt2020); + } + + /// dynamic_range 2 -> DolbyVision, color_space 1 -> BT.709: the other + /// pair of named arms in the same two match expressions. + #[test] + fn parse_playlist_maps_dolby_vision_bt709_from_hevc_nibbles() { + let mut disc = MemDisc::new(); + let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]); + let mpls = build_mpls( + &[PiSpec { + clip_id: *b"00001", + in_time: 0, + out_time: 60 * 45000, + }], + (1, 0, 0, 0, 0, 0, 0, 0), + &[se_video_hevc(0x1011, 2, 1)], + &[], + ); + let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title"); + let v = t + .streams + .iter() + .find_map(|s| match s { + Stream::Video(v) => Some(v), + _ => None, + }) + .expect("video stream"); + assert_eq!(v.hdr, HdrFormat::DolbyVision); + assert_eq!(v.color_space, ColorSpace::Bt709); + } + /// A PGS coding_type (0x90) sitting in the AUDIO STN slot is a /// misaligned-stream guard case: bluray.rs routes it to Subtitle, not /// Audio (`if matches!(codec, Codec::Pgs)`). Wrong-title regression @@ -1055,6 +1225,43 @@ mod tests { assert_eq!(audios.len(), 1); assert_eq!(audios[0].codec, Codec::Ac3); assert_eq!(audios[0].language, "eng"); + assert!( + !audios[0].secondary, + "a primary (stream_type 2) audio entry must not be marked secondary" + ); + } + + /// A secondary-audio STN entry (stream_type 5, e.g. a director's + /// commentary track) must set `AudioStream::secondary` (bluray.rs + /// `secondary: s.stream_type == 5`). + #[test] + fn parse_playlist_secondary_audio_flag_set_for_stream_type_5() { + let mut disc = MemDisc::new(); + let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]); + let mpls = build_mpls( + &[PiSpec { + clip_id: *b"00001", + in_time: 0, + out_time: 60 * 45000, + }], + (0, 0, 0, 0, 1, 0, 0, 0), // one secondary-audio (stream_type 5) entry + &[se_audio(0x1a00, 0x83, b"eng")], + &[], + ); + let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title"); + let audios: Vec<_> = t + .streams + .iter() + .filter_map(|s| match s { + Stream::Audio(a) => Some(a), + _ => None, + }) + .collect(); + assert_eq!(audios.len(), 1); + assert!( + audios[0].secondary, + "stream_type 5 (secondary audio) must set AudioStream::secondary" + ); } /// stream_type 3 PG (PGS 0x90) → Stream::Subtitle with language. @@ -1086,6 +1293,85 @@ mod tests { assert_eq!(subs[0].language, "fra"); } + // --------------------------------------------------------------- + // Tests: Blu-ray 3D dependent-view stream + // --------------------------------------------------------------- + + /// When a clip resolves via `STREAM/SSIF/.ssif`, `is_3d` latches + /// and a synthetic MVC dependent-view video stream is added at + /// `base_pid + 1` (bluray.rs's 3D block). Verifies all three fields set + /// on the synthesized `VideoStream`: `pid`, `secondary`, `label`. + #[test] + fn parse_playlist_3d_adds_dependent_view_stream() { + let mut disc = MemDisc::new(); + let udf = make_bdmv_fs_ssif(&mut disc, &[("00001", 1000, 4000, 5000)]); + let mpls = build_mpls( + &[PiSpec { + clip_id: *b"00001", + in_time: 0, + out_time: 60 * 45000, + }], + (1, 0, 0, 0, 0, 0, 0, 0), + // Base (left-eye) view only -- STN table omits the dependent view. + &[se_video(0x1011, 0x1B)], + &[], + ); + let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title"); + let videos: Vec<_> = t + .streams + .iter() + .filter_map(|s| match s { + Stream::Video(v) => Some(v), + _ => None, + }) + .collect(); + assert_eq!( + videos.len(), + 2, + "a 3D title must add one dependent-view video stream" + ); + let dep = videos + .iter() + .find(|v| v.pid == 0x1012) + .expect("dependent-view stream at base_pid + 1"); + assert!(dep.secondary, "dependent view must be marked secondary"); + assert_eq!( + dep.label, + crate::disc::MVC_DEPENDENT_LABEL, + "dependent view must carry the MVC dependent-view label" + ); + } + + /// If the STN table already lists a video stream at `base_pid + 1` + /// (e.g. an authoring tool that populated STN_table_SS), the synthetic + /// push must be skipped -- never duplicate an existing dependent-view + /// entry (bluray.rs `if !have_dep`). + #[test] + fn parse_playlist_3d_does_not_duplicate_existing_dependent_stream() { + let mut disc = MemDisc::new(); + let udf = make_bdmv_fs_ssif(&mut disc, &[("00001", 1000, 4000, 5000)]); + let mpls = build_mpls( + &[PiSpec { + clip_id: *b"00001", + in_time: 0, + out_time: 60 * 45000, + }], + (1, 0, 0, 0, 0, 1, 0, 0), // primary video + secondary (PiP) video + &[se_video(0x1011, 0x1B), se_video(0x1012, 0x1B)], + &[], + ); + let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title"); + let dep_count = t + .streams + .iter() + .filter(|s| matches!(s, Stream::Video(v) if v.pid == 0x1012)) + .count(); + assert_eq!( + dep_count, 1, + "an already-present stream at base_pid + 1 must not be duplicated" + ); + } + // --------------------------------------------------------------- // Tests: chapters // --------------------------------------------------------------- @@ -1177,6 +1463,47 @@ mod tests { ); } + /// The within-PlayItem offset is `(timestamp - pi.in_time) / 45000` + /// ticks-to-seconds. Uses a non-zero, non-round offset (5s) added to a + /// non-zero `preceding` (60s) so a `*` or `%` in place of `/` would not + /// coincidentally produce the same total (bluray.rs `within = ... / + /// 45000.0`). + #[test] + fn parse_playlist_chapter_within_offset_divides_ticks_to_seconds() { + let mut disc = MemDisc::new(); + let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]); + let pi1_in = 10 * 45000u32; + let within_ticks = 5 * 45000u32; // 5s into PI1 + let mpls = build_mpls( + &[ + PiSpec { + clip_id: *b"00001", + in_time: 0, + out_time: 60 * 45000, // PI0 lasts 60s + }, + PiSpec { + clip_id: *b"00001", + in_time: pi1_in, + out_time: pi1_in + 60 * 45000, + }, + ], + (0, 0, 0, 0, 0, 0, 0, 0), + &[], + &[MarkSpec { + mark_type: 1, + play_item_ref: 1, + timestamp: pi1_in + within_ticks, + }], + ); + let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title"); + assert_eq!(t.chapters.len(), 1); + assert!( + (t.chapters[0].time_secs - 65.0).abs() < 1e-6, + "chapter time must be preceding(60s) + within(5s) = 65s, got {}", + t.chapters[0].time_secs + ); + } + /// A mark whose timestamp precedes its PlayItem's in_time would yield a /// negative within-offset; bluray.rs clamps the chapter to 0.0 (`if /// time_secs < 0.0 { 0.0 }`). Never emits a negative chapter time. @@ -1279,6 +1606,35 @@ mod tests { assert_eq!(t.playlist_id, 0); } + /// A filename that is long enough (>= 5 bytes) but does NOT end in + /// ".mpls" must NOT have its last 5 bytes stripped -- the whole string + /// is handed to the numeric parse instead, which fails and falls back + /// to playlist_id 0 (bluray.rs `filename.len() >= 5 && + /// filename[len-5..].eq_ignore_ascii_case(".mpls")`). + #[test] + fn parse_playlist_id_falls_back_to_zero_when_suffix_is_not_mpls() { + let mut disc = MemDisc::new(); + let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]); + let mpls = build_mpls( + &[PiSpec { + clip_id: *b"00001", + in_time: 0, + out_time: 60 * 45000, + }], + (0, 0, 0, 0, 0, 0, 0, 0), + &[], + &[], + ); + // "00800zzzzz": stripping the last 5 bytes would leave "00800" (a + // valid u16), but the suffix isn't ".mpls" so nothing may be + // stripped -- the whole (non-numeric) string must fail to parse. + let t = Disc::parse_playlist(&mut disc, &udf, "00800zzzzz", &mpls).expect("title"); + assert_eq!( + t.playlist_id, 0, + "a filename not ending in .mpls must not have its last 5 bytes stripped" + ); + } + // --------------------------------------------------------------- // Tests: scan_bluray_titles // --------------------------------------------------------------- @@ -1357,6 +1713,56 @@ mod tests { assert_eq!(titles[0].playlist_id, 800); } + /// A non-directory PLAYLIST entry whose name does NOT end in ".mpls" + /// must be skipped even though its content parses as a perfectly good + /// (long) MPLS playlist -- extension gating, not content sniffing, + /// decides eligibility (bluray.rs `!entry.is_dir && + /// entry.name...ends_with(".mpls")`). + #[test] + fn scan_bluray_titles_skips_non_mpls_extension_file() { + let mut disc = MemDisc::new(); + let mpls = build_mpls( + &[PiSpec { + clip_id: *b"00001", + in_time: 0, + out_time: 7200 * 45000, // 2h -- easily long enough to be kept + }], + (0, 0, 0, 0, 0, 0, 0, 0), + &[], + &[], + ); + let playlist = DirSpec { + name: "PLAYLIST".to_string(), + icb_lba: 26, + dir_data_lba: 27, + files: vec![file_with("00800.dat", 104, 30000, mpls, false)], + subdirs: vec![], + }; + let bdmv = DirSpec { + name: "BDMV".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files: Vec::new(), + subdirs: vec![playlist], + }; + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![bdmv], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = udf::read_filesystem(&mut disc).expect("fs"); + + let titles = Disc::scan_bluray_titles(&mut disc, &udf); + assert!( + titles.is_empty(), + "a PLAYLIST entry not ending in .mpls must be skipped regardless of content" + ); + } + /// With no PLAYLIST directory, scan_bluray_titles returns an empty /// vec (the `find_dir` is None) — never panics. #[test] @@ -1464,6 +1870,52 @@ mod tests { } } + /// A non-.xml file must be ignored even if its content looks like a + /// valid meta XML (contains a ``) -- extension gating, not + /// content sniffing, decides eligibility (bluray.rs `!e.is_dir && + /// e.name...ends_with(".xml")`). + #[test] + fn read_meta_title_ignores_non_xml_file_regardless_of_content() { + let mut disc = MemDisc::new(); + let bogus = b"Should Not Be Used".to_vec(); + let dl = DirSpec { + name: "DL".to_string(), + icb_lba: 30, + dir_data_lba: 31, + files: vec![file_with("bdmt_eng.txt", 104, 50000, bogus, false)], + subdirs: vec![], + }; + let meta = DirSpec { + name: "META".to_string(), + icb_lba: 28, + dir_data_lba: 29, + files: Vec::new(), + subdirs: vec![dl], + }; + let bdmv = DirSpec { + name: "BDMV".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files: Vec::new(), + subdirs: vec![meta], + }; + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![bdmv], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = udf::read_filesystem(&mut disc).expect("fs"); + assert_eq!( + Disc::read_meta_title(&mut disc, &udf), + None, + "a non-.xml file must be ignored even if its content looks like valid meta XML" + ); + } + /// No META directory → None. #[test] fn read_meta_title_no_meta_dir_is_none() { diff --git a/src/disc/dvd.rs b/src/disc/dvd.rs index 2d894f0..7dd6d4d 100644 --- a/src/disc/dvd.rs +++ b/src/disc/dvd.rs @@ -1320,4 +1320,51 @@ mod tests { // Chapter 0 stays at 0.0 (no shift). assert!((t.chapters[0].time_secs - 0.0).abs() < 0.01); } + + /// Audio PID fallback (dvd.rs `Disc::scan_dvd_titles`): when an audio + /// stream has no on-wire private_stream_1 sub-stream id — MP1/MP2 audio, + /// per `ifo::assign_audio_sub_stream_ids` — the PID falls back to + /// `0xBD00 + i` where `i` is the stream's positional index in the IFO + /// audio-attribute table. Two MPEG-audio (coding_mode 2) streams must + /// land on two DISTINCT, correctly-offset PIDs: 0xBD00 and 0xBD01. This + /// pins the `+` (not `-`/`*`) so the second stream doesn't collide with, + /// or wrap under, the first. + #[test] + fn scan_dvd_titles_mp2_audio_pid_fallback_is_additive() { + let mut disc = MemDisc::new(); + let vmg = build_vmg(&[(1, 1, 1)]); + // coding_mode bits are b0>>5 & 0x7; mode 2 = MPEG-1 Layer II (Mp2), + // which `assign_audio_sub_stream_ids` leaves at `sub_stream_id: None`. + // b0 = 0b010_00000 = 0x40. b1 = 0 (mono, sample rate 48k). + let audio = [(0x40u8, 0x00u8, [0u8, 0u8]), (0x40u8, 0x00u8, [0u8, 0u8])]; + let vts = build_vts(1000, 0x00, &audio, &[], &[(10, 109)], false); + let udf = build_video_ts_fs( + &mut disc, + &[ + FileSpec { + name: "VIDEO_TS.IFO".into(), + icb_lba: 60, + data_lba: 5000, + contents: vmg, + }, + FileSpec { + name: "VTS_01_0.IFO".into(), + icb_lba: 62, + data_lba: 6000, + contents: vts, + }, + ], + ); + let titles = Disc::scan_dvd_titles(&mut disc, &udf); + let t = &titles[0]; + let audio_pids: Vec = t + .streams + .iter() + .filter_map(|s| match s { + Stream::Audio(a) => Some(a.pid), + _ => None, + }) + .collect(); + assert_eq!(audio_pids, vec![0xBD00u16, 0xBD01u16]); + } } diff --git a/src/disc/dvd_audio_probe.rs b/src/disc/dvd_audio_probe.rs index 0670da8..47c8d1f 100644 --- a/src/disc/dvd_audio_probe.rs +++ b/src/disc/dvd_audio_probe.rs @@ -242,7 +242,11 @@ pub fn probe_and_remap( #[cfg(test)] mod tests { use super::*; - use crate::disc::{AudioChannels, AudioStream, Codec, LabelPurpose, SampleRate}; + use crate::disc::{ + AudioChannels, AudioStream, Codec, ContentFormat, DiscTitle, Extent, LabelPurpose, + SampleRate, + }; + use crate::sector::SectorSource; /// Build a single, correctly-SIZED AC-3 frame whose `acmod`/`lfeon` encode a /// known channel count. `byte4` is `fscod=0 | frmsizecod=0`, so @@ -463,4 +467,168 @@ mod tests { }; assert_eq!(a.pid, 0xBD80, "no probe data → keep ordinal"); } + + /// `max_substream_channels` must locate the sync at its true ABSOLUTE + /// position (`pos + rel`) when it is preceded by non-sync bytes, not just + /// when the sync sits at offset 0. Regression guard for a hand-checked + /// mutation (`+` → `-` at the `pos + rel` offset computation): with `pos` + /// starting at 0 and the first sync found 3 bytes in, `pos - rel` would + /// underflow a `usize` and panic, or (if it somehow didn't) index the + /// wrong start entirely. `pos + rel` is the only computation that is + /// always in-bounds, since `rel` is itself bounded by the length of the + /// slice searched from `pos`. + #[test] + fn max_substream_channels_locates_sync_after_leading_non_sync_bytes() { + let mut data = vec![0xAA, 0xAA, 0xAA]; // no 0x0B77 pattern in here + data.extend(ac3_frame(2, false)); // real 2.0 frame, sync at absolute offset 3 + assert_eq!( + max_substream_channels(&data), + Some(2), + "must find and decode the frame whose sync is NOT at offset 0" + ); + } + + /// When an AC-3 header's `fscod`/`frmsizecod` is unmappable (reserved + /// `fscod == 3`), `max_substream_channels` must fall back to stepping + /// `start + 2` bytes past the sync to re-lock onto the next genuine sync, + /// and must keep making forward progress doing so (never revisit the same + /// sync, which would loop forever, and never jump so far that it skips + /// the very next real frame). This lays a bogus-sized header at absolute + /// offset 4 (so `start == 4`, `start + 2 == 6`) immediately followed, at + /// offset 6, by a real, fully decodable 2.0 frame — the position the + /// `+ 2` fallback must land on exactly. + #[test] + fn max_substream_channels_unmappable_size_steps_forward_by_two() { + let mut real = ac3_frame(2, false); + // Overwrite the (unchecked) CRC bytes of the real frame — these double + // as byte4/byte5 of the bogus header 2 bytes earlier, at absolute + // offset 4: byte4 = 0xC0 (fscod=3 reserved -> ac3_frame_size == 0, + // unmappable), byte5 = 0xF8 (bsid=31 >= 11 -> acmod_channels == None, + // so the bogus header itself never contributes a spurious channel + // count). + real[2] = 0xC0; + real[3] = 0xF8; + let mut data = vec![0xAA, 0xAA, 0xAA, 0xAA]; // offsets 0..4, no sync + data.push(0x0B); // offset 4: bogus header sync byte 0 + data.push(0x77); // offset 5: bogus header sync byte 1 + data.extend(real); // offset 6..: the real frame (also serves as the + // bogus header's byte4/byte5 at offsets 8/9) + assert_eq!( + max_substream_channels(&data), + Some(2), + "must recover the real frame 2 bytes after the unmappable-size sync, not lose it" + ); + } + + /// Same fallback as above, but with the unmappable-size sync at absolute + /// offset 0 (`start == 0`) so that stepping backward instead of forward + /// (`start - 2`) would underflow rather than merely land on the wrong + /// byte. Also proves the real frame is still found 6 bytes further in, + /// confirming forward progress past the bogus header. + #[test] + fn max_substream_channels_unmappable_size_at_start_steps_forward_not_back() { + let mut data = vec![0x0B, 0x77, 0x00, 0x00, 0xC0, 0xF8]; // bogus header, offsets 0..6 + data.extend(ac3_frame(2, false)); // real 2.0 frame at offset 6 + assert_eq!( + max_substream_channels(&data), + Some(2), + "must step forward past the bogus header at offset 0 and find the real frame at offset 6" + ); + } + + /// `remap_audio_pids` must read a stream's CURRENT physical sub-stream id + /// from the low byte of its PID via `pid & 0x00FF` — not `|` or `^` with + /// `0x00FF`, both of which force the low byte to `0xFF` regardless of the + /// real PID and so always miss the "already matches" shortcut. That + /// matters observably when TWO physical sub-streams share the same probed + /// channel count: with a correct read, a stream already sitting on a + /// matching sub-stream is left alone (conservative, per the module's + /// documented behaviour); with the low byte forced to `0xFF`, + /// `probed.get(&0xFF)` is always `None`, so the code falls through to the + /// "find any unclaimed match" path and picks the FIRST (lowest-keyed, + /// BTreeMap-ordered) matching physical sub-stream instead — which here is + /// a *different* sub-stream (0x80) than the one the PID already correctly + /// names (0x81), producing a spurious PID change. + #[test] + fn remap_reads_current_substream_via_and_not_or_or_xor() { + let mut probed = BTreeMap::new(); + probed.insert(0x80u8, 6u8); + probed.insert(0x81u8, 6u8); // ambiguous: two physical 6ch sub-streams + let mut streams = vec![ac3_stream(0xBD81, AudioChannels::Surround51)]; + let changed = remap_audio_pids(&mut streams, &probed); + assert_eq!( + changed, 0, + "already sitting on a matching physical sub-stream (0x81) must be left alone" + ); + let Stream::Audio(a) = &streams[0] else { + panic!() + }; + assert_eq!( + a.pid, 0xBD81, + "must not be bumped to the other matching sub-stream (0x80)" + ); + } + + /// A `SectorSource` stub that hands back fixed bytes regardless of the + /// requested LBA/count, for exercising `probe_and_remap`'s end-to-end + /// wiring (format/AC-3/extent/count guards -> read -> probe -> remap). + struct FixedSource { + data: Vec, + } + + impl SectorSource for FixedSource { + fn read_sectors( + &mut self, + _lba: u32, + _count: u16, + buf: &mut [u8], + _recovery: bool, + ) -> crate::error::Result { + let n = self.data.len().min(buf.len()); + buf[..n].copy_from_slice(&self.data[..n]); + Ok(n) + } + } + + /// End-to-end `probe_and_remap`: a Silence-of-the-Lambs-shaped MpegPs + /// title (one declared 5.1 AC-3 stream ordinally assigned 0x80) whose + /// physical VOB bytes carry the 2.0 down-mix on 0x80 and the real 5.1 on + /// 0x81. This must reach the `remap_audio_pids` call and re-route the + /// stream to 0xBD81. It also, by construction, proves each of the guards + /// along the way lets a real, positive case through: the content-format + /// check must NOT bail on `MpegPs` (only on non-`MpegPs`), the AC-3 + /// presence check must NOT bail when AC-3 IS present, and the + /// sector-count check must NOT bail when the count is nonzero — any one + /// of those inverted would skip the probe entirely and leave the PID at + /// its untouched ordinal value (0xBD80), which the assertion below would + /// catch. + #[test] + fn probe_and_remap_reroutes_silence_of_the_lambs_scenario_end_to_end() { + let mut bytes = ps_ac3(0x80, 2, false); // physical 0x80 = 2.0 down-mix + bytes.extend(ps_ac3(0x81, 7, true)); // physical 0x81 = 5.1 main mix + let mut title = DiscTitle { + playlist: "00001.ifo".into(), + playlist_id: 1, + duration_secs: 60.0, + size_bytes: bytes.len() as u64, + clips: Vec::new(), + streams: vec![ac3_stream(0xBD80, AudioChannels::Surround51)], + chapters: Vec::new(), + extents: vec![Extent { + start_lba: 0, + sector_count: 2, + }], + content_format: ContentFormat::MpegPs, + codec_privates: vec![None], + }; + let mut source = FixedSource { data: bytes }; + probe_and_remap(&mut source, &mut title); + let Stream::Audio(a) = &title.streams[0] else { + panic!("audio") + }; + assert_eq!( + a.pid, 0xBD81, + "declared 5.1 stream must be re-routed to the physical 5.1 sub-stream 0x81" + ); + } } diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index c8f7a3a..c146223 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -986,6 +986,87 @@ mod tests { assert_eq!(st.uk_ro, uk); } + // --------------------------------------------------------------- + // Minimal hand-rolled `tracing::Subscriber` used ONLY to capture the + // `has_volume_id` boolean field off the `bus_key_unavailable` warn event. + // That field is diagnostic-only (never read back into control flow), so + // it is otherwise invisible to `#[test]` assertions on the returned + // `Result`. No `tracing-subscriber` dev-dependency exists in this crate, + // hence the manual `Subscriber` impl instead of a capture layer. + // --------------------------------------------------------------- + + struct HasVidCapture(std::sync::Mutex>); + + impl tracing::Subscriber for HasVidCapture { + fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool { + true + } + fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {} + fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {} + fn event(&self, event: &tracing::Event<'_>) { + struct V<'a>(&'a HasVidCapture); + impl tracing::field::Visit for V<'_> { + fn record_bool(&mut self, field: &tracing::field::Field, value: bool) { + if field.name() == "has_volume_id" { + *self.0.0.lock().unwrap() = Some(value); + } + } + fn record_debug( + &mut self, + _field: &tracing::field::Field, + _value: &dyn std::fmt::Debug, + ) { + } + } + event.record(&mut V(self)); + } + fn enter(&self, _span: &tracing::span::Id) {} + fn exit(&self, _span: &tracing::span::Id) {} + } + + /// The `bus_key_unavailable` warn's `has_volume_id` field must report + /// the ACTUAL presence of a non-zero Volume ID on the handshake (encrypt.rs + /// `h.volume_id != [0u8; 16]`), not its negation. This is diagnostic-only + /// (it does not affect the returned `Err(AacsBusKeyUnavailable)` itself, + /// which is why a plain `Result` assertion can't distinguish `!=` from + /// `==` here) but it is the ONLY signal an operator has, from this log + /// line, for whether the handshake actually carried a VID when bus + /// encryption could not be removed — a `==` flip would silently invert it. + #[test] + fn resolve_vid_only_bus_key_gate_reports_true_has_volume_id_when_vid_nonzero() { + let capture = std::sync::Arc::new(HasVidCapture(std::sync::Mutex::new(None))); + let dispatch = tracing::Dispatch::new(capture.clone()); + let (mut disc, udf) = disc_with_cert(0x01, true); + let hs = HandshakeResult { + volume_id: [0x11u8; 16], // non-zero: a VID WAS present + read_data_key: None, + read_data_key_err: None, + drive_unlocked: false, + }; + let guard = tracing::dispatcher::set_default(&dispatch); + // Tracing caches per-callsite "any subscriber interested?" the FIRST + // time a callsite fires; another test in this suite may already have + // hit the exact same `warn!` call site under the process default + // (no-op) dispatch, permanently caching "not interested" for it. Force + // recomputation now that our capturing dispatch is installed, or the + // event is silently dropped before it reaches our `Visit` — flaky only + // under full-suite (parallel, ordering-dependent) runs, not in isolation. + tracing::callsite::rebuild_interest_cache(); + let err = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)) + .expect_err("bus-encrypted, no read_data_key must still hard-error"); + drop(guard); + tracing::callsite::rebuild_interest_cache(); + assert!(matches!(err, Error::AacsBusKeyUnavailable)); + assert_eq!( + *capture.0.lock().unwrap(), + Some(true), + "has_volume_id must be true: the handshake's volume_id was non-zero" + ); + } + // --------------------------------------------------------------- // Tests: read_vid_oem (response parsing). The OEM path issues a // READ_BUFFER CDB and parses a 36-byte response; we can't easily diff --git a/src/disc/extract.rs b/src/disc/extract.rs index 1d35e28..633e353 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -1053,6 +1053,32 @@ mod tests { s } + /// Like [`build_two_extent_icb`] but the two extents may have DIFFERENT + /// sector counts — used to exercise the within-extent batch-size + /// arithmetic (`sectors - sector_off`) with a first extent long enough to + /// force a second while-loop iteration. + fn build_two_extent_icb_sized( + sectors_a: u32, + data_lba_a: u32, + sectors_b: u32, + data_lba_b: u32, + ) -> [u8; 2048] { + let mut s = [0u8; 2048]; + s[0..2].copy_from_slice(&266u16.to_le_bytes()); // Extended File Entry + s[34..36].copy_from_slice(&0u16.to_le_bytes()); // Short AD + let size = (sectors_a as u64 + sectors_b as u64) * SECTOR_BYTES as u64; + s[56..64].copy_from_slice(&size.to_le_bytes()); // info_length + s[208..212].copy_from_slice(&0u32.to_le_bytes()); // l_ea + s[212..216].copy_from_slice(&16u32.to_le_bytes()); // l_ad = 2 Short ADs + let len_a = sectors_a * SECTOR_BYTES as u32; + let len_b = sectors_b * SECTOR_BYTES as u32; + s[216..220].copy_from_slice(&(len_a & 0x3FFF_FFFF).to_le_bytes()); + s[220..224].copy_from_slice(&data_lba_a.to_le_bytes()); + s[224..228].copy_from_slice(&(len_b & 0x3FFF_FFFF).to_le_bytes()); + s[228..232].copy_from_slice(&data_lba_b.to_le_bytes()); + s + } + /// Encrypt the clear unit from `clear_aacs_unit(tag)` under `unit_key` so /// `aacs::content::decrypt_unit` recovers it cleanly (zero decrypt loss). /// `tag` distinguishes two units' payloads. @@ -1655,4 +1681,462 @@ mod tests { assert_eq!(read_out(out.path(), "tiny.inf"), Some(payload)); assert!(res.complete); } + + // ── Mutation-triage additions ─────────────────────────────────────────── + + /// `cancelled` must stop on EITHER signal alone (a disjunction) — a + /// progress sink asking to stop must cancel even with no halt token, and a + /// cancelled halt token must cancel even when progress says continue. + #[test] + fn cancelled_stops_on_either_signal_alone() { + let opts_no_halt = ExtractOptions::default(); + assert!( + opts_no_halt.cancelled(false), + "a progress sink asking to stop must cancel even with no halt token" + ); + assert!( + !opts_no_halt.cancelled(true), + "neither signal firing must not cancel" + ); + + let halt = crate::halt::Halt::new(); + halt.cancel(); + let opts_halted = ExtractOptions { + halt: Some(halt), + ..Default::default() + }; + assert!( + opts_halted.cancelled(true), + "a cancelled halt token must cancel even when progress says continue" + ); + } + + /// The free-space pre-check must fire whenever the disc's declared total + /// exceeds real available space. An absurdly large declared size (an + /// exabyte) trips it regardless of the actual free space on whatever + /// machine runs the test. + #[test] + fn insufficient_space_errors_on_absurdly_large_required() { + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: vec![file("huge.bin", 30, 31, Vec::new(), false)], + subdirs: vec![], + }; + let mut disc = build_disc(root); + // Overwrite the declared size (info_length) with an absurd value; the + // extent length stays 0 so no real content is ever read (the space + // gate runs before Phase 2 touches content). + let mut icb = build_file_icb(0, 31, false); + let huge: u64 = 1u64 << 60; // ~1 exabyte -- no real disk has this free + icb[56..64].copy_from_slice(&huge.to_le_bytes()); + disc.put(PART_START + 30, icb); + + let out = TmpDir::new("insufficient_space"); + let err = clear_disc() + .extract_tree(&mut disc, out.path(), &ExtractOptions::default()) + .expect_err("an absurdly large declared size must trip the space gate"); + assert!(matches!(err, Error::DirInsufficientSpace { .. })); + } + + /// Regression: the per-VTS key crack in `resolve_vts_key` must gather ONLY + /// this VTS's own title-VOB extents. Two VTS groups here carry DISTINCT + /// scrambling keys; if the group filter (`vts_group_of(..) == Some(vts) && + /// is_title_vob(..)`) is loosened (`!=`, or `&&` -> `||`), one VTS's + /// resolve gathers the OTHER VTS's extents, cracks the wrong key, and that + /// VTS's own content silently fails to descramble to the expected + /// plaintext. + #[test] + fn css_two_vts_groups_do_not_cross_contaminate_keys() { + fn scrambled_vob(title_key: [u8; 5], marker: u8) -> (Vec, Vec) { + let seed = [0x11u8, 0x22, 0x33, 0x44, marker]; + let mut plain = vec![0u8; 2048]; + // The crack scan's hardened `is_scrambled_pack` gate requires the + // MPEG-PS pack-start signature before it will even ATTEMPT a + // Stevenson crack (see `css::is_scrambled_pack`) -- without it, + // `resolve_vts_key` silently falls back to `base_keys` for BOTH + // VTS groups regardless of which extents were gathered, masking + // this exact regression. + plain[0x00..0x04].copy_from_slice(&crate::css::PACK_START); + plain[0x14] = 0x10; // scramble flag + let pat: Vec = (0..8) + .map(|k| (0xA0u8.wrapping_add(k as u8) ^ marker) ^ 0x5A) + .collect(); + for (i, b) in plain.iter_mut().enumerate().skip(0x59) { + *b = pat[i % 8]; + } + plain[0x54..0x59].copy_from_slice(&seed); + let mut scrambled = plain.clone(); + lfsr::scramble_sector(&title_key, &mut scrambled); + (plain, scrambled) + } + + let key_1 = [0x10u8, 0x20, 0x30, 0x40, 0x50]; + let key_2 = [0x90u8, 0x80, 0x70, 0x60, 0x51]; + let (plain_1, scrambled_1) = scrambled_vob(key_1, 0x01); + let (plain_2, scrambled_2) = scrambled_vob(key_2, 0x02); + + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "VIDEO_TS".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files: vec![ + file("VTS_01_1.VOB", 30, 5000, scrambled_1.clone(), false), + file("VTS_02_1.VOB", 32, 6000, scrambled_2.clone(), false), + ], + subdirs: vec![], + }], + }; + let mut disc = build_disc(root); + let out = TmpDir::new("css_two_vts"); + let mut d = clear_disc(); + d.content_format = crate::disc::ContentFormat::MpegPs; + // Disc-wide key deliberately matches NEITHER VTS's real key: if the + // per-VTS group filter is broken and a crack attempt fails (or is + // skipped), falling back to this key must still mismatch, so the + // fallback path can never accidentally mask a broken filter. + d.css = Some(crate::css::CssState { + title_key: [0xFFu8; 5], + crack_span: None, + }); + let res = d + .extract_tree(&mut disc, out.path(), &ExtractOptions::default()) + .expect("extract"); + + let got_1 = read_out(out.path(), "VIDEO_TS/VTS_01_1.VOB").expect("vts01 vob"); + let got_2 = read_out(out.path(), "VIDEO_TS/VTS_02_1.VOB").expect("vts02 vob"); + let mut expect_1 = plain_1.clone(); + expect_1[0x14] = 0x00; + let mut expect_2 = plain_2.clone(); + expect_2[0x14] = 0x00; + assert_eq!( + got_1, expect_1, + "VTS_01 must descramble under its OWN cracked key, not VTS_02's" + ); + assert_eq!( + got_2, expect_2, + "VTS_02 must descramble under its OWN cracked key, not VTS_01's" + ); + assert!(res.complete); + } + + /// `Borrowed` is a thin forwarding wrapper the decrypting decorator uses + /// to avoid taking ownership of the caller's reader -- every + /// `SectorSource` method must forward to the wrapped `&mut dyn + /// SectorSource` verbatim. Calling directly on a concrete `Borrowed` + /// value (not through `&mut dyn SectorSource`) exercises the actual + /// forwarding body via static dispatch, not vtable dispatch through a + /// trait object. + #[test] + fn borrowed_forwards_every_sector_source_call() { + struct Recorder { + capacity: u32, + last_speed: Option, + last_unit_base: Option, + } + impl SectorSource for Recorder { + fn capacity_sectors(&self) -> u32 { + self.capacity + } + fn read_sectors( + &mut self, + _lba: u32, + _count: u16, + _buf: &mut [u8], + _recovery: bool, + ) -> Result { + Ok(0) + } + fn set_speed(&mut self, kbs: u16) { + self.last_speed = Some(kbs); + } + fn set_unit_base(&mut self, lba: u32) { + self.last_unit_base = Some(lba); + } + } + + let mut inner = Recorder { + capacity: 42, + last_speed: None, + last_unit_base: None, + }; + { + let mut b = Borrowed(&mut inner); + assert_eq!(b.capacity_sectors(), 42, "capacity_sectors must forward"); + b.set_speed(7200); + b.set_unit_base(1234); + } + assert_eq!(inner.last_speed, Some(7200), "set_speed must forward"); + assert_eq!( + inner.last_unit_base, + Some(1234), + "set_unit_base must forward" + ); + } + + /// Regression: within ONE extent, a batch's "sectors remaining IN THIS + /// EXTENT" must be computed as `sectors - sector_off`, not `sectors + + /// sector_off`. The latter inflates without bound as the loop advances, + /// letting a later batch's read run PAST this extent's true end into + /// whatever content sits at the following LBAs (an unrelated disc region + /// in this fixture) and get written into the file as if it were this + /// extent's own data -- untrusted-disc content bleed across extent + /// boundaries. + #[test] + fn extent_second_batch_stays_within_its_own_bounds() { + // Extent A: 1600 sectors of pattern 'A' -- just over + // READ_BATCH_SECTORS (1536), forcing a second while-loop iteration + // with sector_off > 0. + const SECTORS_A: u32 = 1600; + const LBA_A: u32 = 5000; + // The disc region immediately following extent A's true end. Must + // NEVER be read as part of extent A: sized to cover a full erroneous + // READ_BATCH_SECTORS second batch starting right after extent A's + // real tail. + const LBA_FILLER: u32 = LBA_A + SECTORS_A; + const SECTORS_FILLER: u32 = 1472; + // Extent B: the file's real second extent, at a completely different + // LBA, same size as the filler region so the two are exact substitutes + // if the arithmetic bug reads the wrong one. + const SECTORS_B: u32 = SECTORS_FILLER; + const LBA_B: u32 = 90_000; + + let a = vec![0xAAu8; SECTORS_A as usize * SECTOR_BYTES]; + let filler = vec![0xCCu8; SECTORS_FILLER as usize * SECTOR_BYTES]; + let b = vec![0xBBu8; SECTORS_B as usize * SECTOR_BYTES]; + + let mut disc = MemDisc::new(); + build_udf_skeleton(&mut disc, 10); + disc.put_bytes(PART_START + LBA_A, &a); + disc.put_bytes(PART_START + LBA_FILLER, &filler); + disc.put_bytes(PART_START + LBA_B, &b); + disc.put( + PART_START + 30, + build_two_extent_icb_sized(SECTORS_A, LBA_A, SECTORS_B, LBA_B), + ); + let mut root_fids = Vec::new(); + push_fid(&mut root_fids, "", 10, true, true); + push_fid(&mut root_fids, "big.bin", 30, false, false); + disc.put(PART_START + 10, build_dir_icb(11, root_fids.len() as u32)); + disc.put_bytes(PART_START + 11, &root_fids); + + let out = TmpDir::new("extent_bounds"); + let res = clear_disc() + .extract_tree(&mut disc, out.path(), &ExtractOptions::default()) + .expect("extract"); + + let got = read_out(out.path(), "big.bin").expect("file written"); + let mut expect = a.clone(); + expect.extend_from_slice(&b); + assert_eq!( + got.len(), + expect.len(), + "file size matches the two extents' declared total" + ); + assert_eq!( + got, expect, + "extent A's tail batch must not read past its own declared length \ + into the following disc region (pattern 'C' must never appear)" + ); + assert!(res.complete); + assert_eq!(res.bytes_unreadable, 0); + } + + /// Pure-function coverage of `whole_unit_batch`'s cap: a batch that is + /// NOT the extent's final chunk (there is more remaining after it) is + /// capped at `READ_BATCH_SECTORS`, which is itself an exact multiple of + /// `AACS_UNIT_SECTORS` (1536 = 512 * 3) -- so the result is already + /// unit-aligned. + #[test] + fn whole_unit_batch_caps_mid_stream_batches() { + assert_eq!(whole_unit_batch(2000), READ_BATCH_SECTORS); + assert_eq!(whole_unit_batch(READ_BATCH_SECTORS + 1), READ_BATCH_SECTORS); + assert_eq!(whole_unit_batch(READ_BATCH_SECTORS + 2), READ_BATCH_SECTORS); + } + + /// Pure-function coverage of `whole_unit_batch`'s tail contract: the + /// FINAL chunk of an extent (`batch == remaining`, i.e. `remaining <= + /// READ_BATCH_SECTORS`) must NOT be rounded down to a unit boundary, even + /// when it is not itself a multiple of 3 -- `decrypt_sectors`'s + /// trailing-partial contract handles a non-multiple tail specially, and + /// rounding it here would silently drop sectors from the read. + #[test] + fn whole_unit_batch_true_tail_is_never_rounded() { + assert_eq!(whole_unit_batch(5), 5); + assert_eq!(whole_unit_batch(1535), 1535); + assert_eq!(whole_unit_batch(2), 2); + assert_eq!(whole_unit_batch(1), 1); + assert_eq!(whole_unit_batch(READ_BATCH_SECTORS), READ_BATCH_SECTORS); + } + + /// `read_batch` must retry a non-decrypt read failure up to + /// `READ_RETRIES` times and succeed if a later attempt does -- a + /// transient failure must not be treated as permanent on the very first + /// try. + #[test] + fn read_batch_retries_transient_failures_and_succeeds() { + struct FlakySource { + fail_first: u32, + calls: u32, + } + impl SectorSource for FlakySource { + fn capacity_sectors(&self) -> u32 { + 100_000 + } + fn read_sectors( + &mut self, + _lba: u32, + count: u16, + buf: &mut [u8], + _recovery: bool, + ) -> Result { + self.calls += 1; + if self.calls <= self.fail_first { + return Err(Error::DiscRead { + sector: 0, + status: None, + sense: None, + }); + } + let need = count as usize * SECTOR_BYTES; + buf[..need].fill(0x11); + Ok(need) + } + } + + // Fails exactly READ_RETRIES times (attempts 0..READ_RETRIES all + // error), then succeeds on the FINAL attempt (attempt == + // READ_RETRIES) -- the last chance the retry budget allows. + let src = FlakySource { + fail_first: READ_RETRIES, + calls: 0, + }; + let mut dec = DecryptingSectorSource::new(src, DecryptKeys::None); + let mut buf = vec![0u8; 2 * SECTOR_BYTES]; + let ok = read_batch(&mut dec, 0, 2, &mut buf); + assert!( + ok, + "a failure that clears up within the retry budget must succeed, not hole" + ); + assert_eq!(dec.inner().calls, READ_RETRIES + 1); + } + + /// `report` must reflect the sink's verdict -- a sink asking to stop must + /// actually halt the run mid-file, not be swallowed. + #[test] + fn progress_sink_stop_halts_run_mid_file() { + struct StopImmediately; + impl crate::progress::Progress for StopImmediately { + fn report(&self, _p: &crate::progress::PassProgress) -> bool { + false + } + } + + let good = vec![0x66u8; 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, true)], + subdirs: vec![], + }], + }], + }; + let mut disc = build_disc(root); + let out = TmpDir::new("progress_stop"); + let sink = StopImmediately; + let opts = ExtractOptions { + progress: Some(&sink), + ..Default::default() + }; + let res = clear_disc() + .extract_tree(&mut disc, out.path(), &opts) + .expect("extract does not error on a progress halt"); + + assert!(res.halted, "a sink returning false must halt the run"); + assert!( + !res.files[0].complete, + "the in-flight file must be left incomplete, not finalized" + ); + assert!( + read_out(out.path(), "BDMV/STREAM/00001.m2ts").is_none(), + "an incomplete file must not be renamed to its final name" + ); + } + + /// `available_space` must return the real free-space figure (`Some`) for + /// an existing directory on a platform that exposes it -- the free-space + /// pre-check in `extract_tree` silently no-ops whenever this returns + /// `None`, so a `statvfs` SUCCESS (`rc == 0`) must never be read as + /// "unavailable". + #[cfg(unix)] + #[test] + fn available_space_reports_free_bytes_on_a_real_dir() { + let out = TmpDir::new("available_space"); + std::fs::create_dir_all(out.path()).unwrap(); + assert!( + available_space(out.path()).is_some(), + "a real, existing directory must report Some(_) free bytes" + ); + } + + /// A raw control byte (below 0x20, distinct from the separately-rejected + /// NUL) in a disc-authored name must be rejected, not passed through into + /// the host filename. + #[test] + fn sanitize_rejects_control_bytes() { + assert!( + sanitize_component("a\u{1}b").is_err(), + "0x01 must be rejected" + ); + assert!( + sanitize_component("a\nb").is_err(), + "0x0A (newline) must be rejected" + ); + assert!( + sanitize_component("a\u{1f}b").is_err(), + "0x1F must be rejected" + ); + // 0x20 (space) is NOT a control byte -- allowed mid-name (only + // trimmed if trailing). + assert!(sanitize_component("a b").is_ok()); + } + + /// The VTS group number must be EXACTLY 2 ASCII digits -- neither a + /// non-numeric group nor a wrong-length one is a valid `VTS_xx` group. + #[test] + fn vts_group_of_requires_exactly_two_digits() { + assert_eq!( + vts_group_of("VTS_AB_1.VOB"), + None, + "letters are not a group number" + ); + assert_eq!( + vts_group_of("VTS_123_1.VOB"), + None, + "a 3-digit group is not a valid 2-digit VTS number" + ); + assert_eq!( + vts_group_of("VTS_1_1.VOB"), + None, + "a 1-digit group is not valid" + ); + assert_eq!(vts_group_of("VTS_01_1.VOB").as_deref(), Some("VTS_01")); + } } diff --git a/src/disc/hddvd.rs b/src/disc/hddvd.rs index a309432..425c175 100644 --- a/src/disc/hddvd.rs +++ b/src/disc/hddvd.rs @@ -963,6 +963,24 @@ mod tests { sniff_video_codec(&[0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]), None ); + + // Every byte of the `00 00 01` marker is load-bearing: a near-miss + // that gets ANY one of the three bytes wrong must not be recognized. + assert_eq!( + sniff_video_codec(&[0x05, 0x00, 0x01, 0xB3]), + None, + "leading byte must be 0x00, not just any byte" + ); + assert_eq!( + sniff_video_codec(&[0x00, 0x05, 0x01, 0xB3]), + None, + "second byte must be 0x00, not just any byte" + ); + assert_eq!( + sniff_video_codec(&[0x00, 0xFF, 0x01, 0xB3]), + None, + "the middle byte of the marker must actually be checked, not skipped" + ); } #[test] @@ -972,6 +990,13 @@ mod tests { Some(Codec::Ac3Plus) ); assert_eq!(sniff_audio_codec(&[0x00, 0x01, 0x02, 0x03]), None); + // Both syncword bytes are required together: a lone 0x0B with no 0x77 + // partner anywhere must not be recognized. + assert_eq!( + sniff_audio_codec(&[0x0B, 0x00, 0x0B, 0x01]), + None, + "0x0B alone (no 0x77 partner) is not the E-AC-3 syncword" + ); } // ── EVO head probe → streams ────────────────────────────────────────── @@ -1394,4 +1419,490 @@ mod tests { // Both feature halves are in ONE title's extents. assert!(!mm.extents.is_empty()); } + + // ── parse_vti_clip_order: bound / cap / termination edge cases ──────── + + /// The outer scan cap (`MAX_VTI_HITS`) must be exact, not off-by-one. Every + /// entry here is stride-aligned so they ALL land in one residue bucket — + /// unlike the scattered-token cap test above, the cap is directly visible + /// in the output length (the total scanned-hit count IS the bucket size). + #[test] + fn parse_vti_clip_order_caps_hits_at_exact_boundary_same_residue() { + let table_start = 0x200usize; + let n = MAX_VTI_HITS + 50; + let mut v = vec![0u8; table_start + n * VTI_CLIP_ENTRY_STRIDE]; + v[..HDDVD_VTI_MAGIC.len()].copy_from_slice(HDDVD_VTI_MAGIC); + for i in 0..n { + let off = table_start + i * VTI_CLIP_ENTRY_STRIDE + 0x42; + v[off..off + b"X.EVO".len()].copy_from_slice(b"X.EVO"); + } + let out = parse_vti_clip_order(&v); + assert_eq!( + out.len(), + MAX_VTI_HITS, + "the scan must stop at exactly MAX_VTI_HITS, not one past it" + ); + } + + /// A name-byte run that reaches the exact end of the buffer with NO NUL + /// terminator must not be read out of bounds — the inner scan (and the + /// nul-terminated check) must stop at the buffer boundary rather than + /// indexing one past it. + #[test] + fn parse_vti_clip_order_handles_unterminated_name_run_at_buffer_end() { + let mut v = HDDVD_VTI_MAGIC.to_vec(); + v.extend_from_slice(b"TRAILING_JUNK_NO_TERMINATOR"); // all ascii-graphic, no NUL, ends at EOF + let out = parse_vti_clip_order(&v); + assert!( + out.is_empty(), + "unterminated trailing run yields no entries (and must not panic)" + ); + } + + /// A `.EVO`-suffixed name run followed by an in-bounds byte that is NOT a + /// NUL must not be treated as terminated — being merely in-bounds is not + /// the same as actually finding a NUL. + #[test] + fn parse_vti_clip_order_requires_actual_nul_terminator_not_just_in_bounds() { + let mut v = HDDVD_VTI_MAGIC.to_vec(); + v.extend_from_slice(b"FEATURE.EVO"); + v.push(0x01); // in-bounds terminator byte, but NOT a NUL + v.extend_from_slice(&[0u8; 16]); + let out = parse_vti_clip_order(&v); + assert!( + out.is_empty(), + "a non-NUL byte after .EVO must not count as terminated" + ); + } + + /// A NUL-terminated ascii run that does NOT end in ".EVO" must never be + /// collected, no matter how many repeat at a shared residue: nul- + /// termination and the `.EVO`-suffix check are independent gates, one must + /// not short-circuit the other away. + #[test] + fn parse_vti_clip_order_rejects_nul_terminated_names_without_evo_suffix() { + let mut v = HDDVD_VTI_MAGIC.to_vec(); + for _ in 0..20 { + v.extend_from_slice(b"HELLO\0"); // nul-terminated, 5 bytes, not .EVO + } + let out = parse_vti_clip_order(&v); + assert!( + out.is_empty(), + "non-.EVO nul-terminated names must not be collected" + ); + } + + /// A short (<4-byte) nul-terminated name run must be rejected by the + /// length guard BEFORE the `.EVO`-suffix slice runs — slicing a name + /// shorter than 4 bytes at `name.len() - 4` would otherwise underflow. + /// Must not panic, and must not be collected. + #[test] + fn parse_vti_clip_order_short_circuits_length_check_before_slicing_short_names() { + let mut v = HDDVD_VTI_MAGIC.to_vec(); + v.push(b' '); // non-name-byte separator: isolates "AB" from the magic run + v.extend_from_slice(b"AB\0"); // 2-byte name, under the 4-byte slice width + let out = parse_vti_clip_order(&v); + assert!( + out.is_empty(), + "short name is rejected without slicing/panicking" + ); + } + + // ── EVO_ES_SAMPLE_CAP / collect_es capping ───────────────────────────── + + /// The documented sample cap is 128 KiB, i.e. `128 * 1024`. + #[test] + fn evo_es_sample_cap_is_128_kib() { + assert_eq!(EVO_ES_SAMPLE_CAP, 128 * 1024); + } + + /// Plain-video-range (`0xE0..=0xEF`) samples stop growing once the buffer + /// has reached the cap — a subsequent packet must not push it past. + #[test] + fn collect_es_caps_video_sample_at_the_length_cap() { + use crate::consts::pes_stream_id::VIDEO; + let mut video = Vec::new(); + let mut video_pid: Option = None; + let mut audio = BTreeMap::new(); + collect_es( + &ps_pkt(VIDEO, None, vec![0xAA; EVO_ES_SAMPLE_CAP]), + &mut video, + &mut video_pid, + &mut audio, + ); + assert_eq!(video.len(), EVO_ES_SAMPLE_CAP); + collect_es( + &ps_pkt(VIDEO, None, vec![0xBB; 16]), + &mut video, + &mut video_pid, + &mut audio, + ); + assert_eq!( + video.len(), + EVO_ES_SAMPLE_CAP, + "no further growth once at the cap" + ); + } + + /// The VC-1 extended-stream-id (0xFD, ext 0x55) video branch has its own + /// cap check; it must behave identically to the plain-video branch. + #[test] + fn collect_es_caps_vc1_video_sample_at_the_length_cap() { + let mut video = Vec::new(); + let mut video_pid: Option = None; + let mut audio = BTreeMap::new(); + collect_es( + &ps_pkt(0xFD, Some(0x55), vec![0xAA; EVO_ES_SAMPLE_CAP]), + &mut video, + &mut video_pid, + &mut audio, + ); + assert_eq!(video.len(), EVO_ES_SAMPLE_CAP); + collect_es( + &ps_pkt(0xFD, Some(0x55), vec![0xBB; 16]), + &mut video, + &mut video_pid, + &mut audio, + ); + assert_eq!( + video.len(), + EVO_ES_SAMPLE_CAP, + "no further growth once at the cap (VC-1 0xFD branch)" + ); + } + + /// The per-sub-id audio branch has its own cap check; same requirement. + #[test] + fn collect_es_caps_audio_sample_at_the_length_cap() { + use crate::consts::pes_stream_id::PRIVATE_STREAM_1; + let mut video = Vec::new(); + let mut video_pid: Option = None; + let mut audio = BTreeMap::new(); + collect_es( + &ps_pkt(PRIVATE_STREAM_1, Some(0xC0), vec![0xAA; EVO_ES_SAMPLE_CAP]), + &mut video, + &mut video_pid, + &mut audio, + ); + assert_eq!(audio[&0xC0].len(), EVO_ES_SAMPLE_CAP); + collect_es( + &ps_pkt(PRIVATE_STREAM_1, Some(0xC0), vec![0xBB; 16]), + &mut video, + &mut video_pid, + &mut audio, + ); + assert_eq!( + audio[&0xC0].len(), + EVO_ES_SAMPLE_CAP, + "no further growth once at the cap (audio branch)" + ); + } + + // ── read_adv_obj_xpl: prefix AND suffix are both required ────────────── + + /// A file matching the `vplst` prefix but NOT the `.xpl` suffix must not + /// be adopted as the playlist — both conditions are independently + /// required, one must not be short-circuited away by the other. + #[test] + fn read_adv_obj_xpl_requires_both_vplst_prefix_and_xpl_suffix() { + let mut disc = MemDisc::new(); + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "ADV_OBJ".to_string(), + icb_lba: 30, + dir_data_lba: 31, + files: vec![file_with( + "VPLST_NOTES.TXT", + 40, + 4000, + b"not a playlist".to_vec(), + true, + )], + subdirs: vec![], + }], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + assert!( + read_adv_obj_xpl(&mut disc, &udf).is_none(), + "prefix match alone (not ending .xpl) must not select a file" + ); + } + + // ── compose_xpl_titles: size/offset arithmetic ───────────────────────── + + /// Direct unit test of the arithmetic composing a title from its XPL + /// clips: clip sizes are SUMMED (not multiplied), title-time in/out ticks + /// are `seconds * 45000` (not divided), and `duration_secs` is + /// `end - begin` (not `end + begin` or a division). + #[test] + fn compose_xpl_titles_sums_sizes_and_computes_in_out_times() { + let clip_extents: BTreeMap)> = [ + ( + "a.evo".to_string(), + ( + "A.EVO".to_string(), + 1000u64, + vec![Extent { + start_lba: 1, + sector_count: 1, + }], + ), + ), + ( + "b.evo".to_string(), + ( + "B.EVO".to_string(), + 2000u64, + vec![Extent { + start_lba: 2, + sector_count: 1, + }], + ), + ), + ] + .into_iter() + .collect(); + let xpl_titles = vec![XplTitle { + number: 1, + name: "T".to_string(), + duration_secs: 10.0, + clips: vec![ + XplClip { + evo: "a.evo".to_string(), + begin_secs: 2.0, + end_secs: 5.0, + }, + XplClip { + evo: "b.evo".to_string(), + begin_secs: 5.0, + end_secs: 9.0, + }, + ], + chapters: vec![], + }]; + let mut disc = MemDisc::new(); + let titles = compose_xpl_titles(&mut disc, &xpl_titles, &clip_extents); + assert_eq!(titles.len(), 1); + let t = &titles[0]; + assert_eq!(t.size_bytes, 3000, "clip sizes summed, not multiplied"); + assert_eq!( + t.clips[0].in_time, + (2.0f64 * 45000.0) as u32, + "in_time is begin_secs * 45000, not divided" + ); + assert_eq!( + t.clips[0].out_time, + (5.0f64 * 45000.0) as u32, + "out_time is end_secs * 45000, not divided" + ); + assert!( + (t.clips[0].duration_secs - 3.0).abs() < 1e-9, + "duration_secs is end_secs - begin_secs, not +/÷: got {}", + t.clips[0].duration_secs + ); + assert!( + (t.clips[1].duration_secs - 4.0).abs() < 1e-9, + "second clip's duration is also end - begin: got {}", + t.clips[1].duration_secs + ); + } + + // ── Disc::scan_hddvd_titles: VTI-selection / extent-filter guards ────── + + /// A file carrying the real `ADVANCED-VTS` magic but the WRONG extension + /// (not `.vti`) must never be adopted as the navigation file. Absent a + /// real `.vti` file, the scan must fall back to one title per clip (no + /// VTI-driven feature composition) rather than trusting a same-content + /// impostor by name-agnostic magic alone. + #[test] + fn scan_hddvd_titles_ignores_a_vti_look_alike_with_the_wrong_extension() { + let mut disc = MemDisc::new(); + let vti_bytes = synthetic_vti(&["FEATURE_1.EVO", "FEATURE_2.EVO"]); + let files = vec![ + file_with("IMPOSTER.DAT", 90, 20000, vti_bytes, true), + file("FEATURE_1.EVO", 100, 5000, 10 * 2048, true), + file("FEATURE_2.EVO", 101, 8000, 6 * 2048, true), + ]; + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "HVDVD_TS".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files, + subdirs: vec![], + }], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + + let titles = Disc::scan_hddvd_titles(&mut disc, &udf); + assert_eq!( + titles.len(), + 2, + "no real .vti present -> no VTI-driven composition, one title per clip" + ); + } + + /// A clip whose file has a zero-byte size (a degenerate/empty allocation: + /// its ICB's allocation descriptor has `data_len == 0`, the UDF AD-list + /// terminator, so `file_extents` yields no extent at all) must not + /// produce a title. NOTE: this exercises the *upstream* `data_len == 0` + /// terminator path in [`crate::udf::UdfFs::file_extents`], not the + /// `sectors > 0 && lba > 0` guard in `scan_hddvd_titles` itself — with + /// this fixture (`file_extents` never returns a `(lba, 0)` tuple, and + /// `PART_START` unconditionally makes every resolved `lba` positive) + /// that guard is unreachable in a divergent way; kept here as a + /// regression check on the zero-byte-file behavior in its own right. + #[test] + fn scan_hddvd_titles_excludes_a_clip_with_zero_sectors() { + let mut disc = MemDisc::new(); + let files = vec![ + file("REAL.EVO", 100, 5000, 4 * 2048, true), // ordinary, valid clip + file("BOGUS.EVO", 101, 9000, 0, true), // size 0 -> zero-sector extent + ]; + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "HVDVD_TS".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files, + subdirs: vec![], + }], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + + let titles = Disc::scan_hddvd_titles(&mut disc, &udf); + assert_eq!( + titles.len(), + 1, + "the zero-sector clip must not produce a title" + ); + assert_eq!(titles[0].playlist, "REAL.EVO"); + } + + // ── probe_evo_streams: sector-cursor bookkeeping ─────────────────────── + + /// A zero-sector extent must be skipped outright, never entering the read + /// loop — `left` (an unsigned sector count) starting at 0 must gate the + /// loop closed. (A `left >= 0` tautology here would spin forever: `n` + /// would be pinned at 0, so neither `lba`, `left`, nor `remaining` would + /// ever change — a non-terminating loop reachable from a crafted extent + /// list.) + #[test] + fn probe_evo_streams_skips_a_zero_sector_extent_without_reading() { + let mut disc = MemDisc::new(); + let extent = Extent { + start_lba: 500_000, + sector_count: 0, + }; + let streams = probe_evo_streams(&mut disc, std::slice::from_ref(&extent)); + assert!(streams.is_empty(), "a zero-sector extent yields no streams"); + } + + /// The read cursor must advance FORWARD by each chunk's sector count, not + /// backward — real content living only in the second 512-sector (1 MiB) + /// chunk must be reached. + #[test] + fn probe_evo_streams_advances_lba_forward_across_chunk_reads() { + let mut disc = MemDisc::new(); + let start_lba = 100_000u32; + // First chunk (512 sectors): inert filler, no start codes. + disc.put_bytes(start_lba, &vec![0x55u8; 512 * 2048]); + // Second chunk: the real EVO content (H.264 video PES). + let evo = synthetic_evo(); + disc.put_bytes(start_lba + 512, &evo); + let extent = Extent { + start_lba, + sector_count: 512 + (evo.len() as u32).div_ceil(2048), + }; + + let streams = probe_evo_streams(&mut disc, std::slice::from_ref(&extent)); + let has_h264 = streams + .iter() + .any(|s| matches!(s, Stream::Video(v) if v.codec == Codec::H264)); + assert!( + has_h264, + "the second 1 MiB chunk must be read from the correct (forward) LBA" + ); + } + + /// The read loop must stop at the extent's DECLARED `sector_count` — data + /// living just past it must never be read (a buffer over-read past the + /// caller-supplied extent bound, on untrusted disc-layout input). + #[test] + fn probe_evo_streams_stops_reading_at_the_extents_declared_sector_count() { + let mut disc = MemDisc::new(); + let start_lba = 200_000u32; + let declared_sectors = 4u32; + disc.put_bytes(start_lba, &vec![0x55u8; declared_sectors as usize * 2048]); + // Real H.264 PES data placed just PAST the declared extent — must + // never be read. + let evo = synthetic_evo(); + disc.put_bytes(start_lba + declared_sectors, &evo); + let extent = Extent { + start_lba, + sector_count: declared_sectors, + }; + + let streams = probe_evo_streams(&mut disc, std::slice::from_ref(&extent)); + let has_h264 = streams + .iter() + .any(|s| matches!(s, Stream::Video(v) if v.codec == Codec::H264)); + assert!( + !has_h264, + "must not read past the extent's declared sector_count" + ); + } + + /// The total read budget (`EVO_PROBE_SECTORS`) must be enforced ACROSS + /// extents, not just within one — once it is exhausted by an earlier + /// extent, a later extent in the same probe must not be read at all. + #[test] + fn probe_evo_streams_caps_total_reads_across_extents_at_evo_probe_sectors() { + let mut disc = MemDisc::new(); + let first_lba = 300_000u32; + disc.put_bytes(first_lba, &vec![0x55u8; EVO_PROBE_SECTORS as usize * 2048]); + // A second extent, following the first in the extents list: once the + // whole EVO_PROBE_SECTORS budget is spent on the first, this must + // never be reached. + let second_lba = first_lba + EVO_PROBE_SECTORS; + let evo = synthetic_evo(); + disc.put_bytes(second_lba, &evo); + + let extents = vec![ + Extent { + start_lba: first_lba, + sector_count: EVO_PROBE_SECTORS, + }, + Extent { + start_lba: second_lba, + sector_count: 10, + }, + ]; + let streams = probe_evo_streams(&mut disc, &extents); + let has_h264 = streams + .iter() + .any(|s| matches!(s, Stream::Video(v) if v.codec == Codec::H264)); + assert!( + !has_h264, + "must not read past the total EVO_PROBE_SECTORS budget across extents" + ); + } } diff --git a/src/disc/mod.rs b/src/disc/mod.rs index b6d48fd..687249b 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -5167,4 +5167,1210 @@ mod tests { assert_eq!(chapter_name(0), "1"); assert_eq!(chapter_name(41), "42"); } + + // ── correct_truehd_channels ────────────────────────────────────────── + + /// Records every `read_sectors` call and serves a fixed byte buffer + /// (zero-padded to the requested size) — probes + /// `correct_truehd_channels`'s early-return guards (empty pid list, `n == + /// 0`) without needing real TrueHD content, and carries real synthetic + /// TrueHD bytes for the full round-trip tests below. + struct ThdSpyReader { + calls: std::cell::RefCell>, + data: Vec, + } + impl SectorSource for ThdSpyReader { + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + _recovery: bool, + ) -> Result { + self.calls.borrow_mut().push((lba, count)); + let n = self.data.len().min(buf.len()); + buf[..n].copy_from_slice(&self.data[..n]); + for b in buf[n..].iter_mut() { + *b = 0; + } + Ok(buf.len()) + } + } + + /// One 192-byte BD-TS PES packet on `pid` carrying `es` as its raw + /// elementary payload. Minimal PES header (no PTS/DTS) — this probe + /// reads and demuxes+flushes in one shot, so no timestamp is needed. + fn thd_bd_pes(pid: u16, es: &[u8]) -> Vec { + let mut pkt = vec![0u8; 192]; + pkt[4] = 0x47; // TS sync + pkt[5] = 0x40 | ((pid >> 8) & 0x1F) as u8; // PUSI + PID hi + pkt[6] = (pid & 0xFF) as u8; // PID lo + pkt[7] = 0x10; // adaptation = payload-only, cc = 0 + let p = 8; + pkt[p] = 0x00; + pkt[p + 1] = 0x00; + pkt[p + 2] = 0x01; + pkt[p + 3] = 0xBD; // private_stream_1 + pkt[p + 4] = 0x00; + pkt[p + 5] = 0x00; + pkt[p + 6] = 0x80; // flags1 marker bits + pkt[p + 7] = 0x00; // flags2: no PTS/DTS + pkt[p + 8] = 0x00; // PES_header_data_length = 0 + let es_off = p + 9; + let n = es.len().min(192 - es_off); + pkt[es_off..es_off + n].copy_from_slice(&es[..n]); + pkt + } + + /// A synthetic TrueHD major-sync access unit: 2 junk bytes, the + /// 0xF8726FBA sync, `format_info`, then padding through the + /// num_substreams byte (sync offset + 16) so Atmos detection can read it. + fn thd_major_sync_es(format_info: u32, num_substreams: u8) -> Vec { + let mut es = vec![0u8; 24]; + es[0] = 0xAA; + es[1] = 0xBB; + es[2..6].copy_from_slice(&0xF872_6FBAu32.to_be_bytes()); + es[6..10].copy_from_slice(&format_info.to_be_bytes()); + es[2 + 16] = num_substreams << 4; + es + } + + fn truehd_audio_stream(pid: u16, channels: AudioChannels, sample_rate: SampleRate) -> Stream { + Stream::Audio(AudioStream { + pid, + codec: Codec::TrueHd, + channels, + language: "eng".into(), + sample_rate, + secondary: false, + purpose: LabelPurpose::Normal, + label: crate::labels::generate_audio_label(&Codec::TrueHd, &channels, false), + }) + } + + /// No TrueHd stream in the title → the pid list is empty and the probe + /// must return before ever touching the reader (extent/read-count guards + /// are irrelevant once there's nothing to probe for). Mutation guard: + /// `matches!(a.codec, Codec::TrueHd)` flipped to `true` would sweep this + /// title's non-TrueHD stream's pid into the probe list too, and it would + /// read the (spied) source. + #[test] + fn correct_truehd_channels_skips_probe_when_no_truehd_stream() { + let mut title = DiscTitle::empty(); + title.streams = vec![Stream::Audio(AudioStream { + pid: 0x1100, + codec: Codec::Ac3, + channels: AudioChannels::Surround51, + language: "eng".into(), + sample_rate: SampleRate::S48, + secondary: false, + purpose: LabelPurpose::Normal, + label: String::new(), + })]; + title.extents = vec![Extent { + start_lba: 0, + sector_count: 10, + }]; + let mut reader = ThdSpyReader { + calls: std::cell::RefCell::new(Vec::new()), + data: Vec::new(), + }; + correct_truehd_channels(&mut reader, &mut title); + assert!( + reader.calls.borrow().is_empty(), + "no TrueHD stream present → the reader must never be touched: {:?}", + reader.calls.borrow() + ); + } + + /// A TrueHd stream IS present → the probe must proceed past the pid-list + /// guard and actually read the title's first extent. Mutation guard: + /// `matches!(a.codec, Codec::TrueHd)` flipped to `false` would empty the + /// pid list even here and the probe would return before ever reading. + #[test] + fn correct_truehd_channels_reads_when_truehd_stream_present() { + let mut title = DiscTitle::empty(); + title.streams = vec![truehd_audio_stream( + 0x1100, + AudioChannels::Surround51, + SampleRate::S48, + )]; + title.extents = vec![Extent { + start_lba: 7, + sector_count: 10, + }]; + let mut reader = ThdSpyReader { + calls: std::cell::RefCell::new(Vec::new()), + data: Vec::new(), + }; + correct_truehd_channels(&mut reader, &mut title); + assert!( + !reader.calls.borrow().is_empty(), + "a TrueHD stream present must drive a probe read" + ); + } + + /// The bounded-probe sector count is `ext.sector_count.min(4096)`; when the + /// extent has ZERO sectors that count is zero and there is nothing to read + /// — the probe must return before calling into the reader. Mutation guard: + /// `n == 0` flipped to `n != 0` inverts this so a zero-sector extent + /// wrongly falls through to a (zero-length) read. + #[test] + fn correct_truehd_channels_skips_read_on_zero_sector_extent() { + let mut title = DiscTitle::empty(); + title.streams = vec![truehd_audio_stream( + 0x1100, + AudioChannels::Surround51, + SampleRate::S48, + )]; + title.extents = vec![Extent { + start_lba: 7, + sector_count: 0, + }]; + let mut reader = ThdSpyReader { + calls: std::cell::RefCell::new(Vec::new()), + data: Vec::new(), + }; + correct_truehd_channels(&mut reader, &mut title); + assert!( + reader.calls.borrow().is_empty(), + "a zero-sector extent must never trigger a read: {:?}", + reader.calls.borrow() + ); + } + + /// Full round-trip: a real major sync carrying a 7.1 (8ch) presentation, a + /// whitelisted 96 kHz rate nibble, and an Atmos substream count, probed + /// through a container-declared 5.1/48 kHz basic-descriptor stream. All + /// three corrections must land, and the label must be promoted to the + /// Atmos form (the stream still carried the basic, non-editorial label). + /// Kills the was_basic `==`, the channels/rate `!=`/`&&` guards' "already + /// correct" branch, the `!matches!` per-stream skip, the `is_atmos == + /// Some(true)` branch, and the whole-function no-op mutant. + #[test] + fn correct_truehd_channels_full_correction_and_atmos_promotion() { + let pid = 0x1100u16; + // format_info: top nibble 0x1 -> 96 kHz; low 13 bits 0x1F -> 7.1 (8ch). + let format_info = (0x1u32 << 28) | 0x1F; + let es = thd_major_sync_es(format_info, 4); // num_substreams=4 -> Atmos + let ts = thd_bd_pes(pid, &es); + let mut title = DiscTitle::empty(); + title.streams = vec![truehd_audio_stream( + pid, + AudioChannels::Surround51, // base 5.1 the MPLS descriptor understates + SampleRate::S48, // base 48 kHz the container guessed + )]; + title.extents = vec![Extent { + start_lba: 0, + sector_count: 1, + }]; + let mut reader = ThdSpyReader { + calls: std::cell::RefCell::new(Vec::new()), + data: ts, + }; + correct_truehd_channels(&mut reader, &mut title); + let Stream::Audio(a) = &title.streams[0] else { + panic!("stream type must be preserved") + }; + assert_eq!( + a.channels, + AudioChannels::Surround71, + "the 8ch major-sync presentation must correct the understated 5.1" + ); + assert_eq!( + a.sample_rate, + SampleRate::S96, + "the whitelisted 0x1 rate nibble must correct the guessed 48 kHz" + ); + assert_eq!( + a.label, + crate::labels::generate_audio_label_atmos( + &Codec::TrueHd, + &AudioChannels::Surround71, + false + ), + "basic descriptor + detected Atmos substream must promote the label" + ); + } + + /// A major sync whose 8ch/6ch presentation masks are BOTH set to values + /// with no real channel-count meaning (all 13 8ch bits, summing to 20) — + /// `AudioChannels::from_count` maps that to `Unknown`. The correction must + /// leave the container's channel count untouched rather than overwrite a + /// known-good value with `Unknown`. Kills the `new_ch != Unknown` guard's + /// `==` and `&&`-to-`||` mutants (both would let an unmapped count + /// clobber a valid `a.channels`). + #[test] + fn correct_truehd_channels_leaves_channels_when_count_unmapped() { + let pid = 0x1100u16; + // All 13 8ch bits set -> truehd_channels sums to 20 -> from_count(20) + // -> Unknown. Rate nibble 0x0 -> 48 kHz (matches the container, so + // this test isolates the channels guard from the rate guard). + let format_info = 0x1FFF; + let es = thd_major_sync_es(format_info, 0); // not Atmos + let ts = thd_bd_pes(pid, &es); + let mut title = DiscTitle::empty(); + title.streams = vec![truehd_audio_stream( + pid, + AudioChannels::Surround51, + SampleRate::S48, + )]; + title.extents = vec![Extent { + start_lba: 0, + sector_count: 1, + }]; + let mut reader = ThdSpyReader { + calls: std::cell::RefCell::new(Vec::new()), + data: ts, + }; + correct_truehd_channels(&mut reader, &mut title); + let Stream::Audio(a) = &title.streams[0] else { + panic!("stream type must be preserved") + }; + assert_eq!( + a.channels, + AudioChannels::Surround51, + "an unmapped (Unknown) major-sync channel count must not overwrite a known container value" + ); + } + + // ── bytes_bad_in_title: empty-input guard ──────────────────────────── + + // NOTE: `bad_ranges.is_empty() || title.extents.is_empty()` (mod.rs:628) — + // the `||`-to-`&&` mutant is EQUIVALENT here, not tested: the guard is a + // pure short-circuit. Whichever operand is empty, the corresponding loop + // (the outer `for ext in &title.extents` or the inner `for (pos, size) in + // bad_ranges`) simply iterates zero times and `total` stays its initial + // 0 — the early return changes nothing observable. See report. + + // ── byte_offset_in_title ────────────────────────────────────────────── + + fn title_with_size(size_bytes: u64, extents: Vec) -> DiscTitle { + DiscTitle { + size_bytes, + extents, + ..DiscTitle::empty() + } + } + + /// A multi-extent title where the target LBA lands in the SECOND extent. + /// Exercises both the boundary check for the FIRST (non-matching) extent + /// and the running `cumulative` byte total added on the way past it. + /// Kills: `lba >= start && lba < end` flipped to `||` (the first extent's + /// disjunction would trivially match almost any lba and return the wrong, + /// too-early offset); `cumulative +=` flipped to `*=` (cumulative is + /// seeded at 0, so `*=` freezes it at 0 forever); and `sector_count * + /// SECTOR_BYTES_U64` flipped to `+` or `/` (wrong per-extent byte length + /// folded into cumulative). + #[test] + fn byte_offset_in_title_accumulates_across_extents() { + let title = title_with_size( + 0, + vec![ + Extent { + start_lba: 100, + sector_count: 10, + }, // LBAs 100..110, 20_480 bytes + Extent { + start_lba: 200, + sector_count: 10, + }, // LBAs 200..210 + ], + ); + // lba 205 is 5 sectors into the SECOND extent. + let got = byte_offset_in_title(205, &title); + assert_eq!( + got, + Some(20_480 + 5 * 2048), + "offset must be the first extent's full byte length plus the \ + position within the second extent, not a first-extent mismatch" + ); + } + + /// An extent's end is EXCLUSIVE (`start_lba + sector_count`): the LBA one + /// past the last sector of an extent belongs to no extent (or the next + /// one), never this one. Kills `lba < ext_end` flipped to `<=`. + #[test] + fn byte_offset_in_title_extent_end_is_exclusive() { + let title = title_with_size( + 0, + vec![Extent { + start_lba: 100, + sector_count: 10, // covers LBAs 100..110 + }], + ); + assert_eq!( + byte_offset_in_title(110, &title), + None, + "LBA 110 is one past this extent's last sector (109) and must not resolve inside it" + ); + assert_eq!( + byte_offset_in_title(109, &title), + Some(9 * 2048), + "sanity: the extent's actual last sector still resolves" + ); + } + + // ── chapter_at_offset ───────────────────────────────────────────────── + + fn three_chapters() -> Vec { + vec![ + Chapter { + time_secs: 0.0, + name: "1".into(), + }, + Chapter { + time_secs: 50.0, + name: "2".into(), + }, + Chapter { + time_secs: 100.0, + name: "3".into(), + }, + ] + } + + /// Concrete end-to-end arithmetic check: byte_offset 60/100 of a 100s + /// title lands at t=60s, which is chapter index 1 (0-based, the last + /// chapter whose start <= 60) → 1-based chapter 2. Kills: the `/` + /// (time-fraction) flipped to `%` or `*`; the `*` (duration scale) + /// flipped to `+`; the `<=` chapter-scan comparison flipped to `>`; the + /// final `chapter_idx + 1` flipped to `-` or `*`; and every + /// whole-function fixed-tuple replacement (none produce `(2, 60.0)`). + #[test] + fn chapter_at_offset_concrete_arithmetic() { + let chapters = three_chapters(); + let got = chapter_at_offset(&chapters, 60, 100.0, 100); + assert_eq!( + got, + Some((2, 60.0)), + "byte 60/100 of a 100s title = t=60s = chapter 2 (1-based)" + ); + } + + /// `total_bytes == 0` must short-circuit to `None` (no title size to + /// compute a fraction against) regardless of whether chapters exist. + /// Kills `total_bytes == 0` flipped to `!=`, and (combined with the next + /// test) the `||` flipped to `&&`. + #[test] + fn chapter_at_offset_zero_total_bytes_is_none() { + let chapters = three_chapters(); + assert_eq!( + chapter_at_offset(&chapters, 10, 100.0, 0), + None, + "a title with no declared size has no byte-fraction to place a chapter at" + ); + } + + /// No chapters declared → `None`, even with a perfectly valid nonzero + /// title size. Kills the `||` flipped to `&&` (which would let this case + /// fall through the guard and return a bogus `Some((1, ..))` from the + /// then-empty scan loop). + #[test] + fn chapter_at_offset_no_chapters_is_none() { + assert_eq!( + chapter_at_offset(&[], 10, 100.0, 100), + None, + "a title with no chapters has nothing to report a chapter index against" + ); + } + + // ── range_chapter ───────────────────────────────────────────────────── + + fn title_for_range_chapter() -> DiscTitle { + DiscTitle { + duration_secs: 100.0, + size_bytes: 204_800, // 100 sectors * 2048 + chapters: three_chapters(), + extents: vec![Extent { + start_lba: 1_000, + sector_count: 100, + }], + ..DiscTitle::empty() + } + } + + /// Concrete positive case chaining `byte_offset_in_title` + + /// `chapter_at_offset`: lba 1060 is 60 sectors (122_880 bytes) into the + /// extent, 60% of the 204_800-byte title → t=60s → chapter 2. This exact + /// non-default tuple kills every fixed-tuple whole-function replacement + /// mutant (`(None, None)`, `(Some(0), ..)`, `(Some(1), ..)`, etc. — none + /// equal `(Some(2), Some(60.0))`). + #[test] + fn range_chapter_concrete_positive_case() { + let title = title_for_range_chapter(); + assert_eq!(range_chapter(1_060, &title), (Some(2), Some(60.0))); + } + + /// An LBA outside every extent resolves to `(None, None)`. + #[test] + fn range_chapter_outside_extents_is_none() { + let title = title_for_range_chapter(); + assert_eq!(range_chapter(5_000, &title), (None, None)); + } + + // ── locate_ranges ───────────────────────────────────────────────────── + + /// Isolates the per-range `lba`/`count` sector-arithmetic from every + /// bps-dependent branch (duration_secs is negative, so `bps` is 0.0 + /// under both the real `>` and any surviving `>=`/`==`/`<` mutant at that + /// same guard). Non-power-coincidental `pos`/`size` so `/` vs `%` vs `*` + /// all disagree with the expected quotient. Kills `pos / SECTOR_BYTES_U64` + /// and `size / SECTOR_BYTES_U64` each flipped to `%` or `*`. + #[test] + fn locate_ranges_lba_and_count_are_sector_quotients() { + let title = title_with_size(0, vec![]); + let mut title = title; + title.duration_secs = -1.0; + let result = locate_ranges(&[(5_000, 6_000)], &title); + assert_eq!(result.ranges.len(), 1); + assert_eq!(result.ranges[0].lba, 2, "5000 / 2048 = 2"); + assert_eq!(result.ranges[0].count, 2, "6000 / 2048 = 2"); + } + + /// Concrete positive-`bps` arithmetic for both `duration_ms` (per-range) + /// and `main_at_risk_ms` (title-wide): bps = 204_800 B / 100 s = 2048 + /// B/s exactly. A 4096-byte range = 2 sectors = 2000 ms at that rate, + /// and it's entirely inside the title's only extent so all of it counts + /// toward `main_at_risk_ms` too. Kills `bps > 0.0` flipped to `==`/`<` + /// (both would take the `else 0.0` branch here, wrongly reporting 0); + /// `(*size as f64) / bps` flipped to `%` or `*`; and the trailing + /// `* MILLIS_PER_SEC` flipped to `+` or `/`. + #[test] + fn locate_ranges_positive_bps_duration_and_at_risk() { + let title = title_with_size( + 204_800, + vec![Extent { + start_lba: 0, + sector_count: 100, + }], + ); + let mut title = title; + title.duration_secs = 100.0; + let result = locate_ranges(&[(0, 4096)], &title); + assert_eq!(result.ranges.len(), 1); + assert_eq!( + result.ranges[0].duration_ms, 2000.0, + "4096 B / 2048 B/s * 1000 = 2000 ms" + ); + assert_eq!(result.largest_gap_ms, 2000.0); + assert_eq!( + result.main_at_risk_ms, 2000.0, + "the range is entirely inside the title's extent" + ); + } + + /// `bps` computed exactly `0.0` (duration_secs == 0.0): both the + /// per-range `duration_ms` and title-wide `main_at_risk_ms` must stay + /// `0.0`, never a divide-by-zero `inf`/`NaN`. Kills `bps > 0.0` flipped + /// to `>=` at BOTH sites (mod.rs:743 and mod.rs:768) — with the boundary + /// exactly zero, `>=` wrongly takes the division branch and produces + /// `inf` instead of the real code's `0.0`. + #[test] + fn locate_ranges_zero_bps_stays_zero_not_infinite() { + let title = title_with_size( + 204_800, + vec![Extent { + start_lba: 0, + sector_count: 100, + }], + ); + // duration_secs left at DiscTitle::empty()'s default 0.0. + let result = locate_ranges(&[(0, 4096)], &title); + assert_eq!(result.ranges[0].duration_ms, 0.0); + assert_eq!(result.main_at_risk_ms, 0.0); + } + + // NOTE: mod.rs:732 (`title.duration_secs > 0.0` seeding `bps`) — the + // `>`-to-`>=` mutant is EQUIVALENT. `bps` is only ever consumed behind + // its own `bps > 0.0` re-check at both use sites (mod.rs:743, 768); at + // the exact boundary (`duration_secs == 0.0`) the mutant instead computes + // `bps = size_bytes / 0.0` (`inf` or `NaN` since `size_bytes >= 0`), and + // `inf > 0.0` / `NaN > 0.0` are both `false` at the re-check — so the + // final output (`0.0` via the `else` branch) is identical either way. No + // reachable input makes this observable. + + // ── Codec::name / Display ──────────────────────────────────────────── + + /// `name()` is a linear lookup keyed by `==` against `ALL_CODECS`; picking + /// a codec that is NOT the first table entry means a mutated `==`→`!=` + /// returns the (wrong) first entry's name instead. `Unknown` isn't in the + /// table at all, exercising the post-loop fallback. + #[test] + fn codec_name_lookup_and_unknown_fallback() { + assert_eq!(Codec::Hevc.name(), "HEVC"); + assert_eq!(Codec::TrueHd.name(), "TrueHD"); + assert_eq!( + Codec::Unknown(0xAB).name(), + "Unknown", + "a coding type outside the table falls back to the literal \"Unknown\"" + ); + } + + /// `Display` must forward to `name()`, not silently emit nothing. + #[test] + fn codec_display_forwards_to_name() { + assert_eq!(format!("{}", Codec::TrueHd), "TrueHD"); + } + + // ── Resolution::is_sd / from_height ────────────────────────────────── + + #[test] + fn resolution_is_sd_matches_sd_variants_only() { + assert!(Resolution::R480i.is_sd()); + assert!(Resolution::R480p.is_sd()); + assert!(Resolution::R576i.is_sd()); + assert!(Resolution::R576p.is_sd()); + assert!(!Resolution::R720p.is_sd()); + assert!(!Resolution::R1080p.is_sd()); + assert!(!Resolution::Unknown.is_sd()); + } + + /// Every from_height bucket boundary — deleting any one match arm makes + /// its heights fall through to the NEXT surviving arm (a strictly + /// different variant), so each of these pairs (top of one bucket, bottom + /// of the next) pins the arm to its own boundary. + #[test] + fn resolution_from_height_bucket_boundaries() { + assert_eq!(Resolution::from_height(0), Resolution::R480p); + assert_eq!(Resolution::from_height(480), Resolution::R480p); + assert_eq!(Resolution::from_height(481), Resolution::R576p); + assert_eq!(Resolution::from_height(576), Resolution::R576p); + assert_eq!(Resolution::from_height(577), Resolution::R720p); + assert_eq!(Resolution::from_height(720), Resolution::R720p); + assert_eq!(Resolution::from_height(721), Resolution::R1080p); + assert_eq!(Resolution::from_height(1080), Resolution::R1080p); + assert_eq!(Resolution::from_height(1081), Resolution::R2160p); + assert_eq!(Resolution::from_height(2160), Resolution::R2160p); + assert_eq!(Resolution::from_height(2161), Resolution::R4320p); + } + + // ── AudioChannels::from_count ───────────────────────────────────────── + + /// Every mapped count 1..=8, plus an out-of-range fallback. Deleting any + /// one match arm makes that count fall through to `_ => Unknown`. + #[test] + fn audio_channels_from_count_every_mapped_value() { + assert_eq!(AudioChannels::from_count(1), AudioChannels::Mono); + assert_eq!(AudioChannels::from_count(2), AudioChannels::Stereo); + assert_eq!(AudioChannels::from_count(3), AudioChannels::Stereo21); + assert_eq!(AudioChannels::from_count(4), AudioChannels::Quad); + assert_eq!(AudioChannels::from_count(5), AudioChannels::Surround50); + assert_eq!(AudioChannels::from_count(6), AudioChannels::Surround51); + assert_eq!(AudioChannels::from_count(7), AudioChannels::Surround61); + assert_eq!(AudioChannels::from_count(8), AudioChannels::Surround71); + assert_eq!(AudioChannels::from_count(0), AudioChannels::Unknown); + assert_eq!(AudioChannels::from_count(9), AudioChannels::Unknown); + } + + // ── SampleRate::from_hz ─────────────────────────────────────────────── + + /// Every rate the enum can represent, expressed in Hz. Deleting any one + /// match arm sends that rate to `_ => Unknown`, so a rate-by-rate check + /// pins each arm independently. The two combo rates (`S48_96`, + /// `S48_192`) deliberately have no Hz spelling — `hz()` collapses them + /// onto 48000, so 48000 must map back to the plain `S48` and nothing else. + #[test] + fn sample_rate_from_hz_every_mapped_rate() { + assert_eq!(SampleRate::from_hz(44_100), SampleRate::S44_1); + assert_eq!(SampleRate::from_hz(48_000), SampleRate::S48); + assert_eq!(SampleRate::from_hz(88_200), SampleRate::S88_2); + assert_eq!(SampleRate::from_hz(96_000), SampleRate::S96); + assert_eq!(SampleRate::from_hz(176_400), SampleRate::S176_4); + assert_eq!(SampleRate::from_hz(192_000), SampleRate::S192); + assert_eq!(SampleRate::from_hz(0), SampleRate::Unknown); + assert_eq!(SampleRate::from_hz(32_000), SampleRate::Unknown); + } + + /// `from_hz` must invert `hz()` for every rate that has a single Hz + /// value (i.e. all but the two combo rates, whose `hz()` reports their + /// primary 48 kHz). Round-tripping rather than restating the table keeps + /// this honest if a rate is ever added. + #[test] + fn sample_rate_from_hz_inverts_hz_for_single_rate_variants() { + for r in [ + SampleRate::S44_1, + SampleRate::S48, + SampleRate::S88_2, + SampleRate::S96, + SampleRate::S176_4, + SampleRate::S192, + ] { + assert_eq!( + SampleRate::from_hz(r.hz() as u32), + r, + "from_hz must round-trip {r:?}" + ); + } + } + + // ── HdrFormat / ColorSpace: name, Display, FromStr ──────────────────── + + /// `Display` must forward to `name()`, not emit an empty string: these + /// strings reach Matroska track names and the JSON sink, where a blank + /// HDR field is indistinguishable from "no HDR metadata". + #[test] + fn hdr_format_display_forwards_to_name() { + assert_eq!(format!("{}", HdrFormat::Hdr10Plus), "HDR10+"); + assert_eq!(format!("{}", HdrFormat::DolbyVision), "Dolby Vision"); + assert_eq!(format!("{}", HdrFormat::Hlg), HdrFormat::Hlg.name()); + } + + /// `FromStr` accepts the human display names as well as the compact ids, + /// via a second linear scan keyed on `name(v) == s`. Every probe here is + /// a display name that is NOT its own id, so it can only be resolved by + /// that second scan — and none of them is the first table entry, so a + /// scan whose comparison is inverted returns the wrong (first) variant + /// rather than the right one. + #[test] + fn hdr_format_from_str_resolves_display_names() { + assert_eq!( + "Dolby Vision".parse::(), + Ok(HdrFormat::DolbyVision) + ); + assert_eq!("HDR10+".parse::(), Ok(HdrFormat::Hdr10Plus)); + assert_eq!("HLG".parse::(), Ok(HdrFormat::Hlg)); + // An unrecognised string is an error, never a silent SDR. + assert_eq!("not-an-hdr-format".parse::(), Err(())); + } + + /// `ColorSpace::name` is the ITU-R designation used in track metadata. + /// `Unknown` is the one variant with no designation: it names the empty + /// string so nothing prints a fabricated colour space. + #[test] + fn color_space_name_is_the_itu_designation() { + assert_eq!(ColorSpace::Bt709.name(), "BT.709"); + assert_eq!(ColorSpace::Bt2020.name(), "BT.2020"); + assert_eq!(ColorSpace::Bt470bg.name(), "BT.470BG"); + assert_eq!(ColorSpace::Smpte170m.name(), "SMPTE 170M"); + assert!(ColorSpace::Unknown.name().is_empty()); + } + + /// `Display` must forward to `name()`. + #[test] + fn color_space_display_forwards_to_name() { + assert_eq!(format!("{}", ColorSpace::Bt2020), "BT.2020"); + assert_eq!( + format!("{}", ColorSpace::Smpte170m), + ColorSpace::Smpte170m.name() + ); + } + + /// Same second-scan property as `HdrFormat`: display names resolve, and + /// they resolve to THEIR OWN variant. `ColorSpace` has no error case — an + /// unrecognised string is `Unknown`, not `Err`. + #[test] + fn color_space_from_str_resolves_display_names() { + assert_eq!("BT.2020".parse::(), Ok(ColorSpace::Bt2020)); + assert_eq!("BT.470BG".parse::(), Ok(ColorSpace::Bt470bg)); + assert_eq!( + "SMPTE 170M".parse::(), + Ok(ColorSpace::Smpte170m) + ); + assert_eq!("bt2020".parse::(), Ok(ColorSpace::Bt2020)); + assert_eq!("nonsense".parse::(), Ok(ColorSpace::Unknown)); + } + + // ── DiscTitle stream filters ────────────────────────────────────────── + + /// A title whose stream list interleaves all three kinds. Each accessor + /// must yield exactly its own kind, in declared order — an accessor that + /// yields nothing (or drops its match arm) would leave stream selection + /// and the info panel with no tracks at all. + #[test] + fn disc_title_stream_filters_select_their_own_kind_in_order() { + let mut title = DiscTitle::empty(); + title.streams = vec![ + Stream::Subtitle(SubtitleStream { + pid: 0x1200, + codec: Codec::Pgs, + language: "eng".into(), + forced: false, + qualifier: LabelQualifier::None, + codec_data: None, + }), + Stream::Video(VideoStream { + pid: 0x1011, + codec: Codec::Hevc, + resolution: Resolution::R2160p, + frame_rate: FrameRate::F23_976, + hdr: HdrFormat::Hdr10, + color_space: ColorSpace::Bt2020, + display_aspect: None, + secondary: false, + label: String::new(), + measured_cicp: None, + }), + Stream::Audio(AudioStream { + pid: 0x1100, + codec: Codec::TrueHd, + channels: AudioChannels::Surround71, + language: "eng".into(), + sample_rate: SampleRate::S48, + secondary: false, + purpose: LabelPurpose::Normal, + label: String::new(), + }), + Stream::Audio(AudioStream { + pid: 0x1101, + codec: Codec::Ac3, + channels: AudioChannels::Stereo, + language: "fra".into(), + sample_rate: SampleRate::S48, + secondary: true, + purpose: LabelPurpose::Commentary, + label: String::new(), + }), + // Blu-ray 3D dependent view: a second video stream. + Stream::Video(VideoStream { + pid: 0x1012, + codec: Codec::H264, + resolution: Resolution::R1080p, + frame_rate: FrameRate::F23_976, + hdr: HdrFormat::Sdr, + color_space: ColorSpace::Bt709, + display_aspect: None, + secondary: true, + label: String::new(), + measured_cicp: None, + }), + ]; + + let audio: Vec = title.audio_streams().map(|a| a.pid).collect(); + assert_eq!( + audio, + vec![0x1100, 0x1101], + "audio_streams must yield both audio PIDs in declared order" + ); + let subs: Vec = title.subtitle_streams().map(|s| s.pid).collect(); + assert_eq!(subs, vec![0x1200]); + let video: Vec = title.video_streams().map(|v| v.pid).collect(); + assert_eq!( + video, + vec![0x1011, 0x1012], + "video_streams must yield the base view then the dependent view" + ); + // The three filters partition the stream list: nothing is dropped and + // nothing is counted twice. + assert_eq!(audio.len() + subs.len() + video.len(), title.streams.len()); + // Each accessor's payload is the real stream, not a placeholder. + assert_eq!( + title.audio_streams().next().unwrap().channels, + AudioChannels::Surround71 + ); + assert_eq!( + title.video_streams().next().unwrap().resolution, + Resolution::R2160p + ); + assert_eq!(title.subtitle_streams().next().unwrap().language, "eng"); + } + + // ── DiscId::name ────────────────────────────────────────────────────── + + /// The disc's best available name: the META/DL `bdmt_*.xml` title when the + /// disc carries one, otherwise the UDF Volume Identifier. Never a constant + /// and never empty when either source has content — this string names the + /// output file and the rip's directory. + #[test] + fn disc_id_name_prefers_meta_title_then_volume_id() { + let with_meta = DiscId { + volume_id: "SAMPLE_FILM".to_string(), + meta_title: Some("Sample Film".to_string()), + format: DiscFormat::BluRay, + capacity_sectors: 0, + encrypted: false, + layers: 1, + }; + assert_eq!(with_meta.name(), with_meta.meta_title.as_deref().unwrap()); + let without_meta = DiscId { + meta_title: None, + ..with_meta + }; + assert_eq!(without_meta.name(), without_meta.volume_id); + } + + // ── canonical_title_order: the capacity gate is STRICTLY greater-than ── + + /// The "physically possible on this disc" gate is `size_bytes <= + /// capacity_bytes` (see [`Disc::canonical_title_order`]'s contract: + /// *"Real titles (`size_bytes ≤ capacity_bytes`) before virtual + /// composites"*). A title whose declared size EXACTLY equals the disc + /// capacity fits — a full-disc single-layer authoring, no double-counted + /// clips — so it is a REAL title and must outrank the oversize composite, + /// never be demoted alongside it. + /// + /// Asserted on the comparator directly (both argument orders), not on a + /// sort: an inconsistent comparator produces an implementation-defined + /// permutation, which would make a sort-based assertion prove nothing. + #[test] + fn canonical_order_capacity_gate_admits_a_title_that_exactly_fills_the_disc() { + use std::cmp::Ordering; + const CAP: u64 = 50_000_000_000; + // Exactly fills the disc — physically possible, therefore real. + let exact = title_with("00800.mpls", 7_200.0, CAP, 1); + // Twice the disc: cannot exist unless clips are double-counted. + let huge = title_with("00020.mpls", 15_000.0, CAP * 2, 253); + // A smaller real title. + let smaller = title_with("00200.mpls", 3_600.0, CAP / 2, 1); + + // exact (real) before huge (composite), whichever way round it is asked. + assert_eq!( + Disc::canonical_title_order(&exact, &huge, CAP), + Ordering::Less, + "a title that exactly fills the disc is real and outranks the oversize composite" + ); + assert_eq!( + Disc::canonical_title_order(&huge, &exact, CAP), + Ordering::Greater, + "the oversize composite is demoted behind the exactly-fitting real title" + ); + // Both real: the LARGER real title wins. `exact` is the larger, so it + // must still be treated as real when it is the RIGHT-hand argument. + assert_eq!( + Disc::canonical_title_order(&smaller, &exact, CAP), + Ordering::Greater, + "the exactly-fitting title is real on the right-hand side too, and it is larger" + ); + assert_eq!( + Disc::canonical_title_order(&exact, &smaller, CAP), + Ordering::Less + ); + } + + // ── audio_richness: the same-size / same-duration tiebreak ───────────── + + /// A title carrying the given audio tracks, with size and duration fixed so + /// every comparison below falls through to the audio-richness tiebreak. + fn title_with_audio(audio: &[(Codec, AudioChannels)]) -> DiscTitle { + DiscTitle { + size_bytes: 40_000_000_000, + duration_secs: 7_200.0, + streams: audio + .iter() + .enumerate() + .map(|(i, &(codec, channels))| { + Stream::Audio(AudioStream { + pid: 0x1100 + i as u16, + codec, + channels, + language: "eng".into(), + sample_rate: SampleRate::S48, + secondary: false, + purpose: LabelPurpose::Normal, + label: String::new(), + }) + }) + .collect(), + ..DiscTitle::empty() + } + } + + /// Equal-size, equal-duration sibling playlists (the same feature authored + /// twice — a full-audio main and an audio-reduced twin) are separated by + /// audio richness, ranked `(any lossless, best channel count, track count)` + /// with richer first. Each assertion below varies exactly ONE component of + /// that key and holds the other two equal, so each component is pinned + /// independently; the final pair is identical in all three and must compare + /// Equal, so "richer first" is not satisfied by a comparator that simply + /// never reports a tie. + #[test] + fn canonical_order_breaks_equal_size_ties_on_audio_richness() { + use std::cmp::Ordering; + const CAP: u64 = 50_000_000_000; + + // (1) lossless beats lossy at the same channel count and track count. + let lossless = title_with_audio(&[(Codec::DtsHdMa, AudioChannels::Stereo)]); + let lossy = title_with_audio(&[(Codec::Ac3, AudioChannels::Stereo)]); + assert_eq!( + Disc::canonical_title_order(&lossless, &lossy, CAP), + Ordering::Less, + "a lossless track outranks a lossy one" + ); + assert_eq!( + Disc::canonical_title_order(&lossy, &lossless, CAP), + Ordering::Greater + ); + + // (2) more channels wins when both are lossy and single-track. + let surround = title_with_audio(&[(Codec::Ac3, AudioChannels::Surround51)]); + assert_eq!( + Disc::canonical_title_order(&surround, &lossy, CAP), + Ordering::Less, + "5.1 outranks stereo at the same losslessness" + ); + + // (3) more tracks wins when losslessness and channel count are equal. + let two_tracks = title_with_audio(&[ + (Codec::Ac3, AudioChannels::Stereo), + (Codec::Ac3, AudioChannels::Stereo), + ]); + assert_eq!( + Disc::canonical_title_order(&two_tracks, &lossy, CAP), + Ordering::Less, + "the title with more audio tracks is the richer one" + ); + + // (4) identical audio really is a tie. + let same = title_with_audio(&[(Codec::Ac3, AudioChannels::Stereo)]); + assert_eq!( + Disc::canonical_title_order(&same, &lossy, CAP), + Ordering::Equal, + "identical titles must compare Equal — the tiebreak is a real comparison, not a constant" + ); + } + + // ── detect_disc_format: the MKB-less BDMV fallback ──────────────────── + + /// A BDMV-tree disc with NO readable `/AACS/MKB_RO.inf` (unencrypted, or an + /// unreadable MKB) has no AACS generation to classify by, so the format + /// falls back to video resolution. Two rules apply there: + /// * UHD resolution PROMOTES the disc to [`DiscFormat::Uhd`] — the ECC + /// block sweep is sized off this, so losing the promotion mis-sizes it; + /// * everything else is clamped UP to [`DiscFormat::BluRay`], because a + /// BD-tree disc is never a DVD even when an SD bonus title is scanned + /// first. + #[test] + fn mkb_less_bdmv_disc_is_promoted_to_uhd_by_resolution_and_clamped_up_otherwise() { + use crate::udf::fixture::*; + let mut disc = MemDisc::new(); + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + // BDMV only — no /AACS, so there is no MKB Type record to read. + subdirs: vec![DirSpec { + name: "BDMV".into(), + icb_lba: 12, + dir_data_lba: 13, + files: Vec::new(), + subdirs: vec![], + }], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + + let uhd = [title_with_video(Codec::Hevc, Resolution::R2160p)]; + assert_eq!( + Disc::detect_disc_format(&mut disc, &udf, &uhd), + DiscFormat::Uhd, + "a 2160p BDMV disc with no MKB is a UHD" + ); + let hd = [title_with_video(Codec::H264, Resolution::R1080p)]; + assert_eq!( + Disc::detect_disc_format(&mut disc, &udf, &hd), + DiscFormat::BluRay + ); + let sd = [title_with_video(Codec::Mpeg2, Resolution::R480i)]; + assert_eq!( + Disc::detect_disc_format(&mut disc, &udf, &sd), + DiscFormat::BluRay, + "an SD title on a BDMV disc must never downgrade the disc to DVD" + ); + } + + // ── encrypted_content_ranges ────────────────────────────────────────── + + /// The authoritative "which sectors are AACS content" map is the UNION of + /// every title's extents, sorted and merged into a disjoint set. Titles + /// routinely share clips (a play-all playlist references the feature's + /// clips), so the raw per-title extents overlap and arrive in playlist + /// order, not LBA order. The fixture below carries all three shapes at + /// once — an OVERLAP across two titles, an ADJACENT pair, and a DISJOINT + /// region — supplied out of order. + #[test] + fn encrypted_content_ranges_unions_sorts_and_merges_every_titles_extents() { + let mut disc = make_test_disc(200_000, "BD"); + let mut feature = DiscTitle::empty(); + feature.extents = vec![ext(1_000, 100), ext(5_000, 50)]; + let mut play_all = DiscTitle::empty(); + // [1050,1250) overlaps the feature's [1000,1100); [1250,1260) is + // exactly adjacent to it. + play_all.extents = vec![ext(1_050, 200), ext(1_250, 10)]; + disc.titles = vec![play_all, feature]; + + assert_eq!( + disc.encrypted_content_ranges(), + vec![(1_000, 260), (5_000, 50)], + "the encrypted-content map is the merged, disjoint union of every title's extents" + ); + + // No parsed titles => no content gate at all (callers fall back). + let unscanned = make_test_disc(200_000, "BD"); + assert!( + unscanned.encrypted_content_ranges().is_empty(), + "a disc with no titles declares no encrypted content" + ); + } + + // ── aacs_disc_hash ──────────────────────────────────────────────────── + + /// The disc hash names the disc in an [`Error::NoDiscKey`] so the caller can + /// tell the user which keydb entry to add — it must be the disc's OWN + /// captured SHA-1 of `Unit_Key_RO.inf`, in the bare 40-hex form the keydb is + /// keyed on (the stored field carries a `0x` prefix). A disc with no AACS + /// state has no hash to report, and reports nothing rather than a + /// placeholder that would send the user hunting a non-existent entry. + #[test] + fn aacs_disc_hash_is_the_captured_hash_without_its_0x_prefix() { + const SHA1: &str = "0123456789abcdef0123456789abcdef01234567"; + let mut disc = make_test_disc(1_000, "UHD"); + assert!( + disc.aacs_disc_hash().is_empty(), + "no AACS state => no disc to name" + ); + disc.aacs = Some(AacsState { + disc_hash: format!("0x{SHA1}"), + ..aacs_with(Vec::new()) + }); + assert_eq!(disc.aacs_disc_hash(), SHA1); + // Already bare (no prefix) passes through unchanged, never re-stripped. + disc.aacs = Some(AacsState { + disc_hash: SHA1.to_string(), + ..aacs_with(Vec::new()) + }); + assert_eq!(disc.aacs_disc_hash(), SHA1); + } + + // ── decrypt_keys_for_title: CSS crack-span reuse is half-open ───────── + + /// A CSS title key is per-VTS: reusing the scan's cracked key for a title + /// that lives OUTSIDE the cracked span descrambles that title with the wrong + /// key, and the mux emits garbage at exit 0. `crack_span` is documented as + /// the half-open LBA span `[start, end)`, so overlap is + /// `extent.start < span.end && span.start < extent.end` — both comparisons + /// STRICT. A title that merely ABUTS the span (ends exactly where it begins, + /// or begins exactly where it ends) shares no sector with it and must NOT + /// reuse the key. + /// + /// The reader serves only clear (all-zero) sectors, so a title that falls + /// through to its own crack is reported unencrypted — distinguishable from + /// the reused-key answer both in the key and in the is-clear flag. + #[test] + fn decrypt_keys_for_title_css_span_reuse_is_half_open() { + const KEY: [u8; 5] = [0xA1, 0xB2, 0xC3, 0xD4, 0xE5]; + let mut disc = make_test_disc(200_000, "DVD"); + disc.format = DiscFormat::Dvd; + disc.content_format = ContentFormat::MpegPs; + disc.encrypted = true; + // Key cracked from sectors [250, 300). + disc.css = Some(crate::css::CssState { + title_key: KEY, + crack_span: Some((250, 300)), + }); + let mk = |extents: &[(u32, u32)]| { + let mut t = title_with_video(Codec::Mpeg2, Resolution::R480p); + t.extents = extents + .iter() + .map(|&(start_lba, sector_count)| Extent { + start_lba, + sector_count, + }) + .collect(); + t + }; + disc.titles = vec![ + // 0: [200,250) — ends exactly where the span begins. + mk(&[(200, 50)]), + // 1: [300,350) — begins exactly where the span ends. + mk(&[(300, 50)]), + // 2: [260,270) — genuinely inside the span. + mk(&[(260, 10)]), + // 3: no extents at all. + mk(&[]), + ]; + let mut clear = CssMapReader { + key: KEY, + scrambled: (0, 0), + reads: std::cell::RefCell::new(Vec::new()), + }; + + for idx in [0usize, 1] { + let (keys, title_is_clear) = disc.decrypt_keys_for_title(idx, &mut clear, 16); + assert!( + matches!(keys, crate::decrypt::DecryptKeys::None), + "title {idx} only ABUTS the crack span — it shares no sector with it, so the \ + per-VTS key must not be reused" + ); + assert!( + title_is_clear, + "title {idx} re-cracks from its own (clear) extents and is reported unencrypted" + ); + } + + let (keys, title_is_clear) = disc.decrypt_keys_for_title(2, &mut clear, 16); + match keys { + crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!( + title_key, KEY, + "a title INSIDE the crack span reuses the scan's key" + ), + _ => panic!("expected the reused CSS key for an overlapping title"), + } + assert!(!title_is_clear); + + // A title with NO extents has nothing to crack from: it short-circuits + // to the disc-wide keys and is marked clear, so the decrypt gate's + // "None keys + not clear" rule cannot hard-fail it. + let (keys, title_is_clear) = disc.decrypt_keys_for_title(3, &mut clear, 16); + match keys { + crate::decrypt::DecryptKeys::Css { title_key } => assert_eq!(title_key, KEY), + _ => panic!("an extent-less title must return the disc-wide keys"), + } + assert!( + title_is_clear, + "an extent-less title is clear — nothing scrambled to worry about" + ); + } + + // ── mapfile paths ───────────────────────────────────────────────────── + + /// The mapfile sits BESIDE the output as `.mapfile`: the suffix is + /// appended to the whole path, never substituted for the extension (which + /// would make `movie.iso` and `movie.mkv` share one mapfile). + #[test] + fn mapfile_path_for_appends_the_suffix_to_the_whole_output_path() { + assert_eq!( + mapfile_path_for(std::path::Path::new("/tmp/rip/movie.iso")), + std::path::PathBuf::from("/tmp/rip/movie.iso.mapfile") + ); + assert_eq!( + mapfile_path_for(std::path::Path::new("/tmp/rip/movie")), + std::path::PathBuf::from("/tmp/rip/movie.mapfile") + ); + } + + /// Regular output: `Disc::mapfile_for` is the plain `.mapfile` rule. + #[test] + fn mapfile_for_regular_output_is_the_output_path_plus_suffix() { + let disc = make_test_disc(1_000, "SOME_DISC"); + assert_eq!( + disc.mapfile_for(std::path::Path::new("/tmp/rip/movie.iso")), + std::path::PathBuf::from("/tmp/rip/movie.iso.mapfile") + ); + } + + /// `/dev/null` output (the benchmark sink) cannot host a sibling mapfile, so + /// the mapfile is named from the disc and placed in the temp dir. The name + /// is sanitized to `[A-Za-z0-9-_]` — every other character, including the + /// spaces and punctuation that appear in real META/DL titles and the + /// non-ASCII ones, becomes `_` — because this string is used verbatim as a + /// filename. + #[test] + fn mapfile_for_dev_null_sanitizes_the_disc_name_into_a_temp_path() { + let mut disc = make_test_disc(1_000, "VOLUME_ID"); + // Keeps: alphanumeric, '-', '_'. Replaces: space, '!', non-ASCII. + disc.meta_title = Some("A-B_c1 d!é".into()); + assert_eq!( + disc.mapfile_for(std::path::Path::new("/dev/null")), + std::env::temp_dir().join("A-B_c1_d__.mapfile") + ); + // The UDF volume id is the fallback when the disc carries no META/DL + // title. + disc.meta_title = None; + assert_eq!( + disc.mapfile_for(std::path::Path::new("/dev/null")), + std::env::temp_dir().join("VOLUME_ID.mapfile") + ); + } } diff --git a/src/disc/pgs_forced_probe.rs b/src/disc/pgs_forced_probe.rs index 09a68f4..9f372c3 100644 --- a/src/disc/pgs_forced_probe.rs +++ b/src/disc/pgs_forced_probe.rs @@ -1292,4 +1292,240 @@ mod tests { assert!(s.forced, "inconclusive run keeps the vendor flag"); assert!(cache.is_empty(), "inconclusive run is not memoised"); } + + // ── mutation-triage additions ─────────────────────────────────────────── + + /// Mutation guard for the `sub.codec == Codec::Pgs` match guard (probe's PID + /// collection): only PGS subtitle tracks are ever probed by content — DVD + /// VobSub forced comes from the IFO/vendor path, never from sniffing PGS + /// segments over non-PGS bytes. If the guard were dropped, a non-PGS + /// subtitle stream would be treated as a PGS PID and the reader would be + /// touched even though there is nothing PGS to probe. + #[test] + fn non_pgs_subtitle_codec_is_excluded_from_the_probe() { + let mut reader = EndlessReader { served: 0 }; + let mut title = pgs_title(0x1200, true); + title.streams = vec![Stream::Subtitle(SubtitleStream { + pid: 0x1200, + codec: Codec::DvdSub, + language: "eng".into(), + forced: true, + qualifier: LabelQualifier::None, + codec_data: None, + })]; + title.extents = vec![Extent { + start_lba: 0, + sector_count: u32::MAX, + }]; + probe_and_set_forced(&mut reader, &mut title, &mut ForcedProbeCache::new(), None); + assert_eq!( + reader.served, 0, + "a non-PGS subtitle codec must never be probed as if it were PGS" + ); + } + + /// Mutation guard for `stalled > STALL_RETRY_LIMIT`: exactly + /// `STALL_RETRY_LIMIT` retries are allowed (`STALL_RETRY_LIMIT + 1` total + /// read attempts) before the stalled run gives up. Weakening the + /// comparison to `==` or `>=` still stops the spin (so a `<=` bound alone + /// does not catch it), but one retry early — after `STALL_RETRY_LIMIT` + /// attempts instead of `STALL_RETRY_LIMIT + 1`. + #[test] + fn stalled_retries_stop_at_exactly_the_limit() { + let pid = 0x1200u16; + let mut reader = ShortReader { + batch: 1, // never a whole aligned unit + served: Vec::new(), + }; + let mut title = pgs_title(pid, true); + title.extents = vec![Extent { + start_lba: 0, + sector_count: CHUNK_SECTORS as u32, + }]; + let mut cache = ForcedProbeCache::new(); + probe_and_set_forced(&mut reader, &mut title, &mut cache, None); + + assert_eq!( + reader.served.len() as u32, + STALL_RETRY_LIMIT + 1, + "expected exactly STALL_RETRY_LIMIT + 1 read attempts before giving up, got {}", + reader.served.len() + ); + } + + /// `STALL_RETRY_LIMIT` padding TS packets (sync byte only, PID 0 → + /// `adaptation == 0` → discarded harmlessly by the demuxer) so the real + /// display set lands at a byte offset that survives a correct + /// `got * SECTOR_BYTES` feed length but is cut off by a mutated + /// `got + SECTOR_BYTES`. + fn filler_packets(count: usize) -> Vec { + let mut v = vec![0u8; count * 192]; + for i in 0..count { + v[i * 192 + 4] = 0x47; // sync byte only; pid 0, adaptation 0 → discarded + } + v + } + + /// Mutation guard for `got as usize * SECTOR_BYTES` (the feed-length + /// computation on a fully-served chunk): a 3-sector read must hand the + /// WHOLE 6144-byte chunk to the demuxer. Padding pushes the real display + /// set to byte 4032 — past `got + SECTOR_BYTES` (2051) but inside + /// `got * SECTOR_BYTES` (6144) — so a mutated addition would silently + /// drop it from the feed and the run would never observe it. + #[test] + fn feed_uses_the_full_read_length_not_a_truncated_one() { + let pid = 0x1200u16; + let mut data = filler_packets(21); // 21 * 192 = 4032 bytes of padding + data.extend_from_slice(&ts_stream(pid, &pcs_display(false))); + let mut reader = TsReader { data, pos: 0 }; + let mut title = pgs_title(pid, true); // vendor label: forced + title.extents = vec![Extent { + start_lba: 0, + // 3 * 2048 = 6144 B: covers all 4416 B of real data plus the + // reader's zero padding to the sector boundary, in one read. + sector_count: 3, + }]; + probe_and_set_forced(&mut reader, &mut title, &mut ForcedProbeCache::new(), None); + let Stream::Subtitle(s) = &title.streams[0] else { + panic!() + }; + assert!( + !s.forced, + "the padding-shifted non-forced display set must still reach the demuxer \ + and clear the vendor-forced flag" + ); + } + + /// A reader that serves fixed content until exhausted, then unlimited + /// zeros — like [`PartialTsReader`]'s `ThenWhat::Zeros`, but also counts + /// every sector requested (not just what one extent's read attempted), so + /// a test can measure how much of a SECOND, effectively infinite extent + /// actually got read. + struct RealThenZerosReader { + inner: TsReader, + served: u32, + } + impl SectorSource for RealThenZerosReader { + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + ) -> crate::error::Result { + self.served += count as u32; + if self.inner.pos < self.inner.data.len() { + self.inner.read_sectors(lba, count, buf, recovery) + } else { + let want = count as usize * SECTOR_BYTES; + buf[..want].fill(0); + Ok(want) + } + } + fn capacity_sectors(&self) -> u32 { + u32::MAX + } + } + + /// Mutation guard for the `||` in the early-exit check ("every track has + /// already shown a non-forced set — counting evidence CARRIED IN from + /// other extents"): non-forced evidence carried in from a prior extent + /// must stop reading a later extent immediately, even though that later + /// extent's OWN fresh tracker has not itself observed anything. Weakening + /// `||` to `&&` requires local confirmation too, so a huge trailing extent + /// with no PGS content of its own would be read all the way to the sector + /// budget instead of one chunk. + #[test] + fn carried_non_forced_evidence_stops_reading_a_content_free_extent() { + let pid = 0x1200u16; + let mut reader = RealThenZerosReader { + inner: TsReader { + data: ts_stream(pid, &pcs_display(false)), + pos: 0, + }, + served: 0, + }; + let mut title = multi_read_pgs_title(pid, true); // vendor label: forced + let mut cache = ForcedProbeCache::new(); + probe_and_set_forced(&mut reader, &mut title, &mut cache, None); + + let Stream::Subtitle(s) = &title.streams[0] else { + panic!() + }; + assert!( + !s.forced, + "non-forced evidence from extent 1 must still clear the forced flag" + ); + assert!( + reader.served < PROBE_BUDGET_SECTORS, + "carried non-forced evidence must stop extent 2's read after a single \ + chunk, not run it to the sector budget; served {}", + reader.served + ); + } + + /// Counts `tracing` events on target `freemkv::scan`, so a test can prove + /// a debug log fires (or doesn't) without depending on any output + /// formatting. + #[derive(Clone)] + struct ScanDebugCounter(std::sync::Arc); + impl tracing::Subscriber for ScanDebugCounter { + fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { + metadata.target() == "freemkv::scan" + } + fn new_span(&self, _span: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {} + fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {} + fn event(&self, event: &tracing::Event<'_>) { + if event.metadata().target() == "freemkv::scan" { + self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + } + fn enter(&self, _span: &tracing::span::Id) {} + fn exit(&self, _span: &tracing::span::Id) {} + } + + /// Mutation guard for the `!` in `if !conclusive { tracing::debug!(...) }`: + /// the "truncated; verdicts limited" log must fire exactly on an + /// INCONCLUSIVE run, never on one that reached a designed stop. + #[test] + fn truncated_run_logs_but_a_conclusive_run_does_not() { + let pid = 0x1200u16; + + // Conclusive: one exactly-sized read, extent read to its end, no stall. + let conclusive_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + tracing::subscriber::with_default(ScanDebugCounter(conclusive_count.clone()), || { + let mut reader = TsReader { + data: ts_stream(pid, &pcs_display(true)), + pos: 0, + }; + let mut title = pgs_title(pid, false); + title.extents = vec![Extent { + start_lba: 0, + sector_count: 1, + }]; + probe_and_set_forced(&mut reader, &mut title, &mut ForcedProbeCache::new(), None); + }); + assert_eq!( + conclusive_count.load(std::sync::atomic::Ordering::SeqCst), + 0, + "a conclusive (Exhausted) run must not log the truncation debug message" + ); + + // Inconclusive: dies mid-title with a read error → ReadFailed. + let truncated_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + tracing::subscriber::with_default(ScanDebugCounter(truncated_count.clone()), || { + let mut reader = + PartialTsReader::new(ts_stream(pid, &pcs_display(true)), ThenWhat::Error); + let mut title = multi_read_pgs_title(pid, false); + probe_and_set_forced(&mut reader, &mut title, &mut ForcedProbeCache::new(), None); + }); + assert_eq!( + truncated_count.load(std::sync::atomic::Ordering::SeqCst), + 1, + "a truncated (ReadFailed) run must log the truncation debug message exactly once" + ); + } } diff --git a/src/drive/macos.rs b/src/drive/macos.rs index 09f5ab1..206f51f 100644 --- a/src/drive/macos.rs +++ b/src/drive/macos.rs @@ -52,3 +52,33 @@ pub fn resolve_device(path: &str) -> Result<(String, DeviceResolution)> { } Ok((path.to_string(), DeviceResolution::Direct)) } + +#[cfg(test)] +mod resolve_device_tests { + use super::*; + + /// An existing path resolves unchanged as `Direct` — macOS has no + /// `sr`->`sg` substitution, so the returned path must be byte-identical + /// to the input, not some canonicalised/mutated form. + #[test] + fn existing_path_resolves_direct_unchanged() { + // Use the test binary's own executable path: guaranteed to exist, + // no fixture file needed. + let exe = std::env::current_exe().unwrap(); + let path = exe.to_str().unwrap(); + let (resolved, kind) = resolve_device(path).expect("existing path must resolve"); + assert_eq!(resolved, path, "path must be returned unchanged"); + assert_eq!(kind, DeviceResolution::Direct); + } + + /// A path that does not exist must error with `DeviceNotFound` carrying + /// the original path, never silently succeed. + #[test] + fn missing_path_is_device_not_found() { + let path = "/dev/freemkv-definitely-does-not-exist-0xdead"; + match resolve_device(path) { + Err(Error::DeviceNotFound { path: p }) => assert_eq!(p, path), + other => panic!("expected DeviceNotFound, got {other:?}"), + } + } +} diff --git a/src/drive/mod.rs b/src/drive/mod.rs index 0dfebaf..c826ff7 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -1536,6 +1536,27 @@ mod command_tests { assert!(build_error_recovery_select_payload(&bad).is_none()); } + /// Boundary for the "does the buffer hold the full 3-byte page header + /// (code/length/flags)?" guard: `page_off + 3 > sense.len()`. Build a + /// buffer that is EXACTLY long enough (no slack) — `page_off + 3 == + /// sense.len()` — which must be accepted (`> ` is false), not rejected + /// (a `>=` mutation would wrongly reject the last valid byte and return + /// None even though every byte the function touches is in bounds). + #[test] + fn error_recovery_payload_accepts_exact_minimum_length() { + // No block descriptors: page starts right after the 8-byte header. + // Page needs exactly 3 bytes (code, length, flags) -> total 11. + let mut sense = vec![0u8; MODE10_HEADER_LEN + 3]; + let po = MODE10_HEADER_LEN; + sense[po] = MODE_PAGE_ERROR_RECOVERY; + sense[po + 1] = 0x00; // page length field (unused by this function) + sense[po + 2] = ERP_FLAG_DTE; // flags: DTE on, PER/TB off + let out = build_error_recovery_select_payload(&sense) + .expect("page_off + 3 == len is exactly enough room, must be accepted"); + assert_eq!(out[po + 2] & ERP_FLAG_PER, ERP_FLAG_PER, "PER set"); + assert_eq!(out[po + 2] & ERP_FLAG_DTE, 0, "DTE cleared"); + } + /// Mock transport: returns a fixed data payload (copied into the /// caller's buffer, truncated to fit) on every `execute()`. struct FixedTransport { @@ -1803,6 +1824,37 @@ mod command_tests { ); } + /// The existing CDB-encoding test (`read_builds_read10_cdb_with_be_lba_and_count`) + /// uses LBA `0x00AB_CDEF` and count `2` — both of which have a ZERO top + /// byte/top-byte-of-count, so a `(lba >> 24) as u8` or `(count >> 8) as + /// u8` silently mutated to a left shift still yields `0x00` (any + /// left-shift of at least 8 bits zeroes the low byte a `u8` cast keeps), + /// and the assertion can't tell the two apart. Use values with a NONZERO + /// top byte so a right-shift mutated to a left-shift is observable. + #[test] + fn read_cdb_shifts_are_not_masked_by_a_zero_top_byte() { + let RecordingHarness { + drive: mut d, + cdb, + timeouts: _to, + } = recording(TransportOutcome::Ok(300 * 2048)); + let mut buf = vec![0u8; 300 * 2048]; + // count = 300 (0x012C): count >> 8 == 0x01, nonzero — a `<<` + // mutation would instead yield 0x00. + d.read(0xAABB_CCDD, 300, &mut buf, false).unwrap(); + let c = cdb.lock().unwrap(); + assert_eq!( + &c[2..6], + &[0xAA, 0xBB, 0xCC, 0xDD], + "LBA bytes, including the >>24 top byte, must be big-endian verbatim" + ); + assert_eq!( + &c[7..9], + &[0x01, 0x2C], + "count bytes, including the >>8 top byte" + ); + } + #[test] fn read_recovery_flag_selects_60s_timeout() { // recovery=true must use READ_RECOVERY_TIMEOUT_MS (60 s); false @@ -2059,6 +2111,69 @@ mod command_tests { assert_eq!(*reads.lock().unwrap(), vec![(0, 3)], "single CDB, no split"); } + /// Transport that fills whatever slice of `data` it's given with a + /// marker byte derived from the CDB's LBA, so a test can verify BYTE + /// POSITION, not just which (lba, count) pairs were issued. + struct PlacementTransport; + impl ScsiTransport for PlacementTransport { + fn max_transfer_bytes(&self) -> usize { + 4 * 2048 + } + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> Result { + if cdb.first() != Some(&crate::scsi::SCSI_READ_10) || cdb.len() < 10 { + return Ok(ScsiResult { + status: 0, + bytes_transferred: data.len(), + sense: [0u8; 32], + }); + } + let lba = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]); + data.fill((lba + 1) as u8); + Ok(ScsiResult { + status: 0, + bytes_transferred: data.len(), + sense: [0u8; 32], + }) + } + } + + /// The chunk loop computes each chunk's destination slice as + /// `buf[done * 2048 .. done * 2048 + chunk * 2048]`. `reads.lock()`-style + /// assertions on the (lba, count) pairs alone can't tell `done * 2048` + /// apart from a corrupted `done + 2048` (chunk boundaries still line up + /// on nice round numbers for small test LBAs, and neither mock transport + /// above touches the buffer at all) — this test writes a distinct marker + /// per chunk and checks it landed at the BYTE offset the request maps + /// to, catching a `*` -> `+`/`/` mutation in the offset arithmetic that + /// would silently misplace or overlap chunk data written from the + /// physical drive into the caller's assembled buffer. + #[test] + fn read_chunks_write_into_correctly_offset_buffer_regions() { + // max_transfer = 4 sectors (8192 bytes): a 10-sector read at LBA 0 + // splits into (lba=0,4), (lba=4,4), (lba=8,2). + let mut d = Drive::from_transport_for_test(Box::new(PlacementTransport)); + let mut buf = vec![0u8; 10 * 2048]; + d.read(0, 10, &mut buf, false).unwrap(); + assert!( + buf[0..8192].iter().all(|&b| b == 1), + "chunk at LBA 0 (marker 1) must fill bytes [0, 8192)" + ); + assert!( + buf[8192..16384].iter().all(|&b| b == 5), + "chunk at LBA 4 (marker 5) must fill bytes [8192, 16384), not overlap the first chunk" + ); + assert!( + buf[16384..20480].iter().all(|&b| b == 9), + "chunk at LBA 8 (marker 9) must fill bytes [16384, 20480)" + ); + } + /// The multi-chunk path slices the caller's buffer by `count * 2048` with no /// length check, so an undersized `buf` PANICKED ('range end index out of /// range') out of the public `Drive::read` / `Drive::read_fua` — while the @@ -2179,6 +2294,30 @@ mod command_tests { assert_eq!(d.drive_status(), DriveStatus::DiscPresent); } + /// The `bytes_transferred >= 6` guard exists so byte 5 (Media Status) is + /// only trusted when the transport actually delivered it. Craft a SHORT + /// transfer (5 bytes) whose delivered prefix (bytes 0-2) still passes + /// every OTHER check a look-ahead decode would make (descriptor_len=6, + /// NEA clear, class=Media) — the only thing distinguishing "trust it" + /// from "don't" is the byte count. Byte 5 itself was never delivered and + /// is zero only because the local buffer was zero-initialised, not + /// because the drive said so. Correct code falls back to the TUR (which + /// this mock always answers OK) and reports DiscPresent; a guard + /// weakened to unconditionally-true would decode the untransferred + /// byte 5 as Media Status 0 and misreport NoDisc — a disc silently + /// reported as absent from a reply that never said so. + #[test] + fn drive_status_rejects_media_status_from_an_undelivered_byte() { + let short = vec![0x00, 0x06, 0x04, 0x00, 0x00]; // 5 bytes: descriptor_len=6, NEA=0, class=Media + let mut d = drive_with(short); + assert_eq!( + d.drive_status(), + DriveStatus::DiscPresent, + "a transfer too short to include byte 5 must fall back to TUR, \ + not decode a media status the drive never sent" + ); + } + #[test] fn drive_status_short_transfer_falls_back_to_tur() { // bytes_transferred < 6 means the GET EVENT reply is unusable; @@ -2305,6 +2444,15 @@ mod command_tests { assert_eq!(d.mode_sense_page(0x2A), None); } + #[test] + fn mode_sense_page_positive_transfer_returns_prefix() { + // Guard is `end > 0`; without a positive-transfer case the guard + // could be flipped to `end < 0` (always false for a usize) and + // every call would silently return None. + let mut d = drive_with(vec![0xAA, 0xBB, 0xCC]); + assert_eq!(d.mode_sense_page(0x01), Some(vec![0xAA, 0xBB, 0xCC])); + } + #[test] fn read_buffer_returns_prefix_and_clamps() { // read_buffer allocates `length` bytes; FixedTransport returns @@ -2346,6 +2494,93 @@ mod command_tests { assert!(d.probe_disc().is_ok()); } + // ── Tray/speed control CDBs (thin wrappers; verify they actually send) ── + + #[test] + fn set_speed_sends_set_cd_speed_cdb_with_be_speed() { + let RecordingHarness { + drive: mut d, + cdb, + timeouts: _to, + } = recording(TransportOutcome::Ok(0)); + d.set_speed(0x1234); + let c = cdb.lock().unwrap(); + assert_eq!(c[0], crate::scsi::SCSI_SET_CD_SPEED); + assert_eq!(&c[2..4], &[0x12, 0x34], "read speed big-endian"); + } + + #[test] + fn lock_tray_sends_prevent_with_removal_bit_set() { + let RecordingHarness { + drive: mut d, + cdb, + timeouts: _to, + } = recording(TransportOutcome::Ok(0)); + d.lock_tray(); + let c = cdb.lock().unwrap(); + assert_eq!(c[0], SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL); + assert_eq!(c[4], 0x01, "PREVENT bit set (locked)"); + } + + #[test] + fn unlock_tray_sends_prevent_with_removal_bit_clear() { + let RecordingHarness { + drive: mut d, + cdb, + timeouts: _to, + } = recording(TransportOutcome::Ok(0)); + d.unlock_tray(); + let c = cdb.lock().unwrap(); + assert_eq!(c[0], SCSI_PREVENT_ALLOW_MEDIUM_REMOVAL); + assert_eq!(c[4], 0x00, "PREVENT bit clear (unlocked)"); + } + + #[test] + fn eject_unlocks_then_sends_start_stop_with_loej() { + let RecordingHarness { + drive: mut d, + cdb, + timeouts: _to, + } = recording(TransportOutcome::Ok(0)); + d.eject().unwrap(); + // The mock only records the LAST cdb; eject's own START STOP UNIT + // (with LOEJ=1, byte 4 == 0x02) must be what's left recorded, not + // the PREVENT/ALLOW from unlock_tray it calls first. + let c = cdb.lock().unwrap(); + assert_eq!(c[0], SCSI_START_STOP_UNIT); + assert_eq!(c[4], 0x02, "START=0, LOEJ=1 -> eject"); + } + + /// `SectorSource for Drive` must actually forward to `Drive`'s own + /// methods, not silently become a no-op / stub return. + #[test] + fn sector_source_impl_forwards_to_drive_methods() { + let RecordingHarness { + drive: mut d, + cdb, + timeouts: _to, + } = recording(TransportOutcome::Ok(2048)); + let mut buf = vec![0u8; 2048]; + let n = SectorSource::read_sectors(&mut d, 0, 1, &mut buf, false).unwrap(); + assert_eq!(n, 2048, "read_sectors must forward to Drive::read"); + assert_eq!(cdb.lock().unwrap()[0], crate::scsi::SCSI_READ_10); + + let n2 = SectorSource::read_sectors_fua(&mut d, 0, 1, &mut buf, false, true).unwrap(); + assert_eq!(n2, 2048, "read_sectors_fua must forward to Drive::read_fua"); + assert_eq!( + cdb.lock().unwrap()[1], + 0x08, + "fua=true must reach the CDB via the trait method" + ); + + SectorSource::set_speed(&mut d, 0xFFFF); + assert_eq!( + cdb.lock().unwrap()[0], + crate::scsi::SCSI_SET_CD_SPEED, + "SectorSource::set_speed must forward to Drive::set_speed" + ); + } + // ── decode_read_capacity additional boundaries ────────────────── #[test] diff --git a/src/identity.rs b/src/identity.rs index d129429..61234fe 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -339,9 +339,136 @@ mod tests { ); } + /// `ascii_field`'s guard is `data.len() > start` (strictly greater), not + /// `>=`: a buffer whose length is exactly `start` has NO byte at that + /// offset, so it must still yield empty, not attempt to slice. + /// Mutation: `>` -> `>=` would try to slice `data[start..]` when + /// `data.len() == start`, which panics (empty range at the very end is + /// fine, but the guard's job is the `< start` case below it — pinning the + /// exact boundary catches an off-by-one either direction). + #[test] + fn ascii_field_boundary_len_equals_start_is_empty() { + let buf = vec![0u8; 8]; + assert_eq!(ascii_field(&buf, 8, 16), ""); + } + + /// One byte past the boundary: `data.len() == start + 1` must extract + /// that single byte (clamped to `end`), proving the guard is `>` and not + /// off by one in the other direction. + #[test] + fn ascii_field_boundary_len_one_past_start_extracts_one_byte() { + let mut buf = vec![0u8; 9]; + buf[8] = b'X'; + assert_eq!(ascii_field(&buf, 8, 16), "X"); + } + + /// `Display` renders the four trimmed identity fields space-separated — + /// the human-readable counterpart of `match_key`'s pipe-separated form. + /// Not exercised anywhere else in this test module. + /// Mutation: replacing the `fmt` body with `Ok(Default::default())` + /// writes nothing at all, so formatting any `DriveId` yields "". + #[test] + fn display_formats_trimmed_fields_space_separated() { + let mut inquiry = vec![0u8; 96]; + inquiry[8..16].copy_from_slice(b"PIONEER "); + inquiry[16..32].copy_from_slice(b"BD-RW BDR-S09 "); + inquiry[32..36].copy_from_slice(b"1.34"); + inquiry[36..43].copy_from_slice(b" 16/04/"); + let id = DriveId::from_inquiry(&inquiry, "201604250000"); + assert_eq!(id.to_string(), "PIONEER BD-RW BDR-S09 1.34 16/04/"); + } + /// GET CONFIGURATION failure (transport error) must not abort the /// identity probe — firmware_date is empty, raw_gc_010c is empty. /// Mutation: propagating the GET_CONFIGURATION error with `?` aborts from_drive. + /// Transport whose GET CONFIGURATION responses report an exact, + /// caller-chosen `bytes_transferred` for each of the two GC features + /// (010Ch firmware date / 0108h serial), so the `end > 12` / `> 12` + /// boundary guards can be pinned precisely. INQUIRY always succeeds. + struct FixedGcCountTransport { + firmware_bytes: usize, + serial_bytes: usize, + } + + impl ScsiTransport for FixedGcCountTransport { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + buf: &mut [u8], + _timeout_ms: u32, + ) -> Result { + for b in buf.iter_mut() { + *b = b'Z'; + } + let bytes_transferred = match cdb.first() { + Some(&0x12) => buf.len(), + Some(&0x46) if cdb[3] == 0x0C => self.firmware_bytes, + Some(&0x46) if cdb[3] == 0x08 => self.serial_bytes, + _ => buf.len(), + }; + Ok(ScsiResult { + status: 0, + bytes_transferred, + sense: [0u8; 32], + }) + } + } + + /// `end > 12` in the firmware-date branch (`from_drive`) is a strict + /// inequality: `bytes_transferred == 12` reports the field absent + /// (offset 12 is the first byte of the 12-char date; a count of exactly + /// 12 covers bytes 0..12, none of which is the date), so `firmware_date` + /// must be empty, not the mutant's off-by-one read. + /// Mutation: `>` -> `>=` would try `gc[12..12]` at the boundary — an + /// empty but non-panicking slice — silently reporting "present" data + /// that is actually all outside the transferred count. + #[test] + fn from_drive_firmware_date_boundary_exactly_12_is_empty() { + let mut t = FixedGcCountTransport { + firmware_bytes: 12, + serial_bytes: 0, + }; + let id = DriveId::from_drive(&mut t).unwrap(); + assert_eq!(id.firmware_date, ""); + } + + /// One byte past the boundary (`bytes_transferred == 13`) must extract + /// exactly the one available date byte (offset 12), proving the guard + /// is `>` and the slice end is clamped to `end`, not always to 24. + #[test] + fn from_drive_firmware_date_boundary_13_extracts_one_byte() { + let mut t = FixedGcCountTransport { + firmware_bytes: 13, + serial_bytes: 0, + }; + let id = DriveId::from_drive(&mut t).unwrap(); + assert_eq!(id.firmware_date, "Z"); + } + + /// Same `> 12` boundary for the serial-number branch: exactly 12 + /// transferred bytes must yield an empty serial. + #[test] + fn from_drive_serial_boundary_exactly_12_is_empty() { + let mut t = FixedGcCountTransport { + firmware_bytes: 0, + serial_bytes: 12, + }; + let id = DriveId::from_drive(&mut t).unwrap(); + assert_eq!(id.serial_number, ""); + } + + /// One byte past the serial boundary extracts exactly that byte. + #[test] + fn from_drive_serial_boundary_13_extracts_one_byte() { + let mut t = FixedGcCountTransport { + firmware_bytes: 0, + serial_bytes: 13, + }; + let id = DriveId::from_drive(&mut t).unwrap(); + assert_eq!(id.serial_number, "Z"); + } + #[test] fn from_drive_gc_failure_yields_empty_firmware_date() { struct GcFailTransport; diff --git a/src/io/byte_prefetcher.rs b/src/io/byte_prefetcher.rs index 839932f..af0847c 100644 --- a/src/io/byte_prefetcher.rs +++ b/src/io/byte_prefetcher.rs @@ -249,6 +249,27 @@ impl Drop for BytePrefetcher { mod tests { use super::*; + /// `RECYCLE_DEPTH` must be one MORE than `FORWARD_DEPTH` per its own + /// doc comment: the producer needs at least one buffer to fill while + /// the consumer holds the other `FORWARD_DEPTH`-worth in flight. A + /// `+` -> `*`/`-` mutation on `FORWARD_DEPTH + 1` would under-size the + /// recycle channel (e.g. `FORWARD_DEPTH * 1 == FORWARD_DEPTH`, one + /// short), which starves the producer of a spare buffer. + #[test] + fn recycle_depth_is_forward_depth_plus_one() { + assert_eq!(RECYCLE_DEPTH, FORWARD_DEPTH + 1); + assert_eq!(RECYCLE_DEPTH, 3, "FORWARD_DEPTH is 2, so recycle must be 3"); + } + + /// `DEFAULT_CHUNK_BYTES` is documented as 16 MiB. Pins the literal so a + /// `*` -> `+`/`/` mutation on either factor (16 * 1024 * 1024) is + /// caught by a concrete, spec-derived expected value rather than by + /// recomputing the same expression. + #[test] + fn default_chunk_bytes_is_16_mib() { + assert_eq!(DEFAULT_CHUNK_BYTES, 16_777_216, "documented as 16 MiB"); + } + /// Endless reader: every `read` fills the whole buffer and never /// hits EOF, so the producer keeps trying to push batches forward /// until the forward channel disconnects. Exactly the shape that diff --git a/src/labels/bdmt.rs b/src/labels/bdmt.rs index c3ac3f1..adbe37b 100644 --- a/src/labels/bdmt.rs +++ b/src/labels/bdmt.rs @@ -566,4 +566,33 @@ mod tests { let (title, _, _) = parse_bdmt_xml(xml).unwrap(); assert_eq!(title, "Real Title"); } + + /// `is_bdmt_filename` must recognize the `bdmt_.xml` convention + /// and reject everything else — it drives `detect`'s directory scan. + /// Mutation: stub the return to a constant `true`/`false` → every + /// directory listing (or none) would match regardless of filename. + #[test] + fn is_bdmt_filename_matches_convention_only() { + assert!(is_bdmt_filename("bdmt_eng.xml")); + assert!(is_bdmt_filename("BDMT_FRA.XML")); + assert!(!is_bdmt_filename("bdmt_engl.xml")); + assert!(!is_bdmt_filename("index.bdmv")); + assert!(!is_bdmt_filename("foo.xml")); + } + + /// Spec: "Disc 1 of 1" (a single-disc release whose bdmt XML still + /// carries `1`) is a valid, non-nonsensical + /// pair — `total < 1` must reject only `total == 0`, not `total == 1`. + /// Mutation: `total < 1` -> `total == 1` or `total <= 1` would reject + /// this legitimate (1, 1) pair as if it were malformed. + #[test] + fn disc_set_allows_single_disc_release() { + let xml = r#" + Film + 1 + 1 +"#; + let (_, _, set) = parse_bdmt_xml(xml).unwrap(); + assert_eq!(set, Some((1, 1))); + } } diff --git a/src/labels/class_reader.rs b/src/labels/class_reader.rs index cbd85ae..fba4653 100644 --- a/src/labels/class_reader.rs +++ b/src/labels/class_reader.rs @@ -1388,4 +1388,162 @@ mod tests { let _ = decode_modified_utf8(&buf); } } + + // ----------------------------------------------------------------- + // ConstantPool / ClassFile accessor correctness + // + // These exercise plain data accessors on an already-parsed pool + // (built via the test-only `from_entries` constructor) — not the + // untrusted-bytes parsing path, just "does the right variant map to + // the right Option value." + // ----------------------------------------------------------------- + + fn sample_pool() -> ConstantPool { + // index: 0=Empty (reserved), 1=Utf8("Hello"), 2=Integer(42), + // 3=String{string_index:1}, 4=Class{name_index:1}, 5=Float(1.5), + // 6=Long(9), 7=Empty (2-slot tail), 8=Double(2.5), 9=Empty (tail). + ConstantPool::from_entries(vec![ + CpInfo::Empty, + CpInfo::Utf8("Hello".to_string()), + CpInfo::Integer(42), + CpInfo::String { string_index: 1 }, + CpInfo::Class { name_index: 1 }, + CpInfo::Float(1.5), + CpInfo::Long(9), + CpInfo::Empty, + CpInfo::Double(2.5), + CpInfo::Empty, + ]) + } + + #[test] + fn constant_pool_string_resolves_through_string_index() { + let pool = sample_pool(); + // index 3 is CpInfo::String{string_index: 1} -> utf8(1) = "Hello". + assert_eq!(pool.string(3), Some("Hello")); + // Wrong variant (Integer at index 2) must not resolve as a string. + assert_eq!(pool.string(2), None); + // Out of range index. + assert_eq!(pool.string(999), None); + } + + #[test] + fn constant_pool_integer_resolves_only_integer_entries() { + let pool = sample_pool(); + assert_eq!(pool.integer(2), Some(42)); + // Wrong variant (Utf8 at index 1) must not resolve as an integer. + assert_eq!(pool.integer(1), None); + assert_eq!(pool.integer(999), None); + } + + #[test] + fn constant_pool_load_constant_display_covers_ldc_operand_kinds() { + let pool = sample_pool(); + assert_eq!( + pool.load_constant_display(1), + Some("utf8:\"Hello\"".to_string()) + ); + assert_eq!(pool.load_constant_display(2), Some("int:42".to_string())); + assert_eq!( + pool.load_constant_display(3), + Some("str:\"Hello\"".to_string()) + ); + assert_eq!( + pool.load_constant_display(4), + Some("class:\"Hello\"".to_string()) + ); + assert_eq!(pool.load_constant_display(5), Some("float:1.5".to_string())); + assert_eq!(pool.load_constant_display(6), Some("long:9".to_string())); + assert_eq!( + pool.load_constant_display(8), + Some("double:2.5".to_string()) + ); + // A variant with no display arm (e.g. reserved Empty slot) -> None. + assert_eq!(pool.load_constant_display(0), None); + assert_eq!(pool.load_constant_display(999), None); + } + + #[test] + fn constant_pool_len_and_is_empty() { + let pool = sample_pool(); + assert_eq!(pool.len(), 10); + assert!(!pool.is_empty()); + + let empty = ConstantPool::from_entries(vec![]); + assert_eq!(empty.len(), 0); + assert!(empty.is_empty()); + } + + #[test] + fn constant_pool_iter_yields_index_and_entry_pairs() { + let pool = ConstantPool::from_entries(vec![ + CpInfo::Empty, + CpInfo::Utf8("A".to_string()), + CpInfo::Integer(7), + ]); + let indices: Vec = pool.iter().map(|(i, _)| i).collect(); + assert_eq!(indices, vec![0, 1, 2]); + // Confirm the entries themselves come through, not an empty iterator. + let utf8_at_1 = pool.iter().find(|(i, _)| *i == 1).map(|(_, e)| match e { + CpInfo::Utf8(s) => s.as_str(), + _ => "?", + }); + assert_eq!(utf8_at_1, Some("A")); + } + + fn class_file_with(this_class: u16, super_class: u16, pool: ConstantPool) -> ClassFile { + ClassFile { + minor_version: 0, + major_version: 0, + constant_pool: pool, + access_flags: 0, + this_class, + super_class, + interfaces: Vec::new(), + fields: Vec::new(), + methods: Vec::new(), + attributes: Vec::new(), + } + } + + #[test] + fn this_class_name_and_super_class_name_resolve_distinct_indices() { + let pool = ConstantPool::from_entries(vec![ + CpInfo::Empty, + CpInfo::Utf8("com/example/Foo".to_string()), + CpInfo::Utf8("com/example/Bar".to_string()), + CpInfo::Class { name_index: 1 }, + CpInfo::Class { name_index: 2 }, + ]); + let cf = class_file_with(3, 4, pool); + assert_eq!(cf.this_class_name(), Some("com/example/Foo")); + assert_eq!(cf.super_class_name(), Some("com/example/Bar")); + + // this_class index pointing at a non-Class entry must not resolve. + let pool2 = ConstantPool::from_entries(vec![ + CpInfo::Empty, + CpInfo::Utf8("not a class ref".to_string()), + ]); + let cf2 = class_file_with(1, 1, pool2); + assert_eq!(cf2.this_class_name(), None); + assert_eq!(cf2.super_class_name(), None); + } + + #[test] + fn member_descriptor_resolves_the_descriptor_not_the_name() { + let pool = ConstantPool::from_entries(vec![ + CpInfo::Empty, + CpInfo::Utf8("doStuff".to_string()), // index 1: name + CpInfo::Utf8("()V".to_string()), // index 2: descriptor + ]); + let cf = class_file_with(0, 0, pool); + let m = Member { + access_flags: 0, + name_index: 1, + descriptor_index: 2, + attributes: Vec::new(), + }; + assert_eq!(cf.member_descriptor(&m), Some("()V")); + assert_ne!(cf.member_descriptor(&m), Some("doStuff")); + } } diff --git a/src/labels/ctrm.rs b/src/labels/ctrm.rs index c1d43fe..60961c7 100644 --- a/src/labels/ctrm.rs +++ b/src/labels/ctrm.rs @@ -324,6 +324,68 @@ mod tests { assert_eq!(labels[0].qualifier, LabelQualifier::None); } + /// Spec: `menu_base.prop` lines are skipped when `is_empty() || + /// starts_with('#')` — either alone is sufficient. A commented-out + /// key=value line must never be parsed into an entry. + /// Mutation: `||` -> `&&` requires both, which a non-empty comment + /// line can't satisfy, so it falls through to `line.find('=')` and + /// gets parsed as a real property. + #[test] + fn menu_base_comment_line_with_equals_is_still_skipped() { + let labels = parse_props( + "#audio_1.class=AudioButton\n\ + #audio_1.streamNumber=9\n\ + #audio_1.name=Should Not Appear\n\ + audio_2.class=AudioButton\n\ + audio_2.streamNumber=1\n\ + audio_2.name=Real Track\n", + ); + assert_eq!(labels.len(), 1, "commented-out entry must not be parsed"); + assert_eq!(labels[0].name, "Real Track"); + } + + /// Spec: `menu_base.prop` streamNumber (or audioStream/subtitleStream) + /// must be strictly positive — `0` means "no STN entry" and must be + /// skipped, matching the `n > 0` guard on the language_streams side. + /// Mutation: `n > 0` -> `n >= 0` (or the guard deleted) would let a + /// stream_num of 0 through, emitting a dead label apply_labels can + /// never match (its counter starts at 1). + #[test] + fn menu_base_zero_stream_number_skipped() { + let labels = parse_props( + "audio_1.class=AudioButton\n\ + audio_1.streamNumber=0\n\ + audio_1.name=Disabled Slot\n", + ); + assert!( + labels.is_empty(), + "streamNumber=0 must be skipped, got {labels:?}" + ); + } + + /// Spec: `is_subtitle` is `class.contains("SubtitleButton") || + /// prefix.starts_with("subtitle_")` — EITHER signal alone is + /// sufficient to classify (and keep) a subtitle entry whose prefix + /// doesn't follow the `subtitle_` naming convention. + /// Mutation: `||` -> `&&` would require BOTH signals; an entry whose + /// class says SubtitleButton but whose prefix is something else + /// (e.g. a vendor-specific button id) would then satisfy neither + /// `is_audio` nor `is_subtitle` and get dropped entirely. + #[test] + fn menu_base_subtitle_class_alone_is_sufficient() { + let labels = parse_props( + "menuBtn7.class=SubtitleButton\n\ + menuBtn7.streamNumber=1\n\ + menuBtn7.name=English SDH\n", + ); + assert_eq!( + labels.len(), + 1, + "class=SubtitleButton alone must classify as subtitle, not be dropped" + ); + assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle); + } + #[test] fn prefix_commentary_segment_match_not_substring() { // Genuine commentary group segments match. @@ -350,6 +412,39 @@ mod tests { } } + /// Spec: `merge`'s `mb.iter().find(...)` must match an mb entry by + /// (stream_type AND stream_number) TOGETHER — either alone is not a + /// unique key (there can be an audio #1 and a subtitle #1, or two + /// different audio streams). + /// Mutation: `&&` -> `||` inside the closure would match on type OR + /// number alone, so `.find` (which returns the FIRST match) can pick + /// an mb entry with the right type but the WRONG stream number. + #[test] + fn merge_matches_mb_entry_by_type_and_number_together() { + // ls wants audio #2 (empty name, so it will borrow from mb). + let ls = vec![lbl(StreamLabelType::Audio, 2, "")]; + // mb's FIRST audio entry is #1 (wrong number); its #2 entry (the + // real match) comes second. + let mb = vec![ + lbl(StreamLabelType::Audio, 1, "Wrong Number Match"), + lbl(StreamLabelType::Audio, 2, "Correct Match"), + ]; + let merged = merge(ls, mb); + assert_eq!( + merged.len(), + 2, + "mb's own audio #1 must also survive as its own entry" + ); + let a2 = merged + .iter() + .find(|l| l.stream_type == StreamLabelType::Audio && l.stream_number == 2) + .unwrap(); + assert_eq!( + a2.name, "Correct Match", + "must match mb by (type AND number), not type or number alone" + ); + } + #[test] fn merge_preserves_menu_base_only_streams() { // language_streams covers audio 1; menu_base has audio 1 (name) @@ -506,6 +601,60 @@ mod tests { assert_eq!(labels[0].language, "eng"); } + /// Spec: the skip test is `is_empty() || starts_with('#')` — EITHER + /// condition alone must skip the line. A commented-out line that + /// happens to look like valid CSV (a real authoring pattern for + /// disabling a stream entry) must never produce a label. + /// Mutation: `||` -> `&&` requires BOTH conditions, which a non-empty + /// comment line can never satisfy, so it would fall through to the + /// CSV parser and (since it has >= 4 comma fields) emit a spurious + /// label instead of being skipped. + #[test] + fn ls_comment_line_with_csv_shape_is_still_skipped() { + let labels = + parse_language_streams_text("#id,audio_production,1,eng\nid2,audio_production,2,fra\n"); + assert_eq!( + labels.len(), + 1, + "the commented-out CSV-shaped line must not parse" + ); + assert_eq!(labels[0].language, "fra"); + } + + /// Spec: `subtitle_dual` is a recognized subtitle type (Normal/no + /// qualifier). Mutation: delete this match arm → falls to the + /// catch-all `_ => continue`, silently dropping the stream. + #[test] + fn ls_subtitle_dual_parsed() { + let labels = parse_language_streams_text("id,subtitle_dual,1,eng\n"); + assert_eq!(labels.len(), 1, "subtitle_dual must produce a label"); + assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle); + assert_eq!(labels[0].purpose, LabelPurpose::Normal); + assert_eq!(labels[0].qualifier, LabelQualifier::None); + } + + /// Spec: `subtitle_bonus` is a recognized subtitle type (Normal/no + /// qualifier). Mutation: delete this match arm → dropped as unknown. + #[test] + fn ls_subtitle_bonus_parsed() { + let labels = parse_language_streams_text("id,subtitle_bonus,2,eng\n"); + assert_eq!(labels.len(), 1, "subtitle_bonus must produce a label"); + assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle); + assert_eq!(labels[0].purpose, LabelPurpose::Normal); + } + + /// Spec: `subtitle_ime` maps to Subtitle/Ime (no Forced qualifier, + /// unlike `subtitle_ime_narrative`). + /// Mutation: delete this match arm → dropped as unknown. + #[test] + fn ls_subtitle_ime_parsed() { + let labels = parse_language_streams_text("id,subtitle_ime,3,jpn\n"); + assert_eq!(labels.len(), 1, "subtitle_ime must produce a label"); + assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle); + assert_eq!(labels[0].purpose, LabelPurpose::Ime); + assert_eq!(labels[0].qualifier, LabelQualifier::None); + } + /// Spec: multiple valid lines produce multiple labels. /// Mutation: stop after first label → only 1 label returned. #[test] diff --git a/src/labels/dbp.rs b/src/labels/dbp.rs index 059ce9c..41ae6aa 100644 --- a/src/labels/dbp.rs +++ b/src/labels/dbp.rs @@ -186,6 +186,91 @@ fn make_label(num: u16, label: String, stream_type: StreamLabelType) -> StreamLa mod tests { use super::super::{LabelPurpose, LabelQualifier}; use super::*; + use std::io::{Cursor, Write as _}; + + /// Build a minimal, structurally valid `.class` file (JVMS §4.1) whose + /// constant pool holds exactly the given `Utf8` strings (indices 1..=N, + /// no long/double slot padding needed for plain strings). No fields, + /// methods, interfaces, or attributes — `scan_jar`'s only interest is + /// the constant pool. + fn build_class(utf8_entries: &[&str]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&0xCAFEBABEu32.to_be_bytes()); // magic + out.extend_from_slice(&0u16.to_be_bytes()); // minor_version + out.extend_from_slice(&52u16.to_be_bytes()); // major_version (Java 8) + out.extend_from_slice(&((utf8_entries.len() + 1) as u16).to_be_bytes()); // cp_count + for s in utf8_entries { + out.push(1); // CONSTANT_Utf8 tag + out.extend_from_slice(&(s.len() as u16).to_be_bytes()); + out.extend_from_slice(s.as_bytes()); + } + out.extend_from_slice(&0u16.to_be_bytes()); // access_flags + out.extend_from_slice(&0u16.to_be_bytes()); // this_class + out.extend_from_slice(&0u16.to_be_bytes()); // super_class + out.extend_from_slice(&0u16.to_be_bytes()); // interfaces_count + out.extend_from_slice(&0u16.to_be_bytes()); // fields_count + out.extend_from_slice(&0u16.to_be_bytes()); // methods_count + out.extend_from_slice(&0u16.to_be_bytes()); // attributes_count + out + } + + /// Zip `entries` (name -> bytes) into an in-memory, Stored (uncompressed) + /// `jar::Jar` via the `zip` crate's own writer — a real archive, not a + /// hand-rolled central directory. + fn build_jar(entries: &[(&str, Vec)]) -> jar::Jar { + let mut buf = Vec::new(); + { + let mut writer = zip::ZipWriter::new(Cursor::new(&mut buf)); + let opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + for (name, data) in entries { + writer.start_file(*name, opts).expect("start_file"); + writer.write_all(&data[..]).expect("write class bytes"); + } + writer.finish().expect("finish zip"); + } + zip::ZipArchive::new(Cursor::new(buf)).expect("valid zip") + } + + /// `scan_jar` wires together `for_each_class`, constant-pool iteration, + /// `collect_textfield`, and `make_label` into the actual per-jar scan + /// used by `parse`. The pure `collect_textfield`/`make_label` unit + /// tests above don't exercise this wiring at all. + /// + /// Mutation: replace the whole function body with `vec![]` — every + /// dbp disc would silently lose all its stream labels regardless of + /// what's in the jar. + #[test] + fn scan_jar_extracts_labels_from_real_class_entries() { + let class_bytes = build_class(&[ + "com/dbp/Whatever", // unrelated string — must be ignored + "LTextField,Audio1,English Dolby Atmos,Fontstrip_Composite,296,763", + "HTextField,Subtitle1,English SDH,Fontstrip_Composite,1312,763", + "ATextField,Subtitle0,None,Fontstrip_Composite,1312,843", // disable button, skipped + ]); + let mut archive = build_jar(&[("com/dbp/Menu.class", class_bytes)]); + + let labels = scan_jar(&mut archive); + + assert_eq!( + labels.len(), + 2, + "expected one audio + one real subtitle label" + ); + let audio = labels + .iter() + .find(|l| l.stream_type == StreamLabelType::Audio) + .expect("audio label present"); + assert_eq!(audio.stream_number, 1); + assert_eq!(audio.language, "eng"); + + let sub = labels + .iter() + .find(|l| l.stream_type == StreamLabelType::Subtitle) + .expect("subtitle label present"); + assert_eq!(sub.stream_number, 1); + assert_eq!(sub.qualifier, LabelQualifier::Sdh); + } /// A `CONSTANT_Utf8_info` carries a `u16` length (JVMS §4.4.7), so one /// crafted constant contributes up to 65535 bytes and the `u16` stream diff --git a/src/labels/deluxe.rs b/src/labels/deluxe.rs index 1cfd061..e50ee73 100644 --- a/src/labels/deluxe.rs +++ b/src/labels/deluxe.rs @@ -1106,6 +1106,341 @@ fn deluxe_purpose_to_label(ordinal: u16) -> (LabelPurpose, LabelQualifier) { mod tests { use super::*; + // ── Raw .class / .jar fixture builders ────────────────────────────────── + // + // `identify_master_enums`, `find_binding_classes` and `decode_binding` + // operate on `jar::Jar` (a real `ZipArchive`), not on the in-memory + // `ClassFile` struct the rest of this module's tests build directly (see + // `class_with_clinit`). To exercise them we need real serialized + // `.class` bytes inside a real (stored, uncompressed) zip — this is the + // inverse of `ClassFile::parse` / JVMS §4. + + /// Serialize a constant pool (no Long/Double entries — those need the + /// post-slot `Empty` padding this helper doesn't handle) to the on-disk + /// `cp_info` sequence, prefixed by `constant_pool_count`. + fn encode_cp(entries: &[CpInfo]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&(entries.len() as u16).to_be_bytes()); + for e in &entries[1..] { + match e { + CpInfo::Utf8(s) => { + out.push(1); + out.extend_from_slice(&(s.len() as u16).to_be_bytes()); + out.extend_from_slice(s.as_bytes()); + } + CpInfo::Integer(n) => { + out.push(3); + out.extend_from_slice(&n.to_be_bytes()); + } + CpInfo::Class { name_index } => { + out.push(7); + out.extend_from_slice(&name_index.to_be_bytes()); + } + CpInfo::String { string_index } => { + out.push(8); + out.extend_from_slice(&string_index.to_be_bytes()); + } + CpInfo::Fieldref { + class_index, + name_and_type_index, + } => { + out.push(9); + out.extend_from_slice(&class_index.to_be_bytes()); + out.extend_from_slice(&name_and_type_index.to_be_bytes()); + } + CpInfo::NameAndType { + name_index, + descriptor_index, + } => { + out.push(12); + out.extend_from_slice(&name_index.to_be_bytes()); + out.extend_from_slice(&descriptor_index.to_be_bytes()); + } + CpInfo::Methodref { + class_index, + name_and_type_index, + } => { + out.push(10); + out.extend_from_slice(&class_index.to_be_bytes()); + out.extend_from_slice(&name_and_type_index.to_be_bytes()); + } + other => unimplemented!("fixture builder doesn't need {other:?}"), + } + } + out + } + + /// One method's worth of `Code` attribute bytecode, keyed by the cp + /// index of the `"Code"` Utf8 entry. + struct MethodSpec { + name_index: u16, + descriptor_index: u16, + code_attr_name_index: u16, + max_stack: u16, + code: Vec, + } + + /// Serialize a minimal but real `.class` byte buffer: magic, versions, + /// constant pool, an empty interfaces/fields table, the given methods + /// (each with exactly one `Code` attribute), and no class attributes. + fn encode_class(cp: &[CpInfo], this_class: u16, methods: &[MethodSpec]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&0xCAFEBABEu32.to_be_bytes()); + out.extend_from_slice(&0u16.to_be_bytes()); // minor + out.extend_from_slice(&52u16.to_be_bytes()); // major + out.extend_from_slice(&encode_cp(cp)); + out.extend_from_slice(&0u16.to_be_bytes()); // access_flags + out.extend_from_slice(&this_class.to_be_bytes()); + out.extend_from_slice(&0u16.to_be_bytes()); // super_class + out.extend_from_slice(&0u16.to_be_bytes()); // interfaces_count + out.extend_from_slice(&0u16.to_be_bytes()); // fields_count + out.extend_from_slice(&(methods.len() as u16).to_be_bytes()); + for m in methods { + out.extend_from_slice(&0u16.to_be_bytes()); // access_flags + out.extend_from_slice(&m.name_index.to_be_bytes()); + out.extend_from_slice(&m.descriptor_index.to_be_bytes()); + out.extend_from_slice(&1u16.to_be_bytes()); // attributes_count = 1 (Code) + out.extend_from_slice(&m.code_attr_name_index.to_be_bytes()); + let info_len = 2 + 2 + 4 + m.code.len(); + out.extend_from_slice(&(info_len as u32).to_be_bytes()); + out.extend_from_slice(&m.max_stack.to_be_bytes()); + out.extend_from_slice(&0u16.to_be_bytes()); // max_locals + out.extend_from_slice(&(m.code.len() as u32).to_be_bytes()); + out.extend_from_slice(&m.code); + } + out.extend_from_slice(&0u16.to_be_bytes()); // attributes_count (class) + out + } + + /// Build a raw, multi-entry, Stored (uncompressed) ZIP — same format as + /// `jar::tests::build_stored_zip`, generalized to N entries (that helper + /// is private to `jar.rs`). + fn build_zip(entries: &[(&str, Vec)]) -> Vec { + fn crc32(payload: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFFu32; + for &b in payload { + crc ^= b as u32; + for _ in 0..8 { + let mask = (crc & 1).wrapping_neg(); + crc = (crc >> 1) ^ (0xEDB8_8320 & mask); + } + } + !crc + } + let mut out = Vec::new(); + let mut central = Vec::new(); + let mut offsets = Vec::new(); + for (name, payload) in entries { + let name_bytes = name.as_bytes(); + let crc = crc32(payload); + offsets.push(out.len() as u32); + out.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); + out.extend_from_slice(&20u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); // Stored + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&crc.to_le_bytes()); + out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + out.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(name_bytes); + out.extend_from_slice(payload); + } + for ((name, payload), &lfh_offset) in entries.iter().zip(&offsets) { + let name_bytes = name.as_bytes(); + let crc = crc32(payload); + central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); + central.extend_from_slice(&20u16.to_le_bytes()); + central.extend_from_slice(&20u16.to_le_bytes()); + central.extend_from_slice(&0u16.to_le_bytes()); + central.extend_from_slice(&0u16.to_le_bytes()); + central.extend_from_slice(&0u16.to_le_bytes()); + central.extend_from_slice(&0u16.to_le_bytes()); + central.extend_from_slice(&crc.to_le_bytes()); + central.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + central.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + central.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes()); + central.extend_from_slice(&0u16.to_le_bytes()); + central.extend_from_slice(&0u16.to_le_bytes()); + central.extend_from_slice(&0u16.to_le_bytes()); + central.extend_from_slice(&0u16.to_le_bytes()); + central.extend_from_slice(&0u32.to_le_bytes()); + central.extend_from_slice(&lfh_offset.to_le_bytes()); + central.extend_from_slice(name_bytes); + } + let cd_offset = out.len() as u32; + let cd_size = central.len() as u32; + out.extend_from_slice(¢ral); + out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&(entries.len() as u16).to_le_bytes()); + out.extend_from_slice(&(entries.len() as u16).to_le_bytes()); + out.extend_from_slice(&cd_size.to_le_bytes()); + out.extend_from_slice(&cd_offset.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out + } + + fn open_jar(bytes: Vec) -> jar::Jar { + jar::Jar::new(std::io::Cursor::new(bytes)).expect("valid zip") + } + + /// Build a `.class` fixture whose `` does N `ldc` of distinct + /// Utf8 constants `values[0..N]` — i.e. a class matching a + /// `FINGERPRINTS` shape by ldc-sequence. + fn class_with_ldc_strings(class_name: &str, values: &[&str]) -> Vec { + // cp layout: 1 "", 2 "()V", 3 "Code", then one Utf8 + + // one String per value, in pairs (4,5), (6,7), ... + let mut cp = vec![ + CpInfo::Empty, + CpInfo::Utf8("".into()), + CpInfo::Utf8("()V".into()), + CpInfo::Utf8("Code".into()), + ]; + let mut code = Vec::new(); + for v in values { + let utf8_idx = cp.len() as u16; + cp.push(CpInfo::Utf8((*v).to_string())); + let str_idx = cp.len() as u16; + cp.push(CpInfo::String { + string_index: utf8_idx, + }); + code.push(LDC); + code.push(str_idx as u8); + } + cp.push(CpInfo::Utf8(class_name.to_string())); + let this_class_name_idx = (cp.len() - 1) as u16; + cp.push(CpInfo::Class { + name_index: this_class_name_idx, + }); + let this_class_idx = (cp.len() - 1) as u16; + let methods = vec![MethodSpec { + name_index: 1, + descriptor_index: 2, + code_attr_name_index: 3, + max_stack: 2, + code, + }]; + encode_class(&cp, this_class_idx, &methods) + } + + /// Build a `.class` fixture whose `` does N `getstatic` + /// references to `enum_class.FIELD_i`, for `count_master_enum_getstatic` + /// / `find_binding_classes` Jar-level fixtures. + fn class_with_getstatic_refs(class_name: &str, enum_class: &str, n: usize) -> Vec { + let mut cp = vec![ + CpInfo::Empty, + CpInfo::Utf8("".into()), + CpInfo::Utf8("()V".into()), + CpInfo::Utf8("Code".into()), + CpInfo::Utf8(enum_class.to_string()), + ]; + let enum_class_name_idx = 4u16; + cp.push(CpInfo::Class { + name_index: enum_class_name_idx, + }); + let enum_class_idx = (cp.len() - 1) as u16; + cp.push(CpInfo::Utf8("Lsome/Enum;".into())); + let descriptor_idx = (cp.len() - 1) as u16; + let mut code = Vec::new(); + for i in 0..n { + let field_name_idx = cp.len() as u16; + cp.push(CpInfo::Utf8(format!("F{i}"))); + let nat_idx = cp.len() as u16; + cp.push(CpInfo::NameAndType { + name_index: field_name_idx, + descriptor_index: descriptor_idx, + }); + let fieldref_idx = cp.len() as u16; + cp.push(CpInfo::Fieldref { + class_index: enum_class_idx, + name_and_type_index: nat_idx, + }); + code.push(GETSTATIC); + code.extend_from_slice(&fieldref_idx.to_be_bytes()); + code.push(0x57); // pop, so the symbolic stack doesn't matter here + } + cp.push(CpInfo::Utf8(class_name.to_string())); + let this_name_idx = (cp.len() - 1) as u16; + cp.push(CpInfo::Class { + name_index: this_name_idx, + }); + let this_class_idx = (cp.len() - 1) as u16; + let methods = vec![MethodSpec { + name_index: 1, + descriptor_index: 2, + code_attr_name_index: 3, + max_stack: 2, + code, + }]; + encode_class(&cp, this_class_idx, &methods) + } + + /// A `.class` fixture whose `` is exactly `new AudioSlot; dup; + /// getstatic LanguageEnum.English; invokespecial AudioSlot. + /// (LLanguageEnum;)V` — one real `Construction`, for Jar-level + /// `decode_binding` tests. `class_name` only affects the class's own + /// `this_class` entry (informational); the Jar-level lookup key is the + /// zip entry path passed to `build_zip`, not this name. + fn class_with_simple_construction(class_name: &str) -> Vec { + let cp = vec![ + CpInfo::Empty, + CpInfo::Utf8("".into()), // 1 + CpInfo::Utf8("()V".into()), // 2 + CpInfo::Utf8("Code".into()), // 3 + CpInfo::Utf8("LanguageEnum".into()), // 4 + CpInfo::Class { name_index: 4 }, // 5 + CpInfo::Utf8("English".into()), // 6 + CpInfo::Utf8("LLanguageEnum;".into()), // 7 + CpInfo::NameAndType { + name_index: 6, + descriptor_index: 7, + }, // 8 + CpInfo::Fieldref { + class_index: 5, + name_and_type_index: 8, + }, // 9 + CpInfo::Utf8("AudioSlot".into()), // 10 + CpInfo::Class { name_index: 10 }, // 11 + CpInfo::Utf8("".into()), // 12 + CpInfo::Utf8("(LLanguageEnum;)V".into()), // 13 + CpInfo::NameAndType { + name_index: 12, + descriptor_index: 13, + }, // 14 + CpInfo::Methodref { + class_index: 11, + name_and_type_index: 14, + }, // 15 + CpInfo::Utf8(class_name.to_string()), // 16 + CpInfo::Class { name_index: 16 }, // 17 + ]; + let this_class_idx = 17u16; + let code: Vec = vec![ + NEW, + 0, + 11, // new AudioSlot + 0x59, // dup + GETSTATIC, + 0, + 9, // getstatic LanguageEnum.English + INVOKESPECIAL, + 0, + 15, // invokespecial AudioSlot.(LLanguageEnum;)V + ]; + let methods = vec![MethodSpec { + name_index: 1, + descriptor_index: 2, + code_attr_name_index: 3, + max_stack: 4, + code, + }]; + encode_class(&cp, this_class_idx, &methods) + } + #[test] fn ldcs_match_prefix_exact() { let ldcs = vec![ @@ -1167,6 +1502,188 @@ mod tests { } } + // ── Phase A: identify_master_enums (Jar-level) ────────────────────────── + + #[test] + fn identify_master_enums_matches_purpose_fingerprint() { + // Exact match: 8 ldcs, first 4 = the Purpose prefix, count == + // expected_count exactly (abs_diff == 0). A decoy class with the + // same prefix but a wildly different count must be rejected and + // must NOT win over the exact match. + let good = class_with_ldc_strings( + "GoodPurpose", + &[ + "Normal", + "Commentary", + "PiP", + "Trivia", + "Descriptive", + "Score", + "NoForced", + "NoForcedDescriptive", + ], + ); + // Prefix matches but count is 100 — abs_diff(100, 8) = 92, far + // outside LDC_COUNT_TOLERANCE (4). Real logic must reject this + // class as a Purpose candidate entirely. + let mut decoy_values: Vec<&str> = vec!["Normal", "Commentary", "PiP", "Trivia"]; + let filler: Vec = (0..96).map(|i| format!("Filler{i}")).collect(); + decoy_values.extend(filler.iter().map(String::as_str)); + let decoy = class_with_ldc_strings("DecoyPurpose", &decoy_values); + + let zip = build_zip(&[ + ("com/bydeluxe/Good.class", good), + ("com/bydeluxe/Decoy.class", decoy), + ]); + let mut archive = open_jar(zip); + let enums = identify_master_enums(&mut archive); + let purpose = enums + .iter() + .find(|(label, _)| *label == "Purpose") + .unwrap_or_else(|| panic!("Purpose fingerprint not matched: {enums:?}")); + assert_eq!(purpose.1.class_name, "com/bydeluxe/Good.class"); + assert_eq!(purpose.1.values.len(), 8); + assert_eq!(purpose.1.values[0], "Normal"); + assert_eq!(purpose.1.values[7], "NoForcedDescriptive"); + } + + #[test] + fn identify_master_enums_accepts_count_at_the_tolerance_boundary() { + // abs_diff(expected_count, count) == LDC_COUNT_TOLERANCE (4) exactly + // must still be accepted (`> tolerance` rejects, so `== tolerance` + // is the last accepted value). This is the boundary `327:50` + // mutants (`>` -> `==`/`<`/`>=`) disagree on. + let mut values: Vec<&str> = vec!["Normal", "Commentary", "PiP", "Trivia"]; + let filler: Vec = (0..8).map(|i| format!("Filler{i}")).collect(); // 4+8=12, diff=4 + values.extend(filler.iter().map(String::as_str)); + assert_eq!(values.len(), 12); + let class = class_with_ldc_strings("BoundaryPurpose", &values); + let zip = build_zip(&[("com/bydeluxe/B.class", class)]); + let mut archive = open_jar(zip); + let enums = identify_master_enums(&mut archive); + assert!( + enums.iter().any(|(label, _)| *label == "Purpose"), + "a class exactly LDC_COUNT_TOLERANCE away from expected_count must still match" + ); + } + + #[test] + fn identify_master_enums_finds_nothing_without_com_bydeluxe_signal() { + // No FINGERPRINTS-matching class in the jar at all -> empty result + // (kills the `vec![]` mutant only vacuously if paired with the + // positive tests above proving non-emptiness on a real match). + let unrelated = class_with_ldc_strings("Unrelated", &["Foo", "Bar"]); + let zip = build_zip(&[("x/Unrelated.class", unrelated)]); + let mut archive = open_jar(zip); + assert!(identify_master_enums(&mut archive).is_empty()); + } + + // ── Phase C: find_binding_classes / count_master_enum_getstatic ──────── + + #[test] + fn count_master_enum_getstatic_counts_only_master_classes() { + // Directly exercises count_master_enum_getstatic on a synthetic + // ClassFile (no Jar needed — this function takes &ClassFile). + let master: HashSet<&str> = ["LanguageEnum"].into_iter().collect(); + let code_bytes = class_with_getstatic_refs("X", "LanguageEnum", 5); + // Round-trip through ClassFile::parse to get a real &ClassFile. + let class = + super::super::class_reader::ClassFile::parse(&code_bytes).expect("fixture must parse"); + assert_eq!(count_master_enum_getstatic(&class, &master), 5); + + // getstatic refs to a class NOT in master_enum_classes must not count. + let other_master: HashSet<&str> = ["SomeOtherEnum"].into_iter().collect(); + assert_eq!(count_master_enum_getstatic(&class, &other_master), 0); + } + + #[test] + fn find_binding_classes_picks_top_candidates_above_threshold() { + // Class A: 100 getstatic refs (the top / binding class). B: 45 + // (>40% of top, kept). F: 40 (EXACTLY the 40% threshold — pins + // both the `(top_count * 2) / 5` arithmetic and the `>=` + // comparison: any of the `460`/`461` arithmetic mutants shift + // the threshold away from exactly 40, and a `>= -> <` mutant at + // 461 would drop this exact-boundary entry). E: 39 (just BELOW + // the true 40% threshold — a mutant that shrinks the threshold + // below 39 would wrongly keep this). C: 10 (well below, always + // dropped). D: 3 — below MIN_GETSTATIC(4), never even a raw + // candidate. + let master_classes: HashSet<&str> = ["LanguageEnum"].into_iter().collect(); + let a = class_with_getstatic_refs("A", "LanguageEnum", 100); + let b = class_with_getstatic_refs("B", "LanguageEnum", 45); + let f = class_with_getstatic_refs("F", "LanguageEnum", 40); + let e = class_with_getstatic_refs("E", "LanguageEnum", 39); + let c = class_with_getstatic_refs("C", "LanguageEnum", 10); + let d = class_with_getstatic_refs("D", "LanguageEnum", 3); + let zip = build_zip(&[ + ("com/bydeluxe/A.class", a), + ("com/bydeluxe/B.class", b), + ("com/bydeluxe/F.class", f), + ("com/bydeluxe/E.class", e), + ("com/bydeluxe/C.class", c), + ("com/bydeluxe/D.class", d), + ]); + let mut archive = open_jar(zip); + let candidates = find_binding_classes(&mut archive, &master_classes); + let names: Vec<&str> = candidates.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!( + names, + vec![ + "com/bydeluxe/A.class", + "com/bydeluxe/B.class", + "com/bydeluxe/F.class" + ], + "expected [A(100), B(45), F(40)] retained (>=40% of top, descending \ + order, E(39)/C(10)/D(3) dropped), got {names:?}" + ); + assert_eq!(candidates[0].1, 100); + assert_eq!(candidates[1].1, 45); + } + + #[test] + fn find_binding_classes_empty_master_set_yields_no_candidates() { + let master_classes: HashSet<&str> = HashSet::new(); + let a = class_with_getstatic_refs("A", "LanguageEnum", 100); + let zip = build_zip(&[("com/bydeluxe/A.class", a)]); + let mut archive = open_jar(zip); + assert!(find_binding_classes(&mut archive, &master_classes).is_empty()); + } + + // ── Phase D: decode_binding (Jar-level short-circuit wrapper) ─────────── + + #[test] + fn decode_binding_finds_named_class_and_stops_at_first_match() { + // `decode_binding` matches by the Jar entry path (the same string + // `find_binding_classes` returns), not by the class's own + // `this_class` name. Two entries: the target path carries a real + // `new AudioSlot; dup; getstatic; invokespecial` construction; a + // decoy at a different path carries none (and, being pure + // getstatic/pop, would also match nothing if walked). If the name + // comparison is broken (`!=` mutated to `==`), decode_binding would + // either never match the real target (empty result) or would match + // and decode the WRONG entry. + let target = class_with_simple_construction("Ignored"); + let decoy = class_with_getstatic_refs("Ignored2", "LanguageEnum", 2); + let zip = build_zip(&[ + ("com/bydeluxe/Target.class", target), + ("com/bydeluxe/Decoy.class", decoy), + ]); + let mut archive = open_jar(zip); + let master = lang_enum_master(); + + let ctors = decode_binding(&mut archive, "com/bydeluxe/Target.class", &master); + assert_eq!( + ctors.len(), + 1, + "expected the Target entry's one construction" + ); + assert_eq!(ctors[0].binding_type, "AudioSlot"); + + // A name with no matching entry must yield nothing (try_each_class + // never finds a Some). + assert!(decode_binding(&mut archive, "com/bydeluxe/NoSuchClass.class", &master).is_empty()); + } + // ── Phase D bytecode walker tests ─────────────────────────────────────── use super::super::class_reader::{ConstantPool, CpInfo}; @@ -1304,6 +1821,32 @@ mod tests { ); } + #[test] + fn clinit_ldc_string_bytes_boundary_matches_256kib_not_1280() { + // `MAX_CLINIT_LDC_BYTES = 256 * 1024` (262144). A `* -> +` mutant at + // that computation collapses the cap to `256 + 1024` (1280) — 205x + // smaller. 1000-byte strings make the two cap values discriminate + // sharply: correct code retains 262 of them (262000 bytes, the + // 263rd would push to 263000 > 262144); the mutant retains only 1 + // (the 2nd would push to 2000 > 1280). + const N: usize = 400; + let one = "x".repeat(1000); + let mut code = Vec::with_capacity(N * 2); + for _ in 0..N { + code.push(LDC); + code.push(4); + } + let class = class_with_clinit(ldc_pool(&one), 2, &code); + let ldcs = clinit_ldc_strings(&class).expect(" present"); + assert_eq!( + ldcs.len(), + 262, + "expected 262 retained 1000-byte strings under a 256 KiB cap, got {} \ + — either the cap value or the truncation arithmetic changed", + ldcs.len() + ); + } + #[test] fn clinit_ldc_strings_admits_largest_real_fingerprint() { // The biggest framework-stable enum is Language at 70 values; the cap @@ -1398,6 +1941,33 @@ mod tests { ); } + /// `insert` rejects when `bytes.saturating_add(cost) > MAX_CANDIDATE_TOTAL_BYTES` + /// — i.e. landing EXACTLY on the cap is still accepted; only strictly + /// exceeding it is rejected. A `>` -> `>=` mutant would reject the + /// exact-cap entry too. Two entries are sized so the second brings + /// `bytes` to precisely `MAX_CANDIDATE_TOTAL_BYTES`, not one byte over. + #[test] + fn candidate_pool_insert_accepts_landing_exactly_on_the_cap() { + let mut pool = CandidatePool::default(); + // cost = name.len() + payload.len() = 1 + (CAP - 2) = CAP - 1. + let first_payload = "a".repeat(MAX_CANDIDATE_TOTAL_BYTES - 2); + assert!(pool.insert("a", vec![first_payload])); + assert_eq!(pool.bytes, MAX_CANDIDATE_TOTAL_BYTES - 1); + + // cost = 1 (name "b") + 0 (empty string) = 1. bytes becomes exactly + // MAX_CANDIDATE_TOTAL_BYTES — must be ACCEPTED, not rejected. + let accepted = pool.insert("b", vec![String::new()]); + assert!( + accepted, + "an entry landing exactly on MAX_CANDIDATE_TOTAL_BYTES must be accepted, \ + only entries that exceed it should be rejected" + ); + assert_eq!(pool.bytes, MAX_CANDIDATE_TOTAL_BYTES); + + // One more byte of cost now genuinely exceeds the cap and must be rejected. + assert!(!pool.insert("c", vec!["x".to_string()])); + } + // ── Construction accumulation bounds ──────────────────────────────────── /// One `new X / dup / ... / invokespecial X.` per 11 code bytes, so @@ -1574,6 +2144,80 @@ mod tests { MasterEnumTable::from(&[("Language", m)]) } + #[test] + fn decode_binding_class_finds_the_clinit_method_and_emits_its_construction() { + // decode_binding_class wraps BindingDecoder over every method literally + // named "" on the class. Exercises the method-selection + // (`member_name(m) != Some("")`) and per-method-union + // truncation (`room == 0`) logic that decode_binding_class adds on + // top of the already-tested BindingDecoder::step/run. + // + // Pool layout (must hold ""/"()V"/"Code" at 1/2/3 per + // `class_with_clinit`'s contract, while ALSO matching the fixed cp + // indices — 6/8/12 — the reused `new AudioSlot; dup; getstatic; + // invokespecial` bytecode below references): + // 1 Utf8 "" 2 Utf8 "()V" 3 Utf8 "Code" + // 4 Utf8 "LanguageEnum" 5 Class->4 + // 6 Fieldref{class:5,nat:9} 7 Utf8 "English" + // 8 Class->10 (AudioSlot) 9 NameAndType{name:7,desc:11} + // 10 Utf8 "AudioSlot" 11 Utf8 "LLanguageEnum;" + // 12 Methodref{class:8,nat:13} + // 13 NameAndType{name:14,desc:15} + // 14 Utf8 "" 15 Utf8 "(LLanguageEnum;)V" + let pool = ConstantPool::from_entries(vec![ + CpInfo::Empty, + CpInfo::Utf8("".into()), + CpInfo::Utf8("()V".into()), + CpInfo::Utf8("Code".into()), + CpInfo::Utf8("LanguageEnum".into()), + CpInfo::Class { name_index: 4 }, + CpInfo::Fieldref { + class_index: 5, + name_and_type_index: 9, + }, + CpInfo::Utf8("English".into()), + CpInfo::Class { name_index: 10 }, + CpInfo::NameAndType { + name_index: 7, + descriptor_index: 11, + }, + CpInfo::Utf8("AudioSlot".into()), + CpInfo::Utf8("LLanguageEnum;".into()), + CpInfo::Methodref { + class_index: 8, + name_and_type_index: 13, + }, + CpInfo::NameAndType { + name_index: 14, + descriptor_index: 15, + }, + CpInfo::Utf8("".into()), + CpInfo::Utf8("(LLanguageEnum;)V".into()), + ]); + let code: Vec = vec![ + NEW, + 0, + 8, // new AudioSlot + 0x59, // dup + GETSTATIC, + 0, + 6, // getstatic LanguageEnum.English + INVOKESPECIAL, + 0, + 12, // invokespecial AudioSlot.(LLanguageEnum;)V + ]; + let class = class_with_clinit(pool, 4, &code); + let master = lang_enum_master(); + let constructions = decode_binding_class(&class, &master); + assert_eq!( + constructions.len(), + 1, + "expected exactly 1 Construction from the single , got {}", + constructions.len() + ); + assert_eq!(constructions[0].binding_type, "AudioSlot"); + } + #[test] fn binding_decoder_recognizes_simple_construction() { // Synthetic : @@ -1655,6 +2299,217 @@ mod tests { assert_eq!(decoder.constructions.len(), 1); } + #[test] + fn binding_decoder_dup_duplicates_top_of_stack() { + // JVMS §3.11.7 `dup` (0x59): duplicate the top stack value. Checked + // directly on `decoder.stack` (not via emitted Constructions, which + // a single `new X; dup; invokespecial` sequence can satisfy either + // way — the leftover copy `dup` is responsible for only matters + // once something ELSE consumes it afterward). `new AudioSlot; dup` + // with no invokespecial must leave exactly two NewObj("AudioSlot") + // entries. + let pool = build_simple_pool(); + let master = lang_enum_master(); + let code: Vec = vec![NEW, 0, 8, 0x59 /* dup */]; + let attr = super::super::class_reader::CodeAttribute { + max_stack: 4, + max_locals: 0, + code: &code, + }; + let mut decoder = BindingDecoder::new(&pool, &master); + decoder.run(&attr); + assert_eq!( + decoder.stack.len(), + 2, + "dup must duplicate, not skip, the top value" + ); + for v in &decoder.stack { + match v { + StackVal::NewObj(name) => assert_eq!(name, "AudioSlot"), + other => panic!("expected NewObj(\"AudioSlot\") x2, got {other:?}"), + } + } + } + + /// Pool with a single Methodref (cp index 6) to `AnyClass.m`, + /// for the `invokevirtual`/`invokestatic`/`invokeinterface` arg-popping + /// tests below. + fn call_ref_pool(descriptor: &str) -> ConstantPool { + ConstantPool::from_entries(vec![ + CpInfo::Empty, + CpInfo::Utf8("AnyClass".into()), // 1 + CpInfo::Class { name_index: 1 }, // 2 + CpInfo::Utf8("m".into()), // 3 + CpInfo::Utf8(descriptor.to_string()), // 4 + CpInfo::NameAndType { + name_index: 3, + descriptor_index: 4, + }, // 5 + CpInfo::Methodref { + class_index: 2, + name_and_type_index: 5, + }, // 6 + ]) + } + + #[test] + fn binding_decoder_invokevirtual_pops_receiver_plus_args() { + // JVMS §6.5 `invokevirtual`/`invokeinterface` pop the receiver + // PLUS the descriptor's args (`extra = 1` for opcodes 0xB6/0xB9); + // `invokestatic` (0xB8) pops ONLY the args (no receiver). Each + // case below pushes exactly `to_pop` placeholder ints and checks + // the stack is fully drained — a wrong `extra`/`arg_count+extra` + // computation leaves a wrong number of leftovers. + let master = lang_enum_master(); + let run_stack_len = |opcode: u8, descriptor: &str, n_pushes: usize| -> usize { + let pool = call_ref_pool(descriptor); + let mut code = Vec::new(); + for i in 0..n_pushes { + code.push(ICONST_0 + i as u8); // distinct placeholder ints + } + code.push(opcode); + code.push(0); + code.push(6); + if opcode == 0xB9 { + // invokeinterface (JVMS §6.5): 2 extra operand bytes — + // `count` (here: arg slot count + 1 for the receiver, per + // spec) and a reserved zero byte. + code.push((n_pushes) as u8); + code.push(0); + } + let attr = super::super::class_reader::CodeAttribute { + max_stack: 8, + max_locals: 0, + code: &code, + }; + let mut decoder = BindingDecoder::new(&pool, &master); + decoder.run(&attr); + decoder.stack.len() + }; + + // invokevirtual, 1-arg descriptor: pops receiver + 1 arg = 2. + assert_eq!( + run_stack_len(0xB6, "(I)V", 2), + 0, + "invokevirtual must pop receiver + args" + ); + // invokeinterface, 1-arg descriptor: same as invokevirtual. + assert_eq!( + run_stack_len(0xB9, "(I)V", 2), + 0, + "invokeinterface must pop receiver + args" + ); + // invokestatic, 2-arg descriptor: pops ONLY the 2 args, no receiver. + assert_eq!( + run_stack_len(0xB8, "(II)V", 2), + 0, + "invokestatic must pop exactly the arg count, no receiver" + ); + // invokestatic with a leftover value UNDER the args: only the args + // are popped, the leftover survives. Distinguishes a `>` mutant at + // the `len < to_pop` guard (which would incorrectly `clear()` the + // whole stack here instead of leaving the leftover). + assert_eq!( + run_stack_len(0xB8, "(I)V", 2), // 1 leftover + 1 real arg pushed + 1, + "only the descriptor's args must be popped, not the whole stack" + ); + } + + #[test] + fn binding_decoder_invoke_family_defensively_clears_on_stack_underflow() { + // If the symbolic stack has FEWER entries than the call needs to + // pop (malformed/adversarial bytecode, or earlier drift), the + // decoder must defensively clear rather than underflow-subtract + // (`len - to_pop` with `len < to_pop` would panic on the `usize` + // subtraction). + let pool = call_ref_pool("(II)V"); // needs to_pop = 2 + let code: Vec = vec![ICONST_0, 0xB8, 0, 6]; // only 1 value on stack + let master = lang_enum_master(); + let attr = super::super::class_reader::CodeAttribute { + max_stack: 8, + max_locals: 0, + code: &code, + }; + let mut decoder = BindingDecoder::new(&pool, &master); + decoder.run(&attr); + assert_eq!( + decoder.stack.len(), + 0, + "stack-underflowing invoke must clear defensively, not underflow-subtract" + ); + } + + /// Pool for a single-int-arg constructor `AudioSlot.(I)V`, used by + /// `binding_decoder_int_push_opcodes_produce_the_right_value` to isolate + /// each int-push opcode's produced VALUE (not just "a construction + /// happened") — JVMS §3.11.3 (`iconst_`, `bipush`, `sipush`, `ldc` of + /// a `CONSTANT_Integer`) each push a specific known int. + fn int_ctor_pool() -> ConstantPool { + ConstantPool::from_entries(vec![ + CpInfo::Empty, + CpInfo::Utf8("AudioSlot".into()), // 1 + CpInfo::Class { name_index: 1 }, // 2 + CpInfo::Utf8("".into()), // 3 + CpInfo::Utf8("(I)V".into()), // 4 + CpInfo::NameAndType { + name_index: 3, + descriptor_index: 4, + }, // 5 + CpInfo::Methodref { + class_index: 2, + name_and_type_index: 5, + }, // 6 + CpInfo::Integer(12345), // 7 — for the `ldc`/Integer case + ]) + } + + #[test] + fn binding_decoder_int_push_opcodes_produce_the_right_value() { + // JVMS §3.11.3: iconst_ pushes exactly i (i in -1..=5); bipush + // sign-extends its i8 operand; sipush sign-extends its i16 operand; + // ldc of a CONSTANT_Integer pushes that constant. Each is checked + // as the sole arg of `new AudioSlot; dup; ; invokespecial + // AudioSlot.(I)V` so a wrong (or absent, if the opcode's match + // arm were deleted) push shows up as a wrong (or missing/Unknown) + // arg value, not just "some construction happened". + let cases: Vec<(&str, Vec, i32)> = vec![ + ("iconst_m1", vec![ICONST_M1], -1), + ("iconst_0", vec![ICONST_0], 0), + ("iconst_1", vec![ICONST_1], 1), + ("iconst_2", vec![ICONST_2], 2), + ("iconst_3", vec![ICONST_3], 3), + ("iconst_4", vec![ICONST_4], 4), + ("iconst_5", vec![ICONST_5], 5), + ("bipush -100", vec![BIPUSH, 0x9C], -100), // 0x9C as i8 = -100 + ("sipush 4660", vec![SIPUSH, 0x12, 0x34], 4660), // 0x1234 + ("ldc Integer(12345)", vec![LDC, 7], 12345), + ]; + let pool = int_ctor_pool(); + let master = lang_enum_master(); + for (label, push, expected) in cases { + let mut code = vec![NEW, 0, 2, 0x59 /* dup */]; + code.extend_from_slice(&push); + code.extend_from_slice(&[INVOKESPECIAL, 0, 6]); + let attr = super::super::class_reader::CodeAttribute { + max_stack: 4, + max_locals: 0, + code: &code, + }; + let mut decoder = BindingDecoder::new(&pool, &master); + decoder.run(&attr); + assert_eq!( + decoder.constructions.len(), + 1, + "{label}: expected exactly 1 construction" + ); + match &decoder.constructions[0].args[0] { + StackVal::Int(n) => assert_eq!(*n, expected, "{label}: wrong int value"), + other => panic!("{label}: expected StackVal::Int({expected}), got {other:?}"), + } + } + } + #[test] fn binding_decoder_skips_unmatched_invokespecial() { // invokespecial without a preceding `new X; dup` — should diff --git a/src/labels/jar.rs b/src/labels/jar.rs index f07ccf4..902dac6 100644 --- a/src/labels/jar.rs +++ b/src/labels/jar.rs @@ -216,6 +216,27 @@ mod tests { ZipArchive::new(Cursor::new(bytes)).expect("valid zip") } + /// The doc comment states the cap is 64 MiB. Pin the exact numeric + /// value (not derived from the same `64 * 1024 * 1024` expression + /// under test — a hardcoded literal) so a mutation of the arithmetic + /// (e.g. `*` -> `+`) is caught even though no test builds an actual + /// 64 MiB buffer. + #[test] + fn max_class_bytes_is_64_mebibytes() { + assert_eq!(MAX_CLASS_BYTES, 67_108_864); + } + + #[test] + fn has_path_prefix_matches_only_declared_prefix() { + let jar = open(build_stored_zip( + "com/dbp/Loader.class", + MINIMAL_CLASS, + MINIMAL_CLASS.len() as u32, + )); + assert!(has_path_prefix(&jar, "com/dbp/")); + assert!(!has_path_prefix(&jar, "com/bydeluxe/")); + } + #[test] fn try_each_class_reads_minimal_class() { let mut jar = open(build_stored_zip( diff --git a/src/labels/mod.rs b/src/labels/mod.rs index d71b1ac..24823bb 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -1885,6 +1885,32 @@ mod apply_tests { } } + /// Spec: fill_defaults must not clobber a video label that's already + /// set (mirrors the audio preserve-existing-label contract above). + /// Mutation: replace the `v.label.is_empty()` guard with `true` so the + /// Video arm always fires, wiping out a pre-set label. + #[test] + fn fill_defaults_preserves_existing_video_label() { + let mut titles = vec![title_with(vec![Stream::Video(VideoStream { + pid: 0x1011, + codec: Codec::Hevc, + resolution: Resolution::R2160p, + frame_rate: FrameRate::F23_976, + hdr: HdrFormat::Hdr10, + color_space: ColorSpace::Bt2020, + display_aspect: None, + secondary: false, + label: "Pre-set 4K HDR".into(), + measured_cicp: None, + })])]; + fill_defaults(&mut titles); + if let Stream::Video(v) = &titles[0].streams[0] { + assert_eq!(v.label, "Pre-set 4K HDR"); + } else { + panic!("expected video stream"); + } + } + #[test] fn fill_defaults_generates_video_label_with_hdr() { let mut titles = vec![title_with(vec![video()])]; @@ -2086,4 +2112,247 @@ mod apply_tests { assert!(!codec_hint_adds_detail("Dolby Digital Plus 5.1")); assert!(!codec_hint_adds_detail("")); } + + // ── generate_video_label hardening ───────────────────────────────────── + + /// Spec: a secondary (dependent-view) video stream with Dolby Vision + /// enhancement layer gets the brand string "Dolby Vision EL"; every + /// other HDR format on a secondary stream gets no label at all (that + /// wording is a CLI concern). + /// Mutation: delete the `HdrFormat::DolbyVision` arm so it falls + /// through to the `_ => String::new()` catch-all, losing the brand. + #[test] + fn generate_video_label_secondary_dolby_vision_el() { + assert_eq!( + generate_video_label( + &Codec::Hevc, + (3840, 2160), + false, + &HdrFormat::DolbyVision, + true + ), + "Dolby Vision EL" + ); + // Every other HDR format on a secondary stream: empty, not text. + assert_eq!( + generate_video_label(&Codec::Hevc, (3840, 2160), false, &HdrFormat::Hdr10, true), + "" + ); + } + + /// Spec: 480 lines is the SD floor — a stream with height exactly 480 + /// must get the "480p"/"480i" token (BD spec height boundary), not fall + /// through to the empty-resolution case. + /// Mutation: `h >= 480` -> `h < 480` inverts the boundary so a legitimate + /// 480-line stream (h == 480) produces no resolution token at all. + #[test] + fn generate_video_label_480_boundary() { + let label = generate_video_label(&Codec::Mpeg2, (0, 480), false, &HdrFormat::Sdr, false); + assert!( + label.contains("480p"), + "h == 480 must resolve to 480p, got {label:?}" + ); + } + + /// Spec: SDR is the unmarked default — it must never appear as a token + /// in the generated label (only non-SDR formats get an explicit tag). + /// Mutation: delete the `HdrFormat::Sdr` arm so it falls through to + /// `_ => parts.push(hdr.name())`, appending a spurious "SDR" token. + #[test] + fn generate_video_label_sdr_produces_no_hdr_token() { + assert_eq!( + generate_video_label(&Codec::Hevc, (1920, 1080), false, &HdrFormat::Sdr, false), + "HEVC 1080p" + ); + } + + // ── generate_audio_label_atmos ─────────────────────────────────────── + + /// Spec: the Atmos-aware variant folds "Atmos" into the codec brand + /// name for TrueHD/DD+ carriers, distinct from the plain wrapper. + /// Mutation: stub the whole function to `String::new()` / a constant + /// literal — either way it stops reflecting the codec/channel inputs. + #[test] + fn generate_audio_label_atmos_folds_brand() { + assert_eq!( + generate_audio_label_atmos(&Codec::TrueHd, &AudioChannels::Surround71, false), + "Dolby TrueHD Atmos 7.1" + ); + assert_eq!( + generate_audio_label_atmos(&Codec::Ac3Plus, &AudioChannels::Surround51, false), + "Dolby Digital Plus Atmos 5.1" + ); + } + + /// Spec: every disc-audio codec in the enum has a full marketing name, + /// including the lossy PC-container codecs (AAC/MP2/MP3/FLAC/Opus) that + /// `generate_audio_label_all_codecs` above doesn't cover. + /// Mutation: delete any one of these match arms — the codec falls + /// through to `_ => return String::new()`, silently losing its label. + #[test] + fn generate_audio_label_covers_pc_container_codecs() { + assert_eq!( + generate_audio_label(&Codec::Aac, &AudioChannels::Stereo, false), + "AAC 2.0" + ); + assert_eq!( + generate_audio_label(&Codec::Mp2, &AudioChannels::Stereo, false), + "MPEG Audio 2.0" + ); + assert_eq!( + generate_audio_label(&Codec::Mp3, &AudioChannels::Stereo, false), + "MP3 2.0" + ); + assert_eq!( + generate_audio_label(&Codec::Flac, &AudioChannels::Stereo, false), + "FLAC 2.0" + ); + assert_eq!( + generate_audio_label(&Codec::Opus, &AudioChannels::Stereo, false), + "Opus 2.0" + ); + } + + // ── codec_hint_consistent: chained-OR boundary hardening ──────────────── + // + // The family-detection booleans are built from chains of `h.contains(..) + // || h.contains(..) || ...` synonym checks. Each test below isolates ONE + // synonym clause (a hint string that matches that clause and NO other + // clause in the same chain) so a `||` -> `&&` flip at that specific + // position changes the family verdict — and, downstream, whether the + // codec match arm returns the spec-correct answer. + + /// Isolates the `"true hd"` (space form) synonym in `says_truehd`, + /// which mutant testing hit at 396:44's `||`. If that `||` is + /// weakened to `&&`, "True HD" alone (no "truehd" substring) no longer + /// sets `says_truehd`, `names_family` goes false entirely (no other + /// family clause matches), and the function takes the "no family + /// named" early-return path — turning a should-be-`false` verdict for + /// a mismatched codec into `true`. + #[test] + fn codec_hint_consistent_truehd_space_synonym() { + assert!(codec_hint_consistent("True HD 7.1", &Codec::TrueHd)); + assert!(!codec_hint_consistent("True HD 7.1", &Codec::Ac3)); + } + + /// Isolates the `"ac3+"` (no-hyphen) synonym in `says_ddp` (398:9's + /// `||`). A hint matching only this clause must still classify as + /// DD+, not fall through to the plain-AC3 `says_ac3` check. + #[test] + fn codec_hint_consistent_ddp_ac3_plus_no_hyphen_synonym() { + assert!(codec_hint_consistent("AC3+ 5.1", &Codec::Ac3Plus)); + assert!(!codec_hint_consistent("AC3+ 5.1", &Codec::Ac3)); + } + + /// Isolates the `"eac3"` synonym in `says_ddp` (401:9's `||`), the + /// last clause before the chain moves to "digital plus"/"dd+". + #[test] + fn codec_hint_consistent_ddp_eac3_synonym() { + assert!(codec_hint_consistent("EAC3 5.1", &Codec::Ac3Plus)); + assert!(!codec_hint_consistent("EAC3 5.1", &Codec::Ac3)); + } + + /// Isolates the `"pcm"` (no "lpcm") synonym in `says_lpcm` (409:40's + /// `||`). A bare "PCM" hint on a non-LPCM stream must still be judged + /// inconsistent — if the `||` were `&&`, "PCM" alone would fail to set + /// `says_lpcm`, `names_family` would go false, and the function would + /// take the "no family named" path, wrongly returning `true` for ANY + /// codec. + #[test] + fn codec_hint_consistent_lpcm_bare_pcm_synonym() { + assert!(codec_hint_consistent("PCM", &Codec::Lpcm)); + assert!(!codec_hint_consistent("PCM", &Codec::Ac3)); + } + + /// Isolates the `says_dts_ma || says_dts_hr` disjunction inside the + /// `names_family` chain (418:60). A hint that sets `says_dts_ma` alone + /// (e.g. "Master Audio", without "hd ma") must still make + /// `names_family` true; weakening that `||` to `&&` requires both + /// clauses at once, so `names_family` goes false and the function + /// wrongly reports "consistent" for a codec the hint never named. + #[test] + fn codec_hint_consistent_names_family_dts_ma_alone() { + assert!(!codec_hint_consistent("Master Audio", &Codec::Ac3)); + assert!(codec_hint_consistent("Master Audio", &Codec::DtsHdMa)); + } + + /// Isolates the `Codec::TrueHd => says_truehd || says_atmos` arm + /// (433:38). An Atmos-tagged hint that names a DIFFERENT lossless + /// carrier by name (DD+) must still be judged consistent with a + /// TrueHd stream purely on the Atmos marker — `||` -> `&&` would + /// require the hint to ALSO say "truehd", which an Atmos-only marker + /// doesn't. + #[test] + fn codec_hint_consistent_truehd_arm_atmos_alone() { + assert!(codec_hint_consistent( + "Dolby Digital Plus Atmos", + &Codec::TrueHd + )); + } + + /// Spec: `Codec::Dts` is consistent ONLY when the hint's DTS-family + /// bookkeeping (`says_dts`) is true, not just because `names_family` is + /// true via some other carrier. + /// Mutation: delete the `Codec::Dts => says_dts` arm (438:9) — it falls + /// to `_ => true`, so ANY named family is (wrongly) "consistent" with + /// a Dts stream. + #[test] + fn codec_hint_consistent_dts_arm_not_bypassed() { + assert!(!codec_hint_consistent("Dolby Digital", &Codec::Dts)); + } + + /// Spec: `Codec::Lpcm` is consistent ONLY when `says_lpcm` is true. + /// Mutation: delete the `Codec::Lpcm => says_lpcm` arm (439:9) — same + /// bypass-to-`_ => true` failure mode as the Dts arm above. + #[test] + fn codec_hint_consistent_lpcm_arm_not_bypassed() { + assert!(!codec_hint_consistent("Dolby Digital", &Codec::Lpcm)); + } +} + +// ── fill_gaps_from_mpls: no-op-when-nothing-added hardening ──────────────── + +#[cfg(test)] +mod fill_gaps_sort_tests { + use super::*; + + fn label(t: StreamLabelType, n: u16, lang: &str, codec: &str) -> StreamLabel { + StreamLabel { + stream_number: n, + stream_type: t, + language: lang.into(), + name: String::new(), + purpose: LabelPurpose::Normal, + qualifier: LabelQualifier::None, + codec_hint: codec.into(), + variant: String::new(), + } + } + + /// Spec: the sort-by-(type, number) pass only runs when the merge + /// actually added something (`added > 0`); when MPLS contributed + /// nothing new, `framework`'s existing order (however the caller built + /// it) must be left untouched. + /// Mutation: `added > 0` -> `added >= 0` is always true, so the sort + /// runs unconditionally, silently reordering a framework list that + /// wasn't already in (type, number) order even on a no-op merge. + #[test] + fn fill_gaps_leaves_order_untouched_when_nothing_added() { + // Deliberately out of (type, number) order: number 2 before 1. + let mut framework = vec![ + label(StreamLabelType::Audio, 2, "fra", "AC-3"), + label(StreamLabelType::Audio, 1, "eng", "TrueHD"), + ]; + // MPLS covers exactly the same (type, number) slots -> added == 0. + let mpls = vec![ + label(StreamLabelType::Audio, 1, "eng", "TrueHD"), + label(StreamLabelType::Audio, 2, "fra", "AC-3"), + ]; + fill_gaps_from_mpls(&mut framework, &mpls); + assert_eq!( + framework[0].stream_number, 2, + "no gap-fill happened, so the original (out-of-order) sequence must survive" + ); + assert_eq!(framework[1].stream_number, 1); + } } diff --git a/src/labels/mpls_universal.rs b/src/labels/mpls_universal.rs index fec96be..6b293b9 100644 --- a/src/labels/mpls_universal.rs +++ b/src/labels/mpls_universal.rs @@ -61,6 +61,37 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option return None; } + let mut playlists: Vec = Vec::new(); + for name in &mpls_names { + let path = format!("/BDMV/PLAYLIST/{}", name); + let Ok(data) = udf.read_file(reader, &path) else { + continue; + }; + let Ok(playlist) = crate::mpls::parse(&data) else { + continue; + }; + playlists.push(playlist); + } + + let labels = build_labels(&playlists); + if labels.is_empty() { + return None; + } + + // MPLS gives language + codec but never editorial info (no + // commentary/SDH/director's cut). Low confidence means framework + // parsers (paramount, criterion, pixelogic, ctrm, dbp, deluxe) always + // win when they match. MPLS only gets chosen as the parser when + // nothing else fired — exactly the universal-fallback role we want. + Some(ParseResult::low(labels)) +} + +/// Convert every stream entry across `playlists` into deduped +/// [`StreamLabel`]s. Factored out of [`parse`] so unit tests can drive +/// the actual conversion logic (stream-type mapping, dedup key, dense +/// global counters) directly from already-parsed [`crate::mpls::Playlist`] +/// values, without needing a synthetic on-disc UDF image. +fn build_labels(playlists: &[crate::mpls::Playlist]) -> Vec { let mut labels: Vec = Vec::new(); // (stream_type_tag, language, codec_hint, pid) — PID is the // canonical "same physical stream" key; type+lang+codec round @@ -77,15 +108,7 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option let mut audio_idx: u16 = 0; let mut sub_idx: u16 = 0; - for name in &mpls_names { - let path = format!("/BDMV/PLAYLIST/{}", name); - let Ok(data) = udf.read_file(reader, &path) else { - continue; - }; - let Ok(playlist) = crate::mpls::parse(&data) else { - continue; - }; - + for playlist in playlists { for entry in &playlist.streams { let label_type = match entry.stream_type { 2 | 5 => StreamLabelType::Audio, // primary + secondary audio @@ -130,17 +153,7 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option }); } } - - if labels.is_empty() { - return None; - } - - // MPLS gives language + codec but never editorial info (no - // commentary/SDH/director's cut). Low confidence means framework - // parsers (paramount, criterion, pixelogic, ctrm, dbp, deluxe) always - // win when they match. MPLS only gets chosen as the parser when - // nothing else fired — exactly the universal-fallback role we want. - Some(ParseResult::low(labels)) + labels } fn has_mpls_extension(name: &str) -> bool { @@ -336,60 +349,14 @@ mod tests { } } - /// Drive the same conversion logic that `parse()` runs on real - /// disc data, but starting from already-parsed Playlists so we - /// don't have to synthesize valid MPLS bytes. + /// Drive the actual production conversion logic (`build_labels`, the + /// function `parse()` calls) starting from already-parsed Playlists, + /// so tests don't have to synthesize valid on-disc MPLS/UDF bytes. + /// This calls the *real* code under test rather than a hand-written + /// re-implementation, so mutations inside `build_labels` (stream-type + /// mapping, dedup key, counters) are actually caught here. fn labels_from_playlists(playlists: &[Playlist]) -> Vec { - let mut labels: Vec = Vec::new(); - let mut seen: Vec<(StreamLabelType, String, String, u16)> = Vec::new(); - - // Global counters hoisted OUT of the playlist loop to match - // production `parse()` (lines 77-78): stream_numbers are dense - // per type across the whole disc, not reset per playlist. - let mut audio_idx: u16 = 0; - let mut sub_idx: u16 = 0; - - for playlist in playlists { - for entry in &playlist.streams { - let label_type = match entry.stream_type { - 2 | 5 => StreamLabelType::Audio, - 3 => StreamLabelType::Subtitle, - _ => continue, - }; - // Dedup BEFORE consuming a counter value, matching prod - // parse() ordering so a deduped duplicate does not burn a - // stream number. - let language = normalize_language(&entry.language); - let name = language_display_name(&language); - let codec_hint = build_codec_hint(label_type, entry); - let key = (label_type, language.clone(), codec_hint.clone(), entry.pid); - if seen.contains(&key) { - continue; - } - seen.push(key); - let stream_number = match label_type { - StreamLabelType::Audio => { - audio_idx += 1; - audio_idx - } - StreamLabelType::Subtitle => { - sub_idx += 1; - sub_idx - } - }; - labels.push(StreamLabel { - stream_number, - stream_type: label_type, - language, - name, - purpose: LabelPurpose::Normal, - qualifier: LabelQualifier::None, - codec_hint, - variant: String::new(), - }); - } - } - labels + build_labels(playlists) } #[test] diff --git a/src/labels/paramount.rs b/src/labels/paramount.rs index ddea33d..0d028a6 100644 --- a/src/labels/paramount.rs +++ b/src/labels/paramount.rs @@ -470,4 +470,23 @@ mod tests { assert!(find_feature_playlist("").is_none()); assert!(find_feature_playlist("").is_none()); } + + /// Spec: on a tie in audio-slot count, the FIRST playlist encountered + /// wins (consistent with `select_result`'s first-wins tiebreak + /// elsewhere in the registry) — later playlists only displace the + /// current best on a STRICTLY greater count. + /// Mutation: `count > best_aud_count` -> `count >= best_aud_count` + /// would let a later tied playlist silently displace the first. + #[test] + fn find_feature_first_wins_on_audio_count_tie() { + let xml = r#" + + + "#; + let feature = find_feature_playlist(xml).expect("a feature is found"); + assert!( + feature.contains(r#"name="A""#), + "first playlist must win a tie, got: {feature}" + ); + } } diff --git a/src/labels/pixelogic.rs b/src/labels/pixelogic.rs index 8c63fd3..58ca6d8 100644 --- a/src/labels/pixelogic.rs +++ b/src/labels/pixelogic.rs @@ -638,6 +638,87 @@ mod tests { assert!(audio.is_empty() || audio.iter().all(|l| l.stream_number <= 512)); } + /// Spec: the FPL section also ends on an `SF_` marker (not just + /// `SEG_`/`FPL_`). Only `assign_labels_fpl_section_ends_on_seg_boundary` + /// existed before, which cannot distinguish a mutated `||` chain from + /// the correct one (any single true operand already ends the section). + /// This test isolates the `SF_` alternative specifically. + /// Mutation: `||` -> `&&` in the end-of-section check would require + /// ALL THREE prefixes to match simultaneously (impossible for a real + /// single token), so the section would never end on `SF_` alone. + #[test] + fn assign_labels_fpl_section_ends_on_sf_boundary() { + let mut flag = false; + let tokens = strs(&[ + "FPL_MainFeature", + "eng_MLP_", + "SF_Something", // must end the FPL section + "fra_AC3_", // must NOT be parsed + ]); + let labels = assign_labels(&tokens, &mut flag); + assert_eq!(labels.len(), 1, "only eng from FPL section"); + assert_eq!(labels[0].language, "eng"); + } + + /// Spec: the two per-type caps are independent — the loop only stops + /// early once BOTH audio and subtitle counters have reached + /// `MAX_STREAMS_PER_TYPE`. Reaching the audio cap alone must not cut + /// off subtitle processing. + /// Mutation: `&&` -> `||` in the outer stop-condition would break the + /// loop as soon as EITHER counter reaches the cap, silently dropping + /// a legitimate subtitle stream that comes after audio saturates. + #[test] + fn assign_labels_audio_cap_alone_does_not_stop_subtitle_processing() { + let mut flag = false; + let mut tokens = vec!["FPL_MainFeature".to_string()]; + for i in 1..=(MAX_STREAMS_PER_TYPE as usize) { + tokens.push(format!("Audio Stream {}", i)); + } + // Subtitle counter is still 0 here — well under the cap. + tokens.push("eng_SDH_".to_string()); + let labels = assign_labels(&tokens, &mut flag); + let subs: Vec<_> = labels + .iter() + .filter(|l| l.stream_type == StreamLabelType::Subtitle) + .collect(); + assert_eq!( + subs.len(), + 1, + "a subtitle stream after the audio cap (but under the subtitle \ + cap) must still be labeled" + ); + } + + /// Companion to the above: with the subtitle counter saturated but + /// audio still under its cap, a subsequent audio token must still be + /// processed. Isolates the first `>=` operand (`audio_num >= + /// MAX_STREAMS_PER_TYPE`) from the second. + /// Mutation: `audio_num >= MAX_STREAMS_PER_TYPE` -> `audio_num < + /// MAX_STREAMS_PER_TYPE` would flip the stop-condition to trigger + /// whenever audio is UNDER cap and subtitle is AT/over cap — exactly + /// this scenario — dropping the trailing audio token. + #[test] + fn assign_labels_subtitle_cap_alone_does_not_stop_audio_processing() { + let mut flag = false; + let mut tokens = vec!["FPL_MainFeature".to_string()]; + for _ in 1..=(MAX_STREAMS_PER_TYPE as usize) { + tokens.push("eng_SDH_".to_string()); + } + // Audio counter is still 0 here — well under the cap. + tokens.push("fra_MLP_".to_string()); + let labels = assign_labels(&tokens, &mut flag); + let audio: Vec<_> = labels + .iter() + .filter(|l| l.stream_type == StreamLabelType::Audio) + .collect(); + assert_eq!( + audio.len(), + 1, + "an audio stream after the subtitle cap (but under the audio \ + cap) must still be labeled" + ); + } + /// Spec: subtitle placeholders (PG Stream N) do NOT advance the subtitle counter. /// Only audio placeholders (`Audio Stream N`) do. /// Mutation: also advance sub counter on PG placeholder → subtitle labels misnumbered. diff --git a/src/labels/vocab.rs b/src/labels/vocab.rs index c861484..0c0aa15 100644 --- a/src/labels/vocab.rs +++ b/src/labels/vocab.rs @@ -685,4 +685,108 @@ mod tests { fn codec_empty_passes_through() { assert_eq!(codec(""), ""); } + + /// `purpose()`'s multi-word-compound fast path ORs two independent + /// phrase checks ("audio description" / "descriptive service"). Each + /// phrase, when it appears as a *word*-bounded match, is independently + /// caught by the has_word fallback further down — so the OR only + /// matters when a phrase appears as a *substring inside a larger word* + /// (no boundary), which .contains() still catches but has_word() would + /// reject. + /// + /// Mutation: replace `||` with `&&` at line 238 → since "audio + /// description" is absent here, the AND fails, the fast path doesn't + /// fire, and the fallback has_word("descriptive") also fails (no word + /// boundary before "descriptive" in "nondescriptive"), so purpose() + /// wrongly returns Normal instead of Descriptive. + #[test] + fn purpose_descriptive_service_substring_without_word_boundary() { + assert_eq!( + purpose("nondescriptive service track"), + LabelPurpose::Descriptive + ); + } + + /// `menu_lang()` maps every authoring-filename token in its table + /// (ISO-639-2/B and /T spellings, plus ISO-639-1) to the canonical + /// /T code used by the rest of the pipeline. Exhaustive per-arm check: + /// deleting any single match arm makes that arm's tokens return None + /// instead of the documented code. + #[test] + fn menu_lang_covers_every_table_entry() { + let cases: &[(&str, &str)] = &[ + ("eng", "eng"), + ("en", "eng"), + ("ger", "deu"), + ("deu", "deu"), + ("de", "deu"), + ("fre", "fra"), + ("fra", "fra"), + ("fr", "fra"), + ("spa", "spa"), + ("es", "spa"), + ("ita", "ita"), + ("it", "ita"), + ("por", "por"), + ("pt", "por"), + ("jpn", "jpn"), + ("jap", "jpn"), + ("ja", "jpn"), + ("kor", "kor"), + ("ko", "kor"), + ("chi", "zho"), + ("zho", "zho"), + ("zh", "zho"), + ("rus", "rus"), + ("ru", "rus"), + ("dut", "nld"), + ("nld", "nld"), + ("nl", "nld"), + ("pol", "pol"), + ("pl", "pol"), + ("cze", "ces"), + ("ces", "ces"), + ("cs", "ces"), + ("dan", "dan"), + ("da", "dan"), + ("fin", "fin"), + ("fi", "fin"), + ("nor", "nor"), + ("no", "nor"), + ("swe", "swe"), + ("sv", "swe"), + ("hun", "hun"), + ("hu", "hun"), + ("gre", "ell"), + ("ell", "ell"), + ("el", "ell"), + ("tur", "tur"), + ("tr", "tur"), + ("ara", "ara"), + ("ar", "ara"), + ("hin", "hin"), + ("hi", "hin"), + ("tha", "tha"), + ("th", "tha"), + ("ukr", "ukr"), + ("uk", "ukr"), + ("cat", "cat"), + ("ca", "cat"), + ]; + for (token, expected) in cases { + assert_eq!( + menu_lang(token), + Some(*expected), + "menu_lang({:?}) should map to {:?}", + token, + expected + ); + } + // Case-insensitive and trimmed. + assert_eq!(menu_lang("ENG"), Some("eng")); + assert_eq!(menu_lang(" Eng "), Some("eng")); + // Unrecognized token -> None, never a guess. + assert_eq!(menu_lang("xyz"), None); + assert_eq!(menu_lang(""), None); + } } diff --git a/src/labels/xml.rs b/src/labels/xml.rs index 9163eca..52fe24b 100644 --- a/src/labels/xml.rs +++ b/src/labels/xml.rs @@ -634,4 +634,136 @@ mod tests { let (s, e) = find_element(xml, "name", 0).unwrap(); assert_eq!(&xml[s..e], "Title"); } + + // ── Malformed / truncated input (untrusted on-disc XML) ──────────────── + // + // These scrapers run on XML lifted out of BD-J jar entries, which is + // attacker-controllable. Every scan in this module must terminate and + // stay in bounds on truncated or unbalanced input rather than panic. + // XML 1.0 §2.3 defines the Name production these boundary rules model. + + /// A quoted attribute value that is never closed must terminate the + /// scan at EOF rather than reading past the end of the buffer. + #[test] + fn attr_unterminated_quoted_value_scan_stops_at_eof() { + // The scanner enters the `y="` value and runs off the end looking + // for the closing quote; `name` is never found. + assert_eq!(attr(r#""#, "name"), None); + // The real attribute after the decoy still resolves. + assert_eq!( + attr(r#""#, "name"), + Some("real".into()) + ); + } + + /// XML 1.0 §2.3 NameChar includes `-`, `_` and `.`, so `q-a`, `q_a` and + /// `q.a` are each a single attribute name distinct from `a`. Searching + /// for `a` must not match the tail of any of them. + #[test] + fn attr_name_char_boundary_covers_hyphen_underscore_and_dot() { + assert_eq!( + attr(r#""#, "a"), + Some("real".into()) + ); + assert_eq!( + attr(r#""#, "a"), + Some("real".into()) + ); + assert_eq!( + attr(r#""#, "a"), + Some("real".into()) + ); + } + + /// An open tag truncated mid-attribute never terminates, so no element + /// can be returned — and the attribute walk must not read past EOF. + #[test] + fn find_element_unterminated_open_tag_returns_none() { + assert_eq!(find_element("` that would follow it must stay in bounds. + #[test] + fn find_element_trailing_slash_at_eof_returns_none() { + assert_eq!(find_element("` inside a quoted attribute value does not close the element. + #[test] + fn find_element_quoted_self_close_marker_does_not_end_element() { + let xml = r#""#; + let (s, e) = find_element(xml, "x", 0).unwrap(); + assert_eq!(&xml[s..e], r#""#); + } + + /// An attribute value whose quote is never closed leaves the open tag + /// unterminated; the scan must end at EOF and report no element. + #[test] + fn find_element_unterminated_quoted_attr_returns_none() { + assert_eq!(find_element(r#"` is. + #[test] + fn find_element_unquoted_slash_is_not_self_closing() { + let xml = "body"; + let (s, e) = find_element(xml, "a", 0).unwrap(); + assert_eq!(&xml[s..e], "body"); + } + + /// `text` must locate the real end of the open tag: a bare `/` inside + /// an unquoted attribute value must not be treated as `/>`, which would + /// shift the body start and leak tag bytes into the returned text. + #[test] + fn text_unquoted_slash_in_attr_does_not_truncate_body() { + assert_eq!(text("hello", "x"), Some("hello".into())); + } + + /// A `>` inside a quoted attribute value must not be mistaken for the + /// end of the open tag when `text` computes the body start. + #[test] + fn text_quoted_gt_in_attr_does_not_truncate_body() { + assert_eq!(text(r#"hello"#, "x"), Some("hello".into())); + } + + /// A close tag truncated mid-name (``) is not a close + /// tag; matching it must stay in bounds and report no text. + #[test] + fn text_truncated_close_tag_returns_none() { + assert_eq!(text("body` must not be mistaken for ``. + #[test] + fn text_slash_in_body_is_not_a_close_tag() { + assert_eq!(text("a/x> ", "x"), Some("a/x>".into())); + } } diff --git a/src/progress.rs b/src/progress.rs index 6ca5a11..b86ec0c 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -472,6 +472,33 @@ mod pass_progress_tests { assert_eq!(p.pending_pct(), 75.0, "a sized disc must not report 0%"); } + /// The three disc-relative percentages clamp an overshoot too, not just + /// `work_pct`. A counter can transiently exceed the disc size while a pass + /// re-reads a region, and a client fed 137% renders past the end of its bar. + #[test] + fn the_disc_percentages_clamp_an_overshoot_to_a_hundred() { + let over = |f: fn(&PassProgress) -> f64, set: fn(&mut PassProgress)| { + let mut p = PassProgress { + bytes_total_disc: 1000, + ..sample() + }; + set(&mut p); + f(&p) + }; + assert_eq!( + over(PassProgress::good_pct, |p| p.bytes_good_total = 5000), + 100.0 + ); + assert_eq!( + over(PassProgress::bad_pct, |p| p.bytes_unreadable_total = 5000), + 100.0 + ); + assert_eq!( + over(PassProgress::pending_pct, |p| p.bytes_pending_total = 5000), + 100.0 + ); + } + /// The three disc-relative percentages read three DIFFERENT byte counters. /// Nothing above would catch `bad_pct` reading `bytes_pending_total`: each /// test sets one counter and leaves the others zero, so a swapped field diff --git a/src/scsi/macos.rs b/src/scsi/macos.rs index 36e4365..9c56ba1 100644 --- a/src/scsi/macos.rs +++ b/src/scsi/macos.rs @@ -306,7 +306,7 @@ pub(super) fn drive_has_disc(path: &Path) -> Result { #[cfg(test)] mod tests { - use super::{K_MAX_CDB_SIZE, OPEN, bsd_name_of, drive_has_disc}; + use super::{K_MAX_CDB_SIZE, OPEN, bsd_name_of, cstr_to_str, drive_has_disc}; use crate::error::Error; use std::path::Path; use std::sync::atomic::Ordering; @@ -318,6 +318,30 @@ mod tests { assert_eq!(bsd_name_of(Path::new("disk4")).unwrap(), "disk4"); } + /// The shim's fixed-width `[u8; N]` fields are C strings: NUL-terminated, + /// with trailing bytes undefined/garbage past the terminator. `cstr_to_str` + /// must stop at the first NUL, not read the full fixed width, and must + /// never panic on a non-UTF-8 tail the shim could hand back. + #[test] + fn cstr_to_str_stops_at_first_nul() { + let mut bytes = [0xAAu8; 8]; // 0xAA is not valid UTF-8 on its own + bytes[..5].copy_from_slice(b"BU40N"); + bytes[5] = 0; // terminator; bytes[6..8] remain 0xAA "garbage" + assert_eq!(cstr_to_str(&bytes), "BU40N"); + } + + #[test] + fn cstr_to_str_no_nul_uses_whole_buffer() { + let bytes = *b"HL-DT-ST"; + assert_eq!(cstr_to_str(&bytes), "HL-DT-ST"); + } + + #[test] + fn cstr_to_str_invalid_utf8_returns_empty_not_panic() { + let bytes = [0xFFu8, 0xFE, 0x00, 0x00]; + assert_eq!(cstr_to_str(&bytes), ""); + } + /// `drive_has_disc` is documented as a cheap, side-effect-free presence /// probe. It used to be implemented by constructing a FULL exclusive /// transport, whose first act is `diskutil unmountDisk force` on the target diff --git a/src/scsi/mod.rs b/src/scsi/mod.rs index f2d1700..1b409b8 100644 --- a/src/scsi/mod.rs +++ b/src/scsi/mod.rs @@ -1205,6 +1205,57 @@ mod scsi_sense_predicate_tests { assert_eq!(ScsiSense::NONE.sense_key, SENSE_KEY_NO_SENSE); assert!(ScsiSense::NONE.is_marginal()); } + + /// `is_css_locked` must be the exact triple `05/6F/03` (MMC "READ OF + /// SCRAMBLED SECTOR WITHOUT AUTHENTICATION"), all three fields ANDed + /// together — not any single field, and not an OR of the three. The + /// CSS crack scan keys on this to positively distinguish "encrypted but + /// locked" from "unreadable"; a false positive on a bare ILLEGAL REQUEST + /// (e.g. a malformed CDB) would make the scanner treat an unrelated + /// error as proof of CSS scrambling. + #[test] + fn is_css_locked_requires_exact_key_asc_ascq_triple() { + // The real signature: true. + assert!( + ScsiSense { + sense_key: SENSE_KEY_ILLEGAL_REQUEST, + asc: 0x6F, + ascq: 0x03, + } + .is_css_locked() + ); + // Right key, wrong ASC only -> must be false (rules out `||` + // between key and asc, and rules out the `true` constant mutant). + assert!( + !ScsiSense { + sense_key: SENSE_KEY_ILLEGAL_REQUEST, + asc: 0x00, + ascq: 0x03, + } + .is_css_locked() + ); + // Right key, right ASC, wrong ASCQ -> must be false (rules out `||` + // between asc and ascq). + assert!( + !ScsiSense { + sense_key: SENSE_KEY_ILLEGAL_REQUEST, + asc: 0x6F, + ascq: 0x00, + } + .is_css_locked() + ); + // Right ASC/ASCQ but wrong key (e.g. a bare ILLEGAL REQUEST with + // unrelated ASC/ASCQ would already fail above; here flip the key + // instead) -> must be false. + assert!( + !ScsiSense { + sense_key: SENSE_KEY_MEDIUM_ERROR, + asc: 0x6F, + ascq: 0x03, + } + .is_css_locked() + ); + } } #[cfg(test)] diff --git a/tests/disc_tests.rs b/tests/disc_tests.rs index 295dfc5..fad4c06 100644 --- a/tests/disc_tests.rs +++ b/tests/disc_tests.rs @@ -569,3 +569,76 @@ fn scan_encrypted_resolves_no_keys() { // capture, so the keyless state isn't even built) — either way, no keys. assert!(matches!(disc.decrypt_keys(), libfreemkv::DecryptKeys::None)); } + +#[test] +fn aacs_dir_alone_marks_the_disc_encrypted_and_reports_the_capture_error() { + // Encryption detection is an OR over the two on-disc AACS locations: + // `/AACS` (Blu-ray / UHD, ECMA-167 root) and `/BDMV/AACS` (the BDMV-nested + // variant). This fixture carries ONLY `/AACS`, the standard retail layout, + // so a detector that required BOTH would call a genuinely encrypted disc + // clear — the worst possible failure here, because a "clear" disc is muxed + // straight through and ships ciphertext as if it were video, at exit 0. + let mut reader = MockSectorReader::new(); + build_udf_with_aacs_dir(&mut reader); + + let disc = Disc::scan_image(&mut reader, 1000, &ScanOptions::default()).unwrap(); + + assert!( + disc.encrypted, + "a disc carrying /AACS is encrypted even though /BDMV/AACS is absent" + ); + // Encrypted => the scan attempts the (lookup-free) AACS input capture. This + // fixture's /AACS is empty, so that capture fails and the failure must be + // PRESERVED on the disc: callers render it, and its absence is what a scan + // that never attempted the capture at all would look like. + assert!( + disc.aacs_error.is_some(), + "the failed AACS capture on an encrypted disc must be surfaced, not dropped" + ); + assert!( + disc.aacs.is_none(), + "no VID was resolvable from this fixture" + ); +} + +/// The scan reports the medium's size on BOTH axes it exposes, derived from the +/// one sector count the caller hands in: +/// +/// * `capacity_bytes` is that sector count times the 2048-byte logical sector +/// (ECMA-167 / BD-ROM logical block size). It is what sizes a full-disc image +/// read and what the progress percentage divides by, so a wrong scale is a +/// wrong ISO length, not a cosmetic number. +/// * `layers` distinguishes single- from dual-layer media. The threshold sits +/// between the two real capacities: a single-layer BD-25 is 12,219,392 +/// sectors (25,025,314,816 bytes / 2048) and a dual-layer BD-50 is 24,438,784 +/// sectors, so BD-25 must report 1 layer and BD-50 must report 2. +/// +/// `scan_image` takes the sector count as a parameter, so this exercises the +/// real derivation without a 50 GB fixture. +#[test] +fn scan_image_reports_capacity_in_bytes_and_the_layer_count() { + let opts = ScanOptions::default(); + + let mut reader = MockSectorReader::new(); + build_minimal_udf(&mut reader); + let disc = Disc::scan_image(&mut reader, 1_000, &opts).unwrap(); + assert_eq!(disc.capacity_sectors, 1_000); + assert_eq!( + disc.capacity_bytes, 2_048_000, + "capacity_bytes is the sector count scaled by the 2048-byte logical sector" + ); + + // BD-25: single layer. + let mut reader = MockSectorReader::new(); + build_minimal_udf(&mut reader); + let bd25 = Disc::scan_image(&mut reader, 12_219_392, &opts).unwrap(); + assert_eq!(bd25.capacity_bytes, 25_025_314_816); + assert_eq!(bd25.layers, 1, "a BD-25 is single-layer"); + + // BD-50: dual layer. + let mut reader = MockSectorReader::new(); + build_minimal_udf(&mut reader); + let bd50 = Disc::scan_image(&mut reader, 24_438_784, &opts).unwrap(); + assert_eq!(bd50.capacity_bytes, 50_050_629_632); + assert_eq!(bd50.layers, 2, "a BD-50 is dual-layer"); +}