error, mux: coded remux errors; MKV writer hardening; damage rules for key checks

E9077-E9084 for engine remux and preflight refusals; MuxOptions loses
its send deadline. The MKV writer drops unwrap/expect and overflow
paths, tags SDR BT.2020 correctly, leaves out streams with no Matroska
CodecID and derives the Dolby Vision level. On-arrival damage with no
vouching partner is blanked, and a garbled head is damage, not a stop.
This commit is contained in:
Matthew Jackson
2026-09-30 15:50:46 -07:00
parent 4031513574
commit 5b9dd0fbec
30 changed files with 1387 additions and 489 deletions
+1
View File
@@ -4,6 +4,7 @@
### Added
- Error codes for the engine's remux and preflight refusals: `Error::RemuxVerifyFailed { kind: RemuxVerifyKind, path }` (E9077, `E9077: <kind> <path>`, or `E9077: runtime-mismatch <have>/<want> <path>` in seconds), `MuxIncomplete { title }` (E9078), `RemuxStagingInvalid` (E9079), `StagedCopySizeMismatch { have, want }` (E9080), `WorkerLost { op }` (E9081, `E9081: <op>`), `MultipassRequiresRaw` (E9082), `StreamLanguageUnknown { tag }` (E9083), `RemuxTargetExists { path }` (E9084). E9076 is retired.
- `probe_mkv` reads a Matroska file's Info and Tracks (muxing/writing app, declared duration, title, timestamp scale, per-track kind/codec/language) without reading a cluster; `probe_mkv_with_cues` also reports the last Cues timestamp. `parse_freemkv_version` extracts the version from freemkv's muxing-app stamp.
### Changed
+4 -5
View File
@@ -1153,13 +1153,12 @@ mod tests {
assert!(resolve_with(&keydb, &mkb, &[0x22u8; 16]).is_some());
}
// Known-answer vector: FIPS 180 SHA-1("abc").
// Known-answer vector: FIPS 180 SHA-1("abc"), as the standard's H0..H4 words.
#[test]
fn disc_hash_matches_the_sha1_known_answer() {
assert_eq!(
disc_hash_hex(&disc_hash(b"abc")),
"0xA9993E364706816ABA3E25717850C26C9CD0D89D"
);
let h: [u32; 5] = [0xA9993E36, 0x4706816A, 0xBA3E2571, 0x7850C26C, 0x9CD0D89D];
let want: String = h.iter().map(|w| format!("{w:08X}")).collect();
assert_eq!(disc_hash_hex(&disc_hash(b"abc")), format!("0x{want}"));
}
#[test]
+2 -2
View File
@@ -262,8 +262,8 @@ mod tests {
initial_class: &'static str,
}
// Optional content the minimal fixture leaves empty, laid out per the BDJO
// format (libbluray bdjo_parse.c): cache items, accessible playlists, and
// Optional content the minimal fixture leaves empty, laid out per the BD-J
// Object (BDJO) file format: cache items, accessible playlists, and
// per-app profiles / application_name bytes / parameter bytes.
#[derive(Default)]
struct Extras {
+1 -1
View File
@@ -815,7 +815,7 @@ mod tests {
// and a stuffed 13818-1 pack header moves the PES there: neither is scramble evidence.
#[test]
fn is_scrambled_pack_only_reads_an_unstuffed_13818_pack() {
// FFmpeg's MPEG-1 packet: STD buffer '01' at 0x12, then '0010' + PTS at 0x14.
// An ISO/IEC 11172-1 packet: STD buffer '01' at 0x12, then '0010' + PTS at 0x14.
let mut s = vec![0u8; 2048];
s[..12].copy_from_slice(&[0, 0, 1, 0xBA, 0x21, 0, 1, 0, 1, 0x80, 0x1B, 0x83]);
s[12..16].copy_from_slice(&[0, 0, 1, 0xE0]);
+85 -20
View File
@@ -485,6 +485,8 @@ fn apply_aacs_map(
let verify_failed = std::sync::atomic::AtomicBool::new(false);
// Per mapped key: (failed, verified) FMTS units of this read.
let tally: Vec<[AtomicUsize; 2]> = unit_keys.iter().map(|_| Default::default()).collect();
// A failing FMTS unit another held key opens: a wrong mapped key, never damage.
let opened_elsewhere = std::sync::atomic::AtomicBool::new(false);
let decrypt_one = |idx_in_buf: usize, chunk: &mut [u8]| {
let unit_lba = base_lba.saturating_add((idx_in_buf as u32) * unit_sectors);
@@ -551,15 +553,31 @@ fn apply_aacs_map(
}
return;
}
aacs::content::decrypt_unit(chunk, key);
// Correct-phase forensic verify: a failing unit is blanked; the read is judged below.
if matches!(phase, Phase::Even | Phase::Odd) {
let mut ciphertext = [0u8; aacs::content::ALIGNED_UNIT_LEN];
ciphertext.copy_from_slice(chunk);
aacs::content::decrypt_unit(chunk, key);
let clean = aacs::content::is_clean(chunk, format);
tally[key_idx][clean as usize].fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if !clean {
let opens = |k: &[u8; 16]| {
let mut c = ciphertext;
aacs::content::decrypt_unit(&mut c, k);
aacs::content::is_clean(&c, format)
};
if unit_keys
.iter()
.enumerate()
.any(|(j, (_, k))| j != key_idx && opens(k))
{
opened_elsewhere.store(true, std::sync::atomic::Ordering::Relaxed);
}
chunk.fill(0);
return;
}
} else {
aacs::content::decrypt_unit(chunk, key);
}
// KS-5 [BD] §3.10.2: CPI "shall be set to 00₂ if the data is not encrypted": a unit
// we decrypted (damaged or not) is no longer ciphertext (KU design §5.4, K-13).
@@ -590,15 +608,15 @@ fn apply_aacs_map(
return Err(crate::error::Error::DecryptFailed);
}
// A wrong key fails every unit it keys (resolve's phase probe catches it first); damage
// fails one. Two or more failures, none verifying, under one key or the read: E7013.
// fails one. E7013 per key only: two or more failures, none verifying, or a failing unit
// another held key opens. Lone failures under different keys are damage.
let tally: Vec<[usize; 2]> = tally
.into_iter()
.map(|t| t.map(|n| n.into_inner()))
.collect();
let read = tally.iter().fold([0, 0], |a, t| [a[0] + t[0], a[1] + t[1]]);
let wrong = |t: &[usize; 2]| t[0] >= WRONG_KEY_FAILURES && t[1] == 0;
let failed = read[0];
if wrong(&read) || tally.iter().any(wrong) {
let failed: usize = tally.iter().map(|t| t[0]).sum();
if opened_elsewhere.into_inner() || tally.iter().any(wrong) {
tracing::error!(
target: "freemkv::decrypt",
lba = base_lba,
@@ -619,16 +637,16 @@ fn apply_aacs_map(
Ok(failed)
}
/// FMTS units of one read that must fail their verify, none verifying, under one key or
/// across the read, before it is a wrong key rather than damage: a wrong key fails them all.
/// FMTS units of one read that must fail their verify, none verifying, under one key before
/// it is a wrong key rather than damage: a wrong key fails them all.
const WRONG_KEY_FAILURES: usize = 2;
/// Blank the damaged BD-TS units of `buf` (read at `base_lba` on the caller's unit grid):
/// zero-fill them, like a sweep's unread sector, and return how many. Damaged: flagged (CPI)
/// with no TS sync in the seed, or a zero-filled first sector over a non-TS rest. No key opens
/// either, so it is read damage, never a key verdict and never E7013, whether the unit is
/// alone, in a cluster, or a read cut off its grid (1.7.7 muxed through all three). Only units
/// `covered` (keyed or proven on arrival) are judged. A garbage seed that keeps 0x47 at byte 4
/// zero-fill them, like a sweep's unread sector, and return how many. Damaged: no TS sync at
/// byte 4, flagged (CPI) or not; unflagged only when not clean TS either (a zeroed head over
/// clear TS is a sweep hole). No key opens them: read damage, never a key verdict or E7013,
/// alone, clustered, or off the grid (1.7.7 muxed through all three). Only units `covered`
/// (keyed or proven on arrival) are judged. A flagged garbage seed that keeps 0x47 at byte 4
/// (~1 in 256) is not caught here: it decrypts as garbage, or on arrival reads as unopened.
pub(crate) fn blank_damaged_units(
buf: &mut [u8],
@@ -646,18 +664,15 @@ pub(crate) fn blank_damaged_units(
if !covered(lba) {
continue;
}
let lost = if aacs::content::aacs_unit_seed_encrypted(unit, format) {
// KS-4 [BD] §3.10.1: "The first 16 bytes of each Aligned Unit is used as the seed";
// KS-2: each source packet is "the TP_extra_header (4 bytes) and an MPEG Transport
// packet", so an intact seed carries the TS sync at byte 4 (KS-22 corroborates).
// packet", so an intact unit, clear or not, has the TS sync at byte 4 (KS-22 too).
let lost = if aacs::content::aacs_unit_seed_encrypted(unit, format) {
!aacs::content::aacs_unit_on_grid(unit, format)
} else {
// A zero-filled head took the seed (KS-4); a non-zero rest that is not TS is
// ciphertext no key opens. A trailing zero run keeps its head and decrypts.
let (head, rest) = unit.split_at(crate::consts::SECTOR_BYTES);
head.iter().all(|&b| b == 0)
&& rest.iter().any(|&b| b != 0)
&& !aacs::content::is_clean(unit, format)
// A zeroed or garbled head over a rest that is not TS: ciphertext no key opens.
// A trailing zero run keeps its head; an all-zero unit is clean (a sweep hole).
unit[4] != 0x47 && !aacs::content::is_clean(unit, format)
};
if lost {
damaged.push(i);
@@ -2012,6 +2027,56 @@ mod tests {
));
}
/// Option A (E7013): one damaged forensic unit under each of two keys (garbled heads that
/// kept flag and sync), no other forensic unit in the read, is damage: blanked, never E7013.
#[test]
fn mapped_phase_lone_damage_under_two_keys_is_blanked_not_e7013() {
use crate::disc::ContentFormat;
let ul = aacs::content::ALIGNED_UNIT_LEN;
let usz = (ul / 2048) as u32;
let (key_a, key_b) = ([0xAAu8; 16], [0xBBu8; 16]);
let mut buf = vec![0u8; 4 * ul];
for (i, k) in [key_a, key_a, key_b, key_b].iter().enumerate() {
let mut u = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut u, k);
if i % 2 == 0 {
crate::test_util::damage_unit_seed(&mut u);
u[4] = 0x47;
}
buf[i * ul..(i + 1) * ul].copy_from_slice(&u);
}
let keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key_a), (1, key_b)],
format: ContentFormat::BdTs,
};
let map = AacsKeyMap::from_ranges_phased(vec![
(0, 2 * usz, 0, Phase::Even),
(2 * usz, 4 * usz, 1, Phase::Even),
]);
let blanked = decrypt_sectors_mapped_in_content(&mut buf, &keys, 0, &map, None)
.expect("damage, not E7013");
assert_eq!(blanked, 2);
assert!(buf[..ul].iter().all(|&b| b == 0));
assert!(buf[2 * ul..3 * ul].iter().all(|&b| b == 0));
}
/// Option A (E7013): a garbage seed with its CPI bits clear lost the TS sync every BD-TS
/// unit carries at byte 4 (KS-2): damage, blanked and counted; clear and zero units are not.
#[test]
fn blank_damaged_units_counts_a_garbage_seed_with_cpi_clear() {
use crate::disc::ContentFormat;
let ul = aacs::content::ALIGNED_UNIT_LEN;
let mut garbage = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut garbage, &[0xAAu8; 16]);
crate::test_util::damage_unit_seed(&mut garbage);
garbage[0] &= 0x3F;
let mut buf = [garbage, clear_ts_unit(), vec![0u8; ul]].concat();
let n = blank_damaged_units(&mut buf, 0, ContentFormat::BdTs, &|_| true);
assert_eq!(n, 1);
assert!(buf[..ul].iter().all(|&b| b == 0));
assert_eq!(&buf[ul..2 * ul], &clear_ts_unit()[..]);
}
/// Phase::All (multi-CPS / base) decrypts EVERY unit and never runs the verify
/// — the common-disc path is byte-for-byte unchanged.
#[test]
+80 -20
View File
@@ -3266,10 +3266,10 @@ pub(crate) fn mapfile_path_for(iso_path: &std::path::Path) -> std::path::PathBuf
impl Disc {
/// Path to the mapfile for a given output path.
///
/// For `/dev/null` output, returns
/// `{temp_dir}/{volume_id_or_title}-{pid}.mapfile` (temp dir is
/// `TMPDIR`-aware and cross-platform). For regular files, returns
/// `{path}.mapfile`.
/// For `/dev/null` output, returns `{dir}/{volume_id_or_title}.mapfile`
/// where `{dir}` is a directory this process created under the temp dir
/// (owner-only on Unix, unpredictable name, stable for the process). For
/// regular files, returns `{path}.mapfile`.
pub fn mapfile_for(&self, path: &std::path::Path) -> std::path::PathBuf {
if path.as_os_str() == "/dev/null" {
let name: String = self
@@ -3285,15 +3285,60 @@ impl Disc {
}
})
.collect();
// Per-process, so a stale or concurrent same-label run is never resumed.
let pid = std::process::id();
std::env::temp_dir().join(format!("{name}-{pid}.mapfile"))
null_mapfile_dir().join(format!("{name}.mapfile"))
} else {
mapfile_path_for(path)
}
}
}
// Private per-process dir for `/dev/null` mapfiles. Created fresh (never
// reused) so another user cannot pre-create, plant or symlink the path.
fn null_mapfile_dir() -> std::path::PathBuf {
static DIR: std::sync::Mutex<Option<std::path::PathBuf>> = std::sync::Mutex::new(None);
let mut dir = DIR
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(d) = dir.as_ref() {
return d.clone();
}
let mut last = std::path::PathBuf::new();
for _ in 0..8 {
last = std::env::temp_dir().join(format!(
"freemkv-{}-{:016x}",
std::process::id(),
random_u64()
));
match create_private_dir(&last) {
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
_ => break,
}
}
// Remembered even if not created, so every call agrees; a mapfile
// under a dir that failed to create then fails to open.
*dir = Some(last.clone());
last
}
fn create_private_dir(path: &std::path::Path) -> std::io::Result<()> {
let mut b = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
b.create(path)
}
// OS-seeded, per-call random value from std's SipHash keys.
fn random_u64() -> u64 {
use std::hash::{BuildHasher, Hasher};
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
h.write_u128(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos()),
);
h.finish()
}
const MAX_BATCH_SECTORS: u16 = 510;
const DEFAULT_BATCH_SECTORS_OPTICAL: u16 = 60;
const DEFAULT_BATCH_SECTORS_BLOCK: u16 = 8192;
@@ -8727,33 +8772,48 @@ mod tests {
}
// /dev/null output cannot host a sibling mapfile, so it's named from the
// disc and placed in the temp dir, sanitized to [A-Za-z0-9-_] since it's
// used verbatim as a filename.
// disc, sanitized to [A-Za-z0-9-_] since it's used verbatim as a filename.
#[test]
fn mapfile_for_dev_null_sanitizes_the_disc_name_into_a_temp_path() {
fn mapfile_for_dev_null_sanitizes_the_disc_name() {
let mut disc = make_test_disc(1_000, "VOLUME_ID");
// Keeps: alphanumeric, '-', '_'. Replaces: space, '!', non-ASCII.
disc.meta_title = Some("A-B_c1 d!é".into());
let p = disc.mapfile_for(std::path::Path::new("/dev/null"));
assert_eq!(
disc.mapfile_for(std::path::Path::new("/dev/null")),
std::env::temp_dir().join(format!("A-B_c1_d__-{}.mapfile", std::process::id()))
p.file_name().and_then(|n| n.to_str()),
Some("A-B_c1_d__.mapfile")
);
// The UDF volume id is the fallback when the disc carries no META/DL
// title.
// The UDF volume id is the fallback when the disc has no META/DL title.
disc.meta_title = None;
let p = disc.mapfile_for(std::path::Path::new("/dev/null"));
assert_eq!(
disc.mapfile_for(std::path::Path::new("/dev/null")),
std::env::temp_dir().join(format!("VOLUME_ID-{}.mapfile", std::process::id()))
p.file_name().and_then(|n| n.to_str()),
Some("VOLUME_ID.mapfile")
);
}
// Two runs (or same-label discs) must not share a /dev/null mapfile.
// Not a guessable shared-temp path: a fresh dir this process created
// (owner-only on Unix), stable across calls so passes find their mapfile.
#[test]
fn mapfile_for_dev_null_is_per_process() {
fn mapfile_for_dev_null_is_in_a_private_per_process_dir() {
let disc = make_test_disc(1_000, "BDROM");
let name = disc.mapfile_for(std::path::Path::new("/dev/null"));
let name = name.file_name().and_then(|n| n.to_str()).unwrap_or("");
let p = disc.mapfile_for(std::path::Path::new("/dev/null"));
assert_eq!(p, disc.mapfile_for(std::path::Path::new("/dev/null")));
let dir = p.parent().unwrap_or(std::path::Path::new(""));
assert_ne!(dir, std::env::temp_dir().as_path());
assert!(dir.starts_with(std::env::temp_dir()), "{}", dir.display());
let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
assert!(name.contains(&std::process::id().to_string()), "{name}");
let md = std::fs::symlink_metadata(dir).expect("dir created");
assert!(md.is_dir());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(md.permissions().mode() & 0o077, 0);
}
// Writable by us: the mapfile can actually be created there.
std::fs::write(&p, b"").expect("mapfile writable");
let _ = std::fs::remove_file(&p);
}
// STN audio_format 12 is the stereo+multichannel combo, not a 7.1 layout.
+232 -1
View File
@@ -317,6 +317,23 @@ pub const E_TIMED_OUT: u16 = 9073;
pub const E_MPG_NO_VIDEO_TRACK: u16 = 9074;
/// `mpg://` reached end of input with access units no pack could take.
pub const E_MPG_UNPACKETIZED: u16 = 9075;
// 9076: burned/retired — do not reuse.
/// A finished remux output failed its read-back check. See [`RemuxVerifyKind`].
pub const E_REMUX_VERIFY_FAILED: u16 = 9077;
/// A title's mux returned without completing and no Stop was requested.
pub const E_MUX_INCOMPLETE: u16 = 9078;
/// A remux staging path collides with the target or its own `.partial`.
pub const E_REMUX_STAGING_INVALID: u16 = 9079;
/// Copying a staged output into place wrote a different byte count than the source holds.
pub const E_STAGED_COPY_SIZE_MISMATCH: u16 = 9080;
/// A remux worker thread (`op`: "copy", "verify") was lost before it reported.
pub const E_WORKER_LOST: u16 = 9081;
/// A multipass rip was asked to decrypt: multipass recovers a raw whole-disc image.
pub const E_MULTIPASS_REQUIRES_RAW: u16 = 9082;
/// A requested audio/subtitle language tag resolves to no known language.
pub const E_STREAM_LANGUAGE_UNKNOWN: u16 = 9083;
/// A remux target already exists and replacing it was not requested.
pub const E_REMUX_TARGET_EXISTS: u16 = 9084;
// ── Error enum ──────────────────────────────────────────────────────────────
@@ -936,6 +953,64 @@ pub enum Error {
MpgNoVideoTrack,
/// `mpg://` end of input left access units unwritten. See [`E_MPG_UNPACKETIZED`].
MpgUnpacketized,
/// A finished remux output failed its read-back check. See [`E_REMUX_VERIFY_FAILED`].
/// `path` is the file that failed.
RemuxVerifyFailed {
kind: RemuxVerifyKind,
path: String,
},
/// A title's mux returned incomplete without a Stop. `title` is 1-based.
MuxIncomplete {
title: usize,
},
/// See [`E_REMUX_STAGING_INVALID`].
RemuxStagingInvalid,
/// A staged copy wrote `have` bytes where the source holds `want`.
StagedCopySizeMismatch {
have: u64,
want: u64,
},
/// See [`E_WORKER_LOST`]. `op` is a stable identifier, like [`Error::TimedOut`]'s.
WorkerLost {
op: &'static str,
},
/// See [`E_MULTIPASS_REQUIRES_RAW`].
MultipassRequiresRaw,
/// `tag` is the caller's unresolvable language tag, verbatim.
StreamLanguageUnknown {
tag: String,
},
/// See [`E_REMUX_TARGET_EXISTS`].
RemuxTargetExists {
path: String,
},
}
/// Why a finished remux output failed read-back ([`Error::RemuxVerifyFailed`]).
/// Displayed as a stable identifier ([`RemuxVerifyKind::key`]), never prose.
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum RemuxVerifyKind {
/// The output file is zero bytes.
Empty,
/// The output carries no tracks.
NoTracks,
/// The title declares a runtime but the output records none.
NoRuntime,
/// The output's runtime is outside tolerance of the title's declared runtime.
RuntimeMismatch { have_secs: f64, want_secs: f64 },
}
impl RemuxVerifyKind {
/// Stable, language-neutral identifier for this kind.
pub fn key(&self) -> &'static str {
match self {
RemuxVerifyKind::Empty => "empty",
RemuxVerifyKind::NoTracks => "no-tracks",
RemuxVerifyKind::NoRuntime => "no-runtime",
RemuxVerifyKind::RuntimeMismatch { .. } => "runtime-mismatch",
}
}
}
impl Error {
@@ -1080,6 +1155,14 @@ impl Error {
Error::MpgUnpacketized => E_MPG_UNPACKETIZED,
Error::DirImageFileChanged { .. } => E_DIR_IMAGE_FILE_CHANGED,
Error::DirImageTooLarge => E_DIR_IMAGE_TOO_LARGE,
Error::RemuxVerifyFailed { .. } => E_REMUX_VERIFY_FAILED,
Error::MuxIncomplete { .. } => E_MUX_INCOMPLETE,
Error::RemuxStagingInvalid => E_REMUX_STAGING_INVALID,
Error::StagedCopySizeMismatch { .. } => E_STAGED_COPY_SIZE_MISMATCH,
Error::WorkerLost { .. } => E_WORKER_LOST,
Error::MultipassRequiresRaw => E_MULTIPASS_REQUIRES_RAW,
Error::StreamLanguageUnknown { .. } => E_STREAM_LANGUAGE_UNKNOWN,
Error::RemuxTargetExists { .. } => E_REMUX_TARGET_EXISTS,
}
}
}
@@ -1260,9 +1343,32 @@ impl std::fmt::Display for Error {
}
// `op` is a stable, language-neutral identifier (e.g. "verify",
// "artifact_lock"), not translatable prose.
Error::TimedOut { op } => write!(f, "E{}: {op}", self.code()),
Error::TimedOut { op } | Error::WorkerLost { op } => {
write!(f, "E{}: {op}", self.code())
}
Error::BusStreamUnmapped { files } => write!(f, "E{}: {files}", self.code()),
Error::ImageScoped { path } => write!(f, "E{}: {path}", self.code()),
Error::RemuxVerifyFailed { kind, path } => match kind {
RemuxVerifyKind::RuntimeMismatch {
have_secs,
want_secs,
} => write!(
f,
"E{}: {} {have_secs:.1}/{want_secs:.1} {path}",
self.code(),
kind.key()
),
_ => write!(f, "E{}: {} {path}", self.code(), kind.key()),
},
Error::MuxIncomplete { title } => write!(f, "E{}: {title}", self.code()),
Error::StagedCopySizeMismatch { have, want } => {
write!(f, "E{}: {have}/{want}", self.code())
}
// `tag` is raw user input: escape control characters.
Error::StreamLanguageUnknown { tag } => {
write!(f, "E{}: {}", self.code(), tag.escape_debug())
}
Error::RemuxTargetExists { path } => write!(f, "E{}: {path}", self.code()),
_ => write!(f, "E{}", self.code()),
}
}
@@ -1398,6 +1504,12 @@ impl From<Error> for std::io::Error {
E_DIR_IMAGE_FILE_CHANGED => std::io::ErrorKind::InvalidData,
// 9073 TimedOut: a StallTimer expired with no forward progress.
E_TIMED_OUT => std::io::ErrorKind::TimedOut,
// Remux/engine codes keep the kinds their io::Error texts carried.
E_REMUX_VERIFY_FAILED | E_STAGED_COPY_SIZE_MISMATCH => std::io::ErrorKind::InvalidData,
E_REMUX_STAGING_INVALID | E_MULTIPASS_REQUIRES_RAW | E_STREAM_LANGUAGE_UNKNOWN => {
std::io::ErrorKind::InvalidInput
}
E_REMUX_TARGET_EXISTS => std::io::ErrorKind::AlreadyExists,
_ => std::io::ErrorKind::Other,
};
// Carry the typed value itself as the payload, not its stringification.
@@ -1768,6 +1880,18 @@ mod tests {
.code(),
Error::BusStreamUnmapped { files: "x".into() }.code(),
Error::ImageScoped { path: "x".into() }.code(),
Error::RemuxVerifyFailed {
kind: RemuxVerifyKind::Empty,
path: "/m/a.mkv".into(),
}
.code(),
Error::MuxIncomplete { title: 1 }.code(),
Error::RemuxStagingInvalid.code(),
Error::StagedCopySizeMismatch { have: 0, want: 1 }.code(),
Error::WorkerLost { op: "copy" }.code(),
Error::MultipassRequiresRaw.code(),
Error::StreamLanguageUnknown { tag: "x".into() }.code(),
Error::RemuxTargetExists { path: "x".into() }.code(),
];
let mut sorted = codes.to_vec();
sorted.sort();
@@ -2346,6 +2470,113 @@ mod tests {
assert!(declared_error_codes().contains(&("E_AACS_VID_NEEDS_DISC", E_AACS_VID_NEEDS_DISC)));
}
/// Engine remux/core codes E9077-E9084: code, exact Display and ErrorKind for
/// each, plus a typed io::Error round trip that keeps the fields.
#[test]
fn engine_remux_codes_display_and_kind() {
use std::io::ErrorKind;
let cases: Vec<(Error, u16, &str, ErrorKind)> = vec![
(
Error::RemuxVerifyFailed {
kind: RemuxVerifyKind::Empty,
path: "/m/a.mkv".into(),
},
9077,
"E9077: empty /m/a.mkv",
ErrorKind::InvalidData,
),
(
Error::RemuxVerifyFailed {
kind: RemuxVerifyKind::NoTracks,
path: "/m/a.mkv".into(),
},
9077,
"E9077: no-tracks /m/a.mkv",
ErrorKind::InvalidData,
),
(
Error::RemuxVerifyFailed {
kind: RemuxVerifyKind::NoRuntime,
path: "/m/a.mkv".into(),
},
9077,
"E9077: no-runtime /m/a.mkv",
ErrorKind::InvalidData,
),
(
Error::RemuxVerifyFailed {
kind: RemuxVerifyKind::RuntimeMismatch {
have_secs: 5400.04,
want_secs: 7200.0,
},
path: "/m/a.mkv".into(),
},
9077,
"E9077: runtime-mismatch 5400.0/7200.0 /m/a.mkv",
ErrorKind::InvalidData,
),
(
Error::MuxIncomplete { title: 3 },
9078,
"E9078: 3",
ErrorKind::Other,
),
(
Error::RemuxStagingInvalid,
9079,
"E9079",
ErrorKind::InvalidInput,
),
(
Error::StagedCopySizeMismatch { have: 10, want: 12 },
9080,
"E9080: 10/12",
ErrorKind::InvalidData,
),
(
Error::WorkerLost { op: "verify" },
9081,
"E9081: verify",
ErrorKind::Other,
),
(
Error::MultipassRequiresRaw,
9082,
"E9082",
ErrorKind::InvalidInput,
),
(
Error::StreamLanguageUnknown {
tag: "xx\nyy".into(),
},
9083,
"E9083: xx\\nyy",
ErrorKind::InvalidInput,
),
(
Error::RemuxTargetExists {
path: "/m/a.mkv".into(),
},
9084,
"E9084: /m/a.mkv",
ErrorKind::AlreadyExists,
),
];
for (e, code, shown, kind) in cases {
assert_eq!(e.code(), code, "{e:?}");
assert_eq!(e.to_string(), shown, "{e:?}");
let io: std::io::Error = e.into();
assert_eq!(io.kind(), kind, "E{code}");
assert_eq!(error_code(&io), Some(code));
}
let back: Error =
std::io::Error::from(Error::StagedCopySizeMismatch { have: 1, want: 2 }).into();
assert!(matches!(
back,
Error::StagedCopySizeMismatch { have: 1, want: 2 }
));
}
// is_scsi_transport_failure is true for the 0xFF sentinel and non-SCSI
// dead-bus faults (IoError, DeviceNotFound), never for CHECK CONDITION.
// Mutation: dropping the IoError/DeviceNotFound arm lets a dead bus zero-fill.
-3
View File
@@ -19,9 +19,6 @@ use crate::error::{Error, Result};
/// The longest a halt-aware wait sleeps between token checks (§2.1).
pub const WAIT_SLICE: Duration = Duration::from_millis(20);
/// Alias of [`WAIT_SLICE`], kept until ST-X1b removes it.
pub const POLL_INTERVAL: Duration = WAIT_SLICE;
// The one place the token's orderings live, shared with the loom model (LT3).
trait AtomicFlag {
fn store_flag(&self, v: bool, o: Ordering);
+3 -4
View File
@@ -60,11 +60,10 @@ fn from_arc_as_arc_are_pointer_exact() {
assert!(halt2.is_cancelled());
}
/// §2.1: `POLL_INTERVAL` becomes an alias of the 20 ms `WAIT_SLICE`.
/// §2.1: the halt-aware check slice is 20 ms.
#[test]
fn poll_interval_aliases_wait_slice() {
fn wait_slice_is_20ms() {
assert_eq!(WAIT_SLICE, Duration::from_millis(20));
assert_eq!(POLL_INTERVAL, WAIT_SLICE);
}
/// LT1: `check` → `Halted` after cancel; `wait(10 s)` returns `Halted` ≤ 1 s after a
@@ -596,7 +595,7 @@ fn scoped_exec_outside() -> bool {
const LT7_RUNS: usize = 20;
// Cancel this long after the waiter starts: well inside a 250 ms slice, so the old
// `POLL_INTERVAL` wakes ~220 ms late on every run, far past the 150 ms bound.
// 250 ms poll interval wakes ~220 ms late on every run, far past the 150 ms bound.
const LT7_CANCEL_AFTER: Duration = Duration::from_millis(30);
const LT7_BOUND: Duration = Duration::from_millis(150);
const BUDGET: Duration = Duration::from_secs(10);
+1 -1
View File
@@ -215,7 +215,7 @@ mod tests {
});
let elapsed = started.elapsed();
assert!(matches!(r, Err(BoundedError::Timeout)));
// Must bail near the 100ms deadline (one POLL_INTERVAL slack at
// Must bail near the 100ms deadline (one WAIT_SLICE slack at
// most), not after the 3s op. Allow generous CI slack but stay
// well under the op's 3s sleep.
assert!(
+88 -48
View File
@@ -3,9 +3,11 @@
//!
//! A partner is another readable non-segment `Enc` unit of the piece, from the batch, else one
//! backward and one forward side read (`recovery = false`, never retried, failures swallowed).
//! A held key opening the unit and a partner is `Proven`; with no partner, `Provisional` until
//! the next readable unit confirms it or stops (key conflict). A unit no held key opens stops
//! E7022 / E7032, unless a held key opens a batch partner: then it is damage, blanked.
//! A held key opening the unit and a partner is `Proven`; else `Provisional` until another
//! readable unit confirms it. A unit the key does not open is damage, blanked and counted,
//! when a held key opens a partner or no partner is readable; it stops E7022 / E7032 when
//! another held key opens it, when partners are readable and none opens, or when a second
//! partnerless unit (another LBA) opens under no key before any proof; never in a clear piece.
use super::{Proof, ProofCache, ResolvedKeySet, StopKind};
use crate::aacs::content::{
@@ -17,6 +19,7 @@ use crate::disc::ContentFormat;
use crate::error::{Error, Result};
use crate::sector::SectorSource;
use crate::whole_disc::UNIT;
use std::sync::Mutex;
/// Units a side read covers at most, each way (KU §2.4: "up to 32 units").
const SIDE_UNITS: u64 = 32;
@@ -26,13 +29,15 @@ const SIDE_UNITS: u64 = 32;
pub(crate) struct Arrival {
// (start, end, anchor, piece index), sorted by start, disjoint.
spans: Vec<(u32, u32, u64, usize)>,
// (piece id, candidate slot).
pieces: Vec<(u32, Option<usize>)>,
// (piece id, candidate slot, resolved clear).
pieces: Vec<(u32, Option<usize>, bool)>,
base: usize,
segments: Vec<(u32, u32)>,
cache: ProofCache,
format: ContentFormat,
stop: StopKind,
// Per piece: the LBA of a partnerless unit no held key opened since the last proof.
strikes: Mutex<Vec<Option<u32>>>,
}
impl Arrival {
@@ -41,7 +46,7 @@ impl Arrival {
let mut spans = Vec::new();
let mut pieces = Vec::with_capacity(i.arrival.len());
for (p, piece) in i.arrival.iter().enumerate() {
pieces.push((piece.id, piece.candidate));
pieces.push((piece.id, piece.candidate, piece.clear));
for &(s, n, anchor) in &piece.spans {
spans.push((s, s.saturating_add(n), anchor, p));
}
@@ -57,6 +62,7 @@ impl Arrival {
cache: i.proofs.clone(),
format: i.content_format,
stop,
strikes: Mutex::new(vec![None; i.arrival.len()]),
}
}
@@ -108,7 +114,7 @@ impl Arrival {
/// Prove and decrypt the lazy-piece units of `buf`, read at `lba` on the unit grid.
/// Keyed pieces are left to the key map; clear and segment units are untouched. Returns
/// how many damaged units it blanked (see [`is_damage`](Self::is_damage)).
/// how many damaged units it blanked (see [`unopened`](Self::unopened)).
pub(crate) fn process(
&self,
inner: &mut dyn SectorSource,
@@ -130,29 +136,23 @@ impl Arrival {
if self.in_segment(at) || !aacs_unit_encrypted(&buf[range.clone()], self.format) {
continue;
}
let (id, candidate) = self.pieces[span.3];
let (id, candidate, _) = self.pieces[span.3];
let slot = match self.cache.get(id) {
Some(Proof::Proven(s)) => s,
Some(Proof::Provisional(s, from)) => {
// KU §2.4: the next readable unit confirms a provisional key or stops;
// one no held key opens while the key opens a batch partner is damage.
// KU §2.4: another readable unit confirms a provisional key; one it does
// not open is damage or a stop (`unopened`).
if self.opens(&buf[range.clone()], &unit_keys[s].1) {
if at != from {
self.cache.set(id, Proof::Proven(s));
}
self.clear_strike(span.3);
s
} else if self.is_damage(
&buf[range.clone()],
&self.batch_partners(lba, buf, u, span.3),
candidate,
&[s],
unit_keys,
) {
} else {
self.unopened(inner, lba, buf, u, &[s], unit_keys)?;
buf[range].fill(0);
blanked += 1;
continue;
} else {
return Err(self.stop(at, "provisional key contradicted"));
}
}
None => match self.prove(inner, lba, buf, u, span, candidate, unit_keys)? {
@@ -171,7 +171,7 @@ impl Arrival {
target: "freemkv::keys",
lba,
units = blanked,
"damaged AACS unit (no held key opens it, one opens its partners): blanked"
"damaged AACS unit (no held key opens it): blanked, the rip carries on"
);
}
Ok(blanked)
@@ -183,24 +183,69 @@ impl Arrival {
candidate.into_iter().chain(rest)
}
/// Is `unit` damage rather than a key failure? KS-4 \[BD\] §3.10.1: "The first 16 bytes of
/// each Aligned Unit is used as the seed", and a garbled head can keep its CPI flag and TS
/// sync. A wrong key opens no unit of the piece; damage fails one. So: no held key opens
/// it and one of `witnesses` opens a batch partner (no side read: KU §2.4 stops an
/// unopenable unit before any). A blanked unit proves nothing itself.
fn is_damage(
/// Unit `u`, which no key of `witnesses` opens: `Ok` is damage, to blank. KS-4 \[BD\]
/// §3.10.1: "The first 16 bytes of each Aligned Unit is used as the seed", and a garbled
/// head can keep its CPI flag and TS sync. A wrong key opens no unit of the piece; damage
/// fails one. So it stops only when another held key opens `u`, when readable partners
/// exist and no witness opens one, or at a second partnerless unopened unit.
fn unopened(
&self,
unit: &[u8],
partners: &[Vec<u8>],
candidate: Option<usize>,
inner: &mut dyn SectorSource,
lba: u32,
buf: &[u8],
u: usize,
witnesses: &[usize],
unit_keys: &[(u32, [u8; 16])],
) -> bool {
let opens = |u: &[u8], s: usize| self.opens(u, &unit_keys[s].1);
!self.held(candidate).any(|s| opens(unit, s))
&& partners
.iter()
.any(|p| witnesses.iter().any(|&s| opens(p, s)))
) -> Result<()> {
let at = lba.saturating_add(u as u32 * UNIT as u32);
let Some(span) = self.span_at(at) else {
return Ok(());
};
let candidate = self.pieces[span.3].1;
let unit = &buf[u * ALIGNED_UNIT_LEN..(u + 1) * ALIGNED_UNIT_LEN];
let opens = |p: &[u8], s: usize| self.opens(p, &unit_keys[s].1);
if self.held(candidate).any(|s| opens(unit, s)) {
return Err(self.stop(at, "another held key opens the unit"));
}
// No key held for a piece not resolved clear (a mux given no set): nothing to prove.
if self.base == 0 && !self.pieces[span.3].2 {
return Err(self.stop(at, "no key held"));
}
// A clear piece has no partners and no key to be wrong: a flagged unit is damage.
if self.pieces[span.3].2 {
return Ok(());
}
let vouch = |ps: &[Vec<u8>]| ps.iter().any(|p| witnesses.iter().any(|&s| opens(p, s)));
let batch = self.batch_partners(lba, buf, u, span.3);
if vouch(&batch) {
return Ok(());
}
let side = self.side_partners(inner, at, span);
if vouch(&side) {
return Ok(());
}
if !batch.is_empty() || !side.is_empty() {
return Err(self.stop(at, "no held key opens the unit or a partner"));
}
let mut strikes = self.strikes.lock().unwrap_or_else(|e| e.into_inner());
match strikes.get_mut(span.3) {
Some(Some(prev)) if *prev != at => {
Err(self.stop(at, "a second partnerless unit no held key opens"))
}
Some(slot) => {
*slot = Some(at);
Ok(())
}
None => Ok(()),
}
}
// A unit of piece `piece` opened: forget its partnerless failure.
fn clear_strike(&self, piece: usize) {
let mut strikes = self.strikes.lock().unwrap_or_else(|e| e.into_inner());
if let Some(slot) = strikes.get_mut(piece) {
*slot = None;
}
}
// Unit `u`'s partners already in the batch, both ways (KU §2.4 step 1).
@@ -223,7 +268,7 @@ impl Arrival {
}
// Find the held key for unit `u` of `buf` (KU §2.4 steps 1–2) and record it. `None`:
// no held key opens U but one opens a partner, so U is damage (`is_damage`), not proof.
// no held key opens U and `unopened` judged it damage, not proof.
#[allow(clippy::too_many_arguments)]
fn prove(
&self,
@@ -244,24 +289,19 @@ impl Arrival {
.collect();
if openers.is_empty() {
let held: Vec<usize> = self.held(candidate).collect();
let partners = self.batch_partners(lba, buf, u, span.3);
if self.is_damage(unit, &partners, candidate, &held, unit_keys) {
self.unopened(inner, lba, buf, u, &held, unit_keys)?;
return Ok(None);
}
return Err(self.stop(at, "no held key opens the unit"));
}
self.clear_strike(span.3);
// Step 1: partners already in the batch, both ways; then the side reads, also when
// no batch partner opens (they may be damaged heads that kept their sync).
let find = |partners: &[Vec<u8>]| {
let opens = |s: usize| partners.iter().any(|p| self.opens(p, &unit_keys[s].1));
openers.iter().copied().find(|&s| opens(s))
};
let batch = self.batch_partners(lba, buf, u, span.3);
let (mut proven, mut any_partner) = (find(&batch), !batch.is_empty());
let mut proven = find(&self.batch_partners(lba, buf, u, span.3));
if proven.is_none() {
let side = self.side_partners(inner, at, span);
any_partner |= !side.is_empty();
proven = find(&side);
proven = find(&self.side_partners(inner, at, span));
}
let id = self.pieces[span.3].0;
match proven {
@@ -269,13 +309,13 @@ impl Arrival {
self.cache.set(id, Proof::Proven(s));
Ok(Some(s))
}
None if !any_partner => {
// No readable partner: provisional one-unit proof, false-accept ≈ pool × 1e-5.
None => {
// No partner opens (none readable, or all damaged): a provisional one-unit
// proof, false-accept ≈ pool × 1e-5; a unit the key fails decides later.
tracing::debug!(target: "freemkv::keys", lba = at, "provisional one-unit proof");
self.cache.set(id, Proof::Provisional(openers[0], at));
Ok(Some(openers[0]))
}
None => Err(self.stop(at, "no held key opens the unit and a partner")),
}
}
+3
View File
@@ -218,6 +218,8 @@ pub(crate) struct ArrivalPiece {
pub(crate) id: u32,
pub(crate) spans: Vec<UnitSpan>,
pub(crate) candidate: Option<usize>,
// Resolved clear (every probe unflagged), not left to a key.
pub(crate) clear: bool,
}
// Everything a set holds. Key bytes and the VID never leave the crate.
@@ -650,6 +652,7 @@ impl ResolvedKeySet {
id: s,
spans: vec![span],
candidate: None,
clear: false,
});
}
i
+1
View File
@@ -1045,6 +1045,7 @@ fn build(
Verdict::Lazy(c) => c,
_ => None,
},
clear: p.verdict == Verdict::Clear,
});
}
Verdict::Missing | Verdict::Ask => {}
+99 -4
View File
@@ -1349,6 +1349,98 @@ fn on_arrival_damaged_batch_partners_fall_back_to_side_reads() {
assert_eq!(set.proof_cache().get(fx.file(1).0), Some(Proof::Proven(1)));
}
/// Option A (E7013): a unit that opens, whose only partners are damaged (a garbled head that
/// kept its sync) and whose side reads find none, is a provisional proof, never E7022.
#[test]
fn on_arrival_only_damaged_partners_is_provisional_never_e7022() {
let (fx, set, src) = lazy_b_damaged(&[K1, K2], |fx| garble_keeping_sync(fx, 1, 5));
let (b, n) = fx.file(1);
src.kill(b, fx.unit(1, 4));
src.kill(fx.unit(1, 6), b + n);
let mut r = set.title_reader(&fx.disc, 0, src).unwrap();
let mut want = fx.plain(fx.unit(1, 4), 2);
want[ALIGNED_UNIT_LEN..].fill(0);
assert_eq!(read(&mut r, &fx, 1, 4, 2).expect("damage, not E7022"), want);
assert_eq!(r.blanked_units(), 1);
assert_eq!(
set.proof_cache().get(b),
Some(Proof::Provisional(1, fx.unit(1, 4)))
);
}
/// Option A (E7013): a damaged unit read alone after a provisional proof, with no readable
/// partner to vouch, is blanked and counted, never E7022; a second one is a stop.
#[test]
fn on_arrival_lone_damage_after_a_provisional_proof_is_blanked() {
let (fx, set, src) = lazy_b_damaged(&[K1, K2], |fx| {
garble_keeping_sync(fx, 1, 7);
garble_keeping_sync(fx, 1, 8);
});
let (b, n) = fx.file(1);
let isolate = |u: u32| {
src.heal();
src.kill(b, fx.unit(1, u));
src.kill(fx.unit(1, u + 1), b + n);
};
isolate(5);
let mut r = set.title_reader(&fx.disc, 0, src.clone()).unwrap();
read(&mut r, &fx, 1, 5, 1).expect("provisional under K2");
isolate(7);
let got = read(&mut r, &fx, 1, 7, 1).expect("damage does not contradict a key");
assert_eq!(got, vec![0u8; ALIGNED_UNIT_LEN]);
assert_eq!(r.blanked_units(), 1);
assert_eq!(
set.proof_cache().get(b),
Some(Proof::Provisional(1, fx.unit(1, 5)))
);
isolate(8);
assert_eq!(
code(read(&mut r, &fx, 1, 8, 1)),
E7022,
"two intact seeds no key opens"
);
}
/// Option A (E7013): a garbled head that kept its CPI flag and sync inside a clear piece is
/// damage: blanked and counted, never E7032.
#[test]
fn on_arrival_a_garbled_head_in_a_clear_piece_is_blanked_never_e7032() {
let fx = fixture(&[stream(1, 10, None), stream(2, 10, None)], 2, &[&[0, 1]]);
let calls = Calls::default();
let set = resolve(&fx, KeyScope::WholeDisc, &[Spec::keydb(&[K1], &calls)]).unwrap();
let mut fx = fx;
garble_keeping_sync(&mut fx, 0, 4);
let mut w = set.whole_disc_reader(&fx.disc, fx.source(), None).unwrap();
for (u, n) in [(4, 1), (3, 3)] {
let mut buf = vec![0u8; n as usize * ALIGNED_UNIT_LEN];
w.read_sectors(fx.unit(0, u), (n * 3) as u16, &mut buf, true)
.expect("damage, not E7032");
let at = (4 - u) as usize * ALIGNED_UNIT_LEN;
assert!(buf[at..at + ALIGNED_UNIT_LEN].iter().all(|&b| b == 0));
}
assert_eq!(w.blanked_units(), 2);
}
/// Option A (E7013): a clear piece never has partners, so every garbled flagged unit in it,
/// at any LBA, is damage: blanked and counted, never a second-strike E7032.
#[test]
fn on_arrival_garbled_heads_at_two_lbas_of_a_clear_piece_are_blanked() {
let fx = fixture(&[stream(1, 10, None), stream(2, 10, None)], 2, &[&[0, 1]]);
let calls = Calls::default();
let set = resolve(&fx, KeyScope::WholeDisc, &[Spec::keydb(&[K1], &calls)]).unwrap();
let mut fx = fx;
garble_keeping_sync(&mut fx, 0, 2);
garble_keeping_sync(&mut fx, 0, 7);
let mut w = set.whole_disc_reader(&fx.disc, fx.source(), None).unwrap();
for u in [2, 7] {
let mut buf = vec![0u8; ALIGNED_UNIT_LEN];
w.read_sectors(fx.unit(0, u), 3, &mut buf, true)
.expect("damage, not E7032");
assert!(buf.iter().all(|&b| b == 0));
}
assert_eq!(w.blanked_units(), 2);
}
/// Sweep, patch and image→ISO read through the whole-disc reader, one call per block. A
/// cluster of damaged units (KS-4: "The first 16 bytes of each Aligned Unit is used as the
/// seed") is blanked and counted wherever it falls, never E7013: multipass must not error.
@@ -2026,16 +2118,19 @@ fn single_unit_rule_never_overrides_a_piece_another_key_opened() {
assert_eq!(read(&mut r, &fx, 1, 0, 10).unwrap(), fx.plain(b, 10));
}
/// KU §2.4 (review item 5): the held keys are tried on U before any side read. A readable
/// unit no held key opens stops at once, with no side read of a (possibly damaged) area.
/// KU §2.4 (review item 5): the held keys are tried on U first. A readable unit no held key
/// opens, alone in its read, stops within the read once one side read (never retried) finds
/// a readable partner no held key opens either (option A: a lone failure alone is damage).
#[test]
fn unopenable_unit_stops_before_any_side_read() {
fn unopenable_unit_stops_after_one_side_read() {
let (fx, set, src) = lazy_b(&[K1]);
let counted = CountingSource::new(src);
let log = counted.log();
let mut r = set.title_reader(&fx.disc, 0, counted).unwrap();
assert_eq!(code(read(&mut r, &fx, 1, 4, 1)), E7022);
assert_eq!(log.reads(), [(fx.unit(1, 4), 3)], "only the requested read");
let reads = log.reads();
assert_eq!(reads[0], (fx.unit(1, 4), 3), "the requested read first");
assert_eq!(reads.len(), 2, "then one side read");
}
/// KU §2.3 step 10 under invariant 2, "never guessed" (J22, review r2 M1). KS-14 [BD]
+2 -1
View File
@@ -148,7 +148,8 @@ pub use session::{
// All fallible APIs return `Result<T, Error>`; `Error` is a typed enum with a
// numeric `code()` — no English text in the library; see `error.rs` for taxonomy.
pub use error::{
Error, Result, error_code, is_disc_level_no_key, is_halt, is_skippable_title_stub,
Error, RemuxVerifyKind, Result, error_code, is_disc_level_no_key, is_halt,
is_skippable_title_stub,
};
// ─── Cooperative cancellation ───────────────────────────────────────────────
+1 -1
View File
@@ -142,7 +142,7 @@ fn adts_header(
let skip = if data[1] & 1 == 0 { 9 } else { 7 };
let blocks = data[6] & 3;
// A Matroska AAC block is one raw_data_block: with CRC, multi-block frames keep per-block
// CRC words in the payload, so refuse them (as ffmpeg's aac_adtstoasc does).
// CRC words in the payload, so refuse them.
if blocks > 0 && skip == 9 {
return None;
}
+2 -2
View File
@@ -582,7 +582,7 @@ fn final_au_end(buf: &[u8], core_size: usize) -> usize {
}
// Where extensions after a core begin: an EXSS is 4-byte aligned after a core whose size is
// not a multiple of 4 (as ffmpeg's dcadec frames it), so skip up to 3 padding bytes to it.
// not a multiple of 4, so skip up to 3 padding bytes to it.
fn exss_start(buf: &[u8], core_size: usize) -> usize {
let aligned = core_size.next_multiple_of(4);
let padded_exss = buf
@@ -1238,7 +1238,7 @@ mod tests {
}
// A core whose size is not a multiple of 4 is padded to a 4-byte boundary before its
// EXSS (ffmpeg dcadec aligns the same way); the extension must still be kept.
// EXSS; the extension must still be kept.
#[test]
fn exss_after_unaligned_core_is_kept() {
let core = make_dts_core(2013);
+3 -3
View File
@@ -705,7 +705,7 @@ fn split(data: &[u8]) -> Vec<&[u8]> {
}
/// FFmpeg 9.0.2 `-c:a mp2` output (plain MPEG-1, one per 3-B.2 table): the walk must end
/// where FFmpeg's own allocation filled the frame, not generate-then-parse its own layout.
/// where the encoder's own allocation filled the frame, not generate-then-parse its own layout.
#[test]
fn ffmpeg_encoded_frames_walk_to_the_end_of_every_frame() {
let fixtures: [(&[u8], u8); 7] = [
@@ -1142,8 +1142,8 @@ fn configuration_field_lengths_follow_the_tables() {
// ── joint stereo bound (Opus defect 4) ─────────────────────────────────────
/// "bound==16" above 3-B.2c's sblimit 8 is capped at sblimit, as FFmpeg's mp_decode_layer2
/// ("if (bound > sblimit) bound = sblimit") does, so the frame walks on to its mc_header
/// "bound==16" above 3-B.2c's sblimit 8 is capped at sblimit (no subband past sblimit is
/// coded, so a larger bound means sblimit), so the frame walks on to its mc_header
/// instead of falling back. This is per spec; do not change without a citation otherwise.
#[test]
fn joint_stereo_bound_is_capped_at_sblimit() {
+1 -1
View File
@@ -1,7 +1,7 @@
Real-encoder MPEG-1 Layer II fixtures for mp2_channels_tests.rs
(ffmpeg_encoded_frames_walk_to_the_end_of_every_frame).
Content: a synthetic 440 Hz sine tone from FFmpeg's lavfi `sine` source, 0.1 s.
Content: a synthetic 440 Hz sine tone from the FFmpeg lavfi `sine` source, 0.1 s.
No recorded or copyrighted material. Generated for this repository and covered
by its licence (MIT, see /LICENSE).
+40 -201
View File
@@ -41,27 +41,15 @@ fn url_medium(parsed: &StreamUrl) -> Medium {
}
}
// Effectively-unbounded per-frame send deadline for `send_deadline == None` (CLI interactive
// stdout/network): blocks on backpressure without timing out (halt is still checked every 250
// ms).
const NO_SEND_DEADLINE: Duration = Duration::from_secs(10 * 365 * 24 * 60 * 60);
// Resolve `MuxOptions::send_deadline` into the concrete per-frame deadline:
// `Some(d)` is autorip's watchdog timeout; `None` is `NO_SEND_DEADLINE`
// (CLI blocks on a slow-but-alive sink instead).
fn effective_send_deadline(send_deadline: Option<Duration>) -> Duration {
send_deadline.unwrap_or(NO_SEND_DEADLINE)
}
// Ceiling on bytes buffered while waiting for `headers_ready()`, so a damaged title whose
// `codec_private` never resolves fails fast instead of OOM-killing the process.
pub(crate) const HEADER_BUFFER_CAP_BYTES: usize = 512 * 1024 * 1024;
/// Tuning / behaviour knobs for a mux run.
///
/// `Default` = keep-everything, no-skip, decrypt, no send deadline — the
/// archival default. Added so callers set only the fields they care about
/// (and so the additive `selection` field doesn't churn every constructor).
/// `Default` = keep-everything, no-skip, decrypt — the archival default. Added
/// so callers set only the fields they care about (and so an additive field
/// doesn't churn every constructor).
#[derive(Default)]
pub struct MuxOptions {
/// Skip past read errors (zero-fill + continue) on the live-drive path
@@ -81,10 +69,6 @@ pub struct MuxOptions {
/// caller sets `InputOptions::selection` instead. Setting this field for a Url
/// input has no effect.
pub selection: crate::StreamSelection,
/// Per-frame write-pipeline send deadline. `Some(d)` returns
/// `completed = false` if backpressure lasts longer than `d`.
/// `None` waits while the downstream is alive, unless halted.
pub send_deadline: Option<Duration>,
}
/// Progress / event callbacks the consumer implements (CLI `CliProgress`,
@@ -362,7 +346,6 @@ fn mux_unkeyed(
halt,
events.as_ref(),
playlist_name.as_deref(),
effective_send_deadline(opts.send_deadline),
Some(&source),
)
}
@@ -558,7 +541,6 @@ fn mux_keyed(
halt,
events.as_ref(),
playlist.as_deref(),
effective_send_deadline(opts.send_deadline),
Some(&source),
)
}
@@ -696,7 +678,6 @@ fn drive_mux(
halt: &Halt,
events: &dyn MuxEvents,
playlist_name: Option<&str>,
send_deadline: Duration,
source: Option<&SourceInfo>,
) -> std::io::Result<MuxOutcome> {
// Title assembled from the scanned metadata; the playlist name (disc name)
@@ -893,10 +874,9 @@ fn drive_mux(
// The op's token, not the wedge token `finish_pumped` may pass (§2.5 `.partial` rule).
pipe.set_op_token(halt);
// ── Frame pump ── Per-frame send deadline is a hard bound (autorip) or
// effectively unbounded (CLI). Either way `send_with_halt` re-checks halt
// every `WAIT_SLICE`, so Ctrl-C / `/api/stop` stays responsive.
let deadline = send_deadline;
// ── Frame pump ── No per-frame deadline (T27): a slow-but-alive sink blocks and
// Stop is the bound; `send_with_halt` re-checks halt every `WAIT_SLICE`.
let deadline = Duration::MAX;
let mut interrupted = false;
// A send refused with no halt and a healthy consumer ran out its deadline.
let mut send_timed_out = false;
@@ -1406,16 +1386,7 @@ mod tests {
let stream = Box::new(FakeStream::new(1).with_frames(2));
let halt = Halt::new();
let spy = SpyEvents::new();
drive_mux(
stream,
&url,
&halt,
&spy,
None,
Duration::from_secs(60),
Some(&source),
)
.expect("fvi mux runs");
drive_mux(stream, &url, &halt, &spy, None, Some(&source)).expect("fvi mux runs");
let text = std::fs::read_to_string(&dst).expect("index written");
let hdr: serde_json::Value = serde_json::from_str(text.lines().next().unwrap()).unwrap();
@@ -1453,16 +1424,8 @@ mod tests {
let (_dir, url) = tmp("out.xml");
let halt = Halt::new();
let spy = SpyEvents::new();
let out = drive_mux(
stream,
&url,
&halt,
&spy,
None,
Duration::from_secs(60),
None,
)
.expect("chapters must short-circuit");
let out =
drive_mux(stream, &url, &halt, &spy, None, None).expect("chapters must short-circuit");
assert!(out.completed, "metadata sink completes without headers");
assert!(out.output_opened);
assert!(spy.opened.load(Ordering::SeqCst), "sink was opened");
@@ -1475,15 +1438,7 @@ mod tests {
let url = format!("json://{}", dir.path().join("out.json").display());
let stream = Box::new(FakeStream::new(1).never_ready());
let halt = Halt::new();
let out = drive_mux(
stream,
&url,
&halt,
&NoopEvents,
None,
Duration::from_secs(60),
None,
)
let out = drive_mux(stream, &url, &halt, &NoopEvents, None, None)
.expect("json must short-circuit");
assert!(out.completed);
assert!(out.output_opened);
@@ -1495,15 +1450,7 @@ mod tests {
fn header_gate_rejects_unresolved_codec_private() {
let stream = Box::new(FakeStream::new(1).with_frames(3).never_ready());
let halt = Halt::new();
let err = drive_mux(
stream,
"null://",
&halt,
&NoopEvents,
None,
Duration::from_secs(60),
None,
)
let err = drive_mux(stream, "null://", &halt, &NoopEvents, None, None)
.expect_err("unresolved headers must be refused");
// This gate is the GENUINE stub case: no video track's codec_private
// resolved. `MkvInvalid` now means only this (bad `mkv://` input is
@@ -1521,15 +1468,7 @@ mod tests {
fn zero_output_gate_refuses_empty_drain() {
let stream = Box::new(FakeStream::new(1)); // headers ready, no frames
let halt = Halt::new();
let err = drive_mux(
stream,
"null://",
&halt,
&NoopEvents,
None,
Duration::from_secs(60),
None,
)
let err = drive_mux(stream, "null://", &halt, &NoopEvents, None, None)
.expect_err("empty drain must be refused");
assert_eq!(err.to_string(), format!("E{}", crate::error::E_NO_STREAMS));
}
@@ -1544,15 +1483,7 @@ mod tests {
.with_frames(1000)
.cancels(halt.clone(), 2),
);
let out = drive_mux(
stream,
"null://",
&halt,
&NoopEvents,
None,
Duration::from_secs(60),
None,
)
let out = drive_mux(stream, "null://", &halt, &NoopEvents, None, None)
.expect("halt is a clean stop, not an error");
assert!(!out.completed, "an interrupted mux is not complete");
assert!(out.output_opened, "the sink was opened before the halt");
@@ -1567,15 +1498,7 @@ mod tests {
fs.headers_ready_after = usize::MAX;
fs.ready_on_eof = true;
let events = SpyEvents::new();
let out = drive_mux(
Box::new(fs),
"null://",
&halt,
&events,
None,
Duration::from_secs(60),
None,
)
let out = drive_mux(Box::new(fs), "null://", &halt, &events, None, None)
.expect("halt is a clean stop");
assert!(!out.completed);
assert!(!out.output_opened, "no sink may be opened after a halt");
@@ -1590,15 +1513,7 @@ mod tests {
let halt = Halt::new();
// Headers ready immediately; the 3rd read (in the frame pump) errors Halted.
let stream = Box::new(FakeStream::new(1).with_frames(1000).halt_errs_at(2));
let out = drive_mux(
stream,
"null://",
&halt,
&NoopEvents,
None,
Duration::from_secs(60),
None,
)
let out = drive_mux(stream, "null://", &halt, &NoopEvents, None, None)
.expect("a halt mid frame-read is a clean stop, not an Err");
assert!(!out.completed, "interrupted mux is not complete");
assert!(out.output_opened, "sink opened before the mid-read halt");
@@ -1614,15 +1529,7 @@ mod tests {
.never_ready()
.halt_errs_at(1),
);
let out = drive_mux(
stream,
"null://",
&halt,
&NoopEvents,
None,
Duration::from_secs(60),
None,
)
let out = drive_mux(stream, "null://", &halt, &NoopEvents, None, None)
.expect("a halt mid header-read is a clean stop, not an Err");
assert!(!out.completed, "interrupted mux is not complete");
assert!(
@@ -1637,16 +1544,8 @@ mod tests {
let stream = Box::new(FakeStream::new(2).with_frames(10));
let halt = Halt::new();
let spy = SpyEvents::new();
let out = drive_mux(
stream,
"null://",
&halt,
&spy,
None,
Duration::from_secs(60),
None,
)
.expect("normal mux completes");
let out =
drive_mux(stream, "null://", &halt, &spy, None, None).expect("normal mux completes");
assert!(out.completed);
assert!(out.output_opened);
assert!(spy.opened.load(Ordering::SeqCst));
@@ -1829,7 +1728,6 @@ mod tests {
skip_errors: false,
batch_sectors: 8192,
raw: false,
send_deadline: Some(Duration::from_secs(60)),
selection: Default::default(),
};
let halt = Halt::new();
@@ -1956,7 +1854,6 @@ mod tests {
skip_errors: false,
batch_sectors: 3,
raw: false,
send_deadline: Some(Duration::from_secs(60)),
selection: Default::default(),
};
let halt = Halt::new();
@@ -2031,7 +1928,6 @@ mod tests {
skip_errors: false,
batch_sectors: 3,
raw: false,
send_deadline: Some(Duration::from_secs(60)),
selection: Default::default(),
};
let halt = Halt::new();
@@ -2153,7 +2049,6 @@ mod tests {
&halt,
&NoopEvents,
None,
Duration::from_secs(60),
None,
)
.expect("mux succeeds");
@@ -2274,7 +2169,6 @@ mod tests {
&halt,
&NoopEvents,
None,
Duration::from_secs(60),
None,
)
.expect("mux succeeds");
@@ -2379,8 +2273,7 @@ mod tests {
}
fn run(src: LpcmSource, url: &str, spy: &TitleSpy) -> MuxOutcome {
let d = Duration::from_secs(60);
drive_mux(Box::new(src), url, &Halt::new(), spy, None, d, None).unwrap()
drive_mux(Box::new(src), url, &Halt::new(), spy, None, None).unwrap()
}
// Every entry point (Url/Session/Iso/Live) funnels into drive_mux: the BD LPCM
@@ -2504,8 +2397,7 @@ mod tests {
// m2ts: an MKV with a declared but frameless video track is refused.
let url = format!("m2ts://{}", dir.path().join("o.m2ts").display());
let spy = TitleSpy::default();
let d = Duration::from_secs(60);
drive_mux(Box::new(src), &url, &Halt::new(), &spy, None, d, None).unwrap();
drive_mux(Box::new(src), &url, &Halt::new(), &spy, None, None).unwrap();
let t = spy.0.lock().unwrap().clone().unwrap();
let crate::disc::Stream::Audio(a) = t.streams.last().unwrap() else {
panic!("audio")
@@ -2612,15 +2504,7 @@ mod tests {
});
}
let halt = Halt::new();
let err = drive_mux(
Box::new(fs),
"null://",
&halt,
&NoopEvents,
None,
Duration::from_secs(60),
None,
)
let err = drive_mux(Box::new(fs), "null://", &halt, &NoopEvents, None, None)
.expect_err("over-cap header buffer must fail fast, not OOM");
// The cap overflow must carry its OWN code, not `MkvInvalid`: that code
// means "skippable empty stub" and would silently drop a title that
@@ -2666,15 +2550,7 @@ mod tests {
writes: AtomicU64::new(0),
};
let halt = Halt::new();
let out = drive_mux(
Box::new(fs),
"null://",
&halt,
&events,
None,
Duration::from_secs(60),
None,
)
let out = drive_mux(Box::new(fs), "null://", &halt, &events, None, None)
.expect("K-frame stream muxes cleanly");
assert!(out.completed);
assert_eq!(
@@ -2724,61 +2600,26 @@ mod tests {
);
}
// ── Regression E: send_deadline routing (Some vs None) ──────────────────
// autorip passes `Some(60s)`; CLI passes `None`, which must map to an
// unbounded deadline so a slow-but-alive downstream isn't reported interrupted.
// T27 / ST-X1b: `MuxOptions` has no per-frame deadline; no source names it again.
#[test]
fn effective_send_deadline_routes_some_and_none() {
// Some(d) → exactly d.
assert_eq!(
effective_send_deadline(Some(Duration::from_secs(60))),
Duration::from_secs(60),
"Some(d) must resolve to the bounded per-frame deadline d"
);
assert_eq!(
effective_send_deadline(Some(Duration::from_secs(5))),
Duration::from_secs(5),
);
// None → the effectively-unbounded no-timeout sentinel, far larger than
// any real Some deadline so backpressure never trips it.
let none = effective_send_deadline(None);
assert_eq!(
none, NO_SEND_DEADLINE,
"None must resolve to NO_SEND_DEADLINE"
);
assert!(
none >= Duration::from_secs(365 * 24 * 60 * 60),
"None must be effectively unbounded (>= 1 year), got {none:?}"
);
assert!(
none > Duration::from_secs(60),
"None must NOT collapse to the old fixed 60s deadline"
);
fn removed_deadline_field_is_named_nowhere() {
fn walk(dir: &std::path::Path, needle: &str, hits: &mut Vec<String>) {
for e in std::fs::read_dir(dir).unwrap() {
let path = e.unwrap().path();
if path.is_dir() {
walk(&path, needle, hits);
} else if path.extension().is_some_and(|x| x == "rs")
&& std::fs::read_to_string(&path).is_ok_and(|src| src.contains(needle))
{
hits.push(path.display().to_string());
}
// MuxOptions must actually carry the send_deadline knob end-to-end so a
// consumer can pick the bounded (autorip) vs unbounded (CLI) policy.
#[test]
fn mux_options_carries_send_deadline() {
let cli = MuxOptions {
skip_errors: false,
batch_sectors: 0,
raw: false,
send_deadline: None,
selection: Default::default(),
};
assert_eq!(effective_send_deadline(cli.send_deadline), NO_SEND_DEADLINE);
let autorip = MuxOptions {
skip_errors: false,
batch_sectors: 8192,
raw: false,
send_deadline: Some(Duration::from_secs(60)),
selection: Default::default(),
};
assert_eq!(
effective_send_deadline(autorip.send_deadline),
Duration::from_secs(60)
);
}
}
let needle = ["send", "deadline"].join("_");
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut hits = Vec::new();
walk(&root, &needle, &mut hits);
assert!(hits.is_empty(), "{needle} still named in: {hits:?}");
}
#[test]
@@ -2917,7 +2758,6 @@ mod tests {
&Halt::new(),
&SpyEvents::new(),
None,
Duration::from_secs(60),
None,
)
.expect("mkv remux runs");
@@ -3003,7 +2843,6 @@ mod tests {
skip_errors: false,
batch_sectors: 3,
raw: false,
send_deadline: Some(Duration::from_secs(60)),
selection: Default::default(),
}
}
+49 -8
View File
@@ -453,16 +453,15 @@ pub const VOID: u32 = 0xEC;
/// A Void element (0xEC) that occupies EXACTLY `total_len` bytes in place: 1-byte
/// id + 1-byte size VINT + zeroed payload. Used to neutralise a fixed element
/// without shifting the bytes after it. `total_len` must be in `2..=129` (the
/// 1-byte-size range, which covers every fixed element the muxer voids in place).
pub fn void_element(total_len: usize) -> Vec<u8> {
assert!(
(2..=129).contains(&total_len),
"void_element: total_len {total_len} outside the 1-byte-size range 2..=129"
);
/// without shifting the bytes after it. `total_len` must be in `2..=128`: a
/// 127-byte payload would need size byte 0xFF, the reserved unknown-size VINT.
pub fn void_element(total_len: usize) -> io::Result<Vec<u8>> {
if !(2..=128).contains(&total_len) {
return Err(crate::error::Error::MkvUnencodable.into());
}
let mut v = vec![VOID as u8, 0x80 | (total_len - 2) as u8];
v.resize(total_len, 0);
v
Ok(v)
}
pub const SEEK_ID: u32 = 0x53AB;
pub const SEEK_POSITION: u32 = 0x53AC;
@@ -1459,4 +1458,46 @@ mod tests {
assert_eq!(fixed_width_vint8(0x5Au64 << (8 * i)), want, "octet {i}");
}
}
// void_element fills exactly `total_len` bytes with a 1-byte size VINT; 129 would need
// the reserved all-ones size byte 0xFF and is refused.
#[test]
fn void_element_range_and_encoding() {
assert_eq!(void_element(2).unwrap(), vec![0xEC, 0x80]);
let v = void_element(128).unwrap();
assert_eq!((v.len(), v[0], v[1]), (128, 0xEC, 0xFE));
assert!(v[2..].iter().all(|&b| b == 0));
for bad in [0, 1, 129] {
let e = void_element(bad).unwrap_err();
assert_eq!(
crate::error::error_code(&e),
Some(crate::error::E_MKV_UNENCODABLE),
"{bad}"
);
}
}
// An attacker-sized EBML length must not size an allocation: a huge claim against a
// 4-byte source is a clean MkvSourceInvalid (usize::MAX would overflow any up-front buffer).
#[test]
fn read_binary_val_huge_declared_len_is_source_invalid() {
for len in [1usize << 40, usize::MAX] {
let e = read_binary_val(&mut Cursor::new(&[1u8, 2, 3, 4]), len).unwrap_err();
assert_eq!(
crate::error::error_code(&e),
Some(crate::error::E_MKV_SOURCE_INVALID),
"{len}"
);
}
}
// Invalid UTF-8 in an untrusted string element is MkvSourceInvalid, never lossy/panic.
#[test]
fn read_string_val_invalid_utf8_is_source_invalid() {
let e = read_string_val(&mut Cursor::new(&[0xFFu8, 0xFE]), 2).unwrap_err();
assert_eq!(
crate::error::error_code(&e),
Some(crate::error::E_MKV_SOURCE_INVALID)
);
}
}
+33 -1
View File
@@ -66,7 +66,7 @@ pub struct FitReport {
/// The pre-mux fit plan of `title` for `dest`. Every stream is either included or
/// skipped with a reason, except a declared-only MPEG-2 extension track, which is
/// in neither: whether it is lost is known only once its packets arrive (J23).
/// Destinations that carry everything (`mkv://`, the FMKV wire, …) skip nothing.
/// `mkv://` skips only codecs Matroska has no CodecID for; the FMKV wire skips nothing.
pub fn fit_report(dest: &StreamUrl, title: &DiscTitle) -> FitReport {
if let StreamUrl::Mpg { .. } = dest {
return super::mpg::plan(title).report;
@@ -102,6 +102,11 @@ pub fn fit_report(dest: &StreamUrl, title: &DiscTitle) -> FitReport {
.is_none()
.then_some(SkipReason::UnmappableAudio)
}
(StreamUrl::Mkv { .. }, s) if !super::mkv::is_mappable(s) => Some(match s {
Stream::Video(_) => SkipReason::UnmappableVideo,
Stream::Audio(_) => SkipReason::UnmappableAudio,
Stream::Subtitle(_) => SkipReason::UnmappableSubtitle,
}),
_ => None,
};
match refused {
@@ -292,4 +297,31 @@ mod tests {
assert!(r.skipped.is_empty(), "{url}");
}
}
// A codec with no Matroska CodecID (text subs from an FMKV header, Unknown from a
// read-back) is planned out for mkv://, never declared under another codec's ID.
#[test]
fn mkv_plans_out_codecs_matroska_cannot_name() {
let mut srt = pgs();
if let Stream::Subtitle(s) = &mut srt {
s.codec = Codec::Srt;
}
let t = title(vec![
video(Codec::Unknown(0)),
audio(0x1100, Codec::Unknown(0), SampleRate::S48, ""),
audio(0x1101, Codec::Ac3, SampleRate::S48, ""),
srt,
pgs(),
]);
let r = fit_report(&parse_url("mkv:///o/x.mkv"), &t);
assert_eq!(r.included, vec![2, 4]);
assert_eq!(
r.skipped,
vec![
(0, SkipReason::UnmappableVideo),
(1, SkipReason::UnmappableAudio),
(3, SkipReason::UnmappableSubtitle),
]
);
}
}
+478 -116
View File
@@ -9,7 +9,7 @@ use super::ebml;
// `demux://` sink.
use super::timeline::TimelineContinuity;
use crate::disc::{
AudioStream, Chapter, Codec, ColorSpace, HdrFormat, SubtitleStream, VideoStream,
AudioStream, Chapter, Codec, CodecKind, ColorSpace, HdrFormat, SubtitleStream, VideoStream,
};
// Production code reaches resolutions only through `VideoStream::resolution`;
// the fixtures below name the variants directly.
@@ -39,6 +39,8 @@ const CICP_TRANSFER_BT709: u8 = 1;
const CICP_TRANSFER_BT470BG: u8 = 5;
/// TransferCharacteristics = 6 (BT.601-525 / SMPTE 170M) — ITU-T H.273 Table 3.
const CICP_TRANSFER_BT601_525: u8 = 6;
/// TransferCharacteristics = 14 (BT.2020 10-bit, SDR) — ITU-T H.273 Table 3.
const CICP_TRANSFER_BT2020_10: u8 = 14;
/// TransferCharacteristics = 16 (SMPTE ST 2084 / PQ — HDR10/HDR10+/DV) — ITU-T
/// H.273 Table 3.
const CICP_TRANSFER_PQ: u8 = 16;
@@ -123,9 +125,10 @@ pub(crate) fn cicp_for_video(v: &VideoStream) -> (u8, u8, u8, u8) {
return (c.matrix, c.transfer, c.primaries, c.range);
}
let (m, t, p, r) = match v.color_space {
// SDR BT.2020 (UHD MPLS dynamic_range 0); the HDR override below selects PQ/HLG.
ColorSpace::Bt2020 => (
CICP_MATRIX_BT2020NC,
CICP_TRANSFER_PQ,
CICP_TRANSFER_BT2020_10,
CICP_PRIMARIES_BT2020,
COLOUR_RANGE_LIMITED,
),
@@ -237,6 +240,20 @@ pub fn dolby_vision_config(profile: u8, level: u8, bl_compat_id: u8) -> Vec<u8>
v
}
// Dolby Vision level for a disc Profile 7 title: the base layer is always 3840x2160 (the
// DV-tagged stream is often the 1080p EL), so the level follows the frame rate alone
// (Dolby Vision Profiles and Levels: 6 = 2160p24, 7 = p30, 8 = p48, 9 = p60).
fn dv_level_2160p(num: u32, den: u32) -> u8 {
let (num, den) = (u64::from(num), u64::from(den.max(1)));
if num == 0 {
return 6;
}
[24, 30, 48]
.iter()
.position(|&max| num <= max * den)
.map_or(9, |i| 6 + i as u8)
}
/// SEI chromaticity unit (Rec. ITU-T H.265 D.3.28): `display_primaries_*` and
/// `white_point_*` are in increments of 0.00002. Matroska chromaticity elements
/// are floats in the [0, 1] range, so the conversion is `value * 0.00002`.
@@ -313,25 +330,74 @@ fn language_or_und(lang: &str) -> String {
}
}
impl MkvTrack {
/// Build a video track from a [`VideoStream`]. Language defaults to `"und"`;
/// colour metadata is derived from the stream's colour space and HDR format
/// (PQ for HDR10/HDR10+/DV, HLG for HLG). When `hdr == DolbyVision` a dvcC
/// BlockAdditionMapping is attached automatically so players recognise the
/// Dolby Vision layer.
pub fn video(v: &VideoStream) -> Self {
let codec_id = match v.codec {
/// Matroska CodecID for `codec` carried as a `kind` track, or `None` when Matroska has no ID
/// for it here (text subtitles, whose payload is not in Matroska block form; `Unknown`; a codec
/// of another kind). Exhaustive, so a new `Codec` variant must be mapped or refused here.
fn codec_id(codec: Codec, kind: CodecKind) -> Option<&'static str> {
// `A_DTS` is the sole registered ID for the whole DTS family; players tell core/HD-HRA/
// HD-MA apart from the bitstream. Unregistered `A_DTS/MA`/`A_DTS/HR` break strict parsers.
let id = match codec {
Codec::H264 => ebml::CODEC_H264,
Codec::Hevc => ebml::CODEC_HEVC,
Codec::Vc1 => ebml::CODEC_VC1,
Codec::Mpeg2 => ebml::CODEC_MPEG2,
Codec::Mpeg1 => ebml::CODEC_MPEG1,
Codec::Av1 => ebml::CODEC_AV1,
// Every video codec this crate can produce is named above; the remaining
// arm is reached only by a non-video/Unknown codec routed here in error
// (see the audio counterpart — no error channel exists here either).
_ => ebml::CODEC_MPEG2,
Codec::Ac3 => ebml::CODEC_AC3,
Codec::Ac3Plus => ebml::CODEC_EAC3,
Codec::TrueHd => ebml::CODEC_TRUEHD,
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => ebml::CODEC_DTS,
Codec::Lpcm => ebml::CODEC_PCM_BE,
Codec::Aac => ebml::CODEC_AAC,
Codec::Mp2 => ebml::CODEC_MP2,
Codec::Mp3 => ebml::CODEC_MP3,
Codec::Flac => ebml::CODEC_FLAC,
Codec::Opus => ebml::CODEC_OPUS,
Codec::Pgs => ebml::CODEC_PGS,
Codec::DvdSub => ebml::CODEC_VOBSUB,
Codec::Srt | Codec::Ssa | Codec::Unknown(_) => return None,
};
(codec.kind() == kind).then_some(id)
}
/// Whether `s` has a Matroska CodecID, i.e. whether `mkv://` carries it.
pub(crate) fn is_mappable(s: &crate::disc::Stream) -> bool {
match s {
crate::disc::Stream::Video(v) => codec_id(v.codec, CodecKind::Video).is_some(),
crate::disc::Stream::Audio(a) => codec_id(a.codec, CodecKind::Audio).is_some(),
crate::disc::Stream::Subtitle(t) => codec_id(t.codec, CodecKind::Subtitle).is_some(),
}
}
// Test shorthands for [`MkvTrack::from_stream`] on a stream known to be mappable.
#[cfg(test)]
impl MkvTrack {
pub fn video(v: &VideoStream) -> Self {
Self::try_video(v).expect("mappable video codec")
}
pub fn audio(a: &AudioStream) -> Self {
Self::try_audio(a).expect("mappable audio codec")
}
pub fn subtitle(s: &SubtitleStream) -> Self {
Self::try_subtitle(s).expect("mappable subtitle codec")
}
}
impl MkvTrack {
/// Build the track for a title stream, or `None` when its codec has no Matroska CodecID
/// (the stream is then left out rather than declared under another codec's ID).
pub(crate) fn from_stream(s: &crate::disc::Stream) -> Option<Self> {
match s {
crate::disc::Stream::Video(v) => Self::try_video(v),
crate::disc::Stream::Audio(a) => Self::try_audio(a),
crate::disc::Stream::Subtitle(t) => Self::try_subtitle(t),
}
}
// Video track: language `und`, colour from `cicp_for_video`, and a dvcC
// BlockAdditionMapping when `hdr == DolbyVision`.
fn try_video(v: &VideoStream) -> Option<Self> {
let codec_id = codec_id(v.codec, CodecKind::Video)?;
// Unknown resolution -> `pixels()` reports (0, 0) (no default is fabricated),
// and the writer omits the optional PixelWidth/PixelHeight
// on 0 per RFC 9559 5.1.4.1.28-29.
@@ -350,10 +416,14 @@ impl MkvTrack {
// anamorphic — coded pixels aren't square, so derive width from the DAR
// (e.g. 720x576 16:9 -> 1024x576) or players show 5:4/3:2 instead of 16:9.
let (display_width, display_height) = match v.display_aspect {
Some((an, ad)) if an > 0 && ad > 0 && h > 0 => ((h * an + ad / 2) / ad, h),
// u64: a caller-supplied ratio must not overflow; an unrepresentable width keeps (w, h).
Some((an, ad)) if an > 0 && ad > 0 && h > 0 => {
let dw = (u64::from(h) * u64::from(an) + u64::from(ad) / 2) / u64::from(ad);
u32::try_from(dw).map_or((w, h), |dw| (dw, h))
}
_ => (w, h),
};
Self {
Some(Self {
track_type: ebml::TRACK_TYPE_VIDEO,
codec_id,
language: "und".into(),
@@ -385,7 +455,7 @@ impl MkvTrack {
// The DV layer (hdr=DolbyVision) carries the dvcC so the track is
// recognised as Dolby Vision (disc Profile 7 dual-layer).
dv_config: if matches!(v.hdr, HdrFormat::DolbyVision) {
Some(dolby_vision_config(7, 6, 0))
Some(dolby_vision_config(7, dv_level_2160p(num, den), 0))
} else {
None
},
@@ -394,32 +464,12 @@ impl MkvTrack {
// (same deferred path FieldOrder uses). `None` here -> omitted unless seen.
hdr10: None,
mvc_params: None,
}
})
}
/// Build an audio track from an [`AudioStream`]. The codec ID follows the
/// Matroska registry; every DTS family member (core, DTS-HD HR, DTS-HD MA)
/// maps to the single registered `A_DTS` ID (see the note below).
pub fn audio(a: &AudioStream) -> Self {
// `A_DTS` is the sole registered codec ID for the whole DTS family; players
// distinguish core/HD-HRA/HD-MA from the bitstream itself. `A_DTS/MA` and
// `A_DTS/HR` aren't registered and some strict parsers reject them.
let codec_id = match a.codec {
Codec::Ac3 => ebml::CODEC_AC3,
Codec::Ac3Plus => ebml::CODEC_EAC3,
Codec::TrueHd => ebml::CODEC_TRUEHD,
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => ebml::CODEC_DTS,
Codec::Lpcm => ebml::CODEC_PCM_BE,
Codec::Aac => ebml::CODEC_AAC,
Codec::Mp2 => ebml::CODEC_MP2,
Codec::Mp3 => ebml::CODEC_MP3,
Codec::Flac => ebml::CODEC_FLAC,
Codec::Opus => ebml::CODEC_OPUS,
// Every audio codec this crate can produce is named above; the remaining
// arm (A_AC3, wrong for such a track) is reached only in error, since
// `MkvTrack::audio` has no error channel — a test pins each real codec ID.
_ => ebml::CODEC_AC3,
};
// Audio track; every DTS family member maps to the single registered `A_DTS`.
fn try_audio(a: &AudioStream) -> Option<Self> {
let codec_id = codec_id(a.codec, CodecKind::Audio)?;
// Unknown sample rate/channels -> accessors return 0, so the serializer omits
// SamplingFrequency/Channels rather than writing a fabricated 48kHz/6ch value.
let sr = a.sample_rate.hz();
@@ -427,7 +477,7 @@ impl MkvTrack {
let name = a.label.clone();
Self {
Some(Self {
track_type: ebml::TRACK_TYPE_AUDIO,
codec_id,
language: language_or_und(&a.language),
@@ -458,19 +508,14 @@ impl MkvTrack {
dv_config: None,
hdr10: None,
mvc_params: None,
}
})
}
/// Build a subtitle track from a [`SubtitleStream`]. PGS maps to
/// `S_HDMV/PGS` and DVD VobSub to `S_VOBSUB`; the stream's `codec_data`
/// (the VobSub `.idx` palette header for DVD) becomes the track's
/// CodecPrivate. The forced-display flag is propagated from the stream.
pub fn subtitle(s: &SubtitleStream) -> Self {
let codec_id = match s.codec {
Codec::DvdSub => ebml::CODEC_VOBSUB,
_ => ebml::CODEC_PGS,
};
Self {
// Subtitle track (PGS or VobSub); `codec_data` (the VobSub `.idx` palette header) becomes
// the CodecPrivate and the forced flag is propagated.
fn try_subtitle(s: &SubtitleStream) -> Option<Self> {
let codec_id = codec_id(s.codec, CodecKind::Subtitle)?;
Some(Self {
track_type: ebml::TRACK_TYPE_SUBTITLE,
codec_id,
language: language_or_und(&s.language),
@@ -496,7 +541,7 @@ impl MkvTrack {
dv_config: None,
hdr10: None,
mvc_params: None,
}
})
}
}
@@ -1020,7 +1065,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
}
} else if track.codec_id == ebml::CODEC_AAC {
codec_private_reserves.insert(i, writer.stream_position()?);
writer.write_all(&ebml::void_element(CODEC_PRIVATE_RESERVE))?;
writer.write_all(&ebml::void_element(CODEC_PRIVATE_RESERVE)?)?;
}
// Pre-0.13's deferred codecPrivate path was removed as dead code.
@@ -1120,8 +1165,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
// Blu-ray 3D (MVC) signaling: BlockAdditionMapping carries the mvcC record
// so players recognise the dependent (right-eye) view riding as a
// per-frame BlockAdditional under this mapping (BlockAddIDValue = 2).
match mvc_record.as_ref() {
Some(record) => {
match (mvc_record.as_ref(), track.mvc_params.as_ref()) {
(Some(record), _) => {
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
ebml::write_uint(
&mut writer,
@@ -1135,8 +1180,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
// `mvc_params` present but the record failed to build (malformed params):
// no mapping, `track_has_mvc_mapping` is already `false`, so
// BlockAdditionals are dropped — file stays conforming, no orphan BlockAddID.
None if track.mvc_params.is_some() => {
let (s, p) = track.mvc_params.as_ref().unwrap();
(None, Some((s, p))) => {
tracing::warn!(
target: "mux",
"MVC track: could not build MVCDecoderConfigurationRecord from the \
@@ -1146,7 +1190,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
p.len(),
);
}
None => {}
(None, None) => {}
}
// Dolby Vision signaling — BlockAdditionMapping is a child of the
@@ -1295,7 +1339,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
}
el.extend_from_slice(cp);
if rest >= 2 {
el.extend_from_slice(&ebml::void_element(rest));
el.extend_from_slice(&ebml::void_element(rest)?);
}
let here = self.writer.stream_position()?;
self.writer.seek(std::io::SeekFrom::Start(pos))?;
@@ -1795,7 +1839,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
target: "mux",
dropped = self.dropped_pre_cluster,
frames_written = self.frame_count,
"frames were discarded before the first cluster opened (no track-0 video keyframe had arrived yet); they are absent from the output"
driver_track = self.primary_video_track.unwrap_or(0),
"frames were discarded before the first cluster opened (no keyframe on the cluster-driving track had arrived yet); they are absent from the output"
);
}
// Frames excluded by the playlist's clip marks are dropped on purpose (a join
@@ -1915,7 +1960,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
if !rw.final_interlaced
&& let Some((fo_off, fo_len)) = rw.field_order_span
{
interlaced_patches.push((fo_off, ebml::void_element(fo_len as usize)));
interlaced_patches.push((fo_off, ebml::void_element(fo_len as usize)?));
}
tracing::warn!(
target: "mux",
@@ -1962,9 +2007,11 @@ impl<W: Write + Seek> MkvMuxer<W> {
let offset = match fixup.target_id {
ebml::INFO => self.info_offset,
ebml::TRACKS => self.tracks_offset,
ebml::CHAPTERS => self
.chapters_offset
.expect("CHAPTERS seek fixup present => chapters_offset is Some"),
// The CHAPTERS entry is reserved only with chapters, which set the offset.
ebml::CHAPTERS => match self.chapters_offset {
Some(off) => off,
None => return Err(crate::error::Error::MkvUnencodable.into()),
},
ebml::CUES => cues_offset,
_ => 0,
};
@@ -3144,10 +3191,10 @@ mod tests {
max >= tick(600_000),
"timeline must span past the boundary, got {max} ticks"
);
// And does NOT ratchet far beyond clip1+clip2 (~605s): well under 2× clip1.
// And does NOT ratchet beyond clip1+clip2 (~605s), within a 1 s tolerance.
assert!(
max < tick(1_000_000),
"no ratchet: max cluster ts {max} ticks must stay near 605s"
max < tick(606_000),
"no ratchet: max cluster ts {max} ticks must stay within 1 s of 605 s"
);
}
@@ -3204,8 +3251,8 @@ mod tests {
// 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"
max < tick(606_000),
"no ratchet: max cluster ts {max} ticks must stay within 1 s of 605 s"
);
}
@@ -3234,7 +3281,7 @@ mod tests {
let max = *ts.iter().max().unwrap() as i64;
assert!(max >= tick(600_000), "timeline spans the seam, got {max}");
assert!(
max < tick(1_000_000),
max < tick(606_000),
"EL straggler must not ratchet the timeline, got {max} ticks"
);
}
@@ -3386,18 +3433,12 @@ mod tests {
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// FlagDefault ID is 0x88. When is_default is true, FlagDefault is NOT written
// (MKV default is 1). When is_default is false, FlagDefault=0 IS written.
// So we should find at least one FlagDefault element (for the non-default track).
let flag_default_id = ebml::FLAG_DEFAULT.to_be_bytes();
let _needle = &[flag_default_id[3]]; // 0x88 is a 1-byte ID
let count = data.windows(1).filter(|w| w[0] == 0x88).count();
// 0x88 appears as FlagDefault + as TrackType (also 0x83... no, 0x83 != 0x88)
// FlagDefault (0x88) should appear for the non-default track
assert!(
count >= 1,
"FlagDefault should be written for non-default tracks"
);
// FlagDefault defaults to 1, so only the non-default third track carries FlagDefault=0.
let flags: Vec<Option<u64>> = track_entries(&data)
.iter()
.map(|&te| child_uint(&data, te, ebml::FLAG_DEFAULT))
.collect();
assert_eq!(flags, vec![None, None, Some(0)]);
}
/// Read back the value of the FIRST `Language` element in `data` as a
@@ -3647,27 +3688,34 @@ mod tests {
#[test]
fn mkv_forced_flag_on_forced_subtitle() {
use crate::disc::SubtitleStream;
let video = make_video_track();
let forced_sub = MkvTrack::subtitle(&SubtitleStream {
pid: 0x1200,
codec: Codec::Pgs,
// PGS always reserves FlagForced, so the VALUE must carry is_forced; a forced VobSub
// writes FlagForced=1 and an unforced one omits it.
let sub = |codec, forced| {
MkvTrack::subtitle(&SubtitleStream {
pid: 0x20,
codec,
language: "eng".into(),
forced: true,
qualifier: crate::disc::LabelQualifier::Forced,
forced,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
});
assert!(forced_sub.is_forced);
let buf = Cursor::new(Vec::new());
let tracks = [video, forced_sub];
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// FlagForced ID: 0x55AA (2-byte ID)
assert!(
find_id(&data, ebml::FLAG_FORCED).is_some(),
"FlagForced element should be present for forced subtitle track"
);
})
};
let tracks = [
sub(Codec::Pgs, true),
sub(Codec::Pgs, false),
sub(Codec::DvdSub, true),
sub(Codec::DvdSub, false),
];
assert!(tracks[0].is_forced);
let data = MkvMuxer::new(Cursor::new(Vec::new()), &tracks, None, 60.0, &[])
.unwrap()
.writer
.into_inner();
let flags: Vec<Option<u64>> = track_entries(&data)
.iter()
.map(|&te| child_uint(&data, te, ebml::FLAG_FORCED))
.collect();
assert_eq!(flags, vec![Some(1), Some(0), Some(1), None]);
}
#[test]
@@ -4062,6 +4110,27 @@ mod tests {
(te_start, te_size as usize)
}
// Every TrackEntry body (start, size), walked through Segment -> Tracks.
fn track_entries(data: &[u8]) -> Vec<(usize, usize)> {
let (tracks_start, tracks_size) = segment_children(data)
.into_iter()
.find_map(|(id, off, sz)| (id == ebml::TRACKS).then_some((off, sz as usize)))
.expect("Tracks element present");
master_children(data, tracks_start, tracks_size)
.into_iter()
.filter(|(id, _, _)| *id == ebml::TRACK_ENTRY)
.map(|(_, off, sz)| (off, sz as usize))
.collect()
}
// The uint value of the direct child `id` of a master body, if present.
fn child_uint(data: &[u8], (start, size): (usize, usize), id: u32) -> Option<u64> {
let (_, off, sz) = master_children(data, start, size)
.into_iter()
.find(|(cid, _, _)| *cid == id)?;
ebml::read_uint_val(&mut Cursor::new(&data[off..]), sz as usize).ok()
}
/// Find every Cluster: returns Vec<(cluster_data_start_abs, cluster_data_size, cluster_timestamp_ms)>.
fn find_clusters(data: &[u8]) -> Vec<(usize, u64, u64)> {
let mut out = Vec::new();
@@ -4814,7 +4883,49 @@ mod tests {
.write_frame(1, 20_000_000, true, &[0xCC; 8], None, None)
.unwrap();
let err = muxer.finish().unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
// Exactly MkvInvalid: the code that marks an empty title as a skippable stub.
assert_eq!(
crate::error::error_code(&err),
Some(crate::error::E_MKV_INVALID)
);
assert!(crate::error::is_skippable_title_stub(&err));
}
// A seam plan that drops MORE frames than were written fails SeamPlanDroppedMost; dropping
// exactly as many as were written still succeeds.
#[test]
fn seam_plan_dropping_most_frames_fails_the_mux() {
let clip = |id: &str, secs: u32| crate::disc::Clip {
feed_span: None,
clip_id: id.into(),
in_time: secs * 45_000,
out_time: (secs + 100) * 45_000,
duration_secs: 100.0,
source_packets: 0,
};
let clips = [clip("00000", 100), clip("00001", 200)];
let run = |kept: &[i64]| {
let mut m = MkvMuxer::new(
Cursor::new(Vec::new()),
&[make_video_track()],
None,
100.0,
&[],
)
.unwrap();
m.set_clips(&clips, crate::disc::ContentFormat::BdTs);
// 0 s and 1 s sit before the first IN mark: both dropped.
for pts in [0, 1_000_000_000].iter().chain(kept) {
let _ = m.write_frame(0, *pts, true, &[0x01; 16], None, None);
}
m.finish().map(|_| ())
};
let e = run(&[150_000_000_000]).unwrap_err();
assert_eq!(
crate::error::error_code(&e),
Some(crate::error::E_SEAM_PLAN_DROPPED_MOST)
);
run(&[150_000_000_000, 151_000_000_000]).expect("2 dropped, 2 written");
}
#[test]
@@ -4825,7 +4936,11 @@ mod tests {
let tracks = [make_video_track()];
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
let err = muxer.finish().unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
assert_eq!(
crate::error::error_code(&err),
Some(crate::error::E_MKV_INVALID)
);
assert!(crate::error::is_skippable_title_stub(&err));
}
// A seam-plan clip list whose marks exclude every frame must fail with `SinkWroteNothing`,
@@ -4892,14 +5007,13 @@ mod tests {
for t in &ts {
assert!(*t <= i64::MAX as u64, "cluster ts must not have wrapped");
}
// Every block's relative timestamp stays within the i16 range (no silent
// wrap from the 40s back-jump).
for (cluster_ts, rel, abs) in all_block_timestamps(&data) {
assert!(
(MIN_BLOCK_REL..=MAX_BLOCK_REL).contains(&(rel as i64)),
"block rel {rel} wrapped i16 (cluster_ts={cluster_ts}, abs={abs})"
);
}
// Every block's cluster ts + i16 rel reconstructs its own pts (0.1 ms ticks): a
// wrapped rel from the 40s back-jump would land the audio elsewhere.
let abs: Vec<i64> = all_block_timestamps(&data)
.iter()
.map(|&(_, _, abs)| abs)
.collect();
assert_eq!(abs, vec![0, 400_000, 0], "video 0 s, video 40 s, audio 0 s");
// Every cluster carries a Cue (including the back-dated split cluster),
// so the seek index has no hole.
assert_eq!(
@@ -5041,6 +5155,37 @@ mod tests {
);
}
// Open-GOP B-frames shown before the cluster's keyframe carry a NEGATIVE rel ts, and the
// positive i16 edge 32767 stays in the cluster. (A step back past the 3 s discontinuity
// threshold is re-based, so -32768 is not reachable for video.)
#[test]
fn simple_block_rel_ts_negative_and_i16_edge() {
let data = mux_with_durations(
&[make_video_track()],
&[
(0, 0, true, vec![0xAA], None),
(0, 4_000_000_000, true, vec![0xAB], None), // cluster 2 at 40_000 ticks
(0, 3_600_000_000, false, vec![0xAC], None), // -4_000
(0, 1_100_000_000, false, vec![0xAD], None), // -29_000
(0, 7_276_700_000, false, vec![0xAE], None), // 40_000 + 32_767
],
);
let second: Vec<(i16, i64)> = all_block_timestamps(&data)
.iter()
.filter(|&&(c, _, _)| c == 40_000)
.map(|&(_, r, a)| (r, a))
.collect();
assert_eq!(
second,
vec![
(0, 40_000),
(-4_000, 36_000),
(-29_000, 11_000),
(32_767, 72_767)
]
);
}
// BlockGroup (Matroska §6.2.4): a Block inside a BlockGroup carries BlockDuration,
// and its keyframe flag bit (0x80) MUST be 0 (keyframe-ness is signalled by
// absence of ReferenceBlock). PGS subtitle frames take this path.
@@ -5378,18 +5523,18 @@ mod tests {
#[test]
fn keyframe_at_cluster_boundary_opens_new_cluster() {
let tracks = [make_video_track()];
// Keyframe at exactly 3000 ms (>= the 3 s cluster window) → new cluster.
// Keyframe at exactly 2000 ms (== the 2 s cluster window) → new cluster.
let data = mux_with_durations(
&tracks,
&[
(0, 0, true, vec![0xAA], None),
(0, 3_000_000_000, true, vec![0xBB], None),
(0, 2_000_000_000, true, vec![0xBB], None),
],
);
assert_eq!(
find_clusters(&data).len(),
2,
"keyframe at the 3s boundary must open a second cluster"
"keyframe at the 2s boundary must open a second cluster"
);
}
@@ -7166,4 +7311,221 @@ mod tests {
assert!(find_clusters(&data).len() >= 2, "split expected");
assert_eq!(parse_cues(&data).len(), 1, "only the keyframe is a cue");
}
fn uhd_video(hdr: HdrFormat, color_space: ColorSpace) -> VideoStream {
VideoStream {
pid: 0x1011,
codec: Codec::Hevc,
resolution: Resolution::R2160p,
frame_rate: crate::disc::FrameRate::F23_976,
hdr,
color_space,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
}
}
// An SDR BT.2020 UHD title (MPLS dynamic_range 0) is BT.2020-10 transfer, not PQ; only an
// HDR format may select PQ/HLG.
#[test]
fn sdr_bt2020_is_bt2020_transfer_not_pq() {
let (m, t, p, _) = cicp_for_video(&uhd_video(HdrFormat::Sdr, ColorSpace::Bt2020));
assert_eq!(
(m, t, p),
(
CICP_MATRIX_BT2020NC,
CICP_TRANSFER_BT2020_10,
CICP_PRIMARIES_BT2020
),
"SDR BT.2020 must not be tagged PQ"
);
assert_eq!(
cicp_for_video(&uhd_video(HdrFormat::Hdr10, ColorSpace::Bt2020)).1,
CICP_TRANSFER_PQ
);
assert_eq!(
cicp_for_video(&uhd_video(HdrFormat::Hlg, ColorSpace::Bt2020)).1,
CICP_TRANSFER_HLG
);
// Agrees with the enum-only mapping the FVI header documents.
let c = crate::mux::videomap::Colour::from_color_space(ColorSpace::Bt2020);
assert_eq!(c.transfer, t);
}
#[derive(Clone)]
struct SharedBuf(std::sync::Arc<std::sync::Mutex<Cursor<Vec<u8>>>>);
impl Write for SharedBuf {
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(b)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl Seek for SharedBuf {
fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
self.0.lock().unwrap().seek(pos)
}
}
// A stream whose codec has no Matroska CodecID is left out of the file instead of being
// declared A_AC3 / S_HDMV/PGS / V_MPEG2; the mappable tracks keep their frames.
#[test]
fn mkv_stream_leaves_out_unmappable_codecs() {
use crate::pes::Stream as _;
let audio = |codec| {
crate::disc::Stream::Audio(AudioStream {
pid: 0x1100,
codec,
channels: crate::disc::AudioChannels::Stereo,
language: "eng".into(),
sample_rate: crate::disc::SampleRate::S48,
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
})
};
let sub = |codec| {
crate::disc::Stream::Subtitle(SubtitleStream {
pid: 0x1200,
codec,
language: "eng".into(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
})
};
let mut v = uhd_video(HdrFormat::Sdr, ColorSpace::Bt709);
v.codec = Codec::H264;
let title = crate::disc::DiscTitle {
streams: vec![
crate::disc::Stream::Video(v),
audio(Codec::Unknown(0)),
sub(Codec::Srt),
audio(Codec::Ac3),
],
codec_privates: vec![None; 4],
..crate::disc::DiscTitle::empty()
};
let out = SharedBuf(std::sync::Arc::new(std::sync::Mutex::new(Cursor::new(
Vec::new(),
))));
let mut s = super::super::MkvStream::create(Box::new(out.clone()), &title, None).unwrap();
s.set_track_timing(1, Default::default()).unwrap();
for (track, keyframe) in [(0, true), (1, true), (2, true), (3, true)] {
s.write(&crate::pes::PesFrame {
discard_padding_ns: 0,
coding: None,
source: None,
track,
pts: 0,
keyframe,
data: vec![0x10 + track as u8; 8],
duration_ns: None,
})
.unwrap();
}
s.finish().unwrap();
let bytes = out.0.lock().unwrap().get_ref().clone();
let mut back = super::super::MkvStream::open(Cursor::new(bytes)).unwrap();
let codecs: Vec<Codec> = (back.info().streams.iter())
.map(|s| match s {
crate::disc::Stream::Video(v) => v.codec,
crate::disc::Stream::Audio(a) => a.codec,
crate::disc::Stream::Subtitle(s) => s.codec,
})
.collect();
assert_eq!(
codecs,
vec![Codec::H264, Codec::Ac3],
"only mappable tracks"
);
let mut got = Vec::new();
while let Some(f) = back.read().unwrap() {
got.push((f.track, f.data));
}
assert_eq!(got, vec![(0, vec![0x10; 8]), (1, vec![0x13; 8])]);
}
// dvcC level follows the 2160p frame rate; unknown rate keeps the 2160p24 level.
#[test]
fn dv_level_follows_the_frame_rate() {
use crate::disc::FrameRate as F;
for (fr, want) in [
(F::F23_976, 6),
(F::F24, 6),
(F::F25, 7),
(F::F29_97, 7),
(F::F50, 9),
(F::F59_94, 9),
(F::Unknown, 6),
] {
let mut v = uhd_video(HdrFormat::DolbyVision, ColorSpace::Bt2020);
v.frame_rate = fr;
let c = MkvTrack::video(&v).dv_config.unwrap();
assert_eq!(((c[2] & 1) << 5) | (c[3] >> 3), want, "{fr:?}");
}
}
// A huge caller-supplied display aspect must not overflow DisplayWidth; an
// unrepresentable width falls back to the pixel grid. DVD 16:9 still widens.
#[test]
fn display_aspect_overflow_keeps_pixel_grid() {
let mut v = uhd_video(HdrFormat::Sdr, ColorSpace::Bt709);
v.display_aspect = Some((u32::MAX, 1));
let t = MkvTrack::video(&v);
assert_eq!((t.display_width, t.display_height), (3840, 2160));
v.display_aspect = Some((u32::MAX / 2, u32::MAX / 4));
assert_eq!(MkvTrack::video(&v).display_width, 4320, "2:1 of 2160");
}
// The CHAPTERS SeekHead back-patch with no recorded offset is a typed error, not a
// panic after the whole mux.
#[test]
fn chapters_fixup_without_offset_is_an_error() {
let ch = [Chapter {
time_secs: 0.0,
name: "1".into(),
}];
let mut m = MkvMuxer::new(
Cursor::new(Vec::new()),
&[make_video_track()],
None,
0.0,
&ch,
)
.unwrap();
m.write_frame(0, 0, true, &[0x65, 1], None, None).unwrap();
m.chapters_offset = None;
let e = m.finish().unwrap_err();
assert_eq!(
crate::error::error_code(&e),
Some(crate::error::E_MKV_UNENCODABLE)
);
}
// Untrusted SPU chains: a 2-node cycle, a next offset past the end, a 0x07 length overrun
// and a sub-header SPU all end as None. The cycle runs on a thread with a deadline so an
// unbounded walk fails instead of hanging the suite.
#[test]
fn vobsub_display_ns_rejects_malformed_chains() {
// DCSQ A at 4 -> B at 10 -> A, each STA_DSP + CMD_END, no stop.
let cycle = vec![0, 16, 0, 4, 0, 0, 0, 10, 0x01, 0xFF, 0, 0, 0, 4, 0x01, 0xFF];
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || tx.send(vobsub_display_ns(&cycle)));
let got = rx.recv_timeout(std::time::Duration::from_secs(5));
assert_eq!(got, Ok(None), "2-node cycle must terminate with None");
let past_end = [0, 9, 0, 4, 0, 0, 0, 200, 0xFF];
assert_eq!(vobsub_display_ns(&past_end), None);
let overrun = [0, 12, 0, 4, 0, 0, 0, 4, 0x07, 0xFF, 0xFF, 0x02];
assert_eq!(vobsub_display_ns(&overrun), None);
assert_eq!(vobsub_display_ns(&[0, 3, 0]), None);
// Control: the stop in the SECOND DCSQ is found.
let ok = [
0, 16, 0, 4, 0, 0, 0, 10, 0x01, 0xFF, 0, 10, 0, 10, 0x02, 0xFF,
];
assert_eq!(vobsub_display_ns(&ok), Some(10 * SPU_DELAY_NS));
}
}
+12 -9
View File
@@ -649,6 +649,7 @@ impl MkvStream {
// which has no track). Streams after the dependent shift down by one.
let mut stream_to_track: Vec<Option<usize>> = Vec::with_capacity(title.streams.len());
let excluded = super::ps::UnstoredExtensions::new(title, "Matroska");
let mut unmapped = false;
for (idx, s) in title.streams.iter().enumerate() {
if Some(idx) == skip_stream_idx {
stream_to_track.push(None);
@@ -658,10 +659,12 @@ impl MkvStream {
stream_to_track.push(None);
continue;
}
let mut track = match s {
crate::disc::Stream::Video(v) => MkvTrack::video(v),
crate::disc::Stream::Audio(a) => MkvTrack::audio(a),
crate::disc::Stream::Subtitle(s) => MkvTrack::subtitle(s),
// No Matroska CodecID: left out (fit_report plans it out), never mislabelled.
let Some(mut track) = MkvTrack::from_stream(s) else {
tracing::warn!(target: "mux", stream = idx, "codec has no Matroska mapping; left out");
unmapped = true;
stream_to_track.push(None);
continue;
};
// Only first video and first audio are default
if track.is_default {
@@ -678,10 +681,10 @@ impl MkvStream {
tracks.push(track);
}
// Assemble the MVC merge only when active — i.e. a dependent AND a
// distinct base video both exist (established above). `base_stream_idx`
// then always has a built track, so its remap is `Some` (no panic path).
let remap = (!mvc_active && !excluded.is_empty()).then(|| stream_to_track.clone());
// The MVC merge needs a dependent, a distinct base and a built base track (an unmapped
// base yields no merge). An unmapped stream forces the remap even under MVC.
let remap =
(unmapped || (!mvc_active && !excluded.is_empty())).then(|| stream_to_track.clone());
let mvc = match (mvc_active, dep_stream_idx, base_stream_idx) {
(true, Some(dep_stream_idx), Some(base_stream_idx)) => stream_to_track
.get(base_stream_idx)
@@ -959,7 +962,7 @@ impl MkvStream {
}
// `(track, Some(depth) | None=undecidable)` for tracks decidable from `frames`.
// Past the budget, a measured but ambiguous span takes ffmpeg's 16-bit default.
// Past the budget, a measured but ambiguous span takes the common 16-bit default.
fn try_infer_pcm(
&self,
frames: &[crate::pes::PesFrame],
-1
View File
@@ -2040,7 +2040,6 @@ fn an_mpg_source_remuxes_to_mkv() {
skip_errors: false,
batch_sectors: 8192,
raw: false,
send_deadline: Some(std::time::Duration::from_secs(60)),
selection: Default::default(),
};
let out = {
+2 -2
View File
@@ -8,7 +8,7 @@
use super::meta;
use crate::disc::DiscTitle;
use crate::halt::{Halt, POLL_INTERVAL};
use crate::halt::{Halt, WAIT_SLICE};
use rustix::event::{PollFd, PollFlags, Timespec};
use std::io::{self, BufReader, BufWriter, Read, Write};
use std::net::{IpAddr, TcpListener, TcpStream, ToSocketAddrs};
@@ -72,7 +72,7 @@ struct HaltRead {
impl HaltRead {
fn new(stream: TcpStream, halt: Option<Halt>) -> Self {
Self::with_tick(stream, halt, POLL_INTERVAL)
Self::with_tick(stream, halt, WAIT_SLICE)
}
fn with_tick(stream: TcpStream, halt: Option<Halt>, tick: std::time::Duration) -> Self {
+141 -10
View File
@@ -5,14 +5,15 @@
use super::meta;
use crate::disc::DiscTitle;
use std::io::{self, Write};
use std::io::{self, Read, Write};
/// Stdio stream — reads PES from stdin, writes PES to stdout.
/// FMKV metadata header is written/read automatically.
pub struct StdioStream {
disc_title: DiscTitle,
reader: Option<io::Stdin>,
writer: Option<io::BufWriter<io::Stdout>>,
// Boxed so tests can drive the header logic without real stdin/stdout.
reader: Option<Box<dyn Read + Send + Sync>>,
writer: Option<io::BufWriter<Box<dyn Write + Send + Sync>>>,
header_written: bool,
header_read: bool,
/// True once an FMKV header was actually parsed on the read side
@@ -31,9 +32,13 @@ pub struct StdioStream {
impl StdioStream {
/// Create a stdio stream for reading (stdin).
pub fn input() -> Self {
Self::from_reader(Box::new(io::stdin()))
}
fn from_reader(reader: Box<dyn Read + Send + Sync>) -> Self {
Self {
disc_title: DiscTitle::empty(),
reader: Some(io::stdin()),
reader: Some(reader),
writer: None,
header_written: false,
header_read: false,
@@ -45,10 +50,14 @@ impl StdioStream {
/// Create a stdio stream for writing (stdout).
pub fn output(title: &DiscTitle) -> Self {
Self::from_writer(title, Box::new(io::stdout()))
}
fn from_writer(title: &DiscTitle, writer: Box<dyn Write + Send + Sync>) -> Self {
Self {
disc_title: title.clone(),
reader: None,
writer: Some(io::BufWriter::new(io::stdout())),
writer: Some(io::BufWriter::new(writer)),
header_written: false,
header_read: false,
meta_parsed: false,
@@ -76,12 +85,15 @@ impl StdioStream {
if self.header_read {
return Ok(());
}
self.header_read = true;
if let Some(ref mut r) = self.reader {
// Propagate real header errors: read_header consumes stdin bytes BEFORE it can
// fail, so swallowing the Err would misalign the stream and make deserialize read
// garbage. Ok(None) (magic mismatch / clean EOF) stays non-error (default title).
if let Some(m) = meta::read_header(r)? {
// stdin cannot rewind, so any header failure after bytes were consumed is an error
// (never a misaligned frame read); only a clean zero-byte EOF is headerless.
let mut counted = Counted { inner: r, n: 0 };
let header = meta::read_header(&mut counted)?;
if header.is_none() && counted.n > 0 {
return Err(crate::error::Error::NoMetadata.into());
}
if let Some(m) = header {
self.disc_title = m.to_title();
self.timings = (0..self.disc_title.streams.len())
.map(|i| m.timing(i))
@@ -90,10 +102,27 @@ impl StdioStream {
self.meta_parsed = true;
}
}
// Set only on success: a failed header leaves the next read() failing, not parsing
// mid-header bytes as frames.
self.header_read = true;
Ok(())
}
}
// Counts the bytes a header probe consumed from a non-rewindable reader.
struct Counted<'a, R: ?Sized> {
inner: &'a mut R,
n: u64,
}
impl<R: Read + ?Sized> Read for Counted<'_, R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let got = self.inner.read(buf)?;
self.n += got as u64;
Ok(got)
}
}
impl crate::pes::Stream for StdioStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
self.ensure_header_read()?;
@@ -169,6 +198,13 @@ mod tests {
use super::*;
use crate::pes::Stream as _;
// The pub type keeps the auto traits it had when it held Stdin/Stdout.
#[test]
fn stdio_stream_is_send_and_sync() {
fn check<T: Send + Sync>() {}
check::<StdioStream>();
}
fn title_with_codec_privates() -> DiscTitle {
use crate::disc::{Codec, Stream, VideoStream};
let mut t = DiscTitle::empty();
@@ -265,4 +301,99 @@ mod tests {
assert!(s.info().streams.is_empty());
assert_eq!(s.codec_private(0), None);
}
fn frame(data: &[u8]) -> crate::pes::PesFrame {
crate::pes::PesFrame {
discard_padding_ns: 0,
coding: None,
source: None,
track: 0,
pts: 7,
keyframe: true,
data: data.to_vec(),
duration_ns: None,
}
}
fn reader(bytes: Vec<u8>) -> StdioStream {
StdioStream::from_reader(Box::new(io::Cursor::new(bytes)))
}
fn code(e: &io::Error) -> Option<u16> {
crate::error::error_code(e)
}
#[derive(Clone, Default)]
struct Shared(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl Write for Shared {
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
// Write side -> read side over the FMKV wire: header parsed on the first read (title,
// codec_private, timing), headers_ready only after it, timing frozen once written.
#[test]
fn header_round_trips_and_gates_readiness() {
let out = Shared::default();
let mut w = StdioStream::from_writer(&title_with_codec_privates(), Box::new(out.clone()));
let timing = crate::pes::TrackTiming {
codec_delay_ns: 5,
seek_preroll_ns: 9,
};
w.set_track_timing(0, timing).unwrap();
w.write(&frame(&[1, 2, 3])).unwrap();
let e = w.set_track_timing(0, timing).unwrap_err();
assert_eq!(code(&e), Some(crate::error::E_STREAM_HEADER_WRITTEN));
w.finish().unwrap();
let mut r = reader(out.0.lock().unwrap().clone());
assert!(!r.headers_ready());
let f = r.read().unwrap().unwrap();
assert_eq!((f.pts, f.data), (7, vec![1, 2, 3]));
assert!(r.headers_ready());
assert_eq!(r.info().playlist, "StdioTitle");
assert_eq!(r.codec_private(0), Some(vec![0xDE, 0xAD, 0xBE, 0xEF]));
assert_eq!(r.track_timing(0), timing);
assert!(r.read().unwrap().is_none());
}
// A zero-byte stdin is a clean headerless end, and never header-ready.
#[test]
fn empty_input_is_clean_eof_not_ready() {
let mut r = reader(Vec::new());
assert!(r.read().unwrap().is_none());
assert!(!r.headers_ready(), "no header was parsed");
}
// Bytes that are not an FMKV header were consumed from a non-rewindable stream, so the
// frames after them cannot be aligned: NoMetadata, not a misread frame.
#[test]
fn non_fmkv_input_is_no_metadata() {
for lead in [&[0x00u8][..], b"FMKX\0\x01\0\0"] {
let mut bytes = lead.to_vec();
frame(&[4, 5]).serialize(&mut bytes).unwrap();
let e = reader(bytes).read().unwrap_err();
assert_eq!(code(&e), Some(crate::error::E_NO_METADATA), "{lead:?}");
}
}
// A header that failed mid-way is not skipped on a retry: the next read must not parse
// the rest of the header region as frames.
#[test]
fn failed_header_is_not_retried_as_frames() {
let mut bytes = b"FMKV\0\x01\0\0".to_vec();
bytes.extend_from_slice(&u32::MAX.to_be_bytes()); // over the JSON size cap
frame(&[4, 5]).serialize(&mut bytes).unwrap();
let mut r = reader(bytes);
assert_eq!(
code(&r.read().unwrap_err()),
Some(crate::error::E_NO_METADATA)
);
assert!(r.read().is_err(), "retry must not yield a frame");
}
}
+1 -1
View File
@@ -309,7 +309,7 @@ pub const KS_26_FMTS_ANCHOR_EVIDENCE: SpecQuote = SpecQuote {
kind: QuoteKind::Evidence,
source: EVIDENCE,
section: "FMTS anchor contract (key decode server)",
locator: "kdb decode-server main.rs:1640-1688",
locator: "key service FMTS anchor handler",
url: "",
text: "the decode server returns the whole set only for an index-1 anchor, and 422 for the \
wrong phase",
+2 -3
View File
@@ -8,9 +8,8 @@ use super::{QuoteKind, SpecQuote};
const H222: &str = "ITU-T Rec. H.222.0 (02/2000) | ISO/IEC 13818-1:2000";
const H222_URL: &str = "https://www.itu.int/rec/T-REC-H.222.0-200002-S/en";
const FFMPEG_MPEGENC: &str = "FFmpeg 6.1.3, libavformat/mpegenc.c";
const FFMPEG_MPEGENC_URL: &str =
"https://github.com/FFmpeg/FFmpeg/blob/n6.1.3/libavformat/mpegenc.c";
const FFMPEG_MPEGENC: &str = "FFmpeg 6.1.3 MPEG-PS muxer, mpegenc.c";
const FFMPEG_MPEGENC_URL: &str = "https://github.com/FFmpeg/FFmpeg/tree/n6.1.3";
pub const MS_1_PACK: SpecQuote = SpecQuote {
id: "MS-1",
+1 -1
View File
@@ -30,7 +30,7 @@ KS-22 static int _verify_ts(uint8_t *buf) { int i; for (i = 0; i < ALIGNED_UNIT_
KS-23 crypto_error = crypto_aes128e(aacs->uk->uk[curr_uk].key, out_buf, key); if (crypto_error) { LOG_CRYPTO_ERROR(DBG_AACS, "unit key derivation failed", crypto_error); return 1; } for (a = 0; a < 16; a++) { key[a] ^= out_buf[a]; /* here out_buf is plain data from in_buf */ } // test-only reference
KS-24 /* plain ? */ if (!(buf[0] & 0xc0)) { // TP_extra_header Copy_permission_indicator == 0, unit is not encrypted return 1; } // test-only reference
KS-25 AACS 2.x (UHD): the 64-byte Unit_Key_RO.inf stride and the FMTS IndividualSegment.tbl / .fmts model have no public book. They are pinned by the qa UHD fixture and aacs-unit-grid.md §2.
KS-26 FMTS anchor contract: the decode server returns the whole set only for an index-1 anchor, and 422 for the wrong phase (kdb decode-server main.rs:1640-1688)
KS-26 FMTS anchor contract: the decode server returns the whole set only for an index-1 anchor, and 422 for the wrong phase (key service FMTS anchor handler)
KS-27 Please note: The AACS Specifications books for HD DVD and CBHD have been removed from this AACS website due to inactivity.
KS-28 File-anchored grid on real discs: 32/32 sync on the file grid vs 0–1/32 on the disc-LBA grid (aacs-unit-grid.md §2 table). This corroborates KS-1.
KS-29 The Licensed Drive reads Volume ID (Volume_ID) from the media and calculates a message authentication code (Dm) from the Volume ID and the Bus Key (BK) calculated in step 26 in Section 4.3. // test-only reference