Compare commits

...
7 Commits
Author SHA1 Message Date
MattJackson 37e721ee7e v0.12.2: hide halt behind checked_sleep / checked_exec primitives
Drive::read is now halt-check-free in its body. Previously, the halt
flag was checked in 4 places and the sleep logic was scattered across
4 "if halt_aware_sleep { return Halted }" call sites — correct, but
the ugliness invited drift: a new sleep added by someone unfamiliar
with the pattern would silently swallow Stop requests.

Two private primitives now own halt awareness:

  checked_sleep(Duration) -> Result<()>
  checked_exec(cdb, dir, buf, timeout_ms) -> Result<ScsiResult>

Both return Err(Halted) instead of a bool. The ? operator in read()
then propagates halts for free. The recovery path reads top-to-bottom
with no halt vocabulary.

sleep_until_halted lives as a free function so it's unit-testable
without a live Drive. 4 new tests: completes normally, bails on
pre-set flag within one slice, wakes mid-sleep, zero-duration no-op.

Public API unchanged — halt_flag/halt/clear_halt still exposed, the
refactor is entirely internal.
2026-04-24 13:12:24 -07:00
MattJackson 481b47d90d v0.12.1: halt-aware sleeps in Drive::read recovery
Stop was waiting up to 30 s to register when the drive hit L-EC
recovery mid-read — the recovery phase does 30 s sleeps between
retries and the halt flag was only checked at the start of each sleep.
UI feels broken ("stop isnt working") even though the halt was set.

halt_aware_sleep breaks each wait into 100 ms slices and returns
early on halt. Applied to all 4 sleeps in the recovery path (both
30 s retry delays, both 5 s reset-phase delays).
2026-04-24 12:59:36 -07:00
MattJackson dd7cbd70a9 v0.12.1: cut non-recovery read timeout 5s → 1500ms
Disc::copy fast pass (skip_on_error=true, recovery=false) was giving
the drive 5 s per 64 KB block. On structure-protected / marginal UHD
sectors the drive grinds L-EC for nearly the full budget per block,
pinning throughput at ~13 KB/s even though skip_forward would happily
skip past the region.

1500ms bounds the floor at ~43 KB/s/block. Recoverable sectors that
would have succeeded at 3-5 s get picked up on Disc::patch (pass 2+)
where recovery=true and the per-read budget is 30 s.
2026-04-24 12:57:53 -07:00
MattJackson 3dea679dac style: cargo fmt 2026-04-24 12:23:42 -07:00
MattJackson 64ecca0d12 fix(windows): Rust 2024 requires unsafe extern blocks
Missed in the prior 2024 edition sweep because local builds don't
exercise the cfg(target_os = "windows") path. check-windows CI caught it.
2026-04-24 12:21:43 -07:00
MattJackson 84f6baba38 v0.12.0: Rust 2024 edition migration
- edition = "2024" bump.
- FFI block in src/scsi/macos.rs wrapped in `unsafe extern "C" { }`.
- vtable_fn body gets an explicit unsafe block (unsafe_op_in_unsafe_fn).
- Match-ergonomics cleanup in mux/meta.rs, mkvstream.rs, network.rs,
  stdio.rs — removed redundant `ref` / `ref mut` bindings.

MSRV unchanged at 1.86. 226 tests pass. No behavior change.
2026-04-24 12:07:05 -07:00
MattJackson bd5b7795bd docs: multi-pass recovery — README snippet + new rip-recovery.md
- README quick-start gains a multi-pass example using the new
  Disc::copy + Disc::patch primitives.
- New docs/rip-recovery.md documents the two-stage rip model: mapfile
  format (ddrescue-compatible), CopyOptions/PatchOptions surface, the
  pass-1/pass-2 algorithm, and the design decisions (why no MODE
  SELECT, why ISO intermediate, why ddrescue mapfile).

