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.
25 KiB
mp4 read (src/mux/mp4/read.rs) — internal notes
MAX_TRACKS
Track count is otherwise unbounded (a crafted moov can pack tens of
thousands of trak boxes), and the per-track PID is 0x1011 + track_idx,
which overflows u16 past ~61k tracks. Real titles have well under a
hundred tracks.
MAX_SAMPLE_COUNT
MP4 sample-table fields (stsz sample_count, stts/ctts run-lengths) are
untrusted 32-bit values; a crafted box can declare billions of entries in a
few bytes. Real titles stay far under this (a 10 h/60 fps track is ~2M
samples), so clamping to it caps a hostile file's allocation without
truncating any legitimate track.
MIN_FILE_BYTES_PER_SAMPLE
Only vide and soun tracks are indexed here, and no real coded video or
audio access unit is anywhere near this small: the shortest legal AC-3
frame is 128 bytes, an AAC frame is hundreds, and a video sample carries at
least a NAL header plus slice data. A 2-hour title runs to thousands of
file bytes per sample, so this cannot truncate a genuine track — it only
stops a crafted sample table from claiming more samples than the file
could possibly hold.
Test rationale (mod tests)
FakeBigReader
A Read + Seek backed by a small crafted prefix followed by an endless run
of zeros, reporting len bytes total on seek(End). Lets a test exercise
a hundred-MiB-to-multi-GiB boundary (MAX_ALLOC_BYTES, a sparse-file-inflated
file_len) without a real backing allocation of that size for the SOURCE
side — only the destination buffer the code under test allocates is real,
which is the point of the test.
parse_stss_reads_two_distinct_entries_from_their_own_offsets
parse_stss had no direct positive-path test anywhere in this file — only
its short-buffer safety was pinned. Two real, distinct entries in a buffer
sized to exactly fit them (b.len() == 16, so the second entry's
o + 4 == b.len() exactly) separates every arithmetic mutant at once: an
o = 8 + i*4 → 8 - i*4 slip reads the count field instead of the
second entry; an i*4 → i/4 slip re-reads the FIRST entry for the
second (integer division collapses i=1 back to the same offset as
i=0); and an o + 4 > b.len() → o + 4 < b.len() slip breaks out on
the very first entry, because there IS more room after it (12 < 16) —
leaving the set empty instead of holding both.
parse_stss_count_lie_is_bounded_by_the_box_not_trusted
A declared count larger than the box can hold (a lie, or a truncated
box) must be bounded by the per-entry guard, not by trusting the count —
the SAME contract parse_stco/parse_stsc/parse_stts pin with their
own "count lie" tests. Bounded HERE means the loop must stop, not read
past the end: an o + 4 > b.len() → o - 4 > b.len() mutant almost
never fires (o - 4 stays small for every realistic o), so it lets
the loop walk straight past a truncated box into an out-of-bounds be32
read.
sample_offsets_advances_through_distinct_sizes_within_one_chunk
sidx must advance by exactly one PER SAMPLE PLACED, so the third (and
every later) sample in a chunk picks up its own size, not the first
sample's reused over and over. With only two samples in a chunk this bug
is invisible — the second sample's pushed offset was already computed
from sizes[0] before sidx had a chance to matter — so this test needs
a chunk with THREE distinctly-sized samples to expose it.
stream_read_allows_a_sample_of_exactly_max_alloc_bytes
Stream::read's own s.size as u64 > MAX_ALLOC_BYTES cap — a SEPARATE
call site from read_moov's (see
read_moov_allows_a_payload_of_exactly_max_alloc_bytes / the module doc's
note on the same policy hardened at only one of two sites elsewhere in
this crate). A >↔>= mutant here would reject a sample of exactly the
cap, which must be allowed. Builds an Mp4Reader directly (its fields are
private but visible to this module's tests) over a FakeBigReader so the
256 MiB sample doesn't need a real backing file — only the destination
buffer read() allocates is real.
stream_read_rejects_a_sample_one_byte_over_max_alloc_bytes
The same cap's other edge: one byte OVER it must still be rejected. A
>↔== mutant would only catch a sample of EXACTLY the cap and let
anything larger through — the worse direction to get wrong, since it
turns the cap into a no-op for every real oversized/hostile sample.
stts_expands_runs_to_per_sample_deltas_in_order
stts run-length expansion — ISO/IEC 14496-12 §8.6.1.2. The box stores
(sample_count, sample_delta) runs; the reader must expand them back to
one delta PER SAMPLE, in order, or every sample after the first run lands
on the wrong decode time.
(This test used to be called stts_and_ctts_expand while touching no
ctts box at all. The composition-offset half now lives in
ctts_build_and_parse_are_exact_inverses_over_signed_offsets and
b_frame_presentation_order_survives_the_mp4_round_trip.)
declared_track_duration_equals_frame_count_times_frame_duration
A track's declared duration must be its real length. mdhd.duration is in
the MEDIA timescale and mvhd.duration in the movie timescale
(ISO/IEC 14496-12 §8.2.2, §8.4.2); a player uses them to draw the seek bar
and to decide when the title ends, so a zeroed or constant duration makes
a correct file unseekable and apparently empty.
moov_tree_carries_the_mandatory_track_header_and_media_boxes
The moov tree must carry the boxes ISO/IEC 14496-12 makes mandatory for
a playable track, with the field values the spec fixes. These live in
this module rather than the writer's because the box-walking helpers
(find_box) are here — asserting through them means the test reads the
file the way the demuxer does, instead of re-deriving the layout.
Nothing in the demux path needs tkhd/vmhd/smhd/dinf, so an empty
one of any of them round-trips through this crate unnoticed while making
the file unplayable elsewhere.
table_parsers_reject_every_length_up_to_the_header_size
Every one of these five table parsers opens with if b.len() < 8 { return <empty> } before reading count = be32(b, 4), which needs b.len() >= 8.
No existing fixture ever called any of them with a buffer shorter than 8
bytes — every test builds a complete box — so a <→== mutant of that
guard (which only early-returns at EXACTLY b.len() == 8, letting every
length below it fall through to the out-of-bounds be32 read) had
nothing to fail against. Pins the entire boundary at once, every length
from 0 through 8, for all five.
parse_stco_co64_reads_each_byte_from_its_own_offset
co64's 8-byte offsets are decoded byte-by-byte
(u64::from_be_bytes([b[o], b[o+1], ..., b[o+7]])) — a SEPARATE code path
from the 32-bit stco case, which goes through be32 instead and was
never itself built with a co64 fixture anywhere in this file. Every byte
here is distinct, so an index slip (off by one, negated, or scaled) that
reads the wrong byte cannot agree by coincidence.
parse_stsd_rejects_every_length_below_the_header_size
Every buffer shorter than the 8-byte version+flags+entry_count header
must return None — never fall through to &b[8..], which would slice
past the end and panic. b.len() == 8 is the boundary itself (see the
equivalence note on parse_stsd's guard); everything strictly below it
must be rejected by THIS check, not rely on a later one that isn't
reached yet.
parse_esds_asc_boundary_checks_are_independent
The final guard is asc_len == 0 || end > b.len() — two independent
rejection reasons, not one condition that needs both. Pins each half:
asc_len == 0must be rejected even thoughend == posis trivially in bounds (an||→&&mutant would let a useless zero-length ASC through asSome(vec![]));end > b.len()means TRUNCATED, not "anything before the buffer's end" — trailing bytes after a complete ASC (end < b.len()) must still succeed (a>→<mutant rejects exactly this, the common case of an esds embedded inside a larger box with more child boxes or padding after it).
read_moov_exactly_header_sized_is_a_valid_empty_box
box_size < header_len rejects a box that cannot even hold its own
header. A box whose size is EXACTLY header_len (8, no 64-bit largesize)
is the boundary itself: legal (an empty box), so an empty moov — size
8, zero payload bytes — must parse to Ok(vec![]), not be rejected by a
<→<=/== mutant that also rejects the boundary.
read_moov_rejects_a_box_that_overruns_the_file_via_the_or_not_and_guard
The forward-progress / EOF guard is box_size < header_len || pos.checked_add(box_size).is_none_or(|end| end > file_end) — an ||↔&&
mutant only shows up on an input where EXACTLY ONE side is true. A box
that claims to run 992 bytes past a file that ends right after its header
satisfies only the second clause (box_size >= header_len, so the first
is false); under && the guard would not fire, and read_moov would
instead fail later inside read_exact with a plain UnexpectedEof — a
different, uncoded io::Error rather than this crate's Mp4Invalid
(ErrorKind::InvalidData), which the guard exists to produce uniformly
for every malformed box.
read_moov_allows_a_payload_of_exactly_max_alloc_bytes
payload_len > MAX_ALLOC_BYTES is a SEPARATE, sparse-file-proof cap from
the EOF check above (see
read_moov_over_cap_rejected_despite_inflated_file_len). A >→>=
mutant would reject a moov whose payload is EXACTLY MAX_ALLOC_BYTES,
which must be allowed.
edit_list_media_time_shifts_the_presentation_timeline
Regression (silent A/V desync): the sample timeline was built purely from
stts/ctts starting at tick 0 and no edts/elst was ever parsed, so the
presentation timeline an edit list defines was discarded. A non-empty
edit with media_time = 1024 — the standard way encoder delay is
expressed — must move the track's presentation, not be ignored.
parse_elst_v1_entry_bytes_come_from_their_own_offsets
Every byte of a version-1 entry's segment_duration (8 bytes) and
media_time (8 bytes) at its own offset — not a neighbour's. The
existing fixtures elsewhere use 5_000u64 and -1i64, which are almost
all zero/0xFF bytes, so an index slip that reads an adjacent byte (or a
header byte outside the entry) often reads the SAME value and the test
cannot tell. Every byte here is distinct and nonzero, and the header
bytes are zero, so any wrong index — off by one, negated, or scaled —
pulls in a value that cannot match by coincidence.
elst_offset_ticks_stays_safe_at_zero_empty_ticks_even_with_a_zero_movie_timescale
Some(mts) if empty_movie_ticks > 0 and the None branch's if empty_movie_ticks > 0 are both guards around a tracing::warn!/delay
computation that, at empty_movie_ticks == 0, must produce the exact
same answer as skipping it (0 ticks of delay — there is no empty edit to
convert). The one case where the two are NOT interchangeable is
movie_timescale == Some(0): skipping the arithmetic returns 0, but
entering it divides by that zero timescale and panics. Real callers
never pass this — from_reader filters mvhd's timescale through
.filter(|&t| t != 0) before it ever reaches this function (see
mdhd_timescale_zero_does_not_divide_by_zero for the sibling guard on
the media timescale) — but the function itself has to stay panic-free at
its own boundary, independent of what its one caller happens to do
today, because libfreemkv runs inside the long-lived autorip service
and a panic there is downtime.
audio_trak_hostile_count
A crafted file with a fixed-size stsz (sample_size != 0) claiming
count = 0xFFFFFFFF must not inflate the sample index past the file's own
byte length. from_reader sets sample_budget = MAX_SAMPLE_COUNT.min(file_len), so a few-hundred-byte file bounds the
Vec<SampleRef> to a few hundred — NOT the 16M MAX_SAMPLE_COUNT
ceiling. Mutation check: revert the budget to a bare MAX_SAMPLE_COUNT
and this file yields ~16M samples, failing the <= file_len (and < MAX_SAMPLE_COUNT) assertions below. Built by a minimal-but-complete
audio trak whose fixed-size stsz LIES about its sample count
(u32::MAX), with an stsc/stts wide enough to place whatever count
survives the budget.
sample_index_ram_is_bounded_by_a_multiple_of_the_file
The RAM amplification the byte-per-sample budget actually bounds: a
small crafted file whose stsz claims u32::MAX samples must not force
an eager multi-hundred-MB index. At ~60 bytes of RAM per indexed sample,
a budget of one sample per file byte gave ~60x the input size; the
assertion pins the amplification factor rather than a raw count, so it
fails if the budget ever goes back to counting samples per byte.
audio_trak_missing
Build an audio trak identical to audio_trak(48_000) but with the
named stbl child box omitted. Used to reach the untrusted-input guards
that drop a track whose stsz says samples exist yet whose stco/co64
(chunk offsets) or stsc (sample-to-chunk map) is missing — without such
a table every sample offset would resolve near file byte 0.
missing_stco_drops_track_all_dropped_is_invalid
A track with samples (stsz) but no chunk-offset table (stco/co64)
must be DROPPED, not indexed with offsets that resolve near file byte 0.
With it the only track, the whole file fails Mp4Invalid. Mutation
check: delete the if chunk_offsets.is_empty() { continue; } guard and
from_reader returns Ok (garbage samples), flipping this to FAIL.
missing_stsc_drops_track_all_dropped_is_invalid
A track with samples (stsz) and chunk offsets (stco) but no
sample-to-chunk map (stsc) must be DROPPED — without stsc the
samples cannot be placed against the chunk offsets and would pack from
byte 0. Mutation check: delete the if stsc.is_empty() { continue; }
guard and from_reader returns Ok, flipping this to FAIL.
missing_stts_drops_track_all_dropped_is_invalid
A track with samples (stsz) but no time-to-sample table (stts,
mandatory per ISO/IEC 14496-12 §8.6.1) must be DROPPED — without it every
sample takes dur=0, collapsing the whole track onto one instant
(all-zero timestamps). With it the only track, the file fails
Mp4Invalid. Mutation check: delete the if durations.is_empty() { continue; } guard and from_reader returns Ok (all-zero-timestamp
samples), flipping this to FAIL.
stsc_placing_fewer_samples_than_stsz_drops_the_track
An stsc that PASSES the non-empty guard but places fewer samples than
stsz declares must drop the track. sample_offsets used to pack the
unplaced tail after the last known offset, inventing a position, so
those frames were read from arbitrary file bytes — the same "emit
garbage" outcome the stco/stsc presence guards exist to refuse.
Mutation check: restore the trailing while offsets.len() < sizes.len()
pack-after-last loop and this file indexes 3 samples instead of erroring.
short_stts_drops_the_track_like_an_absent_one
A SHORT stts — present and non-empty, but covering fewer samples than
stsz declares — must drop the track just like an absent one. The tail
samples took dur = 0, collapsing the whole tail onto a single
timestamp, which is the exact degenerate timing the absent-stts guard
refuses.
Mutation check: weaken the guard back to durations.is_empty() and this
file indexes 3 samples whose last two share one timestamp.
mdhd_language_boundary_rejects_short_not_merely_non_exact
The length guard is b.len() < off + 2 — reject too SHORT, not "not
exactly off + 2". Pins both edges of that boundary for the version-0
offset (off == 20):
b.len() == off(no room for the packed field at all) must returnNone, not read two bytes past the end;b.len() == off + 3(one byte MORE than the minimum) must still succeed — a<→>mutant of the guard would reject this case, sinceoff + 3 > off + 2.
parse_stsd_takes_height_from_its_own_field_not_the_width_beside_it
height is the SECOND of the two 16-bit dimensions in a
VisualSampleEntry, at byte 26 — width sits at 24. Reading the wrong one
is silent: the value is still a plausible dimension, so the track's
seeded resolution simply comes out wrong (1920 read as a height would
classify a 1080p title as UHD). The two are deliberately different here;
a fixture with width == height would pass under either offset.
parse_stsd_reads_channelcount_from_its_own_field_and_defaults_a_short_entry
channelcount is at byte 16 of an AudioSampleEntry, after the 8 reserved
bytes that follow data_reference_index. An offset slip reads a reserved
field, and reserved fields are conventionally zero — which would make
every audio track come out as 0 channels rather than fail. The
short-entry fallback of 2 is pinned in the same test: an entry with no
room for the fixed part still has to name SOME channel count, and 0 is
not a usable one.
parse_stsd_extracts_aac_codec_private_only_when_the_body_is_long_enough
mp4a's codec_private (the AudioSpecificConfig inside a child esds
box) is only read when the entry is Codec::Aac AND its body is at
least the 28-byte fixed AudioSampleEntry part — find_box(&body[28..], ...) would slice past the end otherwise. No existing test built an
actual mp4a entry, so neither half of that guard (&&, or the >= 28
on its right) was constrained: an &&→|| mutant reaches the same
&body[28..] on a codec-only match, and a >=→< mutant loses a real
title's AAC CodecPrivate outright by only running on entries too short
to have one.
parse_stsd_recognises_every_audio_fourcc
Every recognised audio fourcc must map to its own Codec, checked one by
one. ec-3/dtsc/dtse/dtsh/dtsl had no coverage at all before
this test — a deleted match arm for any of them falls through to the
catch-all _ => return None, silently dropping every E-AC-3 or DTS
variant track from the title instead of remuxing it.
read_descriptor_len_is_a_four_byte_base_128_varint
A descriptor length is a base-128 varint: 7 bits per byte, continued
while the top bit is set, to a maximum of FOUR bytes. Every existing
esds fixture uses a single-byte length, so the continuation path is
unconstrained by them — yet a multi-byte length is exactly what an
esds carrying a long AudioSpecificConfig (or one written by a tool
that always pads to 4 bytes, which is common) uses.
parse_esds_asc_steps_over_every_optional_es_descriptor_field
The optional ES_Descriptor fields (ISO/IEC 14496-1 §7.2.6.5) are
selected by three flag bits, and each one that is set inserts bytes
before the DecoderConfigDescriptor. Skipping them wrongly does not
corrupt anything — the tag check fails and parse_esds_asc returns
None, so the AAC track simply loses its CodecPrivate and the remux
emits AAC no decoder can initialise.
The existing fixture has flags = 0, so all three skips are unconstrained. This one sets all three at once and still has to reach the same ASC.
find_boxes_capped_refuses_a_box_smaller_than_its_own_header
A box header is 8 bytes, so a declared size below 8 cannot describe a
box — and taking it at face value slices payload[pos + 8 .. pos + size] with the start past the end, which panics. A crafted moov is
untrusted input read straight off a user's file.
find_boxes_capped_decodes_the_size_field_from_its_own_bytes
The declared box size is decoded from four specific bytes
(payload[pos..pos+4], big-endian). A size deliberately chosen so byte
1 differs from byte 0 catches an index slip that rereads byte 0 (or any
other wrong offset) instead of the byte the field actually occupies —
with a small size every byte but the last is zero, so such a slip would
go unnoticed.
sample_offsets_clamps_an_stsc_run_that_outruns_the_chunk_table
An stsc entry names a first_chunk that may exceed the chunk count
the stco actually declares (a truncated or crafted table). The run it
would fill has to be clamped to the chunks that exist — indexing spc
past its length is a panic on a file the user merely opened.
The clamp is only reachable through a NON-final entry: the final entry's
end is n_chunks by construction, so a fixture whose only over-range
entry is last never reaches the line.
elst_offset_ticks_honours_the_first_media_edit_not_the_last
ISO/IEC 14496-12 §8.6.6: an edit list may hold several media edits. This frame model can only express a constant shift, so it honours the LEADING one and logs the rest — taking the last instead would shift the whole track by a trim that belongs to a later segment, i.e. silent A/V desync of exactly the size of the difference.
mvhd_timescale_version_1_reads_past_the_64_bit_times
A version-1 mvhd carries 64-bit creation/modification times, so its
timescale sits at byte 20 rather than 12 (ISO/IEC 14496-12 §8.2.2).
Every existing fixture is version 0, so the version-1 offset is
unconstrained by them — and it is not a hypothetical: writers emit
version 1 whenever the movie duration does not fit 32 bits.
A wrong offset reads part of the 64-bit modification time, which is a large arbitrary number — and the movie timescale is the denominator that converts an empty edit's delay into media ticks, so the A/V offset it produces is arbitrary too.
parse_elst_entry_count_is_capped
Only the leading empty edits and the FIRST media edit shape the offset,
so MAX_ELST_ENTRIES bounds what a crafted elst can allocate. Without
it a box declaring millions of entries — and carrying the bytes for
them, inside a moov already capped at 256 MiB — expands to a Vec of
20-byte tuples for no benefit at all.
non_av_handler_track_is_dropped_not_folded_into_audio
A hdlr of anything other than vide/soun (e.g. a hint or subtitle
track) must be dropped, not folded into the audio branch. Before this
guard existed as a literal &h == b"soun" comparison, weakening it to
"anything that isn't vide" would silently turn a hint track's stsd
entry into a fabricated AudioStream — with whatever codec its sample
entry happened to name.
per_track_pid_arithmetic_is_exact_past_the_first_track
The per-track PID is 0x1011 + track_idx for video and 0x1100 + track_idx for audio. At track_idx == 0 a +↔-/* mutant of either
is invisible (0x1011 + 0 == 0x1011 - 0 == 0x1011 * 0… no — * 0 is 0,
already different — but +↔- specifically needs track_idx != 0 to
separate). Four tracks (two video, two audio) push track_idx to 1 and
3 respectively, so every one of +→*, +→- is forced to disagree
with the correct PID on the second track of its kind.
track_idx_advances_past_a_sample_less_track
A track whose stsz is absent (n == 0) still occupies a track_idx
slot and must still advance the counter for the NEXT track — otherwise
two tracks collide on the same PID, or (worse, if the increment
direction is reversed) track_idx underflows a usize and panics on
the very first sample-less track.
sample_budget_is_shared_and_exhausted_across_tracks
The global sample_budget is DECREMENTED by each track's real sample
count, and it is SHARED across tracks — a track late in the file gets
only what earlier tracks left behind. Build two tracks that both LIE
about their sample count (stsz fixed-size, count = u32::MAX, as in
audio_trak_hostile_count): the file is tiny, so the first track's
clamped count exactly exhausts the whole initial budget, and the second
track — clamped against what's left — must come back with ZERO samples.
An +=//= mutant of the decrement either grows the budget or leaves
it nonzero, letting the second track claim samples it must not have.
elst_offset_ticks
Presentation-time offset an edit list imposes on a track's samples, in the track's MEDIA timescale ticks (ISO/IEC 14496-12 §8.6.5-§8.6.6).
Two constructs cover essentially every real edit list, and both reduce to a constant shift of the whole track:
- an EMPTY edit (
media_time == -1) before the media edit, whosesegment_duration— in MOVIE timescale ticks — delays presentation; - a non-empty edit whose
media_timetrims that much media off the front.
So the offset is (sum of leading empty segment_durations) - media_time.
A list with several non-empty edits, or a non-empty edit at a rate other
than 1, describes a timeline this frame model cannot express (it would
need samples dropped, reordered or repeated); the leading edit is still
honoured, and the part that is not is LOGGED rather than passed off as a
faithful copy.
MAX_ALLOC_BYTES
The EOF check alone is not enough: file_len is cheaply inflatable with a
sparse file (truncate -s 8G), so a crafted stsz size or moov box size
just under an 8 GiB apparent length would otherwise force a multi-GiB
allocation. No real sample or moov approaches this.