Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
767d205fd4 | ||
|
|
bbb5a953f1 | ||
|
|
3f238ab3dc | ||
|
|
ec62618bde | ||
|
|
15111d544b | ||
|
|
19b646e012 | ||
|
|
17694af625 | ||
|
|
828f5c0192 | ||
|
|
075223a4bd | ||
|
|
94ac4a442a | ||
|
|
f468237279 | ||
|
|
227545fabc | ||
|
|
05fd7fcbfa | ||
|
|
967d0ac77e | ||
|
|
eb0aa3556e | ||
|
|
7ba43d9c02 | ||
|
|
bf9a69ec80 | ||
|
|
6d6e60fdf8 | ||
|
|
661ab138c6 |
+51
-1
@@ -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
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "1.5.2"
|
||||
version = "1.6.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "MIT"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -1,6 +1,7 @@
|
||||
//! `Disc::extract_tree` — decrypted file-tree extraction (`dir://`).
|
||||
//!
|
||||
//! Sibling of [`Disc::copy`](super::Disc::copy) (disc → ISO sector dump),
|
||||
//! Sibling of the disc→ISO 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
File diff suppressed because it is too large
Load Diff
+41
-2448
File diff suppressed because it is too large
Load Diff
-1666
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
@@ -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 = {:?}",
|
||||
§or[..8]
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user