No code change.
2026-04-24 11:53:28 -07:00
42 changed files with 448 additions and 158 deletions
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## 0.12.0 (2026-04-24)
### Rust 2024 edition migration
- Bumped `edition = "2024"`. Required code changes:
- FFI declarations in `src/scsi/macos.rs` wrapped in `unsafe extern "C" { … }` per the 2024 FFI safety rules.
- `unsafe_op_in_unsafe_fn` lint: `vtable_fn()` body now has an explicit `unsafe { … }` block rather than relying on implicit unsafe of the containing `unsafe fn`.
- Match-ergonomics: removed redundant `ref`/`ref mut` bindings in `mux/meta.rs`, `mux/mkvstream.rs`, `mux/network.rs`, `mux/stdio.rs` — 2024 tightens "cannot explicitly borrow within an implicitly-borrowing pattern."
- No behavior change. MSRV stays at 1.86.
### Minor / version sync
- Part of the 0.12.0 ecosystem release. The autorip-side fixes (progress regressions, UI redesign, regression-guard tests) drove the minor bump.
## 0.11.22 (2026-04-24)
### Version sync — no functional changes
+2 -2
View File
@@ -1,7 +1,7 @@
[package]
name = "libfreemkv"
version = "0.11.22"
edition = "2021"
version = "0.12.2"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
description = "Open source raw disc access library for optical drives"
+25
View File
@@ -51,6 +51,31 @@ while let Ok(Some(frame)) = input.read() {
output.finish()?;
```
### Multi-pass recovery rip
For damaged discs, the library offers a two-stage rip model: fast sweep with zero-fill and a ddrescue-format mapfile, then targeted retry of bad ranges. See [`docs/rip-recovery.md`](docs/rip-recovery.md) for the full architecture.
```rust
use libfreemkv::disc::{CopyOptions, PatchOptions};
// Pass 1: disc → ISO. Fast 64 KB reads, skip-forward on failure,
// zero-fill bad blocks, write a sidecar .mapfile.
let mut result = disc.copy(
&mut drive,
Path::new("disc.iso"),
&CopyOptions { skip_on_error: true, skip_forward: true, ..Default::default() },
)?;
// Pass 2..N: retry bad ranges with full drive recovery.
// Idempotent — call as many times as you want.
while result.bytes_unreadable + result.bytes_pending > 0 {
let pr = disc.patch(&mut drive, Path::new("disc.iso"), &PatchOptions::default())?;
if pr.bytes_recovered_this_pass == 0 { break; }
}
// Then mux from the ISO via the normal stream pipeline (no drive involvement).
```
## What It Does
- **Drive access** — open, identify, unlock, firmware upload, speed calibration, eject
+127
View File
@@ -0,0 +1,127 @@
# Rip recovery — multi-pass architecture
`libfreemkv` supports a two-stage rip model for damaged or protection-bearing
discs: a fast forward sweep that tolerates read failures, followed by targeted
retry passes against a persistent bad-range map. The stream pipeline
(`DiscStream` + `input`/`output`) operates against the resulting ISO image, so
the mux stage never touches the drive.
Three primitives compose the flow:
| Primitive | What it does |
|---------------------------|-----------------------------------------------------------------------|
| `Disc::copy` | disc → ISO. Writes a sidecar `.mapfile`. Opt-in skip-forward on failure. |
| `Disc::patch` | Re-reads bad ranges from the drive. Idempotent; call N times. |
| `DiscStream` (ISO source) | Reads sectors from the ISO, feeds decrypt → demux → codec → mux. |
The caller orchestrates. Autorip's `rip_disc` loops `copy` then N × `patch` per
the `MAX_RETRIES` config, then hands the ISO off to the existing mux pipeline.
## Data model
### Mapfile
Format: [ddrescue](https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html)-compatible
plain text, greppable, tool-interoperable. Flushed to disk on every `record()`
so a crashed rip loses at most one block.
```
# Rescue Logfile. Created by libfreemkv v0.11.22
# Current pos / status / pass / pass_time
0x000000000 ? 1 0
# pos size status
0x000000000 0x12a35d000 +
0x12a35d000 0x000003000 -
0x12a360000 0x009c4a000 +
0x12d00a000 0x000064000 *
```
Status characters match ddrescue:
| Char | Meaning |
|------|----------------------------------------------------|
| `?` | Not yet attempted |
| `*` | Fast-pass failed; needs edge-trim |
| `/` | Trimmed; interior needs sector scrape |
| `-` | Unreadable this session |
| `+` | Finished (good) |
Position and size are hex byte offsets into the ISO.
### `CopyOptions` and `PatchOptions`
Defaults preserve pre-`0.11.21` behavior — full drive recovery on every read,
abort on the first unreadable sector. Opt in to the recovery-friendly path:
```rust
CopyOptions {
skip_on_error: true, // zero-fill bad blocks, continue
skip_forward: true, // exponential skip-forward after a failure
resume: true, // pick up from an existing ISO + mapfile
decrypt: false, // keep the ISO a raw disc image
..Default::default()
}
```
## Algorithm
### Pass 1 — fast sweep
1. Read 64 KB (32 sectors, one BD ECC block) at the current LBA.
2. On success: mark the range `+`, advance by one block.
3. On failure (with `skip_on_error`): zero-fill the block in the ISO, mark
it `*`, advance.
4. If `skip_forward` is set: after a failure, jump ahead by an exponentially
growing amount (256 KB initial, doubling on consecutive failures, capped at
1% of disc). The skipped bytes are also marked `*` — `patch` will visit
them later.
5. Reset the skip size to 256 KB on the first success after a failure.
Pass 1 completes when every byte has terminal status (`+`, `-`, or the caller
bails via the halt flag).
### Pass 2+ — patch
`Disc::patch` reads the mapfile and iterates every non-`+` range. For each:
1. Issue a drive read with full recovery enabled (SCSI-level retries,
ECC recovery, the lot).
2. On success: write the good bytes into the ISO at the exact byte offset,
mark `+`.
3. On failure: mark `-`.
4. Update the mapfile after every block — crash-safe resume.
Idempotent. Call `patch` N times for N retry attempts; typically the caller
stops early if a pass recovers zero bytes (structure-protected sectors will
never yield).
## Design choices
**No `MODE SELECT` to disable drive retries.** Research showed neither ddrescue
nor MakeMKV does this. Drive firmware has access to raw analog signal, laser
power control, and drive-specific ECC tuning that userspace can't replicate —
disabling it throws away recovery headroom on marginal sectors. We fail fast
via short SG_IO timeouts instead, and we avoid per-sector probing on first
contact by using large blocks + skip-forward.
**ISO intermediate, even for single-pass.** Pass 1 always writes an ISO. The
mux stage reads the ISO via `IsoSectorReader`. For single-pass (no retries),
this adds ~2-3 min (local disk mux) but gains resumability across crashes,
re-muxability without re-ripping, and a persistent forensic artifact. Callers
who need pure speed can bypass and use `DiscStream::new(Box::new(drive), …)`
directly — the lib doesn't forbid it.
**Mapfile in ddrescue format.** Plain text so users can `less` it, `diff` it,
or feed it to ddrescue's own tooling. Crash-safe (flush-per-record). Entries
coalesce on adjacent same-status ranges so files stay small.
**Patches target `-`, `*`, `/`, and `?` alike.** The status state machine is
ddrescue's but `patch` collapses the distinction — it just tries every
non-finished range with full recovery. Future work can specialize (trim vs.
scrape vs. retry with direction reversal) if there's measured benefit.
## References
- [ddrescue manual, Algorithm chapter](https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html)
- [ddrescue optical media notes](https://www.electric-spoon.com/doc/gddrescue/html/Optical-media.html)
- Source: [`src/disc/mapfile.rs`](../src/disc/mapfile.rs), [`src/disc/mod.rs`](../src/disc/mod.rs) (`Disc::copy`, `Disc::patch`)
+1 -1
View File
@@ -1,7 +1,7 @@
//! AACS content decryption — AES primitives, unit decryption, bus encryption.
use aes::cipher::{generic_array::GenericArray, BlockDecrypt, BlockEncrypt, KeyInit};
use aes::Aes128;
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray};
// ── AACS constants ──────────────────────────────────────────────────────────
+1 -1
View File
@@ -674,8 +674,8 @@ fn generate_host_key_pair() -> ([u8; 20], [u8; 20], [u8; 20]) {
/// AES-128-CMAC over 16 bytes of data.
fn aes_cmac_16(data: &[u8; 16], key: &[u8; 16]) -> [u8; 16] {
use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
let cipher = Aes128::new(GenericArray::from_slice(key));
+1 -5
View File
@@ -361,11 +361,7 @@ mod tests {
/// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found.
fn keydb_path() -> Option<std::path::PathBuf> {
let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?);
if path.exists() {
Some(path)
} else {
None
}
if path.exists() { Some(path) } else { None }
}
#[test]
+3 -7
View File
@@ -661,18 +661,14 @@ pub fn resolve_keys(
#[cfg(test)]
mod tests {
use super::super::decrypt::{aes_ecb_encrypt, ALIGNED_UNIT_LEN};
use super::super::decrypt::{ALIGNED_UNIT_LEN, aes_ecb_encrypt};
use super::super::keydb::{DiscEntry, KeyDb};
use super::*;
/// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found.
fn keydb_path() -> Option<std::path::PathBuf> {
let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?);
if path.exists() {
Some(path)
} else {
None
}
if path.exists() { Some(path) } else { None }
}
#[test]
@@ -849,7 +845,7 @@ mod tests {
data[23] = 1; // top_menu = CPS unit 1
data[24] = 0;
data[25] = 1; // num_titles = 1
// Title 0 entry: 2 bytes pad + CPS unit
// Title 0 entry: 2 bytes pad + CPS unit
data[28] = 0;
data[29] = 1; // CPS unit 1
+5 -5
View File
@@ -21,12 +21,12 @@ pub mod keys;
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
pub use decrypt::{
decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys, is_unit_encrypted,
ALIGNED_UNIT_LEN,
ALIGNED_UNIT_LEN, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys,
is_unit_encrypted,
};
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
pub use keys::{
decrypt_unit_key, derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash,
disc_hash_hex, mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive,
resolve_keys, ContentCert, ResolvedKeys, UnitKeyFile,
ContentCert, ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_from_dk,
derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex, mkb_version,
parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys,
};
+1 -1
View File
@@ -149,7 +149,7 @@ mod tests {
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
let mut sector = vec![0xAA; 2048];
sector[0x14] = 0x30; // scramble flag set
// Set a sector seed
// Set a sector seed
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
let original = sector.clone();
descramble_sector(&key, &mut sector);
+31 -7
View File
@@ -170,7 +170,9 @@ impl 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) if e.kind() == io::ErrorKind::NotFound => {
Self::create(path, total_size, version)
}
Err(e) => Err(e),
}
}
@@ -290,12 +292,22 @@ impl Mapfile {
{
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,
"# 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())?;
writeln!(
w,
"0x{:09x} 0x{:09x} {}",
e.pos,
e.size,
e.status.to_char()
)?;
}
w.flush()?;
}
@@ -347,9 +359,18 @@ mod tests {
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));
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);
}
@@ -363,7 +384,10 @@ mod tests {
// 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));
assert_eq!(
(es[1].pos, es[1].size, es[1].status),
(100, 200, SectorStatus::Finished)
);
let _ = std::fs::remove_file(&p);
}
+25 -13
View File
@@ -1243,20 +1243,26 @@ impl Disc {
if !opts.resume {
let _ = std::fs::remove_file(&mapfile_path);
}
let mut map = mapfile::Mapfile::open_or_create(&mapfile_path, total_bytes, env!("CARGO_PKG_VERSION"))
.map_err(|e| Error::IoError { source: e })?;
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) {
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.set_len(total_bytes)
.map_err(|e| Error::IoError { source: e })?;
f
};
@@ -1290,8 +1296,7 @@ impl Disc {
// 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)
let Some((region_pos, region_size)) = map.next_with(0, mapfile::SectorStatus::NonTried)
else {
break;
};
@@ -1319,8 +1324,10 @@ impl Disc {
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 })?;
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);
@@ -1329,8 +1336,10 @@ impl Disc {
} 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 })?;
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;
@@ -1453,7 +1462,8 @@ impl Disc {
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 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()
@@ -1507,8 +1517,10 @@ impl Disc {
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 })?;
file.seek(SeekFrom::Start(pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&buf[..bytes])
.map_err(|e| Error::IoError { source: e })?;
map.record(pos, block_bytes, mapfile::SectorStatus::Finished)
.map_err(|e| Error::IoError { source: e })?;
} else {
+137 -48
View File
@@ -18,14 +18,14 @@ mod windows;
use crate::error::{Error, Result};
use crate::event::{Event, EventKind};
use crate::identity::DriveId;
use crate::platform::mt1959::Mt1959;
use crate::platform::PlatformDriver;
use crate::platform::mt1959::Mt1959;
use crate::profile::{self, DriveProfile};
use crate::scsi::ScsiTransport;
use crate::sector::SectorReader;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
/// Physical state of the drive tray and disc.
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -128,6 +128,34 @@ impl Drive {
self.halt.load(Ordering::Relaxed)
}
/// Halt-aware sleep. Returns `Err(Halted)` if the flag fires before or
/// during the wait. The only sleep the drive's recovery path is allowed
/// to use — makes it impossible to accidentally block through a Stop.
fn checked_sleep(&self, total: std::time::Duration) -> Result<()> {
sleep_until_halted(&self.halt, total)
}
/// Halt-aware SCSI execute. Returns `Err(Halted)` if the flag is set
/// before the command dispatches or by the time it completes. The only
/// path to talk to the drive in the recovery hot loop; keeps Drive::read
/// free of explicit halt checks.
fn checked_exec(
&mut self,
cdb: &[u8],
dir: crate::scsi::DataDirection,
buf: &mut [u8],
timeout_ms: u32,
) -> Result<crate::scsi::ScsiResult> {
if self.is_halted() {
return Err(Error::Halted);
}
let r = self.scsi.as_mut().execute(cdb, dir, buf, timeout_ms)?;
if self.is_halted() {
return Err(Error::Halted);
}
Ok(r)
}
/// Close the drive cleanly. Unlocks tray, flushes SCSI state, closes fd.
/// Also runs automatically on Drop as a safety net.
pub fn close(self) {
@@ -497,8 +525,15 @@ impl Drive {
/// Returns Err only after all attempts exhausted — user should clean
/// the disc and resume.
pub fn read(&mut self, lba: u32, count: u16, buf: &mut [u8], recovery: bool) -> Result<usize> {
// Non-recovery mode is the Disc::copy fast pass — intent is "fail
// fast, let skip_forward advance past bad regions." A 5s budget let
// the drive grind L-EC on structure-protected / marginal sectors for
// nearly the full interval per 64 KB block, pinning throughput at
// ~13 KB/s on difficult discs. 1500ms kills slow reads early so
// skip_forward can double its stride; recoverable sectors are picked
// up on Disc::patch where recovery=true and the timeout is 30s.
let timeout_ms = if !recovery {
5_000
1_500
} else if self.recovery_bytes_remaining > 0 {
30_000
} else {
@@ -517,26 +552,31 @@ impl Drive {
0x00,
];
// Normal read
if let Ok(result) = self.scsi.as_mut().execute(
// Normal read. Fast path: drive returns data, we return it.
// checked_exec short-circuits to Err(Halted) if Stop was requested;
// read() never touches the halt flag directly.
match self.checked_exec(
&cdb,
crate::scsi::DataDirection::FromDevice,
buf,
timeout_ms,
) {
if self.recovery_bytes_remaining > 0 {
let bytes_read = count as u64 * 2048;
self.recovery_bytes_remaining =
self.recovery_bytes_remaining.saturating_sub(bytes_read);
if self.recovery_bytes_remaining == 0 {
self.emit(EventKind::SpeedChange { speed_kbs: 0xFFFF });
self.set_speed(0xFFFF);
Ok(result) => {
if self.recovery_bytes_remaining > 0 {
let bytes_read = count as u64 * 2048;
self.recovery_bytes_remaining =
self.recovery_bytes_remaining.saturating_sub(bytes_read);
if self.recovery_bytes_remaining == 0 {
self.emit(EventKind::SpeedChange { speed_kbs: 0xFFFF });
self.set_speed(0xFFFF);
}
}
return Ok(result.bytes_transferred);
}
return Ok(result.bytes_transferred);
Err(Error::Halted) => return Err(Error::Halted),
Err(_) => { /* fall through to recovery */ }
}
// Read failed
if !recovery {
return Err(Error::DiscRead { sector: lba as u64 });
}
@@ -549,58 +589,36 @@ impl Drive {
self.emit(EventKind::SpeedChange { speed_kbs: 0 });
self.set_speed(0);
// Phase 1: gentle — sleep 30s, retry. 5 times.
// Phase 1: gentle — sleep 30 s, retry. 5 times.
for attempt in 1..=5u32 {
if self.is_halted() {
return Err(Error::Halted);
}
self.emit(EventKind::Retry { attempt });
std::thread::sleep(std::time::Duration::from_secs(30));
if let Ok(result) = self.scsi.as_mut().execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
buf,
30_000,
) {
self.checked_sleep(std::time::Duration::from_secs(30))?;
if let Ok(result) =
self.checked_exec(&cdb, crate::scsi::DataDirection::FromDevice, buf, 30_000)
{
self.emit(EventKind::SectorRecovered { sector: lba as u64 });
self.recovery_bytes_remaining = RECOVERY_WINDOW;
return Ok(result.bytes_transferred);
}
}
if self.is_halted() {
return Err(Error::Halted);
}
// Phase 2: fresh start — close, reset, open, init.
let device = std::path::PathBuf::from(&self.device_path);
std::thread::sleep(std::time::Duration::from_secs(5));
self.checked_sleep(std::time::Duration::from_secs(5))?;
let _ = crate::scsi::reset(&device);
std::thread::sleep(std::time::Duration::from_secs(5));
self.checked_sleep(std::time::Duration::from_secs(5))?;
self.scsi = crate::scsi::open(&device)?;
let _ = self.init();
let _ = self.wait_ready();
self.set_speed(0);
if self.is_halted() {
return Err(Error::Halted);
}
// Phase 3: gentle again on fresh connection — sleep 30s, retry. 5 times.
// Phase 3: gentle again on fresh connection — sleep 30 s, retry. 5 times.
for attempt in 6..=10u32 {
if self.is_halted() {
return Err(Error::Halted);
}
self.emit(EventKind::Retry { attempt });
std::thread::sleep(std::time::Duration::from_secs(30));
if let Ok(result) = self.scsi.as_mut().execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
buf,
30_000,
) {
self.checked_sleep(std::time::Duration::from_secs(30))?;
if let Ok(result) =
self.checked_exec(&cdb, crate::scsi::DataDirection::FromDevice, buf, 30_000)
{
self.emit(EventKind::SectorRecovered { sector: lba as u64 });
self.recovery_bytes_remaining = RECOVERY_WINDOW;
return Ok(result.bytes_transferred);
@@ -721,6 +739,27 @@ impl SectorReader for Drive {
}
}
/// Halt-aware sleep primitive. Returns `Err(Halted)` if the flag is set
/// before or during the wait. Extracted as a free function so it can be
/// unit-tested without constructing a full `Drive`.
///
/// Wakes within ~100 ms of a halt, regardless of the requested duration.
fn sleep_until_halted(halt: &AtomicBool, total: std::time::Duration) -> Result<()> {
const SLICE: std::time::Duration = std::time::Duration::from_millis(100);
let deadline = std::time::Instant::now() + total;
loop {
if halt.load(Ordering::Relaxed) {
return Err(Error::Halted);
}
let now = std::time::Instant::now();
if now >= deadline {
return Ok(());
}
let remaining = deadline - now;
std::thread::sleep(remaining.min(SLICE));
}
}
/// Find all optical drives connected to this system.
/// Returns opened Drive objects ready for use.
pub fn find_drives() -> Vec<Drive> {
@@ -783,3 +822,53 @@ fn create_driver(
}),
}
}
#[cfg(test)]
mod halt_tests {
use super::*;
use std::time::{Duration, Instant};
#[test]
fn sleep_until_halted_completes_when_not_halted() {
let flag = AtomicBool::new(false);
let t0 = Instant::now();
let r = sleep_until_halted(&flag, Duration::from_millis(150));
assert!(r.is_ok());
assert!(t0.elapsed() >= Duration::from_millis(140));
}
#[test]
fn sleep_until_halted_returns_immediately_if_preflagged() {
let flag = AtomicBool::new(true);
let t0 = Instant::now();
let r = sleep_until_halted(&flag, Duration::from_secs(10));
assert!(matches!(r, Err(Error::Halted)));
// Must wake within one slice (100 ms) — the whole point of the
// primitive is that a 30 s sleep doesn't block Stop.
assert!(t0.elapsed() < Duration::from_millis(200));
}
#[test]
fn sleep_until_halted_wakes_mid_sleep() {
let flag = Arc::new(AtomicBool::new(false));
let f2 = flag.clone();
let t0 = Instant::now();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(150));
f2.store(true, Ordering::Relaxed);
});
let r = sleep_until_halted(&flag, Duration::from_secs(10));
assert!(matches!(r, Err(Error::Halted)));
let waited = t0.elapsed();
// Flag flipped at ~150 ms; we wake within one 100 ms slice → <300 ms.
assert!(waited < Duration::from_millis(350), "waited {waited:?}");
assert!(waited >= Duration::from_millis(140), "waited {waited:?}");
}
#[test]
fn sleep_until_halted_zero_duration_is_noop_when_not_halted() {
let flag = AtomicBool::new(false);
let r = sleep_until_halted(&flag, Duration::ZERO);
assert!(r.is_ok());
}
}
+3 -3
View File
@@ -734,12 +734,12 @@ mod tests {
let mut pgc = vec![0u8; 0xEA];
pgc[0x02] = 1; // 1 program
pgc[0x03] = 2; // 2 cells
// 1h 59m 30s at 29.97fps, 0 frames
// 1h 59m 30s at 29.97fps, 0 frames
pgc[0x04] = 0x01; // hours BCD
pgc[0x05] = 0x59; // minutes BCD
pgc[0x06] = 0x30; // seconds BCD
pgc[0x07] = 0b11_000000; // 29.97fps, 0 frames
// Cell playback info offset at PGC+0xE8
// Cell playback info offset at PGC+0xE8
let cell_offset: u16 = 0xEA; // right after minimum header
pgc[0xE8] = (cell_offset >> 8) as u8;
pgc[0xE9] = cell_offset as u8;
@@ -755,7 +755,7 @@ mod tests {
pgc[co + 21] = 0;
pgc[co + 22] = 0;
pgc[co + 23] = 200; // last sector
// Cell 1: sectors 300-400
// Cell 1: sectors 300-400
let co = 0xEA + 24;
pgc[co + 8] = 0;
pgc[co + 9] = 0;
+1 -1
View File
@@ -4,7 +4,7 @@
//! When both exist, language_streams.txt provides structured types while
//! menu_base.prop provides stream number → button name mapping.
use super::{vocab, LabelPurpose, LabelQualifier, StreamLabel, StreamLabelType};
use super::{LabelPurpose, LabelQualifier, StreamLabel, StreamLabelType, vocab};
use crate::sector::SectorReader;
use crate::udf::UdfFs;
use std::collections::HashMap;
+1 -1
View File
@@ -5,7 +5,7 @@
//!
//! Token format: `{lang}_{codec?}_{purpose?}_{region?}_`
use super::{vocab, LabelPurpose, LabelQualifier, StreamLabel, StreamLabelType};
use super::{LabelPurpose, LabelQualifier, StreamLabel, StreamLabelType, vocab};
use crate::sector::SectorReader;
use crate::udf::UdfFs;
+5 -5
View File
@@ -95,15 +95,15 @@ pub(crate) mod udf;
pub mod verify;
pub use drive::capture::{
capture_drive_data, mask_bytes, mask_string, CapturedFeature, DriveCapture,
CapturedFeature, DriveCapture, capture_drive_data, mask_bytes, mask_string,
};
pub use drive::{find_drive, find_drives, Drive, DriveStatus};
pub use drive::{Drive, DriveStatus, find_drive, find_drives};
pub use error::{Error, Result};
pub use event::{Event, EventKind};
pub use identity::DriveId;
pub use profile::DriveProfile;
// Platform trait is pub(crate) -- callers use Drive, not Platform directly
pub use decrypt::{decrypt_sectors, DecryptKeys};
pub use decrypt::{DecryptKeys, decrypt_sectors};
pub use disc::{
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, Disc,
DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, Resolution, SampleRate,
@@ -115,8 +115,8 @@ pub use mux::MkvStream;
pub use mux::NetworkStream;
pub use mux::NullStream;
pub use mux::StdioStream;
pub use mux::{input, output, parse_url, InputOptions, StreamUrl};
pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
pub use scsi::ScsiTransport;
pub use sector::{FileSectorReader, SectorReader};
pub use speed::DriveSpeed;
pub use udf::{read_filesystem, UdfFs};
pub use udf::{UdfFs, read_filesystem};
+1 -1
View File
@@ -4,7 +4,7 @@
//! Buffers across PES boundaries so frames that span two PES packets
//! are emitted complete, not truncated.
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
pub struct Ac3Parser {
/// Leftover bytes from previous PES (incomplete frame at end).
+1 -1
View File
@@ -5,7 +5,7 @@
//! Buffers across PES boundaries so frames spanning two PES packets
//! are emitted complete.
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
const DTS_CORE_SYNC: [u8; 4] = [0x7F, 0xFE, 0x80, 0x01];
const DTS_HD_EXT_SYNC: [u8; 4] = [0x64, 0x58, 0x20, 0x25];
+1 -1
View File
@@ -7,7 +7,7 @@
//! For MKV: codec ID "S_VOBSUB".
//! All frames are keyframes (each is a complete bitmap).
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
pub struct DvdSubParser {
/// Pre-formatted VobSub .idx palette header for codec_private.
+2 -2
View File
@@ -4,7 +4,7 @@
//! Detects keyframes (IDR slices).
//! Each PES packet = one access unit = one frame.
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
/// H.264 NAL unit types we care about.
const NAL_SLICE_IDR: u8 = 5;
@@ -263,7 +263,7 @@ mod tests {
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x67); // SPS
data.extend_from_slice(&[0x42, 0x00, 0x1E, 0xAB, 0xCD]); // profile=0x42, compat=0x00, level=0x1E
// PPS: 00 00 01 [68 <payload>]
// PPS: 00 00 01 [68 <payload>]
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x68); // PPS
data.extend_from_slice(&[0xCE, 0x01]);
+4 -4
View File
@@ -5,7 +5,7 @@
//! Each PES packet = one access unit = one frame.
use super::h264::{find_start_code, skip_start_code};
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
// HEVC NAL unit types
const NAL_VPS: u8 = 32;
@@ -122,7 +122,7 @@ impl CodecParser for HevcParser {
// Minimal HEVCDecoderConfigurationRecord header
record.push(1); // configurationVersion
// General profile space, tier flag, profile IDC from SPS
// General profile space, tier flag, profile IDC from SPS
if sps.len() > 3 {
record.push(sps[1]); // general_profile_space + general_tier_flag + general_profile_idc
} else {
@@ -154,7 +154,7 @@ impl CodecParser for HevcParser {
record.push(0xFC);
// chromaFormat (6 + 2 bits)
record.push(0xFC | 1); // 4:2:0
// bitDepthLumaMinus8 (5 + 3 bits)
// bitDepthLumaMinus8 (5 + 3 bits)
record.push(0xF8);
// bitDepthChromaMinus8 (5 + 3 bits)
record.push(0xF8);
@@ -162,7 +162,7 @@ impl CodecParser for HevcParser {
record.extend_from_slice(&[0, 0]);
// constantFrameRate + numTemporalLayers + temporalIdNested + lengthSizeMinusOne
record.push(0x03); // lengthSizeMinusOne = 3 (4 bytes)
// numOfArrays
// numOfArrays
record.push(3); // VPS, SPS, PPS
// VPS array
+1 -1
View File
@@ -13,7 +13,7 @@
//! For MKV: codec ID "A_PCM/INT/BIG" (BD) or "A_PCM/INT/LIT" (DVD).
//! All frames are keyframes (uncompressed audio).
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
/// BD LPCM header size in bytes.
const BD_LPCM_HEADER_SIZE: usize = 4;
+2 -2
View File
@@ -9,7 +9,7 @@
//! - Sequence extension: 00 00 01 B5
//! - Picture header: 00 00 01 00
use super::{pts_to_ns, CodecParser, Frame};
use super::{CodecParser, Frame, pts_to_ns};
use crate::mux::ts::PesPacket;
/// Sequence header start code suffix.
@@ -415,7 +415,7 @@ mod tests {
// Sequence extension: 00 00 01 B5 [ext data]
data.extend_from_slice(&[0x00, 0x00, 0x01, SEQ_EXT_CODE]);
data.extend_from_slice(&[0x14, 0x8A, 0x00, 0x01, 0x00, 0x00]); // ext payload
// Picture header follows.
// Picture header follows.
data.extend_from_slice(&make_picture_header(PICTURE_TYPE_I));
data.extend_from_slice(&[0xFF; 4]);
+1 -1
View File
@@ -4,7 +4,7 @@
//! Each PES packet contains one or more segments.
//! All segments are keyframes (no inter-segment dependencies).
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
pub struct PgsParser;
+1 -1
View File
@@ -11,7 +11,7 @@
//! AC-3 frames (interleaved, same PID): start with sync word 0x0B77.
//! We skip AC-3 frames and only emit TrueHD access units.
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
/// Duration of one TrueHD access unit in nanoseconds (1/1200 second).
const AU_DURATION_NS: i64 = 833_333;
+1 -1
View File
@@ -5,7 +5,7 @@
//! Frame start = Frame header start code (0x0D).
//! I-frames (keyframes) are identified from the frame header.
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
const SC_SEQUENCE_HEADER: u8 = 0x0F;
const SC_ENTRY_POINT: u8 = 0x0E;
+3 -11
View File
@@ -9,8 +9,8 @@ use crate::disc::{Disc, DiscTitle, Extent};
use crate::event::{BatchSizeReason, Event, EventKind};
use crate::sector::SectorReader;
use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
/// Ramp back up to the preferred batch size after this many sectors
/// of clean reading at the current (reduced) size. 100 MiB = 51,200 sectors.
@@ -26,22 +26,14 @@ const PROBE_THRESHOLD_SECTORS: u32 = 100 * 1024 * 1024 / 2048;
/// through 3 → 1 without intermediate unaligned sizes.
fn halve_batch_size(size: u16) -> u16 {
let h = (size / 2).max(1);
if h >= 6 {
h - (h % 3)
} else {
h
}
if h >= 6 { h - (h % 3) } else { h }
}
/// Double a batch size toward a preferred max, keeping 3-sector alignment
/// when the result is >= 6.
fn double_batch_size(size: u16, preferred: u16) -> u16 {
let d = size.saturating_mul(2).min(preferred);
if d >= 6 {
d - (d % 3)
} else {
d
}
if d >= 6 { d - (d % 3) } else { d }
}
/// Adaptive batch sizer. Shrinks on read failure, grows after a sustained
+1 -1
View File
@@ -441,7 +441,7 @@ mod tests {
fn test_write_uint() {
let mut buf = Vec::new();
write_uint(&mut buf, 0x4286, 1).unwrap(); // EBML_VERSION = 1
// ID: 42 86, Size: 81 (1 byte), Data: 01
// ID: 42 86, Size: 81 (1 byte), Data: 01
assert_eq!(buf, [0x42, 0x86, 0x81, 0x01]);
}
+1 -1
View File
@@ -211,7 +211,7 @@ impl M2tsMeta {
.iter()
.map(|s| {
if let MetaStream::Video {
codec_private: Some(ref b64),
codec_private: Some(b64),
..
} = s
{
+1 -1
View File
@@ -699,7 +699,7 @@ mod tests {
// Track VINT: 1 byte (track 1 = 0x81)
let track_vint_pos = after_id + size_len;
let track_vint_len = 1; // track 1 encoded as 0x81
// 2-byte relative timestamp
// 2-byte relative timestamp
let ts_pos = track_vint_pos + track_vint_len;
// flags byte
let flags_pos = ts_pos + 2;
+4 -4
View File
@@ -4,7 +4,7 @@
//! Write: PES frames in → MKV mux → Matroska container.
use super::mkv::{MkvMuxer, MkvTrack};
use super::{ebml, WriteSeek};
use super::{WriteSeek, ebml};
type MkvHeaderResult = io::Result<(crate::disc::DiscTitle, Vec<(u16, Vec<u8>)>)>;
@@ -150,9 +150,9 @@ impl crate::pes::Stream for MkvStream {
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
match &mut self.mode {
Mode::Write {
muxer: Some(ref mut m),
} => m.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data),
Mode::Write { muxer: Some(m) } => {
m.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data)
}
Mode::Write { muxer: None } => Ok(()),
Mode::Read(_) => Err(crate::error::Error::StreamReadOnly.into()),
}
+1 -1
View File
@@ -38,7 +38,7 @@ pub use m2ts::M2tsStream;
pub use mkvstream::MkvStream;
pub use network::NetworkStream;
pub use null::NullStream;
pub use resolve::{input, output, parse_url, InputOptions, StreamUrl};
pub use resolve::{InputOptions, StreamUrl, input, output, parse_url};
pub use stdio::StdioStream;
use std::io::{Seek, Write};
+1 -1
View File
@@ -81,7 +81,7 @@ impl crate::pes::Stream for NetworkStream {
match &mut self.mode {
Mode::Write {
writer,
ref mut header_written,
header_written,
..
} => {
if !*header_written {
+1 -1
View File
@@ -70,7 +70,7 @@ impl crate::pes::Stream for StdioStream {
}
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
match &mut self.writer {
Some(ref mut w) => {
Some(w) => {
if !self.header_written {
if !self.disc_title.streams.is_empty() {
let m = meta::M2tsMeta::from_title(&self.disc_title);
+1 -5
View File
@@ -227,11 +227,7 @@ fn hvcc_to_annex_b(hvcc: &[u8]) -> Option<Vec<u8>> {
}
}
if out.is_empty() {
None
} else {
Some(out)
}
if out.is_empty() { None } else { Some(out) }
}
/// Convert length-prefixed NALUs (4-byte BE length + NAL) to Annex B
+13 -4
View File
@@ -63,7 +63,8 @@ struct SCSITaskSGElement {
// ── External IOKit / CoreFoundation functions ───────────────────────────────
extern "C" {
// Rust 2024: FFI blocks declaring extern fns must be `unsafe extern`.
unsafe extern "C" {
fn IOMasterPort(bootstrap: u32, master: *mut MachPort) -> IOReturn;
fn IOBSDNameMatching(
master: MachPort,
@@ -97,10 +98,18 @@ extern "C" {
// IOSCSIArchitectureModelFamily/UserClientLib/SCSITaskLib.h
/// Read a function pointer from a COM vtable at the given index.
///
/// # Safety
/// `iface` must be a valid COM interface pointer (*mut *mut c_void), and
/// `index` must be a valid vtable slot for the target type `T`.
unsafe fn vtable_fn<T>(iface: ComRef, index: usize) -> T {
let vtable = *iface as *const *const std::ffi::c_void;
let fn_ptr = *vtable.add(index);
std::mem::transmute_copy(&fn_ptr)
// Rust 2024: `unsafe fn` bodies are no longer implicitly unsafe.
// Each unsafe op needs its own `unsafe { }` block.
unsafe {
let vtable = *iface as *const *const std::ffi::c_void;
let fn_ptr = *vtable.add(index);
std::mem::transmute_copy(&fn_ptr)
}
}
/// Call Release (vtable index 3) on any COM interface.
+1 -1
View File
@@ -56,7 +56,7 @@ struct SptwbDirect {
// ── Windows FFI ────────────────────────────────────────────────────────────
extern "system" {
unsafe extern "system" {
fn CreateFileW(
lpFileName: *const u16,
dwDesiredAccess: u32,
+3 -3
View File
@@ -101,7 +101,7 @@ impl UdfFs {
None => {
return Err(Error::UdfNotFound {
path: path.to_string(),
})
});
}
};
let entry = current
@@ -136,7 +136,7 @@ impl UdfFs {
None => {
return Err(Error::UdfNotFound {
path: path.to_string(),
})
});
}
};
let entry = current
@@ -375,7 +375,7 @@ impl UdfFs {
None => {
return Err(Error::UdfNotFound {
path: path.to_string(),
})
});
}
};
let entry = current
+5 -5
View File
@@ -73,8 +73,8 @@ fn css_is_scrambled_detection() {
/// then decrypt with decrypt_unit() and verify the plaintext matches.
#[test]
fn aacs_decrypt_unit_roundtrip() {
use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
let unit_key = [0xAAu8; 16];
let aacs_iv: [u8; 16] = [
@@ -207,8 +207,8 @@ fn aacs_disc_hash_deterministic() {
/// decrypt_unit_key recovers the original.
#[test]
fn aacs_decrypt_unit_key_roundtrip() {
use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
let vuk = [
0x11u8, 0x14, 0x36, 0x0B, 0x10, 0xEE, 0x6E, 0xAC, 0x78, 0xAA, 0x4A, 0xC0, 0xB7, 0x52, 0xEA,
@@ -309,8 +309,8 @@ fn aacs_decrypt_unit_unencrypted_passthrough() {
/// Independent AES-128-ECB encrypt (uses `aes` crate directly, NOT our library).
fn ref_aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
let cipher = Aes128::new(GenericArray::from_slice(key));
let mut block = GenericArray::clone_from_slice(data);
cipher.encrypt_block(&mut block);
@@ -321,8 +321,8 @@ fn ref_aes_ecb_encrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
/// Independent AES-128-CBC encrypt (uses `aes` crate directly, NOT our library).
fn ref_aes_cbc_encrypt(key: &[u8; 16], iv: &[u8; 16], data: &mut [u8]) {
use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
use aes::Aes128;
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
let cipher = Aes128::new(GenericArray::from_slice(key));
let mut prev = *iv;
let num_blocks = data.len() / 16;
@@ -703,7 +703,7 @@ fn aacs_parse_unit_key_ro_minimal() {
data[0..4].copy_from_slice(&uk_pos.to_be_bytes());
// app_type
data[16] = 1; // BD-ROM
// num_bdmv_dir
// num_bdmv_dir
data[17] = 1;
// flags
data[18] = 0;
+8 -2
View File
@@ -1,7 +1,7 @@
//! Disc scanning pipeline tests.
use libfreemkv::error::Result;
use libfreemkv::SectorReader;
use libfreemkv::error::Result;
use libfreemkv::{Disc, DiscTitle, ScanOptions};
use std::collections::HashMap;
@@ -21,7 +21,13 @@ impl MockSectorReader {
}
impl SectorReader for MockSectorReader {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8], _recovery: bool) -> Result<usize> {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let total = count as usize * SECTOR_SIZE;
for i in 0..count as u32 {
let offset = i as usize * SECTOR_SIZE;
+8 -2
View File
@@ -1,7 +1,7 @@
//! UDF parser tests using a MockSectorReader.
use libfreemkv::error::Result;
use libfreemkv::{read_filesystem, SectorReader};
use libfreemkv::{SectorReader, read_filesystem};
use std::collections::HashMap;
const SECTOR_SIZE: usize = 2048;
@@ -39,7 +39,13 @@ impl MockSectorReader {
}
impl SectorReader for MockSectorReader {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8], _recovery: bool) -> Result<usize> {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let total = count as usize * SECTOR_SIZE;
assert!(buf.len() >= total, "buffer too small");
for i in 0..count as u32 {