Compare commits

...
27 Commits
Author SHA1 Message Date
MattJackson 7d58ba7b08 v0.30.0: DiscInputs carries the disc's volume label (title)
Add `DiscInputs.volume_label: Option<String>` — the disc's human title (UDF/ISO
volume identifier, else BDMV <di:name>), populated by Disc::inputs() from the
scan. Identity only, no secret, not used in any AACS derivation; lets a key
source forward the title so a key service can catalog disc_hash → title.
2026-06-06 09:05:15 -07:00
MattJackson 4f1dbfd042 v0.29.0: bump version 2026-06-05 20:36:19 -07:00
MattJackson 5b702a76a7 mux: write PTS (presentation), not DTS, as the MKV block timecode
The video codec parsers (HEVC, H.264, VC-1, MPEG-2) used
pes.dts.or(pes.pts) as each frame's timestamp. MKV block timecodes
are presentation timestamps; frames are stored in decode order and
the player reorders for display by timecode. Using DTS makes the
timecode monotonic in storage order, presenting B-frames in decode
order — visible motion judder / wrong frames on playback, and
PTS-based seeking lands on the wrong frame.

The compressed video was always byte-correct (verified by NAL-level
diff against a known-good demux); this was purely a timestamp defect
affecting every B-frame title. Fix: prefer PTS (pes.pts.or(pes.dts)).
Verified on a real UHD iso->mkv: emitted PTS now reorders for
B-frames identically to a reference muxer.

Update the two tests that asserted the old DTS-preferred behavior and
add an HEVC regression test pinning PTS as the block timecode.
2026-06-05 20:01:13 -07:00
MattJackson 6be5198886 libfreemkv: v1.0 hardening — codec/EBML/TS robustness + DTS parser fixes
Audit-driven fixes (rounds 1–3):
- hevc: correct hvcC profile/level SPS offsets (HEVC has a 2-byte NAL header)
- mkv: map all DTS variants to the registered A_DTS codec id; force a new
  cluster before the i16 cluster-relative timestamp can overflow
- ebml/mkvstream: bound untrusted EBML sizes (no multi-GB allocs); reject
  uint>8 (was an OOB panic) and non-{0,4,8} float widths (were a desync)
- ts: skip PES-header bytes that span a TS packet boundary; add the PMT
  section_len/prog_info_len bounds the PAT parser already had
- ac3: preserve a 0x0B77 syncword split across a PES boundary; cap buffer
- dts: validate each next-core boundary by decoded core size (a 0x7FFE8001
  pattern inside XLL payload no longer false-splits/drops the lossless
  extension); reject sub-minimum core frames; fix forced-emit PTS base
- lpcm: DVD program-stream PCM no longer double-strips the BD LPCM header
- vc1/mpeg2: do not emit a parameter-set-only PES as a standalone frame
- pgs/truehd: cap the pending reassembly buffer (parity with ac3/dts)
- aacs: ts_syncs_intact uses the exact packet count
- prefetched: capacity-guard the recycled-buffer set_len
- Cargo.toml: exclude project docs from the published crate

Convergence: a third independent audit pass found no remaining material
(CRITICAL/HIGH/MEDIUM) issues. Full precommit (fmt + clippy -D + tests,
Rust 1.86) green.
2026-06-05 16:23:39 -07:00
MattJackson e2aa9abd6d aacs: validate a resolved key against content before applying it
decrypt_with now takes the disc's encrypted content samples and, after
deriving the candidate unit keys, confirms at least one de-scrambles a real
aligned unit before committing them. A wrong key (a keydb VK that doesn't
match the disc, a stale UK) is rejected with AacsKeyRejected instead of
silently applying garbage unit keys. Conservative by design: with no samples
(resume / mapfile cache) it accepts as before, leaving those paths unchanged.

