Compare commits

...
4 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
31 changed files with 1779 additions and 618 deletions
+269
View File
@@ -1,5 +1,274 @@
# 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
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.12.1"
version = "0.13.2"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
+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
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(),
})
})
+13 -6
View File
@@ -149,10 +149,13 @@ impl Mapfile {
.next()
.and_then(SectorStatus::from_char)
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("bad status char in mapfile: {}", fields[2]),
)
// 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 });
}
@@ -318,8 +321,12 @@ impl Mapfile {
fn parse_hex(s: &str) -> io::Result<u64> {
let s = s.strip_prefix("0x").unwrap_or(s);
u64::from_str_radix(s, 16)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("bad hex {s}: {e}")))
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)]
+13 -8
View File
@@ -20,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.
@@ -180,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,
}
@@ -195,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>>,
}
@@ -889,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 {
+132 -74
View File
@@ -8,12 +8,15 @@
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};
@@ -128,21 +131,32 @@ impl Drive {
self.halt.load(Ordering::Relaxed)
}
/// Sleep for `total`, but wake within ~100 ms if the halt flag fires.
/// Returns `true` if halted during sleep. Used by the recovery path
/// where a single 30 s sleep would otherwise swallow a Stop request
/// and make the UI feel frozen.
fn halt_aware_sleep(&self, total: std::time::Duration) -> bool {
let slice = std::time::Duration::from_millis(100);
let deadline = std::time::Instant::now() + total;
while std::time::Instant::now() < deadline {
if self.is_halted() {
return true;
}
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
std::thread::sleep(remaining.min(slice));
/// 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);
}
self.is_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.
@@ -541,26 +555,31 @@ impl Drive {
0x00,
];
// Normal read
if let Ok(result) = self.scsi.as_mut().execute(
// Normal read. Fast path: drive returns data, we return it.
// checked_exec short-circuits to Err(Halted) if Stop was requested;
// read() never touches the halt flag directly.
match self.checked_exec(
&cdb,
crate::scsi::DataDirection::FromDevice,
buf,
timeout_ms,
) {
if self.recovery_bytes_remaining > 0 {
let bytes_read = count as u64 * 2048;
self.recovery_bytes_remaining =
self.recovery_bytes_remaining.saturating_sub(bytes_read);
if self.recovery_bytes_remaining == 0 {
self.emit(EventKind::SpeedChange { speed_kbs: 0xFFFF });
self.set_speed(0xFFFF);
Ok(result) => {
if self.recovery_bytes_remaining > 0 {
let bytes_read = count as u64 * 2048;
self.recovery_bytes_remaining =
self.recovery_bytes_remaining.saturating_sub(bytes_read);
if self.recovery_bytes_remaining == 0 {
self.emit(EventKind::SpeedChange { speed_kbs: 0xFFFF });
self.set_speed(0xFFFF);
}
}
return Ok(result.bytes_transferred);
}
return Ok(result.bytes_transferred);
Err(Error::Halted) => return Err(Error::Halted),
Err(_) => { /* fall through to recovery */ }
}
// Read failed
if !recovery {
return Err(Error::DiscRead { sector: lba as u64 });
}
@@ -573,66 +592,36 @@ impl Drive {
self.emit(EventKind::SpeedChange { speed_kbs: 0 });
self.set_speed(0);
// Phase 1: gentle — sleep 30s, retry. 5 times.
// Phase 1: gentle — sleep 30 s, retry. 5 times.
for attempt in 1..=5u32 {
if self.is_halted() {
return Err(Error::Halted);
}
self.emit(EventKind::Retry { attempt });
if self.halt_aware_sleep(std::time::Duration::from_secs(30)) {
return Err(Error::Halted);
}
if let Ok(result) = self.scsi.as_mut().execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
buf,
30_000,
) {
self.checked_sleep(std::time::Duration::from_secs(30))?;
if let Ok(result) =
self.checked_exec(&cdb, crate::scsi::DataDirection::FromDevice, buf, 30_000)
{
self.emit(EventKind::SectorRecovered { sector: lba as u64 });
self.recovery_bytes_remaining = RECOVERY_WINDOW;
return Ok(result.bytes_transferred);
}
}
if self.is_halted() {
return Err(Error::Halted);
}
// Phase 2: fresh start — close, reset, open, init.
let device = std::path::PathBuf::from(&self.device_path);
if self.halt_aware_sleep(std::time::Duration::from_secs(5)) {
return Err(Error::Halted);
}
self.checked_sleep(std::time::Duration::from_secs(5))?;
let _ = crate::scsi::reset(&device);
if self.halt_aware_sleep(std::time::Duration::from_secs(5)) {
return Err(Error::Halted);
}
self.checked_sleep(std::time::Duration::from_secs(5))?;
self.scsi = crate::scsi::open(&device)?;
let _ = self.init();
let _ = self.wait_ready();
self.set_speed(0);
if self.is_halted() {
return Err(Error::Halted);
}
// Phase 3: gentle again on fresh connection — sleep 30s, retry. 5 times.
// Phase 3: gentle again on fresh connection — sleep 30 s, retry. 5 times.
for attempt in 6..=10u32 {
if self.is_halted() {
return Err(Error::Halted);
}
self.emit(EventKind::Retry { attempt });
if self.halt_aware_sleep(std::time::Duration::from_secs(30)) {
return Err(Error::Halted);
}
if let Ok(result) = self.scsi.as_mut().execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
buf,
30_000,
) {
self.checked_sleep(std::time::Duration::from_secs(30))?;
if let Ok(result) =
self.checked_exec(&cdb, crate::scsi::DataDirection::FromDevice, buf, 30_000)
{
self.emit(EventKind::SectorRecovered { sector: lba as u64 });
self.recovery_bytes_remaining = RECOVERY_WINDOW;
return Ok(result.bytes_transferred);
@@ -753,6 +742,27 @@ impl SectorReader for Drive {
}
}
/// Halt-aware sleep primitive. Returns `Err(Halted)` if the flag is set
/// before or during the wait. Extracted as a free function so it can be
/// unit-tested without constructing a full `Drive`.
///
/// Wakes within ~100 ms of a halt, regardless of the requested duration.
fn sleep_until_halted(halt: &AtomicBool, total: std::time::Duration) -> Result<()> {
const SLICE: std::time::Duration = std::time::Duration::from_millis(100);
let deadline = std::time::Instant::now() + total;
loop {
if halt.load(Ordering::Relaxed) {
return Err(Error::Halted);
}
let now = std::time::Instant::now();
if now >= deadline {
return Ok(());
}
let remaining = deadline - now;
std::thread::sleep(remaining.min(SLICE));
}
}
/// Find all optical drives connected to this system.
/// Returns opened Drive objects ready for use.
pub fn find_drives() -> Vec<Drive> {
@@ -808,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());
}
}
+210 -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;
@@ -49,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;
@@ -84,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 ──────────────────────────────────────────────────────────────
@@ -103,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 {
@@ -111,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,
@@ -149,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,
@@ -202,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 {
@@ -211,8 +256,13 @@ 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,
@@ -226,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,
@@ -255,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,
}
}
}
@@ -267,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,
@@ -357,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)
@@ -366,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 -18
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;
}
@@ -173,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(),
};
}
@@ -217,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",
@@ -251,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)
}
}
+55 -4
View File
@@ -94,21 +94,65 @@ 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::{
CapturedFeature, DriveCapture, capture_drive_data, mask_bytes, mask_string,
};
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
// 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;
@@ -116,7 +160,14 @@ pub use mux::NetworkStream;
pub use mux::NullStream;
pub use mux::StdioStream;
pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
pub use scsi::ScsiTransport;
// ─── 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::{UdfFs, read_filesystem};
-27
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;
+1 -2
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 {
-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);
}
}
+2
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,
}),
})
+8 -46
View File
@@ -8,7 +8,7 @@ use super::ebml;
use crate::disc::{
AudioStream, Chapter, Codec, ColorSpace, HdrFormat, SubtitleStream, VideoStream,
};
use std::io::{self, Seek, SeekFrom, Write};
use std::io::{self, Seek, Write};
/// MKV track definition (built from disc stream metadata).
pub struct MkvTrack {
@@ -170,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.
@@ -219,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)?;
@@ -243,20 +237,12 @@ 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 {
@@ -344,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,
})
}
@@ -415,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 {
@@ -810,6 +770,7 @@ mod tests {
codec: Codec::Pgs,
language: "eng".into(),
forced: true,
qualifier: crate::disc::LabelQualifier::Forced,
codec_data: None,
});
assert!(forced_sub.is_forced);
@@ -835,6 +796,7 @@ mod tests {
codec: Codec::Pgs,
language: "eng".into(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
});
assert!(!sub.is_forced);
+2
View File
@@ -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,
+30 -13
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;
@@ -43,6 +53,13 @@ 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 {}
+1
View File
@@ -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)?))
}
+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::*;
-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, None)));
}
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
}
+292
View File
@@ -19,6 +19,15 @@ const SG_DXFER_TO_DEV: i32 = -2;
const SG_DXFER_FROM_DEV: i32 = -3;
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 {
@@ -172,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 {
@@ -427,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;
+267 -21
View File
@@ -212,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,
});
}
@@ -231,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(),
});
}
@@ -244,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,
});
}
@@ -275,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 {
@@ -404,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 }
@@ -418,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.
@@ -441,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.
+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.
+56
View File
@@ -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 {
+6 -4
View File
@@ -39,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 {
+6 -1
View File
@@ -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;
}
+9 -3
View File
@@ -133,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"
@@ -143,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);
}
@@ -511,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,
}),
],