From 32b2fd8adf4bcf1bdf3e4f6728af6f3230c03ee4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 14:10:50 -0500 Subject: [PATCH] media: cap a Matroska track's content encoding chain (#541) A track could list any number of ContentEncoding entries, and readers walk the whole list for every frame they return (frames_readable, stripped_header). A track with more than 16 entries, far beyond the one or two a muxer writes, is now treated as having an unsupported compression, so its frames are returned as stored and the list is never long. --- crates/media/src/matroska/meta.rs | 20 ++++++++++++++++++++ crates/media/src/matroska/track.rs | 1 + 2 files changed, 21 insertions(+) diff --git a/crates/media/src/matroska/meta.rs b/crates/media/src/matroska/meta.rs index d0356a6b..2d19ea0b 100644 --- a/crates/media/src/matroska/meta.rs +++ b/crates/media/src/matroska/meta.rs @@ -28,6 +28,11 @@ const MAX_META_DEPTH: usize = 64; /// produce. Real files strip a few bytes of codec header; a longer one is treated as an /// unsupported compression and its frames are not reconstructed. const MAX_STRIPPED_HEADER: usize = 256; +/// Most `ContentEncoding` entries a track may list. A muxer writes one or two (compression, +/// encryption); readers check the whole chain for every frame they return, so a longer chain +/// would multiply that work by the frame count. A track with more is treated as having an +/// unsupported compression, and its frames are returned as stored. +const MAX_CONTENT_ENCODINGS: usize = 16; /// Largest accepted `TimestampScale` (about 4.3 s per tick). It keeps tick to nanosecond /// arithmetic on 64-bit timestamps within `i128`. const MAX_TIMESTAMP_SCALE: u64 = 1 << 32; @@ -459,6 +464,9 @@ fn parse_audio(d: &[u8]) -> AudioInfo { } fn parse_encodings(d: &[u8]) -> Vec { + if Children::new(d).filter(|e| e.id == CONTENT_ENCODING).nth(MAX_CONTENT_ENCODINGS).is_some() { + return vec![ContentEncoding::Compression { algo: u64::MAX, settings: Vec::new(), scope: 1 }]; + } let mut list: Vec<(u64, ContentEncoding)> = Vec::new(); for ce in Children::new(d).filter(|e| e.id == CONTENT_ENCODING) { let (mut order, mut scope, mut ty) = (0u64, 1u64, 0u64); @@ -628,6 +636,18 @@ mod metadata_depth_tests { parsed.remove(0) } + #[test] + fn a_long_encoding_chain_is_not_rechecked_for_every_frame() { + let strip: (&[u8], u64) = (b"", 2); + let most = track_with_private(b"private", &[strip; MAX_CONTENT_ENCODINGS]); + assert_eq!(most.encodings.len(), MAX_CONTENT_ENCODINGS); + assert!(most.frames_readable()); + let many = track_with_private(b"private", &vec![strip; 10_000]); + assert!(matches!(&many.encodings[..], [ContentEncoding::Compression { scope: 1, .. }]), "{:?}", many.encodings.len()); + assert!(!many.frames_readable()); + assert_eq!(many.codec_private, b"private"); + } + #[test] fn codec_private_stripped_headers_are_restored_in_reverse_order() { let strips: [(&[u8], u64); 4] = [(b"first", 2), (b"", 2), (b"second", 2), (b"frame", 1)]; diff --git a/crates/media/src/matroska/track.rs b/crates/media/src/matroska/track.rs index 4f1db134..edb8fc87 100644 --- a/crates/media/src/matroska/track.rs +++ b/crates/media/src/matroska/track.rs @@ -169,6 +169,7 @@ pub enum ContentEncoding { /// `ContentCompAlgo` 3: the given bytes were stripped from the start of every frame. HeaderStripping { bytes: Vec, scope: u64 }, /// Other compression (0 zlib, 1 bzlib, 2 lzo1x): not supported for reading frame data. + /// `algo` is `u64::MAX` for a chain of more encodings than a reader checks. Compression { algo: u64, settings: Vec, scope: u64 }, /// Encryption (not supported for reading frame data). Encryption { scope: u64 },