mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -21,7 +21,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
|
||||
|
||||
+1086
File diff suppressed because it is too large
Load Diff
+8
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.12.0"
|
||||
version = "0.14.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
@@ -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
|
||||
|
||||
@@ -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.13"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@@ -84,7 +84,7 @@ while result.bytes_unreadable + result.bytes_pending > 0 {
|
||||
- **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
|
||||
@@ -92,21 +92,21 @@ while result.bytes_unreadable + result.bytes_pending > 0 {
|
||||
| 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::copy()`) |
|
||||
| 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 `pes::Stream` (frame-level). `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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+33
-3
@@ -126,15 +126,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,8 +175,8 @@ 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
|
||||
├── 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
|
||||
|
||||
@@ -41,7 +41,7 @@ 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)
|
||||
│ ├── 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)
|
||||
@@ -93,10 +93,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
|
||||
|
||||
+4
-2
@@ -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.)
|
||||
@@ -110,7 +112,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 |
|
||||
|
||||
+37
-15
@@ -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,22 +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(lba, count, buf, recovery)` is the single read method. The
|
||||
`recovery` parameter controls whether to attempt multi-phase recovery on
|
||||
failure or return immediately (used by DiscStream's binary search for
|
||||
single-sector probes).
|
||||
`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:
|
||||
|
||||
On error with `recovery = true`:
|
||||
| `recovery` | Timeout | Used by |
|
||||
|------------|----------|------------------------------------------|
|
||||
| `false` | 1.5 s | `Disc::copy` fast skip-forward sweep, `DiscStream::fill_extents` |
|
||||
| `true` | 30 s | `Disc::patch` multi-pass over the mapfile |
|
||||
|
||||
1. **Phase 1 — gentle retry (5 attempts):** set min speed, sleep 30s, retry.
|
||||
Each retry has a hard wall-clock timeout via async SG_IO.
|
||||
2. **Phase 2 — fresh start:** close transport, reset device, reopen, reinit.
|
||||
3. **Phase 3 — gentle retry on fresh connection (5 attempts).**
|
||||
4. If all fail: return `Err(DiscRead)`. DiscStream handles it (binary search,
|
||||
skip, zero-fill).
|
||||
5. Stay at min speed for 500 MB after any recovery (recovery window).
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -99,6 +110,17 @@ cannot block us. Opens with `O_RDWR | O_NONBLOCK`.
|
||||
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
|
||||
|
||||
The `scsi` module provides platform-agnostic CDB constructors:
|
||||
|
||||
+74
-20
@@ -1,12 +1,27 @@
|
||||
# Rip recovery — multi-pass architecture
|
||||
# Rip recovery — three-layer architecture
|
||||
|
||||
`libfreemkv` supports a two-stage rip model for damaged or protection-bearing
|
||||
discs: a fast forward sweep that tolerates read failures, followed by targeted
|
||||
`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.
|
||||
|
||||
Three primitives compose the flow:
|
||||
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` (multi-pass over the mapfile) | Re-reads non-`+` ranges with the long timeout. Idempotent; call 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 caller orchestrates layer 1. Autorip's `rip_disc` loops `copy` then
|
||||
N × `patch` per the `MAX_RETRIES` config, then hands the ISO off to the
|
||||
existing mux pipeline. 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 |
|
||||
|---------------------------|-----------------------------------------------------------------------|
|
||||
@@ -14,9 +29,6 @@ Three primitives compose the flow:
|
||||
| `Disc::patch` | Re-reads bad ranges from the drive. Idempotent; call N times. |
|
||||
| `DiscStream` (ISO source) | Reads sectors from the ISO, feeds decrypt → demux → codec → mux. |
|
||||
|
||||
The caller orchestrates. Autorip's `rip_disc` loops `copy` then N × `patch` per
|
||||
the `MAX_RETRIES` config, then hands the ISO off to the existing mux pipeline.
|
||||
|
||||
## Data model
|
||||
|
||||
### Mapfile
|
||||
@@ -26,7 +38,7 @@ plain text, greppable, tool-interoperable. Flushed to disk on every `record()`
|
||||
so a crashed rip loses at most one block.
|
||||
|
||||
```
|
||||
# Rescue Logfile. Created by libfreemkv v0.11.22
|
||||
# Rescue Logfile. Created by libfreemkv v0.13.6
|
||||
# Current pos / status / pass / pass_time
|
||||
0x000000000 ? 1 0
|
||||
# pos size status
|
||||
@@ -50,8 +62,8 @@ Position and size are hex byte offsets into the ISO.
|
||||
|
||||
### `CopyOptions` and `PatchOptions`
|
||||
|
||||
Defaults preserve pre-`0.11.21` behavior — full drive recovery on every read,
|
||||
abort on the first unreadable sector. Opt in to the recovery-friendly path:
|
||||
Defaults preserve pre-`0.11.21` behavior — abort on the first unreadable
|
||||
sector. Opt in to the recovery-friendly path:
|
||||
|
||||
```rust
|
||||
CopyOptions {
|
||||
@@ -65,9 +77,10 @@ CopyOptions {
|
||||
|
||||
## Algorithm
|
||||
|
||||
### Pass 1 — fast sweep
|
||||
### Pass 1 — fast sweep (`Disc::copy`)
|
||||
|
||||
1. Read 64 KB (32 sectors, one BD ECC block) at the current LBA.
|
||||
1. Read 64 KB (32 sectors, one BD ECC block) at the current LBA via
|
||||
`Drive::read(.., recovery=false)` — short 1.5 s timeout, single shot.
|
||||
2. On success: mark the range `+`, advance by one block.
|
||||
3. On failure (with `skip_on_error`): zero-fill the block in the ISO, mark
|
||||
it `*`, advance.
|
||||
@@ -80,12 +93,13 @@ CopyOptions {
|
||||
Pass 1 completes when every byte has terminal status (`+`, `-`, or the caller
|
||||
bails via the halt flag).
|
||||
|
||||
### Pass 2+ — patch
|
||||
### Pass 2+ — patch (`Disc::patch`)
|
||||
|
||||
`Disc::patch` reads the mapfile and iterates every non-`+` range. For each:
|
||||
|
||||
1. Issue a drive read with full recovery enabled (SCSI-level retries,
|
||||
ECC recovery, the lot).
|
||||
1. Issue a drive read via `Drive::read(.., recovery=true)` — long 30 s
|
||||
timeout, still single shot. Drive firmware does its own ECC and retries
|
||||
inside that window; userspace does not pile on additional retries here.
|
||||
2. On success: write the good bytes into the ISO at the exact byte offset,
|
||||
mark `+`.
|
||||
3. On failure: mark `-`.
|
||||
@@ -95,21 +109,61 @@ Idempotent. Call `patch` N times for N retry attempts; typically the caller
|
||||
stops early if a pass recovers zero bytes (structure-protected sectors will
|
||||
never yield).
|
||||
|
||||
### 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 instead, and we avoid per-sector probing on first
|
||||
contact by using large blocks + skip-forward.
|
||||
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.
|
||||
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
|
||||
@@ -117,11 +171,11 @@ coalesce on adjacent same-status ranges so files stay small.
|
||||
|
||||
**Patches target `-`, `*`, `/`, and `?` alike.** The status state machine is
|
||||
ddrescue's but `patch` collapses the distinction — it just tries every
|
||||
non-finished range with full recovery. Future work can specialize (trim vs.
|
||||
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/mod.rs`](../src/disc/mod.rs) (`Disc::copy`, `Disc::patch`)
|
||||
- Source: [`src/disc/mapfile.rs`](../src/disc/mapfile.rs), [`src/disc/mod.rs`](../src/disc/mod.rs) (`Disc::copy`, `Disc::patch`), [`src/drive/mod.rs`](../src/drive/mod.rs) (`Drive::read`), [`src/mux/disc.rs`](../src/mux/disc.rs) (`DiscStream::fill_extents`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
})
|
||||
|
||||
+37
-9
@@ -69,12 +69,28 @@ pub struct MapEntry {
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// Write-through mapfile. Every `record()` persists to disk immediately
|
||||
@@ -149,10 +165,13 @@ impl Mapfile {
|
||||
.next()
|
||||
.and_then(SectorStatus::from_char)
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("bad status char in mapfile: {}", fields[2]),
|
||||
)
|
||||
// No English text — the variant carries a stable
|
||||
// language-neutral kind identifier (`status_char`).
|
||||
let e: io::Error = crate::error::Error::MapfileInvalid {
|
||||
kind: "status_char",
|
||||
}
|
||||
.into();
|
||||
e
|
||||
})?;
|
||||
entries.push(MapEntry { pos, size, status });
|
||||
}
|
||||
@@ -272,8 +291,13 @@ impl Mapfile {
|
||||
match e.status {
|
||||
SectorStatus::Finished => s.bytes_good += e.size,
|
||||
SectorStatus::Unreadable => s.bytes_unreadable += e.size,
|
||||
SectorStatus::NonTried | SectorStatus::NonTrimmed | SectorStatus::NonScraped => {
|
||||
s.bytes_pending += e.size
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,7 +318,7 @@ impl Mapfile {
|
||||
let mut w = std::io::BufWriter::new(file);
|
||||
writeln!(
|
||||
w,
|
||||
"# Rescue Logfile. Created by libfreemkv v{}",
|
||||
"# Rescue Logfile. Created by {}",
|
||||
self.version
|
||||
)?;
|
||||
writeln!(w, "# Current pos / status / pass / pass_time")?;
|
||||
@@ -318,8 +342,12 @@ impl Mapfile {
|
||||
|
||||
fn parse_hex(s: &str) -> io::Result<u64> {
|
||||
let s = s.strip_prefix("0x").unwrap_or(s);
|
||||
u64::from_str_radix(s, 16)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("bad hex {s}: {e}")))
|
||||
u64::from_str_radix(s, 16).map_err(|_| {
|
||||
// Underlying ParseIntError dropped — its Display is OS-locale text.
|
||||
// The typed variant carries `kind = "hex"` which is stable.
|
||||
let e: io::Error = crate::error::Error::MapfileInvalid { kind: "hex" }.into();
|
||||
e
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+646
-131
@@ -13,13 +13,18 @@ mod dvd;
|
||||
mod encrypt;
|
||||
pub mod mapfile;
|
||||
|
||||
use crate::drive::Drive;
|
||||
use crate::drive::{Drive, extract_scsi_context};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf;
|
||||
|
||||
use encrypt::HandshakeResult;
|
||||
|
||||
// Re-export label classification enums alongside AudioStream / SubtitleStream
|
||||
// so the public surface keeps the structured metadata together. Callers map
|
||||
// these to display text in their own locale.
|
||||
pub use crate::labels::{LabelPurpose, LabelQualifier};
|
||||
|
||||
// ─── Public types ───────────────────────────────────────────────────────────
|
||||
|
||||
/// A scanned Blu-ray disc.
|
||||
@@ -180,7 +185,11 @@ pub struct AudioStream {
|
||||
pub sample_rate: SampleRate,
|
||||
/// Whether this is a secondary stream (commentary)
|
||||
pub secondary: bool,
|
||||
/// Extra label
|
||||
/// Stream purpose (commentary / descriptive / score / IME / normal).
|
||||
/// Callers translate this to display text in their own locale.
|
||||
pub purpose: LabelPurpose,
|
||||
/// Codec / variant text (e.g. "Dolby TrueHD 5.1", "(US)").
|
||||
/// NEVER contains English purpose words — see `purpose` for that.
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
@@ -195,6 +204,9 @@ pub struct SubtitleStream {
|
||||
pub language: String,
|
||||
/// Whether this is a forced subtitle
|
||||
pub forced: bool,
|
||||
/// Subtitle qualifier (SDH / descriptive service / forced / none).
|
||||
/// Callers translate this to display text in their own locale.
|
||||
pub qualifier: LabelQualifier,
|
||||
/// Pre-formatted codec private data (e.g. VobSub .idx palette header)
|
||||
pub codec_data: Option<Vec<u8>>,
|
||||
}
|
||||
@@ -889,13 +901,6 @@ pub struct ScanOptions {
|
||||
}
|
||||
|
||||
impl ScanOptions {
|
||||
/// Create options with a specific KEYDB path.
|
||||
pub fn with_keydb(path: impl Into<std::path::PathBuf>) -> Self {
|
||||
ScanOptions {
|
||||
keydb_path: Some(path.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve KEYDB path: explicit path first, then standard locations.
|
||||
fn resolve_keydb(&self) -> Option<std::path::PathBuf> {
|
||||
if let Some(p) = &self.keydb_path {
|
||||
@@ -1205,29 +1210,114 @@ impl Disc {
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw sector copy — write the entire disc image to a file.
|
||||
/// Copy disc sectors to an ISO image file.
|
||||
///
|
||||
/// NOT a stream operation. Copies sectors 0→capacity byte-for-byte producing
|
||||
/// a valid ISO/UDF image. Records progress in a ddrescue-format mapfile at
|
||||
/// NOT a stream operation. Copies sectors byte-for-byte producing a valid
|
||||
/// ISO/UDF image. Records progress in a ddrescue-format mapfile at
|
||||
/// `path + ".mapfile"` — flushed every block for crash-safe resume.
|
||||
///
|
||||
/// # Options
|
||||
/// - **default** (all false): behavior matches pre-v0.11.21 — uses full
|
||||
/// drive recovery (may take minutes per bad sector), aborts on error.
|
||||
/// Mapfile is produced as a side-effect.
|
||||
/// - **skip_on_error**: zero-fill bad blocks in the ISO, mark them in the
|
||||
/// mapfile, and continue. Uses fast reads (no drive-level recovery loop).
|
||||
/// - **skip_forward** (implies skip_on_error): on block failure, also skip
|
||||
/// forward by an exponentially-growing amount, marking the jumped region
|
||||
/// as `non-trimmed` for later trimming/scraping by `Disc::patch`.
|
||||
/// - **resume**: if the mapfile exists, resume from its state — only
|
||||
/// `non-tried` ranges are read. Without `resume`, a fresh mapfile is
|
||||
/// written and the ISO recreated from scratch.
|
||||
/// Auto-detects the pass based on mapfile state:
|
||||
/// - **No mapfile** → Pass 1 (sweep): sequential read of the entire disc,
|
||||
/// ECC-aligned batches, damage-jump on contiguous failures, marks bad
|
||||
/// blocks as NonTrimmed. No drive-level recovery — fast.
|
||||
/// - **Mapfile with bad ranges** → Pass N (patch): re-reads only bad ranges
|
||||
/// sector-by-sector with full drive-level recovery. Marks recovered
|
||||
/// sectors as Finished, failed as Unreadable (terminal).
|
||||
/// - **Mapfile clean** → no-op: all sectors are Finished.
|
||||
///
|
||||
/// Without `multipass`: aborts on the first read error (legacy single-pass).
|
||||
pub fn copy(
|
||||
&self,
|
||||
reader: &mut dyn SectorReader,
|
||||
path: &std::path::Path,
|
||||
opts: &CopyOptions,
|
||||
) -> Result<CopyResult> {
|
||||
if opts.multipass {
|
||||
let mf_path = self.mapfile_for(path);
|
||||
if mf_path.exists() {
|
||||
let map = mapfile::Mapfile::load(&mf_path)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
let stats = map.stats();
|
||||
let bad_bytes = stats.bytes_pending + stats.bytes_unreadable;
|
||||
tracing::info!(
|
||||
"copy dispatch: {} good, {} nontried, {} pending, {} unreadable",
|
||||
stats.bytes_good,
|
||||
stats.bytes_nontried,
|
||||
stats.bytes_pending,
|
||||
stats.bytes_unreadable,
|
||||
);
|
||||
if bad_bytes == 0 {
|
||||
return Ok(CopyResult {
|
||||
bytes_total: map.total_size(),
|
||||
bytes_good: stats.bytes_good,
|
||||
bytes_unreadable: stats.bytes_unreadable,
|
||||
bytes_pending: 0,
|
||||
recovered_this_pass: 0,
|
||||
complete: true,
|
||||
halted: false,
|
||||
});
|
||||
}
|
||||
if stats.bytes_nontried > 0 {
|
||||
tracing::info!("copy dispatch: → sweep (NonTried regions remain)");
|
||||
return self.sweep_internal(reader, path, opts);
|
||||
}
|
||||
tracing::info!("copy dispatch: → patch (only NonTrimmed/NonScraped/Unreadable remain)");
|
||||
return self.patch_internal(reader, path, opts);
|
||||
}
|
||||
}
|
||||
self.sweep_internal(reader, path, opts)
|
||||
}
|
||||
|
||||
fn sweep_internal(
|
||||
&self,
|
||||
reader: &mut dyn SectorReader,
|
||||
path: &std::path::Path,
|
||||
opts: &CopyOptions,
|
||||
) -> Result<CopyResult> {
|
||||
let sweep_opts = SweepOptions {
|
||||
decrypt: opts.decrypt,
|
||||
resume: false,
|
||||
batch_sectors: None,
|
||||
skip_on_error: opts.multipass,
|
||||
progress: opts.progress,
|
||||
halt: opts.halt.clone(),
|
||||
};
|
||||
self.sweep(reader, path, &sweep_opts)
|
||||
}
|
||||
|
||||
fn patch_internal(
|
||||
&self,
|
||||
reader: &mut dyn SectorReader,
|
||||
path: &std::path::Path,
|
||||
opts: &CopyOptions,
|
||||
) -> Result<CopyResult> {
|
||||
let patch_opts = PatchOpts {
|
||||
decrypt: opts.decrypt,
|
||||
block_sectors: Some(1),
|
||||
full_recovery: true,
|
||||
reverse: false,
|
||||
wedged_threshold: 50,
|
||||
progress: opts.progress,
|
||||
halt: opts.halt.clone(),
|
||||
};
|
||||
let pr = self.patch(reader, path, &patch_opts)?;
|
||||
Ok(CopyResult {
|
||||
bytes_total: pr.bytes_total,
|
||||
bytes_good: pr.bytes_good,
|
||||
bytes_unreadable: pr.bytes_unreadable,
|
||||
bytes_pending: pr.bytes_pending,
|
||||
recovered_this_pass: pr.bytes_recovered_this_pass,
|
||||
complete: pr.bytes_pending == 0 && pr.bytes_unreadable > 0
|
||||
|| pr.bytes_pending == 0 && pr.bytes_unreadable == 0,
|
||||
halted: pr.halted,
|
||||
})
|
||||
}
|
||||
|
||||
fn sweep(
|
||||
&self,
|
||||
reader: &mut dyn SectorReader,
|
||||
path: &std::path::Path,
|
||||
opts: &SweepOptions,
|
||||
) -> Result<CopyResult> {
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
|
||||
@@ -1239,12 +1329,12 @@ impl Disc {
|
||||
};
|
||||
|
||||
// Mapfile: load if resuming, else wipe + recreate.
|
||||
let mapfile_path = mapfile_path_for(path);
|
||||
let mapfile_path = self.mapfile_for(path);
|
||||
if !opts.resume {
|
||||
let _ = std::fs::remove_file(&mapfile_path);
|
||||
}
|
||||
let mut map =
|
||||
mapfile::Mapfile::open_or_create(&mapfile_path, total_bytes, env!("CARGO_PKG_VERSION"))
|
||||
mapfile::Mapfile::open_or_create(&mapfile_path, total_bytes, concat!("libfreemkv v", env!("CARGO_PKG_VERSION")))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
|
||||
// ISO file: if resuming and mapfile has Finished ranges, open existing;
|
||||
@@ -1269,39 +1359,68 @@ impl Disc {
|
||||
let mut file = file;
|
||||
let batch: u16 = match opts.batch_sectors {
|
||||
Some(b) => b,
|
||||
None if opts.skip_forward => 32, // 64 KB = BD ECC block size
|
||||
None if opts.skip_on_error => ecc_sectors(self.format),
|
||||
None => DEFAULT_BATCH_SECTORS,
|
||||
};
|
||||
|
||||
// Skip-forward state.
|
||||
let skip_init = 256 * 1024u64; // 256 KB
|
||||
let skip_max = (total_bytes / 100).max(skip_init); // cap at 1% of disc
|
||||
let mut skip_size = skip_init;
|
||||
|
||||
let mut buf = vec![0u8; batch as usize * 2048];
|
||||
let mut bytes_done = 0u64;
|
||||
let mut halt_requested = false;
|
||||
let copy_t0 = std::time::Instant::now();
|
||||
let mut iter_count: u64 = 0;
|
||||
let mut read_ok_count: u64 = 0;
|
||||
let mut read_err_count: u64 = 0;
|
||||
let mut last_log_iter: u64 = 0;
|
||||
let mut not_ready_retries: u32 = 0;
|
||||
const NOT_READY_MAX_RETRIES: u32 = 3;
|
||||
let mut bridge_degradation_count: u32 = 0;
|
||||
const BRIDGE_DEGRADATION_MAX: u32 = 5;
|
||||
const BRIDGE_DEGRADATION_COOLDOWN_SECS: u64 = 10;
|
||||
const DAMAGE_WINDOW: usize = 50;
|
||||
const DAMAGE_THRESHOLD_PCT: usize = 25;
|
||||
const JUMP_SECTORS_FACTOR: u64 = 256;
|
||||
let mut damage_window: Vec<bool> = Vec::with_capacity(DAMAGE_WINDOW);
|
||||
let mut jump_multiplier: u64 = 1;
|
||||
let mut consecutive_good: u64 = 0;
|
||||
let mut in_damage_zone = false;
|
||||
tracing::trace!(
|
||||
target: "freemkv::disc",
|
||||
phase = "copy_start",
|
||||
total_bytes,
|
||||
batch,
|
||||
skip_on_error = opts.skip_on_error,
|
||||
"Disc::copy entered"
|
||||
);
|
||||
|
||||
// Iterate over not-yet-finished regions from the mapfile. We re-read the
|
||||
// mapfile after each block because record() mutates the region list.
|
||||
'outer: loop {
|
||||
let regions_to_do = map.ranges_with(&[
|
||||
mapfile::SectorStatus::NonTried,
|
||||
mapfile::SectorStatus::NonTrimmed,
|
||||
mapfile::SectorStatus::NonScraped,
|
||||
]);
|
||||
tracing::trace!(
|
||||
target: "freemkv::disc",
|
||||
phase = "outer_loop",
|
||||
regions_remaining = regions_to_do.len(),
|
||||
"Disc::copy outer iter"
|
||||
);
|
||||
if regions_to_do.is_empty() {
|
||||
break;
|
||||
}
|
||||
// Only process the first NonTried range per outer pass; skip_forward
|
||||
// may turn others into NonTrimmed which we DO NOT re-enter here —
|
||||
// Disc::patch handles those.
|
||||
let Some((region_pos, region_size)) = map.next_with(0, mapfile::SectorStatus::NonTried)
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let region_end = region_pos + region_size;
|
||||
let mut pos = region_pos;
|
||||
tracing::trace!(
|
||||
target: "freemkv::disc",
|
||||
phase = "region_enter",
|
||||
region_pos,
|
||||
region_size,
|
||||
region_end,
|
||||
"entering NonTried region"
|
||||
);
|
||||
|
||||
while pos < region_end {
|
||||
if let Some(ref h) = opts.halt {
|
||||
@@ -1310,158 +1429,351 @@ impl Disc {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
|
||||
let block_bytes = (region_end - pos).min(batch as u64 * 2048);
|
||||
let lba = (pos / 2048) as u32;
|
||||
let count = (block_bytes / 2048) as u16;
|
||||
let bytes = count as usize * 2048;
|
||||
let block_lba = (pos / 2048) as u32;
|
||||
let block_count = (block_bytes / 2048) as u16;
|
||||
let recovery = !opts.skip_on_error;
|
||||
|
||||
let recovery = !opts.skip_on_error; // fast reads when skipping
|
||||
let read_ok = reader
|
||||
.read_sectors(lba, count, &mut buf[..bytes], recovery)
|
||||
.is_ok();
|
||||
let read_result = reader.read_sectors(
|
||||
block_lba,
|
||||
block_count,
|
||||
&mut buf[..block_bytes as usize],
|
||||
recovery,
|
||||
);
|
||||
|
||||
let mut did_skip_ahead = false;
|
||||
|
||||
if read_result.is_ok() {
|
||||
read_ok_count += 1;
|
||||
damage_window.push(true);
|
||||
if damage_window.len() > DAMAGE_WINDOW {
|
||||
damage_window.remove(0);
|
||||
}
|
||||
consecutive_good += 1;
|
||||
if consecutive_good >= DAMAGE_WINDOW as u64 {
|
||||
jump_multiplier = 1;
|
||||
if in_damage_zone {
|
||||
in_damage_zone = false;
|
||||
reader.set_speed(0xFFFF);
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "damage_exit",
|
||||
lba = block_lba,
|
||||
"Exited damage zone; restoring max read speed"
|
||||
);
|
||||
}
|
||||
}
|
||||
bridge_degradation_count = 0;
|
||||
|
||||
if read_ok {
|
||||
if opts.decrypt {
|
||||
crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?;
|
||||
crate::decrypt::decrypt_sectors(
|
||||
&mut buf[..block_bytes as usize],
|
||||
&keys,
|
||||
0,
|
||||
)?;
|
||||
}
|
||||
file.seek(SeekFrom::Start(pos))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
file.write_all(&buf[..bytes])
|
||||
file.write_all(&buf[..block_bytes as usize])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
map.record(pos, block_bytes, mapfile::SectorStatus::Finished)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
bytes_done = bytes_done.saturating_add(block_bytes);
|
||||
skip_size = skip_init; // reset after success
|
||||
pos += block_bytes;
|
||||
} else if opts.skip_on_error {
|
||||
// Zero-fill this block, mark non-trimmed for later patch trim.
|
||||
buf[..bytes].fill(0);
|
||||
} else if !opts.skip_on_error {
|
||||
let (status, sense) = read_result
|
||||
.as_ref()
|
||||
.err()
|
||||
.map(extract_scsi_context)
|
||||
.unwrap_or((0, None));
|
||||
return Err(Error::DiscRead {
|
||||
sector: block_lba as u64,
|
||||
status: Some(status),
|
||||
sense,
|
||||
});
|
||||
} else {
|
||||
let err = read_result.err().unwrap();
|
||||
read_err_count += 1;
|
||||
consecutive_good = 0;
|
||||
|
||||
if err.is_scsi_transport_failure() {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "transport_failure",
|
||||
lba = block_lba,
|
||||
error = %err,
|
||||
"transport failure (bridge crash); aborting copy — caller should USB reset + resume"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if err.is_bridge_degradation() {
|
||||
if bridge_degradation_count < BRIDGE_DEGRADATION_MAX {
|
||||
bridge_degradation_count += 1;
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "bridge_degradation",
|
||||
lba = block_lba,
|
||||
degradation_count = bridge_degradation_count,
|
||||
error = %err,
|
||||
"bridge degradation; cooling down 10s"
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_secs(BRIDGE_DEGRADATION_COOLDOWN_SECS));
|
||||
continue;
|
||||
}
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "bridge_degradation_exhausted",
|
||||
lba = block_lba,
|
||||
"bridge degradation retries exhausted; treating as bad sector"
|
||||
);
|
||||
}
|
||||
|
||||
let sense = err.scsi_sense();
|
||||
let sense_key = sense.map(|s| s.sense_key).unwrap_or(0);
|
||||
let asc = sense.map(|s| s.asc).unwrap_or(0);
|
||||
let ascq = sense.map(|s| s.ascq).unwrap_or(0);
|
||||
|
||||
if sense_key == crate::scsi::SENSE_KEY_NOT_READY && not_ready_retries < NOT_READY_MAX_RETRIES {
|
||||
not_ready_retries += 1;
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "not_ready_pause",
|
||||
lba = block_lba,
|
||||
sense_key,
|
||||
asc,
|
||||
ascq,
|
||||
retry = not_ready_retries,
|
||||
"NOT READY; pausing 3s then retrying"
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
continue;
|
||||
}
|
||||
not_ready_retries = 0;
|
||||
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "skip_ecc_block",
|
||||
lba = block_lba,
|
||||
sectors = block_count,
|
||||
sense_key,
|
||||
asc,
|
||||
ascq,
|
||||
error = %err,
|
||||
"ECC block failed; marking NonTrimmed"
|
||||
);
|
||||
let zero = vec![0u8; block_bytes as usize];
|
||||
file.seek(SeekFrom::Start(pos))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
file.write_all(&buf[..bytes])
|
||||
file.write_all(&zero)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
map.record(pos, block_bytes, mapfile::SectorStatus::NonTrimmed)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
pos += block_bytes;
|
||||
bytes_done = bytes_done.saturating_add(block_bytes);
|
||||
|
||||
if opts.skip_forward && pos < region_end {
|
||||
// Skip ahead; mark skipped bytes as non-trimmed too.
|
||||
let jump = skip_size.min(region_end - pos);
|
||||
if jump > 0 {
|
||||
map.record(pos, jump, mapfile::SectorStatus::NonTrimmed)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
pos += jump;
|
||||
damage_window.push(false);
|
||||
if damage_window.len() > DAMAGE_WINDOW {
|
||||
damage_window.remove(0);
|
||||
}
|
||||
|
||||
let bad_count = damage_window.iter().filter(|&&b| !b).count();
|
||||
if damage_window.len() >= DAMAGE_WINDOW
|
||||
&& bad_count * 100 / damage_window.len() >= DAMAGE_THRESHOLD_PCT
|
||||
{
|
||||
let jump_sectors = JUMP_SECTORS_FACTOR * batch as u64 * jump_multiplier;
|
||||
let jump_lba = ((pos / 2048) + jump_sectors) as u32;
|
||||
let region_end_lba = (region_end / 2048) as u32;
|
||||
if jump_lba < region_end_lba {
|
||||
if !in_damage_zone {
|
||||
in_damage_zone = true;
|
||||
reader.set_speed(0x0000);
|
||||
tracing::debug!(
|
||||
target: "freemkv::disc",
|
||||
phase = "damage_enter",
|
||||
lba = block_lba,
|
||||
"Entered damage zone; dropping to minimum read speed"
|
||||
);
|
||||
}
|
||||
let jump_pos = jump_lba as u64 * 2048;
|
||||
let gap_start = pos + block_bytes;
|
||||
let gap_bytes = jump_pos.saturating_sub(gap_start);
|
||||
let jump_mb = gap_bytes / 1_048_576;
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "damage_jump",
|
||||
from_lba = block_lba,
|
||||
to_lba = jump_lba,
|
||||
jump_mb,
|
||||
bad_pct = bad_count * 100 / damage_window.len(),
|
||||
multiplier = jump_multiplier,
|
||||
"25%+ failures in last 50 blocks; jumping ahead"
|
||||
);
|
||||
if gap_bytes > 0 {
|
||||
let zero = vec![0u8; 65536];
|
||||
let mut filled: u64 = 0;
|
||||
while filled < gap_bytes {
|
||||
let chunk = (gap_bytes - filled).min(zero.len() as u64);
|
||||
file.seek(SeekFrom::Start(gap_start + filled))
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
file.write_all(&zero[..chunk as usize])
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
filled += chunk;
|
||||
}
|
||||
map.record(gap_start, gap_bytes, mapfile::SectorStatus::NonTrimmed)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
bytes_done = bytes_done.saturating_add(gap_bytes);
|
||||
}
|
||||
pos = jump_pos;
|
||||
jump_multiplier *= 2;
|
||||
did_skip_ahead = true;
|
||||
}
|
||||
skip_size = (skip_size * 2).min(skip_max);
|
||||
}
|
||||
} else {
|
||||
// Current behavior (pre-0.11.21): abort on first bad sector.
|
||||
return Err(Error::DiscRead { sector: lba as u64 });
|
||||
}
|
||||
|
||||
if let Some(cb) = opts.on_progress {
|
||||
if !did_skip_ahead {
|
||||
pos += block_bytes;
|
||||
}
|
||||
iter_count += 1;
|
||||
|
||||
if iter_count - last_log_iter >= 100 {
|
||||
last_log_iter = iter_count;
|
||||
let stats = map.stats();
|
||||
cb(stats.bytes_good, total_bytes);
|
||||
tracing::trace!(
|
||||
target: "freemkv::disc",
|
||||
phase = "iter_progress",
|
||||
iter_count,
|
||||
read_ok_count,
|
||||
read_err_count,
|
||||
pos,
|
||||
region_end,
|
||||
bytes_good = stats.bytes_good,
|
||||
bytes_pending = stats.bytes_pending,
|
||||
copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64,
|
||||
"Disc::copy inner iter"
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(reporter) = opts.progress {
|
||||
let stats = map.stats();
|
||||
reporter.report(&crate::progress::PassProgress {
|
||||
kind: crate::progress::PassKind::Sweep,
|
||||
work_done: pos,
|
||||
work_total: total_bytes,
|
||||
bytes_good_total: stats.bytes_good,
|
||||
bytes_bad_total: stats.bytes_unreadable + stats.bytes_retryable,
|
||||
bytes_total_disc: total_bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file.sync_all().map_err(|e| Error::IoError { source: e })?;
|
||||
let stats = map.stats();
|
||||
tracing::trace!(
|
||||
target: "freemkv::disc",
|
||||
phase = "copy_done",
|
||||
iter_count,
|
||||
read_ok_count,
|
||||
read_err_count,
|
||||
bytes_good = stats.bytes_good,
|
||||
bytes_pending = stats.bytes_pending,
|
||||
halted = halt_requested,
|
||||
copy_elapsed_ms = copy_t0.elapsed().as_millis() as u64,
|
||||
"Disc::copy returning"
|
||||
);
|
||||
Ok(CopyResult {
|
||||
bytes_total: total_bytes,
|
||||
bytes_good: stats.bytes_good,
|
||||
bytes_unreadable: stats.bytes_unreadable,
|
||||
bytes_pending: stats.bytes_pending,
|
||||
recovered_this_pass: 0,
|
||||
complete: stats.bytes_pending == 0 && !halt_requested,
|
||||
halted: halt_requested,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for `Disc::copy`. All fields default to the pre-v0.11.21 behavior
|
||||
/// (recovery reads, abort on bad sector).
|
||||
#[derive(Default)]
|
||||
pub struct CopyOptions<'a> {
|
||||
pub decrypt: bool,
|
||||
/// Resume from existing mapfile + ISO if present. Without this, any
|
||||
/// existing mapfile is wiped and the ISO recreated.
|
||||
pub resume: bool,
|
||||
/// Override the default block size. Defaults to 32 sectors (64 KB) in
|
||||
/// `skip_forward` mode, `DEFAULT_BATCH_SECTORS` otherwise.
|
||||
pub batch_sectors: Option<u16>,
|
||||
/// Zero-fill bad blocks in the ISO, mark them in the mapfile, continue.
|
||||
/// Uses fast reads (no drive-level recovery loop).
|
||||
pub skip_on_error: bool,
|
||||
/// ddrescue-style exponential skip-forward on block failure. Implies
|
||||
/// `skip_on_error`. The skipped region is marked `non-trimmed` for later
|
||||
/// trimming/scraping by `Disc::patch`.
|
||||
pub skip_forward: bool,
|
||||
pub on_progress: Option<&'a dyn Fn(u64, u64)>,
|
||||
pub multipass: bool,
|
||||
pub progress: Option<&'a dyn crate::progress::Progress>,
|
||||
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
}
|
||||
|
||||
/// Result of `Disc::copy`. `complete=true` means every byte reached a terminal
|
||||
/// state (Finished or Unreadable). `complete=false` means there's still pending
|
||||
/// work (halt, abort, or non-tried ranges) that `Disc::patch` or a resumed
|
||||
/// `Disc::copy` would continue.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CopyResult {
|
||||
pub bytes_total: u64,
|
||||
pub bytes_good: u64,
|
||||
pub bytes_unreadable: u64,
|
||||
pub bytes_pending: u64,
|
||||
pub recovered_this_pass: u64,
|
||||
pub complete: bool,
|
||||
pub halted: bool,
|
||||
}
|
||||
|
||||
/// Sidecar mapfile path for a given ISO path — `foo.iso` → `foo.iso.mapfile`.
|
||||
pub fn mapfile_path_for(iso_path: &std::path::Path) -> std::path::PathBuf {
|
||||
let mut s = iso_path.as_os_str().to_os_string();
|
||||
s.push(".mapfile");
|
||||
std::path::PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// Options for `Disc::patch`. Idempotent — each call is one patch attempt.
|
||||
#[derive(Default)]
|
||||
pub struct PatchOptions<'a> {
|
||||
pub(crate) struct SweepOptions<'a> {
|
||||
pub decrypt: bool,
|
||||
/// Sector-granularity block size for retries. Defaults to 1 sector (2 KB).
|
||||
pub block_sectors: Option<u16>,
|
||||
/// Use full drive-level recovery on each read (slow but thorough). Defaults
|
||||
/// to true — patch is the pass where we *want* the drive to try hard.
|
||||
pub full_recovery: bool,
|
||||
pub on_progress: Option<&'a dyn Fn(u64, u64)>,
|
||||
pub resume: bool,
|
||||
pub batch_sectors: Option<u16>,
|
||||
pub skip_on_error: bool,
|
||||
pub progress: Option<&'a dyn crate::progress::Progress>,
|
||||
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
}
|
||||
|
||||
/// Result of `Disc::patch` — how many bad bytes were recovered.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PatchResult {
|
||||
pub(crate) struct PatchOpts<'a> {
|
||||
pub decrypt: bool,
|
||||
pub block_sectors: Option<u16>,
|
||||
pub full_recovery: bool,
|
||||
pub reverse: bool,
|
||||
pub wedged_threshold: u64,
|
||||
pub progress: Option<&'a dyn crate::progress::Progress>,
|
||||
pub halt: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
|
||||
}
|
||||
|
||||
pub(crate) struct PatchOutcome {
|
||||
pub bytes_total: u64,
|
||||
pub bytes_good: u64,
|
||||
pub bytes_unreadable: u64,
|
||||
pub bytes_pending: u64,
|
||||
pub bytes_recovered_this_pass: u64,
|
||||
pub halted: bool,
|
||||
pub blocks_attempted: u64,
|
||||
pub blocks_read_ok: u64,
|
||||
pub blocks_read_failed: u64,
|
||||
pub wedged_exit: bool,
|
||||
}
|
||||
|
||||
pub fn mapfile_path_for(iso_path: &std::path::Path) -> std::path::PathBuf {
|
||||
let mut s = iso_path.as_os_str().to_os_string();
|
||||
s.push(".mapfile");
|
||||
std::path::PathBuf::from(s)
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
/// Patch an existing ISO using its sidecar mapfile. Re-reads every range
|
||||
/// that's not yet `+` (Finished) and writes successful bytes into the ISO
|
||||
/// at their exact offsets. Updates mapfile entries as it goes.
|
||||
///
|
||||
/// Idempotent — call repeatedly to apply more retry attempts. Stops early
|
||||
/// if a pass recovered zero bytes (no point continuing).
|
||||
pub fn patch(
|
||||
fn mapfile_for(&self, path: &std::path::Path) -> std::path::PathBuf {
|
||||
if path.as_os_str() == "/dev/null" {
|
||||
let name: String = self.meta_title.as_deref().unwrap_or(&self.volume_id)
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||
.collect();
|
||||
std::path::PathBuf::from(format!("/tmp/{name}.mapfile"))
|
||||
} else {
|
||||
mapfile_path_for(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Disc {
|
||||
fn patch(
|
||||
&self,
|
||||
reader: &mut dyn SectorReader,
|
||||
path: &std::path::Path,
|
||||
opts: &PatchOptions,
|
||||
) -> Result<PatchResult> {
|
||||
opts: &PatchOpts,
|
||||
) -> Result<PatchOutcome> {
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
|
||||
let mapfile_path = mapfile_path_for(path);
|
||||
let mapfile_path = self.mapfile_for(path);
|
||||
let mut map =
|
||||
mapfile::Mapfile::load(&mapfile_path).map_err(|e| Error::IoError { source: e })?;
|
||||
let total_bytes = map.total_size();
|
||||
@@ -1477,43 +1789,107 @@ impl Disc {
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
|
||||
let block_sectors = opts.block_sectors.unwrap_or(1);
|
||||
// Patch always reads with full drive recovery — this is the pass where
|
||||
// we want the drive's ECC retry machinery. Consumers who want fast-fail
|
||||
// use Disc::copy with skip_on_error instead.
|
||||
let _ = opts.full_recovery;
|
||||
let recovery = opts.full_recovery;
|
||||
|
||||
let bytes_good_before = map.stats().bytes_good;
|
||||
let mut halted = false;
|
||||
let mut wedged_exit = false;
|
||||
let mut blocks_attempted: u64 = 0;
|
||||
let mut blocks_read_ok: u64 = 0;
|
||||
let mut blocks_read_failed: u64 = 0;
|
||||
let mut consecutive_failures: u64 = 0;
|
||||
let mut unreadable_count: u64 = 0;
|
||||
let mut buf = vec![0u8; block_sectors as usize * 2048];
|
||||
|
||||
// Collect bad ranges up front. Iterating while mutating is fragile;
|
||||
// each recorded change is persisted, so resume works even if we crash
|
||||
// mid-loop.
|
||||
let bad_ranges = map.ranges_with(&[
|
||||
let mut bad_ranges = map.ranges_with(&[
|
||||
mapfile::SectorStatus::NonTried,
|
||||
mapfile::SectorStatus::NonTrimmed,
|
||||
mapfile::SectorStatus::NonScraped,
|
||||
mapfile::SectorStatus::Unreadable,
|
||||
]);
|
||||
// Reverse mode: walk ranges from highest LBA to lowest.
|
||||
if opts.reverse {
|
||||
bad_ranges.reverse();
|
||||
}
|
||||
// work_total = sum of all bad-range bytes. on_progress's third arg
|
||||
// is this value; the second arg (work_done) is incremented per block
|
||||
// attempted. UI consumers (autorip) compute pass_progress_pct =
|
||||
// work_done / work_total — true 0..100% per pass per RIP_DESIGN.md §16.
|
||||
let work_total: u64 = bad_ranges.iter().map(|(_, sz)| *sz).sum();
|
||||
let mut work_done: u64 = 0;
|
||||
tracing::trace!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_start",
|
||||
block_sectors,
|
||||
recovery,
|
||||
reverse = opts.reverse,
|
||||
wedged_threshold = opts.wedged_threshold,
|
||||
num_ranges = bad_ranges.len(),
|
||||
work_total,
|
||||
"Disc::patch entered"
|
||||
);
|
||||
|
||||
'outer: for (range_pos, range_size) in bad_ranges {
|
||||
let mut pos = range_pos;
|
||||
let end = range_pos + range_size;
|
||||
while pos < end {
|
||||
// In reverse mode, walk this range from end - block_bytes back to range_pos.
|
||||
// Each iteration emits the block ending at `block_end` (so reads land on
|
||||
// increasing LBAs internally; we just choose blocks back-to-front).
|
||||
let mut block_end = if opts.reverse { end } else { range_pos };
|
||||
loop {
|
||||
if let Some(ref h) = opts.halt {
|
||||
if h.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
halted = true;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
let block_bytes = (end - pos).min(block_sectors as u64 * 2048);
|
||||
// Compute block boundaries based on direction.
|
||||
let (pos, block_bytes) = if opts.reverse {
|
||||
if block_end <= range_pos {
|
||||
break;
|
||||
}
|
||||
let span = (block_end - range_pos).min(block_sectors as u64 * 2048);
|
||||
(block_end - span, span)
|
||||
} else {
|
||||
if block_end >= end {
|
||||
break;
|
||||
}
|
||||
let span = (end - block_end).min(block_sectors as u64 * 2048);
|
||||
(block_end, span)
|
||||
};
|
||||
let lba = (pos / 2048) as u32;
|
||||
let count = (block_bytes / 2048) as u16;
|
||||
let bytes = count as usize * 2048;
|
||||
let read_ok = reader
|
||||
.read_sectors(lba, count, &mut buf[..bytes], true)
|
||||
.is_ok();
|
||||
blocks_attempted += 1;
|
||||
let read_result = reader.read_sectors(lba, count, &mut buf[..bytes], recovery);
|
||||
// 0.13.23: parity with Disc::copy — bail immediately on a
|
||||
// non-marginal SCSI sense (transport failure, HARDWARE
|
||||
// ERROR, DATA PROTECT, UNIT ATTENTION, NOT READY,
|
||||
// ILLEGAL REQUEST, kernel IoError). The wedged_threshold
|
||||
// counter is a defense-in-depth backstop for ~50
|
||||
// consecutive marginal failures, but a single
|
||||
// non-marginal error already proves the drive can't
|
||||
// produce data this pass — don't waste 50× the timeout
|
||||
// budget proving it again.
|
||||
if let Err(ref e) = read_result {
|
||||
if !e.is_marginal_read() {
|
||||
let err = read_result.err().unwrap();
|
||||
tracing::trace!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_bail",
|
||||
lba,
|
||||
error = %err,
|
||||
"patch read failed with non-marginal sense; bailing"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
let read_ok = read_result.is_ok();
|
||||
if read_ok {
|
||||
blocks_read_ok += 1;
|
||||
consecutive_failures = 0;
|
||||
if opts.decrypt {
|
||||
crate::decrypt::decrypt_sectors(&mut buf[..bytes], &keys, 0)?;
|
||||
}
|
||||
@@ -1524,27 +1900,93 @@ impl Disc {
|
||||
map.record(pos, block_bytes, mapfile::SectorStatus::Finished)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
} else {
|
||||
blocks_read_failed += 1;
|
||||
consecutive_failures += 1;
|
||||
unreadable_count += 1;
|
||||
map.record(pos, block_bytes, mapfile::SectorStatus::Unreadable)
|
||||
.map_err(|e| Error::IoError { source: e })?;
|
||||
}
|
||||
pos += block_bytes;
|
||||
// Advance block_end in chosen direction.
|
||||
if opts.reverse {
|
||||
block_end = block_end.saturating_sub(block_bytes);
|
||||
} else {
|
||||
block_end += block_bytes;
|
||||
}
|
||||
|
||||
if let Some(cb) = opts.on_progress {
|
||||
// Wedged-drive early-exit: many consecutive failures with zero
|
||||
// recovered bytes this pass means the drive is stuck and won't
|
||||
// produce data this pass. Save the wallclock budget for productive
|
||||
// grinding; future passes (with smaller block size, reverse, or
|
||||
// after settle) may still recover.
|
||||
if opts.wedged_threshold > 0
|
||||
&& consecutive_failures >= opts.wedged_threshold
|
||||
&& blocks_read_ok == 0
|
||||
{
|
||||
tracing::trace!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_wedged_exit",
|
||||
consecutive_failures,
|
||||
blocks_read_failed,
|
||||
"Disc::patch giving up — drive appears wedged"
|
||||
);
|
||||
wedged_exit = true;
|
||||
break 'outer;
|
||||
}
|
||||
|
||||
// Track work done in this pass for the per-pass progress bar.
|
||||
// Each block iterated counts as work, regardless of read
|
||||
// outcome — a failed retry is still progress through the
|
||||
// bad-range walk.
|
||||
work_done = work_done.saturating_add(block_bytes);
|
||||
|
||||
if let Some(reporter) = opts.progress {
|
||||
let s = map.stats();
|
||||
cb(s.bytes_good, total_bytes);
|
||||
let kind = if block_sectors == 1 {
|
||||
crate::progress::PassKind::Scrape {
|
||||
reverse: opts.reverse,
|
||||
}
|
||||
} else {
|
||||
crate::progress::PassKind::Trim {
|
||||
reverse: opts.reverse,
|
||||
}
|
||||
};
|
||||
reporter.report(&crate::progress::PassProgress {
|
||||
kind,
|
||||
work_done,
|
||||
work_total,
|
||||
bytes_good_total: s.bytes_good,
|
||||
bytes_bad_total: s.bytes_unreadable + s.bytes_retryable,
|
||||
bytes_total_disc: total_bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file.sync_all().map_err(|e| Error::IoError { source: e })?;
|
||||
let stats = map.stats();
|
||||
Ok(PatchResult {
|
||||
tracing::info!(
|
||||
target: "freemkv::disc",
|
||||
phase = "patch_done",
|
||||
blocks_attempted,
|
||||
blocks_read_ok,
|
||||
blocks_read_failed,
|
||||
unreadable_count,
|
||||
wedged_exit,
|
||||
halted,
|
||||
bytes_recovered = stats.bytes_good.saturating_sub(bytes_good_before),
|
||||
"Disc::patch returning"
|
||||
);
|
||||
Ok(PatchOutcome {
|
||||
bytes_total: total_bytes,
|
||||
bytes_good: stats.bytes_good,
|
||||
bytes_unreadable: stats.bytes_unreadable,
|
||||
bytes_pending: stats.bytes_pending,
|
||||
bytes_recovered_this_pass: stats.bytes_good.saturating_sub(bytes_good_before),
|
||||
halted,
|
||||
blocks_attempted,
|
||||
blocks_read_ok,
|
||||
blocks_read_failed,
|
||||
wedged_exit,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1553,6 +1995,79 @@ const MAX_BATCH_SECTORS: u16 = 510;
|
||||
const DEFAULT_BATCH_SECTORS: u16 = 60;
|
||||
const MIN_BATCH_SECTORS: u16 = 3;
|
||||
|
||||
pub(crate) fn ecc_sectors(format: DiscFormat) -> u16 {
|
||||
match format {
|
||||
DiscFormat::Uhd | DiscFormat::BluRay => 32,
|
||||
DiscFormat::Dvd => 16,
|
||||
DiscFormat::Unknown => 32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Coarse damage tier for a finished or in-progress rip. Maps the
|
||||
/// observable signals (bad sector count + lost wallclock playback time)
|
||||
/// onto a small discrete classification so UIs can render a colored badge
|
||||
/// and operators can decide whether to rescan / replug / accept.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DamageSeverity {
|
||||
/// No bad sectors at all.
|
||||
Clean,
|
||||
/// 1–50 bad sectors AND <1 sec lost. Likely unnoticeable.
|
||||
Cosmetic,
|
||||
/// 51–500 sectors OR 1–30 sec lost. Visible artifacts possible.
|
||||
Moderate,
|
||||
/// 500+ sectors OR 30+ sec lost. Significant damage; consider rescan
|
||||
/// or different drive.
|
||||
Serious,
|
||||
}
|
||||
|
||||
/// Classify damage severity from raw counters. `bad_sectors` is the
|
||||
/// number of sectors marked unreadable (or NonTrimmed pending Pass 2);
|
||||
/// `lost_ms` is the cumulative wallclock playback time those sectors
|
||||
/// represent (computed from the title's bytes-per-sec).
|
||||
pub fn classify_damage(bad_sectors: u64, lost_ms: f64) -> DamageSeverity {
|
||||
if bad_sectors == 0 {
|
||||
return DamageSeverity::Clean;
|
||||
}
|
||||
if bad_sectors >= 500 || lost_ms >= 30_000.0 {
|
||||
return DamageSeverity::Serious;
|
||||
}
|
||||
if bad_sectors >= 51 || lost_ms >= 1_000.0 {
|
||||
return DamageSeverity::Moderate;
|
||||
}
|
||||
DamageSeverity::Cosmetic
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod severity_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn clean_when_no_damage() {
|
||||
assert_eq!(classify_damage(0, 0.0), DamageSeverity::Clean);
|
||||
}
|
||||
#[test]
|
||||
fn cosmetic_for_a_handful() {
|
||||
assert_eq!(classify_damage(1, 5.0), DamageSeverity::Cosmetic);
|
||||
assert_eq!(classify_damage(50, 999.0), DamageSeverity::Cosmetic);
|
||||
}
|
||||
#[test]
|
||||
fn moderate_threshold_by_sectors() {
|
||||
assert_eq!(classify_damage(51, 0.0), DamageSeverity::Moderate);
|
||||
}
|
||||
#[test]
|
||||
fn moderate_threshold_by_time() {
|
||||
assert_eq!(classify_damage(10, 1_000.0), DamageSeverity::Moderate);
|
||||
}
|
||||
#[test]
|
||||
fn serious_threshold_by_sectors() {
|
||||
assert_eq!(classify_damage(500, 0.0), DamageSeverity::Serious);
|
||||
}
|
||||
#[test]
|
||||
fn serious_threshold_by_time() {
|
||||
assert_eq!(classify_damage(10, 30_000.0), DamageSeverity::Serious);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the maximum transfer size in sectors for a device.
|
||||
/// Reads /sys/block/<dev>/queue/max_hw_sectors_kb on Linux.
|
||||
/// For sg devices, resolves the corresponding block device via sysfs.
|
||||
|
||||
+182
-233
@@ -1,19 +1,26 @@
|
||||
//! 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};
|
||||
@@ -50,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>,
|
||||
@@ -61,11 +65,9 @@ pub struct Drive {
|
||||
pub platform: Option<profile::Platform>,
|
||||
pub drive_id: DriveId,
|
||||
device_path: String,
|
||||
/// Bytes remaining in the min-speed recovery window.
|
||||
recovery_bytes_remaining: u64,
|
||||
/// Halt flag — when set, Drive::read() bails at the next check point.
|
||||
halt: Arc<AtomicBool>,
|
||||
/// Event handler — fires during read recovery.
|
||||
/// Event handler — fires for read errors and library-level state changes.
|
||||
event_fn: Option<Box<dyn Fn(Event) + Send>>,
|
||||
}
|
||||
|
||||
@@ -92,7 +94,6 @@ 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,
|
||||
})
|
||||
@@ -118,6 +119,9 @@ impl Drive {
|
||||
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 });
|
||||
@@ -128,6 +132,27 @@ impl Drive {
|
||||
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) {
|
||||
@@ -139,11 +164,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()
|
||||
@@ -156,31 +176,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));
|
||||
}
|
||||
@@ -235,94 +240,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(),
|
||||
@@ -488,22 +417,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.
|
||||
/// 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 {
|
||||
5_000
|
||||
} else if self.recovery_bytes_remaining > 0 {
|
||||
30_000
|
||||
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,
|
||||
@@ -517,99 +465,31 @@ 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.emit(EventKind::SpeedChange { speed_kbs: 0xFFFF });
|
||||
self.set_speed(0xFFFF);
|
||||
}
|
||||
}
|
||||
return Ok(result.bytes_transferred);
|
||||
}
|
||||
|
||||
// Read failed
|
||||
if !recovery {
|
||||
return Err(Error::DiscRead { sector: lba as u64 });
|
||||
}
|
||||
|
||||
// Enter recovery
|
||||
self.emit(EventKind::ReadError {
|
||||
sector: lba as u64,
|
||||
error: Error::DiscRead { sector: lba as u64 },
|
||||
});
|
||||
self.emit(EventKind::SpeedChange { speed_kbs: 0 });
|
||||
self.set_speed(0);
|
||||
|
||||
// Phase 1: gentle — sleep 30s, retry. 5 times.
|
||||
for attempt in 1..=5u32 {
|
||||
if self.is_halted() {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
self.emit(EventKind::Retry { attempt });
|
||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||
|
||||
if let Ok(result) = self.scsi.as_mut().execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
buf,
|
||||
30_000,
|
||||
) {
|
||||
self.emit(EventKind::SectorRecovered { sector: lba as u64 });
|
||||
self.recovery_bytes_remaining = RECOVERY_WINDOW;
|
||||
return Ok(result.bytes_transferred);
|
||||
Ok(result) => Ok(result.bytes_transferred),
|
||||
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"
|
||||
);
|
||||
Err(Error::DiscRead {
|
||||
sector: lba as u64,
|
||||
status: Some(status),
|
||||
sense,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if self.is_halted() {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
|
||||
// Phase 2: fresh start — close, reset, open, init.
|
||||
let device = std::path::PathBuf::from(&self.device_path);
|
||||
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||
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);
|
||||
|
||||
if self.is_halted() {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
|
||||
// Phase 3: gentle again on fresh connection — sleep 30s, retry. 5 times.
|
||||
for attempt in 6..=10u32 {
|
||||
if self.is_halted() {
|
||||
return Err(Error::Halted);
|
||||
}
|
||||
self.emit(EventKind::Retry { attempt });
|
||||
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||
|
||||
if let Ok(result) = self.scsi.as_mut().execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
buf,
|
||||
30_000,
|
||||
) {
|
||||
self.emit(EventKind::SectorRecovered { sector: lba as u64 });
|
||||
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 })
|
||||
}
|
||||
|
||||
/// Read the disc capacity in sectors (2048 bytes each).
|
||||
@@ -719,21 +599,42 @@ impl SectorReader for Drive {
|
||||
) -> Result<usize> {
|
||||
self.read(lba, count, buf, recovery)
|
||||
}
|
||||
|
||||
fn set_speed(&mut self, kbs: u16) {
|
||||
Drive::set_speed(self, kbs);
|
||||
}
|
||||
}
|
||||
|
||||
/// Find all optical drives connected to this system.
|
||||
/// Returns opened Drive objects ready for use.
|
||||
pub fn find_drives() -> Vec<Drive> {
|
||||
/// 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.
|
||||
@@ -776,10 +677,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());
|
||||
}
|
||||
}
|
||||
|
||||
+336
-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;
|
||||
@@ -49,6 +54,7 @@ pub const E_DISC_TITLE_RANGE: u16 = 6005;
|
||||
pub const E_IFO_PARSE: u16 = 6007;
|
||||
pub const E_MKV_INVALID: u16 = 6008;
|
||||
pub const E_NO_STREAMS: u16 = 6009;
|
||||
pub const E_MAPFILE_INVALID: u16 = 6011;
|
||||
|
||||
// AACS (7xxx)
|
||||
pub const E_AACS_NO_KEYS: u16 = 7000;
|
||||
@@ -84,6 +90,7 @@ pub const E_PES_FRAME_TOO_LARGE: u16 = 9005;
|
||||
pub const E_PES_INVALID_MAGIC: u16 = 9006;
|
||||
pub const E_ISO_TOO_LARGE: u16 = 9007;
|
||||
pub const E_NO_METADATA: u16 = 9008;
|
||||
pub const E_DISC_URL_NOT_DIRECT: u16 = 9009;
|
||||
|
||||
// ── Error enum ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -103,6 +110,24 @@ pub enum Error {
|
||||
DeviceResetFailed {
|
||||
path: String,
|
||||
},
|
||||
/// Platform-specific SCSI interface couldn't be obtained from the OS
|
||||
/// (macOS: `SCSITaskDeviceInterface` unavailable). The `path` field
|
||||
/// carries the device path; no English commentary on the failure mode.
|
||||
ScsiInterfaceUnavailable {
|
||||
path: String,
|
||||
},
|
||||
/// Device is held by another process / kernel state. `kr` is the
|
||||
/// platform return code (macOS IOReturn, Linux errno-equivalent).
|
||||
DeviceLocked {
|
||||
path: String,
|
||||
kr: u32,
|
||||
},
|
||||
/// macOS IOKit plugin couldn't be created for this device. `kr` is
|
||||
/// the IOReturn code from `IOCreatePlugInInterfaceForService`.
|
||||
IoKitPluginFailed {
|
||||
path: String,
|
||||
kr: u32,
|
||||
},
|
||||
|
||||
// Profile (2xxx)
|
||||
UnsupportedDrive {
|
||||
@@ -111,6 +136,16 @@ pub enum Error {
|
||||
product_revision: String,
|
||||
},
|
||||
ProfileParse,
|
||||
/// SCSI transport was requested on an OS without a backend
|
||||
/// implementation. `target` is the `std::env::consts::OS` value.
|
||||
UnsupportedPlatform {
|
||||
target: String,
|
||||
},
|
||||
/// Drive matched a known platform that we haven't implemented yet
|
||||
/// (e.g. Renesas firmware). `platform` is a stable identifier.
|
||||
PlatformNotImplemented {
|
||||
platform: String,
|
||||
},
|
||||
|
||||
// Unlock (3xxx)
|
||||
UnlockFailed,
|
||||
@@ -120,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)
|
||||
@@ -134,6 +183,8 @@ pub enum Error {
|
||||
// Disc format (6xxx)
|
||||
DiscRead {
|
||||
sector: u64,
|
||||
status: Option<u8>,
|
||||
sense: Option<crate::scsi::ScsiSense>,
|
||||
},
|
||||
/// Drive was halted by caller.
|
||||
Halted,
|
||||
@@ -149,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,
|
||||
@@ -202,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 {
|
||||
@@ -211,8 +272,13 @@ impl Error {
|
||||
Error::DevicePermission { .. } => E_DEVICE_PERMISSION,
|
||||
Error::DeviceNotReady { .. } => E_DEVICE_NOT_READY,
|
||||
Error::DeviceResetFailed { .. } => E_DEVICE_RESET_FAILED,
|
||||
Error::ScsiInterfaceUnavailable { .. } => E_SCSI_INTERFACE_UNAVAILABLE,
|
||||
Error::DeviceLocked { .. } => E_DEVICE_LOCKED,
|
||||
Error::IoKitPluginFailed { .. } => E_IOKIT_PLUGIN_FAILED,
|
||||
Error::UnsupportedDrive { .. } => E_UNSUPPORTED_DRIVE,
|
||||
Error::ProfileParse => E_PROFILE_PARSE,
|
||||
Error::UnsupportedPlatform { .. } => E_UNSUPPORTED_PLATFORM,
|
||||
Error::PlatformNotImplemented { .. } => E_PLATFORM_NOT_IMPLEMENTED,
|
||||
Error::UnlockFailed => E_UNLOCK_FAILED,
|
||||
Error::SignatureMismatch { .. } => E_SIGNATURE_MISMATCH,
|
||||
Error::ScsiError { .. } => E_SCSI_ERROR,
|
||||
@@ -226,6 +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,
|
||||
@@ -255,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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -267,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,
|
||||
@@ -295,19 +379,46 @@ 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 } => {
|
||||
@@ -357,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)
|
||||
@@ -366,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, NOT READY, 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);
|
||||
}
|
||||
}
|
||||
|
||||
+25
-18
@@ -17,6 +17,10 @@ use crate::disc::{DiscTitle, Stream};
|
||||
use crate::sector::SectorReader;
|
||||
use crate::udf::UdfFs;
|
||||
|
||||
// Re-exported via crate::disc — the public API surfaces these next to
|
||||
// AudioStream/SubtitleStream so callers can map purpose/qualifier to display
|
||||
// text in their own locale.
|
||||
|
||||
/// A stream label extracted from disc config files.
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
@@ -46,7 +50,6 @@ pub enum StreamLabelType {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
#[allow(dead_code)]
|
||||
pub enum LabelPurpose {
|
||||
Normal,
|
||||
Commentary,
|
||||
@@ -99,16 +102,12 @@ pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle
|
||||
if let Some(label) = labels.iter().find(|l| {
|
||||
l.stream_type == StreamLabelType::Audio && l.stream_number == audio_idx
|
||||
}) {
|
||||
// Structured fields — callers translate purpose to UI text.
|
||||
a.purpose = label.purpose;
|
||||
|
||||
// a.label only carries codec/variant info. NEVER any
|
||||
// English purpose text — the CLI handles that via i18n.
|
||||
let mut parts = Vec::new();
|
||||
match label.purpose {
|
||||
LabelPurpose::Commentary => parts.push("Commentary".to_string()),
|
||||
LabelPurpose::Descriptive => {
|
||||
parts.push("Descriptive Audio".to_string())
|
||||
}
|
||||
LabelPurpose::Score => parts.push("Score".to_string()),
|
||||
LabelPurpose::Ime => parts.push("IME".to_string()),
|
||||
LabelPurpose::Normal => {}
|
||||
}
|
||||
if !label.variant.is_empty() {
|
||||
parts.push(format!("({})", label.variant));
|
||||
}
|
||||
@@ -117,7 +116,10 @@ pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
a.label = parts.join(" ");
|
||||
} else if !label.name.is_empty() {
|
||||
} else if !label.name.is_empty() && label.purpose == LabelPurpose::Normal {
|
||||
// Only fall back to the parser-supplied display
|
||||
// name when there's no purpose to flag — the CLI
|
||||
// handles purpose rendering itself.
|
||||
a.label = label.name.clone();
|
||||
}
|
||||
}
|
||||
@@ -127,6 +129,7 @@ pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle
|
||||
if let Some(label) = labels.iter().find(|l| {
|
||||
l.stream_type == StreamLabelType::Subtitle && l.stream_number == sub_idx
|
||||
}) {
|
||||
s.qualifier = label.qualifier;
|
||||
if label.qualifier == LabelQualifier::Forced {
|
||||
s.forced = true;
|
||||
}
|
||||
@@ -173,9 +176,12 @@ fn generate_video_label(
|
||||
use crate::disc::HdrFormat;
|
||||
|
||||
if secondary {
|
||||
// "Dolby Vision EL" is a brand identifier, not English prose, so the
|
||||
// library may emit it. Other "secondary video" wording is a CLI
|
||||
// concern — the library just leaves the label empty.
|
||||
return match hdr {
|
||||
HdrFormat::DolbyVision => "Dolby Vision EL".to_string(),
|
||||
_ => "Secondary Video".to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -217,11 +223,12 @@ fn generate_video_label(
|
||||
fn generate_audio_label(
|
||||
codec: &crate::disc::Codec,
|
||||
channels: &crate::disc::AudioChannels,
|
||||
secondary: bool,
|
||||
_secondary: bool,
|
||||
) -> String {
|
||||
use crate::disc::{AudioChannels, Codec};
|
||||
|
||||
// Full marketing names for disc audio codecs
|
||||
// Full marketing names for disc audio codecs.
|
||||
// These are codec brand identifiers, not user-facing English prose.
|
||||
let codec_name = match codec {
|
||||
Codec::TrueHd => "Dolby TrueHD",
|
||||
Codec::Ac3 => "Dolby Digital",
|
||||
@@ -251,12 +258,12 @@ fn generate_audio_label(
|
||||
AudioChannels::Unknown => "",
|
||||
};
|
||||
|
||||
let suffix = if secondary { " (Secondary)" } else { "" };
|
||||
|
||||
// The "(Secondary)" suffix is a CLI/UI concern — callers display it from
|
||||
// the AudioStream::secondary bool, not the library.
|
||||
if channel_str.is_empty() {
|
||||
format!("{}{}", codec_name, suffix)
|
||||
codec_name.to_string()
|
||||
} else {
|
||||
format!("{} {}{}", codec_name, channel_str, suffix)
|
||||
format!("{} {}", codec_name, channel_str)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+59
-6
@@ -88,27 +88,73 @@ pub mod mux;
|
||||
pub mod pes;
|
||||
pub(crate) mod platform;
|
||||
pub mod profile;
|
||||
pub mod progress;
|
||||
pub mod scsi;
|
||||
pub mod sector;
|
||||
pub(crate) mod speed;
|
||||
pub(crate) mod udf;
|
||||
pub mod verify;
|
||||
|
||||
// ─── Drive lifecycle ────────────────────────────────────────────────────────
|
||||
//
|
||||
// `Drive::open(path)` → `wait_ready()` → `init()` → `Disc::scan()`. `Drive`
|
||||
// owns the SCSI session; `DriveCapture` etc. let advanced callers introspect
|
||||
// drive identity / profile data for sharing.
|
||||
pub use drive::capture::{
|
||||
CapturedFeature, DriveCapture, capture_drive_data, mask_bytes, mask_string,
|
||||
};
|
||||
pub use drive::{Drive, DriveStatus, find_drive, find_drives};
|
||||
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};
|
||||
|
||||
// ─── Drive events (low-level callbacks) ─────────────────────────────────────
|
||||
pub use event::{Event, EventKind};
|
||||
pub use identity::DriveId;
|
||||
pub use profile::DriveProfile;
|
||||
// Platform trait is pub(crate) -- callers use Drive, not Platform directly
|
||||
// Platform trait is pub(crate) — callers use Drive, not Platform directly.
|
||||
|
||||
// ─── Decryption (AACS / CSS) ────────────────────────────────────────────────
|
||||
//
|
||||
// `Disc::scan()` resolves keys and stores them on `Disc`; in most flows you
|
||||
// don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)`
|
||||
// accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is
|
||||
// for callers that operate on raw sector buffers (e.g. ISO patching).
|
||||
pub use decrypt::{DecryptKeys, decrypt_sectors};
|
||||
|
||||
// ─── Disc structure ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// `Disc::scan()` produces a fully-populated `Disc` (titles, streams, AACS
|
||||
// state). `Disc::identify()` is the fast path — UDF only, no playlist parse,
|
||||
// for displaying disc name + format quickly while a full scan runs in the
|
||||
// background. The codec / channel / resolution enums are the canonical
|
||||
// structured representation; never compare against display strings.
|
||||
pub use disc::{
|
||||
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, Disc,
|
||||
DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, Resolution, SampleRate,
|
||||
ScanOptions, Stream, SubtitleStream, VideoStream,
|
||||
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DamageSeverity,
|
||||
Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, LabelPurpose,
|
||||
LabelQualifier, Resolution, SampleRate, ScanOptions, Stream, SubtitleStream, 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 `SectorReader`). Always read.
|
||||
// - `MkvStream` — Matroska container. Read on `open()`, write on `create()`.
|
||||
// - `M2tsStream` — Blu-ray Transport Stream. Read on `open()`, write on `create()`.
|
||||
// - `NetworkStream` — TCP. Read on `listen()`, write on `connect()`.
|
||||
// - `NullStream` — write-only black-hole sink. Useful for benchmarks.
|
||||
// - `StdioStream` — pipe to/from stdin/stdout. Read or write.
|
||||
//
|
||||
// Most consumers use the URL resolvers (`input()` / `output()`) which pick
|
||||
// the right type from a scheme:// URL. Direct construction is for callers
|
||||
// that need to wire custom readers (e.g. autorip's drive-session reuse).
|
||||
pub use mux::DiscStream;
|
||||
pub use mux::M2tsStream;
|
||||
pub use mux::MkvStream;
|
||||
@@ -116,7 +162,14 @@ pub use mux::NetworkStream;
|
||||
pub use mux::NullStream;
|
||||
pub use mux::StdioStream;
|
||||
pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
|
||||
pub use scsi::ScsiTransport;
|
||||
|
||||
// ─── Lower-level surfaces ───────────────────────────────────────────────────
|
||||
//
|
||||
// `ScsiTransport` is the platform-abstraction trait Drive uses; expose for
|
||||
// out-of-tree platform backends. `SectorReader` lets callers feed any byte
|
||||
// source (test harness, network image, SMB share) into the disc scan
|
||||
// pipeline; `FileSectorReader` is the standard ISO-on-disk implementation.
|
||||
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives};
|
||||
pub use sector::{FileSectorReader, SectorReader};
|
||||
pub use speed::DriveSpeed;
|
||||
pub use udf::{UdfFs, read_filesystem};
|
||||
|
||||
+29
-1
@@ -6,6 +6,7 @@
|
||||
//! Read-only. For disc→ISO (raw sector copy), use `Disc::copy()`.
|
||||
|
||||
use crate::disc::{Disc, DiscTitle, Extent};
|
||||
use crate::drive::extract_scsi_context;
|
||||
use crate::event::{BatchSizeReason, Event, EventKind};
|
||||
use crate::sector::SectorReader;
|
||||
use std::io;
|
||||
@@ -127,6 +128,14 @@ pub struct DiscStream {
|
||||
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>,
|
||||
@@ -149,6 +158,7 @@ impl DiscStream {
|
||||
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();
|
||||
|
||||
let mut pids = Vec::new();
|
||||
let mut parsers = Vec::new();
|
||||
@@ -194,6 +204,8 @@ impl DiscStream {
|
||||
halt: None,
|
||||
event_fn: None,
|
||||
eof: false,
|
||||
bytes_read_total: 0,
|
||||
bytes_total_extents,
|
||||
ts_demuxer,
|
||||
ps_demuxer,
|
||||
parsers,
|
||||
@@ -287,6 +299,11 @@ impl DiscStream {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -301,7 +318,18 @@ impl DiscStream {
|
||||
self.current_offset += 1;
|
||||
break;
|
||||
} else {
|
||||
return Err(crate::error::Error::DiscRead { sector: lba as u64 }.into());
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,11 +87,6 @@ pub fn write_uint(w: &mut impl Write, id: u32, val: u64) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a complete EBML signed integer element.
|
||||
pub fn write_int(w: &mut impl Write, id: u32, val: i64) -> io::Result<()> {
|
||||
write_uint(w, id, val as u64)
|
||||
}
|
||||
|
||||
/// Write a complete EBML float element (8-byte double).
|
||||
pub fn write_float(w: &mut impl Write, id: u32, val: f64) -> io::Result<()> {
|
||||
write_id(w, id)?;
|
||||
@@ -309,22 +304,6 @@ pub fn read_binary_val(r: &mut impl Read, len: usize) -> io::Result<Vec<u8>> {
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Read a VINT (track number) from a SimpleBlock. Returns (value, bytes_consumed).
|
||||
pub fn read_vint(r: &mut impl Read) -> io::Result<(u64, usize)> {
|
||||
let mut first = [0u8; 1];
|
||||
r.read_exact(&mut first)?;
|
||||
let b0 = first[0];
|
||||
if b0 & 0x80 != 0 {
|
||||
return Ok(((b0 & 0x7F) as u64, 1));
|
||||
}
|
||||
if b0 & 0x40 != 0 {
|
||||
let mut b = [0u8; 1];
|
||||
r.read_exact(&mut b)?;
|
||||
return Ok(((((b0 & 0x3F) as u64) << 8) | b[0] as u64, 2));
|
||||
}
|
||||
Err(crate::error::Error::MkvInvalid.into())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Matroska Element IDs
|
||||
// ============================================================
|
||||
@@ -342,12 +321,6 @@ pub const EBML_DOC_TYPE_READ_VERSION: u32 = 0x4285;
|
||||
// Segment
|
||||
pub const SEGMENT: u32 = 0x1853_8067;
|
||||
|
||||
// Seek Head
|
||||
pub const SEEK_HEAD: u32 = 0x114D_9B74;
|
||||
pub const SEEK: u32 = 0x4DBB;
|
||||
pub const SEEK_ID: u32 = 0x53AB;
|
||||
pub const SEEK_POSITION: u32 = 0x53AC;
|
||||
|
||||
// Segment Info
|
||||
pub const INFO: u32 = 0x1549_A966;
|
||||
pub const TIMESTAMP_SCALE: u32 = 0x2A_D7B1;
|
||||
|
||||
+1
-2
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,7 @@ impl M2tsMeta {
|
||||
.parse()
|
||||
.unwrap_or(crate::disc::SampleRate::Unknown),
|
||||
secondary: *secondary,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: label.clone(),
|
||||
}),
|
||||
MetaStream::Subtitle {
|
||||
@@ -185,6 +186,7 @@ impl M2tsMeta {
|
||||
codec: codec.parse().unwrap_or(crate::disc::Codec::Unknown(0)),
|
||||
language: language.clone(),
|
||||
forced: *forced,
|
||||
qualifier: crate::disc::LabelQualifier::None,
|
||||
codec_data: None,
|
||||
}),
|
||||
})
|
||||
|
||||
+8
-46
@@ -8,7 +8,7 @@ use super::ebml;
|
||||
use crate::disc::{
|
||||
AudioStream, Chapter, Codec, ColorSpace, HdrFormat, SubtitleStream, VideoStream,
|
||||
};
|
||||
use std::io::{self, Seek, SeekFrom, Write};
|
||||
use std::io::{self, Seek, Write};
|
||||
|
||||
/// MKV track definition (built from disc stream metadata).
|
||||
pub struct MkvTrack {
|
||||
@@ -170,10 +170,6 @@ pub struct MkvMuxer<W: Write + Seek> {
|
||||
base_pts_ms: Option<i64>,
|
||||
cues: Vec<CuePoint>,
|
||||
frame_count: u64,
|
||||
/// File positions of codecPrivate placeholders (track_idx → offset, max_size).
|
||||
/// Used to seek back and fill in SPS/PPS after first keyframe.
|
||||
codec_private_slots: Vec<Option<(u64, usize)>>,
|
||||
codec_private_filled: Vec<bool>,
|
||||
}
|
||||
|
||||
/// New cluster every 5 seconds.
|
||||
@@ -219,8 +215,6 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
ebml::end_master(&mut writer, info_pos)?;
|
||||
|
||||
// Tracks
|
||||
let mut codec_private_slots: Vec<Option<(u64, usize)>> = Vec::new();
|
||||
let mut codec_private_filled: Vec<bool> = Vec::new();
|
||||
let tracks_pos = ebml::start_master(&mut writer, ebml::TRACKS)?;
|
||||
for (i, track) in tracks.iter().enumerate() {
|
||||
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
|
||||
@@ -243,20 +237,12 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
|
||||
if let Some(ref cp) = track.codec_private {
|
||||
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?;
|
||||
codec_private_slots.push(None); // already filled
|
||||
codec_private_filled.push(true);
|
||||
} else if track.track_type == ebml::TRACK_TYPE_VIDEO {
|
||||
// Reserve space for codecPrivate — will be filled after first keyframe
|
||||
// Reserve 256 bytes (enough for SPS+PPS or VPS+SPS+PPS)
|
||||
let cp_pos = writer.stream_position()?;
|
||||
let placeholder = vec![0u8; 256];
|
||||
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, &placeholder)?;
|
||||
codec_private_slots.push(Some((cp_pos, 256)));
|
||||
codec_private_filled.push(false);
|
||||
} else {
|
||||
codec_private_slots.push(None);
|
||||
codec_private_filled.push(true);
|
||||
}
|
||||
// Pre-0.13 a deferred codecPrivate path existed for video tracks
|
||||
// (placeholder reserve + later seek-back fill via
|
||||
// `fill_codec_private`). The PES pipeline hands codec_private
|
||||
// up-front via the DiscTitle, so the deferred path was never
|
||||
// exercised — removed in the 0.13 dead-code sweep.
|
||||
|
||||
// DefaultDuration — frame duration in nanoseconds
|
||||
if track.default_duration_ns > 0 {
|
||||
@@ -344,8 +330,6 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
base_pts_ms: None,
|
||||
cues: Vec::new(),
|
||||
frame_count: 0,
|
||||
codec_private_slots,
|
||||
codec_private_filled,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -415,30 +399,6 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fill in a deferred codecPrivate for a track.
|
||||
/// Seeks back to the placeholder, writes the actual data, restores position.
|
||||
pub fn fill_codec_private(&mut self, track_idx: usize, data: &[u8]) -> io::Result<()> {
|
||||
if track_idx >= self.codec_private_filled.len() || self.codec_private_filled[track_idx] {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((pos, max_size)) = self.codec_private_slots[track_idx] {
|
||||
if data.len() > max_size {
|
||||
// Data too large for reserved space — can't fill in place
|
||||
// This shouldn't happen with 256 bytes reserved
|
||||
return Ok(());
|
||||
}
|
||||
let current = self.writer.stream_position()?;
|
||||
self.writer.seek(SeekFrom::Start(pos))?;
|
||||
// Rewrite: element ID + size + data + zero-pad remainder
|
||||
let mut padded = data.to_vec();
|
||||
padded.resize(max_size, 0);
|
||||
ebml::write_binary(&mut self.writer, ebml::CODEC_PRIVATE, &padded)?;
|
||||
self.writer.seek(SeekFrom::Start(current))?;
|
||||
self.codec_private_filled[track_idx] = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_cluster(&mut self, ts_ms: i64) -> io::Result<()> {
|
||||
// Close previous cluster if open
|
||||
if self.cluster_open {
|
||||
@@ -810,6 +770,7 @@ mod tests {
|
||||
codec: Codec::Pgs,
|
||||
language: "eng".into(),
|
||||
forced: true,
|
||||
qualifier: crate::disc::LabelQualifier::Forced,
|
||||
codec_data: None,
|
||||
});
|
||||
assert!(forced_sub.is_forced);
|
||||
@@ -835,6 +796,7 @@ mod tests {
|
||||
codec: Codec::Pgs,
|
||||
language: "eng".into(),
|
||||
forced: false,
|
||||
qualifier: crate::disc::LabelQualifier::None,
|
||||
codec_data: None,
|
||||
});
|
||||
assert!(!sub.is_forced);
|
||||
|
||||
@@ -380,6 +380,7 @@ fn parse_track(
|
||||
language: lang,
|
||||
sample_rate: srs,
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: name,
|
||||
})),
|
||||
17 => Some(crate::disc::Stream::Subtitle(SubtitleStream {
|
||||
@@ -387,6 +388,7 @@ fn parse_track(
|
||||
codec,
|
||||
language: lang,
|
||||
forced,
|
||||
qualifier: crate::disc::LabelQualifier::None,
|
||||
codec_data: None,
|
||||
})),
|
||||
_ => None,
|
||||
|
||||
+30
-13
@@ -15,22 +15,32 @@
|
||||
//!
|
||||
//! For disc→ISO (raw sector copy), use `Disc::copy()` instead.
|
||||
|
||||
// Public modules — types here are intentionally part of the consumable API.
|
||||
pub mod codec;
|
||||
pub mod disc;
|
||||
pub mod ebml;
|
||||
pub mod iso;
|
||||
mod m2ts;
|
||||
pub mod meta;
|
||||
pub mod mkv;
|
||||
mod mkvstream;
|
||||
pub mod network;
|
||||
pub mod null;
|
||||
pub mod ps;
|
||||
pub mod resolve;
|
||||
pub mod stdio;
|
||||
pub mod ts;
|
||||
pub mod tsmux;
|
||||
pub mod tsreader;
|
||||
|
||||
// Internal modules — implementation details. Their *types* are re-exported
|
||||
// where appropriate (`MkvStream`, `M2tsStream`, etc. surface from `lib.rs`),
|
||||
// but the module paths themselves are not part of the API. Pre-0.13 these
|
||||
// were `pub`, leaking low-level EBML primitives, TS muxer internals, and
|
||||
// network/stdio implementations that no external caller had business
|
||||
// reaching for.
|
||||
pub(crate) mod ebml;
|
||||
pub(crate) mod m2ts;
|
||||
/// FMKV metadata header (used by `M2tsStream` / `NetworkStream` / `StdioStream`
|
||||
/// to round-trip codec_privates that don't fit inside the underlying format).
|
||||
/// Exposed for integration tests that exercise the wire format directly.
|
||||
pub mod meta;
|
||||
pub(crate) mod mkv;
|
||||
pub(crate) mod mkvstream;
|
||||
pub(crate) mod network;
|
||||
pub(crate) mod null;
|
||||
pub(crate) mod ps;
|
||||
pub(crate) mod stdio;
|
||||
pub(crate) mod ts;
|
||||
pub(crate) mod tsmux;
|
||||
|
||||
pub use disc::DiscStream;
|
||||
pub use iso::IsoSectorReader;
|
||||
@@ -43,6 +53,13 @@ pub use stdio::StdioStream;
|
||||
|
||||
use std::io::{Seek, Write};
|
||||
|
||||
// WriteSeek — used internally by MKV muxer (container format requires seeking).
|
||||
/// Combined `Write + Seek` for sinks accepted by the MKV muxer.
|
||||
///
|
||||
/// Matroska's `SeekHead`, `Cues`, and `Cluster` size fields are written with
|
||||
/// placeholder values during streaming and updated in-place at finalization,
|
||||
/// so the output sink must support seeking. Provided as a single trait
|
||||
/// alias so callers don't have to repeat `Write + Seek` everywhere; the
|
||||
/// blanket impl below opts every `T: Write + Seek` in automatically
|
||||
/// (`File`, `BufWriter<File>`, `Cursor<Vec<u8>>`).
|
||||
pub trait WriteSeek: Write + Seek {}
|
||||
impl<T: Write + Seek> WriteSeek for T {}
|
||||
|
||||
@@ -144,6 +144,7 @@ mod tests {
|
||||
language: "eng".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: crate::disc::LabelPurpose::Normal,
|
||||
label: "English".into(),
|
||||
}),
|
||||
],
|
||||
|
||||
+12
-19
@@ -171,17 +171,18 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
let parsed = parse_url(url);
|
||||
match parsed {
|
||||
StreamUrl::Disc { .. } => {
|
||||
// Disc sources should use DiscStream::new() directly.
|
||||
// The caller opens the drive, inits, scans, then creates the stream.
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"Use Drive::open() + Disc::scan() + DiscStream::new() for disc sources",
|
||||
))
|
||||
// Disc sources require live SCSI state — caller must use
|
||||
// `Drive::open() + Disc::scan() + DiscStream::new()` directly.
|
||||
// Surfaced as a typed error (no English commentary in the
|
||||
// library; the CLI/UI explains the right entry point).
|
||||
Err(crate::error::Error::DiscUrlNotDirect.into())
|
||||
}
|
||||
StreamUrl::Iso { ref path } => {
|
||||
validate_file_path(path, "iso")?;
|
||||
let scan_opts = match &opts.keydb_path {
|
||||
Some(p) => crate::disc::ScanOptions::with_keydb(p),
|
||||
Some(p) => crate::disc::ScanOptions {
|
||||
keydb_path: Some(p.into()),
|
||||
},
|
||||
None => crate::disc::ScanOptions::default(),
|
||||
};
|
||||
let mut reader = super::iso::IsoSectorReader::open(&path.to_string_lossy())?;
|
||||
@@ -210,17 +211,13 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
}
|
||||
StreamUrl::M2ts { ref path } => {
|
||||
validate_file_path(path, "m2ts")?;
|
||||
let file = std::fs::File::open(path).map_err(|e| {
|
||||
io::Error::new(e.kind(), format!("m2ts://{}: {}", path.display(), e))
|
||||
})?;
|
||||
let file = std::fs::File::open(path)?;
|
||||
let reader = std::io::BufReader::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(M2tsStream::open(reader)?))
|
||||
}
|
||||
StreamUrl::Mkv { ref path } => {
|
||||
validate_file_path(path, "mkv")?;
|
||||
let file = std::fs::File::open(path).map_err(|e| {
|
||||
io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e))
|
||||
})?;
|
||||
let file = std::fs::File::open(path)?;
|
||||
let reader = std::io::BufReader::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(MkvStream::open(reader)?))
|
||||
}
|
||||
@@ -245,18 +242,14 @@ pub fn output(
|
||||
match parsed {
|
||||
StreamUrl::Mkv { ref path } => {
|
||||
validate_file_path(path, "mkv")?;
|
||||
let file = std::fs::File::create(path).map_err(|e| {
|
||||
io::Error::new(e.kind(), format!("mkv://{}: {}", path.display(), e))
|
||||
})?;
|
||||
let file = std::fs::File::create(path)?;
|
||||
let writer: Box<dyn super::WriteSeek> =
|
||||
Box::new(std::io::BufWriter::with_capacity(IO_BUF_SIZE, file));
|
||||
Ok(Box::new(MkvStream::create(writer, title)?))
|
||||
}
|
||||
StreamUrl::M2ts { ref path } => {
|
||||
validate_file_path(path, "m2ts")?;
|
||||
let file = std::fs::File::create(path).map_err(|e| {
|
||||
io::Error::new(e.kind(), format!("m2ts://{}: {}", path.display(), e))
|
||||
})?;
|
||||
let file = std::fs::File::create(path)?;
|
||||
let writer = std::io::BufWriter::with_capacity(IO_BUF_SIZE, file);
|
||||
Ok(Box::new(M2tsStream::create(writer, title)?))
|
||||
}
|
||||
|
||||
+15
-115
@@ -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,115 +0,0 @@
|
||||
//! TsDemuxReader — reads from any source, demuxes BD-TS, produces PES frames.
|
||||
//!
|
||||
//! Wraps any Read source with a TsDemuxer + CodecParsers.
|
||||
//! One implementation used by M2TS, Network, Stdio, and any other BD-TS input.
|
||||
|
||||
use super::codec::{self, CodecParser};
|
||||
use super::ts::TsDemuxer;
|
||||
use crate::disc::Stream as DiscStream;
|
||||
use crate::pes::PesFrame;
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, Read};
|
||||
|
||||
const READ_BUF_SIZE: usize = 192 * 1024; // 1024 BD-TS packets
|
||||
|
||||
/// Generic BD-TS → PES frame reader.
|
||||
pub struct TsDemuxReader<R: Read> {
|
||||
reader: R,
|
||||
demuxer: TsDemuxer,
|
||||
parsers: Vec<(u16, Box<dyn CodecParser>)>,
|
||||
pid_to_track: Vec<(u16, usize)>,
|
||||
pending: VecDeque<PesFrame>,
|
||||
buf: Vec<u8>,
|
||||
eof: bool,
|
||||
}
|
||||
|
||||
impl<R: Read> TsDemuxReader<R> {
|
||||
/// Create from a reader and stream metadata.
|
||||
pub fn new(reader: R, streams: &[DiscStream]) -> Self {
|
||||
let mut pids = Vec::new();
|
||||
let mut parsers: Vec<(u16, Box<dyn CodecParser>)> = Vec::new();
|
||||
let mut pid_to_track = Vec::new();
|
||||
for (i, s) in streams.iter().enumerate() {
|
||||
let (pid, c) = match s {
|
||||
DiscStream::Video(v) => (v.pid, v.codec),
|
||||
DiscStream::Audio(a) => (a.pid, a.codec),
|
||||
DiscStream::Subtitle(s) => (s.pid, s.codec),
|
||||
};
|
||||
pids.push(pid);
|
||||
pid_to_track.push((pid, i));
|
||||
parsers.push((pid, codec::parser_for_codec(c, None)));
|
||||
}
|
||||
|
||||
Self {
|
||||
reader,
|
||||
demuxer: TsDemuxer::new(&pids),
|
||||
parsers,
|
||||
pid_to_track,
|
||||
pending: VecDeque::new(),
|
||||
buf: vec![0u8; READ_BUF_SIZE],
|
||||
eof: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the next PES frame. Returns None at EOF.
|
||||
pub fn next_frame(&mut self) -> io::Result<Option<PesFrame>> {
|
||||
if let Some(frame) = self.pending.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
if self.eof {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
loop {
|
||||
let n = self.reader.read(&mut self.buf)?;
|
||||
if n == 0 {
|
||||
self.eof = true;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let packets = self.demuxer.feed(&self.buf[..n]);
|
||||
for pes in &packets {
|
||||
if let Some((_, track)) = self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
if let Some((_, parser)) =
|
||||
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
|
||||
{
|
||||
for frame in parser.parse(pes) {
|
||||
self.pending
|
||||
.push_back(PesFrame::from_codec_frame(*track, frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(frame) = self.pending.pop_front() {
|
||||
return Ok(Some(frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Codec private data for a track.
|
||||
pub fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
|
||||
let pid = self
|
||||
.pid_to_track
|
||||
.iter()
|
||||
.find(|(_, idx)| *idx == track)
|
||||
.map(|(pid, _)| *pid)?;
|
||||
self.parsers
|
||||
.iter()
|
||||
.find(|(p, _)| *p == pid)
|
||||
.and_then(|(_, parser)| parser.codec_private())
|
||||
}
|
||||
|
||||
/// True when all primary video tracks have codec_private.
|
||||
pub fn headers_ready(&self, streams: &[DiscStream]) -> bool {
|
||||
for (idx, s) in streams.iter().enumerate() {
|
||||
if let DiscStream::Video(v) = s {
|
||||
if !v.secondary && self.codec_private(idx).is_none() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
+44
-7
@@ -78,26 +78,63 @@ impl PesFrame {
|
||||
}
|
||||
}
|
||||
|
||||
/// A stream. Read from it or write to it. Not both.
|
||||
/// A PES frame source or sink. Each implementor is **either** read-only or
|
||||
/// write-only — never both.
|
||||
///
|
||||
/// Implementors fall into two camps:
|
||||
///
|
||||
/// - **Read sources**: `DiscStream` (drive or ISO), `M2tsStream` (when
|
||||
/// constructed from an existing file), `MkvStream` (demux), `NetworkStream`
|
||||
/// (TCP listener), `StdioStream::input()`. These return frames from
|
||||
/// `read()` and surface `StreamWriteOnly` (E9001) from `write()`.
|
||||
/// - **Write sinks**: `MkvStream::create`, `M2tsStream::create`,
|
||||
/// `NetworkStream::connect`, `StdioStream::output()`, `NullStream`.
|
||||
/// These accept frames in `write()` and surface `StreamReadOnly` (E9000)
|
||||
/// from `read()`. Always call `finish()` when done — that's where MKV
|
||||
/// writes its `Cues` index and `M2tsStream` flushes the TS muxer.
|
||||
///
|
||||
/// Direction is established at construction; mixing produces an error code,
|
||||
/// not a panic. Most consumers don't construct streams directly — call
|
||||
/// `mux::input(url, opts)` / `mux::output(url, title)` and let URL parsing
|
||||
/// pick the right type.
|
||||
///
|
||||
/// `info()` returns the stream's `DiscTitle` metadata (track list, codec
|
||||
/// info, duration). For sources it's parsed from the input; for sinks it's
|
||||
/// the metadata supplied at creation. Stable across all reads.
|
||||
///
|
||||
/// `codec_private(track)` exposes per-track initialization data
|
||||
/// (H.264 SPS/PPS, HEVC VPS/SPS/PPS, AC-3 fscod, etc.) that some output
|
||||
/// formats need before any frame can be written. `headers_ready()` returns
|
||||
/// false until enough input frames have been seen to populate every video
|
||||
/// track's codec-private blob — callers buffer frames they read until
|
||||
/// `headers_ready()` returns true.
|
||||
pub trait Stream {
|
||||
/// Read the next frame. Returns None at end of stream.
|
||||
/// Read the next frame, or `Ok(None)` at end of stream. Returns
|
||||
/// `StreamWriteOnly` (E9001) on a write-only sink.
|
||||
fn read(&mut self) -> std::io::Result<Option<PesFrame>>;
|
||||
|
||||
/// Write a frame.
|
||||
/// Write a frame to the sink. Returns `StreamReadOnly` (E9000) on a
|
||||
/// read-only source.
|
||||
fn write(&mut self, frame: &PesFrame) -> std::io::Result<()>;
|
||||
|
||||
/// Finalize (flush, write index, close).
|
||||
/// Finalize the stream: flush buffered frames, write any container
|
||||
/// index (MKV `Cues`), close the underlying file/socket. Idempotent
|
||||
/// for read-only streams (no-op).
|
||||
fn finish(&mut self) -> std::io::Result<()>;
|
||||
|
||||
/// Stream metadata.
|
||||
/// Stream metadata. Stable across reads — implementors must return a
|
||||
/// consistent reference for the lifetime of the stream.
|
||||
fn info(&self) -> &crate::disc::DiscTitle;
|
||||
|
||||
/// Codec initialization data for a track (SPS/PPS, etc).
|
||||
/// Codec initialization data for a track (SPS/PPS, AC-3 fscod, etc.).
|
||||
/// `None` for tracks that don't need codec_private (raw passthrough).
|
||||
fn codec_private(&self, _track: usize) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// True when codec_private is available for all video tracks.
|
||||
/// True when `codec_private` is available for every video track —
|
||||
/// callers buffer input frames until this flips, since some output
|
||||
/// formats (MKV) can't write frames without codec init data.
|
||||
fn headers_ready(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//! 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`, mux) 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,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PassProgress {
|
||||
pub kind: PassKind,
|
||||
/// Bytes processed in this pass so far. Monotonically non-decreasing.
|
||||
pub work_done: u64,
|
||||
/// Total bytes this pass will process. Constant for the duration of
|
||||
/// the pass.
|
||||
pub work_total: u64,
|
||||
/// Cumulative bytes confirmed clean (`Finished` mapfile state) across
|
||||
/// every pass run on this rip. Doesn't change across pass boundaries.
|
||||
pub bytes_good_total: u64,
|
||||
/// Cumulative bytes marked bad (`NonTrimmed` + `NonScraped` +
|
||||
/// `Unreadable`) across every pass run on this rip.
|
||||
pub bytes_bad_total: u64,
|
||||
/// Total disc capacity in bytes. Constant.
|
||||
pub bytes_total_disc: u64,
|
||||
}
|
||||
|
||||
/// 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).
|
||||
///
|
||||
/// No `Send`/`Sync` bound — `report` is always called from the same thread
|
||||
/// running the rip 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);
|
||||
}
|
||||
|
||||
impl<F: Fn(&PassProgress)> Progress for F {
|
||||
fn report(&self, p: &PassProgress) {
|
||||
(self)(p)
|
||||
}
|
||||
}
|
||||
+377
-219
@@ -1,19 +1,29 @@
|
||||
//! Linux SCSI transport via async sg write/poll/read.
|
||||
//! Linux SCSI transport via synchronous blocking SG_IO ioctl.
|
||||
//!
|
||||
//! Uses the sg driver's asynchronous interface instead of the blocking
|
||||
//! SG_IO ioctl. Commands are submitted via write(), waited on via
|
||||
//! poll() with a hard timeout, and completed via read(). If poll()
|
||||
//! times out, the fd is abandoned (closed in a background thread) and
|
||||
//! a fresh fd is opened. This gives us true user-controlled timeouts
|
||||
//! that the kernel's USB error recovery cannot override.
|
||||
//! `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;
|
||||
@@ -47,23 +57,22 @@ 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(
|
||||
@@ -77,109 +86,32 @@ impl SgIoTransport {
|
||||
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 | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
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 | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
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 {
|
||||
@@ -189,8 +121,7 @@ impl SgIoTransport {
|
||||
}
|
||||
|
||||
/// Send a raw SCSI command on an fd. Used by reset() before the
|
||||
/// transport is constructed. Uses synchronous SG_IO — fine for
|
||||
/// short commands (TUR, PREVENT MEDIUM REMOVAL, START/STOP).
|
||||
/// transport is constructed.
|
||||
fn raw_command(fd: i32, cdb: &[u8], timeout_ms: u32) -> std::result::Result<(), ()> {
|
||||
let mut sense = [0u8; 32];
|
||||
let mut hdr: sg_io_hdr = unsafe { std::mem::zeroed() };
|
||||
@@ -206,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(())
|
||||
@@ -251,7 +182,7 @@ impl SgIoTransport {
|
||||
impl Drop for SgIoTransport {
|
||||
fn drop(&mut self) {
|
||||
if self.fd >= 0 {
|
||||
// Unlock tray before closing — don't leave it locked
|
||||
// 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) };
|
||||
}
|
||||
@@ -259,21 +190,33 @@ impl Drop for SgIoTransport {
|
||||
}
|
||||
|
||||
impl ScsiTransport for SgIoTransport {
|
||||
/// Execute a SCSI command with an enforceable timeout.
|
||||
/// Execute a SCSI command via synchronous blocking SG_IO.
|
||||
///
|
||||
/// Uses the sg driver's async write/poll/read interface:
|
||||
/// 1. write() submits the command — returns immediately
|
||||
/// 2. poll() waits for completion — respects our timeout exactly
|
||||
/// 3. read() retrieves the result — copies data to caller's buffer
|
||||
/// 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.
|
||||
///
|
||||
/// If poll() times out, the pending command is abandoned: the old fd
|
||||
/// is closed in a background thread (may block while kernel finishes
|
||||
/// the USB transfer) and a fresh fd is opened. The caller sees a
|
||||
/// normal SCSI error and can retry.
|
||||
/// Errors we surface to caller (any of these = command failed):
|
||||
///
|
||||
/// Without SG_FLAG_DIRECT_IO, the kernel uses internal buffers for
|
||||
/// DMA and copies to userspace during read(). On timeout (no read),
|
||||
/// the caller's buffer is untouched — safe to return immediately.
|
||||
/// - 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],
|
||||
@@ -281,30 +224,50 @@ impl ScsiTransport for SgIoTransport {
|
||||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult> {
|
||||
if self.fd < 0 {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut sense = [0u8; 32];
|
||||
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;
|
||||
@@ -317,109 +280,118 @@ impl ScsiTransport for SgIoTransport {
|
||||
hdr.timeout = timeout_ms;
|
||||
hdr.flags = SG_FLAG_Q_AT_HEAD;
|
||||
|
||||
// Submit command asynchronously via write()
|
||||
let hdr_size = std::mem::size_of::<sg_io_hdr>();
|
||||
let wr = unsafe {
|
||||
libc::write(
|
||||
self.fd,
|
||||
&hdr as *const sg_io_hdr as *const libc::c_void,
|
||||
hdr_size,
|
||||
)
|
||||
};
|
||||
if wr < 0 {
|
||||
return Err(Error::IoError {
|
||||
source: std::io::Error::last_os_error(),
|
||||
});
|
||||
// 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 {
|
||||
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 });
|
||||
}
|
||||
|
||||
// Wait for completion with enforceable timeout.
|
||||
// Retry on EINTR (signal interrupted poll) with remaining time.
|
||||
let deadline =
|
||||
std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms as u64);
|
||||
let pr = loop {
|
||||
let remaining = deadline
|
||||
.saturating_duration_since(std::time::Instant::now())
|
||||
.as_millis() as i32;
|
||||
if remaining <= 0 {
|
||||
break 0; // expired
|
||||
}
|
||||
let mut pfd = libc::pollfd {
|
||||
fd: self.fd,
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
let ret = unsafe { libc::poll(&mut pfd, 1, remaining) };
|
||||
if ret >= 0 || std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
|
||||
{
|
||||
break ret;
|
||||
}
|
||||
};
|
||||
// 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)"
|
||||
);
|
||||
|
||||
if pr <= 0 {
|
||||
// Timeout (0) or fatal poll error (-1).
|
||||
// Command is still pending in the kernel. Abandon this fd and
|
||||
// open a fresh one. The old fd is closed in a background thread
|
||||
// because close() blocks until the kernel completes/aborts the
|
||||
// pending command.
|
||||
// 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 || {
|
||||
unsafe { libc::close(old_fd) };
|
||||
if old_fd >= 0 {
|
||||
unsafe { libc::close(old_fd) };
|
||||
}
|
||||
});
|
||||
|
||||
let c_path = Self::to_c_path(&self.device_path);
|
||||
let new_fd = unsafe {
|
||||
libc::open(
|
||||
c_path.as_ptr() as *const libc::c_char,
|
||||
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
|
||||
)
|
||||
};
|
||||
self.fd = if new_fd >= 0 { new_fd } else { -1 };
|
||||
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: 0xFF,
|
||||
sense_key: 0,
|
||||
status: super::SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Read response — copies data from kernel buffer to caller's buffer
|
||||
let rd = unsafe {
|
||||
libc::read(
|
||||
self.fd,
|
||||
&mut hdr as *mut sg_io_hdr as *mut libc::c_void,
|
||||
hdr_size,
|
||||
)
|
||||
};
|
||||
if rd < 0 {
|
||||
return Err(Error::IoError {
|
||||
source: std::io::Error::last_os_error(),
|
||||
// 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 >= 3 {
|
||||
let response_code = sense[0] & 0x7F;
|
||||
if response_code == 0x72 || response_code == 0x73 {
|
||||
// Descriptor format sense: sense key at byte 1
|
||||
sense[1] & 0x0F
|
||||
} else {
|
||||
// Fixed format sense (0x70/0x71): sense key at byte 2
|
||||
sense[2] & 0x0F
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
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,
|
||||
@@ -427,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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+99
-407
@@ -1,183 +1,41 @@
|
||||
//! 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` so the kernel block-storage driver releases
|
||||
//! 2. Find `IOBDServices` directly via `IOServiceMatching` (not IOMedia walk)
|
||||
//! 3. Create `MMCDeviceInterface` → `SCSITaskDeviceInterface`
|
||||
//! 4. `ObtainExclusiveAccess`
|
||||
//! 5. Raw CDB dispatch via `CreateSCSITask` + `ExecuteTaskSync`
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
// ── External IOKit / CoreFoundation functions ───────────────────────────────
|
||||
|
||||
// Rust 2024: FFI blocks declaring extern fns must be `unsafe extern`.
|
||||
unsafe extern "C" {
|
||||
fn IOMasterPort(bootstrap: u32, master: *mut MachPort) -> IOReturn;
|
||||
fn IOBSDNameMatching(
|
||||
master: MachPort,
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
///
|
||||
/// # Safety
|
||||
/// `iface` must be a valid COM interface pointer (*mut *mut c_void), and
|
||||
/// `index` must be a valid vtable slot for the target type `T`.
|
||||
unsafe fn vtable_fn<T>(iface: ComRef, index: usize) -> T {
|
||||
// Rust 2024: `unsafe fn` bodies are no longer implicitly unsafe.
|
||||
// Each unsafe op needs its own `unsafe { }` block.
|
||||
unsafe {
|
||||
let vtable = *iface as *const *const std::ffi::c_void;
|
||||
let fn_ptr = *vtable.add(index);
|
||||
std::mem::transmute_copy(&fn_ptr)
|
||||
}
|
||||
}
|
||||
|
||||
/// Call Release (vtable index 3) on any COM interface.
|
||||
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 {
|
||||
@@ -186,7 +44,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/") {
|
||||
@@ -195,98 +52,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() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,198 +80,106 @@ 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 ────────────────────────────────────────────────────
|
||||
|
||||
/// 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),
|
||||
});
|
||||
}
|
||||
|
||||
// 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),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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)
|
||||
|
||||
// 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, c"IOService".as_ptr() as *const u8, &mut parent)
|
||||
pub(super) fn list_drives() -> Vec<super::DriveInfo> {
|
||||
let mut out = Vec::new();
|
||||
for i in 0..K_DEV_DISK_MAX {
|
||||
let path = format!("/dev/disk{i}");
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
continue;
|
||||
}
|
||||
let mut transport = match MacScsiTransport::open(std::path::Path::new(&path)) {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if current != start {
|
||||
unsafe { IOObjectRelease(current) };
|
||||
let inquiry = match super::inquiry(&mut transport) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if inquiry.raw.is_empty()
|
||||
|| (inquiry.raw[K_INQUIRY_TYPE_BYTE] & K_INQUIRY_TYPE_MASK) != K_SCSI_TYPE_OPTICAL
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(super::DriveInfo {
|
||||
path,
|
||||
vendor: inquiry.vendor_id,
|
||||
model: inquiry.model,
|
||||
firmware: inquiry.firmware,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
if kr != K_IO_RETURN_SUCCESS || parent == 0 {
|
||||
return None;
|
||||
}
|
||||
const K_DEV_DISK_MAX: u8 = 16;
|
||||
const K_INQUIRY_TYPE_BYTE: usize = 0;
|
||||
const K_INQUIRY_TYPE_MASK: u8 = 0x1F;
|
||||
const K_SCSI_TYPE_OPTICAL: u8 = 0x05;
|
||||
|
||||
// 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;
|
||||
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),
|
||||
}
|
||||
|
||||
if current != start {
|
||||
unsafe { IOObjectRelease(current) };
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
#include <IOKit/IOKitLib.h>
|
||||
#include <IOKit/IOCFPlugIn.h>
|
||||
#include <IOKit/scsi/SCSITaskLib.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
typedef struct {
|
||||
IOCFPlugInInterface **plugin;
|
||||
MMCDeviceInterface **mmc;
|
||||
SCSITaskDeviceInterface **scsi;
|
||||
int exclusive;
|
||||
} ShimHandle;
|
||||
|
||||
static ShimHandle g_handle = {NULL, NULL, NULL, 0};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
char cmd[128];
|
||||
snprintf(cmd, sizeof(cmd), "diskutil unmountDisk force %s 2>/dev/null", bsd_name);
|
||||
system(cmd);
|
||||
usleep(500000);
|
||||
|
||||
mach_port_t mp;
|
||||
IOMainPort(0, &mp);
|
||||
|
||||
CFMutableDictionaryRef matching = IOServiceMatching("IOBDServices");
|
||||
io_service_t 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;
|
||||
}
|
||||
|
||||
kr = (*g_handle.scsi)->ObtainExclusiveAccess(g_handle.scsi);
|
||||
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;
|
||||
}
|
||||
+477
-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,244 @@ 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. Doubles
|
||||
/// the fast-path budget so a sector that fails at 30 s gets one more
|
||||
/// honest attempt. Matches sg_dd's default per-command timeout
|
||||
/// (`DEF_TIMEOUT = 60000`).
|
||||
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
|
||||
/// - `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, NOT
|
||||
/// READY, 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_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 +324,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 +558,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);
|
||||
}
|
||||
}
|
||||
|
||||
+76
-13
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+8
-4
@@ -24,6 +24,8 @@ pub trait SectorReader: Send {
|
||||
fn capacity(&self) -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
fn set_speed(&mut self, _kbs: u16) {}
|
||||
}
|
||||
|
||||
/// SectorReader backed by a file (ISO image).
|
||||
@@ -39,10 +41,12 @@ impl FileSectorReader {
|
||||
let len = file.metadata()?.len();
|
||||
let sectors = len / 2048;
|
||||
if sectors > u32::MAX as u64 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("{path}: image too large, max ~8 TB"),
|
||||
));
|
||||
// ~8 TB hard cap (u32::MAX × 2048 bytes). Path lives in the
|
||||
// typed Error variant — no English in the message.
|
||||
return Err(crate::error::Error::IsoTooLarge {
|
||||
path: path.to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let capacity = sectors as u32;
|
||||
Ok(Self {
|
||||
|
||||
+42
-9
@@ -278,10 +278,11 @@ impl UdfFs {
|
||||
/// The data_lba is partition-relative.
|
||||
fn read_icb_extent(&self, reader: &mut dyn SectorReader, meta_lba: u32) -> Result<(u32, u32)> {
|
||||
let extents = self.read_icb_extents(reader, meta_lba)?;
|
||||
extents
|
||||
.first()
|
||||
.copied()
|
||||
.ok_or(Error::DiscRead { sector: 0 })
|
||||
extents.first().copied().ok_or(Error::DiscRead {
|
||||
sector: 0,
|
||||
status: None,
|
||||
sense: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read ALL allocation extents for a file from its ICB.
|
||||
@@ -307,6 +308,8 @@ impl UdfFs {
|
||||
if ad_offset + l_ad > icb.len() {
|
||||
return Err(Error::DiscRead {
|
||||
sector: self.meta_to_abs(meta_lba) as u64,
|
||||
status: None,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
(ad_offset, l_ad)
|
||||
@@ -319,11 +322,19 @@ impl UdfFs {
|
||||
if ad_offset + l_ad > icb.len() {
|
||||
return Err(Error::DiscRead {
|
||||
sector: self.meta_to_abs(meta_lba) as u64,
|
||||
status: None,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
(ad_offset, l_ad)
|
||||
}
|
||||
_ => return Err(Error::DiscRead { sector: 0 }),
|
||||
_ => {
|
||||
return Err(Error::DiscRead {
|
||||
sector: 0,
|
||||
status: None,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let mut extents = Vec::new();
|
||||
@@ -414,7 +425,11 @@ pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> {
|
||||
|
||||
let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]);
|
||||
if tag_id != 2 {
|
||||
return Err(Error::DiscRead { sector: 0 });
|
||||
return Err(Error::DiscRead {
|
||||
sector: 0,
|
||||
status: None,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Main VDS extent location: bytes [16:20] = LBA, [20:24] = length
|
||||
@@ -455,14 +470,22 @@ pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> {
|
||||
}
|
||||
|
||||
if partition_start == 0 {
|
||||
return Err(Error::DiscRead { sector: 0 });
|
||||
return Err(Error::DiscRead {
|
||||
sector: 0,
|
||||
status: None,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 3: Parse partition maps from LVD to find metadata partition
|
||||
// BD-ROM discs (UDF 2.50) use a metadata partition (Type 2 map with "*UDF Metadata Partition")
|
||||
// The metadata file is stored at lba=0 of the physical partition
|
||||
let metadata_start = if num_partition_maps >= 2 {
|
||||
let lvd_sec = lvd_sector.ok_or(Error::DiscRead { sector: 0 })?;
|
||||
let lvd_sec = lvd_sector.ok_or(Error::DiscRead {
|
||||
sector: 0,
|
||||
status: None,
|
||||
sense: None,
|
||||
})?;
|
||||
|
||||
// Read LVD to check partition map type
|
||||
let mut lvd = [0u8; 2048];
|
||||
@@ -497,6 +520,8 @@ pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> {
|
||||
if ad_off + 8 > meta_icb.len() {
|
||||
return Err(Error::DiscRead {
|
||||
sector: meta_file_lba as u64,
|
||||
status: None,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
let ad_len = u32::from_le_bytes([
|
||||
@@ -536,7 +561,11 @@ pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> {
|
||||
|
||||
let fsd_tag = u16::from_le_bytes([fsd[0], fsd[1]]);
|
||||
if fsd_tag != 256 {
|
||||
return Err(Error::DiscRead { sector: 0 });
|
||||
return Err(Error::DiscRead {
|
||||
sector: 0,
|
||||
status: None,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Root Directory ICB: long_ad at FSD offset 400
|
||||
@@ -585,6 +614,8 @@ fn read_directory(
|
||||
if ad_off + 8 > icb.len() {
|
||||
return Err(Error::DiscRead {
|
||||
sector: (meta_start + meta_lba) as u64,
|
||||
status: None,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
let len = u32::from_le_bytes([
|
||||
@@ -607,6 +638,8 @@ fn read_directory(
|
||||
if ad_off + 8 > icb.len() {
|
||||
return Err(Error::DiscRead {
|
||||
sector: (meta_start + meta_lba) as u64,
|
||||
status: None,
|
||||
sense: None,
|
||||
});
|
||||
}
|
||||
let len = u32::from_le_bytes([
|
||||
|
||||
@@ -361,7 +361,9 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
plaintext[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
// Fill the rest with a recognisable pattern (prime modulus avoids artefacts)
|
||||
// Fill the rest with a recognisable pattern (prime modulus avoids artefacts).
|
||||
// Index-arithmetic — clearer with a counted loop than an enumerate chain.
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for i in 16..aacs::ALIGNED_UNIT_LEN {
|
||||
if plaintext[i] == 0 {
|
||||
plaintext[i] = (i % 251) as u8;
|
||||
@@ -462,6 +464,7 @@ fn aacs_bus_decrypt_cross_validation() {
|
||||
];
|
||||
|
||||
let mut plaintext = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for i in 0..aacs::ALIGNED_UNIT_LEN {
|
||||
plaintext[i] = ((i * 3 + 17) & 0xFF) as u8;
|
||||
}
|
||||
@@ -494,6 +497,7 @@ fn css_roundtrip_with_snapshot() {
|
||||
let mut sector = vec![0x00u8; 2048];
|
||||
sector[0x14] = 0x30;
|
||||
sector[0x54..0x59].copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF, 0x42]);
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for i in 0x80..2048 {
|
||||
sector[i] = ((i * 7 + 3) & 0xFF) as u8;
|
||||
}
|
||||
@@ -661,6 +665,7 @@ fn css_recover_title_key_with_exact_plaintext() {
|
||||
sector[0x54..0x59].copy_from_slice(&seed);
|
||||
let pes_header: [u8; 10] = [0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21];
|
||||
sector[0x80..0x8A].copy_from_slice(&pes_header);
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for i in 0x8A..2048 {
|
||||
sector[i] = ((i * 13 + 7) & 0xFF) as u8;
|
||||
}
|
||||
|
||||
+9
-3
@@ -133,7 +133,9 @@ fn scan_options_default() {
|
||||
|
||||
#[test]
|
||||
fn scan_options_with_keydb() {
|
||||
let opts = ScanOptions::with_keydb("/tmp/KEYDB.cfg");
|
||||
let opts = ScanOptions {
|
||||
keydb_path: Some(("/tmp/KEYDB.cfg").into()),
|
||||
};
|
||||
assert_eq!(
|
||||
opts.keydb_path.as_ref().unwrap().to_str().unwrap(),
|
||||
"/tmp/KEYDB.cfg"
|
||||
@@ -143,7 +145,9 @@ fn scan_options_with_keydb() {
|
||||
#[test]
|
||||
fn scan_options_with_keydb_pathbuf() {
|
||||
let path = std::path::PathBuf::from("/home/user/.config/aacs/KEYDB.cfg");
|
||||
let opts = ScanOptions::with_keydb(path.clone());
|
||||
let opts = ScanOptions {
|
||||
keydb_path: Some(path.clone()),
|
||||
};
|
||||
assert_eq!(opts.keydb_path.unwrap(), path);
|
||||
}
|
||||
|
||||
@@ -511,7 +515,9 @@ fn resolve_encryption_no_keydb() {
|
||||
build_udf_with_aacs_dir(&mut reader);
|
||||
|
||||
// No keydb configured and no standard keydb on the system
|
||||
let opts = ScanOptions::with_keydb("/nonexistent/path/KEYDB.cfg");
|
||||
let opts = ScanOptions {
|
||||
keydb_path: Some(("/nonexistent/path/KEYDB.cfg").into()),
|
||||
};
|
||||
let disc = Disc::scan_image(&mut reader, 1000, &opts).unwrap();
|
||||
|
||||
// The disc detects encryption but can't resolve keys without a keydb
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
//! Integration tests for progress reporting, halt behavior, drop safety,
|
||||
//! and the file-backed sector reader round trip.
|
||||
|
||||
use libfreemkv::disc::{CopyOptions, DiscRegion};
|
||||
use libfreemkv::error::Result;
|
||||
use libfreemkv::pes::Stream as PesStream;
|
||||
use libfreemkv::{
|
||||
ContentFormat, Disc, DiscFormat, DiscStream, DiscTitle, EventKind, Extent, FileSectorReader,
|
||||
SectorReader,
|
||||
};
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const SECTOR_SIZE: usize = 2048;
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns zeroed sectors. Always succeeds. Counts each call.
|
||||
struct ZeroSectorReader {
|
||||
capacity: u32,
|
||||
calls: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl ZeroSectorReader {
|
||||
fn new(capacity: u32) -> Self {
|
||||
Self {
|
||||
capacity,
|
||||
calls: Arc::new(AtomicU64::new(0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for ZeroSectorReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
||||
let bytes = count as usize * SECTOR_SIZE;
|
||||
buf[..bytes].fill(0);
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn capacity(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
|
||||
/// Like ZeroSectorReader but sleeps a configurable duration per call.
|
||||
/// Used by the halt test so the copy takes >1 s.
|
||||
struct SlowZeroSectorReader {
|
||||
capacity: u32,
|
||||
sleep_per_call: Duration,
|
||||
}
|
||||
|
||||
impl SlowZeroSectorReader {
|
||||
fn new(capacity: u32, sleep_per_call: Duration) -> Self {
|
||||
Self {
|
||||
capacity,
|
||||
sleep_per_call,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for SlowZeroSectorReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
std::thread::sleep(self.sleep_per_call);
|
||||
let bytes = count as usize * SECTOR_SIZE;
|
||||
buf[..bytes].fill(0);
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn capacity(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a Disc instance with a known capacity, no titles, no encryption.
|
||||
/// Sufficient for `Disc::copy` (which only uses capacity_sectors + decrypt keys).
|
||||
fn synthetic_disc(capacity_sectors: u32) -> Disc {
|
||||
Disc {
|
||||
volume_id: String::new(),
|
||||
meta_title: None,
|
||||
format: DiscFormat::BluRay,
|
||||
capacity_sectors,
|
||||
capacity_bytes: capacity_sectors as u64 * SECTOR_SIZE as u64,
|
||||
layers: 1,
|
||||
titles: Vec::new(),
|
||||
region: DiscRegion::Free,
|
||||
aacs: None,
|
||||
css: None,
|
||||
encrypted: false,
|
||||
content_format: ContentFormat::BdTs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a DiscTitle with a single extent of `sector_count` sectors and no
|
||||
/// streams (DiscStream still iterates sectors and would emit BytesRead).
|
||||
fn synthetic_title(sector_count: u32) -> DiscTitle {
|
||||
DiscTitle {
|
||||
playlist: String::new(),
|
||||
playlist_id: 0,
|
||||
duration_secs: 0.0,
|
||||
size_bytes: sector_count as u64 * SECTOR_SIZE as u64,
|
||||
clips: Vec::new(),
|
||||
streams: Vec::new(),
|
||||
chapters: Vec::new(),
|
||||
extents: vec![Extent {
|
||||
start_lba: 0,
|
||||
sector_count,
|
||||
}],
|
||||
content_format: ContentFormat::BdTs,
|
||||
codec_privates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1. BytesRead events emitted during disc copy (TDD red) ────────────────
|
||||
|
||||
#[test]
|
||||
fn test_bytes_read_emitted_during_disc_copy() {
|
||||
// Build a tiny synthetic disc and stream it through DiscStream.
|
||||
let reader = ZeroSectorReader::new(64);
|
||||
let title = synthetic_title(64);
|
||||
let keys = libfreemkv::DecryptKeys::None;
|
||||
|
||||
let mut stream = DiscStream::new(Box::new(reader), title, keys, 60, ContentFormat::BdTs);
|
||||
|
||||
let count = Arc::new(AtomicU64::new(0));
|
||||
let count_cb = count.clone();
|
||||
stream.on_event(move |ev| {
|
||||
if let EventKind::BytesRead { .. } = ev.kind {
|
||||
count_cb.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
|
||||
// Drive the stream to EOF. With no streams configured, read() returns
|
||||
// Ok(None) once all extents are exhausted.
|
||||
loop {
|
||||
match stream.read() {
|
||||
Ok(Some(_frame)) => {}
|
||||
Ok(None) => break,
|
||||
Err(e) => panic!("stream read failed: {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
let n = count.load(Ordering::Relaxed);
|
||||
// EXPECTED TO FAIL until BytesRead emission is wired up. TDD red.
|
||||
assert!(
|
||||
n > 0,
|
||||
"expected at least one BytesRead event, got {n} (lib does not yet emit BytesRead)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. Disc::copy on_progress callback fires (regression guard) ───────────
|
||||
|
||||
#[test]
|
||||
fn test_disc_copy_progress_callback_fires() {
|
||||
let disc = synthetic_disc(64);
|
||||
let mut reader = ZeroSectorReader::new(64);
|
||||
|
||||
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
||||
let iso_path = tmp.path().to_path_buf();
|
||||
drop(tmp); // we want the path, not the file handle
|
||||
|
||||
let calls = Arc::new(AtomicU64::new(0));
|
||||
let last_bytes = Arc::new(AtomicU64::new(0));
|
||||
|
||||
struct CountingReporter {
|
||||
calls: Arc<AtomicU64>,
|
||||
last_bytes: Arc<AtomicU64>,
|
||||
}
|
||||
impl libfreemkv::progress::Progress for CountingReporter {
|
||||
fn report(&self, p: &libfreemkv::progress::PassProgress) {
|
||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
||||
self.last_bytes.store(p.bytes_good_total, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
let reporter = CountingReporter {
|
||||
calls: calls.clone(),
|
||||
last_bytes: last_bytes.clone(),
|
||||
};
|
||||
|
||||
let opts = CopyOptions {
|
||||
decrypt: false,
|
||||
progress: Some(&reporter),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = disc.copy(&mut reader, &iso_path, &opts).expect("copy ok");
|
||||
|
||||
// Cleanup any sidecar mapfile + ISO before assertions.
|
||||
let _ = std::fs::remove_file(&iso_path);
|
||||
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
||||
|
||||
assert!(result.complete, "copy should be complete");
|
||||
let n = calls.load(Ordering::Relaxed);
|
||||
let last = last_bytes.load(Ordering::Relaxed);
|
||||
assert!(n > 0, "on_progress should fire at least once, got {n}");
|
||||
assert!(
|
||||
last > 0,
|
||||
"final progress bytes should be non-zero, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. Halt aborts disc copy promptly ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_halt_aborts_disc_copy_promptly() {
|
||||
// 6000 sectors, 60-sector batches → 100 read_sectors() calls.
|
||||
// 10 ms sleep per call → ~1 s total without halt.
|
||||
let capacity_sectors: u32 = 6000;
|
||||
let mut reader = SlowZeroSectorReader::new(capacity_sectors, Duration::from_millis(10));
|
||||
let disc = synthetic_disc(capacity_sectors);
|
||||
|
||||
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
||||
let iso_path = tmp.path().to_path_buf();
|
||||
drop(tmp);
|
||||
|
||||
let halt = Arc::new(AtomicBool::new(false));
|
||||
let halt_for_thread = halt.clone();
|
||||
let iso_path_for_thread = iso_path.clone();
|
||||
|
||||
let join = std::thread::spawn(move || {
|
||||
let opts = CopyOptions {
|
||||
decrypt: false,
|
||||
halt: Some(halt_for_thread),
|
||||
..Default::default()
|
||||
};
|
||||
let t0 = Instant::now();
|
||||
let res = disc.copy(&mut reader, &iso_path_for_thread, &opts);
|
||||
(res, t0.elapsed())
|
||||
});
|
||||
|
||||
// Let copy run, then halt.
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
halt.store(true, Ordering::Relaxed);
|
||||
|
||||
// Bound the join: should exit far before the full 1 s otherwise needed.
|
||||
let started = Instant::now();
|
||||
let mut joined = None;
|
||||
while started.elapsed() < Duration::from_millis(2000) {
|
||||
if join.is_finished() {
|
||||
joined = Some(join.join().expect("thread join"));
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let (result, elapsed) = joined.expect("copy thread did not exit within 2s of halt");
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_file(&iso_path);
|
||||
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
||||
|
||||
let copy_result = result.expect("copy returns Ok with halted=true on halt");
|
||||
assert!(
|
||||
copy_result.halted,
|
||||
"copy_result.halted should be true after halt"
|
||||
);
|
||||
assert!(
|
||||
!copy_result.complete,
|
||||
"copy_result.complete should be false when halted"
|
||||
);
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(2000),
|
||||
"copy thread exit elapsed {elapsed:?} exceeded 2s"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. DiscStream Drop does not panic or block ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_drop_impls_do_not_panic_or_block() {
|
||||
let reader = ZeroSectorReader::new(64);
|
||||
let title = synthetic_title(64);
|
||||
let keys = libfreemkv::DecryptKeys::None;
|
||||
let stream = DiscStream::new(Box::new(reader), title, keys, 60, ContentFormat::BdTs);
|
||||
|
||||
// Drop on a worker thread; main thread enforces the timeout.
|
||||
let handle = std::thread::spawn(move || {
|
||||
drop(stream);
|
||||
});
|
||||
|
||||
let started = Instant::now();
|
||||
while started.elapsed() < Duration::from_millis(100) {
|
||||
if handle.is_finished() {
|
||||
handle.join().expect("drop thread join");
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
panic!("DiscStream drop did not complete within 100ms");
|
||||
}
|
||||
|
||||
// ── 5. FileSectorReader round trip ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_file_sector_reader_round_trip() {
|
||||
// Build 8 sectors of pseudo-random bytes (sector-aligned).
|
||||
const N_SECTORS: usize = 8;
|
||||
let mut data = vec![0u8; N_SECTORS * SECTOR_SIZE];
|
||||
for (i, b) in data.iter_mut().enumerate() {
|
||||
// Cheap PRNG: just a multiplicative pattern, deterministic for asserts.
|
||||
*b = ((i as u64).wrapping_mul(2654435761) >> 16) as u8;
|
||||
}
|
||||
|
||||
let mut tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
||||
tmp.write_all(&data).expect("write data");
|
||||
tmp.flush().expect("flush");
|
||||
|
||||
let path = tmp.path().to_str().expect("path utf-8").to_string();
|
||||
let mut fsr = FileSectorReader::open(&path).expect("open FileSectorReader");
|
||||
|
||||
assert_eq!(fsr.capacity(), N_SECTORS as u32, "capacity mismatch");
|
||||
|
||||
// Read each sector individually and compare.
|
||||
let mut buf = vec![0u8; SECTOR_SIZE];
|
||||
for lba in 0..N_SECTORS as u32 {
|
||||
let n = fsr
|
||||
.read_sectors(lba, 1, &mut buf, false)
|
||||
.expect("read_sectors");
|
||||
assert_eq!(n, SECTOR_SIZE);
|
||||
let off = lba as usize * SECTOR_SIZE;
|
||||
assert_eq!(
|
||||
&buf[..],
|
||||
&data[off..off + SECTOR_SIZE],
|
||||
"sector {lba} mismatch"
|
||||
);
|
||||
}
|
||||
|
||||
// Read all sectors at once and compare.
|
||||
let mut all = vec![0u8; N_SECTORS * SECTOR_SIZE];
|
||||
let n = fsr
|
||||
.read_sectors(0, N_SECTORS as u16, &mut all, false)
|
||||
.expect("read all sectors");
|
||||
assert_eq!(n, N_SECTORS * SECTOR_SIZE);
|
||||
assert_eq!(all, data, "bulk read mismatch");
|
||||
}
|
||||
|
||||
// ── 6. Pass 1 sweeps the entire disc even when every read fails ───────────
|
||||
//
|
||||
// Per RIP_DESIGN.md §2.1 + §3: Disc::copy must reach the end of the disc
|
||||
// regardless of how many reads fail. The only legitimate early exit is the
|
||||
// halt flag. With `skip_on_error` and a reader that returns
|
||||
// Err for every read, Pass 1 must:
|
||||
// - mark every sector NonTrimmed (so Pass 2 can retry them)
|
||||
// - return cleanly (no panic, no hang)
|
||||
// - bytes_good = 0
|
||||
// - bytes_pending = total_bytes (NonTrimmed counts as pending in mapfile
|
||||
// accounting; see disc/mapfile.rs::stats)
|
||||
// - bytes_unreadable = 0 (only Pass 2 marks Unreadable)
|
||||
// - complete = false (work remains for Pass 2)
|
||||
// - halted = false (no user stop)
|
||||
// - ISO file is `total_bytes` size on disk (sparse zeros)
|
||||
|
||||
/// Reader that returns Err for every read. Optionally signals a halt
|
||||
/// flag on the first read so tests can exercise the halt-during-skip-forward
|
||||
/// path deterministically (no wallclock dependency).
|
||||
struct FailingSectorReader {
|
||||
capacity: u32,
|
||||
/// If set, signals halt on the first `read_sectors` call. Cleared after
|
||||
/// the first signal so subsequent reads are plain Err.
|
||||
halt_on_first_read: Option<Arc<AtomicBool>>,
|
||||
}
|
||||
|
||||
impl FailingSectorReader {
|
||||
fn new(capacity: u32) -> Self {
|
||||
Self {
|
||||
capacity,
|
||||
halt_on_first_read: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_halt_on_first_read(capacity: u32, halt: Arc<AtomicBool>) -> Self {
|
||||
Self {
|
||||
capacity,
|
||||
halt_on_first_read: Some(halt),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SectorReader for FailingSectorReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
_lba: u32,
|
||||
_count: u16,
|
||||
_buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
if let Some(h) = self.halt_on_first_read.take() {
|
||||
h.store(true, Ordering::Relaxed);
|
||||
}
|
||||
// Model what a real damaged-disc read returns: CHECK CONDITION +
|
||||
// MEDIUM ERROR (sense_key 3, ASC 0x11 UNRECOVERED READ ERROR,
|
||||
// ASCQ 0x05 L-EC UNCORRECTABLE). Disc::copy's hysteresis must
|
||||
// engage on this — `Error::DiscRead` is libfreemkv's own
|
||||
// post-classification signal, not what a real reader emits.
|
||||
Err(libfreemkv::error::Error::ScsiError {
|
||||
opcode: libfreemkv::scsi::SCSI_READ_10,
|
||||
status: libfreemkv::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some(libfreemkv::ScsiSense {
|
||||
sense_key: libfreemkv::scsi::SENSE_KEY_MEDIUM_ERROR,
|
||||
asc: 0x11,
|
||||
ascq: 0x05,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn capacity(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disc_copy_completes_full_disc_with_failing_reader() {
|
||||
// 1024 sectors = 2 MB. Reader fails every read. With skip_on_error +
|
||||
// skip_on_error, Pass 1 must mark every sector NonTrimmed and return
|
||||
// cleanly — no bail, no hang.
|
||||
let capacity_sectors: u32 = 1024;
|
||||
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
||||
|
||||
let mut reader = FailingSectorReader::new(capacity_sectors);
|
||||
let disc = synthetic_disc(capacity_sectors);
|
||||
|
||||
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
||||
let iso_path = tmp.path().to_path_buf();
|
||||
drop(tmp);
|
||||
|
||||
let opts = CopyOptions {
|
||||
decrypt: false,
|
||||
multipass: true,
|
||||
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let t0 = Instant::now();
|
||||
let result = disc
|
||||
.copy(&mut reader, &iso_path, &opts)
|
||||
.expect("copy returns Ok");
|
||||
let elapsed = t0.elapsed();
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_file(&iso_path);
|
||||
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
||||
|
||||
// Hard bound — even at 0 ms per read, 1024 sectors with skip-forward
|
||||
// should complete in well under a second on any host.
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(5),
|
||||
"Pass 1 took {elapsed:?} on a 2 MB synthetic disc — expected < 5 s"
|
||||
);
|
||||
|
||||
// Per RIP_DESIGN.md §2.1: Pass 1 must reach end of disc regardless of
|
||||
// read outcomes.
|
||||
assert_eq!(
|
||||
result.bytes_total, total_bytes,
|
||||
"bytes_total must match disc capacity"
|
||||
);
|
||||
assert_eq!(
|
||||
result.bytes_good, 0,
|
||||
"no reads succeeded, bytes_good must be 0"
|
||||
);
|
||||
assert_eq!(
|
||||
result.bytes_unreadable, 0,
|
||||
"Pass 1 does not mark Unreadable; only Pass 2 (Disc::patch) does"
|
||||
);
|
||||
assert_eq!(
|
||||
result.bytes_pending, total_bytes,
|
||||
"every sector must be NonTrimmed → counted as pending. \
|
||||
Got bytes_pending={} of total {}",
|
||||
result.bytes_pending, total_bytes
|
||||
);
|
||||
assert!(
|
||||
!result.complete,
|
||||
"complete=false because NonTrimmed regions remain (work for Pass 2)"
|
||||
);
|
||||
assert!(!result.halted, "no halt was set; halted must be false");
|
||||
|
||||
// ISO file should be the full disc size on disk (sparse zeros where
|
||||
// reads failed).
|
||||
// Note: tempfile was dropped above; the file may or may not still exist
|
||||
// depending on cleanup ordering. We only assert what we can observe in
|
||||
// the CopyResult.
|
||||
}
|
||||
|
||||
// ── 7. Halt during Pass 1 skip-forward path returns promptly (deterministic) ─
|
||||
//
|
||||
// Per RIP_DESIGN.md §3: halt is the only legitimate early exit from Pass 1.
|
||||
// Even when every read is failing (skip-forward path), a halt must be
|
||||
// honored within a small bounded time.
|
||||
//
|
||||
// Deterministic fixture: the reader signals halt on its FIRST read. The
|
||||
// inner copy loop's halt check fires on the next iteration, breaking out
|
||||
// of 'outer. This avoids any wallclock race on fast CI runners (where a
|
||||
// 2 GB synthetic disc can sweep skip-forward in <100 ms).
|
||||
|
||||
#[test]
|
||||
fn test_disc_copy_halts_promptly_on_failing_reader() {
|
||||
let capacity_sectors: u32 = 1024 * 1024; // 2 GB synthetic disc
|
||||
|
||||
let halt = Arc::new(AtomicBool::new(false));
|
||||
let mut reader = FailingSectorReader::with_halt_on_first_read(capacity_sectors, halt.clone());
|
||||
let disc = synthetic_disc(capacity_sectors);
|
||||
|
||||
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
||||
let iso_path = tmp.path().to_path_buf();
|
||||
drop(tmp);
|
||||
|
||||
let opts = CopyOptions {
|
||||
decrypt: false,
|
||||
multipass: true,
|
||||
|
||||
halt: Some(halt),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let t0 = Instant::now();
|
||||
let result = disc
|
||||
.copy(&mut reader, &iso_path, &opts)
|
||||
.expect("copy returns Ok on halt");
|
||||
let elapsed = t0.elapsed();
|
||||
|
||||
// Cleanup
|
||||
let _ = std::fs::remove_file(&iso_path);
|
||||
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
||||
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(2),
|
||||
"halt must return within 2 s; took {elapsed:?}"
|
||||
);
|
||||
assert!(result.halted, "result.halted must be true");
|
||||
assert!(
|
||||
!result.complete,
|
||||
"halted run cannot be complete (bytes_pending > 0 expected)"
|
||||
);
|
||||
assert!(
|
||||
result.bytes_pending > 0,
|
||||
"halt fired before sweep completed; bytes_pending must be > 0"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 8. Hysteresis recovers data the drive can read individually ──────────
|
||||
//
|
||||
// Pass 1 reads in batch (32 sectors = 1 ECC block). Failed blocks are marked
|
||||
// NonTrimmed for Pass 2 recovery. This test verifies that a reader where every
|
||||
// multi-sector read fails produces all NonTrimmed output with zero bytes_good.
|
||||
|
||||
struct BlockSizeFailingReader {
|
||||
capacity: u32,
|
||||
}
|
||||
|
||||
impl SectorReader for BlockSizeFailingReader {
|
||||
fn read_sectors(
|
||||
&mut self,
|
||||
lba: u32,
|
||||
count: u16,
|
||||
buf: &mut [u8],
|
||||
_recovery: bool,
|
||||
) -> Result<usize> {
|
||||
if count == 1 {
|
||||
for chunk in buf.chunks_mut(SECTOR_SIZE) {
|
||||
chunk.fill((lba & 0xff) as u8);
|
||||
}
|
||||
Ok(buf.len())
|
||||
} else {
|
||||
Err(libfreemkv::error::Error::ScsiError {
|
||||
opcode: libfreemkv::scsi::SCSI_READ_10,
|
||||
status: libfreemkv::scsi::SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some(libfreemkv::ScsiSense {
|
||||
sense_key: libfreemkv::scsi::SENSE_KEY_MEDIUM_ERROR,
|
||||
asc: 0x11,
|
||||
ascq: 0x00,
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn capacity(&self) -> u32 {
|
||||
self.capacity
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disc_copy_marks_failed_ecc_blocks_as_nontrimmed() {
|
||||
let capacity_sectors: u32 = 256;
|
||||
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
|
||||
|
||||
let mut reader = BlockSizeFailingReader {
|
||||
capacity: capacity_sectors,
|
||||
};
|
||||
let disc = synthetic_disc(capacity_sectors);
|
||||
|
||||
let tmp = tempfile::NamedTempFile::new().expect("tempfile create");
|
||||
let iso_path = tmp.path().to_path_buf();
|
||||
drop(tmp);
|
||||
|
||||
let opts = CopyOptions {
|
||||
decrypt: false,
|
||||
multipass: true,
|
||||
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = disc
|
||||
.copy(&mut reader, &iso_path, &opts)
|
||||
.expect("copy returns Ok");
|
||||
|
||||
let _ = std::fs::remove_file(&iso_path);
|
||||
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
|
||||
|
||||
// Pass 1 reads every batch at bpt=32 (no batch reduction, no skip-ahead).
|
||||
// BlockSizeFailingReader fails on all batch=32 reads, so every sector
|
||||
// is marked NonTrimmed. bytes_good=0 is correct — Pass 2 recovers them.
|
||||
assert_eq!(
|
||||
result.bytes_good, 0,
|
||||
"Pass 1 at bpt=32 should not recover any sectors when all batches fail"
|
||||
);
|
||||
assert!(
|
||||
result.bytes_pending > 0,
|
||||
"all sectors should be NonTrimmed pending Pass 2"
|
||||
);
|
||||
assert!(
|
||||
!result.complete,
|
||||
"complete=false when sectors remain NonTrimmed"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
//! Integration tests for the SCSI error-decoding contract.
|
||||
//!
|
||||
//! v0.13.20 rewrote `scsi/linux.rs` to a synchronous blocking SG_IO and
|
||||
//! consolidated sense parsing into the `parse_sense` helper that every
|
||||
//! platform backend now shares. v0.13.23 replaced the `Error::ScsiError`
|
||||
//! flat-fields shape with `{ opcode, status, sense: Option<ScsiSense> }`
|
||||
//! so callers can route on structured sense data (key + ASC + ASCQ) via
|
||||
//! [`Error::scsi_sense`] / [`Error::is_marginal_read`] /
|
||||
//! [`ScsiSense::is_*`].
|
||||
//!
|
||||
//! The actual `ioctl(SG_IO, ...)` call is impossible to mock without a
|
||||
//! kernel — see
|
||||
//! `(internal)/docs/audits/2026-04-26-scsi-architecture-research.md`
|
||||
//! for why the audit recommends against libc shims here. These tests
|
||||
//! therefore pin the *contract* every backend must satisfy via a mock
|
||||
//! `ScsiTransport`:
|
||||
//!
|
||||
//! 1. Healthy result → `Ok(ScsiResult { bytes_transferred = data.len() - resid })`.
|
||||
//! 2. Transport-level failure (no SCSI status delivered: kernel
|
||||
//! timeout, USB bridge wedge, IOKit service error) →
|
||||
//! `Error::ScsiError { status: SCSI_STATUS_TRANSPORT_FAILURE, sense: None }`.
|
||||
//! `Error::is_scsi_transport_failure()` returns `true`. Used by
|
||||
//! `drive_has_disc` to detect the wedge signature.
|
||||
//! 3. SCSI-level failure (drive replied CHECK CONDITION with sense) →
|
||||
//! `Error::ScsiError { status: 0x02, sense: Some(ScsiSense {…}) }`
|
||||
//! with the parsed key/ASC/ASCQ.
|
||||
//! 4. `Error::is_marginal_read()` is `true` for MEDIUM ERROR /
|
||||
//! ABORTED COMMAND / RECOVERED ERROR / NO SENSE; `false` for
|
||||
//! HARDWARE / DATA PROTECT / UNIT ATTENTION / NOT READY / ILLEGAL
|
||||
//! REQUEST and for transport failures.
|
||||
//!
|
||||
//! Inline `parse_sense_tests` in `src/scsi/mod.rs` cover the pure parse
|
||||
//! logic (descriptor 0x72/0x73 vs fixed 0x70/0x71, short-buffer, VALID
|
||||
//! bit masking, unknown response codes, ASC/ASCQ offsets); this file
|
||||
//! covers the consumer side — a real transport feeding a real Error
|
||||
//! variant to a real call site (`scsi::inquiry`).
|
||||
|
||||
use libfreemkv::error::Error;
|
||||
use libfreemkv::scsi::{
|
||||
DataDirection, SCSI_STATUS_CHECK_CONDITION, SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
SENSE_KEY_ABORTED_COMMAND, SENSE_KEY_DATA_PROTECT, SENSE_KEY_HARDWARE_ERROR,
|
||||
SENSE_KEY_ILLEGAL_REQUEST, SENSE_KEY_MEDIUM_ERROR, SENSE_KEY_NOT_READY,
|
||||
SENSE_KEY_RECOVERED_ERROR, SENSE_KEY_UNIT_ATTENTION, ScsiResult, ScsiSense, ScsiTransport,
|
||||
};
|
||||
|
||||
/// A scripted ScsiTransport. Each `execute()` consumes the next entry
|
||||
/// from `script` and returns the corresponding outcome.
|
||||
///
|
||||
/// Outcomes mirror what each backend's `execute()` should produce after
|
||||
/// the v0.13.23 sense plumbing: `Option<ScsiSense>` carrying the full
|
||||
/// SPC-4 triple for drive-reported failures, `None` for transport-level
|
||||
/// failures.
|
||||
struct MockTransport {
|
||||
script: Vec<MockOutcome>,
|
||||
next: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum MockOutcome {
|
||||
/// Healthy completion. `data` is what the transport wrote into the
|
||||
/// caller's data buffer (truncated to the buffer length); `resid`
|
||||
/// is reported back as `data.len() - bytes_transferred`.
|
||||
Ok { data: Vec<u8>, resid: i32 },
|
||||
/// Transport-level failure: `hdr.host_status = DID_TIME_OUT` on
|
||||
/// Linux, `kIOReturnError` on macOS, `DeviceIoControl` returning 0
|
||||
/// on Windows. Backends synthesise `SCSI_STATUS_TRANSPORT_FAILURE`
|
||||
/// with `sense = None`.
|
||||
TransportFailure,
|
||||
/// Drive replied with sense data (typically `SCSI_STATUS_CHECK_CONDITION`
|
||||
/// + a populated sense buffer).
|
||||
ScsiFailure { status: u8, sense: ScsiSense },
|
||||
}
|
||||
|
||||
impl MockTransport {
|
||||
fn new(script: Vec<MockOutcome>) -> Self {
|
||||
Self { script, next: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl ScsiTransport for MockTransport {
|
||||
fn execute(
|
||||
&mut self,
|
||||
cdb: &[u8],
|
||||
_direction: DataDirection,
|
||||
data: &mut [u8],
|
||||
_timeout_ms: u32,
|
||||
) -> libfreemkv::error::Result<ScsiResult> {
|
||||
let i = self.next;
|
||||
self.next += 1;
|
||||
let outcome = self
|
||||
.script
|
||||
.get(i)
|
||||
.cloned()
|
||||
.expect("MockTransport script ran out — test wrote fewer outcomes than calls");
|
||||
match outcome {
|
||||
MockOutcome::Ok { data: d, resid } => {
|
||||
let n = d.len().min(data.len());
|
||||
data[..n].copy_from_slice(&d[..n]);
|
||||
let bytes = (data.len() as i32).saturating_sub(resid).max(0) as usize;
|
||||
Ok(ScsiResult {
|
||||
status: 0,
|
||||
bytes_transferred: bytes,
|
||||
sense: [0u8; 32],
|
||||
})
|
||||
}
|
||||
MockOutcome::TransportFailure => Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
}),
|
||||
MockOutcome::ScsiFailure { status, sense } => Err(Error::ScsiError {
|
||||
opcode: cdb[0],
|
||||
status,
|
||||
sense: Some(sense),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper — build a CHECK CONDITION outcome with a given sense key
|
||||
/// and zero ASC/ASCQ. Most consumer-side tests only care about the key.
|
||||
fn check_cond(sense_key: u8) -> MockOutcome {
|
||||
MockOutcome::ScsiFailure {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: ScsiSense {
|
||||
sense_key,
|
||||
asc: 0,
|
||||
ascq: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1. Healthy read ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_healthy_inquiry_returns_ok_with_full_transfer() {
|
||||
// Build a fake INQUIRY response (96 bytes per scsi::inquiry).
|
||||
// Vendor "TEST-VND", model "TEST-MODEL ", firmware "1.00".
|
||||
let mut payload = vec![0u8; 96];
|
||||
payload[8..16].copy_from_slice(b"TEST-VND");
|
||||
payload[16..32].copy_from_slice(b"TEST-MODEL ");
|
||||
payload[32..36].copy_from_slice(b"1.00");
|
||||
|
||||
let mut transport = MockTransport::new(vec![MockOutcome::Ok {
|
||||
data: payload,
|
||||
resid: 0,
|
||||
}]);
|
||||
|
||||
let r = libfreemkv::scsi::inquiry(&mut transport).expect("inquiry should succeed");
|
||||
assert_eq!(r.vendor_id, "TEST-VND");
|
||||
assert_eq!(r.model, "TEST-MODEL");
|
||||
assert_eq!(r.firmware, "1.00");
|
||||
}
|
||||
|
||||
// ── 2. Transport-level failure: no SCSI status, no sense data ─────────────
|
||||
|
||||
#[test]
|
||||
fn test_transport_failure_surfaces_with_sense_none() {
|
||||
let mut transport = MockTransport::new(vec![MockOutcome::TransportFailure]);
|
||||
|
||||
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
||||
assert!(
|
||||
err.is_scsi_transport_failure(),
|
||||
"TransportFailure must satisfy is_scsi_transport_failure()"
|
||||
);
|
||||
assert!(
|
||||
err.scsi_sense().is_none(),
|
||||
"transport failure has no sense data"
|
||||
);
|
||||
assert!(
|
||||
!err.is_marginal_read(),
|
||||
"transport failure must not be classified as marginal-read"
|
||||
);
|
||||
match err {
|
||||
Error::ScsiError { status, sense, .. } => {
|
||||
assert_eq!(status, SCSI_STATUS_TRANSPORT_FAILURE);
|
||||
assert!(sense.is_none());
|
||||
}
|
||||
other => panic!("expected ScsiError, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. CHECK CONDITION + ILLEGAL REQUEST ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_check_cond_illegal_request_carries_sense() {
|
||||
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_ILLEGAL_REQUEST)]);
|
||||
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
||||
|
||||
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
|
||||
assert_eq!(sense.sense_key, SENSE_KEY_ILLEGAL_REQUEST);
|
||||
assert!(sense.is_illegal_request());
|
||||
assert!(!err.is_marginal_read(), "ILLEGAL REQUEST is not marginal");
|
||||
assert!(!err.is_scsi_transport_failure());
|
||||
}
|
||||
|
||||
// ── 4. CHECK CONDITION + NOT READY (drive_has_disc relies on this) ────────
|
||||
|
||||
#[test]
|
||||
fn test_check_cond_not_ready_predicate() {
|
||||
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_NOT_READY)]);
|
||||
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
||||
|
||||
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
|
||||
assert!(sense.is_not_ready(), "sense_key 2 ⇒ is_not_ready");
|
||||
assert!(!err.is_marginal_read(), "NOT READY is not marginal");
|
||||
}
|
||||
|
||||
// ── 5. CHECK CONDITION + MEDIUM ERROR (canonical marginal-read) ───────────
|
||||
|
||||
#[test]
|
||||
fn test_check_cond_medium_error_is_marginal() {
|
||||
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_MEDIUM_ERROR)]);
|
||||
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
||||
|
||||
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
|
||||
assert!(sense.is_medium_error());
|
||||
assert!(sense.is_marginal());
|
||||
assert!(
|
||||
err.is_marginal_read(),
|
||||
"MEDIUM ERROR is the canonical marginal-read signal"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 6. CHECK CONDITION + ABORTED COMMAND (also marginal) ──────────────────
|
||||
|
||||
#[test]
|
||||
fn test_check_cond_aborted_command_is_marginal() {
|
||||
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_ABORTED_COMMAND)]);
|
||||
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
||||
|
||||
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
|
||||
assert!(sense.is_aborted_command());
|
||||
assert!(err.is_marginal_read(), "ABORTED COMMAND is marginal");
|
||||
}
|
||||
|
||||
// ── 7. CHECK CONDITION + RECOVERED ERROR (drive recovered; marginal) ──────
|
||||
|
||||
#[test]
|
||||
fn test_check_cond_recovered_error_is_marginal() {
|
||||
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_RECOVERED_ERROR)]);
|
||||
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
||||
assert!(
|
||||
err.is_marginal_read(),
|
||||
"RECOVERED ERROR is treated as marginal (drive recovered, retry-friendly class)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 8. CHECK CONDITION + HARDWARE ERROR (NOT marginal — bail) ─────────────
|
||||
|
||||
#[test]
|
||||
fn test_check_cond_hardware_error_not_marginal() {
|
||||
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_HARDWARE_ERROR)]);
|
||||
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
||||
|
||||
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
|
||||
assert!(sense.is_hardware_error());
|
||||
assert!(
|
||||
!err.is_marginal_read(),
|
||||
"HARDWARE ERROR must not be marginal — drive failing, retry can't help"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 9. CHECK CONDITION + DATA PROTECT (NOT marginal — bail) ───────────────
|
||||
|
||||
#[test]
|
||||
fn test_check_cond_data_protect_not_marginal() {
|
||||
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_DATA_PROTECT)]);
|
||||
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
||||
|
||||
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
|
||||
assert!(sense.is_data_protect());
|
||||
assert!(
|
||||
!err.is_marginal_read(),
|
||||
"DATA PROTECT (AACS / region) must not be marginal — retry can't help"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 10. CHECK CONDITION + UNIT ATTENTION (NOT marginal — caller rescans) ──
|
||||
|
||||
#[test]
|
||||
fn test_check_cond_unit_attention_not_marginal() {
|
||||
let mut transport = MockTransport::new(vec![check_cond(SENSE_KEY_UNIT_ATTENTION)]);
|
||||
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
||||
|
||||
let sense = err.scsi_sense().expect("CHECK CONDITION must carry sense");
|
||||
assert!(sense.is_unit_attention());
|
||||
assert!(
|
||||
!err.is_marginal_read(),
|
||||
"UNIT ATTENTION must not be marginal — caller should rescan, not retry"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 11. ASC/ASCQ propagate from sense buffer to ScsiError.sense ───────────
|
||||
|
||||
#[test]
|
||||
fn test_asc_ascq_round_trip_through_error() {
|
||||
let outcome = MockOutcome::ScsiFailure {
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: ScsiSense {
|
||||
sense_key: SENSE_KEY_MEDIUM_ERROR,
|
||||
asc: 0x11,
|
||||
ascq: 0x05, // L-EC UNCORRECTABLE
|
||||
},
|
||||
};
|
||||
let mut transport = MockTransport::new(vec![outcome]);
|
||||
let err = libfreemkv::scsi::inquiry(&mut transport).unwrap_err();
|
||||
|
||||
let sense = err.scsi_sense().expect("must carry sense");
|
||||
assert_eq!(sense.sense_key, SENSE_KEY_MEDIUM_ERROR);
|
||||
assert_eq!(sense.asc, 0x11);
|
||||
assert_eq!(sense.ascq, 0x05);
|
||||
}
|
||||
|
||||
// ── 12. Healthy short transfer (resid > 0) ────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_healthy_short_transfer_reports_partial_bytes() {
|
||||
// Transport reported back fewer bytes than requested (resid = 16).
|
||||
// For a 96-byte buffer that means bytes_transferred = 80.
|
||||
let payload = vec![0u8; 96];
|
||||
let mut transport = MockTransport::new(vec![MockOutcome::Ok {
|
||||
data: payload,
|
||||
resid: 16,
|
||||
}]);
|
||||
|
||||
let cdb = [libfreemkv::scsi::SCSI_INQUIRY, 0, 0, 0, 0x60, 0];
|
||||
let mut buf = [0u8; 96];
|
||||
let r = transport
|
||||
.execute(&cdb, DataDirection::FromDevice, &mut buf, 1_000)
|
||||
.expect("ok");
|
||||
assert_eq!(r.status, 0);
|
||||
assert_eq!(
|
||||
r.bytes_transferred, 80,
|
||||
"bytes_transferred must equal data.len() - resid"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 13. Error::Display does not leak English in the SCSI variant ──────────
|
||||
//
|
||||
// Library rule (project docs): no English in error display, only "E{code}"
|
||||
// + structured data. Regression guard for the SCSI variant specifically
|
||||
// because it's the most-emitted error in the rip path.
|
||||
|
||||
#[test]
|
||||
fn test_scsi_error_display_format_is_codes_only() {
|
||||
let err = Error::ScsiError {
|
||||
opcode: 0x12,
|
||||
status: SCSI_STATUS_CHECK_CONDITION,
|
||||
sense: Some(ScsiSense {
|
||||
sense_key: SENSE_KEY_ILLEGAL_REQUEST,
|
||||
asc: 0x24,
|
||||
ascq: 0x00,
|
||||
}),
|
||||
};
|
||||
let s = err.to_string();
|
||||
assert!(
|
||||
s.starts_with("E4000:"),
|
||||
"ScsiError must lead with E4000: {s}"
|
||||
);
|
||||
assert!(
|
||||
s.contains("0x12") && s.contains("0x02") && s.contains("0x05") && s.contains("0x24"),
|
||||
"ScsiError must show opcode/status/key/asc in hex: {s}"
|
||||
);
|
||||
// Crude English filter — same as the inline error.rs::display test.
|
||||
for word in s.split(|c: char| !c.is_ascii_alphabetic()) {
|
||||
assert!(
|
||||
word.len() <= 4,
|
||||
"ScsiError display contains suspicious word `{word}`: {s}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 14. Transport-failure Display omits sense fields ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_scsi_transport_failure_display_short_form() {
|
||||
let err = Error::ScsiError {
|
||||
opcode: 0x28,
|
||||
status: SCSI_STATUS_TRANSPORT_FAILURE,
|
||||
sense: None,
|
||||
};
|
||||
let s = err.to_string();
|
||||
assert!(s.starts_with("E4000:"));
|
||||
assert!(s.contains("0x28") && s.contains("0xff"));
|
||||
// No sense triple should appear in the no-sense form.
|
||||
assert!(
|
||||
!s.contains("0x00/0x00/0x00"),
|
||||
"transport failure must not carry phantom sense: {s}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Tests for SgIoTransport timeout recovery (Fix 2).
|
||||
//!
|
||||
//! When `execute()` detects a transport-level failure (kernel timeout /
|
||||
//! USB bridge wedge — `host_status != 0`), it spawns two background
|
||||
//! threads to (1) close the old fd and (2) open a fresh one, then
|
||||
//! stores the new fd in `fd_recovery`. The next `execute()` call picks
|
||||
//! up the recovered fd without blocking on close().
|
||||
//!
|
||||
//! These tests require a real /dev/sg* device and are therefore #[ignore].
|
||||
|
||||
use libfreemkv::scsi::{DataDirection, SCSI_STATUS_TRANSPORT_FAILURE};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_sgio_transport_timeout_does_not_kill_transport() {
|
||||
let device = "/dev/sg2";
|
||||
let device = std::env::var("FREEMKV_TEST_SG_DEVICE").unwrap_or(device.to_string());
|
||||
let path = Path::new(&device);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use libfreemkv::scsi::ScsiTransport;
|
||||
use libfreemkv::scsi::linux::SgIoTransport;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
|
||||
let mut transport = SgIoTransport::open(path).expect("open device");
|
||||
let fd_before = transport.fd;
|
||||
|
||||
// READ_10 with 1 ms timeout to force kernel timeout.
|
||||
let cdb = [0x28, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00];
|
||||
let mut data = vec![0u8; 2048];
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let result = transport.execute(&cdb, DataDirection::FromDevice, &mut data, 1);
|
||||
let _elapsed = start.elapsed();
|
||||
|
||||
assert!(result.is_err(), "expected Err on timeout, got {:?}", result);
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, libfreemkv::Error::ScsiError { status, .. } if *status == SCSI_STATUS_TRANSPORT_FAILURE),
|
||||
"expected ScsiError(TRANSPORT_FAILURE), got {:?}",
|
||||
err
|
||||
);
|
||||
assert_eq!(transport.fd, -1, "fd should be -1 after timeout");
|
||||
|
||||
// Wait for recovery thread to store new fd.
|
||||
let recovered = Arc::new(AtomicI32::new(-1));
|
||||
let recovery_ref = transport.fd_recovery.clone();
|
||||
for _ in 0..100 {
|
||||
let v = recovery_ref.load(Ordering::Acquire);
|
||||
if v >= 0 {
|
||||
recovered.store(v, Ordering::Release);
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
let new_fd = recovered.load(Ordering::Acquire);
|
||||
assert!(new_fd >= 0, "recovery thread should have produced a new fd");
|
||||
|
||||
// Next execute() must pick up recovered fd quickly.
|
||||
let mut data2 = vec![0u8; 2048];
|
||||
let start2 = std::time::Instant::now();
|
||||
let result2 = transport.execute(&cdb, DataDirection::FromDevice, &mut data2, 5000);
|
||||
let elapsed2 = start2.elapsed();
|
||||
|
||||
assert!(
|
||||
result2.is_ok(),
|
||||
"execute() with recovered fd should succeed, got {:?}",
|
||||
result2
|
||||
);
|
||||
assert!(
|
||||
elapsed2 < Duration::from_secs(2),
|
||||
"should return quickly, took {:?}",
|
||||
elapsed2
|
||||
);
|
||||
assert_ne!(transport.fd, -1, "fd should be valid after recovery");
|
||||
assert_ne!(transport.fd, fd_before, "fd should be fresh after recovery");
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
eprintln!("SKIP: test requires Linux / SgIoTransport");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_drive_read_per_cdb_timeout_bounds_call() {
|
||||
let device = "/dev/sg2";
|
||||
let device = std::env::var("FREEMKV_TEST_SG_DEVICE").unwrap_or(device.to_string());
|
||||
let path = Path::new(&device);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let mut drive = libfreemkv::Drive::open(path).expect("open drive");
|
||||
let timeout_ms: u32 = 5_000;
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let _ = drive.read(0, 1, &mut [0u8; 2048], false);
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
let overhead = Duration::from_millis(500);
|
||||
assert!(
|
||||
elapsed < Duration::from_millis(timeout_ms as u64) + overhead,
|
||||
"Drive::read should return within timeout_ms + overhead, took {:?}",
|
||||
elapsed
|
||||
);
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
eprintln!("SKIP: test requires Linux / SgIoTransport");
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ fn sample_disc_title() -> DiscTitle {
|
||||
language: "eng".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: libfreemkv::LabelPurpose::Normal,
|
||||
label: "English Atmos".into(),
|
||||
}),
|
||||
Stream::Audio(AudioStream {
|
||||
@@ -39,6 +40,7 @@ fn sample_disc_title() -> DiscTitle {
|
||||
language: "fra".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: libfreemkv::LabelPurpose::Normal,
|
||||
label: "French".into(),
|
||||
}),
|
||||
Stream::Subtitle(SubtitleStream {
|
||||
@@ -46,6 +48,7 @@ fn sample_disc_title() -> DiscTitle {
|
||||
codec: Codec::Pgs,
|
||||
language: "eng".into(),
|
||||
forced: false,
|
||||
qualifier: libfreemkv::LabelQualifier::None,
|
||||
codec_data: None,
|
||||
}),
|
||||
],
|
||||
@@ -404,6 +407,7 @@ fn meta_codec_roundtrip() {
|
||||
language: "eng".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: libfreemkv::LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
}));
|
||||
}
|
||||
@@ -413,6 +417,7 @@ fn meta_codec_roundtrip() {
|
||||
codec,
|
||||
language: "eng".into(),
|
||||
forced: false,
|
||||
qualifier: libfreemkv::LabelQualifier::None,
|
||||
codec_data: None,
|
||||
}));
|
||||
}
|
||||
@@ -501,6 +506,7 @@ fn meta_all_stream_types() {
|
||||
language: "eng".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: libfreemkv::LabelPurpose::Normal,
|
||||
label: "Primary Audio".into(),
|
||||
}),
|
||||
Stream::Subtitle(SubtitleStream {
|
||||
@@ -508,6 +514,7 @@ fn meta_all_stream_types() {
|
||||
codec: Codec::Pgs,
|
||||
language: "fra".into(),
|
||||
forced: true,
|
||||
qualifier: libfreemkv::LabelQualifier::None,
|
||||
codec_data: None,
|
||||
}),
|
||||
Stream::Audio(AudioStream {
|
||||
@@ -517,6 +524,7 @@ fn meta_all_stream_types() {
|
||||
language: "eng".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: true,
|
||||
purpose: libfreemkv::LabelPurpose::Normal,
|
||||
label: "Commentary".into(),
|
||||
}),
|
||||
],
|
||||
@@ -623,6 +631,7 @@ fn mkvstream_roundtrip_bdts() {
|
||||
language: "eng".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: libfreemkv::LabelPurpose::Normal,
|
||||
label: "English".into(),
|
||||
})],
|
||||
chapters: Vec::new(),
|
||||
@@ -679,6 +688,7 @@ fn mkvstream_meta_preserves_all_streams() {
|
||||
language: "eng".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: libfreemkv::LabelPurpose::Normal,
|
||||
label: "English".into(),
|
||||
}),
|
||||
Stream::Audio(AudioStream {
|
||||
@@ -688,6 +698,7 @@ fn mkvstream_meta_preserves_all_streams() {
|
||||
language: "fra".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: libfreemkv::LabelPurpose::Normal,
|
||||
label: "French".into(),
|
||||
}),
|
||||
Stream::Subtitle(SubtitleStream {
|
||||
@@ -695,6 +706,7 @@ fn mkvstream_meta_preserves_all_streams() {
|
||||
codec: Codec::Pgs,
|
||||
language: "eng".into(),
|
||||
forced: false,
|
||||
qualifier: libfreemkv::LabelQualifier::None,
|
||||
codec_data: None,
|
||||
}),
|
||||
Stream::Subtitle(SubtitleStream {
|
||||
@@ -702,6 +714,7 @@ fn mkvstream_meta_preserves_all_streams() {
|
||||
codec: Codec::Pgs,
|
||||
language: "fra".into(),
|
||||
forced: true,
|
||||
qualifier: libfreemkv::LabelQualifier::None,
|
||||
codec_data: None,
|
||||
}),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user