//! 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 vX.Y.Z //! # 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 at most once per `FLUSH_INTERVAL` //! during `record()` calls, plus on explicit `flush()` and on `Drop`. //! This bounds atomic-rename RPC rate on networked staging (e.g. NFS) //! where per-record persists otherwise serialize the rip pipeline. use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; /// Minimum interval between mapfile persists. `record()` updates in-memory /// state every call but only writes to disk when this interval has elapsed /// since the last persist (or when `flush()` is called explicitly, or on /// `Drop`). Bounds RPC rate on NFS staging where atomic-rename per record /// otherwise dominates throughput. On crash the worst-case progress loss /// is one interval's worth of records. const FLUSH_INTERVAL: Duration = Duration::from_millis(1000); /// 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 { /// The single ddrescue status character for this status /// (`?`/`*`/`/`/`-`/`+`). pub fn to_char(self) -> char { match self { Self::NonTried => '?', Self::NonTrimmed => '*', Self::NonScraped => '/', Self::Unreadable => '-', Self::Finished => '+', } } /// Parse a ddrescue status character into a `SectorStatus`. Returns /// `None` for any character that is not one of `?*/-+`. 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. /// /// `bytes_pending` aggregates `NonTried + NonTrimmed + NonScraped` for /// back-compat. `bytes_nontried` and `bytes_retryable` (= NonTrimmed + /// NonScraped) split that aggregate so UIs can distinguish *unread* /// territory (still ahead of Pass 1's read head) from *needs-retry* /// territory (Pass 1 already encountered, queued for Pass 2-N). #[derive(Debug, Clone, Copy, Default)] pub struct MapStats { pub bytes_total: u64, pub bytes_good: u64, pub bytes_unreadable: u64, pub bytes_pending: u64, /// Sectors Pass 1 hasn't reached yet (`NonTried`). Subset of /// `bytes_pending`. pub bytes_nontried: u64, /// Sectors flagged for Pass 2-N retry — `NonTrimmed` (multi-sector /// read failed; needs split) + `NonScraped` (small-block read /// partially recovered; remainder still pending). Subset of /// `bytes_pending`. This is the right signal for a "MAYBE / will /// retry" UI bucket; `bytes_pending` over-counts because it folds /// in `bytes_nontried`. pub bytes_retryable: u64, /// Number of distinct `Unreadable` ranges (for UI display). /// Computed by `compute_stats` (counts coalesced `-` entries). pub num_bad_ranges: u32, /// Largest gap among unreadable ranges in milliseconds. Computed as /// largest range size / bytes_per_sec * 1000. Set by caller (autorip) /// since bytes_per_sec is application-specific. pub main_lost_ms: f64, } /// Time-batched mapfile. `record()` keeps in-memory state up-to-date on /// every call; persists to disk at most once per `FLUSH_INTERVAL`. /// Explicit `flush()` and `Drop` guarantee state is on disk after a sweep /// or patch finishes. On hard crash the worst-case loss is one flush /// interval of records — the file's payload bytes are unaffected. pub struct Mapfile { path: PathBuf, entries: Vec, total_size: u64, version: String, /// Incrementally maintained stats — updated on every `record()` call /// so `stats()` is O(1) instead of O(n). stats: MapStats, /// True when in-memory state has changed but `write_to_disk` has not /// yet captured it. dirty: bool, /// Wall-clock timestamp of the last successful `write_to_disk` (or /// the moment the mapfile was constructed, whichever is later). last_flushed: Instant, /// AACS Volume ID (16 bytes) for the disc, persisted as a /// `# freemkv-vid:` comment header so it survives to deferred-mux / /// resume without altering the ISO payload or breaking ddrescue /// data-line parsing. `None` for unencrypted / non-AACS discs. /// /// MUTUALLY EXCLUSIVE with `unit_keys`: a disc whose keys were resolved /// persists the keys (`unit_keys`) and NOT the VID — the keys are the final /// answer, so deferred-mux/resume decrypts directly with no key service. A /// disc that did NOT resolve persists only the VID, the retry-able "still /// need a key" marker (a future mux can re-ask the key service with it). vid: Option<[u8; 16]>, /// Decrypted AACS unit keys `(CPS unit, key)`, persisted as `# freemkv-uk:` /// comment headers when the disc was successfully keyed. Mutually exclusive /// with `vid` (see above). Empty when unresolved. unit_keys: Vec<(u32, [u8; 16])>, } 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 mut mf = Self { path: path.to_path_buf(), entries: vec![MapEntry { pos: 0, size: total_size, status: SectorStatus::NonTried, }], total_size, version: version.to_string(), stats: MapStats { bytes_total: total_size, bytes_pending: total_size, bytes_nontried: total_size, ..Default::default() }, dirty: false, last_flushed: Instant::now(), vid: None, unit_keys: Vec::new(), }; // Eager initial persist so a resume can pick this up even if // `record()` is never called. mf.write_to_disk()?; mf.last_flushed = Instant::now(); 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"); let mut vid: Option<[u8; 16]> = None; let mut unit_keys: Vec<(u32, [u8; 16])> = Vec::new(); 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(); } if let Some(hex) = rest.strip_prefix("freemkv-vid:") { // Best-effort: a malformed or short VID comment is // ignored rather than failing the whole load. vid = parse_vid_hex(hex.trim()); } if let Some(uk) = rest.strip_prefix("freemkv-uk:") { // `:<32hex>`. Best-effort: a malformed line is skipped. if let Some(entry) = parse_uk_line(uk.trim()) { unit_keys.push(entry); } } 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; // Discriminate by ddrescue's actual line shape, not by a // `0x`-prefix heuristic (which dropped a valid first data line // whose size field happened to lack `0x`). A *current* line's // 2nd field is a single status char (`?*/-+`); a *data* line's // 2nd field is the hex size, with the status char in the 3rd. // So: single-char-and-valid-status 2nd field ⇒ current line // (skip); anything else ⇒ fall through to entry parse. let fields: Vec<&str> = t.split_whitespace().collect(); let is_current_line = fields .get(1) .and_then(|f| { let mut chars = f.chars(); match (chars.next(), chars.next()) { // Exactly one char that is a valid status char. (Some(c), None) => SectorStatus::from_char(c), _ => None, } }) .is_some(); if is_current_line { continue; } // Otherwise it's a data line — fall through to entry parse. } // 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])?; // Reject an entry whose pos+size overflows u64 up front. The // downstream overlap/coalesce/next_with code adds pos+size // freely; a crafted/corrupt line like // `0xfffffffffffffff0 0x20 +` would otherwise panic (debug) // or wrap to a tiny range (release), corrupting stats and // resume logic. if pos.checked_add(size).is_none() { let e: io::Error = crate::error::Error::MapfileInvalid { kind: "range" }.into(); return Err(e); } // A zero-size entry is degenerate: it contributes nothing to the // partition yet trips overlap/coalesce arithmetic (two entries can // share the same pos). Reject it rather than carry it through. if size == 0 { let e: io::Error = crate::error::Error::MapfileInvalid { kind: "zero_size" }.into(); return Err(e); } let status = fields[2] .chars() .next() .and_then(SectorStatus::from_char) .ok_or_else(|| { // No English text — the variant carries a stable // language-neutral kind identifier (`status_char`). let e: io::Error = crate::error::Error::MapfileInvalid { kind: "status_char", } .into(); e })?; entries.push(MapEntry { pos, size, status }); } entries.sort_by_key(|e| e.pos); // Reject overlapping ranges, then COALESCE-FILL any internal gaps // with synthetic NonTried entries. A well-formed ddrescue mapfile // is a *gap-free* disjoint partition of [0, total_size). // // Overlaps (from a corrupt/hand-edited file) would make // compute_stats double-count, so bytes_good / bytes_unreadable / // bytes_pending could exceed bytes_total and inflate resume / // abort-on-loss decisions and >100% progress — hard-reject those. // // GAPS are a subtler hazard: total_size is derived from the last // entry's end, so a holed mapfile passes the caller's // `covers_disc = (total_size == disc_size)` check and copy() would // report complete=true even though the hole was never read. Rather // than hard-reject (which would strand existing partial mapfiles), // we fill every gap — leading, internal, and any between entries — // with a NonTried entry so the gap is visible to the resume // sweep's NonTried region list and actually gets read. (A trailing // gap up to the disc size is filled by the caller's full-sweep // path when total_size < disc_size; here we only have the mapfile's // own extent to reason about.) let mut filled: Vec = Vec::with_capacity(entries.len() + 1); let mut cursor: u64 = 0; for e in entries { if e.pos < cursor { let err: io::Error = crate::error::Error::MapfileInvalid { kind: "overlap" }.into(); return Err(err); } if e.pos > cursor { // Leading or internal gap — fill it as NonTried. filled.push(MapEntry { pos: cursor, size: e.pos - cursor, status: SectorStatus::NonTried, }); } cursor = e.pos.saturating_add(e.size); filled.push(e); } let entries = filled; let total_size = entries .last() .map(|e| e.pos.saturating_add(e.size)) .unwrap_or(0); // Enforce the keys-XOR-vid invariant that set_unit_keys() // guarantees: a corrupt/hand-edited file carrying both comment // types would otherwise load with vid=Some AND non-empty // unit_keys, violating the invariant downstream code relies on. // Unit keys win, matching the setter (it clears vid when keys // are present). if !unit_keys.is_empty() { vid = None; } let stats = Self::compute_stats(&entries, total_size); Ok(Self { path: path.to_path_buf(), entries, total_size, version, stats, dirty: false, last_flushed: Instant::now(), vid, unit_keys, }) } /// 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) => { // load() derives total_size from the last entry's // pos+size; if that disagrees with the caller's // expected disc size (different disc, edited/partial // file, trimmed trailing region) the downstream // resume/progress math keys off the wrong basis. Surface // it so an operator can spot a mismatched mapfile rather // than failing the resume outright. if mf.total_size != total_size { tracing::warn!( target: "freemkv::disc", phase = "mapfile_total_size_mismatch", loaded_total = mf.total_size, supplied_total = total_size, path = %path.display(), "loaded mapfile coverage differs from supplied disc size" ); } 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(()); } // Mirror load()'s overflow contract: reject a range that would // wrap u64 rather than storing a saturated entry narrower than // its size, which load() would then reject on the next resume // (making the mapfile unreadable). let Some(end) = pos.checked_add(size) else { let e: io::Error = crate::error::Error::MapfileInvalid { kind: "range" }.into(); return Err(e); }; let mut new_entries = Vec::with_capacity(self.entries.len() + 2); for e in self.entries.drain(..) { let e_end = e.pos.saturating_add(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.saturating_add(last.size) == e.pos && last.status == e.status { last.size = last.size.saturating_add(e.size); continue; } } merged.push(e); } // Recompute stats from merged entries. record() is already O(n) due to // drain-and-rebuild, so this is a constant-factor overhead. The critical // win is that stats() is now O(1) — called millions of times in the hot // path during sweep/patch, it just returns the cached value. self.stats = Self::compute_stats(&merged, self.total_size); self.entries = merged; self.dirty = true; if self.last_flushed.elapsed() >= FLUSH_INTERVAL { self.write_to_disk()?; self.dirty = false; self.last_flushed = Instant::now(); } Ok(()) } /// Persist any pending in-memory changes to disk. No-op if clean. /// Callers (sweep/patch finalisation) invoke this after their last /// `record()` to guarantee state is durable before returning. pub fn flush(&mut self) -> io::Result<()> { if self.dirty { self.write_to_disk()?; self.dirty = false; self.last_flushed = Instant::now(); } Ok(()) } /// Record the disc's 16-byte AACS Volume ID so it persists in the /// mapfile's comment header. Marks the mapfile dirty; the next /// `flush()` / `Drop` writes the `# freemkv-vid:` line. Does not /// touch the ISO payload or the ddrescue data lines. pub fn set_vid(&mut self, vid: [u8; 16]) { self.vid = Some(vid); self.dirty = true; } /// The disc's AACS Volume ID, if one was set or parsed from a /// `# freemkv-vid:` comment on load. `None` for unencrypted / /// non-AACS discs. pub fn vid(&self) -> Option<[u8; 16]> { self.vid } /// Record the disc's decrypted AACS unit keys so they persist in the /// mapfile header (`# freemkv-uk:` lines). The KEYED state: a deferred-mux / /// resume decrypts directly from these with no key-service round-trip. /// Setting keys clears any VID — the mapfile holds keys XOR VID, never both /// (keys are the final answer; VID is only the "still unresolved" marker). pub fn set_unit_keys(&mut self, keys: &[(u32, [u8; 16])]) { self.unit_keys = keys.to_vec(); if !self.unit_keys.is_empty() { self.vid = None; } self.dirty = true; } /// The disc's decrypted AACS unit keys, if the disc was keyed (parsed from /// `# freemkv-uk:` comments on load). Empty = unresolved (check `vid()`). pub fn unit_keys(&self) -> &[(u32, [u8; 16])] { &self.unit_keys } /// All map entries, sorted ascending by `pos` and (after load) /// guaranteed disjoint and non-overflowing. pub fn entries(&self) -> &[MapEntry] { &self.entries } /// Total image size in bytes, i.e. the end byte of the last entry. 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.saturating_add(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() } /// Snapshot of the incrementally-maintained summary statistics. /// O(1) — returns the cached `MapStats`. pub fn stats(&self) -> MapStats { self.stats } fn compute_stats(entries: &[MapEntry], total_size: u64) -> MapStats { let mut s = MapStats { bytes_total: total_size, ..Default::default() }; for e in entries { match e.status { SectorStatus::Finished => s.bytes_good += e.size, SectorStatus::Unreadable => { s.bytes_unreadable += e.size; s.num_bad_ranges += 1; } SectorStatus::NonTried => { s.bytes_pending += e.size; s.bytes_nontried += e.size; } SectorStatus::NonTrimmed | SectorStatus::NonScraped => { s.bytes_pending += e.size; s.bytes_retryable += 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 {}", self.version)?; // VID comment lives in the header block. ddrescue treats any // `#`-prefixed line as a comment, so this round-trips through // our `load()` without affecting the `pos size status` data // parser. 16 bytes → 32 lowercase hex chars. // KEYS XOR VID: a keyed disc persists its unit keys (the final // answer — deferred-mux decrypts directly); an unresolved disc // persists only the VID (the retry marker, so a future mux can // re-ask the key service). Never both. use std::fmt::Write as _; if !self.unit_keys.is_empty() { for (cps, key) in &self.unit_keys { let mut hex = String::with_capacity(32); for b in key { let _ = write!(hex, "{b:02x}"); } writeln!(w, "# freemkv-uk: {cps}:{hex}")?; } } else if let Some(vid) = self.vid { let mut hex = String::with_capacity(32); for b in vid { let _ = write!(hex, "{b:02x}"); } writeln!(w, "# freemkv-vid: {hex}")?; } 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()?; // fsync the tmp file before the rename so the bytes are durable on // disk (notably on NFS, where a rename can otherwise reach the // server before the data does and leave a truncated mapfile after // a crash). Recover the File from the BufWriter to call sync_all. let file = w.into_inner().map_err(|e| e.into_error())?; file.sync_all()?; } std::fs::rename(&tmp, &self.path)?; // fsync the parent directory so the rename itself is durable. Syncing // the tmp file's bytes (above) is not enough: after the rename the new // dirent for the final mapfile name lives only in the directory's // page cache, so a crash / power loss in the rename-commit window can // lose it and leave resume reading a stale or absent mapfile even // though the data was synced. On NFS — the case the tmp-fsync guards — // this window is the wide one. Best-effort: a dir that can't be // opened/synced (some filesystems, Windows) is not a write failure. if let Some(parent) = self.path.parent() { fsync_dir(parent); } Ok(()) } } /// fsync a directory so a prior `rename(2)` into it is durable. After a /// crash a renamed file's dirent can otherwise be lost even though the /// rename returned, because it is still page-cache-only. Best-effort: /// opening the directory for read and `sync_all()`ing it is the POSIX way /// to flush its metadata; failures (unsupported fs, Windows) are logged /// and ignored rather than propagated, since the file bytes are already /// durable and the caller's write succeeded. fn fsync_dir(dir: &Path) { match std::fs::File::open(dir) { Ok(f) => { if let Err(e) = f.sync_all() { tracing::warn!(path = %dir.display(), error = %e, "failed to fsync mapfile directory"); } } Err(e) => { tracing::warn!(path = %dir.display(), error = %e, "could not open mapfile directory to fsync"); } } } impl Drop for Mapfile { /// Best-effort flush on drop so a sweep / patch that returns early /// (or unwinds) doesn't lose its in-memory state. Errors here are /// swallowed because Drop has no way to surface them; explicit /// `flush()` on the success path gives callers proper error handling. fn drop(&mut self) { let _ = self.flush(); } } /// Parse a 32-char lowercase/uppercase hex string into a 16-byte VID. /// Returns `None` on any malformation (wrong length, non-hex) — the /// caller treats a bad VID comment as simply absent rather than an /// error, so a corrupt header never fails a mapfile load. fn parse_vid_hex(s: &str) -> Option<[u8; 16]> { let s = s.strip_prefix("0x").unwrap_or(s); // Parse on bytes, not on the &str: slicing a &str by byte index // (`&s[i*2..i*2+2]`) panics when the cut lands inside a multi-byte // UTF-8 char. A hand-edited/corrupt `# freemkv-vid:` comment of // exactly 32 bytes containing a multi-byte char would otherwise // kill the whole load. ASCII hex is one byte per char, so anything // non-ASCII is simply rejected here as malformed. let bytes = s.as_bytes(); if bytes.len() != 32 { return None; } let mut out = [0u8; 16]; for (i, b) in out.iter_mut().enumerate() { let hi = hex_nibble(bytes[i * 2])?; let lo = hex_nibble(bytes[i * 2 + 1])?; *b = (hi << 4) | lo; } Some(out) } /// Map a single ASCII hex digit byte to its 0-15 value. Returns `None` /// for any non-hex byte (including any non-ASCII / multi-byte lead byte). fn hex_nibble(c: u8) -> Option { match c { b'0'..=b'9' => Some(c - b'0'), b'a'..=b'f' => Some(c - b'a' + 10), b'A'..=b'F' => Some(c - b'A' + 10), _ => None, } } /// Parse a `# freemkv-uk:` value `:<32hex>` into `(cps_unit, key)`. Returns /// `None` on any malformation so a corrupt line is ignored, never fatal. fn parse_uk_line(s: &str) -> Option<(u32, [u8; 16])> { let (cps, hex) = s.split_once(':')?; let cps: u32 = cps.trim().parse().ok()?; let key = parse_vid_hex(hex.trim())?; // 32-hex → [u8; 16], shared parser Some((cps, key)) } fn parse_hex(s: &str) -> io::Result { let s = s.strip_prefix("0x").unwrap_or(s); u64::from_str_radix(s, 16).map_err(|_| { // Underlying ParseIntError dropped — its Display is OS-locale text. // The typed variant carries `kind = "hex"` which is stable. let e: io::Error = crate::error::Error::MapfileInvalid { kind: "hex" }.into(); 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 ); let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/test-scratch"); let _ = std::fs::create_dir_all(&dir); 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(); // record() batches; explicit flush before reading back from disk. mf.flush().unwrap(); let loaded = Mapfile::load(&p).unwrap(); assert_eq!(loaded.entries(), mf.entries()); let _ = std::fs::remove_file(&p); } #[test] fn write_to_disk_fsyncs_and_leaves_no_tmp() { // Regression: write_to_disk must recover the File from the BufWriter // and sync_all() it before rename (NFS durability). The .tmp file // must not survive a successful write, and the renamed mapfile must // load back identically. let p = tmpfile("write_to_disk_fsyncs"); let _ = std::fs::remove_file(&p); let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); mf.record(100, 200, SectorStatus::Finished).unwrap(); mf.write_to_disk().unwrap(); let mut tmp = p.clone().into_os_string(); tmp.push(".tmp"); assert!( !PathBuf::from(&tmp).exists(), "tmp file should be renamed away after a successful write" ); let loaded = Mapfile::load(&p).unwrap(); assert_eq!(loaded.entries(), mf.entries()); let _ = std::fs::remove_file(&p); } #[test] fn write_to_disk_fsyncs_parent_dir() { // Regression: after rename(2), write_to_disk must fsync the parent // directory so the new dirent is durable (not page-cache-only). We // can't observe a power-loss window in a unit test, but we exercise // the parent-fsync branch against a real subdirectory and confirm the // best-effort dir-sync neither errors the write nor corrupts the // round-trip. A missing/unsyncable dir must not fail the write. let dir = tmpfile("write_to_disk_fsyncs_parent_dir"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let p = dir.join("disc.mapfile"); let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); mf.record(0, 400, SectorStatus::Finished).unwrap(); mf.record(400, 100, SectorStatus::Unreadable).unwrap(); mf.write_to_disk().unwrap(); // The directly-called dir fsync helper must be a no-op-on-error, // never a panic, even for a nonexistent directory. fsync_dir(&dir.join("does-not-exist")); let loaded = Mapfile::load(&p).unwrap(); assert_eq!(loaded.entries(), mf.entries()); let _ = std::fs::remove_dir_all(&dir); } #[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); } #[test] fn stats_consistent_after_overlapping_records() { let p = tmpfile("stats_consistent_after_overlapping"); let _ = std::fs::remove_file(&p); let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); // Record some finished, some unreadable, some nontrimmed mf.record(0, 300, SectorStatus::Finished).unwrap(); mf.record(300, 200, SectorStatus::NonTrimmed).unwrap(); mf.record(500, 100, SectorStatus::Unreadable).unwrap(); mf.record(600, 400, SectorStatus::Finished).unwrap(); // Final entries: [0..300 Finished, 300..500 NonTrimmed, 500..600 Unreadable, 600..1000 Finished] let s = mf.stats(); assert_eq!(s.bytes_good, 700); // 300 + 400 assert_eq!(s.bytes_unreadable, 100); // 100 assert_eq!(s.bytes_pending, 200); // NonTrimmed only (NonTried=0) assert_eq!(s.bytes_nontried, 0); assert_eq!(s.bytes_retryable, 200); // NonTrimmed assert_eq!(s.bytes_total, 1000); // Overwrite a NonTrimmed range with Finished mf.record(300, 100, SectorStatus::Finished).unwrap(); // Entries: [0..400 Finished, 400..500 NonTrimmed, 500..600 Unreadable, 600..1000 Finished] let s2 = mf.stats(); assert_eq!(s2.bytes_good, 800); // 400 + 400 assert_eq!(s2.bytes_unreadable, 100); assert_eq!(s2.bytes_pending, 100); // NonTrimmed only assert_eq!(s2.bytes_retryable, 100); let _ = std::fs::remove_file(&p); } #[test] fn unit_keys_round_trip_and_are_mutually_exclusive_with_vid() { let p = tmpfile("uk_round_trips"); let _ = std::fs::remove_file(&p); let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); mf.record(0, 500, SectorStatus::Finished).unwrap(); // Set a VID first, then unit keys: keys must WIN and clear the VID. mf.set_vid([0xAA; 16]); let keys: Vec<(u32, [u8; 16])> = vec![ ( 0, [ 0x57, 0x60, 0xcc, 0x83, 0x3d, 0x86, 0x0e, 0x48, 0x92, 0x1f, 0x88, 0x16, 0xe1, 0x35, 0x9b, 0xad, ], ), (1, [0x11; 16]), ]; mf.set_unit_keys(&keys); assert_eq!( mf.vid(), None, "set_unit_keys must clear vid (keys XOR vid)" ); mf.flush().unwrap(); let text = std::fs::read_to_string(&p).unwrap(); assert!( text.contains("# freemkv-uk: 0:5760cc833d860e48921f8816e1359bad"), "uk comment format mismatch: {text}" ); assert!( text.contains("# freemkv-uk: 1:11111111111111111111111111111111"), "second uk missing: {text}" ); assert!( !text.contains("# freemkv-vid:"), "VID must NOT be written when keys are present: {text}" ); // load() recovers the unit keys (and no VID). let loaded = Mapfile::load(&p).unwrap(); assert_eq!(loaded.unit_keys(), keys.as_slice()); assert_eq!(loaded.vid(), None); assert_eq!(loaded.entries(), mf.entries()); // VID-only path (no keys) still persists the VID as the retry marker. let p2 = tmpfile("uk_vid_only"); let _ = std::fs::remove_file(&p2); let mut mf2 = Mapfile::create(&p2, 1000, "test").unwrap(); mf2.set_vid([0xBB; 16]); mf2.flush().unwrap(); let loaded2 = Mapfile::load(&p2).unwrap(); assert_eq!(loaded2.vid(), Some([0xBB; 16])); assert!(loaded2.unit_keys().is_empty()); let _ = std::fs::remove_file(&p); let _ = std::fs::remove_file(&p2); } #[test] fn load_rejects_entry_whose_range_overflows_u64() { let p = tmpfile("load_overflow"); let _ = std::fs::remove_file(&p); // pos near u64::MAX with a nonzero size overflows pos+size. let body = format!("0x{:x} 0x10 +\n", u64::MAX - 4); std::fs::write(&p, body).unwrap(); let kind = match Mapfile::load(&p) { Ok(_) => panic!("overflowing entry must be rejected"), Err(e) => e.kind(), }; assert_eq!(kind, io::ErrorKind::InvalidData); let _ = std::fs::remove_file(&p); } #[test] fn record_rejects_range_overflowing_u64() { let p = tmpfile("record_overflow"); let _ = std::fs::remove_file(&p); let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); let err = mf .record(u64::MAX - 4, 16, SectorStatus::Finished) .expect_err("overflowing record must be rejected"); assert_eq!(err.kind(), io::ErrorKind::InvalidData); let _ = std::fs::remove_file(&p); } #[test] fn load_enforces_keys_xor_vid_on_malformed_file() { let p = tmpfile("load_keys_xor_vid"); let _ = std::fs::remove_file(&p); // Hand-craft a file carrying BOTH a vid comment and a uk comment // (which write_to_disk would never emit together). load() must // resolve to keys-only, matching set_unit_keys()'s invariant. let body = "# freemkv-vid:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\ # freemkv-uk: 0:11111111111111111111111111111111\n\ 0x0 0x200 +\n"; std::fs::write(&p, body).unwrap(); let loaded = Mapfile::load(&p).unwrap(); assert_eq!( loaded.vid(), None, "load() must clear vid when unit keys are present" ); assert_eq!(loaded.unit_keys(), &[(0u32, [0x11u8; 16])]); let _ = std::fs::remove_file(&p); } #[test] fn vid_round_trips_and_data_lines_unaffected() { let p = tmpfile("vid_round_trips"); let _ = std::fs::remove_file(&p); // Build a mapfile with some data ranges, set a VID, persist. let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); mf.record(100, 200, SectorStatus::Finished).unwrap(); mf.record(500, 100, SectorStatus::Unreadable).unwrap(); mf.record(700, 50, SectorStatus::NonTrimmed).unwrap(); let vid: [u8; 16] = [ 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, ]; mf.set_vid(vid); mf.flush().unwrap(); // The saved file must contain the VID comment in lowercase hex. let text = std::fs::read_to_string(&p).unwrap(); assert!( text.contains("# freemkv-vid:"), "saved mapfile missing VID comment: {text}" ); assert!( text.contains("# freemkv-vid: 00112233445566778899aabbccddeeff"), "VID comment format mismatch: {text}" ); // load() recovers the VID and the identical data ranges. let loaded = Mapfile::load(&p).unwrap(); assert_eq!(loaded.vid(), Some(vid)); assert_eq!(loaded.entries(), mf.entries()); // A mapfile WITHOUT the VID comment must parse the same +/-/? // data ranges as the one WITH it (comment ignored by parser). let p2 = tmpfile("vid_round_trips_novid"); let _ = std::fs::remove_file(&p2); let mut mf2 = Mapfile::create(&p2, 1000, "test").unwrap(); mf2.record(100, 200, SectorStatus::Finished).unwrap(); mf2.record(500, 100, SectorStatus::Unreadable).unwrap(); mf2.record(700, 50, SectorStatus::NonTrimmed).unwrap(); mf2.flush().unwrap(); let loaded_novid = Mapfile::load(&p2).unwrap(); assert_eq!(loaded_novid.vid(), None); assert_eq!(loaded_novid.entries(), loaded.entries()); // Malformed VID comments must not error the load (treated absent). let mut bad = text.replace("00112233445566778899aabbccddeeff", "zzzz"); let pbad = tmpfile("vid_round_trips_bad"); let _ = std::fs::remove_file(&pbad); std::fs::write(&pbad, &bad).unwrap(); let loaded_bad = Mapfile::load(&pbad).unwrap(); assert_eq!(loaded_bad.vid(), None); assert_eq!(loaded_bad.entries(), loaded.entries()); // A load->save cycle preserves the VID (the patch-pass path). bad.clear(); let resaved = tmpfile("vid_round_trips_resave"); let _ = std::fs::remove_file(&resaved); let mut reloaded = Mapfile::load(&p).unwrap(); // Repoint at a fresh path and flush; mark dirty via a no-op record. reloaded.path = resaved.clone(); reloaded.dirty = true; reloaded.flush().unwrap(); let again = Mapfile::load(&resaved).unwrap(); assert_eq!(again.vid(), Some(vid)); let _ = std::fs::remove_file(&p); let _ = std::fs::remove_file(&p2); let _ = std::fs::remove_file(&pbad); let _ = std::fs::remove_file(&resaved); } #[test] fn parse_vid_hex_does_not_panic_on_multibyte_32_byte_input() { // A 32-BYTE comment containing a multi-byte char would make the // old `&s[i*2..i*2+2]` slice fall inside a char boundary and // panic. Must return None instead. let s = "中".to_string() + &"a".repeat(29); // 3 + 29 = 32 bytes assert_eq!(s.len(), 32); assert_eq!(parse_vid_hex(&s), None); // A valid 32-char ASCII hex string still parses. assert_eq!( parse_vid_hex("00112233445566778899aabbccddeeff"), Some([ 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, ]) ); } #[test] fn load_rejects_overflowing_pos_plus_size() { let p = tmpfile("load_rejects_overflow"); let _ = std::fs::remove_file(&p); std::fs::write( &p, "# Rescue Logfile. Created by test\n\ 0x000000000 ? 1 0\n\ 0xfffffffffffffff0 0x20 +\n", ) .unwrap(); assert!( Mapfile::load(&p).is_err(), "a pos+size that overflows u64 must be rejected, not wrap" ); let _ = std::fs::remove_file(&p); } #[test] fn load_rejects_overlapping_ranges() { let p = tmpfile("load_rejects_overlap"); let _ = std::fs::remove_file(&p); std::fs::write( &p, "# Rescue Logfile. Created by test\n\ 0x000000000 ? 1 0\n\ 0x000000000 0x00000100 +\n\ 0x000000080 0x00000100 -\n", ) .unwrap(); assert!( Mapfile::load(&p).is_err(), "overlapping ranges must be rejected so stats can't double-count" ); let _ = std::fs::remove_file(&p); } /// Regression: a mapfile with an INTERNAL hole (a byte range no entry /// covers) must load with the hole filled as NonTried, so the hole is /// visible to resume (counted as pending, not silently "complete"). /// Without the fill, total_size (= last entry's end) would still equal /// the disc size and copy()'s `covers_disc && bad_bytes == 0` check /// would report a holed rip as complete. #[test] fn load_fills_internal_gap_as_nontried() { let p = tmpfile("load_fills_internal_gap"); let _ = std::fs::remove_file(&p); // Two Finished entries: [0,0x100) and [0x200,0x300). The hole at // [0x100,0x200) is never covered. std::fs::write( &p, "# Rescue Logfile. Created by test\n\ 0x000000000 ? 1 0\n\ 0x000000000 0x00000100 +\n\ 0x000000200 0x00000100 +\n", ) .unwrap(); let mf = Mapfile::load(&p).expect("holed mapfile must load (gap filled, not rejected)"); // The hole [0x100,0x200) must now be a NonTried entry. let hole = mf .entries() .iter() .find(|e| e.pos == 0x100) .expect("internal gap must be filled with a synthetic entry"); assert_eq!(hole.size, 0x100, "filled gap covers the whole hole"); assert_eq!( hole.status, SectorStatus::NonTried, "filled gap must be NonTried so resume reads it" ); // total_size unchanged (last entry end), but the hole is now pending. assert_eq!(mf.total_size(), 0x300); assert!( mf.stats().bytes_pending >= 0x100, "the hole must count as pending so copy() doesn't report complete" ); let _ = std::fs::remove_file(&p); } /// Regression: a LEADING gap (first entry doesn't start at 0) is filled /// as NonTried too, so resume reads the head of the disc. #[test] fn load_fills_leading_gap_as_nontried() { let p = tmpfile("load_fills_leading_gap"); let _ = std::fs::remove_file(&p); std::fs::write( &p, "# Rescue Logfile. Created by test\n\ 0x000000000 ? 1 0\n\ 0x000000080 0x00000100 +\n", ) .unwrap(); let mf = Mapfile::load(&p).expect("leading-gap mapfile must load"); let head = mf .entries() .first() .expect("must have a leading fill entry"); assert_eq!(head.pos, 0, "fill must start at byte 0"); assert_eq!(head.size, 0x80); assert_eq!(head.status, SectorStatus::NonTried); let _ = std::fs::remove_file(&p); } #[test] fn num_bad_ranges_counts_unreadable_entries() { let p = tmpfile("num_bad_ranges"); 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(); assert_eq!(mf.stats().num_bad_ranges, 2); let _ = std::fs::remove_file(&p); } // ── status char round-trip (ddrescue alphabet ?*/-+) ────────── /// Every SectorStatus must round-trip through to_char/from_char, and /// the chars must be the exact ddrescue alphabet (header doc: `?` `*` /// `/` `-` `+`). A swapped mapping would silently misclassify resume /// state (e.g. a good sector read back as unreadable). #[test] fn status_char_round_trip_is_ddrescue_alphabet() { let pairs = [ (SectorStatus::NonTried, '?'), (SectorStatus::NonTrimmed, '*'), (SectorStatus::NonScraped, '/'), (SectorStatus::Unreadable, '-'), (SectorStatus::Finished, '+'), ]; for (st, ch) in pairs { assert_eq!(st.to_char(), ch, "{st:?} must map to '{ch}'"); assert_eq!(SectorStatus::from_char(ch), Some(st)); } // Any char outside the alphabet is rejected. for bad in ['x', ' ', '0', '#', '?'.to_ascii_uppercase()] { if "?*/-+".contains(bad) { continue; } assert_eq!( SectorStatus::from_char(bad), None, "'{bad}' is not a status" ); } } // ── parse_hex / parse_uk_line / parse_vid_hex error paths ───── /// parse_hex accepts both `0x`-prefixed and bare hex (ddrescue writes /// `0x`-prefixed). A non-hex field is a MapfileInvalid{kind:"hex"}. #[test] fn parse_hex_accepts_prefixed_and_bare_rejects_garbage() { assert_eq!(parse_hex("0x10").unwrap(), 16); assert_eq!(parse_hex("10").unwrap(), 16); assert_eq!(parse_hex("0xffffffff").unwrap(), 0xffff_ffff); let err = parse_hex("0xzz").unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::InvalidData); } /// A `# freemkv-uk:` line missing the `cps:hex` shape, with a bad cps, /// or a wrong-length key, must parse to None (best-effort, never fatal). #[test] fn parse_uk_line_rejects_malformed() { assert_eq!(parse_uk_line("no-colon"), None); assert_eq!( parse_uk_line("notanumber:11111111111111111111111111111111"), None ); // 30 hex chars (15 bytes) — wrong length. assert_eq!(parse_uk_line("0:1111111111111111111111111111"), None); // Valid. assert_eq!( parse_uk_line("3:000102030405060708090a0b0c0d0e0f"), Some((3u32, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])) ); } /// parse_vid_hex tolerates an optional `0x` prefix and uppercase hex, /// but a 31- or 33-char string (not 32) is rejected — a VID is exactly /// 16 bytes = 32 hex chars. #[test] fn parse_vid_hex_length_and_case() { assert_eq!( parse_vid_hex("0xAABBCCDDEEFF00112233445566778899"), Some([ 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99 ]) ); assert_eq!(parse_vid_hex(&"a".repeat(31)), None); assert_eq!(parse_vid_hex(&"a".repeat(33)), None); } // ── next_with / ranges_with semantics ───────────────────────── /// next_with returns the first matching range AT OR AFTER `from`, /// clipping the returned start to `from` when `from` lands inside a /// matching range (the patch loop relies on resuming mid-range). #[test] fn next_with_clips_start_to_from() { let p = tmpfile("next_with_clips"); let _ = std::fs::remove_file(&p); let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); mf.record(200, 300, SectorStatus::NonTrimmed).unwrap(); // from inside the NonTrimmed range [200,500): start clips to 350, // size is 500-350 = 150. assert_eq!( mf.next_with(350, SectorStatus::NonTrimmed), Some((350, 150)) ); // from before the range: returns the whole range from its pos. assert_eq!(mf.next_with(0, SectorStatus::NonTrimmed), Some((200, 300))); // from at/after the range end: no match. assert_eq!(mf.next_with(500, SectorStatus::NonTrimmed), None); // status with no entries: None. assert_eq!(mf.next_with(0, SectorStatus::Unreadable), None); let _ = std::fs::remove_file(&p); } /// ranges_with matches ANY of the supplied statuses, preserving /// position order. Used to build the Pass-N retry queue (NonTrimmed + /// NonScraped together). #[test] fn ranges_with_multiple_statuses_in_order() { let p = tmpfile("ranges_with_multi"); let _ = std::fs::remove_file(&p); let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); mf.record(100, 100, SectorStatus::NonTrimmed).unwrap(); mf.record(300, 100, SectorStatus::NonScraped).unwrap(); mf.record(500, 100, SectorStatus::Unreadable).unwrap(); let retry = mf.ranges_with(&[SectorStatus::NonTrimmed, SectorStatus::NonScraped]); assert_eq!(retry, vec![(100, 100), (300, 100)]); let _ = std::fs::remove_file(&p); } // ── record edge cases ───────────────────────────────────────── /// A zero-size record is a no-op (record() early-returns on size==0): /// entries and stats are unchanged. #[test] fn record_zero_size_is_noop() { let p = tmpfile("record_zero"); let _ = std::fs::remove_file(&p); let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); let before = mf.entries().to_vec(); mf.record(500, 0, SectorStatus::Finished).unwrap(); assert_eq!(mf.entries(), before.as_slice()); assert_eq!(mf.stats().bytes_good, 0); let _ = std::fs::remove_file(&p); } /// Recording the FULL disc with one status collapses to a single /// coalesced entry (record splits then merges adjacent same-status). #[test] fn record_full_span_coalesces_to_one_entry() { let p = tmpfile("record_full_span"); let _ = std::fs::remove_file(&p); let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); mf.record(0, 500, SectorStatus::Finished).unwrap(); mf.record(500, 500, SectorStatus::Finished).unwrap(); let es = mf.entries(); assert_eq!(es.len(), 1, "two adjacent Finished must coalesce"); assert_eq!((es[0].pos, es[0].size), (0, 1000)); assert_eq!(mf.stats().bytes_good, 1000); let _ = std::fs::remove_file(&p); } /// A record that exactly overwrites the whole previous entry leaves the /// partition disjoint and total coverage invariant. bytes_total stays /// constant; good+pending+unreadable always sums to total. #[test] fn record_partition_invariant_total_coverage() { let p = tmpfile("record_invariant"); let _ = std::fs::remove_file(&p); let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); mf.record(0, 250, SectorStatus::Finished).unwrap(); mf.record(250, 250, SectorStatus::Unreadable).unwrap(); mf.record(500, 250, SectorStatus::NonTrimmed).unwrap(); // NonTried (500..750? no) leftover is [750,1000). let s = mf.stats(); assert_eq!( s.bytes_good + s.bytes_unreadable + s.bytes_pending, s.bytes_total, "coverage must partition the disc exactly" ); // Entries must be disjoint and sorted. let es = mf.entries(); for w in es.windows(2) { assert!( w[0].pos + w[0].size <= w[1].pos, "entries must stay disjoint and sorted" ); } let _ = std::fs::remove_file(&p); } // ── load() current-line heuristic ───────────────────────────── /// load() skips the ddrescue "current pos" status line (2nd field is a /// status char, not a 0x size) and parses the data lines that follow. /// The header doc shows `0x000000000 ? 1 0` as the status line. #[test] fn load_skips_current_status_line() { let p = tmpfile("load_skips_current"); let _ = std::fs::remove_file(&p); std::fs::write( &p, "# Rescue Logfile. Created by test\n\ 0x000000000 ? 1 0\n\ 0x000000000 0x00000100 +\n\ 0x000000100 0x00000100 -\n", ) .unwrap(); let mf = Mapfile::load(&p).unwrap(); assert_eq!(mf.entries().len(), 2); assert_eq!(mf.entries()[0].status, SectorStatus::Finished); assert_eq!(mf.entries()[1].status, SectorStatus::Unreadable); let _ = std::fs::remove_file(&p); } /// A mapfile written WITHOUT a current-line (first non-comment line is /// already a data entry: 2nd field starts `0x`) must still parse that /// first line as an entry — the heuristic detects it and falls through. #[test] fn load_treats_leading_data_line_as_entry() { let p = tmpfile("load_leading_entry"); let _ = std::fs::remove_file(&p); std::fs::write( &p, "# Rescue Logfile. Created by test\n\ 0x000000000 0x00000200 +\n\ 0x000000200 0x00000100 ?\n", ) .unwrap(); let mf = Mapfile::load(&p).unwrap(); // First line is NOT a status line; both lines are entries. assert_eq!(mf.entries().len(), 2); assert_eq!(mf.entries()[0].size, 0x200); let _ = std::fs::remove_file(&p); } /// Regression (finding 4): a leading DATA line whose size field has NO /// `0x` prefix (ddrescue/`parse_hex` both accept bare hex) must still be /// parsed as an entry, not misclassified as the current-status line and /// dropped. The shape-based discriminator keys off the 2nd field being a /// single status char (current line) vs. a multi-char hex size (data line). #[test] fn load_treats_leading_data_line_without_0x_prefix_as_entry() { let p = tmpfile("load_leading_entry_no_0x"); let _ = std::fs::remove_file(&p); // Note: sizes/positions written WITHOUT the `0x` prefix. std::fs::write( &p, "# Rescue Logfile. Created by test\n\ 000000000 200 +\n\ 000000200 100 ?\n", ) .unwrap(); let mf = Mapfile::load(&p).unwrap(); // The old `0x`-prefix heuristic would have skipped the first line as a // "current line" and lost a valid `+` entry. Both lines are entries. assert_eq!(mf.entries().len(), 2); assert_eq!(mf.entries()[0].size, 0x200); assert_eq!(mf.entries()[0].status, SectorStatus::Finished); assert_eq!(mf.entries()[1].status, SectorStatus::NonTried); let _ = std::fs::remove_file(&p); } /// load() parses the version from the `# Rescue Logfile. Created by` /// header and exposes it (round-trips through write_to_disk). #[test] fn load_parses_version_header() { let p = tmpfile("load_version"); let _ = std::fs::remove_file(&p); std::fs::write( &p, "# Rescue Logfile. Created by libfreemkv v9.9.9\n\ 0x000000000 ? 1 0\n\ 0x000000000 0x00000100 +\n", ) .unwrap(); let mf = Mapfile::load(&p).unwrap(); assert_eq!(mf.version, "libfreemkv v9.9.9"); let _ = std::fs::remove_file(&p); } /// load() rejects an entry with a non-hex pos/size field /// (MapfileInvalid{kind:"hex"}) rather than silently skipping it — /// a corrupt data line must not be dropped, masking missing coverage. #[test] fn load_rejects_non_hex_field() { let p = tmpfile("load_nonhex"); let _ = std::fs::remove_file(&p); std::fs::write( &p, "# Rescue Logfile. Created by test\n\ 0x000000000 ? 1 0\n\ 0xZZZ 0x100 +\n", ) .unwrap(); assert!(Mapfile::load(&p).is_err()); let _ = std::fs::remove_file(&p); } /// load() rejects an unknown status char (MapfileInvalid{kind: /// "status_char"}). A `~` is not in the ddrescue alphabet. #[test] fn load_rejects_unknown_status_char() { let p = tmpfile("load_badstatus"); let _ = std::fs::remove_file(&p); std::fs::write( &p, "# Rescue Logfile. Created by test\n\ 0x000000000 ? 1 0\n\ 0x000000000 0x100 ~\n", ) .unwrap(); let err = match Mapfile::load(&p) { Ok(_) => panic!("unknown status char must be rejected"), Err(e) => e, }; assert_eq!(err.kind(), io::ErrorKind::InvalidData); let _ = std::fs::remove_file(&p); } /// An empty mapfile (only comments / blank lines) loads with zero /// entries and total_size 0 — never panics on the `entries.last()` None. #[test] fn load_empty_mapfile_is_zero_total() { let p = tmpfile("load_empty"); let _ = std::fs::remove_file(&p); std::fs::write(&p, "# Rescue Logfile. Created by test\n\n \n").unwrap(); let mf = Mapfile::load(&p).unwrap(); assert!(mf.entries().is_empty()); assert_eq!(mf.total_size(), 0); assert_eq!(mf.stats().bytes_total, 0); let _ = std::fs::remove_file(&p); } /// load() sorts entries by pos even when the file lists them out of /// order, and total_size derives from the highest end (entries are /// sorted then last().pos+size). #[test] fn load_sorts_out_of_order_entries() { let p = tmpfile("load_sort"); let _ = std::fs::remove_file(&p); std::fs::write( &p, "# Rescue Logfile. Created by test\n\ 0x000000000 ? 1 0\n\ 0x000000200 0x00000100 -\n\ 0x000000000 0x00000200 +\n", ) .unwrap(); let mf = Mapfile::load(&p).unwrap(); assert_eq!(mf.entries()[0].pos, 0); assert_eq!(mf.entries()[1].pos, 0x200); assert_eq!(mf.total_size(), 0x300); let _ = std::fs::remove_file(&p); } // ── write_to_disk format ────────────────────────────────────── /// write_to_disk emits each entry as `0x{pos:09x} 0x{size:09x} {char}` /// and a load() recovers identical entries (the canonical resume path). /// Also verifies the fixed header block (Created by / Current pos / /// column header) is present so external ddrescue tools parse it. #[test] fn write_to_disk_format_round_trips_and_has_headers() { let p = tmpfile("write_format"); let _ = std::fs::remove_file(&p); let mut mf = Mapfile::create(&p, 0x1000, "vTEST").unwrap(); mf.record(0x100, 0x200, SectorStatus::Finished).unwrap(); mf.record(0x500, 0x100, SectorStatus::Unreadable).unwrap(); mf.flush().unwrap(); let text = std::fs::read_to_string(&p).unwrap(); assert!(text.contains("# Rescue Logfile. Created by vTEST")); assert!(text.contains("# Current pos / status / pass / pass_time")); assert!(text.contains("0x000000100 0x000000200 +")); assert!(text.contains("0x000000500 0x000000100 -")); let reloaded = Mapfile::load(&p).unwrap(); assert_eq!(reloaded.entries(), mf.entries()); let _ = std::fs::remove_file(&p); } /// create() persists immediately so a resume sees the fresh mapfile /// even if record() is never called (load right after create matches). #[test] fn create_persists_eagerly() { let p = tmpfile("create_eager"); let _ = std::fs::remove_file(&p); let mf = Mapfile::create(&p, 4096, "test").unwrap(); let loaded = Mapfile::load(&p).unwrap(); assert_eq!(loaded.entries(), mf.entries()); assert_eq!(loaded.total_size(), 4096); let _ = std::fs::remove_file(&p); } /// open_or_create returns a fresh NonTried mapfile when the path does /// not exist (NotFound → create), not an error. #[test] fn open_or_create_creates_when_absent() { let p = tmpfile("open_or_create_absent"); let _ = std::fs::remove_file(&p); let mf = Mapfile::open_or_create(&p, 2048, "test").unwrap(); assert_eq!(mf.entries().len(), 1); assert_eq!(mf.entries()[0].status, SectorStatus::NonTried); assert_eq!(mf.total_size(), 2048); let _ = std::fs::remove_file(&p); } /// open_or_create loads an existing file (and does NOT reset it to /// NonTried) even when the supplied total_size differs from the loaded /// coverage — the warn path must still return the loaded state. #[test] fn open_or_create_loads_existing_despite_size_mismatch() { let p = tmpfile("open_or_create_mismatch"); let _ = std::fs::remove_file(&p); let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); mf.record(0, 500, SectorStatus::Finished).unwrap(); mf.flush().unwrap(); // Supply a DIFFERENT total; must still load the existing entries. let reopened = Mapfile::open_or_create(&p, 999_999, "test").unwrap(); assert_eq!(reopened.stats().bytes_good, 500); // Loaded total reflects the file, not the supplied arg. assert_eq!(reopened.total_size(), 1000); let _ = std::fs::remove_file(&p); } /// set_unit_keys with an EMPTY slice must NOT clear an existing VID — /// the keys-XOR-vid invariant only flips when keys are actually present /// (mapfile.rs: `if !self.unit_keys.is_empty() { self.vid = None }`). #[test] fn set_unit_keys_empty_preserves_vid() { let p = tmpfile("uk_empty_preserves_vid"); let _ = std::fs::remove_file(&p); let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); mf.set_vid([0x7Au8; 16]); mf.set_unit_keys(&[]); // empty — must not clear vid assert_eq!(mf.vid(), Some([0x7Au8; 16])); assert!(mf.unit_keys().is_empty()); let _ = std::fs::remove_file(&p); } /// Drop flushes pending in-memory state (a sweep that returns early /// must not lose records). After dropping a dirty Mapfile, a fresh /// load() sees the last record. #[test] fn drop_flushes_pending_state() { let p = tmpfile("drop_flush"); let _ = std::fs::remove_file(&p); { let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); // record may or may not flush (time-batched); ensure dirty. mf.record(0, 400, SectorStatus::Finished).unwrap(); // Drop here flushes. } let loaded = Mapfile::load(&p).unwrap(); assert_eq!(loaded.stats().bytes_good, 400); let _ = std::fs::remove_file(&p); } #[test] fn stats_consistent_after_split_record() { let p = tmpfile("stats_consistent_after_split"); let _ = std::fs::remove_file(&p); let mut mf = Mapfile::create(&p, 1000, "test").unwrap(); // Mark middle as NonTrimmed mf.record(200, 400, SectorStatus::NonTrimmed).unwrap(); // Entries: [0..200 NonTried, 200..600 NonTrimmed, 600..1000 NonTried] let s = mf.stats(); assert_eq!(s.bytes_pending, 1000); // NonTried(600) + NonTrimmed(400) assert_eq!(s.bytes_retryable, 400); // NonTrimmed only assert_eq!(s.bytes_nontried, 600); // 200 + 400 // Overwrite the NonTrimmed with Finished (splitting the remaining NonTried) mf.record(200, 400, SectorStatus::Finished).unwrap(); // Entries: [0..200 NonTried, 200..600 Finished, 600..1000 NonTried] let s2 = mf.stats(); assert_eq!(s2.bytes_good, 400); assert_eq!(s2.bytes_pending, 600); // NonTried(200 + 400) assert_eq!(s2.bytes_nontried, 600); assert_eq!(s2.bytes_retryable, 0); let _ = std::fs::remove_file(&p); } }