DTS core decodability gate (core_header_drop_reason) — full ETSI TS 102 114
spec-conformance sweep against ffmpeg ff_dca_parse_core_frame_header and
dcadec parse_frame_header:
- deficit_samples: only require ==32 for NORMAL frames (FTYPE==1). A
TERMINATION frame (FTYPE==0, the last frame of a stream) legitimately
carries fewer and is fully decodable; the old unconditional check dropped
it on every stream that ends on one — a guaranteed per-track silence gap.
Matches ffmpeg (normal_frame && deficit != DCA_PCMBLOCK_SAMPLES) and
dcadec (branches on normal_frame).
- reserved bit (after RATE): both reference decoders SKIP it (ffmpeg
skip_bits1, dcadec bits_skip1 "Reserved field") and never reject on it.
Rejecting was a false-drop that silenced any real stream whose encoder
set the bit. Relaxed to read-and-discard; DropReason::ReservedBit removed.
Swept and confirmed spec-correct as-is (no change): npcmblocks multiple-of-8,
frame_size>=96, audio_mode>=16 (ffmpeg-permissive), sample-rate validity
table (matches avpriv_dca_sample_rates incl 96k/192k at 14/15), LFE flag==3
invalid, PCMR bits table (matches dcadec sample_res {16,16,20,20,0,24,24,0}).
Bit-read order verified field-by-field against dcadec. bit_rate is left
unvalidated (lenient, never-false-drop direction) as before.
Tests: termination frame with small deficit is kept; normal frame with bad
deficit is dropped; reserved-bit-set frame is kept. make_bad_dts_core now
uses an invalid LFE flag (duration-neutral) instead of the relaxed reserved
bit.
TrueHD: add coverage for the EXTENDED major-sync header CRC path (ms[25]&1,
mshdr=28+2+2n) — previously zero-tested, the exact path a shipped endianness
bug once used to silently drop whole 7.1/Atmos tracks. Trailer is an
independently-computed oracle (separate CRC-16/0x2D, anchored to the 0x4FF7
catalogue value, NOT crc16_mlp), stored little-endian; test asserts accept,
body-corruption reject, and big-endian-trailer reject.
mux driver: extract the finish completion mapping into pure mux_run_completed
so the finalize_failed -> completed=false branch (reachable only via real
write-thread wedge timing) is unit-tested; add an out-of-range
MuxInput::Session title_index test asserting a clean Error::MuxTrackRange
(E9011) instead of a panic.
2089 lines
86 KiB
Rust
2089 lines
86 KiB
Rust
//! High-level mux driver — the one place that runs the
|
||
//! `construct → headers gate → open sink → pump → finish` pipeline the
|
||
//! consumers (CLI `pipe`/`pipe_disc`, autorip `run_mux`) each hand-rolled.
|
||
//!
|
||
//! `mux_stream` DRIVES the existing highway; it does not replace it. For a
|
||
//! file/ISO source it constructs the SAME
|
||
//! [`build_iso_pipeline`](crate::mux::resolve::build_iso_pipeline) 3-stage
|
||
//! prefetch → demux → parse chain the consumers build today (the 660 MB/s
|
||
//! highway), reads frames off it exactly where the consumers call
|
||
//! `stream.read()`, and writes them through a
|
||
//! [`WRITE_PIPELINE_DEPTH`]-deep write [`Pipeline`] so the latency-bound sink
|
||
//! write overlaps the next read. No wrapper is inserted around the reader or
|
||
//! the frames — the only threads are the three inside `build_iso_pipeline` plus
|
||
//! the single write consumer, exactly as in the consumers today.
|
||
//!
|
||
//! ## Gate ordering (bug fix)
|
||
//!
|
||
//! The `chapters://` / `json://` metadata sinks write their whole file from the
|
||
//! scanned title at `output()` time and consume no PES frames, so they need no
|
||
//! codec headers. The CLI put that short-circuit AFTER the `headers_resolved`
|
||
//! gate (`pipe.rs`), so a metadata export on a title whose video headers never
|
||
//! resolved failed with `MkvInvalid`. Here the short-circuit runs BEFORE the
|
||
//! header pump/gate — by construction a metadata sink can never trip the header
|
||
//! gate.
|
||
|
||
use std::path::Path;
|
||
use std::sync::Arc;
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
use std::time::Duration;
|
||
|
||
use crate::decrypt::{AacsKeyMap, DecryptKeys};
|
||
use crate::disc::DiscTitle;
|
||
use crate::error::Error;
|
||
use crate::event::{BatchSizeReason, Event, EventKind};
|
||
use crate::halt::Halt;
|
||
use crate::io::pipeline::{Flow, Pipeline, Sink, WRITE_PIPELINE_DEPTH};
|
||
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, 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
|
||
/// path). A slow-but-alive downstream — a paused pager, a backpressured pipe,
|
||
/// a slow network sink — must NOT be reported as an interrupted mux, which is
|
||
/// exactly what a finite per-frame deadline would do after that much
|
||
/// backpressure. `None` therefore means "block on backpressure, don't time
|
||
/// out", while still honouring the halt token for Ctrl-C / `/api/stop`.
|
||
///
|
||
/// This is safe because [`Pipeline::send_with_halt`] slices its wait at
|
||
/// [`crate::halt::POLL_INTERVAL`] (250 ms) internally and re-checks the halt
|
||
/// token on EVERY slice — halt responsiveness is independent of the deadline
|
||
/// value. So a huge (but finite, `Instant`-overflow-safe) duration blocks
|
||
/// indefinitely on a live-but-slow sink yet still unwinds within ~250 ms of a
|
||
/// halt. ~10 years is far past any real mux; a literal `Duration::MAX` risks
|
||
/// panicking the `Instant::now() + deadline` addition inside `send_with_halt`.
|
||
const NO_SEND_DEADLINE: Duration = Duration::from_secs(10 * 365 * 24 * 60 * 60);
|
||
|
||
/// Resolve [`MuxOptions::send_deadline`] into the concrete per-frame deadline
|
||
/// handed to [`Pipeline::send_with_halt`] — the tightest seam of the
|
||
/// send-path decision.
|
||
///
|
||
/// - `Some(d)` → a hard `d` per-frame timeout. autorip's file mux wants this:
|
||
/// its container-restart watchdog model treats a genuinely wedged sink as a
|
||
/// bounded per-frame timeout rather than an unbounded block.
|
||
/// - `None` → [`NO_SEND_DEADLINE`] (no backpressure timeout). The CLI's
|
||
/// interactive stdout / network sinks must block on a slow-but-alive
|
||
/// downstream, exactly as the pre-refactor inline `output.write()` did, and
|
||
/// surface only a real halt as an interrupt.
|
||
fn effective_send_deadline(send_deadline: Option<Duration>) -> Duration {
|
||
send_deadline.unwrap_or(NO_SEND_DEADLINE)
|
||
}
|
||
|
||
/// Ceiling on bytes buffered while waiting for `headers_ready()` to resolve.
|
||
/// Normally that resolves after a handful of frames; a damaged/undecryptable
|
||
/// title whose video `codec_private` never resolves would otherwise grow the
|
||
/// pre-headers buffer without bound (the whole 30-90 GB title, one PES frame at
|
||
/// a time) until the process is OOM-killed. 512 MiB is far more than any real
|
||
/// codec-private resolution needs but small enough to fail fast rather than
|
||
/// swap the box to death. Once exceeded the mux is refused exactly as the
|
||
/// headers-never-resolved gate refuses it (`Error::MkvInvalid`). Mirrors
|
||
/// autorip's pre-refactor `HEADER_BUFFER_CAP_BYTES`.
|
||
const HEADER_BUFFER_CAP_BYTES: usize = 512 * 1024 * 1024;
|
||
|
||
/// Where [`mux_stream`] reads its PES frames from. The driver owns the
|
||
/// construction of the underlying [`Stream`] so consumers stop hand-rolling
|
||
/// `DiscStream::new` / `build_iso_pipeline` / `input()`.
|
||
pub enum MuxInput<'a> {
|
||
/// Live single-pass mux off an opened [`DiscSession`]. The driver takes the
|
||
/// session's staged reader ([`DiscSession::take_reader`]); a missing reader
|
||
/// (never staged, or already consumed) is a clean error, never a panic.
|
||
Session {
|
||
/// The opened, scanned session (its keys resolved via
|
||
/// [`DiscSession::resolve_keys`]).
|
||
session: &'a mut DiscSession,
|
||
/// Index into `session.disc().titles` of the title to mux.
|
||
title_index: usize,
|
||
},
|
||
/// Multipass / remux from a staged ISO on disk. Keys/map/fetch are the
|
||
/// pre-resolved material the consumer already banked — the driver does no
|
||
/// internal re-resolution beyond what [`build_iso_pipeline`] performs.
|
||
Iso {
|
||
/// Path to the ISO image.
|
||
path: &'a Path,
|
||
/// The scanned title to mux out of the image.
|
||
title: DiscTitle,
|
||
/// Container format of the title (TS vs PS demuxer selection).
|
||
format: crate::disc::ContentFormat,
|
||
/// Decryption keys for the title (`DecryptKeys::None` for raw/clear).
|
||
keys: DecryptKeys,
|
||
/// Optional read-time key fetch closure (banked by `resolve_keys`).
|
||
key_fetch: Option<KeyFetch>,
|
||
},
|
||
/// Live single-pass mux off a raw disc [`SectorSource`] (autorip's
|
||
/// live-drive path). The driver wraps the reader in a
|
||
/// [`DiscStream`](crate::mux::DiscStream) — the **INLINE** stream, NOT the
|
||
/// prefetch highway — so the consumer's adaptive batch-retry in
|
||
/// `DiscStream::fill_extents` still fires on a bad sector (the highway would
|
||
/// bypass it). The live analogue of [`MuxInput::Iso`]: the consumer resolves
|
||
/// keys as its own app-layer policy and hands the already-banked material in;
|
||
/// the driver does no internal re-resolution.
|
||
Live {
|
||
/// The raw sector source (physical drive). Boxed `dyn` — the driver owns
|
||
/// it and moves it into `DiscStream::new` (whose reader param is exactly
|
||
/// `Box<dyn SectorSource>`), so no concrete drive type leaks in here.
|
||
reader: Box<dyn SectorSource>,
|
||
/// The scanned title to mux.
|
||
title: DiscTitle,
|
||
/// Container format (TS vs PS demux selection).
|
||
format: crate::disc::ContentFormat,
|
||
/// Decryption keys the consumer already banked (`DecryptKeys::None` for
|
||
/// raw/clear). The driver consumes them as-is — never re-resolves.
|
||
keys: DecryptKeys,
|
||
/// Optional pre-resolved forensic AACS key map, applied VERBATIM via
|
||
/// [`DiscStream::with_key_map`](crate::mux::DiscStream::with_key_map)
|
||
/// BEFORE any read. `None` for every non-FMTS disc. Single-pass FMTS
|
||
/// correctness depends on this reaching the inline reader: `with_key_map`
|
||
/// rewrites the extent walk to our-phase units only and installs the map
|
||
/// so each unit decrypts with its mapped key.
|
||
key_map: Option<Arc<AacsKeyMap>>,
|
||
},
|
||
/// Any URL-addressed source (`iso://`, `mkv://`, `m2ts://`, `network://`,
|
||
/// stdio) opened via [`input`].
|
||
Url {
|
||
/// The source URL.
|
||
url: &'a str,
|
||
/// Input options forwarded to [`input`].
|
||
opts: InputOptions,
|
||
},
|
||
}
|
||
|
||
/// Tuning / behaviour knobs for a mux run.
|
||
pub struct MuxOptions {
|
||
/// Skip past read errors (zero-fill + continue) on the live-drive path
|
||
/// instead of aborting. Wired onto `DiscStream::skip_errors`.
|
||
pub skip_errors: bool,
|
||
/// Read batch size in logical (2048-byte) sectors.
|
||
pub batch_sectors: u16,
|
||
/// Ciphertext passthrough — skip decryption / CSS self-crack.
|
||
pub raw: bool,
|
||
/// Per-frame write-pipeline send deadline.
|
||
///
|
||
/// - `Some(d)` — a hard `d` timeout: a sink that back-pressures a single
|
||
/// frame past `d` is treated as wedged and the mux returns
|
||
/// `completed = false`. autorip passes `Some(Duration::from_secs(60))`
|
||
/// so its hard watchdog / container-restart model bounds a stuck sink.
|
||
/// - `None` — NO backpressure timeout: the send blocks as long as the
|
||
/// downstream is alive (only a `halt` interrupts it). The CLI's
|
||
/// interactive stdout / network sinks pass `None` so a slow-but-alive
|
||
/// consumer (paused pager, backpressured pipe, slow peer) is never
|
||
/// spuriously reported as an interrupted/incomplete mux — matching the
|
||
/// pre-refactor inline `output.write()` which had no deadline.
|
||
pub send_deadline: Option<Duration>,
|
||
}
|
||
|
||
/// Progress / event callbacks the consumer implements (CLI `CliProgress`,
|
||
/// autorip's stream event handler). Every method has a no-op default so a
|
||
/// consumer overrides only what it renders.
|
||
///
|
||
/// `Send + Sync + 'static` because [`mux_stream`] takes the handle as an
|
||
/// [`Arc<dyn MuxEvents>`] and clones it into the constructors' `'static`
|
||
/// [`EventFn`] (via [`reader_event_fn`]) so the reader-side events fire from the
|
||
/// highway's producer thread / the live `DiscStream`'s read loop. The driver
|
||
/// fires [`Self::on_output_opened`] and the write-side [`Self::on_write_progress`]
|
||
/// from the driving thread; the reader-side events
|
||
/// ([`Self::on_sector_skipped`] / [`Self::on_batch_size_changed`] /
|
||
/// [`Self::on_read_error`], plus [`Self::on_read_progress`]) are fired
|
||
/// by that cloned `EventFn`.
|
||
///
|
||
/// Progress is split into two callbacks because consumers drive their progress
|
||
/// UI from different sides of the pipeline: the CLI renders from the WRITE side
|
||
/// (bytes finalised to the sink), autorip from the READ side (bytes pulled off
|
||
/// the disc). Keeping them distinct avoids the old ambiguous single
|
||
/// `on_progress(bytes, total)` where the caller couldn't tell which number it
|
||
/// was handed.
|
||
pub trait MuxEvents: Send + Sync + 'static {
|
||
/// Fired once, immediately after the output sink is created.
|
||
fn on_output_opened(&self, _title: &DiscTitle) {}
|
||
/// Fired periodically from the reader side with the running read-byte count
|
||
/// and the source extents' total byte estimate.
|
||
fn on_read_progress(&self, _bytes_read: u64, _bytes_total: u64) {}
|
||
/// Fired periodically during the frame pump with the running written-byte
|
||
/// count and the title's total byte estimate.
|
||
fn on_write_progress(&self, _bytes_written: u64, _bytes_total: u64) {}
|
||
/// A bad sector was skipped (zero-filled) at `lba`.
|
||
fn on_sector_skipped(&self, _lba: u32) {}
|
||
/// The adaptive read batch size changed.
|
||
fn on_batch_size_changed(&self, _batch: u16, _reason: BatchSizeReason) {}
|
||
/// A read error occurred at `lba`.
|
||
fn on_read_error(&self, _lba: u32) {}
|
||
}
|
||
|
||
/// A [`MuxEvents`] that ignores everything — for callers that render no
|
||
/// progress.
|
||
pub struct NoopEvents;
|
||
impl MuxEvents for NoopEvents {}
|
||
|
||
/// The result of a [`mux_stream`] run.
|
||
#[derive(Debug, Clone)]
|
||
pub struct MuxOutcome {
|
||
/// The mux drained to a natural EOF, finalised cleanly, and produced real
|
||
/// output. `false` on interrupt (halt), or a wedged/failed finalise.
|
||
pub completed: bool,
|
||
/// The output sink was created (`output()` succeeded). `false` if the mux
|
||
/// bailed before opening the sink (header gate, halt during header read).
|
||
pub output_opened: bool,
|
||
/// Total PES frame-payload bytes written to the sink (matches the CLI's
|
||
/// `CountingStream::bytes_written`).
|
||
pub bytes_written: u64,
|
||
/// Cumulative read-error skip *events* (`Stream::errors`).
|
||
pub errors: u64,
|
||
/// Cumulative bytes zero-filled past read errors (`Stream::lost_bytes`).
|
||
pub lost_bytes: u64,
|
||
/// Number of streams in the muxed title.
|
||
pub streams: usize,
|
||
}
|
||
|
||
/// Run the decrypt + mux pipeline end-to-end: construct the source stream from
|
||
/// `input`, open the `dest_url` sink, and pump PES frames through a write
|
||
/// pipeline until EOF (or `halt`).
|
||
///
|
||
/// This is the shared driver extracted from the CLI's `pipe`/`pipe_disc` and
|
||
/// autorip's `run_mux`. It preserves their exact semantics:
|
||
/// - `chapters://` / `json://` sinks short-circuit BEFORE the header gate (they
|
||
/// need no codec headers — bug fix over the CLI's post-gate placement);
|
||
/// - a stream whose codec headers never resolve is refused with
|
||
/// [`Error::MkvInvalid`] (a structurally-invalid MKV must not be finalised);
|
||
/// - a natural drain that produced no streams or zero payload bytes is refused
|
||
/// with [`Error::NoStreams`] (the zero-output / undecryptable-input guard);
|
||
/// - a `halt` mid-run yields `completed = false` rather than a success marker.
|
||
///
|
||
/// `halt` is mandatory: there is no global interrupt flag and no `None` — the
|
||
/// caller threads a real [`Halt`] so `/api/stop` / SIGINT stop the pump at the
|
||
/// next boundary.
|
||
pub fn mux_stream(
|
||
input_src: MuxInput,
|
||
dest_url: &str,
|
||
opts: &MuxOptions,
|
||
halt: &Halt,
|
||
events: std::sync::Arc<dyn MuxEvents>,
|
||
) -> std::io::Result<MuxOutcome> {
|
||
// Construct the source stream. The file/ISO path calls the untouched
|
||
// `build_iso_pipeline` highway (zero added copies); the live path builds a
|
||
// `DiscStream`; a URL source goes through `input()`. The reader-side
|
||
// constructors take a `'static` `EventFn`; we clone the `Arc<dyn MuxEvents>`
|
||
// into one (`reader_event_fn`) so the highway's producer thread — and the
|
||
// 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 {
|
||
MuxInput::Url { url, opts: in_opts } => (input(url, &in_opts)?, None),
|
||
MuxInput::Iso {
|
||
path,
|
||
title,
|
||
format,
|
||
keys,
|
||
key_fetch,
|
||
} => {
|
||
let reader = FileSectorSource::open(path)?;
|
||
let stream = build_iso_pipeline(
|
||
reader,
|
||
title,
|
||
keys,
|
||
opts.batch_sectors,
|
||
format,
|
||
opts.raw,
|
||
Some(halt.clone()),
|
||
Some(reader_event_fn(events.clone())),
|
||
key_fetch,
|
||
)?;
|
||
(Box::new(stream), None)
|
||
}
|
||
MuxInput::Session {
|
||
session,
|
||
title_index,
|
||
} => {
|
||
// 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, mut keys, playlist) = {
|
||
let disc = session.disc().ok_or_else(|| Error::DeviceNotReady {
|
||
path: session.device_path().to_string(),
|
||
})?;
|
||
let title = disc
|
||
.titles
|
||
.get(title_index)
|
||
.cloned()
|
||
.ok_or(Error::MuxTrackRange {
|
||
track: title_index,
|
||
tracks: disc.titles.len(),
|
||
})?;
|
||
let playlist = disc
|
||
.meta_title
|
||
.clone()
|
||
.unwrap_or_else(|| disc.volume_id.clone());
|
||
// DVD CSS is per-VTS: resolve the per-title key via the pipeline
|
||
// (see `session_mux_keys`), never the whole-disc `decrypt_keys()`.
|
||
(title, disc.content_format, session_mux_keys(disc), playlist)
|
||
};
|
||
// A missing staged reader ("already consumed" / never staged) is a
|
||
// clean error, not a panic (contract Q2).
|
||
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,
|
||
keys,
|
||
opts.batch_sectors,
|
||
format,
|
||
opts.raw,
|
||
Some(halt.clone()),
|
||
)?;
|
||
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
|
||
// adaptive sizer, `BytesRead` progress) — forward them all.
|
||
stream.on_event(reader_event_fn(events.clone()));
|
||
(Box::new(stream), Some(playlist))
|
||
}
|
||
MuxInput::Live {
|
||
mut reader,
|
||
title,
|
||
format,
|
||
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
|
||
// highway would bypass; the live single-pass path must keep it.
|
||
let mut stream = crate::mux::DiscStream::new(
|
||
reader,
|
||
title,
|
||
keys,
|
||
opts.batch_sectors,
|
||
format,
|
||
opts.raw,
|
||
Some(halt.clone()),
|
||
)?;
|
||
if opts.raw {
|
||
stream.set_raw();
|
||
}
|
||
// 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;
|
||
// Same reader-side event vocabulary as the `Session` arm
|
||
// (`SectorSkipped` / `BatchSizeChanged` / `BytesRead`).
|
||
stream.on_event(reader_event_fn(events.clone()));
|
||
(Box::new(stream), None)
|
||
}
|
||
};
|
||
|
||
drive_mux(
|
||
stream,
|
||
dest_url,
|
||
halt,
|
||
events.as_ref(),
|
||
playlist_name.as_deref(),
|
||
effective_send_deadline(opts.send_deadline),
|
||
)
|
||
}
|
||
|
||
/// Decrypt keys for the live `Session` mux of `disc`.
|
||
///
|
||
/// A DVD is handed [`DecryptKeys::None`] so [`DiscStream::new`](crate::mux::DiscStream)
|
||
/// cracks the CORRECT per-title CSS key: CSS title keys are per-VTS, and
|
||
/// [`Disc::decrypt_keys`](crate::disc::Disc::decrypt_keys) returns the LARGEST
|
||
/// title's VTS key — muxing a bonus title in a DIFFERENT VTS with it descrambles
|
||
/// to corrupt MPEG-PS. `resolve_dvd_title_key`'s crack-guard only fires when
|
||
/// `keys` is `None`, so passing the whole-disc key would skip the per-title
|
||
/// crack entirely. This mirrors the sibling `resolve.rs::input()` DVD
|
||
/// special-case (and the pre-refactor CLI `pipe.rs`). AACS / genuinely-clear
|
||
/// discs resolve from `decrypt_keys()` with no read.
|
||
fn session_mux_keys(disc: &crate::disc::Disc) -> DecryptKeys {
|
||
if matches!(disc.format, crate::disc::DiscFormat::Dvd) {
|
||
DecryptKeys::None
|
||
} else {
|
||
disc.decrypt_keys()
|
||
}
|
||
}
|
||
|
||
/// 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),
|
||
EventKind::SectorSkipped { sector } => events.on_sector_skipped(sector as u32),
|
||
EventKind::BatchSizeChanged { new_size, reason } => {
|
||
events.on_batch_size_changed(new_size, reason)
|
||
}
|
||
EventKind::ReadError { sector, .. } => events.on_read_error(sector as u32),
|
||
_ => {}
|
||
})
|
||
}
|
||
|
||
/// 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,
|
||
halt: &Halt,
|
||
events: &dyn MuxEvents,
|
||
playlist_name: Option<&str>,
|
||
send_deadline: Duration,
|
||
) -> std::io::Result<MuxOutcome> {
|
||
// Title assembled from the scanned metadata; the playlist name (disc name)
|
||
// overrides `info().playlist` where the consumer supplied one.
|
||
let mut out_title = stream.info().clone();
|
||
if let Some(name) = playlist_name {
|
||
out_title.playlist = name.to_string();
|
||
}
|
||
|
||
// ── chapters:// / json:// short-circuit — BEFORE the header pump/gate ──
|
||
//
|
||
// These sinks write their whole file from the scanned title at `output()`
|
||
// time and consume no PES frames, so they need no codec headers. Running
|
||
// the header pump/gate first (the CLI's ordering) would false-fail a
|
||
// metadata export on a title whose video headers never resolve. Do it here
|
||
// by construction instead.
|
||
if matches!(
|
||
parse_url(dest_url),
|
||
StreamUrl::Chapters { .. } | StreamUrl::Json { .. }
|
||
) {
|
||
let mut sink = CountingStream::new(output(dest_url, &out_title)?);
|
||
events.on_output_opened(&out_title);
|
||
sink.finish()?;
|
||
return Ok(MuxOutcome {
|
||
completed: true,
|
||
output_opened: true,
|
||
bytes_written: sink.bytes_written(),
|
||
errors: stream.errors(),
|
||
lost_bytes: stream.lost_bytes(),
|
||
streams: out_title.streams.len(),
|
||
});
|
||
}
|
||
|
||
// ── Header pump ──
|
||
//
|
||
// Buffer frames until every video track's codec_private has resolved; MKV
|
||
// can't write a track header without codec init data. The loop breaks on
|
||
// EOF/None too, so the gate below re-checks.
|
||
let mut buffered: Vec<PesFrame> = Vec::new();
|
||
let mut buffered_bytes: usize = 0;
|
||
while !stream.headers_ready() {
|
||
if halt.is_cancelled() {
|
||
return Ok(MuxOutcome {
|
||
completed: false,
|
||
output_opened: false,
|
||
bytes_written: 0,
|
||
errors: stream.errors(),
|
||
lost_bytes: stream.lost_bytes(),
|
||
streams: 0,
|
||
});
|
||
}
|
||
let read = match stream.read() {
|
||
Ok(r) => r,
|
||
// A halt landing DURING a blocking read surfaces as `Error::Halted`
|
||
// (reads dominate wall-clock, so an operator stop most often lands
|
||
// here, not at the between-reads check above). A clean stop is not a
|
||
// failure — yield `completed = false`, matching the finish stage and
|
||
// the highway path, so the consumer's stop-preserves-staging path runs.
|
||
Err(e) if crate::error::is_halt(&e) => {
|
||
return Ok(MuxOutcome {
|
||
completed: false,
|
||
output_opened: false,
|
||
bytes_written: 0,
|
||
errors: stream.errors(),
|
||
lost_bytes: stream.lost_bytes(),
|
||
streams: 0,
|
||
});
|
||
}
|
||
Err(e) => return Err(e),
|
||
};
|
||
match read {
|
||
Some(frame) => {
|
||
buffered_bytes = buffered_bytes.saturating_add(frame.data.len());
|
||
buffered.push(frame);
|
||
// Bounded header buffer: a title whose codec_private never
|
||
// resolves would otherwise buffer the entire (tens-of-GB)
|
||
// stream into RAM until OOM. Treat cap-exceeded identically to
|
||
// "headers never resolved" — fail fast with the SAME
|
||
// `Error::MkvInvalid` the header gate below returns, instead of
|
||
// swapping the box to death.
|
||
if buffered_bytes > HEADER_BUFFER_CAP_BYTES {
|
||
return Err(Error::MkvInvalid.into());
|
||
}
|
||
}
|
||
None => break,
|
||
}
|
||
}
|
||
|
||
// ── Header gate ──
|
||
//
|
||
// The pump can break on EOF (or a read error re-surfaced as `?`) without
|
||
// headers ever resolving. Finalising then writes a track header with no
|
||
// CODEC_PRIVATE — a structurally-invalid MKV the zero-output guard does not
|
||
// catch. Refuse.
|
||
if !stream.headers_ready() {
|
||
return Err(Error::MkvInvalid.into());
|
||
}
|
||
|
||
// Assemble the output title now that codec_privates have resolved.
|
||
let info = stream.info().clone();
|
||
out_title.streams = info.streams.clone();
|
||
out_title.size_bytes = info.size_bytes;
|
||
out_title.codec_privates = (0..info.streams.len())
|
||
.map(|i| stream.codec_private(i))
|
||
.collect();
|
||
let total_bytes = info.size_bytes;
|
||
let num_streams = info.streams.len();
|
||
|
||
// ── Open the sink, wrap in a byte counter, hand it to the write pipeline ──
|
||
let output_stream = CountingStream::new(output(dest_url, &out_title)?);
|
||
events.on_output_opened(&out_title);
|
||
|
||
// The write consumer runs on its own thread so the latency-bound sink write
|
||
// overlaps the next `stream.read()`. `bytes` mirrors the consumer's running
|
||
// written-byte count out to the driving thread for `on_write_progress`.
|
||
let bytes = Arc::new(AtomicU64::new(0));
|
||
let sink = WriteSink {
|
||
output: output_stream,
|
||
bytes: bytes.clone(),
|
||
};
|
||
let pipe = Pipeline::spawn_named("freemkv-mux-consumer", WRITE_PIPELINE_DEPTH, sink)
|
||
.map_err(std::io::Error::from)?;
|
||
|
||
// ── Frame pump ──
|
||
// Per-frame send deadline resolved from `MuxOptions.send_deadline` at the
|
||
// driver entry: a hard bound (autorip) or the effectively-unbounded
|
||
// [`NO_SEND_DEADLINE`] (CLI interactive path). Either way `send_with_halt`
|
||
// re-checks the halt token every `POLL_INTERVAL`, so Ctrl-C / `/api/stop`
|
||
// stays responsive regardless of this value.
|
||
let deadline = send_deadline;
|
||
let mut interrupted = false;
|
||
|
||
// Buffered header frames first, in order.
|
||
for frame in buffered {
|
||
if pipe.send_with_halt(frame, halt, deadline).is_err() {
|
||
interrupted = true;
|
||
break;
|
||
}
|
||
// Feed write-side progress during the drain exactly as the steady-state
|
||
// loop below does. The watchdog is fed only from `on_write_progress`;
|
||
// no `stream.read()` runs here (reads finished in the header pump), so
|
||
// omitting this leaves the watchdog unfed for the whole header-drain
|
||
// phase and it can false-escalate on a long/slow drain.
|
||
events.on_write_progress(bytes.load(Ordering::Relaxed), total_bytes);
|
||
}
|
||
|
||
// Then the remainder of the stream.
|
||
if !interrupted {
|
||
loop {
|
||
if halt.is_cancelled() {
|
||
interrupted = true;
|
||
break;
|
||
}
|
||
match stream.read() {
|
||
Ok(Some(frame)) => {
|
||
if pipe.send_with_halt(frame, halt, deadline).is_err() {
|
||
interrupted = true;
|
||
break;
|
||
}
|
||
events.on_write_progress(bytes.load(Ordering::Relaxed), total_bytes);
|
||
}
|
||
Ok(None) => break,
|
||
// A halt landing mid-read is a clean operator stop, not a read
|
||
// failure: drain the consumer and fall through to the
|
||
// `completed = false` interrupt path, matching the header pump,
|
||
// the finish stage, and the highway (which returns Ok(None) on halt).
|
||
Err(e) if crate::error::is_halt(&e) => {
|
||
interrupted = true;
|
||
break;
|
||
}
|
||
Err(e) => {
|
||
// Drain + join the consumer so its output file handle is
|
||
// released, then propagate the read error.
|
||
let _ = pipe.finish_with_halt(Some(halt));
|
||
return Err(e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Finish ──
|
||
//
|
||
// Drop the producer side and join the consumer; its `close()` finalises the
|
||
// container and returns the payload-byte count. On halt/wedge this returns
|
||
// an error variant; we translate that to `completed = false` rather than
|
||
// surfacing it as a hard failure (a clean operator stop is not an error).
|
||
let (bytes_written, finalize_failed) = match pipe.finish_with_halt(Some(halt)) {
|
||
Ok(b) => (b, false),
|
||
Err(Error::Halted | Error::PipelineJoinTimeout) => (bytes.load(Ordering::Relaxed), true),
|
||
Err(e) => return Err(e.into()),
|
||
};
|
||
|
||
if !mux_run_completed(interrupted, finalize_failed, halt.is_cancelled()) {
|
||
return Ok(MuxOutcome {
|
||
completed: false,
|
||
output_opened: true,
|
||
bytes_written,
|
||
errors: stream.errors(),
|
||
lost_bytes: stream.lost_bytes(),
|
||
streams: num_streams,
|
||
});
|
||
}
|
||
|
||
// ── Zero-output / NoStreams gate ──
|
||
//
|
||
// A natural drain that wrote no streams or not a single payload byte is the
|
||
// empty/undecryptable-input silent failure — refuse to report it complete.
|
||
if num_streams == 0 || bytes_written == 0 {
|
||
return Err(Error::NoStreams.into());
|
||
}
|
||
|
||
Ok(MuxOutcome {
|
||
completed: true,
|
||
output_opened: true,
|
||
bytes_written,
|
||
errors: stream.errors(),
|
||
lost_bytes: stream.lost_bytes(),
|
||
streams: num_streams,
|
||
})
|
||
}
|
||
|
||
/// Write-side [`Sink`]: applies each frame to the counting output stream and
|
||
/// finalises the container on close. `close()` returns the payload-byte count.
|
||
struct WriteSink {
|
||
output: CountingStream,
|
||
bytes: Arc<AtomicU64>,
|
||
}
|
||
|
||
impl Sink<PesFrame> for WriteSink {
|
||
type Output = u64;
|
||
|
||
fn apply(&mut self, frame: PesFrame) -> Result<Flow, Error> {
|
||
self.output.write(&frame).map_err(Error::from)?;
|
||
self.bytes
|
||
.store(self.output.bytes_written(), Ordering::Relaxed);
|
||
Ok(Flow::Continue)
|
||
}
|
||
|
||
fn close(mut self) -> Result<u64, Error> {
|
||
self.output.finish().map_err(Error::from)?;
|
||
Ok(self.output.bytes_written())
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::disc::DiscTitle;
|
||
use std::sync::atomic::AtomicBool;
|
||
|
||
/// A synthetic [`Stream`] the tests fully control: a queue of frames, a
|
||
/// configurable `headers_ready` behaviour, and an optional halt it cancels
|
||
/// after `cancel_after` reads (to drive the mid-pump interrupt path).
|
||
struct FakeStream {
|
||
info: DiscTitle,
|
||
frames: std::collections::VecDeque<PesFrame>,
|
||
/// Number of successful `read()`s after which `headers_ready` flips to
|
||
/// true. `usize::MAX` means "never ready".
|
||
headers_ready_after: usize,
|
||
reads: usize,
|
||
codec_private_ready: bool,
|
||
/// If set, `read()` cancels this halt once `reads` reaches the value.
|
||
cancel_halt: Option<(Halt, usize)>,
|
||
/// If set, every successful `read()` bumps this shared counter so a test
|
||
/// can observe how many frames the pump consumed after the stream was
|
||
/// moved into `drive_mux`.
|
||
read_observer: Option<Arc<std::sync::atomic::AtomicUsize>>,
|
||
/// If set, `read()` returns `Err(Error::Halted)` once `reads` reaches the
|
||
/// value — simulating a halt landing DURING a blocking `fill_extents` read
|
||
/// (the common operator-stop case).
|
||
halt_err_at_read: Option<usize>,
|
||
}
|
||
|
||
fn audio_stream() -> crate::disc::Stream {
|
||
use crate::disc::{AudioChannels, AudioStream, Codec, LabelPurpose, SampleRate, Stream};
|
||
Stream::Audio(AudioStream {
|
||
pid: 0x1100,
|
||
codec: Codec::Aac,
|
||
channels: AudioChannels::Stereo,
|
||
language: "eng".into(),
|
||
sample_rate: SampleRate::S48,
|
||
secondary: false,
|
||
purpose: LabelPurpose::Normal,
|
||
label: String::new(),
|
||
})
|
||
}
|
||
|
||
impl FakeStream {
|
||
fn new(streams: usize) -> Self {
|
||
let mut info = DiscTitle::empty();
|
||
info.streams = (0..streams).map(|_| audio_stream()).collect();
|
||
info.size_bytes = 1_000_000;
|
||
FakeStream {
|
||
info,
|
||
frames: std::collections::VecDeque::new(),
|
||
headers_ready_after: 0,
|
||
reads: 0,
|
||
codec_private_ready: true,
|
||
cancel_halt: None,
|
||
read_observer: None,
|
||
halt_err_at_read: None,
|
||
}
|
||
}
|
||
/// After `after` successful reads, the next `read()` returns
|
||
/// `Err(Error::Halted)` (a stop landing mid-read).
|
||
fn halt_errs_at(mut self, after: usize) -> Self {
|
||
self.halt_err_at_read = Some(after);
|
||
self
|
||
}
|
||
fn with_frames(mut self, n: usize) -> Self {
|
||
for i in 0..n {
|
||
self.frames.push_back(PesFrame {
|
||
track: 0,
|
||
pts: i as i64,
|
||
keyframe: true,
|
||
data: vec![0xAB; 100],
|
||
duration_ns: None,
|
||
source: None,
|
||
coding: None,
|
||
});
|
||
}
|
||
self
|
||
}
|
||
fn never_ready(mut self) -> Self {
|
||
self.headers_ready_after = usize::MAX;
|
||
self.codec_private_ready = false;
|
||
self
|
||
}
|
||
fn cancels(mut self, halt: Halt, after: usize) -> Self {
|
||
self.cancel_halt = Some((halt, after));
|
||
self
|
||
}
|
||
}
|
||
|
||
impl Stream for FakeStream {
|
||
fn read(&mut self) -> std::io::Result<Option<PesFrame>> {
|
||
if let Some((halt, after)) = &self.cancel_halt {
|
||
if self.reads >= *after {
|
||
halt.cancel();
|
||
}
|
||
}
|
||
if let Some(after) = self.halt_err_at_read {
|
||
if self.reads >= after {
|
||
return Err(crate::error::Error::Halted.into());
|
||
}
|
||
}
|
||
let f = self.frames.pop_front();
|
||
if f.is_some() {
|
||
self.reads += 1;
|
||
if let Some(obs) = &self.read_observer {
|
||
obs.fetch_add(1, Ordering::SeqCst);
|
||
}
|
||
}
|
||
Ok(f)
|
||
}
|
||
fn write(&mut self, _frame: &PesFrame) -> std::io::Result<()> {
|
||
Ok(())
|
||
}
|
||
fn finish(&mut self) -> std::io::Result<()> {
|
||
Ok(())
|
||
}
|
||
fn info(&self) -> &DiscTitle {
|
||
&self.info
|
||
}
|
||
fn codec_private(&self, _track: usize) -> Option<Vec<u8>> {
|
||
self.codec_private_ready.then(|| vec![1, 2, 3])
|
||
}
|
||
fn headers_ready(&self) -> bool {
|
||
self.reads >= self.headers_ready_after
|
||
}
|
||
}
|
||
|
||
/// Records whether `on_output_opened` fired.
|
||
struct SpyEvents {
|
||
opened: AtomicBool,
|
||
}
|
||
impl SpyEvents {
|
||
fn new() -> Self {
|
||
SpyEvents {
|
||
opened: AtomicBool::new(false),
|
||
}
|
||
}
|
||
}
|
||
impl MuxEvents for SpyEvents {
|
||
fn on_output_opened(&self, _title: &DiscTitle) {
|
||
self.opened.store(true, Ordering::SeqCst);
|
||
}
|
||
}
|
||
|
||
fn tmp(name: &str) -> (tempfile::TempDir, String) {
|
||
let dir = tempfile::tempdir().expect("tempdir");
|
||
let path = dir.path().join(name);
|
||
let url = format!("chapters://{}", path.display());
|
||
(dir, url)
|
||
}
|
||
|
||
// ── chapters:// / json:// short-circuit runs even when headers never
|
||
// resolve (the bug fix). Mutation: moving the header gate before the
|
||
// short-circuit makes this return Err(MkvInvalid) and the test fails.
|
||
#[test]
|
||
fn chapters_short_circuits_before_header_gate() {
|
||
let stream = Box::new(FakeStream::new(2).never_ready());
|
||
let (_dir, url) = tmp("out.xml");
|
||
let halt = Halt::new();
|
||
let spy = SpyEvents::new();
|
||
let out = drive_mux(stream, &url, &halt, &spy, None, Duration::from_secs(60))
|
||
.expect("chapters must short-circuit");
|
||
assert!(out.completed, "metadata sink completes without headers");
|
||
assert!(out.output_opened);
|
||
assert!(spy.opened.load(Ordering::SeqCst), "sink was opened");
|
||
assert_eq!(out.streams, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn json_short_circuits_before_header_gate() {
|
||
let dir = tempfile::tempdir().expect("tempdir");
|
||
let url = format!("json://{}", dir.path().join("out.json").display());
|
||
let stream = Box::new(FakeStream::new(1).never_ready());
|
||
let halt = Halt::new();
|
||
let out = drive_mux(
|
||
stream,
|
||
&url,
|
||
&halt,
|
||
&NoopEvents,
|
||
None,
|
||
Duration::from_secs(60),
|
||
)
|
||
.expect("json must short-circuit");
|
||
assert!(out.completed);
|
||
assert!(out.output_opened);
|
||
}
|
||
|
||
// ── header gate rejects a stream whose codec_private never resolves. ──
|
||
// Mutation: dropping the gate lets it proceed to a NoStreams / success path.
|
||
#[test]
|
||
fn header_gate_rejects_unresolved_codec_private() {
|
||
let stream = Box::new(FakeStream::new(1).with_frames(3).never_ready());
|
||
let halt = Halt::new();
|
||
let err = drive_mux(
|
||
stream,
|
||
"null://",
|
||
&halt,
|
||
&NoopEvents,
|
||
None,
|
||
Duration::from_secs(60),
|
||
)
|
||
.expect_err("unresolved headers must be refused");
|
||
assert!(
|
||
crate::error::is_skippable_title_stub(&err),
|
||
"MkvInvalid is a skippable stub, got {err}"
|
||
);
|
||
assert_eq!(err.to_string(), format!("E{}", crate::error::E_MKV_INVALID));
|
||
}
|
||
|
||
// ── zero-output gate: a headers-ready stream that yields no frames. ──
|
||
// Mutation: dropping the gate returns completed=true with 0 bytes.
|
||
#[test]
|
||
fn zero_output_gate_refuses_empty_drain() {
|
||
let stream = Box::new(FakeStream::new(1)); // headers ready, no frames
|
||
let halt = Halt::new();
|
||
let err = drive_mux(
|
||
stream,
|
||
"null://",
|
||
&halt,
|
||
&NoopEvents,
|
||
None,
|
||
Duration::from_secs(60),
|
||
)
|
||
.expect_err("empty drain must be refused");
|
||
assert_eq!(err.to_string(), format!("E{}", crate::error::E_NO_STREAMS));
|
||
}
|
||
|
||
// ── halt mid-pump stops cleanly with completed=false, no panic. ──
|
||
#[test]
|
||
fn halt_mid_pump_stops_cleanly() {
|
||
let halt = Halt::new();
|
||
// Ready immediately, plenty of frames, cancels the halt after 2 reads.
|
||
let stream = Box::new(
|
||
FakeStream::new(1)
|
||
.with_frames(1000)
|
||
.cancels(halt.clone(), 2),
|
||
);
|
||
let out = drive_mux(
|
||
stream,
|
||
"null://",
|
||
&halt,
|
||
&NoopEvents,
|
||
None,
|
||
Duration::from_secs(60),
|
||
)
|
||
.expect("halt is a clean stop, not an error");
|
||
assert!(!out.completed, "an interrupted mux is not complete");
|
||
assert!(out.output_opened, "the sink was opened before the halt");
|
||
}
|
||
|
||
// ── A halt landing mid-read (Err(Halted) from the stream, the common
|
||
// operator-stop case since reads dominate wall-clock) is a clean stop,
|
||
// NOT a mux failure. Both the frame pump and the header pump must yield
|
||
// completed=false. Mutation: propagating the read-arm Err instead of
|
||
// mapping Halted → completed=false makes these `.expect` calls panic. ──
|
||
#[test]
|
||
fn halt_err_during_frame_read_yields_completed_false() {
|
||
let halt = Halt::new();
|
||
// Headers ready immediately; the 3rd read (in the frame pump) errors Halted.
|
||
let stream = Box::new(FakeStream::new(1).with_frames(1000).halt_errs_at(2));
|
||
let out = drive_mux(
|
||
stream,
|
||
"null://",
|
||
&halt,
|
||
&NoopEvents,
|
||
None,
|
||
Duration::from_secs(60),
|
||
)
|
||
.expect("a halt mid frame-read is a clean stop, not an Err");
|
||
assert!(!out.completed, "interrupted mux is not complete");
|
||
assert!(out.output_opened, "sink opened before the mid-read halt");
|
||
}
|
||
|
||
#[test]
|
||
fn halt_err_during_header_read_yields_completed_false() {
|
||
let halt = Halt::new();
|
||
// Headers never resolve; the 2nd read (in the header pump) errors Halted.
|
||
let stream = Box::new(
|
||
FakeStream::new(1)
|
||
.with_frames(1000)
|
||
.never_ready()
|
||
.halt_errs_at(1),
|
||
);
|
||
let out = drive_mux(
|
||
stream,
|
||
"null://",
|
||
&halt,
|
||
&NoopEvents,
|
||
None,
|
||
Duration::from_secs(60),
|
||
)
|
||
.expect("a halt mid header-read is a clean stop, not an Err");
|
||
assert!(!out.completed, "interrupted mux is not complete");
|
||
assert!(
|
||
!out.output_opened,
|
||
"halt before headers resolve → sink never opened"
|
||
);
|
||
}
|
||
|
||
// ── a normal stream pumps N frames → bytes_written>0, completed=true. ──
|
||
#[test]
|
||
fn normal_stream_completes_with_bytes() {
|
||
let stream = Box::new(FakeStream::new(2).with_frames(10));
|
||
let halt = Halt::new();
|
||
let spy = SpyEvents::new();
|
||
let out = drive_mux(
|
||
stream,
|
||
"null://",
|
||
&halt,
|
||
&spy,
|
||
None,
|
||
Duration::from_secs(60),
|
||
)
|
||
.expect("normal mux completes");
|
||
assert!(out.completed);
|
||
assert!(out.output_opened);
|
||
assert!(spy.opened.load(Ordering::SeqCst));
|
||
assert_eq!(out.bytes_written, 10 * 100, "10 frames × 100 bytes payload");
|
||
assert_eq!(out.streams, 2);
|
||
}
|
||
|
||
// ── reader-side event forwarding through the Arc ────────────────────────
|
||
|
||
/// A [`MuxEvents`] backed by atomics that records every callback so a test
|
||
/// can assert reader-side events actually flowed through the `Arc` clone.
|
||
struct CountingEvents {
|
||
opened: AtomicBool,
|
||
progress_calls: AtomicU64,
|
||
/// Set once `on_read_progress` is called with a `total` equal to the ISO
|
||
/// extents' byte total — the fingerprint of the *read-side* `BytesRead`
|
||
/// event (the write-side `on_write_progress` carries the title's
|
||
/// `size_bytes`, a different number), so it isolates the `EventFn`
|
||
/// translation.
|
||
saw_read_total: AtomicBool,
|
||
read_total: u64,
|
||
skipped: AtomicU64,
|
||
batch_changed: AtomicU64,
|
||
read_errors: AtomicU64,
|
||
}
|
||
impl CountingEvents {
|
||
fn new(read_total: u64) -> std::sync::Arc<Self> {
|
||
std::sync::Arc::new(CountingEvents {
|
||
opened: AtomicBool::new(false),
|
||
progress_calls: AtomicU64::new(0),
|
||
saw_read_total: AtomicBool::new(false),
|
||
read_total,
|
||
skipped: AtomicU64::new(0),
|
||
batch_changed: AtomicU64::new(0),
|
||
read_errors: AtomicU64::new(0),
|
||
})
|
||
}
|
||
}
|
||
impl MuxEvents for CountingEvents {
|
||
fn on_output_opened(&self, _title: &DiscTitle) {
|
||
self.opened.store(true, Ordering::SeqCst);
|
||
}
|
||
fn on_read_progress(&self, _bytes_read: u64, bytes_total: u64) {
|
||
self.progress_calls.fetch_add(1, Ordering::SeqCst);
|
||
if bytes_total == self.read_total {
|
||
self.saw_read_total.store(true, Ordering::SeqCst);
|
||
}
|
||
}
|
||
fn on_sector_skipped(&self, _lba: u32) {
|
||
self.skipped.fetch_add(1, Ordering::SeqCst);
|
||
}
|
||
fn on_batch_size_changed(&self, _batch: u16, _reason: BatchSizeReason) {
|
||
self.batch_changed.fetch_add(1, Ordering::SeqCst);
|
||
}
|
||
fn on_read_error(&self, _lba: u32) {
|
||
self.read_errors.fetch_add(1, Ordering::SeqCst);
|
||
}
|
||
}
|
||
|
||
/// The `reader_event_fn` translation maps every real [`EventKind`] the
|
||
/// reader emits onto the matching [`MuxEvents`] method, dispatched through
|
||
/// the cloned `Arc`. Mutation: dropping any match arm (or narrowing the
|
||
/// `Arc` clone so nothing fires) leaves that counter at 0 and fails here.
|
||
#[test]
|
||
fn reader_event_fn_translates_every_variant() {
|
||
let events = CountingEvents::new(6144);
|
||
let f = reader_event_fn(events.clone());
|
||
f(Event {
|
||
kind: EventKind::BytesRead {
|
||
bytes: 4096,
|
||
total: 6144,
|
||
},
|
||
});
|
||
f(Event {
|
||
kind: EventKind::SectorSkipped { sector: 42 },
|
||
});
|
||
f(Event {
|
||
kind: EventKind::BatchSizeChanged {
|
||
new_size: 8,
|
||
reason: BatchSizeReason::Shrunk,
|
||
},
|
||
});
|
||
f(Event {
|
||
kind: EventKind::ReadError {
|
||
sector: 7,
|
||
error: Error::NoStreams,
|
||
},
|
||
});
|
||
assert_eq!(
|
||
events.progress_calls.load(Ordering::SeqCst),
|
||
1,
|
||
"BytesRead → on_read_progress"
|
||
);
|
||
assert!(
|
||
events.saw_read_total.load(Ordering::SeqCst),
|
||
"read-side total forwarded"
|
||
);
|
||
assert_eq!(
|
||
events.skipped.load(Ordering::SeqCst),
|
||
1,
|
||
"SectorSkipped → on_sector_skipped"
|
||
);
|
||
assert_eq!(
|
||
events.batch_changed.load(Ordering::SeqCst),
|
||
1,
|
||
"BatchSizeChanged → on_batch_size_changed"
|
||
);
|
||
assert_eq!(
|
||
events.read_errors.load(Ordering::SeqCst),
|
||
1,
|
||
"ReadError → on_read_error"
|
||
);
|
||
}
|
||
|
||
/// A 192-byte BD-TS packet carrying `payload` as a payload-only TS packet on
|
||
/// `pid` (4-byte TP_extra_header + 188-byte TS packet, AFC=payload-only).
|
||
fn bdts_data_packet(pid: u16, pusi: bool, payload: &[u8]) -> [u8; 192] {
|
||
let mut pkt = [0u8; 192];
|
||
pkt[4] = 0x47;
|
||
pkt[5] = ((pid >> 8) as u8) & 0x1F;
|
||
if pusi {
|
||
pkt[5] |= 0x40;
|
||
}
|
||
pkt[6] = (pid & 0xFF) as u8;
|
||
pkt[7] = 0x10;
|
||
let n = payload.len().min(184);
|
||
pkt[8..8 + n].copy_from_slice(&payload[..n]);
|
||
pkt
|
||
}
|
||
|
||
/// A complete audio PES (stream_id 0xC0, no PTS) carrying `es`.
|
||
fn audio_pes(es: &[u8]) -> Vec<u8> {
|
||
let mut v = vec![0x00, 0x00, 0x01, 0xC0];
|
||
let len = (3 + es.len()) as u16;
|
||
v.extend_from_slice(&len.to_be_bytes());
|
||
v.extend_from_slice(&[0x80, 0x00, 0x00]);
|
||
v.extend_from_slice(es);
|
||
v
|
||
}
|
||
|
||
fn aac_audio_title(pid: u16) -> DiscTitle {
|
||
use crate::disc::{AudioChannels, AudioStream, Codec, LabelPurpose, SampleRate, Stream};
|
||
let mut t = DiscTitle::empty();
|
||
t.streams.push(Stream::Audio(AudioStream {
|
||
pid,
|
||
codec: Codec::Aac,
|
||
channels: AudioChannels::Stereo,
|
||
language: "eng".into(),
|
||
sample_rate: SampleRate::S48,
|
||
secondary: false,
|
||
purpose: LabelPurpose::Normal,
|
||
label: String::new(),
|
||
}));
|
||
t
|
||
}
|
||
|
||
/// End-to-end through `mux_stream` on the ISO path: a real 3-sector ISO on
|
||
/// disk (one BD-TS audio PES, one AACS unit) muxed to `null://`. Asserts the
|
||
/// reader-side `BytesRead` progress AND `on_output_opened` reach the
|
||
/// `Arc<dyn MuxEvents>` — i.e. the constructor `EventFn` really carries the
|
||
/// consumer's events across the highway's producer thread.
|
||
///
|
||
/// Mutation: passing `None` (instead of `Some(reader_event_fn(...))`) to
|
||
/// `build_iso_pipeline` in the ISO arm leaves `saw_read_total` false — the
|
||
/// write-side `on_write_progress` carries `size_bytes` (0 here), never the
|
||
/// 6144 extents total — and this test fails.
|
||
#[test]
|
||
fn mux_stream_iso_forwards_reader_progress_through_arc() {
|
||
let es = [0xDE, 0xAD, 0xBE, 0xEF, 0x11, 0x22];
|
||
let pkt = bdts_data_packet(0x1100, true, &audio_pes(&es));
|
||
let mut data = vec![0u8; 3 * 2048]; // 3 sectors = one AACS unit = 6144 bytes
|
||
data[..192].copy_from_slice(&pkt);
|
||
|
||
let dir = tempfile::tempdir().expect("tempdir");
|
||
let iso_path = dir.path().join("clip.iso");
|
||
std::fs::write(&iso_path, &data).expect("write iso");
|
||
|
||
let mut title = aac_audio_title(0x1100);
|
||
title.extents = vec![crate::disc::Extent {
|
||
start_lba: 0,
|
||
sector_count: 3,
|
||
}];
|
||
|
||
let events = CountingEvents::new(3 * 2048);
|
||
let opts = MuxOptions {
|
||
skip_errors: false,
|
||
batch_sectors: 8192,
|
||
raw: false,
|
||
send_deadline: Some(Duration::from_secs(60)),
|
||
};
|
||
let halt = Halt::new();
|
||
let out = mux_stream(
|
||
MuxInput::Iso {
|
||
path: &iso_path,
|
||
title,
|
||
format: crate::disc::ContentFormat::BdTs,
|
||
keys: DecryptKeys::None,
|
||
key_fetch: None,
|
||
},
|
||
"null://",
|
||
&opts,
|
||
&halt,
|
||
events.clone(),
|
||
)
|
||
.expect("audio-only ISO muxes to null sink");
|
||
|
||
assert!(out.completed, "the clip drained and finalised");
|
||
assert!(
|
||
events.saw_read_total.load(Ordering::SeqCst),
|
||
"the reader-side BytesRead (total=6144) reached the Arc via the EventFn"
|
||
);
|
||
assert!(
|
||
events.opened.load(Ordering::SeqCst),
|
||
"on_output_opened fired through the Arc"
|
||
);
|
||
assert!(
|
||
events.progress_calls.load(Ordering::SeqCst) > 0,
|
||
"at least one on_read_progress call observed"
|
||
);
|
||
}
|
||
|
||
/// Recording `SectorSource` for the live-path tests: logs every read's
|
||
/// `(lba, count)` and returns zeroed sectors (never flagged scrambled, so a
|
||
/// synthetic key map's decrypt is a pass-through — no real crypto). The
|
||
/// recorded LBAs are the observable proof of WHICH extent walk the inline
|
||
/// `DiscStream` executed.
|
||
struct RecordingReader {
|
||
capacity: u32,
|
||
log: std::sync::Arc<std::sync::Mutex<Vec<(u32, u16)>>>,
|
||
}
|
||
impl crate::sector::SectorSource for RecordingReader {
|
||
fn read_sectors(
|
||
&mut self,
|
||
lba: u32,
|
||
count: u16,
|
||
buf: &mut [u8],
|
||
_recovery: bool,
|
||
) -> crate::error::Result<usize> {
|
||
self.log.lock().unwrap().push((lba, count));
|
||
let bytes = count as usize * 2048;
|
||
buf[..bytes].fill(0);
|
||
Ok(bytes)
|
||
}
|
||
fn capacity_sectors(&self) -> u32 {
|
||
self.capacity
|
||
}
|
||
}
|
||
|
||
/// `MuxInput::Live` builds the INLINE `DiscStream` (not the highway) AND
|
||
/// applies the forensic `key_map` before reading. Proof is the recorded LBA
|
||
/// set: `with_key_map` rewrites the extent walk via `AacsKeyMap::read_plan`
|
||
/// so the alternate-phase (odd) forensic units are NEVER fetched off the
|
||
/// source. The zeroed reader can't resolve codec headers, so the mux drains
|
||
/// to a `NoStreams`/`MkvInvalid` refusal — irrelevant here; the test asserts
|
||
/// only the read plan the reader actually executed.
|
||
///
|
||
/// Mutation: deleting the `stream = stream.with_key_map(map)` line in the
|
||
/// `Live` arm leaves the extents un-rewritten, so the reader DOES fetch the
|
||
/// dropped odd-phase LBAs and the "never read" assertion below fails. (A
|
||
/// second mutation — routing `Live` through `build_iso_pipeline` instead of
|
||
/// `DiscStream::new` — would not compile against a `Box<dyn SectorSource>`
|
||
/// and drop the inline `fill_extents` retry entirely.)
|
||
#[test]
|
||
fn mux_input_live_uses_inline_discstream_and_applies_key_map() {
|
||
use crate::decrypt::{AacsKeyMap, Phase};
|
||
use crate::disc::Extent;
|
||
|
||
// 3 sectors == one AACS aligned unit. A 10-unit Even forensic segment at
|
||
// LBA [1030, 1060): kept even units start at 1030,1036,1042,1048,1054;
|
||
// the odd units 1033,1039,1045,1051,1057 must never be read.
|
||
let us = 3u32;
|
||
let map = std::sync::Arc::new(AacsKeyMap::from_ranges_phased(vec![(
|
||
1030,
|
||
1060,
|
||
5,
|
||
Phase::Even,
|
||
)]));
|
||
|
||
let mut title = aac_audio_title(0x1100);
|
||
title.extents = vec![Extent {
|
||
start_lba: 1000,
|
||
sector_count: 300,
|
||
}];
|
||
|
||
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(u32, u16)>::new()));
|
||
let reader = Box::new(RecordingReader {
|
||
capacity: 4000,
|
||
log: log.clone(),
|
||
});
|
||
|
||
let opts = MuxOptions {
|
||
skip_errors: false,
|
||
// One unit per read so the recorded LBAs land on unit boundaries and
|
||
// an odd-phase unit can't hide inside a larger coalesced batch.
|
||
batch_sectors: us as u16,
|
||
raw: false,
|
||
send_deadline: Some(Duration::from_secs(60)),
|
||
};
|
||
let halt = Halt::new();
|
||
// Drains to a NoStreams refusal (zeroed data resolves no headers); we
|
||
// only care about the reads it performed on the way there.
|
||
let _ = mux_stream(
|
||
MuxInput::Live {
|
||
reader,
|
||
title,
|
||
format: crate::disc::ContentFormat::BdTs,
|
||
keys: DecryptKeys::None,
|
||
key_map: Some(map),
|
||
},
|
||
"null://",
|
||
&opts,
|
||
&halt,
|
||
Arc::new(NoopEvents),
|
||
);
|
||
|
||
let reads = log.lock().unwrap();
|
||
let read_lbas: std::collections::HashSet<u32> = reads.iter().map(|&(lba, _)| lba).collect();
|
||
// The dropped odd-phase forensic units must never be fetched.
|
||
for odd in [1033u32, 1039, 1045, 1051, 1057] {
|
||
assert!(
|
||
!read_lbas.contains(&odd),
|
||
"alternate-phase forensic unit LBA {odd} was read — key_map not applied \
|
||
(with_key_map dropped from the Live arm?)"
|
||
);
|
||
}
|
||
// Sanity: our-phase units on either side ARE read (the walk still ran).
|
||
assert!(
|
||
read_lbas.contains(&1030) && read_lbas.contains(&1054),
|
||
"our-phase forensic units must still be read (kept LBAs 1030/1054)"
|
||
);
|
||
}
|
||
|
||
/// 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)),
|
||
};
|
||
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)),
|
||
};
|
||
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)),
|
||
};
|
||
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)),
|
||
};
|
||
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
|
||
// be refused once the pre-headers buffer passes `HEADER_BUFFER_CAP_BYTES`,
|
||
// BEFORE draining the whole (unbounded, OOM-inducing) stream. Frames are
|
||
// zero-filled `Vec`s (`alloc_zeroed`, lazily paged) so the nominal size is
|
||
// cheap in RSS. We hand the pump FAR more frames than the cap needs and
|
||
// assert both that it fails with the same `MkvInvalid` outcome AND that it
|
||
// stopped EARLY (bounded read count) — the latter is what distinguishes the
|
||
// capped path from the un-capped one.
|
||
//
|
||
// Mutation: removing the cap makes the pump drain all `MANY` frames (reads
|
||
// == MANY) before failing at the header gate → the `reads_seen` bound fails.
|
||
#[test]
|
||
fn header_buffer_cap_fails_fast_instead_of_oom() {
|
||
use std::sync::atomic::AtomicUsize;
|
||
const FRAME: usize = 64 * 1024 * 1024; // 64 MiB per frame
|
||
let cap_frames = HEADER_BUFFER_CAP_BYTES / FRAME; // 8 frames == cap
|
||
const MANY: usize = 200; // vastly more than the cap needs
|
||
|
||
let reads_seen = Arc::new(AtomicUsize::new(0));
|
||
let mut fs = FakeStream::new(1).never_ready();
|
||
fs.read_observer = Some(reads_seen.clone());
|
||
for i in 0..MANY {
|
||
fs.frames.push_back(PesFrame {
|
||
track: 0,
|
||
pts: i as i64,
|
||
keyframe: true,
|
||
data: vec![0u8; FRAME],
|
||
duration_ns: None,
|
||
source: None,
|
||
coding: None,
|
||
});
|
||
}
|
||
let halt = Halt::new();
|
||
let err = drive_mux(
|
||
Box::new(fs),
|
||
"null://",
|
||
&halt,
|
||
&NoopEvents,
|
||
None,
|
||
Duration::from_secs(60),
|
||
)
|
||
.expect_err("over-cap header buffer must fail fast, not OOM");
|
||
assert_eq!(
|
||
err.to_string(),
|
||
format!("E{}", crate::error::E_MKV_INVALID),
|
||
"cap-exceeded returns the same MkvInvalid as headers-never-resolved"
|
||
);
|
||
let reads = reads_seen.load(Ordering::SeqCst);
|
||
assert!(
|
||
reads <= cap_frames + 1,
|
||
"must fail after ~{cap_frames} frames (cap), not drain all {MANY} (read {reads})"
|
||
);
|
||
}
|
||
|
||
// ── Regression B: watchdog fed during the buffered header drain ─────────
|
||
//
|
||
// Headers resolve only after K frames are buffered; those K frames are then
|
||
// flushed to the sink in the drain loop. Every flushed frame must fire
|
||
// `on_write_progress` (the sole watchdog feed) — with exactly K frames and
|
||
// no remainder, ALL `on_write_progress` calls come from the drain.
|
||
//
|
||
// Mutation: dropping the `events.on_write_progress(...)` call in the drain
|
||
// loop leaves the count at 0 → this fails.
|
||
#[test]
|
||
fn write_progress_fed_during_header_drain() {
|
||
const K: usize = 6;
|
||
let mut fs = FakeStream::new(1).with_frames(K);
|
||
// Headers stay unresolved until all K frames have been read+buffered.
|
||
fs.headers_ready_after = K;
|
||
|
||
struct WriteProgressCounter {
|
||
writes: AtomicU64,
|
||
}
|
||
impl MuxEvents for WriteProgressCounter {
|
||
fn on_write_progress(&self, _bytes_written: u64, _bytes_total: u64) {
|
||
self.writes.fetch_add(1, Ordering::SeqCst);
|
||
}
|
||
}
|
||
let events = WriteProgressCounter {
|
||
writes: AtomicU64::new(0),
|
||
};
|
||
let halt = Halt::new();
|
||
let out = drive_mux(
|
||
Box::new(fs),
|
||
"null://",
|
||
&halt,
|
||
&events,
|
||
None,
|
||
Duration::from_secs(60),
|
||
)
|
||
.expect("K-frame stream muxes cleanly");
|
||
assert!(out.completed);
|
||
assert_eq!(
|
||
events.writes.load(Ordering::SeqCst),
|
||
K as u64,
|
||
"each of the K buffered header frames must feed on_write_progress on drain"
|
||
);
|
||
}
|
||
|
||
// ── Regression C: Session key selection special-cases DVD ───────────────
|
||
//
|
||
// `session_mux_keys` is the seam the `MuxInput::Session` arm uses to pick
|
||
// decrypt keys. A DVD must get `None` (so DiscStream cracks the correct
|
||
// per-title/per-VTS CSS key); a non-DVD passes `decrypt_keys()` through.
|
||
// Building a full multi-VTS DiscSession fixture is infeasible in a unit
|
||
// test, so this asserts the is_dvd→None DECISION at the tightest seam.
|
||
//
|
||
// Mutation: reverting to unconditional `disc.decrypt_keys()` returns
|
||
// `Css{..}` for the DVD case → the `None` assertion fails.
|
||
fn disc_with_css(format: crate::disc::DiscFormat) -> crate::disc::Disc {
|
||
crate::disc::Disc {
|
||
volume_id: "TEST".into(),
|
||
meta_title: None,
|
||
format,
|
||
capacity_sectors: 0,
|
||
capacity_bytes: 0,
|
||
layers: 1,
|
||
titles: Vec::new(),
|
||
region: crate::disc::DiscRegion::Free,
|
||
aacs: None,
|
||
css: Some(crate::css::CssState {
|
||
title_key: [1, 2, 3, 4, 5],
|
||
crack_span: None,
|
||
}),
|
||
encrypted: true,
|
||
aacs_error: None,
|
||
css_error: None,
|
||
content_format: crate::disc::ContentFormat::MpegPs,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn session_mux_keys_uses_none_for_dvd() {
|
||
// DVD: must be None so the pipeline cracks the correct per-title key,
|
||
// even though decrypt_keys() would hand back a whole-disc Css key.
|
||
let dvd = disc_with_css(crate::disc::DiscFormat::Dvd);
|
||
assert!(
|
||
matches!(dvd.decrypt_keys(), DecryptKeys::Css { .. }),
|
||
"precondition: a CSS disc's decrypt_keys() is Css{{..}}"
|
||
);
|
||
assert!(
|
||
matches!(session_mux_keys(&dvd), DecryptKeys::None),
|
||
"a DVD must be handed None so DiscStream cracks the per-title key"
|
||
);
|
||
}
|
||
|
||
// ── Regression E: send_deadline routing (Some vs None) ──────────────────
|
||
//
|
||
// `effective_send_deadline` is the tightest seam of the write-pipeline send
|
||
// decision. autorip passes `Some(60s)` (bounded, watchdog-backed file mux);
|
||
// the CLI's interactive stdout/network path passes `None`, which must map to
|
||
// an effectively-unbounded deadline so a slow-but-alive downstream is never
|
||
// spuriously reported as an interrupted mux. Ctrl-C stays responsive under
|
||
// `None` because `send_with_halt` re-checks the halt token every
|
||
// `POLL_INTERVAL` regardless of the deadline (see `NO_SEND_DEADLINE`).
|
||
//
|
||
// Mutation: reverting `None` to a fixed `Duration::from_secs(60)` (the
|
||
// pre-fix behaviour) makes the None-case assertions fail.
|
||
#[test]
|
||
fn effective_send_deadline_routes_some_and_none() {
|
||
// Some(d) → exactly d.
|
||
assert_eq!(
|
||
effective_send_deadline(Some(Duration::from_secs(60))),
|
||
Duration::from_secs(60),
|
||
"Some(d) must resolve to the bounded per-frame deadline d"
|
||
);
|
||
assert_eq!(
|
||
effective_send_deadline(Some(Duration::from_secs(5))),
|
||
Duration::from_secs(5),
|
||
);
|
||
// None → the effectively-unbounded no-timeout sentinel, far larger than
|
||
// any real Some deadline so backpressure never trips it.
|
||
let none = effective_send_deadline(None);
|
||
assert_eq!(
|
||
none, NO_SEND_DEADLINE,
|
||
"None must resolve to NO_SEND_DEADLINE"
|
||
);
|
||
assert!(
|
||
none >= Duration::from_secs(365 * 24 * 60 * 60),
|
||
"None must be effectively unbounded (>= 1 year), got {none:?}"
|
||
);
|
||
assert!(
|
||
none > Duration::from_secs(60),
|
||
"None must NOT collapse to the old fixed 60s deadline"
|
||
);
|
||
}
|
||
|
||
// MuxOptions must actually carry the send_deadline knob end-to-end so a
|
||
// consumer can pick the bounded (autorip) vs unbounded (CLI) policy.
|
||
#[test]
|
||
fn mux_options_carries_send_deadline() {
|
||
let cli = MuxOptions {
|
||
skip_errors: false,
|
||
batch_sectors: 0,
|
||
raw: false,
|
||
send_deadline: None,
|
||
};
|
||
assert_eq!(effective_send_deadline(cli.send_deadline), NO_SEND_DEADLINE);
|
||
let autorip = MuxOptions {
|
||
skip_errors: false,
|
||
batch_sectors: 8192,
|
||
raw: false,
|
||
send_deadline: Some(Duration::from_secs(60)),
|
||
};
|
||
assert_eq!(
|
||
effective_send_deadline(autorip.send_deadline),
|
||
Duration::from_secs(60)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn session_mux_keys_passes_decrypt_keys_for_non_dvd() {
|
||
// Non-DVD (e.g. BluRay): the whole-disc key IS the per-title key, so it
|
||
// passes through — proving the special-case keys on FORMAT, not on the
|
||
// mere presence of a key.
|
||
let bd = disc_with_css(crate::disc::DiscFormat::BluRay);
|
||
assert!(
|
||
matches!(session_mux_keys(&bd), DecryptKeys::Css { .. }),
|
||
"a non-DVD passes decrypt_keys() through unchanged"
|
||
);
|
||
}
|
||
}
|