test: salvage the orphaned labels/disc triage, and extract build_labels
Thirteen agents triaging src/labels and src/disc died on a saturated
machine, leaving 5,836 insertions across 28 files uncommitted in a
worktree. Recovered by 3-way apply onto twelve commits of drift; zero
conflicts. The diff was archived to freemkv-private first, because a
worktree is not a backup and this one had already nearly been lost.
One production change, and it is the right one: mpls_universal::parse
read every playlist off the disc AND converted the entries to labels in
a single function, so the conversion — stream-type mapping, dedup key,
the dense global counters — could only be reached through a synthetic
UDF image. Extracted to build_labels(&[Playlist]), which unit tests can
drive from already-parsed values. Behaviour-preserving: same iteration
order, same skip-on-error.
Two collisions resolved by hand:
A second mod pass_progress_tests, written independently against the
same survivors as the one committed in c610285. Kept mine — it covers
the distinct-counters case and the Progress blanket impl, which theirs
does not — but theirs had three clamp tests mine lacked: good_pct,
bad_pct and pending_pct also clamp an overshoot, and I had only tested
that for work_pct. Merged those in as one test and proved each of the
three clamps load-bearing by removing them individually.
An unused_parens warning in a new fixture.
Method note, recorded because it cost real time: git apply --3way
STAGES its result, so `git diff` reads empty and the tree looks
untouched. I nearly concluded the patch had silently failed. Worse, the
first attempt piped through `head -20`, so `echo exit=$?` reported
head's status rather than git's — the same mistake this audit has
already documented once. Check the real exit status, and check
--cached, not just the working tree.
This commit is contained in:
@@ -441,6 +441,18 @@ mod tests {
|
|||||||
out.extend_from_slice(&attrs);
|
out.extend_from_slice(&attrs);
|
||||||
out
|
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<u8> {
|
||||||
|
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,
|
/// Build an MPLS playlist. `stn_counts` = (video, audio, pg, ig,
|
||||||
/// sec_audio, sec_video, pip_pg, dv); `stream_entries` are appended on
|
/// sec_audio, sec_video, pip_pg, dv); `stream_entries` are appended on
|
||||||
@@ -698,6 +710,77 @@ mod tests {
|
|||||||
udf::read_filesystem(disc).expect("fs")
|
udf::read_filesystem(disc).expect("fs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full BDMV with a real Blu-ray 3D layout: `.ssif` files under
|
||||||
|
/// `BDMV/STREAM/SSIF/<clip>.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
|
/// Single-clip playlist: size_bytes = source_packets * 192 and the
|
||||||
/// physical extent is pulled from the m2ts Long-AD ICB. Per bluray.rs:
|
/// physical extent is pulled from the m2ts Long-AD ICB. Per bluray.rs:
|
||||||
/// `total_size += pkt_count * 192`; extents from file_extents.
|
/// `total_size += pkt_count * 192`; extents from file_extents.
|
||||||
@@ -727,6 +810,33 @@ mod tests {
|
|||||||
assert_eq!(t.clips[0].source_packets, 4000);
|
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
|
/// AACS 2.1: the feature clip is `00001.fmts`, NOT `.m2ts`. The
|
||||||
/// [`CLIP_STREAM_EXTS`] fallback in `parse_playlist` must still resolve the
|
/// [`CLIP_STREAM_EXTS`] fallback in `parse_playlist` must still resolve the
|
||||||
/// physical extent — before the fix the hard-coded `.m2ts` path errored,
|
/// physical extent — before the fix the hard-coded `.m2ts` path errored,
|
||||||
@@ -995,6 +1105,66 @@ mod tests {
|
|||||||
assert_eq!(videos[0].codec, Codec::Hevc);
|
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
|
/// A PGS coding_type (0x90) sitting in the AUDIO STN slot is a
|
||||||
/// misaligned-stream guard case: bluray.rs routes it to Subtitle, not
|
/// misaligned-stream guard case: bluray.rs routes it to Subtitle, not
|
||||||
/// Audio (`if matches!(codec, Codec::Pgs)`). Wrong-title regression
|
/// Audio (`if matches!(codec, Codec::Pgs)`). Wrong-title regression
|
||||||
@@ -1055,6 +1225,43 @@ mod tests {
|
|||||||
assert_eq!(audios.len(), 1);
|
assert_eq!(audios.len(), 1);
|
||||||
assert_eq!(audios[0].codec, Codec::Ac3);
|
assert_eq!(audios[0].codec, Codec::Ac3);
|
||||||
assert_eq!(audios[0].language, "eng");
|
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.
|
/// stream_type 3 PG (PGS 0x90) → Stream::Subtitle with language.
|
||||||
@@ -1086,6 +1293,85 @@ mod tests {
|
|||||||
assert_eq!(subs[0].language, "fra");
|
assert_eq!(subs[0].language, "fra");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Tests: Blu-ray 3D dependent-view stream
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/// When a clip resolves via `STREAM/SSIF/<clip>.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
|
// 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
|
/// 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
|
/// negative within-offset; bluray.rs clamps the chapter to 0.0 (`if
|
||||||
/// time_secs < 0.0 { 0.0 }`). Never emits a negative chapter time.
|
/// time_secs < 0.0 { 0.0 }`). Never emits a negative chapter time.
|
||||||
@@ -1279,6 +1606,35 @@ mod tests {
|
|||||||
assert_eq!(t.playlist_id, 0);
|
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
|
// Tests: scan_bluray_titles
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
@@ -1357,6 +1713,56 @@ mod tests {
|
|||||||
assert_eq!(titles[0].playlist_id, 800);
|
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
|
/// With no PLAYLIST directory, scan_bluray_titles returns an empty
|
||||||
/// vec (the `find_dir` is None) — never panics.
|
/// vec (the `find_dir` is None) — never panics.
|
||||||
#[test]
|
#[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 `<di:name>`) -- 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"<x><di:name>Should Not Be Used</di:name></x>".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.
|
/// No META directory → None.
|
||||||
#[test]
|
#[test]
|
||||||
fn read_meta_title_no_meta_dir_is_none() {
|
fn read_meta_title_no_meta_dir_is_none() {
|
||||||
|
|||||||
@@ -1320,4 +1320,51 @@ mod tests {
|
|||||||
// Chapter 0 stays at 0.0 (no shift).
|
// Chapter 0 stays at 0.0 (no shift).
|
||||||
assert!((t.chapters[0].time_secs - 0.0).abs() < 0.01);
|
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<u16> = t
|
||||||
|
.streams
|
||||||
|
.iter()
|
||||||
|
.filter_map(|s| match s {
|
||||||
|
Stream::Audio(a) => Some(a.pid),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(audio_pids, vec![0xBD00u16, 0xBD01u16]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+169
-1
@@ -242,7 +242,11 @@ pub fn probe_and_remap<S: SectorSource + ?Sized>(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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
|
/// Build a single, correctly-SIZED AC-3 frame whose `acmod`/`lfeon` encode a
|
||||||
/// known channel count. `byte4` is `fscod=0 | frmsizecod=0`, so
|
/// 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");
|
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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SectorSource for FixedSource {
|
||||||
|
fn read_sectors(
|
||||||
|
&mut self,
|
||||||
|
_lba: u32,
|
||||||
|
_count: u16,
|
||||||
|
buf: &mut [u8],
|
||||||
|
_recovery: bool,
|
||||||
|
) -> crate::error::Result<usize> {
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -986,6 +986,87 @@ mod tests {
|
|||||||
assert_eq!(st.uk_ro, uk);
|
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<Option<bool>>);
|
||||||
|
|
||||||
|
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
|
// Tests: read_vid_oem (response parsing). The OEM path issues a
|
||||||
// READ_BUFFER CDB and parses a 36-byte response; we can't easily
|
// READ_BUFFER CDB and parses a 36-byte response; we can't easily
|
||||||
|
|||||||
@@ -1053,6 +1053,32 @@ mod tests {
|
|||||||
s
|
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
|
/// Encrypt the clear unit from `clear_aacs_unit(tag)` under `unit_key` so
|
||||||
/// `aacs::content::decrypt_unit` recovers it cleanly (zero decrypt loss).
|
/// `aacs::content::decrypt_unit` recovers it cleanly (zero decrypt loss).
|
||||||
/// `tag` distinguishes two units' payloads.
|
/// `tag` distinguishes two units' payloads.
|
||||||
@@ -1655,4 +1681,462 @@ mod tests {
|
|||||||
assert_eq!(read_out(out.path(), "tiny.inf"), Some(payload));
|
assert_eq!(read_out(out.path(), "tiny.inf"), Some(payload));
|
||||||
assert!(res.complete);
|
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<u8>, Vec<u8>) {
|
||||||
|
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<u8> = (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<u16>,
|
||||||
|
last_unit_base: Option<u32>,
|
||||||
|
}
|
||||||
|
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<usize> {
|
||||||
|
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<usize> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -963,6 +963,24 @@ mod tests {
|
|||||||
sniff_video_codec(&[0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]),
|
sniff_video_codec(&[0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]),
|
||||||
None
|
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]
|
#[test]
|
||||||
@@ -972,6 +990,13 @@ mod tests {
|
|||||||
Some(Codec::Ac3Plus)
|
Some(Codec::Ac3Plus)
|
||||||
);
|
);
|
||||||
assert_eq!(sniff_audio_codec(&[0x00, 0x01, 0x02, 0x03]), None);
|
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 ──────────────────────────────────────────
|
// ── EVO head probe → streams ──────────────────────────────────────────
|
||||||
@@ -1394,4 +1419,490 @@ mod tests {
|
|||||||
// Both feature halves are in ONE title's extents.
|
// Both feature halves are in ONE title's extents.
|
||||||
assert!(!mm.extents.is_empty());
|
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<u16> = 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<u16> = 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<u16> = 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<String, (String, u64, Vec<Extent>)> = [
|
||||||
|
(
|
||||||
|
"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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1206
File diff suppressed because it is too large
Load Diff
@@ -1292,4 +1292,240 @@ mod tests {
|
|||||||
assert!(s.forced, "inconclusive run keeps the vendor flag");
|
assert!(s.forced, "inconclusive run keeps the vendor flag");
|
||||||
assert!(cache.is_empty(), "inconclusive run is not memoised");
|
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<u8> {
|
||||||
|
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<usize> {
|
||||||
|
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<std::sync::atomic::AtomicUsize>);
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,3 +52,33 @@ pub fn resolve_device(path: &str) -> Result<(String, DeviceResolution)> {
|
|||||||
}
|
}
|
||||||
Ok((path.to_string(), DeviceResolution::Direct))
|
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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1536,6 +1536,27 @@ mod command_tests {
|
|||||||
assert!(build_error_recovery_select_payload(&bad).is_none());
|
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
|
/// Mock transport: returns a fixed data payload (copied into the
|
||||||
/// caller's buffer, truncated to fit) on every `execute()`.
|
/// caller's buffer, truncated to fit) on every `execute()`.
|
||||||
struct FixedTransport {
|
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]
|
#[test]
|
||||||
fn read_recovery_flag_selects_60s_timeout() {
|
fn read_recovery_flag_selects_60s_timeout() {
|
||||||
// recovery=true must use READ_RECOVERY_TIMEOUT_MS (60 s); false
|
// 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");
|
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<ScsiResult> {
|
||||||
|
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
|
/// 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
|
/// length check, so an undersized `buf` PANICKED ('range end index out of
|
||||||
/// range') out of the public `Drive::read` / `Drive::read_fua` — while the
|
/// 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);
|
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]
|
#[test]
|
||||||
fn drive_status_short_transfer_falls_back_to_tur() {
|
fn drive_status_short_transfer_falls_back_to_tur() {
|
||||||
// bytes_transferred < 6 means the GET EVENT reply is unusable;
|
// 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);
|
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]
|
#[test]
|
||||||
fn read_buffer_returns_prefix_and_clamps() {
|
fn read_buffer_returns_prefix_and_clamps() {
|
||||||
// read_buffer allocates `length` bytes; FixedTransport returns
|
// read_buffer allocates `length` bytes; FixedTransport returns
|
||||||
@@ -2346,6 +2494,93 @@ mod command_tests {
|
|||||||
assert!(d.probe_disc().is_ok());
|
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 ──────────────────
|
// ── decode_read_capacity additional boundaries ──────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+127
@@ -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
|
/// GET CONFIGURATION failure (transport error) must not abort the
|
||||||
/// identity probe — firmware_date is empty, raw_gc_010c is empty.
|
/// identity probe — firmware_date is empty, raw_gc_010c is empty.
|
||||||
/// Mutation: propagating the GET_CONFIGURATION error with `?` aborts from_drive.
|
/// 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<ScsiResult> {
|
||||||
|
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]
|
#[test]
|
||||||
fn from_drive_gc_failure_yields_empty_firmware_date() {
|
fn from_drive_gc_failure_yields_empty_firmware_date() {
|
||||||
struct GcFailTransport;
|
struct GcFailTransport;
|
||||||
|
|||||||
@@ -249,6 +249,27 @@ impl Drop for BytePrefetcher {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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
|
/// Endless reader: every `read` fills the whole buffer and never
|
||||||
/// hits EOF, so the producer keeps trying to push batches forward
|
/// hits EOF, so the producer keeps trying to push batches forward
|
||||||
/// until the forward channel disconnects. Exactly the shape that
|
/// until the forward channel disconnects. Exactly the shape that
|
||||||
|
|||||||
@@ -566,4 +566,33 @@ mod tests {
|
|||||||
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
|
let (title, _, _) = parse_bdmt_xml(xml).unwrap();
|
||||||
assert_eq!(title, "Real Title");
|
assert_eq!(title, "Real Title");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `is_bdmt_filename` must recognize the `bdmt_<lang>.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 `<di:numSets>1</di:numSets>`) 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#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||||
|
<di:name>Film</di:name>
|
||||||
|
<di:discNumber>1</di:discNumber>
|
||||||
|
<di:numSets>1</di:numSets>
|
||||||
|
</discInfo>"#;
|
||||||
|
let (_, _, set) = parse_bdmt_xml(xml).unwrap();
|
||||||
|
assert_eq!(set, Some((1, 1)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1388,4 +1388,162 @@ mod tests {
|
|||||||
let _ = decode_modified_utf8(&buf);
|
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<u16> = 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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -324,6 +324,68 @@ mod tests {
|
|||||||
assert_eq!(labels[0].qualifier, LabelQualifier::None);
|
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]
|
#[test]
|
||||||
fn prefix_commentary_segment_match_not_substring() {
|
fn prefix_commentary_segment_match_not_substring() {
|
||||||
// Genuine commentary group segments match.
|
// 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]
|
#[test]
|
||||||
fn merge_preserves_menu_base_only_streams() {
|
fn merge_preserves_menu_base_only_streams() {
|
||||||
// language_streams covers audio 1; menu_base has audio 1 (name)
|
// language_streams covers audio 1; menu_base has audio 1 (name)
|
||||||
@@ -506,6 +601,60 @@ mod tests {
|
|||||||
assert_eq!(labels[0].language, "eng");
|
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.
|
/// Spec: multiple valid lines produce multiple labels.
|
||||||
/// Mutation: stop after first label → only 1 label returned.
|
/// Mutation: stop after first label → only 1 label returned.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -186,6 +186,91 @@ fn make_label(num: u16, label: String, stream_type: StreamLabelType) -> StreamLa
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::super::{LabelPurpose, LabelQualifier};
|
use super::super::{LabelPurpose, LabelQualifier};
|
||||||
use super::*;
|
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<u8> {
|
||||||
|
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<u8>)]) -> 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
|
/// 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
|
/// crafted constant contributes up to 65535 bytes and the `u16` stream
|
||||||
|
|||||||
@@ -1106,6 +1106,341 @@ fn deluxe_purpose_to_label(ordinal: u16) -> (LabelPurpose, LabelQualifier) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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<u8> {
|
||||||
|
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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<u8> {
|
||||||
|
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<u8>)]) -> Vec<u8> {
|
||||||
|
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<u8>) -> jar::Jar {
|
||||||
|
jar::Jar::new(std::io::Cursor::new(bytes)).expect("valid zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `.class` fixture whose `<clinit>` 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<u8> {
|
||||||
|
// cp layout: 1 "<clinit>", 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("<clinit>".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 `<clinit>` 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<u8> {
|
||||||
|
let mut cp = vec![
|
||||||
|
CpInfo::Empty,
|
||||||
|
CpInfo::Utf8("<clinit>".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 `<clinit>` is exactly `new AudioSlot; dup;
|
||||||
|
/// getstatic LanguageEnum.English; invokespecial AudioSlot.<init>
|
||||||
|
/// (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<u8> {
|
||||||
|
let cp = vec![
|
||||||
|
CpInfo::Empty,
|
||||||
|
CpInfo::Utf8("<clinit>".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("<init>".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<u8> = vec![
|
||||||
|
NEW,
|
||||||
|
0,
|
||||||
|
11, // new AudioSlot
|
||||||
|
0x59, // dup
|
||||||
|
GETSTATIC,
|
||||||
|
0,
|
||||||
|
9, // getstatic LanguageEnum.English
|
||||||
|
INVOKESPECIAL,
|
||||||
|
0,
|
||||||
|
15, // invokespecial AudioSlot.<init>(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]
|
#[test]
|
||||||
fn ldcs_match_prefix_exact() {
|
fn ldcs_match_prefix_exact() {
|
||||||
let ldcs = vec![
|
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<String> = (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<String> = (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 ───────────────────────────────────────
|
// ── Phase D bytecode walker tests ───────────────────────────────────────
|
||||||
|
|
||||||
use super::super::class_reader::{ConstantPool, CpInfo};
|
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("<clinit> 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]
|
#[test]
|
||||||
fn clinit_ldc_strings_admits_largest_real_fingerprint() {
|
fn clinit_ldc_strings_admits_largest_real_fingerprint() {
|
||||||
// The biggest framework-stable enum is Language at 70 values; the cap
|
// 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 ────────────────────────────────────
|
// ── Construction accumulation bounds ────────────────────────────────────
|
||||||
|
|
||||||
/// One `new X / dup / ... / invokespecial X.<init>` per 11 code bytes, so
|
/// One `new X / dup / ... / invokespecial X.<init>` per 11 code bytes, so
|
||||||
@@ -1574,6 +2144,80 @@ mod tests {
|
|||||||
MasterEnumTable::from(&[("Language", m)])
|
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 "<clinit>" on the class. Exercises the method-selection
|
||||||
|
// (`member_name(m) != Some("<clinit>")`) 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 "<clinit>"/"()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 "<clinit>" 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 "<init>" 15 Utf8 "(LLanguageEnum;)V"
|
||||||
|
let pool = ConstantPool::from_entries(vec![
|
||||||
|
CpInfo::Empty,
|
||||||
|
CpInfo::Utf8("<clinit>".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("<init>".into()),
|
||||||
|
CpInfo::Utf8("(LLanguageEnum;)V".into()),
|
||||||
|
]);
|
||||||
|
let code: Vec<u8> = vec![
|
||||||
|
NEW,
|
||||||
|
0,
|
||||||
|
8, // new AudioSlot
|
||||||
|
0x59, // dup
|
||||||
|
GETSTATIC,
|
||||||
|
0,
|
||||||
|
6, // getstatic LanguageEnum.English
|
||||||
|
INVOKESPECIAL,
|
||||||
|
0,
|
||||||
|
12, // invokespecial AudioSlot.<init>(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 <clinit>, got {}",
|
||||||
|
constructions.len()
|
||||||
|
);
|
||||||
|
assert_eq!(constructions[0].binding_type, "AudioSlot");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn binding_decoder_recognizes_simple_construction() {
|
fn binding_decoder_recognizes_simple_construction() {
|
||||||
// Synthetic <clinit>:
|
// Synthetic <clinit>:
|
||||||
@@ -1655,6 +2299,217 @@ mod tests {
|
|||||||
assert_eq!(decoder.constructions.len(), 1);
|
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<u8> = 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<descriptor>`,
|
||||||
|
/// 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<u8> = 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.<init>(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_<i>`, `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("<init>".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_<i> 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; <push>; invokespecial
|
||||||
|
// AudioSlot.<init>(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<u8>, 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]
|
#[test]
|
||||||
fn binding_decoder_skips_unmatched_invokespecial() {
|
fn binding_decoder_skips_unmatched_invokespecial() {
|
||||||
// invokespecial without a preceding `new X; dup` — should
|
// invokespecial without a preceding `new X; dup` — should
|
||||||
|
|||||||
@@ -216,6 +216,27 @@ mod tests {
|
|||||||
ZipArchive::new(Cursor::new(bytes)).expect("valid zip")
|
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]
|
#[test]
|
||||||
fn try_each_class_reads_minimal_class() {
|
fn try_each_class_reads_minimal_class() {
|
||||||
let mut jar = open(build_stored_zip(
|
let mut jar = open(build_stored_zip(
|
||||||
|
|||||||
@@ -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]
|
#[test]
|
||||||
fn fill_defaults_generates_video_label_with_hdr() {
|
fn fill_defaults_generates_video_label_with_hdr() {
|
||||||
let mut titles = vec![title_with(vec![video()])];
|
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("Dolby Digital Plus 5.1"));
|
||||||
assert!(!codec_hint_adds_detail(""));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,37 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut playlists: Vec<crate::mpls::Playlist> = 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<StreamLabel> {
|
||||||
let mut labels: Vec<StreamLabel> = Vec::new();
|
let mut labels: Vec<StreamLabel> = Vec::new();
|
||||||
// (stream_type_tag, language, codec_hint, pid) — PID is the
|
// (stream_type_tag, language, codec_hint, pid) — PID is the
|
||||||
// canonical "same physical stream" key; type+lang+codec round
|
// canonical "same physical stream" key; type+lang+codec round
|
||||||
@@ -77,15 +108,7 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
|||||||
let mut audio_idx: u16 = 0;
|
let mut audio_idx: u16 = 0;
|
||||||
let mut sub_idx: u16 = 0;
|
let mut sub_idx: u16 = 0;
|
||||||
|
|
||||||
for name in &mpls_names {
|
for playlist in playlists {
|
||||||
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 entry in &playlist.streams {
|
for entry in &playlist.streams {
|
||||||
let label_type = match entry.stream_type {
|
let label_type = match entry.stream_type {
|
||||||
2 | 5 => StreamLabelType::Audio, // primary + secondary audio
|
2 | 5 => StreamLabelType::Audio, // primary + secondary audio
|
||||||
@@ -130,17 +153,7 @@ pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
labels
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_mpls_extension(name: &str) -> bool {
|
fn has_mpls_extension(name: &str) -> bool {
|
||||||
@@ -336,60 +349,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drive the same conversion logic that `parse()` runs on real
|
/// Drive the actual production conversion logic (`build_labels`, the
|
||||||
/// disc data, but starting from already-parsed Playlists so we
|
/// function `parse()` calls) starting from already-parsed Playlists,
|
||||||
/// don't have to synthesize valid MPLS bytes.
|
/// 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<StreamLabel> {
|
fn labels_from_playlists(playlists: &[Playlist]) -> Vec<StreamLabel> {
|
||||||
let mut labels: Vec<StreamLabel> = Vec::new();
|
build_labels(playlists)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -470,4 +470,23 @@ mod tests {
|
|||||||
assert!(find_feature_playlist("").is_none());
|
assert!(find_feature_playlist("").is_none());
|
||||||
assert!(find_feature_playlist("<root />").is_none());
|
assert!(find_feature_playlist("<root />").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#"
|
||||||
|
<playlist name="A" aud="eng,fra" />
|
||||||
|
<playlist name="B" aud="deu,spa" />
|
||||||
|
"#;
|
||||||
|
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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -638,6 +638,87 @@ mod tests {
|
|||||||
assert!(audio.is_empty() || audio.iter().all(|l| l.stream_number <= 512));
|
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.
|
/// Spec: subtitle placeholders (PG Stream N) do NOT advance the subtitle counter.
|
||||||
/// Only audio placeholders (`Audio Stream N`) do.
|
/// Only audio placeholders (`Audio Stream N`) do.
|
||||||
/// Mutation: also advance sub counter on PG placeholder → subtitle labels misnumbered.
|
/// Mutation: also advance sub counter on PG placeholder → subtitle labels misnumbered.
|
||||||
|
|||||||
@@ -685,4 +685,108 @@ mod tests {
|
|||||||
fn codec_empty_passes_through() {
|
fn codec_empty_passes_through() {
|
||||||
assert_eq!(codec(""), "");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -634,4 +634,136 @@ mod tests {
|
|||||||
let (s, e) = find_element(xml, "name", 0).unwrap();
|
let (s, e) = find_element(xml, "name", 0).unwrap();
|
||||||
assert_eq!(&xml[s..e], "<di:name>Title</di:name>");
|
assert_eq!(&xml[s..e], "<di:name>Title</di:name>");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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#"<x y="oops"#, "name"), None);
|
||||||
|
assert_eq!(attr("<x y='oops", "name"), None);
|
||||||
|
// The truncated attribute itself has no terminated value either.
|
||||||
|
assert_eq!(attr(r#"<x y="oops"#, "y"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An attribute name at EOF followed only by whitespace (no `=`) must
|
||||||
|
/// return None, not read past the buffer while skipping that whitespace.
|
||||||
|
#[test]
|
||||||
|
fn attr_name_with_trailing_whitespace_and_no_equals_returns_none() {
|
||||||
|
assert_eq!(attr("<x name ", "name"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `name=` followed only by whitespace to EOF has no value to return.
|
||||||
|
#[test]
|
||||||
|
fn attr_equals_with_trailing_whitespace_and_no_value_returns_none() {
|
||||||
|
assert_eq!(attr("<x name= ", "name"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A quoted attribute value is opaque: a `name="..."` pair that appears
|
||||||
|
/// *inside* another attribute's value must never be reported, even when
|
||||||
|
/// it is preceded by whitespace so it would otherwise clear the
|
||||||
|
/// word-boundary check.
|
||||||
|
#[test]
|
||||||
|
fn attr_decoy_name_after_space_inside_quoted_value_is_skipped() {
|
||||||
|
assert_eq!(attr(r#"<x y=" name='decoy'" />"#, "name"), None);
|
||||||
|
// The real attribute after the decoy still resolves.
|
||||||
|
assert_eq!(
|
||||||
|
attr(r#"<x y=" name='decoy'" name="real" />"#, "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#"<x q-a="decoy" a="real" />"#, "a"),
|
||||||
|
Some("real".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
attr(r#"<x q_a="decoy" a="real" />"#, "a"),
|
||||||
|
Some("real".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
attr(r#"<x q.a="decoy" a="real" />"#, "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("<x attr=", "x", 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `/` as the final byte of the buffer is not a self-closing marker;
|
||||||
|
/// probing for the `>` that would follow it must stay in bounds.
|
||||||
|
#[test]
|
||||||
|
fn find_element_trailing_slash_at_eof_returns_none() {
|
||||||
|
assert_eq!(find_element("<a /", "a", 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/>` 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#"<x a="/>"/>"#;
|
||||||
|
let (s, e) = find_element(xml, "x", 0).unwrap();
|
||||||
|
assert_eq!(&xml[s..e], r#"<x a="/>"/>"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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#"<x a="oops"#, "x", 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `/` in the middle of an unquoted attribute value is not a
|
||||||
|
/// self-closing marker — only `/>` is.
|
||||||
|
#[test]
|
||||||
|
fn find_element_unquoted_slash_is_not_self_closing() {
|
||||||
|
let xml = "<a href=x/y>body</a>";
|
||||||
|
let (s, e) = find_element(xml, "a", 0).unwrap();
|
||||||
|
assert_eq!(&xml[s..e], "<a href=x/y>body</a>");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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("<x a=b/c>hello</x>", "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#"<x a="b>c">hello</x>"#, "x"), Some("hello".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A close tag truncated mid-name (`</x` with no `>`) 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("<x>body</x", "x"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `/` in element content is only a close tag when preceded by `<`.
|
||||||
|
/// Body text containing `a/x>` must not be mistaken for `</x>`.
|
||||||
|
#[test]
|
||||||
|
fn text_slash_in_body_is_not_a_close_tag() {
|
||||||
|
assert_eq!(text("<x>a/x> </x>", "x"), Some("a/x>".into()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -472,6 +472,33 @@ mod pass_progress_tests {
|
|||||||
assert_eq!(p.pending_pct(), 75.0, "a sized disc must not report 0%");
|
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.
|
/// The three disc-relative percentages read three DIFFERENT byte counters.
|
||||||
/// Nothing above would catch `bad_pct` reading `bytes_pending_total`: each
|
/// Nothing above would catch `bad_pct` reading `bytes_pending_total`: each
|
||||||
/// test sets one counter and leaves the others zero, so a swapped field
|
/// test sets one counter and leaves the others zero, so a swapped field
|
||||||
|
|||||||
+25
-1
@@ -306,7 +306,7 @@ pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 crate::error::Error;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
@@ -318,6 +318,30 @@ mod tests {
|
|||||||
assert_eq!(bsd_name_of(Path::new("disk4")).unwrap(), "disk4");
|
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
|
/// `drive_has_disc` is documented as a cheap, side-effect-free presence
|
||||||
/// probe. It used to be implemented by constructing a FULL exclusive
|
/// probe. It used to be implemented by constructing a FULL exclusive
|
||||||
/// transport, whose first act is `diskutil unmountDisk force` on the target
|
/// transport, whose first act is `diskutil unmountDisk force` on the target
|
||||||
|
|||||||
@@ -1205,6 +1205,57 @@ mod scsi_sense_predicate_tests {
|
|||||||
assert_eq!(ScsiSense::NONE.sense_key, SENSE_KEY_NO_SENSE);
|
assert_eq!(ScsiSense::NONE.sense_key, SENSE_KEY_NO_SENSE);
|
||||||
assert!(ScsiSense::NONE.is_marginal());
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -569,3 +569,76 @@ fn scan_encrypted_resolves_no_keys() {
|
|||||||
// capture, so the keyless state isn't even built) — either way, no keys.
|
// capture, so the keyless state isn't even built) — either way, no keys.
|
||||||
assert!(matches!(disc.decrypt_keys(), libfreemkv::DecryptKeys::None));
|
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");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user