v0.11.21: multi-pass rip — Disc::copy + Disc::patch + mapfile module
New primitives for two-stage rip workflows: fast forward pass with zero-fill on failures, then targeted retries of bad ranges via a ddrescue-compatible mapfile. - Disc::copy now takes &CopyOptions (breaking change from positional args). Always writes a sidecar .mapfile. Opt-in skip_on_error + skip_forward give ddrescue-style fast sweep: 64 KB blocks, exponential skip-forward on failure, zero-fill bad blocks. Defaults preserve pre-0.11.21 behavior (recovery reads, abort on bad sector). - Disc::patch is new and idempotent. Reads the mapfile, re-reads every non-finished range with full drive recovery, patches good bytes back into the ISO at exact offsets. Call N times for N retry attempts. - disc::mapfile is a new module. ddrescue text format, crash-safe (flushed on every record()), greppable, human-editable, tool-compatible. Status chars match ddrescue: ? / * / / / - / +. - Re-exports FileSectorReader from the crate root. - freemkv CLI caller (pipe.rs) updated to the new Disc::copy signature in lockstep — shipped in the 0.11.21 freemkv CLI release. Part of the 0.11.21 ecosystem sync (libfreemkv + freemkv + bdemu + autorip all on 0.11.21).
This commit is contained in:
@@ -1,5 +1,22 @@
|
|||||||
# Changelog
|
# 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)
|
## 0.11.18 (2026-04-24)
|
||||||
|
|
||||||
### DiscStream halt flag — Stop works during dense bad-sector regions
|
### DiscStream halt flag — Stop works during dense bad-sector regions
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "libfreemkv"
|
name = "libfreemkv"
|
||||||
version = "0.11.18"
|
version = "0.11.21"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.86"
|
rust-version = "1.86"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
|
|||||||
@@ -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<Self> {
|
||||||
|
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<MapEntry>,
|
||||||
|
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<Self> {
|
||||||
|
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<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");
|
||||||
|
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<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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<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.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<u64> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+301
-61
@@ -11,6 +11,7 @@
|
|||||||
mod bluray;
|
mod bluray;
|
||||||
mod dvd;
|
mod dvd;
|
||||||
mod encrypt;
|
mod encrypt;
|
||||||
|
pub mod mapfile;
|
||||||
|
|
||||||
use crate::drive::Drive;
|
use crate::drive::Drive;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
@@ -1206,94 +1207,333 @@ impl Disc {
|
|||||||
|
|
||||||
/// Raw sector copy — write the entire disc image to a file.
|
/// 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,
|
/// NOT a stream operation. Copies sectors 0→capacity byte-for-byte producing
|
||||||
/// producing a valid ISO/UDF image. The disc's filesystem structure is preserved.
|
/// 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.
|
/// # Options
|
||||||
/// If `resume` is true and the file already exists, resumes from the last safe position.
|
/// - **default** (all false): behavior matches pre-v0.11.21 — uses full
|
||||||
///
|
/// drive recovery (may take minutes per bad sector), aborts on error.
|
||||||
/// `on_progress` is called periodically with (bytes_done, total_bytes).
|
/// 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(
|
pub fn copy(
|
||||||
&self,
|
&self,
|
||||||
reader: &mut dyn SectorReader,
|
reader: &mut dyn SectorReader,
|
||||||
path: &std::path::Path,
|
path: &std::path::Path,
|
||||||
decrypt: bool,
|
opts: &CopyOptions,
|
||||||
resume: bool,
|
) -> Result<CopyResult> {
|
||||||
batch_sectors: Option<u16>,
|
|
||||||
on_progress: Option<&dyn Fn(u64, u64)>,
|
|
||||||
) -> Result<()> {
|
|
||||||
use std::io::{Seek, SeekFrom, Write};
|
use std::io::{Seek, SeekFrom, Write};
|
||||||
|
|
||||||
let total_bytes = self.capacity_sectors as u64 * 2048;
|
let total_bytes = self.capacity_sectors as u64 * 2048;
|
||||||
let keys = if decrypt {
|
let keys = if opts.decrypt {
|
||||||
self.decrypt_keys()
|
self.decrypt_keys()
|
||||||
} else {
|
} else {
|
||||||
crate::decrypt::DecryptKeys::None
|
crate::decrypt::DecryptKeys::None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resume: check existing file
|
// Mapfile: load if resuming, else wipe + recreate.
|
||||||
let (start_lba, file) = if resume {
|
let mapfile_path = mapfile_path_for(path);
|
||||||
match std::fs::metadata(path) {
|
if !opts.resume {
|
||||||
Ok(meta) if meta.len() > 0 => {
|
let _ = std::fs::remove_file(&mapfile_path);
|
||||||
let safe_sectors = (meta.len() / 2048).saturating_sub(5) as u32;
|
}
|
||||||
let mut f = std::fs::OpenOptions::new()
|
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 })?;
|
||||||
|
f.set_len(total_bytes).map_err(|e| Error::IoError { source: e })?;
|
||||||
|
f
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<u16>,
|
||||||
|
/// 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<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<u16>,
|
||||||
|
/// 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<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<PatchResult> {
|
||||||
|
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)
|
.write(true)
|
||||||
.open(path)
|
.open(path)
|
||||||
.map_err(|e| Error::IoError { source: e })?;
|
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let f = std::fs::File::create(path).map_err(|e| Error::IoError { source: e })?;
|
|
||||||
(0u32, f)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut writer = std::io::BufWriter::with_capacity(4 * 1024 * 1024, file);
|
let block_sectors = opts.block_sectors.unwrap_or(1);
|
||||||
let batch: u16 = batch_sectors.unwrap_or(DEFAULT_BATCH_SECTORS);
|
// Patch always reads with full drive recovery — this is the pass where
|
||||||
let mut lba = start_lba;
|
// we want the drive's ECC retry machinery. Consumers who want fast-fail
|
||||||
let mut bytes_done = start_lba as u64 * 2048;
|
// use Disc::copy with skip_on_error instead.
|
||||||
let mut buf = vec![0u8; batch as usize * 2048];
|
let _ = opts.full_recovery;
|
||||||
|
|
||||||
while lba < self.capacity_sectors {
|
let bytes_good_before = map.stats().bytes_good;
|
||||||
let remaining = self.capacity_sectors - lba;
|
let mut halted = false;
|
||||||
let count = remaining.min(batch as u32) as u16;
|
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 bytes = count as usize * 2048;
|
||||||
|
let read_ok = reader
|
||||||
reader
|
|
||||||
.read_sectors(lba, count, &mut buf[..bytes], true)
|
.read_sectors(lba, count, &mut buf[..bytes], true)
|
||||||
.map_err(|e| Error::IoError {
|
.is_ok();
|
||||||
source: std::io::Error::other(e.to_string()),
|
if read_ok {
|
||||||
})?;
|
if opts.decrypt {
|
||||||
|
|
||||||
// Decrypt if requested
|
|
||||||
if decrypt {
|
|
||||||
crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?;
|
crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?;
|
||||||
}
|
}
|
||||||
|
file.seek(SeekFrom::Start(pos)).map_err(|e| Error::IoError { source: e })?;
|
||||||
writer
|
file.write_all(&buf[..bytes]).map_err(|e| Error::IoError { source: e })?;
|
||||||
.write_all(&buf[..bytes])
|
map.record(pos, block_bytes, mapfile::SectorStatus::Finished)
|
||||||
.map_err(|e| Error::IoError { source: e })?;
|
.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;
|
||||||
|
|
||||||
lba += count as u32;
|
if let Some(cb) = opts.on_progress {
|
||||||
bytes_done += bytes as u64;
|
let s = map.stats();
|
||||||
|
cb(s.bytes_good, total_bytes);
|
||||||
if let Some(ref cb) = on_progress {
|
}
|
||||||
cb(bytes_done, total_bytes);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
writer.flush().map_err(|e| Error::IoError { source: e })?;
|
file.sync_all().map_err(|e| Error::IoError { source: e })?;
|
||||||
Ok(())
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -117,6 +117,6 @@ pub use mux::NullStream;
|
|||||||
pub use mux::StdioStream;
|
pub use mux::StdioStream;
|
||||||
pub use mux::{input, output, parse_url, InputOptions, StreamUrl};
|
pub use mux::{input, output, parse_url, InputOptions, StreamUrl};
|
||||||
pub use scsi::ScsiTransport;
|
pub use scsi::ScsiTransport;
|
||||||
pub use sector::SectorReader;
|
pub use sector::{FileSectorReader, SectorReader};
|
||||||
pub use speed::DriveSpeed;
|
pub use speed::DriveSpeed;
|
||||||
pub use udf::{read_filesystem, UdfFs};
|
pub use udf::{read_filesystem, UdfFs};
|
||||||
|
|||||||
Reference in New Issue
Block a user