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:
+148
-126
@@ -168,136 +168,158 @@ impl PrefetchedSectorSource {
|
||||
let producer = std::thread::Builder::new()
|
||||
.name("freemkv-prefetch".into())
|
||||
.spawn(move || {
|
||||
let mut ext_idx = 0usize;
|
||||
let mut offset: u32 = 0;
|
||||
let mut bytes_read_total: u64 = 0;
|
||||
while ext_idx < extents.len() {
|
||||
if halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
let extent = &extents[ext_idx];
|
||||
// AACS aligned units are anchored at THIS extent's start
|
||||
// LBA, so tell the decrypt-on-read source to gate relative
|
||||
// to it (clip-anchored), not absolute disc LBA 0. A no-op
|
||||
// for non-decrypting / CSS / None sources.
|
||||
reader.set_unit_base(extent.start_lba);
|
||||
let remaining = extent.sector_count.saturating_sub(offset);
|
||||
if remaining == 0 {
|
||||
ext_idx += 1;
|
||||
offset = 0;
|
||||
continue;
|
||||
}
|
||||
// The AACS aligned unit is SECTOR_ALIGNMENT (3)
|
||||
// sectors / 6144 bytes; the decrypt step only
|
||||
// processes full units and silently leaves a
|
||||
// shorter trailing chunk encrypted. So a batch must
|
||||
// be a whole number of units — except for the final
|
||||
// batch of an extent whose `sector_count` is itself
|
||||
// unit-aligned (then the remaining tail is exactly
|
||||
// 0 mod 3 and forms full units on its own).
|
||||
//
|
||||
// If the trailing sectors of this extent cannot fill
|
||||
// a complete unit (`remaining < SECTOR_ALIGNMENT`
|
||||
// with nothing more to read, or a 1-2 sector
|
||||
// leftover after the last full unit), there is no
|
||||
// way to hand the decrypt step an aligned chunk —
|
||||
// surface a typed error instead of emitting
|
||||
// still-encrypted bytes.
|
||||
if remaining % unit_align as u32 != 0 && remaining < unit_align as u32 {
|
||||
let _ = tx.send(Err(crate::error::Error::ExtentNotUnitAligned.into()));
|
||||
return;
|
||||
}
|
||||
let mut sectors = remaining.min(batch_sectors as u32) as u16;
|
||||
// Trim to a whole number of units. Once trimmed to 0
|
||||
// here it means `remaining >= SECTOR_ALIGNMENT` but
|
||||
// the *batch window* landed on a sub-unit boundary —
|
||||
// never the trailing-tail case, which the guard
|
||||
// above already rejected. Clamp to one unit so we
|
||||
// always make forward progress.
|
||||
if sectors >= unit_align {
|
||||
sectors -= sectors % unit_align;
|
||||
} else {
|
||||
sectors = unit_align;
|
||||
}
|
||||
let bytes = sectors as usize * 2048;
|
||||
// Park on the recycle channel, but re-poll halt every
|
||||
// POLL_INTERVAL: a pure-AtomicBool Halt does not
|
||||
// disconnect the channel, so a blocking recv() would
|
||||
// never re-reach the cancel check at the loop top.
|
||||
// Mirrors the BytePrefetcher pattern exactly.
|
||||
let mut buf = loop {
|
||||
match recycle_rx.recv_timeout(POLL_INTERVAL) {
|
||||
Ok(b) => break b,
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Consumer dropped both channels.
|
||||
Err(RecvTimeoutError::Disconnected) => return,
|
||||
}
|
||||
};
|
||||
if bytes <= buf.capacity() {
|
||||
// Re-expose `bytes` without zero-filling pages that
|
||||
// `read_sectors` is about to overwrite. The enclosing
|
||||
// capacity guard makes the `set_len` provably sound even
|
||||
// if a recycled buffer ever comes back smaller than the
|
||||
// `vec![0u8; batch_bytes]` it was born with.
|
||||
debug_assert!(bytes <= buf.capacity(), "set_len exceeds capacity");
|
||||
unsafe { buf.set_len(bytes) };
|
||||
} else {
|
||||
buf.resize(bytes, 0);
|
||||
}
|
||||
// `start_lba + offset` derives from untrusted extent
|
||||
// data — saturate rather than wrap/panic on a
|
||||
// hostile start_lba near u32::MAX.
|
||||
let lba = extent.start_lba.saturating_add(offset);
|
||||
match reader.read_sectors(lba, sectors, &mut buf[..bytes], false) {
|
||||
Ok(n) => {
|
||||
// A short read must not silently desync the
|
||||
// stream: advance the extent cursor by the
|
||||
// sectors actually read, not the requested
|
||||
// count, and reject a byte count that isn't a
|
||||
// whole number of sectors (it would split a
|
||||
// sector and leave the decrypt step a partial
|
||||
// unit). The sole production inner source
|
||||
// (FileSectorSource) read_exact's the full
|
||||
// request, so this is belt-and-braces against
|
||||
// a future short-reading source.
|
||||
if n % 2048 != 0 {
|
||||
let _ =
|
||||
tx.send(Err(crate::error::Error::ExtentNotUnitAligned.into()));
|
||||
return;
|
||||
}
|
||||
let sectors_read = (n / 2048) as u32;
|
||||
buf.truncate(n);
|
||||
bytes_read_total = bytes_read_total.saturating_add(n as u64);
|
||||
if let Some(ref f) = event_fn {
|
||||
f(Event {
|
||||
kind: EventKind::BytesRead {
|
||||
bytes: bytes_read_total,
|
||||
total: bytes_total_extents,
|
||||
},
|
||||
});
|
||||
}
|
||||
if tx.send(Ok(buf)).is_err() {
|
||||
return; // consumer dropped
|
||||
}
|
||||
// A genuine zero-byte read with no error would
|
||||
// otherwise spin this loop forever; treat it
|
||||
// as end-of-source.
|
||||
if sectors_read == 0 {
|
||||
return;
|
||||
}
|
||||
offset = offset.saturating_add(sectors_read);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(Err(e.into()));
|
||||
// Wrap the whole feed loop in catch_unwind so a panic inside a
|
||||
// decrypt-on-read SectorSource, the read path, or a panicking
|
||||
// `event_fn` callback is NOT indistinguishable from a clean
|
||||
// finish at the demux boundary. On a clean exit (input
|
||||
// exhausted, halt cancelled, consumer disconnect) the body
|
||||
// returns and `tx` is dropped → the demux loop reads RecvError
|
||||
// as EOF, which is correct. On a PANIC we send an explicit error
|
||||
// sentinel FIRST so the demux loop's `Ok(Err(_))` arm fires and
|
||||
// propagates a typed error instead of converting the dropped
|
||||
// channel into a clean `DemuxBatch::Eof` (which would finalize a
|
||||
// TRUNCATED mux while reporting success). `tx`/`reader`/locals
|
||||
// are only touched on this thread, so AssertUnwindSafe is sound.
|
||||
let body = std::panic::AssertUnwindSafe(|| {
|
||||
let mut ext_idx = 0usize;
|
||||
let mut offset: u32 = 0;
|
||||
let mut bytes_read_total: u64 = 0;
|
||||
while ext_idx < extents.len() {
|
||||
if halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
let extent = &extents[ext_idx];
|
||||
// AACS aligned units are anchored at THIS extent's start
|
||||
// LBA, so tell the decrypt-on-read source to gate relative
|
||||
// to it (clip-anchored), not absolute disc LBA 0. A no-op
|
||||
// for non-decrypting / CSS / None sources.
|
||||
reader.set_unit_base(extent.start_lba);
|
||||
let remaining = extent.sector_count.saturating_sub(offset);
|
||||
if remaining == 0 {
|
||||
ext_idx += 1;
|
||||
offset = 0;
|
||||
continue;
|
||||
}
|
||||
// The AACS aligned unit is SECTOR_ALIGNMENT (3)
|
||||
// sectors / 6144 bytes; the decrypt step only
|
||||
// processes full units and silently leaves a
|
||||
// shorter trailing chunk encrypted. So a batch must
|
||||
// be a whole number of units — except for the final
|
||||
// batch of an extent whose `sector_count` is itself
|
||||
// unit-aligned (then the remaining tail is exactly
|
||||
// 0 mod 3 and forms full units on its own).
|
||||
//
|
||||
// If the trailing sectors of this extent cannot fill
|
||||
// a complete unit (`remaining < SECTOR_ALIGNMENT`
|
||||
// with nothing more to read, or a 1-2 sector
|
||||
// leftover after the last full unit), there is no
|
||||
// way to hand the decrypt step an aligned chunk —
|
||||
// surface a typed error instead of emitting
|
||||
// still-encrypted bytes.
|
||||
if remaining % unit_align as u32 != 0 && remaining < unit_align as u32 {
|
||||
let _ = tx.send(Err(crate::error::Error::ExtentNotUnitAligned.into()));
|
||||
return;
|
||||
}
|
||||
let mut sectors = remaining.min(batch_sectors as u32) as u16;
|
||||
// Trim to a whole number of units. Once trimmed to 0
|
||||
// here it means `remaining >= SECTOR_ALIGNMENT` but
|
||||
// the *batch window* landed on a sub-unit boundary —
|
||||
// never the trailing-tail case, which the guard
|
||||
// above already rejected. Clamp to one unit so we
|
||||
// always make forward progress.
|
||||
if sectors >= unit_align {
|
||||
sectors -= sectors % unit_align;
|
||||
} else {
|
||||
sectors = unit_align;
|
||||
}
|
||||
let bytes = sectors as usize * 2048;
|
||||
// Park on the recycle channel, but re-poll halt every
|
||||
// POLL_INTERVAL: a pure-AtomicBool Halt does not
|
||||
// disconnect the channel, so a blocking recv() would
|
||||
// never re-reach the cancel check at the loop top.
|
||||
// Mirrors the BytePrefetcher pattern exactly.
|
||||
let mut buf = loop {
|
||||
match recycle_rx.recv_timeout(POLL_INTERVAL) {
|
||||
Ok(b) => break b,
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
if halt.as_ref().map(|h| h.is_cancelled()).unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Consumer dropped both channels.
|
||||
Err(RecvTimeoutError::Disconnected) => return,
|
||||
}
|
||||
};
|
||||
if bytes <= buf.capacity() {
|
||||
// Re-expose `bytes` without zero-filling pages that
|
||||
// `read_sectors` is about to overwrite. The enclosing
|
||||
// capacity guard makes the `set_len` provably sound even
|
||||
// if a recycled buffer ever comes back smaller than the
|
||||
// `vec![0u8; batch_bytes]` it was born with.
|
||||
debug_assert!(bytes <= buf.capacity(), "set_len exceeds capacity");
|
||||
unsafe { buf.set_len(bytes) };
|
||||
} else {
|
||||
buf.resize(bytes, 0);
|
||||
}
|
||||
// `start_lba + offset` derives from untrusted extent
|
||||
// data — saturate rather than wrap/panic on a
|
||||
// hostile start_lba near u32::MAX.
|
||||
let lba = extent.start_lba.saturating_add(offset);
|
||||
match reader.read_sectors(lba, sectors, &mut buf[..bytes], false) {
|
||||
Ok(n) => {
|
||||
// A short read must not silently desync the
|
||||
// stream: advance the extent cursor by the
|
||||
// sectors actually read, not the requested
|
||||
// count, and reject a byte count that isn't a
|
||||
// whole number of sectors (it would split a
|
||||
// sector and leave the decrypt step a partial
|
||||
// unit). The sole production inner source
|
||||
// (FileSectorSource) read_exact's the full
|
||||
// request, so this is belt-and-braces against
|
||||
// a future short-reading source.
|
||||
if n % 2048 != 0 {
|
||||
let _ = tx.send(Err(
|
||||
crate::error::Error::ExtentNotUnitAligned.into()
|
||||
));
|
||||
return;
|
||||
}
|
||||
let sectors_read = (n / 2048) as u32;
|
||||
buf.truncate(n);
|
||||
bytes_read_total = bytes_read_total.saturating_add(n as u64);
|
||||
if let Some(ref f) = event_fn {
|
||||
f(Event {
|
||||
kind: EventKind::BytesRead {
|
||||
bytes: bytes_read_total,
|
||||
total: bytes_total_extents,
|
||||
},
|
||||
});
|
||||
}
|
||||
if tx.send(Ok(buf)).is_err() {
|
||||
return; // consumer dropped
|
||||
}
|
||||
// A genuine zero-byte read with no error would
|
||||
// otherwise spin this loop forever; treat it
|
||||
// as end-of-source.
|
||||
if sectors_read == 0 {
|
||||
return;
|
||||
}
|
||||
offset = offset.saturating_add(sectors_read);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(Err(e.into()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Drop tx implicitly — consumer sees RecvError → EOF.
|
||||
});
|
||||
if std::panic::catch_unwind(body).is_err() {
|
||||
// Producer panicked mid-stream — surface a typed terminal
|
||||
// error so the demux thread does NOT read the dropped channel
|
||||
// as a clean EOF and truncate output. Ignore the send result:
|
||||
// if the consumer is already gone there is nothing to report.
|
||||
let _ = tx.send(Err(crate::error::Error::DemuxThreadPanicked.into()));
|
||||
}
|
||||
// Drop tx implicitly — consumer sees RecvError → EOF.
|
||||
})
|
||||
.map_err(|e| crate::error::Error::IoError { source: e })?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user