Files
libfreemkv/docs/ps-mod.md
T
Matthew Jackson 6e14fc0c0c libfreemkv: 1.7.0 groundwork — sector-recycle soundness + CI hardening
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.
2026-09-02 12:40:55 -07:00

220 lines
11 KiB
Markdown

# `src/mux/ps.rs` — extended notes
Relocated prose from doc/inline comments in `ps.rs`, trimmed there to stay
within the comment-guard's prose caps. Each `//`/`///` pointer in the source
names the section here it corresponds to.
## Stream-ID constant rationale
- `EXTENDED_STREAM_ID` (`0xFD`): the H.222.0 escape whereby the real stream id
is the `stream_id_extension` carried in the PES extension. HD-DVD `.evo`
puts its VC-1 video (and HD audio) here (a real HD-DVD title: VC-1 on
`0xFD` ext `0x55`); a transport stream never uses it. The elementary-stream
bytes follow the PES header exactly like any other PES — only the routing
key differs.
- `MAX_PS_BUFFER`: a length-0 (unbounded) video PES is delimited by the next
PS-layer boundary; if a corrupt stream declares an unbounded PES and never
follows it with a boundary, `feed()` would otherwise accumulate the entire
input. Past this cap we force the in-progress unbounded PES to flush at
the buffer end so untrusted input cannot drive unbounded allocation. A
real DVD pack/PES is at most a few KB; this leaves generous slack while
still bounding worst-case memory.
## `dvd_audio_pid` rationale
The PID is `0xBD00 | sub_stream_id`, which is unique per sub-stream id (AC-3
/ DTS `0x80..=0x8F`, LPCM `0xA0..=0xA7`). Unlike the old per-codec relative
arithmetic, distinct sub-ids therefore always yield distinct PIDs — so a
mixed-codec title (e.g. AC-3 + DTS, whose sub-ids are 0x80 and 0x88) can
never collide on one PID. This is the single source of truth shared with
`Disc::scan_dvd_titles` (`src/disc/dvd.rs`), which sets each
`AudioStream.pid` from the same function so demuxer output routes through
the title's `pid_to_track`.
HD-DVD (`.evo` Enhanced VOB) carries Dolby Digital Plus (E-AC-3) on
`private_stream_1` sub-stream ids `0xC0..=0xC7` — a range DVD never uses
(DVD audio is `0x80..=0x8F` / `0xA0..=0xA7`), so admitting it here is purely
additive and cannot change any DVD mapping. The PID is `0xBD00 | sub` just
like the DVD audio ranges, so a mixed HD-DVD title (four DD+ tracks
`0xC0..0xC3`) routes each track to its own distinct PID.
## `dvd_pid` rationale
Routes by the REAL on-wire `(stream_id, sub_stream_id)` via the shared
[`dvd_audio_pid`] / [`dvd_subtitle_pid`] tables the scanner also uses —
never per-codec relative arithmetic, which collided on mixed-codec audio
(AC-3 0x80 and DTS 0x88 both mapping to 0xBD00).
`None` is returned for stream/sub-stream combinations the DVD title scanner
does not assign a PID to (e.g. MPEG audio 0xC0-0xDF, private stream 2,
unrecognized sub-stream ranges). The caller is expected to WARN-and-drop in
that case rather than silently mis-routing the packet.
## `PsDemuxer::pending_scan`
Boundary-scan cursor for an unbounded (length-0) PES still waiting for its
terminating PS-layer unit: `(buffer offset of the PES start code, buffer
offset up to which the search has already proved there is no boundary)`.
Both are buffer-relative and are rebased when the buffer drains.
Without it, every `feed` re-searches the WHOLE accumulated payload from the
PES header: the buffer only stops growing at `MAX_PS_BUFFER`, so a stream
that declares an unbounded PES and then never emits a PS-layer start code (a
corrupt or crafted VOB) makes the demuxer scan up to 4 MiB per call,
quadratic in the bytes fed. Cleared whenever the PES is emitted, so it can
never outlive the packet it describes.
## `find_ps_boundary`
Find the next PS-layer unit boundary at or after `from`: a start code whose
ID byte is a pack (0xBA), system header (0xBB), program-end (0xB9), or a
payload-carrying PES stream ID (0xBD..=0xEF).
A length-0 (unbounded) video PES must be delimited by the next PS-layer unit
— NOT by the next raw `00 00 01`. The MPEG-2 video elementary stream inside
the PES is itself full of `00 00 01 xx` start codes (picture 0x00, slices
0x01..=0xAF, GOP 0xB8, sequence 0xB3); a plain start-code scan would cut the
PES inside its own payload and re-scan the discarded video bytes as bogus PS
units. Restricting the search to PS-layer IDs (>= 0xB9, excluding the video
ES codes below it) frames the unbounded PES at the right boundary.
Returns `(boundary, searched_to)`. `searched_to` is the offset up to which
every byte has been PROVED not to begin a PS-layer boundary start code, so a
later call over the same buffer (grown at the tail) may resume there instead
of re-scanning the payload from the PES header. When the scan runs off the
end, the last two bytes are NOT proved: a `00 00 01` prefix can straddle the
next feed's boundary by up to two bytes.
## `parse_pts` — 5-byte PTS/DTS layout (ISO/IEC 13818-1 Table 2-17)
```text
byte0: [prefix:4][pts 32..30:3][marker:1]
byte1: [pts 29..22:8]
byte2: [pts 21..15:7][marker:1]
byte3: [pts 14..7:8]
byte4: [pts 6..0:7][marker:1]
```
## Test: `only_private_stream_2_is_navigation_and_never_a_routable_stream`
`is_nav()` exists to separate the ONE unmappable stream a DVD is expected to
contain — private_stream_2 (0xBF), the PCI/DSI navigation packs (ISO/IEC
13818-1 Table 2-22) — from every other packet whose `dvd_pid()` comes back
`None`, which is an unexpected, possibly-lost real stream. The mux loops use
the distinction to choose between a silent tally and a per-packet WARN, so
collapsing it to a constant either buries a genuine stream loss in the nav
tally, or floods the log with one warning per navigation pack on every DVD
ever ripped.
The invariant that ties the two together: `is_nav()` may only ever be true
where `dvd_pid()` is `None` — a packet that routes to a real track must
never be silently classified as navigation.
## Test: `max_ps_buffer_has_the_documented_value`
`MAX_PS_BUFFER` is read by its own tests only through the same symbol, so a
mutated arithmetic expression in its definition changes what the symbol
itself evaluates to and every self-referential assertion still passes. Pin
the compiled value against a literal computed independently.
## Test: `pack_header_exact_fit_is_consumed_not_awaited`
Pack-header framing (`sc + 14 > len`, then `sc + pack_len > len`) must accept
an EXACT fit — the whole pack (mandatory 14 bytes, or with stuffing) present
and not one byte more — rather than waiting for data that will never come.
Both checks are preceded by an unrelated start code so `sc != 0`: at
`sc == 0` a `sc + pack_len` vs. `sc * pack_len` mutant collapses to the same
value (`0`) and the bound stays unreachable from any input.
## Test: `system_header_length_high_byte_is_not_dropped`
`header_len` is a 16-bit big-endian field (`buffer[sc+4] << 8 |
buffer[sc+5]`). A `<<` -> `>>` mutation collapses the high byte to zero, so
any `header_length > 255` is misread as just its low byte — here 300
(`0x012C`) misread as 44. A start code embedded 50 bytes in (well inside the
true 306-byte unit but exactly where the mis-parsed 50-byte unit would end)
must stay buried in the skipped body under correct parsing, and surface as a
bogus extra PES under the mutant.
## Test: `find_ps_boundary_handles_a_bare_start_code_at_the_buffer_head`
`find_ps_boundary`'s bounds check (`sc + 3 >= data.len()`) must stay an
ADDITION: a `+` -> `-` mutation at `sc == 0` underflows the `usize`
subtraction and panics on a plain 3-byte start code with nothing after it —
exactly the tail a real feed can end on.
## Test: `an_unterminated_pes_is_not_rescanned_from_its_header_every_feed`
An unbounded (length-0) PES is terminated by the next PS-LAYER unit, and
until one arrives the payload accumulates in the buffer. The search for that
unit must not restart at the PES header on every feed: the buffer only stops
growing at `MAX_PS_BUFFER` (4 MiB), and a feed is one read batch (at most
510 sectors ≈ 1 MiB, 60 sectors ≈ 120 KiB on an optical drive), so
re-scanning from byte 0 costs work quadratic in the bytes fed — up to 4 MiB
of scanning per call, for as long as a corrupt or crafted VOB withholds the
boundary. A conformant DVD ends every pack within 2048 bytes and never
reaches this state.
Measured directly, because a work bound has no packet-level shadow:
`boundary_bytes_scanned` counts the bytes `find_ps_boundary` examines. 256
chunks x 4 KiB of boundary-free payload is 1 MiB of input; re-scanning from
the header on every call examines 4 KiB * 256*257/2 = ~128 MiB.
Mutation: drop the `Some((pes_at, searched_to)) if pes_at == sc` arm so
`from` is always `sc + 4`.
## Test: `a_resume_cursor_survives_the_drain_of_units_ahead_of_the_unbounded_pes`
The resume cursor is a BUFFER offset, so it must be rebased when the buffer
drains — and this is the only test in which a drain actually happens while a
cursor is live.
Mutations this catches, both halves of
`self.pending_scan.map(|(pes_at, searched_to)| (pes_at - pos, searched_to - pos))`
in `extract_packets`:
* `searched_to - pos` -> `searched_to`: the next call resumes `pos` bytes
PAST where the previous scan actually stopped, so that window is never
examined. Here the terminating pack header lands inside it and is missed
outright — the unbounded PES runs on past its real end, swallowing the
following unit. That is a CORRECTNESS failure, not a slow path, and the
assertion on the emitted packet catches it.
* `pes_at - pos` -> `pes_at`: the stale offset no longer equals the PES's
post-drain `sc`, the resume arm stops matching and the search restarts
at the PES header. Caught by `boundary_bytes_scanned`, which is why the
fixture puts 64 KiB of payload in the SAME chunk that opens the PES:
that is exactly the span a restart re-examines, so the mutant roughly
doubles the bytes scanned.
`an_unterminated_pes_is_not_rescanned_from_its_header_every_feed` cannot
reach either: it opens the unbounded PES as the very FIRST bytes of the very
first feed, so nothing ever drains ahead of it, `pos` stays 0 and the
subtraction is a no-op. The comment above it asserts neither component can
underflow; nothing exercised the arithmetic at all.
So this fixture puts COMPLETE PS units — a pack header and a length-bounded
PES — ahead of the unbounded video PES *in the same chunk*. The loop
consumes them, breaks on the unbounded PES, and drains `pos` bytes with
`pending_scan` live: exactly the real DVD shape, where a video PES opens
partway through a read batch.
## Test: `parse_stream_id_extension_walks_every_optional_field_to_the_right_offset`
`parse_stream_id_extension` walks every optional PES-header field (PTS/DTS,
ESCR, ES_rate, DSM_trick_mode, additional_copy_info, PES_CRC) and every
optional PES_extension sub-field (PES_private_data, pack_header_field,
program_packet_sequence_counter, P-STD_buffer) before reaching
`stream_id_extension`. Every one of those skips is a `pos +=`; a single
mutated increment (`-=`/`*=`) misaligns every read after it. This test arms
EVERY optional field at once with a known byte count, so any single wrong
skip anywhere in the chain lands on the wrong byte and the assertion fails —
one test proving the whole walk, rather than one per field.
## Test: `length_bounded_pes_exact_fit_is_emitted_not_awaited`
A length-bounded PES (`pes_packet_len != 0`) must be emitted the moment its
declared length is EXACTLY satisfied by the buffer (`sc + 6 > len`, then
`e = sc + 6 + pes_packet_len; e > len`), not held back waiting for a byte
that will never arrive. Feed nothing after the packet and don't flush — if
the boundary checks were `>=` instead of `>`, an exact fit would incorrectly
be treated as "not enough data yet" and the packet would never be produced.