diff --git a/src/disc/extract.rs b/src/disc/extract.rs index fbc74de..060527d 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -217,7 +217,7 @@ impl Disc { )) } DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new( - self.resolve_content_key_map(reader, &mut base_keys, None)?, + self.resolve_content_key_map(reader, &mut base_keys, None, opts.halt.as_ref())?, )), _ => None, }; diff --git a/src/disc/mod.rs b/src/disc/mod.rs index c570556..afcf98f 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -2401,11 +2401,18 @@ impl Disc { reader: &mut dyn SectorSource, keys: &mut crate::decrypt::DecryptKeys, fetch: Option<&crate::sector::KeyFetch>, + halt: Option<&crate::halt::Halt>, ) -> Result { let mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new(); for title in &self.titles { - let map = - crate::mux::resolve_mux_key_map(reader, title, keys, fetch, self.content_format)?; + let map = crate::mux::resolve_mux_key_map( + reader, + title, + keys, + fetch, + self.content_format, + halt, + )?; ranges.extend_from_slice(map.ranges()); } Ok(crate::decrypt::AacsKeyMap::from_ranges_phased( @@ -3206,10 +3213,12 @@ impl Disc { // separate content gate is needed. CSS keeps the content-gated // self-descramble path (the map path is AACS-only). let key_map = if opts.decrypt && decrypt_is_aacs { + let halt = opts.halt.clone().map(crate::halt::Halt::from_arc); Some(std::sync::Arc::new(self.resolve_content_key_map( reader, &mut keys, opts.key_fetch.as_ref(), + halt.as_ref(), )?)) } else { None diff --git a/src/disc/patch.rs b/src/disc/patch.rs index 0207643..76e948a 100644 --- a/src/disc/patch.rs +++ b/src/disc/patch.rs @@ -1339,10 +1339,12 @@ impl Disc { // via the map (identical to `Disc::sweep`). CSS keeps the content-gated // self-descramble path. (Multipass patch is `--raw`, so decrypt is a no-op.) let key_map = if opts.decrypt && decrypt_is_aacs { + let halt = opts.halt.clone().map(crate::halt::Halt::from_arc); Some(std::sync::Arc::new(self.resolve_content_key_map( reader, &mut keys, opts.key_fetch.as_ref(), + halt.as_ref(), )?)) } else { None diff --git a/src/error.rs b/src/error.rs index 66f919a..937ff04 100644 --- a/src/error.rs +++ b/src/error.rs @@ -882,15 +882,13 @@ pub type Result = std::result::Result; /// The numeric error code carried by an [`io::Error`](std::io::Error) that was /// produced from an [`Error`], or `None` if it carries none. /// -/// Two shapes are recognised: an `io::Error` that still wraps the typed -/// [`Error`] (via `io::Error::new(kind, Error)`), and the round-tripped form -/// produced by [`From for io::Error`] whose message is the `Error`'s -/// `E[: …]` [`Display`](std::fmt::Display) string. +/// [`From for io::Error`] is the ONLY path from a typed [`Error`] to an +/// `io::Error` in this crate, and it stringifies (`io::Error::new(kind, msg)` +/// where `msg` is the `Error`'s `E[: …]` [`Display`](std::fmt::Display) +/// string) rather than boxing the typed value — no code path constructs an +/// `io::Error` that still holds a `crate::error::Error` via `get_ref`. So the +/// only recognised shape is the round-tripped `E` message prefix. fn io_error_code(e: &std::io::Error) -> Option { - // Direct: the io::Error still holds the typed Error. - if let Some(err) = e.get_ref().and_then(|r| r.downcast_ref::()) { - return Some(err.code()); - } // Round-tripped: `From for io::Error` stringifies as "E[: …]". let s = e.to_string(); let digits = s.strip_prefix('E')?; diff --git a/src/mux/driver.rs b/src/mux/driver.rs index c685198..0f04902 100644 --- a/src/mux/driver.rs +++ b/src/mux/driver.rs @@ -344,6 +344,7 @@ pub fn mux_stream( session.key_fetch(), format, opts.raw, + Some(halt), )?; let mut stream = crate::mux::DiscStream::new( reader, @@ -395,6 +396,7 @@ pub fn mux_stream( None, format, opts.raw, + Some(halt), )?, }; // INLINE `DiscStream` — the same constructor the `Session` arm uses, @@ -514,11 +516,17 @@ fn resolve_inline_base_map( fetch: Option<&KeyFetch>, format: crate::disc::ContentFormat, raw: bool, + halt: Option<&crate::halt::Halt>, ) -> std::io::Result>> { if raw || !matches!(keys, DecryptKeys::Aacs { .. }) { return Ok(None); } - let map = resolve_mux_key_map(reader, title, keys, fetch, format)?; + // Thread the driver's cancel token into the live-drive key resolution: the + // resolve chain samples ciphertext off the LIVE reader (the FMTS probe can do + // hundreds of reads, each able to stall to the SCSI recovery timeout), so an + // operator `/api/stop` mid-resolution must be honored here — not only once the + // read loop starts. + let map = resolve_mux_key_map(reader, title, keys, fetch, format, halt)?; Ok(Some(Arc::new(map))) } @@ -1547,6 +1555,149 @@ mod tests { ); } + /// Build a synthetic single-CPS AACS `Disc` carrying `unit_key` and one + /// title whose sole extent is the encrypted unit at LBA 0..3 — the disc a + /// `MuxInput::Session` mux scans off a live drive, minus the hardware. + fn aacs_session_disc(title: DiscTitle, unit_key: [u8; 16]) -> crate::disc::Disc { + crate::disc::Disc { + volume_id: "TEST".into(), + meta_title: None, + format: crate::DiscFormat::Uhd, + capacity_sectors: 0, + capacity_bytes: 0, + layers: 1, + titles: vec![title], + region: crate::disc::DiscRegion::Free, + aacs: Some(crate::disc::AacsState { + version: crate::aacs::mkb::AACS_MAJOR_UHD, + bus_encryption: false, + mkb_version: None, + disc_hash: "0xabc".into(), + key_source: crate::disc::KeyOrigin::KeyDb, + vuk: None, + unit_keys: vec![(0, unit_key)], + read_data_key: None, + volume_id: [0u8; 16], + uk_ro: Vec::new(), + mkb: Vec::new(), + }), + css: None, + encrypted: true, + aacs_error: None, + css_error: None, + content_format: crate::ContentFormat::BdTs, + } + } + + /// END-TO-END decrypt on the live single-pass `MuxInput::Session` path — the + /// exact shape of `freemkv rip disc://…mkv`. The `Session` arm runs the SAME + /// sequence as `Live` (take_reader → resolve_inline_base_map → DiscStream → + /// with_key_map), but until now had NO end-to-end coverage because a real + /// `DiscSession` needs a live `Drive`. Using the `#[cfg(test)]` + /// `from_parts_for_test` constructor, a genuinely-AACS-encrypted unit muxed + /// through the `Session` arm must resolve+install the base key map itself and + /// DECRYPT to a valid audio PES. + /// + /// Mutation: dropping `stream = stream.with_key_map(map)` (or the + /// resolve_inline_base_map call) in the `Session` arm leaves the reader + /// mapless → the content batch cannot decrypt → the mux aborts (`Err`) and + /// `out.completed` is never reached. + #[test] + fn mux_input_session_aacs_without_caller_map_resolves_and_decrypts() { + use crate::disc::Extent; + use crate::session::DiscSession; + + let unit_key = [0x5Au8; 16]; + let reader = Box::new(AacsUnitReader { + unit: encrypted_audio_unit(&unit_key), + capacity: 2048, + }); + let mut title = aac_audio_title(0x1100); + title.extents = vec![Extent { + start_lba: 0, + sector_count: 3, + }]; + let disc = aacs_session_disc(title, unit_key); + // No caller key_fetch: a single-CPS disc resolves its base map with the + // banked unit key alone (the FMTS/multi-CPS fetch path is not exercised). + let mut session = DiscSession::from_parts_for_test(disc, Some(reader), None); + + let opts = MuxOptions { + skip_errors: false, // a DecryptFailed must PROPAGATE, not zero-fill + batch_sectors: 3, // one aligned unit per read + raw: false, + send_deadline: Some(Duration::from_secs(60)), + }; + let halt = Halt::new(); + let out = mux_stream( + MuxInput::Session { + session: &mut session, + title_index: 0, + }, + "null://", + &opts, + &halt, + Arc::new(NoopEvents), + ) + .expect( + "a plain AACS Session mux must resolve+install its base key map and DECRYPT \ + (no map → the content batch fails to decrypt and the mux aborts)", + ); + assert!( + out.completed, + "the decrypted audio PES drained and finalised — proves the unit decrypted" + ); + assert!( + out.bytes_written > 0, + "decrypted payload bytes reached the sink" + ); + } + + /// A `MuxInput::Session` whose reader was never staged (`take_reader()` → + /// `None`) must surface a clean typed error, NOT panic — the boundary-audit + /// Q2 contract. Guards the `ok_or_else(|| Error::DeviceNotReady …)` in the + /// `Session` arm against a regression to `.expect()`/`.unwrap()`. + #[test] + fn mux_input_session_missing_reader_is_clean_error_not_panic() { + use crate::disc::Extent; + use crate::session::DiscSession; + + let unit_key = [0x5Au8; 16]; + let mut title = aac_audio_title(0x1100); + title.extents = vec![Extent { + start_lba: 0, + sector_count: 3, + }]; + let disc = aacs_session_disc(title, unit_key); + // reader: None — never staged. + let mut session = DiscSession::from_parts_for_test(disc, None, None); + + let opts = MuxOptions { + skip_errors: false, + batch_sectors: 3, + raw: false, + send_deadline: Some(Duration::from_secs(60)), + }; + let halt = Halt::new(); + let err = mux_stream( + MuxInput::Session { + session: &mut session, + title_index: 0, + }, + "null://", + &opts, + &halt, + Arc::new(NoopEvents), + ) + .expect_err("a missing staged reader must be a clean error, not a panic"); + // The device-name-carrying DeviceNotReady (code E4xxx) round-trips through + // io::Error; assert it is NOT a decrypt/other-shaped failure. + assert!( + err.to_string().starts_with('E'), + "expected a typed libfreemkv error (E…), got: {err}" + ); + } + /// The shared `resolve_inline_base_map` helper's gating: an AACS key set /// yields a map (Some); CSS/clear/None and `raw` yield None (CSS self-cracks /// in `DiscStream::new`; raw is ciphertext passthrough). Guards the Session @@ -1580,6 +1731,7 @@ mod tests { None, crate::disc::ContentFormat::BdTs, false, + None, ) .expect("resolve must not error for a single-CPS AACS disc"); assert!(map.is_some(), "AACS non-raw must resolve a base map"); @@ -1598,6 +1750,7 @@ mod tests { None, crate::disc::ContentFormat::BdTs, true, + None, ) .expect("raw resolve is a no-op"); assert!(map_raw.is_none(), "raw must NOT resolve a map"); @@ -1612,6 +1765,7 @@ mod tests { None, crate::disc::ContentFormat::MpegPs, false, + None, ) .expect("clear/CSS resolve is a no-op"); assert!(map_none.is_none(), "CSS/clear must NOT resolve an AACS map"); diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index cf8b6c4..a11586f 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -458,6 +458,9 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result, format: ContentFormat, + halt: Option<&crate::halt::Halt>, ) -> io::Result> { use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_encrypted, decrypt_unit, is_clean}; use crate::aacs::segment::{clip_byte_to_lba, parse_individual_segments}; + // Cooperative cancel: this probes the LIVE drive across up to a few hundred + // `read_sectors` (the anchor + per-index probe loops), each able to stall to + // the SCSI recovery timeout. An operator `/api/stop` during forensic key + // resolution must be honored at each loop boundary rather than blocking until + // the whole probe completes (hard rule: don't hammer a struggling live drive). + let check_halt = || -> io::Result<()> { + if halt.is_some_and(|h| h.is_cancelled()) { + return Err(crate::error::Error::Halted.into()); + } + Ok(()) + }; + // Load the segment map; absent → not an FMTS disc. let Ok(udf) = crate::udf::read_filesystem(reader) else { return Ok(None); @@ -855,6 +871,7 @@ fn resolve_fmts_key_map( .filter(|s| s.index == 1) .take(MAX_ANCHOR_ATTEMPTS) { + check_halt()?; for phase_off in [0usize, 1usize] { let Some(batch) = read_phase_batch(reader, seg, phase_off) else { continue; // read fault on this phase — try the other / next segment @@ -911,6 +928,7 @@ fn resolve_fmts_key_map( let mut phase_of_index: std::collections::HashMap = std::collections::HashMap::new(); for (i, k) in index_keys.iter().enumerate() { + check_halt()?; let tag = (i + 1) as u16; let Some(seg) = segments.iter().find(|s| s.index == tag) else { continue; // no segment carries this index on this feature — skip @@ -1132,6 +1150,7 @@ pub fn resolve_mux_key_map( keys: &mut crate::decrypt::DecryptKeys, fetch: Option<&crate::sector::KeyFetch>, format: ContentFormat, + halt: Option<&crate::halt::Halt>, ) -> io::Result { use crate::aacs::content::{ ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted, decrypt_unit, is_clean, @@ -1153,7 +1172,7 @@ pub fn resolve_mux_key_map( // front from the configured source and build a per-segment map. Returns `None` // when the disc is not FMTS, or no key source is configured (then the base UK // path below applies and the forensic units garble → demux drops them). - if let Some(map) = resolve_fmts_key_map(reader, title, keys, fetch, format)? { + if let Some(map) = resolve_fmts_key_map(reader, title, keys, fetch, format, halt)? { return Ok(map); } if pool_len == 1 { @@ -1204,6 +1223,11 @@ pub fn resolve_mux_key_map( let mut ranges: Vec<(u32, u32, usize)> = Vec::with_capacity(title.extents.len()); let mut last_idx = 0usize; for ext in &title.extents { + // Cooperative cancel between extents: multi-CPS sampling reads real + // content units off the live drive, so honor an operator stop here too. + if halt.is_some_and(|h| h.is_cancelled()) { + return Err(crate::error::Error::Halted.into()); + } let samples = sample_units(reader, ext.start_lba, ext.sector_count); // Snapshot the current pool for the pure `pick` closure. let pool: Vec<(u32, [u8; 16])> = match keys { @@ -1319,13 +1343,17 @@ pub fn build_iso_pipeline( // with its KNOWN key and trusts it: no per-unit `is_clean` verdict, no reactive // key-fetch, no key-server storm. A unit that decrypts to broken TS is the // muxer's problem, exactly as before. AACS-only; CSS self-cracks per region. - let key_map = - match &keys { - crate::decrypt::DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new( - resolve_mux_key_map(&mut reader, &title, &mut keys, fetch.as_ref(), format)?, - )), - _ => None, - }; + let key_map = match &keys { + crate::decrypt::DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new(resolve_mux_key_map( + &mut reader, + &title, + &mut keys, + fetch.as_ref(), + format, + halt.as_ref(), + )?)), + _ => None, + }; // The map IS the title's read plan: it says which CPS unit / forensic segment // each LBA belongs to. Walk ONLY the units it marks as ours — every default / // CPS unit, and inside an FMTS forensic segment only our-phase units. The @@ -1794,6 +1822,126 @@ mod tests { assert!(ps.is_none()); } + // ── Fix 1: halt threading into live-drive key resolution ─────────────── + + /// A counting `SectorSource` over zeros. `touched_extent` flags whether any + /// read landed in the title's extent region (LBA >= 1000); the UDF probe only + /// reads near LBA 256 (small `capacity`), so a hit there means the expensive + /// per-extent `sample_units` loop ran. + struct HaltCountSource { + reads: u32, + touched_extent: bool, + } + impl SectorSource for HaltCountSource { + fn capacity_sectors(&self) -> u32 { + 512 // keeps the UDF secondary anchor well below the extent region + } + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + _recovery: bool, + ) -> crate::error::Result { + self.reads += 1; + if lba >= 1000 { + self.touched_extent = true; + } + let want = count as usize * 2048; + buf[..want].fill(0); + Ok(want) + } + } + + /// `resolve_mux_key_map` on the multi-CPS live path must honor a pre-cancelled + /// halt PROMPTLY — `Err(Halted)` at the first extent boundary, before sampling + /// any extent's ciphertext — rather than reading through every extent. This is + /// the round-2 Fix 1 guard: the resolve chain runs on the LIVE drive (each + /// `read_sectors` can stall to the SCSI recovery timeout), so an operator Stop + /// during key resolution must interrupt it. + /// + /// Mutation: dropping the `halt.is_some_and(...) → Err(Halted)` check in the + /// multi-CPS extent loop makes the resolve run the sampling reads and return + /// `Ok(map)` (zeros sample to no encrypted units → carry key 0), so + /// `expect_err` fails AND `touched_extent` flips true. + #[test] + fn resolve_mux_key_map_honors_pre_cancelled_halt() { + use crate::halt::Halt; + let mut title = DiscTitle::empty(); + title.extents = vec![ + Extent { + start_lba: 1000, + sector_count: 300, + }, + Extent { + start_lba: 5000, + sector_count: 300, + }, + ]; + // Multi-CPS (pool_len = 2) → the extent-sampling loop is the resolve path + // (pool_len == 1 would short-circuit to content_map before any read). + let mut keys = DecryptKeys::Aacs { + unit_keys: vec![(0, [0x11u8; 16]), (1, [0x22u8; 16])], + read_data_key: None, + format: ContentFormat::BdTs, + }; + let mut reader = HaltCountSource { + reads: 0, + touched_extent: false, + }; + let halt = Halt::new(); + halt.cancel(); // pre-cancelled: the very first extent boundary must bail + + let err = super::resolve_mux_key_map( + &mut reader, + &title, + &mut keys, + None, + ContentFormat::BdTs, + Some(&halt), + ) + .expect_err("a pre-cancelled halt must abort key resolution"); + assert!(crate::error::is_halt(&err), "expected Halted, got: {err}"); + assert!( + !reader.touched_extent, + "extent sampling must be skipped on a pre-cancelled halt (a read landed \ + in the extent region — the halt check was not honored)" + ); + } + + /// A `None` halt (no token) must NOT abort — the resolve runs to completion. + /// Guards against a mutation that treats `None` as cancelled. + #[test] + fn resolve_mux_key_map_none_halt_does_not_abort() { + let mut title = DiscTitle::empty(); + title.extents = vec![Extent { + start_lba: 1000, + sector_count: 300, + }]; + let mut keys = DecryptKeys::Aacs { + unit_keys: vec![(0, [0x11u8; 16]), (1, [0x22u8; 16])], + read_data_key: None, + format: ContentFormat::BdTs, + }; + let mut reader = HaltCountSource { + reads: 0, + touched_extent: false, + }; + // No halt token → resolution proceeds and samples the extent (zeros → no + // encrypted unit → carries key 0), returning Ok. + let map = super::resolve_mux_key_map( + &mut reader, + &title, + &mut keys, + None, + ContentFormat::BdTs, + None, + ) + .expect("no halt → resolution completes"); + assert!(reader.touched_extent, "the extent WAS sampled with no halt"); + assert!(!map.ranges().is_empty(), "a map is produced for the extent"); + } + // ── build_iso_pipeline: end-to-end highway wiring ────────────────────── /// An in-memory SectorSource that serves a fixed byte image. Reads beyond diff --git a/src/session.rs b/src/session.rs index e5ea9c2..a41f43b 100644 --- a/src/session.rs +++ b/src/session.rs @@ -370,6 +370,32 @@ impl DiscSession { pub fn take_reader(&mut self) -> Option> { self.reader.take() } + + /// Test-only constructor: build a session over an INJECTED reader + already- + /// scanned disc WITHOUT opening a live [`Drive`]. `DiscSession::open` needs + /// real hardware, so this is the only way to exercise the + /// [`MuxInput::Session`](crate::mux::MuxInput::Session) mux arm (take_reader → + /// resolve_inline_base_map → DiscStream → with_key_map) and + /// [`Self::resolve_keys`]'s title-sampling branch against a synthetic reader. + /// + /// The drive slot stays `None` (a `MuxInput::Session` mux never touches it — + /// it reads through the staged `reader`); `device` carries a sentinel path so + /// the driver's missing-reader error still has a name. + #[cfg(test)] + pub(crate) fn from_parts_for_test( + disc: Disc, + reader: Option>, + key_fetch: Option, + ) -> DiscSession { + DiscSession { + drive: None, + device: "test://session".to_string(), + spec: KeySpec::default(), + disc: Some(disc), + reader, + key_fetch, + } + } } /// Scan an ISO image's structure from a file path, returning the scanned @@ -616,6 +642,71 @@ mod tests { ); } + /// A counting reader over zeros — records the highest LBA sampled so the test + /// can prove the LARGEST title's extent (not the small one) was read. + struct SamplingReader { + reads: u32, + max_lba: u32, + } + impl SectorSource for SamplingReader { + fn capacity_sectors(&self) -> u32 { + 100_000 + } + fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8], _: bool) -> Result { + self.reads += 1; + self.max_lba = self.max_lba.max(lba); + let want = count as usize * 2048; + buf[..want].fill(0); + Ok(want) + } + } + + /// `resolve_keys_for` samples the LARGEST title's ciphertext through the + /// reader when a source is configured (the `session.rs:90` sampling branch). + /// The other tests use a title-less disc (no sampling read), so this branch + /// was uncovered. With two titles and a non-empty source, the sampling read + /// fires against the LARGER title's extent. + #[test] + fn resolve_keys_for_samples_largest_title_through_reader() { + use crate::disc::{DiscTitle, Extent}; + let mut disc = aacs_disc(); + let mut small = DiscTitle::empty(); + small.size_bytes = 1_000; + small.extents = vec![Extent { + start_lba: 100, + sector_count: 300, + }]; + let mut large = DiscTitle::empty(); + large.size_bytes = 9_000_000; + large.extents = vec![Extent { + start_lba: 9_000, + sector_count: 300, + }]; + disc.titles = vec![small, large]; + let mut reader = SamplingReader { + reads: 0, + max_lba: 0, + }; + + // Non-empty source ⇒ the sampling read is NOT skipped. + let resolved = resolve_keys_for(&mut reader, &mut disc, factory_of(|| HasUnitKey([1; 16]))); + + assert!( + reader.reads > 0, + "the largest title was sampled via the reader" + ); + assert!( + reader.max_lba >= 9_000, + "sampling read the LARGER title's extent (lba>=9000), not the small one \ + (max_lba={})", + reader.max_lba + ); + assert!( + resolved.key_fetch.is_some(), + "an AACS disc still retains a read-time fetch" + ); + } + /// A non-AACS disc (CSS / unencrypted — `inputs()` is `None`): resolution is a /// no-op. Empty trace, NO fetch, disc untouched. This is the out-of-the-box /// CSS/None path that must keep working with no keydb. diff --git a/tests/passn_handler_ab.rs b/tests/passn_handler_ab.rs index 295c01d..59686b6 100644 --- a/tests/passn_handler_ab.rs +++ b/tests/passn_handler_ab.rs @@ -814,6 +814,18 @@ fn profile_08_batch_fail_singles_ok() { /// Run a single-always-bad-sector (LBA 130, inside a NonTrimmed [128,192) /// range) patch pass with the given failure step and return the final map /// stats. 256-sector synthetic disc; everything outside the range is Finished. +/// +/// TODO(coverage gap): these HARDWARE_ERROR / ILLEGAL_REQUEST cases assert only +/// the persistent-sense RECOVERY CONTRACT (never Unreadable, byte conservation, +/// dead sector stays pending) — they do NOT exercise the patch WEDGE-EXIT path. +/// A single dead sector in one range structurally cannot reach either exit: +/// `WEDGE_ABORT_THRESHOLD=16` needs 16 CONSECUTIVE wedge-family senses within a +/// range, and the `wedged_threshold=50` exit additionally needs `range_idx > 0` +/// (a prior range already processed). No test anywhere asserts +/// `PatchOutcome::wedged_exit == true`. A real wedge-exit fixture (a first +/// throwaway range, then a second range of >=16 sectors that ALL always-fail +/// with HARDWARE_ERROR, in reverse mode) is a separate, larger synthetic build; +/// left out here rather than bent into this shared single-sector helper. fn single_dead_sector_patch_stats(step: ScriptStep) -> libfreemkv::disc::mapfile::MapStats { let capacity_sectors: u32 = 256; let (mut reader, _trace) = ScriptedSectorReader::new(capacity_sectors);