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

11 KiB

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)

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.