Parse MKV lacing, route by real TrackNumber, honour NAL length size and edit lists

Four conformance defects in the read paths, two of them silent corruption.

**Lacing was ignored entirely.** RFC 9559 §10.2 defines Xiph, EBML and
fixed-size lacing, where one Block carries several frames; the reader took the
Block payload verbatim, so a laced Block became a single "frame" consisting of a
lacing header followed by concatenated frames — garbage to the codec parser, no
error. Audio tracks from other muxers commonly use lacing, so an ordinary
foreign MKV was silently mangled.

All three modes are now parsed: Xiph 255-run sizes including the trailing-zero
rule for exact multiples of 255, EBML unsigned first size plus SIGNED VINT deltas
with the 2^((7*n)-1)-1 bias of §10.3.3, and fixed-size even division, with the
last frame's size deduced from the remainder. Laced timestamps follow §10.3.5:
the first frame takes the Block timestamp and the rest are spaced by the track's
DefaultDuration, else BlockDuration/count, else shared with a warn.

Parsing was chosen over refusing because refusal would leave freemkv unable to
remux common foreign audio at all, and each mode is about fifteen lines.

A malformed lacing header now raises a NEW code, E_MKV_LACING_INVALID = 9052,
deliberately NOT MkvInvalid — because is_skippable_title_stub classifies
MkvInvalid as a skippable nav stub, so reusing it would have recreated the exact
conflation that is still open as a separate finding. A test asserts the new code
is not skippable.

**TrackNumber was assumed to be 1..N in TrackEntry order.** RFC 9559 §5.1.4.1.1
only requires it to be non-zero and unique, so sparse or unordered numbers are
legal. Block routing and codec_private both computed track + 1. A real
TrackNumber map is now built, recorded only for TrackEntries that yield a stream
so dropped track types no longer shift the mapping.

Verified red here independently, and the failure mode is worse than mis-routing:
with track + 1 restored, a buttons track's payload was attributed to the AUDIO
stream — wrong payload into the wrong codec parser.

**The NAL length prefix was hardcoded to 4 bytes.** lengthSizeMinusOne lives in
avcC byte 4 and hvcC byte 21 (ISO/IEC 14496-15 §5.3.3.1.2, §8.3.3.1.2) and was
never read, so a source declaring 1- or 2-byte prefixes had its raw prefixed
bytes emitted verbatim with no start codes. All four conversion sites now derive
the width from the track's own configuration record.

**Edit lists were ignored.** No edts/elst was parsed, so the presentation
timeline an edit list defines (ISO/IEC 14496-12 §8.6.5/§8.6.6) was dropped —
which is how encoder delay is normally expressed. Leading empty edits and the
first media edit's media_time are now applied to both dts and pts, with the movie
vs media timescale distinction respected. A list needing more than a constant
shift applies the leading edit and warns rather than presenting the result as
faithful.

17 tests. I reproduced the lacing mutant independently: returning the body whole
kills five of them, including the exact-payload and malformed-header cases.

Still open and deliberately untouched: the MkvInvalid / is_skippable_title_stub
conflation across ~20 reader raise sites. It is a cross-cutting error.rs change
and E_MKV_LACING_INVALID is the template for it.
This commit is contained in:
Matthew Jackson
2026-07-29 21:53:19 -07:00
parent 9527bc1e13
commit 4fcd28b487
7 changed files with 1347 additions and 88 deletions
+19
View File
@@ -156,6 +156,15 @@ pub const E_MUX_EMPTY: u16 = 9023;
/// [`is_skippable_title_stub`] treats as a skippable empty nav/menu stub — a
/// cap-overflow is a real title and must never be silently skipped.
pub const E_MUX_HEADER_BUFFER_EXCEEDED: u16 = 9051;
/// An `mkv://` SOURCE Block declared lacing (RFC 9559 §10.3) whose header does
/// not describe its own payload, so the frame boundaries inside the Block are
/// unknowable. Deliberately NOT [`E_MKV_INVALID`], which
/// [`is_skippable_title_stub`] treats as a skippable empty nav/menu stub: a
/// laced Block belongs to a track with real media in it, and mis-reporting the
/// rejection as a stub would drop that media from a run that then exits
/// successfully — the same conflation [`E_MUX_HEADER_BUFFER_EXCEEDED`] exists to
/// avoid.
pub const E_MKV_LACING_INVALID: u16 = 9052;
pub const E_EXTENT_NOT_UNIT_ALIGNED: u16 = 9030;
/// `mp4://` output but the title has no (primary) video track to carry.
pub const E_MP4_NO_VIDEO_TRACK: u16 = 9048;
@@ -297,6 +306,10 @@ pub enum Error {
},
IfoParse,
MkvInvalid,
/// An `mkv://` source Block's lacing header does not describe its payload —
/// the frames packed into that Block cannot be separated. NOT
/// [`Error::MkvInvalid`]: see [`E_MKV_LACING_INVALID`].
MkvLacingInvalid,
NoStreams,
/// A [`crate::StreamSelection`] listed a PID that does not exist in the
/// title's declared streams — a caller bug (e.g. a stale scan), reported
@@ -604,6 +617,7 @@ impl Error {
Error::DiscTitleRange { .. } => E_DISC_TITLE_RANGE,
Error::IfoParse => E_IFO_PARSE,
Error::MkvInvalid => E_MKV_INVALID,
Error::MkvLacingInvalid => E_MKV_LACING_INVALID,
Error::NoStreams => E_NO_STREAMS,
Error::SelectionPidUnknown { .. } => E_SELECTION_PID_UNKNOWN,
Error::MapfileInvalid { .. } => E_MAPFILE_INVALID,
@@ -895,6 +909,9 @@ impl From<Error> for std::io::Error {
// 9051 MuxHeaderBufferExceeded: the source kept yielding frames but
// never its codec init data — the input is unusable as declared.
E_MUX_HEADER_BUFFER_EXCEEDED => std::io::ErrorKind::InvalidData,
// 9052 MkvLacingInvalid: a source Block's lacing header does not
// describe its own payload — malformed input data.
E_MKV_LACING_INVALID => std::io::ErrorKind::InvalidData,
// mp4:// demux errors: a malformed/truncated source file
// (E_MP4_INVALID), or a source whose tracks the mux can't use — no
// video track / missing codec-private config. All are invalid data.
@@ -1391,6 +1408,7 @@ mod tests {
E_NETWORK_ADDR_BLOCKED,
E_MUX_EMPTY,
E_MUX_HEADER_BUFFER_EXCEEDED,
E_MKV_LACING_INVALID,
E_MP4_NO_VIDEO_TRACK,
E_MP4_INVALID,
E_MP4_MISSING_CODEC_PRIVATE,
@@ -1485,6 +1503,7 @@ mod tests {
Error::MuxHeaderBufferExceeded { bytes: 0 },
E_MUX_HEADER_BUFFER_EXCEEDED,
),
(Error::MkvLacingInvalid, E_MKV_LACING_INVALID),
(Error::Mp4NoVideoTrack, E_MP4_NO_VIDEO_TRACK),
(Error::Mp4Invalid, E_MP4_INVALID),
(Error::Mp4MissingCodecPrivate, E_MP4_MISSING_CODEC_PRIVATE),