Stop the mp4 demuxer dropping tracks silently or inventing sample offsets
Three defects in the mp4:// read path, all of the same family: a damaged source was remuxed minus a track, or with fabricated data, and the run reported success. Silent drops. Eight paths dropped a whole track on malformed input with no report of any kind, so an mp4:// source missing its audio looked like a clean run. Each now emits a tracing::warn! naming the track and the missing or inconsistent table (tracing English is permitted in this crate; the numeric error codes are unchanged). The non-A/V handler case is debug!, since skipping a timecode or hint track is normal. Fabricated offsets. sample_offsets ended with a `while offsets.len() < sizes.len()` loop that packed unplaced samples after the last known offset. Those samples have no known location, so the invented offsets made the reader pull frame data from arbitrary file bytes — the exact "emit garbage" outcome the stco/stsc presence guards refuse. It now returns the short list and the caller drops the track. Short stts. `durations.get(i).unwrap_or(0)` gave every sample past the end of a short stts a duration of 0, collapsing the whole tail onto one timestamp. That is the same degenerate timing the `durations.is_empty()` guard was written to refuse, so the guard now refuses both cases. Two shared test fixtures were internally inconsistent and only passed because the reader was lenient: stsz declared 3 samples while stsc placed 1, and the hostile-stsz fixture's stsc/stts covered a single sample. Both are now consistent. The hostile fixture keeps its lying stsz count — that lie is what it tests — but its stsc and stts now cover whatever count survives the file_len bound, so it exercises the allocation bound rather than the inconsistency guards. Two new tests pin the new refusals by mutating the consistent fixture: an stsc that places 1 of 3 samples, and an stts that covers 1 of 3.
This commit is contained in:
+137
-15
@@ -108,6 +108,7 @@ impl<R: Read + Seek> Mp4Reader<R> {
|
|||||||
break; // bound track count so the per-track PID can't overflow u16
|
break; // bound track count so the per-track PID can't overflow u16
|
||||||
}
|
}
|
||||||
let Some(mdia) = find_box(trak, b"mdia") else {
|
let Some(mdia) = find_box(trak, b"mdia") else {
|
||||||
|
tracing::warn!(track = track_idx, "mp4: trak has no mdia, dropping track");
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let timescale = find_box(mdia, b"mdhd")
|
let timescale = find_box(mdia, b"mdhd")
|
||||||
@@ -117,12 +118,15 @@ impl<R: Read + Seek> Mp4Reader<R> {
|
|||||||
let language = find_box(mdia, b"mdhd").and_then(mdhd_language);
|
let language = find_box(mdia, b"mdhd").and_then(mdhd_language);
|
||||||
let handler = find_box(mdia, b"hdlr").and_then(hdlr_type);
|
let handler = find_box(mdia, b"hdlr").and_then(hdlr_type);
|
||||||
let Some(minf) = find_box(mdia, b"minf") else {
|
let Some(minf) = find_box(mdia, b"minf") else {
|
||||||
|
tracing::warn!(track = track_idx, "mp4: mdia has no minf, dropping track");
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(stbl) = find_box(minf, b"stbl") else {
|
let Some(stbl) = find_box(minf, b"stbl") else {
|
||||||
|
tracing::warn!(track = track_idx, "mp4: minf has no stbl, dropping track");
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(stsd) = find_box(stbl, b"stsd") else {
|
let Some(stsd) = find_box(stbl, b"stsd") else {
|
||||||
|
tracing::warn!(track = track_idx, "mp4: stbl has no stsd, dropping track");
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -133,7 +137,11 @@ impl<R: Read + Seek> Mp4Reader<R> {
|
|||||||
channels,
|
channels,
|
||||||
}) = parse_stsd(stsd)
|
}) = parse_stsd(stsd)
|
||||||
else {
|
else {
|
||||||
continue; // unrecognised sample entry — skip the track
|
tracing::warn!(
|
||||||
|
track = track_idx,
|
||||||
|
"mp4: unrecognised stsd sample entry, dropping track"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build the stream model for this track.
|
// Build the stream model for this track.
|
||||||
@@ -162,7 +170,13 @@ impl<R: Read + Seek> Mp4Reader<R> {
|
|||||||
purpose: LabelPurpose::Normal,
|
purpose: LabelPurpose::Normal,
|
||||||
label: String::new(),
|
label: String::new(),
|
||||||
}),
|
}),
|
||||||
_ => continue, // non-A/V handler
|
_ => {
|
||||||
|
tracing::debug!(
|
||||||
|
track = track_idx,
|
||||||
|
"mp4: non-audio/video handler, skipping track"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Per-sample tables. `stsz` is bounded by the remaining global budget;
|
// Per-sample tables. `stsz` is bounded by the remaining global budget;
|
||||||
@@ -184,6 +198,11 @@ impl<R: Read + Seek> Mp4Reader<R> {
|
|||||||
.or_else(|| find_box(stbl, b"co64").map(|b| parse_stco(b, true)))
|
.or_else(|| find_box(stbl, b"co64").map(|b| parse_stco(b, true)))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if chunk_offsets.is_empty() {
|
if chunk_offsets.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
track = track_idx,
|
||||||
|
samples = n,
|
||||||
|
"mp4: stbl has samples but no stco/co64 chunk-offset table, dropping track"
|
||||||
|
);
|
||||||
// Samples exist but there is no chunk-offset table: the stbl is
|
// Samples exist but there is no chunk-offset table: the stbl is
|
||||||
// malformed and every sample offset would resolve to file byte 0
|
// malformed and every sample offset would resolve to file byte 0
|
||||||
// (muxing header bytes as frame data). Drop the track rather than
|
// (muxing header bytes as frame data). Drop the track rather than
|
||||||
@@ -192,16 +211,43 @@ impl<R: Read + Seek> Mp4Reader<R> {
|
|||||||
}
|
}
|
||||||
let stsc = find_box(stbl, b"stsc").map(parse_stsc).unwrap_or_default();
|
let stsc = find_box(stbl, b"stsc").map(parse_stsc).unwrap_or_default();
|
||||||
if stsc.is_empty() {
|
if stsc.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
track = track_idx,
|
||||||
|
samples = n,
|
||||||
|
"mp4: stbl has samples but no stsc sample-to-chunk map, dropping track"
|
||||||
|
);
|
||||||
// No sample-to-chunk map: samples can't be placed against the chunk
|
// No sample-to-chunk map: samples can't be placed against the chunk
|
||||||
// offsets (they would pack from byte 0). Drop the track rather than
|
// offsets (they would pack from byte 0). Drop the track rather than
|
||||||
// emit header bytes as frame data — a valid stbl always has stsc.
|
// emit header bytes as frame data — a valid stbl always has stsc.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let offsets = sample_offsets(&sizes, &chunk_offsets, &stsc);
|
let offsets = sample_offsets(&sizes, &chunk_offsets, &stsc);
|
||||||
|
if offsets.len() < sizes.len() {
|
||||||
|
// The stsc passed the non-empty guard but does not place every
|
||||||
|
// sample. The unplaced tail has no real offset, so carrying the
|
||||||
|
// track would read frames from arbitrary file bytes.
|
||||||
|
tracing::warn!(
|
||||||
|
track = track_idx,
|
||||||
|
placed = offsets.len(),
|
||||||
|
samples = n,
|
||||||
|
"mp4: stsc places fewer samples than stsz declares, dropping track"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let durations = find_box(stbl, b"stts")
|
let durations = find_box(stbl, b"stts")
|
||||||
.map(|b| parse_stts(b, n))
|
.map(|b| parse_stts(b, n))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if durations.is_empty() {
|
if durations.len() < n {
|
||||||
|
// Absent OR short: `stts` is mandatory and must cover every sample
|
||||||
|
// (ISO/IEC 14496-12 §8.6.1). A short table gave every unmapped tail
|
||||||
|
// sample dur=0, collapsing the whole tail onto one instant — the
|
||||||
|
// same degenerate timing the absent case refuses, so refuse both.
|
||||||
|
tracing::warn!(
|
||||||
|
track = track_idx,
|
||||||
|
durations = durations.len(),
|
||||||
|
samples = n,
|
||||||
|
"mp4: stts does not cover every sample, dropping track"
|
||||||
|
);
|
||||||
// Samples exist but there is no decoding-time table: `stts` is
|
// Samples exist but there is no decoding-time table: `stts` is
|
||||||
// mandatory in a valid stbl (ISO/IEC 14496-12 §8.6.1). Without it
|
// mandatory in a valid stbl (ISO/IEC 14496-12 §8.6.1). Without it
|
||||||
// every sample would take dur=0 → all-zero, identical timestamps,
|
// every sample would take dur=0 → all-zero, identical timestamps,
|
||||||
@@ -682,15 +728,11 @@ fn sample_offsets(sizes: &[u32], chunk_offsets: &[u64], stsc: &[(u32, u32)]) ->
|
|||||||
sidx += 1;
|
sidx += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Any trailing samples with no chunk mapping: pack after the last offset.
|
// Samples the stsc did not place have NO known location. Fabricating one by
|
||||||
while offsets.len() < sizes.len() {
|
// packing after the last offset invents a position, and the frame is then read
|
||||||
let last = offsets.last().copied().unwrap_or(0);
|
// from arbitrary file bytes — exactly the "emit garbage" outcome the stco/stsc
|
||||||
let last_sz = sizes
|
// guards above refuse. Report the shortfall instead and let the caller drop
|
||||||
.get(offsets.len().saturating_sub(1))
|
// the track.
|
||||||
.copied()
|
|
||||||
.unwrap_or(0);
|
|
||||||
offsets.push(last.saturating_add(last_sz as u64));
|
|
||||||
}
|
|
||||||
offsets
|
offsets
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1334,7 +1376,11 @@ mod tests {
|
|||||||
p.extend_from_slice(&[0, 0, 0, 0]);
|
p.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
p.extend_from_slice(&1u32.to_be_bytes()); // count
|
p.extend_from_slice(&1u32.to_be_bytes()); // count
|
||||||
p.extend_from_slice(&1u32.to_be_bytes()); // first_chunk
|
p.extend_from_slice(&1u32.to_be_bytes()); // first_chunk
|
||||||
p.extend_from_slice(&1u32.to_be_bytes()); // samples_per_chunk
|
// Deliberately large: the stsz COUNT is the lie under test, so the
|
||||||
|
// stsc and stts must still cover whatever count survives the file_len
|
||||||
|
// bound. Both are clamped to the real sample count when expanded, so
|
||||||
|
// this places every bounded sample without itself being degenerate.
|
||||||
|
p.extend_from_slice(&0xFFFFu32.to_be_bytes()); // samples_per_chunk
|
||||||
p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx
|
p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx
|
||||||
mp4_box(b"stsc", &p)
|
mp4_box(b"stsc", &p)
|
||||||
};
|
};
|
||||||
@@ -1342,7 +1388,7 @@ mod tests {
|
|||||||
let mut p = Vec::new();
|
let mut p = Vec::new();
|
||||||
p.extend_from_slice(&[0, 0, 0, 0]); // version+flags
|
p.extend_from_slice(&[0, 0, 0, 0]); // version+flags
|
||||||
p.extend_from_slice(&1u32.to_be_bytes()); // entry_count
|
p.extend_from_slice(&1u32.to_be_bytes()); // entry_count
|
||||||
p.extend_from_slice(&1u32.to_be_bytes()); // sample_count
|
p.extend_from_slice(&0xFFFFu32.to_be_bytes()); // sample_count (see stsc)
|
||||||
p.extend_from_slice(&1000u32.to_be_bytes()); // sample_delta
|
p.extend_from_slice(&1000u32.to_be_bytes()); // sample_delta
|
||||||
mp4_box(b"stts", &p)
|
mp4_box(b"stts", &p)
|
||||||
};
|
};
|
||||||
@@ -1421,7 +1467,11 @@ mod tests {
|
|||||||
p.extend_from_slice(&[0, 0, 0, 0]);
|
p.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
p.extend_from_slice(&1u32.to_be_bytes()); // count
|
p.extend_from_slice(&1u32.to_be_bytes()); // count
|
||||||
p.extend_from_slice(&1u32.to_be_bytes()); // first_chunk
|
p.extend_from_slice(&1u32.to_be_bytes()); // first_chunk
|
||||||
p.extend_from_slice(&1u32.to_be_bytes()); // samples_per_chunk
|
// All 3 samples live in the single chunk. This MUST match the stsz
|
||||||
|
// count: an stsc placing fewer samples than stsz declares is a
|
||||||
|
// malformed stbl, and the reader now drops such a track rather than
|
||||||
|
// fabricating offsets for the unplaced tail.
|
||||||
|
p.extend_from_slice(&3u32.to_be_bytes()); // samples_per_chunk
|
||||||
p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx
|
p.extend_from_slice(&0u32.to_be_bytes()); // sample_desc_idx
|
||||||
mp4_box(b"stsc", &p)
|
mp4_box(b"stsc", &p)
|
||||||
};
|
};
|
||||||
@@ -1503,6 +1553,78 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
#[test]
|
||||||
|
fn stsc_placing_fewer_samples_than_stsz_drops_the_track() {
|
||||||
|
use std::io::Cursor;
|
||||||
|
// audio_trak_missing(b"____") is the complete, consistent fixture: stsz=3
|
||||||
|
// and stsc samples_per_chunk=3. Knock samples_per_chunk down to 1 so the
|
||||||
|
// stsc is present and non-empty yet places only sample 1 of 3.
|
||||||
|
let mut trak = audio_trak_missing(b"____");
|
||||||
|
let stsc_tag = b"stsc";
|
||||||
|
let pos = trak
|
||||||
|
.windows(4)
|
||||||
|
.position(|w| w == stsc_tag)
|
||||||
|
.expect("fixture has an stsc");
|
||||||
|
// stsc payload: version+flags(4) entry_count(4) first_chunk(4)
|
||||||
|
// samples_per_chunk(4) — so samples_per_chunk starts 16 bytes past the tag.
|
||||||
|
let spc = pos + 4 + 12;
|
||||||
|
assert_eq!(
|
||||||
|
&trak[spc..spc + 4],
|
||||||
|
&3u32.to_be_bytes(),
|
||||||
|
"expected the consistent fixture's samples_per_chunk = 3"
|
||||||
|
);
|
||||||
|
trak[spc..spc + 4].copy_from_slice(&1u32.to_be_bytes());
|
||||||
|
|
||||||
|
let moov = mp4_box(b"moov", &trak);
|
||||||
|
let rd = Mp4Reader::from_reader(Cursor::new(moov), "short-stsc".into());
|
||||||
|
assert!(
|
||||||
|
rd.is_err(),
|
||||||
|
"an stsc that places 1 of 3 samples must drop the track; \
|
||||||
|
all-dropped → Mp4Invalid"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
#[test]
|
||||||
|
fn short_stts_drops_the_track_like_an_absent_one() {
|
||||||
|
use std::io::Cursor;
|
||||||
|
let mut trak = audio_trak_missing(b"____");
|
||||||
|
let pos = trak
|
||||||
|
.windows(4)
|
||||||
|
.position(|w| w == b"stts")
|
||||||
|
.expect("fixture has an stts");
|
||||||
|
// stts payload: version+flags(4) entry_count(4) sample_count(4) delta(4)
|
||||||
|
let sample_count = pos + 4 + 8;
|
||||||
|
assert_eq!(
|
||||||
|
&trak[sample_count..sample_count + 4],
|
||||||
|
&3u32.to_be_bytes(),
|
||||||
|
"expected the consistent fixture's stts sample_count = 3"
|
||||||
|
);
|
||||||
|
trak[sample_count..sample_count + 4].copy_from_slice(&1u32.to_be_bytes());
|
||||||
|
|
||||||
|
let moov = mp4_box(b"moov", &trak);
|
||||||
|
let rd = Mp4Reader::from_reader(Cursor::new(moov), "short-stts".into());
|
||||||
|
assert!(
|
||||||
|
rd.is_err(),
|
||||||
|
"an stts covering 1 of 3 samples must drop the track; \
|
||||||
|
all-dropped → Mp4Invalid"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Sanity companion: the SAME builder WITH both tables present yields a valid,
|
/// Sanity companion: the SAME builder WITH both tables present yields a valid,
|
||||||
/// indexed single-track file — proving the two Err results above come from the
|
/// indexed single-track file — proving the two Err results above come from the
|
||||||
/// missing table, not from some unrelated defect in the fixture builder.
|
/// missing table, not from some unrelated defect in the fixture builder.
|
||||||
|
|||||||
Reference in New Issue
Block a user