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
|
||||
}
|
||||
/// 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,
|
||||
/// sec_audio, sec_video, pip_pg, dv); `stream_entries` are appended on
|
||||
@@ -698,6 +710,77 @@ mod tests {
|
||||
udf::read_filesystem(disc).expect("fs")
|
||||
}
|
||||
|
||||
/// Full BDMV with a real Blu-ray 3D layout: `.ssif` files under
|
||||
/// `BDMV/STREAM/SSIF/<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
|
||||
/// physical extent is pulled from the m2ts Long-AD ICB. Per bluray.rs:
|
||||
/// `total_size += pkt_count * 192`; extents from file_extents.
|
||||
@@ -727,6 +810,33 @@ mod tests {
|
||||
assert_eq!(t.clips[0].source_packets, 4000);
|
||||
}
|
||||
|
||||
/// Each Clip's `duration_secs` is `(out_time - in_time) / 45000` (the BD
|
||||
/// 45kHz playback clock). Uses a duration (75s) whose ticks are not a
|
||||
/// multiple of any small constant, so a `*` or `%` in place of `/` would
|
||||
/// produce a wildly different (or non-matching) value instead of 75.0.
|
||||
#[test]
|
||||
fn parse_playlist_clip_duration_secs_computed_from_ticks() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]);
|
||||
let mpls = build_mpls(
|
||||
&[PiSpec {
|
||||
clip_id: *b"00001",
|
||||
in_time: 45000,
|
||||
out_time: 45000 + 75 * 45000, // 75s clip
|
||||
}],
|
||||
(0, 0, 0, 0, 0, 0, 0, 0),
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
assert_eq!(t.clips.len(), 1);
|
||||
assert!(
|
||||
(t.clips[0].duration_secs - 75.0).abs() < 1e-6,
|
||||
"clip duration_secs must be ticks/45000 seconds, got {}",
|
||||
t.clips[0].duration_secs
|
||||
);
|
||||
}
|
||||
|
||||
/// AACS 2.1: the feature clip is `00001.fmts`, NOT `.m2ts`. The
|
||||
/// [`CLIP_STREAM_EXTS`] fallback in `parse_playlist` must still resolve the
|
||||
/// physical extent — before the fix the hard-coded `.m2ts` path errored,
|
||||
@@ -995,6 +1105,66 @@ mod tests {
|
||||
assert_eq!(videos[0].codec, Codec::Hevc);
|
||||
}
|
||||
|
||||
/// HEVC HDR byte (sa[2]): high nibble = dynamic_range, low nibble =
|
||||
/// color_space. dynamic_range 1 -> HDR10, color_space 2 -> BT.2020
|
||||
/// (bluray.rs `match s.dynamic_range { 1 => Hdr10, ... }` /
|
||||
/// `match s.color_space { 2 => Bt2020, ... }`).
|
||||
#[test]
|
||||
fn parse_playlist_maps_hdr10_bt2020_from_hevc_nibbles() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]);
|
||||
let mpls = build_mpls(
|
||||
&[PiSpec {
|
||||
clip_id: *b"00001",
|
||||
in_time: 0,
|
||||
out_time: 60 * 45000,
|
||||
}],
|
||||
(1, 0, 0, 0, 0, 0, 0, 0),
|
||||
&[se_video_hevc(0x1011, 1, 2)],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let v = t
|
||||
.streams
|
||||
.iter()
|
||||
.find_map(|s| match s {
|
||||
Stream::Video(v) => Some(v),
|
||||
_ => None,
|
||||
})
|
||||
.expect("video stream");
|
||||
assert_eq!(v.hdr, HdrFormat::Hdr10);
|
||||
assert_eq!(v.color_space, ColorSpace::Bt2020);
|
||||
}
|
||||
|
||||
/// dynamic_range 2 -> DolbyVision, color_space 1 -> BT.709: the other
|
||||
/// pair of named arms in the same two match expressions.
|
||||
#[test]
|
||||
fn parse_playlist_maps_dolby_vision_bt709_from_hevc_nibbles() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]);
|
||||
let mpls = build_mpls(
|
||||
&[PiSpec {
|
||||
clip_id: *b"00001",
|
||||
in_time: 0,
|
||||
out_time: 60 * 45000,
|
||||
}],
|
||||
(1, 0, 0, 0, 0, 0, 0, 0),
|
||||
&[se_video_hevc(0x1011, 2, 1)],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let v = t
|
||||
.streams
|
||||
.iter()
|
||||
.find_map(|s| match s {
|
||||
Stream::Video(v) => Some(v),
|
||||
_ => None,
|
||||
})
|
||||
.expect("video stream");
|
||||
assert_eq!(v.hdr, HdrFormat::DolbyVision);
|
||||
assert_eq!(v.color_space, ColorSpace::Bt709);
|
||||
}
|
||||
|
||||
/// A PGS coding_type (0x90) sitting in the AUDIO STN slot is a
|
||||
/// misaligned-stream guard case: bluray.rs routes it to Subtitle, not
|
||||
/// Audio (`if matches!(codec, Codec::Pgs)`). Wrong-title regression
|
||||
@@ -1055,6 +1225,43 @@ mod tests {
|
||||
assert_eq!(audios.len(), 1);
|
||||
assert_eq!(audios[0].codec, Codec::Ac3);
|
||||
assert_eq!(audios[0].language, "eng");
|
||||
assert!(
|
||||
!audios[0].secondary,
|
||||
"a primary (stream_type 2) audio entry must not be marked secondary"
|
||||
);
|
||||
}
|
||||
|
||||
/// A secondary-audio STN entry (stream_type 5, e.g. a director's
|
||||
/// commentary track) must set `AudioStream::secondary` (bluray.rs
|
||||
/// `secondary: s.stream_type == 5`).
|
||||
#[test]
|
||||
fn parse_playlist_secondary_audio_flag_set_for_stream_type_5() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]);
|
||||
let mpls = build_mpls(
|
||||
&[PiSpec {
|
||||
clip_id: *b"00001",
|
||||
in_time: 0,
|
||||
out_time: 60 * 45000,
|
||||
}],
|
||||
(0, 0, 0, 0, 1, 0, 0, 0), // one secondary-audio (stream_type 5) entry
|
||||
&[se_audio(0x1a00, 0x83, b"eng")],
|
||||
&[],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
let audios: Vec<_> = t
|
||||
.streams
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Stream::Audio(a) => Some(a),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(audios.len(), 1);
|
||||
assert!(
|
||||
audios[0].secondary,
|
||||
"stream_type 5 (secondary audio) must set AudioStream::secondary"
|
||||
);
|
||||
}
|
||||
|
||||
/// stream_type 3 PG (PGS 0x90) → Stream::Subtitle with language.
|
||||
@@ -1086,6 +1293,85 @@ mod tests {
|
||||
assert_eq!(subs[0].language, "fra");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Tests: Blu-ray 3D dependent-view stream
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
/// When a clip resolves via `STREAM/SSIF/<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
|
||||
// ---------------------------------------------------------------
|
||||
@@ -1177,6 +1463,47 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The within-PlayItem offset is `(timestamp - pi.in_time) / 45000`
|
||||
/// ticks-to-seconds. Uses a non-zero, non-round offset (5s) added to a
|
||||
/// non-zero `preceding` (60s) so a `*` or `%` in place of `/` would not
|
||||
/// coincidentally produce the same total (bluray.rs `within = ... /
|
||||
/// 45000.0`).
|
||||
#[test]
|
||||
fn parse_playlist_chapter_within_offset_divides_ticks_to_seconds() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]);
|
||||
let pi1_in = 10 * 45000u32;
|
||||
let within_ticks = 5 * 45000u32; // 5s into PI1
|
||||
let mpls = build_mpls(
|
||||
&[
|
||||
PiSpec {
|
||||
clip_id: *b"00001",
|
||||
in_time: 0,
|
||||
out_time: 60 * 45000, // PI0 lasts 60s
|
||||
},
|
||||
PiSpec {
|
||||
clip_id: *b"00001",
|
||||
in_time: pi1_in,
|
||||
out_time: pi1_in + 60 * 45000,
|
||||
},
|
||||
],
|
||||
(0, 0, 0, 0, 0, 0, 0, 0),
|
||||
&[],
|
||||
&[MarkSpec {
|
||||
mark_type: 1,
|
||||
play_item_ref: 1,
|
||||
timestamp: pi1_in + within_ticks,
|
||||
}],
|
||||
);
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title");
|
||||
assert_eq!(t.chapters.len(), 1);
|
||||
assert!(
|
||||
(t.chapters[0].time_secs - 65.0).abs() < 1e-6,
|
||||
"chapter time must be preceding(60s) + within(5s) = 65s, got {}",
|
||||
t.chapters[0].time_secs
|
||||
);
|
||||
}
|
||||
|
||||
/// A mark whose timestamp precedes its PlayItem's in_time would yield a
|
||||
/// negative within-offset; bluray.rs clamps the chapter to 0.0 (`if
|
||||
/// time_secs < 0.0 { 0.0 }`). Never emits a negative chapter time.
|
||||
@@ -1279,6 +1606,35 @@ mod tests {
|
||||
assert_eq!(t.playlist_id, 0);
|
||||
}
|
||||
|
||||
/// A filename that is long enough (>= 5 bytes) but does NOT end in
|
||||
/// ".mpls" must NOT have its last 5 bytes stripped -- the whole string
|
||||
/// is handed to the numeric parse instead, which fails and falls back
|
||||
/// to playlist_id 0 (bluray.rs `filename.len() >= 5 &&
|
||||
/// filename[len-5..].eq_ignore_ascii_case(".mpls")`).
|
||||
#[test]
|
||||
fn parse_playlist_id_falls_back_to_zero_when_suffix_is_not_mpls() {
|
||||
let mut disc = MemDisc::new();
|
||||
let udf = make_bdmv_fs(&mut disc, &[("00001", 100, 400, 5000)]);
|
||||
let mpls = build_mpls(
|
||||
&[PiSpec {
|
||||
clip_id: *b"00001",
|
||||
in_time: 0,
|
||||
out_time: 60 * 45000,
|
||||
}],
|
||||
(0, 0, 0, 0, 0, 0, 0, 0),
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
// "00800zzzzz": stripping the last 5 bytes would leave "00800" (a
|
||||
// valid u16), but the suffix isn't ".mpls" so nothing may be
|
||||
// stripped -- the whole (non-numeric) string must fail to parse.
|
||||
let t = Disc::parse_playlist(&mut disc, &udf, "00800zzzzz", &mpls).expect("title");
|
||||
assert_eq!(
|
||||
t.playlist_id, 0,
|
||||
"a filename not ending in .mpls must not have its last 5 bytes stripped"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Tests: scan_bluray_titles
|
||||
// ---------------------------------------------------------------
|
||||
@@ -1357,6 +1713,56 @@ mod tests {
|
||||
assert_eq!(titles[0].playlist_id, 800);
|
||||
}
|
||||
|
||||
/// A non-directory PLAYLIST entry whose name does NOT end in ".mpls"
|
||||
/// must be skipped even though its content parses as a perfectly good
|
||||
/// (long) MPLS playlist -- extension gating, not content sniffing,
|
||||
/// decides eligibility (bluray.rs `!entry.is_dir &&
|
||||
/// entry.name...ends_with(".mpls")`).
|
||||
#[test]
|
||||
fn scan_bluray_titles_skips_non_mpls_extension_file() {
|
||||
let mut disc = MemDisc::new();
|
||||
let mpls = build_mpls(
|
||||
&[PiSpec {
|
||||
clip_id: *b"00001",
|
||||
in_time: 0,
|
||||
out_time: 7200 * 45000, // 2h -- easily long enough to be kept
|
||||
}],
|
||||
(0, 0, 0, 0, 0, 0, 0, 0),
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let playlist = DirSpec {
|
||||
name: "PLAYLIST".to_string(),
|
||||
icb_lba: 26,
|
||||
dir_data_lba: 27,
|
||||
files: vec![file_with("00800.dat", 104, 30000, mpls, false)],
|
||||
subdirs: vec![],
|
||||
};
|
||||
let bdmv = DirSpec {
|
||||
name: "BDMV".to_string(),
|
||||
icb_lba: 20,
|
||||
dir_data_lba: 21,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![playlist],
|
||||
};
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: Vec::new(),
|
||||
subdirs: vec![bdmv],
|
||||
};
|
||||
build_udf_skeleton(&mut disc, 10);
|
||||
lay_dir(&mut disc, &root);
|
||||
let udf = udf::read_filesystem(&mut disc).expect("fs");
|
||||
|
||||
let titles = Disc::scan_bluray_titles(&mut disc, &udf);
|
||||
assert!(
|
||||
titles.is_empty(),
|
||||
"a PLAYLIST entry not ending in .mpls must be skipped regardless of content"
|
||||
);
|
||||
}
|
||||
|
||||
/// With no PLAYLIST directory, scan_bluray_titles returns an empty
|
||||
/// vec (the `find_dir` is None) — never panics.
|
||||
#[test]
|
||||
@@ -1464,6 +1870,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-.xml file must be ignored even if its content looks like a
|
||||
/// valid meta XML (contains a `<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.
|
||||
#[test]
|
||||
fn read_meta_title_no_meta_dir_is_none() {
|
||||
|
||||
@@ -1320,4 +1320,51 @@ mod tests {
|
||||
// Chapter 0 stays at 0.0 (no shift).
|
||||
assert!((t.chapters[0].time_secs - 0.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
/// Audio PID fallback (dvd.rs `Disc::scan_dvd_titles`): when an audio
|
||||
/// stream has no on-wire private_stream_1 sub-stream id — MP1/MP2 audio,
|
||||
/// per `ifo::assign_audio_sub_stream_ids` — the PID falls back to
|
||||
/// `0xBD00 + i` where `i` is the stream's positional index in the IFO
|
||||
/// audio-attribute table. Two MPEG-audio (coding_mode 2) streams must
|
||||
/// land on two DISTINCT, correctly-offset PIDs: 0xBD00 and 0xBD01. This
|
||||
/// pins the `+` (not `-`/`*`) so the second stream doesn't collide with,
|
||||
/// or wrap under, the first.
|
||||
#[test]
|
||||
fn scan_dvd_titles_mp2_audio_pid_fallback_is_additive() {
|
||||
let mut disc = MemDisc::new();
|
||||
let vmg = build_vmg(&[(1, 1, 1)]);
|
||||
// coding_mode bits are b0>>5 & 0x7; mode 2 = MPEG-1 Layer II (Mp2),
|
||||
// which `assign_audio_sub_stream_ids` leaves at `sub_stream_id: None`.
|
||||
// b0 = 0b010_00000 = 0x40. b1 = 0 (mono, sample rate 48k).
|
||||
let audio = [(0x40u8, 0x00u8, [0u8, 0u8]), (0x40u8, 0x00u8, [0u8, 0u8])];
|
||||
let vts = build_vts(1000, 0x00, &audio, &[], &[(10, 109)], false);
|
||||
let udf = build_video_ts_fs(
|
||||
&mut disc,
|
||||
&[
|
||||
FileSpec {
|
||||
name: "VIDEO_TS.IFO".into(),
|
||||
icb_lba: 60,
|
||||
data_lba: 5000,
|
||||
contents: vmg,
|
||||
},
|
||||
FileSpec {
|
||||
name: "VTS_01_0.IFO".into(),
|
||||
icb_lba: 62,
|
||||
data_lba: 6000,
|
||||
contents: vts,
|
||||
},
|
||||
],
|
||||
);
|
||||
let titles = Disc::scan_dvd_titles(&mut disc, &udf);
|
||||
let t = &titles[0];
|
||||
let audio_pids: Vec<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::disc::{AudioChannels, AudioStream, Codec, LabelPurpose, SampleRate};
|
||||
use crate::disc::{
|
||||
AudioChannels, AudioStream, Codec, ContentFormat, DiscTitle, Extent, LabelPurpose,
|
||||
SampleRate,
|
||||
};
|
||||
use crate::sector::SectorSource;
|
||||
|
||||
/// Build a single, correctly-SIZED AC-3 frame whose `acmod`/`lfeon` encode a
|
||||
/// known channel count. `byte4` is `fscod=0 | frmsizecod=0`, so
|
||||
@@ -463,4 +467,168 @@ mod tests {
|
||||
};
|
||||
assert_eq!(a.pid, 0xBD80, "no probe data → keep ordinal");
|
||||
}
|
||||
|
||||
/// `max_substream_channels` must locate the sync at its true ABSOLUTE
|
||||
/// position (`pos + rel`) when it is preceded by non-sync bytes, not just
|
||||
/// when the sync sits at offset 0. Regression guard for a hand-checked
|
||||
/// mutation (`+` → `-` at the `pos + rel` offset computation): with `pos`
|
||||
/// starting at 0 and the first sync found 3 bytes in, `pos - rel` would
|
||||
/// underflow a `usize` and panic, or (if it somehow didn't) index the
|
||||
/// wrong start entirely. `pos + rel` is the only computation that is
|
||||
/// always in-bounds, since `rel` is itself bounded by the length of the
|
||||
/// slice searched from `pos`.
|
||||
#[test]
|
||||
fn max_substream_channels_locates_sync_after_leading_non_sync_bytes() {
|
||||
let mut data = vec![0xAA, 0xAA, 0xAA]; // no 0x0B77 pattern in here
|
||||
data.extend(ac3_frame(2, false)); // real 2.0 frame, sync at absolute offset 3
|
||||
assert_eq!(
|
||||
max_substream_channels(&data),
|
||||
Some(2),
|
||||
"must find and decode the frame whose sync is NOT at offset 0"
|
||||
);
|
||||
}
|
||||
|
||||
/// When an AC-3 header's `fscod`/`frmsizecod` is unmappable (reserved
|
||||
/// `fscod == 3`), `max_substream_channels` must fall back to stepping
|
||||
/// `start + 2` bytes past the sync to re-lock onto the next genuine sync,
|
||||
/// and must keep making forward progress doing so (never revisit the same
|
||||
/// sync, which would loop forever, and never jump so far that it skips
|
||||
/// the very next real frame). This lays a bogus-sized header at absolute
|
||||
/// offset 4 (so `start == 4`, `start + 2 == 6`) immediately followed, at
|
||||
/// offset 6, by a real, fully decodable 2.0 frame — the position the
|
||||
/// `+ 2` fallback must land on exactly.
|
||||
#[test]
|
||||
fn max_substream_channels_unmappable_size_steps_forward_by_two() {
|
||||
let mut real = ac3_frame(2, false);
|
||||
// Overwrite the (unchecked) CRC bytes of the real frame — these double
|
||||
// as byte4/byte5 of the bogus header 2 bytes earlier, at absolute
|
||||
// offset 4: byte4 = 0xC0 (fscod=3 reserved -> ac3_frame_size == 0,
|
||||
// unmappable), byte5 = 0xF8 (bsid=31 >= 11 -> acmod_channels == None,
|
||||
// so the bogus header itself never contributes a spurious channel
|
||||
// count).
|
||||
real[2] = 0xC0;
|
||||
real[3] = 0xF8;
|
||||
let mut data = vec![0xAA, 0xAA, 0xAA, 0xAA]; // offsets 0..4, no sync
|
||||
data.push(0x0B); // offset 4: bogus header sync byte 0
|
||||
data.push(0x77); // offset 5: bogus header sync byte 1
|
||||
data.extend(real); // offset 6..: the real frame (also serves as the
|
||||
// bogus header's byte4/byte5 at offsets 8/9)
|
||||
assert_eq!(
|
||||
max_substream_channels(&data),
|
||||
Some(2),
|
||||
"must recover the real frame 2 bytes after the unmappable-size sync, not lose it"
|
||||
);
|
||||
}
|
||||
|
||||
/// Same fallback as above, but with the unmappable-size sync at absolute
|
||||
/// offset 0 (`start == 0`) so that stepping backward instead of forward
|
||||
/// (`start - 2`) would underflow rather than merely land on the wrong
|
||||
/// byte. Also proves the real frame is still found 6 bytes further in,
|
||||
/// confirming forward progress past the bogus header.
|
||||
#[test]
|
||||
fn max_substream_channels_unmappable_size_at_start_steps_forward_not_back() {
|
||||
let mut data = vec![0x0B, 0x77, 0x00, 0x00, 0xC0, 0xF8]; // bogus header, offsets 0..6
|
||||
data.extend(ac3_frame(2, false)); // real 2.0 frame at offset 6
|
||||
assert_eq!(
|
||||
max_substream_channels(&data),
|
||||
Some(2),
|
||||
"must step forward past the bogus header at offset 0 and find the real frame at offset 6"
|
||||
);
|
||||
}
|
||||
|
||||
/// `remap_audio_pids` must read a stream's CURRENT physical sub-stream id
|
||||
/// from the low byte of its PID via `pid & 0x00FF` — not `|` or `^` with
|
||||
/// `0x00FF`, both of which force the low byte to `0xFF` regardless of the
|
||||
/// real PID and so always miss the "already matches" shortcut. That
|
||||
/// matters observably when TWO physical sub-streams share the same probed
|
||||
/// channel count: with a correct read, a stream already sitting on a
|
||||
/// matching sub-stream is left alone (conservative, per the module's
|
||||
/// documented behaviour); with the low byte forced to `0xFF`,
|
||||
/// `probed.get(&0xFF)` is always `None`, so the code falls through to the
|
||||
/// "find any unclaimed match" path and picks the FIRST (lowest-keyed,
|
||||
/// BTreeMap-ordered) matching physical sub-stream instead — which here is
|
||||
/// a *different* sub-stream (0x80) than the one the PID already correctly
|
||||
/// names (0x81), producing a spurious PID change.
|
||||
#[test]
|
||||
fn remap_reads_current_substream_via_and_not_or_or_xor() {
|
||||
let mut probed = BTreeMap::new();
|
||||
probed.insert(0x80u8, 6u8);
|
||||
probed.insert(0x81u8, 6u8); // ambiguous: two physical 6ch sub-streams
|
||||
let mut streams = vec![ac3_stream(0xBD81, AudioChannels::Surround51)];
|
||||
let changed = remap_audio_pids(&mut streams, &probed);
|
||||
assert_eq!(
|
||||
changed, 0,
|
||||
"already sitting on a matching physical sub-stream (0x81) must be left alone"
|
||||
);
|
||||
let Stream::Audio(a) = &streams[0] else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
a.pid, 0xBD81,
|
||||
"must not be bumped to the other matching sub-stream (0x80)"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `SectorSource` stub that hands back fixed bytes regardless of the
|
||||
/// requested LBA/count, for exercising `probe_and_remap`'s end-to-end
|
||||
/// wiring (format/AC-3/extent/count guards -> read -> probe -> remap).
|
||||
struct FixedSource {
|
||||
data: Vec<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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// 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
|
||||
// READ_BUFFER CDB and parses a 36-byte response; we can't easily
|
||||
|
||||
@@ -1053,6 +1053,32 @@ mod tests {
|
||||
s
|
||||
}
|
||||
|
||||
/// Like [`build_two_extent_icb`] but the two extents may have DIFFERENT
|
||||
/// sector counts — used to exercise the within-extent batch-size
|
||||
/// arithmetic (`sectors - sector_off`) with a first extent long enough to
|
||||
/// force a second while-loop iteration.
|
||||
fn build_two_extent_icb_sized(
|
||||
sectors_a: u32,
|
||||
data_lba_a: u32,
|
||||
sectors_b: u32,
|
||||
data_lba_b: u32,
|
||||
) -> [u8; 2048] {
|
||||
let mut s = [0u8; 2048];
|
||||
s[0..2].copy_from_slice(&266u16.to_le_bytes()); // Extended File Entry
|
||||
s[34..36].copy_from_slice(&0u16.to_le_bytes()); // Short AD
|
||||
let size = (sectors_a as u64 + sectors_b as u64) * SECTOR_BYTES as u64;
|
||||
s[56..64].copy_from_slice(&size.to_le_bytes()); // info_length
|
||||
s[208..212].copy_from_slice(&0u32.to_le_bytes()); // l_ea
|
||||
s[212..216].copy_from_slice(&16u32.to_le_bytes()); // l_ad = 2 Short ADs
|
||||
let len_a = sectors_a * SECTOR_BYTES as u32;
|
||||
let len_b = sectors_b * SECTOR_BYTES as u32;
|
||||
s[216..220].copy_from_slice(&(len_a & 0x3FFF_FFFF).to_le_bytes());
|
||||
s[220..224].copy_from_slice(&data_lba_a.to_le_bytes());
|
||||
s[224..228].copy_from_slice(&(len_b & 0x3FFF_FFFF).to_le_bytes());
|
||||
s[228..232].copy_from_slice(&data_lba_b.to_le_bytes());
|
||||
s
|
||||
}
|
||||
|
||||
/// Encrypt the clear unit from `clear_aacs_unit(tag)` under `unit_key` so
|
||||
/// `aacs::content::decrypt_unit` recovers it cleanly (zero decrypt loss).
|
||||
/// `tag` distinguishes two units' payloads.
|
||||
@@ -1655,4 +1681,462 @@ mod tests {
|
||||
assert_eq!(read_out(out.path(), "tiny.inf"), Some(payload));
|
||||
assert!(res.complete);
|
||||
}
|
||||
|
||||
// ── Mutation-triage additions ───────────────────────────────────────────
|
||||
|
||||
/// `cancelled` must stop on EITHER signal alone (a disjunction) — a
|
||||
/// progress sink asking to stop must cancel even with no halt token, and a
|
||||
/// cancelled halt token must cancel even when progress says continue.
|
||||
#[test]
|
||||
fn cancelled_stops_on_either_signal_alone() {
|
||||
let opts_no_halt = ExtractOptions::default();
|
||||
assert!(
|
||||
opts_no_halt.cancelled(false),
|
||||
"a progress sink asking to stop must cancel even with no halt token"
|
||||
);
|
||||
assert!(
|
||||
!opts_no_halt.cancelled(true),
|
||||
"neither signal firing must not cancel"
|
||||
);
|
||||
|
||||
let halt = crate::halt::Halt::new();
|
||||
halt.cancel();
|
||||
let opts_halted = ExtractOptions {
|
||||
halt: Some(halt),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
opts_halted.cancelled(true),
|
||||
"a cancelled halt token must cancel even when progress says continue"
|
||||
);
|
||||
}
|
||||
|
||||
/// The free-space pre-check must fire whenever the disc's declared total
|
||||
/// exceeds real available space. An absurdly large declared size (an
|
||||
/// exabyte) trips it regardless of the actual free space on whatever
|
||||
/// machine runs the test.
|
||||
#[test]
|
||||
fn insufficient_space_errors_on_absurdly_large_required() {
|
||||
let root = DirSpec {
|
||||
name: String::new(),
|
||||
icb_lba: 10,
|
||||
dir_data_lba: 11,
|
||||
files: vec![file("huge.bin", 30, 31, Vec::new(), false)],
|
||||
subdirs: vec![],
|
||||
};
|
||||
let mut disc = build_disc(root);
|
||||
// Overwrite the declared size (info_length) with an absurd value; the
|
||||
// extent length stays 0 so no real content is ever read (the space
|
||||
// gate runs before Phase 2 touches content).
|
||||
let mut icb = build_file_icb(0, 31, false);
|
||||
let huge: u64 = 1u64 << 60; // ~1 exabyte -- no real disk has this free
|
||||
icb[56..64].copy_from_slice(&huge.to_le_bytes());
|
||||
disc.put(PART_START + 30, icb);
|
||||
|
||||
let out = TmpDir::new("insufficient_space");
|
||||
let err = clear_disc()
|
||||
.extract_tree(&mut disc, out.path(), &ExtractOptions::default())
|
||||
.expect_err("an absurdly large declared size must trip the space gate");
|
||||
assert!(matches!(err, Error::DirInsufficientSpace { .. }));
|
||||
}
|
||||
|
||||
/// Regression: the per-VTS key crack in `resolve_vts_key` must gather ONLY
|
||||
/// this VTS's own title-VOB extents. Two VTS groups here carry DISTINCT
|
||||
/// scrambling keys; if the group filter (`vts_group_of(..) == Some(vts) &&
|
||||
/// is_title_vob(..)`) is loosened (`!=`, or `&&` -> `||`), one VTS's
|
||||
/// resolve gathers the OTHER VTS's extents, cracks the wrong key, and that
|
||||
/// VTS's own content silently fails to descramble to the expected
|
||||
/// plaintext.
|
||||
#[test]
|
||||
fn css_two_vts_groups_do_not_cross_contaminate_keys() {
|
||||
fn scrambled_vob(title_key: [u8; 5], marker: u8) -> (Vec<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]),
|
||||
None
|
||||
);
|
||||
|
||||
// Every byte of the `00 00 01` marker is load-bearing: a near-miss
|
||||
// that gets ANY one of the three bytes wrong must not be recognized.
|
||||
assert_eq!(
|
||||
sniff_video_codec(&[0x05, 0x00, 0x01, 0xB3]),
|
||||
None,
|
||||
"leading byte must be 0x00, not just any byte"
|
||||
);
|
||||
assert_eq!(
|
||||
sniff_video_codec(&[0x00, 0x05, 0x01, 0xB3]),
|
||||
None,
|
||||
"second byte must be 0x00, not just any byte"
|
||||
);
|
||||
assert_eq!(
|
||||
sniff_video_codec(&[0x00, 0xFF, 0x01, 0xB3]),
|
||||
None,
|
||||
"the middle byte of the marker must actually be checked, not skipped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -972,6 +990,13 @@ mod tests {
|
||||
Some(Codec::Ac3Plus)
|
||||
);
|
||||
assert_eq!(sniff_audio_codec(&[0x00, 0x01, 0x02, 0x03]), None);
|
||||
// Both syncword bytes are required together: a lone 0x0B with no 0x77
|
||||
// partner anywhere must not be recognized.
|
||||
assert_eq!(
|
||||
sniff_audio_codec(&[0x0B, 0x00, 0x0B, 0x01]),
|
||||
None,
|
||||
"0x0B alone (no 0x77 partner) is not the E-AC-3 syncword"
|
||||
);
|
||||
}
|
||||
|
||||
// ── EVO head probe → streams ──────────────────────────────────────────
|
||||
@@ -1394,4 +1419,490 @@ mod tests {
|
||||
// Both feature halves are in ONE title's extents.
|
||||
assert!(!mm.extents.is_empty());
|
||||
}
|
||||
|
||||
// ── parse_vti_clip_order: bound / cap / termination edge cases ────────
|
||||
|
||||
/// The outer scan cap (`MAX_VTI_HITS`) must be exact, not off-by-one. Every
|
||||
/// entry here is stride-aligned so they ALL land in one residue bucket —
|
||||
/// unlike the scattered-token cap test above, the cap is directly visible
|
||||
/// in the output length (the total scanned-hit count IS the bucket size).
|
||||
#[test]
|
||||
fn parse_vti_clip_order_caps_hits_at_exact_boundary_same_residue() {
|
||||
let table_start = 0x200usize;
|
||||
let n = MAX_VTI_HITS + 50;
|
||||
let mut v = vec![0u8; table_start + n * VTI_CLIP_ENTRY_STRIDE];
|
||||
v[..HDDVD_VTI_MAGIC.len()].copy_from_slice(HDDVD_VTI_MAGIC);
|
||||
for i in 0..n {
|
||||
let off = table_start + i * VTI_CLIP_ENTRY_STRIDE + 0x42;
|
||||
v[off..off + b"X.EVO".len()].copy_from_slice(b"X.EVO");
|
||||
}
|
||||
let out = parse_vti_clip_order(&v);
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
MAX_VTI_HITS,
|
||||
"the scan must stop at exactly MAX_VTI_HITS, not one past it"
|
||||
);
|
||||
}
|
||||
|
||||
/// A name-byte run that reaches the exact end of the buffer with NO NUL
|
||||
/// terminator must not be read out of bounds — the inner scan (and the
|
||||
/// nul-terminated check) must stop at the buffer boundary rather than
|
||||
/// indexing one past it.
|
||||
#[test]
|
||||
fn parse_vti_clip_order_handles_unterminated_name_run_at_buffer_end() {
|
||||
let mut v = HDDVD_VTI_MAGIC.to_vec();
|
||||
v.extend_from_slice(b"TRAILING_JUNK_NO_TERMINATOR"); // all ascii-graphic, no NUL, ends at EOF
|
||||
let out = parse_vti_clip_order(&v);
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"unterminated trailing run yields no entries (and must not panic)"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `.EVO`-suffixed name run followed by an in-bounds byte that is NOT a
|
||||
/// NUL must not be treated as terminated — being merely in-bounds is not
|
||||
/// the same as actually finding a NUL.
|
||||
#[test]
|
||||
fn parse_vti_clip_order_requires_actual_nul_terminator_not_just_in_bounds() {
|
||||
let mut v = HDDVD_VTI_MAGIC.to_vec();
|
||||
v.extend_from_slice(b"FEATURE.EVO");
|
||||
v.push(0x01); // in-bounds terminator byte, but NOT a NUL
|
||||
v.extend_from_slice(&[0u8; 16]);
|
||||
let out = parse_vti_clip_order(&v);
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"a non-NUL byte after .EVO must not count as terminated"
|
||||
);
|
||||
}
|
||||
|
||||
/// A NUL-terminated ascii run that does NOT end in ".EVO" must never be
|
||||
/// collected, no matter how many repeat at a shared residue: nul-
|
||||
/// termination and the `.EVO`-suffix check are independent gates, one must
|
||||
/// not short-circuit the other away.
|
||||
#[test]
|
||||
fn parse_vti_clip_order_rejects_nul_terminated_names_without_evo_suffix() {
|
||||
let mut v = HDDVD_VTI_MAGIC.to_vec();
|
||||
for _ in 0..20 {
|
||||
v.extend_from_slice(b"HELLO\0"); // nul-terminated, 5 bytes, not .EVO
|
||||
}
|
||||
let out = parse_vti_clip_order(&v);
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"non-.EVO nul-terminated names must not be collected"
|
||||
);
|
||||
}
|
||||
|
||||
/// A short (<4-byte) nul-terminated name run must be rejected by the
|
||||
/// length guard BEFORE the `.EVO`-suffix slice runs — slicing a name
|
||||
/// shorter than 4 bytes at `name.len() - 4` would otherwise underflow.
|
||||
/// Must not panic, and must not be collected.
|
||||
#[test]
|
||||
fn parse_vti_clip_order_short_circuits_length_check_before_slicing_short_names() {
|
||||
let mut v = HDDVD_VTI_MAGIC.to_vec();
|
||||
v.push(b' '); // non-name-byte separator: isolates "AB" from the magic run
|
||||
v.extend_from_slice(b"AB\0"); // 2-byte name, under the 4-byte slice width
|
||||
let out = parse_vti_clip_order(&v);
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"short name is rejected without slicing/panicking"
|
||||
);
|
||||
}
|
||||
|
||||
// ── EVO_ES_SAMPLE_CAP / collect_es capping ─────────────────────────────
|
||||
|
||||
/// The documented sample cap is 128 KiB, i.e. `128 * 1024`.
|
||||
#[test]
|
||||
fn evo_es_sample_cap_is_128_kib() {
|
||||
assert_eq!(EVO_ES_SAMPLE_CAP, 128 * 1024);
|
||||
}
|
||||
|
||||
/// Plain-video-range (`0xE0..=0xEF`) samples stop growing once the buffer
|
||||
/// has reached the cap — a subsequent packet must not push it past.
|
||||
#[test]
|
||||
fn collect_es_caps_video_sample_at_the_length_cap() {
|
||||
use crate::consts::pes_stream_id::VIDEO;
|
||||
let mut video = Vec::new();
|
||||
let mut video_pid: Option<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!(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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user