fix(libfreemkv): rc6 hardening pass — mux timeline/colour/PCR, demux panic sentinel, parser robustness + doc accuracy

Surgical fixes (each with a regression test that fails without the change):

mux/mkv.rs, mux/demux_sink.rs: drive the clip-boundary timeline epoch
off the resolved PRIMARY VIDEO track, not the literal stream index 0.
An M2TS/PMT title can list an audio ES before video, so streams[0] may
be audio; a non-video epoch driver ratchets the frontier and inflates
the timeline. mkv cluster-opening falls back to track 0 for audio-only
titles so they still open clusters.

mux/codec/ac3.rs: correct ACMOD_CHANNELS — acmod=5 (3/1) is 4 channels,
not 3 (was undercounting a 3/1 stream); fix the A/52 Table 5.8 doc.

disc/mod.rs: HDMV coding_type 0x91 (Interactive Graphics / menus) no
longer maps to PGS subtitle — it falls through to Unknown so the PMT/STN
walker drops it instead of surfacing a bogus subtitle track.

mux/videomap.rs + mux/mkv.rs: FVI colour now mirrors the MKV muxer's CICP
precedence (measured CICP authoritative; HDR-driven PQ/HLG transfer
override) via a shared cicp_for_video helper, so the two sinks can't
disagree (HDR10 BT.2020 no longer emits SDR transfer 14).

mux/mkvstream.rs: saturating_add on cluster_ts + rel_ts so an adversarial
CLUSTER_TIMESTAMP near i64::MAX can't overflow/panic before the existing
saturating_mul.

mux/timeline.rs: tighten the tail-straggler clamp so a normal new-epoch
non-video frame leading the sparse video frontier by >3s is not demoted
into the previous clip's epoch.

mux/m2ts_mux/mod.rs: re-stamp PCR per video TS packet (mid-PES), not only
at PES boundaries, so a large UHD I-frame can't open a multi-second PCR
gap; modular 33-bit PTS rebasing so a real 90 kHz clock wrap is not
collapsed to PTS 0 (pre-base frames still floor to 0).

io/byte_prefetcher.rs, sector/prefetched.rs: wrap the producer feed loop
in catch_unwind and emit a typed error sentinel on panic, so a mid-stream
producer panic is not read as a clean EOF at the demux boundary (which
would silently truncate the mux).

mux/codec/h264.rs: extend HIGH_PROFILES to the full ISO/IEC 14496-15 set
that mandates the avcC chroma/bit-depth extension (adds 244 et al.).

Doc/comment accuracy: css/mod.rs (50000 sectors, not scrambled-sectors),
aacs/decrypt.rs (decrypt_unit already-clear path), ifo.rs (TT_SRPT at
0xC4), css/lfsr.rs (LFSR0 24-bit; TAB1-then-XOR cipher; real scramble-flag
predicate), disc/read_error.rs (for_sweep does bounded transient retries).

