19 Commits
Author SHA1 Message Date
Matthew Jackson 767d205fd4 mux: document that the Url arm selects via InputOptions.selection
CI / lint (push) Failing after 1m19s
CI / test (push) Failing after 1m20s
leak-guard / leak-guard (push) Successful in 10s
CI / check-macos (push) Has been cancelled
CI / check-windows (push) Has been cancelled
The Url mux path prunes streams inside input() via InputOptions.selection;
MuxOptions.selection only applies on the File/Session arms. A Url-source caller
must set InputOptions.selection — noting it so a future caller does not put the
selection on MuxOptions and silently keep every track (the bug the GUI hit).
2026-07-28 18:40:47 -07:00
Matthew Jackson bbb5a953f1 1.6.0: remove recovery strategy (moved to freemkv-engine) + trim dead surface
The sweep/patch recovery strategy, mapfile, retry-decision state machine,
section-recover, and damage classification move out of libfreemkv into the
new freemkv-engine crate. libfreemkv keeps the raw single-shot read and
SCSI-fact translation (SenseFamily stays in scsi).

- Delete disc/{sweep,patch,mapfile,read_error,section_recover}.rs, the
  Disc::copy/sweep/patch methods, the Copy/Sweep/Patch option+result types,
  classify_damage/DamageSeverity, progress_snapshot_from_mapfile, and the
  three recovery integration tests.
- Trim public surface the recovery deletion orphaned: delete the dead
  READ_PIPELINE_DEPTH const, the write-side SectorSink/FileSectorSink (no
  consumer), and the DriveSpeed enum (its one live use — set max drive
  speed — becomes Drive::SPEED_MAX_KBPS). Make mapfile_path_for,
  decrypt_sectors_mapped pub(crate); gate NoopEvents to test.
- Version 1.6.0.
2026-07-28 15:35:19 -07:00
MattJackson 3f238ab3dc changelog: 1.6.0 section (layering API, mux fixes, engine relocation, stream selection); date 1.5.2 2026-07-28 13:54:42 -07:00
MattJackson ec62618bde mux: end-to-end test that stream selection drops excluded-PID frames
A title declaring two audio PIDs, pruned to one via StreamSelection::apply
before build_iso_pipeline, must never surface a frame from the excluded PID.
Proves the declaration-driven seam end-to-end through the full highway (read ->
demux -> codec parse): the demuxer is built from the pruned title.streams, so
the excluded PID is untracked and skipped, and both track headers and frames
follow the pruned list. Builds on the existing synthetic-TS harness.
2026-07-28 13:36:36 -07:00
MattJackson 15111d544b disc: add DiscTitle audio_streams/subtitle_streams/video_streams accessors
Typed iterators over the audio/subtitle/video streams, cleaner than matching on
the Stream enum for the common iterate-the-tracks case (stream selection, the
desktop UI info panel, disc-info). Additive; all lib tests pass on 1.86.
2026-07-28 13:35:12 -07:00
MattJackson 19b646e012 mux: StreamSelection primitive + apply sites for per-title stream selection
The demux pipeline is declaration-driven off DiscTitle.streams (build_demux_state,
DiscStream::new, and the MKV writer all key off that list), so 'which streams to
keep' is already a pipeline capability with no public knob. This adds the knob:

- mux/select.rs: StreamSelection { audio, subtitle: PidFilter::All | Only(Vec<u16>) }
  + apply(&mut DiscTitle): keep Video always, keep Audio/Subtitle whose PID the
  filter lists, prune the rest (and the parallel codec_privates in lockstep);
  error SelectionPidUnknown on a listed PID absent from the title (fail loud, not
  a silently-missing track). Pure; 6 unit tests. Re-exported at crate root.
- Error::SelectionPidUnknown (E6014).
- MuxOptions gains  (+ derives Default now) applied in mux_stream's
  Iso/Session arms before the highway/DiscStream builds demux state (and before
  probe_and_remap's DVD AC-3 PID rewrite). InputOptions gains  applied
  in input()'s iso arm right after the title-index bounds check.

PIDs not languages -- language->PID is caller/engine policy. Default All/All is a
no-op (apply gated on !is_all()), so the no-selection path is byte-identical:
nothing below the title-finalization line changes (ts/ps/demux_thread/
pipelined_stream/mkv/disc untouched). All 2488 lib tests pass on 1.86.

Design: freemkv-private/audit/engine-split/STREAM-SELECTION-DESIGN.md (Fable).
2026-07-28 13:14:40 -07:00
MattJackson 17694af625 error: add is_disc_level_no_key classifier (re-exported)
Distinguishes a WHOLE-DISC key failure (E_NO_DISC_KEY / E_KEYDB_LOAD /
E_AACS_NO_KEYS -- every title fails identically) from a per-title skippable
stub. The engine's multi-title loop uses it to fail-fast on the first no-key
title instead of iterating all N. Additive.
2026-07-28 12:45:58 -07:00
MattJackson 828f5c0192 error: re-export is_halt + is_skippable_title_stub at crate root
The engine's multi-title rip loop classifies per-title mux failures (halt vs
skippable stub vs hard) using these typed classifiers instead of E-code string
matching. Additive; no behavior change.
2026-07-28 12:31:40 -07:00
MattJackson 075223a4bd disc: promote locate_ranges to pub (engine multipass reads it)
Small pub promotion + fmt one-lining. The relocated multipass progress
reporting in freemkv-engine needs locate_ranges externally. No behavior change.
2026-07-28 12:09:38 -07:00
MattJackson 94ac4a442a drive: promote extract_scsi_context to pub
Small pure error->(status,sense) introspection helper the relocated
sweep/patch will need externally. Same category as the prior WritebackFile/
resolve_content_key_map promotions -- infra, not policy. No behavior change.
2026-07-28 11:41:49 -07:00
MattJackson f468237279 io: promote WritebackFile to pub
Bounded-cache buffered File replacement used across mux/extract/sweep/patch
-- general I/O infrastructure, not recovery policy. freemkv-engine's
relocated sweep/patch need to construct it directly once those methods
leave this crate. No behavior change.
2026-07-28 11:36:43 -07:00
MattJackson 227545fabc scsi: promote SenseFamily to a lib-level SCSI-fact primitive
Moved SenseFamily::from_sense_key + is_wedge_family from disc/read_error.rs
into scsi/mod.rs (with its own tests) and re-exported at the crate root.
This is pure SCSI sense-code classification -- objective hardware fact, zero
recovery-policy opinion -- so it belongs in the library primitives, unlike
the retry-DECISION state machine (ReadCtx/PassSummary/ReadAction/
handle_read_error) built on top of it, which is freemkv's specific recovery
strategy and is moving to freemkv-engine next.

disc/read_error.rs and disc/section_recover.rs now import SenseFamily from
crate::scsi instead of defining/re-exporting their own copy. No behavior
change. Precommit green on Rust 1.86 (fmt+clippy+test).
2026-07-28 11:32:13 -07:00
MattJackson 05fd7fcbfa disc: promote resolve_content_key_map + encrypted_content_ranges to pub
The upcoming freemkv-engine crate needs both to build the multipass
sweep/patch recovery strategy externally over Disc's public API. Everything
else sweep/patch touch on Disc was already pub; these were the only two
gaps. No behavior change -- visibility only.
2026-07-28 11:26:37 -07:00
Matthew Jackson 967d0ac77e mux: fix DTS core-header false-drops + close TrueHD/mux gate coverage
DTS core decodability gate (core_header_drop_reason) — full ETSI TS 102 114
spec-conformance sweep against ffmpeg ff_dca_parse_core_frame_header and
dcadec parse_frame_header:

- deficit_samples: only require ==32 for NORMAL frames (FTYPE==1). A
  TERMINATION frame (FTYPE==0, the last frame of a stream) legitimately
  carries fewer and is fully decodable; the old unconditional check dropped
  it on every stream that ends on one — a guaranteed per-track silence gap.
  Matches ffmpeg (normal_frame && deficit != DCA_PCMBLOCK_SAMPLES) and
  dcadec (branches on normal_frame).
- reserved bit (after RATE): both reference decoders SKIP it (ffmpeg
  skip_bits1, dcadec bits_skip1 "Reserved field") and never reject on it.
  Rejecting was a false-drop that silenced any real stream whose encoder
  set the bit. Relaxed to read-and-discard; DropReason::ReservedBit removed.

Swept and confirmed spec-correct as-is (no change): npcmblocks multiple-of-8,
frame_size>=96, audio_mode>=16 (ffmpeg-permissive), sample-rate validity
table (matches avpriv_dca_sample_rates incl 96k/192k at 14/15), LFE flag==3
invalid, PCMR bits table (matches dcadec sample_res {16,16,20,20,0,24,24,0}).
Bit-read order verified field-by-field against dcadec. bit_rate is left
unvalidated (lenient, never-false-drop direction) as before.

Tests: termination frame with small deficit is kept; normal frame with bad
deficit is dropped; reserved-bit-set frame is kept. make_bad_dts_core now
uses an invalid LFE flag (duration-neutral) instead of the relaxed reserved
bit.

TrueHD: add coverage for the EXTENDED major-sync header CRC path (ms[25]&1,
mshdr=28+2+2n) — previously zero-tested, the exact path a shipped endianness
bug once used to silently drop whole 7.1/Atmos tracks. Trailer is an
independently-computed oracle (separate CRC-16/0x2D, anchored to the 0x4FF7
catalogue value, NOT crc16_mlp), stored little-endian; test asserts accept,
body-corruption reject, and big-endian-trailer reject.

mux driver: extract the finish completion mapping into pure mux_run_completed
so the finalize_failed -> completed=false branch (reachable only via real
write-thread wedge timing) is unit-tested; add an out-of-range
MuxInput::Session title_index test asserting a clean Error::MuxTrackRange
(E9011) instead of a panic.
2026-07-24 10:28:21 -07:00
Matthew Jackson eb0aa3556e Fix read-fault misclassification, DTS AMODE channel table, and untestable guards
- resolve_fmts_key_map: distinguish a genuinely-not-FMTS disc from a
  transient live-drive read fault. read_filesystem now returns the new
  Error::UdfNotFilesystem for a deterministic tag/format mismatch (no AVDP,
  no partition descriptor, no FSD); resolve maps only UdfNotFilesystem (fs)
  and UdfNotFound (.tbl absent) to Ok(None), and PROPAGATES DiscRead / other
  I/O faults so a marginal AACS 2.1 disc fails loud instead of silently
  dropping forensic content under a base-Unit-Key-only map.

- DTS_AMODE_CH (mp4/audio.rs): extend 10→16 entries
  {1,2,2,2,2,3,3,4,4,5,6,6,6,7,8,8} (ff_dca_channels / ETSI TS 102 114) so
  the spec-legal high AMODEs that now pass the decodability gate declare
  their true channelcount (AMODE 13→7, 14/15→8) instead of a truncated 6.

- session.rs resolve_keys "called before scan" guard is now testable:
  from_parts_for_test takes Option<Disc>; added a test that a disc-less
  session returns a clean DeviceNotReady Err rather than panicking.

- mp4/read.rs: a track with samples but a missing/malformed stts (mandatory
  per ISO/IEC 14496-12) is dropped rather than emitting all-zero timestamps,
  matching the existing stco/stsc guards; all-tracks-dropped → Mp4Invalid.

- Remove the inert MuxInput::Iso.key_map field (the Iso path re-derives its
  map inside build_iso_pipeline); the live path keeps Live.key_map.

All four fixes are mutation-verified.
2026-07-24 09:59:33 -07:00
Matthew Jackson 7ba43d9c02 mux: distinguish FMTS phase-probe read fault from wrong key; cover multi-CPS
Fix 1 (correctness): resolve_fmts_key_map's per-index phase probe read a
single representative segment via read_unit (whose read_sectors(...).ok()?
swallows read errors into None) with no fault fallback. A transient live-drive
read fault (e.g. NOT READY 2/04/3E on the BU40N) made every probe read return
None, giving even==0 && odd==0 — indistinguishable from a genuine wrong key —
so resolve_tie_phase returned FmtsKeyMissing and aborted the entire multi-title
rip even though the forensic index keys were valid and in hand.

Extract the probe into probe_index_phase, which returns Phase / WrongKey /
ReadFault. It mirrors the anchor loop's tolerance: it tries multiple same-index
segments and only concludes WrongKey once a read actually succeeded and decrypted
to no clean parity. If every read of every same-index segment faults it returns
ReadFault; the caller then leaves the phase unresolved so the range-builder
defaults to Phase::All (decrypt both parities, demux drops the garbled alternate
half) instead of aborting. A wrong key can never be masked as a read fault:
ReadFault requires that not a single read succeeded, so zero decrypt evidence.

Fix 2 (test coverage): resolve_mux_key_map's multi-CPS branch was only exercised
with an all-zero source, so pick(), the KeyFetch cold path, and the fail-loud
DecryptFailed guard never ran. Add tests over real AACS ciphertext (via
aacs_encrypt_unit_for_test) covering pick() selecting the correct pool index,
the DecryptFailed guard firing when a clean sample matches no held/fetched key,
and the fetch cold path recovering a missing unit key. Mutation-verified.

Fix 3 (doc): move the reader_event_fn EventKind->MuxEvents mapping doc off
session_mux_keys onto reader_event_fn.
2026-07-24 09:28:09 -07:00
Matthew Jackson bf9a69ec80 mux: thread halt into live AACS key-map resolution; cover Session arm
Round-2 follow-ups to 6d6e60f (inline base-map resolve on the live
single-pass Session/Live mux arms).

Fix 1 (halt threading) — the inline resolve chain sampled ciphertext off
the LIVE drive with no cancel token, so an operator /api/stop during key
resolution was not honored (the FMTS probe can issue hundreds of reads,
each able to stall to the 60s SCSI recovery timeout — violating the
"don't hammer a struggling live drive" rule). Add an optional
`halt: Option<&Halt>` to `resolve_mux_key_map`, `resolve_fmts_key_map`,
`resolve_inline_base_map`, and `Disc::resolve_content_key_map`, and poll
it at each loop boundary (FMTS anchor + per-index probe loops, multi-CPS
extent loop) — returning Err(Halted) promptly. Live/Session arms pass the
driver's halt; sweep/patch pass their own token (via Halt::from_arc);
file-backed probe/ISO callers pass None. Tested with a pre-cancelled halt
(Err Halted, no extent sampling) and a None-halt no-abort case;
mutation-verified (dropping the extent-loop check → Ok, not Err).

Fix 2 (Session-arm coverage) — the MuxInput::Session arm ran the same
resolve→install→decrypt sequence as Live but had NO end-to-end test
(DiscSession only exposed open(), which needs live hardware). Add a
#[cfg(test)] DiscSession::from_parts_for_test (injected reader + scanned
disc, no Drive), an end-to-end AACS decrypt test through the Session arm
(mutation-verified: dropping with_key_map → mux aborts), and a
missing-reader clean-error (not panic) test.

Fix 3 (cleanups) — io_error_code: remove the unreachable typed-Error
downcast branch (From<Error> for io::Error stringifies; no path builds an
io::Error holding a typed Error), keeping the stringify parse is_halt /
is_skippable_title_stub rely on. Add a resolve_keys_for test covering the
largest-title sampling branch. Document the patch wedge-exit coverage gap
(TODO) in passn_handler_ab.rs.
2026-07-24 09:04:19 -07:00
Matthew Jackson 6d6e60fdf8 mux: resolve+install AACS key map on live single-pass (Session/Live)
Under the map-only decrypt model an AACS DecryptingSectorSource decrypts
nothing until a key map is installed; with no map the AACS arm fails loud
with DecryptFailed on the first content unit. The two inline live-mux arms
in mux_stream did not install one:

  - MuxInput::Session (freemkv `rip disc://…mkv`) installed NO map at all.
  - MuxInput::Live (autorip non-FMTS single-pass) installed only a
    caller-supplied forensic FMTS map, which is None for a plain AACS disc.

So EVERY plain AACS Blu-ray/UHD ripped via the live single-pass path failed
DecryptFailed on the first content read. This predates the mux_stream
refactor: the bug was introduced with the map-only decrypt model, and the
pre-refactor CLI likewise built DiscStream::new without with_key_map.

Fix: add resolve_inline_base_map, the inline counterpart to what
build_iso_pipeline does for the file highway. Both arms now resolve the
AACS map off the reader (borrow to sample, then move into DiscStream) and
install it via with_key_map before any read. DVD/CSS keeps DecryptKeys::None
(DiscStream's per-title CSS crack owns it); clear/raw resolve to no map.
Session passes session.key_fetch() so a multi-CPS/orphan unit can still be
recovered; a caller-supplied FMTS map (autorip) is used verbatim, never
re-resolved.

Tests: an end-to-end MuxInput::Live mux over a genuinely-AACS-encrypted
synthetic unit now decrypts and finalises (mutation-verified: dropping the
resolve/install makes the mux abort). Adds a pub(crate) test-only AACS
encrypt helper so the mux test can build a real encrypted fixture, and a
gating test for resolve_inline_base_map (AACS→map, CSS/clear/raw→none).
2026-07-24 08:34:51 -07:00
Matthew Jackson 661ab138c6 Fix audit findings: DTS AMODE bound, key-fetch negative memoization, PGS probe coverage
- dts: accept all 16 legal AMODE channel-arrangement codes (0-15), not just
  0-9. Per ETSI TS 102 114 the 6-bit AMODE field has 16 defined arrangements;
  only 16-63 are reserved. ffmpeg's ff_dca_channels[16] confirms 10-15 are
  decodable 6/7/8-channel layouts. The old bound of 10 dropped spec-legal
  multichannel core frames as undecodable, silencing recoverable audio. Add a
  regression test (literal 0..16 range) that fails if the bound reverts to 10.

- keysource: only memoize a NEGATIVE (empty) key-fetch result when every source
  genuinely ran and none held the key — never when a source Err'd (network down,
  unreachable). A transient outage was being cached as a permanent "no key" for
  the fingerprint, permanently dropping a unit that could be recovered once the
  source came back. Thread an `errored` flag out of the drivers and gate the
  cache insert on it. Tests cover both the recover-after-outage case and that a
  genuine absence is still memoized.

- pgs_forced_probe: add happy-path coverage feeding real synthetic BD-TS PGS
  display sets through the full demux -> parse -> observe -> apply path, both a
  forced verdict landing and a non-forced verdict clearing a vendor flag.

- mp4: correct fit_report doc (audio carried is AC-3/E-AC-3 AND DTS/DTS-HD).

- scan_iso test: add independent fixture expectations (volume id) so the parity
  test is no longer purely tautological against a re-run of the same composition.
