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.
11 KiB
AC3 / E-AC3 parser notes (src/mux/codec/ac3.rs)
Why a frame set is one sample, never split into separate tracks
A legacy AC-3 syncframe is a complete access unit on its own, but an E-AC-3
access unit (ETSI TS 102 366 / ATSC A/52 Annex E) is a whole FRAME SET: the
mandatory independent substream (substreamid 0) plus every DEPENDENT
substream frame that follows it, plus any ADDITIONAL independent substreams
(substreamid 1..7) and their own dependents. A decoder needs the whole set
to reconstruct the programme (a 5.1 independent substream plus a dependent
substream carrying the extra channels of a 7.1 programme, and the AC-3-core +
E-AC-3-dependent arrangement used for backwards-compatible Dolby Digital
Plus), and every substream of the frame set covers the SAME time period.
WHY the whole frame set is ONE sample, rather than splitting an additional
independent substream (an associated / commentary service) into a track of
its own: Annex E orders the substreams of a frame set inside a single
elementary stream, all covering one time period, and a decoder handed a frame
set renders the programme it is asked for from it — that is exactly what the
container's E-AC-3 sample and its dec3/num_ind_sub description denote.
Splitting would mean rewriting the bitstream (re-numbering substreamid so
the extracted service becomes substream 0, and rebuilding its frame sets),
because a substream numbered 1..7 with no substream 0 is not a conforming
stream. That is a transcode, not a remux; this parser is lossless, so the
frame set stays whole and player-side programme selection decides what is
heard.
SAMPLE_RATES / fscod2
Sample rates indexed by fscod (0=48kHz, 1=44.1kHz, 2=32kHz). fscod=3 is
reserved in AC-3; in E-AC-3 it signals "fscod2" (reduced rates: 24/22.05/16
kHz, selected by byte-4 bits [5:4]). frame_sample_rate decodes fscod2 in
the E-AC-3 case; the table's index-3 entry (48 kHz) is only the fallback when
the header is too short to read fscod2.
MAX_AC3_BUF derivation
An AC-3/E-AC-3 syncframe is at most 8192 bytes (the frame_size > 8192
reject) and an access unit is a whole frame set: up to 8 independent
substreams, each with up to 8 dependent substreams (ETSI TS 102 366 Annex E)
— 72 syncframes worst case. A cap of one straddling frame set plus slack
therefore has to exceed 72 × 8192 = 576 KiB. If the buffer grows past the cap
without yielding a frame (pathological / never-syncing input) it is dropped
and resync begins, rather than accumulating one PES worth of data per call
for the whole title.
scan_access_units
Per ETSI TS 102 366 (ATSC A/52) Annex E an access unit is one FRAME SET: the
mandatory independent substream (substreamid 0) plus every DEPENDENT
substream that follows it, plus any ADDITIONAL independent substreams
(substreamid 1..7 — associated/commentary services) with their own
dependents. The access unit therefore closes only when the next
substreamid-0 independent substream (or, with at_eos, the end of the
stream) is seen, and it carries THAT substream's PTS and duration: every
other substream of the frame set describes the SAME time period and adds no
duration of its own.
base_pts_ns times the access unit that begins at data[0] — i.e. the
running cadence carried over from the previous call. anchor re-anchors the
running PTS to this PES's own timestamp at the first access unit that STARTS
in the newly-appended bytes: a PES timestamp applies to the first access unit
beginning in that PES, never to one that began in an earlier PES and is only
being completed (or was held) here.
Returns the emitted access units, the offset in data from which bytes must
be carried over to the next call, and the PTS to stamp on the access unit
that begins that carry-over.
substream_role
Byte 2 of an E-AC-3 syncframe is strmtyp(2) | substreamid(3) | frmsiz[10:8]
(ETSI TS 102 366 Annex E BSI). strmtyp 0 and 2 are independent substreams
(type 2 being an independent substream that is not the first of the bit
stream); strmtyp 1 is the dependent substream.
Annex E defines a FRAME SET as independent substream 0 — mandatory, always
first — with its dependent substreams, followed by the OPTIONAL additional
independent substreams 1..7, each with their own dependents; all of them
carry the same time period. So the access-unit boundary is an independent
substream with substreamid == 0, and NOT merely "an independent substream":
an additional independent substream (an associated or commentary service)
sits INSIDE the frame set already open. Keying the boundary on strmtyp
alone made every such substream close the access unit and advance the
running PTS a second time over the same ~32 ms, doubling the timeline (about
a second of A/V drift per second of audio).
strmtyp 3 is reserved: its BSI layout is not defined, so its substreamid
bits cannot be trusted and it is treated as starting a fresh access unit — an
unknown frame is never merged into an unrelated programme, and never
discarded as an orphan either.
Legacy AC-3 (bsid < 11) has no substream structure — byte 2 there is the
crc1 field, never strmtyp — so it always starts an access unit.
A frame set MAY carry a substream as several consecutive syncframes of fewer
than six blocks each (Annex E allows numblkscod < 3). Those extra syncframes
are substreamid 0 too, so each starts its own access unit here rather than
merging into one frame set. That is deliberate: each carries its OWN
numblkscod-derived duration (see frame_duration_ns), so the timeline total
stays exact, and the substreams are still delivered in bitstream order.
frame_crc_ok
Per ETSI TS 102 366 (ATSC A/52) the frame carries a CRC-16/ANSI (poly 0x8005,
init 0, non-reflected) over the bytes after the 2-byte syncword — i.e.
crc16_ansi(&buf[2..]) == 0 covers frame_size - 2 bytes; the trailing crc
word makes a clean frame's residue zero. A nonzero residue is a
~1-in-65536-certain sign of payload corruption, so the frame is dropped
(silence gap) rather than shipped as a glitch.
acmod_channels bit layout
This is the AUTHORITATIVE channel count for the track header: the DVD IFO
audio_attr_t.channels nibble is a well-known unreliable/stale field, so the
muxer prefers this over the IFO-claimed count (the bitstream acmod is
authoritative; the IFO audio nibble is not trusted). LFE adds one channel
(e.g. acmod=7 + lfeon → 6 = 5.1).
Bit layout from the syncword (A/52 §5.3.2 BSI):
byte 5: bsid(5) | bsmod(3)
byte 6: acmod(3) | [cmixlev(2) if acmod has a centre and acmod!=1]
| [surmixlev(2) if acmod has surround]
| [dsurmod(2) if acmod==2] | lfeon(1) | ...
acmod therefore always occupies byte-6 bits 7-5; lfeon follows a variable
number of optional 2-bit fields, so the decoder tracks the bit cursor.
ACMOD_CHANNELS table
Base channel count per AC-3 acmod (A/52 Table 5.8), BEFORE the LFE. Index is
the 3-bit acmod value; add 1 when lfeon is set.
0 = 1+1 (Ch1, Ch2) -> 2 4 = 2/1 (L,R,S) -> 3
1 = 1/0 (C, mono) -> 1 5 = 3/1 (L,C,R,S) -> 4
2 = 2/0 (L, R) -> 2 6 = 2/2 (L,R,SL,SR) -> 4
3 = 3/0 (L,C,R) -> 3 7 = 3/2 (L,C,R,SL,SR) -> 5
Test helpers and coverage notes
finalize_ac3_crc
Relies on the CRC-16/ANSI residue property: appending crc16([2..n-2])
(big-endian) zeroes the register over [2..n].
the_working_buffer_is_reused_across_packets_not_reallocated
parse runs once per PES packet — of the order of 10^5 times on a feature's
audio track — and used to build a fresh Vec every call. The copy itself
cannot go without restructuring the borrow relationship, but the allocation
can: the buffer is taken out of self, filled, and put back with its
capacity intact. Asserting on capacity is deliberately white-box, because
that is exactly what regresses if someone reverts to to_vec() — the
behaviour would be identical and no other test would notice.
make_eac3_frame byte layout
ETSI TS 102 366 Annex E: byte 2 is strmtyp(2) | substreamid(3) | frmsiz[10:8],
byte 3 is frmsiz[7:0], and the frame is (frmsiz + 1) * 2 bytes. byte 4 =
fscod 0 (48 kHz), numblkscod 3 (6 blocks → 1536 samples → 32 ms), acmod 7 +
lfeon (5.1); byte 5 = bsid 16 so the E-AC-3 paths are taken.
a_held_access_unit_is_not_rescanned_from_its_first_frame_every_packet
An access unit closes only at the next substreamid-0 independent substream,
so one that keeps gaining dependent substreams stays OPEN across PES
boundaries and its bytes stay in the carry-over. The carry-over must not be
re-scanned — and re-CRCed — from the access unit's first byte on every
packet: the buffer only stops growing at MAX_AC3_BUF (1 MiB), and a PES on
a DVD is about 2 KiB, so re-deriving the held access unit costs work
quadratic in the packets fed.
Measured directly, because a work bound has no frame-level shadow:
frames_scanned counts the syncframes the scanner sizes and CRC-gates.
Re-scanning from byte 0 examines 1 + 2 + ... + (N+1) frames.
Mutation: pass None for held in parse (or drop the if let Some(h) = held resume) — the count returns to the quadratic figure.
a_discontinuity_drops_the_held_access_unit_with_its_bytes
parse clears self.acc on a discontinuity because the buffered bytes are a
truncated frame. The held access unit is described by OFFSETS into exactly
those bytes, so it has to go with them. Without self.held = None, the next
packet resumes a HeldAu whose start/end were computed against the
pre-gap buffer but are applied to the unrelated post-gap bytes — splicing
audio across the gap at best, and indexing past the end of the new, shorter
buffer at worst.
The two existing discontinuity tests use plain AC-3 (bsid < 11), which never holds an access unit open, so neither of them reaches this reset.
a_held_access_unit_is_dropped_when_the_track_poisons_before_it_resumes
Mutation this catches: deleting (or inverting) the resume-path re-check if drop_reason.is_none() && self.tally.is_poisoned() at the top of
scan_access_units. The verdict on a held access unit is frozen at the
moment it was OPENED, and ac3_drop_reason reads the tally BEFORE the
previous access unit is closed — so the very drop that crosses the poison
threshold lands after the next unit's verdict was already taken as None.
Without the re-check that unit is emitted as an ordinary frame after the
track has been judged too damaged to mux: corrupt audio passed through as
success, and worse, it is the ONE frame that escapes a whole-track fallback
whose entire point is that nothing after the verdict ships.
Neither existing held-AU test reaches this: both keep a pristine tally.
The fixture drives the exact interleaving above. DropTally poisons once
verified_dropped * 2 > kept + dropped past the 200-AU gate, so one PES
carries 200 CRC-failing substreamid-0 syncframes followed by a clean one.
Closing corrupt unit #200 (which happens only when the clean frame is
reached) latches the poison — after that clean frame's own verdict was
computed. Being an E-AC-3 unit that can still gain substreams, it is then
HELD across the boundary with drop_reason: None, which is precisely the
state the re-check exists for.