Close the MEDIUM mutation gaps across transport, labels and codecs
The remaining triage items after tonight's HIGH fixes: 1,290 lines, almost all tests. Covers disc/mod.rs's DVD scan path (with real minimal VMG/VTS IFO fixtures rather than mocks), drive/mod.rs, labels/class_reader.rs and labels/mod.rs — the two biggest untriaged survivor clusters in the crate — plus hevc.rs and ps.rs. One production change, and it is an extraction rather than a behaviour change: MacScsiTransport::open mapped the shim's negative failure sentinels to typed errors inline, where nothing could reach it without a real IOKit FFI call. It is now map_shim_open_error, so the mapping can be pinned. It matters because collapsing -5 into the DeviceNotFound catch-all turns "another process holds the drive" into "no such drive", and an operator chasing the wrong problem is worse than a blunt error. Gate green on the pinned toolchain including the secrets scanner.
This commit is contained in:
+577
-16
@@ -2048,22 +2048,7 @@ impl Disc {
|
|||||||
// one shared PGS classifier); the rip path leaves it off — the muxer
|
// one shared PGS classifier); the rip path leaves it off — the muxer
|
||||||
// detects forced while muxing, without a second read of the clip.
|
// detects forced while muxing, without a second read of the clip.
|
||||||
if opts.probe_forced_subtitles {
|
if opts.probe_forced_subtitles {
|
||||||
// One cache across every title: a disc's playlists overwhelmingly
|
Self::probe_forced_subtitles_for_bdts_titles(reader, &mut titles, opts.halt.as_ref());
|
||||||
// reference the same handful of clips (main feature, play-all,
|
|
||||||
// seamless-branch variants), so without memoisation the same physical
|
|
||||||
// extents are re-read from the drive once per playlist — 30-150 times
|
|
||||||
// on a typical Blu-ray.
|
|
||||||
let mut cache = pgs_forced_probe::ForcedProbeCache::new();
|
|
||||||
for title in &mut titles {
|
|
||||||
if title.content_format == ContentFormat::BdTs {
|
|
||||||
pgs_forced_probe::probe_and_set_forced(
|
|
||||||
reader,
|
|
||||||
title,
|
|
||||||
&mut cache,
|
|
||||||
opts.halt.as_ref(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
crate::labels::fill_defaults(&mut titles);
|
crate::labels::fill_defaults(&mut titles);
|
||||||
|
|
||||||
@@ -2184,6 +2169,35 @@ impl Disc {
|
|||||||
pub const CANONICAL_TITLE_ORDER_KEYS: &'static [&'static str] =
|
pub const CANONICAL_TITLE_ORDER_KEYS: &'static [&'static str] =
|
||||||
&["fits-disc", "largest-size", "longest", "richest-audio"];
|
&["fits-disc", "largest-size", "longest", "richest-audio"];
|
||||||
|
|
||||||
|
/// Content-based forced-subtitle detection, restricted to `BdTs` titles —
|
||||||
|
/// gates on the STREAM CONTAINER, not the disc-tree format, because a
|
||||||
|
/// disc can carry non-`BdTs` titles even under `BDMV/` scanning. Only
|
||||||
|
/// `BdTs` titles carry PES-wrapped PGS the shared classifier
|
||||||
|
/// (`pgs_forced_probe`) understands; running it against an HD-DVD/DVD
|
||||||
|
/// (`MpegPs`) title's extents would demux the wrong container.
|
||||||
|
///
|
||||||
|
/// Pulled out of `scan_with` as its own callable predicate: the decision
|
||||||
|
/// is otherwise reachable only by driving the full scan pipeline (tree
|
||||||
|
/// dispatch → title parsing → this loop), so a test could not pin the
|
||||||
|
/// gate without also authoring an entire synthetic disc image.
|
||||||
|
fn probe_forced_subtitles_for_bdts_titles(
|
||||||
|
reader: &mut dyn SectorSource,
|
||||||
|
titles: &mut [DiscTitle],
|
||||||
|
halt: Option<&crate::halt::Halt>,
|
||||||
|
) {
|
||||||
|
// One cache across every title: a disc's playlists overwhelmingly
|
||||||
|
// reference the same handful of clips (main feature, play-all,
|
||||||
|
// seamless-branch variants), so without memoisation the same physical
|
||||||
|
// extents are re-read from the drive once per playlist — 30-150 times
|
||||||
|
// on a typical Blu-ray.
|
||||||
|
let mut cache = pgs_forced_probe::ForcedProbeCache::new();
|
||||||
|
for title in titles.iter_mut() {
|
||||||
|
if title.content_format == ContentFormat::BdTs {
|
||||||
|
pgs_forced_probe::probe_and_set_forced(reader, title, &mut cache, halt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn canonical_title_order(
|
pub fn canonical_title_order(
|
||||||
a: &DiscTitle,
|
a: &DiscTitle,
|
||||||
b: &DiscTitle,
|
b: &DiscTitle,
|
||||||
@@ -3272,6 +3286,321 @@ mod tests {
|
|||||||
assert_eq!(got[0].sector_count, 100_000);
|
assert_eq!(got[0].sector_count, 100_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── scan_image's CSS-crack gate: DVD-only, never AACS/HD-DVD ────────────
|
||||||
|
|
||||||
|
/// Minimal VIDEO_TS.IFO (VMG): one title, VTS 1, title 1. Layout mirrors
|
||||||
|
/// `disc::dvd`'s own IFO builders (magic@0, TT_SRPT ptr@0xC4).
|
||||||
|
fn dvd_vmg_bytes() -> Vec<u8> {
|
||||||
|
let tt_srpt_sector = 1u32;
|
||||||
|
let mut d = vec![0u8; 2 * 2048];
|
||||||
|
d[0..12].copy_from_slice(b"DVDVIDEO-VMG");
|
||||||
|
d[0xC4..0xC8].copy_from_slice(&tt_srpt_sector.to_be_bytes());
|
||||||
|
let base = tt_srpt_sector as usize * 2048;
|
||||||
|
d[base..base + 2].copy_from_slice(&1u16.to_be_bytes()); // num_titles = 1
|
||||||
|
let e = base + 8;
|
||||||
|
d[e + 2..e + 4].copy_from_slice(&1u16.to_be_bytes()); // chapters
|
||||||
|
d[e + 6] = 1; // vts
|
||||||
|
d[e + 7] = 1; // vts_title
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal VTS_01_0.IFO: one PGC, one cell `[first, last]`, NTSC/4:3, no
|
||||||
|
/// audio/subs. Layout mirrors `disc::dvd`'s own IFO builders.
|
||||||
|
fn dvd_vts_bytes(vob_start: u32, first_sector: u32, last_sector: u32) -> Vec<u8> {
|
||||||
|
let pgcit_sector = 2u32;
|
||||||
|
let mut d = vec![0u8; 4 * 2048];
|
||||||
|
d[0..12].copy_from_slice(b"DVDVIDEO-VTS");
|
||||||
|
d[0xC4..0xC8].copy_from_slice(&vob_start.to_be_bytes()); // vtstt_vobs
|
||||||
|
d[0xCC..0xD0].copy_from_slice(&pgcit_sector.to_be_bytes());
|
||||||
|
d[0x202..0x204].copy_from_slice(&0u16.to_be_bytes()); // num_audio
|
||||||
|
d[0x254..0x256].copy_from_slice(&0u16.to_be_bytes()); // num_subs
|
||||||
|
|
||||||
|
let pg = pgcit_sector as usize * 2048;
|
||||||
|
d[pg..pg + 2].copy_from_slice(&1u16.to_be_bytes()); // num_pgcs = 1
|
||||||
|
let pgc_rel: u32 = 0x100;
|
||||||
|
d[pg + 8 + 4..pg + 8 + 8].copy_from_slice(&pgc_rel.to_be_bytes());
|
||||||
|
let pgc = pg + pgc_rel as usize;
|
||||||
|
d[pgc + 0x02] = 1; // nr_of_programs
|
||||||
|
d[pgc + 0x03] = 1; // nr_of_cells
|
||||||
|
d[pgc + 0x06] = 0x30; // BCD 30s duration
|
||||||
|
d[pgc + 0x07] = 0b0100_0000; // 25fps rate bits
|
||||||
|
let cell_tbl_rel: u16 = 0xF0;
|
||||||
|
let pgm_map_rel: u16 = 0xEC;
|
||||||
|
d[pgc + 0xE6..pgc + 0xE8].copy_from_slice(&pgm_map_rel.to_be_bytes());
|
||||||
|
d[pgc + 0xE8..pgc + 0xEA].copy_from_slice(&cell_tbl_rel.to_be_bytes());
|
||||||
|
d[pgc + pgm_map_rel as usize] = 1; // program 0 -> cell 1
|
||||||
|
let cell_base = pgc + cell_tbl_rel as usize;
|
||||||
|
d[cell_base + 8..cell_base + 12].copy_from_slice(&first_sector.to_be_bytes());
|
||||||
|
d[cell_base + 20..cell_base + 24].copy_from_slice(&last_sector.to_be_bytes());
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A still-scrambled CSS DVD image must come back from `scan_image` with
|
||||||
|
/// `css.is_some()` and `encrypted == true` — the known-plaintext crack must
|
||||||
|
/// actually run against a `DiscFormat::Dvd` image with titles.
|
||||||
|
#[test]
|
||||||
|
fn scan_image_scrambled_css_dvd_is_cracked_and_marked_encrypted() {
|
||||||
|
use crate::udf::fixture::*;
|
||||||
|
|
||||||
|
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
|
let crackable = crackable_css_sector(&title_key).to_vec();
|
||||||
|
|
||||||
|
let vmg = dvd_vmg_bytes();
|
||||||
|
// vob_start = 1000, single cell sector [10, 10] -> 1-sector extent.
|
||||||
|
let vts = dvd_vts_bytes(1000, 10, 10);
|
||||||
|
|
||||||
|
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: "VIDEO_TS".into(),
|
||||||
|
icb_lba: 50,
|
||||||
|
dir_data_lba: 51,
|
||||||
|
files: vec![
|
||||||
|
file_with("VIDEO_TS.IFO", 60, 5000, vmg, true),
|
||||||
|
file_with("VTS_01_0.IFO", 62, 6000, vts, true),
|
||||||
|
],
|
||||||
|
subdirs: vec![],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
build_udf_skeleton(&mut disc, 10);
|
||||||
|
lay_dir(&mut disc, &root);
|
||||||
|
// IFO absolute LBA = PART_START(2000) + data_lba(6000) = 8000; extent
|
||||||
|
// absolute LBA = 8000 + vob_start(1000) + first_sector(10) = 9010.
|
||||||
|
disc.put_bytes(9010, &crackable);
|
||||||
|
|
||||||
|
let disc_scan = Disc::scan_image(&mut disc, 500_000, &ScanOptions::default())
|
||||||
|
.expect("scan_image must succeed on this synthetic DVD image");
|
||||||
|
assert_eq!(disc_scan.format, DiscFormat::Dvd, "sanity: image is DVD");
|
||||||
|
assert!(!disc_scan.titles.is_empty(), "sanity: a title was parsed");
|
||||||
|
assert!(
|
||||||
|
disc_scan.css.is_some(),
|
||||||
|
"a scrambled CSS DVD image must be cracked, not left in the clear"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
disc_scan.encrypted,
|
||||||
|
"a cracked CSS DVD must be reported encrypted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An HD-DVD image is also MPEG-PS, but must NEVER enter the CSS
|
||||||
|
/// known-plaintext crack — its `.evo` payload can never satisfy the CSS
|
||||||
|
/// attack, and running it wastes a 50_000-sector scan budget in the real
|
||||||
|
/// case. Prove the gate actually keeps the crack away: the HD-DVD clip's
|
||||||
|
/// own extent carries a genuinely Stevenson-crackable CSS sector, so if the
|
||||||
|
/// gate wrongly let the crack run, `disc.css` would come back `Some`.
|
||||||
|
#[test]
|
||||||
|
fn scan_image_hddvd_never_enters_css_crack() {
|
||||||
|
use crate::udf::fixture::*;
|
||||||
|
|
||||||
|
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
|
||||||
|
let crackable = crackable_css_sector(&title_key).to_vec();
|
||||||
|
|
||||||
|
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: "HVDVD_TS".into(),
|
||||||
|
icb_lba: 20,
|
||||||
|
dir_data_lba: 21,
|
||||||
|
files: vec![file_with("FEATURE.EVO", 30, 9000, crackable, true)],
|
||||||
|
subdirs: vec![],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
build_udf_skeleton(&mut disc, 10);
|
||||||
|
lay_dir(&mut disc, &root);
|
||||||
|
|
||||||
|
let disc_scan = Disc::scan_image(&mut disc, 500_000, &ScanOptions::default())
|
||||||
|
.expect("scan_image must succeed on this synthetic HD-DVD image");
|
||||||
|
assert_eq!(
|
||||||
|
disc_scan.format,
|
||||||
|
DiscFormat::HdDvd,
|
||||||
|
"sanity: image is HD-DVD, not DVD"
|
||||||
|
);
|
||||||
|
assert!(!disc_scan.titles.is_empty(), "sanity: a title was parsed");
|
||||||
|
assert!(
|
||||||
|
disc_scan.css.is_none(),
|
||||||
|
"the CSS crack must never run against a non-DVD (HD-DVD/AACS) image, \
|
||||||
|
even when its content happens to be CSS-crackable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── scan_with's forced-subtitle probe: BdTs-only gate (finding 10) ──────
|
||||||
|
|
||||||
|
/// A `PGS` subtitle title with the given `content_format`, one extent
|
||||||
|
/// starting at `start_lba`, for the `probe_forced_subtitles_for_bdts_titles`
|
||||||
|
/// container gate.
|
||||||
|
fn pgs_title(content_format: ContentFormat, start_lba: u32) -> DiscTitle {
|
||||||
|
DiscTitle {
|
||||||
|
content_format,
|
||||||
|
streams: vec![Stream::Subtitle(SubtitleStream {
|
||||||
|
pid: 0x1200,
|
||||||
|
codec: Codec::Pgs,
|
||||||
|
language: "eng".into(),
|
||||||
|
forced: false,
|
||||||
|
qualifier: LabelQualifier::None,
|
||||||
|
codec_data: None,
|
||||||
|
})],
|
||||||
|
extents: vec![Extent {
|
||||||
|
start_lba,
|
||||||
|
sector_count: 4,
|
||||||
|
}],
|
||||||
|
..DiscTitle::empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records every LBA read — used to prove WHICH title's extents were
|
||||||
|
/// actually touched, not merely that some read happened.
|
||||||
|
struct ForcedProbeSpyReader {
|
||||||
|
lbas: std::cell::RefCell<Vec<u32>>,
|
||||||
|
}
|
||||||
|
impl SectorSource for ForcedProbeSpyReader {
|
||||||
|
fn read_sectors(
|
||||||
|
&mut self,
|
||||||
|
lba: u32,
|
||||||
|
count: u16,
|
||||||
|
buf: &mut [u8],
|
||||||
|
_recovery: bool,
|
||||||
|
) -> Result<usize> {
|
||||||
|
self.lbas.borrow_mut().push(lba);
|
||||||
|
let n = (count as usize * 2048).min(buf.len());
|
||||||
|
buf[..n].fill(0);
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The forced-subtitle probe gates on `content_format == BdTs`, NOT on
|
||||||
|
/// whether a title happens to declare a PGS stream: an HD-DVD/DVD
|
||||||
|
/// (`MpegPs`) title's PES container is not what the shared PGS classifier
|
||||||
|
/// understands, so it must never be probed even if it carries a PGS pid.
|
||||||
|
/// The BdTs title (at LBA 0) must be probed; the MpegPs title (at LBA
|
||||||
|
/// 5000) must never be touched.
|
||||||
|
#[test]
|
||||||
|
fn probe_forced_subtitles_only_touches_bdts_titles() {
|
||||||
|
let mut titles = vec![
|
||||||
|
pgs_title(ContentFormat::BdTs, 0),
|
||||||
|
pgs_title(ContentFormat::MpegPs, 5000),
|
||||||
|
];
|
||||||
|
let mut reader = ForcedProbeSpyReader {
|
||||||
|
lbas: std::cell::RefCell::new(Vec::new()),
|
||||||
|
};
|
||||||
|
Disc::probe_forced_subtitles_for_bdts_titles(&mut reader, &mut titles, None);
|
||||||
|
let lbas = reader.lbas.borrow();
|
||||||
|
assert!(
|
||||||
|
!lbas.is_empty(),
|
||||||
|
"the BdTs title must be probed (a read must occur)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
lbas.iter().all(|&l| l < 5000),
|
||||||
|
"the MpegPs title's extent (LBA 5000) must never be read: {lbas:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── scan_with's capacity_bytes feeds canonical_title_order (finding 10) ─
|
||||||
|
|
||||||
|
/// Two HD-DVD `.evo` clips (no VTI/playlist, so each is its own title —
|
||||||
|
/// see `scan_hddvd_titles`), with the given DECLARED byte sizes. No real
|
||||||
|
/// content is written; `scan_with`'s capacity-threshold ranking depends
|
||||||
|
/// only on the ICB-declared size, not the bytes at the extent.
|
||||||
|
fn hddvd_two_clip_disc(
|
||||||
|
main_bytes: u32,
|
||||||
|
other_bytes: u32,
|
||||||
|
) -> (crate::udf::fixture::MemDisc, udf::UdfFs) {
|
||||||
|
use crate::udf::fixture::*;
|
||||||
|
let files = vec![
|
||||||
|
file("MAIN.EVO", 100, 5_000, main_bytes, true),
|
||||||
|
file("OTHER.EVO", 101, 50_000, other_bytes, true),
|
||||||
|
];
|
||||||
|
let root = DirSpec {
|
||||||
|
name: String::new(),
|
||||||
|
icb_lba: 10,
|
||||||
|
dir_data_lba: 11,
|
||||||
|
files: Vec::new(),
|
||||||
|
subdirs: vec![DirSpec {
|
||||||
|
name: "HVDVD_TS".into(),
|
||||||
|
icb_lba: 20,
|
||||||
|
dir_data_lba: 21,
|
||||||
|
files,
|
||||||
|
subdirs: vec![],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
build_udf_skeleton(&mut disc, 10);
|
||||||
|
lay_dir(&mut disc, &root);
|
||||||
|
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||||
|
(disc, udf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `scan_with`'s `capacity_bytes = capacity as u64 * 2048` feeds
|
||||||
|
/// `canonical_title_order`'s "bigger than the whole disc = play-all
|
||||||
|
/// composite" threshold. Chosen so `capacity * 2048` clears both titles
|
||||||
|
/// (neither is oversize, so the bigger one — OTHER — ranks first), but
|
||||||
|
/// `capacity + 2048` lands BETWEEN the two sizes: MAIN stays non-oversize
|
||||||
|
/// while OTHER flips to oversize and gets demoted, changing `titles[0]`.
|
||||||
|
/// A `*` → `+` mutation is caught by this ranking flip, not merely a
|
||||||
|
/// wrong numeric threshold value.
|
||||||
|
#[test]
|
||||||
|
fn scan_with_capacity_bytes_uses_multiplication_not_addition() {
|
||||||
|
const CAPACITY_SECTORS: u32 = 3_997_952; // *2048 = 8_187_805_696; +2048 = 4_000_000
|
||||||
|
let (mut disc, udf) = hddvd_two_clip_disc(3_000_000, 5_000_000);
|
||||||
|
let scanned = Disc::scan_with(
|
||||||
|
&mut disc,
|
||||||
|
CAPACITY_SECTORS,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&ScanOptions::default(),
|
||||||
|
udf,
|
||||||
|
)
|
||||||
|
.expect("scan_with must succeed on this synthetic HD-DVD image");
|
||||||
|
assert_eq!(
|
||||||
|
scanned.titles.first().map(|t| t.playlist.as_str()),
|
||||||
|
Some("OTHER.EVO"),
|
||||||
|
"with the real *2048 capacity both titles are non-oversize, so the \
|
||||||
|
bigger clip (OTHER, 5_000_000) must rank first: {:?}",
|
||||||
|
scanned
|
||||||
|
.titles
|
||||||
|
.iter()
|
||||||
|
.map(|t| &t.playlist)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same mechanism as above, tuned to catch a `*` → `/` mutation instead:
|
||||||
|
/// `capacity * 2048` clears both titles, but `capacity / 2048` collapses
|
||||||
|
/// to a threshold far below MAIN's declared size while still leaving
|
||||||
|
/// MAIN under it, so only OTHER flips to oversize.
|
||||||
|
#[test]
|
||||||
|
fn scan_with_capacity_bytes_uses_multiplication_not_division() {
|
||||||
|
const CAPACITY_SECTORS: u32 = 204_800_000; // *2048 = huge; /2048 = 100_000
|
||||||
|
let (mut disc, udf) = hddvd_two_clip_disc(1_000, 5_000_000);
|
||||||
|
let scanned = Disc::scan_with(
|
||||||
|
&mut disc,
|
||||||
|
CAPACITY_SECTORS,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&ScanOptions::default(),
|
||||||
|
udf,
|
||||||
|
)
|
||||||
|
.expect("scan_with must succeed on this synthetic HD-DVD image");
|
||||||
|
assert_eq!(
|
||||||
|
scanned.titles.first().map(|t| t.playlist.as_str()),
|
||||||
|
Some("OTHER.EVO"),
|
||||||
|
"with the real *2048 capacity both titles are non-oversize, so the \
|
||||||
|
bigger clip (OTHER, 5_000_000) must rank first: {:?}",
|
||||||
|
scanned
|
||||||
|
.titles
|
||||||
|
.iter()
|
||||||
|
.map(|t| &t.playlist)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Unknown must not fabricate a plausible value (finding 2) ────────────
|
// ── Unknown must not fabricate a plausible value (finding 2) ────────────
|
||||||
|
|
||||||
/// `Resolution::Unknown` has no dimensions, so `pixels()` must report none
|
/// `Resolution::Unknown` has no dimensions, so `pixels()` must report none
|
||||||
@@ -3838,6 +4167,201 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `read_mkb_content` reads a bounded 16 MiB starting prefix and must GROW
|
||||||
|
/// it when the MKB's real record stream runs past that prefix — otherwise
|
||||||
|
/// the caller silently gets a truncated MKB. This fixture's record stream
|
||||||
|
/// is deliberately built so the first 16 records land EXACTLY on the 16
|
||||||
|
/// MiB boundary (so the 16 MiB prefix alone parses as a clean, complete
|
||||||
|
/// record stream — the case a naive "n < buf.len() means done" check would
|
||||||
|
/// wrongly accept) and 4 more MiB of records follow, terminated by the
|
||||||
|
/// explicit `00 000000` end marker. Only a caller that actually grows past
|
||||||
|
/// 16 MiB recovers the true 20 MiB content length.
|
||||||
|
///
|
||||||
|
/// Run inside a watchdog thread: some mutants of the growth step (e.g.
|
||||||
|
/// `want / 2` instead of `want * 2`) walk `want` to 0 and spin forever
|
||||||
|
/// re-reading a zero-length prefix, which must fail this test rather than
|
||||||
|
/// hang the suite.
|
||||||
|
#[test]
|
||||||
|
fn read_mkb_content_grows_prefix_past_16mib_when_records_run_longer() {
|
||||||
|
use crate::udf::fixture::*;
|
||||||
|
|
||||||
|
const REC_LEN: usize = 1024 * 1024; // 1 MiB, header included
|
||||||
|
const N_RECORDS: usize = 20; // 20 MiB of real record stream
|
||||||
|
const TOTAL: usize = N_RECORDS * REC_LEN;
|
||||||
|
|
||||||
|
let mut mkb = Vec::with_capacity(TOTAL + 4);
|
||||||
|
for _ in 0..N_RECORDS {
|
||||||
|
mkb.push(0x04); // REC_SUBSET_DIFFERENCE — any non-zero, non-terminator type
|
||||||
|
let len = REC_LEN as u32;
|
||||||
|
mkb.push((len >> 16) as u8);
|
||||||
|
mkb.push((len >> 8) as u8);
|
||||||
|
mkb.push(len as u8);
|
||||||
|
mkb.resize(mkb.len() + (REC_LEN - 4), 0xAA);
|
||||||
|
}
|
||||||
|
mkb.extend_from_slice(&[0, 0, 0, 0]); // explicit end-of-records marker
|
||||||
|
assert_eq!(mkb.len(), TOTAL + 4);
|
||||||
|
|
||||||
|
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: "AACS".into(),
|
||||||
|
icb_lba: 12,
|
||||||
|
dir_data_lba: 13,
|
||||||
|
files: vec![file_with("MKB_RO.inf", 14, 1000, mkb, true)],
|
||||||
|
subdirs: vec![],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
build_udf_skeleton(&mut disc, 10);
|
||||||
|
lay_dir(&mut disc, &root);
|
||||||
|
let udf = crate::udf::read_filesystem(&mut disc).expect("fs");
|
||||||
|
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut disc = disc;
|
||||||
|
let r = Disc::read_mkb_content(&mut disc, &udf).map(|v| v.len());
|
||||||
|
let _ = tx.send(r);
|
||||||
|
});
|
||||||
|
let got = rx
|
||||||
|
.recv_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.expect(
|
||||||
|
"read_mkb_content did not terminate — the prefix-growth loop \
|
||||||
|
spun forever instead of converging on the record-stream length",
|
||||||
|
)
|
||||||
|
.expect("read_mkb_content failed");
|
||||||
|
assert_eq!(
|
||||||
|
got, TOTAL,
|
||||||
|
"must recover the full 20 MiB record stream, not the 16 MiB starting prefix"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── identify(): AACS-directory encrypted gate (finding 6) ──────────────
|
||||||
|
|
||||||
|
/// A `ScsiTransport` that serves a synthetic UDF image (built with the
|
||||||
|
/// shared `udf::fixture` helpers) through real READ CAPACITY(10) /
|
||||||
|
/// READ(10) commands, so `Disc::identify` is exercised end-to-end through
|
||||||
|
/// a real `Drive` rather than mocking `identify` itself — proving the
|
||||||
|
/// AACS-directory check is actually what the caller uses, not just that
|
||||||
|
/// `UdfFs::find_dir` works in isolation.
|
||||||
|
struct MemDiscDrive {
|
||||||
|
mem: crate::udf::fixture::MemDisc,
|
||||||
|
last_lba: u32,
|
||||||
|
}
|
||||||
|
impl crate::scsi::ScsiTransport for MemDiscDrive {
|
||||||
|
fn execute(
|
||||||
|
&mut self,
|
||||||
|
cdb: &[u8],
|
||||||
|
_dir: crate::scsi::DataDirection,
|
||||||
|
data: &mut [u8],
|
||||||
|
_timeout_ms: u32,
|
||||||
|
) -> Result<crate::scsi::ScsiResult> {
|
||||||
|
match cdb.first().copied() {
|
||||||
|
Some(op) if op == crate::scsi::SCSI_READ_CAPACITY => {
|
||||||
|
data[0..4].copy_from_slice(&self.last_lba.to_be_bytes());
|
||||||
|
data[4..8].copy_from_slice(&2048u32.to_be_bytes());
|
||||||
|
Ok(crate::scsi::ScsiResult {
|
||||||
|
status: 0,
|
||||||
|
bytes_transferred: 8,
|
||||||
|
sense: [0u8; 32],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Some(op) if op == crate::scsi::SCSI_READ_10 => {
|
||||||
|
let lba = u32::from_be_bytes([cdb[2], cdb[3], cdb[4], cdb[5]]);
|
||||||
|
let count = u16::from_be_bytes([cdb[7], cdb[8]]);
|
||||||
|
let n = self.mem.read_sectors(lba, count, data, false)?;
|
||||||
|
Ok(crate::scsi::ScsiResult {
|
||||||
|
status: 0,
|
||||||
|
bytes_transferred: n,
|
||||||
|
sense: [0u8; 32],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => Ok(crate::scsi::ScsiResult {
|
||||||
|
status: 0,
|
||||||
|
bytes_transferred: 0,
|
||||||
|
sense: [0u8; 32],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a synthetic disc with (or without) a root `/AACS` directory
|
||||||
|
/// and/or a nested `/BDMV/AACS` directory, then run the real
|
||||||
|
/// `Disc::identify` end-to-end through a mocked `Drive`.
|
||||||
|
fn identify_with_aacs_dirs(has_aacs: bool, has_bdmv_aacs: bool) -> DiscId {
|
||||||
|
use crate::udf::fixture::*;
|
||||||
|
let mut subdirs = Vec::new();
|
||||||
|
if has_aacs {
|
||||||
|
subdirs.push(DirSpec {
|
||||||
|
name: "AACS".into(),
|
||||||
|
icb_lba: 20,
|
||||||
|
dir_data_lba: 21,
|
||||||
|
files: Vec::new(),
|
||||||
|
subdirs: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if has_bdmv_aacs {
|
||||||
|
subdirs.push(DirSpec {
|
||||||
|
name: "BDMV".into(),
|
||||||
|
icb_lba: 30,
|
||||||
|
dir_data_lba: 31,
|
||||||
|
files: Vec::new(),
|
||||||
|
subdirs: vec![DirSpec {
|
||||||
|
name: "AACS".into(),
|
||||||
|
icb_lba: 32,
|
||||||
|
dir_data_lba: 33,
|
||||||
|
files: Vec::new(),
|
||||||
|
subdirs: Vec::new(),
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let root = DirSpec {
|
||||||
|
name: String::new(),
|
||||||
|
icb_lba: 10,
|
||||||
|
dir_data_lba: 11,
|
||||||
|
files: Vec::new(),
|
||||||
|
subdirs,
|
||||||
|
};
|
||||||
|
let mut mem = MemDisc::new();
|
||||||
|
build_udf_skeleton(&mut mem, 10);
|
||||||
|
lay_dir(&mut mem, &root);
|
||||||
|
let mut drive = crate::drive::Drive::from_transport_for_test(Box::new(MemDiscDrive {
|
||||||
|
mem,
|
||||||
|
last_lba: 99_999,
|
||||||
|
}));
|
||||||
|
Disc::identify(&mut drive).expect("identify must succeed on this synthetic image")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A root `/AACS` directory alone (the near-universal retail BD/UHD
|
||||||
|
/// shape) must report `encrypted == true`.
|
||||||
|
#[test]
|
||||||
|
fn identify_reports_encrypted_for_aacs_dir_alone() {
|
||||||
|
let id = identify_with_aacs_dirs(true, false);
|
||||||
|
assert!(id.encrypted, "/AACS alone must report encrypted");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A nested `/BDMV/AACS` directory alone must ALSO report
|
||||||
|
/// `encrypted == true` — dropping this side of the `||` (degrading it to
|
||||||
|
/// `&&`) would report virtually every retail BD as unencrypted, since
|
||||||
|
/// real discs almost never carry both paths at once.
|
||||||
|
#[test]
|
||||||
|
fn identify_reports_encrypted_for_bdmv_aacs_dir_alone() {
|
||||||
|
let id = identify_with_aacs_dirs(false, true);
|
||||||
|
assert!(id.encrypted, "/BDMV/AACS alone must report encrypted");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Neither AACS path present must report `encrypted == false`.
|
||||||
|
#[test]
|
||||||
|
fn identify_reports_unencrypted_when_no_aacs_dir_exists() {
|
||||||
|
let id = identify_with_aacs_dirs(false, false);
|
||||||
|
assert!(
|
||||||
|
!id.encrypted,
|
||||||
|
"no AACS directory at all must report unencrypted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Title selection is by largest physical size, NOT clip count or duration.
|
/// Title selection is by largest physical size, NOT clip count or duration.
|
||||||
/// Real-disc shape (Fast Five): a 57 GB / 11-clip feature must outrank both a
|
/// Real-disc shape (Fast Five): a 57 GB / 11-clip feature must outrank both a
|
||||||
/// small 1-clip bonus reel and a long-but-tiny decoy "play-all" (91 reused
|
/// small 1-clip bonus reel and a long-but-tiny decoy "play-all" (91 reused
|
||||||
@@ -5456,6 +5980,43 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A major sync whose rate nibble (`format_info` bits 31..28) is `0x3` —
|
||||||
|
/// not one of the six whitelisted rates `truehd_sample_rate_hz` recognises
|
||||||
|
/// — must leave the container's sample rate untouched, mirroring the
|
||||||
|
/// channels guard above ("never write a wrong SamplingFrequency"). Both
|
||||||
|
/// channel-assignment masks are zeroed so `truehd_channels` returns `None`
|
||||||
|
/// too, isolating this to the sample-rate guard alone.
|
||||||
|
#[test]
|
||||||
|
fn correct_truehd_channels_leaves_sample_rate_when_nibble_unrecognized() {
|
||||||
|
let pid = 0x1100u16;
|
||||||
|
let format_info = 0x3u32 << 28; // unrecognised rate nibble; ch8/ch6 masks = 0
|
||||||
|
let es = thd_major_sync_es(format_info, 0);
|
||||||
|
let ts = thd_bd_pes(pid, &es);
|
||||||
|
let mut title = DiscTitle::empty();
|
||||||
|
title.streams = vec![truehd_audio_stream(
|
||||||
|
pid,
|
||||||
|
AudioChannels::Surround51,
|
||||||
|
SampleRate::S48,
|
||||||
|
)];
|
||||||
|
title.extents = vec![Extent {
|
||||||
|
start_lba: 0,
|
||||||
|
sector_count: 1,
|
||||||
|
}];
|
||||||
|
let mut reader = ThdSpyReader {
|
||||||
|
calls: std::cell::RefCell::new(Vec::new()),
|
||||||
|
data: ts,
|
||||||
|
};
|
||||||
|
correct_truehd_channels(&mut reader, &mut title);
|
||||||
|
let Stream::Audio(a) = &title.streams[0] else {
|
||||||
|
panic!("stream type must be preserved")
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
a.sample_rate,
|
||||||
|
SampleRate::S48,
|
||||||
|
"an unrecognised major-sync rate nibble must not overwrite the container's sample rate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── bytes_bad_in_title: empty-input guard ────────────────────────────
|
// ── bytes_bad_in_title: empty-input guard ────────────────────────────
|
||||||
|
|
||||||
// NOTE: `bad_ranges.is_empty() || title.extents.is_empty()` (mod.rs:628) —
|
// NOTE: `bad_ranges.is_empty() || title.extents.is_empty()` (mod.rs:628) —
|
||||||
|
|||||||
@@ -2458,6 +2458,127 @@ mod command_tests {
|
|||||||
assert_eq!(d.get_config_feature(0x0000), None);
|
assert_eq!(d.get_config_feature(0x0000), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_config_feature_encodes_feature_code_be_in_cdb() {
|
||||||
|
// GET CONFIGURATION (0x46), RT field byte 1 = 0x02 (report the
|
||||||
|
// named feature), then the 16-bit feature code big-endian in
|
||||||
|
// bytes 2..4. 0x010D has a nonzero high byte, so a swapped shift
|
||||||
|
// (>> vs <<) or byte order bug asks the drive for the wrong
|
||||||
|
// feature entirely.
|
||||||
|
let RecordingHarness {
|
||||||
|
drive: mut d,
|
||||||
|
cdb,
|
||||||
|
timeouts: _to,
|
||||||
|
} = recording(TransportOutcome::Ok(0));
|
||||||
|
let _ = d.get_config_feature(0x010D);
|
||||||
|
let c = cdb.lock().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
&c[..4],
|
||||||
|
&[crate::scsi::SCSI_GET_CONFIGURATION, 0x02, 0x01, 0x0D],
|
||||||
|
"feature code must be big-endian in CDB bytes 2..4"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── spin_cycle / wait_ready: recovery entry points (LOW finding 8) ──────
|
||||||
|
|
||||||
|
/// Records EVERY CDB issued, in order — unlike `RecordingTransport`
|
||||||
|
/// (used above), which only keeps the last one. Needed to assert a
|
||||||
|
/// multi-command sequence like `spin_cycle`'s STOP-then-START.
|
||||||
|
struct SequenceTransport {
|
||||||
|
cdbs: Arc<Mutex<Vec<Vec<u8>>>>,
|
||||||
|
ok: bool,
|
||||||
|
}
|
||||||
|
impl ScsiTransport for SequenceTransport {
|
||||||
|
fn execute(
|
||||||
|
&mut self,
|
||||||
|
cdb: &[u8],
|
||||||
|
_dir: DataDirection,
|
||||||
|
_data: &mut [u8],
|
||||||
|
_timeout_ms: u32,
|
||||||
|
) -> Result<ScsiResult> {
|
||||||
|
self.cdbs.lock().unwrap().push(cdb.to_vec());
|
||||||
|
if self.ok {
|
||||||
|
Ok(ScsiResult {
|
||||||
|
status: 0,
|
||||||
|
bytes_transferred: 0,
|
||||||
|
sense: [0u8; 32],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err(Error::ScsiError {
|
||||||
|
opcode: cdb[0],
|
||||||
|
status: 2,
|
||||||
|
sense: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The documented BU40N/Initio wedge recovery: spin the disc down then
|
||||||
|
/// back up WITHOUT ejecting. Exactly two START STOP UNIT (0x1B) commands,
|
||||||
|
/// in order — STOP (START=0) then START (START=1) — both with LOEJ=0
|
||||||
|
/// (byte 4 bit 1 clear): a slot-loading BU40N must never eject during
|
||||||
|
/// unattended recovery. Real time cost (~15s: the validated 5s spin-down
|
||||||
|
/// idle + 10s spin-up settle) is accepted here rather than adding an
|
||||||
|
/// injectable-sleep seam.
|
||||||
|
#[test]
|
||||||
|
fn spin_cycle_issues_stop_then_start_without_ejecting() {
|
||||||
|
let cdbs = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let t = SequenceTransport {
|
||||||
|
cdbs: cdbs.clone(),
|
||||||
|
ok: true,
|
||||||
|
};
|
||||||
|
let mut d = Drive::from_transport_for_test(Box::new(t));
|
||||||
|
d.spin_cycle()
|
||||||
|
.expect("spin_cycle must succeed when both SCSI commands succeed");
|
||||||
|
let seq = cdbs.lock().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
seq.len(),
|
||||||
|
2,
|
||||||
|
"spin_cycle must issue exactly two commands: {seq:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(seq[0][0], SCSI_START_STOP_UNIT);
|
||||||
|
assert_eq!(seq[0][4], 0x00, "first command: START=0 (spin down)");
|
||||||
|
assert_eq!(seq[1][0], SCSI_START_STOP_UNIT);
|
||||||
|
assert_eq!(seq[1][4], 0x01, "second command: START=1 (spin up)");
|
||||||
|
for (i, c) in seq.iter().enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
c[4] & 0x02,
|
||||||
|
0,
|
||||||
|
"LOEJ bit must be clear on command {i} — spin_cycle must never eject"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A drive that never answers TEST UNIT READY successfully must surface
|
||||||
|
/// `Err(DeviceNotReady)`, not silently report ready. Real time cost
|
||||||
|
/// accepted (60 x 500ms = ~30s) rather than adding an injectable-sleep
|
||||||
|
/// seam for the poll backoff.
|
||||||
|
#[test]
|
||||||
|
fn wait_ready_returns_err_when_drive_never_becomes_ready() {
|
||||||
|
struct NeverReady;
|
||||||
|
impl ScsiTransport for NeverReady {
|
||||||
|
fn execute(
|
||||||
|
&mut self,
|
||||||
|
cdb: &[u8],
|
||||||
|
_dir: DataDirection,
|
||||||
|
_data: &mut [u8],
|
||||||
|
_timeout_ms: u32,
|
||||||
|
) -> Result<ScsiResult> {
|
||||||
|
Err(Error::ScsiError {
|
||||||
|
opcode: cdb[0],
|
||||||
|
status: 2,
|
||||||
|
sense: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut d = Drive::from_transport_for_test(Box::new(NeverReady));
|
||||||
|
let r = d.wait_ready();
|
||||||
|
assert!(
|
||||||
|
matches!(r, Err(Error::DeviceNotReady { .. })),
|
||||||
|
"a drive that never answers TUR successfully must be DeviceNotReady, got {r:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── report_key / mode_sense / read_buffer empty-vs-some ─────────
|
// ── report_key / mode_sense / read_buffer empty-vs-some ─────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1546,4 +1546,181 @@ mod tests {
|
|||||||
assert_eq!(cf.member_descriptor(&m), Some("()V"));
|
assert_eq!(cf.member_descriptor(&m), Some("()V"));
|
||||||
assert_ne!(cf.member_descriptor(&m), Some("doStuff"));
|
assert_ne!(cf.member_descriptor(&m), Some("doStuff"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Reader::u16/u32/u64 boundary + value correctness
|
||||||
|
//
|
||||||
|
// Mirrors `slice_boundary_is_inclusive_of_the_final_byte`: an
|
||||||
|
// exact-fit read must succeed, one byte short must fail. Plus
|
||||||
|
// positive-value tests so a scrambled byte assembly (not just an
|
||||||
|
// out-of-bounds read) would be caught.
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u16_boundary_is_inclusive_of_the_final_byte() {
|
||||||
|
let data = [0xAB, 0xCD];
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert_eq!(r.u16("exact fit").expect("2 bytes available"), 0xABCD);
|
||||||
|
|
||||||
|
let data = [0xAB];
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert!(matches!(
|
||||||
|
r.u16("one byte short"),
|
||||||
|
Err(Error::UnexpectedEof { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u16_decodes_big_endian_value() {
|
||||||
|
let data = [0x01, 0x02];
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert_eq!(r.u16("value").unwrap(), 0x0102);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u32_boundary_is_inclusive_of_the_final_byte() {
|
||||||
|
let data = [0x00, 0x00, 0x00, 0x2A];
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert_eq!(r.u32("exact fit").expect("4 bytes available"), 42);
|
||||||
|
|
||||||
|
let data = [0x00, 0x00, 0x00];
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert!(matches!(
|
||||||
|
r.u32("one byte short"),
|
||||||
|
Err(Error::UnexpectedEof { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u32_decodes_big_endian_value() {
|
||||||
|
let data = [0x00, 0x00, 0x05, 0x39]; // 1337
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert_eq!(r.u32("value").unwrap(), 1337);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u64_boundary_is_inclusive_of_the_final_byte() {
|
||||||
|
// pos == 0, buffer exactly 8 bytes: must succeed.
|
||||||
|
let data = [0, 0, 0, 0, 0, 0, 0, 0x7B]; // 123
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert_eq!(r.u64("exact fit").expect("8 bytes available"), 123);
|
||||||
|
|
||||||
|
// pos == 0, buffer one byte short of 8: must fail cleanly, not
|
||||||
|
// panic on the internal self.data[self.pos + 7] index.
|
||||||
|
let data = [0u8; 7];
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert!(matches!(
|
||||||
|
r.u64("one byte short"),
|
||||||
|
Err(Error::UnexpectedEof { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u64_decodes_big_endian_value() {
|
||||||
|
let data = [0, 0, 0, 0, 0, 0, 0x05, 0x39]; // 1337
|
||||||
|
let mut r = Reader::new(&data);
|
||||||
|
assert_eq!(r.u64("value").unwrap(), 1337);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// decode_modified_utf8: 3-byte (BMP) decode path
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modified_utf8_three_byte_cjk() {
|
||||||
|
// U+3042 (hiragana あ) in modified UTF-8: 1110xxxx 10xxxxxx 10xxxxxx
|
||||||
|
// = 0xE3 0x81 0x82.
|
||||||
|
let s = decode_modified_utf8(&[0xE3, 0x81, 0x82]).unwrap();
|
||||||
|
assert_eq!(s, "\u{3042}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modified_utf8_three_byte_rejects_bad_first_continuation() {
|
||||||
|
// Valid lead byte (0xE3), but the first continuation byte is not
|
||||||
|
// 10xxxxxx (0x01 instead) — must be rejected, proving the first
|
||||||
|
// `& 0xC0 != 0x80` check is live.
|
||||||
|
assert!(decode_modified_utf8(&[0xE3, 0x01, 0x82]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modified_utf8_three_byte_rejects_bad_second_continuation() {
|
||||||
|
// Valid lead + first continuation, but the second continuation
|
||||||
|
// byte is not 10xxxxxx — proves the second check is independently
|
||||||
|
// live (not short-circuited by the first).
|
||||||
|
assert!(decode_modified_utf8(&[0xE3, 0x81, 0x01]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// read_constant_pool: Long/Double two-slot skip, real byte parsing
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constant_pool_long_entry_occupies_two_slots_via_real_parse() {
|
||||||
|
// Real class-file bytes (not the `from_entries` synthetic ctor):
|
||||||
|
// magic + minor/major + cp_count=4 + tag=5 (Long, 8-byte payload
|
||||||
|
// at index 1, reserved slot at index 2) + tag=1 (Utf8 at index 3)
|
||||||
|
// + empty access_flags/this/super/interfaces/fields/methods/attrs.
|
||||||
|
let mut buf = vec![
|
||||||
|
0xCA, 0xFE, 0xBA, 0xBE, // magic
|
||||||
|
0x00, 0x00, // minor
|
||||||
|
0x00, 0x34, // major
|
||||||
|
0x00, 0x04, // cp_count = 4 (0=Empty,1=Long,2=Empty tail,3=Utf8)
|
||||||
|
5, // Long tag
|
||||||
|
];
|
||||||
|
buf.extend_from_slice(&0x1122_3344_5566_7788u64.to_be_bytes()); // 8-byte payload
|
||||||
|
buf.push(1); // Utf8 tag
|
||||||
|
let name = b"marker";
|
||||||
|
buf.extend_from_slice(&(name.len() as u16).to_be_bytes());
|
||||||
|
buf.extend_from_slice(name);
|
||||||
|
// access_flags, this_class, super_class, interfaces_count
|
||||||
|
buf.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0]);
|
||||||
|
// fields_count, methods_count, attributes_count
|
||||||
|
buf.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
|
||||||
|
|
||||||
|
let cf = ClassFile::parse(&buf).expect("well-formed synthetic class file");
|
||||||
|
assert_eq!(cf.constant_pool.len(), 4);
|
||||||
|
// The Long occupies indices 1 AND 2 (its reserved tail slot).
|
||||||
|
// The Utf8 must resolve at index 3 = long_index(1) + 2, NOT +1.
|
||||||
|
assert_eq!(cf.constant_pool.utf8(3), Some("marker"));
|
||||||
|
// Index 2 is the reserved tail slot: not a Utf8, must not
|
||||||
|
// resolve as one (guards against the Utf8 landing one slot early).
|
||||||
|
assert_eq!(cf.constant_pool.utf8(2), None);
|
||||||
|
match cf.constant_pool.get(1) {
|
||||||
|
Some(CpInfo::Long(v)) => assert_eq!(*v, 0x1122_3344_5566_7788u64 as i64),
|
||||||
|
other => panic!("expected Long at index 1, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// instruction_size: tableswitch/lookupswitch with non-degenerate
|
||||||
|
// low/high/npairs (the existing tests only cover low==high==0 and
|
||||||
|
// npairs==0, which can't distinguish `-` from `+` in the entry-count
|
||||||
|
// arithmetic).
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instruction_size_tableswitch_non_degenerate_range() {
|
||||||
|
// low=1, high=4 -> 4 entries (high-low+1 = 4). A `-`->`+` mutation
|
||||||
|
// on that arithmetic would instead compute high+low+1 = 6.
|
||||||
|
let mut code = vec![TABLESWITCH];
|
||||||
|
code.extend_from_slice(&[0, 0, 0]); // padding
|
||||||
|
code.extend_from_slice(&[0, 0, 0, 0]); // default offset
|
||||||
|
code.extend_from_slice(&1i32.to_be_bytes()); // low = 1
|
||||||
|
code.extend_from_slice(&4i32.to_be_bytes()); // high = 4
|
||||||
|
code.extend_from_slice(&[0; 16]); // 4 jump entries * 4 bytes
|
||||||
|
// total = 1 (opcode) + 3 (pad) + 12 (default/low/high) + 16 (entries) = 32
|
||||||
|
assert_eq!(instruction_size(&code, 0), Some(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instruction_size_lookupswitch_non_degenerate_npairs() {
|
||||||
|
// npairs = 3 -> 3 * 8 = 24 bytes of pairs.
|
||||||
|
let mut code = vec![LOOKUPSWITCH];
|
||||||
|
code.extend_from_slice(&[0, 0, 0]); // padding
|
||||||
|
code.extend_from_slice(&[0, 0, 0, 0]); // default
|
||||||
|
code.extend_from_slice(&3i32.to_be_bytes()); // npairs = 3
|
||||||
|
code.extend_from_slice(&[0; 24]); // 3 pairs
|
||||||
|
// total = 1 + 3 + 8 (default/npairs) + 24 = 36
|
||||||
|
assert_eq!(instruction_size(&code, 0), Some(36));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2363,3 +2363,225 @@ mod fill_gaps_sort_tests {
|
|||||||
assert_eq!(framework[1].stream_number, 1);
|
assert_eq!(framework[1].stream_number, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── append_clpi_orphans ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod clpi_orphan_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::udf::fixture::*;
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a CLPI ProgramInfo section for one program with the given
|
||||||
|
/// (pid, stream_coding_info) pairs. Layout mirrors
|
||||||
|
/// `crate::clpi::parse_program_info`'s expectations: length(4) +
|
||||||
|
/// reserved(1) + num_programs(1), then per-program
|
||||||
|
/// spn(4)+pmt_pid(2)+num_streams(1)+num_groups(1), then per-stream
|
||||||
|
/// pid(2)+sci_len(1)+sci.
|
||||||
|
fn build_program_info(streams: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||||||
|
let mut body = Vec::new();
|
||||||
|
body.push(0); // reserved
|
||||||
|
body.push(1); // num_programs = 1
|
||||||
|
body.extend_from_slice(&0u32.to_be_bytes()); // spn_program_sequence_start
|
||||||
|
body.extend_from_slice(&0u16.to_be_bytes()); // program_map_pid
|
||||||
|
body.push(streams.len() as u8); // num_streams
|
||||||
|
body.push(0); // num_groups
|
||||||
|
for (pid, sci) in streams {
|
||||||
|
body.extend_from_slice(&pid.to_be_bytes());
|
||||||
|
body.push(sci.len() as u8);
|
||||||
|
body.extend_from_slice(sci);
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
out.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||||
|
out.extend_from_slice(&body);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a full CLPI byte buffer (HDMV header + ProgramInfo) declaring
|
||||||
|
/// the given (pid, coding_type, lang) streams. `sci` layout follows
|
||||||
|
/// `crate::clpi::parse_program_info`'s per-coding-type match arms:
|
||||||
|
/// PG/IG = coding_type + 3-byte lang; audio (primary or secondary) =
|
||||||
|
/// coding_type + format/rate byte + 3-byte lang.
|
||||||
|
fn build_clpi(streams: &[(u16, u8, &str)]) -> Vec<u8> {
|
||||||
|
use crate::consts::coding_type as c;
|
||||||
|
let sci_streams: Vec<(u16, Vec<u8>)> = streams
|
||||||
|
.iter()
|
||||||
|
.map(|(pid, coding, lang)| {
|
||||||
|
let lang_bytes = lang.as_bytes();
|
||||||
|
let sci = match *coding {
|
||||||
|
c::PG | c::IG => {
|
||||||
|
let mut v = vec![*coding];
|
||||||
|
v.extend_from_slice(lang_bytes);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let mut v = vec![*coding, 0x61];
|
||||||
|
v.extend_from_slice(lang_bytes);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(*pid, sci)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let pi = build_program_info(&sci_streams);
|
||||||
|
let mut buf = vec![0u8; 60];
|
||||||
|
buf[0..4].copy_from_slice(b"HDMV");
|
||||||
|
buf[4..8].copy_from_slice(b"0200");
|
||||||
|
let prog_info_start: u32 = 60;
|
||||||
|
buf[12..16].copy_from_slice(&prog_info_start.to_be_bytes());
|
||||||
|
buf[56..60].copy_from_slice(&1000u32.to_be_bytes()); // source_packet_count
|
||||||
|
buf.extend_from_slice(&pi);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lay a minimal BDMV/CLIPINF/00001.clpi tree on `disc`, with the CLPI
|
||||||
|
/// declaring the given synthetic streams, and return the parsed UdfFs.
|
||||||
|
fn fs_with_clpi(disc: &mut MemDisc, streams: &[(u16, u8, &str)]) -> crate::udf::UdfFs {
|
||||||
|
let clpi_data = build_clpi(streams);
|
||||||
|
let clipinf = DirSpec {
|
||||||
|
name: "CLIPINF".to_string(),
|
||||||
|
icb_lba: 24,
|
||||||
|
dir_data_lba: 25,
|
||||||
|
files: vec![file_with("00001.clpi", 26, 8000, clpi_data, false)],
|
||||||
|
subdirs: vec![],
|
||||||
|
};
|
||||||
|
let bdmv = DirSpec {
|
||||||
|
name: "BDMV".to_string(),
|
||||||
|
icb_lba: 20,
|
||||||
|
dir_data_lba: 21,
|
||||||
|
files: Vec::new(),
|
||||||
|
subdirs: vec![clipinf],
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
crate::udf::read_filesystem(disc).expect("fs")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (a) A PG-coded CLPI orphan becomes a Subtitle label.
|
||||||
|
#[test]
|
||||||
|
fn pg_orphan_becomes_subtitle() {
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
let udf = fs_with_clpi(
|
||||||
|
&mut disc,
|
||||||
|
&[(0x1200, crate::consts::coding_type::PG, "eng")],
|
||||||
|
);
|
||||||
|
let mut labels: Vec<StreamLabel> = Vec::new();
|
||||||
|
let added = append_clpi_orphans(&mut labels, &mut disc, &udf);
|
||||||
|
assert_eq!(added, 1);
|
||||||
|
assert_eq!(labels.len(), 1);
|
||||||
|
assert_eq!(labels[0].stream_type, StreamLabelType::Subtitle);
|
||||||
|
assert_eq!(labels[0].stream_number, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (b) An audio-range-coded orphan (here DTS-HD MA, the top of the
|
||||||
|
/// `LPCM..=DTS_HD_MA` primary-audio range) becomes an Audio label.
|
||||||
|
#[test]
|
||||||
|
fn audio_range_orphan_becomes_audio() {
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
let udf = fs_with_clpi(
|
||||||
|
&mut disc,
|
||||||
|
&[(0x1100, crate::consts::coding_type::DTS_HD_MA, "eng")],
|
||||||
|
);
|
||||||
|
let mut labels: Vec<StreamLabel> = Vec::new();
|
||||||
|
let added = append_clpi_orphans(&mut labels, &mut disc, &udf);
|
||||||
|
assert_eq!(added, 1);
|
||||||
|
assert_eq!(labels[0].stream_type, StreamLabelType::Audio);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (b, secondary) AC3_PLUS_SECONDARY is outside the primary
|
||||||
|
/// `LPCM..=DTS_HD_MA` range and must be classified through the
|
||||||
|
/// dedicated secondary-audio arm.
|
||||||
|
#[test]
|
||||||
|
fn secondary_audio_orphan_becomes_audio() {
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
let udf = fs_with_clpi(
|
||||||
|
&mut disc,
|
||||||
|
&[(
|
||||||
|
0x1A00,
|
||||||
|
crate::consts::coding_type::AC3_PLUS_SECONDARY,
|
||||||
|
"eng",
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
let mut labels: Vec<StreamLabel> = Vec::new();
|
||||||
|
let added = append_clpi_orphans(&mut labels, &mut disc, &udf);
|
||||||
|
assert_eq!(added, 1);
|
||||||
|
assert_eq!(labels[0].stream_type, StreamLabelType::Audio);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (c) IG (0x91, BD-J menu overlay) is not a user-facing subtitle and
|
||||||
|
/// must be skipped entirely, not appended as anything.
|
||||||
|
#[test]
|
||||||
|
fn ig_orphan_is_skipped() {
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
let udf = fs_with_clpi(
|
||||||
|
&mut disc,
|
||||||
|
&[(0x1201, crate::consts::coding_type::IG, "eng")],
|
||||||
|
);
|
||||||
|
let mut labels: Vec<StreamLabel> = Vec::new();
|
||||||
|
let added = append_clpi_orphans(&mut labels, &mut disc, &udf);
|
||||||
|
assert_eq!(added, 0);
|
||||||
|
assert!(labels.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (d) Numbering continues from `max(existing) + 1` and increments once
|
||||||
|
/// per new orphan stream, independently of PID order.
|
||||||
|
#[test]
|
||||||
|
fn numbering_continues_from_max_existing_and_increments() {
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
let udf = fs_with_clpi(
|
||||||
|
&mut disc,
|
||||||
|
&[
|
||||||
|
(0x1100, crate::consts::coding_type::TRUEHD, "eng"),
|
||||||
|
(0x1101, crate::consts::coding_type::AC3, "fra"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let mut labels = vec![label(StreamLabelType::Audio, 3, "jpn", "DTS")];
|
||||||
|
let added = append_clpi_orphans(&mut labels, &mut disc, &udf);
|
||||||
|
assert_eq!(added, 2);
|
||||||
|
let mut nums: Vec<u16> = labels
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.stream_type == StreamLabelType::Audio && l.language != "jpn")
|
||||||
|
.map(|l| l.stream_number)
|
||||||
|
.collect();
|
||||||
|
nums.sort();
|
||||||
|
assert_eq!(
|
||||||
|
nums,
|
||||||
|
vec![4, 5],
|
||||||
|
"orphans must number 4 and 5 after the existing max of 3"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (e) A CLPI stream whose (type, language, codec) tuple already exists
|
||||||
|
/// in `existing` is a duplicate and must be skipped, not double-listed.
|
||||||
|
#[test]
|
||||||
|
fn duplicate_type_lang_codec_already_in_existing_is_skipped() {
|
||||||
|
let mut disc = MemDisc::new();
|
||||||
|
let udf = fs_with_clpi(
|
||||||
|
&mut disc,
|
||||||
|
&[(0x1100, crate::consts::coding_type::TRUEHD, "eng")],
|
||||||
|
);
|
||||||
|
let mut labels = vec![label(StreamLabelType::Audio, 1, "eng", "TrueHD")];
|
||||||
|
let added = append_clpi_orphans(&mut labels, &mut disc, &udf);
|
||||||
|
assert_eq!(added, 0);
|
||||||
|
assert_eq!(labels.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2272,6 +2272,56 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The wrap-vs-backstep test above (`cra_after_33bit_pts_wrap_not_rewritten`)
|
||||||
|
/// keeps `high_pts` near the very top of the 33-bit range, which is close
|
||||||
|
/// enough to the `PTS_WRAP_PERIOD / 2` threshold that `high - unwrapped`
|
||||||
|
/// and a hand-flipped `high + unwrapped` land on the SAME side of the
|
||||||
|
/// threshold at every step in that sequence — it does not actually
|
||||||
|
/// distinguish the two. This test uses PTS magnitudes around 3e9 (order
|
||||||
|
/// 2^32, well below the wrap threshold but large enough that a real
|
||||||
|
/// stream reaches it in well under an hour), where the subtraction and
|
||||||
|
/// the addition diverge: ordinary forward progression keeps
|
||||||
|
/// `high - unwrapped` small (no bogus wrap), but `high + unwrapped`
|
||||||
|
/// already exceeds `PTS_WRAP_PERIOD / 2` on the very next frame, which
|
||||||
|
/// pollutes `pts_wrap_offset` by a full period. That pollution then
|
||||||
|
/// masks the GENUINE two-clip splice that follows: the splice's small
|
||||||
|
/// reset PTS gets `+= pts_wrap_offset` and lands ABOVE the (also
|
||||||
|
/// polluted) high-water mark instead of below it, so the backward-step
|
||||||
|
/// detector never fires and the splice CRA is wrongly left as CRA
|
||||||
|
/// instead of being rewritten to BLA_W_LP.
|
||||||
|
#[test]
|
||||||
|
fn cra_splice_detected_at_large_pts_magnitude_not_masked_by_wrap_logic() {
|
||||||
|
let mut parser = HevcParser::new();
|
||||||
|
// Clip 1: two ordinary forward-progressing frames at ~3e9 ticks
|
||||||
|
// (order 2^32, comfortably below PTS_WRAP_PERIOD/2 = 2^32 exactly,
|
||||||
|
// and far from the actual 2^33 wrap point).
|
||||||
|
let clip1_base = 3_000_000_000i64;
|
||||||
|
parser.parse(&make_pes(cra_au(&[0x01]), Some(clip1_base)));
|
||||||
|
let dip = parser.parse(&make_pes(cra_au(&[0x02]), Some(clip1_base + 3750)));
|
||||||
|
assert_eq!(
|
||||||
|
nal_type_of(&nals_of(&dip[0].data)[0]),
|
||||||
|
NAL_CRA_NUT,
|
||||||
|
"ordinary forward progression at large PTS magnitude must not itself \
|
||||||
|
be mistaken for anything"
|
||||||
|
);
|
||||||
|
// Clip 2 splice: PES PTS resets to a small new-clip base — a genuine,
|
||||||
|
// large (~3e9-tick) backward step that is NOT a 2^33 wrap (the
|
||||||
|
// backward delta here is far short of PTS_WRAP_PERIOD/2).
|
||||||
|
let splice = parser.parse(&make_pes(cra_au(&[0x03]), Some(500)));
|
||||||
|
assert_eq!(
|
||||||
|
nal_type_of(&nals_of(&splice[0].data)[0]),
|
||||||
|
NAL_BLA_W_LP,
|
||||||
|
"a genuine large backward PTS reset at this magnitude must still be \
|
||||||
|
detected as a clip splice and rewrite the CRA to BLA_W_LP"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
splice[0].pts_ns,
|
||||||
|
pts_to_ns(500),
|
||||||
|
"the emitted PTS must be the raw splice-clip PTS, unaffected by the \
|
||||||
|
internal unwrap bookkeeping"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Test 3: non-CRA NALs are never rewritten even when a boundary IS marked.
|
/// Test 3: non-CRA NALs are never rewritten even when a boundary IS marked.
|
||||||
/// IDR (19), RASL (8/9), VPS/SPS/PPS, and a trailing slice all pass through
|
/// IDR (19), RASL (8/9), VPS/SPS/PPS, and a trailing slice all pass through
|
||||||
/// unmodified; the IDR clears the pending boundary so no later CRA is wrongly
|
/// unmodified; the IDR clears the pending boundary so no later CRA is wrongly
|
||||||
@@ -3345,6 +3395,61 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The hvcC array length is a 16-bit big-endian field written as two
|
||||||
|
/// separate `push`es: `(len >> 8) as u8` then `len as u8`. Every VPS/SPS/
|
||||||
|
/// PPS the rest of the suite feeds is well under 256 bytes, so the high
|
||||||
|
/// byte is always 0 and a `>>` -> `<<` mutation (which also always
|
||||||
|
/// truncates to 0 for those inputs, since `(len << 8) as u8` masks off
|
||||||
|
/// exactly the low 8 bits) is unobservable there. A real HEVC SPS with an
|
||||||
|
/// extended VUI/HRD block can exceed 256 bytes, so use a 300+ byte VPS,
|
||||||
|
/// SPS and PPS here and decode the 16-bit length fields back to confirm
|
||||||
|
/// they round-trip to the exact NAL length, not merely a byte that
|
||||||
|
/// happens to be 0.
|
||||||
|
#[test]
|
||||||
|
fn hvcc_array_length_round_trips_above_256_bytes() {
|
||||||
|
let mut parser = HevcParser::new();
|
||||||
|
let mut data = Vec::new();
|
||||||
|
// VPS: 2-byte NAL header + 300 filler bytes -> NAL length 302.
|
||||||
|
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||||
|
data.extend_from_slice(&hevc_nal_header(32));
|
||||||
|
data.extend_from_slice(&vec![0x11u8; 300]);
|
||||||
|
// SPS: same size.
|
||||||
|
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||||
|
data.extend_from_slice(&hevc_nal_header(33));
|
||||||
|
data.extend_from_slice(&vec![0x11u8; 300]);
|
||||||
|
// PPS: same size.
|
||||||
|
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||||
|
data.extend_from_slice(&hevc_nal_header(34));
|
||||||
|
data.extend_from_slice(&vec![0x11u8; 300]);
|
||||||
|
parser.parse(&make_pes(data, Some(0)));
|
||||||
|
let cp = parser.codec_private().expect("hvcC");
|
||||||
|
|
||||||
|
let expected_nal_len = 2 + 300; // NAL header + payload
|
||||||
|
let mut o = 23; // past the 23-byte fixed header
|
||||||
|
assert_eq!(cp[o], 0x20 | 32, "VPS array nal_type byte");
|
||||||
|
let vps_len = u16::from_be_bytes([cp[o + 3], cp[o + 4]]) as usize;
|
||||||
|
assert_eq!(
|
||||||
|
vps_len, expected_nal_len,
|
||||||
|
"VPS length must round-trip above 256 bytes"
|
||||||
|
);
|
||||||
|
o += 5 + vps_len;
|
||||||
|
|
||||||
|
assert_eq!(cp[o], 0x20 | 33, "SPS array nal_type byte");
|
||||||
|
let sps_len = u16::from_be_bytes([cp[o + 3], cp[o + 4]]) as usize;
|
||||||
|
assert_eq!(
|
||||||
|
sps_len, expected_nal_len,
|
||||||
|
"SPS length must round-trip above 256 bytes"
|
||||||
|
);
|
||||||
|
o += 5 + sps_len;
|
||||||
|
|
||||||
|
assert_eq!(cp[o], 0x20 | 34, "PPS array nal_type byte");
|
||||||
|
let pps_len = u16::from_be_bytes([cp[o + 3], cp[o + 4]]) as usize;
|
||||||
|
assert_eq!(
|
||||||
|
pps_len, expected_nal_len,
|
||||||
|
"PPS length must round-trip above 256 bytes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// HEVC counterpart of `h264_ps_reorder_reconstructs_distinct_display_pts`.
|
/// HEVC counterpart of `h264_ps_reorder_reconstructs_distinct_display_pts`.
|
||||||
///
|
///
|
||||||
/// A DVD/HD-DVD program stream stamps a PTS only on each GOP anchor, so the
|
/// A DVD/HD-DVD program stream stamps a PTS only on each GOP anchor, so the
|
||||||
|
|||||||
@@ -1816,4 +1816,36 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(parsed.data, es);
|
assert_eq!(parsed.data, es);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A length-bounded PES (`pes_packet_len != 0`) must be emitted the
|
||||||
|
/// moment its declared length is EXACTLY satisfied by the buffer
|
||||||
|
/// (`sc + 6 > len`, then `e = sc + 6 + pes_packet_len; e > len`), not
|
||||||
|
/// held back waiting for a byte that will never arrive. Feed nothing
|
||||||
|
/// after the packet and don't flush — if the boundary checks were
|
||||||
|
/// `>=` instead of `>`, an exact fit would incorrectly be treated as
|
||||||
|
/// "not enough data yet" and the packet would never be produced.
|
||||||
|
#[test]
|
||||||
|
fn length_bounded_pes_exact_fit_is_emitted_not_awaited() {
|
||||||
|
let mut demuxer = PsDemuxer::new();
|
||||||
|
let payload = [0x11u8, 0x22, 0x33, 0x44, 0x55];
|
||||||
|
let mut data = vec![0x00, 0x00, 0x01, 0xC0]; // audio stream id
|
||||||
|
let pes_packet_len = (3 + payload.len()) as u16; // flags+header_len byte + payload
|
||||||
|
data.extend_from_slice(&pes_packet_len.to_be_bytes());
|
||||||
|
data.extend_from_slice(&[0x80, 0x00, 0x00]); // no PTS/DTS, header_data_len = 0
|
||||||
|
data.extend_from_slice(&payload);
|
||||||
|
assert_eq!(data.len(), 6 + pes_packet_len as usize, "sanity: exact fit");
|
||||||
|
|
||||||
|
let packets = demuxer.feed(&data);
|
||||||
|
assert_eq!(
|
||||||
|
packets.len(),
|
||||||
|
1,
|
||||||
|
"an exact-fit length-bounded PES must be emitted immediately, \
|
||||||
|
not held awaiting a byte that will never come"
|
||||||
|
);
|
||||||
|
assert_eq!(packets[0].data, payload);
|
||||||
|
assert!(
|
||||||
|
demuxer.buffer.is_empty(),
|
||||||
|
"the exact-fit PES must be fully consumed, leaving nothing buffered"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-17
@@ -76,6 +76,27 @@ fn bsd_name_of(device: &Path) -> Result<&str> {
|
|||||||
.unwrap_or(dev_str))
|
.unwrap_or(dev_str))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Map a `shim_open_exclusive` failure sentinel (a negative return code, NOT
|
||||||
|
/// an `IOReturn`) to the typed [`Error`] variant it represents. Pulled out of
|
||||||
|
/// [`MacScsiTransport::open`] as its own callable predicate so the mapping —
|
||||||
|
/// otherwise reachable only through a real IOKit FFI call — can be pinned by
|
||||||
|
/// a test: collapsing `-4..=-2` or `-5` into the `DeviceNotFound` catch-all
|
||||||
|
/// would silently turn "another process holds the drive" into "no such
|
||||||
|
/// drive", or "the IOKit plugin chain failed" into the same.
|
||||||
|
fn map_shim_open_error(rc: i32, path: String) -> Error {
|
||||||
|
match rc {
|
||||||
|
// -2/-3/-4: IOCreatePlugInInterfaceForService /
|
||||||
|
// QueryInterface MMCDeviceInterface /
|
||||||
|
// GetSCSITaskDeviceInterface failed.
|
||||||
|
-4..=-2 => Error::IoKitPluginFailed { path, kr: 0 },
|
||||||
|
// -5: ObtainExclusiveAccess failed (held by another
|
||||||
|
// process).
|
||||||
|
-5 => Error::DeviceLocked { path, kr: 0 },
|
||||||
|
// -1 and anything else: device not present.
|
||||||
|
_ => Error::DeviceNotFound { path },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct MacScsiTransport {
|
pub struct MacScsiTransport {
|
||||||
_bsd_name: String,
|
_bsd_name: String,
|
||||||
}
|
}
|
||||||
@@ -103,22 +124,7 @@ impl MacScsiTransport {
|
|||||||
// Release the single-instance lock taken by the OPEN.swap above;
|
// Release the single-instance lock taken by the OPEN.swap above;
|
||||||
// a failed open must not leave it held or every later open wedges.
|
// a failed open must not leave it held or every later open wedges.
|
||||||
OPEN.store(false, Ordering::Release);
|
OPEN.store(false, Ordering::Release);
|
||||||
let path = bsd_name.to_string();
|
return Err(map_shim_open_error(rc, bsd_name.to_string()));
|
||||||
// The shim returns distinct negative sentinels per failure
|
|
||||||
// stage; map them to the typed variants that already exist
|
|
||||||
// rather than collapsing every failure to DeviceNotFound.
|
|
||||||
// These sentinels are not IOReturn codes, so kr is left 0.
|
|
||||||
return Err(match rc {
|
|
||||||
// -2/-3/-4: IOCreatePlugInInterfaceForService /
|
|
||||||
// QueryInterface MMCDeviceInterface /
|
|
||||||
// GetSCSITaskDeviceInterface failed.
|
|
||||||
-4..=-2 => Error::IoKitPluginFailed { path, kr: 0 },
|
|
||||||
// -5: ObtainExclusiveAccess failed (held by another
|
|
||||||
// process).
|
|
||||||
-5 => Error::DeviceLocked { path, kr: 0 },
|
|
||||||
// -1 and anything else: device not present.
|
|
||||||
_ => Error::DeviceNotFound { path },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(MacScsiTransport {
|
Ok(MacScsiTransport {
|
||||||
@@ -306,7 +312,9 @@ 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, cstr_to_str, drive_has_disc};
|
use super::{
|
||||||
|
K_MAX_CDB_SIZE, OPEN, bsd_name_of, cstr_to_str, drive_has_disc, map_shim_open_error,
|
||||||
|
};
|
||||||
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;
|
||||||
@@ -378,6 +386,37 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every negative sentinel `shim_open_exclusive` can return must map to
|
||||||
|
/// its own distinct typed error, not collapse into the `DeviceNotFound`
|
||||||
|
/// catch-all. `-2..=-4` (IOKit plugin chain) and `-5` (exclusive access
|
||||||
|
/// held elsewhere) are the two that a deleted match arm would silently
|
||||||
|
/// fold into "no such drive".
|
||||||
|
#[test]
|
||||||
|
fn map_shim_open_error_distinguishes_every_sentinel() {
|
||||||
|
for rc in [-2, -3, -4] {
|
||||||
|
match map_shim_open_error(rc, "disk4".into()) {
|
||||||
|
Error::IoKitPluginFailed { path, kr } => {
|
||||||
|
assert_eq!(path, "disk4");
|
||||||
|
assert_eq!(kr, 0);
|
||||||
|
}
|
||||||
|
other => panic!("rc={rc}: expected IoKitPluginFailed, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match map_shim_open_error(-5, "disk4".into()) {
|
||||||
|
Error::DeviceLocked { path, kr } => {
|
||||||
|
assert_eq!(path, "disk4");
|
||||||
|
assert_eq!(kr, 0);
|
||||||
|
}
|
||||||
|
other => panic!("expected DeviceLocked, got {other:?}"),
|
||||||
|
}
|
||||||
|
for rc in [-1, -6, i32::MIN] {
|
||||||
|
match map_shim_open_error(rc, "disk4".into()) {
|
||||||
|
Error::DeviceNotFound { path } => assert_eq!(path, "disk4"),
|
||||||
|
other => panic!("rc={rc}: expected DeviceNotFound, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A CDB longer than K_MAX_CDB_SIZE must be rejected with
|
/// A CDB longer than K_MAX_CDB_SIZE must be rejected with
|
||||||
/// `Error::InvalidCdbLength` before the shim is ever called. Exercises the
|
/// `Error::InvalidCdbLength` before the shim is ever called. Exercises the
|
||||||
/// real guard `MacScsiTransport::execute` uses, without opening an IOKit
|
/// real guard `MacScsiTransport::execute` uses, without opening an IOKit
|
||||||
|
|||||||
Reference in New Issue
Block a user