2026-07-24 08:32:37 -07:00
39 changed files with 2775 additions and 12626 deletions
+51 -1
View File
@@ -1,6 +1,56 @@
# Changelog
## [1.5.2] — UNRELEASED
## [1.6.0] — UNRELEASED
### Added
- **High-level orchestration API — a single mux driver and a disc session.**
`mux_stream` drives the whole read → decrypt → demux → write pipeline for any
source (`MuxInput::Url` / `Iso` / `Session` / `Live`), so consumers stop
hand-rolling the frame pump. `DiscSession` hoists drive open + SCSI bring-up +
scan + key resolution behind one type; `scan_iso` does the same for a
file-backed ISO; `resolve_keys` / `resolve_keys_for` resolve base AACS keys.
These let the CLI and autorip shrink to thin front-ends (and back the new
`freemkv-engine` crate).
- **Per-title stream selection (`StreamSelection`).** A pure primitive that
prunes a `DiscTitle`'s audio/subtitle streams to a chosen set of PIDs (video
is always kept) before the mux builds its demux state — so track headers,
`codec_privates`, and frame routing all follow the pruned list, with no
demux-internal filter. Carried on `MuxOptions.selection` /
`InputOptions.selection` (both default to keep-everything, a no-op).
Languages are the caller's concern; the library speaks PIDs.
- `DiscTitle::audio_streams()` / `subtitle_streams()` / `video_streams()`
typed iterators over each stream class.
- `MuxOptions` gains a per-call write-pipeline `send_deadline` and derives
`Default`.
### Changed
- **The recovery strategy moved to the new `freemkv-engine` crate.** Sweep,
patch, the retry-decision state machine, mapfile bookkeeping, and damage
classification are freemkv's specific recovery *philosophy*, not disc-access
primitives — they now live in `freemkv-engine`, which composes libfreemkv's
public API. The library keeps the raw single-shot read, SCSI-sense-fact
translation (`SenseFamily`, now in `scsi`), decrypt, and the mux highway.
- Small deliberate `pub` promotions to support the engine as an external
consumer: `Disc::resolve_content_key_map` / `encrypted_content_ranges`,
`io::WritebackFile`, `drive::extract_scsi_context`, `disc::locate_ranges`.
- New typed error classifiers re-exported at the crate root — `is_halt`,
`is_skippable_title_stub`, `is_disc_level_no_key` — and a new error variant
`SelectionPidUnknown` (E6014).
### Fixed
- **Mux correctness pass** (the `v1.4.0..HEAD` 10-phase audit): DTS core-header
false-drops that dropped good DTS frames; the TrueHD channel-correction probe
now runs correctly on AACS discs (7.1/Atmos no longer understated as 5.1);
an FMTS phase-probe read fault is distinguished from a wrong key; multi-CPS
and orphan-clip keying; the AACS key map is now a *positive* map (a sector
with no key passes through rather than failing), with fail-loud on genuinely
unresolvable keys; a user Stop mid-read is reported as `completed = false`
(a stop is not a failure), not a spurious error.
## [1.5.2] — 2026-07-22
### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "1.5.2"
version = "1.6.0"
edition = "2024"
rust-version = "1.86"
license = "MIT"
+5 -4
View File
@@ -19,10 +19,11 @@ reach into the others.
The library exposes flat verbs; the caller drives the multipass loop. Autorip
runs `Disc::sweep` once, then loops `Disc::patch` until either the mapfile is
clean or the configured retry budget is exhausted, then hands the ISO off to
the mux pipeline. The `freemkv` CLI does the same shape with a
terminal-output progress sink. Layer 3 runs inside any consumer of
`DiscStream` (direct PES pipeline, ISO playback, etc.) without caller
involvement.
the mux pipeline. The `freemkv` CLI does the same shape, but as of 1.6.0 the
loop itself (including the multi-title rip loop) lives one layer up, in the
shared `freemkv-engine` crate, with a terminal-output progress sink plugged
into it as the `Sink`. Layer 3 runs inside any consumer of `DiscStream`
(direct PES pipeline, ISO playback, etc.) without caller involvement.
Three primitives compose the disc-side flow:
+38 -23
View File
@@ -326,6 +326,41 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
}
}
/// Test-only inverse of [`decrypt_unit`]: encrypt a clear aligned unit under
/// `unit_key` and set the CPI-encrypted flag (top 2 bits of byte 0) so the unit
/// reads as encrypted under [`aacs_unit_encrypted`]. Exposed `pub(crate)` for
/// cross-module mux tests (the mux `driver.rs` builds a genuinely-AACS-encrypted
/// fixture to prove the live/session decrypt path installs its key map). Uses
/// only the module-scope primitives so it stays in lock-step with `decrypt_unit`.
#[cfg(test)]
pub(crate) fn aacs_encrypt_unit_for_test(unit: &mut [u8], unit_key: &[u8; 16]) {
if unit.len() < ALIGNED_UNIT_LEN {
return;
}
// Set CPI bits BEFORE key derivation so the recovered plaintext header matches.
unit[0] |= 0xC0;
let mut header = [0u8; 16];
header.copy_from_slice(&unit[..16]);
let derived = aes_ecb_encrypt(unit_key, &header);
let mut k = [0u8; 16];
for i in 0..16 {
k[i] = derived[i] ^ header[i];
}
// CBC-encrypt bytes 16.. under the fixed AACS IV (forward of `aes_cbc_decrypt`).
let mut prev = AACS_IV;
let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16;
for i in 0..num_blocks {
let off = 16 + i * 16;
let mut block = [0u8; 16];
for j in 0..16 {
block[j] = unit[off + j] ^ prev[j];
}
let enc = aes_ecb_encrypt(&k, &block);
unit[off..off + 16].copy_from_slice(&enc);
prev.copy_from_slice(&enc);
}
}
/// Remove bus encryption from an aligned unit (AACS 2.0 / UHD).
/// Bus encryption uses read_data_key, decrypting bytes 16..2048 of each 2048-byte sector.
pub(crate) fn decrypt_bus(unit: &mut [u8], read_data_key: &[u8; 16]) {
@@ -615,29 +650,9 @@ mod tests {
/// header) XOR header`, then CBC-encrypt bytes 16..6144 under the
/// fixed AACS IV.
fn aacs_encrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) {
// Set the CPI bits (top 2 of byte 0) so the unit reads as encrypted under
// `aacs_unit_encrypted` — done BEFORE key derivation so the plaintext
// header the real decrypt recovers matches what we encrypt under.
unit[0] |= 0xC0;
let header: [u8; 16] = unit[..16].try_into().unwrap();
let derived = aes_ecb_encrypt(unit_key, &header);
let mut k = [0u8; 16];
for i in 0..16 {
k[i] = derived[i] ^ header[i];
}
let cipher = Aes128::new(GenericArray::from_slice(&k));
let mut prev = AACS_IV;
let num_blocks = (ALIGNED_UNIT_LEN - 16) / 16;
for i in 0..num_blocks {
let off = 16 + i * 16;
for j in 0..16 {
unit[off + j] ^= prev[j];
}
let mut block = GenericArray::clone_from_slice(&unit[off..off + 16]);
cipher.encrypt_block(&mut block);
unit[off..off + 16].copy_from_slice(&block);
prev.copy_from_slice(&unit[off..off + 16]);
}
// Delegate to the module-scope `pub(crate)` helper (the single encrypt
// implementation, shared with the mux `driver.rs` decrypt test).
super::aacs_encrypt_unit_for_test(unit, unit_key);
}
/// Build a clear aligned unit with TS sync bytes at offset 4 + k*192.
+1 -1
View File
@@ -381,7 +381,7 @@ impl AacsKeyMap {
/// decorator can dispatch uniformly. A map index outside the held pool is a
/// fail-loud [`Error::DecryptFailed`]: the resolver's job is to guarantee every
/// selectable index is present, so a gap here is a resolver bug, not silent loss.
pub fn decrypt_sectors_mapped(
pub(crate) fn decrypt_sectors_mapped(
buf: &mut [u8],
keys: &DecryptKeys,
base_lba: u32,
+3 -2
View File
@@ -1,6 +1,7 @@
//! `Disc::extract_tree` — decrypted file-tree extraction (`dir://`).
//!
//! Sibling of [`Disc::copy`](super::Disc::copy) (discISO sector dump),
//! Sibling of the discISO sector dump (the sweep/patch recovery passes, which
//! now live in the `freemkv-engine` crate),
//! specialized to write **per file** rather than a whole image, applying
//! decryption on the way out, and **without** any multipass / recovery
//! orchestration. 1-shot, decrypt-only.
@@ -217,7 +218,7 @@ impl Disc {
))
}
DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new(
self.resolve_content_key_map(reader, &mut base_keys, None)?,
self.resolve_content_key_map(reader, &mut base_keys, None, opts.halt.as_ref())?,
)),
_ => None,
};
-1670
View File
File diff suppressed because it is too large Load Diff
+41 -2448
View File
File diff suppressed because it is too large Load Diff
-1666
View File
File diff suppressed because it is too large Load Diff
+130
View File
@@ -182,6 +182,136 @@ mod tests {
assert!(s.forced, "no content observed → vendor forced preserved");
}
/// A reader that serves a fixed BD-TS byte stream once (across sequential
/// `read_sectors` calls), then EOF — so the probe's demux→parse→observe→apply
/// path runs on real synthetic PGS content.
struct TsReader {
data: Vec<u8>,
pos: usize,
}
impl SectorSource for TsReader {
fn read_sectors(
&mut self,
_lba: u32,
_count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
if self.pos >= self.data.len() {
return Ok(0);
}
let n = buf.len().min(self.data.len() - self.pos);
buf[..n].copy_from_slice(&self.data[self.pos..self.pos + n]);
self.pos += n;
Ok(n)
}
fn capacity_sectors(&self) -> u32 {
self.data.len().div_ceil(SECTOR_BYTES) as u32
}
}
// PGS PCS layout (matches the private constants in mux::codec::pgs): a
// display-set frame begins with a PCS (segment type 0x16); byte 13 is
// number_of_composition_objects; byte 17 is the first object's flags, whose
// 0x40 bit is forced_on_flag.
const PCS_SEG: u8 = 0x16;
const PCS_NUM_OBJECTS_OFF: usize = 13;
const PCS_FLAGS_OFF: usize = 17;
const PCS_FORCED_FLAG: u8 = 0x40;
/// One PGS display-set elementary payload with a single composition object;
/// `forced` sets forced_on_flag.
fn pcs_display(forced: bool) -> Vec<u8> {
let mut d = vec![0u8; 18];
d[0] = PCS_SEG;
d[PCS_NUM_OBJECTS_OFF] = 1;
d[PCS_FLAGS_OFF] = if forced { PCS_FORCED_FLAG } else { 0 };
d
}
/// Wrap an elementary payload in one 192-byte BD-TS PES packet (PUSI, PTS
/// present) on `pid`. `cc` is the 4-bit continuity counter.
fn bd_pes_packet(pid: u16, cc: u8, es: &[u8]) -> Vec<u8> {
let mut pkt = vec![0u8; 192];
// pkt[0..4] = TP_extra_header (zeros). TS packet starts at pkt[4].
pkt[4] = 0x47; // sync
pkt[5] = 0x40 | ((pid >> 8) & 0x1F) as u8; // PUSI + PID high 5 bits
pkt[6] = (pid & 0xFF) as u8; // PID low 8 bits
pkt[7] = 0x10 | (cc & 0x0F); // adaptation=payload-only + continuity counter
// PES header (at ts payload = pkt[8..]): 00 00 01 stream_id len flags.
let p = 8;
pkt[p] = 0x00;
pkt[p + 1] = 0x00;
pkt[p + 2] = 0x01;
pkt[p + 3] = 0xBD; // private_stream_1 (carries the standard PES extension)
pkt[p + 4] = 0x00; // PES packet length hi (0 = unbounded; ignored by demux)
pkt[p + 5] = 0x00; // PES packet length lo
pkt[p + 6] = 0x80; // flags1 ('10' marker)
pkt[p + 7] = 0x80; // flags2 → PTS present
pkt[p + 8] = 0x05; // PES_header_data_length = 5 (one PTS)
// 5-byte PTS with the mandatory marker bits (bytes 0,2,4 low bit = 1).
pkt[p + 9] = 0x21;
pkt[p + 10] = 0x00;
pkt[p + 11] = 0x01;
pkt[p + 12] = 0x00;
pkt[p + 13] = 0x01;
let es_off = p + 14; // ES data follows the 14-byte PES header
let n = es.len().min(192 - es_off);
pkt[es_off..es_off + n].copy_from_slice(&es[..n]);
pkt
}
/// Two BD-TS PES on `pid`: the FIRST carries `es` (the observed display set);
/// the second (a fresh PUSI) exists only to flush the first PES out of the
/// demuxer — the probe never calls `TsDemuxer::flush`, so an open PES stays
/// buffered until the next PES start arrives.
fn ts_stream(pid: u16, es: &[u8]) -> Vec<u8> {
let mut s = bd_pes_packet(pid, 0, es);
s.extend_from_slice(&bd_pes_packet(pid, 1, &pcs_display(false)));
s
}
#[test]
fn forced_display_sets_apply_forced_verdict() {
// Feed REAL synthetic PGS bytes through the full demux→parse→observe→apply
// path: a forced display set must flip a vendor-not-forced PGS track to
// forced. Mutation guard: inverting ForcedTracker::is_forced flips this.
let pid = 0x1200u16;
let mut reader = TsReader {
data: ts_stream(pid, &pcs_display(true)),
pos: 0,
};
let mut title = pgs_title(pid, false); // vendor label says NOT forced
probe_and_set_forced(&mut reader, &mut title);
let Stream::Subtitle(s) = &title.streams[0] else {
panic!()
};
assert!(
s.forced,
"an all-forced PGS track → forced verdict applied onto the stream"
);
}
#[test]
fn nonforced_display_sets_clear_forced_verdict() {
// A non-forced display set observed on the wire overrides a vendor-forced
// label → the track settles as not-forced.
let pid = 0x1200u16;
let mut reader = TsReader {
data: ts_stream(pid, &pcs_display(false)),
pos: 0,
};
let mut title = pgs_title(pid, true); // vendor label says forced
probe_and_set_forced(&mut reader, &mut title);
let Stream::Subtitle(s) = &title.streams[0] else {
panic!()
};
assert!(
!s.forced,
"a non-forced display set observed → forced verdict cleared"
);
}
#[test]
fn no_pgs_streams_is_noop() {
// A title with no PGS subtitle streams is a no-op (the reader is never
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-218
View File
@@ -1,218 +0,0 @@
//! `Disc::sweep`'s consumer-side `Sink<WorkItem>`.
//!
//! Background: the original sweep loop runs strictly serialised —
//! SCSI read → decrypt → seek + write → mapfile.record → next iter.
//! On a healthy disc the SCSI read costs ~5-12 ms per 64 KB batch and
//! the post-read work (decrypt 1-3 ms + file write + mapfile fsync
//! 5-15 ms) adds another batch's worth of latency. The drive idles
//! during the post-read work; throughput tops out at the *sum* of
//! both costs.
//!
//! A producer/consumer split overlaps the two stages on the generic
//! [`crate::io::Pipeline`] + [`crate::io::Sink`] primitive. This module
//! is the sweep-specific `Sink` impl; the producer-side state machine
//! (read_error context, decrypt, set_speed, halt) stays in
//! `Disc::sweep` in `disc/mod.rs`.
//!
//! Correctness invariants preserved:
//! - Mapfile is single-writer (consumer-only). No locking.
//! - All `read_error::ReadCtx` state stays on the producer thread.
//! - `set_speed` calls happen on the producer thread (same thread that
//! owns the `SectorSource`). No new SCSI concurrency.
//! - Per-iteration ordering of file-write → mapfile-record is kept
//! intact in the consumer (write before record), so the on-disk
//! invariant "mapfile only marks Finished what the file has
//! received" survives a crash mid-pass.
//! - Only one SCSI command is in flight at a time; error-path timing
//! is identical and no new retry logic is introduced.
use std::io::{Seek, SeekFrom, Write};
use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
use crate::error::Error;
use crate::io::{Flow, Sink};
use super::mapfile::{MapStats, Mapfile, SectorStatus};
/// Reusable zero buffer for SkipFill / GapFill / BisectBad. 64 KB
/// matches the existing zero_gap chunk size used by the pre-split
/// sweep loop.
const ZERO_CHUNK: usize = 64 * 1024;
/// Producer → Consumer messages. The consumer applies these in FIFO
/// order; ordering of file writes and mapfile records across items is
/// preserved.
pub(super) enum WorkItem {
/// Successful batch read. Producer has already decrypted `buf` if
/// `opts.decrypt` was set. Consumer writes `buf` at `pos` and
/// records the range as `Finished`.
Good { pos: u64, buf: Vec<u8> },
/// Bisect inner-loop good single sector (already decrypted by the
/// producer). 2048 bytes.
BisectGood { pos: u64, buf: Box<[u8; 2048]> },
/// Bisect inner-loop bad single sector. Consumer writes 2048
/// zeros at `pos` and records the sector as `NonTrimmed`.
BisectBad { pos: u64 },
/// Whole-batch zero-fill (failed batch on `SkipBlock`, or the
/// failed batch portion of `JumpAhead`). Consumer streams zeros
/// across `[pos, pos+len)` and records the range as `NonTrimmed`.
SkipFill { pos: u64, len: u64 },
/// Gap fill following a `JumpAhead`. Same effect as `SkipFill`;
/// distinguished only so future logging / instrumentation can
/// tell them apart without parsing a flag.
GapFill { pos: u64, len: u64 },
/// Producer wants the latest mapfile stats for the progress
/// callback. Consumer responds on `prog_tx` with a fresh
/// [`ProgressSnapshot`]. Best-effort: if the producer hasn't
/// drained the previous snapshot, the new one is silently
/// dropped — the producer's local cache stays current enough.
StatsRequest,
}
/// Snapshot the consumer sends back to the producer for the progress
/// callback.
pub(super) struct ProgressSnapshot {
pub stats: MapStats,
pub bad_ranges: Vec<(u64, u64)>,
}
/// Final summary returned by the consumer thread on shutdown — what
/// `SweepSink::close` produces, surfaced to the producer via
/// `Pipeline::finish`.
pub(super) struct ConsumerSummary {
pub stats: MapStats,
}
/// Drain any pending progress snapshots from the consumer. Returns
/// the most recent one, if any. The producer caches it and uses it
/// for subsequent progress callbacks until a fresh one arrives.
pub(super) fn try_recv_progress(rx: &Receiver<ProgressSnapshot>) -> Option<ProgressSnapshot> {
let mut latest = None;
while let Ok(snap) = rx.try_recv() {
latest = Some(snap);
}
latest
}
/// `Sink<WorkItem>` for sweep. Owns the writeback file + mapfile +
/// progress back-channel. `apply` carries the file-write +
/// mapfile.record per item; `close` drains the writeback pipeline,
/// fsyncs the ISO, and flushes the mapfile.
pub(super) struct SweepSink {
file: crate::io::WritebackFile,
map: Mapfile,
/// `sync_all`-on-failure-is-an-error iff the output is a regular
/// file. `/dev/null` and pipes always fail `sync_all`; that's not
/// a real error.
is_regular: bool,
/// Back-channel for `StatsRequest` responses. The producer caches
/// the latest snapshot and uses it for the progress callback;
/// dropped sends on a full channel are by design.
prog_tx: SyncSender<ProgressSnapshot>,
/// Reusable zero buffer for SkipFill / GapFill / BisectBad. Held
/// in the sink so each apply call doesn't reallocate.
zero: Box<[u8; ZERO_CHUNK]>,
}
impl SweepSink {
/// Construct a new `SweepSink` plus the matching progress
/// receiver. Channel depth on the back-channel is `1` — the
/// producer's cache is the source of truth between snapshots.
pub(super) fn new(
file: crate::io::WritebackFile,
map: Mapfile,
is_regular: bool,
) -> (Self, Receiver<ProgressSnapshot>) {
let (prog_tx, prog_rx) = sync_channel::<ProgressSnapshot>(1);
let sink = SweepSink {
file,
map,
is_regular,
prog_tx,
zero: Box::new([0u8; ZERO_CHUNK]),
};
(sink, prog_rx)
}
}
impl Sink<WorkItem> for SweepSink {
type Output = ConsumerSummary;
fn apply(&mut self, item: WorkItem) -> Result<Flow, Error> {
match item {
WorkItem::Good { pos, buf } => {
// Decrypt is on the producer; consumer assumes plaintext.
let len = buf.len() as u64;
self.file.seek(SeekFrom::Start(pos))?;
self.file.write_all(&buf)?;
self.map.record(pos, len, SectorStatus::Finished)?;
}
WorkItem::BisectGood { pos, buf } => {
self.file.seek(SeekFrom::Start(pos))?;
self.file.write_all(&buf[..])?;
self.map.record(pos, 2048, SectorStatus::Finished)?;
}
WorkItem::BisectBad { pos } => {
self.file.seek(SeekFrom::Start(pos))?;
self.file.write_all(&self.zero[..2048])?;
self.map.record(pos, 2048, SectorStatus::NonTrimmed)?;
}
WorkItem::SkipFill { pos, len } | WorkItem::GapFill { pos, len } => {
self.file.seek(SeekFrom::Start(pos))?;
// Subsequent writes are sequential; `WritebackFile`'s
// seek-elision keeps them on the writeback pipeline path.
let mut filled = 0u64;
while filled < len {
let chunk = (len - filled).min(self.zero.len() as u64) as usize;
self.file.write_all(&self.zero[..chunk])?;
filled += chunk as u64;
}
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
}
WorkItem::StatsRequest => {
let stats = self.map.stats();
// DAMAGE only — NOT NonTried. NonTried is the unread remainder
// ahead of the sweep head, not damage; including it made the live
// located drilldown (at-risk movie time + range count) treat the
// whole unread disc as confirmed damage, so at sweep start it
// showed ~full-movie at-risk and melted to 0 as the sweep
// progressed. Matches the one-shot progress path, which already
// excludes NonTried.
let bad_ranges = self.map.ranges_with(&[
SectorStatus::NonTrimmed,
SectorStatus::Unreadable,
SectorStatus::NonScraped,
]);
// Best-effort: drop on backpressure; producer's cache
// stays current enough.
let _ = self
.prog_tx
.try_send(ProgressSnapshot { stats, bad_ranges });
}
}
Ok(Flow::Continue)
}
fn close(mut self) -> Result<Self::Output, Error> {
// Drain the writeback pipeline + fsync the ISO, then persist
// any pending mapfile state. Same finalisation order as the
// pre-Pipeline consumer loop.
if let Err(e) = self.file.sync_all() {
if self.is_regular {
return Err(Error::IoError { source: e });
}
// Non-regular outputs (/dev/null, pipes) always fail
// sync_all; that's not a real error.
}
self.map.flush()?;
Ok(ConsumerSummary {
stats: self.map.stats(),
})
}
}
+5 -2
View File
@@ -4,7 +4,7 @@
//! optionally unlocks/initializes via the `freemkv-unlock` dispatch
//! (through [`crate::unlock_bridge`]), and reads sectors.
pub(crate) fn extract_scsi_context(e: &Error) -> (u8, Option<crate::scsi::ScsiSense>) {
pub 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),
@@ -483,7 +483,7 @@ impl Drive {
// the stock riplock). A stock-mode drive with no firmware unlocker still
// wants max speed. Best-effort: a failure here must NOT fail the rip.
if r.is_ok() {
self.set_speed(crate::speed::DriveSpeed::Max.to_kbps());
self.set_speed(Self::SPEED_MAX_KBPS);
// Ask the drive to REPORT recovered/marginal reads rather than
// silently commit best-effort data as GOOD (the dirty-disc
// "passed-clean-but-decodes-with-errors" trap). Best-effort: a drive
@@ -951,6 +951,9 @@ impl Drive {
decode_read_capacity(&buf, result.bytes_transferred)
}
/// SET CD SPEED "use the drive's maximum" sentinel (0xFFFF KB/s per MMC).
pub const SPEED_MAX_KBPS: u16 = 0xFFFF;
pub fn set_speed(&mut self, speed_kbs: u16) {
let cdb = crate::scsi::build_set_cd_speed(speed_kbs);
let mut dummy = [0u8; 0];
+43 -8
View File
@@ -59,7 +59,9 @@ pub const E_MKV_INVALID: u16 = 6008;
pub const E_NO_STREAMS: u16 = 6009;
pub const E_HALTED: u16 = 6010;
pub const E_MAPFILE_INVALID: u16 = 6011;
pub const E_SELECTION_PID_UNKNOWN: u16 = 6014;
pub const E_UDF_BUFFER_TOO_SMALL: u16 = 6012;
pub const E_UDF_NOT_FILESYSTEM: u16 = 6013;
// AACS (7xxx)
pub const E_AACS_NO_KEYS: u16 = 7000;
@@ -272,6 +274,14 @@ pub enum Error {
UdfNotFound {
path: String,
},
/// The reader was addressable but the bytes are structurally NOT a UDF
/// filesystem — a deterministic tag/format mismatch (e.g. no Anchor Volume
/// Descriptor Pointer at sector 256, no partition descriptor, no File Set
/// Descriptor). Distinct from [`Error::DiscRead`] (a transient I/O fault):
/// this is a stable property of the media, not something a retry fixes. Lets
/// callers (notably FMTS key resolution) treat "not a UDF/FMTS disc" as a
/// clean negative while still failing loud on a real read fault.
UdfNotFilesystem,
/// A `SectorSource` caller passed a destination buffer smaller than one
/// 2048-byte sector. A contract violation on the public reader API —
/// returned instead of panicking on the slice.
@@ -283,6 +293,12 @@ pub enum Error {
IfoParse,
MkvInvalid,
NoStreams,
/// A [`crate::StreamSelection`] listed a PID that does not exist in the
/// title's declared streams — a caller bug (e.g. a stale scan), reported
/// loudly rather than silently producing an MKV missing a requested track.
SelectionPidUnknown {
pid: u16,
},
/// ddrescue mapfile parse failed. `kind` is a stable, language-neutral
/// identifier (e.g. `"status_char"`, `"hex"`); not a translatable
/// English message.
@@ -564,11 +580,13 @@ impl Error {
Error::MplsParse => E_MPLS_PARSE,
Error::ClpiParse => E_CLPI_PARSE,
Error::UdfNotFound { .. } => E_UDF_NOT_FOUND,
Error::UdfNotFilesystem => E_UDF_NOT_FILESYSTEM,
Error::UdfBufferTooSmall => E_UDF_BUFFER_TOO_SMALL,
Error::DiscTitleRange { .. } => E_DISC_TITLE_RANGE,
Error::IfoParse => E_IFO_PARSE,
Error::MkvInvalid => E_MKV_INVALID,
Error::NoStreams => E_NO_STREAMS,
Error::SelectionPidUnknown { .. } => E_SELECTION_PID_UNKNOWN,
Error::MapfileInvalid { .. } => E_MAPFILE_INVALID,
Error::AacsNoKeys => E_AACS_NO_KEYS,
Error::AacsCertShort => E_AACS_CERT_SHORT,
@@ -776,6 +794,9 @@ impl std::fmt::Display for Error {
Error::InvalidCdbLength { len, max } => {
write!(f, "E{}: {}/{}", self.code(), len, max)
}
Error::SelectionPidUnknown { pid } => {
write!(f, "E{}: 0x{:04x}", self.code(), pid)
}
_ => write!(f, "E{}", self.code()),
}
}
@@ -882,15 +903,13 @@ pub type Result<T> = std::result::Result<T, Error>;
/// The numeric error code carried by an [`io::Error`](std::io::Error) that was
/// produced from an [`Error`], or `None` if it carries none.
///
/// Two shapes are recognised: an `io::Error` that still wraps the typed
/// [`Error`] (via `io::Error::new(kind, Error)`), and the round-tripped form
/// produced by [`From<Error> for io::Error`] whose message is the `Error`'s
/// `E<code>[: …]` [`Display`](std::fmt::Display) string.
/// [`From<Error> for io::Error`] is the ONLY path from a typed [`Error`] to an
/// `io::Error` in this crate, and it stringifies (`io::Error::new(kind, msg)`
/// where `msg` is the `Error`'s `E<code>[: …]` [`Display`](std::fmt::Display)
/// string) rather than boxing the typed value — no code path constructs an
/// `io::Error` that still holds a `crate::error::Error` via `get_ref`. So the
/// only recognised shape is the round-tripped `E<code>` message prefix.
fn io_error_code(e: &std::io::Error) -> Option<u16> {
// Direct: the io::Error still holds the typed Error.
if let Some(err) = e.get_ref().and_then(|r| r.downcast_ref::<Error>()) {
return Some(err.code());
}
// Round-tripped: `From<Error> for io::Error` stringifies as "E<code>[: …]".
let s = e.to_string();
let digits = s.strip_prefix('E')?;
@@ -921,6 +940,21 @@ pub fn is_halt(e: &std::io::Error) -> bool {
io_error_code(e) == Some(E_HALTED)
}
/// Whether an [`io::Error`](std::io::Error) is a **disc-level** key failure —
/// the disc as a whole cannot be decrypted, so EVERY title will fail the same
/// way. Distinct from a per-title skippable stub
/// ([`is_skippable_title_stub`]): `E_NO_DISC_KEY` (keydb present but no entry
/// for this disc), `E_KEYDB_LOAD` (no keydb at all), and `E_AACS_NO_KEYS` (no
/// usable AACS key material) are all whole-disc conditions. A multi-title rip
/// loop should stop immediately on this (fail-fast) rather than iterate every
/// title re-printing the same error.
pub fn is_disc_level_no_key(e: &std::io::Error) -> bool {
matches!(
io_error_code(e),
Some(E_NO_DISC_KEY | E_KEYDB_LOAD | E_AACS_NO_KEYS)
)
}
impl Error {
/// Borrow the drive-returned SPC-4 sense triple if this error is a
/// [`Error::ScsiError`] carrying sense data. `None` for any other
@@ -1277,6 +1311,7 @@ mod tests {
E_HALTED,
E_MAPFILE_INVALID,
E_UDF_BUFFER_TOO_SMALL,
E_UDF_NOT_FILESYSTEM,
E_AACS_NO_KEYS,
E_AACS_CERT_SHORT,
E_AACS_AGID_ALLOC,
+2 -3
View File
@@ -40,9 +40,8 @@ pub(crate) mod platform_macos;
pub mod pipeline;
pub(crate) use writeback_file::WritebackFile;
pub use writeback_file::WritebackFile;
pub use pipeline::{
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
WRITE_THROUGH_DEPTH,
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, Sink, WRITE_PIPELINE_DEPTH, WRITE_THROUGH_DEPTH,
};
+1 -6
View File
@@ -173,14 +173,9 @@ fn finish_with_grace<R: Send + 'static>(
/// Default channel depth for callers without a specific reason to
/// pick another value. Kept conservative (4) — most callers should
/// use READ_PIPELINE_DEPTH or WRITE_PIPELINE_DEPTH instead.
/// use WRITE_PIPELINE_DEPTH instead.
pub const DEFAULT_PIPELINE_DEPTH: usize = 4;
/// Read pipeline depth. Larger buffer compensates for drive variability
/// and NFS sync_file_range stalls; keeps ISO reader thread fed even when
/// consumer blocks on write.
pub const READ_PIPELINE_DEPTH: usize = 32;
/// Write pipeline depth. Smaller buffer reduces backpressure risk when
/// sync_file_range blocks; prevents producer from accumulating too much
/// work while consumer waits for NFS to drain.
+6 -6
View File
@@ -97,7 +97,7 @@ fn writeback_chunk_bytes() -> u64 {
.unwrap_or(WRITEBACK_CHUNK_BYTES_DEFAULT)
}
pub(crate) struct WritebackFile {
pub struct WritebackFile {
file: File,
pipeline: WritebackPipeline,
pos: u64,
@@ -115,7 +115,7 @@ impl WritebackFile {
/// once so the pipeline starts tracking from wherever the file
/// already is (typically 0 for fresh files; non-zero for resumed
/// or appended files).
pub(crate) fn new(mut file: File) -> io::Result<Self> {
pub fn new(mut file: File) -> io::Result<Self> {
let pos = file.stream_position()?;
let pipeline = WritebackPipeline::new(&file, pos, writeback_chunk_bytes());
Ok(Self {
@@ -136,7 +136,7 @@ impl WritebackFile {
/// [`Self::create_with_size_hint`] so the kernel can pre-reserve
/// extents.
#[allow(dead_code)]
pub(crate) fn create(path: &Path) -> io::Result<Self> {
pub fn create(path: &Path) -> io::Result<Self> {
let file = File::create(path)?;
Self::new(file)
}
@@ -153,7 +153,7 @@ impl WritebackFile {
/// On platforms without an extent-preallocation primitive this is
/// equivalent to `create` — the size hint is dropped after a debug
/// log.
pub(crate) fn create_with_size_hint(path: &Path, size_bytes: u64) -> io::Result<Self> {
pub fn create_with_size_hint(path: &Path, size_bytes: u64) -> io::Result<Self> {
let file = File::create(path)?;
platform::preallocate(&file, size_bytes);
Self::new(file)
@@ -163,7 +163,7 @@ impl WritebackFile {
/// wrap it. Mirrors `File::open` semantics for the writable case
/// — used by patch / resume paths that mutate an existing ISO in
/// place.
pub(crate) fn open(path: &Path) -> io::Result<Self> {
pub fn open(path: &Path) -> io::Result<Self> {
let file = OpenOptions::new().write(true).open(path)?;
Self::new(file)
}
@@ -184,7 +184,7 @@ impl WritebackFile {
/// completed. Callers needing crash-consistency (e.g. mux-finish
/// then external commit/DB update) must not treat `Ok(())` as a
/// durability barrier.
pub(crate) fn sync_all(&mut self) -> io::Result<()> {
pub fn sync_all(&mut self) -> io::Result<()> {
if self.seek_count > 0 {
tracing::debug!(
target: "mux",
+166 -21
View File
@@ -375,14 +375,42 @@ pub fn resolve_and_apply_traced(
/// [`resolve_and_apply`] this does not validate/commit to a disc — the read's
/// decorator re-decrypts with the returned keys, which is the validation.
pub fn fetch_unit_keys(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
drive_unit_keys(sources, ctx).keys
}
/// Whether a driver run resolved keys, and — when it did NOT — whether the miss
/// was a genuine "no source holds this key" (`errored == false`) or at least one
/// source FAILED (`errored == true`, e.g. a network source was unreachable). The
/// distinction gates negative-result memoization: an empty-because-absent result
/// is safe to cache, an empty-because-a-source-was-down result is transient and
/// must NOT be cached (the key may resolve once the source recovers).
struct FetchOutcome {
keys: Vec<UnitKey>,
errored: bool,
}
/// [`fetch_unit_keys`] plus the error signal: drive `sources` in order, return the
/// first source's non-empty Unit Keys, and flag whether any source that failed to
/// answer did so with an `Err` (a source failure) rather than an empty `Ok`
/// (genuine absence — see [`KeySource::get_unit_keys`]).
fn drive_unit_keys(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> FetchOutcome {
let mut errored = false;
for source in sources {
if let Ok(uks) = source.get_unit_keys(ctx) {
if !uks.is_empty() {
return uks;
match source.get_unit_keys(ctx) {
Ok(uks) if !uks.is_empty() => {
return FetchOutcome {
keys: uks,
errored: false,
};
}
Ok(_) => {}
Err(_) => errored = true,
}
}
Vec::new()
FetchOutcome {
keys: Vec::new(),
errored,
}
}
/// The forensic counterpart to [`fetch_unit_keys`]: drive `sources` in order and
@@ -392,14 +420,29 @@ pub fn fetch_unit_keys(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) ->
/// keying on `disc_hash`) ignores them. Whatever the winning source returns —
/// ≥ 1 key — is trusted as the COMPLETE ordered set; no fixed count is assumed.
pub fn fetch_fmts_indexes(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> Vec<UnitKey> {
drive_fmts_indexes(sources, ctx).keys
}
/// [`fetch_fmts_indexes`] plus the error signal (see [`drive_unit_keys`]): the
/// forensic counterpart that flags whether any source `Err`ed during the miss.
fn drive_fmts_indexes(sources: &[Box<dyn KeySource>], ctx: &dyn ResolveCtx) -> FetchOutcome {
let mut errored = false;
for source in sources {
if let Ok(uks) = source.get_fmts_indexes(ctx) {
if !uks.is_empty() {
return uks;
match source.get_fmts_indexes(ctx) {
Ok(uks) if !uks.is_empty() => {
return FetchOutcome {
keys: uks,
errored: false,
};
}
Ok(_) => {}
Err(_) => errored = true,
}
}
Vec::new()
FetchOutcome {
keys: Vec::new(),
errored,
}
}
/// Build the read-time [`crate::sector::KeyFetch`] from the disc's public AACS
@@ -426,12 +469,17 @@ pub fn key_fetch(
// batch: the resolved keys are disc-level (a clip's index / CPS keys are
// identical for every title that references it), so the first batch resolves
// over the network and every repeat is answered from the cache with no
// request. Empty replies are cached too — a key the service lacks for a batch
// won't appear on a re-ask, so re-hitting the network buys nothing. Each
// operation gets its OWN cache: a base batch and a forensic anchor never
// collide, and the same bytes could legitimately resolve differently per op.
// The per-kind driver: `fetch_unit_keys` or `fetch_fmts_indexes`.
type FetchDriver = fn(&[Box<dyn KeySource>], &dyn ResolveCtx) -> Vec<UnitKey>;
// request. A GENUINELY-empty reply (every source ran and none held the key)
// is cached too — the key the service lacks for a batch won't appear on a
// re-ask, so re-hitting the network buys nothing. But an empty reply caused
// by a source FAILURE (network down, source unreachable) is NOT cached: that
// is a transient miss, and caching it would permanently drop a unit that
// could be recovered once the source recovers — the `errored` flag on
// `FetchOutcome` draws exactly that line. Each operation gets its OWN cache:
// a base batch and a forensic anchor never collide, and the same bytes could
// legitimately resolve differently per op.
// The per-kind driver: `drive_unit_keys` or `drive_fmts_indexes`.
type FetchDriver = fn(&[Box<dyn KeySource>], &dyn ResolveCtx) -> FetchOutcome;
fn make_op(
inputs: DiscInputs,
make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>,
@@ -460,16 +508,22 @@ pub fn key_fetch(
// derives unit keys from `enc_title_keys`, which a V10 disc parses at
// the 48-byte stride — hardcoding the V20 stride here corrupted them.
let ctx = DiscInputsCtx::new(&di);
let keys: Vec<[u8; 16]> = drive(&sources, &ctx).into_iter().map(|u| u.key).collect();
cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(fp, keys.clone());
let outcome = drive(&sources, &ctx);
let keys: Vec<[u8; 16]> = outcome.keys.into_iter().map(|u| u.key).collect();
// Memoize a positive result always; memoize a NEGATIVE (empty) result
// only when it is a genuine absence, never when a source errored — a
// transient outage must not permanently poison this fingerprint.
if !keys.is_empty() || !outcome.errored {
cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(fp, keys.clone());
}
keys
})
}
let unit = make_op(inputs.clone(), make_sources.clone(), fetch_unit_keys);
let fmts = make_op(inputs, make_sources, fetch_fmts_indexes);
let unit = make_op(inputs.clone(), make_sources.clone(), drive_unit_keys);
let fmts = make_op(inputs, make_sources, drive_fmts_indexes);
crate::sector::KeyFetch::new(unit, fmts)
}
@@ -867,6 +921,97 @@ mod tests {
);
}
/// A transient source outage must NOT be memoized as a permanent "no key":
/// a fingerprint whose first fetch failed because the source errored must be
/// re-asked, and once the source recovers the key resolves. Regression guard
/// for the negative-result memoization fix — caching the errored empty would
/// permanently drop a recoverable unit for the rest of the op.
#[test]
fn errored_empty_is_not_cached_and_retries_when_source_recovers() {
use std::sync::atomic::{AtomicUsize, Ordering};
let key = [0x77u8; 16];
// Shared across every `make_sources()` rebuild: call 0 errors (source
// down), every later call succeeds (source recovered).
let calls = Arc::new(AtomicUsize::new(0));
struct Flaky {
calls: Arc<AtomicUsize>,
key: [u8; 16],
}
impl KeySource for Flaky {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
Err(Error::AacsNoKeys) // first attempt: source unreachable
} else {
Ok(vec![UnitKey::new(0, self.key)])
}
}
}
let calls_c = Arc::clone(&calls);
let make: Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync> = Arc::new(move || {
vec![Box::new(Flaky {
calls: Arc::clone(&calls_c),
key,
}) as Box<dyn KeySource>]
});
let cb = key_fetch(empty_inputs(), make);
let samples = vec![vec![0xCDu8; 8]];
// First fetch: the source errors → empty, but the miss must NOT be cached.
assert!(
cb.unit_keys(&samples).is_empty(),
"source down → empty this time"
);
// Second fetch, SAME samples: not blocked by a cached empty → the now-
// recovered source resolves the key.
assert_eq!(
cb.unit_keys(&samples),
vec![key],
"recovered source resolves — errored empty was not memoized"
);
}
/// A GENUINE absence (a source that runs and returns an empty `Ok`) is still
/// memoized — the benefit the fix preserves. A source counting its calls must
/// be asked exactly once for a fingerprint whose first (clean) reply was empty.
#[test]
fn genuine_empty_is_still_memoized() {
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = Arc::new(AtomicUsize::new(0));
struct AlwaysEmpty {
calls: Arc<AtomicUsize>,
}
impl KeySource for AlwaysEmpty {
fn get_unit_keys(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(Vec::new()) // ran fine, genuinely holds no key
}
}
let calls_c = Arc::clone(&calls);
let make: Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync> = Arc::new(move || {
vec![Box::new(AlwaysEmpty {
calls: Arc::clone(&calls_c),
}) as Box<dyn KeySource>]
});
let cb = key_fetch(empty_inputs(), make);
let samples = vec![vec![0xEFu8; 8]];
assert!(cb.unit_keys(&samples).is_empty());
assert!(cb.unit_keys(&samples).is_empty());
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"a clean empty reply is cached — the source is asked only once"
);
}
/// The two `KeyFetch` operations route to the two DISTINCT trait methods:
/// `unit_keys` drives `get_unit_keys`, `fmts_indexes` drives
/// `get_fmts_indexes`. A source that returns different keys per method proves
+28 -25
View File
@@ -126,7 +126,6 @@ pub mod progress;
pub mod scsi;
pub mod sector;
pub mod session;
pub(crate) mod speed;
pub(crate) mod udf;
pub(crate) mod unlock_bridge;
@@ -138,7 +137,7 @@ pub(crate) mod unlock_bridge;
pub use drive::capture::{
CapturedFeature, DriveCapture, capture_drive_data, mask_bytes, mask_string,
};
pub use drive::{Drive, DriveStatus, find_drive};
pub use drive::{Drive, DriveStatus, extract_scsi_context, find_drive};
// ─── Disc session (drive open + SCSI bring-up hoist) ─────────────────────────
//
@@ -155,31 +154,40 @@ pub use session::{
// 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};
pub use error::{Error, Result, is_disc_level_no_key, is_halt, is_skippable_title_stub};
// ─── Cooperative cancellation ───────────────────────────────────────────────
//
// One-bit cooperative cancellation token, shared by every long-running loop
// in libfreemkv (sweep, patch, mux). Clone it cheaply; pass it by value into
// each component; poll `is_cancelled()` inside the loop body.
// One-bit cooperative cancellation token, shared by every long-running loop
// libfreemkv's mux, and the recovery passes (sweep/patch) that now live in the
// freemkv-engine crate. Clone it cheaply; pass it by value into each component;
// poll `is_cancelled()` inside the loop body.
pub use halt::Halt;
// Generic bounded producer/consumer primitive used by sweep, patch, and
// mux to overlap reads with writes via a dedicated consumer thread.
// Generic bounded producer/consumer primitive used by the mux pipeline (and,
// via this re-export, by the engine's sweep/patch recovery passes) to overlap
// reads with writes via a dedicated consumer thread.
// `Pipeline::spawn(name, depth, sink)` spawns a named consumer; `pipe.send(item)`
// pushes one item with back-pressure; `pipe.finish()` joins the
// consumer and surfaces its `close()` output. Callers implement `Sink`
// to define per-item behaviour and end-of-stream finalisation.
//
// `DEFAULT_PIPELINE_DEPTH` (=4) is for callers without specific needs;
// most should use READ_PIPELINE_DEPTH or WRITE_PIPELINE_DEPTH instead.
// most should use WRITE_PIPELINE_DEPTH instead.
// Patch uses `WRITE_THROUGH_DEPTH` (=1). Returning `Flow::Stop` from
// `apply` ends the consumer cleanly (still calls `close()`).
pub use io::pipeline::{
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, READ_PIPELINE_DEPTH, Sink, WRITE_PIPELINE_DEPTH,
WRITE_THROUGH_DEPTH,
DEFAULT_PIPELINE_DEPTH, Flow, Pipeline, Sink, WRITE_PIPELINE_DEPTH, WRITE_THROUGH_DEPTH,
};
// ─── Bounded-cache buffered file writer ─────────────────────────────────────
//
// Drop-in `std::fs::File` replacement used everywhere the lib writes large
// sequential output (mux, extract, sweep, patch) — drains dirty pages
// continuously instead of bursting. General I/O infra, not recovery policy;
// promoted to `pub` so freemkv-engine's relocated sweep/patch can use it too.
pub use io::WritebackFile;
// ─── Drive events (low-level callbacks) ─────────────────────────────────────
pub use event::{BatchSizeReason, Event, EventKind};
pub use identity::DriveId;
@@ -198,10 +206,7 @@ pub use identity::DriveId;
// 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::{
AacsKeyMap, DecryptKeys, decrypt_sectors, decrypt_sectors_mapped, decrypt_threads,
set_decrypt_threads,
};
pub use decrypt::{AacsKeyMap, DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_threads};
// ─── Disc structure ─────────────────────────────────────────────────────────
//
@@ -215,11 +220,10 @@ pub use decrypt::{
// different concepts, the same short name; the trait gets the `Pes`
// prefix at the crate root to keep both addressable.
pub use disc::{
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DamageSeverity,
Disc, DiscFormat, DiscId, DiscTitle, DriveCredentials, Extent, ExtractOptions, ExtractResult,
FileResult, FrameRate, HdrFormat, Key, KeyOrigin, LabelPurpose, LabelQualifier, PatchOptions,
PatchOutcome, Resolution, SampleRate, ScanOptions, Stream, SubtitleStream, SweepOptions,
VideoStream, classify_damage,
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, Disc,
DiscFormat, DiscId, DiscTitle, DriveCredentials, Extent, ExtractOptions, ExtractResult,
FileResult, FrameRate, HdrFormat, Key, KeyOrigin, LabelPurpose, LabelQualifier, Resolution,
SampleRate, ScanOptions, Stream, SubtitleStream, VideoStream,
};
pub use keysource::{DiscInputs, KeySource, read_encrypted_units, resolve_and_apply};
@@ -264,11 +268,10 @@ pub use mux::{Mp4FitReport, Mp4SkipReason, mp4_fit_report};
// `SectorSource` to get plaintext sectors out.
pub use mux::build_iso_pipeline;
pub use mux::resolve_mux_key_map;
pub use mux::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, NoopEvents, mux_stream};
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives};
pub use mux::select::{PidFilter, StreamSelection};
pub use mux::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, mux_stream};
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, SenseFamily, drive_has_disc, list_drives};
pub use sector::{
DecryptingSectorSource, FileSectorSink, FileSectorSource, KeyFetch, PrefetchedSectorSource,
SectorSink, SectorSource,
DecryptingSectorSource, FileSectorSource, KeyFetch, PrefetchedSectorSource, SectorSource,
};
pub use speed::DriveSpeed;
pub use udf::{UdfFs, read_filesystem};
+150 -31
View File
@@ -683,12 +683,20 @@ fn dts_core_duration_ns(data: &[u8]) -> u64 {
}
/// DTS core-header validity constants (ETSI TS 102 114).
/// `deficit_samples` must equal this (`DTS_PCMBLOCK_SAMPLES`); `npcmblocks`
/// must be a multiple of `DTS_SUBBAND_SAMPLES`; `audio_mode` must be below
/// `DTS_AMODE_COUNT`; `lfe_present == DTS_LFE_FLAG_INVALID` is rejected.
/// For a NORMAL frame `deficit_samples` must equal this (`DTS_PCMBLOCK_SAMPLES`)
/// — a termination frame may carry fewer; `npcmblocks` must be a multiple of
/// `DTS_SUBBAND_SAMPLES`; `audio_mode` must be below `DTS_AMODE_COUNT`;
/// `lfe_present == DTS_LFE_FLAG_INVALID` is rejected.
const DTS_PCMBLOCK_SAMPLES: u32 = 32;
const DTS_SUBBAND_SAMPLES: u32 = 8;
const DTS_AMODE_COUNT: u32 = 10;
/// Number of LEGAL `AMODE` (channel-arrangement) codes. The 6-bit AMODE field
/// (ETSI TS 102 114 §5.3.1) has 16 defined channel arrangements, codes 0-15;
/// only 16-63 are reserved/user-defined and undecodable. ffmpeg's
/// `ff_dca_channels[16] = {1,2,2,2,2,3,3,4,4,5,6,6,6,7,8,8}` confirms all 16 are
/// decodable — codes 10-15 are the 6/7/8-channel layouts. A frame is dropped
/// only when `audio_mode >= DTS_AMODE_COUNT` (i.e. a truly reserved 16-63 code);
/// dropping a legal 10-15 multichannel core would silence recoverable audio.
const DTS_AMODE_COUNT: u32 = 16;
const DTS_LFE_FLAG_INVALID: u32 = 3;
/// Sample rate (Hz) per core `SFREQ` code (ETSI TS 102 114 Table 6-4); a `0`
@@ -716,7 +724,6 @@ enum DropReason {
FrameSize,
Amode,
SampleRate,
ReservedBit,
LfeFlag,
PcmRes,
TrackPoisoned,
@@ -731,7 +738,6 @@ impl DropReason {
DropReason::FrameSize => "frame-size",
DropReason::Amode => "audio-mode",
DropReason::SampleRate => "sample-rate",
DropReason::ReservedBit => "reserved-bit",
DropReason::LfeFlag => "lfe-flag",
DropReason::PcmRes => "pcm-resolution",
DropReason::TrackPoisoned => "track-poisoned",
@@ -755,9 +761,17 @@ impl DropReason {
fn core_header_drop_reason(au: &[u8]) -> Option<DropReason> {
let mut r = BitReader::new(au.get(SYNCWORD_BYTES..)?);
let _ftype = r.read_bit()?;
// FTYPE: 1 = NORMAL frame, 0 = TERMINATION frame (the last frame of the
// stream). Per ETSI TS 102 114 and both reference decoders — ffmpeg's
// `ff_dca_parse_core_frame_header` (`normal_frame && deficit_samples !=
// DCA_PCMBLOCK_SAMPLES`) and dcadec's `parse_frame_header` (which branches
// on `normal_frame`) — the deficit-sample field must equal 32 ONLY for a
// normal frame. A termination frame legitimately carries fewer samples and
// is fully decodable; dropping it would silence the last frame of every
// stream that ends on one (a guaranteed per-track loss on real discs).
let normal_frame = r.read_bit()? == 1;
let deficit_samples = r.read_bits(5)? + 1;
if deficit_samples != DTS_PCMBLOCK_SAMPLES {
if normal_frame && deficit_samples != DTS_PCMBLOCK_SAMPLES {
return Some(DropReason::DeficitSamples);
}
let crc_present = r.read_bit()? == 1;
@@ -778,9 +792,12 @@ fn core_header_drop_reason(au: &[u8]) -> Option<DropReason> {
return Some(DropReason::SampleRate);
}
let _br_code = r.read_bits(5)?;
if r.read_bit()? != 0 {
return Some(DropReason::ReservedBit);
}
// Reserved bit. Both reference decoders SKIP this field rather than reject
// on it — ffmpeg (`skip_bits1`) and dcadec (`bits_skip1`, comment "Reserved
// field"). A frame that sets it is still fully decodable, so rejecting it
// was a false-drop that silenced any real stream whose encoder set the bit.
// Read past it without gating (never reject a decodable frame).
let _reserved = r.read_bit()?;
// drc, ts, aux, hdcd (1 each) → ext_audio_type (3) → ext_present, aspf (1 each).
r.skip_bits(4)?;
r.skip_bits(3)?;
@@ -824,11 +841,12 @@ mod tests {
let fsize = size - 1;
let mut data = vec![0u8; size];
data[0..4].copy_from_slice(&DTS_CORE_SYNC);
// byte4: FTYPE(0) SHORT(5) CPF(0) NBLKS-high(0). SHORT = 31 makes
// deficit_samples = 32 = DTS_PCMBLOCK_SAMPLES, which the decodability
// gate (per ETSI TS 102 114) requires of a real core frame. NBLKS high
// bit (byte4 bit0) stays 0 for NBLKS = 15.
data[4] = 31u8 << 2;
// byte4: FTYPE(1) SHORT(5) CPF(0) NBLKS-high(0). FTYPE = 1 = a NORMAL
// frame (the common real-stream case); SHORT = 31 makes deficit_samples
// = 32 = DTS_PCMBLOCK_SAMPLES, which the decodability gate (per ETSI TS
// 102 114) requires of a normal frame. NBLKS high bit (byte4 bit0) stays
// 0 for NBLKS = 15. (0x80 | (31 << 2) = 0xFC.)
data[4] = 0x80 | (31u8 << 2);
// NBLKS = 15 → (15+1)*32 = 512 samples/frame (the DVD/UHD DTS-core norm).
// NBLKS is byte4 bit0 + byte5 bits7-2; here byte4 bit0 = 0, byte5 = 15<<2.
data[5] = (15u8 << 2) | ((fsize >> 12) & 0x03) as u8;
@@ -1784,22 +1802,24 @@ mod tests {
);
}
/// A structurally-framed but UNDECODABLE core: a valid `make_dts_core`
/// whose reserved header bit is set. It still sizes and syncs correctly (so
/// the framer delimits it normally), but the core-frame header validity
/// check rejects it as a set reserved bit (ETSI TS 102 114). The reserved
/// bit is byte9 bit4 in the core header (after SYNC..RATE).
/// A structurally-framed but UNDECODABLE core: a valid `make_dts_core` whose
/// LFE flag is set to the reserved value 3 (`DTS_LFE_FLAG_INVALID`). It still
/// sizes and syncs correctly (so the framer delimits it normally), but the
/// core-frame header validity check rejects it as an invalid LFE flag (ETSI
/// TS 102 114; dcadec `LFE_FLAG_INVALID`). LFE is byte10 bits2-1, and does
/// NOT feed the frame duration (NBLKS + SFREQ only), so a dropped bad core
/// still carries the same `DTS_CORE_DUR_NS` as its good peers.
fn make_bad_dts_core(size: usize) -> Vec<u8> {
let mut d = make_dts_core(size);
assert!(
core_header_drop_reason(&d).is_none(),
"base core is decodable"
);
d[9] |= 0x10; // set the reserved bit
d[10] |= 0x06; // LFE flag = 3 (invalid)
assert_eq!(
core_header_drop_reason(&d),
Some(DropReason::ReservedBit),
"reserved-bit core must be judged undecodable"
Some(DropReason::LfeFlag),
"invalid-LFE core must be judged undecodable"
);
d
}
@@ -1946,8 +1966,11 @@ mod tests {
d[5] = (d[5] & 0x03) | (14u8 << 2);
assert_eq!(core_header_drop_reason(&d), Some(DropReason::PcmBlocks));
// audio_mode >= 10: AMODE = byte7 bits3-0 (high 4) + byte8 bits7-6. Set
// AMODE high nibble to 0xF → audio_mode >= 60.
// audio_mode reserved (>= 16): AMODE = byte7 bits3-0 (high 4) + byte8
// bits7-6. Set AMODE high nibble to 0xF → audio_mode = 60, a genuinely
// RESERVED code (16-63) a decoder rejects. (Codes 10-15 are LEGAL
// multichannel layouts and must NOT be dropped — see
// legal_multichannel_amode_is_not_dropped.)
let mut d = good.clone();
d[7] |= 0x0F;
assert_eq!(core_header_drop_reason(&d), Some(DropReason::Amode));
@@ -1957,11 +1980,6 @@ mod tests {
d[8] &= !0x3C;
assert_eq!(core_header_drop_reason(&d), Some(DropReason::SampleRate));
// reserved bit set: byte9 bit4.
let mut d = good.clone();
d[9] |= 0x10;
assert_eq!(core_header_drop_reason(&d), Some(DropReason::ReservedBit));
// lfe_present == 3: LFE is byte10 bits2-1.
let mut d = good.clone();
d[10] |= 0x06;
@@ -1975,6 +1993,107 @@ mod tests {
assert_eq!(core_header_drop_reason(&d), Some(DropReason::PcmRes));
}
#[test]
fn legal_multichannel_amode_is_not_dropped() {
// ETSI TS 102 114 §5.3.1: AMODE is a 6-bit field with 16 LEGAL
// channel-arrangement codes (0-15); only 16-63 are reserved. ffmpeg's
// ff_dca_channels[16] = {1,2,2,2,2,3,3,4,4,5,6,6,6,7,8,8} confirms codes
// 10-15 are decodable 6/7/8-channel layouts. The decodability gate must
// KEEP them — dropping a spec-legal multichannel core silences audio the
// recover-100% goal must preserve.
fn set_amode(core: &mut [u8], amode: u32) {
// audio_mode = (byte7 & 0x0F) << 2 | (byte8 >> 6).
core[7] = (core[7] & 0xF0) | ((amode >> 2) & 0x0F) as u8;
core[8] = (core[8] & 0x3F) | (((amode & 0x03) << 6) as u8);
}
// Every legal code 0-15 is kept — the range is a literal (NOT
// DTS_AMODE_COUNT) so reverting the bound to 10 makes 10-15 fail here.
for amode in 0u32..16 {
let mut core = make_dts_core(512);
set_amode(&mut core, amode);
assert_eq!(
core_header_drop_reason(&core),
None,
"legal AMODE {amode} must not be dropped"
);
}
// The first reserved code (16) and above are still rejected.
for amode in [16u32, 40, 63] {
let mut core = make_dts_core(512);
set_amode(&mut core, amode);
assert_eq!(
core_header_drop_reason(&core),
Some(DropReason::Amode),
"reserved AMODE {amode} must be dropped"
);
}
}
/// Set FTYPE (byte4 bit7: 1 = normal, 0 = termination) and the 5-bit SHORT
/// field (byte4 bits6-2), leaving CPF and the NBLKS high bit (bits1-0) intact.
/// `deficit_samples = short_field + 1`.
fn set_ftype_short(core: &mut [u8], normal: bool, short_field: u8) {
core[4] = (core[4] & 0x03) | ((normal as u8) << 7) | ((short_field & 0x1F) << 2);
}
#[test]
fn termination_frame_with_small_deficit_is_kept() {
// ETSI TS 102 114 / ffmpeg (`normal_frame && deficit != 32`) / dcadec:
// a TERMINATION frame (FTYPE=0) may legally carry fewer than 32 deficit
// samples and is fully decodable. It must NOT be dropped — dropping the
// last frame of a stream silences real audio (recover-100% violation).
let mut core = make_dts_core(512);
set_ftype_short(&mut core, false, 10); // termination, deficit = 11 (< 32)
assert_eq!(
core_header_drop_reason(&core),
None,
"a termination frame with a small deficit is decodable and must be kept"
);
// End-to-end: a termination frame closed by a following core survives.
let mut term = make_dts_core(512);
set_ftype_short(&mut term, false, 5); // deficit = 6
let mut stream = term;
stream.extend_from_slice(&make_dts_core(640));
let mut parser = DtsParser::new();
let mut frames = parser.parse(&make_pes(stream, Some(90000)));
frames.extend(parser.flush());
assert_eq!(frames.len(), 2, "termination frame is emitted, not dropped");
assert_eq!(frames[0].data.len(), 512);
assert_eq!(parser.dropped_frames(), 0);
}
#[test]
fn normal_frame_with_wrong_deficit_is_dropped() {
// The other side of the FTYPE gate: a NORMAL frame (FTYPE=1) whose
// deficit-sample field is not 32 is genuinely undecodable and must be
// dropped. Guards against the fix over-relaxing into "never check deficit".
let mut core = make_dts_core(512);
set_ftype_short(&mut core, true, 10); // normal, deficit = 11 (!= 32)
assert_eq!(
core_header_drop_reason(&core),
Some(DropReason::DeficitSamples),
"a normal frame with deficit != 32 is undecodable and must be dropped"
);
}
#[test]
fn reserved_bit_set_is_not_dropped() {
// The bit after RATE is a RESERVED field that both reference decoders
// SKIP (ffmpeg `skip_bits1`, dcadec `bits_skip1` "Reserved field") — they
// never reject a frame that sets it. Rejecting was a false-drop that
// silenced any real stream whose encoder set the bit. Setting it (byte9
// bit4) on an otherwise-valid core must leave it KEPT.
let mut core = make_dts_core(512);
assert_eq!(core_header_drop_reason(&core), None, "baseline decodable");
core[9] |= 0x10; // set the reserved bit
assert_eq!(
core_header_drop_reason(&core),
None,
"a set reserved bit must NOT drop a decodable frame"
);
}
/// Real-data fixture (ignored). Re-parses a raw `.dts` elementary stream
/// through `DtsParser` and writes the emitted access units back out, so the
/// garbage-extension → core-only drop can be validated against an actual
+97
View File
@@ -835,6 +835,103 @@ mod tests {
// `corrupt_major_sync_drops_forward_to_next_valid`).
}
/// Independent bitwise CRC-16 (poly 0x002D, init 0, MSB-first) — a SEPARATE
/// oracle from `crc16_mlp`, so a fixture built with it is not tautological
/// with the validator under test. Anchored to the catalogue check value
/// (0x4FF7 for "123456789") so the oracle itself is proven correct without
/// reference to the code under test.
fn ref_crc16_2d(data: &[u8]) -> u16 {
let mut crc: u16 = 0;
for &b in data {
crc ^= (b as u16) << 8;
for _ in 0..8 {
crc = if crc & 0x8000 != 0 {
(crc << 1) ^ 0x002D
} else {
crc << 1
};
}
}
crc
}
#[test]
fn extended_major_sync_crc_validates_and_rejects() {
// COVERAGE GAP (the endianness bug that once "silently dropped the whole
// track" on real 7.1/Atmos): the EXTENDED major-sync header path
// (ms[25]&1 set, mshdr = 28 + 2 + 2*n) had ZERO test coverage — every
// other fixture builds only the basic 28-byte header. Build an extended
// header whose trailer is an INDEPENDENTLY-computed oracle (ref_crc16_2d,
// NOT crc16_mlp) stored LITTLE-ENDIAN, and assert the validator accepts
// it, rejects a body corruption, and rejects the same trailer stored
// big-endian (which is exactly the endianness-mix regression).
assert_eq!(
ref_crc16_2d(b"123456789"),
0x4FF7,
"oracle anchored to catalogue"
);
// n = 3 extension words → mshdr = 28 + 2 + 2*3 = 36.
let n = 3usize;
let mshdr = 28 + 2 + 2 * n;
assert_eq!(mshdr, 36);
let mut ms = vec![0u8; 40]; // slack past the 36-byte header
// Non-trivial, varied body so the CRC is a meaningful function of it.
for (i, b) in ms.iter_mut().enumerate().take(mshdr - 4) {
*b = (0x37u8).wrapping_add((i as u8).wrapping_mul(0x53));
}
ms[25] |= 1; // extension flag → selects the extended header size
ms[26] = (ms[26] & 0x0F) | ((n as u8) << 4); // extension word count in high nibble
// The 2-byte "penultimate" word (between the CRC-covered body and the
// trailer). Chosen non-zero and non-palindromic so the LE/BE distinction
// is observable.
ms[mshdr - 4] = 0x12;
ms[mshdr - 3] = 0x34;
// Oracle: checksum16 = crc16_2D(body).swap_bytes() ^ le16(penultimate),
// computed with the INDEPENDENT ref CRC, then stored LITTLE-ENDIAN.
let le_word = u16::from_le_bytes([ms[mshdr - 4], ms[mshdr - 3]]);
let trailer = ref_crc16_2d(&ms[..mshdr - 4]).swap_bytes() ^ le_word;
ms[mshdr - 2] = (trailer & 0xFF) as u8;
ms[mshdr - 1] = (trailer >> 8) as u8;
assert_ne!(
ms[mshdr - 2],
ms[mshdr - 1],
"trailer bytes must differ so the LE/BE swap below is a real distinction"
);
// The extended header size is computed from ms[25]/ms[26].
assert_eq!(
mlp_major_sync_header_size(&ms),
Some(mshdr),
"extended header size = 28 + 2 + 2*n"
);
// The validator accepts the independently-built extended major sync.
assert!(
mlp_major_sync_crc_ok(&ms, mshdr),
"valid extended major-sync checksum must validate"
);
// A single corrupted body byte must be rejected.
let mut corrupt = ms.clone();
corrupt[10] ^= 0xFF;
assert!(
!mlp_major_sync_crc_ok(&corrupt, mshdr),
"a corrupted extended major sync must be rejected"
);
// The endianness regression: the SAME checksum stored big-endian must be
// rejected. A validator that reads the trailer big-endian (the shipped
// bug) would instead accept this and reject the correct LE form above.
let mut swapped = ms.clone();
swapped.swap(mshdr - 2, mshdr - 1);
assert!(
!mlp_major_sync_crc_ok(&swapped, mshdr),
"a big-endian-stored trailer must be rejected (little-endian is load-bearing)"
);
}
#[test]
fn parity_failure_is_dropped() {
// A normal AU whose header parity is broken (after a major sync sets
+596 -40
View File
@@ -38,7 +38,9 @@ use crate::pes::{CountingStream, PesFrame, Stream};
use crate::sector::{FileSectorSource, KeyFetch, SectorSource};
use crate::session::DiscSession;
use super::resolve::{InputOptions, StreamUrl, build_iso_pipeline, input, output, parse_url};
use super::resolve::{
InputOptions, StreamUrl, build_iso_pipeline, input, output, parse_url, resolve_mux_key_map,
};
/// Effectively-unbounded per-frame send deadline used when a consumer passes
/// `MuxOptions.send_deadline == None` (the CLI's interactive stdout / network
@@ -109,10 +111,6 @@ pub enum MuxInput<'a> {
format: crate::disc::ContentFormat,
/// Decryption keys for the title (`DecryptKeys::None` for raw/clear).
keys: DecryptKeys,
/// Optional pre-resolved AACS key map. Carried for forward-compat and
/// the live path; the file highway re-derives its own map from
/// `keys`/`key_fetch` inside [`build_iso_pipeline`].
key_map: Option<Arc<AacsKeyMap>>,
/// Optional read-time key fetch closure (banked by `resolve_keys`).
key_fetch: Option<KeyFetch>,
},
@@ -155,6 +153,11 @@ pub enum MuxInput<'a> {
}
/// Tuning / behaviour knobs for a mux run.
///
/// `Default` = keep-everything, no-skip, decrypt, no send deadline — the
/// archival default. Added so callers set only the fields they care about
/// (and so the additive `selection` field doesn't churn every constructor).
#[derive(Default)]
pub struct MuxOptions {
/// Skip past read errors (zero-fill + continue) on the live-drive path
/// instead of aborting. Wired onto `DiscStream::skip_errors`.
@@ -163,6 +166,11 @@ pub struct MuxOptions {
pub batch_sectors: u16,
/// Ciphertext passthrough — skip decryption / CSS self-crack.
pub raw: bool,
/// Which audio/subtitle streams to keep in the muxed title. Default keeps
/// every stream (video is always kept). Applied to the title before the
/// demux pipeline is built, so track headers, `codec_privates`, and frame
/// routing all follow the pruned list. See [`crate::StreamSelection`].
pub selection: crate::StreamSelection,
/// Per-frame write-pipeline send deadline.
///
/// - `Some(d)` — a hard `d` timeout: a sink that back-pressures a single
@@ -215,9 +223,11 @@ pub trait MuxEvents: Send + Sync + 'static {
fn on_read_error(&self, _lba: u32) {}
}
/// A [`MuxEvents`] that ignores everything — for callers that render no
/// progress.
pub struct NoopEvents;
/// A [`MuxEvents`] that ignores everything — test-only (production callers
/// supply their own events sink).
#[cfg(test)]
pub(crate) struct NoopEvents;
#[cfg(test)]
impl MuxEvents for NoopEvents {}
/// The result of a [`mux_stream`] run.
@@ -272,15 +282,26 @@ pub fn mux_stream(
// live `DiscStream`'s read loop — forward `BytesRead`/`SectorSkipped`/
// `BatchSizeChanged`/`ReadError` back to the consumer's handle.
let (stream, playlist_name): (Box<dyn Stream>, Option<String>) = match input_src {
// The Url path builds its demux INSIDE `input()`, which prunes to the
// selected streams via `InputOptions.selection` (which the caller sets).
// Note: `MuxOptions.selection` does NOT apply here — that's the File/
// Session arms' field; a Url-source caller must set `InputOptions.selection`.
MuxInput::Url { url, opts: in_opts } => (input(url, &in_opts)?, None),
MuxInput::Iso {
path,
title,
format,
keys,
key_map: _,
key_fetch,
} => {
// Prune to the selected audio/subtitle streams BEFORE the highway
// builds its demux state from `title.streams` (and before
// `build_iso_pipeline`'s `probe_and_remap` may rewrite DVD AC-3
// PIDs). Video is always kept; a no-op for the default All/All.
let mut title = title;
opts.selection
.apply(&mut title)
.map_err(std::io::Error::from)?;
let reader = FileSectorSource::open(path)?;
let stream = build_iso_pipeline(
reader,
@@ -302,7 +323,7 @@ pub fn mux_stream(
// Pull everything we need out of the disc as owned values so the
// immutable disc borrow is released before the mutable
// `take_reader` below.
let (title, format, keys, playlist) = {
let (mut title, format, mut keys, playlist) = {
let disc = session.disc().ok_or_else(|| Error::DeviceNotReady {
path: session.device_path().to_string(),
})?;
@@ -322,11 +343,36 @@ pub fn mux_stream(
// (see `session_mux_keys`), never the whole-disc `decrypt_keys()`.
(title, disc.content_format, session_mux_keys(disc), playlist)
};
// Prune to the selected streams before `DiscStream::new` builds its
// demux tables from `title.streams` (and before its inline
// `probe_and_remap`). Only touches the stream list, so the
// ciphertext sampling in `resolve_inline_base_map` (keyed on extents)
// is unaffected. No-op for the default All/All.
opts.selection
.apply(&mut title)
.map_err(std::io::Error::from)?;
// A missing staged reader ("already consumed" / never staged) is a
// clean error, not a panic (contract Q2).
let reader = session.take_reader().ok_or_else(|| Error::DeviceNotReady {
let mut reader = session.take_reader().ok_or_else(|| Error::DeviceNotReady {
path: session.device_path().to_string(),
})?;
// Resolve the AACS key map off the STAGED reader BEFORE it is moved
// into `DiscStream::new` (borrow to sample, then move to construct).
// Without this the AACS `DecryptingSectorSource` inside the stream has
// no map and fails `DecryptFailed` on the first content unit — the
// single-pass live-mux decrypt bug. `session.key_fetch()` (retained by
// `resolve_keys`) recovers a multi-CPS/orphan/forensic unit the pool is
// missing. DVD/clear/`raw` resolve to `None` (CSS self-cracks in
// `DiscStream::new`; raw is ciphertext passthrough) — unchanged.
let base_map = resolve_inline_base_map(
&mut *reader,
&title,
&mut keys,
session.key_fetch(),
format,
opts.raw,
Some(halt),
)?;
let mut stream = crate::mux::DiscStream::new(
reader,
title,
@@ -339,6 +385,9 @@ pub fn mux_stream(
if opts.raw {
stream.set_raw();
}
if let Some(map) = base_map {
stream = stream.with_key_map(map);
}
stream.skip_errors = opts.skip_errors;
// Live path: the `DiscStream` emits the full reader-side vocabulary
// (`SectorSkipped` on skip-mode zero-fill, `BatchSizeChanged` on the
@@ -347,12 +396,36 @@ pub fn mux_stream(
(Box::new(stream), Some(playlist))
}
MuxInput::Live {
reader,
mut reader,
title,
format,
keys,
mut keys,
key_map,
} => {
// The map installed BEFORE reads begin. Two sources:
// - A caller-supplied `key_map` (autorip's FMTS gate resolved the
// forensic per-segment map and passes it here) is used VERBATIM —
// never re-resolved.
// - `None` on an AACS disc means a plain (non-FMTS) single/multi-CPS
// disc that the caller did NOT map. Resolve the base map here off the
// live reader, exactly as the `Session` arm and `build_iso_pipeline`
// do — otherwise the AACS `DecryptingSectorSource` has no map and
// fails `DecryptFailed` on the first content unit (the single-pass
// live-mux decrypt bug). Borrow to sample, then move into the stream.
// DVD/clear/`raw` → `None` (unchanged: CSS self-cracks in
// `DiscStream::new`; raw is ciphertext passthrough).
let base_map = match key_map {
Some(map) => Some(map),
None => resolve_inline_base_map(
&mut *reader,
&title,
&mut keys,
None,
format,
opts.raw,
Some(halt),
)?,
};
// INLINE `DiscStream` — the same constructor the `Session` arm uses,
// NOT `build_iso_pipeline` (the prefetch highway). The consumer's
// adaptive batch-retry lives in `DiscStream::fill_extents`, which the
@@ -369,13 +442,14 @@ pub fn mux_stream(
if opts.raw {
stream.set_raw();
}
// Apply the forensic FMTS key map BEFORE reads begin — rewrites the
// extent walk to our-phase units only and installs the map so each
// unit decrypts with its mapped key. `None` leaves the walk unchanged
// (identical to a plain single/multi-CPS disc). Single-pass FMTS
// correctness depends on this: dropping it reads the alternate
// device-group units and mis-decrypts the forensic segment.
if let Some(map) = key_map {
// Apply the key map BEFORE reads begin — for an FMTS forensic map this
// rewrites the extent walk to our-phase units only and installs the map
// so each unit decrypts with its mapped key; for a plain single/multi-CPS
// base map it installs the per-unit content key. `None` leaves the walk
// unchanged (CSS / clear / raw). Single-pass FMTS correctness depends on
// this: dropping the forensic map reads the alternate device-group units
// and mis-decrypts the forensic segment.
if let Some(map) = base_map {
stream = stream.with_key_map(map);
}
stream.skip_errors = opts.skip_errors;
@@ -396,24 +470,6 @@ pub fn mux_stream(
)
}
/// Translate libfreemkv's reader-side [`Event`]s into [`MuxEvents`] calls,
/// producing the `'static` [`EventFn`](crate::sector::prefetched::EventFn) the
/// file highway ([`build_iso_pipeline`]) and the live
/// [`DiscStream`](crate::mux::DiscStream) constructors require. Cloning the
/// `Arc` into the returned closure is precisely what lets a borrowed-lifetime
/// consumer's events reach the highway's producer thread — a `&dyn MuxEvents`
/// borrow cannot satisfy the `'static` bound.
///
/// Mapping (real [`EventKind`] variants):
/// - `BytesRead { bytes, total }` → [`MuxEvents::on_read_progress`] (read-side;
/// the file highway's only reader event — `total` is the extents' byte total)
/// - `SectorSkipped { sector }` → [`MuxEvents::on_sector_skipped`] (live only)
/// - `BatchSizeChanged { new_size, reason }` → [`MuxEvents::on_batch_size_changed`]
/// (live only)
/// - `ReadError { sector, .. }` → [`MuxEvents::on_read_error`]
///
/// Sector numbers are the library's `u64`; the `MuxEvents` LBA hooks take `u32`
/// (the disc's LBA space), so they are narrowed with `as u32`.
/// Decrypt keys for the live `Session` mux of `disc`.
///
/// A DVD is handed [`DecryptKeys::None`] so [`DiscStream::new`](crate::mux::DiscStream)
@@ -433,6 +489,74 @@ fn session_mux_keys(disc: &crate::disc::Disc) -> DecryptKeys {
}
}
/// Resolve the base AACS key map for an INLINE live-drive mux (the `Session` /
/// `Live` arms) BEFORE the reader is moved into [`DiscStream::new`] — the
/// counterpart to the map resolution [`build_iso_pipeline`] performs internally
/// for the file highway.
///
/// Under the map-only decrypt model an AACS [`DecryptingSectorSource`] decrypts
/// NOTHING until a key map is installed: with no map the AACS arm of the decrypt
/// path fails loud with [`Error::DecryptFailed`] on the first content unit (the
/// deliberate "a reader built without its map is a bug" guard). Both inline arms
/// used to skip this — `Session` installed no map at all, and `Live` installed
/// only a caller-supplied forensic FMTS map (`None` for a plain AACS disc) — so
/// EVERY plain AACS Blu-ray/UHD muxed via the live single-pass path failed
/// `DecryptFailed` on the first content read. This resolves + returns the map so
/// the caller can install it via [`DiscStream::with_key_map`].
///
/// - AACS keys → resolve (`resolve_mux_key_map`: single-CPS content map,
/// multi-CPS per-extent key selection, or FMTS per-segment map) and return
/// `Some(map)`. Resolution failure propagates (fail loud), matching the ISO
/// path's decrypt gate.
/// - CSS / clear / `None` → `Ok(None)`: CSS self-cracks per title inside
/// [`DiscStream::new`], and a genuinely-clear disc needs no map.
/// - `raw` → `Ok(None)`: ciphertext passthrough, no decrypt step to key.
///
/// The `reader` is borrowed only to SAMPLE ciphertext here (the UDF/FMTS probe
/// and any multi-CPS unit samples); a single-CPS disc — the overwhelming
/// majority, including every single-key UHD — resolves its map with NO content
/// read beyond the one-time UDF filesystem probe. The caller then moves the same
/// reader into [`DiscStream::new`]; reads are by absolute LBA, so the sampling
/// leaves no read-position state behind.
fn resolve_inline_base_map(
reader: &mut dyn SectorSource,
title: &DiscTitle,
keys: &mut DecryptKeys,
fetch: Option<&KeyFetch>,
format: crate::disc::ContentFormat,
raw: bool,
halt: Option<&crate::halt::Halt>,
) -> std::io::Result<Option<Arc<AacsKeyMap>>> {
if raw || !matches!(keys, DecryptKeys::Aacs { .. }) {
return Ok(None);
}
// Thread the driver's cancel token into the live-drive key resolution: the
// resolve chain samples ciphertext off the LIVE reader (the FMTS probe can do
// hundreds of reads, each able to stall to the SCSI recovery timeout), so an
// operator `/api/stop` mid-resolution must be honored here — not only once the
// read loop starts.
let map = resolve_mux_key_map(reader, title, keys, fetch, format, halt)?;
Ok(Some(Arc::new(map)))
}
/// Translate libfreemkv's reader-side [`Event`]s into [`MuxEvents`] calls,
/// producing the `'static` [`EventFn`](crate::sector::prefetched::EventFn) the
/// file highway ([`build_iso_pipeline`]) and the live
/// [`DiscStream`](crate::mux::DiscStream) constructors require. Cloning the
/// `Arc` into the returned closure is precisely what lets a borrowed-lifetime
/// consumer's events reach the highway's producer thread — a `&dyn MuxEvents`
/// borrow cannot satisfy the `'static` bound.
///
/// Mapping (real [`EventKind`] variants):
/// - `BytesRead { bytes, total }` → [`MuxEvents::on_read_progress`] (read-side;
/// the file highway's only reader event — `total` is the extents' byte total)
/// - `SectorSkipped { sector }` → [`MuxEvents::on_sector_skipped`] (live only)
/// - `BatchSizeChanged { new_size, reason }` → [`MuxEvents::on_batch_size_changed`]
/// (live only)
/// - `ReadError { sector, .. }` → [`MuxEvents::on_read_error`]
///
/// Sector numbers are the library's `u64`; the `MuxEvents` LBA hooks take `u32`
/// (the disc's LBA space), so they are narrowed with `as u32`.
fn reader_event_fn(events: Arc<dyn MuxEvents>) -> crate::sector::prefetched::EventFn {
Box::new(move |e: Event| match e.kind {
EventKind::BytesRead { bytes, total } => events.on_read_progress(bytes, total),
@@ -448,6 +572,20 @@ fn reader_event_fn(events: Arc<dyn MuxEvents>) -> crate::sector::prefetched::Eve
/// The reader-agnostic driver body: headers → gate → sink → pump → finish.
/// Split out so it can be unit-tested against a synthetic [`Stream`] (the
/// injection seam), independent of which constructor built `stream`.
/// Whether a finished mux counts as COMPLETED. A clean operator stop
/// (`interrupted`), a wedged/halted finalize (`finalize_failed` — the write
/// [`Pipeline`] returned `Halted`/`PipelineJoinTimeout` from `finish`), or a
/// halt cancellation each force `completed = false`, so the consumer runs its
/// stop-preserves-staging path instead of reporting a truncated file as done.
///
/// Extracted as a pure fn because the `finalize_failed` branch is otherwise
/// reachable only through real write-thread wedge timing (the internally-built
/// `WriteSink` offers no seam to force a `finish` timeout deterministically), so
/// the mapping is unit-tested here directly.
fn mux_run_completed(interrupted: bool, finalize_failed: bool, halt_cancelled: bool) -> bool {
!(interrupted || finalize_failed || halt_cancelled)
}
fn drive_mux(
mut stream: Box<dyn Stream>,
dest_url: &str,
@@ -646,7 +784,7 @@ fn drive_mux(
Err(e) => return Err(e.into()),
};
if interrupted || finalize_failed || halt.is_cancelled() {
if !mux_run_completed(interrupted, finalize_failed, halt.is_cancelled()) {
return Ok(MuxOutcome {
completed: false,
output_opened: true,
@@ -1202,6 +1340,7 @@ mod tests {
batch_sectors: 8192,
raw: false,
send_deadline: Some(Duration::from_secs(60)),
selection: Default::default(),
};
let halt = Halt::new();
let out = mux_stream(
@@ -1210,7 +1349,6 @@ mod tests {
title,
format: crate::disc::ContentFormat::BdTs,
keys: DecryptKeys::None,
key_map: None,
key_fetch: None,
},
"null://",
@@ -1311,6 +1449,7 @@ mod tests {
batch_sectors: us as u16,
raw: false,
send_deadline: Some(Duration::from_secs(60)),
selection: Default::default(),
};
let halt = Halt::new();
// Drains to a NoStreams refusal (zeroed data resolves no headers); we
@@ -1346,6 +1485,421 @@ mod tests {
);
}
/// A `SectorSource` that serves ONE genuinely-AACS-encrypted aligned unit
/// (6144 bytes) at LBA 0..3 and zeros everywhere else — enough for the
/// map-only decrypt path to prove itself end-to-end. Zeros elsewhere make the
/// UDF filesystem probe inside `resolve_mux_key_map` fail cleanly (→ not an
/// FMTS disc, single-CPS base map).
struct AacsUnitReader {
unit: Vec<u8>, // 6144 bytes, encrypted
capacity: u32,
}
impl crate::sector::SectorSource for AacsUnitReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
let bytes = count as usize * 2048;
buf[..bytes].fill(0);
// The content unit lives at LBA 0..3; serve it whenever a read starts
// there (the inline `DiscStream` reads the [0,3) extent as one batch).
if lba == 0 && bytes >= self.unit.len() {
buf[..self.unit.len()].copy_from_slice(&self.unit);
}
Ok(bytes)
}
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
/// Build one AACS-encrypted BD-TS aligned unit whose plaintext is a single
/// audio PES (the same clip the ISO test muxes), encrypted under `unit_key`.
fn encrypted_audio_unit(unit_key: &[u8; 16]) -> Vec<u8> {
let es = [0xDE, 0xAD, 0xBE, 0xEF, 0x11, 0x22];
let pkt = bdts_data_packet(0x1100, true, &audio_pes(&es));
let mut unit = vec![0u8; 3 * 2048]; // one 6144-byte aligned unit
unit[..192].copy_from_slice(&pkt);
crate::aacs::content::aacs_encrypt_unit_for_test(&mut unit, unit_key);
unit
}
/// END-TO-END decrypt on the live single-pass `MuxInput::Live` path with a
/// plain (non-FMTS) AACS disc and NO caller-supplied key map — the exact shape
/// of `freemkv rip disc://…` and autorip's non-FMTS single-pass. The driver's
/// `Live` arm must RESOLVE + INSTALL the base AACS key map itself; the unit
/// then decrypts to a valid audio PES and the mux drains and finalises.
///
/// This is the regression guard for the confirmed bug: without the map the
/// AACS `DecryptingSectorSource` fails `DecryptFailed` on the first content
/// unit and no AACS disc could ever be live-muxed.
///
/// Mutation: deleting the `resolve_inline_base_map` call (or the
/// `stream = stream.with_key_map(map)` install) in the `Live` arm leaves the
/// reader mapless → the first content batch cannot decrypt (root cause
/// `DecryptFailed`, surfaced through `fill_extents`' non-skip read-error path
/// as a `DiscRead`) → `mux_stream` returns `Err` and `out.completed` is never
/// reached (verified: the mux aborts instead of finalising).
#[test]
fn mux_input_live_aacs_without_caller_map_resolves_and_decrypts() {
use crate::disc::Extent;
let unit_key = [0x5Au8; 16];
let reader = Box::new(AacsUnitReader {
unit: encrypted_audio_unit(&unit_key),
capacity: 2048,
});
let mut title = aac_audio_title(0x1100);
title.extents = vec![Extent {
start_lba: 0,
sector_count: 3,
}];
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, unit_key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let opts = MuxOptions {
skip_errors: false, // a DecryptFailed must PROPAGATE, not zero-fill
batch_sectors: 3, // one aligned unit per read
raw: false,
send_deadline: Some(Duration::from_secs(60)),
selection: Default::default(),
};
let halt = Halt::new();
let out = mux_stream(
MuxInput::Live {
reader,
title,
format: crate::disc::ContentFormat::BdTs,
keys,
key_map: None, // plain AACS disc: the driver must resolve the base map
},
"null://",
&opts,
&halt,
Arc::new(NoopEvents),
)
.expect(
"a plain AACS live mux must resolve+install its base key map and DECRYPT \
(no map the content batch fails to decrypt and the mux aborts)",
);
assert!(
out.completed,
"the decrypted audio PES drained and finalised — proves the unit decrypted"
);
assert!(
out.bytes_written > 0,
"decrypted payload bytes reached the sink"
);
}
/// Build a synthetic single-CPS AACS `Disc` carrying `unit_key` and one
/// title whose sole extent is the encrypted unit at LBA 0..3 — the disc a
/// `MuxInput::Session` mux scans off a live drive, minus the hardware.
fn aacs_session_disc(title: DiscTitle, unit_key: [u8; 16]) -> crate::disc::Disc {
crate::disc::Disc {
volume_id: "TEST".into(),
meta_title: None,
format: crate::DiscFormat::Uhd,
capacity_sectors: 0,
capacity_bytes: 0,
layers: 1,
titles: vec![title],
region: crate::disc::DiscRegion::Free,
aacs: Some(crate::disc::AacsState {
version: crate::aacs::mkb::AACS_MAJOR_UHD,
bus_encryption: false,
mkb_version: None,
disc_hash: "0xabc".into(),
key_source: crate::disc::KeyOrigin::KeyDb,
vuk: None,
unit_keys: vec![(0, unit_key)],
read_data_key: None,
volume_id: [0u8; 16],
uk_ro: Vec::new(),
mkb: Vec::new(),
}),
css: None,
encrypted: true,
aacs_error: None,
css_error: None,
content_format: crate::ContentFormat::BdTs,
}
}
/// END-TO-END decrypt on the live single-pass `MuxInput::Session` path — the
/// exact shape of `freemkv rip disc://…mkv`. The `Session` arm runs the SAME
/// sequence as `Live` (take_reader → resolve_inline_base_map → DiscStream →
/// with_key_map), but until now had NO end-to-end coverage because a real
/// `DiscSession` needs a live `Drive`. Using the `#[cfg(test)]`
/// `from_parts_for_test` constructor, a genuinely-AACS-encrypted unit muxed
/// through the `Session` arm must resolve+install the base key map itself and
/// DECRYPT to a valid audio PES.
///
/// Mutation: dropping `stream = stream.with_key_map(map)` (or the
/// resolve_inline_base_map call) in the `Session` arm leaves the reader
/// mapless → the content batch cannot decrypt → the mux aborts (`Err`) and
/// `out.completed` is never reached.
#[test]
fn mux_input_session_aacs_without_caller_map_resolves_and_decrypts() {
use crate::disc::Extent;
use crate::session::DiscSession;
let unit_key = [0x5Au8; 16];
let reader = Box::new(AacsUnitReader {
unit: encrypted_audio_unit(&unit_key),
capacity: 2048,
});
let mut title = aac_audio_title(0x1100);
title.extents = vec![Extent {
start_lba: 0,
sector_count: 3,
}];
let disc = aacs_session_disc(title, unit_key);
// No caller key_fetch: a single-CPS disc resolves its base map with the
// banked unit key alone (the FMTS/multi-CPS fetch path is not exercised).
let mut session = DiscSession::from_parts_for_test(Some(disc), Some(reader), None);
let opts = MuxOptions {
skip_errors: false, // a DecryptFailed must PROPAGATE, not zero-fill
batch_sectors: 3, // one aligned unit per read
raw: false,
send_deadline: Some(Duration::from_secs(60)),
selection: Default::default(),
};
let halt = Halt::new();
let out = mux_stream(
MuxInput::Session {
session: &mut session,
title_index: 0,
},
"null://",
&opts,
&halt,
Arc::new(NoopEvents),
)
.expect(
"a plain AACS Session mux must resolve+install its base key map and DECRYPT \
(no map the content batch fails to decrypt and the mux aborts)",
);
assert!(
out.completed,
"the decrypted audio PES drained and finalised — proves the unit decrypted"
);
assert!(
out.bytes_written > 0,
"decrypted payload bytes reached the sink"
);
}
/// A `MuxInput::Session` whose reader was never staged (`take_reader()` →
/// `None`) must surface a clean typed error, NOT panic — the boundary-audit
/// Q2 contract. Guards the `ok_or_else(|| Error::DeviceNotReady …)` in the
/// `Session` arm against a regression to `.expect()`/`.unwrap()`.
#[test]
fn mux_input_session_missing_reader_is_clean_error_not_panic() {
use crate::disc::Extent;
use crate::session::DiscSession;
let unit_key = [0x5Au8; 16];
let mut title = aac_audio_title(0x1100);
title.extents = vec![Extent {
start_lba: 0,
sector_count: 3,
}];
let disc = aacs_session_disc(title, unit_key);
// reader: None — never staged.
let mut session = DiscSession::from_parts_for_test(Some(disc), None, None);
let opts = MuxOptions {
skip_errors: false,
batch_sectors: 3,
raw: false,
send_deadline: Some(Duration::from_secs(60)),
selection: Default::default(),
};
let halt = Halt::new();
let err = mux_stream(
MuxInput::Session {
session: &mut session,
title_index: 0,
},
"null://",
&opts,
&halt,
Arc::new(NoopEvents),
)
.expect_err("a missing staged reader must be a clean error, not a panic");
// The device-name-carrying DeviceNotReady (code E4xxx) round-trips through
// io::Error; assert it is NOT a decrypt/other-shaped failure.
assert!(
err.to_string().starts_with('E'),
"expected a typed libfreemkv error (E<code>…), got: {err}"
);
}
/// FIX 3: a mux whose read side drained cleanly (`interrupted = false`, halt
/// not cancelled) but whose write pipeline WEDGED on finish (`finish_with_halt`
/// → `Err(Halted | PipelineJoinTimeout)` → `finalize_failed = true`) must fall
/// through to `completed = false` — never surface a truncated file as a
/// finished rip. The wedge is reachable only via real write-thread timing, so
/// the completion mapping is tested via the extracted pure fn.
///
/// Mutation: dropping `finalize_failed` from `mux_run_completed`'s condition
/// makes `mux_run_completed(false, true, false)` return `true` → this fails.
#[test]
fn finalize_failed_forces_incomplete_outcome() {
// The load-bearing case: clean drain, wedged finalize → NOT completed.
assert!(
!mux_run_completed(false, true, false),
"a wedged/halted finalize must force completed = false"
);
// A fully clean finish is the only path to completed = true.
assert!(
mux_run_completed(false, false, false),
"a clean drain + clean finalize completes"
);
// The other two forcers likewise yield incomplete.
assert!(
!mux_run_completed(true, false, false),
"operator stop → incomplete"
);
assert!(
!mux_run_completed(false, false, true),
"halt cancel → incomplete"
);
}
/// FIX 4: `MuxInput::Session` with a `title_index` past the disc's title count
/// must surface a clean `Error::MuxTrackRange` (code E9011), NOT panic on the
/// out-of-range `titles.get(idx)`. Everything else is valid (disc scanned,
/// reader staged) so the range guard is the sole failure.
///
/// Mutation: replacing the `.ok_or(MuxTrackRange…)?` guard with `.unwrap()`
/// panics on the out-of-range index → this test fails.
#[test]
fn mux_input_session_out_of_range_title_is_clean_error_not_panic() {
use crate::disc::Extent;
use crate::session::DiscSession;
let unit_key = [0x5Au8; 16];
let reader = Box::new(AacsUnitReader {
unit: encrypted_audio_unit(&unit_key),
capacity: 2048,
});
let mut title = aac_audio_title(0x1100);
title.extents = vec![Extent {
start_lba: 0,
sector_count: 3,
}];
let disc = aacs_session_disc(title, unit_key);
let num_titles = disc.titles.len();
let mut session = DiscSession::from_parts_for_test(Some(disc), Some(reader), None);
let opts = MuxOptions {
skip_errors: false,
batch_sectors: 3,
raw: false,
send_deadline: Some(Duration::from_secs(60)),
selection: Default::default(),
};
let halt = Halt::new();
let err = mux_stream(
MuxInput::Session {
session: &mut session,
title_index: num_titles + 5, // out of range
},
"null://",
&opts,
&halt,
Arc::new(NoopEvents),
)
.expect_err("an out-of-range title index must be a clean error, not a panic");
// MuxTrackRange renders as "E9011: track/tracks".
assert!(
err.to_string().contains("E9011"),
"expected MuxTrackRange (E9011), got: {err}"
);
}
/// The shared `resolve_inline_base_map` helper's gating: an AACS key set
/// yields a map (Some); CSS/clear/None and `raw` yield None (CSS self-cracks
/// in `DiscStream::new`; raw is ciphertext passthrough). Guards the Session
/// and Live arms against accidentally mapping a DVD (which would suppress the
/// per-title CSS crack) or resolving under `--raw`.
#[test]
fn resolve_inline_base_map_gates_on_aacs_and_raw() {
use crate::disc::Extent;
let unit_key = [0x5Au8; 16];
let mut title = aac_audio_title(0x1100);
title.extents = vec![Extent {
start_lba: 0,
sector_count: 3,
}];
let mk_reader = || AacsUnitReader {
unit: encrypted_audio_unit(&unit_key),
capacity: 2048,
};
// AACS, not raw → a map is resolved and installed.
let mut r = mk_reader();
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, unit_key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let map = resolve_inline_base_map(
&mut r,
&title,
&mut keys,
None,
crate::disc::ContentFormat::BdTs,
false,
None,
)
.expect("resolve must not error for a single-CPS AACS disc");
assert!(map.is_some(), "AACS non-raw must resolve a base map");
// AACS but raw → no map (ciphertext passthrough).
let mut r = mk_reader();
let mut keys_raw = DecryptKeys::Aacs {
unit_keys: vec![(0, unit_key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let map_raw = resolve_inline_base_map(
&mut r,
&title,
&mut keys_raw,
None,
crate::disc::ContentFormat::BdTs,
true,
None,
)
.expect("raw resolve is a no-op");
assert!(map_raw.is_none(), "raw must NOT resolve a map");
// CSS / clear (DecryptKeys::None) → no map (CSS self-cracks per title).
let mut r = mk_reader();
let mut keys_none = DecryptKeys::None;
let map_none = resolve_inline_base_map(
&mut r,
&title,
&mut keys_none,
None,
crate::disc::ContentFormat::MpegPs,
false,
None,
)
.expect("clear/CSS resolve is a no-op");
assert!(map_none.is_none(), "CSS/clear must NOT resolve an AACS map");
}
// ── Regression A: header-buffer cap fails fast instead of OOM ───────────
//
// A stream whose headers never resolve but that keeps yielding frames must
@@ -1544,6 +2098,7 @@ mod tests {
batch_sectors: 0,
raw: false,
send_deadline: None,
selection: Default::default(),
};
assert_eq!(effective_send_deadline(cli.send_deadline), NO_SEND_DEADLINE);
let autorip = MuxOptions {
@@ -1551,6 +2106,7 @@ mod tests {
batch_sectors: 8192,
raw: false,
send_deadline: Some(Duration::from_secs(60)),
selection: Default::default(),
};
assert_eq!(
effective_send_deadline(autorip.send_deadline),
+2 -1
View File
@@ -28,6 +28,7 @@ pub mod disc;
pub mod driver;
pub mod pipelined_stream;
pub mod resolve;
pub mod select;
// Internal-only modules. Every reference is via `crate::mux::…` /
// `super::…` from inside the crate; nothing in the downstream crates or
@@ -115,7 +116,7 @@ pub(crate) mod videomap;
// direct `super::demux_sink::` / `super::fvi_sink::` paths — no re-export needed,
// and no consumer names these types, so they are not public API.
pub use disc::DiscStream;
pub use driver::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, NoopEvents, mux_stream};
pub use driver::{MuxEvents, MuxInput, MuxOptions, MuxOutcome, mux_stream};
pub use m2ts::M2tsStream;
pub use mkvstream::MkvStream;
pub use mp4::{Mp4FitReport, Mp4SkipReason, fit_report as mp4_fit_report};
+37 -2
View File
@@ -239,8 +239,12 @@ const DTS_SFREQ: [u32; 16] = [
48_000, 8_000, 16_000, 32_000, 48_000, 48_000, 11_025, 22_050, 44_100, 48_000, 48_000, 12_000,
24_000, 48_000, 96_000, 192_000,
];
/// DTS core base channel count per `AMODE` (0..=9); higher AMODEs are rare on disc.
const DTS_AMODE_CH: [u8; 10] = [1, 2, 2, 2, 2, 3, 3, 4, 4, 5];
/// DTS core base channel count per `AMODE` (all 16 defined values). Matches the
/// reference `ff_dca_channels[16]` table (ETSI TS 102 114) that the decodability
/// gate in `dts.rs` (`DTS_AMODE_COUNT`) also uses, so a spec-legal DTS-ES / 6.1 /
/// 7.1 core (AMODE 13→7, 14/15→8) is DECLARED with its true channel count in the
/// mp4 AudioSampleEntry / `ddts` box rather than a truncated 6.
const DTS_AMODE_CH: [u8; 16] = [1, 2, 2, 2, 2, 3, 3, 4, 4, 5, 6, 6, 6, 7, 8, 8];
/// Decoded DTS core parameters needed for the `ddts` box.
struct DtsConfig {
@@ -557,6 +561,37 @@ mod tests {
assert_eq!(&e[4..8], b"dtsh");
}
#[test]
fn dts_high_amode_channel_counts_are_declared() {
// The 16-entry DTS_AMODE_CH must declare the true core channel count for the
// spec-legal high AMODEs that now pass the decodability gate: AMODE 13→7,
// 14→8, 15→8 (ETSI TS 102 114 / ff_dca_channels). The old 10-entry table
// fell through `unwrap_or(6)` → every one of these was declared as 6.
//
// Frame layout (mirrors dts_core_5_1_and_ddts): SFREQ=13 (48k), LFF=0 (no
// LFE) so `channels` is the bare base count. AMODE is split across
// f[7] low nibble (amode>>2) and f[8] top 2 bits (amode&3).
// f[8] = (amode&3)<<6 | 13<<2 = ... (keeps SFREQ=13)
let frame = |f7: u8, f8: u8| {
vec![
0x7F, 0xFE, 0x80, 0x01, 0x00, 0x05, 0xF2, f7, f8, 0x00, 0x00, 0x00,
]
};
// AMODE 13 → base 7 channels.
let c = parse_dts(&frame(0xF3, 0x74)).expect("amode 13 parses");
assert_eq!(c.amode, 13);
assert!(!c.lfe);
assert_eq!(c.channels, 7, "AMODE 13 core is 7 channels, not 6");
// AMODE 14 → base 8 channels.
let c = parse_dts(&frame(0xF3, 0xB4)).expect("amode 14 parses");
assert_eq!(c.amode, 14);
assert_eq!(c.channels, 8, "AMODE 14 core is 8 channels, not 6");
// AMODE 15 → base 8 channels.
let c = parse_dts(&frame(0xF3, 0xF4)).expect("amode 15 parses");
assert_eq!(c.amode, 15);
assert_eq!(c.channels, 8, "AMODE 15 core is 8 channels, not 6");
}
#[test]
fn sample_entry_samplerate_does_not_overflow_at_96k() {
// 96 kHz > 65535: the 16.16 integer part must saturate, not wrap to garbage.
+3 -2
View File
@@ -158,8 +158,9 @@ pub struct Mp4FitReport {
}
/// Compute the fit plan without opening a file. Video: the first primary
/// HEVC/H.264 track. Audio: every AC-3 / E-AC-3 track. Everything else is
/// skipped with a reason.
/// HEVC/H.264 track. Audio: every track `audio::audio_fits` carries — the Dolby
/// family (AC-3 / E-AC-3) and DTS (core / DTS-HD HRA / DTS-HD MA). Everything
/// else is skipped with a reason.
pub fn fit_report(title: &DiscTitle) -> Mp4FitReport {
let mut included = Vec::new();
let mut skipped = Vec::new();
+57
View File
@@ -201,6 +201,15 @@ impl<R: Read + Seek> Mp4Reader<R> {
let durations = find_box(stbl, b"stts")
.map(|b| parse_stts(b, n))
.unwrap_or_default();
if durations.is_empty() {
// Samples exist but there is no decoding-time table: `stts` is
// mandatory in a valid stbl (ISO/IEC 14496-12 §8.6.1). Without it
// every sample would take dur=0 → all-zero, identical timestamps,
// collapsing the whole track onto one instant. Drop the track
// rather than emit degenerate timing (mirrors the stco/stsc
// guards above); an all-tracks-dropped file fails Mp4Invalid below.
continue;
}
let ctts = find_box(stbl, b"ctts")
.map(|b| parse_ctts(b, n))
.unwrap_or_default();
@@ -1216,11 +1225,21 @@ mod tests {
p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx
mp4_box(b"stsc", &p)
};
let stts = {
// Mandatory time-to-sample box: 1 entry → 1 sample × 1000 ticks.
let mut p = Vec::new();
p.extend_from_slice(&[0, 0, 0, 0]); // version+flags
p.extend_from_slice(&1u32.to_be_bytes()); // entry_count
p.extend_from_slice(&1u32.to_be_bytes()); // sample_count
p.extend_from_slice(&1000u32.to_be_bytes()); // sample_delta
mp4_box(b"stts", &p)
};
let mut stbl = Vec::new();
stbl.extend_from_slice(&stsd);
stbl.extend_from_slice(&stsz);
stbl.extend_from_slice(&stco);
stbl.extend_from_slice(&stsc);
stbl.extend_from_slice(&stts);
let minf = mp4_box(b"minf", &mp4_box(b"stbl", &stbl));
let mut mdia = Vec::new();
mdia.extend_from_slice(&mdhd);
@@ -1319,11 +1338,20 @@ mod tests {
p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx
mp4_box(b"stsc", &p)
};
let stts = {
let mut p = Vec::new();
p.extend_from_slice(&[0, 0, 0, 0]); // version+flags
p.extend_from_slice(&1u32.to_be_bytes()); // entry_count
p.extend_from_slice(&1u32.to_be_bytes()); // sample_count
p.extend_from_slice(&1000u32.to_be_bytes()); // sample_delta
mp4_box(b"stts", &p)
};
let mut stbl = Vec::new();
stbl.extend_from_slice(&stsd);
stbl.extend_from_slice(&stsz);
stbl.extend_from_slice(&stco);
stbl.extend_from_slice(&stsc);
stbl.extend_from_slice(&stts);
let minf = mp4_box(b"minf", &mp4_box(b"stbl", &stbl));
let mut mdia = Vec::new();
mdia.extend_from_slice(&mdhd);
@@ -1397,6 +1425,15 @@ mod tests {
p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx
mp4_box(b"stsc", &p)
};
let stts = {
// Mandatory time-to-sample box: 3 entries' worth via one run (3×1000).
let mut p = Vec::new();
p.extend_from_slice(&[0, 0, 0, 0]); // version+flags
p.extend_from_slice(&1u32.to_be_bytes()); // entry_count
p.extend_from_slice(&3u32.to_be_bytes()); // sample_count
p.extend_from_slice(&1000u32.to_be_bytes()); // sample_delta
mp4_box(b"stts", &p)
};
let mut stbl = Vec::new();
stbl.extend_from_slice(&stsd);
stbl.extend_from_slice(&stsz);
@@ -1406,6 +1443,9 @@ mod tests {
if omit != b"stsc" {
stbl.extend_from_slice(&stsc);
}
if omit != b"stts" {
stbl.extend_from_slice(&stts);
}
let minf = mp4_box(b"minf", &mp4_box(b"stbl", &stbl));
let mut mdia = Vec::new();
mdia.extend_from_slice(&mdhd);
@@ -1446,6 +1486,23 @@ mod tests {
);
}
/// A track with samples (`stsz`) but no time-to-sample table (`stts`, mandatory
/// per ISO/IEC 14496-12 §8.6.1) must be DROPPED — without it every sample takes
/// dur=0, collapsing the whole track onto one instant (all-zero timestamps).
/// With it the only track, the file fails `Mp4Invalid`.
/// Mutation check: delete the `if durations.is_empty() { continue; }` guard and
/// `from_reader` returns `Ok` (all-zero-timestamp samples), flipping this to FAIL.
#[test]
fn missing_stts_drops_track_all_dropped_is_invalid() {
use std::io::Cursor;
let moov = mp4_box(b"moov", &audio_trak_missing(b"stts"));
let rd = Mp4Reader::from_reader(Cursor::new(moov), "no-stts".into());
assert!(
rd.is_err(),
"a track with stsz but no stts must be dropped; all-dropped → Mp4Invalid"
);
}
/// Sanity companion: the SAME builder WITH both tables present yields a valid,
/// indexed single-track file — proving the two Err results above come from the
/// missing table, not from some unrelated defect in the fixture builder.
+810 -50
View File
@@ -330,6 +330,12 @@ pub struct InputOptions {
/// still-scrambled unit is re-tried via the application's key source.
/// Application seam only; the library makes no network call.
pub key_fetch: Option<crate::sector::KeyFetch>,
/// Which audio/subtitle streams to keep. `input()` scans the source and
/// picks the title internally, so the caller can't prune the `DiscTitle`
/// itself — it passes the selection here and `input()` applies it right
/// after the title-index bounds check. Default keeps every stream (video is
/// always kept). See [`crate::StreamSelection`].
pub selection: crate::StreamSelection,
}
// `KeyFetchFactory` holds a trait object that is not `Debug`; hand-roll the
@@ -342,6 +348,7 @@ impl std::fmt::Debug for InputOptions {
.field("title_index", &self.title_index)
.field("raw", &self.raw)
.field("key_fetch", &self.key_fetch.is_some())
.field("selection", &self.selection)
.finish()
}
}
@@ -403,6 +410,14 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
}
.into());
}
// Prune to the selected audio/subtitle streams now, on the scanned
// (pre-`probe_and_remap`) title, so everything downstream — the
// TrueHD channel-correction probe, the final title clone, and
// `build_iso_pipeline`'s demux/track construction — sees the pruned
// list. Video is always kept; a no-op for the default All/All.
opts.selection
.apply(&mut disc.titles[idx])
.map_err(|e| -> io::Error { e.into() })?;
// Per-title key resolution. DVD CSS is resolved at exactly ONE site —
// `build_iso_pipeline`'s per-title crack (below), which decrypts a
// crackable title, passes a genuinely-clear one through, and
@@ -458,6 +473,9 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
&mut probe_keys,
opts.key_fetch.as_ref(),
disc.content_format,
// File-backed, bounded probe (best-effort `.ok()`);
// no live drive to protect from a stuck stop here.
None,
)
.ok()
.map(std::sync::Arc::new),
@@ -769,16 +787,43 @@ fn resolve_fmts_key_map(
keys: &mut crate::decrypt::DecryptKeys,
fetch: Option<&crate::sector::KeyFetch>,
format: ContentFormat,
halt: Option<&crate::halt::Halt>,
) -> io::Result<Option<crate::decrypt::AacsKeyMap>> {
use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_encrypted, decrypt_unit, is_clean};
use crate::aacs::content::ALIGNED_UNIT_LEN;
use crate::aacs::segment::{clip_byte_to_lba, parse_individual_segments};
// Load the segment map; absent → not an FMTS disc.
let Ok(udf) = crate::udf::read_filesystem(reader) else {
return Ok(None);
// Cooperative cancel: this probes the LIVE drive across up to a few hundred
// `read_sectors` (the anchor + per-index probe loops), each able to stall to
// the SCSI recovery timeout. An operator `/api/stop` during forensic key
// resolution must be honored at each loop boundary rather than blocking until
// the whole probe completes (hard rule: don't hammer a struggling live drive).
let check_halt = || -> io::Result<()> {
if halt.is_some_and(|h| h.is_cancelled()) {
return Err(crate::error::Error::Halted.into());
}
Ok(())
};
let Ok(tbl) = udf.read_file(reader, "/AACS/IndividualSegment.tbl") else {
return Ok(None);
// Load the segment map. Distinguish a genuine "not an FMTS disc" negative
// from a transient live-drive I/O fault: swallowing the latter into Ok(None)
// would fall through to a base-Unit-Key-only map, garble the forensic units,
// let the demux drop them, and complete the mux with NO error — silently
// losing forensic content, contradicting this function's fail-loud contract.
// - `UdfNotFilesystem`: bytes read fine but are not a UDF disc (deterministic
// tag/format mismatch) → genuinely not FMTS → Ok(None).
// - `UdfNotFound`: the disc is UDF but has no `IndividualSegment.tbl`
// → genuinely not FMTS → Ok(None).
// - any other error (notably `DiscRead`): a read fault → propagate so the
// rip fails loud / can be retried rather than dropping forensic content.
let udf = match crate::udf::read_filesystem(reader) {
Ok(u) => u,
Err(crate::error::Error::UdfNotFilesystem) => return Ok(None),
Err(e) => return Err(e.into()),
};
let tbl = match udf.read_file(reader, "/AACS/IndividualSegment.tbl") {
Ok(t) => t,
Err(crate::error::Error::UdfNotFound { .. }) => return Ok(None),
Err(e) => return Err(e.into()),
};
let Some(segments) = parse_individual_segments(&tbl) else {
return Ok(None);
@@ -855,6 +900,7 @@ fn resolve_fmts_key_map(
.filter(|s| s.index == 1)
.take(MAX_ANCHOR_ATTEMPTS)
{
check_halt()?;
for phase_off in [0usize, 1usize] {
let Some(batch) = read_phase_batch(reader, seg, phase_off) else {
continue; // read fault on this phase — try the other / next segment
@@ -911,45 +957,47 @@ fn resolve_fmts_key_map(
let mut phase_of_index: std::collections::HashMap<u16, crate::decrypt::Phase> =
std::collections::HashMap::new();
for (i, k) in index_keys.iter().enumerate() {
check_halt()?;
let tag = (i + 1) as u16;
let Some(seg) = segments.iter().find(|s| s.index == tag) else {
continue; // no segment carries this index on this feature — skip
};
let (mut even, mut odd) = (0usize, 0usize);
for p in 0..BATCH_UNITS {
for (phase_off, counter) in [(0usize, &mut even), (1usize, &mut odd)] {
if let Some(mut c) = read_unit(reader, seg, p * 2 + phase_off) {
if aacs_unit_encrypted(&c, format) {
decrypt_unit(&mut c, k);
if is_clean(&c, format) {
*counter += 1;
}
}
}
// Probe this index's parity with the anchor loop's read-fault tolerance:
// try up to MAX_ANCHOR_ATTEMPTS same-index segments (not a single `.find`),
// skipping any whose reads all fault. The outcome distinguishes a genuine
// wrong key (reads succeeded, no clean parity) from a transient live-drive
// read fault (zero decrypt evidence) — the load-bearing distinction so a
// recoverable fault never hard-aborts a rip whose index keys are valid.
match probe_index_phase(
&segments,
tag,
BATCH_UNITS,
MAX_ANCHOR_ATTEMPTS,
format,
k,
|seg, unit| read_unit(reader, seg, unit),
) {
IndexProbe::Phase(phase) => {
phase_of_index.insert(tag, phase);
}
IndexProbe::WrongKey => {
// Reads SUCCEEDED but NEITHER parity decrypts clean under this index's
// key on any same-index segment: the key is wrong (or the sample isn't
// this index's real content). The map would be wrong — fail loud rather
// than emit a broken segment. (Preserves the genuine-wrong-key path.)
tracing::warn!(target: "freemkv::keysource", index = tag, "fmts: no clean phase under index key — refusing broken map");
return Err(crate::error::Error::FmtsKeyMissing.into());
}
IndexProbe::ReadFault => {
// EVERY probe read of EVERY same-index segment faulted (a transient
// live-drive read fault — e.g. NOT READY 2/04/3E, the common bad-sector
// sense on the BU40N). There is ZERO decrypt evidence, so this is NOT a
// wrong key: the index key is valid and already in hand. Do NOT abort a
// rip whose forensic keys are good. Leave this index's phase unresolved
// so the range-builder below defaults it to `Phase::All` — decrypt BOTH
// parities and let the demux drop the garbled alternate half (the
// coherent-stream outcome the module doc describes for whole-range key
// application). Degraded but complete; never a wrong-key abort.
tracing::warn!(target: "freemkv::keysource", index = tag, "fmts: index phase probe read-faulted on every segment — defaulting Phase::All (recoverable read fault, not a wrong key)");
}
}
let phase = match resolve_tie_phase(even, odd) {
Ok(p) => {
if even == odd {
// BOTH halves decrypt clean (even == odd > 0): the sampled units
// are source-zero padding (`is_clean_ts` is true for all-zero
// content under ANY key), so the key is valid and the parity is
// immaterial here — default Even (we decrypt one parity; padding
// in the dropped parity is harmless). A padding-heavy sample must
// NOT abort the rip.
tracing::debug!(target: "freemkv::keysource", index = tag, even, odd, "fmts: padding tie — defaulting Even");
}
p
}
Err(e) => {
// NEITHER half decrypts clean under this index's key: the key is
// wrong or the sampled units aren't this index's real content. The
// map would be wrong — fail loud rather than emit a broken segment.
tracing::warn!(target: "freemkv::keysource", index = tag, even, odd, "fmts: no clean phase under index key — refusing broken map");
return Err(e);
}
};
phase_of_index.insert(tag, phase);
}
// ── Build the per-segment LBA ranges: each forensic segment → its tag's key
@@ -1054,6 +1102,90 @@ fn resolve_tie_phase(even_clean: usize, odd_clean: usize) -> io::Result<crate::d
}
}
/// Outcome of probing ONE forensic index's decrypt phase (see [`probe_index_phase`]).
/// The load-bearing distinction is between the last two: a genuine wrong key and a
/// transient live-drive read fault both leave zero clean decrypts, but only the
/// former is a real `FmtsKeyMissing` — the latter must NOT abort a rip whose index
/// keys are valid.
#[derive(Debug, PartialEq, Eq)]
enum IndexProbe {
/// A parity decrypted clean under this index's key (or a padding tie) → its phase.
Phase(crate::decrypt::Phase),
/// At least one unit was READ and decrypt-attempted, yet NEITHER parity came up
/// clean under this index's key on any same-index segment → genuine wrong key.
WrongKey,
/// EVERY probe read of every same-index segment faulted (`read` returned `None`
/// for all attempts) → zero decrypt evidence. A recoverable read fault, NOT a
/// wrong key: there is no data to conclude the key is bad.
ReadFault,
}
/// Probe one forensic index's decrypt phase by reading a representative segment's
/// EVEN vs ODD aligned units and counting clean decrypts under `key`. Extracted
/// from [`resolve_fmts_key_map`] so the read-fault-vs-wrong-key decision is
/// directly testable without a full UDF/segment-table fixture.
///
/// Mirrors the anchor loop's read-fault tolerance: try up to `max_segments`
/// same-index segments (rather than a single `.find`), skipping any whose reads all
/// fault, and only conclude [`IndexProbe::WrongKey`] once a segment actually yielded
/// decrypt attempts. If EVERY read of EVERY same-index segment faults, return
/// [`IndexProbe::ReadFault`] — the caller then leaves the phase unresolved (defaults
/// to `Phase::All`) instead of hard-aborting the rip. `read(seg, unit)` reads
/// aligned unit `unit` of `seg`; `None` is a read fault.
///
/// Masking guard: [`IndexProbe::ReadFault`] is returned ONLY when not a single read
/// succeeded, so a genuine wrong key (whose reads DO succeed) can never be masked as
/// a read fault — any successful, non-clean decrypt yields [`IndexProbe::WrongKey`].
fn probe_index_phase(
segments: &[crate::aacs::segment::Segment],
tag: u16,
batch_units: usize,
max_segments: usize,
format: ContentFormat,
key: &[u8; 16],
mut read: impl FnMut(&crate::aacs::segment::Segment, usize) -> Option<Vec<u8>>,
) -> IndexProbe {
use crate::aacs::content::{aacs_unit_encrypted, decrypt_unit, is_clean};
let mut any_read = false;
for seg in segments
.iter()
.filter(|s| s.index == tag)
.take(max_segments)
{
let (mut even, mut odd) = (0usize, 0usize);
let mut seg_read = false;
for p in 0..batch_units {
for (phase_off, counter) in [(0usize, &mut even), (1usize, &mut odd)] {
if let Some(mut c) = read(seg, p * 2 + phase_off) {
seg_read = true;
if aacs_unit_encrypted(&c, format) {
decrypt_unit(&mut c, key);
if is_clean(&c, format) {
*counter += 1;
}
}
}
}
}
if !seg_read {
continue; // every read of this segment faulted — try the next same-index one
}
any_read = true;
// A clean parity (even != odd) or a padding tie (even == odd > 0) resolves the
// phase; even == odd == 0 is this segment's wrong-key signature, but a DIFFERENT
// same-index segment could still anchor (this one's sampled units may all be the
// alternate variant), so keep trying rather than concluding immediately.
if let Ok(phase) = resolve_tie_phase(even, odd) {
return IndexProbe::Phase(phase);
}
}
if any_read {
IndexProbe::WrongKey
} else {
IndexProbe::ReadFault
}
}
/// Back-fill the LBA gaps NOT covered by the forensic segment ranges with the base
/// Unit Key, so the finished map is a COMPLETE positive list over the title's
/// content extents: every content LBA resolves to either a forensic key (inside a
@@ -1132,6 +1264,7 @@ pub fn resolve_mux_key_map(
keys: &mut crate::decrypt::DecryptKeys,
fetch: Option<&crate::sector::KeyFetch>,
format: ContentFormat,
halt: Option<&crate::halt::Halt>,
) -> io::Result<crate::decrypt::AacsKeyMap> {
use crate::aacs::content::{
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted, decrypt_unit, is_clean,
@@ -1153,7 +1286,7 @@ pub fn resolve_mux_key_map(
// front from the configured source and build a per-segment map. Returns `None`
// when the disc is not FMTS, or no key source is configured (then the base UK
// path below applies and the forensic units garble → demux drops them).
if let Some(map) = resolve_fmts_key_map(reader, title, keys, fetch, format)? {
if let Some(map) = resolve_fmts_key_map(reader, title, keys, fetch, format, halt)? {
return Ok(map);
}
if pool_len == 1 {
@@ -1204,6 +1337,11 @@ pub fn resolve_mux_key_map(
let mut ranges: Vec<(u32, u32, usize)> = Vec::with_capacity(title.extents.len());
let mut last_idx = 0usize;
for ext in &title.extents {
// Cooperative cancel between extents: multi-CPS sampling reads real
// content units off the live drive, so honor an operator stop here too.
if halt.is_some_and(|h| h.is_cancelled()) {
return Err(crate::error::Error::Halted.into());
}
let samples = sample_units(reader, ext.start_lba, ext.sector_count);
// Snapshot the current pool for the pure `pick` closure.
let pool: Vec<(u32, [u8; 16])> = match keys {
@@ -1319,13 +1457,17 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
// with its KNOWN key and trusts it: no per-unit `is_clean` verdict, no reactive
// key-fetch, no key-server storm. A unit that decrypts to broken TS is the
// muxer's problem, exactly as before. AACS-only; CSS self-cracks per region.
let key_map =
match &keys {
crate::decrypt::DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new(
resolve_mux_key_map(&mut reader, &title, &mut keys, fetch.as_ref(), format)?,
)),
_ => None,
};
let key_map = match &keys {
crate::decrypt::DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new(resolve_mux_key_map(
&mut reader,
&title,
&mut keys,
fetch.as_ref(),
format,
halt.as_ref(),
)?)),
_ => None,
};
// The map IS the title's read plan: it says which CPS unit / forensic segment
// each LBA belongs to. Walk ONLY the units it marks as ours — every default /
// CPS unit, and inside an FMTS forensic segment only our-phase units. The
@@ -1794,6 +1936,126 @@ mod tests {
assert!(ps.is_none());
}
// ── Fix 1: halt threading into live-drive key resolution ───────────────
/// A counting `SectorSource` over zeros. `touched_extent` flags whether any
/// read landed in the title's extent region (LBA >= 1000); the UDF probe only
/// reads near LBA 256 (small `capacity`), so a hit there means the expensive
/// per-extent `sample_units` loop ran.
struct HaltCountSource {
reads: u32,
touched_extent: bool,
}
impl SectorSource for HaltCountSource {
fn capacity_sectors(&self) -> u32 {
512 // keeps the UDF secondary anchor well below the extent region
}
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
self.reads += 1;
if lba >= 1000 {
self.touched_extent = true;
}
let want = count as usize * 2048;
buf[..want].fill(0);
Ok(want)
}
}
/// `resolve_mux_key_map` on the multi-CPS live path must honor a pre-cancelled
/// halt PROMPTLY — `Err(Halted)` at the first extent boundary, before sampling
/// any extent's ciphertext — rather than reading through every extent. This is
/// the round-2 Fix 1 guard: the resolve chain runs on the LIVE drive (each
/// `read_sectors` can stall to the SCSI recovery timeout), so an operator Stop
/// during key resolution must interrupt it.
///
/// Mutation: dropping the `halt.is_some_and(...) → Err(Halted)` check in the
/// multi-CPS extent loop makes the resolve run the sampling reads and return
/// `Ok(map)` (zeros sample to no encrypted units → carry key 0), so
/// `expect_err` fails AND `touched_extent` flips true.
#[test]
fn resolve_mux_key_map_honors_pre_cancelled_halt() {
use crate::halt::Halt;
let mut title = DiscTitle::empty();
title.extents = vec![
Extent {
start_lba: 1000,
sector_count: 300,
},
Extent {
start_lba: 5000,
sector_count: 300,
},
];
// Multi-CPS (pool_len = 2) → the extent-sampling loop is the resolve path
// (pool_len == 1 would short-circuit to content_map before any read).
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0x11u8; 16]), (1, [0x22u8; 16])],
read_data_key: None,
format: ContentFormat::BdTs,
};
let mut reader = HaltCountSource {
reads: 0,
touched_extent: false,
};
let halt = Halt::new();
halt.cancel(); // pre-cancelled: the very first extent boundary must bail
let err = super::resolve_mux_key_map(
&mut reader,
&title,
&mut keys,
None,
ContentFormat::BdTs,
Some(&halt),
)
.expect_err("a pre-cancelled halt must abort key resolution");
assert!(crate::error::is_halt(&err), "expected Halted, got: {err}");
assert!(
!reader.touched_extent,
"extent sampling must be skipped on a pre-cancelled halt (a read landed \
in the extent region the halt check was not honored)"
);
}
/// A `None` halt (no token) must NOT abort — the resolve runs to completion.
/// Guards against a mutation that treats `None` as cancelled.
#[test]
fn resolve_mux_key_map_none_halt_does_not_abort() {
let mut title = DiscTitle::empty();
title.extents = vec![Extent {
start_lba: 1000,
sector_count: 300,
}];
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0x11u8; 16]), (1, [0x22u8; 16])],
read_data_key: None,
format: ContentFormat::BdTs,
};
let mut reader = HaltCountSource {
reads: 0,
touched_extent: false,
};
// No halt token → resolution proceeds and samples the extent (zeros → no
// encrypted unit → carries key 0), returning Ok.
let map = super::resolve_mux_key_map(
&mut reader,
&title,
&mut keys,
None,
ContentFormat::BdTs,
None,
)
.expect("no halt → resolution completes");
assert!(reader.touched_extent, "the extent WAS sampled with no halt");
assert!(!map.ranges().is_empty(), "a map is produced for the extent");
}
// ── build_iso_pipeline: end-to-end highway wiring ──────────────────────
/// An in-memory SectorSource that serves a fixed byte image. Reads beyond
@@ -1949,6 +2211,89 @@ mod tests {
);
}
/// End-to-end proof of stream selection: a title declaring TWO audio PIDs,
/// pruned to one via `StreamSelection::apply` BEFORE `build_iso_pipeline`,
/// must never surface a frame from the excluded PID. The demuxer is built
/// from the pruned `title.streams`, so the excluded PID is untracked and its
/// packets are skipped — track headers and frames both follow the pruned
/// list, which is the whole point of the selection seam.
#[test]
fn build_iso_pipeline_pruned_title_drops_unselected_pid_frames() {
use crate::disc::{AudioChannels, AudioStream, Codec, LabelPurpose, SampleRate, Stream};
use crate::mux::select::{PidFilter, StreamSelection};
let es_keep = [0xDE, 0xAD, 0xBE, 0xEF];
let es_drop = [0x99, 0x88, 0x77, 0x66];
let pkt_keep = bdts_data_packet(0x1100, true, &audio_pes(&es_keep));
let pkt_drop = bdts_data_packet(0x1101, true, &audio_pes(&es_drop));
// Both 192-byte packets in one 3-sector extent (offsets 0 and 192).
let mut data = vec![0u8; 3 * 2048];
data[..192].copy_from_slice(&pkt_keep);
data[192..384].copy_from_slice(&pkt_drop);
// Title declares BOTH audio streams (eng 0x1100, spa 0x1101).
let mut title = aac_audio_title(0x1100);
title.streams.push(Stream::Audio(AudioStream {
pid: 0x1101,
codec: Codec::Aac,
channels: AudioChannels::Stereo,
language: "spa".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: LabelPurpose::Normal,
label: String::new(),
}));
title.extents = vec![Extent {
start_lba: 0,
sector_count: 3,
}];
// Prune to keep only PID 0x1100 (the eng audio) — exactly what a
// `-a eng` selection resolves to.
let sel = StreamSelection {
audio: PidFilter::Only(vec![0x1100]),
subtitle: PidFilter::All,
};
sel.apply(&mut title).unwrap();
assert_eq!(
title.streams.len(),
1,
"only the kept audio survives pruning"
);
let mut stream = build_iso_pipeline(
MemSource { data },
title,
DecryptKeys::None,
8192,
ContentFormat::BdTs,
false,
None,
None,
None,
)
.expect("pipeline builds");
// Exactly ONE frame — the kept PID's — reaches us; the 0x1101 packet was
// never tracked by the demuxer, so it produced no frame.
let frame = stream
.read()
.expect("read ok")
.expect("one frame from 0x1100");
assert_eq!(frame.track, 0, "the single retained stream is track 0");
assert_eq!(
&frame.data[..es_keep.len()],
&es_keep[..],
"the KEPT PID's ES bytes"
);
assert!(
stream.read().unwrap().is_none(),
"clean EOF — the excluded 0x1101 packet never surfaced as a frame"
);
// The muxed stream info advertises exactly the one retained audio stream.
assert_eq!(stream.info().streams.len(), 1);
}
/// build_iso_pipeline with batch_sectors = 0 must fail fast (the
/// prefetcher rejects a zero batch as a programming error — a zero batch
/// would spin the producer forever). Surfaced as an io error, not a hang.
@@ -2277,4 +2622,419 @@ mod tests {
);
assert_gapless(&exts, &forensic, &fills);
}
// ── Fix 1: FMTS phase-probe read-fault vs wrong-key distinction ─────────
/// Build a 6144-byte aligned unit of CLEAN MPEG-TS (sync `0x47` + non-zero
/// payload in packets 1.., packet 0 is the clear seed) then AACS-encrypt it
/// under `key`. Decrypting under the SAME key restores clean TS (`is_clean` →
/// true); decrypting under any other key yields garbage.
fn encrypted_clean_unit(key: &[u8; 16]) -> Vec<u8> {
use crate::aacs::content::ALIGNED_UNIT_LEN;
let mut u = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 0;
while off + 192 <= ALIGNED_UNIT_LEN {
u[off + 4] = 0x47; // TS sync at the BD-TS packet stride
for b in &mut u[off + 5..off + 192] {
*b = 0xAB; // non-zero payload so is_clean counts it as content
}
off += 192;
}
crate::aacs::content::aacs_encrypt_unit_for_test(&mut u, key);
u
}
fn a_segment(index: u16) -> crate::aacs::segment::Segment {
crate::aacs::segment::Segment {
index,
start_spn: 0,
end_spn: 100,
}
}
/// A probe whose EVERY read faults (`read` returns `None`) must classify as
/// [`IndexProbe::ReadFault`], NOT [`IndexProbe::WrongKey`] — a transient
/// live-drive read fault while probing must not be read as a missing key (which
/// the caller turns into a rip-aborting `FmtsKeyMissing`).
///
/// Mutation: reverting to the no-fallback single-segment probe (i.e. treating
/// even==odd==0 as unconditional `FmtsKeyMissing` regardless of whether any read
/// succeeded) makes this return `WrongKey` → the assert fails.
#[test]
fn probe_index_phase_all_faults_is_read_fault_not_wrong_key() {
let segs = vec![a_segment(1)];
let key = [0x11u8; 16];
let got = super::probe_index_phase(
&segs,
1,
8,
16,
ContentFormat::BdTs,
&key,
|_seg, _unit| None, // every read faults
);
assert_eq!(
got,
super::IndexProbe::ReadFault,
"all-faulted probe is a recoverable read fault, never a wrong key"
);
}
/// Reads SUCCEED but decrypt to NEITHER clean parity (ciphertext under a key we
/// do NOT hold) → [`IndexProbe::WrongKey`]. This is the genuine-missing-key path
/// the caller MUST keep as a hard `FmtsKeyMissing`.
#[test]
fn probe_index_phase_reads_succeed_but_no_clean_phase_is_wrong_key() {
let segs = vec![a_segment(1)];
let cipher = encrypted_clean_unit(&[0xAAu8; 16]); // encrypted under key A
let probe_key = [0xBBu8; 16]; // ... probed under the WRONG key B
let got = super::probe_index_phase(
&segs,
1,
8,
16,
ContentFormat::BdTs,
&probe_key,
|_seg, _unit| Some(cipher.clone()),
);
assert_eq!(
got,
super::IndexProbe::WrongKey,
"reads that decrypt to no clean parity under the probed key are a wrong key"
);
}
/// Reads succeed and the EVEN units decrypt clean under this index's key while
/// the ODD units are (unencrypted) padding → [`IndexProbe::Phase`]`(Even)`.
#[test]
fn probe_index_phase_resolves_clean_even_phase() {
use crate::aacs::content::ALIGNED_UNIT_LEN;
use crate::decrypt::Phase;
let segs = vec![a_segment(1)];
let key = [0x33u8; 16];
let even_unit = encrypted_clean_unit(&key);
let got = super::probe_index_phase(
&segs,
1,
8,
16,
ContentFormat::BdTs,
&key,
// even unit index → clean ciphertext under `key`; odd → zero padding
// (aacs_unit_encrypted false → not counted).
|_seg, unit| {
if unit % 2 == 0 {
Some(even_unit.clone())
} else {
Some(vec![0u8; ALIGNED_UNIT_LEN])
}
},
);
assert_eq!(
got,
super::IndexProbe::Phase(Phase::Even),
"clean even units + padding odd → Even phase"
);
}
/// Read-fault TOLERANCE across segments: the first same-index segment faults on
/// every read, but a SECOND same-index segment decrypts clean → the probe must
/// fall through to it and resolve a phase (mirrors the anchor loop's multi-
/// segment retry). A single-`.find` probe would have stopped at the faulting
/// first segment.
#[test]
fn probe_index_phase_falls_through_faulting_segment_to_next() {
use crate::decrypt::Phase;
let mut faulting = a_segment(1);
faulting.start_spn = 1; // distinguish the two same-index segments
let good = a_segment(1);
let segs = vec![faulting, good];
let key = [0x44u8; 16];
let clean = encrypted_clean_unit(&key);
let got = super::probe_index_phase(
&segs,
1,
8,
16,
ContentFormat::BdTs,
&key,
// The faulting segment (start_spn == 1) reads None; the good one reads a
// clean even unit / padding odd.
|seg, unit| {
if seg.start_spn == 1 {
None
} else if unit % 2 == 0 {
Some(clean.clone())
} else {
Some(vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN])
}
},
);
assert_eq!(
got,
super::IndexProbe::Phase(Phase::Even),
"a faulting first segment must not block resolving from the next same-index one"
);
}
// ── Fix 2: resolve_mux_key_map multi-CPS key selection (real ciphertext) ─
/// A SectorSource that tiles a fixed 6144-byte ciphertext unit across each
/// registered extent range (`(start_lba, end_lba, unit)`), zeros elsewhere.
/// Every 3-sector aligned-unit read inside a range returns the same ciphertext,
/// so `sample_units` collects real encrypted content for `pick`/fetch to run on.
/// Low LBAs are zero, so `udf::read_filesystem` fails → the FMTS branch returns
/// `Ok(None)` and the multi-CPS path is exercised.
struct CipherSource {
units: Vec<(u32, u32, Vec<u8>)>,
}
impl SectorSource for CipherSource {
fn capacity_sectors(&self) -> u32 {
1_000_000
}
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
let want = count as usize * 2048;
buf[..want].fill(0);
for s in 0..count as usize {
let cur = lba + s as u32;
if let Some((start, _end, unit)) =
self.units.iter().find(|(a, b, _)| cur >= *a && cur < *b)
{
// 3 sectors per aligned unit; tile the ciphertext by sector.
let within = ((cur - start) % 3) as usize;
let src = &unit[within * 2048..within * 2048 + 2048];
buf[s * 2048..s * 2048 + 2048].copy_from_slice(src);
}
}
Ok(want)
}
}
fn multi_cps_title(start_lba: u32, sectors: u32) -> DiscTitle {
let mut t = DiscTitle::empty();
t.extents = vec![Extent {
start_lba,
sector_count: sectors,
}];
t
}
/// `pick()` must select the pool index of the key that actually opens the
/// extent's real ciphertext — index 2 here, NOT 0. Feeds units encrypted under
/// the third pool key through the live multi-CPS path.
///
/// Mutation: `pick` hard-returning `Some(0)` keys the extent to 0 → this assert
/// (Some(2)) fails.
#[test]
fn resolve_mux_key_map_multi_cps_pick_selects_correct_index() {
let key_a = [0x01u8; 16];
let key_b = [0x02u8; 16];
let key_c = [0x03u8; 16];
let unit = encrypted_clean_unit(&key_c); // extent content opens under C (idx 2)
let start = 1000u32;
let sectors = 30u32; // 10 aligned units
let mut reader = CipherSource {
units: vec![(start, start + sectors, unit)],
};
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a), (1, key_b), (2, key_c)],
read_data_key: None,
format: ContentFormat::BdTs,
};
let title = multi_cps_title(start, sectors);
let map = super::resolve_mux_key_map(
&mut reader,
&title,
&mut keys,
None,
ContentFormat::BdTs,
None,
)
.expect("multi-CPS resolve succeeds when a held key opens the extent");
assert_eq!(
map.key_idx_for(start),
Some(2),
"the extent must be keyed to the pool index whose key opens its ciphertext"
);
}
/// Fail-loud: a sample that decrypts clean under NO held key and NO fetched key
/// (fetch = None) must surface [`Error::DecryptFailed`], never silently key the
/// extent to a neighbour's (wrong) index.
///
/// Mutation: dropping the `None => Err(DecryptFailed)` guard (e.g. falling back
/// to `last_idx`) returns `Ok` → this `expect_err` fails.
#[test]
fn resolve_mux_key_map_multi_cps_fail_loud_on_absent_key() {
let key_a = [0x01u8; 16];
let key_b = [0x02u8; 16];
let key_x = [0x09u8; 16]; // NOT in the pool, NOT fetchable (fetch None)
let unit = encrypted_clean_unit(&key_x);
let start = 1000u32;
let sectors = 30u32;
let mut reader = CipherSource {
units: vec![(start, start + sectors, unit)],
};
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a), (1, key_b)],
read_data_key: None,
format: ContentFormat::BdTs,
};
let title = multi_cps_title(start, sectors);
let err = super::resolve_mux_key_map(
&mut reader,
&title,
&mut keys,
None,
ContentFormat::BdTs,
None,
)
.expect_err("an extent no held/fetched key opens must fail loud, not mis-key");
let expected = std::io::Error::from(crate::error::Error::DecryptFailed).to_string();
assert_eq!(err.to_string(), expected, "expected DecryptFailed");
}
/// KeyFetch cold path: the pool is missing the extent's key, but the injected
/// `KeyFetch::unit_keys` returns it from the failing samples → the extent
/// resolves to the newly-appended pool index (2) and the map succeeds. Proves
/// the on-miss fetch+re-pick branch runs end to end.
#[test]
fn resolve_mux_key_map_multi_cps_fetch_recovers_missing_key() {
let key_a = [0x01u8; 16];
let key_b = [0x02u8; 16];
let key_x = [0x09u8; 16]; // absent from the pool, supplied by fetch
let unit = encrypted_clean_unit(&key_x);
let start = 1000u32;
let sectors = 30u32;
let mut reader = CipherSource {
units: vec![(start, start + sectors, unit)],
};
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a), (1, key_b)],
read_data_key: None,
format: ContentFormat::BdTs,
};
let title = multi_cps_title(start, sectors);
// A unit-only KeyFetch that hands back key_x for any non-empty sample batch.
let fetch = crate::sector::KeyFetch::unit_only(std::sync::Arc::new(
move |samples: &[Vec<u8>]| {
if samples.is_empty() {
Vec::new()
} else {
vec![key_x]
}
},
));
let map = super::resolve_mux_key_map(
&mut reader,
&title,
&mut keys,
Some(&fetch),
ContentFormat::BdTs,
None,
)
.expect("the fetched key recovers the extent");
assert_eq!(
map.key_idx_for(start),
Some(2),
"the fetched key is appended at pool index 2 and keys the extent"
);
}
// ── Fix 1: read-fault vs genuinely-not-FMTS in resolve_fmts_key_map ──────
/// A SectorSource whose every read is a transient I/O fault (`DiscRead`),
/// modelling a marginal live drive stalling while `resolve_fmts_key_map`
/// probes the UDF metadata / segment table.
struct FaultSource;
impl SectorSource for FaultSource {
fn capacity_sectors(&self) -> u32 {
1_000_000
}
fn read_sectors(
&mut self,
lba: u32,
_count: u16,
_buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
Err(crate::error::Error::DiscRead {
sector: lba as u64,
status: None,
sense: None,
})
}
}
/// A transient `DiscRead` fault while reading the UDF metadata for the segment
/// table must PROPAGATE (fail loud / retryable), NOT be swallowed into the
/// not-FMTS `Ok(None)` fall-through — otherwise a marginal AACS 2.1 disc would
/// silently drop its forensic content under a base-Unit-Key-only map and the
/// mux would report success.
///
/// Mutation: revert the read_filesystem arm to `let Ok(udf) = ... else { return
/// Ok(None) }` → this returns `Ok(None)` and the assert fails.
#[test]
fn resolve_fmts_key_map_read_fault_propagates() {
let mut reader = FaultSource;
let title = multi_cps_title(1000, 30);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0x01u8; 16])],
read_data_key: None,
format: ContentFormat::BdTs,
};
let got = super::resolve_fmts_key_map(
&mut reader,
&title,
&mut keys,
None,
ContentFormat::BdTs,
None,
);
let err = got.expect_err("a transient DiscRead must fail loud, never Ok(None)");
let expected = std::io::Error::from(crate::error::Error::DiscRead {
sector: 256,
status: None,
sense: None,
})
.to_string();
assert_eq!(
err.to_string(),
expected,
"the DiscRead fault must propagate"
);
}
/// A reader whose bytes are structurally NOT a UDF disc (all zeros → no AVDP at
/// sector 256 → `UdfNotFilesystem`) is genuinely not FMTS: it must map to the
/// clean `Ok(None)` negative, NOT fail loud. Guards against Fix 1 over-reaching
/// and rejecting benign non-FMTS discs.
#[test]
fn resolve_fmts_key_map_not_udf_is_clean_none() {
// CipherSource with no registered units reads as all zeros everywhere, so
// read_filesystem sees tag_id 0 at sector 256 → UdfNotFilesystem.
let mut reader = CipherSource { units: Vec::new() };
let title = multi_cps_title(1000, 30);
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0x01u8; 16])],
read_data_key: None,
format: ContentFormat::BdTs,
};
let got = super::resolve_fmts_key_map(
&mut reader,
&title,
&mut keys,
None,
ContentFormat::BdTs,
None,
)
.expect("a structurally non-UDF disc is a clean not-FMTS negative");
assert!(got.is_none(), "not a UDF/FMTS disc → Ok(None)");
}
}
+299
View File
@@ -0,0 +1,299 @@
//! Per-title audio/subtitle stream selection — the pure primitive.
//!
//! The demux pipeline is **declaration-driven**: every demux table
//! (`build_demux_state` in `mux/resolve.rs`, `DiscStream::new` in `mux/disc.rs`)
//! is built from the input [`DiscTitle`]'s `streams` list, and the MKV writer
//! builds its track headers + `codec_privates` from that same list. A PID not
//! declared there is never tracked, extracted, or written. So "which streams to
//! keep" is already a capability of the pipeline — it just has no public knob.
//!
//! [`StreamSelection::apply`] is that knob: prune the `DiscTitle.streams` list
//! (video always kept) BEFORE the mux path finalizes the title, and everything
//! downstream — track headers, `codec_privates`, PID routing, frame emission —
//! follows from the pruned list by construction, with zero scattered PID
//! checks. This is language-agnostic: PIDs, not languages (the language→PID
//! mapping is the caller's/engine's policy).
use crate::disc::{DiscTitle, Stream};
use crate::error::{Error, Result};
/// Which PIDs to keep for one stream class (audio or subtitle). Video is always
/// kept, so it has no filter.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum PidFilter {
/// Keep every stream of this class. The default; [`StreamSelection::apply`]
/// is a no-op for an All/All selection, so the no-selection path is
/// byte-identical to no selection at all.
#[default]
All,
/// Keep only the streams whose PID is listed. `Only(vec![])` is legal and
/// means keep none (a video-only output when both classes are `Only([])`).
Only(Vec<u16>),
}
/// A per-title stream selection: which audio and which subtitle PIDs to keep.
/// Video is always retained (it is implicit and never pruned).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct StreamSelection {
pub audio: PidFilter,
pub subtitle: PidFilter,
}
impl StreamSelection {
/// True for the All/All default. Apply sites gate on `!is_all()` so the
/// no-selection path never even clones the title.
pub fn is_all(&self) -> bool {
matches!(self.audio, PidFilter::All) && matches!(self.subtitle, PidFilter::All)
}
/// Prune `title.streams` in place: keep every [`Stream::Video`]
/// unconditionally; keep an [`Stream::Audio`]/[`Stream::Subtitle`] iff its
/// PID passes the corresponding [`PidFilter`]; drop the rest. Declared order
/// is preserved. The parallel `codec_privates` vec is pruned in lockstep
/// when it is populated (it is empty on a freshly-scanned title, non-empty
/// only if a caller pre-filled it).
///
/// Errors [`Error::SelectionPidUnknown`] if a filter lists a PID that does
/// not exist in `title.streams` — a caller bug (e.g. a stale scan). Fail
/// loud rather than silently emit an MKV missing a requested track. On
/// error the title is left unmodified.
pub fn apply(&self, title: &mut DiscTitle) -> Result<()> {
if self.is_all() {
return Ok(());
}
// Validate every listed PID exists in the title before mutating, so an
// unknown PID leaves the title untouched (no partial prune).
for pid in self.listed_pids() {
let present = title.streams.iter().any(|s| stream_pid(s) == Some(pid));
if !present {
return Err(Error::SelectionPidUnknown { pid });
}
}
let codec_privates_aligned = title.codec_privates.len() == title.streams.len();
// Retain by index so we can prune the parallel codec_privates in lockstep.
let keep: Vec<bool> = title
.streams
.iter()
.map(|s| self.keeps(s))
.collect::<Vec<_>>();
let mut i = 0;
title.streams.retain(|_| {
let k = keep[i];
i += 1;
k
});
if codec_privates_aligned {
let mut j = 0;
title.codec_privates.retain(|_| {
let k = keep[j];
j += 1;
k
});
}
Ok(())
}
/// Whether this selection keeps `stream`.
fn keeps(&self, stream: &Stream) -> bool {
match stream {
Stream::Video(_) => true,
Stream::Audio(a) => filter_keeps(&self.audio, a.pid),
Stream::Subtitle(s) => filter_keeps(&self.subtitle, s.pid),
}
}
/// Every PID explicitly listed across both filters (for existence checking).
fn listed_pids(&self) -> Vec<u16> {
let mut v = Vec::new();
if let PidFilter::Only(pids) = &self.audio {
v.extend_from_slice(pids);
}
if let PidFilter::Only(pids) = &self.subtitle {
v.extend_from_slice(pids);
}
v
}
}
fn filter_keeps(filter: &PidFilter, pid: u16) -> bool {
match filter {
PidFilter::All => true,
PidFilter::Only(pids) => pids.contains(&pid),
}
}
/// The PID of an audio/subtitle stream; `None` for video (which is never
/// filtered, so its PID is irrelevant to selection).
fn stream_pid(stream: &Stream) -> Option<u16> {
match stream {
Stream::Audio(a) => Some(a.pid),
Stream::Subtitle(s) => Some(s.pid),
Stream::Video(_) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::disc::{
AudioChannels, AudioStream, Codec, ColorSpace, FrameRate, HdrFormat, LabelPurpose,
LabelQualifier, Resolution, SampleRate, SubtitleStream, VideoStream,
};
fn video(pid: u16) -> Stream {
Stream::Video(VideoStream {
pid,
codec: Codec::Hevc,
resolution: Resolution::R2160p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Hdr10,
color_space: ColorSpace::Bt2020,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
})
}
fn audio(pid: u16, lang: &str) -> Stream {
Stream::Audio(AudioStream {
pid,
codec: Codec::TrueHd,
channels: AudioChannels::Stereo,
language: lang.into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: LabelPurpose::Normal,
label: String::new(),
})
}
fn subtitle(pid: u16, lang: &str) -> Stream {
Stream::Subtitle(SubtitleStream {
pid,
codec: Codec::Pgs,
language: lang.into(),
forced: false,
qualifier: LabelQualifier::None,
codec_data: None,
})
}
// video + 3 audio (eng/spa/fra) + 2 subs (eng/spa).
fn title() -> DiscTitle {
let mut t = DiscTitle::empty();
t.streams = vec![
video(0x1011),
audio(0x1100, "eng"),
audio(0x1101, "spa"),
audio(0x1102, "fra"),
subtitle(0x1200, "eng"),
subtitle(0x1201, "spa"),
];
t
}
fn pids(t: &DiscTitle) -> Vec<u16> {
t.streams
.iter()
.filter_map(|s| match s {
Stream::Video(v) => Some(v.pid),
Stream::Audio(a) => Some(a.pid),
Stream::Subtitle(s) => Some(s.pid),
})
.collect()
}
#[test]
fn apply_all_is_identity_and_untouched() {
let sel = StreamSelection::default();
assert!(sel.is_all());
let mut t = title();
let before = pids(&t);
sel.apply(&mut t).unwrap();
assert_eq!(pids(&t), before, "All/All must not change the stream list");
}
#[test]
fn apply_only_retains_listed_audio_pids_in_declared_order() {
// Keep eng+fra audio (skip spa); leave subtitles alone.
let sel = StreamSelection {
audio: PidFilter::Only(vec![0x1100, 0x1102]),
subtitle: PidFilter::All,
};
let mut t = title();
sel.apply(&mut t).unwrap();
assert_eq!(
pids(&t),
vec![0x1011, 0x1100, 0x1102, 0x1200, 0x1201],
"video + eng/fra audio (order preserved) + both subs"
);
}
#[test]
fn apply_only_empty_yields_video_only() {
let sel = StreamSelection {
audio: PidFilter::Only(vec![]),
subtitle: PidFilter::Only(vec![]),
};
let mut t = title();
sel.apply(&mut t).unwrap();
assert_eq!(pids(&t), vec![0x1011], "only the video stream survives");
}
#[test]
fn apply_subtitle_filter_does_not_touch_audio() {
let sel = StreamSelection {
audio: PidFilter::All,
subtitle: PidFilter::Only(vec![0x1200]),
};
let mut t = title();
sel.apply(&mut t).unwrap();
assert_eq!(
pids(&t),
vec![0x1011, 0x1100, 0x1101, 0x1102, 0x1200],
"all audio kept, only eng subtitle kept"
);
}
#[test]
fn apply_unknown_pid_errors_and_leaves_title_untouched() {
let sel = StreamSelection {
audio: PidFilter::Only(vec![0x9999]),
subtitle: PidFilter::All,
};
let mut t = title();
let before = pids(&t);
let err = sel.apply(&mut t).unwrap_err();
assert!(matches!(err, Error::SelectionPidUnknown { pid: 0x9999 }));
assert_eq!(pids(&t), before, "title unmodified on error");
}
#[test]
fn apply_prunes_codec_privates_in_lockstep_when_populated() {
// A caller that pre-filled codec_privates parallel to streams: pruning
// must keep the two vecs aligned.
let mut t = title();
t.codec_privates = vec![
Some(vec![0xAA]), // video 0x1011
Some(vec![0x11]), // audio 0x1100 eng
Some(vec![0x22]), // audio 0x1101 spa
Some(vec![0x33]), // audio 0x1102 fra
None, // sub 0x1200
None, // sub 0x1201
];
let sel = StreamSelection {
audio: PidFilter::Only(vec![0x1100]),
subtitle: PidFilter::Only(vec![]),
};
sel.apply(&mut t).unwrap();
assert_eq!(pids(&t), vec![0x1011, 0x1100]);
assert_eq!(
t.codec_privates,
vec![Some(vec![0xAA]), Some(vec![0x11])],
"codec_privates pruned to match the retained streams, in order"
);
}
}
+70
View File
@@ -119,6 +119,76 @@ pub const SENSE_KEY_DATA_PROTECT: u8 = 0x07;
pub const SENSE_KEY_BLANK_CHECK: u8 = 0x08;
pub const SENSE_KEY_ABORTED_COMMAND: u8 = 0x0B;
/// Coarse classification of a SCSI sense key, for callers that need to
/// branch on "what kind of failure was this" without a `match` over every
/// `SENSE_KEY_*` constant. Pure hardware-fact translation — no retry policy
/// here; see the recovery/engine layer for what to DO about a given family.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SenseFamily {
NotReady,
Medium,
Hardware,
IllegalRequest,
Other,
}
impl SenseFamily {
pub fn from_sense_key(sense_key: u8) -> Self {
match sense_key {
SENSE_KEY_NOT_READY => SenseFamily::NotReady,
SENSE_KEY_MEDIUM_ERROR => SenseFamily::Medium,
SENSE_KEY_HARDWARE_ERROR => SenseFamily::Hardware,
SENSE_KEY_ILLEGAL_REQUEST => SenseFamily::IllegalRequest,
_ => SenseFamily::Other,
}
}
/// True for the "wedge family" — some drives (e.g. the BU40N over a
/// USB-SATA bridge) return Hardware or IllegalRequest sense once their
/// firmware enters a fast-fail state after sustained bad-media reads.
pub fn is_wedge_family(self) -> bool {
matches!(self, SenseFamily::Hardware | SenseFamily::IllegalRequest)
}
}
#[cfg(test)]
mod sense_family_tests {
use super::*;
#[test]
fn classifies_each_named_sense_key() {
assert_eq!(
SenseFamily::from_sense_key(SENSE_KEY_NOT_READY),
SenseFamily::NotReady
);
assert_eq!(
SenseFamily::from_sense_key(SENSE_KEY_MEDIUM_ERROR),
SenseFamily::Medium
);
assert_eq!(
SenseFamily::from_sense_key(SENSE_KEY_HARDWARE_ERROR),
SenseFamily::Hardware
);
assert_eq!(
SenseFamily::from_sense_key(SENSE_KEY_ILLEGAL_REQUEST),
SenseFamily::IllegalRequest
);
assert_eq!(
SenseFamily::from_sense_key(SENSE_KEY_ABORTED_COMMAND),
SenseFamily::Other
);
}
#[test]
fn wedge_family_is_hardware_and_illegal_request_only() {
assert!(SenseFamily::Hardware.is_wedge_family());
assert!(SenseFamily::IllegalRequest.is_wedge_family());
assert!(!SenseFamily::Medium.is_wedge_family());
assert!(!SenseFamily::NotReady.is_wedge_family());
assert!(!SenseFamily::Other.is_wedge_family());
}
}
// ── Sense parsing ───────────────────────────────────────────────────────────
/// Decoded SPC-4 sense triple — the precise reason a SCSI command failed.
-180
View File
@@ -1,180 +0,0 @@
//! File-backed sector sink — write 2048-byte sectors to an ISO image
//! on disk.
//!
//! The read-side counterpart ([`crate::io::file_sector_source::FileSectorSource`])
//! lives under `io/` because its internals (read-ahead buffer, per-OS
//! `fadvise`/`F_RDADVISE` hints) are I/O infrastructure rather than
//! sector-trait business logic. Both types remain re-exported at
//! [`crate::sector`] for ergonomic imports.
use std::fs::OpenOptions;
use std::io::{Seek, SeekFrom, Write};
use std::path::Path;
use crate::error::{Error, Result};
use super::SectorSink;
/// SectorSink backed by a file (ISO image).
///
/// Writes go through [`crate::io::WritebackFile`], which on Linux drives
/// continuous `sync_file_range` + `posix_fadvise(DONTNEED)` to keep
/// the kernel dirty page cache bounded during multi-GB sequential
/// writes. macOS / Windows fall through to a no-op pipeline.
///
/// `finish` runs `sync_all` before dropping the underlying file.
pub struct FileSectorSink {
inner: crate::io::WritebackFile,
}
impl FileSectorSink {
/// Create a new ISO file at `path`, truncating any existing
/// file. The file is opened read-write so the same handle can
/// later be reused for verification reads if needed (sweep
/// doesn't, but it costs nothing here).
pub fn create(path: &Path) -> std::io::Result<Self> {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(path)?;
Ok(Self {
inner: crate::io::WritebackFile::new(file)?,
})
}
/// Open an existing ISO file for in-place updates (e.g. patch
/// pass writing recovered sectors over zero-filled holes).
/// Does not truncate.
pub fn open(path: &Path) -> std::io::Result<Self> {
let file = OpenOptions::new().read(true).write(true).open(path)?;
Ok(Self {
inner: crate::io::WritebackFile::new(file)?,
})
}
}
impl SectorSink for FileSectorSink {
fn write_sectors(&mut self, lba: u32, buf: &[u8]) -> Result<()> {
// SectorSink's contract requires a 2048-multiple buffer. Enforce
// it in all build modes (a `debug_assert!` is a no-op in release):
// a misaligned buffer would `write_all` partial bytes at
// lba*2048 and silently corrupt the ISO. Current in-tree callers
// always pass aligned buffers; this guards the public trait
// contract against any (including future external) caller.
if buf.len() % 2048 != 0 {
return Err(Error::IoError {
source: std::io::Error::from(std::io::ErrorKind::InvalidInput),
});
}
let offset = lba as u64 * 2048;
self.inner
.seek(SeekFrom::Start(offset))
.map_err(|e| Error::IoError { source: e })?;
self.inner
.write_all(buf)
.map_err(|e| Error::IoError { source: e })?;
Ok(())
}
fn finish(mut self: Box<Self>) -> Result<()> {
self.inner
.sync_all()
.map_err(|e| Error::IoError { source: e })?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::FileSectorSink;
use crate::io::file_sector_source::FileSectorSource;
use crate::sector::{SectorSink, SectorSource};
use tempfile::tempdir;
#[test]
fn round_trip_single_sector() {
let dir = tempdir().unwrap();
let path = dir.path().join("rt.iso");
let mut sink = FileSectorSink::create(&path).unwrap();
// Pre-extend the file to 4 sectors of zeros so we can write
// sector 2 in place. Easiest way: write zeros first.
let zeros = [0u8; 4 * 2048];
sink.write_sectors(0, &zeros).unwrap();
let mut payload = [0u8; 2048];
for (i, b) in payload.iter_mut().enumerate() {
*b = (i as u8).wrapping_mul(17);
}
sink.write_sectors(2, &payload).unwrap();
Box::new(sink).finish().unwrap();
let mut src = FileSectorSource::open(&path).unwrap();
assert_eq!(src.capacity_sectors(), 4);
let mut got = [0u8; 2048];
let n = src.read_sectors(2, 1, &mut got, false).unwrap();
assert_eq!(n, 2048);
assert_eq!(got, payload);
// Sectors 0,1,3 still zero.
let mut z = [0xffu8; 2048];
src.read_sectors(0, 1, &mut z, false).unwrap();
assert!(z.iter().all(|b| *b == 0));
}
#[test]
fn round_trip_multi_sector() {
let dir = tempdir().unwrap();
let path = dir.path().join("multi.iso");
let mut sink = FileSectorSink::create(&path).unwrap();
let mut payload = vec![0u8; 8 * 2048];
for (i, b) in payload.iter_mut().enumerate() {
*b = ((i * 31) ^ (i >> 7)) as u8;
}
sink.write_sectors(0, &payload).unwrap();
Box::new(sink).finish().unwrap();
let mut src = FileSectorSource::open(&path).unwrap();
assert_eq!(src.capacity_sectors(), 8);
let mut got = vec![0u8; 8 * 2048];
let n = src.read_sectors(0, 8, &mut got, false).unwrap();
assert_eq!(n, 8 * 2048);
assert_eq!(got, payload);
}
#[test]
fn open_existing_does_not_truncate() {
let dir = tempdir().unwrap();
let path = dir.path().join("open.iso");
// Create with 4 sectors of pattern A.
let mut sink = FileSectorSink::create(&path).unwrap();
let pat_a = [0xaau8; 4 * 2048];
sink.write_sectors(0, &pat_a).unwrap();
Box::new(sink).finish().unwrap();
// Reopen and overwrite sector 1 only.
let mut sink = FileSectorSink::open(&path).unwrap();
let pat_b = [0xbbu8; 2048];
sink.write_sectors(1, &pat_b).unwrap();
Box::new(sink).finish().unwrap();
let mut src = FileSectorSource::open(&path).unwrap();
assert_eq!(src.capacity_sectors(), 4);
let mut got = [0u8; 2048];
src.read_sectors(0, 1, &mut got, false).unwrap();
assert_eq!(got, [0xaau8; 2048]);
src.read_sectors(1, 1, &mut got, false).unwrap();
assert_eq!(got, [0xbbu8; 2048]);
src.read_sectors(2, 1, &mut got, false).unwrap();
assert_eq!(got, [0xaau8; 2048]);
}
}
+2 -27
View File
@@ -1,20 +1,14 @@
//! Sector-level I/O traits.
//! Sector-level read I/O traits.
//!
//! The sector layer is direction-typed: [`SectorSource`] reads
//! 2048-byte sectors, [`SectorSink`] writes them. Concrete impls
//! never do both — physical drives are read-only, file-backed
//! ISO images are opened for read OR write at construction time.
//! [`SectorSource`] reads 2048-byte sectors from a disc.
//!
//! - [`SectorSource`] is implemented by `Drive` (hardware) and
//! [`FileSectorSource`] (file-backed).
//! - [`SectorSink`] is implemented by [`FileSectorSink`]
//! (ISO-backed).
//! - [`DecryptingSectorSource`] is a decorator that wraps any
//! `SectorSource` and applies AACS / CSS in-place decrypt to
//! yield plaintext sectors.
pub mod decrypting;
pub mod file;
pub mod prefetched;
use crate::error::Result;
@@ -169,27 +163,8 @@ impl SectorSource for &mut (dyn SectorSource + '_) {
}
}
/// Write 2048-byte sectors to a disc image or composed sink.
///
/// The terminal [`finish`] takes `Box<Self>` so it can run on `dyn
/// SectorSink` and consume the sink (`fsync` + close).
///
/// [`finish`]: SectorSink::finish
pub trait SectorSink: Send {
/// Write the sectors in `buf` starting at `lba`. `buf.len()`
/// must be a multiple of 2048; the implementation seeks to
/// `lba as u64 * 2048` before writing (the `u64` cast is required —
/// a bare `u32` `lba * 2048` wraps past ~4 GB on UHD-scale images).
fn write_sectors(&mut self, lba: u32, buf: &[u8]) -> Result<()>;
/// Flush, fsync, and close. Consumes the sink. Always called
/// last; subsequent operations are not defined.
fn finish(self: Box<Self>) -> Result<()>;
}
pub use crate::io::file_sector_source::FileSectorSource;
pub use decrypting::{DecryptingSectorSource, KeyFetch, KeyFetchFn};
pub use file::FileSectorSink;
pub use prefetched::PrefetchedSectorSource;
#[cfg(test)]
+112
View File
@@ -370,6 +370,35 @@ impl DiscSession {
pub fn take_reader(&mut self) -> Option<Box<dyn SectorSource>> {
self.reader.take()
}
/// Test-only constructor: build a session over an INJECTED reader + already-
/// scanned disc WITHOUT opening a live [`Drive`]. `DiscSession::open` needs
/// real hardware, so this is the only way to exercise the
/// [`MuxInput::Session`](crate::mux::MuxInput::Session) mux arm (take_reader →
/// resolve_inline_base_map → DiscStream → with_key_map) and
/// [`Self::resolve_keys`]'s title-sampling branch against a synthetic reader.
///
/// The drive slot stays `None` (a `MuxInput::Session` mux never touches it —
/// it reads through the staged `reader`); `device` carries a sentinel path so
/// the driver's missing-reader error still has a name.
///
/// `disc` is an `Option` so a test can construct a session that has NOT been
/// scanned (`None`) to exercise the `resolve_keys` "called before scan" guard.
#[cfg(test)]
pub(crate) fn from_parts_for_test(
disc: Option<Disc>,
reader: Option<Box<dyn SectorSource>>,
key_fetch: Option<KeyFetch>,
) -> DiscSession {
DiscSession {
drive: None,
device: "test://session".to_string(),
spec: KeySpec::default(),
disc,
reader,
key_fetch,
}
}
}
/// Scan an ISO image's structure from a file path, returning the scanned
@@ -616,6 +645,71 @@ mod tests {
);
}
/// A counting reader over zeros — records the highest LBA sampled so the test
/// can prove the LARGEST title's extent (not the small one) was read.
struct SamplingReader {
reads: u32,
max_lba: u32,
}
impl SectorSource for SamplingReader {
fn capacity_sectors(&self) -> u32 {
100_000
}
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8], _: bool) -> Result<usize> {
self.reads += 1;
self.max_lba = self.max_lba.max(lba);
let want = count as usize * 2048;
buf[..want].fill(0);
Ok(want)
}
}
/// `resolve_keys_for` samples the LARGEST title's ciphertext through the
/// reader when a source is configured (the `session.rs:90` sampling branch).
/// The other tests use a title-less disc (no sampling read), so this branch
/// was uncovered. With two titles and a non-empty source, the sampling read
/// fires against the LARGER title's extent.
#[test]
fn resolve_keys_for_samples_largest_title_through_reader() {
use crate::disc::{DiscTitle, Extent};
let mut disc = aacs_disc();
let mut small = DiscTitle::empty();
small.size_bytes = 1_000;
small.extents = vec![Extent {
start_lba: 100,
sector_count: 300,
}];
let mut large = DiscTitle::empty();
large.size_bytes = 9_000_000;
large.extents = vec![Extent {
start_lba: 9_000,
sector_count: 300,
}];
disc.titles = vec![small, large];
let mut reader = SamplingReader {
reads: 0,
max_lba: 0,
};
// Non-empty source ⇒ the sampling read is NOT skipped.
let resolved = resolve_keys_for(&mut reader, &mut disc, factory_of(|| HasUnitKey([1; 16])));
assert!(
reader.reads > 0,
"the largest title was sampled via the reader"
);
assert!(
reader.max_lba >= 9_000,
"sampling read the LARGER title's extent (lba>=9000), not the small one \
(max_lba={})",
reader.max_lba
);
assert!(
resolved.key_fetch.is_some(),
"an AACS disc still retains a read-time fetch"
);
}
/// A non-AACS disc (CSS / unencrypted — `inputs()` is `None`): resolution is a
/// no-op. Empty trace, NO fetch, disc untouched. This is the out-of-the-box
/// CSS/None path that must keep working with no keydb.
@@ -642,4 +736,22 @@ mod tests {
"the disc is left untouched"
);
}
/// `resolve_keys` called before `scan` (disc slot still `None`) must return the
/// clean typed `DeviceNotReady` guard, never reach the `.expect("disc present
/// (checked above)")` below it and panic.
///
/// Mutation: change the `if self.disc.is_none()` guard to `.expect()`/panic
/// (e.g. drop the early return) → this test panics instead of getting an Err.
#[test]
fn resolve_keys_before_scan_is_clean_device_not_ready() {
let mut session = DiscSession::from_parts_for_test(None, None, None);
let err = session
.resolve_keys(factory_of(|| HasUnitKey([1; 16])))
.expect_err("resolve_keys before scan must error, not panic");
assert!(
matches!(err, Error::DeviceNotReady { .. }),
"expected DeviceNotReady, got {err:?}"
);
}
}
-91
View File
@@ -1,91 +0,0 @@
//! Drive speed constants.
/// Common optical drive speeds with KB/s values for SET_CD_SPEED.
///
/// Ordering is by [`to_kbps`](Self::to_kbps) throughput, not declaration
/// order — `PartialOrd`/`Ord` are implemented manually so e.g.
/// `DVD1x < BD1x` (1385 < 4500 KB/s) holds. A naive derive would have
/// ordered by variant position, making the slow DVD speeds sort above the
/// fast BD speeds. `Max` (0xFFFF) sorts highest, as intended.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DriveSpeed {
BD1x,
BD2x,
BD4x,
BD6x,
BD8x,
BD10x,
BD12x,
DVD1x,
DVD2x,
DVD4x,
DVD8x,
DVD16x,
Max,
}
impl DriveSpeed {
/// Throughput in KB/s for the SET_CD_SPEED CDB. `Max` maps to the
/// 0xFFFF sentinel that tells the drive to use its maximum speed.
pub fn to_kbps(self) -> u16 {
match self {
DriveSpeed::BD1x => 4_500,
DriveSpeed::BD2x => 9_000,
DriveSpeed::BD4x => 18_000,
DriveSpeed::BD6x => 27_000,
DriveSpeed::BD8x => 36_000,
DriveSpeed::BD10x => 45_000,
DriveSpeed::BD12x => 54_000,
DriveSpeed::DVD1x => 1_385,
DriveSpeed::DVD2x => 2_770,
DriveSpeed::DVD4x => 5_540,
DriveSpeed::DVD8x => 11_080,
DriveSpeed::DVD16x => 22_160,
DriveSpeed::Max => 0xFFFF,
}
}
}
impl PartialOrd for DriveSpeed {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for DriveSpeed {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.to_kbps().cmp(&other.to_kbps())
}
}
impl std::fmt::Display for DriveSpeed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// `Max` is the "let the drive pick its maximum" sentinel; printing
// its 0xFFFF KB/s value would read as a real (absurd) throughput.
match self {
DriveSpeed::Max => write!(f, "Max"),
_ => write!(f, "{:?} ({} KB/s)", self, self.to_kbps()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ordering_is_by_throughput_not_declaration() {
assert!(DriveSpeed::DVD1x < DriveSpeed::BD1x);
assert!(DriveSpeed::DVD16x < DriveSpeed::BD8x);
assert!(DriveSpeed::BD12x < DriveSpeed::Max);
let mut v = [DriveSpeed::Max, DriveSpeed::DVD1x, DriveSpeed::BD4x];
v.sort();
assert_eq!(v, [DriveSpeed::DVD1x, DriveSpeed::BD4x, DriveSpeed::Max]);
}
#[test]
fn max_display_omits_sentinel_value() {
assert_eq!(DriveSpeed::Max.to_string(), "Max");
assert!(DriveSpeed::BD1x.to_string().contains("4500 KB/s"));
}
}
+8 -15
View File
@@ -731,11 +731,9 @@ pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result<UdfFs> {
let tag_id = u16::from_le_bytes([avdp[0], avdp[1]]);
if tag_id != 2 {
return Err(Error::DiscRead {
sector: 256,
status: None,
sense: None,
});
// Sector 256 read fine but carries no Anchor Volume Descriptor Pointer:
// this is deterministically not a UDF disc, not a transient read fault.
return Err(Error::UdfNotFilesystem);
}
// Main VDS extent location: bytes [16:20] = LBA, [20:24] = length
@@ -776,11 +774,8 @@ pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result<UdfFs> {
}
if partition_start == 0 {
return Err(Error::DiscRead {
sector: 0,
status: None,
sense: None,
});
// No Partition Descriptor found in the VDS: structurally not a UDF disc.
return Err(Error::UdfNotFilesystem);
}
// Step 3: Parse partition maps from LVD to find metadata partition
@@ -871,11 +866,9 @@ pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result<UdfFs> {
let fsd_tag = u16::from_le_bytes([fsd[0], fsd[1]]);
if fsd_tag != 256 {
return Err(Error::DiscRead {
sector: metadata_start as u64,
status: None,
sense: None,
});
// The metadata sector read fine but carries no File Set Descriptor:
// structurally not a UDF disc, not a transient read fault.
return Err(Error::UdfNotFilesystem);
}
// Root Directory ICB: long_ad at FSD offset 400
-844
View File
@@ -1,844 +0,0 @@
//! 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, FileSectorSource,
SectorSource,
};
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 SectorSource 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_sectors(&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 SectorSource 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_sectors(&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,
aacs_error: None,
css_error: None,
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 ──────────────────────────
#[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,
false,
None,
)
.unwrap();
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);
assert!(
n > 0,
"expected at least one BytesRead event during disc copy, got {n}"
);
}
// ── 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) -> bool {
self.calls.fetch_add(1, Ordering::Relaxed);
self.last_bytes.store(p.bytes_good_total, Ordering::Relaxed);
true
}
}
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,
false,
None,
)
.unwrap();
// 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. FileSectorSource 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_path_buf();
let mut fsr = FileSectorSource::open(&path).expect("open FileSectorSource");
assert_eq!(
fsr.capacity_sectors(),
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 SectorSource 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_sectors(&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 — Pass 1 must NOT infinite-loop on a fully-failing
// reader. The threshold accommodates the 2026-05-10 wedge-
// avoidance pause (PASS_1_FAIL_PAUSE_SECS = 5 s on each failed
// batch). With batch=32 and 1024 sectors that's up to ~5 batch
// failures + a few damage-jump pauses before fast-trigger jumps
// us past end-of-disc — well-bounded total, ~20-30 s typical.
// The point of this test is "finishes cleanly, not infinitely",
// not "completes in milliseconds."
assert!(
elapsed < Duration::from_secs(60),
"Pass 1 took {elapsed:?} on a 2 MB synthetic disc — expected < 60 s (not infinite)"
);
// 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 SectorSource 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_sectors(&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's job is "fast and accurate, get the most data in the
// shortest time." It no longer bisects on marginal media — that's
// Pass N's purpose-built role. So a BlockSizeFailingReader that
// fails on multi-sector reads and succeeds on single-sector
// results in: every batch fails → SkipBlock → whole 32-sector
// ECC block marked NonTrimmed → Pass N (Disc::patch) revisits and
// recovers via single-sector reads with proper recovery semantics.
//
// Pass 1 alone:
assert_eq!(
result.bytes_good, 0,
"Pass 1 doesn't bisect on marginal media — failed batches become NonTrimmed for Pass N to revisit"
);
assert_eq!(
result.bytes_pending, total_bytes,
"every sector is NonTrimmed (pending) after Pass 1, awaiting Pass N"
);
assert!(
!result.complete,
"complete=false because NonTrimmed regions remain (Pass N's work)"
);
}
// ── 9. PassProgress carries separate unreadable vs pending byte counts ─────
//
// 2026-05-11 design call: Pass N never marks bytes as `Unreadable` mid-multipass —
// failed reads stay `NonTrimmed` so the next pass can retry them. The orchestrator
// (autorip) promotes still-NonTrimmed bytes to Unreadable after the FINAL retry
// pass completes. This test was rewritten from its pre-design-call shape (which
// asserted Pass 2 produced bytes_unreadable > 0) to verify the new invariant:
// pass-level retries keep failed bytes in `bytes_pending` so subsequent passes
// get more shots at them.
#[test]
fn test_pass2_leaves_failed_reads_as_pending_not_unreadable() {
let capacity_sectors: u32 = 128;
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 pass1 = disc.copy(&mut reader, &iso_path, &opts).expect("pass1 ok");
assert_eq!(pass1.bytes_good, 0, "pass1: no good sectors");
assert_eq!(pass1.bytes_unreadable, 0, "pass1: no confirmed unreadable");
assert_eq!(
pass1.bytes_pending, total_bytes,
"pass1: all sectors NonTrimmed"
);
let last_unreadable = Arc::new(AtomicU64::new(0));
let last_pending = Arc::new(AtomicU64::new(0));
let last_good = Arc::new(AtomicU64::new(0));
let last_dur = Arc::new(AtomicU64::new(0));
struct SnapshotReporter {
unreadable: Arc<AtomicU64>,
pending: Arc<AtomicU64>,
good: Arc<AtomicU64>,
dur: Arc<AtomicU64>,
}
impl libfreemkv::progress::Progress for SnapshotReporter {
fn report(&self, p: &libfreemkv::progress::PassProgress) -> bool {
self.unreadable
.store(p.bytes_unreadable_total, Ordering::Relaxed);
self.pending.store(p.bytes_pending_total, Ordering::Relaxed);
self.good.store(p.bytes_good_total, Ordering::Relaxed);
if let Some(d) = p.disc_duration_secs {
self.dur.store((d * 1000.0) as u64, Ordering::Relaxed);
}
true
}
}
let reporter = SnapshotReporter {
unreadable: last_unreadable.clone(),
pending: last_pending.clone(),
good: last_good.clone(),
dur: last_dur.clone(),
};
let pass2_opts = CopyOptions {
decrypt: false,
multipass: true,
progress: Some(&reporter),
..Default::default()
};
let pass2 = disc
.copy(&mut reader, &iso_path, &pass2_opts)
.expect("pass2 ok");
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(libfreemkv::disc::mapfile_path_for(&iso_path));
assert_eq!(
pass2.bytes_good, 0,
"pass2: still no good sectors (reader always fails)"
);
// 2026-05-11 design: pass-level retries do NOT promote failed bytes
// to Unreadable. Failed bytes stay NonTrimmed (pending) so a later
// pass can retry. End-of-recovery promotion is an orchestrator
// concern (autorip), not the patch loop's.
assert_eq!(
pass2.bytes_unreadable, 0,
"pass2: Disc::patch never marks Unreadable mid-multipass — orchestrator promotes after final pass"
);
// bytes_pending stays at total_bytes because everything still
// failed and nothing got recovered or promoted out of pending.
assert_eq!(
pass2.bytes_pending, total_bytes,
"pass2: failed bytes remain NonTrimmed for the next pass to retry"
);
let observed_unreadable = last_unreadable.load(Ordering::Relaxed);
let observed_pending = last_pending.load(Ordering::Relaxed);
assert_eq!(
observed_unreadable, 0,
"progress should report zero confirmed-unreadable mid-pass under the new design"
);
assert!(
observed_pending > 0,
"progress should report pending bytes as the reader keeps failing"
);
// Video damage time: unreadable / total * duration
// With no titles on synthetic disc, disc_duration_secs = None
assert_eq!(
last_dur.load(Ordering::Relaxed),
0,
"synthetic disc has no titles, duration should be None/0"
);
}
// ── 10. Damage time calculation (unit test) ────────────────────────────────
//
// Verifies the formula: damage_secs = bytes_unreadable / bytes_total * duration
// This mirrors the CLI's print_disc_progress logic.
#[test]
fn test_damage_time_calculation() {
// 78.8 GB disc, 2h45m movie (9900s), 74 KB unreadable
let disc_bytes: u64 = 78_800_000_000;
let duration_secs: f64 = 9900.0;
let cases: Vec<(u64, &str)> = vec![
(74 * 1024, "~10ms"), // 74 KB → ~9ms, negligible
(10 * 1024 * 1024, "~1.3s"), // 10 MB → ~1.3s
(100 * 1024 * 1024, "~13s"), // 100 MB → ~13s
(1024 * 1024 * 1024, "~134s"), // 1 GB → ~134s
];
for (bad_bytes, label) in cases {
let damage_secs = bad_bytes as f64 / disc_bytes as f64 * duration_secs;
match label {
"~10ms" => assert!(damage_secs < 0.05, "{label}: {damage_secs:.3}s"),
"~1.3s" => assert!(
(damage_secs - 1.3).abs() < 0.2,
"{label}: {damage_secs:.2}s"
),
"~13s" => assert!(
(damage_secs - 13.0).abs() < 1.0,
"{label}: {damage_secs:.1}s"
),
"~134s" => assert!(
(damage_secs - 134.0).abs() < 2.0,
"{label}: {damage_secs:.0}s"
),
_ => {}
}
}
// 0.25s threshold: how many bad bytes = 0.25s of damage?
let threshold_bytes = (0.25 / duration_secs * disc_bytes as f64) as u64;
assert!(
threshold_bytes > 0,
"0.25s damage threshold should be > 0 bytes"
);
// At 9900s / 78.8 GB ≈ 0.25s = ~2 MB
let expected_mb = threshold_bytes as f64 / (1024.0 * 1024.0);
assert!(
(expected_mb - 2.0).abs() < 0.5,
"0.25s ≈ {expected_mb:.2} MB (expected ~2 MB)"
);
}
-491
View File
@@ -1,491 +0,0 @@
//! Pass N (Disc::patch) size-aware-skip targeted tests.
//!
//! The user's failure mode (2026-05-07): "what if we have a 100 sector zone
//! and its really 2 25 sector zones and we keep jumping over the good in
//! the middle." Today's pre-fix patch escalates skip-distance based on
//! `consecutive_skips_without_recovery` with hardcoded 32 → 4096 sector
//! caps. A 100-sector bad range whose actual layout is 25 bad + 50 good +
//! 25 bad would have the patch skip 32-4096 sectors after a couple of
//! failures, leaping over the entire range AND the good middle.
//!
//! The fix: cap each skip at `range_remaining/4`. These tests exercise
//! that boundary.
use libfreemkv::disc::CopyOptions;
use libfreemkv::disc::DiscRegion;
use libfreemkv::disc::PatchOptions;
use libfreemkv::disc::mapfile::{Mapfile, SectorStatus};
use libfreemkv::error::Result;
use libfreemkv::{ContentFormat, Disc, DiscFormat, SectorSource};
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
const SECTOR_SIZE: usize = 2048;
/// Reader where you specify exactly which LBAs return Err. Everything else
/// returns Ok with the LBA encoded in each byte for verification.
struct PatternedSectorReader {
capacity: u32,
bad_lbas: HashSet<u32>,
/// Trace every read so tests can assert what was actually attempted.
trace: Arc<Mutex<Vec<(u32, u16)>>>,
}
type ReadTrace = Arc<Mutex<Vec<(u32, u16)>>>;
impl PatternedSectorReader {
fn new(capacity: u32, bad_lbas: HashSet<u32>) -> (Self, ReadTrace) {
let trace = Arc::new(Mutex::new(Vec::new()));
(
Self {
capacity,
bad_lbas,
trace: trace.clone(),
},
trace,
)
}
}
impl SectorSource for PatternedSectorReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> Result<usize> {
self.trace.lock().unwrap().push((lba, count));
// Whole-batch fails if ANY sector in the batch is bad. (Models a
// real drive: a multi-sector READ aborts on the first ECC failure.)
for offset in 0..count as u32 {
if self.bad_lbas.contains(&(lba + offset)) {
return 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,
}),
});
}
}
// Fill each sector with ITS OWN LBA byte, not the starting LBA's
// byte. This matches real drive behavior: a multi-sector READ
// returns per-sector-correct data. Pre-0.18.13 only single-sector
// reads were exercised by patch tests, so the cheaper "fill the
// whole batch with one byte" worked; adaptive batching needs the
// per-sector pattern to verify correct positioning.
for (i, chunk) in buf.chunks_mut(SECTOR_SIZE).enumerate() {
chunk.fill(((lba + i as u32) & 0xff) as u8);
}
Ok(buf.len())
}
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
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,
aacs_error: None,
css_error: None,
content_format: ContentFormat::BdTs,
}
}
/// Pre-populate a mapfile with one large NonTrimmed range so patch's work-
/// list has something to do. Caller pre-allocates the ISO at `total_bytes`
/// so seeks don't fail.
fn prep_iso_and_mapfile(
iso_path: &std::path::Path,
total_bytes: u64,
finished_ranges: &[(u64, u64)],
nontrimmed_ranges: &[(u64, u64)],
) {
use std::fs::OpenOptions;
use std::io::{Seek, SeekFrom, Write};
let mut f = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(iso_path)
.unwrap();
f.set_len(total_bytes).unwrap();
f.seek(SeekFrom::Start(0)).unwrap();
f.write_all(&[]).unwrap();
let map_path = libfreemkv::disc::mapfile_path_for(iso_path);
let mut mf = Mapfile::create(&map_path, total_bytes, "test").unwrap();
for &(pos, size) in finished_ranges {
mf.record(pos, size, SectorStatus::Finished).unwrap();
}
for &(pos, size) in nontrimmed_ranges {
mf.record(pos, size, SectorStatus::NonTrimmed).unwrap();
}
}
/// THE critical test. A 100-sector "bad" range hides 50 good sectors in
/// the middle (LBAs 125-174). Pre-fix patch would skip-escalate at 32+
/// sectors and leap over the whole range. Post-fix: skip is capped at
/// range_remaining/4 (=25 sectors initially), which forces convergence.
#[test]
fn patch_recovers_good_middle_of_a_bad_range() {
let capacity_sectors: u32 = 1024;
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
// Bad range layout: LBAs 100-124 bad, 125-174 GOOD, 175-199 bad.
let mut bad_lbas = HashSet::new();
for lba in 100..125 {
bad_lbas.insert(lba);
}
for lba in 175..200 {
bad_lbas.insert(lba);
}
let (mut reader, _trace) = PatternedSectorReader::new(capacity_sectors, bad_lbas);
let disc = synthetic_disc(capacity_sectors);
let tmp = tempfile::NamedTempFile::new().unwrap();
let iso_path = tmp.path().to_path_buf();
drop(tmp);
// Pre-populate: 0..100 already Finished from an imagined Pass 1,
// 100..200 NonTrimmed (the range we want patch to retry),
// 200..1024 already Finished.
let finished = [
(0, 100 * 2048),
(200 * 2048, (capacity_sectors as u64 - 200) * 2048),
];
let nontrimmed = [(100 * 2048, 100 * 2048)];
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
// Run patch.
// disc.copy() with multipass=true auto-dispatches to patch when the
// mapfile already covers the disc and has retryable ranges.
let opts = CopyOptions {
decrypt: false,
multipass: true,
..Default::default()
};
let pr = disc
.copy(&mut reader, &iso_path, &opts)
.expect("copy returns Ok");
// Re-load mapfile and inspect.
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
let map = Mapfile::load(&map_path).unwrap();
// The good middle (125..175) MUST end up Finished. If size-aware skip
// is not enabled, patch would skip 32+ sectors after a few failures
// and leap clean over LBA 125 → middle stays NonTrimmed.
let finished_ranges = map.ranges_with(&[SectorStatus::Finished]);
let total_finished_in_middle: u64 = finished_ranges
.iter()
.map(|&(pos, sz)| {
let start = pos.max(125 * 2048);
let end = (pos + sz).min(175 * 2048);
end.saturating_sub(start)
})
.sum();
// Allow 2 sectors (4 KB) of boundary slop — patch's bisection may
// not converge exactly on the good/bad boundary in a single pass,
// and that's acceptable. The pre-fix behaviour would have left the
// entire good middle as NonTrimmed (~0 bytes recovered).
let good_middle_bytes: u64 = 50 * 2048;
let min_acceptable: u64 = good_middle_bytes - 2 * 2048;
// Cleanup before assertions
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(&map_path);
assert!(
total_finished_in_middle >= min_acceptable,
"size-aware skip should have discovered most of the 50 good sectors in the middle. \
Recovered {} of {} good middle bytes (min acceptable {}). bytes_good={} bytes_total={}",
total_finished_in_middle,
good_middle_bytes,
min_acceptable,
pr.bytes_good,
pr.bytes_total,
);
}
/// Regression: `PatchOptions::block_sectors == Some(0)` must not
/// busy-spin. `block_sectors` is a public `Option<u16>` field; a zero
/// value would compute a zero-length read every iteration, never
/// advance `block_end`, and burn a CPU core until the per-range
/// watchdog fired (up to 30 min on a large range). The entry-point
/// `.max(1)` clamp turns Some(0) into a single-sector batch so the
/// range recovers and the call returns promptly.
#[test]
fn patch_block_sectors_zero_does_not_busy_spin() {
let capacity_sectors: u32 = 256;
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
// Small NonTrimmed range that is entirely readable (no bad LBAs), so
// single-sector patch reads recover it immediately. Without the
// clamp the loop would never progress regardless of readability.
let (mut reader, _trace) = PatternedSectorReader::new(capacity_sectors, HashSet::new());
let disc = synthetic_disc(capacity_sectors);
let tmp = tempfile::NamedTempFile::new().unwrap();
let iso_path = tmp.path().to_path_buf();
drop(tmp);
let finished = [
(0, 100 * 2048),
(110 * 2048, (capacity_sectors as u64 - 110) * 2048),
];
let nontrimmed = [(100 * 2048, 10 * 2048)];
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
// A halt watchdog bounds the run: the inner loop polls `halt` every
// iteration, so even a busy-spin regression breaks out within the
// window instead of hanging the test binary. With the clamp the run
// finishes long before the watchdog fires; without it the watchdog
// trips and the bytes_good assertion below fails loudly.
let halt = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let halt_for_watchdog = halt.clone();
let watchdog = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(20));
halt_for_watchdog.store(true, std::sync::atomic::Ordering::Relaxed);
});
let opts = PatchOptions {
decrypt: false,
block_sectors: Some(0),
full_recovery: false,
reverse: false,
wedged_threshold: 0,
progress: None,
halt: Some(halt.clone()),
key_fetch: None,
};
let outcome = disc.patch(&mut reader, &iso_path, &opts);
// Stop the watchdog regardless of outcome.
halt.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = watchdog.join();
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(&map_path);
let outcome = outcome.expect("patch returns Ok");
assert!(
!outcome.halted,
"patch with block_sectors=Some(0) must complete on its own \
(clamped to a 1-sector batch), not be cut off by the watchdog"
);
let bytes_good = outcome.bytes_good;
// The 10-sector NonTrimmed range was fully readable; clamped to a
// 1-sector batch it must recover. Initial good = 100 + (256-110) =
// 246 sectors; after patch the 10-sector range is also Finished.
let initial_good_sectors: u64 = 100 + (capacity_sectors as u64 - 110);
assert!(
bytes_good >= (initial_good_sectors + 10) * 2048,
"block_sectors=Some(0) clamped to 1 should recover the readable range; \
bytes_good={bytes_good}"
);
}
/// A second test: a bad range that's actually 4 small bad sub-zones
/// separated by good sectors. Demonstrates the bisection behaviour
/// converges when zones are non-uniform.
#[test]
fn patch_recovers_multiple_good_middles() {
let capacity_sectors: u32 = 2048;
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
// Bad pattern: 1000-1024 bad, 1025-1099 good, 1100-1124 bad,
// 1125-1199 good, 1200-1224 bad, 1225-1299 good.
let mut bad_lbas = HashSet::new();
for lba in 1000..1025 {
bad_lbas.insert(lba);
}
for lba in 1100..1125 {
bad_lbas.insert(lba);
}
for lba in 1200..1225 {
bad_lbas.insert(lba);
}
let (mut reader, _trace) = PatternedSectorReader::new(capacity_sectors, bad_lbas);
let disc = synthetic_disc(capacity_sectors);
let tmp = tempfile::NamedTempFile::new().unwrap();
let iso_path = tmp.path().to_path_buf();
drop(tmp);
let finished = [
(0, 1000 * 2048),
(1300 * 2048, (capacity_sectors as u64 - 1300) * 2048),
];
let nontrimmed = [(1000 * 2048, 300 * 2048)];
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
let opts = CopyOptions {
decrypt: false,
multipass: true,
..Default::default()
};
let pr = disc
.copy(&mut reader, &iso_path, &opts)
.expect("copy returns Ok");
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
let map = Mapfile::load(&map_path).unwrap();
let finished_ranges = map.ranges_with(&[SectorStatus::Finished]);
let recovered: u64 = finished_ranges
.iter()
.map(|&(pos, sz)| {
let start = pos.max(1000 * 2048);
let end = (pos + sz).min(1300 * 2048);
end.saturating_sub(start)
})
.sum();
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(&map_path);
// Three good middles of 75 sectors each = 225 good sectors in the
// bad range. Total bad = 75. So we want at least most of 225 sectors
// (= 460800 bytes) to be Finished after patch.
let target = 200 * 2048; // be generous — anything over 200 sectors is convincing
assert!(
recovered >= target,
"size-aware skip should find most of the 3 good middles. \
Recovered {} bytes; expected {}. bytes_good={} bytes_total={}",
recovered,
target,
pr.bytes_good,
pr.bytes_total,
);
}
/// 0.18 Pass N pipeline split: exercises the new producer/consumer
/// path end-to-end on a synthetic patterned reader. Bad range layout
/// is small (5 bad LBAs surrounded by good middle) so the producer
/// emits a mix of `Recovered` and `NonTrimmed` items and the consumer
/// thread must apply both kinds. Verifies:
///
/// - `bytes_good` advances (good sectors flow producer→consumer→file
/// →mapfile with the data preserved).
/// - The recovered LBAs end up Finished; the bad LBAs end up NonTrimmed
/// (NOT Unreadable — promotion to Unreadable is the orchestrator's job
/// after the final pass).
/// - Bytes written at the recovered offsets match what the producer
/// read from the patterned source (proves the channel hand-off
/// didn't drop or reorder buffers, and the consumer's seek+write
/// landed at the right offsets).
#[test]
fn patch_pipeline_split_recovers_and_records_correctly() {
let capacity_sectors: u32 = 512;
let total_bytes: u64 = capacity_sectors as u64 * SECTOR_SIZE as u64;
// Layout: LBAs 200-204 inclusive are bad (5 sectors), 205-249 good.
// The pre-existing range is LBAs 200-249 NonTrimmed (100 KB).
let mut bad_lbas = HashSet::new();
for lba in 200..205 {
bad_lbas.insert(lba);
}
let (mut reader, _trace) = PatternedSectorReader::new(capacity_sectors, bad_lbas.clone());
let disc = synthetic_disc(capacity_sectors);
let tmp = tempfile::NamedTempFile::new().unwrap();
let iso_path = tmp.path().to_path_buf();
drop(tmp);
let finished = [
(0, 200 * 2048),
(250 * 2048, (capacity_sectors as u64 - 250) * 2048),
];
let nontrimmed = [(200 * 2048, 50 * 2048)];
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
let opts = CopyOptions {
decrypt: false,
multipass: true,
..Default::default()
};
let pr = disc
.copy(&mut reader, &iso_path, &opts)
.expect("copy returns Ok");
// Bytes_good_total should advance — the good LBAs in the bad range
// (205-249, 45 sectors) are all reachable via per-sector retry.
// Initial bytes_good = 200 * 2048 + (512-250) * 2048 = 462 sectors.
// After patch, bytes_good should be ≥ 462 + 45 = 507 sectors worth.
let initial_good_sectors: u64 = 200 + (capacity_sectors as u64 - 250);
let min_expected_good_bytes = (initial_good_sectors + 30) * 2048;
assert!(
pr.bytes_good >= min_expected_good_bytes,
"patch should have recovered most good LBAs in the bad range via the pipeline. \
bytes_good={} (expected {}); bytes_total={}",
pr.bytes_good,
min_expected_good_bytes,
pr.bytes_total,
);
// Verify the mapfile records: every good LBA is Finished, every
// bad LBA is NonTrimmed (not Finished).
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
let map = Mapfile::load(&map_path).unwrap();
let finished_ranges = map.ranges_with(&[SectorStatus::Finished]);
let in_finished = |lba: u32| -> bool {
let pos = lba as u64 * 2048;
finished_ranges
.iter()
.any(|&(p, sz)| pos >= p && pos < p + sz)
};
for lba in 205..250 {
assert!(
in_finished(lba),
"good LBA {lba} should be Finished after pipeline patch run"
);
}
for lba in 200..205 {
assert!(
!in_finished(lba),
"bad LBA {lba} should NOT be Finished after pipeline patch run"
);
}
// Verify the consumer wrote the producer's bytes at the right
// offsets. PatternedSectorReader fills each sector with `(lba & 0xff)
// as u8` — picking LBA 220 (well inside the recovered region) gives
// a clean signature byte to check.
use std::io::{Read, Seek, SeekFrom};
let mut iso = std::fs::File::open(&iso_path).unwrap();
iso.seek(SeekFrom::Start(220 * 2048)).unwrap();
let mut sector = [0u8; 2048];
iso.read_exact(&mut sector).unwrap();
let expected_byte = (220u32 & 0xff) as u8;
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(&map_path);
assert!(
sector.iter().all(|&b| b == expected_byte),
"consumer should have written PatternedSectorReader's pattern \
(byte {expected_byte:#x} for LBA 220) to the recovered offset; \
got first 8 bytes = {:?}",
&sector[..8]
);
}
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -141,10 +141,20 @@ fn scan_iso_matches_manual_scan_image_path() {
.expect("manual scan_image succeeds");
assert_eq!(disc.capacity_sectors, manual.capacity_sectors, "capacity");
assert_eq!(disc.capacity_sectors, expected_capacity, "capacity value");
assert_eq!(disc.titles.len(), manual.titles.len(), "title count");
assert_eq!(disc.encrypted, manual.encrypted, "encrypted flag");
assert_eq!(disc.format, manual.format, "disc format");
// Independent expectations (not parity against a re-run of the same
// composition): the scanned Disc must match KNOWN properties of the fixture
// itself — its capacity (max LBA + 1), its PVD volume id ("TEST_DISC"), and
// that a UDF with no /AACS directory is unencrypted. These would fail even if
// scan_iso and the manual path drifted together.
assert_eq!(disc.capacity_sectors, expected_capacity, "capacity value");
assert_eq!(
disc.volume_id, "TEST_DISC",
"PVD volume id from the fixture"
);
assert!(!disc.encrypted, "minimal UDF (no /AACS) is not encrypted");
// The returned reader is usable: correct capacity and a real read of sector