use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
const FLUSH_INTERVAL: Duration = Duration::from_millis(1000);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SectorStatus {
NonTried,
NonTrimmed,
NonScraped,
Unreadable,
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<Self> {
Some(match c {
'?' => Self::NonTried,
'*' => Self::NonTrimmed,
'/' => Self::NonScraped,
'-' => Self::Unreadable,
'+' => Self::Finished,
_ => return None,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MapEntry {
pub pos: u64,
pub size: u64,
pub status: SectorStatus,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct MapStats {
pub bytes_total: u64,
pub bytes_good: u64,
pub bytes_unreadable: u64,
pub bytes_pending: u64,
pub bytes_nontried: u64,
pub bytes_retryable: u64,
pub num_bad_ranges: u32,
pub main_lost_ms: f64,
}
pub struct Mapfile {
path: PathBuf,
entries: Vec<MapEntry>,
total_size: u64,
version: String,
stats: MapStats,
dirty: bool,
last_flushed: Instant,
vid: Option<[u8; 16]>,
unit_keys: Vec<(u32, [u8; 16])>,
}
impl Mapfile {
pub fn create(path: &Path, total_size: u64, version: &str) -> io::Result<Self> {
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(),
};
mf.write_to_disk()?;
mf.last_flushed = Instant::now();
Ok(mf)
}
pub fn load(path: &Path) -> io::Result<Self> {
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:") {
vid = parse_vid_hex(hex.trim());
}
if let Some(uk) = rest.strip_prefix("freemkv-uk:") {
if let Some(entry) = parse_uk_line(uk.trim()) {
unit_keys.push(entry);
}
}
continue;
}
if !saw_current_line {
saw_current_line = true;
let fields: Vec<&str> = t.split_whitespace().collect();
if fields.len() >= 3 && fields[1].starts_with("0x") {
} else {
continue;
}
}
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(|| {
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);
let total_size = entries.last().map(|e| e.pos + e.size).unwrap_or(0);
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,
})
}
pub fn open_or_create(path: &Path, total_size: u64, version: &str) -> io::Result<Self> {
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),
}
}
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 {
new_entries.push(e);
continue;
}
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);
let mut merged: Vec<MapEntry> = 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.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(())
}
pub fn flush(&mut self) -> io::Result<()> {
if self.dirty {
self.write_to_disk()?;
self.dirty = false;
self.last_flushed = Instant::now();
}
Ok(())
}
pub fn set_vid(&mut self, vid: [u8; 16]) {
self.vid = Some(vid);
self.dirty = true;
}
pub fn vid(&self) -> Option<[u8; 16]> {
self.vid
}
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;
}
pub fn unit_keys(&self) -> &[(u32, [u8; 16])] {
&self.unit_keys
}
pub fn entries(&self) -> &[MapEntry] {
&self.entries
}
pub fn total_size(&self) -> u64 {
self.total_size
}
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
}
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 {
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,
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<()> {
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)?;
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()?;
}
std::fs::rename(&tmp, &self.path)?;
Ok(())
}
}
impl Drop for Mapfile {
fn drop(&mut self) {
let _ = self.flush();
}
}
fn parse_vid_hex(s: &str) -> Option<[u8; 16]> {
let s = s.strip_prefix("0x").unwrap_or(s);
if s.len() != 32 {
return None;
}
let mut out = [0u8; 16];
for (i, b) in out.iter_mut().enumerate() {
*b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
}
Some(out)
}
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())?; Some((cps, key))
}
fn parse_hex(s: &str) -> io::Result<u64> {
let s = s.strip_prefix("0x").unwrap_or(s);
u64::from_str_radix(s, 16).map_err(|_| {
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
);
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();
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();
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();
mf.flush().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);
}
#[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();
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();
let s = mf.stats();
assert_eq!(s.bytes_good, 700); assert_eq!(s.bytes_unreadable, 100); assert_eq!(s.bytes_pending, 200); assert_eq!(s.bytes_nontried, 0);
assert_eq!(s.bytes_retryable, 200); assert_eq!(s.bytes_total, 1000);
mf.record(300, 100, SectorStatus::Finished).unwrap();
let s2 = mf.stats();
assert_eq!(s2.bytes_good, 800); assert_eq!(s2.bytes_unreadable, 100);
assert_eq!(s2.bytes_pending, 100); 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();
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}"
);
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());
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 vid_round_trips_and_data_lines_unaffected() {
let p = tmpfile("vid_round_trips");
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();
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();
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}"
);
let loaded = Mapfile::load(&p).unwrap();
assert_eq!(loaded.vid(), Some(vid));
assert_eq!(loaded.entries(), mf.entries());
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());
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());
bad.clear();
let resaved = tmpfile("vid_round_trips_resave");
let _ = std::fs::remove_file(&resaved);
let mut reloaded = Mapfile::load(&p).unwrap();
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 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();
mf.record(200, 400, SectorStatus::NonTrimmed).unwrap();
let s = mf.stats();
assert_eq!(s.bytes_pending, 1000); assert_eq!(s.bytes_retryable, 400); assert_eq!(s.bytes_nontried, 600);
mf.record(200, 400, SectorStatus::Finished).unwrap();
let s2 = mf.stats();
assert_eq!(s2.bytes_good, 400);
assert_eq!(s2.bytes_pending, 600); assert_eq!(s2.bytes_nontried, 600);
assert_eq!(s2.bytes_retryable, 0);
let _ = std::fs::remove_file(&p);
}
}