mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Security - Remove the unsafe Vec::set_len over recycled sector buffers — UB reachable from safe code (GHSA-j8ww-f5fg-9pmh, low). Uses Vec::resize; pooling unchanged. CI / tooling - clippy on stable with the MSRV single-sourced from Cargo.toml; cargo-deny dependency-audit gate with first-party git sources allowed (#15); audience-based comment-guard (#17). Docs - README (keydb ownership, BD-J parser count), drive-access transport/reset notes, mux timeline module-doc links.
236 lines
12 KiB
Markdown
236 lines
12 KiB
Markdown
# `src/mux/driver.rs` — design notes
|
|
|
|
## Overview
|
|
|
|
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`](../src/mux/resolve.rs) 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.
|
|
|
|
## `NO_SEND_DEADLINE`
|
|
|
|
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`.
|
|
|
|
## `MuxOptions::send_deadline`
|
|
|
|
`None` means 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.
|
|
|
|
## `MuxEvents` event-firing threads
|
|
|
|
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 the
|
|
cloned `EventFn` from the highway's producer thread / the live
|
|
`DiscStream`'s read loop.
|
|
|
|
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.
|
|
|
|
## `MuxOutcome::undelivered_streams`
|
|
|
|
Non-empty means the file does NOT match the pre-mux plan
|
|
(`mp4_fit_report`), even with `completed = true`: those streams are
|
|
missing. Always empty when the run stopped before the sink was finalised
|
|
(nothing was finished, so nothing is known).
|
|
|
|
## `mux_run_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.
|
|
|
|
## `session_mux_keys`
|
|
|
|
CSS title keys are per-VTS, and `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.
|
|
|
|
## `resolve_inline_base_map`
|
|
|
|
Resolves 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.
|
|
|
|
## `reader_event_fn`
|
|
|
|
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`.
|
|
|
|
## `mux_input_live_uses_inline_discstream_and_applies_key_map` test
|
|
|
|
`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.)
|
|
|
|
## `mux_input_live_aacs_without_caller_map_resolves_and_decrypts` test
|
|
|
|
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).
|
|
|
|
## `mux_input_session_aacs_without_caller_map_resolves_and_decrypts` test
|
|
|
|
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.
|
|
|
|
## `finalize_failed_forces_incomplete_outcome` test (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.
|
|
|
|
Mutation: dropping `finalize_failed` from `mux_run_completed`'s condition
|
|
makes `mux_run_completed(false, true, false)` return `true` → this fails.
|
|
|
|
## `mux_input_session_out_of_range_title_is_clean_error_not_panic` test (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.
|
|
|
|
## `HEADER_BUFFER_CAP_BYTES`
|
|
|
|
Normally `headers_ready()` 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 with `Error::MuxHeaderBufferExceeded` — its OWN code, not
|
|
the headers-never-resolved gate's `Error::MkvInvalid`, which
|
|
`error::is_skippable_title_stub` reports as a skippable stub. Mirrors
|
|
autorip's pre-refactor `HEADER_BUFFER_CAP_BYTES`.
|