Round 4: fix 26 defects across crypto, resource use and codec paths

Twenty-six confirmed findings from the fourth audit round, landed as one
cluster because they were found by agents working over disjoint file sets.

The one worth calling out is a pair of AACS tests that could not fail.
Both asserted CBC behaviour against a hand-rolled expectation that
happened to be IV-independent, so replacing AACS_IV with sixteen zero
bytes left them passing — they were pinning the code's own arithmetic,
not the published constant. Replaced with a literal witness of the
published IV plus the NIST SP 800-38A F.2.2 CBC-AES128 vector, and
verified the other way round: zeroing AACS_IV now fails three tests.

The rest are allocation and correctness work on hot paths: the Annex-B
writer in demux_sink allocated and freed a whole-frame Vec per frame,
which for a UHD title is ~200,000 allocations over the mmap threshold
plus the page faults to first-touch each one; it now reuses a buffer on
the writer, and still takes the NAL prefix width from the configuration
record rather than assuming four.

Six findings whose real fix lives in a consumer crate are recorded for
re-filing rather than patched here.
This commit is contained in:
Matthew Jackson
2026-07-29 22:09:52 -07:00
parent 4fcd28b487
commit 0bbceed985
19 changed files with 1342 additions and 126 deletions
+83 -4
View File
@@ -126,6 +126,12 @@ pub(crate) struct AuAssembler {
/// so a long run of junk with no start code (hostile/corrupt input) costs
/// O(bytes) total, not O(buffer) per push. Reset when `buf[0]` moves.
opener_pos: usize,
/// Test-only: how many times `take_front` fell back to the COPY path. The
/// handover is the whole point of `take_front`, so "did it actually fire" is a
/// property to MEASURE, not to reason about. See
/// `handover_survives_a_large_au_instead_of_copying_every_later_one`.
#[cfg(test)]
copy_path_hits: usize,
}
impl AuAssembler {
@@ -155,6 +161,8 @@ impl AuAssembler {
scan_pos: 0,
seen_unit: false,
opener_pos: 0,
#[cfg(test)]
copy_path_hits: 0,
}
}
@@ -172,6 +180,8 @@ impl AuAssembler {
scan_pos: 0,
seen_unit: false,
opener_pos: 0,
#[cfg(test)]
copy_path_hits: 0,
}
}
@@ -356,14 +366,38 @@ impl AuAssembler {
/// (a small AU after a multi-MB one): handing over would otherwise attach an
/// oversized idle allocation to a small frame for as long as the frame queues
/// downstream, trading a copy for resident memory.
///
/// That fallback must not become permanent. `buf`'s capacity used to be a
/// one-way high-water mark — the replacement buffer was created with
/// `cap.max(tail_len)`, and the copy path's `drain` also preserves `cap` — so
/// once ONE large AU had been assembled, every later smaller AU satisfied
/// `cap > 2*end` and took the copy path forever. On a UHD HEVC title the first
/// IDR grows `buf` to ~4-8 MB, after which each ~200-400 KB P/B AU paid a
/// whole-AU allocation plus a whole-AU memcpy plus a tail memmove for ~99% of
/// the ~200,000 coded pictures — tens of GB of exactly the memcpy this handover
/// exists to remove. So the copy path now also RELEASES the high-water
/// capacity, which re-arms the handover for the next AU: one copy after a size
/// step down, not one per frame forever.
fn take_front(&mut self, end: usize) -> Vec<u8> {
let cap = self.buf.capacity();
let tail_len = self.buf.len() - end;
if cap > end.saturating_mul(2) {
#[cfg(test)]
{
self.copy_path_hits += 1;
}
let data = self.buf[..end].to_vec();
self.buf.drain(..end);
// Shrink toward what this AU actually needed (the tail plus room for
// another AU of about this size). Only the short tail is copied, and it
// brings `cap` back under the `2*end` threshold so the next AU of this
// size hands over instead of copying.
self.buf.shrink_to(end.max(tail_len));
return data;
}
let mut tail = Vec::with_capacity(cap.max(self.buf.len() - end));
// Replacement buffer: enough for the tail plus room to accumulate the next
// AU of about this size. NOT `cap`, which would re-pin the high-water mark.
let mut tail = Vec::with_capacity(end.max(tail_len));
tail.extend_from_slice(&self.buf[end..]);
let mut data = std::mem::replace(&mut self.buf, tail);
data.truncate(end);
@@ -915,11 +949,56 @@ mod tests {
before,
"the emitted AU must own the buffer's allocation (no whole-frame copy)"
);
assert_eq!(
// The replacement buffer keeps room for another AU of about this size, so
// the next AU does not re-grow — but it is NOT pinned to the OLD capacity,
// which would make `buf` a permanent high-water mark and send every later
// smaller AU down the copy path (see
// `handover_survives_a_large_au_instead_of_copying_every_later_one`).
assert!(
a.buf.capacity() >= au1.len(),
"replacement buffer must fit another AU of this size: {} < {}",
a.buf.capacity(),
cap_before,
"the replacement buffer keeps the capacity, so the next AU does not re-grow"
au1.len()
);
assert!(
a.buf.capacity() <= cap_before,
"replacement buffer must never EXCEED the old capacity"
);
assert_eq!(a.buf.len(), 4, "the buffer holds only AU2's delimiter tail");
}
/// MEASURED: `take_front`'s copy fallback must not become permanent.
///
/// `buf`'s capacity used to be a one-way high-water mark, and the copy path's
/// `drain` preserves it, so after ONE large AU every later smaller AU satisfied
/// `cap > 2*end` and copied forever. On a UHD HEVC title the first IDR grows
/// `buf` to multiple MB, after which ~99% of the ~200,000 coded pictures each
/// paid a whole-AU allocation + whole-AU memcpy + tail memmove — tens of GB of
/// exactly the copy the handover exists to remove. Counted at the copy path
/// itself: one copy is expected right after the size step down; a per-frame
/// copy is the bug.
#[test]
fn handover_survives_a_large_au_instead_of_copying_every_later_one() {
let mut a = AuAssembler::for_codec(Codec::H264);
// Production shape: BD-TS aligns one access unit per PES, so each `push`
// carries about one AU and the buffer holds ~one AU at a time. One large AU
// (the IDR) followed by a run of much smaller ones (P/B frames). Each AU is
// pushed with the NEXT AU's opener so the previous one closes.
const SMALL: usize = 64 * 1024;
let mut pending = au(0x11, 2 * 1024 * 1024);
for i in 0..20u8 {
let next = au(0x30 + i, SMALL);
// Append the next AU's 4-byte opener to close `pending`, push, and
// carry the rest of `next` forward.
pending.extend_from_slice(&next[..4]);
a.push(&pending, Some(1), None, None, false);
pending = next[4..].to_vec();
}
let hits = a.copy_path_hits;
assert!(
hits <= 2,
"the copy fallback must re-arm the handover, not fire for every AU \
after a large one: {hits} copies over 20 access units"
);
}
}