From c1b4f3cbb3e8eb5a98a3dfbdbf84745443824fdb Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:46:00 -0700 Subject: [PATCH] tests: harden UDF allocation-descriptor + bad-sector recovery paths Spec-grounded unit tests for the silent-corruption surfaces, each verified to fail under a targeted source mutation (no vacuous tests). udf (10): Extended-AD 20-byte stride + extent LBA at off+12, type-1 sparse extents skipped not emitted, zero-length type-0 terminator, continuation-loop bound (anti-hang), UTF-16BE and 8-bit name decoding, FID L_IU offset, parent (..) FID skip, d-string length-byte cap. Locks the spec branches a future allocation-descriptor refactor must not silently break. recovery (9): Pass-N damage-skip range bounds (forward/reverse cursor stays in range), one-quarter-of-remaining skip cap, below-threshold no-op, work-done accounting, and bridge-degradation retry-to-budget fall-through. --- src/disc/patch.rs | 234 ++++++++++++++++++++++++++++++++++++++++ src/disc/read_error.rs | 166 +++++++++++++++++++++++++++++ src/udf.rs | 236 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 636 insertions(+) diff --git a/src/disc/patch.rs b/src/disc/patch.rs index f3cf563..b145553 100644 --- a/src/disc/patch.rs +++ b/src/disc/patch.rs @@ -1856,4 +1856,238 @@ mod tests { assert_eq!(r, Some((1000, 300))); assert_eq!(skip_limit_remainder(true, 1000, 2000, 1000), None); } + + // ---------------------------------------------------------------- + // compute_damage_skip - range-boundary + size-aware-cap coverage. + // + // These exercise the Pass-N damage-cluster skip documented in + // CLAUDE.md "Patch (Pass N)": skip is capped at 1/4 of the + // remaining bad range "so a single jump can't blow past a good + // middle", and the per-iteration cursor (`block_end`) must never + // cross the range boundary in either walk direction. A bug here + // silently abandons recoverable sectors (over-skip) or downgrades + // already-recovered sectors (cursor crossing the boundary). + // + // All byte offsets are multiples of 2048 (the sector size the code + // divides by at `range_remaining_bytes / 2048`). + // ---------------------------------------------------------------- + + /// Build a `PatchOptions` with only `reverse` meaningful for the + /// pure helpers under test (no I/O is performed). + fn opts_with_reverse(reverse: bool) -> crate::disc::PatchOptions<'static> { + crate::disc::PatchOptions { + decrypt: false, + block_sectors: Some(1), + full_recovery: false, + reverse, + wedged_threshold: 50, + progress: None, + halt: None, + } + } + + /// A `PatchLoopState` whose damage window is full (16 entries) with + /// exactly `bad` failures - enough to evaluate the + /// `PASSN_DAMAGE_THRESHOLD_PCT` gate. `escalation` seeds + /// `consecutive_skips_without_recovery` so we can drive the + /// `PASSN_SKIP_SECTORS_BASE << escalation` size. + fn state_with_window(bad: usize, escalation: u32) -> PatchLoopState { + let mut s = PatchLoopState::new(0, 1 << 40, 1, false, 1 << 40); + s.damage_window.clear(); + for i in 0..PASSN_DAMAGE_WINDOW { + s.damage_window.push(i >= bad); // first `bad` entries = false + } + s.consecutive_skips_without_recovery = escalation; + s + } + + #[test] + fn damage_skip_forward_advances_cursor_toward_end_and_stays_in_range() { + // Forward walk: the per-iteration cursor moves UP, toward `end`, + // so the attempted region grows as [range_pos, block_end). A + // damage skip must push block_end FORWARD (higher) and never + // past `end` (the call site breaks on `block_end >= end`). Range + // [0, 80 KiB) = 40 sectors, cursor mid-range at 20 KiB. Spec: + // CLAUDE.md Pass-N reverse=false walks start->end. + // Mutation that makes this RED: swap the forward branch to + // subtract (reverse direction) e.g. `block_end - skip_bytes` -> + // the cursor moves the WRONG way and re-attempts recovered + // sectors / never converges. (Confirmed: the assertion + // block_end > before fails.) + let mut state = state_with_window(4, 0); + let opts = opts_with_reverse(false); + let mut frame = RangeFrame { + range_idx: 0, + range_pos: 0, + range_size: 80 * 1024, + end: 80 * 1024, + block_end: 20 * 1024, // 10 sectors in + range_budget_secs: 1, + range_sectors: 40, + }; + let before = frame.block_end; + let did = compute_damage_skip(&mut state, &mut frame, &opts, 0, 2048); + assert!(did, "threshold crossed: a skip must apply"); + assert!( + frame.block_end > before, + "forward skip must advance the cursor UP (toward end): {} !> {}", + frame.block_end, + before + ); + assert!( + frame.block_end <= frame.end, + "forward cursor {} overshot range end {}", + frame.block_end, + frame.end + ); + } + + #[test] + fn damage_skip_reverse_moves_cursor_toward_range_start_and_stays_in_range() { + // Reverse walk (the recovery walker default): the cursor moves + // DOWN, toward `range_pos`, so the attempted region grows as + // [block_end, end). A damage skip must push block_end BACKWARD + // (lower) and never below `range_pos` (the call site breaks on + // `block_end <= range_pos`). Spec: CLAUDE.md "Patch (Pass N) - + // Default: reverse mode ... within each range from end to start." + // Mutation that makes this RED: the reverse branch adds instead + // of subtracts (copy-paste of the forward formula) -> cursor + // moves UP, away from range_pos, and the walk never converges on + // the low end of the range, silently abandoning those sectors. + let mut state = state_with_window(4, 0); + let opts = opts_with_reverse(true); + let range_pos = 40 * 1024; + let mut frame = RangeFrame { + range_idx: 0, + range_pos, + range_size: 80 * 1024, + end: range_pos + 80 * 1024, + block_end: range_pos + 60 * 1024, // 30 sectors above range_pos + range_budget_secs: 1, + range_sectors: 40, + }; + let before = frame.block_end; + let did = compute_damage_skip(&mut state, &mut frame, &opts, 0, 2048); + assert!(did, "threshold crossed: a skip must apply"); + assert!( + frame.block_end < before, + "reverse skip must move the cursor DOWN (toward range_pos): {} !< {}", + frame.block_end, + before + ); + assert!( + frame.block_end >= frame.range_pos, + "reverse cursor {} descended below range_pos {}", + frame.block_end, + frame.range_pos + ); + } + + #[test] + fn damage_skip_caps_at_one_quarter_of_remaining_range() { + // CLAUDE.md: skip "capped at 1/4 of the remaining bad range so + // a single jump can't blow past a good middle." Forward walk, + // range [0, 80 KiB) = 40 sectors, cursor at start (block_end=0) + // so remaining = 40 sectors and quarter = 10 sectors. Drive a + // large escalation so the raw escalated skip far exceeds 10. + // The applied gap must be <= quarter (10 sectors = 20480 bytes). + // Mutation that makes this RED: remove `.min(range_quarter)` + // from `skip_sectors` -> the jump leaps the entire good middle. + let mut state = state_with_window(4, 10); + let opts = opts_with_reverse(false); + let mut frame = RangeFrame { + range_idx: 0, + range_pos: 0, + range_size: 80 * 1024, + end: 80 * 1024, + block_end: 0, + range_budget_secs: 1, + range_sectors: 40, + }; + let did = compute_damage_skip(&mut state, &mut frame, &opts, 0, 2048); + assert!(did, "threshold crossed: a skip must apply"); + let quarter_bytes = (40u64 / 4) * 2048; // 10 sectors + assert!( + frame.block_end <= quarter_bytes, + "skip advanced cursor to {} bytes, exceeding the 1/4 cap of {} bytes", + frame.block_end, + quarter_bytes + ); + assert!(frame.block_end > 0, "a real skip must advance the cursor"); + } + + #[test] + fn damage_skip_below_threshold_does_not_skip_or_mutate_state() { + // With an all-good window (bad=0) the damage threshold is NOT + // crossed, so compute_damage_skip must be a no-op: it must NOT + // advance the cursor, increment skip_count, or charge work_done. + // A spurious skip here silently abandons readable sectors that + // the patch loop would otherwise retry. + // Mutation that makes this RED: invert/weaken the threshold + // guard (e.g. `>=` -> `<`) so a clean window still skips. + let mut state = state_with_window(/*bad=*/ 0, /*escalation=*/ 0); + let opts = opts_with_reverse(false); + let work_before = state.work_done; + let skips_before = state.skip_count; + let mut frame = RangeFrame { + range_idx: 0, + range_pos: 0, + range_size: 80 * 1024, + end: 80 * 1024, + block_end: 4096, + range_budget_secs: 1, + range_sectors: 40, + }; + let did = compute_damage_skip(&mut state, &mut frame, &opts, 0, 2048); + assert!(!did, "clean window must not trigger a damage skip"); + assert_eq!(frame.block_end, 4096, "cursor must not move on a no-op"); + assert_eq!( + state.work_done, work_before, + "no-op must not charge work_done" + ); + assert_eq!( + state.skip_count, skips_before, + "no-op must not bump skip_count" + ); + } + + #[test] + fn damage_skip_work_done_equals_actual_gap_skipped() { + // Progress accounting: when a skip fires, `work_done` must grow + // by EXACTLY the number of bytes the cursor moved (the gap), + // and skip_count must increment by exactly 1. Reverse range + // [0, 64 KiB) = 32 sectors, cursor at the top so remaining = + // 32, quarter = 8 sectors, escalation 0 -> escalated = base + // (32) capped to quarter (8). Expected gap = 8 sectors. + // Mutation that makes this RED: compute `gap_bytes` from the + // wrong endpoints or double-add it. + let mut state = state_with_window(/*bad=*/ 4, /*escalation=*/ 0); + let opts = opts_with_reverse(true); + let end = 64 * 1024; + let mut frame = RangeFrame { + range_idx: 0, + range_pos: 0, + range_size: end, + end, + block_end: end, + range_budget_secs: 1, + range_sectors: 32, + }; + let before = frame.block_end; + let work_before = state.work_done; + let did = compute_damage_skip(&mut state, &mut frame, &opts, 0, 2048); + assert!(did, "threshold crossed: a skip must apply"); + let expected_gap = 8 * 2048u64; // min(base=32, quarter=8) sectors + let actual_gap = before - frame.block_end; // reverse: cursor moved down + assert_eq!( + actual_gap, expected_gap, + "reverse skip should move the cursor down by the quarter-cap gap" + ); + assert_eq!( + state.work_done - work_before, + actual_gap, + "work_done must grow by exactly the gap skipped" + ); + assert_eq!(state.skip_count, 1, "exactly one skip must be recorded"); + } } diff --git a/src/disc/read_error.rs b/src/disc/read_error.rs index 0f6ee55..8d7ee03 100644 --- a/src/disc/read_error.rs +++ b/src/disc/read_error.rs @@ -1205,4 +1205,170 @@ mod tests { assert_eq!(ctx.consecutive_failures, 0); assert!(*ctx.damage_window.last().unwrap()); } + + // ---------------------------------------------------------------- + // Additional hardening: retry-budget boundaries, transport-abort + // precedence, and the bounded-jump invariant. These guard against + // off-by-one in the retry caps (which would either hammer a wedging + // drive or give up a recovery one attempt early) and against an + // unbounded jump multiplier skipping the rest of the disc. + // ---------------------------------------------------------------- + + /// NOT_READY check-condition (status 0x02 so it is NOT classified as + /// bridge degradation, which keys off non-standard status bytes). + /// sense_key=2 with a generic ASC routes to the NOT_READY retry path. + fn not_ready_err() -> Error { + Error::DiscRead { + sector: 100, + status: Some(crate::scsi::SCSI_STATUS_CHECK_CONDITION), + sense: Some(ScsiSense { + sense_key: scsi::SENSE_KEY_NOT_READY, + asc: 0x04, + ascq: 0x00, // not 0x3E, so not the bridge-degradation signature + }), + } + } + + /// Transport failure: SCSI status 0xFF (bridge crash). CLAUDE.md + /// "Bad-sector handling": this aborts the copy. + fn transport_failure_err() -> Error { + Error::DiscRead { + sector: 100, + status: Some(crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE), + sense: None, + } + } + + /// Bridge degradation: a non-standard status byte (0x04 - neither + /// GOOD/CHECK/TRANSPORT) with empty sense, per `Error::is_bridge_degradation`. + fn bridge_degradation_err() -> Error { + Error::DiscRead { + sector: 100, + status: Some(0x04), + sense: None, + } + } + + #[test] + fn not_ready_retries_capped_at_three_then_falls_through() { + // CLAUDE.md "Bad-sector handling" mode 1: NOT READY -> "Pause 3s, + // retry up to 3x, then mark NonTrimmed." NOT_READY_MAX_RETRIES=3. + // The 1st-3rd NOT_READY must Retry; the 4th must NOT Retry (it + // falls through to skip). Pass N (batch=1) so the marginal-bisect + // branch is irrelevant. + // Mutation that makes this RED: change `ctx.not_ready_retries < + // NOT_READY_MAX_RETRIES` to `<=` (retries 4 times) or to `>` + // (never retries). + let mut ctx = ReadCtx::for_patch(1); + for i in 0..NOT_READY_MAX_RETRIES { + let a = handle_read_error(¬_ready_err(), &mut ctx); + assert!( + matches!(a, ReadAction::Retry { .. }), + "NOT_READY attempt {i} should Retry, got {a:?}" + ); + } + // Budget exhausted: the next NOT_READY must not Retry. + let a = handle_read_error(¬_ready_err(), &mut ctx); + assert!( + !matches!(a, ReadAction::Retry { .. }), + "NOT_READY past the retry cap must fall through, got {a:?}" + ); + } + + #[test] + fn transport_failure_aborts_even_mid_bisect() { + // CLAUDE.md "Bad-sector handling" mode 2: a transport failure + // (bridge crash, status 0xFF) aborts the pass so the outer loop + // can re-enumerate the bridge. This must hold even while + // bisecting and even on Pass N - the wedge-skip/jump paths must + // NOT swallow a real transport crash into a JumpAhead. + // Mutation that makes this RED: move the transport-failure check + // below the HARDWARE/ILLEGAL wedge arm, so a transport failure + // that also carried a wedge-family sense would JumpAhead instead. + let mut ctx = ReadCtx::for_patch(32); + ctx.bisecting = true; + assert_eq!( + handle_read_error(&transport_failure_err(), &mut ctx), + ReadAction::AbortPass + ); + // And on a fresh Pass 1 context, still AbortPass. + let mut ctx1 = ReadCtx::for_sweep(32); + assert_eq!( + handle_read_error(&transport_failure_err(), &mut ctx1), + ReadAction::AbortPass + ); + } + + #[test] + fn bridge_degradation_retries_to_budget_then_falls_through() { + // The bridge-degradation cooldown retry is bounded by + // BRIDGE_DEGRADATION_MAX_RETRIES (=5). The first 5 degradation + // errors must Retry with the long bridge cooldown; the 6th must + // fall through to skip/jump rather than retrying forever and + // stalling the pass. + // Mutation that makes this RED: change the budget comparison + // `ctx.bridge_degradation_count < BRIDGE_DEGRADATION_MAX_RETRIES` + // to `<=` (retries 6 times). + let mut ctx = ReadCtx::for_patch(1); + for i in 0..BRIDGE_DEGRADATION_MAX_RETRIES { + let a = handle_read_error(&bridge_degradation_err(), &mut ctx); + match a { + ReadAction::Retry { pause_secs } => { + assert_eq!( + pause_secs, BRIDGE_DEGRADATION_PAUSE_SECS, + "bridge retry {i} should use the bridge cooldown" + ); + } + other => panic!("bridge degradation attempt {i} should Retry, got {other:?}"), + } + } + let a = handle_read_error(&bridge_degradation_err(), &mut ctx); + assert!( + !matches!(a, ReadAction::Retry { .. }), + "bridge degradation past the retry budget must fall through, got {a:?}" + ); + } + + #[test] + fn jump_multiplier_caps_and_jump_distance_stays_bounded() { + // CLAUDE.md damage-jump: multiplier doubles per jump but is + // capped at MAX_JUMP_MULTIPLIER=64 (the "4 GiB cap"); a single + // jump must never be allowed to grow without bound and skip the + // rest of the disc. Drive a long single-sector failure streak on + // a sweep ctx with a tiny window so window-trigger jumps fire + // repeatedly, and verify the multiplier saturates at 64 and the + // emitted jump distance equals JUMP_BASE_SECTORS * batch * 64. + // Mutation that makes this RED: remove the + // `.min(MAX_JUMP_MULTIPLIER)` on the multiplier doubling, or use + // wrapping/non-saturating mul -> distance overshoots or panics. + const MAX_JUMP_MULTIPLIER: u64 = 64; + let batch: u16 = 32; + let mut ctx = ReadCtx::for_sweep(batch); + // Small window + 0% threshold so every failure can window-trigger + // a jump and keep doubling the multiplier toward the cap. + ctx.damage_window_max = 2; + ctx.damage_threshold_pct = 0; + let mut last_jump_sectors = 0u64; + for _ in 0..40 { + // Reset bisecting flag defensively; these are outer failures. + ctx.bisecting = false; + if let ReadAction::JumpAhead { sectors, .. } = + handle_read_error(&medium_err(), &mut ctx) + { + last_jump_sectors = sectors; + } + assert!( + ctx.jump_multiplier <= MAX_JUMP_MULTIPLIER, + "jump_multiplier {} exceeded the cap {}", + ctx.jump_multiplier, + MAX_JUMP_MULTIPLIER + ); + } + // After saturation, the jump distance is exactly base*batch*cap. + let expected = JUMP_BASE_SECTORS * batch as u64 * MAX_JUMP_MULTIPLIER; + assert_eq!( + last_jump_sectors, expected, + "saturated jump distance must equal base*batch*64" + ); + } } diff --git a/src/udf.rs b/src/udf.rs index 638029d..a4858a4 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -1702,4 +1702,240 @@ mod tests { assert!(dir.entries.is_empty()); assert!(dir.is_dir); } + + // ---- added: spec-boundary coverage for AD strides, flags, FIDs ---- + + /// Build an Extended File Entry (tag 266) ICB whose allocation + /// descriptors are EXTENDED ADs (ECMA-167 §14.14.3, 20 bytes each): + /// ExtentLength(4) | RecordedLength(4) | InformationLength(4) | + /// ExtentLocation lb_addr { logicalBlockNumber(4) | partitionRef(2) } | + /// impl_use(2) + /// The 30-bit length + 2-bit type live in ExtentLength (offset +0); the + /// logical block number lives in ExtentLocation at offset +12. Sets ICB + /// Tag flags (abs offset 34) low bits to 2 = Extended AD so the parser + /// must select the 20-byte stride AND read the LBA from off+12, not off+4. + fn build_efe_ext(info_length: u64, ads: &[(u32, u32, u32)]) -> [u8; 2048] { + let mut s = [0u8; 2048]; + s[0..2].copy_from_slice(&266u16.to_le_bytes()); // tag + // ICB Tag flags at abs offset 34: AD type 2 = Extended AD. + s[34..36].copy_from_slice(&2u16.to_le_bytes()); + s[56..64].copy_from_slice(&info_length.to_le_bytes()); + let l_ea: u32 = 0; + let l_ad: u32 = (ads.len() * 20) as u32; + s[208..212].copy_from_slice(&l_ea.to_le_bytes()); + s[212..216].copy_from_slice(&l_ad.to_le_bytes()); + let mut off = 216 + l_ea as usize; + for &(etype, dlen, dlba) in ads { + let raw_len = (etype << 30) | (dlen & 0x3FFF_FFFF); + // ExtentLength at +0 (carries type + 30-bit length). + s[off..off + 4].copy_from_slice(&raw_len.to_le_bytes()); + // RecordedLength (+4) and InformationLength (+8) set to distinct + // non-zero junk so a parser misreading the LBA at off+4 would + // pick THESE up instead of the real LBA at off+12. + s[off + 4..off + 8].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); + s[off + 8..off + 12].copy_from_slice(&0xCAFE_BABEu32.to_le_bytes()); + // ExtentLocation logicalBlockNumber at +12. + s[off + 12..off + 16].copy_from_slice(&dlba.to_le_bytes()); + off += 20; + } + s + } + + #[test] + fn icb_extents_extended_ad_uses_20byte_stride_and_lba_at_off12() { + // ECMA-167 §14.14.3: an Extended AD is 20 bytes and its extent LBA + // is at byte offset +12, NOT +4 (that's RecordedLength). The parser + // branches on ICB-tag flags==2 to a 20-byte stride and lba_off=off+12. + // Three extents must come back with the CORRECT LBAs and lengths. + let icb = build_efe_ext(3 * 2048, &[(0, 2048, 700), (0, 2048, 800), (0, 4096, 900)]); + let mut reader = MapReader::new(); + reader.put(5, icb); + let fs = fs_with(0, 0, file_entry("EXT", 5, 3 * 2048)); + let extents = fs.read_icb_extents(&mut reader, 5).expect("extents"); + // If the stride were wrong (8 or 16) or lba_off were off+4, the LBAs + // would be the 0xDEADBEEF junk or misaligned garbage, not these. + assert_eq!(extents, vec![(700, 2048), (800, 2048), (900, 4096)]); + } + + #[test] + fn icb_extents_short_ad_type1_sparse_extent_is_skipped_not_emitted() { + // ECMA-167 §14.14.1.1: extent type 1 = "allocated but not recorded" + // (a sparse hole). It carries no on-disc data, so it must NOT be + // returned as a readable extent. A type-0 extent after it must still + // be reached (the loop must continue past a type-1, not break). + let icb = build_efe( + 6144, + &[ + (0, 2048, 10), // recorded + (1, 2048, 20), // sparse — allocated, not recorded + (0, 2048, 30), // recorded, after the hole + ], + ); + let mut reader = MapReader::new(); + reader.put(5, icb); + let fs = fs_with(0, 0, file_entry("SP", 5, 6144)); + let extents = fs.read_icb_extents(&mut reader, 5).expect("extents"); + // The sparse (type-1) middle descriptor must be absent; the two + // recorded extents must both be present and in order. + assert_eq!(extents, vec![(10, 2048), (30, 2048)]); + } + + #[test] + fn icb_extents_zero_length_type0_terminates_list() { + // ECMA-167: a zero-length type-0 AD terminates the descriptor list. + // Trailing zero padding (all-zero ADs) MUST stop parsing — otherwise + // a stray non-zero AD after the terminator becomes a bogus extent. + // One real extent, then a zero AD, then an AD that must NEVER be read. + let icb = build_efe( + 2048, + &[ + (0, 2048, 10), // recorded extent + (0, 0, 0), // zero-length type-0 = terminator + (0, 4096, 999), // must NOT be parsed + ], + ); + let mut reader = MapReader::new(); + reader.put(5, icb); + let fs = fs_with(0, 0, file_entry("T", 5, 2048)); + let extents = fs.read_icb_extents(&mut reader, 5).expect("extents"); + assert_eq!( + extents, + vec![(10, 2048)], + "parsing must stop at the zero-length terminator" + ); + } + + #[test] + fn icb_extents_continuation_loop_terminates_without_hang_or_panic() { + // Hostile input: a type-3 continuation descriptor whose continuation + // block points back at itself (a cycle). The MAX_AD_BLOCKS bound must + // make this terminate rather than loop forever. We assert it returns + // a finite Vec and does not panic. The continuation block at meta-rel + // lba 50 contains a recorded extent + a type-3 AD pointing to lba 50. + let icb = build_efe(2048, &[(0, 2048, 10), (3, 2048, 50)]); + let cont = build_cont_block(&[(0, 2048, 20), (3, 2048, 50)]); + let mut reader = MapReader::new(); + reader.put(5, icb); + reader.put(50, cont); + let fs = fs_with(0, 0, file_entry("LOOP", 5, 2048)); + // Must return Ok (bounded), not hang or panic. + let extents = fs.read_icb_extents(&mut reader, 5).expect("extents"); + // First block contributes extent (10,2048); each revisit of the + // self-referential cont block adds (20,2048). The hop bound caps the + // total, so the Vec is finite. (256 blocks max → < 600 extents.) + assert!(extents.len() < 1024, "continuation chain must be bounded"); + assert_eq!(extents[0], (10, 2048)); + assert_eq!(extents[1], (20, 2048)); + } + + #[test] + fn parse_udf_name_decodes_utf16be_compression_id_16() { + // UDF dchar: compression ID 16 = 16-bit big-endian Unicode. A FID + // whose filename uses ID 16 must decode correctly, not as mojibake. + // Bytes: [16][00 'A'][00 'Z']. + let raw = [16u8, 0x00, b'A', 0x00, b'Z']; + assert_eq!(parse_udf_name(&raw), "AZ"); + } + + #[test] + fn parse_udf_name_8bit_compression_id_8() { + // Compression ID 8 = 8-bit (OSTA CS0 / ASCII). "BDMV" must round-trip. + let mut raw = vec![8u8]; + raw.extend_from_slice(b"BDMV"); + assert_eq!(parse_udf_name(&raw), "BDMV"); + } + + #[test] + fn read_directory_honors_l_iu_offset_for_fid_name() { + // ECMA-167 §14.4 File Identifier Descriptor: the File Identifier + // begins at offset 38 + L_IU. A non-zero L_IU must shift the name + // read; ignoring it would read impl_use bytes as the name. + // 0..2 tag = 257 18 file chars 19 L_FI + // 24..28 ICB LBA 36..38 L_IU 38.. impl_use[L_IU] then FI[L_FI] + let mut dir = [0u8; 2048]; + let l_iu: u16 = 4; + let mut name_bytes = vec![8u8]; // compression id 8 + name_bytes.extend_from_slice(b"CLPI"); + let l_fi = name_bytes.len() as u8; + dir[0..2].copy_from_slice(&257u16.to_le_bytes()); + dir[18] = 0x00; // not parent, not dir → a file + dir[19] = l_fi; + dir[24..28].copy_from_slice(&7u32.to_le_bytes()); // child ICB LBA + dir[36..38].copy_from_slice(&l_iu.to_le_bytes()); + dir[38..42].copy_from_slice(&[0xFF, 0xFE, 0xFD, 0xFC]); // impl_use junk + let name_start = 38 + l_iu as usize; + dir[name_start..name_start + name_bytes.len()].copy_from_slice(&name_bytes); + + let dir_icb = build_efe_icb(2048, 2048, 60); // dir data at ad_pos 60 + let mut reader = MemReader::new(); + reader.put(5, dir_icb); + reader.put(60, dir); + reader.put(7, build_efe_icb(123, 2048, 0)); // child size ICB + + let parsed = read_directory(&mut reader, 0, 0, 5, "ROOT", 0).expect("dir parses"); + assert_eq!(parsed.entries.len(), 1, "exactly one FID entry"); + assert_eq!( + parsed.entries[0].name, "CLPI", + "name must be read at 38+L_IU, not from impl_use bytes" + ); + assert!(!parsed.entries[0].is_dir); + } + + #[test] + fn read_directory_skips_parent_fid_entry() { + // ECMA-167 §14.4.3: file characteristics bit 3 (0x08) = "parent" (the + // ".." back-link). It must NOT appear as a named child entry. To + // isolate the parent-flag gate (rather than the L_FI==0 gate that + // real parent FIDs also have), this fixture gives the parent FID a + // VALID non-zero L_FI and a real name: the ONLY reason it must be + // skipped is the parent characteristic bit. + let mut dir = [0u8; 2048]; + let mut name_bytes = vec![8u8]; + name_bytes.extend_from_slice(b"PARENT"); + let l_fi = name_bytes.len() as u8; + dir[0..2].copy_from_slice(&257u16.to_le_bytes()); + dir[18] = 0x08 | 0x02; // parent + directory bits + dir[19] = l_fi; // non-zero L_FI: name present but must be ignored + dir[24..28].copy_from_slice(&9u32.to_le_bytes()); + dir[36..38].copy_from_slice(&0u16.to_le_bytes()); // L_IU = 0 + dir[38..38 + name_bytes.len()].copy_from_slice(&name_bytes); + + let dir_icb = build_efe_icb(2048, 2048, 60); + let mut reader = MemReader::new(); + reader.put(5, dir_icb); + reader.put(60, dir); + reader.put(9, build_efe_icb(0, 2048, 0)); // child size ICB + + let parsed = read_directory(&mut reader, 0, 0, 5, "ROOT", 0).expect("dir parses"); + assert!( + parsed.entries.is_empty(), + "the parent (..) FID must not be emitted even with a valid name" + ); + } + + #[test] + fn parse_dstring_length_byte_caps_content() { + // UDF d-string: the final byte of the fixed field is the length of + // valid content (compression id + chars). Bytes past that length must + // be ignored. Field: [8]['V']['O']['L'] ... last byte = 4. + let mut field = [0u8; 32]; + field[0] = 8; // compression id 8 + field[1] = b'V'; + field[2] = b'O'; + field[3] = b'L'; + field[10] = b'X'; // garbage beyond declared length — must be ignored + *field.last_mut().unwrap() = 4; // 4 valid bytes (id + 3 chars) + assert_eq!(parse_dstring(&field), "VOL"); + } + + #[test] + fn parse_dstring_oversized_length_byte_returns_empty_not_panic() { + // Hostile/corrupt input: a length byte larger than the field must not + // index out of bounds. parse_dstring guards len > data.len() → "". + let mut field = [0u8; 8]; + field[0] = 8; + field[1] = b'A'; + *field.last_mut().unwrap() = 200; // way past the 8-byte field + assert_eq!(parse_dstring(&field), ""); + } }