Fix mux concealing decryptable video over a single defective packet

AACS content decryption rejected a whole 6144-byte aligned unit unless
EVERY content packet was conformant MPEG-TS. One authored-bad packet (a
pressing/encoding defect or an AACS 2.1 forensic-variant frame) made the
mux conceal the entire unit as NULL TS — destroying up to 31/32 good
packets and tallying them as loss, surfacing as false "corruption" on
otherwise-clean discs (observed across two UHD titles).

decrypt_unit now asks only "did a key OPEN this unit?" — a padding-aware
>=75% supermajority of content packets restoring their 0x47 sync, a gate
no wrong key can reach (uniform-AES noise floor) yet one that tolerates a
minority of authored-bad packets. Opened units pass through VERBATIM; a
non-conforming packet is left for the demuxer to drop on sync-loss and
resync past. TS-sync conformance is a muxer concern, never a decryption
verdict. The post-read verify/sweep gate now shares the same primitive so
it can never disagree with the mux decrypt.

Also unify the MVC (Blu-ray 3D) track signals: the mvcC CodecPrivate
extension, the BlockAdditionMapping, and each per-frame BlockAdditional
all derive from one MVCDecoderConfigurationRecord built once per track, so
a malformed dependent-view parameter set can no longer orphan a BlockAddID.
This commit is contained in:
Matthew Jackson
2026-07-14 14:43:06 -07:00
parent f99670ceaa
commit 6858cd064d
4 changed files with 475 additions and 60 deletions
+33
View File
@@ -1,5 +1,38 @@
# Changelog
## [1.4.1] — 2026-07-14
### Fixed
- **Mux no longer discards good video over a single defective packet.** AACS
content decryption judged a 6144-byte aligned unit "undecryptable" unless
**every** content packet was conformant MPEG-TS. A single authored-bad packet
— a pressing/encoding defect, or an AACS 2.1 forensic-variant frame — made the
mux conceal the **whole** unit as NULL TS, destroying up to 31 of 32 good
packets and tallying them as loss. On discs carrying such packets this
surfaced as false "corruption" over large runs of otherwise-perfect video
(observed across two UHD titles: ~466 MB concealed, every unit decryptable).
The decrypt path now asks only *"did a key OPEN this unit?"* — a padding-aware
**≥75% supermajority** of content packets restoring their `0x47` sync, a gate
no wrong key can reach (uniform-AES noise floor ≈ 256⁻ⁿ) yet one that tolerates
a minority of authored-bad packets. Opened units pass through **verbatim**; a
non-conforming packet is left for the demuxer to drop on sync-loss and resync
past — TS-sync conformance is a muxer concern, never a decryption verdict. The
read/decrypt path no longer rewrites content bytes. The post-read verify/sweep
gate now shares the exact same primitive (`decrypt_unit` for TS), so verify can
never disagree with the mux decrypt and never false-marks a defect unit as a
bad read.
- **MVC (Blu-ray 3D) track signals unified and hardened.** The `mvcC`
`CodecPrivate` extension, the `BlockAdditionMapping`, and each frame's
`BlockAdditional` now all derive from a single `MVCDecoderConfigurationRecord`
built once per track, so they can no longer diverge. A track is flagged 3D
only when that record actually builds — a malformed dependent-view parameter
set no longer emits a mapping with no matching record (previously the flag was
taken from `mvc_params.is_some()`, which could orphan a `BlockAddID`). The base
track's `CodecPrivate` now carries the `mvcC` extension block
(`avcC ‖ u32be(size) ‖ "mvcC" ‖ record`, Matroska-spec size = block 4) so
players and mediainfo detect MVC at the track level.
## [1.4.0] — 2026-07-13
### Added
+249 -41
View File
@@ -113,51 +113,68 @@ pub fn aacs_unit_needs_decrypt(unit: &[u8]) -> bool {
aacs_unit_encrypted(unit) && ts_sync_destroyed(unit)
}
/// PADDING-AWARE "is this aligned unit STILL genuine ciphertext?" — the conceal-
/// path twin of [`decrypt_unit`]'s acceptance criterion, run on the POST-decrypt
/// bytes.
/// The one canonical "did a key OPEN this unit?" signal — a padding-aware,
/// defect-TOLERANT structural check on the POST-decrypt bytes.
///
/// [`aacs_unit_needs_decrypt`] cannot answer this: it composes CPI with the
/// MAJORITY-VOTE [`ts_sync_destroyed`] (`ts_sync_count <= total/2`). A
/// successfully padding-aware-decrypted content-fragment TAIL unit (e.g. 11 of 32
/// packets are real content, the other 21 source-zero padding) carries only 11 TS
/// syncs after decrypt, so the majority vote calls it "destroyed" and
/// `aacs_unit_needs_decrypt` returns true — even though the unit decrypted
/// PERFECTLY. Concealing on that predicate overwrites the good decrypted tail with
/// NULL-TS, silently discarding correct video (the bug this fixes; the 1.2.0
/// fragment-tail fix in [`decrypt_unit`] must not be undone by the conceal loop).
/// ARCHITECTURE (why this is the only TS-sync test the read/decrypt path may
/// use): TS-sync presence is a *muxer* concern, not a decryption verdict. The
/// read/decrypt path is not allowed to reject or conceal a unit just because a
/// packet isn't conformant MPEG-TS — a non-conforming packet inside an otherwise
/// perfectly-decrypted unit is an authoring/pressing defect (or an AACS 2.1
/// forensic-variant frame), which the demuxer drops on sync-loss and resyncs
/// past. The ONLY thing the decrypt path legitimately needs from TS structure is
/// KEY SELECTION: "did this candidate key turn ciphertext back into MPEG-TS at
/// all?" — used to pick among held keys (multi-CPS-unit discs) and to detect a
/// genuinely-missing key. That is a coarse, all-or-nothing question, and this is
/// its answer.
///
/// The correct, padding-aware notion of "still ciphertext", checkable on the
/// post-decrypt bytes, uses the SAME discriminator [`decrypt_unit`] uses:
/// * A genuinely-FAILED unit was restored to on-disc ciphertext by
/// `decrypt_unit_try_keys`/`decrypt_buf` → its packets are scrambled: a
/// non-padding (non-zero payload) packet LACKS the `0x47` sync at offset 4.
/// * A SUCCESSFULLY-decrypted unit (full OR padding-tail) → every non-zero
/// (content) packet carries `0x47`; padding packets are all-zero.
/// Verdict: the key opened the unit iff a SUPERMAJORITY (>= 75%) of the
/// non-padding content packets carry their `0x47` TS sync. Rationale:
/// * WRONG key → AES output is uniform random → each content packet carries
/// `0x47` at offset 4 with probability 1/256 → ~0 synced. Reaching 75% by
/// chance is cryptographically impossible (e.g. 17-of-22 ≈ 256^-17). So a
/// wrong key can NEVER pass — no silent-corruption hole.
/// * RIGHT key → every real content packet decrypts to clear TS. A handful of
/// authored-bad packets (pressing defects / variant frames) legitimately
/// lack `0x47`, but they are a small minority and MUST NOT reject the unit —
/// the decrypted bytes (defects and all) pass through verbatim; the muxer
/// handles the bad packets. This is the whole point: correctness of a
/// *packet* is not a decryption verdict.
///
/// So this is true iff `aacs_unit_encrypted` AND at least one 192-byte packet
/// whose 188-byte payload (`[off+4..off+192]`) is NOT all-zero is missing its
/// `0x47` sync at `off+4`. A full content unit (no zero-payload packets) reduces
/// to the strict all-32 check, so the common cases are unchanged: a fully-clear /
/// fully-decrypted unit is never flagged; a fully-ciphertext unit always is.
pub fn aacs_unit_still_ciphertext(unit: &[u8]) -> bool {
if !aacs_unit_encrypted(unit) {
return false;
}
/// Padding-aware: a 192-byte packet whose 188-byte payload is all-zero is source
/// padding (or a NULL packet) and is excluded from the count, so a legitimate
/// content-fragment TAIL (a few real packets + source-zero padding) is judged on
/// its real packets only. A full content unit reduces to "nearly all 32 synced".
pub fn unit_content_decrypted(unit: &[u8]) -> bool {
const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192
let limit = ALIGNED_UNIT_LEN.min(unit.len());
let mut content = 0usize;
let mut synced = 0usize;
let mut off = 0;
while off + PKT <= limit {
// A packet whose 188-byte payload is all-zero is padding (source zeros) —
// excluded from the verdict, exactly as `decrypt_unit` excludes it. Any
// other (content) packet that lacks its TS sync is un-restored ciphertext.
// All-zero payload → padding / NULL packet → excluded from the verdict.
let payload = &unit[off + 4..off + PKT];
if !payload.iter().all(|&b| b == 0) && unit[off + 4] != TS_SYNC {
return true;
if !payload.iter().all(|&b| b == 0) {
content += 1;
if unit[off + 4] == TS_SYNC {
synced += 1;
}
}
off += PKT;
}
false
// No content packets (all padding) → trivially "opened". Otherwise require a
// >=75% supermajority of content packets restored — the wrong-key-proof gate.
content == 0 || synced * 4 >= content * 3
}
/// "Is this aligned unit STILL genuine ciphertext (no held key opened it)?" — the
/// conceal-path twin of [`unit_content_decrypted`], run on the POST-decrypt
/// bytes. True iff the unit is flagged encrypted (CPI set) AND no key opened it
/// (below the supermajority-sync gate). A unit the right key opened — even one
/// carrying a few defective packets — is NOT ciphertext and is never concealed;
/// its bytes belong to the muxer.
pub fn aacs_unit_still_ciphertext(unit: &[u8]) -> bool {
aacs_unit_encrypted(unit) && !unit_content_decrypted(unit)
}
/// Overwrite an aligned unit (6144 bytes) IN PLACE with valid NULL MPEG-TS
@@ -349,20 +366,25 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
// Final 6128 bytes of the aligned unit under the Block Key; first 16 = clear seed. [BD] §3.10.1.
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
// Verify content packets; zero out padding packets (their decrypted bytes are
// garbage from AES-decrypting zeros, but the source was zero so a clean zero
// fill is lossless and gives the demux a tidy gap instead of garbage).
// Restore source-zero padding packets to zero (their decrypted bytes are
// garbage from AES-decrypting zeros, but the source WAS zero, so writing the
// true source back is faithful — not concealment — and gives the demux a tidy
// gap instead of AES noise). Content packets are left EXACTLY as decrypted:
// the read/decrypt path never rewrites content, so an authored-bad packet
// (no `0x47`) passes through verbatim for the muxer to drop.
for (p, &is_pad) in pad.iter().enumerate().take(npkt) {
let off = p * PKT;
if is_pad {
let off = p * PKT;
for b in unit[off..off + PKT].iter_mut() {
*b = 0;
}
} else if unit[off + 4] != TS_SYNC {
return false; // a real content packet failed → genuinely undecryptable
}
}
true
// KEY-SELECTION verdict only: did this key OPEN the unit (restore the TS
// structure of a supermajority of content packets)? A wrong key can't; the
// right key can even when a few content packets are authored-bad. This is NOT
// a per-packet conformance gate — that belongs to the muxer.
unit_content_decrypted(unit)
}
/// Decrypt an AACS aligned unit in place, accepting the key only when `accept`
@@ -882,6 +904,192 @@ mod tests {
);
}
// ── Defect-tolerant "did a key OPEN this unit?" verdict ─────────────────
//
// The Bourne-UHD bug: a commercial disc carries the odd authored-bad TS
// packet (a pressing/encoding defect, or an AACS 2.1 forensic-variant frame)
// — one non-conforming packet inside an otherwise perfectly-decrypted 6144
// unit. The OLD strict per-packet acceptance rejected the WHOLE unit over
// that single packet, so the mux concealed ~all of it as NULL and 21/22 good
// video packets were destroyed and tallied as loss ("false corruption").
// The read/decrypt path must instead ACCEPT the unit (the key opened it) and
// pass the defective packet through VERBATIM for the muxer to drop. TS-sync
// conformance is a muxer concern, never a decryption verdict.
/// Build a unit that decrypts to 32 content packets, with `defect_pkts`
/// marked authored-bad (a non-`0x47` at the sync position + non-zero payload
/// so they count as genuine content, not padding), encrypted under `key`.
fn unit_with_defects(key: &[u8; 16], defect_pkts: &[usize]) -> Vec<u8> {
let mut unit = clear_unit();
for &p in defect_pkts {
let off = p * BD_SOURCE_PACKET_BYTES;
unit[off + 4] = 0x80; // NOT a TS sync
unit[off + 5] = 0xAB; // non-zero payload => real content, not padding
}
aacs_encrypt_unit(&mut unit, key);
unit
}
/// A POST-DECRYPT-looking unit: `content` non-padding packets, `synced` of
/// them carrying `0x47`; the remaining packets are source-zero padding. CPI
/// (encrypted flag) set iff `cpi`. Feeds the key-independent predicates.
fn decrypted_shape(content: usize, synced: usize, cpi: bool) -> Vec<u8> {
let mut u = vec![0u8; ALIGNED_UNIT_LEN];
for p in 0..content {
let off = p * BD_SOURCE_PACKET_BYTES;
u[off + 5] = 0xAB; // non-zero payload => counted as content
u[off + 4] = if p < synced { TS_SYNC } else { 0x80 };
}
if cpi {
u[0] |= 0xC0;
}
u
}
#[test]
fn single_authored_bad_packet_still_decrypts_and_passes_verbatim() {
// 1 defective content packet in an otherwise-perfect unit (the real case).
let key = [0x5Au8; 16];
let mut unit = unit_with_defects(&key, &[17]);
assert!(
decrypt_unit(&mut unit, &key),
"31/32 content packets synced -> the key OPENED the unit"
);
let off = 17 * BD_SOURCE_PACKET_BYTES;
assert_eq!(
unit[off + 4],
0x80,
"defect packet's bytes pass through VERBATIM (no null-fill, no zeroing)"
);
assert_eq!(unit[off + 5], 0xAB, "defect payload untouched");
assert!(
!aacs_unit_still_ciphertext(&unit),
"an opened unit is NOT ciphertext -> the mux never conceals it"
);
for p in 0..32 {
if p == 17 {
continue;
}
assert_eq!(
unit[p * BD_SOURCE_PACKET_BYTES + 4],
TS_SYNC,
"every other packet restored its sync (pkt {p})"
);
}
}
#[test]
fn several_defect_packets_within_tolerance_still_decrypt() {
// Up to 25% authored-bad content packets are tolerated (opened + passed
// through); the muxer drops them.
let key = [0x33u8; 16];
let mut unit = unit_with_defects(&key, &[3, 9, 17, 24, 30]); // 5/32 ≈ 16%
assert!(
decrypt_unit(&mut unit, &key),
"27/32 synced (>=75%) -> opened"
);
}
#[test]
fn wrong_key_never_opens_a_unit() {
// A wrong key restores ~0 syncs -> far below the supermajority gate.
let key = [0x5Au8; 16];
let mut unit = clear_unit();
aacs_encrypt_unit(&mut unit, &key);
assert!(
!decrypt_unit(&mut unit, &[0x22u8; 16]),
"wrong key cannot open the unit"
);
}
#[test]
fn threshold_accepts_at_75pct_and_rejects_just_below() {
// 24/32 = exactly 75% opens; 23/32 does not.
assert!(unit_content_decrypted(&decrypted_shape(32, 24, false)));
assert!(!unit_content_decrypted(&decrypted_shape(32, 23, false)));
}
#[test]
fn still_ciphertext_tracks_the_open_verdict() {
// Fully clean -> opened, not ciphertext.
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 32, true)));
// One defect -> still opened, still not ciphertext.
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 31, true)));
// Wrong-key noise floor -> not opened -> ciphertext (concealable).
assert!(aacs_unit_still_ciphertext(&decrypted_shape(32, 3, true)));
// CPI-clear bytes are never "ciphertext" regardless of sync count.
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 0, false)));
}
#[test]
fn all_padding_unit_is_trivially_opened() {
// CPI set but every packet is source-zero padding: nothing to decrypt.
assert!(unit_content_decrypted(&decrypted_shape(0, 0, true)));
assert!(!aacs_unit_still_ciphertext(&decrypted_shape(0, 0, true)));
}
#[test]
fn defect_count_boundary_via_real_decrypt() {
// Pin the 75% gate on the REAL decrypt path (not just the predicate):
// 8/32 defects => 24 synced = exactly 75% => opens; 9/32 => 23 synced =>
// does NOT open. Packets 1.. avoid the clear-seed packet 0.
let key = [0x77u8; 16];
let eight: Vec<usize> = (1..9).collect();
let mut u_ok = unit_with_defects(&key, &eight);
assert!(
decrypt_unit(&mut u_ok, &key),
"8 defects (24/32 = 75%) -> opened"
);
let nine: Vec<usize> = (1..10).collect();
let mut u_no = unit_with_defects(&key, &nine);
assert!(
!decrypt_unit(&mut u_no, &key),
"9 defects (23/32 < 75%) -> NOT opened (too corrupt / wrong key)"
);
}
#[test]
fn small_content_units_keep_the_wrong_key_floor() {
// Tiny content units are where a coincidental wrong-key sync matters most.
// The gate stays strict enough that a single fluke can't "open" them.
assert!(unit_content_decrypted(&decrypted_shape(2, 2, false))); // 2/2 -> open
assert!(!unit_content_decrypted(&decrypted_shape(2, 1, false))); // 1/2 -> not
assert!(unit_content_decrypted(&decrypted_shape(4, 3, false))); // 3/4=75% -> open
assert!(!unit_content_decrypted(&decrypted_shape(4, 2, false))); // 2/4 -> not
}
#[test]
fn fragment_tail_padding_plus_one_defect_still_decrypts() {
// Both tolerances at once: source-zero padding tail EXCLUDED, and the one
// authored-bad packet in the real prefix TOLERATED. 20 real packets
// (pkt 5 defective) => 19/20 synced => opened; padding emitted as zeros.
let key = [0x5Au8; 16];
let mut unit = clear_unit();
let off = 5 * BD_SOURCE_PACKET_BYTES;
unit[off + 4] = 0x80; // defect inside the real content
unit[off + 5] = 0xAB;
for b in unit[20 * BD_SOURCE_PACKET_BYTES..].iter_mut() {
*b = 0; // source-zero padding tail (packets 20..32)
}
aacs_encrypt_unit(&mut unit, &key);
assert!(
decrypt_unit(&mut unit, &key),
"19/20 real packets synced + zero pad -> opened"
);
assert_eq!(
unit[off + 4],
0x80,
"the defect packet passes through verbatim"
);
for p in 20..32 {
let o = p * BD_SOURCE_PACKET_BYTES;
assert!(
unit[o..o + BD_SOURCE_PACKET_BYTES].iter().all(|&b| b == 0),
"padding pkt {p} emitted as clean zeros"
);
}
}
#[test]
fn wrong_key_full_unit_is_not_decryptable() {
let mut unit = clear_unit();
+74 -11
View File
@@ -6,8 +6,11 @@
//! decrypt-verify are meaningful is each clip's FILE-anchored 6144-byte unit
//! grid (clips can start off the 6144 grid and fragment across UDF extents). So
//! this gate BUFFERS the disc-absolute read stream and re-ALIGNS it into
//! clip-file units, then applies the standards-correct
//! [`crate::aacs::content::unit_is_clean_ts`] gate (all-32 TS syncs).
//! clip-file units, then decrypts each with the SAME primitive the mux/rip path
//! uses ([`crate::aacs::content::decrypt_unit`] for TS) so verify and the real
//! decrypt can never disagree. That verdict is "did a key OPEN this unit?" —
//! defect-TOLERANT: an opened unit carrying a few authored-bad packets (pressing
//! defects / forensic-variant frames) is NOT bad; only a unit no key opens is.
//!
//! FAIL-SAFE CONTRACT (this sits in the middle of every read, so it must never
//! break a good read): the gate can ONLY downgrade a unit it is *confident* is
@@ -62,6 +65,15 @@ pub enum ContainerKind {
Ps,
}
/// PS-container per-unit decrypt for the verify gate — the analogue of
/// [`aacs::content::decrypt_unit`] (TS) for HD-DVD program-stream content. Wraps
/// [`aacs::content::decrypt_unit_checked`] with the pack-start structural check.
/// A free `fn` (not a closure) so it has the same `fn(&mut [u8], &[u8;16]) -> bool`
/// type as `decrypt_unit`, letting [`Verifier::decrypt_for`] return either.
fn decrypt_unit_ps(unit: &mut [u8], key: &[u8; 16]) -> bool {
aacs::content::decrypt_unit_checked(unit, key, aacs::content::unit_is_clean_ps)
}
/// A clip's on-disc layout: declared file size, its absolute disc extents in
/// FILE order, and its stream container. `extents` is `(disc_lba, byte_len)`;
/// the verifier reuses exactly the same `(abs_lba, byte_len)` extents the
@@ -185,7 +197,9 @@ impl UnitVerifier {
})
}
/// The post-decrypt structural check for a clip's container.
/// The post-decrypt structural check for a clip's container — used ONLY on the
/// CPI-clear branch (a plaintext unit that is structurally clean is
/// decryptable-as-is; if not, we return Unknown, never bad).
fn accept_for(&self, clip: u32) -> fn(&[u8]) -> bool {
match self.containers[clip as usize] {
ContainerKind::Ts => aacs::content::unit_is_clean_ts,
@@ -193,6 +207,21 @@ impl UnitVerifier {
}
}
/// The per-unit DECRYPT for a clip's container — the key-selection primitive
/// verify uses to prove a candidate key opens an ENCRYPTED unit. It MUST be
/// the exact same primitive the mux/rip decrypt path uses, so verify and the
/// real decrypt never disagree (a unit the mux will happily decrypt must not
/// be marked bad here). For TS that is [`aacs::content::decrypt_unit`], whose
/// verdict is defect-TOLERANT (a supermajority of content packets restored) —
/// an authored-bad packet or forensic-variant frame no longer false-flags the
/// whole unit as undecryptable.
fn decrypt_for(&self, clip: u32) -> fn(&mut [u8], &[u8; 16]) -> bool {
match self.containers[clip as usize] {
ContainerKind::Ts => aacs::content::decrypt_unit,
ContainerKind::Ps => decrypt_unit_ps,
}
}
/// Feed a just-read, just-`Finished` disc byte range (`bytes` starts at disc
/// sector `disc_lba`). Routes each backing sector into its clip-file unit;
/// every unit that becomes fully assembled is verified immediately. Returns
@@ -215,7 +244,8 @@ impl UnitVerifier {
self.fill(clip, unit, slot, lba, &bytes[off..off + sector]);
if let Some((raw, lbas)) = self.take_if_complete(clip, unit) {
let accept = self.accept_for(clip);
match self.decryptability(&raw, accept) {
let decrypt = self.decrypt_for(clip);
match self.decryptability(&raw, accept, decrypt) {
Decryptability::Undecryptable => push_ranges(&mut bad, &lbas),
Decryptability::Decryptable | Decryptability::Unknown => {}
}
@@ -309,6 +339,7 @@ impl UnitVerifier {
&mut self,
raw: &[u8; ALIGNED_UNIT_LEN],
accept: fn(&[u8]) -> bool,
decrypt: fn(&mut [u8], &[u8; 16]) -> bool,
) -> Decryptability {
// CPI clear -> the unit is plaintext by spec (no key needed). If it is
// structurally clean, it is decryptable-as-is. If it ISN'T, we
@@ -327,7 +358,7 @@ impl UnitVerifier {
};
}
// Encrypted: any held key that decrypts to a structurally-clean unit.
if self.try_keys(raw, accept) {
if self.try_keys(raw, decrypt) {
return Decryptability::Decryptable;
}
// No held key works. Ask the application's key source ONCE for this
@@ -350,7 +381,7 @@ impl UnitVerifier {
self.fetch_spent = true; // service has nothing new; stop asking
return Decryptability::Unknown;
}
if self.try_keys(raw, accept) {
if self.try_keys(raw, decrypt) {
return Decryptability::Decryptable;
}
return Decryptability::Undecryptable; // service's keys don't open it -> bad ciphertext
@@ -359,12 +390,18 @@ impl UnitVerifier {
Decryptability::Unknown
}
/// True if any currently-held key decrypts `raw` to a structurally-clean unit
/// under the container's `accept` check.
fn try_keys(&self, raw: &[u8; ALIGNED_UNIT_LEN], accept: fn(&[u8]) -> bool) -> bool {
/// True if any currently-held key OPENS `raw` — i.e. the container's per-unit
/// `decrypt` (the same primitive the mux uses) reports the key restored the
/// unit's TS structure. Defect-tolerant for TS: an opened unit carrying a few
/// authored-bad packets still counts as decryptable (never marked bad).
fn try_keys(
&self,
raw: &[u8; ALIGNED_UNIT_LEN],
decrypt: fn(&mut [u8], &[u8; 16]) -> bool,
) -> bool {
for k in &self.keys {
let mut scratch = *raw;
if aacs::content::decrypt_unit_checked(&mut scratch, k, accept) {
if decrypt(&mut scratch, k) {
return true;
}
}
@@ -439,9 +476,10 @@ impl UnitVerifier {
}
}
let accept = self.accept_for(clip);
let decrypt = self.decrypt_for(clip);
if readable
&& matches!(
self.decryptability(&raw, accept),
self.decryptability(&raw, accept, decrypt),
Decryptability::Undecryptable
)
{
@@ -898,6 +936,31 @@ mod tests {
);
}
#[test]
fn reverify_iso_defect_packet_unit_is_not_flagged_bad() {
// A unit with ONE authored-bad content packet (a pressing defect). The
// held key OPENS it (31/32 syncs restored), so verify must AGREE with the
// mux decrypt and NOT mark it bad — the bad packet is the muxer's problem,
// not a read/decrypt failure. (Old strict all-32 gate flagged it bad.)
let key = [0x5a; 16];
let mut u = clear_unit();
let off = 17 * 192; // corrupt packet 17's sync in the plaintext
u[off + 4] = 0x80;
u[off + 5] = 0xAB;
encrypt_unit(&mut u, &key);
let mut iso = MockIso {
sectors: Default::default(),
err_lba: None,
};
place_unit(&mut iso, [100, 101, 102], &u);
let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[key]), None).unwrap();
let bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)], &|_| true);
assert!(
bad.is_empty(),
"defect-packet unit is opened by the held key -> not flagged bad"
);
}
#[test]
fn reverify_iso_fragmented_unit_reads_distant_sectors() {
let key = [0x5a; 16];
+119 -8
View File
@@ -121,6 +121,29 @@ fn mvc_decoder_config_record(subset_sps: &[u8], pps: &[u8]) -> Option<Vec<u8>> {
Some(record)
}
/// Build the `CodecPrivate` for an MVC (Blu-ray 3D) base track: the base view's
/// `AVCDecoderConfigurationRecord` (`avcc`) followed by an `mvcC` extension
/// block, per the Matroska Codec Specifications §4.3.9:
///
/// ```text
/// avcC ‖ u32be(extension_block_size 4) ‖ "mvcC" ‖ MVCDecoderConfigurationRecord
/// ```
///
/// The size field is the extension block length **excluding the 4-byte size
/// field itself** — i.e. `4 ("mvcC") + record.len()`. This is the track-level
/// MVC signal that decoders and mediainfo read (the per-frame `BlockAdditional`
/// under the `mvcC` BlockAdditionMapping carries the dependent view's data). A
/// plain (2D) track never calls this — it writes its `avcc` verbatim.
fn mvc_codec_private(avcc: &[u8], record: &[u8]) -> Vec<u8> {
let ext_size = (4 + record.len()) as u32; // "mvcC" (4) + record; = block size 4
let mut out = Vec::with_capacity(avcc.len() + 8 + record.len());
out.extend_from_slice(avcc);
out.extend_from_slice(&ext_size.to_be_bytes());
out.extend_from_slice(b"mvcC");
out.extend_from_slice(record);
out
}
/// Resolve a video stream's CICP colour code points — `(matrix, transfer,
/// primaries, range)`, ITU-T H.273 — using a single precedence so EVERY sink
/// (the MKV muxer here AND the FVI sidecar in `videomap.rs`) agrees and can
@@ -882,9 +905,23 @@ impl<W: Write + Seek> MkvMuxer<W> {
let mut track_uids: Vec<u64> = Vec::with_capacity(tracks.len());
let mut ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup> =
std::collections::HashMap::new();
// Per track: whether it emitted a conforming `mvcC` BlockAdditionMapping.
// Filled below from the SAME built record that drives the CodecPrivate
// mvcC extension, so the three MVC signals never diverge.
let mut track_has_mvc_mapping: Vec<bool> = Vec::with_capacity(tracks.len());
for (i, track) in tracks.iter().enumerate() {
let track_uid = (i + 1) as u64 | 0x100_0000;
track_uids.push(track_uid);
// Build the MVC (Blu-ray 3D) MVCDecoderConfigurationRecord ONCE per
// track from the dependent view's subset-SPS/PPS. `None` for every
// non-3D track (and if the params are malformed) — the single source
// of truth for the CodecPrivate mvcC extension, the
// BlockAdditionMapping, and whether BlockAdditionals are conforming.
let mvc_record = track
.mvc_params
.as_ref()
.and_then(|(sps, pps)| mvc_decoder_config_record(sps, pps));
track_has_mvc_mapping.push(mvc_record.is_some());
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?;
ebml::write_uint(&mut writer, ebml::TRACK_UID, track_uid)?;
@@ -904,7 +941,20 @@ impl<W: Write + Seek> MkvMuxer<W> {
}
if let Some(ref cp) = track.codec_private {
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?;
match mvc_record.as_ref() {
// MVC (Blu-ray 3D) base track: CodecPrivate = base-view avcC
// followed by the `mvcC` extension block. This is the
// track-level signal decoders/mediainfo read to recognise the
// stereoscopic MVC track (the per-frame dependent view rides
// in BlockAdditional under the mapping below).
Some(record) => {
let cp_mvc = mvc_codec_private(cp, record);
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, &cp_mvc)?;
}
// Non-MVC (2D/UHD/audio/…): write the codec_private verbatim —
// the unchanged path, byte-identical to a 2D mux.
None => ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?,
}
}
// Pre-0.13 a deferred codecPrivate path existed for video tracks
// (placeholder reserve + later seek-back fill via
@@ -1004,8 +1054,8 @@ impl<W: Write + Seek> MkvMuxer<W> {
// Video) carries the mvcC MVCDecoderConfigurationRecord so players /
// mediainfo recognise the dependent (right-eye) view that rides as a
// per-frame BlockAdditional under this mapping (BlockAddIDValue = 2).
if let Some((subset_sps, pps)) = track.mvc_params.as_ref() {
if let Some(record) = mvc_decoder_config_record(subset_sps, pps) {
match mvc_record.as_ref() {
Some(record) => {
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
ebml::write_uint(
&mut writer,
@@ -1013,18 +1063,25 @@ impl<W: Write + Seek> MkvMuxer<W> {
BLOCK_ADD_ID_VALUE_MVC,
)?;
ebml::write_uint(&mut writer, ebml::BLOCK_ADD_ID_TYPE, BLOCK_ADD_ID_TYPE_MVCC)?;
ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, &record)?;
ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, record)?;
ebml::end_master(&mut writer, map_pos)?;
} else {
}
// `mvc_params` present but the record failed to build (malformed
// parameter sets): no mapping, and `track_has_mvc_mapping` above
// is already `false`, so BlockAdditionals are dropped — the file
// stays conforming rather than carrying an orphaned BlockAddID.
None if track.mvc_params.is_some() => {
let (s, p) = track.mvc_params.as_ref().unwrap();
tracing::warn!(
target: "mux",
"MVC track: could not build MVCDecoderConfigurationRecord from the \
dependent view's parameter sets (subset_sps={} B, pps={} B); \
emitting no mvcC mapping the 3D pairing will not be signalled.",
subset_sps.len(),
pps.len(),
s.len(),
p.len(),
);
}
None => {}
}
// Dolby Vision signaling — BlockAdditionMapping is a child of the
@@ -1117,7 +1174,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
primary_video_track: tracks
.iter()
.position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO),
track_has_mvc_mapping: tracks.iter().map(|t| t.mvc_params.is_some()).collect(),
track_has_mvc_mapping,
continuity: TimelineContinuity::new(),
cues: Vec::new(),
frame_count: 0,
@@ -4600,6 +4657,60 @@ mod tests {
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
}
#[test]
fn mvc_codec_private_appends_mvcc_extension_block() {
// avcC ‖ u32be(4 + record.len()) ‖ "mvcC" ‖ record (Matroska Codec Spec §4.3.9).
let avcc = vec![0x01, 0x64, 0x00, 0x33, 0xFF, 0xE1, 0xAA];
let record = vec![0x01, 0x80, 0x00, 0x33, 0xBF, 0x01, 0xCC]; // 7 bytes
let out = mvc_codec_private(&avcc, &record);
assert_eq!(&out[..avcc.len()], &avcc[..], "avcC preserved verbatim");
// size field = 4 ("mvcC") + 7 (record) = 11 = extension block size minus 4.
assert_eq!(&out[avcc.len()..avcc.len() + 4], &11u32.to_be_bytes());
assert_eq!(&out[avcc.len() + 4..avcc.len() + 8], b"mvcC");
assert_eq!(
&out[avcc.len() + 8..],
&record[..],
"record after the mvcC fourcc"
);
}
#[test]
fn mvc_track_codec_private_carries_avcc_plus_mvcc() {
// An MVC base track's CodecPrivate must be the base avcC followed by the
// mvcC extension — the track-level signal mediainfo/decoders read.
let avcc = vec![
0x01, 0x64, 0x00, 0x33, 0xFF, 0xE1, 0x00, 0x05, 0x67, 0x64, 0x00, 0x33, 0x99,
];
let subset_sps = vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22];
let pps = vec![0x68, 0xEE, 0x3C];
let mut v = make_video_track();
v.codec_private = Some(avcc.clone());
v.mvc_params = Some((subset_sps.clone(), pps.clone()));
let data = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[])
.unwrap()
.writer
.into_inner();
let record = mvc_decoder_config_record(&subset_sps, &pps).unwrap();
let expected = mvc_codec_private(&avcc, &record);
assert!(
data.windows(expected.len())
.any(|w| w == expected.as_slice()),
"emitted CodecPrivate must be avcC + mvcC extension"
);
// A non-MVC (2D) track writes its avcC VERBATIM — no mvcC appended.
let mut v2 = make_video_track();
v2.codec_private = Some(avcc.clone());
let d2 = MkvMuxer::new(Cursor::new(Vec::new()), &[v2], None, 0.0, &[])
.unwrap()
.writer
.into_inner();
assert!(
!d2.windows(4).any(|w| w == b"mvcC"),
"2D track CodecPrivate must not carry an mvcC extension"
);
}
// ---- CodecPrivate emission (avcC / hvcC / VC-1 / MPEG-2) -------------
//
// `MkvTrack::video` always builds with `codec_private: None`; the PES mux