diff --git a/CHANGELOG.md b/CHANGELOG.md index d11e60e..7e45c7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.11.21 (2026-04-24) + +### Multi-pass rip architecture — disc → ISO → patch → ISO + +New primitives for two-stage rip: fast forward pass with zero-fill on failures, then targeted retries of bad ranges. Keeps the library API stream-based; the multi-pass model lives entirely in caller-orchestrated function composition. + +- **New `Disc::copy(reader, path, &CopyOptions)`** replaces the positional-arg version. Always produces a ddrescue-format mapfile at `path + ".mapfile"` as a side-effect. With `skip_on_error=true` + `skip_forward=true`, does ddrescue-style fast sweep: 64 KB block reads, exponential skip-forward (256 KB → cap at 1% of disc) on failure, zero-fill bad blocks, record ranges in the mapfile. With defaults (both false), matches pre-0.11.21 behavior — uses drive-level recovery, aborts on bad sector. Mapfile is produced either way. +- **New `Disc::patch(reader, path, &PatchOptions)`** — idempotent retry pass. Reads the mapfile, re-reads every non-`+` range with full drive recovery enabled, writes successful bytes back into the ISO at exact offsets, updates mapfile. Call N times for N retry attempts. +- **New `disc::mapfile` module** — ddrescue-compatible plain-text format. Crash-safe (flushes on every `record()`), greppable, human-editable, tool-interoperable. Status chars match ddrescue: `?` non-tried · `*` non-trimmed · `/` non-scraped · `-` unreadable · `+` finished. +- **Re-exports:** `FileSectorReader` from the crate root for ISO readers. + +### Breaking changes +- `Disc::copy`'s signature changes from positional args (`decrypt, resume, batch, on_progress`) to `CopyOptions`. Previous callers must migrate. `freemkv` CLI updated in lockstep. + +### Version sync +- Part of the 0.11.21 ecosystem release (libfreemkv + freemkv + bdemu + autorip all on 0.11.21). + ## 0.11.18 (2026-04-24) ### DiscStream halt flag — Stop works during dense bad-sector regions diff --git a/Cargo.toml b/Cargo.toml index 76cfadc..71c6a17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libfreemkv" -version = "0.11.18" +version = "0.11.21" edition = "2021" rust-version = "1.86" license = "AGPL-3.0-only" diff --git a/src/disc/mapfile.rs b/src/disc/mapfile.rs new file mode 100644 index 0000000..895e8d8 --- /dev/null +++ b/src/disc/mapfile.rs @@ -0,0 +1,422 @@ +//! ddrescue-compatible mapfile for tracking rip progress. +//! +//! Records which byte ranges of a disc image are good, unreadable, +//! or not-yet-attempted. Written as plain text so it's greppable, +//! human-editable, and interoperates with ddrescue's own tools. +//! +//! Format: +//! ```text +//! # Rescue Logfile. Created by libfreemkv v0.11.21 +//! # Current pos / status / pass / pass_time (ddrescue state machine — we only populate pos) +//! 0x000000000 ? 1 0 +//! # pos size status +//! 0x000000000 0x12345678 + +//! 0x012345678 0x00001000 - +//! 0x012346678 0x01234500 ? +//! ``` +//! +//! Status chars: `?` non-tried · `*` non-trimmed · `/` non-scraped · `-` unreadable · `+` finished. +//! +//! The mapfile is flushed to disk on every `record()` call so a crashed +//! rip loses at most one block of recorded state. + +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +/// Status of a byte range in the mapfile. ddrescue-compatible. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SectorStatus { + /// `?` — not yet attempted. Initial state for a fresh mapfile. + NonTried, + /// `*` — fast-pass read failed; edges need trimming. + NonTrimmed, + /// `/` — trimmed; interior needs sector scrape. + NonScraped, + /// `-` — drive couldn't read it this session. + Unreadable, + /// `+` — good. + Finished, +} + +impl SectorStatus { + pub fn to_char(self) -> char { + match self { + Self::NonTried => '?', + Self::NonTrimmed => '*', + Self::NonScraped => '/', + Self::Unreadable => '-', + Self::Finished => '+', + } + } + pub fn from_char(c: char) -> Option { + Some(match c { + '?' => Self::NonTried, + '*' => Self::NonTrimmed, + '/' => Self::NonScraped, + '-' => Self::Unreadable, + '+' => Self::Finished, + _ => return None, + }) + } +} + +/// One contiguous range of bytes with a status. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MapEntry { + pub pos: u64, + pub size: u64, + pub status: SectorStatus, +} + +/// Summary statistics over all entries. +#[derive(Debug, Clone, Copy, Default)] +pub struct MapStats { + pub bytes_total: u64, + pub bytes_good: u64, + pub bytes_unreadable: u64, + pub bytes_pending: u64, +} + +/// Write-through mapfile. Every `record()` persists to disk immediately +/// so a crash during rip loses at most one block. +pub struct Mapfile { + path: PathBuf, + entries: Vec, + total_size: u64, + version: String, +} + +impl Mapfile { + /// Create a new mapfile with one `NonTried` region covering the whole disc. + /// Writes to disk immediately so a resume can pick up even if the caller + /// never records anything. + pub fn create(path: &Path, total_size: u64, version: &str) -> io::Result { + let mf = Self { + path: path.to_path_buf(), + entries: vec![MapEntry { + pos: 0, + size: total_size, + status: SectorStatus::NonTried, + }], + total_size, + version: version.to_string(), + }; + mf.write_to_disk()?; + Ok(mf) + } + + /// Load an existing mapfile from disk. + pub fn load(path: &Path) -> io::Result { + let text = std::fs::read_to_string(path)?; + let mut entries = Vec::new(); + let mut saw_current_line = false; + let mut version = String::from("unknown"); + for line in text.lines() { + let t = line.trim(); + if t.is_empty() { + continue; + } + if let Some(rest) = t.strip_prefix('#') { + let rest = rest.trim(); + if let Some(v) = rest.strip_prefix("Rescue Logfile. Created by ") { + version = v.to_string(); + } + continue; + } + // First non-comment line is the "current" state line (pos status [pass] [pass_time]). + // We ignore its contents but skip over it. + if !saw_current_line { + saw_current_line = true; + // But if the line looks like an entry (has at least 3 fields starting 0x...), + // it's probably actually an entry for a mapfile we wrote without a current line. + // Heuristic: current line has status char as 2nd field; entry has size as 2nd field. + let fields: Vec<&str> = t.split_whitespace().collect(); + if fields.len() >= 3 && fields[1].starts_with("0x") { + // It's an entry, not a current line — fall through to entry parse. + } else { + continue; + } + } + // Entry: `pos size statuschar` + let fields: Vec<&str> = t.split_whitespace().collect(); + if fields.len() < 3 { + continue; + } + let pos = parse_hex(fields[0])?; + let size = parse_hex(fields[1])?; + let status = fields[2] + .chars() + .next() + .and_then(SectorStatus::from_char) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("bad status char in mapfile: {}", fields[2]), + ) + })?; + entries.push(MapEntry { pos, size, status }); + } + entries.sort_by_key(|e| e.pos); + let total_size = entries.last().map(|e| e.pos + e.size).unwrap_or(0); + Ok(Self { + path: path.to_path_buf(), + entries, + total_size, + version, + }) + } + + /// Load if the file exists, otherwise create a fresh mapfile. + pub fn open_or_create(path: &Path, total_size: u64, version: &str) -> io::Result { + match Self::load(path) { + Ok(mf) => Ok(mf), + Err(e) if e.kind() == io::ErrorKind::NotFound => Self::create(path, total_size, version), + Err(e) => Err(e), + } + } + + /// Mark a byte range as having the given status. Splits any overlapping + /// existing entries, merges with adjacent same-status entries, and flushes + /// to disk. + pub fn record(&mut self, pos: u64, size: u64, status: SectorStatus) -> io::Result<()> { + if size == 0 { + return Ok(()); + } + let end = pos.saturating_add(size); + let mut new_entries = Vec::with_capacity(self.entries.len() + 2); + + for e in self.entries.drain(..) { + let e_end = e.pos + e.size; + if e_end <= pos || e.pos >= end { + // entirely before or after — keep + new_entries.push(e); + continue; + } + // Overlap — keep portions outside [pos, end) + if e.pos < pos { + new_entries.push(MapEntry { + pos: e.pos, + size: pos - e.pos, + status: e.status, + }); + } + if e_end > end { + new_entries.push(MapEntry { + pos: end, + size: e_end - end, + status: e.status, + }); + } + } + new_entries.push(MapEntry { pos, size, status }); + new_entries.sort_by_key(|e| e.pos); + + // Coalesce adjacent same-status entries. + let mut merged: Vec = Vec::with_capacity(new_entries.len()); + for e in new_entries { + if let Some(last) = merged.last_mut() { + if last.pos + last.size == e.pos && last.status == e.status { + last.size += e.size; + continue; + } + } + merged.push(e); + } + + self.entries = merged; + self.write_to_disk()?; + Ok(()) + } + + pub fn entries(&self) -> &[MapEntry] { + &self.entries + } + + pub fn total_size(&self) -> u64 { + self.total_size + } + + /// First range with a given status starting at or after `from`. + pub fn next_with(&self, from: u64, status: SectorStatus) -> Option<(u64, u64)> { + for e in &self.entries { + if e.status != status { + continue; + } + let e_end = e.pos + e.size; + if e_end <= from { + continue; + } + let start = e.pos.max(from); + return Some((start, e_end - start)); + } + None + } + + /// All ranges matching one of the given statuses, in position order. + pub fn ranges_with(&self, statuses: &[SectorStatus]) -> Vec<(u64, u64)> { + self.entries + .iter() + .filter(|e| statuses.contains(&e.status)) + .map(|e| (e.pos, e.size)) + .collect() + } + + pub fn stats(&self) -> MapStats { + let mut s = MapStats { + bytes_total: self.total_size, + ..Default::default() + }; + for e in &self.entries { + match e.status { + SectorStatus::Finished => s.bytes_good += e.size, + SectorStatus::Unreadable => s.bytes_unreadable += e.size, + SectorStatus::NonTried | SectorStatus::NonTrimmed | SectorStatus::NonScraped => { + s.bytes_pending += e.size + } + } + } + s + } + + fn write_to_disk(&self) -> io::Result<()> { + // Write to a tempfile then rename for atomicity. Appending ".tmp" + // rather than `with_extension` so we don't clobber the original + // extension (which may already be ".mapfile"). + let tmp = { + let mut s = self.path.clone().into_os_string(); + s.push(".tmp"); + PathBuf::from(s) + }; + { + let file = std::fs::File::create(&tmp)?; + let mut w = std::io::BufWriter::new(file); + writeln!(w, "# Rescue Logfile. Created by libfreemkv v{}", self.version)?; + writeln!(w, "# Current pos / status / pass / pass_time")?; + writeln!(w, "0x000000000 ? 1 0")?; + writeln!(w, "# pos size status")?; + for e in &self.entries { + writeln!(w, "0x{:09x} 0x{:09x} {}", e.pos, e.size, e.status.to_char())?; + } + w.flush()?; + } + std::fs::rename(&tmp, &self.path)?; + Ok(()) + } +} + +fn parse_hex(s: &str) -> io::Result { + let s = s.strip_prefix("0x").unwrap_or(s); + u64::from_str_radix(s, 16) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("bad hex {s}: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmpfile(tag: &str) -> PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static CTR: AtomicU64 = AtomicU64::new(0); + let n = CTR.fetch_add(1, Ordering::Relaxed); + let name = format!( + "libfreemkv-mapfile-test-{}-{}-{}.mapfile", + std::process::id(), + tag, + n + ); + std::env::temp_dir().join(name) + } + + #[test] + fn create_has_one_nontried_region() { + let p = tmpfile("create_has_one_nontried_region"); + let _ = std::fs::remove_file(&p); + let mf = Mapfile::create(&p, 1000, "test").unwrap(); + assert_eq!(mf.entries().len(), 1); + assert_eq!(mf.entries()[0].pos, 0); + assert_eq!(mf.entries()[0].size, 1000); + assert_eq!(mf.entries()[0].status, SectorStatus::NonTried); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn record_splits_overlap() { + let p = tmpfile("record_splits_overlap"); + let _ = std::fs::remove_file(&p); + let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); + mf.record(200, 100, SectorStatus::Finished).unwrap(); + let es = mf.entries(); + assert_eq!(es.len(), 3); + assert_eq!((es[0].pos, es[0].size, es[0].status), (0, 200, SectorStatus::NonTried)); + assert_eq!((es[1].pos, es[1].size, es[1].status), (200, 100, SectorStatus::Finished)); + assert_eq!((es[2].pos, es[2].size, es[2].status), (300, 700, SectorStatus::NonTried)); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn record_coalesces_adjacent_same_status() { + let p = tmpfile("record_coalesces_adjacent_same_status"); + let _ = std::fs::remove_file(&p); + let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); + mf.record(100, 100, SectorStatus::Finished).unwrap(); + mf.record(200, 100, SectorStatus::Finished).unwrap(); + // Entries: [0..100 NonTried, 100..300 Finished (merged), 300..1000 NonTried] + let es = mf.entries(); + assert_eq!(es.len(), 3); + assert_eq!((es[1].pos, es[1].size, es[1].status), (100, 200, SectorStatus::Finished)); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn record_replaces_existing_status() { + let p = tmpfile("record_replaces_existing_status"); + let _ = std::fs::remove_file(&p); + let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); + mf.record(200, 100, SectorStatus::Unreadable).unwrap(); + mf.record(200, 100, SectorStatus::Finished).unwrap(); + let es = mf.entries(); + // The overwrite should result in all finished at 200..300, NonTried elsewhere — 3 entries. + assert_eq!(es.len(), 3); + assert_eq!(es[1].status, SectorStatus::Finished); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn round_trip_load() { + let p = tmpfile("round_trip_load"); + let _ = std::fs::remove_file(&p); + let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); + mf.record(100, 200, SectorStatus::Finished).unwrap(); + mf.record(500, 100, SectorStatus::Unreadable).unwrap(); + let loaded = Mapfile::load(&p).unwrap(); + assert_eq!(loaded.entries(), mf.entries()); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn stats_sum_correctly() { + let p = tmpfile("stats_sum_correctly"); + let _ = std::fs::remove_file(&p); + let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); + mf.record(0, 400, SectorStatus::Finished).unwrap(); + mf.record(400, 100, SectorStatus::Unreadable).unwrap(); + let s = mf.stats(); + assert_eq!(s.bytes_good, 400); + assert_eq!(s.bytes_unreadable, 100); + assert_eq!(s.bytes_pending, 500); + assert_eq!(s.bytes_total, 1000); + let _ = std::fs::remove_file(&p); + } + + #[test] + fn ranges_with_filters() { + let p = tmpfile("ranges_with_filters"); + let _ = std::fs::remove_file(&p); + let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); + mf.record(100, 50, SectorStatus::Unreadable).unwrap(); + mf.record(300, 50, SectorStatus::Unreadable).unwrap(); + let bad = mf.ranges_with(&[SectorStatus::Unreadable]); + assert_eq!(bad, vec![(100, 50), (300, 50)]); + let _ = std::fs::remove_file(&p); + } +} diff --git a/src/disc/mod.rs b/src/disc/mod.rs index a7a9f24..99a6993 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -11,6 +11,7 @@ mod bluray; mod dvd; mod encrypt; +pub mod mapfile; use crate::drive::Drive; use crate::error::{Error, Result}; @@ -1206,94 +1207,333 @@ impl Disc { /// Raw sector copy — write the entire disc image to a file. /// - /// This is NOT a stream operation. It copies sectors 0→capacity byte-for-byte, - /// producing a valid ISO/UDF image. The disc's filesystem structure is preserved. + /// NOT a stream operation. Copies sectors 0→capacity byte-for-byte producing + /// a valid ISO/UDF image. Records progress in a ddrescue-format mapfile at + /// `path + ".mapfile"` — flushed every block for crash-safe resume. /// - /// If `decrypt` is true and keys are available, sectors are decrypted on the fly. - /// If `resume` is true and the file already exists, resumes from the last safe position. - /// - /// `on_progress` is called periodically with (bytes_done, total_bytes). + /// # Options + /// - **default** (all false): behavior matches pre-v0.11.21 — uses full + /// drive recovery (may take minutes per bad sector), aborts on error. + /// Mapfile is produced as a side-effect. + /// - **skip_on_error**: zero-fill bad blocks in the ISO, mark them in the + /// mapfile, and continue. Uses fast reads (no drive-level recovery loop). + /// - **skip_forward** (implies skip_on_error): on block failure, also skip + /// forward by an exponentially-growing amount, marking the jumped region + /// as `non-trimmed` for later trimming/scraping by `Disc::patch`. + /// - **resume**: if the mapfile exists, resume from its state — only + /// `non-tried` ranges are read. Without `resume`, a fresh mapfile is + /// written and the ISO recreated from scratch. pub fn copy( &self, reader: &mut dyn SectorReader, path: &std::path::Path, - decrypt: bool, - resume: bool, - batch_sectors: Option, - on_progress: Option<&dyn Fn(u64, u64)>, - ) -> Result<()> { + opts: &CopyOptions, + ) -> Result { use std::io::{Seek, SeekFrom, Write}; let total_bytes = self.capacity_sectors as u64 * 2048; - let keys = if decrypt { + let keys = if opts.decrypt { self.decrypt_keys() } else { crate::decrypt::DecryptKeys::None }; - // Resume: check existing file - let (start_lba, file) = if resume { - match std::fs::metadata(path) { - Ok(meta) if meta.len() > 0 => { - let safe_sectors = (meta.len() / 2048).saturating_sub(5) as u32; - let mut f = std::fs::OpenOptions::new() - .write(true) - .open(path) - .map_err(|e| Error::IoError { source: e })?; - let resume_pos = safe_sectors as u64 * 2048; - f.set_len(resume_pos) - .map_err(|e| Error::IoError { source: e })?; - f.seek(SeekFrom::End(0)) - .map_err(|e| Error::IoError { source: e })?; - (safe_sectors, f) - } - _ => { - let f = - std::fs::File::create(path).map_err(|e| Error::IoError { source: e })?; - (0u32, f) - } - } + // Mapfile: load if resuming, else wipe + recreate. + let mapfile_path = mapfile_path_for(path); + if !opts.resume { + let _ = std::fs::remove_file(&mapfile_path); + } + let mut map = mapfile::Mapfile::open_or_create(&mapfile_path, total_bytes, env!("CARGO_PKG_VERSION")) + .map_err(|e| Error::IoError { source: e })?; + + // ISO file: if resuming and mapfile has Finished ranges, open existing; + // otherwise create fresh and pre-size to total_bytes (sparse holes for + // non-tried regions). + let file = if opts.resume && std::fs::metadata(path).map(|m| m.len() > 0).unwrap_or(false) { + std::fs::OpenOptions::new() + .write(true) + .open(path) + .map_err(|e| Error::IoError { source: e })? } else { let f = std::fs::File::create(path).map_err(|e| Error::IoError { source: e })?; - (0u32, f) + f.set_len(total_bytes).map_err(|e| Error::IoError { source: e })?; + f }; - let mut writer = std::io::BufWriter::with_capacity(4 * 1024 * 1024, file); - let batch: u16 = batch_sectors.unwrap_or(DEFAULT_BATCH_SECTORS); - let mut lba = start_lba; - let mut bytes_done = start_lba as u64 * 2048; + let mut file = file; + let batch: u16 = match opts.batch_sectors { + Some(b) => b, + None if opts.skip_forward => 32, // 64 KB = BD ECC block size + None => DEFAULT_BATCH_SECTORS, + }; + + // Skip-forward state. + let skip_init = 256 * 1024u64; // 256 KB + let skip_max = (total_bytes / 100).max(skip_init); // cap at 1% of disc + let mut skip_size = skip_init; + let mut buf = vec![0u8; batch as usize * 2048]; + let mut bytes_done = 0u64; + let mut halt_requested = false; - while lba < self.capacity_sectors { - let remaining = self.capacity_sectors - lba; - let count = remaining.min(batch as u32) as u16; - let bytes = count as usize * 2048; - - reader - .read_sectors(lba, count, &mut buf[..bytes], true) - .map_err(|e| Error::IoError { - source: std::io::Error::other(e.to_string()), - })?; - - // Decrypt if requested - if decrypt { - crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?; + // Iterate over not-yet-finished regions from the mapfile. We re-read the + // mapfile after each block because record() mutates the region list. + 'outer: loop { + let regions_to_do = map.ranges_with(&[ + mapfile::SectorStatus::NonTried, + mapfile::SectorStatus::NonTrimmed, + mapfile::SectorStatus::NonScraped, + ]); + if regions_to_do.is_empty() { + break; } + // Only process the first NonTried range per outer pass; skip_forward + // may turn others into NonTrimmed which we DO NOT re-enter here — + // Disc::patch handles those. + let Some((region_pos, region_size)) = map + .next_with(0, mapfile::SectorStatus::NonTried) + else { + break; + }; + let region_end = region_pos + region_size; + let mut pos = region_pos; - writer - .write_all(&buf[..bytes]) - .map_err(|e| Error::IoError { source: e })?; + while pos < region_end { + if let Some(ref h) = opts.halt { + if h.load(std::sync::atomic::Ordering::Relaxed) { + halt_requested = true; + break 'outer; + } + } + let block_bytes = (region_end - pos).min(batch as u64 * 2048); + let lba = (pos / 2048) as u32; + let count = (block_bytes / 2048) as u16; + let bytes = count as usize * 2048; - lba += count as u32; - bytes_done += bytes as u64; + let recovery = !opts.skip_on_error; // fast reads when skipping + let read_ok = reader + .read_sectors(lba, count, &mut buf[..bytes], recovery) + .is_ok(); - if let Some(ref cb) = on_progress { - cb(bytes_done, total_bytes); + if read_ok { + if opts.decrypt { + crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?; + } + file.seek(SeekFrom::Start(pos)).map_err(|e| Error::IoError { source: e })?; + file.write_all(&buf[..bytes]).map_err(|e| Error::IoError { source: e })?; + map.record(pos, block_bytes, mapfile::SectorStatus::Finished) + .map_err(|e| Error::IoError { source: e })?; + bytes_done = bytes_done.saturating_add(block_bytes); + skip_size = skip_init; // reset after success + pos += block_bytes; + } else if opts.skip_on_error { + // Zero-fill this block, mark non-trimmed for later patch trim. + buf[..bytes].fill(0); + file.seek(SeekFrom::Start(pos)).map_err(|e| Error::IoError { source: e })?; + file.write_all(&buf[..bytes]).map_err(|e| Error::IoError { source: e })?; + map.record(pos, block_bytes, mapfile::SectorStatus::NonTrimmed) + .map_err(|e| Error::IoError { source: e })?; + pos += block_bytes; + + if opts.skip_forward && pos < region_end { + // Skip ahead; mark skipped bytes as non-trimmed too. + let jump = skip_size.min(region_end - pos); + if jump > 0 { + map.record(pos, jump, mapfile::SectorStatus::NonTrimmed) + .map_err(|e| Error::IoError { source: e })?; + pos += jump; + } + skip_size = (skip_size * 2).min(skip_max); + } + } else { + // Current behavior (pre-0.11.21): abort on first bad sector. + return Err(Error::DiscRead { sector: lba as u64 }); + } + + if let Some(cb) = opts.on_progress { + let stats = map.stats(); + cb(stats.bytes_good, total_bytes); + } } } - writer.flush().map_err(|e| Error::IoError { source: e })?; - Ok(()) + file.sync_all().map_err(|e| Error::IoError { source: e })?; + let stats = map.stats(); + Ok(CopyResult { + bytes_total: total_bytes, + bytes_good: stats.bytes_good, + bytes_unreadable: stats.bytes_unreadable, + bytes_pending: stats.bytes_pending, + complete: stats.bytes_pending == 0 && !halt_requested, + halted: halt_requested, + }) + } +} + +/// Options for `Disc::copy`. All fields default to the pre-v0.11.21 behavior +/// (recovery reads, abort on bad sector). +#[derive(Default)] +pub struct CopyOptions<'a> { + pub decrypt: bool, + /// Resume from existing mapfile + ISO if present. Without this, any + /// existing mapfile is wiped and the ISO recreated. + pub resume: bool, + /// Override the default block size. Defaults to 32 sectors (64 KB) in + /// `skip_forward` mode, `DEFAULT_BATCH_SECTORS` otherwise. + pub batch_sectors: Option, + /// Zero-fill bad blocks in the ISO, mark them in the mapfile, continue. + /// Uses fast reads (no drive-level recovery loop). + pub skip_on_error: bool, + /// ddrescue-style exponential skip-forward on block failure. Implies + /// `skip_on_error`. The skipped region is marked `non-trimmed` for later + /// trimming/scraping by `Disc::patch`. + pub skip_forward: bool, + pub on_progress: Option<&'a dyn Fn(u64, u64)>, + pub halt: Option>, +} + +/// Result of `Disc::copy`. `complete=true` means every byte reached a terminal +/// state (Finished or Unreadable). `complete=false` means there's still pending +/// work (halt, abort, or non-tried ranges) that `Disc::patch` or a resumed +/// `Disc::copy` would continue. +#[derive(Debug, Clone, Copy)] +pub struct CopyResult { + pub bytes_total: u64, + pub bytes_good: u64, + pub bytes_unreadable: u64, + pub bytes_pending: u64, + pub complete: bool, + pub halted: bool, +} + +/// Sidecar mapfile path for a given ISO path — `foo.iso` → `foo.iso.mapfile`. +pub fn mapfile_path_for(iso_path: &std::path::Path) -> std::path::PathBuf { + let mut s = iso_path.as_os_str().to_os_string(); + s.push(".mapfile"); + std::path::PathBuf::from(s) +} + +/// Options for `Disc::patch`. Idempotent — each call is one patch attempt. +#[derive(Default)] +pub struct PatchOptions<'a> { + pub decrypt: bool, + /// Sector-granularity block size for retries. Defaults to 1 sector (2 KB). + pub block_sectors: Option, + /// Use full drive-level recovery on each read (slow but thorough). Defaults + /// to true — patch is the pass where we *want* the drive to try hard. + pub full_recovery: bool, + pub on_progress: Option<&'a dyn Fn(u64, u64)>, + pub halt: Option>, +} + +/// Result of `Disc::patch` — how many bad bytes were recovered. +#[derive(Debug, Clone, Copy)] +pub struct PatchResult { + pub bytes_total: u64, + pub bytes_good: u64, + pub bytes_unreadable: u64, + pub bytes_pending: u64, + pub bytes_recovered_this_pass: u64, + pub halted: bool, +} + +impl Disc { + /// Patch an existing ISO using its sidecar mapfile. Re-reads every range + /// that's not yet `+` (Finished) and writes successful bytes into the ISO + /// at their exact offsets. Updates mapfile entries as it goes. + /// + /// Idempotent — call repeatedly to apply more retry attempts. Stops early + /// if a pass recovered zero bytes (no point continuing). + pub fn patch( + &self, + reader: &mut dyn SectorReader, + path: &std::path::Path, + opts: &PatchOptions, + ) -> Result { + use std::io::{Seek, SeekFrom, Write}; + + let mapfile_path = mapfile_path_for(path); + let mut map = mapfile::Mapfile::load(&mapfile_path).map_err(|e| Error::IoError { source: e })?; + let total_bytes = map.total_size(); + let keys = if opts.decrypt { + self.decrypt_keys() + } else { + crate::decrypt::DecryptKeys::None + }; + + let mut file = std::fs::OpenOptions::new() + .write(true) + .open(path) + .map_err(|e| Error::IoError { source: e })?; + + let block_sectors = opts.block_sectors.unwrap_or(1); + // Patch always reads with full drive recovery — this is the pass where + // we want the drive's ECC retry machinery. Consumers who want fast-fail + // use Disc::copy with skip_on_error instead. + let _ = opts.full_recovery; + + let bytes_good_before = map.stats().bytes_good; + let mut halted = false; + let mut buf = vec![0u8; block_sectors as usize * 2048]; + + // Collect bad ranges up front. Iterating while mutating is fragile; + // each recorded change is persisted, so resume works even if we crash + // mid-loop. + let bad_ranges = map.ranges_with(&[ + mapfile::SectorStatus::NonTried, + mapfile::SectorStatus::NonTrimmed, + mapfile::SectorStatus::NonScraped, + mapfile::SectorStatus::Unreadable, + ]); + + 'outer: for (range_pos, range_size) in bad_ranges { + let mut pos = range_pos; + let end = range_pos + range_size; + while pos < end { + if let Some(ref h) = opts.halt { + if h.load(std::sync::atomic::Ordering::Relaxed) { + halted = true; + break 'outer; + } + } + let block_bytes = (end - pos).min(block_sectors as u64 * 2048); + let lba = (pos / 2048) as u32; + let count = (block_bytes / 2048) as u16; + let bytes = count as usize * 2048; + let read_ok = reader + .read_sectors(lba, count, &mut buf[..bytes], true) + .is_ok(); + if read_ok { + if opts.decrypt { + crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?; + } + file.seek(SeekFrom::Start(pos)).map_err(|e| Error::IoError { source: e })?; + file.write_all(&buf[..bytes]).map_err(|e| Error::IoError { source: e })?; + map.record(pos, block_bytes, mapfile::SectorStatus::Finished) + .map_err(|e| Error::IoError { source: e })?; + } else { + map.record(pos, block_bytes, mapfile::SectorStatus::Unreadable) + .map_err(|e| Error::IoError { source: e })?; + } + pos += block_bytes; + + if let Some(cb) = opts.on_progress { + let s = map.stats(); + cb(s.bytes_good, total_bytes); + } + } + } + + file.sync_all().map_err(|e| Error::IoError { source: e })?; + let stats = map.stats(); + Ok(PatchResult { + bytes_total: total_bytes, + bytes_good: stats.bytes_good, + bytes_unreadable: stats.bytes_unreadable, + bytes_pending: stats.bytes_pending, + bytes_recovered_this_pass: stats.bytes_good.saturating_sub(bytes_good_before), + halted, + }) } } diff --git a/src/lib.rs b/src/lib.rs index 14ba094..ad0506f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -117,6 +117,6 @@ pub use mux::NullStream; pub use mux::StdioStream; pub use mux::{input, output, parse_url, InputOptions, StreamUrl}; pub use scsi::ScsiTransport; -pub use sector::SectorReader; +pub use sector::{FileSectorReader, SectorReader}; pub use speed::DriveSpeed; pub use udf::{read_filesystem, UdfFs};