AACS pipeline reshape + TrueHD metadata + central consts + clippy/fmt clean

- AACS: delete in-lib keydb parser (Step 3); boil-down primitives
  (mk_from_dk/vuk_from_mk/uk_from_vuk) + newtypes; KeySource->get_uk(ctx)+
  ResolveCtx; Unlocker->unlock()->Result<Vid,UnlockError> + AacsCertUnlocker;
  OEM bus-key gate (AacsBusKeyUnavailable); structured ResolutionTrace (Step 4).
- TrueHD: sample-rate from major-sync, Atmos label, 44.1k AU duration.
- consts: central media/format constants module; 17 duplicate const-defs
  centralized (sector/TS-packet/source-packet); mpls stream-entry + category
  codes named.
- clippy --all-targets -D warnings clean (1.86); fmt clean; 2199 lib tests.
This commit is contained in:
Matthew Jackson
2026-06-26 12:19:24 -07:00
parent 05729f5dfe
commit decb87a250
48 changed files with 2208 additions and 2031 deletions
+2 -2
View File
@@ -583,7 +583,7 @@ mod tests {
}
/// One STN stream entry: stream_entry (len(1)=3, type(1)=0x01, pid(2))
/// + stream_attributes (len(1) + coding_type(1) + payload). Matches
/// plus stream_attributes (len(1) + coding_type(1) + payload). Matches
/// the mpls.rs test builders.
fn se_video(pid: u16, coding_type: u8) -> Vec<u8> {
let mut out = vec![3u8, 0x01];
@@ -962,7 +962,7 @@ mod tests {
/// A clip whose .clpi is missing contributes NO size and NO extent
/// (bluray.rs only fetches extents inside the `if let Ok(clpi_data)`
/// + `if let Ok(clip_info)` blocks), but the Clip entry is still
/// and `if let Ok(clip_info)` blocks), but the Clip entry is still
/// recorded with packet count 0. Never panics on the missing read.
#[test]
fn parse_playlist_missing_clpi_yields_no_extent_no_size() {
+371 -175
View File
@@ -13,6 +13,171 @@ pub(super) struct HandshakeResult {
pub read_data_key: Option<[u8; 16]>,
}
/// In-tree AACS host-certificate cert-auth "unlocker" — the Drive-level peer of
/// the external firmware [`crate::unlock::Unlocker`]s.
///
/// It is NOT a registry `dyn Unlocker`: the cert handshake helpers
/// ([`crate::aacs::handshake::aacs_authenticate`] et al.) operate on a concrete
/// `&mut Drive`, whereas the registry trait hands out a `&mut dyn ScsiTransport`
/// for external firmware unlockers (and keeps their unit tests trivially
/// fakeable). So the firmware path stays transport-level and registry-routed,
/// while this cert path is an in-tree Drive-level peer invoked directly by
/// [`Disc::do_handshake`]. Both produce a Volume ID under the shared
/// [`crate::unlock::UnlockError`] taxonomy.
struct AacsCertUnlocker<'a> {
opts: &'a ScanOptions,
}
impl AacsCertUnlocker<'_> {
/// Run the host-certificate mutual-auth handshake: collect non-compiled-in
/// host certs from the key sources + credentials, try each (wedge-guarded),
/// and on success read the Volume ID + `read_data_key` (the AACS 2.0 bus
/// key). Returns a structured [`crate::unlock::UnlockError`] on every
/// no-VID outcome.
fn authenticate(
&self,
session: &mut crate::drive::Drive,
) -> std::result::Result<HandshakeResult, crate::unlock::UnlockError> {
use crate::aacs;
use crate::unlock::UnlockError;
// MKB generation (best-effort) — forwarded to each source's
// `host_certs(mkb)` so a source MAY select a generation-appropriate cert
// (the default impl ignores it). A read failure leaves it `None`.
let mkb_gen = aacs::read_mkb_from_drive(session)
.ok()
.and_then(|m| aacs::mkb_version(&m));
// Host certs are keysource-served, never compiled in — unioned from the
// explicit `DriveCredentials` and the key-source layer. With ZERO certs
// the cert route cannot run: NoUsableHostCert (folded to AacsNoHostCert
// by the caller, preserving the graceful path-1 disc-hash → VUK fallback).
let host_certs = Disc::collect_host_certs(self.opts, mkb_gen);
if host_certs.is_empty() {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_no_host_cert",
"No AACS host certificate available from any key source, so the host-certificate handshake can't run."
);
return Err(UnlockError::NoUsableHostCert { mkb: mkb_gen });
}
let host_cert_count = host_certs.len();
tracing::debug!(
target: "freemkv::disc",
phase = "handshake_start",
host_cert_count,
"handshake starting"
);
// Cert-attempt wedge guard. An earlier version fired up to 16 AACS
// authenticate attempts back-to-back with no pause — 80-160 SCSI
// REPORT_KEY/SEND_KEY commands in a few hundred ms, which can drive
// consumer optical drives into a fast-fail firmware wedge (every CDB
// returns ILLEGAL_REQUEST until power-cycled). Defense-in-depth: cap
// attempts, sleep between, bail early on the drive's wedge sense.
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 host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() {
if idx > 0 {
std::thread::sleep(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS));
}
match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) {
Ok(mut auth) => {
let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) {
Ok(vid) => vid,
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_vid_read_failed",
cert_index = idx,
error_code = e.code(),
"auth ok but volume ID read failed"
);
return Err(UnlockError::VidUnavailable);
}
};
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
.ok()
.map(|(rdk, _)| rdk);
tracing::debug!(
target: "freemkv::disc",
phase = "handshake_ok",
cert_index = idx,
has_read_data_key = read_data_key.is_some(),
);
return Ok(HandshakeResult {
volume_id,
read_data_key,
});
}
Err(e) => {
last_err_code = Some(e.code());
// Read the wedge sense off the structured ScsiSense, NOT
// `e.code()` (a flat constant for every ScsiError). On
// ILLEGAL_REQUEST the drive is signalling it won't talk to us
// — trying more certs worsens the wedge, so bail immediately.
let sense = e.scsi_sense();
if sense.map(|s| s.is_illegal_request()).unwrap_or(false) {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_wedge_detected",
cert_index = idx,
sense_key = sense.map(|s| s.sense_key),
asc = sense.map(|s| s.asc),
ascq = sense.map(|s| s.ascq),
"drive returned ILLEGAL_REQUEST during auth; bailing out to avoid wedge"
);
return Err(UnlockError::HandshakeRejected);
}
continue;
}
}
}
tracing::info!(
target: "freemkv::disc",
phase = "vid_cert_rejected",
host_cert_count,
tried = host_cert_count.min(MAX_CERT_ATTEMPTS),
last_error_code = last_err_code,
"The drive rejected the AACS host certificate, so no Volume ID was obtained."
);
Err(UnlockError::HandshakeRejected)
}
}
/// Map an [`crate::unlock::UnlockError`] from the cert path back to the
/// `Error` variant `do_handshake_cert` has always surfaced, so `scan_with`'s
/// rendering and the path-1 disc-hash → VUK fallback are byte-for-byte
/// unchanged. (`NoUsableHostCert` keeps the `<no host cert>` sentinel.)
fn unlock_error_to_error(e: crate::unlock::UnlockError) -> Error {
use crate::unlock::UnlockError;
match e {
UnlockError::NoUsableHostCert { .. } => Error::AacsNoHostCert {
path: "<no host cert>".into(),
},
UnlockError::VidUnavailable => Error::AacsVidUnavailable,
UnlockError::HandshakeRejected
| UnlockError::CertRevoked { .. }
| UnlockError::FirmwareNotUnlockable
| UnlockError::Scsi(_) => Error::AacsHostCertRejected,
}
}
/// Map a cert-path [`crate::unlock::UnlockError`] to a structured
/// [`crate::aacs::UnlockOutcome`] for the resolution trace (English-free).
fn cert_unlock_outcome(e: &crate::unlock::UnlockError) -> crate::aacs::UnlockOutcome {
use crate::aacs::UnlockOutcome;
use crate::unlock::UnlockError;
match e {
UnlockError::FirmwareNotUnlockable => UnlockOutcome::FirmwareNotUnlockable,
UnlockError::NoUsableHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb },
UnlockError::CertRevoked { mkb } => UnlockOutcome::CertRevoked { mkb: *mkb },
UnlockError::VidUnavailable => UnlockOutcome::VidUnavailable,
UnlockError::HandshakeRejected | UnlockError::Scsi(_) => UnlockOutcome::HandshakeRejected,
}
}
impl Disc {
/// SCSI handshake — drives the VID-acquisition flow and returns
/// a structured `HandshakeResult` for downstream key resolution.
@@ -71,13 +236,16 @@ impl Disc {
/// are keysource-served, never compiled in; this is the one place the OEM
/// cert route gathers them. An empty result is the graceful no-cert signal
/// (the caller turns it into [`Error::AacsNoHostCert`]).
fn collect_host_certs(opts: &ScanOptions) -> Vec<crate::aacs::HostCert> {
/// `mkb` is the disc's MKB generation when known, forwarded to each source's
/// [`crate::KeySource::host_certs`] so a source MAY return only
/// generation-appropriate certs (the default ignores it).
fn collect_host_certs(opts: &ScanOptions, mkb: Option<u32>) -> Vec<crate::aacs::HostCert> {
let mut host_certs: Vec<crate::aacs::HostCert> = Vec::new();
if let Some(c) = &opts.credentials {
host_certs.extend(c.host_certs.iter().cloned());
}
for src in &opts.key_sources {
host_certs.extend(src.host_certs());
host_certs.extend(src.host_certs(mkb));
}
host_certs
}
@@ -86,184 +254,51 @@ impl Disc {
session: &mut crate::drive::Drive,
opts: &ScanOptions,
) -> (Option<HandshakeResult>, Option<Error>) {
use crate::aacs;
// OEM VID shortcut. Resolve the SAME unlocker that would unlock this
// drive and ask it for the VID via its OEM mechanism. Cloning the
// DriveId first releases the immutable borrow before we hand the
// mutable transport to the registry.
let drive_id = session.drive_id.clone();
match crate::unlock::unlocker_read_volume_id(session.scsi_mut(), &drive_id) {
Ok(Some(volume_id)) => {
tracing::debug!(
target: "freemkv::disc",
phase = "oem_vid_ok",
"Got the disc's Volume ID from the drive unlocker; skipping the AACS host-certificate handshake."
);
return (
Some(HandshakeResult {
volume_id,
read_data_key: None,
}),
None,
);
}
Ok(None) => {
tracing::debug!(
target: "freemkv::disc",
phase = "oem_vid_none",
"Drive unlocker has no Volume ID for this disc; trying the AACS host-certificate handshake next."
);
}
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
phase = "oem_vid_failed",
error_code = e.code(),
"Drive unlocker errored while reading the Volume ID; trying the AACS host-certificate handshake next."
);
}
}
// No VID from the unlocker → try the cert handshake (host certs are
// served by the key sources). Even on a firmware-unlocked drive we try
// it: the drive may still honour a cert. If it doesn't, that is not a
// failure here — no VID is fine for a keydb VK/UK, and the wedge guard
// bounds the attempts. We log the outcome and continue.
// Host certs are keysource-served, never compiled in. Collect them from
// BOTH places the caller may carry them:
// 1. the explicit `DriveCredentials` (certs the app pre-extracted), and
// 2. the key-source layer (`KeySource::host_certs()` across every
// registered source — the keydb source exposes its `| HC |`/`| HC2 |`
// rows here; an online source whose cert-serving isn't yet designed
// contributes none).
// The two are unioned so either wiring works. With ZERO certs from any
// source the OEM cert route cannot run: we fail GRACEFULLY with
// `AacsNoHostCert` (no panic, no generic failure). Resolution then
// proceeds with VID=zero and relies on the path-1 disc-hash → VUK lookup,
// which drops the error when it hits.
let host_certs = Self::collect_host_certs(opts);
if host_certs.is_empty() {
tracing::warn!(
// OEM VID shortcut: a matching firmware unlocker stashed the disc's
// Volume ID at drive `init()` (the new `unlock()` folds in the old
// `read_volume_id`). Use it and SKIP the cert handshake — the OEM path
// decouples the VID from the host cert + HRL. It yields no
// `read_data_key`; a bus-encrypted disc that needs the bus key is caught
// by the bus-key gate in `resolve_vid_only`.
if let Some(volume_id) = session.oem_vid() {
tracing::debug!(
target: "freemkv::disc",
phase = "handshake_no_host_cert",
"No AACS host certificate available from any key source, so the host-certificate handshake can't run. Continuing without a Volume ID; a key source may still supply this disc's key."
phase = "oem_vid_ok",
"Volume ID supplied by the drive unlocker at init; skipping the AACS host-certificate handshake."
);
return (
None,
Some(Error::AacsNoHostCert {
path: "<no host cert>".into(),
Some(HandshakeResult {
volume_id,
read_data_key: None,
}),
None,
);
}
let host_certs: &[aacs::HostCert] = &host_certs;
let host_cert_count = host_certs.len();
tracing::debug!(
target: "freemkv::disc",
phase = "handshake_start",
host_cert_count,
"handshake starting"
phase = "oem_vid_none",
"No drive-unlocker Volume ID; running the in-tree AACS host-certificate handshake (AacsCertUnlocker)."
);
// Cert-attempt wedge guard. An earlier version 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 host cert isn't in the KEYDB (or one the drive rejects),
// that's 80-160 SCSI commands hammered at the drive in a few
// hundred milliseconds — and consumer optical drives can respond
// by entering a fast-fail firmware wedge state where every
// subsequent CDB returns ILLEGAL_REQUEST/INVALID_FIELD_IN_CDB
// (sense 05/24) until power-cycled. Observed live on a UHD scan:
// KEYDB miss → many cert attempts in a tight loop → wedge →
// forced power cycle to recover.
//
// Defense-in-depth: cap attempts, sleep between, and bail
// early on the drive's wedge sense so any later regression
// can't undo the protection silently.
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 host_certs.iter().take(MAX_CERT_ATTEMPTS).enumerate() {
if idx > 0 {
std::thread::sleep(std::time::Duration::from_millis(PER_CERT_BACKOFF_MS));
}
match aacs::handshake::aacs_authenticate(session, &hc.private_key, &hc.certificate) {
Ok(mut auth) => {
let volume_id = match aacs::handshake::read_volume_id(session, &mut auth) {
Ok(vid) => vid,
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_vid_read_failed",
cert_index = idx,
error_code = e.code(),
"auth ok but volume ID read failed"
);
return (None, Some(Error::AacsVidUnavailable));
}
};
let read_data_key = aacs::handshake::read_data_keys(session, &mut auth)
.ok()
.map(|(rdk, _)| rdk);
tracing::debug!(
target: "freemkv::disc",
phase = "handshake_ok",
cert_index = idx,
has_read_data_key = read_data_key.is_some(),
);
return (
Some(HandshakeResult {
volume_id,
read_data_key,
}),
None,
);
}
Err(e) => {
last_err_code = Some(e.code());
// Log the real SCSI sense triple, not `e.code()` —
// `code()` collapses every ScsiError to the flat
// E_SCSI_ERROR constant and carries no sense key,
// so it has no diagnostic value for auth-failure
// routing.
let sense = e.scsi_sense();
// Drive wedge senses (ILLEGAL_REQUEST, sense key
// 0x05). The drive isn't merely rejecting our
// cert — it's signalling it won't talk to us
// anymore. Trying more certs makes the wedge worse,
// so bail out immediately. NOTE: this must read the
// sense key off the structured ScsiSense, NOT off
// `e.code()`; `code()` is a flat constant for every
// ScsiError so the old `(code >> 8) & 0xFF` guard
// never matched and was dead code (the very wedge
// this defense exists to prevent could recur).
if sense.map(|s| s.is_illegal_request()).unwrap_or(false) {
tracing::warn!(
target: "freemkv::disc",
phase = "handshake_wedge_detected",
cert_index = idx,
sense_key = sense.map(|s| s.sense_key),
asc = sense.map(|s| s.asc),
ascq = sense.map(|s| s.ascq),
"drive returned ILLEGAL_REQUEST during auth; bailing out to avoid wedge"
);
return (None, Some(Error::AacsHostCertRejected));
}
continue;
}
// Cert path: the in-tree `AacsCertUnlocker` peer absorbs the host-cert
// mutual-auth. It collects host certs from the key sources + credentials,
// runs `aacs_authenticate` per cert (wedge-guarded), and on success reads
// the VID + read_data_key. Its `UnlockError` is folded back to the same
// `Error` variants this function has always surfaced, so `scan_with`'s
// error rendering and the path-1 disc-hash → VUK fallback are unchanged.
let unlocker = AacsCertUnlocker { opts };
match unlocker.authenticate(session) {
Ok(hs) => (Some(hs), None),
Err(e) => {
tracing::info!(
target: "freemkv::disc",
phase = "cert_handshake_outcome",
outcome = ?cert_unlock_outcome(&e),
"AACS cert handshake produced no VID; a key source may still supply this disc's key."
);
(None, Some(unlock_error_to_error(e)))
}
}
tracing::info!(
target: "freemkv::disc",
phase = "vid_cert_rejected",
host_cert_count,
tried = host_cert_count.min(MAX_CERT_ATTEMPTS),
last_error_code = last_err_code,
"The drive rejected the AACS host certificate, so no Volume ID was obtained. Continuing; a key source may still supply this disc's key."
);
(None, Some(Error::AacsHostCertRejected))
}
/// Build a keys-free AACS state that carries only the Volume ID (+ version
@@ -298,6 +333,29 @@ impl Disc {
None if bus_encryption => 2,
None => 1,
};
// OEM bus-key gate (wrong-keys guard). A bus-encrypted disc (Content
// Certificate bus-encryption bit set) still carries bus encryption on
// its sectors; descrambling needs the `read_data_key` (bus key), which
// ONLY the AACS host-certificate cert-auth handshake produces. A
// VID-only OEM unlock path returns `read_data_key: None`, and a VID
// alone does NOT remove bus encryption — so if a handshake ran (live
// drive) and yielded a VID but no bus key on a bus-encrypted disc, the
// bytes would decrypt to garbage. Fail loudly here instead.
//
// Gated on `handshake.is_some()` so the two preserved cases never
// regress: (1) file-backed/ISO scans reach here with `handshake = None`
// and have already had bus encryption removed at read time; (2) AACS 1.0
// BD is not bus-encrypted, so `bus_encryption` is false and the gate is
// skipped (its `read_data_key` is legitimately absent).
if bus_encryption && handshake.is_some_and(|h| h.read_data_key.is_none()) {
tracing::warn!(
target: "freemkv::disc",
phase = "bus_key_unavailable",
"Disc declares bus encryption but the handshake produced no read_data_key; a VID-only/OEM unlock cannot remove bus encryption. Refusing to proceed with a key that would decrypt to garbage."
);
return Err(Error::AacsBusKeyUnavailable);
}
// 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/
@@ -712,6 +770,90 @@ mod tests {
assert_eq!(st.read_data_key, Some(rdk));
}
// ---------------------------------------------------------------
// OEM bus-key gate: a bus-encrypted disc scanned on a LIVE drive
// (handshake present) with no read_data_key must HARD-ERROR
// (AacsBusKeyUnavailable) rather than silently yield garbage. The
// three non-regressing cases must still succeed.
// ---------------------------------------------------------------
fn disc_with_cert(cert_type: u8, bus_encryption: bool) -> (MemDisc, udf::UdfFs) {
let mut disc = MemDisc::new();
let udf = build_aacs_fs(
&mut disc,
&[
AacsFile {
name: "Unit_Key_RO.inf",
icb_lba: 60,
data_lba: 5000,
contents: vec![0xAB; 32],
},
AacsFile {
name: "Content000.cer",
icb_lba: 62,
data_lba: 6000,
contents: build_content_cert(cert_type, bus_encryption),
},
],
);
(disc, udf)
}
/// Live-drive (handshake Some) + bus_encryption cert + NO read_data_key
/// → AacsBusKeyUnavailable. This is the wrong-keys guard: a VID-only/OEM
/// unlock cannot remove bus encryption.
#[test]
fn resolve_vid_only_bus_encrypted_live_drive_without_rdk_errors() {
let (mut disc, udf) = disc_with_cert(0x01, true);
let hs = HandshakeResult {
volume_id: [0x11u8; 16],
read_data_key: None,
};
let err = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs))
.expect_err("bus-encrypted disc with no bus key must hard-error");
assert!(matches!(err, Error::AacsBusKeyUnavailable));
}
/// Live-drive + bus_encryption cert + read_data_key PRESENT → Ok (the cert
/// handshake produced the bus key, as required).
#[test]
fn resolve_vid_only_bus_encrypted_live_drive_with_rdk_ok() {
let (mut disc, udf) = disc_with_cert(0x01, true);
let hs = HandshakeResult {
volume_id: [0x11u8; 16],
read_data_key: Some([0x22u8; 16]),
};
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("bus key present → ok");
assert!(st.bus_encryption);
assert_eq!(st.read_data_key, Some([0x22u8; 16]));
}
/// ISO scan (handshake None) of a bus_encryption disc → Ok. Bus encryption
/// was already removed at read time; the gate must NOT fire without a
/// handshake (no UHD-ISO-mux regression).
#[test]
fn resolve_vid_only_bus_encrypted_iso_no_handshake_ok() {
let (mut disc, udf) = disc_with_cert(0x01, true);
let st = Disc::resolve_vid_only(&udf, &mut disc, None).expect("ISO bus disc → ok");
assert!(st.bus_encryption);
assert_eq!(st.read_data_key, None);
}
/// AACS 1.0 BD (V10 cert, bus_encryption off) on a live drive with NO
/// read_data_key → Ok. read_data_key is legitimately absent for AACS 1.0;
/// the gate must NOT fire when bus_encryption is false.
#[test]
fn resolve_vid_only_aacs10_live_drive_without_rdk_ok() {
let (mut disc, udf) = disc_with_cert(0x00, false);
let hs = HandshakeResult {
volume_id: [0x11u8; 16],
read_data_key: None,
};
let st = Disc::resolve_vid_only(&udf, &mut disc, Some(&hs)).expect("AACS 1.0 → ok");
assert!(!st.bus_encryption);
assert_eq!(st.read_data_key, None);
}
/// With NO handshake, volume_id defaults to all-zero (encrypt.rs
/// `.unwrap_or([0u8; 16])`) and read_data_key is None.
#[test]
@@ -798,10 +940,13 @@ mod tests {
/// A minimal in-test KeySource that yields no keys but a fixed cert list.
struct CertSource(Vec<aacs::HostCert>);
impl crate::KeySource for CertSource {
fn next_key(&mut self, _inputs: &crate::keysource::DiscInputs) -> Option<crate::disc::Key> {
None
fn get_uk(
&self,
_ctx: &dyn crate::keysource::ResolveCtx,
) -> Result<Vec<crate::aacs::UnitKey>> {
Ok(Vec::new())
}
fn host_certs(&self) -> Vec<aacs::HostCert> {
fn host_certs(&self, _mkb: Option<u32>) -> Vec<aacs::HostCert> {
self.0.clone()
}
}
@@ -809,7 +954,7 @@ mod tests {
#[test]
fn collect_host_certs_empty_when_no_credentials_no_sources() {
let opts = ScanOptions::default();
assert!(Disc::collect_host_certs(&opts).is_empty());
assert!(Disc::collect_host_certs(&opts, None).is_empty());
}
#[test]
@@ -820,7 +965,7 @@ mod tests {
}),
..Default::default()
};
let certs = Disc::collect_host_certs(&opts);
let certs = Disc::collect_host_certs(&opts, None);
assert_eq!(certs.len(), 1);
assert_eq!(certs[0].private_key, [1u8; 20]);
}
@@ -831,7 +976,7 @@ mod tests {
key_sources: vec![Box::new(CertSource(vec![fake_cert(2)]))],
..Default::default()
};
let certs = Disc::collect_host_certs(&opts);
let certs = Disc::collect_host_certs(&opts, None);
assert_eq!(certs.len(), 1);
assert_eq!(certs[0].private_key, [2u8; 20]);
}
@@ -851,11 +996,62 @@ mod tests {
],
..Default::default()
};
let mut tags: Vec<u8> = Disc::collect_host_certs(&opts)
let mut tags: Vec<u8> = Disc::collect_host_certs(&opts, None)
.iter()
.map(|c| c.private_key[0])
.collect();
tags.sort_unstable();
assert_eq!(tags, vec![1, 2, 3]);
}
// ---------------------------------------------------------------
// AacsCertUnlocker outcome mapping: UnlockError → Error (preserving
// the legacy do_handshake_cert surface) and → UnlockOutcome (the
// structured trace step). No English in either.
// ---------------------------------------------------------------
#[test]
fn unlock_error_maps_to_legacy_error_variants() {
use crate::unlock::UnlockError;
// No host cert keeps the AacsNoHostCert sentinel path.
match unlock_error_to_error(UnlockError::NoUsableHostCert { mkb: Some(68) }) {
Error::AacsNoHostCert { path } => assert_eq!(path, "<no host cert>"),
other => panic!("expected AacsNoHostCert, got {other:?}"),
}
assert!(matches!(
unlock_error_to_error(UnlockError::VidUnavailable),
Error::AacsVidUnavailable
));
assert!(matches!(
unlock_error_to_error(UnlockError::HandshakeRejected),
Error::AacsHostCertRejected
));
assert!(matches!(
unlock_error_to_error(UnlockError::CertRevoked { mkb: None }),
Error::AacsHostCertRejected
));
}
#[test]
fn cert_unlock_outcome_maps_to_structured_trace_step() {
use crate::aacs::UnlockOutcome;
use crate::unlock::UnlockError;
assert_eq!(
cert_unlock_outcome(&UnlockError::NoUsableHostCert { mkb: Some(77) }),
UnlockOutcome::NoUsableHostCert { mkb: Some(77) }
);
assert_eq!(
cert_unlock_outcome(&UnlockError::VidUnavailable),
UnlockOutcome::VidUnavailable
);
assert_eq!(
cert_unlock_outcome(&UnlockError::HandshakeRejected),
UnlockOutcome::HandshakeRejected
);
// A SCSI/transport error folds to HandshakeRejected at the trace layer.
assert_eq!(
cert_unlock_outcome(&UnlockError::Scsi(4000)),
UnlockOutcome::HandshakeRejected
);
}
}
+7 -7
View File
@@ -24,7 +24,7 @@ use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
const SECTOR_LEN: usize = 2048;
use crate::consts::SECTOR_BYTES;
/// AACS aligned unit = 3 sectors / 6144 bytes. Content reads are issued in
/// multiples of this so the decrypt step always sees whole units.
const AACS_UNIT_SECTORS: u32 = 3;
@@ -282,7 +282,7 @@ impl Disc {
for &(abs_lba, byte_len) in &pf.extents {
extents.push(crate::disc::Extent {
start_lba: abs_lba,
sector_count: (byte_len as u64).div_ceil(SECTOR_LEN as u64) as u32,
sector_count: (byte_len as u64).div_ceil(SECTOR_BYTES as u64) as u32,
});
}
}
@@ -450,7 +450,7 @@ fn extract_one_file<S: SectorSource>(
}
let mut written: u64 = 0;
let mut buf = vec![0u8; READ_BATCH_SECTORS as usize * SECTOR_LEN];
let mut buf = vec![0u8; READ_BATCH_SECTORS as usize * SECTOR_BYTES];
'extents: for &(abs_lba, byte_len) in &pf.extents {
if written >= pf.size {
break;
@@ -467,7 +467,7 @@ fn extract_one_file<S: SectorSource>(
// the per-extent re-anchoring in the mux read paths
// (`mux/disc.rs`, `sector/prefetched.rs`). No-op for CSS / None.
dec.set_unit_base(abs_lba);
let sectors = (byte_len as u64).div_ceil(SECTOR_LEN as u64) as u32;
let sectors = (byte_len as u64).div_ceil(SECTOR_BYTES as u64) as u32;
let mut sector_off: u32 = 0;
while sector_off < sectors {
let mut batch = (sectors - sector_off).min(READ_BATCH_SECTORS);
@@ -486,7 +486,7 @@ fn extract_one_file<S: SectorSource>(
batch -= batch % AACS_UNIT_SECTORS;
}
let lba = abs_lba + sector_off;
let want = batch as usize * SECTOR_LEN;
let want = batch as usize * SECTOR_BYTES;
let read_ok = read_batch(dec, lba, batch, &mut buf[..want]);
let chunk_bytes = want as u64;
// Clip the chunk to the remaining file size on the final extent.
@@ -1000,11 +1000,11 @@ mod tests {
s[0..2].copy_from_slice(&266u16.to_le_bytes()); // Extended File Entry
// ad_type 0 = Short AD (icb flags low 3 bits at offset 34).
s[34..36].copy_from_slice(&0u16.to_le_bytes());
let size = sectors_each * SECTOR_LEN as u32 * 2;
let size = sectors_each * SECTOR_BYTES as u32 * 2;
s[56..64].copy_from_slice(&(size as u64).to_le_bytes()); // info_length
s[208..212].copy_from_slice(&0u32.to_le_bytes()); // l_ea
s[212..216].copy_from_slice(&16u32.to_le_bytes()); // l_ad = 2 Short ADs
let ext_len = sectors_each * SECTOR_LEN as u32; // bytes, type-0 recorded
let ext_len = sectors_each * SECTOR_BYTES as u32; // bytes, type-0 recorded
// AD #0
s[216..220].copy_from_slice(&(ext_len & 0x3FFF_FFFF).to_le_bytes());
s[220..224].copy_from_slice(&data_lba_a.to_le_bytes());
+64 -18
View File
@@ -370,8 +370,12 @@ pub enum SampleRate {
S44_1,
/// 48 kHz — standard BD/DVD/UHD audio
S48,
/// 88.2 kHz — 44.1 kHz-family high-res TrueHD (music BD)
S88_2,
/// 96 kHz — high-res BD audio
S96,
/// 176.4 kHz — 44.1 kHz-family high-res TrueHD (music BD)
S176_4,
/// 192 kHz — highest BD audio (LPCM)
S192,
/// 48/96 kHz combo (secondary audio resampled)
@@ -432,16 +436,26 @@ 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.
/// Correct a title's TrueHD audio-stream metadata by probing the first
/// decrypted access units — channel count, real sample rate, and Atmos
/// detection in a single major-sync read. The MPLS descriptors declare the BASE
/// layout (often 5.1 / a container-guessed rate) even for a 7.1/Atmos TrueHD
/// track; the truth 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.
///
/// Corrections, each individually guarded so a malformed field never writes a
/// wrong header:
/// - **Channels**: from the presentation channel masks (as before).
/// - **Sample rate**: from the whitelisted rate nibble; left untouched on an
/// unknown rate or no major sync.
/// - **Atmos**: when a 4th substream is detected AND the stream still carries
/// the basic descriptor label, the label is promoted to the Atmos form;
/// richer editorial labels (e.g. an existing "Dolby Atmos") are left intact.
pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut DiscTitle) {
use crate::mux::codec::truehd::truehd_channels_from_stream;
use crate::mux::codec::truehd::{
truehd_channels, truehd_sample_rate_hz, truehd_sync_info_from_stream,
};
let pids: Vec<u16> = title
.streams
@@ -496,18 +510,44 @@ pub(crate) fn correct_truehd_channels(reader: &mut dyn SectorSource, title: &mut
let Some(payload) = payloads.get(&a.pid) else {
continue;
};
let Some(count) = truehd_channels_from_stream(payload) else {
// One major-sync read yields channels, sample rate and the Atmos signal.
let Some(info) = truehd_sync_info_from_stream(payload) else {
continue;
};
let new_ch = AudioChannels::from_count(count);
if new_ch == AudioChannels::Unknown || new_ch == a.channels {
continue;
}
// Whether the label is still the plain descriptor (no richer editorial
// label). Captured against the CURRENT channels before any correction so
// a label promotion only happens when nothing editorial is present.
let was_basic =
a.label == crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary);
a.channels = new_ch;
// (1) Channels — only when the major sync resolves a different layout.
if let Some(count) = truehd_channels(info.format_info) {
let new_ch = AudioChannels::from_count(count);
if new_ch != AudioChannels::Unknown && new_ch != a.channels {
a.channels = new_ch;
}
}
// (2) Sample rate — whitelisted rates only; an unknown nibble or a rate
// that maps to no enum variant leaves the container value untouched
// (never write a wrong SamplingFrequency).
if let Some(hz) = truehd_sample_rate_hz(info.format_info) {
let new_sr = SampleRate::from_hz(hz);
if new_sr != SampleRate::Unknown && new_sr != a.sample_rate {
a.sample_rate = new_sr;
}
}
// (3) Label — refresh to the corrected channels; promote to the Atmos
// form only when the stream carried the basic descriptor (no editorial
// Atmos already) AND a 4th substream was positively detected.
if was_basic {
a.label = crate::labels::generate_audio_label(&a.codec, &new_ch, a.secondary);
a.label = if info.is_atmos == Some(true) {
crate::labels::generate_audio_label_atmos(&a.codec, &a.channels, a.secondary)
} else {
crate::labels::generate_audio_label(&a.codec, &a.channels, a.secondary)
};
}
}
}
@@ -840,7 +880,9 @@ impl SampleRate {
match self {
SampleRate::S44_1 => 44100.0,
SampleRate::S48 | SampleRate::S48_96 | SampleRate::S48_192 => 48000.0,
SampleRate::S88_2 => 88200.0,
SampleRate::S96 => 96000.0,
SampleRate::S176_4 => 176400.0,
SampleRate::S192 => 192000.0,
SampleRate::Unknown => 48000.0,
}
@@ -851,7 +893,9 @@ impl SampleRate {
match hz {
44100 => SampleRate::S44_1,
48000 => SampleRate::S48,
88200 => SampleRate::S88_2,
96000 => SampleRate::S96,
176400 => SampleRate::S176_4,
192000 => SampleRate::S192,
_ => SampleRate::Unknown,
}
@@ -1037,7 +1081,9 @@ enum_str!(
[
("44.1kHz", SampleRate::S44_1),
("48kHz", SampleRate::S48),
("88.2kHz", SampleRate::S88_2),
("96kHz", SampleRate::S96),
("176.4kHz", SampleRate::S176_4),
("192kHz", SampleRate::S192),
("48/96kHz", SampleRate::S48_96),
("48/192kHz", SampleRate::S48_192),
@@ -1894,7 +1940,7 @@ pub enum 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>),
Device(Vec<crate::aacs::types::DeviceKey>),
/// Processing key(s) (AACS PK). libfreemkv applies each against the MKB
/// → media key → VUK → unit keys.
Processing(Vec<[u8; 16]>),
@@ -5023,7 +5069,7 @@ mod tests {
let good = mf.ranges_with(&[SectorStatus::Finished]);
let bad_ranges = mf.ranges_with(&[SectorStatus::NonTrimmed]);
let disc_bytes = sectors as u64 * 2048;
const SEC: u64 = 2048;
const SEC: u64 = crate::consts::SECTOR_BYTES as u64;
// The first failing batch starts at LBA 320; everything before it read
// cleanly and must be Finished.
+1 -1
View File
@@ -2588,7 +2588,7 @@ mod tests {
range_sectors: 1,
};
let timed_out = check_range_watchdog(&mut state, &frame, &*shared);
let timed_out = check_range_watchdog(&mut state, &frame, &shared);
assert!(!timed_out, "range must not time out immediately");
// With correct initialization bytes_good_now == range_bytes_good,
// so the `bytes_good_now > range_bytes_good` branch does NOT fire