scan_with() collapsed every failure path from resolve_encryption() into None via .ok(), so callers couldn't tell the difference between "no KEYDB found", "KEYDB failed to parse", "disc hash not in KEYDB and fallback derivation failed", "AACS files unreadable on disc", and a handshake that rejected every host cert. autorip's UI was stuck printing "no decryption keys found (check KEYDB)" for all of them, which is a particularly bad message when the user has actually loaded a KEYDB and the real failure is something else. Changes: - New pub field Disc.aacs_error: Option<Error>. Populated by scan_with whenever encrypted && aacs.is_none(). Sentinel KeydbLoad path "<no keydb in search paths>" distinguishes the no-keydb case from a real load failure without adding a new Error variant (which would be a breaking change for downstream exhaustive matches). - tracing::warn in scan_with at scan_aacs_resolve_failed and scan_aacs_no_keydb, with error_code and keydb path for grepping. - tracing in do_handshake: keydb load failure, host-cert exhaustion (with cert count and last error code), VID read failure post-auth, and a debug-level success log. Lets us see whether handshake even got off the ground for a given disc. Test fixtures updated to set aacs_error: None.
828 lines
28 KiB
Rust
828 lines
28 KiB
Rust
//! Integration tests for progress reporting, halt behavior, drop safety,
|
|
//! and the file-backed sector reader round trip.
|
|
|
|
// 0.18 trait split: this suite still drives the deprecated `pes::Stream`
|
|
// trait directly. It will be migrated to `FrameSource`/`FrameSink` in the
|
|
// follow-up that ports concrete impls.
|
|
#![allow(deprecated)]
|
|
|
|
use libfreemkv::disc::{CopyOptions, DiscRegion};
|
|
use libfreemkv::error::Result;
|
|
use libfreemkv::pes::Stream as PesStream;
|
|
use libfreemkv::{
|
|
ContentFormat, Disc, DiscFormat, DiscStream, DiscTitle, EventKind, Extent, FileSectorReader,
|
|
SectorReader,
|
|
};
|
|
use std::io::Write;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
use std::time::{Duration, Instant};
|
|
|
|
const SECTOR_SIZE: usize = 2048;
|
|
|
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
|
|
/// Returns zeroed sectors. Always succeeds. Counts each call.
|
|
struct ZeroSectorReader {
|
|
capacity: u32,
|
|
calls: Arc<AtomicU64>,
|
|
}
|
|
|
|
impl ZeroSectorReader {
|
|
fn new(capacity: u32) -> Self {
|
|
Self {
|
|
capacity,
|
|
calls: Arc::new(AtomicU64::new(0)),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SectorReader for ZeroSectorReader {
|
|
fn read_sectors(
|
|
&mut self,
|
|
_lba: u32,
|
|
count: u16,
|
|
buf: &mut [u8],
|
|
_recovery: bool,
|
|
) -> Result<usize> {
|
|
self.calls.fetch_add(1, Ordering::Relaxed);
|
|
let bytes = count as usize * SECTOR_SIZE;
|
|
buf[..bytes].fill(0);
|
|
Ok(bytes)
|
|
}
|
|
|
|
fn capacity(&self) -> u32 {
|
|
self.capacity
|
|
}
|
|
}
|
|
|
|
/// Like ZeroSectorReader but sleeps a configurable duration per call.
|
|
/// Used by the halt test so the copy takes >1 s.
|
|
struct SlowZeroSectorReader {
|
|
capacity: u32,
|
|
sleep_per_call: Duration,
|
|
}
|
|
|
|
impl SlowZeroSectorReader {
|
|
fn new(capacity: u32, sleep_per_call: Duration) -> Self {
|
|
Self {
|
|
capacity,
|
|
sleep_per_call,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SectorReader for SlowZeroSectorReader {
|
|
fn read_sectors(
|
|
&mut self,
|
|
_lba: u32,
|
|
count: u16,
|
|
buf: &mut [u8],
|
|
_recovery: bool,
|
|
) -> Result<usize> {
|
|
std::thread::sleep(self.sleep_per_call);
|
|
let bytes = count as usize * SECTOR_SIZE;
|
|
buf[..bytes].fill(0);
|
|
Ok(bytes)
|
|
}
|
|
|
|
fn capacity(&self) -> u32 {
|
|
self.capacity
|
|
}
|
|
}
|
|
|
|
/// Build a Disc instance with a known capacity, no titles, no encryption.
|
|
/// Sufficient for `Disc::copy` (which only uses capacity_sectors + decrypt keys).
|
|
fn synthetic_disc(capacity_sectors: u32) -> Disc {
|
|
Disc {
|
|
volume_id: String::new(),
|
|
meta_title: None,
|
|
format: DiscFormat::BluRay,
|
|
capacity_sectors,
|
|
capacity_bytes: capacity_sectors as u64 * SECTOR_SIZE as u64,
|
|
layers: 1,
|
|
titles: Vec::new(),
|
|
region: DiscRegion::Free,
|
|
aacs: None,
|
|
css: None,
|
|
encrypted: false,
|
|
aacs_error: None,
|
|
content_format: ContentFormat::BdTs,
|
|
}
|
|
}
|
|
|
|
/// Build a DiscTitle with a single extent of `sector_count` sectors and no
|
|
/// streams (DiscStream still iterates sectors and would emit BytesRead).
|
|
fn synthetic_title(sector_count: u32) -> DiscTitle {
|
|
DiscTitle {
|
|
playlist: String::new(),
|
|
playlist_id: 0,
|
|
duration_secs: 0.0,
|
|
size_bytes: sector_count as u64 * SECTOR_SIZE as u64,
|
|
clips: Vec::new(),
|
|
streams: Vec::new(),
|
|
chapters: Vec::new(),
|
|
extents: vec![Extent {
|
|
start_lba: 0,
|
|
sector_count,
|
|
}],
|
|
content_format: ContentFormat::BdTs,
|
|
codec_privates: Vec::new(),
|
|
}
|
|
}
|
|
|
|
// ── 1. BytesRead events emitted during disc copy (TDD red) ────────────────
|
|
|
|
#[test]
|
|
fn test_bytes_read_emitted_during_disc_copy() {
|
|
// Build a tiny synthetic disc and stream it through DiscStream.
|
|
let reader = ZeroSectorReader::new(64);
|
|
let title = synthetic_title(64);
|
|
let keys = libfreemkv::DecryptKeys::None;
|
|
|
|
let mut stream = DiscStream::new(Box::new(reader), title, keys, 60, ContentFormat::BdTs);
|
|
|
|
let count = Arc::new(AtomicU64::new(0));
|
|
let count_cb = count.clone();
|
|
stream.on_event(move |ev| {
|
|
if let EventKind::BytesRead { .. } = ev.kind {
|
|
count_cb.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
});
|
|
|
|
// Drive the stream to EOF. With no streams configured, read() returns
|
|
// Ok(None) once all extents are exhausted.
|
|
loop {
|
|
match stream.read() {
|
|
Ok(Some(_frame)) => {}
|
|
Ok(None) => break,
|
|
Err(e) => panic!("stream read failed: {e:?}"),
|
|
}
|
|
}
|
|
|
|
let n = count.load(Ordering::Relaxed);
|
|
// EXPECTED TO FAIL until BytesRead emission is wired up. TDD red.
|
|
assert!(
|
|
n > 0,
|
|
"expected at least one BytesRead event, got {n} (lib does not yet emit BytesRead)"
|
|
);
|
|
}
|
|
|
|
// ── 2. Disc::copy on_progress callback fires (regression guard) ───────────
|
|
|
|
#[test]
|
|
fn test_disc_copy_progress_callback_fires() {
|
|
let disc = synthetic_disc(64);
|
|
let mut reader = ZeroSectorReader::new(64);
|
|
|
|
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
|
let iso_path = tmp.path().to_path_buf();
|
|
drop(tmp); // we want the path, not the file handle
|
|
|
|
let calls = Arc::new(AtomicU64::new(0));
|
|
let last_bytes = Arc::new(AtomicU64::new(0));
|
|
|
|
struct CountingReporter {
|
|
calls: Arc<AtomicU64>,
|
|
last_bytes: Arc<AtomicU64>,
|
|
}
|
|
impl libfreemkv::progress::Progress for CountingReporter {
|
|
fn report(&self, p: &libfreemkv::progress::PassProgress) -> bool {
|
|
self.calls.fetch_add(1, Ordering::Relaxed);
|
|
self.last_bytes.store(p.bytes_good_total, Ordering::Relaxed);
|
|
true
|
|
}
|
|
}
|
|
let reporter = CountingReporter {
|
|
calls: calls.clone(),
|
|
last_bytes: last_bytes.clone(),
|
|
};
|
|
|
|
let opts = CopyOptions {
|
|
decrypt: false,
|
|
progress: Some(&reporter),
|
|
..Default::default()
|
|
};
|
|
|
|
let result = disc.copy(&mut reader, &iso_path, &opts).expect("copy ok");
|
|
|
|
// Cleanup any sidecar mapfile + ISO before assertions.
|
|
let _ = std::fs::remove_file(&iso_path);
|
|
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
|
|
|
assert!(result.complete, "copy should be complete");
|
|
let n = calls.load(Ordering::Relaxed);
|
|
let last = last_bytes.load(Ordering::Relaxed);
|
|
assert!(n > 0, "on_progress should fire at least once, got {n}");
|
|
assert!(
|
|
last > 0,
|
|
"final progress bytes should be non-zero, got {last}"
|
|
);
|
|
}
|
|
|
|
// ── 3. Halt aborts disc copy promptly ─────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_halt_aborts_disc_copy_promptly() {
|
|
// 6000 sectors, 60-sector batches → 100 read_sectors() calls.
|
|
// 10 ms sleep per call → ~1 s total without halt.
|
|
let capacity_sectors: u32 = 6000;
|
|
let mut reader = SlowZeroSectorReader::new(capacity_sectors, Duration::from_millis(10));
|
|
let disc = synthetic_disc(capacity_sectors);
|
|
|
|
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
|
let iso_path = tmp.path().to_path_buf();
|
|
drop(tmp);
|
|
|
|
let halt = Arc::new(AtomicBool::new(false));
|
|
let halt_for_thread = halt.clone();
|
|
let iso_path_for_thread = iso_path.clone();
|
|
|
|
let join = std::thread::spawn(move || {
|
|
let opts = CopyOptions {
|
|
decrypt: false,
|
|
halt: Some(halt_for_thread),
|
|
..Default::default()
|
|
};
|
|
let t0 = Instant::now();
|
|
let res = disc.copy(&mut reader, &iso_path_for_thread, &opts);
|
|
(res, t0.elapsed())
|
|
});
|
|
|
|
// Let copy run, then halt.
|
|
std::thread::sleep(Duration::from_millis(200));
|
|
halt.store(true, Ordering::Relaxed);
|
|
|
|
// Bound the join: should exit far before the full 1 s otherwise needed.
|
|
let started = Instant::now();
|
|
let mut joined = None;
|
|
while started.elapsed() < Duration::from_millis(2000) {
|
|
if join.is_finished() {
|
|
joined = Some(join.join().expect("thread join"));
|
|
break;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
let (result, elapsed) = joined.expect("copy thread did not exit within 2s of halt");
|
|
|
|
// Cleanup
|
|
let _ = std::fs::remove_file(&iso_path);
|
|
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
|
|
|
let copy_result = result.expect("copy returns Ok with halted=true on halt");
|
|
assert!(
|
|
copy_result.halted,
|
|
"copy_result.halted should be true after halt"
|
|
);
|
|
assert!(
|
|
!copy_result.complete,
|
|
"copy_result.complete should be false when halted"
|
|
);
|
|
assert!(
|
|
elapsed < Duration::from_millis(2000),
|
|
"copy thread exit elapsed {elapsed:?} exceeded 2s"
|
|
);
|
|
}
|
|
|
|
// ── 4. DiscStream Drop does not panic or block ────────────────────────────
|
|
|
|
#[test]
|
|
fn test_drop_impls_do_not_panic_or_block() {
|
|
let reader = ZeroSectorReader::new(64);
|
|
let title = synthetic_title(64);
|
|
let keys = libfreemkv::DecryptKeys::None;
|
|
let stream = DiscStream::new(Box::new(reader), title, keys, 60, ContentFormat::BdTs);
|
|
|
|
// Drop on a worker thread; main thread enforces the timeout.
|
|
let handle = std::thread::spawn(move || {
|
|
drop(stream);
|
|
});
|
|
|
|
let started = Instant::now();
|
|
while started.elapsed() < Duration::from_millis(100) {
|
|
if handle.is_finished() {
|
|
handle.join().expect("drop thread join");
|
|
return;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(5));
|
|
}
|
|
panic!("DiscStream drop did not complete within 100ms");
|
|
}
|
|
|
|
// ── 5. FileSectorReader round trip ────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_file_sector_reader_round_trip() {
|
|
// Build 8 sectors of pseudo-random bytes (sector-aligned).
|
|
const N_SECTORS: usize = 8;
|
|
let mut data = vec![0u8; N_SECTORS * SECTOR_SIZE];
|
|
for (i, b) in data.iter_mut().enumerate() {
|
|
// Cheap PRNG: just a multiplicative pattern, deterministic for asserts.
|
|
*b = ((i as u64).wrapping_mul(2654435761) >> 16) as u8;
|
|
}
|
|
|
|
let mut tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
|
tmp.write_all(&data).expect("write data");
|
|
tmp.flush().expect("flush");
|
|
|
|
let path = tmp.path().to_path_buf();
|
|
let mut fsr = FileSectorReader::open(&path).expect("open FileSectorReader");
|
|
|
|
assert_eq!(fsr.capacity(), N_SECTORS as u32, "capacity mismatch");
|
|
|
|
// Read each sector individually and compare.
|
|
let mut buf = vec![0u8; SECTOR_SIZE];
|
|
for lba in 0..N_SECTORS as u32 {
|
|
let n = fsr
|
|
.read_sectors(lba, 1, &mut buf, false)
|
|
.expect("read_sectors");
|
|
assert_eq!(n, SECTOR_SIZE);
|
|
let off = lba as usize * SECTOR_SIZE;
|
|
assert_eq!(
|
|
&buf[..],
|
|
&data[off..off + SECTOR_SIZE],
|
|
"sector {lba} mismatch"
|
|
);
|
|
}
|
|
|
|
// Read all sectors at once and compare.
|
|
let mut all = vec![0u8; N_SECTORS * SECTOR_SIZE];
|
|
let n = fsr
|
|
.read_sectors(0, N_SECTORS as u16, &mut all, false)
|
|
.expect("read all sectors");
|
|
assert_eq!(n, N_SECTORS * SECTOR_SIZE);
|
|
assert_eq!(all, data, "bulk read mismatch");
|
|
}
|
|
|
|
// ── 6. Pass 1 sweeps the entire disc even when every read fails ───────────
|
|
//
|
|
// Per RIP_DESIGN.md §2.1 + §3: Disc::copy must reach the end of the disc
|
|
// regardless of how many reads fail. The only legitimate early exit is the
|
|
// halt flag. With `skip_on_error` and a reader that returns
|
|
// Err for every read, Pass 1 must:
|
|
// - mark every sector NonTrimmed (so Pass 2 can retry them)
|
|
// - return cleanly (no panic, no hang)
|
|
// - bytes_good = 0
|
|
// - bytes_pending = total_bytes (NonTrimmed counts as pending in mapfile
|
|
// accounting; see disc/mapfile.rs::stats)
|
|
// - bytes_unreadable = 0 (only Pass 2 marks Unreadable)
|
|
// - complete = false (work remains for Pass 2)
|
|
// - halted = false (no user stop)
|
|
// - ISO file is `total_bytes` size on disk (sparse zeros)
|
|
|
|
/// Reader that returns Err for every read. Optionally signals a halt
|
|
/// flag on the first read so tests can exercise the halt-during-skip-forward
|
|
/// path deterministically (no wallclock dependency).
|
|
struct FailingSectorReader {
|
|
capacity: u32,
|
|
/// If set, signals halt on the first `read_sectors` call. Cleared after
|
|
/// the first signal so subsequent reads are plain Err.
|
|
halt_on_first_read: Option<Arc<AtomicBool>>,
|
|
}
|
|
|
|
impl FailingSectorReader {
|
|
fn new(capacity: u32) -> Self {
|
|
Self {
|
|
capacity,
|
|
halt_on_first_read: None,
|
|
}
|
|
}
|
|
|
|
fn with_halt_on_first_read(capacity: u32, halt: Arc<AtomicBool>) -> Self {
|
|
Self {
|
|
capacity,
|
|
halt_on_first_read: Some(halt),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SectorReader for FailingSectorReader {
|
|
fn read_sectors(
|
|
&mut self,
|
|
_lba: u32,
|
|
_count: u16,
|
|
_buf: &mut [u8],
|
|
_recovery: bool,
|
|
) -> Result<usize> {
|
|
if let Some(h) = self.halt_on_first_read.take() {
|
|
h.store(true, Ordering::Relaxed);
|
|
}
|
|
// Model what a real damaged-disc read returns: CHECK CONDITION +
|
|
// MEDIUM ERROR (sense_key 3, ASC 0x11 UNRECOVERED READ ERROR,
|
|
// ASCQ 0x05 L-EC UNCORRECTABLE). Disc::copy's hysteresis must
|
|
// engage on this — `Error::DiscRead` is libfreemkv's own
|
|
// post-classification signal, not what a real reader emits.
|
|
Err(libfreemkv::error::Error::ScsiError {
|
|
opcode: libfreemkv::scsi::SCSI_READ_10,
|
|
status: libfreemkv::scsi::SCSI_STATUS_CHECK_CONDITION,
|
|
sense: Some(libfreemkv::ScsiSense {
|
|
sense_key: libfreemkv::scsi::SENSE_KEY_MEDIUM_ERROR,
|
|
asc: 0x11,
|
|
ascq: 0x05,
|
|
}),
|
|
})
|
|
}
|
|
|
|
fn capacity(&self) -> u32 {
|
|
self.capacity
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_disc_copy_completes_full_disc_with_failing_reader() {
|
|
// 1024 sectors = 2 MB. Reader fails every read. With skip_on_error +
|
|
// skip_on_error, Pass 1 must mark every sector NonTrimmed and return
|
|
// cleanly — no bail, no hang.
|
|
let capacity_sectors: u32 = 1024;
|
|
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
|
|
|
let mut reader = FailingSectorReader::new(capacity_sectors);
|
|
let disc = synthetic_disc(capacity_sectors);
|
|
|
|
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
|
let iso_path = tmp.path().to_path_buf();
|
|
drop(tmp);
|
|
|
|
let opts = CopyOptions {
|
|
decrypt: false,
|
|
multipass: true,
|
|
|
|
..Default::default()
|
|
};
|
|
|
|
let t0 = Instant::now();
|
|
let result = disc
|
|
.copy(&mut reader, &iso_path, &opts)
|
|
.expect("copy returns Ok");
|
|
let elapsed = t0.elapsed();
|
|
|
|
// Cleanup
|
|
let _ = std::fs::remove_file(&iso_path);
|
|
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
|
|
|
// Hard bound — Pass 1 must NOT infinite-loop on a fully-failing
|
|
// reader. The threshold accommodates the 2026-05-10 wedge-
|
|
// avoidance pause (PASS_1_FAIL_PAUSE_SECS = 5 s on each failed
|
|
// batch). With batch=32 and 1024 sectors that's up to ~5 batch
|
|
// failures + a few damage-jump pauses before fast-trigger jumps
|
|
// us past end-of-disc — well-bounded total, ~20-30 s typical.
|
|
// The point of this test is "finishes cleanly, not infinitely",
|
|
// not "completes in milliseconds."
|
|
assert!(
|
|
elapsed < Duration::from_secs(60),
|
|
"Pass 1 took {elapsed:?} on a 2 MB synthetic disc — expected < 60 s (not infinite)"
|
|
);
|
|
|
|
// Per RIP_DESIGN.md §2.1: Pass 1 must reach end of disc regardless of
|
|
// read outcomes.
|
|
assert_eq!(
|
|
result.bytes_total, total_bytes,
|
|
"bytes_total must match disc capacity"
|
|
);
|
|
assert_eq!(
|
|
result.bytes_good, 0,
|
|
"no reads succeeded, bytes_good must be 0"
|
|
);
|
|
assert_eq!(
|
|
result.bytes_unreadable, 0,
|
|
"Pass 1 does not mark Unreadable; only Pass 2 (Disc::patch) does"
|
|
);
|
|
assert_eq!(
|
|
result.bytes_pending, total_bytes,
|
|
"every sector must be NonTrimmed → counted as pending. \
|
|
Got bytes_pending={} of total {}",
|
|
result.bytes_pending, total_bytes
|
|
);
|
|
assert!(
|
|
!result.complete,
|
|
"complete=false because NonTrimmed regions remain (work for Pass 2)"
|
|
);
|
|
assert!(!result.halted, "no halt was set; halted must be false");
|
|
|
|
// ISO file should be the full disc size on disk (sparse zeros where
|
|
// reads failed).
|
|
// Note: tempfile was dropped above; the file may or may not still exist
|
|
// depending on cleanup ordering. We only assert what we can observe in
|
|
// the CopyResult.
|
|
}
|
|
|
|
// ── 7. Halt during Pass 1 skip-forward path returns promptly (deterministic) ─
|
|
//
|
|
// Per RIP_DESIGN.md §3: halt is the only legitimate early exit from Pass 1.
|
|
// Even when every read is failing (skip-forward path), a halt must be
|
|
// honored within a small bounded time.
|
|
//
|
|
// Deterministic fixture: the reader signals halt on its FIRST read. The
|
|
// inner copy loop's halt check fires on the next iteration, breaking out
|
|
// of 'outer. This avoids any wallclock race on fast CI runners (where a
|
|
// 2 GB synthetic disc can sweep skip-forward in <100 ms).
|
|
|
|
#[test]
|
|
fn test_disc_copy_halts_promptly_on_failing_reader() {
|
|
let capacity_sectors: u32 = 1024 * 1024; // 2 GB synthetic disc
|
|
|
|
let halt = Arc::new(AtomicBool::new(false));
|
|
let mut reader = FailingSectorReader::with_halt_on_first_read(capacity_sectors, halt.clone());
|
|
let disc = synthetic_disc(capacity_sectors);
|
|
|
|
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
|
let iso_path = tmp.path().to_path_buf();
|
|
drop(tmp);
|
|
|
|
let opts = CopyOptions {
|
|
decrypt: false,
|
|
multipass: true,
|
|
|
|
halt: Some(halt),
|
|
..Default::default()
|
|
};
|
|
|
|
let t0 = Instant::now();
|
|
let result = disc
|
|
.copy(&mut reader, &iso_path, &opts)
|
|
.expect("copy returns Ok on halt");
|
|
let elapsed = t0.elapsed();
|
|
|
|
// Cleanup
|
|
let _ = std::fs::remove_file(&iso_path);
|
|
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
|
|
|
assert!(
|
|
elapsed < Duration::from_secs(2),
|
|
"halt must return within 2 s; took {elapsed:?}"
|
|
);
|
|
assert!(result.halted, "result.halted must be true");
|
|
assert!(
|
|
!result.complete,
|
|
"halted run cannot be complete (bytes_pending > 0 expected)"
|
|
);
|
|
assert!(
|
|
result.bytes_pending > 0,
|
|
"halt fired before sweep completed; bytes_pending must be > 0"
|
|
);
|
|
}
|
|
|
|
// ── 8. Hysteresis recovers data the drive can read individually ──────────
|
|
//
|
|
// Pass 1 reads in batch (32 sectors = 1 ECC block). Failed blocks are marked
|
|
// NonTrimmed for Pass 2 recovery. This test verifies that a reader where every
|
|
// multi-sector read fails produces all NonTrimmed output with zero bytes_good.
|
|
|
|
struct BlockSizeFailingReader {
|
|
capacity: u32,
|
|
}
|
|
|
|
impl SectorReader for BlockSizeFailingReader {
|
|
fn read_sectors(
|
|
&mut self,
|
|
lba: u32,
|
|
count: u16,
|
|
buf: &mut [u8],
|
|
_recovery: bool,
|
|
) -> Result<usize> {
|
|
if count == 1 {
|
|
for chunk in buf.chunks_mut(SECTOR_SIZE) {
|
|
chunk.fill((lba & 0xff) as u8);
|
|
}
|
|
Ok(buf.len())
|
|
} else {
|
|
Err(libfreemkv::error::Error::ScsiError {
|
|
opcode: libfreemkv::scsi::SCSI_READ_10,
|
|
status: libfreemkv::scsi::SCSI_STATUS_CHECK_CONDITION,
|
|
sense: Some(libfreemkv::ScsiSense {
|
|
sense_key: libfreemkv::scsi::SENSE_KEY_MEDIUM_ERROR,
|
|
asc: 0x11,
|
|
ascq: 0x00,
|
|
}),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn capacity(&self) -> u32 {
|
|
self.capacity
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_disc_copy_marks_failed_ecc_blocks_as_nontrimmed() {
|
|
let capacity_sectors: u32 = 256;
|
|
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
|
|
|
let mut reader = BlockSizeFailingReader {
|
|
capacity: capacity_sectors,
|
|
};
|
|
let disc = synthetic_disc(capacity_sectors);
|
|
|
|
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
|
let iso_path = tmp.path().to_path_buf();
|
|
drop(tmp);
|
|
|
|
let opts = CopyOptions {
|
|
decrypt: false,
|
|
multipass: true,
|
|
|
|
..Default::default()
|
|
};
|
|
|
|
let result = disc
|
|
.copy(&mut reader, &iso_path, &opts)
|
|
.expect("copy returns Ok");
|
|
|
|
let _ = std::fs::remove_file(&iso_path);
|
|
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
|
|
|
// Pass 1's job is "fast and accurate, get the most data in the
|
|
// shortest time." It no longer bisects on marginal media — that's
|
|
// Pass N's purpose-built role. So a BlockSizeFailingReader that
|
|
// fails on multi-sector reads and succeeds on single-sector
|
|
// results in: every batch fails → SkipBlock → whole 32-sector
|
|
// ECC block marked NonTrimmed → Pass N (Disc::patch) revisits and
|
|
// recovers via single-sector reads with proper recovery semantics.
|
|
//
|
|
// Pass 1 alone:
|
|
assert_eq!(
|
|
result.bytes_good, 0,
|
|
"Pass 1 doesn't bisect on marginal media — failed batches become NonTrimmed for Pass N to revisit"
|
|
);
|
|
assert_eq!(
|
|
result.bytes_pending, total_bytes,
|
|
"every sector is NonTrimmed (pending) after Pass 1, awaiting Pass N"
|
|
);
|
|
assert!(
|
|
!result.complete,
|
|
"complete=false because NonTrimmed regions remain (Pass N's work)"
|
|
);
|
|
}
|
|
|
|
// ── 9. PassProgress carries separate unreadable vs pending byte counts ─────
|
|
//
|
|
// 2026-05-11 design call: Pass N never marks bytes as `Unreadable` mid-multipass —
|
|
// failed reads stay `NonTrimmed` so the next pass can retry them. The orchestrator
|
|
// (autorip) promotes still-NonTrimmed bytes to Unreadable after the FINAL retry
|
|
// pass completes. This test was rewritten from its pre-design-call shape (which
|
|
// asserted Pass 2 produced bytes_unreadable > 0) to verify the new invariant:
|
|
// pass-level retries keep failed bytes in `bytes_pending` so subsequent passes
|
|
// get more shots at them.
|
|
|
|
#[test]
|
|
fn test_pass2_leaves_failed_reads_as_pending_not_unreadable() {
|
|
let capacity_sectors: u32 = 128;
|
|
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
|
|
|
let mut reader = FailingSectorReader::new(capacity_sectors);
|
|
let disc = synthetic_disc(capacity_sectors);
|
|
|
|
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
|
let iso_path = tmp.path().to_path_buf();
|
|
drop(tmp);
|
|
|
|
let opts = CopyOptions {
|
|
decrypt: false,
|
|
multipass: true,
|
|
..Default::default()
|
|
};
|
|
|
|
let pass1 = disc.copy(&mut reader, &iso_path, &opts).expect("pass1 ok");
|
|
|
|
assert_eq!(pass1.bytes_good, 0, "pass1: no good sectors");
|
|
assert_eq!(pass1.bytes_unreadable, 0, "pass1: no confirmed unreadable");
|
|
assert_eq!(
|
|
pass1.bytes_pending, total_bytes,
|
|
"pass1: all sectors NonTrimmed"
|
|
);
|
|
|
|
let last_unreadable = Arc::new(AtomicU64::new(0));
|
|
let last_pending = Arc::new(AtomicU64::new(0));
|
|
let last_good = Arc::new(AtomicU64::new(0));
|
|
let last_dur = Arc::new(AtomicU64::new(0));
|
|
|
|
struct SnapshotReporter {
|
|
unreadable: Arc<AtomicU64>,
|
|
pending: Arc<AtomicU64>,
|
|
good: Arc<AtomicU64>,
|
|
dur: Arc<AtomicU64>,
|
|
}
|
|
impl libfreemkv::progress::Progress for SnapshotReporter {
|
|
fn report(&self, p: &libfreemkv::progress::PassProgress) -> bool {
|
|
self.unreadable
|
|
.store(p.bytes_unreadable_total, Ordering::Relaxed);
|
|
self.pending.store(p.bytes_pending_total, Ordering::Relaxed);
|
|
self.good.store(p.bytes_good_total, Ordering::Relaxed);
|
|
if let Some(d) = p.disc_duration_secs {
|
|
self.dur.store((d * 1000.0) as u64, Ordering::Relaxed);
|
|
}
|
|
true
|
|
}
|
|
}
|
|
let reporter = SnapshotReporter {
|
|
unreadable: last_unreadable.clone(),
|
|
pending: last_pending.clone(),
|
|
good: last_good.clone(),
|
|
dur: last_dur.clone(),
|
|
};
|
|
|
|
let pass2_opts = CopyOptions {
|
|
decrypt: false,
|
|
multipass: true,
|
|
progress: Some(&reporter),
|
|
..Default::default()
|
|
};
|
|
|
|
let pass2 = disc
|
|
.copy(&mut reader, &iso_path, &pass2_opts)
|
|
.expect("pass2 ok");
|
|
|
|
let _ = std::fs::remove_file(&iso_path);
|
|
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
|
|
|
assert_eq!(
|
|
pass2.bytes_good, 0,
|
|
"pass2: still no good sectors (reader always fails)"
|
|
);
|
|
// 2026-05-11 design: pass-level retries do NOT promote failed bytes
|
|
// to Unreadable. Failed bytes stay NonTrimmed (pending) so a later
|
|
// pass can retry. End-of-recovery promotion is an orchestrator
|
|
// concern (autorip), not the patch loop's.
|
|
assert_eq!(
|
|
pass2.bytes_unreadable, 0,
|
|
"pass2: Disc::patch never marks Unreadable mid-multipass — orchestrator promotes after final pass"
|
|
);
|
|
// bytes_pending stays at total_bytes because everything still
|
|
// failed and nothing got recovered or promoted out of pending.
|
|
assert_eq!(
|
|
pass2.bytes_pending, total_bytes,
|
|
"pass2: failed bytes remain NonTrimmed for the next pass to retry"
|
|
);
|
|
|
|
let observed_unreadable = last_unreadable.load(Ordering::Relaxed);
|
|
let observed_pending = last_pending.load(Ordering::Relaxed);
|
|
assert_eq!(
|
|
observed_unreadable, 0,
|
|
"progress should report zero confirmed-unreadable mid-pass under the new design"
|
|
);
|
|
assert!(
|
|
observed_pending > 0,
|
|
"progress should report pending bytes as the reader keeps failing"
|
|
);
|
|
|
|
// Video damage time: unreadable / total * duration
|
|
// With no titles on synthetic disc, disc_duration_secs = None
|
|
assert_eq!(
|
|
last_dur.load(Ordering::Relaxed),
|
|
0,
|
|
"synthetic disc has no titles, duration should be None/0"
|
|
);
|
|
}
|
|
|
|
// ── 10. Damage time calculation (unit test) ────────────────────────────────
|
|
//
|
|
// Verifies the formula: damage_secs = bytes_unreadable / bytes_total * duration
|
|
// This mirrors the CLI's print_disc_progress logic.
|
|
|
|
#[test]
|
|
fn test_damage_time_calculation() {
|
|
// 78.8 GB disc, 2h45m movie (9900s), 74 KB unreadable
|
|
let disc_bytes: u64 = 78_800_000_000;
|
|
let duration_secs: f64 = 9900.0;
|
|
|
|
let cases: Vec<(u64, &str)> = vec![
|
|
(74 * 1024, "~10ms"), // 74 KB → ~9ms, negligible
|
|
(10 * 1024 * 1024, "~1.3s"), // 10 MB → ~1.3s
|
|
(100 * 1024 * 1024, "~13s"), // 100 MB → ~13s
|
|
(1024 * 1024 * 1024, "~134s"), // 1 GB → ~134s
|
|
];
|
|
|
|
for (bad_bytes, label) in cases {
|
|
let damage_secs = bad_bytes as f64 / disc_bytes as f64 * duration_secs;
|
|
match label {
|
|
"~10ms" => assert!(damage_secs < 0.05, "{label}: {damage_secs:.3}s"),
|
|
"~1.3s" => assert!(
|
|
(damage_secs - 1.3).abs() < 0.2,
|
|
"{label}: {damage_secs:.2}s"
|
|
),
|
|
"~13s" => assert!(
|
|
(damage_secs - 13.0).abs() < 1.0,
|
|
"{label}: {damage_secs:.1}s"
|
|
),
|
|
"~134s" => assert!(
|
|
(damage_secs - 134.0).abs() < 2.0,
|
|
"{label}: {damage_secs:.0}s"
|
|
),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// 0.25s threshold: how many bad bytes = 0.25s of damage?
|
|
let threshold_bytes = (0.25 / duration_secs * disc_bytes as f64) as u64;
|
|
assert!(
|
|
threshold_bytes > 0,
|
|
"0.25s damage threshold should be > 0 bytes"
|
|
);
|
|
// At 9900s / 78.8 GB ≈ 0.25s = ~2 MB
|
|
let expected_mb = threshold_bytes as f64 / (1024.0 * 1024.0);
|
|
assert!(
|
|
(expected_mb - 2.0).abs() < 0.5,
|
|
"0.25s ≈ {expected_mb:.2} MB (expected ~2 MB)"
|
|
);
|
|
}
|