diff --git a/.github/workflows/ffmpeg-validation.yml b/.github/workflows/ffmpeg-validation.yml index 87f393ef..354852ee 100644 --- a/.github/workflows/ffmpeg-validation.yml +++ b/.github/workflows/ffmpeg-validation.yml @@ -48,7 +48,7 @@ jobs: cargo test --release --lib ffmpeg_ -- --ignored --nocapture 2>&1 | tee "$FREEMKV_FFMPEG_ARTIFACT_DIR/tests.log" - name: Upload fixtures, probes, hashes and logs if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ffmpeg-validation-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ env.FREEMKV_FFMPEG_ARTIFACT_DIR }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index c2b43ace..fbe014d9 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -22,6 +22,6 @@ jobs: results_file: results.sarif results_format: sarif publish_results: true - - uses: github/codeql-action/upload-sarif@v3 + - uses: github/codeql-action/upload-sarif@v4 with: sarif_file: results.sarif diff --git a/CHANGELOG.md b/CHANGELOG.md index 551759e0..666b6493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ - `Error::ImageTruncated` displays as `E: have/want`. - `json://` audio `sample_rate` is now a number in Hz (`null` when unknown) instead of a display string such as `"48kHz"`; a new `sample_rates` array lists every rate a stream carries (e.g. `[48000, 96000]` for a 48/96 kHz combo). - `network://` / `stdio://` FMKV headers use version 2 when a track carries decoder timing (Opus CodecDelay/SeekPreRoll), adding per-frame DiscardPadding; streams without timing stay version 1. +- `Disc::scan` follows the standard AACS order: UDF, then `Unit_Key_RO.inf`, the content certificate and the MKB with plain reads, then the handshake, and only on a disc with an AACS directory. It no longer reads the MKB from the drive, and passes `None` to `KeySource::host_certs`. +- `Disc::scan` fails with `Error::AacsKeyFileUnreadable` (E7031) when a live AACS disc's `Unit_Key_RO.inf` is missing or unreadable, before any AACS command. Image and folder scans still record the error and continue. +- A transport fault during the AACS or CSS handshake makes `Disc::scan` fail with the same error `Drive::init` returns; a Stop during the handshake returns `Error::Halted`. +- `DecryptingSectorSource` has no default AACS unit base: an AACS content read before `set_unit_base` fails `DecryptFailed` (it used to decrypt on the disc-LBA-0 grid). A BD-TS unit flagged encrypted whose clear seed lacks the TS sync at byte 4 (read off its file's unit grid) also fails `DecryptFailed` instead of decrypting to garbage. ### Removed @@ -26,9 +30,15 @@ - `AudioChannels` gains 3.0, 3.1, 4.1, 6.0 and 7.0. - `DiscPresence` (`Present` / `Absent` / `Settling`) and `disc_presence(path)`: the tri-state answer `drive_has_disc` collapses (`Settling` counts as a disc). - `Disc::inputs_with_samples` fills `DiscInputs::samples` from the main feature. +- `Error::AacsKeyFileUnreadable` (E7031). +- `DecryptingSectorSource::clear_unit_base()`: drop the unit base so AACS content reads fail loud until the next `set_unit_base`. +- `Error::WholeDiscKeyMissing` (E7032): a decrypted whole-disc image would keep encrypted pieces because a stream file no title plays has no held key. The fix is an MKV rip or a raw copy. +- `whole_disc::whole_disc_reader` / `WholeDiscReader`: the decrypting reader for a whole-disc or image → ISO copy, shared by every front end. It keys every stream file no title plays, not just the kept titles. Each is probed at its first unit and up to 32 across it, and a held base key must open two probed units before it keys the file. Ciphertext that no held key opens refuses with E7032 before any output. A file with no proven key stays unkeyed on a multi-key disc, and a copy that reaches an encrypted unit there stops with E7032. `UnitAligned::unit_block_end` tiles sweep blocks on each file's unit grid. An AACS disc with titles but no stream folder fails with `Error::UdfNotFound` (E6003) naming the folder. +- `ScanOptions::raw_copy`: a raw disc→ISO copy scans on past an unreadable `Unit_Key_RO.inf`, with E7031 recorded and every key refused. ### Fixed +- HD DVD XPL timecodes at a 60fps timeBase run at 60000/1001; title, clip and chapter times were 0.1% short. - TrueHD channel labels keep the LFE split (3.0 was labelled "2.1", 7.0 "6.1"), and a 6-channel presentation counts its Lvh/Rvh pair as 2. An older network:// receiver parses the new layout strings as unknown and omits the MKV Channels element for those tracks; keep both ends on the same version. - `mp4://` DTS tracks write the stream's maximum rate in `ddts` and read back as 96 kHz where the source is 96 kHz. - `mkv://` PCM tracks without BitDepth: bounded read-ahead and a multi-block depth estimate. diff --git a/src/aacs/content.rs b/src/aacs/content.rs index f7efa72e..499f43eb 100644 --- a/src/aacs/content.rs +++ b/src/aacs/content.rs @@ -87,6 +87,19 @@ pub fn aacs_unit_seed_encrypted(unit: &[u8], format: crate::disc::ContentFormat) } } +/// Was `unit` cut on its file's unit grid? An encrypted BD-TS unit's clear seed +/// always starts a source packet, so its TS sync (0x47) sits at byte 4; a chunk +/// read off the grid starts mid-unit on ciphertext. Clear units, and `MpegPs` +/// (seed layout unverified), always pass. A wrong-grid chunk with CPI=0 also +/// passes (~1 in 4 per unit), so single-unit reads can slip; multi-unit reads are +/// caught almost surely. An on-grid unit whose seed is corrupt (media or read +/// damage in bytes 0..5) fails the whole read as DecryptFailed. +pub(crate) fn aacs_unit_on_grid(unit: &[u8], format: crate::disc::ContentFormat) -> bool { + format != crate::disc::ContentFormat::BdTs + || !aacs_unit_seed_encrypted(unit, format) + || unit.get(4) == Some(&TS_SYNC) +} + /// True when an aligned unit is flagged encrypted AND still looks scrambled /// (structure not yet restored) — genuine encrypted content NOT yet decrypted. /// diff --git a/src/aacs/host_certs.rs b/src/aacs/host_certs.rs index 3e11adf4..4ec14f4b 100644 --- a/src/aacs/host_certs.rs +++ b/src/aacs/host_certs.rs @@ -5,8 +5,8 @@ /// Union the host certificates a scan can offer the drive: the explicit /// `DriveCredentials`, then each key source's `host_certs(mkb)`. Host certs are -/// keysource-served, never compiled in. `mkb` lets a source pick a -/// generation-appropriate cert (the default impl ignores it). +/// keysource-served, never compiled in. The scan passes `None` for `mkb`: the +/// argument is reserved and will be removed at the next breaking window. pub fn collect_host_certs( opts: &crate::disc::ScanOptions, mkb: Option, diff --git a/src/decrypt.rs b/src/decrypt.rs index 3207ed26..7d62d1fe 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -446,6 +446,12 @@ fn apply_aacs_map( } return; }; + // Off-grid guard: a real unit's clear seed carries its sync byte, so a flagged + // chunk without it was cut across two units (wrong unit base) — fail loud. + if !aacs::content::aacs_unit_on_grid(chunk, format) { + verify_failed.store(true, std::sync::atomic::Ordering::Relaxed); + return; + } // PHASE GATE (FMTS forensic segment): the segment interleaves two variants // at the unit level. Decrypt ONLY our parity; leave the alternate half as // ciphertext (the muxer drops untouched ciphertext cleanly — no garble). diff --git a/src/disc/dvd_audio_probe.rs b/src/disc/dvd_audio_probe.rs index c9951820..1a27ba97 100644 --- a/src/disc/dvd_audio_probe.rs +++ b/src/disc/dvd_audio_probe.rs @@ -212,6 +212,7 @@ pub fn probe_and_remap( let mut buf = vec![0u8; count as usize * 2048]; // `recovery=false`: a single best-effort attempt — the probe must never // stall the mux or hammer a marginal drive. On any error, bail to ordinal. + reader.set_unit_base(ext.start_lba); // HD DVD AACS: anchor at the title head let n = match reader.read_sectors(ext.start_lba, count, &mut buf, false) { Ok(n) => n, Err(_) => return, diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index 30fda64b..45040f70 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -1,4 +1,4 @@ -//! AACS encryption resolution — key derivation, SCSI handshake, VUK lookup. +//! AACS scan steps: capture the key files, run the handshake, record the verdict. use super::*; use crate::error::{Error, Result}; @@ -12,9 +12,6 @@ pub(super) struct HandshakeResult { pub read_data_key: Option<[u8; 16]>, /// When `read_data_key` is `None` because the bus-key read FAILED (as opposed /// to a path that never attempts it), the error code from `read_data_keys`. - /// Carried so the downstream bus-key gate can log WHY the bus key is missing - /// instead of a bare "unavailable" — the difference between a diagnosable log - /// and archaeology. pub read_data_key_err: Option, /// True when the VID came from an unlocker that unlocked the drive, so bus encryption is /// already removed AT THE DRIVE (firmware, not AKE). The bus-key gate must credit this the @@ -49,28 +46,44 @@ pub(super) enum BusSource<'a> { FirmwareUnlocked, } -impl<'a> BusSource<'a> { - // Classify a scan's handshake outcome. `firmware_unlocked` = a firmware - // unlocker claimed the live drive (`Drive::unlocker_name`). - pub(super) fn classify( - handshake: Option<&'a HandshakeResult>, - handshake_failed: bool, - firmware_unlocked: bool, - ) -> Self { - match handshake { - Some(h) => BusSource::Handshake(h), - None if handshake_failed => BusSource::HandshakeFailed, - None if firmware_unlocked => BusSource::FirmwareUnlocked, - None => BusSource::FileOrIso, +/// The owned outcome of a scan's AACS bus step; [`BusSource`] is its borrowed view. +#[derive(Debug)] +pub(super) enum BusOutcome { + /// Image or folder: no handshake runs. + FileOrIso, + /// Live drive: the handshake (cert AKE or unlocker OEM VID) produced a result. + Handshake(HandshakeResult), + /// Live drive: the handshake failed with this handshake-class error. + Failed(Error), + /// Live drive claimed by a firmware unlocker with no VID. + FirmwareUnlocked, +} + +impl BusOutcome { + pub(super) fn source(&self) -> BusSource<'_> { + match self { + BusOutcome::FileOrIso => BusSource::FileOrIso, + BusOutcome::Handshake(h) => BusSource::Handshake(h), + BusOutcome::Failed(_) => BusSource::HandshakeFailed, + BusOutcome::FirmwareUnlocked => BusSource::FirmwareUnlocked, } } - fn handshake(self) -> Option<&'a HandshakeResult> { + fn handshake(&self) -> Option<&HandshakeResult> { match self { - BusSource::Handshake(h) => Some(h), + BusOutcome::Handshake(h) => Some(h), _ => None, } } + + fn label(&self) -> &'static str { + match self { + BusOutcome::FileOrIso => "file", + BusOutcome::Handshake(_) => "handshake", + BusOutcome::Failed(_) => "failed", + BusOutcome::FirmwareUnlocked => "firmware", + } + } } // Single source of truth for "is AACS bus encryption gone for this scan?". Add a NEW removal @@ -86,24 +99,19 @@ pub(super) fn bus_encryption_removed(bus_encryption: bool, source: BusSource<'_> } } -// The single keep/refuse policy for a scanned AACS disc: the state is always kept -// (non-secret triage metadata); this is the error recorded alongside it. Without bus -// encryption a failed handshake blocks nothing, so it is logged, not recorded. -pub(super) fn aacs_scan_error( - bus_encryption: bool, - source: BusSource<'_>, - handshake_error: Option, -) -> Option { +// The single keep/refuse policy for a captured AACS disc: the error recorded beside the +// state. Without bus encryption a failed handshake blocks nothing, so it is not recorded. +pub(super) fn aacs_verdict(bus_encryption: bool, bus: &BusOutcome) -> Option { if !bus_encryption { - if let Some(e) = &handshake_error { - tracing::info!(target: "freemkv::disc", error_code = e.code(), "AACS handshake failed; disc has no bus encryption, so keys still apply"); - } return None; } - if handshake_error.is_none() && !bus_encryption_removed(bus_encryption, source) { + if let BusOutcome::Failed(e) = bus { + return handshake_class_error(e).or(Some(Error::AacsBusKeyUnavailable)); + } + if !bus_encryption_removed(bus_encryption, bus.source()) { return Some(Error::AacsBusKeyUnavailable); } - handshake_error + None } // A copy of `e` if it is a handshake-class failure (the drive's AACS route did not @@ -111,6 +119,7 @@ pub(super) fn aacs_scan_error( pub(super) fn handshake_class_error(e: &Error) -> Option { match e { Error::AacsNoHostCert { path } => Some(Error::AacsNoHostCert { path: path.clone() }), + Error::AacsNoUsableHostCert => Some(Error::AacsNoUsableHostCert), Error::AacsHostCertRejected => Some(Error::AacsHostCertRejected), Error::AacsVidUnavailable => Some(Error::AacsVidUnavailable), Error::AacsBusKeyUnavailable => Some(Error::AacsBusKeyUnavailable), @@ -119,10 +128,13 @@ pub(super) fn handshake_class_error(e: &Error) -> Option { } impl Disc { - // Sticky refusal: a bus-encrypted disc whose handshake failed reads ciphertext - // through the bus, so no supplied key may commit or clear the reason. - // Fail-safe: with no AACS state the bus flag is unknown, so assume bus encryption. + // Sticky refusal: a failed handshake on a bus-encrypted disc, or a live disc without + // its key file (E7031; key sources then trace BUS_BLOCKED), admits no supplied key. + // With no AACS state the bus flag is unknown, so bus encryption is assumed. pub(crate) fn bus_blocked_error(&self) -> Option { + if matches!(self.aacs_error, Some(Error::AacsKeyFileUnreadable)) { + return Some(Error::AacsKeyFileUnreadable); + } let bus = self.aacs.as_ref().is_none_or(|a| a.bus_encryption); if !bus { return None; @@ -146,189 +158,353 @@ fn disc_is_uhd(udf_fs: &udf::UdfFs, reader: &mut dyn SectorSource) -> bool { .is_some_and(|g| matches!(g, AacsVersion::V20 | AacsVersion::V21)) } +/// A disc's AACS files, read before any AACS command is sent to the drive. +pub(super) struct AacsCapture { + /// `Unit_Key_RO.inf`, or why neither copy could be read. + pub(super) uk_ro: Result>, + /// The content cert's BEE flag; `None` when the cert is missing or unparseable. + pub(super) cert_bee: Option, + /// The fail-safe bus-encryption verdict (an unknown cert on a UHD counts as bus). + pub(super) bus_encryption: bool, + pub(super) version: u8, + pub(super) mkb: Vec, +} + +/// Where a capture reads from, which decides what an unreadable `Unit_Key_RO.inf` does. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum CaptureFrom { + /// Image or folder: the key-file error is recorded as read (E7000/E6000). + Image, + /// Live drive: E7031, fatal unless `raw_copy` (then recorded, keys refused). + Live { raw_copy: bool }, +} + +// Reads the AACS key files (plain READs, no AACS command). `Err` for a Stop, and for a +// live key-file failure without `raw_copy`, returned before any other read. +pub(super) fn capture( + reader: &mut dyn SectorSource, + udf_fs: &udf::UdfFs, + from: CaptureFrom, +) -> Result { + use crate::aacs; + let live = from != CaptureFrom::Image; + let mut uk_ro = aacs::read_first(&aacs::role_paths(udf_fs, aacs::AacsRole::UnitKey), |p| { + udf_fs.read_file(reader, p) + }); + match (&uk_ro, from) { + (Err(Error::Halted), _) => return Err(Error::Halted), + (Err(e), CaptureFrom::Live { raw_copy }) => { + tracing::debug!(target: "freemkv::scan", phase = "aacs_capture", error_code = e.code(), raw_copy); + if !raw_copy { + return Err(Error::AacsKeyFileUnreadable); + } + uk_ro = Err(Error::AacsKeyFileUnreadable); + } + _ => {} + } + let cc_raw = match aacs::read_first( + &aacs::role_paths(udf_fs, aacs::AacsRole::ContentCert), + |p| udf_fs.read_file(reader, p), + ) { + Ok(raw) => Some(raw), + Err(Error::Halted) => return Err(Error::Halted), + Err(_) => None, + }; + let cc = cc_raw.as_deref().and_then(aacs::inf::parse_content_cert); + // No-cert default = UHD (V20 stride), matching `read_aacs_version`. + let version = cc + .as_ref() + .map(|c| c.version.major()) + .unwrap_or(aacs::mkb::AACS_MAJOR_UHD); + // Fail safe: an unparseable cert, or none on a live disc known to be UHD, may mean bus encryption. + let bus_encryption = match &cc { + Some(c) => c.bus_encryption, + None => cc_raw.is_some() || (live && disc_is_uhd(udf_fs, reader)), + }; + // The bounded MKB reader, not `read_file` (the ~128 MiB padded MKB would fail). + let mkb = match Disc::read_mkb_content(reader, udf_fs) { + Ok(m) => m, + Err(Error::Halted) => return Err(Error::Halted), + Err(e) => { + tracing::info!( + target: "freemkv::disc", + phase = "scan_aacs_mkb", + error_code = e.code(), + "MKB unreadable at scan; continuing with an empty MKB (disc-hash lookups are unaffected)." + ); + Vec::new() + } + }; + Ok(AacsCapture { + uk_ro, + cert_bee: cc.map(|c| c.bus_encryption), + bus_encryption, + version, + mkb, + }) +} + +// Builds the keys-free AACS state from a capture and the bus outcome, records the +// verdict, and emits the scan's single `aacs_verdict` event. +pub(super) fn resolve_aacs( + cap: AacsCapture, + bus: &BusOutcome, +) -> (Option, Option) { + use crate::aacs; + let mkb_version = aacs::mkb::mkb_version(&cap.mkb); + let (state, error) = match cap.uk_ro { + Err(e) => (None, Some(e)), + Ok(uk_ro) => { + let dh = aacs::inf::disc_hash(&uk_ro); + let state = AacsState { + version: cap.version, + bus_encryption: cap.bus_encryption, + mkb_version, + disc_hash: aacs::inf::disc_hash_hex(&dh), + key_source: KeyOrigin::ExternalUk, + vuk: None, + unit_keys: vec![], + volume_id: bus.handshake().map(|h| h.volume_id).unwrap_or([0u8; 16]), + uk_ro, + mkb: cap.mkb, + }; + (Some(state), aacs_verdict(cap.bus_encryption, bus)) + } + }; + let bus_removed = bus_encryption_removed(cap.bus_encryption, bus.source()); + let bus_error = match bus { + BusOutcome::Failed(e) => Some(e.code()), + _ => None, + }; + let rdk_err = bus.handshake().and_then(|h| h.read_data_key_err); + let has_vid = bus.handshake().is_some_and(handshake_has_volume_id); + let recorded = error.as_ref().map(|e| e.code()); + if recorded.is_some() { + tracing::warn!( + target: "freemkv::scan", + phase = "aacs_verdict", + bus_encryption = cap.bus_encryption, + bus_removed, + bus_outcome = bus.label(), + bus_error_code = bus_error, + read_data_key_err = rdk_err, + has_volume_id = has_vid, + recorded_error_code = recorded, + mkb_version, + "AACS verdict: keys for this disc will be refused or cannot be derived" + ); + } else { + tracing::info!( + target: "freemkv::scan", + phase = "aacs_verdict", + bus_encryption = cap.bus_encryption, + bus_removed, + bus_outcome = bus.label(), + bus_error_code = bus_error, + read_data_key_err = rdk_err, + has_volume_id = has_vid, + mkb_version, + "AACS verdict: no blocking error" + ); + } + (state, error) +} + +// Did the handshake actually carry a Volume ID? A VALUE, so it is testable without tracing. +fn handshake_has_volume_id(h: &HandshakeResult) -> bool { + h.volume_id != [0u8; 16] +} + +// The Read Data Key to de-bus with: dropped when the content cert's BEE flag is clear +// (AACS spec; libaacs gates on `bee && bec`). An unreadable cert keeps the key. +pub(super) fn bus_key(cap: &AacsCapture, bus: &BusOutcome) -> Option<[u8; 16]> { + let rdk = bus.handshake().and_then(|h| h.read_data_key)?; + if cap.cert_bee == Some(false) { + tracing::info!(target: "freemkv::scan", "content cert BEE=0: bus removal off"); + return None; + } + Some(rdk) +} + // libfreemkv-side driver for the AACS cert route: owns host-cert collection, dispatches // mutual-auth to the freemkv-unlock AACS unlocker. struct AacsCertUnlocker<'a> { opts: &'a ScanOptions, } -/// Why the AACS cert path produced no Volume ID. Distinguishes the libfreemkv- -/// side "no host cert at all" case (which carries the disc MKB generation for -/// the outcome trace) from the unlocker-reported [`freemkv_unlock::UnlockError`]. +/// Why the AACS cert path produced no Volume ID (a dead bus is [`CertFail::Transport`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CertUnlockFailure { - /// No host cert was available from any source — detected in libfreemkv - /// before the unlocker runs, so the MKB generation is still known. - NoHostCert { mkb: Option }, - /// The AACS unlocker ran and reported a specific failure. - Unlock(freemkv_unlock::UnlockError), + /// No host cert was available from any source (detected before the unlocker runs). + NoHostCert, + /// The unlocker had certs but none was usable host-side. + NoUsableHostCert, + /// The drive rejected every offered cert. + Rejected, + /// Auth succeeded but no Volume ID could be read. + VidUnavailable, +} + +/// A failed cert route: the dead bus aborts the scan, every other failure is recorded. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CertFail { + Transport, + Other(CertUnlockFailure), +} + +impl From for CertFail { + fn from(e: freemkv_unlock::UnlockError) -> Self { + use freemkv_unlock::UnlockError as U; + match e { + U::Transport => CertFail::Transport, + U::NoUsableHostCert => CertFail::Other(CertUnlockFailure::NoUsableHostCert), + U::VidUnavailable => CertFail::Other(CertUnlockFailure::VidUnavailable), + U::HandshakeRejected | U::NotApplicable => CertFail::Other(CertUnlockFailure::Rejected), + } + } +} + +/// A successful cert-route step: a handshake result, or a firmware unlocker's claim. +enum CertRoute { + Handshake(HandshakeResult), + FirmwareUnlocked, } impl AacsCertUnlocker<'_> { - // Runs the host-cert mutual-auth handshake and returns a CertUnlockFailure on every no-VID - // outcome. + // Runs the host-cert mutual-auth handshake. Host certs come from the key sources + // best-first, with no disc input (no MKB read from the drive or the disc). fn authenticate( &self, session: &mut crate::drive::Drive, - ) -> std::result::Result { - use crate::aacs; - use freemkv_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::inf::read_mkb_from_drive(session.scsi_mut()) - .ok() - .and_then(|m| aacs::mkb::mkb_version(&m)); - - // Host certs are keysource-served, never compiled in — unioned from - // `DriveCredentials` and the key-source layer. With ZERO certs, NoHostCert - // (folded to AacsNoHostCert) preserves the graceful disc-hash → VUK fallback. - let host_certs = Disc::collect_host_certs(self.opts, mkb_gen); + ) -> std::result::Result { + let host_certs = Disc::collect_host_certs(self.opts, None); if host_certs.is_empty() { - tracing::warn!( + tracing::info!( 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(CertUnlockFailure::NoHostCert { mkb: mkb_gen }); + return Err(CertFail::Other(CertUnlockFailure::NoHostCert)); } - // Hand the collected certs to the AACS unlocker; the cert-route bus removal - // depends on read_data_key, NOT a drive unlock. Borrow checker can't split - // `session` across `scsi_mut()` + `&drive_id`, so clone the cheap identity. + // Borrow checker can't split `session` across `scsi_mut()` + `&drive_id`. let drive_id = session.drive_id.clone(); let fu_certs = crate::unlock_bridge::map_host_certs(&host_certs); - // `matched` (the unlocker name) is intentionally unused: on this disc-keyed - // cert route it is only ever "AACS"/"DVD"/"", never a drive unlock — see the - // `drive_unlocked: false` note below. + // `matched` is only ever "AACS"/"DVD"/"" on this disc-keyed route, never a drive unlock. let (_matched, unlock_res) = crate::unlock_bridge::run_bus( session.scsi_mut(), &drive_id, freemkv_unlock::DiscKind::Aacs, &fu_certs, ); - // `Ok(None)` = the cert route declined/was rejected (certs were present — - // checked above — so this is a rejected handshake). `Err` = dead bus. + // `Ok(None)` with certs present = a rejected handshake. `Err` = dead bus. let unlocked = match unlock_res { Ok(Some(u)) => u, - Ok(None) => return Err(CertUnlockFailure::Unlock(UnlockError::HandshakeRejected)), - Err(e) => return Err(CertUnlockFailure::Unlock(e)), + Ok(None) => return Err(CertFail::Other(CertUnlockFailure::Rejected)), + Err(e) => return Err(e.into()), }; - // The cert handshake yields a VID on success; its absence is VidUnavailable. let Some(volume_id) = unlocked.vid else { - return Err(CertUnlockFailure::Unlock(UnlockError::VidUnavailable)); + return Err(CertFail::Other(CertUnlockFailure::VidUnavailable)); }; Ok(HandshakeResult { volume_id, read_data_key: unlocked.bus_key, - // The generic `Unlocked` contract carries no bus-key error code; the - // AACS-specific "why the read_data_key read failed" diagnostic does - // not cross the seam. The bus-key gate keys off presence, not cause. + // The generic `Unlocked` contract carries no bus-key error code. read_data_key_err: None, - // Always false on the cert route: `run_bus` dispatches ONLY disc-keyed unlockers - // (AACS, DVD), so `matched` is never a firmware/drive unlocker (freemkv/LD/Renesas); - // a genuine DRIVE unlock instead sets `drive_unlocked: true` via the OEM-VID short-circuit in `do_handshake_cert` (the rdk-less case, credited by the gate like a cert `read_data_key`). + // The cert route never unlocks the drive; a firmware unlock arrives via the OEM VID. drive_unlocked: false, }) } } -// Maps CertUnlockFailure to the Error variant do_handshake_cert has always surfaced, so -// scan_with's rendering stays unchanged. -fn unlock_error_to_error(e: &CertUnlockFailure) -> Error { - use freemkv_unlock::UnlockError; +// Maps a recorded cert-route failure to the handshake-class Error the scan records. +fn unlock_error_to_error(e: CertUnlockFailure) -> Error { match e { - CertUnlockFailure::NoHostCert { .. } - | CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert) => Error::AacsNoHostCert { + CertUnlockFailure::NoHostCert => Error::AacsNoHostCert { path: "".into(), }, - CertUnlockFailure::Unlock(UnlockError::VidUnavailable) => Error::AacsVidUnavailable, - CertUnlockFailure::Unlock( - UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Transport, - ) => Error::AacsHostCertRejected, + // Certs were offered but none survived the LOCAL keydb check (dead + // pairing) — never reached the drive, so this is not a rejection. + CertUnlockFailure::NoUsableHostCert => Error::AacsNoUsableHostCert, + CertUnlockFailure::VidUnavailable => Error::AacsVidUnavailable, + CertUnlockFailure::Rejected => Error::AacsHostCertRejected, } } /// Map a [`CertUnlockFailure`] to a structured [`crate::aacs::trace::UnlockOutcome`] -/// for the resolution trace (English-free). -fn cert_unlock_outcome(e: &CertUnlockFailure) -> crate::aacs::trace::UnlockOutcome { +/// for the resolution trace (English-free). Scan has no MKB generation to report. +fn cert_unlock_outcome(e: CertUnlockFailure) -> crate::aacs::trace::UnlockOutcome { use crate::aacs::trace::UnlockOutcome; - use freemkv_unlock::UnlockError; match e { - CertUnlockFailure::NoHostCert { mkb } => UnlockOutcome::NoUsableHostCert { mkb: *mkb }, - CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert) => { + CertUnlockFailure::NoHostCert | CertUnlockFailure::NoUsableHostCert => { UnlockOutcome::NoUsableHostCert { mkb: None } } - CertUnlockFailure::Unlock(UnlockError::VidUnavailable) => UnlockOutcome::VidUnavailable, - CertUnlockFailure::Unlock( - UnlockError::HandshakeRejected | UnlockError::NotApplicable | UnlockError::Transport, - ) => UnlockOutcome::HandshakeRejected, + CertUnlockFailure::VidUnavailable => UnlockOutcome::VidUnavailable, + CertUnlockFailure::Rejected => UnlockOutcome::HandshakeRejected, } } -// Did the cert handshake actually carry a Volume ID? Extracted so it can be tested as a VALUE -// instead of asserting on a racy tracing field. -fn handshake_has_volume_id(h: &HandshakeResult) -> bool { - h.volume_id != [0u8; 16] +// The AACS bus step of a live scan, run after the AACS files are captured. A dead bus +// aborts like `Drive::init`, and a Stop during the AKE is `Halted`. +pub(super) fn aacs_bus_step( + session: &mut crate::drive::Drive, + opts: &ScanOptions, +) -> Result { + if session.is_halted() { + return Err(Error::Halted); + } + let t0 = std::time::Instant::now(); + let r = Disc::do_handshake_cert(session, opts); + tracing::info!( + target: "freemkv::scan", + phase = "do_handshake", + ok = matches!(r, Ok(CertRoute::Handshake(_))), + elapsed_ms = t0.elapsed().as_millis() as u64, + "end" + ); + if session.is_halted() { + return Err(Error::Halted); + } + match r { + Ok(CertRoute::Handshake(h)) => Ok(BusOutcome::Handshake(h)), + Ok(CertRoute::FirmwareUnlocked) => Ok(BusOutcome::FirmwareUnlocked), + Err(CertFail::Transport) => Err(crate::unlock_bridge::unlock_transport_error()), + Err(CertFail::Other(f)) => { + tracing::info!( + target: "freemkv::disc", + phase = "cert_handshake_outcome", + outcome = ?cert_unlock_outcome(f), + "AACS cert handshake produced no VID; a key source may still supply this disc's key." + ); + Ok(BusOutcome::Failed(unlock_error_to_error(f))) + } + } } impl Disc { - // SCSI handshake — drives the VID-acquisition flow via do_handshake_cert and returns a - // structured HandshakeResult for downstream key resolution. - pub(super) fn do_handshake( - session: &mut crate::drive::Drive, - opts: &ScanOptions, - ) -> (Option, Option) { - let t0 = std::time::Instant::now(); - tracing::info!(target: "freemkv::scan", phase = "do_handshake", "begin"); - // VID comes from the unlocker's OEM path when available (decoupled - // from the host cert + HRL), else the cert-based handshake — both - // resolved inside `do_handshake_cert`. - let (result, err) = Self::do_handshake_cert(session, opts); - tracing::info!( - target: "freemkv::scan", - phase = "do_handshake", - ok = result.is_some(), - error_code = err.as_ref().map(|e| e.code()), - elapsed_ms = t0.elapsed().as_millis() as u64, - "end" - ); - (result, err) - } - - // Cert-based AACS handshake — the cert route for VID acquisition. Checks for an OEM VID - // stashed at drive init() first; skips cert auth if found. + // VID acquisition: the OEM VID stashed at init, else nothing for a firmware-claimed + // drive, else the cert route. fn do_handshake_cert( session: &mut crate::drive::Drive, opts: &ScanOptions, - ) -> (Option, Option) { - // OEM VID shortcut: a matching unlocker stashed the disc's Volume ID at - // drive `init()`; use it and SKIP the cert handshake. It yields no - // `read_data_key`, caught by the bus-key gate in `resolve_vid_only`. + ) -> std::result::Result { + // OEM VID shortcut: the unlocker matched and unlocked the drive at init, so it + // serves clear content (credited as `drive_unlocked`); no bus key comes with it. if let Some(volume_id) = session.oem_vid() { tracing::debug!( target: "freemkv::disc", phase = "oem_vid_ok", "Volume ID supplied by the drive unlocker at init; skipping the AACS host-certificate handshake." ); - return ( - Some(HandshakeResult { - volume_id, - read_data_key: None, - // OEM/VID-only path never attempts the bus-key read — None here - // is "not attempted", not "failed". - read_data_key_err: None, - // The unlocker stashed this VID at init, which means it - // matched and unlocked the drive — it now serves clear content, - // so bus encryption is removed at the drive. Credit it. - drive_unlocked: true, - }), - None, - ); + return Ok(CertRoute::Handshake(HandshakeResult { + volume_id, + read_data_key: None, + read_data_key_err: None, + drive_unlocked: true, + })); } - // A picked unlocker is the sole VID source — no fall-through. A - // firmware-claimed drive with no VID stays None: the AACS cert route's - // REPORT KEY AGID cycle would poison it here (see the guard test). + // A firmware-claimed drive with no VID stays that way: the cert route's REPORT + // KEY AGID cycle would poison it (see the guard test). if let Some(unlocker) = session.unlocker_name() { tracing::debug!( target: "freemkv::disc", @@ -336,31 +512,16 @@ impl Disc { unlocker, "Drive claimed by a firmware unlocker with no Volume ID; not running the AACS cert handshake — a key source may supply the key." ); - return (None, None); + return Ok(CertRoute::FirmwareUnlocked); } - tracing::debug!( target: "freemkv::disc", phase = "oem_vid_none", - "No firmware unlocker claimed the drive; running the AACS host-certificate handshake (AacsCertUnlocker) — the cert route IS the unlocker here." + "No firmware unlocker claimed the drive; running the AACS host-certificate handshake." ); - - // Cert path: `AacsCertUnlocker` collects host certs, runs - // `aacs_authenticate` per cert, and on success reads VID + read_data_key. - // Its `UnlockError` folds back to this function's usual `Error` variants. - 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))) - } - } + AacsCertUnlocker { opts } + .authenticate(session) + .map(CertRoute::Handshake) } // Collects every AACS host cert the caller carries, from DriveCredentials AND the @@ -369,109 +530,8 @@ impl Disc { opts: &ScanOptions, mkb: Option, ) -> Vec { - // Delegates to the shared cert primitive (the external freemkv-unlock-aacs - // plugin uses the same one). Kept as a thin Disc method so the existing - // collect_host_certs_* unit tests and call sites are unchanged. crate::aacs::host_certs::collect_host_certs(opts, mkb) } - - // Builds a keys-free AACS state carrying only the Volume ID (+ version), for callers that - // resolve Unit Keys out-of-band with the keydb disabled. - pub(super) fn resolve_vid_only( - udf_fs: &udf::UdfFs, - reader: &mut dyn SectorSource, - source: BusSource<'_>, - ) -> Result { - use crate::aacs; - let handshake = source.handshake(); - - let uk_ro_data = - aacs::read_first(&aacs::role_paths(udf_fs, aacs::AacsRole::UnitKey), |p| { - udf_fs.read_file(reader, p) - })?; - let dh = aacs::inf::disc_hash(&uk_ro_data); - - let cc_raw = aacs::read_first( - &aacs::role_paths(udf_fs, aacs::AacsRole::ContentCert), - |p| udf_fs.read_file(reader, p), - ) - .ok(); - let cc = cc_raw.as_deref().and_then(aacs::inf::parse_content_cert); - // No-cert default = UHD (V20 stride), matching `read_aacs_version` so - // scanned/out-of-band versions agree; the main path fails loudly on a - // wrong stride, so this conservative default is safe here too. - let version = cc - .as_ref() - .map(|c| c.version.major()) - .unwrap_or(aacs::mkb::AACS_MAJOR_UHD); - // Fail safe: an unparseable cert, or none on a live drive (even one whose - // handshake failed) with a disc positively known to be UHD, may mean bus encryption. - let bus_encryption = match &cc { - Some(c) => c.bus_encryption, - None => { - let live = !matches!(source, BusSource::FileOrIso); - cc_raw.is_some() || (live && disc_is_uhd(udf_fs, reader)) - } - }; - - // Bus encryption still in place: the state is kept for triage and `scan_with` - // records the blocking error (`aacs_scan_error`), which makes it sticky. - if !bus_encryption_removed(bus_encryption, source) { - let (rdk_err, has_vid) = handshake - .map(|h| (h.read_data_key_err, handshake_has_volume_id(h))) - .unwrap_or((None, false)); - tracing::warn!( - target: "freemkv::disc", - phase = "bus_key_unavailable", - read_data_key_err = ?rdk_err, - has_volume_id = has_vid, - "Disc declares bus encryption but it could not be removed: no unlocker \ - unlocked the drive AND no handshake produced a read_data_key. Keys will be \ - refused; they would decrypt to garbage." - ); - } - // Use the SAME bounded reader as `read_aacs_inputs` (`read_mkb_content`), - // NOT `read_file` (fails on the ~128 MiB zero-padded MKB_RO/RW, silently - // emptying `a.mkb`). A read ERROR is logged; we still continue with an empty MKB. - let mkb_bytes = match Self::read_mkb_content(reader, udf_fs) { - Ok(m) => m, - Err(e) => { - tracing::warn!( - target: "freemkv::disc", - phase = "scan_aacs_mkb", - error = %e, - "MKB read failed at scan; AACS inputs will carry an empty MKB \ - (online key resolution cannot proceed without it). Continuing \ - — disc-hash-keyed lookups are unaffected." - ); - Vec::new() - } - }; - let mkb_ver = aacs::mkb::mkb_version(&mkb_bytes); - - tracing::debug!( - target: "freemkv::disc", - phase = "scan_aacs_vid_only", - disc_hash = %aacs::inf::disc_hash_hex(&dh), - version, - bus_encryption, - has_vid = handshake.is_some(), - "Read this disc's AACS data (media-key block and unit-key file). No decryption key computed here — a key source supplies it." - ); - - Ok(AacsState { - version, - bus_encryption, - mkb_version: mkb_ver, - disc_hash: aacs::inf::disc_hash_hex(&dh), - key_source: KeyOrigin::ExternalUk, - vuk: None, - unit_keys: vec![], - volume_id: handshake.map(|h| h.volume_id).unwrap_or([0u8; 16]), - uk_ro: uk_ro_data, - mkb: mkb_bytes, - }) - } } #[cfg(test)] @@ -684,17 +744,35 @@ mod tests { } // --------------------------------------------------------------- - // Tests: resolve_vid_only + // Tests: capture + verdict (the keys-free state) // --------------------------------------------------------------- + fn from_for(bus: &BusOutcome) -> CaptureFrom { + match bus { + BusOutcome::FileOrIso => CaptureFrom::Image, + _ => CaptureFrom::Live { raw_copy: false }, + } + } + + /// The keys-free state a scan keeps for `bus`, or the recorded error when there is none. + fn vid_only( + udf: &udf::UdfFs, + reader: &mut dyn SectorSource, + bus: &BusOutcome, + ) -> Result { + let cap = capture(reader, udf, from_for(bus))?; + let (state, err) = resolve_aacs(cap, bus); + state.ok_or_else(|| err.unwrap_or(Error::AacsNoKeys)) + } + /// Missing Unit_Key_RO.inf (and its DUPLICATE) → Error::AacsNoKeys /// (encrypt.rs `.map_err(|_| Error::AacsNoKeys)`). Never panics. #[test] - fn resolve_vid_only_missing_unit_key_ro_errors() { + fn vid_only_missing_unit_key_ro_errors() { let mut disc = MemDisc::new(); // AACS dir exists but has no Unit_Key_RO.inf. let udf = build_aacs_fs(&mut disc, &[]); - let err = Disc::resolve_vid_only(&udf, &mut disc, BusSource::FileOrIso) + let err = vid_only(&udf, &mut disc, &BusOutcome::FileOrIso) .expect_err("missing Unit_Key_RO must error"); assert!(matches!(err, Error::AacsNoKeys)); } @@ -702,7 +780,7 @@ mod tests { /// A V10 content cert (type 0x00, bus_encryption off) → version 1, /// bus_encryption false (encrypt.rs version match: Some(V10) → 1). #[test] - fn resolve_vid_only_v10_cert_sets_version_1() { + fn vid_only_v10_cert_sets_version_1() { let mut disc = MemDisc::new(); let udf = build_aacs_fs( &mut disc, @@ -721,7 +799,7 @@ mod tests { }, ], ); - let st = Disc::resolve_vid_only(&udf, &mut disc, BusSource::FileOrIso).expect("state"); + let st = vid_only(&udf, &mut disc, &BusOutcome::FileOrIso).expect("state"); assert_eq!(st.version, 1, "V10 cert → AACS version 1"); assert!(!st.bus_encryption); assert_eq!(st.key_source, KeyOrigin::ExternalUk); @@ -731,7 +809,7 @@ mod tests { /// A V20 content cert (type != 0x00) → version 2 (encrypt.rs Some(_) → 2). #[test] - fn resolve_vid_only_v20_cert_sets_version_2() { + fn vid_only_v20_cert_sets_version_2() { let mut disc = MemDisc::new(); let udf = build_aacs_fs( &mut disc, @@ -750,7 +828,7 @@ mod tests { }, ], ); - let st = Disc::resolve_vid_only(&udf, &mut disc, BusSource::FileOrIso).expect("state"); + let st = vid_only(&udf, &mut disc, &BusOutcome::FileOrIso).expect("state"); assert_eq!(st.version, 2, "V20 cert → AACS version 2"); assert!(st.bus_encryption, "cert bus_encryption bit must propagate"); } @@ -758,7 +836,7 @@ mod tests { // No content cert → version defaults to UHD (major 2), matching read_aacs_version (audit // #4). bus_encryption false (unreadable → off). #[test] - fn resolve_vid_only_no_cert_defaults_version_uhd() { + fn vid_only_no_cert_defaults_version_uhd() { let mut disc = MemDisc::new(); let udf = build_aacs_fs( &mut disc, @@ -769,7 +847,7 @@ mod tests { contents: vec![0xAB; 32], }], ); - let st = Disc::resolve_vid_only(&udf, &mut disc, BusSource::FileOrIso).expect("state"); + let st = vid_only(&udf, &mut disc, &BusOutcome::FileOrIso).expect("state"); assert_eq!( st.version, aacs::mkb::AACS_MAJOR_UHD, @@ -782,7 +860,7 @@ mod tests { /// and uppercase (aacs::inf::disc_hash + disc_hash_hex). The state's /// disc_hash must match independently computing it over the same bytes. #[test] - fn resolve_vid_only_disc_hash_is_sha1_of_unit_key_ro() { + fn vid_only_disc_hash_is_sha1_of_unit_key_ro() { let mut disc = MemDisc::new(); let uk = vec![0x42u8; 100]; let udf = build_aacs_fs( @@ -794,7 +872,7 @@ mod tests { contents: uk.clone(), }], ); - let st = Disc::resolve_vid_only(&udf, &mut disc, BusSource::FileOrIso).expect("state"); + let st = vid_only(&udf, &mut disc, &BusOutcome::FileOrIso).expect("state"); let expected = aacs::inf::disc_hash_hex(&aacs::inf::disc_hash(&uk)); assert_eq!(st.disc_hash, expected); assert!(st.disc_hash.starts_with("0x")); @@ -806,7 +884,7 @@ mod tests { /// fixed-region zero-pad (encrypt.rs `mkb_bytes.truncate(mkb_content_len)`). /// A 16-byte record + 5000 bytes of padding must trim to 16. #[test] - fn resolve_vid_only_trims_mkb_padding() { + fn vid_only_trims_mkb_padding() { let mut disc = MemDisc::new(); let mkb = build_mkb(77, 5000); // record + 4984 pad bytes assert_eq!(mkb.len(), 5000); @@ -827,7 +905,7 @@ mod tests { }, ], ); - let st = Disc::resolve_vid_only(&udf, &mut disc, BusSource::FileOrIso).expect("state"); + let st = vid_only(&udf, &mut disc, &BusOutcome::FileOrIso).expect("state"); // Real record stream is the single 16-byte type-0x10 record. assert_eq!( st.mkb.len(), @@ -842,7 +920,7 @@ mod tests { /// With no MKB file present, mkb is empty and mkb_version is None /// (encrypt.rs `.unwrap_or_default()` → empty Vec; mkb_version(&[]) None). #[test] - fn resolve_vid_only_no_mkb_is_empty() { + fn vid_only_no_mkb_is_empty() { let mut disc = MemDisc::new(); let udf = build_aacs_fs( &mut disc, @@ -853,7 +931,7 @@ mod tests { contents: vec![0xAB; 32], }], ); - let st = Disc::resolve_vid_only(&udf, &mut disc, BusSource::FileOrIso).expect("state"); + let st = vid_only(&udf, &mut disc, &BusOutcome::FileOrIso).expect("state"); assert!(st.mkb.is_empty()); assert_eq!(st.mkb_version, None); } @@ -862,7 +940,7 @@ mod tests { /// AacsState (encrypt.rs `handshake.map(|h| h.volume_id)` / /// `handshake.and_then(|h| h.read_data_key)`). #[test] - fn resolve_vid_only_propagates_handshake_vid_and_rdk() { + fn vid_only_propagates_handshake_vid_and_rdk() { let mut disc = MemDisc::new(); let udf = build_aacs_fs( &mut disc, @@ -881,7 +959,7 @@ mod tests { read_data_key_err: None, drive_unlocked: false, }; - let st = Disc::resolve_vid_only(&udf, &mut disc, BusSource::Handshake(&hs)).expect("state"); + let st = vid_only(&udf, &mut disc, &BusOutcome::Handshake(hs)).expect("state"); assert_eq!(st.volume_id, vid); // The bus key no longer propagates onto AacsState — it feeds the drive's // single de-bus point via the handshake, not a decrypt-time field. @@ -917,13 +995,13 @@ mod tests { /// state is kept for triage, and the policy records AacsBusKeyUnavailable — /// the wrong-keys guard (a VID-only/OEM handshake cannot remove bus encryption). #[test] - fn resolve_vid_only_bus_encrypted_live_drive_without_rdk_is_blocked() { + fn vid_only_bus_encrypted_live_drive_without_rdk_is_blocked() { let (mut disc, udf) = disc_with_cert(0x10, true); let hs = hs_with(false, None); - let src = BusSource::Handshake(&hs); - let st = Disc::resolve_vid_only(&udf, &mut disc, src).expect("metadata kept"); + let src = BusOutcome::Handshake(hs); + let st = vid_only(&udf, &mut disc, &src).expect("metadata kept"); assert!(matches!( - super::aacs_scan_error(st.bus_encryption, src, None), + super::aacs_verdict(st.bus_encryption, &src), Some(Error::AacsBusKeyUnavailable) )); } @@ -931,7 +1009,7 @@ mod tests { /// 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() { + fn vid_only_bus_encrypted_live_drive_with_rdk_ok() { let (mut disc, udf) = disc_with_cert(0x10, true); let hs = HandshakeResult { volume_id: [0x11u8; 16], @@ -939,8 +1017,8 @@ mod tests { read_data_key_err: None, drive_unlocked: false, }; - let st = Disc::resolve_vid_only(&udf, &mut disc, BusSource::Handshake(&hs)) - .expect("bus key present → ok"); + let st = + vid_only(&udf, &mut disc, &BusOutcome::Handshake(hs)).expect("bus key present → ok"); assert!(st.bus_encryption); } @@ -971,7 +1049,7 @@ mod tests { read_data_key_err: None, drive_unlocked: true, }; - let st = Disc::resolve_vid_only(&udf, &mut disc, BusSource::Handshake(&hs)) + let st = vid_only(&udf, &mut disc, &BusOutcome::Handshake(hs)) .expect("a drive-unlocked disc removes bus encryption even without a read_data_key"); assert!(st.bus_encryption); } @@ -979,14 +1057,14 @@ mod tests { // Fail safe: a cert that reads but does not parse (unknown type byte) may // declare bus encryption, so a live drive without a Read Data Key refuses keys. #[test] - fn resolve_vid_only_unparseable_cert_is_treated_as_bus_encrypted() { + fn vid_only_unparseable_cert_is_treated_as_bus_encrypted() { let (mut disc, udf) = disc_with_cert(0x55, false); let hs = hs_with(false, None); - let src = BusSource::Handshake(&hs); - let st = Disc::resolve_vid_only(&udf, &mut disc, src).expect("metadata kept"); + let src = BusOutcome::Handshake(hs); + let st = vid_only(&udf, &mut disc, &src).expect("metadata kept"); assert!(st.bus_encryption); assert!(matches!( - super::aacs_scan_error(st.bus_encryption, src, None), + super::aacs_verdict(st.bus_encryption, &src), Some(Error::AacsBusKeyUnavailable) )); } @@ -995,34 +1073,40 @@ mod tests { // disc is positively UHD (index.bdmv version "0300"); a BD ("0200") resolves. // A FAILED live handshake is still a live drive (must not fail open). #[test] - fn resolve_vid_only_missing_cert_refuses_only_on_a_known_uhd_disc() { + fn vid_only_missing_cert_refuses_only_on_a_known_uhd_disc() { let uk = [AacsFile { name: "Unit_Key_RO.inf", icb_lba: 60, data_lba: 5000, contents: vec![0xAB; 32], }]; - let hs = hs_with(false, None); let mut uhd = MemDisc::new(); let udf = build_aacs_fs_with_index(&mut uhd, &uk, Some(b"INDX0300")); - for src in [BusSource::Handshake(&hs), BusSource::HandshakeFailed] { - let st = Disc::resolve_vid_only(&udf, &mut uhd, src).expect("state"); + for src in [ + BusOutcome::Handshake(hs_with(false, None)), + BusOutcome::Failed(Error::AacsHostCertRejected), + ] { + let st = vid_only(&udf, &mut uhd, &src).expect("state"); assert!( st.bus_encryption, "UHD live drive must assume bus encryption" ); - let err = super::aacs_scan_error(st.bus_encryption, src, None); - assert!(matches!(err, Some(Error::AacsBusKeyUnavailable)), "{err:?}"); + let err = super::aacs_verdict(st.bus_encryption, &src); + let want = match src { + BusOutcome::Failed(_) => crate::error::E_AACS_HOST_CERT_REJECTED, + _ => crate::error::E_AACS_BUS_KEY_UNAVAILABLE, + }; + assert_eq!(err.map(|e| e.code()), Some(want)); } - let iso = Disc::resolve_vid_only(&udf, &mut uhd, BusSource::FileOrIso).expect("ISO"); - assert!(super::aacs_scan_error(iso.bus_encryption, BusSource::FileOrIso, None).is_none()); + let iso = vid_only(&udf, &mut uhd, &BusOutcome::FileOrIso).expect("ISO"); + assert!(super::aacs_verdict(iso.bus_encryption, &BusOutcome::FileOrIso).is_none()); for index in [Some(&b"INDX0200"[..]), None] { let mut bd = MemDisc::new(); let udf = build_aacs_fs_with_index(&mut bd, &uk, index); - let src = BusSource::Handshake(&hs); - let st = Disc::resolve_vid_only(&udf, &mut bd, src).expect("state"); + let src = BusOutcome::Handshake(hs_with(false, None)); + let st = vid_only(&udf, &mut bd, &src).expect("state"); assert!( - super::aacs_scan_error(st.bus_encryption, src, None).is_none(), + super::aacs_verdict(st.bus_encryption, &src).is_none(), "BD / unknown must resolve: {index:?}" ); } @@ -1032,10 +1116,9 @@ mod tests { /// 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() { + fn vid_only_bus_encrypted_iso_no_handshake_ok() { let (mut disc, udf) = disc_with_cert(0x10, true); - let st = Disc::resolve_vid_only(&udf, &mut disc, BusSource::FileOrIso) - .expect("ISO bus disc → ok"); + let st = vid_only(&udf, &mut disc, &BusOutcome::FileOrIso).expect("ISO bus disc → ok"); assert!(st.bus_encryption); } @@ -1048,16 +1131,7 @@ mod tests { let hs_err = Error::AacsNoHostCert { path: "".into(), }; - let scanned = Disc::scan_with( - &mut disc, - 10_000, - None, - Some(hs_err), - false, - &ScanOptions::default(), - udf, - ) - .expect("scan"); + let scanned = finish_disc(&mut disc, udf, BusOutcome::Failed(hs_err)); // Non-secret triage metadata survives; no keys, so nothing claims decryptability. let a = scanned.aacs.as_ref().expect("AACS metadata kept"); assert!(!a.disc_hash.is_empty(), "disc hash must survive"); @@ -1074,7 +1148,13 @@ mod tests { ); } - // Scan a synthetic AACS disc through `scan_with` with the given handshake outcome. + // Capture a synthetic AACS disc and `finish` it with the given bus outcome. + fn finish_disc(disc: &mut MemDisc, udf: udf::UdfFs, bus: BusOutcome) -> Disc { + let cap = capture(disc, &udf, from_for(&bus)).expect("capture"); + Disc::finish(disc, 10_000, udf, Some((cap, bus)), &ScanOptions::default()).expect("scan") + } + + // Scan a synthetic AACS disc with the given handshake outcome. fn scan_cert_disc( cert_type: u8, bus: bool, @@ -1082,16 +1162,12 @@ mod tests { hs_err: Option, ) -> Disc { let (mut disc, udf) = disc_with_cert(cert_type, bus); - Disc::scan_with( - &mut disc, - 10_000, - hs, - hs_err, - false, - &ScanOptions::default(), - udf, - ) - .expect("scan") + let outcome = match (hs, hs_err) { + (Some(h), _) => BusOutcome::Handshake(h), + (None, Some(e)) => BusOutcome::Failed(e), + (None, None) => BusOutcome::FileOrIso, + }; + finish_disc(&mut disc, udf, outcome) } fn no_host_cert() -> Option { @@ -1146,23 +1222,14 @@ mod tests { assert_eq!(e.code(), crate::error::E_NO_DISC_KEY); } - /// Unit_Key_RO.inf unreadable (no AACS state) after a failed live handshake: bus - /// state unknown, so supplied keys are refused as for a bus-blocked disc. + /// The `aacs = None` fail-safe: bus state unknown, so a recorded handshake-class + /// error refuses supplied keys as for a bus-blocked disc. #[test] fn failed_handshake_without_aacs_state_refuses_keys() { - let mut disc = MemDisc::new(); - let udf = build_aacs_fs(&mut disc, &[]); - let mut d = Disc::scan_with( - &mut disc, - 10_000, - None, - no_host_cert(), - false, - &ScanOptions::default(), - udf, - ) - .expect("scan"); - assert!(d.aacs.is_none()); + let (mut disc, udf) = disc_with_cert(0x10, true); + let mut d = finish_disc(&mut disc, udf, BusOutcome::FileOrIso); + d.aacs = None; + d.aacs_error = no_host_cert(); assert!( d.decrypt_with(Key::Unit(vec![(1, [0x11; 16])]), &[]) .is_err() @@ -1171,27 +1238,19 @@ mod tests { assert!(matches!(d.aacs_error, Some(Error::AacsNoHostCert { .. }))); } - /// Unreadable Unit_Key_RO.inf with a non-handshake-class handshake error: the - /// capture error is the one surfaced. + /// A live key-file failure recorded by a raw-copy scan refuses every key, even + /// with no AACS state to say the disc is bus-encrypted. #[test] - fn capture_error_surfaces_over_a_non_handshake_error() { - let mut disc = MemDisc::new(); - let udf = build_aacs_fs(&mut disc, &[]); - let d = Disc::scan_with( - &mut disc, - 10_000, - None, - Some(Error::Halted), - false, - &ScanOptions::default(), - udf, - ) - .expect("scan"); - assert!( - matches!(d.aacs_error, Some(Error::AacsNoKeys)), - "{:?}", - d.aacs_error - ); + fn unreadable_key_file_refuses_keys() { + let (mut disc, udf) = disc_with_cert(0x00, false); + let mut d = finish_disc(&mut disc, udf, BusOutcome::FileOrIso); + d.aacs = None; + d.aacs_error = Some(Error::AacsKeyFileUnreadable); + let e = d + .decrypt_with(Key::Unit(vec![(1, [0x11; 16])]), &[]) + .expect_err("no key file, no key"); + assert_eq!(e.code(), crate::error::E_AACS_KEY_FILE_UNREADABLE); + assert!(d.inject_unit_keys(vec![(1, [0x11; 16])]).is_err()); } /// A bus-blocked disc does not query key sources at all. @@ -1262,18 +1321,9 @@ mod tests { /// no error, unlike a failed handshake. An image source (FileOrIso) likewise. #[test] fn firmware_unlocked_and_image_sources_report_no_bus_error() { - for firmware_unlocked in [true, false] { + for bus in [BusOutcome::FirmwareUnlocked, BusOutcome::FileOrIso] { let (mut disc, udf) = disc_with_cert(0x10, true); - let scanned = Disc::scan_with( - &mut disc, - 10_000, - None, - None, - firmware_unlocked, - &ScanOptions::default(), - udf, - ) - .expect("scan"); + let scanned = finish_disc(&mut disc, udf, bus); assert!(scanned.aacs.is_some()); assert!(scanned.aacs_error.is_none(), "{:?}", scanned.aacs_error); } @@ -1283,7 +1333,7 @@ mod tests { /// 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() { + fn vid_only_aacs10_live_drive_without_rdk_ok() { let (mut disc, udf) = disc_with_cert(0x00, false); let hs = HandshakeResult { volume_id: [0x11u8; 16], @@ -1291,15 +1341,14 @@ mod tests { read_data_key_err: None, drive_unlocked: false, }; - let st = Disc::resolve_vid_only(&udf, &mut disc, BusSource::Handshake(&hs)) - .expect("AACS 1.0 → ok"); + let st = vid_only(&udf, &mut disc, &BusOutcome::Handshake(hs)).expect("AACS 1.0 → ok"); assert!(!st.bus_encryption); } /// With NO handshake, volume_id defaults to all-zero (encrypt.rs /// `.unwrap_or([0u8; 16])`) and read_data_key is None. #[test] - fn resolve_vid_only_no_handshake_zero_vid() { + fn vid_only_no_handshake_zero_vid() { let mut disc = MemDisc::new(); let udf = build_aacs_fs( &mut disc, @@ -1310,7 +1359,7 @@ mod tests { contents: vec![0xAB; 32], }], ); - let st = Disc::resolve_vid_only(&udf, &mut disc, BusSource::FileOrIso).expect("state"); + let st = vid_only(&udf, &mut disc, &BusOutcome::FileOrIso).expect("state"); assert_eq!(st.volume_id, [0u8; 16]); } @@ -1354,23 +1403,22 @@ mod tests { /// The policy: a handshake error always wins; otherwise bus encryption left in /// place is AacsBusKeyUnavailable; otherwise no error. #[test] - fn aacs_scan_error_policy() { - use super::{BusSource as B, aacs_scan_error as verdict}; - let plain = hs_with(false, None); - let rdk = hs_with(false, Some([0x22; 16])); + fn aacs_verdict_policy() { + use super::{BusOutcome as B, aacs_verdict as verdict}; assert!(matches!( - verdict(true, B::Handshake(&plain), None), + verdict(true, &B::Handshake(hs_with(false, None))), Some(Error::AacsBusKeyUnavailable) )); - assert!(verdict(true, B::Handshake(&rdk), None).is_none()); - assert!(verdict(true, B::FileOrIso, None).is_none()); - assert!(verdict(true, B::FirmwareUnlocked, None).is_none()); + assert!(verdict(true, &B::Handshake(hs_with(false, Some([0x22; 16])))).is_none()); + assert!(verdict(true, &B::Handshake(hs_with(true, None))).is_none()); + assert!(verdict(true, &B::FileOrIso).is_none()); + assert!(verdict(true, &B::FirmwareUnlocked).is_none()); assert!(matches!( - verdict(true, B::HandshakeFailed, Some(Error::AacsHostCertRejected)), + verdict(true, &B::Failed(Error::AacsHostCertRejected)), Some(Error::AacsHostCertRejected) )); // Bus off: a failed handshake blocks nothing, so it is not recorded. - assert!(verdict(false, B::HandshakeFailed, Some(Error::AacsHostCertRejected)).is_none()); + assert!(verdict(false, &B::Failed(Error::AacsHostCertRejected)).is_none()); } // Tests: read_vid_oem and collect_host_certs coverage notes. @@ -1441,7 +1489,7 @@ mod tests { ))); drive.set_unlocker_name_for_test("freemkv"); let opts = ScanOptions::default(); - let (result, err) = Disc::do_handshake_cert(&mut drive, &opts); + let r = Disc::do_handshake_cert(&mut drive, &opts); // Read the count BEFORE `drive` drops (Drop::cleanup issues a tray-unlock). assert_eq!( scsi_count.load(SeqCst), @@ -1449,12 +1497,8 @@ mod tests { "the guard must issue NO SCSI on a firmware-claimed drive with no VID" ); assert!( - result.is_none(), - "a firmware unlocker with no VID yields no HandshakeResult" - ); - assert!( - err.is_none(), - "no VID from a firmware unlocker is not an error — a key source may still supply the key" + matches!(r, Ok(CertRoute::FirmwareUnlocked)), + "a firmware unlocker with no VID is a claim, not an error — a key source may still supply the key" ); } @@ -1505,57 +1549,51 @@ mod tests { assert_eq!(tags, vec![1, 2, 3]); } - // AacsCertUnlocker outcome mapping: UnlockError → Error (legacy surface) - // and → UnlockOutcome (structured trace step). No English in either. - + // Cert-route outcome mapping: a dead bus is split out (it aborts the scan), every + // other failure maps to a recorded Error and a trace step. No English in either. #[test] - fn unlock_error_maps_to_legacy_error_variants() { - use freemkv_unlock::UnlockError; - // No host cert (libfreemkv-side, carries mkb) keeps the AacsNoHostCert - // sentinel path — as does the unlocker's own NoUsableHostCert. - match unlock_error_to_error(&CertUnlockFailure::NoHostCert { mkb: Some(68) }) { - Error::AacsNoHostCert { path } => assert_eq!(path, ""), - other => panic!("expected AacsNoHostCert, got {other:?}"), - } - match unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::NoUsableHostCert)) { + fn unlock_errors_split_transport_from_recorded_failures() { + use freemkv_unlock::UnlockError as U; + assert_eq!(CertFail::from(U::Transport), CertFail::Transport); + let recorded = |u: U| match CertFail::from(u) { + CertFail::Other(f) => f, + CertFail::Transport => panic!("only Transport is a dead bus"), + }; + // All-broken cert list (never reached the drive) is its own error — + // distinct from truly having no cert to offer at all. + assert!(matches!( + unlock_error_to_error(recorded(U::NoUsableHostCert)), + Error::AacsNoUsableHostCert + )); + match unlock_error_to_error(CertUnlockFailure::NoHostCert) { Error::AacsNoHostCert { path } => assert_eq!(path, ""), other => panic!("expected AacsNoHostCert, got {other:?}"), } assert!(matches!( - unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::VidUnavailable)), + unlock_error_to_error(recorded(U::VidUnavailable)), Error::AacsVidUnavailable )); - assert!(matches!( - unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::HandshakeRejected)), - Error::AacsHostCertRejected - )); - // A transport fault folds to the rejected surface too. - assert!(matches!( - unlock_error_to_error(&CertUnlockFailure::Unlock(UnlockError::Transport)), - Error::AacsHostCertRejected - )); + for u in [U::HandshakeRejected, U::NotApplicable] { + assert!(matches!( + unlock_error_to_error(recorded(u)), + Error::AacsHostCertRejected + )); + } } #[test] fn cert_unlock_outcome_maps_to_structured_trace_step() { use crate::aacs::trace::UnlockOutcome; - use freemkv_unlock::UnlockError; - // The libfreemkv-side no-cert case carries the MKB generation. assert_eq!( - cert_unlock_outcome(&CertUnlockFailure::NoHostCert { mkb: Some(77) }), - UnlockOutcome::NoUsableHostCert { mkb: Some(77) } + cert_unlock_outcome(CertUnlockFailure::NoHostCert), + UnlockOutcome::NoUsableHostCert { mkb: None } ); assert_eq!( - cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::VidUnavailable)), + cert_unlock_outcome(CertUnlockFailure::VidUnavailable), UnlockOutcome::VidUnavailable ); assert_eq!( - cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::HandshakeRejected)), - UnlockOutcome::HandshakeRejected - ); - // A transport fault folds to HandshakeRejected at the trace layer. - assert_eq!( - cert_unlock_outcome(&CertUnlockFailure::Unlock(UnlockError::Transport)), + cert_unlock_outcome(CertUnlockFailure::Rejected), UnlockOutcome::HandshakeRejected ); } @@ -1582,15 +1620,16 @@ mod tests { let vid = [0x7Au8; 16]; drive.set_oem_vid_for_test(vid); let opts = ScanOptions::default(); - let (result, err) = Disc::do_handshake_cert(&mut drive, &opts); + let r = Disc::do_handshake_cert(&mut drive, &opts); // Read the count BEFORE `drive` drops (Drop::cleanup issues a tray-unlock). assert_eq!( scsi_count.load(SeqCst), 0, "the OEM-VID short-circuit must issue NO SCSI (the cert route is skipped)" ); - assert!(err.is_none(), "an OEM VID is a success, not an error"); - let hs = result.expect("OEM VID yields a HandshakeResult"); + let Ok(CertRoute::Handshake(hs)) = r else { + panic!("an OEM VID is a success, not an error"); + }; assert_eq!(hs.volume_id, vid, "the stashed OEM VID must propagate"); assert!( hs.drive_unlocked, @@ -1606,35 +1645,22 @@ mod tests { ); } - /// When NO unlocker serves a VID (neither `oem_vid` nor a firmware - /// `unlocker_name`), `do_handshake_cert` RUNS the cert route: it reads the - /// MKB from the drive (issuing SCSI) and collects host certs. With zero certs - /// available it folds to `AacsNoHostCert` and yields no HandshakeResult — but - /// crucially the cert route DID run (SCSI issued), unlike the firmware-claimed - /// guard which issues zero. This is the counterpart to the OEM-VID and - /// firmware-claimed short-circuits. + /// With no unlocker and no host cert the cert route runs host-side only: no SCSI + /// (no drive MKB read), folding to AacsNoHostCert. The AKE itself is pinned by + /// `scan_order_tests::no_unlocker_runs_cert_route_and_issues_ake`. #[test] - fn no_unlocker_runs_cert_route_and_issues_scsi() { + fn no_unlocker_and_no_cert_issues_no_scsi() { use std::sync::atomic::Ordering::SeqCst; let scsi_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let mut drive = crate::drive::Drive::from_transport_for_test(Box::new(CountingTransport( scsi_count.clone(), ))); - // No oem_vid, no unlocker_name → the cert route is the unlocker here. - let opts = ScanOptions::default(); - let (result, err) = Disc::do_handshake_cert(&mut drive, &opts); - assert!( - scsi_count.load(SeqCst) > 0, - "the cert route must have run (MKB read issues SCSI) when no unlocker serves a VID" - ); - assert!( - result.is_none(), - "with no host cert the cert route yields no VID" - ); - assert!( - matches!(err, Some(Error::AacsNoHostCert { .. })), - "no host cert from any source folds to AacsNoHostCert, got {err:?}" - ); + let r = Disc::do_handshake_cert(&mut drive, &ScanOptions::default()); + assert_eq!(scsi_count.load(SeqCst), 0); + assert!(matches!( + r, + Err(CertFail::Other(CertUnlockFailure::NoHostCert)) + )); } // --------------------------------------------------------------- @@ -1688,19 +1714,18 @@ mod tests { } #[test] - fn bus_source_classify_encodes_each_route() { - use super::BusSource as B; - let hs = hs_with(false, None); + fn bus_outcome_source_encodes_each_route() { + use super::{BusOutcome as O, BusSource as B}; assert!(matches!( - B::classify(Some(&hs), false, false), + O::Handshake(hs_with(false, None)).source(), B::Handshake(_) )); - assert!(matches!(B::classify(None, true, true), B::HandshakeFailed)); assert!(matches!( - B::classify(None, false, true), - B::FirmwareUnlocked + O::Failed(Error::AacsHostCertRejected).source(), + B::HandshakeFailed )); - assert!(matches!(B::classify(None, false, false), B::FileOrIso)); + assert!(matches!(O::FirmwareUnlocked.source(), B::FirmwareUnlocked)); + assert!(matches!(O::FileOrIso.source(), B::FileOrIso)); } /// A handshake that carries a VID but `read_data_key: None` must still surface @@ -1708,7 +1733,7 @@ mod tests { /// does not fire) the VID propagates; the bus key itself no longer lives on /// AacsState — it feeds the drive's single de-bus point via the handshake. #[test] - fn resolve_vid_only_surfaces_vid_with_absent_read_data_key() { + fn vid_only_surfaces_vid_with_absent_read_data_key() { let (mut disc, udf) = disc_with_cert(0x00, false); // V10, bus off let vid = [0x33u8; 16]; let hs = HandshakeResult { @@ -1717,7 +1742,7 @@ mod tests { read_data_key_err: None, drive_unlocked: false, }; - let st = Disc::resolve_vid_only(&udf, &mut disc, BusSource::Handshake(&hs)).expect("state"); + let st = vid_only(&udf, &mut disc, &BusOutcome::Handshake(hs)).expect("state"); assert_eq!( st.volume_id, vid, "the VID must propagate even without an RDK" diff --git a/src/disc/extract.rs b/src/disc/extract.rs index 7bf7cfa1..a04d7f0d 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -437,7 +437,8 @@ fn unique_probe_token() -> String { /// Recursively plan the host tree: collect directories to create and files to /// extract, sanitizing each component and detecting host-path collisions. -/// Skips the top-level `AACS/` and `CERTIFICATE/` directories (§7). +/// Skips the top-level `AACS/`, `CERTIFICATE/`, and HD DVD's discovered `X!` +/// AACS directories (§7). #[allow(clippy::too_many_arguments)] fn plan_tree( reader: &mut dyn SectorSource, @@ -451,16 +452,21 @@ fn plan_tree( dirs: &mut Vec, seen_hosts: &mut std::collections::HashMap, ) -> Result<()> { + // Same discovery the AACS capture reads use, so the two can't drift. + let hddvd_aacs_dir = is_root + .then(|| crate::aacs::find_hddvd_aacs_dir(fs)) + .flatten(); for entry in &dir.entries { if entry.name.is_empty() { // The "parent" FID (".") has an empty name — skip. continue; } // Strip AACS / CERTIFICATE at the top level only (a deeper dir of the - // same name, if it ever existed, is content). + // same name is content). if is_root && (entry.name.eq_ignore_ascii_case("AACS") - || entry.name.eq_ignore_ascii_case("CERTIFICATE")) + || entry.name.eq_ignore_ascii_case("CERTIFICATE") + || hddvd_aacs_dir.is_some_and(|d| std::ptr::eq(d, entry))) { continue; } @@ -1396,6 +1402,46 @@ mod tests { assert_eq!(res.bytes_lost(), 0); } + /// An HD DVD's `X!` AACS dir is stripped like BD's `AACS/`, so the decrypted + /// folder does not rescan as AACS-encrypted. + #[test] + fn hddvd_extract_strips_the_aacs_dir() { + let evo = vec![0x5Au8; 3 * 2048]; + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![ + DirSpec { + name: "HVDVD_TS".to_string(), + icb_lba: 20, + dir_data_lba: 21, + files: vec![file("MAIN.EVO", 30, 5000, evo.clone(), false)], + subdirs: vec![], + }, + DirSpec { + name: "AAC!".to_string(), + icb_lba: 22, + dir_data_lba: 23, + files: vec![ + file("MKBROM.AACS", 32, 33, vec![1; 64], false), + file("VTKF000.AACS", 34, 35, vec![2; 64], false), + ], + subdirs: vec![], + }, + ], + }; + let mut disc = build_disc(root); + let out = TmpDir::new("hddvd"); + let mut d = clear_disc(); + d.content_format = crate::disc::ContentFormat::MpegPs; + d.extract_tree(&mut disc, out.path(), &ExtractOptions::default()) + .expect("extract"); + assert_eq!(read_out(out.path(), "HVDVD_TS/MAIN.EVO"), Some(evo)); + assert!(!out.path().join("AAC!").exists(), "AAC!/ must be stripped"); + } + /// VIDEO_TS extraction writes VOBs + IFO/BUP. Here the content is clear /// (None keys), proving the tree walk + per-file write for the DVD layout; /// CSS descramble correctness is tested separately below. diff --git a/src/disc/hddvd.rs b/src/disc/hddvd.rs index 2e17e0c4..b9bc2ef7 100644 --- a/src/disc/hddvd.rs +++ b/src/disc/hddvd.rs @@ -297,25 +297,33 @@ struct XplTitle { chapters: Vec, } -/// Parse an `HH:MM:SS:FF` (or `MM:SS:FF`) timecode at `tick_base` frames/sec into -/// seconds. `None` on a malformed field. -fn parse_timecode(s: &str, tick_base: u32) -> Option { +/// Parse an `HH:MM:SS:FF` (or `MM:SS:FF`) title timecode, `FF` in `time_base` +/// frames/sec (the ``), into seconds. `None` on a malformed field. +/// A 60fps timeBase is scaled by 1001/1000: measured, not from a spec (a real disc's +/// second feature clip starts at 2951.604 s where 60 exact puts it at 2948.667 s). +/// Other timeBases (24/30/50) are unverified on real media and taken at face value. +fn parse_timecode(s: &str, time_base: u32) -> Option { let n: Vec = s .split(':') .map(|p| p.trim().parse::()) .collect::, _>>() .ok()?; - let tb = tick_base.max(1) as f64; + let tb = time_base.max(1) as f64; let (h, m, sec, f) = match n.as_slice() { [h, m, s, f] => (*h, *m, *s, *f), [m, s, f] => (0, *m, *s, *f), _ => return None, }; - Some(h as f64 * 3600.0 + m as f64 * 60.0 + sec as f64 + f as f64 / tb) + let nominal = h as f64 * 3600.0 + m as f64 * 60.0 + sec as f64 + f as f64 / tb; + Some(if time_base == 60 { + nominal * 1.001 + } else { + nominal + }) } -/// `tickBase="60fps"` → 60. Defaults to 60 when absent/unparseable. -fn parse_tick_base(s: &str) -> u32 { +/// `timeBase="60fps"` → 60. Defaults to 60 when unparseable. +fn parse_frame_rate(s: &str) -> u32 { let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect(); digits.parse().unwrap_or(60) } @@ -455,12 +463,12 @@ fn parse_xpl_titles(xpl: &[u8]) -> Vec { }; let local = |n: &roxmltree::Node, name: &str| n.tag_name().name() == name; - // tickBase lives on (default 60fps). - let tick_base = doc - .descendants() - .find(|n| local(n, "TitleSet")) - .and_then(|n| n.attribute("tickBase")) - .map(parse_tick_base) + // Title timecodes count frames in ; tickBase is the markup + // clock and only a fallback when timeBase is absent (default 60fps). + let title_set = doc.descendants().find(|n| local(n, "TitleSet")); + let time_base = title_set + .and_then(|n| n.attribute("timeBase").or_else(|| n.attribute("tickBase"))) + .map(parse_frame_rate) .unwrap_or(60); let mut titles = Vec::new(); @@ -480,7 +488,7 @@ fn parse_xpl_titles(xpl: &[u8]) -> Vec { .to_string(); let duration_secs = tnode .attribute("titleDuration") - .and_then(|s| parse_timecode(s, tick_base)) + .and_then(|s| parse_timecode(s, time_base)) .unwrap_or(0.0); let mut clips = Vec::new(); @@ -499,11 +507,11 @@ fn parse_xpl_titles(xpl: &[u8]) -> Vec { }; let begin_secs = c .attribute("titleTimeBegin") - .and_then(|s| parse_timecode(s, tick_base)) + .and_then(|s| parse_timecode(s, time_base)) .unwrap_or(0.0); let end_secs = c .attribute("titleTimeEnd") - .and_then(|s| parse_timecode(s, tick_base)) + .and_then(|s| parse_timecode(s, time_base)) .unwrap_or(begin_secs); clips.push(XplClip { evo, @@ -522,7 +530,7 @@ fn parse_xpl_titles(xpl: &[u8]) -> Vec { .filter(|n| local(n, "Chapter")) .filter_map(|ch| { ch.attribute("titleTimeBegin") - .and_then(|s| parse_timecode(s, tick_base)) + .and_then(|s| parse_timecode(s, time_base)) }) .take(MAX_XPL_CHAPTERS_PER_TITLE) .collect(); @@ -1691,17 +1699,67 @@ mod tests { #[test] fn parse_timecode_hhmmssff_at_60fps() { - // 01:37:20:00 = 5840 s exactly. - assert!((parse_timecode("01:37:20:00", 60).unwrap() - 5840.0).abs() < 1e-6); + // 01:37:20:00 = 5840 nominal seconds, x1.001 at the NTSC tick rate. + assert!((parse_timecode("01:37:20:00", 60).unwrap() - 5840.0 * 1.001).abs() < 1e-6); // 00:48:29:50 = 48m29s + 50/60 frames. - let want = 48.0 * 60.0 + 29.0 + 50.0 / 60.0; + let want = (48.0 * 60.0 + 29.0 + 50.0 / 60.0) * 1.001; assert!((parse_timecode("00:48:29:50", 60).unwrap() - want).abs() < 1e-6); // MM:SS:FF short form (hours omitted). - assert!((parse_timecode("02:05:15", 60).unwrap() - (125.0 + 15.0 / 60.0)).abs() < 1e-6); + let want = (125.0 + 15.0 / 60.0) * 1.001; + assert!((parse_timecode("02:05:15", 60).unwrap() - want).abs() < 1e-6); assert_eq!(parse_timecode("garbage", 60), None); assert_eq!(parse_timecode("", 60), None); } + // A real HD DVD (VPLST000.XPL, timeBase="60fps") against its own EVOs as measured + // by ffprobe: title time runs at 60000/1001, so 60 exact reads every title 0.1% short. + #[test] + fn xpl_60fps_timecodes_run_at_ntsc_rate() { + for (tc, evo_secs) in [ + ("00:49:08:40", 2951.604), // PEVOB_2 (feature clip 2) video start + ("00:14:49:00", 889.888), // hazards + ("00:04:42:00", 282.272), // daisy + ("00:02:01:56", 122.064), // intro + ] { + let got = parse_timecode(tc, 60).unwrap(); + assert!( + (got - evo_secs).abs() < 0.1, + "{tc}: {got} vs EVO {evo_secs}" + ); + } + // PAL tick base is exact. + assert!((parse_timecode("00:14:49:00", 50).unwrap() - 889.0).abs() < 1e-9); + } + + // Timecode frames count in timeBase, not tickBase (the markup tick clock). + #[test] + fn xpl_timecodes_use_time_base_not_tick_base() { + let xpl = |attrs: &str| { + format!( + r#" + + + <PrimaryAudioVideoClip titleTimeBegin="00:00:00:00" titleTimeEnd="00:00:10:30" src="file:///dvddisc/HVDVD_TS/A.MAP"/> + + +"# + ) + }; + let dur = |attrs: &str| parse_xpl_titles(xpl(attrs).as_bytes())[0].duration_secs; + let ntsc60 = 10.5 * 1.001; + let d = dur(r#"timeBase="60fps" tickBase="24fps""#); + assert!( + (d - ntsc60).abs() < 1e-9, + "timeBase 60 wins over tickBase 24: {d}" + ); + let d = dur(r#"tickBase="60fps""#); + assert!((d - ntsc60).abs() < 1e-9, "tickBase is the fallback: {d}"); + let d = dur(r#"timeBase="50fps" tickBase="60fps""#); + assert!((d - 10.6).abs() < 1e-9, "50fps unadjusted: {d}"); + let d = dur(""); + assert!((d - ntsc60).abs() < 1e-9, "default 60fps: {d}"); + } + #[test] fn evo_from_src_maps_map_sidecar_to_evo() { assert_eq!( @@ -1727,7 +1785,7 @@ mod tests { assert_eq!(mm.number, 2); assert_eq!(mm.name, "Main Movie"); assert!( - (mm.duration_secs - 5840.0).abs() < 1e-6, + (mm.duration_secs - 5840.0 * 1.001).abs() < 1e-6, "97:20 from titleDuration" ); // The layer-break split is ONE title with TWO clips, contiguous timeline. @@ -1743,7 +1801,7 @@ mod tests { "second clip carries a title-time offset" ); assert_eq!(mm.chapters.len(), 2); - assert!((mm.chapters[1] - (3.0 * 60.0 + 40.0 + 30.0 / 60.0)).abs() < 1e-6); + assert!((mm.chapters[1] - (3.0 * 60.0 + 40.0 + 30.0 / 60.0) * 1.001).abs() < 1e-6); let del = &titles[1]; assert_eq!(del.name, "Deleted Scenes - Veronica Past"); @@ -2005,7 +2063,7 @@ mod tests { .expect("MainMovie composed from the playlist"); assert_eq!(mm.playlist_id, 2); assert!( - (mm.duration_secs - 5840.0).abs() < 1.0, + (mm.duration_secs - 5840.0 * 1.001).abs() < 1.0, "97:20 duration from titleDuration, not 0/unknown" ); assert_eq!( diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 02f6c50f..f833282d 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -16,14 +16,14 @@ mod extract; mod hddvd; pub(crate) mod pgs_forced_probe; pub mod profile; +#[cfg(test)] +mod scan_order_tests; use crate::drive::Drive; use crate::error::{Error, Result}; use crate::sector::SectorSource; use crate::udf; -use encrypt::HandshakeResult; - // Re-export label classification enums alongside AudioStream / SubtitleStream // so the public surface keeps the structured metadata together. Callers map // these to display text in their own locale. @@ -496,9 +496,12 @@ pub struct Extent { } // THE ONE definition of "structurally AACS-encrypted" — shared by the fast identify and the -// full scan so they can't silently desync. Structural, not cryptographic. +// full scan so they can't silently desync. Structural, not cryptographic; HD DVD's `X!` dir +// is found by the same discovery the key-file reads use. pub(crate) fn aacs_dir_present(udf_fs: &crate::udf::UdfFs) -> bool { - udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some() + udf_fs.find_dir("/AACS").is_some() + || udf_fs.find_dir("/BDMV/AACS").is_some() + || crate::aacs::find_hddvd_aacs_dir(udf_fs).is_some() } // Title-ranking heuristic thresholds. Each gates a DISTINCT decision — several share a value @@ -1640,6 +1643,11 @@ pub struct ScanOptions { /// scanned title (e.g. `freemkv info`) opt in. The rip path leaves it off: /// the muxer detects forced during muxing without a second read. pub probe_forced_subtitles: bool, + /// The caller copies sectors raw and never decrypts or accepts keys (a raw + /// disc→ISO copy). A live AACS disc whose `Unit_Key_RO.inf` is unreadable then + /// still scans, with [`Error::AacsKeyFileUnreadable`] recorded and every key refused; + /// otherwise `Disc::scan` returns that error. + pub raw_copy: bool, } /// Quick disc identification — name, format, capacity. No title/stream parsing. @@ -1698,7 +1706,9 @@ impl Disc { /// Read UDF filesystem and set up buffered reader with metadata prefetched. /// Shared setup for both identify() and scan(). - fn read_udf(session: &mut Drive) -> Result<(u32, udf::BufferedSectorReader<'_>, udf::UdfFs)> { + fn read_udf( + session: &mut Drive, + ) -> Result<(u32, udf::BufferedSectorReader<'_, Drive>, udf::UdfFs)> { // READ CAPACITY is the sole authoritative whole-disc size (the UDF partition // is a subset — 288 sectors short on a real BD, truncating the backup anchor). // Its sporadic failures are ridden out by `read_capacity_retrying` (0 on hard fail). @@ -1708,7 +1718,7 @@ impl Disc { } // UDF filesystem + metadata-prefetched reader, without the READ CAPACITY step. - fn open_udf(session: &mut Drive) -> Result<(udf::BufferedSectorReader<'_>, udf::UdfFs)> { + fn open_udf(session: &mut Drive) -> Result<(udf::BufferedSectorReader<'_, Drive>, udf::UdfFs)> { let batch = detect_max_batch_sectors(session.device_path()); let mut buffered = udf::BufferedSectorReader::new(session, batch); let udf_fs = udf::read_filesystem(&mut buffered)?; @@ -1796,136 +1806,117 @@ impl Disc { }) } - /// Scan a disc — parse filesystem, playlists, streams, and set up AACS - /// decryption. This is the main entry point; after `scan()` the Disc is - /// ready (titles populated with streams, AACS inputs captured, content - /// readable and decryptable transparently). One pipeline, one order: - /// (1) read capacity + UDF filesystem, (2) AACS handshake + key - /// resolution, (3) parse playlists + streams, (4) apply labels. The - /// session must be open and unlocked (`Drive::open` handles this). All - /// disc reads use standard READ(10) via UDF — no vendor SCSI commands. + /// Scan a disc — parse filesystem, playlists, streams, and capture AACS + /// inputs. The main entry point; the session must be open. One order, the + /// standard one: (1) DVD only: CSS bus-auth, (2) read capacity + UDF, (3) read + /// the AACS files (`Unit_Key_RO.inf`, content cert, MKB) with plain READs, + /// (4) the AACS handshake when the disc has AACS, (5) titles + labels. + /// + /// A live AACS disc whose `Unit_Key_RO.inf` cannot be read fails with + /// [`Error::AacsKeyFileUnreadable`] before any AACS command (see + /// [`ScanOptions::raw_copy`]). A dead bus during a handshake aborts the scan. pub fn scan(session: &mut Drive, opts: &ScanOptions) -> Result { - // AACS handshake (Blu-ray/UHD) acquires the Volume ID via cert-based - // mutual-auth; drive unlock runs separately behind the `Unlocker` seam. - // DVD uses CSS, so skip AACS entirely — no OEM-VID/cert SCSI before bus-auth. - let (handshake, handshake_error) = if session.disc_is_dvd() { - (None, None) - } else { - tracing::info!(target: "freemkv::scan", "phase: AACS handshake"); - Self::do_handshake(session, opts) - }; - tracing::info!(target: "freemkv::scan", handshake = handshake.is_some(), "phase: handshake done"); - Self::scan_after_handshake(session, handshake, handshake_error, opts) - } - - // Everything in `scan` after the AACS handshake; split out so the bus wiring is - // testable against a mock transport with a synthetic handshake. - fn scan_after_handshake( - session: &mut Drive, - handshake: Option, - handshake_error: Option, - opts: &ScanOptions, - ) -> Result { - let firmware_unlocked = session.unlocker_name().is_some(); - // Request max read speed — removes riplock on DVD - // (BD/UHD speed is set by drive unlock/init, but DVD needs explicit SET CD SPEED) + let dvd = session.disc_is_dvd(); + // Max read speed; removes riplock on DVD. session.set_speed(0xFFFF); - - // CSS bus-auth unlock — run BEFORE any scrambled-sector read, else the UDF - // metadata prefetch below hits scrambled VOB extents (sense 05/6F/03). - // Title keys are cracked later via `css::resolve_dvd_title_key` instead. - if session.disc_is_dvd() { - tracing::info!(target: "freemkv::scan", "phase: CSS — bus-auth unlock (pre-scan)"); - let drive_id = session.drive_id.clone(); - let (_, css_unlock_res) = crate::unlock_bridge::run_bus( - session.scsi_mut(), - &drive_id, - freemkv_unlock::DiscKind::Css, - &[], - ); - match css_unlock_res { - Ok(Some(_)) => {} - Ok(None) => tracing::warn!( - target: "freemkv::scan", - "CSS bus-auth unlock declined; scrambled sectors may be unavailable" - ), - Err(e) => tracing::warn!( - target: "freemkv::scan", - outcome = ?e, - "CSS bus-auth unlock did not apply; scrambled sectors may be unavailable" - ), - } + // CSS bus-auth before any read, else the UDF prefetch hits scrambled VOB extents. + if dvd { + Self::css_bus_step(session)?; } - // Read UDF filesystem with buffered sector reader tracing::info!(target: "freemkv::scan", "phase: reading UDF filesystem"); let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?; tracing::info!(target: "freemkv::scan", capacity, "phase: UDF read"); - - // Pre-read all small file sectors (AACS, MPLS, CLPI, META, *.bdmv). - // Without this, each read_file() triggers individual SCSI commands at 500ms each. + // Pre-read small files (AACS, MPLS, CLPI, META, *.bdmv): one command each otherwise. if let Ok(ranges) = udf_fs.metadata_sector_ranges(&mut buffered) { buffered.prefetch_ranges(&ranges); } - // The AACS bus stage is decided ONCE from the handshake result, captured - // before `handshake` is moved into `scan_with` (see `BusStage`). - let bus_key = Self::bus_key_for_disc( - &mut buffered, - &udf_fs, - handshake.as_ref().and_then(|h| h.read_data_key), - ); + let aacs = if aacs_dir_present(&udf_fs) { + // A DVD with /AACS gets no handshake, so its key file keeps the image rule. + let from = if dvd { + encrypt::CaptureFrom::Image + } else { + encrypt::CaptureFrom::Live { + raw_copy: opts.raw_copy, + } + }; + let cap = encrypt::capture(&mut buffered, &udf_fs, from)?; + let bus = if !dvd { + tracing::info!(target: "freemkv::scan", "phase: AACS handshake"); + encrypt::aacs_bus_step(buffered.inner_mut(), opts)? + } else if buffered.inner_mut().unlocker_name().is_some() { + encrypt::BusOutcome::FirmwareUnlocked + } else { + encrypt::BusOutcome::FileOrIso + }; + Some((cap, bus)) + } else { + None + }; + Self::live_finish(buffered, capacity, udf_fs, aacs, opts) + } + + // `finish` over the live reader, then the drive's bus wiring from the bus outcome. + fn live_finish( + mut buffered: udf::BufferedSectorReader<'_, Drive>, + capacity: u32, + udf_fs: udf::UdfFs, + aacs: Option<(encrypt::AacsCapture, encrypt::BusOutcome)>, + opts: &ScanOptions, + ) -> Result { + let bus_key = aacs.as_ref().and_then(|(c, b)| encrypt::bus_key(c, b)); let stream_files = match bus_key { Some(_) => Self::stream_file_extents(&mut buffered, &udf_fs), None => Vec::new(), }; tracing::info!(target: "freemkv::scan", "phase: parsing titles/streams"); - let disc = Self::scan_with( - &mut buffered, - capacity, - handshake, - handshake_error, - firmware_unlocked, - opts, - udf_fs, - )?; + let disc = Self::finish(&mut buffered, capacity, udf_fs, aacs, opts)?; tracing::info!(target: "freemkv::scan", titles = disc.titles.len(), format = ?disc.content_format, "phase: titles parsed"); - // Wire the SINGLE de-bus point onto the drive from the handshake result, - // BEFORE the caller samples keys or muxes (the earlier UDF/metadata reads - // ran under default Passthrough). + // The SINGLE de-bus point, wired before the caller samples keys or muxes (the + // metadata reads above ran under Passthrough). + let session = buffered.into_inner(); Self::wire_bus_removal(session, bus_key, bus_map(stream_files, &disc.titles)); - // No CSS key recovery at scan time: DVD CSS keys are per-title and are - // re-cracked keylessly at read/decrypt time (`disc.css` stays unset), - // so log the format rather than a key state that is always `None`. + // No CSS key recovery at scan time: DVD CSS keys are re-cracked keylessly at read time. tracing::info!(target: "freemkv::scan", format = ?disc.format, titles = disc.titles.len(), "phase: scan complete"); Ok(disc) } - // Drops the Read Data Key when the content cert's BEE flag is clear (AACS spec; - // libaacs gates on `bee && bec`). An unreadable cert keeps the key. HD DVD - // CONTENT_CERT.AACS BEE semantics are unverified; the same rule applies. - pub(crate) fn bus_key_for_disc( - reader: &mut dyn SectorSource, - udf_fs: &udf::UdfFs, - read_data_key: Option<[u8; 16]>, - ) -> Option<[u8; 16]> { - read_data_key?; - let bee = crate::aacs::read_first( - &crate::aacs::role_paths(udf_fs, crate::aacs::AacsRole::ContentCert), - |p| udf_fs.read_file(reader, p), - ) - .ok() - .as_deref() - .and_then(crate::aacs::inf::parse_content_cert) - .map(|c| c.bus_encryption); - if bee == Some(false) { - tracing::info!(target: "freemkv::scan", "content cert BEE=0: bus removal off"); - return None; + // CSS bus-auth for a live DVD. A dead bus aborts like `Drive::init`; a Stop is `Halted`. + fn css_bus_step(session: &mut Drive) -> Result<()> { + tracing::info!(target: "freemkv::scan", "phase: CSS — bus-auth unlock (pre-scan)"); + if session.is_halted() { + return Err(Error::Halted); } - read_data_key + let drive_id = session.drive_id.clone(); + let (_, res) = crate::unlock_bridge::run_bus( + session.scsi_mut(), + &drive_id, + freemkv_unlock::DiscKind::Css, + &[], + ); + if session.is_halted() { + return Err(Error::Halted); + } + match res { + Ok(Some(_)) => {} + Ok(None) => tracing::warn!( + target: "freemkv::scan", + "CSS bus-auth unlock declined; scrambled sectors may be unavailable" + ), + Err(freemkv_unlock::UnlockError::Transport) => { + return Err(crate::unlock_bridge::unlock_transport_error()); + } + Err(e) => tracing::warn!( + target: "freemkv::scan", + outcome = ?e, + "CSS bus-auth unlock did not apply; scrambled sectors may be unavailable" + ), + } + Ok(()) } /// The AACS stream files' sectors (every file under `/BDMV/STREAM`: m2ts, @@ -2014,7 +2005,7 @@ impl Disc { opts: &ScanOptions, ) -> Result { let udf_fs = udf::read_filesystem(reader)?; - let mut disc = Self::scan_with(reader, capacity, None, None, false, opts, udf_fs)?; + let mut disc = Self::scan_fs(reader, capacity, opts, udf_fs)?; // CSS for a raw (still-scrambled) DVD image: recover the title key via // known-plaintext crack (no SCSI auth needed). Gated on `DiscFormat::Dvd`, @@ -2241,50 +2232,40 @@ impl Disc { Self::read_aacs_inputs_from_reader(&mut reader, &udf_fs) } - // Core scan pipeline — works with any SectorSource. `handshake_error` is - // plumbed so failures are preserved as `disc.aacs_error`, unless key - // resolution still succeeds (built-in keys + disc-hash hit). - fn scan_with( + // Image scan body (no SCSI): capture the AACS files, then `finish`. A missing + // `Unit_Key_RO.inf` is recorded, not fatal (decrypted folders, deferred mux). + fn scan_fs( reader: &mut dyn SectorSource, capacity: u32, - handshake: Option, - handshake_error: Option, - firmware_unlocked: bool, opts: &ScanOptions, udf_fs: udf::UdfFs, + ) -> Result { + let aacs = if aacs_dir_present(&udf_fs) { + let cap = encrypt::capture(reader, &udf_fs, encrypt::CaptureFrom::Image)?; + Some((cap, encrypt::BusOutcome::FileOrIso)) + } else { + None + }; + Self::finish(reader, capacity, udf_fs, aacs, opts) + } + + // Everything after the AACS bus step, for live and image scans alike: the AACS + // verdict, titles, labels, format. `aacs` is `None` for a disc with no AACS dir. + fn finish( + reader: &mut dyn SectorSource, + capacity: u32, + udf_fs: udf::UdfFs, + aacs: Option<(encrypt::AacsCapture, encrypt::BusOutcome)>, + opts: &ScanOptions, ) -> Result { let scan_with_t0 = std::time::Instant::now(); tracing::info!(target: "freemkv::scan", phase = "scan_with", "begin"); - // 2. Resolve encryption (AACS, CSS, or none) - let encrypted = aacs_dir_present(&udf_fs); - - let (aacs, aacs_error) = if !encrypted { - (None, None) - } else { - // Lookup-free: capture the disc's AACS inputs (MKB, VID, Unit_Key_RO.inf) - // but resolve no key — the caller resolves one and applies it via - // `Disc::decrypt_with`; until then the disc reports "encrypted, no keys". - let source = encrypt::BusSource::classify( - handshake.as_ref(), - handshake_error.is_some(), - firmware_unlocked, - ); - match Self::resolve_vid_only(&udf_fs, reader, source) { - Ok(state) => { - let err = - encrypt::aacs_scan_error(state.bus_encryption, source, handshake_error); - (Some(state), err) - } - // The capture error is surfaced, except that a handshake-class failure - // is kept: it is what makes `bus_blocked_error` refuse keys fail-safe. - Err(e) => match handshake_error { - Some(h) if encrypt::handshake_class_error(&h).is_some() => { - tracing::warn!(target: "freemkv::scan", capture_error = e.code(), handshake_error = h.code(), "AACS inputs unreadable after a failed handshake"); - (None, Some(h)) - } - _ => (None, Some(e)), - }, - } + let encrypted = aacs.is_some(); + // Lookup-free: the state carries the disc's AACS inputs but no key; the caller + // resolves one and applies it via `Disc::decrypt_with`. + let (aacs, aacs_error) = match aacs { + Some((cap, bus)) => encrypt::resolve_aacs(cap, &bus), + None => (None, None), }; // 3. Titles + container — dispatched by on-disc tree (HD-DVD/DVD peers, @@ -4069,7 +4050,7 @@ mod tests { inner: &mut disc, clip_reads: 0, }; - let res = Disc::scan_with(&mut reader, 3_997_952, None, None, false, &opts, udf); + let res = Disc::scan_fs(&mut reader, 3_997_952, &opts, udf); let clip_reads = reader.clip_reads; assert!( @@ -4111,16 +4092,7 @@ mod tests { // Sanity: the same disc scans clean when nothing is cancelled, so a // pass below cannot be some unrelated failure wearing Halted. assert!( - Disc::scan_with( - &mut disc, - 500_000, - None, - None, - false, - &ScanOptions::default(), - udf - ) - .is_ok(), + Disc::scan_fs(&mut disc, 500_000, &ScanOptions::default(), udf).is_ok(), "fixture must scan successfully when not cancelled" ); @@ -4131,7 +4103,7 @@ mod tests { halt: Some(halt), ..Default::default() }; - let res = Disc::scan_with(&mut disc, 500_000, None, None, false, &opts, udf); + let res = Disc::scan_fs(&mut disc, 500_000, &opts, udf); assert!( matches!(res, Err(Error::Halted)), "a cancelled BD scan must say so; returning a title list built \ @@ -4168,16 +4140,7 @@ mod tests { lay_dir(&mut disc, &root); let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); assert!( - Disc::scan_with( - &mut disc, - 500_000, - None, - None, - false, - &ScanOptions::default(), - udf - ) - .is_ok(), + Disc::scan_fs(&mut disc, 500_000, &ScanOptions::default(), udf).is_ok(), "fixture must scan successfully when not cancelled" ); @@ -4188,7 +4151,7 @@ mod tests { halt: Some(halt), ..Default::default() }; - let res = Disc::scan_with(&mut disc, 500_000, None, None, false, &opts, udf); + let res = Disc::scan_fs(&mut disc, 500_000, &opts, udf); assert!( matches!(res, Err(Error::Halted)), "a cancelled DVD scan must say so, not hand back whatever it had \ @@ -4227,15 +4190,7 @@ mod tests { let (mut disc, udf) = hddvd_two_clip_disc(3_000_000, 5_000_000); let mut reader = HaltingReader { inner: &mut disc }; - let res = Disc::scan_with( - &mut reader, - 3_997_952, - None, - None, - false, - &ScanOptions::default(), - udf, - ); + let res = Disc::scan_fs(&mut reader, 3_997_952, &ScanOptions::default(), udf); assert!( matches!(res, Err(Error::Halted)), "reads cancelled by the drive's own halt flag must surface as a \ @@ -4255,16 +4210,8 @@ mod tests { fn scan_with_capacity_bytes_uses_multiplication_not_addition() { const CAPACITY_SECTORS: u32 = 3_997_952; // *2048 = 8_187_805_696; +2048 = 4_000_000 let (mut disc, udf) = hddvd_two_clip_disc(3_000_000, 5_000_000); - let scanned = Disc::scan_with( - &mut disc, - CAPACITY_SECTORS, - None, - None, - false, - &ScanOptions::default(), - udf, - ) - .expect("scan_with must succeed on this synthetic HD-DVD image"); + let scanned = Disc::scan_fs(&mut disc, CAPACITY_SECTORS, &ScanOptions::default(), udf) + .expect("scan_with must succeed on this synthetic HD-DVD image"); assert_eq!( scanned.titles.first().map(|t| t.playlist.as_str()), Some("OTHER.EVO"), @@ -4283,16 +4230,8 @@ mod tests { fn scan_with_capacity_bytes_uses_multiplication_not_division() { const CAPACITY_SECTORS: u32 = 204_800_000; // *2048 = huge; /2048 = 100_000 let (mut disc, udf) = hddvd_two_clip_disc(1_000, 5_000_000); - let scanned = Disc::scan_with( - &mut disc, - CAPACITY_SECTORS, - None, - None, - false, - &ScanOptions::default(), - udf, - ) - .expect("scan_with must succeed on this synthetic HD-DVD image"); + let scanned = Disc::scan_fs(&mut disc, CAPACITY_SECTORS, &ScanOptions::default(), udf) + .expect("scan_with must succeed on this synthetic HD-DVD image"); assert_eq!( scanned.titles.first().map(|t| t.playlist.as_str()), Some("OTHER.EVO"), @@ -9252,6 +9191,23 @@ mod tests { ); } + // The de-bus key a live scan picks for `rdk` over this disc's captured content cert. + fn bus_key_for_disc( + mem: &mut crate::udf::fixture::MemDisc, + udf: &udf::UdfFs, + rdk: Option<[u8; 16]>, + ) -> Option<[u8; 16]> { + let from = encrypt::CaptureFrom::Live { raw_copy: true }; + let cap = encrypt::capture(mem, udf, from).expect("capture"); + let bus = encrypt::BusOutcome::Handshake(encrypt::HandshakeResult { + volume_id: [0x11; 16], + read_data_key: rdk, + read_data_key_err: None, + drive_unlocked: false, + }); + encrypt::bus_key(&cap, &bus) + } + // libaacs gates bus decrypt on the content cert BEE flag (`bee && bec`): a // BEE=0 disc must not be de-bussed even when the drive served a Read Data Key. #[test] @@ -9259,13 +9215,13 @@ mod tests { let rdk = Some([0x5Au8; 16]); let (mut mem, udf) = bus_fixture(0x00); assert_eq!( - Disc::bus_key_for_disc(&mut mem, &udf, rdk), + bus_key_for_disc(&mut mem, &udf, rdk), None, "BEE=0 content cert must force Passthrough" ); let (mut mem, udf) = bus_fixture(0x80); assert_eq!( - Disc::bus_key_for_disc(&mut mem, &udf, rdk), + bus_key_for_disc(&mut mem, &udf, rdk), rdk, "BEE=1 keeps the cert-route Read Data Key" ); @@ -9277,23 +9233,15 @@ mod tests { fn bus_key_kept_when_content_cert_absent_or_unparseable() { let rdk = Some([0x5Au8; 16]); let (mut mem, udf) = bus_fixture_with(None, Vec::new()); - assert_eq!(Disc::bus_key_for_disc(&mut mem, &udf, rdk), rdk, "no cert"); + assert_eq!(bus_key_for_disc(&mut mem, &udf, rdk), rdk, "no cert"); let (mut mem, udf) = bus_fixture_with(Some(vec![0x10, 0x00, 0x00]), Vec::new()); - assert_eq!( - Disc::bus_key_for_disc(&mut mem, &udf, rdk), - rdk, - "short cert" - ); + assert_eq!(bus_key_for_disc(&mut mem, &udf, rdk), rdk, "short cert"); let mut odd = vec![0u8; 32]; odd[0] = 0x55; let (mut mem, udf) = bus_fixture_with(Some(odd), Vec::new()); + assert_eq!(bus_key_for_disc(&mut mem, &udf, rdk), rdk, "unknown type"); assert_eq!( - Disc::bus_key_for_disc(&mut mem, &udf, rdk), - rdk, - "unknown type" - ); - assert_eq!( - Disc::bus_key_for_disc(&mut mem, &udf, None), + bus_key_for_disc(&mut mem, &udf, None), None, "no key stays none" ); @@ -9345,13 +9293,11 @@ mod tests { let mut wire = clear.clone(); crate::aacs::content::encrypt_bus(&mut wire[..3 * 2048], &rdk); crate::aacs::content::encrypt_bus(&mut wire[3 * 2048..], &rdk); - let handshake = || { - Some(encrypt::HandshakeResult { - volume_id: [0x11; 16], - read_data_key: Some(rdk), - read_data_key_err: None, - drive_unlocked: false, - }) + let handshake = || encrypt::HandshakeResult { + volume_id: [0x11; 16], + read_data_key: Some(rdk), + read_data_key_err: None, + drive_unlocked: false, }; let lba = PART_START + 2_000; for (bee, want) in [(0x80u8, &clear), (0x00u8, &wire)] { @@ -9360,8 +9306,18 @@ mod tests { cert[1] = bee; let (mem, _) = bus_fixture_with(Some(cert), wire.clone()); let mut d = Drive::from_transport_for_test(Box::new(MemTransport(mem))); - Disc::scan_after_handshake(&mut d, handshake(), None, &ScanOptions::default()) - .expect("scan"); + let (capacity, mut buffered, udf) = Disc::read_udf(&mut d).expect("udf"); + let from = encrypt::CaptureFrom::Live { raw_copy: true }; + let cap = encrypt::capture(&mut buffered, &udf, from).expect("capture"); + let bus = encrypt::BusOutcome::Handshake(handshake()); + Disc::live_finish( + buffered, + capacity, + udf, + Some((cap, bus)), + &ScanOptions::default(), + ) + .expect("scan"); let mut got = vec![0u8; 6 * 2048]; d.read_sectors(lba, 6, &mut got, false).unwrap(); assert_eq!(&got, want, "BEE byte {bee:#04x}: non-title m2ts bus state"); diff --git a/src/disc/scan_order_tests.rs b/src/disc/scan_order_tests.rs new file mode 100644 index 00000000..a3cf41e1 --- /dev/null +++ b/src/disc/scan_order_tests.rs @@ -0,0 +1,802 @@ +//! Live-scan command order: UDF and the AACS files are read before any AACS +//! CDB, and the handshake's failures land where they belong. + +use super::*; +use crate::scsi::{DataDirection, ScsiResult, ScsiSense}; +use crate::udf::fixture::*; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +// A real AACS1 host keypair (pubkey = d·G on the AACS1 curve); T0 guards it. +fn test_hc() -> crate::aacs::types::HostCert { + let mut cert = vec![0u8; 92]; + cert[0] = 0x02; + cert[3] = 92; + cert[4..10].copy_from_slice(&[0xF7, 0xEE, 0x00, 0x00, 0x00, 0x01]); + cert[12..32].copy_from_slice(&hex20("603c6514caab3f999bdd5790624434741fb875cf")); + cert[32..52].copy_from_slice(&hex20("1cc0b11e2c894c62ba253f7e268eac2ca9a1f30b")); + crate::aacs::types::HostCert { + private_key: *b"freemkv-test-hc-key!", + certificate: cert, + private_key_v2: None, + certificate_v2: None, + } +} + +fn hex20(s: &str) -> [u8; 20] { + let mut out = [0u8; 20]; + for (i, b) in out.iter_mut().enumerate() { + *b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap(); + } + out +} + +type Log = Arc>>>; +type Pred = fn(&[u8]) -> bool; +type ReadFail = Box]) -> Option + Send>; +type HaltOn = Box bool + Send>; + +fn is_aacs_cdb(c: &[u8]) -> bool { + matches!(c[0], 0xA3 | 0xA4 | 0xAD) +} +fn is_read10(c: &[u8]) -> bool { + c[0] == crate::scsi::SCSI_READ_10 +} +fn is_aacs_agid_alloc(c: &[u8]) -> bool { + c[0] == 0xA4 && c[7] == 0x02 && c[10] & 0x3F == 0x00 +} +fn is_send_host_cert(c: &[u8]) -> bool { + c[0] == 0xA3 && c[10] & 0x3F == 0x01 +} +fn is_css_agid_alloc(c: &[u8]) -> bool { + c[0] == 0xA4 && c[7] == 0x00 && c[10] & 0x3F == 0x00 +} +fn is_drive_mkb_read(c: &[u8]) -> bool { + c[0] == 0xAD && c[7] == 0x83 +} +fn read_range(c: &[u8]) -> (u32, u32) { + let lba = u32::from_be_bytes([c[2], c[3], c[4], c[5]]); + (lba, u16::from_be_bytes([c[7], c[8]]) as u32) +} +fn check(sense_key: u8, asc: u8, ascq: u8, opcode: u8) -> Error { + Error::ScsiError { + opcode, + status: 2, + sense: Some(ScsiSense { + sense_key, + asc, + ascq, + }), + } +} +fn transport_fault(opcode: u8) -> Error { + Error::ScsiError { + opcode, + status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, + sense: None, + } +} + +// A CDB-logging drive over a MemDisc: SEND KEY host cert → 05/6F/00, so the AKE +// really issues REPORT/SEND KEY but can never complete. +struct AkeMemTransport { + mem: MemDisc, + log: Log, + profile: u16, + fail_read: Option, + fault_on: Option, + halt_on: Option, + halt: Arc>>>, + mkb_pack: Option>, +} + +impl crate::scsi::ScsiTransport for AkeMemTransport { + fn execute( + &mut self, + cdb: &[u8], + _dir: DataDirection, + data: &mut [u8], + _timeout_ms: u32, + ) -> Result { + let history = self.log.lock().unwrap().clone(); + self.log.lock().unwrap().push(cdb.to_vec()); + if self.halt_on.as_ref().is_some_and(|p| p(cdb)) + && let Some(h) = self.halt.lock().unwrap().as_ref() + { + h.store(true, Ordering::Relaxed); + } + if self.fault_on.is_some_and(|p| p(cdb)) { + return Err(transport_fault(cdb[0])); + } + data.fill(0); + match cdb[0] { + crate::scsi::SCSI_READ_10 => { + let (lba, count) = read_range(cdb); + if let Some(f) = &self.fail_read + && let Some(e) = f(lba, count as u16, &history) + { + return Err(e); + } + self.mem.read_sectors(lba, count as u16, data, false)?; + } + crate::scsi::SCSI_READ_CAPACITY => { + data[..4].copy_from_slice(&9_999u32.to_be_bytes()); + data[4..8].copy_from_slice(&2048u32.to_be_bytes()); + } + 0x46 if data.len() >= 8 => data[6..8].copy_from_slice(&self.profile.to_be_bytes()), + 0xA3 if is_send_host_cert(cdb) => return Err(check(5, 0x6F, 0, 0xA3)), + 0xAD if cdb[7] == 0x83 => { + if let Some(m) = &self.mkb_pack { + data[..2].copy_from_slice(&((m.len() + 2) as u16).to_be_bytes()); + data[3] = 1; + data[4..4 + m.len()].copy_from_slice(m); + } + } + _ => {} + } + Ok(ScsiResult { + status: 0, + bytes_transferred: data.len(), + sense: [0u8; 32], + }) + } +} + +fn mkb_gen(version: u32) -> Vec { + let mut v = vec![0x10, 0x00, 0x00, 0x10, 0, 0, 0, 0]; + v.extend_from_slice(&version.to_be_bytes()); + v.extend_from_slice(&[0u8; 4]); + v +} + +fn cert(bee: bool) -> Vec { + let mut c = vec![0u8; 20]; + c[0] = 0x10; + c[1] = if bee { 0x80 } else { 0x00 }; + c +} + +fn mpls_2h() -> Vec { + let mut b = b"MPLS0200".to_vec(); + b.extend_from_slice(&40u32.to_be_bytes()); + b.extend_from_slice(&[0u8; 28]); + let pl = b.len(); + b.extend_from_slice(&[0u8; 4]); + b.extend_from_slice(&[0, 0, 0, 1, 0, 0]); + let mut item = b"00001M2TS".to_vec(); + item.extend_from_slice(&[0, 0, 0]); + item.extend_from_slice(&0u32.to_be_bytes()); + item.extend_from_slice(&(7200u32 * 45_000).to_be_bytes()); + item.extend_from_slice(&[0u8; 12]); + item.extend_from_slice(&[0, 14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + b.extend_from_slice(&(item.len() as u16).to_be_bytes()); + b.extend_from_slice(&item); + let len = (b.len() - pl - 4) as u32; + b[pl..pl + 4].copy_from_slice(&len.to_be_bytes()); + let marks = b.len() as u32; + b[12..16].copy_from_slice(&marks.to_be_bytes()); + b.extend_from_slice(&2u32.to_be_bytes()); + b.extend_from_slice(&0u16.to_be_bytes()); + b +} + +fn clpi() -> Vec { + let mut d = vec![0u8; 60]; + d[0..4].copy_from_slice(b"HDMV"); + d[4..8].copy_from_slice(b"0200"); + d[56..60].copy_from_slice(&4000u32.to_be_bytes()); + d +} + +const UK_LBA: u32 = PART_START + 600; +const CERT_LBA: u32 = PART_START + 610; +const MKB_LBA: u32 = PART_START + 620; + +// A BD with one 2 h title; `aacs` adds /AACS (Unit_Key_RO.inf, Content000.cer, MKB_RO.inf). +fn bd_disc(aacs: Option) -> MemDisc { + let bdmv = DirSpec { + name: "BDMV".into(), + icb_lba: 22, + dir_data_lba: 23, + files: vec![file_with( + "index.bdmv", + 43, + 630, + b"INDX0200".to_vec(), + false, + )], + subdirs: vec![ + DirSpec { + name: "PLAYLIST".into(), + icb_lba: 24, + dir_data_lba: 25, + files: vec![file_with("00800.mpls", 44, 700, mpls_2h(), false)], + subdirs: vec![], + }, + DirSpec { + name: "CLIPINF".into(), + icb_lba: 26, + dir_data_lba: 27, + files: vec![file_with("00001.clpi", 45, 720, clpi(), false)], + subdirs: vec![], + }, + DirSpec { + name: "STREAM".into(), + icb_lba: 28, + dir_data_lba: 29, + files: vec![file("00001.m2ts", 46, 5_000, 1_000 * 2048, true)], + subdirs: vec![], + }, + ], + }; + let mut subdirs = vec![bdmv]; + if let Some(bee) = aacs { + subdirs.push(DirSpec { + name: "AACS".into(), + icb_lba: 20, + dir_data_lba: 21, + files: vec![ + file_with("Unit_Key_RO.inf", 40, 600, vec![0xAB; 64], false), + file_with("Content000.cer", 41, 610, cert(bee), false), + file_with("MKB_RO.inf", 42, 620, mkb_gen(70), false), + ], + subdirs: vec![], + }); + } + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs, + }; + let mut mem = MemDisc::new(); + build_udf_skeleton(&mut mem, 10); + lay_dir(&mut mem, &root); + mem +} + +struct Rig { + drive: Drive, + log: Log, +} + +impl Rig { + fn new(mem: MemDisc, f: impl FnOnce(&mut AkeMemTransport)) -> Self { + let log: Log = Arc::default(); + let halt: Arc>>> = Arc::default(); + let mut t = AkeMemTransport { + mem, + log: log.clone(), + profile: 0x0040, + fail_read: None, + fault_on: None, + halt_on: None, + halt: halt.clone(), + mkb_pack: Some(mkb_gen(70)), + }; + f(&mut t); + let drive = Drive::from_transport_for_test(Box::new(t)); + *halt.lock().unwrap() = Some(drive.halt_flag()); + Rig { drive, log } + } + fn cdbs(&self) -> Vec> { + self.log.lock().unwrap().clone() + } + fn count(&self, p: Pred) -> usize { + self.cdbs().iter().filter(|c| p(c)).count() + } +} + +fn with_hc() -> ScanOptions { + ScanOptions { + credentials: Some(crate::DriveCredentials { + host_certs: vec![test_hc()], + }), + ..Default::default() + } +} + +fn touches(c: &[u8], lbas: &[u32]) -> bool { + is_read10(c) && { + let (lba, n) = read_range(c); + lbas.iter().any(|x| (lba..lba + n).contains(x)) + } +} + +// AACS-path WARN+ events (the drive's per-CDB read diagnostics are out of scope). +fn aacs_warns(ev: &[crate::testlog::CapturedEvent]) -> Vec<&crate::testlog::CapturedEvent> { + ev.iter() + .filter(|e| e.level <= tracing::Level::WARN && e.target != "freemkv::drive") + .collect() +} + +fn verdicts(ev: &[crate::testlog::CapturedEvent]) -> Vec<&crate::testlog::CapturedEvent> { + ev.iter() + .filter(|e| e.field("phase") == Some("aacs_verdict")) + .collect() +} + +const KEY_FILE_CODE: u16 = crate::error::E_AACS_KEY_FILE_UNREADABLE; + +#[test] +fn test_hc_is_a_valid_aacs1_pairing() { + let hc = test_hc(); + assert!(freemkv_unlock::aacs1_keypair_matches( + &hc.private_key, + &hc.certificate + )); +} + +#[test] +fn scan_reads_udf_and_aacs_files_before_any_aacs_scsi() { + let mut rig = Rig::new(bd_disc(Some(true)), |_| {}); + let _ = Disc::scan(&mut rig.drive, &with_hc()); + let cdbs = rig.cdbs(); + let first_aacs = cdbs.iter().position(|c| is_aacs_cdb(c)).expect("AACS CDBs"); + let last_file = cdbs + .iter() + .rposition(|c| touches(c, &[UK_LBA, CERT_LBA, MKB_LBA])) + .expect("AACS file reads"); + assert!( + first_aacs > last_file, + "first AACS CDB #{first_aacs} precedes the last AACS file read #{last_file}" + ); + assert!(rig.count(is_aacs_agid_alloc) >= 1 && rig.count(is_send_host_cert) >= 1); +} + +#[test] +fn unencrypted_bd_issues_no_aacs_scsi() { + let mut rig = Rig::new(bd_disc(None), |_| {}); + let d = Disc::scan(&mut rig.drive, &with_hc()).expect("scan"); + assert!(d.aacs.is_none() && d.aacs_error.is_none()); + assert_eq!(rig.count(is_aacs_cdb), 0, "{:02x?}", rig.cdbs()); +} + +#[test] +fn live_unreadable_uk_ro_fails_scan_before_any_aacs_scsi() { + for bee in [true, false] { + // Both copies absent: the file is simply not on the disc. + let mut mem = bd_disc(Some(bee)); + lay_dir( + &mut mem, + &DirSpec { + name: "AACS".into(), + icb_lba: 20, + dir_data_lba: 21, + files: vec![ + file_with("Content000.cer", 41, 610, cert(bee), false), + file_with("MKB_RO.inf", 42, 620, mkb_gen(70), false), + ], + subdirs: vec![], + }, + ); + let mut rig = Rig::new(mem, |_| {}); + let r = Disc::scan(&mut rig.drive, &with_hc()); + assert_eq!( + r.err().map(|e| e.code()), + Some(KEY_FILE_CODE), + "absent, bee={bee}" + ); + assert_eq!(rig.count(is_aacs_cdb), 0, "absent, bee={bee}"); + + // Present but unreadable. + let mut rig = Rig::new(bd_disc(Some(bee)), |t| { + t.fail_read = Some(Box::new(|lba, n, _| { + (lba..lba + n as u32) + .contains(&UK_LBA) + .then(|| check(3, 0x11, 0, 0x28)) + })); + }); + let r = Disc::scan(&mut rig.drive, &with_hc()); + assert_eq!( + r.err().map(|e| e.code()), + Some(KEY_FILE_CODE), + "read error, bee={bee}" + ); + assert_eq!(rig.count(is_aacs_cdb), 0, "read error, bee={bee}"); + } +} + +#[test] +fn raw_copy_scan_records_unreadable_uk_ro_and_refuses_keys() { + let mut rig = Rig::new(bd_disc(Some(false)), |t| { + t.fail_read = Some(Box::new(|lba, n, _| { + (lba..lba + n as u32) + .contains(&UK_LBA) + .then(|| check(3, 0x11, 0, 0x28)) + })); + }); + let opts = ScanOptions { + raw_copy: true, + ..with_hc() + }; + let mut d = Disc::scan(&mut rig.drive, &opts).expect("raw copy scans on"); + assert!(d.aacs.is_none() && !d.titles.is_empty()); + assert_eq!(d.aacs_error.as_ref().map(|e| e.code()), Some(KEY_FILE_CODE)); + assert!( + d.decrypt_with(Key::Unit(vec![(0, [0x11; 16])]), &[]) + .is_err() + ); +} + +#[test] +fn failed_handshake_cannot_poison_uk_ro_read() { + let mut rig = Rig::new(bd_disc(Some(false)), |t| { + t.fail_read = Some(Box::new(|lba, n, hist| { + (hist.iter().any(|c| is_aacs_agid_alloc(c)) && (lba..lba + n as u32).contains(&UK_LBA)) + .then(|| check(3, 0x11, 0, 0x28)) + })); + }); + let d = Disc::scan(&mut rig.drive, &with_hc()).expect("scan"); + assert!(d.aacs.is_some(), "{:?}", d.aacs_error); + assert!(d.aacs_error.is_none(), "{:?}", d.aacs_error); +} + +#[test] +fn handshake_issues_no_drive_mkb_read() { + let mut rig = Rig::new(bd_disc(Some(true)), |_| {}); + let _ = Disc::scan(&mut rig.drive, &with_hc()); + assert_eq!(rig.count(is_drive_mkb_read), 0); +} + +#[test] +fn non_bus_failed_handshake_is_info_only() { + let mut rig = Rig::new(bd_disc(Some(false)), |_| {}); + let (r, ev) = crate::testlog::capture(|| Disc::scan(&mut rig.drive, &with_hc())); + let d = r.expect("scan"); + assert!(d.aacs_error.is_none(), "{:?}", d.aacs_error); + assert!(aacs_warns(&ev).is_empty(), "{:?}", aacs_warns(&ev)); + let v = verdicts(&ev); + assert_eq!(v.len(), 1, "{ev:?}"); + assert_eq!(v[0].level, tracing::Level::INFO); +} + +#[test] +fn bus_disc_failed_handshake_lists_titles_one_error_refuses_keys() { + let mut rig = Rig::new(bd_disc(Some(true)), |_| {}); + let (r, ev) = crate::testlog::capture(|| Disc::scan(&mut rig.drive, &with_hc())); + let mut d = r.expect("scan"); + assert!(!d.titles.is_empty()); + assert!( + matches!(d.aacs_error, Some(Error::AacsHostCertRejected)), + "{:?}", + d.aacs_error + ); + let w = aacs_warns(&ev); + assert_eq!(w.len(), 1, "{w:?}"); + assert_eq!(w[0].field("phase"), Some("aacs_verdict")); + assert!( + d.decrypt_with(Key::Unit(vec![(0, [0x11; 16])]), &[]) + .is_err() + ); + let inputs = d.inputs().expect("inputs"); + let (ok, _) = crate::keysource::resolve_and_apply_traced(&[], &inputs, &mut d); + assert!(!ok); +} + +#[test] +fn transport_fault_mid_ake_aborts_scan() { + for bee in [true, false] { + let mut rig = Rig::new(bd_disc(Some(bee)), |t| t.fault_on = Some(is_send_host_cert)); + let r = Disc::scan(&mut rig.drive, &with_hc()); + assert!( + matches!( + r, + Err(Error::ScsiError { + status: 0xFF, + sense: None, + .. + }) + ), + "bee={bee}: {:?}", + r.map(|d| d.aacs_error) + ); + let cdbs = rig.cdbs(); + let fault = cdbs + .iter() + .position(|c| is_send_host_cert(c)) + .expect("fault"); + assert!(!cdbs[fault..].iter().any(|c| is_read10(c)), "bee={bee}"); + } +} + +fn dvd_disc() -> MemDisc { + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "VIDEO_TS".into(), + icb_lba: 20, + dir_data_lba: 21, + files: Vec::new(), + subdirs: vec![], + }], + }; + let mut mem = MemDisc::new(); + build_udf_skeleton(&mut mem, 10); + lay_dir(&mut mem, &root); + mem +} + +#[test] +fn css_transport_fault_aborts_scan() { + let mut rig = Rig::new(dvd_disc(), |t| { + t.profile = 0x0010; + t.fault_on = Some(is_css_agid_alloc); + }); + let r = Disc::scan(&mut rig.drive, &ScanOptions::default()); + assert!( + matches!( + r, + Err(Error::ScsiError { + status: 0xFF, + sense: None, + .. + }) + ), + "{:?}", + r.map(|d| d.titles.len()) + ); + assert_eq!(rig.count(is_read10), 0); +} + +#[test] +fn dvd_css_bus_auth_precedes_the_first_udf_read() { + let mut rig = Rig::new(dvd_disc(), |t| t.profile = 0x0010); + let _ = Disc::scan(&mut rig.drive, &ScanOptions::default()); + let cdbs = rig.cdbs(); + let css = cdbs.iter().position(|c| is_css_agid_alloc(c)).expect("CSS"); + let read = cdbs.iter().position(|c| is_read10(c)).expect("READ"); + assert!(css < read); +} + +// A DVD carrying /AACS gets no AACS handshake, so its missing key file is recorded. +#[test] +fn dvd_with_aacs_dir_records_missing_key_file_without_aacs_scsi() { + let mut mem = dvd_disc(); + lay_dir( + &mut mem, + &DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![ + DirSpec { + name: "VIDEO_TS".into(), + icb_lba: 20, + dir_data_lba: 21, + files: Vec::new(), + subdirs: vec![], + }, + DirSpec { + name: "AACS".into(), + icb_lba: 22, + dir_data_lba: 23, + files: vec![file_with("Content000.cer", 41, 610, cert(true), false)], + subdirs: vec![], + }, + ], + }, + ); + let mut rig = Rig::new(mem, |t| t.profile = 0x0010); + let d = Disc::scan(&mut rig.drive, &with_hc()).expect("scan"); + assert!(d.aacs.is_none()); + assert!( + matches!(d.aacs_error, Some(Error::AacsNoKeys)), + "{:?}", + d.aacs_error + ); + let aacs_class = |c: &[u8]| c[0] == 0xAD || (matches!(c[0], 0xA3 | 0xA4) && c[7] == 0x02); + assert_eq!(rig.count(aacs_class), 0, "{:02x?}", rig.cdbs()); +} + +#[test] +fn stop_during_ake_returns_halted() { + let mut rig = Rig::new(bd_disc(Some(true)), |t| { + t.halt_on = Some(Box::new(is_aacs_agid_alloc)) + }); + let r = Disc::scan(&mut rig.drive, &with_hc()); + assert!( + matches!(r, Err(Error::Halted)), + "{:?}", + r.map(|d| d.aacs_error) + ); +} + +#[derive(Clone, Default)] +struct SpySource(Arc>>>); +impl crate::KeySource for SpySource { + fn get_unit_keys( + &self, + _ctx: &dyn crate::keysource::ResolveCtx, + ) -> Result> { + Ok(Vec::new()) + } + fn host_certs(&self, mkb: Option) -> Vec { + self.0.lock().unwrap().push(mkb); + vec![test_hc()] + } +} + +#[test] +fn cert_sources_receive_no_mkb_hint() { + let spy = SpySource::default(); + let mut rig = Rig::new(bd_disc(Some(true)), |_| {}); + let opts = ScanOptions { + key_sources: vec![Box::new(spy.clone())], + ..Default::default() + }; + let _ = Disc::scan(&mut rig.drive, &opts); + let seen = spy.0.lock().unwrap().clone(); + assert!(!seen.is_empty()); + assert!(seen.iter().all(Option::is_none), "{seen:?}"); +} + +#[test] +fn pre_ake_read_refusal_is_a_plain_scan_error() { + let mut rig = Rig::new(bd_disc(Some(true)), |t| { + t.fail_read = Some(Box::new(|_, _, hist| { + (!hist.iter().any(|c| c[0] == 0xA4)).then(|| check(5, 0x21, 0, 0x28)) + })); + }); + let r = Disc::scan(&mut rig.drive, &with_hc()); + let Err(e) = r else { panic!("scan must fail") }; + let s = e.scsi_sense().copied(); + assert!( + s.is_some_and(|s| s.sense_key == 5 && s.asc == 0x21), + "{e:?}" + ); + assert_eq!(rig.count(|c| matches!(c[0], 0xA3 | 0xA4)), 0); +} + +fn hddvd_disc() -> MemDisc { + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![ + DirSpec { + name: "HVDVD_TS".into(), + icb_lba: 20, + dir_data_lba: 21, + files: vec![file("MAIN.EVO", 100, 5_000, 3_000_000, true)], + subdirs: vec![], + }, + DirSpec { + name: "AACS!".into(), + icb_lba: 22, + dir_data_lba: 23, + files: vec![ + file_with("MKBROM.AACS", 101, 900, mkb_gen(70), false), + file_with("VTKF000.AACS", 102, 910, vec![0xAB; 64], false), + ], + subdirs: vec![], + }, + ], + }; + let mut mem = MemDisc::new(); + build_udf_skeleton(&mut mem, 10); + lay_dir(&mut mem, &root); + mem +} + +const VTKF_LBA: u32 = PART_START + 910; + +// An HD DVD's `X!` AACS dir is an AACS dir: identified as encrypted, its VTKF captured +// before the handshake, and a keyless rip refused instead of writing ciphertext. +#[test] +fn hddvd_aacs_dir_is_captured_and_handshaked() { + let mut rig = Rig::new(hddvd_disc(), |_| {}); + assert!(Disc::identify(&mut rig.drive).expect("identify").encrypted); + let d = Disc::scan(&mut rig.drive, &with_hc()).expect("scan"); + assert!(d.encrypted && !d.titles.is_empty()); + let a = d.aacs.as_ref().expect("HD DVD AACS state"); + assert!( + !a.bus_encryption && d.aacs_error.is_none(), + "{:?}", + d.aacs_error + ); + assert!(matches!( + d.ensure_decryptable(false), + Err(Error::NoDiscKey { .. }) + )); + let cdbs = rig.cdbs(); + let first_aacs = cdbs.iter().position(|c| is_aacs_cdb(c)).expect("AACS CDBs"); + let vtkf = cdbs.iter().rposition(|c| touches(c, &[VTKF_LBA])); + assert!(vtkf.is_some_and(|i| i < first_aacs), "{cdbs:02x?}"); +} + +#[test] +fn hddvd_image_aacs_dir_is_captured() { + let mut mem = hddvd_disc(); + let d = Disc::scan_image(&mut mem, 9_999, &ScanOptions::default()).expect("scan"); + assert!(d.encrypted && d.aacs.is_some() && d.aacs_error.is_none()); +} + +#[test] +fn halted_during_capture_propagates() { + for lba in [CERT_LBA, MKB_LBA] { + let mut rig = Rig::new(bd_disc(Some(true)), |t| { + t.halt_on = Some(Box::new(move |c: &[u8]| touches(c, &[lba]))); + }); + let r = Disc::scan(&mut rig.drive, &with_hc()); + assert!( + matches!(r, Err(Error::Halted)), + "lba {lba}: {:?}", + r.map(|d| d.aacs_error) + ); + } +} + +// A source whose reads covering `lba` fail with `Halted`, as a stopped drive's do. +struct HaltAt { + mem: MemDisc, + lba: u32, +} +impl crate::sector::SectorSource for HaltAt { + fn read_sectors(&mut self, lba: u32, n: u16, buf: &mut [u8], rec: bool) -> Result { + if (lba..lba + n as u32).contains(&self.lba) { + return Err(Error::Halted); + } + self.mem.read_sectors(lba, n, buf, rec) + } +} + +// No later check may mask a Stop swallowed by one of capture's own reads. +#[test] +fn capture_propagates_a_stop_from_every_key_file_read() { + use super::encrypt::{CaptureFrom, capture}; + for lba in [UK_LBA, CERT_LBA, MKB_LBA] { + for from in [ + CaptureFrom::Image, + CaptureFrom::Live { raw_copy: true }, + CaptureFrom::Live { raw_copy: false }, + ] { + let mut src = HaltAt { + mem: bd_disc(Some(true)), + lba, + }; + let udf = crate::udf::read_filesystem(&mut src).expect("udf"); + let r = capture(&mut src, &udf, from); + assert!(matches!(r, Err(Error::Halted)), "lba {lba}"); + } + } +} + +#[test] +fn no_unlocker_runs_cert_route_and_issues_ake() { + let mut rig = Rig::new(bd_disc(Some(true)), |_| {}); + let d = Disc::scan(&mut rig.drive, &with_hc()).expect("scan"); + assert!(rig.count(is_aacs_agid_alloc) >= 1 && rig.count(is_send_host_cert) >= 1); + assert_eq!(rig.count(|c| c[0] == 0xAD), 0); + assert!(matches!(d.aacs_error, Some(Error::AacsHostCertRejected))); +} + +#[test] +fn image_unreadable_uk_ro_is_recorded_not_fatal() { + let mut mem = bd_disc(Some(false)); + lay_dir( + &mut mem, + &DirSpec { + name: "AACS".into(), + icb_lba: 20, + dir_data_lba: 21, + files: vec![file_with("Content000.cer", 41, 610, cert(false), false)], + subdirs: vec![], + }, + ); + let mut d = Disc::scan_image(&mut mem, 9_999, &ScanOptions::default()).expect("scan"); + assert!(d.aacs.is_none()); + assert!( + matches!(d.aacs_error, Some(Error::AacsNoKeys)), + "{:?}", + d.aacs_error + ); + d.decrypt_with(Key::Unit(vec![(0, [0x11; 16])]), &[]) + .expect("a mapfile unit key is accepted on an image"); +} diff --git a/src/drive/mod.rs b/src/drive/mod.rs index 3bc0d50e..6d1233bf 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -289,7 +289,7 @@ impl Drive { self.event_fn = Some(Box::new(f)); } - fn is_halted(&self) -> bool { + pub(crate) fn is_halted(&self) -> bool { self.halt.load(Ordering::Relaxed) } @@ -611,7 +611,7 @@ impl Drive { // Record which firmware unlocker ran, not the id-only lookup. self.unlocker_name = Some(matched.to_string()); // Stash the OEM Volume ID (best-effort: a transient miss leaves - // the drive unlocked with no VID). do_handshake reads `oem_vid()`. + // the drive unlocked with no VID). do_handshake_cert reads `oem_vid()`. if let Some(vid) = unlocked.vid { self.oem_vid = Some(vid); } @@ -620,11 +620,9 @@ impl Drive { // No firmware unlocker claimed the drive — a stock/cert-only drive; // the AACS cert route runs later at the handshake phase. Ok(None) => Ok(()), - Err(freemkv_unlock::UnlockError::Transport) => Err(Error::ScsiError { - opcode: 0, - status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, - sense: None, - }), + Err(freemkv_unlock::UnlockError::Transport) => { + Err(crate::unlock_bridge::unlock_transport_error()) + } Err(_) => Ok(()), }; // Raise to max read speed UNCONDITIONALLY whenever the bus is alive, for diff --git a/src/error.rs b/src/error.rs index 9edb5d57..a7efc6f3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -113,6 +113,19 @@ pub const E_KEY_SERVICE_UNAUTHORIZED: u16 = 7029; /// The operator action is to back off and retry more slowly; the disc's key may /// well exist. pub const E_KEY_SERVICE_RATE_LIMITED: u16 = 7030; +/// A live AACS disc's `Unit_Key_RO.inf` (both copies) is missing or unreadable. +/// The key file is required, so the live scan stops. NOT [`E_AACS_NO_KEYS`]: +/// no key lookup was reached, and the fix is the disc, not a key database. +pub const E_AACS_KEY_FILE_UNREADABLE: u16 = 7031; +/// A decrypted whole-disc image (disc/image -> ISO) cannot be made: a stream +/// file no title plays is encrypted with a key none of the held keys opens. +/// NOT [`E_DECRYPT_FAILED`]: the fix is an MKV rip or a raw copy, not a key. +pub const E_WHOLE_DISC_KEY_MISSING: u16 = 7032; +/// A key source offered host cert(s), but every one failed a local check +/// before any drive round-trip (keydb data problem). NOT +/// [`E_AACS_NO_HOST_CERT`] (no cert offered at all) or +/// [`E_AACS_HOST_CERT_REJECTED`] (the drive itself rejected a cert). +pub const E_AACS_NO_USABLE_HOST_CERT: u16 = 7033; // Keydb (8xxx) pub const E_KEYDB_CONNECT: u16 = 8000; @@ -584,6 +597,13 @@ pub enum Error { AacsNoHostCert { path: String, }, + /// A key source offered host cert(s) for the drive's handshake, but every + /// one failed a LOCAL check (stored private key vs cert public key) + /// before any drive round-trip — a keydb data problem, not a drive + /// rejection. Distinct from [`Error::AacsNoHostCert`] (no cert offered at + /// all) and [`Error::AacsHostCertRejected`] (the drive rejected a cert + /// it actually saw). + AacsNoUsableHostCert, /// A bus-encrypted disc (AACS 2.0 / UHD, Content Certificate bus-encryption bit set) was /// scanned on a live drive, the Volume ID was obtained, but no `read_data_key` (bus key) /// was produced — so the on-disc bytes are still bus-encrypted and would decrypt to @@ -592,6 +612,13 @@ pub enum Error { /// of silently producing a corrupt rip. NOT raised for AACS 1.0 BD or file-backed (ISO) /// scans, where no bus-key handshake runs. AacsBusKeyUnavailable, + /// A live AACS disc's `Unit_Key_RO.inf` is missing or unreadable (both copies). + /// `Disc::scan` returns it; a `raw_copy` scan records it and refuses every key. + AacsKeyFileUnreadable, + /// A decrypted whole-disc image would keep encrypted pieces: a stream file + /// no title plays is encrypted and no held key opens it. See + /// [`E_WHOLE_DISC_KEY_MISSING`]. Raised before the copy where it can be. + WholeDiscKeyMissing, /// AACS 2.1 (FMTS) disc carries forensic variant segments, but no segment /// (variant) key is available to open them. Raised UPFRONT — before the mux — @@ -922,7 +949,10 @@ impl Error { Error::KeyServiceUnauthorized => E_KEY_SERVICE_UNAUTHORIZED, Error::KeyServiceRateLimited => E_KEY_SERVICE_RATE_LIMITED, Error::AacsNoHostCert { .. } => E_AACS_NO_HOST_CERT, + Error::AacsNoUsableHostCert => E_AACS_NO_USABLE_HOST_CERT, Error::AacsBusKeyUnavailable => E_AACS_BUS_KEY_UNAVAILABLE, + Error::AacsKeyFileUnreadable => E_AACS_KEY_FILE_UNREADABLE, + Error::WholeDiscKeyMissing => E_WHOLE_DISC_KEY_MISSING, Error::FmtsKeyMissing => E_FMTS_KEY_MISSING, Error::KeydbConnect { .. } => E_KEYDB_CONNECT, Error::KeydbHttp { .. } => E_KEYDB_HTTP, @@ -1584,6 +1614,9 @@ mod tests { Error::SinkWroteNothing.code(), Error::StreamClosed.code(), Error::StreamHeaderWritten.code(), + Error::AacsKeyFileUnreadable.code(), + Error::AacsNoUsableHostCert.code(), + Error::WholeDiscKeyMissing.code(), Error::ShortImageRead { lba: 0, expected: 1, @@ -1667,6 +1700,9 @@ mod tests { (Error::KeyServiceUnavailable, E_KEY_SERVICE_UNAVAILABLE), (Error::KeyServiceUnauthorized, E_KEY_SERVICE_UNAUTHORIZED), (Error::KeyServiceRateLimited, E_KEY_SERVICE_RATE_LIMITED), + (Error::AacsKeyFileUnreadable, E_AACS_KEY_FILE_UNREADABLE), + (Error::AacsNoUsableHostCert, E_AACS_NO_USABLE_HOST_CERT), + (Error::WholeDiscKeyMissing, E_WHOLE_DISC_KEY_MISSING), ]; for (e, want_code) in cases { let s = e.to_string(); @@ -1980,6 +2016,8 @@ mod tests { // AACS (7xxx) assert!((7000..8000).contains(&E_AACS_NO_KEYS)); assert!((7000..8000).contains(&E_NO_DISC_KEY)); + assert!((7000..8000).contains(&E_AACS_KEY_FILE_UNREADABLE)); + assert!((7000..8000).contains(&E_WHOLE_DISC_KEY_MISSING)); // Keydb (8xxx) assert!((8000..9000).contains(&E_KEYDB_CONNECT)); assert!((8000..9000).contains(&E_KEYDB_TOO_MANY_REDIRECTS)); @@ -2081,6 +2119,7 @@ mod tests { (Error::DecryptFailed, E_DECRYPT_FAILED), (Error::CssAuthFailed, E_CSS_AUTH_FAILED), (Error::AacsHostCertRejected, E_AACS_HOST_CERT_REJECTED), + (Error::AacsNoUsableHostCert, E_AACS_NO_USABLE_HOST_CERT), (Error::AacsRawReadUnsupported, E_AACS_RAW_READ_UNSUPPORTED), (Error::AacsVidUnavailable, E_AACS_VID_UNAVAILABLE), (Error::AacsMkUnavailable, E_AACS_MK_UNAVAILABLE), diff --git a/src/io/image_writer.rs b/src/io/image_writer.rs index 71c7825f..50691669 100644 --- a/src/io/image_writer.rs +++ b/src/io/image_writer.rs @@ -22,9 +22,10 @@ const BATCH_SECTORS: u32 = 2048; /// Write `total_sectors` sectors from `reader` to `dest`. /// -/// Reads sequentially, writes in order; does no decryption itself (caller's call). -/// `on_progress` runs after each batch with cumulative bytes and must not block; `halt` is -/// checked once per batch, and on cancellation the partial file is kept. Returns bytes written. +/// Reads 2048-sector batches from LBA 0 in order; decrypts nothing. Batches are not whole +/// 3-sector AACS units: wrap an AACS decrypting source to read on the unit grid, or it fails +/// the first misaligned batch ([`Error::DecryptFailed`]). `on_progress` gets cumulative bytes +/// per batch (must not block); `halt` is checked per batch, keeping the partial file. /// /// # Errors /// @@ -143,6 +144,69 @@ mod tests { } } + // `lba`s from `file` hold the same encrypted AACS unit, on the file's own grid. + struct AacsFileImage { + file: u32, + unit: Vec, + } + + impl SectorSource for AacsFileImage { + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + _recovery: bool, + ) -> FmResult { + for i in 0..count as usize { + let sector = &mut buf[i * SECTOR_BYTES..(i + 1) * SECTOR_BYTES]; + let s = lba + i as u32; + match s.checked_sub(self.file).filter(|&o| o < 30) { + Some(o) => { + let k = (o % 3) as usize * SECTOR_BYTES; + sector.copy_from_slice(&self.unit[k..k + SECTOR_BYTES]); + } + None => sector.fill(0), + } + } + Ok(count as usize * SECTOR_BYTES) + } + } + + fn clear_aacs_unit() -> Vec { + let mut unit = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN]; + for off in (4..unit.len()).step_by(192) { + unit[off] = 0x47; + } + unit[0] |= 0xC0; + unit + } + + // Batches are not whole AACS units: an unwrapped AACS decrypting source fails + // loud at the first misaligned batch that touches content, never ships ciphertext. + #[test] + fn write_image_over_an_unwrapped_aacs_source_fails_loud_at_a_misaligned_batch() { + use crate::decrypt::{AacsKeyMap, DecryptKeys}; + use std::sync::Arc; + let key = [0x5A; 16]; + let mut unit = clear_aacs_unit(); + assert!(crate::aacs::content::encrypt_unit(&mut unit, &key)); + let src = AacsFileImage { file: 3000, unit }; + let keys = DecryptKeys::Aacs { + unit_keys: vec![(0, key)], + format: crate::disc::ContentFormat::BdTs, + }; + let mut dec = crate::sector::DecryptingSectorSource::new(src, keys) + .with_key_map(Arc::new(AacsKeyMap::from_ranges(vec![(3000, 3030, 0)]))) + .with_content_ranges(Arc::from(vec![(3000u32, 30u32)])); + dec.set_unit_base(0); // file grid (3000 % 3 == 0): fails loud past the no-base gate + let dest = tmp("aacs-batch"); + let r = write_image(&mut dec, &dest, 3100, &Halt::new(), |_| {}); + let _ = std::fs::remove_file(&dest); + let e = r.expect_err("batch 2048 is off the unit grid and touches content"); + assert_eq!(e.code(), Error::DecryptFailed.code()); + } + fn tmp(name: &str) -> std::path::PathBuf { let mut p = std::env::temp_dir(); p.push(format!("fmkv-image-writer-{name}-{}", std::process::id())); diff --git a/src/keysource.rs b/src/keysource.rs index 1892382f..038e7557 100644 --- a/src/keysource.rs +++ b/src/keysource.rs @@ -288,9 +288,9 @@ pub trait KeySource { } /// The AACS host certificate(s) this source can supply for the live-drive - /// SCSI mutual-auth handshake (the OEM/AACS baseline route). `mkb` is the - /// disc's MKB generation when known, so a source MAY return only certs whose - /// generation matches (the default ignores it). A host cert unlocks the + /// SCSI mutual-auth handshake (the OEM/AACS baseline route), best first. + /// libfreemkv passes `None` for `mkb`: the argument is reserved and will be + /// removed at the next breaking window. A host cert unlocks the /// authenticated bus so the drive reports the Volume ID and bus key; it is /// **perishable** (revocable on a drive's HRL), so it is served by a source, /// never compiled in. A source holding no cert returns the empty vec. diff --git a/src/lib.rs b/src/lib.rs index 9b311022..e8d8eaba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -122,6 +122,8 @@ pub(crate) mod testlog; pub(crate) mod udf; #[cfg(feature = "rip")] pub(crate) mod unlock_bridge; +#[cfg(feature = "rip")] +pub mod whole_disc; // ─── Drive lifecycle ──────────────────────────────────────────────────────── // `Drive::open(path)` → `wait_ready()` → `init()` → `Disc::scan()`. `Drive` owns diff --git a/src/mux/disc.rs b/src/mux/disc.rs index ac16675b..cd414c73 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -269,7 +269,7 @@ impl DiscStream { // `reader` is already wrapped above (DecryptKeys::None makes it a // pass-through). Reset the unit base the probe read advanced so the // first fill_extents read starts cleanly. - reader.set_unit_base(0); + reader.clear_unit_base(); // B1 resync gates: one per stream, video flagged so the gate only // drop-to-keyframes video (audio/subtitle always admit). Computed before @@ -2587,6 +2587,7 @@ mod tests { // are installed, a read not starting on a 6144-byte unit boundary // hard-fails (it would mis-decrypt every following unit). let mut buf = vec![0u8; 2048]; + s.reader.set_unit_base(0); // anchored: witnesses the keys, not the no-base gate let before = s.reader.read_sectors(1, 1, &mut buf, false); assert!( matches!(before, Err(crate::error::Error::DecryptFailed)), diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index f3108e68..a9943736 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -802,7 +802,7 @@ fn single_base_key_slot(unit_keys: &[(u32, [u8; 16])]) -> Option { /// The pool slots of the disc's BASE CPS Unit Keys, in pool order — i.e. every /// entry that is not a forensic index key banked by [`resolve_fmts_key_map`] /// ([`FMTS_POOL_TAG_BASE`]). One element per CPS unit whose key is held. -fn base_key_slots(unit_keys: &[(u32, [u8; 16])]) -> Vec { +pub(crate) fn base_key_slots(unit_keys: &[(u32, [u8; 16])]) -> Vec { unit_keys .iter() .enumerate() @@ -1774,7 +1774,7 @@ pub fn build_iso_pipeline( } } crate::disc::dvd_audio_probe::probe_and_remap(&mut decrypting, &mut title); - decrypting.set_unit_base(0); + decrypting.clear_unit_base(); let prefetched = crate::sector::PrefetchedSectorSource::new_with_events( decrypting, diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index 8d9ece29..0a4392a7 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -76,11 +76,12 @@ pub struct DecryptingSectorSource { /// extent `start_lba` that AACS aligned units are anchored at. The unit- /// alignment gate measures `lba` relative to THIS, not absolute disc LBA 0, /// so a clip whose `start_lba` is not 3-aligned still gates correctly. Set - /// per-extent by the mux read paths via [`set_unit_base`]; defaults to 0 - /// (absolute alignment) for callers that read from a 3-aligned base. + /// per-extent via [`set_unit_base`]. `None` (the default): there is no implicit + /// disc-LBA-0 grid, so an AACS content read fails loud until a base is set — + /// callers of [`new`](Self::new) must anchor before reading AACS content. /// /// [`set_unit_base`]: Self::set_unit_base - unit_base: u32, + unit_base: Option, /// Encrypted-content map: every stream file's sorted/merged `(start_lba, /// sector_count)` (e.g. [`Disc::stream_content_ranges`](crate::Disc::stream_content_ranges)). /// When `Some`, a unit outside these ranges is clear and passed through @@ -113,7 +114,7 @@ impl DecryptingSectorSource { Self { inner, keys, - unit_base: 0, + unit_base: None, content_ranges: None, key_map: None, } @@ -156,6 +157,12 @@ impl DecryptingSectorSource { self.keys = keys; } + /// Drop the unit base: AACS content reads fail loud until + /// [`set_unit_base`](SectorSource::set_unit_base) anchors the next one. + pub fn clear_unit_base(&mut self) { + self.unit_base = None; + } + /// Borrow the inner source. Useful for tests and for adapters /// that want to introspect the underlying drive / file without /// unwrapping the decorator. @@ -223,12 +230,14 @@ impl SectorSource for DecryptingSectorSource { let content = self.content_ranges.clone(); let content_ref = content.as_deref(); - // Defense-in-depth: AACS units are 3-sector aligned; misalignment silently mis- - // decrypts, so reject loud (DecryptFailed) before reading — gated vs `unit_base` - // (not raw `lba % 3`), only when the span TOUCHES content (else UDF/nav passes through). + // AACS units are 3-sector aligned on each file's grid; a content read with no + // base, or off it, silently mis-decrypts, so reject loud before reading. Only + // spans that TOUCH content are gated (UDF/nav passes through). if matches!(self.keys, DecryptKeys::Aacs { .. }) && span_touches_content(content_ref, lba, count) - && !crate::aacs::content::is_unit_aligned(lba, self.unit_base) + && !self + .unit_base + .is_some_and(|b| crate::aacs::content::is_unit_aligned(lba, b)) { return Err(crate::error::Error::DecryptFailed); } @@ -262,7 +271,7 @@ impl SectorSource for DecryptingSectorSource { } fn set_unit_base(&mut self, lba: u32) { - self.unit_base = lba; + self.unit_base = Some(lba); } } @@ -741,6 +750,7 @@ mod tests { 0, )])); let mut dec = DecryptingSectorSource::new(src, keys).with_key_map(map); + dec.set_unit_base(0); let mut buf = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN]; let n = dec.read_sectors(0, 3, &mut buf, false).unwrap(); assert_eq!(n, crate::aacs::content::ALIGNED_UNIT_LEN); @@ -767,6 +777,7 @@ mod tests { format: crate::disc::ContentFormat::BdTs, }; let mut dec = DecryptingSectorSource::new(src, keys); // no with_key_map + dec.set_unit_base(0); let mut buf = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN]; let err = dec .read_sectors(0, 3, &mut buf, false) @@ -860,6 +871,7 @@ mod tests { )])); let mut dec = DecryptingSectorSource::new(CountingSource(reads.clone()), keys).with_key_map(map); + dec.set_unit_base(0); let mut buf = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN]; let err = dec .read_sectors(1, 3, &mut buf, false) @@ -879,4 +891,75 @@ mod tests { assert!(dec.read_sectors(3, 3, &mut buf, false).is_err()); assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 1); } + + // A file whose first sector is LBA 1001 (1001 % 3 == 2): two encrypted units on + // its own grid (1001, 1004), zeroes elsewhere. + struct MisalignedFile(Vec); + const FILE_LBA: u32 = 1001; + impl SectorSource for MisalignedFile { + fn read_sectors(&mut self, lba: u32, c: u16, b: &mut [u8], _r: bool) -> Result { + for i in 0..c as usize { + let rel = (lba as usize + i).wrapping_sub(FILE_LBA as usize) * 2048; + let dst = &mut b[i * 2048..(i + 1) * 2048]; + match self.0.get(rel..rel + 2048) { + Some(src) => dst.copy_from_slice(src), + None => dst.fill(0), + } + } + Ok(c as usize * 2048) + } + } + + fn misaligned_file_source() -> DecryptingSectorSource { + let key = [0x5Au8; 16]; + let mut data = encrypt_aacs_unit(&key); + data.extend(encrypt_aacs_unit(&key)); + let keys = DecryptKeys::Aacs { + unit_keys: vec![(0, key)], + format: crate::disc::ContentFormat::BdTs, + }; + let map = Arc::new(crate::decrypt::AacsKeyMap::from_ranges(vec![( + FILE_LBA, + FILE_LBA + 6, + 0, + )])); + DecryptingSectorSource::new(MisalignedFile(data), keys) + .with_key_map(map) + .with_content_ranges(Arc::from(vec![(FILE_LBA, 6u32)])) + } + + // No default grid: an AACS content read before any `set_unit_base` fails loud, + // never decrypts on the disc-LBA-0 grid (1002 % 3 == 0 once passed the gate). + #[test] + fn aacs_content_read_without_unit_base_fails_loud() { + let mut dec = misaligned_file_source(); + let mut buf = vec![0u8; 2 * crate::aacs::content::ALIGNED_UNIT_LEN]; + let r = dec.read_sectors(FILE_LBA + 1, 6, &mut buf, false); + assert!( + matches!(r, Err(crate::error::Error::DecryptFailed)), + "no unit base must be DecryptFailed, got {r:?}" + ); + } + + // A wrong explicit base (disc grid) cuts chunks across real units: their seed has + // no TS sync at byte 4, so the decrypt refuses instead of returning garbage. + #[test] + fn aacs_read_on_wrong_unit_grid_fails_loud() { + let mut dec = misaligned_file_source(); + let mut buf = vec![0u8; 2 * crate::aacs::content::ALIGNED_UNIT_LEN]; + dec.set_unit_base(FILE_LBA + 1); + let r = dec.read_sectors(FILE_LBA + 1, 6, &mut buf, false); + assert!( + matches!(r, Err(crate::error::Error::DecryptFailed)), + "an off-grid read must be DecryptFailed, got Ok" + ); + + // The file's own grid decrypts both units byte-exact. + dec.set_unit_base(FILE_LBA); + dec.read_sectors(FILE_LBA, 6, &mut buf, false) + .expect("file-grid read decrypts"); + let mut want = clear_aacs_unit(); + want.extend(clear_aacs_unit()); + assert_eq!(buf, want); + } } diff --git a/src/udf.rs b/src/udf.rs index 88091eb2..cbab4b8c 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -1575,8 +1575,8 @@ pub(crate) fn parse_dstring_for_test(data: &[u8]) -> String { // Buffered sector reader — coalesces single-sector reads into `batch`-sized SCSI commands, // since per-command latency dominates on USB drives. -pub(crate) struct BufferedSectorReader<'a> { - inner: &'a mut dyn SectorSource, +pub(crate) struct BufferedSectorReader<'a, S: SectorSource + ?Sized = dyn SectorSource> { + inner: &'a mut S, cache_start: u32, cache: Vec, cache_sectors: u32, @@ -1585,8 +1585,8 @@ pub(crate) struct BufferedSectorReader<'a> { prefetched: std::collections::HashMap>, } -impl<'a> BufferedSectorReader<'a> { - pub(crate) fn new(inner: &'a mut dyn SectorSource, batch: u16) -> Self { +impl<'a, S: SectorSource + ?Sized> BufferedSectorReader<'a, S> { + pub(crate) fn new(inner: &'a mut S, batch: u16) -> Self { Self { inner, cache_start: u32::MAX, @@ -1596,9 +1596,20 @@ impl<'a> BufferedSectorReader<'a> { prefetched: std::collections::HashMap::new(), } } + + // The wrapped source, so a live scan can run the handshake on the drive + // without dropping the prefetched metadata cache. + pub(crate) fn inner_mut(&mut self) -> &mut S { + self.inner + } + + // Ends the buffering and hands the source back (the live scan's bus wiring). + pub(crate) fn into_inner(self) -> &'a mut S { + self.inner + } } -impl BufferedSectorReader<'_> { +impl BufferedSectorReader<'_, S> { /// Pre-read a contiguous range of sectors into the sliding cache. /// Used to bulk-load the UDF metadata partition so subsequent reads are instant. pub(crate) fn prefetch(&mut self, start_lba: u32, count: u32) { @@ -1680,7 +1691,7 @@ impl BufferedSectorReader<'_> { } } -impl SectorSource for BufferedSectorReader<'_> { +impl SectorSource for BufferedSectorReader<'_, S> { fn read_sectors( &mut self, lba: u32, diff --git a/src/unlock_bridge.rs b/src/unlock_bridge.rs index f48e501e..8ab57abc 100644 --- a/src/unlock_bridge.rs +++ b/src/unlock_bridge.rs @@ -75,6 +75,16 @@ impl fu::scsi::ScsiTransport for ScsiAdapter<'_> { } } +// The one Error for an unlocker's `Transport` (dead bus): `Drive::init` and both +// scan bus steps (AACS, CSS) abort with it. +pub(crate) fn unlock_transport_error() -> crate::error::Error { + crate::error::Error::ScsiError { + opcode: 0, + status: crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE, + sense: None, + } +} + /// Map libfreemkv's host certs (keysource-collected) to the unlock contract's. pub(crate) fn map_host_certs(certs: &[crate::aacs::types::HostCert]) -> Vec { certs diff --git a/src/whole_disc.rs b/src/whole_disc.rs new file mode 100644 index 00000000..e5f354b3 --- /dev/null +++ b/src/whole_disc.rs @@ -0,0 +1,657 @@ +//! The whole-disc decrypting reader behind every decrypted disc/image → ISO copy, so the +//! GUI, the CLI and the image path share one set of rules. AACS content is every content +//! file (`/BDMV/STREAM`, HD DVD `/HVDVD_TS/*.EVO`), not just the kept titles. Each file no +//! title plays is keyed on its own, probed at up to 32 units across it: a held key must +//! open TWO probed units before it keys the file (one chance TS-sync pass is no proof); +//! ciphertext no held key opens refuses up front ([`Error::WholeDiscKeyMissing`]); a file +//! with no proven key is keyed only on a provably single-CPS disc, else left unkeyed so an +//! encrypted unit there stops the pass with the same error (never ships as ciphertext). +//! Reads follow each file's own 3-sector unit grid ([`UnitAligned`]). + +use crate::decrypt::{AacsKeyMap, DecryptKeys, Phase}; +use crate::error::{Error, Result}; +use crate::sector::{DecryptingSectorSource, KeyFetch, SectorSource}; + +/// Sectors in one AACS aligned unit (6144 bytes). +const UNIT: u64 = (crate::aacs::content::ALIGNED_UNIT_LEN / 2048) as u64; + +/// Units probed per unplayed content file: its first unit, then evenly across it. +const PROBES: u64 = 32; + +/// Probed units a key must open before it keys a file. +const PROOFS: u8 = 2; + +/// The decrypting reader a whole-disc copy reads through. +pub type WholeDiscReader = UnitAligned>; + +/// Build the whole-disc reader over `reader`. For AACS every content file is keyed here, so +/// a refusal (or a stop during keying) comes before the caller creates any output; the one +/// exception is a file with no provable key on a multi-key disc, which stops the pass with +/// E7032 at its first encrypted unit. `fetch` goes to [`crate::mux::resolve_mux_key_map`], +/// which asks it only when the pool holds several base keys and none opens a file's +/// samples. CSS is descrambled; `decrypt == false` (raw copy) and clear discs pass through. +pub fn whole_disc_reader( + disc: &crate::Disc, + mut reader: S, + decrypt: bool, + fetch: Option<&KeyFetch>, + halt: Option<&crate::halt::Halt>, +) -> Result> { + let mut keys = if decrypt { + disc.decrypt_keys() + } else { + DecryptKeys::None + }; + let mut content = disc.encrypted_content_ranges(); + let mut spans = Vec::new(); + let mut unproven = Vec::new(); + let mut key_map = None; + if matches!(keys, DecryptKeys::Aacs { .. }) { + let r: &mut dyn SectorSource = &mut reader; + let map = disc.resolve_content_key_map(r, &mut keys, fetch, halt)?; + // freemkv#55: the key map alone is no gate; clear UDF/nav outside it would refuse. + let files = content_files(r)?; + if files.is_empty() && !content.is_empty() { + tracing::warn!(target: "freemkv::scan", "titles but no AACS content files"); + let path = match disc.format { + crate::DiscFormat::HdDvd => "/HVDVD_TS", + _ => "/BDMV/STREAM", + }; + return Err(Error::UdfNotFound { path: path.into() }); + } + spans = unit_spans(&files, &content); + let rule = KeyRule { + format: disc.content_format, + single: single_cps_key_slot(disc, &keys, &map), + fetch, + halt, + }; + let keyed = key_content_files(r, &rule, &mut keys, map, &files, &spans)?; + key_map = Some(std::sync::Arc::new(keyed.map)); + unproven = keyed.unproven; + content.extend(files.iter().flatten()); + content = merge_ranges(content); + // A stop that landed during the last key lookup still ends before any output. + if halt.is_some_and(|h| h.is_cancelled()) { + return Err(Error::Halted); + } + } + let mut dec = DecryptingSectorSource::new(reader, keys); + if let Some(map) = key_map { + dec = dec.with_key_map(map); + } + // CSS self-descrambles and `None` decrypts nothing: the gate only matters for AACS. + if decrypt && !content.is_empty() { + dec = dec.with_content_ranges(std::sync::Arc::from(content)); + } + let mut out = UnitAligned::new(dec, spans); + out.unproven = unproven; + Ok(out) +} + +// Every AACS content file's extents, one entry per file (contiguous extents joined): +// `/BDMV/STREAM` (m2ts before SSIF, which re-lists them) or else HD DVD `/HVDVD_TS/*.EVO`. +// An unreadable UDF or unmappable file fails loud: it would otherwise ship as ciphertext. +fn content_files(reader: &mut dyn SectorSource) -> Result>> { + let fs = crate::udf::read_filesystem(reader)?; + let (top, evo_only) = if fs.find_dir("/BDMV/STREAM").is_some() { + ("/BDMV/STREAM", false) + } else if fs.find_dir("/HVDVD_TS").is_some() { + ("/HVDVD_TS", true) + } else { + return Ok(Vec::new()); + }; + let mut paths = Vec::new(); + let mut stack: Vec<_> = fs + .find_dir(top) + .map(|d| (top.to_string(), d)) + .into_iter() + .collect(); + while let Some((dir, entry)) = stack.pop() { + for e in &entry.entries { + let path = format!("{dir}/{}", e.name); + if e.is_dir { + stack.push((path, e)); + } else if !evo_only || e.name.to_ascii_uppercase().ends_with(".EVO") { + paths.push(path); + } + } + } + paths.sort_by_key(|p| (p.to_ascii_uppercase().contains("/SSIF/"), p.clone())); + let mut files = Vec::with_capacity(paths.len()); + for path in paths { + let mut extents: Vec<(u32, u32)> = Vec::new(); + for (lba, n) in fs.file_extents(reader, &path)? { + match extents.last_mut() { + Some(last) if n > 0 && last.0 as u64 + last.1 as u64 == lba as u64 => last.1 += n, + _ if n > 0 => extents.push((lba, n)), + _ => {} + } + } + if !extents.is_empty() { + files.push(extents); + } + } + Ok(files) +} + +/// A content extent `(start, count)` and the LBA its unit grid is anchored at: the +/// owning file's first sector, carried across extents by file offset. +type UnitSpan = (u32, u32, u64); + +// The unit grid of every content extent: per file by file offset (never a merged run: +// adjacent files each start their own grid), then title extents no file covers (anchored +// at the extent start). Sorted, disjoint; on overlap the earlier-listed source wins. +fn unit_spans(files: &[Vec<(u32, u32)>], title_extents: &[(u32, u32)]) -> Vec { + let mut raw: Vec<(UnitSpan, usize)> = Vec::new(); + for (i, file) in files.iter().enumerate() { + let mut off = 0u64; + for &(lba, n) in file { + raw.push(((lba, n, (lba as u64).saturating_sub(off % UNIT)), i)); + off += n as u64; + } + } + for &(lba, n) in title_extents { + raw.push(((lba, n, lba as u64), files.len())); + } + raw.retain(|&((_, n, _), _)| n > 0); + raw.sort_by_key(|&((lba, _, _), i)| (lba, i)); + let mut spans: Vec = Vec::with_capacity(raw.len()); + let mut covered = 0u64; + for ((lba, n, anchor), _) in raw { + let end = lba as u64 + n as u64; + if end <= covered { + continue; + } + let start = (lba as u64).max(covered); + spans.push((start as u32, (end - start) as u32, anchor)); + covered = end; + } + spans +} + +// The span holding `lba`, if any. +fn span_at(spans: &[UnitSpan], lba: u64) -> Option { + let i = spans + .partition_point(|&(s, _, _)| s as u64 <= lba) + .checked_sub(1)?; + let span = spans[i]; + (lba < span.0 as u64 + span.1 as u64).then_some(span) +} + +// The first unit head at or after `lba` on the grid anchored at `anchor`. +fn unit_head(lba: u32, anchor: u64) -> u64 { + lba as u64 + (UNIT - (lba as u64).saturating_sub(anchor) % UNIT) % UNIT +} + +// How content files are keyed: the disc's content format, the single-CPS slot an +// unproven file may take, and the key source + stop token. +struct KeyRule<'a> { + format: crate::ContentFormat, + single: Option, + fetch: Option<&'a KeyFetch>, + halt: Option<&'a crate::halt::Halt>, +} + +// The keyed content map plus the `[start, end)` pieces left unkeyed (no proven key). +struct ContentKeys { + map: AacsKeyMap, + unproven: Vec<(u32, u32)>, +} + +// Key every content file no kept title plays, ONE FILE AT A TIME (CPS units sit back to +// back), with a held key proven on its ciphertext; ciphertext none opens refuses now. +fn key_content_files( + reader: &mut dyn SectorSource, + rule: &KeyRule, + keys: &mut DecryptKeys, + map: AacsKeyMap, + files: &[Vec<(u32, u32)>], + spans: &[UnitSpan], +) -> Result { + let mut keyed: Vec<(u32, u32)> = map.ranges().iter().map(|&(s, e, _, _)| (s, e)).collect(); + let mut ranges = map.ranges().to_vec(); + let mut unproven = Vec::new(); + for file in files { + let orphans = subtract_ranges(file, &keyed); + if orphans.is_empty() { + continue; + } + // Each piece from its first unit head, so key resolution samples on the file grid. + let heads: Vec<(u32, u32, u64)> = orphans + .iter() + .map(|&(s, e)| { + let anchor = span_at(spans, s as u64).map_or(s as u64, |sp| sp.2); + (unit_head(s, anchor).min(e as u64) as u32, e, anchor) + }) + .collect(); + let mut title = crate::DiscTitle::empty(); + title.content_format = rule.format; + title.extents = heads + .iter() + .filter(|&&(h, e, _)| h < e) + .map(|&(h, e, _)| crate::Extent { + start_lba: h, + sector_count: e - h, + }) + .collect(); + let fmap = crate::mux::resolve_mux_key_map( + reader, + &title, + keys, + rule.fetch, + rule.format, + rule.halt, + ) + .map_err(|e| unkeyable(Error::from(e), file))?; + for (&(s, e), &(_, _, anchor)) in orphans.iter().zip(&heads) { + let probe = Probe { + map: &fmap, + keys, + format: rule.format, + halt: rule.halt, + }; + match probe + .run(reader, (s, e), anchor) + .map_err(|e| unkeyable(e, file))? + { + Proof::Forensic => { + ranges.extend(fmap.ranges().iter().filter_map(|&(rs, re, i, p)| { + (rs < e && re > s).then_some((rs.max(s), re.min(e), i, p)) + })); + } + Proof::Key(slot) => ranges.push((s, e, slot, Phase::All)), + Proof::Unproven => match rule.single { + Some(slot) => ranges.push((s, e, slot, Phase::All)), + None => { + tracing::warn!( + target: "freemkv::scan", + start = s, + end = e, + probes = PROBES, + "unplayed stream file: no key opened two readable encrypted \ + probes (damaged area, or too little ciphertext), so its key \ + is unproven on this multi-key disc. Left unkeyed: if the copy \ + meets an encrypted unit here it stops with E7032. An MKV rip \ + or a raw copy avoids this." + ); + unproven.push((s, e)); + } + }, + } + } + // Settled either way: a file re-listing these sectors (SSIF) is not re-keyed. + keyed.extend(orphans); + keyed.sort_unstable(); + } + Ok(ContentKeys { + map: AacsKeyMap::from_ranges_phased(merge_key_ranges(ranges)), + unproven: merge_spans(unproven), + }) +} + +// A key-resolution refusal for an unplayed file: "no held key opens it" is +// [`Error::WholeDiscKeyMissing`] (the fix is an MKV rip or a raw copy). +fn unkeyable(e: Error, file: &[(u32, u32)]) -> Error { + match e { + Error::DecryptFailed | Error::WholeDiscKeyMissing => { + tracing::error!( + target: "freemkv::scan", + lba = file.first().map_or(0, |f| f.0), + code = crate::error::E_WHOLE_DISC_KEY_MISSING, + "unplayed stream file is encrypted and no held key opens it: a decrypted \ + image would keep encrypted pieces. Refusing before the copy starts." + ); + Error::WholeDiscKeyMissing + } + other => other, + } +} + +// What probing an unplayed file's ciphertext proved. +#[derive(Debug, PartialEq)] +enum Proof { + /// A forensic (FMTS) segment entry: libfreemkv verifies each such unit as it decrypts. + Forensic, + /// This pool slot opened [`PROOFS`] probed units. + Key(usize), + /// No key opened enough readable encrypted units, and none was unopenable. + Unproven, +} + +// Inputs for proving an unplayed file's key. +struct Probe<'a> { + map: &'a AacsKeyMap, + keys: &'a DecryptKeys, + format: crate::ContentFormat, + halt: Option<&'a crate::halt::Halt>, +} + +impl Probe<'_> { + // Probe `[start, end)` on the file's grid (`anchor`): its first unit, then evenly + // across it. Each encrypted unit is tried with the mapped key, then every held base + // key (never forensic ones). Err(WholeDiscKeyMissing): a unit no held key opens. + fn run( + &self, + reader: &mut dyn SectorSource, + (start, end): (u32, u32), + anchor: u64, + ) -> Result { + use crate::aacs::content::{aacs_unit_encrypted, decrypt_unit, is_clean}; + let DecryptKeys::Aacs { unit_keys, .. } = self.keys else { + return Ok(Proof::Unproven); + }; + let base = crate::mux::resolve::base_key_slots(unit_keys); + let head = unit_head(start, anchor); + let units = (end as u64).saturating_sub(head) / UNIT; + let mut opened = vec![0u8; unit_keys.len()]; + let mut unopened = false; + let mut buf = vec![0u8; crate::aacs::content::ALIGNED_UNIT_LEN]; + let opens = |buf: &[u8], slot: usize| { + unit_keys.get(slot).is_some_and(|(_, key)| { + let mut u = buf.to_vec(); + decrypt_unit(&mut u, key); + is_clean(&u, self.format) + }) + }; + for unit in probe_units(units) { + if self.halt.is_some_and(|h| h.is_cancelled()) { + return Err(Error::Halted); + } + let Ok(lba) = u32::try_from(head + unit * UNIT) else { + continue; + }; + if !matches!(reader.read_sectors(lba, UNIT as u16, &mut buf, false), + Ok(n) if n == buf.len()) + || !aacs_unit_encrypted(&buf, self.format) + { + continue; + } + let mapped = match self.map.entry_for(lba) { + Some((_, p, _)) if p != Phase::All => return Ok(Proof::Forensic), + entry => entry.map(|(slot, _, _)| slot), + }; + let mut candidates = mapped + .into_iter() + .chain(base.iter().copied().filter(|&s| Some(s) != mapped)); + match candidates.find(|&slot| opens(&buf, slot)) { + Some(slot) => { + opened[slot] = opened[slot].saturating_add(1); + if opened[slot] >= PROOFS { + return Ok(Proof::Key(slot)); + } + } + None => unopened = true, + } + } + if unopened { + return Err(Error::WholeDiscKeyMissing); + } + Ok(Proof::Unproven) + } +} + +// Which of a piece's `units` to probe, in order: all of them when few, else the +// first unit, then `PROBES - 1` more spread evenly to the end. +fn probe_units(units: u64) -> Vec { + if units <= PROBES { + return (0..units).collect(); + } + (0..PROBES).map(|p| units * p / PROBES).collect() +} + +/// The key-pool slot of the disc's only CPS unit, when provably single-CPS: +/// `Unit_Key_RO.inf` declares exactly one unit, the disc is not FMTS, and `map` +/// uses at most one key. Never the pool size: one held key on a two-unit disc is +/// not single-CPS. +fn single_cps_key_slot(disc: &crate::Disc, keys: &DecryptKeys, map: &AacsKeyMap) -> Option { + use crate::aacs::mkb::AacsVersion; + let aacs = disc.aacs.as_ref()?; + if disc.format == crate::DiscFormat::Fmts || map.ranges().iter().any(|r| r.3 != Phase::All) { + return None; + } + let version = if aacs.version >= 2 { + AacsVersion::V20 + } else { + AacsVersion::V10 + }; + let ukf = crate::aacs::inf::parse_unit_key_ro(&aacs.uk_ro, version)?; + if ukf.encrypted_keys.len() != 1 { + return None; + } + match (map.key_indices(), keys) { + ([only], _) => Some(*only), + ([], DecryptKeys::Aacs { unit_keys, .. }) if unit_keys.len() == 1 => Some(0), + _ => None, + } +} + +// Sorted, disjoint key ranges (`AacsKeyMap::entry_for` checks one neighbour), merged as +// the per-disc map is: same key + phase overlap unions; a conflicting overlap drops. +fn merge_key_ranges(mut ranges: Vec<(u32, u32, usize, Phase)>) -> Vec<(u32, u32, usize, Phase)> { + ranges.sort_by_key(|r| r.0); + let mut merged: Vec<(u32, u32, usize, Phase)> = Vec::with_capacity(ranges.len()); + for r in ranges { + match merged.last_mut() { + Some(last) if r.0 < last.1 => { + if r.2 == last.2 && r.3 == last.3 { + last.1 = last.1.max(r.1); + } + } + _ => merged.push(r), + } + } + merged +} + +// `content` `(start, count)` ranges minus the sorted, disjoint `[start, end)` `keyed` +// ranges, as `[start, end)` pieces. +fn subtract_ranges(content: &[(u32, u32)], keyed: &[(u32, u32)]) -> Vec<(u32, u32)> { + let mut out = Vec::new(); + for &(start, count) in content { + let end = start.saturating_add(count); + let mut pos = start; + for &(ks, ke) in keyed { + if ke <= pos || ks >= end { + continue; + } + if ks > pos { + out.push((pos, ks)); + } + pos = pos.max(ke); + } + if pos < end { + out.push((pos, end)); + } + } + out +} + +// Sort + coalesce overlapping/adjacent `(start, count)` ranges (the content gate's +// binary search needs them sorted and disjoint); empty ranges are dropped. +fn merge_ranges(mut ranges: Vec<(u32, u32)>) -> Vec<(u32, u32)> { + ranges.retain(|&(_, count)| count > 0); + ranges.sort_unstable(); + let mut out: Vec<(u32, u32)> = Vec::with_capacity(ranges.len()); + for (start, count) in ranges { + let end = start as u64 + count as u64; + if let Some(last) = out.last_mut() { + let last_end = last.0 as u64 + last.1 as u64; + if start as u64 <= last_end { + let merged = last_end.max(end) - last.0 as u64; + last.1 = u32::try_from(merged).unwrap_or(u32::MAX); + continue; + } + } + out.push((start, count)); + } + out +} + +// Sort + coalesce `[start, end)` ranges. +fn merge_spans(mut v: Vec<(u32, u32)>) -> Vec<(u32, u32)> { + v.sort_unstable(); + let mut out: Vec<(u32, u32)> = Vec::with_capacity(v.len()); + for (s, e) in v { + match out.last_mut() { + Some(last) if s <= last.1 => last.1 = last.1.max(e), + _ => out.push((s, e)), + } + } + out +} + +/// Reads through `inner` on each content file's AACS unit grid: a read touching a span +/// is widened to whole units of that span (sweep batches, `write_image` batches and +/// patch's single-sector reads are not unit multiples). A unit whose head lies outside +/// its span fails loud; a decrypt refusal inside an unproven file is E7032. +pub struct UnitAligned { + inner: S, + spans: Vec, + scratch: Vec, + /// Unplayed-file pieces left unkeyed (no proven key), `[start, end)`. + unproven: Vec<(u32, u32)>, +} + +impl UnitAligned { + fn new(inner: S, spans: Vec) -> Self { + Self { + inner, + spans, + scratch: Vec::new(), + unproven: Vec::new(), + } + } + + /// Where a block of sectors `[start, end)` should end so consecutive blocks tile each + /// file's unit grid: `end` pulled back to its unit's head when that unit straddles it + /// (else a bad sector there fails both neighbouring blocks). Never at or before `start`. + pub fn unit_block_end(&self, start: u64, end: u64) -> u64 { + match span_at(&self.spans, end) { + Some((s, _, anchor)) if end > anchor => { + let head = anchor + (end - anchor) / UNIT * UNIT; + if head > start && head >= s as u64 { + head + } else { + end + } + } + _ => end, + } + } + + // A decrypt refusal inside an unproven piece is the key it could not prove up front. + fn refusal(&self, err: Error, a0: u64, a1: u64) -> Error { + let hit = self + .unproven + .iter() + .any(|&(s, e)| (s as u64) < a1 && (e as u64) > a0); + match err { + Error::DecryptFailed if hit => { + tracing::error!( + target: "freemkv::disc", + lba = a0, + code = crate::error::E_WHOLE_DISC_KEY_MISSING, + "encrypted unit in an unplayed stream file whose key could not be \ + proven before the copy (no readable probe); stopping rather than \ + writing ciphertext into a decrypted image" + ); + Error::WholeDiscKeyMissing + } + other => other, + } + } +} + +impl SectorSource for UnitAligned { + fn capacity_sectors(&self) -> u32 { + self.inner.capacity_sectors() + } + + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + ) -> Result { + self.read_sectors_fua(lba, count, buf, recovery, false) + } + + fn read_sectors_fua( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + fua: bool, + ) -> Result { + const SECTOR: usize = 2048; + let end = lba as u64 + count as u64; + let mut cur = lba as u64; + while cur < end { + let off = (cur - lba as u64) as usize * SECTOR; + let Some((s, n, anchor)) = span_at(&self.spans, cur) else { + // Outside every content span: a plain read up to the next span. + let i = self.spans.partition_point(|&(s, _, _)| s as u64 <= cur); + let next = self + .spans + .get(i) + .map_or(end, |&(s, _, _)| end.min(s as u64)); + let want = (next - cur) as usize * SECTOR; + self.inner.set_unit_base(cur as u32); + let got = self.inner.read_sectors_fua( + cur as u32, + (next - cur) as u16, + &mut buf[off..off + want], + recovery, + fua, + )?; + if got < want { + return Ok(off + got); + } + cur = next; + continue; + }; + let (s, e) = (s as u64, s as u64 + n as u64); + let a0 = anchor + (cur - anchor) / UNIT * UNIT; + if a0 < s { + // The unit straddles a non-contiguous extent boundary: undecryptable here. + return Err(Error::DecryptFailed); + } + // Capped so the widened read still fits one u16-count request. + let piece_end = end.min(e).min(a0 + (u16::MAX as u64 / UNIT - 1) * UNIT); + let a1 = e.min(a0 + (piece_end - a0).div_ceil(UNIT) * UNIT); + let len = (a1 - a0) as usize * SECTOR; + self.scratch.resize(len, 0); + self.inner.set_unit_base(a0 as u32); + let got = self + .inner + .read_sectors_fua( + a0 as u32, + (a1 - a0) as u16, + &mut self.scratch[..len], + recovery, + fua, + ) + .map_err(|e| self.refusal(e, a0, a1))?; + let skip = (cur - a0) as usize * SECTOR; + let want = (piece_end - cur) as usize * SECTOR; + let have = got.saturating_sub(skip).min(want); + buf[off..off + have].copy_from_slice(&self.scratch[skip..skip + have]); + if have < want { + return Ok(off + have); + } + cur = piece_end; + } + Ok(count as usize * SECTOR) + } + + fn set_speed(&mut self, kbs: u16) { + self.inner.set_speed(kbs) + } +} + +#[cfg(test)] +#[path = "whole_disc_tests.rs"] +mod tests; diff --git a/src/whole_disc_tests.rs b/src/whole_disc_tests.rs new file mode 100644 index 00000000..ffd40348 --- /dev/null +++ b/src/whole_disc_tests.rs @@ -0,0 +1,575 @@ +use super::*; +use crate::aacs::content::encrypt_unit; + +const SECTOR: usize = 2048; +const K0: [u8; 16] = [0x11; 16]; +const K1: [u8; 16] = [0x22; 16]; +const K2: [u8; 16] = [0x33; 16]; +const STRANGER: [u8; 16] = [0x77; 16]; + +// A clear content unit for the unit starting at `lba`: varied payload, TS sync at +// offset 4 of every 192-byte source packet, CPI bits clear. +fn clear_unit(lba: u32) -> Vec { + let mut u: Vec = (0..6144u32) + .map(|i| (lba.wrapping_mul(7919).wrapping_add(i) % 251) as u8 | 1) + .collect(); + for off in (4..6144).step_by(192) { + u[off] = 0x47; + } + u[0] &= 0x3F; + u +} + +/// An in-memory image: stream files laid out on their own unit grid, the listed +/// units encrypted under the file's key; sectors outside every file are +/// CPI-flagged-looking clear data (the freemkv#55 shape). +struct Img { + data: Vec, + plain: Vec, + /// One-unit reads starting in `[start, end)` fail (probes of a damaged area). + probe_fail: Option<(u32, u32)>, + reads: Vec<(u32, u16)>, +} + +// One file: start, sectors, key, and which of its units are encrypted. +type FileSpec = (u32, u32, [u8; 16], &'static [u32]); + +// Every unit of a 30-sector file. +const ALL: &[u32] = &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + +fn img(capacity: u32, files: &[FileSpec]) -> Img { + let mut data = vec![0u8; capacity as usize * SECTOR]; + for lba in 0..capacity { + let o = lba as usize * SECTOR; + for (i, b) in data[o..o + SECTOR].iter_mut().enumerate() { + *b = (lba as usize + i) as u8; + } + data[o] = 0xC0; + } + let mut plain = data.clone(); + for (start, n, key, enc) in files { + for u in 0..n / 3 { + let lba = start + u * 3; + let o = lba as usize * SECTOR; + let mut unit = clear_unit(lba); + if enc.contains(&u) { + unit[0] |= 0xC0; + plain[o..o + 6144].copy_from_slice(&unit); + assert!(encrypt_unit(&mut unit, key)); + } else { + plain[o..o + 6144].copy_from_slice(&unit); + } + data[o..o + 6144].copy_from_slice(&unit); + } + } + Img { + data, + plain, + probe_fail: None, + reads: Vec::new(), + } +} + +impl SectorSource for Img { + fn capacity_sectors(&self) -> u32 { + (self.data.len() / SECTOR) as u32 + } + fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8], _: bool) -> Result { + self.reads.push((lba, count)); + if count == 3 && self.probe_fail.is_some_and(|(s, e)| (s..e).contains(&lba)) { + return Err(Error::DiscRead { + sector: lba as u64, + status: None, + sense: None, + }); + } + let (o, n) = (lba as usize * SECTOR, count as usize * SECTOR); + buf[..n].copy_from_slice(&self.data[o..o + n]); + Ok(n) + } +} + +fn pool(keys: &[[u8; 16]]) -> DecryptKeys { + DecryptKeys::Aacs { + unit_keys: keys + .iter() + .enumerate() + .map(|(i, k)| (i as u32, *k)) + .collect(), + format: crate::ContentFormat::BdTs, + } +} + +fn rule(single: Option) -> KeyRule<'static> { + KeyRule { + format: crate::ContentFormat::BdTs, + single, + fetch: None, + halt: None, + } +} + +/// Key `files` the way [`whole_disc_reader`] does; the kept title plays file 0 with slot 0. +fn plan( + src: &mut Img, + keys: &mut DecryptKeys, + files: &[Vec<(u32, u32)>], + rule: &KeyRule, +) -> Result { + let (s, n) = files[0][0]; + let title = AacsKeyMap::from_ranges(vec![(s, s + n, 0)]); + let spans = unit_spans(files, &[]); + key_content_files(src, rule, keys, title, files, &spans) +} + +fn one_file_each(specs: &[FileSpec]) -> Vec> { + specs.iter().map(|f| vec![(f.0, f.1)]).collect() +} + +/// Wire the reader over `src` from a plan and copy the whole image with `write_image`. +fn write_through( + src: Img, + keys: DecryptKeys, + files: &[Vec<(u32, u32)>], + planned: ContentKeys, +) -> Result> { + let content = merge_ranges(files.iter().flatten().copied().collect()); + let cap = src.capacity_sectors(); + let dec = DecryptingSectorSource::new(src, keys) + .with_key_map(std::sync::Arc::new(planned.map)) + .with_content_ranges(std::sync::Arc::from(content)); + let mut r = UnitAligned::new(dec, unit_spans(files, &[])); + r.unproven = planned.unproven; + let dir = tempfile::tempdir().map_err(|e| Error::IoError { source: e })?; + let dest = dir.path().join("out.iso"); + crate::write_image(&mut r, &dest, cap, &crate::halt::Halt::new(), |_| {})?; + std::fs::read(&dest).map_err(|e| Error::IoError { source: e }) +} + +fn assert_plain(img_plain: &[u8], bytes: &[u8]) { + assert_eq!(bytes.len(), img_plain.len()); + for (lba, (got, want)) in bytes + .chunks(SECTOR) + .zip(img_plain.chunks(SECTOR)) + .enumerate() + { + assert!(got == want, "sector {lba} not decrypted as expected"); + } +} + +#[test] +fn every_unplayed_file_is_keyed_and_the_image_decrypts_across_batches() { + // Second file misaligned (start % 3 != 0) and crossing a 2048-sector batch edge. + let specs = [(300, 30, K0, ALL), (2041, 30, K0, ALL)]; + let files = one_file_each(&specs); + let mut src = img(2200, &specs); + let mut keys = pool(&[K0]); + let planned = plan(&mut src, &mut keys, &files, &rule(Some(0))).unwrap(); + assert!(planned.unproven.is_empty()); + let want = src.plain.clone(); + let bytes = write_through(img(2200, &specs), keys, &files, planned).unwrap(); + assert_plain(&want, &bytes); +} + +/// Back-to-back unplayed files in different CPS units each get their own key. +#[test] +fn adjacent_files_in_different_cps_units_each_get_their_own_key() { + let specs = [(300, 30, K0, ALL), (600, 30, K1, ALL), (630, 30, K2, ALL)]; + let files = one_file_each(&specs); + let mut keys = pool(&[K0, K1, K2]); + let planned = plan(&mut img(1200, &specs), &mut keys, &files, &rule(None)).unwrap(); + assert_eq!(planned.map.entry_for(600).map(|e| e.0), Some(1)); + assert_eq!(planned.map.entry_for(630).map(|e| e.0), Some(2)); + let bytes = write_through(img(1200, &specs), keys, &files, planned).unwrap(); + assert_plain(&img(1200, &specs).plain, &bytes); +} + +/// Ciphertext no held key opens refuses the plan: multi-CPS pool, and a single key. +#[test] +fn a_file_no_held_key_opens_is_refused_up_front() { + let specs = [(300, 30, K0, ALL), (600, 30, STRANGER, ALL)]; + let files = one_file_each(&specs); + for (p, single) in [(&[K0, K1][..], None), (&[K0][..], Some(0))] { + let r = plan(&mut img(1200, &specs), &mut pool(p), &files, &rule(single)); + assert!( + matches!(r, Err(Error::WholeDiscKeyMissing)), + "pool {}: {:?}", + p.len(), + r.err() + ); + } +} + +/// The resolver's evenly spaced samples miss a file encrypted only at its ends; the +/// probes cover every unit of a short file and prove the other held key twice. +#[test] +fn another_held_key_opening_two_probes_keys_the_file() { + let specs = [(300, 30, K0, ALL), (600, 30, K1, &[0, 9][..])]; + let files = one_file_each(&specs); + let mut src = img(1200, &specs); + let planned = plan(&mut src, &mut pool(&[K0, K1]), &files, &rule(None)).unwrap(); + assert_eq!(planned.map.entry_for(600).map(|e| e.0), Some(1)); + assert!(planned.unproven.is_empty()); +} + +/// One opened probe is no proof: a chance TS-sync pass must not key a whole file. +/// With no unit left unopened the file is unproven (keyed only on single-CPS). +#[test] +fn a_key_opening_a_single_probe_is_not_trusted() { + let specs = [(300, 30, K0, ALL), (600, 30, K1, &[0])]; + let files = one_file_each(&specs); + let planned = plan( + &mut img(1200, &specs), + &mut pool(&[K0, K1]), + &files, + &rule(None), + ) + .unwrap(); + assert_eq!(planned.map.entry_for(600), None); + assert_eq!(planned.unproven, vec![(600, 630)]); + let planned = plan( + &mut img(1200, &specs), + &mut pool(&[K0, K1]), + &files, + &rule(Some(0)), + ) + .unwrap(); + assert_eq!(planned.map.entry_for(600).map(|e| e.0), Some(0)); +} + +/// A key that opens one probe while another encrypted unit opens under none is +/// demonstrably unkeyable: refused, not trusted on the one pass. +#[test] +fn a_single_opened_probe_beside_an_unopenable_unit_is_refused() { + let mut src = img(1200, &[(300, 30, K0, ALL), (600, 30, K1, &[0])]); + let other = img(1200, &[(600, 30, STRANGER, &[5])]); + let (a, b) = (615 * SECTOR, 618 * SECTOR); + src.data[a..b].copy_from_slice(&other.data[a..b]); + let files = vec![vec![(300, 30)], vec![(600, 30)]]; + let r = plan(&mut src, &mut pool(&[K0, K1]), &files, &rule(None)); + assert!( + matches!(r, Err(Error::WholeDiscKeyMissing)), + "{:?}", + r.err() + ); +} + +/// Alternates are the held BASE keys only: a forensic (FMTS-tagged) pool key never +/// keys a whole file, while another base key still does. +#[test] +fn alternates_never_use_forensic_pool_keys() { + // Only the ends encrypted: the resolver's samples miss the file. + let specs = [(300, 30, K0, ALL), (600, 30, K1, &[0, 9][..])]; + let files = one_file_each(&specs); + let mut src = img(1200, &specs); + let tagged = |extra: &[(u32, [u8; 16])]| { + let mut unit_keys = vec![(0u32, K0)]; + unit_keys.extend_from_slice(extra); + DecryptKeys::Aacs { + unit_keys, + format: crate::ContentFormat::BdTs, + } + }; + let mut forensic = tagged(&[(1 << 24, K1)]); + let r = plan(&mut src, &mut forensic, &files, &rule(None)); + assert!( + matches!(r, Err(Error::WholeDiscKeyMissing)), + "{:?}", + r.err() + ); + let mut base = tagged(&[(1 << 24, STRANGER), (1, K1)]); + let planned = plan(&mut src, &mut base, &files, &rule(None)).unwrap(); + assert_eq!(planned.map.entry_for(600).map(|e| e.0), Some(2)); +} + +/// Last resort: no probe is readable, so the file stays unkeyed and the copy stops +/// at its first encrypted unit with E7032, never writing ciphertext. +#[test] +fn an_unreadable_probe_leaves_the_file_unproven_and_the_copy_stops_with_e7032() { + let specs = [(300, 30, K0, ALL), (600, 30, STRANGER, ALL)]; + let files = one_file_each(&specs); + let mut src = img(1200, &specs); + src.probe_fail = Some((600, 630)); + let mut keys = pool(&[K0, K1]); + let planned = plan(&mut src, &mut keys, &files, &rule(None)).unwrap(); + assert_eq!(planned.unproven, vec![(600, 630)]); + let r = write_through(img(1200, &specs), keys, &files, planned); + assert!( + matches!(r, Err(Error::WholeDiscKeyMissing)), + "{:?}", + r.err() + ); +} + +/// A stop request is honoured between probes. +#[test] +fn probing_honours_a_stop() { + let specs = [(300, 30, K0, ALL), (600, 30, K1, ALL)]; + let files = one_file_each(&specs); + let halt = crate::halt::Halt::new(); + halt.cancel(); + let rule = KeyRule { + halt: Some(&halt), + ..rule(None) + }; + let r = plan(&mut img(1200, &specs), &mut pool(&[K0, K1]), &files, &rule); + assert!(matches!(r, Err(Error::Halted)), "{:?}", r.err()); +} + +/// SSIF re-lists an m2ts's extents: that file is probed once, the map stays disjoint. +#[test] +fn a_relisted_file_is_keyed_once_into_a_disjoint_map() { + let files = vec![vec![(300, 30)], vec![(600, 30)], vec![(600, 30)]]; + let mut src = img(1200, &[(300, 30, K0, ALL), (600, 30, K0, ALL)]); + let planned = plan(&mut src, &mut pool(&[K0]), &files, &rule(Some(0))).unwrap(); + let r = planned.map.ranges(); + assert!(r.windows(2).all(|w| w[0].1 <= w[1].0), "disjoint: {r:?}"); + let probes = src + .reads + .iter() + .filter(|&&(l, n)| l >= 600 && n == 3) + .count(); + assert!(probes <= PROBES as usize + 8, "probed twice: {probes}"); +} + +#[test] +fn probes_start_at_the_first_unit_and_spread_to_the_end() { + assert_eq!(probe_units(0), Vec::::new()); + assert_eq!(probe_units(3), vec![0, 1, 2]); + assert_eq!(probe_units(PROBES), (0..PROBES).collect::>()); + let long = probe_units(3200); + assert_eq!(long.len(), PROBES as usize); + assert_eq!((long[0], long[1], long[31]), (0, 100, 3100)); +} + +// A minimal AACS 1.0 `Unit_Key_RO.inf` declaring `units` CPS units. +fn unit_key_ro(units: u16) -> Vec { + let mut v = vec![0u8; 96 + 48 * units as usize]; + v[..4].copy_from_slice(&48u32.to_be_bytes()); + v[48..50].copy_from_slice(&units.to_be_bytes()); + v +} + +fn aacs_disc(declared: u16) -> crate::Disc { + crate::Disc { + volume_id: String::new(), + meta_title: None, + format: crate::DiscFormat::BluRay, + capacity_sectors: 1000, + capacity_bytes: 1000 * 2048, + layers: 1, + titles: Vec::new(), + region: crate::disc::DiscRegion::Free, + aacs: Some(crate::disc::AacsState { + version: 1, + bus_encryption: false, + mkb_version: None, + disc_hash: String::new(), + key_source: crate::disc::KeyOrigin::DeviceKey, + vuk: None, + unit_keys: vec![(1, K0)], + volume_id: [0; 16], + uk_ro: unit_key_ro(declared), + mkb: Vec::new(), + }), + css: None, + encrypted: true, + aacs_error: None, + css_error: None, + content_format: crate::ContentFormat::BdTs, + } +} + +/// Single-CPS is the DECLARED unit count, never the pool size. +#[test] +fn single_cps_follows_the_declared_unit_count_not_the_pool() { + let empty = AacsKeyMap::from_ranges(Vec::new()); + let keys = pool(&[K0]); + assert_eq!(single_cps_key_slot(&aacs_disc(1), &keys, &empty), Some(0)); + assert_eq!(single_cps_key_slot(&aacs_disc(2), &keys, &empty), None); + let mut fmts = aacs_disc(1); + fmts.format = crate::DiscFormat::Fmts; + assert_eq!(single_cps_key_slot(&fmts, &keys, &empty), None); +} + +#[test] +fn subtract_leaves_only_the_unkeyed_pieces() { + let got = subtract_ranges(&[(10, 30), (50, 10)], &[(0, 12), (20, 25), (38, 55)]); + assert_eq!(got, vec![(12, 20), (25, 38), (55, 60)]); + assert_eq!(subtract_ranges(&[(10, 5)], &[(10, 15)]), Vec::new()); + assert_eq!(subtract_ranges(&[(10, 5)], &[]), vec![(10, 15)]); +} + +#[test] +fn merge_sorts_coalesces_overlap_and_adjacency_and_drops_empties() { + let got = merge_ranges(vec![(50, 10), (10, 5), (12, 10), (22, 3), (40, 0), (30, 1)]); + assert_eq!(got, vec![(10, 15), (30, 1), (50, 10)]); + assert_eq!(merge_ranges(vec![(0, 100), (10, 5)]), vec![(0, 100)]); + let got = merge_ranges(vec![(0, u32::MAX), (u32::MAX - 1, u32::MAX)]); + assert_eq!(got, vec![(0, u32::MAX)]); +} + +/// Each file anchors its own grid at its first sector; a later extent keeps the grid +/// by FILE OFFSET; title extents outside every file get their own. +#[test] +fn unit_spans_follow_each_file_by_offset_across_extents() { + let files = vec![vec![(100, 4), (300, 5)], vec![(104, 7)]]; + let spans = unit_spans(&files, &[]); + assert_eq!(spans, vec![(100, 4, 100), (104, 7, 104), (300, 5, 299)]); + assert_eq!(unit_spans(&[], &[(7, 9)]), vec![(7, 9, 7)]); + let spans = unit_spans(&[vec![(10, 7)], vec![(10, 7), (40, 3)]], &[]); + assert_eq!(spans, vec![(10, 7, 10), (40, 3, 39)]); + let spans = unit_spans(&[vec![(100, 6)]], &[(100, 6), (200, 4)]); + assert_eq!(spans, vec![(100, 6, 100), (200, 4, 200)]); +} + +/// Synthetic drive: each sector filled with its LBA's low byte; records reads, +/// unit bases and FUA flags. +#[derive(Default)] +struct Mem { + reads: Vec<(u32, u16)>, + bases: Vec, + fua: Vec, + short: Option, + fail: Option Error>, +} +impl SectorSource for Mem { + fn capacity_sectors(&self) -> u32 { + u32::MAX + } + fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8], r: bool) -> Result { + self.read_sectors_fua(lba, count, buf, r, false) + } + fn read_sectors_fua( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + _: bool, + fua: bool, + ) -> Result { + self.reads.push((lba, count)); + self.fua.push(fua); + if let Some(make) = self.fail { + return Err(make()); + } + let n = self.short.unwrap_or(usize::MAX).min(count as usize * 2048); + for (i, b) in buf[..n].iter_mut().enumerate() { + *b = (lba as usize + i / 2048) as u8; + } + Ok(n) + } + fn set_unit_base(&mut self, lba: u32) { + self.bases.push(lba); + } +} + +fn reader(spans: Vec) -> UnitAligned { + UnitAligned::new(Mem::default(), spans) +} + +#[test] +fn unit_aligned_splits_a_max_length_read_on_the_grid() { + let mut r = reader(vec![(0, 70_000, 0)]); + let mut buf = vec![0u8; u16::MAX as usize * 2048]; + assert_eq!( + r.read_sectors(1, u16::MAX, &mut buf, false).unwrap(), + buf.len() + ); + assert_eq!(r.inner.reads, vec![(0, 65_532), (65_532, 6)]); + assert!( + buf.chunks(2048) + .enumerate() + .all(|(i, c)| c[0] == (1 + i) as u8) + ); +} + +#[test] +fn unit_aligned_reports_a_short_inner_transfer() { + let mut r = reader(vec![(100, 30, 100)]); + r.inner.short = Some(2048); + let mut buf = vec![0u8; 3 * 2048]; + assert_eq!(r.read_sectors(100, 3, &mut buf, false).unwrap(), 2048); + r.inner.short = Some(0); + assert_eq!(r.read_sectors(98, 2, &mut buf, false).unwrap(), 0); +} + +#[test] +fn unit_aligned_passes_fua_and_bases_plain_reads_at_their_start() { + let mut r = reader(vec![(100, 30, 100)]); + let mut buf = vec![0u8; 5 * 2048]; + r.read_sectors_fua(98, 5, &mut buf, true, true).unwrap(); + assert_eq!(r.inner.fua, vec![true, true]); + assert_eq!(r.inner.bases, vec![98, 100]); +} + +#[test] +fn unit_aligned_widens_reads_onto_each_files_grid() { + let mut r = reader(vec![(100, 30, 100), (300, 9, 299)]); + let mut buf = vec![0u8; 5 * 2048]; + assert_eq!(r.read_sectors(98, 5, &mut buf, false).unwrap(), 5 * 2048); + let got: Vec = buf.chunks(2048).map(|c| c[0]).collect(); + assert_eq!(got, vec![98, 99, 100, 101, 102]); + assert_eq!(r.inner.reads, vec![(98, 2), (100, 3)]); + r.inner.reads.clear(); + let mut one = vec![0u8; 2048]; + r.read_sectors(305, 1, &mut one, false).unwrap(); + assert_eq!(one[0], (305u32 % 256) as u8); + assert_eq!(r.inner.reads, vec![(305, 3)]); +} + +#[test] +fn unit_aligned_refuses_a_unit_split_across_extents() { + let mut r = reader(vec![(100, 4, 100), (300, 5, 299)]); + let mut buf = vec![0u8; 2048]; + assert!(matches!( + r.read_sectors(300, 1, &mut buf, false), + Err(Error::DecryptFailed) + )); +} + +/// A decrypt refusal is E7032 only inside an unproven piece. +#[test] +fn a_refusal_in_an_unproven_piece_is_the_mkv_or_raw_error() { + let mut r = reader(vec![(100, 30, 100)]); + r.unproven = vec![(110, 130)]; + r.inner.fail = Some(|| Error::DecryptFailed); + let mut buf = vec![0u8; 2048]; + let got = r.read_sectors(111, 1, &mut buf, false); + assert!(matches!(got, Err(Error::WholeDiscKeyMissing)), "{got:?}"); + let got = r.read_sectors(101, 1, &mut buf, false); + assert!(matches!(got, Err(Error::DecryptFailed)), "{got:?}"); + r.inner.fail = Some(|| Error::Halted); + let got = r.read_sectors(111, 1, &mut buf, false); + assert!(matches!(got, Err(Error::Halted)), "{got:?}"); +} + +#[test] +fn unit_block_end_pulls_back_to_the_straddling_units_head() { + let r = reader(vec![(100, 30, 100)]); + assert_eq!(r.unit_block_end(90, 104), 103); + assert_eq!(r.unit_block_end(90, 103), 103); + assert_eq!(r.unit_block_end(103, 104), 104); + assert_eq!(r.unit_block_end(0, 50), 50); +} + +/// An AACS disc with titles but no stream folder names the missing folder (E6003), +/// not a key failure: no key refresh can fix a tree with no content files. +#[test] +fn titles_without_a_stream_folder_name_the_missing_folder() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("BDMV")).unwrap(); + std::fs::write(dir.path().join("BDMV/index.bdmv"), b"INDX0200").unwrap(); + let img = crate::DirImage::open(dir.path()).unwrap(); + let mut d = aacs_disc(1); + let mut title = crate::DiscTitle::empty(); + title.extents = vec![crate::Extent { + start_lba: 300, + sector_count: 30, + }]; + d.titles = vec![title]; + match whole_disc_reader(&d, img, true, None, None) { + Err(Error::UdfNotFound { path }) => assert_eq!(path, "/BDMV/STREAM"), + other => panic!("expected UdfNotFound, got {:?}", other.err()), + } +} diff --git a/tests/scsi_recovery.rs b/tests/scsi_recovery.rs index 15e08c28..3da608f8 100644 --- a/tests/scsi_recovery.rs +++ b/tests/scsi_recovery.rs @@ -1,36 +1,30 @@ //! Drive-level timeout bound on a real device. The SgIoTransport fd-recovery //! test lives beside the transport (its fields are private) in //! `src/scsi/linux.rs`. Requires a real /dev/sg* device, hence #[ignore]. +//! Linux-only at the crate level: elsewhere it would report a pass having run nothing. +#![cfg(target_os = "linux")] use std::path::Path; +use std::time::Duration; #[test] #[ignore] fn test_drive_read_per_cdb_timeout_bounds_call() { let device = "/dev/sg2"; let device = std::env::var("FREEMKV_TEST_SG_DEVICE").unwrap_or(device.to_string()); - let _path = Path::new(&device); + let path = Path::new(&device); - #[cfg(target_os = "linux")] - { - use std::time::Duration; + let mut drive = libfreemkv::Drive::open(path).expect("open drive"); + let timeout_ms: u32 = 5_000; - let mut drive = libfreemkv::Drive::open(_path).expect("open drive"); - let timeout_ms: u32 = 5_000; + let start = std::time::Instant::now(); + let _ = drive.read(0, 1, &mut [0u8; 2048], false); + let elapsed = start.elapsed(); - let start = std::time::Instant::now(); - let _ = drive.read(0, 1, &mut [0u8; 2048], false); - let elapsed = start.elapsed(); - - let overhead = Duration::from_millis(500); - assert!( - elapsed < Duration::from_millis(timeout_ms as u64) + overhead, - "Drive::read should return within timeout_ms + overhead, took {:?}", - elapsed - ); - } - #[cfg(not(target_os = "linux"))] - { - eprintln!("SKIP: test requires Linux / SgIoTransport"); - } + let overhead = Duration::from_millis(500); + assert!( + elapsed < Duration::from_millis(timeout_ms as u64) + overhead, + "Drive::read should return within timeout_ms + overhead, took {:?}", + elapsed + ); }