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
|
||||
// detects forced while muxing, without a second read of the clip.
|
||||
if opts.probe_forced_subtitles {
|
||||
// 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 &mut titles {
|
||||
if title.content_format == ContentFormat::BdTs {
|
||||
pgs_forced_probe::probe_and_set_forced(
|
||||
reader,
|
||||
title,
|
||||
&mut cache,
|
||||
opts.halt.as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Self::probe_forced_subtitles_for_bdts_titles(reader, &mut titles, opts.halt.as_ref());
|
||||
}
|
||||
crate::labels::fill_defaults(&mut titles);
|
||||
|
||||
@@ -2184,6 +2169,35 @@ impl Disc {
|
||||
pub const CANONICAL_TITLE_ORDER_KEYS: &'static [&'static str] =
|
||||
&["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(
|
||||
a: &DiscTitle,
|
||||
b: &DiscTitle,
|
||||
@@ -3272,6 +3286,321 @@ mod tests {
|
||||
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) ────────────
|
||||
|
||||
/// `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.
|
||||
/// 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
|
||||
@@ -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 ────────────────────────────
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
#[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 ─────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1546,4 +1546,181 @@ mod tests {
|
||||
assert_eq!(cf.member_descriptor(&m), Some("()V"));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
/// 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
|
||||
@@ -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`.
|
||||
///
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
_bsd_name: String,
|
||||
}
|
||||
@@ -103,22 +124,7 @@ impl MacScsiTransport {
|
||||
// Release the single-instance lock taken by the OPEN.swap above;
|
||||
// a failed open must not leave it held or every later open wedges.
|
||||
OPEN.store(false, Ordering::Release);
|
||||
let path = 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 },
|
||||
});
|
||||
return Err(map_shim_open_error(rc, bsd_name.to_string()));
|
||||
}
|
||||
|
||||
Ok(MacScsiTransport {
|
||||
@@ -306,7 +312,9 @@ pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
|
||||
|
||||
#[cfg(test)]
|
||||
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 std::path::Path;
|
||||
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
|
||||
/// `Error::InvalidCdbLength` before the shim is ever called. Exercises the
|
||||
/// real guard `MacScsiTransport::execute` uses, without opening an IOKit
|
||||
|
||||
Reference in New Issue
Block a user