mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e04d79c593 | ||
|
|
b34af1fa74 | ||
|
|
a7317f8885 | ||
|
|
474273afc0 | ||
|
|
0a2bab5789 | ||
|
|
fa4d7ef871 | ||
|
|
36d1af1b7f | ||
|
|
b96f6206fe | ||
|
|
bb32fb993b | ||
|
|
c1eb74dfa5 | ||
|
|
46838c63ca | ||
|
|
1e60220ff6 | ||
|
|
080f03e8ed | ||
|
|
35de6101d4 | ||
|
|
9012101573 | ||
|
|
8bc1de6c9b | ||
|
|
d94a4d3444 |
+1
-1
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -207,14 +207,41 @@ pub(crate) fn apply_labels(labels: &[StreamLabel], titles: &mut [DiscTitle]) {
|
||||
// Structured fields — callers translate purpose to UI text.
|
||||
a.purpose = label.purpose;
|
||||
|
||||
// Codec descriptor: trust the parser's `codec_hint` ONLY
|
||||
// when it's consistent with the stream's actual codec — it
|
||||
// may legitimately be richer (e.g. "Dolby Atmos" on a TrueHD
|
||||
// stream, which the raw spec codec can't express). If the
|
||||
// hint CONTRADICTS the stream (a mis-bound / shuffled label,
|
||||
// e.g. "AC-3 2.0" on a TrueHD track, or "TrueHD" on a DD+
|
||||
// track), discard it and derive the descriptor from the
|
||||
// stream itself — that's correct per-stream and can never be
|
||||
// shuffled. An empty hint is left for `fill_defaults`.
|
||||
let codec_desc = if label.codec_hint.is_empty() {
|
||||
// No codec hint — leave for fill_defaults.
|
||||
String::new()
|
||||
} else if !codec_hint_consistent(&label.codec_hint, &a.codec) {
|
||||
// Hint contradicts the stream (mis-bound / shuffled):
|
||||
// derive from the stream itself.
|
||||
generate_audio_label(&a.codec, &a.channels, a.secondary)
|
||||
} else if codec_hint_adds_detail(&label.codec_hint) {
|
||||
// Consistent AND richer than the spec codec can express
|
||||
// (e.g. "Dolby Atmos", "DTS:X") — keep the parser's hint.
|
||||
label.codec_hint.clone()
|
||||
} else {
|
||||
// Consistent but a plain codec/channel restatement —
|
||||
// normalize to the stream's own marketing descriptor so
|
||||
// styling is uniform across tracks.
|
||||
generate_audio_label(&a.codec, &a.channels, a.secondary)
|
||||
};
|
||||
|
||||
// a.label only carries codec/variant info. NEVER any
|
||||
// English purpose text — the CLI handles that via i18n.
|
||||
let mut parts = Vec::new();
|
||||
if !label.variant.is_empty() {
|
||||
parts.push(format!("({})", label.variant));
|
||||
}
|
||||
if !label.codec_hint.is_empty() {
|
||||
parts.push(label.codec_hint.clone());
|
||||
if !codec_desc.is_empty() {
|
||||
parts.push(codec_desc);
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
a.label = parts.join(" ");
|
||||
@@ -322,7 +349,70 @@ fn generate_video_label(
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
fn generate_audio_label(
|
||||
/// Does the parser's `codec_hint` name a codec consistent with the stream's
|
||||
/// actual `codec`? [`apply_labels`] uses this to keep richer-but-consistent
|
||||
/// hints (e.g. "Dolby Atmos" on a TrueHD stream — Atmos is a TrueHD extension
|
||||
/// the raw spec codec can't express) while rejecting mis-bound ones (e.g.
|
||||
/// "AC-3 2.0" on a TrueHD stream, the shuffled-label bug). Matching is by codec
|
||||
/// FAMILY parsed out of the hint string. "Atmos" with no carrier named is
|
||||
/// treated as compatible with its lossless carriers (TrueHD / E-AC-3). A hint
|
||||
/// naming no recognizable codec family (pure editorial, e.g. "Commentary") is
|
||||
/// consistent — it isn't asserting a codec.
|
||||
fn codec_hint_consistent(hint: &str, codec: &crate::disc::Codec) -> bool {
|
||||
use crate::disc::Codec;
|
||||
let h = hint.to_ascii_lowercase();
|
||||
|
||||
let says_truehd = h.contains("truehd") || h.contains("true hd");
|
||||
let says_ddp = h.contains("ac-3+")
|
||||
|| h.contains("ac3+")
|
||||
|| h.contains("e-ac-3")
|
||||
|| h.contains("eac-3")
|
||||
|| h.contains("eac3")
|
||||
|| h.contains("digital plus")
|
||||
|| h.contains("dd+");
|
||||
let says_ac3 =
|
||||
!says_ddp && (h.contains("ac-3") || h.contains("ac3") || h.contains("dolby digital"));
|
||||
let says_dts_ma = h.contains("master audio") || h.contains("hd ma");
|
||||
let says_dts_hr = h.contains("high resolution") || h.contains("hd hr");
|
||||
let says_dts = !says_dts_ma && !says_dts_hr && h.contains("dts");
|
||||
let says_lpcm = h.contains("lpcm") || h.contains("pcm");
|
||||
let says_atmos = h.contains("atmos");
|
||||
|
||||
let names_family =
|
||||
says_truehd || says_ddp || says_ac3 || says_dts_ma || says_dts_hr || says_dts || says_lpcm;
|
||||
|
||||
// Pure-editorial hint (no codec family named) isn't asserting a codec →
|
||||
// consistent. "Atmos" alone implies a lossless carrier (TrueHD or DD+).
|
||||
if !names_family {
|
||||
return if says_atmos {
|
||||
matches!(codec, Codec::TrueHd | Codec::Ac3Plus)
|
||||
} else {
|
||||
true
|
||||
};
|
||||
}
|
||||
|
||||
match codec {
|
||||
Codec::TrueHd => says_truehd || says_atmos,
|
||||
Codec::Ac3Plus => says_ddp || says_atmos,
|
||||
Codec::Ac3 => says_ac3,
|
||||
Codec::DtsHdMa => says_dts_ma,
|
||||
Codec::DtsHdHr => says_dts_hr,
|
||||
Codec::Dts => says_dts,
|
||||
Codec::Lpcm => says_lpcm,
|
||||
// Unknown / other stream codec — don't second-guess the parser's hint.
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the hint carry object-audio detail the spec codec can't express
|
||||
/// (Atmos / DTS:X)? Such hints are kept verbatim; plain codec/channel hints are
|
||||
/// normalized to the stream's own descriptor for uniform styling across tracks.
|
||||
fn codec_hint_adds_detail(hint: &str) -> bool {
|
||||
let h = hint.to_ascii_lowercase();
|
||||
h.contains("atmos") || h.contains("dts:x") || h.contains("dts-x") || h.contains("dtsx")
|
||||
}
|
||||
|
||||
pub(crate) fn generate_audio_label(
|
||||
codec: &crate::disc::Codec,
|
||||
channels: &crate::disc::AudioChannels,
|
||||
_secondary: bool,
|
||||
@@ -1182,6 +1272,94 @@ mod apply_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_rejects_mismatched_codec_hint_and_uses_stream_codec() {
|
||||
// Paddington case: a TrueHD+Atmos main track the parser mislabeled
|
||||
// "AC-3 2.0" (a compat-core hint bound to the wrong stream). The hint
|
||||
// contradicts the stream's real codec → discard it, use the stream's own.
|
||||
let mut titles = vec![title_with(vec![audio(
|
||||
0x1100,
|
||||
Codec::TrueHd,
|
||||
AudioChannels::Surround71,
|
||||
"eng",
|
||||
)])];
|
||||
let labels = vec![audio_label(1, "eng", "AC-3 2.0", "")];
|
||||
apply_labels(&labels, &mut titles);
|
||||
if let Stream::Audio(a) = &titles[0].streams[0] {
|
||||
assert_eq!(a.label, "Dolby TrueHD 7.1");
|
||||
} else {
|
||||
panic!("expected audio stream");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_unshuffles_cross_labeled_streams() {
|
||||
// Wicked case: hints fully cross-bound — a TrueHD stream wears "AC-3 5.1"
|
||||
// and a DD+ stream wears "TrueHD 5.1". Each is corrected from its own
|
||||
// stream codec, eliminating the shuffle.
|
||||
let mut titles = vec![title_with(vec![
|
||||
audio(0x1100, Codec::TrueHd, AudioChannels::Surround51, "eng"),
|
||||
audio(0x1101, Codec::Ac3Plus, AudioChannels::Surround51, "spa"),
|
||||
])];
|
||||
let labels = vec![
|
||||
audio_label(1, "eng", "AC-3 5.1", ""),
|
||||
audio_label(2, "spa", "TrueHD 5.1", ""),
|
||||
];
|
||||
apply_labels(&labels, &mut titles);
|
||||
let got: Vec<String> = titles[0]
|
||||
.streams
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
if let Stream::Audio(a) = s {
|
||||
Some(a.label.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(got, vec!["Dolby TrueHD 5.1", "Dolby Digital Plus 5.1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_keeps_consistent_richer_atmos_hint() {
|
||||
// A DD+ Atmos stream legitimately labeled "Dolby Atmos" — the hint is
|
||||
// richer than the spec codec yet consistent with it, so it's kept.
|
||||
let mut titles = vec![title_with(vec![audio(
|
||||
0x1100,
|
||||
Codec::Ac3Plus,
|
||||
AudioChannels::Surround51,
|
||||
"eng",
|
||||
)])];
|
||||
let labels = vec![audio_label(1, "eng", "Dolby Atmos", "")];
|
||||
apply_labels(&labels, &mut titles);
|
||||
if let Stream::Audio(a) = &titles[0].streams[0] {
|
||||
assert_eq!(a.label, "Dolby Atmos");
|
||||
} else {
|
||||
panic!("expected audio stream");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_normalizes_plain_consistent_hint_to_marketing() {
|
||||
// Wicked's French track: a DD+ stream whose hint "AC-3+ 5.1" is correct
|
||||
// but short-form. A sibling DD+ track that fell back uses the marketing
|
||||
// form — keeping the short form here would read inconsistently, so a
|
||||
// plain (non-richer) consistent hint is normalized to the stream's own.
|
||||
let mut titles = vec![title_with(vec![audio(
|
||||
0x1100,
|
||||
Codec::Ac3Plus,
|
||||
AudioChannels::Surround51,
|
||||
"fra",
|
||||
)])];
|
||||
let labels = vec![audio_label(1, "fra", "AC-3+ 5.1", "")];
|
||||
apply_labels(&labels, &mut titles);
|
||||
if let Stream::Audio(a) = &titles[0].streams[0] {
|
||||
assert_eq!(a.label, "Dolby Digital Plus 5.1");
|
||||
} else {
|
||||
panic!("expected audio stream");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_sets_purpose_on_audio_commentary() {
|
||||
let mut titles = vec![title_with(vec![audio(
|
||||
@@ -1342,9 +1520,11 @@ mod apply_tests {
|
||||
if let Stream::Audio(a) = &titles[0].streams[1] {
|
||||
assert_eq!(a.label, "Dolby Atmos");
|
||||
}
|
||||
// Audio #2 (4th stream overall)
|
||||
// Audio #2 (4th stream overall). The plain "Dolby Digital" hint is
|
||||
// consistent with the AC-3 stream but carries no channel info, so it's
|
||||
// normalized to the stream's own uniform descriptor.
|
||||
if let Stream::Audio(a) = &titles[0].streams[3] {
|
||||
assert_eq!(a.label, "Dolby Digital");
|
||||
assert_eq!(a.label, "Dolby Digital 2.0");
|
||||
}
|
||||
// Subtitle #1
|
||||
if let Stream::Subtitle(s) = &titles[0].streams[2] {
|
||||
|
||||
+5
-3
@@ -85,6 +85,7 @@ pub(crate) mod identity;
|
||||
pub(crate) mod ifo;
|
||||
pub mod io;
|
||||
pub mod keydb;
|
||||
pub mod keysource;
|
||||
pub mod labels;
|
||||
pub(crate) mod mpls;
|
||||
pub mod mux;
|
||||
@@ -170,10 +171,11 @@ pub use decrypt::{DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_thr
|
||||
// prefix at the crate root to keep both addressable.
|
||||
pub use disc::{
|
||||
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DamageSeverity,
|
||||
Disc, DiscFormat, DiscId, DiscTitle, Extent, FrameRate, HdrFormat, 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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user