Skipped: keydb.rs SSRF guard (low/latent, no live caller) — a hard
loopback block breaks an existing behavioral test that exercises the
header-EOF path over a loopback server; a clean fix needs a resolver test
seam beyond this surgical pass. The sibling keydb_fetch.rs comment fix is
out of scope (freemkv crate).
This commit is contained in:
Matthew Jackson
2026-06-25 23:39:03 -07:00
parent dc1d05985b
commit 05729f5dfe
16 changed files with 876 additions and 328 deletions
+145 -56
View File
@@ -60,6 +60,63 @@ const COLOUR_RANGE_LIMITED: u8 = 1;
/// Vision configuration record (RFC 9559 + Dolby Vision-in-Matroska spec).
const BLOCK_ADD_ID_TYPE_DVCC: u64 = 0x6476_6343;
/// Resolve a video stream's CICP colour code points — `(matrix, transfer,
/// primaries, range)`, ITU-T H.273 — using a single precedence so EVERY sink
/// (the MKV muxer here AND the FVI sidecar in `videomap.rs`) agrees and can
/// never drift:
///
/// 1. **Measured CICP** read from the bitstream (HEVC/H.264 VUI
/// `colour_description` or MPEG-2 `sequence_display_extension`) is
/// AUTHORITATIVE — copied through verbatim when present.
/// 2. Otherwise fall back to the coarse `color_space` enum (a playlist nibble /
/// PAL-NTSC guess), THEN apply the HDR-driven transfer override: BT.2020 only
/// appears on HDR UHD, where the real transfer is PQ (16) for
/// HDR10/HDR10+/DV or HLG (18) for HLG — never the SDR transfer 14 the enum
/// alone would emit.
pub(crate) fn cicp_for_video(v: &VideoStream) -> (u8, u8, u8, u8) {
if let Some(c) = v.measured_cicp {
return (c.matrix, c.transfer, c.primaries, c.range);
}
let (m, t, p, r) = match v.color_space {
ColorSpace::Bt2020 => (
CICP_MATRIX_BT2020NC,
CICP_TRANSFER_PQ,
CICP_PRIMARIES_BT2020,
COLOUR_RANGE_LIMITED,
),
ColorSpace::Bt709 => (
CICP_MATRIX_BT709,
CICP_TRANSFER_BT709,
CICP_PRIMARIES_BT709,
COLOUR_RANGE_LIMITED,
),
// PAL SD: BT.470 System B/G matrix/transfer/primaries.
ColorSpace::Bt470bg => (
CICP_MATRIX_BT470BG,
CICP_TRANSFER_BT470BG,
CICP_PRIMARIES_BT470BG,
COLOUR_RANGE_LIMITED,
),
// NTSC SD: SMPTE 170M / BT.601-525.
ColorSpace::Smpte170m => (
CICP_MATRIX_BT601_525,
CICP_TRANSFER_BT601_525,
CICP_PRIMARIES_BT601_525,
COLOUR_RANGE_LIMITED,
),
ColorSpace::Unknown => (0, 0, 0, 0),
};
// Override the transfer for HDR signalled by the HdrFormat (the coarse enum
// can't express PQ/HLG). Only applies on the enum fallback; a measured CICP
// already carries the real transfer and returned above.
let t = match v.hdr {
HdrFormat::Hdr10 | HdrFormat::Hdr10Plus | HdrFormat::DolbyVision => CICP_TRANSFER_PQ,
HdrFormat::Hlg => CICP_TRANSFER_HLG,
_ => t,
};
(m, t, p, r)
}
/// MKV track definition (built from disc stream metadata).
pub struct MkvTrack {
pub track_type: u64, // 1=video, 2=audio, 17=subtitle
@@ -225,58 +282,9 @@ impl MkvTrack {
0
};
// CICP (matrix, transfer, primaries, range) — ITU-T H.273 code points.
//
// Prefer MEASURED CICP read from the bitstream (HEVC/H.264 VUI
// colour_description or MPEG-2 sequence_display_extension) when the
// stream states it: those are authoritative. Fall back to the coarse
// `color_space` enum (a playlist nibble / PAL-NTSC guess) only when no
// measured triplet is present, so the container stops ASSUMING a colour
// space the bitstream may contradict.
let (matrix, transfer, primaries, range) = match v.measured_cicp {
Some(c) => (c.matrix, c.transfer, c.primaries, c.range),
None => {
let (m, t, p, r) = match v.color_space {
ColorSpace::Bt2020 => (
CICP_MATRIX_BT2020NC,
CICP_TRANSFER_PQ,
CICP_PRIMARIES_BT2020,
COLOUR_RANGE_LIMITED,
),
ColorSpace::Bt709 => (
CICP_MATRIX_BT709,
CICP_TRANSFER_BT709,
CICP_PRIMARIES_BT709,
COLOUR_RANGE_LIMITED,
),
// PAL SD: BT.470 System B/G matrix/transfer/primaries.
ColorSpace::Bt470bg => (
CICP_MATRIX_BT470BG,
CICP_TRANSFER_BT470BG,
CICP_PRIMARIES_BT470BG,
COLOUR_RANGE_LIMITED,
),
// NTSC SD: SMPTE 170M / BT.601-525.
ColorSpace::Smpte170m => (
CICP_MATRIX_BT601_525,
CICP_TRANSFER_BT601_525,
CICP_PRIMARIES_BT601_525,
COLOUR_RANGE_LIMITED,
),
ColorSpace::Unknown => (0, 0, 0, 0),
};
// Override the transfer for non-PQ HDR signalled by the HdrFormat
// (the enum can't express HLG). Only applies on the enum
// fallback; a measured CICP already carries the real transfer.
let t = match v.hdr {
HdrFormat::Hdr10 | HdrFormat::Hdr10Plus | HdrFormat::DolbyVision => {
CICP_TRANSFER_PQ
}
HdrFormat::Hlg => CICP_TRANSFER_HLG,
_ => t,
};
(m, t, p, r)
}
};
// Derived by the single shared resolver so every sink (this muxer, the
// FVI sidecar in `videomap.rs`) reports identical code points.
let (matrix, transfer, primaries, range) = cicp_for_video(v);
// Display dimensions. For square-pixel video (HD/UHD/BD) the display
// aspect equals the pixel grid, so display == pixel. For anamorphic
// content (DVD: 720x480/576 pixels shown as 16:9 or 4:3) the coded
@@ -498,6 +506,13 @@ pub struct MkvMuxer<W: Write + Seek> {
/// non-monotonic. Keying the exemption on track type (not index) keeps that
/// EL's true PTS instead of clobbering it to prev+1ms.
track_is_video: Vec<bool>,
/// Index of the PRIMARY video track — the first track whose type is video.
/// This (not the literal index 0) is the clip-boundary epoch driver: the
/// M2TS/PMT path orders streams by PMT declaration order and may list an
/// audio ES before the video ES, so `streams[0]` is not guaranteed to be
/// the primary video. `None` when the title has no video track (no track
/// drives epochs).
primary_video_track: Option<usize>,
/// Cross-clip timeline-continuity corrector (clip-boundary PTS rebasing).
continuity: TimelineContinuity,
cues: Vec<CuePoint>,
@@ -961,6 +976,9 @@ impl<W: Write + Seek> MkvMuxer<W> {
.iter()
.map(|t| t.track_type == ebml::TRACK_TYPE_VIDEO)
.collect(),
primary_video_track: tracks
.iter()
.position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO),
continuity: TimelineContinuity::new(),
cues: Vec::new(),
frame_count: 0,
@@ -1026,7 +1044,9 @@ impl<W: Write + Seek> MkvMuxer<W> {
let is_video = self.track_is_video.get(track_idx).copied().unwrap_or(false);
// The clip-boundary epoch decision is driven by the PRIMARY video track
// ONLY (track 0). A title can carry a SECOND video track — a Dolby Vision
// ONLY (the first video track, NOT the literal index 0 — the M2TS/PMT
// path can list an audio ES before the video ES, so streams[0] may be
// audio). A title can carry a SECOND video track — a Dolby Vision
// enhancement layer — whose PTS runs on its OWN timeline, interleaved
// with the base layer's. The two video PTS sequences overlap, so the EL's
// frames look like multi-second backward jumps against the base layer's
@@ -1034,7 +1054,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
// ratchet that inflated Top Gun's 1-clip timeline to ~7 h). Only the base
// video layer establishes/advances the frontier and opens epochs; the EL
// — like audio and subtitles — rides the current offset.
let drives_epoch = track_idx == 0;
let drives_epoch = Some(track_idx) == self.primary_video_track;
// Map the raw PES PTS onto the continuous output timeline FIRST, before
// any base/cluster math: at a non-seamless clip / layer-break boundary
@@ -1047,8 +1067,13 @@ impl<W: Write + Seek> MkvMuxer<W> {
let raw_ticks = pts_ns / TIMESTAMP_SCALE_NS;
// Cluster boundaries normally coincide with a video keyframe so every
// Cues entry resolves to a seekable IDR at the cluster start.
let is_video_key = keyframe && track_idx == 0;
// Cues entry resolves to a seekable IDR at the cluster start. Keyed on
// the primary video track (first video index) when the title HAS video;
// for an audio-only / subtitle-only title (no video track) fall back to
// the first track (index 0) so its keyframes still open clusters —
// otherwise no cluster would ever open and every frame would be dropped.
let cluster_driver = self.primary_video_track.unwrap_or(0);
let is_video_key = keyframe && track_idx == cluster_driver;
// Derive the timestamp base from the first *kept* keyframe (the frame
// that opens the first cluster), NOT the first frame merely seen. The
@@ -1905,6 +1930,70 @@ mod tests {
);
}
/// Regression for the hardcoded-`track 0` epoch driver: on an M2TS/PMT
/// title the PMT may list an AUDIO ES before the VIDEO ES, so `streams[0]`
/// is audio and the video is at index 1. The epoch driver must follow the
/// PRIMARY VIDEO track (first video index), not the literal 0. If audio
/// (index 0) drove epochs, its sparse/lagging PTS would ratchet the frontier
/// and false-trigger boundary resets, inflating the timeline. This drives
/// the muxer with audio=track0 / video=track1 and asserts cluster timestamps
/// stay monotonic and the timeline does NOT ratchet past the real span.
#[test]
fn epoch_driver_follows_primary_video_not_index_zero() {
// Audio FIRST (index 0), video SECOND (index 1) — the M2TS/PMT ordering.
let tracks = [make_audio_track(), make_video_track()];
// The muxer must pick track 1 (first video) as the epoch driver.
{
let buf = Cursor::new(Vec::new());
let mux = MkvMuxer::new(buf, &tracks, None, 0.0, &[]).unwrap();
assert_eq!(
mux.primary_video_track,
Some(1),
"primary video must be the first VIDEO track (index 1), not 0"
);
}
let ms = |m: i64| m * 1_000_000;
// Track 0 = AUDIO, track 1 = VIDEO. Same clip-boundary + straggler shape
// as clip_boundary_with_straggler_yields_monotonic_clusters, but with the
// video at index 1.
let frames: Vec<(usize, i64, bool, Vec<u8>)> = vec![
(1, ms(0), true, vec![0x01; 16]), // video kf 0s
(0, ms(0), true, vec![0xA0; 8]), // audio 0s
(1, ms(600_000), true, vec![0x02; 16]), // video kf 600s
(0, ms(600_000), true, vec![0xA1; 8]), // audio 600s
// Clip 2: video keyframe RESETS to 0 (the -600s boundary).
(1, ms(0), true, vec![0x03; 16]),
// Straggler: clip 1's tail audio arrives interleaved after the reset.
(0, ms(599_500), true, vec![0xA2; 8]),
(0, ms(0), true, vec![0xA3; 8]), // clip2 audio at 0
(1, ms(5_000), true, vec![0x04; 16]), // clip2 + 5s video kf
];
let (data, frame_count) = mux_to_bytes(&tracks, &[], &frames);
assert_eq!(frame_count, 8, "all frames written (none dropped)");
let tick = |ms: i64| ms * 1_000_000 / TIMESTAMP_SCALE_NS;
let clusters = find_clusters(&data);
let ts: Vec<u64> = clusters.iter().map(|&(_, _, t)| t).collect();
assert!(!ts.is_empty(), "expected clusters");
assert!(
ts.windows(2).all(|w| w[1] >= w[0]),
"cluster timestamps must be monotonic, got {ts:?}"
);
let max = *ts.iter().max().unwrap() as i64;
// Timeline reaches past the boundary (clip 2 present): ≥ ~600s.
assert!(
max >= tick(600_000),
"timeline must span past the boundary, got {max} ticks"
);
// Must NOT ratchet far beyond clip1+clip2 (~605s). With the bug (audio
// index 0 driving epochs) the lagging-audio straggler ratchets the
// frontier and inflates the timeline well past this bound.
assert!(
max < tick(1_000_000),
"no ratchet: max cluster ts {max} ticks must stay near 605s"
);
}
#[test]
fn mkv_multiple_tracks() {
let buf = Cursor::new(Vec::new());