Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43836865be | ||
|
|
5e22199441 | ||
|
|
761b77bbd9 | ||
|
|
cc05ef2a3a | ||
|
|
e27b82ce5b | ||
|
|
8af47e4c19 | ||
|
|
d1f09439a5 | ||
|
|
37e721ee7e | ||
|
|
481b47d90d | ||
|
|
dd7cbd70a9 | ||
|
|
3dea679dac | ||
|
|
64ecca0d12 | ||
|
|
84f6baba38 | ||
|
|
bd5b7795bd | ||
|
|
a57506c1ce | ||
|
|
c33f3e9557 | ||
|
|
166fc4bf8c | ||
|
|
9aaddaa9b8 | ||
|
|
40ec1e1eba | ||
|
|
8843833ea7 | ||
|
|
afae37c8c3 | ||
|
|
d8089ec491 | ||
|
|
ce8132d181 | ||
|
|
71e1f57364 | ||
|
|
d8092ee093 | ||
|
|
dd49d55b11 | ||
|
|
6ba958fe8a | ||
|
|
f30b958408 | ||
|
|
6971f00be2 | ||
|
|
ad86f92d85 | ||
|
|
cce1be29b2 | ||
|
|
081ad4f619 | ||
|
|
43ecad2d43 | ||
|
|
8870efa77b | ||
|
|
a693ef965b | ||
|
|
0596307c19 | ||
|
|
18c6365e08 | ||
|
|
981f30b1b0 | ||
|
|
04086d73a0 | ||
|
|
20e404e59a | ||
|
|
1bea8eb650 | ||
|
|
0165f18fad | ||
|
|
ca3914d537 | ||
|
|
80fcffd190 | ||
|
|
7437af39c8 | ||
|
|
bcd6925170 | ||
|
|
fba1eb189c | ||
|
|
a328ad3cd8 | ||
|
|
9791e60c65 | ||
|
|
8b1ded84cd | ||
|
|
3e4febe7f5 | ||
|
|
aa7afc0c98 | ||
|
|
29f9e916d6 | ||
|
|
30d59063d5 | ||
|
|
6e958b0f71 | ||
|
|
911d260695 | ||
|
|
59d4eb8854 | ||
|
|
8820f7a460 | ||
|
|
8de99bb8e8 | ||
|
|
3e5e570573 | ||
|
|
f95894bba3 | ||
|
|
597f512eca | ||
|
|
8e907393a3 | ||
|
|
c47b9a9c3c |
75 changed files with 5365 additions and 1885 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
602
CHANGELOG.md
602
CHANGELOG.md
|
|
@ -1,5 +1,607 @@
|
|||
# Changelog
|
||||
|
||||
## 0.13.6 (2026-04-25)
|
||||
|
||||
### Inline retry/reset stripped from `Drive::read`; `BytesRead` now emitted
|
||||
|
||||
Two related changes that close the loop on the BU40N wedge work from
|
||||
0.13.1–0.13.4 and on the long-standing autorip "0 KB/s, 0%" UI bug.
|
||||
|
||||
**`Drive::read` is now single-shot.** The phase 1 / 2 / 3 retry loop
|
||||
(reset → reopen → repeat) inside `Drive::read` is gone (~80 lines
|
||||
deleted). `recovery=true` only bumps the per-CDB timeout to 30 s;
|
||||
`recovery=false` keeps the 1.5 s timeout. On a failed read the
|
||||
function returns `Err(DiscRead)` immediately. Per the BU40N
|
||||
post-mortem, every USB / SCSI reset path tested in 0.13.1–0.13.3
|
||||
resets the bridge but not the drive firmware, and the inline
|
||||
reset+reopen *was* the wedge primitive itself — issuing it from
|
||||
inside `Drive::read` produced multi-minute hangs and made the wedge
|
||||
class harder to surface to the user. The correct retry layer is
|
||||
`Disc::patch`'s outer multi-pass loop, which is unaffected. A stuck
|
||||
drive now surfaces as a clean `DiscRead` to the caller, who can
|
||||
prompt physical replug.
|
||||
|
||||
**SCSI reset surface trimmed.** `SgIoTransport::reset` (Linux) drops
|
||||
the `SG_SCSI_RESET` ioctl and the STOP / START UNIT escalation; it
|
||||
keeps the kernel `SG_IO` state flush plus `ALLOW MEDIUM REMOVAL`.
|
||||
`MacScsiTransport::reset` is removed entirely (was open + drop +
|
||||
sleep, no SCSI). The top-level `scsi::reset` /
|
||||
`scsi::reset_with_timeout` / `scsi::reset_blocking` family is
|
||||
removed — no callers remain after the `Drive::read` strip.
|
||||
|
||||
**`EventKind::BytesRead` now emitted.** The variant was declared in
|
||||
0.13.0 but never fired. `DiscStream::fill_extents` now emits
|
||||
`BytesRead { bytes_read_total, total_extents_bytes }` after every
|
||||
successful sector read, so consumers in direct (no-mapfile) mode can
|
||||
drive a real-time progress bar without polling `output.bytes_written`.
|
||||
Multi-pass mode continues to use `Disc::copy`'s `on_progress`
|
||||
callback unchanged. Drives the autorip per-device live progress UI.
|
||||
|
||||
`Drive::checked_sleep` is removed (only used by the recovery loop);
|
||||
`Drive::sleep_until_halted` is `#[cfg(test)]`-only; `Drive::emit` is
|
||||
retained because `BytesRead` uses it.
|
||||
|
||||
### Tests
|
||||
- New `tests/integration_progress_and_halt.rs` (5 tests): `BytesRead`
|
||||
emission, `Disc::copy` `on_progress` regression guard, halt aborts
|
||||
copy, Drop safety, `FileSectorReader` round-trip.
|
||||
- 233 unit tests + 5 integration tests pass.
|
||||
|
||||
### Net diff
|
||||
~80 lines deleted, ~20 added.
|
||||
|
||||
### Version sync
|
||||
0.13.6 ecosystem release (libfreemkv + freemkv + bdemu + autorip all
|
||||
on 0.13.6).
|
||||
|
||||
## 0.13.5 (2026-04-25)
|
||||
|
||||
### Version sync — no functional changes
|
||||
Sync bump for the ecosystem. All four freemkv crates (libfreemkv,
|
||||
freemkv CLI, bdemu, autorip) always share a version number; 0.13.5
|
||||
carries autorip-side fixes (stop-is-reset, startup staging sweep).
|
||||
|
||||
## 0.13.4 (2026-04-25)
|
||||
|
||||
### Wedge recovery rolled back + sysfs identity fallback
|
||||
|
||||
**What changed.** The in-library USB / SCSI wedge-recovery escalation
|
||||
added in 0.13.1 – 0.13.3 has been removed. `drive_has_disc` now returns
|
||||
the raw TUR result (or the `0xFF` poll-timeout wedge error) directly to
|
||||
the caller. `scsi::usb_reset()` / `usb_reset_with_timeout()` /
|
||||
`DEFAULT_USB_RESET_TIMEOUT_SECS` and the per-platform
|
||||
`SgIoTransport::usb_reset` / `MacScsiTransport::usb_reset` /
|
||||
`SptiTransport::usb_reset` are gone. All three platform backends pass
|
||||
transport errors through verbatim, keeping the public
|
||||
`list_drives` + `drive_has_disc` contract symmetric
|
||||
(Linux / macOS / Windows).
|
||||
|
||||
**Why.** Production testing against the LG BU40N USB BD-RE (the drive
|
||||
that drove the whole 0.13.1–0.13.3 recovery push) showed:
|
||||
- `SG_SCSI_RESET`, `STOP UNIT` + `START UNIT`, and `USBDEVFS_RESET`
|
||||
all succeed at the USB transport layer (kernel logs
|
||||
`usb 3-2: reset high-speed USB device`, device re-authorises).
|
||||
- But the drive firmware *below* the USB bridge stays locked: no
|
||||
LUN enumerates on the fresh `scsi_host`, TUR never succeeds,
|
||||
`/dev/sg*` never reappears.
|
||||
- Also tried (outside the lib): `/sys/bus/usb/devices/<port>/authorized`
|
||||
toggle, `usb-storage` driver unbind/rebind, forced SCSI host rescan.
|
||||
All same outcome.
|
||||
|
||||
Only physical unplug-replug (or host reboot) clears this wedge class.
|
||||
The library was logging 2-minute-per-tick escalation cycles for nothing,
|
||||
and consumers had no way to surface "drive needs physical intervention"
|
||||
to users because the escalation was masking the real failure. Upper
|
||||
layers (autorip, CLI) now see the wedge error directly and prompt the
|
||||
user.
|
||||
|
||||
A breadcrumb in `scsi/linux.rs::drive_has_disc` catalogues every
|
||||
recovery method tried and points to git tag `v0.13.3` for the full
|
||||
implementation, in case a future hardware class is found where
|
||||
USB-layer recovery actually works.
|
||||
|
||||
**New: sysfs-cached identity fallback (Linux).** `list_drives` now
|
||||
populates empty INQUIRY vendor/model/firmware fields from
|
||||
`/sys/class/scsi_generic/sgN/device/{vendor,model,rev}` — the kernel
|
||||
runs its own INQUIRY at device probe time and stashes the answer there,
|
||||
so even a mid-wedge INQUIRY still yields the UI a human-readable
|
||||
identity. The drive surface on screen doesn't suddenly go blank the
|
||||
moment the drive firmware locks up.
|
||||
|
||||
## 0.13.3 (2026-04-24)
|
||||
|
||||
### Bug fix — `drive_has_disc` wedge recovery was dead code for TUR errors
|
||||
|
||||
The wedge-signature predicate introduced in 0.13.2 gated on
|
||||
`opcode == SCSI_INQUIRY (0x12)` — a holdover from when enumerate-time
|
||||
INQUIRY was the only path wedges surfaced on. `drive_has_disc` issues
|
||||
`TEST UNIT READY (0x00)`, so its wedge errors (`E4000: 0x00/0xff/0x00`)
|
||||
never matched the predicate and the SCSI-reset + USB-reset escalation
|
||||
never fired. Production result: the BU40N USB BD-RE stayed wedged
|
||||
indefinitely, with autorip logging `recovery exhausted` on the raw
|
||||
pass-through error while no recovery had actually been attempted.
|
||||
|
||||
Fix: drop the opcode constraint. Status byte `0xFF` is synthesised by
|
||||
our own `execute()` path when `poll()` on the SG fd times out; it's
|
||||
the ground-truth wedge marker regardless of which opcode was in flight.
|
||||
Doc comments on `is_wedge_signature` and `WEDGE_STATUS_BYTE` updated
|
||||
accordingly. Linux-only — macOS / Windows use sense-key-based wedge
|
||||
detection and are unaffected.
|
||||
|
||||
## 0.13.2 (2026-04-24)
|
||||
|
||||
### Public discovery + presence APIs; SCSI/USB primitives no longer
|
||||
### exposed to consumer crates
|
||||
|
||||
The autorip / freemkv-CLI side of the ecosystem was reimplementing
|
||||
hardware discovery (sysfs walking, SCSI type-5 filtering, sg-path
|
||||
construction) and SCSI recovery primitives in their own crates — a
|
||||
direct violation of the architectural rule that ALL hardware-aware
|
||||
code lives in libfreemkv. 0.13.2 closes that gap with two cheap public
|
||||
probes that absorb everything consumers were doing themselves, plus
|
||||
visibility tightening to make future violations a compile error.
|
||||
|
||||
#### New public APIs
|
||||
|
||||
- `pub struct DriveInfo { path, vendor, model, firmware }` — a single
|
||||
enumerated optical drive's identity. Returned by `list_drives()`,
|
||||
populated from a single SCSI INQUIRY at enumeration time. No
|
||||
firmware reset, no `init`.
|
||||
- `pub fn list_drives() -> Vec<DriveInfo>` — one-shot enumeration
|
||||
across Linux/macOS/Windows. Linux walks `/sys/class/scsi_generic/`
|
||||
with the SCSI type-5 filter and `/dev/sg0..15` fallback; macOS
|
||||
walks `/dev/disk0..15` with the INQUIRY peripheral-type-5 filter;
|
||||
Windows iterates `CdRom0..15`. Cheap (~10 ms / drive); cache the
|
||||
result and refresh on udev events.
|
||||
- `pub fn drive_has_disc(path: &Path) -> Result<bool>` — single TEST
|
||||
UNIT READY. Returns `Ok(true)` when ready / `Ok(false)` on sense-key 2
|
||||
("medium not present") / `Err` only after recovery has been exhausted.
|
||||
**Internal wedge recovery is hidden from callers** — when the kernel
|
||||
returns the wedge-signature pattern (status 0xFF, no sense), this
|
||||
function transparently escalates: SCSI bus reset → if still wedged →
|
||||
USB device reset → retry TUR. Consumers never see the escalation.
|
||||
|
||||
#### USB-layer reset, multi-platform
|
||||
|
||||
`USBDEVFS_RESET` (Linux) is the only thing that recovers a kernel-
|
||||
level USB Mass Storage wedge — software equivalent of unplug-replug.
|
||||
Now wired for all three OSes:
|
||||
|
||||
- **Linux**: `USBDEVFS_RESET` ioctl on `/dev/bus/usb/BBB/DDD`. Resolves
|
||||
sg → USB device via sysfs walk (`busnum`/`devnum` parents).
|
||||
- **macOS**: `IOUSBDeviceInterface::ResetDevice()`. Walks IORegistry
|
||||
parents from the SCSI service to the USB device, queries the IOKit
|
||||
USB plugin, calls ResetDevice.
|
||||
- **Windows**: existing `IOCTL_STORAGE_RESET_DEVICE` covers both SCSI
|
||||
and USB layers via storport, so `usb_reset` returns `DeviceNotFound`
|
||||
by design — the recovery escalation in `drive_has_disc` falls
|
||||
through cleanly. (See the `windows::usb_reset` doc comment for
|
||||
why a separate cycle-port IOCTL isn't needed on Windows.)
|
||||
|
||||
All wrapped in a thread + `mpsc::recv_timeout` so a kernel ioctl that
|
||||
hangs forever can't lock up the caller (the inner thread leaks one OS
|
||||
thread per hard wedge — acceptable for a daemon that recovers vs. one
|
||||
that wedges the whole poll loop).
|
||||
|
||||
#### Visibility tightening (architectural enforcement)
|
||||
|
||||
These were `pub` in 0.13.1; consumer crates could (and did) call them
|
||||
directly, leaking SCSI knowledge across the lib boundary:
|
||||
|
||||
- `scsi::reset` → `pub(crate)`
|
||||
- `scsi::reset_with_timeout` → `pub(crate)`
|
||||
- `scsi::usb_reset` → `pub(crate)`
|
||||
- `scsi::usb_reset_with_timeout` → `pub(crate)`
|
||||
- `DEFAULT_RESET_TIMEOUT_SECS` / `DEFAULT_USB_RESET_TIMEOUT_SECS` →
|
||||
`pub(crate)`
|
||||
|
||||
Consumers now reach recovery exclusively through `drive_has_disc`,
|
||||
which folds the escalation in. **Compile-time guarantee** that no
|
||||
future autorip/CLI/bdemu commit can reintroduce direct SCSI access.
|
||||
|
||||
#### Why this design
|
||||
|
||||
`Drive::open(path)` runs a ~2 s firmware-reset preamble + identify
|
||||
sequence; suitable for ripping but wasteful for a poll loop probing
|
||||
"is there a disc?". Pre-0.13.2 autorip called `Drive::open` 4 × every
|
||||
5 s = ~17 000 speculative SCSI sessions/day, hammering the drives
|
||||
between actual rips. The wedge in production at 23:51 UTC was
|
||||
triggered by exactly this hot-loop pattern. With `drive_has_disc`,
|
||||
the same poll cadence costs ~50 ms / drive (one TUR) — 40× cheaper
|
||||
and side-effect-free on a healthy drive.
|
||||
|
||||
#### Tests
|
||||
|
||||
- 233 lib tests pass (no change in count; APIs covered indirectly via
|
||||
the existing transport tests + a new `device_key` test on the
|
||||
autorip side).
|
||||
- `cargo clippy --all-targets -D warnings` clean across Linux/macOS.
|
||||
|
||||
## 0.13.1 (2026-04-24)
|
||||
|
||||
### `scsi::reset()` now has a hard wallclock timeout
|
||||
|
||||
Production incident on a wedged BU40N USB drive: autorip's poll loop
|
||||
called `scsi::reset()` and the call hung for 60+ seconds before the
|
||||
operator manually intervened. Root cause: the Linux `SG_SCSI_RESET`
|
||||
ioctl can block indefinitely when the kernel SCSI subsystem is waiting
|
||||
on a bus-wedged device that will never ack — there's no kernel-side
|
||||
timeout on this ioctl. Without an outer wallclock bound the caller's
|
||||
thread is stuck in the kernel until the device unwedges (which, for a
|
||||
permanently-dead USB target, may be never).
|
||||
|
||||
`scsi::reset()` is now a wrapper that runs the platform-specific reset
|
||||
on a detached worker thread and bounds the caller's wait via
|
||||
`mpsc::recv_timeout(DEFAULT_RESET_TIMEOUT_SECS)` (30 s). Returns
|
||||
`DeviceResetFailed` on timeout. The worker thread keeps running until
|
||||
the kernel eventually unblocks (we can't cancel a Linux ioctl from
|
||||
userspace) — this leaks one OS thread per hard wedge, an acceptable
|
||||
cost for a daemon that recovers vs. one that hangs.
|
||||
|
||||
- New `pub const DEFAULT_RESET_TIMEOUT_SECS: u64 = 30;`
|
||||
- New `pub fn reset_with_timeout(device, Duration) -> Result<()>` for
|
||||
callers that want a different bound.
|
||||
- Existing `pub fn reset(device) -> Result<()>` keeps the same
|
||||
signature; behaviour change is the timeout, not the API.
|
||||
|
||||
### Follow-up flagged
|
||||
|
||||
`SG_SCSI_RESET` only resets at the SCSI layer. For USB-attached drives
|
||||
(the BU40N case), the wedge is often in the USB Mass Storage layer
|
||||
*below* SCSI — `SG_SCSI_RESET` doesn't help. The proper escalation is
|
||||
`USBDEVFS_RESET` (the `usbreset.c` ioctl), which re-enumerates the
|
||||
device at the USB layer. Tracked for 0.13.2: a `scsi::usb_reset(path)`
|
||||
that resolves sg → USB device and issues `USBDEVFS_RESET`. That would
|
||||
have recovered tonight's BU40N without operator intervention.
|
||||
|
||||
## 0.13.0 (2026-04-24)
|
||||
|
||||
### Zero English in library — typed variants for every error path
|
||||
|
||||
Audit pass against the `project docs` rule (no English text in library code).
|
||||
Found nine call sites that violated the contract by stuffing English into
|
||||
`io::Error::new(kind, "…")` or by abusing `Error::DeviceNotFound { path }`
|
||||
as a free-form description field. Each is now a typed variant with
|
||||
structured fields; the CLI / autorip translates to localized text.
|
||||
|
||||
New `Error` variants and codes:
|
||||
|
||||
- `ScsiInterfaceUnavailable { path }` — `E1004` (macOS
|
||||
`SCSITaskDeviceInterface` couldn't be obtained)
|
||||
- `DeviceLocked { path, kr }` — `E1005` (replaces an English
|
||||
"exclusive access denied. Try: diskutil unmountDisk" message)
|
||||
- `IoKitPluginFailed { path, kr }` — `E1006`
|
||||
- `UnsupportedPlatform { target }` — `E2003` (built on an OS without an
|
||||
SCSI backend)
|
||||
- `PlatformNotImplemented { platform }` — `E2004` (replaces the
|
||||
`product_revision: "Renesas not yet implemented"` string-stuffing in
|
||||
`drive::mod`)
|
||||
- `MapfileInvalid { kind }` — `E6011` (ddrescue mapfile parse, with a
|
||||
stable `&'static str` kind: `"status_char"` or `"hex"`)
|
||||
- `DiscUrlNotDirect` — `E9009` (replaces the full English sentence
|
||||
`"Use Drive::open() + Disc::scan() + DiscStream::new() for disc sources"`
|
||||
that `mux::input(disc://…)` returned to callers)
|
||||
|
||||
Migrated call sites:
|
||||
|
||||
- `mux/resolve.rs` — disc URL → `DiscUrlNotDirect`; the four
|
||||
`format!("m2ts://…")` / `format!("mkv://…")` IO error wraps now
|
||||
propagate the inner `io::Error` unchanged (the URL-prefix wrap added
|
||||
no semantic information).
|
||||
- `mux/iso.rs` — same `format!("iso://…")` wrap dropped.
|
||||
- `sector.rs` — image-too-large now uses the existing `IsoTooLarge`
|
||||
variant instead of `format!("…image too large, max ~8 TB")`.
|
||||
- `disc/mapfile.rs` — bad-status-char and bad-hex parser errors now use
|
||||
`MapfileInvalid { kind }`.
|
||||
- `scsi/mod.rs` — unsupported-platform path uses `UnsupportedPlatform`.
|
||||
- `scsi/macos.rs` — IOKit plugin failure → `IoKitPluginFailed`,
|
||||
`SCSITaskDeviceInterface` missing → `ScsiInterfaceUnavailable`,
|
||||
exclusive-access denied → `DeviceLocked` with the IOReturn code as a
|
||||
structured `kr` field. The `find_scsi_service` four-stage failure path
|
||||
is now a single `DeviceNotFound { path }` (none of the prior
|
||||
per-stage English descriptions were user-actionable individually).
|
||||
- `drive/mod.rs` — Renesas platform → `PlatformNotImplemented`.
|
||||
|
||||
### Stripped English from `labels` module
|
||||
|
||||
`labels::apply()` previously pushed `"Commentary"`, `"Descriptive Audio"`,
|
||||
`"Score"`, `"IME"`, and `" (Secondary)"` directly into
|
||||
`AudioStream.label`. Those English strings then leaked into MKV titles
|
||||
and into autorip's UI. The data was already structured upstream
|
||||
(`LabelPurpose` enum on `StreamLabel`); the lib was downcasting it for
|
||||
the caller's convenience.
|
||||
|
||||
- `AudioStream` gains `purpose: LabelPurpose` (re-exported from
|
||||
`crate::disc` next to the struct, alongside `LabelQualifier`).
|
||||
- `SubtitleStream` gains `qualifier: LabelQualifier`.
|
||||
- `apply()` writes structured fields, never English. `label` keeps
|
||||
codec-formatting only (`"Dolby TrueHD 5.1"`).
|
||||
- `generate_audio_label` drops the `" (Secondary)"` suffix; `secondary`
|
||||
is already a `bool` field, callers render it.
|
||||
- `generate_video_label` drops the `"Secondary Video"` fallback.
|
||||
`"Dolby Vision EL"` kept (brand identifier, not translatable).
|
||||
|
||||
### API hygiene
|
||||
|
||||
- **mux module visibility tightened**. `pub mod ebml`, `m2ts`, `mkv`,
|
||||
`network`, `null`, `ps`, `stdio`, `ts`, `tsmux` are now `pub(crate)`.
|
||||
Their *types* are still re-exported from `lib.rs` — the modules
|
||||
themselves were leaking low-level EBML primitives, TS muxer
|
||||
internals, and network/stdio implementations that no external caller
|
||||
used. `mux::codec`, `mux::disc`, `mux::iso`, `mux::resolve`, and
|
||||
`mux::meta` stay public (genuine APIs).
|
||||
- **Stream trait rustdoc**. The keystone PES `Stream` trait (in
|
||||
`pes.rs`) had per-method docs but no trait-level doc. Now explains
|
||||
read-vs-write split, error contracts, the role of `info()` /
|
||||
`codec_private()` / `headers_ready()`.
|
||||
- **lib.rs re-export sections**. Eight grouped sections with a
|
||||
paragraph each (Drive lifecycle, Errors, Decryption, Disc structure,
|
||||
Streams, Lower-level surfaces) so `cargo doc` tells callers when to
|
||||
reach for what.
|
||||
- **Dropped `ScanOptions::with_keydb()`**. The `_with_X` constructor
|
||||
pattern was banned by `project docs` (one method per action). Use the
|
||||
struct literal: `ScanOptions { keydb_path: Some(p.into()) }`. Five
|
||||
external call sites (autorip ×3, freemkv CLI ×3) and three test
|
||||
fixtures migrated.
|
||||
- **`pid_index` allocation documented**. The `TsDemuxer::new` flat
|
||||
lookup table was flagged by audit as "unbounded for adversarial
|
||||
PIDs"; on closer reading it's bounded by `u16::MAX × 2 bytes ≈ 128 KB`.
|
||||
Doc comment now states the bound explicitly so future contributors
|
||||
don't re-flag it.
|
||||
|
||||
### Dead-code sweep
|
||||
|
||||
Pre-PES-rewrite leftovers that were `pub` but unreachable:
|
||||
|
||||
- Deleted `mux/lookahead.rs` entirely (orphan file — never had a `mod`
|
||||
declaration; only used by its own tests).
|
||||
- Deleted `mux/tsreader.rs` (`TsDemuxReader` struct + four methods,
|
||||
used nowhere).
|
||||
- Deleted `mux::ebml::write_int`, `read_vint`, `SEEK_HEAD`, `SEEK`,
|
||||
`SEEK_ID`, `SEEK_POSITION` (unused).
|
||||
- Deleted `mux::ts::scan_first_pts`, `scan_last_pts`, `scan_duration`,
|
||||
`SCAN_HEAD_SIZE`, `SCAN_TAIL_SIZE`, `take_remainder`, `set_remainder`
|
||||
(unused since the v0.10 PES rewrite).
|
||||
- Deleted `MkvMuxer::codec_private_slots` /
|
||||
`codec_private_filled` fields and `fill_codec_private` method —
|
||||
deferred-codecPrivate path was never exercised once codec_privates
|
||||
flowed through `DiscTitle`.
|
||||
|
||||
`cargo clippy --all-targets -- -D warnings` is clean.
|
||||
|
||||
### Tests
|
||||
|
||||
- New `error::tests` module — code distinctness, Display has no English
|
||||
words, `io::ErrorKind` mapping for every new variant.
|
||||
- 233 lib tests (was 230), all green.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
Source-compatible for callers who use `Error` opaquely (handle
|
||||
`Result<T, Error>` and `error.code()` only). The following are breaking:
|
||||
|
||||
- `ScanOptions::with_keydb()` removed — use struct literal.
|
||||
- `mux::ebml`, `mux::mkv`, `mux::ts`, etc. modules no longer accessible
|
||||
externally — use the re-exported types from the crate root instead.
|
||||
- `AudioStream` and `SubtitleStream` gained required fields (`purpose`,
|
||||
`qualifier`). Construction-by-struct-literal must include them.
|
||||
- `Error::UnsupportedDrive { product_revision: "Renesas not yet
|
||||
implemented" }` no longer produced — match `PlatformNotImplemented`.
|
||||
|
||||
### Magic-number policy
|
||||
|
||||
Per the v0.13 audit directive: new code in this release uses named
|
||||
documented constants (e.g. `POLL_INTERVAL_SECS` in autorip, the wedge
|
||||
signature literals in `ripper.rs`, the `BD_TS_PID_SPACE` floor in
|
||||
`ts.rs`'s table allocation). A comprehensive retrofit of pre-existing
|
||||
magic numbers across the older codebase is queued as follow-up work for
|
||||
0.13.1+ — too large to absorb into this release without scope creep.
|
||||
|
||||
## 0.12.0 (2026-04-24)
|
||||
|
||||
### Rust 2024 edition migration
|
||||
- Bumped `edition = "2024"`. Required code changes:
|
||||
- FFI declarations in `src/scsi/macos.rs` wrapped in `unsafe extern "C" { … }` per the 2024 FFI safety rules.
|
||||
- `unsafe_op_in_unsafe_fn` lint: `vtable_fn()` body now has an explicit `unsafe { … }` block rather than relying on implicit unsafe of the containing `unsafe fn`.
|
||||
- Match-ergonomics: removed redundant `ref`/`ref mut` bindings in `mux/meta.rs`, `mux/mkvstream.rs`, `mux/network.rs`, `mux/stdio.rs` — 2024 tightens "cannot explicitly borrow within an implicitly-borrowing pattern."
|
||||
- No behavior change. MSRV stays at 1.86.
|
||||
|
||||
### Minor / version sync
|
||||
- Part of the 0.12.0 ecosystem release. The autorip-side fixes (progress regressions, UI redesign, regression-guard tests) drove the minor bump.
|
||||
|
||||
## 0.11.22 (2026-04-24)
|
||||
|
||||
### Version sync — no functional changes
|
||||
Part of the 0.11.22 ecosystem release. autorip 0.11.22 ships full multi-pass UI (bad-range viz, live mapfile stats, Recovery settings); the library API is unchanged from 0.11.21.
|
||||
|
||||
## 0.11.21 (2026-04-24)
|
||||
|
||||
### Multi-pass rip architecture — disc → ISO → patch → ISO
|
||||
|
||||
New primitives for two-stage rip: fast forward pass with zero-fill on failures, then targeted retries of bad ranges. Keeps the library API stream-based; the multi-pass model lives entirely in caller-orchestrated function composition.
|
||||
|
||||
- **New `Disc::copy(reader, path, &CopyOptions)`** replaces the positional-arg version. Always produces a ddrescue-format mapfile at `path + ".mapfile"` as a side-effect. With `skip_on_error=true` + `skip_forward=true`, does ddrescue-style fast sweep: 64 KB block reads, exponential skip-forward (256 KB → cap at 1% of disc) on failure, zero-fill bad blocks, record ranges in the mapfile. With defaults (both false), matches pre-0.11.21 behavior — uses drive-level recovery, aborts on bad sector. Mapfile is produced either way.
|
||||
- **New `Disc::patch(reader, path, &PatchOptions)`** — idempotent retry pass. Reads the mapfile, re-reads every non-`+` range with full drive recovery enabled, writes successful bytes back into the ISO at exact offsets, updates mapfile. Call N times for N retry attempts.
|
||||
- **New `disc::mapfile` module** — ddrescue-compatible plain-text format. Crash-safe (flushes on every `record()`), greppable, human-editable, tool-interoperable. Status chars match ddrescue: `?` non-tried · `*` non-trimmed · `/` non-scraped · `-` unreadable · `+` finished.
|
||||
- **Re-exports:** `FileSectorReader` from the crate root for ISO readers.
|
||||
|
||||
### Breaking changes
|
||||
- `Disc::copy`'s signature changes from positional args (`decrypt, resume, batch, on_progress`) to `CopyOptions`. Previous callers must migrate. `freemkv` CLI updated in lockstep.
|
||||
|
||||
### Version sync
|
||||
- Part of the 0.11.21 ecosystem release (libfreemkv + freemkv + bdemu + autorip all on 0.11.21).
|
||||
|
||||
## 0.11.18 (2026-04-24)
|
||||
|
||||
### DiscStream halt flag — Stop works during dense bad-sector regions
|
||||
|
||||
`DiscStream::fill_extents` loops internally when the demuxer hasn't accumulated enough data to emit a PES frame — during a dense bad-sector run, that loop can spend many minutes shrinking batch sizes and zero-filling sectors without ever returning to the outer read() call. Without an internal halt check, the caller's Stop request goes unserviced until the demuxer eventually emits a frame, which may be very far away.
|
||||
|
||||
- **`DiscStream::set_halt(Arc<AtomicBool>)`** — share a halt flag with the stream. Typically wired to `Drive::halt_flag()` so Stop propagates across both the drive's recovery phases and the stream's sector processing.
|
||||
- **`fill_extents()` checks the halt flag** at the top of every retry iteration (before each attempt at every size level). Raising the flag aborts within one read round-trip — at most the current SCSI command's timeout.
|
||||
- Returns `Err(Error::Halted)` (E6010) so the outer rip pipeline terminates cleanly.
|
||||
|
||||
No behavior change for callers that don't call `set_halt`. Unblocks the architectural fix for the "Stop doesn't stop" bug observed on a damaged UHD disc where the stream was stuck in a 12+ hour bad-sector grind.
|
||||
|
||||
## 0.11.17 (2026-04-23)
|
||||
|
||||
### Adaptive batch sizer in DiscStream — no more per-sector descent
|
||||
|
||||
Rip recovery rewritten. The old binary-search-per-bad-sector model paid the full descent (batch → half → quarter → … → single) for every bad sector in a region. On a damaged disc with 600 consecutive bad sectors this took 12+ hours. The new algorithm pays the descent once, remembers the working size, and ramps back up only after a sustained clean streak.
|
||||
|
||||
- **`BatchSizeChanged { new_size, reason }` event** — fires on shrink (read failed) and probe-up (clean streak threshold hit). Consumers use this to distinguish a "recovering" rip from a normal one.
|
||||
- **Removed `BinarySearch` and `SectorRecovered` emissions from DiscStream** — no longer produced by the rip path. `SectorRecovered` still fires from `Drive::read`'s multi-phase recovery (unused by rips today, but kept for scan/other callers).
|
||||
- **Removed `read_with_binary_search` and the 3×5s light-recovery loop** — no retry loops, no sleeps. One 5s attempt per read. On size-1 failure, skip (zero-fill) or error.
|
||||
- **Probe-up threshold: 100 MiB (51,200 sectors) of clean reading at current size** before doubling toward preferred. Ramp 1 → preferred on good reading takes ~100 seconds for a typical BD — trivial vs. rip duration, conservative enough that a single lucky sector in a marginal zone can't trigger a premature probe.
|
||||
- **Bad-region math**: ~600 consecutive bad sectors now complete in ~50 min (600 × 5s) instead of ~12h. The descent is O(log preferred) one time, not per sector.
|
||||
|
||||
### macOS
|
||||
|
||||
- Fix new clippy lint (`manual_c_str_literals`) in `scsi/macos.rs`.
|
||||
|
||||
## 0.11.16 (2026-04-21)
|
||||
|
||||
### API cleanup — one method per action
|
||||
- **SectorReader::read_sectors(lba, count, buf, recovery)** — single method with `recovery: bool`. Removes `read_sectors_recover()`.
|
||||
- **parser_for_codec(codec, codec_data)** — single constructor. Removes `parser_for_codec_with_data()`.
|
||||
- **DvdSubParser::new(codec_data)** — single constructor. Removes `with_codec_data()`.
|
||||
- **MkvMuxer::new(writer, tracks, title, duration, chapters)** — single constructor. Removes `new_with_chapters()`.
|
||||
|
||||
## 0.11.15 (2026-04-21)
|
||||
|
||||
### Lint cleanup
|
||||
- Fix all `cargo fmt` and `cargo clippy -D warnings` across codebase.
|
||||
- Remove unused imports, dead code, collapsible if-statements, div_ceil reimplementation.
|
||||
|
||||
## 0.11.14 (2026-04-21)
|
||||
|
||||
### Audit fixes: read recovery, verify, SCSI
|
||||
- **Fix: trailing sectors at extent boundaries** — extents with sector_count not divisible by 3 no longer drop 1-2 trailing sectors. decrypt_sectors() safely skips partial AACS units.
|
||||
- **Fix: verify_title stop support** — progress callback now returns bool. Return false to stop verification early instead of running to completion.
|
||||
- **Fix: O_CLOEXEC on all SCSI fd opens** — prevents fd leak to child processes.
|
||||
- **Fix: SCSI sense descriptor format** — correctly detect response code 0x72/0x73 (descriptor format) and extract sense key from byte 1 instead of byte 2.
|
||||
- **Fix: DecryptFailed on missing unit key** — decrypt_sectors() returns Err(DecryptFailed) instead of silently using a zero key.
|
||||
|
||||
## 0.11.13 (2026-04-21)
|
||||
|
||||
### Fix: all rip reads use fast timeout
|
||||
- Initial batch read changed from full Drive::read() recovery to fast 5s timeout. Binary search starts immediately on failure instead of after 10 minutes of retries.
|
||||
- Max 15 seconds per bad sector (3 x 5s attempts). Max 23 seconds per batch with 1 bad sector.
|
||||
|
||||
## 0.11.12 (2026-04-21)
|
||||
|
||||
### Drive halt + sector events + light recovery
|
||||
- **Drive.halt()** — AtomicBool flag checked between retry phases. Max 30s to stop.
|
||||
- **Drive.on_event()** — callback for ReadError, Retry, SpeedChange, SectorRecovered events.
|
||||
- **Error::Halted (E6010)** — distinct from DiscRead, indicates intentional stop.
|
||||
- **Binary search light recovery** — single sectors get 3 attempts x 5s (15s max) instead of full 10-min Drive::read() recovery. Marginal disc zones complete in minutes not hours.
|
||||
- **DiscStream.on_event()** — BinarySearch, SectorRecovered, SectorSkipped events.
|
||||
|
||||
## 0.11.11 (2026-04-20)
|
||||
|
||||
### Binary search error recovery
|
||||
- **fill_extents binary search** — when a batch read fails, binary search to isolate the failing sector(s). Good sectors read in sub-batches at full speed. Only truly bad sectors get individual recovery. 60-sector batch with 1 bad sector: ~5 seconds instead of 10+ minutes.
|
||||
|
||||
## 0.11.10 (2026-04-20)
|
||||
|
||||
### Skip errors + clean verify API
|
||||
- **DiscStream.skip_errors** — when true, zero-fills unreadable sectors and continues instead of aborting. Caller sets based on user preference.
|
||||
- **read_sectors_recover(recovery: bool)** — single API for recovery vs fast reads. Replaces separate read_sectors_fast method.
|
||||
|
||||
## 0.11.9 (2026-04-20)
|
||||
|
||||
### Fast verify reads
|
||||
- **read_sectors_fast()** — single-attempt 5s timeout SCSI read for verify. No recovery loop. Bad sectors detected in seconds instead of 10+ minutes.
|
||||
- **SectorReader trait** — added read_sectors_fast() with default fallback to read_sectors().
|
||||
|
||||
## 0.11.8 (2026-04-20)
|
||||
|
||||
### Disc verify
|
||||
- **verify::verify_title()** — sector-by-sector health check. Classifies sectors as Good/Slow/Recovered/Bad. Progress callback, chapter mapping, sector ranges.
|
||||
|
||||
## 0.11.7 (2026-04-19)
|
||||
|
||||
### TrueHD parser rewrite
|
||||
- **12-bit length mask** — access unit length is lower 12 bits of first 2 bytes, not full 16. Upper 4 bits are parity nibble. Wrong mask caused misaligned frame splits.
|
||||
- **AC-3 frame skipping** — BD-TS TrueHD PES contains interleaved AC-3 frames (same PID). Parser now detects AC-3 sync word (0x0B77) and skips those frames.
|
||||
- **Cross-PES buffering** — access units that span PES packet boundaries are correctly reassembled.
|
||||
- **Per-unit timestamps** — each access unit gets incrementing PTS (1/1200th second apart) instead of all units in one PES sharing the same timestamp.
|
||||
- **Major sync detection** — keyframe flag set when access unit contains MLP major sync (0xF8726FBA).
|
||||
- Result: zero TrueHD decode errors on UHD and BD (was ~19 per 30 seconds).
|
||||
|
||||
## 0.11.6 (2026-04-18)
|
||||
|
||||
### TrueHD fix (incomplete)
|
||||
- Initial attempt at TrueHD header stripping — wrong approach, superseded by 0.11.7.
|
||||
|
||||
## 0.11.5 (2026-04-18)
|
||||
|
||||
### MKV container fixes — Jellyfin/player compatibility
|
||||
- **Timestamp normalization** — MKV and M2TS output starts at 0.000s instead of raw disc PTS offset. Fixes playback failures in Jellyfin and other players.
|
||||
- **DefaultDuration** — correct frame rate written to MKV track header. Fixes wrong avg_frame_rate (was 293/12, now 24000/1001).
|
||||
- **HDR Colour metadata** — MatrixCoefficients, TransferCharacteristics, Primaries, Range written to MKV video track. Enables HDR tone mapping in players.
|
||||
- **DisplayWidth/DisplayHeight** — aspect ratio fields in MKV video track.
|
||||
- **Chapters (Blu-ray)** — accept mark_type 0 as chapter entry (was filtering to type 1 only, which no disc uses).
|
||||
- **Chapters (DVD)** — extract chapter timestamps from PGC program map + cell durations.
|
||||
- **Default disposition** — only first video and first audio track marked default. Fixes wrong auto-selection in players.
|
||||
|
||||
## 0.11.3 (2026-04-18)
|
||||
|
||||
### Unified versioning
|
||||
- All freemkv repos now share the same version number. No functional changes from 0.10.10.
|
||||
|
||||
## 0.10.10 (2026-04-18)
|
||||
|
||||
### Dual-layer disc fix
|
||||
- **UDF extent allocation** — use actual UDF allocation descriptors (`file_extents()`) instead of assuming m2ts files are contiguous from `file_start_lba`. Dual-layer UHD discs split large files across many extents (~1 GB each). The old single-extent assumption truncated rips at ~37% on affected discs.
|
||||
- **Read error propagation** — `fill_extents()` returns `io::Result<bool>` so SCSI read errors propagate to the caller instead of being silently treated as EOF.
|
||||
|
||||
## 0.10.9 (2026-04-17)
|
||||
|
||||
### Fast disc identification
|
||||
- **Disc::identify()** — reads UDF filesystem only (name, format, layers, encrypted). ~3s on USB vs 18s for full scan. No AACS handshake or playlist parsing.
|
||||
- **KEYDB path fix** — added `~/.config/freemkv/keydb.cfg` to search paths. Fixes silent rip hang when KEYDB exists but isn't found by `resolve_keydb()`.
|
||||
|
||||
## 0.10.8 (2026-04-17)
|
||||
|
||||
### Buffered UDF reads
|
||||
- **BufferedSectorReader** — prefetches batch sectors on single-sector reads. USB drives have ~500ms per SCSI command; this eliminates scan hangs.
|
||||
- **Metadata partition pre-read** — loads entire UDF metadata partition into memory after initial parse.
|
||||
- Scan time reduced from 10+ minutes to ~18 seconds on USB.
|
||||
|
||||
## 0.10.7 (2026-04-17)
|
||||
|
||||
### DiscStream::new()
|
||||
- Replaced open_drive(), open_iso(), from_reader() with single new() constructor
|
||||
- Stream accepts ContentFormat and sets up demuxer internally
|
||||
- Removed disc:// case from input() — callers use primitives directly
|
||||
|
||||
## 0.10.6 (2026-04-16)
|
||||
|
||||
### Docker compatibility
|
||||
- **Drive discovery** — removed sysfs check that blocked detection inside Docker containers. Device nodes are sufficient; INQUIRY command validates the device is an optical drive.
|
||||
|
||||
## 0.10.5 (2026-04-16)
|
||||
|
||||
### Audio parser buffering
|
||||
- **AC3** — buffer across PES boundaries with frame size from fscod/frmsizecod table. Eliminates all AC3 decode errors on BD and UHD.
|
||||
- **DTS** — buffer with core sync detection + frame size from header. DTS-HD extension frames handled correctly.
|
||||
- **TrueHD** — buffer with unit length field parsing. Incomplete units held for next PES.
|
||||
- All audio parsers now emit complete frames only. When PES boundaries align (normal case), buffering is a no-op.
|
||||
|
||||
## 0.10.4 (2026-04-16)
|
||||
|
||||
### CSS decryption — full key hierarchy
|
||||
- **Bus auth → disc key → title key** — complete CSS key chain. Bus authentication with CSSCryptKey challenge-response, disc key decryption using 31 player keys via READ DVD STRUCTURE, title key extraction via REPORT KEY format 0x04.
|
||||
- **CSS descramble cipher** — correct LFSR keystream generation with TAB5 for LFSR1 output and TAB4 for LFSR0 output. Per-sector key derivation from title key XOR sector seed.
|
||||
- **Stevenson plaintext attack** — expanded pattern set (padding, video, audio, nav pack headers), scans up to 50K scrambled sectors for ISO key recovery.
|
||||
- **Disc::copy() CSS decrypt** — sector-level decryption during disc→ISO copy produces clean ISOs with zero scramble flags.
|
||||
|
||||
### MPEG-2 PS demuxer fixes
|
||||
- **DVD PS path routes through codec parsers** — was bypassing parser.parse(), producing raw PES frames without codec_private extraction or keyframe detection.
|
||||
- **MPEG-2 sequence header extraction** — calculates exact header size including quantizer matrices (intra/non-intra flags), captures sequence extension from subsequent PES packets.
|
||||
- **TsDemuxer dynamic PID table** — Vec instead of fixed [i16; 8192] for DVD PIDs that may exceed 8192.
|
||||
|
||||
## 0.10.3 (2026-04-16)
|
||||
|
||||
### DVD CSS authentication
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.10.3"
|
||||
edition = "2021"
|
||||
version = "0.13.6"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
description = "Open source raw disc access library for optical drives"
|
||||
|
|
@ -31,6 +31,9 @@ libc = "0.2"
|
|||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[[bench]]
|
||||
name = "sgio_read"
|
||||
harness = false
|
||||
|
|
|
|||
12
FEATURES.md
12
FEATURES.md
|
|
@ -1,6 +1,6 @@
|
|||
# libfreemkv — Feature List
|
||||
|
||||
## v0.7.1 (current)
|
||||
## v0.10.10 (current)
|
||||
|
||||
### Done
|
||||
- [x] Drive access: open, identify, unlock, firmware upload, speed calibration, eject
|
||||
|
|
@ -11,13 +11,18 @@
|
|||
- [x] CLPI clip info parser (EP map, sector extents)
|
||||
- [x] AACS 1.0 decryption (4 VUK paths: KEYDB, media key, processing key, device key)
|
||||
- [x] AACS 2.0 SCSI handshake (P-256/SHA-256 ECDH, bus decryption, read data key)
|
||||
- [x] DVD IFO parser (VMG, VTS, PGC chains, cell addresses)
|
||||
- [x] DVD CSS decryption (bus auth, disc key via player keys, title key, sector descramble)
|
||||
- [x] CSS Stevenson plaintext attack for ISO key recovery
|
||||
- [x] MPEG-2 PS demuxer (DVD Program Stream with PES extraction)
|
||||
- [x] MPEG-2 video codec parser (sequence header, quantizer matrices, keyframe detection)
|
||||
- [x] KEYDB.cfg download, verify, save (raw TCP, zero HTTP deps)
|
||||
- [x] Content reading with adaptive batch sizing, error recovery, 12+ MB/s
|
||||
- [x] Stream labels: 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
||||
- [x] MKV muxer: 8 codec parsers (H.264, HEVC, AC-3, DTS, TrueHD, PGS, VC-1, LPCM)
|
||||
- [x] MKV muxer: 14 codec parsers (H.264, HEVC, MPEG-2, AC-3, DTS, TrueHD, PGS, DVD Sub, VC-1, LPCM, +4 more)
|
||||
- [x] SectorReader trait: decouples disc scanning from SCSI
|
||||
- [x] 7 stream types: Disc, ISO, MKV, M2TS, Network, Stdio, Null
|
||||
- [x] IOStream trait with URL-based resolver (scheme://path)
|
||||
- [x] PES pipeline with unified Stream trait (any source → any dest)
|
||||
- [x] FMKV metadata header for M2TS and network streams
|
||||
- [x] Numeric error codes only (no English text in library)
|
||||
- [x] Event system for progress callbacks
|
||||
|
|
@ -25,6 +30,5 @@
|
|||
### Planned
|
||||
- [ ] Windows testing on real hardware
|
||||
- [ ] Pioneer Renesas platform support (48 drives, need GET_CONFIG 010C)
|
||||
- [ ] DVD CSS decryption
|
||||
- [ ] TranscodeStream (ffmpeg integration)
|
||||
- [ ] ISO write with BD-compliant UDF structure
|
||||
|
|
|
|||
20
Makefile
Normal file
20
Makefile
Normal file
|
|
@ -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
|
||||
39
README.md
39
README.md
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
# libfreemkv
|
||||
|
||||
Rust library for 4K UHD / Blu-ray optical drives. Drive access, disc scanning, stream labels, AACS decryption, KEYDB updates, and content reading in one crate. Bundled drive profiles — no external files needed.
|
||||
Rust library for 4K UHD / Blu-ray / DVD optical drives. Drive access, disc scanning, stream labels, AACS decryption, CSS decryption, KEYDB updates, and content reading in one crate. Bundled drive profiles — no external files needed.
|
||||
|
||||
**12+ MB/s** sustained read speeds on BD. Full init: unlock, firmware upload, speed calibration — all from pure Rust.
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ Part of the [freemkv](https://github.com/freemkv) project.
|
|||
|
||||
```toml
|
||||
[dependencies]
|
||||
libfreemkv = "0.10"
|
||||
libfreemkv = "0.13"
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
|
@ -51,6 +51,31 @@ while let Ok(Some(frame)) = input.read() {
|
|||
output.finish()?;
|
||||
```
|
||||
|
||||
### Multi-pass recovery rip
|
||||
|
||||
For damaged discs, the library offers a two-stage rip model: fast sweep with zero-fill and a ddrescue-format mapfile, then targeted retry of bad ranges. See [`docs/rip-recovery.md`](docs/rip-recovery.md) for the full architecture.
|
||||
|
||||
```rust
|
||||
use libfreemkv::disc::{CopyOptions, PatchOptions};
|
||||
|
||||
// Pass 1: disc → ISO. Fast 64 KB reads, skip-forward on failure,
|
||||
// zero-fill bad blocks, write a sidecar .mapfile.
|
||||
let mut result = disc.copy(
|
||||
&mut drive,
|
||||
Path::new("disc.iso"),
|
||||
&CopyOptions { skip_on_error: true, skip_forward: true, ..Default::default() },
|
||||
)?;
|
||||
|
||||
// Pass 2..N: retry bad ranges with full drive recovery.
|
||||
// Idempotent — call as many times as you want.
|
||||
while result.bytes_unreadable + result.bytes_pending > 0 {
|
||||
let pr = disc.patch(&mut drive, Path::new("disc.iso"), &PatchOptions::default())?;
|
||||
if pr.bytes_recovered_this_pass == 0 { break; }
|
||||
}
|
||||
|
||||
// Then mux from the ISO via the normal stream pipeline (no drive involvement).
|
||||
```
|
||||
|
||||
## What It Does
|
||||
|
||||
- **Drive access** — open, identify, unlock, firmware upload, speed calibration, eject
|
||||
|
|
@ -59,7 +84,7 @@ output.finish()?;
|
|||
- **Stream labels** — 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
|
||||
- **AACS decryption** — transparent key resolution and content decrypt (1.0 + 2.0 bus decryption)
|
||||
- **KEYDB updates** — download, verify, save from any HTTP URL (zero deps, raw TCP)
|
||||
- **Content reading** — adaptive batch reads with automatic decryption and error recovery
|
||||
- **Content reading** — adaptive batch reads with automatic decryption
|
||||
- **Stream I/O** — unified stream pipeline for reading and writing any format
|
||||
|
||||
### Streams
|
||||
|
|
@ -67,21 +92,21 @@ output.finish()?;
|
|||
| Stream | Input | Output | Transport |
|
||||
|--------|-------|--------|-----------|
|
||||
| DiscStream | Yes | -- | Optical drive via SCSI |
|
||||
| IsoStream | Yes | Yes | Blu-ray ISO image file |
|
||||
| IsoStream | Yes | -- | Blu-ray ISO image file (read via stream pipeline; written via `Disc::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)
|
||||
|
|
@ -134,7 +159,7 @@ All errors are structured with numeric codes. No user-facing English text — ap
|
|||
|
||||
## Contributing
|
||||
|
||||
Run `freemkv drive-info --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to contribute your drive's profile.
|
||||
Run `freemkv info disc:// --share` with the [freemkv CLI](https://github.com/freemkv/freemkv) to contribute your drive's profile.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -44,9 +44,15 @@ fn main() {
|
|||
let mut fail = 0u32;
|
||||
let mut bytes: u64 = 0;
|
||||
|
||||
// Recovery flag: true matches pre-0.11.13 bench behavior — full SCSI
|
||||
// ECC retry loop on errors (slower, what the rip path used before the
|
||||
// adaptive batch sizer landed). Flip to `false` for the fast-fail path
|
||||
// that current rips use; benches are configurable via this constant.
|
||||
const READ_WITH_RECOVERY: bool = true;
|
||||
|
||||
for i in 0..1000u32 {
|
||||
let lba = i * batch as u32;
|
||||
match drive.read(lba, batch, &mut buf) {
|
||||
match drive.read(lba, batch, &mut buf, READ_WITH_RECOVERY) {
|
||||
Ok(_) => {
|
||||
writer.write_all(&buf).unwrap();
|
||||
ok += 1;
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ libfreemkv (lib.rs)
|
|||
│
|
||||
├── Drive Access
|
||||
│ ├── drive Drive — open, identify, init, unlock, read (with recovery)
|
||||
│ ├── scsi ScsiTransport trait + platform backends (SG_IO, IOKit, SPTI)
|
||||
│ ├── scsi ScsiTransport trait + platform backends (sg async, IOKit, SPTI)
|
||||
│ ├── platform/ Platform trait — per-chipset command handlers
|
||||
│ │ └── mt1959 MediaTek MT1959 driver (LG, ASUS, HP)
|
||||
│ ├── profile DriveProfile loading, matching, bundled JSON
|
||||
|
|
@ -83,7 +83,7 @@ libfreemkv (lib.rs)
|
|||
```
|
||||
Drive::open(Path::new("/dev/sg4"))
|
||||
│
|
||||
├─ scsi::open() Open /dev/sg4 via SG_IO
|
||||
├─ scsi::open() Open /dev/sg4 (async write/poll/read)
|
||||
├─ DriveId::from_drive() INQUIRY + GET_CONFIG 010C
|
||||
├─ profile::find_by_drive_id() Match against bundled profiles
|
||||
├─ Platform::new() Instantiate chipset driver (Mt1959)
|
||||
|
|
@ -175,7 +175,7 @@ is baked into the library.
|
|||
|
||||
| Platform | Transport | Status |
|
||||
|----------|-----------|--------|
|
||||
| Linux | SG_IO ioctl on `/dev/sg*` | Supported |
|
||||
| Linux | async sg write/poll/read on `/dev/sg*` | Supported |
|
||||
| macOS | IOKit SCSITask | Supported |
|
||||
| Windows | SPTI (`IOCTL_SCSI_PASS_THROUGH_DIRECT`) | Supported |
|
||||
|
||||
|
|
|
|||
|
|
@ -46,14 +46,20 @@ platform driver. The drive is ready for `wait_ready()` and `init()`.
|
|||
|
||||
### read() with Recovery
|
||||
|
||||
`Drive::read()` is the single read method. On error:
|
||||
`Drive::read(lba, count, buf, recovery)` is the single read method. The
|
||||
`recovery` parameter controls whether to attempt multi-phase recovery on
|
||||
failure or return immediately (used by DiscStream's binary search for
|
||||
single-sector probes).
|
||||
|
||||
1. Set minimum speed immediately
|
||||
2. Reset device (close/reopen/TUR)
|
||||
3. Wait 2s for drive to settle
|
||||
4. Retry at min speed, min batch (3 sectors)
|
||||
5. If still failing: skip sectors, zero-fill, log
|
||||
6. Stay at min speed for 500 MB after error (recovery window)
|
||||
On error with `recovery = true`:
|
||||
|
||||
1. **Phase 1 — gentle retry (5 attempts):** set min speed, sleep 30s, retry.
|
||||
Each retry has a hard wall-clock timeout via async SG_IO.
|
||||
2. **Phase 2 — fresh start:** close transport, reset device, reopen, reinit.
|
||||
3. **Phase 3 — gentle retry on fresh connection (5 attempts).**
|
||||
4. If all fail: return `Err(DiscRead)`. DiscStream handles it (binary search,
|
||||
skip, zero-fill).
|
||||
5. Stay at min speed for 500 MB after any recovery (recovery window).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -70,8 +76,6 @@ pub trait ScsiTransport: Send {
|
|||
data: &mut [u8],
|
||||
timeout_ms: u32,
|
||||
) -> Result<ScsiResult>;
|
||||
|
||||
fn reset(&mut self, device: &str) -> Result<()>;
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -82,15 +86,18 @@ descriptors or calls ioctls outside of a `ScsiTransport` implementation.
|
|||
|
||||
| Platform | Implementation | Device |
|
||||
|----------|---------------|--------|
|
||||
| Linux | `SgIoTransport` — `ioctl(fd, SG_IO, &hdr)` | `/dev/sg*` |
|
||||
| Linux | `SgIoTransport` — async `write`/`poll`/`read` on `/dev/sg*` | `/dev/sg*` |
|
||||
| macOS | `MacScsiTransport` — IOKit SCSITask | IOKit service |
|
||||
| Windows | `WindowsScsiTransport` — SPTI | `\\.\CdRomN` |
|
||||
|
||||
The Linux backend opens with `O_RDWR | O_NONBLOCK`, constructs `sg_io_hdr`,
|
||||
and returns `ScsiResult` with status, bytes transferred, and sense data.
|
||||
The Linux backend uses the sg driver's asynchronous interface: `write()` submits
|
||||
the command, `poll()` waits with an enforceable wall-clock timeout, `read()`
|
||||
retrieves the result. If `poll()` times out, the fd is abandoned (closed in a
|
||||
background thread) and a fresh fd opened — the kernel's USB error recovery
|
||||
cannot block us. Opens with `O_RDWR | O_NONBLOCK`.
|
||||
|
||||
On non-zero SCSI status, the transport parses sense key, ASC, and ASCQ from the
|
||||
sense buffer and returns `Error::ScsiError`.
|
||||
On non-zero SCSI status, the transport parses sense key from the sense buffer
|
||||
and returns `Error::ScsiError`.
|
||||
|
||||
### CDB Builders
|
||||
|
||||
|
|
|
|||
127
docs/rip-recovery.md
Normal file
127
docs/rip-recovery.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# Rip recovery — multi-pass architecture
|
||||
|
||||
`libfreemkv` supports a two-stage rip model for damaged or protection-bearing
|
||||
discs: a fast forward sweep that tolerates read failures, followed by targeted
|
||||
retry passes against a persistent bad-range map. The stream pipeline
|
||||
(`DiscStream` + `input`/`output`) operates against the resulting ISO image, so
|
||||
the mux stage never touches the drive.
|
||||
|
||||
Three primitives compose the flow:
|
||||
|
||||
| Primitive | What it does |
|
||||
|---------------------------|-----------------------------------------------------------------------|
|
||||
| `Disc::copy` | disc → ISO. Writes a sidecar `.mapfile`. Opt-in skip-forward on failure. |
|
||||
| `Disc::patch` | Re-reads bad ranges from the drive. Idempotent; call N times. |
|
||||
| `DiscStream` (ISO source) | Reads sectors from the ISO, feeds decrypt → demux → codec → mux. |
|
||||
|
||||
The caller orchestrates. Autorip's `rip_disc` loops `copy` then N × `patch` per
|
||||
the `MAX_RETRIES` config, then hands the ISO off to the existing mux pipeline.
|
||||
|
||||
## Data model
|
||||
|
||||
### Mapfile
|
||||
|
||||
Format: [ddrescue](https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html)-compatible
|
||||
plain text, greppable, tool-interoperable. Flushed to disk on every `record()`
|
||||
so a crashed rip loses at most one block.
|
||||
|
||||
```
|
||||
# Rescue Logfile. Created by libfreemkv v0.11.22
|
||||
# Current pos / status / pass / pass_time
|
||||
0x000000000 ? 1 0
|
||||
# pos size status
|
||||
0x000000000 0x12a35d000 +
|
||||
0x12a35d000 0x000003000 -
|
||||
0x12a360000 0x009c4a000 +
|
||||
0x12d00a000 0x000064000 *
|
||||
```
|
||||
|
||||
Status characters match ddrescue:
|
||||
|
||||
| Char | Meaning |
|
||||
|------|----------------------------------------------------|
|
||||
| `?` | Not yet attempted |
|
||||
| `*` | Fast-pass failed; needs edge-trim |
|
||||
| `/` | Trimmed; interior needs sector scrape |
|
||||
| `-` | Unreadable this session |
|
||||
| `+` | Finished (good) |
|
||||
|
||||
Position and size are hex byte offsets into the ISO.
|
||||
|
||||
### `CopyOptions` and `PatchOptions`
|
||||
|
||||
Defaults preserve pre-`0.11.21` behavior — full drive recovery on every read,
|
||||
abort on the first unreadable sector. Opt in to the recovery-friendly path:
|
||||
|
||||
```rust
|
||||
CopyOptions {
|
||||
skip_on_error: true, // zero-fill bad blocks, continue
|
||||
skip_forward: true, // exponential skip-forward after a failure
|
||||
resume: true, // pick up from an existing ISO + mapfile
|
||||
decrypt: false, // keep the ISO a raw disc image
|
||||
..Default::default()
|
||||
}
|
||||
```
|
||||
|
||||
## Algorithm
|
||||
|
||||
### Pass 1 — fast sweep
|
||||
|
||||
1. Read 64 KB (32 sectors, one BD ECC block) at the current LBA.
|
||||
2. On success: mark the range `+`, advance by one block.
|
||||
3. On failure (with `skip_on_error`): zero-fill the block in the ISO, mark
|
||||
it `*`, advance.
|
||||
4. If `skip_forward` is set: after a failure, jump ahead by an exponentially
|
||||
growing amount (256 KB initial, doubling on consecutive failures, capped at
|
||||
1% of disc). The skipped bytes are also marked `*` — `patch` will visit
|
||||
them later.
|
||||
5. Reset the skip size to 256 KB on the first success after a failure.
|
||||
|
||||
Pass 1 completes when every byte has terminal status (`+`, `-`, or the caller
|
||||
bails via the halt flag).
|
||||
|
||||
### Pass 2+ — patch
|
||||
|
||||
`Disc::patch` reads the mapfile and iterates every non-`+` range. For each:
|
||||
|
||||
1. Issue a drive read with full recovery enabled (SCSI-level retries,
|
||||
ECC recovery, the lot).
|
||||
2. On success: write the good bytes into the ISO at the exact byte offset,
|
||||
mark `+`.
|
||||
3. On failure: mark `-`.
|
||||
4. Update the mapfile after every block — crash-safe resume.
|
||||
|
||||
Idempotent. Call `patch` N times for N retry attempts; typically the caller
|
||||
stops early if a pass recovers zero bytes (structure-protected sectors will
|
||||
never yield).
|
||||
|
||||
## Design choices
|
||||
|
||||
**No `MODE SELECT` to disable drive retries.** Research showed neither ddrescue
|
||||
nor MakeMKV does this. Drive firmware has access to raw analog signal, laser
|
||||
power control, and drive-specific ECC tuning that userspace can't replicate —
|
||||
disabling it throws away recovery headroom on marginal sectors. We fail fast
|
||||
via short SG_IO timeouts instead, and we avoid per-sector probing on first
|
||||
contact by using large blocks + skip-forward.
|
||||
|
||||
**ISO intermediate, even for single-pass.** Pass 1 always writes an ISO. The
|
||||
mux stage reads the ISO via `IsoSectorReader`. For single-pass (no retries),
|
||||
this adds ~2-3 min (local disk mux) but gains resumability across crashes,
|
||||
re-muxability without re-ripping, and a persistent forensic artifact. Callers
|
||||
who need pure speed can bypass and use `DiscStream::new(Box::new(drive), …)`
|
||||
directly — the lib doesn't forbid it.
|
||||
|
||||
**Mapfile in ddrescue format.** Plain text so users can `less` it, `diff` it,
|
||||
or feed it to ddrescue's own tooling. Crash-safe (flush-per-record). Entries
|
||||
coalesce on adjacent same-status ranges so files stay small.
|
||||
|
||||
**Patches target `-`, `*`, `/`, and `?` alike.** The status state machine is
|
||||
ddrescue's but `patch` collapses the distinction — it just tries every
|
||||
non-finished range with full recovery. Future work can specialize (trim vs.
|
||||
scrape vs. retry with direction reversal) if there's measured benefit.
|
||||
|
||||
## References
|
||||
|
||||
- [ddrescue manual, Algorithm chapter](https://www.gnu.org/software/ddrescue/manual/ddrescue_manual.html)
|
||||
- [ddrescue optical media notes](https://www.electric-spoon.com/doc/gddrescue/html/Optical-media.html)
|
||||
- Source: [`src/disc/mapfile.rs`](../src/disc/mapfile.rs), [`src/disc/mod.rs`](../src/disc/mod.rs) (`Disc::copy`, `Disc::patch`)
|
||||
|
|
@ -120,6 +120,14 @@ Each directory read involves two sector reads: one for the ICB, then one or more
|
|||
|
||||
`read_file()` reads a file by navigating the directory tree, reading the file's ICB to get its data extent, then reading the data sector by sector from the **physical partition** (partition_start + LBA, not metadata_start).
|
||||
|
||||
## Buffered Sector Reads
|
||||
|
||||
USB optical drives have ~500ms round-trip latency per SCSI command. Since `read_filesystem()` and `read_file()` issue one SCSI READ per sector, a full disc scan can require hundreds of commands -- taking 10+ minutes on USB.
|
||||
|
||||
`Disc::scan()` wraps the drive in a `BufferedSectorReader` before reading. On a single-sector read, the buffer prefetches a batch of sectors (sized from the kernel's `max_hw_sectors_kb` for the device) and caches them. Subsequent reads to nearby LBAs return from cache with zero SCSI overhead. After parsing the UDF directory structure, the entire metadata partition is pre-read into the cache, so all ICB lookups during title scanning and encryption resolution are instant.
|
||||
|
||||
The buffer is transparent -- `read_filesystem()`, `read_file()`, and all downstream code still call `read_sectors(lba, 1, buf)` as before. The batching happens inside the `SectorReader` implementation.
|
||||
|
||||
### UDF Filename Encoding
|
||||
|
||||
UDF filenames use a compression ID as the first byte:
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ fn main() {
|
|||
// Tiny yield between reads — test if pacing prevents firmware throttle
|
||||
std::thread::yield_now();
|
||||
let t0 = Instant::now();
|
||||
let ok = drive.read(lba, count, &mut buf[..n]).is_ok();
|
||||
let ok = drive.read(lba, count, &mut buf[..n], true).is_ok();
|
||||
let read_ms = t0.elapsed().as_millis();
|
||||
|
||||
// Flag slow reads
|
||||
|
|
|
|||
1
rustfmt.toml
Normal file
1
rustfmt.toml
Normal file
|
|
@ -0,0 +1 @@
|
|||
max_width = 100
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
//! AACS content decryption — AES primitives, unit decryption, bus encryption.
|
||||
|
||||
use aes::cipher::{generic_array::GenericArray, BlockDecrypt, BlockEncrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
|
||||
// ── AACS constants ──────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -674,8 +674,8 @@ fn generate_host_key_pair() -> ([u8; 20], [u8; 20], [u8; 20]) {
|
|||
|
||||
/// AES-128-CMAC over 16 bytes of data.
|
||||
fn aes_cmac_16(data: &[u8; 16], key: &[u8; 16]) -> [u8; 16] {
|
||||
use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray};
|
||||
|
||||
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||
|
||||
|
|
|
|||
|
|
@ -361,11 +361,7 @@ mod tests {
|
|||
/// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found.
|
||||
fn keydb_path() -> Option<std::path::PathBuf> {
|
||||
let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?);
|
||||
if path.exists() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
if path.exists() { Some(path) } else { None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -661,18 +661,14 @@ pub fn resolve_keys(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::decrypt::{aes_ecb_encrypt, ALIGNED_UNIT_LEN};
|
||||
use super::super::decrypt::{ALIGNED_UNIT_LEN, aes_ecb_encrypt};
|
||||
use super::super::keydb::{DiscEntry, KeyDb};
|
||||
use super::*;
|
||||
|
||||
/// Get KEYDB path from KEYDB_PATH environment variable. Returns None if not set or not found.
|
||||
fn keydb_path() -> Option<std::path::PathBuf> {
|
||||
let path = std::path::PathBuf::from(std::env::var("KEYDB_PATH").ok()?);
|
||||
if path.exists() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
if path.exists() { Some(path) } else { None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -849,7 +845,7 @@ mod tests {
|
|||
data[23] = 1; // top_menu = CPS unit 1
|
||||
data[24] = 0;
|
||||
data[25] = 1; // num_titles = 1
|
||||
// Title 0 entry: 2 bytes pad + CPS unit
|
||||
// Title 0 entry: 2 bytes pad + CPS unit
|
||||
data[28] = 0;
|
||||
data[29] = 1; // CPS unit 1
|
||||
|
||||
|
|
|
|||
|
|
@ -21,12 +21,12 @@ pub mod keys;
|
|||
// Explicit re-exports — only items needed by external consumers and sibling crate modules.
|
||||
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
|
||||
pub use decrypt::{
|
||||
decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys, is_unit_encrypted,
|
||||
ALIGNED_UNIT_LEN,
|
||||
ALIGNED_UNIT_LEN, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys,
|
||||
is_unit_encrypted,
|
||||
};
|
||||
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
|
||||
pub use keys::{
|
||||
decrypt_unit_key, derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash,
|
||||
disc_hash_hex, mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive,
|
||||
resolve_keys, ContentCert, ResolvedKeys, UnitKeyFile,
|
||||
ContentCert, ResolvedKeys, UnitKeyFile, decrypt_unit_key, derive_media_key_from_dk,
|
||||
derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex, mkb_version,
|
||||
parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys,
|
||||
};
|
||||
|
|
|
|||
327
src/css/auth.rs
327
src/css/auth.rs
|
|
@ -1,23 +1,52 @@
|
|||
//! CSS drive authentication — SCSI handshake to unlock scrambled sector reads.
|
||||
//!
|
||||
//! Pure SCSI (REPORT KEY / SEND KEY). Works on all platforms via ScsiTransport.
|
||||
//!
|
||||
//! The CSS auth uses a challenge-response with a 6-round substitution-permutation
|
||||
//! cipher (CSSCryptKey). This is DIFFERENT from the content descrambling cipher.
|
||||
//! CSS drive authentication — full key hierarchy.
|
||||
//!
|
||||
//! Protocol:
|
||||
//! 1. Allocate AGID
|
||||
//! 2. Host sends challenge, drive returns Key1
|
||||
//! 3. Host brute-forces variant (0-31) by checking which produces Key1
|
||||
//! 4. Drive sends challenge, host computes Key2 using found variant
|
||||
//! 5. Host sends Key2, drive verifies — authentication complete
|
||||
//! 1. Bus authentication (challenge-response) → bus key
|
||||
//! 2. Read disc key block (READ DVD STRUCTURE) → XOR with bus key → decrypt with player keys → disc key
|
||||
//! 3. Read title key (REPORT KEY format 0x04) → XOR with bus key → decrypt with disc key → title key
|
||||
//!
|
||||
//! Based on Stevenson 1999 analysis and libdvdcss CSSAuth()/CSSCryptKey().
|
||||
//! Based on libdvdcss (VideoLAN) and Stevenson 1999 analysis.
|
||||
|
||||
use crate::drive::Drive;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
// ── Tables from csstables.h (libdvdcss) ────────────────────────────────────
|
||||
// ── Player keys (from libdvdcss, Stevenson's PlayerKey cracker) ───────────
|
||||
|
||||
const PLAYER_KEYS: [[u8; 5]; 31] = [
|
||||
[0x01, 0xaf, 0xe3, 0x12, 0x80],
|
||||
[0x12, 0x11, 0xca, 0x04, 0x3b],
|
||||
[0x14, 0x0c, 0x9e, 0xd0, 0x09],
|
||||
[0x14, 0x71, 0x35, 0xba, 0xe2],
|
||||
[0x1a, 0xa4, 0x33, 0x21, 0xa6],
|
||||
[0x26, 0xec, 0xc4, 0xa7, 0x4e],
|
||||
[0x2c, 0xb2, 0xc1, 0x09, 0xee],
|
||||
[0x2f, 0x25, 0x9e, 0x96, 0xdd],
|
||||
[0x33, 0x2f, 0x49, 0x6c, 0xe0],
|
||||
[0x35, 0x5b, 0xc1, 0x31, 0x0f],
|
||||
[0x36, 0x67, 0xb2, 0xe3, 0x85],
|
||||
[0x39, 0x3d, 0xf1, 0xf1, 0xbd],
|
||||
[0x3b, 0x31, 0x34, 0x0d, 0x91],
|
||||
[0x45, 0xed, 0x28, 0xeb, 0xd3],
|
||||
[0x48, 0xb7, 0x6c, 0xce, 0x69],
|
||||
[0x4b, 0x65, 0x0d, 0xc1, 0xee],
|
||||
[0x4c, 0xbb, 0xf5, 0x5b, 0x23],
|
||||
[0x51, 0x67, 0x67, 0xc5, 0xe0],
|
||||
[0x53, 0x94, 0xe1, 0x75, 0xbf],
|
||||
[0x57, 0x2c, 0x8b, 0x31, 0xae],
|
||||
[0x63, 0xdb, 0x4c, 0x5b, 0x4a],
|
||||
[0x7b, 0x1e, 0x5e, 0x2b, 0x57],
|
||||
[0x85, 0xf3, 0x85, 0xa0, 0xe0],
|
||||
[0xab, 0x1e, 0xe7, 0x7b, 0x72],
|
||||
[0xab, 0x36, 0xe3, 0xeb, 0x76],
|
||||
[0xb1, 0xb8, 0xf9, 0x38, 0x03],
|
||||
[0xb8, 0x5d, 0xd8, 0x53, 0xbd],
|
||||
[0xbf, 0x92, 0xc3, 0xb0, 0xe2],
|
||||
[0xcf, 0x1a, 0xb2, 0xf8, 0x0a],
|
||||
[0xec, 0xa0, 0xcf, 0xb3, 0xff],
|
||||
[0xfc, 0x95, 0xa9, 0x87, 0x35],
|
||||
];
|
||||
|
||||
// ── CryptKey tables ───────────────────────────────────────────────────────
|
||||
|
||||
const CRYPT_TAB0: [u8; 256] = [
|
||||
0xB7, 0xF4, 0x82, 0x57, 0xDA, 0x4D, 0xDB, 0xE2, 0x2F, 0x52, 0x1A, 0xA8, 0x68, 0x5A, 0x8A, 0xFF,
|
||||
|
|
@ -123,17 +152,56 @@ const PERM_VARIANT: [[u8; 32]; 2] = [
|
|||
],
|
||||
];
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
// ── SCSI constants ────────────────────────────────────────────────────────
|
||||
|
||||
/// Perform CSS authentication with the drive.
|
||||
const SCSI_READ_DVD_STRUCTURE: u8 = 0xAD;
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Perform CSS bus authentication only.
|
||||
pub fn authenticate(drive: &mut Drive) -> Result<()> {
|
||||
let (_, _) = bus_auth(drive)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full CSS key extraction: bus auth → disc key → title key.
|
||||
pub fn authenticate_and_read_title_key(drive: &mut Drive, lba: u32) -> Result<[u8; 5]> {
|
||||
// Session 1: bus auth → disc key (AGID consumed by READ_DVD_STRUCTURE)
|
||||
let (agid, bus_key) = bus_auth(drive)?;
|
||||
let disc_key = read_disc_key(drive, agid, &bus_key)?;
|
||||
|
||||
// Session 2: fresh bus auth → title key (needs separate AGID)
|
||||
let (agid2, bus_key2) = bus_auth(drive)?;
|
||||
let encrypted_title = read_raw_title_key(drive, agid2, lba)?;
|
||||
|
||||
// Decrypt title key: XOR with bus key, then decrypt with disc key
|
||||
let mut title_key = [0u8; 5];
|
||||
for i in 0..5 {
|
||||
title_key[i] = encrypted_title[i] ^ bus_key2[i];
|
||||
}
|
||||
|
||||
if title_key == [0u8; 5] {
|
||||
return Ok(title_key);
|
||||
}
|
||||
|
||||
let title_key = super::lfsr::decrypt_key(0xFF, &disc_key, &title_key);
|
||||
Ok(title_key)
|
||||
}
|
||||
|
||||
// ── Step 1: Bus Authentication ────────────────────────────────────────────
|
||||
|
||||
fn bus_auth(drive: &mut Drive) -> Result<(u8, [u8; 5])> {
|
||||
let scsi = drive.scsi_mut();
|
||||
|
||||
// Invalidate all AGIDs
|
||||
// Invalidate all AGIDs via REPORT KEY format 0x3F
|
||||
for agid in 0..4u8 {
|
||||
let mut buf = [0u8; 4];
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
|
||||
// alloc_len = 0 (no data transfer)
|
||||
cdb[10] = (agid << 6) | 0x3F;
|
||||
let mut buf = [0u8; 8];
|
||||
let _ = scsi.execute(
|
||||
&report_key_cdb(agid, 0x3F, 0),
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
|
|
@ -151,12 +219,11 @@ pub fn authenticate(drive: &mut Drive) -> Result<()> {
|
|||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
let agid = (buf[7] >> 6) & 0x03;
|
||||
|
||||
// Step 1: Send host challenge
|
||||
// Host sends challenge
|
||||
let host_challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let mut hc_buf = [0u8; 16];
|
||||
hc_buf[0] = 0x00;
|
||||
hc_buf[1] = 0x0E;
|
||||
// Byte-reverse on wire
|
||||
for i in 0..10 {
|
||||
hc_buf[4 + i] = host_challenge[9 - i];
|
||||
}
|
||||
|
|
@ -168,7 +235,7 @@ pub fn authenticate(drive: &mut Drive) -> Result<()> {
|
|||
)
|
||||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
|
||||
// Step 2: Get Key1 from drive
|
||||
// Get Key1 from drive
|
||||
let mut dk_buf = [0u8; 12];
|
||||
scsi.execute(
|
||||
&report_key_cdb(agid, 0x02, 12),
|
||||
|
|
@ -179,21 +246,20 @@ pub fn authenticate(drive: &mut Drive) -> Result<()> {
|
|||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
let mut key1 = [0u8; 5];
|
||||
for i in 0..5 {
|
||||
key1[i] = dk_buf[4 + (4 - i)]; // byte-reverse
|
||||
key1[i] = dk_buf[4 + (4 - i)];
|
||||
}
|
||||
|
||||
// Step 3: Brute-force variant (0-31)
|
||||
// Brute-force variant (0-31)
|
||||
let mut variant: Option<u8> = None;
|
||||
for v in 0..32u8 {
|
||||
let check = crypt_key(0, v, &host_challenge);
|
||||
if check == key1 {
|
||||
if crypt_key(0, v, &host_challenge) == key1 {
|
||||
variant = Some(v);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let variant = variant.ok_or(Error::CssAuthFailed)?;
|
||||
|
||||
// Step 4: Get drive challenge
|
||||
// Get drive challenge
|
||||
let mut dc_buf = [0u8; 16];
|
||||
scsi.execute(
|
||||
&report_key_cdb(agid, 0x01, 16),
|
||||
|
|
@ -204,41 +270,192 @@ pub fn authenticate(drive: &mut Drive) -> Result<()> {
|
|||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
let mut drive_challenge = [0u8; 10];
|
||||
for i in 0..10 {
|
||||
drive_challenge[i] = dc_buf[4 + (9 - i)]; // byte-reverse
|
||||
drive_challenge[i] = dc_buf[4 + (9 - i)];
|
||||
}
|
||||
|
||||
// Step 5: Compute Key2 and send it
|
||||
// Compute Key2 and send it
|
||||
let key2 = crypt_key(1, variant, &drive_challenge);
|
||||
let mut hk_buf = [0u8; 12];
|
||||
hk_buf[0] = 0x00;
|
||||
hk_buf[1] = 0x0A;
|
||||
for i in 0..5 {
|
||||
hk_buf[4 + i] = key2[4 - i]; // byte-reverse
|
||||
hk_buf[4 + i] = key2[4 - i];
|
||||
}
|
||||
scsi.execute(
|
||||
&send_key_cdb(agid, 0x03, 12), // format 0x03 for Key2
|
||||
&send_key_cdb(agid, 0x03, 12),
|
||||
crate::scsi::DataDirection::ToDevice,
|
||||
&mut hk_buf,
|
||||
5_000,
|
||||
)
|
||||
.map_err(|_| Error::CssAuthFailed)?;
|
||||
|
||||
Ok(())
|
||||
// Bus key = CryptKey(2, variant, key1 || key2)
|
||||
let mut combined = [0u8; 10];
|
||||
combined[..5].copy_from_slice(&key1);
|
||||
combined[5..].copy_from_slice(&key2);
|
||||
let bus_key = crypt_key(2, variant, &combined);
|
||||
|
||||
Ok((agid, bus_key))
|
||||
}
|
||||
|
||||
// ── CSSCryptKey — the bus key challenge-response cipher ─────────────────────
|
||||
// ── Step 2: Disc Key ──────────────────────────────────────────────────────
|
||||
|
||||
fn read_disc_key(drive: &mut Drive, agid: u8, bus_key: &[u8; 5]) -> Result<[u8; 5]> {
|
||||
let scsi = drive.scsi_mut();
|
||||
|
||||
// READ DVD STRUCTURE, format 0x02 (disc key), 2048+4 bytes
|
||||
let alloc_len: u16 = 2048 + 4;
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = SCSI_READ_DVD_STRUCTURE;
|
||||
// bytes 2-5: address = 0
|
||||
cdb[6] = 0; // layer
|
||||
cdb[7] = 0x02; // format = disc key
|
||||
cdb[8] = (alloc_len >> 8) as u8;
|
||||
cdb[9] = alloc_len as u8;
|
||||
cdb[10] = agid << 6;
|
||||
|
||||
let mut buf = vec![0u8; alloc_len as usize];
|
||||
let dvd_result = scsi.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
);
|
||||
dvd_result.map_err(|_| Error::CssAuthFailed)?;
|
||||
|
||||
// Disc key block starts at offset 4 (skip 4-byte header)
|
||||
let disc_key_block = &mut buf[4..4 + 2048];
|
||||
|
||||
// XOR with reversed bus key (per libdvdcss)
|
||||
for (i, byte) in disc_key_block.iter_mut().enumerate() {
|
||||
*byte ^= bus_key[4 - (i % 5)];
|
||||
}
|
||||
|
||||
// Try each player key against each of 408 disc key entries.
|
||||
// Each entry in the block is the disc key encrypted with a specific player key.
|
||||
// We try all known player keys and verify by checking that two different
|
||||
// entries produce the same disc key.
|
||||
let mut candidates: Vec<([u8; 5], usize, usize)> = Vec::new(); // (disc_key, pk_idx, pos)
|
||||
|
||||
for (pk_idx, player_key) in PLAYER_KEYS.iter().enumerate() {
|
||||
for pos in 0..408 {
|
||||
let offset = pos * 5;
|
||||
if offset + 5 > disc_key_block.len() {
|
||||
break;
|
||||
}
|
||||
let mut enc = [0u8; 5];
|
||||
enc.copy_from_slice(&disc_key_block[offset..offset + 5]);
|
||||
let candidate = super::lfsr::decrypt_key(0x00, player_key, &enc);
|
||||
|
||||
// Check if any previous candidate matches (same disc key from different entry/pk)
|
||||
for (prev, _, _) in &candidates {
|
||||
if *prev == candidate {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
candidates.push((candidate, pk_idx, pos));
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::CssAuthFailed)
|
||||
}
|
||||
|
||||
// ── Step 3: Title Key ─────────────────────────────────────────────────────
|
||||
|
||||
/// Read the raw (bus-encrypted) title key bytes from the drive.
|
||||
fn read_raw_title_key(drive: &mut Drive, agid: u8, lba: u32) -> Result<[u8; 5]> {
|
||||
let scsi = drive.scsi_mut();
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
|
||||
cdb[2] = (lba >> 24) as u8;
|
||||
cdb[3] = (lba >> 16) as u8;
|
||||
cdb[4] = (lba >> 8) as u8;
|
||||
cdb[5] = lba as u8;
|
||||
cdb[8] = 0x00;
|
||||
cdb[9] = 0x0C;
|
||||
cdb[10] = (agid << 6) | 0x04;
|
||||
|
||||
let mut buf = [0u8; 12];
|
||||
let result = scsi.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
);
|
||||
result.map_err(|_| Error::CssAuthFailed)?;
|
||||
|
||||
let mut key = [0u8; 5];
|
||||
for i in 0..5 {
|
||||
key[i] = buf[5 + (4 - i)];
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn read_title_key(
|
||||
drive: &mut Drive,
|
||||
agid: u8,
|
||||
lba: u32,
|
||||
bus_key: &[u8; 5],
|
||||
disc_key: &[u8; 5],
|
||||
) -> Result<[u8; 5]> {
|
||||
let scsi = drive.scsi_mut();
|
||||
|
||||
let mut cdb = [0u8; 12];
|
||||
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
|
||||
cdb[2] = (lba >> 24) as u8;
|
||||
cdb[3] = (lba >> 16) as u8;
|
||||
cdb[4] = (lba >> 8) as u8;
|
||||
cdb[5] = lba as u8;
|
||||
cdb[8] = 0x00;
|
||||
cdb[9] = 0x0C;
|
||||
cdb[10] = (agid << 6) | 0x04;
|
||||
|
||||
let mut buf = [0u8; 12];
|
||||
let tk_result = scsi.execute(
|
||||
&cdb,
|
||||
crate::scsi::DataDirection::FromDevice,
|
||||
&mut buf,
|
||||
5_000,
|
||||
);
|
||||
tk_result.map_err(|_| Error::CssAuthFailed)?;
|
||||
|
||||
// Title key at bytes 5..10, byte-reversed
|
||||
let mut title_key = [0u8; 5];
|
||||
for i in 0..5 {
|
||||
title_key[i] = buf[5 + (4 - i)];
|
||||
}
|
||||
|
||||
// XOR with reversed bus key (same pattern as disc key block)
|
||||
for i in 0..5 {
|
||||
title_key[i] ^= bus_key[4 - i];
|
||||
}
|
||||
|
||||
// Check for null key (title not encrypted)
|
||||
if title_key == [0u8; 5] {
|
||||
return Ok(title_key);
|
||||
}
|
||||
|
||||
// Decrypt with disc key (invert=0xFF for title keys)
|
||||
let title_key = super::lfsr::decrypt_key(0xFF, disc_key, &title_key);
|
||||
|
||||
Ok(title_key)
|
||||
}
|
||||
|
||||
// ── CSSCryptKey ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Exposed for testing only.
|
||||
pub fn test_crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
|
||||
crypt_key(key_type, variant, challenge)
|
||||
}
|
||||
|
||||
/// Compute CSS bus key challenge response.
|
||||
/// key_type: 0=Key1, 1=Key2, 2=bus_key
|
||||
fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
|
||||
// Permute challenge
|
||||
let perm = &PERM_CHALLENGE[key_type];
|
||||
let mut scratch = [0u8; 10];
|
||||
for i in 0..10 {
|
||||
scratch[i] = challenge[perm[i]];
|
||||
}
|
||||
|
||||
// Resolve CSS variant
|
||||
let css_variant = match key_type {
|
||||
0 => variant as usize,
|
||||
1 => PERM_VARIANT[0][variant as usize] as usize,
|
||||
|
|
@ -247,7 +464,6 @@ fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
|
|||
|
||||
let cse = VARIANTS[css_variant] ^ CRYPT_TAB2[css_variant];
|
||||
|
||||
// LFSR init from upper 5 challenge bytes + secret
|
||||
let mut tmp1 = [0u8; 5];
|
||||
for i in 0..5 {
|
||||
tmp1[i] = scratch[5 + i] ^ SECRET[i] ^ CRYPT_TAB2[i];
|
||||
|
|
@ -261,7 +477,6 @@ fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
|
|||
|
||||
let mut lfsr1: u32 = ((tmp1[3] as u32) << 9) | 0x100 | (tmp1[4] as u32);
|
||||
|
||||
// Generate 30 pseudo-random bytes
|
||||
let mut bits = [0u8; 30];
|
||||
let mut carry: u32 = 0;
|
||||
for idx in (0..30).rev() {
|
||||
|
|
@ -280,24 +495,22 @@ fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
|
|||
bits[idx] = val;
|
||||
}
|
||||
|
||||
// Six substitution-permutation rounds (NOT all identical — rounds 3,4 use CRYPT_TAB0)
|
||||
// Matches libdvdcss CryptKey() exactly.
|
||||
let mut tmp1 = [scratch[0], scratch[1], scratch[2], scratch[3], scratch[4]];
|
||||
let mut tmp2 = [0u8; 5];
|
||||
|
||||
// Round 1: bits[25..29] ^ scratch -> tmp1
|
||||
// Round 1: bits[25..29] ^ scratch -> tmp1 (term from original scratch)
|
||||
{
|
||||
let mut term: u8 = 0;
|
||||
for i in (0..5usize).rev() {
|
||||
let idx = (bits[25 + i] ^ tmp1[i]) as usize;
|
||||
let idx2 = (CRYPT_TAB1[idx] ^ (!CRYPT_TAB2[idx]) ^ cse) as usize;
|
||||
tmp1[i] = CRYPT_TAB2[idx2] ^ CRYPT_TAB3[idx2] ^ term;
|
||||
term = scratch[i];
|
||||
term = scratch[i]; // original challenge, NOT modified tmp1
|
||||
}
|
||||
tmp1[4] ^= tmp1[0];
|
||||
}
|
||||
|
||||
// Round 2: bits[20..24] ^ tmp1 -> tmp2
|
||||
// Round 2
|
||||
{
|
||||
let mut term: u8 = 0;
|
||||
for i in (0..5usize).rev() {
|
||||
|
|
@ -309,7 +522,7 @@ fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
|
|||
tmp2[4] ^= tmp2[0];
|
||||
}
|
||||
|
||||
// Round 3: bits[15..19] ^ tmp2 -> tmp1 (uses CRYPT_TAB0!)
|
||||
// Round 3 (uses CRYPT_TAB0)
|
||||
{
|
||||
let mut term: u8 = 0;
|
||||
for i in (0..5usize).rev() {
|
||||
|
|
@ -322,7 +535,7 @@ fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
|
|||
tmp1[4] ^= tmp1[0];
|
||||
}
|
||||
|
||||
// Round 4: bits[10..14] ^ tmp1 -> tmp2 (uses CRYPT_TAB0!)
|
||||
// Round 4 (uses CRYPT_TAB0)
|
||||
{
|
||||
let mut term: u8 = 0;
|
||||
for i in (0..5usize).rev() {
|
||||
|
|
@ -335,7 +548,7 @@ fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
|
|||
tmp2[4] ^= tmp2[0];
|
||||
}
|
||||
|
||||
// Round 5: bits[5..9] ^ tmp2 -> tmp1
|
||||
// Round 5
|
||||
{
|
||||
let mut term: u8 = 0;
|
||||
for i in (0..5usize).rev() {
|
||||
|
|
@ -347,7 +560,7 @@ fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
|
|||
tmp1[4] ^= tmp1[0];
|
||||
}
|
||||
|
||||
// Round 6: bits[0..4] ^ tmp1 -> key (output)
|
||||
// Round 6
|
||||
let mut key = [0u8; 5];
|
||||
{
|
||||
let mut term: u8 = 0;
|
||||
|
|
@ -362,7 +575,7 @@ fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
|
|||
key
|
||||
}
|
||||
|
||||
// ── SCSI CDB builders ──────────────────────────────────────────────────────
|
||||
// ── SCSI CDB builders ────────────────────────────────────────────────────
|
||||
|
||||
fn report_key_cdb(agid: u8, format: u8, alloc_len: u16) -> [u8; 12] {
|
||||
let mut cdb = [0u8; 12];
|
||||
|
|
@ -382,7 +595,7 @@ fn send_key_cdb(agid: u8, format: u8, param_len: u16) -> [u8; 12] {
|
|||
cdb
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
@ -401,25 +614,25 @@ mod tests {
|
|||
#[test]
|
||||
fn crypt_key_varies_by_variant() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let r0 = crypt_key(0, 0, &challenge);
|
||||
let r1 = crypt_key(0, 1, &challenge);
|
||||
assert_ne!(r0, r1);
|
||||
assert_ne!(crypt_key(0, 0, &challenge), crypt_key(0, 1, &challenge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_varies_by_type() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
let r0 = crypt_key(0, 5, &challenge);
|
||||
let r1 = crypt_key(1, 5, &challenge);
|
||||
assert_ne!(r0, r1);
|
||||
assert_ne!(crypt_key(0, 5, &challenge), crypt_key(1, 5, &challenge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_key_nonzero() {
|
||||
let challenge: [u8; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
for v in 0..32u8 {
|
||||
let r = crypt_key(0, v, &challenge);
|
||||
assert_ne!(r, [0u8; 5], "zero result for variant {}", v);
|
||||
assert_ne!(crypt_key(0, v, &challenge), [0u8; 5]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_keys_count() {
|
||||
assert_eq!(PLAYER_KEYS.len(), 31);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,37 +197,48 @@ pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
|
|||
//
|
||||
// We try multiple stream IDs and use zeros for unknown bytes (most common).
|
||||
|
||||
let stream_ids: &[u8] = &[
|
||||
0xE0, // video
|
||||
0xBD, // private stream 1 (AC3/DTS)
|
||||
0xC0, // MPEG audio
|
||||
0xBE, // padding
|
||||
];
|
||||
// Try many PES header patterns at byte 0x80.
|
||||
// Structure: 00 00 01 [stream_id] [len_hi] [len_lo] [flags1] [flags2] [hdr_len] [data]
|
||||
let mut patterns: Vec<[u8; 10]> = Vec::with_capacity(128);
|
||||
|
||||
for &sid in stream_ids {
|
||||
// Build candidate plaintext (10 bytes)
|
||||
// Bytes 0-2: PES start code 00 00 01
|
||||
// Byte 3: stream ID
|
||||
// Bytes 4-9: we try with zeros first (common for padding streams)
|
||||
// and with typical PES header bytes
|
||||
let patterns: &[[u8; 10]] = &[
|
||||
[0x00, 0x00, 0x01, sid, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21],
|
||||
[0x00, 0x00, 0x01, sid, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00],
|
||||
[0x00, 0x00, 0x01, sid, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||
];
|
||||
// Padding stream (0xBE): payload is 0xFF bytes, various lengths
|
||||
for len_hi in 0u8..8 {
|
||||
for len_lo_top in [0x00u8, 0x80, 0xFF] {
|
||||
patterns.push([
|
||||
0x00, 0x00, 0x01, 0xBE, len_hi, len_lo_top, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
for pattern in patterns {
|
||||
if let Some(key) = recover_title_key(sector, pattern) {
|
||||
// Verify: the key should produce valid MPEG-2 when used to descramble
|
||||
let mut test = sector.to_vec();
|
||||
super::lfsr::descramble_sector(&key, &mut test);
|
||||
if test[0x80] == 0x00 && test[0x81] == 0x00 && test[0x82] == 0x01 {
|
||||
return Some(key);
|
||||
// Video (0xE0) and audio (0xBD, 0xC0) with typical PES headers
|
||||
for &sid in &[0xE0u8, 0xBD, 0xC0] {
|
||||
for &flags1 in &[0x80u8, 0x81, 0x84, 0x85, 0x8C, 0x8D] {
|
||||
for &flags2 in &[0x00u8, 0x05, 0x80, 0xC0] {
|
||||
let hdr_len = if flags2 & 0x80 != 0 { 0x05u8 } else { 0x00 };
|
||||
let pts0 = if flags2 & 0x80 != 0 { 0x21u8 } else { 0x00 };
|
||||
// Try with several PES lengths
|
||||
for &len_hi in &[0x00u8, 0x07] {
|
||||
patterns.push([
|
||||
0x00, 0x00, 0x01, sid, len_hi, 0x00, flags1, flags2, hdr_len, pts0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation pack system header (0xBB)
|
||||
patterns.push([0x00, 0x00, 0x01, 0xBB, 0x00, 0x12, 0x80, 0xC4, 0xE1, 0x04]);
|
||||
|
||||
for pattern in &patterns {
|
||||
if let Some(key) = recover_title_key(sector, pattern) {
|
||||
let mut test = sector.to_vec();
|
||||
super::lfsr::descramble_sector(&key, &mut test);
|
||||
if test[0x80] == 0x00 && test[0x81] == 0x00 && test[0x82] == 0x01 {
|
||||
return Some(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue