mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa12bdad62 | ||
|
|
ef3895cdc5 | ||
|
|
2dcf969ac8 | ||
|
|
005f887bf9 | ||
|
|
d7243a6044 | ||
|
|
f1926c38dc | ||
|
|
4709a73c80 | ||
|
|
55bd1ee868 | ||
|
|
5285e8b6ec | ||
|
|
c5af61e46b | ||
|
|
ed73c1ab68 | ||
|
|
5ce2df0888 | ||
|
|
ea591a36a5 | ||
|
|
368e10486e | ||
|
|
a99c4f8487 | ||
|
|
4df470e572 | ||
|
|
a0b13941fe | ||
|
|
275c9da009 | ||
|
|
da183722fa | ||
|
|
4a5424eabe | ||
|
|
293d89c586 | ||
|
|
4840f5134d | ||
|
|
11c8211605 | ||
|
|
1038beaf06 | ||
|
|
f1df57196c | ||
|
|
8ba9dc1c0b | ||
|
|
49b131f39a | ||
|
|
8238ec4ce7 | ||
|
|
4fd5d27df8 | ||
|
|
1fbe272832 | ||
|
|
6bd635725e | ||
|
|
09b77b4dea | ||
|
|
688058b3e8 | ||
|
|
c96bac7977 | ||
|
|
c32acff3e4 | ||
|
|
b58e2d9873 | ||
|
|
a9e802c1c2 | ||
|
|
a876ce846b | ||
|
|
058fd8396f | ||
|
|
5ee28c08b9 | ||
|
|
764230b1eb | ||
|
|
7fb2e07aed | ||
|
|
376aadb335 | ||
|
|
586495b2ee | ||
|
|
72f2224efe | ||
|
|
0bca7a11bd | ||
|
|
72ab714c1e | ||
|
|
d7fb1b35ed | ||
|
|
06be4defd2 | ||
|
|
a832bad697 | ||
|
|
26e2d847d5 | ||
|
|
4442fa2df6 | ||
|
|
23dc55661c | ||
|
|
fbdb50c79f | ||
|
|
7cc74f0087 | ||
|
|
3aa1e528c5 | ||
|
|
7c6b0f82ab | ||
|
|
4226a53e73 | ||
|
|
92f34a289e | ||
|
|
fdbe469d50 | ||
|
|
4ec75a03f2 | ||
|
|
99236ffd55 | ||
|
|
9e3327a569 | ||
|
|
0081955686 | ||
|
|
3244fdd683 | ||
|
|
ef5487e5a5 | ||
|
|
e7dffa63d2 | ||
|
|
e5989c8270 | ||
|
|
e1c938b82a | ||
|
|
ed801a708b | ||
|
|
d94451954c | ||
|
|
385c9f094c | ||
|
|
9f2a13739d | ||
|
|
d9ce69bc9d | ||
|
|
7cd2c937ed | ||
|
|
107524da5e | ||
|
|
055a3c5276 | ||
|
|
50b04790ce | ||
|
|
a00b2c884b | ||
|
|
44e2c64d7e | ||
|
|
d6535b8f57 | ||
|
|
90ab00ed45 | ||
|
|
6bfd7dfd13 | ||
|
|
b9a7f601d4 | ||
|
|
cf0a61f8b4 | ||
|
|
f98f07b2d3 | ||
|
|
b53454fa09 | ||
|
|
f28b6ee6d9 | ||
|
|
d9d778fa64 | ||
|
|
bbfb887a35 | ||
|
|
9884346c24 | ||
|
|
766b7c6636 | ||
|
|
925c30686b | ||
|
|
f52e4c5d22 | ||
|
|
8d67790d2e | ||
|
|
246a439990 | ||
|
|
bd19041648 | ||
|
|
198268b725 | ||
|
|
31424c8203 | ||
|
|
c747ecc589 | ||
|
|
283a561c12 | ||
|
|
2667d68675 | ||
|
|
57f2c22e30 | ||
|
|
5f3545d244 | ||
|
|
6e17ef0859 | ||
|
|
6ec97af104 | ||
|
|
3a6c1aa5a3 | ||
|
|
1ba3264747 | ||
|
|
ae2909fe8d | ||
|
|
6a3d19a453 | ||
|
|
a35596d2d1 | ||
|
|
318f654fed | ||
|
|
5e82ef65de | ||
|
|
8534607329 | ||
|
|
97e1a4cad3 | ||
|
|
3f6bf33de5 | ||
|
|
359301e8fc | ||
|
|
bfa527162a | ||
|
|
45defd47f3 | ||
|
|
fe8933dad2 | ||
|
|
0a3c1b7b70 | ||
|
|
2bcdc97341 | ||
|
|
4863e9c545 | ||
|
|
03db038dd9 | ||
|
|
95001ba2c3 | ||
|
|
a1f4dff6f6 | ||
|
|
7dd5001d45 | ||
|
|
67fe93c0b8 | ||
|
|
5c4f575c46 | ||
|
|
e2cb7a78bb | ||
|
|
a6f1bd19bc | ||
|
|
9e3d0f1383 | ||
|
|
1e6eb0698d | ||
|
|
bd744171e4 | ||
|
|
7de9d4d42c | ||
|
|
9ac0c2fcdf | ||
|
|
3aa27d5d1b | ||
|
|
5083f70cff | ||
|
|
477cf7ee0e | ||
|
|
0b079e27ce | ||
|
|
5f69fd2a44 | ||
|
|
befe6ef69b | ||
|
|
29491d6eaf | ||
|
|
2e602f7e37 | ||
|
|
a126f31b70 | ||
|
|
6ff10df83a | ||
|
|
52b8522a75 | ||
|
|
13eb4331b6 | ||
|
|
1a3f7ce676 | ||
|
|
e1a1f730be | ||
|
|
646c22ae93 | ||
|
|
2f6092a686 | ||
|
|
e5a90a6567 | ||
|
|
afce1031d5 | ||
|
|
2c0bff1303 | ||
|
|
2067ebe485 | ||
|
|
2ce4c20221 | ||
|
|
da3004b120 | ||
|
|
b5ed97801b | ||
|
|
ae76aaf0fa | ||
|
|
2cd4fbead7 | ||
|
|
ebffc6eb88 | ||
|
|
424d3cd4f2 | ||
|
|
d2905ba7bb | ||
|
|
b4de5d343d | ||
|
|
8beeac7df9 | ||
|
|
b33f41e219 | ||
|
|
e85e20f436 | ||
|
|
7bc54be8d3 | ||
|
|
6d9083743b | ||
|
|
c49a68054f | ||
|
|
4bd38787c6 | ||
|
|
b4951b1c5b | ||
|
|
92dc145864 | ||
|
|
36b65f526a | ||
|
|
ca8ebf418f | ||
|
|
34182d956a | ||
|
|
0f967a2084 | ||
|
|
0341995c0d | ||
|
|
fe0ec0c5cf | ||
|
|
022657d481 | ||
|
|
fc8eca44e1 | ||
|
|
43836865be | ||
|
|
5e22199441 | ||
|
|
761b77bbd9 | ||
|
|
cc05ef2a3a | ||
|
|
e27b82ce5b | ||
|
|
8af47e4c19 | ||
|
|
d1f09439a5 | ||
|
|
37e721ee7e | ||
|
|
481b47d90d | ||
|
|
dd7cbd70a9 | ||
|
|
3dea679dac | ||
|
|
64ecca0d12 | ||
|
|
84f6baba38 | ||
|
|
bd5b7795bd | ||
|
|
a57506c1ce | ||
|
|
c33f3e9557 | ||
|
|
166fc4bf8c | ||
|
|
9aaddaa9b8 | ||
|
|
40ec1e1eba | ||
|
|
8843833ea7 | ||
|
|
afae37c8c3 | ||
|
|
d8089ec491 | ||
|
|
ce8132d181 | ||
|
|
71e1f57364 | ||
|
|
d8092ee093 | ||
|
|
dd49d55b11 | ||
|
|
6ba958fe8a | ||
|
|
f30b958408 | ||
|
|
6971f00be2 | ||
|
|
ad86f92d85 | ||
|
|
cce1be29b2 | ||
|
|
081ad4f619 | ||
|
|
43ecad2d43 |
@@ -14,6 +14,10 @@ jobs:
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
- run: cargo fmt --check
|
||||
# libfreemkv is a library — Cargo.lock is gitignored. --locked
|
||||
# would always fail on a fresh runner because there's no committed
|
||||
# lockfile to lock against. The binary crates (freemkv, autorip,
|
||||
# bdemu) track Cargo.lock and DO use --locked.
|
||||
- run: cargo clippy -- -D warnings
|
||||
|
||||
test:
|
||||
@@ -21,7 +25,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
- run: cargo test
|
||||
- run: cargo test --tests
|
||||
|
||||
check-macos:
|
||||
runs-on: macos-latest
|
||||
|
||||
@@ -28,6 +28,8 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.86.0
|
||||
# libfreemkv is a library — Cargo.lock isn't tracked, so --locked
|
||||
# would always fail (no lockfile to lock against on a fresh runner).
|
||||
- run: cargo test
|
||||
|
||||
publish:
|
||||
|
||||
+1641
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.11.9"
|
||||
edition = "2021"
|
||||
version = "0.20.6"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
@@ -24,6 +24,10 @@ rand = "0.8"
|
||||
cmac = "0.7"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
base64 = "0.22.1"
|
||||
# Trace-level instrumentation for Disc::copy + SgIoTransport::execute. Permitted
|
||||
# under project docs ("Acceptable strings: debug/trace logging"). Consumers (autorip)
|
||||
# wire a tracing subscriber and pipe events into the JSONL debug log.
|
||||
tracing = "0.1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
@@ -31,6 +35,9 @@ libc = "0.2"
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[[bench]]
|
||||
name = "sgio_read"
|
||||
harness = false
|
||||
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
# libfreemkv — Feature List
|
||||
|
||||
## v0.10.10 (current)
|
||||
|
||||
### Done
|
||||
- [x] Drive access: open, identify, unlock, firmware upload, speed calibration, eject
|
||||
- [x] 206 bundled drive profiles (MediaTek MT1959 A + B variants)
|
||||
- [x] SCSI transport: Linux SG_IO, macOS IOKit, Windows SPTI
|
||||
- [x] UDF 2.50 filesystem parser (metadata partitions, Blu-ray profile)
|
||||
- [x] MPLS playlist parser (play items, STN table, secondary streams)
|
||||
- [x] CLPI clip info parser (EP map, sector extents)
|
||||
- [x] AACS 1.0 decryption (4 VUK paths: KEYDB, media key, processing key, device key)
|
||||
- [x] AACS 2.0 SCSI handshake (P-256/SHA-256 ECDH, bus decryption, read data key)
|
||||
- [x] DVD IFO parser (VMG, VTS, PGC chains, cell addresses)
|
||||
- [x] DVD CSS decryption (bus auth, disc key via player keys, title key, sector descramble)
|
||||
- [x] CSS Stevenson plaintext attack for ISO key recovery
|
||||
- [x] MPEG-2 PS demuxer (DVD Program Stream with PES extraction)
|
||||
- [x] MPEG-2 video codec parser (sequence header, quantizer matrices, keyframe detection)
|
||||
- [x] KEYDB.cfg download, verify, save (raw TCP, zero HTTP deps)
|
||||
- [x] Content reading with adaptive batch sizing, error recovery, 12+ MB/s
|
||||
- [x] Stream labels: 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
||||
- [x] MKV muxer: 14 codec parsers (H.264, HEVC, MPEG-2, AC-3, DTS, TrueHD, PGS, DVD Sub, VC-1, LPCM, +4 more)
|
||||
- [x] SectorReader trait: decouples disc scanning from SCSI
|
||||
- [x] 7 stream types: Disc, ISO, MKV, M2TS, Network, Stdio, Null
|
||||
- [x] PES pipeline with unified Stream trait (any source → any dest)
|
||||
- [x] FMKV metadata header for M2TS and network streams
|
||||
- [x] Numeric error codes only (no English text in library)
|
||||
- [x] Event system for progress callbacks
|
||||
|
||||
### Planned
|
||||
- [ ] Windows testing on real hardware
|
||||
- [ ] Pioneer Renesas platform support (48 drives, need GET_CONFIG 010C)
|
||||
- [ ] TranscodeStream (ffmpeg integration)
|
||||
- [ ] ISO write with BD-compliant UDF structure
|
||||
@@ -0,0 +1,20 @@
|
||||
# libfreemkv — local dev helper.
|
||||
# Mirrors the cross-crate scripts in (internal)/scripts/test-all.sh
|
||||
# but scoped to this single crate.
|
||||
|
||||
.PHONY: test build check ci clean
|
||||
|
||||
test:
|
||||
cargo test --tests
|
||||
|
||||
build:
|
||||
cargo build --release
|
||||
|
||||
check:
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
|
||||
ci: check build test
|
||||
|
||||
clean:
|
||||
cargo clean
|
||||
@@ -18,7 +18,7 @@ Part of the [freemkv](https://github.com/freemkv) project.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
libfreemkv = "0.11"
|
||||
libfreemkv = "0.18"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@@ -51,6 +51,52 @@ while let Ok(Some(frame)) = input.read() {
|
||||
output.finish()?;
|
||||
```
|
||||
|
||||
### Multi-pass recovery rip
|
||||
|
||||
For damaged discs the library exposes two flat verbs — `Disc::sweep` for the
|
||||
forward Pass 1 and `Disc::patch` for retrying bad ranges. The library never
|
||||
loops; the multipass policy is the caller's job. See
|
||||
[`docs/rip-recovery.md`](docs/rip-recovery.md) and the design notes in
|
||||
`(internal)/memory/0_18_redesign.md`.
|
||||
|
||||
```rust
|
||||
use libfreemkv::{SweepOptions, PatchOptions};
|
||||
use libfreemkv::disc::{mapfile, mapfile_path_for};
|
||||
use std::path::Path;
|
||||
|
||||
let iso = Path::new("disc.iso");
|
||||
|
||||
// Pass 1: disc → ISO. Skip-on-error, zero-fill, write the sidecar mapfile.
|
||||
disc.sweep(&mut drive, iso, &SweepOptions {
|
||||
decrypt: true,
|
||||
resume: false,
|
||||
batch_sectors: None,
|
||||
skip_on_error: true,
|
||||
progress: None,
|
||||
halt: None,
|
||||
})?;
|
||||
|
||||
// Pass 2..N: retry every non-finished range. Idempotent.
|
||||
loop {
|
||||
let map = mapfile::Mapfile::load(&mapfile_path_for(iso))?;
|
||||
let stats = map.stats();
|
||||
if stats.bytes_pending + stats.bytes_unreadable == 0 { break; }
|
||||
|
||||
let outcome = disc.patch(&mut drive, iso, &PatchOptions {
|
||||
decrypt: true,
|
||||
block_sectors: None,
|
||||
full_recovery: true,
|
||||
reverse: true,
|
||||
wedged_threshold: 50,
|
||||
progress: None,
|
||||
halt: None,
|
||||
})?;
|
||||
if outcome.bytes_recovered_this_pass == 0 { break; }
|
||||
}
|
||||
|
||||
// Mux from the ISO via the normal stream pipeline (no drive involvement).
|
||||
```
|
||||
|
||||
## What It Does
|
||||
|
||||
- **Drive access** — open, identify, unlock, firmware upload, speed calibration, eject
|
||||
@@ -59,7 +105,7 @@ output.finish()?;
|
||||
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
||||
- **AACS decryption** — transparent key resolution and content decrypt (1.0 + 2.0 bus decryption)
|
||||
- **KEYDB updates** — download, verify, save from any HTTP URL (zero deps, raw TCP)
|
||||
- **Content reading** — adaptive batch reads with automatic decryption and error recovery
|
||||
- **Content reading** — adaptive batch reads with automatic decryption
|
||||
- **Stream I/O** — unified stream pipeline for reading and writing any format
|
||||
|
||||
### Streams
|
||||
@@ -67,21 +113,21 @@ output.finish()?;
|
||||
| Stream | Input | Output | Transport |
|
||||
|--------|-------|--------|-----------|
|
||||
| DiscStream | Yes | -- | Optical drive via SCSI |
|
||||
| IsoStream | Yes | Yes | Blu-ray ISO image file |
|
||||
| IsoStream | Yes | -- | Blu-ray ISO image file (read via stream pipeline; written via `Disc::sweep()`) |
|
||||
| MkvStream | Yes | Yes | Matroska container |
|
||||
| M2tsStream | Yes | Yes | BD transport stream with FMKV metadata header |
|
||||
| NetworkStream | Yes (listen) | Yes (connect) | TCP with FMKV metadata header |
|
||||
| StdioStream | Yes (stdin) | Yes (stdout) | Raw byte pipe |
|
||||
| NullStream | -- | Yes | Discard sink (byte counter for benchmarks) |
|
||||
|
||||
Streams implement `IOStream` (byte-level) and `pes::Stream` (frame-level). `input()` / `output()` resolve URL strings to PES stream instances. `open_input()` / `open_output()` resolve to byte-level IOStream instances. All URLs use the `scheme://path` format — bare paths are rejected.
|
||||
Streams implement `FrameSource` (read) and/or `FrameSink` (write); direction is type-checked. `input()` / `output()` resolve URL strings to PES stream instances. All URLs use the `scheme://path` format — bare paths are rejected.
|
||||
|
||||
AACS decryption requires a KEYDB.cfg file. If available at `~/.config/aacs/KEYDB.cfg` or passed via `ScanOptions`, the library handles everything — handshake, key derivation, and per-sector decryption — without the application needing to know anything about encryption.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
Drive — open, identify, init, unlock, read (with recovery)
|
||||
Drive — open, identify, init, unlock, single-shot read
|
||||
├── ScsiTransport — SG_IO (Linux), IOKit (macOS), SPTI (Windows)
|
||||
├── DriveProfile — per-drive unlock parameters (bundled)
|
||||
└── PlatformDriver — MediaTek (supported), Renesas (planned)
|
||||
@@ -97,7 +143,8 @@ Disc — scan titles, streams, AACS/CSS state
|
||||
└── KEYDB — download + verify + save
|
||||
|
||||
Streams — unified PES pipeline
|
||||
├── pes::Stream — read()/write() PES frames
|
||||
├── FrameSource — read() PES frames (direction-typed)
|
||||
├── FrameSink — write() PES frames (direction-typed)
|
||||
├── DiscStream — sectors → decrypt → TS demux → PES
|
||||
├── IsoStream — ISO file → decrypt → TS demux → PES
|
||||
├── MkvStream — MKV mux/demux
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
# Troubleshooting Guide
|
||||
|
||||
Common problems and solutions for optical drive ripping with freemkv.
|
||||
|
||||
---
|
||||
|
||||
## 1. USB-SATA Bridge Issues
|
||||
|
||||
This is the single most common source of problems when ripping discs over USB.
|
||||
|
||||
### Symptoms
|
||||
|
||||
- The drive disappears mid-rip. The ripping tool reports the device is gone, and `ls /dev/sg*` no longer shows it.
|
||||
- The device re-enumerates under a different name: `sg4` becomes `sg5`, then `sg7`, then `sg11` after each USB port reset.
|
||||
- `dmesg` shows USB port resets: `usb X-Y: reset high-speed USB device`, `xhci_hcd 0000:00:14.0: Cannot enable. Maybe the USB cable is bad?`, or `usb-storage: device reset failed`.
|
||||
- The SCSI layer reports `host_status=7` (Linux USB transport error) in sense data.
|
||||
- The drive works fine for reading data discs or burning, but crashes when hitting damaged sectors during a rip.
|
||||
- After the crash, the drive is completely invisible until physically unplugged and reconnected.
|
||||
|
||||
### Root Cause
|
||||
|
||||
USB-SATA bridges translate between the USB Mass Storage protocol (BOT or UAS) and the drive's native SATA interface. When the optical drive encounters an unreadable sector, it returns a SCSI CHECK CONDITION with sense key 0x03 (MEDIUM ERROR). Some bridge chipsets -- particularly the Initio INIC-36xx family -- have buggy firmware that mishandles this error response.
|
||||
|
||||
Specific failure modes:
|
||||
|
||||
- **Incorrect residue reporting.** The bridge claims a different number of bytes transferred than what actually occurred. The Linux USB storage driver sees this discrepancy as a protocol violation and resets the port to recover. The `US_FL_IGNORE_RESIDUE` quirk exists specifically for this class of bug (see `drivers/usb/storage/transport.c` in the Linux kernel).
|
||||
- **Bridge firmware crash.** On some Initio bridges, a malformed SCSI error response from the drive causes the bridge MCU to hang entirely. The USB controller sees the device stop responding and initiates a port reset. The bridge recovers (it re-enumerates), but the rip is dead -- all state is lost.
|
||||
- **Sense data corruption.** The bridge forwards garbled or truncated sense data to the host, which the SCSI midlayer cannot parse, leading to a transport reset.
|
||||
|
||||
This is a hardware + firmware problem, not a software bug. The same drive connected via direct SATA does not exhibit these symptoms.
|
||||
|
||||
### Known Affected Bridges
|
||||
|
||||
| Chipset | USB IDs | Notes |
|
||||
|---------|---------|-------|
|
||||
| Initio INIC-3609 | `13fd:3609` | Very common in cheap SATA-to-USB enclosures. Highly problematic. |
|
||||
| Initio INIC-3619 | `13fd:3940` | Same firmware family as INIC-3609. |
|
||||
| Initio INIC-3069 | `13fd:0840` | Older variant, same residue bug. |
|
||||
| ASMedia ASM1051 | `174c:5106` | Early ASM SATA bridge. Residue issues on error paths. |
|
||||
| JMicron JMB36x | `152d:0561` | Some firmware versions. Not all JMicroon chips are affected. |
|
||||
|
||||
If your drive came in a pre-built external enclosure (Vantec, Sabrent, OWC, etc.), it almost certainly uses one of these bridge chips internally.
|
||||
|
||||
### The Fix: USB Storage Quirk
|
||||
|
||||
Apply the `US_FL_IGNORE_RESIDUE` kernel quirk for your bridge. This tells the Linux USB storage driver to ignore the residue field in SCSI response frames, preventing the port reset on mismatched byte counts.
|
||||
|
||||
**Step 1: Identify your bridge's vendor:product ID.**
|
||||
|
||||
```bash
|
||||
lsusb
|
||||
```
|
||||
|
||||
Look for your drive's entry. Example output:
|
||||
|
||||
```
|
||||
Bus 002 Device 005: ID 13fd:0840 Initio Corporation INIC-3609
|
||||
```
|
||||
|
||||
Here the vendor ID is `13fd` and the product ID is `0840`.
|
||||
|
||||
**Step 2: Apply the quirk at runtime.**
|
||||
|
||||
```bash
|
||||
echo "13fd:0840:i" > /sys/module/usb_storage/parameters/quirks
|
||||
```
|
||||
|
||||
Replace `13fd:0840` with your device's actual IDs. The `:i` flag means `US_FL_IGNORE_RESIDUE`.
|
||||
|
||||
You can combine multiple flags. Common additions:
|
||||
|
||||
- `:i` -- ignore residue (`US_FL_IGNORE_RESIDUE`)
|
||||
- `:u` -- force BOT mode instead of UAS, for bridges with UAS bugs
|
||||
|
||||
**Step 3: Reconnect the drive.** Unplug and replug the USB cable, or bind/unbind the device. The quirk is applied per-module-load, so existing sessions may need the drive reconnected.
|
||||
|
||||
### Making It Persistent
|
||||
|
||||
Add the quirk to your kernel boot parameters so it survives reboots.
|
||||
|
||||
Edit `/etc/default/grub` (GRUB) and add to `GRUB_CMDLINE_LINUX_DEFAULT`:
|
||||
|
||||
```
|
||||
GRUB_CMDLINE_LINUX_DEFAULT="quiet usb_storage.quirks=13fd:0840:i"
|
||||
```
|
||||
|
||||
Then rebuild the GRUB config:
|
||||
|
||||
```bash
|
||||
sudo update-grub
|
||||
```
|
||||
|
||||
For systemd-boot, add to your loader entry or `/etc/kernel/cmdline`:
|
||||
|
||||
```
|
||||
usb_storage.quirks=13fd:0840:i
|
||||
```
|
||||
|
||||
Multiple devices can be separated by commas:
|
||||
|
||||
```
|
||||
usb_storage.quirks=13fd:0840:i,174c:5106:u
|
||||
```
|
||||
|
||||
### Recommended Bridges
|
||||
|
||||
If you are buying a USB-SATA adapter or enclosure for optical drive use:
|
||||
|
||||
| Bridge | USB IDs | Notes |
|
||||
|--------|---------|-------|
|
||||
| ASMedia ASM1153 | `174c:1153` | Reliable. Widely available in SATA-USB 3.0 cables. |
|
||||
| JMicron JMS578 | `152d:0578` | Good firmware. Supports UASP. |
|
||||
| Icy Box IB-AC640-C3 | N/A | Uses a known-good bridge internally. Plug-and-play. |
|
||||
|
||||
Avoid any enclosure or adapter listing an Initio chipset.
|
||||
|
||||
### Best Solution: Direct SATA
|
||||
|
||||
Connect your optical drive directly to a motherboard SATA port. This eliminates the USB-SATA bridge entirely and is the most reliable configuration:
|
||||
|
||||
- No USB protocol overhead or translation errors.
|
||||
- No bridge firmware bugs.
|
||||
- No port resets or re-enumeration.
|
||||
- Full SATA error recovery handled natively by the kernel's libata driver.
|
||||
- Sustained read speeds are limited only by the drive, not the USB bus.
|
||||
|
||||
If your machine has a free SATA port, use it.
|
||||
|
||||
---
|
||||
|
||||
## 2. Damaged Disc Handling
|
||||
|
||||
### Symptoms
|
||||
|
||||
- SCSI MEDIUM ERROR (sense key 0x03) at specific LBAs. `dmesg` shows `sr X:0:0:0: [srY] Unrecoverable read error` or similar.
|
||||
- Read speed drops to near zero when approaching a damaged area.
|
||||
- The drive makes audible retrying noises (laser repositioning, spindle speed changes).
|
||||
- On USB-connected drives: the bridge crashes (see section 1 above) when the drive returns the error.
|
||||
|
||||
### How freemkv Handles This
|
||||
|
||||
freemkv uses a three-layer recovery model. See [`docs/rip-recovery.md`](docs/rip-recovery.md) for full details.
|
||||
|
||||
- **Pass 1 (Disc::copy):** Fast sweep with 64 KB reads. On failure, zero-fills the block and skips forward. Writes a ddrescue-format mapfile for later retry.
|
||||
- **Pass 2+ (Disc::patch):** Targeted re-reads of bad ranges with a long 30-second timeout per CDB. The drive firmware performs its own ECC and laser power retries within that window.
|
||||
- **In-stream (DiscStream):** Adaptive batch halving -- reduces request size on failure to isolate bad sectors within a larger block.
|
||||
|
||||
This means a disc with some bad sectors will still produce a usable ISO. The damaged areas are zero-filled in pass 1 and retried in subsequent passes. Structure-protected sectors (deliberate unreadable regions from copy protection) will never yield, which is expected.
|
||||
|
||||
### The Drive Taint Issue (LG BU40N)
|
||||
|
||||
Some drives, notably the LG BU40N, exhibit a "taint" behavior after encountering MEDIUM ERRORs:
|
||||
|
||||
1. The drive hits a damaged sector and returns a MEDIUM ERROR.
|
||||
2. From that point forward, **all subsequent reads fail** -- even reads to sectors that were previously successful.
|
||||
3. The only recovery is to physically unplug and reconnect the drive (or power-cycle it).
|
||||
|
||||
This is not a freemkv bug. It is a drive firmware behavior triggered by the interaction between the drive's internal error recovery and the USB-SATA bridge's handling of the error response. The drive firmware enters a degraded state that it does not recover from without a power cycle.
|
||||
|
||||
Workarounds:
|
||||
|
||||
- **Use a direct SATA connection.** This eliminates the bridge interaction that triggers the taint.
|
||||
- **Use a different bridge.** The ASM1153 and JMS578 are less likely to trigger this behavior.
|
||||
- **Accept the partial ISO.** freemkv's skip-forward recovery will zero-fill the unreadable blocks and continue. The resulting ISO may be playable with minor glitches in the affected areas.
|
||||
- **Physical replug between retry passes.** If running multi-pass patch, replug the drive between passes to clear the taint state.
|
||||
|
||||
freemkv deliberately does not attempt inline SCSI resets or eject cycles to recover from this state, because those operations were found to make the problem worse on affected hardware (see the design rationale in [`docs/rip-recovery.md`](docs/rip-recovery.md)).
|
||||
|
||||
---
|
||||
|
||||
## 3. Drive Not Detected
|
||||
|
||||
### Check Hardware Visibility
|
||||
|
||||
```bash
|
||||
lsusb
|
||||
```
|
||||
|
||||
Verify the drive appears in the USB device list. If it does not show up, the drive is not visible to the host at all -- check cables, power, and USB port.
|
||||
|
||||
```bash
|
||||
ls /dev/sg*
|
||||
```
|
||||
|
||||
On Linux, optical drives appear as `/dev/sg*` devices (the SCSI Generic interface). freemkv uses `/dev/sg*`, not `/dev/sr*`. If `lsusb` shows the device but no `/dev/sg*` entry exists, the `sg` kernel module may not be loaded:
|
||||
|
||||
```bash
|
||||
sudo modprobe sg
|
||||
```
|
||||
|
||||
### Check Kernel Messages
|
||||
|
||||
```bash
|
||||
dmesg | grep -i usb | tail -30
|
||||
dmesg | grep -i sg | tail -10
|
||||
```
|
||||
|
||||
Look for:
|
||||
- USB enumeration errors or failed port resets.
|
||||
- `sg_add` messages confirming the sg device was registered.
|
||||
- Permission denied or access errors.
|
||||
|
||||
### Permission Issues
|
||||
|
||||
On most Linux distributions, `/dev/sg*` devices are owned by `root:disk` or `root:cdrom` with restricted permissions. Running freemkv as an unprivileged user will fail with permission errors.
|
||||
|
||||
Options:
|
||||
|
||||
- Add your user to the appropriate group:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG disk $USER
|
||||
```
|
||||
|
||||
Then log out and back in for the change to take effect. On some distributions the group is `cdrom` or `optical` instead of `disk`.
|
||||
|
||||
- Run with elevated privileges:
|
||||
|
||||
```bash
|
||||
sudo freemkv ...
|
||||
```
|
||||
|
||||
- Install a udev rule for persistent per-device permissions. Create `/etc/udev/rules.d/99-sg-optical.rules`:
|
||||
|
||||
```
|
||||
SUBSYSTEM=="scsi_generic", ATTRS{type}=="5", MODE="0666"
|
||||
```
|
||||
|
||||
Then reload udev rules:
|
||||
|
||||
```bash
|
||||
sudo udevadm control --reload-rules && sudo udevadm trigger
|
||||
```
|
||||
|
||||
### Spin-Up Delay
|
||||
|
||||
Optical drives take 30-60 seconds to spin up and become ready after hot-plug or disc insertion. During this window, SCSI commands may return NOT READY or timeout.
|
||||
|
||||
freemkv's `Drive::wait_ready()` handles this automatically by polling with TEST UNIT READY until the drive responds. If you are writing your own code using the library, always call `wait_ready()` before `init()`:
|
||||
|
||||
```rust
|
||||
let mut drive = Drive::open(Path::new("/dev/sg4"))?;
|
||||
drive.wait_ready()?; // blocks until disc is ready, up to 30s
|
||||
drive.init()?;
|
||||
```
|
||||
|
||||
If the drive was just plugged in, wait a full minute before concluding it is not detected.
|
||||
|
||||
---
|
||||
|
||||
## 4. How to Identify Your USB-SATA Bridge
|
||||
|
||||
If you are experiencing the issues described in section 1, you need to know which bridge chipset your adapter or enclosure uses.
|
||||
|
||||
### Step 1: Find the Device
|
||||
|
||||
```bash
|
||||
lsusb
|
||||
```
|
||||
|
||||
Look for entries matching your drive or enclosure. Bridges may appear under their own manufacturer name or as a generic SATA device. Common examples:
|
||||
|
||||
```
|
||||
Bus 002 Device 005: ID 13fd:0840 Initio Corporation
|
||||
Bus 002 Device 006: ID 174c:1153 ASMedia Technology Inc. ASM1153
|
||||
Bus 002 Device 007: ID 152d:0578 JMicron Technology Corp. JMS578
|
||||
```
|
||||
|
||||
### Step 2: Match the IDs
|
||||
|
||||
| Vendor | Product ID | Chipset | Status |
|
||||
|--------|-----------|---------|--------|
|
||||
| `13fd` | `3609` | Initio INIC-3609 | Affected. Apply quirk. |
|
||||
| `13fd` | `3940` | Initio INIC-3619 | Affected. Apply quirk. |
|
||||
| `13fd` | `0840` | Initio INIC-3069 | Affected. Apply quirk. |
|
||||
| `174c` | `5106` | ASMedia ASM1051 | Affected (early firmware). Apply quirk. |
|
||||
| `174c` | `1153` | ASMedia ASM1153 | Good. No quirk needed. |
|
||||
| `152d` | `0561` | JMicron JMB36x | Affected (some firmware). Apply quirk if issues occur. |
|
||||
| `152d` | `0578` | JMicron JMS578 | Good. No quirk needed. |
|
||||
|
||||
### Step 3: Check dmesg for the Bridge Name
|
||||
|
||||
```bash
|
||||
dmesg | grep -i "usb-storage\|uas\|initio\|asmedia\|jmicron"
|
||||
```
|
||||
|
||||
This often reveals the bridge chipset even when `lsusb` shows a generic name.
|
||||
|
||||
### Step 4: If the Enclosure Is Sealed
|
||||
|
||||
Many external drive enclosures (Vantec NexStar, Sabrent, OWC, etc.) do not advertise the bridge chipset on the packaging. In this case:
|
||||
|
||||
1. Check `lsusb` while the enclosure is connected.
|
||||
2. Search the vendor:product ID online -- there are community-maintained lists of which chipsets popular enclosures use.
|
||||
3. If you cannot determine the chipset and are experiencing bridge crashes, assume it is an Initio and apply the quirk with its IDs.
|
||||
4. The definitive test: connect the bare drive to a motherboard SATA port. If the problems disappear, the bridge was the cause.
|
||||
|
||||
---
|
||||
|
||||
## 5. General Debugging Checklist
|
||||
|
||||
When something goes wrong during a rip, gather this information before filing an issue:
|
||||
|
||||
1. **freemkv version:** `freemkv --version` or the crate version in `Cargo.toml`.
|
||||
2. **Drive model:** from the drive label, or from `freemkv info`.
|
||||
3. **Connection type:** USB (with bridge chipset if known) or direct SATA.
|
||||
4. **Operating system and kernel:** `uname -a`.
|
||||
5. **Kernel messages during the failure:** `dmesg | tail -50` immediately after the crash.
|
||||
6. **SCSI device:** which `/dev/sg*` the drive was on, and whether it changed after the failure.
|
||||
7. **The disc:** title, format (BD/DVD/UHD), condition.
|
||||
|
||||
Include all of the above in bug reports. SCSI transport errors that resolve with the `US_FL_IGNORE_RESIDUE` quirk or by switching to direct SATA are bridge firmware bugs, not freemkv bugs.
|
||||
+10
-5
@@ -8,13 +8,12 @@ fn main() {
|
||||
let device = std::env::args()
|
||||
.skip(1)
|
||||
.find(|a| !a.starts_with('-'))
|
||||
.unwrap_or_else(|| {
|
||||
let drives = libfreemkv::find_drives();
|
||||
if drives.is_empty() {
|
||||
.unwrap_or_else(|| match libfreemkv::find_drive() {
|
||||
Some(d) => d.device_path().to_string(),
|
||||
None => {
|
||||
eprintln!("No drives found");
|
||||
std::process::exit(1);
|
||||
}
|
||||
drives[0].device_path().to_string()
|
||||
});
|
||||
|
||||
let mut drive = Drive::open(Path::new(&device)).unwrap_or_else(|e| {
|
||||
@@ -44,9 +43,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,5 +3,34 @@ fn main() {
|
||||
if target == "macos" {
|
||||
println!("cargo:rustc-link-lib=framework=IOKit");
|
||||
println!("cargo:rustc-link-lib=framework=CoreFoundation");
|
||||
|
||||
let out_dir = std::env::var("OUT_DIR").unwrap();
|
||||
let obj = format!("{out_dir}/macos_shim.o");
|
||||
let lib = format!("{out_dir}/libmacos_scsi.a");
|
||||
|
||||
std::process::Command::new("cc")
|
||||
.args([
|
||||
"-c",
|
||||
"src/scsi/macos_shim.c",
|
||||
"-o",
|
||||
&obj,
|
||||
"-framework",
|
||||
"IOKit",
|
||||
"-framework",
|
||||
"CoreFoundation",
|
||||
"-Wall",
|
||||
"-O2",
|
||||
])
|
||||
.status()
|
||||
.expect("failed to compile macos_shim.c");
|
||||
|
||||
std::process::Command::new("ar")
|
||||
.args(["rcs", &lib, &obj])
|
||||
.status()
|
||||
.expect("failed to create static lib");
|
||||
|
||||
println!("cargo:rustc-link-search=native={out_dir}");
|
||||
println!("cargo:rustc-link-lib=static=macos_scsi");
|
||||
println!("cargo:rerun-if-changed=src/scsi/macos_shim.c");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ Technical documentation for [libfreemkv](https://github.com/freemkv/libfreemkv),
|
||||
|----------|---------------|
|
||||
| [Architecture](architecture.md) | Module map, design principles, error codes, platform support |
|
||||
| [Drive Access](drive-access.md) | Drive, SCSI transport, profiles, unlock, why raw mode is needed |
|
||||
| [Rip Recovery](rip-recovery.md) | Three-layer recovery model: Disc::patch, single-shot Drive::read, DiscStream batch halving |
|
||||
| [AACS Encryption](aacs.md) | Key resolution (4 paths), content decryption, bus encryption, SCSI handshake |
|
||||
| [UDF Filesystem](udf.md) | UDF 2.50 with metadata partitions, pointer chain, how files are read from disc |
|
||||
| [MPLS Playlists](mpls.md) | Playlist format, play items, STN stream table, coding types |
|
||||
|
||||
+72
-40
@@ -44,35 +44,30 @@ while let Ok(Some(frame)) = input.read() {
|
||||
output.finish()?;
|
||||
```
|
||||
|
||||
The `pes::Stream` trait:
|
||||
The `FrameSource` and `FrameSink` traits — direction is type-checked, so
|
||||
calling `read()` on a write-only sink (or `write()` on a read-only source)
|
||||
is a compile error rather than a runtime fault:
|
||||
|
||||
```rust
|
||||
pub trait Stream {
|
||||
fn read(&mut self) -> io::Result<Option<PesFrame>>;
|
||||
fn write(&mut self, frame: &PesFrame) -> io::Result<()>;
|
||||
fn finish(&mut self) -> io::Result<()>;
|
||||
pub trait FrameSource: Send {
|
||||
fn read(&mut self) -> Result<Option<PesFrame>, Error>;
|
||||
fn info(&self) -> &DiscTitle;
|
||||
fn codec_private(&self, track: usize) -> Option<Vec<u8>>;
|
||||
fn headers_ready(&self) -> bool;
|
||||
fn codec_private(&self, track: usize) -> Option<Vec<u8>> { None }
|
||||
fn headers_ready(&self) -> bool { true }
|
||||
}
|
||||
```
|
||||
|
||||
## IOStream (byte-level API)
|
||||
|
||||
For raw byte copies (disc→ISO, resume, benchmarks). Lower level than PES.
|
||||
|
||||
```rust
|
||||
let opts = InputOptions::default();
|
||||
let mut input = open_input("iso://Disc.iso", &opts)?;
|
||||
let mut output = open_output("mkv://Movie.mkv", input.info())?;
|
||||
io::copy(&mut *input, &mut *output)?;
|
||||
output.finish()?;
|
||||
pub trait FrameSink: Send {
|
||||
fn write(&mut self, frame: &PesFrame) -> Result<(), Error>;
|
||||
fn finish(self: Box<Self>) -> Result<(), Error>;
|
||||
fn info(&self) -> &DiscTitle;
|
||||
}
|
||||
```
|
||||
|
||||
## Streams
|
||||
|
||||
All streams implement `IOStream` (byte-level) and/or `pes::Stream` (frame-level).
|
||||
URL-based resolvers open any stream by string.
|
||||
All streams implement `FrameSource` (read) and/or `FrameSink` (write); the
|
||||
directional split prevents runtime "wrong-direction" errors. URL-based
|
||||
resolvers open any stream by string.
|
||||
|
||||
| Stream | Input | Output | URL | Transport |
|
||||
|--------|-------|--------|-----|-----------|
|
||||
@@ -87,21 +82,14 @@ URL-based resolvers open any stream by string.
|
||||
All URLs require a `scheme://path` format. Bare paths are rejected.
|
||||
|
||||
```rust
|
||||
// PES pipeline (frame-level)
|
||||
// PES pipeline (frame-level) — input() returns Box<dyn FrameSource>,
|
||||
// output() returns Box<dyn FrameSink>.
|
||||
let input = libfreemkv::input("disc:///dev/sg4", &opts)?; // DiscStream
|
||||
let input = libfreemkv::input("iso://Dune.iso", &opts)?; // IsoStream
|
||||
let output = libfreemkv::output("mkv://Dune.mkv", &title)?; // MkvOutputStream
|
||||
let output = libfreemkv::output("m2ts://Dune.m2ts", &title)?; // M2tsOutputStream
|
||||
let output = libfreemkv::output("network://10.0.0.1:9000", &title)?; // NetworkOutputStream
|
||||
let output = libfreemkv::output("null://", &title)?; // NullOutputStream
|
||||
|
||||
// IOStream (byte-level)
|
||||
let input = open_input("disc://", &opts)?; // DiscStream
|
||||
let input = open_input("iso://Dune.iso", &opts)?; // IsoStream
|
||||
let output = open_output("iso://Copy.iso", &meta)?; // IsoStream (write)
|
||||
let output = open_output("mkv://Dune.mkv", &meta)?; // MkvStream
|
||||
let output = open_output("m2ts://Dune.m2ts", &meta)?; // M2tsStream
|
||||
let output = open_output("null://", &meta)?; // NullStream
|
||||
```
|
||||
|
||||
### FMKV Metadata Header
|
||||
@@ -126,15 +114,45 @@ pub struct Event {
|
||||
}
|
||||
|
||||
pub enum EventKind {
|
||||
// Init / scan
|
||||
DriveOpened { device: String },
|
||||
DriveReady,
|
||||
InitComplete { success: bool },
|
||||
ProbeComplete { success: bool },
|
||||
ScanComplete { titles: usize },
|
||||
|
||||
// Read pipeline
|
||||
BytesRead { bytes: u64, total: u64 },
|
||||
ReadError { sector: u64, error: Error },
|
||||
Retry { attempt: u32 },
|
||||
SpeedChange { speed_kbs: u16 },
|
||||
ExtentStart { index: usize, start_sector: u64, sector_count: u64 },
|
||||
SectorSkipped { sector: u64 },
|
||||
BatchSizeChanged { new_size: u16, reason: BatchSizeReason },
|
||||
Complete { bytes: u64, errors: u32 },
|
||||
|
||||
// Kept for forward-compat; not emitted in 0.13.6+
|
||||
Retry { attempt: u32 },
|
||||
SectorRecovered { sector: u64 },
|
||||
}
|
||||
```
|
||||
|
||||
Emission notes:
|
||||
|
||||
- `BytesRead { bytes, total }` is emitted from `DiscStream::fill_extents`
|
||||
after each successful sector read. `bytes` is the cumulative running
|
||||
total; `total` is the precomputed extent sum (0 if unknown).
|
||||
- `SpeedChange` is emitted from the public `Drive::set_speed` API path.
|
||||
It is no longer emitted from a recovery hot loop (recovery loop removed
|
||||
in 0.13.6).
|
||||
- `BatchSizeChanged` fires from the `DiscStream` adaptive sizer on shrink
|
||||
(read failed at a larger size) and on probe-up (clean-read streak hit
|
||||
the threshold). Consumers use it to display a "recovering" state
|
||||
distinct from "ripping normally".
|
||||
- `Retry` and `SectorRecovered` are NOT emitted in 0.13.6+. They were
|
||||
tied to the inline `Drive::read` recovery phases that were removed; the
|
||||
variants are kept for forward compatibility so consumers' match arms
|
||||
don't need conditional compilation.
|
||||
|
||||
Events report what happened. App decides what to do. GUI shows a dialog. CLI
|
||||
prints a line. Server logs to file.
|
||||
|
||||
@@ -145,20 +163,34 @@ libfreemkv/src/
|
||||
├── lib.rs Public exports
|
||||
├── error.rs Error codes (no English)
|
||||
├── event.rs Event types for callbacks
|
||||
├── drive/ Drive (open, init, read with recovery)
|
||||
│ ├── mod.rs Drive struct, init, read, reset, eject
|
||||
├── halt.rs Halt cancellation token (Arc<AtomicBool> wrapper)
|
||||
├── io/ Pipeline + WritebackFile primitives
|
||||
│ ├── mod.rs Re-exports WritebackFile, Pipeline, Sink, Flow
|
||||
│ ├── pipeline.rs Generic Pipeline<I, R> + Sink trait
|
||||
│ ├── writeback_file.rs WritebackFile (was crate::io::Writer)
|
||||
│ └── writeback.rs sync_file_range pipeline
|
||||
├── drive/ Drive (open, init, single-shot read)
|
||||
│ ├── mod.rs Drive struct, init, read (single-shot), reset, eject
|
||||
│ ├── capture.rs Drive profile capture for contribution
|
||||
│ ├── linux.rs Linux drive discovery
|
||||
│ ├── macos.rs macOS drive discovery
|
||||
│ └── windows.rs Windows drive discovery
|
||||
├── disc/ Disc (scan, titles, AACS setup)
|
||||
├── disc/ Disc (scan, titles, AACS setup, sweep, patch)
|
||||
│ ├── mod.rs Disc struct, scan, titles, formats
|
||||
│ ├── sweep.rs Disc::sweep (Pass 1 forward sweep)
|
||||
│ ├── patch.rs Disc::patch (Pass N retry over mapfile)
|
||||
│ ├── mapfile.rs ddrescue-format mapfile
|
||||
│ └── read_error.rs ReadCtx / ReadAction state machine
|
||||
├── scsi/ SCSI transport (Linux SG_IO, macOS IOKit, Windows SPTI)
|
||||
├── platform/ Drive unlock (MT1959 A/B)
|
||||
├── aacs/ AACS decryption (handshake, keys, keydb, decrypt)
|
||||
├── css/ DVD CSS cipher
|
||||
├── decrypt.rs Unified decrypt dispatcher (AACS/CSS/None)
|
||||
├── pes.rs PES frame types, Stream trait
|
||||
├── sector.rs SectorReader trait
|
||||
├── pes.rs PES frame types, FrameSource / FrameSink traits
|
||||
├── sector/ Sector I/O (was sector.rs in 0.17)
|
||||
│ ├── mod.rs SectorSource, SectorSink traits
|
||||
│ ├── file.rs FileSectorSource, FileSectorSink (ISO-backed)
|
||||
│ └── decrypting.rs DecryptingSectorSource decorator
|
||||
├── udf.rs UDF 2.50 filesystem parser
|
||||
├── mpls.rs MPLS playlist parser
|
||||
├── clpi.rs CLPI clip info parser
|
||||
@@ -169,15 +201,15 @@ libfreemkv/src/
|
||||
├── profile.rs Bundled drive profiles
|
||||
├── speed.rs DriveSpeed enum
|
||||
├── mux/
|
||||
│ ├── mod.rs IOStream trait, public exports
|
||||
│ ├── resolve.rs URL parser + open_input/open_output + input/output
|
||||
│ ├── mod.rs Public mux exports
|
||||
│ ├── resolve.rs URL parser + input/output (Box<dyn FrameSource/Sink>)
|
||||
│ ├── meta.rs FMKV header format
|
||||
│ ├── disc.rs DiscStream (optical drive → PES)
|
||||
│ ├── iso.rs IsoStream (ISO image read/write)
|
||||
│ ├── iso.rs IsoStream (ISO image read)
|
||||
│ ├── isowriter.rs ISO image writer (UDF, AVDP, multi-extent)
|
||||
│ ├── mkvstream.rs MkvStream (bidirectional Matroska, IOStream)
|
||||
│ ├── mkvstream.rs MkvStream (bidirectional Matroska)
|
||||
│ ├── mkvout.rs MkvOutputStream (PES → MKV)
|
||||
│ ├── m2ts.rs M2tsStream (BD-TS, IOStream)
|
||||
│ ├── m2ts.rs M2tsStream (BD-TS)
|
||||
│ ├── pesout.rs PES output streams (M2ts, Network, Stdio, Null)
|
||||
│ ├── network.rs NetworkStream (TCP + FMKV header)
|
||||
│ ├── stdio.rs StdioStream (stdin/stdout pipe)
|
||||
|
||||
+18
-7
@@ -41,8 +41,8 @@ AACS keys are derived internally, and all SCSI communication is handled in-proce
|
||||
libfreemkv (lib.rs)
|
||||
│
|
||||
├── Drive Access
|
||||
│ ├── drive Drive — open, identify, init, unlock, read (with recovery)
|
||||
│ ├── scsi ScsiTransport trait + platform backends (SG_IO, IOKit, SPTI)
|
||||
│ ├── drive Drive — open, identify, init, unlock, single-shot read
|
||||
│ ├── scsi ScsiTransport trait + platform backends (sg async, IOKit, SPTI)
|
||||
│ ├── platform/ Platform trait — per-chipset command handlers
|
||||
│ │ └── mt1959 MediaTek MT1959 driver (LG, ASUS, HP)
|
||||
│ ├── profile DriveProfile loading, matching, bundled JSON
|
||||
@@ -65,8 +65,12 @@ libfreemkv (lib.rs)
|
||||
│
|
||||
├── Streaming
|
||||
│ ├── mux/ Stream implementations (Disc, ISO, MKV, M2TS, Network, Stdio, Null)
|
||||
│ ├── pes PES frame types, Stream trait (read/write frames)
|
||||
│ └── sector SectorReader trait — abstracts disc vs ISO vs file
|
||||
│ ├── pes PES frame types; FrameSource / FrameSink direction-typed traits
|
||||
│ └── sector/ SectorSource / SectorSink traits, FileSector{Source,Sink}, DecryptingSectorSource
|
||||
│
|
||||
├── I/O Primitives
|
||||
│ ├── halt Halt cancellation token (one Arc<AtomicBool>, cloneable)
|
||||
│ └── io/ Pipeline<I, R> + Sink trait + WritebackFile (bounded-cache writer)
|
||||
│
|
||||
├── Support
|
||||
│ ├── keydb KEYDB.cfg download, parse, verify, save
|
||||
@@ -83,7 +87,7 @@ libfreemkv (lib.rs)
|
||||
```
|
||||
Drive::open(Path::new("/dev/sg4"))
|
||||
│
|
||||
├─ scsi::open() Open /dev/sg4 via SG_IO
|
||||
├─ scsi::open() Open /dev/sg4 (async write/poll/read)
|
||||
├─ DriveId::from_drive() INQUIRY + GET_CONFIG 010C
|
||||
├─ profile::find_by_drive_id() Match against bundled profiles
|
||||
├─ Platform::new() Instantiate chipset driver (Mt1959)
|
||||
@@ -93,10 +97,17 @@ Drive::open(Path::new("/dev/sg4"))
|
||||
After open:
|
||||
- `init()` -- unlock + firmware upload + speed calibration
|
||||
- `probe_disc()` -- probe disc surface for optimal speeds
|
||||
- `read(lba, count, buf)` -- single read method with built-in error recovery
|
||||
- `read(lba, count, buf, recovery)` -- single-shot read; `recovery` only selects the per-CDB timeout (1.5 s vs. 30 s)
|
||||
- `wait_ready()` -- wait for disc insertion
|
||||
- `eject()` -- eject tray
|
||||
|
||||
Recovery is layered above `Drive::read`, not inside it. Layer 1
|
||||
(`Disc::patch`) handles bad-range retry by replaying the ddrescue mapfile.
|
||||
Layer 3 (`DiscStream::fill_extents` adaptive batch sizer) handles in-loop
|
||||
request-size adaptation. Inline recovery (gentle retry → SCSI reset → retry)
|
||||
was removed in 0.13.6 — see [`rip-recovery.md`](rip-recovery.md) and
|
||||
the stop-wedge postmortem (2026-04-25).
|
||||
|
||||
---
|
||||
|
||||
## Disc Scanning Flow
|
||||
@@ -175,7 +186,7 @@ is baked into the library.
|
||||
|
||||
| Platform | Transport | Status |
|
||||
|----------|-----------|--------|
|
||||
| Linux | SG_IO ioctl on `/dev/sg*` | Supported |
|
||||
| Linux | async sg write/poll/read on `/dev/sg*` | Supported |
|
||||
| macOS | IOKit SCSITask | Supported |
|
||||
| Windows | SPTI (`IOCTL_SCSI_PASS_THROUGH_DIRECT`) | Supported |
|
||||
|
||||
|
||||
+12
-6
@@ -77,7 +77,9 @@ Insert disc
|
||||
10. Stream content (mux/disc.rs → DiscStream)
|
||||
│ Read sectors → decrypt → TS demux → PES frames
|
||||
│ Or: read sectors → decrypt → raw bytes (for ISO output)
|
||||
│ Drive::read() handles error recovery (min speed → reset → retry)
|
||||
│ Drive::read() is single-shot. DiscStream::fill_extents adapts the
|
||||
│ batch size on failure (halve / probe-up). Bad-range retry is layer
|
||||
│ 1 above this — Disc::patch re-runs against the mapfile.
|
||||
│
|
||||
▼
|
||||
PES frames → output stream (MKV, M2TS, network, etc.)
|
||||
@@ -95,7 +97,9 @@ drive.probe_disc()?;
|
||||
// Scan disc (UDF + playlists + AACS — all automatic)
|
||||
let disc = Disc::scan(&mut drive, &ScanOptions::default())?;
|
||||
|
||||
// Stream pipeline — PES frames from any source to any output
|
||||
// Stream pipeline — PES frames from any source to any output.
|
||||
// 0.18: input() returns Box<dyn FrameSource>, output() returns Box<dyn FrameSink>;
|
||||
// direction is type-checked, so calling .write() on an input is a compile error.
|
||||
let opts = InputOptions::default();
|
||||
let mut input = libfreemkv::input("disc:///dev/sg4", &opts)?;
|
||||
let title = input.info().clone();
|
||||
@@ -110,7 +114,7 @@ output.finish()?;
|
||||
|
||||
| Module | Doc | Purpose |
|
||||
|--------|-----|---------|
|
||||
| drive/ | [drive-access.md](drive-access.md) | Open, identify, init, unlock, read (with recovery) |
|
||||
| drive/ | [drive-access.md](drive-access.md) | Open, identify, init, unlock, single-shot read |
|
||||
| scsi/ | [drive-access.md](drive-access.md) | Platform SCSI transport (Linux, macOS, Windows) |
|
||||
| udf.rs | [udf.md](udf.md) | UDF 2.50 filesystem |
|
||||
| mpls.rs | [mpls.md](mpls.md) | MPLS playlists + STN streams |
|
||||
@@ -119,11 +123,13 @@ output.finish()?;
|
||||
| aacs/ | [aacs.md](aacs.md) | Key resolution + content decrypt + bus handshake |
|
||||
| css/ | -- | DVD CSS cipher |
|
||||
| decrypt.rs | -- | Unified decrypt dispatcher (AACS/CSS/None) |
|
||||
| disc/ | -- | High-level scan + read API |
|
||||
| disc/ | [rip-recovery.md](rip-recovery.md) | Disc::scan + Disc::sweep + Disc::patch + mapfile |
|
||||
| labels/ | -- | BD-J stream labels (5 format parsers) |
|
||||
| mux/ | -- | Stream implementations (7 stream types) |
|
||||
| pes.rs | -- | PES frame types + Stream trait |
|
||||
| sector.rs | -- | SectorReader trait |
|
||||
| pes.rs | -- | PES frame types + FrameSource / FrameSink traits |
|
||||
| sector/ | -- | SectorSource / SectorSink + DecryptingSectorSource decorator |
|
||||
| io/ | -- | Pipeline<I, R> + Sink trait + WritebackFile |
|
||||
| halt.rs | -- | Halt cancellation token |
|
||||
| keydb.rs | -- | KEYDB download, parse, save |
|
||||
| error.rs | -- | Error codes (E1xxx-E8xxx) |
|
||||
| event.rs | -- | Drive event system |
|
||||
|
||||
+57
-18
@@ -26,8 +26,8 @@ platform driver. The drive is ready for `wait_ready()` and `init()`.
|
||||
| `wait_ready()` | Wait for disc insertion (30s timeout, TUR polling) |
|
||||
| `init()` | Firmware upload + unlock + speed calibration |
|
||||
| `probe_disc()` | Probe disc surface for optimal speeds |
|
||||
| `read(lba, count, buf)` | Read sectors with built-in error recovery |
|
||||
| `reset()` | Close/reopen device, TUR, escalate if needed |
|
||||
| `read(lba, count, buf, recovery)` | Read sectors. Single-shot — no inline retries or reset. |
|
||||
| `reset()` | Eject-cycle escape hatch. Caller-invoked only; not on the read path. |
|
||||
| `lock_tray()` | Prevent tray ejection during rip |
|
||||
| `unlock_tray()` | Allow tray ejection (also runs on Drop) |
|
||||
| `eject()` | Eject disc tray |
|
||||
@@ -44,16 +44,33 @@ platform driver. The drive is ready for `wait_ready()` and `init()`.
|
||||
3. Speed calibration after unlock
|
||||
4. Max 3 attempts before giving up
|
||||
|
||||
### read() with Recovery
|
||||
### read() — single-shot
|
||||
|
||||
`Drive::read()` is the single read method. On error:
|
||||
`Drive::read(lba, count, buf, recovery)` is the single read method. It issues
|
||||
exactly one READ(10) CDB and returns the result. The `recovery` parameter only
|
||||
selects the per-CDB timeout:
|
||||
|
||||
1. Set minimum speed immediately
|
||||
2. Reset device (close/reopen/TUR)
|
||||
3. Wait 2s for drive to settle
|
||||
4. Retry at min speed, min batch (3 sectors)
|
||||
5. If still failing: skip sectors, zero-fill, log
|
||||
6. Stay at min speed for 500 MB after error (recovery window)
|
||||
| `recovery` | Timeout | Used by |
|
||||
|------------|----------|------------------------------------------|
|
||||
| `false` | 1.5 s | `Disc::sweep` fast skip-forward pass, `DiscStream::fill_extents` |
|
||||
| `true` | 30 s | `Disc::patch` retry pass over the mapfile |
|
||||
|
||||
On any SCSI failure or timeout, `read` returns `Err(DiscRead)` immediately.
|
||||
There are no inline retries, no SCSI reset, no Phase 1/2/3 escalation.
|
||||
|
||||
Recovery is layered above `Drive::read`:
|
||||
|
||||
- **Layer 1 — `Disc::patch`** loops over the ddrescue mapfile and re-issues
|
||||
`read(.., recovery=true)` against each non-`+` range.
|
||||
- **Layer 3 — `DiscStream::fill_extents`** halves the request size on
|
||||
failure, retries at the same LBA, and probes back up on a clean-read
|
||||
streak.
|
||||
|
||||
Inline recovery (5× gentle retry → close + reset + reopen → 5× more) was
|
||||
removed in 0.13.6. See the stop-wedge postmortem (2026-04-25)
|
||||
for rationale: the inline reset wedged drive firmware on the LG BU40N (Initio
|
||||
USB-SATA bridge) without ever recovering a sector. See
|
||||
[`rip-recovery.md`](rip-recovery.md) for the full three-layer model.
|
||||
|
||||
---
|
||||
|
||||
@@ -70,8 +87,6 @@ pub trait ScsiTransport: Send {
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult>;
|
||||
|
||||
fn reset(&mut self, device: &str) -> Result<()>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -82,15 +97,39 @@ descriptors or calls ioctls outside of a `ScsiTransport` implementation.
|
||||
|
||||
| Platform | Implementation | Device |
|
||||
|----------|---------------|--------|
|
||||
| Linux | `SgIoTransport` — `ioctl(fd, SG_IO, &hdr)` | `/dev/sg*` |
|
||||
| Linux | `SgIoTransport` — async `write`/`poll`/`read` on `/dev/sg*` | `/dev/sg*` |
|
||||
| macOS | `MacScsiTransport` — IOKit SCSITask | IOKit service |
|
||||
| Windows | `WindowsScsiTransport` — SPTI | `\\.\CdRomN` |
|
||||
|
||||
The Linux backend opens with `O_RDWR | O_NONBLOCK`, constructs `sg_io_hdr`,
|
||||
and returns `ScsiResult` with status, bytes transferred, and sense data.
|
||||
The Linux backend uses the sg driver's asynchronous interface: `write()` submits
|
||||
the command, `poll()` waits with an enforceable wall-clock timeout, `read()`
|
||||
retrieves the result. If `poll()` times out, the fd is abandoned (closed in a
|
||||
background thread) and a fresh fd opened — the kernel's USB error recovery
|
||||
cannot block us. Opens with `O_RDWR | O_NONBLOCK`.
|
||||
|
||||
On non-zero SCSI status, the transport parses sense key, ASC, and ASCQ from the
|
||||
sense buffer and returns `Error::ScsiError`.
|
||||
The macOS backend uses a C shim (`macos_shim.c`) for IOKit exclusive access.
|
||||
The shim handles:
|
||||
1. `shim_open_exclusive(bsd_name)` — unmounts the target device via `diskutil`,
|
||||
then walks the IOKit registry to find the `IOBDServices` matching the
|
||||
requested BSD name (IOBDServices → IOBDBlockStorageDriver → IOMedia → "BSD Name"),
|
||||
then creates MMCDeviceInterface → SCSITaskDeviceInterface → ObtainExclusiveAccess.
|
||||
2. `shim_list_drives()` — registry-based enumeration with zero SCSI, zero exclusive
|
||||
access, zero unmounts. Reads IOBDServices "Device Characteristics" for
|
||||
vendor/model/firmware and child IOMedia "BSD Name" for the device path.
|
||||
3. `shim_execute()` / `shim_close()` — raw CDB dispatch and cleanup.
|
||||
|
||||
On non-zero SCSI status, the transport parses sense key from the sense buffer
|
||||
and returns `Error::ScsiError`.
|
||||
|
||||
`SgIoTransport::reset` (Linux) does pure userspace state cleanup: an open +
|
||||
close pair to make the kernel cancel any SG_IO commands queued against a
|
||||
previous fd, a 2 s sleep to let the kernel finish that cancellation, then a
|
||||
fresh fd to send ALLOW MEDIUM REMOVAL to clear any stale tray lock. It does
|
||||
NOT issue `SG_SCSI_RESET` or escalate via STOP+START UNIT. Both were tried
|
||||
in 0.13.0–0.13.5 against the LG BU40N (Initio USB-SATA bridge); both failed
|
||||
to recover wedged drives and made the wedge worse. The macOS reset (which
|
||||
had been a no-op) was removed entirely in 0.13.6, and the top-level
|
||||
`scsi::reset()` / `reset_with_timeout()` / `reset_blocking()` wrappers were
|
||||
removed at the same time (no callers).
|
||||
|
||||
### CDB Builders
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# Rip recovery — three-layer architecture
|
||||
|
||||
`libfreemkv` supports a multi-stage rip model for damaged or protection-bearing
|
||||
discs: a fast forward sweep that tolerates read failures, in-loop request-size
|
||||
adaptation that survives transient drive trouble without bailing, and targeted
|
||||
retry passes against a persistent bad-range map. The stream pipeline
|
||||
(`DiscStream` + `input`/`output`) operates against the resulting ISO image, so
|
||||
the mux stage never touches the drive.
|
||||
|
||||
Recovery is layered cleanly. Each layer has one responsibility and does not
|
||||
reach into the others.
|
||||
|
||||
| Layer | Where it lives | What it does |
|
||||
|-------|---------------|--------------|
|
||||
| 1 — Bad-range retry | `Disc::patch` (one pass over the mapfile per call) | Re-reads non-`+` ranges with the long timeout. Idempotent; caller invokes N times. |
|
||||
| 2 — Single-shot primitive | `Drive::read` in `src/drive/mod.rs` | One CDB, one timeout, one result. No inline retries, no SCSI reset. |
|
||||
| 3 — In-loop request adaptation | `DiscStream::fill_extents` adaptive batch sizer | Halves the batch on failure, retries at the same LBA, walks back up on a clean-read streak. |
|
||||
|
||||
The library exposes flat verbs; the caller drives the multipass loop. Autorip
|
||||
runs `Disc::sweep` once, then loops `Disc::patch` until either the mapfile is
|
||||
clean or the configured retry budget is exhausted, then hands the ISO off to
|
||||
the mux pipeline. The `freemkv` CLI does the same shape with a
|
||||
terminal-output progress sink. Layer 3 runs inside any consumer of
|
||||
`DiscStream` (direct PES pipeline, ISO playback, etc.) without caller
|
||||
involvement.
|
||||
|
||||
Three primitives compose the disc-side flow:
|
||||
|
||||
| Primitive | What it does |
|
||||
|---------------------------|-----------------------------------------------------------------------|
|
||||
| `Disc::sweep` | disc → ISO, one forward pass. Writes a sidecar `.mapfile`. Opt-in skip-on-error. |
|
||||
| `Disc::patch` | Re-reads bad ranges from the drive. One pass per call; caller invokes N times. |
|
||||
| `DiscStream` (ISO source) | Reads sectors from the ISO, feeds decrypt → demux → codec → mux. |
|
||||
|
||||
## Data model
|
||||
|
||||
### Mapfile
|
||||
|
||||
Format: [ddrescue](https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html)-compatible
|
||||
plain text, greppable, tool-interoperable. Flushed to disk on every `record()`
|
||||
so a crashed rip loses at most one block.
|
||||
|
||||
```
|
||||
# Rescue Logfile. Created by libfreemkv v0.13.6
|
||||
# Current pos / status / pass / pass_time
|
||||
0x000000000 ? 1 0
|
||||
# pos size status
|
||||
0x000000000 0x12a35d000 +
|
||||
0x12a35d000 0x000003000 -
|
||||
0x12a360000 0x009c4a000 +
|
||||
0x12d00a000 0x000064000 *
|
||||
```
|
||||
|
||||
Status characters match ddrescue:
|
||||
|
||||
| Char | Meaning |
|
||||
|------|----------------------------------------------------|
|
||||
| `?` | Not yet attempted |
|
||||
| `*` | Fast-pass failed; needs edge-trim |
|
||||
| `/` | Trimmed; interior needs sector scrape |
|
||||
| `-` | Unreadable this session |
|
||||
| `+` | Finished (good) |
|
||||
|
||||
Position and size are hex byte offsets into the ISO.
|
||||
|
||||
### `SweepOptions` and `PatchOptions`
|
||||
|
||||
The library no longer dispatches between sweep and patch internally — the
|
||||
caller picks the verb explicitly per pass. The two option structs are flat
|
||||
and have no overlap:
|
||||
|
||||
```rust
|
||||
SweepOptions {
|
||||
decrypt: true,
|
||||
resume: false,
|
||||
batch_sectors: None,
|
||||
skip_on_error: true, // damage-jump + zero-fill on read failure
|
||||
progress: Some(&reporter),
|
||||
halt: Some(flag),
|
||||
}
|
||||
|
||||
PatchOptions {
|
||||
decrypt: true,
|
||||
block_sectors: None,
|
||||
full_recovery: true,
|
||||
reverse: true, // walk bad ranges high → low LBA
|
||||
wedged_threshold: 50,
|
||||
progress: Some(&reporter),
|
||||
halt: Some(flag),
|
||||
}
|
||||
```
|
||||
|
||||
Caller-orchestrated dispatch (the policy `Disc::copy` used to embed):
|
||||
|
||||
- No mapfile → `sweep` (fresh Pass 1).
|
||||
- Mapfile with `?` ranges → `sweep` with `resume: true`.
|
||||
- Mapfile covers full disc, only `*` / `/` / `-` ranges → `patch`.
|
||||
- Mapfile clean → done; no further pass needed.
|
||||
|
||||
Each consumer (autorip, `freemkv` CLI) implements the loop in roughly five
|
||||
lines of `Mapfile::stats()` checks.
|
||||
|
||||
## Algorithm
|
||||
|
||||
### Pass 1 — fast sweep (`Disc::sweep`)
|
||||
|
||||
1. Read one ECC block (32 sectors for UHD, 16 for BD/DVD) at the current LBA.
|
||||
2. On success: write data to ISO, mark `+`, advance.
|
||||
3. On failure (with `multipass`): zero-fill, mark `*`, advance.
|
||||
4. Track a sliding window of the last 16 ECC block results. When ≥12% are failures
|
||||
→ **damage-jump**: skip ahead by `256×batch×multiplier` sectors (8 MB base for
|
||||
UHD). Double the multiplier on each jump (8→16→32→64 MB...). Zero-fill the gap as `*`.
|
||||
5. On 16 consecutive good reads: reset jump multiplier to 1, restore max read speed.
|
||||
6. Speed control: damage zone entry → minimum speed, exit → maximum speed.
|
||||
7. Only transport failures (USB bridge crash) abort the pass.
|
||||
|
||||
Pass 1 completes when every byte has been visited (either `+` or `*`).
|
||||
|
||||
### Pass 2+ — patch (`Disc::patch`)
|
||||
|
||||
`Disc::patch` reads the mapfile and iterates every non-`+` range. Default: **reverse** mode
|
||||
(walks ranges from highest LBA to lowest, within each range from end to start).
|
||||
|
||||
1. Issue a single-sector read with 60 s timeout (`recovery=true`). Drive firmware
|
||||
does its own ECC recovery inside that window.
|
||||
2. On success: write the good bytes into the ISO, mark `+`.
|
||||
3. On failure with non-marginal SCSI sense: bail immediately (drive won't produce data).
|
||||
4. On failure with marginal sense: mark `-`, continue.
|
||||
5. Update the mapfile after every block — crash-safe resume.
|
||||
6. Wedged-drive exit: 50 consecutive failures with zero recovery → bail this pass.
|
||||
|
||||
### In-stream — adaptive batch halving (`DiscStream::fill_extents`)
|
||||
|
||||
When a consumer reads a `DiscStream` directly (no ISO intermediate),
|
||||
`fill_extents` runs an adaptive sizer in front of `Drive::read`:
|
||||
|
||||
1. Try the current preferred batch size (e.g. 32 sectors, one BD ECC block).
|
||||
2. On failure: halve the batch and retry at the same LBA. Emit
|
||||
`EventKind::BatchSizeChanged { reason: Shrunk }`.
|
||||
3. On a clean-read streak: probe back up toward the preferred size. Emit
|
||||
`EventKind::BatchSizeChanged { reason: Probed }`.
|
||||
4. If a single-sector read fails: skip (zero-fill, emit
|
||||
`EventKind::SectorSkipped`) when `skip_errors` is set, otherwise return
|
||||
`Err(DiscRead)`.
|
||||
|
||||
This is layer 3. It exists so a transient single-sector glitch in a 32-sector
|
||||
batch can be isolated and read individually without the caller needing to
|
||||
implement retry logic.
|
||||
|
||||
## Design choices
|
||||
|
||||
**`Drive::read` is single-shot.** No inline retry phases, no SCSI reset,
|
||||
no eject cycle. The `recovery` flag controls only the per-CDB timeout
|
||||
(1.5 s vs. 30 s); on any failure it returns `Err(DiscRead)` immediately.
|
||||
Inline recovery (5× gentle retry → close + SCSI reset + reopen → 5× more)
|
||||
was removed in 0.13.6. See the stop-wedge postmortem (2026-04-25) for rationale:
|
||||
the inline reset on the LG BU40N (Initio USB-SATA bridge)
|
||||
wedged drive firmware below the bridge without ever recovering a sector,
|
||||
and the gentle-retry phase produced long stretches of 0 KB/s with no
|
||||
recoveries to show for it. Recovery responsibility is now layered: layer 1
|
||||
handles ranges, layer 3 handles request size, neither touches the
|
||||
wedge-prone reset path.
|
||||
|
||||
**No `MODE SELECT` to disable drive retries.** Research showed neither ddrescue
|
||||
nor MakeMKV does this. Drive firmware has access to raw analog signal, laser
|
||||
power control, and drive-specific ECC tuning that userspace can't replicate —
|
||||
disabling it throws away recovery headroom on marginal sectors. We fail fast
|
||||
via short SG_IO timeouts in pass 1 and let the firmware work the long timeout
|
||||
in pass 2 / patch.
|
||||
|
||||
**No SCSI reset from any retry path.** `SgIoTransport::reset` (Linux) is
|
||||
trimmed to a kernel SG_IO state flush plus ALLOW MEDIUM REMOVAL — the
|
||||
`SG_SCSI_RESET` ioctl and STOP/START UNIT escalation were removed in 0.13.6.
|
||||
The macOS reset (which had been a no-op) was removed entirely. The top-level
|
||||
`scsi::reset()` / `reset_with_timeout()` / `reset_blocking()` wrappers were
|
||||
also removed (no callers). The remaining `Drive::reset()` is only invoked
|
||||
explicitly by callers that need an eject-cycle escape hatch — it is never
|
||||
reached from a read path.
|
||||
|
||||
**ISO intermediate, even for single-pass.** Pass 1 always writes an ISO. The
|
||||
mux stage reads the ISO via `IsoSectorReader`. For single-pass (no retries),
|
||||
this adds ~2-3 min (local disk mux) but gains resumability across crashes,
|
||||
re-muxability without re-ripping, and a persistent forensic artifact. Callers
|
||||
who need pure speed can bypass and use `DiscStream::new(Box::new(drive), …)`
|
||||
directly — the lib doesn't forbid it, and layer 3 (adaptive batch halving)
|
||||
still applies there.
|
||||
|
||||
**Mapfile in ddrescue format.** Plain text so users can `less` it, `diff` it,
|
||||
or feed it to ddrescue's own tooling. Crash-safe (flush-per-record). Entries
|
||||
coalesce on adjacent same-status ranges so files stay small.
|
||||
|
||||
**Patches target `-`, `*`, `/`, and `?` alike.** The status state machine is
|
||||
ddrescue's but `patch` collapses the distinction — it just tries every
|
||||
non-finished range with the long timeout. Future work can specialize (trim vs.
|
||||
scrape vs. retry with direction reversal) if there's measured benefit.
|
||||
|
||||
## References
|
||||
|
||||
- [ddrescue manual, Algorithm chapter](https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html)
|
||||
- [ddrescue optical media notes](https://www.electric-spoon.com/doc/gddrescue/html/Optical-media.html)
|
||||
- Source: [`src/disc/mapfile.rs`](../src/disc/mapfile.rs), [`src/disc/sweep.rs`](../src/disc/sweep.rs) (`Disc::sweep`), [`src/disc/patch.rs`](../src/disc/patch.rs) (`Disc::patch`), [`src/drive/mod.rs`](../src/drive/mod.rs) (`Drive::read`), [`src/mux/disc.rs`](../src/mux/disc.rs) (`DiscStream::fill_extents`).
|
||||
+1
-1
@@ -126,7 +126,7 @@ USB optical drives have ~500ms round-trip latency per SCSI command. Since `read_
|
||||
|
||||
`Disc::scan()` wraps the drive in a `BufferedSectorReader` before reading. On a single-sector read, the buffer prefetches a batch of sectors (sized from the kernel's `max_hw_sectors_kb` for the device) and caches them. Subsequent reads to nearby LBAs return from cache with zero SCSI overhead. After parsing the UDF directory structure, the entire metadata partition is pre-read into the cache, so all ICB lookups during title scanning and encryption resolution are instant.
|
||||
|
||||
The buffer is transparent -- `read_filesystem()`, `read_file()`, and all downstream code still call `read_sectors(lba, 1, buf)` as before. The batching happens inside the `SectorReader` implementation.
|
||||
The buffer is transparent -- `read_filesystem()`, `read_file()`, and all downstream code still call `read_sectors(lba, 1, buf)` as before. The batching happens inside the `SectorSource` implementation.
|
||||
|
||||
### UDF Filename Encoding
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ fn main() {
|
||||
// Tiny yield between reads — test if pacing prevents firmware throttle
|
||||
std::thread::yield_now();
|
||||
let t0 = Instant::now();
|
||||
let ok = drive.read(lba, count, &mut buf[..n]).is_ok();
|
||||
let ok = drive.read(lba, count, &mut buf[..n], true).is_ok();
|
||||
let read_ms = t0.elapsed().as_millis();
|
||||
|
||||
// Flag slow reads
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
//! AACS content decryption — AES primitives, unit decryption, bus encryption.
|
||||
|
||||
use aes::cipher::{generic_array::GenericArray, BlockDecrypt, BlockEncrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
|
||||
// ── AACS constants ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -674,8 +674,8 @@ fn generate_host_key_pair() -> ([u8; 20], [u8; 20], [u8; 20]) {
|
||||
|
||||
/// AES-128-CMAC over 16 bytes of data.
|
||||
fn aes_cmac_16(data: &[u8; 16], key: &[u8; 16]) -> [u8; 16] {
|
||||
use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
|
||||
|
||||
+1
-5
@@ -361,11 +361,7 @@ mod tests {
|
||||
/// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found.
|
||||
fn keydb_path() -> Option<std::path::PathBuf> {
|
||||
let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?);
|
||||
if path.exists() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
if path.exists() { Some(path) } else { None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+60
-7
@@ -247,6 +247,7 @@ fn validate_processing_key(
|
||||
/// Find Verify Media Key Record (type 0x10) in MKB.
|
||||
fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
let mut pos = 0;
|
||||
let mut type10_seen: Vec<(usize, usize)> = Vec::new();
|
||||
while pos + 4 <= mkb.len() {
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
|
||||
@@ -254,14 +255,32 @@ fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
|
||||
break;
|
||||
}
|
||||
|
||||
if rec_type == 0x10 {
|
||||
type10_seen.push((pos, rec_len));
|
||||
}
|
||||
|
||||
if rec_type == 0x10 && rec_len >= 20 {
|
||||
// mk_dv is at offset 4 (after record header)
|
||||
let mut dv = [0u8; 16];
|
||||
dv.copy_from_slice(&mkb[pos + 4..pos + 20]);
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_found",
|
||||
pos,
|
||||
rec_len,
|
||||
"mk_dv extracted from MKB"
|
||||
);
|
||||
return Some(dv);
|
||||
}
|
||||
pos += rec_len;
|
||||
}
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "mkb_mk_dv_not_found",
|
||||
type10_seen = ?type10_seen,
|
||||
scanned_bytes = pos,
|
||||
"no 0x10 record with rec_len>=20 found"
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
@@ -628,32 +647,70 @@ pub fn resolve_keys(
|
||||
}
|
||||
};
|
||||
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "resolve_keys_start",
|
||||
aacs2,
|
||||
bus_encryption,
|
||||
disc_hash = %hash_hex,
|
||||
mkb_present = mkb_data.is_some(),
|
||||
"resolve_keys: starting"
|
||||
);
|
||||
|
||||
// Path 1: Look up VUK by disc hash in KEYDB
|
||||
if let Some(entry) = keydb.find_disc(&hash_hex) {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_hit_entry", "disc hash found in keydb");
|
||||
if let Some(vuk) = entry.vuk {
|
||||
return Some(build(vuk, 1));
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_no_vuk", "disc hash entry has no VUK");
|
||||
} else {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path1_miss", "disc hash NOT in keydb");
|
||||
}
|
||||
|
||||
// Path 2: Find entry with matching VID → derive VUK from MK + VID
|
||||
let mut path2_mk_did_count = 0usize;
|
||||
for entry in keydb.disc_entries.values() {
|
||||
if let (Some(mk), Some(did)) = (entry.media_key, entry.disc_id) {
|
||||
path2_mk_did_count += 1;
|
||||
if did == *volume_id {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_hit", "MK+VID entry matched volume_id");
|
||||
return Some(build(derive_vuk(&mk, volume_id), 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", mk_did_entries = path2_mk_did_count, "no MK+VID entry matched volume_id");
|
||||
|
||||
// Path 3: MKB + processing keys → media key → VUK
|
||||
if let Some(mkb) = mkb_data {
|
||||
let mk_dv = mkb_find_mk_dv(mkb);
|
||||
let subdiff = mkb_find_subdiff_records(mkb);
|
||||
let cvalues = mkb_find_cvalues(mkb);
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "resolve_keys_mkb_records",
|
||||
mk_dv_found = mk_dv.is_some(),
|
||||
subdiff_found = subdiff.is_some(),
|
||||
subdiff_len = subdiff.as_ref().map(|s| s.len()).unwrap_or(0),
|
||||
cvalues_found = cvalues.is_some(),
|
||||
cvalues_len = cvalues.as_ref().map(|c| c.len()).unwrap_or(0),
|
||||
"MKB record scan results"
|
||||
);
|
||||
|
||||
if let Some(mk) = derive_media_key_from_pk(mkb, &keydb.processing_keys) {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_hit", "media key derived from processing key");
|
||||
return Some(build(derive_vuk(&mk, volume_id), 3));
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path3_miss", pk_count = keydb.processing_keys.len(), "PK derivation failed");
|
||||
|
||||
// Path 4: MKB + device keys → processing key → media key → VUK
|
||||
if let Some(mk) = derive_media_key_from_dk(mkb, &keydb.device_keys) {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_hit", "media key derived from device key");
|
||||
return Some(build(derive_vuk(&mk, volume_id), 4));
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path4_miss", dk_count = keydb.device_keys.len(), "DK derivation failed");
|
||||
} else {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB data available; paths 3/4 skipped");
|
||||
}
|
||||
|
||||
None
|
||||
@@ -661,18 +718,14 @@ pub fn resolve_keys(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::decrypt::{aes_ecb_encrypt, ALIGNED_UNIT_LEN};
|
||||
use super::super::decrypt::{ALIGNED_UNIT_LEN, aes_ecb_encrypt};
|
||||
use super::super::keydb::{DiscEntry, KeyDb};
|
||||
use super::*;
|
||||
|
||||
/// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found.
|
||||
fn keydb_path() -> Option<std::path::PathBuf> {
|
||||
let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?);
|
||||
if path.exists() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
if path.exists() { Some(path) } else { None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -849,7 +902,7 @@ mod tests {
|
||||
data[23] = 1; // top_menu = CPS unit 1
|
||||
data[24] = 0;
|
||||
data[25] = 1; // num_titles = 1
|
||||
// Title 0 entry: 2 bytes pad + CPS unit
|
||||
// Title 0 entry: 2 bytes pad + CPS unit
|
||||
data[28] = 0;
|
||||
data[29] = 1; // CPS unit 1
|
||||
|
||||
|
||||
+5
-5
@@ -21,12 +21,12 @@ pub mod keys;
|
||||
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
|
||||
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
|
||||
pub use decrypt::{
|
||||
decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys, is_unit_encrypted,
|
||||
ALIGNED_UNIT_LEN,
|
||||
ALIGNED_UNIT_LEN, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys,
|
||||
is_unit_encrypted,
|
||||
};
|
||||
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
|
||||
pub use keys::{
|
||||
decrypt_unit_key, derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash,
|
||||
disc_hash_hex, mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive,
|
||||
resolve_keys, ContentCert, ResolvedKeys, UnitKeyFile,
|
||||
ContentCert, ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_from_dk,
|
||||
derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex, mkb_version,
|
||||
parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys,
|
||||
};
|
||||
|
||||
+158
-1
@@ -20,6 +20,35 @@ pub struct ClipInfo {
|
||||
pub ep_coarse: Vec<EpCoarse>,
|
||||
/// Fine EP entries for the primary video stream
|
||||
pub ep_fine: Vec<EpFine>,
|
||||
/// Per-stream metadata from the ProgramInfo section (BD spec).
|
||||
/// Cross-validates the MPLS STN view — see `labels/clpi.rs`.
|
||||
/// Empty when program_info is missing or malformed.
|
||||
pub streams: Vec<ClpiStream>,
|
||||
}
|
||||
|
||||
/// One stream descriptor from the CLPI ProgramInfo / stream_coding_info
|
||||
/// table. Mirrors the same fields the MPLS STN table carries — see
|
||||
/// `mpls::StreamEntry` for the playlist-side equivalent.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct ClpiStream {
|
||||
/// PID of the stream in the MPEG-TS (matches MPLS).
|
||||
pub pid: u16,
|
||||
/// SCSI/BD coding type byte (0x80 LPCM, 0x83 TrueHD, 0x86 DTS-HD MA,
|
||||
/// 0x90 PG, etc.). See `labels::mpls_universal::coding_type_to_codec_hint`.
|
||||
pub coding_type: u8,
|
||||
/// ISO 639-2 3-char language code. Empty for video streams.
|
||||
pub language: String,
|
||||
/// Audio format byte (1=mono, 3=stereo, 6=5.1, 12=7.1).
|
||||
/// Zero for non-audio streams.
|
||||
pub audio_format: u8,
|
||||
/// Audio sample rate (1=48kHz, 4=96kHz, 5=192kHz). Zero for non-audio.
|
||||
pub audio_rate: u8,
|
||||
/// Video format byte (1=480i, 4=1080i, 5=720p, 6=1080p, 8=2160p).
|
||||
/// Zero for non-video.
|
||||
pub video_format: u8,
|
||||
/// Video rate (1=23.976, 2=24, 3=25, 4=29.97, 6=50, 7=59.94).
|
||||
pub video_rate: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -128,7 +157,7 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
||||
|
||||
// Header offsets
|
||||
let _seq_info_start = u32::from_be_bytes([data[8], data[9], data[10], data[11]]) as usize;
|
||||
let _prog_info_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize;
|
||||
let prog_info_start = u32::from_be_bytes([data[12], data[13], data[14], data[15]]) as usize;
|
||||
let cpi_start = u32::from_be_bytes([data[16], data[17], data[18], data[19]]) as usize;
|
||||
|
||||
// ClipInfo section at offset 40
|
||||
@@ -139,6 +168,16 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
||||
0
|
||||
};
|
||||
|
||||
// Parse ProgramInfo (per-stream language + codec). Best-effort:
|
||||
// malformed program_info doesn't fail the parse, just gives an
|
||||
// empty streams list. EP map is unaffected — sector-range lookups
|
||||
// continue to work.
|
||||
let streams = if prog_info_start > 0 && prog_info_start + 6 < data.len() {
|
||||
parse_program_info(&data[prog_info_start..])
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Parse CPI / EP Map
|
||||
let (ep_coarse, ep_fine) = if cpi_start > 0 && cpi_start + 8 < data.len() {
|
||||
parse_cpi(&data[cpi_start..])?
|
||||
@@ -151,9 +190,127 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
|
||||
source_packet_count,
|
||||
ep_coarse,
|
||||
ep_fine,
|
||||
streams,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the ProgramInfo section: per-stream (pid, coding_type,
|
||||
/// language, codec sub-fields). Layout per BD spec / libbluray
|
||||
/// clpi_parse.c:
|
||||
///
|
||||
/// ```text
|
||||
/// ProgramInfo:
|
||||
/// length: 4 bytes
|
||||
/// reserved: 1 byte
|
||||
/// num_programs: 1 byte
|
||||
/// for each program:
|
||||
/// spn_program_sequence_start: 4 bytes
|
||||
/// program_map_pid: 2 bytes
|
||||
/// num_streams: 1 byte
|
||||
/// num_groups: 1 byte
|
||||
/// for each stream:
|
||||
/// pid: 2 bytes
|
||||
/// stream_coding_info_length: 1 byte
|
||||
/// stream_coding_info: (varies by coding_type)
|
||||
/// coding_type: 1 byte
|
||||
/// per-type bytes (see match arms below)
|
||||
/// ```
|
||||
///
|
||||
/// Returns `Vec::new()` on any structural mismatch — we don't propagate
|
||||
/// errors because the EP map is the primary CLPI output, and a corrupt
|
||||
/// program_info shouldn't break sector-range lookups.
|
||||
fn parse_program_info(data: &[u8]) -> Vec<ClpiStream> {
|
||||
let mut out = Vec::new();
|
||||
if data.len() < 6 {
|
||||
return out;
|
||||
}
|
||||
// length: 4 bytes (skipped — we trust the section bounds in the
|
||||
// caller's slice and read the bytes that follow). Reserved 1 byte
|
||||
// at offset 4. num_programs at offset 5.
|
||||
let num_programs = data[5] as usize;
|
||||
let mut pos = 6usize;
|
||||
for _ in 0..num_programs {
|
||||
// Program header: 4 (spn) + 2 (pmt_pid) + 1 (num_streams) + 1 (num_groups) = 8 bytes
|
||||
if pos + 8 > data.len() {
|
||||
return out;
|
||||
}
|
||||
let num_streams = data[pos + 6] as usize;
|
||||
pos += 8;
|
||||
|
||||
for _ in 0..num_streams {
|
||||
// Stream header: 2 (pid) + 1 (sci_length) + sci bytes
|
||||
if pos + 3 > data.len() {
|
||||
return out;
|
||||
}
|
||||
let pid = u16::from_be_bytes([data[pos], data[pos + 1]]);
|
||||
let sci_len = data[pos + 2] as usize;
|
||||
let sci_end = pos + 3 + sci_len;
|
||||
if sci_end > data.len() || sci_len < 1 {
|
||||
return out;
|
||||
}
|
||||
let sci = &data[pos + 3..sci_end];
|
||||
let coding_type = sci[0];
|
||||
|
||||
let mut audio_format = 0u8;
|
||||
let mut audio_rate = 0u8;
|
||||
let mut video_format = 0u8;
|
||||
let mut video_rate = 0u8;
|
||||
let mut language = String::new();
|
||||
|
||||
match coding_type {
|
||||
// Video — MPEG-2 (0x02), H.264 (0x1B), HEVC (0x24)
|
||||
0x02 | 0x1B | 0x24 => {
|
||||
if sci.len() >= 2 {
|
||||
video_format = (sci[1] >> 4) & 0x0F;
|
||||
video_rate = sci[1] & 0x0F;
|
||||
}
|
||||
}
|
||||
// Primary audio — LPCM(0x80), AC-3(0x81), DTS(0x82),
|
||||
// TrueHD(0x83), AC-3+(0x84), DTS-HD(0x85), DTS-HD MA(0x86)
|
||||
0x80..=0x86 => {
|
||||
if sci.len() >= 2 {
|
||||
audio_format = (sci[1] >> 4) & 0x0F;
|
||||
audio_rate = sci[1] & 0x0F;
|
||||
}
|
||||
if sci.len() >= 5 {
|
||||
language = String::from_utf8_lossy(&sci[2..5]).to_string();
|
||||
}
|
||||
}
|
||||
// Secondary audio (0xA1 AC-3+, 0xA2 DTS-HD)
|
||||
0xA1 | 0xA2 => {
|
||||
if sci.len() >= 2 {
|
||||
audio_format = (sci[1] >> 4) & 0x0F;
|
||||
audio_rate = sci[1] & 0x0F;
|
||||
}
|
||||
if sci.len() >= 5 {
|
||||
language = String::from_utf8_lossy(&sci[2..5]).to_string();
|
||||
}
|
||||
}
|
||||
// PG (0x90), IG (0x91): coding_type + 3-byte language [+ char_code for PG]
|
||||
0x90 | 0x91 => {
|
||||
if sci.len() >= 4 {
|
||||
language = String::from_utf8_lossy(&sci[1..4]).to_string();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
out.push(ClpiStream {
|
||||
pid,
|
||||
coding_type,
|
||||
language,
|
||||
audio_format,
|
||||
audio_rate,
|
||||
video_format,
|
||||
video_rate,
|
||||
});
|
||||
|
||||
pos = sci_end;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse the CPI section containing the EP map.
|
||||
fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
|
||||
if data.len() < 8 {
|
||||
|
||||
+2
-1
@@ -348,7 +348,7 @@ fn read_disc_key(drive: &mut Drive, agid: u8, bus_key: &[u8; 5]) -> Result<[u8;
|
||||
let candidate = super::lfsr::decrypt_key(0x00, player_key, &enc);
|
||||
|
||||
// Check if any previous candidate matches (same disc key from different entry/pk)
|
||||
for &(ref prev, _, _) in &candidates {
|
||||
for (prev, _, _) in &candidates {
|
||||
if *prev == candidate {
|
||||
return Ok(candidate);
|
||||
}
|
||||
@@ -391,6 +391,7 @@ fn read_raw_title_key(drive: &mut Drive, agid: u8, lba: u32) -> Result<[u8; 5]>
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn read_title_key(
|
||||
drive: &mut Drive,
|
||||
agid: u8,
|
||||
|
||||
+1
-1
@@ -149,7 +149,7 @@ mod tests {
|
||||
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
|
||||
let mut sector = vec![0xAA; 2048];
|
||||
sector[0x14] = 0x30; // scramble flag set
|
||||
// Set a sector seed
|
||||
// Set a sector seed
|
||||
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
|
||||
let original = sector.clone();
|
||||
descramble_sector(&key, &mut sector);
|
||||
|
||||
+7
-3
@@ -16,7 +16,7 @@ pub mod lfsr;
|
||||
pub(crate) mod tables;
|
||||
|
||||
use crate::disc::Extent;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::sector::SectorSource;
|
||||
|
||||
/// CSS decryption state for a DVD title.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -34,7 +34,7 @@ pub struct CssState {
|
||||
/// 0x80 (start of the encrypted region). This only happens when a new PES
|
||||
/// packet begins at exactly sector offset 128. We scan up to 50000
|
||||
/// scrambled sectors sequentially across all extents.
|
||||
pub fn crack_key(reader: &mut dyn SectorReader, extents: &[Extent]) -> Option<CssState> {
|
||||
pub fn crack_key(reader: &mut dyn SectorSource, extents: &[Extent]) -> Option<CssState> {
|
||||
let mut tried = 0u32;
|
||||
let max_tries = 50_000;
|
||||
|
||||
@@ -42,7 +42,11 @@ pub fn crack_key(reader: &mut dyn SectorReader, extents: &[Extent]) -> Option<Cs
|
||||
let mut i = 0;
|
||||
while i < ext.sector_count && tried < max_tries {
|
||||
let mut buf = vec![0u8; 2048];
|
||||
if reader.read_sectors(ext.start_lba + i, 1, &mut buf).is_ok() && is_scrambled(&buf) {
|
||||
if reader
|
||||
.read_sectors(ext.start_lba + i, 1, &mut buf, true)
|
||||
.is_ok()
|
||||
&& is_scrambled(&buf)
|
||||
{
|
||||
if let Some(key) = crack::crack_title_key(&buf) {
|
||||
return Some(CssState { title_key: key });
|
||||
}
|
||||
|
||||
+50
-1
@@ -62,7 +62,23 @@ pub fn decrypt_sectors(
|
||||
|
||||
for chunk in buf.chunks_mut(unit_len) {
|
||||
if chunk.len() == unit_len && aacs::is_unit_encrypted(chunk) {
|
||||
aacs::decrypt_unit_full(chunk, &uk, rdk);
|
||||
// `is_unit_encrypted` is a byte-0 heuristic: it fires on any
|
||||
// unit whose first byte has the top 2 bits set, which is
|
||||
// correct for m2ts source packets (where those bits are the
|
||||
// copy-control marker) but false-positives on any other binary
|
||||
// data with similarly-shaped first bytes — notably MPLS/CLPI
|
||||
// navigation files that begin with ASCII magic ('M', 'H'…)
|
||||
// and survive sweep mixed in with encrypted m2ts payloads.
|
||||
// `decrypt_unit_full` self-checks via TS-sync verification and
|
||||
// returns false on a misfire, but it has already mutated the
|
||||
// chunk by then. Snapshot and restore on verification failure
|
||||
// — same pattern `decrypt_unit_try_keys` uses for multi-key
|
||||
// discs. Real m2ts units verify and stay decrypted; nav-file
|
||||
// sectors get scrambled briefly and then put back as-was.
|
||||
let original: Vec<u8> = chunk.to_vec();
|
||||
if !aacs::decrypt_unit_full(chunk, &uk, rdk) {
|
||||
chunk.copy_from_slice(&original);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,3 +90,36 @@ pub fn decrypt_sectors(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit whose
|
||||
/// first byte has the top 2 bits set (here: the ASCII letter 'M' that
|
||||
/// MPLS files start with, 0x4D = 0b01001101) trips `is_unit_encrypted`,
|
||||
/// gets AES-decrypted with the unit key, fails the TS-sync verification,
|
||||
/// and must be restored to its original bytes — not left scrambled.
|
||||
#[test]
|
||||
fn nav_file_unit_survives_decrypt_attempt() {
|
||||
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
unit[0] = b'M';
|
||||
unit[1] = b'P';
|
||||
unit[2] = b'L';
|
||||
unit[3] = b'S';
|
||||
for (i, b) in unit.iter_mut().enumerate().skip(4) {
|
||||
*b = (i as u8).wrapping_mul(31);
|
||||
}
|
||||
let snapshot = unit.clone();
|
||||
|
||||
let keys = DecryptKeys::Aacs {
|
||||
unit_keys: vec![(0, [0xAB; 16])],
|
||||
read_data_key: None,
|
||||
};
|
||||
decrypt_sectors(&mut unit, &keys, 0).unwrap();
|
||||
assert_eq!(
|
||||
unit, snapshot,
|
||||
"non-m2ts unit must be restored after failed decrypt"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-4
@@ -3,13 +3,13 @@
|
||||
use super::*;
|
||||
use crate::clpi;
|
||||
use crate::mpls;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf;
|
||||
|
||||
impl Disc {
|
||||
/// Scan Blu-ray titles from MPLS playlists.
|
||||
pub(super) fn scan_bluray_titles(
|
||||
reader: &mut dyn SectorReader,
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &udf::UdfFs,
|
||||
) -> Vec<DiscTitle> {
|
||||
let mut titles = Vec::new();
|
||||
@@ -31,7 +31,7 @@ impl Disc {
|
||||
}
|
||||
|
||||
pub(super) fn parse_playlist(
|
||||
reader: &mut dyn SectorReader,
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &udf::UdfFs,
|
||||
filename: &str,
|
||||
data: &[u8],
|
||||
@@ -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
|
||||
@@ -196,7 +199,7 @@ impl Disc {
|
||||
/// Prefers English, falls back to first available language.
|
||||
/// Returns None if META directory is empty or XML has no usable title.
|
||||
pub(super) fn read_meta_title(
|
||||
reader: &mut dyn SectorReader,
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &udf::UdfFs,
|
||||
) -> Option<String> {
|
||||
let meta_dir = udf_fs.find_dir("/BDMV/META")?;
|
||||
|
||||
+4
-2
@@ -2,13 +2,13 @@
|
||||
|
||||
use super::*;
|
||||
use crate::ifo;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf;
|
||||
|
||||
impl Disc {
|
||||
/// Scan DVD titles from IFO files (VIDEO_TS.IFO + VTS_XX_0.IFO).
|
||||
pub(super) fn scan_dvd_titles(
|
||||
reader: &mut dyn SectorReader,
|
||||
reader: &mut dyn SectorSource,
|
||||
udf_fs: &udf::UdfFs,
|
||||
) -> Vec<DiscTitle> {
|
||||
let dvd_info = match ifo::parse_vmg(reader, udf_fs) {
|
||||
@@ -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(),
|
||||
})
|
||||
})
|
||||
|
||||
+104
-8
@@ -2,7 +2,7 @@
|
||||
|
||||
use super::*;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf;
|
||||
|
||||
/// Result of SCSI AACS handshake (ECDH authentication).
|
||||
@@ -22,42 +22,103 @@ impl Disc {
|
||||
) -> Option<HandshakeResult> {
|
||||
use crate::aacs::{self, KeyDb};
|
||||
|
||||
let keydb_path = opts.resolve_keydb()?;
|
||||
let keydb = KeyDb::load(&keydb_path).ok()?;
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_entry",
|
||||
"do_handshake entered"
|
||||
);
|
||||
let keydb_path = match opts.resolve_keydb() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_no_keydb",
|
||||
"no KEYDB found in search paths; handshake skipped"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let keydb = match KeyDb::load(&keydb_path) {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_keydb_load_failed",
|
||||
io_error_kind = ?e.kind(),
|
||||
keydb = %keydb_path.display(),
|
||||
"KEYDB load failed; handshake skipped"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let host_cert_count = keydb.host_certs.len();
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_start",
|
||||
host_cert_count,
|
||||
keydb = %keydb_path.display(),
|
||||
"handshake starting"
|
||||
);
|
||||
|
||||
const MAX_CERT_ATTEMPTS: usize = 16;
|
||||
for hc in keydb.host_certs.iter().take(MAX_CERT_ATTEMPTS) {
|
||||
let mut last_err_code: Option<u16> = None;
|
||||
for (idx, hc) in keydb.host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() {
|
||||
match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) {
|
||||
Ok(mut auth) => {
|
||||
let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) {
|
||||
Ok(vid) => vid,
|
||||
Err(_) => return None, // handshake succeeded but can't read VID
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_vid_read_failed",
|
||||
cert_index = idx,
|
||||
error_code = e.code(),
|
||||
"auth ok but volume ID read failed"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
|
||||
.ok()
|
||||
.map(|(rdk, _)| rdk);
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_ok",
|
||||
cert_index = idx,
|
||||
has_read_data_key = read_data_key.is_some(),
|
||||
);
|
||||
return Some(HandshakeResult {
|
||||
volume_id,
|
||||
read_data_key,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
Err(e) => {
|
||||
last_err_code = Some(e.code());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_all_certs_failed",
|
||||
host_cert_count,
|
||||
tried = host_cert_count.min(MAX_CERT_ATTEMPTS),
|
||||
last_error_code = last_err_code,
|
||||
"all host certs in KEYDB rejected by drive"
|
||||
);
|
||||
// All host certs failed — return None, not a fake success
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none.
|
||||
///
|
||||
/// Reads AACS files from UDF (via SectorReader), resolves keys through
|
||||
/// Reads AACS files from UDF (via SectorSource), resolves keys through
|
||||
/// whatever path works: KEYDB VUK lookup, media key derivation, processing
|
||||
/// keys, device keys. Uses handshake result (volume ID, bus key) if available.
|
||||
pub(super) fn resolve_encryption(
|
||||
udf_fs: &udf::UdfFs,
|
||||
reader: &mut dyn SectorReader,
|
||||
reader: &mut dyn SectorSource,
|
||||
keydb_path: &std::path::Path,
|
||||
handshake: Option<&HandshakeResult>,
|
||||
) -> Result<AacsState> {
|
||||
@@ -73,6 +134,19 @@ impl Disc {
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
|
||||
// Log the disc hash so we can confirm whether it's present in KEYDB
|
||||
// when key resolution fails. The disc hash is SHA-1 of the full
|
||||
// Unit_Key_RO.inf file bytes — same value KEYDB.cfg keys VUK entries by.
|
||||
let dh = crate::aacs::disc_hash(&uk_ro_data);
|
||||
let dh_hex = crate::aacs::disc_hash_hex(&dh);
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_disc_hash",
|
||||
disc_hash = %dh_hex,
|
||||
uk_ro_len = uk_ro_data.len(),
|
||||
"disc hash computed (compare with keydb.cfg entries)"
|
||||
);
|
||||
|
||||
let cc_data = udf_fs
|
||||
.read_file(reader, "/AACS/Content000.cer")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
|
||||
@@ -84,6 +158,28 @@ impl Disc {
|
||||
.ok();
|
||||
let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version);
|
||||
|
||||
let mkb_first_64_hex = mkb_data
|
||||
.as_deref()
|
||||
.map(|m| {
|
||||
m.iter()
|
||||
.take(64)
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_mkb_info",
|
||||
mkb_present = mkb_data.is_some(),
|
||||
mkb_len = mkb_data.as_deref().map(|m| m.len()).unwrap_or(0),
|
||||
mkb_version = ?mkb_ver,
|
||||
mkb_first_64 = %mkb_first_64_hex,
|
||||
keydb_disc_count = keydb.disc_entries.len(),
|
||||
keydb_dk_count = keydb.device_keys.len(),
|
||||
keydb_pk_count = keydb.processing_keys.len(),
|
||||
"AACS resolution inputs"
|
||||
);
|
||||
|
||||
// Use handshake volume ID if available, otherwise zeros
|
||||
// (KEYDB VUK lookup by disc hash works without volume ID)
|
||||
let volume_id = handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]);
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
//! ddrescue-compatible mapfile for tracking rip progress.
|
||||
//!
|
||||
//! Records which byte ranges of a disc image are good, unreadable,
|
||||
//! or not-yet-attempted. Written as plain text so it's greppable,
|
||||
//! human-editable, and interoperates with ddrescue's own tools.
|
||||
//!
|
||||
//! Format:
|
||||
//! ```text
|
||||
//! # Rescue Logfile. Created by libfreemkv v0.11.21
|
||||
//! # Current pos / status / pass / pass_time (ddrescue state machine — we only populate pos)
|
||||
//! 0x000000000 ? 1 0
|
||||
//! # pos size status
|
||||
//! 0x000000000 0x12345678 +
|
||||
//! 0x012345678 0x00001000 -
|
||||
//! 0x012346678 0x01234500 ?
|
||||
//! ```
|
||||
//!
|
||||
//! Status chars: `?` non-tried · `*` non-trimmed · `/` non-scraped · `-` unreadable · `+` finished.
|
||||
//!
|
||||
//! The mapfile is flushed to disk at most once per `FLUSH_INTERVAL`
|
||||
//! during `record()` calls, plus on explicit `flush()` and on `Drop`.
|
||||
//! This bounds atomic-rename RPC rate on networked staging (e.g. NFS)
|
||||
//! where per-record persists otherwise serialize the rip pipeline.
|
||||
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Minimum interval between mapfile persists. `record()` updates in-memory
|
||||
/// state every call but only writes to disk when this interval has elapsed
|
||||
/// since the last persist (or when `flush()` is called explicitly, or on
|
||||
/// `Drop`). Bounds RPC rate on NFS staging where atomic-rename per record
|
||||
/// otherwise dominates throughput. On crash the worst-case progress loss
|
||||
/// is one interval's worth of records.
|
||||
const FLUSH_INTERVAL: Duration = Duration::from_millis(1000);
|
||||
|
||||
/// Status of a byte range in the mapfile. ddrescue-compatible.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SectorStatus {
|
||||
/// `?` — not yet attempted. Initial state for a fresh mapfile.
|
||||
NonTried,
|
||||
/// `*` — fast-pass read failed; edges need trimming.
|
||||
NonTrimmed,
|
||||
/// `/` — trimmed; interior needs sector scrape.
|
||||
NonScraped,
|
||||
/// `-` — drive couldn't read it this session.
|
||||
Unreadable,
|
||||
/// `+` — good.
|
||||
Finished,
|
||||
}
|
||||
|
||||
impl SectorStatus {
|
||||
pub fn to_char(self) -> char {
|
||||
match self {
|
||||
Self::NonTried => '?',
|
||||
Self::NonTrimmed => '*',
|
||||
Self::NonScraped => '/',
|
||||
Self::Unreadable => '-',
|
||||
Self::Finished => '+',
|
||||
}
|
||||
}
|
||||
pub fn from_char(c: char) -> Option<Self> {
|
||||
Some(match c {
|
||||
'?' => Self::NonTried,
|
||||
'*' => Self::NonTrimmed,
|
||||
'/' => Self::NonScraped,
|
||||
'-' => Self::Unreadable,
|
||||
'+' => Self::Finished,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// One contiguous range of bytes with a status.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MapEntry {
|
||||
pub pos: u64,
|
||||
pub size: u64,
|
||||
pub status: SectorStatus,
|
||||
}
|
||||
|
||||
/// Summary statistics over all entries.
|
||||
///
|
||||
/// `bytes_pending` aggregates `NonTried + NonTrimmed + NonScraped` for
|
||||
/// back-compat. `bytes_nontried` and `bytes_retryable` (= NonTrimmed +
|
||||
/// NonScraped) split that aggregate so UIs can distinguish *unread*
|
||||
/// territory (still ahead of Pass 1's read head) from *needs-retry*
|
||||
/// territory (Pass 1 already encountered, queued for Pass 2-N).
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct MapStats {
|
||||
pub bytes_total: u64,
|
||||
pub bytes_good: u64,
|
||||
pub bytes_unreadable: u64,
|
||||
pub bytes_pending: u64,
|
||||
/// Sectors Pass 1 hasn't reached yet (`NonTried`). Subset of
|
||||
/// `bytes_pending`.
|
||||
pub bytes_nontried: u64,
|
||||
/// Sectors flagged for Pass 2-N retry — `NonTrimmed` (multi-sector
|
||||
/// read failed; needs split) + `NonScraped` (small-block read
|
||||
/// partially recovered; remainder still pending). Subset of
|
||||
/// `bytes_pending`. This is the right signal for a "MAYBE / will
|
||||
/// retry" UI bucket; `bytes_pending` over-counts because it folds
|
||||
/// in `bytes_nontried`.
|
||||
pub bytes_retryable: u64,
|
||||
/// Number of unreadable ranges (for UI display). Computed from
|
||||
/// `ranges_with(&[Unreadable])`.
|
||||
pub num_bad_ranges: u32,
|
||||
/// Largest gap among unreadable ranges in milliseconds. Computed as
|
||||
/// largest range size / bytes_per_sec * 1000. Set by caller (autorip)
|
||||
/// since bytes_per_sec is application-specific.
|
||||
pub main_lost_ms: f64,
|
||||
}
|
||||
|
||||
/// Time-batched mapfile. `record()` keeps in-memory state up-to-date on
|
||||
/// every call; persists to disk at most once per `FLUSH_INTERVAL`.
|
||||
/// Explicit `flush()` and `Drop` guarantee state is on disk after a sweep
|
||||
/// or patch finishes. On hard crash the worst-case loss is one flush
|
||||
/// interval of records — the file's payload bytes are unaffected.
|
||||
pub struct Mapfile {
|
||||
path: PathBuf,
|
||||
entries: Vec<MapEntry>,
|
||||
total_size: u64,
|
||||
version: String,
|
||||
/// Incrementally maintained stats — updated on every `record()` call
|
||||
/// so `stats()` is O(1) instead of O(n).
|
||||
stats: MapStats,
|
||||
/// True when in-memory state has changed but `write_to_disk` has not
|
||||
/// yet captured it.
|
||||
dirty: bool,
|
||||
/// Wall-clock timestamp of the last successful `write_to_disk` (or
|
||||
/// the moment the mapfile was constructed, whichever is later).
|
||||
last_flushed: Instant,
|
||||
}
|
||||
|
||||
impl Mapfile {
|
||||
/// Create a new mapfile with one `NonTried` region covering the whole disc.
|
||||
/// Writes to disk immediately so a resume can pick up even if the caller
|
||||
/// never records anything.
|
||||
pub fn create(path: &Path, total_size: u64, version: &str) -> io::Result<Self> {
|
||||
let mut mf = Self {
|
||||
path: path.to_path_buf(),
|
||||
entries: vec![MapEntry {
|
||||
pos: 0,
|
||||
size: total_size,
|
||||
status: SectorStatus::NonTried,
|
||||
}],
|
||||
total_size,
|
||||
version: version.to_string(),
|
||||
stats: MapStats {
|
||||
bytes_total: total_size,
|
||||
bytes_pending: total_size,
|
||||
bytes_nontried: total_size,
|
||||
..Default::default()
|
||||
},
|
||||
dirty: false,
|
||||
last_flushed: Instant::now(),
|
||||
};
|
||||
// Eager initial persist so a resume can pick this up even if
|
||||
// `record()` is never called.
|
||||
mf.write_to_disk()?;
|
||||
mf.last_flushed = Instant::now();
|
||||
Ok(mf)
|
||||
}
|
||||
|
||||
/// Load an existing mapfile from disk.
|
||||
pub fn load(path: &Path) -> io::Result<Self> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let mut entries = Vec::new();
|
||||
let mut saw_current_line = false;
|
||||
let mut version = String::from("unknown");
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(rest) = t.strip_prefix('#') {
|
||||
let rest = rest.trim();
|
||||
if let Some(v) = rest.strip_prefix("Rescue Logfile. Created by ") {
|
||||
version = v.to_string();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// First non-comment line is the "current" state line (pos status [pass] [pass_time]).
|
||||
// We ignore its contents but skip over it.
|
||||
if !saw_current_line {
|
||||
saw_current_line = true;
|
||||
// But if the line looks like an entry (has at least 3 fields starting 0x...),
|
||||
// it's probably actually an entry for a mapfile we wrote without a current line.
|
||||
// Heuristic: current line has status char as 2nd field; entry has size as 2nd field.
|
||||
let fields: Vec<&str> = t.split_whitespace().collect();
|
||||
if fields.len() >= 3 && fields[1].starts_with("0x") {
|
||||
// It's an entry, not a current line — fall through to entry parse.
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Entry: `pos size statuschar`
|
||||
let fields: Vec<&str> = t.split_whitespace().collect();
|
||||
if fields.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let pos = parse_hex(fields[0])?;
|
||||
let size = parse_hex(fields[1])?;
|
||||
let status = fields[2]
|
||||
.chars()
|
||||
.next()
|
||||
.and_then(SectorStatus::from_char)
|
||||
.ok_or_else(|| {
|
||||
// No English text — the variant carries a stable
|
||||
// language-neutral kind identifier (`status_char`).
|
||||
let e: io::Error = crate::error::Error::MapfileInvalid {
|
||||
kind: "status_char",
|
||||
}
|
||||
.into();
|
||||
e
|
||||
})?;
|
||||
entries.push(MapEntry { pos, size, status });
|
||||
}
|
||||
entries.sort_by_key(|e| e.pos);
|
||||
let total_size = entries.last().map(|e| e.pos + e.size).unwrap_or(0);
|
||||
let stats = Self::compute_stats(&entries, total_size);
|
||||
Ok(Self {
|
||||
path: path.to_path_buf(),
|
||||
entries,
|
||||
total_size,
|
||||
version,
|
||||
stats,
|
||||
dirty: false,
|
||||
last_flushed: Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Load if the file exists, otherwise create a fresh mapfile.
|
||||
pub fn open_or_create(path: &Path, total_size: u64, version: &str) -> io::Result<Self> {
|
||||
match Self::load(path) {
|
||||
Ok(mf) => Ok(mf),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
Self::create(path, total_size, version)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a byte range as having the given status. Splits any overlapping
|
||||
/// existing entries, merges with adjacent same-status entries, and flushes
|
||||
/// to disk.
|
||||
pub fn record(&mut self, pos: u64, size: u64, status: SectorStatus) -> io::Result<()> {
|
||||
if size == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let end = pos.saturating_add(size);
|
||||
let mut new_entries = Vec::with_capacity(self.entries.len() + 2);
|
||||
|
||||
for e in self.entries.drain(..) {
|
||||
let e_end = e.pos + e.size;
|
||||
if e_end <= pos || e.pos >= end {
|
||||
// entirely before or after — keep
|
||||
new_entries.push(e);
|
||||
continue;
|
||||
}
|
||||
// Overlap — keep portions outside [pos, end)
|
||||
if e.pos < pos {
|
||||
new_entries.push(MapEntry {
|
||||
pos: e.pos,
|
||||
size: pos - e.pos,
|
||||
status: e.status,
|
||||
});
|
||||
}
|
||||
if e_end > end {
|
||||
new_entries.push(MapEntry {
|
||||
pos: end,
|
||||
size: e_end - end,
|
||||
status: e.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
new_entries.push(MapEntry { pos, size, status });
|
||||
new_entries.sort_by_key(|e| e.pos);
|
||||
|
||||
// Coalesce adjacent same-status entries.
|
||||
let mut merged: Vec<MapEntry> = Vec::with_capacity(new_entries.len());
|
||||
for e in new_entries {
|
||||
if let Some(last) = merged.last_mut() {
|
||||
if last.pos + last.size == e.pos && last.status == e.status {
|
||||
last.size += e.size;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
merged.push(e);
|
||||
}
|
||||
|
||||
// Recompute stats from merged entries. record() is already O(n) due to
|
||||
// drain-and-rebuild, so this is a constant-factor overhead. The critical
|
||||
// win is that stats() is now O(1) — called millions of times in the hot
|
||||
// path during sweep/patch, it just returns the cached value.
|
||||
self.stats = Self::compute_stats(&merged, self.total_size);
|
||||
self.entries = merged;
|
||||
self.dirty = true;
|
||||
if self.last_flushed.elapsed() >= FLUSH_INTERVAL {
|
||||
self.write_to_disk()?;
|
||||
self.dirty = false;
|
||||
self.last_flushed = Instant::now();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist any pending in-memory changes to disk. No-op if clean.
|
||||
/// Callers (sweep/patch finalisation) invoke this after their last
|
||||
/// `record()` to guarantee state is durable before returning.
|
||||
pub fn flush(&mut self) -> io::Result<()> {
|
||||
if self.dirty {
|
||||
self.write_to_disk()?;
|
||||
self.dirty = false;
|
||||
self.last_flushed = Instant::now();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &[MapEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
pub fn total_size(&self) -> u64 {
|
||||
self.total_size
|
||||
}
|
||||
|
||||
/// First range with a given status starting at or after `from`.
|
||||
pub fn next_with(&self, from: u64, status: SectorStatus) -> Option<(u64, u64)> {
|
||||
for e in &self.entries {
|
||||
if e.status != status {
|
||||
continue;
|
||||
}
|
||||
let e_end = e.pos + e.size;
|
||||
if e_end <= from {
|
||||
continue;
|
||||
}
|
||||
let start = e.pos.max(from);
|
||||
return Some((start, e_end - start));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// All ranges matching one of the given statuses, in position order.
|
||||
pub fn ranges_with(&self, statuses: &[SectorStatus]) -> Vec<(u64, u64)> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|e| statuses.contains(&e.status))
|
||||
.map(|e| (e.pos, e.size))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> MapStats {
|
||||
self.stats
|
||||
}
|
||||
|
||||
fn compute_stats(entries: &[MapEntry], total_size: u64) -> MapStats {
|
||||
let mut s = MapStats {
|
||||
bytes_total: total_size,
|
||||
..Default::default()
|
||||
};
|
||||
for e in entries {
|
||||
match e.status {
|
||||
SectorStatus::Finished => s.bytes_good += e.size,
|
||||
SectorStatus::Unreadable => s.bytes_unreadable += e.size,
|
||||
SectorStatus::NonTried => {
|
||||
s.bytes_pending += e.size;
|
||||
s.bytes_nontried += e.size;
|
||||
}
|
||||
SectorStatus::NonTrimmed | SectorStatus::NonScraped => {
|
||||
s.bytes_pending += e.size;
|
||||
s.bytes_retryable += e.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn write_to_disk(&self) -> io::Result<()> {
|
||||
// Write to a tempfile then rename for atomicity. Appending ".tmp"
|
||||
// rather than `with_extension` so we don't clobber the original
|
||||
// extension (which may already be ".mapfile").
|
||||
let tmp = {
|
||||
let mut s = self.path.clone().into_os_string();
|
||||
s.push(".tmp");
|
||||
PathBuf::from(s)
|
||||
};
|
||||
{
|
||||
let file = std::fs::File::create(&tmp)?;
|
||||
let mut w = std::io::BufWriter::new(file);
|
||||
writeln!(w, "# Rescue Logfile. Created by {}", self.version)?;
|
||||
writeln!(w, "# Current pos / status / pass / pass_time")?;
|
||||
writeln!(w, "0x000000000 ? 1 0")?;
|
||||
writeln!(w, "# pos size status")?;
|
||||
for e in &self.entries {
|
||||
writeln!(
|
||||
w,
|
||||
"0x{:09x} 0x{:09x} {}",
|
||||
e.pos,
|
||||
e.size,
|
||||
e.status.to_char()
|
||||
)?;
|
||||
}
|
||||
w.flush()?;
|
||||
}
|
||||
std::fs::rename(&tmp, &self.path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Mapfile {
|
||||
/// Best-effort flush on drop so a sweep / patch that returns early
|
||||
/// (or unwinds) doesn't lose its in-memory state. Errors here are
|
||||
/// swallowed because Drop has no way to surface them; explicit
|
||||
/// `flush()` on the success path gives callers proper error handling.
|
||||
fn drop(&mut self) {
|
||||
let _ = self.flush();
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_hex(s: &str) -> io::Result<u64> {
|
||||
let s = s.strip_prefix("0x").unwrap_or(s);
|
||||
u64::from_str_radix(s, 16).map_err(|_| {
|
||||
// Underlying ParseIntError dropped — its Display is OS-locale text.
|
||||
// The typed variant carries `kind = "hex"` which is stable.
|
||||
let e: io::Error = crate::error::Error::MapfileInvalid { kind: "hex" }.into();
|
||||
e
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmpfile(tag: &str) -> PathBuf {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static CTR: AtomicU64 = AtomicU64::new(0);
|
||||
let n = CTR.fetch_add(1, Ordering::Relaxed);
|
||||
let name = format!(
|
||||
"libfreemkv-mapfile-test-{}-{}-{}.mapfile",
|
||||
std::process::id(),
|
||||
tag,
|
||||
n
|
||||
);
|
||||
std::env::temp_dir().join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_has_one_nontried_region() {
|
||||
let p = tmpfile("create_has_one_nontried_region");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
assert_eq!(mf.entries().len(), 1);
|
||||
assert_eq!(mf.entries()[0].pos, 0);
|
||||
assert_eq!(mf.entries()[0].size, 1000);
|
||||
assert_eq!(mf.entries()[0].status, SectorStatus::NonTried);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_splits_overlap() {
|
||||
let p = tmpfile("record_splits_overlap");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(200, 100, SectorStatus::Finished).unwrap();
|
||||
let es = mf.entries();
|
||||
assert_eq!(es.len(), 3);
|
||||
assert_eq!(
|
||||
(es[0].pos, es[0].size, es[0].status),
|
||||
(0, 200, SectorStatus::NonTried)
|
||||
);
|
||||
assert_eq!(
|
||||
(es[1].pos, es[1].size, es[1].status),
|
||||
(200, 100, SectorStatus::Finished)
|
||||
);
|
||||
assert_eq!(
|
||||
(es[2].pos, es[2].size, es[2].status),
|
||||
(300, 700, SectorStatus::NonTried)
|
||||
);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_coalesces_adjacent_same_status() {
|
||||
let p = tmpfile("record_coalesces_adjacent_same_status");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(100, 100, SectorStatus::Finished).unwrap();
|
||||
mf.record(200, 100, SectorStatus::Finished).unwrap();
|
||||
// Entries: [0..100 NonTried, 100..300 Finished (merged), 300..1000 NonTried]
|
||||
let es = mf.entries();
|
||||
assert_eq!(es.len(), 3);
|
||||
assert_eq!(
|
||||
(es[1].pos, es[1].size, es[1].status),
|
||||
(100, 200, SectorStatus::Finished)
|
||||
);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_replaces_existing_status() {
|
||||
let p = tmpfile("record_replaces_existing_status");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(200, 100, SectorStatus::Unreadable).unwrap();
|
||||
mf.record(200, 100, SectorStatus::Finished).unwrap();
|
||||
let es = mf.entries();
|
||||
// The overwrite should result in all finished at 200..300, NonTried elsewhere — 3 entries.
|
||||
assert_eq!(es.len(), 3);
|
||||
assert_eq!(es[1].status, SectorStatus::Finished);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_load() {
|
||||
let p = tmpfile("round_trip_load");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(100, 200, SectorStatus::Finished).unwrap();
|
||||
mf.record(500, 100, SectorStatus::Unreadable).unwrap();
|
||||
// record() batches; explicit flush before reading back from disk.
|
||||
mf.flush().unwrap();
|
||||
let loaded = Mapfile::load(&p).unwrap();
|
||||
assert_eq!(loaded.entries(), mf.entries());
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_sum_correctly() {
|
||||
let p = tmpfile("stats_sum_correctly");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(0, 400, SectorStatus::Finished).unwrap();
|
||||
mf.record(400, 100, SectorStatus::Unreadable).unwrap();
|
||||
let s = mf.stats();
|
||||
assert_eq!(s.bytes_good, 400);
|
||||
assert_eq!(s.bytes_unreadable, 100);
|
||||
assert_eq!(s.bytes_pending, 500);
|
||||
assert_eq!(s.bytes_total, 1000);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranges_with_filters() {
|
||||
let p = tmpfile("ranges_with_filters");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(100, 50, SectorStatus::Unreadable).unwrap();
|
||||
mf.record(300, 50, SectorStatus::Unreadable).unwrap();
|
||||
let bad = mf.ranges_with(&[SectorStatus::Unreadable]);
|
||||
assert_eq!(bad, vec![(100, 50), (300, 50)]);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_consistent_after_overlapping_records() {
|
||||
let p = tmpfile("stats_consistent_after_overlapping");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
// Record some finished, some unreadable, some nontrimmed
|
||||
mf.record(0, 300, SectorStatus::Finished).unwrap();
|
||||
mf.record(300, 200, SectorStatus::NonTrimmed).unwrap();
|
||||
mf.record(500, 100, SectorStatus::Unreadable).unwrap();
|
||||
mf.record(600, 400, SectorStatus::Finished).unwrap();
|
||||
|
||||
// Final entries: [0..300 Finished, 300..500 NonTrimmed, 500..600 Unreadable, 600..1000 Finished]
|
||||
let s = mf.stats();
|
||||
assert_eq!(s.bytes_good, 700); // 300 + 400
|
||||
assert_eq!(s.bytes_unreadable, 100); // 100
|
||||
assert_eq!(s.bytes_pending, 200); // NonTrimmed only (NonTried=0)
|
||||
assert_eq!(s.bytes_nontried, 0);
|
||||
assert_eq!(s.bytes_retryable, 200); // NonTrimmed
|
||||
assert_eq!(s.bytes_total, 1000);
|
||||
|
||||
// Overwrite a NonTrimmed range with Finished
|
||||
mf.record(300, 100, SectorStatus::Finished).unwrap();
|
||||
// Entries: [0..400 Finished, 400..500 NonTrimmed, 500..600 Unreadable, 600..1000 Finished]
|
||||
let s2 = mf.stats();
|
||||
assert_eq!(s2.bytes_good, 800); // 400 + 400
|
||||
assert_eq!(s2.bytes_unreadable, 100);
|
||||
assert_eq!(s2.bytes_pending, 100); // NonTrimmed only
|
||||
assert_eq!(s2.bytes_retryable, 100);
|
||||
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_consistent_after_split_record() {
|
||||
let p = tmpfile("stats_consistent_after_split");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
// Mark middle as NonTrimmed
|
||||
mf.record(200, 400, SectorStatus::NonTrimmed).unwrap();
|
||||
// Entries: [0..200 NonTried, 200..600 NonTrimmed, 600..1000 NonTried]
|
||||
let s = mf.stats();
|
||||
assert_eq!(s.bytes_pending, 1000); // NonTried(600) + NonTrimmed(400)
|
||||
assert_eq!(s.bytes_retryable, 400); // NonTrimmed only
|
||||
assert_eq!(s.bytes_nontried, 600); // 200 + 400
|
||||
|
||||
// Overwrite the NonTrimmed with Finished (splitting the remaining NonTried)
|
||||
mf.record(200, 400, SectorStatus::Finished).unwrap();
|
||||
// Entries: [0..200 NonTried, 200..600 Finished, 600..1000 NonTried]
|
||||
let s2 = mf.stats();
|
||||
assert_eq!(s2.bytes_good, 400);
|
||||
assert_eq!(s2.bytes_pending, 600); // NonTried(200 + 400)
|
||||
assert_eq!(s2.bytes_nontried, 600);
|
||||
assert_eq!(s2.bytes_retryable, 0);
|
||||
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
+1376
-129
File diff suppressed because it is too large
Load Diff
+1472
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
||||
//! `Disc::sweep`'s consumer-side `Sink<WorkItem>`.
|
||||
//!
|
||||
//! Background: the original sweep loop runs strictly serialised —
|
||||
//! SCSI read → decrypt → seek + write → mapfile.record → next iter.
|
||||
//! On a healthy disc the SCSI read costs ~5-12 ms per 64 KB batch and
|
||||
//! the post-read work (decrypt 1-3 ms + file write + mapfile fsync
|
||||
//! 5-15 ms) adds another batch's worth of latency. The drive idles
|
||||
//! during the post-read work; throughput tops out at the *sum* of
|
||||
//! both costs.
|
||||
//!
|
||||
//! 0.17.11 introduced a bespoke producer/consumer split (the now-
|
||||
//! removed `disc/sweep_pipeline.rs`) to overlap the two stages. 0.18
|
||||
//! collapses that split — together with the analogous splits patch
|
||||
//! and mux need — onto the generic [`crate::io::Pipeline`] +
|
||||
//! [`crate::io::Sink`] primitive. This module is the sweep-specific
|
||||
//! `Sink` impl; the producer-side state machine (read_error context,
|
||||
//! decrypt, set_speed, halt) stays in `Disc::sweep` in `disc/mod.rs`.
|
||||
//!
|
||||
//! Correctness invariants preserved (same as 0.17.11):
|
||||
//! - Mapfile is single-writer (consumer-only). No locking.
|
||||
//! - All `read_error::ReadCtx` state stays on the producer thread.
|
||||
//! - `set_speed` calls happen on the producer thread (same thread that
|
||||
//! owns the `SectorSource`). No new SCSI concurrency.
|
||||
//! - Per-iteration ordering of file-write → mapfile-record is kept
|
||||
//! intact in the consumer (write before record), so the on-disk
|
||||
//! invariant "mapfile only marks Finished what the file has
|
||||
//! received" survives a crash mid-pass.
|
||||
//! - The BU40N+Initio bridge wedge concern is unchanged: only one
|
||||
//! SCSI command in flight at a time, error-path timing identical,
|
||||
//! no new retry logic.
|
||||
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
|
||||
|
||||
use crate::error::Error;
|
||||
use crate::io::{Flow, Sink};
|
||||
|
||||
use super::mapfile::{MapStats, Mapfile, SectorStatus};
|
||||
|
||||
/// Reusable zero buffer for SkipFill / GapFill / BisectBad. 64 KB
|
||||
/// matches the existing zero_gap chunk size used by the pre-split
|
||||
/// sweep loop.
|
||||
const ZERO_CHUNK: usize = 65 * 1024;
|
||||
|
||||
/// Producer → Consumer messages. The consumer applies these in FIFO
|
||||
/// order; ordering of file writes and mapfile records across items is
|
||||
/// preserved.
|
||||
pub(super) enum WorkItem {
|
||||
/// Successful batch read. Producer has already decrypted `buf` if
|
||||
/// `opts.decrypt` was set. Consumer writes `buf` at `pos` and
|
||||
/// records the range as `Finished`.
|
||||
Good { pos: u64, buf: Vec<u8> },
|
||||
|
||||
/// Bisect inner-loop good single sector (already decrypted by the
|
||||
/// producer). 2048 bytes.
|
||||
BisectGood { pos: u64, buf: Box<[u8; 2048]> },
|
||||
|
||||
/// Bisect inner-loop bad single sector. Consumer writes 2048
|
||||
/// zeros at `pos` and records the sector as `NonTrimmed`.
|
||||
BisectBad { pos: u64 },
|
||||
|
||||
/// Whole-batch zero-fill (failed batch on `SkipBlock`, or the
|
||||
/// failed batch portion of `JumpAhead`). Consumer streams zeros
|
||||
/// across `[pos, pos+len)` and records the range as `NonTrimmed`.
|
||||
SkipFill { pos: u64, len: u64 },
|
||||
|
||||
/// Gap fill following a `JumpAhead`. Same effect as `SkipFill`;
|
||||
/// distinguished only so future logging / instrumentation can
|
||||
/// tell them apart without parsing a flag.
|
||||
GapFill { pos: u64, len: u64 },
|
||||
|
||||
/// Producer wants the latest mapfile stats for the progress
|
||||
/// callback. Consumer responds on `prog_tx` with a fresh
|
||||
/// [`ProgressSnapshot`]. Best-effort: if the producer hasn't
|
||||
/// drained the previous snapshot, the new one is silently
|
||||
/// dropped — the producer's local cache stays current enough.
|
||||
StatsRequest,
|
||||
}
|
||||
|
||||
/// Snapshot the consumer sends back to the producer for the progress
|
||||
/// callback.
|
||||
pub(super) struct ProgressSnapshot {
|
||||
pub stats: MapStats,
|
||||
pub bad_ranges: Vec<(u64, u64)>,
|
||||
}
|
||||
|
||||
/// Final summary returned by the consumer thread on shutdown — what
|
||||
/// `SweepSink::close` produces, surfaced to the producer via
|
||||
/// `Pipeline::finish`.
|
||||
pub(super) struct ConsumerSummary {
|
||||
pub stats: MapStats,
|
||||
}
|
||||
|
||||
/// Drain any pending progress snapshots from the consumer. Returns
|
||||
/// the most recent one, if any. The producer caches it and uses it
|
||||
/// for subsequent progress callbacks until a fresh one arrives.
|
||||
pub(super) fn try_recv_progress(rx: &Receiver<ProgressSnapshot>) -> Option<ProgressSnapshot> {
|
||||
let mut latest = None;
|
||||
while let Ok(snap) = rx.try_recv() {
|
||||
latest = Some(snap);
|
||||
}
|
||||
latest
|
||||
}
|
||||
|
||||
/// `Sink<WorkItem>` for sweep. Owns the writeback file + mapfile +
|
||||
/// progress back-channel. `apply` carries the file-write +
|
||||
/// mapfile.record per item; `close` drains the writeback pipeline,
|
||||
/// fsyncs the ISO, and flushes the mapfile.
|
||||
pub(super) struct SweepSink {
|
||||
file: crate::io::WritebackFile,
|
||||
map: Mapfile,
|
||||
/// `sync_all`-on-failure-is-an-error iff the output is a regular
|
||||
/// file. `/dev/null` and pipes always fail `sync_all`; that's not
|
||||
/// a real error.
|
||||
is_regular: bool,
|
||||
/// Back-channel for `StatsRequest` responses. The producer caches
|
||||
/// the latest snapshot and uses it for the progress callback;
|
||||
/// dropped sends on a full channel are by design.
|
||||
prog_tx: SyncSender<ProgressSnapshot>,
|
||||
/// Reusable zero buffer for SkipFill / GapFill / BisectBad. Held
|
||||
/// in the sink so each apply call doesn't reallocate.
|
||||
zero: Box<[u8; ZERO_CHUNK]>,
|
||||
}
|
||||
|
||||
impl SweepSink {
|
||||
/// Construct a new `SweepSink` plus the matching progress
|
||||
/// receiver. Channel depth on the back-channel is `1` — the
|
||||
/// producer's cache is the source of truth between snapshots.
|
||||
pub(super) fn new(
|
||||
file: crate::io::WritebackFile,
|
||||
map: Mapfile,
|
||||
is_regular: bool,
|
||||
) -> (Self, Receiver<ProgressSnapshot>) {
|
||||
let (prog_tx, prog_rx) = sync_channel::<ProgressSnapshot>(1);
|
||||
let sink = SweepSink {
|
||||
file,
|
||||
map,
|
||||
is_regular,
|
||||
prog_tx,
|
||||
zero: Box::new([0u8; ZERO_CHUNK]),
|
||||
};
|
||||
(sink, prog_rx)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sink<WorkItem> for SweepSink {
|
||||
type Output = ConsumerSummary;
|
||||
|
||||
fn apply(&mut self, item: WorkItem) -> Result<Flow, Error> {
|
||||
match item {
|
||||
WorkItem::Good { pos, buf } => {
|
||||
// Decrypt is on the producer; consumer assumes plaintext.
|
||||
let len = buf.len() as u64;
|
||||
self.file
|
||||
.seek(SeekFrom::Start(pos))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.file
|
||||
.write_all(&buf)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.map
|
||||
.record(pos, len, SectorStatus::Finished)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
}
|
||||
WorkItem::BisectGood { pos, buf } => {
|
||||
self.file
|
||||
.seek(SeekFrom::Start(pos))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.file
|
||||
.write_all(&buf[..])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.map
|
||||
.record(pos, 2048, SectorStatus::Finished)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
}
|
||||
WorkItem::BisectBad { pos } => {
|
||||
self.file
|
||||
.seek(SeekFrom::Start(pos))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.file
|
||||
.write_all(&self.zero[..2048])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
self.map
|
||||
.record(pos, 2048, SectorStatus::NonTrimmed)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
}
|
||||
WorkItem::SkipFill { pos, len } | WorkItem::GapFill { pos, len } => {
|
||||
self.file
|
||||
.seek(SeekFrom::Start(pos))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
// Subsequent writes are sequential; `WritebackFile`'s
|
||||
// seek-elision keeps them on the writeback pipeline path.
|
||||
let mut filled = 0u64;
|
||||
while filled < len {
|
||||
let chunk = (len - filled).min(self.zero.len() as u64) as usize;
|
||||
self.file
|
||||
.write_all(&self.zero[..chunk])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
filled += chunk as u64;
|
||||
}
|
||||
self.map
|
||||
.record(pos, len, SectorStatus::NonTrimmed)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
}
|
||||
WorkItem::StatsRequest => {
|
||||
let stats = self.map.stats();
|
||||
let bad_ranges = self.map.ranges_with(&[
|
||||
SectorStatus::NonTrimmed,
|
||||
SectorStatus::Unreadable,
|
||||
SectorStatus::NonScraped,
|
||||
SectorStatus::NonTried,
|
||||
]);
|
||||
// Best-effort: drop on backpressure; producer's cache
|
||||
// stays current enough.
|
||||
let _ = self
|
||||
.prog_tx
|
||||
.try_send(ProgressSnapshot { stats, bad_ranges });
|
||||
}
|
||||
}
|
||||
Ok(Flow::Continue)
|
||||
}
|
||||
|
||||
fn close(mut self) -> Result<Self::Output, Error> {
|
||||
// Drain the writeback pipeline + fsync the ISO, then persist
|
||||
// any pending mapfile state. Same finalisation order as the
|
||||
// pre-Pipeline consumer loop.
|
||||
if let Err(e) = self.file.sync_all() {
|
||||
if self.is_regular {
|
||||
return Err(Error::IoError { source: e });
|
||||
}
|
||||
// Non-regular outputs (/dev/null, pipes) always fail
|
||||
// sync_all; that's not a real error.
|
||||
}
|
||||
self.map.flush().map_err(|e| Error::IoError { source: e })?;
|
||||
|
||||
Ok(ConsumerSummary {
|
||||
stats: self.map.stats(),
|
||||
})
|
||||
}
|
||||
}
|
||||
+10
-11
@@ -1,26 +1,25 @@
|
||||
//! macOS drive discovery and device resolution.
|
||||
//!
|
||||
//! `find_drives` uses IOKit registry enumeration (via `scsi::list_drives`)
|
||||
//! to discover optical drives without exclusive access or unmounts. Only
|
||||
//! the returned paths are then opened for INQUIRY to build full `DriveId`.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::identity::DriveId;
|
||||
|
||||
pub fn find_drives() -> Vec<(String, DriveId)> {
|
||||
let mut drives = Vec::new();
|
||||
for i in 0..16 {
|
||||
let path = format!("/dev/disk{}", i);
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
continue;
|
||||
}
|
||||
match crate::scsi::open(std::path::Path::new(&path)) {
|
||||
let discovered = crate::scsi::list_drives();
|
||||
for info in discovered {
|
||||
let path = std::path::Path::new(&info.path);
|
||||
match crate::scsi::open(path) {
|
||||
Ok(mut transport) => {
|
||||
if let Ok(id) = DriveId::from_drive(transport.as_mut()) {
|
||||
if !id.raw_inquiry.is_empty() && (id.raw_inquiry[0] & 0x1F) == 0x05 {
|
||||
drives.push((path, id));
|
||||
}
|
||||
drives.push((info.path.clone(), id));
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Device exists but can't be opened (likely mounted).
|
||||
// Use `diskutil unmountDisk /dev/diskN` to unmount before accessing.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+348
-235
@@ -1,28 +1,38 @@
|
||||
//! Drive session — open, identify, and read from optical drives.
|
||||
//!
|
||||
//! Three-step open:
|
||||
//! 1. `open()` — open device, identify drive. Always OEM.
|
||||
//! 2. `wait_ready()` — wait for disc to spin up. Call before reading.
|
||||
//! 3. `init()` — activate custom firmware. Removes riplock.
|
||||
//! 4. `probe_disc()` — probe disc surface. Drive learns optimal speeds.
|
||||
|
||||
pub(crate) fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) {
|
||||
match e {
|
||||
Error::ScsiError { status, sense, .. } => (*status, *sense),
|
||||
Error::DiscRead { status, sense, .. } => (status.unwrap_or(0), *sense),
|
||||
_ => (0, None),
|
||||
}
|
||||
}
|
||||
|
||||
pub mod capture;
|
||||
|
||||
// Per-platform discovery helpers (the `pub(crate)` `find_drives` /
|
||||
// equivalents). Crate-public so `scsi/{linux,macos,windows}.rs` can
|
||||
// reuse the existing enumeration logic when shaping `DriveInfo`.
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
pub(crate) mod linux;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
pub(crate) mod macos;
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
pub(crate) mod windows;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::event::{Event, EventKind};
|
||||
use crate::identity::DriveId;
|
||||
use crate::platform::mt1959::Mt1959;
|
||||
use crate::platform::PlatformDriver;
|
||||
use crate::platform::mt1959::Mt1959;
|
||||
use crate::profile::{self, DriveProfile};
|
||||
use crate::scsi::ScsiTransport;
|
||||
use crate::sector::SectorReader;
|
||||
use crate::sector::SectorSource;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Physical state of the drive tray and disc.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -47,9 +57,6 @@ const SCSI_GET_EVENT_STATUS: u8 = 0x4A;
|
||||
const SCSI_MODE_SENSE: u8 = 0x5A;
|
||||
const SCSI_REPORT_KEY: u8 = 0xA4;
|
||||
|
||||
/// Recovery state after a read error — stay at min speed for N bytes.
|
||||
const RECOVERY_WINDOW: u64 = 500 * 1024 * 1024; // 500 MB
|
||||
|
||||
/// Optical disc drive session -- open, identify, unlock, and read.
|
||||
pub struct Drive {
|
||||
scsi: Box<dyn ScsiTransport>,
|
||||
@@ -58,9 +65,20 @@ pub struct Drive {
|
||||
pub platform: Option<profile::Platform>,
|
||||
pub drive_id: DriveId,
|
||||
device_path: String,
|
||||
/// Bytes remaining in the min-speed recovery window.
|
||||
/// After a read error, we stay at min speed for RECOVERY_WINDOW bytes.
|
||||
recovery_bytes_remaining: u64,
|
||||
/// Halt flag — when set, Drive::read() bails at the next check point.
|
||||
halt: Arc<AtomicBool>,
|
||||
/// Event handler — fires for read errors and library-level state changes.
|
||||
event_fn: Option<Box<dyn Fn(Event) + Send>>,
|
||||
/// Linux only: raw fd for the corresponding block device (`/dev/sr*`)
|
||||
/// used as a recovery fallback when SCSI READ via `/dev/sg*` returns
|
||||
/// an error. The kernel `sr_mod` driver auto-retries failed reads
|
||||
/// (~5× per command) — historically the reason `dd if=/dev/sr0`
|
||||
/// recovers ~50% of bad sectors that single-shot `SG_IO` READ
|
||||
/// misses on the same drive. `None` when the block device couldn't
|
||||
/// be resolved or opened (no fallback in that case; SCSI read
|
||||
/// errors propagate as before).
|
||||
#[cfg(target_os = "linux")]
|
||||
block_dev_fd: Option<std::os::unix::io::RawFd>,
|
||||
}
|
||||
|
||||
impl Drive {
|
||||
@@ -79,6 +97,9 @@ impl Drive {
|
||||
None => (None, None, None),
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let block_dev_fd = open_block_device_for_sg(device);
|
||||
|
||||
Ok(Drive {
|
||||
scsi: transport,
|
||||
driver,
|
||||
@@ -86,10 +107,67 @@ impl Drive {
|
||||
profile,
|
||||
drive_id,
|
||||
device_path: device.to_string_lossy().to_string(),
|
||||
recovery_bytes_remaining: 0,
|
||||
halt: Arc::new(AtomicBool::new(false)),
|
||||
event_fn: None,
|
||||
#[cfg(target_os = "linux")]
|
||||
block_dev_fd,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a clone of the halt flag. Set to true to interrupt Drive::read().
|
||||
pub fn halt_flag(&self) -> Arc<AtomicBool> {
|
||||
self.halt.clone()
|
||||
}
|
||||
|
||||
/// Halt the drive — Drive::read() will bail at the next check point.
|
||||
pub fn halt(&self) {
|
||||
self.halt.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Clear the halt flag for the next operation.
|
||||
pub fn clear_halt(&self) {
|
||||
self.halt.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Set an event handler for read recovery events.
|
||||
pub fn on_event(&mut self, f: impl Fn(Event) + Send + 'static) {
|
||||
self.event_fn = Some(Box::new(f));
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // public on_event registration kept; Drive currently
|
||||
// has no internal emission sites after the 0.13.6 recovery strip.
|
||||
// DiscStream is the BytesRead source. Plan to drop on_event in 0.14.
|
||||
fn emit(&self, kind: EventKind) {
|
||||
if let Some(ref f) = self.event_fn {
|
||||
f(Event { kind });
|
||||
}
|
||||
}
|
||||
|
||||
fn is_halted(&self) -> bool {
|
||||
self.halt.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Halt-aware SCSI execute. Returns `Err(Halted)` if the flag is set
|
||||
/// before the command dispatches or by the time it completes. The only
|
||||
/// path to talk to the drive in the recovery hot loop; keeps Drive::read
|
||||
/// free of explicit halt checks.
|
||||
fn checked_exec(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
dir: crate::scsi::DataDirection,
|
||||
buf: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<crate::scsi::ScsiResult> {
|
||||
if self.is_halted() {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
let r = self.scsi.as_mut().execute(cdb, dir, buf, timeout_ms)?;
|
||||
if self.is_halted() {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Close the drive cleanly. Unlocks tray, flushes SCSI state, closes fd.
|
||||
/// Also runs automatically on Drop as a safety net.
|
||||
pub fn close(self) {
|
||||
@@ -101,11 +179,6 @@ impl Drive {
|
||||
self.unlock_tray();
|
||||
}
|
||||
|
||||
// NOTE: Debug aid — remove after fd issue is resolved
|
||||
pub fn device_path_owned(&self) -> String {
|
||||
self.device_path.clone()
|
||||
}
|
||||
|
||||
/// Whether this drive has a known profile (unlock parameters available).
|
||||
pub fn has_profile(&self) -> bool {
|
||||
self.profile.is_some()
|
||||
@@ -118,31 +191,16 @@ impl Drive {
|
||||
|
||||
pub fn wait_ready(&mut self) -> Result<()> {
|
||||
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let mut tried_reset = false;
|
||||
|
||||
for _ in 0..60 {
|
||||
let mut buf = [0u8; 0];
|
||||
match self.scsi.as_mut().execute(
|
||||
&tur,
|
||||
crate::scsi::DataDirection::None,
|
||||
&mut buf,
|
||||
5_000,
|
||||
) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(Error::ScsiError { sense_key: 5, .. }) if !tried_reset => {
|
||||
// Illegal Request on TUR — drive may be stuck from a previous session.
|
||||
// Try reset() which attempts multiple recovery approaches.
|
||||
tried_reset = true;
|
||||
if self.reset().is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
// If reset failed but disc is present, proceed anyway —
|
||||
// the scan path will handle errors individually.
|
||||
if self.drive_status() == DriveStatus::DiscPresent {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
if self
|
||||
.scsi
|
||||
.as_mut()
|
||||
.execute(&tur, crate::scsi::DataDirection::None, &mut buf, 5_000)
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
@@ -197,94 +255,18 @@ impl Drive {
|
||||
5_000,
|
||||
) {
|
||||
Ok(_) => DriveStatus::DiscPresent,
|
||||
Err(Error::ScsiError { sense_key: 2, .. }) => DriveStatus::NotReady,
|
||||
Err(Error::ScsiError { sense_key: 6, .. }) => DriveStatus::NotReady, // UNIT ATTENTION
|
||||
Err(ref e)
|
||||
if e.scsi_sense()
|
||||
.is_some_and(|s| s.is_not_ready() || s.is_unit_attention()) =>
|
||||
{
|
||||
DriveStatus::NotReady
|
||||
}
|
||||
_ => DriveStatus::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to reset the drive to a clean state.
|
||||
///
|
||||
/// Escalates through increasingly aggressive recovery:
|
||||
/// 1. Unlock tray + stop/start — handles normal stuck states
|
||||
/// 2. Eject cycle — clears LibreDrive firmware stuck state (proven on BU40N)
|
||||
/// 3. Re-init — firmware re-upload if profile available
|
||||
///
|
||||
/// Note: step 2 physically ejects the tray. On slimline drives the user
|
||||
/// must push it back in manually. Returns Ok(()) if TUR succeeds after
|
||||
/// any step, even if the drive reports "tray open" (that's a valid state).
|
||||
pub fn reset(&mut self) -> Result<()> {
|
||||
let mut buf = [0u8; 0];
|
||||
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
|
||||
// 1. Unlock + stop/start
|
||||
self.unlock_tray();
|
||||
let stop = [SCSI_START_STOP_UNIT, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
let _ =
|
||||
self.scsi
|
||||
.as_mut()
|
||||
.execute(&stop, crate::scsi::DataDirection::None, &mut buf, 5_000);
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
let start = [SCSI_START_STOP_UNIT, 0x00, 0x00, 0x00, 0x01, 0x00];
|
||||
let _ =
|
||||
self.scsi
|
||||
.as_mut()
|
||||
.execute(&start, crate::scsi::DataDirection::None, &mut buf, 5_000);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2000));
|
||||
|
||||
if self
|
||||
.scsi
|
||||
.as_mut()
|
||||
.execute(&tur, crate::scsi::DataDirection::None, &mut buf, 5_000)
|
||||
.is_ok()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 2. Eject cycle — clears MT1959 LibreDrive stuck state.
|
||||
// After eject, TUR returning "Not Ready — tray open" (sense key 2)
|
||||
// counts as success: the drive is functional, just needs disc reinserted.
|
||||
self.unlock_tray();
|
||||
let eject = [SCSI_START_STOP_UNIT, 0x00, 0x00, 0x00, 0x02, 0x00];
|
||||
let _ =
|
||||
self.scsi
|
||||
.as_mut()
|
||||
.execute(&eject, crate::scsi::DataDirection::None, &mut buf, 30_000);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2000));
|
||||
|
||||
match self
|
||||
.scsi
|
||||
.as_mut()
|
||||
.execute(&tur, crate::scsi::DataDirection::None, &mut buf, 5_000)
|
||||
{
|
||||
Ok(_) => return Ok(()),
|
||||
Err(Error::ScsiError { sense_key: 2, .. }) => return Ok(()), // tray open = valid
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 3. If still stuck and we have a profile, try re-init
|
||||
if self.driver.is_some() {
|
||||
self.init()?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(1000));
|
||||
match self.scsi.as_mut().execute(
|
||||
&tur,
|
||||
crate::scsi::DataDirection::None,
|
||||
&mut buf,
|
||||
5_000,
|
||||
) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(Error::ScsiError { sense_key: 2, .. }) => return Ok(()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::DeviceResetFailed {
|
||||
path: self.device_path.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn platform_name(&self) -> &str {
|
||||
match self.platform {
|
||||
Some(ref p) => p.name(),
|
||||
@@ -450,20 +432,41 @@ impl Drive {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read sectors from the disc with automatic error recovery.
|
||||
/// Read sectors from the disc. Single-shot — no inline retries, no
|
||||
/// SCSI reset.
|
||||
///
|
||||
/// On failure: drops to min speed, waits with escalating patience
|
||||
/// (5s, 10s, 15s, 30s, 60s), resets drive between attempts.
|
||||
/// After recovery, stays at min speed for 500 MB before ramping up.
|
||||
/// `recovery=true` uses [`crate::scsi::READ_RECOVERY_TIMEOUT_MS`] (60 s,
|
||||
/// matches sg_dd) for the `Disc::patch` pass; `recovery=false` uses
|
||||
/// [`crate::scsi::READ_TIMEOUT_MS`] (30 s, matches the kernel's
|
||||
/// `/sys/block/sr*/device/timeout` default) for `Disc::copy`'s fast
|
||||
/// skip-forward sweep. Both budgets are generous enough that the drive
|
||||
/// can finish ECC recovery on a marginal sector — pre-0.13.21 this was
|
||||
/// 1.5 s on the fast path which forced the kernel mid-layer to time
|
||||
/// out and escalate while we waited anyway. On any failure returns
|
||||
/// `Err(DiscRead)` immediately; orchestration (`Disc::patch` multi-pass,
|
||||
/// `DiscStream` adaptive batch halving) handles retry policy.
|
||||
///
|
||||
/// Returns Err only after all attempts exhausted — user should clean
|
||||
/// the disc and resume.
|
||||
pub fn read(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let timeout_ms = if self.recovery_bytes_remaining > 0 {
|
||||
30_000
|
||||
/// Inline retry phases (5× gentle + reset+reopen + 5× more) were
|
||||
/// removed in 0.13.6. Per
|
||||
/// the stop-wedge postmortem (2026-04-25),
|
||||
/// the inline reset on the LG BU40N (Initio bridge) wedged drive
|
||||
/// firmware without ever recovering a sector. The remaining recovery
|
||||
/// layers (Disc::patch multi-pass, DiscStream batch halving) do not
|
||||
/// touch the wedge-prone reset path.
|
||||
pub fn read(&mut self, lba: u32, count: u16, buf: &mut [u8], recovery: bool) -> Result<usize> {
|
||||
let timeout_ms = if recovery {
|
||||
crate::scsi::READ_RECOVERY_TIMEOUT_MS
|
||||
} else {
|
||||
10_000
|
||||
crate::scsi::READ_TIMEOUT_MS
|
||||
};
|
||||
tracing::debug!(
|
||||
target: "freemkv::drive",
|
||||
lba,
|
||||
count,
|
||||
recovery,
|
||||
timeout_ms,
|
||||
"Drive::read enter"
|
||||
);
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_10,
|
||||
0x00,
|
||||
@@ -477,94 +480,78 @@ impl Drive {
|
||||
0x00,
|
||||
];
|
||||
|
||||
// Normal read
|
||||
if let Ok(result) = self.scsi.as_mut().execute(
|
||||
match self.checked_exec(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
buf,
|
||||
timeout_ms,
|
||||
) {
|
||||
if self.recovery_bytes_remaining > 0 {
|
||||
let bytes_read = count as u64 * 2048;
|
||||
self.recovery_bytes_remaining =
|
||||
self.recovery_bytes_remaining.saturating_sub(bytes_read);
|
||||
if self.recovery_bytes_remaining == 0 {
|
||||
self.set_speed(0xFFFF);
|
||||
}
|
||||
}
|
||||
return Ok(result.bytes_transferred);
|
||||
}
|
||||
|
||||
// Phase 1: gentle — sleep 30s, retry. 5 times.
|
||||
self.set_speed(0);
|
||||
|
||||
for _ in 0..5 {
|
||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||
|
||||
if let Ok(result) = self.scsi.as_mut().execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
buf,
|
||||
30_000,
|
||||
) {
|
||||
self.recovery_bytes_remaining = RECOVERY_WINDOW;
|
||||
return Ok(result.bytes_transferred);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: fresh start — close, reset, open, init.
|
||||
let device = std::path::PathBuf::from(&self.device_path);
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
let _ = crate::scsi::reset(&device);
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
self.scsi = crate::scsi::open(&device)?;
|
||||
let _ = self.init();
|
||||
let _ = self.wait_ready();
|
||||
self.set_speed(0);
|
||||
|
||||
// Phase 3: gentle again on fresh connection — sleep 30s, retry. 5 times.
|
||||
for _ in 0..5 {
|
||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||
|
||||
if let Ok(result) = self.scsi.as_mut().execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
buf,
|
||||
30_000,
|
||||
) {
|
||||
self.recovery_bytes_remaining = RECOVERY_WINDOW;
|
||||
return Ok(result.bytes_transferred);
|
||||
}
|
||||
}
|
||||
|
||||
// Both phases failed.
|
||||
self.recovery_bytes_remaining = RECOVERY_WINDOW;
|
||||
Err(Error::DiscRead { sector: lba as u64 })
|
||||
}
|
||||
|
||||
/// Fast single-attempt read for verification — no recovery, short timeout.
|
||||
/// Returns Ok on success, Err on any failure. Does not retry.
|
||||
pub fn read_fast(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
let cdb = [
|
||||
crate::scsi::SCSI_READ_10,
|
||||
0x00,
|
||||
(lba >> 24) as u8,
|
||||
(lba >> 16) as u8,
|
||||
(lba >> 8) as u8,
|
||||
lba as u8,
|
||||
0x00,
|
||||
(count >> 8) as u8,
|
||||
count as u8,
|
||||
0x00,
|
||||
];
|
||||
match self.scsi.as_mut().execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
buf,
|
||||
5_000, // 5 second timeout — enough for healthy reads, fast fail for bad sectors
|
||||
) {
|
||||
Ok(result) => Ok(result.bytes_transferred),
|
||||
Err(_) => Err(Error::DiscRead { sector: lba as u64 }),
|
||||
Err(Error::Halted) => Err(Error::Halted),
|
||||
Err(e) => {
|
||||
let (status, sense) = extract_scsi_context(&e);
|
||||
tracing::warn!(
|
||||
target: "freemkv::drive",
|
||||
lba,
|
||||
count,
|
||||
inner_error = %e,
|
||||
scsi_status = status,
|
||||
"Drive::read checked_exec failed"
|
||||
);
|
||||
|
||||
// /dev/sr0 pread fallback (Linux only). The kernel
|
||||
// sr_mod driver auto-retries failed reads (~5× per
|
||||
// command). Empirically (BU40N + Dune Part 2 UHD,
|
||||
// 2026-05-08) dd via /dev/sr0 recovers ~50% of bad
|
||||
// sectors that a single-shot SG_IO READ misses.
|
||||
#[cfg(target_os = "linux")]
|
||||
if recovery {
|
||||
if let Some(fd) = self.block_dev_fd {
|
||||
let len = count as usize * 2048;
|
||||
if buf.len() >= len {
|
||||
let offset = lba as i64 * 2048;
|
||||
// Drop kernel cache for this region so we get
|
||||
// a fresh device read, not stale page-cache
|
||||
// data from a prior successful neighbour read.
|
||||
let _ = unsafe {
|
||||
libc::posix_fadvise(
|
||||
fd,
|
||||
offset,
|
||||
len as i64,
|
||||
libc::POSIX_FADV_DONTNEED,
|
||||
)
|
||||
};
|
||||
let n = unsafe {
|
||||
libc::pread(fd, buf.as_mut_ptr() as *mut libc::c_void, len, offset)
|
||||
};
|
||||
if n == len as isize {
|
||||
tracing::info!(
|
||||
target: "freemkv::drive",
|
||||
lba,
|
||||
count,
|
||||
bytes = len,
|
||||
"Drive::read recovered via /dev/sr0 pread fallback"
|
||||
);
|
||||
return Ok(len);
|
||||
}
|
||||
tracing::debug!(
|
||||
target: "freemkv::drive",
|
||||
lba,
|
||||
count,
|
||||
pread_ret = n as i64,
|
||||
errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
|
||||
"/dev/sr0 pread fallback also failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::DiscRead {
|
||||
sector: lba as u64,
|
||||
status: Some(status),
|
||||
sense,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,32 +649,110 @@ impl Drop for Drive {
|
||||
fn drop(&mut self) {
|
||||
self.cleanup();
|
||||
// SgIoTransport::drop() runs next, calling libc::close(fd)
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(fd) = self.block_dev_fd.take() {
|
||||
unsafe { libc::close(fd) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for Drive {
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
self.read(lba, count, buf)
|
||||
/// Resolve a `/dev/sg*` path to the corresponding `/dev/sr*` block
|
||||
/// device by walking sysfs, then open it for read (no `O_DIRECT` —
|
||||
/// `posix_fadvise(POSIX_FADV_DONTNEED)` flushes the cache before each
|
||||
/// pread, which avoids buffer-alignment requirements while still
|
||||
/// forcing fresh device reads).
|
||||
///
|
||||
/// Returns `None` on any error (sysfs not present, no matching block
|
||||
/// device, open failed). Callers treat that as "no fallback available"
|
||||
/// and propagate the original SCSI READ error.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn open_block_device_for_sg(sg_path: &Path) -> Option<std::os::unix::io::RawFd> {
|
||||
let basename = sg_path.file_name()?.to_str()?;
|
||||
if !basename.starts_with("sg") {
|
||||
return None;
|
||||
}
|
||||
let sysfs_dir = format!("/sys/class/scsi_generic/{}/device/block", basename);
|
||||
let entries = std::fs::read_dir(&sysfs_dir).ok()?;
|
||||
let block_name = entries
|
||||
.flatten()
|
||||
.find_map(|e| e.file_name().into_string().ok())?;
|
||||
let block_path = format!("/dev/{}", block_name);
|
||||
|
||||
fn read_sectors_fast(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
self.read_fast(lba, count, buf)
|
||||
let mut bytes = block_path.as_bytes().to_vec();
|
||||
bytes.push(0);
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
bytes.as_ptr() as *const libc::c_char,
|
||||
libc::O_RDONLY | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
tracing::debug!(
|
||||
target: "freemkv::drive",
|
||||
sg = basename,
|
||||
block_path,
|
||||
errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
|
||||
"Failed to open block device for fallback; sr0 fallback disabled"
|
||||
);
|
||||
None
|
||||
} else {
|
||||
tracing::info!(
|
||||
target: "freemkv::drive",
|
||||
sg = basename,
|
||||
block_path,
|
||||
fd,
|
||||
"Opened /dev/sr* as recovery fallback for failed SCSI reads"
|
||||
);
|
||||
Some(fd)
|
||||
}
|
||||
}
|
||||
|
||||
/// Find all optical drives connected to this system.
|
||||
/// Returns opened Drive objects ready for use.
|
||||
pub fn find_drives() -> Vec<Drive> {
|
||||
impl SectorSource for Drive {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
recovery: bool,
|
||||
) -> Result<usize> {
|
||||
self.read(lba, count, buf, recovery)
|
||||
}
|
||||
|
||||
fn set_speed(&mut self, kbs: u16) {
|
||||
Drive::set_speed(self, kbs);
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the first optical drive on this system and open it.
|
||||
///
|
||||
/// For just listing drives without opening (e.g. UI sidebar), use
|
||||
/// `scsi::list_drives()` — that returns `DriveInfo` (path + identity)
|
||||
/// without the cost of running every drive's profile + identity probe.
|
||||
pub fn find_drive() -> Option<Drive> {
|
||||
discover_drives()
|
||||
.into_iter()
|
||||
.filter_map(|(path, _)| Drive::open(std::path::Path::new(&path)).ok())
|
||||
.collect()
|
||||
.find_map(|(path, _)| Drive::open(std::path::Path::new(&path)).ok())
|
||||
}
|
||||
|
||||
/// Find the first optical drive.
|
||||
/// Returns an opened Drive ready for use.
|
||||
pub fn find_drive() -> Option<Drive> {
|
||||
find_drives().into_iter().next()
|
||||
/// Halt-aware sleep primitive — wakes within ~100 ms of `halt` flipping
|
||||
/// to true. Kept for the unit tests that cover the slicing behaviour;
|
||||
/// production code paths no longer sleep on the recovery hot path
|
||||
/// (recovery loop removed in 0.13.6).
|
||||
#[cfg(test)]
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal: discover drive paths + IDs without opening full Drive objects.
|
||||
@@ -730,10 +795,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());
|
||||
}
|
||||
}
|
||||
|
||||
+341
-10
@@ -24,10 +24,15 @@ pub const E_DEVICE_NOT_FOUND: u16 = 1000;
|
||||
pub const E_DEVICE_PERMISSION: u16 = 1001;
|
||||
pub const E_DEVICE_NOT_READY: u16 = 1002;
|
||||
pub const E_DEVICE_RESET_FAILED: u16 = 1003;
|
||||
pub const E_SCSI_INTERFACE_UNAVAILABLE: u16 = 1004;
|
||||
pub const E_DEVICE_LOCKED: u16 = 1005;
|
||||
pub const E_IOKIT_PLUGIN_FAILED: u16 = 1006;
|
||||
|
||||
// Profile (2xxx)
|
||||
pub const E_UNSUPPORTED_DRIVE: u16 = 2000;
|
||||
pub const E_PROFILE_PARSE: u16 = 2002;
|
||||
pub const E_UNSUPPORTED_PLATFORM: u16 = 2003;
|
||||
pub const E_PLATFORM_NOT_IMPLEMENTED: u16 = 2004;
|
||||
|
||||
// Unlock (3xxx)
|
||||
pub const E_UNLOCK_FAILED: u16 = 3000;
|
||||
@@ -41,6 +46,7 @@ pub const E_IO_ERROR: u16 = 5000;
|
||||
|
||||
// Disc format (6xxx)
|
||||
pub const E_DISC_READ: u16 = 6000;
|
||||
pub const E_HALTED: u16 = 6010;
|
||||
pub const E_MPLS_PARSE: u16 = 6001;
|
||||
pub const E_CLPI_PARSE: u16 = 6002;
|
||||
pub const E_UDF_NOT_FOUND: u16 = 6003;
|
||||
@@ -48,6 +54,7 @@ pub const E_DISC_TITLE_RANGE: u16 = 6005;
|
||||
pub const E_IFO_PARSE: u16 = 6007;
|
||||
pub const E_MKV_INVALID: u16 = 6008;
|
||||
pub const E_NO_STREAMS: u16 = 6009;
|
||||
pub const E_MAPFILE_INVALID: u16 = 6011;
|
||||
|
||||
// AACS (7xxx)
|
||||
pub const E_AACS_NO_KEYS: u16 = 7000;
|
||||
@@ -83,6 +90,7 @@ pub const E_PES_FRAME_TOO_LARGE: u16 = 9005;
|
||||
pub const E_PES_INVALID_MAGIC: u16 = 9006;
|
||||
pub const E_ISO_TOO_LARGE: u16 = 9007;
|
||||
pub const E_NO_METADATA: u16 = 9008;
|
||||
pub const E_DISC_URL_NOT_DIRECT: u16 = 9009;
|
||||
|
||||
// ── Error enum ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -102,6 +110,24 @@ pub enum Error {
|
||||
DeviceResetFailed {
|
||||
path: String,
|
||||
},
|
||||
/// Platform-specific SCSI interface couldn't be obtained from the OS
|
||||
/// (macOS: `SCSITaskDeviceInterface` unavailable). The `path` field
|
||||
/// carries the device path; no English commentary on the failure mode.
|
||||
ScsiInterfaceUnavailable {
|
||||
path: String,
|
||||
},
|
||||
/// Device is held by another process / kernel state. `kr` is the
|
||||
/// platform return code (macOS IOReturn, Linux errno-equivalent).
|
||||
DeviceLocked {
|
||||
path: String,
|
||||
kr: u32,
|
||||
},
|
||||
/// macOS IOKit plugin couldn't be created for this device. `kr` is
|
||||
/// the IOReturn code from `IOCreatePlugInInterfaceForService`.
|
||||
IoKitPluginFailed {
|
||||
path: String,
|
||||
kr: u32,
|
||||
},
|
||||
|
||||
// Profile (2xxx)
|
||||
UnsupportedDrive {
|
||||
@@ -110,6 +136,16 @@ pub enum Error {
|
||||
product_revision: String,
|
||||
},
|
||||
ProfileParse,
|
||||
/// SCSI transport was requested on an OS without a backend
|
||||
/// implementation. `target` is the `std::env::consts::OS` value.
|
||||
UnsupportedPlatform {
|
||||
target: String,
|
||||
},
|
||||
/// Drive matched a known platform that we haven't implemented yet
|
||||
/// (e.g. Renesas firmware). `platform` is a stable identifier.
|
||||
PlatformNotImplemented {
|
||||
platform: String,
|
||||
},
|
||||
|
||||
// Unlock (3xxx)
|
||||
UnlockFailed,
|
||||
@@ -119,10 +155,24 @@ pub enum Error {
|
||||
},
|
||||
|
||||
// SCSI (4xxx)
|
||||
/// SCSI command failed.
|
||||
///
|
||||
/// `opcode` is the failing CDB byte 0. `status` is the raw SCSI
|
||||
/// status byte: `0x02` = CHECK CONDITION (drive replied with sense
|
||||
/// data), `0xFF` = libfreemkv-synthesised sentinel meaning "no SCSI
|
||||
/// status delivered" (kernel timeout, USB bridge wedge, IOKit
|
||||
/// service failure). `sense` carries the drive's SPC-4 sense triple
|
||||
/// when the drive replied; `None` for transport-layer failures.
|
||||
///
|
||||
/// Recommended dispatch (callers shouldn't pattern-match raw
|
||||
/// fields):
|
||||
/// - [`Error::is_scsi_transport_failure`] — bail; bridge/transport wedge
|
||||
/// - [`Error::is_marginal_read`] — drive said this read was marginal; smaller block may recover
|
||||
/// - [`Error::scsi_sense`] — borrow the sense triple for finer routing ([`ScsiSense::is_medium_error`] etc.)
|
||||
ScsiError {
|
||||
opcode: u8,
|
||||
status: u8,
|
||||
sense_key: u8,
|
||||
sense: Option<crate::scsi::ScsiSense>,
|
||||
},
|
||||
|
||||
// I/O (5xxx)
|
||||
@@ -133,7 +183,11 @@ pub enum Error {
|
||||
// Disc format (6xxx)
|
||||
DiscRead {
|
||||
sector: u64,
|
||||
status: Option<u8>,
|
||||
sense: Option<crate::scsi::ScsiSense>,
|
||||
},
|
||||
/// Drive was halted by caller.
|
||||
Halted,
|
||||
MplsParse,
|
||||
ClpiParse,
|
||||
UdfNotFound {
|
||||
@@ -146,6 +200,12 @@ pub enum Error {
|
||||
IfoParse,
|
||||
MkvInvalid,
|
||||
NoStreams,
|
||||
/// ddrescue mapfile parse failed. `kind` is a stable, language-neutral
|
||||
/// identifier (e.g. `"status_char"`, `"hex"`); not a translatable
|
||||
/// English message.
|
||||
MapfileInvalid {
|
||||
kind: &'static str,
|
||||
},
|
||||
|
||||
// AACS (7xxx)
|
||||
AacsNoKeys,
|
||||
@@ -199,6 +259,10 @@ pub enum Error {
|
||||
path: String,
|
||||
},
|
||||
NoMetadata,
|
||||
/// `disc://` URLs aren't openable through `input()` — callers must use
|
||||
/// `Drive::open() + Disc::scan() + DiscStream::new()` directly. This
|
||||
/// is a structural API constraint, not a parse failure.
|
||||
DiscUrlNotDirect,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
@@ -208,13 +272,19 @@ impl Error {
|
||||
Error::DevicePermission { .. } => E_DEVICE_PERMISSION,
|
||||
Error::DeviceNotReady { .. } => E_DEVICE_NOT_READY,
|
||||
Error::DeviceResetFailed { .. } => E_DEVICE_RESET_FAILED,
|
||||
Error::ScsiInterfaceUnavailable { .. } => E_SCSI_INTERFACE_UNAVAILABLE,
|
||||
Error::DeviceLocked { .. } => E_DEVICE_LOCKED,
|
||||
Error::IoKitPluginFailed { .. } => E_IOKIT_PLUGIN_FAILED,
|
||||
Error::UnsupportedDrive { .. } => E_UNSUPPORTED_DRIVE,
|
||||
Error::ProfileParse => E_PROFILE_PARSE,
|
||||
Error::UnsupportedPlatform { .. } => E_UNSUPPORTED_PLATFORM,
|
||||
Error::PlatformNotImplemented { .. } => E_PLATFORM_NOT_IMPLEMENTED,
|
||||
Error::UnlockFailed => E_UNLOCK_FAILED,
|
||||
Error::SignatureMismatch { .. } => E_SIGNATURE_MISMATCH,
|
||||
Error::ScsiError { .. } => E_SCSI_ERROR,
|
||||
Error::IoError { .. } => E_IO_ERROR,
|
||||
Error::DiscRead { .. } => E_DISC_READ,
|
||||
Error::Halted => E_HALTED,
|
||||
Error::MplsParse => E_MPLS_PARSE,
|
||||
Error::ClpiParse => E_CLPI_PARSE,
|
||||
Error::UdfNotFound { .. } => E_UDF_NOT_FOUND,
|
||||
@@ -222,6 +292,7 @@ impl Error {
|
||||
Error::IfoParse => E_IFO_PARSE,
|
||||
Error::MkvInvalid => E_MKV_INVALID,
|
||||
Error::NoStreams => E_NO_STREAMS,
|
||||
Error::MapfileInvalid { .. } => E_MAPFILE_INVALID,
|
||||
Error::AacsNoKeys => E_AACS_NO_KEYS,
|
||||
Error::AacsCertShort => E_AACS_CERT_SHORT,
|
||||
Error::AacsAgidAlloc => E_AACS_AGID_ALLOC,
|
||||
@@ -251,6 +322,7 @@ impl Error {
|
||||
Error::PesInvalidMagic => E_PES_INVALID_MAGIC,
|
||||
Error::IsoTooLarge { .. } => E_ISO_TOO_LARGE,
|
||||
Error::NoMetadata => E_NO_METADATA,
|
||||
Error::DiscUrlNotDirect => E_DISC_URL_NOT_DIRECT,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -263,6 +335,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,
|
||||
@@ -291,19 +379,47 @@ impl std::fmt::Display for Error {
|
||||
Error::ScsiError {
|
||||
opcode,
|
||||
status,
|
||||
sense_key,
|
||||
} => {
|
||||
write!(
|
||||
sense,
|
||||
} => match sense {
|
||||
Some(s) => write!(
|
||||
f,
|
||||
"E{}: 0x{:02x}/0x{:02x}/0x{:02x}",
|
||||
"E{}: 0x{:02x}/0x{:02x}/0x{:02x}/0x{:02x}/0x{:02x}",
|
||||
self.code(),
|
||||
opcode,
|
||||
status,
|
||||
sense_key
|
||||
)
|
||||
}
|
||||
s.sense_key,
|
||||
s.asc,
|
||||
s.ascq,
|
||||
),
|
||||
None => write!(f, "E{}: 0x{:02x}/0x{:02x}", self.code(), opcode, status,),
|
||||
},
|
||||
Error::IoError { source } => write!(f, "E{}: {}", self.code(), source),
|
||||
Error::DiscRead { sector } => write!(f, "E{}: {}", self.code(), sector),
|
||||
Error::DiscRead {
|
||||
sector,
|
||||
status,
|
||||
sense,
|
||||
} => match (status, sense) {
|
||||
(Some(st), Some(s)) => write!(
|
||||
f,
|
||||
"E{}: {} 0x{:02x}/0x{:02x}/0x{:02x}",
|
||||
self.code(),
|
||||
sector,
|
||||
st,
|
||||
s.sense_key,
|
||||
s.asc,
|
||||
),
|
||||
(Some(st), None) => write!(f, "E{}: {} 0x{:02x}", self.code(), sector, st,),
|
||||
(None, Some(s)) => write!(
|
||||
f,
|
||||
"E{}: {} 0x{:02x}/0x{:02x}",
|
||||
self.code(),
|
||||
sector,
|
||||
s.sense_key,
|
||||
s.asc,
|
||||
),
|
||||
(None, None) => write!(f, "E{}: {}", self.code(), sector),
|
||||
},
|
||||
Error::Halted => write!(f, "E{}", self.code()),
|
||||
Error::UdfNotFound { path } => write!(f, "E{}: {}", self.code(), path),
|
||||
Error::DiscTitleRange { index, count } => {
|
||||
write!(f, "E{}: {}/{}", self.code(), index, count)
|
||||
@@ -352,7 +468,10 @@ impl From<Error> for std::io::Error {
|
||||
7000..=7999 => std::io::ErrorKind::PermissionDenied,
|
||||
8000..=8999 => std::io::ErrorKind::Other,
|
||||
9000..=9001 => std::io::ErrorKind::Unsupported,
|
||||
9002..=9009 => std::io::ErrorKind::InvalidInput,
|
||||
9002..=9008 => std::io::ErrorKind::InvalidInput,
|
||||
// 9009 DiscUrlNotDirect: structurally unsupported entry point,
|
||||
// not a parse failure — caller used the wrong API.
|
||||
9009 => std::io::ErrorKind::Unsupported,
|
||||
_ => std::io::ErrorKind::Other,
|
||||
};
|
||||
std::io::Error::new(kind, msg)
|
||||
@@ -361,3 +480,215 @@ impl From<Error> for std::io::Error {
|
||||
|
||||
/// Convenience alias for `Result<T, Error>`.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
impl Error {
|
||||
/// Borrow the drive-returned SPC-4 sense triple if this error is a
|
||||
/// [`Error::ScsiError`] carrying sense data. `None` for any other
|
||||
/// variant **and** for `ScsiError`s that represent a transport-layer
|
||||
/// failure (where the device never delivered a SCSI status reply, so
|
||||
/// no sense data exists).
|
||||
pub fn scsi_sense(&self) -> Option<&crate::scsi::ScsiSense> {
|
||||
match self {
|
||||
Error::ScsiError { sense: Some(s), .. } => Some(s),
|
||||
Error::DiscRead { sense: Some(s), .. } => Some(s),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if this is a [`Error::ScsiError`] representing a transport-layer
|
||||
/// failure — kernel timeout, USB bridge wedge, IOKit service error.
|
||||
/// The device never delivered a SCSI status reply, so there is no
|
||||
/// sense data to inspect; retrying typically requires physical
|
||||
/// intervention (replug).
|
||||
pub fn is_scsi_transport_failure(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Error::ScsiError {
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
..
|
||||
}
|
||||
) || matches!(
|
||||
self,
|
||||
Error::DiscRead {
|
||||
status: Some(crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE),
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// True if this error indicates bridge degradation — the SCSI status
|
||||
/// is neither GOOD (0x00), CHECK CONDITION (0x02), nor transport failure
|
||||
/// (0xFF). Observed on the Initio INIC-1618L USB bridge preceding a full
|
||||
/// crash: the bridge firmware returns non-standard status bytes (e.g.
|
||||
/// 0x04, 0x05) with empty sense data. The caller should cool down
|
||||
/// (10 s pause) and retry rather than hammering the bridge.
|
||||
pub fn is_bridge_degradation(&self) -> bool {
|
||||
let status = match self {
|
||||
Error::ScsiError { status, .. } => *status,
|
||||
Error::DiscRead { status, .. } => status.unwrap_or(0),
|
||||
_ => return false,
|
||||
};
|
||||
status != crate::scsi::SCSI_STATUS_GOOD
|
||||
&& status != crate::scsi::SCSI_STATUS_CHECK_CONDITION
|
||||
&& status != crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE
|
||||
}
|
||||
|
||||
/// True if the underlying SCSI failure is a *marginal read* — the
|
||||
/// drive returned an error category in which smaller-granularity
|
||||
/// retries can sometimes recover the data:
|
||||
///
|
||||
/// - MEDIUM ERROR (sense key 3) — canonical bad-sector signal
|
||||
/// - ABORTED COMMAND (sense key B) — transient; retry usually works
|
||||
/// - RECOVERED ERROR (sense key 1) / NO SENSE (sense key 0) — not
|
||||
/// classified as fatal; treat as recoverable
|
||||
///
|
||||
/// Returns `false` for transport failures (no sense data delivered),
|
||||
/// HARDWARE ERROR, DATA PROTECT, UNIT ATTENTION, ILLEGAL
|
||||
/// REQUEST, BLANK CHECK, kernel `IoError`, and any non-SCSI variant.
|
||||
/// Caller-agnostic predicate — describes a property of the *error*,
|
||||
/// not what one specific call site should do with it. Used by
|
||||
/// `Disc::copy`'s hysteresis dispatch.
|
||||
pub fn is_marginal_read(&self) -> bool {
|
||||
self.scsi_sense()
|
||||
.map(crate::scsi::ScsiSense::is_marginal)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,22 @@ pub enum EventKind {
|
||||
sector_count: u64,
|
||||
},
|
||||
|
||||
/// Sector recovered after a retry (Drive::read multi-phase recovery).
|
||||
SectorRecovered { sector: u64 },
|
||||
|
||||
/// Sector unreadable, zero-filled (skip mode).
|
||||
SectorSkipped { sector: u64 },
|
||||
|
||||
/// Adaptive batch sizer changed the read size.
|
||||
///
|
||||
/// Fires on shrink (read failed at larger size) and on probe-up
|
||||
/// (enough clean reads to try larger again). Consumers use this to
|
||||
/// display a "recovering" state distinct from "ripping normally".
|
||||
BatchSizeChanged {
|
||||
new_size: u16,
|
||||
reason: BatchSizeReason,
|
||||
},
|
||||
|
||||
/// Operation complete.
|
||||
Complete {
|
||||
/// Total bytes written.
|
||||
@@ -89,5 +105,14 @@ pub enum EventKind {
|
||||
},
|
||||
}
|
||||
|
||||
/// Why the adaptive batch sizer changed size.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BatchSizeReason {
|
||||
/// Read failed; sizer halved the batch.
|
||||
Shrunk,
|
||||
/// Clean-read streak threshold hit; sizer doubled toward preferred.
|
||||
Probed,
|
||||
}
|
||||
|
||||
/// A no-op event handler. Ignores all events.
|
||||
pub fn ignore(_event: Event) {}
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
//! One-bit cooperative cancellation flag.
|
||||
//!
|
||||
//! `Halt` is a clonable token wrapping `Arc<AtomicBool>`. Pass clones into
|
||||
//! every long-running loop; the loop polls `is_cancelled()` and bails out
|
||||
//! cleanly. Calling `cancel()` from any clone flips the shared flag, and
|
||||
//! every other clone observes it on its next poll.
|
||||
//!
|
||||
//! Why: `Ordering::Relaxed` is sufficient on both load and store because
|
||||
//! this flag is purely advisory — no other memory operations piggyback on
|
||||
//! it for happens-before ordering. Callers that need to publish data
|
||||
//! across threads do so via channels or other synchronization, not via
|
||||
//! this bit.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Clonable, infallible cooperative-cancellation token.
|
||||
///
|
||||
/// Clones share the same underlying flag. `cancel()` is one-way; there is
|
||||
/// no `reset()` by design — construct a fresh `Halt` for a fresh
|
||||
/// operation.
|
||||
///
|
||||
/// Construct with [`Halt::new`] (or [`Halt::default`]). The `Default`
|
||||
/// impl forwards to `new()` — both produce a fresh, uncancelled token.
|
||||
/// The pair exists because clippy's `new_without_default` lint requires
|
||||
/// `Default` whenever a public `new()` is present, even when the two
|
||||
/// would do exactly the same thing.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Halt(Arc<AtomicBool>);
|
||||
|
||||
impl Halt {
|
||||
/// Construct a fresh, uncancelled token.
|
||||
pub fn new() -> Self {
|
||||
Self(Arc::new(AtomicBool::new(false)))
|
||||
}
|
||||
|
||||
/// Wrap an existing `Arc<AtomicBool>` as a `Halt`. Useful as a
|
||||
/// bridge during the 0.18 deprecation window: callers that already
|
||||
/// hold an `Arc<AtomicBool>` (e.g. `Drive::halt_flag()`, the
|
||||
/// deprecated `DiscStream::set_halt`) can adopt the new token API
|
||||
/// without changing the underlying flag.
|
||||
///
|
||||
/// Cancelling either side flips the same bit — the wrapping `Halt`
|
||||
/// and the original `Arc` are two views over one shared flag.
|
||||
pub fn from_arc(flag: Arc<AtomicBool>) -> Self {
|
||||
Self(flag)
|
||||
}
|
||||
|
||||
/// Borrow the underlying `Arc<AtomicBool>`. Used at boundaries with
|
||||
/// pre-`Halt` APIs that still take an `Arc<AtomicBool>` directly
|
||||
/// (`CopyOptions::halt`, the deprecated `DiscStream::set_halt`).
|
||||
/// Round 3 deletes those boundaries and this accessor with them.
|
||||
pub fn as_arc(&self) -> &Arc<AtomicBool> {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Flip the shared flag to cancelled. Idempotent.
|
||||
pub fn cancel(&self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Read the shared flag.
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.0.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Halt {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fresh_is_not_cancelled() {
|
||||
let h = Halt::new();
|
||||
assert!(!h.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_flips_state() {
|
||||
let h = Halt::new();
|
||||
assert!(!h.is_cancelled());
|
||||
h.cancel();
|
||||
assert!(h.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_is_idempotent() {
|
||||
let h = Halt::new();
|
||||
h.cancel();
|
||||
h.cancel();
|
||||
assert!(h.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_shares_state() {
|
||||
let original = Halt::new();
|
||||
let cloned = original.clone();
|
||||
assert!(!original.is_cancelled());
|
||||
assert!(!cloned.is_cancelled());
|
||||
|
||||
// Cancel via the clone; the original observes it.
|
||||
cloned.cancel();
|
||||
assert!(original.is_cancelled());
|
||||
assert!(cloned.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_shares_state_reverse_direction() {
|
||||
let original = Halt::new();
|
||||
let cloned = original.clone();
|
||||
|
||||
// Cancel via the original; the clone observes it.
|
||||
original.cancel();
|
||||
assert!(cloned.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_shares_state_across_threads() {
|
||||
let h = Halt::new();
|
||||
let h2 = h.clone();
|
||||
let handle = std::thread::spawn(move || {
|
||||
h2.cancel();
|
||||
});
|
||||
handle.join().unwrap();
|
||||
assert!(h.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_arc_shares_state() {
|
||||
// The 0.18 deprecation-window bridge: a Halt built from an
|
||||
// existing Arc<AtomicBool> must be a *view* over the same bit,
|
||||
// not a fresh copy. Cancelling either side flips both.
|
||||
let arc = Arc::new(AtomicBool::new(false));
|
||||
let halt = Halt::from_arc(arc.clone());
|
||||
assert!(!halt.is_cancelled());
|
||||
assert!(!arc.load(Ordering::Relaxed));
|
||||
|
||||
// Cancel via the wrapping Halt; the original Arc observes it.
|
||||
halt.cancel();
|
||||
assert!(arc.load(Ordering::Relaxed));
|
||||
|
||||
// Conversely: flip the Arc directly; the Halt view observes it.
|
||||
let arc2 = Arc::new(AtomicBool::new(false));
|
||||
let halt2 = Halt::from_arc(arc2.clone());
|
||||
arc2.store(true, Ordering::Relaxed);
|
||||
assert!(halt2.is_cancelled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn as_arc_returns_backing_flag() {
|
||||
// `as_arc()` must hand back the *same* Arc, not a clone of a
|
||||
// different bit. Verified by writing through the borrowed Arc
|
||||
// and observing through the Halt.
|
||||
let halt = Halt::new();
|
||||
let arc = halt.as_arc().clone();
|
||||
assert!(!halt.is_cancelled());
|
||||
arc.store(true, Ordering::Relaxed);
|
||||
assert!(halt.is_cancelled());
|
||||
}
|
||||
}
|
||||
+9
-7
@@ -9,7 +9,7 @@
|
||||
|
||||
use crate::disc::{Codec, Resolution};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
|
||||
// ── Public types ────────────────────────────────────────────────────────────
|
||||
@@ -184,7 +184,7 @@ fn bcd_byte(b: u8) -> u32 {
|
||||
///
|
||||
/// Reads the VMG (Video Manager) to discover title sets, then reads each
|
||||
/// VTS IFO to extract PGC chains, cell addresses, and stream attributes.
|
||||
pub fn parse_vmg(reader: &mut dyn SectorReader, udf: &UdfFs) -> Result<DvdInfo> {
|
||||
pub fn parse_vmg(reader: &mut dyn SectorSource, udf: &UdfFs) -> Result<DvdInfo> {
|
||||
let vmg_data = udf.read_file(reader, "/VIDEO_TS/VIDEO_TS.IFO")?;
|
||||
|
||||
// Validate VMG magic
|
||||
@@ -263,7 +263,7 @@ pub fn parse_vmg(reader: &mut dyn SectorReader, udf: &UdfFs) -> Result<DvdInfo>
|
||||
///
|
||||
/// `titles_info` is a list of (chapter_count, vts_title_number) from TT_SRPT.
|
||||
fn parse_vts(
|
||||
reader: &mut dyn SectorReader,
|
||||
reader: &mut dyn SectorSource,
|
||||
udf: &UdfFs,
|
||||
vts_number: u8,
|
||||
titles_info: &[(u16, u8)],
|
||||
@@ -564,7 +564,9 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle>
|
||||
}
|
||||
// Program map: each byte is the first cell number (1-based) for that program
|
||||
for p in 0..nr_of_programs {
|
||||
if pgm_base + p >= data.len() { break; }
|
||||
if pgm_base + p >= data.len() {
|
||||
break;
|
||||
}
|
||||
let first_cell = data[pgm_base + p] as usize;
|
||||
// Chapter time = sum of cell durations before this program's first cell
|
||||
let time: f64 = cell_durations[..first_cell.saturating_sub(1)].iter().sum();
|
||||
@@ -732,12 +734,12 @@ mod tests {
|
||||
let mut pgc = vec![0u8; 0xEA];
|
||||
pgc[0x02] = 1; // 1 program
|
||||
pgc[0x03] = 2; // 2 cells
|
||||
// 1h 59m 30s at 29.97fps, 0 frames
|
||||
// 1h 59m 30s at 29.97fps, 0 frames
|
||||
pgc[0x04] = 0x01; // hours BCD
|
||||
pgc[0x05] = 0x59; // minutes BCD
|
||||
pgc[0x06] = 0x30; // seconds BCD
|
||||
pgc[0x07] = 0b11_000000; // 29.97fps, 0 frames
|
||||
// Cell playback info offset at PGC+0xE8
|
||||
// Cell playback info offset at PGC+0xE8
|
||||
let cell_offset: u16 = 0xEA; // right after minimum header
|
||||
pgc[0xE8] = (cell_offset >> 8) as u8;
|
||||
pgc[0xE9] = cell_offset as u8;
|
||||
@@ -753,7 +755,7 @@ mod tests {
|
||||
pgc[co + 21] = 0;
|
||||
pgc[co + 22] = 0;
|
||||
pgc[co + 23] = 200; // last sector
|
||||
// Cell 1: sectors 300-400
|
||||
// Cell 1: sectors 300-400
|
||||
let co = 0xEA + 24;
|
||||
pgc[co + 8] = 0;
|
||||
pgc[co + 9] = 0;
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Bounded-syscall primitive: run a (potentially-blocking) operation
|
||||
//! on a worker thread, with a hard wall-clock deadline and an optional
|
||||
//! cooperative [`Halt`] poll. The calling thread is never trapped
|
||||
//! inside a kernel call.
|
||||
//!
|
||||
//! ## Why this exists
|
||||
//!
|
||||
//! [`crate::halt::Halt`] is cooperative: callers poll
|
||||
//! `is_cancelled()`. It cannot reach inside a syscall the kernel
|
||||
//! currently owns the thread for — `libc::sync_file_range`,
|
||||
//! `libc::fsync`, `File::write` on NFS, and so on. `/api/stop` from
|
||||
//! autorip therefore can't unstick a thread sitting in such a syscall.
|
||||
//!
|
||||
//! [`bounded_syscall`] is the escape hatch: it runs `op` on a fresh
|
||||
//! worker thread, then `recv_timeout`s on a rendezvous channel for the
|
||||
//! result. The wait is broken into ~250 ms slices so the calling
|
||||
//! thread can poll the supplied [`Halt`] in between. If the deadline
|
||||
//! elapses or the halt fires, the worker is intentionally leaked — the
|
||||
//! syscall will unwind whenever the kernel decides, or at process
|
||||
//! exit, but the caller is free to fall back to a degraded code path
|
||||
//! (skip the sync, log loudly, etc.).
|
||||
//!
|
||||
//! ## Trade-offs
|
||||
//!
|
||||
//! - **Thread per call.** Cheap (`std::thread::spawn` is < 100 µs on
|
||||
//! Linux/macOS), but not free. Use on coarse-grained finalisation
|
||||
//! syscalls (`sync_all`, `sync_file_range(WAIT_AFTER)`), not on hot
|
||||
//! inner-loop writes.
|
||||
//! - **Leak on timeout.** A wedged syscall keeps a kernel slot and a
|
||||
//! user-space thread around for the rest of the process's life.
|
||||
//! Bounded by the number of independent rip/mux sessions, which is
|
||||
//! one per disc. The alternative — trapping the caller forever —
|
||||
//! defeats the entire purpose of `/api/stop`.
|
||||
//! - **Halt granularity ~250 ms.** Halt observation is not instant;
|
||||
//! it's the worst-case latency of the `recv_timeout` slice. Good
|
||||
//! enough for human-driven stop requests; not suitable for hard
|
||||
//! real-time deadlines.
|
||||
//!
|
||||
//! ## Single source of truth
|
||||
//!
|
||||
//! Do NOT inline this pattern. Every blocking-syscall wrapper in the
|
||||
//! rip + mux pipeline calls this helper, so changes (e.g. swapping the
|
||||
//! channel impl, adjusting the poll slice, adding metrics) land in one
|
||||
//! place.
|
||||
//!
|
||||
//! ## Platform
|
||||
//!
|
||||
//! Pure `std::thread` + `std::sync::mpsc`. No `cfg(target_os)` needed
|
||||
//! here — the helper itself is platform-agnostic. Callers that wrap
|
||||
//! Linux-only syscalls (`sync_file_range`) still need their own
|
||||
//! `#[cfg(target_os = "linux")]` gates; this helper does not.
|
||||
|
||||
use std::sync::mpsc::{RecvTimeoutError, sync_channel};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::halt::Halt;
|
||||
|
||||
/// Granularity of the halt poll. The receive loop wakes every
|
||||
/// [`POLL_INTERVAL`] to (a) check the [`Halt`] token, then (b) check
|
||||
/// the overall deadline, then go back to waiting. 250 ms is a
|
||||
/// pragmatic balance: short enough that human-driven `/api/stop` feels
|
||||
/// responsive (< 0.5 s p99), long enough that the polling overhead is
|
||||
/// negligible against multi-second syscalls.
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Failure outcome from a bounded syscall wrapper.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum BoundedError {
|
||||
/// The user-visible halt token fired during the wait. The worker
|
||||
/// thread is intentionally leaked — the caller should fall back to
|
||||
/// a degraded code path rather than waiting on the syscall to
|
||||
/// return.
|
||||
Halted,
|
||||
/// The deadline elapsed before the syscall returned. Same leak
|
||||
/// semantics as `Halted`.
|
||||
Timeout,
|
||||
/// The worker thread panicked, or its sender disconnected before
|
||||
/// sending a result. Treat as a benign no-op (callers usually
|
||||
/// log and continue) rather than a hard error — by definition no
|
||||
/// syscall observably ran to completion in this case.
|
||||
WorkerLost,
|
||||
}
|
||||
|
||||
/// Run a (potentially-blocking) operation on a worker thread with a
|
||||
/// deadline and an optional cooperative halt-token poll. Returns the
|
||||
/// operation's result if it completes within `timeout`; otherwise one
|
||||
/// of [`BoundedError::Halted`] / [`BoundedError::Timeout`] /
|
||||
/// [`BoundedError::WorkerLost`].
|
||||
///
|
||||
/// On `Halted` / `Timeout` the worker thread is intentionally leaked:
|
||||
/// the syscall will unwind whenever the kernel decides, or when the
|
||||
/// process exits. The calling thread is never trapped inside a kernel
|
||||
/// call.
|
||||
///
|
||||
/// `halt` is polled at [`POLL_INTERVAL`] granularity. Pass `None` for
|
||||
/// callers that don't (yet) have a halt token plumbed through —
|
||||
/// behaviour degrades to deadline-only, matching the 0.20.5
|
||||
/// `wait_after_with_timeout` shape this helper generalises.
|
||||
///
|
||||
/// `op` returns `R: Send + 'static`. The closure must own everything
|
||||
/// it touches because it may outlive this call (timeout / halt cases).
|
||||
pub(crate) fn bounded_syscall<F, R>(
|
||||
halt: Option<&Halt>,
|
||||
timeout: Duration,
|
||||
op: F,
|
||||
) -> Result<R, BoundedError>
|
||||
where
|
||||
F: FnOnce() -> R + Send + 'static,
|
||||
R: Send + 'static,
|
||||
{
|
||||
// Rendezvous channel: the worker sends exactly one value (the
|
||||
// op's return) and then exits. Capacity-0 means the send blocks
|
||||
// until we receive — fine on the happy path; on the timeout /
|
||||
// halt path the receiver is dropped and the worker's send
|
||||
// returns Err, which the worker ignores.
|
||||
let (tx, rx) = sync_channel::<R>(0);
|
||||
let _ = thread::Builder::new()
|
||||
.name("freemkv-bounded-syscall".into())
|
||||
.spawn(move || {
|
||||
// Ignore the send error: if we time out (or get halted)
|
||||
// before the worker finishes, the receiver is dropped
|
||||
// and `tx.send` returns Err. Either way, the worker has
|
||||
// nothing more to do.
|
||||
let _ = tx.send(op());
|
||||
});
|
||||
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
let remaining = deadline.saturating_duration_since(now);
|
||||
let slice = remaining.min(POLL_INTERVAL);
|
||||
match rx.recv_timeout(slice) {
|
||||
Ok(v) => return Ok(v),
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if let Some(h) = halt {
|
||||
if h.is_cancelled() {
|
||||
return Err(BoundedError::Halted);
|
||||
}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err(BoundedError::Timeout);
|
||||
}
|
||||
// Otherwise: another slice.
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => {
|
||||
// Worker thread spawn failed, or it panicked before
|
||||
// sending. Caller treats this as "no syscall ran" —
|
||||
// typically a no-op + log.
|
||||
return Err(BoundedError::WorkerLost);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
#[test]
|
||||
fn op_completes_quickly() {
|
||||
let r = bounded_syscall(None, Duration::from_secs(2), || 42u32);
|
||||
assert!(matches!(r, Ok(42)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn op_exceeds_timeout() {
|
||||
// Op sleeps longer than the deadline → Timeout.
|
||||
let r = bounded_syscall(None, Duration::from_millis(300), || {
|
||||
thread::sleep(Duration::from_secs(2));
|
||||
0u32
|
||||
});
|
||||
assert!(matches!(r, Err(BoundedError::Timeout)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halt_fires_during_wait() {
|
||||
let halt = Halt::new();
|
||||
let halt2 = halt.clone();
|
||||
// Flip the halt from a side thread after ~300 ms — long
|
||||
// enough that the receive loop has rolled at least one
|
||||
// 250 ms slice and is sitting in `recv_timeout` again when
|
||||
// the bit flips.
|
||||
thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
halt2.cancel();
|
||||
});
|
||||
let r = bounded_syscall(Some(&halt), Duration::from_secs(5), || {
|
||||
thread::sleep(Duration::from_secs(5));
|
||||
0u32
|
||||
});
|
||||
assert!(matches!(r, Err(BoundedError::Halted)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_panics() {
|
||||
// Worker panics → sender drops without sending → recv sees
|
||||
// Disconnected → WorkerLost. We use an explicit panic in the
|
||||
// op closure rather than `panic!()` from inside the channel
|
||||
// machinery; the spawned thread's panic is contained (no
|
||||
// process abort) because we don't `.join()` it.
|
||||
let r = bounded_syscall(None, Duration::from_secs(2), || -> u32 {
|
||||
panic!("intentional test panic");
|
||||
});
|
||||
assert!(matches!(r, Err(BoundedError::WorkerLost)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halt_already_set_before_call_still_returns_halted() {
|
||||
// Halt observed on the very first poll slice. The op blocks
|
||||
// forever; we must not wait the full timeout to notice the
|
||||
// halt is already set.
|
||||
let halt = Halt::new();
|
||||
halt.cancel();
|
||||
let started = Instant::now();
|
||||
let r = bounded_syscall(Some(&halt), Duration::from_secs(10), || {
|
||||
thread::sleep(Duration::from_secs(10));
|
||||
0u32
|
||||
});
|
||||
assert!(matches!(r, Err(BoundedError::Halted)));
|
||||
// Should bail out within ~1 s; allow 2 s of slack for slow
|
||||
// CI hosts.
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(2),
|
||||
"halt-already-set took {:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_path_takes_no_halt_token() {
|
||||
// Sanity: the `None` halt path is the documented zero-config
|
||||
// form (matches the 0.20.5 `wait_after_with_timeout`
|
||||
// behaviour). Op returns immediately; we must observe Ok.
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let f2 = flag.clone();
|
||||
let r = bounded_syscall(None, Duration::from_secs(2), move || {
|
||||
f2.store(true, Ordering::Relaxed);
|
||||
"ok"
|
||||
});
|
||||
assert!(matches!(r, Ok("ok")));
|
||||
assert!(flag.load(Ordering::Relaxed));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! File I/O helpers that bound kernel cache pressure on big writes.
|
||||
//!
|
||||
//! `WritebackFile` is a drop-in wrapper around `std::fs::File` for any
|
||||
//! call site that performs large sequential writes (sweep, patch, mux,
|
||||
//! etc.). It implements `Write` and `Seek` so existing code paths can
|
||||
//! swap `File` for `WritebackFile` with no body changes. Internally it
|
||||
//! drives a `WritebackPipeline` that, on Linux, drains dirty pages
|
||||
//! continuously at 32 MB granularity to avoid the kernel's
|
||||
//! accumulate-then-burst flush behaviour. macOS and Windows use a
|
||||
//! no-op pipeline — their default cache policies have not been shown
|
||||
//! to exhibit the same pathology for this access pattern.
|
||||
//!
|
||||
//! `Pipeline` + `Sink` (0.18) is the generic producer/consumer primitive
|
||||
//! used by sweep, patch, and mux to overlap reads with writes via a
|
||||
//! bounded channel + dedicated consumer thread.
|
||||
|
||||
pub(crate) mod bounded;
|
||||
mod writeback;
|
||||
mod writeback_file;
|
||||
|
||||
pub mod pipeline;
|
||||
|
||||
pub(crate) use writeback_file::WritebackFile;
|
||||
|
||||
// Re-exports for the 0.18 redesign. Sweep + patch are both wired up
|
||||
// (disc/sweep.rs, disc/patch.rs); mux migrates separately in autorip.
|
||||
// `WRITE_THROUGH_DEPTH` is patch-specific and has no other in-tree
|
||||
// caller — the targeted `#[allow]` keeps the re-export visible without
|
||||
// dragging the rest of the module under `dead_code`.
|
||||
#[allow(unused_imports)]
|
||||
pub use pipeline::{
|
||||
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
|
||||
WRITE_THROUGH_DEPTH,
|
||||
};
|
||||
@@ -0,0 +1,556 @@
|
||||
//! Generic bounded producer/consumer pipeline.
|
||||
//!
|
||||
//! `Pipeline<I, R>` spawns a single consumer thread, hands it items
|
||||
//! through a bounded `mpsc::sync_channel`, and joins it on `finish()`.
|
||||
//! The consumer's behaviour is supplied by a [`Sink`] implementation:
|
||||
//! `apply` is called once per item, `close` is called once at the end.
|
||||
//!
|
||||
//! Three call sites in libfreemkv want a producer/consumer split —
|
||||
//! sweep (migrated to `disc/sweep.rs::SweepSink`), patch, and mux.
|
||||
//! 0.18 collapses all three onto this primitive; sweep is in,
|
||||
//! patch and mux migrate in later 0.18 slices.
|
||||
//! See `(internal)/memory/0_18_redesign.md` for the full picture.
|
||||
//!
|
||||
//! ## Cancellation and error semantics
|
||||
//!
|
||||
//! - Producer dropping the channel (via `Pipeline::finish` dropping
|
||||
//! `tx`) signals end-of-stream; consumer flushes via `close()` and
|
||||
//! returns its `Output`.
|
||||
//! - Consumer returning [`Flow::Stop`] also calls `close()` and
|
||||
//! returns its `Output`. `send()` from the producer will then either
|
||||
//! succeed (if the item already fit in the channel buffer) or fail
|
||||
//! with `Err(item)` once the consumer has dropped its receiver.
|
||||
//! - Consumer returning `Err` from `apply` skips `close()` entirely;
|
||||
//! the consumer keeps draining the channel so the producer never
|
||||
//! blocks on a dead receiver, and the first error is propagated as
|
||||
//! the `JoinHandle` result.
|
||||
//! - Consumer panic is converted into
|
||||
//! `Error::IoError { source: io::Error::other(...) }`.
|
||||
//!
|
||||
//! ## Debug logging
|
||||
//!
|
||||
//! Set `FREEMKV_DEBUG=1` environment variable to enable verbose debug
|
||||
//! logging throughout the pipeline (channel sends/receives, backpressure,
|
||||
//! consumer lag detection). This is critical for diagnosing stalls.
|
||||
|
||||
use std::io;
|
||||
use std::sync::mpsc::{SyncSender, sync_channel};
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
/// Check if verbose debug logging is enabled via FREEMKV_DEBUG env var.
|
||||
pub fn debug_enabled() -> bool {
|
||||
std::env::var("FREEMKV_DEBUG")
|
||||
.ok()
|
||||
.map(|v| v == "1" || v == "true" || v == "yes")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Default channel depth for callers without a specific reason to
|
||||
/// pick another value. Kept conservative (4) — most callers should
|
||||
/// use READ_PIPELINE_DEPTH or WRITE_PIPELINE_DEPTH instead.
|
||||
pub const DEFAULT_PIPELINE_DEPTH: usize = 4;
|
||||
|
||||
/// Read pipeline depth. Larger buffer compensates for drive variability
|
||||
/// and NFS sync_file_range stalls; keeps ISO reader thread fed even when
|
||||
/// consumer blocks on write.
|
||||
pub const READ_PIPELINE_DEPTH: usize = 32;
|
||||
|
||||
/// Write pipeline depth. Smaller buffer reduces backpressure risk when
|
||||
/// sync_file_range blocks; prevents producer from accumulating too much
|
||||
/// work while consumer waits for NFS to drain.
|
||||
pub const WRITE_PIPELINE_DEPTH: usize = 16;
|
||||
|
||||
/// Channel depth for write-through pipelines. Each `send` fully
|
||||
/// drains before the next can enqueue. Use this when the producer
|
||||
/// must observe consumer side-effects (e.g. mapfile state) before
|
||||
/// emitting the next item.
|
||||
#[allow(dead_code)]
|
||||
pub const WRITE_THROUGH_DEPTH: usize = 1;
|
||||
|
||||
/// Outcome of [`Sink::apply`]: either keep feeding items
|
||||
/// ([`Flow::Continue`]), or stop the pipeline early and run `close()`
|
||||
/// ([`Flow::Stop`]).
|
||||
///
|
||||
/// `Stop` has no in-tree caller in this slice — sweep never returns
|
||||
/// it (it always processes the producer's full work-list before the
|
||||
/// channel is dropped). Patch and mux are the intended consumers and
|
||||
/// migrate in later 0.18 slices. The variant ships now so the contract
|
||||
/// is fixed; the targeted `#[allow]` is removed when patch lands.
|
||||
pub enum Flow {
|
||||
Continue,
|
||||
#[allow(dead_code)]
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// Consumer-side of a [`Pipeline`]. The pipeline owns one of these on
|
||||
/// its consumer thread and calls `apply` once per received item, then
|
||||
/// `close` once at end-of-stream.
|
||||
pub trait Sink<I>: Send + 'static {
|
||||
/// Type returned from `close()` and surfaced via
|
||||
/// [`Pipeline::finish`].
|
||||
type Output: Send + 'static;
|
||||
|
||||
/// Apply one item. Returning [`Flow::Continue`] keeps the
|
||||
/// pipeline running; [`Flow::Stop`] ends it cleanly (still calls
|
||||
/// `close()`). An error short-circuits: `close()` is *not* called
|
||||
/// and the error is what `finish()` will return, but the consumer
|
||||
/// keeps draining the channel so the producer never blocks on a
|
||||
/// dead receiver.
|
||||
fn apply(&mut self, item: I) -> Result<Flow, Error>;
|
||||
|
||||
/// Called once at end-of-stream — either because the producer
|
||||
/// dropped `tx` or because `apply` returned [`Flow::Stop`]. Use
|
||||
/// this to flush, fsync, finalise. Skipped if any prior `apply`
|
||||
/// returned `Err`.
|
||||
fn close(self) -> Result<Self::Output, Error>;
|
||||
}
|
||||
|
||||
/// Bounded producer/consumer pipeline. Holds the producer-side
|
||||
/// channel and the consumer thread's join handle.
|
||||
pub struct Pipeline<I: Send + 'static, R: Send + 'static> {
|
||||
tx: SyncSender<I>,
|
||||
handle: JoinHandle<Result<R, Error>>,
|
||||
}
|
||||
|
||||
impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
|
||||
/// Spawn the consumer thread with the given channel depth and
|
||||
/// [`Sink`].
|
||||
///
|
||||
/// The thread is named `freemkv-pipeline-consumer` so it shows up
|
||||
/// distinctly in stack traces and `top -H`. Callers that want a
|
||||
/// more specific name (e.g. `freemkv-sweep-consumer`) should use
|
||||
/// [`Pipeline::spawn_named`] instead. Returns an `Error::IoError`
|
||||
/// if the OS refuses the thread spawn (resource exhaustion);
|
||||
/// callers already operate in fallible context, so this is
|
||||
/// propagated rather than panicked.
|
||||
///
|
||||
/// Sweep uses [`Pipeline::spawn_named`] directly so the consumer
|
||||
/// thread shows up as `freemkv-sweep-consumer`; this function has
|
||||
/// no in-tree caller yet. Patch and mux migrate in later 0.18
|
||||
/// slices. The targeted `#[allow]` is removed when one of them
|
||||
/// lands on the default name.
|
||||
#[allow(dead_code)]
|
||||
pub fn spawn<S: Sink<I, Output = R>>(depth: usize, sink: S) -> Result<Self, Error> {
|
||||
Self::spawn_named("freemkv-pipeline-consumer", depth, sink)
|
||||
}
|
||||
|
||||
/// Like [`Pipeline::spawn`] but lets the caller supply the
|
||||
/// consumer thread's name. Useful when several pipelines run in
|
||||
/// the same process and stack traces / `top -H` need to tell them
|
||||
/// apart (e.g. `freemkv-sweep-consumer`, `freemkv-mux-consumer`).
|
||||
pub fn spawn_named<S: Sink<I, Output = R>>(
|
||||
name: &str,
|
||||
depth: usize,
|
||||
sink: S,
|
||||
) -> Result<Self, Error> {
|
||||
let (tx, rx) = sync_channel::<I>(depth);
|
||||
let handle = thread::Builder::new()
|
||||
.name(name.into())
|
||||
.spawn(move || -> Result<R, Error> {
|
||||
let mut sink = sink;
|
||||
let mut first_err: Option<Error> = None;
|
||||
let mut stopped = false;
|
||||
|
||||
while let Ok(item) = rx.recv() {
|
||||
if debug_enabled() {
|
||||
tracing::debug!("Pipeline receive: item={}", std::any::type_name::<I>());
|
||||
}
|
||||
|
||||
let apply_start = std::time::Instant::now();
|
||||
|
||||
if first_err.is_some() || stopped {
|
||||
// Drain remaining items so the producer never
|
||||
// blocks on a dead receiver. `apply` is not
|
||||
// called once we've decided to stop.
|
||||
continue;
|
||||
}
|
||||
match sink.apply(item) {
|
||||
Ok(Flow::Continue) => {}
|
||||
Ok(Flow::Stop) => {
|
||||
stopped = true;
|
||||
if debug_enabled() {
|
||||
tracing::debug!("Pipeline: consumer returned Flow::Stop");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if debug_enabled() {
|
||||
tracing::debug!("Pipeline: apply error, stopping, err={:?}", e);
|
||||
}
|
||||
first_err = Some(e);
|
||||
}
|
||||
}
|
||||
|
||||
let apply_elapsed = apply_start.elapsed();
|
||||
if debug_enabled() && apply_elapsed > std::time::Duration::from_millis(100) {
|
||||
tracing::debug!(
|
||||
"Pipeline apply: took {:.2}s, item={}",
|
||||
apply_elapsed.as_secs_f64(),
|
||||
std::any::type_name::<I>()
|
||||
);
|
||||
} else if debug_enabled() {
|
||||
tracing::debug!(
|
||||
"Pipeline apply: OK in {:.3}ms, item={}",
|
||||
apply_elapsed.as_micros(),
|
||||
std::any::type_name::<I>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
match first_err {
|
||||
Some(e) => Err(e),
|
||||
None => sink.close(),
|
||||
}
|
||||
})
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
|
||||
Ok(Pipeline { tx, handle })
|
||||
}
|
||||
|
||||
/// Push one item. Blocks if the channel is full — that's the
|
||||
/// back-pressure the whole primitive exists to provide. Returns
|
||||
/// the item back if the consumer thread is gone (panicked or
|
||||
/// already returned).
|
||||
///
|
||||
/// After the consumer returns [`Flow::Stop`], `send` will silently
|
||||
/// buffer items into the channel until the channel fills, then
|
||||
/// return `Err(item)` once the consumer has dropped its receiver.
|
||||
/// Producers that need to stop pushing on `Stop` should track an
|
||||
/// independent signal (e.g. `Halt`) — `send` alone is not the
|
||||
/// notification edge.
|
||||
pub fn send(&self, item: I) -> Result<(), I> {
|
||||
let start = std::time::Instant::now();
|
||||
match self.tx.send(item) {
|
||||
Ok(()) => {
|
||||
let elapsed = start.elapsed();
|
||||
if debug_enabled() && elapsed > std::time::Duration::from_millis(10) {
|
||||
tracing::debug!(
|
||||
"Pipeline send: blocked {:.2}s, item={}",
|
||||
elapsed.as_secs_f64(),
|
||||
std::any::type_name::<I>()
|
||||
);
|
||||
} else if debug_enabled() {
|
||||
tracing::debug!("Pipeline send: OK in {:.3}ms", elapsed.as_micros());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let elapsed = start.elapsed();
|
||||
if debug_enabled() && elapsed > std::time::Duration::from_millis(10) {
|
||||
tracing::debug!(
|
||||
"Pipeline send: blocked {:.2}s before channel closed, item={}",
|
||||
elapsed.as_secs_f64(),
|
||||
std::any::type_name::<I>()
|
||||
);
|
||||
} else if debug_enabled() {
|
||||
tracing::debug!("Pipeline send: failed after {:.3}ms", elapsed.as_micros());
|
||||
}
|
||||
Err(e.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-blocking variant of [`Pipeline::send`]. If the channel is
|
||||
/// full or the consumer has hung up, the item is returned in
|
||||
/// `Err`. Useful for best-effort signalling (e.g. sweep's
|
||||
/// throttled `StatsRequest`) where dropping the message is
|
||||
/// preferable to blocking the producer.
|
||||
pub fn try_send(&self, item: I) -> Result<(), std::sync::mpsc::TrySendError<I>> {
|
||||
self.tx.try_send(item)
|
||||
}
|
||||
|
||||
/// Drop the producer-side channel and wait for the consumer
|
||||
/// thread to finish. Returns whatever the consumer's `close()`
|
||||
/// produced, or the first `apply` error, or — on consumer panic —
|
||||
/// an `Error::IoError` whose source is `io::Error::other(...)`
|
||||
/// with a "pipeline consumer panicked: <payload>" message
|
||||
/// (callers can match on the constant prefix).
|
||||
pub fn finish(self) -> Result<R, Error> {
|
||||
let Pipeline { tx, handle } = self;
|
||||
// Explicit drop, although the destructure already drops `tx`
|
||||
// at end-of-scope. Being explicit keeps the intent obvious.
|
||||
drop(tx);
|
||||
match handle.join() {
|
||||
Ok(result) => result,
|
||||
Err(payload) => {
|
||||
// Preserve the original panic message when the
|
||||
// consumer's panic payload was a `&str` or `String`
|
||||
// (the two stdlib formats that `panic!` produces).
|
||||
// Anything else falls back to "(no message)".
|
||||
let msg = payload
|
||||
.downcast_ref::<&'static str>()
|
||||
.copied()
|
||||
.or_else(|| payload.downcast_ref::<String>().map(|s| s.as_str()))
|
||||
.unwrap_or("(no message)");
|
||||
Err(Error::IoError {
|
||||
source: io::Error::other(format!("pipeline consumer panicked: {msg}")),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Sums u64s; returns the total from `close`.
|
||||
struct SumSink {
|
||||
total: u64,
|
||||
}
|
||||
|
||||
impl Sink<u64> for SumSink {
|
||||
type Output = u64;
|
||||
|
||||
fn apply(&mut self, item: u64) -> Result<Flow, Error> {
|
||||
self.total += item;
|
||||
Ok(Flow::Continue)
|
||||
}
|
||||
|
||||
fn close(self) -> Result<u64, Error> {
|
||||
Ok(self.total)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_path_sums_items() {
|
||||
let pipe = Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, SumSink { total: 0 })
|
||||
.expect("spawn should succeed");
|
||||
let mut expected = 0u64;
|
||||
for i in 0..100u64 {
|
||||
expected += i;
|
||||
pipe.send(i).expect("send should succeed");
|
||||
}
|
||||
let total = pipe.finish().expect("finish should succeed");
|
||||
assert_eq!(total, expected);
|
||||
assert_eq!(total, (0..100u64).sum::<u64>());
|
||||
}
|
||||
|
||||
/// Sleeps `delay` per apply; counts how many it received.
|
||||
struct SlowSink {
|
||||
delay: Duration,
|
||||
count: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Sink<()> for SlowSink {
|
||||
type Output = usize;
|
||||
|
||||
fn apply(&mut self, _item: ()) -> Result<Flow, Error> {
|
||||
std::thread::sleep(self.delay);
|
||||
self.count.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(Flow::Continue)
|
||||
}
|
||||
|
||||
fn close(self) -> Result<usize, Error> {
|
||||
Ok(self.count.load(Ordering::SeqCst))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn back_pressure_blocks_sender() {
|
||||
// depth=2 + 5 sends + 50ms/apply: with the consumer pinned at
|
||||
// 50 ms per item, the producer can buffer 2 (channel cap) +
|
||||
// 1 (consumer in flight) = 3 items before sends 4 and 5 must
|
||||
// block on consumer progress. Wall-clock floor across all 5
|
||||
// sends is therefore ~2 * 50ms = 100ms (sends 4 and 5 each
|
||||
// wait roughly one apply-cycle). Use 80 ms as the assertion
|
||||
// floor to stay above the 50ms-per-item progress floor while
|
||||
// tolerating CI jitter — it still proves blocking is real.
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let sink = SlowSink {
|
||||
delay: Duration::from_millis(50),
|
||||
count: count.clone(),
|
||||
};
|
||||
let pipe = Pipeline::spawn(2, sink).expect("spawn should succeed");
|
||||
|
||||
let start = Instant::now();
|
||||
for _ in 0..5 {
|
||||
pipe.send(()).expect("send should succeed");
|
||||
}
|
||||
let elapsed_send = start.elapsed();
|
||||
|
||||
let total = pipe.finish().expect("finish should succeed");
|
||||
assert_eq!(total, 5);
|
||||
assert!(
|
||||
elapsed_send >= Duration::from_millis(80),
|
||||
"back-pressure not observed: 5 sends with depth=2 and 50ms/apply \
|
||||
took {elapsed_send:?}, expected ≥ ~100ms (one or more sends \
|
||||
should have blocked behind the consumer)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns `Err` on the Nth apply (1-indexed). Tracks all calls.
|
||||
struct FailOnNthSink {
|
||||
n: usize,
|
||||
seen: Arc<AtomicUsize>,
|
||||
close_called: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Sink<u64> for FailOnNthSink {
|
||||
type Output = ();
|
||||
|
||||
fn apply(&mut self, _item: u64) -> Result<Flow, Error> {
|
||||
let i = self.seen.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if i == self.n {
|
||||
Err(Error::DecryptFailed)
|
||||
} else {
|
||||
Ok(Flow::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
fn close(self) -> Result<(), Error> {
|
||||
self.close_called.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_error_drains_then_propagates() {
|
||||
let seen = Arc::new(AtomicUsize::new(0));
|
||||
let close_called = Arc::new(AtomicUsize::new(0));
|
||||
let pipe = Pipeline::spawn(
|
||||
DEFAULT_PIPELINE_DEPTH,
|
||||
FailOnNthSink {
|
||||
n: 3,
|
||||
seen: seen.clone(),
|
||||
close_called: close_called.clone(),
|
||||
},
|
||||
)
|
||||
.expect("spawn should succeed");
|
||||
|
||||
// Send 10 items. Subsequent sends after the 3rd error must
|
||||
// still succeed (the consumer is draining).
|
||||
for i in 0..10u64 {
|
||||
pipe.send(i).expect("send should succeed even after error");
|
||||
}
|
||||
|
||||
let res = pipe.finish();
|
||||
assert!(matches!(res, Err(Error::DecryptFailed)));
|
||||
assert_eq!(
|
||||
close_called.load(Ordering::SeqCst),
|
||||
0,
|
||||
"close() must not be called when apply returned Err"
|
||||
);
|
||||
// The consumer kept calling `recv` to drain after the error;
|
||||
// it just stopped invoking `apply`. So `seen` is exactly 3
|
||||
// (apply was called for items 1, 2, 3).
|
||||
assert_eq!(seen.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
/// Returns `Flow::Stop` on the Nth apply.
|
||||
struct StopOnNthSink {
|
||||
n: usize,
|
||||
seen: Arc<AtomicUsize>,
|
||||
close_called: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Sink<u64> for StopOnNthSink {
|
||||
type Output = usize;
|
||||
|
||||
fn apply(&mut self, _item: u64) -> Result<Flow, Error> {
|
||||
let i = self.seen.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if i >= self.n {
|
||||
Ok(Flow::Stop)
|
||||
} else {
|
||||
Ok(Flow::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
fn close(self) -> Result<usize, Error> {
|
||||
self.close_called.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(self.seen.load(Ordering::SeqCst))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_stop_calls_close_and_returns_output() {
|
||||
let seen = Arc::new(AtomicUsize::new(0));
|
||||
let close_called = Arc::new(AtomicUsize::new(0));
|
||||
let pipe = Pipeline::spawn(
|
||||
DEFAULT_PIPELINE_DEPTH,
|
||||
StopOnNthSink {
|
||||
n: 3,
|
||||
seen: seen.clone(),
|
||||
close_called: close_called.clone(),
|
||||
},
|
||||
)
|
||||
.expect("spawn should succeed");
|
||||
|
||||
// Send 10 items. After Stop, subsequent sends may either
|
||||
// succeed (already buffered) or fail with Err(I) (channel
|
||||
// closed). Both are valid — we don't assert on the send
|
||||
// results.
|
||||
for i in 0..10u64 {
|
||||
let _ = pipe.send(i);
|
||||
}
|
||||
|
||||
let out = pipe.finish().expect("finish should succeed after Stop");
|
||||
assert_eq!(close_called.load(Ordering::SeqCst), 1);
|
||||
// At least 3 items processed (the one that returned Stop).
|
||||
assert!(
|
||||
out >= 3,
|
||||
"expected ≥ 3 applies before Stop took effect, got {out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Panics on the first apply call.
|
||||
struct PanickingSink;
|
||||
|
||||
impl Sink<u64> for PanickingSink {
|
||||
type Output = ();
|
||||
|
||||
fn apply(&mut self, _item: u64) -> Result<Flow, Error> {
|
||||
panic!("synthetic test panic");
|
||||
}
|
||||
|
||||
fn close(self) -> Result<(), Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumer_panic_becomes_io_error() {
|
||||
// Silence the panic message that would otherwise pollute the
|
||||
// test output — we expect this panic.
|
||||
let prev = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(|_| {}));
|
||||
|
||||
let pipe =
|
||||
Pipeline::spawn(DEFAULT_PIPELINE_DEPTH, PanickingSink).expect("spawn should succeed");
|
||||
// First send may succeed (item buffered before panic) or fail
|
||||
// (channel closed after panic) — either is fine.
|
||||
let _ = pipe.send(1);
|
||||
// Drain a few more sends; once the channel is closed they'll
|
||||
// return Err(I), which we just discard.
|
||||
for i in 0..5u64 {
|
||||
let _ = pipe.send(i);
|
||||
}
|
||||
let res = pipe.finish();
|
||||
|
||||
std::panic::set_hook(prev);
|
||||
|
||||
match res {
|
||||
Err(Error::IoError { source }) => {
|
||||
let msg = source.to_string();
|
||||
// Constant prefix lets callers match without parsing
|
||||
// the variable payload tail.
|
||||
assert!(
|
||||
msg.contains("pipeline consumer panicked"),
|
||||
"expected constant panic prefix, got: {msg}"
|
||||
);
|
||||
// The original `panic!` payload (a `&'static str`) must
|
||||
// be preserved — without the downcast the message
|
||||
// would just be the prefix.
|
||||
assert!(
|
||||
msg.contains("synthetic test panic"),
|
||||
"expected original panic payload, got: {msg}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Err(IoError), got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Per-platform writeback pipeline. On Linux, drains dirty pages
|
||||
//! continuously at chunk granularity to keep the kernel's writeback
|
||||
//! queue bounded. On macOS and Windows, a no-op stub.
|
||||
//!
|
||||
//! The platform decision lives entirely in this file (the cfg-gated
|
||||
//! `pub use` below). Callers — and `DiskWriter` itself — are
|
||||
//! platform-independent.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod noop;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) use linux::WritebackPipeline;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(super) use noop::WritebackPipeline;
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Linux writeback pipeline using `sync_file_range` + `posix_fadvise`.
|
||||
//!
|
||||
//! Pathology this fixes: the kernel's default `vm.dirty_ratio` (~20 %
|
||||
//! of RAM) lets dirty pages accumulate to hundreds of MB during a
|
||||
//! big sequential write, then bursts a flush at 99 % disk utilisation.
|
||||
//! While the burst runs, app writes block on the writeback queue —
|
||||
//! observed empirically as instantaneous speed dropping from ~15 MB/s
|
||||
//! to ~1 MB/s every ~30 s during a Pass 1 sweep.
|
||||
//!
|
||||
//! Strategy: every `chunk_bytes` of new sequential output, kick async
|
||||
//! writeback (`SYNC_FILE_RANGE_WRITE`) on the just-completed chunk and
|
||||
//! finalise the *previous* chunk via `WAIT_AFTER` + `posix_fadvise
|
||||
//! (DONTNEED)`. By the time we finalise, that previous chunk has had
|
||||
//! a full chunk's worth of work to flush — the wait is near-instant.
|
||||
//! Dirty cache stays bounded at ~2 × `chunk_bytes` and writes drain
|
||||
//! continuously instead of in bursts.
|
||||
//!
|
||||
//! The chunk size is adaptive: we measure the elapsed time of the
|
||||
//! `WAIT_AFTER` call over a rolling window of the last 16 chunks and
|
||||
//! resize the chunk based on the p95. Slow storage (NFS, network
|
||||
//! shares, HDD) sees larger chunks to amortise per-chunk overhead;
|
||||
//! fast storage (NVMe) sees smaller chunks to keep cache pressure
|
||||
//! tight. Bounds: [4 MiB, 256 MiB].
|
||||
//!
|
||||
//! ## NFS escape hatch
|
||||
//!
|
||||
//! `sync_file_range(WAIT_AFTER)` on an NFS-mounted file can block
|
||||
//! indefinitely waiting for the server's commit ack. If the server
|
||||
//! never acks (network partition, server-side hang, slow commit), the
|
||||
//! syscall never returns and the consumer thread is stuck inside the
|
||||
//! kernel — `/api/stop` can't reach it because halt is cooperative.
|
||||
//!
|
||||
//! When `fstatfs` reports the file lives on an NFS mount
|
||||
//! (`f_type == NFS_SUPER_MAGIC`), the pipeline skips the WAIT_AFTER +
|
||||
//! `posix_fadvise(DONTNEED)` dance entirely. NFS clients have their
|
||||
//! own buffering and commit semantics that handle dirty-page bounds
|
||||
//! without us forcing the issue. The async `SYNC_FILE_RANGE_WRITE`
|
||||
//! kickoff still runs (non-blocking by spec) so writeback still gets
|
||||
//! a nudge.
|
||||
//!
|
||||
//! ## Defence in depth: WAIT_AFTER timeout
|
||||
//!
|
||||
//! Even on local storage, a degraded disk or odd filesystem driver
|
||||
//! could in principle wedge inside WAIT_AFTER. Each WAIT_AFTER call
|
||||
//! runs on a worker thread with a 30s recv_timeout on its result
|
||||
//! channel. On timeout we log a loud error, set a `degraded` flag,
|
||||
//! and from then on skip WAIT_AFTER + DONTNEED for the rest of the
|
||||
//! pipeline's life (same shape as the NFS path). The worker thread
|
||||
//! is intentionally leaked — it unwinds whenever the syscall
|
||||
//! eventually returns or the process exits. The mux continues; the
|
||||
//! original dirty-burst pathology re-emerges but the rip can still
|
||||
//! finish instead of freezing.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fs::File;
|
||||
use std::os::unix::io::{AsRawFd, RawFd};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const ADAPTIVE_WINDOW: usize = 16;
|
||||
const CHUNK_BYTES_MIN: u64 = 4 * 1024 * 1024;
|
||||
const CHUNK_BYTES_MAX: u64 = 256 * 1024 * 1024;
|
||||
const ADAPTIVE_GROW_MS: u64 = 200;
|
||||
const ADAPTIVE_SHRINK_MS: u64 = 20;
|
||||
/// Every N chunks, emit a `debug!` snapshot of the current chunk
|
||||
/// size so operators tailing the log can see where the autoscaler
|
||||
/// settled.
|
||||
const SIZE_LOG_INTERVAL: u64 = 32;
|
||||
/// Hard upper bound on a single `sync_file_range(WAIT_AFTER)` call.
|
||||
/// Beyond this we declare the pipeline degraded and stop calling
|
||||
/// WAIT_AFTER for the rest of its life.
|
||||
const WAIT_AFTER_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
pub(crate) struct WritebackPipeline {
|
||||
/// Aliases the wrapping `WritebackFile::file`. Only valid for the
|
||||
/// lifetime of that struct — moving the `File` independently
|
||||
/// would silently UAF this fd. The pipeline is a private field of
|
||||
/// `WritebackFile` and never exposed outside that wrapper, which
|
||||
/// is what keeps the alias sound.
|
||||
fd: RawFd,
|
||||
chunk_bytes: u64,
|
||||
last_flush_pos: u64,
|
||||
pending: Option<(u64, u64)>,
|
||||
/// Rolling window of recent `WAIT_AFTER` elapsed_ms measurements.
|
||||
wait_after_window: VecDeque<u64>,
|
||||
/// Count of chunks emitted (used to space out periodic
|
||||
/// `debug!` size snapshots).
|
||||
chunk_count: u64,
|
||||
/// True when the underlying file is on an NFS mount. NFS makes
|
||||
/// WAIT_AFTER unsafe (can block forever on missing server ack), so
|
||||
/// we skip it entirely and let the NFS client handle commit on
|
||||
/// close.
|
||||
is_nfs: bool,
|
||||
/// Set the first time WAIT_AFTER exceeds [`WAIT_AFTER_TIMEOUT`].
|
||||
/// Once set, behaviour matches the NFS path for the rest of the
|
||||
/// pipeline's life. Wrapped in `Arc` only because both this
|
||||
/// struct and the spawned worker thread (which itself doesn't
|
||||
/// touch the flag) share-via-fd patterns might one day need it;
|
||||
/// today it's effectively a single-owner cell — the `Arc` shape
|
||||
/// keeps the door open for moving the read side into a worker
|
||||
/// without re-plumbing types.
|
||||
degraded: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl WritebackPipeline {
|
||||
/// Construct a pipeline aliasing `file`'s file descriptor. The
|
||||
/// returned `WritebackPipeline` MUST be dropped before `file`
|
||||
/// itself, or kept inside the same struct that owns `file` — the
|
||||
/// alias is unchecked.
|
||||
pub(crate) fn new(file: &File, start_pos: u64, chunk_bytes: u64) -> Self {
|
||||
let fd = file.as_raw_fd();
|
||||
let is_nfs = detect_nfs(fd);
|
||||
tracing::info!(
|
||||
target: "mux",
|
||||
"WritebackPipeline fd={fd} is_nfs={is_nfs} chunk_bytes={chunk_bytes} strategy={}",
|
||||
if is_nfs { "nfs-skip-wait" } else { "wait+dontneed" }
|
||||
);
|
||||
Self {
|
||||
fd,
|
||||
chunk_bytes,
|
||||
last_flush_pos: start_pos,
|
||||
pending: None,
|
||||
wait_after_window: VecDeque::with_capacity(ADAPTIVE_WINDOW),
|
||||
chunk_count: 0,
|
||||
is_nfs,
|
||||
degraded: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// True if we should bypass the WAIT_AFTER + DONTNEED finalisation
|
||||
/// step. NFS always bypasses; local storage bypasses once the
|
||||
/// pipeline has flipped to degraded after a WAIT_AFTER timeout.
|
||||
#[inline]
|
||||
fn skip_wait(&self) -> bool {
|
||||
self.is_nfs || self.degraded.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Caller advanced the file position to `pos`. If a chunk boundary
|
||||
/// was crossed, kick async writeback for the just-completed chunk
|
||||
/// and finalise the previous one.
|
||||
pub(crate) fn note_progress(&mut self, pos: u64) {
|
||||
if pos < self.last_flush_pos.saturating_add(self.chunk_bytes) {
|
||||
return;
|
||||
}
|
||||
let chunk_off = self.last_flush_pos as i64;
|
||||
let chunk_len = (pos - self.last_flush_pos) as i64;
|
||||
let mut wait_ms: u64 = 0;
|
||||
let mut fadvise_ms: u64 = 0;
|
||||
// Async kickoff for the just-completed chunk runs on every
|
||||
// path (NFS, degraded, normal) — it's nominally non-blocking
|
||||
// by spec and gives the kernel an early hint that this range
|
||||
// is ready to flush.
|
||||
unsafe {
|
||||
libc::sync_file_range(self.fd, chunk_off, chunk_len, libc::SYNC_FILE_RANGE_WRITE);
|
||||
}
|
||||
if let Some((prev_off, prev_len)) = self.pending.take() {
|
||||
if self.skip_wait() {
|
||||
// NFS branch (or degraded fallback after a prior
|
||||
// timeout): the WAIT_AFTER + DONTNEED dance is what
|
||||
// hangs on NFS — skip it. We still advance `pending`
|
||||
// so the next call has a stable cycle.
|
||||
} else {
|
||||
// Normal local-storage branch with belt-and-braces
|
||||
// timeout. If WAIT_AFTER hangs > WAIT_AFTER_TIMEOUT
|
||||
// we mark the pipeline degraded, log a loud error,
|
||||
// and fall through to the skip path on subsequent
|
||||
// calls.
|
||||
match wait_after_with_timeout(self.fd, prev_off, prev_len) {
|
||||
Some(ms) => {
|
||||
wait_ms = ms;
|
||||
let t_fadv = Instant::now();
|
||||
unsafe {
|
||||
libc::posix_fadvise(
|
||||
self.fd,
|
||||
prev_off as i64,
|
||||
prev_len as i64,
|
||||
libc::POSIX_FADV_DONTNEED,
|
||||
);
|
||||
}
|
||||
fadvise_ms = t_fadv.elapsed().as_millis() as u64;
|
||||
self.record_wait(wait_ms);
|
||||
}
|
||||
None => {
|
||||
// Timeout branch: switch to NFS-style skip
|
||||
// for the rest of the pipeline's life. Do
|
||||
// NOT call DONTNEED — if WAIT_AFTER hasn't
|
||||
// returned, the pages aren't safely flushed.
|
||||
self.degraded.store(true, Ordering::Relaxed);
|
||||
tracing::error!(
|
||||
target: "mux",
|
||||
"WritebackPipeline WAIT_AFTER timed out after {}s on chunk off={} len={}, marking writeback degraded (subsequent chunks will skip WAIT_AFTER + DONTNEED)",
|
||||
WAIT_AFTER_TIMEOUT.as_secs(),
|
||||
prev_off,
|
||||
prev_len
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.pending = Some((chunk_off as u64, chunk_len as u64));
|
||||
self.last_flush_pos = pos;
|
||||
self.chunk_count += 1;
|
||||
tracing::trace!(
|
||||
target: "mux",
|
||||
"WritebackPipeline chunk off={} len={} sync_file_range_ms={wait_ms} fadvise_ms={fadvise_ms} chunk_bytes={} skip_wait={}",
|
||||
chunk_off,
|
||||
chunk_len,
|
||||
self.chunk_bytes,
|
||||
self.skip_wait(),
|
||||
);
|
||||
if self.chunk_count % SIZE_LOG_INTERVAL == 0 {
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"WritebackPipeline chunk_bytes={} after {} chunks is_nfs={} degraded={}",
|
||||
self.chunk_bytes,
|
||||
self.chunk_count,
|
||||
self.is_nfs,
|
||||
self.degraded.load(Ordering::Relaxed),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a new `WAIT_AFTER` measurement into the rolling window
|
||||
/// and, if the window is full, adapt `chunk_bytes` based on p95.
|
||||
fn record_wait(&mut self, wait_ms: u64) {
|
||||
if self.wait_after_window.len() == ADAPTIVE_WINDOW {
|
||||
self.wait_after_window.pop_front();
|
||||
}
|
||||
self.wait_after_window.push_back(wait_ms);
|
||||
if self.wait_after_window.len() < ADAPTIVE_WINDOW {
|
||||
return;
|
||||
}
|
||||
// p95 of 16 samples ≈ sorted[14] (5 % of 16 = 0.8 ≈ 1 above).
|
||||
let mut sorted: Vec<u64> = self.wait_after_window.iter().copied().collect();
|
||||
sorted.sort_unstable();
|
||||
let p95 = sorted[14];
|
||||
let old = self.chunk_bytes;
|
||||
let new = if p95 > ADAPTIVE_GROW_MS && self.chunk_bytes < CHUNK_BYTES_MAX {
|
||||
(self.chunk_bytes * 2).min(CHUNK_BYTES_MAX)
|
||||
} else if p95 < ADAPTIVE_SHRINK_MS && self.chunk_bytes > CHUNK_BYTES_MIN {
|
||||
(self.chunk_bytes / 2).max(CHUNK_BYTES_MIN)
|
||||
} else {
|
||||
self.chunk_bytes
|
||||
};
|
||||
if new != old {
|
||||
self.chunk_bytes = new;
|
||||
tracing::info!(
|
||||
target: "mux",
|
||||
"WritebackPipeline adaptive chunk_bytes {} -> {} p95_ms={p95}",
|
||||
old,
|
||||
new
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Caller is about to seek away from the current write region.
|
||||
/// Drain any in-flight chunk and reset tracking.
|
||||
pub(crate) fn handle_seek(&mut self, new_pos: u64) {
|
||||
self.finalize();
|
||||
self.last_flush_pos = new_pos;
|
||||
}
|
||||
|
||||
/// Drain any in-flight chunk. Idempotent. Call before `sync_all()`
|
||||
/// or when discarding the pipeline.
|
||||
pub(crate) fn finalize(&mut self) {
|
||||
if let Some((prev_off, prev_len)) = self.pending.take() {
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"WritebackPipeline finalize chunk off={prev_off} len={prev_len} skip_wait={} is_nfs={} degraded={}",
|
||||
self.skip_wait(),
|
||||
self.is_nfs,
|
||||
self.degraded.load(Ordering::Relaxed),
|
||||
);
|
||||
if self.skip_wait() {
|
||||
// NFS / degraded: skip WAIT_AFTER + DONTNEED. close()
|
||||
// / sync_all() handle commit through their normal
|
||||
// paths.
|
||||
return;
|
||||
}
|
||||
match wait_after_with_timeout(self.fd, prev_off, prev_len) {
|
||||
Some(_ms) => unsafe {
|
||||
libc::posix_fadvise(
|
||||
self.fd,
|
||||
prev_off as i64,
|
||||
prev_len as i64,
|
||||
libc::POSIX_FADV_DONTNEED,
|
||||
);
|
||||
},
|
||||
None => {
|
||||
self.degraded.store(true, Ordering::Relaxed);
|
||||
tracing::error!(
|
||||
target: "mux",
|
||||
"WritebackPipeline finalize WAIT_AFTER timed out after {}s on chunk off={prev_off} len={prev_len}, marking writeback degraded",
|
||||
WAIT_AFTER_TIMEOUT.as_secs(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe whether `fd` lives on an NFS mount via `fstatfs`. Returns
|
||||
/// `false` on any error — we fail open, not closed: better to run the
|
||||
/// normal local-storage path on a misdetected NFS mount (and surface
|
||||
/// the freeze loudly via the timeout) than to needlessly disable
|
||||
/// writeback bounding on every local file because of a transient
|
||||
/// stat error.
|
||||
fn detect_nfs(fd: RawFd) -> bool {
|
||||
// `libc::statfs` is repr(C) with a fixed layout; zeroing is the
|
||||
// documented init pattern for the kernel uapi struct.
|
||||
let mut buf: libc::statfs = unsafe { std::mem::zeroed() };
|
||||
let rc = unsafe { libc::fstatfs(fd, &mut buf) };
|
||||
if rc != 0 {
|
||||
let errno = std::io::Error::last_os_error();
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"WritebackPipeline fstatfs(fd={fd}) failed: {errno} — defaulting is_nfs=false",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// `f_type` is signed (`__fsword_t`) on glibc and unsigned
|
||||
// (`c_ulong`) on musl. Cast both sides to i64 for a portable
|
||||
// comparison.
|
||||
let f_type = buf.f_type as i64;
|
||||
let nfs_magic = libc::NFS_SUPER_MAGIC as i64;
|
||||
f_type == nfs_magic
|
||||
}
|
||||
|
||||
/// Run `sync_file_range(WAIT_AFTER)` on a worker thread and wait up
|
||||
/// to [`WAIT_AFTER_TIMEOUT`] for it to return. `Some(elapsed_ms)` on
|
||||
/// success; `None` on timeout. On timeout the worker thread is
|
||||
/// 0.20.6 generalizes the worker-thread + recv_timeout pattern into
|
||||
/// [`crate::io::bounded::bounded_syscall`]; this helper now just adapts
|
||||
/// the generic primitive to the WAIT_AFTER call shape (returns elapsed_ms
|
||||
/// instead of the syscall's `()` return, treats `WorkerLost` as a benign
|
||||
/// no-op to match the original semantics).
|
||||
fn wait_after_with_timeout(fd: RawFd, off: u64, len: u64) -> Option<u64> {
|
||||
let started = Instant::now();
|
||||
match crate::io::bounded::bounded_syscall(None, WAIT_AFTER_TIMEOUT, move || unsafe {
|
||||
libc::sync_file_range(fd, off as i64, len as i64, libc::SYNC_FILE_RANGE_WAIT_AFTER);
|
||||
}) {
|
||||
Ok(()) => Some(started.elapsed().as_millis() as u64),
|
||||
Err(crate::io::bounded::BoundedError::Timeout)
|
||||
| Err(crate::io::bounded::BoundedError::Halted) => None,
|
||||
Err(crate::io::bounded::BoundedError::WorkerLost) => {
|
||||
// Worker thread spawn failed or panicked before sending.
|
||||
// Treat as a benign success (no syscall ran) rather than
|
||||
// a degrade trigger — falling through with elapsed_ms=0
|
||||
// matches the no-op behaviour.
|
||||
Some(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! No-op writeback pipeline for non-Linux targets. macOS and Windows
|
||||
//! page cache policies have not been shown to exhibit the Linux
|
||||
//! accumulate-then-burst flush pathology for our access pattern.
|
||||
//! If that changes, replace this stub with a real implementation
|
||||
//! (e.g. `F_NOCACHE` on macOS, `FILE_FLAG_WRITE_THROUGH` on Windows).
|
||||
|
||||
use std::fs::File;
|
||||
|
||||
pub(crate) struct WritebackPipeline;
|
||||
|
||||
impl WritebackPipeline {
|
||||
pub(crate) fn new(_file: &File, _start_pos: u64, _chunk_bytes: u64) -> Self {
|
||||
Self
|
||||
}
|
||||
pub(crate) fn note_progress(&mut self, _pos: u64) {}
|
||||
pub(crate) fn handle_seek(&mut self, _new_pos: u64) {}
|
||||
pub(crate) fn finalize(&mut self) {}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! `WritebackFile` — a `File` wrapper whose reason for existing is the
|
||||
//! bounded-cache writeback pipeline.
|
||||
//!
|
||||
//! Why: large sequential writes (sweep, patch, mux on UHD-scale output)
|
||||
//! left to the kernel's default writeback policy accumulate hundreds of
|
||||
//! megabytes of dirty pages and then burst-flush, stalling subsequent
|
||||
//! writes for seconds at a time. `WritebackFile` drives a continuous
|
||||
//! [`super::writeback::WritebackPipeline`] that on Linux issues
|
||||
//! incremental `sync_file_range` + `posix_fadvise(DONTNEED)` calls at
|
||||
//! 32 MB granularity so dirty pages drain at the same rate they're
|
||||
//! produced. macOS and Windows fall through to a no-op pipeline — their
|
||||
//! default cache policies have not been shown to exhibit the same
|
||||
//! pathology for this access pattern.
|
||||
//!
|
||||
//! It implements `Write` and `Seek` so any call site that wrote to a
|
||||
//! plain `File` through those traits (sweep, patch, mux) can swap in
|
||||
//! `WritebackFile` without touching the body of the loop. The wrapper
|
||||
//! also tracks the current file position to feed the pipeline with
|
||||
//! progress + seek boundaries.
|
||||
//!
|
||||
//! See `super::writeback::linux` for the underlying pathology and the
|
||||
//! strategy.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Seek, SeekFrom, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use super::writeback::WritebackPipeline;
|
||||
|
||||
const WRITEBACK_CHUNK_BYTES: u64 = 32 * 1024 * 1024;
|
||||
|
||||
pub(crate) struct WritebackFile {
|
||||
file: File,
|
||||
pipeline: WritebackPipeline,
|
||||
pos: u64,
|
||||
}
|
||||
|
||||
impl WritebackFile {
|
||||
/// Wrap an open `File`. The current OS file position is queried
|
||||
/// once so the pipeline starts tracking from wherever the file
|
||||
/// already is (typically 0 for fresh files; non-zero for resumed
|
||||
/// or appended files).
|
||||
pub(crate) fn new(mut file: File) -> io::Result<Self> {
|
||||
let pos = file.stream_position()?;
|
||||
let pipeline = WritebackPipeline::new(&file, pos, WRITEBACK_CHUNK_BYTES);
|
||||
Ok(Self {
|
||||
file,
|
||||
pipeline,
|
||||
pos,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new file at `path` (truncating any existing contents)
|
||||
/// and wrap it. Convenience for the common
|
||||
/// `File::create(path)` + `WritebackFile::new(file)` pair so callers
|
||||
/// don't have to assemble a `File` first.
|
||||
///
|
||||
/// Callers that know the target output size should prefer
|
||||
/// [`Self::create_with_size_hint`] so the kernel can pre-reserve
|
||||
/// extents.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn create(path: &Path) -> io::Result<Self> {
|
||||
let file = File::create(path)?;
|
||||
Self::new(file)
|
||||
}
|
||||
|
||||
/// Like [`Self::create`] but pre-reserves `size_bytes` of disk
|
||||
/// space via `fallocate(FALLOC_FL_KEEP_SIZE)` on Linux. The
|
||||
/// reported file size is unchanged (writes still grow the file
|
||||
/// naturally) — only the on-disk extent allocation is preallocated,
|
||||
/// which reduces extent fragmentation on large sequential writes
|
||||
/// (mux output, especially on slow storage / NFS).
|
||||
///
|
||||
/// On macOS / Windows the size hint is ignored and this is
|
||||
/// equivalent to `create`.
|
||||
pub(crate) fn create_with_size_hint(path: &Path, size_bytes: u64) -> io::Result<Self> {
|
||||
let file = File::create(path)?;
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::unix::io::AsRawFd;
|
||||
// FALLOC_FL_KEEP_SIZE = 0x01 — keep the reported file
|
||||
// size at 0 (writes grow it normally) while still
|
||||
// pre-reserving the extents.
|
||||
let rc = unsafe {
|
||||
libc::fallocate(
|
||||
file.as_raw_fd(),
|
||||
libc::FALLOC_FL_KEEP_SIZE,
|
||||
0,
|
||||
size_bytes as i64,
|
||||
)
|
||||
};
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"WritebackFile fallocate size_hint={size_bytes} rc={rc} ok={}",
|
||||
rc == 0
|
||||
);
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"WritebackFile fallocate size_hint={size_bytes} skipped (non-linux)"
|
||||
);
|
||||
}
|
||||
Self::new(file)
|
||||
}
|
||||
|
||||
/// Open an existing file at `path` for writing (no truncation) and
|
||||
/// wrap it. Mirrors `File::open` semantics for the writable case
|
||||
/// — used by patch / resume paths that mutate an existing ISO in
|
||||
/// place.
|
||||
pub(crate) fn open(path: &Path) -> io::Result<Self> {
|
||||
let file = OpenOptions::new().write(true).open(path)?;
|
||||
Self::new(file)
|
||||
}
|
||||
|
||||
/// Drain in-flight writeback then issue a full fsync. Use this in
|
||||
/// place of `File::sync_all`.
|
||||
///
|
||||
/// The final fsync is wrapped in
|
||||
/// [`crate::io::bounded::bounded_syscall`] with a 60 s deadline.
|
||||
/// fsync on a wedged NFS server (or a degraded local disk) can
|
||||
/// hang the calling thread; the wrapper ensures the worst case is
|
||||
/// 60 s + log-and-continue rather than indefinite. On timeout the
|
||||
/// page cache is left to the kernel's normal flush-on-close path —
|
||||
/// best effort, but bounded.
|
||||
pub(crate) fn sync_all(&mut self) -> io::Result<()> {
|
||||
self.pipeline.finalize();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::time::Duration;
|
||||
let fd = self.file.as_raw_fd();
|
||||
match crate::io::bounded::bounded_syscall(
|
||||
None,
|
||||
Duration::from_secs(60),
|
||||
move || -> io::Result<()> {
|
||||
let rc = unsafe { libc::fsync(fd) };
|
||||
if rc == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::last_os_error())
|
||||
}
|
||||
},
|
||||
) {
|
||||
Ok(inner) => inner,
|
||||
Err(crate::io::bounded::BoundedError::Timeout) => {
|
||||
tracing::error!(
|
||||
target: "mux",
|
||||
"WritebackFile::sync_all fsync timed out after 60s; kernel will flush on close (best-effort)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(crate::io::bounded::BoundedError::Halted) => Ok(()),
|
||||
Err(crate::io::bounded::BoundedError::WorkerLost) => Ok(()),
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
self.file.sync_all()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for WritebackFile {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
let n = self.file.write(buf)?;
|
||||
self.pos += n as u64;
|
||||
self.pipeline.note_progress(self.pos);
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
|
||||
self.file.write_all(buf)?;
|
||||
self.pos += buf.len() as u64;
|
||||
self.pipeline.note_progress(self.pos);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.file.flush()
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for WritebackFile {
|
||||
fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
|
||||
let p = self.file.seek(from)?;
|
||||
// Only treat seeks that actually move the position as
|
||||
// boundaries — sweep does a redundant `seek(Current(pos))`
|
||||
// before every write, and we don't want that to drain the
|
||||
// pipeline on every iteration.
|
||||
if p != self.pos {
|
||||
// Diagnostic for the NFS 73 % mux hang: the MKV format
|
||||
// requires the muxer to seek back occasionally (cluster
|
||||
// size patching, Cues index write, Segment header
|
||||
// backpatch). Each such seek invalidates the writeback
|
||||
// chunk tracking and forces a finalize → WAIT_AFTER on
|
||||
// the in-flight chunk. Logging the seek delta lets us
|
||||
// correlate hang offsets with specific muxer operations.
|
||||
let from_pos = self.pos;
|
||||
let to_pos = p;
|
||||
let delta: i64 = (to_pos as i64).wrapping_sub(from_pos as i64);
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"WritebackFile seek from={from_pos} to={to_pos} delta={delta}"
|
||||
);
|
||||
self.pipeline.handle_seek(p);
|
||||
self.pos = p;
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WritebackFile {
|
||||
fn drop(&mut self) {
|
||||
// Run the pipeline's tail finalize so the last in-flight chunk
|
||||
// gets its `WAIT_AFTER` + `posix_fadvise(DONTNEED)`. Without
|
||||
// this, callers that drop a `WritebackFile` without calling
|
||||
// `sync_all` (panic, early-return, idiomatic `let _ = w;`)
|
||||
// leave the trailing chunk in cache; the kernel still flushes
|
||||
// on close, but the bounded-cache invariant fails at the tail.
|
||||
// We deliberately do *not* call `self.file.sync_all()` here —
|
||||
// close already triggers a flush, and an `fsync` from `Drop`
|
||||
// would silently swallow its `io::Error` anyway. `finalize` is
|
||||
// idempotent so an explicit `sync_all` followed by drop is
|
||||
// still safe.
|
||||
self.pipeline.finalize();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//! BDMV disc-library metadata (`/BDMV/META/DL/bdmt_<lang>.xml`).
|
||||
//!
|
||||
//! Every commercial Blu-ray carries a disc-library metadata directory
|
||||
//! with one XML file per shipped language. The schema is the Blu-ray
|
||||
//! "disc library metadata" namespace (`urn:BDA:bdmv;disclibmeta`),
|
||||
//! conventionally prefixed `di:`. Fields commonly present:
|
||||
//!
|
||||
//! - `<di:title>` or `<di:name>` — the title string. Vendor practice
|
||||
//! varies (Paramount discs tend to use `<di:name>`).
|
||||
//! - `<di:description>` — optional synopsis (often absent on retail
|
||||
//! discs; common on box sets and special editions).
|
||||
//! - `<di:discNumber>` / `<di:numSets>` (or `<di:numberOfSets>`) —
|
||||
//! set position for multi-disc releases.
|
||||
//!
|
||||
//! This module is intentionally separate from the BD-J `StreamLabel`
|
||||
//! parsers under `labels/*.rs`. The XML here is disc-level (title,
|
||||
//! description, set position), not per-stream — wiring into the main
|
||||
//! parser registry happens elsewhere.
|
||||
//!
|
||||
//! Real-world XML is irregular: missing description elements, multiple
|
||||
//! title elements (first one wins), and occasional malformed content.
|
||||
//! Extraction is best-effort — a malformed file is treated as "no
|
||||
//! metadata" (returns `None` from the helper), and the caller can
|
||||
//! still get metadata from sibling-language XML files.
|
||||
|
||||
// The module wiring (registry hook + public re-export) is added
|
||||
// separately. Until then the parse/detect entry points have no
|
||||
use super::xml;
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Disc-level metadata extracted from `/BDMV/META/DL/bdmt_*.xml`.
|
||||
///
|
||||
/// All maps are keyed by 3-char ISO 639-2 language code (e.g.
|
||||
/// `"eng"`, `"fra"`, `"jpn"`) — the same key segment used in the
|
||||
/// `bdmt_<lang>.xml` filename.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub struct DiscMetadata {
|
||||
/// Localized titles, keyed by 3-char ISO 639-2 lang code
|
||||
/// (e.g. "eng" → "Dune Part Two")
|
||||
pub titles: BTreeMap<String, String>,
|
||||
/// First-line / short description, per lang
|
||||
pub descriptions: BTreeMap<String, String>,
|
||||
/// Disc N of M for box sets (None if not a box set)
|
||||
pub disc_number: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// True if `/BDMV/META/DL/` exists and contains at least one
|
||||
/// `bdmt_*.xml` file.
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
let Some(dir) = udf.find_dir("/BDMV/META/DL") else {
|
||||
return false;
|
||||
};
|
||||
dir.entries
|
||||
.iter()
|
||||
.any(|e| !e.is_dir && is_bdmt_filename(&e.name))
|
||||
}
|
||||
|
||||
/// Read every `bdmt_<lang>.xml` under `/BDMV/META/DL/` and return the
|
||||
/// aggregated [`DiscMetadata`]. Returns `None` if no titles could be
|
||||
/// extracted from any file.
|
||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<DiscMetadata> {
|
||||
let dir = udf.find_dir("/BDMV/META/DL")?;
|
||||
let mut out = DiscMetadata::default();
|
||||
|
||||
for entry in &dir.entries {
|
||||
if entry.is_dir {
|
||||
continue;
|
||||
}
|
||||
let Some(lang) = lang_code_from_filename(&entry.name) else {
|
||||
continue;
|
||||
};
|
||||
let path = format!("/BDMV/META/DL/{}", entry.name);
|
||||
let Ok(bytes) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = std::str::from_utf8(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
let Some((title, description, disc_set)) = parse_bdmt_xml(&lang, text) else {
|
||||
continue;
|
||||
};
|
||||
out.titles.insert(lang.clone(), title);
|
||||
if let Some(desc) = description {
|
||||
out.descriptions.insert(lang.clone(), desc);
|
||||
}
|
||||
// Disc-set position is disc-global; first one we successfully
|
||||
// read wins. (All bdmt_*.xml on a given disc carry the same
|
||||
// value in practice.)
|
||||
if out.disc_number.is_none() {
|
||||
if let Some(ds) = disc_set {
|
||||
out.disc_number = Some(ds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if out.titles.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `name` matches the `bdmt_<lang>.xml` convention with a
|
||||
/// 3-character ISO 639-2 lang code segment. Case-insensitive.
|
||||
fn is_bdmt_filename(name: &str) -> bool {
|
||||
lang_code_from_filename(name).is_some()
|
||||
}
|
||||
|
||||
/// Extract the 3-char language code from a `bdmt_<lang>.xml` filename.
|
||||
/// Returns `None` if the filename doesn't match. Lang code is
|
||||
/// lowercased so callers always see e.g. `"eng"` not `"ENG"`.
|
||||
fn lang_code_from_filename(name: &str) -> Option<String> {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let stem = lower.strip_suffix(".xml")?;
|
||||
let lang = stem.strip_prefix("bdmt_")?;
|
||||
// ISO 639-2 codes are exactly 3 ASCII letters. Be strict — keeps
|
||||
// us from picking up unrelated `bdmt_foo.xml` siblings.
|
||||
if lang.len() != 3 || !lang.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
return None;
|
||||
}
|
||||
Some(lang.to_string())
|
||||
}
|
||||
|
||||
/// Tuple returned by [`parse_bdmt_xml`]: `(title, description?, disc_set?)`.
|
||||
/// Aliased so the function signature isn't a clippy::type-complexity offender.
|
||||
pub(crate) type BdmtFields = (String, Option<String>, Option<(u32, u32)>);
|
||||
|
||||
/// Parse one `bdmt_<lang>.xml` document and return
|
||||
/// `(title, description?, disc_set?)`. Returns `None` if no title
|
||||
/// could be located — the caller treats this as "skip this file".
|
||||
///
|
||||
/// Title-element preference: `<di:name>` → `<di:title>` →
|
||||
/// `<di:tableOfContents>/<di:titleName>` (first match wins, per the
|
||||
/// authoring-tool conventions documented at the module level).
|
||||
pub(crate) fn parse_bdmt_xml(_lang_code: &str, xml_text: &str) -> Option<BdmtFields> {
|
||||
let title = extract_title(xml_text)?;
|
||||
let description = xml::text(xml_text, "description")
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter(|s| !looks_like_xml(s))
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let disc_set = extract_disc_set(xml_text);
|
||||
Some((title, description, disc_set))
|
||||
}
|
||||
|
||||
/// Reject candidate description strings that are themselves XML
|
||||
/// fragments — observed on disc-04 (Top Gun: Maverick), where
|
||||
/// `<di:description>` contained `<di:thumbnail href="…"/>` child
|
||||
/// elements and no actual prose. Surfacing that raw to the JSON
|
||||
/// output is worse than dropping the field entirely.
|
||||
fn looks_like_xml(s: &str) -> bool {
|
||||
let t = s.trim_start();
|
||||
t.starts_with('<')
|
||||
}
|
||||
|
||||
/// Try title-bearing element variants in priority order. The `xml`
|
||||
/// helpers are case- and namespace-insensitive, so callers pass the
|
||||
/// bare local name (no `di:` prefix).
|
||||
fn extract_title(xml_text: &str) -> Option<String> {
|
||||
// Order matches the module-level convention: <di:name> first
|
||||
// (Paramount-style), then <di:title>, then the nested
|
||||
// tableOfContents/titleName form.
|
||||
for tag in ["name", "title"] {
|
||||
if let Some(s) = xml::text(xml_text, tag) {
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// tableOfContents/titleName: search inside the toc block so we
|
||||
// don't accidentally pick a stray <titleName> from elsewhere.
|
||||
if let Some((s, e)) = xml::find_element(xml_text, "tableOfContents", 0) {
|
||||
let block = &xml_text[s..e];
|
||||
if let Some(t) = xml::text(block, "titleName") {
|
||||
let trimmed = t.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract `(discNumber, numSets)` if both are present and parse as
|
||||
/// `u32`. Accepts either `<di:numSets>` or `<di:numberOfSets>` for
|
||||
/// the denominator (both forms appear in the wild).
|
||||
fn extract_disc_set(xml_text: &str) -> Option<(u32, u32)> {
|
||||
let n = xml::text(xml_text, "discNumber")?
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.ok()?;
|
||||
let total = xml::text(xml_text, "numSets")
|
||||
.or_else(|| xml::text(xml_text, "numberOfSets"))?
|
||||
.trim()
|
||||
.parse::<u32>()
|
||||
.ok()?;
|
||||
Some((n, total))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_simple_title() {
|
||||
// Minimal Paramount-style document: <di:name> as the title
|
||||
// carrier inside a <discInfo> root.
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Dune Part Two</di:name>
|
||||
</discInfo>"#;
|
||||
let (title, desc, set) = parse_bdmt_xml("eng", xml).expect("title should parse");
|
||||
assert_eq!(title, "Dune Part Two");
|
||||
assert_eq!(desc, None);
|
||||
assert_eq!(set, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_title_element_variant() {
|
||||
// <di:title> is the alternate carrier; should be picked up
|
||||
// when <di:name> is absent.
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:title>The Matrix</di:title>
|
||||
<di:description>A film about computers.</di:description>
|
||||
</discInfo>"#;
|
||||
let (title, desc, _) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(title, "The Matrix");
|
||||
assert_eq!(desc.as_deref(), Some("A film about computers."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_title_from_table_of_contents_fallback() {
|
||||
// Some authoring tools nest the title under tableOfContents.
|
||||
// No <di:name> or <di:title> at top level → fall back to
|
||||
// titleName inside tableOfContents.
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:tableOfContents>
|
||||
<di:titleName>Inside Out 2</di:titleName>
|
||||
</di:tableOfContents>
|
||||
</discInfo>"#;
|
||||
let (title, _, _) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(title, "Inside Out 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_box_set_position() {
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>LOTR Disc 2</di:name>
|
||||
<di:discNumber>2</di:discNumber>
|
||||
<di:numSets>5</di:numSets>
|
||||
</discInfo>"#;
|
||||
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(set, Some((2, 5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_box_set_position_alternate_total_tag() {
|
||||
// <di:numberOfSets> is an alternate spelling we've seen.
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>X</di:name>
|
||||
<di:discNumber>3</di:discNumber>
|
||||
<di:numberOfSets>6</di:numberOfSets>
|
||||
</discInfo>"#;
|
||||
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(set, Some((3, 6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_box_set_requires_both_fields() {
|
||||
// discNumber alone (no total) yields None — we don't fabricate
|
||||
// a denominator.
|
||||
let xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>X</di:name>
|
||||
<di:discNumber>1</di:discNumber>
|
||||
</discInfo>"#;
|
||||
let (_, _, set) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(set, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_languages_keyed_correctly() {
|
||||
// Simulate driving parse_bdmt_xml from two synthetic XML
|
||||
// blobs and aggregating into DiscMetadata the same way parse()
|
||||
// would. This exercises the BTreeMap key handling without
|
||||
// needing a UdfFs.
|
||||
let eng_xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Dune Part Two</di:name>
|
||||
</discInfo>"#;
|
||||
let fra_xml = r#"<discInfo xmlns:di="urn:BDA:bdmv;disclibmeta">
|
||||
<di:name>Dune Deuxième Partie</di:name>
|
||||
<di:description>Suite du film de 2021.</di:description>
|
||||
</discInfo>"#;
|
||||
|
||||
let mut meta = DiscMetadata::default();
|
||||
for (lang, blob) in [("eng", eng_xml), ("fra", fra_xml)] {
|
||||
let (title, desc, ds) = parse_bdmt_xml(lang, blob).unwrap();
|
||||
meta.titles.insert(lang.to_string(), title);
|
||||
if let Some(d) = desc {
|
||||
meta.descriptions.insert(lang.to_string(), d);
|
||||
}
|
||||
if meta.disc_number.is_none() {
|
||||
if let Some(d) = ds {
|
||||
meta.disc_number = Some(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
meta.titles.get("eng").map(String::as_str),
|
||||
Some("Dune Part Two")
|
||||
);
|
||||
assert_eq!(
|
||||
meta.titles.get("fra").map(String::as_str),
|
||||
Some("Dune Deuxième Partie")
|
||||
);
|
||||
assert!(meta.descriptions.get("eng").is_none());
|
||||
assert_eq!(
|
||||
meta.descriptions.get("fra").map(String::as_str),
|
||||
Some("Suite du film de 2021.")
|
||||
);
|
||||
assert_eq!(meta.disc_number, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_xml_returns_none() {
|
||||
// Random gibberish has no recognizable title element. We
|
||||
// document the contract: parse_bdmt_xml returns None, and
|
||||
// parse() (the caller) skips the file. Aggregating across
|
||||
// zero files leaves DiscMetadata::default() — which parse()
|
||||
// surfaces as None to its caller. Either is documented as
|
||||
// acceptable per the module spec.
|
||||
let bad = "this is not xml &&& <<< nope";
|
||||
assert!(parse_bdmt_xml("eng", bad).is_none());
|
||||
|
||||
// Half-open tag, no body, no close: also yields no title.
|
||||
let truncated = "<discInfo><di:name>";
|
||||
assert!(parse_bdmt_xml("eng", truncated).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_with_only_child_xml_is_dropped() {
|
||||
// Real-world bug from disc-04 (Top Gun: Maverick, 2026-05-11
|
||||
// capture): <di:description> contained only <di:thumbnail/>
|
||||
// child elements with no actual prose. The previous parser
|
||||
// surfaced the raw XML fragment as the description string.
|
||||
// Now we reject candidates that begin with `<`.
|
||||
let xml = r#"<discInfo>
|
||||
<di:name>Top Gun: Maverick</di:name>
|
||||
<di:description>
|
||||
<di:thumbnail href="tgm_meta_sm.jpg" />
|
||||
<di:thumbnail href="tgm_meta_lg.jpg" />
|
||||
</di:description>
|
||||
</discInfo>"#;
|
||||
let (title, description, _) =
|
||||
parse_bdmt_xml("eng", xml).expect("title is present so parse must succeed");
|
||||
assert_eq!(title, "Top Gun: Maverick");
|
||||
assert!(
|
||||
description.is_none(),
|
||||
"description containing only XML children must be dropped, got {description:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_with_plain_text_passes_through() {
|
||||
// The legitimate case still works: a description with actual
|
||||
// prose survives the looks_like_xml filter.
|
||||
let xml = r#"<discInfo>
|
||||
<di:name>Some Movie</di:name>
|
||||
<di:description>An epic tale of one man's quest for tea.</di:description>
|
||||
</discInfo>"#;
|
||||
let (_, description, _) = parse_bdmt_xml("eng", xml).expect("must parse");
|
||||
assert_eq!(
|
||||
description.as_deref(),
|
||||
Some("An epic tale of one man's quest for tea.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_in_title_is_trimmed() {
|
||||
let xml = r#"<discInfo><di:name>
|
||||
Dune Part Two
|
||||
</di:name></discInfo>"#;
|
||||
let (title, _, _) = parse_bdmt_xml("eng", xml).unwrap();
|
||||
assert_eq!(title, "Dune Part Two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_code_extraction() {
|
||||
assert_eq!(lang_code_from_filename("bdmt_eng.xml"), Some("eng".into()));
|
||||
assert_eq!(lang_code_from_filename("BDMT_FRA.XML"), Some("fra".into()));
|
||||
assert_eq!(lang_code_from_filename("bdmt_jpn.xml"), Some("jpn".into()));
|
||||
// Non-matching cases:
|
||||
assert_eq!(lang_code_from_filename("bdmt_.xml"), None);
|
||||
assert_eq!(lang_code_from_filename("bdmt_engl.xml"), None);
|
||||
assert_eq!(lang_code_from_filename("bdmt_e1g.xml"), None);
|
||||
assert_eq!(lang_code_from_filename("bdmt_eng.txt"), None);
|
||||
assert_eq!(lang_code_from_filename("foo.xml"), None);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,291 @@
|
||||
//! CLPI vs MPLS cross-validation diagnostic.
|
||||
//!
|
||||
//! Empirical question (raised 2026-05-11): is CLPI's per-stream
|
||||
//! language and codec data truly redundant with MPLS's STN-table data
|
||||
//! on real-world Blu-rays?
|
||||
//!
|
||||
//! Build a quick audit that walks both sources, normalizes their stream
|
||||
//! lists by (PID, language, coding_type), and flags any disagreement.
|
||||
//!
|
||||
//! Three classes of mismatch we want to detect:
|
||||
//!
|
||||
//! 1. **CLPI has streams MPLS doesn't reference.** Orphan streams in
|
||||
//! the .m2ts that no playlist's STN table includes. Means the user
|
||||
//! can't reach them through the menu but they're physically on the
|
||||
//! disc.
|
||||
//! 2. **MPLS has streams CLPI doesn't list.** Should never happen if
|
||||
//! both parsers are correct — playlists reference clips which
|
||||
//! reference streams. If it happens, one of our parsers has a bug.
|
||||
//! 3. **Same PID, different language / coding_type.** The playlist re-
|
||||
//! tagged a stream's metadata. Rare but spec-permitted. Means CLPI
|
||||
//! and MPLS disagree about the same physical stream's properties.
|
||||
//!
|
||||
//! If audits across the corpus show zero mismatches of any class, CLPI
|
||||
//! program_info extraction is **empirically redundant** for labels and
|
||||
//! we can leave it out of the registry. If even one mismatch surfaces,
|
||||
//! we add a CLPI label parser to the registry as belt-and-suspenders.
|
||||
//!
|
||||
//! This module exposes `audit(reader, udf)` returning a structured
|
||||
//! report. Surfaced via the labels-analyze tool — not part of the
|
||||
//! `analyze()` pipeline (no impact on the label output).
|
||||
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// One row in the audit: a stream PID that's known to one source or
|
||||
/// both, with the fields each source reported.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClpiVsMplsRow {
|
||||
pub pid: u16,
|
||||
pub clpi_coding_type: Option<u8>,
|
||||
pub clpi_language: Option<String>,
|
||||
pub mpls_coding_type: Option<u8>,
|
||||
pub mpls_language: Option<String>,
|
||||
}
|
||||
|
||||
impl ClpiVsMplsRow {
|
||||
/// Three rules for classification:
|
||||
/// - both sources missing (impossible — caller wouldn't insert)
|
||||
/// - one source missing → class A or B (orphan-on-disc / playlist-only)
|
||||
/// - both present but fields differ → class C (metadata divergence)
|
||||
/// - both present and identical → no mismatch
|
||||
pub fn class(&self) -> ClpiVsMplsClass {
|
||||
match (
|
||||
self.clpi_coding_type.is_some(),
|
||||
self.mpls_coding_type.is_some(),
|
||||
) {
|
||||
(true, false) => ClpiVsMplsClass::ClpiOnly,
|
||||
(false, true) => ClpiVsMplsClass::MplsOnly,
|
||||
(true, true) => {
|
||||
let coding_match = self.clpi_coding_type == self.mpls_coding_type;
|
||||
let lang_match = self.clpi_language == self.mpls_language;
|
||||
if coding_match && lang_match {
|
||||
ClpiVsMplsClass::Match
|
||||
} else {
|
||||
ClpiVsMplsClass::Divergent
|
||||
}
|
||||
}
|
||||
(false, false) => ClpiVsMplsClass::Match,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classification of one (PID) row.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClpiVsMplsClass {
|
||||
/// PID seen in CLPI ProgramInfo but no MPLS STN table references
|
||||
/// it. Orphan on disc.
|
||||
ClpiOnly,
|
||||
/// PID seen in MPLS STN table but no CLPI ProgramInfo includes it.
|
||||
/// One of our parsers probably has a bug.
|
||||
MplsOnly,
|
||||
/// Both sources see this PID with the same coding_type + language.
|
||||
Match,
|
||||
/// Both sources see this PID but disagree on coding_type or language.
|
||||
/// MPLS wins for label rendering (playlist-authoritative view); CLPI
|
||||
/// is the per-clip ground truth.
|
||||
Divergent,
|
||||
}
|
||||
|
||||
/// Full audit report.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ClpiVsMplsAudit {
|
||||
pub rows: Vec<ClpiVsMplsRow>,
|
||||
}
|
||||
|
||||
impl ClpiVsMplsAudit {
|
||||
pub fn class_counts(&self) -> (usize, usize, usize, usize) {
|
||||
let mut clpi_only = 0;
|
||||
let mut mpls_only = 0;
|
||||
let mut matches = 0;
|
||||
let mut divergent = 0;
|
||||
for r in &self.rows {
|
||||
match r.class() {
|
||||
ClpiVsMplsClass::ClpiOnly => clpi_only += 1,
|
||||
ClpiVsMplsClass::MplsOnly => mpls_only += 1,
|
||||
ClpiVsMplsClass::Match => matches += 1,
|
||||
ClpiVsMplsClass::Divergent => divergent += 1,
|
||||
}
|
||||
}
|
||||
(clpi_only, mpls_only, matches, divergent)
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk `/BDMV/CLIPINF/*.clpi` and `/BDMV/PLAYLIST/*.mpls`, build a
|
||||
/// dedup-by-PID table of (CLPI fields, MPLS fields), return the
|
||||
/// merged view. Missing files (read errors, parse failures) are
|
||||
/// silently skipped — this is diagnostic, not correctness-critical.
|
||||
pub fn audit(reader: &mut dyn SectorSource, udf: &UdfFs) -> ClpiVsMplsAudit {
|
||||
// Aggregate by PID across all CLPI files. If a PID appears in
|
||||
// multiple clips (typical — main movie clip + trailers reference
|
||||
// the same audio stream PIDs), first encountered wins (they should
|
||||
// all agree per BD spec).
|
||||
let mut clpi_by_pid: BTreeMap<u16, (u8, String)> = BTreeMap::new();
|
||||
if let Some(dir) = udf.find_dir("/BDMV/CLIPINF") {
|
||||
let names: Vec<String> = dir
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(".clpi"))
|
||||
.map(|e| e.name.clone())
|
||||
.collect();
|
||||
for name in names {
|
||||
let path = format!("/BDMV/CLIPINF/{}", name);
|
||||
let Ok(data) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(clip) = crate::clpi::parse(&data) else {
|
||||
continue;
|
||||
};
|
||||
for s in clip.streams {
|
||||
clpi_by_pid
|
||||
.entry(s.pid)
|
||||
.or_insert((s.coding_type, s.language));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same for MPLS streams.
|
||||
let mut mpls_by_pid: BTreeMap<u16, (u8, String)> = BTreeMap::new();
|
||||
if let Some(dir) = udf.find_dir("/BDMV/PLAYLIST") {
|
||||
let names: Vec<String> = dir
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(".mpls"))
|
||||
.map(|e| e.name.clone())
|
||||
.collect();
|
||||
for name in names {
|
||||
let path = format!("/BDMV/PLAYLIST/{}", name);
|
||||
let Ok(data) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(pl) = crate::mpls::parse(&data) else {
|
||||
continue;
|
||||
};
|
||||
for s in pl.streams {
|
||||
if s.pid == 0 {
|
||||
// PID 0 means "no PID in stream entry" — skip rather
|
||||
// than collide with other entries.
|
||||
continue;
|
||||
}
|
||||
mpls_by_pid
|
||||
.entry(s.pid)
|
||||
.or_insert((s.coding_type, s.language));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge views: every PID seen anywhere gets a row.
|
||||
let mut all_pids: std::collections::BTreeSet<u16> = std::collections::BTreeSet::new();
|
||||
all_pids.extend(clpi_by_pid.keys().copied());
|
||||
all_pids.extend(mpls_by_pid.keys().copied());
|
||||
|
||||
let mut rows = Vec::with_capacity(all_pids.len());
|
||||
for pid in all_pids {
|
||||
let clpi = clpi_by_pid.get(&pid);
|
||||
let mpls = mpls_by_pid.get(&pid);
|
||||
rows.push(ClpiVsMplsRow {
|
||||
pid,
|
||||
clpi_coding_type: clpi.map(|(c, _)| *c),
|
||||
clpi_language: clpi.map(|(_, l)| l.clone()),
|
||||
mpls_coding_type: mpls.map(|(c, _)| *c),
|
||||
mpls_language: mpls.map(|(_, l)| l.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
ClpiVsMplsAudit { rows }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn class_match_when_identical() {
|
||||
let r = ClpiVsMplsRow {
|
||||
pid: 0x1100,
|
||||
clpi_coding_type: Some(0x83),
|
||||
clpi_language: Some("eng".into()),
|
||||
mpls_coding_type: Some(0x83),
|
||||
mpls_language: Some("eng".into()),
|
||||
};
|
||||
assert_eq!(r.class(), ClpiVsMplsClass::Match);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_clpi_only_when_mpls_missing() {
|
||||
let r = ClpiVsMplsRow {
|
||||
pid: 0x1100,
|
||||
clpi_coding_type: Some(0x83),
|
||||
clpi_language: Some("eng".into()),
|
||||
mpls_coding_type: None,
|
||||
mpls_language: None,
|
||||
};
|
||||
assert_eq!(r.class(), ClpiVsMplsClass::ClpiOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_mpls_only_when_clpi_missing() {
|
||||
let r = ClpiVsMplsRow {
|
||||
pid: 0x1100,
|
||||
clpi_coding_type: None,
|
||||
clpi_language: None,
|
||||
mpls_coding_type: Some(0x90),
|
||||
mpls_language: Some("fra".into()),
|
||||
};
|
||||
assert_eq!(r.class(), ClpiVsMplsClass::MplsOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_divergent_on_lang_disagreement() {
|
||||
let r = ClpiVsMplsRow {
|
||||
pid: 0x1100,
|
||||
clpi_coding_type: Some(0x83),
|
||||
clpi_language: Some("eng".into()),
|
||||
mpls_coding_type: Some(0x83),
|
||||
mpls_language: Some("und".into()),
|
||||
};
|
||||
assert_eq!(r.class(), ClpiVsMplsClass::Divergent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_counts_sum_rows() {
|
||||
let audit = ClpiVsMplsAudit {
|
||||
rows: vec![
|
||||
ClpiVsMplsRow {
|
||||
pid: 0x1100,
|
||||
clpi_coding_type: Some(0x83),
|
||||
clpi_language: Some("eng".into()),
|
||||
mpls_coding_type: Some(0x83),
|
||||
mpls_language: Some("eng".into()),
|
||||
}, // Match
|
||||
ClpiVsMplsRow {
|
||||
pid: 0x1101,
|
||||
clpi_coding_type: Some(0x83),
|
||||
clpi_language: Some("fra".into()),
|
||||
mpls_coding_type: None,
|
||||
mpls_language: None,
|
||||
}, // ClpiOnly
|
||||
ClpiVsMplsRow {
|
||||
pid: 0x1102,
|
||||
clpi_coding_type: None,
|
||||
clpi_language: None,
|
||||
mpls_coding_type: Some(0x90),
|
||||
mpls_language: Some("eng".into()),
|
||||
}, // MplsOnly
|
||||
ClpiVsMplsRow {
|
||||
pid: 0x1103,
|
||||
clpi_coding_type: Some(0x86),
|
||||
clpi_language: Some("spa".into()),
|
||||
mpls_coding_type: Some(0x86),
|
||||
mpls_language: Some("ita".into()),
|
||||
}, // Divergent
|
||||
],
|
||||
};
|
||||
let (co, mo, m, d) = audit.class_counts();
|
||||
assert_eq!(co, 1);
|
||||
assert_eq!(mo, 1);
|
||||
assert_eq!(m, 1);
|
||||
assert_eq!(d, 1);
|
||||
}
|
||||
}
|
||||
+56
-92
@@ -3,8 +3,8 @@
|
||||
//! Clean structured XML with Content/Qualifier per stream and
|
||||
//! stream number mapping via playbackconfig.
|
||||
|
||||
use super::{LabelPurpose, LabelQualifier, StreamLabel, StreamLabelType};
|
||||
use crate::sector::SectorReader;
|
||||
use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, xml};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -12,7 +12,7 @@ pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "streamproperties.xml")
|
||||
}
|
||||
|
||||
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||
let sp_data = super::read_jar_file(reader, udf, "streamproperties.xml")?;
|
||||
let sp_text = std::str::from_utf8(&sp_data).ok()?;
|
||||
|
||||
@@ -66,7 +66,8 @@ pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLab
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(labels)
|
||||
// High confidence: streamproperties.xml is fully structured.
|
||||
Some(ParseResult::high(labels))
|
||||
}
|
||||
|
||||
struct StreamInfo {
|
||||
@@ -78,105 +79,68 @@ struct StreamInfo {
|
||||
qualifier: LabelQualifier,
|
||||
}
|
||||
|
||||
fn parse_stream_infos(xml: &str) -> Vec<StreamInfo> {
|
||||
fn parse_stream_infos(text: &str) -> Vec<StreamInfo> {
|
||||
let mut infos = Vec::new();
|
||||
let mut pos = 0;
|
||||
|
||||
while pos < xml.len() {
|
||||
let (tag, stream_type) = if let Some(p) = xml[pos..].find("<AudioStreamInfos>") {
|
||||
(p + pos, StreamLabelType::Audio)
|
||||
} else if let Some(p) = xml[pos..].find("<SubtitleStreamInfos>") {
|
||||
(p + pos, StreamLabelType::Subtitle)
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
for (tag_name, stream_type) in [
|
||||
("AudioStreamInfos", StreamLabelType::Audio),
|
||||
("SubtitleStreamInfos", StreamLabelType::Subtitle),
|
||||
] {
|
||||
let mut from = 0;
|
||||
while let Some((start, end)) = xml::find_element(text, tag_name, from) {
|
||||
let block = &text[start..end];
|
||||
let id = xml::text(block, "ID").unwrap_or_default();
|
||||
let lang_id = xml::text(block, "LangInfoID").unwrap_or_default();
|
||||
let content = xml::text(block, "Content").unwrap_or_default();
|
||||
let qualifier_str = xml::text(block, "Qualifier").unwrap_or_default();
|
||||
|
||||
let end_tag = match stream_type {
|
||||
StreamLabelType::Audio => "</AudioStreamInfos>",
|
||||
StreamLabelType::Subtitle => "</SubtitleStreamInfos>",
|
||||
};
|
||||
let (language, variant) = if lang_id.contains('_') {
|
||||
let parts: Vec<&str> = lang_id.splitn(2, '_').collect();
|
||||
(parts[0].to_lowercase(), parts[1].to_string())
|
||||
} else {
|
||||
(lang_id.to_lowercase(), String::new())
|
||||
};
|
||||
|
||||
let block_end = match xml[tag..].find(end_tag) {
|
||||
Some(p) => tag + p + end_tag.len(),
|
||||
None => break,
|
||||
};
|
||||
let purpose = if content.eq_ignore_ascii_case("COMMENTARY") {
|
||||
LabelPurpose::Commentary
|
||||
} else {
|
||||
LabelPurpose::Normal
|
||||
};
|
||||
|
||||
let block = &xml[tag..block_end];
|
||||
let id = extract_tag(block, "ID").unwrap_or_default();
|
||||
let lang_id = extract_tag(block, "LangInfoID").unwrap_or_default();
|
||||
let content = extract_tag(block, "Content").unwrap_or_default();
|
||||
let qualifier_str = extract_tag(block, "Qualifier").unwrap_or_default();
|
||||
let qualifier = match qualifier_str.to_ascii_uppercase().as_str() {
|
||||
"SDH" => LabelQualifier::Sdh,
|
||||
"DS" => LabelQualifier::DescriptiveService,
|
||||
_ => LabelQualifier::None,
|
||||
};
|
||||
|
||||
let (language, variant) = if lang_id.contains('_') {
|
||||
let parts: Vec<&str> = lang_id.splitn(2, '_').collect();
|
||||
(parts[0].to_lowercase(), parts[1].to_string())
|
||||
} else {
|
||||
(lang_id.to_lowercase(), String::new())
|
||||
};
|
||||
|
||||
let purpose = match content.as_str() {
|
||||
"COMMENTARY" => LabelPurpose::Commentary,
|
||||
_ => LabelPurpose::Normal,
|
||||
};
|
||||
|
||||
let qualifier = match qualifier_str.as_str() {
|
||||
"SDH" => LabelQualifier::Sdh,
|
||||
"DS" => LabelQualifier::DescriptiveService,
|
||||
_ => LabelQualifier::None,
|
||||
};
|
||||
|
||||
infos.push(StreamInfo {
|
||||
id,
|
||||
stream_type,
|
||||
language,
|
||||
variant,
|
||||
purpose,
|
||||
qualifier,
|
||||
});
|
||||
pos = block_end;
|
||||
infos.push(StreamInfo {
|
||||
id,
|
||||
stream_type,
|
||||
language,
|
||||
variant,
|
||||
purpose,
|
||||
qualifier,
|
||||
});
|
||||
from = end;
|
||||
}
|
||||
}
|
||||
infos
|
||||
}
|
||||
|
||||
fn parse_playback_config(xml: &str, map: &mut HashMap<String, u16>) {
|
||||
let mut pos = 0;
|
||||
while pos < xml.len() {
|
||||
let tag_start = if let Some(p) = xml[pos..].find("<AudioStreams>") {
|
||||
Some(p + pos)
|
||||
} else {
|
||||
xml[pos..].find("<SubtitlesStreams>").map(|p| p + pos)
|
||||
};
|
||||
|
||||
let tag_start = match tag_start {
|
||||
Some(p) => p,
|
||||
None => break,
|
||||
};
|
||||
|
||||
let block_end = xml[tag_start..]
|
||||
.find("</AudioStreams>")
|
||||
.or_else(|| xml[tag_start..].find("</SubtitlesStreams>"))
|
||||
.map(|p| tag_start + p + 20)
|
||||
.unwrap_or(xml.len());
|
||||
|
||||
let block = &xml[tag_start..block_end];
|
||||
|
||||
if let (Some(stream_id_str), Some(info_id)) = (
|
||||
extract_tag(block, "StreamID"),
|
||||
extract_tag(block, "StreamInfo_ID"),
|
||||
) {
|
||||
if let Ok(stream_num) = stream_id_str.parse::<u16>() {
|
||||
map.insert(info_id, stream_num);
|
||||
fn parse_playback_config(text: &str, map: &mut HashMap<String, u16>) {
|
||||
for tag_name in ["AudioStreams", "SubtitlesStreams"] {
|
||||
let mut from = 0;
|
||||
while let Some((start, end)) = xml::find_element(text, tag_name, from) {
|
||||
let block = &text[start..end];
|
||||
if let (Some(stream_id_str), Some(info_id)) = (
|
||||
xml::text(block, "StreamID"),
|
||||
xml::text(block, "StreamInfo_ID"),
|
||||
) {
|
||||
if let Ok(stream_num) = stream_id_str.parse::<u16>() {
|
||||
map.insert(info_id, stream_num);
|
||||
}
|
||||
}
|
||||
from = end;
|
||||
}
|
||||
|
||||
pos = block_end;
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_tag(xml: &str, tag: &str) -> Option<String> {
|
||||
let open = format!("<{tag}>");
|
||||
let close = format!("</{tag}>");
|
||||
let start = xml.find(&open)? + open.len();
|
||||
let end = xml[start..].find(&close)? + start;
|
||||
Some(xml[start..end].trim().to_string())
|
||||
}
|
||||
|
||||
+185
-17
@@ -4,8 +4,8 @@
|
||||
//! When both exist, language_streams.txt provides structured types while
|
||||
//! menu_base.prop provides stream number → button name mapping.
|
||||
|
||||
use super::{vocab, LabelPurpose, LabelQualifier, StreamLabel, StreamLabelType};
|
||||
use crate::sector::SectorReader;
|
||||
use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, vocab};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -14,7 +14,7 @@ pub fn detect(udf: &UdfFs) -> bool {
|
||||
|| super::jar_file_exists(udf, "language_streams.txt")
|
||||
}
|
||||
|
||||
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||
// Try language_streams.txt first (richer structured data)
|
||||
let ls_labels = parse_language_streams(reader, udf);
|
||||
|
||||
@@ -22,12 +22,18 @@ pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLab
|
||||
let mb_labels = parse_menu_base(reader, udf);
|
||||
|
||||
// If we have both, merge: language_streams for structure, menu_base for names
|
||||
match (ls_labels, mb_labels) {
|
||||
(Some(ls), Some(mb)) => Some(merge(ls, mb)),
|
||||
(Some(ls), None) => Some(ls),
|
||||
(None, Some(mb)) => Some(mb),
|
||||
(None, None) => None,
|
||||
let labels = match (ls_labels, mb_labels) {
|
||||
(Some(ls), Some(mb)) => merge(ls, mb),
|
||||
(Some(ls), None) => ls,
|
||||
(None, Some(mb)) => mb,
|
||||
(None, None) => return None,
|
||||
};
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// High confidence: both language_streams.txt and menu_base.prop
|
||||
// are structured key-value formats with documented types.
|
||||
Some(ParseResult::high(labels))
|
||||
}
|
||||
|
||||
fn merge(ls: Vec<StreamLabel>, mb: Vec<StreamLabel>) -> Vec<StreamLabel> {
|
||||
@@ -49,7 +55,7 @@ fn merge(ls: Vec<StreamLabel>, mb: Vec<StreamLabel>) -> Vec<StreamLabel> {
|
||||
|
||||
// ── language_streams.txt parser ────────────────────────────────────────────
|
||||
|
||||
fn parse_language_streams(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
fn parse_language_streams(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(reader, udf, "language_streams.txt")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
|
||||
@@ -172,9 +178,167 @@ fn parse_language_streams(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<
|
||||
Some(labels)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a minimal menu_base.prop text and run `parse_menu_base`'s
|
||||
/// inner logic via a temporary closure. This isolates the prop
|
||||
/// parsing without needing a SectorSource.
|
||||
fn parse_props(text: &str) -> Vec<StreamLabel> {
|
||||
// Mirror the inner loop of parse_menu_base exactly. Kept
|
||||
// separate so the test doesn't need disc fixtures.
|
||||
use std::collections::HashMap;
|
||||
let mut entries: HashMap<String, HashMap<String, String>> = HashMap::new();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let Some(eq_pos) = line.find('=') else {
|
||||
continue;
|
||||
};
|
||||
let full_key = &line[..eq_pos];
|
||||
let value = &line[eq_pos + 1..];
|
||||
if let Some(dot_pos) = full_key.rfind('.') {
|
||||
entries
|
||||
.entry(full_key[..dot_pos].to_string())
|
||||
.or_default()
|
||||
.insert(full_key[dot_pos + 1..].to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
let mut labels = Vec::new();
|
||||
for (prefix, props) in &entries {
|
||||
let is_audio = props
|
||||
.get("class")
|
||||
.is_some_and(|c| c.contains("AudioButton"))
|
||||
|| prefix.starts_with("audio_");
|
||||
let is_subtitle = props
|
||||
.get("class")
|
||||
.is_some_and(|c| c.contains("SubtitleButton"))
|
||||
|| prefix.starts_with("subtitle_");
|
||||
let stream_num_str = props
|
||||
.get("streamNumber")
|
||||
.or_else(|| props.get("audioStream"))
|
||||
.or_else(|| props.get("subtitleStream"));
|
||||
let stream_num: u16 = match stream_num_str.and_then(|s| s.parse().ok()) {
|
||||
Some(n) if n > 0 => n,
|
||||
_ => continue,
|
||||
};
|
||||
if !is_audio && !is_subtitle {
|
||||
continue;
|
||||
}
|
||||
let name = props.get("name").cloned().unwrap_or_default();
|
||||
let purpose = match vocab::purpose(&name) {
|
||||
LabelPurpose::Normal if prefix.contains("comm") => LabelPurpose::Commentary,
|
||||
p => p,
|
||||
};
|
||||
let qualifier = if is_subtitle {
|
||||
vocab::qualifier(&name)
|
||||
} else {
|
||||
LabelQualifier::None
|
||||
};
|
||||
let stream_type = if is_audio {
|
||||
StreamLabelType::Audio
|
||||
} else {
|
||||
StreamLabelType::Subtitle
|
||||
};
|
||||
let language = props
|
||||
.get("audioLanguage")
|
||||
.or_else(|| props.get("subtitleLanguage"))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
labels.push(StreamLabel {
|
||||
stream_number: stream_num,
|
||||
stream_type,
|
||||
language,
|
||||
name,
|
||||
purpose,
|
||||
qualifier,
|
||||
codec_hint: String::new(),
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
labels.sort_by_key(|l| (l.stream_type as u8, l.stream_number));
|
||||
labels
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commentary_via_name() {
|
||||
let labels = parse_props(
|
||||
"audio_1.class=AudioButton\n\
|
||||
audio_1.streamNumber=2\n\
|
||||
audio_1.name=Director's Commentary\n\
|
||||
audio_1.audioLanguage=eng\n",
|
||||
);
|
||||
assert_eq!(labels.len(), 1);
|
||||
assert_eq!(labels[0].purpose, LabelPurpose::Commentary);
|
||||
assert_eq!(labels[0].language, "eng");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commentary_via_prefix_when_name_silent() {
|
||||
let labels = parse_props(
|
||||
"audio_commentary_1.class=AudioButton\n\
|
||||
audio_commentary_1.streamNumber=2\n\
|
||||
audio_commentary_1.name=Track 2\n\
|
||||
audio_commentary_1.audioLanguage=eng\n",
|
||||
);
|
||||
assert_eq!(labels[0].purpose, LabelPurpose::Commentary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commenter_does_not_false_match_commentary() {
|
||||
// Regression for the pre-refactor `name.contains("comment")`
|
||||
// bug: this would wrongly classify a "Commenter Pro" track as
|
||||
// Commentary. vocab::purpose enforces a word boundary.
|
||||
let labels = parse_props(
|
||||
"audio_1.class=AudioButton\n\
|
||||
audio_1.streamNumber=2\n\
|
||||
audio_1.name=Commenter Pro Track\n\
|
||||
audio_1.audioLanguage=eng\n",
|
||||
);
|
||||
assert_eq!(labels[0].purpose, LabelPurpose::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptive_via_name() {
|
||||
let labels = parse_props(
|
||||
"audio_1.class=AudioButton\n\
|
||||
audio_1.streamNumber=3\n\
|
||||
audio_1.name=English Descriptive Audio\n",
|
||||
);
|
||||
assert_eq!(labels[0].purpose, LabelPurpose::Descriptive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sdh_only_on_subtitles() {
|
||||
// SDH applied to a subtitle stream.
|
||||
let labels = parse_props(
|
||||
"subtitle_1.class=SubtitleButton\n\
|
||||
subtitle_1.streamNumber=4\n\
|
||||
subtitle_1.name=English SDH\n",
|
||||
);
|
||||
assert_eq!(labels[0].qualifier, LabelQualifier::Sdh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sdh_not_applied_to_audio_stream_even_if_name_contains_sdh() {
|
||||
// Audio streams should not pick up SDH (it's a subtitle
|
||||
// concept). Edge case: badly-authored name happens to include
|
||||
// "SDH" — we don't propagate it to audio metadata.
|
||||
let labels = parse_props(
|
||||
"audio_1.class=AudioButton\n\
|
||||
audio_1.streamNumber=5\n\
|
||||
audio_1.name=English SDH (track?)\n",
|
||||
);
|
||||
assert_eq!(labels[0].qualifier, LabelQualifier::None);
|
||||
}
|
||||
}
|
||||
|
||||
// ── menu_base.prop parser ──────────────────────────────────────────────────
|
||||
|
||||
fn parse_menu_base(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
fn parse_menu_base(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
let data = super::read_jar_file(reader, udf, "menu_base.prop")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
|
||||
@@ -231,16 +395,20 @@ fn parse_menu_base(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<Str
|
||||
}
|
||||
|
||||
let name = props.get("name").cloned().unwrap_or_default();
|
||||
let name_lower = name.to_lowercase();
|
||||
|
||||
let purpose = if name_lower.contains("comment") || prefix.contains("comm") {
|
||||
LabelPurpose::Commentary
|
||||
} else {
|
||||
LabelPurpose::Normal
|
||||
// Purpose: ask vocab first (word-boundary matched — avoids the
|
||||
// "Commenter" false positive the prior `name.contains("comment")`
|
||||
// had). Then fall back to the structural prefix check
|
||||
// (`audio_commentary.foo`-style keys group commentary streams
|
||||
// regardless of display name).
|
||||
let purpose = match vocab::purpose(&name) {
|
||||
LabelPurpose::Normal if prefix.contains("comm") => LabelPurpose::Commentary,
|
||||
p => p,
|
||||
};
|
||||
|
||||
let qualifier = if is_subtitle && name_lower.contains("sdh") {
|
||||
LabelQualifier::Sdh
|
||||
// Qualifier: only apply to subtitles (SDH is a subtitle concept).
|
||||
let qualifier = if is_subtitle {
|
||||
vocab::qualifier(&name)
|
||||
} else {
|
||||
LabelQualifier::None
|
||||
};
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
//! "dbp" framework — Magnolia Pictures BD-J authoring shop (per
|
||||
//! `bd-live.magpictures.com` referenced in the disc's
|
||||
//! `com/dbp/bluray.MenuXlet.perm`). Detected on UHD discs whose
|
||||
//! `/BDMV/JAR/<x>.jar` (top-level, not in a subdir) contains
|
||||
//! `com/dbp/` package paths.
|
||||
//!
|
||||
//! Stream labels live as plain ASCII strings inside compiled `.class`
|
||||
//! files in the jar — a quirk of the menu-rendering layer encoding
|
||||
//! its TextField positions and content as constant strings the
|
||||
//! Java compiler retained in the class string pool. Format observed
|
||||
//! in the corpus (Civil War UHD, 2024):
|
||||
//!
|
||||
//! ```text
|
||||
//! LTextField,Audio1,English Dolby Atmos,Fontstrip_Composite,...
|
||||
//! RTextField,Audio2,English Descriptive Audio,Fontstrip_Composite,...
|
||||
//! HTextField,Subtitle1,English SDH,Fontstrip_Composite,...
|
||||
//! ATextField,Subtitle0,None,Fontstrip_Composite,...
|
||||
//! ```
|
||||
//!
|
||||
//! The single uppercase letter before `TextField` is string-pool
|
||||
//! prefix noise — the parser anchors on `TextField,` regardless of
|
||||
//! what precedes it. `Subtitle0` is the disable-subtitles menu
|
||||
//! button and is skipped (not a real subtitle stream).
|
||||
//!
|
||||
//! ## Implementation
|
||||
//!
|
||||
//! v2 (2026-05-10): rewritten on top of [`super::class_reader`] —
|
||||
//! iterates `CpInfo::Utf8` constant-pool entries instead of raw byte
|
||||
//! scanning each class file. Equivalent label coverage (the literal
|
||||
//! `TextField,...` strings live in the CP as Utf8 entries), but
|
||||
//! structurally cleaner: no false-positive risk from method bytecode
|
||||
//! or attribute names happening to contain `TextField,`. Language /
|
||||
//! purpose / qualifier classification moved to [`super::vocab`] so all
|
||||
//! Java-parser families share one source of truth.
|
||||
|
||||
use super::class_reader::CpInfo;
|
||||
use super::{ParseResult, StreamLabel, StreamLabelType, jar, vocab};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// dbp detect can't peek inside a jar without a SectorSource (the
|
||||
/// trait function only takes `&UdfFs`), so we trigger on the cheap
|
||||
/// signal "any top-level .jar in /BDMV/JAR/." That fires on every
|
||||
/// BD-J disc, but parse() does the real `com/dbp/` check and
|
||||
/// returns None on a mismatch — so this parser only ever consumes
|
||||
/// time on discs that fell through every earlier parser.
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
jar::has_any_top_level_jar(udf)
|
||||
}
|
||||
|
||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||
jar::for_each_jar(reader, udf, |_entry_name, archive| {
|
||||
if !jar::has_path_prefix(archive, "com/dbp/") {
|
||||
return None;
|
||||
}
|
||||
let labels = scan_jar(archive);
|
||||
if labels.is_empty() {
|
||||
None
|
||||
} else {
|
||||
// High confidence: TextField,Audio1,... is a stable anchor
|
||||
// pattern + vocab routes language/purpose/qualifier.
|
||||
Some(ParseResult::high(labels))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn scan_jar(archive: &mut jar::Jar) -> Vec<StreamLabel> {
|
||||
// BTreeMap so we keep the highest-numbered (last-written) label
|
||||
// for each stream slot deterministic across runs. The same
|
||||
// TextField,Audio1,... string can appear in multiple classes
|
||||
// (button-state variants, localization fallbacks). Last write
|
||||
// wins — they should all agree, but the structure is defensive.
|
||||
let mut audios: BTreeMap<u16, String> = BTreeMap::new();
|
||||
let mut subs: BTreeMap<u16, String> = BTreeMap::new();
|
||||
|
||||
jar::for_each_class(archive, |_class_name, class| {
|
||||
for (_idx, cp) in class.constant_pool.iter() {
|
||||
if let CpInfo::Utf8(s) = cp {
|
||||
collect_textfield(s, &mut audios, &mut subs);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut out = Vec::new();
|
||||
for (num, label) in audios {
|
||||
out.push(make_label(num, label, StreamLabelType::Audio));
|
||||
}
|
||||
for (num, label) in subs {
|
||||
out.push(make_label(num, label, StreamLabelType::Subtitle));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_textfield(
|
||||
s: &str,
|
||||
audios: &mut BTreeMap<u16, String>,
|
||||
subs: &mut BTreeMap<u16, String>,
|
||||
) {
|
||||
// Anchor on "TextField," — the prefix character before it varies
|
||||
// (string-pool ordering inside compiled Java) and is irrelevant.
|
||||
let Some(idx) = s.find("TextField,") else {
|
||||
return;
|
||||
};
|
||||
let after = &s[idx + "TextField,".len()..];
|
||||
let mut parts = after.splitn(3, ',');
|
||||
let kind_n = parts.next().unwrap_or("").trim();
|
||||
let label = parts.next().unwrap_or("").trim();
|
||||
if label.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(rest) = kind_n.strip_prefix("Audio") {
|
||||
if let Ok(n) = rest.parse::<u16>() {
|
||||
audios.insert(n, label.to_string());
|
||||
}
|
||||
} else if let Some(rest) = kind_n.strip_prefix("Subtitle") {
|
||||
if let Ok(n) = rest.parse::<u16>() {
|
||||
// Subtitle0 is conventionally the "None / Off" disable
|
||||
// button, not an actual subtitle stream.
|
||||
if n > 0 {
|
||||
subs.insert(n, label.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_label(num: u16, label: String, stream_type: StreamLabelType) -> StreamLabel {
|
||||
let lang_info = vocab::lang(&label);
|
||||
let language = lang_info.map(|l| l.code).unwrap_or("").to_string();
|
||||
let variant = lang_info.map(|l| l.variant).unwrap_or("").to_string();
|
||||
let qualifier = vocab::qualifier(&label);
|
||||
let purpose = vocab::purpose(&label);
|
||||
StreamLabel {
|
||||
stream_number: num,
|
||||
stream_type,
|
||||
language,
|
||||
name: label,
|
||||
purpose,
|
||||
qualifier,
|
||||
codec_hint: String::new(),
|
||||
variant,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{LabelPurpose, LabelQualifier};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn collect_extracts_audio_and_subtitle_indices() {
|
||||
let mut audios = BTreeMap::new();
|
||||
let mut subs = BTreeMap::new();
|
||||
let lines = [
|
||||
"LTextField,Audio1,English Dolby Atmos,Fontstrip_Composite,296,763,275,25,left",
|
||||
"RTextField,Audio2,English Descriptive Audio,Fontstrip_Composite,296,803,275,25,left",
|
||||
"RTextField,Audio3,Spanish 5.1 Dolby Digital,Fontstrip_Composite,296,843,275,25,left",
|
||||
"ATextField,Subtitle0,None,Fontstrip_Composite,1312,843,275,25,left",
|
||||
"HTextField,Subtitle1,English SDH,Fontstrip_Composite,1312,763,275,25,left",
|
||||
"DTextField,Subtitle2,Spanish,Fontstrip_Composite,1312,803,275,25,left",
|
||||
];
|
||||
for s in &lines {
|
||||
collect_textfield(s, &mut audios, &mut subs);
|
||||
}
|
||||
assert_eq!(audios.len(), 3);
|
||||
assert_eq!(audios[&1], "English Dolby Atmos");
|
||||
assert_eq!(audios[&2], "English Descriptive Audio");
|
||||
assert_eq!(audios[&3], "Spanish 5.1 Dolby Digital");
|
||||
// Subtitle0 ("None") is skipped — disable button, not a stream.
|
||||
assert_eq!(subs.len(), 2);
|
||||
assert_eq!(subs[&1], "English SDH");
|
||||
assert_eq!(subs[&2], "Spanish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_ignores_non_textfield_strings() {
|
||||
let mut audios = BTreeMap::new();
|
||||
let mut subs = BTreeMap::new();
|
||||
for s in [
|
||||
"GraphicButton,SU_Audio",
|
||||
"AudioMenu",
|
||||
"CommentaryMenuAlternateScenes",
|
||||
"PrimaryAudioControl",
|
||||
] {
|
||||
collect_textfield(s, &mut audios, &mut subs);
|
||||
}
|
||||
assert!(audios.is_empty());
|
||||
assert!(subs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_label_routes_via_vocab() {
|
||||
let l = make_label(1, "English SDH".to_string(), StreamLabelType::Subtitle);
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.qualifier, LabelQualifier::Sdh);
|
||||
assert_eq!(l.purpose, LabelPurpose::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_label_descriptive_audio() {
|
||||
let l = make_label(
|
||||
2,
|
||||
"English Descriptive Audio".to_string(),
|
||||
StreamLabelType::Audio,
|
||||
);
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.purpose, LabelPurpose::Descriptive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_label_commentary() {
|
||||
let l = make_label(
|
||||
3,
|
||||
"English Director's Commentary".to_string(),
|
||||
StreamLabelType::Audio,
|
||||
);
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.purpose, LabelPurpose::Commentary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_label_compound_languages_populate_variant() {
|
||||
let brazilian = make_label(1, "Brazilian Portuguese 5.1".into(), StreamLabelType::Audio);
|
||||
assert_eq!(brazilian.language, "por");
|
||||
assert_eq!(brazilian.variant, "Brazilian");
|
||||
|
||||
let castilian = make_label(1, "Castilian Spanish".into(), StreamLabelType::Audio);
|
||||
assert_eq!(castilian.language, "spa");
|
||||
assert_eq!(castilian.variant, "Castilian");
|
||||
|
||||
let canadian = make_label(
|
||||
1,
|
||||
"Canadian French Dolby Digital".into(),
|
||||
StreamLabelType::Audio,
|
||||
);
|
||||
assert_eq!(canadian.language, "fra");
|
||||
assert_eq!(canadian.variant, "Canadian");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_label_bare_language_has_empty_variant() {
|
||||
let l = make_label(1, "English Dolby Atmos".into(), StreamLabelType::Audio);
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.variant, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_label_unknown_language_is_empty() {
|
||||
// vocab::lang returns None — make_label converts both fields to "".
|
||||
let l = make_label(1, "Klingon Dolby Atmos".into(), StreamLabelType::Audio);
|
||||
assert_eq!(l.language, "");
|
||||
assert_eq!(l.variant, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_label_rnib_descriptive_service() {
|
||||
let l = make_label(1, "English RNIB".into(), StreamLabelType::Subtitle);
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.qualifier, LabelQualifier::DescriptiveService);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
||||
//! BD-J jar utilities — common scaffolding for parsers that read
|
||||
//! `/BDMV/JAR/*.jar`.
|
||||
//!
|
||||
//! Composes with [`class_reader`](super::class_reader) for structured
|
||||
//! `.class` access. Used by `dbp` (string-pool scan via constant pool)
|
||||
//! and `deluxe` (bytecode pattern matching) — those parsers express
|
||||
//! "open every top-level jar, look at every .class inside" without
|
||||
//! repeating the zip-archive boilerplate.
|
||||
|
||||
// `try_each_class` is staged for `labels::deluxe`, which needs the
|
||||
// early-return form to short-circuit class iteration on a match.
|
||||
// dead-code allow comes off when deluxe lands.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use super::class_reader::ClassFile;
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::io::Cursor;
|
||||
use zip::ZipArchive;
|
||||
|
||||
/// In-memory zip archive: backed by a `Vec<u8>` read from UDF. Owns
|
||||
/// the buffer; callers pass it to [`has_path_prefix`], [`for_each_class`],
|
||||
/// etc.
|
||||
pub type Jar = ZipArchive<Cursor<Vec<u8>>>;
|
||||
|
||||
/// True if `/BDMV/JAR/` contains at least one top-level `.jar` file
|
||||
/// (not under a subdir). Used by `detect()` in parsers whose real
|
||||
/// signal lives inside a jar — they can't open the jar without a
|
||||
/// `SectorSource`, so they use this cheap pre-check and do the real
|
||||
/// `com/<vendor>/` discriminator in `parse()`.
|
||||
pub fn has_any_top_level_jar(udf: &UdfFs) -> bool {
|
||||
let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else {
|
||||
return false;
|
||||
};
|
||||
jar_dir
|
||||
.entries
|
||||
.iter()
|
||||
.any(|e| !e.is_dir && e.name.to_lowercase().ends_with(".jar"))
|
||||
}
|
||||
|
||||
/// Open every top-level `*.jar` entry in `/BDMV/JAR/` and yield each
|
||||
/// `(entry_name, Jar)` to `f`. Returns the first `Some(R)` the callback
|
||||
/// produces, or `None` if every jar was visited without a hit.
|
||||
///
|
||||
/// "Top-level" means entries directly under `/BDMV/JAR/`, not nested
|
||||
/// under a subdir. (Pixelogic, Criterion, Paramount, etc. put their
|
||||
/// data files inside `/BDMV/JAR/<x>/`; dbp and Deluxe put their jar
|
||||
/// directly at `/BDMV/JAR/<name>.jar`.)
|
||||
///
|
||||
/// Entries that fail to read from UDF or that aren't valid zips are
|
||||
/// silently skipped — same defensive shape as the existing dbp parser.
|
||||
pub fn for_each_jar<R, F>(reader: &mut dyn SectorSource, udf: &UdfFs, mut f: F) -> Option<R>
|
||||
where
|
||||
F: FnMut(&str, &mut Jar) -> Option<R>,
|
||||
{
|
||||
let jar_dir = udf.find_dir("/BDMV/JAR")?;
|
||||
for entry in &jar_dir.entries {
|
||||
if entry.is_dir {
|
||||
continue;
|
||||
}
|
||||
if !entry.name.to_lowercase().ends_with(".jar") {
|
||||
continue;
|
||||
}
|
||||
let path = format!("/BDMV/JAR/{}", entry.name);
|
||||
let Ok(bytes) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(mut archive) = ZipArchive::new(Cursor::new(bytes)) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(r) = f(&entry.name, &mut archive) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// True if any entry in this jar's central directory starts with
|
||||
/// `prefix`. Fast — only reads filenames, never extracts bytes.
|
||||
///
|
||||
/// Used by parsers as a cheap "is this MY framework's jar?" check
|
||||
/// (e.g. `has_path_prefix(archive, "com/dbp/")` for dbp,
|
||||
/// `has_path_prefix(archive, "com/bydeluxe/")` for Deluxe).
|
||||
pub fn has_path_prefix(archive: &mut Jar, prefix: &str) -> bool {
|
||||
for i in 0..archive.len() {
|
||||
if let Ok(f) = archive.by_index(i) {
|
||||
if f.name().starts_with(prefix) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Iterate every `.class` entry in the jar, parse it with
|
||||
/// [`class_reader`], and call `f` with `(entry_name, &ClassFile)`.
|
||||
///
|
||||
/// Entries that fail to read or parse are silently skipped — this is
|
||||
/// label-extraction code, robustness matters more than completeness.
|
||||
/// Callers that need to know which classes failed should use the
|
||||
/// lower-level [`class_reader`] API directly.
|
||||
pub fn for_each_class<F>(archive: &mut Jar, mut f: F)
|
||||
where
|
||||
F: FnMut(&str, &ClassFile),
|
||||
{
|
||||
for i in 0..archive.len() {
|
||||
let Ok(mut entry) = archive.by_index(i) else {
|
||||
continue;
|
||||
};
|
||||
if !entry.name().ends_with(".class") {
|
||||
continue;
|
||||
}
|
||||
let name = entry.name().to_string();
|
||||
let mut bytes = Vec::with_capacity(entry.size() as usize);
|
||||
if std::io::Read::read_to_end(&mut entry, &mut bytes).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Ok(class) = ClassFile::parse(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
f(&name, &class);
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`for_each_class`] but allows the callback to short-circuit
|
||||
/// iteration. Returns the first `Some(R)` the callback produces.
|
||||
pub fn try_each_class<R, F>(archive: &mut Jar, mut f: F) -> Option<R>
|
||||
where
|
||||
F: FnMut(&str, &ClassFile) -> Option<R>,
|
||||
{
|
||||
for i in 0..archive.len() {
|
||||
let Ok(mut entry) = archive.by_index(i) else {
|
||||
continue;
|
||||
};
|
||||
if !entry.name().ends_with(".class") {
|
||||
continue;
|
||||
}
|
||||
let name = entry.name().to_string();
|
||||
let mut bytes = Vec::with_capacity(entry.size() as usize);
|
||||
if std::io::Read::read_to_end(&mut entry, &mut bytes).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Ok(class) = ClassFile::parse(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(r) = f(&name, &class) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
+1158
-35
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,597 @@
|
||||
//! Universal MPLS-based stream labels.
|
||||
//!
|
||||
//! Unlike the framework-specific parsers in this directory (dbp,
|
||||
//! pixelogic, ctrm, criterion, ...), this module is the *floor*:
|
||||
//! every Blu-ray ships with MPLS playlists under `/BDMV/PLAYLIST/`,
|
||||
//! and every MPLS file has an STN table with per-stream ISO 639-2
|
||||
//! language codes plus coding-type / channel-layout / sample-rate
|
||||
//! bytes from the BD spec.
|
||||
//!
|
||||
//! The framework parsers extract richer editorial labels ("English
|
||||
//! Dolby Atmos", "Director's Commentary") when the disc was authored
|
||||
//! with a recognized tool. When none of them match (e.g. a "no BD-J"
|
||||
//! disc, or an authoring framework we haven't catalogued), MPLS still
|
||||
//! gives us language + codec on every stream — enough to render
|
||||
//! something more useful than the bare PID.
|
||||
//!
|
||||
//! Output confidence is Low: MPLS carries language + codec but
|
||||
//! never purpose/qualifier info (no way to tell "Commentary" from
|
||||
//! "Normal" from the STN table alone). Higher-confidence framework
|
||||
//! parsers, when present, always win on the registry's max-by-confidence
|
||||
//! tiebreaker — MPLS is only chosen when nothing else matched.
|
||||
|
||||
use super::{
|
||||
LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType,
|
||||
vocab::{self, LangInfo},
|
||||
};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
|
||||
/// True iff `/BDMV/PLAYLIST/` exists and contains at least one
|
||||
/// `.mpls` file. Cheap directory walk only — no sector reads.
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
let Some(dir) = udf.find_dir("/BDMV/PLAYLIST") else {
|
||||
return false;
|
||||
};
|
||||
dir.entries
|
||||
.iter()
|
||||
.any(|e| !e.is_dir && has_mpls_extension(&e.name))
|
||||
}
|
||||
|
||||
/// Walk every `*.mpls` in `/BDMV/PLAYLIST/`, parse it, and convert
|
||||
/// each StreamEntry to a [`StreamLabel`]. Streams shared across
|
||||
/// playlists (same PID) are deduped.
|
||||
///
|
||||
/// Returns `None` if no labels could be produced (e.g. no .mpls files
|
||||
/// parsed successfully, or every parsed stream was a type we skip
|
||||
/// like IG / DV EL).
|
||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||
let playlist_dir = udf.find_dir("/BDMV/PLAYLIST")?;
|
||||
|
||||
// Collect mpls filenames first so we don't hold a borrow on udf
|
||||
// while we call udf.read_file (which takes &self).
|
||||
let mpls_names: Vec<String> = playlist_dir
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| !e.is_dir && has_mpls_extension(&e.name))
|
||||
.map(|e| e.name.clone())
|
||||
.collect();
|
||||
|
||||
if mpls_names.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut labels: Vec<StreamLabel> = Vec::new();
|
||||
// (stream_type_tag, language, codec_hint, pid) — PID is the
|
||||
// canonical "same physical stream" key; type+lang+codec round
|
||||
// out the rare case where two distinct logical streams happen
|
||||
// to share a PID across playlists with different metadata.
|
||||
let mut seen: Vec<(u8, String, String, u16)> = Vec::new();
|
||||
|
||||
// Global 1-based counters keyed by StreamLabelType. Incremented
|
||||
// only when an entry survives dedup, so stream_numbers are dense
|
||||
// (1, 2, 3, ...) per type across the whole disc — not reset per
|
||||
// playlist. A disc with 2 MPLS files that each list the same
|
||||
// 8 audio streams ends up with audio_1..audio_8, not audio_1..
|
||||
// audio_16 or audio_1..audio_8 with audio_1 duplicated.
|
||||
let mut audio_idx: u16 = 0;
|
||||
let mut sub_idx: u16 = 0;
|
||||
|
||||
for name in &mpls_names {
|
||||
let path = format!("/BDMV/PLAYLIST/{}", name);
|
||||
let Ok(data) = udf.read_file(reader, &path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(playlist) = crate::mpls::parse(&data) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for entry in &playlist.streams {
|
||||
let label_type = match entry.stream_type {
|
||||
2 | 5 => StreamLabelType::Audio, // primary + secondary audio
|
||||
3 => StreamLabelType::Subtitle, // PG subtitle
|
||||
// 1 = primary video, 6 = secondary video, 7 = DV EL
|
||||
// → no StreamLabelType variant for video, skip.
|
||||
// 4 = IG (interactive graphics) — not a user-facing
|
||||
// stream, skip.
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let language = normalize_language(&entry.language);
|
||||
let name = language_display_name(&language);
|
||||
let codec_hint = build_codec_hint(label_type, entry);
|
||||
|
||||
let type_tag = type_tag(label_type);
|
||||
let key = (type_tag, language.clone(), codec_hint.clone(), entry.pid);
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
seen.push(key);
|
||||
|
||||
let stream_number = match label_type {
|
||||
StreamLabelType::Audio => {
|
||||
audio_idx += 1;
|
||||
audio_idx
|
||||
}
|
||||
StreamLabelType::Subtitle => {
|
||||
sub_idx += 1;
|
||||
sub_idx
|
||||
}
|
||||
};
|
||||
|
||||
labels.push(StreamLabel {
|
||||
stream_number,
|
||||
stream_type: label_type,
|
||||
language,
|
||||
name,
|
||||
purpose: LabelPurpose::Normal,
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_hint,
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// MPLS gives language + codec but never editorial info (no
|
||||
// commentary/SDH/director's cut). Low confidence means framework
|
||||
// parsers (paramount, criterion, pixelogic, ctrm, dbp, deluxe) always
|
||||
// win when they match. MPLS only gets chosen as the parser when
|
||||
// nothing else fired — exactly the universal-fallback role we want.
|
||||
Some(ParseResult::low(labels))
|
||||
}
|
||||
|
||||
fn has_mpls_extension(name: &str) -> bool {
|
||||
// Case-insensitive ".mpls" suffix. Some discs use uppercase,
|
||||
// some lowercase; UDF filenames preserve case but we don't.
|
||||
let n = name.len();
|
||||
if n < 5 {
|
||||
return false;
|
||||
}
|
||||
name[n - 5..].eq_ignore_ascii_case(".mpls")
|
||||
}
|
||||
|
||||
/// Lowercase + trim the raw 3-char ISO 639-2 code. If the lowered
|
||||
/// string maps via [`vocab::lang`] (it won't for plain "eng" — that
|
||||
/// matcher is for English-name fragments, not codes) use its
|
||||
/// canonical code; otherwise return the trimmed lowercase string.
|
||||
fn normalize_language(raw: &str) -> String {
|
||||
let trimmed = raw.trim().to_ascii_lowercase();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
// vocab::lang() matches free-form English names, not ISO 639-2
|
||||
// codes — so for the typical MPLS payload ("eng", "fra", ...)
|
||||
// it returns None and we keep the trimmed code.
|
||||
if let Some(LangInfo { code, .. }) = vocab::lang(&trimmed) {
|
||||
return code.to_string();
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
|
||||
/// Human-readable English name for an ISO 639-2 code, or empty if
|
||||
/// the code is unknown. Kept inline rather than in vocab because
|
||||
/// vocab is the *reverse* mapping (name → code).
|
||||
pub(crate) fn language_display_name(iso: &str) -> String {
|
||||
match iso {
|
||||
"eng" => "English",
|
||||
"fra" | "fre" => "French",
|
||||
"spa" => "Spanish",
|
||||
"deu" | "ger" => "German",
|
||||
"ita" => "Italian",
|
||||
"jpn" => "Japanese",
|
||||
"zho" | "chi" => "Chinese",
|
||||
"kor" => "Korean",
|
||||
"por" => "Portuguese",
|
||||
"pol" => "Polish",
|
||||
"ces" | "cze" => "Czech",
|
||||
"hun" => "Hungarian",
|
||||
"nld" | "dut" => "Dutch",
|
||||
"ara" => "Arabic",
|
||||
"hin" => "Hindi",
|
||||
"tur" => "Turkish",
|
||||
"tha" => "Thai",
|
||||
"swe" => "Swedish",
|
||||
"nor" => "Norwegian",
|
||||
"dan" => "Danish",
|
||||
"fin" => "Finnish",
|
||||
"heb" => "Hebrew",
|
||||
"rus" => "Russian",
|
||||
"ell" | "gre" => "Greek",
|
||||
"vie" => "Vietnamese",
|
||||
"ind" => "Indonesian",
|
||||
"msa" | "may" => "Malay",
|
||||
"ukr" => "Ukrainian",
|
||||
"ron" | "rum" => "Romanian",
|
||||
"bul" => "Bulgarian",
|
||||
"hrv" => "Croatian",
|
||||
"srp" => "Serbian",
|
||||
"slk" | "slo" => "Slovak",
|
||||
"slv" => "Slovenian",
|
||||
"est" => "Estonian",
|
||||
"lav" => "Latvian",
|
||||
"lit" => "Lithuanian",
|
||||
"isl" | "ice" => "Icelandic",
|
||||
"eus" | "baq" => "Basque",
|
||||
"cat" => "Catalan",
|
||||
"glg" => "Galician",
|
||||
_ => "",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Map BD coding_type byte → codec name. Returns empty for unknown
|
||||
/// bytes (the table covers everything the spec defines, but unknown
|
||||
/// values are still possible on malformed discs).
|
||||
pub(crate) fn codec_name(coding_type: u8) -> &'static str {
|
||||
match coding_type {
|
||||
0x02 => "MPEG-2",
|
||||
0x1B => "H.264",
|
||||
0x24 => "HEVC",
|
||||
0x80 => "LPCM",
|
||||
0x81 => "AC-3",
|
||||
0x82 => "DTS",
|
||||
0x83 => "TrueHD",
|
||||
0x84 => "AC-3+",
|
||||
0x85 => "DTS-HD",
|
||||
0x86 => "DTS-HD MA",
|
||||
0x90 => "PG",
|
||||
0x91 => "IG",
|
||||
0xA1 => "AC-3+ Secondary",
|
||||
0xA2 => "DTS-HD Secondary",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the final `codec_hint`. For audio streams, optionally
|
||||
/// append " <channels>" and/or " <rate>" suffixes. Sample rate is
|
||||
/// only spelled out for non-48k (the universal default).
|
||||
fn build_codec_hint(label_type: StreamLabelType, entry: &crate::mpls::StreamEntry) -> String {
|
||||
let base = codec_name(entry.coding_type);
|
||||
if base.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if label_type != StreamLabelType::Audio {
|
||||
return base.to_string();
|
||||
}
|
||||
|
||||
let mut out = base.to_string();
|
||||
|
||||
let channels = match entry.audio_format {
|
||||
1 => Some("mono"),
|
||||
3 => Some("2.0"),
|
||||
6 => Some("5.1"),
|
||||
12 => Some("7.1"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ch) = channels {
|
||||
out.push(' ');
|
||||
out.push_str(ch);
|
||||
}
|
||||
|
||||
// 1 = 48 kHz (universal default, omit). Only call out higher rates.
|
||||
let rate = match entry.audio_rate {
|
||||
4 => Some("96kHz"),
|
||||
5 => Some("192kHz"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(r) = rate {
|
||||
out.push(' ');
|
||||
out.push_str(r);
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Dedup-tag for the label type. `u8` instead of `StreamLabelType`
|
||||
/// itself because the enum does not derive `Hash` / `Eq`-by-discriminant
|
||||
/// in a way that we want to couple to (and `==` works fine for the
|
||||
/// linear `Vec::contains` lookup we do).
|
||||
fn type_tag(t: StreamLabelType) -> u8 {
|
||||
match t {
|
||||
StreamLabelType::Audio => 1,
|
||||
StreamLabelType::Subtitle => 2,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mpls::{Playlist, StreamEntry};
|
||||
|
||||
fn audio_entry(pid: u16, coding: u8, fmt: u8, rate: u8, lang: &str) -> StreamEntry {
|
||||
StreamEntry {
|
||||
stream_type: 2,
|
||||
pid,
|
||||
coding_type: coding,
|
||||
video_format: 0,
|
||||
video_rate: 0,
|
||||
audio_format: fmt,
|
||||
audio_rate: rate,
|
||||
language: lang.to_string(),
|
||||
dynamic_range: 0,
|
||||
color_space: 0,
|
||||
secondary: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn pg_entry(pid: u16, lang: &str) -> StreamEntry {
|
||||
StreamEntry {
|
||||
stream_type: 3,
|
||||
pid,
|
||||
coding_type: 0x90,
|
||||
video_format: 0,
|
||||
video_rate: 0,
|
||||
audio_format: 0,
|
||||
audio_rate: 0,
|
||||
language: lang.to_string(),
|
||||
dynamic_range: 0,
|
||||
color_space: 0,
|
||||
secondary: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn playlist_with(streams: Vec<StreamEntry>) -> Playlist {
|
||||
Playlist {
|
||||
version: "0200".to_string(),
|
||||
play_items: Vec::new(),
|
||||
streams,
|
||||
marks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the same conversion logic that `parse()` runs on real
|
||||
/// disc data, but starting from already-parsed Playlists so we
|
||||
/// don't have to synthesize valid MPLS bytes.
|
||||
fn labels_from_playlists(playlists: &[Playlist]) -> Vec<StreamLabel> {
|
||||
let mut labels: Vec<StreamLabel> = Vec::new();
|
||||
let mut seen: Vec<(u8, String, String, u16)> = Vec::new();
|
||||
|
||||
for playlist in playlists {
|
||||
let mut audio_idx: u16 = 0;
|
||||
let mut sub_idx: u16 = 0;
|
||||
|
||||
for entry in &playlist.streams {
|
||||
let label_type = match entry.stream_type {
|
||||
2 | 5 => StreamLabelType::Audio,
|
||||
3 => StreamLabelType::Subtitle,
|
||||
_ => continue,
|
||||
};
|
||||
let stream_number = match label_type {
|
||||
StreamLabelType::Audio => {
|
||||
audio_idx += 1;
|
||||
audio_idx
|
||||
}
|
||||
StreamLabelType::Subtitle => {
|
||||
sub_idx += 1;
|
||||
sub_idx
|
||||
}
|
||||
};
|
||||
let language = normalize_language(&entry.language);
|
||||
let name = language_display_name(&language);
|
||||
let codec_hint = build_codec_hint(label_type, entry);
|
||||
let key = (
|
||||
type_tag(label_type),
|
||||
language.clone(),
|
||||
codec_hint.clone(),
|
||||
entry.pid,
|
||||
);
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
seen.push(key);
|
||||
labels.push(StreamLabel {
|
||||
stream_number,
|
||||
stream_type: label_type,
|
||||
language,
|
||||
name,
|
||||
purpose: LabelPurpose::Normal,
|
||||
qualifier: LabelQualifier::None,
|
||||
codec_hint,
|
||||
variant: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
labels
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mpls_audio_streams_become_labels() {
|
||||
// Two audio streams: English TrueHD 7.1 48k, French AC-3 5.1 48k.
|
||||
let pl = playlist_with(vec![
|
||||
audio_entry(0x1100, 0x83, 12, 1, "eng"),
|
||||
audio_entry(0x1101, 0x81, 6, 1, "fra"),
|
||||
]);
|
||||
let labels = labels_from_playlists(&[pl]);
|
||||
assert_eq!(labels.len(), 2);
|
||||
|
||||
// English TrueHD 7.1
|
||||
let a = &labels[0];
|
||||
assert_eq!(a.stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(a.stream_number, 1);
|
||||
assert_eq!(a.language, "eng");
|
||||
assert_eq!(a.name, "English");
|
||||
assert_eq!(a.codec_hint, "TrueHD 7.1");
|
||||
assert_eq!(a.purpose, LabelPurpose::Normal);
|
||||
assert_eq!(a.qualifier, LabelQualifier::None);
|
||||
assert_eq!(a.variant, "");
|
||||
|
||||
// French AC-3 5.1
|
||||
let b = &labels[1];
|
||||
assert_eq!(b.stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(b.stream_number, 2);
|
||||
assert_eq!(b.language, "fra");
|
||||
assert_eq!(b.name, "French");
|
||||
assert_eq!(b.codec_hint, "AC-3 5.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mpls_pg_streams_become_subtitle_labels() {
|
||||
let pl = playlist_with(vec![
|
||||
pg_entry(0x1200, "eng"),
|
||||
pg_entry(0x1201, "spa"),
|
||||
pg_entry(0x1202, "fra"),
|
||||
]);
|
||||
let labels = labels_from_playlists(&[pl]);
|
||||
assert_eq!(labels.len(), 3);
|
||||
for label in &labels {
|
||||
assert_eq!(label.stream_type, StreamLabelType::Subtitle);
|
||||
assert_eq!(label.codec_hint, "PG");
|
||||
}
|
||||
assert_eq!(labels[0].stream_number, 1);
|
||||
assert_eq!(labels[0].language, "eng");
|
||||
assert_eq!(labels[0].name, "English");
|
||||
assert_eq!(labels[1].stream_number, 2);
|
||||
assert_eq!(labels[1].language, "spa");
|
||||
assert_eq!(labels[1].name, "Spanish");
|
||||
assert_eq!(labels[2].stream_number, 3);
|
||||
assert_eq!(labels[2].language, "fra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_streams_across_playlists() {
|
||||
// Two playlists, same English TrueHD 7.1 PID 0x1100 in both.
|
||||
// Expect one Audio label, not two.
|
||||
let pl1 = playlist_with(vec![
|
||||
audio_entry(0x1100, 0x83, 12, 1, "eng"),
|
||||
audio_entry(0x1101, 0x81, 6, 1, "fra"),
|
||||
]);
|
||||
let pl2 = playlist_with(vec![
|
||||
audio_entry(0x1100, 0x83, 12, 1, "eng"), // duplicate
|
||||
audio_entry(0x1102, 0x82, 6, 1, "deu"), // new
|
||||
]);
|
||||
let labels = labels_from_playlists(&[pl1, pl2]);
|
||||
// Expected: eng@0x1100, fra@0x1101, deu@0x1102 — three uniques.
|
||||
assert_eq!(labels.len(), 3);
|
||||
// PID isn't stored on StreamLabel, so assert on the surviving
|
||||
// language set instead.
|
||||
let mut langs: Vec<String> = labels.iter().map(|l| l.language.clone()).collect();
|
||||
langs.sort();
|
||||
assert_eq!(langs, vec!["deu", "eng", "fra"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coding_type_to_codec_hint_table() {
|
||||
// Spot-check every entry in the spec table. Audio entries
|
||||
// come back bare (no channels/rate set) so codec_hint is the
|
||||
// codec name alone.
|
||||
let cases: &[(u8, &str)] = &[
|
||||
(0x02, "MPEG-2"),
|
||||
(0x1B, "H.264"),
|
||||
(0x24, "HEVC"),
|
||||
(0x80, "LPCM"),
|
||||
(0x81, "AC-3"),
|
||||
(0x82, "DTS"),
|
||||
(0x83, "TrueHD"),
|
||||
(0x84, "AC-3+"),
|
||||
(0x85, "DTS-HD"),
|
||||
(0x86, "DTS-HD MA"),
|
||||
(0x90, "PG"),
|
||||
(0x91, "IG"),
|
||||
(0xA1, "AC-3+ Secondary"),
|
||||
(0xA2, "DTS-HD Secondary"),
|
||||
];
|
||||
for (ct, expected) in cases {
|
||||
assert_eq!(
|
||||
codec_name(*ct),
|
||||
*expected,
|
||||
"coding_type 0x{:02X} should map to {}",
|
||||
ct,
|
||||
expected
|
||||
);
|
||||
}
|
||||
// Unknown bytes return empty.
|
||||
assert_eq!(codec_name(0x00), "");
|
||||
assert_eq!(codec_name(0xFF), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_format_appends_channel_layout() {
|
||||
let mono = audio_entry(1, 0x83, 1, 1, "eng");
|
||||
let stereo = audio_entry(2, 0x83, 3, 1, "eng");
|
||||
let surround_51 = audio_entry(3, 0x83, 6, 1, "eng");
|
||||
let surround_71 = audio_entry(4, 0x83, 12, 1, "eng");
|
||||
let unknown = audio_entry(5, 0x83, 0, 1, "eng");
|
||||
assert_eq!(
|
||||
build_codec_hint(StreamLabelType::Audio, &mono),
|
||||
"TrueHD mono"
|
||||
);
|
||||
assert_eq!(
|
||||
build_codec_hint(StreamLabelType::Audio, &stereo),
|
||||
"TrueHD 2.0"
|
||||
);
|
||||
assert_eq!(
|
||||
build_codec_hint(StreamLabelType::Audio, &surround_51),
|
||||
"TrueHD 5.1"
|
||||
);
|
||||
assert_eq!(
|
||||
build_codec_hint(StreamLabelType::Audio, &surround_71),
|
||||
"TrueHD 7.1"
|
||||
);
|
||||
assert_eq!(build_codec_hint(StreamLabelType::Audio, &unknown), "TrueHD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_rate_only_shows_above_48k() {
|
||||
// 48 kHz (rate=1) is the universal default → not surfaced.
|
||||
let r48 = audio_entry(1, 0x83, 6, 1, "eng");
|
||||
// 96 kHz (rate=4) → surfaced.
|
||||
let r96 = audio_entry(2, 0x83, 6, 4, "eng");
|
||||
// 192 kHz (rate=5) → surfaced.
|
||||
let r192 = audio_entry(3, 0x83, 6, 5, "eng");
|
||||
assert_eq!(build_codec_hint(StreamLabelType::Audio, &r48), "TrueHD 5.1");
|
||||
assert_eq!(
|
||||
build_codec_hint(StreamLabelType::Audio, &r96),
|
||||
"TrueHD 5.1 96kHz"
|
||||
);
|
||||
assert_eq!(
|
||||
build_codec_hint(StreamLabelType::Audio, &r192),
|
||||
"TrueHD 5.1 192kHz"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_iso_code_passes_through_without_display_name() {
|
||||
// Made-up code: keep the raw lowercase code as `language`,
|
||||
// but `name` is empty because we don't know it.
|
||||
let pl = playlist_with(vec![audio_entry(0x1100, 0x83, 6, 1, "xyz")]);
|
||||
let labels = labels_from_playlists(&[pl]);
|
||||
assert_eq!(labels.len(), 1);
|
||||
assert_eq!(labels[0].language, "xyz");
|
||||
assert_eq!(labels[0].name, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ig_and_dv_streams_are_skipped() {
|
||||
// stream_type 4 = IG, 7 = DV EL — both must not surface.
|
||||
let mut ig = pg_entry(0x1400, "eng");
|
||||
ig.stream_type = 4;
|
||||
let mut dv = audio_entry(0x1011, 0x24, 0, 0, "");
|
||||
dv.stream_type = 7;
|
||||
let pl = playlist_with(vec![ig, dv]);
|
||||
let labels = labels_from_playlists(&[pl]);
|
||||
assert!(labels.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secondary_audio_becomes_audio_label() {
|
||||
// stream_type 5 = secondary audio. The conversion should
|
||||
// still produce an Audio label (the registry's apply path
|
||||
// can ignore secondary if it wants — this module just
|
||||
// surfaces what's there).
|
||||
let mut sec = audio_entry(0x1A00, 0x83, 3, 1, "eng");
|
||||
sec.stream_type = 5;
|
||||
sec.secondary = true;
|
||||
let pl = playlist_with(vec![sec]);
|
||||
let labels = labels_from_playlists(&[pl]);
|
||||
assert_eq!(labels.len(), 1);
|
||||
assert_eq!(labels[0].stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(labels[0].codec_hint, "TrueHD 2.0");
|
||||
}
|
||||
}
|
||||
+20
-31
@@ -12,15 +12,15 @@
|
||||
//! sub_com1_idx="23,24,25" />
|
||||
//! ```
|
||||
|
||||
use super::{LabelPurpose, LabelQualifier, StreamLabel, StreamLabelType};
|
||||
use crate::sector::SectorReader;
|
||||
use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, xml};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
|
||||
pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "playlists.xml")
|
||||
}
|
||||
|
||||
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||
let data = super::read_jar_file(reader, udf, "playlists.xml")?;
|
||||
let text = std::str::from_utf8(&data).ok()?;
|
||||
|
||||
@@ -30,8 +30,8 @@ pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLab
|
||||
let mut labels = Vec::new();
|
||||
|
||||
// Parse audio streams
|
||||
if let Some(aud) = extract_attr(&feature, "aud") {
|
||||
let com_idx = extract_attr(&feature, "aud_com1_idx").and_then(|s| s.parse::<usize>().ok());
|
||||
if let Some(aud) = xml::attr(&feature, "aud") {
|
||||
let com_idx = xml::attr(&feature, "aud_com1_idx").and_then(|s| s.parse::<usize>().ok());
|
||||
|
||||
for (i, lang) in aud.split(',').enumerate() {
|
||||
let lang = lang.trim();
|
||||
@@ -57,12 +57,12 @@ pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLab
|
||||
}
|
||||
|
||||
// Parse subtitle streams
|
||||
if let Some(sub) = extract_attr(&feature, "sub") {
|
||||
let forced: Vec<bool> = extract_attr(&feature, "forced_sub")
|
||||
if let Some(sub) = xml::attr(&feature, "sub") {
|
||||
let forced: Vec<bool> = xml::attr(&feature, "forced_sub")
|
||||
.map(|s| s.split(',').map(|f| f.trim() == "1").collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let com_indices: Vec<usize> = extract_attr(&feature, "sub_com1_idx")
|
||||
let com_indices: Vec<usize> = xml::attr(&feature, "sub_com1_idx")
|
||||
.map(|s| s.split(',').filter_map(|i| i.trim().parse().ok()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -100,32 +100,29 @@ pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLab
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(labels)
|
||||
// High confidence: paramount's playlists.xml is fully structured
|
||||
// and we extract every documented field.
|
||||
Some(ParseResult::high(labels))
|
||||
}
|
||||
|
||||
/// Find the feature playlist element (the one with the most audio tracks).
|
||||
fn find_feature_playlist(xml: &str) -> Option<String> {
|
||||
fn find_feature_playlist(text: &str) -> Option<String> {
|
||||
let mut best: Option<String> = None;
|
||||
let mut best_aud_count = 0;
|
||||
let mut from = 0;
|
||||
|
||||
let mut pos = 0;
|
||||
while let Some(start) = xml[pos..].find("<playlist ") {
|
||||
let abs_start = pos + start;
|
||||
let end = match xml[abs_start..].find("/>") {
|
||||
Some(p) => abs_start + p + 2,
|
||||
None => break,
|
||||
};
|
||||
let element = &xml[abs_start..end];
|
||||
while let Some((start, end)) = xml::find_element(text, "playlist", from) {
|
||||
let element = &text[start..end];
|
||||
|
||||
// Prefer name="Feature" explicitly
|
||||
if let Some(name) = extract_attr(element, "name") {
|
||||
// Prefer name="Feature" explicitly.
|
||||
if let Some(name) = xml::attr(element, "name") {
|
||||
if name.eq_ignore_ascii_case("Feature") {
|
||||
return Some(element.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise pick the one with the most audio streams
|
||||
if let Some(aud) = extract_attr(element, "aud") {
|
||||
// Otherwise pick the one with the most audio streams.
|
||||
if let Some(aud) = xml::attr(element, "aud") {
|
||||
let count = aud.split(',').count();
|
||||
if count > best_aud_count {
|
||||
best_aud_count = count;
|
||||
@@ -133,15 +130,7 @@ fn find_feature_playlist(xml: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
pos = end;
|
||||
from = end;
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Extract an XML attribute value from an element string.
|
||||
fn extract_attr(element: &str, name: &str) -> Option<String> {
|
||||
let needle = format!("{name}=\"");
|
||||
let start = element.find(&needle)? + needle.len();
|
||||
let end = element[start..].find('"')? + start;
|
||||
Some(element[start..end].to_string())
|
||||
}
|
||||
|
||||
+102
-23
@@ -5,9 +5,13 @@
|
||||
//!
|
||||
//! Token format: `{lang}_{codec?}_{purpose?}_{region?}_`
|
||||
|
||||
use super::{vocab, LabelPurpose, LabelQualifier, StreamLabel, StreamLabelType};
|
||||
use crate::sector::SectorReader;
|
||||
use super::{
|
||||
Confidence, LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, text,
|
||||
vocab,
|
||||
};
|
||||
use crate::sector::SectorSource;
|
||||
use crate::udf::UdfFs;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Known audio codec tokens
|
||||
const AUDIO_CODECS: &[&str] = &["MLP", "AC3", "DTS", "DDL", "WAV", "AC"];
|
||||
@@ -20,9 +24,18 @@ pub fn detect(udf: &UdfFs) -> bool {
|
||||
super::jar_file_exists(udf, "bluray_project.bin")
|
||||
}
|
||||
|
||||
pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLabel>> {
|
||||
pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option<ParseResult> {
|
||||
let data = super::read_jar_file(reader, udf, "bluray_project.bin")?;
|
||||
let strings = extract_strings(&data);
|
||||
// min_len=4 matches the prior local extract_strings impl. The token
|
||||
// grammar is `{lang3}_{codec?}_{purpose?}_{region?}_` so the
|
||||
// shortest meaningful run is 4 chars (lang + underscore).
|
||||
let strings = text::extract_ascii_strings(&data, 4);
|
||||
|
||||
// Tracked across all parse_token calls in this run: did any stream
|
||||
// hit an unrecognized token component (skip-unknown path)? If yes
|
||||
// we downgrade confidence to Medium — the labels are still valid
|
||||
// but the corpus surfaced something we don't catalogue.
|
||||
let saw_unknown = AtomicBool::new(false);
|
||||
|
||||
let mut labels = Vec::new();
|
||||
let mut in_feature = false;
|
||||
@@ -50,7 +63,7 @@ pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLab
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(label) = parse_token(s) {
|
||||
if let Some(label) = parse_token_inner(s, Some(&saw_unknown)) {
|
||||
match label.stream_type {
|
||||
StreamLabelType::Audio => {
|
||||
audio_num += 1;
|
||||
@@ -73,10 +86,15 @@ pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option<Vec<StreamLab
|
||||
if labels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(labels)
|
||||
let confidence = if saw_unknown.load(Ordering::Relaxed) {
|
||||
Confidence::Medium
|
||||
} else {
|
||||
Confidence::High
|
||||
};
|
||||
Some(ParseResult { labels, confidence })
|
||||
}
|
||||
|
||||
fn parse_token(s: &str) -> Option<StreamLabel> {
|
||||
fn parse_token_inner(s: &str, saw_unknown: Option<&AtomicBool>) -> Option<StreamLabel> {
|
||||
let clean = s.trim().trim_start_matches('\t').trim_end_matches('_');
|
||||
let parts: Vec<&str> = clean.split('_').collect();
|
||||
if parts.len() < 2 {
|
||||
@@ -127,7 +145,18 @@ fn parse_token(s: &str) -> Option<StreamLabel> {
|
||||
} else if part.starts_with("PGStream") {
|
||||
is_subtitle = true;
|
||||
} else {
|
||||
return None;
|
||||
// Unknown token component — skip this single part rather
|
||||
// than discarding the entire stream record. Pre-refactor
|
||||
// behavior was `return None` here, which silently dropped
|
||||
// any stream containing a single uncatalogued token (e.g.
|
||||
// a new codec ID or framework variant). Better to surface
|
||||
// what we know than discard a whole stream over one part,
|
||||
// but flag the parse as Medium-confidence so callers know
|
||||
// some data was elided.
|
||||
tracing::debug!(part = %part, "pixelogic: unrecognized token component, skipping");
|
||||
if let Some(flag) = saw_unknown {
|
||||
flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,22 +182,72 @@ fn parse_token(s: &str) -> Option<StreamLabel> {
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_strings(data: &[u8]) -> Vec<String> {
|
||||
let mut strings = Vec::new();
|
||||
let mut current = String::new();
|
||||
// extract_strings removed — replaced by super::text::extract_ascii_strings(data, 4).
|
||||
|
||||
for &b in data {
|
||||
if (0x20..0x7f).contains(&b) {
|
||||
current.push(b as char);
|
||||
} else {
|
||||
if current.len() > 3 {
|
||||
strings.push(current.clone());
|
||||
}
|
||||
current.clear();
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_token_basic_audio() {
|
||||
let l = parse_token_inner("eng_MLP_", None).unwrap();
|
||||
assert_eq!(l.stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.codec_hint, "TrueHD");
|
||||
assert_eq!(l.purpose, LabelPurpose::Normal);
|
||||
}
|
||||
if current.len() > 3 {
|
||||
strings.push(current);
|
||||
|
||||
#[test]
|
||||
fn parse_token_basic_subtitle_sdh() {
|
||||
let l = parse_token_inner("eng_SDH_", None).unwrap();
|
||||
assert_eq!(l.stream_type, StreamLabelType::Subtitle);
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.qualifier, LabelQualifier::Sdh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_commentary() {
|
||||
let l = parse_token_inner("eng_MLP_ACOM_", None).unwrap();
|
||||
assert_eq!(l.stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(l.purpose, LabelPurpose::Commentary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_descriptive() {
|
||||
let l = parse_token_inner("eng_AC3_ADES_", None).unwrap();
|
||||
assert_eq!(l.purpose, LabelPurpose::Descriptive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_with_region() {
|
||||
let l = parse_token_inner("eng_MLP_US_", None).unwrap();
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.variant, "US");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_unknown_component_does_not_kill_stream() {
|
||||
// Regression: pre-refactor, an unrecognized token part returned
|
||||
// None for the whole stream, silently dropping it. New
|
||||
// behavior: skip the unknown part, surface what we know.
|
||||
let l = parse_token_inner("eng_MLP_FUTUREFLAG_FOR_", None).unwrap();
|
||||
assert_eq!(l.stream_type, StreamLabelType::Audio);
|
||||
assert_eq!(l.language, "eng");
|
||||
assert_eq!(l.codec_hint, "TrueHD");
|
||||
assert_eq!(l.qualifier, LabelQualifier::Forced);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_no_audio_or_subtitle_signal_returns_none() {
|
||||
// A token that has only a language and an unknown part with
|
||||
// no audio/subtitle classifier should still return None —
|
||||
// there's no way to file it as a stream.
|
||||
assert!(parse_token_inner("eng_UNKNOWN_", None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_token_rejects_non_lang_prefix() {
|
||||
assert!(parse_token_inner("XX_MLP_", None).is_none());
|
||||
assert!(parse_token_inner("ENG_MLP_", None).is_none()); // uppercase not accepted as ISO 639-2
|
||||
}
|
||||
strings
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Text-extraction helpers used by parsers that scan binary blobs for
|
||||
//! embedded label strings.
|
||||
//!
|
||||
//! Promoted from two near-duplicate implementations:
|
||||
//! - `pixelogic::extract_strings` (`bluray_project.bin`, min_len=4)
|
||||
//! - `dbp::extract_printable` (`.class` files in jars, min_len=5)
|
||||
//!
|
||||
//! Single implementation, threshold passed in. Callers that have a
|
||||
//! more structured parse path (e.g. `class_reader` for .class) should
|
||||
//! prefer that — this helper is for genuinely unstructured input.
|
||||
|
||||
/// Walk `data`, emit every maximal run of printable-ASCII bytes
|
||||
/// (`0x20..=0x7E`) whose length is at least `min_len`.
|
||||
///
|
||||
/// Non-printable bytes (including `\t`, `\n`, NUL) terminate the
|
||||
/// current run. Output strings are guaranteed valid UTF-8 (they're
|
||||
/// pure 7-bit ASCII). Strings shorter than `min_len` are dropped.
|
||||
pub fn extract_ascii_strings(data: &[u8], min_len: usize) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut current = String::new();
|
||||
for &b in data {
|
||||
if (0x20..=0x7E).contains(&b) {
|
||||
current.push(b as char);
|
||||
} else if current.len() >= min_len {
|
||||
out.push(std::mem::take(&mut current));
|
||||
} else {
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
if current.len() >= min_len {
|
||||
out.push(current);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extracts_simple_runs() {
|
||||
let got = extract_ascii_strings(b"hello\0world\0", 3);
|
||||
assert_eq!(got, vec!["hello", "world"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_minimum_length() {
|
||||
let got = extract_ascii_strings(b"hi\0ok\0longer\0", 5);
|
||||
assert_eq!(got, vec!["longer"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn treats_tab_and_newline_as_separators() {
|
||||
// \t (0x09) and \n (0x0A) are below 0x20, so they break runs.
|
||||
let got = extract_ascii_strings(b"alpha\tbeta\ngamma", 3);
|
||||
assert_eq!(got, vec!["alpha", "beta", "gamma"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_trailing_run_without_terminator() {
|
||||
// Run at the very end of the buffer should still be emitted.
|
||||
let got = extract_ascii_strings(b"prefix\0tail", 3);
|
||||
assert_eq!(got, vec!["prefix", "tail"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_high_bit_bytes() {
|
||||
// 0x80+ is non-printable per this helper's definition.
|
||||
let mut buf = b"good".to_vec();
|
||||
buf.push(0xC3);
|
||||
buf.push(0xA9);
|
||||
buf.extend_from_slice(b"more");
|
||||
let got = extract_ascii_strings(&buf, 3);
|
||||
assert_eq!(got, vec!["good", "more"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_returns_empty() {
|
||||
let got = extract_ascii_strings(&[], 1);
|
||||
assert!(got.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn min_len_zero_emits_singletons() {
|
||||
// Pathological but well-defined.
|
||||
let got = extract_ascii_strings(b"a\0b", 0);
|
||||
assert_eq!(got, vec!["a", "b"]);
|
||||
}
|
||||
}
|
||||
+432
-9
@@ -1,19 +1,40 @@
|
||||
//! Shared label vocabulary — values we are 100% confident about.
|
||||
//! Shared label vocabulary — canonical mappings used by ≥1 label parser.
|
||||
//!
|
||||
//! Labels come from BD-J authoring tool files (bluray_project.bin,
|
||||
//! playlists.xml, menu_base.prop, etc.) — NOT from BD spec fields.
|
||||
//! This is NOT for MPLS/CLPI/STN data. Those follow the BD spec directly.
|
||||
//! playlists.xml, menu_base.prop, .class string pools, etc.) — NOT
|
||||
//! from BD spec fields. This module is the central, regression-tested
|
||||
//! source of truth for:
|
||||
//!
|
||||
//! Rules:
|
||||
//! - Only map values we are 100% certain about (published codec names).
|
||||
//! - Unknown codes (csp, eda, cf, etc.) pass through raw from disc.
|
||||
//! - The app/CLI handles display text, not the lib.
|
||||
//! - Codec brand name aliases (`MLP` → `TrueHD`).
|
||||
//! - English / multi-word language name → ISO 639-2 code.
|
||||
//! - English text → [`LabelPurpose`] (Commentary / Descriptive / etc.).
|
||||
//! - English text → [`LabelQualifier`] (SDH / Forced / Descriptive Service).
|
||||
//!
|
||||
//! Rules of engagement (carried over from
|
||||
//! `(internal)/memory/feedback_label_data_rules.md`):
|
||||
//!
|
||||
//! 1. Only map values we are 100% certain about — published codec
|
||||
//! names, well-known ISO 639-2 mappings, vendor-documented purpose
|
||||
//! keywords.
|
||||
//! 2. Unknown codes / unrecognized phrases pass through raw or return
|
||||
//! `None`. We never guess.
|
||||
//! 3. Matching is case-insensitive and word-boundary-aware where
|
||||
//! relevant (so "Commenter" doesn't match "commentary"). Anchoring
|
||||
//! on whole tokens is the responsibility of this module — callers
|
||||
//! pass raw text, we handle it.
|
||||
//!
|
||||
//! This module is NOT for BD spec STN codec IDs; those decode in
|
||||
//! `mpls.rs` separately.
|
||||
|
||||
use super::{LabelPurpose, LabelQualifier};
|
||||
|
||||
// ── Codec aliases ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Map a codec identifier found in label data to its display name.
|
||||
///
|
||||
/// These are well-known codec identifiers used across multiple BD-J
|
||||
/// authoring tools. NOT BD spec STN codec IDs — those are decoded
|
||||
/// separately in mpls.rs.
|
||||
/// authoring tools. Unknown codes pass through unchanged so callers
|
||||
/// can still surface vendor-specific tokens we haven't catalogued.
|
||||
pub fn codec(code: &str) -> &str {
|
||||
match code {
|
||||
"MLP" => "TrueHD",
|
||||
@@ -25,3 +46,405 @@ pub fn codec(code: &str) -> &str {
|
||||
_ => code,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Language: English / multi-word names → ISO 639-2 ─────────────────────────
|
||||
|
||||
/// Result of [`lang`] — ISO code + human-readable regional variant.
|
||||
///
|
||||
/// `code` is ISO 639-2 (always 3 lowercase letters).
|
||||
/// `variant` is the regional dialect as a human-readable English word
|
||||
/// (`"Brazilian"`, `"Castilian"`, `"Canadian"`, `"Simplified"`, ...)
|
||||
/// or `""` when the input names just a bare language without
|
||||
/// dialect ("Spanish" → variant=""). The variant matches the
|
||||
/// convention pixelogic / ctrm / criterion already use for their
|
||||
/// `StreamLabel::variant` field: a short display token the UI can
|
||||
/// surface verbatim.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LangInfo {
|
||||
pub code: &'static str,
|
||||
pub variant: &'static str,
|
||||
}
|
||||
|
||||
/// Map a free-form language label fragment to an ISO 639-2 code AND
|
||||
/// (where applicable) its regional variant.
|
||||
///
|
||||
/// Handles both bare English names ("English", "Spanish") and the
|
||||
/// multi-word vendor variants we've seen in the corpus ("Brazilian
|
||||
/// Portuguese", "Castilian Spanish", "Canadian French"). Match is
|
||||
/// case-insensitive; longer compound phrases win over their bare
|
||||
/// counterparts (so "Brazilian Portuguese" returns
|
||||
/// `LangInfo { code: "por", variant: "Brazilian" }`, not consumed by
|
||||
/// the bare "Portuguese" entry).
|
||||
///
|
||||
/// Bare-name matches return `variant: ""`.
|
||||
///
|
||||
/// Returns `None` for unrecognized input — callers decide whether to
|
||||
/// fall back to MPLS spec codes, pass through raw, or drop the stream.
|
||||
/// Never guesses.
|
||||
///
|
||||
/// Why the variant: the prior `lang() -> Option<&str>` shape silently
|
||||
/// dropped regional dialect info. "Brazilian Portuguese 5.1" became
|
||||
/// `language="por", variant=""` — UI displayed plain "Portuguese"
|
||||
/// even though the disc had explicitly labeled this stream Brazilian.
|
||||
/// Capturing the variant here parallels how pixelogic and ctrm
|
||||
/// populate `StreamLabel::variant` from their own region tables.
|
||||
pub fn lang(text: &str) -> Option<LangInfo> {
|
||||
let lower = text.to_lowercase();
|
||||
// Multi-word compounds first — longest-match wins.
|
||||
for (needle, code, variant) in COMPOUND_LANGS {
|
||||
if lower.contains(needle) {
|
||||
return Some(LangInfo { code, variant });
|
||||
}
|
||||
}
|
||||
// Bare names: word-boundary match (avoid "english" inside "englishman"
|
||||
// or any other accidental substring).
|
||||
for (needle, code) in BARE_LANGS {
|
||||
if has_word(&lower, needle) {
|
||||
return Some(LangInfo { code, variant: "" });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
const COMPOUND_LANGS: &[(&str, &str, &str)] = &[
|
||||
("brazilian portuguese", "por", "Brazilian"),
|
||||
("euro portuguese", "por", "European"),
|
||||
("european portuguese", "por", "European"),
|
||||
("castilian spanish", "spa", "Castilian"),
|
||||
("latin american spanish", "spa", "Latin American"),
|
||||
("latin spanish", "spa", "Latin American"),
|
||||
("canadian french", "fra", "Canadian"),
|
||||
("parisian french", "fra", "Parisian"),
|
||||
("australian english", "eng", "Australian"),
|
||||
("austrailian english", "eng", "Australian"), // disc-corpus typo, keep matching
|
||||
("british english", "eng", "British"),
|
||||
("simplified chinese", "zho", "Simplified"),
|
||||
("traditional chinese", "zho", "Traditional"),
|
||||
("mandarin chinese", "zho", "Mandarin"),
|
||||
("cantonese chinese", "zho", "Cantonese"),
|
||||
];
|
||||
|
||||
const BARE_LANGS: &[(&str, &str)] = &[
|
||||
("english", "eng"),
|
||||
("spanish", "spa"),
|
||||
("french", "fra"),
|
||||
("german", "deu"),
|
||||
("italian", "ita"),
|
||||
("japanese", "jpn"),
|
||||
("chinese", "zho"),
|
||||
("mandarin", "zho"),
|
||||
("cantonese", "zho"),
|
||||
("portuguese", "por"),
|
||||
("polish", "pol"),
|
||||
("czech", "ces"),
|
||||
("hungarian", "hun"),
|
||||
("dutch", "nld"),
|
||||
("korean", "kor"),
|
||||
("arabic", "ara"),
|
||||
("hindi", "hin"),
|
||||
("turkish", "tur"),
|
||||
("thai", "tha"),
|
||||
("swedish", "swe"),
|
||||
("norwegian", "nor"),
|
||||
("danish", "dan"),
|
||||
("finnish", "fin"),
|
||||
("hebrew", "heb"),
|
||||
("russian", "rus"),
|
||||
("greek", "ell"),
|
||||
("vietnamese", "vie"),
|
||||
("indonesian", "ind"),
|
||||
("malay", "msa"),
|
||||
("ukrainian", "ukr"),
|
||||
("romanian", "ron"),
|
||||
("bulgarian", "bul"),
|
||||
("croatian", "hrv"),
|
||||
("serbian", "srp"),
|
||||
("slovak", "slk"),
|
||||
("slovenian", "slv"),
|
||||
("estonian", "est"),
|
||||
("latvian", "lav"),
|
||||
("lithuanian", "lit"),
|
||||
("icelandic", "isl"),
|
||||
("basque", "eus"),
|
||||
("catalan", "cat"),
|
||||
("galician", "glg"),
|
||||
];
|
||||
|
||||
// ── Purpose ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Classify a free-form English label string into a [`LabelPurpose`].
|
||||
///
|
||||
/// Recognized keywords (case-insensitive, word-boundary matched):
|
||||
/// - "commentary", "director's commentary" → `Commentary`
|
||||
/// - "descriptive", "description", "audio description", "described" → `Descriptive`
|
||||
/// - "score", "music only" → `Score`
|
||||
/// - "ime" (alternate music for closing themes etc.) → `Ime`
|
||||
/// - anything else → `Normal`
|
||||
///
|
||||
/// Word-boundary matching means "Commentary track" matches but
|
||||
/// "Commenter Pro audio" does not.
|
||||
pub fn purpose(text: &str) -> LabelPurpose {
|
||||
let lower = text.to_lowercase();
|
||||
// Multi-word compounds first — they're more specific.
|
||||
if lower.contains("audio description") || lower.contains("descriptive service") {
|
||||
return LabelPurpose::Descriptive;
|
||||
}
|
||||
if lower.contains("music only") {
|
||||
return LabelPurpose::Score;
|
||||
}
|
||||
if has_word(&lower, "commentary") {
|
||||
return LabelPurpose::Commentary;
|
||||
}
|
||||
if has_word(&lower, "descriptive")
|
||||
|| has_word(&lower, "description")
|
||||
|| has_word(&lower, "described")
|
||||
{
|
||||
return LabelPurpose::Descriptive;
|
||||
}
|
||||
if has_word(&lower, "score") {
|
||||
return LabelPurpose::Score;
|
||||
}
|
||||
if has_word(&lower, "ime") {
|
||||
return LabelPurpose::Ime;
|
||||
}
|
||||
LabelPurpose::Normal
|
||||
}
|
||||
|
||||
// ── Qualifier ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Classify a free-form English label string into a [`LabelQualifier`].
|
||||
///
|
||||
/// Recognized keywords (case-insensitive, word-boundary matched):
|
||||
/// - "sdh", "captions" → `Sdh`
|
||||
/// - "forced", "forced narrative" → `Forced`
|
||||
/// - "rnib", "descriptive service" → `DescriptiveService`
|
||||
/// - anything else → `None`
|
||||
///
|
||||
/// SDH (Subtitles for the Deaf and Hard of hearing) wins over Forced
|
||||
/// when both keywords are present, because an SDH track is its own
|
||||
/// stream regardless of whether the player flags it as "forced".
|
||||
pub fn qualifier(text: &str) -> LabelQualifier {
|
||||
let lower = text.to_lowercase();
|
||||
if has_word(&lower, "sdh") || has_word(&lower, "captions") {
|
||||
return LabelQualifier::Sdh;
|
||||
}
|
||||
if lower.contains("descriptive service") || has_word(&lower, "rnib") {
|
||||
return LabelQualifier::DescriptiveService;
|
||||
}
|
||||
if has_word(&lower, "forced") {
|
||||
return LabelQualifier::Forced;
|
||||
}
|
||||
LabelQualifier::None
|
||||
}
|
||||
|
||||
// ── Internal: word-boundary matching ────────────────────────────────────────
|
||||
|
||||
/// True if `needle` appears in `haystack` surrounded by non-alphanumeric
|
||||
/// boundaries (or string ends). `haystack` is assumed lowercase already.
|
||||
///
|
||||
/// This is the load-bearing primitive for `lang` / `purpose` /
|
||||
/// `qualifier`: bare-token matchers MUST use it, otherwise we match
|
||||
/// "english" inside "englishman" and "sdh" inside "lambdash". The
|
||||
/// existing parsers used `.contains()` and got lucky on the corpus;
|
||||
/// vocab guarantees the boundary.
|
||||
fn has_word(haystack: &str, needle: &str) -> bool {
|
||||
if needle.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let bytes = haystack.as_bytes();
|
||||
let nb = needle.as_bytes();
|
||||
let mut i = 0;
|
||||
while i + nb.len() <= bytes.len() {
|
||||
if &bytes[i..i + nb.len()] == nb {
|
||||
let before = if i == 0 { None } else { Some(bytes[i - 1]) };
|
||||
let after = bytes.get(i + nb.len()).copied();
|
||||
let bound = |c: Option<u8>| match c {
|
||||
None => true,
|
||||
Some(b) => !b.is_ascii_alphanumeric(),
|
||||
};
|
||||
if bound(before) && bound(after) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn codec_known_aliases() {
|
||||
assert_eq!(codec("MLP"), "TrueHD");
|
||||
assert_eq!(codec("AC3"), "Dolby Digital");
|
||||
assert_eq!(codec("AC"), "Dolby Digital");
|
||||
assert_eq!(codec("DDL"), "Dolby Digital Plus");
|
||||
assert_eq!(codec("atmos"), "Dolby Atmos");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_unknown_passes_through() {
|
||||
assert_eq!(codec("FX9"), "FX9");
|
||||
assert_eq!(codec(""), "");
|
||||
}
|
||||
|
||||
fn li(code: &'static str, variant: &'static str) -> LangInfo {
|
||||
LangInfo { code, variant }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_bare_names_have_empty_variant() {
|
||||
assert_eq!(lang("English"), Some(li("eng", "")));
|
||||
assert_eq!(lang("english"), Some(li("eng", "")));
|
||||
assert_eq!(lang("Spanish 5.1 Dolby Digital"), Some(li("spa", "")));
|
||||
assert_eq!(lang("japanese"), Some(li("jpn", "")));
|
||||
assert_eq!(lang("Italian"), Some(li("ita", "")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_compounds_carry_variant() {
|
||||
assert_eq!(
|
||||
lang("Brazilian Portuguese 5.1"),
|
||||
Some(li("por", "Brazilian"))
|
||||
);
|
||||
assert_eq!(lang("Castilian Spanish"), Some(li("spa", "Castilian")));
|
||||
assert_eq!(
|
||||
lang("Canadian French Dolby Digital"),
|
||||
Some(li("fra", "Canadian"))
|
||||
);
|
||||
assert_eq!(
|
||||
lang("Latin American Spanish"),
|
||||
Some(li("spa", "Latin American"))
|
||||
);
|
||||
assert_eq!(lang("Simplified Chinese"), Some(li("zho", "Simplified")));
|
||||
assert_eq!(lang("British English"), Some(li("eng", "British")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_compounds_win_over_bare() {
|
||||
// Brazilian Portuguese must map to (por, Brazilian) via the
|
||||
// compound rule, not be intercepted by bare "portuguese"
|
||||
// (which would yield (por, "") and lose the variant).
|
||||
assert_eq!(lang("Brazilian Portuguese").unwrap().variant, "Brazilian");
|
||||
assert_eq!(lang("Canadian French").unwrap().variant, "Canadian");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_unknown_returns_none() {
|
||||
assert_eq!(lang("Klingon Dolby Atmos"), None);
|
||||
assert_eq!(lang(""), None);
|
||||
assert_eq!(lang("eng"), None); // 3-letter codes are not English names
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_word_boundary_avoids_substring_false_positive() {
|
||||
// No false positive — "engineering" must NOT match "english".
|
||||
assert_eq!(lang("Audio Engineering Demo"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purpose_recognizes_commentary() {
|
||||
assert_eq!(purpose("English Commentary"), LabelPurpose::Commentary);
|
||||
assert_eq!(purpose("Director's Commentary"), LabelPurpose::Commentary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purpose_recognizes_descriptive() {
|
||||
assert_eq!(
|
||||
purpose("English Descriptive Audio"),
|
||||
LabelPurpose::Descriptive
|
||||
);
|
||||
assert_eq!(purpose("Audio Description"), LabelPurpose::Descriptive);
|
||||
assert_eq!(purpose("Described Video"), LabelPurpose::Descriptive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purpose_descriptive_service_routes_to_descriptive() {
|
||||
// "Descriptive Service" is qualifier territory but the purpose
|
||||
// implication is Descriptive — vocab::purpose treats it as such.
|
||||
assert_eq!(
|
||||
purpose("English Descriptive Service"),
|
||||
LabelPurpose::Descriptive
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purpose_word_boundary_avoids_commenter_false_positive() {
|
||||
// "Commenter Pro audio" does NOT match "commentary" — the
|
||||
// existing dbp/ctrm hand-rolls would have. Vocab is stricter.
|
||||
assert_eq!(purpose("Commenter Pro audio track"), LabelPurpose::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purpose_recognizes_score() {
|
||||
assert_eq!(purpose("Music Only"), LabelPurpose::Score);
|
||||
assert_eq!(purpose("Isolated Score"), LabelPurpose::Score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purpose_unknown_is_normal() {
|
||||
assert_eq!(purpose("English Dolby Atmos"), LabelPurpose::Normal);
|
||||
assert_eq!(purpose(""), LabelPurpose::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualifier_recognizes_sdh() {
|
||||
assert_eq!(qualifier("English SDH"), LabelQualifier::Sdh);
|
||||
assert_eq!(qualifier("English Captions"), LabelQualifier::Sdh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualifier_recognizes_forced() {
|
||||
assert_eq!(qualifier("English Forced"), LabelQualifier::Forced);
|
||||
assert_eq!(qualifier("Forced Narrative"), LabelQualifier::Forced);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualifier_recognizes_descriptive_service() {
|
||||
assert_eq!(
|
||||
qualifier("English RNIB"),
|
||||
LabelQualifier::DescriptiveService
|
||||
);
|
||||
assert_eq!(
|
||||
qualifier("English Descriptive Service"),
|
||||
LabelQualifier::DescriptiveService
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualifier_sdh_wins_over_forced_when_both_present() {
|
||||
// SDH track is its own stream regardless of forced flag.
|
||||
assert_eq!(qualifier("English Forced SDH"), LabelQualifier::Sdh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualifier_unknown_is_none() {
|
||||
assert_eq!(qualifier("English"), LabelQualifier::None);
|
||||
assert_eq!(qualifier(""), LabelQualifier::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_word_basic() {
|
||||
assert!(has_word("english forced", "english"));
|
||||
assert!(has_word("english forced", "forced"));
|
||||
assert!(has_word("english", "english"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_word_rejects_substring() {
|
||||
assert!(!has_word("engineering", "english"));
|
||||
assert!(!has_word("englishman", "english"));
|
||||
assert!(!has_word("aenglish", "english"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_word_punctuation_boundary() {
|
||||
// "(SDH)" is a valid boundary — parentheses count as non-alphanum.
|
||||
assert!(has_word("english (sdh)", "sdh"));
|
||||
assert!(has_word("commentary,extra,info", "commentary"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
//! Tolerant XML scraping helpers — promoted from two near-duplicate
|
||||
//! hand-rolls in `paramount.rs` (attribute extraction) and `criterion.rs`
|
||||
//! (tag-text extraction).
|
||||
//!
|
||||
//! These are NOT a full XML parser. They handle the subset of XML the
|
||||
//! BD-J authoring tools we've seen actually emit: ASCII tag/attr
|
||||
//! names, no entity references inside label strings, optional XML
|
||||
//! namespaces. Hardening goals over the prior `find("<tag>")` /
|
||||
//! `find(r#"name=""#)` matchers:
|
||||
//!
|
||||
//! 1. **Case-insensitive** tag and attribute names — vendors casing
|
||||
//! is inconsistent across authoring-tool revisions.
|
||||
//! 2. **Namespace-aware** — strip an optional `ns:` prefix so
|
||||
//! `<ns:playlist>` and `<playlist>` both match.
|
||||
//! 3. **Whitespace-tolerant** — multiple/tab/newline characters
|
||||
//! around `=` between attribute name and value; whitespace inside
|
||||
//! the opening tag.
|
||||
//! 4. **Quote-style tolerant** — both `"value"` and `'value'`.
|
||||
//! 5. **Self-closing tag handling** — `<tag />` and `<tag/>` both
|
||||
//! work; [`text`] returns `Some("")` for empty content.
|
||||
//!
|
||||
//! Out of scope (intentionally simple): XML entity decoding
|
||||
//! (`&`, `<`, etc.), CDATA sections, comments, processing
|
||||
//! instructions, DTD declarations. None of the BD-J authored disc
|
||||
//! data we've observed exercises any of those — labels are plain
|
||||
//! ASCII/Latin-1 in attribute values.
|
||||
|
||||
/// Extract the value of attribute `name` from one XML element
|
||||
/// fragment (e.g. `<playlist name="Feature" id="00222" />`).
|
||||
///
|
||||
/// Returns the raw attribute text (no entity decoding) or `None` if
|
||||
/// the attribute isn't present. Empty string for `name=""` is
|
||||
/// represented as `Some("")`.
|
||||
pub fn attr(element: &str, name: &str) -> Option<String> {
|
||||
let bytes = element.as_bytes();
|
||||
let name_lower = name.to_ascii_lowercase();
|
||||
let name_bytes = name_lower.as_bytes();
|
||||
let mut i = 0;
|
||||
while i + name_bytes.len() < bytes.len() {
|
||||
// Find the next position where `name=` could start. We need
|
||||
// a word boundary before the name (whitespace or `<` or `:`).
|
||||
if i > 0 && is_name_char(bytes[i - 1]) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if !slice_eq_ignore_case(&bytes[i..i + name_bytes.len()], name_bytes) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let after_name = i + name_bytes.len();
|
||||
// The character immediately after the name must not be a
|
||||
// name-continuation (otherwise we matched a prefix like
|
||||
// `lang_id` when looking for `lang`).
|
||||
if after_name < bytes.len() && is_name_char(bytes[after_name]) {
|
||||
i = after_name;
|
||||
continue;
|
||||
}
|
||||
// Walk past whitespace, then `=`, then more whitespace, then
|
||||
// the opening quote.
|
||||
let mut j = after_name;
|
||||
while j < bytes.len() && is_ws(bytes[j]) {
|
||||
j += 1;
|
||||
}
|
||||
if j >= bytes.len() || bytes[j] != b'=' {
|
||||
i = j.max(i + 1);
|
||||
continue;
|
||||
}
|
||||
j += 1; // past '='
|
||||
while j < bytes.len() && is_ws(bytes[j]) {
|
||||
j += 1;
|
||||
}
|
||||
if j >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let quote = bytes[j];
|
||||
if quote != b'"' && quote != b'\'' {
|
||||
// Unquoted attribute values aren't part of well-formed
|
||||
// XML (HTML5 allows them, XML doesn't). Skip.
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
let value_start = j + 1;
|
||||
let close = bytes[value_start..].iter().position(|&b| b == quote)?;
|
||||
let value = &element[value_start..value_start + close];
|
||||
return Some(value.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the trimmed text content of the first occurrence of
|
||||
/// `<tag>...</tag>` in `xml`. Returns `None` if the tag isn't found
|
||||
/// or its opening tag is malformed.
|
||||
///
|
||||
/// Whitespace around the inner text is stripped. Self-closing
|
||||
/// `<tag />` yields `Some("")`. Nested same-name tags are NOT
|
||||
/// handled — the first close encountered wins (this matches the
|
||||
/// prior behavior in criterion.rs).
|
||||
pub fn text(xml: &str, tag: &str) -> Option<String> {
|
||||
let (open_end, body_start) = find_open_tag(xml, tag, 0)?;
|
||||
// Self-closing — already consumed in find_open_tag if `/>`.
|
||||
if open_end == body_start {
|
||||
// Means find_open_tag returned the same offset twice for
|
||||
// self-closing form. (Not currently the case in our impl,
|
||||
// but defensive.)
|
||||
return Some(String::new());
|
||||
}
|
||||
// For self-closing tags, body_start is past `/>` and we have no
|
||||
// content. Detect by checking the char at body_start - 1 was `/`.
|
||||
if body_start >= 2 && &xml[body_start - 2..body_start] == "/>" {
|
||||
return Some(String::new());
|
||||
}
|
||||
// Find the matching close tag. Case-insensitive + namespace-aware.
|
||||
let close_start = find_close_tag(xml, tag, body_start)?;
|
||||
Some(xml[body_start..close_start].trim().to_string())
|
||||
}
|
||||
|
||||
/// Locate the next `<tag>` opening AND its closing `</tag>` in
|
||||
/// `xml`, starting at byte offset `from`. Returns `(element_start,
|
||||
/// element_end)` — `element_start` is the `<` of the opening tag,
|
||||
/// `element_end` is one past the `>` of the closing tag. Useful for
|
||||
/// iterating over repeated elements like `<playlist>` blocks in
|
||||
/// `paramount`.
|
||||
///
|
||||
/// Self-closing elements return the same offset for body_end as the
|
||||
/// element_end (i.e. `element_end - element_start` includes only the
|
||||
/// `<tag .../>` text).
|
||||
pub fn find_element(xml: &str, tag: &str, from: usize) -> Option<(usize, usize)> {
|
||||
let bytes = xml.as_bytes();
|
||||
let mut i = from;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] != b'<' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Try matching tag name at i+1 (after `<`).
|
||||
let after_lt = i + 1;
|
||||
if !matches_tag_name_at(bytes, after_lt, tag) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
// Found an open tag at offset i. Walk to find the closing `>`
|
||||
// of the open tag itself.
|
||||
let mut j = after_lt;
|
||||
// Skip past the tag name (and optional namespace prefix).
|
||||
while j < bytes.len() && (is_name_char(bytes[j]) || bytes[j] == b':') {
|
||||
j += 1;
|
||||
}
|
||||
// Walk attributes — track quoting state.
|
||||
let mut self_closing = false;
|
||||
while j < bytes.len() {
|
||||
match bytes[j] {
|
||||
b'>' => {
|
||||
j += 1;
|
||||
break;
|
||||
}
|
||||
b'/' if j + 1 < bytes.len() && bytes[j + 1] == b'>' => {
|
||||
self_closing = true;
|
||||
j += 2;
|
||||
break;
|
||||
}
|
||||
b'"' | b'\'' => {
|
||||
let q = bytes[j];
|
||||
j += 1;
|
||||
while j < bytes.len() && bytes[j] != q {
|
||||
j += 1;
|
||||
}
|
||||
if j < bytes.len() {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
_ => j += 1,
|
||||
}
|
||||
}
|
||||
if self_closing {
|
||||
return Some((i, j));
|
||||
}
|
||||
// Find matching close. Doesn't handle nested same-name; OK
|
||||
// for our authoring-tool subset.
|
||||
let close_start = find_close_tag(xml, tag, j)?;
|
||||
let close_end = find_byte(bytes, b'>', close_start)? + 1;
|
||||
return Some((i, close_end));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/// True if `bytes[start..]` opens a tag named `tag`, allowing an
|
||||
/// optional `ns:` namespace prefix. Comparison is case-insensitive.
|
||||
/// The character after the tag name must not be a name-continuation
|
||||
/// (so `<player>` doesn't match `<play>`).
|
||||
fn matches_tag_name_at(bytes: &[u8], start: usize, tag: &str) -> bool {
|
||||
let tag_lower = tag.to_ascii_lowercase();
|
||||
let tag_bytes = tag_lower.as_bytes();
|
||||
// Skip optional `prefix:` (one or more name chars + `:`).
|
||||
let mut name_start = start;
|
||||
let mut scan = start;
|
||||
while scan < bytes.len() && is_name_char(bytes[scan]) {
|
||||
scan += 1;
|
||||
}
|
||||
if scan < bytes.len() && bytes[scan] == b':' {
|
||||
name_start = scan + 1;
|
||||
}
|
||||
if name_start + tag_bytes.len() > bytes.len() {
|
||||
return false;
|
||||
}
|
||||
if !slice_eq_ignore_case(&bytes[name_start..name_start + tag_bytes.len()], tag_bytes) {
|
||||
return false;
|
||||
}
|
||||
// Boundary: char after the tag name must be `>`, `/`, whitespace.
|
||||
let after = name_start + tag_bytes.len();
|
||||
if after >= bytes.len() {
|
||||
return false;
|
||||
}
|
||||
matches!(bytes[after], b'>' | b'/' | b' ' | b'\t' | b'\n' | b'\r')
|
||||
}
|
||||
|
||||
/// Find the offset of the next `</tag>` (or `</ns:tag>`) in `xml`
|
||||
/// starting at `from`. Case-insensitive; returns the offset of the
|
||||
/// `<`. None if not found.
|
||||
fn find_close_tag(xml: &str, tag: &str, from: usize) -> Option<usize> {
|
||||
let bytes = xml.as_bytes();
|
||||
let mut i = from;
|
||||
while i + 2 < bytes.len() {
|
||||
if bytes[i] == b'<' && bytes[i + 1] == b'/' {
|
||||
// Check tag name (with optional namespace).
|
||||
if matches_tag_name_at(bytes, i + 2, tag) {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find the open tag of `<tag>` in `xml` starting at `from`. Returns
|
||||
/// `(after_open_lt, after_open_gt)` — the offsets are: just past
|
||||
/// the `<` of the open tag, and just past the `>` of the open tag.
|
||||
fn find_open_tag(xml: &str, tag: &str, from: usize) -> Option<(usize, usize)> {
|
||||
let bytes = xml.as_bytes();
|
||||
let (elem_start, _) = find_element(xml, tag, from)?;
|
||||
let after_lt = elem_start + 1;
|
||||
// Find the `>` that ends the open tag (handling quoted attrs).
|
||||
let mut j = after_lt;
|
||||
while j < bytes.len() {
|
||||
match bytes[j] {
|
||||
b'>' => return Some((after_lt, j + 1)),
|
||||
b'/' if j + 1 < bytes.len() && bytes[j + 1] == b'>' => {
|
||||
return Some((after_lt, j + 2));
|
||||
}
|
||||
b'"' | b'\'' => {
|
||||
let q = bytes[j];
|
||||
j += 1;
|
||||
while j < bytes.len() && bytes[j] != q {
|
||||
j += 1;
|
||||
}
|
||||
if j < bytes.len() {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
_ => j += 1,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_byte(bytes: &[u8], target: u8, from: usize) -> Option<usize> {
|
||||
bytes[from..]
|
||||
.iter()
|
||||
.position(|&b| b == target)
|
||||
.map(|p| p + from)
|
||||
}
|
||||
|
||||
/// True if `c` can be part of an XML name token (rough). We accept
|
||||
/// alphanumerics, `_`, `-`, `.`.
|
||||
fn is_name_char(c: u8) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || c == b'.'
|
||||
}
|
||||
|
||||
fn is_ws(c: u8) -> bool {
|
||||
matches!(c, b' ' | b'\t' | b'\n' | b'\r')
|
||||
}
|
||||
|
||||
fn slice_eq_ignore_case(a: &[u8], b_lower: &[u8]) -> bool {
|
||||
if a.len() != b_lower.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter()
|
||||
.zip(b_lower.iter())
|
||||
.all(|(&x, &y)| x.to_ascii_lowercase() == y)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn attr_basic() {
|
||||
assert_eq!(
|
||||
attr(r#"<playlist name="Feature" id="00222" />"#, "name"),
|
||||
Some("Feature".into())
|
||||
);
|
||||
assert_eq!(
|
||||
attr(r#"<playlist name="Feature" id="00222" />"#, "id"),
|
||||
Some("00222".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attr_case_insensitive_name() {
|
||||
assert_eq!(
|
||||
attr(r#"<playlist Name="Feature" />"#, "name"),
|
||||
Some("Feature".into())
|
||||
);
|
||||
assert_eq!(
|
||||
attr(r#"<playlist NAME="Feature" />"#, "Name"),
|
||||
Some("Feature".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attr_accepts_single_quotes() {
|
||||
assert_eq!(
|
||||
attr(r#"<playlist name='Feature' />"#, "name"),
|
||||
Some("Feature".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attr_whitespace_around_equals() {
|
||||
assert_eq!(
|
||||
attr(r#"<playlist name = "Feature" />"#, "name"),
|
||||
Some("Feature".into())
|
||||
);
|
||||
assert_eq!(
|
||||
attr("<playlist name\n=\n\"Feature\" />", "name"),
|
||||
Some("Feature".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attr_missing_returns_none() {
|
||||
assert_eq!(attr(r#"<playlist name="X" />"#, "id"), None);
|
||||
assert_eq!(attr("", "name"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attr_no_substring_false_positive() {
|
||||
// Looking for "lang" should NOT match "lang_id" or
|
||||
// "language" because of the name-char boundary check.
|
||||
assert_eq!(attr(r#"<x lang_id="fra" language="eng" />"#, "lang"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attr_empty_value() {
|
||||
assert_eq!(attr(r#"<x name="" id="1" />"#, "name"), Some("".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_basic() {
|
||||
assert_eq!(text("<x>hello</x>", "x"), Some("hello".into()));
|
||||
assert_eq!(
|
||||
text("<x> hello world </x>", "x"),
|
||||
Some("hello world".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_case_insensitive_tag() {
|
||||
assert_eq!(text("<X>foo</X>", "x"), Some("foo".into()));
|
||||
assert_eq!(text("<Foo>bar</foo>", "foo"), Some("bar".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_namespace_prefix() {
|
||||
assert_eq!(text("<ns:tag>value</ns:tag>", "tag"), Some("value".into()));
|
||||
assert_eq!(text("<foo:Bar>v</foo:Bar>", "bar"), Some("v".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_self_closing() {
|
||||
assert_eq!(text("<x/>", "x"), Some("".into()));
|
||||
assert_eq!(text("<x />", "x"), Some("".into()));
|
||||
assert_eq!(text("<x attr=\"y\" />", "x"), Some("".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_with_attrs() {
|
||||
assert_eq!(
|
||||
text(r#"<x id="1" name="y">hello</x>"#, "x"),
|
||||
Some("hello".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_missing_close_returns_none() {
|
||||
assert_eq!(text("<x>hello", "x"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_skips_inner_tags_naively() {
|
||||
// Limitation noted: nested same-name tags aren't handled.
|
||||
// Different-name nesting works (we just return everything
|
||||
// between the open and close).
|
||||
assert_eq!(
|
||||
text("<x><y>nested</y></x>", "x"),
|
||||
Some("<y>nested</y>".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_element_basic() {
|
||||
let xml = r#"<x /> <y attr="1">body</y>"#;
|
||||
let (s, e) = find_element(xml, "y", 0).unwrap();
|
||||
assert_eq!(&xml[s..e], r#"<y attr="1">body</y>"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_element_self_closing() {
|
||||
let xml = r#"<x />"#;
|
||||
let (s, e) = find_element(xml, "x", 0).unwrap();
|
||||
assert_eq!(&xml[s..e], "<x />");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_element_handles_quoted_gt_in_attr() {
|
||||
// A `>` inside a quoted attribute value should not terminate
|
||||
// the open tag prematurely.
|
||||
let xml = r#"<x attr="foo>bar">body</x>"#;
|
||||
let (s, e) = find_element(xml, "x", 0).unwrap();
|
||||
assert_eq!(&xml[s..e], r#"<x attr="foo>bar">body</x>"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_element_iteration() {
|
||||
let xml = "<p>a</p><p>b</p><p>c</p>";
|
||||
let mut positions = Vec::new();
|
||||
let mut from = 0;
|
||||
while let Some((s, e)) = find_element(xml, "p", from) {
|
||||
positions.push(&xml[s..e]);
|
||||
from = e;
|
||||
}
|
||||
assert_eq!(positions, vec!["<p>a</p>", "<p>b</p>", "<p>c</p>"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_element_with_namespace() {
|
||||
let xml = r#"<root><ns:item id="1" /></root>"#;
|
||||
let (s, e) = find_element(xml, "item", 0).unwrap();
|
||||
assert_eq!(&xml[s..e], r#"<ns:item id="1" />"#);
|
||||
}
|
||||
}
|
||||
+110
-13
@@ -79,44 +79,141 @@ pub mod disc;
|
||||
pub mod drive;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod halt;
|
||||
pub(crate) mod identity;
|
||||
pub(crate) mod ifo;
|
||||
pub mod io;
|
||||
pub mod keydb;
|
||||
pub(crate) mod labels;
|
||||
pub mod labels;
|
||||
pub(crate) mod mpls;
|
||||
pub mod mux;
|
||||
pub mod pes;
|
||||
pub(crate) mod platform;
|
||||
pub mod profile;
|
||||
pub mod progress;
|
||||
pub mod scsi;
|
||||
pub mod sector;
|
||||
pub mod verify;
|
||||
pub(crate) mod speed;
|
||||
pub(crate) mod udf;
|
||||
pub mod verify;
|
||||
|
||||
// Re-export verify types at the crate root for ergonomic imports.
|
||||
pub use verify::{SectorRange, SectorStatus, VerifyResult, verify_title};
|
||||
|
||||
// ─── Drive lifecycle ────────────────────────────────────────────────────────
|
||||
//
|
||||
// `Drive::open(path)` → `wait_ready()` → `init()` → `Disc::scan()`. `Drive`
|
||||
// owns the SCSI session; `DriveCapture` etc. let advanced callers introspect
|
||||
// drive identity / profile data for sharing.
|
||||
pub use drive::capture::{
|
||||
capture_drive_data, mask_bytes, mask_string, CapturedFeature, DriveCapture,
|
||||
CapturedFeature, DriveCapture, capture_drive_data, mask_bytes, mask_string,
|
||||
};
|
||||
pub use drive::{find_drive, find_drives, Drive, DriveStatus};
|
||||
pub use drive::{Drive, DriveStatus, find_drive};
|
||||
|
||||
// ─── 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};
|
||||
|
||||
// ─── 0.18 primitives ────────────────────────────────────────────────────────
|
||||
//
|
||||
// One-bit cooperative cancellation token, shared by every long-running loop
|
||||
// in libfreemkv (sweep, patch, mux). Replaces the ad-hoc `Arc<AtomicBool>`
|
||||
// flags scattered across 0.17 (`DiscStream::set_halt`, autorip's
|
||||
// `HALT_FLAGS` registry). Clone it cheaply; pass it by value into each
|
||||
// component; poll `is_cancelled()` inside the loop body.
|
||||
pub use halt::Halt;
|
||||
|
||||
// Generic bounded producer/consumer primitive used by sweep, patch, and
|
||||
// mux to overlap reads with writes via a dedicated consumer thread.
|
||||
// `Pipeline::spawn(name, depth, sink)` spawns a named consumer; `pipe.send(item)`
|
||||
// pushes one item with back-pressure; `pipe.finish()` joins the
|
||||
// consumer and surfaces its `close()` output. Callers implement `Sink`
|
||||
// to define per-item behaviour and end-of-stream finalisation.
|
||||
//
|
||||
// `DEFAULT_PIPELINE_DEPTH` (=4) is for callers without specific needs;
|
||||
// most should use READ_PIPELINE_DEPTH or WRITE_PIPELINE_DEPTH instead.
|
||||
// Patch uses `WRITE_THROUGH_DEPTH` (=1). Returning `Flow::Stop` from
|
||||
// `apply` ends the consumer cleanly (still calls `close()`).
|
||||
pub use io::pipeline::{
|
||||
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
|
||||
WRITE_THROUGH_DEPTH,
|
||||
};
|
||||
|
||||
// ─── Drive events (low-level callbacks) ─────────────────────────────────────
|
||||
pub use event::{Event, EventKind};
|
||||
pub use identity::DriveId;
|
||||
pub use profile::DriveProfile;
|
||||
// Platform trait is pub(crate) -- callers use Drive, not Platform directly
|
||||
pub use decrypt::{decrypt_sectors, DecryptKeys};
|
||||
// Platform trait is pub(crate) — callers use Drive, not Platform directly.
|
||||
|
||||
// ─── Decryption (AACS / CSS) ────────────────────────────────────────────────
|
||||
//
|
||||
// `Disc::scan()` resolves keys and stores them on `Disc`; in most flows you
|
||||
// don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)`
|
||||
// accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is
|
||||
// for callers that operate on raw sector buffers (e.g. ISO patching).
|
||||
pub use decrypt::{DecryptKeys, decrypt_sectors};
|
||||
|
||||
// ─── Disc structure ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// `Disc::scan()` produces a fully-populated `Disc` (titles, streams, AACS
|
||||
// state). `Disc::identify()` is the fast path — UDF only, no playlist parse,
|
||||
// for displaying disc name + format quickly while a full scan runs in the
|
||||
// background. The codec / channel / resolution enums are the canonical
|
||||
// structured representation; never compare against display strings.
|
||||
// Note: `disc::Stream` here is the codec enum (audio / video / sub kind)
|
||||
// — not the `pes::Stream` trait re-exported below as `PesStream`. Two
|
||||
// different concepts, the same short name; the trait gets the `Pes`
|
||||
// prefix at the crate root to keep both addressable.
|
||||
pub use disc::{
|
||||
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, Disc,
|
||||
DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, Resolution, SampleRate,
|
||||
ScanOptions, Stream, SubtitleStream, VideoStream,
|
||||
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DamageSeverity,
|
||||
Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, LabelPurpose,
|
||||
LabelQualifier, PatchOptions, PatchOutcome, Resolution, SampleRate, ScanOptions, Stream,
|
||||
SubtitleStream, SweepOptions, VideoStream, classify_damage,
|
||||
};
|
||||
|
||||
// ─── 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 `SectorSource`). Read-only.
|
||||
// - `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).
|
||||
// The trait is re-exported as `PesStream` here to disambiguate from
|
||||
// `disc::Stream` (the codec-kind enum re-exported above), which would
|
||||
// otherwise collide at the crate root.
|
||||
pub use pes::PesFrame;
|
||||
pub use pes::Stream as PesStream;
|
||||
|
||||
pub use mux::DiscStream;
|
||||
pub use mux::M2tsStream;
|
||||
pub use mux::MkvStream;
|
||||
pub use mux::NetworkStream;
|
||||
pub use mux::NullStream;
|
||||
pub use mux::StdioStream;
|
||||
pub use mux::{input, output, parse_url, InputOptions, StreamUrl};
|
||||
pub use scsi::ScsiTransport;
|
||||
pub use sector::SectorReader;
|
||||
pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
|
||||
|
||||
// ─── Lower-level surfaces ───────────────────────────────────────────────────
|
||||
//
|
||||
// `ScsiTransport` is the platform-abstraction trait Drive uses; expose for
|
||||
// out-of-tree platform backends. `SectorSource` / `SectorSink` are the
|
||||
// direction-typed read/write traits; `FileSectorSource` and `FileSectorSink`
|
||||
// are the ISO-on-disk implementations. [`DecryptingSectorSource`] is the
|
||||
// single decrypt-on-read decorator (AACS / CSS / none) — wrap any
|
||||
// `SectorSource` to get plaintext sectors out.
|
||||
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives};
|
||||
pub use sector::{
|
||||
DecryptingSectorSource, FileSectorSink, FileSectorSource, SectorSink, SectorSource,
|
||||
};
|
||||
pub use speed::DriveSpeed;
|
||||
pub use udf::{read_filesystem, UdfFs};
|
||||
pub use udf::{UdfFs, read_filesystem};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Buffers across PES boundaries so frames that span two PES packets
|
||||
//! are emitted complete, not truncated.
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
pub struct Ac3Parser {
|
||||
/// Leftover bytes from previous PES (incomplete frame at end).
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! Buffers across PES boundaries so frames spanning two PES packets
|
||||
//! are emitted complete.
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
const DTS_CORE_SYNC: [u8; 4] = [0x7F, 0xFE, 0x80, 0x01];
|
||||
const DTS_HD_EXT_SYNC: [u8; 4] = [0x64, 0x58, 0x20, 0x25];
|
||||
|
||||
+8
-19
@@ -7,26 +7,15 @@
|
||||
//! For MKV: codec ID "S_VOBSUB".
|
||||
//! All frames are keyframes (each is a complete bitmap).
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
pub struct DvdSubParser {
|
||||
/// Pre-formatted VobSub .idx palette header for codec_private.
|
||||
codec_data: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Default for DvdSubParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DvdSubParser {
|
||||
pub fn new() -> Self {
|
||||
Self { codec_data: None }
|
||||
}
|
||||
|
||||
/// Create a parser with pre-formatted codec private data (palette header).
|
||||
pub fn with_codec_data(codec_data: Option<Vec<u8>>) -> Self {
|
||||
pub fn new(codec_data: Option<Vec<u8>>) -> Self {
|
||||
Self { codec_data }
|
||||
}
|
||||
}
|
||||
@@ -109,7 +98,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn passthrough_data() {
|
||||
let mut parser = DvdSubParser::new();
|
||||
let mut parser = DvdSubParser::new(None);
|
||||
let sub_data = vec![0x00, 0x0A, 0x00, 0x08, 0x01, 0xFF, 0x02, 0x03, 0x04, 0x05];
|
||||
let pes = make_pes(sub_data.clone(), Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
@@ -124,7 +113,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn always_keyframe() {
|
||||
let mut parser = DvdSubParser::new();
|
||||
let mut parser = DvdSubParser::new(None);
|
||||
for i in 0..3u8 {
|
||||
let data = vec![0x00, i, 0x00, i + 1];
|
||||
let pes = make_pes(data, Some(90000 * i as i64));
|
||||
@@ -139,21 +128,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn empty_pes_returns_no_frames() {
|
||||
let mut parser = DvdSubParser::new();
|
||||
let mut parser = DvdSubParser::new(None);
|
||||
let pes = make_pes(Vec::new(), Some(0));
|
||||
assert!(parser.parse(&pes).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none_by_default() {
|
||||
let parser = DvdSubParser::new();
|
||||
let parser = DvdSubParser::new(None);
|
||||
assert!(parser.codec_private().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_returns_palette_when_set() {
|
||||
let palette_data = b"palette: 000000, ffffff\n".to_vec();
|
||||
let parser = DvdSubParser::with_codec_data(Some(palette_data.clone()));
|
||||
let parser = DvdSubParser::new(Some(palette_data.clone()));
|
||||
let cp = parser.codec_private();
|
||||
assert!(cp.is_some());
|
||||
assert_eq!(cp.unwrap(), palette_data);
|
||||
@@ -161,7 +150,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn no_pts_defaults_to_zero() {
|
||||
let mut parser = DvdSubParser::new();
|
||||
let mut parser = DvdSubParser::new(None);
|
||||
let pes = make_pes(vec![0x01, 0x02], None);
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Detects keyframes (IDR slices).
|
||||
//! Each PES packet = one access unit = one frame.
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
/// H.264 NAL unit types we care about.
|
||||
const NAL_SLICE_IDR: u8 = 5;
|
||||
@@ -263,7 +263,7 @@ mod tests {
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x67); // SPS
|
||||
data.extend_from_slice(&[0x42, 0x00, 0x1E, 0xAB, 0xCD]); // profile=0x42, compat=0x00, level=0x1E
|
||||
// PPS: 00 00 01 [68 <payload>]
|
||||
// PPS: 00 00 01 [68 <payload>]
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.push(0x68); // PPS
|
||||
data.extend_from_slice(&[0xCE, 0x01]);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! Each PES packet = one access unit = one frame.
|
||||
|
||||
use super::h264::{find_start_code, skip_start_code};
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
// HEVC NAL unit types
|
||||
const NAL_VPS: u8 = 32;
|
||||
@@ -122,7 +122,7 @@ impl CodecParser for HevcParser {
|
||||
|
||||
// Minimal HEVCDecoderConfigurationRecord header
|
||||
record.push(1); // configurationVersion
|
||||
// General profile space, tier flag, profile IDC from SPS
|
||||
// General profile space, tier flag, profile IDC from SPS
|
||||
if sps.len() > 3 {
|
||||
record.push(sps[1]); // general_profile_space + general_tier_flag + general_profile_idc
|
||||
} else {
|
||||
@@ -154,7 +154,7 @@ impl CodecParser for HevcParser {
|
||||
record.push(0xFC);
|
||||
// chromaFormat (6 + 2 bits)
|
||||
record.push(0xFC | 1); // 4:2:0
|
||||
// bitDepthLumaMinus8 (5 + 3 bits)
|
||||
// bitDepthLumaMinus8 (5 + 3 bits)
|
||||
record.push(0xF8);
|
||||
// bitDepthChromaMinus8 (5 + 3 bits)
|
||||
record.push(0xF8);
|
||||
@@ -162,7 +162,7 @@ impl CodecParser for HevcParser {
|
||||
record.extend_from_slice(&[0, 0]);
|
||||
// constantFrameRate + numTemporalLayers + temporalIdNested + lengthSizeMinusOne
|
||||
record.push(0x03); // lengthSizeMinusOne = 3 (4 bytes)
|
||||
// numOfArrays
|
||||
// numOfArrays
|
||||
record.push(3); // VPS, SPS, PPS
|
||||
|
||||
// VPS array
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//! For MKV: codec ID "A_PCM/INT/BIG" (BD) or "A_PCM/INT/LIT" (DVD).
|
||||
//! All frames are keyframes (uncompressed audio).
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
/// BD LPCM header size in bytes.
|
||||
const BD_LPCM_HEADER_SIZE: usize = 4;
|
||||
|
||||
+2
-10
@@ -79,18 +79,10 @@ impl CodecParser for PassthroughParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the appropriate parser for a codec.
|
||||
pub fn parser_for_codec(codec: Codec) -> Box<dyn CodecParser> {
|
||||
parser_for_codec_with_data(codec, None)
|
||||
}
|
||||
|
||||
/// Create the appropriate parser for a codec, with optional codec private data.
|
||||
///
|
||||
/// For DvdSub, `codec_data` should be the pre-formatted VobSub .idx palette header.
|
||||
pub fn parser_for_codec_with_data(
|
||||
codec: Codec,
|
||||
codec_data: Option<Vec<u8>>,
|
||||
) -> Box<dyn CodecParser> {
|
||||
pub fn parser_for_codec(codec: Codec, codec_data: Option<Vec<u8>>) -> Box<dyn CodecParser> {
|
||||
match codec {
|
||||
Codec::H264 => Box::new(h264::H264Parser::new()),
|
||||
Codec::Hevc => Box::new(hevc::HevcParser::new()),
|
||||
@@ -101,7 +93,7 @@ pub fn parser_for_codec_with_data(
|
||||
Codec::TrueHd => Box::new(truehd::TrueHdParser::new()),
|
||||
Codec::Pgs => Box::new(pgs::PgsParser::new()),
|
||||
Codec::Lpcm => Box::new(lpcm::LpcmParser::new()),
|
||||
Codec::DvdSub => Box::new(dvdsub::DvdSubParser::with_codec_data(codec_data)),
|
||||
Codec::DvdSub => Box::new(dvdsub::DvdSubParser::new(codec_data)),
|
||||
_ => Box::new(PassthroughParser::new(true)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! - Sequence extension: 00 00 01 B5
|
||||
//! - Picture header: 00 00 01 00
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame};
|
||||
use super::{CodecParser, Frame, pts_to_ns};
|
||||
use crate::mux::ts::PesPacket;
|
||||
|
||||
/// Sequence header start code suffix.
|
||||
@@ -153,7 +153,7 @@ impl CodecParser for Mpeg2Parser {
|
||||
bits += 64 * 8;
|
||||
}
|
||||
}
|
||||
let total_bytes = 4 + ((bits + 7) / 8) as usize;
|
||||
let total_bytes = 4 + bits.div_ceil(8) as usize;
|
||||
(sc + total_bytes).min(data.len())
|
||||
}
|
||||
}
|
||||
@@ -415,7 +415,7 @@ mod tests {
|
||||
// Sequence extension: 00 00 01 B5 [ext data]
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SEQ_EXT_CODE]);
|
||||
data.extend_from_slice(&[0x14, 0x8A, 0x00, 0x01, 0x00, 0x00]); // ext payload
|
||||
// Picture header follows.
|
||||
// Picture header follows.
|
||||
data.extend_from_slice(&make_picture_header(PICTURE_TYPE_I));
|
||||
data.extend_from_slice(&[0xFF; 4]);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Each PES packet contains one or more segments.
|
||||
//! All segments are keyframes (no inter-segment dependencies).
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
pub struct PgsParser;
|
||||
|
||||
|
||||
+10
-13
@@ -11,7 +11,7 @@
|
||||
//! AC-3 frames (interleaved, same PID): start with sync word 0x0B77.
|
||||
//! We skip AC-3 frames and only emit TrueHD access units.
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
/// Duration of one TrueHD access unit in nanoseconds (1/1200 second).
|
||||
const AU_DURATION_NS: i64 = 833_333;
|
||||
@@ -50,30 +50,27 @@ impl TrueHdParser {
|
||||
0 => {
|
||||
// 48 kHz
|
||||
static SIZES: [usize; 38] = [
|
||||
64, 64, 80, 80, 96, 96, 112, 112, 128, 128,
|
||||
160, 160, 192, 192, 224, 224, 256, 256, 320, 320,
|
||||
384, 384, 448, 448, 512, 512, 640, 640, 768, 768,
|
||||
896, 896, 1024, 1024, 1152, 1152, 1280, 1280,
|
||||
64, 64, 80, 80, 96, 96, 112, 112, 128, 128, 160, 160, 192, 192, 224, 224, 256,
|
||||
256, 320, 320, 384, 384, 448, 448, 512, 512, 640, 640, 768, 768, 896, 896,
|
||||
1024, 1024, 1152, 1152, 1280, 1280,
|
||||
];
|
||||
SIZES.get(frmsizecod).copied().unwrap_or(0)
|
||||
}
|
||||
1 => {
|
||||
// 44.1 kHz
|
||||
static SIZES: [usize; 38] = [
|
||||
69, 70, 87, 88, 104, 105, 121, 122, 139, 140,
|
||||
174, 175, 208, 209, 243, 244, 278, 279, 348, 349,
|
||||
417, 418, 487, 488, 557, 558, 696, 697, 835, 836,
|
||||
975, 976, 1114, 1115, 1253, 1254, 1393, 1394,
|
||||
69, 70, 87, 88, 104, 105, 121, 122, 139, 140, 174, 175, 208, 209, 243, 244,
|
||||
278, 279, 348, 349, 417, 418, 487, 488, 557, 558, 696, 697, 835, 836, 975, 976,
|
||||
1114, 1115, 1253, 1254, 1393, 1394,
|
||||
];
|
||||
SIZES.get(frmsizecod).copied().unwrap_or(0)
|
||||
}
|
||||
2 => {
|
||||
// 32 kHz
|
||||
static SIZES: [usize; 38] = [
|
||||
96, 96, 120, 120, 144, 144, 168, 168, 192, 192,
|
||||
240, 240, 288, 288, 336, 336, 384, 384, 480, 480,
|
||||
576, 576, 672, 672, 768, 768, 960, 960, 1152, 1152,
|
||||
1344, 1344, 1536, 1536, 1728, 1728, 1920, 1920,
|
||||
96, 96, 120, 120, 144, 144, 168, 168, 192, 192, 240, 240, 288, 288, 336, 336,
|
||||
384, 384, 480, 480, 576, 576, 672, 672, 768, 768, 960, 960, 1152, 1152, 1344,
|
||||
1344, 1536, 1536, 1728, 1728, 1920, 1920,
|
||||
];
|
||||
SIZES.get(frmsizecod).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! Frame start = Frame header start code (0x0D).
|
||||
//! I-frames (keyframes) are identified from the frame header.
|
||||
|
||||
use super::{pts_to_ns, CodecParser, Frame, PesPacket};
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
const SC_SEQUENCE_HEADER: u8 = 0x0F;
|
||||
const SC_ENTRY_POINT: u8 = 0x0E;
|
||||
|
||||
+417
-27
@@ -1,22 +1,118 @@
|
||||
//! DiscStream — read any disc (physical drive or ISO file) → PES frames.
|
||||
//!
|
||||
//! One stream type for all disc sources. The source is a SectorReader —
|
||||
//! One stream type for all disc sources. The source is a SectorSource —
|
||||
//! Drive (hardware) or IsoSectorReader (file). DiscStream doesn't care.
|
||||
//!
|
||||
//! Read-only. For disc→ISO (raw sector copy), use `Disc::copy()`.
|
||||
|
||||
use crate::disc::{detect_max_batch_sectors, Disc, DiscTitle, Extent, ScanOptions};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::disc::{Disc, DiscTitle, Extent};
|
||||
use crate::drive::extract_scsi_context;
|
||||
use crate::event::{BatchSizeReason, Event, EventKind};
|
||||
use crate::halt::Halt;
|
||||
use crate::sector::{DecryptingSectorSource, SectorSource};
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
/// Ramp back up to the preferred batch size after this many sectors
|
||||
/// of clean reading at the current (reduced) size. 100 MiB = 51,200 sectors.
|
||||
///
|
||||
/// Chosen so that an isolated transient failure doesn't lock the rip at
|
||||
/// size 1: once past the bad zone, we probe up after ~100 ms of good reads.
|
||||
/// And so that noisy zones with occasional successes can't trigger a
|
||||
/// premature probe — we need a sustained clean run.
|
||||
const PROBE_THRESHOLD_SECTORS: u32 = 100 * 1024 * 1024 / 2048;
|
||||
|
||||
/// Halve a batch size, keeping 3-sector alignment when >= 6
|
||||
/// (3-sector alignment = one AACS unit). At sizes < 6 we descend
|
||||
/// through 3 → 1 without intermediate unaligned sizes.
|
||||
fn halve_batch_size(size: u16) -> u16 {
|
||||
let h = (size / 2).max(1);
|
||||
if h >= 6 { h - (h % 3) } else { h }
|
||||
}
|
||||
|
||||
/// Double a batch size toward a preferred max, keeping 3-sector alignment
|
||||
/// when the result is >= 6.
|
||||
fn double_batch_size(size: u16, preferred: u16) -> u16 {
|
||||
let d = size.saturating_mul(2).min(preferred);
|
||||
if d >= 6 { d - (d % 3) } else { d }
|
||||
}
|
||||
|
||||
/// Adaptive batch sizer. Shrinks on read failure, grows after a sustained
|
||||
/// clean streak. Amortizes the cost of entering a bad zone — descent happens
|
||||
/// once, not once per bad sector.
|
||||
#[derive(Debug)]
|
||||
struct AdaptiveBatch {
|
||||
preferred: u16,
|
||||
current: u16,
|
||||
streak_sectors: u32,
|
||||
}
|
||||
|
||||
impl AdaptiveBatch {
|
||||
fn new(preferred: u16) -> Self {
|
||||
Self {
|
||||
preferred,
|
||||
current: preferred,
|
||||
streak_sectors: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn current(&self) -> u16 {
|
||||
self.current
|
||||
}
|
||||
|
||||
/// Record a successful read of `sectors`. Returns an event if the
|
||||
/// sizer probed up to a larger batch size.
|
||||
fn on_success(&mut self, sectors: u16) -> Option<EventKind> {
|
||||
self.streak_sectors = self.streak_sectors.saturating_add(sectors as u32);
|
||||
if self.current < self.preferred && self.streak_sectors >= PROBE_THRESHOLD_SECTORS {
|
||||
let new_size = double_batch_size(self.current, self.preferred);
|
||||
if new_size != self.current {
|
||||
self.current = new_size;
|
||||
self.streak_sectors = 0;
|
||||
return Some(EventKind::BatchSizeChanged {
|
||||
new_size,
|
||||
reason: BatchSizeReason::Probed,
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Record a read failure. Returns an event if the sizer shrank.
|
||||
/// Does nothing at size 1 (caller handles skip/error).
|
||||
fn on_failure(&mut self) -> Option<EventKind> {
|
||||
self.streak_sectors = 0;
|
||||
if self.current <= 1 {
|
||||
return None;
|
||||
}
|
||||
let new_size = halve_batch_size(self.current);
|
||||
self.current = new_size;
|
||||
Some(EventKind::BatchSizeChanged {
|
||||
new_size,
|
||||
reason: BatchSizeReason::Shrunk,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Disc stream. Reads sectors from any source → PES frames.
|
||||
///
|
||||
/// Sources: physical drive, ISO file, or any SectorReader.
|
||||
/// Sources: physical drive, ISO file, or any SectorSource.
|
||||
/// Decrypt, demux, and codec parsing happen internally.
|
||||
pub struct DiscStream {
|
||||
reader: Box<dyn SectorReader>,
|
||||
/// Underlying sector source wrapped in the 0.18
|
||||
/// [`DecryptingSectorSource`] decorator. Every `read_sectors`
|
||||
/// call yields plaintext, so `fill_extents` no longer needs an
|
||||
/// inline `decrypt::decrypt_sectors` step. `DecryptKeys::None`
|
||||
/// (raw / unencrypted disc) makes the decorator a pass-through.
|
||||
reader: DecryptingSectorSource<Box<dyn SectorSource>>,
|
||||
title: DiscTitle,
|
||||
disc: Option<Disc>,
|
||||
/// Mirror of the keys handed in at construction. The decorator
|
||||
/// owns the cryptographic state; this field is kept for
|
||||
/// metadata-side callers (`info()` and friends) that want to
|
||||
/// know whether the disc was encrypted, without reaching through
|
||||
/// the wrapper.
|
||||
decrypt_keys: crate::decrypt::DecryptKeys,
|
||||
|
||||
// Extents to read
|
||||
@@ -30,11 +126,29 @@ pub struct DiscStream {
|
||||
read_buf: Vec<u8>,
|
||||
buf_valid: usize,
|
||||
|
||||
// Batch size for reads
|
||||
batch_sectors: u16,
|
||||
// Adaptive batch sizer — preferred comes from the caller
|
||||
// (detect_max_batch_sectors), shrinks/grows based on read outcomes.
|
||||
adaptive: AdaptiveBatch,
|
||||
pub errors: u64,
|
||||
pub skip_errors: bool,
|
||||
/// When set and the token is cancelled, fill_extents returns Err(Halted)
|
||||
/// at the next retry boundary. Unlike skip_errors, this propagates the
|
||||
/// error up so the rip terminates cleanly. Construct with
|
||||
/// [`DiscStream::with_halt`] (preferred) or set post-hoc via the
|
||||
/// deprecated [`DiscStream::set_halt`] bridge — both populate this same
|
||||
/// field and either entry point yields one source of truth.
|
||||
halt: Option<Halt>,
|
||||
event_fn: Option<Box<dyn Fn(Event) + Send>>,
|
||||
eof: bool,
|
||||
|
||||
// Cumulative bytes successfully read from the source. Drives
|
||||
// EventKind::BytesRead emission and autorip's per-device progress.
|
||||
bytes_read_total: u64,
|
||||
// Pre-computed total of all extents in bytes (or 0 if extents are
|
||||
// empty). Carried in EventKind::BytesRead.total so consumers can show
|
||||
// a percent without a separate API call.
|
||||
bytes_total_extents: u64,
|
||||
|
||||
// PES output
|
||||
ts_demuxer: Option<super::ts::TsDemuxer>,
|
||||
ps_demuxer: Option<super::ps::PsDemuxer>,
|
||||
@@ -46,17 +160,25 @@ pub struct DiscStream {
|
||||
impl DiscStream {
|
||||
/// Create a disc stream from any sector reader.
|
||||
///
|
||||
/// Works with physical drives and ISO files — both implement SectorReader.
|
||||
/// Works with physical drives and ISO files — both implement SectorSource.
|
||||
/// The caller opens the source, scans for titles/keys, and passes them in.
|
||||
/// The stream handles demuxing, decryption, and codec parsing internally.
|
||||
pub fn new(
|
||||
reader: Box<dyn SectorReader>,
|
||||
reader: Box<dyn SectorSource>,
|
||||
title: DiscTitle,
|
||||
decrypt_keys: crate::decrypt::DecryptKeys,
|
||||
batch_sectors: u16,
|
||||
content_format: crate::disc::ContentFormat,
|
||||
) -> Self {
|
||||
let extents = title.extents.clone();
|
||||
let bytes_total_extents: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
|
||||
|
||||
// Debug log reader type at construction — critical for diagnosing mux reading from drive instead of ISO
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"DiscStream constructed with reader type: {}",
|
||||
std::any::type_name::<dyn SectorSource>()
|
||||
);
|
||||
|
||||
let mut pids = Vec::new();
|
||||
let mut parsers = Vec::new();
|
||||
@@ -69,7 +191,7 @@ impl DiscStream {
|
||||
};
|
||||
pids.push(pid);
|
||||
pid_to_track.push((pid, idx));
|
||||
parsers.push((pid, super::codec::parser_for_codec(codec)));
|
||||
parsers.push((pid, super::codec::parser_for_codec(codec, None)));
|
||||
}
|
||||
|
||||
let mut ts_demuxer = None;
|
||||
@@ -87,7 +209,11 @@ impl DiscStream {
|
||||
}
|
||||
|
||||
Self {
|
||||
reader,
|
||||
// Wrap the input reader in DecryptingSectorSource so the
|
||||
// internal fill_extents path sees plaintext bytes. For
|
||||
// DecryptKeys::None (unencrypted / raw / test fixtures)
|
||||
// the decorator is a pass-through.
|
||||
reader: DecryptingSectorSource::new(reader, decrypt_keys.clone()),
|
||||
title,
|
||||
disc: None,
|
||||
decrypt_keys,
|
||||
@@ -96,9 +222,14 @@ impl DiscStream {
|
||||
current_offset: 0,
|
||||
read_buf: Vec::with_capacity(batch_sectors as usize * 2048),
|
||||
buf_valid: 0,
|
||||
batch_sectors,
|
||||
adaptive: AdaptiveBatch::new(batch_sectors),
|
||||
errors: 0,
|
||||
skip_errors: false,
|
||||
halt: None,
|
||||
event_fn: None,
|
||||
eof: false,
|
||||
bytes_read_total: 0,
|
||||
bytes_total_extents,
|
||||
ts_demuxer,
|
||||
ps_demuxer,
|
||||
parsers,
|
||||
@@ -107,9 +238,59 @@ impl DiscStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// Skip decryption — return raw encrypted bytes.
|
||||
/// Set event handler for sector-level events (binary search, skip, recover).
|
||||
pub fn on_event(&mut self, f: impl Fn(Event) + Send + 'static) {
|
||||
self.event_fn = Some(Box::new(f));
|
||||
}
|
||||
|
||||
/// Constructor-time builder: attach a [`Halt`] token so that when
|
||||
/// any clone is cancelled, the next read-retry boundary inside
|
||||
/// `fill_extents` returns `Err(Halted)`. Required for Stop to work
|
||||
/// during dense bad-sector regions (where the outer PES read() loop
|
||||
/// can spend minutes inside fill_extents before emitting a frame).
|
||||
///
|
||||
/// Preferred over the post-hoc [`DiscStream::set_halt`] bridge —
|
||||
/// pass the same `Halt` clone you hand to sweep / patch / mux so
|
||||
/// every phase observes a single Stop signal.
|
||||
pub fn with_halt(mut self, halt: Halt) -> Self {
|
||||
self.halt = Some(halt);
|
||||
self
|
||||
}
|
||||
|
||||
/// Bridge for callers that haven't migrated to the
|
||||
/// [`DiscStream::with_halt`] constructor-time path yet. Wraps the
|
||||
/// supplied `Arc<AtomicBool>` as a [`Halt`] (`Halt::from_arc`) and
|
||||
/// stores it in the same internal slot, so a halt installed via
|
||||
/// either entry point goes through one halt-check inside
|
||||
/// `fill_extents`. Calling `set_halt` after `with_halt` (or vice
|
||||
/// versa) replaces the previous token with the new one.
|
||||
#[deprecated(
|
||||
since = "0.18.0",
|
||||
note = "use `DiscStream::with_halt(Halt)` at construction instead"
|
||||
)]
|
||||
pub fn set_halt(&mut self, flag: Arc<AtomicBool>) {
|
||||
self.halt = Some(Halt::from_arc(flag));
|
||||
}
|
||||
|
||||
fn is_halted(&self) -> bool {
|
||||
self.halt
|
||||
.as_ref()
|
||||
.map(|h| h.is_cancelled())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn emit(&self, kind: EventKind) {
|
||||
if let Some(ref f) = self.event_fn {
|
||||
f(Event { kind });
|
||||
}
|
||||
}
|
||||
|
||||
/// Skip decryption — return raw encrypted bytes. Updates both
|
||||
/// the metadata-side key field and the wrapped reader's keys so
|
||||
/// subsequent `read_sectors` calls become a pass-through.
|
||||
pub fn set_raw(&mut self) {
|
||||
self.decrypt_keys = crate::decrypt::DecryptKeys::None;
|
||||
self.reader.set_keys(crate::decrypt::DecryptKeys::None);
|
||||
}
|
||||
|
||||
/// Get the scanned Disc (for listing all titles).
|
||||
@@ -125,22 +306,95 @@ impl DiscStream {
|
||||
let ext_sectors = self.extents[self.current_extent].sector_count;
|
||||
|
||||
let remaining = ext_sectors.saturating_sub(self.current_offset);
|
||||
let sectors = remaining.min(self.batch_sectors as u32) as u16;
|
||||
let sectors = sectors - (sectors % 3);
|
||||
if sectors == 0 {
|
||||
if remaining == 0 {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
return self.fill_extents();
|
||||
}
|
||||
|
||||
let lba = ext_start + self.current_offset;
|
||||
let bytes = sectors as usize * 2048;
|
||||
self.read_buf.resize(bytes, 0);
|
||||
|
||||
self.reader
|
||||
.read_sectors(lba, sectors, &mut self.read_buf[..bytes])?;
|
||||
self.buf_valid = bytes;
|
||||
self.current_offset += sectors as u32;
|
||||
// Adaptive sizer: start at current (preferred until a failure), shrink
|
||||
// on failure, advance on success. One 5s read attempt per try — no
|
||||
// retry loops, no sleeps. On size-1 failure, skip or error.
|
||||
//
|
||||
// Halt is checked at the top of every iteration — in a dense bad zone
|
||||
// this loop can spend minutes shrinking and skipping sectors; without
|
||||
// the check, Stop wouldn't take effect until the outer PES read() loop
|
||||
// finally emits a frame, which may never happen.
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
loop {
|
||||
if self.is_halted() {
|
||||
return Err(crate::error::Error::Halted.into());
|
||||
}
|
||||
|
||||
// Debug: log slow reads during mux — helps diagnose stalls
|
||||
if cfg!(debug_assertions) && start_time.elapsed().as_secs() > 5 {
|
||||
tracing::debug!(target: "mux", "fill_extents waiting at LBA {} ({}s elapsed, sectors={})", lba, start_time.elapsed().as_secs(), remaining);
|
||||
}
|
||||
|
||||
let mut sectors = remaining.min(self.adaptive.current() as u32) as u16;
|
||||
// Align to 3-sector AACS units when possible. Partial units at
|
||||
// extent boundaries are safely handled by decrypt_sectors().
|
||||
if sectors >= 3 {
|
||||
sectors -= sectors % 3;
|
||||
}
|
||||
let bytes = sectors as usize * 2048;
|
||||
self.read_buf.resize(bytes, 0);
|
||||
|
||||
let ok = self
|
||||
.reader
|
||||
.read_sectors(lba, sectors, &mut self.read_buf[..bytes], false)
|
||||
.is_ok();
|
||||
|
||||
if ok {
|
||||
if let Some(ev) = self.adaptive.on_success(sectors) {
|
||||
self.emit(ev);
|
||||
}
|
||||
self.buf_valid = bytes;
|
||||
self.current_offset += sectors as u32;
|
||||
self.bytes_read_total = self.bytes_read_total.saturating_add(bytes as u64);
|
||||
self.emit(EventKind::BytesRead {
|
||||
bytes: self.bytes_read_total,
|
||||
total: self.bytes_total_extents,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
if sectors == 1 {
|
||||
// Bottomed out. Skip this sector or bail.
|
||||
if self.skip_errors {
|
||||
self.read_buf.resize(2048, 0);
|
||||
self.read_buf[..2048].fill(0);
|
||||
self.buf_valid = 2048;
|
||||
self.errors += 1;
|
||||
self.emit(EventKind::SectorSkipped { sector: lba as u64 });
|
||||
self.current_offset += 1;
|
||||
break;
|
||||
} else {
|
||||
let err = self
|
||||
.reader
|
||||
.read_sectors(lba, sectors, &mut self.read_buf[..2048], false)
|
||||
.err();
|
||||
let (status, sense) =
|
||||
err.as_ref().map(extract_scsi_context).unwrap_or((0, None));
|
||||
return Err(crate::error::Error::DiscRead {
|
||||
sector: lba as u64,
|
||||
status: Some(status),
|
||||
sense,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Shrink and retry at the same LBA with a smaller batch.
|
||||
if let Some(ev) = self.adaptive.on_failure() {
|
||||
self.emit(ev);
|
||||
}
|
||||
}
|
||||
|
||||
if self.current_offset >= ext_sectors {
|
||||
self.current_extent += 1;
|
||||
self.current_offset = 0;
|
||||
@@ -221,11 +475,10 @@ impl crate::pes::Stream for DiscStream {
|
||||
}
|
||||
|
||||
let bytes = self.buf_valid;
|
||||
if let Err(e) =
|
||||
crate::decrypt::decrypt_sectors(&mut self.read_buf[..bytes], &self.decrypt_keys, 0)
|
||||
{
|
||||
return Err(e.into());
|
||||
}
|
||||
// Plaintext: the wrapped reader (DecryptingSectorSource)
|
||||
// applied AACS / CSS in-place during fill_extents'
|
||||
// read_sectors call. The pre-0.18 inline decrypt step
|
||||
// lived here.
|
||||
|
||||
if let Some(ref mut demuxer) = self.ts_demuxer {
|
||||
let packets = demuxer.feed(&self.read_buf[..bytes]);
|
||||
@@ -327,3 +580,140 @@ impl crate::pes::Stream for DiscStream {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! `DiscStream` is the only read-only `Stream` impl in tree (every
|
||||
//! other concrete impl in `mux/*` is bidirectional or write-only).
|
||||
//! These tests lock down a static `Send` assertion plus a
|
||||
//! `Box<dyn Stream>` round trip exercising every method through the
|
||||
//! trait object, so future Send-breaking edits to `DiscStream`'s
|
||||
//! interior types fail at compile time.
|
||||
use super::*;
|
||||
use crate::disc::{ContentFormat, DiscTitle};
|
||||
use crate::pes::Stream;
|
||||
|
||||
/// Static-assert `DiscStream: Send`. The `Stream` trait has `Send` as a
|
||||
/// supertrait — if a future field on `DiscStream` is non-`Send` (e.g.
|
||||
/// a `Box<dyn Read>` instead of `Box<dyn SectorSource>`), this fails
|
||||
/// at compile time, before the runtime trait-object test below.
|
||||
fn _assert_disc_stream_is_send() {
|
||||
fn requires_send<T: Send>() {}
|
||||
requires_send::<DiscStream>();
|
||||
}
|
||||
|
||||
/// Trivial `SectorSource` that yields zeroed sectors. Empty title means
|
||||
/// the demuxer produces no PES frames, so `read()` walks the extents to
|
||||
/// EOF and returns `Ok(None)`. That's enough to exercise the trait-object
|
||||
/// dispatch — the goal here is the bridge, not the demuxer.
|
||||
struct ZeroReader {
|
||||
capacity: u32,
|
||||
}
|
||||
|
||||
impl crate::sector::SectorSource for ZeroReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> crate::error::Result<usize> {
|
||||
let bytes = count as usize * 2048;
|
||||
buf[..bytes].fill(0);
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn capacity_sectors(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
|
||||
fn synthetic_title(sector_count: u32) -> DiscTitle {
|
||||
DiscTitle {
|
||||
extents: vec![crate::disc::Extent {
|
||||
start_lba: 0,
|
||||
sector_count,
|
||||
}],
|
||||
..DiscTitle::empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Smallest credible witness that `DiscStream` flows through `dyn Stream`:
|
||||
/// build a `Box<dyn Stream>`, drive `read()` to EOF, exercise `info()` /
|
||||
/// `headers_ready()` / `codec_private()` through the trait object.
|
||||
#[test]
|
||||
fn stream_via_dyn_object() {
|
||||
let reader = ZeroReader { capacity: 8 };
|
||||
let title = synthetic_title(8);
|
||||
let stream = DiscStream::new(
|
||||
Box::new(reader),
|
||||
title,
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
ContentFormat::BdTs,
|
||||
);
|
||||
|
||||
let mut src: Box<dyn Stream> = Box::new(stream);
|
||||
|
||||
// Empty-title fixture has no streams configured, so headers are
|
||||
// trivially ready and codec_private() yields nothing on track 0.
|
||||
assert!(src.headers_ready());
|
||||
assert!(src.codec_private(0).is_none());
|
||||
let _ = src.info();
|
||||
|
||||
// Drive read() to EOF through the trait object — empty-title fixture
|
||||
// produces no frames, but the call still routes through the blanket
|
||||
// dispatch into Stream::read.
|
||||
let mut frames = 0usize;
|
||||
while src.read().expect("read").is_some() {
|
||||
frames += 1;
|
||||
if frames > 1024 {
|
||||
panic!("unexpected unbounded frame stream from empty title");
|
||||
}
|
||||
}
|
||||
assert_eq!(frames, 0);
|
||||
}
|
||||
|
||||
/// `is_halted()` must observe a cancellation signal regardless of
|
||||
/// which entry point installed the token. The deprecated
|
||||
/// `set_halt(Arc<AtomicBool>)` and the new `with_halt(Halt)` are
|
||||
/// two views over one slot — flipping either bit must cause the
|
||||
/// next `fill_extents` retry boundary to bail.
|
||||
#[test]
|
||||
fn halt_via_with_halt_observed_by_is_halted() {
|
||||
let halt = Halt::new();
|
||||
let stream = DiscStream::new(
|
||||
Box::new(ZeroReader { capacity: 8 }),
|
||||
synthetic_title(8),
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
crate::disc::ContentFormat::BdTs,
|
||||
)
|
||||
.with_halt(halt.clone());
|
||||
assert!(!stream.is_halted());
|
||||
halt.cancel();
|
||||
assert!(
|
||||
stream.is_halted(),
|
||||
"with_halt token cancellation must be observed by is_halted()"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halt_via_set_halt_bridge_observed_by_is_halted() {
|
||||
let arc = Arc::new(AtomicBool::new(false));
|
||||
let mut stream = DiscStream::new(
|
||||
Box::new(ZeroReader { capacity: 8 }),
|
||||
synthetic_title(8),
|
||||
crate::decrypt::DecryptKeys::None,
|
||||
8,
|
||||
crate::disc::ContentFormat::BdTs,
|
||||
);
|
||||
stream.set_halt(arc.clone());
|
||||
assert!(!stream.is_halted());
|
||||
arc.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(
|
||||
stream.is_halted(),
|
||||
"set_halt(Arc<AtomicBool>) bridge must observe Arc-side flips"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-28
@@ -87,11 +87,6 @@ pub fn write_uint(w: &mut impl Write, id: u32, val: u64) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a complete EBML signed integer element.
|
||||
pub fn write_int(w: &mut impl Write, id: u32, val: i64) -> io::Result<()> {
|
||||
write_uint(w, id, val as u64)
|
||||
}
|
||||
|
||||
/// Write a complete EBML float element (8-byte double).
|
||||
pub fn write_float(w: &mut impl Write, id: u32, val: f64) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
@@ -309,22 +304,6 @@ pub fn read_binary_val(r: &mut impl Read, len: usize) -> io::Result<Vec<u8>> {
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Read a VINT (track number) from a SimpleBlock. Returns (value, bytes_consumed).
|
||||
pub fn read_vint(r: &mut impl Read) -> io::Result<(u64, usize)> {
|
||||
let mut first = [0u8; 1];
|
||||
r.read_exact(&mut first)?;
|
||||
let b0 = first[0];
|
||||
if b0 & 0x80 != 0 {
|
||||
return Ok(((b0 & 0x7F) as u64, 1));
|
||||
}
|
||||
if b0 & 0x40 != 0 {
|
||||
let mut b = [0u8; 1];
|
||||
r.read_exact(&mut b)?;
|
||||
return Ok(((((b0 & 0x3F) as u64) << 8) | b[0] as u64, 2));
|
||||
}
|
||||
Err(crate::error::Error::MkvInvalid.into())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Matroska Element IDs
|
||||
// ============================================================
|
||||
@@ -342,12 +321,6 @@ pub const EBML_DOC_TYPE_READ_VERSION: u32 = 0x4285;
|
||||
// Segment
|
||||
pub const SEGMENT: u32 = 0x1853_8067;
|
||||
|
||||
// Seek Head
|
||||
pub const SEEK_HEAD: u32 = 0x114D_9B74;
|
||||
pub const SEEK: u32 = 0x4DBB;
|
||||
pub const SEEK_ID: u32 = 0x53AB;
|
||||
pub const SEEK_POSITION: u32 = 0x53AC;
|
||||
|
||||
// Segment Info
|
||||
pub const INFO: u32 = 0x1549_A966;
|
||||
pub const TIMESTAMP_SCALE: u32 = 0x2A_D7B1;
|
||||
@@ -441,7 +414,7 @@ mod tests {
|
||||
fn test_write_uint() {
|
||||
let mut buf = Vec::new();
|
||||
write_uint(&mut buf, 0x4286, 1).unwrap(); // EBML_VERSION = 1
|
||||
// ID: 42 86, Size: 81 (1 byte), Data: 01
|
||||
// ID: 42 86, Size: 81 (1 byte), Data: 01
|
||||
assert_eq!(buf, [0x42, 0x86, 0x81, 0x01]);
|
||||
}
|
||||
|
||||
|
||||
+16
-11
@@ -1,10 +1,10 @@
|
||||
//! ISO sector reader — file-backed SectorReader for Blu-ray ISO images.
|
||||
//! ISO sector reader — file-backed SectorSource for Blu-ray ISO images.
|
||||
//!
|
||||
//! An ISO is a flat image of 2048-byte sectors. Sector N starts at byte offset N * 2048.
|
||||
//! Used by DiscStream::open_iso() and Disc::scan_image().
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::sector::SectorSource;
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
@@ -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 {
|
||||
@@ -33,13 +32,19 @@ impl IsoSectorReader {
|
||||
Ok(Self { file, capacity })
|
||||
}
|
||||
|
||||
pub fn capacity(&self) -> u32 {
|
||||
pub fn capacity_sectors(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for IsoSectorReader {
|
||||
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
|
||||
impl SectorSource for IsoSectorReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
let bytes = count as usize * SECTOR_SIZE as usize;
|
||||
self.file
|
||||
.seek(SeekFrom::Start(lba as u64 * SECTOR_SIZE))
|
||||
@@ -68,14 +73,14 @@ mod tests {
|
||||
std::fs::write(&dir, &data).unwrap();
|
||||
|
||||
let mut reader = IsoSectorReader::open(dir.to_str().unwrap()).unwrap();
|
||||
assert_eq!(reader.capacity(), 4);
|
||||
assert_eq!(reader.capacity_sectors(), 4);
|
||||
|
||||
let mut buf = [0u8; 2048];
|
||||
reader.read_sectors(0, 1, &mut buf).unwrap();
|
||||
reader.read_sectors(0, 1, &mut buf, true).unwrap();
|
||||
assert_eq!(buf[0], 1);
|
||||
assert_eq!(buf[2047], 100);
|
||||
|
||||
reader.read_sectors(2, 1, &mut buf).unwrap();
|
||||
reader.read_sectors(2, 1, &mut buf, true).unwrap();
|
||||
assert_eq!(buf[0], 3);
|
||||
assert_eq!(buf[2047], 102);
|
||||
|
||||
@@ -89,7 +94,7 @@ mod tests {
|
||||
std::fs::write(&dir, &data).unwrap();
|
||||
|
||||
let reader = IsoSectorReader::open(dir.to_str().unwrap()).unwrap();
|
||||
assert_eq!(reader.capacity(), 10);
|
||||
assert_eq!(reader.capacity_sectors(), 10);
|
||||
|
||||
std::fs::remove_file(&dir).ok();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -18,10 +18,10 @@ const SCAN_SIZE: usize = 1024 * 1024;
|
||||
|
||||
enum Mode {
|
||||
Write {
|
||||
muxer: super::tsmux::TsMuxer<Box<dyn Write>>,
|
||||
muxer: super::tsmux::TsMuxer<Box<dyn Write + Send>>,
|
||||
},
|
||||
Read {
|
||||
reader: Box<dyn Read>,
|
||||
reader: Box<dyn Read + Send>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ pub struct M2tsStream {
|
||||
impl M2tsStream {
|
||||
/// Create for writing PES frames → BD-TS output.
|
||||
/// Writes FMKV metadata header, then muxes PES frames into BD transport stream.
|
||||
pub fn create(mut writer: impl Write + 'static, title: &DiscTitle) -> io::Result<Self> {
|
||||
pub fn create(mut writer: impl Write + Send + 'static, title: &DiscTitle) -> io::Result<Self> {
|
||||
// Write FMKV metadata header
|
||||
if !title.streams.is_empty() {
|
||||
let m = meta::M2tsMeta::from_title(title);
|
||||
@@ -72,7 +72,7 @@ impl M2tsStream {
|
||||
DiscStream::Subtitle(s) => s.pid,
|
||||
})
|
||||
.collect();
|
||||
let boxed: Box<dyn Write> = Box::new(writer);
|
||||
let boxed: Box<dyn Write + Send> = Box::new(writer);
|
||||
let mut muxer = super::tsmux::TsMuxer::new(boxed, &pids);
|
||||
for (i, cp) in title.codec_privates.iter().enumerate() {
|
||||
if let Some(data) = cp {
|
||||
@@ -103,7 +103,7 @@ impl M2tsStream {
|
||||
};
|
||||
pids.push(pid);
|
||||
pid_to_track.push((pid, i));
|
||||
parsers.push((pid, super::codec::parser_for_codec(codec)));
|
||||
parsers.push((pid, super::codec::parser_for_codec(codec, None)));
|
||||
}
|
||||
(pids, parsers, pid_to_track)
|
||||
}
|
||||
@@ -111,7 +111,7 @@ impl M2tsStream {
|
||||
/// Open an M2TS stream for reading. Takes any Read source — file, pipe, socket.
|
||||
///
|
||||
/// Tries FMKV metadata header first. Falls back to PMT scan of first 1 MB.
|
||||
pub fn open(mut reader: impl Read + 'static) -> io::Result<Self> {
|
||||
pub fn open(mut reader: impl Read + Send + 'static) -> io::Result<Self> {
|
||||
// Read first chunk — enough for FMKV header or PMT scan
|
||||
let mut head = vec![0u8; SCAN_SIZE];
|
||||
let head_len = read_fill(&mut reader, &mut head)?;
|
||||
@@ -125,7 +125,7 @@ impl M2tsStream {
|
||||
let (pids, parsers, pid_to_track) = Self::setup_pes(&title.streams);
|
||||
// Chain: remaining head bytes + rest of reader
|
||||
let remaining_head = &head[header_end..];
|
||||
let chain: Box<dyn Read> =
|
||||
let chain: Box<dyn Read + Send> =
|
||||
Box::new(io::Cursor::new(remaining_head.to_vec()).chain(reader));
|
||||
return Ok(Self {
|
||||
disc_title: title.clone(),
|
||||
@@ -150,7 +150,7 @@ impl M2tsStream {
|
||||
let (pids, parsers, pid_to_track) = Self::setup_pes(&streams);
|
||||
|
||||
// Chain: full head (it's all TS data) + rest of reader
|
||||
let chain: Box<dyn Read> = Box::new(io::Cursor::new(head).chain(reader));
|
||||
let chain: Box<dyn Read + Send> = Box::new(io::Cursor::new(head).chain(reader));
|
||||
|
||||
Ok(Self {
|
||||
disc_title: DiscTitle {
|
||||
|
||||
+3
-1
@@ -173,6 +173,7 @@ impl M2tsMeta {
|
||||
.parse()
|
||||
.unwrap_or(crate::disc::SampleRate::Unknown),
|
||||
secondary: *secondary,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: label.clone(),
|
||||
}),
|
||||
MetaStream::Subtitle {
|
||||
@@ -185,6 +186,7 @@ impl M2tsMeta {
|
||||
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
|
||||
language: language.clone(),
|
||||
forced: *forced,
|
||||
qualifier: crate::disc::LabelQualifier::None,
|
||||
codec_data: None,
|
||||
}),
|
||||
})
|
||||
@@ -211,7 +213,7 @@ impl M2tsMeta {
|
||||
.iter()
|
||||
.map(|s| {
|
||||
if let MetaStream::Video {
|
||||
codec_private: Some(ref b64),
|
||||
codec_private: Some(b64),
|
||||
..
|
||||
} = s
|
||||
{
|
||||
|
||||
+48
-81
@@ -5,8 +5,10 @@
|
||||
//! cues and seek head are finalized at the end.
|
||||
|
||||
use super::ebml;
|
||||
use crate::disc::{AudioStream, Chapter, Codec, ColorSpace, HdrFormat, SubtitleStream, VideoStream};
|
||||
use std::io::{self, Seek, SeekFrom, Write};
|
||||
use crate::disc::{
|
||||
AudioStream, Chapter, Codec, ColorSpace, HdrFormat, SubtitleStream, VideoStream,
|
||||
};
|
||||
use std::io::{self, Seek, Write};
|
||||
|
||||
/// MKV track definition (built from disc stream metadata).
|
||||
pub struct MkvTrack {
|
||||
@@ -24,10 +26,10 @@ pub struct MkvTrack {
|
||||
pub display_width: u32, // display aspect ratio width (0 = same as pixel)
|
||||
pub display_height: u32, // display aspect ratio height (0 = same as pixel)
|
||||
// HDR colour metadata
|
||||
pub colour_matrix: u8, // MatrixCoefficients (9=bt2020nc)
|
||||
pub colour_transfer: u8, // TransferCharacteristics (16=smpte2084/PQ)
|
||||
pub colour_primaries: u8, // Primaries (9=bt2020)
|
||||
pub colour_range: u8, // Range (1=tv/limited)
|
||||
pub colour_matrix: u8, // MatrixCoefficients (9=bt2020nc)
|
||||
pub colour_transfer: u8, // TransferCharacteristics (16=smpte2084/PQ)
|
||||
pub colour_primaries: u8, // Primaries (9=bt2020)
|
||||
pub colour_range: u8, // Range (1=tv/limited)
|
||||
// Audio-specific
|
||||
pub sample_rate: f64,
|
||||
pub channels: u8,
|
||||
@@ -52,7 +54,7 @@ impl MkvTrack {
|
||||
};
|
||||
let (matrix, transfer, primaries, range) = match v.color_space {
|
||||
ColorSpace::Bt2020 => (9, 16, 9, 1), // bt2020nc, PQ, bt2020, limited
|
||||
ColorSpace::Bt709 => (1, 1, 1, 1), // bt709
|
||||
ColorSpace::Bt709 => (1, 1, 1, 1), // bt709
|
||||
ColorSpace::Unknown => (0, 0, 0, 0),
|
||||
};
|
||||
// Override transfer for non-PQ HDR
|
||||
@@ -168,10 +170,6 @@ pub struct MkvMuxer<W: Write + Seek> {
|
||||
base_pts_ms: Option<i64>,
|
||||
cues: Vec<CuePoint>,
|
||||
frame_count: u64,
|
||||
/// File positions of codecPrivate placeholders (track_idx → offset, max_size).
|
||||
/// Used to seek back and fill in SPS/PPS after first keyframe.
|
||||
codec_private_slots: Vec<Option<(u64, usize)>>,
|
||||
codec_private_filled: Vec<bool>,
|
||||
}
|
||||
|
||||
/// New cluster every 5 seconds.
|
||||
@@ -180,16 +178,6 @@ const CLUSTER_DURATION_MS: i64 = 5000;
|
||||
impl<W: Write + Seek> MkvMuxer<W> {
|
||||
/// Create a new MKV muxer: writes EBML header, Segment start, Info, Tracks, Chapters.
|
||||
pub fn new(
|
||||
writer: W,
|
||||
tracks: &[MkvTrack],
|
||||
title: Option<&str>,
|
||||
duration_secs: f64,
|
||||
) -> io::Result<Self> {
|
||||
Self::new_with_chapters(writer, tracks, title, duration_secs, &[])
|
||||
}
|
||||
|
||||
/// Create a new MKV muxer with chapters: writes EBML header, Segment start, Info, Tracks, Chapters.
|
||||
pub fn new_with_chapters(
|
||||
mut writer: W,
|
||||
tracks: &[MkvTrack],
|
||||
title: Option<&str>,
|
||||
@@ -227,8 +215,6 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
ebml::end_master(&mut writer, info_pos)?;
|
||||
|
||||
// Tracks
|
||||
let mut codec_private_slots: Vec<Option<(u64, usize)>> = Vec::new();
|
||||
let mut codec_private_filled: Vec<bool> = Vec::new();
|
||||
let tracks_pos = ebml::start_master(&mut writer, ebml::TRACKS)?;
|
||||
for (i, track) in tracks.iter().enumerate() {
|
||||
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
|
||||
@@ -251,24 +237,20 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
|
||||
if let Some(ref cp) = track.codec_private {
|
||||
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?;
|
||||
codec_private_slots.push(None); // already filled
|
||||
codec_private_filled.push(true);
|
||||
} else if track.track_type == ebml::TRACK_TYPE_VIDEO {
|
||||
// Reserve space for codecPrivate — will be filled after first keyframe
|
||||
// Reserve 256 bytes (enough for SPS+PPS or VPS+SPS+PPS)
|
||||
let cp_pos = writer.stream_position()?;
|
||||
let placeholder = vec![0u8; 256];
|
||||
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, &placeholder)?;
|
||||
codec_private_slots.push(Some((cp_pos, 256)));
|
||||
codec_private_filled.push(false);
|
||||
} else {
|
||||
codec_private_slots.push(None);
|
||||
codec_private_filled.push(true);
|
||||
}
|
||||
// Pre-0.13 a deferred codecPrivate path existed for video tracks
|
||||
// (placeholder reserve + later seek-back fill via
|
||||
// `fill_codec_private`). The PES pipeline hands codec_private
|
||||
// up-front via the DiscTitle, so the deferred path was never
|
||||
// exercised — removed in the 0.13 dead-code sweep.
|
||||
|
||||
// DefaultDuration — frame duration in nanoseconds
|
||||
if track.default_duration_ns > 0 {
|
||||
ebml::write_uint(&mut writer, ebml::DEFAULT_DURATION, track.default_duration_ns)?;
|
||||
ebml::write_uint(
|
||||
&mut writer,
|
||||
ebml::DEFAULT_DURATION,
|
||||
track.default_duration_ns,
|
||||
)?;
|
||||
}
|
||||
|
||||
// Video-specific
|
||||
@@ -278,13 +260,25 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
ebml::write_uint(&mut writer, ebml::PIXEL_HEIGHT, track.pixel_height as u64)?;
|
||||
if track.display_width > 0 && track.display_height > 0 {
|
||||
ebml::write_uint(&mut writer, ebml::DISPLAY_WIDTH, track.display_width as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::DISPLAY_HEIGHT, track.display_height as u64)?;
|
||||
ebml::write_uint(
|
||||
&mut writer,
|
||||
ebml::DISPLAY_HEIGHT,
|
||||
track.display_height as u64,
|
||||
)?;
|
||||
}
|
||||
// Colour metadata (HDR)
|
||||
if track.colour_matrix > 0 || track.colour_transfer > 0 {
|
||||
let col_pos = ebml::start_master(&mut writer, ebml::COLOUR)?;
|
||||
ebml::write_uint(&mut writer, ebml::MATRIX_COEFFICIENTS, track.colour_matrix as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRANSFER_CHARACTERISTICS, track.colour_transfer as u64)?;
|
||||
ebml::write_uint(
|
||||
&mut writer,
|
||||
ebml::MATRIX_COEFFICIENTS,
|
||||
track.colour_matrix as u64,
|
||||
)?;
|
||||
ebml::write_uint(
|
||||
&mut writer,
|
||||
ebml::TRANSFER_CHARACTERISTICS,
|
||||
track.colour_transfer as u64,
|
||||
)?;
|
||||
ebml::write_uint(&mut writer, ebml::PRIMARIES, track.colour_primaries as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::RANGE, track.colour_range as u64)?;
|
||||
ebml::end_master(&mut writer, col_pos)?;
|
||||
@@ -336,8 +330,6 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
base_pts_ms: None,
|
||||
cues: Vec::new(),
|
||||
frame_count: 0,
|
||||
codec_private_slots,
|
||||
codec_private_filled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -407,30 +399,6 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fill in a deferred codecPrivate for a track.
|
||||
/// Seeks back to the placeholder, writes the actual data, restores position.
|
||||
pub fn fill_codec_private(&mut self, track_idx: usize, data: &[u8]) -> io::Result<()> {
|
||||
if track_idx >= self.codec_private_filled.len() || self.codec_private_filled[track_idx] {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((pos, max_size)) = self.codec_private_slots[track_idx] {
|
||||
if data.len() > max_size {
|
||||
// Data too large for reserved space — can't fill in place
|
||||
// This shouldn't happen with 256 bytes reserved
|
||||
return Ok(());
|
||||
}
|
||||
let current = self.writer.stream_position()?;
|
||||
self.writer.seek(SeekFrom::Start(pos))?;
|
||||
// Rewrite: element ID + size + data + zero-pad remainder
|
||||
let mut padded = data.to_vec();
|
||||
padded.resize(max_size, 0);
|
||||
ebml::write_binary(&mut self.writer, ebml::CODEC_PRIVATE, &padded)?;
|
||||
self.writer.seek(SeekFrom::Start(current))?;
|
||||
self.codec_private_filled[track_idx] = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_cluster(&mut self, ts_ms: i64) -> io::Result<()> {
|
||||
// Close previous cluster if open
|
||||
if self.cluster_open {
|
||||
@@ -562,7 +530,7 @@ mod tests {
|
||||
fn mkv_writes_ebml_header() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let tracks = [make_video_track()];
|
||||
let muxer = MkvMuxer::new(buf, &tracks, Some("Test"), 120.0).unwrap();
|
||||
let muxer = MkvMuxer::new(buf, &tracks, Some("Test"), 120.0, &[]).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
// EBML header element ID: 0x1A45DFA3
|
||||
assert!(data.len() >= 4);
|
||||
@@ -573,7 +541,7 @@ mod tests {
|
||||
fn mkv_writes_segment() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let tracks = [make_video_track()];
|
||||
let muxer = MkvMuxer::new(buf, &tracks, None, 0.0).unwrap();
|
||||
let muxer = MkvMuxer::new(buf, &tracks, None, 0.0, &[]).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
// Segment element ID: 0x18538067
|
||||
assert!(
|
||||
@@ -586,7 +554,7 @@ mod tests {
|
||||
fn mkv_write_frame_creates_cluster() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let tracks = [make_video_track()];
|
||||
let mut muxer = MkvMuxer::new(buf, &tracks, None, 60.0).unwrap();
|
||||
let mut muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
|
||||
muxer
|
||||
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF])
|
||||
.unwrap();
|
||||
@@ -625,7 +593,7 @@ mod tests {
|
||||
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
|
||||
let writer = SharedWriter(shared.clone());
|
||||
let tracks = [make_video_track()];
|
||||
let mut muxer = MkvMuxer::new(writer, &tracks, Some("Cue Test"), 60.0).unwrap();
|
||||
let mut muxer = MkvMuxer::new(writer, &tracks, Some("Cue Test"), 60.0, &[]).unwrap();
|
||||
muxer.write_frame(0, 0, true, &[0x01, 0x02, 0x03]).unwrap();
|
||||
muxer.finish().unwrap();
|
||||
|
||||
@@ -640,7 +608,7 @@ mod tests {
|
||||
fn mkv_multiple_tracks() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let tracks = [make_video_track(), make_audio_track()];
|
||||
let mut muxer = MkvMuxer::new(buf, &tracks, Some("Multi"), 120.0).unwrap();
|
||||
let mut muxer = MkvMuxer::new(buf, &tracks, Some("Multi"), 120.0, &[]).unwrap();
|
||||
// Write frames to both tracks
|
||||
muxer.write_frame(0, 0, true, &[0x00, 0x00, 0x01]).unwrap();
|
||||
muxer.write_frame(1, 0, false, &[0x0B, 0x77, 0x00]).unwrap();
|
||||
@@ -659,7 +627,7 @@ mod tests {
|
||||
fn mkv_keyframe_flag() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let tracks = [make_video_track()];
|
||||
let mut muxer = MkvMuxer::new(buf, &tracks, None, 10.0).unwrap();
|
||||
let mut muxer = MkvMuxer::new(buf, &tracks, None, 10.0, &[]).unwrap();
|
||||
|
||||
// Record position before first frame
|
||||
let pos_before_kf = muxer.writer.position();
|
||||
@@ -691,7 +659,7 @@ mod tests {
|
||||
// Track VINT: 1 byte (track 1 = 0x81)
|
||||
let track_vint_pos = after_id + size_len;
|
||||
let track_vint_len = 1; // track 1 encoded as 0x81
|
||||
// 2-byte relative timestamp
|
||||
// 2-byte relative timestamp
|
||||
let ts_pos = track_vint_pos + track_vint_len;
|
||||
// flags byte
|
||||
let flags_pos = ts_pos + 2;
|
||||
@@ -733,9 +701,7 @@ mod tests {
|
||||
name: "Chapter 3".into(),
|
||||
},
|
||||
];
|
||||
let muxer =
|
||||
MkvMuxer::new_with_chapters(buf, &tracks, Some("Chapter Test"), 900.0, &chapters)
|
||||
.unwrap();
|
||||
let muxer = MkvMuxer::new(buf, &tracks, Some("Chapter Test"), 900.0, &chapters).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
|
||||
// Chapters element ID: 0x1043A770
|
||||
@@ -759,7 +725,7 @@ mod tests {
|
||||
fn mkv_no_chapters_when_empty() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let tracks = [make_video_track()];
|
||||
let muxer = MkvMuxer::new(buf, &tracks, Some("No Chapters"), 60.0).unwrap();
|
||||
let muxer = MkvMuxer::new(buf, &tracks, Some("No Chapters"), 60.0, &[]).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
assert!(
|
||||
find_id(&data, ebml::CHAPTERS).is_none(),
|
||||
@@ -778,7 +744,7 @@ mod tests {
|
||||
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let tracks = [video, audio1, audio2];
|
||||
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0).unwrap();
|
||||
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
|
||||
// FlagDefault ID is 0x88. When is_default is true, FlagDefault is NOT written
|
||||
@@ -804,13 +770,14 @@ mod tests {
|
||||
codec: Codec::Pgs,
|
||||
language: "eng".into(),
|
||||
forced: true,
|
||||
qualifier: crate::disc::LabelQualifier::Forced,
|
||||
codec_data: None,
|
||||
});
|
||||
assert!(forced_sub.is_forced);
|
||||
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let tracks = [video, forced_sub];
|
||||
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0).unwrap();
|
||||
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
|
||||
// FlagForced ID: 0x55AA (2-byte ID)
|
||||
@@ -829,13 +796,14 @@ mod tests {
|
||||
codec: Codec::Pgs,
|
||||
language: "eng".into(),
|
||||
forced: false,
|
||||
qualifier: crate::disc::LabelQualifier::None,
|
||||
codec_data: None,
|
||||
});
|
||||
assert!(!sub.is_forced);
|
||||
|
||||
let buf = Cursor::new(Vec::new());
|
||||
let tracks = [video, sub];
|
||||
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0).unwrap();
|
||||
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
|
||||
// FlagForced should NOT be written for non-forced tracks
|
||||
@@ -845,4 +813,3 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-9
@@ -4,7 +4,7 @@
|
||||
//! Write: PES frames in → MKV mux → Matroska container.
|
||||
|
||||
use super::mkv::{MkvMuxer, MkvTrack};
|
||||
use super::{ebml, WriteSeek};
|
||||
use super::{WriteSeek, ebml};
|
||||
|
||||
type MkvHeaderResult = io::Result<(crate::disc::DiscTitle, Vec<(u16, Vec<u8>)>)>;
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::disc::*;
|
||||
use std::io::{self, Read};
|
||||
|
||||
struct ReadState {
|
||||
reader: Box<dyn Read>,
|
||||
reader: Box<dyn Read + Send>,
|
||||
cluster_ts_ms: i64,
|
||||
/// Codec private data per track (track_number, hvcC/avcC bytes).
|
||||
codec_privates: Vec<(u16, Vec<u8>)>,
|
||||
@@ -26,7 +26,7 @@ struct ReadState {
|
||||
|
||||
enum Mode {
|
||||
Write {
|
||||
muxer: Option<MkvMuxer<Box<dyn WriteSeek>>>,
|
||||
muxer: Option<MkvMuxer<Box<dyn WriteSeek + Send>>>,
|
||||
},
|
||||
Read(ReadState),
|
||||
}
|
||||
@@ -40,7 +40,7 @@ pub struct MkvStream {
|
||||
impl MkvStream {
|
||||
/// Create for writing PES frames → MKV container.
|
||||
/// Codec privates come from title.codec_privates (populated by input stream).
|
||||
pub fn create(writer: Box<dyn WriteSeek>, title: &DiscTitle) -> io::Result<Self> {
|
||||
pub fn create(writer: Box<dyn WriteSeek + Send>, title: &DiscTitle) -> io::Result<Self> {
|
||||
let mut tracks = Vec::new();
|
||||
let mut has_default_video = false;
|
||||
let mut has_default_audio = false;
|
||||
@@ -64,7 +64,7 @@ impl MkvStream {
|
||||
tracks.push(track);
|
||||
}
|
||||
|
||||
let muxer = MkvMuxer::new_with_chapters(
|
||||
let muxer = MkvMuxer::new(
|
||||
writer,
|
||||
&tracks,
|
||||
Some(&title.playlist),
|
||||
@@ -79,7 +79,7 @@ impl MkvStream {
|
||||
}
|
||||
|
||||
/// Open an MKV file for reading → PES frames.
|
||||
pub fn open(mut reader: impl Read + 'static) -> io::Result<Self> {
|
||||
pub fn open(mut reader: impl Read + Send + 'static) -> io::Result<Self> {
|
||||
let (disc_title, codec_privates) = parse_mkv_header(&mut reader)?;
|
||||
Ok(Self {
|
||||
disc_title,
|
||||
@@ -150,9 +150,9 @@ impl crate::pes::Stream for MkvStream {
|
||||
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
match &mut self.mode {
|
||||
Mode::Write {
|
||||
muxer: Some(ref mut m),
|
||||
} => m.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data),
|
||||
Mode::Write { muxer: Some(m) } => {
|
||||
m.write_frame(frame.track, frame.pts, frame.keyframe, &frame.data)
|
||||
}
|
||||
Mode::Write { muxer: None } => Ok(()),
|
||||
Mode::Read(_) => Err(crate::error::Error::StreamReadOnly.into()),
|
||||
}
|
||||
@@ -380,6 +380,7 @@ fn parse_track(
|
||||
language: lang,
|
||||
sample_rate: srs,
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: name,
|
||||
})),
|
||||
17 => Some(crate::disc::Stream::Subtitle(SubtitleStream {
|
||||
@@ -387,6 +388,7 @@ fn parse_track(
|
||||
codec,
|
||||
language: lang,
|
||||
forced,
|
||||
qualifier: crate::disc::LabelQualifier::None,
|
||||
codec_data: None,
|
||||
})),
|
||||
_ => None,
|
||||
|
||||
+31
-14
@@ -15,22 +15,32 @@
|
||||
//!
|
||||
//! For disc→ISO (raw sector copy), use `Disc::copy()` instead.
|
||||
|
||||
// Public modules — types here are intentionally part of the consumable API.
|
||||
pub mod codec;
|
||||
pub mod disc;
|
||||
pub mod ebml;
|
||||
pub mod iso;
|
||||
mod m2ts;
|
||||
pub mod meta;
|
||||
pub mod mkv;
|
||||
mod mkvstream;
|
||||
pub mod network;
|
||||
pub mod null;
|
||||
pub mod ps;
|
||||
pub mod resolve;
|
||||
pub mod stdio;
|
||||
pub mod ts;
|
||||
pub mod tsmux;
|
||||
pub mod tsreader;
|
||||
|
||||
// Internal modules — implementation details. Their *types* are re-exported
|
||||
// where appropriate (`MkvStream`, `M2tsStream`, etc. surface from `lib.rs`),
|
||||
// but the module paths themselves are not part of the API. Pre-0.13 these
|
||||
// were `pub`, leaking low-level EBML primitives, TS muxer internals, and
|
||||
// network/stdio implementations that no external caller had business
|
||||
// reaching for.
|
||||
pub(crate) mod ebml;
|
||||
pub(crate) mod m2ts;
|
||||
/// FMKV metadata header (used by `M2tsStream` / `NetworkStream` / `StdioStream`
|
||||
/// to round-trip codec_privates that don't fit inside the underlying format).
|
||||
/// Exposed for integration tests that exercise the wire format directly.
|
||||
pub mod meta;
|
||||
pub(crate) mod mkv;
|
||||
pub(crate) mod mkvstream;
|
||||
pub(crate) mod network;
|
||||
pub(crate) mod null;
|
||||
pub(crate) mod ps;
|
||||
pub(crate) mod stdio;
|
||||
pub(crate) mod ts;
|
||||
pub(crate) mod tsmux;
|
||||
|
||||
pub use disc::DiscStream;
|
||||
pub use iso::IsoSectorReader;
|
||||
@@ -38,11 +48,18 @@ pub use m2ts::M2tsStream;
|
||||
pub use mkvstream::MkvStream;
|
||||
pub use network::NetworkStream;
|
||||
pub use null::NullStream;
|
||||
pub use resolve::{input, output, parse_url, InputOptions, StreamUrl};
|
||||
pub use resolve::{InputOptions, StreamUrl, input, output, parse_url};
|
||||
pub use stdio::StdioStream;
|
||||
|
||||
use std::io::{Seek, Write};
|
||||
|
||||
// WriteSeek — used internally by MKV muxer (container format requires seeking).
|
||||
/// Combined `Write + Seek` for sinks accepted by the MKV muxer.
|
||||
///
|
||||
/// Matroska's `SeekHead`, `Cues`, and `Cluster` size fields are written with
|
||||
/// placeholder values during streaming and updated in-place at finalization,
|
||||
/// so the output sink must support seeking. Provided as a single trait
|
||||
/// alias so callers don't have to repeat `Write + Seek` everywhere; the
|
||||
/// blanket impl below opts every `T: Write + Seek` in automatically
|
||||
/// (`File`, `BufWriter<File>`, `Cursor<Vec<u8>>`).
|
||||
pub trait WriteSeek: Write + Seek {}
|
||||
impl<T: Write + Seek> WriteSeek for T {}
|
||||
|
||||
+2
-1
@@ -81,7 +81,7 @@ impl crate::pes::Stream for NetworkStream {
|
||||
match &mut self.mode {
|
||||
Mode::Write {
|
||||
writer,
|
||||
ref mut header_written,
|
||||
header_written,
|
||||
..
|
||||
} => {
|
||||
if !*header_written {
|
||||
@@ -144,6 +144,7 @@ mod tests {
|
||||
language: "eng".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: "English".into(),
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -30,3 +30,26 @@ impl crate::pes::Stream for NullStream {
|
||||
&self.disc_title
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pes::Stream;
|
||||
|
||||
/// Verify NullStream routes through the `Stream` trait object cleanly.
|
||||
#[test]
|
||||
fn stream_via_dyn_object_writes_and_finishes() {
|
||||
let title = DiscTitle::empty();
|
||||
let mut sink: Box<dyn Stream> = Box::new(NullStream::new(&title));
|
||||
|
||||
let frame = crate::pes::PesFrame {
|
||||
track: 0,
|
||||
pts: 0,
|
||||
keyframe: true,
|
||||
data: vec![0x01, 0x02, 0x03],
|
||||
};
|
||||
sink.write(&frame).unwrap();
|
||||
let _ = sink.info();
|
||||
sink.finish().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+32
-24
@@ -171,21 +171,22 @@ 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())?;
|
||||
let capacity = reader.capacity();
|
||||
let capacity = reader.capacity_sectors();
|
||||
let disc = crate::disc::Disc::scan_image(&mut reader, capacity, &scan_opts)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
if disc.titles.is_empty() {
|
||||
@@ -202,7 +203,11 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
let title = disc.titles[idx].clone();
|
||||
let keys = disc.decrypt_keys();
|
||||
let format = disc.content_format;
|
||||
let mut stream = DiscStream::new(Box::new(reader), title, keys, 64, format);
|
||||
// ISO file: use large batch size (16 MB) — sequential read from fast storage, no bad sectors.
|
||||
// Physical drives need small batches for adaptive error handling and retry logic.
|
||||
const ISO_MUX_BATCH_SECTORS: u16 = 8192;
|
||||
let mut stream =
|
||||
DiscStream::new(Box::new(reader), title, keys, ISO_MUX_BATCH_SECTORS, format);
|
||||
if opts.raw {
|
||||
stream.set_raw();
|
||||
}
|
||||
@@ -210,17 +215,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,19 +246,26 @@ 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 writer: Box<dyn super::WriteSeek> =
|
||||
Box::new(std::io::BufWriter::with_capacity(IO_BUF_SIZE, file));
|
||||
// Wrap the output in `crate::io::WritebackFile` (bounded-cache
|
||||
// writeback) so a UHD-scale MKV mux to slow / network-attached
|
||||
// staging doesn't hit the dirty-page burst pathology that
|
||||
// sweep already side-steps. BufWriter sits on top to coalesce
|
||||
// mux's many small EBML element writes. Pre-reserve the
|
||||
// target's worth of extents on Linux via fallocate(KEEP_SIZE)
|
||||
// to reduce extent fragmentation during the mux.
|
||||
let writer: Box<dyn super::WriteSeek + Send> =
|
||||
Box::new(std::io::BufWriter::with_capacity(
|
||||
IO_BUF_SIZE,
|
||||
crate::io::WritebackFile::create_with_size_hint(path, title.size_bytes)?,
|
||||
));
|
||||
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 writer = std::io::BufWriter::with_capacity(IO_BUF_SIZE, file);
|
||||
let writer = std::io::BufWriter::with_capacity(
|
||||
IO_BUF_SIZE,
|
||||
crate::io::WritebackFile::create_with_size_hint(path, title.size_bytes)?,
|
||||
);
|
||||
Ok(Box::new(M2tsStream::create(writer, title)?))
|
||||
}
|
||||
StreamUrl::Network { ref addr } => {
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ impl crate::pes::Stream for StdioStream {
|
||||
}
|
||||
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
|
||||
match &mut self.writer {
|
||||
Some(ref mut w) => {
|
||||
Some(w) => {
|
||||
if !self.header_written {
|
||||
if !self.disc_title.streams.is_empty() {
|
||||
let m = meta::M2tsMeta::from_title(&self.disc_title);
|
||||
|
||||
+15
-115
@@ -15,12 +15,6 @@ const TS_PACKET_SIZE: usize = 188;
|
||||
/// TS sync byte.
|
||||
const SYNC_BYTE: u8 = 0x47;
|
||||
|
||||
/// Size of head buffer for PTS/stream scanning (1 MB).
|
||||
const SCAN_HEAD_SIZE: usize = 1024 * 1024;
|
||||
|
||||
/// Size of tail buffer for last-PTS scanning (2 MB).
|
||||
const SCAN_TAIL_SIZE: usize = 2 * 1024 * 1024;
|
||||
|
||||
/// A reassembled PES packet with timestamp info.
|
||||
#[derive(Debug)]
|
||||
pub struct PesPacket {
|
||||
@@ -104,19 +98,16 @@ pub struct TsDemuxer {
|
||||
}
|
||||
|
||||
impl TsDemuxer {
|
||||
/// Take the remainder bytes (leftover from last feed() that didn't
|
||||
/// align to a 192-byte packet boundary).
|
||||
pub fn take_remainder(&mut self) -> Vec<u8> {
|
||||
std::mem::take(&mut self.remainder)
|
||||
}
|
||||
|
||||
/// Set the remainder bytes. Used to transfer alignment state from
|
||||
/// one demuxer to another without losing sync.
|
||||
pub fn set_remainder(&mut self, data: Vec<u8>) {
|
||||
self.remainder = data;
|
||||
}
|
||||
|
||||
/// Create a new demuxer tracking the given PIDs.
|
||||
///
|
||||
/// Allocates a flat lookup table of `i16` slots — one per possible PID
|
||||
/// up to `max(8192, max_pid + 1)`. The 8192 floor matches the BD-TS
|
||||
/// 13-bit PID space (0..0x1FFF); the variable upper bound exists for
|
||||
/// DVD program streams which may use 16-bit stream IDs above 8191.
|
||||
/// Worst-case allocation is `u16::MAX × 2 bytes ≈ 128 KB` — bounded by
|
||||
/// the type, so adversarial input can't drive this beyond predictable
|
||||
/// limits. Empty `pids` yields max_pid 0; the floor still produces a
|
||||
/// valid (wholly-unused) table.
|
||||
pub fn new(pids: &[u16]) -> Self {
|
||||
let max_pid = pids.iter().copied().max().unwrap_or(0) as usize;
|
||||
let table_size = (max_pid + 1).max(8192);
|
||||
@@ -446,6 +437,7 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
language: "und".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x83 => Some(Stream::Audio(AudioStream {
|
||||
@@ -455,6 +447,7 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
language: "und".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x84 | 0xA1 => Some(Stream::Audio(AudioStream {
|
||||
@@ -464,6 +457,7 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
language: "und".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x85 | 0x86 => Some(Stream::Audio(AudioStream {
|
||||
@@ -473,6 +467,7 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
language: "und".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x82 => Some(Stream::Audio(AudioStream {
|
||||
@@ -482,6 +477,7 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
language: "und".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
})),
|
||||
0x90 => Some(Stream::Subtitle(SubtitleStream {
|
||||
@@ -489,6 +485,7 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
codec: Codec::Pgs,
|
||||
language: "und".into(),
|
||||
forced: false,
|
||||
qualifier: crate::disc::LabelQualifier::None,
|
||||
codec_data: None,
|
||||
})),
|
||||
_ => None,
|
||||
@@ -511,103 +508,6 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PTS scanning utilities (for duration detection)
|
||||
// ============================================================
|
||||
|
||||
/// Find the first PTS for a given PID in BD-TS data.
|
||||
pub fn scan_first_pts(data: &[u8], target_pid: u16) -> Option<i64> {
|
||||
let mut offset = 0;
|
||||
while offset + BD_TS_PACKET_SIZE <= data.len() {
|
||||
if data[offset + 4] != SYNC_BYTE {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
let pid = (((data[offset + 5] & 0x1F) as u16) << 8) | data[offset + 6] as u16;
|
||||
let pusi = data[offset + 5] & 0x40 != 0;
|
||||
if pid == target_pid && pusi {
|
||||
let ts = &data[offset + 4..offset + BD_TS_PACKET_SIZE];
|
||||
let afc = (ts[3] >> 4) & 0x03;
|
||||
let payload_start = if afc == 3 { 5 + ts[4] as usize } else { 4 };
|
||||
if payload_start < TS_PACKET_SIZE {
|
||||
let payload = &ts[payload_start..];
|
||||
if payload.len() >= 14 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 {
|
||||
let pts_dts_flags = (payload[7] >> 6) & 0x03;
|
||||
if pts_dts_flags >= 2 {
|
||||
return parse_timestamp(&payload[9..14]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find the last PTS for a given PID in BD-TS data.
|
||||
pub fn scan_last_pts(data: &[u8], target_pid: u16) -> Option<i64> {
|
||||
let mut last_pts = None;
|
||||
let mut offset = 0;
|
||||
while offset + BD_TS_PACKET_SIZE <= data.len() {
|
||||
if data[offset + 4] != SYNC_BYTE {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
let pid = (((data[offset + 5] & 0x1F) as u16) << 8) | data[offset + 6] as u16;
|
||||
let pusi = data[offset + 5] & 0x40 != 0;
|
||||
if pid == target_pid && pusi {
|
||||
let ts = &data[offset + 4..offset + BD_TS_PACKET_SIZE];
|
||||
let afc = (ts[3] >> 4) & 0x03;
|
||||
let payload_start = if afc == 3 { 5 + ts[4] as usize } else { 4 };
|
||||
if payload_start < TS_PACKET_SIZE {
|
||||
let payload = &ts[payload_start..];
|
||||
if payload.len() >= 14 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 {
|
||||
let pts_dts_flags = (payload[7] >> 6) & 0x03;
|
||||
if pts_dts_flags >= 2 {
|
||||
last_pts = parse_timestamp(&payload[9..14]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
}
|
||||
last_pts
|
||||
}
|
||||
|
||||
/// Scan an m2ts file for duration by reading first and last PTS.
|
||||
/// Returns duration in seconds, or None if PTS cannot be found.
|
||||
/// The reader position is restored after scanning.
|
||||
pub fn scan_duration<R: std::io::Read + std::io::Seek>(r: &mut R, video_pid: u16) -> Option<f64> {
|
||||
use std::io::SeekFrom;
|
||||
|
||||
let start_pos = r.stream_position().ok()?;
|
||||
|
||||
// Read first 1MB for first PTS
|
||||
let mut head_buf = vec![0u8; SCAN_HEAD_SIZE];
|
||||
r.seek(SeekFrom::Start(0)).ok()?;
|
||||
let head_n = r.read(&mut head_buf).ok()?;
|
||||
let first_pts = scan_first_pts(&head_buf[..head_n], video_pid)?;
|
||||
|
||||
// Read last 2MB for last PTS (aligned to 192-byte boundary)
|
||||
let file_size = r.seek(SeekFrom::End(0)).ok()?;
|
||||
let tail_size: u64 = SCAN_TAIL_SIZE as u64;
|
||||
let raw_pos = file_size.saturating_sub(tail_size);
|
||||
let seek_pos = (raw_pos / BD_TS_PACKET_SIZE as u64) * BD_TS_PACKET_SIZE as u64;
|
||||
r.seek(SeekFrom::Start(seek_pos)).ok()?;
|
||||
let mut tail_buf = vec![0u8; tail_size as usize];
|
||||
let tail_n = r.read(&mut tail_buf).ok()?;
|
||||
let last_pts = scan_last_pts(&tail_buf[..tail_n], video_pid)?;
|
||||
|
||||
// Restore reader position
|
||||
let _ = r.seek(SeekFrom::Start(start_pos));
|
||||
|
||||
if last_pts > first_pts {
|
||||
Some((last_pts - first_pts) as f64 / 90000.0)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+1
-5
@@ -227,11 +227,7 @@ fn hvcc_to_annex_b(hvcc: &[u8]) -> Option<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
if out.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(out)
|
||||
}
|
||||
if out.is_empty() { None } else { Some(out) }
|
||||
}
|
||||
|
||||
/// Convert length-prefixed NALUs (4-byte BE length + NAL) to Annex B
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
//! TsDemuxReader — reads from any source, demuxes BD-TS, produces PES frames.
|
||||
//!
|
||||
//! Wraps any Read source with a TsDemuxer + CodecParsers.
|
||||
//! One implementation used by M2TS, Network, Stdio, and any other BD-TS input.
|
||||
|
||||
use super::codec::{self, CodecParser};
|
||||
use super::ts::TsDemuxer;
|
||||
use crate::disc::Stream as DiscStream;
|
||||
use crate::pes::PesFrame;
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, Read};
|
||||
|
||||
const READ_BUF_SIZE: usize = 192 * 1024; // 1024 BD-TS packets
|
||||
|
||||
/// Generic BD-TS → PES frame reader.
|
||||
pub struct TsDemuxReader<R: Read> {
|
||||
reader: R,
|
||||
demuxer: TsDemuxer,
|
||||
parsers: Vec<(u16, Box<dyn CodecParser>)>,
|
||||
pid_to_track: Vec<(u16, usize)>,
|
||||
pending: VecDeque<PesFrame>,
|
||||
buf: Vec<u8>,
|
||||
eof: bool,
|
||||
}
|
||||
|
||||
impl<R: Read> TsDemuxReader<R> {
|
||||
/// Create from a reader and stream metadata.
|
||||
pub fn new(reader: R, streams: &[DiscStream]) -> Self {
|
||||
let mut pids = Vec::new();
|
||||
let mut parsers: Vec<(u16, Box<dyn CodecParser>)> = Vec::new();
|
||||
let mut pid_to_track = Vec::new();
|
||||
for (i, s) in streams.iter().enumerate() {
|
||||
let (pid, c) = match s {
|
||||
DiscStream::Video(v) => (v.pid, v.codec),
|
||||
DiscStream::Audio(a) => (a.pid, a.codec),
|
||||
DiscStream::Subtitle(s) => (s.pid, s.codec),
|
||||
};
|
||||
pids.push(pid);
|
||||
pid_to_track.push((pid, i));
|
||||
parsers.push((pid, codec::parser_for_codec(c)));
|
||||
}
|
||||
|
||||
Self {
|
||||
reader,
|
||||
demuxer: TsDemuxer::new(&pids),
|
||||
parsers,
|
||||
pid_to_track,
|
||||
pending: VecDeque::new(),
|
||||
buf: vec![0u8; READ_BUF_SIZE],
|
||||
eof: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the next PES frame. Returns None at EOF.
|
||||
pub fn next_frame(&mut self) -> io::Result<Option<PesFrame>> {
|
||||
if let Some(frame) = self.pending.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
if self.eof {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
loop {
|
||||
let n = self.reader.read(&mut self.buf)?;
|
||||
if n == 0 {
|
||||
self.eof = true;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let packets = self.demuxer.feed(&self.buf[..n]);
|
||||
for pes in &packets {
|
||||
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
if let Some((_, parser)) =
|
||||
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
for frame in parser.parse(pes) {
|
||||
self.pending
|
||||
.push_back(PesFrame::from_codec_frame(*track, frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(frame) = self.pending.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Codec private data for a track.
|
||||
pub fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||
let pid = self
|
||||
.pid_to_track
|
||||
.iter()
|
||||
.find(|(_, idx)| *idx == track)
|
||||
.map(|(pid, _)| *pid)?;
|
||||
self.parsers
|
||||
.iter()
|
||||
.find(|(p, _)| *p == pid)
|
||||
.and_then(|(_, parser)| parser.codec_private())
|
||||
}
|
||||
|
||||
/// True when all primary video tracks have codec_private.
|
||||
pub fn headers_ready(&self, streams: &[DiscStream]) -> bool {
|
||||
for (idx, s) in streams.iter().enumerate() {
|
||||
if let DiscStream::Video(v) = s {
|
||||
if !v.secondary && self.codec_private(idx).is_none() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
+116
-9
@@ -78,26 +78,40 @@ impl PesFrame {
|
||||
}
|
||||
}
|
||||
|
||||
/// A stream. Read from it or write to it. Not both.
|
||||
pub trait Stream {
|
||||
/// Read the next frame. Returns None at end of stream.
|
||||
/// A PES frame stream. One trait per format — same type opens for read
|
||||
/// (`open()` / `listen()` / `input()`) or write (`create()` / `connect()` /
|
||||
/// `output()`). Calling the wrong-direction method returns a typed
|
||||
/// `StreamReadOnly` / `StreamWriteOnly` error.
|
||||
///
|
||||
/// `Send` is required so streams can move across the producer/consumer
|
||||
/// threads in autorip's mux pipeline.
|
||||
pub trait Stream: Send {
|
||||
/// Read the next frame, or `Ok(None)` at end of stream. Returns
|
||||
/// `StreamWriteOnly` on a stream opened for writing.
|
||||
fn read(&mut self) -> std::io::Result<Option<PesFrame>>;
|
||||
|
||||
/// Write a frame.
|
||||
/// Write a frame to the sink. Returns `StreamReadOnly` on a stream
|
||||
/// opened for reading.
|
||||
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
|
||||
}
|
||||
@@ -106,7 +120,7 @@ pub trait Stream {
|
||||
/// Wraps any output stream and counts bytes written.
|
||||
///
|
||||
/// Progress tracking is a CLI concern — streams don't know their size.
|
||||
/// Wrap the output with CountingStream, then query bytes_written().
|
||||
/// Wrap the output with `CountingStream`, then query `bytes_written()`.
|
||||
///
|
||||
/// ```text
|
||||
/// let mut output = CountingStream::new(libfreemkv::output(dest, &title)?);
|
||||
@@ -157,3 +171,96 @@ impl Stream for CountingStream {
|
||||
self.inner.headers_ready()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::disc::DiscTitle;
|
||||
|
||||
fn make_frame(track: usize, pts: i64) -> PesFrame {
|
||||
PesFrame {
|
||||
track,
|
||||
pts,
|
||||
keyframe: track == 0 && pts == 0,
|
||||
data: vec![track as u8, (pts & 0xff) as u8, 0xAA],
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal in-memory `Stream` for trait-shape tests. `read` replays
|
||||
/// pre-seeded frames; `write` collects them.
|
||||
struct MockStream {
|
||||
read_queue: std::vec::IntoIter<PesFrame>,
|
||||
written: Vec<PesFrame>,
|
||||
title: DiscTitle,
|
||||
}
|
||||
|
||||
impl MockStream {
|
||||
fn new(read_frames: Vec<PesFrame>) -> Self {
|
||||
Self {
|
||||
read_queue: read_frames.into_iter(),
|
||||
written: Vec::new(),
|
||||
title: DiscTitle::empty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream for MockStream {
|
||||
fn read(&mut self) -> std::io::Result<Option<PesFrame>> {
|
||||
Ok(self.read_queue.next())
|
||||
}
|
||||
|
||||
fn write(&mut self, frame: &PesFrame) -> std::io::Result<()> {
|
||||
self.written.push(frame.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn info(&self) -> &DiscTitle {
|
||||
&self.title
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_read_yields_frames_then_eof() {
|
||||
let frames = vec![make_frame(0, 0), make_frame(1, 1_000), make_frame(0, 2_000)];
|
||||
let mut s = MockStream::new(frames.clone());
|
||||
|
||||
let f0 = s.read().unwrap().expect("first frame");
|
||||
assert_eq!(f0.track, frames[0].track);
|
||||
assert_eq!(f0.pts, frames[0].pts);
|
||||
assert!(f0.keyframe);
|
||||
|
||||
let f1 = s.read().unwrap().expect("second frame");
|
||||
assert_eq!(f1.pts, frames[1].pts);
|
||||
|
||||
let f2 = s.read().unwrap().expect("third frame");
|
||||
assert_eq!(f2.pts, frames[2].pts);
|
||||
|
||||
assert!(s.read().unwrap().is_none());
|
||||
assert!(s.read().unwrap().is_none()); // idempotent at EOF
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_write_collects_then_finishes() {
|
||||
let mut s = MockStream::new(Vec::new());
|
||||
let frames = [make_frame(0, 0), make_frame(1, 100), make_frame(2, 200)];
|
||||
|
||||
for f in &frames {
|
||||
s.write(f).unwrap();
|
||||
}
|
||||
assert_eq!(s.written.len(), 3);
|
||||
s.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_via_dyn_object() {
|
||||
let mut s: Box<dyn Stream> = Box::new(MockStream::new(vec![make_frame(0, 0)]));
|
||||
let frame = s.read().unwrap().expect("first frame");
|
||||
s.write(&frame).unwrap();
|
||||
let _ = s.info();
|
||||
s.finish().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +96,8 @@ impl Mt1959 {
|
||||
if result.bytes_transferred != expected {
|
||||
return Err(Error::ScsiError {
|
||||
opcode: SCSI_READ_BUFFER,
|
||||
status: 0xFF,
|
||||
sense_key: 0,
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
Ok(result.bytes_transferred)
|
||||
@@ -169,8 +169,8 @@ impl Mt1959 {
|
||||
}
|
||||
Err(Error::ScsiError {
|
||||
opcode: SCSI_READ_BUFFER,
|
||||
status: 0xFF,
|
||||
sense_key: 0,
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -275,8 +275,8 @@ impl Mt1959 {
|
||||
{
|
||||
return Err(Error::ScsiError {
|
||||
opcode: SCSI_READ_BUFFER,
|
||||
status: 0xFF,
|
||||
sense_key: 0,
|
||||
status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
addr = addr.wrapping_add(PROBE_STEP);
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
//! Pipeline-progress reporting for the rip pipeline.
|
||||
//!
|
||||
//! v0.13.16 architecture rule: ONE progress signal type. Every long-running
|
||||
//! pipeline operation (`Disc::copy`, `Disc::patch`, `verify_title`) emits the same
|
||||
//! `PassProgress` shape via the `Progress` trait. Consumers (autorip) compute
|
||||
//! a single `PipelineStats` derived view and never reach into per-pass
|
||||
//! internals.
|
||||
//!
|
||||
//! Why this matters: pre-0.13.16 the API leaked `pos`, `bytes_good`,
|
||||
//! `work_done`, `bytes_pending`, `Finished/NonTrimmed` mapfile semantics —
|
||||
//! and consumers reinvented the math each time they wanted a percentage.
|
||||
//! UIs ended up reading one source while server-side computed from another,
|
||||
//! producing wrong percentages without anyone noticing.
|
||||
|
||||
/// Identifies which pipeline phase the progress event belongs to.
|
||||
///
|
||||
/// Consumers can render a phase-specific label (e.g. "Sweep", "Trim
|
||||
/// (reverse)", "Scrape", "Mux") or just use a generic "Pass N" label.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PassKind {
|
||||
/// `Disc::copy` — initial sweep across the entire disc.
|
||||
Sweep,
|
||||
/// `Disc::patch` retry pass with `block_sectors >= 2`. `reverse=true`
|
||||
/// means walking bad ranges from highest to lowest LBA.
|
||||
Trim { reverse: bool },
|
||||
/// `Disc::patch` final pass at 1 sector per block.
|
||||
Scrape { reverse: bool },
|
||||
/// Demux ISO → output (MKV / M2TS / network). Single phase that runs
|
||||
/// after all rip passes complete.
|
||||
Mux,
|
||||
/// Sector verification — reads every sector and classifies health.
|
||||
Verify,
|
||||
}
|
||||
|
||||
/// One progress sample from a pipeline phase.
|
||||
///
|
||||
/// `work_done / work_total` is the per-pass percentage — always 0..=100%
|
||||
/// regardless of which kind of pass is running. `bytes_good_total` is the
|
||||
/// cumulative count of confirmed-clean bytes across the whole rip; useful
|
||||
/// for the "data recovered" stat the user sees.
|
||||
///
|
||||
/// For `PassKind::Verify`, the fields map as follows:
|
||||
/// - `work_done` = sectors read so far
|
||||
/// - `work_total` = total sectors in title
|
||||
/// - `bytes_good_total` = good + slow + recovered sectors × 2048
|
||||
/// - `bytes_unreadable_total` = bad sectors × 2048
|
||||
/// - `bytes_pending_total` = 0 (verify processes sequentially, nothing pending)
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PassProgress {
|
||||
pub kind: PassKind,
|
||||
pub work_done: u64,
|
||||
pub work_total: u64,
|
||||
pub bytes_good_total: u64,
|
||||
pub bytes_unreadable_total: u64,
|
||||
pub bytes_pending_total: u64,
|
||||
pub bytes_total_disc: u64,
|
||||
pub disc_duration_secs: Option<f64>,
|
||||
/// How many bytes of the worst-case damage (unreadable + pending) fall
|
||||
/// within the main title's extents. Zero means none of the damage
|
||||
/// affects the main movie — it's all in extras/menus.
|
||||
pub bytes_bad_in_main_title: u64,
|
||||
/// Main title duration in seconds. Same as disc_duration_secs when the
|
||||
/// disc has one dominant title, but separate so consumers can show both.
|
||||
pub main_title_duration_secs: Option<f64>,
|
||||
/// Main title size in bytes (sum of extent sizes).
|
||||
pub main_title_size_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
impl PassProgress {
|
||||
/// Percentage of work completed for this pass (0..=100).
|
||||
///
|
||||
/// Returns `100.0` if `work_total` is zero to avoid division by zero.
|
||||
pub fn work_pct(&self) -> f64 {
|
||||
if self.work_total == 0 {
|
||||
return 100.0;
|
||||
}
|
||||
self.work_done as f64 / self.work_total as f64 * 100.0
|
||||
}
|
||||
|
||||
/// Percentage of the disc that is confirmed clean (0..=100).
|
||||
///
|
||||
/// Computed from `bytes_good_total / bytes_total_disc`.
|
||||
pub fn good_pct(&self) -> f64 {
|
||||
if self.bytes_total_disc == 0 {
|
||||
return 100.0;
|
||||
}
|
||||
self.bytes_good_total as f64 / self.bytes_total_disc as f64 * 100.0
|
||||
}
|
||||
|
||||
/// Percentage of the disc that is unreadable (0..=100).
|
||||
pub fn bad_pct(&self) -> f64 {
|
||||
if self.bytes_total_disc == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.bytes_unreadable_total as f64 / self.bytes_total_disc as f64 * 100.0
|
||||
}
|
||||
|
||||
/// Percentage of the disc that is still pending (not yet attempted or needs retry).
|
||||
pub fn pending_pct(&self) -> f64 {
|
||||
if self.bytes_total_disc == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.bytes_pending_total as f64 / self.bytes_total_disc as f64 * 100.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A consumer of pipeline progress events. Library code calls
|
||||
/// `Progress::report` once per inner-loop iteration (throttling is the
|
||||
/// consumer's job — `report` is cheap; the library doesn't gate it).
|
||||
///
|
||||
/// Returns `true` to continue, `false` to request early stop.
|
||||
///
|
||||
/// No `Send`/`Sync` bound — `report` is always called from the same thread
|
||||
/// running the pipeline, so closures with non-`Sync` captures (e.g.
|
||||
/// `RefCell<PassProgressState>`) work directly. Blanket impl below lets
|
||||
/// callers pass closures without explicit struct types.
|
||||
pub trait Progress {
|
||||
fn report(&self, p: &PassProgress) -> bool;
|
||||
}
|
||||
|
||||
impl<F: Fn(&PassProgress) -> bool> Progress for F {
|
||||
fn report(&self, p: &PassProgress) -> bool {
|
||||
(self)(p)
|
||||
}
|
||||
}
|
||||
+408
-143
@@ -1,16 +1,32 @@
|
||||
//! Linux SCSI transport via SG_IO ioctl.
|
||||
//! Linux SCSI transport via synchronous blocking SG_IO ioctl.
|
||||
//!
|
||||
//! `execute()` is one syscall: `ioctl(fd, SG_IO, &hdr)` blocks until the
|
||||
//! kernel completes the command (success, error, or its own timeout).
|
||||
//! No userspace abort, no fd close+reopen, no SG_SCSI_RESET escalation —
|
||||
//! the kernel SCSI mid-layer's `scsi_eh.rst` ladder
|
||||
//! (ABORT TASK → LUN RESET → BUS RESET → HOST RESET) runs internally
|
||||
//! when `hdr.timeout` expires, and by the time the ioctl returns the
|
||||
//! kernel has already done what it can.
|
||||
//!
|
||||
//! This matches what every reference project does: MakeMKV (8 s sync
|
||||
//! ioctl), sg_dd (60 s sync ioctl), the kernel default for SCSI block
|
||||
//! devices (30 s `/sys/.../timeout`). See
|
||||
//! the SCSI architecture audit (2026-04-26) for the full primary-source
|
||||
//! references.
|
||||
//!
|
||||
//! Pre-0.13.20 we ran an async `write() + poll(1.5s) + close-on-timeout +
|
||||
//! bg reopen` pattern. That abandoned slow-but-alive commands faster than
|
||||
//! the drive could drain its internal queue, deepening the wedge
|
||||
//! pattern on the LG BU40N. Reverted in 0.13.20.
|
||||
|
||||
use super::{DataDirection, ScsiResult, ScsiTransport};
|
||||
use crate::error::{Error, Result};
|
||||
use std::path::Path;
|
||||
|
||||
const SG_IO: u32 = 0x2285;
|
||||
const SG_SCSI_RESET: u32 = 0x2284;
|
||||
const SG_SCSI_RESET_DEVICE: i32 = 1;
|
||||
const SG_DXFER_NONE: i32 = -1;
|
||||
const SG_DXFER_TO_DEV: i32 = -2;
|
||||
const SG_DXFER_FROM_DEV: i32 = -3;
|
||||
const SG_FLAG_DIRECT_IO: u32 = 1;
|
||||
const SG_FLAG_Q_AT_HEAD: u32 = 0x10;
|
||||
|
||||
#[repr(C)]
|
||||
@@ -41,135 +57,61 @@ struct sg_io_hdr {
|
||||
}
|
||||
|
||||
// Compile-time validation: sg_io_hdr must match the kernel's layout.
|
||||
// 64 bytes on 64-bit, 44 bytes on 32-bit (pointer-size dependent).
|
||||
// 88 bytes on 64-bit, 64 bytes on 32-bit (pointer-size dependent).
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
const _: () = assert!(std::mem::size_of::<sg_io_hdr>() == 88);
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
const _: () = assert!(std::mem::size_of::<sg_io_hdr>() == 64);
|
||||
|
||||
pub struct SgIoTransport {
|
||||
fd: i32,
|
||||
pub fd: i32,
|
||||
device_path: std::path::PathBuf,
|
||||
pub fd_recovery: std::sync::Arc<std::sync::atomic::AtomicI32>,
|
||||
}
|
||||
|
||||
impl SgIoTransport {
|
||||
/// Open a SCSI device for use. Resets the drive first to ensure
|
||||
/// a known good state, then opens a fresh fd for commands.
|
||||
/// Open a SCSI device for use.
|
||||
pub fn open(device: &Path) -> Result<Self> {
|
||||
let device = Self::resolve_to_sg(device);
|
||||
Self::reset(&device)?;
|
||||
let c_path = Self::to_c_path(&device);
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
c_path.as_ptr() as *const libc::c_char,
|
||||
libc::O_RDWR | libc::O_NONBLOCK,
|
||||
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Self::open_error(&device);
|
||||
}
|
||||
Ok(SgIoTransport { fd })
|
||||
Ok(SgIoTransport {
|
||||
fd,
|
||||
device_path: device,
|
||||
fd_recovery: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(-1)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Reset the drive to a known good state — equivalent to unplug/replug.
|
||||
/// After reset, the drive is clean and no fd is held open.
|
||||
/// Clean up kernel SG_IO state and unlock the tray. NOT a hardware
|
||||
/// reset — purely software cleanup before this process opens the
|
||||
/// device for real work.
|
||||
///
|
||||
/// ## Why each step exists
|
||||
/// When a previous process is killed (SIGKILL) mid-SG_IO, the kernel
|
||||
/// may hold queued commands against the dead fd, and `Drop` never
|
||||
/// ran so the tray may still be locked via PREVENT MEDIUM REMOVAL.
|
||||
/// This routine handles both: open + close flushes the kernel SG
|
||||
/// queue (sg_release cancels commands tied to the fd), the 2 s sleep
|
||||
/// gives the kernel time to finish that cleanup, then a fresh fd
|
||||
/// sends ALLOW MEDIUM REMOVAL to clear any stale tray lock.
|
||||
///
|
||||
/// When a process is killed (SIGKILL/kill -9) mid-SG_IO ioctl, two things
|
||||
/// go wrong: (1) the kernel's SG driver may have stale pending commands
|
||||
/// queued for the dead process's fd, and (2) the drive firmware may still
|
||||
/// be mid-operation (seeking, reading, processing a vendor command).
|
||||
///
|
||||
/// A new process opening the same /dev/sg* device gets a fresh fd, but the
|
||||
/// kernel doesn't automatically abort the dead process's commands — the
|
||||
/// drive can appear hung on the first SCSI command.
|
||||
///
|
||||
/// Additionally, killed processes skip Drop, so the tray may be locked
|
||||
/// via PREVENT MEDIUM REMOVAL with no process alive to unlock it.
|
||||
///
|
||||
/// ## Sequence
|
||||
///
|
||||
/// 1. **open** — allocates kernel SG state for this fd
|
||||
/// 2. **close** — triggers kernel cleanup: aborts any pending SG_IO
|
||||
/// commands associated with this fd. The key operation —
|
||||
/// the kernel's sg_release() cancels queued commands.
|
||||
/// 3. **sleep 2s** — the drive firmware needs time to finish/abort whatever
|
||||
/// it was doing when the previous process died. Without
|
||||
/// this, the next command may block on drive-internal state.
|
||||
/// 4. **open** — fresh fd with no stale commands in the kernel queue
|
||||
/// 5. **unlock** — ALLOW MEDIUM REMOVAL (CDB 0x1E, prevent=0). Clears
|
||||
/// any tray lock left by a killed process that never
|
||||
/// ran its Drop/cleanup.
|
||||
/// 6. **TUR** — TEST UNIT READY (CDB 0x00) with 3s timeout. If the
|
||||
/// drive responds, it's in a good state.
|
||||
/// 7. **escalate** — if TUR fails:
|
||||
/// - SG_SCSI_RESET (device level) — kernel sends a SCSI
|
||||
/// bus reset to the device, clearing all firmware state.
|
||||
/// - STOP + START UNIT (CDB 0x1B) — power-cycles the
|
||||
/// drive's logical unit, like pressing the eject button
|
||||
/// and reinserting.
|
||||
/// 8. **close** — release the fd. Drive is clean, nobody holds it.
|
||||
pub fn reset(device: &Path) -> Result<()> {
|
||||
let c_path = Self::to_c_path(device);
|
||||
|
||||
// Step 1-2: open + close — flush stale kernel SG_IO state
|
||||
let probe_fd = unsafe {
|
||||
libc::open(
|
||||
c_path.as_ptr() as *const libc::c_char,
|
||||
libc::O_RDWR | libc::O_NONBLOCK,
|
||||
)
|
||||
};
|
||||
if probe_fd >= 0 {
|
||||
unsafe { libc::close(probe_fd) };
|
||||
}
|
||||
|
||||
// Step 3: let drive settle
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
|
||||
// Step 4: open clean fd
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
c_path.as_ptr() as *const libc::c_char,
|
||||
libc::O_RDWR | libc::O_NONBLOCK,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Self::open_error(device);
|
||||
}
|
||||
|
||||
// Step 5: unlock tray
|
||||
let _ = Self::raw_command(fd, &[0x1E, 0, 0, 0, 0, 0], 3_000);
|
||||
|
||||
// Step 6: TUR — if drive responds, we're done
|
||||
if Self::raw_command(fd, &[0, 0, 0, 0, 0, 0], 3_000).is_err() {
|
||||
// Step 7: escalate — SG_SCSI_RESET
|
||||
let mut reset_type: i32 = SG_SCSI_RESET_DEVICE;
|
||||
unsafe { libc::ioctl(fd, SG_SCSI_RESET as _, &mut reset_type) };
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
|
||||
if Self::raw_command(fd, &[0, 0, 0, 0, 0, 0], 3_000).is_err() {
|
||||
// STOP + START
|
||||
let _ = Self::raw_command(fd, &[0x1B, 0, 0, 0, 0x00, 0], 3_000);
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
let _ = Self::raw_command(fd, &[0x1B, 0, 0, 0, 0x01, 0], 3_000);
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
let _ = Self::raw_command(fd, &[0, 0, 0, 0, 0, 0], 3_000);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8: close — drive is clean
|
||||
unsafe { libc::close(fd) };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// We do NOT verify the drive with TUR or escalate to SG_SCSI_RESET /
|
||||
/// STOP+START UNIT. Both escalations were tried in 0.13.0–0.13.5
|
||||
/// against the LG BU40N (Initio USB-SATA bridge); both failed to
|
||||
/// recover wedged drives and made the wedge worse — see
|
||||
/// the BU40N wedge recovery postmortem (2026-04-25).
|
||||
fn open_error<T>(device: &Path) -> Result<T> {
|
||||
let err = std::io::Error::last_os_error();
|
||||
Err(if err.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
Error::DevicePermission {
|
||||
path: format!(
|
||||
"{}: permission denied (try running as root)",
|
||||
device.display()
|
||||
),
|
||||
path: device.display().to_string(),
|
||||
}
|
||||
} else {
|
||||
Error::DeviceNotFound {
|
||||
@@ -195,7 +137,7 @@ impl SgIoTransport {
|
||||
hdr.flags = SG_FLAG_Q_AT_HEAD;
|
||||
|
||||
let ret = unsafe { libc::ioctl(fd, SG_IO as _, &mut hdr as *mut sg_io_hdr) };
|
||||
if ret < 0 || hdr.status != 0 {
|
||||
if ret < 0 || hdr.status != 0 || hdr.host_status != 0 || hdr.driver_status != 0 {
|
||||
Err(())
|
||||
} else {
|
||||
Ok(())
|
||||
@@ -239,13 +181,42 @@ impl SgIoTransport {
|
||||
|
||||
impl Drop for SgIoTransport {
|
||||
fn drop(&mut self) {
|
||||
// Unlock tray before closing — don't leave it locked
|
||||
let _ = Self::raw_command(self.fd, &[0x1E, 0, 0, 0, 0, 0], 3_000);
|
||||
unsafe { libc::close(self.fd) };
|
||||
if self.fd >= 0 {
|
||||
// Unlock tray before closing — don't leave it locked.
|
||||
let _ = Self::raw_command(self.fd, &[0x1E, 0, 0, 0, 0, 0], 3_000);
|
||||
unsafe { libc::close(self.fd) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScsiTransport for SgIoTransport {
|
||||
/// Execute a SCSI command via synchronous blocking SG_IO.
|
||||
///
|
||||
/// One syscall: `ioctl(fd, SG_IO, &hdr)`. The kernel honors
|
||||
/// `hdr.timeout` and runs its own ABORT TASK → LUN RESET → BUS
|
||||
/// RESET → HOST RESET escalation if the device times out (per
|
||||
/// `Documentation/scsi/scsi_eh.rst`). By the time this returns,
|
||||
/// the kernel has done its recovery work.
|
||||
///
|
||||
/// Errors we surface to caller (any of these = command failed):
|
||||
///
|
||||
/// - ioctl returned -1 → `Error::IoError` (kernel-level failure)
|
||||
/// - `hdr.host_status` != 0 OR `(hdr.driver_status & ~DRIVER_SENSE)` != 0
|
||||
/// → `Error::ScsiError { status: 0xFF, sense_key: 0, asc: 0, ascq: 0 }`
|
||||
/// (real transport-layer failure: kernel timeout, bridge wedge, bus error)
|
||||
/// - `hdr.status` != 0 (typically `0x02` CHECK CONDITION) →
|
||||
/// `Error::ScsiError { status, sense_key, asc, ascq }` carrying the
|
||||
/// drive's full SPC-4 sense triple. Callers route on
|
||||
/// `is_medium_error()`, `is_unit_attention()`, etc.
|
||||
///
|
||||
/// Note: SG's `DRIVER_SENSE` (0x08) bit indicates *sense data is
|
||||
/// attached* — it's set on every CHECK CONDITION reply. It is **not**
|
||||
/// a transport failure; pre-0.13.23 we conflated it with one and
|
||||
/// silently lost every drive-reported error reason. The mask in the
|
||||
/// transport-error check below is the fix.
|
||||
///
|
||||
/// Caller's `data` buffer is mutated only on success; partial
|
||||
/// transfers are reported via `bytes_transferred = data.len() - resid`.
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
@@ -253,24 +224,50 @@ impl ScsiTransport for SgIoTransport {
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
let mut sense = [0u8; 32];
|
||||
let exec_t0 = std::time::Instant::now();
|
||||
let opcode = cdb[0];
|
||||
tracing::trace!(
|
||||
target: "freemkv::scsi",
|
||||
phase = "enter",
|
||||
opcode = opcode,
|
||||
timeout_ms,
|
||||
data_len = data.len(),
|
||||
fd = self.fd,
|
||||
"SgIoTransport::execute"
|
||||
);
|
||||
|
||||
// Check if a background recovery has produced a new fd.
|
||||
let recovered = self
|
||||
.fd_recovery
|
||||
.swap(-1, std::sync::atomic::Ordering::Acquire);
|
||||
if recovered >= 0 {
|
||||
// Close the old fd if it's still valid.
|
||||
if self.fd >= 0 {
|
||||
unsafe { libc::close(self.fd) };
|
||||
}
|
||||
self.fd = recovered;
|
||||
} else if self.fd < 0 {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: self.device_path.display().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
if data.len() > u32::MAX as usize {
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: super::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
|
||||
let dxfer_direction = match direction {
|
||||
DataDirection::None => SG_DXFER_NONE,
|
||||
DataDirection::FromDevice => SG_DXFER_FROM_DEV,
|
||||
DataDirection::ToDevice => SG_DXFER_TO_DEV,
|
||||
};
|
||||
|
||||
if data.len() > u32::MAX as usize {
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: 0xFF,
|
||||
sense_key: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let cmd_len = cdb.len().min(16) as u8;
|
||||
|
||||
let mut sense = [0u8; 32];
|
||||
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
|
||||
hdr.interface_id = b'S' as i32;
|
||||
hdr.dxfer_direction = dxfer_direction;
|
||||
@@ -281,38 +278,120 @@ impl ScsiTransport for SgIoTransport {
|
||||
hdr.cmdp = cdb.as_ptr();
|
||||
hdr.sbp = sense.as_mut_ptr();
|
||||
hdr.timeout = timeout_ms;
|
||||
if dxfer_direction == SG_DXFER_FROM_DEV
|
||||
&& data.len() >= 4096
|
||||
&& (data.as_ptr() as usize) % 4096 == 0
|
||||
{
|
||||
hdr.flags = SG_FLAG_DIRECT_IO | SG_FLAG_Q_AT_HEAD;
|
||||
} else {
|
||||
hdr.flags = SG_FLAG_Q_AT_HEAD;
|
||||
}
|
||||
hdr.flags = SG_FLAG_Q_AT_HEAD;
|
||||
|
||||
// The single blocking syscall. Returns when the device responds,
|
||||
// when the kernel's timeout fires, or when the kernel's error
|
||||
// recovery completes its escalation. On a healthy read this is
|
||||
// <100 ms; on a slow-recovery bad sector it can be tens of
|
||||
// seconds; on a hung drive it returns at `timeout_ms` with
|
||||
// `host_status` flagged.
|
||||
let ret = unsafe { libc::ioctl(self.fd, SG_IO as _, &mut hdr as *mut sg_io_hdr) };
|
||||
let exec_elapsed_ms = exec_t0.elapsed().as_millis() as u64;
|
||||
|
||||
if ret < 0 {
|
||||
return Err(Error::IoError {
|
||||
source: std::io::Error::last_os_error(),
|
||||
let errno = std::io::Error::last_os_error();
|
||||
tracing::trace!(
|
||||
target: "freemkv::scsi",
|
||||
phase = "ioctl_err",
|
||||
opcode = opcode,
|
||||
errno = errno.raw_os_error().unwrap_or(0),
|
||||
exec_elapsed_ms,
|
||||
"ioctl(SG_IO) returned <0"
|
||||
);
|
||||
return Err(Error::IoError { source: errno });
|
||||
}
|
||||
|
||||
// Transport-level failure (kernel timeout, USB bridge wedge,
|
||||
// bus error). `hdr.status` may still be zero — the SCSI device
|
||||
// never got to send a status byte. Surface as 0xFF so callers
|
||||
// (e.g. `drive_has_disc`) can detect the wedge signature.
|
||||
//
|
||||
// 0.13.23: mask out `DRIVER_SENSE` (0x08) before treating
|
||||
// `driver_status` as a transport failure. That bit is set on
|
||||
// *every* CHECK CONDITION reply just to flag "sense data is
|
||||
// attached in `sbp`" — it's not an error of its own. Pre-fix
|
||||
// we collapsed every drive-reported error into a synthetic
|
||||
// 0xFF wedge signature and discarded the sense data, which
|
||||
// killed the rip's classification logic on damaged discs.
|
||||
let driver_status_real = hdr.driver_status & !super::DRIVER_SENSE;
|
||||
if hdr.host_status != 0 || driver_status_real != 0 {
|
||||
tracing::trace!(
|
||||
target: "freemkv::scsi",
|
||||
phase = "transport_err",
|
||||
opcode = opcode,
|
||||
host_status = hdr.host_status,
|
||||
driver_status = hdr.driver_status,
|
||||
status = hdr.status,
|
||||
exec_elapsed_ms,
|
||||
"transport-level failure (timeout / bridge wedge)"
|
||||
);
|
||||
|
||||
// Spawn recovery: close old fd, open new one in background.
|
||||
// This prevents the main thread from blocking on close() while
|
||||
// the kernel finishes the previous ioctl.
|
||||
let old_fd = self.fd;
|
||||
self.fd = -1;
|
||||
let path = self.device_path.clone();
|
||||
let recovery = self.fd_recovery.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
if old_fd >= 0 {
|
||||
unsafe { libc::close(old_fd) };
|
||||
}
|
||||
});
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let c_path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap();
|
||||
let new_fd = unsafe {
|
||||
libc::open(
|
||||
c_path.as_ptr() as *const libc::c_char,
|
||||
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
recovery.store(new_fd, std::sync::atomic::Ordering::Release);
|
||||
});
|
||||
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: super::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
|
||||
// SCSI-level failure: device responded, returned non-zero status
|
||||
// (typically 0x02 CHECK CONDITION). Parse the full SPC-4 sense
|
||||
// triple so callers can route on `ScsiSense::is_medium_error()`
|
||||
// etc.
|
||||
if hdr.status != 0 {
|
||||
let parsed = super::parse_sense(&sense, hdr.sb_len_wr);
|
||||
tracing::trace!(
|
||||
target: "freemkv::scsi",
|
||||
phase = "scsi_err",
|
||||
opcode = opcode,
|
||||
status = hdr.status,
|
||||
sense_key = parsed.sense_key,
|
||||
asc = parsed.asc,
|
||||
ascq = parsed.ascq,
|
||||
exec_elapsed_ms,
|
||||
"SCSI status non-zero"
|
||||
);
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: hdr.status,
|
||||
sense: Some(parsed),
|
||||
});
|
||||
}
|
||||
|
||||
let bytes_transferred = (data.len() as i32).saturating_sub(hdr.resid).max(0) as usize;
|
||||
|
||||
if hdr.status != 0 {
|
||||
let sense_key = if hdr.sb_len_wr > 2 {
|
||||
sense[2] & 0x0F
|
||||
} else {
|
||||
0
|
||||
};
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: hdr.status,
|
||||
sense_key,
|
||||
});
|
||||
}
|
||||
|
||||
tracing::trace!(
|
||||
target: "freemkv::scsi",
|
||||
phase = "ok",
|
||||
opcode = opcode,
|
||||
bytes_transferred,
|
||||
exec_elapsed_ms,
|
||||
"execute() success"
|
||||
);
|
||||
Ok(ScsiResult {
|
||||
status: hdr.status,
|
||||
bytes_transferred,
|
||||
@@ -320,3 +399,189 @@ 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 — synthesised by `execute()`
|
||||
// from a non-zero `host_status`) the error bubbles directly to the caller;
|
||||
// no in-library reset escalation. See the rationale block on
|
||||
// `drive_has_disc` below.
|
||||
|
||||
/// 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";
|
||||
|
||||
/// 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Read sysfs-cached identity first. The kernel runs its own INQUIRY
|
||||
// at device probe time and stashes vendor/model/rev under
|
||||
// `/sys/class/scsi_generic/sgN/device/`. Those values survive even
|
||||
// when the drive firmware is wedged below the USB bridge (our own
|
||||
// INQUIRY times out but sysfs still has the pre-wedge answer), so
|
||||
// the UI always has a human-readable identity to show.
|
||||
let (sysfs_vendor, sysfs_model, sysfs_firmware) = sysfs_identity(&name);
|
||||
|
||||
// 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.
|
||||
let info = match SgIoTransport::open(std::path::Path::new(&path)) {
|
||||
Ok(mut transport) => match super::inquiry(&mut transport) {
|
||||
Ok(r) => super::DriveInfo {
|
||||
path: path.clone(),
|
||||
vendor: pick_identity(r.vendor_id, &sysfs_vendor),
|
||||
model: pick_identity(r.model, &sysfs_model),
|
||||
firmware: pick_identity(r.firmware, &sysfs_firmware),
|
||||
},
|
||||
Err(_) => super::DriveInfo {
|
||||
path: path.clone(),
|
||||
vendor: sysfs_vendor,
|
||||
model: sysfs_model,
|
||||
firmware: sysfs_firmware,
|
||||
},
|
||||
},
|
||||
Err(_) => super::DriveInfo {
|
||||
path: path.clone(),
|
||||
vendor: sysfs_vendor,
|
||||
model: sysfs_model,
|
||||
firmware: sysfs_firmware,
|
||||
},
|
||||
};
|
||||
out.push(info);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Prefer the live INQUIRY answer over the sysfs-cached one, but fall
|
||||
/// back to sysfs when the live answer is empty (wedge / bridge bug).
|
||||
fn pick_identity(live: String, sysfs: &str) -> String {
|
||||
let trimmed = live.trim();
|
||||
if trimmed.is_empty() {
|
||||
sysfs.to_string()
|
||||
} else {
|
||||
live
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the kernel's cached INQUIRY identity strings for `sgN` from
|
||||
/// `/sys/class/scsi_generic/sgN/device/{vendor,model,rev}`. Empty strings
|
||||
/// when sysfs is unavailable (minimal container, non-Linux filesystem).
|
||||
fn sysfs_identity(name: &str) -> (String, String, String) {
|
||||
let read = |field: &str| -> String {
|
||||
std::fs::read_to_string(format!("/sys/class/scsi_generic/{name}/device/{field}"))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
(read("vendor"), read("model"), read("rev"))
|
||||
}
|
||||
|
||||
/// 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(_) => {}
|
||||
}
|
||||
}
|
||||
} 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
|
||||
}
|
||||
|
||||
/// Send TEST UNIT READY directly — no transport, no reset, no side effects.
|
||||
pub(super) fn drive_has_disc(path: &Path) -> Result<bool> {
|
||||
let device = SgIoTransport::resolve_to_sg(path);
|
||||
let c_path = SgIoTransport::to_c_path(&device);
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
c_path.as_ptr() as *const libc::c_char,
|
||||
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return SgIoTransport::open_error(&device);
|
||||
}
|
||||
|
||||
let cdb = [crate::scsi::SCSI_TEST_UNIT_READY, 0, 0, 0, 0, 0];
|
||||
let mut sense = [0u8; 32];
|
||||
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
|
||||
hdr.interface_id = b'S' as i32;
|
||||
hdr.dxfer_direction = SG_DXFER_NONE;
|
||||
hdr.cmd_len = cdb.len() as u8;
|
||||
hdr.mx_sb_len = sense.len() as u8;
|
||||
hdr.dxfer_len = 0;
|
||||
hdr.dxferp = std::ptr::null_mut();
|
||||
hdr.cmdp = cdb.as_ptr();
|
||||
hdr.sbp = sense.as_mut_ptr();
|
||||
hdr.timeout = crate::scsi::TUR_TIMEOUT_MS;
|
||||
hdr.flags = SG_FLAG_Q_AT_HEAD;
|
||||
|
||||
let ret = unsafe { libc::ioctl(fd, SG_IO as _, &mut hdr as *mut sg_io_hdr) };
|
||||
unsafe { libc::close(fd) };
|
||||
|
||||
if ret < 0 {
|
||||
return Err(Error::IoError {
|
||||
source: std::io::Error::last_os_error(),
|
||||
});
|
||||
}
|
||||
|
||||
let driver_status_real = hdr.driver_status & !super::DRIVER_SENSE;
|
||||
if hdr.host_status != 0 || driver_status_real != 0 {
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: super::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
|
||||
if hdr.status == 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let parsed = super::parse_sense(&sense, hdr.sb_len_wr);
|
||||
if parsed.is_not_ready() {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: hdr.status,
|
||||
sense: Some(parsed),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+106
-394
@@ -1,174 +1,55 @@
|
||||
//! macOS SCSI transport via IOKit SCSITaskDeviceInterface.
|
||||
//! macOS SCSI transport: IOKit SCSITaskDeviceInterface with exclusive access.
|
||||
//!
|
||||
//! Sends SCSI commands to optical drives through IOKit's SCSI Architecture
|
||||
//! Model family. Accepts BSD device paths like `/dev/disk2` or `/dev/rdisk2`.
|
||||
//! Single dispatch path: **all** CDBs (INQUIRY, READ, REPORT KEY, etc.) go
|
||||
//! through `SCSITaskDeviceInterface::ExecuteTaskSync` — 1:1 with the Linux
|
||||
//! SG_IO backend. The C shim (`macos_shim.c`) handles:
|
||||
//!
|
||||
//! Requires exclusive access to the device — unmount the disc first:
|
||||
//! `diskutil unmountDisk /dev/disk2`
|
||||
//! 1. `diskutil unmountDisk force` on the target device only
|
||||
//! 2. Find `IOBDServices` matching the requested BSD name (walks IOKit
|
||||
//! registry: IOBDServices → IOBDBlockStorageDriver → IOMedia → BSD Name)
|
||||
//! 3. Create `MMCDeviceInterface` → `SCSITaskDeviceInterface`
|
||||
//! 4. `ObtainExclusiveAccess`
|
||||
//! 5. Raw CDB dispatch via `CreateSCSITask` + `ExecuteTaskSync`
|
||||
//!
|
||||
//! Drive enumeration (`list_drives`) uses the IOKit registry directly via
|
||||
//! `shim_list_drives` — no exclusive access, no SCSI commands, no unmounts.
|
||||
|
||||
use super::{DataDirection, ScsiResult, ScsiTransport};
|
||||
use crate::error::{Error, Result};
|
||||
use std::path::Path;
|
||||
|
||||
// ── IOKit / CoreFoundation type aliases ─────────────────────────────────────
|
||||
|
||||
type CFMutableDictionaryRef = *mut std::ffi::c_void;
|
||||
type IOObject = u32;
|
||||
type IOReturn = i32;
|
||||
type MachPort = u32;
|
||||
|
||||
/// Opaque COM interface pointer — `*mut *mut VTable` (double-indirect).
|
||||
/// IOKit plugins use COM-style vtables: the pointer points to a pointer
|
||||
/// to the function table.
|
||||
type ComRef = *mut *mut std::ffi::c_void;
|
||||
|
||||
const K_IO_RETURN_SUCCESS: IOReturn = 0;
|
||||
|
||||
// SCSI data transfer directions (SCSITaskLib.h)
|
||||
const K_SCSI_DATA_TRANSFER_NO_DATA: u8 = 0;
|
||||
const K_SCSI_DATA_TRANSFER_FROM_TARGET: u8 = 1;
|
||||
const K_SCSI_DATA_TRANSFER_TO_TARGET: u8 = 2;
|
||||
|
||||
// SCSI task status values
|
||||
const K_SCSI_TASK_STATUS_GOOD: u8 = 0x00;
|
||||
|
||||
const K_MAX_CDB_SIZE: usize = 16;
|
||||
const K_SENSE_DATA_SIZE: usize = 32;
|
||||
|
||||
// ── IOKit plugin UUIDs ──────────────────────────────────────────────────────
|
||||
// From IOKit/scsi/SCSITaskLib.h
|
||||
|
||||
/// kIOMMCDeviceUserClientTypeID — plugin type for MMC (optical) devices.
|
||||
const K_IO_MMC_DEVICE_USER_CLIENT_TYPE_ID: [u8; 16] = [
|
||||
0x97, 0xAB, 0xCF, 0x5C, 0x45, 0x71, 0x11, 0xD6, 0xB6, 0xA0, 0x00, 0x30, 0x65, 0xA4, 0x7A, 0xEE,
|
||||
];
|
||||
|
||||
/// kIOCFPlugInInterfaceID — base IOCFPlugin interface.
|
||||
const K_IO_CFPLUGIN_INTERFACE_ID: [u8; 16] = [
|
||||
0xC2, 0x44, 0xE8, 0x58, 0x10, 0x9C, 0x11, 0xD4, 0x91, 0xD4, 0x00, 0x50, 0xE4, 0xC6, 0x42, 0x6F,
|
||||
];
|
||||
|
||||
/// kIOSCSITaskDeviceInterfaceID — the interface we QueryInterface for.
|
||||
const K_IO_SCSI_TASK_DEVICE_INTERFACE_ID: [u8; 16] = [
|
||||
0x61, 0x3E, 0x48, 0xB0, 0x30, 0x01, 0x11, 0xD6, 0xA4, 0xC0, 0x00, 0x0A, 0x27, 0x05, 0x28, 0x61,
|
||||
];
|
||||
|
||||
// ── Scatter/gather element ──────────────────────────────────────────────────
|
||||
|
||||
#[repr(C)]
|
||||
struct SCSITaskSGElement {
|
||||
address: u64,
|
||||
length: u64,
|
||||
#[derive(Copy, Clone)]
|
||||
struct ShimDriveInfo {
|
||||
bsd_name: [u8; 32],
|
||||
vendor: [u8; 32],
|
||||
model: [u8; 48],
|
||||
firmware: [u8; 16],
|
||||
}
|
||||
|
||||
// ── External IOKit / CoreFoundation functions ───────────────────────────────
|
||||
|
||||
extern "C" {
|
||||
fn IOMasterPort(bootstrap: u32, master: *mut MachPort) -> IOReturn;
|
||||
fn IOBSDNameMatching(
|
||||
master: MachPort,
|
||||
options: u32,
|
||||
bsd_name: *const u8,
|
||||
) -> CFMutableDictionaryRef;
|
||||
fn IOServiceGetMatchingService(master: MachPort, matching: CFMutableDictionaryRef) -> IOObject;
|
||||
fn IOObjectRelease(object: IOObject) -> IOReturn;
|
||||
fn IORegistryEntryGetParentEntry(
|
||||
entry: IOObject,
|
||||
plane: *const u8,
|
||||
parent: *mut IOObject,
|
||||
) -> IOReturn;
|
||||
fn IOObjectConformsTo(object: IOObject, class_name: *const u8) -> u8;
|
||||
fn IOCreatePlugInInterfaceForService(
|
||||
service: IOObject,
|
||||
plugin_type: *const [u8; 16],
|
||||
interface_type: *const [u8; 16],
|
||||
the_interface: *mut ComRef,
|
||||
the_score: *mut i32,
|
||||
) -> IOReturn;
|
||||
unsafe extern "C" {
|
||||
fn shim_open_exclusive(bsd_name: *const u8) -> i32;
|
||||
fn shim_close();
|
||||
fn shim_execute(
|
||||
cdb: *const u8,
|
||||
cdb_len: u8,
|
||||
buf: *mut u8,
|
||||
buf_len: u32,
|
||||
data_in: i32,
|
||||
sense_out: *mut u8,
|
||||
sense_len: u32,
|
||||
task_status_out: *mut u8,
|
||||
transfer_count: *mut u64,
|
||||
) -> i32;
|
||||
fn shim_list_drives(out: *mut ShimDriveInfo, max_entries: i32) -> i32;
|
||||
}
|
||||
|
||||
// ── COM vtable helpers ──────────────────────────────────────────────────────
|
||||
//
|
||||
// IOKit plugin interfaces use COM-style vtables. A ComRef is **vtable —
|
||||
// dereferencing once gives the vtable pointer, then index into it for
|
||||
// individual function pointers.
|
||||
//
|
||||
// All vtable indices verified against Apple open source:
|
||||
// IOSCSIArchitectureModelFamily/UserClientLib/SCSITaskLib.h
|
||||
|
||||
/// Read a function pointer from a COM vtable at the given index.
|
||||
unsafe fn vtable_fn<T>(iface: ComRef, index: usize) -> T {
|
||||
let vtable = *iface as *const *const std::ffi::c_void;
|
||||
let fn_ptr = *vtable.add(index);
|
||||
std::mem::transmute_copy(&fn_ptr)
|
||||
}
|
||||
|
||||
/// Call Release (vtable index 3) on any COM interface.
|
||||
fn com_release(iface: ComRef) {
|
||||
type Fn = unsafe extern "C" fn(ComRef) -> u32;
|
||||
unsafe {
|
||||
let f: Fn = vtable_fn(iface, 3);
|
||||
f(iface);
|
||||
}
|
||||
}
|
||||
|
||||
// ── SCSITaskDeviceInterface vtable ──────────────────────────────────────────
|
||||
//
|
||||
// Index Method
|
||||
// 0 _reserved
|
||||
// 1 QueryInterface
|
||||
// 2 AddRef
|
||||
// 3 Release
|
||||
// 4 IsExclusiveAccessAvailable
|
||||
// 5 AddCallbackDispatcherToRunLoop
|
||||
// 6 RemoveCallbackDispatcherFromRunLoop
|
||||
// 7 ObtainExclusiveAccess
|
||||
// 8 ReleaseExclusiveAccess
|
||||
// 9 CreateSCSITask
|
||||
|
||||
const VTIDX_OBTAIN_EXCLUSIVE: usize = 7;
|
||||
const VTIDX_RELEASE_EXCLUSIVE: usize = 8;
|
||||
const VTIDX_CREATE_TASK: usize = 9;
|
||||
|
||||
// ── SCSITaskInterface vtable ────────────────────────────────────────────────
|
||||
//
|
||||
// Index Method
|
||||
// 0 _reserved
|
||||
// 1 QueryInterface
|
||||
// 2 AddRef
|
||||
// 3 Release
|
||||
// 4 IsTaskActive
|
||||
// 5 SetTaskAttribute
|
||||
// 6 GetTaskAttribute
|
||||
// 7 GetTaskState
|
||||
// 8 SetCommandDescriptorBlock
|
||||
// 9 GetCommandDescriptorBlockSize
|
||||
// 10 GetCommandDescriptorBlock
|
||||
// 11 SetScatterGatherEntries
|
||||
// 12 SetTimeoutDuration
|
||||
// 13 GetTimeoutDuration
|
||||
// 14 SetTaskCompletionCallback
|
||||
// 15 ExecuteTaskSync
|
||||
// 16 ExecuteTaskAsync
|
||||
// 17 AbortTask
|
||||
// 18 GetSCSIServiceResponse
|
||||
// 19 GetTaskStatus
|
||||
// 20 GetRealizedDataTransferCount
|
||||
// 21 GetAutoSenseData
|
||||
|
||||
const VTIDX_SET_CDB: usize = 8;
|
||||
const VTIDX_SET_SG: usize = 11;
|
||||
const VTIDX_SET_TIMEOUT: usize = 12;
|
||||
const VTIDX_EXECUTE_SYNC: usize = 15;
|
||||
|
||||
// ── Transport implementation ────────────────────────────────────────────────
|
||||
|
||||
pub struct MacScsiTransport {
|
||||
device_iface: ComRef,
|
||||
exclusive: bool,
|
||||
_bsd_name: String,
|
||||
}
|
||||
|
||||
// IOKit COM interface pointers are Mach port references — safe to send between threads.
|
||||
unsafe impl Send for MacScsiTransport {}
|
||||
|
||||
impl MacScsiTransport {
|
||||
@@ -177,7 +58,6 @@ impl MacScsiTransport {
|
||||
path: device.display().to_string(),
|
||||
})?;
|
||||
|
||||
// Strip /dev/ prefix to get BSD name (e.g. "disk2")
|
||||
let bsd_name = if let Some(rest) = dev_str.strip_prefix("/dev/r") {
|
||||
rest
|
||||
} else if let Some(rest) = dev_str.strip_prefix("/dev/") {
|
||||
@@ -186,98 +66,25 @@ impl MacScsiTransport {
|
||||
dev_str
|
||||
};
|
||||
|
||||
let service = find_scsi_service(bsd_name)?;
|
||||
let mut bsd_c = bsd_name.as_bytes().to_vec();
|
||||
bsd_c.push(0);
|
||||
|
||||
// Create IOKit plugin for the MMC device
|
||||
let mut plugin: ComRef = std::ptr::null_mut();
|
||||
let mut score: i32 = 0;
|
||||
let kr = unsafe {
|
||||
IOCreatePlugInInterfaceForService(
|
||||
service,
|
||||
&K_IO_MMC_DEVICE_USER_CLIENT_TYPE_ID,
|
||||
&K_IO_CFPLUGIN_INTERFACE_ID,
|
||||
&mut plugin,
|
||||
&mut score,
|
||||
)
|
||||
};
|
||||
unsafe { IOObjectRelease(service) };
|
||||
|
||||
if kr != K_IO_RETURN_SUCCESS || plugin.is_null() {
|
||||
let rc = unsafe { shim_open_exclusive(bsd_c.as_ptr()) };
|
||||
if rc != 0 {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: format!("{}: IOKit plugin creation failed (0x{:08x})", dev_str, kr),
|
||||
});
|
||||
}
|
||||
|
||||
// QueryInterface for SCSITaskDeviceInterface
|
||||
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_SCSI_TASK_DEVICE_INTERFACE_ID,
|
||||
&mut device_iface,
|
||||
)
|
||||
};
|
||||
com_release(plugin);
|
||||
|
||||
if hr != 0 || device_iface.is_null() {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: format!("{}: SCSITaskDeviceInterface not available", dev_str),
|
||||
});
|
||||
}
|
||||
|
||||
// Obtain exclusive access
|
||||
let kr = unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef) -> IOReturn;
|
||||
let f: Fn = vtable_fn(device_iface, VTIDX_OBTAIN_EXCLUSIVE);
|
||||
f(device_iface)
|
||||
};
|
||||
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
|
||||
),
|
||||
path: bsd_name.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(MacScsiTransport {
|
||||
device_iface,
|
||||
exclusive: true,
|
||||
_bsd_name: bsd_name.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Reset the drive to a known good state.
|
||||
/// On macOS, we open the device, release exclusive access, wait for
|
||||
/// the system to reclaim it, then the next open() re-acquires.
|
||||
/// IOKit's USB layer handles device-level resets internally when the
|
||||
/// exclusive access is released and re-acquired.
|
||||
///
|
||||
/// NOTE: untested — macOS reset may need IOUSBDeviceInterface::ResetDevice()
|
||||
/// for USB drives. This is a best-effort implementation.
|
||||
pub fn reset(device: &Path) -> Result<()> {
|
||||
// Opening and immediately dropping triggers release of exclusive access
|
||||
// which forces IOKit to reset the device state.
|
||||
if let Ok(transport) = Self::open(device) {
|
||||
drop(transport); // Drop releases exclusive access + closes plugin
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MacScsiTransport {
|
||||
fn drop(&mut self) {
|
||||
if self.exclusive {
|
||||
unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef) -> IOReturn;
|
||||
let f: Fn = vtable_fn(self.device_iface, VTIDX_RELEASE_EXCLUSIVE);
|
||||
f(self.device_iface);
|
||||
}
|
||||
}
|
||||
com_release(self.device_iface);
|
||||
unsafe { shim_close() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,197 +94,102 @@ impl ScsiTransport for MacScsiTransport {
|
||||
cdb: &[u8],
|
||||
direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
_timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
// Create a SCSI task
|
||||
let task: ComRef = unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef) -> ComRef;
|
||||
let f: Fn = vtable_fn(self.device_iface, VTIDX_CREATE_TASK);
|
||||
f(self.device_iface)
|
||||
};
|
||||
if task.is_null() {
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: 0xFF,
|
||||
sense_key: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Set CDB
|
||||
let mut cdb_padded = [0u8; K_MAX_CDB_SIZE];
|
||||
let cdb_len = cdb.len().min(K_MAX_CDB_SIZE);
|
||||
cdb_padded[..cdb_len].copy_from_slice(&cdb[..cdb_len]);
|
||||
unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef, *const u8, u8) -> IOReturn;
|
||||
let f: Fn = vtable_fn(task, VTIDX_SET_CDB);
|
||||
f(task, cdb_padded.as_ptr(), cdb_len as u8);
|
||||
}
|
||||
|
||||
// Set scatter/gather and transfer direction
|
||||
let iokit_dir = match direction {
|
||||
DataDirection::None => K_SCSI_DATA_TRANSFER_NO_DATA,
|
||||
DataDirection::FromDevice => K_SCSI_DATA_TRANSFER_FROM_TARGET,
|
||||
DataDirection::ToDevice => K_SCSI_DATA_TRANSFER_TO_TARGET,
|
||||
let data_in = match direction {
|
||||
DataDirection::FromDevice => 1,
|
||||
DataDirection::ToDevice => 0,
|
||||
DataDirection::None => 0,
|
||||
};
|
||||
|
||||
if direction != DataDirection::None && !data.is_empty() {
|
||||
let sg = SCSITaskSGElement {
|
||||
address: data.as_mut_ptr() as u64,
|
||||
length: data.len() as u64,
|
||||
};
|
||||
unsafe {
|
||||
type Fn =
|
||||
unsafe extern "C" fn(ComRef, *const SCSITaskSGElement, u8, u64, u8) -> IOReturn;
|
||||
let f: Fn = vtable_fn(task, VTIDX_SET_SG);
|
||||
f(task, &sg, 1, data.len() as u64, iokit_dir);
|
||||
}
|
||||
} else {
|
||||
unsafe {
|
||||
type Fn =
|
||||
unsafe extern "C" fn(ComRef, *const SCSITaskSGElement, u8, u64, u8) -> IOReturn;
|
||||
let f: Fn = vtable_fn(task, VTIDX_SET_SG);
|
||||
f(task, std::ptr::null(), 0, 0, K_SCSI_DATA_TRANSFER_NO_DATA);
|
||||
}
|
||||
}
|
||||
|
||||
// Set timeout (IOKit SCSITask takes milliseconds)
|
||||
unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef, u32);
|
||||
let f: Fn = vtable_fn(task, VTIDX_SET_TIMEOUT);
|
||||
f(task, timeout_ms);
|
||||
}
|
||||
|
||||
// Execute synchronously
|
||||
let mut sense = [0u8; K_SENSE_DATA_SIZE];
|
||||
let mut task_status: u32 = 0;
|
||||
let mut realized_count: u64 = 0;
|
||||
let mut task_status: u8 = 0xFF;
|
||||
let mut transfer_count: u64 = 0;
|
||||
|
||||
let kr = unsafe {
|
||||
type Fn = unsafe extern "C" fn(ComRef, *mut u8, *mut u32, *mut u64) -> IOReturn;
|
||||
let f: Fn = vtable_fn(task, VTIDX_EXECUTE_SYNC);
|
||||
f(
|
||||
task,
|
||||
shim_execute(
|
||||
cdb.as_ptr(),
|
||||
cdb.len() as u8,
|
||||
data.as_mut_ptr(),
|
||||
data.len() as u32,
|
||||
data_in,
|
||||
sense.as_mut_ptr(),
|
||||
K_SENSE_DATA_SIZE as u32,
|
||||
&mut task_status,
|
||||
&mut realized_count,
|
||||
&mut transfer_count,
|
||||
)
|
||||
};
|
||||
|
||||
com_release(task);
|
||||
|
||||
if kr != K_IO_RETURN_SUCCESS {
|
||||
if kr != 0 {
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: 0xFF,
|
||||
sense_key: 0,
|
||||
status: super::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
|
||||
if task_status != K_SCSI_TASK_STATUS_GOOD as u32 {
|
||||
let sense_key = if sense[2] != 0 { sense[2] & 0x0F } else { 0 };
|
||||
if task_status != 0 {
|
||||
let parsed = super::parse_sense(&sense, K_SENSE_DATA_SIZE as u8);
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: task_status as u8,
|
||||
sense_key,
|
||||
status: task_status,
|
||||
sense: Some(parsed),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ScsiResult {
|
||||
status: task_status as u8,
|
||||
bytes_transferred: realized_count as usize,
|
||||
status: 0,
|
||||
bytes_transferred: transfer_count as usize,
|
||||
sense,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── IOKit service discovery ─────────────────────────────────────────────────
|
||||
// ── Drive enumeration (registry-based, no exclusive access) ──────────────
|
||||
|
||||
/// BSD name → IOKit service for the SCSI device.
|
||||
///
|
||||
/// Walk: IOMedia (BSD name match) → parent chain → SCSIPeripheralDeviceNub.
|
||||
fn find_scsi_service(bsd_name: &str) -> Result<IOObject> {
|
||||
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),
|
||||
pub(super) fn list_drives() -> Vec<super::DriveInfo> {
|
||||
let mut buf = [ShimDriveInfo {
|
||||
bsd_name: [0; 32],
|
||||
vendor: [0; 32],
|
||||
model: [0; 48],
|
||||
firmware: [0; 16],
|
||||
}; 8];
|
||||
|
||||
let count = unsafe { shim_list_drives(buf.as_mut_ptr(), buf.len() as i32) };
|
||||
|
||||
let mut out = Vec::new();
|
||||
for info in buf.iter().take((count as usize).min(buf.len())) {
|
||||
let bsd_name = cstr_to_str(&info.bsd_name);
|
||||
if bsd_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(super::DriveInfo {
|
||||
path: format!("/dev/{bsd_name}"),
|
||||
vendor: cstr_to_str(&info.vendor).to_string(),
|
||||
model: cstr_to_str(&info.model).to_string(),
|
||||
firmware: cstr_to_str(&info.firmware).to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// IOBSDNameMatching creates a dictionary matching { "BSD Name" = bsd_name }
|
||||
let mut bsd_c = bsd_name.as_bytes().to_vec();
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
// 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),
|
||||
});
|
||||
}
|
||||
|
||||
// Walk up the IOService plane to find the authoring device.
|
||||
// The chain is typically:
|
||||
// IOMedia → IOPartitionScheme → IOMedia → IOBlockStorageDriver
|
||||
// → IOSCSIPeripheralDeviceNub (this is what we want)
|
||||
//
|
||||
// We walk up until we find a service that IOCreatePlugInInterfaceForService
|
||||
// accepts with kIOMMCDeviceUserClientTypeID, or until we hit the root.
|
||||
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),
|
||||
})
|
||||
out
|
||||
}
|
||||
|
||||
/// Walk up the IOService plane from an IOMedia to the SCSI authoring device.
|
||||
fn walk_to_authoring_device(start: IOObject) -> Option<IOObject> {
|
||||
let mut current = start;
|
||||
// Retain start so we can release uniformly in the loop
|
||||
// (IORegistryEntryGetParentEntry retains the parent for us)
|
||||
fn cstr_to_str(bytes: &[u8]) -> &str {
|
||||
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
||||
std::str::from_utf8(&bytes[..end]).unwrap_or("")
|
||||
}
|
||||
|
||||
// Target class names for authoring devices
|
||||
let target_classes: &[&[u8]] = &[
|
||||
b"IOSCSIPeripheralDeviceNub\0",
|
||||
b"IOBDBlockStorageDevice\0",
|
||||
b"IODVDBlockStorageDevice\0",
|
||||
b"IOCDBlockStorageDevice\0",
|
||||
b"IOBlockStorageDevice\0",
|
||||
];
|
||||
|
||||
// Walk up to 10 levels (more than enough)
|
||||
for _ in 0..10 {
|
||||
let mut parent: IOObject = 0;
|
||||
let kr =
|
||||
unsafe { IORegistryEntryGetParentEntry(current, b"IOService\0".as_ptr(), &mut parent) };
|
||||
|
||||
if current != start {
|
||||
unsafe { IOObjectRelease(current) };
|
||||
}
|
||||
|
||||
if kr != K_IO_RETURN_SUCCESS || parent == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check if this parent matches any of our target classes
|
||||
for class in target_classes {
|
||||
if unsafe { IOObjectConformsTo(parent, class.as_ptr()) } != 0 {
|
||||
return Some(parent);
|
||||
}
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
if current != start {
|
||||
unsafe { IOObjectRelease(current) };
|
||||
pub(super) fn drive_has_disc(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];
|
||||
match transport.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::None,
|
||||
&mut buf,
|
||||
crate::scsi::TUR_TIMEOUT_MS,
|
||||
) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(ref e) if e.scsi_sense().is_some_and(|s| s.is_not_ready()) => Ok(false),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
#include <IOKit/IOKitLib.h>
|
||||
#include <IOKit/IOCFPlugIn.h>
|
||||
#include <IOKit/scsi/SCSITaskLib.h>
|
||||
#include <CoreFoundation/CoreFoundation.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
typedef struct {
|
||||
IOCFPlugInInterface **plugin;
|
||||
MMCDeviceInterface **mmc;
|
||||
SCSITaskDeviceInterface **scsi;
|
||||
int exclusive;
|
||||
} ShimHandle;
|
||||
|
||||
typedef struct {
|
||||
char bsd_name[32];
|
||||
char vendor[32];
|
||||
char model[48];
|
||||
char firmware[16];
|
||||
} ShimDriveInfo;
|
||||
|
||||
// ── Global handle (single-drive, same as before) ──────────────────────────
|
||||
|
||||
static ShimHandle g_handle = {NULL, NULL, NULL, 0};
|
||||
|
||||
// ── Registry helpers ──────────────────────────────────────────────────────
|
||||
|
||||
static int cfstring_to_cstr(CFStringRef cf, char *buf, size_t buflen) {
|
||||
if (!cf) return 0;
|
||||
if (!CFStringGetCString(cf, buf, buflen, kCFStringEncodingUTF8)) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int registry_entry_bsd_name(io_registry_entry_t entry, char *buf, size_t buflen) {
|
||||
CFStringRef cf = IORegistryEntryCreateCFProperty(entry, CFSTR("BSD Name"),
|
||||
kCFAllocatorDefault, 0);
|
||||
if (!cf) return 0;
|
||||
int ok = cfstring_to_cstr(cf, buf, buflen);
|
||||
CFRelease(cf);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static io_registry_entry_t find_iomedia_child(io_registry_entry_t parent) {
|
||||
io_iterator_t iter;
|
||||
kern_return_t kr = IORegistryEntryGetChildIterator(parent, kIOServicePlane, &iter);
|
||||
if (kr != KERN_SUCCESS) return 0;
|
||||
|
||||
io_registry_entry_t child;
|
||||
while ((child = IOIteratorNext(iter)) != 0) {
|
||||
char cls[128];
|
||||
kr = IOObjectGetClass(child, cls);
|
||||
if (kr == KERN_SUCCESS) {
|
||||
if (strcmp(cls, "IOMedia") == 0 || strcmp(cls, "IOBDMedia") == 0) {
|
||||
IOObjectRelease(iter);
|
||||
return child;
|
||||
}
|
||||
}
|
||||
IOObjectRelease(child);
|
||||
}
|
||||
IOObjectRelease(iter);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static io_registry_entry_t find_child_of_class(io_registry_entry_t parent, const char *target_class) {
|
||||
io_iterator_t iter;
|
||||
kern_return_t kr = IORegistryEntryGetChildIterator(parent, kIOServicePlane, &iter);
|
||||
if (kr != KERN_SUCCESS) return 0;
|
||||
|
||||
io_registry_entry_t child;
|
||||
while ((child = IOIteratorNext(iter)) != 0) {
|
||||
char cls[128];
|
||||
kr = IOObjectGetClass(child, cls);
|
||||
if (kr == KERN_SUCCESS && strcmp(cls, target_class) == 0) {
|
||||
IOObjectRelease(iter);
|
||||
return child;
|
||||
}
|
||||
IOObjectRelease(child);
|
||||
}
|
||||
IOObjectRelease(iter);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static io_registry_entry_t find_parent_of_class(io_registry_entry_t entry, const char *target_class) {
|
||||
io_registry_entry_t parent;
|
||||
kern_return_t kr = IORegistryEntryGetParentEntry(entry, kIOServicePlane, &parent);
|
||||
if (kr != KERN_SUCCESS) return 0;
|
||||
|
||||
char cls[128];
|
||||
kr = IOObjectGetClass(parent, cls);
|
||||
if (kr == KERN_SUCCESS && strcmp(cls, target_class) == 0) {
|
||||
return parent;
|
||||
}
|
||||
IOObjectRelease(parent);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Given an IOBDServices, find the BSD name of its IOMedia child.
|
||||
// Chain: IOBDServices -> IOBDBlockStorageDriver -> IOMedia (has "BSD Name")
|
||||
static int bdsvc_to_bsd_name(io_registry_entry_t bdsvc, char *buf, size_t buflen) {
|
||||
io_registry_entry_t driver = find_child_of_class(bdsvc, "IOBDBlockStorageDriver");
|
||||
if (!driver) return 0;
|
||||
|
||||
io_registry_entry_t media = find_iomedia_child(driver);
|
||||
IOObjectRelease(driver);
|
||||
if (!media) return 0;
|
||||
|
||||
int ok = registry_entry_bsd_name(media, buf, buflen);
|
||||
IOObjectRelease(media);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Given an IOBDServices, extract Device Characteristics strings.
|
||||
static void bdsvc_device_info(io_registry_entry_t bdsvc, ShimDriveInfo *info) {
|
||||
CFDictionaryRef dc = IORegistryEntryCreateCFProperty(bdsvc,
|
||||
CFSTR("Device Characteristics"), kCFAllocatorDefault, 0);
|
||||
if (!dc) return;
|
||||
|
||||
CFStringRef val;
|
||||
|
||||
val = CFDictionaryGetValue(dc, CFSTR("Vendor Name"));
|
||||
if (val) cfstring_to_cstr(val, info->vendor, sizeof(info->vendor));
|
||||
|
||||
val = CFDictionaryGetValue(dc, CFSTR("Product Name"));
|
||||
if (val) cfstring_to_cstr(val, info->model, sizeof(info->model));
|
||||
|
||||
val = CFDictionaryGetValue(dc, CFSTR("Product Revision Level"));
|
||||
if (val) cfstring_to_cstr(val, info->firmware, sizeof(info->firmware));
|
||||
|
||||
CFRelease(dc);
|
||||
}
|
||||
|
||||
// Find the IOBDServices that owns the given BSD name.
|
||||
// Returns a retained io_service_t (caller must release), or 0.
|
||||
static io_service_t find_bdsvc_by_bsd_name(mach_port_t mp, const char *bsd_name) {
|
||||
CFMutableDictionaryRef matching = IOServiceMatching("IOBDServices");
|
||||
if (!matching) return 0;
|
||||
|
||||
io_iterator_t iter;
|
||||
kern_return_t kr = IOServiceGetMatchingServices(mp, matching, &iter);
|
||||
if (kr != KERN_SUCCESS) return 0;
|
||||
|
||||
io_service_t result = 0;
|
||||
io_service_t svc;
|
||||
while ((svc = IOIteratorNext(iter)) != 0) {
|
||||
char name[64];
|
||||
if (bdsvc_to_bsd_name(svc, name, sizeof(name))) {
|
||||
if (strcmp(name, bsd_name) == 0) {
|
||||
result = svc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
IOObjectRelease(svc);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
IOIteratorReset(iter);
|
||||
while ((svc = IOIteratorNext(iter)) != 0) {
|
||||
IOObjectRelease(svc);
|
||||
}
|
||||
}
|
||||
|
||||
IOObjectRelease(iter);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Find the IOBDServices that owns the given BSD name by walking from
|
||||
// IOMedia upward. Used as fallback when bdsvc_to_bsd_name fails
|
||||
// (e.g. disc under exclusive access, no IOMedia child).
|
||||
// Chain: IOMedia -> IOBDBlockStorageDriver -> IOBDServices
|
||||
static io_service_t find_bdsvc_from_iomedia(mach_port_t mp, const char *bsd_name) {
|
||||
CFMutableDictionaryRef matching = IOServiceMatching("IOMedia");
|
||||
if (!matching) return 0;
|
||||
|
||||
io_iterator_t iter;
|
||||
kern_return_t kr = IOServiceGetMatchingServices(mp, matching, &iter);
|
||||
if (kr != KERN_SUCCESS) return 0;
|
||||
|
||||
io_service_t result = 0;
|
||||
io_service_t media;
|
||||
while ((media = IOIteratorNext(iter)) != 0) {
|
||||
char name[64];
|
||||
if (registry_entry_bsd_name(media, name, sizeof(name))
|
||||
&& strcmp(name, bsd_name) == 0)
|
||||
{
|
||||
io_registry_entry_t driver = find_parent_of_class(media, "IOBDBlockStorageDriver");
|
||||
if (driver) {
|
||||
io_registry_entry_t bdsvc = find_parent_of_class(driver, "IOBDServices");
|
||||
IOObjectRelease(driver);
|
||||
if (bdsvc) {
|
||||
result = bdsvc;
|
||||
IOObjectRelease(media);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
IOObjectRelease(media);
|
||||
}
|
||||
|
||||
IOObjectRelease(iter);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
int shim_open_exclusive(const char *bsd_name) {
|
||||
kern_return_t kr;
|
||||
HRESULT hr;
|
||||
SInt32 score = 0;
|
||||
|
||||
if (g_handle.exclusive && g_handle.scsi) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Use a shell wrapper so the device path is not subject to buffer limits.
|
||||
// snprintf into 128 bytes could truncate long BSD names (e.g. disk12s3s1),
|
||||
// producing a broken command. sh -c with $1 passes the arg via argv.
|
||||
const char *shell_fmt = "sh -c 'diskutil unmountDisk force \"$1\" >/dev/null 2>&1' _ %s";
|
||||
char cmd[512];
|
||||
int written = snprintf(cmd, sizeof(cmd), shell_fmt, bsd_name);
|
||||
if (written < 0 || (size_t)written >= sizeof(cmd)) {
|
||||
return -1;
|
||||
}
|
||||
system(cmd);
|
||||
usleep(500000);
|
||||
|
||||
mach_port_t mp;
|
||||
IOMainPort(0, &mp);
|
||||
|
||||
io_service_t svc = find_bdsvc_by_bsd_name(mp, bsd_name);
|
||||
if (!svc) {
|
||||
svc = find_bdsvc_from_iomedia(mp, bsd_name);
|
||||
}
|
||||
if (!svc) {
|
||||
CFMutableDictionaryRef matching = IOServiceMatching("IOBDServices");
|
||||
svc = IOServiceGetMatchingService(mp, matching);
|
||||
}
|
||||
if (!svc) return -1;
|
||||
|
||||
kr = IOCreatePlugInInterfaceForService(svc,
|
||||
kIOMMCDeviceUserClientTypeID, kIOCFPlugInInterfaceID,
|
||||
&g_handle.plugin, &score);
|
||||
IOObjectRelease(svc);
|
||||
|
||||
if (kr != KERN_SUCCESS || !g_handle.plugin) return -2;
|
||||
|
||||
hr = (*g_handle.plugin)->QueryInterface(g_handle.plugin,
|
||||
CFUUIDGetUUIDBytes(kIOMMCDeviceInterfaceID), (LPVOID *)&g_handle.mmc);
|
||||
if (hr != S_OK || !g_handle.mmc) {
|
||||
IODestroyPlugInInterface(g_handle.plugin);
|
||||
g_handle.plugin = NULL;
|
||||
return -3;
|
||||
}
|
||||
|
||||
g_handle.scsi = (*g_handle.mmc)->GetSCSITaskDeviceInterface(g_handle.mmc);
|
||||
if (!g_handle.scsi) {
|
||||
(*g_handle.mmc)->Release(g_handle.mmc);
|
||||
IODestroyPlugInInterface(g_handle.plugin);
|
||||
g_handle.mmc = NULL;
|
||||
g_handle.plugin = NULL;
|
||||
return -4;
|
||||
}
|
||||
|
||||
for (int retry = 0; retry < 10; retry++) {
|
||||
kr = (*g_handle.scsi)->ObtainExclusiveAccess(g_handle.scsi);
|
||||
if (kr == kIOReturnSuccess) break;
|
||||
usleep(500000);
|
||||
}
|
||||
if (kr != kIOReturnSuccess) {
|
||||
(*g_handle.scsi)->Release(g_handle.scsi);
|
||||
(*g_handle.mmc)->Release(g_handle.mmc);
|
||||
IODestroyPlugInInterface(g_handle.plugin);
|
||||
g_handle.scsi = NULL;
|
||||
g_handle.mmc = NULL;
|
||||
g_handle.plugin = NULL;
|
||||
return -5;
|
||||
}
|
||||
|
||||
g_handle.exclusive = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void shim_close(void) {
|
||||
if (g_handle.exclusive && g_handle.scsi) {
|
||||
(*g_handle.scsi)->ReleaseExclusiveAccess(g_handle.scsi);
|
||||
}
|
||||
if (g_handle.scsi) {
|
||||
(*g_handle.scsi)->Release(g_handle.scsi);
|
||||
g_handle.scsi = NULL;
|
||||
}
|
||||
if (g_handle.mmc) {
|
||||
(*g_handle.mmc)->Release(g_handle.mmc);
|
||||
g_handle.mmc = NULL;
|
||||
}
|
||||
if (g_handle.plugin) {
|
||||
IODestroyPlugInInterface(g_handle.plugin);
|
||||
g_handle.plugin = NULL;
|
||||
}
|
||||
g_handle.exclusive = 0;
|
||||
}
|
||||
|
||||
int shim_execute(const unsigned char *cdb, unsigned char cdb_len,
|
||||
void *buf, unsigned int buf_len, int data_in,
|
||||
unsigned char *sense_out, unsigned int sense_len,
|
||||
unsigned char *task_status_out, unsigned long long *transfer_count) {
|
||||
if (!g_handle.scsi) return -1;
|
||||
|
||||
SCSITaskInterface **task = (*g_handle.scsi)->CreateSCSITask(g_handle.scsi);
|
||||
if (!task) return -2;
|
||||
|
||||
SCSICommandDescriptorBlock cdb_buf;
|
||||
memset(&cdb_buf, 0, sizeof(cdb_buf));
|
||||
memcpy(&cdb_buf, cdb, cdb_len);
|
||||
|
||||
(*task)->SetCommandDescriptorBlock(task, cdb_buf, cdb_len);
|
||||
|
||||
if (buf_len > 0 && buf) {
|
||||
SCSITaskSGElement sg;
|
||||
sg.address = (UInt64)(uintptr_t)buf;
|
||||
sg.length = buf_len;
|
||||
(*task)->SetScatterGatherEntries(task, &sg, 1, buf_len,
|
||||
data_in ? kSCSIDataTransfer_FromTargetToInitiator
|
||||
: kSCSIDataTransfer_FromInitiatorToTarget);
|
||||
} else {
|
||||
(*task)->SetScatterGatherEntries(task, NULL, 0, 0,
|
||||
kSCSIDataTransfer_NoDataTransfer);
|
||||
}
|
||||
|
||||
(*task)->SetTimeoutDuration(task, 30000);
|
||||
|
||||
SCSI_Sense_Data sense;
|
||||
memset(&sense, 0, sizeof(sense));
|
||||
SCSITaskStatus status = 0xFF;
|
||||
UInt64 count = 0;
|
||||
|
||||
IOReturn kr = (*task)->ExecuteTaskSync(task, &sense, &status, &count);
|
||||
|
||||
if (sense_out && sense_len > 0) {
|
||||
size_t copy = sense_len < sizeof(sense) ? sense_len : sizeof(sense);
|
||||
memcpy(sense_out, &sense, copy);
|
||||
}
|
||||
if (task_status_out) *task_status_out = (unsigned char)status;
|
||||
if (transfer_count) *transfer_count = count;
|
||||
|
||||
(*task)->Release(task);
|
||||
|
||||
return (int)kr;
|
||||
}
|
||||
|
||||
// ── Registry-based drive enumeration ──────────────────────────────────────
|
||||
//
|
||||
// Walks IOBDServices entries in the IOKit registry. No exclusive access,
|
||||
// no SCSI commands, no unmounts. Returns up to max_entries drives.
|
||||
|
||||
int shim_list_drives(ShimDriveInfo *out, int max_entries) {
|
||||
mach_port_t mp;
|
||||
IOReturn ret = IOMainPort(0, &mp);
|
||||
if (ret != kIOReturnSuccess) return 0;
|
||||
|
||||
CFMutableDictionaryRef matching = IOServiceMatching("IOBDServices");
|
||||
if (!matching) return 0;
|
||||
|
||||
io_iterator_t iter;
|
||||
kern_return_t kr = IOServiceGetMatchingServices(mp, matching, &iter);
|
||||
if (kr != KERN_SUCCESS) return 0;
|
||||
|
||||
int count = 0;
|
||||
io_service_t svc;
|
||||
while ((svc = IOIteratorNext(iter)) != 0 && count < max_entries) {
|
||||
ShimDriveInfo *info = &out[count];
|
||||
memset(info, 0, sizeof(*info));
|
||||
|
||||
bdsvc_device_info(svc, info);
|
||||
bdsvc_to_bsd_name(svc, info->bsd_name, sizeof(info->bsd_name));
|
||||
|
||||
if (info->bsd_name[0]) {
|
||||
count++;
|
||||
}
|
||||
|
||||
IOObjectRelease(svc);
|
||||
}
|
||||
|
||||
IOObjectRelease(iter);
|
||||
return count;
|
||||
}
|
||||
+495
-12
@@ -2,11 +2,11 @@
|
||||
//!
|
||||
//! Platform backends are in separate files:
|
||||
//! - `linux.rs` — SG_IO ioctl
|
||||
//! - `macos.rs` — IOKit SCSITaskDeviceInterface
|
||||
//! - `macos.rs` — IOKit SCSITaskDeviceInterface (exclusive access)
|
||||
//! - `windows.rs` — SPTI (SCSI Pass-Through Interface)
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
pub mod linux;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -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,262 @@ 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;
|
||||
|
||||
/// Timeout for content READ commands (READ_10 / READ_12) on the fast
|
||||
/// path — the [`disc::Disc::copy`] sweep that bisects-on-failure.
|
||||
///
|
||||
/// 10 s is calibrated from live empirical data on an LG BU40N + Initio
|
||||
/// 1618L bridge ripping a UHD with marginal sectors:
|
||||
///
|
||||
/// - Sustained sequential reads: 3 – 7 ms
|
||||
/// - Cold-start seek + read: up to ~1500 ms
|
||||
/// - Successful ECC recovery: 1.6 – 2.6 sec
|
||||
/// - Confirmed unreadable sector: 3.6 – 8.8 sec (kernel timeout)
|
||||
///
|
||||
/// 10 s catches every legitimate slow read with comfortable margin and
|
||||
/// short-circuits truly bad sectors at ~10 s rather than letting the
|
||||
/// kernel mid-layer escalate for 30 s+. See the SCSI architecture audit
|
||||
/// (2026-04-26) for primary-source references.
|
||||
///
|
||||
/// Pre-0.13.21 this was 1.5 s, which forced the kernel mid-layer to
|
||||
/// time out *normal* reads (cold-start often takes ~1.5 s) and run its
|
||||
/// full ABORT TASK / LUN RESET / BUS RESET escalation while userspace
|
||||
/// kept submitting fresh reads. The Initio bridge couldn't drain the
|
||||
/// resulting command queue and entered a wedge state that only physical
|
||||
/// replug recovered — proven by the v0.13.18 + v0.13.20 live tests.
|
||||
pub(crate) const READ_TIMEOUT_MS: u32 = 10_000;
|
||||
|
||||
/// Timeout for content READ commands on the recovery path —
|
||||
/// [`disc::Disc::patch`]'s targeted retries on bad ranges. Matches
|
||||
/// `sg_dd`'s 60 s ceiling: long enough that any sector the drive can
|
||||
/// recover at all gets the time to do so, short enough that an
|
||||
/// unresponsive bus is detected before the per-range watchdog fires.
|
||||
///
|
||||
/// In practice failed reads return in 1–4 s (the drive itself gives up
|
||||
/// on uncorrectable ECC before the timeout); the 60 s value is a
|
||||
/// safety ceiling, not a steady-state cost.
|
||||
///
|
||||
/// Historical note (2026-05-08): briefly lowered to 2 s with a 5×
|
||||
/// inline retry loop in `Disc::patch` to mimic the kernel `sr_mod`
|
||||
/// driver's auto-retry pattern. The synthetic logic worked but on the
|
||||
/// live drive each "2 s" read paid ~1.5 s of kernel SCSI mid-layer
|
||||
/// error escalation on top, so 5× retries took ~17 s per LBA and
|
||||
/// triggered MAX_RANGE_SECS after 4 sectors — pushing recovery to
|
||||
/// 0/22 ranges (worse than the 0/22 baseline of v0.17.3 single-shot
|
||||
/// at 60 s, since that at least visited every range). Reverted; the
|
||||
/// kernel-auto-retry approach is being pursued via a `/dev/sr0` pread
|
||||
/// fallback instead.
|
||||
pub(crate) const READ_RECOVERY_TIMEOUT_MS: u32 = 60_000;
|
||||
|
||||
// ── SCSI status bytes (SPC-4 §4.5.5) ────────────────────────────────────────
|
||||
|
||||
/// Status byte 0x00 — `GOOD`. Command completed successfully.
|
||||
pub const SCSI_STATUS_GOOD: u8 = 0x00;
|
||||
/// Status byte 0x02 — `CHECK CONDITION`. Drive completed the command
|
||||
/// reply and attached sense data describing the failure.
|
||||
pub const SCSI_STATUS_CHECK_CONDITION: u8 = 0x02;
|
||||
/// libfreemkv-synthesised sentinel: the transport never delivered a
|
||||
/// SCSI status byte (kernel timeout, USB bridge wedge, IOKit service
|
||||
/// failure). Distinct from any drive-returned value. Carriers
|
||||
/// [`Error::ScsiError`] with `sense = None`.
|
||||
pub const SCSI_STATUS_TRANSPORT_FAILURE: u8 = 0xFF;
|
||||
|
||||
// ── SPC-4 sense keys (§4.5.6 Table 28) ─────────────────────────────────────
|
||||
//
|
||||
// Broad failure category returned in a CHECK CONDITION reply's sense data.
|
||||
// Names match the SCSI spec; predicate methods on [`ScsiSense`] (e.g.
|
||||
// `is_medium_error`, `is_unit_attention`) read more fluently than raw
|
||||
// constant comparisons at call sites.
|
||||
|
||||
pub const SENSE_KEY_NO_SENSE: u8 = 0x00;
|
||||
pub const SENSE_KEY_RECOVERED_ERROR: u8 = 0x01;
|
||||
pub const SENSE_KEY_NOT_READY: u8 = 0x02;
|
||||
pub const SENSE_KEY_MEDIUM_ERROR: u8 = 0x03;
|
||||
pub const SENSE_KEY_HARDWARE_ERROR: u8 = 0x04;
|
||||
pub const SENSE_KEY_ILLEGAL_REQUEST: u8 = 0x05;
|
||||
pub const SENSE_KEY_UNIT_ATTENTION: u8 = 0x06;
|
||||
pub const SENSE_KEY_DATA_PROTECT: u8 = 0x07;
|
||||
pub const SENSE_KEY_BLANK_CHECK: u8 = 0x08;
|
||||
pub const SENSE_KEY_ABORTED_COMMAND: u8 = 0x0B;
|
||||
|
||||
// ── Sense parsing ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Decoded SPC-4 sense triple — the precise reason a SCSI command failed.
|
||||
///
|
||||
/// Returned by [`parse_sense`] and embedded inside [`Error::ScsiError`]
|
||||
/// (`sense: Option<ScsiSense>`). Predicate methods (`is_medium_error`,
|
||||
/// `is_unit_attention`, `is_marginal`, …) read more fluently at call
|
||||
/// sites than raw `sense_key` comparisons.
|
||||
///
|
||||
/// `Default::default()` and the [`ScsiSense::NONE`] constant both
|
||||
/// produce the all-zero "no sense info" triple. Per SPC-4 §4.5.3, an
|
||||
/// empty sense buffer is reported as NO SENSE (key 0); use the constant
|
||||
/// for explicit intent at construction sites.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct ScsiSense {
|
||||
/// Sense key — broad failure category (SPC-4 §4.5.6 Table 28).
|
||||
/// See the `SENSE_KEY_*` constants for named values.
|
||||
pub sense_key: u8,
|
||||
/// Additional Sense Code — narrows the cause within a sense key
|
||||
/// (SPC-4 §4.5.6 Table 29). E.g. `0x11` = UNRECOVERED READ ERROR.
|
||||
pub asc: u8,
|
||||
/// Additional Sense Code Qualifier — finest-grain disambiguation.
|
||||
/// E.g. `0x05` (with `asc=0x11`) = L-EC UNCORRECTABLE.
|
||||
pub ascq: u8,
|
||||
}
|
||||
|
||||
impl ScsiSense {
|
||||
/// Sense reply with all-zero fields — explicit "no sense info"
|
||||
/// constructor for sites where `Default::default()` would be opaque.
|
||||
pub const NONE: ScsiSense = ScsiSense {
|
||||
sense_key: 0,
|
||||
asc: 0,
|
||||
ascq: 0,
|
||||
};
|
||||
|
||||
/// `true` when the sense key indicates a *marginal-read* failure —
|
||||
/// the kind of error where the same read at smaller granularity
|
||||
/// (or a brief retry) sometimes succeeds:
|
||||
///
|
||||
/// - `MEDIUM ERROR` (3) — canonical bad-sector signal
|
||||
/// - `NOT READY` (2) — on many drives (notably BU40N), this is the
|
||||
/// dominant response for unreadable sectors (ASC 04/3E, 04/01, etc.)
|
||||
/// - `ABORTED COMMAND` (B) — transient; retry usually works
|
||||
/// - `RECOVERED ERROR` (1) / `NO SENSE` (0) — drive is healthy and
|
||||
/// either recovered the data or has no specific fault to report
|
||||
///
|
||||
/// `false` for HARDWARE ERROR, DATA PROTECT, UNIT ATTENTION,
|
||||
/// ILLEGAL REQUEST, BLANK CHECK, and any unknown key. Used
|
||||
/// by [`Error::is_marginal_read`] / `Disc::copy`'s hysteresis
|
||||
/// dispatch.
|
||||
pub fn is_marginal(&self) -> bool {
|
||||
matches!(
|
||||
self.sense_key,
|
||||
SENSE_KEY_NO_SENSE
|
||||
| SENSE_KEY_RECOVERED_ERROR
|
||||
| SENSE_KEY_NOT_READY
|
||||
| SENSE_KEY_MEDIUM_ERROR
|
||||
| SENSE_KEY_ABORTED_COMMAND
|
||||
)
|
||||
}
|
||||
|
||||
/// `true` if `sense_key == MEDIUM ERROR (3)` — canonical "bad sector"
|
||||
/// signal from the drive.
|
||||
pub fn is_medium_error(&self) -> bool {
|
||||
self.sense_key == SENSE_KEY_MEDIUM_ERROR
|
||||
}
|
||||
|
||||
/// `true` if `sense_key == HARDWARE ERROR (4)` — drive itself is
|
||||
/// failing. Not recoverable by retry.
|
||||
pub fn is_hardware_error(&self) -> bool {
|
||||
self.sense_key == SENSE_KEY_HARDWARE_ERROR
|
||||
}
|
||||
|
||||
/// `true` if `sense_key == NOT READY (2)` — medium not present /
|
||||
/// drive becoming ready / etc.
|
||||
pub fn is_not_ready(&self) -> bool {
|
||||
self.sense_key == SENSE_KEY_NOT_READY
|
||||
}
|
||||
|
||||
/// `true` if `sense_key == UNIT ATTENTION (6)` — disc/drive state
|
||||
/// changed since the prior command (media inserted/removed,
|
||||
/// power-on reset, parameters changed). Caller should rescan rather
|
||||
/// than retry the read.
|
||||
pub fn is_unit_attention(&self) -> bool {
|
||||
self.sense_key == SENSE_KEY_UNIT_ATTENTION
|
||||
}
|
||||
|
||||
/// `true` if `sense_key == DATA PROTECT (7)` — read blocked by
|
||||
/// AACS / region / write-protect. Retry won't help.
|
||||
pub fn is_data_protect(&self) -> bool {
|
||||
self.sense_key == SENSE_KEY_DATA_PROTECT
|
||||
}
|
||||
|
||||
/// `true` if `sense_key == ILLEGAL REQUEST (5)` — typically a bug
|
||||
/// in the CDB we sent (LBA out of range, reserved bit, etc.). Don't
|
||||
/// retry.
|
||||
pub fn is_illegal_request(&self) -> bool {
|
||||
self.sense_key == SENSE_KEY_ILLEGAL_REQUEST
|
||||
}
|
||||
|
||||
/// `true` if `sense_key == ABORTED COMMAND (B)` — transient; one
|
||||
/// retry is usually safe.
|
||||
pub fn is_aborted_command(&self) -> bool {
|
||||
self.sense_key == SENSE_KEY_ABORTED_COMMAND
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode an SPC-4 sense buffer into the structured triple
|
||||
/// `(sense_key, asc, ascq)`.
|
||||
///
|
||||
/// Handles both response-code formats SPC-4 mandates:
|
||||
///
|
||||
/// - **Descriptor format** (response code `0x72` / `0x73`):
|
||||
/// - sense key = `sense[1] & 0x0F`
|
||||
/// - asc = `sense[2]`
|
||||
/// - ascq = `sense[3]`
|
||||
/// - **Fixed format** (response code `0x70` / `0x71` and any unknown
|
||||
/// code per SPC-4 §4.5.3):
|
||||
/// - sense key = `sense[2] & 0x0F`
|
||||
/// - asc = `sense[12]`
|
||||
/// - ascq = `sense[13]`
|
||||
///
|
||||
/// `sb_len_wr` is the number of bytes the transport actually wrote into
|
||||
/// `sense`. When the buffer is too short for the relevant fields we
|
||||
/// return [`ScsiSense::NONE`] for the missing pieces rather than reading
|
||||
/// uninitialised memory. The minimum useful sense reply per SPC-4 is 8
|
||||
/// bytes (descriptor) or 14 bytes (fixed, to reach ASC/ASCQ at offsets
|
||||
/// 12/13).
|
||||
///
|
||||
/// Pure function — same parse on every platform backend (Linux SG_IO,
|
||||
/// macOS IOKit, Windows SPTI) so a regression here would silently
|
||||
/// mis-route SCSI errors on all three OSes simultaneously.
|
||||
pub(crate) fn parse_sense(sense: &[u8], sb_len_wr: u8) -> ScsiSense {
|
||||
let n = (sb_len_wr as usize).min(sense.len());
|
||||
if n < 3 {
|
||||
return ScsiSense::NONE;
|
||||
}
|
||||
let response_code = sense[0] & 0x7F;
|
||||
let descriptor = response_code == 0x72 || response_code == 0x73;
|
||||
if descriptor {
|
||||
// Descriptor format: key/asc/ascq are at fixed offsets 1/2/3.
|
||||
let asc = if n >= 3 { sense[2] } else { 0 };
|
||||
let ascq = if n >= 4 { sense[3] } else { 0 };
|
||||
ScsiSense {
|
||||
sense_key: sense[1] & 0x0F,
|
||||
asc,
|
||||
ascq,
|
||||
}
|
||||
} else {
|
||||
// Fixed format: key at byte 2, ASC/ASCQ at bytes 12/13.
|
||||
let asc = if n >= 13 { sense[12] } else { 0 };
|
||||
let ascq = if n >= 14 { sense[13] } else { 0 };
|
||||
ScsiSense {
|
||||
sense_key: sense[2] & 0x0F,
|
||||
asc,
|
||||
ascq,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── SG_IO driver_status bits ────────────────────────────────────────────────
|
||||
|
||||
/// `DRIVER_SENSE` (0x08) — bit set in `driver_status` to indicate that
|
||||
/// sense data was attached to a CHECK CONDITION reply. **Not** a transport
|
||||
/// failure on its own. Mask this off before deciding whether `driver_status`
|
||||
/// represents a real bus/host problem.
|
||||
///
|
||||
/// Used by Linux SG_IO (`sg_io_hdr.driver_status`); macOS IOKit and
|
||||
/// Windows SPTI carry the equivalent signal in different fields and
|
||||
/// don't need the same masking — the misclassification was Linux-only.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) const DRIVER_SENSE: u16 = 0x08;
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -82,34 +342,144 @@ 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<()> {
|
||||
// Note: a top-level `scsi::reset()` used to live here, wrapping a
|
||||
// platform reset in a thread+recv_timeout so a kernel-wedged ioctl
|
||||
// couldn't hang the caller. Removed in 0.13.6 along with the
|
||||
// SG_SCSI_RESET / STOP+START UNIT escalation that needed it. The
|
||||
// remaining platform reset (Linux: SgIoTransport::reset, available
|
||||
// for explicit opt-in) does pure userspace state cleanup with bounded
|
||||
// sleeps — no escape-hatch wrapper required.
|
||||
|
||||
// ── USB-layer recovery: rolled back in 0.13.4 ───────────────────────────────
|
||||
//
|
||||
// 0.13.1 – 0.13.3 exposed `scsi::usb_reset()` (`USBDEVFS_RESET` on Linux,
|
||||
// `IOUSBDeviceInterface::ResetDevice` on macOS) and chained it into
|
||||
// `drive_has_disc` recovery. Production testing on the LG BU40N USB BD-RE
|
||||
// confirmed the USB stack resets succeed — dmesg logs
|
||||
// `usb 3-2: reset high-speed USB device` and the device re-authorises —
|
||||
// but the drive firmware below the USB bridge stays locked: LUN never
|
||||
// re-enumerates, TUR still times out, the drive is unusable until
|
||||
// physical unplug-replug or host reboot. Additional approaches tried
|
||||
// and discarded: `authorized` 0→1 toggle, usb-storage driver
|
||||
// unbind/rebind, forced SCSI host rescan, `STOP` + `START UNIT`.
|
||||
//
|
||||
// The APIs were removed so no caller can be misled into thinking a
|
||||
// software-only recovery exists for this class of wedge. If a future
|
||||
// hardware class surfaces where USB-layer recovery actually helps, the
|
||||
// code should live here again, gated on a wedge signature — see git
|
||||
// tag `v0.13.3` for the full implementation.
|
||||
|
||||
// ── 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::SgIoTransport::reset(device)
|
||||
linux::list_drives()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::MacScsiTransport::reset(device)
|
||||
macos::list_drives()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::SptiTransport::reset(device)
|
||||
windows::list_drives()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = device;
|
||||
Ok(())
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,3 +576,116 @@ pub fn build_read10_raw(lba: u32, count: u16) -> [u8; 10] {
|
||||
0x00,
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod parse_sense_tests {
|
||||
//! Unit tests for `parse_sense_key`. Covers both SPC-4 sense data
|
||||
//! formats (descriptor / fixed) and the short-buffer fallback. The
|
||||
//! same helper runs on every platform backend so a regression here
|
||||
//! would silently miscategorize SCSI errors on Linux, macOS, and
|
||||
//! Windows simultaneously.
|
||||
use super::parse_sense;
|
||||
fn parse_sense_key(sense: &[u8], sb_len_wr: u8) -> u8 {
|
||||
parse_sense(sense, sb_len_wr).sense_key
|
||||
}
|
||||
|
||||
/// Helper: build a 32-byte sense buffer whose first three bytes are
|
||||
/// the given prefix; the rest are zeroes (sense data area).
|
||||
fn buf(b0: u8, b1: u8, b2: u8) -> [u8; 32] {
|
||||
let mut s = [0u8; 32];
|
||||
s[0] = b0;
|
||||
s[1] = b1;
|
||||
s[2] = b2;
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_format_72_picks_byte_1() {
|
||||
// Response code 0x72 (current, descriptor): sense key is the
|
||||
// low nibble of byte 1. Byte 2 here is 0x77 to prove it is NOT
|
||||
// the byte the parser reads.
|
||||
let s = buf(0x72, 0x05, 0x77); // ILLEGAL REQUEST
|
||||
assert_eq!(parse_sense_key(&s, 8), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_format_73_picks_byte_1() {
|
||||
// Response code 0x73 (deferred, descriptor): same parse rule
|
||||
// as 0x72.
|
||||
let s = buf(0x73, 0x06, 0xFF); // UNIT ATTENTION
|
||||
assert_eq!(parse_sense_key(&s, 8), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_format_70_picks_byte_2() {
|
||||
// Response code 0x70 (current, fixed): sense key is the low
|
||||
// nibble of byte 2. Byte 1 is 0x77 to prove it is NOT read.
|
||||
let s = buf(0x70, 0x77, 0x05); // ILLEGAL REQUEST
|
||||
assert_eq!(parse_sense_key(&s, 18), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_format_71_picks_byte_2() {
|
||||
// Response code 0x71 (deferred, fixed): same parse as 0x70.
|
||||
let s = buf(0x71, 0x77, 0x02); // NOT READY
|
||||
assert_eq!(parse_sense_key(&s, 18), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_bit_in_byte_0_is_masked() {
|
||||
// SPC-4 sets the top bit of byte 0 ("INFORMATION VALID" / "VALID")
|
||||
// independently of the response code. parse_sense_key must mask
|
||||
// it off before classifying the format.
|
||||
let s = buf(0xF2, 0x05, 0x77);
|
||||
assert_eq!(
|
||||
parse_sense_key(&s, 8),
|
||||
5,
|
||||
"VALID-bit must not leak into format detection"
|
||||
);
|
||||
let s = buf(0xF0, 0x77, 0x02);
|
||||
assert_eq!(parse_sense_key(&s, 18), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_nibble_in_key_byte_is_masked() {
|
||||
// Sense key is byte_n & 0x0F (low nibble). Top nibble holds
|
||||
// FILEMARK / EOM / ILI / SDAT_OVFL flags, which must not bleed
|
||||
// into the key value.
|
||||
let s = buf(0x70, 0x00, 0xE5); // 0xE0 flags + key 5
|
||||
assert_eq!(parse_sense_key(&s, 18), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sb_len_wr_zero_returns_no_sense() {
|
||||
// Transport set status non-zero but wrote zero sense bytes —
|
||||
// SPC-4 §4.5.3 says treat as NO SENSE (key 0).
|
||||
let s = buf(0x72, 0x05, 0x05);
|
||||
assert_eq!(parse_sense_key(&s, 0), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sb_len_wr_below_three_returns_no_sense() {
|
||||
// Less than three bytes in the buffer means we can't safely
|
||||
// read either format byte 0 or key byte 2 — return 0.
|
||||
let s = buf(0x72, 0x05, 0x05);
|
||||
assert_eq!(parse_sense_key(&s, 1), 0);
|
||||
assert_eq!(parse_sense_key(&s, 2), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_below_three_returns_no_sense() {
|
||||
// Defense-in-depth: even if a caller passes a too-short slice
|
||||
// with a falsely-large sb_len_wr, we don't panic and we return 0.
|
||||
let s = [0x72u8, 0x05];
|
||||
assert_eq!(parse_sense_key(&s, 8), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_response_code_falls_through_to_fixed() {
|
||||
// SPC-4 mandates implementations tolerate unknown response
|
||||
// codes and treat them as fixed format. Vendor-specific codes
|
||||
// in the 0x74..0x7E range surface here.
|
||||
let s = buf(0x7A, 0x77, 0x03); // MEDIUM ERROR via "fixed"
|
||||
assert_eq!(parse_sense_key(&s, 18), 3);
|
||||
}
|
||||
}
|
||||
|
||||
+77
-14
@@ -56,7 +56,7 @@ struct SptwbDirect {
|
||||
|
||||
// ── Windows FFI ────────────────────────────────────────────────────────────
|
||||
|
||||
extern "system" {
|
||||
unsafe extern "system" {
|
||||
fn CreateFileW(
|
||||
lpFileName: *const u16,
|
||||
dwDesiredAccess: u32,
|
||||
@@ -87,6 +87,10 @@ pub struct SptiTransport {
|
||||
handle: isize,
|
||||
}
|
||||
|
||||
// SptiTransport's only field is the isize HANDLE — Send is auto-derived
|
||||
// and intentional. Sync is NOT: handle mutation in execute() requires
|
||||
// &mut, enforced by the trait object dispatch.
|
||||
|
||||
/// Normalize a device path to Windows \\.\X: format.
|
||||
///
|
||||
/// NOTE: A near-identical `normalize_path` exists in `drive::windows`.
|
||||
@@ -129,8 +133,17 @@ impl SptiTransport {
|
||||
};
|
||||
|
||||
if handle == INVALID_HANDLE_VALUE {
|
||||
return Err(Error::DeviceNotFound {
|
||||
path: format!("{}: cannot open device (run as administrator)", dev_str),
|
||||
// Map last-os-error → Error variant; don't embed English hints
|
||||
// in the path field (the CLI handles localization).
|
||||
let err = std::io::Error::last_os_error();
|
||||
return Err(if err.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
Error::DevicePermission {
|
||||
path: dev_str.to_string(),
|
||||
}
|
||||
} else {
|
||||
Error::DeviceNotFound {
|
||||
path: dev_str.to_string(),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -187,10 +200,45 @@ impl SptiTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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. No in-library recovery — see the
|
||||
/// Linux `drive_has_disc` doc block for the rationale.
|
||||
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(ref e) if e.scsi_sense().is_some_and(|s| s.is_not_ready()) => Ok(false),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SptiTransport {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
CloseHandle(self.handle);
|
||||
if self.handle != INVALID_HANDLE_VALUE {
|
||||
unsafe {
|
||||
CloseHandle(self.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,7 +269,11 @@ impl ScsiTransport for SptiTransport {
|
||||
DataDirection::ToDevice => SCSI_IOCTL_DATA_OUT,
|
||||
};
|
||||
sptwb.spt.DataTransferLength = data.len() as u32;
|
||||
sptwb.spt.TimeOutValue = (timeout_ms / 1000).max(1) as u32;
|
||||
// Round up to the next whole second so a 1500ms request gets at
|
||||
// least 2s, not 1s. SPTI's TimeOutValue is u32 seconds with no
|
||||
// sub-second resolution; biasing toward "more time" is safer than
|
||||
// truncating (truncation broke 1500ms fast-reads on Drive::read).
|
||||
sptwb.spt.TimeOutValue = ((timeout_ms + 999) / 1000).max(1);
|
||||
sptwb.spt.DataBuffer = if data.is_empty() {
|
||||
std::ptr::null_mut()
|
||||
} else {
|
||||
@@ -247,23 +299,34 @@ impl ScsiTransport for SptiTransport {
|
||||
};
|
||||
|
||||
if ok == 0 {
|
||||
// Driver-level failure (timeout, handle gone, etc.). Bubble
|
||||
// up; in-library handle recovery was removed in 0.13.20 along
|
||||
// with Linux's async fd-recovery and macOS's `try_recover` —
|
||||
// the kernel mid-layer already did its escalation by the time
|
||||
// DeviceIoControl returned, and re-issuing reset/reopen here
|
||||
// is at best redundant and at worst deepens the wedge. Caller
|
||||
// surfaces the failure to UX.
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: 0xFF,
|
||||
sense_key: 0,
|
||||
status: super::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
|
||||
if sptwb.spt.ScsiStatus != 0 {
|
||||
let sense_key = if sptwb.sense[2] != 0 {
|
||||
sptwb.sense[2] & 0x0F
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// SPTI doesn't surface a "bytes written into sense buffer"
|
||||
// count separate from SenseInfoLength (input). Pass the full
|
||||
// K_SENSE_SIZE; parse_sense keys off byte 0's response code
|
||||
// to handle descriptor (0x72/0x73) vs fixed (0x70/0x71).
|
||||
//
|
||||
// 0.13.23: carry the full SPC-4 sense triple in
|
||||
// `Error::ScsiError::sense` so callers can route on
|
||||
// `ScsiSense::is_medium_error()` etc.
|
||||
let parsed = super::parse_sense(&sptwb.sense, K_SENSE_SIZE as u8);
|
||||
return Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: sptwb.spt.ScsiStatus,
|
||||
sense_key,
|
||||
sense: Some(parsed),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user