mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1045,6 +1045,7 @@ fn build(
|
||||
Verdict::Lazy(c) => c,
|
||||
_ => None,
|
||||
},
|
||||
clear: p.verdict == Verdict::Clear,
|
||||
});
|
||||
}
|
||||
Verdict::Missing | Verdict::Ask => {}
|
||||
|
||||
+99
-4
@@ -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
@@ -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 ───────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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],
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user