The KeySource trait becomes a stateful provider — next_key hands one candidate
at a time (the source owns the order) and reports exhaustion, replacing the
all-at-once resolve; errored() distinguishes a failed source from a clean
no-key.
2026-06-05 09:23:08 -07:00
MattJackson 3b7bee9ed4 Fix DTS-HD MA muxing as lossy core only (#10)
DTS-HD MA/HRA access units on Blu-ray are a DTS core frame (sync
0x7FFE8001) followed by one or more DTS extension substreams (sync
0x64582025) carrying the lossless audio. Ground-truthing the Dunkirk
ISO showed the m2ts demuxer hands these out as SEPARATE PES packets on
the same PID: one core PES (exactly core-sized, nothing trailing), then
the extension substreams in following PES packets with their own later
PTS.

The old DtsParser emitted one frame per PES the moment a core frame was
complete, and dropped any PES with no core sync. So every core became a
core-only (lossy) frame and the extension PES packets were discarded as
junk -- silently downgrading the track to lossy DTS core (1557 kb/s CBR,
16-bit) instead of DTS-HD MA (VBR, 24-bit lossless).

Rewrite the parser to assemble across PES boundaries: an access unit
runs from its core sync up to (but not including) the NEXT core sync, so
the core plus every following extension substream stays together. Add a
CodecParser::flush() (default empty) called at end-of-stream by both the
pipelined and inline DiscStream mux paths to drain the final buffered
unit. A 64 KiB cap guarantees forward progress and never stalls if a
boundary can't be found.

Validated on the rip1 testbed: Dunkirk eng+ger and Fight Club eng main
audio now ffprobe as profile=DTS-HD MA (Fight Club eng at 24-bit), with
VBR packet sizes (~2716-2788 B) well above the old fixed 2012 B lossy
core. Genuinely-lossy DTS dub tracks are left untouched.
2026-06-05 06:40:27 -07:00
MattJackson b7405e2d27 mux: fail iso:// with no usable AACS key instead of muxing garbage
When an AACS-encrypted ISO is muxed with decryption requested (not --raw)
but key resolution yielded no usable key, input() proceeded to mux the
still-encrypted stream — emitting ~100 MB of garbage (no TS syncs, demuxer
emits nothing) and sometimes spinning for tens of minutes.

Add a cheap result-check in resolve::input()'s Iso branch via the pure
predicate aacs_key_missing(raw, has_aacs, keys): when decryption is
requested AND the disc carries AACS state AND decrypt_keys() is None,
return new Error::NoDiscKey { disc_hash } (E7022) before muxing. The
40-hex disc hash is sourced from AacsState::disc_hash. --raw and
non-AACS (unencrypted / CSS) discs are unaffected. Unit-tested.
2026-06-05 05:06:15 -07:00
MattJackson e04d79c593 v0.28.1: bump version 2026-06-04 21:46:41 -07:00
MattJackson b34af1fa74 mux: wire TrueHD channel probe into iso:// mux setup
correct_truehd_channels() probes the first DECRYPTED access units of the
chosen title (TrueHD PIDs, bounded 8 MiB read of the first extent) and
sets AudioStream.channels from the MLP major sync — fixing the MPLS
audio_format understatement (5.1 declared on a 7.1/Atmos TrueHD track).
Regenerates the basic codec label for the corrected count; richer
editorial labels are left untouched. Wired in resolve.rs input() for
iso:// after decrypt_with (the m2ts is only decryptable post-key), using
a fresh reader so the mux reader is undisturbed. generate_audio_label
made pub(crate).
2026-06-04 20:28:37 -07:00
MattJackson a7317f8885 mux/truehd: decode true channel count from MLP major-sync format_info
Adds truehd_channels(format_info) + truehd_channels_from_stream(): the
real presentation channel count (8ch/7.1 when present, else 6ch/5.1)
from the TrueHD major sync, which the MPLS audio_format base field
understates (declares 5.1 even on a 7.1/Atmos track — the Dune case).
Per the MLP spec presentation channel-assignment masks. 3 unit tests.

This is the decoder; wiring it to correct AudioStream.channels requires
a mux-time probe of the DECRYPTED audio payload (the channel truth isn't
readable at scan — the m2ts is AACS-encrypted until the key is applied
at mux), to be added + validated on a real rip.
2026-06-04 19:51:46 -07:00
MattJackson 474273afc0 mux/mkv: signal Dolby Vision via dvcC BlockAdditionMapping
With the EL now carried (mpls PID fix), the MKV writer tags the Dolby
Vision layer so players/mediainfo recognise it: a BlockAdditionMapping
(0x41E4) with BlockAddIDType 'dvcC' and a DOVIDecoderConfigurationRecord
(profile 7, bl+el+rpu present) on the DV-layer video track. Disc
Profile 7 dual-layer is preserved losslessly as a second video track
(archival-correct, MakeMKV-equivalent); M2TS carries the EL as a native
second video PID with no extra signaling needed.

Adds dolby_vision_config() + a profile-7 dvcC unit test. End-to-end DV
recognition (mediainfo / playback) to be confirmed on a full rip.
2026-06-04 19:42:14 -07:00
MattJackson 0a2bab5789 mpls: resolve PID for sub-path / DV-EL stream entries (types 2/3/4)
parse_stream_entry only read the PID for stream-entry type 1 (a stream
in the PlayItem's own clip), returning PID 0 for every other type. The
Dolby Vision enhancement layer uses type 4 (verified on Wicked + Dune:
se_len=9 type=0x04 [00 10 15] -> PID 0x1015), so the EL fell through to
PID 0x0000 and was silently dropped by the demux/mux — Dolby Vision lost.

Now the PID offset is keyed off the entry type per the BD stream_entry()
layout: type 1 -> +2, type 2 -> +4, type 3/4 -> +3. The DV EL now
resolves to its real PID (0x1015) so it is demuxed and carried as a
stream through the PES layer to every writer (M2TS carries dual-PID DV
natively; the MKV writer's DV signaling is the format-specific piece).
2026-06-04 19:28:34 -07:00
MattJackson fa4d7ef871 mux/dts: keep DTS-HD extension substream across PES boundaries
The DTS parser emitted a core-only frame and discarded the trailing
DTS-HD extension substream whenever the extension straddled a PES
boundary (it advanced past only the core, then re-synced on the core
syncword, skipping the leftover extension bytes). That silently
downgrades DTS-HD MA / HRA to lossy DTS core.

Now: when an extension sync is visible after the core — full, or a
partial prefix at the buffer edge — wait for the full extension instead
of splitting it off; only 'nothing after the core' (final unit / EOF,
no parser flush) is taken as a genuine lossy core-only unit. Adds a
boundary-split regression test asserting core+extension is preserved.
2026-06-04 19:16:26 -07:00
MattJackson 36d1af1b7f labels+disc: codec from stream, audio-richness title tiebreak
Two validated audio-correctness fixes (proven on real discs Wicked/
Paddington/Dune/Fight Club via head-captures):

labels: apply_labels now derives the codec/channel descriptor from the
stream's OWN codec/channels unless the parser's codec_hint is BOTH
consistent with it AND richer (e.g. "Dolby Atmos" on a TrueHD stream).
A mis-bound hint ("AC-3 2.0" on a TrueHD track) is rejected and the
stream's own codec used — killing the cross-labeled shuffle (Wicked) and
the compat-core mislabel (Paddington), while keeping rich hints and
normalizing plain ones to uniform marketing names. (codec_hint_consistent
+ codec_hint_adds_detail, 5 tests.)

disc: canonical_title_order gains an audio-richness tiebreak
(lossless > channels > track-count) for titles that tie on
duration+clips — so a movie authored as a full-audio playlist plus a
stereo-only twin (Fight Club 00800 vs 00004) picks the full-audio one
instead of falling to array order.
2026-06-04 19:10:11 -07:00
MattJackson b96f6206fe 0.28.0: version bump (keydb decoupling) 2026-06-04 16:41:04 -07:00
MattJackson bb32fb993b 0.28.0 review fixes: propagate mux key-apply error; scrub key-shaped fixtures
- mux input(): propagate a failed decrypt_with rather than muxing an
  undecryptable stream silently (review M6).
- Scrub synthetic but key-SHAPED hex (HOST_PRIV_KEY/DEVICE_KEY/disc-entry) from
  the keydb parser tests to obvious zero/repeated-byte placeholders. No real or
  real-looking key material in code.
2026-06-04 16:38:58 -07:00
MattJackson c1eb74dfa5 AACS: cutover — lookup-free lib; DriveCredentials for the handshake
The library no longer loads a keydb anywhere. The scan path always captures the
disc's AACS inputs (MKB, VID, Unit_Key_RO.inf) and resolves NO key; a caller
resolves a Key from a key source and applies it via Disc::decrypt_with.

- ScanOptions loses keydb_path / unit_key / disable_keydb (and the path search);
  it now carries only optional DriveCredentials (host certs) for the live-drive
  AACS handshake. do_handshake_cert uses those instead of loading the keydb.
  An unlocked / LibreDrive drive takes the OEM Volume-ID path and needs none.
- The mux input() path takes caller-resolved unit_keys instead of a keydb_path,
  and applies them via decrypt_with.
- Deleted the now-dead inline resolve_encryption / resolve_encryption_static.

All 700+ lib tests pass.
2026-06-04 16:04:06 -07:00
MattJackson 46838c63ca AACS: trim the MKB captured at keyless scan to its record length
The VID-only scan path stashed MKB_RO/RW raw — those files are allocated to a
fixed ~128 MiB and zero-padded, so the MKB on AacsState (consumed by
Disc::inputs() and the device/processing-key decrypt_with derivation) was the
full pad, not the ~few-MB record stream. Trim to mkb_content_len, matching
read_aacs_inputs.
2026-06-04 15:32:12 -07:00
MattJackson 1e60220ff6 AACS: KeySource::needs_samples() gates the content-sample read
A source that validates server-side against ciphertext (an online key service)
needs encrypted content samples; one that keys on disc identity (keydb, mapfile)
does not. needs_samples() lets the caller skip the extra disc read unless a
configured source actually needs it. Defaults false.
2026-06-04 14:45:40 -07:00
MattJackson 080f03e8ed AACS: DiscInputs gains app-populated samples for ciphertext-validating sources
An online key service validates a candidate against real ciphertext, so it
needs a few encrypted content sample units. Add a samples field to DiscInputs;
Disc::inputs() leaves it empty (reading content needs the disc reader, which
scan does not retain) for the application to fill.
2026-06-04 14:42:11 -07:00
MattJackson 35de6101d4 AACS: add KeySource trait + DiscInputs; Media key carries a candidate pool
The library now describes the key-source abstraction it consumes, while the
concrete sources live in a companion crate. A KeySource looks a disc up from
its DiscInputs (disc hash, volume id, MKB, Unit_Key_RO.inf) and returns a Key;
the library derives down and decrypts. Sources do no derivation.

Disc::inputs() exposes those inputs from a scanned disc.

Key::Media now carries a Vec of candidate media keys rather than one: a media
key is MKB-scoped, so a source hands its whole pool and the library picks the
one that verifies against the disc's MKB. That mirrors Device/Processing and
keeps key selection (a derivation step) inside the library.
2026-06-04 14:29:32 -07:00
MattJackson 9012101573 AACS: decrypt_with accepts higher-level keys and derives down
Disc::decrypt_with now takes Device / Processing / Media / Volume keys in
addition to Unit. A caller hands in whatever level it resolved and the
library derives down the AACS chain to the per-CPS-unit keys, then
decrypts:

  Device   -> MKB walk         -> media key -> VUK -> per-CPS unit keys
  Processing -> MKB            -> media key -> VUK -> per-CPS unit keys
  Media      -> Volume ID      -> VUK              -> per-CPS unit keys
  Volume     -> Unit_Key_RO.inf, one unit key per CPS unit
  Unit       -> used directly (terminal)

Derivation stays centralized in the version-dispatched resolver
(1.0 / 2.0 / 2.1), fed by a single-key provider built from the supplied
key — no new crypto. Volume notably does NOT stop at the volume key: it
decrypts every CPS unit's key.

Scan stashes the AACS inputs (Unit_Key_RO.inf and MKB) on AacsState so an
out-of-band decrypt_with can derive without re-reading the disc.

Non-breaking: Key is #[non_exhaustive] and the existing Unit path is
unchanged. New tests cover the Volume -> per-CPS derive-down, the
missing-inputs error, and the no-units rejection.
2026-06-04 14:20:16 -07:00
MattJackson 8bc1de6c9b 0.27.5 (step 2, Phase 1): expose AACS inputs (uk_ro, mkb) on AacsState
scan now stashes the raw Unit_Key_RO.inf + MKB bytes on AacsState (via
resolve_vid_only, the disable_keydb path), so an external key-resolver can
derive unit keys from a resolved VUK without re-reading the disc — the
foundation for moving lookup/derivation out of libfreemkv. Additive: the keydb
path is untouched, all existing constructors default the new fields empty.
584 lib tests green. Builds on the KeyOrigin rename + the Key/decrypt_with API.
2026-06-04 13:20:27 -07:00
MattJackson d94a4d3444 0.27.5 (step 2, item 2): rename provenance enum KeySource -> KeyOrigin
Disambiguates the key vocabulary: Key (the input handed to decrypt_with),
key sources (the resolver's lookup list), and KeyOrigin (how a key was
resolved). Internal-only rename — no dependents import it.
2026-06-04 11:42:21 -07:00
MattJackson 1bd7e1de2a 0.27.4: lookup-free Key API (Disc::decrypt_with) + E8005 deferred-mux fix
Add libfreemkv::Key (non_exhaustive; Unit wired) + Disc::decrypt_with(Key) — the
caller resolves a key from any source and hands it in; the library decrypts with
no keydb/network of its own. inject_unit_keys now synthesizes a minimal
ExternalUk AACS state when a scan built none (no keydb at scan time), so a disc
keyed at sweep but scanned without a keydb still decrypts from its mapfile UK
instead of reporting E8005 and deferring the mux forever. Tests cover the
synthesize, replace, empty->None, and decrypt_with paths.
2026-06-04 11:08:33 -07:00
MattJackson da62ee7cf2 v0.27.3: bump version 2026-06-03 21:47:43 -07:00
MattJackson 575c76156f mapfile: persist resolved unit keys (keys XOR VID)
A keyed disc now writes its decrypted AACS unit keys to the mapfile header
(# freemkv-uk: <cps>:<hex>); an unresolved disc writes only the VID. The two are
mutually exclusive (set_unit_keys clears the VID) — unit keys are the final
answer, so deferred-mux / resume decrypts directly with no key lookup, while the
VID alone is the 'still unresolved, retry' marker. CopyOptions/SweepOptions carry
the keys (written when present, else the VID); Disc::inject_unit_keys applies
mapfile-recovered keys to a scanned disc. Round-trip test added.
2026-06-03 21:45:37 -07:00
32 changed files with 3094 additions and 653 deletions
+40
View File
@@ -1,5 +1,45 @@
# 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
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.27.0"
version = "0.30.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"] }
+6 -1
View File
@@ -100,7 +100,12 @@ fn ts_syncs_intact(unit: &[u8]) -> bool {
}
offset += TS_PACKET_LEN;
}
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
}
+29 -14
View File
@@ -455,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());
@@ -464,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);
@@ -479,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!(
@@ -495,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);
}
+39
View File
@@ -120,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()
}
}
+29 -252
View File
@@ -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,219 +296,6 @@ 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(
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;
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)?;
let dh = aacs::disc_hash(&uk_ro_data);
let cc = udf_fs
.read_file(reader, "/AACS/Content000.cer")
.or_else(|_| udf_fs.read_file(reader, "/AACS/Content001.cer"))
.ok()
.as_deref()
.and_then(aacs::parse_content_cert);
let bus_encryption = cc.as_ref().map(|c| c.bus_encryption).unwrap_or(false);
let version = match cc.as_ref().map(|c| c.version) {
Some(aacs::AacsVersion::V10) => 1,
Some(_) => 2,
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"))
.ok()
.as_deref()
.and_then(aacs::mkb_version);
tracing::warn!(
target: "freemkv::disc",
phase = "scan_aacs_external_uk",
disc_hash = %aacs::disc_hash_hex(&dh),
version,
bus_encryption,
"using caller-supplied unit key"
);
Ok(AacsState {
version,
bus_encryption,
mkb_version: mkb_ver,
disc_hash: aacs::disc_hash_hex(&dh),
key_source: KeySource::ExternalUk,
vuk: None,
unit_keys: vec![(1, unit_key)],
read_data_key: handshake.and_then(|h| h.read_data_key),
volume_id: handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]),
})
}
/// 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
@@ -562,13 +328,22 @@ impl Disc {
None if bus_encryption => 2,
None => 1,
};
// MKB_RO is the correctly-sized copy; avoid reading the padded RW region.
let mkb_ver = udf_fs
// 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",
@@ -585,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![],
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
View File
@@ -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");
+771 -130
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -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()),
}
}
+89
View File
@@ -0,0 +1,89 @@
//! 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>>,
/// The disc's human title — the UDF/ISO volume identifier (e.g.
/// `WICKED_FOR_GOOD`), falling back to the BDMV `<di:name>` when present.
/// `None` when not captured. Identity only, no secret; a key service may
/// record it (keyed by `disc_hash`) to build a hash→title catalog. Not used
/// in any AACS derivation.
pub volume_label: Option<String>,
}
/// 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+81
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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]);
}
}
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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];
+8 -7
View File
@@ -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) {
+15 -32
View File
@@ -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));
}