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.
This commit is contained in:
@@ -1856,4 +1856,238 @@ mod tests {
|
|||||||
assert_eq!(r, Some((1000, 300)));
|
assert_eq!(r, Some((1000, 300)));
|
||||||
assert_eq!(skip_limit_remainder(true, 1000, 2000, 1000), None);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1205,4 +1205,170 @@ mod tests {
|
|||||||
assert_eq!(ctx.consecutive_failures, 0);
|
assert_eq!(ctx.consecutive_failures, 0);
|
||||||
assert!(*ctx.damage_window.last().unwrap());
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+236
@@ -1702,4 +1702,240 @@ mod tests {
|
|||||||
assert!(dir.entries.is_empty());
|
assert!(dir.entries.is_empty());
|
||||||
assert!(dir.is_dir);
|
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), "");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user