mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f1dbfd042 | ||
|
|
5b702a76a7 | ||
|
|
6be5198886 | ||
|
|
e2aa9abd6d | ||
|
|
3b7bee9ed4 | ||
|
|
b7405e2d27 | ||
|
|
e04d79c593 | ||
|
|
b34af1fa74 | ||
|
|
a7317f8885 | ||
|
|
474273afc0 | ||
|
|
0a2bab5789 | ||
|
|
fa4d7ef871 | ||
|
|
36d1af1b7f | ||
|
|
b96f6206fe | ||
|
|
bb32fb993b | ||
|
|
c1eb74dfa5 | ||
|
|
46838c63ca | ||
|
|
1e60220ff6 | ||
|
|
080f03e8ed | ||
|
|
35de6101d4 | ||
|
|
9012101573 | ||
|
|
8bc1de6c9b | ||
|
|
d94a4d3444 | ||
|
|
1bd7e1de2a | ||
|
|
da62ee7cf2 | ||
|
|
575c76156f | ||
|
|
b518860d9c | ||
|
|
c8eb42b490 | ||
|
|
48570ac065 | ||
|
|
08e46640fd | ||
|
|
a9195824ff | ||
|
|
401fe23988 | ||
|
|
f89bce5851 |
@@ -1,5 +1,60 @@
|
||||
# Changelog
|
||||
|
||||
## 0.29.0 (2026-06-06)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Video frames now carry their presentation timestamp (PTS), not the decode
|
||||
timestamp (DTS), as the MKV block timecode.** The HEVC, H.264, VC-1 and
|
||||
MPEG-2 parsers stamped each frame with `dts.or(pts)`, so B-frame titles were
|
||||
muxed with monotonic decode-order timecodes: players presented frames in
|
||||
decode order (visible judder / apparent "corruption") and PTS-based seeking
|
||||
landed on the wrong frame. The compressed video was always byte-correct —
|
||||
this was purely a timestamp defect, and it affected every B-frame title. The
|
||||
parsers now use `pts.or(dts)`. Verified against a reference muxer on a real
|
||||
UHD title: the emitted frame sequence is now identical.
|
||||
- **DTS-HD MA tracks no longer downgrade to the lossy DTS core** — the access
|
||||
unit is delimited by the next *validated* core sync, so a `0x7FFE8001` byte
|
||||
pattern inside the XLL extension can no longer truncate the unit. (#10)
|
||||
- **A resolved AACS key is verified against the content before it is applied**,
|
||||
so a stale or wrong VUK can no longer silently produce garbage output.
|
||||
- **`iso://` mux fails fast when no usable AACS key is available** instead of
|
||||
writing a garbage MKV.
|
||||
|
||||
### Changed
|
||||
|
||||
- Codec / EBML / TS hardening: bounds-checked PMT and PES-header parsing, EBML
|
||||
length validation, and bounded codec buffers (DTS, TrueHD, PGS).
|
||||
|
||||
## 0.27.3 (2026-06-04)
|
||||
|
||||
### Added
|
||||
|
||||
- **The mapfile now persists resolved AACS unit keys, not just the Volume ID.**
|
||||
A keyed disc writes its decrypted unit keys as `# freemkv-uk: <cps>:<hex>`
|
||||
comment headers; an unresolved disc writes only the `# freemkv-vid:` marker.
|
||||
The two are **mutually exclusive** (`Mapfile::set_unit_keys` clears the VID):
|
||||
unit keys are the final answer, so a deferred-mux / resume decrypts directly
|
||||
with no key-service round-trip, while the VID alone means "still unresolved —
|
||||
retry the key service." `CopyOptions`/`SweepOptions` gain a `unit_keys` field
|
||||
(written when non-empty, else the VID), and `Disc::inject_unit_keys` applies
|
||||
mapfile-recovered keys to a scanned disc (marking the source `ExternalUk`).
|
||||
|
||||
## 0.27.0 (2026-06-03)
|
||||
|
||||
### Changed
|
||||
|
||||
- **AACS unit-encryption detection now reads the raw MPEG-TS sync bytes
|
||||
instead of header flag bits.** `is_unit_encrypted` is renamed
|
||||
`is_aacs_scrambled`: a unit is encrypted iff its body TS packet sync bytes
|
||||
(`0x47` at offset 4 and every 192 bytes) are not intact — the encrypted
|
||||
body destroys them. The previous check read the TP_extra copy-control bits
|
||||
(byte 0) or TS transport_scrambling_control bits (byte 7), which discs and
|
||||
players do not set reliably. A single predicate now backs both the decrypt
|
||||
gate and out-of-band key validation, so all callers agree on what
|
||||
"encrypted" means. Decryption restores the syncs, so a decrypted unit reads
|
||||
as clear and there is no encryption flag to clear.
|
||||
|
||||
## 0.26.1 (2026-05-22)
|
||||
|
||||
### Added
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "0.26.6"
|
||||
version = "0.29.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
@@ -8,6 +8,8 @@ description = "Open source raw disc access library for optical drives"
|
||||
repository = "https://github.com/freemkv/libfreemkv"
|
||||
keywords = ["bluray", "uhd", "optical", "scsi", "disc"]
|
||||
categories = ["hardware-support", "multimedia"]
|
||||
# Keep internal AI-instruction / private notes out of the published crate.
|
||||
exclude = []
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
+50
-24
@@ -69,16 +69,29 @@ pub(crate) fn aes_cbc_decrypt(key: &[u8; 16], data: &mut [u8]) {
|
||||
|
||||
// ── Content decryption ──────────────────────────────────────────────────────
|
||||
|
||||
/// Check if a 6144-byte aligned unit is encrypted (copy_permission_indicator bits).
|
||||
pub fn is_unit_encrypted(unit: &[u8]) -> bool {
|
||||
unit.len() >= ALIGNED_UNIT_LEN && (unit[0] & 0xC0) != 0
|
||||
/// True if a 6144-byte aligned unit is AACS-scrambled on disc.
|
||||
///
|
||||
/// AACS encrypts the unit body, which destroys the MPEG-TS sync bytes (`0x47`)
|
||||
/// a clear unit carries at offsets 4, 196, 388, … (one per 192-byte source
|
||||
/// packet). So "scrambled" = "the TS syncs are NOT intact". This is
|
||||
/// flag-independent: it does NOT read the TP_extra copy-control bits (byte 0)
|
||||
/// or the TS scrambling-control bits (byte 7) — AACS sets neither reliably
|
||||
/// across discs/players.
|
||||
///
|
||||
/// This is the single shared definition of "encrypted" for the whole ecosystem
|
||||
/// — libfreemkv's decrypt gate, autorip's sample selection, and the online key
|
||||
/// service's validation gate all call THIS, so they always agree on what is
|
||||
/// encrypted. A correctly-decrypted (or natively-clear) unit reports `false`,
|
||||
/// so the decrypt path never double-decrypts and there is no flag to clear.
|
||||
pub fn is_aacs_scrambled(unit: &[u8]) -> bool {
|
||||
unit.len() >= ALIGNED_UNIT_LEN && !ts_syncs_intact(unit)
|
||||
}
|
||||
|
||||
/// Verify decrypted unit by checking TS sync bytes at expected offsets.
|
||||
fn verify_ts(unit: &[u8]) -> bool {
|
||||
// In a 6144-byte unit, TS packets start at byte 0 with 4-byte TP_extra_header
|
||||
// then 188-byte TS packet, repeating every 192 bytes.
|
||||
// Sync byte 0x47 should appear at offset 4, 196, 388, ...
|
||||
/// Most TS packet positions in `unit` carry the `0x47` sync byte — i.e. the
|
||||
/// unit looks like clear MPEG-TS. Syncs sit at offset 4 and every 192 bytes
|
||||
/// after (4-byte TP_extra_header + 188-byte TS packet). An encrypted body
|
||||
/// scrambles all but the first (which lives in the clear 16-byte seed).
|
||||
fn ts_syncs_intact(unit: &[u8]) -> bool {
|
||||
let mut count = 0;
|
||||
let mut offset = 4;
|
||||
while offset < unit.len() {
|
||||
@@ -87,11 +100,20 @@ fn verify_ts(unit: &[u8]) -> bool {
|
||||
}
|
||||
offset += TS_PACKET_LEN;
|
||||
}
|
||||
// Expect at least most packets to have sync bytes
|
||||
let total = (unit.len() - 4) / TS_PACKET_LEN + 1;
|
||||
// One sync byte is checked per 192-byte BD-TS packet (at offset 4 of
|
||||
// each). `total` is exactly that packet count; the old
|
||||
// `(len - 4) / TS_PACKET_LEN + 1` over-counted by one for lengths of
|
||||
// the form `4 + k·192` (harmless for the always-6144 aligned unit, but
|
||||
// wrong in general and it biased the majority threshold).
|
||||
let total = unit.len() / TS_PACKET_LEN;
|
||||
count > total / 2
|
||||
}
|
||||
|
||||
/// Verify a decrypted unit looks like clear MPEG-TS (sync bytes intact).
|
||||
fn verify_ts(unit: &[u8]) -> bool {
|
||||
ts_syncs_intact(unit)
|
||||
}
|
||||
|
||||
/// Decrypt one AACS aligned unit (6144 bytes) in-place.
|
||||
/// Returns true if decryption succeeded (verified by TS sync bytes).
|
||||
///
|
||||
@@ -99,12 +121,14 @@ fn verify_ts(unit: &[u8]) -> bool {
|
||||
/// 1. AES-128-ECB encrypt first 16 bytes with unit_key → derived
|
||||
/// 2. XOR derived with original 16 bytes → unit_decrypt_key
|
||||
/// 3. AES-128-CBC decrypt bytes 16..6143 with unit_decrypt_key and AACS IV
|
||||
/// 4. Clear encryption flag bits
|
||||
///
|
||||
/// Decryption restores the TS sync bytes, so the unit reads as clear afterward;
|
||||
/// there is no flag to clear.
|
||||
pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||
if unit.len() < ALIGNED_UNIT_LEN {
|
||||
return false;
|
||||
}
|
||||
if !is_unit_encrypted(unit) {
|
||||
if !is_aacs_scrambled(unit) {
|
||||
return true; // not encrypted
|
||||
}
|
||||
|
||||
@@ -124,16 +148,13 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool {
|
||||
// Step 3: Decrypt bytes 16..6143 with AES-CBC
|
||||
aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]);
|
||||
|
||||
// Step 4: Clear encryption flag
|
||||
unit[0] &= !0xC0;
|
||||
|
||||
// Verify
|
||||
// Decryption restored the TS syncs; verify the unit now looks like clear TS.
|
||||
verify_ts(unit)
|
||||
}
|
||||
|
||||
/// Decrypt one aligned unit trying multiple unit keys. Returns the key index that worked.
|
||||
pub fn decrypt_unit_try_keys(unit: &mut [u8], unit_keys: &[[u8; 16]]) -> Option<usize> {
|
||||
if !is_unit_encrypted(unit) {
|
||||
if !is_aacs_scrambled(unit) {
|
||||
return Some(0);
|
||||
}
|
||||
|
||||
@@ -173,7 +194,7 @@ pub fn decrypt_unit_full(
|
||||
unit_key: &[u8; 16],
|
||||
read_data_key: Option<&[u8; 16]>,
|
||||
) -> bool {
|
||||
if !is_unit_encrypted(unit) {
|
||||
if !is_aacs_scrambled(unit) {
|
||||
return true;
|
||||
}
|
||||
if let Some(rdk) = read_data_key {
|
||||
@@ -200,10 +221,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_unit_unencrypted() {
|
||||
// Unit with 0xC0 bits clear should pass through unchanged
|
||||
// A clear unit (TS syncs intact) is not scrambled → passes through.
|
||||
let mut unit = vec![0u8; ALIGNED_UNIT_LEN];
|
||||
unit[0] = 0x00; // not encrypted
|
||||
let mut off = 4;
|
||||
while off < ALIGNED_UNIT_LEN {
|
||||
unit[off] = TS_SYNC;
|
||||
off += TS_PACKET_LEN;
|
||||
}
|
||||
let key = [0u8; 16];
|
||||
assert!(!is_aacs_scrambled(&unit));
|
||||
assert!(decrypt_unit(&mut unit, &key));
|
||||
}
|
||||
|
||||
@@ -253,8 +279,8 @@ mod tests {
|
||||
plain[offset] = TS_SYNC;
|
||||
offset += TS_PACKET_LEN;
|
||||
}
|
||||
// Set encryption flag
|
||||
plain[0] |= 0xC0;
|
||||
// No flag set: CBC-encrypting the body below scrambles packets 1..31's
|
||||
// TS syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects.
|
||||
|
||||
// Now encrypt bytes 16..6143 using the AACS algorithm (reverse of decrypt)
|
||||
let header: [u8; 16] = plain[..16].try_into().unwrap();
|
||||
@@ -281,9 +307,9 @@ mod tests {
|
||||
|
||||
// Now plain contains encrypted data. Decrypt it.
|
||||
let mut unit = plain;
|
||||
assert!(is_unit_encrypted(&unit));
|
||||
assert!(is_aacs_scrambled(&unit));
|
||||
assert!(decrypt_unit(&mut unit, &unit_key));
|
||||
assert!(!is_unit_encrypted(&unit)); // flag should be cleared
|
||||
assert!(!is_aacs_scrambled(&unit)); // decrypted: TS syncs restored
|
||||
|
||||
// Verify TS sync bytes
|
||||
let mut count = 0;
|
||||
|
||||
+36
-14
@@ -223,6 +223,13 @@ impl super::provider::KeyProvider for KeyDb {
|
||||
fn processing_keys(&self) -> Vec<[u8; 16]> {
|
||||
self.processing_keys.clone()
|
||||
}
|
||||
fn media_keys(&self) -> Vec<[u8; 16]> {
|
||||
// Every per-disc Media Key in the db. The resolver dedups; MKs are
|
||||
// MKB-scoped so the same value recurs across a pressing's discs.
|
||||
self.iter_disc_entries()
|
||||
.filter_map(|e| e.media_key)
|
||||
.collect()
|
||||
}
|
||||
fn host_certs(&self) -> Vec<HostCert> {
|
||||
self.host_certs.clone()
|
||||
}
|
||||
@@ -448,8 +455,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_parse_disc_entry() {
|
||||
let line = r#"0x000102030405060708090A0B0C0D0E0F10111213 = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x000102030405060708090A0B0C0D0E0F | I | 0x101112131415161718191A1B1C1D1E1F | V | 0x202122232425262728292A2B2C2D2E2F | U | 1-0x303132333435363738393A3B3C3D3E3F ; MKBv77"#;
|
||||
let entry = KeyDb::parse_disc_entry(line).unwrap();
|
||||
// All-zero placeholders — synthetic; no real key material in code.
|
||||
let z40 = "00".repeat(20);
|
||||
let z32 = "00".repeat(16);
|
||||
let line = format!(
|
||||
"0x{z40} = SAMPLE_FILM (Sample Film) | D | 2024-01-01 | M | 0x{z32} | I | 0x{z32} | V | 0x{z32} | U | 1-0x{z32} ; MKBv77"
|
||||
);
|
||||
let entry = KeyDb::parse_disc_entry(&line).unwrap();
|
||||
assert_eq!(entry.title, "Sample Film");
|
||||
assert!(entry.media_key.is_some());
|
||||
assert!(entry.vuk.is_some());
|
||||
@@ -457,9 +469,14 @@ mod tests {
|
||||
assert_eq!(entry.unit_keys[0].0, 1);
|
||||
}
|
||||
|
||||
// NOTE: key fields below use obvious repeated-byte / zero placeholders
|
||||
// (0x01.., 0x02.., 0x03.., 0x00..). NEVER put real — or real-looking — host,
|
||||
// device, or processing key material in code; these tests exercise the
|
||||
// parser's field-splitting only, not any genuine key.
|
||||
|
||||
#[test]
|
||||
fn test_parse_device_key() {
|
||||
let line = "| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48";
|
||||
let line = "| DK | DEVICE_KEY 0x00000000000000000000000000000000 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; MKBv01-MKBv48";
|
||||
let dk = KeyDb::parse_device_key(line).unwrap();
|
||||
assert_eq!(dk.node, 0x0800);
|
||||
assert_eq!(dk.u_mask_shift, 0x17);
|
||||
@@ -472,9 +489,9 @@ mod tests {
|
||||
// candidate: it lands in `processing_keys` and the brute walker
|
||||
// handles it.
|
||||
let cfg = r#"
|
||||
| DK | DEVICE_KEY 0xDEADBEEF0001020304050607080900AA ; orphan, no position fields
|
||||
| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; positioned MKBv01-MKBv48
|
||||
| PK | 0xCAFEBABE0001020304050607080900BB ; legacy PK row still works
|
||||
| DK | DEVICE_KEY 0x01010101010101010101010101010101 ; orphan, no position fields
|
||||
| DK | DEVICE_KEY 0x02020202020202020202020202020202 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17 ; positioned MKBv01-MKBv48
|
||||
| PK | 0x03030303030303030303030303030303 ; legacy PK row still works
|
||||
"#;
|
||||
let db = KeyDb::parse(cfg);
|
||||
assert_eq!(
|
||||
@@ -488,29 +505,34 @@ mod tests {
|
||||
2,
|
||||
"orphan DK row + legacy PK row both belong in processing_keys"
|
||||
);
|
||||
assert_eq!(db.processing_keys[0][..4], [0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
assert_eq!(db.processing_keys[1][..4], [0xCA, 0xFE, 0xBA, 0xBE]);
|
||||
assert_eq!(db.processing_keys[0][..4], [0x01, 0x01, 0x01, 0x01]);
|
||||
assert_eq!(db.processing_keys[1][..4], [0x03, 0x03, 0x03, 0x03]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_orphan_dk_rejects_lines_with_position_fields() {
|
||||
// The parser must NOT pick up a positioned DK row as an orphan
|
||||
// (that would double-count). parse_orphan_dk explicitly checks.
|
||||
let positioned = "| DK | DEVICE_KEY 0x000102030405060708090A0B0C0D0E0F | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17";
|
||||
let positioned = "| DK | DEVICE_KEY 0x02020202020202020202020202020202 | DEVICE_NODE 0x0800 | KEY_UV 0x00000400 | KEY_U_MASK_SHIFT 0x17";
|
||||
assert!(
|
||||
KeyDb::parse_orphan_dk(positioned).is_none(),
|
||||
"positioned DK must not match orphan parser"
|
||||
);
|
||||
let orphan = "| DK | DEVICE_KEY 0xDEADBEEF0001020304050607080900AA";
|
||||
let orphan = "| DK | DEVICE_KEY 0x01010101010101010101010101010101";
|
||||
let key = KeyDb::parse_orphan_dk(orphan).expect("orphan should parse");
|
||||
assert_eq!(key[..4], [0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
assert_eq!(key[..4], [0x01, 0x01, 0x01, 0x01]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_host_cert() {
|
||||
let line = "| HC | HOST_PRIV_KEY 0xDEADBEEF000102030405060708090A0B0C0D0E0F | HOST_CERT 0x000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F202122232425262728292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F505152535455565758595A5B ; Revoked";
|
||||
let hc = KeyDb::parse_host_cert(line).unwrap();
|
||||
assert_eq!(hc.private_key[0], 0xDE);
|
||||
// 20-byte priv + 92-byte cert, all zeros — placeholders, not a key.
|
||||
let line = format!(
|
||||
"| HC | HOST_PRIV_KEY 0x{} | HOST_CERT 0x{} ; Revoked",
|
||||
"00".repeat(20),
|
||||
"00".repeat(92)
|
||||
);
|
||||
let hc = KeyDb::parse_host_cert(&line).unwrap();
|
||||
assert_eq!(hc.private_key, [0u8; 20]);
|
||||
assert_eq!(hc.certificate.len(), 92);
|
||||
}
|
||||
|
||||
|
||||
+128
-1
@@ -532,6 +532,27 @@ fn find_record_body(mkb: &[u8], rec_type_wanted: u8) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Real content length of an MKB: the byte offset where the record stream
|
||||
/// ends. MKB files (especially `MKB_RW.inf`, but `MKB_RO.inf` too on some
|
||||
/// discs) are allocated to a fixed size — often ~128 MiB — with the records at
|
||||
/// the front and the rest zero padding. Walking records (type+len) and stopping
|
||||
/// at the first padding byte (`type == 0` / zero-length / overrun) gives the
|
||||
/// actual size so callers can trim off megabytes of zeros before sending or
|
||||
/// archiving. Returns `mkb.len()` only if the whole buffer parsed as records.
|
||||
pub fn mkb_content_len(mkb: &[u8]) -> usize {
|
||||
let mut pos = 0;
|
||||
while pos + 4 <= mkb.len() {
|
||||
let rec_type = mkb[pos];
|
||||
let rec_len = u32::from_be_bytes([0, mkb[pos + 1], mkb[pos + 2], mkb[pos + 3]]) as usize;
|
||||
// A zero type, a zero/short length, or an overrun = records done, padding begun.
|
||||
if rec_type == 0x00 || rec_len < 4 || pos + rec_len > mkb.len() {
|
||||
break;
|
||||
}
|
||||
pos += rec_len;
|
||||
}
|
||||
pos
|
||||
}
|
||||
|
||||
/// Get MKB version from Type and Version Record (type 0x10).
|
||||
/// Version is a BE u32 at offset 8 of the record body (offset 12 from `pos`).
|
||||
pub fn mkb_version(mkb: &[u8]) -> Option<u32> {
|
||||
@@ -1082,6 +1103,30 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
|
||||
return Some(build(Some(vuk), derive_uks(&vuk), 2));
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_miss", pk_count = all_pks.len(), "PK derivation failed");
|
||||
|
||||
// Path 2.5: MK-pool brute. keydb stores Media Keys per-disc, but an
|
||||
// MK is MKB-scoped (shared across a pressing/MKB-family). A disc
|
||||
// whose own hash/VID isn't keyed can still resolve if ANY stored MK
|
||||
// verifies against its MKB. Try every distinct MK via km_verifies;
|
||||
// a UNIQUE pass is this disc's Km → derive VUK (needs VID) → UK.
|
||||
// km_verifies is one AES-D + magic check per candidate (cheap).
|
||||
let mks = providers.media_keys();
|
||||
let mut mk_hits: Vec<[u8; 16]> = Vec::new();
|
||||
for mk in &mks {
|
||||
if probe::km_verifies(mkb, mk) && !mk_hits.contains(mk) {
|
||||
mk_hits.push(*mk);
|
||||
if mk_hits.len() > 1 {
|
||||
break; // ambiguous — bail to avoid a wrong key
|
||||
}
|
||||
}
|
||||
}
|
||||
if mk_hits.len() == 1 {
|
||||
let vuk = derive_vuk(&mk_hits[0], ctx.volume_id);
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_5_hit", mk_pool = mks.len(), "media key from keydb MK-pool brute (km_verifies)");
|
||||
// Same class as path 3 (KEYDB MK → derived VUK).
|
||||
return Some(build(Some(vuk), derive_uks(&vuk), 3));
|
||||
}
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_path2_5_miss", mk_pool = mks.len(), mk_hits = mk_hits.len(), "MK-pool brute: no unique verifying MK");
|
||||
} else {
|
||||
tracing::warn!(target: "freemkv::disc", phase = "resolve_keys_no_mkb", "no MKB; paths 1/2 skipped");
|
||||
}
|
||||
@@ -1247,7 +1292,7 @@ mod tests {
|
||||
let original = std::fs::read(unit_path).unwrap();
|
||||
assert_eq!(original.len(), ALIGNED_UNIT_LEN);
|
||||
assert!(
|
||||
super::super::decrypt::is_unit_encrypted(&original),
|
||||
super::super::decrypt::is_aacs_scrambled(&original),
|
||||
"Unit should be encrypted"
|
||||
);
|
||||
|
||||
@@ -1374,6 +1419,26 @@ mod tests {
|
||||
assert_eq!(mkb_version(&mkb), Some(77));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkb_content_len_trims_trailing_padding() {
|
||||
// Two real records (0x10 type/version + 0x86 verify), then 128 KiB of
|
||||
// zero padding (the fixed-region tail). Content length must stop at the
|
||||
// end of the records, not include the padding.
|
||||
let mut mkb = vec![
|
||||
0x10, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4D,
|
||||
];
|
||||
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x18]);
|
||||
mkb.extend_from_slice(&[0xAB; 16]);
|
||||
mkb.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]);
|
||||
let records_len = mkb.len();
|
||||
mkb.extend(std::iter::repeat(0u8).take(128 * 1024)); // padding
|
||||
assert_eq!(mkb_content_len(&mkb), records_len);
|
||||
// No padding → returns the full length.
|
||||
assert_eq!(mkb_content_len(&mkb[..records_len]), records_len);
|
||||
// Empty → 0.
|
||||
assert_eq!(mkb_content_len(&[]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkb_version_returns_none_on_empty() {
|
||||
assert_eq!(mkb_version(&[]), None);
|
||||
@@ -1913,6 +1978,68 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_keys_path2_5_mk_pool_brute_resolves_unkeyed_disc() {
|
||||
// The Dunkirk case: this disc's own hash/VID are NOT in keydb, but its
|
||||
// Media Key IS — filed under a sibling disc that shares its MKB. Path
|
||||
// 2.5 must km_verifies that MK against the MKB and resolve.
|
||||
use super::super::decrypt::aes_ecb_encrypt as enc;
|
||||
|
||||
let km = [0x11u8; 16];
|
||||
let vid = [0x22u8; 16];
|
||||
|
||||
// MKB: 0x10 type/version + 0x86 verify record whose mk_dv decrypts under
|
||||
// km to the AACS verify magic, so km_verifies(mkb, km) == true.
|
||||
let mut vd = [0u8; 16];
|
||||
vd[..8].copy_from_slice(&[0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF]);
|
||||
let mk_dv = enc(&km, &vd);
|
||||
let mut mkb = mkb_record(0x10, &[0, 0, 0, 0x20, 0, 0, 0, 0x4D]);
|
||||
mkb.extend_from_slice(&mkb_record(0x86, &mk_dv));
|
||||
assert!(
|
||||
probe::km_verifies(&mkb, &km),
|
||||
"fixture: km must verify the MKB"
|
||||
);
|
||||
|
||||
// This disc's inf (its hash will NOT be in keydb).
|
||||
let uk_ro = minimal_unit_key_ro();
|
||||
|
||||
// keydb: a SIBLING disc carries our km, keyed by the sibling's own
|
||||
// hash + VID (neither matches THIS disc) — so only the MK-pool brute
|
||||
// (km_verifies) can find it.
|
||||
let mut keydb = KeyDb::empty();
|
||||
keydb.disc_entries.insert(
|
||||
"0xsibling".to_string(),
|
||||
DiscEntry {
|
||||
disc_hash: "0xsibling".to_string(),
|
||||
title: "sibling".to_string(),
|
||||
media_key: Some(km),
|
||||
disc_id: Some([0x99u8; 16]),
|
||||
vuk: None,
|
||||
unit_keys: Vec::new(),
|
||||
},
|
||||
);
|
||||
|
||||
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
||||
let ctx = ResolveContext {
|
||||
unit_key_ro: &uk_ro,
|
||||
content_cert: None,
|
||||
volume_id: &vid,
|
||||
providers,
|
||||
mkb: Some(&mkb),
|
||||
};
|
||||
let resolved = resolve_keys_v1(&ctx)
|
||||
.expect("MK-pool brute (path 2.5) must resolve a disc whose MK is in keydb");
|
||||
assert_eq!(
|
||||
resolved.key_source, 3,
|
||||
"MK-pool brute is the KEYDB-derived class"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.vuk,
|
||||
Some(derive_vuk(&km, &vid)),
|
||||
"VUK must derive from the verified Km + this disc's VID"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_cert_parse() {
|
||||
// AACS 1.0 cert
|
||||
|
||||
+3
-3
@@ -24,15 +24,15 @@ pub mod variants;
|
||||
// AES primitives (aes_ecb_encrypt, aes_ecb_decrypt, aes_cbc_decrypt) are pub(crate) in decrypt.rs.
|
||||
pub use decrypt::{
|
||||
ALIGNED_UNIT_LEN, decrypt_bus, decrypt_unit, decrypt_unit_full, decrypt_unit_try_keys,
|
||||
is_unit_encrypted,
|
||||
is_aacs_scrambled,
|
||||
};
|
||||
pub use keydb::{DeviceKey, DiscEntry, HostCert, KeyDb};
|
||||
pub use keys::probe;
|
||||
pub use keys::{
|
||||
AacsVersion, ContentCert, ResolveContext, ResolvedKeys, UnitKeyFile, decrypt_unit_key,
|
||||
derive_media_key_from_dk, derive_media_key_from_pk, derive_vuk, disc_hash, disc_hash_hex,
|
||||
mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive, resolve_keys_v1,
|
||||
resolve_keys_v2, resolve_keys_v21,
|
||||
mkb_content_len, mkb_version, parse_content_cert, parse_unit_key_ro, read_mkb_from_drive,
|
||||
resolve_keys_v1, resolve_keys_v2, resolve_keys_v21,
|
||||
};
|
||||
pub use provider::KeyProvider;
|
||||
pub use variants::{
|
||||
|
||||
@@ -48,6 +48,14 @@ pub trait KeyProvider: Send + Sync {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Every Media Key this provider holds, regardless of which disc it was
|
||||
/// filed under. An MK is MKB-scoped (shared across a pressing/MKB-family),
|
||||
/// so the resolver can verify each against the disc's MKB (`km_verifies`)
|
||||
/// and resolve a disc whose own hash/VID isn't directly keyed.
|
||||
fn media_keys(&self) -> Vec<[u8; 16]> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// AACS host certificates (with their private keys) for drive
|
||||
/// authentication. Multiple in case some are revoked.
|
||||
fn host_certs(&self) -> Vec<HostCert> {
|
||||
@@ -85,6 +93,15 @@ impl Providers<'_> {
|
||||
self.0.iter().flat_map(|p| p.processing_keys()).collect()
|
||||
}
|
||||
|
||||
/// Union of distinct Media Keys across every provider, for the MK-pool
|
||||
/// brute (`km_verifies` against the disc's MKB).
|
||||
pub fn media_keys(&self) -> Vec<[u8; 16]> {
|
||||
let mut v: Vec<[u8; 16]> = self.0.iter().flat_map(|p| p.media_keys()).collect();
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
/// Union — gather host certs from every provider. Not yet wired into
|
||||
/// the SCSI handshake (which still reads `KeyDb.host_certs` directly);
|
||||
/// kept here so a provider-aware handshake refactor is a drop-in.
|
||||
@@ -103,3 +120,42 @@ impl Providers<'_> {
|
||||
self.0.iter().find_map(|p| p.lookup_disc_by_vid(volume_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`KeyProvider`] backed by a single caller-supplied key's raw material —
|
||||
/// the bridge for [`crate::disc::Disc::decrypt_with`].
|
||||
///
|
||||
/// The application's key source did the lookup and handed in material at one
|
||||
/// level (DK / PK / MK / VUK). This exposes exactly that material to the
|
||||
/// version-dispatched resolver, which owns ALL derivation — so a source never
|
||||
/// derives, and the lib remains the single home for the AACS chain across
|
||||
/// 1.0 / 2.0 / 2.1 / 2.x.
|
||||
///
|
||||
/// Each level fills only its own field; the rest stay empty, so the resolver
|
||||
/// naturally runs the matching path (DK→…, PK→…, MK-pool brute, or a
|
||||
/// disc-keyed VUK hit). `decrypt_with` already knows the disc, so the
|
||||
/// `lookup_disc_by_*` hash/VID arguments are irrelevant — a present
|
||||
/// `disc_entry` is returned for any query.
|
||||
pub(crate) struct SuppliedKey {
|
||||
pub device_keys: Vec<DeviceKey>,
|
||||
pub processing_keys: Vec<[u8; 16]>,
|
||||
pub media_keys: Vec<[u8; 16]>,
|
||||
pub disc_entry: Option<DiscEntry>,
|
||||
}
|
||||
|
||||
impl KeyProvider for SuppliedKey {
|
||||
fn device_keys(&self) -> Vec<DeviceKey> {
|
||||
self.device_keys.clone()
|
||||
}
|
||||
fn processing_keys(&self) -> Vec<[u8; 16]> {
|
||||
self.processing_keys.clone()
|
||||
}
|
||||
fn media_keys(&self) -> Vec<[u8; 16]> {
|
||||
self.media_keys.clone()
|
||||
}
|
||||
fn lookup_disc_by_hash(&self, _disc_hash: &[u8; 20]) -> Option<DiscEntry> {
|
||||
self.disc_entry.clone()
|
||||
}
|
||||
fn lookup_disc_by_vid(&self, _volume_id: &[u8; 16]) -> Option<DiscEntry> {
|
||||
self.disc_entry.clone()
|
||||
}
|
||||
}
|
||||
|
||||
+11
-10
@@ -185,12 +185,13 @@ pub fn decrypt_sectors(
|
||||
let chunks: Vec<&mut [u8]> = buf.chunks_mut(unit_len).collect();
|
||||
let nunits = chunks.len();
|
||||
|
||||
// Per-unit decrypt closure. The is_unit_encrypted check is
|
||||
// a byte-0 heuristic; on a misfire we snapshot+restore via
|
||||
// the original bytes so non-m2ts (e.g. MPLS/CLPI nav files)
|
||||
// survive. See test `nav_file_unit_survives_decrypt_attempt`.
|
||||
// Per-unit decrypt closure. The is_aacs_scrambled check reads the
|
||||
// raw TS syncs; a non-m2ts unit (e.g. MPLS/CLPI nav file) can look
|
||||
// scrambled and trigger a decrypt attempt, so on a verify miss we
|
||||
// snapshot+restore the original bytes so it survives. See test
|
||||
// `nav_file_unit_survives_decrypt_attempt`.
|
||||
let decrypt_one = |chunk: &mut [u8]| {
|
||||
if chunk.len() == unit_len && aacs::is_unit_encrypted(chunk) {
|
||||
if chunk.len() == unit_len && aacs::is_aacs_scrambled(chunk) {
|
||||
let original: Vec<u8> = chunk.to_vec();
|
||||
if !aacs::decrypt_unit_full(chunk, &uk, rdk.as_ref()) {
|
||||
chunk.copy_from_slice(&original);
|
||||
@@ -232,11 +233,11 @@ pub fn decrypt_sectors(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit whose
|
||||
/// first byte has the top 2 bits set (here: the ASCII letter 'M' that
|
||||
/// MPLS files start with, 0x4D = 0b01001101) trips `is_unit_encrypted`,
|
||||
/// gets AES-decrypted with the unit key, fails the TS-sync verification,
|
||||
/// and must be restored to its original bytes — not left scrambled.
|
||||
/// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit (here
|
||||
/// an MPLS file: starts "MPLS", carries no TS syncs) reads as scrambled
|
||||
/// under `is_aacs_scrambled`, gets AES-decrypted with the unit key, fails
|
||||
/// the TS-sync verification, and must be restored to its original bytes —
|
||||
/// not left scrambled.
|
||||
#[test]
|
||||
fn nav_file_unit_survives_decrypt_attempt() {
|
||||
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
|
||||
+42
-202
@@ -179,54 +179,33 @@ impl Disc {
|
||||
session: &mut crate::drive::Drive,
|
||||
opts: &ScanOptions,
|
||||
) -> (Option<HandshakeResult>, Option<Error>) {
|
||||
use crate::aacs::{self, KeyDb};
|
||||
use crate::aacs;
|
||||
|
||||
let keydb_path = match opts.resolve_keydb() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
// Host certs come from the caller's DriveCredentials (e.g. the keydb's
|
||||
// host_certs(), sourced app-side) — the library does not load a keydb.
|
||||
// Absent ⇒ no cert auth: an unlocked / LibreDrive drive already returned
|
||||
// a Volume ID via the OEM path before reaching here, so this is the
|
||||
// locked-drive-without-credentials case.
|
||||
let host_certs: &[aacs::HostCert] = match &opts.credentials {
|
||||
Some(c) if !c.host_certs.is_empty() => &c.host_certs,
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_no_keydb",
|
||||
"no KEYDB found in search paths; handshake skipped"
|
||||
phase = "handshake_no_credentials",
|
||||
"no drive credentials supplied; cert handshake skipped"
|
||||
);
|
||||
return (None, None);
|
||||
}
|
||||
};
|
||||
let keydb = match KeyDb::load(&keydb_path) {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_keydb_load_failed",
|
||||
io_error_kind = ?e.kind(),
|
||||
keydb = %keydb_path.display(),
|
||||
"KEYDB load failed; handshake skipped"
|
||||
);
|
||||
return (
|
||||
None,
|
||||
Some(Error::KeydbLoad {
|
||||
path: keydb_path.display().to_string(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let host_cert_count = keydb.host_certs.len();
|
||||
let host_cert_count = host_certs.len();
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "handshake_start",
|
||||
host_cert_count,
|
||||
keydb = %keydb_path.display(),
|
||||
"handshake starting"
|
||||
);
|
||||
|
||||
if host_cert_count == 0 {
|
||||
// No host certs in keydb -> cert auth cannot proceed.
|
||||
// Surface as RawReadUnsupported so the caller knows
|
||||
// neither path is available on this configuration.
|
||||
return (None, Some(Error::AacsRawReadUnsupported));
|
||||
}
|
||||
|
||||
// v0.25.7 wedge fix. Pre-0.25.7 this loop fired up to 16 AACS
|
||||
// authenticate attempts back-to-back with no pause. Each attempt
|
||||
// is 5-10 SCSI REPORT_KEY/SEND_KEY exchanges. On a disc whose
|
||||
@@ -246,7 +225,7 @@ impl Disc {
|
||||
const MAX_CERT_ATTEMPTS: usize = 3;
|
||||
const PER_CERT_BACKOFF_MS: u64 = 1000;
|
||||
let mut last_err_code: Option<u16> = None;
|
||||
for (idx, hc) in keydb.host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() {
|
||||
for (idx, hc) in host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() {
|
||||
if idx > 0 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS));
|
||||
}
|
||||
@@ -317,166 +296,15 @@ impl Disc {
|
||||
(None, Some(Error::AacsHostCertRejected))
|
||||
}
|
||||
|
||||
/// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none.
|
||||
///
|
||||
/// Reads AACS files from UDF (via SectorSource), resolves keys through
|
||||
/// whatever path works: KEYDB VUK lookup, media key derivation, processing
|
||||
/// keys, device keys. Uses handshake result (volume ID, bus key) if available.
|
||||
pub(super) fn resolve_encryption(
|
||||
/// Build a keys-free AACS state that carries only the Volume ID (+ version
|
||||
/// metadata), for callers that resolve Unit Keys out-of-band and have
|
||||
/// disabled the local keydb. The VID is on-disc content read during the
|
||||
/// handshake; preserving it here lets the out-of-band path use it. No keys
|
||||
/// are present (`unit_keys` empty, `vuk` None), so the disc reports as
|
||||
/// "encrypted, no keys" until the caller re-scans with a resolved Unit Key.
|
||||
pub(super) fn resolve_vid_only(
|
||||
udf_fs: &udf::UdfFs,
|
||||
reader: &mut dyn SectorSource,
|
||||
keydb_path: &std::path::Path,
|
||||
handshake: Option<&HandshakeResult>,
|
||||
) -> Result<AacsState> {
|
||||
use crate::aacs::{self, KeyDb};
|
||||
use crate::drm::{DrmContext, DrmProbe, DrmScheme, ResolvedScheme};
|
||||
|
||||
let keydb = KeyDb::load(keydb_path).map_err(|_| Error::KeydbLoad {
|
||||
path: keydb_path.display().to_string(),
|
||||
})?;
|
||||
|
||||
// Read AACS files from disc/image via UDF
|
||||
let uk_ro_data = udf_fs
|
||||
.read_file(reader, "/AACS/Unit_Key_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/DUPLICATE/Unit_Key_RO.inf"))
|
||||
.map_err(|_| Error::AacsNoKeys)?;
|
||||
|
||||
// Log the disc hash so we can confirm whether it's present in KEYDB
|
||||
// when key resolution fails. The disc hash is SHA-1 of the full
|
||||
// Unit_Key_RO.inf file bytes — same value KEYDB.cfg keys VUK entries by.
|
||||
let dh = crate::aacs::disc_hash(&uk_ro_data);
|
||||
let dh_hex = crate::aacs::disc_hash_hex(&dh);
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_disc_hash",
|
||||
disc_hash = %dh_hex,
|
||||
uk_ro_len = uk_ro_data.len(),
|
||||
"disc hash computed (compare with keydb.cfg entries)"
|
||||
);
|
||||
|
||||
let cc_data = udf_fs
|
||||
.read_file(reader, "/AACS/Content000.cer")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
|
||||
.ok();
|
||||
|
||||
let mkb_data = udf_fs
|
||||
.read_file(reader, "/AACS/MKB_RW.inf")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RO.inf"))
|
||||
.ok();
|
||||
let mkb_ver = mkb_data.as_deref().and_then(aacs::mkb_version);
|
||||
|
||||
let mkb_first_64_hex = mkb_data
|
||||
.as_deref()
|
||||
.map(|m| {
|
||||
m.iter()
|
||||
.take(64)
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_mkb_info",
|
||||
mkb_present = mkb_data.is_some(),
|
||||
mkb_len = mkb_data.as_deref().map(|m| m.len()).unwrap_or(0),
|
||||
mkb_version = ?mkb_ver,
|
||||
mkb_first_64 = %mkb_first_64_hex,
|
||||
keydb_disc_count = keydb.disc_entries.len(),
|
||||
keydb_dk_count = keydb.device_keys.len(),
|
||||
keydb_pk_count = keydb.processing_keys.len(),
|
||||
"AACS resolution inputs"
|
||||
);
|
||||
|
||||
// Use handshake volume ID if available, otherwise zeros
|
||||
// (KEYDB VUK lookup by disc hash works without volume ID;
|
||||
// paths 2/3/4 in `resolve_keys` short-circuit on the zero
|
||||
// sentinel and don't waste cycles trying to derive against
|
||||
// garbage input).
|
||||
let volume_id = handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]);
|
||||
let vid_available = volume_id != [0u8; 16];
|
||||
let read_data_key = handshake.and_then(|h| h.read_data_key);
|
||||
|
||||
// Resolve: tries all available paths — KEYDB VUK, media key, processing key, device key.
|
||||
//
|
||||
// Distinguish "we had every input and still missed" from "we
|
||||
// never had VID so the derivation paths couldn't run." The
|
||||
// former points at a stale keydb / unsupported MKB; the
|
||||
// latter points at a failed handshake upstream. Path 1
|
||||
// (disc-hash lookup) ran without VID and missed -> disc isn't
|
||||
// in the keydb. If the caller has a handshake-failure reason
|
||||
// it overrides this in `scan_with`.
|
||||
let miss_error = if vid_available {
|
||||
Error::AacsMkUnavailable
|
||||
} else {
|
||||
Error::AacsVukNotInKeydb
|
||||
};
|
||||
|
||||
// Build a probe + context and let the dispatcher pick V10 / V20
|
||||
// / V21. CSS is impossible here (this function is only called
|
||||
// when /AACS exists), so we don't populate the DVD probe sector
|
||||
// or a CSS context.
|
||||
let probe = DrmProbe {
|
||||
dvd_sample_sector: None,
|
||||
content_cert: cc_data.as_deref(),
|
||||
mkb: mkb_data.as_deref(),
|
||||
};
|
||||
let scheme = match DrmScheme::detect(&probe) {
|
||||
Some(s) => s,
|
||||
None => return Err(miss_error),
|
||||
};
|
||||
let providers: &[&dyn aacs::KeyProvider] = &[&keydb];
|
||||
let aacs_ctx = aacs::ResolveContext {
|
||||
unit_key_ro: &uk_ro_data,
|
||||
content_cert: cc_data.as_deref(),
|
||||
volume_id: &volume_id,
|
||||
providers,
|
||||
mkb: mkb_data.as_deref(),
|
||||
};
|
||||
let mut ctx = DrmContext {
|
||||
aacs: Some(aacs_ctx),
|
||||
css: None,
|
||||
};
|
||||
let resolved = match scheme.load(&mut ctx) {
|
||||
Some(ResolvedScheme::Aacs(r)) => r,
|
||||
// Resolution against /AACS inputs can only produce AACS
|
||||
// keys. Either the dispatcher returned None (load failed)
|
||||
// or — structurally impossible here — a CSS state. Both
|
||||
// surface as the upstream miss-error.
|
||||
_ => return Err(miss_error),
|
||||
};
|
||||
|
||||
Ok(AacsState {
|
||||
version: match resolved.version {
|
||||
aacs::AacsVersion::V10 => 1,
|
||||
aacs::AacsVersion::V20 | aacs::AacsVersion::V21 => 2,
|
||||
},
|
||||
bus_encryption: resolved.bus_encryption,
|
||||
mkb_version: mkb_ver,
|
||||
disc_hash: aacs::disc_hash_hex(&resolved.disc_hash),
|
||||
key_source: match resolved.key_source {
|
||||
1 => KeySource::DeviceKey,
|
||||
2 => KeySource::ProcessingKey,
|
||||
3 => KeySource::KeyDbDerived,
|
||||
4 => KeySource::KeyDb,
|
||||
5 => KeySource::KeyDbUnitKeys,
|
||||
_ => KeySource::KeyDb,
|
||||
},
|
||||
vuk: resolved.vuk,
|
||||
unit_keys: resolved.unit_keys,
|
||||
read_data_key,
|
||||
volume_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve encryption from a caller-supplied Unit Key (the external key service
|
||||
/// path). No keydb, no derivation: read `Unit_Key_RO.inf` for the disc
|
||||
/// hash + version/bus-encryption flags, then use `unit_key` directly as
|
||||
/// CPS unit 1's decryption key. The handshake (if any) still supplies the
|
||||
/// volume ID and AACS 2.0 read-data key for bus decryption.
|
||||
pub(super) fn resolve_encryption_static(
|
||||
udf_fs: &udf::UdfFs,
|
||||
reader: &mut dyn SectorSource,
|
||||
unit_key: [u8; 16],
|
||||
handshake: Option<&HandshakeResult>,
|
||||
) -> Result<AacsState> {
|
||||
use crate::aacs;
|
||||
@@ -500,21 +328,31 @@ impl Disc {
|
||||
None if bus_encryption => 2,
|
||||
None => 1,
|
||||
};
|
||||
|
||||
let mkb_ver = udf_fs
|
||||
.read_file(reader, "/AACS/MKB_RW.inf")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RO.inf"))
|
||||
// MKB_RO/RW are allocated to a fixed ~128 MiB and zero-padded; trim to
|
||||
// the real record length (same as `read_aacs_inputs`). Without this the
|
||||
// MKB stashed on `AacsState` — which `Disc::inputs()` and the device/
|
||||
// processing-key `decrypt_with` derivation consume, and which a key
|
||||
// source ships to an online service — is the full 128 MiB pad, not the
|
||||
// ~few-MB record stream.
|
||||
let mut mkb_bytes = udf_fs
|
||||
.read_file(reader, "/AACS/MKB_RO.inf")
|
||||
.or_else(|_| udf_fs.read_file(reader, "/AACS/MKB_RW.inf"))
|
||||
.ok()
|
||||
.as_deref()
|
||||
.and_then(aacs::mkb_version);
|
||||
.unwrap_or_default();
|
||||
let n = aacs::mkb_content_len(&mkb_bytes);
|
||||
if n > 0 && n < mkb_bytes.len() {
|
||||
mkb_bytes.truncate(n);
|
||||
}
|
||||
let mkb_ver = aacs::mkb_version(&mkb_bytes);
|
||||
|
||||
tracing::warn!(
|
||||
target: "freemkv::disc",
|
||||
phase = "scan_aacs_external_uk",
|
||||
phase = "scan_aacs_vid_only",
|
||||
disc_hash = %aacs::disc_hash_hex(&dh),
|
||||
version,
|
||||
bus_encryption,
|
||||
"using caller-supplied unit key"
|
||||
has_vid = handshake.is_some(),
|
||||
"keydb disabled — carrying VID only, keys resolved out-of-band"
|
||||
);
|
||||
|
||||
Ok(AacsState {
|
||||
@@ -522,11 +360,13 @@ impl Disc {
|
||||
bus_encryption,
|
||||
mkb_version: mkb_ver,
|
||||
disc_hash: aacs::disc_hash_hex(&dh),
|
||||
key_source: KeySource::ExternalUk,
|
||||
key_source: KeyOrigin::ExternalUk,
|
||||
vuk: None,
|
||||
unit_keys: vec![(1, unit_key)],
|
||||
unit_keys: vec![],
|
||||
read_data_key: handshake.and_then(|h| h.read_data_key),
|
||||
volume_id: handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]),
|
||||
uk_ro: uk_ro_data,
|
||||
mkb: mkb_bytes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+120
-2
@@ -134,7 +134,17 @@ pub struct Mapfile {
|
||||
/// `# freemkv-vid:` comment header so it survives to deferred-mux /
|
||||
/// resume without altering the ISO payload or breaking ddrescue
|
||||
/// data-line parsing. `None` for unencrypted / non-AACS discs.
|
||||
///
|
||||
/// MUTUALLY EXCLUSIVE with `unit_keys`: a disc whose keys were resolved
|
||||
/// persists the keys (`unit_keys`) and NOT the VID — the keys are the final
|
||||
/// answer, so deferred-mux/resume decrypts directly with no key service. A
|
||||
/// disc that did NOT resolve persists only the VID, the retry-able "still
|
||||
/// need a key" marker (a future mux can re-ask the key service with it).
|
||||
vid: Option<[u8; 16]>,
|
||||
/// Decrypted AACS unit keys `(CPS unit, key)`, persisted as `# freemkv-uk:`
|
||||
/// comment headers when the disc was successfully keyed. Mutually exclusive
|
||||
/// with `vid` (see above). Empty when unresolved.
|
||||
unit_keys: Vec<(u32, [u8; 16])>,
|
||||
}
|
||||
|
||||
impl Mapfile {
|
||||
@@ -160,6 +170,7 @@ impl Mapfile {
|
||||
dirty: false,
|
||||
last_flushed: Instant::now(),
|
||||
vid: None,
|
||||
unit_keys: Vec::new(),
|
||||
};
|
||||
// Eager initial persist so a resume can pick this up even if
|
||||
// `record()` is never called.
|
||||
@@ -175,6 +186,7 @@ impl Mapfile {
|
||||
let mut saw_current_line = false;
|
||||
let mut version = String::from("unknown");
|
||||
let mut vid: Option<[u8; 16]> = None;
|
||||
let mut unit_keys: Vec<(u32, [u8; 16])> = Vec::new();
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if t.is_empty() {
|
||||
@@ -190,6 +202,12 @@ impl Mapfile {
|
||||
// ignored rather than failing the whole load.
|
||||
vid = parse_vid_hex(hex.trim());
|
||||
}
|
||||
if let Some(uk) = rest.strip_prefix("freemkv-uk:") {
|
||||
// `<cps>:<32hex>`. Best-effort: a malformed line is skipped.
|
||||
if let Some(entry) = parse_uk_line(uk.trim()) {
|
||||
unit_keys.push(entry);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// First non-comment line is the "current" state line (pos status [pass] [pass_time]).
|
||||
@@ -240,6 +258,7 @@ impl Mapfile {
|
||||
dirty: false,
|
||||
last_flushed: Instant::now(),
|
||||
vid,
|
||||
unit_keys,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -345,6 +364,25 @@ impl Mapfile {
|
||||
self.vid
|
||||
}
|
||||
|
||||
/// Record the disc's decrypted AACS unit keys so they persist in the
|
||||
/// mapfile header (`# freemkv-uk:` lines). The KEYED state: a deferred-mux /
|
||||
/// resume decrypts directly from these with no key-service round-trip.
|
||||
/// Setting keys clears any VID — the mapfile holds keys XOR VID, never both
|
||||
/// (keys are the final answer; VID is only the "still unresolved" marker).
|
||||
pub fn set_unit_keys(&mut self, keys: &[(u32, [u8; 16])]) {
|
||||
self.unit_keys = keys.to_vec();
|
||||
if !self.unit_keys.is_empty() {
|
||||
self.vid = None;
|
||||
}
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// The disc's decrypted AACS unit keys, if the disc was keyed (parsed from
|
||||
/// `# freemkv-uk:` comments on load). Empty = unresolved (check `vid()`).
|
||||
pub fn unit_keys(&self) -> &[(u32, [u8; 16])] {
|
||||
&self.unit_keys
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> &[MapEntry] {
|
||||
&self.entries
|
||||
}
|
||||
@@ -421,10 +459,22 @@ impl Mapfile {
|
||||
// `#`-prefixed line as a comment, so this round-trips through
|
||||
// our `load()` without affecting the `pos size status` data
|
||||
// parser. 16 bytes → 32 lowercase hex chars.
|
||||
if let Some(vid) = self.vid {
|
||||
// KEYS XOR VID: a keyed disc persists its unit keys (the final
|
||||
// answer — deferred-mux decrypts directly); an unresolved disc
|
||||
// persists only the VID (the retry marker, so a future mux can
|
||||
// re-ask the key service). Never both.
|
||||
use std::fmt::Write as _;
|
||||
if !self.unit_keys.is_empty() {
|
||||
for (cps, key) in &self.unit_keys {
|
||||
let mut hex = String::with_capacity(32);
|
||||
for b in key {
|
||||
let _ = write!(hex, "{b:02x}");
|
||||
}
|
||||
writeln!(w, "# freemkv-uk: {cps}:{hex}")?;
|
||||
}
|
||||
} else if let Some(vid) = self.vid {
|
||||
let mut hex = String::with_capacity(32);
|
||||
for b in vid {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(hex, "{b:02x}");
|
||||
}
|
||||
writeln!(w, "# freemkv-vid: {hex}")?;
|
||||
@@ -474,6 +524,15 @@ fn parse_vid_hex(s: &str) -> Option<[u8; 16]> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Parse a `# freemkv-uk:` value `<cps>:<32hex>` into `(cps_unit, key)`. Returns
|
||||
/// `None` on any malformation so a corrupt line is ignored, never fatal.
|
||||
fn parse_uk_line(s: &str) -> Option<(u32, [u8; 16])> {
|
||||
let (cps, hex) = s.split_once(':')?;
|
||||
let cps: u32 = cps.trim().parse().ok()?;
|
||||
let key = parse_vid_hex(hex.trim())?; // 32-hex → [u8; 16], shared parser
|
||||
Some((cps, key))
|
||||
}
|
||||
|
||||
fn parse_hex(s: &str) -> io::Result<u64> {
|
||||
let s = s.strip_prefix("0x").unwrap_or(s);
|
||||
u64::from_str_radix(s, 16).map_err(|_| {
|
||||
@@ -640,6 +699,65 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_keys_round_trip_and_are_mutually_exclusive_with_vid() {
|
||||
let p = tmpfile("uk_round_trips");
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let mut mf = Mapfile::create(&p, 1000, "test").unwrap();
|
||||
mf.record(0, 500, SectorStatus::Finished).unwrap();
|
||||
// Set a VID first, then unit keys: keys must WIN and clear the VID.
|
||||
mf.set_vid([0xAA; 16]);
|
||||
let keys: Vec<(u32, [u8; 16])> = vec![
|
||||
(
|
||||
0,
|
||||
[
|
||||
0x57, 0x60, 0xcc, 0x83, 0x3d, 0x86, 0x0e, 0x48, 0x92, 0x1f, 0x88, 0x16, 0xe1,
|
||||
0x35, 0x9b, 0xad,
|
||||
],
|
||||
),
|
||||
(1, [0x11; 16]),
|
||||
];
|
||||
mf.set_unit_keys(&keys);
|
||||
assert_eq!(
|
||||
mf.vid(),
|
||||
None,
|
||||
"set_unit_keys must clear vid (keys XOR vid)"
|
||||
);
|
||||
mf.flush().unwrap();
|
||||
|
||||
let text = std::fs::read_to_string(&p).unwrap();
|
||||
assert!(
|
||||
text.contains("# freemkv-uk: 0:5760cc833d860e48921f8816e1359bad"),
|
||||
"uk comment format mismatch: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("# freemkv-uk: 1:11111111111111111111111111111111"),
|
||||
"second uk missing: {text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("# freemkv-vid:"),
|
||||
"VID must NOT be written when keys are present: {text}"
|
||||
);
|
||||
|
||||
// load() recovers the unit keys (and no VID).
|
||||
let loaded = Mapfile::load(&p).unwrap();
|
||||
assert_eq!(loaded.unit_keys(), keys.as_slice());
|
||||
assert_eq!(loaded.vid(), None);
|
||||
assert_eq!(loaded.entries(), mf.entries());
|
||||
|
||||
// VID-only path (no keys) still persists the VID as the retry marker.
|
||||
let p2 = tmpfile("uk_vid_only");
|
||||
let _ = std::fs::remove_file(&p2);
|
||||
let mut mf2 = Mapfile::create(&p2, 1000, "test").unwrap();
|
||||
mf2.set_vid([0xBB; 16]);
|
||||
mf2.flush().unwrap();
|
||||
let loaded2 = Mapfile::load(&p2).unwrap();
|
||||
assert_eq!(loaded2.vid(), Some([0xBB; 16]));
|
||||
assert!(loaded2.unit_keys().is_empty());
|
||||
let _ = std::fs::remove_file(&p);
|
||||
let _ = std::fs::remove_file(&p2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vid_round_trips_and_data_lines_unaffected() {
|
||||
let p = tmpfile("vid_round_trips");
|
||||
|
||||
+791
-128
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,7 @@ pub const E_AACS_MK_UNAVAILABLE: u16 = 7018;
|
||||
pub const E_AACS_VUK_NOT_IN_KEYDB: u16 = 7019;
|
||||
pub const E_DRIVE_PROFILE_MISSING: u16 = 7020;
|
||||
pub const E_VID_CDB_UNAVAILABLE: u16 = 7021;
|
||||
pub const E_NO_DISC_KEY: u16 = 7022;
|
||||
|
||||
// Keydb (8xxx)
|
||||
pub const E_KEYDB_CONNECT: u16 = 8000;
|
||||
@@ -253,6 +254,16 @@ pub enum Error {
|
||||
/// template (older profile blob, or a drive class without an OEM
|
||||
/// VID path).
|
||||
VidCdbUnavailable,
|
||||
/// The disc is AACS-encrypted and decryption was requested, but key
|
||||
/// resolution produced no usable key for it — so muxing would emit
|
||||
/// undecryptable garbage. Distinct from [`Error::KeydbLoad`] (no keydb
|
||||
/// file at all): a keydb may be present but lack an entry for this disc.
|
||||
/// `disc_hash` is the 40-hex SHA1 of `Unit_Key_RO.inf` (no `0x` prefix)
|
||||
/// so the application can name the disc; empty if the hash wasn't
|
||||
/// captured at scan.
|
||||
NoDiscKey {
|
||||
disc_hash: String,
|
||||
},
|
||||
|
||||
// Keydb (8xxx)
|
||||
KeydbConnect {
|
||||
@@ -345,6 +356,7 @@ impl Error {
|
||||
Error::AacsVukNotInKeydb => E_AACS_VUK_NOT_IN_KEYDB,
|
||||
Error::DriveProfileMissing => E_DRIVE_PROFILE_MISSING,
|
||||
Error::VidCdbUnavailable => E_VID_CDB_UNAVAILABLE,
|
||||
Error::NoDiscKey { .. } => E_NO_DISC_KEY,
|
||||
Error::KeydbConnect { .. } => E_KEYDB_CONNECT,
|
||||
Error::KeydbHttp { .. } => E_KEYDB_HTTP,
|
||||
Error::KeydbInvalid => E_KEYDB_INVALID,
|
||||
@@ -471,6 +483,7 @@ impl std::fmt::Display for Error {
|
||||
Error::StreamUrlMissingPort { addr } => write!(f, "E{}: {}", self.code(), addr),
|
||||
Error::PesFrameTooLarge { size } => write!(f, "E{}: {}", self.code(), size),
|
||||
Error::IsoTooLarge { path } => write!(f, "E{}: {}", self.code(), path),
|
||||
Error::NoDiscKey { disc_hash } => write!(f, "E{}: {}", self.code(), disc_hash),
|
||||
_ => write!(f, "E{}", self.code()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Key sources — the lookup layer that hands libfreemkv a [`Key`].
|
||||
//!
|
||||
//! libfreemkv performs NO key lookup. An application resolves a key for a disc
|
||||
//! through one or more [`KeySource`]s, each a dumb adapter over a backing store
|
||||
//! (a keydb file, a key server, the mapfile cache): given the disc's
|
||||
//! [`DiscInputs`] it returns the raw [`Key`] at whatever level it holds. The
|
||||
//! library then derives down and decrypts via `Disc::decrypt_with`.
|
||||
//!
|
||||
//! Source implementations are published in the companion `freemkv-keysources`
|
||||
//! crate — keeping all key *policy* (which store, which order, online vs local)
|
||||
//! out of the library while all key *mechanism* (the AACS derivation chain)
|
||||
//! stays in it.
|
||||
|
||||
use crate::disc::Key;
|
||||
|
||||
/// The public AACS inputs a key source needs to look a disc up. Captured at
|
||||
/// scan; contains no secrets — only the disc identity and the on-disc AACS
|
||||
/// structures a source or key server may key on.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscInputs {
|
||||
/// SHA-1 of `Unit_Key_RO.inf`, `0x`-prefixed hex. The value a keydb keys
|
||||
/// its per-disc entries by, and a key server identifies the disc with.
|
||||
pub disc_hash: String,
|
||||
/// Volume ID (16 bytes). `[0u8; 16]` when no authenticated handshake ran
|
||||
/// (e.g. an ISO/mapfile flow), which disables VID-keyed lookups.
|
||||
pub volume_id: [u8; 16],
|
||||
/// Raw MKB bytes. Empty when not captured.
|
||||
pub mkb: Vec<u8>,
|
||||
/// Raw `Unit_Key_RO.inf` bytes. Empty when not captured.
|
||||
pub unit_key_ro: Vec<u8>,
|
||||
/// Encrypted on-disc content sample units (each a 6144-byte aligned unit),
|
||||
/// for sources that validate a key server-side against real ciphertext
|
||||
/// (e.g. an online key service). Empty for sources that don't need them
|
||||
/// (a local keydb). Populated by the application — reading content requires
|
||||
/// the disc reader, which the library's scan does not retain — so
|
||||
/// [`crate::Disc::inputs`] leaves it empty for the caller to fill.
|
||||
pub samples: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A key source: a stateful provider that hands a disc's candidate [`Key`]s out
|
||||
/// **one at a time**, in whatever order it judges best for its backing store.
|
||||
///
|
||||
/// Dumb by contract — a source queries its store and yields the raw material it
|
||||
/// holds at whatever level it has (device / processing / media / volume / unit).
|
||||
/// It performs NO AACS derivation and NO validation: `Disc::decrypt_with`
|
||||
/// derives down AND validates against real ciphertext, returning `Err` for a key
|
||||
/// that does not decrypt this disc. That keeps every derivation step and the one
|
||||
/// validation gate in the library, across AACS 1.0 / 2.0 / 2.1 / 2.x.
|
||||
///
|
||||
/// The source is the one that knows how many candidates it has and in what order
|
||||
/// to try them — a keydb holds a per-disc UK *and* VUK *and* a device-key pool,
|
||||
/// so it hands them out cheapest/most-specific first (UK ▸ VK ▸ MK ▸ DK) and
|
||||
/// reports exhaustion when its list runs out; an online key service or a mapfile
|
||||
/// cache hold exactly one. The caller drives the loop: `next_key` →
|
||||
/// `Disc::decrypt_with` → on `Err`, ask again → until a key decrypts or the
|
||||
/// source returns `None` (a genuine "no key for this disc"). Compose several
|
||||
/// sources, in the caller's chosen order, with [`crate`]'s `MultiSource`.
|
||||
pub trait KeySource {
|
||||
/// Hand the NEXT candidate key for this disc, or `None` once this source is
|
||||
/// exhausted. Stateful: the source tracks what it already handed out this
|
||||
/// session, so asking again after a rejected key yields the next candidate
|
||||
/// (or `None`) — it never re-offers a key or re-hits a one-shot backend (an
|
||||
/// online service is asked at most once). A source failure (I/O, network,
|
||||
/// parse) surfaces as `None` — there is simply nothing more to try.
|
||||
fn next_key(&mut self, inputs: &DiscInputs) -> Option<Key>;
|
||||
|
||||
/// Whether this source needs [`DiscInputs::samples`] populated (encrypted
|
||||
/// content samples) — true for a source that validates against ciphertext
|
||||
/// server-side, false for one that keys purely on disc identity. The caller
|
||||
/// reads samples (an extra disc read) only when some source needs them.
|
||||
fn needs_samples(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether this source FAILED (I/O, network, parse) rather than simply
|
||||
/// having no key. Checked after exhaustion so the caller can tell a genuine
|
||||
/// "no key for this disc" apart from "the key service was unreachable". A
|
||||
/// store that treats absence as not-an-error (a missing keydb / mapfile)
|
||||
/// leaves this `false`.
|
||||
fn errored(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
+185
-5
@@ -207,14 +207,41 @@ pub(crate) fn apply_labels(labels: &[StreamLabel], titles: &mut [DiscTitle]) {
|
||||
// Structured fields — callers translate purpose to UI text.
|
||||
a.purpose = label.purpose;
|
||||
|
||||
// Codec descriptor: trust the parser's `codec_hint` ONLY
|
||||
// when it's consistent with the stream's actual codec — it
|
||||
// may legitimately be richer (e.g. "Dolby Atmos" on a TrueHD
|
||||
// stream, which the raw spec codec can't express). If the
|
||||
// hint CONTRADICTS the stream (a mis-bound / shuffled label,
|
||||
// e.g. "AC-3 2.0" on a TrueHD track, or "TrueHD" on a DD+
|
||||
// track), discard it and derive the descriptor from the
|
||||
// stream itself — that's correct per-stream and can never be
|
||||
// shuffled. An empty hint is left for `fill_defaults`.
|
||||
let codec_desc = if label.codec_hint.is_empty() {
|
||||
// No codec hint — leave for fill_defaults.
|
||||
String::new()
|
||||
} else if !codec_hint_consistent(&label.codec_hint, &a.codec) {
|
||||
// Hint contradicts the stream (mis-bound / shuffled):
|
||||
// derive from the stream itself.
|
||||
generate_audio_label(&a.codec, &a.channels, a.secondary)
|
||||
} else if codec_hint_adds_detail(&label.codec_hint) {
|
||||
// Consistent AND richer than the spec codec can express
|
||||
// (e.g. "Dolby Atmos", "DTS:X") — keep the parser's hint.
|
||||
label.codec_hint.clone()
|
||||
} else {
|
||||
// Consistent but a plain codec/channel restatement —
|
||||
// normalize to the stream's own marketing descriptor so
|
||||
// styling is uniform across tracks.
|
||||
generate_audio_label(&a.codec, &a.channels, a.secondary)
|
||||
};
|
||||
|
||||
// a.label only carries codec/variant info. NEVER any
|
||||
// English purpose text — the CLI handles that via i18n.
|
||||
let mut parts = Vec::new();
|
||||
if !label.variant.is_empty() {
|
||||
parts.push(format!("({})", label.variant));
|
||||
}
|
||||
if !label.codec_hint.is_empty() {
|
||||
parts.push(label.codec_hint.clone());
|
||||
if !codec_desc.is_empty() {
|
||||
parts.push(codec_desc);
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
a.label = parts.join(" ");
|
||||
@@ -322,7 +349,70 @@ fn generate_video_label(
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
fn generate_audio_label(
|
||||
/// Does the parser's `codec_hint` name a codec consistent with the stream's
|
||||
/// actual `codec`? [`apply_labels`] uses this to keep richer-but-consistent
|
||||
/// hints (e.g. "Dolby Atmos" on a TrueHD stream — Atmos is a TrueHD extension
|
||||
/// the raw spec codec can't express) while rejecting mis-bound ones (e.g.
|
||||
/// "AC-3 2.0" on a TrueHD stream, the shuffled-label bug). Matching is by codec
|
||||
/// FAMILY parsed out of the hint string. "Atmos" with no carrier named is
|
||||
/// treated as compatible with its lossless carriers (TrueHD / E-AC-3). A hint
|
||||
/// naming no recognizable codec family (pure editorial, e.g. "Commentary") is
|
||||
/// consistent — it isn't asserting a codec.
|
||||
fn codec_hint_consistent(hint: &str, codec: &crate::disc::Codec) -> bool {
|
||||
use crate::disc::Codec;
|
||||
let h = hint.to_ascii_lowercase();
|
||||
|
||||
let says_truehd = h.contains("truehd") || h.contains("true hd");
|
||||
let says_ddp = h.contains("ac-3+")
|
||||
|| h.contains("ac3+")
|
||||
|| h.contains("e-ac-3")
|
||||
|| h.contains("eac-3")
|
||||
|| h.contains("eac3")
|
||||
|| h.contains("digital plus")
|
||||
|| h.contains("dd+");
|
||||
let says_ac3 =
|
||||
!says_ddp && (h.contains("ac-3") || h.contains("ac3") || h.contains("dolby digital"));
|
||||
let says_dts_ma = h.contains("master audio") || h.contains("hd ma");
|
||||
let says_dts_hr = h.contains("high resolution") || h.contains("hd hr");
|
||||
let says_dts = !says_dts_ma && !says_dts_hr && h.contains("dts");
|
||||
let says_lpcm = h.contains("lpcm") || h.contains("pcm");
|
||||
let says_atmos = h.contains("atmos");
|
||||
|
||||
let names_family =
|
||||
says_truehd || says_ddp || says_ac3 || says_dts_ma || says_dts_hr || says_dts || says_lpcm;
|
||||
|
||||
// Pure-editorial hint (no codec family named) isn't asserting a codec →
|
||||
// consistent. "Atmos" alone implies a lossless carrier (TrueHD or DD+).
|
||||
if !names_family {
|
||||
return if says_atmos {
|
||||
matches!(codec, Codec::TrueHd | Codec::Ac3Plus)
|
||||
} else {
|
||||
true
|
||||
};
|
||||
}
|
||||
|
||||
match codec {
|
||||
Codec::TrueHd => says_truehd || says_atmos,
|
||||
Codec::Ac3Plus => says_ddp || says_atmos,
|
||||
Codec::Ac3 => says_ac3,
|
||||
Codec::DtsHdMa => says_dts_ma,
|
||||
Codec::DtsHdHr => says_dts_hr,
|
||||
Codec::Dts => says_dts,
|
||||
Codec::Lpcm => says_lpcm,
|
||||
// Unknown / other stream codec — don't second-guess the parser's hint.
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the hint carry object-audio detail the spec codec can't express
|
||||
/// (Atmos / DTS:X)? Such hints are kept verbatim; plain codec/channel hints are
|
||||
/// normalized to the stream's own descriptor for uniform styling across tracks.
|
||||
fn codec_hint_adds_detail(hint: &str) -> bool {
|
||||
let h = hint.to_ascii_lowercase();
|
||||
h.contains("atmos") || h.contains("dts:x") || h.contains("dts-x") || h.contains("dtsx")
|
||||
}
|
||||
|
||||
pub(crate) fn generate_audio_label(
|
||||
codec: &crate::disc::Codec,
|
||||
channels: &crate::disc::AudioChannels,
|
||||
_secondary: bool,
|
||||
@@ -1182,6 +1272,94 @@ mod apply_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_rejects_mismatched_codec_hint_and_uses_stream_codec() {
|
||||
// Paddington case: a TrueHD+Atmos main track the parser mislabeled
|
||||
// "AC-3 2.0" (a compat-core hint bound to the wrong stream). The hint
|
||||
// contradicts the stream's real codec → discard it, use the stream's own.
|
||||
let mut titles = vec![title_with(vec![audio(
|
||||
0x1100,
|
||||
Codec::TrueHd,
|
||||
AudioChannels::Surround71,
|
||||
"eng",
|
||||
)])];
|
||||
let labels = vec![audio_label(1, "eng", "AC-3 2.0", "")];
|
||||
apply_labels(&labels, &mut titles);
|
||||
if let Stream::Audio(a) = &titles[0].streams[0] {
|
||||
assert_eq!(a.label, "Dolby TrueHD 7.1");
|
||||
} else {
|
||||
panic!("expected audio stream");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_unshuffles_cross_labeled_streams() {
|
||||
// Wicked case: hints fully cross-bound — a TrueHD stream wears "AC-3 5.1"
|
||||
// and a DD+ stream wears "TrueHD 5.1". Each is corrected from its own
|
||||
// stream codec, eliminating the shuffle.
|
||||
let mut titles = vec![title_with(vec![
|
||||
audio(0x1100, Codec::TrueHd, AudioChannels::Surround51, "eng"),
|
||||
audio(0x1101, Codec::Ac3Plus, AudioChannels::Surround51, "spa"),
|
||||
])];
|
||||
let labels = vec![
|
||||
audio_label(1, "eng", "AC-3 5.1", ""),
|
||||
audio_label(2, "spa", "TrueHD 5.1", ""),
|
||||
];
|
||||
apply_labels(&labels, &mut titles);
|
||||
let got: Vec<String> = titles[0]
|
||||
.streams
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
if let Stream::Audio(a) = s {
|
||||
Some(a.label.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(got, vec!["Dolby TrueHD 5.1", "Dolby Digital Plus 5.1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_keeps_consistent_richer_atmos_hint() {
|
||||
// A DD+ Atmos stream legitimately labeled "Dolby Atmos" — the hint is
|
||||
// richer than the spec codec yet consistent with it, so it's kept.
|
||||
let mut titles = vec![title_with(vec![audio(
|
||||
0x1100,
|
||||
Codec::Ac3Plus,
|
||||
AudioChannels::Surround51,
|
||||
"eng",
|
||||
)])];
|
||||
let labels = vec![audio_label(1, "eng", "Dolby Atmos", "")];
|
||||
apply_labels(&labels, &mut titles);
|
||||
if let Stream::Audio(a) = &titles[0].streams[0] {
|
||||
assert_eq!(a.label, "Dolby Atmos");
|
||||
} else {
|
||||
panic!("expected audio stream");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_normalizes_plain_consistent_hint_to_marketing() {
|
||||
// Wicked's French track: a DD+ stream whose hint "AC-3+ 5.1" is correct
|
||||
// but short-form. A sibling DD+ track that fell back uses the marketing
|
||||
// form — keeping the short form here would read inconsistently, so a
|
||||
// plain (non-richer) consistent hint is normalized to the stream's own.
|
||||
let mut titles = vec![title_with(vec![audio(
|
||||
0x1100,
|
||||
Codec::Ac3Plus,
|
||||
AudioChannels::Surround51,
|
||||
"fra",
|
||||
)])];
|
||||
let labels = vec![audio_label(1, "fra", "AC-3+ 5.1", "")];
|
||||
apply_labels(&labels, &mut titles);
|
||||
if let Stream::Audio(a) = &titles[0].streams[0] {
|
||||
assert_eq!(a.label, "Dolby Digital Plus 5.1");
|
||||
} else {
|
||||
panic!("expected audio stream");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_sets_purpose_on_audio_commentary() {
|
||||
let mut titles = vec![title_with(vec![audio(
|
||||
@@ -1342,9 +1520,11 @@ mod apply_tests {
|
||||
if let Stream::Audio(a) = &titles[0].streams[1] {
|
||||
assert_eq!(a.label, "Dolby Atmos");
|
||||
}
|
||||
// Audio #2 (4th stream overall)
|
||||
// Audio #2 (4th stream overall). The plain "Dolby Digital" hint is
|
||||
// consistent with the AC-3 stream but carries no channel info, so it's
|
||||
// normalized to the stream's own uniform descriptor.
|
||||
if let Stream::Audio(a) = &titles[0].streams[3] {
|
||||
assert_eq!(a.label, "Dolby Digital");
|
||||
assert_eq!(a.label, "Dolby Digital 2.0");
|
||||
}
|
||||
// Subtitle #1
|
||||
if let Stream::Subtitle(s) = &titles[0].streams[2] {
|
||||
|
||||
+5
-3
@@ -85,6 +85,7 @@ pub(crate) mod identity;
|
||||
pub(crate) mod ifo;
|
||||
pub mod io;
|
||||
pub mod keydb;
|
||||
pub mod keysource;
|
||||
pub mod labels;
|
||||
pub(crate) mod mpls;
|
||||
pub mod mux;
|
||||
@@ -170,10 +171,11 @@ pub use decrypt::{DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_thr
|
||||
// prefix at the crate root to keep both addressable.
|
||||
pub use disc::{
|
||||
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DamageSeverity,
|
||||
Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, KeySource, LabelPurpose,
|
||||
LabelQualifier, PatchOptions, PatchOutcome, Resolution, SampleRate, ScanOptions, Stream,
|
||||
SubtitleStream, SweepOptions, VideoStream, classify_damage,
|
||||
Disc, DiscFormat, DiscId, DiscTitle, DriveCredentials, Extent, FrameRate, HdrFormat, Key,
|
||||
KeyOrigin, LabelPurpose, LabelQualifier, PatchOptions, PatchOutcome, Resolution, SampleRate,
|
||||
ScanOptions, Stream, SubtitleStream, SweepOptions, VideoStream, classify_damage,
|
||||
};
|
||||
pub use keysource::{DiscInputs, KeySource};
|
||||
|
||||
// ─── Streams ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
|
||||
+15
-3
@@ -301,9 +301,21 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea
|
||||
return None;
|
||||
}
|
||||
|
||||
// PID from stream entry (type 0x01 = PlayItem stream: PID at bytes 2-3)
|
||||
let pid = if item[pos + 1] == 0x01 && pos + 4 <= item.len() {
|
||||
u16::from_be_bytes([item[pos + 2], item[pos + 3]])
|
||||
// PID location depends on the stream-entry type (BD spec stream_entry()):
|
||||
// type 1 (stream in the PlayItem's Clip): PID at +2
|
||||
// type 2 (stream in a SubPath SubClip): +subpath_id(1)+subclip_id(1) → PID at +4
|
||||
// type 3 / 4 (SubPath clip; type 4 = Dolby Vision +subpath_id(1) → PID at +3
|
||||
// enhancement layer, e.g. PID 0x1015):
|
||||
// Previously only type 1 was handled, so the DV EL (type 4) and any
|
||||
// sub-path stream fell through to PID 0 and were dropped by the mux.
|
||||
let pid_off = match item[pos + 1] {
|
||||
0x01 => 2,
|
||||
0x02 => 4,
|
||||
0x03 | 0x04 => 3,
|
||||
_ => 0,
|
||||
};
|
||||
let pid = if pid_off != 0 && pos + pid_off + 2 <= item.len() {
|
||||
u16::from_be_bytes([item[pos + pid_off], item[pos + pid_off + 1]])
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
+119
-7
@@ -6,6 +6,14 @@
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
/// Hard cap on the carry-over buffer. An AC-3/E-AC-3 frame is at most 8192
|
||||
/// bytes (the `frame_size > 8192` reject below), so a single straddling frame
|
||||
/// plus a little slack never needs more than this. If the buffer grows past
|
||||
/// the cap without yielding a frame (pathological / never-syncing input) we
|
||||
/// drop it and resync rather than accumulate one PES worth of data per call
|
||||
/// for the whole title.
|
||||
const MAX_AC3_BUF: usize = 64 * 1024;
|
||||
|
||||
pub struct Ac3Parser {
|
||||
/// Leftover bytes from previous PES (incomplete frame at end).
|
||||
buf: Vec<u8>,
|
||||
@@ -81,19 +89,38 @@ impl CodecParser for Ac3Parser {
|
||||
pos = start + frame_size;
|
||||
}
|
||||
|
||||
// Keep unconsumed data for next call
|
||||
// `pos` points to the start of unconsumed data (either a partial sync or leftover)
|
||||
// Keep unconsumed data for the next call. `pos` is the start of the
|
||||
// unconsumed region: either a partial frame that straddles this PES
|
||||
// boundary — which, by construction, begins at a syncword (every byte
|
||||
// before `pos` was emitted as a frame or skipped as pre-sync junk) — or
|
||||
// trailing bytes too short to size/complete a frame. Carry from `pos`,
|
||||
// NOT from the next syncword: discarding bytes between `pos` and the
|
||||
// next sync would drop the partial frame we are deliberately keeping
|
||||
// across the boundary.
|
||||
let keep_from = if pos < data.len() {
|
||||
// Find the last sync word position in the unconsumed region
|
||||
find_ac3_sync(&data[pos..])
|
||||
.map(|o| pos + o)
|
||||
.unwrap_or(data.len())
|
||||
// A syncword at/after `pos` marks the carry-over start (anything
|
||||
// before it is junk with no sync). With no full sync, retain the
|
||||
// whole tail — including a lone trailing 0x0B that may be the first
|
||||
// half of a syncword split across the PES boundary.
|
||||
match find_ac3_sync(&data[pos..]) {
|
||||
Some(o) => pos + o,
|
||||
None if data.last() == Some(&0x0B) => data.len() - 1,
|
||||
None => data.len(),
|
||||
}
|
||||
} else {
|
||||
data.len()
|
||||
};
|
||||
|
||||
if keep_from < data.len() {
|
||||
self.buf = data[keep_from..].to_vec();
|
||||
let tail = &data[keep_from..];
|
||||
if tail.len() > MAX_AC3_BUF {
|
||||
// No frame could be parsed out of a buffer this large — this is
|
||||
// not valid AC-3 here. Drop it and resync on the next PES rather
|
||||
// than grow without bound on pathological input.
|
||||
self.buf.clear();
|
||||
} else {
|
||||
self.buf = tail.to_vec();
|
||||
}
|
||||
} else {
|
||||
self.buf.clear();
|
||||
}
|
||||
@@ -276,6 +303,91 @@ mod tests {
|
||||
assert_eq!(frames[0].data.len(), 160);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_word_split_across_pes_is_preserved() {
|
||||
// A frame whose 0x0B77 syncword straddles the PES boundary (0x0B at the
|
||||
// tail of PES 1, 0x77 at the head of PES 2) must still be emitted whole.
|
||||
// Previously the lone trailing 0x0B was dropped and the frame lost.
|
||||
let mut parser = Ac3Parser::new();
|
||||
let frame_data = make_ac3_frame(0, 2); // 160 bytes, starts with 0x0B 0x77
|
||||
|
||||
// PES 1: a complete frame, then a single 0x0B (first half of next sync).
|
||||
let mut pes1_data = frame_data.clone();
|
||||
pes1_data.push(0x0B);
|
||||
let pes1 = PesPacket {
|
||||
pid: 0,
|
||||
pts: Some(90000),
|
||||
dts: None,
|
||||
data: pes1_data,
|
||||
};
|
||||
let frames1 = parser.parse(&pes1);
|
||||
assert_eq!(frames1.len(), 1, "first complete frame emitted");
|
||||
|
||||
// PES 2: 0x77 (second half of sync) + rest of the second frame.
|
||||
let mut pes2_data = vec![0x77];
|
||||
pes2_data.extend_from_slice(&frame_data[2..]);
|
||||
let pes2 = PesPacket {
|
||||
pid: 0,
|
||||
pts: Some(93000),
|
||||
dts: None,
|
||||
data: pes2_data,
|
||||
};
|
||||
let frames2 = parser.parse(&pes2);
|
||||
assert_eq!(frames2.len(), 1, "split-sync frame must be recovered");
|
||||
assert_eq!(frames2[0].data.len(), 160);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_stays_bounded_across_many_garbage_pes() {
|
||||
// Finding 14: the carry-over buffer must never grow without bound. Feed
|
||||
// many large PES packets that contain no usable frame and assert the
|
||||
// retained buffer stays tiny — carry-from-`pos` drops all pre-sync junk,
|
||||
// and a never-completing frame is bounded by the 8192-byte frame cap and
|
||||
// the MAX_AC3_BUF resync guard.
|
||||
let mut parser = Ac3Parser::new();
|
||||
for i in 0..256 {
|
||||
// Vary the trailing byte so we also exercise the lone-0x0B retain.
|
||||
let mut data = vec![0x55u8; 8192];
|
||||
if i % 3 == 0 {
|
||||
*data.last_mut().unwrap() = 0x0B;
|
||||
}
|
||||
let pes = PesPacket {
|
||||
pid: 0,
|
||||
pts: None,
|
||||
dts: None,
|
||||
data,
|
||||
};
|
||||
let frames = parser.parse(&pes);
|
||||
assert!(frames.is_empty());
|
||||
assert!(
|
||||
parser.buf.len() <= MAX_AC3_BUF,
|
||||
"buffer grew to {} (cap {})",
|
||||
parser.buf.len(),
|
||||
MAX_AC3_BUF
|
||||
);
|
||||
}
|
||||
// After all that garbage the retained tail is at most a single partial
|
||||
// syncword byte — never an accumulation of whole PES packets.
|
||||
assert!(parser.buf.len() <= 1, "retained {} bytes", parser.buf.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_sync_below_cap_is_still_retained() {
|
||||
// The cap must not break the normal split-sync straddle: a short tail
|
||||
// ending in 0x0B (well under the cap) is retained so the next PES can
|
||||
// complete the syncword.
|
||||
let mut parser = Ac3Parser::new();
|
||||
let data = vec![0x00, 0x00, 0x0B];
|
||||
let pes = PesPacket {
|
||||
pid: 0,
|
||||
pts: None,
|
||||
dts: None,
|
||||
data,
|
||||
};
|
||||
assert!(parser.parse(&pes).is_empty());
|
||||
assert_eq!(parser.buf, vec![0x0B], "lone trailing 0x0B retained");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ac3_frame_size_table() {
|
||||
// fscod=0 (48kHz), frmsizecod=0: 64 words = 128 bytes
|
||||
|
||||
+409
-77
@@ -8,10 +8,20 @@
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
const DTS_CORE_SYNC: [u8; 4] = [0x7F, 0xFE, 0x80, 0x01];
|
||||
/// DTS-HD extension substream syncword. The parser delimits an access unit by
|
||||
/// the next CORE sync (so every extension between two cores is captured), and
|
||||
/// never needs to locate or size the extension itself — so this is referenced
|
||||
/// only by the tests that synthesize extension substreams.
|
||||
#[cfg(test)]
|
||||
const DTS_HD_EXT_SYNC: [u8; 4] = [0x64, 0x58, 0x20, 0x25];
|
||||
|
||||
pub struct DtsParser {
|
||||
buf: Vec<u8>,
|
||||
/// PTS of the access unit currently being assembled in `buf` (the unit
|
||||
/// starting at the first buffered core sync). Captured when that core
|
||||
/// frame's PES first arrived; the trailing extension-substream PES
|
||||
/// packets carry their own (later) PTS which must NOT override it.
|
||||
pending_pts: i64,
|
||||
}
|
||||
|
||||
impl Default for DtsParser {
|
||||
@@ -24,10 +34,29 @@ impl DtsParser {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
buf: Vec::with_capacity(32768),
|
||||
pending_pts: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard cap on a buffered access unit (core + all its extension substreams).
|
||||
/// A DTS-HD MA frame is at most a few tens of KB; if the buffer grows past
|
||||
/// this without a clean boundary we resync rather than stall or balloon.
|
||||
const MAX_AU_BYTES: usize = 65536;
|
||||
|
||||
/// Minimum plausible DTS core frame size. The core header alone is ~10-14
|
||||
/// bytes; a decoded `core_size` below this means we matched a false/corrupt
|
||||
/// core sync (the 14-bit `fsize` field decoded to a tiny value) rather than a
|
||||
/// real frame, so we resync instead of emitting a junk access unit.
|
||||
const MIN_CORE_FRAME_BYTES: usize = 10;
|
||||
|
||||
/// Sentinel for "no valid PTS base captured yet". Real PTS-in-ns values are
|
||||
/// non-negative (derived from the unsigned 90 kHz PES timestamp), so a negative
|
||||
/// value can never collide with a genuine timestamp. Used to mark the PTS base
|
||||
/// invalid after a forced flush so the next PES sets it regardless of buffer
|
||||
/// state.
|
||||
const PTS_UNSET: i64 = -1;
|
||||
|
||||
impl CodecParser for DtsParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
if pes.data.is_empty() {
|
||||
@@ -35,80 +64,162 @@ impl CodecParser for DtsParser {
|
||||
}
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
|
||||
// On Blu-ray, a DTS-HD MA/HRA access unit is a DTS core frame
|
||||
// (sync 0x7FFE8001) followed by one or more DTS extension substreams
|
||||
// (sync 0x64582025). The m2ts demuxer hands those out as SEPARATE PES
|
||||
// packets on the same PID — the core in one PES, then the extension
|
||||
// substreams in following PES packets (with their own, later PTS). The
|
||||
// lossless audio lives entirely in the extension substreams, so an
|
||||
// access unit is only complete once all of its trailing extensions
|
||||
// have been buffered. We assemble across PES boundaries here: an access
|
||||
// unit runs from its core sync up to (but not including) the NEXT core
|
||||
// sync. Emitting on the core boundary keeps the core + every following
|
||||
// extension substream together (the lossless data), instead of the
|
||||
// old per-PES emit that dropped the extension PES packets and
|
||||
// downgraded the track to lossy DTS core (the Dunkirk / Fight Club
|
||||
// bug). The PTS is the core frame's PTS, captured when the unit began.
|
||||
// Capture the access unit's PTS base on a fresh buffer, or whenever a
|
||||
// prior forced (safety-valve) flush left it invalidated — in the
|
||||
// forced case the bytes still in `buf` are not a real core frame, so
|
||||
// the first PES to arrive after the flush carries the correct base.
|
||||
if self.buf.is_empty() || self.pending_pts == PTS_UNSET {
|
||||
self.pending_pts = pts_ns;
|
||||
}
|
||||
self.buf.extend_from_slice(&pes.data);
|
||||
|
||||
let data = &self.buf;
|
||||
let mut frames = Vec::new();
|
||||
let mut pos = 0;
|
||||
|
||||
while pos < data.len() {
|
||||
// Find DTS core sync
|
||||
let start = match find_sync(&data[pos..], &DTS_CORE_SYNC) {
|
||||
Some(offset) => pos + offset,
|
||||
None => break,
|
||||
loop {
|
||||
// Resync to the first core sync; drop any leading junk.
|
||||
let Some(start) = find_sync(&self.buf, &DTS_CORE_SYNC) else {
|
||||
// No core sync at all yet — keep at most a 3-byte tail so a
|
||||
// sync split across PES packets can still be found next time.
|
||||
if self.buf.len() > 3 {
|
||||
let tail = self.buf.len() - 3;
|
||||
self.buf.drain(..tail);
|
||||
}
|
||||
break;
|
||||
};
|
||||
|
||||
// Need at least 10 bytes for core header to get frame size
|
||||
if start + 10 > data.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let core_size = dts_core_frame_size(&data[start..]);
|
||||
if core_size == 0 || core_size > 32768 {
|
||||
pos = start + 4;
|
||||
continue;
|
||||
}
|
||||
|
||||
if start + core_size > data.len() {
|
||||
// Incomplete core frame
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for DTS-HD extension after core
|
||||
let mut total_size = core_size;
|
||||
if start + core_size + 4 <= data.len() {
|
||||
if let Some(0) = find_sync(
|
||||
&data[start + core_size..start + core_size + 4],
|
||||
&DTS_HD_EXT_SYNC,
|
||||
) {
|
||||
let ext = &data[start + core_size..];
|
||||
if ext.len() >= 9 {
|
||||
let ext_size = dts_hd_ext_frame_size(ext);
|
||||
if start + core_size + ext_size <= data.len() {
|
||||
total_size = core_size + ext_size;
|
||||
}
|
||||
// If ext incomplete, just emit core
|
||||
}
|
||||
if start > 0 {
|
||||
self.buf.drain(..start);
|
||||
if find_sync(&self.buf, &DTS_CORE_SYNC) != Some(0) {
|
||||
// Shouldn't happen, but never loop forever.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Need the core header to size the core frame.
|
||||
if self.buf.len() < 10 {
|
||||
break;
|
||||
}
|
||||
let core_size = dts_core_frame_size(&self.buf);
|
||||
// `dts_core_frame_size` returns a 14-bit `fsize + 1`, so it is
|
||||
// always in [1, 16384]; the bare `== 0` / `> MAX_AU_BYTES` checks
|
||||
// can never fire. A real DTS core header is at least ~10-14 bytes,
|
||||
// so any decoded size below that came from a false/corrupt sync.
|
||||
// Reject it (drain the 4 syncword bytes and resync) instead of
|
||||
// letting a tiny bogus size close the current access unit at a junk
|
||||
// boundary and drop the trailing extension substreams. The
|
||||
// `> MAX_AU_BYTES` upper bound is kept as a harmless guard.
|
||||
if !(MIN_CORE_FRAME_BYTES..=MAX_AU_BYTES).contains(&core_size) {
|
||||
// Bogus core sync — skip past it and resync.
|
||||
self.buf.drain(..4);
|
||||
continue;
|
||||
}
|
||||
if self.buf.len() < core_size {
|
||||
break; // core frame not fully buffered yet — wait
|
||||
}
|
||||
|
||||
// The access unit ends at the next *valid* core sync. The search
|
||||
// begins after this core's syncword so we don't re-match it.
|
||||
// Anything between the core and that next sync is this unit's
|
||||
// extension substream(s) — which can themselves contain byte
|
||||
// sequences matching the core syncword, so a raw `find_sync` match
|
||||
// is not enough: a candidate is only a real boundary if its decoded
|
||||
// core size is plausible. `next_core_boundary` skips bogus matches.
|
||||
//
|
||||
// `forced` distinguishes a real next-core boundary from a forced
|
||||
// safety-valve flush. On a forced flush the access unit was NOT
|
||||
// closed by a new core sync, so the bytes following it are not a
|
||||
// fresh core frame and the current PES's PTS (which on a forced
|
||||
// flush is an extension-substream PES, carrying its own later
|
||||
// timestamp) must NOT become the next unit's PTS base.
|
||||
let mut forced = false;
|
||||
let au_end = match next_core_boundary(&self.buf, core_size) {
|
||||
NextCore::Found(end) => end,
|
||||
NextCore::NeedMore => break, // candidate sync needs more header
|
||||
NextCore::None => {
|
||||
// No next core sync buffered yet. The trailing extension
|
||||
// substream PES packets may still be arriving, so WAIT for
|
||||
// them rather than emit a core-only (lossy) frame — unless
|
||||
// the buffer has grown unreasonably large, in which case
|
||||
// emit what we have to guarantee forward progress.
|
||||
if self.buf.len() <= MAX_AU_BYTES {
|
||||
break;
|
||||
}
|
||||
forced = true;
|
||||
self.buf.len()
|
||||
}
|
||||
};
|
||||
|
||||
let au: Vec<u8> = self.buf[..au_end].to_vec();
|
||||
frames.push(Frame {
|
||||
pts_ns,
|
||||
pts_ns: self.pending_pts,
|
||||
keyframe: true,
|
||||
data: data[start..start + total_size].to_vec(),
|
||||
data: au,
|
||||
duration_ns: None,
|
||||
});
|
||||
pos = start + total_size;
|
||||
}
|
||||
|
||||
// Keep unconsumed data
|
||||
let keep_from = if pos < data.len() {
|
||||
find_sync(&data[pos..], &DTS_CORE_SYNC)
|
||||
.map(|o| pos + o)
|
||||
.unwrap_or(data.len())
|
||||
} else {
|
||||
data.len()
|
||||
};
|
||||
|
||||
if keep_from < data.len() {
|
||||
self.buf = data[keep_from..].to_vec();
|
||||
} else {
|
||||
self.buf.clear();
|
||||
self.buf.drain(..au_end);
|
||||
if forced {
|
||||
// Safety-valve flush: the next access unit's real core PES has
|
||||
// not arrived. Invalidate the PTS so the next PES sets it
|
||||
// regardless of buffer state, rather than inheriting this
|
||||
// (non-core) PES's timestamp.
|
||||
self.pending_pts = PTS_UNSET;
|
||||
} else {
|
||||
// Real boundary: the next access unit (now at buf start) begins
|
||||
// at a core sync that arrived inside this PES, so this PES's PTS
|
||||
// is the correct base for it.
|
||||
self.pending_pts = pts_ns;
|
||||
}
|
||||
}
|
||||
|
||||
frames
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Vec<Frame> {
|
||||
// End of stream: emit the final access unit still buffered (the last
|
||||
// core + its extension substreams, which had no following core sync to
|
||||
// close it during streaming). Require a complete core frame; drop a
|
||||
// bare partial sync tail.
|
||||
if find_sync(&self.buf, &DTS_CORE_SYNC) != Some(0) || self.buf.len() < 10 {
|
||||
self.buf.clear();
|
||||
return Vec::new();
|
||||
}
|
||||
let core_size = dts_core_frame_size(&self.buf);
|
||||
// `dts_core_frame_size` returns a 14-bit `fsize + 1` (never 0), so the
|
||||
// old `== 0` check was dead; reject a sub-minimum core like `parse()`.
|
||||
if core_size < MIN_CORE_FRAME_BYTES || self.buf.len() < core_size {
|
||||
self.buf.clear();
|
||||
return Vec::new();
|
||||
}
|
||||
let au = std::mem::take(&mut self.buf);
|
||||
// A non-empty buffer here means a PES arrived after any prior forced
|
||||
// flush (which fully drains `buf`), so `pending_pts` was reset to that
|
||||
// PES's real PTS. Clamp the sentinel to 0 defensively all the same.
|
||||
let pts_ns = if self.pending_pts == PTS_UNSET {
|
||||
0
|
||||
} else {
|
||||
self.pending_pts
|
||||
};
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: au,
|
||||
duration_ns: None,
|
||||
}]
|
||||
}
|
||||
|
||||
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
@@ -121,6 +232,40 @@ fn find_sync(data: &[u8], pattern: &[u8; 4]) -> Option<usize> {
|
||||
(0..=data.len() - 4).find(|&i| data[i..i + 4] == *pattern)
|
||||
}
|
||||
|
||||
/// Result of scanning for the next valid core sync that closes an access unit.
|
||||
enum NextCore {
|
||||
/// A valid next core sync was found; the access unit ends at this offset.
|
||||
Found(usize),
|
||||
/// A candidate core sync was found but its header isn't fully buffered yet,
|
||||
/// so its validity can't be decided — wait for more data.
|
||||
NeedMore,
|
||||
/// No (further) core sync found in the buffer.
|
||||
None,
|
||||
}
|
||||
|
||||
/// Find the next *valid* core sync after the current core frame, to delimit the
|
||||
/// access unit. Extension-substream payload can contain byte sequences that
|
||||
/// match the core syncword, so each candidate is validated by decoding its
|
||||
/// core size: a match whose decoded size is implausible (< MIN_CORE_FRAME_BYTES
|
||||
/// or > MAX_AU_BYTES) is a false sync and is skipped, continuing the search.
|
||||
fn next_core_boundary(buf: &[u8], core_size: usize) -> NextCore {
|
||||
let mut from = core_size;
|
||||
while let Some(rel) = find_sync(&buf[from..], &DTS_CORE_SYNC) {
|
||||
let pos = from + rel;
|
||||
// Need the candidate's core header to judge it.
|
||||
if buf.len() - pos < 10 {
|
||||
return NextCore::NeedMore;
|
||||
}
|
||||
let sz = dts_core_frame_size(&buf[pos..]);
|
||||
if (MIN_CORE_FRAME_BYTES..=MAX_AU_BYTES).contains(&sz) {
|
||||
return NextCore::Found(pos);
|
||||
}
|
||||
// False sync inside extension payload — skip it and keep searching.
|
||||
from = pos + 4;
|
||||
}
|
||||
NextCore::None
|
||||
}
|
||||
|
||||
/// DTS core frame size from header bits.
|
||||
/// fsize is at bits 46-59 (14 bits) of the header: bytes 5-7.
|
||||
fn dts_core_frame_size(data: &[u8]) -> usize {
|
||||
@@ -134,20 +279,6 @@ fn dts_core_frame_size(data: &[u8]) -> usize {
|
||||
fsize + 1
|
||||
}
|
||||
|
||||
/// DTS-HD extension frame size from extension header.
|
||||
pub fn dts_hd_ext_frame_size(ext: &[u8]) -> usize {
|
||||
if ext.len() < 9 {
|
||||
return 0;
|
||||
}
|
||||
let raw =
|
||||
((ext[6] as usize & 0x1F) << 11) | ((ext[7] as usize) << 3) | ((ext[8] as usize) >> 5);
|
||||
raw + 1
|
||||
}
|
||||
|
||||
pub fn find_dts_hd_ext_sync(data: &[u8]) -> Option<usize> {
|
||||
find_sync(data, &DTS_HD_EXT_SYNC)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -181,12 +312,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_single_frame() {
|
||||
// A single core frame with no following core sync is the LAST access
|
||||
// unit — held during streaming (can't know an extension won't follow),
|
||||
// then drained on flush() at EOF.
|
||||
let mut parser = DtsParser::new();
|
||||
let frame = make_dts_core(512);
|
||||
let pes = make_pes(frame, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data.len(), 512);
|
||||
assert!(parser.parse(&pes).is_empty());
|
||||
let tail = parser.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(tail[0].data.len(), 512);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -199,9 +334,206 @@ mod tests {
|
||||
assert!(parser.parse(&pes1).is_empty());
|
||||
|
||||
let pes2 = make_pes(frame[mid..].to_vec(), Some(93000));
|
||||
let frames = parser.parse(&pes2);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data.len(), 512);
|
||||
assert!(parser.parse(&pes2).is_empty());
|
||||
let tail = parser.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(tail[0].data.len(), 512);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_cores_back_to_back_emit_first_on_boundary() {
|
||||
// The first complete unit is emitted as soon as the next core sync is
|
||||
// seen; the second is held until flush.
|
||||
let mut parser = DtsParser::new();
|
||||
let mut stream = make_dts_core(512);
|
||||
stream.extend_from_slice(&make_dts_core(640));
|
||||
let f = parser.parse(&make_pes(stream, Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].data.len(), 512);
|
||||
let tail = parser.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(tail[0].data.len(), 640);
|
||||
}
|
||||
|
||||
/// Build a minimal DTS-HD extension substream of `size` bytes (just the
|
||||
/// sync + zero-padding). The parser delimits extensions by the next CORE
|
||||
/// sync, not by the extension's own size header, so a valid header isn't
|
||||
/// required — only that the bytes carry no spurious core sync.
|
||||
fn make_dts_ext(size: usize) -> Vec<u8> {
|
||||
let mut e = vec![0u8; size];
|
||||
e[0..4].copy_from_slice(&DTS_HD_EXT_SYNC);
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_dts_hd_extension_in_separate_pes_packets() {
|
||||
// The real Blu-ray layout (ground-truthed on Dunkirk): the DTS core
|
||||
// arrives in one PES, then its DTS-HD MA extension substreams arrive
|
||||
// in SEPARATE following PES packets on the same PID. The parser must
|
||||
// stitch core + all trailing extensions into one access unit — not
|
||||
// emit a core-only (lossy) frame and drop the extension PES packets
|
||||
// (the Dunkirk / Fight Club lossy-core bug).
|
||||
let mut parser = DtsParser::new();
|
||||
|
||||
// Frame 1: core (512) + two extension substreams (256 + 200).
|
||||
assert!(
|
||||
parser
|
||||
.parse(&make_pes(make_dts_core(512), Some(90000)))
|
||||
.is_empty(),
|
||||
"core alone: must wait for any following extension"
|
||||
);
|
||||
assert!(
|
||||
parser
|
||||
.parse(&make_pes(make_dts_ext(256), Some(91000)))
|
||||
.is_empty(),
|
||||
"first extension PES: still waiting for the unit to close"
|
||||
);
|
||||
assert!(
|
||||
parser
|
||||
.parse(&make_pes(make_dts_ext(200), Some(91500)))
|
||||
.is_empty(),
|
||||
"second extension PES: unit still not closed (no next core yet)"
|
||||
);
|
||||
|
||||
// Frame 2's core PES arrives — that closes frame 1. The emitted unit
|
||||
// must be core + BOTH extensions (lossless preserved), and keep the
|
||||
// core's PTS, not the extension PES timestamps.
|
||||
let f = parser.parse(&make_pes(make_dts_core(512), Some(93000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(
|
||||
f[0].data.len(),
|
||||
512 + 256 + 200,
|
||||
"frame must include core + every extension substream"
|
||||
);
|
||||
|
||||
// EOF drains frame 2.
|
||||
let tail = parser.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(tail[0].data.len(), 512);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_split_across_pes_is_preserved() {
|
||||
// An extension substream straddling a PES boundary must still be fully
|
||||
// attached to its core.
|
||||
let mut parser = DtsParser::new();
|
||||
let ext = make_dts_ext(300);
|
||||
assert!(
|
||||
parser
|
||||
.parse(&make_pes(make_dts_core(512), Some(90000)))
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
parser
|
||||
.parse(&make_pes(ext[..150].to_vec(), Some(91000)))
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
parser
|
||||
.parse(&make_pes(ext[150..].to_vec(), Some(91000)))
|
||||
.is_empty()
|
||||
);
|
||||
let tail = parser.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(tail[0].data.len(), 512 + 300);
|
||||
}
|
||||
|
||||
/// Build 4 bytes that look like a DTS core sync but whose `fsize` field
|
||||
/// decodes to a tiny `core_size` (< MIN_CORE_FRAME_BYTES). With the
|
||||
/// dead-code guards this passed validation and could close an access unit
|
||||
/// at a junk boundary; with the fix it must be drained and resynced past.
|
||||
fn bogus_tiny_core_sync() -> Vec<u8> {
|
||||
// Core sync + zero header bytes. fsize = 0 → core_size = 1 (< 10).
|
||||
let mut v = vec![0u8; 10];
|
||||
v[0..4].copy_from_slice(&DTS_CORE_SYNC);
|
||||
// bytes 5,6,7 left zero → fsize = 0 → dts_core_frame_size = 1.
|
||||
assert_eq!(dts_core_frame_size(&v), 1);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bogus_tiny_core_sync_does_not_split_or_drop_real_au() {
|
||||
// A real core frame followed by an extension substream that happens to
|
||||
// contain a false core sync whose fsize decodes tiny. The bogus sync
|
||||
// must NOT close the real access unit early (dropping the rest of the
|
||||
// extension) nor emit a junk few-byte frame — it must be skipped, and
|
||||
// the whole core + extension preserved as one access unit.
|
||||
let mut parser = DtsParser::new();
|
||||
|
||||
// Frame 1: core(512) + an extension whose body embeds a bogus tiny
|
||||
// core sync midway through.
|
||||
let mut ext = make_dts_ext(256);
|
||||
// Embed the bogus core sync inside the extension body (offset 64).
|
||||
let bogus = bogus_tiny_core_sync();
|
||||
ext[64..64 + bogus.len()].copy_from_slice(&bogus);
|
||||
|
||||
let mut frame1 = make_dts_core(512);
|
||||
frame1.extend_from_slice(&ext);
|
||||
|
||||
// No next REAL core yet → frame 1 held.
|
||||
assert!(
|
||||
parser.parse(&make_pes(frame1, Some(90000))).is_empty(),
|
||||
"bogus tiny core sync must not close the AU; wait for a real core"
|
||||
);
|
||||
|
||||
// Frame 2's real core arrives — closes frame 1.
|
||||
let f = parser.parse(&make_pes(make_dts_core(640), Some(93000)));
|
||||
assert_eq!(f.len(), 1, "exactly one real access unit emitted");
|
||||
assert_eq!(
|
||||
f[0].data.len(),
|
||||
512 + 256,
|
||||
"AU must be the full core + extension, not split at the bogus sync"
|
||||
);
|
||||
assert_eq!(f[0].pts_ns, pts_to_ns(90000), "AU keeps the core's PTS");
|
||||
|
||||
let tail = parser.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(tail[0].data.len(), 640);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_emit_does_not_corrupt_next_au_pts() {
|
||||
// When the buffer exceeds MAX_AU_BYTES with no next core sync, the
|
||||
// parser force-emits for forward progress. The current PES at that
|
||||
// point is an extension-substream PES (later PTS). The forced path must
|
||||
// NOT make that extension PTS the base of the NEXT access unit.
|
||||
let mut parser = DtsParser::new();
|
||||
|
||||
// Core PES at the real PTS, then a giant extension (no next core) that
|
||||
// pushes the buffer past MAX_AU_BYTES, forcing an emit.
|
||||
let core_pts = 90000i64;
|
||||
assert!(
|
||||
parser
|
||||
.parse(&make_pes(make_dts_core(512), Some(core_pts)))
|
||||
.is_empty()
|
||||
);
|
||||
let ext_pts = 120000i64; // later extension-PES timestamp
|
||||
let big_ext = make_dts_ext(MAX_AU_BYTES + 1024);
|
||||
let f = parser.parse(&make_pes(big_ext, Some(ext_pts)));
|
||||
assert_eq!(f.len(), 1, "oversized buffer force-emits one AU");
|
||||
assert_eq!(
|
||||
f[0].pts_ns,
|
||||
pts_to_ns(core_pts),
|
||||
"forced AU keeps the core PTS"
|
||||
);
|
||||
|
||||
// The next REAL core PES arrives with its own PTS. Its AU must inherit
|
||||
// THIS core's PTS, not the prior extension PES timestamp.
|
||||
let next_core_pts = 150000i64;
|
||||
assert!(
|
||||
parser
|
||||
.parse(&make_pes(make_dts_core(512), Some(next_core_pts)))
|
||||
.is_empty()
|
||||
);
|
||||
let next_next_pts = 180000i64;
|
||||
let f2 = parser.parse(&make_pes(make_dts_core(512), Some(next_next_pts)));
|
||||
assert_eq!(f2.len(), 1);
|
||||
assert_eq!(
|
||||
f2[0].pts_ns,
|
||||
pts_to_ns(next_core_pts),
|
||||
"AU after a forced emit must use the next core's PTS, not the \
|
||||
stale extension PTS"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+11
-8
@@ -38,8 +38,11 @@ impl CodecParser for H264Parser {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Use DTS when available (monotonic for B-frame content), fall back to PTS
|
||||
let pts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
|
||||
// MKV block timecodes are PRESENTATION timestamps; frames are stored in
|
||||
// decode order and the player reorders by timecode. Use PTS, not DTS —
|
||||
// DTS presents B-frames in decode order (visible judder) and breaks
|
||||
// PTS-based seeking. Fall back to DTS only if PTS is absent.
|
||||
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
|
||||
|
||||
// Scan NAL units for SPS, PPS, and IDR detection
|
||||
let mut keyframe = false;
|
||||
@@ -451,10 +454,10 @@ mod tests {
|
||||
assert!(frames.is_empty());
|
||||
}
|
||||
|
||||
// --- DTS preferred over PTS when present ---
|
||||
// --- PTS (presentation) used for the MKV block timecode, not DTS ---
|
||||
|
||||
#[test]
|
||||
fn dts_preferred_over_pts() {
|
||||
fn pts_preferred_over_dts() {
|
||||
let mut parser = H264Parser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
@@ -464,13 +467,13 @@ mod tests {
|
||||
|
||||
let pes = PesPacket {
|
||||
pid: 0x1011,
|
||||
pts: Some(180000), // 2 seconds
|
||||
dts: Some(90000), // 1 second
|
||||
pts: Some(180000), // 2 seconds (presentation)
|
||||
dts: Some(90000), // 1 second (decode)
|
||||
data,
|
||||
};
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
// DTS should be used, not PTS
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
// PTS must be used — MKV block timecodes are presentation timestamps.
|
||||
assert_eq!(frames[0].pts_ns, 2_000_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
+166
-25
@@ -48,8 +48,13 @@ impl CodecParser for HevcParser {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Use DTS when available (monotonic for B-frame content), fall back to PTS
|
||||
let pts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
|
||||
// MKV block timecodes are PRESENTATION timestamps; frames are stored
|
||||
// in decode order (the order they arrive here) and the player reorders
|
||||
// for display by timecode. So use PTS, not DTS — using DTS makes the
|
||||
// block timecode monotonic in storage order, which presents B-frames in
|
||||
// decode order (visible judder / wrong frames) and breaks PTS-based
|
||||
// seeking. Fall back to DTS only if PTS is somehow absent.
|
||||
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
|
||||
let data = &pes.data;
|
||||
let mut keyframe = false;
|
||||
// Pre-size: output is ~input bytes with a few 4-byte length
|
||||
@@ -62,6 +67,11 @@ impl CodecParser for HevcParser {
|
||||
while let Some(sc_pos) = find_start_code(data, pos) {
|
||||
if let Some(nal_start) = skip_start_code(data, sc_pos) {
|
||||
let next = find_start_code(data, nal_start).unwrap_or(data.len());
|
||||
// Strip the leading zeros of the following start code. For a
|
||||
// conforming bitstream this is lossless: rbsp_trailing_bits()
|
||||
// sets a stop-one bit, so the final byte of any RBSP is never
|
||||
// 0x00 — the only trailing zeros here belong to the next
|
||||
// 00 00 (00) 01 prefix.
|
||||
let mut end = next;
|
||||
while end > nal_start && data[end - 1] == 0x00 {
|
||||
end -= 1;
|
||||
@@ -124,34 +134,41 @@ impl CodecParser for HevcParser {
|
||||
// Full HEVCDecoderConfigurationRecord is complex — for now, concatenate
|
||||
let mut record = Vec::new();
|
||||
|
||||
// Minimal HEVCDecoderConfigurationRecord header
|
||||
// Minimal HEVCDecoderConfigurationRecord header.
|
||||
//
|
||||
// The stored SPS NAL is [2-byte HEVC NAL header][SPS RBSP...].
|
||||
// The RBSP begins at sps[2]; profile_tier_level() begins one byte
|
||||
// later, after sps_video_parameter_set_id u(4) +
|
||||
// sps_max_sub_layers_minus1 u(3) + sps_temporal_id_nesting_flag u(1)
|
||||
// (= sps[2], a full byte). So the profile_tier_level fields are:
|
||||
// sps[3] general_profile_space u(2)+tier u(1)+profile_idc u(5)
|
||||
// sps[4..8] general_profile_compatibility_flags u(32)
|
||||
// sps[8..14] general_constraint_indicator_flags 48 bits
|
||||
// sps[14] general_level_idc u(8)
|
||||
// (Byte-aligned read; emulation-prevention bytes within the first
|
||||
// 15 SPS bytes are not handled — extremely rare and matches the
|
||||
// pre-existing simplification.)
|
||||
record.push(1); // configurationVersion
|
||||
// General profile space, tier flag, profile IDC from SPS
|
||||
if sps.len() > 3 {
|
||||
record.push(sps[1]); // general_profile_space + general_tier_flag + general_profile_idc
|
||||
// general_profile_space + general_tier_flag + general_profile_idc
|
||||
record.push(if sps.len() > 3 { sps[3] } else { 0 });
|
||||
// general_profile_compatibility_flags (4 bytes) — SPS bytes 4..8
|
||||
if sps.len() > 7 {
|
||||
record.extend_from_slice(&sps[4..8]);
|
||||
} else {
|
||||
record.push(0);
|
||||
let avail = sps.len().saturating_sub(4).min(4);
|
||||
record.extend_from_slice(&sps[sps.len().min(4)..sps.len().min(8)]);
|
||||
record.extend_from_slice(&vec![0u8; 4 - avail]);
|
||||
}
|
||||
// general_profile_compatibility_flags (4 bytes) — from SPS bytes 2..6
|
||||
if sps.len() > 5 {
|
||||
record.extend_from_slice(&sps[2..6]);
|
||||
// general_constraint_indicator_flags (6 bytes) — SPS bytes 8..14
|
||||
if sps.len() > 13 {
|
||||
record.extend_from_slice(&sps[8..14]);
|
||||
} else {
|
||||
record.extend_from_slice(&[0, 0, 0, 0]);
|
||||
let avail = sps.len().saturating_sub(8).min(6);
|
||||
record.extend_from_slice(&sps[sps.len().min(8)..sps.len().min(14)]);
|
||||
record.extend_from_slice(&vec![0u8; 6 - avail]);
|
||||
}
|
||||
// general_constraint_indicator_flags (6 bytes) — from SPS bytes 6..12
|
||||
if sps.len() > 11 {
|
||||
record.extend_from_slice(&sps[6..12]);
|
||||
} else {
|
||||
let avail = sps.len().saturating_sub(6).min(6);
|
||||
if avail > 0 {
|
||||
record.extend_from_slice(&sps[6..6 + avail]);
|
||||
record.extend_from_slice(&vec![0u8; 6 - avail]);
|
||||
} else {
|
||||
record.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
|
||||
}
|
||||
}
|
||||
// general_level_idc
|
||||
record.push(if sps.len() > 12 { sps[12] } else { 0 });
|
||||
// general_level_idc — SPS byte 14
|
||||
record.push(if sps.len() > 14 { sps[14] } else { 0 });
|
||||
// min_spatial_segmentation_idc (4 + 12 bits)
|
||||
record.extend_from_slice(&[0xF0, 0x00]);
|
||||
// parallelismType (6 + 2 bits)
|
||||
@@ -268,6 +285,103 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvcc_profile_tier_level_offsets() {
|
||||
// The hvcC fixed header must read profile_tier_level from the SPS
|
||||
// RBSP, not from the NAL header. Stored SPS = [2-byte NAL header][RBSP].
|
||||
// RBSP layout (byte-aligned):
|
||||
// sps[2] sps_vps_id/max_sub_layers/temporal_nesting
|
||||
// sps[3] general_profile_space+tier+profile_idc
|
||||
// sps[4..8] general_profile_compatibility_flags
|
||||
// sps[8..14] general_constraint_indicator_flags
|
||||
// sps[14] general_level_idc
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
// Distinct, recognizable values for each field.
|
||||
let sps_rbsp: [u8; 13] = [
|
||||
0xAB, // sps[2] (vps_id etc.) — must NOT leak into profile fields
|
||||
0x21, // sps[3] profile byte: space=0, tier=0, profile_idc=1
|
||||
0x60, 0x00, 0x00, 0x00, // sps[4..8] compat flags
|
||||
0x90, 0x00, 0x00, 0x00, 0x00, 0x00, // sps[8..14] constraint flags
|
||||
0x7B, // sps[14] level_idc = 123
|
||||
];
|
||||
|
||||
let mut data = Vec::new();
|
||||
// VPS
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(32));
|
||||
data.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
|
||||
// SPS — 2-byte header + the structured RBSP above
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(33));
|
||||
data.extend_from_slice(&sps_rbsp);
|
||||
// PPS
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(34));
|
||||
data.extend_from_slice(&[0xDD, 0xEE]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
parser.parse(&pes);
|
||||
|
||||
let cp = parser
|
||||
.codec_private()
|
||||
.expect("codec_private should be Some");
|
||||
|
||||
// record[0] = configurationVersion
|
||||
assert_eq!(cp[0], 1, "configurationVersion");
|
||||
// record[1] = general_profile_space+tier+profile_idc <- sps[3]
|
||||
assert_eq!(
|
||||
cp[1], 0x21,
|
||||
"profile byte must come from SPS RBSP, not NAL hdr"
|
||||
);
|
||||
// record[2..6] = general_profile_compatibility_flags <- sps[4..8]
|
||||
assert_eq!(&cp[2..6], &[0x60, 0x00, 0x00, 0x00], "compatibility flags");
|
||||
// record[6..12] = general_constraint_indicator_flags <- sps[8..14]
|
||||
assert_eq!(
|
||||
&cp[6..12],
|
||||
&[0x90, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||
"constraint flags"
|
||||
);
|
||||
// record[12] = general_level_idc <- sps[14]
|
||||
assert_eq!(cp[12], 0x7B, "level_idc must come from sps[14]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvcc_short_sps_does_not_panic() {
|
||||
// A truncated SPS must still produce a fixed header without panicking
|
||||
// and zero-pad the missing profile/level bytes.
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(32));
|
||||
data.extend_from_slice(&[0xAA]);
|
||||
// SPS with only 3 RBSP bytes (stored len = 5): forces every guard path
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(33));
|
||||
data.extend_from_slice(&[0x11, 0x22, 0x33]);
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(34));
|
||||
data.extend_from_slice(&[0xDD]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
parser.parse(&pes);
|
||||
|
||||
let cp = parser
|
||||
.codec_private()
|
||||
.expect("codec_private should be Some");
|
||||
// sps stored = [hdr0, hdr1, 0x11, 0x22, 0x33], len 5.
|
||||
// profile byte = sps[3] = 0x22; everything past sps[4]=0x33 is absent.
|
||||
assert_eq!(cp[0], 1);
|
||||
assert_eq!(cp[1], 0x22, "profile byte = sps[3]");
|
||||
// compat flags: only sps[4]=0x33 present, rest zero-padded.
|
||||
assert_eq!(&cp[2..6], &[0x33, 0x00, 0x00, 0x00]);
|
||||
// constraint flags: none present, all zero.
|
||||
assert_eq!(&cp[6..12], &[0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
|
||||
// level_idc: absent, zero.
|
||||
assert_eq!(cp[12], 0x00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none_before_params() {
|
||||
let parser = HevcParser::new();
|
||||
@@ -474,6 +588,33 @@ mod tests {
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
// --- PTS (presentation), not DTS, drives the MKV block timecode ---
|
||||
// Regression for B-frame presentation: writing DTS as the block timecode
|
||||
// presents frames in decode order (visible judder) and breaks seeking.
|
||||
|
||||
#[test]
|
||||
fn pts_preferred_over_dts() {
|
||||
let mut parser = HevcParser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01]);
|
||||
data.extend_from_slice(&hevc_nal_header(1)); // TRAIL_R slice
|
||||
data.extend_from_slice(&[0x10, 0x20]);
|
||||
|
||||
let pes = PesPacket {
|
||||
pid: 0x1011,
|
||||
pts: Some(180000), // 2 s (presentation)
|
||||
dts: Some(90000), // 1 s (decode)
|
||||
data,
|
||||
};
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(
|
||||
frames[0].pts_ns, 2_000_000_000,
|
||||
"block timecode must be PTS"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Dolby Vision enhancement layer ---
|
||||
|
||||
#[test]
|
||||
|
||||
+88
-10
@@ -1,24 +1,39 @@
|
||||
//! BD/DVD LPCM (Linear PCM) audio parser.
|
||||
//!
|
||||
//! BD LPCM PES packets have a 4-byte header:
|
||||
//! BD LPCM PES packets (TS stream type 0x80) carry a 4-byte header on the
|
||||
//! elementary-stream payload:
|
||||
//! Bytes 0-1: audio frame number
|
||||
//! Byte 2: reserved
|
||||
//! Byte 3: quantization (bits 7-6), sample rate (bits 5-4), channel assignment (bits 3-0)
|
||||
//! This header is part of the ES payload, so the BD parser must strip it.
|
||||
//!
|
||||
//! DVD LPCM (private stream 1, sub-stream 0xA0-0xA7) has a 3-byte header.
|
||||
//! DVD LPCM lives in private stream 1 (sub-stream 0xA0-0xA7). Its 7-byte
|
||||
//! private sub-header (sub_id + frames + first-access-unit-ptr(2) + emphasis +
|
||||
//! quant/freq + channels) is stripped by `PsDemuxer` while demuxing the
|
||||
//! Program Stream. By the time a DVD LPCM `PesPacket` reaches this parser its
|
||||
//! `data` is already raw PCM, so the parser must NOT strip any further bytes —
|
||||
//! doing so drops one sample pair per PES and drifts the audio.
|
||||
//!
|
||||
//! The raw PCM data follows the header. No framing is needed — each PES
|
||||
//! payload minus its header is one complete audio frame.
|
||||
//! The two origins are distinguished by the `strip_header` flag: BD = strip,
|
||||
//! DVD = leave intact. The raw PCM data is otherwise one complete audio frame
|
||||
//! per PES; no framing is needed.
|
||||
//!
|
||||
//! For MKV: codec ID "A_PCM/INT/BIG" (BD) or "A_PCM/INT/LIT" (DVD).
|
||||
//! All frames are keyframes (uncompressed audio).
|
||||
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
/// BD LPCM header size in bytes.
|
||||
/// BD LPCM header size in bytes (present on BD-TS LPCM, absent on DVD-PS LPCM
|
||||
/// because `PsDemuxer` already stripped the private sub-header).
|
||||
const BD_LPCM_HEADER_SIZE: usize = 4;
|
||||
|
||||
pub struct LpcmParser;
|
||||
pub struct LpcmParser {
|
||||
/// Whether to strip the 4-byte BD LPCM header from each PES payload.
|
||||
///
|
||||
/// `true` for BD-TS LPCM (header still present), `false` for DVD-PS LPCM
|
||||
/// (header already removed by `PsDemuxer`).
|
||||
strip_header: bool,
|
||||
}
|
||||
|
||||
impl Default for LpcmParser {
|
||||
fn default() -> Self {
|
||||
@@ -27,23 +42,36 @@ impl Default for LpcmParser {
|
||||
}
|
||||
|
||||
impl LpcmParser {
|
||||
/// BD-TS LPCM parser: strips the 4-byte BD LPCM header from each PES.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
Self { strip_header: true }
|
||||
}
|
||||
|
||||
/// DVD-PS LPCM parser: `PsDemuxer` already stripped the private sub-header,
|
||||
/// so the payload is raw PCM and no further bytes are removed.
|
||||
pub fn new_dvd() -> Self {
|
||||
Self {
|
||||
strip_header: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CodecParser for LpcmParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
// Skip the BD LPCM header (4 bytes).
|
||||
let offset = if self.strip_header {
|
||||
BD_LPCM_HEADER_SIZE
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// If the PES is too short to contain header + data, return nothing.
|
||||
if pes.data.len() <= BD_LPCM_HEADER_SIZE {
|
||||
if pes.data.len() <= offset {
|
||||
return Vec::new();
|
||||
}
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: pes.data[BD_LPCM_HEADER_SIZE..].to_vec(),
|
||||
data: pes.data[offset..].to_vec(),
|
||||
duration_ns: None,
|
||||
}]
|
||||
}
|
||||
@@ -84,6 +112,56 @@ mod tests {
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000); // 90000 ticks = 1 second
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bd_lpcm_strips_4_byte_header() {
|
||||
// BD-TS LPCM: the 4-byte BD header is part of the ES payload and must
|
||||
// be stripped, leaving exactly the PCM bytes.
|
||||
let mut parser = LpcmParser::new();
|
||||
let header = vec![0x00, 0x01, 0x00, 0b1001_0001];
|
||||
let pcm = vec![0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88];
|
||||
let mut data = header;
|
||||
data.extend_from_slice(&pcm);
|
||||
|
||||
let frames = parser.parse(&make_pes(data, Some(0)));
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data, pcm, "BD must strip exactly 4 header bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dvd_lpcm_preserves_all_pcm_bytes() {
|
||||
// DVD-PS LPCM: PsDemuxer already removed the 7-byte private sub-header,
|
||||
// so the payload handed to this parser is raw PCM. The DVD parser must
|
||||
// NOT strip any further bytes (the round-2 audit Finding 3 bug: the BD
|
||||
// 4-byte strip dropped one sample pair per PES, drifting the audio).
|
||||
let mut parser = LpcmParser::new_dvd();
|
||||
let pcm = vec![0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0x01, 0x02];
|
||||
let frames = parser.parse(&make_pes(pcm.clone(), Some(90000)));
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(
|
||||
frames[0].data, pcm,
|
||||
"DVD must preserve every PCM byte — no second strip"
|
||||
);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dvd_lpcm_emits_short_payload_bd_would_drop() {
|
||||
// A 4-byte raw-PCM DVD payload (1 sample pair at 16-bit stereo). The BD
|
||||
// parser drops <= 4 bytes as "header only"; the DVD parser must emit it.
|
||||
let mut bd = LpcmParser::new();
|
||||
let mut dvd = LpcmParser::new_dvd();
|
||||
let pcm = vec![0xAA, 0xBB, 0xCC, 0xDD];
|
||||
|
||||
assert!(
|
||||
bd.parse(&make_pes(pcm.clone(), Some(0))).is_empty(),
|
||||
"BD treats 4 bytes as header-only"
|
||||
);
|
||||
let frames = dvd.parse(&make_pes(pcm.clone(), Some(0)));
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].data, pcm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn always_keyframe() {
|
||||
let mut parser = LpcmParser::new();
|
||||
|
||||
+23
-1
@@ -52,6 +52,18 @@ pub trait CodecParser: Send {
|
||||
/// Some (TrueHD): multiple access units per PES.
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame>;
|
||||
|
||||
/// Drain any access unit still buffered after the last PES.
|
||||
///
|
||||
/// Parsers that buffer across PES boundaries to assemble a complete
|
||||
/// access unit (e.g. DTS-HD, whose extension substreams arrive in
|
||||
/// separate PES packets) hold the final unit until they can prove it's
|
||||
/// complete. At end-of-stream there is no following packet to prove it,
|
||||
/// so the demuxer calls `flush()` once after the last PES to emit it.
|
||||
/// Default: nothing buffered, no tail.
|
||||
fn flush(&mut self) -> Vec<Frame> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Get codec initialization data (e.g., SPS+PPS for H.264).
|
||||
/// Returns None until enough data has been seen.
|
||||
fn codec_private(&self) -> Option<Vec<u8>>;
|
||||
@@ -90,7 +102,16 @@ impl CodecParser for PassthroughParser {
|
||||
/// Create the appropriate parser for a codec, with optional codec private data.
|
||||
///
|
||||
/// For DvdSub, `codec_data` should be the pre-formatted VobSub .idx palette header.
|
||||
pub fn parser_for_codec(codec: Codec, codec_data: Option<Vec<u8>>) -> Box<dyn CodecParser> {
|
||||
///
|
||||
/// `is_dvd_ps` selects the DVD program-stream variant where it matters: DVD
|
||||
/// LPCM arrives with its private sub-header already stripped by the
|
||||
/// `PsDemuxer`, so the LPCM parser must NOT strip the 4-byte BD LPCM header
|
||||
/// again (that would drop one PCM sample pair per PES → progressive drift).
|
||||
pub fn parser_for_codec(
|
||||
codec: Codec,
|
||||
codec_data: Option<Vec<u8>>,
|
||||
is_dvd_ps: bool,
|
||||
) -> Box<dyn CodecParser> {
|
||||
match codec {
|
||||
Codec::H264 => Box::new(h264::H264Parser::new()),
|
||||
Codec::Hevc => Box::new(hevc::HevcParser::new()),
|
||||
@@ -100,6 +121,7 @@ pub fn parser_for_codec(codec: Codec, codec_data: Option<Vec<u8>>) -> Box<dyn Co
|
||||
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => Box::new(dts::DtsParser::new()),
|
||||
Codec::TrueHd => Box::new(truehd::TrueHdParser::new()),
|
||||
Codec::Pgs => Box::new(pgs::PgsParser::new()),
|
||||
Codec::Lpcm if is_dvd_ps => Box::new(lpcm::LpcmParser::new_dvd()),
|
||||
Codec::Lpcm => Box::new(lpcm::LpcmParser::new()),
|
||||
Codec::DvdSub => Box::new(dvdsub::DvdSubParser::new(codec_data)),
|
||||
_ => Box::new(PassthroughParser::new(true)),
|
||||
|
||||
+79
-4
@@ -96,9 +96,14 @@ impl CodecParser for Mpeg2Parser {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let pts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
|
||||
// MKV block timecodes are PRESENTATION timestamps; frames are stored in
|
||||
// decode order and the player reorders by timecode. Use PTS, not DTS —
|
||||
// DTS presents B-frames in decode order (visible judder) and breaks
|
||||
// PTS-based seeking. Fall back to DTS only if PTS is absent.
|
||||
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
|
||||
let data = &pes.data;
|
||||
let mut keyframe = false;
|
||||
let mut has_picture = false;
|
||||
|
||||
// Scan for start codes in the elementary stream data.
|
||||
let mut pos = 0;
|
||||
@@ -176,6 +181,7 @@ impl CodecParser for Mpeg2Parser {
|
||||
PICTURE_CODE => {
|
||||
// Picture header: bytes after start code contain temporal_reference
|
||||
// (10 bits) + picture_coding_type (3 bits).
|
||||
has_picture = true;
|
||||
if sc + 5 < data.len() {
|
||||
let picture_coding_type = (data[sc + 5] >> 3) & 0x07;
|
||||
if picture_coding_type == PICTURE_TYPE_I {
|
||||
@@ -190,6 +196,23 @@ impl CodecParser for Mpeg2Parser {
|
||||
}
|
||||
}
|
||||
|
||||
// A PES that carried a sequence header but no picture start code is a
|
||||
// parameter-set-only access unit: it has no coded picture to emit.
|
||||
// Emitting it as a standalone keyframe would put bare sequence-header
|
||||
// bytes into frame data with no picture. The sequence header is
|
||||
// captured into codec_private above and is re-emitted in-band on the
|
||||
// next real picture's PES, so dropping the empty access unit loses
|
||||
// nothing. Mirrors how the H.264/HEVC parsers skip parameter-set-only
|
||||
// access units.
|
||||
//
|
||||
// Conservative: only drop when this PES actually contained a sequence
|
||||
// header and no picture. A PES with neither (e.g. a slice
|
||||
// continuation) still passes through unchanged, preserving real
|
||||
// keyframe detection.
|
||||
if !has_picture && contains_seq_header(data) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
vec![Frame {
|
||||
pts_ns,
|
||||
keyframe,
|
||||
@@ -241,6 +264,21 @@ fn parse_aspect_ratio(hdr: &[u8]) -> Option<(u8, u8)> {
|
||||
Some(ASPECT_RATIOS[ar_code])
|
||||
}
|
||||
|
||||
/// Returns true if `data` contains a sequence-header start code (00 00 01 B3).
|
||||
fn contains_seq_header(data: &[u8]) -> bool {
|
||||
let mut pos = 0;
|
||||
while let Some(sc) = find_start_code(data, pos) {
|
||||
if sc + 3 >= data.len() {
|
||||
break;
|
||||
}
|
||||
if data[sc + 3] == SEQ_HEADER_CODE {
|
||||
return true;
|
||||
}
|
||||
pos = sc + 4;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Find the position of the next start code (00 00 01) at or after `from`.
|
||||
fn find_start_code(data: &[u8], from: usize) -> Option<usize> {
|
||||
if data.len() < from + 3 {
|
||||
@@ -429,15 +467,15 @@ mod tests {
|
||||
assert!(has_ext, "codec_private should include sequence extension");
|
||||
}
|
||||
|
||||
// --- I-frame with sequence header = keyframe ---
|
||||
// --- sequence header + picture = keyframe ---
|
||||
|
||||
#[test]
|
||||
fn sequence_header_implies_keyframe() {
|
||||
fn sequence_header_with_picture_is_keyframe() {
|
||||
let mut parser = Mpeg2Parser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&make_seq_header(720, 480, 3, 4));
|
||||
// Even without an explicit picture header, a sequence header implies I-frame.
|
||||
data.extend_from_slice(&make_picture_header(PICTURE_TYPE_I));
|
||||
data.extend_from_slice(&[0xFF; 16]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
@@ -445,6 +483,43 @@ mod tests {
|
||||
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert!(frames[0].keyframe);
|
||||
// codecPrivate is still captured.
|
||||
assert!(parser.codec_private().is_some());
|
||||
}
|
||||
|
||||
// --- parameter-set-only PES (seq header, no picture) emits no frame ---
|
||||
|
||||
#[test]
|
||||
fn sequence_header_only_pes_emits_no_frame() {
|
||||
let mut parser = Mpeg2Parser::new();
|
||||
|
||||
// A PES carrying only a sequence header (+ extension), no picture.
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&make_seq_header(1920, 1080, 3, 4));
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SEQ_EXT_CODE]);
|
||||
data.extend_from_slice(&[0x14, 0x8A, 0x00, 0x01, 0x00, 0x00]);
|
||||
|
||||
let pes = make_pes(data, Some(0));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
// No coded picture → no frame emitted, but the sequence header is
|
||||
// still captured for codecPrivate.
|
||||
assert!(
|
||||
frames.is_empty(),
|
||||
"parameter-set-only PES should not emit a frame"
|
||||
);
|
||||
assert!(
|
||||
parser.codec_private().is_some(),
|
||||
"sequence header should still be captured into codec_private"
|
||||
);
|
||||
|
||||
// A following picture-bearing PES emits the real keyframe.
|
||||
let mut data2 = Vec::new();
|
||||
data2.extend_from_slice(&make_picture_header(PICTURE_TYPE_I));
|
||||
data2.extend_from_slice(&[0xFF; 16]);
|
||||
let frames2 = parser.parse(&make_pes(data2, Some(3600)));
|
||||
assert_eq!(frames2.len(), 1);
|
||||
assert!(frames2[0].keyframe);
|
||||
}
|
||||
|
||||
// --- PTS conversion ---
|
||||
|
||||
+39
-1
@@ -18,6 +18,13 @@
|
||||
use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
|
||||
const SEGMENT_PCS: u8 = 0x16;
|
||||
// Upper bound on a pending display set's accumulated bytes. Real PGS
|
||||
// display sets are small (a 1080p RLE bitmap plus palette is well under
|
||||
// 1 MB); a stream that keeps appending non-PCS segments without ever
|
||||
// emitting a PCS is malformed. Cap accumulation to bound memory and
|
||||
// drop further appends until the next PCS resyncs the parser. Mirrors
|
||||
// the MAX_*_BYTES / MAX_*_BUF caps in the DTS and AC-3 parsers.
|
||||
const MAX_PGS_PENDING_BYTES: usize = 4 * 1024 * 1024;
|
||||
// Offset within the PES payload at which number_of_composition_objects
|
||||
// lives in a PCS: 3-byte segment header + 10 bytes of PCS fields
|
||||
// (video_w/h, frame_rate, comp_num, comp_state, palette_update,
|
||||
@@ -90,7 +97,12 @@ impl CodecParser for PgsParser {
|
||||
// a pending display, append; otherwise emit as-is.
|
||||
None => {
|
||||
if let Some((_, ref mut buf)) = self.pending {
|
||||
buf.extend_from_slice(&pes.data);
|
||||
// Bound accumulation: a well-formed display set is small.
|
||||
// Past the cap, drop further appends (malformed stream);
|
||||
// the next PCS will take/replace `pending` and resync.
|
||||
if buf.len() + pes.data.len() <= MAX_PGS_PENDING_BYTES {
|
||||
buf.extend_from_slice(&pes.data);
|
||||
}
|
||||
} else {
|
||||
out.push(Frame {
|
||||
pts_ns,
|
||||
@@ -180,6 +192,32 @@ mod tests {
|
||||
assert!(data.windows(5).any(|w| w == [0x15, 0x00, 0x02, 0xAA, 0xBB]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_buffer_is_capped() {
|
||||
let mut parser = PgsParser::new();
|
||||
// Open a display set.
|
||||
let _ = parser.parse(&make_pes(pcs_bytes(1), Some(90000)));
|
||||
|
||||
// Flood with non-PCS segments far exceeding the cap.
|
||||
let chunk = vec![0x15u8; 256 * 1024]; // 256 KB ODS-like segment
|
||||
let floods = (MAX_PGS_PENDING_BYTES / chunk.len()) + 32;
|
||||
for _ in 0..floods {
|
||||
let frames = parser.parse(&make_pes(chunk.clone(), Some(90000)));
|
||||
assert!(frames.is_empty(), "non-PCS appends should not emit");
|
||||
}
|
||||
|
||||
// The pending buffer must not have grown without bound.
|
||||
let pending_len = parser.pending.as_ref().map(|(_, b)| b.len()).unwrap_or(0);
|
||||
assert!(
|
||||
pending_len <= MAX_PGS_PENDING_BYTES,
|
||||
"pending buffer {pending_len} exceeded cap {MAX_PGS_PENDING_BYTES}"
|
||||
);
|
||||
|
||||
// A following PCS still resyncs and emits the (capped) pending set.
|
||||
let frames = parser.parse(&make_pes(pcs_bytes(0), Some(180000)));
|
||||
assert_eq!(frames.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_private_none() {
|
||||
let parser = PgsParser::new();
|
||||
|
||||
@@ -16,6 +16,12 @@ use super::{CodecParser, Frame, PesPacket, pts_to_ns};
|
||||
/// Duration of one TrueHD access unit in nanoseconds (1/1200 second).
|
||||
const AU_DURATION_NS: i64 = 833_333;
|
||||
|
||||
/// Hard cap on the reassembly buffer. A valid TrueHD/MAT access unit is
|
||||
/// well under 32 KiB; if the buffer grows far past that without yielding a
|
||||
/// frame the stream is malformed, so we drop it and resync rather than grow
|
||||
/// without bound. Parity with the AC-3 / DTS / PGS caps.
|
||||
const MAX_TRUEHD_BUF: usize = 256 * 1024;
|
||||
|
||||
pub struct TrueHdParser {
|
||||
buf: Vec<u8>,
|
||||
next_pts_ns: i64,
|
||||
@@ -145,6 +151,12 @@ impl CodecParser for TrueHdParser {
|
||||
self.next_pts_ns += AU_DURATION_NS;
|
||||
}
|
||||
|
||||
// Bound memory on malformed input: a stream that never yields a
|
||||
// complete frame must not grow the buffer without limit.
|
||||
if self.buf.len() > MAX_TRUEHD_BUF {
|
||||
self.buf.clear();
|
||||
}
|
||||
|
||||
frames
|
||||
}
|
||||
|
||||
@@ -153,6 +165,52 @@ impl CodecParser for TrueHdParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-bit channel counts for the TrueHD 8-channel and 6-channel presentation
|
||||
/// channel-assignment masks (per the MLP spec / FFmpeg `thd_channels`). Some
|
||||
/// bits denote a stereo pair (2), others a single channel (1).
|
||||
const THD_8CH: [u8; 13] = [2, 1, 1, 2, 2, 2, 2, 1, 1, 2, 2, 1, 1];
|
||||
const THD_6CH: [u8; 5] = [2, 1, 1, 2, 1];
|
||||
|
||||
/// Decode the true channel count from a TrueHD major-sync `format_info` word
|
||||
/// (the 32 bits immediately after the 0xF8726FBA sync). Returns the richest
|
||||
/// presentation's channel count — the 8-channel (e.g. 7.1) presentation when
|
||||
/// present, else the 6-channel (5.1) one. This is the real layout that the MPLS
|
||||
/// `audio_format` base field (often 5.1 even on a 7.1/Atmos track) understates.
|
||||
pub fn truehd_channels(format_info: u32) -> Option<u8> {
|
||||
let ch8 = (format_info & 0x1FFF) as u16; // 8ch_presentation_channel_assignment (13 bits)
|
||||
let ch6 = ((format_info >> 15) & 0x1F) as u16; // 6ch_presentation_channel_assignment (5 bits)
|
||||
let count = |mask: u16, tbl: &[u8]| -> u8 {
|
||||
tbl.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| mask & (1 << i) != 0)
|
||||
.map(|(_, &c)| c)
|
||||
.sum()
|
||||
};
|
||||
if ch8 != 0 {
|
||||
Some(count(ch8, &THD_8CH))
|
||||
} else if ch6 != 0 {
|
||||
Some(count(ch6, &THD_6CH))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan a demuxed TrueHD elementary-stream chunk for the first major sync and
|
||||
/// decode its true channel count. The stream may interleave AC-3; we scan for
|
||||
/// the major-sync word anywhere and read the following `format_info`.
|
||||
pub fn truehd_channels_from_stream(data: &[u8]) -> Option<u8> {
|
||||
let mut p = 0;
|
||||
while p + 8 <= data.len() {
|
||||
let w = u32::from_be_bytes([data[p], data[p + 1], data[p + 2], data[p + 3]]);
|
||||
if (w & 0xFFFF_FFFE) == 0xF872_6FBA {
|
||||
let fi = u32::from_be_bytes([data[p + 4], data[p + 5], data[p + 6], data[p + 7]]);
|
||||
return truehd_channels(fi);
|
||||
}
|
||||
p += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -247,4 +305,27 @@ mod tests {
|
||||
let parser = TrueHdParser::new();
|
||||
assert!(parser.codec_private().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truehd_channels_71_from_8ch_presentation() {
|
||||
// 8ch presentation assignment bits 0-4 (LR,C,LFE,LsRs,back-LR) = 2+1+1+2+2 = 8.
|
||||
let format_info = 0x1F; // low 13 bits = 0x1F
|
||||
assert_eq!(truehd_channels(format_info), Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truehd_channels_51_from_6ch_presentation() {
|
||||
// No 8ch presentation; 6ch bits 0-3 (LR,C,LFE,LsRs) = 2+1+1+2 = 6.
|
||||
let format_info = 0xF << 15; // 6ch field = 0xF, 8ch field = 0
|
||||
assert_eq!(truehd_channels(format_info), Some(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truehd_channels_scan_finds_major_sync() {
|
||||
// [junk][major sync 0xF8726FBA][format_info: 8ch=0x1F -> 7.1]
|
||||
let mut data = vec![0xAA, 0xBB];
|
||||
data.extend_from_slice(&0xF872_6FBAu32.to_be_bytes());
|
||||
data.extend_from_slice(&0x0000_001Fu32.to_be_bytes());
|
||||
assert_eq!(truehd_channels_from_stream(&data), Some(8));
|
||||
}
|
||||
}
|
||||
|
||||
+64
-10
@@ -41,9 +41,13 @@ impl CodecParser for Vc1Parser {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Use DTS when available (monotonic for B-frame content), fall back to PTS
|
||||
let ts_ns = pes.dts.or(pes.pts).map(pts_to_ns).unwrap_or(0);
|
||||
// MKV block timecodes are PRESENTATION timestamps; frames are stored in
|
||||
// decode order and the player reorders by timecode. Use PTS, not DTS —
|
||||
// DTS presents B-frames in decode order (visible judder) and breaks
|
||||
// PTS-based seeking. Fall back to DTS only if PTS is absent.
|
||||
let ts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
|
||||
let mut has_seq_header = false;
|
||||
let mut has_entry_point = false;
|
||||
let mut frame_start: Option<usize> = None;
|
||||
|
||||
// Scan for start codes (00 00 01 XX)
|
||||
@@ -67,6 +71,7 @@ impl CodecParser for Vc1Parser {
|
||||
SC_ENTRY_POINT => {
|
||||
let end = find_next_sc(data, i + 4).unwrap_or(data.len());
|
||||
self.entry_point = Some(data[i..end].to_vec());
|
||||
has_entry_point = true;
|
||||
}
|
||||
SC_FRAME => {
|
||||
// Frame data starts at this start code
|
||||
@@ -85,11 +90,23 @@ impl CodecParser for Vc1Parser {
|
||||
// Keyframe = this PES contains a sequence header (I-frame indicator in BD)
|
||||
let keyframe = has_seq_header;
|
||||
|
||||
// Strip sequence header + entry point from frame data — those are in codecPrivate.
|
||||
// Only include data from the frame start code onwards.
|
||||
// Strip sequence header + entry point from frame data — those are in
|
||||
// codecPrivate, not coded-picture data. Only include data from the
|
||||
// frame start code onwards.
|
||||
let frame_data = match frame_start {
|
||||
Some(start) => &data[start..],
|
||||
None => data, // no frame start code found, pass through entire PES
|
||||
None => {
|
||||
// No frame start code. If this PES carried only parameter sets
|
||||
// (sequence header / entry point, captured above into
|
||||
// codecPrivate), there is no coded picture to emit — drop it
|
||||
// rather than passing parameter bytes through as a bogus
|
||||
// keyframe. Mirrors how the H.264/HEVC parsers skip
|
||||
// parameter-set-only access units.
|
||||
if has_seq_header || has_entry_point {
|
||||
return Vec::new();
|
||||
}
|
||||
data // genuine picture payload with no leading 0x0D — pass through
|
||||
}
|
||||
};
|
||||
|
||||
vec![Frame {
|
||||
@@ -343,6 +360,42 @@ mod tests {
|
||||
assert_eq!(&frames[0].data[0..4], &[0x00, 0x00, 0x01, SC_FRAME]);
|
||||
}
|
||||
|
||||
// --- parameter-set-only PES (seq header + entry point, no frame SC) ---
|
||||
|
||||
#[test]
|
||||
fn param_set_only_pes_emits_no_frame() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
// Sequence header + entry point, but NO frame start code (0x0D).
|
||||
let mut data = Vec::new();
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]);
|
||||
data.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
|
||||
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_ENTRY_POINT]);
|
||||
data.extend_from_slice(&[0x11, 0x22, 0x33, 0x44]);
|
||||
|
||||
let pes = make_pes(data, Some(90000));
|
||||
let frames = parser.parse(&pes);
|
||||
|
||||
// No coded picture → no frame emitted (parameter bytes must not be
|
||||
// passed through as a bogus keyframe).
|
||||
assert!(
|
||||
frames.is_empty(),
|
||||
"parameter-set-only PES should not emit a frame"
|
||||
);
|
||||
// But codecPrivate is still captured.
|
||||
assert!(parser.seq_header.is_some());
|
||||
assert!(parser.entry_point.is_some());
|
||||
assert!(parser.codec_private().is_some());
|
||||
|
||||
// A following frame-bearing PES still emits its picture.
|
||||
let mut data2 = Vec::new();
|
||||
data2.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
|
||||
data2.extend_from_slice(&[0x55, 0x66, 0x77]);
|
||||
let frames2 = parser.parse(&make_pes(data2, Some(180000)));
|
||||
assert_eq!(frames2.len(), 1);
|
||||
assert_eq!(&frames2[0].data[0..4], &[0x00, 0x00, 0x01, SC_FRAME]);
|
||||
}
|
||||
|
||||
// --- empty PES ---
|
||||
|
||||
#[test]
|
||||
@@ -369,10 +422,10 @@ mod tests {
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
}
|
||||
|
||||
// --- DTS preferred over PTS ---
|
||||
// --- PTS (presentation) used for the MKV block timecode, not DTS ---
|
||||
|
||||
#[test]
|
||||
fn dts_preferred_over_pts() {
|
||||
fn pts_preferred_over_dts() {
|
||||
let mut parser = Vc1Parser::new();
|
||||
|
||||
let mut data = Vec::new();
|
||||
@@ -381,13 +434,14 @@ mod tests {
|
||||
|
||||
let pes = PesPacket {
|
||||
pid: 0x1011,
|
||||
pts: Some(180000),
|
||||
dts: Some(90000),
|
||||
pts: Some(180000), // presentation
|
||||
dts: Some(90000), // decode
|
||||
data,
|
||||
};
|
||||
let frames = parser.parse(&pes);
|
||||
assert_eq!(frames.len(), 1);
|
||||
assert_eq!(frames[0].pts_ns, 1_000_000_000);
|
||||
// PTS must be used — MKV block timecodes are presentation timestamps.
|
||||
assert_eq!(frames[0].pts_ns, 2_000_000_000);
|
||||
}
|
||||
|
||||
// --- find_next_sc utility ---
|
||||
|
||||
+15
-1
@@ -195,7 +195,8 @@ impl DiscStream {
|
||||
};
|
||||
pids.push(pid);
|
||||
pid_to_track.push((pid, idx));
|
||||
parsers.push((pid, super::codec::parser_for_codec(codec, None)));
|
||||
let is_dvd_ps = matches!(content_format, crate::disc::ContentFormat::MpegPs);
|
||||
parsers.push((pid, super::codec::parser_for_codec(codec, None, is_dvd_ps)));
|
||||
}
|
||||
|
||||
let mut ts_demuxer = None;
|
||||
@@ -541,6 +542,19 @@ impl crate::pes::Stream for DiscStream {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Drain any access unit a codec parser buffered past the last
|
||||
// PES (DTS-HD's final core+extension unit, assembled across
|
||||
// PES boundaries).
|
||||
let pid_to_track = &self.pid_to_track;
|
||||
let pending = &mut self.pending_frames;
|
||||
for (pid, parser) in self.parsers.iter_mut() {
|
||||
let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else {
|
||||
continue;
|
||||
};
|
||||
for frame in parser.flush() {
|
||||
pending.push_back(crate::pes::PesFrame::from_codec_frame(track, frame));
|
||||
}
|
||||
}
|
||||
return Ok(self.pending_frames.pop_front());
|
||||
}
|
||||
|
||||
|
||||
+50
-13
@@ -264,6 +264,13 @@ pub fn read_element_header(r: &mut impl Read) -> io::Result<(u32, u64, usize)> {
|
||||
|
||||
/// Read an unsigned integer value of `len` bytes.
|
||||
pub fn read_uint_val(r: &mut impl Read, len: usize) -> io::Result<u64> {
|
||||
// An EBML unsigned integer is at most 8 bytes. A malformed element
|
||||
// claiming `len > 8` would index past this stack buffer and panic
|
||||
// (DoS on untrusted input) — reject it at the source so every caller
|
||||
// is safe, not just the ones that pre-check.
|
||||
if len > 8 {
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
let mut buf = [0u8; 8];
|
||||
r.read_exact(&mut buf[..len])?;
|
||||
let mut val = 0u64;
|
||||
@@ -273,23 +280,33 @@ pub fn read_uint_val(r: &mut impl Read, len: usize) -> io::Result<u64> {
|
||||
Ok(val)
|
||||
}
|
||||
|
||||
/// Read a float value (4 or 8 bytes).
|
||||
/// Read a float value. EBML floats are exactly 0, 4, or 8 bytes.
|
||||
///
|
||||
/// The previous `else` branch read a fixed 8 bytes for ANY non-4 length,
|
||||
/// so a malformed element with `len > 8` left `len - 8` unconsumed bytes
|
||||
/// (mis-read as the next EBML header → desync of the rest of the parent
|
||||
/// element) and `len < 4` over-read. Consume exactly `len` bytes and
|
||||
/// reject anything that isn't a valid float width.
|
||||
pub fn read_float_val(r: &mut impl Read, len: usize) -> io::Result<f64> {
|
||||
if len == 4 {
|
||||
let mut buf = [0u8; 4];
|
||||
r.read_exact(&mut buf)?;
|
||||
Ok(f32::from_be_bytes(buf) as f64)
|
||||
} else {
|
||||
let mut buf = [0u8; 8];
|
||||
r.read_exact(&mut buf)?;
|
||||
Ok(f64::from_be_bytes(buf))
|
||||
match len {
|
||||
0 => Ok(0.0),
|
||||
4 => {
|
||||
let mut buf = [0u8; 4];
|
||||
r.read_exact(&mut buf)?;
|
||||
Ok(f32::from_be_bytes(buf) as f64)
|
||||
}
|
||||
8 => {
|
||||
let mut buf = [0u8; 8];
|
||||
r.read_exact(&mut buf)?;
|
||||
Ok(f64::from_be_bytes(buf))
|
||||
}
|
||||
_ => Err(crate::error::Error::MkvInvalid.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a UTF-8 string value of `len` bytes.
|
||||
pub fn read_string_val(r: &mut impl Read, len: usize) -> io::Result<String> {
|
||||
let mut buf = vec![0u8; len];
|
||||
r.read_exact(&mut buf)?;
|
||||
let mut buf = read_exact_bounded(r, len)?;
|
||||
// Strip trailing nulls
|
||||
while buf.last() == Some(&0) {
|
||||
buf.pop();
|
||||
@@ -299,8 +316,22 @@ pub fn read_string_val(r: &mut impl Read, len: usize) -> io::Result<String> {
|
||||
|
||||
/// Read binary data of `len` bytes.
|
||||
pub fn read_binary_val(r: &mut impl Read, len: usize) -> io::Result<Vec<u8>> {
|
||||
let mut buf = vec![0u8; len];
|
||||
r.read_exact(&mut buf)?;
|
||||
read_exact_bounded(r, len)
|
||||
}
|
||||
|
||||
/// Read exactly `len` bytes WITHOUT trusting `len` to size the allocation.
|
||||
///
|
||||
/// `vec![0u8; len]` on an attacker-controlled EBML size would allocate
|
||||
/// gigabytes before the read fails. Instead we cap the reader to `len`
|
||||
/// and grow the buffer as bytes actually arrive: a malformed element that
|
||||
/// claims a huge length but supplies few bytes allocates only what it
|
||||
/// delivers, then errors on the short read.
|
||||
fn read_exact_bounded(r: &mut impl Read, len: usize) -> io::Result<Vec<u8>> {
|
||||
let mut buf = Vec::new();
|
||||
let got = r.take(len as u64).read_to_end(&mut buf)?;
|
||||
if got != len {
|
||||
return Err(io::ErrorKind::UnexpectedEof.into());
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
@@ -362,6 +393,12 @@ pub const MATRIX_COEFFICIENTS: u32 = 0x55B1;
|
||||
pub const PRIMARIES: u32 = 0x55BB;
|
||||
pub const RANGE: u32 = 0x55B9;
|
||||
|
||||
// Dolby Vision — BlockAdditionMapping carries the DOVIDecoderConfigurationRecord
|
||||
// (dvcC) so players / mediainfo recognise the track as Dolby Vision.
|
||||
pub const BLOCK_ADDITION_MAPPING: u32 = 0x41E4;
|
||||
pub const BLOCK_ADD_ID_TYPE: u32 = 0x41E7;
|
||||
pub const BLOCK_ADD_ID_EXTRA_DATA: u32 = 0x41ED;
|
||||
|
||||
// Audio
|
||||
pub const AUDIO: u32 = 0xE1;
|
||||
pub const SAMPLING_FREQUENCY: u32 = 0xB5;
|
||||
|
||||
+199
-10
@@ -34,6 +34,24 @@ pub struct MkvTrack {
|
||||
pub sample_rate: f64,
|
||||
pub channels: u8,
|
||||
pub bit_depth: u8,
|
||||
// Dolby Vision: the dvcC (DOVIDecoderConfigurationRecord) for the DV layer,
|
||||
// emitted as a BlockAdditionMapping. `None` for non-DV tracks.
|
||||
pub dv_config: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Build a DOVIDecoderConfigurationRecord (dvcC) — 24 bytes — for the Matroska
|
||||
/// BlockAdditionMapping. For disc Profile 7 dual-layer the base, enhancement,
|
||||
/// and RPU are all present (lossless FEL/MEL preserved as a second track).
|
||||
pub fn dolby_vision_config(profile: u8, level: u8, bl_compat_id: u8) -> Vec<u8> {
|
||||
let mut v = vec![0u8; 24];
|
||||
v[0] = 1; // dv_version_major
|
||||
v[1] = 0; // dv_version_minor
|
||||
// profile(7) | level(6) | rpu_present(1) | el_present(1) | bl_present(1)
|
||||
v[2] = ((profile & 0x7F) << 1) | ((level >> 5) & 0x01);
|
||||
v[3] = ((level & 0x1F) << 3) | (1 << 2) | (1 << 1) | 1; // rpu = el = bl = 1
|
||||
v[4] = (bl_compat_id & 0x0F) << 4;
|
||||
// v[5..24] reserved = 0
|
||||
v
|
||||
}
|
||||
|
||||
impl MkvTrack {
|
||||
@@ -83,23 +101,33 @@ impl MkvTrack {
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
bit_depth: 0,
|
||||
// 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))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn audio(a: &AudioStream) -> Self {
|
||||
// Codec ID strings must distinguish the DTS family — strict
|
||||
// players (Plex transcoder, some hardware decoders, some AV
|
||||
// receivers) reject lossless DTS-HD MA payload when the
|
||||
// track advertises plain `A_DTS` because it implies the
|
||||
// bitstream is the 1.5 Mbps "core" only. Fix is just to
|
||||
// emit the right ID per BD-STN codec field.
|
||||
// The Matroska codec-ID registry defines `A_DTS` for the entire
|
||||
// DTS family — the spec text for `A_DTS` explicitly states it
|
||||
// "Supports DTS, DTS-ES, DTS-96/26, DTS-HD High Resolution Audio
|
||||
// and DTS-HD Master Audio." Players distinguish core vs HD-HRA vs
|
||||
// HD-MA by parsing the DTS bitstream extension substreams, not by
|
||||
// the container codec ID. The previously-emitted `A_DTS/MA` and
|
||||
// `A_DTS/HR` suffixes are NOT registered codec IDs; strict parsers
|
||||
// (libmatroska) and some hardware renderers fail to recognise the
|
||||
// track at all. Emit plain `A_DTS` for every DTS variant — the
|
||||
// lossless MA / HRA payload bytes are unchanged, only the
|
||||
// container codec-ID string differs.
|
||||
let codec_id = match a.codec {
|
||||
Codec::Ac3 => "A_AC3",
|
||||
Codec::Ac3Plus => "A_EAC3",
|
||||
Codec::TrueHd => "A_TRUEHD",
|
||||
Codec::DtsHdMa => "A_DTS/MA",
|
||||
Codec::DtsHdHr => "A_DTS/HR",
|
||||
Codec::Dts => "A_DTS",
|
||||
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => "A_DTS",
|
||||
Codec::Lpcm => "A_PCM/INT/BIG",
|
||||
_ => "A_AC3",
|
||||
};
|
||||
@@ -128,6 +156,7 @@ impl MkvTrack {
|
||||
sample_rate: sr,
|
||||
channels: ch,
|
||||
bit_depth: 0,
|
||||
dv_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +185,7 @@ impl MkvTrack {
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
bit_depth: 0,
|
||||
dv_config: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,6 +223,12 @@ pub struct MkvMuxer<W: Write + Seek> {
|
||||
/// New cluster every 5 seconds.
|
||||
const CLUSTER_DURATION_MS: i64 = 5000;
|
||||
|
||||
/// Maximum block-relative timestamp expressible in the signed 16-bit
|
||||
/// SimpleBlock/Block field (`i16::MAX` ms). A frame further than this from
|
||||
/// the open cluster's timestamp forces a new cluster (see `write_frame`) so
|
||||
/// the `as i16` cast can never wrap.
|
||||
const MAX_BLOCK_REL_MS: i64 = i16::MAX as i64;
|
||||
|
||||
impl<W: Write + Seek> MkvMuxer<W> {
|
||||
/// Create a new MKV muxer: writes EBML header, Segment start, Info, Tracks, Chapters.
|
||||
pub fn new(
|
||||
@@ -333,6 +369,17 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
ebml::end_master(&mut writer, vid_pos)?;
|
||||
}
|
||||
|
||||
// Dolby Vision signaling — BlockAdditionMapping is a child of the
|
||||
// TrackEntry (sibling of Video). Carries the dvcC so players /
|
||||
// mediainfo recognise the track as Dolby Vision.
|
||||
if let Some(ref dvcc) = track.dv_config {
|
||||
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
|
||||
// BlockAddIDType = "dvcC" fourcc (DOVIDecoderConfigurationRecord).
|
||||
ebml::write_uint(&mut writer, ebml::BLOCK_ADD_ID_TYPE, 0x6476_6343)?;
|
||||
ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, dvcc)?;
|
||||
ebml::end_master(&mut writer, map_pos)?;
|
||||
}
|
||||
|
||||
// Audio-specific
|
||||
if track.track_type == ebml::TRACK_TYPE_AUDIO && track.sample_rate > 0.0 {
|
||||
let aud_pos = ebml::start_master(&mut writer, ebml::AUDIO)?;
|
||||
@@ -406,7 +453,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
let base = *self.base_pts_ms.get_or_insert(raw_ms);
|
||||
let pts_ms = raw_ms - base;
|
||||
|
||||
// Cluster boundaries must coincide with a video keyframe so every
|
||||
// Cluster boundaries normally coincide with a video keyframe so every
|
||||
// Cues entry resolves to a seekable IDR at the cluster start.
|
||||
let is_video_key = keyframe && track_idx == 0;
|
||||
let needs_new_cluster = !self.cluster_open
|
||||
@@ -422,6 +469,17 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
track: track_idx + 1,
|
||||
cluster_pos: self.cluster_pos - self.segment_start,
|
||||
});
|
||||
} else if (pts_ms - self.cluster_ts_ms) > MAX_BLOCK_REL_MS {
|
||||
// The block-relative timestamp is a signed 16-bit value, so a
|
||||
// frame more than i16::MAX ms (~32.767 s) past the current
|
||||
// cluster's timestamp would silently wrap on the `as i16` cast,
|
||||
// corrupting A/V sync. The keyframe-driven boundary above only
|
||||
// fires on a video keyframe — a long audio-only stretch, or a
|
||||
// very long GOP with no intervening keyframe, can drift past the
|
||||
// i16 range. Force a fresh cluster here even without a keyframe
|
||||
// to keep the cast in range. This cluster is not keyframe-aligned
|
||||
// so it gets no Cues entry (Cues stay IDR-only for seekability).
|
||||
self.start_cluster(pts_ms)?;
|
||||
}
|
||||
|
||||
let relative_ts = (pts_ms - self.cluster_ts_ms) as i16;
|
||||
@@ -612,6 +670,7 @@ mod tests {
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
bit_depth: 0,
|
||||
dv_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,9 +695,60 @@ mod tests {
|
||||
sample_rate: 48000.0,
|
||||
channels: 6,
|
||||
bit_depth: 0,
|
||||
dv_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn audio_stream(codec: Codec) -> AudioStream {
|
||||
use crate::disc::{AudioChannels, LabelPurpose, SampleRate};
|
||||
AudioStream {
|
||||
pid: 0x1100,
|
||||
codec,
|
||||
channels: AudioChannels::Surround51,
|
||||
language: "eng".into(),
|
||||
sample_rate: SampleRate::S48,
|
||||
secondary: false,
|
||||
purpose: LabelPurpose::Normal,
|
||||
label: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dts_variants_map_to_registered_a_dts_codec_id() {
|
||||
// The Matroska codec-ID registry defines `A_DTS` for the whole DTS
|
||||
// family (core, DTS-HD HRA, DTS-HD MA). The `/MA` and `/HR` suffixes
|
||||
// are not registered and break strict parsers, so every DTS variant
|
||||
// must emit plain `A_DTS`.
|
||||
for codec in [Codec::Dts, Codec::DtsHdMa, Codec::DtsHdHr] {
|
||||
let track = MkvTrack::audio(&audio_stream(codec));
|
||||
assert_eq!(
|
||||
track.codec_id, "A_DTS",
|
||||
"{codec:?} must map to registered codec ID A_DTS, got {}",
|
||||
track.codec_id
|
||||
);
|
||||
}
|
||||
// Sanity: the non-DTS variants keep their distinct IDs.
|
||||
assert_eq!(MkvTrack::audio(&audio_stream(Codec::Ac3)).codec_id, "A_AC3");
|
||||
assert_eq!(
|
||||
MkvTrack::audio(&audio_stream(Codec::TrueHd)).codec_id,
|
||||
"A_TRUEHD"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dolby_vision_config_profile7() {
|
||||
// dvcC for disc Profile 7 dual-layer: version 1.0, profile 7, all of
|
||||
// bl/el/rpu present. 24 bytes.
|
||||
let c = dolby_vision_config(7, 6, 0);
|
||||
assert_eq!(c.len(), 24);
|
||||
assert_eq!(c[0], 1); // dv_version_major
|
||||
assert_eq!(c[1], 0); // dv_version_minor
|
||||
// profile in the top 7 bits of byte 2
|
||||
assert_eq!(c[2] >> 1, 7, "dv_profile must be 7");
|
||||
// rpu/el/bl present flags in byte 3 (low 3 bits after level)
|
||||
assert_eq!(c[3] & 0b0000_0111, 0b0000_0111, "rpu+el+bl all present");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mkv_writes_ebml_header() {
|
||||
let buf = Cursor::new(Vec::new());
|
||||
@@ -1323,6 +1433,85 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Collect every (cluster_ts_ms, block_relative_ts_i16, absolute_ms) for
|
||||
/// all SimpleBlocks across all clusters, so a test can assert that the
|
||||
/// reconstructed absolute timestamp (cluster_ts + relative_ts) is correct
|
||||
/// and that no relative_ts ever wrapped the i16 range.
|
||||
fn all_block_timestamps(data: &[u8]) -> Vec<(i64, i16, i64)> {
|
||||
let mut out = Vec::new();
|
||||
for (body_start, body_size, cluster_ts) in find_clusters(data) {
|
||||
let body = &data[body_start..body_start + body_size as usize];
|
||||
let mut cursor = Cursor::new(body);
|
||||
// Skip CLUSTER_TIMESTAMP.
|
||||
let (tid, tsize, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||
assert_eq!(tid, ebml::CLUSTER_TIMESTAMP);
|
||||
cursor.seek(io::SeekFrom::Current(tsize as i64)).unwrap();
|
||||
while (cursor.position() as usize) < body.len() {
|
||||
let (id, sz, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||||
if id == ebml::SIMPLE_BLOCK {
|
||||
let bstart = cursor.position() as usize;
|
||||
let b0 = body[bstart];
|
||||
let vint_len = if b0 & 0x80 != 0 { 1 } else { 2 };
|
||||
let ts_pos = bstart + vint_len;
|
||||
let rel = i16::from_be_bytes([body[ts_pos], body[ts_pos + 1]]);
|
||||
out.push((cluster_ts as i64, rel, cluster_ts as i64 + rel as i64));
|
||||
}
|
||||
cursor.seek(io::SeekFrom::Current(sz as i64)).unwrap();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_audio_gap_forces_cluster_no_i16_overflow() {
|
||||
// Regression for the `(pts_ms - cluster_ts_ms) as i16` truncation:
|
||||
// a single video keyframe at t=0 opens one cluster, then a long
|
||||
// audio-only stretch (no further video keyframe) drifts well past
|
||||
// i16::MAX ms (~32.767 s). Without the overflow guard the audio
|
||||
// blocks past 32.767 s would write a wrapped (negative) relative
|
||||
// timestamp into the SimpleBlock. With the guard a fresh cluster is
|
||||
// forced so every relative_ts stays in range and reconstructs to the
|
||||
// true absolute timestamp.
|
||||
let tracks = [make_video_track(), make_audio_track()];
|
||||
let mut frames: Vec<(usize, i64, bool, Vec<u8>)> = Vec::new();
|
||||
// One video keyframe at t=0 (opens the first cluster).
|
||||
frames.push((0, 0, true, vec![0xAB; 16]));
|
||||
// Audio frames every 100 ms out to 60 s — past the 32.767 s i16 limit
|
||||
// and past two i16 spans, with NO further video keyframe.
|
||||
let mut t_ms = 0i64;
|
||||
while t_ms <= 60_000 {
|
||||
frames.push((1, t_ms * 1_000_000, true, vec![0xCD; 16]));
|
||||
t_ms += 100;
|
||||
}
|
||||
|
||||
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
|
||||
|
||||
let blocks = all_block_timestamps(&data);
|
||||
assert!(!blocks.is_empty());
|
||||
// Every block's relative timestamp must be within i16 range (it is by
|
||||
// type), AND must reconstruct to a non-negative, monotonic-ish
|
||||
// absolute timestamp matching the source — i.e. no silent wrap.
|
||||
for (cluster_ts, rel, abs) in &blocks {
|
||||
assert!(
|
||||
*rel as i64 >= 0 && (*rel as i64) <= MAX_BLOCK_REL_MS,
|
||||
"block relative_ts {rel} out of [0, i16::MAX] range \
|
||||
(cluster_ts={cluster_ts}, abs={abs}) — i16 overflow"
|
||||
);
|
||||
}
|
||||
// The latest audio frame is at 60_000 ms; its reconstructed absolute
|
||||
// timestamp must equal that, proving no truncation occurred.
|
||||
let max_abs = blocks.iter().map(|(_, _, abs)| *abs).max().unwrap();
|
||||
assert_eq!(max_abs, 60_000, "last block must reconstruct to 60_000 ms");
|
||||
// The overflow guard must have opened more than one cluster (the
|
||||
// single keyframe alone would otherwise yield exactly one).
|
||||
let clusters = find_clusters(&data);
|
||||
assert!(
|
||||
clusters.len() >= 2,
|
||||
"expected the i16 guard to force extra clusters, got {}",
|
||||
clusters.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_first_keyframe_frames_dropped() {
|
||||
let tracks = [make_video_track()];
|
||||
|
||||
+203
-16
@@ -14,6 +14,49 @@ fn skip_bytes(r: &mut impl Read, n: u64) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Sanity caps for untrusted EBML element sizes ──────────────
|
||||
//
|
||||
// Sizes come straight from the EBML stream (file or network) and are
|
||||
// otherwise cast to `usize` and used to allocate/read. An adversarial
|
||||
// or corrupt container can claim a multi-GB element and trigger an OOM
|
||||
// allocation, or claim an integer element wider than 8 bytes and panic
|
||||
// the fixed 8-byte reader. Every untrusted size is validated against
|
||||
// one of these caps before allocation.
|
||||
|
||||
/// Largest accepted SIMPLE_BLOCK payload. A block is a small vint track
|
||||
/// header + 2-byte rel-ts + 1-byte flags + one frame of elementary data.
|
||||
/// UHD HEVC keyframes run a few MB; 64 MiB is generously above any real
|
||||
/// single-frame block while still bounding a hostile allocation.
|
||||
const MAX_BLOCK_SIZE: u64 = 64 * 1024 * 1024;
|
||||
/// Largest accepted CODEC_PRIVATE payload. hvcC/avcC/setup blobs are a
|
||||
/// few KB in practice; 16 MiB is far above any legitimate value.
|
||||
const MAX_CODEC_PRIVATE: u64 = 16 * 1024 * 1024;
|
||||
/// Largest accepted string element (TITLE/CODEC_ID/LANGUAGE/TRACK_NAME).
|
||||
const MAX_STRING_LEN: u64 = 64 * 1024;
|
||||
/// EBML unsigned-int elements are at most 8 bytes wide.
|
||||
const MAX_UINT_LEN: u64 = 8;
|
||||
|
||||
/// Reject an untrusted element size that exceeds `cap` before it is used
|
||||
/// to allocate or read. Returns the size as `usize` when within bounds.
|
||||
fn checked_size(size: u64, cap: u64) -> io::Result<usize> {
|
||||
if size > cap {
|
||||
return Err(crate::error::Error::MkvInvalid.into());
|
||||
}
|
||||
Ok(size as usize)
|
||||
}
|
||||
|
||||
/// Read a bounded unsigned int. Guards against `size > 8` (which would
|
||||
/// otherwise index out of the fixed 8-byte buffer in `read_uint_val`)
|
||||
/// before delegating.
|
||||
fn read_uint_bounded(r: &mut impl Read, size: u64) -> io::Result<u64> {
|
||||
ebml::read_uint_val(r, checked_size(size, MAX_UINT_LEN)?)
|
||||
}
|
||||
|
||||
/// Read a bounded UTF-8 string element.
|
||||
fn read_string_bounded(r: &mut impl Read, size: u64) -> io::Result<String> {
|
||||
ebml::read_string_val(r, checked_size(size, MAX_STRING_LEN)?)
|
||||
}
|
||||
|
||||
use crate::disc::*;
|
||||
use std::io::{self, Read};
|
||||
|
||||
@@ -108,11 +151,12 @@ impl crate::pes::Stream for MkvStream {
|
||||
match id {
|
||||
ebml::CLUSTER => continue,
|
||||
ebml::CLUSTER_TIMESTAMP => {
|
||||
rs.cluster_ts_ms = ebml::read_uint_val(&mut rs.reader, size as usize)? as i64;
|
||||
rs.cluster_ts_ms = read_uint_bounded(&mut rs.reader, size)? as i64;
|
||||
continue;
|
||||
}
|
||||
ebml::SIMPLE_BLOCK => {
|
||||
let block = ebml::read_binary_val(&mut rs.reader, size as usize)?;
|
||||
let block =
|
||||
ebml::read_binary_val(&mut rs.reader, checked_size(size, MAX_BLOCK_SIZE)?)?;
|
||||
if block.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
@@ -198,7 +242,10 @@ impl crate::pes::Stream for MkvStream {
|
||||
/// Returns (DiscTitle, codec_privates: Vec<(track_number, codec_private_bytes)>)
|
||||
fn parse_mkv_header(r: &mut impl Read) -> MkvHeaderResult {
|
||||
let mut title = String::new();
|
||||
let mut duration_ms = 0.0f64;
|
||||
// EBML `DURATION` is a float expressed in TimestampScale ticks, not
|
||||
// milliseconds (Matroska spec). Named accordingly; converted to
|
||||
// seconds below as ticks * ts_scale_ns / 1e9.
|
||||
let mut duration_ticks = 0.0f64;
|
||||
let mut ts_scale: u64 = 1_000_000;
|
||||
let mut streams: Vec<crate::disc::Stream> = Vec::new();
|
||||
let mut codec_privates: Vec<(u16, Vec<u8>)> = Vec::new();
|
||||
@@ -235,9 +282,9 @@ fn parse_mkv_header(r: &mut impl Read) -> MkvHeaderResult {
|
||||
let (cid, cs, hlen) = ebml::read_element_header(r)?;
|
||||
remaining = remaining.saturating_sub(hlen as u64 + cs);
|
||||
match cid {
|
||||
ebml::TIMESTAMP_SCALE => ts_scale = ebml::read_uint_val(r, cs as usize)?,
|
||||
ebml::DURATION => duration_ms = ebml::read_float_val(r, cs as usize)?,
|
||||
ebml::TITLE => title = ebml::read_string_val(r, cs as usize)?,
|
||||
ebml::TIMESTAMP_SCALE => ts_scale = read_uint_bounded(r, cs)?,
|
||||
ebml::DURATION => duration_ticks = ebml::read_float_val(r, cs as usize)?,
|
||||
ebml::TITLE => title = read_string_bounded(r, cs)?,
|
||||
_ => {
|
||||
skip_bytes(r, cs)?;
|
||||
}
|
||||
@@ -274,7 +321,7 @@ fn parse_mkv_header(r: &mut impl Read) -> MkvHeaderResult {
|
||||
|
||||
let disc_title = DiscTitle {
|
||||
playlist: title,
|
||||
duration_secs: duration_ms * (ts_scale as f64) / 1_000_000_000.0,
|
||||
duration_secs: duration_ticks * (ts_scale as f64) / 1_000_000_000.0,
|
||||
streams,
|
||||
..DiscTitle::empty()
|
||||
};
|
||||
@@ -296,20 +343,25 @@ fn parse_track(
|
||||
let (cid, cs, hlen) = ebml::read_element_header(r)?;
|
||||
remaining = remaining.saturating_sub(hlen as u64 + cs);
|
||||
match cid {
|
||||
ebml::TRACK_NUMBER => tnum = ebml::read_uint_val(r, cs as usize)? as u16,
|
||||
ebml::TRACK_TYPE => ttype = ebml::read_uint_val(r, cs as usize)?,
|
||||
ebml::CODEC_ID => codec_id = ebml::read_string_val(r, cs as usize)?,
|
||||
ebml::CODEC_PRIVATE => codec_priv = Some(ebml::read_binary_val(r, cs as usize)?),
|
||||
ebml::LANGUAGE => lang = ebml::read_string_val(r, cs as usize)?,
|
||||
ebml::TRACK_NAME => name = ebml::read_string_val(r, cs as usize)?,
|
||||
ebml::FLAG_FORCED => forced = ebml::read_uint_val(r, cs as usize)? != 0,
|
||||
ebml::TRACK_NUMBER => tnum = read_uint_bounded(r, cs)? as u16,
|
||||
ebml::TRACK_TYPE => ttype = read_uint_bounded(r, cs)?,
|
||||
ebml::CODEC_ID => codec_id = read_string_bounded(r, cs)?,
|
||||
ebml::CODEC_PRIVATE => {
|
||||
codec_priv = Some(ebml::read_binary_val(
|
||||
r,
|
||||
checked_size(cs, MAX_CODEC_PRIVATE)?,
|
||||
)?)
|
||||
}
|
||||
ebml::LANGUAGE => lang = read_string_bounded(r, cs)?,
|
||||
ebml::TRACK_NAME => name = read_string_bounded(r, cs)?,
|
||||
ebml::FLAG_FORCED => forced = read_uint_bounded(r, cs)? != 0,
|
||||
ebml::VIDEO => {
|
||||
let mut vrem = cs;
|
||||
while vrem > 0 {
|
||||
let (vid, vs, vhlen) = ebml::read_element_header(r)?;
|
||||
vrem = vrem.saturating_sub(vhlen as u64 + vs);
|
||||
if vid == ebml::PIXEL_HEIGHT {
|
||||
ph = ebml::read_uint_val(r, vs as usize)? as u32;
|
||||
ph = read_uint_bounded(r, vs)? as u32;
|
||||
} else {
|
||||
skip_bytes(r, vs)?;
|
||||
}
|
||||
@@ -322,7 +374,7 @@ fn parse_track(
|
||||
arem = arem.saturating_sub(ahlen as u64 + as_);
|
||||
match aid {
|
||||
ebml::SAMPLING_FREQUENCY => sr = ebml::read_float_val(r, as_ as usize)?,
|
||||
ebml::CHANNELS => ch = ebml::read_uint_val(r, as_ as usize)? as u8,
|
||||
ebml::CHANNELS => ch = read_uint_bounded(r, as_)? as u8,
|
||||
_ => {
|
||||
skip_bytes(r, as_)?;
|
||||
}
|
||||
@@ -428,3 +480,138 @@ fn block_vint(d: &[u8]) -> (u64, usize) {
|
||||
}
|
||||
(0, 1) // Unsupported 5+ byte VINT — treat as track 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pes::Stream as _;
|
||||
use std::io::Cursor;
|
||||
|
||||
// `From<Error> for io::Error` encodes the numeric code into the
|
||||
// Display string as "E{code}: ...". Check the prefix.
|
||||
fn is_mkv_invalid(e: &io::Error) -> bool {
|
||||
e.kind() == io::ErrorKind::InvalidData
|
||||
&& e.to_string()
|
||||
.starts_with(&format!("E{}", crate::error::E_MKV_INVALID))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_size_rejects_over_cap() {
|
||||
// Within cap → Ok with usize value.
|
||||
assert_eq!(checked_size(100, 256).unwrap(), 100);
|
||||
assert_eq!(checked_size(256, 256).unwrap(), 256);
|
||||
// Over cap → MkvInvalid, never a giant allocation.
|
||||
let e = checked_size(257, 256).unwrap_err();
|
||||
assert!(is_mkv_invalid(&e));
|
||||
// A hostile multi-GB block size is rejected as MkvInvalid.
|
||||
let e = checked_size(4 * 1024 * 1024 * 1024, MAX_BLOCK_SIZE).unwrap_err();
|
||||
assert!(is_mkv_invalid(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_uint_bounded_rejects_oversized_int() {
|
||||
// size > 8 would index out of the fixed 8-byte buffer in
|
||||
// read_uint_val (panic / OOB). The guard turns it into a clean
|
||||
// MkvInvalid error instead.
|
||||
let mut data = Cursor::new(vec![0u8; 16]);
|
||||
let e = read_uint_bounded(&mut data, 9).unwrap_err();
|
||||
assert!(is_mkv_invalid(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_uint_bounded_accepts_valid_width() {
|
||||
// 8 bytes is the max legal EBML uint width and must still work.
|
||||
let mut data = Cursor::new(vec![0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02]);
|
||||
assert_eq!(read_uint_bounded(&mut data, 8).unwrap(), 0x0102);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_string_bounded_rejects_huge_string() {
|
||||
// Claimed string length far above the cap must not allocate.
|
||||
let mut data = Cursor::new(vec![0u8; 16]);
|
||||
let e = read_string_bounded(&mut data, MAX_STRING_LEN + 1).unwrap_err();
|
||||
assert!(is_mkv_invalid(&e));
|
||||
}
|
||||
|
||||
/// Build a minimal MKV (EBML header + Segment + Info + Tracks) so the
|
||||
/// reader is positioned in the cluster body, then append the given
|
||||
/// cluster bytes. Returns the full byte stream.
|
||||
fn minimal_mkv_with_cluster(cluster_body: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
// EBML header (empty body).
|
||||
ebml::write_id(&mut out, ebml::EBML).unwrap();
|
||||
ebml::write_size(&mut out, 0).unwrap();
|
||||
// Segment (unknown size so the reader streams children).
|
||||
ebml::write_id(&mut out, ebml::SEGMENT).unwrap();
|
||||
ebml::write_unknown_size(&mut out).unwrap();
|
||||
// Empty Info.
|
||||
ebml::write_id(&mut out, ebml::INFO).unwrap();
|
||||
ebml::write_size(&mut out, 0).unwrap();
|
||||
// Empty Tracks.
|
||||
ebml::write_id(&mut out, ebml::TRACKS).unwrap();
|
||||
ebml::write_size(&mut out, 0).unwrap();
|
||||
out.extend_from_slice(cluster_body);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_block_oversized_size_is_rejected() {
|
||||
// Cluster containing a SIMPLE_BLOCK that claims a 2 GiB payload.
|
||||
// The reader must reject it (MkvInvalid) rather than attempt a
|
||||
// multi-GB allocation. Header parse stops at CLUSTER, so the
|
||||
// SIMPLE_BLOCK is hit on the first read().
|
||||
let mut cluster = Vec::new();
|
||||
ebml::write_id(&mut cluster, ebml::CLUSTER).unwrap();
|
||||
ebml::write_unknown_size(&mut cluster).unwrap();
|
||||
ebml::write_id(&mut cluster, ebml::SIMPLE_BLOCK).unwrap();
|
||||
ebml::write_size(&mut cluster, 2 * 1024 * 1024 * 1024).unwrap();
|
||||
// No payload follows — but we must fail on the size check, before
|
||||
// any read of the body.
|
||||
let bytes = minimal_mkv_with_cluster(&cluster);
|
||||
|
||||
let mut stream = MkvStream::open(Cursor::new(bytes)).unwrap();
|
||||
let e = stream.read().unwrap_err();
|
||||
assert!(is_mkv_invalid(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_formed_simple_block_round_trips() {
|
||||
// A small, well-formed SIMPLE_BLOCK must still parse into a frame.
|
||||
// We need at least one stream so the track index is in range, so
|
||||
// give Tracks one video TRACK_ENTRY (track number 1).
|
||||
let mut out = Vec::new();
|
||||
ebml::write_id(&mut out, ebml::EBML).unwrap();
|
||||
ebml::write_size(&mut out, 0).unwrap();
|
||||
ebml::write_id(&mut out, ebml::SEGMENT).unwrap();
|
||||
ebml::write_unknown_size(&mut out).unwrap();
|
||||
ebml::write_id(&mut out, ebml::INFO).unwrap();
|
||||
ebml::write_size(&mut out, 0).unwrap();
|
||||
|
||||
// Tracks → one TRACK_ENTRY (track number 1, type 1 = video).
|
||||
let mut entry = Vec::new();
|
||||
ebml::write_uint(&mut entry, ebml::TRACK_NUMBER, 1).unwrap();
|
||||
ebml::write_uint(&mut entry, ebml::TRACK_TYPE, 1).unwrap();
|
||||
let mut track_entry = Vec::new();
|
||||
ebml::write_id(&mut track_entry, ebml::TRACK_ENTRY).unwrap();
|
||||
ebml::write_size(&mut track_entry, entry.len() as u64).unwrap();
|
||||
track_entry.extend_from_slice(&entry);
|
||||
ebml::write_id(&mut out, ebml::TRACKS).unwrap();
|
||||
ebml::write_size(&mut out, track_entry.len() as u64).unwrap();
|
||||
out.extend_from_slice(&track_entry);
|
||||
|
||||
// Cluster with a SIMPLE_BLOCK: track vint=0x81 (track 1),
|
||||
// rel_ts=0x0000, flags=0x80 (keyframe), then 4 bytes of data.
|
||||
ebml::write_id(&mut out, ebml::CLUSTER).unwrap();
|
||||
ebml::write_unknown_size(&mut out).unwrap();
|
||||
let block = [0x81u8, 0x00, 0x00, 0x80, 0xAA, 0xBB, 0xCC, 0xDD];
|
||||
ebml::write_id(&mut out, ebml::SIMPLE_BLOCK).unwrap();
|
||||
ebml::write_size(&mut out, block.len() as u64).unwrap();
|
||||
out.extend_from_slice(&block);
|
||||
|
||||
let mut stream = MkvStream::open(Cursor::new(out)).unwrap();
|
||||
let frame = stream.read().unwrap().expect("expected a frame");
|
||||
assert_eq!(frame.track, 0);
|
||||
assert!(frame.keyframe);
|
||||
assert_eq!(frame.data, vec![0xAA, 0xBB, 0xCC, 0xDD]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +176,18 @@ impl Stream for PipelinedPesStream {
|
||||
}
|
||||
false => {
|
||||
self.eof = true;
|
||||
// Drain any access unit a parser buffered past the last
|
||||
// PES (e.g. DTS-HD's final core+extension unit).
|
||||
let pid_to_track = &self.pid_to_track;
|
||||
let pending = &mut self.pending_frames;
|
||||
for (pid, parser) in self.parsers.iter_mut() {
|
||||
let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else {
|
||||
continue;
|
||||
};
|
||||
for frame in parser.flush() {
|
||||
pending.push_back(PesFrame::from_codec_frame(track, frame));
|
||||
}
|
||||
}
|
||||
return Ok(self.pending_frames.pop_front());
|
||||
}
|
||||
}
|
||||
|
||||
+110
-13
@@ -162,15 +162,32 @@ fn validate_network_addr(addr: &str) -> io::Result<()> {
|
||||
/// Options for opening an input stream.
|
||||
#[derive(Default)]
|
||||
pub struct InputOptions {
|
||||
pub keydb_path: Option<String>,
|
||||
/// Caller-supplied Unit Key (external Unit Key) — the second, mutually
|
||||
/// exclusive key source. Takes precedence over `keydb_path`.
|
||||
pub unit_key: Option<[u8; 16]>,
|
||||
/// Caller-resolved per-CPS-unit AACS keys to apply to the scanned disc
|
||||
/// (`(cps_unit, 16-byte key)`). Empty for an unencrypted disc or when the
|
||||
/// caller has no key. The library does no lookup — a key source resolves
|
||||
/// these and the caller passes them here.
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
pub title_index: Option<usize>,
|
||||
/// Skip decryption — return raw encrypted bytes.
|
||||
pub raw: bool,
|
||||
}
|
||||
|
||||
/// Decide whether an ISO mux must abort for lack of a usable AACS key.
|
||||
///
|
||||
/// Returns `true` only when ALL hold: decryption is requested (`!raw`), the
|
||||
/// disc carries AACS state (`has_aacs` — AACS-encrypted, not CSS/unencrypted),
|
||||
/// and key resolution produced no usable key (`keys` is
|
||||
/// [`crate::decrypt::DecryptKeys::None`]). In that case muxing would emit
|
||||
/// undecryptable garbage, so the caller fails fast with [`Error::NoDiscKey`].
|
||||
///
|
||||
/// `--raw` (raw=true) always returns `false` — raw intentionally skips
|
||||
/// decryption and needs no key. A non-AACS disc (`has_aacs=false`) always
|
||||
/// returns `false`: unencrypted content has `None` keys legitimately, and CSS
|
||||
/// DVDs resolve to `DecryptKeys::Css{..}` (never `None`).
|
||||
fn aacs_key_missing(raw: bool, has_aacs: bool, keys: &crate::decrypt::DecryptKeys) -> bool {
|
||||
!raw && has_aacs && matches!(keys, crate::decrypt::DecryptKeys::None)
|
||||
}
|
||||
|
||||
/// Open a PES input stream (produces PES frames).
|
||||
pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::Stream>> {
|
||||
let parsed = parse_url(url);
|
||||
@@ -184,11 +201,6 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
}
|
||||
StreamUrl::Iso { ref path } => {
|
||||
validate_file_path(path, "iso")?;
|
||||
let scan_opts = crate::disc::ScanOptions {
|
||||
keydb_path: opts.keydb_path.as_ref().map(Into::into),
|
||||
unit_key: opts.unit_key,
|
||||
..Default::default()
|
||||
};
|
||||
// FileSectorSource is the sole file-backed sector source.
|
||||
// It carries the platform-tuned SEQUENTIAL fadvise hint
|
||||
// (so the kernel readahead window widens) and the periodic
|
||||
@@ -196,8 +208,38 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
// when the mux output is being written to the same disk.
|
||||
let mut reader = crate::io::file_sector_source::FileSectorSource::open(path)?;
|
||||
let capacity = reader.capacity_sectors();
|
||||
let disc = crate::disc::Disc::scan_image(&mut reader, capacity, &scan_opts)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
let mut disc = crate::disc::Disc::scan_image(
|
||||
&mut reader,
|
||||
capacity,
|
||||
&crate::disc::ScanOptions::default(),
|
||||
)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
// Apply the caller-resolved keys (lookup-free); decrypt_keys() then
|
||||
// yields them for the stream below. Propagate a failed application
|
||||
// rather than silently muxing an undecryptable stream.
|
||||
if !opts.unit_keys.is_empty() {
|
||||
// These UKs were already resolved AND validated by the caller
|
||||
// (the CLI's keydb loop), so no re-validation sample is needed.
|
||||
disc.decrypt_with(crate::disc::Key::Unit(opts.unit_keys.clone()), &[])
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
}
|
||||
// No-key guard: if decryption is requested (not --raw) and the disc
|
||||
// is AACS-encrypted but key resolution yielded no usable key, FAIL
|
||||
// here — muxing an undecryptable stream produces ~100 MB of garbage
|
||||
// (encrypted m2ts → no TS syncs → demuxer emits nothing). A cheap
|
||||
// result-check on `decrypt_keys()`; no probe decryption needed.
|
||||
// CSS (DVD) decrypts from compiled keys (`decrypt_keys()` returns
|
||||
// `Css{..}`, never `None`), so this gate is AACS-only via `disc.aacs`.
|
||||
if aacs_key_missing(opts.raw, disc.aacs.is_some(), &disc.decrypt_keys()) {
|
||||
// Surface the disc hash (40-hex, no `0x` prefix) so the caller
|
||||
// can name the disc. Empty if scan didn't capture it.
|
||||
let disc_hash = disc
|
||||
.aacs
|
||||
.as_ref()
|
||||
.map(|a| a.disc_hash.trim_start_matches("0x").to_string())
|
||||
.unwrap_or_default();
|
||||
return Err(crate::error::Error::NoDiscKey { disc_hash }.into());
|
||||
}
|
||||
if disc.titles.is_empty() {
|
||||
return Err(crate::error::Error::NoStreams.into());
|
||||
}
|
||||
@@ -209,8 +251,15 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let title = disc.titles[idx].clone();
|
||||
// Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1)
|
||||
// by probing the first DECRYPTED access units of the chosen title.
|
||||
// A fresh reader avoids disturbing the mux reader below.
|
||||
let keys = disc.decrypt_keys();
|
||||
if let Ok(probe) = crate::io::file_sector_source::FileSectorSource::open(path) {
|
||||
let mut dec = crate::sector::DecryptingSectorSource::new(probe, keys.clone());
|
||||
crate::disc::correct_truehd_channels(&mut dec, &mut disc.titles[idx]);
|
||||
}
|
||||
let title = disc.titles[idx].clone();
|
||||
let format = disc.content_format;
|
||||
// ISO file: 16 MiB batch — sequential read from fast
|
||||
// storage, no bad sectors. Measured optimum on the rip1
|
||||
@@ -335,7 +384,8 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
|
||||
};
|
||||
pids.push(pid);
|
||||
pid_to_track.push((pid, idx));
|
||||
parsers.push((pid, super::codec::parser_for_codec(codec, None)));
|
||||
let is_dvd_ps = matches!(format, ContentFormat::MpegPs);
|
||||
parsers.push((pid, super::codec::parser_for_codec(codec, None, is_dvd_ps)));
|
||||
}
|
||||
let (ts, ps) = match format {
|
||||
ContentFormat::MpegPs => (None, Some(super::ps::PsDemuxer::new())),
|
||||
@@ -443,3 +493,50 @@ fn build_m2ts_pipeline<R: std::io::Read + Send + 'static>(
|
||||
pid_to_track,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::aacs_key_missing;
|
||||
use crate::decrypt::DecryptKeys;
|
||||
|
||||
fn aacs_keys() -> DecryptKeys {
|
||||
DecryptKeys::Aacs {
|
||||
unit_keys: vec![(1, [0x11u8; 16])],
|
||||
read_data_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn css_keys() -> DecryptKeys {
|
||||
DecryptKeys::Css {
|
||||
title_key: [0u8; 5],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_no_key_aborts() {
|
||||
// AACS disc, decryption requested, resolver yielded no key → abort.
|
||||
assert!(aacs_key_missing(false, true, &DecryptKeys::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_with_key_proceeds() {
|
||||
// AACS disc with a usable key → proceed.
|
||||
assert!(!aacs_key_missing(false, true, &aacs_keys()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_encrypted_proceeds() {
|
||||
// No AACS state: unencrypted (None keys) and CSS (Css keys) both OK.
|
||||
assert!(!aacs_key_missing(false, false, &DecryptKeys::None));
|
||||
assert!(!aacs_key_missing(false, false, &css_keys()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_never_aborts() {
|
||||
// --raw skips decryption — must never hit the no-key abort, even on an
|
||||
// AACS disc with no key resolved.
|
||||
assert!(!aacs_key_missing(true, true, &DecryptKeys::None));
|
||||
assert!(!aacs_key_missing(true, true, &aacs_keys()));
|
||||
assert!(!aacs_key_missing(true, false, &DecryptKeys::None));
|
||||
}
|
||||
}
|
||||
|
||||
+59
-7
@@ -35,6 +35,15 @@ struct PesAssembler {
|
||||
pts: Option<i64>,
|
||||
dts: Option<i64>,
|
||||
active: bool,
|
||||
/// PES-header bytes still to be skipped on the next continuation
|
||||
/// packet(s). A PES header (9 + PES_header_data_length, up to 264
|
||||
/// bytes) can exceed a single 184-byte TS payload, spilling into the
|
||||
/// following continuation packet. Those spillover bytes are NOT
|
||||
/// elementary-stream data and must be skipped, or the PES start code
|
||||
/// (`00 00 01 …`) and timestamp bytes get injected into the ES — for
|
||||
/// HEVC/H264 that reads as a spurious start code / corrupt slice
|
||||
/// payload. Tracks how many header bytes remain across packets.
|
||||
header_remaining: usize,
|
||||
}
|
||||
|
||||
/// Initial capacity for a fresh PES buffer. Sized to cover the
|
||||
@@ -54,6 +63,7 @@ impl PesAssembler {
|
||||
pts: None,
|
||||
dts: None,
|
||||
active: false,
|
||||
header_remaining: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,12 +245,35 @@ impl TsDemuxer {
|
||||
let payload = &ts[payload_start..];
|
||||
|
||||
if pusi {
|
||||
let (pts, dts, pes_data_start) = parse_pes_header(payload);
|
||||
// `header_len` is the FULL (uncapped) PES-header length:
|
||||
// 0 = malformed (payload is not a PES start), else 6/9+N.
|
||||
let (pts, dts, header_len) = parse_pes_header(payload);
|
||||
if let Some(prev) = asm.start(pts, dts) {
|
||||
completed.push(prev);
|
||||
}
|
||||
if pes_data_start < payload.len() {
|
||||
asm.push(&payload[pes_data_start..]);
|
||||
if header_len == 0 {
|
||||
// PUSI packet whose payload is not a valid PES start. Do
|
||||
// NOT push it — those bytes are not elementary-stream data
|
||||
// and would inject a spurious start code / garbage.
|
||||
asm.header_remaining = 0;
|
||||
} else if header_len <= payload.len() {
|
||||
// Header fits in this packet (the common case).
|
||||
asm.header_remaining = 0;
|
||||
if header_len < payload.len() {
|
||||
asm.push(&payload[header_len..]);
|
||||
}
|
||||
} else {
|
||||
// Header spills past this packet — skip the remainder on
|
||||
// the following continuation packet(s).
|
||||
asm.header_remaining = header_len - payload.len();
|
||||
}
|
||||
} else if asm.header_remaining > 0 {
|
||||
// Continuation packet still inside a PES header that spanned
|
||||
// the boundary — consume header bytes before any ES data.
|
||||
let skip = asm.header_remaining.min(payload.len());
|
||||
asm.header_remaining -= skip;
|
||||
if skip < payload.len() {
|
||||
asm.push(&payload[skip..]);
|
||||
}
|
||||
} else {
|
||||
asm.push(payload);
|
||||
@@ -260,7 +293,14 @@ impl TsDemuxer {
|
||||
}
|
||||
|
||||
/// Parse a PES packet header, extracting PTS and DTS.
|
||||
/// Returns (pts, dts, offset_to_elementary_stream_data).
|
||||
///
|
||||
/// Returns `(pts, dts, header_len)` where `header_len` is the FULL,
|
||||
/// UNCAPPED PES-header length in bytes (`9 + PES_header_data_length`, or
|
||||
/// 6 for stream IDs without the standard extension). `0` signals the
|
||||
/// payload is not a valid PES start (malformed / too short). The caller
|
||||
/// must treat `header_len` as bytes-to-skip and carry any remainder past
|
||||
/// this packet's payload into the next continuation packet — the header
|
||||
/// can exceed one TS payload, and the spillover is header, not ES data.
|
||||
fn parse_pes_header(data: &[u8]) -> (Option<i64>, Option<i64>, usize) {
|
||||
// PES packet: 00 00 01 [stream_id] [length:2] [flags...]
|
||||
if data.len() < 9 || data[0] != 0x00 || data[1] != 0x00 || data[2] != 0x01 {
|
||||
@@ -288,7 +328,10 @@ fn parse_pes_header(data: &[u8]) -> (Option<i64>, Option<i64>, usize) {
|
||||
|
||||
let pts_dts_flags = (data[7] >> 6) & 0x03;
|
||||
let header_data_len = data[8] as usize;
|
||||
let data_start = (9 + header_data_len).min(data.len());
|
||||
// Full, uncapped header length. PTS/DTS (if present) live in the
|
||||
// first ~19 bytes, always within this packet's payload, so they parse
|
||||
// here; only the *skip* length may extend into the next packet.
|
||||
let header_len = 9 + header_data_len;
|
||||
|
||||
let mut pts = None;
|
||||
let mut dts = None;
|
||||
@@ -300,7 +343,7 @@ fn parse_pes_header(data: &[u8]) -> (Option<i64>, Option<i64>, usize) {
|
||||
dts = parse_timestamp(&data[14..19]);
|
||||
}
|
||||
|
||||
(pts, dts, data_start)
|
||||
(pts, dts, header_len)
|
||||
}
|
||||
|
||||
/// Parse a 5-byte PTS/DTS timestamp (33 bits in 90kHz).
|
||||
@@ -404,10 +447,19 @@ pub fn scan_streams(data: &[u8]) -> Option<Vec<crate::disc::Stream>> {
|
||||
|
||||
let section_len =
|
||||
(((data[pmt_start + 1] & 0x0F) as usize) << 8) | data[pmt_start + 2] as usize;
|
||||
// section_length counts the bytes after this field, including the
|
||||
// trailing 4-byte CRC; `< 4` would underflow `end` below. Guard it
|
||||
// exactly like the PAT parser above.
|
||||
if section_len < 4 {
|
||||
offset += BD_TS_PACKET_SIZE;
|
||||
continue;
|
||||
}
|
||||
let prog_info_len =
|
||||
(((data[pmt_start + 10] & 0x0F) as usize) << 8) | data[pmt_start + 11] as usize;
|
||||
let mut pos = pmt_start + 12 + prog_info_len;
|
||||
let end = pmt_start + 3 + section_len - 4;
|
||||
// Clamp the section end to the buffer; a malformed section_len or
|
||||
// prog_info_len must never drive reads past `data`.
|
||||
let end = (pmt_start + 3 + section_len - 4).min(data.len());
|
||||
|
||||
while pos + 5 <= data.len() && pos < end {
|
||||
let stream_type = data[pos];
|
||||
|
||||
@@ -157,14 +157,15 @@ impl PrefetchedSectorSource {
|
||||
Ok(b) => b,
|
||||
Err(_) => return, // consumer dropped both channels
|
||||
};
|
||||
if buf.len() < bytes {
|
||||
buf.resize(bytes, 0);
|
||||
} else {
|
||||
// Re-expose the full extent; previous truncate
|
||||
// shrank the visible len without freeing pages.
|
||||
// SAFETY: capacity is at least `bytes` after
|
||||
// construction with `vec![0u8; batch_bytes]`.
|
||||
if bytes <= buf.capacity() {
|
||||
// Re-expose `bytes` without zero-filling pages that
|
||||
// `read_sectors` is about to overwrite. The capacity
|
||||
// guard makes the `set_len` provably sound even if a
|
||||
// recycled buffer ever comes back smaller than the
|
||||
// `vec![0u8; batch_bytes]` it was born with.
|
||||
unsafe { buf.set_len(bytes) };
|
||||
} else {
|
||||
buf.resize(bytes, 0);
|
||||
}
|
||||
let lba = extent.start_lba + offset;
|
||||
match reader.read_sectors(lba, sectors, &mut buf[..bytes], false) {
|
||||
|
||||
+55
-53
@@ -89,8 +89,8 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
plain[offset] = 0x47; // TS sync byte
|
||||
offset += 192;
|
||||
}
|
||||
// Set encryption flag (bits 6-7 of byte 0)
|
||||
plain[0] |= 0xC0;
|
||||
// No flag set: CBC-encrypting the body below scrambles packets 1..31's TS
|
||||
// syncs, which is exactly what `is_aacs_scrambled` (raw-sync) detects.
|
||||
|
||||
// Save original plaintext for comparison
|
||||
let expected = plain.clone();
|
||||
@@ -126,8 +126,8 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
prev.copy_from_slice(&plain[off..off + 16]);
|
||||
}
|
||||
|
||||
// Verify it looks encrypted
|
||||
assert!(aacs::is_unit_encrypted(&plain));
|
||||
// Verify it looks encrypted (body TS syncs scrambled)
|
||||
assert!(aacs::is_aacs_scrambled(&plain));
|
||||
|
||||
// Now decrypt
|
||||
let result = aacs::decrypt_unit(&mut plain, &unit_key);
|
||||
@@ -136,8 +136,8 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
"decrypt_unit should return true on valid encrypted unit"
|
||||
);
|
||||
assert!(
|
||||
!aacs::is_unit_encrypted(&plain),
|
||||
"encryption flag should be cleared"
|
||||
!aacs::is_aacs_scrambled(&plain),
|
||||
"decrypted unit should read as clear (TS syncs restored)"
|
||||
);
|
||||
|
||||
// Verify TS sync bytes at expected positions (flag byte is cleared by decrypt)
|
||||
@@ -156,17 +156,11 @@ fn aacs_decrypt_unit_roundtrip() {
|
||||
sync_count, expected_syncs
|
||||
);
|
||||
|
||||
// Compare all bytes except byte 0 (encryption flag cleared)
|
||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||
assert_eq!(
|
||||
&plain[1..aacs::ALIGNED_UNIT_LEN],
|
||||
&expected[1..aacs::ALIGNED_UNIT_LEN],
|
||||
"decrypted unit body does not match original"
|
||||
);
|
||||
// Byte 0: original had 0xC0 set, decrypted has it cleared
|
||||
assert_eq!(
|
||||
plain[0] & !0xC0,
|
||||
expected[0] & !0xC0,
|
||||
"byte 0 mismatch ignoring flag"
|
||||
&plain[..],
|
||||
&expected[..],
|
||||
"decrypted unit does not match original plaintext"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -260,49 +254,65 @@ fn aacs_vuk_derivation_roundtrip() {
|
||||
assert_eq!(vuk, vuk2, "derive_vuk not deterministic");
|
||||
}
|
||||
|
||||
/// Test: aacs_is_unit_encrypted detects encryption flags correctly.
|
||||
/// Test: aacs_is_aacs_scrambled detects scrambled units via the raw TS syncs.
|
||||
#[test]
|
||||
fn aacs_is_unit_encrypted_detection() {
|
||||
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
|
||||
fn aacs_is_aacs_scrambled_detection() {
|
||||
// A clear unit: TS sync (0x47) intact at every 192-byte packet → not
|
||||
// scrambled. (Flag bits play no role.)
|
||||
let mut clear = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
while off < aacs::ALIGNED_UNIT_LEN {
|
||||
clear[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
assert!(
|
||||
!aacs::is_unit_encrypted(&unit),
|
||||
"zero unit should not be encrypted"
|
||||
!aacs::is_aacs_scrambled(&clear),
|
||||
"clear unit (syncs intact) must not be scrambled"
|
||||
);
|
||||
|
||||
unit[0] = 0x40; // bit 6 set
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
// Flag bits do NOT make a synced unit look scrambled.
|
||||
let mut flagged = clear.clone();
|
||||
flagged[0] = 0xC0; // copy-control bits
|
||||
flagged[7] = 0xC0; // TSC bits
|
||||
assert!(
|
||||
!aacs::is_aacs_scrambled(&flagged),
|
||||
"flag bits must not be read as encryption"
|
||||
);
|
||||
|
||||
unit[0] = 0x80; // bit 7 set
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
|
||||
unit[0] = 0xC0; // both bits set
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
|
||||
unit[0] = 0x3F; // bits 6-7 clear
|
||||
assert!(!aacs::is_unit_encrypted(&unit));
|
||||
// A scrambled body (syncs destroyed) → scrambled.
|
||||
let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN];
|
||||
assert!(
|
||||
aacs::is_aacs_scrambled(&scrambled),
|
||||
"unit with no intact TS syncs must read as scrambled"
|
||||
);
|
||||
|
||||
// Too short
|
||||
let short = vec![0xC0u8; 100];
|
||||
let short = vec![0xFFu8; 100];
|
||||
assert!(
|
||||
!aacs::is_unit_encrypted(&short),
|
||||
!aacs::is_aacs_scrambled(&short),
|
||||
"short buffer should not be detected"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test: aacs_decrypt_unit_unencrypted_passthrough
|
||||
///
|
||||
/// A unit without encryption flags should pass through decrypt_unit unchanged.
|
||||
/// A clear unit (TS syncs intact) should pass through decrypt_unit unchanged.
|
||||
#[test]
|
||||
fn aacs_decrypt_unit_unencrypted_passthrough() {
|
||||
let mut unit = vec![0x42u8; aacs::ALIGNED_UNIT_LEN];
|
||||
unit[0] = 0x00; // no encryption flag
|
||||
// Intact TS syncs every 192 bytes → not scrambled → passthrough.
|
||||
let mut off = 4;
|
||||
while off < aacs::ALIGNED_UNIT_LEN {
|
||||
unit[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
let original = unit.clone();
|
||||
let key = [0xAA; 16];
|
||||
|
||||
assert!(!aacs::is_aacs_scrambled(&unit));
|
||||
let result = aacs::decrypt_unit(&mut unit, &key);
|
||||
assert!(result, "unencrypted unit should return true");
|
||||
assert_eq!(unit, original, "unencrypted unit should be unchanged");
|
||||
assert!(result, "clear unit should return true");
|
||||
assert_eq!(unit, original, "clear unit should be unchanged");
|
||||
}
|
||||
|
||||
// ── AACS cross-validation with independent AES implementation ──────────────
|
||||
@@ -369,8 +379,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
plaintext[i] = (i % 251) as u8;
|
||||
}
|
||||
}
|
||||
// Set encryption flag
|
||||
plaintext[0] = 0xC0;
|
||||
// No flag set: the CBC-encrypted body scrambles the packet syncs.
|
||||
|
||||
let expected = plaintext.clone();
|
||||
|
||||
@@ -401,14 +410,11 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
|
||||
ok,
|
||||
"decrypt_unit returned false (TS sync verification failed)"
|
||||
);
|
||||
assert_eq!(plaintext[0] & 0xC0, 0x00, "encryption flag not cleared");
|
||||
|
||||
// Compare (byte 0 flag was cleared)
|
||||
let mut expected_cleared = expected.clone();
|
||||
expected_cleared[0] &= !0xC0;
|
||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||
assert_eq!(
|
||||
&plaintext[1..aacs::ALIGNED_UNIT_LEN],
|
||||
&expected_cleared[1..aacs::ALIGNED_UNIT_LEN],
|
||||
&plaintext[..],
|
||||
&expected[..],
|
||||
"decrypted unit does not match original plaintext"
|
||||
);
|
||||
}
|
||||
@@ -428,7 +434,7 @@ fn aacs_cross_validation_alternate_key() {
|
||||
plaintext[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
plaintext[0] = 0xC0;
|
||||
// No flag set: the CBC-encrypted body scrambles the packet syncs.
|
||||
let expected = plaintext.clone();
|
||||
|
||||
let mut header = [0u8; 16];
|
||||
@@ -446,12 +452,8 @@ fn aacs_cross_validation_alternate_key() {
|
||||
|
||||
assert!(aacs::decrypt_unit(&mut plaintext, &unit_key));
|
||||
|
||||
let mut expected_cleared = expected;
|
||||
expected_cleared[0] &= !0xC0;
|
||||
assert_eq!(
|
||||
&plaintext[1..aacs::ALIGNED_UNIT_LEN],
|
||||
&expected_cleared[1..aacs::ALIGNED_UNIT_LEN],
|
||||
);
|
||||
// Decryption clears no flag, so the unit round-trips byte-for-byte.
|
||||
assert_eq!(&plaintext[..], &expected[..]);
|
||||
}
|
||||
|
||||
/// Verify that `decrypt_bus` correctly reverses AES-CBC encryption applied
|
||||
|
||||
+15
-32
@@ -126,31 +126,19 @@ fn disc_title_total_sectors() {
|
||||
// ── ScanOptions tests ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn scan_options_default() {
|
||||
fn scan_options_default_has_no_credentials() {
|
||||
// Lookup-free: the only scan input is the optional live-drive credentials.
|
||||
let opts = ScanOptions::default();
|
||||
assert!(opts.keydb_path.is_none());
|
||||
assert!(opts.credentials.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_options_with_keydb() {
|
||||
fn scan_options_with_credentials() {
|
||||
let opts = ScanOptions {
|
||||
keydb_path: Some(("/tmp/KEYDB.cfg").into()),
|
||||
..Default::default()
|
||||
credentials: Some(libfreemkv::DriveCredentials::default()),
|
||||
};
|
||||
assert_eq!(
|
||||
opts.keydb_path.as_ref().unwrap().to_str().unwrap(),
|
||||
"/tmp/KEYDB.cfg"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_options_with_keydb_pathbuf() {
|
||||
let path = std::path::PathBuf::from("/home/user/.config/aacs/KEYDB.cfg");
|
||||
let opts = ScanOptions {
|
||||
keydb_path: Some(path.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(opts.keydb_path.unwrap(), path);
|
||||
assert!(opts.credentials.is_some());
|
||||
assert!(opts.credentials.unwrap().host_certs.is_empty());
|
||||
}
|
||||
|
||||
// ── detect_format integration tests ───────────────────────────────────────
|
||||
@@ -511,21 +499,16 @@ fn batch_count_max_batch_sizes() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_encryption_no_keydb() {
|
||||
// A UDF image with /AACS directory but no keydb path -> aacs is None
|
||||
fn scan_encrypted_resolves_no_keys() {
|
||||
// A UDF image with an /AACS directory: the lookup-free scan detects
|
||||
// encryption and captures inputs, but resolves NO key on its own — a
|
||||
// caller applies one later via Disc::decrypt_with.
|
||||
let mut reader = MockSectorReader::new();
|
||||
build_udf_with_aacs_dir(&mut reader);
|
||||
|
||||
// No keydb configured and no standard keydb on the system
|
||||
let opts = ScanOptions {
|
||||
keydb_path: Some(("/nonexistent/path/KEYDB.cfg").into()),
|
||||
..Default::default()
|
||||
};
|
||||
let disc = Disc::scan_image(&mut reader, 1000, &opts).unwrap();
|
||||
let disc = Disc::scan_image(&mut reader, 1000, &ScanOptions::default()).unwrap();
|
||||
|
||||
// The disc detects encryption but can't resolve keys without a keydb
|
||||
assert!(
|
||||
disc.aacs.is_none(),
|
||||
"aacs should be None when keydb is unavailable"
|
||||
);
|
||||
// No unit keys without an external key (the mock has no Unit_Key_RO.inf to
|
||||
// capture, so the keyless state isn't even built) — either way, no keys.
|
||||
assert!(matches!(disc.decrypt_keys(), libfreemkv::DecryptKeys::None));
|
||||
}
|
||||
|
||||
+15
-13
@@ -82,22 +82,24 @@ fn decrypt_sectors_with_css_keys_works() {
|
||||
/// Test: AACS unit encryption detection works.
|
||||
#[test]
|
||||
fn aacs_encryption_flag_detection() {
|
||||
// A clear unit: TS syncs (0x47) intact at every 192-byte packet.
|
||||
let mut unit = vec![0u8; aacs::ALIGNED_UNIT_LEN];
|
||||
let mut off = 4;
|
||||
while off < aacs::ALIGNED_UNIT_LEN {
|
||||
unit[off] = 0x47;
|
||||
off += 192;
|
||||
}
|
||||
// Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit.
|
||||
assert!(!aacs::is_aacs_scrambled(&unit));
|
||||
|
||||
// No encryption flag
|
||||
assert!(!aacs::is_unit_encrypted(&unit));
|
||||
|
||||
// Set bit 6
|
||||
unit[0] |= 0x40;
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
|
||||
// Set bit 7
|
||||
unit[0] = 0x80;
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
|
||||
// Both bits set
|
||||
// Flag bits on a synced unit do not make it look encrypted.
|
||||
unit[0] = 0xC0;
|
||||
assert!(aacs::is_unit_encrypted(&unit));
|
||||
unit[7] = 0xC0;
|
||||
assert!(!aacs::is_aacs_scrambled(&unit));
|
||||
|
||||
// Scrambled body (syncs gone) → encrypted.
|
||||
let scrambled = vec![0x99u8; aacs::ALIGNED_UNIT_LEN];
|
||||
assert!(aacs::is_aacs_scrambled(&scrambled));
|
||||
}
|
||||
|
||||
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
|
||||
|
||||
Reference in New Issue
Block a user