Compare commits

...
17 Commits
Author SHA1 Message Date
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
15 changed files with 996 additions and 477 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "0.27.4"
version = "0.28.1"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
+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,
})
}
}
+380 -139
View File
@@ -375,6 +375,79 @@ pub struct Extent {
pub sector_count: u32,
}
/// Correct the channel count of a title's TrueHD audio streams by probing the
/// first decrypted access units. The MPLS `audio_format` field declares the
/// BASE layout (often 5.1) even for a 7.1/Atmos TrueHD track; the real count is
/// in the MLP major sync. `reader` must yield DECRYPTED sectors (the m2ts is
/// AACS-encrypted, so this can only run at mux time, not scan). Reads a bounded
/// window of the title's first extent. Also regenerates the stream's codec
/// label when it was the basic descriptor for the (now corrected) count —
/// richer editorial labels (e.g. "Dolby Atmos") are left untouched.
pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut DiscTitle) {
use crate::mux::codec::truehd::truehd_channels_from_stream;
let pids: Vec<u16> = title
.streams
.iter()
.filter_map(|s| match s {
Stream::Audio(a) if matches!(a.codec, Codec::TrueHd) => Some(a.pid),
_ => None,
})
.collect();
if pids.is_empty() {
return;
}
let Some(ext) = title.extents.first() else {
return;
};
// Bounded probe: up to 8 MiB from the start of the title — enough for the
// first interleaved TrueHD major sync of each stream.
const PROBE_SECTORS: u32 = 4096;
let n = ext.sector_count.min(PROBE_SECTORS) as u16;
if n == 0 {
return;
}
let mut buf = vec![0u8; n as usize * 2048];
if reader
.read_sectors(ext.start_lba, n, &mut buf, true)
.is_err()
{
return;
}
let mut demux = crate::mux::ts::TsDemuxer::new(&pids);
let mut payloads: std::collections::HashMap<u16, Vec<u8>> = std::collections::HashMap::new();
for pes in demux.feed(&buf).into_iter().chain(demux.flush()) {
payloads
.entry(pes.pid)
.or_default()
.extend_from_slice(&pes.data);
}
for s in title.streams.iter_mut() {
let Stream::Audio(a) = s else { continue };
if !matches!(a.codec, Codec::TrueHd) {
continue;
}
let Some(payload) = payloads.get(&a.pid) else {
continue;
};
let Some(count) = truehd_channels_from_stream(payload) else {
continue;
};
let new_ch = AudioChannels::from_count(count);
if new_ch == AudioChannels::Unknown || new_ch == a.channels {
continue;
}
let was_basic =
a.label == crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary);
a.channels = new_ch;
if was_basic {
a.label = crate::labels::generate_audio_label(&a.codec, &new_ch, a.secondary);
}
}
}
/// Calculate how many bytes of bad/unreadable data fall within a title's extents.
/// `pub(crate)` so autorip can use it for main-movie lost_ms computation.
pub fn bytes_bad_in_title(title: &DiscTitle, bad_ranges: &[(u64, u64)]) -> u64 {
@@ -882,9 +955,9 @@ pub struct AacsState {
/// Disc hash (SHA1 of Unit_Key_RO.inf) -- hex string with 0x prefix
pub disc_hash: String,
/// How keys were resolved
pub key_source: KeySource,
pub key_source: KeyOrigin,
/// Volume Unique Key (16 bytes). `None` when keys were resolved
/// via the [`KeySource::KeyDbUnitKeys`] path — that source delivers
/// via the [`KeyOrigin::KeyDbUnitKeys`] path — that source delivers
/// pre-decrypted unit keys without a VUK to derive them from.
pub vuk: Option<[u8; 16]>,
/// Decrypted unit keys (CPS unit number, key)
@@ -893,6 +966,13 @@ pub struct AacsState {
pub read_data_key: Option<[u8; 16]>,
/// Volume ID (16 bytes) -- from SCSI handshake
pub volume_id: [u8; 16],
/// Raw `Unit_Key_RO.inf` bytes (encrypted unit keys + CPS map). Stashed at
/// scan so an external resolver (key-resolver) can derive the unit keys
/// from a VUK without re-reading the disc. Empty when not captured.
pub uk_ro: Vec<u8>,
/// Raw MKB bytes (`MKB_RO.inf`). Stashed at scan so an external resolver can
/// walk it (device/processing key → media key). Empty when not captured.
pub mkb: Vec<u8>,
}
/// How AACS keys were resolved. Variants are ordered root-of-trust →
@@ -900,7 +980,7 @@ pub struct AacsState {
/// attempts derivation from the strongest input it has first and falls
/// back toward pre-computed per-disc material.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum KeySource {
pub enum KeyOrigin {
/// MKB + device keys → subset-difference tree → VUK
DeviceKey,
/// MKB + processing keys → media key → VUK
@@ -917,74 +997,46 @@ pub enum KeySource {
ExternalUk,
}
impl KeySource {
impl KeyOrigin {
pub fn name(&self) -> &'static str {
match self {
KeySource::DeviceKey => "MKB + device key",
KeySource::ProcessingKey => "MKB + processing key",
KeySource::KeyDbDerived => "KEYDB (derived)",
KeySource::KeyDb => "KEYDB",
KeySource::KeyDbUnitKeys => "KEYDB (unit keys)",
KeySource::ExternalUk => "external UK",
KeyOrigin::DeviceKey => "MKB + device key",
KeyOrigin::ProcessingKey => "MKB + processing key",
KeyOrigin::KeyDbDerived => "KEYDB (derived)",
KeyOrigin::KeyDb => "KEYDB",
KeyOrigin::KeyDbUnitKeys => "KEYDB (unit keys)",
KeyOrigin::ExternalUk => "external UK",
}
}
}
// ─── Disc scanning ──────────────────────────────────────────────────────────
/// Standard KEYDB.cfg search locations (compatible with libaacs).
const KEYDB_SEARCH_PATHS: &[&str] = &[
".config/aacs/KEYDB.cfg", // libaacs standard path
".config/freemkv/keydb.cfg", // freemkv download path
];
const KEYDB_SYSTEM_PATH: &str = "/etc/aacs/KEYDB.cfg";
/// Options for disc scanning.
#[derive(Default)]
pub struct ScanOptions {
/// Path to KEYDB.cfg for AACS key lookup.
/// If None, searches standard locations ($HOME/.config/aacs/ and /etc/aacs/).
pub keydb_path: Option<std::path::PathBuf>,
/// Caller-supplied Unit Key — an alternative to keydb lookup. When set,
/// libfreemkv skips keydb lookup and all derivation and uses this key
/// directly to decrypt; it takes precedence over `keydb_path`. The caller
/// obtains the key however it likes; libfreemkv stays free of any network
/// dependency.
pub unit_key: Option<[u8; 16]>,
/// Disable KEYDB entirely: skip both the explicit `keydb_path` and the
/// standard-location search, so no keydb is loaded for this scan. A caller
/// that resolves keys out-of-band (e.g. a remote key service) sets this so
/// a keydb that merely happens to sit in a default location does not shadow
/// the out-of-band path. `unit_key` still takes precedence over everything.
pub disable_keydb: bool,
/// AACS host credentials for the live-drive authenticated handshake.
///
/// Optional and source-agnostic: an unlocked / LibreDrive drive uses the OEM
/// Volume-ID path and needs none, and an ISO scan has no handshake at all. The
/// caller supplies the host cert(s) from wherever it likes — today the keydb's
/// `host_certs()`, tomorrow a cert file or built-in. Decoupled from the key
/// source: a locked drive needs the cert to unlock even when the decryption key
/// comes from an online service.
#[derive(Default, Clone)]
pub struct DriveCredentials {
/// Host certificate(s) + private key(s) for the SCSI AACS handshake.
pub host_certs: Vec<crate::aacs::HostCert>,
}
impl ScanOptions {
/// Resolve KEYDB path: explicit path first, then standard locations.
/// Returns `None` when `disable_keydb` is set — no keydb is consulted.
fn resolve_keydb(&self) -> Option<std::path::PathBuf> {
if self.disable_keydb {
return None;
}
if let Some(p) = &self.keydb_path {
if p.exists() {
return Some(p.clone());
}
}
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
for relative in KEYDB_SEARCH_PATHS {
let p = std::path::PathBuf::from(&home).join(relative);
if p.exists() {
return Some(p);
}
}
}
let p = std::path::PathBuf::from(KEYDB_SYSTEM_PATH);
if p.exists() {
return Some(p);
}
None
}
/// Options for disc scanning.
///
/// libfreemkv is lookup-free — it resolves no keys. The caller resolves a key
/// out-of-band (a key source) and applies it via [`Disc::decrypt_with`]. The
/// only scan input is the optional drive credentials for the live-drive
/// authenticated handshake.
#[derive(Default)]
pub struct ScanOptions {
/// Host credentials for the live-drive AACS handshake. `None` for an
/// unlocked / LibreDrive drive (OEM Volume-ID path) and for ISO scans.
pub credentials: Option<DriveCredentials>,
}
/// Quick disc identification — name, format, capacity. No title/stream parsing.
@@ -1226,7 +1278,7 @@ impl Disc {
capacity: u32,
handshake: Option<HandshakeResult>,
handshake_error: Option<Error>,
opts: &ScanOptions,
_opts: &ScanOptions,
udf_fs: udf::UdfFs,
) -> Result<Self> {
// 2. Resolve encryption (AACS, CSS, or none)
@@ -1235,69 +1287,16 @@ impl Disc {
let (aacs, aacs_error) = if !encrypted {
(None, None)
} else if let Some(unit_key) = opts.unit_key {
// Second key source: caller supplied the Unit Key directly
// (external Unit Key). Skip keydb entirely.
match Self::resolve_encryption_static(&udf_fs, reader, unit_key, handshake.as_ref()) {
Ok(state) => (Some(state), None),
Err(e) => (None, Some(e)),
}
} else if opts.disable_keydb {
// Keydb disabled: keys are resolved out-of-band. Still capture the
// VID (read during the handshake) so the out-of-band path has it;
// carry no keys (disc reports "encrypted, no keys" until re-scanned
// with a resolved Unit Key).
} else {
// Lookup-free: capture the disc's AACS inputs (MKB, VID,
// Unit_Key_RO.inf) but resolve NO key. The caller resolves a Key
// from a key source and applies it via `Disc::decrypt_with`. The
// disc reports "encrypted, no keys" until then.
match Self::resolve_vid_only(&udf_fs, reader, handshake.as_ref()) {
Ok(state) => (Some(state), None),
Err(e) => (None, Some(e)),
}
} else {
match opts.resolve_keydb() {
Some(keydb_path) => {
match Self::resolve_encryption(&udf_fs, reader, &keydb_path, handshake.as_ref())
{
Ok(state) => (Some(state), None),
Err(e) => {
// When the handshake itself failed AND resolution
// bottomed out at "no keys", surface the upstream
// handshake failure — it's more actionable than
// the generic AacsNoKeys.
let final_err = match (&e, handshake_error.as_ref()) {
(
Error::AacsNoKeys
| Error::AacsVukNotInKeydb
| Error::AacsVidUnavailable,
Some(_),
) => handshake_error.unwrap(),
_ => e,
};
tracing::warn!(
target: "freemkv::disc",
phase = "scan_aacs_resolve_failed",
error_code = final_err.code(),
keydb = %keydb_path.display(),
handshake_ok = handshake.is_some(),
"AACS key resolution failed"
);
(None, Some(final_err))
}
}
}
None => {
tracing::warn!(
target: "freemkv::disc",
phase = "scan_aacs_no_keydb",
"encrypted disc but no KEYDB found in search paths"
);
// Sentinel path string lets autorip's message switch
// distinguish "no keydb found anywhere" from "keydb at
// <path> failed to parse".
let final_err =
handshake_error.unwrap_or_else(|| crate::error::Error::KeydbLoad {
path: String::from("<no keydb in search paths>"),
});
(None, Some(final_err))
}
// A handshake failure (no VID) is more actionable than the
// generic capture error, so surface it when present.
Err(e) => (None, Some(handshake_error.unwrap_or(e))),
}
};
@@ -1430,6 +1429,44 @@ impl Disc {
.cmp(&b_oversize)
.then_with(|| a.clips.len().cmp(&b.clips.len()))
.then_with(|| b.duration_secs.total_cmp(&a.duration_secs))
// Same length + clip count = the same feature authored as multiple
// playlists (a full-audio main vs an audio-reduced twin, e.g. Fight
// Club's 00800 [DTS-HD MA + 13 tracks] vs 00004 [stereo AC-3 only]).
// Prefer the richer audio so we never rip a stereo-only variant over
// the lossless-multichannel main feature.
.then_with(|| Self::audio_richness(b).cmp(&Self::audio_richness(a)))
}
/// Audio-richness rank for `canonical_title_order`'s same-length tiebreak.
/// Higher is better: `(any lossless track, best channel count, audio count)`.
fn audio_richness(t: &DiscTitle) -> (u8, u8, usize) {
let mut lossless = 0u8;
let mut max_ch = 0u8;
let mut count = 0usize;
for s in &t.streams {
if let Stream::Audio(a) = s {
count += 1;
if matches!(
a.codec,
Codec::TrueHd | Codec::DtsHdMa | Codec::DtsHdHr | Codec::Lpcm | Codec::Flac
) {
lossless = 1;
}
let ch = match a.channels {
AudioChannels::Surround71 => 8,
AudioChannels::Surround61 => 7,
AudioChannels::Surround51 => 6,
AudioChannels::Surround50 => 5,
AudioChannels::Quad => 4,
AudioChannels::Stereo21 => 3,
AudioChannels::Stereo => 2,
AudioChannels::Mono => 1,
AudioChannels::Unknown => 0,
};
max_ch = max_ch.max(ch);
}
}
(lossless, max_ch, count)
}
fn detect_format(titles: &[DiscTitle]) -> DiscFormat {
@@ -1485,16 +1522,36 @@ impl Disc {
/// AACS-chain steps it can from disc-read inputs (MKB / VID / `Unit_Key_RO.inf`).
///
/// `#[non_exhaustive]`: AACS is a derivation chain
/// (`DK →(MKB)→ MK →(VID)→ VK →(Unit_Key_RO)→ UK`). Today only the final
/// [`Key::Unit`] entry point is wired; the higher entry points
/// (device / media / volume key) land as the derivation is lifted out of the
/// keydb-coupled scan path. Adding them is non-breaking thanks to
/// `#[non_exhaustive]`.
/// (`DK →(MKB)→ MK →(VID)→ VK →(Unit_Key_RO)→ UK`). Each variant is an entry
/// point at one level of that chain; [`Disc::decrypt_with`] derives down from
/// it to the per-CPS unit keys. New levels can be added without breaking
/// callers.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum Key {
/// Device key(s) (AACS DK, positioned). libfreemkv walks the MKB
/// (subset-difference tree) to find the one that applies → media key →
/// VUK → unit keys. A source hands in its FULL device-key set, because
/// choosing which one applies *is* the MKB walk (derivation), and all
/// derivation lives here — never in a source.
Device(Vec<crate::aacs::keydb::DeviceKey>),
/// Processing key(s) (AACS PK). libfreemkv applies each against the MKB
/// → media key → VUK → unit keys.
Processing(Vec<[u8; 16]>),
/// Media key candidate(s) (Km). A source hands its full pool because an MK
/// is MKB-scoped (shared across a pressing/MKB family) — picking the one
/// that applies is `km_verifies` against this disc's MKB, which is
/// derivation, so it lives here. libfreemkv verifies, then derives the VUK
/// via the Volume ID and the per-CPS-unit keys.
Media(Vec<[u8; 16]>),
/// Volume Unique Key (VK / VUK). libfreemkv decrypts `Unit_Key_RO.inf`
/// into the per-CPS-unit keys. NOT terminal — the chain continues to the
/// unit keys.
Volume([u8; 16]),
/// Final per-CPS-unit AACS keys (`(cps_unit, 16-byte key)`). A key source
/// (keydb / key server) resolved these, or they were cached in the mapfile
/// at sweep; libfreemkv decrypts directly with no further derivation.
/// at sweep; libfreemkv decrypts directly with no further derivation. This
/// is the terminal level every other variant derives down into.
Unit(Vec<(u32, [u8; 16])>),
}
@@ -1543,18 +1600,20 @@ impl Disc {
pub fn inject_unit_keys(&mut self, keys: Vec<(u32, [u8; 16])>) {
if let Some(aacs) = self.aacs.as_mut() {
aacs.unit_keys = keys;
aacs.key_source = KeySource::ExternalUk;
aacs.key_source = KeyOrigin::ExternalUk;
} else if self.encrypted && self.css.is_none() {
self.aacs = Some(AacsState {
version: if self.format == DiscFormat::Uhd { 2 } else { 1 },
bus_encryption: self.format == DiscFormat::Uhd,
mkb_version: None,
disc_hash: String::new(),
key_source: KeySource::ExternalUk,
key_source: KeyOrigin::ExternalUk,
vuk: None,
unit_keys: keys,
read_data_key: None,
volume_id: [0u8; 16],
uk_ro: Vec::new(),
mkb: Vec::new(),
});
// The prior resolution error (e.g. KeydbLoad) is now moot — we have
// the decryption key. Clear it so callers don't treat the disc as
@@ -1563,6 +1622,22 @@ impl Disc {
}
}
/// The public AACS inputs for this disc, for a [`crate::KeySource`] to look
/// a key up. `None` when the disc carries no AACS state (unencrypted, CSS,
/// or AACS inputs not captured at scan). Contains no secrets — just disc
/// identity plus the on-disc AACS structures.
pub fn inputs(&self) -> Option<crate::keysource::DiscInputs> {
self.aacs.as_ref().map(|a| crate::keysource::DiscInputs {
disc_hash: a.disc_hash.clone(),
volume_id: a.volume_id,
mkb: a.mkb.clone(),
unit_key_ro: a.uk_ro.clone(),
// Content samples need the disc reader, which scan does not retain;
// the caller fills these for sources that validate against ciphertext.
samples: Vec::new(),
})
}
/// Apply a caller-resolved [`Key`] so [`Self::decrypt_keys`] yields usable
/// decryption state. **Lookup-free**: no keydb, no network — the caller
/// (an application, via a key source) does all resolution and hands the key
@@ -1570,12 +1645,85 @@ impl Disc {
/// unit keys came from a key source or the mapfile cache, and libfreemkv
/// decrypts directly (see [`Self::inject_unit_keys`]).
pub fn decrypt_with(&mut self, key: Key) -> Result<()> {
match key {
Key::Unit(keys) => {
self.inject_unit_keys(keys);
Ok(())
}
// Unit keys are terminal — no derivation. Inject directly (resume /
// mapfile cache / a source that already had the final UKs).
if let Key::Unit(keys) = key {
self.inject_unit_keys(keys);
return Ok(());
}
// Every higher level derives DOWN to the unit keys, reusing the
// version-dispatched resolver — the single home for all AACS
// derivation (1.0 / 2.0 / 2.1 / 2.x). It needs the AACS inputs
// (Unit_Key_RO.inf, MKB, VID) stashed on the disc at scan time.
let aacs = self.aacs.as_ref().ok_or(crate::error::Error::AacsNoKeys)?;
if aacs.uk_ro.is_empty() {
// Scan captured no Unit_Key_RO.inf — nothing to derive into.
return Err(crate::error::Error::AacsNoKeys);
}
// Map the supplied Key -> raw material. The source handed in material
// at exactly one level; choosing/applying it is the resolver's job.
let mut supplied = crate::aacs::provider::SuppliedKey {
device_keys: Vec::new(),
processing_keys: Vec::new(),
media_keys: Vec::new(),
disc_entry: None,
};
match key {
Key::Device(dks) => supplied.device_keys = dks,
Key::Processing(pks) => supplied.processing_keys = pks,
Key::Media(mks) => supplied.media_keys = mks,
Key::Volume(vuk) => {
supplied.disc_entry = Some(crate::aacs::DiscEntry {
disc_hash: aacs.disc_hash.clone(),
title: String::new(),
media_key: None,
disc_id: None,
vuk: Some(vuk),
unit_keys: Vec::new(),
});
}
Key::Unit(_) => unreachable!("Key::Unit handled above"),
}
// Snapshot inputs (releases the &self borrow before the &mut below).
let volume_id = aacs.volume_id;
let mkb = aacs.mkb.clone();
let uk_ro = aacs.uk_ro.clone();
let version_u8 = aacs.version;
let provider_refs: [&dyn crate::aacs::KeyProvider; 1] = [&supplied];
let ctx = crate::aacs::ResolveContext {
unit_key_ro: &uk_ro,
content_cert: None,
volume_id: &volume_id,
providers: &provider_refs,
mkb: if mkb.is_empty() { None } else { Some(&mkb) },
};
// Version dispatch — V10 uses the classical resolver at 48-byte
// stride; V20/V21 share the 64-byte stride, so try the classical V20
// paths first and fall back to the 2.1 variant chain.
let resolved = match version_u8 {
1 => crate::aacs::resolve_keys_v1(&ctx),
_ => crate::aacs::resolve_keys_v2(&ctx).or_else(|| crate::aacs::resolve_keys_v21(&ctx)),
}
.ok_or(crate::error::Error::AacsKeyRejected)?;
if resolved.unit_keys.is_empty() {
return Err(crate::error::Error::AacsKeyRejected);
}
let a = self.aacs.as_mut().expect("aacs present (checked above)");
a.unit_keys = resolved.unit_keys;
if resolved.vuk.is_some() {
a.vuk = resolved.vuk;
}
a.key_source = KeyOrigin::ExternalUk;
// A prior scan-time resolution error (e.g. keyless scan) is now moot.
self.aacs_error = None;
Ok(())
}
/// Copy disc sectors to an ISO image file.
@@ -2816,7 +2964,7 @@ mod tests {
);
assert_eq!(
disc.aacs.as_ref().unwrap().key_source,
KeySource::ExternalUk
KeyOrigin::ExternalUk
);
}
@@ -2828,11 +2976,13 @@ mod tests {
bus_encryption: true,
mkb_version: None,
disc_hash: String::new(),
key_source: KeySource::DeviceKey,
key_source: KeyOrigin::DeviceKey,
vuk: None,
unit_keys,
read_data_key: None,
volume_id: [0u8; 16],
uk_ro: Vec::new(),
mkb: Vec::new(),
}
}
@@ -2866,10 +3016,101 @@ mod tests {
}
assert_eq!(
disc.aacs.as_ref().unwrap().key_source,
KeySource::ExternalUk
KeyOrigin::ExternalUk
);
}
/// Build a minimal valid `Unit_Key_RO.inf` carrying the given encrypted
/// unit keys at the V20 (64-byte) stride. Header is inert (no titles); only
/// the key-storage area matters for `parse_unit_key_ro`.
fn uk_ro_v20(enc_keys: &[[u8; 16]]) -> Vec<u8> {
let uk_pos = 32usize;
let keys_start = uk_pos + 48;
let stride = 64usize;
let mut data = vec![0u8; keys_start + enc_keys.len().max(1) * stride];
data[0..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
data[uk_pos..uk_pos + 2].copy_from_slice(&(enc_keys.len() as u16).to_be_bytes());
for (i, k) in enc_keys.iter().enumerate() {
let off = keys_start + i * stride;
data[off..off + 16].copy_from_slice(k);
}
data
}
#[test]
fn decrypt_with_volume_derives_per_cps_unit_keys() {
// A Volume key (VUK) is NOT terminal — the lib must decrypt
// Unit_Key_RO.inf into ONE unit key per CPS unit. Oracle = the lib's
// own decrypt_unit_key, so this pins the derive-down WIRING (Volume →
// per-CPS Unit), not the cipher.
let vuk = [0x5au8; 16];
let enc0 = [0x12u8; 16];
let enc1 = [0x34u8; 16];
let exp0 = crate::aacs::decrypt_unit_key(&vuk, &enc0);
let exp1 = crate::aacs::decrypt_unit_key(&vuk, &enc1);
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
let mut a = aacs_with(Vec::new());
a.uk_ro = uk_ro_v20(&[enc0, enc1]);
disc.aacs = Some(a);
disc.decrypt_with(Key::Volume(vuk)).unwrap();
match disc.decrypt_keys() {
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => {
assert_eq!(
unit_keys,
vec![(1u32, exp0), (2u32, exp1)],
"VUK must decrypt EACH CPS unit's encrypted key (does not stop at VK)"
);
}
_ => panic!("expected Aacs decrypt keys after Volume-key derive-down"),
}
assert_eq!(
disc.aacs.as_ref().unwrap().key_source,
KeyOrigin::ExternalUk
);
}
#[test]
fn decrypt_with_higher_key_without_inputs_errors() {
// A non-Unit key needs the AACS inputs (Unit_Key_RO.inf) stashed at
// scan. Without them the lib cannot derive — surfaces AacsNoKeys, not a
// panic and not a silent keyless "success".
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
disc.aacs = Some(aacs_with(Vec::new())); // uk_ro empty
assert!(matches!(
disc.decrypt_with(Key::Volume([0x11u8; 16])).unwrap_err(),
crate::error::Error::AacsNoKeys
));
// No AACS state at all → same.
let mut disc2 = make_test_disc(1000, "UHD");
disc2.encrypted = true;
assert!(matches!(
disc2
.decrypt_with(Key::Media(vec![[0x22u8; 16]]))
.unwrap_err(),
crate::error::Error::AacsNoKeys
));
}
#[test]
fn decrypt_with_volume_yielding_no_units_is_rejected() {
// A key that produces zero unit keys (here: an empty key-storage area)
// is a rejection, not a silent empty success.
let mut disc = make_test_disc(1000, "UHD");
disc.encrypted = true;
let mut a = aacs_with(Vec::new());
a.uk_ro = uk_ro_v20(&[]); // num_uk = 0
disc.aacs = Some(a);
assert!(matches!(
disc.decrypt_with(Key::Volume([0x11u8; 16])).unwrap_err(),
crate::error::Error::AacsKeyRejected
));
}
#[test]
fn decrypt_with_unit_key_yields_decrypt_keys() {
// The public lookup-free entry point: hand libfreemkv a Key::Unit and
+69
View File
@@ -0,0 +1,69 @@
//! 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;
use crate::error::Result;
/// The public AACS inputs a key source needs to look a disc up. Captured at
/// scan; contains no secrets — only the disc identity and the on-disc AACS
/// structures a source or key server may key on.
#[derive(Debug, Clone)]
pub struct DiscInputs {
/// SHA-1 of `Unit_Key_RO.inf`, `0x`-prefixed hex. The value a keydb keys
/// its per-disc entries by, and a key server identifies the disc with.
pub disc_hash: String,
/// Volume ID (16 bytes). `[0u8; 16]` when no authenticated handshake ran
/// (e.g. an ISO/mapfile flow), which disables VID-keyed lookups.
pub volume_id: [u8; 16],
/// Raw MKB bytes. Empty when not captured.
pub mkb: Vec<u8>,
/// Raw `Unit_Key_RO.inf` bytes. Empty when not captured.
pub unit_key_ro: Vec<u8>,
/// Encrypted on-disc content sample units (each a 6144-byte aligned unit),
/// for sources that validate a key server-side against real ciphertext
/// (e.g. an online key service). Empty for sources that don't need them
/// (a local keydb). Populated by the application — reading content requires
/// the disc reader, which the library's scan does not retain — so
/// [`crate::Disc::inputs`] leaves it empty for the caller to fill.
pub samples: Vec<Vec<u8>>,
}
/// A key source: given a disc's [`DiscInputs`], offer candidate [`Key`]s.
///
/// Dumb by contract — a source queries its backing store and enumerates the raw
/// material it holds as candidate keys at whatever level it has (device /
/// processing / media / volume / unit). It performs NO AACS derivation and NO
/// validation; `Disc::decrypt_with` derives down, and the caller validates by
/// decrypting a sample. That keeps every derivation step in one place (the
/// library) across AACS 1.0 / 2.0 / 2.1 / 2.x.
///
/// A source returns *multiple ordered candidates* because a single store can
/// hold material for several derivation paths (a keydb has a per-disc VUK *and*
/// a device-key pool *and* a media-key pool — the source can't know which
/// applies without the MKB walk, which is derivation). The caller tries the
/// candidates in order and keeps the first that decrypts (validate-before-
/// return). A source that resolves server-side (an online key service) or holds
/// a cached final key (the mapfile) simply returns one candidate.
pub trait KeySource {
/// Candidate keys for this disc, most-specific first. Empty = this source
/// has nothing; `Err(_)` = the source itself failed (I/O, network, parse).
fn resolve(&self, inputs: &DiscInputs) -> Result<Vec<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
}
}
+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, Key, 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
};
+74 -16
View File
@@ -64,23 +64,40 @@ impl CodecParser for DtsParser {
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
}
// Include the DTS-HD extension substream if one immediately follows
// the core. The extension carries the LOSSLESS (DTS-HD MA / HRA)
// data; emitting a core-only frame and dropping the trailing
// extension silently downgrades the track to lossy DTS core. If we
// can't yet tell whether an extension follows, or it's present but
// not fully buffered, WAIT for more PES data (break, leaving `pos`
// at this access unit's core sync so the buffer keeps the partial
// unit) rather than splitting the extension off and losing it.
let after = start + core_size;
let avail = data.len() - after;
// Does a DTS-HD extension substream follow the core? Match the full
// sync when it's buffered, or a partial PREFIX when the buffer ends
// mid-sync — so we wait for the rest instead of splitting the
// extension off and losing the lossless data. Nothing after the core
// (e.g. the final access unit / EOF, with no parser flush) is taken
// as a legitimate lossy core-only unit.
let ext_follows = if avail >= 4 {
data[after..after + 4] == DTS_HD_EXT_SYNC
} else if avail > 0 {
DTS_HD_EXT_SYNC[..avail] == data[after..]
} else {
false
};
let total_size = if !ext_follows {
core_size
} else if avail < 9 {
break; // extension present but its size header isn't buffered — wait
} else {
let ext_size = dts_hd_ext_frame_size(&data[after..]);
if ext_size == 0 || avail < ext_size {
break; // extension known but not fully buffered — wait
}
}
core_size + ext_size
};
frames.push(Frame {
pts_ns,
@@ -204,6 +221,47 @@ mod tests {
assert_eq!(frames[0].data.len(), 512);
}
/// Build a DTS-HD extension substream of `size` bytes with a valid sync +
/// size header (matching `dts_hd_ext_frame_size`).
fn make_dts_ext(size: usize) -> Vec<u8> {
let raw = size - 1;
let mut e = vec![0u8; size];
e[0..4].copy_from_slice(&DTS_HD_EXT_SYNC);
e[6] = ((raw >> 11) & 0x1F) as u8;
e[7] = ((raw >> 3) & 0xFF) as u8;
e[8] = (((raw & 0x07) << 5) as u8) | (e[8] & 0x1F);
e
}
#[test]
fn keeps_dts_hd_extension_across_pes_boundary() {
// DTS-HD MA access unit = core + extension substream. When the
// extension straddles a PES boundary, the parser must WAIT and emit the
// full unit — not a core-only frame (which would drop the lossless
// data, the Dunkirk lossy-core bug).
let mut parser = DtsParser::new();
let core = make_dts_core(512);
let ext = make_dts_ext(256);
let mut au = core;
au.extend_from_slice(&ext); // 768-byte access unit
// Split mid-extension: first PES carries the core + 100 ext bytes.
let split = 512 + 100;
let f1 = parser.parse(&make_pes(au[..split].to_vec(), Some(90000)));
assert!(
f1.is_empty(),
"must wait for the full extension, not emit a core-only frame"
);
let f2 = parser.parse(&make_pes(au[split..].to_vec(), Some(90000)));
assert_eq!(f2.len(), 1);
assert_eq!(
f2[0].data.len(),
768,
"frame must include core + extension (lossless preserved)"
);
}
#[test]
fn codec_private_none() {
let parser = DtsParser::new();
+69
View File
@@ -153,6 +153,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 +293,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));
}
}
+6
View File
@@ -362,6 +362,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;
+54
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,6 +101,13 @@ 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
},
}
}
@@ -128,6 +153,7 @@ impl MkvTrack {
sample_rate: sr,
channels: ch,
bit_depth: 0,
dv_config: None,
}
}
@@ -156,6 +182,7 @@ impl MkvTrack {
sample_rate: 0.0,
channels: 0,
bit_depth: 0,
dv_config: None,
}
}
}
@@ -333,6 +360,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)?;
@@ -612,6 +650,7 @@ mod tests {
sample_rate: 0.0,
channels: 0,
bit_depth: 0,
dv_config: None,
}
}
@@ -636,9 +675,24 @@ mod tests {
sample_rate: 48000.0,
channels: 6,
bit_depth: 0,
dv_config: None,
}
}
#[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());
+26 -12
View File
@@ -162,10 +162,11 @@ 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,
@@ -184,11 +185,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 +192,19 @@ 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() {
disc.decrypt_with(crate::disc::Key::Unit(opts.unit_keys.clone()))
.map_err(|e| -> io::Error { e.into() })?;
}
if disc.titles.is_empty() {
return Err(crate::error::Error::NoStreams.into());
}
@@ -209,8 +216,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
+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));
}