Compare commits

...
29 Commits
Author SHA1 Message Date
MattJackson e27b82ce5b v0.13.2: list_drives + drive_has_disc; SCSI primitives pub(crate)
Architectural cleanup. autorip + freemkv CLI were reimplementing drive
discovery (sysfs walking, type-5 filtering, sg-path construction) and
calling SCSI reset primitives directly. All of that hardware-aware code
moves into libfreemkv with two cheap public probes:

- DriveInfo + list_drives() — multi-OS enumeration (Linux/macOS/Windows)
  with peripheral-type-5 filtering and INQUIRY identity. Cheap.
- drive_has_disc(path) — single TUR with internal wedge recovery
  escalation (SCSI reset → USB reset → retry) hidden from callers.

USB-layer reset (USBDEVFS_RESET / IOUSBDeviceInterface::ResetDevice /
storport's combined reset) wired across all three platforms.

Visibility tightening — scsi::reset, scsi::usb_reset, and the timeout
constants are now pub(crate). Compile-time guarantee that no consumer
crate can issue SCSI commands directly.

233 lib tests pass; clippy clean.
2026-04-24 17:31:15 -07:00
MattJackson 8af47e4c19 v0.13.1: scsi::reset() bounded by wallclock timeout
Production incident: autorip's poll loop called scsi::reset() on a
wedged BU40N USB drive. The Linux SG_SCSI_RESET ioctl blocked
indefinitely (kernel SCSI subsystem waiting for a bus-wedged device to
ack a reset that will never come). Caller's poll loop hung for 60+
seconds before manual intervention.

scsi::reset() now spawns a detached worker for the platform-specific
reset and bounds the caller's wait via mpsc::recv_timeout. Default
30 s (DEFAULT_RESET_TIMEOUT_SECS); reset_with_timeout(device, dur)
exposes the bound for callers that want a different value. Returns
DeviceResetFailed on timeout. Worker thread keeps running until the
kernel eventually unblocks — leaks one OS thread per hard wedge, but
the daemon stays responsive instead of hanging forever.

Follow-up flagged for 0.13.2: USB-attached drives wedge at the USB
Mass Storage layer below SCSI; SG_SCSI_RESET doesn't help. A
scsi::usb_reset(path) using USBDEVFS_RESET is the proper escalation.
2026-04-24 16:58:13 -07:00
MattJackson d1f09439a5 v0.13.0: zero English in library + API hygiene + dead-code sweep
Audit pass against the project docs "no English text in library code" rule.
Found 9 call sites that violated the contract by stuffing English into
io::Error::new(kind, "…") or by abusing Error::DeviceNotFound { path }
as a free-form description field. Each is now a typed Error variant.

New variants and codes: ScsiInterfaceUnavailable (E1004), DeviceLocked
(E1005), IoKitPluginFailed (E1006), UnsupportedPlatform (E2003),
PlatformNotImplemented (E2004), MapfileInvalid (E6011), DiscUrlNotDirect
(E9009).

labels::apply() previously pushed Commentary/Descriptive/Score/IME and
" (Secondary)" English literals into AudioStream.label, leaking into
MKV titles + autorip UI. AudioStream now exposes structured `purpose:
LabelPurpose`, SubtitleStream `qualifier: LabelQualifier`. Callers
translate to localized text. label keeps codec-formatting only.

API hygiene: 11 mux/* modules dropped from `pub` to `pub(crate)` —
their *types* are still re-exported from lib.rs, but the modules were
leaking low-level EBML/TS/network primitives. Stream trait gets a real
rustdoc explaining read-vs-write split. lib.rs grouped re-exports into
documented sections. ScanOptions::with_keydb() removed (one-method-per-
action rule); use struct literal.

Dead-code sweep: removed lookahead.rs (orphan, never declared as mod),
tsreader.rs (TsDemuxReader unused), ebml::{write_int,read_vint,SEEK_*},
ts::{scan_first/last_pts,scan_duration,SCAN_HEAD/TAIL_SIZE,take/set_
remainder}, MkvMuxer codec_private_slots/filled fields and
fill_codec_private method (deferred-codecPrivate path never used since
the v0.10 PES rewrite). cargo clippy --all-targets -D warnings clean.

Tests: new error::tests for variant codes + Display "no English" guard +
io::ErrorKind mapping. 233 lib tests, all green (was 230).

Breaking: ScanOptions::with_keydb removed; mux/* modules pub(crate);
AudioStream and SubtitleStream gained required fields; UnsupportedDrive
{ product_revision: "Renesas not yet implemented" } no longer produced
(use PlatformNotImplemented).
2026-04-24 16:41:02 -07:00
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
MattJackson a57506c1ce v0.11.22: version sync — no functional changes
autorip 0.11.22 ships the full multi-pass UI (live mapfile stats,
bad-range viz, Recovery settings). libfreemkv API is unchanged from
0.11.21. Part of the 0.11.22 ecosystem sync.
2026-04-24 11:43:32 -07:00
MattJackson c33f3e9557 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).
2026-04-24 09:24:32 -07:00
MattJackson 166fc4bf8c v0.11.18: DiscStream halt flag — Stop works in dense bad-sector regions
DiscStream::fill_extents loops internally while the demuxer waits for
enough clean data to emit a PES frame. In a dense bad zone that loop
can run for minutes without returning to the outer read() call, so
the caller's Stop signal never gets serviced until a frame is finally
emitted — which may be very far away.

Add DiscStream::set_halt(Arc<AtomicBool>) — typically wired to
Drive::halt_flag() for unified Stop across drive recovery phases and
stream sector processing. fill_extents checks the flag at the top of
every retry iteration; raising it returns Err(Error::Halted) within
one SCSI round-trip.

No behavior change for callers that don't call set_halt. Unblocks the
architectural fix for the "Stop doesn't stop" bug observed on a
damaged UHD disc.
2026-04-24 07:41:13 -07:00
MattJackson 9aaddaa9b8 v0.11.17: adaptive batch sizer — no per-sector descent
Replace read_with_binary_search + 3×5s light recovery with an adaptive
sizer that shrinks on failure (halve, 3-aligned ≥6) and probes back up
after 100 MiB (51,200 sectors) of clean reads. Descent cost is paid
once per bad region, not once per bad sector.

Emit BatchSizeChanged { new_size, reason } on shrink and probe-up.
Remove BinarySearch event — no longer produced.

Side fix: scsi/macos.rs one-liner for manual_c_str_literals clippy
lint that surfaced on a newer toolchain.
2026-04-23 20:24:40 -07:00
Matt Jackson 40ec1e1eba v0.11.16: API cleanup — one method per action 2026-04-21 19:17:50 +00:00
Matt Jackson 8843833ea7 v0.11.15: lint cleanup — fmt + clippy clean 2026-04-21 18:52:55 +00:00
Matt Jackson afae37c8c3 fix: iso_dump example missing recovery arg 2026-04-21 18:41:41 +00:00
Matt Jackson d8089ec491 v0.11.14: audit fixes — trailing sectors, verify stop, SCSI sense, O_CLOEXEC
Fix trailing sectors dropped at extent boundaries when sector_count % 3 != 0.
Add verify_title stop support via progress callback returning bool.
Add O_CLOEXEC on all SCSI fd opens to prevent leak to child processes.
Fix SCSI sense descriptor format detection (0x72/0x73 vs 0x70/0x71).
2026-04-21 18:40:04 +00:00
Matt Jackson ce8132d181 Update docs: async sg transport, Drive::read recovery phases 2026-04-21 18:01:48 +00:00
Matt Jackson 71e1f57364 Async SG_IO: enforceable timeouts via write/poll/read
Replace blocking ioctl(SG_IO) with the sg driver's async interface.
Commands are submitted via write(), waited on via poll() with a hard
wall-clock timeout, and completed via read(). If poll() times out,
the fd is abandoned and a fresh one opened — the kernel can no longer
hold us hostage during USB error recovery.

- write() submits command, returns immediately
- poll() enforces exact timeout (EINTR-safe with deadline tracking)
- read() retrieves result + copies data to caller's buffer
- On timeout: old fd closed in background thread, new fd opened
- No SG_FLAG_DIRECT_IO — kernel buffers for safe timeout abandonment
- Store device_path for fd reopen after timeout
- Drop guards fd=-1 (abandoned fd)
2026-04-21 17:57:58 +00:00
Matt Jackson d8092ee093 Clean API: merge read() and read_fast() into read(recovery: bool) 2026-04-21 03:27:17 +00:00
Matt Jackson dd49d55b11 v0.11.13: all rip reads use fast timeout, no full recovery in read path 2026-04-21 02:16:49 +00:00
Matt Jackson 6ba958fe8a Fix: initial batch read uses fast read, not full recovery 2026-04-21 02:02:13 +00:00
Matt Jackson f30b958408 v0.11.12: halt, events, light recovery 2026-04-21 00:25:44 +00:00
Matt Jackson 6971f00be2 Drive halt flag, sector events, binary search light recovery (3x5s) 2026-04-21 00:18:46 +00:00
Matt Jackson ad86f92d85 v0.11.11: binary search error recovery in fill_extents 2026-04-20 21:51:46 +00:00
Matt Jackson cce1be29b2 v0.11.10: skip_errors, read_sectors_recover API 2026-04-20 15:27:56 +00:00
Matt Jackson 081ad4f619 DiscStream skip_errors, read_sectors_recover(recovery) API 2026-04-20 03:38:32 +00:00
Matt Jackson 43ecad2d43 Clean API: read_sectors_recover(recovery: bool) replaces read_sectors_fast 2026-04-20 01:12:59 +00:00
66 changed files with 3490 additions and 937 deletions
+380
View File
@@ -1,5 +1,385 @@
# Changelog
## 0.13.2 (2026-04-24)
### Public discovery + presence APIs; SCSI/USB primitives no longer
### exposed to consumer crates
The autorip / freemkv-CLI side of the ecosystem was reimplementing
hardware discovery (sysfs walking, SCSI type-5 filtering, sg-path
construction) and SCSI recovery primitives in their own crates — a
direct violation of the architectural rule that ALL hardware-aware
code lives in libfreemkv. 0.13.2 closes that gap with two cheap public
probes that absorb everything consumers were doing themselves, plus
visibility tightening to make future violations a compile error.
#### New public APIs
- `pub struct DriveInfo { path, vendor, model, firmware }` — a single
enumerated optical drive's identity. Returned by `list_drives()`,
populated from a single SCSI INQUIRY at enumeration time. No
firmware reset, no `init`.
- `pub fn list_drives() -> Vec<DriveInfo>` — one-shot enumeration
across Linux/macOS/Windows. Linux walks `/sys/class/scsi_generic/`
with the SCSI type-5 filter and `/dev/sg0..15` fallback; macOS
walks `/dev/disk0..15` with the INQUIRY peripheral-type-5 filter;
Windows iterates `CdRom0..15`. Cheap (~10 ms / drive); cache the
result and refresh on udev events.
- `pub fn drive_has_disc(path: &Path) -> Result<bool>` — single TEST
UNIT READY. Returns `Ok(true)` when ready / `Ok(false)` on sense-key 2
("medium not present") / `Err` only after recovery has been exhausted.
**Internal wedge recovery is hidden from callers** — when the kernel
returns the wedge-signature pattern (status 0xFF, no sense), this
function transparently escalates: SCSI bus reset → if still wedged →
USB device reset → retry TUR. Consumers never see the escalation.
#### USB-layer reset, multi-platform
`USBDEVFS_RESET` (Linux) is the only thing that recovers a kernel-
level USB Mass Storage wedge — software equivalent of unplug-replug.
Now wired for all three OSes:
- **Linux**: `USBDEVFS_RESET` ioctl on `/dev/bus/usb/BBB/DDD`. Resolves
sg → USB device via sysfs walk (`busnum`/`devnum` parents).
- **macOS**: `IOUSBDeviceInterface::ResetDevice()`. Walks IORegistry
parents from the SCSI service to the USB device, queries the IOKit
USB plugin, calls ResetDevice.
- **Windows**: existing `IOCTL_STORAGE_RESET_DEVICE` covers both SCSI
and USB layers via storport, so `usb_reset` returns `DeviceNotFound`
by design — the recovery escalation in `drive_has_disc` falls
through cleanly. (See the `windows::usb_reset` doc comment for
why a separate cycle-port IOCTL isn't needed on Windows.)
All wrapped in a thread + `mpsc::recv_timeout` so a kernel ioctl that
hangs forever can't lock up the caller (the inner thread leaks one OS
thread per hard wedge — acceptable for a daemon that recovers vs. one
that wedges the whole poll loop).
#### Visibility tightening (architectural enforcement)
These were `pub` in 0.13.1; consumer crates could (and did) call them
directly, leaking SCSI knowledge across the lib boundary:
- `scsi::reset` → `pub(crate)`
- `scsi::reset_with_timeout` → `pub(crate)`
- `scsi::usb_reset` → `pub(crate)`
- `scsi::usb_reset_with_timeout` → `pub(crate)`
- `DEFAULT_RESET_TIMEOUT_SECS` / `DEFAULT_USB_RESET_TIMEOUT_SECS` →
`pub(crate)`
Consumers now reach recovery exclusively through `drive_has_disc`,
which folds the escalation in. **Compile-time guarantee** that no
future autorip/CLI/bdemu commit can reintroduce direct SCSI access.
#### Why this design
`Drive::open(path)` runs a ~2 s firmware-reset preamble + identify
sequence; suitable for ripping but wasteful for a poll loop probing
"is there a disc?". Pre-0.13.2 autorip called `Drive::open` 4 × every
5 s = ~17 000 speculative SCSI sessions/day, hammering the drives
between actual rips. The wedge in production at 23:51 UTC was
triggered by exactly this hot-loop pattern. With `drive_has_disc`,
the same poll cadence costs ~50 ms / drive (one TUR) — 40× cheaper
and side-effect-free on a healthy drive.
#### Tests
- 233 lib tests pass (no change in count; APIs covered indirectly via
the existing transport tests + a new `device_key` test on the
autorip side).
- `cargo clippy --all-targets -D warnings` clean across Linux/macOS.
## 0.13.1 (2026-04-24)
### `scsi::reset()` now has a hard wallclock timeout
Production incident on a wedged BU40N USB drive: autorip's poll loop
called `scsi::reset()` and the call hung for 60+ seconds before the
operator manually intervened. Root cause: the Linux `SG_SCSI_RESET`
ioctl can block indefinitely when the kernel SCSI subsystem is waiting
on a bus-wedged device that will never ack — there's no kernel-side
timeout on this ioctl. Without an outer wallclock bound the caller's
thread is stuck in the kernel until the device unwedges (which, for a
permanently-dead USB target, may be never).
`scsi::reset()` is now a wrapper that runs the platform-specific reset
on a detached worker thread and bounds the caller's wait via
`mpsc::recv_timeout(DEFAULT_RESET_TIMEOUT_SECS)` (30 s). Returns
`DeviceResetFailed` on timeout. The worker thread keeps running until
the kernel eventually unblocks (we can't cancel a Linux ioctl from
userspace) — this leaks one OS thread per hard wedge, an acceptable
cost for a daemon that recovers vs. one that hangs.
- New `pub const DEFAULT_RESET_TIMEOUT_SECS: u64 = 30;`
- New `pub fn reset_with_timeout(device, Duration) -> Result<()>` for
callers that want a different bound.
- Existing `pub fn reset(device) -> Result<()>` keeps the same
signature; behaviour change is the timeout, not the API.
### Follow-up flagged
`SG_SCSI_RESET` only resets at the SCSI layer. For USB-attached drives
(the BU40N case), the wedge is often in the USB Mass Storage layer
*below* SCSI — `SG_SCSI_RESET` doesn't help. The proper escalation is
`USBDEVFS_RESET` (the `usbreset.c` ioctl), which re-enumerates the
device at the USB layer. Tracked for 0.13.2: a `scsi::usb_reset(path)`
that resolves sg → USB device and issues `USBDEVFS_RESET`. That would
have recovered tonight's BU40N without operator intervention.
## 0.13.0 (2026-04-24)
### Zero English in library — typed variants for every error path
Audit pass against the `project docs` rule (no English text in library code).
Found nine call sites that violated the contract by stuffing English into
`io::Error::new(kind, "…")` or by abusing `Error::DeviceNotFound { path }`
as a free-form description field. Each is now a typed variant with
structured fields; the CLI / autorip translates to localized text.
New `Error` variants and codes:
- `ScsiInterfaceUnavailable { path }` — `E1004` (macOS
`SCSITaskDeviceInterface` couldn't be obtained)
- `DeviceLocked { path, kr }` — `E1005` (replaces an English
"exclusive access denied. Try: diskutil unmountDisk" message)
- `IoKitPluginFailed { path, kr }` — `E1006`
- `UnsupportedPlatform { target }` — `E2003` (built on an OS without an
SCSI backend)
- `PlatformNotImplemented { platform }` — `E2004` (replaces the
`product_revision: "Renesas not yet implemented"` string-stuffing in
`drive::mod`)
- `MapfileInvalid { kind }` — `E6011` (ddrescue mapfile parse, with a
stable `&'static str` kind: `"status_char"` or `"hex"`)
- `DiscUrlNotDirect` — `E9009` (replaces the full English sentence
`"Use Drive::open() + Disc::scan() + DiscStream::new() for disc sources"`
that `mux::input(disc://…)` returned to callers)
Migrated call sites:
- `mux/resolve.rs` — disc URL → `DiscUrlNotDirect`; the four
`format!("m2ts://…")` / `format!("mkv://…")` IO error wraps now
propagate the inner `io::Error` unchanged (the URL-prefix wrap added
no semantic information).
- `mux/iso.rs` — same `format!("iso://…")` wrap dropped.
- `sector.rs` — image-too-large now uses the existing `IsoTooLarge`
variant instead of `format!("…image too large, max ~8 TB")`.
- `disc/mapfile.rs` — bad-status-char and bad-hex parser errors now use
`MapfileInvalid { kind }`.
- `scsi/mod.rs` — unsupported-platform path uses `UnsupportedPlatform`.
- `scsi/macos.rs` — IOKit plugin failure → `IoKitPluginFailed`,
`SCSITaskDeviceInterface` missing → `ScsiInterfaceUnavailable`,
exclusive-access denied → `DeviceLocked` with the IOReturn code as a
structured `kr` field. The `find_scsi_service` four-stage failure path
is now a single `DeviceNotFound { path }` (none of the prior
per-stage English descriptions were user-actionable individually).
- `drive/mod.rs` — Renesas platform → `PlatformNotImplemented`.
### Stripped English from `labels` module
`labels::apply()` previously pushed `"Commentary"`, `"Descriptive Audio"`,
`"Score"`, `"IME"`, and `" (Secondary)"` directly into
`AudioStream.label`. Those English strings then leaked into MKV titles
and into autorip's UI. The data was already structured upstream
(`LabelPurpose` enum on `StreamLabel`); the lib was downcasting it for
the caller's convenience.
- `AudioStream` gains `purpose: LabelPurpose` (re-exported from
`crate::disc` next to the struct, alongside `LabelQualifier`).
- `SubtitleStream` gains `qualifier: LabelQualifier`.
- `apply()` writes structured fields, never English. `label` keeps
codec-formatting only (`"Dolby TrueHD 5.1"`).
- `generate_audio_label` drops the `" (Secondary)"` suffix; `secondary`
is already a `bool` field, callers render it.
- `generate_video_label` drops the `"Secondary Video"` fallback.
`"Dolby Vision EL"` kept (brand identifier, not translatable).
### API hygiene
- **mux module visibility tightened**. `pub mod ebml`, `m2ts`, `mkv`,
`network`, `null`, `ps`, `stdio`, `ts`, `tsmux` are now `pub(crate)`.
Their *types* are still re-exported from `lib.rs` — the modules
themselves were leaking low-level EBML primitives, TS muxer
internals, and network/stdio implementations that no external caller
used. `mux::codec`, `mux::disc`, `mux::iso`, `mux::resolve`, and
`mux::meta` stay public (genuine APIs).
- **Stream trait rustdoc**. The keystone PES `Stream` trait (in
`pes.rs`) had per-method docs but no trait-level doc. Now explains
read-vs-write split, error contracts, the role of `info()` /
`codec_private()` / `headers_ready()`.
- **lib.rs re-export sections**. Eight grouped sections with a
paragraph each (Drive lifecycle, Errors, Decryption, Disc structure,
Streams, Lower-level surfaces) so `cargo doc` tells callers when to
reach for what.
- **Dropped `ScanOptions::with_keydb()`**. The `_with_X` constructor
pattern was banned by `project docs` (one method per action). Use the
struct literal: `ScanOptions { keydb_path: Some(p.into()) }`. Five
external call sites (autorip ×3, freemkv CLI ×3) and three test
fixtures migrated.
- **`pid_index` allocation documented**. The `TsDemuxer::new` flat
lookup table was flagged by audit as "unbounded for adversarial
PIDs"; on closer reading it's bounded by `u16::MAX × 2 bytes ≈ 128 KB`.
Doc comment now states the bound explicitly so future contributors
don't re-flag it.
### Dead-code sweep
Pre-PES-rewrite leftovers that were `pub` but unreachable:
- Deleted `mux/lookahead.rs` entirely (orphan file — never had a `mod`
declaration; only used by its own tests).
- Deleted `mux/tsreader.rs` (`TsDemuxReader` struct + four methods,
used nowhere).
- Deleted `mux::ebml::write_int`, `read_vint`, `SEEK_HEAD`, `SEEK`,
`SEEK_ID`, `SEEK_POSITION` (unused).
- Deleted `mux::ts::scan_first_pts`, `scan_last_pts`, `scan_duration`,
`SCAN_HEAD_SIZE`, `SCAN_TAIL_SIZE`, `take_remainder`, `set_remainder`
(unused since the v0.10 PES rewrite).
- Deleted `MkvMuxer::codec_private_slots` /
`codec_private_filled` fields and `fill_codec_private` method —
deferred-codecPrivate path was never exercised once codec_privates
flowed through `DiscTitle`.
`cargo clippy --all-targets -- -D warnings` is clean.
### Tests
- New `error::tests` module — code distinctness, Display has no English
words, `io::ErrorKind` mapping for every new variant.
- 233 lib tests (was 230), all green.
### Breaking changes
Source-compatible for callers who use `Error` opaquely (handle
`Result<T, Error>` and `error.code()` only). The following are breaking:
- `ScanOptions::with_keydb()` removed — use struct literal.
- `mux::ebml`, `mux::mkv`, `mux::ts`, etc. modules no longer accessible
externally — use the re-exported types from the crate root instead.
- `AudioStream` and `SubtitleStream` gained required fields (`purpose`,
`qualifier`). Construction-by-struct-literal must include them.
- `Error::UnsupportedDrive { product_revision: "Renesas not yet
implemented" }` no longer produced — match `PlatformNotImplemented`.
### Magic-number policy
Per the v0.13 audit directive: new code in this release uses named
documented constants (e.g. `POLL_INTERVAL_SECS` in autorip, the wedge
signature literals in `ripper.rs`, the `BD_TS_PID_SPACE` floor in
`ts.rs`'s table allocation). A comprehensive retrofit of pre-existing
magic numbers across the older codebase is queued as follow-up work for
0.13.1+ — too large to absorb into this release without scope creep.
## 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
Part of the 0.11.22 ecosystem release. autorip 0.11.22 ships full multi-pass UI (bad-range viz, live mapfile stats, Recovery settings); the library API is unchanged from 0.11.21.
## 0.11.21 (2026-04-24)
### Multi-pass rip architecture — disc → ISO → patch → ISO
New primitives for two-stage rip: fast forward pass with zero-fill on failures, then targeted retries of bad ranges. Keeps the library API stream-based; the multi-pass model lives entirely in caller-orchestrated function composition.
- **New `Disc::copy(reader, path, &CopyOptions)`** replaces the positional-arg version. Always produces a ddrescue-format mapfile at `path + ".mapfile"` as a side-effect. With `skip_on_error=true` + `skip_forward=true`, does ddrescue-style fast sweep: 64 KB block reads, exponential skip-forward (256 KB → cap at 1% of disc) on failure, zero-fill bad blocks, record ranges in the mapfile. With defaults (both false), matches pre-0.11.21 behavior — uses drive-level recovery, aborts on bad sector. Mapfile is produced either way.
- **New `Disc::patch(reader, path, &PatchOptions)`** — idempotent retry pass. Reads the mapfile, re-reads every non-`+` range with full drive recovery enabled, writes successful bytes back into the ISO at exact offsets, updates mapfile. Call N times for N retry attempts.
- **New `disc::mapfile` module** — ddrescue-compatible plain-text format. Crash-safe (flushes on every `record()`), greppable, human-editable, tool-interoperable. Status chars match ddrescue: `?` non-tried · `*` non-trimmed · `/` non-scraped · `-` unreadable · `+` finished.
- **Re-exports:** `FileSectorReader` from the crate root for ISO readers.
### Breaking changes
- `Disc::copy`'s signature changes from positional args (`decrypt, resume, batch, on_progress`) to `CopyOptions`. Previous callers must migrate. `freemkv` CLI updated in lockstep.
### Version sync
- Part of the 0.11.21 ecosystem release (libfreemkv + freemkv + bdemu + autorip all on 0.11.21).
## 0.11.18 (2026-04-24)
### DiscStream halt flag — Stop works during dense bad-sector regions
`DiscStream::fill_extents` loops internally when the demuxer hasn't accumulated enough data to emit a PES frame — during a dense bad-sector run, that loop can spend many minutes shrinking batch sizes and zero-filling sectors without ever returning to the outer read() call. Without an internal halt check, the caller's Stop request goes unserviced until the demuxer eventually emits a frame, which may be very far away.
- **`DiscStream::set_halt(Arc<AtomicBool>)`** — share a halt flag with the stream. Typically wired to `Drive::halt_flag()` so Stop propagates across both the drive's recovery phases and the stream's sector processing.
- **`fill_extents()` checks the halt flag** at the top of every retry iteration (before each attempt at every size level). Raising the flag aborts within one read round-trip — at most the current SCSI command's timeout.
- Returns `Err(Error::Halted)` (E6010) so the outer rip pipeline terminates cleanly.
No behavior change for callers that don't call `set_halt`. Unblocks the architectural fix for the "Stop doesn't stop" bug observed on a damaged UHD disc where the stream was stuck in a 12+ hour bad-sector grind.
## 0.11.17 (2026-04-23)
### Adaptive batch sizer in DiscStream — no more per-sector descent
Rip recovery rewritten. The old binary-search-per-bad-sector model paid the full descent (batch → half → quarter → … → single) for every bad sector in a region. On a damaged disc with 600 consecutive bad sectors this took 12+ hours. The new algorithm pays the descent once, remembers the working size, and ramps back up only after a sustained clean streak.
- **`BatchSizeChanged { new_size, reason }` event** — fires on shrink (read failed) and probe-up (clean streak threshold hit). Consumers use this to distinguish a "recovering" rip from a normal one.
- **Removed `BinarySearch` and `SectorRecovered` emissions from DiscStream** — no longer produced by the rip path. `SectorRecovered` still fires from `Drive::read`'s multi-phase recovery (unused by rips today, but kept for scan/other callers).
- **Removed `read_with_binary_search` and the 3×5s light-recovery loop** — no retry loops, no sleeps. One 5s attempt per read. On size-1 failure, skip (zero-fill) or error.
- **Probe-up threshold: 100 MiB (51,200 sectors) of clean reading at current size** before doubling toward preferred. Ramp 1 → preferred on good reading takes ~100 seconds for a typical BD — trivial vs. rip duration, conservative enough that a single lucky sector in a marginal zone can't trigger a premature probe.
- **Bad-region math**: ~600 consecutive bad sectors now complete in ~50 min (600 × 5s) instead of ~12h. The descent is O(log preferred) one time, not per sector.
### macOS
- Fix new clippy lint (`manual_c_str_literals`) in `scsi/macos.rs`.
## 0.11.16 (2026-04-21)
### API cleanup — one method per action
- **SectorReader::read_sectors(lba, count, buf, recovery)** — single method with `recovery: bool`. Removes `read_sectors_recover()`.
- **parser_for_codec(codec, codec_data)** — single constructor. Removes `parser_for_codec_with_data()`.
- **DvdSubParser::new(codec_data)** — single constructor. Removes `with_codec_data()`.
- **MkvMuxer::new(writer, tracks, title, duration, chapters)** — single constructor. Removes `new_with_chapters()`.
## 0.11.15 (2026-04-21)
### Lint cleanup
- Fix all `cargo fmt` and `cargo clippy -D warnings` across codebase.
- Remove unused imports, dead code, collapsible if-statements, div_ceil reimplementation.
## 0.11.14 (2026-04-21)
### Audit fixes: read recovery, verify, SCSI
- **Fix: trailing sectors at extent boundaries** — extents with sector_count not divisible by 3 no longer drop 1-2 trailing sectors. decrypt_sectors() safely skips partial AACS units.
- **Fix: verify_title stop support** — progress callback now returns bool. Return false to stop verification early instead of running to completion.
- **Fix: O_CLOEXEC on all SCSI fd opens** — prevents fd leak to child processes.
- **Fix: SCSI sense descriptor format** — correctly detect response code 0x72/0x73 (descriptor format) and extract sense key from byte 1 instead of byte 2.
- **Fix: DecryptFailed on missing unit key** — decrypt_sectors() returns Err(DecryptFailed) instead of silently using a zero key.
## 0.11.13 (2026-04-21)
### Fix: all rip reads use fast timeout
- Initial batch read changed from full Drive::read() recovery to fast 5s timeout. Binary search starts immediately on failure instead of after 10 minutes of retries.
- Max 15 seconds per bad sector (3 x 5s attempts). Max 23 seconds per batch with 1 bad sector.
## 0.11.12 (2026-04-21)
### Drive halt + sector events + light recovery
- **Drive.halt()** — AtomicBool flag checked between retry phases. Max 30s to stop.
- **Drive.on_event()** — callback for ReadError, Retry, SpeedChange, SectorRecovered events.
- **Error::Halted (E6010)** — distinct from DiscRead, indicates intentional stop.
- **Binary search light recovery** — single sectors get 3 attempts x 5s (15s max) instead of full 10-min Drive::read() recovery. Marginal disc zones complete in minutes not hours.
- **DiscStream.on_event()** — BinarySearch, SectorRecovered, SectorSkipped events.
## 0.11.11 (2026-04-20)
### Binary search error recovery
- **fill_extents binary search** — when a batch read fails, binary search to isolate the failing sector(s). Good sectors read in sub-batches at full speed. Only truly bad sectors get individual recovery. 60-sector batch with 1 bad sector: ~5 seconds instead of 10+ minutes.
## 0.11.10 (2026-04-20)
### Skip errors + clean verify API
- **DiscStream.skip_errors** — when true, zero-fills unreadable sectors and continues instead of aborting. Caller sets based on user preference.
- **read_sectors_recover(recovery: bool)** — single API for recovery vs fast reads. Replaces separate read_sectors_fast method.
## 0.11.9 (2026-04-20)
### Fast verify reads
+2 -2
View File
@@ -1,7 +1,7 @@
[package]
name = "libfreemkv"
version = "0.11.9"
edition = "2021"
version = "0.13.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
+7 -1
View File
@@ -44,9 +44,15 @@ fn main() {
let mut fail = 0u32;
let mut bytes: u64 = 0;
// Recovery flag: true matches pre-0.11.13 bench behavior — full SCSI
// ECC retry loop on errors (slower, what the rip path used before the
// adaptive batch sizer landed). Flip to `false` for the fast-fail path
// that current rips use; benches are configurable via this constant.
const READ_WITH_RECOVERY: bool = true;
for i in 0..1000u32 {
let lba = i * batch as u32;
match drive.read(lba, batch, &mut buf) {
match drive.read(lba, batch, &mut buf, READ_WITH_RECOVERY) {
Ok(_) => {
writer.write_all(&buf).unwrap();
ok += 1;
+3 -3
View File
@@ -42,7 +42,7 @@ libfreemkv (lib.rs)
│
├── Drive Access
│ ├── drive Drive — open, identify, init, unlock, read (with recovery)
│ ├── scsi ScsiTransport trait + platform backends (SG_IO, IOKit, SPTI)
│ ├── scsi ScsiTransport trait + platform backends (sg async, IOKit, SPTI)
│ ├── platform/ Platform trait — per-chipset command handlers
│ │ └── mt1959 MediaTek MT1959 driver (LG, ASUS, HP)
│ ├── profile DriveProfile loading, matching, bundled JSON
@@ -83,7 +83,7 @@ libfreemkv (lib.rs)
```
Drive::open(Path::new("/dev/sg4"))
│
├─ scsi::open() Open /dev/sg4 via SG_IO
├─ scsi::open() Open /dev/sg4 (async write/poll/read)
├─ DriveId::from_drive() INQUIRY + GET_CONFIG 010C
├─ profile::find_by_drive_id() Match against bundled profiles
├─ Platform::new() Instantiate chipset driver (Mt1959)
@@ -175,7 +175,7 @@ is baked into the library.
| Platform | Transport | Status |
|----------|-----------|--------|
| Linux | SG_IO ioctl on `/dev/sg*` | Supported |
| Linux | async sg write/poll/read on `/dev/sg*` | Supported |
| macOS | IOKit SCSITask | Supported |
| Windows | SPTI (`IOCTL_SCSI_PASS_THROUGH_DIRECT`) | Supported |
+21 -14
View File
@@ -46,14 +46,20 @@ platform driver. The drive is ready for `wait_ready()` and `init()`.
### read() with Recovery
`Drive::read()` is the single read method. On error:
`Drive::read(lba, count, buf, recovery)` is the single read method. The
`recovery` parameter controls whether to attempt multi-phase recovery on
failure or return immediately (used by DiscStream's binary search for
single-sector probes).
1. Set minimum speed immediately
2. Reset device (close/reopen/TUR)
3. Wait 2s for drive to settle
4. Retry at min speed, min batch (3 sectors)
5. If still failing: skip sectors, zero-fill, log
6. Stay at min speed for 500 MB after error (recovery window)
On error with `recovery = true`:
1. **Phase 1 — gentle retry (5 attempts):** set min speed, sleep 30s, retry.
Each retry has a hard wall-clock timeout via async SG_IO.
2. **Phase 2 — fresh start:** close transport, reset device, reopen, reinit.
3. **Phase 3 — gentle retry on fresh connection (5 attempts).**
4. If all fail: return `Err(DiscRead)`. DiscStream handles it (binary search,
skip, zero-fill).
5. Stay at min speed for 500 MB after any recovery (recovery window).
---
@@ -70,8 +76,6 @@ pub trait ScsiTransport: Send {
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult>;
fn reset(&mut self, device: &str) -> Result<()>;
}
```
@@ -82,15 +86,18 @@ descriptors or calls ioctls outside of a `ScsiTransport` implementation.
| Platform | Implementation | Device |
|----------|---------------|--------|
| Linux | `SgIoTransport` — `ioctl(fd, SG_IO, &hdr)` | `/dev/sg*` |
| Linux | `SgIoTransport` — async `write`/`poll`/`read` on `/dev/sg*` | `/dev/sg*` |
| macOS | `MacScsiTransport` — IOKit SCSITask | IOKit service |
| Windows | `WindowsScsiTransport` — SPTI | `\\.\CdRomN` |
The Linux backend opens with `O_RDWR | O_NONBLOCK`, constructs `sg_io_hdr`,
and returns `ScsiResult` with status, bytes transferred, and sense data.
The Linux backend uses the sg driver's asynchronous interface: `write()` submits
the command, `poll()` waits with an enforceable wall-clock timeout, `read()`
retrieves the result. If `poll()` times out, the fd is abandoned (closed in a
background thread) and a fresh fd opened — the kernel's USB error recovery
cannot block us. Opens with `O_RDWR | O_NONBLOCK`.
On non-zero SCSI status, the transport parses sense key, ASC, and ASCQ from the
sense buffer and returns `Error::ScsiError`.
On non-zero SCSI status, the transport parses sense key from the sense buffer
and returns `Error::ScsiError`.
### CDB Builders
+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
@@ -42,7 +42,7 @@ fn main() {
// Tiny yield between reads — test if pacing prevents firmware throttle
std::thread::yield_now();
let t0 = Instant::now();
let ok = drive.read(lba, count, &mut buf[..n]).is_ok();
let ok = drive.read(lba, count, &mut buf[..n], true).is_ok();
let read_ms = t0.elapsed().as_millis();
// Flag slow reads
+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,
};
+2 -1
View File
@@ -348,7 +348,7 @@ fn read_disc_key(drive: &mut Drive, agid: u8, bus_key: &[u8; 5]) -> Result<[u8;
let candidate = super::lfsr::decrypt_key(0x00, player_key, &enc);
// Check if any previous candidate matches (same disc key from different entry/pk)
for &(ref prev, _, _) in &candidates {
for (prev, _, _) in &candidates {
if *prev == candidate {
return Ok(candidate);
}
@@ -391,6 +391,7 @@ fn read_raw_title_key(drive: &mut Drive, agid: u8, lba: u32) -> Result<[u8; 5]>
Ok(key)
}
#[allow(dead_code)]
fn read_title_key(
drive: &mut Drive,
agid: u8,
+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);
+5 -1
View File
@@ -42,7 +42,11 @@ pub fn crack_key(reader: &mut dyn SectorReader, extents: &[Extent]) -> Option<Cs
let mut i = 0;
while i < ext.sector_count && tried < max_tries {
let mut buf = vec![0u8; 2048];
if reader.read_sectors(ext.start_lba + i, 1, &mut buf).is_ok() && is_scrambled(&buf) {
if reader
.read_sectors(ext.start_lba + i, 1, &mut buf, true)
.is_ok()
&& is_scrambled(&buf)
{
if let Some(key) = crack::crack_title_key(&buf) {
return Some(CssState { title_key: key });
}
+3
View File
@@ -132,6 +132,7 @@ impl Disc {
codec,
language: s.language.clone(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
}))
} else {
@@ -142,6 +143,7 @@ impl Disc {
language: s.language.clone(),
sample_rate: SampleRate::from_audio_rate(s.audio_rate),
secondary: s.stream_type == 5,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
}))
}
@@ -151,6 +153,7 @@ impl Disc {
codec,
language: s.language.clone(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
})),
// Stream type 4 = IG, unknown types -- skip
+2
View File
@@ -48,6 +48,7 @@ impl Disc {
language: a.language.clone(),
sample_rate: SampleRate::from_hz(a.sample_rate),
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
})
})
@@ -92,6 +93,7 @@ impl Disc {
codec: Codec::DvdSub,
language: s.language.clone(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: codec_data.clone(),
})
})
+453
View File
@@ -0,0 +1,453 @@
//! 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(|| {
// No English text — the variant carries a stable
// language-neutral kind identifier (`status_char`).
let e: io::Error = crate::error::Error::MapfileInvalid {
kind: "status_char",
}
.into();
e
})?;
entries.push(MapEntry { pos, size, status });
}
entries.sort_by_key(|e| e.pos);
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(|_| {
// Underlying ParseIntError dropped — its Display is OS-locale text.
// The typed variant carries `kind = "hex"` which is stable.
let e: io::Error = crate::error::Error::MapfileInvalid { kind: "hex" }.into();
e
})
}
#[cfg(test)]
mod tests {
use super::*;
fn tmpfile(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static CTR: AtomicU64 = AtomicU64::new(0);
let n = CTR.fetch_add(1, Ordering::Relaxed);
let name = format!(
"libfreemkv-mapfile-test-{}-{}-{}.mapfile",
std::process::id(),
tag,
n
);
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);
}
}
+343 -86
View File
@@ -11,6 +11,7 @@
mod bluray;
mod dvd;
mod encrypt;
pub mod mapfile;
use crate::drive::Drive;
use crate::error::{Error, Result};
@@ -19,6 +20,11 @@ use crate::udf;
use encrypt::HandshakeResult;
// Re-export label classification enums alongside AudioStream / SubtitleStream
// so the public surface keeps the structured metadata together. Callers map
// these to display text in their own locale.
pub use crate::labels::{LabelPurpose, LabelQualifier};
// ─── Public types ───────────────────────────────────────────────────────────
/// A scanned Blu-ray disc.
@@ -179,7 +185,11 @@ pub struct AudioStream {
pub sample_rate: SampleRate,
/// Whether this is a secondary stream (commentary)
pub secondary: bool,
/// Extra label
/// Stream purpose (commentary / descriptive / score / IME / normal).
/// Callers translate this to display text in their own locale.
pub purpose: LabelPurpose,
/// Codec / variant text (e.g. "Dolby TrueHD 5.1", "(US)").
/// NEVER contains English purpose words — see `purpose` for that.
pub label: String,
}
@@ -194,6 +204,9 @@ pub struct SubtitleStream {
pub language: String,
/// Whether this is a forced subtitle
pub forced: bool,
/// Subtitle qualifier (SDH / descriptive service / forced / none).
/// Callers translate this to display text in their own locale.
pub qualifier: LabelQualifier,
/// Pre-formatted codec private data (e.g. VobSub .idx palette header)
pub codec_data: Option<Vec<u8>>,
}
@@ -874,8 +887,8 @@ impl KeySource {
/// Standard KEYDB.cfg search locations (compatible with libaacs).
const KEYDB_SEARCH_PATHS: &[&str] = &[
".config/aacs/KEYDB.cfg", // libaacs standard path
".config/freemkv/keydb.cfg", // freemkv download path
".config/aacs/KEYDB.cfg", // libaacs standard path
".config/freemkv/keydb.cfg", // freemkv download path
];
const KEYDB_SYSTEM_PATH: &str = "/etc/aacs/KEYDB.cfg";
@@ -888,13 +901,6 @@ pub struct ScanOptions {
}
impl ScanOptions {
/// Create options with a specific KEYDB path.
pub fn with_keydb(path: impl Into<std::path::PathBuf>) -> Self {
ScanOptions {
keydb_path: Some(path.into()),
}
}
/// Resolve KEYDB path: explicit path first, then standard locations.
fn resolve_keydb(&self) -> Option<std::path::PathBuf> {
if let Some(p) = &self.keydb_path {
@@ -938,9 +944,7 @@ pub struct DiscId {
impl DiscId {
/// Best available name: meta_title, then formatted volume_id.
pub fn name(&self) -> &str {
self.meta_title
.as_deref()
.unwrap_or(&self.volume_id)
self.meta_title.as_deref().unwrap_or(&self.volume_id)
}
}
@@ -1031,10 +1035,12 @@ impl Disc {
{
let lba = disc.titles[0].extents.iter().find_map(|ext| {
let mut buf = vec![0u8; 2048];
if session.read_sectors(ext.start_lba, 1, &mut buf).is_ok() {
if crate::css::is_scrambled(&buf) {
return Some(ext.start_lba);
}
if session
.read_sectors(ext.start_lba, 1, &mut buf, true)
.is_ok()
&& crate::css::is_scrambled(&buf)
{
return Some(ext.start_lba);
}
None
});
@@ -1206,94 +1212,345 @@ impl Disc {
/// Raw sector copy — write the entire disc image to a file.
///
/// This is NOT a stream operation. It copies sectors 0→capacity byte-for-byte,
/// producing a valid ISO/UDF image. The disc's filesystem structure is preserved.
/// NOT a stream operation. Copies sectors 0→capacity byte-for-byte producing
/// a valid ISO/UDF image. Records progress in a ddrescue-format mapfile at
/// `path + ".mapfile"` — flushed every block for crash-safe resume.
///
/// If `decrypt` is true and keys are available, sectors are decrypted on the fly.
/// If `resume` is true and the file already exists, resumes from the last safe position.
///
/// `on_progress` is called periodically with (bytes_done, total_bytes).
/// # Options
/// - **default** (all false): behavior matches pre-v0.11.21 — uses full
/// drive recovery (may take minutes per bad sector), aborts on error.
/// Mapfile is produced as a side-effect.
/// - **skip_on_error**: zero-fill bad blocks in the ISO, mark them in the
/// mapfile, and continue. Uses fast reads (no drive-level recovery loop).
/// - **skip_forward** (implies skip_on_error): on block failure, also skip
/// forward by an exponentially-growing amount, marking the jumped region
/// as `non-trimmed` for later trimming/scraping by `Disc::patch`.
/// - **resume**: if the mapfile exists, resume from its state — only
/// `non-tried` ranges are read. Without `resume`, a fresh mapfile is
/// written and the ISO recreated from scratch.
pub fn copy(
&self,
reader: &mut dyn SectorReader,
path: &std::path::Path,
decrypt: bool,
resume: bool,
batch_sectors: Option<u16>,
on_progress: Option<&dyn Fn(u64, u64)>,
) -> Result<()> {
opts: &CopyOptions,
) -> Result<CopyResult> {
use std::io::{Seek, SeekFrom, Write};
let total_bytes = self.capacity_sectors as u64 * 2048;
let keys = if decrypt {
let keys = if opts.decrypt {
self.decrypt_keys()
} else {
crate::decrypt::DecryptKeys::None
};
// Resume: check existing file
let (start_lba, file) = if resume {
match std::fs::metadata(path) {
Ok(meta) if meta.len() > 0 => {
let safe_sectors = (meta.len() / 2048).saturating_sub(5) as u32;
let mut f = std::fs::OpenOptions::new()
.write(true)
.open(path)
.map_err(|e| Error::IoError { source: e })?;
let resume_pos = safe_sectors as u64 * 2048;
f.set_len(resume_pos)
.map_err(|e| Error::IoError { source: e })?;
f.seek(SeekFrom::End(0))
.map_err(|e| Error::IoError { source: e })?;
(safe_sectors, f)
}
_ => {
let f =
std::fs::File::create(path).map_err(|e| Error::IoError { source: e })?;
(0u32, f)
}
}
} 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 batch: u16 = batch_sectors.unwrap_or(DEFAULT_BATCH_SECTORS);
let mut lba = start_lba;
let mut bytes_done = start_lba as u64 * 2048;
let mut buf = vec![0u8; batch as usize * 2048];
while lba < self.capacity_sectors {
let remaining = self.capacity_sectors - lba;
let count = remaining.min(batch as u32) as u16;
let bytes = count as usize * 2048;
reader
.read_sectors(lba, count, &mut buf[..bytes])
.map_err(|e| Error::IoError {
source: std::io::Error::other(e.to_string()),
})?;
// Decrypt if requested
if decrypt {
crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?;
}
writer
.write_all(&buf[..bytes])
// Mapfile: load if resuming, else wipe + recreate.
let mapfile_path = mapfile_path_for(path);
if !opts.resume {
let _ = std::fs::remove_file(&mapfile_path);
}
let mut map =
mapfile::Mapfile::open_or_create(&mapfile_path, total_bytes, env!("CARGO_PKG_VERSION"))
.map_err(|e| Error::IoError { source: e })?;
lba += count as u32;
bytes_done += bytes as u64;
// 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
};
if let Some(ref cb) = on_progress {
cb(bytes_done, total_bytes);
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);
}
}
}
writer.flush().map_err(|e| Error::IoError { source: e })?;
Ok(())
file.sync_all().map_err(|e| Error::IoError { source: e })?;
let stats = map.stats();
Ok(CopyResult {
bytes_total: total_bytes,
bytes_good: stats.bytes_good,
bytes_unreadable: stats.bytes_unreadable,
bytes_pending: stats.bytes_pending,
complete: stats.bytes_pending == 0 && !halt_requested,
halted: halt_requested,
})
}
}
/// Options for `Disc::copy`. All fields default to the pre-v0.11.21 behavior
/// (recovery reads, abort on bad sector).
#[derive(Default)]
pub struct CopyOptions<'a> {
pub decrypt: bool,
/// Resume from existing mapfile + ISO if present. Without this, any
/// existing mapfile is wiped and the ISO recreated.
pub resume: bool,
/// Override the default block size. Defaults to 32 sectors (64 KB) in
/// `skip_forward` mode, `DEFAULT_BATCH_SECTORS` otherwise.
pub batch_sectors: Option<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)
.open(path)
.map_err(|e| Error::IoError { source: e })?;
let block_sectors = opts.block_sectors.unwrap_or(1);
// Patch always reads with full drive recovery — this is the pass where
// we want the drive's ECC retry machinery. Consumers who want fast-fail
// use Disc::copy with skip_on_error instead.
let _ = opts.full_recovery;
let bytes_good_before = map.stats().bytes_good;
let mut halted = false;
let mut buf = vec![0u8; block_sectors as usize * 2048];
// Collect bad ranges up front. Iterating while mutating is fragile;
// each recorded change is persisted, so resume works even if we crash
// mid-loop.
let bad_ranges = map.ranges_with(&[
mapfile::SectorStatus::NonTried,
mapfile::SectorStatus::NonTrimmed,
mapfile::SectorStatus::NonScraped,
mapfile::SectorStatus::Unreadable,
]);
'outer: for (range_pos, range_size) in bad_ranges {
let mut pos = range_pos;
let end = range_pos + range_size;
while pos < end {
if let Some(ref h) = opts.halt {
if h.load(std::sync::atomic::Ordering::Relaxed) {
halted = true;
break 'outer;
}
}
let block_bytes = (end - pos).min(block_sectors as u64 * 2048);
let lba = (pos / 2048) as u32;
let count = (block_bytes / 2048) as u16;
let bytes = count as usize * 2048;
let read_ok = reader
.read_sectors(lba, count, &mut buf[..bytes], true)
.is_ok();
if read_ok {
if opts.decrypt {
crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?;
}
file.seek(SeekFrom::Start(pos))
.map_err(|e| Error::IoError { source: e })?;
file.write_all(&buf[..bytes])
.map_err(|e| Error::IoError { source: e })?;
map.record(pos, block_bytes, mapfile::SectorStatus::Finished)
.map_err(|e| Error::IoError { source: e })?;
} else {
map.record(pos, block_bytes, mapfile::SectorStatus::Unreadable)
.map_err(|e| Error::IoError { source: e })?;
}
pos += block_bytes;
if let Some(cb) = opts.on_progress {
let s = map.stats();
cb(s.bytes_good, total_bytes);
}
}
}
file.sync_all().map_err(|e| Error::IoError { source: e })?;
let stats = map.stats();
Ok(PatchResult {
bytes_total: total_bytes,
bytes_good: stats.bytes_good,
bytes_unreadable: stats.bytes_unreadable,
bytes_pending: stats.bytes_pending,
bytes_recovered_this_pass: stats.bytes_good.saturating_sub(bytes_good_before),
halted,
})
}
}
+208 -72
View File
@@ -8,21 +8,27 @@
pub mod capture;
// Per-platform discovery helpers (the `pub(crate)` `find_drives` /
// equivalents). Crate-public so `scsi/{linux,macos,windows}.rs` can
// reuse the existing enumeration logic when shaping `DriveInfo`.
#[cfg(target_os = "linux")]
mod linux;
pub(crate) mod linux;
#[cfg(target_os = "macos")]
mod macos;
pub(crate) mod macos;
#[cfg(windows)]
mod windows;
pub(crate) 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::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
/// Physical state of the drive tray and disc.
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -59,8 +65,11 @@ pub struct Drive {
pub drive_id: DriveId,
device_path: String,
/// Bytes remaining in the min-speed recovery window.
/// After a read error, we stay at min speed for RECOVERY_WINDOW bytes.
recovery_bytes_remaining: u64,
/// Halt flag — when set, Drive::read() bails at the next check point.
halt: Arc<AtomicBool>,
/// Event handler — fires during read recovery.
event_fn: Option<Box<dyn Fn(Event) + Send>>,
}
impl Drive {
@@ -87,9 +96,69 @@ impl Drive {
drive_id,
device_path: device.to_string_lossy().to_string(),
recovery_bytes_remaining: 0,
halt: Arc::new(AtomicBool::new(false)),
event_fn: None,
})
}
/// Get a clone of the halt flag. Set to true to interrupt Drive::read().
pub fn halt_flag(&self) -> Arc<AtomicBool> {
self.halt.clone()
}
/// Halt the drive — Drive::read() will bail at the next check point.
pub fn halt(&self) {
self.halt.store(true, Ordering::Relaxed);
}
/// Clear the halt flag for the next operation.
pub fn clear_halt(&self) {
self.halt.store(false, Ordering::Relaxed);
}
/// Set an event handler for read recovery events.
pub fn on_event(&mut self, f: impl Fn(Event) + Send + 'static) {
self.event_fn = Some(Box::new(f));
}
fn emit(&self, kind: EventKind) {
if let Some(ref f) = self.event_fn {
f(Event { kind });
}
}
fn is_halted(&self) -> bool {
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) {
@@ -458,8 +527,17 @@ 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]) -> Result<usize> {
let timeout_ms = if self.recovery_bytes_remaining > 0 {
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 {
1_500
} else if self.recovery_bytes_remaining > 0 {
30_000
} else {
10_000
@@ -477,36 +555,51 @@ 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.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 */ }
}
// Phase 1: gentle — sleep 30s, retry. 5 times.
if !recovery {
return Err(Error::DiscRead { sector: lba as u64 });
}
// Enter recovery
self.emit(EventKind::ReadError {
sector: lba as u64,
error: Error::DiscRead { sector: lba as u64 },
});
self.emit(EventKind::SpeedChange { speed_kbs: 0 });
self.set_speed(0);
for _ in 0..5 {
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,
) {
// Phase 1: gentle — sleep 30 s, retry. 5 times.
for attempt in 1..=5u32 {
self.emit(EventKind::Retry { attempt });
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);
}
@@ -514,24 +607,22 @@ impl Drive {
// 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);
// Phase 3: gentle again on fresh connection — sleep 30s, retry. 5 times.
for _ in 0..5 {
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,
) {
// Phase 3: gentle again on fresh connection — sleep 30 s, retry. 5 times.
for attempt in 6..=10u32 {
self.emit(EventKind::Retry { attempt });
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);
}
@@ -542,32 +633,6 @@ impl Drive {
Err(Error::DiscRead { sector: lba as u64 })
}
/// Fast single-attempt read for verification — no recovery, short timeout.
/// Returns Ok on success, Err on any failure. Does not retry.
pub fn read_fast(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
let cdb = [
crate::scsi::SCSI_READ_10,
0x00,
(lba >> 24) as u8,
(lba >> 16) as u8,
(lba >> 8) as u8,
lba as u8,
0x00,
(count >> 8) as u8,
count as u8,
0x00,
];
match self.scsi.as_mut().execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
buf,
5_000, // 5 second timeout — enough for healthy reads, fast fail for bad sectors
) {
Ok(result) => Ok(result.bytes_transferred),
Err(_) => Err(Error::DiscRead { sector: lba as u64 }),
}
}
/// Read the disc capacity in sectors (2048 bytes each).
pub fn read_capacity(&mut self) -> Result<u32> {
let cdb = [
@@ -666,12 +731,35 @@ impl Drop for Drive {
}
impl SectorReader for Drive {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
self.read(lba, count, buf)
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize> {
self.read(lba, count, buf, recovery)
}
}
fn read_sectors_fast(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
self.read_fast(lba, count, buf)
/// 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));
}
}
@@ -730,10 +818,58 @@ fn create_driver(
match platform {
profile::Platform::Mt1959A => Ok(Box::new(Mt1959::new(profile.clone(), false))),
profile::Platform::Mt1959B => Ok(Box::new(Mt1959::new(profile.clone(), true))),
profile::Platform::Renesas => Err(Error::UnsupportedDrive {
vendor_id: profile.identity.vendor_id.trim().to_string(),
product_id: String::new(),
product_revision: "Renesas not yet implemented".to_string(),
profile::Platform::Renesas => Err(Error::PlatformNotImplemented {
platform: "renesas".to_string(),
}),
}
}
#[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());
}
}
+215 -1
View File
@@ -24,10 +24,15 @@ pub const E_DEVICE_NOT_FOUND: u16 = 1000;
pub const E_DEVICE_PERMISSION: u16 = 1001;
pub const E_DEVICE_NOT_READY: u16 = 1002;
pub const E_DEVICE_RESET_FAILED: u16 = 1003;
pub const E_SCSI_INTERFACE_UNAVAILABLE: u16 = 1004;
pub const E_DEVICE_LOCKED: u16 = 1005;
pub const E_IOKIT_PLUGIN_FAILED: u16 = 1006;
// Profile (2xxx)
pub const E_UNSUPPORTED_DRIVE: u16 = 2000;
pub const E_PROFILE_PARSE: u16 = 2002;
pub const E_UNSUPPORTED_PLATFORM: u16 = 2003;
pub const E_PLATFORM_NOT_IMPLEMENTED: u16 = 2004;
// Unlock (3xxx)
pub const E_UNLOCK_FAILED: u16 = 3000;
@@ -41,6 +46,7 @@ pub const E_IO_ERROR: u16 = 5000;
// Disc format (6xxx)
pub const E_DISC_READ: u16 = 6000;
pub const E_HALTED: u16 = 6010;
pub const E_MPLS_PARSE: u16 = 6001;
pub const E_CLPI_PARSE: u16 = 6002;
pub const E_UDF_NOT_FOUND: u16 = 6003;
@@ -48,6 +54,7 @@ pub const E_DISC_TITLE_RANGE: u16 = 6005;
pub const E_IFO_PARSE: u16 = 6007;
pub const E_MKV_INVALID: u16 = 6008;
pub const E_NO_STREAMS: u16 = 6009;
pub const E_MAPFILE_INVALID: u16 = 6011;
// AACS (7xxx)
pub const E_AACS_NO_KEYS: u16 = 7000;
@@ -83,6 +90,7 @@ pub const E_PES_FRAME_TOO_LARGE: u16 = 9005;
pub const E_PES_INVALID_MAGIC: u16 = 9006;
pub const E_ISO_TOO_LARGE: u16 = 9007;
pub const E_NO_METADATA: u16 = 9008;
pub const E_DISC_URL_NOT_DIRECT: u16 = 9009;
// ── Error enum ──────────────────────────────────────────────────────────────
@@ -102,6 +110,24 @@ pub enum Error {
DeviceResetFailed {
path: String,
},
/// Platform-specific SCSI interface couldn't be obtained from the OS
/// (macOS: `SCSITaskDeviceInterface` unavailable). The `path` field
/// carries the device path; no English commentary on the failure mode.
ScsiInterfaceUnavailable {
path: String,
},
/// Device is held by another process / kernel state. `kr` is the
/// platform return code (macOS IOReturn, Linux errno-equivalent).
DeviceLocked {
path: String,
kr: u32,
},
/// macOS IOKit plugin couldn't be created for this device. `kr` is
/// the IOReturn code from `IOCreatePlugInInterfaceForService`.
IoKitPluginFailed {
path: String,
kr: u32,
},
// Profile (2xxx)
UnsupportedDrive {
@@ -110,6 +136,16 @@ pub enum Error {
product_revision: String,
},
ProfileParse,
/// SCSI transport was requested on an OS without a backend
/// implementation. `target` is the `std::env::consts::OS` value.
UnsupportedPlatform {
target: String,
},
/// Drive matched a known platform that we haven't implemented yet
/// (e.g. Renesas firmware). `platform` is a stable identifier.
PlatformNotImplemented {
platform: String,
},
// Unlock (3xxx)
UnlockFailed,
@@ -134,6 +170,8 @@ pub enum Error {
DiscRead {
sector: u64,
},
/// Drive was halted by caller.
Halted,
MplsParse,
ClpiParse,
UdfNotFound {
@@ -146,6 +184,12 @@ pub enum Error {
IfoParse,
MkvInvalid,
NoStreams,
/// ddrescue mapfile parse failed. `kind` is a stable, language-neutral
/// identifier (e.g. `"status_char"`, `"hex"`); not a translatable
/// English message.
MapfileInvalid {
kind: &'static str,
},
// AACS (7xxx)
AacsNoKeys,
@@ -199,6 +243,10 @@ pub enum Error {
path: String,
},
NoMetadata,
/// `disc://` URLs aren't openable through `input()` — callers must use
/// `Drive::open() + Disc::scan() + DiscStream::new()` directly. This
/// is a structural API constraint, not a parse failure.
DiscUrlNotDirect,
}
impl Error {
@@ -208,13 +256,19 @@ impl Error {
Error::DevicePermission { .. } => E_DEVICE_PERMISSION,
Error::DeviceNotReady { .. } => E_DEVICE_NOT_READY,
Error::DeviceResetFailed { .. } => E_DEVICE_RESET_FAILED,
Error::ScsiInterfaceUnavailable { .. } => E_SCSI_INTERFACE_UNAVAILABLE,
Error::DeviceLocked { .. } => E_DEVICE_LOCKED,
Error::IoKitPluginFailed { .. } => E_IOKIT_PLUGIN_FAILED,
Error::UnsupportedDrive { .. } => E_UNSUPPORTED_DRIVE,
Error::ProfileParse => E_PROFILE_PARSE,
Error::UnsupportedPlatform { .. } => E_UNSUPPORTED_PLATFORM,
Error::PlatformNotImplemented { .. } => E_PLATFORM_NOT_IMPLEMENTED,
Error::UnlockFailed => E_UNLOCK_FAILED,
Error::SignatureMismatch { .. } => E_SIGNATURE_MISMATCH,
Error::ScsiError { .. } => E_SCSI_ERROR,
Error::IoError { .. } => E_IO_ERROR,
Error::DiscRead { .. } => E_DISC_READ,
Error::Halted => E_HALTED,
Error::MplsParse => E_MPLS_PARSE,
Error::ClpiParse => E_CLPI_PARSE,
Error::UdfNotFound { .. } => E_UDF_NOT_FOUND,
@@ -222,6 +276,7 @@ impl Error {
Error::IfoParse => E_IFO_PARSE,
Error::MkvInvalid => E_MKV_INVALID,
Error::NoStreams => E_NO_STREAMS,
Error::MapfileInvalid { .. } => E_MAPFILE_INVALID,
Error::AacsNoKeys => E_AACS_NO_KEYS,
Error::AacsCertShort => E_AACS_CERT_SHORT,
Error::AacsAgidAlloc => E_AACS_AGID_ALLOC,
@@ -251,6 +306,7 @@ impl Error {
Error::PesInvalidMagic => E_PES_INVALID_MAGIC,
Error::IsoTooLarge { .. } => E_ISO_TOO_LARGE,
Error::NoMetadata => E_NO_METADATA,
Error::DiscUrlNotDirect => E_DISC_URL_NOT_DIRECT,
}
}
}
@@ -263,6 +319,22 @@ impl std::fmt::Display for Error {
Error::DevicePermission { path } => write!(f, "E{}: {}", self.code(), path),
Error::DeviceNotReady { path } => write!(f, "E{}: {}", self.code(), path),
Error::DeviceResetFailed { path } => write!(f, "E{}: {}", self.code(), path),
Error::ScsiInterfaceUnavailable { path } => write!(f, "E{}: {}", self.code(), path),
Error::DeviceLocked { path, kr } => {
write!(f, "E{}: {} 0x{:08x}", self.code(), path, kr)
}
Error::IoKitPluginFailed { path, kr } => {
write!(f, "E{}: {} 0x{:08x}", self.code(), path, kr)
}
Error::UnsupportedPlatform { target } => {
write!(f, "E{}: {}", self.code(), target)
}
Error::PlatformNotImplemented { platform } => {
write!(f, "E{}: {}", self.code(), platform)
}
Error::MapfileInvalid { kind } => {
write!(f, "E{}: {}", self.code(), kind)
}
Error::UnsupportedDrive {
vendor_id,
product_id,
@@ -304,6 +376,7 @@ impl std::fmt::Display for Error {
}
Error::IoError { source } => write!(f, "E{}: {}", self.code(), source),
Error::DiscRead { sector } => write!(f, "E{}: {}", self.code(), sector),
Error::Halted => write!(f, "E{}", self.code()),
Error::UdfNotFound { path } => write!(f, "E{}: {}", self.code(), path),
Error::DiscTitleRange { index, count } => {
write!(f, "E{}: {}/{}", self.code(), index, count)
@@ -352,7 +425,10 @@ impl From<Error> for std::io::Error {
7000..=7999 => std::io::ErrorKind::PermissionDenied,
8000..=8999 => std::io::ErrorKind::Other,
9000..=9001 => std::io::ErrorKind::Unsupported,
9002..=9009 => std::io::ErrorKind::InvalidInput,
9002..=9008 => std::io::ErrorKind::InvalidInput,
// 9009 DiscUrlNotDirect: structurally unsupported entry point,
// not a parse failure — caller used the wrong API.
9009 => std::io::ErrorKind::Unsupported,
_ => std::io::ErrorKind::Other,
};
std::io::Error::new(kind, msg)
@@ -361,3 +437,141 @@ impl From<Error> for std::io::Error {
/// Convenience alias for `Result<T, Error>`.
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
//! Smoke tests for the error code → variant mapping. Each new variant
//! added in 0.13.0 (English-elimination work) gets a code() check + a
//! Display sanity-check (no English words) + an io::ErrorKind mapping
//! check. Without these, future drift between the const codes and the
//! match arms in `code()` / the From impl could silently miscategorize.
use super::*;
#[test]
fn new_variants_have_distinct_codes() {
let codes = [
Error::ScsiInterfaceUnavailable { path: "p".into() }.code(),
Error::DeviceLocked {
path: "p".into(),
kr: 0,
}
.code(),
Error::IoKitPluginFailed {
path: "p".into(),
kr: 0,
}
.code(),
Error::UnsupportedPlatform { target: "x".into() }.code(),
Error::PlatformNotImplemented {
platform: "renesas".into(),
}
.code(),
Error::MapfileInvalid { kind: "hex" }.code(),
Error::DiscUrlNotDirect.code(),
];
let mut sorted = codes.to_vec();
sorted.sort();
sorted.dedup();
assert_eq!(
sorted.len(),
codes.len(),
"two new variants share a code — check error.rs constants"
);
}
#[test]
fn display_emits_no_english_words() {
// Every variant's Display must be `E{code}: {data}` — no English.
// Sample a few of the new variants and a few existing ones to
// catch accidental string-stuffing in future edits.
let cases: &[(Error, u16)] = &[
(
Error::ScsiInterfaceUnavailable {
path: "/dev/sg4".into(),
},
E_SCSI_INTERFACE_UNAVAILABLE,
),
(
Error::DeviceLocked {
path: "/dev/sg4".into(),
kr: 0xE00002C5,
},
E_DEVICE_LOCKED,
),
(
Error::UnsupportedPlatform {
target: "freebsd".into(),
},
E_UNSUPPORTED_PLATFORM,
),
(
Error::PlatformNotImplemented {
platform: "renesas".into(),
},
E_PLATFORM_NOT_IMPLEMENTED,
),
(Error::MapfileInvalid { kind: "hex" }, E_MAPFILE_INVALID),
(Error::DiscUrlNotDirect, E_DISC_URL_NOT_DIRECT),
];
for (e, want_code) in cases {
let s = e.to_string();
assert!(
s.starts_with(&format!("E{}", want_code)),
"{:?} display does not lead with code: {}",
e,
s
);
// Crude English filter — `Display` should never emit ASCII words
// longer than 4 chars (codes/paths/identifiers like `/dev/sg4`,
// `renesas`, `freebsd` all pass; "exclusive access denied" would
// not).
for word in s.split(|c: char| !c.is_ascii_alphabetic()) {
assert!(
word.len() <= 8
|| word.eq_ignore_ascii_case("renesas")
|| word.eq_ignore_ascii_case("freebsd"),
"Display contains suspicious English-looking word `{word}` in `{s}`"
);
}
}
}
#[test]
fn iokind_mapping_for_new_variants() {
use std::io::ErrorKind;
let mapped = |e: Error| -> ErrorKind {
let io: std::io::Error = e.into();
io.kind()
};
// 1xxx range → NotFound
assert_eq!(
mapped(Error::ScsiInterfaceUnavailable { path: "p".into() }),
ErrorKind::NotFound
);
assert_eq!(
mapped(Error::DeviceLocked {
path: "p".into(),
kr: 0
}),
ErrorKind::NotFound
);
// 2xxx range → Unsupported
assert_eq!(
mapped(Error::UnsupportedPlatform { target: "x".into() }),
ErrorKind::Unsupported
);
assert_eq!(
mapped(Error::PlatformNotImplemented {
platform: "x".into()
}),
ErrorKind::Unsupported
);
// 6xxx range → InvalidData
assert_eq!(
mapped(Error::MapfileInvalid { kind: "hex" }),
ErrorKind::InvalidData
);
// 9009 special-cased to Unsupported
assert_eq!(mapped(Error::DiscUrlNotDirect), ErrorKind::Unsupported);
}
}
+25
View File
@@ -80,6 +80,22 @@ pub enum EventKind {
sector_count: u64,
},
/// Sector recovered after a retry (Drive::read multi-phase recovery).
SectorRecovered { sector: u64 },
/// Sector unreadable, zero-filled (skip mode).
SectorSkipped { sector: u64 },
/// Adaptive batch sizer changed the read size.
///
/// Fires on shrink (read failed at larger size) and on probe-up
/// (enough clean reads to try larger again). Consumers use this to
/// display a "recovering" state distinct from "ripping normally".
BatchSizeChanged {
new_size: u16,
reason: BatchSizeReason,
},
/// Operation complete.
Complete {
/// Total bytes written.
@@ -89,5 +105,14 @@ pub enum EventKind {
},
}
/// Why the adaptive batch sizer changed size.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BatchSizeReason {
/// Read failed; sizer halved the batch.
Shrunk,
/// Clean-read streak threshold hit; sizer doubled toward preferred.
Probed,
}
/// A no-op event handler. Ignores all events.
pub fn ignore(_event: Event) {}
+6 -4
View File
@@ -564,7 +564,9 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle>
}
// Program map: each byte is the first cell number (1-based) for that program
for p in 0..nr_of_programs {
if pgm_base + p >= data.len() { break; }
if pgm_base + p >= data.len() {
break;
}
let first_cell = data[pgm_base + p] as usize;
// Chapter time = sum of cell durations before this program's first cell
let time: f64 = cell_durations[..first_cell.saturating_sub(1)].iter().sum();
@@ -732,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;
@@ -753,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;
+28 -20
View File
@@ -17,6 +17,10 @@ use crate::disc::{DiscTitle, Stream};
use crate::sector::SectorReader;
use crate::udf::UdfFs;
// Re-exported via crate::disc — the public API surfaces these next to
// AudioStream/SubtitleStream so callers can map purpose/qualifier to display
// text in their own locale.
/// A stream label extracted from disc config files.
#[derive(Debug, Clone)]
#[allow(dead_code)]
@@ -46,7 +50,6 @@ pub enum StreamLabelType {
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[allow(dead_code)]
pub enum LabelPurpose {
Normal,
Commentary,
@@ -99,16 +102,12 @@ pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle
if let Some(label) = labels.iter().find(|l| {
l.stream_type == StreamLabelType::Audio && l.stream_number == audio_idx
}) {
// Structured fields — callers translate purpose to UI text.
a.purpose = label.purpose;
// a.label only carries codec/variant info. NEVER any
// English purpose text — the CLI handles that via i18n.
let mut parts = Vec::new();
match label.purpose {
LabelPurpose::Commentary => parts.push("Commentary".to_string()),
LabelPurpose::Descriptive => {
parts.push("Descriptive Audio".to_string())
}
LabelPurpose::Score => parts.push("Score".to_string()),
LabelPurpose::Ime => parts.push("IME".to_string()),
LabelPurpose::Normal => {}
}
if !label.variant.is_empty() {
parts.push(format!("({})", label.variant));
}
@@ -117,7 +116,10 @@ pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle
}
if !parts.is_empty() {
a.label = parts.join(" ");
} else if !label.name.is_empty() {
} else if !label.name.is_empty() && label.purpose == LabelPurpose::Normal {
// Only fall back to the parser-supplied display
// name when there's no purpose to flag — the CLI
// handles purpose rendering itself.
a.label = label.name.clone();
}
}
@@ -127,6 +129,7 @@ pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle
if let Some(label) = labels.iter().find(|l| {
l.stream_type == StreamLabelType::Subtitle && l.stream_number == sub_idx
}) {
s.qualifier = label.qualifier;
if label.qualifier == LabelQualifier::Forced {
s.forced = true;
}
@@ -142,7 +145,7 @@ pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle
/// Runs after BD-J label extraction — fills gaps with codec + channel descriptions.
/// This is the central place for all fallback label generation.
pub fn fill_defaults(titles: &mut [crate::disc::DiscTitle]) {
use crate::disc::{Codec, HdrFormat, Stream};
use crate::disc::Stream;
for title in titles.iter_mut() {
for stream in &mut title.streams {
@@ -151,7 +154,8 @@ pub fn fill_defaults(titles: &mut [crate::disc::DiscTitle]) {
a.label = generate_audio_label(&a.codec, &a.channels, a.secondary);
}
Stream::Video(v) if v.label.is_empty() => {
v.label = generate_video_label(&v.codec, v.resolution.pixels(), &v.hdr, v.secondary);
v.label =
generate_video_label(&v.codec, v.resolution.pixels(), &v.hdr, v.secondary);
}
Stream::Subtitle(s) if s.forced => {
// Ensure forced subs are labeled even if BD-J didn't set a name
@@ -172,9 +176,12 @@ fn generate_video_label(
use crate::disc::HdrFormat;
if secondary {
// "Dolby Vision EL" is a brand identifier, not English prose, so the
// library may emit it. Other "secondary video" wording is a CLI
// concern — the library just leaves the label empty.
return match hdr {
HdrFormat::DolbyVision => "Dolby Vision EL".to_string(),
_ => "Secondary Video".to_string(),
_ => String::new(),
};
}
@@ -216,11 +223,12 @@ fn generate_video_label(
fn generate_audio_label(
codec: &crate::disc::Codec,
channels: &crate::disc::AudioChannels,
secondary: bool,
_secondary: bool,
) -> String {
use crate::disc::{AudioChannels, Codec};
// Full marketing names for disc audio codecs
// Full marketing names for disc audio codecs.
// These are codec brand identifiers, not user-facing English prose.
let codec_name = match codec {
Codec::TrueHd => "Dolby TrueHD",
Codec::Ac3 => "Dolby Digital",
@@ -250,12 +258,12 @@ fn generate_audio_label(
AudioChannels::Unknown => "",
};
let suffix = if secondary { " (Secondary)" } else { "" };
// The "(Secondary)" suffix is a CLI/UI concern — callers display it from
// the AudioStream::secondary bool, not the library.
if channel_str.is_empty() {
format!("{}{}", codec_name, suffix)
codec_name.to_string()
} else {
format!("{} {}{}", codec_name, channel_str, suffix)
format!("{} {}", codec_name, channel_str)
}
}
+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;
+62 -11
View File
@@ -90,33 +90,84 @@ pub(crate) mod platform;
pub mod profile;
pub mod scsi;
pub mod sector;
pub mod verify;
pub(crate) mod speed;
pub(crate) mod udf;
pub mod verify;
// ─── Drive lifecycle ────────────────────────────────────────────────────────
//
// `Drive::open(path)` → `wait_ready()` → `init()` → `Disc::scan()`. `Drive`
// owns the SCSI session; `DriveCapture` etc. let advanced callers introspect
// drive identity / profile data for sharing.
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};
// ─── Errors ─────────────────────────────────────────────────────────────────
//
// All fallible APIs return `Result<T, Error>`. `Error` is a typed enum with a
// numeric `code()`; **no English text in the library** — applications map
// codes to localized messages. See `error.rs` for the full taxonomy.
pub use error::{Error, Result};
// ─── Drive events (low-level callbacks) ─────────────────────────────────────
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};
// Platform trait is pub(crate) — callers use Drive, not Platform directly.
// ─── Decryption (AACS / CSS) ────────────────────────────────────────────────
//
// `Disc::scan()` resolves keys and stores them on `Disc`; in most flows you
// don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)`
// accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is
// for callers that operate on raw sector buffers (e.g. ISO patching).
pub use decrypt::{DecryptKeys, decrypt_sectors};
// ─── Disc structure ─────────────────────────────────────────────────────────
//
// `Disc::scan()` produces a fully-populated `Disc` (titles, streams, AACS
// state). `Disc::identify()` is the fast path — UDF only, no playlist parse,
// for displaying disc name + format quickly while a full scan runs in the
// background. The codec / channel / resolution enums are the canonical
// structured representation; never compare against display strings.
pub use disc::{
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, Disc,
DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, Resolution, SampleRate,
ScanOptions, Stream, SubtitleStream, VideoStream,
DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, LabelPurpose,
LabelQualifier, Resolution, SampleRate, ScanOptions, Stream, SubtitleStream, VideoStream,
};
// ─── Streams ────────────────────────────────────────────────────────────────
//
// All stream types implement `pes::Stream` — read PES frames from a source,
// write PES frames to a sink. Pick the right type at construction:
//
// - `DiscStream` — physical drive or ISO (any `SectorReader`). Always read.
// - `MkvStream` — Matroska container. Read on `open()`, write on `create()`.
// - `M2tsStream` — Blu-ray Transport Stream. Read on `open()`, write on `create()`.
// - `NetworkStream` — TCP. Read on `listen()`, write on `connect()`.
// - `NullStream` — write-only black-hole sink. Useful for benchmarks.
// - `StdioStream` — pipe to/from stdin/stdout. Read or write.
//
// Most consumers use the URL resolvers (`input()` / `output()`) which pick
// the right type from a scheme:// URL. Direct construction is for callers
// that need to wire custom readers (e.g. autorip's drive-session reuse).
pub use mux::DiscStream;
pub use mux::M2tsStream;
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 scsi::ScsiTransport;
pub use sector::SectorReader;
pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
// ─── Lower-level surfaces ───────────────────────────────────────────────────
//
// `ScsiTransport` is the platform-abstraction trait Drive uses; expose for
// out-of-tree platform backends. `SectorReader` lets callers feed any byte
// source (test harness, network image, SMB share) into the disc scan
// pipeline; `FileSectorReader` is the standard ISO-on-disk implementation.
pub use scsi::{DriveInfo, ScsiTransport, drive_has_disc, list_drives};
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];
+8 -19
View File
@@ -7,26 +7,15 @@
//! 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.
codec_data: Option<Vec<u8>>,
}
impl Default for DvdSubParser {
fn default() -> Self {
Self::new()
}
}
impl DvdSubParser {
pub fn new() -> Self {
Self { codec_data: None }
}
/// Create a parser with pre-formatted codec private data (palette header).
pub fn with_codec_data(codec_data: Option<Vec<u8>>) -> Self {
pub fn new(codec_data: Option<Vec<u8>>) -> Self {
Self { codec_data }
}
}
@@ -109,7 +98,7 @@ mod tests {
#[test]
fn passthrough_data() {
let mut parser = DvdSubParser::new();
let mut parser = DvdSubParser::new(None);
let sub_data = vec![0x00, 0x0A, 0x00, 0x08, 0x01, 0xFF, 0x02, 0x03, 0x04, 0x05];
let pes = make_pes(sub_data.clone(), Some(90000));
let frames = parser.parse(&pes);
@@ -124,7 +113,7 @@ mod tests {
#[test]
fn always_keyframe() {
let mut parser = DvdSubParser::new();
let mut parser = DvdSubParser::new(None);
for i in 0..3u8 {
let data = vec![0x00, i, 0x00, i + 1];
let pes = make_pes(data, Some(90000 * i as i64));
@@ -139,21 +128,21 @@ mod tests {
#[test]
fn empty_pes_returns_no_frames() {
let mut parser = DvdSubParser::new();
let mut parser = DvdSubParser::new(None);
let pes = make_pes(Vec::new(), Some(0));
assert!(parser.parse(&pes).is_empty());
}
#[test]
fn codec_private_none_by_default() {
let parser = DvdSubParser::new();
let parser = DvdSubParser::new(None);
assert!(parser.codec_private().is_none());
}
#[test]
fn codec_private_returns_palette_when_set() {
let palette_data = b"palette: 000000, ffffff\n".to_vec();
let parser = DvdSubParser::with_codec_data(Some(palette_data.clone()));
let parser = DvdSubParser::new(Some(palette_data.clone()));
let cp = parser.codec_private();
assert!(cp.is_some());
assert_eq!(cp.unwrap(), palette_data);
@@ -161,7 +150,7 @@ mod tests {
#[test]
fn no_pts_defaults_to_zero() {
let mut parser = DvdSubParser::new();
let mut parser = DvdSubParser::new(None);
let pes = make_pes(vec![0x01, 0x02], None);
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
+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 -10
View File
@@ -79,18 +79,10 @@ impl CodecParser for PassthroughParser {
}
}
/// Create the appropriate parser for a codec.
pub fn parser_for_codec(codec: Codec) -> Box<dyn CodecParser> {
parser_for_codec_with_data(codec, None)
}
/// Create the appropriate parser for a codec, with optional codec private data.
///
/// For DvdSub, `codec_data` should be the pre-formatted VobSub .idx palette header.
pub fn parser_for_codec_with_data(
codec: Codec,
codec_data: Option<Vec<u8>>,
) -> Box<dyn CodecParser> {
pub fn parser_for_codec(codec: Codec, codec_data: Option<Vec<u8>>) -> Box<dyn CodecParser> {
match codec {
Codec::H264 => Box::new(h264::H264Parser::new()),
Codec::Hevc => Box::new(hevc::HevcParser::new()),
@@ -101,7 +93,7 @@ pub fn parser_for_codec_with_data(
Codec::TrueHd => Box::new(truehd::TrueHdParser::new()),
Codec::Pgs => Box::new(pgs::PgsParser::new()),
Codec::Lpcm => Box::new(lpcm::LpcmParser::new()),
Codec::DvdSub => Box::new(dvdsub::DvdSubParser::with_codec_data(codec_data)),
Codec::DvdSub => Box::new(dvdsub::DvdSubParser::new(codec_data)),
_ => Box::new(PassthroughParser::new(true)),
}
}
+3 -3
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.
@@ -153,7 +153,7 @@ impl CodecParser for Mpeg2Parser {
bits += 64 * 8;
}
}
let total_bytes = 4 + ((bits + 7) / 8) as usize;
let total_bytes = 4 + bits.div_ceil(8) as usize;
(sc + total_bytes).min(data.len())
}
}
@@ -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;
+10 -13
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;
@@ -50,30 +50,27 @@ impl TrueHdParser {
0 => {
// 48 kHz
static SIZES: [usize; 38] = [
64, 64, 80, 80, 96, 96, 112, 112, 128, 128,
160, 160, 192, 192, 224, 224, 256, 256, 320, 320,
384, 384, 448, 448, 512, 512, 640, 640, 768, 768,
896, 896, 1024, 1024, 1152, 1152, 1280, 1280,
64, 64, 80, 80, 96, 96, 112, 112, 128, 128, 160, 160, 192, 192, 224, 224, 256,
256, 320, 320, 384, 384, 448, 448, 512, 512, 640, 640, 768, 768, 896, 896,
1024, 1024, 1152, 1152, 1280, 1280,
];
SIZES.get(frmsizecod).copied().unwrap_or(0)
}
1 => {
// 44.1 kHz
static SIZES: [usize; 38] = [
69, 70, 87, 88, 104, 105, 121, 122, 139, 140,
174, 175, 208, 209, 243, 244, 278, 279, 348, 349,
417, 418, 487, 488, 557, 558, 696, 697, 835, 836,
975, 976, 1114, 1115, 1253, 1254, 1393, 1394,
69, 70, 87, 88, 104, 105, 121, 122, 139, 140, 174, 175, 208, 209, 243, 244,
278, 279, 348, 349, 417, 418, 487, 488, 557, 558, 696, 697, 835, 836, 975, 976,
1114, 1115, 1253, 1254, 1393, 1394,
];
SIZES.get(frmsizecod).copied().unwrap_or(0)
}
2 => {
// 32 kHz
static SIZES: [usize; 38] = [
96, 96, 120, 120, 144, 144, 168, 168, 192, 192,
240, 240, 288, 288, 336, 336, 384, 384, 480, 480,
576, 576, 672, 672, 768, 768, 960, 960, 1152, 1152,
1344, 1344, 1536, 1536, 1728, 1728, 1920, 1920,
96, 96, 120, 120, 144, 144, 168, 168, 192, 192, 240, 240, 288, 288, 336, 336,
384, 384, 480, 480, 576, 576, 672, 672, 768, 768, 960, 960, 1152, 1152, 1344,
1344, 1536, 1536, 1728, 1728, 1920, 1920,
];
SIZES.get(frmsizecod).copied().unwrap_or(0)
}
+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;
+184 -14
View File
@@ -5,9 +5,93 @@
//!
//! Read-only. For disc→ISO (raw sector copy), use `Disc::copy()`.
use crate::disc::{detect_max_batch_sectors, Disc, DiscTitle, Extent, ScanOptions};
use crate::disc::{Disc, DiscTitle, Extent};
use crate::event::{BatchSizeReason, Event, EventKind};
use crate::sector::SectorReader;
use std::io;
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.
///
/// Chosen so that an isolated transient failure doesn't lock the rip at
/// size 1: once past the bad zone, we probe up after ~100 ms of good reads.
/// And so that noisy zones with occasional successes can't trigger a
/// premature probe — we need a sustained clean run.
const PROBE_THRESHOLD_SECTORS: u32 = 100 * 1024 * 1024 / 2048;
/// Halve a batch size, keeping 3-sector alignment when >= 6
/// (3-sector alignment = one AACS unit). At sizes < 6 we descend
/// 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 }
}
/// 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 }
}
/// Adaptive batch sizer. Shrinks on read failure, grows after a sustained
/// clean streak. Amortizes the cost of entering a bad zone — descent happens
/// once, not once per bad sector.
#[derive(Debug)]
struct AdaptiveBatch {
preferred: u16,
current: u16,
streak_sectors: u32,
}
impl AdaptiveBatch {
fn new(preferred: u16) -> Self {
Self {
preferred,
current: preferred,
streak_sectors: 0,
}
}
fn current(&self) -> u16 {
self.current
}
/// Record a successful read of `sectors`. Returns an event if the
/// sizer probed up to a larger batch size.
fn on_success(&mut self, sectors: u16) -> Option<EventKind> {
self.streak_sectors = self.streak_sectors.saturating_add(sectors as u32);
if self.current < self.preferred && self.streak_sectors >= PROBE_THRESHOLD_SECTORS {
let new_size = double_batch_size(self.current, self.preferred);
if new_size != self.current {
self.current = new_size;
self.streak_sectors = 0;
return Some(EventKind::BatchSizeChanged {
new_size,
reason: BatchSizeReason::Probed,
});
}
}
None
}
/// Record a read failure. Returns an event if the sizer shrank.
/// Does nothing at size 1 (caller handles skip/error).
fn on_failure(&mut self) -> Option<EventKind> {
self.streak_sectors = 0;
if self.current <= 1 {
return None;
}
let new_size = halve_batch_size(self.current);
self.current = new_size;
Some(EventKind::BatchSizeChanged {
new_size,
reason: BatchSizeReason::Shrunk,
})
}
}
/// Disc stream. Reads sectors from any source → PES frames.
///
@@ -30,9 +114,17 @@ pub struct DiscStream {
read_buf: Vec<u8>,
buf_valid: usize,
// Batch size for reads
batch_sectors: u16,
// Adaptive batch sizer — preferred comes from the caller
// (detect_max_batch_sectors), shrinks/grows based on read outcomes.
adaptive: AdaptiveBatch,
pub errors: u64,
pub skip_errors: bool,
/// When set and the flag is raised, fill_extents returns Err(Halted) at the
/// next retry boundary. Unlike skip_errors, this propagates the error up so
/// the rip terminates cleanly. Share the Arc with Drive::halt_flag() to get
/// unified Stop behavior across drive reads and sector processing.
halt: Option<Arc<AtomicBool>>,
event_fn: Option<Box<dyn Fn(Event) + Send>>,
eof: bool,
// PES output
@@ -69,7 +161,7 @@ impl DiscStream {
};
pids.push(pid);
pid_to_track.push((pid, idx));
parsers.push((pid, super::codec::parser_for_codec(codec)));
parsers.push((pid, super::codec::parser_for_codec(codec, None)));
}
let mut ts_demuxer = None;
@@ -96,8 +188,11 @@ impl DiscStream {
current_offset: 0,
read_buf: Vec::with_capacity(batch_sectors as usize * 2048),
buf_valid: 0,
batch_sectors,
adaptive: AdaptiveBatch::new(batch_sectors),
errors: 0,
skip_errors: false,
halt: None,
event_fn: None,
eof: false,
ts_demuxer,
ps_demuxer,
@@ -107,6 +202,33 @@ impl DiscStream {
}
}
/// Set event handler for sector-level events (binary search, skip, recover).
pub fn on_event(&mut self, f: impl Fn(Event) + Send + 'static) {
self.event_fn = Some(Box::new(f));
}
/// Share a halt flag — typically from `Drive::halt_flag()`. When raised,
/// the next read-retry boundary inside fill_extents returns Err(Halted)
/// instead of continuing. Required for Stop to work during dense bad-sector
/// regions (where read() loops internally waiting for enough clean data to
/// emit a frame and would otherwise never check an external stop signal).
pub fn set_halt(&mut self, flag: Arc<AtomicBool>) {
self.halt = Some(flag);
}
fn is_halted(&self) -> bool {
self.halt
.as_ref()
.map(|h| h.load(Ordering::Relaxed))
.unwrap_or(false)
}
fn emit(&self, kind: EventKind) {
if let Some(ref f) = self.event_fn {
f(Event { kind });
}
}
/// Skip decryption — return raw encrypted bytes.
pub fn set_raw(&mut self) {
self.decrypt_keys = crate::decrypt::DecryptKeys::None;
@@ -125,22 +247,70 @@ impl DiscStream {
let ext_sectors = self.extents[self.current_extent].sector_count;
let remaining = ext_sectors.saturating_sub(self.current_offset);
let sectors = remaining.min(self.batch_sectors as u32) as u16;
let sectors = sectors - (sectors % 3);
if sectors == 0 {
if remaining == 0 {
self.current_extent += 1;
self.current_offset = 0;
return self.fill_extents();
}
let lba = ext_start + self.current_offset;
let bytes = sectors as usize * 2048;
self.read_buf.resize(bytes, 0);
self.reader
.read_sectors(lba, sectors, &mut self.read_buf[..bytes])?;
self.buf_valid = bytes;
self.current_offset += sectors as u32;
// Adaptive sizer: start at current (preferred until a failure), shrink
// on failure, advance on success. One 5s read attempt per try — no
// retry loops, no sleeps. On size-1 failure, skip or error.
//
// Halt is checked at the top of every iteration — in a dense bad zone
// this loop can spend minutes shrinking and skipping sectors; without
// the check, Stop wouldn't take effect until the outer PES read() loop
// finally emits a frame, which may never happen.
loop {
if self.is_halted() {
return Err(crate::error::Error::Halted.into());
}
let mut sectors = remaining.min(self.adaptive.current() as u32) as u16;
// Align to 3-sector AACS units when possible. Partial units at
// extent boundaries are safely handled by decrypt_sectors().
if sectors >= 3 {
sectors -= sectors % 3;
}
let bytes = sectors as usize * 2048;
self.read_buf.resize(bytes, 0);
let ok = self
.reader
.read_sectors(lba, sectors, &mut self.read_buf[..bytes], false)
.is_ok();
if ok {
if let Some(ev) = self.adaptive.on_success(sectors) {
self.emit(ev);
}
self.buf_valid = bytes;
self.current_offset += sectors as u32;
break;
}
if sectors == 1 {
// Bottomed out. Skip this sector or bail.
if self.skip_errors {
self.read_buf.resize(2048, 0);
self.read_buf[..2048].fill(0);
self.buf_valid = 2048;
self.errors += 1;
self.emit(EventKind::SectorSkipped { sector: lba as u64 });
self.current_offset += 1;
break;
} else {
return Err(crate::error::Error::DiscRead { sector: lba as u64 }.into());
}
}
// Shrink and retry at the same LBA with a smaller batch.
if let Some(ev) = self.adaptive.on_failure() {
self.emit(ev);
}
}
if self.current_offset >= ext_sectors {
self.current_extent += 1;
self.current_offset = 0;
+1 -28
View File
@@ -87,11 +87,6 @@ pub fn write_uint(w: &mut impl Write, id: u32, val: u64) -> io::Result<()> {
}
}
/// Write a complete EBML signed integer element.
pub fn write_int(w: &mut impl Write, id: u32, val: i64) -> io::Result<()> {
write_uint(w, id, val as u64)
}
/// Write a complete EBML float element (8-byte double).
pub fn write_float(w: &mut impl Write, id: u32, val: f64) -> io::Result<()> {
write_id(w, id)?;
@@ -309,22 +304,6 @@ pub fn read_binary_val(r: &mut impl Read, len: usize) -> io::Result<Vec<u8>> {
Ok(buf)
}
/// Read a VINT (track number) from a SimpleBlock. Returns (value, bytes_consumed).
pub fn read_vint(r: &mut impl Read) -> io::Result<(u64, usize)> {
let mut first = [0u8; 1];
r.read_exact(&mut first)?;
let b0 = first[0];
if b0 & 0x80 != 0 {
return Ok(((b0 & 0x7F) as u64, 1));
}
if b0 & 0x40 != 0 {
let mut b = [0u8; 1];
r.read_exact(&mut b)?;
return Ok(((((b0 & 0x3F) as u64) << 8) | b[0] as u64, 2));
}
Err(crate::error::Error::MkvInvalid.into())
}
// ============================================================
// Matroska Element IDs
// ============================================================
@@ -342,12 +321,6 @@ pub const EBML_DOC_TYPE_READ_VERSION: u32 = 0x4285;
// Segment
pub const SEGMENT: u32 = 0x1853_8067;
// Seek Head
pub const SEEK_HEAD: u32 = 0x114D_9B74;
pub const SEEK: u32 = 0x4DBB;
pub const SEEK_ID: u32 = 0x53AB;
pub const SEEK_POSITION: u32 = 0x53AC;
// Segment Info
pub const INFO: u32 = 0x1549_A966;
pub const TIMESTAMP_SCALE: u32 = 0x2A_D7B1;
@@ -441,7 +414,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]);
}
+10 -5
View File
@@ -19,8 +19,7 @@ pub struct IsoSectorReader {
impl IsoSectorReader {
pub fn open(path: &str) -> std::io::Result<Self> {
let file = File::open(Path::new(path))
.map_err(|e| std::io::Error::new(e.kind(), format!("iso://{path}: {e}")))?;
let file = File::open(Path::new(path))?;
let size = file.metadata()?.len();
let sectors = size / SECTOR_SIZE;
if sectors > u32::MAX as u64 {
@@ -39,7 +38,13 @@ impl IsoSectorReader {
}
impl SectorReader for IsoSectorReader {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
let bytes = count as usize * SECTOR_SIZE as usize;
self.file
.seek(SeekFrom::Start(lba as u64 * SECTOR_SIZE))
@@ -71,11 +76,11 @@ mod tests {
assert_eq!(reader.capacity(), 4);
let mut buf = [0u8; 2048];
reader.read_sectors(0, 1, &mut buf).unwrap();
reader.read_sectors(0, 1, &mut buf, true).unwrap();
assert_eq!(buf[0], 1);
assert_eq!(buf[2047], 100);
reader.read_sectors(2, 1, &mut buf).unwrap();
reader.read_sectors(2, 1, &mut buf, true).unwrap();
assert_eq!(buf[0], 3);
assert_eq!(buf[2047], 102);
-127
View File
@@ -1,127 +0,0 @@
//! LookaheadBuffer — generic pre-scan buffer for stream pipelines.
//!
//! Accumulates data up to a configurable limit. When the consumer finds
//! what it needs, the buffer can be drained (fast path, no re-read).
//! If the buffer fills before the consumer is satisfied, it signals
//! overflow — the caller should discard and re-read from the source.
//!
//! Used by MkvStream to collect SPS/PPS before writing the MKV header.
//! Reusable for any stream stage that needs to look ahead.
/// Default lookahead buffer size: 5 MB.
pub const DEFAULT_LOOKAHEAD_SIZE: usize = 5 * 1024 * 1024;
/// Lookahead buffer states.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LookaheadState {
/// Still collecting data, haven't found what we need yet.
Collecting,
/// Found what we need, buffer has the data ready to drain.
Ready,
/// Buffer overflowed before finding what we need.
/// Caller should discard buffer, finish scanning without buffering,
/// then re-read from the source.
Overflow,
}
/// A bounded lookahead buffer.
pub struct LookaheadBuffer {
data: Vec<u8>,
max_size: usize,
state: LookaheadState,
}
impl LookaheadBuffer {
/// Create a new buffer with the given max size.
/// Pass 0 for no buffering (always overflows immediately).
pub fn new(max_size: usize) -> Self {
Self {
data: Vec::with_capacity(max_size.min(DEFAULT_LOOKAHEAD_SIZE)),
max_size,
state: LookaheadState::Collecting,
}
}
/// Push data into the buffer. Returns the new state.
/// If the buffer would overflow, transitions to Overflow state.
pub fn push(&mut self, chunk: &[u8]) -> LookaheadState {
if self.state != LookaheadState::Collecting {
return self.state;
}
if self.data.len() + chunk.len() > self.max_size {
self.state = LookaheadState::Overflow;
return self.state;
}
self.data.extend_from_slice(chunk);
self.state
}
/// Mark the buffer as ready — we found what we need.
pub fn mark_ready(&mut self) {
if self.state == LookaheadState::Collecting {
self.state = LookaheadState::Ready;
}
}
/// Get the buffered data (only valid in Ready state).
pub fn data(&self) -> &[u8] {
&self.data
}
/// Take ownership of the buffered data, clearing the buffer.
pub fn drain(&mut self) -> Vec<u8> {
self.state = LookaheadState::Collecting;
std::mem::take(&mut self.data)
}
/// Current state.
pub fn state(&self) -> LookaheadState {
self.state
}
/// How many bytes are buffered.
pub fn len(&self) -> usize {
self.data.len()
}
/// Is the buffer empty?
pub fn is_empty(&self) -> bool {
self.data.is_empty()
}
/// Max size this buffer can hold.
pub fn max_size(&self) -> usize {
self.max_size
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_flow() {
let mut buf = LookaheadBuffer::new(100);
assert_eq!(buf.push(b"hello"), LookaheadState::Collecting);
assert_eq!(buf.push(b"world"), LookaheadState::Collecting);
assert_eq!(buf.len(), 10);
buf.mark_ready();
assert_eq!(buf.state(), LookaheadState::Ready);
assert_eq!(buf.data(), b"helloworld");
}
#[test]
fn test_overflow() {
let mut buf = LookaheadBuffer::new(5);
assert_eq!(buf.push(b"abc"), LookaheadState::Collecting);
assert_eq!(buf.push(b"def"), LookaheadState::Overflow);
}
#[test]
fn test_zero_size() {
let mut buf = LookaheadBuffer::new(0);
assert_eq!(buf.push(b"a"), LookaheadState::Overflow);
}
}
+1 -1
View File
@@ -103,7 +103,7 @@ impl M2tsStream {
};
pids.push(pid);
pid_to_track.push((pid, i));
parsers.push((pid, super::codec::parser_for_codec(codec)));
parsers.push((pid, super::codec::parser_for_codec(codec, None)));
}
(pids, parsers, pid_to_track)
}
+3 -1
View File
@@ -173,6 +173,7 @@ impl M2tsMeta {
.parse()
.unwrap_or(crate::disc::SampleRate::Unknown),
secondary: *secondary,
purpose: crate::disc::LabelPurpose::Normal,
label: label.clone(),
}),
MetaStream::Subtitle {
@@ -185,6 +186,7 @@ impl M2tsMeta {
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
language: language.clone(),
forced: *forced,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
}),
})
@@ -211,7 +213,7 @@ impl M2tsMeta {
.iter()
.map(|s| {
if let MetaStream::Video {
codec_private: Some(ref b64),
codec_private: Some(b64),
..
} = s
{
+48 -81
View File
@@ -5,8 +5,10 @@
//! cues and seek head are finalized at the end.
use super::ebml;
use crate::disc::{AudioStream, Chapter, Codec, ColorSpace, HdrFormat, SubtitleStream, VideoStream};
use std::io::{self, Seek, SeekFrom, Write};
use crate::disc::{
AudioStream, Chapter, Codec, ColorSpace, HdrFormat, SubtitleStream, VideoStream,
};
use std::io::{self, Seek, Write};
/// MKV track definition (built from disc stream metadata).
pub struct MkvTrack {
@@ -24,10 +26,10 @@ pub struct MkvTrack {
pub display_width: u32, // display aspect ratio width (0 = same as pixel)
pub display_height: u32, // display aspect ratio height (0 = same as pixel)
// HDR colour metadata
pub colour_matrix: u8, // MatrixCoefficients (9=bt2020nc)
pub colour_transfer: u8, // TransferCharacteristics (16=smpte2084/PQ)
pub colour_primaries: u8, // Primaries (9=bt2020)
pub colour_range: u8, // Range (1=tv/limited)
pub colour_matrix: u8, // MatrixCoefficients (9=bt2020nc)
pub colour_transfer: u8, // TransferCharacteristics (16=smpte2084/PQ)
pub colour_primaries: u8, // Primaries (9=bt2020)
pub colour_range: u8, // Range (1=tv/limited)
// Audio-specific
pub sample_rate: f64,
pub channels: u8,
@@ -52,7 +54,7 @@ impl MkvTrack {
};
let (matrix, transfer, primaries, range) = match v.color_space {
ColorSpace::Bt2020 => (9, 16, 9, 1), // bt2020nc, PQ, bt2020, limited
ColorSpace::Bt709 => (1, 1, 1, 1), // bt709
ColorSpace::Bt709 => (1, 1, 1, 1), // bt709
ColorSpace::Unknown => (0, 0, 0, 0),
};
// Override transfer for non-PQ HDR
@@ -168,10 +170,6 @@ pub struct MkvMuxer<W: Write + Seek> {
base_pts_ms: Option<i64>,
cues: Vec<CuePoint>,
frame_count: u64,
/// File positions of codecPrivate placeholders (track_idx → offset, max_size).
/// Used to seek back and fill in SPS/PPS after first keyframe.
codec_private_slots: Vec<Option<(u64, usize)>>,
codec_private_filled: Vec<bool>,
}
/// New cluster every 5 seconds.
@@ -180,16 +178,6 @@ const CLUSTER_DURATION_MS: i64 = 5000;
impl<W: Write + Seek> MkvMuxer<W> {
/// Create a new MKV muxer: writes EBML header, Segment start, Info, Tracks, Chapters.
pub fn new(
writer: W,
tracks: &[MkvTrack],
title: Option<&str>,
duration_secs: f64,
) -> io::Result<Self> {
Self::new_with_chapters(writer, tracks, title, duration_secs, &[])
}
/// Create a new MKV muxer with chapters: writes EBML header, Segment start, Info, Tracks, Chapters.
pub fn new_with_chapters(
mut writer: W,
tracks: &[MkvTrack],
title: Option<&str>,
@@ -227,8 +215,6 @@ impl<W: Write + Seek> MkvMuxer<W> {
ebml::end_master(&mut writer, info_pos)?;
// Tracks
let mut codec_private_slots: Vec<Option<(u64, usize)>> = Vec::new();
let mut codec_private_filled: Vec<bool> = Vec::new();
let tracks_pos = ebml::start_master(&mut writer, ebml::TRACKS)?;
for (i, track) in tracks.iter().enumerate() {
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
@@ -251,24 +237,20 @@ impl<W: Write + Seek> MkvMuxer<W> {
if let Some(ref cp) = track.codec_private {
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?;
codec_private_slots.push(None); // already filled
codec_private_filled.push(true);
} else if track.track_type == ebml::TRACK_TYPE_VIDEO {
// Reserve space for codecPrivate — will be filled after first keyframe
// Reserve 256 bytes (enough for SPS+PPS or VPS+SPS+PPS)
let cp_pos = writer.stream_position()?;
let placeholder = vec![0u8; 256];
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, &placeholder)?;
codec_private_slots.push(Some((cp_pos, 256)));
codec_private_filled.push(false);
} else {
codec_private_slots.push(None);
codec_private_filled.push(true);
}
// Pre-0.13 a deferred codecPrivate path existed for video tracks
// (placeholder reserve + later seek-back fill via
// `fill_codec_private`). The PES pipeline hands codec_private
// up-front via the DiscTitle, so the deferred path was never
// exercised — removed in the 0.13 dead-code sweep.
// DefaultDuration — frame duration in nanoseconds
if track.default_duration_ns > 0 {
ebml::write_uint(&mut writer, ebml::DEFAULT_DURATION, track.default_duration_ns)?;
ebml::write_uint(
&mut writer,
ebml::DEFAULT_DURATION,
track.default_duration_ns,
)?;
}
// Video-specific
@@ -278,13 +260,25 @@ impl<W: Write + Seek> MkvMuxer<W> {
ebml::write_uint(&mut writer, ebml::PIXEL_HEIGHT, track.pixel_height as u64)?;
if track.display_width > 0 && track.display_height > 0 {
ebml::write_uint(&mut writer, ebml::DISPLAY_WIDTH, track.display_width as u64)?;
ebml::write_uint(&mut writer, ebml::DISPLAY_HEIGHT, track.display_height as u64)?;
ebml::write_uint(
&mut writer,
ebml::DISPLAY_HEIGHT,
track.display_height as u64,
)?;
}
// Colour metadata (HDR)
if track.colour_matrix > 0 || track.colour_transfer > 0 {
let col_pos = ebml::start_master(&mut writer, ebml::COLOUR)?;
ebml::write_uint(&mut writer, ebml::MATRIX_COEFFICIENTS, track.colour_matrix as u64)?;
ebml::write_uint(&mut writer, ebml::TRANSFER_CHARACTERISTICS, track.colour_transfer as u64)?;
ebml::write_uint(
&mut writer,
ebml::MATRIX_COEFFICIENTS,
track.colour_matrix as u64,
)?;
ebml::write_uint(
&mut writer,
ebml::TRANSFER_CHARACTERISTICS,
track.colour_transfer as u64,
)?;
ebml::write_uint(&mut writer, ebml::PRIMARIES, track.colour_primaries as u64)?;
ebml::write_uint(&mut writer, ebml::RANGE, track.colour_range as u64)?;
ebml::end_master(&mut writer, col_pos)?;
@@ -336,8 +330,6 @@ impl<W: Write + Seek> MkvMuxer<W> {
base_pts_ms: None,
cues: Vec::new(),
frame_count: 0,
codec_private_slots,
codec_private_filled,
})
}
@@ -407,30 +399,6 @@ impl<W: Write + Seek> MkvMuxer<W> {
Ok(())
}
/// Fill in a deferred codecPrivate for a track.
/// Seeks back to the placeholder, writes the actual data, restores position.
pub fn fill_codec_private(&mut self, track_idx: usize, data: &[u8]) -> io::Result<()> {
if track_idx >= self.codec_private_filled.len() || self.codec_private_filled[track_idx] {
return Ok(());
}
if let Some((pos, max_size)) = self.codec_private_slots[track_idx] {
if data.len() > max_size {
// Data too large for reserved space — can't fill in place
// This shouldn't happen with 256 bytes reserved
return Ok(());
}
let current = self.writer.stream_position()?;
self.writer.seek(SeekFrom::Start(pos))?;
// Rewrite: element ID + size + data + zero-pad remainder
let mut padded = data.to_vec();
padded.resize(max_size, 0);
ebml::write_binary(&mut self.writer, ebml::CODEC_PRIVATE, &padded)?;
self.writer.seek(SeekFrom::Start(current))?;
self.codec_private_filled[track_idx] = true;
}
Ok(())
}
fn start_cluster(&mut self, ts_ms: i64) -> io::Result<()> {
// Close previous cluster if open
if self.cluster_open {
@@ -562,7 +530,7 @@ mod tests {
fn mkv_writes_ebml_header() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track()];
let muxer = MkvMuxer::new(buf, &tracks, Some("Test"), 120.0).unwrap();
let muxer = MkvMuxer::new(buf, &tracks, Some("Test"), 120.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// EBML header element ID: 0x1A45DFA3
assert!(data.len() >= 4);
@@ -573,7 +541,7 @@ mod tests {
fn mkv_writes_segment() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track()];
let muxer = MkvMuxer::new(buf, &tracks, None, 0.0).unwrap();
let muxer = MkvMuxer::new(buf, &tracks, None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// Segment element ID: 0x18538067
assert!(
@@ -586,7 +554,7 @@ mod tests {
fn mkv_write_frame_creates_cluster() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(buf, &tracks, None, 60.0).unwrap();
let mut muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF])
.unwrap();
@@ -625,7 +593,7 @@ mod tests {
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let writer = SharedWriter(shared.clone());
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(writer, &tracks, Some("Cue Test"), 60.0).unwrap();
let mut muxer = MkvMuxer::new(writer, &tracks, Some("Cue Test"), 60.0, &[]).unwrap();
muxer.write_frame(0, 0, true, &[0x01, 0x02, 0x03]).unwrap();
muxer.finish().unwrap();
@@ -640,7 +608,7 @@ mod tests {
fn mkv_multiple_tracks() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track(), make_audio_track()];
let mut muxer = MkvMuxer::new(buf, &tracks, Some("Multi"), 120.0).unwrap();
let mut muxer = MkvMuxer::new(buf, &tracks, Some("Multi"), 120.0, &[]).unwrap();
// Write frames to both tracks
muxer.write_frame(0, 0, true, &[0x00, 0x00, 0x01]).unwrap();
muxer.write_frame(1, 0, false, &[0x0B, 0x77, 0x00]).unwrap();
@@ -659,7 +627,7 @@ mod tests {
fn mkv_keyframe_flag() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(buf, &tracks, None, 10.0).unwrap();
let mut muxer = MkvMuxer::new(buf, &tracks, None, 10.0, &[]).unwrap();
// Record position before first frame
let pos_before_kf = muxer.writer.position();
@@ -691,7 +659,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;
@@ -733,9 +701,7 @@ mod tests {
name: "Chapter 3".into(),
},
];
let muxer =
MkvMuxer::new_with_chapters(buf, &tracks, Some("Chapter Test"), 900.0, &chapters)
.unwrap();
let muxer = MkvMuxer::new(buf, &tracks, Some("Chapter Test"), 900.0, &chapters).unwrap();
let data = muxer.writer.into_inner();
// Chapters element ID: 0x1043A770
@@ -759,7 +725,7 @@ mod tests {
fn mkv_no_chapters_when_empty() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track()];
let muxer = MkvMuxer::new(buf, &tracks, Some("No Chapters"), 60.0).unwrap();
let muxer = MkvMuxer::new(buf, &tracks, Some("No Chapters"), 60.0, &[]).unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::CHAPTERS).is_none(),
@@ -778,7 +744,7 @@ mod tests {
let buf = Cursor::new(Vec::new());
let tracks = [video, audio1, audio2];
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0).unwrap();
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// FlagDefault ID is 0x88. When is_default is true, FlagDefault is NOT written
@@ -804,13 +770,14 @@ mod tests {
codec: Codec::Pgs,
language: "eng".into(),
forced: true,
qualifier: crate::disc::LabelQualifier::Forced,
codec_data: None,
});
assert!(forced_sub.is_forced);
let buf = Cursor::new(Vec::new());
let tracks = [video, forced_sub];
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0).unwrap();
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// FlagForced ID: 0x55AA (2-byte ID)
@@ -829,13 +796,14 @@ mod tests {
codec: Codec::Pgs,
language: "eng".into(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
});
assert!(!sub.is_forced);
let buf = Cursor::new(Vec::new());
let tracks = [video, sub];
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0).unwrap();
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// FlagForced should NOT be written for non-forced tracks
@@ -845,4 +813,3 @@ mod tests {
);
}
}
+7 -5
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>)>)>;
@@ -64,7 +64,7 @@ impl MkvStream {
tracks.push(track);
}
let muxer = MkvMuxer::new_with_chapters(
let muxer = MkvMuxer::new(
writer,
&tracks,
Some(&title.playlist),
@@ -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()),
}
@@ -380,6 +380,7 @@ fn parse_track(
language: lang,
sample_rate: srs,
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: name,
})),
17 => Some(crate::disc::Stream::Subtitle(SubtitleStream {
@@ -387,6 +388,7 @@ fn parse_track(
codec,
language: lang,
forced,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
})),
_ => None,
+31 -14
View File
@@ -15,22 +15,32 @@
//!
//! For disc→ISO (raw sector copy), use `Disc::copy()` instead.
// Public modules — types here are intentionally part of the consumable API.
pub mod codec;
pub mod disc;
pub mod ebml;
pub mod iso;
mod m2ts;
pub mod meta;
pub mod mkv;
mod mkvstream;
pub mod network;
pub mod null;
pub mod ps;
pub mod resolve;
pub mod stdio;
pub mod ts;
pub mod tsmux;
pub mod tsreader;
// Internal modules — implementation details. Their *types* are re-exported
// where appropriate (`MkvStream`, `M2tsStream`, etc. surface from `lib.rs`),
// but the module paths themselves are not part of the API. Pre-0.13 these
// were `pub`, leaking low-level EBML primitives, TS muxer internals, and
// network/stdio implementations that no external caller had business
// reaching for.
pub(crate) mod ebml;
pub(crate) mod m2ts;
/// FMKV metadata header (used by `M2tsStream` / `NetworkStream` / `StdioStream`
/// to round-trip codec_privates that don't fit inside the underlying format).
/// Exposed for integration tests that exercise the wire format directly.
pub mod meta;
pub(crate) mod mkv;
pub(crate) mod mkvstream;
pub(crate) mod network;
pub(crate) mod null;
pub(crate) mod ps;
pub(crate) mod stdio;
pub(crate) mod ts;
pub(crate) mod tsmux;
pub use disc::DiscStream;
pub use iso::IsoSectorReader;
@@ -38,11 +48,18 @@ 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};
// WriteSeek — used internally by MKV muxer (container format requires seeking).
/// Combined `Write + Seek` for sinks accepted by the MKV muxer.
///
/// Matroska's `SeekHead`, `Cues`, and `Cluster` size fields are written with
/// placeholder values during streaming and updated in-place at finalization,
/// so the output sink must support seeking. Provided as a single trait
/// alias so callers don't have to repeat `Write + Seek` everywhere; the
/// blanket impl below opts every `T: Write + Seek` in automatically
/// (`File`, `BufWriter<File>`, `Cursor<Vec<u8>>`).
pub trait WriteSeek: Write + Seek {}
impl<T: Write + Seek> WriteSeek for T {}
+2 -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 {
@@ -144,6 +144,7 @@ mod tests {
language: "eng".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: "English".into(),
}),
],
+12 -19
View File
@@ -171,17 +171,18 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
let parsed = parse_url(url);
match parsed {
StreamUrl::Disc { .. } => {
// Disc sources should use DiscStream::new() directly.
// The caller opens the drive, inits, scans, then creates the stream.
Err(io::Error::new(
io::ErrorKind::Unsupported,
"Use Drive::open() + Disc::scan() + DiscStream::new() for disc sources",
))
// Disc sources require live SCSI state — caller must use
// `Drive::open() + Disc::scan() + DiscStream::new()` directly.
// Surfaced as a typed error (no English commentary in the
// library; the CLI/UI explains the right entry point).
Err(crate::error::Error::DiscUrlNotDirect.into())
}
StreamUrl::Iso { ref path } => {
validate_file_path(path, "iso")?;
let scan_opts = match &opts.keydb_path {
Some(p) => crate::disc::ScanOptions::with_keydb(p),
Some(p) => crate::disc::ScanOptions {
keydb_path: Some(p.into()),
},
None => crate::disc::ScanOptions::default(),
};
let mut reader = super::iso::IsoSectorReader::open(&path.to_string_lossy())?;
@@ -210,17 +211,13 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
}
StreamUrl::M2ts { ref path } => {
validate_file_path(path, "m2ts")?;
let file = std::fs::File::open(path).map_err(|e| {
io::Error::new(e.kind(), format!("m2ts://{}: {}", path.display(), e))
})?;
let file = std::fs::File::open(path)?;
let reader = std::io::BufReader::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(M2tsStream::open(reader)?))
}
StreamUrl::Mkv { ref path } => {
validate_file_path(path, "mkv")?;
let file = std::fs::File::open(path).map_err(|e| {
io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e))
})?;
let file = std::fs::File::open(path)?;
let reader = std::io::BufReader::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(MkvStream::open(reader)?))
}
@@ -245,18 +242,14 @@ pub fn output(
match parsed {
StreamUrl::Mkv { ref path } => {
validate_file_path(path, "mkv")?;
let file = std::fs::File::create(path).map_err(|e| {
io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e))
})?;
let file = std::fs::File::create(path)?;
let writer: Box<dyn super::WriteSeek> =
Box::new(std::io::BufWriter::with_capacity(IO_BUF_SIZE, file));
Ok(Box::new(MkvStream::create(writer, title)?))
}
StreamUrl::M2ts { ref path } => {
validate_file_path(path, "m2ts")?;
let file = std::fs::File::create(path).map_err(|e| {
io::Error::new(e.kind(), format!("m2ts://{}: {}", path.display(), e))
})?;
let file = std::fs::File::create(path)?;
let writer = std::io::BufWriter::with_capacity(IO_BUF_SIZE, file);
Ok(Box::new(M2tsStream::create(writer, title)?))
}
+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);
+15 -115
View File
@@ -15,12 +15,6 @@ const TS_PACKET_SIZE: usize = 188;
/// TS sync byte.
const SYNC_BYTE: u8 = 0x47;
/// Size of head buffer for PTS/stream scanning (1 MB).
const SCAN_HEAD_SIZE: usize = 1024 * 1024;
/// Size of tail buffer for last-PTS scanning (2 MB).
const SCAN_TAIL_SIZE: usize = 2 * 1024 * 1024;
/// A reassembled PES packet with timestamp info.
#[derive(Debug)]
pub struct PesPacket {
@@ -104,19 +98,16 @@ pub struct TsDemuxer {
}
impl TsDemuxer {
/// Take the remainder bytes (leftover from last feed() that didn't
/// align to a 192-byte packet boundary).
pub fn take_remainder(&mut self) -> Vec<u8> {
std::mem::take(&mut self.remainder)
}
/// Set the remainder bytes. Used to transfer alignment state from
/// one demuxer to another without losing sync.
pub fn set_remainder(&mut self, data: Vec<u8>) {
self.remainder = data;
}
/// Create a new demuxer tracking the given PIDs.
///
/// Allocates a flat lookup table of `i16` slots — one per possible PID
/// up to `max(8192, max_pid + 1)`. The 8192 floor matches the BD-TS
/// 13-bit PID space (0..0x1FFF); the variable upper bound exists for
/// DVD program streams which may use 16-bit stream IDs above 8191.
/// Worst-case allocation is `u16::MAX × 2 bytes ≈ 128 KB` — bounded by
/// the type, so adversarial input can't drive this beyond predictable
/// limits. Empty `pids` yields max_pid 0; the floor still produces a
/// valid (wholly-unused) table.
pub fn new(pids: &[u16]) -> Self {
let max_pid = pids.iter().copied().max().unwrap_or(0) as usize;
let table_size = (max_pid + 1).max(8192);
@@ -446,6 +437,7 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
language: "und".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
})),
0x83 => Some(Stream::Audio(AudioStream {
@@ -455,6 +447,7 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
language: "und".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
})),
0x84 | 0xA1 => Some(Stream::Audio(AudioStream {
@@ -464,6 +457,7 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
language: "und".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
})),
0x85 | 0x86 => Some(Stream::Audio(AudioStream {
@@ -473,6 +467,7 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
language: "und".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
})),
0x82 => Some(Stream::Audio(AudioStream {
@@ -482,6 +477,7 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
language: "und".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
})),
0x90 => Some(Stream::Subtitle(SubtitleStream {
@@ -489,6 +485,7 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
codec: Codec::Pgs,
language: "und".into(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
})),
_ => None,
@@ -511,103 +508,6 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
}
}
// ============================================================
// PTS scanning utilities (for duration detection)
// ============================================================
/// Find the first PTS for a given PID in BD-TS data.
pub fn scan_first_pts(data: &[u8], target_pid: u16) -> Option<i64> {
let mut offset = 0;
while offset + BD_TS_PACKET_SIZE <= data.len() {
if data[offset + 4] != SYNC_BYTE {
offset += 1;
continue;
}
let pid = (((data[offset + 5] & 0x1F) as u16) << 8) | data[offset + 6] as u16;
let pusi = data[offset + 5] & 0x40 != 0;
if pid == target_pid && pusi {
let ts = &data[offset + 4..offset + BD_TS_PACKET_SIZE];
let afc = (ts[3] >> 4) & 0x03;
let payload_start = if afc == 3 { 5 + ts[4] as usize } else { 4 };
if payload_start < TS_PACKET_SIZE {
let payload = &ts[payload_start..];
if payload.len() >= 14 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 {
let pts_dts_flags = (payload[7] >> 6) & 0x03;
if pts_dts_flags >= 2 {
return parse_timestamp(&payload[9..14]);
}
}
}
}
offset += BD_TS_PACKET_SIZE;
}
None
}
/// Find the last PTS for a given PID in BD-TS data.
pub fn scan_last_pts(data: &[u8], target_pid: u16) -> Option<i64> {
let mut last_pts = None;
let mut offset = 0;
while offset + BD_TS_PACKET_SIZE <= data.len() {
if data[offset + 4] != SYNC_BYTE {
offset += 1;
continue;
}
let pid = (((data[offset + 5] & 0x1F) as u16) << 8) | data[offset + 6] as u16;
let pusi = data[offset + 5] & 0x40 != 0;
if pid == target_pid && pusi {
let ts = &data[offset + 4..offset + BD_TS_PACKET_SIZE];
let afc = (ts[3] >> 4) & 0x03;
let payload_start = if afc == 3 { 5 + ts[4] as usize } else { 4 };
if payload_start < TS_PACKET_SIZE {
let payload = &ts[payload_start..];
if payload.len() >= 14 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 {
let pts_dts_flags = (payload[7] >> 6) & 0x03;
if pts_dts_flags >= 2 {
last_pts = parse_timestamp(&payload[9..14]);
}
}
}
}
offset += BD_TS_PACKET_SIZE;
}
last_pts
}
/// Scan an m2ts file for duration by reading first and last PTS.
/// Returns duration in seconds, or None if PTS cannot be found.
/// The reader position is restored after scanning.
pub fn scan_duration<R: std::io::Read + std::io::Seek>(r: &mut R, video_pid: u16) -> Option<f64> {
use std::io::SeekFrom;
let start_pos = r.stream_position().ok()?;
// Read first 1MB for first PTS
let mut head_buf = vec![0u8; SCAN_HEAD_SIZE];
r.seek(SeekFrom::Start(0)).ok()?;
let head_n = r.read(&mut head_buf).ok()?;
let first_pts = scan_first_pts(&head_buf[..head_n], video_pid)?;
// Read last 2MB for last PTS (aligned to 192-byte boundary)
let file_size = r.seek(SeekFrom::End(0)).ok()?;
let tail_size: u64 = SCAN_TAIL_SIZE as u64;
let raw_pos = file_size.saturating_sub(tail_size);
let seek_pos = (raw_pos / BD_TS_PACKET_SIZE as u64) * BD_TS_PACKET_SIZE as u64;
r.seek(SeekFrom::Start(seek_pos)).ok()?;
let mut tail_buf = vec![0u8; tail_size as usize];
let tail_n = r.read(&mut tail_buf).ok()?;
let last_pts = scan_last_pts(&tail_buf[..tail_n], video_pid)?;
// Restore reader position
let _ = r.seek(SeekFrom::Start(start_pos));
if last_pts > first_pts {
Some((last_pts - first_pts) as f64 / 90000.0)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
+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
-115
View File
@@ -1,115 +0,0 @@
//! TsDemuxReader — reads from any source, demuxes BD-TS, produces PES frames.
//!
//! Wraps any Read source with a TsDemuxer + CodecParsers.
//! One implementation used by M2TS, Network, Stdio, and any other BD-TS input.
use super::codec::{self, CodecParser};
use super::ts::TsDemuxer;
use crate::disc::Stream as DiscStream;
use crate::pes::PesFrame;
use std::collections::VecDeque;
use std::io::{self, Read};
const READ_BUF_SIZE: usize = 192 * 1024; // 1024 BD-TS packets
/// Generic BD-TS → PES frame reader.
pub struct TsDemuxReader<R: Read> {
reader: R,
demuxer: TsDemuxer,
parsers: Vec<(u16, Box<dyn CodecParser>)>,
pid_to_track: Vec<(u16, usize)>,
pending: VecDeque<PesFrame>,
buf: Vec<u8>,
eof: bool,
}
impl<R: Read> TsDemuxReader<R> {
/// Create from a reader and stream metadata.
pub fn new(reader: R, streams: &[DiscStream]) -> Self {
let mut pids = Vec::new();
let mut parsers: Vec<(u16, Box<dyn CodecParser>)> = Vec::new();
let mut pid_to_track = Vec::new();
for (i, s) in streams.iter().enumerate() {
let (pid, c) = match s {
DiscStream::Video(v) => (v.pid, v.codec),
DiscStream::Audio(a) => (a.pid, a.codec),
DiscStream::Subtitle(s) => (s.pid, s.codec),
};
pids.push(pid);
pid_to_track.push((pid, i));
parsers.push((pid, codec::parser_for_codec(c)));
}
Self {
reader,
demuxer: TsDemuxer::new(&pids),
parsers,
pid_to_track,
pending: VecDeque::new(),
buf: vec![0u8; READ_BUF_SIZE],
eof: false,
}
}
/// Get the next PES frame. Returns None at EOF.
pub fn next_frame(&mut self) -> io::Result<Option<PesFrame>> {
if let Some(frame) = self.pending.pop_front() {
return Ok(Some(frame));
}
if self.eof {
return Ok(None);
}
loop {
let n = self.reader.read(&mut self.buf)?;
if n == 0 {
self.eof = true;
return Ok(None);
}
let packets = self.demuxer.feed(&self.buf[..n]);
for pes in &packets {
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid)
{
if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
{
for frame in parser.parse(pes) {
self.pending
.push_back(PesFrame::from_codec_frame(*track, frame));
}
}
}
}
if let Some(frame) = self.pending.pop_front() {
return Ok(Some(frame));
}
}
}
/// Codec private data for a track.
pub fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
let pid = self
.pid_to_track
.iter()
.find(|(_, idx)| *idx == track)
.map(|(pid, _)| *pid)?;
self.parsers
.iter()
.find(|(p, _)| *p == pid)
.and_then(|(_, parser)| parser.codec_private())
}
/// True when all primary video tracks have codec_private.
pub fn headers_ready(&self, streams: &[DiscStream]) -> bool {
for (idx, s) in streams.iter().enumerate() {
if let DiscStream::Video(v) = s {
if !v.secondary && self.codec_private(idx).is_none() {
return false;
}
}
}
true
}
}
+44 -7
View File
@@ -78,26 +78,63 @@ impl PesFrame {
}
}
/// A stream. Read from it or write to it. Not both.
/// A PES frame source or sink. Each implementor is **either** read-only or
/// write-only — never both.
///
/// Implementors fall into two camps:
///
/// - **Read sources**: `DiscStream` (drive or ISO), `M2tsStream` (when
/// constructed from an existing file), `MkvStream` (demux), `NetworkStream`
/// (TCP listener), `StdioStream::input()`. These return frames from
/// `read()` and surface `StreamWriteOnly` (E9001) from `write()`.
/// - **Write sinks**: `MkvStream::create`, `M2tsStream::create`,
/// `NetworkStream::connect`, `StdioStream::output()`, `NullStream`.
/// These accept frames in `write()` and surface `StreamReadOnly` (E9000)
/// from `read()`. Always call `finish()` when done — that's where MKV
/// writes its `Cues` index and `M2tsStream` flushes the TS muxer.
///
/// Direction is established at construction; mixing produces an error code,
/// not a panic. Most consumers don't construct streams directly — call
/// `mux::input(url, opts)` / `mux::output(url, title)` and let URL parsing
/// pick the right type.
///
/// `info()` returns the stream's `DiscTitle` metadata (track list, codec
/// info, duration). For sources it's parsed from the input; for sinks it's
/// the metadata supplied at creation. Stable across all reads.
///
/// `codec_private(track)` exposes per-track initialization data
/// (H.264 SPS/PPS, HEVC VPS/SPS/PPS, AC-3 fscod, etc.) that some output
/// formats need before any frame can be written. `headers_ready()` returns
/// false until enough input frames have been seen to populate every video
/// track's codec-private blob — callers buffer frames they read until
/// `headers_ready()` returns true.
pub trait Stream {
/// Read the next frame. Returns None at end of stream.
/// Read the next frame, or `Ok(None)` at end of stream. Returns
/// `StreamWriteOnly` (E9001) on a write-only sink.
fn read(&mut self) -> std::io::Result<Option<PesFrame>>;
/// Write a frame.
/// Write a frame to the sink. Returns `StreamReadOnly` (E9000) on a
/// read-only source.
fn write(&mut self, frame: &PesFrame) -> std::io::Result<()>;
/// Finalize (flush, write index, close).
/// Finalize the stream: flush buffered frames, write any container
/// index (MKV `Cues`), close the underlying file/socket. Idempotent
/// for read-only streams (no-op).
fn finish(&mut self) -> std::io::Result<()>;
/// Stream metadata.
/// Stream metadata. Stable across reads — implementors must return a
/// consistent reference for the lifetime of the stream.
fn info(&self) -> &crate::disc::DiscTitle;
/// Codec initialization data for a track (SPS/PPS, etc).
/// Codec initialization data for a track (SPS/PPS, AC-3 fscod, etc.).
/// `None` for tracks that don't need codec_private (raw passthrough).
fn codec_private(&self, _track: usize) -> Option<Vec<u8>> {
None
}
/// True when codec_private is available for all video tracks.
/// True when `codec_private` is available for every video track —
/// callers buffer input frames until this flips, since some output
/// formats (MKV) can't write frames without codec init data.
fn headers_ready(&self) -> bool {
true
}
+420 -21
View File
@@ -1,4 +1,11 @@
//! Linux SCSI transport via SG_IO ioctl.
//! Linux SCSI transport via async sg write/poll/read.
//!
//! Uses the sg driver's asynchronous interface instead of the blocking
//! SG_IO ioctl. Commands are submitted via write(), waited on via
//! poll() with a hard timeout, and completed via read(). If poll()
//! times out, the fd is abandoned (closed in a background thread) and
//! a fresh fd is opened. This gives us true user-controlled timeouts
//! that the kernel's USB error recovery cannot override.
use super::{DataDirection, ScsiResult, ScsiTransport};
use crate::error::{Error, Result};
@@ -10,9 +17,17 @@ const SG_SCSI_RESET_DEVICE: i32 = 1;
const SG_DXFER_NONE: i32 = -1;
const SG_DXFER_TO_DEV: i32 = -2;
const SG_DXFER_FROM_DEV: i32 = -3;
const SG_FLAG_DIRECT_IO: u32 = 1;
const SG_FLAG_Q_AT_HEAD: u32 = 0x10;
/// `USBDEVFS_RESET = _IO('U', 20)` — re-enumerates the USB device,
/// equivalent to a software unplug-replug. Resets at the USB layer
/// *below* SCSI, which is what's needed when the USB Mass Storage
/// interface itself wedges (the wedge mode `SG_SCSI_RESET` can't
/// recover, since SCSI commands never make it through the broken USB
/// link to the device). 30-line `usbreset.c` everyone passes around
/// uses this same ioctl.
const USBDEVFS_RESET: u32 = 0x5514;
#[repr(C)]
#[allow(non_camel_case_types)]
struct sg_io_hdr {
@@ -49,6 +64,7 @@ const _: () = assert!(std::mem::size_of::<sg_io_hdr>() == 64);
pub struct SgIoTransport {
fd: i32,
device_path: std::path::PathBuf,
}
impl SgIoTransport {
@@ -61,13 +77,16 @@ impl SgIoTransport {
let fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
libc::O_RDWR | libc::O_NONBLOCK,
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if fd < 0 {
return Self::open_error(&device);
}
Ok(SgIoTransport { fd })
Ok(SgIoTransport {
fd,
device_path: device,
})
}
/// Reset the drive to a known good state — equivalent to unplug/replug.
@@ -116,7 +135,7 @@ impl SgIoTransport {
let probe_fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
libc::O_RDWR | libc::O_NONBLOCK,
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if probe_fd >= 0 {
@@ -130,7 +149,7 @@ impl SgIoTransport {
let fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
libc::O_RDWR | libc::O_NONBLOCK,
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if fd < 0 {
@@ -162,6 +181,106 @@ impl SgIoTransport {
Ok(())
}
/// USB-layer reset. Resolves the sg device → underlying USB device
/// (`/dev/bus/usb/BBB/DDD`) and issues `USBDEVFS_RESET`, the same
/// ioctl `usbreset.c` uses. Software equivalent of unplug-replug.
///
/// Returns `DeviceNotFound` if the sg device isn't USB-attached
/// (SATA/PERC etc.) so callers can detect the fall-through case and
/// know not to retry — USB reset is meaningless for non-USB drives.
/// Returns `DeviceResetFailed` for actual ioctl failures.
///
/// Step-by-step:
/// 1. `/dev/sg4` → device name `sg4`
/// 2. Canonicalize `/sys/class/scsi_generic/sg4/device` to follow
/// the kernel's symlink chain into `/sys/devices/pci…/usb1/1-2/…`
/// 3. Walk parents until we find a directory that has both
/// `busnum` and `devnum` files — that's the USB device node
/// 4. Read `busnum` + `devnum`, format `/dev/bus/usb/{busnum:03}/{devnum:03}`
/// 5. open(O_WRONLY), ioctl(USBDEVFS_RESET), close
pub fn usb_reset(device: &Path) -> Result<()> {
let usb_path = Self::resolve_usb_device(device)?;
let c_path = Self::to_c_path(&usb_path);
let fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
libc::O_WRONLY | libc::O_CLOEXEC,
)
};
if fd < 0 {
return Err(Error::DeviceResetFailed {
path: usb_path.display().to_string(),
});
}
// USBDEVFS_RESET — kernel does its own bounded wait here (the
// USB stack waits for the device to come back, typically ≤1 s).
// Unlike SG_SCSI_RESET this rarely hangs because the kernel USB
// layer has its own timeouts on the device-side handshake.
let r = unsafe { libc::ioctl(fd, USBDEVFS_RESET as _) };
unsafe { libc::close(fd) };
if r < 0 {
Err(Error::DeviceResetFailed {
path: usb_path.display().to_string(),
})
} else {
Ok(())
}
}
/// Resolve `/dev/sgN` → `/dev/bus/usb/BBB/DDD` for USB-attached SCSI
/// devices. Returns `DeviceNotFound` (not a reset failure) when the
/// sg device isn't USB-attached, so callers can distinguish "this
/// drive isn't a USB drive" from "USB reset attempted but failed".
fn resolve_usb_device(device: &Path) -> Result<std::path::PathBuf> {
let dev_name =
device
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| Error::DeviceNotFound {
path: device.display().to_string(),
})?;
let sysfs_link = format!("/sys/class/scsi_generic/{dev_name}/device");
let canonical = std::fs::canonicalize(&sysfs_link).map_err(|_| Error::DeviceNotFound {
path: device.display().to_string(),
})?;
// Walk up the parent chain looking for a directory that
// contains both `busnum` and `devnum`. That marks the USB
// device entry in sysfs (e.g. /sys/devices/.../usb1/1-2/).
let mut cur = canonical.as_path();
while let Some(parent) = cur.parent() {
let busnum_p = parent.join("busnum");
let devnum_p = parent.join("devnum");
if busnum_p.exists() && devnum_p.exists() {
let busnum: u32 = std::fs::read_to_string(&busnum_p)
.ok()
.and_then(|s| s.trim().parse().ok())
.ok_or_else(|| Error::DeviceNotFound {
path: device.display().to_string(),
})?;
let devnum: u32 = std::fs::read_to_string(&devnum_p)
.ok()
.and_then(|s| s.trim().parse().ok())
.ok_or_else(|| Error::DeviceNotFound {
path: device.display().to_string(),
})?;
return Ok(std::path::PathBuf::from(format!(
"/dev/bus/usb/{busnum:03}/{devnum:03}"
)));
}
cur = parent;
}
// No USB ancestor found — SATA / RAID / non-USB SCSI device.
Err(Error::DeviceNotFound {
path: device.display().to_string(),
})
}
fn open_error<T>(device: &Path) -> Result<T> {
let err = std::io::Error::last_os_error();
Err(if err.kind() == std::io::ErrorKind::PermissionDenied {
@@ -179,7 +298,8 @@ impl SgIoTransport {
}
/// Send a raw SCSI command on an fd. Used by reset() before the
/// transport is constructed.
/// transport is constructed. Uses synchronous SG_IO — fine for
/// short commands (TUR, PREVENT MEDIUM REMOVAL, START/STOP).
fn raw_command(fd: i32, cdb: &[u8], timeout_ms: u32) -> std::result::Result<(), ()> {
let mut sense = [0u8; 32];
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
@@ -239,13 +359,30 @@ impl SgIoTransport {
impl Drop for SgIoTransport {
fn drop(&mut self) {
// Unlock tray before closing — don't leave it locked
let _ = Self::raw_command(self.fd, &[0x1E, 0, 0, 0, 0, 0], 3_000);
unsafe { libc::close(self.fd) };
if self.fd >= 0 {
// Unlock tray before closing — don't leave it locked
let _ = Self::raw_command(self.fd, &[0x1E, 0, 0, 0, 0, 0], 3_000);
unsafe { libc::close(self.fd) };
}
}
}
impl ScsiTransport for SgIoTransport {
/// Execute a SCSI command with an enforceable timeout.
///
/// Uses the sg driver's async write/poll/read interface:
/// 1. write() submits the command — returns immediately
/// 2. poll() waits for completion — respects our timeout exactly
/// 3. read() retrieves the result — copies data to caller's buffer
///
/// If poll() times out, the pending command is abandoned: the old fd
/// is closed in a background thread (may block while kernel finishes
/// the USB transfer) and a fresh fd is opened. The caller sees a
/// normal SCSI error and can retry.
///
/// Without SG_FLAG_DIRECT_IO, the kernel uses internal buffers for
/// DMA and copies to userspace during read(). On timeout (no read),
/// the caller's buffer is untouched — safe to return immediately.
fn execute(
&mut self,
cdb: &[u8],
@@ -253,6 +390,12 @@ impl ScsiTransport for SgIoTransport {
data: &mut [u8],
timeout_ms: u32,
) -> Result<ScsiResult> {
if self.fd < 0 {
return Err(Error::DeviceNotFound {
path: self.device_path.display().to_string(),
});
}
let mut sense = [0u8; 32];
let dxfer_direction = match direction {
@@ -281,18 +424,84 @@ impl ScsiTransport for SgIoTransport {
hdr.cmdp = cdb.as_ptr();
hdr.sbp = sense.as_mut_ptr();
hdr.timeout = timeout_ms;
if dxfer_direction == SG_DXFER_FROM_DEV
&& data.len() >= 4096
&& (data.as_ptr() as usize) % 4096 == 0
{
hdr.flags = SG_FLAG_DIRECT_IO | SG_FLAG_Q_AT_HEAD;
} else {
hdr.flags = SG_FLAG_Q_AT_HEAD;
hdr.flags = SG_FLAG_Q_AT_HEAD;
// Submit command asynchronously via write()
let hdr_size = std::mem::size_of::<sg_io_hdr>();
let wr = unsafe {
libc::write(
self.fd,
&hdr as *const sg_io_hdr as *const libc::c_void,
hdr_size,
)
};
if wr < 0 {
return Err(Error::IoError {
source: std::io::Error::last_os_error(),
});
}
let ret = unsafe { libc::ioctl(self.fd, SG_IO as _, &mut hdr as *mut sg_io_hdr) };
// Wait for completion with enforceable timeout.
// Retry on EINTR (signal interrupted poll) with remaining time.
let deadline =
std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms as u64);
let pr = loop {
let remaining = deadline
.saturating_duration_since(std::time::Instant::now())
.as_millis() as i32;
if remaining <= 0 {
break 0; // expired
}
let mut pfd = libc::pollfd {
fd: self.fd,
events: libc::POLLIN,
revents: 0,
};
let ret = unsafe { libc::poll(&mut pfd, 1, remaining) };
if ret >= 0 || std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
{
break ret;
}
};
if ret < 0 {
if pr <= 0 {
// Timeout (0) or fatal poll error (-1).
// Command is still pending in the kernel. Abandon this fd and
// open a fresh one. The old fd is closed in a background thread
// because close() blocks until the kernel completes/aborts the
// pending command.
let old_fd = self.fd;
self.fd = -1;
std::thread::spawn(move || {
unsafe { libc::close(old_fd) };
});
let c_path = Self::to_c_path(&self.device_path);
let new_fd = unsafe {
libc::open(
c_path.as_ptr() as *const libc::c_char,
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
self.fd = if new_fd >= 0 { new_fd } else { -1 };
return Err(Error::ScsiError {
opcode: cdb[0],
status: 0xFF,
sense_key: 0,
});
}
// Read response — copies data from kernel buffer to caller's buffer
let rd = unsafe {
libc::read(
self.fd,
&mut hdr as *mut sg_io_hdr as *mut libc::c_void,
hdr_size,
)
};
if rd < 0 {
return Err(Error::IoError {
source: std::io::Error::last_os_error(),
});
@@ -301,8 +510,15 @@ impl ScsiTransport for SgIoTransport {
let bytes_transferred = (data.len() as i32).saturating_sub(hdr.resid).max(0) as usize;
if hdr.status != 0 {
let sense_key = if hdr.sb_len_wr > 2 {
sense[2] & 0x0F
let sense_key = if hdr.sb_len_wr >= 3 {
let response_code = sense[0] & 0x7F;
if response_code == 0x72 || response_code == 0x73 {
// Descriptor format sense: sense key at byte 1
sense[1] & 0x0F
} else {
// Fixed format sense (0x70/0x71): sense key at byte 2
sense[2] & 0x0F
}
} else {
0
};
@@ -320,3 +536,186 @@ impl ScsiTransport for SgIoTransport {
})
}
}
// ── Lightweight discovery + presence (Linux) ────────────────────────────────
//
// `list_drives` walks `/sys/class/scsi_generic/`, filters to type-5 (CD/DVD/BD),
// and runs one INQUIRY each for vendor/model/firmware. Falls back to a
// `/dev/sg0..15` probe when sysfs is unreadable (minimal containers).
//
// `drive_has_disc` issues a single TEST UNIT READY. On the wedge signature
// (kernel returns status `0xff` with no sense) it escalates: SCSI bus reset
// → if still wedged → USB device reset (`USBDEVFS_RESET`) → retry TUR.
// Callers never see the escalation; if it fails too, surface
// `DeviceResetFailed` so the caller can back off.
/// SCSI peripheral type 5 = "CD-ROM device" (covers DVD, BD-ROM, BD-RE, etc.).
/// Stored in `/sys/class/scsi_generic/sgN/device/type` as ASCII decimal.
const SCSI_TYPE_OPTICAL: &str = "5";
/// SCSI sense key 2 = "NOT READY". Sub-codes distinguish "medium not present"
/// (no disc) from other not-ready states (loading, etc.); for poll-loop
/// purposes any sense-key 2 means "no disc to act on".
const SENSE_KEY_NOT_READY: u8 = 2;
/// Maximum sg index probed in the fallback path when sysfs is unavailable.
/// Linux assigns `/dev/sgN` sequentially per host adapter; 16 covers any
/// realistic homelab (typical PERC + USB optical = ≤8 nodes).
const SG_FALLBACK_MAX: u8 = 16;
/// SCSI INQUIRY response field offsets (SPC-4, 6-byte standard CDB
/// returning 96 bytes). Used to populate `DriveInfo` fields without
/// magic-number arithmetic at the call site.
const INQUIRY_VENDOR_OFFSET: usize = 8;
const INQUIRY_VENDOR_LEN: usize = 8;
const INQUIRY_MODEL_OFFSET: usize = 16;
const INQUIRY_MODEL_LEN: usize = 16;
const INQUIRY_FIRMWARE_OFFSET: usize = 32;
const INQUIRY_FIRMWARE_LEN: usize = 4;
pub(super) fn list_drives() -> Vec<super::DriveInfo> {
let mut out = Vec::new();
let names = enumerate_sg_names();
for name in names {
let path = format!("/dev/{name}");
if !std::path::Path::new(&path).exists() {
continue;
}
// INQUIRY-only probe — open transport, run INQUIRY, drop. No
// identify, no init, no firmware reset preamble's secondary
// commands beyond what `SgIoTransport::open` already does (one
// SCSI bus reset on the kernel SG fd, ~2 s).
let mut transport = match SgIoTransport::open(std::path::Path::new(&path)) {
Ok(t) => t,
Err(_) => continue,
};
let info = match super::inquiry(&mut transport) {
Ok(r) => super::DriveInfo {
path: path.clone(),
vendor: r.vendor_id,
model: r.model,
firmware: r.firmware,
},
Err(_) => super::DriveInfo {
path: path.clone(),
vendor: String::new(),
model: String::new(),
firmware: String::new(),
},
};
out.push(info);
}
out
}
/// Enumerate `sg*` names via `/sys/class/scsi_generic/`, filtered to
/// SCSI peripheral type 5 (optical). Falls back to a `sg0..15` probe
/// when sysfs is unreadable. Returns names sorted lexically so caller
/// iteration is deterministic.
fn enumerate_sg_names() -> Vec<String> {
let mut names = Vec::new();
if let Ok(entries) = std::fs::read_dir("/sys/class/scsi_generic") {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if !name.starts_with("sg") {
continue;
}
let type_path = format!("/sys/class/scsi_generic/{name}/device/type");
match std::fs::read_to_string(&type_path) {
Ok(s) if s.trim() == SCSI_TYPE_OPTICAL => names.push(name),
Ok(_) => {} // not optical
Err(_) => names.push(name), // sysfs unreadable — let INQUIRY decide
}
}
} else {
// Sysfs missing — fall back to a brute-force probe. The INQUIRY
// step in `list_drives` filters non-optical responses naturally.
for i in 0..SG_FALLBACK_MAX {
let name = format!("sg{i}");
if std::path::Path::new(&format!("/dev/{name}")).exists() {
names.push(name);
}
}
}
names.sort();
names
}
pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
match probe_tur(path) {
Ok(present) => Ok(present),
Err(e) if is_wedge_signature(&e) => recover_then_probe(path, e),
Err(e) => Err(e),
}
}
/// Single TEST UNIT READY — the cheapest way to ask "is there a disc?".
/// Returns `Ok(true)` on a sense-clean OK, `Ok(false)` on sense-key 2
/// ("not ready, medium not present"), and `Err` for any other failure
/// (the wedge case lands here too — caller's escalation handles it).
fn probe_tur(path: &Path) -> Result<bool> {
let mut transport = SgIoTransport::open(path)?;
let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0];
let mut buf = [0u8; 0];
match transport.execute(
&cdb,
crate::scsi::DataDirection::None,
&mut buf,
crate::scsi::TUR_TIMEOUT_MS,
) {
Ok(_) => Ok(true),
Err(Error::ScsiError {
sense_key: SENSE_KEY_NOT_READY,
..
}) => Ok(false),
Err(e) => Err(e),
}
}
/// Two-stage wedge recovery: SCSI reset → USB reset → retry probe.
/// Caller has already classified the original error as a wedge.
fn recover_then_probe(path: &Path, original: Error) -> Result<bool> {
// Stage 1: SCSI bus reset. Bounded by `DEFAULT_RESET_TIMEOUT_SECS`.
let _ = super::reset(path);
if let Ok(present) = probe_tur(path) {
return Ok(present);
}
// Stage 2: USB-layer re-enumeration (USBDEVFS_RESET). Software
// equivalent of unplug-replug; the only thing that recovers a
// kernel-level USB Mass Storage wedge.
if super::usb_reset(path).is_ok() {
std::thread::sleep(std::time::Duration::from_secs(USB_RESET_SETTLE_SECS));
if let Ok(present) = probe_tur(path) {
return Ok(present);
}
}
// Both stages exhausted — surface the original error so the caller
// can choose to back off / mark this drive stay-clear.
Err(original)
}
/// Wedge signature: `Error::ScsiError` with INQUIRY opcode (0x12) and
/// status byte 0xFF. 0xFF isn't a valid SCSI status — the kernel synthesises
/// it when the device gives no answer, which is the real-world signature
/// of a USB Mass Storage layer wedge.
fn is_wedge_signature(err: &Error) -> bool {
matches!(
err,
Error::ScsiError {
opcode: crate::scsi::SCSI_INQUIRY,
status: WEDGE_STATUS_BYTE,
..
}
)
}
/// Synthesised SCSI status byte returned by the Linux SG driver when
/// the kernel got no useful response from the device — the wedge
/// signature. Real SCSI statuses are GOOD (0x00), CHECK_CONDITION (0x02),
/// BUSY (0x08), etc.; 0xFF is reserved/invalid in the spec.
const WEDGE_STATUS_BYTE: u8 = 0xFF;
/// Settle time after `USBDEVFS_RESET` returns. The kernel re-enumerates
/// the device over ~1-2 s; sleeping briefly avoids racing the next
/// `Drive::open` against an interim sysfs-vanished state.
const USB_RESET_SETTLE_SECS: u64 = 2;
+283 -27
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.
@@ -203,8 +212,9 @@ impl MacScsiTransport {
unsafe { IOObjectRelease(service) };
if kr != K_IO_RETURN_SUCCESS || plugin.is_null() {
return Err(Error::DeviceNotFound {
path: format!("{}: IOKit plugin creation failed (0x{:08x})", dev_str, kr),
return Err(Error::IoKitPluginFailed {
path: dev_str.to_string(),
kr: kr as u32,
});
}
@@ -222,8 +232,8 @@ impl MacScsiTransport {
com_release(plugin);
if hr != 0 || device_iface.is_null() {
return Err(Error::DeviceNotFound {
path: format!("{}: SCSITaskDeviceInterface not available", dev_str),
return Err(Error::ScsiInterfaceUnavailable {
path: dev_str.to_string(),
});
}
@@ -235,11 +245,12 @@ impl MacScsiTransport {
};
if kr != K_IO_RETURN_SUCCESS {
com_release(device_iface);
return Err(Error::DevicePermission {
path: format!(
"{}: exclusive access denied (0x{:08x}). Try: diskutil unmountDisk {}",
dev_str, kr, dev_str
),
// No "Try: diskutil unmountDisk" hint — that's the CLI's job.
// The typed variant carries device path + IOReturn so the
// caller can render the right message in the right language.
return Err(Error::DeviceLocked {
path: dev_str.to_string(),
kr: kr as u32,
});
}
@@ -266,6 +277,246 @@ impl MacScsiTransport {
std::thread::sleep(std::time::Duration::from_secs(2));
Ok(())
}
/// USB-layer reset on macOS via `IOUSBDeviceInterface::ResetDevice`.
///
/// Mirrors the Linux `USBDEVFS_RESET` path: walk from the BSD-named
/// SCSI service up the IORegistry plane to the parent `IOUSBDevice`,
/// query its `IOUSBDeviceInterface`, call `ResetDevice()`. Software
/// equivalent of unplug-replug — the only thing that recovers a
/// kernel-level USB Mass Storage wedge on macOS.
///
/// Returns `DeviceNotFound` when the device isn't USB-attached
/// (Thunderbolt/SATA/internal SuperDrive over PCIe — they don't
/// have an `IOUSBDevice` ancestor) so the caller's escalation can
/// fall through cleanly. `DeviceResetFailed` on actual reset
/// failures.
pub fn usb_reset(device: &Path) -> Result<()> {
let bsd_name =
device
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| Error::DeviceNotFound {
path: device.display().to_string(),
})?;
let service = find_scsi_service(bsd_name)?;
let usb_service = walk_to_usb_device(service);
unsafe { IOObjectRelease(service) };
let usb_service = usb_service.ok_or_else(|| Error::DeviceNotFound {
path: device.display().to_string(),
})?;
// Get IOUSBDeviceInterface from the USB device service.
let mut plugin: ComRef = std::ptr::null_mut();
let mut score: i32 = 0;
let kr = unsafe {
IOCreatePlugInInterfaceForService(
usb_service,
&K_IO_USB_DEVICE_USER_CLIENT_TYPE_ID,
&K_IO_CFPLUGIN_INTERFACE_ID,
&mut plugin,
&mut score,
)
};
unsafe { IOObjectRelease(usb_service) };
if kr != K_IO_RETURN_SUCCESS || plugin.is_null() {
return Err(Error::DeviceResetFailed {
path: device.display().to_string(),
});
}
// QueryInterface for IOUSBDeviceInterface.
let mut device_iface: ComRef = std::ptr::null_mut();
let hr = unsafe {
type QiFn = unsafe extern "C" fn(ComRef, *const [u8; 16], *mut ComRef) -> i32;
let qi: QiFn = vtable_fn(plugin, 1);
qi(plugin, &K_IO_USB_DEVICE_INTERFACE_ID, &mut device_iface)
};
com_release(plugin);
if hr != 0 || device_iface.is_null() {
return Err(Error::DeviceResetFailed {
path: device.display().to_string(),
});
}
// Call ResetDevice() — vtable index 11 in IOUSBDeviceInterface.
// Verified against IOUSBLib.h headers (Apple OSS).
let kr = unsafe {
type ResetFn = unsafe extern "C" fn(ComRef) -> IOReturn;
let f: ResetFn = vtable_fn(device_iface, K_IO_USB_DEVICE_RESET_VTABLE_INDEX);
f(device_iface)
};
com_release(device_iface);
if kr != K_IO_RETURN_SUCCESS {
Err(Error::DeviceResetFailed {
path: device.display().to_string(),
})
} else {
Ok(())
}
}
}
/// `kIOUSBDeviceUserClientTypeID` — IOKit plugin type for accessing a
/// USB device through user-space (the gateway to `IOUSBDeviceInterface`).
const K_IO_USB_DEVICE_USER_CLIENT_TYPE_ID: [u8; 16] = [
0x9D, 0xC7, 0xB7, 0x80, 0x9E, 0xC0, 0x11, 0xD4, 0xA5, 0x4F, 0x00, 0x0A, 0x27, 0x05, 0x28, 0x61,
];
/// `kIOUSBDeviceInterfaceID` — `IOUSBDeviceInterface` (revision 0).
/// Sufficient for `ResetDevice()` which has been at vtable index 11
/// since the original interface revision.
const K_IO_USB_DEVICE_INTERFACE_ID: [u8; 16] = [
0x5C, 0x81, 0x87, 0xD0, 0x9E, 0xF3, 0x11, 0xD4, 0x8B, 0x45, 0x00, 0x0A, 0x27, 0x05, 0x28, 0x61,
];
/// Vtable index of `IOUSBDeviceInterface::ResetDevice`. Per IOUSBLib.h:
/// the interface inherits from IOCFPlugInInterface which occupies slots
/// 0..2 (QueryInterface, AddRef, Release), then IOUSBDeviceInterface
/// methods start at slot 3. ResetDevice is the 9th IOUSBDevice-specific
/// method → slot 3 + 8 = 11.
const K_IO_USB_DEVICE_RESET_VTABLE_INDEX: usize = 11;
/// Walk up the IORegistry plane from a SCSI peripheral service to the
/// parent `IOUSBDevice` (if any). Mirrors the Linux sysfs walk in
/// `linux::SgIoTransport::resolve_usb_device`. Returns the IOService
/// for the USB device (caller owns the reference; release with
/// `IOObjectRelease`), or `None` for non-USB-attached drives.
fn walk_to_usb_device(start: IOObject) -> Option<IOObject> {
let mut current = start;
// Retain the start so we can release uniformly each loop iteration.
unsafe {
let kr = IOObjectRetain(current);
if kr != K_IO_RETURN_SUCCESS {
return None;
}
}
for _ in 0..K_USB_PARENT_WALK_LIMIT {
if unsafe { IOObjectConformsTo(current, c"IOUSBDevice".as_ptr() as *const u8) } != 0 {
return Some(current);
}
let mut parent: IOObject = 0;
let kr = unsafe {
IORegistryEntryGetParentEntry(current, c"IOService".as_ptr() as *const u8, &mut parent)
};
unsafe { IOObjectRelease(current) };
if kr != K_IO_RETURN_SUCCESS || parent == 0 {
return None;
}
current = parent;
}
unsafe { IOObjectRelease(current) };
None
}
/// Maximum IORegistry parent-chain depth searched for a USB ancestor.
/// Real chains for USB-attached optical drives are 6-10 entries deep
/// (IOMedia → BlockStorageDriver → SCSIPeripheralDeviceNub →
/// SCSIProtocolEmulator → IOUSBInterface → IOUSBDevice → ...). 32 is
/// generous; if we don't find it by then, the device isn't USB.
const K_USB_PARENT_WALK_LIMIT: u32 = 32;
/// Enumerate optical drives on macOS. Mirrors `drive::macos::find_drives`
/// (which iterates `/dev/disk0..15` + INQUIRY + filters peripheral
/// type 5). Same logic, exposed through the new `DriveInfo` shape so
/// callers — `list_drives()` in `scsi::mod` — never reach into
/// `crate::drive::macos`.
pub(super) fn list_drives() -> Vec<super::DriveInfo> {
let mut out = Vec::new();
for i in 0..K_DEV_DISK_MAX {
let path = format!("/dev/disk{i}");
if !std::path::Path::new(&path).exists() {
continue;
}
let mut transport = match MacScsiTransport::open(std::path::Path::new(&path)) {
Ok(t) => t,
Err(_) => continue,
};
let inquiry = match super::inquiry(&mut transport) {
Ok(r) => r,
Err(_) => continue,
};
// SCSI peripheral type field is the lower 5 bits of byte 0.
if inquiry.raw.is_empty()
|| (inquiry.raw[K_INQUIRY_TYPE_BYTE] & K_INQUIRY_TYPE_MASK) != K_SCSI_TYPE_OPTICAL
{
continue;
}
out.push(super::DriveInfo {
path,
vendor: inquiry.vendor_id,
model: inquiry.model,
firmware: inquiry.firmware,
});
}
out
}
/// Maximum BSD disk index probed during enumeration. macOS assigns
/// `/dev/diskN` sequentially per attached storage device; 16 covers
/// any realistic homelab.
const K_DEV_DISK_MAX: u8 = 16;
/// SCSI INQUIRY response: peripheral device type lives in byte 0,
/// lower 5 bits.
const K_INQUIRY_TYPE_BYTE: usize = 0;
const K_INQUIRY_TYPE_MASK: u8 = 0x1F;
/// SCSI peripheral type 5 = "CD-ROM device" (covers DVD, BD-ROM, BD-RE).
const K_SCSI_TYPE_OPTICAL: u8 = 0x05;
/// TEST UNIT READY probe on macOS. Same shape as the Linux impl —
/// open transport, run TUR, classify response. The macOS path doesn't
/// surface the Linux `0xff`-status wedge pattern (IOKit returns its
/// own error codes), so wedge-detection here is sense-key based: a
/// transport-level error during TUR escalates to SCSI reset → USB
/// reset. Most macOS drives auto-recover at the SCSI-reset stage.
pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
match probe_tur(path) {
Ok(present) => Ok(present),
Err(Error::ScsiError { sense_key, .. }) if sense_key == K_SENSE_KEY_NOT_READY => Ok(false),
Err(_) => recover_then_probe(path),
}
}
const K_SENSE_KEY_NOT_READY: u8 = 2;
fn probe_tur(path: &Path) -> Result<bool> {
let mut transport = MacScsiTransport::open(path)?;
let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0];
let mut buf = [0u8; 0];
transport
.execute(
&cdb,
crate::scsi::DataDirection::None,
&mut buf,
crate::scsi::TUR_TIMEOUT_MS,
)
.map(|_| true)
}
fn recover_then_probe(path: &Path) -> Result<bool> {
let _ = super::reset(path);
if let Ok(present) = probe_tur(path) {
return Ok(present);
}
if super::usb_reset(path).is_ok() {
std::thread::sleep(std::time::Duration::from_secs(K_USB_RESET_SETTLE_SECS));
if let Ok(present) = probe_tur(path) {
return Ok(present);
}
}
Err(Error::DeviceResetFailed {
path: path.display().to_string(),
})
}
const K_USB_RESET_SETTLE_SECS: u64 = 2;
unsafe extern "C" {
fn IOObjectRetain(object: IOObject) -> IOReturn;
}
impl Drop for MacScsiTransport {
@@ -395,13 +646,23 @@ impl ScsiTransport for MacScsiTransport {
/// BSD name → IOKit service for the SCSI device.
///
/// Walk: IOMedia (BSD name match) → parent chain → SCSIPeripheralDeviceNub.
///
/// All failure paths surface as `Error::DeviceNotFound { path: bsd_name }` —
/// the four internal stages (IOMasterPort / IOBSDNameMatching / IOMedia
/// lookup / walk_to_authoring_device) collapse into one observable error
/// because none of them are user-actionable individually. Pre-0.13 each
/// stage stuffed an English description into `path:` ("…IOMasterPort
/// failed", "…SCSITaskDeviceInterface not available", etc.) which broke
/// the library's "no English text" rule.
fn find_scsi_service(bsd_name: &str) -> Result<IOObject> {
let not_found = || Error::DeviceNotFound {
path: bsd_name.to_string(),
};
let mut master: MachPort = 0;
let kr = unsafe { IOMasterPort(0, &mut master) };
if kr != K_IO_RETURN_SUCCESS {
return Err(Error::DeviceNotFound {
path: format!("{}: IOMasterPort failed", bsd_name),
});
return Err(not_found());
}
// IOBSDNameMatching creates a dictionary matching { "BSD Name" = bsd_name }
@@ -409,17 +670,13 @@ fn find_scsi_service(bsd_name: &str) -> Result<IOObject> {
bsd_c.push(0);
let matching = unsafe { IOBSDNameMatching(master, 0, bsd_c.as_ptr()) };
if matching.is_null() {
return Err(Error::DeviceNotFound {
path: format!("{}: IOBSDNameMatching failed", bsd_name),
});
return Err(not_found());
}
// Find the single IOMedia service (consumes the matching dict)
let media = unsafe { IOServiceGetMatchingService(master, matching) };
if media == 0 {
return Err(Error::DeviceNotFound {
path: format!("{}: no IOMedia found", bsd_name),
});
return Err(not_found());
}
// Walk up the IOService plane to find the authoring device.
@@ -432,9 +689,7 @@ fn find_scsi_service(bsd_name: &str) -> Result<IOObject> {
let service = walk_to_authoring_device(media);
unsafe { IOObjectRelease(media) };
service.ok_or_else(|| Error::DeviceNotFound {
path: format!("{}: no SCSI authoring device in IORegistry", bsd_name),
})
service.ok_or_else(not_found)
}
/// Walk up the IOService plane from an IOMedia to the SCSI authoring device.
@@ -455,8 +710,9 @@ fn walk_to_authoring_device(start: IOObject) -> Option<IOObject> {
// Walk up to 10 levels (more than enough)
for _ in 0..10 {
let mut parent: IOObject = 0;
let kr =
unsafe { IORegistryEntryGetParentEntry(current, b"IOService\0".as_ptr(), &mut parent) };
let kr = unsafe {
IORegistryEntryGetParentEntry(current, c"IOService".as_ptr() as *const u8, &mut parent)
};
if current != start {
unsafe { IOObjectRelease(current) };
+285 -5
View File
@@ -18,6 +18,10 @@ use std::path::Path;
// ── SCSI opcodes (SPC-4, MMC-6) ────────────────────────────────────────────
/// SPC-4 TEST UNIT READY — six-byte CDB, no data transfer. Used by
/// [`drive_has_disc`] as the cheapest "is the drive responsive / does
/// it have media?" probe.
pub const SCSI_TEST_UNIT_READY: u8 = 0x00;
pub const SCSI_INQUIRY: u8 = 0x12;
pub const SCSI_READ_CAPACITY: u8 = 0x25;
pub const SCSI_READ_10: u8 = 0x28;
@@ -33,6 +37,12 @@ pub const SCSI_READ_DISC_STRUCTURE: u8 = 0xAD;
/// AACS key class for REPORT KEY / SEND KEY commands.
pub const AACS_KEY_CLASS: u8 = 0x02;
/// Timeout for TEST UNIT READY probes used by [`drive_has_disc`].
/// TUR is the cheapest SCSI op (no data transfer); 5 s is generous
/// for any healthy bus and short enough that a hung device can't stall
/// a poll-loop tick.
pub(crate) const TUR_TIMEOUT_MS: u32 = 5_000;
// ── Types ───────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -82,15 +92,88 @@ pub fn open(device: &Path) -> Result<Box<dyn ScsiTransport>> {
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
Err(Error::DeviceNotFound {
path: format!("{}: unsupported platform", device.display()),
Err(Error::UnsupportedPlatform {
target: std::env::consts::OS.to_string(),
})
}
}
/// Reset a SCSI device to a known good state. Platform-specific.
/// On Linux: open/close fd cycle + TUR + SG_SCSI_RESET escalation.
pub fn reset(device: &Path) -> Result<()> {
/// Default upper bound on `scsi::reset()`. The platform-specific reset
/// sequence does ~15 s of bounded sleeps + ioctls in the happy path, so
/// 30 s is roughly 2× the worst-case happy time — long enough that a
/// healthy-but-slow drive isn't false-positively timed out, short enough
/// that a kernel-wedged ioctl doesn't take down the caller's poll loop
/// for minutes.
pub(crate) const DEFAULT_RESET_TIMEOUT_SECS: u64 = 30;
/// Reset a SCSI device to a known good state, with a hard wallclock
/// bound (`DEFAULT_RESET_TIMEOUT_SECS`). On Linux: open/close fd cycle +
/// TUR + SG_SCSI_RESET escalation.
///
/// **Why the timeout matters.** SG_SCSI_RESET is an ioctl that can block
/// indefinitely on a kernel-wedged USB target (the kernel waits for the
/// SCSI subsystem to ack the reset, which never comes from a dead-bus
/// device). Without an outer bound, the call hangs the caller's thread
/// forever — observed in production on a wedged BU40N where the autorip
/// poll loop sat in the ioctl for 60+ s. The bounded version returns
/// `DeviceResetFailed` after `DEFAULT_RESET_TIMEOUT_SECS`; the inner
/// thread keeps running until the kernel eventually unblocks it (we
/// can't cancel a Linux ioctl from userspace), so this leaks one OS
/// thread per wedge — acceptable cost for a daemon that recovers
/// instead of hanging.
///
/// `pub(crate)` — outside callers use the higher-level `drive_has_disc`
/// (which folds in recovery escalation) or `Drive::reset` (instance-level).
/// Direct primitive exposure removed in 0.13.2 to enforce the
/// architectural rule that no consumer crate issues SCSI commands.
pub(crate) fn reset(device: &Path) -> Result<()> {
reset_with_timeout(
device,
std::time::Duration::from_secs(DEFAULT_RESET_TIMEOUT_SECS),
)
}
/// Reset with a caller-specified timeout. See [`reset`] for the full
/// rationale on why an outer wallclock bound is required.
pub(crate) fn reset_with_timeout(device: &Path, timeout: std::time::Duration) -> Result<()> {
let device_owned = device.to_path_buf();
let (tx, rx) = std::sync::mpsc::channel();
// Detach a worker thread for the actual reset. We never `join` it —
// if the kernel ioctl is wedged, the join would block forever, which
// is the very thing we're protecting the caller from. The thread will
// exit on its own when the kernel eventually returns from the ioctl
// (or never, if the device is permanently dead — process exit cleans
// it up).
std::thread::Builder::new()
.name("scsi-reset".into())
.spawn(move || {
let r = reset_blocking(&device_owned);
let _ = tx.send(r);
})
.map_err(|_| Error::DeviceResetFailed {
path: device.display().to_string(),
})?;
match rx.recv_timeout(timeout) {
Ok(result) => result,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(Error::DeviceResetFailed {
path: device.display().to_string(),
}),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
// The worker thread panicked before sending. Surface as a
// reset failure rather than a generic error.
Err(Error::DeviceResetFailed {
path: device.display().to_string(),
})
}
}
}
/// Inner reset — runs on the worker thread. May block indefinitely if
/// the kernel's SCSI subsystem is wedged. Callers must use the bounded
/// `reset()` wrapper above; this raw function isn't exposed.
fn reset_blocking(device: &Path) -> Result<()> {
#[cfg(target_os = "linux")]
{
linux::SgIoTransport::reset(device)
@@ -113,6 +196,203 @@ pub fn reset(device: &Path) -> Result<()> {
}
}
/// Default upper bound on `scsi::usb_reset()`. Kernel USB stack
/// typically completes a port reset in under a second; 5 s is generous
/// while still catching a hang in the driver.
pub(crate) const DEFAULT_USB_RESET_TIMEOUT_SECS: u64 = 5;
/// USB-layer reset for USB-attached SCSI devices.
///
/// `pub(crate)` — outside callers reach recovery through the
/// higher-level `drive_has_disc` (poll-loop entry point) which folds
/// in the SCSI→USB escalation internally. See module-level docs for
/// the architectural reasoning.
///
/// **When to call this.** `scsi::reset()` resets at the SCSI layer; if
/// the wedge is at the USB Mass Storage interface *below* SCSI (the
/// classic mode where INQUIRY returns status `0xFF` because the kernel
/// got nothing back), SCSI commands never reach the device and SCSI
/// reset is meaningless. USB reset re-enumerates the device at the USB
/// layer — software equivalent of unplug-replug.
///
/// **Linux**: resolves `/dev/sgN` → `/dev/bus/usb/BBB/DDD` via sysfs,
/// then `USBDEVFS_RESET` ioctl. Same mechanism as the well-known
/// `usbreset.c` snippet.
///
/// **macOS**, **Windows**: returns `DeviceNotFound` for now (stub).
/// Real impls tracked for 0.13.3.
///
/// **Returns** `DeviceNotFound` when the sg device isn't USB-attached
/// (SATA / RAID / NVMe-passthrough sg nodes) so callers can detect the
/// fall-through case and know not to retry — USB reset is meaningless
/// for those drives.
///
/// Wraps the platform call in a thread + `recv_timeout` for the same
/// reason as `reset()` — kernel ioctls can hang and we don't want the
/// caller's poll loop wedged on it.
pub(crate) fn usb_reset(device: &Path) -> Result<()> {
usb_reset_with_timeout(
device,
std::time::Duration::from_secs(DEFAULT_USB_RESET_TIMEOUT_SECS),
)
}
/// USB reset with a caller-specified timeout. See [`usb_reset`].
pub(crate) fn usb_reset_with_timeout(device: &Path, timeout: std::time::Duration) -> Result<()> {
let device_owned = device.to_path_buf();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("usb-reset".into())
.spawn(move || {
let r = usb_reset_blocking(&device_owned);
let _ = tx.send(r);
})
.map_err(|_| Error::DeviceResetFailed {
path: device.display().to_string(),
})?;
match rx.recv_timeout(timeout) {
Ok(result) => result,
Err(_) => Err(Error::DeviceResetFailed {
path: device.display().to_string(),
}),
}
}
fn usb_reset_blocking(device: &Path) -> Result<()> {
#[cfg(target_os = "linux")]
{
linux::SgIoTransport::usb_reset(device)
}
#[cfg(target_os = "macos")]
{
macos::MacScsiTransport::usb_reset(device)
}
#[cfg(target_os = "windows")]
{
windows::SptiTransport::usb_reset(device)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = device;
Err(Error::DeviceNotFound {
path: device.display().to_string(),
})
}
}
// ── Lightweight discovery + presence probes ─────────────────────────────────
//
// These two are the *only* hardware-touching APIs autorip + freemkv CLI use
// outside the rip path itself. They're intentionally cheap:
//
// - `list_drives()` is a one-shot enumeration: filesystem walk for sg/cdrom
// nodes, type-5 filter, single INQUIRY per candidate. No firmware, no
// reset-on-open, no init. Caller caches the result.
// - `drive_has_disc(path)` is a single TEST UNIT READY (six-byte CDB, no
// data transfer) with internal wedge-recovery escalation. Callers in a
// poll loop don't need any other primitive to detect "disc inserted /
// removed" — and they never see the SCSI-vs-USB-reset escalation.
//
// `Drive::open` + `drive.init()` + `Disc::scan` remain heavy and on-demand;
// callers only invoke them once they've decided to actually rip / verify a
// specific drive.
/// One optical drive on the system. Returned by [`list_drives`]. The
/// fields are populated from a single INQUIRY at enumeration time —
/// no firmware reset, no init.
#[derive(Debug, Clone)]
pub struct DriveInfo {
/// Platform device path: `/dev/sgN` (Linux), `/dev/diskN` (macOS),
/// `\\.\CdRomN` (Windows).
pub path: String,
/// SCSI INQUIRY vendor identifier (e.g. `"HL-DT-ST"`).
pub vendor: String,
/// SCSI INQUIRY product identifier (e.g. `"BD-RE BU40N"`).
pub model: String,
/// SCSI INQUIRY firmware revision (e.g. `"1.04"`).
pub firmware: String,
}
/// Enumerate optical drives present on the system.
///
/// **What it does**: per-platform sysfs / IOKit / setupapi walk for SCSI
/// devices, filtered to type 5 (CD/DVD/BD), with a single INQUIRY each
/// for vendor/model/firmware. No firmware reset, no `Drive::init`, no
/// disc scan. Suitable for an autorip-style poll loop or a CLI's
/// drive-list command.
///
/// **What it doesn't do**: probe disc presence (use [`drive_has_disc`]),
/// open a `Drive` for ripping (use [`crate::Drive::open`]), or load
/// drive profiles. Those are heavier operations callers invoke once
/// they've selected a drive.
pub fn list_drives() -> Vec<DriveInfo> {
#[cfg(target_os = "linux")]
{
linux::list_drives()
}
#[cfg(target_os = "macos")]
{
macos::list_drives()
}
#[cfg(target_os = "windows")]
{
windows::list_drives()
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
Vec::new()
}
}
/// True if the drive at `path` currently has a disc inserted.
///
/// Issues a single TEST UNIT READY (cheapest SCSI op, no data transfer).
/// Sense-key 2 ("not ready, medium not present") → `Ok(false)`; any
/// other ready/not-ready response → `Ok(true)` or interpreted ready
/// state. Suitable for poll-loop tick (~50 ms / drive on a healthy bus).
///
/// **Internal wedge recovery.** When the kernel's response indicates a
/// wedged target — the `0xff` status pattern that means "no answer from
/// the device" — this function transparently escalates: SCSI bus reset
/// → if still wedged → USB device reset (`USBDEVFS_RESET` on Linux) →
/// retry TUR. Callers never see wedge errors and never need to know
/// about the escalation; if even the recovery path can't get a response,
/// `Err(DeviceResetFailed)` surfaces. **No SCSI primitive is exposed to
/// outside crates** — autorip / freemkv CLI / bdemu use this single
/// function for the entire "is there a disc?" decision.
pub fn drive_has_disc(path: &Path) -> Result<bool> {
#[cfg(target_os = "linux")]
{
linux::drive_has_disc(path)
}
#[cfg(target_os = "macos")]
{
macos::drive_has_disc(path)
}
#[cfg(target_os = "windows")]
{
windows::drive_has_disc(path)
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = path;
Err(Error::UnsupportedPlatform {
target: std::env::consts::OS.to_string(),
})
}
}
// ── CDB builders (platform-agnostic) ────────────────────────────────────────
/// SCSI INQUIRY response.
+57 -1
View File
@@ -56,7 +56,7 @@ struct SptwbDirect {
// ── Windows FFI ────────────────────────────────────────────────────────────
extern "system" {
unsafe extern "system" {
fn CreateFileW(
lpFileName: *const u16,
dwDesiredAccess: u32,
@@ -185,6 +185,62 @@ impl SptiTransport {
std::thread::sleep(std::time::Duration::from_secs(2));
Ok(())
}
/// USB-layer reset on Windows. Returns `DeviceNotFound` —
/// **intentional**, not a stub.
///
/// Why: `reset()` above already issues `IOCTL_STORAGE_RESET_DEVICE`
/// which goes through `storport.sys`. On Windows that single IOCTL
/// covers both the SCSI layer **and** the USB Mass Storage layer
/// for storport-attached devices — functionally equivalent to
/// Linux's `SG_SCSI_RESET` + `USBDEVFS_RESET` combined into one
/// call. So Windows doesn't need a separate USB-layer step in the
/// recovery escalation; `drive_has_disc`'s second stage gracefully
/// falls through (it treats `DeviceNotFound` as "not applicable
/// for this platform / this drive type").
///
/// If a future case is found where storport's reset doesn't reach
/// the USB layer (e.g. raw Win USB devices that bypass storport),
/// this can become a real `IOCTL_USB_HUB_CYCLE_PORT_EX` impl.
pub fn usb_reset(device: &Path) -> Result<()> {
Err(Error::DeviceNotFound {
path: device.display().to_string(),
})
}
}
/// Enumerate optical drives on Windows via `find_drives()` (CdRom0..15
/// scan) and re-shape into `DriveInfo`. Existing implementation already
/// returns `(path, DriveId)`; mapped here to the public struct.
pub(super) fn list_drives() -> Vec<super::DriveInfo> {
crate::drive::windows::find_drives()
.into_iter()
.map(|(path, id)| super::DriveInfo {
path,
vendor: id.vendor_id.trim().to_string(),
model: id.product_id.trim().to_string(),
firmware: id.product_revision.trim().to_string(),
})
.collect()
}
/// TEST UNIT READY probe on Windows. Wedge recovery on this platform
/// goes through `IOCTL_STORAGE_RESET_DEVICE` (already in `reset()`);
/// USB-layer cycle-port is stubbed — see `usb_reset` above.
pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
let mut transport = SptiTransport::open(path)?;
let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0];
let mut buf = [0u8; 0];
match transport.execute(
&cdb,
crate::scsi::DataDirection::None,
&mut buf,
crate::scsi::TUR_TIMEOUT_MS,
) {
Ok(_) => Ok(true),
Err(Error::ScsiError { sense_key: 2, .. }) => Ok(false),
Err(e) => Err(e),
}
}
impl Drop for SptiTransport {
+22 -12
View File
@@ -10,13 +10,15 @@ use crate::error::Result;
pub trait SectorReader: Send {
/// Read `count` sectors starting at `lba` into `buf`.
/// `buf` must be at least `count * 2048` bytes.
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize>;
/// Fast single-attempt read — no recovery, short timeout.
/// Default implementation calls read_sectors (ISO files don't need fast mode).
fn read_sectors_fast(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
self.read_sectors(lba, count, buf)
}
/// `recovery`: true = full retry/reset loop (ripping), false = single attempt (verify).
/// File-backed readers ignore the flag.
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> Result<usize>;
/// Total capacity in sectors, if known.
fn capacity(&self) -> u32 {
@@ -37,10 +39,12 @@ impl FileSectorReader {
let len = file.metadata()?.len();
let sectors = len / 2048;
if sectors > u32::MAX as u64 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{path}: image too large, max ~8 TB"),
));
// ~8 TB hard cap (u32::MAX × 2048 bytes). Path lives in the
// typed Error variant — no English in the message.
return Err(crate::error::Error::IsoTooLarge {
path: path.to_string(),
}
.into());
}
let capacity = sectors as u32;
Ok(Self {
@@ -51,7 +55,13 @@ impl FileSectorReader {
}
impl SectorReader for FileSectorReader {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
use std::io::{Read, Seek, SeekFrom};
let offset = lba as u64 * 2048;
let bytes = count as usize * 2048;
+10 -8
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
@@ -879,6 +879,7 @@ impl BufferedSectorReader<'_> {
start_lba + offset,
batch,
&mut self.cache[buf_off..buf_off + batch as usize * 2048],
true,
)
.is_err()
{
@@ -902,7 +903,7 @@ impl BufferedSectorReader<'_> {
let bytes = batch as usize * 2048;
if self
.inner
.read_sectors(start + offset, batch, &mut tmp[..bytes])
.read_sectors(start + offset, batch, &mut tmp[..bytes], true)
.is_err()
{
break;
@@ -924,6 +925,7 @@ impl SectorReader for BufferedSectorReader<'_> {
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> std::result::Result<usize, crate::error::Error> {
if count == 1 {
// Check permanent prefetch cache first (HashMap)
@@ -939,7 +941,7 @@ impl SectorReader for BufferedSectorReader<'_> {
}
let block = self.batch;
self.cache.resize(block as usize * 2048, 0);
match self.inner.read_sectors(lba, block, &mut self.cache) {
match self.inner.read_sectors(lba, block, &mut self.cache, true) {
Ok(_) => {
self.cache_start = lba;
self.cache_sectors = block as u32;
@@ -947,7 +949,7 @@ impl SectorReader for BufferedSectorReader<'_> {
Err(_) => {
// Near end of disc or error — single sector fallback
self.cache.resize(2048, 0);
self.inner.read_sectors(lba, 1, &mut self.cache)?;
self.inner.read_sectors(lba, 1, &mut self.cache, true)?;
self.cache_start = lba;
self.cache_sectors = 1;
}
@@ -956,12 +958,12 @@ impl SectorReader for BufferedSectorReader<'_> {
Ok(2048)
} else {
// Multi-sector read — pass through
self.inner.read_sectors(lba, count, buf)
self.inner.read_sectors(lba, count, buf, true)
}
}
}
fn read_sector(reader: &mut dyn SectorReader, lba: u32, buf: &mut [u8]) -> Result<()> {
reader.read_sectors(lba, 1, buf)?;
reader.read_sectors(lba, 1, buf, true)?;
Ok(())
}
+34 -12
View File
@@ -1,6 +1,6 @@
//! Disc sector verification — read every sector and classify health.
use crate::disc::{Chapter, DiscTitle, Extent};
use crate::disc::{Chapter, DiscTitle};
use crate::sector::SectorReader;
use std::time::Instant;
@@ -55,7 +55,12 @@ impl VerifyResult {
}
/// Map a bad sector range to a chapter timestamp.
pub fn chapter_at_offset(chapters: &[Chapter], byte_offset: u64, duration_secs: f64, total_bytes: u64) -> Option<(usize, f64)> {
pub fn chapter_at_offset(
chapters: &[Chapter],
byte_offset: u64,
duration_secs: f64,
total_bytes: u64,
) -> Option<(usize, f64)> {
if total_bytes == 0 || chapters.is_empty() {
return None;
}
@@ -73,10 +78,12 @@ impl VerifyResult {
}
/// Progress callback: (sectors_done, total_sectors, current_status)
pub type ProgressFn = Box<dyn FnMut(u64, u64, SectorStatus)>;
/// Return false to stop verification early.
pub type ProgressFn = Box<dyn FnMut(u64, u64, SectorStatus) -> bool>;
/// Verify all sectors in a title's extents.
/// Reads in batches for speed, falls back to single-sector on failure.
/// The progress callback returns false to request early stop.
pub fn verify_title(
reader: &mut dyn SectorReader,
title: &DiscTitle,
@@ -90,12 +97,13 @@ pub fn verify_title(
let mut bad: u64 = 0;
let mut ranges: Vec<SectorRange> = Vec::new();
let mut sectors_done: u64 = 0;
let mut _stopped = false;
let mut byte_offset: u64 = 0;
let total_sectors: u64 = title.extents.iter().map(|e| e.sector_count as u64).sum();
let mut buf = vec![0u8; batch_sectors as usize * 2048];
for ext in &title.extents {
'outer: for ext in &title.extents {
let mut offset: u32 = 0;
while offset < ext.sector_count {
let remaining = ext.sector_count - offset;
@@ -105,7 +113,7 @@ pub fn verify_title(
let batch_start = Instant::now();
let batch_ok = reader
.read_sectors_fast(lba, count, &mut buf[..bytes])
.read_sectors(lba, count, &mut buf[..bytes], false)
.is_ok();
let batch_ms = batch_start.elapsed().as_millis();
@@ -133,7 +141,10 @@ pub fn verify_title(
sectors_done += count as u64;
if let Some(ref mut cb) = on_progress {
cb(sectors_done, total_sectors, status);
if !cb(sectors_done, total_sectors, status) {
_stopped = true;
break 'outer;
}
}
} else {
// Batch failed — test each sector individually
@@ -144,7 +155,12 @@ pub fn verify_title(
let s1 = Instant::now();
let first_ok = reader
.read_sectors_fast(sector_lba, 1, &mut buf[sector_offset..sector_offset + 2048])
.read_sectors(
sector_lba,
1,
&mut buf[sector_offset..sector_offset + 2048],
false,
)
.is_ok();
let s1_ms = s1.elapsed().as_millis();
@@ -158,7 +174,12 @@ pub fn verify_title(
// Retry once more after brief pause
std::thread::sleep(std::time::Duration::from_secs(2));
if reader
.read_sectors_fast(sector_lba, 1, &mut buf[sector_offset..sector_offset + 2048])
.read_sectors(
sector_lba,
1,
&mut buf[sector_offset..sector_offset + 2048],
false,
)
.is_ok()
{
recovered += 1;
@@ -172,9 +193,7 @@ pub fn verify_title(
if status != SectorStatus::Good {
// Merge with previous range if contiguous and same status
if let Some(last) = ranges.last_mut() {
if last.status == status
&& last.start_lba + last.count == sector_lba
{
if last.status == status && last.start_lba + last.count == sector_lba {
last.count += 1;
} else {
ranges.push(SectorRange {
@@ -196,7 +215,10 @@ pub fn verify_title(
sectors_done += 1;
if let Some(ref mut cb) = on_progress {
cb(sectors_done, total_sectors, status);
if !cb(sectors_done, total_sectors, status) {
_stopped = true;
break 'outer;
}
}
}
}
+11 -6
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;
@@ -361,7 +361,9 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
plaintext[off] = 0x47;
off += 192;
}
// Fill the rest with a recognisable pattern (prime modulus avoids artefacts)
// Fill the rest with a recognisable pattern (prime modulus avoids artefacts).
// Index-arithmetic — clearer with a counted loop than an enumerate chain.
#[allow(clippy::needless_range_loop)]
for i in 16..aacs::ALIGNED_UNIT_LEN {
if plaintext[i] == 0 {
plaintext[i] = (i % 251) as u8;
@@ -462,6 +464,7 @@ fn aacs_bus_decrypt_cross_validation() {
];
let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN];
#[allow(clippy::needless_range_loop)]
for i in 0..aacs::ALIGNED_UNIT_LEN {
plaintext[i] = ((i * 3 + 17) & 0xFF) as u8;
}
@@ -494,6 +497,7 @@ fn css_roundtrip_with_snapshot() {
let mut sector = vec![0x00u8; 2048];
sector[0x14] = 0x30;
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
#[allow(clippy::needless_range_loop)]
for i in 0x80..2048 {
sector[i] = ((i * 7 + 3) & 0xFF) as u8;
}
@@ -661,6 +665,7 @@ fn css_recover_title_key_with_exact_plaintext() {
sector[0x54..0x59].copy_from_slice(&seed);
let pes_header: [u8; 10] = [0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21];
sector[0x80..0x8A].copy_from_slice(&pes_header);
#[allow(clippy::needless_range_loop)]
for i in 0x8A..2048 {
sector[i] = ((i * 13 + 7) & 0xFF) as u8;
}
@@ -703,7 +708,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;
+17 -5
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]) -> 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;
@@ -127,7 +133,9 @@ fn scan_options_default() {
#[test]
fn scan_options_with_keydb() {
let opts = ScanOptions::with_keydb("/tmp/KEYDB.cfg");
let opts = ScanOptions {
keydb_path: Some(("/tmp/KEYDB.cfg").into()),
};
assert_eq!(
opts.keydb_path.as_ref().unwrap().to_str().unwrap(),
"/tmp/KEYDB.cfg"
@@ -137,7 +145,9 @@ fn scan_options_with_keydb() {
#[test]
fn scan_options_with_keydb_pathbuf() {
let path = std::path::PathBuf::from("/home/user/.config/aacs/KEYDB.cfg");
let opts = ScanOptions::with_keydb(path.clone());
let opts = ScanOptions {
keydb_path: Some(path.clone()),
};
assert_eq!(opts.keydb_path.unwrap(), path);
}
@@ -505,7 +515,9 @@ fn resolve_encryption_no_keydb() {
build_udf_with_aacs_dir(&mut reader);
// No keydb configured and no standard keydb on the system
let opts = ScanOptions::with_keydb("/nonexistent/path/KEYDB.cfg");
let opts = ScanOptions {
keydb_path: Some(("/nonexistent/path/KEYDB.cfg").into()),
};
let disc = Disc::scan_image(&mut reader, 1000, &opts).unwrap();
// The disc detects encryption but can't resolve keys without a keydb
+13
View File
@@ -30,6 +30,7 @@ fn sample_disc_title() -> DiscTitle {
language: "eng".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: libfreemkv::LabelPurpose::Normal,
label: "English Atmos".into(),
}),
Stream::Audio(AudioStream {
@@ -39,6 +40,7 @@ fn sample_disc_title() -> DiscTitle {
language: "fra".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: libfreemkv::LabelPurpose::Normal,
label: "French".into(),
}),
Stream::Subtitle(SubtitleStream {
@@ -46,6 +48,7 @@ fn sample_disc_title() -> DiscTitle {
codec: Codec::Pgs,
language: "eng".into(),
forced: false,
qualifier: libfreemkv::LabelQualifier::None,
codec_data: None,
}),
],
@@ -404,6 +407,7 @@ fn meta_codec_roundtrip() {
language: "eng".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: libfreemkv::LabelPurpose::Normal,
label: String::new(),
}));
}
@@ -413,6 +417,7 @@ fn meta_codec_roundtrip() {
codec,
language: "eng".into(),
forced: false,
qualifier: libfreemkv::LabelQualifier::None,
codec_data: None,
}));
}
@@ -501,6 +506,7 @@ fn meta_all_stream_types() {
language: "eng".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: libfreemkv::LabelPurpose::Normal,
label: "Primary Audio".into(),
}),
Stream::Subtitle(SubtitleStream {
@@ -508,6 +514,7 @@ fn meta_all_stream_types() {
codec: Codec::Pgs,
language: "fra".into(),
forced: true,
qualifier: libfreemkv::LabelQualifier::None,
codec_data: None,
}),
Stream::Audio(AudioStream {
@@ -517,6 +524,7 @@ fn meta_all_stream_types() {
language: "eng".into(),
sample_rate: SampleRate::S48,
secondary: true,
purpose: libfreemkv::LabelPurpose::Normal,
label: "Commentary".into(),
}),
],
@@ -623,6 +631,7 @@ fn mkvstream_roundtrip_bdts() {
language: "eng".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: libfreemkv::LabelPurpose::Normal,
label: "English".into(),
})],
chapters: Vec::new(),
@@ -679,6 +688,7 @@ fn mkvstream_meta_preserves_all_streams() {
language: "eng".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: libfreemkv::LabelPurpose::Normal,
label: "English".into(),
}),
Stream::Audio(AudioStream {
@@ -688,6 +698,7 @@ fn mkvstream_meta_preserves_all_streams() {
language: "fra".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: libfreemkv::LabelPurpose::Normal,
label: "French".into(),
}),
Stream::Subtitle(SubtitleStream {
@@ -695,6 +706,7 @@ fn mkvstream_meta_preserves_all_streams() {
codec: Codec::Pgs,
language: "eng".into(),
forced: false,
qualifier: libfreemkv::LabelQualifier::None,
codec_data: None,
}),
Stream::Subtitle(SubtitleStream {
@@ -702,6 +714,7 @@ fn mkvstream_meta_preserves_all_streams() {
codec: Codec::Pgs,
language: "fra".into(),
forced: true,
qualifier: libfreemkv::LabelQualifier::None,
codec_data: None,
}),
],
+12 -6
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]) -> 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 {
@@ -215,7 +221,7 @@ fn mock_sector_reader_roundtrip() {
// Read it back
let mut buf = vec![0u8; SECTOR_SIZE];
let n = reader.read_sectors(100, 1, &mut buf).unwrap();
let n = reader.read_sectors(100, 1, &mut buf, true).unwrap();
assert_eq!(n, SECTOR_SIZE);
assert_eq!(buf[0], 0xAB);
assert_eq!(buf[1], 0xCD);
@@ -223,7 +229,7 @@ fn mock_sector_reader_roundtrip() {
// Reading an unmapped sector returns zeros
let mut buf2 = vec![0xFFu8; SECTOR_SIZE];
let n2 = reader.read_sectors(999, 1, &mut buf2).unwrap();
let n2 = reader.read_sectors(999, 1, &mut buf2, true).unwrap();
assert_eq!(n2, SECTOR_SIZE);
assert_eq!(buf2[0], 0);
assert_eq!(buf2[2047], 0);
@@ -243,7 +249,7 @@ fn mock_sector_reader_multi_sector() {
// Read 2 consecutive sectors
let mut buf = vec![0u8; SECTOR_SIZE * 2];
let n = reader.read_sectors(10, 2, &mut buf).unwrap();
let n = reader.read_sectors(10, 2, &mut buf, true).unwrap();
assert_eq!(n, SECTOR_SIZE * 2);
assert_eq!(buf[0], 10);
assert_eq!(buf[SECTOR_SIZE], 11);
@@ -463,7 +469,7 @@ fn sector_reader_is_object_safe() {
let dyn_reader: &mut dyn SectorReader = &mut reader;
let mut buf = vec![0u8; SECTOR_SIZE];
let n = dyn_reader.read_sectors(0, 1, &mut buf).unwrap();
let n = dyn_reader.read_sectors(0, 1, &mut buf, true).unwrap();
assert_eq!(n, SECTOR_SIZE);
assert_eq!(buf[0], 42);
}