rc2: macOS cross-compile fix + security/recovery hardening

- build.rs: pass target -arch to cc so macos_shim cross-compiles (x86_64-apple-darwin)
- AACS/CSS: unit-aligned decrypting sweep; per-VTS CSS title keys (hard-fail on wrong VTS);
  reject truncated Unit_Key_RO; AACS 2.0 sig-verify skip; CSS bus-auth random nonce
- recovery: gap-filling mapfile load; sweep/copy resume reconciliation; stale-mapfile abort;
  patch wedge/damage-window range reset
- mux: TS continuity + PSI CC desync guards; HEVC numTemporalLayers clamp; MPEG-2 pending
  byte-cap; PS parse_pts marker-bit validation; HdrFormat strict parse; Unknown-variant metadata
- net/keydb: network:// SSRF parity (IPv4-mapped, CGNAT, 0.0.0.0/8, Class-E); bounded keydb
  header read + size cap + error context
- io: durable mapfile fsync; NFS writeback degrade; sync_file_range error capture;
  Windows SCSI u32 transfer guard
This commit is contained in:
Matthew Jackson
2026-06-22 08:58:10 -07:00
parent 5941c059c6
commit 337e77951c
25 changed files with 1722 additions and 153 deletions
+4 -1
View File
@@ -447,7 +447,10 @@ impl CodecParser for HevcParser {
// numTemporalLayers u(3) = sps_max_sub_layers_minus1 + 1
// temporalIdNested u(1) = sps_temporal_id_nesting_flag
// lengthSizeMinusOne u(2) = 3 (4-byte length prefix)
let num_temporal_layers = (chroma.max_sub_layers_minus1 + 1) & 0x07;
// sps_max_sub_layers_minus1 is u(3) (0..7), so +1 is 1..8. The hvcC
// numTemporalLayers field is u(3) (0..7); the max legal value (8) is
// saturated to 7 rather than wrapping to 0 via the & 0x07 mask.
let num_temporal_layers = chroma.max_sub_layers_minus1.saturating_add(1).min(7) & 0x07;
let temporal_id_nested = chroma.temporal_id_nesting_flag & 0x01;
record.push((num_temporal_layers << 3) | (temporal_id_nested << 2) | 0x03);
// numOfArrays
+1 -1
View File
@@ -105,7 +105,7 @@ impl PassthroughParser {
impl CodecParser for PassthroughParser {
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
vec![Frame {
pts_ns,
keyframe: self.keyframe,
+26 -1
View File
@@ -58,6 +58,13 @@ const MAX_AU_BUFFER: usize = 8 * 1024 * 1024;
/// ever arrives within the cap, buffered frames are released on a 0 base.
const MAX_PENDING_FRAMES: usize = 600;
/// Byte cap on frames held awaiting the first PES PTS anchor. `MAX_PENDING_FRAMES`
/// alone bounds the *count*, but 600 full HD/UHD intra pictures can be ~1 GiB.
/// Mirror the AC-3/DTS/PGS byte caps: once the held data exceeds this, release
/// on the 0 base instead of accumulating further. 8 MiB ≈ a few large I-frames,
/// far more than the ~15 frames a well-formed DVD buffers before its first PTS.
const MAX_PENDING_BYTES: usize = 8 * 1024 * 1024;
/// Frame rate table (index from sequence header frame_rate_code).
const FRAME_RATES: [(u32, u32); 9] = [
(0, 1), // 0: forbidden
@@ -117,6 +124,9 @@ pub struct Mpeg2Parser {
/// sequence whose PTS lands a few frames in; buffering until the anchor lets
/// those leading frames take the disc's real timeline instead of a 0 base.
pending: Vec<(u64, Frame)>,
/// Accumulated `data.len()` of frames currently in `pending`. Bounds the
/// pre-anchor hold by BYTES, not just frame count (see [`MAX_PENDING_BYTES`]).
pending_bytes: usize,
}
impl Default for Mpeg2Parser {
@@ -139,6 +149,7 @@ impl Mpeg2Parser {
anchor_index: None,
anchor_pts: 0,
pending: Vec::new(),
pending_bytes: 0,
}
}
@@ -287,6 +298,7 @@ impl Mpeg2Parser {
p + (di as i64 - display_index as i64) * self.frame_duration_ns;
out.push(held);
}
self.pending_bytes = 0;
frame.pts_ns = p;
out.push(frame);
}
@@ -296,13 +308,25 @@ impl Mpeg2Parser {
+ (display_index as i64 - ai as i64) * self.frame_duration_ns;
out.push(frame);
}
None if self.pending.len() < MAX_PENDING_FRAMES => {
None if self.pending.len() < MAX_PENDING_FRAMES
&& self.pending_bytes < MAX_PENDING_BYTES =>
{
// No anchor yet — hold so leading frames get the
// disc's real timeline once the first PTS arrives,
// not a 0 base.
self.pending_bytes += frame.data.len();
self.pending.push((display_index, frame));
}
None => {
// Hold cap (count OR bytes) reached without a PTS
// anchor ever arriving. Release everything held so
// far on the 0-base timeline rather than growing the
// buffer unbounded, then emit this frame the same way.
for (di, mut held) in self.pending.drain(..) {
held.pts_ns = di as i64 * self.frame_duration_ns;
out.push(held);
}
self.pending_bytes = 0;
frame.pts_ns = display_index as i64 * self.frame_duration_ns;
out.push(frame);
}
@@ -364,6 +388,7 @@ impl CodecParser for Mpeg2Parser {
frame.pts_ns = di as i64 * self.frame_duration_ns;
out.push(frame);
}
self.pending_bytes = 0;
}
out
}
+29 -5
View File
@@ -6,7 +6,8 @@
use super::ebml;
use crate::disc::{
AudioStream, Chapter, Codec, ColorSpace, HdrFormat, SubtitleStream, VideoStream,
AudioChannels, AudioStream, Chapter, Codec, ColorSpace, HdrFormat, Resolution, SampleRate,
SubtitleStream, VideoStream,
};
use std::io::{self, Seek, Write};
@@ -68,7 +69,14 @@ impl MkvTrack {
Codec::Mpeg2 => ebml::CODEC_MPEG2,
_ => ebml::CODEC_MPEG2,
};
let (w, h) = v.resolution.pixels();
// An Unknown resolution has no real dimensions — emit (0, 0) so the
// serializer omits PixelWidth/PixelHeight (Matroska marks them
// optional) rather than writing a fabricated 1920x1080 default.
let (w, h) = if matches!(v.resolution, Resolution::Unknown) {
(0, 0)
} else {
v.resolution.pixels()
};
let (num, den) = v.frame_rate.as_fraction();
let default_duration_ns = if num > 0 {
(1_000_000_000u64 * den as u64) / num as u64
@@ -139,8 +147,20 @@ impl MkvTrack {
Codec::Lpcm => ebml::CODEC_PCM_BE,
_ => ebml::CODEC_AC3,
};
let sr = a.sample_rate.hz();
let ch = a.channels.count();
// Unknown sample rate / channel layout: emit 0 so the serializer omits
// the SamplingFrequency / Channels element (Matroska supplies its own
// spec default) rather than writing a fabricated 48000 Hz / 6-channel
// value into the file.
let sr = if matches!(a.sample_rate, SampleRate::Unknown) {
0.0
} else {
a.sample_rate.hz()
};
let ch = if matches!(a.channels, AudioChannels::Unknown) {
0
} else {
a.channels.count()
};
let name = a.label.clone();
@@ -592,7 +612,11 @@ impl<W: Write + Seek> MkvMuxer<W> {
if track.track_type == ebml::TRACK_TYPE_AUDIO && track.sample_rate > 0.0 {
let aud_pos = ebml::start_master(&mut writer, ebml::AUDIO)?;
ebml::write_float(&mut writer, ebml::SAMPLING_FREQUENCY, track.sample_rate)?;
ebml::write_uint(&mut writer, ebml::CHANNELS, track.channels as u64)?;
// Omit Channels when unknown (0) — Matroska defaults it to 1
// rather than us fabricating a 6-channel count.
if track.channels > 0 {
ebml::write_uint(&mut writer, ebml::CHANNELS, track.channels as u64)?;
}
if track.bit_depth > 0 {
ebml::write_uint(&mut writer, ebml::BIT_DEPTH, track.bit_depth as u64)?;
}
+8 -1
View File
@@ -214,7 +214,14 @@ impl crate::pes::Stream for MkvStream {
if cs == u64::MAX {
return Err(crate::error::Error::MkvInvalid.into());
}
remaining = remaining.saturating_sub(hlen as u64 + cs);
// A child whose header + body exceeds the bytes left in
// the BlockGroup is malformed — reject it rather than
// saturating `remaining` to 0 and reading past the group.
let consumed = (hlen as u64).saturating_add(cs);
if consumed > remaining {
return Err(crate::error::Error::MkvInvalid.into());
}
remaining -= consumed;
match cid {
ebml::BLOCK => {
block = Some(ebml::read_binary_val(
+34
View File
@@ -25,12 +25,19 @@ const NET_BUF_SIZE: usize = 256 * 1024;
pub(crate) fn is_blocked_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
let o = v4.octets();
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_multicast()
|| v4.is_broadcast()
// carrier-grade NAT 100.64.0.0/10
|| (o[0] == 100 && (o[1] & 0xc0) == 0x40)
// "this network" 0.0.0.0/8
|| o[0] == 0
// Class E reserved 240.0.0.0/4
|| o[0] >= 240
}
IpAddr::V6(v6) => {
v6.is_loopback()
@@ -40,6 +47,10 @@ pub(crate) fn is_blocked_ip(ip: IpAddr) -> bool {
|| (v6.segments()[0] & 0xfe00) == 0xfc00
// link-local fe80::/10
|| (v6.segments()[0] & 0xffc0) == 0xfe80
// IPv4-mapped (::ffff:x.x.x.x) and IPv4-compatible (::x.x.x.x);
// to_ipv4() returns Some for both forms — re-check as IPv4 so an
// IPv4-mapped private/loopback address can't bypass the block above.
|| v6.to_ipv4().map(|m| is_blocked_ip(IpAddr::V4(m))) == Some(true)
}
}
}
@@ -268,6 +279,25 @@ mod tests {
IpAddr::V6(Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 1)),
"multicast v6",
),
// CGNAT / 0.0.0.0/8 / Class E (finding 8).
(v4(100, 64, 0, 1), "carrier-grade NAT"),
(v4(100, 127, 255, 254), "carrier-grade NAT edge"),
(v4(0, 1, 2, 3), "0.0.0.0/8"),
(v4(240, 0, 0, 1), "Class E"),
(v4(255, 0, 0, 1), "Class E high"),
// IPv4-mapped / -compatible IPv6 bypass (finding 7).
(
IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x0a00, 0x0001)),
"IPv4-mapped RFC1918 (::ffff:0a00:0001)",
),
(
IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x7f00, 0x0001)),
"::ffff:127.0.0.1 mapped",
),
(
IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0x7f00, 0x0001)),
"::127.0.0.1 compatible",
),
];
for (ip, label) in blocked {
assert!(is_blocked_ip(*ip), "{label} ({ip}) must be blocked");
@@ -281,6 +311,10 @@ mod tests {
IpAddr::V6(Ipv6Addr::new(0x2606, 0x2800, 0x220, 1, 0, 0, 0, 1)),
"public v6",
),
(
IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x0808, 0x0808)),
"::ffff:8.8.8.8 public mapped",
),
];
for (ip, label) in allowed {
assert!(!is_blocked_ip(*ip), "{label} ({ip}) must be allowed");
+29 -7
View File
@@ -351,10 +351,10 @@ fn parse_pes_packet(data: &[u8]) -> Option<PsPacket> {
// non-conformant packet that sets the flags but declares a too-short
// header would otherwise read payload bytes as a bogus timestamp.
if pts_dts_flags >= 2 && header_data_len >= 5 && data.len() >= 14 {
pts = Some(parse_pts(&data[9..14]));
pts = parse_pts(&data[9..14]);
}
if pts_dts_flags == 3 && header_data_len >= 10 && data.len() >= 19 {
dts = Some(parse_pts(&data[14..19]));
dts = parse_pts(&data[14..19]);
}
let payload = &data[header_end..];
@@ -393,15 +393,21 @@ fn parse_pes_packet(data: &[u8]) -> Option<PsPacket> {
/// byte3: [pts 14..7:8]
/// byte4: [pts 6..0:7][marker:1]
/// ```
fn parse_pts(buf: &[u8]) -> u64 {
fn parse_pts(buf: &[u8]) -> Option<u64> {
debug_assert!(buf.len() >= 5);
// Validate the three marker bits (bit 0 of bytes 0, 2, 4) per MPEG-2
// Systems Table 2-17. A timestamp with a cleared marker is malformed —
// matching ts.rs::parse_timestamp, reject it rather than decode garbage.
if (buf[0] & 0x01) == 0 || (buf[2] & 0x01) == 0 || (buf[4] & 0x01) == 0 {
return None;
}
let b0 = buf[0] as u64;
let b1 = buf[1] as u64;
let b2 = buf[2] as u64;
let b3 = buf[3] as u64;
let b4 = buf[4] as u64;
((b0 >> 1) & 0x07) << 30 | b1 << 22 | (b2 >> 1) << 15 | b3 << 7 | b4 >> 1
Some(((b0 >> 1) & 0x07) << 30 | b1 << 22 | (b2 >> 1) << 15 | b3 << 7 | b4 >> 1)
}
#[cfg(test)]
@@ -748,7 +754,7 @@ mod tests {
fn pts_zero() {
// PTS = 0 encoded
let pts = parse_pts(&encode_pts(0, 0x20));
assert_eq!(pts, 0);
assert_eq!(pts, Some(0));
}
#[test]
@@ -757,7 +763,7 @@ mod tests {
let val: u64 = (1 << 32) - 1; // 0xFFFFFFFF
let encoded = encode_pts(val, 0x20);
let decoded = parse_pts(&encoded);
assert_eq!(decoded, val);
assert_eq!(decoded, Some(val));
}
// --- DVD PID mapping (track-routing collision regression) ---
@@ -886,7 +892,23 @@ mod tests {
// The PTS field is exactly 33 bits; 2^33-1 must round-trip — a
// truncated shift/mask would lose the top bits.
let max = (1u64 << 33) - 1;
assert_eq!(parse_pts(&encode_pts(max, 0x20)), max);
assert_eq!(parse_pts(&encode_pts(max, 0x20)), Some(max));
}
#[test]
fn parse_pts_rejects_bad_marker_bits() {
// A timestamp with any marker bit (bit 0 of bytes 0/2/4) cleared is
// malformed and must be rejected, matching ts.rs::parse_timestamp.
let mut buf = encode_pts(90000, 0x20);
assert!(parse_pts(&buf).is_some());
buf[0] &= !0x01;
assert_eq!(parse_pts(&buf), None);
let mut buf = encode_pts(90000, 0x20);
buf[2] &= !0x01;
assert_eq!(parse_pts(&buf), None);
let mut buf = encode_pts(90000, 0x20);
buf[4] &= !0x01;
assert_eq!(parse_pts(&buf), None);
}
// ── pack header (0xBA) framing ────────────────────────────────────────
+92 -1
View File
@@ -179,6 +179,25 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
}
.into());
}
// Split host:port on the LAST ':' so a bracketed IPv6 literal
// (`[2001:db8::1]:9000`) splits at the port colon, not an address colon.
// Require the port substring to be a non-empty u16 — `host:` (empty) and
// `host:abc` (non-numeric) are invalid, despite containing ':'.
let port = match addr.rsplit_once(':') {
Some((_host, port)) => port,
None => {
return Err(crate::error::Error::StreamUrlMissingPort {
addr: addr.to_string(),
}
.into());
}
};
if port.is_empty() || port.parse::<u16>().is_err() {
return Err(crate::error::Error::StreamUrlInvalid {
url: addr.to_string(),
}
.into());
}
Ok(())
}
@@ -213,6 +232,16 @@ fn aacs_key_missing(raw: bool, has_aacs: bool, keys: &crate::decrypt::DecryptKey
!raw && has_aacs && matches!(keys, crate::decrypt::DecryptKeys::None)
}
/// CSS analogue of [`aacs_key_missing`]. Returns `true` when decryption is
/// requested (`!raw`), the disc is CSS-encrypted (`has_css`), and per-title key
/// resolution yielded no usable key (`keys` is
/// [`crate::decrypt::DecryptKeys::None`] — e.g. a multi-VTS DVD whose chosen
/// title's VTS could not be re-cracked). Muxing that would emit scrambled
/// ciphertext, so the caller fails fast with [`Error::CssKeyMissing`].
fn css_key_missing(raw: bool, has_css: bool, keys: &crate::decrypt::DecryptKeys) -> bool {
!raw && has_css && matches!(keys, crate::decrypt::DecryptKeys::None)
}
/// Open a PES input stream (produces PES frames).
pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> {
let parsed = parse_url(url);
@@ -276,13 +305,29 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
}
.into());
}
// Per-title key resolution. For a multi-VTS CSS DVD the scan's
// single cracked key only descrambles its own VTS; re-crack from
// the chosen title's extents if it lives elsewhere. A fresh reader
// avoids disturbing the mux reader below. 64 sectors is a
// file-safe batch for an ISO. AACS / single-VTS paths are
// unchanged (decrypt_keys_for_title short-circuits to decrypt_keys).
let keys = match crate::io::file_sector_source::FileSectorSource::open(path) {
Ok(mut crack_reader) => disc.decrypt_keys_for_title(idx, &mut crack_reader, 64),
Err(_) => disc.decrypt_keys(),
};
// CSS no-key guard (parallel to the AACS gate above): on a CSS
// disc, decrypt_keys_for_title may return `None` when the chosen
// title's VTS could not be re-cracked. Muxing that would emit
// scrambled ciphertext verbatim, so fail loudly here instead.
if css_key_missing(opts.raw, disc.css.is_some(), &keys) {
return Err(crate::error::Error::CssKeyMissing.into());
}
// Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1)
// by probing the first DECRYPTED access units of the chosen title.
// A fresh reader avoids disturbing the mux reader below. Skipped in
// --raw mode: the probe would re-open + decrypt for nothing (on an
// AACS disc with no key the correction is a no-op on ciphertext, and
// raw output isn't decoded anyway).
let keys = disc.decrypt_keys();
if !opts.raw {
match crate::io::file_sector_source::FileSectorSource::open(path) {
Ok(probe) => {
@@ -593,6 +638,7 @@ fn build_m2ts_pipeline<R: std::io::Read + Send + 'static>(
#[cfg(test)]
mod tests {
use super::aacs_key_missing;
use super::css_key_missing;
use super::validate_network_addr;
use super::{build_demux_state, build_iso_pipeline, input, output};
use crate::decrypt::DecryptKeys;
@@ -612,6 +658,26 @@ mod tests {
assert!(validate_network_addr("host:9000").is_ok());
}
#[test]
fn validate_network_addr_requires_numeric_port() {
// An empty port (`host:`) and a non-numeric port (`host:abc`) both
// contain ':' but are NOT valid host:port — must be rejected.
assert!(validate_network_addr("host:").is_err());
assert!(validate_network_addr("127.0.0.1:").is_err());
assert!(validate_network_addr("host:abc").is_err());
assert!(validate_network_addr("host:99x").is_err());
// Out-of-u16-range port is rejected (parse::<u16> fails).
assert!(validate_network_addr("host:70000").is_err());
// Bracketed IPv6 with a valid port passes; split on the LAST ':' so the
// address colons are not mistaken for the port separator.
assert!(validate_network_addr("[2001:db8::1]:9000").is_ok());
// Bracketed IPv6 WITHOUT a port is rejected (port substring not a u16).
assert!(validate_network_addr("[2001:db8::1]").is_err());
// Valid numeric port (incl. 0 and max u16) passes.
assert!(validate_network_addr("host:0").is_ok());
assert!(validate_network_addr("host:65535").is_ok());
}
fn aacs_keys() -> DecryptKeys {
DecryptKeys::Aacs {
unit_keys: vec![(1, [0x11u8; 16])],
@@ -644,6 +710,31 @@ mod tests {
assert!(!aacs_key_missing(false, false, &css_keys()));
}
#[test]
fn css_no_key_aborts() {
// CSS disc, decryption requested, per-title resolver yielded None
// (e.g. an un-re-crackable VTS) → abort instead of muxing ciphertext.
assert!(css_key_missing(false, true, &DecryptKeys::None));
}
#[test]
fn css_with_key_proceeds() {
// CSS disc with a resolved title key → proceed.
assert!(!css_key_missing(false, true, &css_keys()));
}
#[test]
fn css_raw_never_aborts() {
// --raw skips decryption: never abort even with no CSS key.
assert!(!css_key_missing(true, true, &DecryptKeys::None));
}
#[test]
fn css_guard_ignores_non_css() {
// No CSS state (AACS / unencrypted): the CSS guard never fires.
assert!(!css_key_missing(false, false, &DecryptKeys::None));
}
#[test]
fn raw_never_aborts() {
// --raw skips decryption — must never hit the no-key abort, even on an
+192 -3
View File
@@ -44,6 +44,13 @@ struct PesAssembler {
/// HEVC/H264 that reads as a spurious start code / corrupt slice
/// payload. Tracks how many header bytes remain across packets.
header_remaining: usize,
/// 4-bit continuity_counter of the last payload-bearing TS packet seen
/// on this PID. A non-PUSI continuation whose CC is not `(prev + 1) & 0xf`
/// — or whose adaptation field flags a discontinuity — means one or more
/// TS packets for this PID were dropped; splicing the new payload onto the
/// partial PES would inject corrupt bytes. The partial PES is dropped and
/// the assembler resyncs on the next PUSI. `None` until the first packet.
last_cc: Option<u8>,
}
/// Initial capacity for a fresh PES buffer. Sized to cover the
@@ -76,6 +83,7 @@ impl PesAssembler {
dts: None,
active: false,
header_remaining: 0,
last_cc: None,
}
}
@@ -288,6 +296,37 @@ impl TsDemuxer {
let payload = &ts[payload_start..];
// Continuity check. The 4-bit continuity_counter increments by 1 on
// every payload-bearing packet of a PID; a gap means dropped TS
// packets. The adaptation field's discontinuity_indicator (first AF
// byte, bit 0x80) explicitly flags an intentional break. On a non-PUSI
// continuation that is discontinuous, the partial PES has a hole in it
// — splicing the new payload would corrupt the elementary stream — so
// drop the partial and resync on the next PUSI.
let cc = ts[3] & 0x0f;
let discontinuity_flag =
(adaptation == 0x03 || adaptation == 0x02) && ts[4] > 0 && (ts[5] & 0x80) != 0;
// A gap is a CC that is neither the expected `(prev + 1) & 0xf` nor a
// duplicate `prev` (ISO 13818-1 permits a packet to repeat its CC; a
// duplicate is not a loss). Anything else means one or more packets for
// this PID were dropped.
let cc_gap = match asm.last_cc {
Some(prev) => cc != ((prev + 1) & 0x0f) && cc != prev,
None => false,
};
asm.last_cc = Some(cc);
if !pusi && (discontinuity_flag || cc_gap) && asm.active {
tracing::trace!(
target: "mux",
pid = asm.pid,
"TS continuity break on non-PUSI continuation; dropping partial PES",
);
asm.buffer.clear();
asm.active = false;
asm.header_remaining = 0;
return;
}
if pusi {
// `header_len` is the FULL (uncapped) PES-header length:
// 0 = malformed (payload is not a PES start), else 6/9+N.
@@ -518,16 +557,33 @@ fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option<Vec
section.truncate(total);
return Some(section);
}
// Need continuation packets: same PID, no PUSI.
// Need continuation packets: same PID, no PUSI, with a
// monotonically incrementing continuity counter. The CC lives in
// the low nibble of the 4th TS-header byte (offset+7 here: the
// BD-TS 4-byte prefix precedes the sync byte). A CC gap means a
// dropped/duplicated packet → the assembled section is corrupt, so
// abandon it rather than splicing in misordered payload.
let mut expected_cc = ((data[offset + 7] & 0x0F) + 1) & 0x0F;
let mut scan = offset + BD_TS_PACKET_SIZE;
let mut desync = false;
while scan + BD_TS_PACKET_SIZE <= data.len() && section.len() < total {
if data[scan + 4] != SYNC_BYTE {
// Require a corroborated resync point (this sync byte plus the
// follower one packet ahead) before trusting the header. A
// stray 0x47 in corrupt payload would otherwise misread the CC
// and fire a false desync.
if !is_resync_point(data, scan) {
scan += 1;
continue;
}
let cpid = (((data[scan + 5] & 0x1F) as u16) << 8) | data[scan + 6] as u16;
let cpusi = data[scan + 5] & 0x40 != 0;
if cpid == target_pid && !cpusi {
let cc = data[scan + 7] & 0x0F;
if cc != expected_cc {
desync = true;
break;
}
expected_cc = (cc + 1) & 0x0F;
// Continuation packets may also carry an adaptation
// field; compute their payload base the same way.
if let Some(cbase) = psi_payload_base(&data[scan..scan + BD_TS_PACKET_SIZE]) {
@@ -536,6 +592,12 @@ fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option<Vec
}
scan += BD_TS_PACKET_SIZE;
}
if desync {
// Restart PSI assembly from the next packet after this PUSI;
// a later clean copy of the section may still appear.
offset += BD_TS_PACKET_SIZE;
continue;
}
if section.len() >= total {
section.truncate(total);
return Some(section);
@@ -697,6 +759,99 @@ mod tests {
assert_eq!(parse_timestamp(&bad), None);
}
/// Build a 192-byte BD-TS payload packet for `pid` with explicit PUSI and
/// continuity_counter, carrying `payload` (truncated/padded to 184 bytes,
/// payload-only adaptation).
fn ts_payload_packet(pid: u16, pusi: bool, cc: u8, payload: &[u8]) -> Vec<u8> {
let mut pkt = vec![0u8; BD_TS_PACKET_SIZE];
pkt[4] = SYNC_BYTE;
pkt[5] = ((pid >> 8) as u8) & 0x1F;
if pusi {
pkt[5] |= 0x40;
}
pkt[6] = (pid & 0xFF) as u8;
pkt[7] = 0x10 | (cc & 0x0f); // payload-only adaptation + CC
let n = payload.len().min(184);
pkt[8..8 + n].copy_from_slice(&payload[..n]);
pkt
}
/// A minimal valid PES start for a video stream id, with no PTS/DTS flags,
/// followed by `es` elementary-stream bytes. header_len = 9.
fn pes_start(es: &[u8]) -> Vec<u8> {
let mut v = vec![0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00];
v.extend_from_slice(es);
v
}
/// Regression (finding 3): a non-PUSI continuation whose continuity_counter
/// is not (prev+1)&0xf means TS packets were dropped — the partial PES has a
/// hole and must be discarded, not spliced. We start a PES (cc=0), then feed
/// a continuation with a CC gap (cc=5 instead of 1); the assembler drops the
/// partial. A clean follow-on PUSI then produces exactly that next PES,
/// proving the corrupt splice didn't happen.
#[test]
fn continuity_gap_drops_partial_pes() {
let pid = 0x1011;
let mut demux = TsDemuxer::new(&[pid]);
// Start a PES (cc=0) carrying "AAAA".
let mut out = demux.feed(&ts_payload_packet(pid, true, 0, &pes_start(b"AAAA")));
assert!(
out.is_empty(),
"first PES still open, nothing completed yet"
);
// Discontinuous continuation (cc jumps 0 -> 5) carrying "BBBB". The gap
// must drop the partial PES rather than append "BBBB".
out = demux.feed(&ts_payload_packet(pid, false, 5, b"BBBB"));
assert!(out.is_empty(), "dropped partial PES is not emitted here");
// A fresh PUSI (cc=6) starts the next PES "CCCC"; starting it would
// normally flush the previous one — but it was dropped, so nothing is
// flushed yet.
out = demux.feed(&ts_payload_packet(pid, true, 6, &pes_start(b"CCCC")));
assert!(
out.is_empty(),
"the dropped partial must NOT be flushed by the next PUSI"
);
// Flush: only the clean "CCCC" PES comes out — it must NOT begin with
// the dropped "AAAA" payload. (Payload-only packets pad to 184 bytes,
// so compare the leading ES bytes, not the whole padded buffer.)
let final_out = demux.flush();
assert_eq!(final_out.len(), 1, "exactly one clean PES");
assert_eq!(
&final_out[0].data[..4],
b"CCCC",
"surviving PES is the clean one, not the dropped partial"
);
// The dropped "BBBB" continuation must not have been spliced anywhere.
assert!(
!final_out[0].data.windows(4).any(|w| w == b"BBBB"),
"dropped continuation must not appear in any emitted PES"
);
}
/// In-sequence continuation (cc 0 -> 1) must still splice normally — the
/// continuity check must not break the happy path.
#[test]
fn continuity_in_sequence_splices() {
let pid = 0x1011;
let mut demux = TsDemuxer::new(&[pid]);
demux.feed(&ts_payload_packet(pid, true, 0, &pes_start(b"AAAA")));
demux.feed(&ts_payload_packet(pid, false, 1, b"BBBB"));
let out = demux.flush();
assert_eq!(out.len(), 1);
// First payload's ES leads, and the in-sequence continuation's "BBBB"
// is present (spliced) — the padding zeros sit between them.
assert_eq!(&out[0].data[..4], b"AAAA", "first PES ES leads");
assert!(
out[0].data.windows(4).any(|w| w == b"BBBB"),
"in-sequence continuation must be spliced in"
);
}
#[test]
fn test_demuxer_empty() {
let mut demux = TsDemuxer::new(&[0x1011]);
@@ -984,7 +1139,12 @@ mod tests {
let tail = &section[head_len..];
assert!(!tail.is_empty(), "test must actually span two packets");
p1[..tail.len()].copy_from_slice(tail);
let pkt1 = bdts_packet(p1, pmt_pid, false);
let mut pkt1 = bdts_packet(p1, pmt_pid, false);
// Continuity counter must increment from the PUSI packet (CC=0) to its
// continuation (CC=1) — `collect_psi_section` rejects a CC gap as a
// desync. The CC lives in the low nibble of TS-header byte 4 (offset 7
// here, after the 4-byte BD-TS timecode prefix).
pkt1[7] = (pkt1[7] & 0xF0) | 0x01;
let mut out = pkt0;
out.extend(pkt1);
@@ -1025,6 +1185,35 @@ mod tests {
);
}
/// Regression for the PSI continuity-counter guard: a continuation packet
/// whose CC does NOT increment from the PUSI packet is a desync (dropped or
/// reordered packet). `collect_psi_section` must abandon that assembly
/// rather than splice misordered payload. Here the only continuation has a
/// bad CC, so the section never completes and no streams are found.
#[test]
fn scan_streams_rejects_pmt_with_cc_desync() {
let pmt_pid = 0x0100;
let mut entries: Vec<(u8, u16)> = Vec::new();
entries.push((0x1B, 0x1011));
for i in 0..40u16 {
entries.push((0x80, 0x1100 + i));
}
let mut pmt = pmt_two_packets(pmt_pid, &entries);
// Corrupt the continuation packet's CC. pmt is exactly two BD-TS
// packets; the second starts at BD_TS_PACKET_SIZE. Its CC (low nibble
// of offset+7) was set to 1 by pmt_two_packets; flip it to a gap (5).
let cc_off = BD_TS_PACKET_SIZE + 7;
pmt[cc_off] = (pmt[cc_off] & 0xF0) | 0x05;
let mut data = pat_packet(pmt_pid);
data.extend(pmt);
// The PMT section can't be reassembled (CC gap) → no program found.
assert!(
scan_streams(&data).is_none(),
"a CC-desynced PMT continuation must not yield streams"
);
}
// ════════════════════════════════════════════════════════════════════
// Added hardening tests
// ════════════════════════════════════════════════════════════════════