diff --git a/src/drive/mod.rs b/src/drive/mod.rs index 7226adb..0dfebaf 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -324,14 +324,61 @@ impl Drive { 0x00, ]; let mut buf = [0u8; 8]; - match self.scsi.as_mut().execute( + let reply = self.scsi.as_mut().execute( &cdb, crate::scsi::DataDirection::FromDevice, &mut buf, 5_000, - ) { + ); + + // MMC-6 §6.7: byte 5 is a Media Status only when the Event Header + // actually announces a Media Event Descriptor behind it. The header is + // Event Descriptor Length (big-endian, bytes 0-1), then byte 2 = NEA + // (bit 7) + Notification Class (bits 2-0), then byte 3 = Supported + // Event Classes. With NEA set the drive is telling us there is NO event + // to report and returns the header alone; with a different Notification + // Class the descriptor that follows is not a media one and its second + // byte means something else entirely. + // + // Decoding byte 5 unconditionally turns both of those into Media Status + // 0 == "tray closed, no disc" — a drive with a disc loaded reported as + // empty, from a reply that said nothing about the media at all. The + // drive is untrusted input here, so an event-less or foreign-class reply + // yields no media state and the TEST UNIT READY fallback answers + // instead. + const NEA: u8 = 0x80; + const NOTIFICATION_CLASS_MASK: u8 = 0x07; + const NOTIFICATION_CLASS_MEDIA: u8 = 0x04; + // Bytes 2..7: the 2 remaining header bytes plus the 4-byte Media Event + // Descriptor — the shortest reply in which byte 5 exists and is a Media + // Status. + const MIN_DESCRIPTOR_LENGTH: u16 = 6; + + let media_status = match reply { Ok(r) if r.bytes_transferred >= 6 => { - let media_status = buf[5]; + let descriptor_len = u16::from_be_bytes([buf[0], buf[1]]); + let class = buf[2] & NOTIFICATION_CLASS_MASK; + if buf[2] & NEA == 0 + && class == NOTIFICATION_CLASS_MEDIA + && descriptor_len >= MIN_DESCRIPTOR_LENGTH + { + Some(buf[5]) + } else { + tracing::debug!( + target: "freemkv::drive", + nea = buf[2] & NEA != 0, + class, + descriptor_len, + "get event status carried no media event descriptor" + ); + None + } + } + _ => None, + }; + + match media_status { + Some(media_status) => { // Bits 1-0: door/tray state // Bit 1: media present, Bit 0: tray open match media_status & 0x03 { @@ -346,7 +393,7 @@ impl Drive { _ => DriveStatus::Unknown, } } - _ => { + None => { // Fallback: try TUR let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00]; let mut empty = [0u8; 0]; @@ -1569,25 +1616,83 @@ mod command_tests { ); } + /// A conformant GET EVENT STATUS NOTIFICATION reply carrying one Media + /// Event Descriptor (MMC-6 §6.7): Event Header — Event Descriptor Length + /// (big-endian, bytes 0-1), then NEA (bit 7) + Notification Class (bits + /// 2-0) in byte 2 and the Supported Event Class bitmap in byte 3 — followed + /// by the 4-byte Media Event Descriptor whose byte 1 (reply byte 5) is the + /// Media Status. + fn media_event_reply(media_status: u8) -> Vec { + let mut buf = vec![0u8; 8]; + buf[0..2].copy_from_slice(&6u16.to_be_bytes()); // 6 bytes follow + buf[2] = 0x04; // NEA = 0, Notification Class 4 = Media + buf[3] = 0x10; // Supported Event Classes: media + buf[4] = 0x00; // Event Code: NoChg + buf[5] = media_status; + buf + } + #[test] fn drive_status_tray_open_and_media_present_is_not_ready_to_rip() { - // GET EVENT STATUS reply: byte 5 (media_status) low bits = 0b11 - // (tray-open AND media-present, contradictory). Must NOT report - // DiscPresent. Buffer is 8 bytes; bytes_transferred >= 6. - let mut buf = vec![0u8; 8]; - buf[5] = 0x03; - let mut d = drive_with(buf); + // Media Status low bits = 0b11 (tray-open AND media-present, + // contradictory). Must NOT report DiscPresent. + let mut d = drive_with(media_event_reply(0x03)); assert_eq!(d.drive_status(), DriveStatus::TrayOpen); } #[test] fn drive_status_disc_present_maps_correctly() { - let mut buf = vec![0u8; 8]; - buf[5] = 0x02; // media present, tray closed - let mut d = drive_with(buf); + // Media Status 0x02 = media present, tray closed. + let mut d = drive_with(media_event_reply(0x02)); assert_eq!(d.drive_status(), DriveStatus::DiscPresent); } + /// MMC-6 §6.7: byte 5 of the reply is a Media Status ONLY when the Event + /// Header says a media event descriptor follows — NEA (byte 2 bit 7) clear + /// AND Notification Class (byte 2 bits 2-0) == 4 (Media). A drive that + /// answers with NEA set, or with a different class it chose to report, still + /// returns 8 bytes; decoding byte 5 regardless reads a reserved/zero byte as + /// Media Status 0 and reports NoDisc on a drive that has a disc loaded — + /// the classic "works on my drive, not theirs" firmware split. The drive is + /// untrusted input: an event-less reply carries no media state at all, so + /// the status must come from the TEST UNIT READY fallback instead. + /// + /// This mock answers every command (including the fallback TUR) with + /// success, so the fallback's verdict is `DiscPresent` — the point is that + /// it is NOT the fabricated `NoDisc`. + #[test] + fn drive_status_rejects_a_reply_carrying_no_media_event_descriptor() { + // NEA = 1: "No Event Available" — no descriptor was returned, so the + // bytes after the header are not a Media Event Descriptor. + let mut nea = media_event_reply(0x00); + nea[2] = 0x80 | 0x04; + let mut d = drive_with(nea); + assert_ne!( + d.drive_status(), + DriveStatus::NoDisc, + "NEA=1 means no event descriptor — byte 5 is not a Media Status" + ); + assert_eq!(d.drive_status(), DriveStatus::DiscPresent); + + // Notification Class 1 (Operational Change), not 4 (Media): a real + // descriptor, but of a class whose byte 5 means something else. + let mut other_class = media_event_reply(0x00); + other_class[2] = 0x01; + let mut d = drive_with(other_class); + assert_ne!( + d.drive_status(), + DriveStatus::NoDisc, + "a non-Media notification class carries no media status" + ); + assert_eq!(d.drive_status(), DriveStatus::DiscPresent); + + // Control: the same 8 bytes WITH a valid media event header really do + // decode Media Status 0 as NoDisc, so the two asserts above are about + // the header and not about byte 5. + let mut d = drive_with(media_event_reply(0x00)); + assert_eq!(d.drive_status(), DriveStatus::NoDisc); + } + // ── Mocks for Drive::read single-shot semantics + CDB encoding ── use std::sync::{Arc, Mutex}; @@ -2009,9 +2114,7 @@ mod command_tests { /// 0x00 = NoDisc, etc. Stands in for a real opened drive so the /// selection policy is testable without hardware. fn drive_with_media_byte(media_status: u8) -> Drive { - let mut buf = vec![0u8; 8]; - buf[5] = media_status; - drive_with(buf) + drive_with(media_event_reply(media_status)) } #[test] @@ -2056,29 +2159,23 @@ mod command_tests { #[test] fn drive_status_no_disc_maps_correctly() { // media_status low bits 0b00 = tray closed, no disc. - let mut buf = vec![0u8; 8]; - buf[5] = 0x00; - let mut d = drive_with(buf); + let mut d = drive_with(media_event_reply(0x00)); assert_eq!(d.drive_status(), DriveStatus::NoDisc); } #[test] fn drive_status_tray_open_maps_correctly() { // media_status low bits 0b01 = tray open, no media. - let mut buf = vec![0u8; 8]; - buf[5] = 0x01; - let mut d = drive_with(buf); + let mut d = drive_with(media_event_reply(0x01)); assert_eq!(d.drive_status(), DriveStatus::TrayOpen); } #[test] fn drive_status_high_bits_in_media_status_ignored() { - // Only the low 2 bits of byte 5 are the door/media state; upper - // bits (NEA, etc.) must be masked. 0xFE has low bits 0b10 = - // DiscPresent. - let mut buf = vec![0u8; 8]; - buf[5] = 0xFE; - let mut d = drive_with(buf); + // MMC-6 §6.7: only the low 2 bits of the Media Event Descriptor's + // Media Status are the door/media state; the reserved upper bits must + // be masked. 0xFE has low bits 0b10 = DiscPresent. + let mut d = drive_with(media_event_reply(0xFE)); assert_eq!(d.drive_status(), DriveStatus::DiscPresent); } diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 27c49fb..f5eb5f2 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -786,13 +786,139 @@ const FMTS_POOL_TAG_BASE: u32 = 1 << 24; /// are excluded, so the answer is a property of the DISC and not of how many titles /// have already resolved through the shared pool. fn single_base_key_slot(unit_keys: &[(u32, [u8; 16])]) -> Option { - let mut base = unit_keys + match base_key_slots(unit_keys)[..] { + [only] => Some(only), + _ => None, + } +} + +/// 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 { + unit_keys .iter() .enumerate() .filter(|(_, (cps_id, _))| *cps_id < FMTS_POOL_TAG_BASE) - .map(|(slot, _)| slot); - let slot = base.next()?; - base.next().is_none().then_some(slot) + .map(|(slot, _)| slot) + .collect() +} + +/// Read a spread of real encrypted aligned units from `[start, start + sectors)`. +/// Only units that are genuinely AACS-encrypted are returned, so a caller can +/// treat a decrypt-to-clean as proof that the key it used is this extent's. +fn sample_encrypted_units( + reader: &mut dyn SectorSource, + start: u32, + sectors: u32, + format: ContentFormat, +) -> Vec> { + use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted}; + let total_units = sectors / ALIGNED_UNIT_SECTORS; + let mut out = Vec::new(); + if total_units == 0 { + return out; + } + const PROBES: u32 = 8; + for p in 1..=PROBES { + let unit = ((total_units as u64 * p as u64) / (PROBES as u64 + 1)) as u32; + if unit >= total_units { + continue; + } + let lba = start.saturating_add(unit.saturating_mul(ALIGNED_UNIT_SECTORS)); + let mut buf = vec![0u8; ALIGNED_UNIT_LEN]; + if reader + .read_sectors(lba, ALIGNED_UNIT_SECTORS as u16, &mut buf, false) + .is_ok() + && aacs_unit_encrypted(&buf, format) + { + out.push(buf); + } + } + out +} + +/// The FIRST pool slot whose key decrypts one of `samples` to clean content. +/// `slots` restricts the search (and its order) to the candidate pool entries — +/// the whole pool for the multi-CPS path, the base CPS unit keys only when the +/// question is "which CPS unit is this extent in". +fn pick_pool_slot( + samples: &[Vec], + pool: &[(u32, [u8; 16])], + slots: &[usize], + format: ContentFormat, +) -> Option { + use crate::aacs::content::{decrypt_unit, is_clean}; + slots.iter().copied().find(|&slot| { + let Some((_, k)) = pool.get(slot) else { + return false; + }; + samples.iter().any(|s| { + let mut u = s.clone(); + decrypt_unit(&mut u, k); + is_clean(&u, format) + }) + }) +} + +/// Which CPS unit's BASE Unit Key opens `ext`, as a pool slot — decided by this +/// extent's own ciphertext, exactly like the multi-CPS path in +/// [`resolve_mux_key_map_cached`], and memoised in the same per-disc +/// [`CpsUnitCache`] so a clip several playlists share is sampled once. +/// +/// Only base keys are considered: the forensic index keys share the pool but +/// belong to segment ranges, which are already mapped by tag before this runs. +/// +/// `last_idx` is the slot resolved for the PRECEDING extent of this title, +/// carried into an extent with no sampleable encrypted units (nothing to +/// mis-decrypt). An extent that does have real ciphertext no held or fetched key +/// opens is a fail-loud [`crate::error::Error::DecryptFailed`] rather than a +/// silently wrong key over the whole extent. +fn base_slot_for_extent( + reader: &mut dyn SectorSource, + ext: &crate::disc::Extent, + keys: &mut crate::decrypt::DecryptKeys, + fetch: Option<&crate::sector::KeyFetch>, + format: ContentFormat, + cps: &mut CpsUnitCache, + last_idx: usize, +) -> io::Result { + let ck = (format, ext.start_lba, ext.sector_count); + if let Some(&hit) = cps.get(&ck) { + return Ok(hit); + } + let samples = sample_encrypted_units(reader, ext.start_lba, ext.sector_count, format); + let pool: Vec<(u32, [u8; 16])> = match keys { + crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => unit_keys.clone(), + _ => Vec::new(), + }; + let mut idx = pick_pool_slot(&samples, &pool, &base_key_slots(&pool), format); + if idx.is_none() + && let Some(f) = fetch + && !samples.is_empty() + { + let fresh = f.unit_keys(&samples); + if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys { + for k in fresh { + if !unit_keys.iter().any(|(_, h)| *h == k) { + let i = unit_keys.len() as u32; + unit_keys.push((i, k)); + } + } + idx = pick_pool_slot(&samples, unit_keys, &base_key_slots(unit_keys), format); + } + } + match idx { + // A real decision from this extent's own ciphertext — memoise it. + Some(i) => { + cps.insert(ck, i); + Ok(i) + } + // Inherited from the PRECEDING extent of THIS title, so it is not a + // property of this extent: never cache it. + None if samples.is_empty() => Ok(last_idx), + None => Err(crate::error::Error::DecryptFailed.into()), + } } /// FMTS (AACS 2.1) branch of [`resolve_mux_key_map`]. Returns `Some(map)` when the @@ -821,6 +947,13 @@ fn single_base_key_slot(unit_keys: &[(u32, [u8; 16])]) -> Option { /// index keys + phases. See [`resolve_mux_key_map_cached`] for why a hit is provably /// the same answer. What remains per title is the pool→slot mapping, the LBA range /// arithmetic and the base-key gap fill. +/// +/// The gap fill covers each non-forensic LBA with the base Unit Key of the CPS unit +/// it belongs to. On the single-base-key disc that is every FMTS disc seen so far +/// that costs nothing; a disc with several base CPS Unit Keys resolves each extent +/// from its own ciphertext through `cps` ([`CpsUnitCache`], shared with the +/// multi-CPS path so a clip is sampled once per disc). +#[allow(clippy::too_many_arguments)] fn resolve_fmts_key_map( reader: &mut dyn SectorSource, title: &DiscTitle, @@ -829,6 +962,7 @@ fn resolve_fmts_key_map( format: ContentFormat, halt: Option<&crate::halt::Halt>, cache: &mut FmtsCache, + cps: &mut CpsUnitCache, ) -> io::Result> { let FmtsCache { table, @@ -971,11 +1105,10 @@ fn resolve_fmts_key_map( }; // Map array position → forensic index (element i = index i+1); add each key to - // the pool and remember its slot by tag. `base_idx` is the Unit Key (slot 0). - // Per TITLE, and deliberately so: the pool is the caller's and grows across - // titles, so a title reached with the keys already banked finds them by value at - // the same slots instead of appending duplicates. - let base_idx = 0usize; + // the pool and remember its slot by tag. Per TITLE, and deliberately so: the + // pool is the caller's and grows across titles, so a title reached with the keys + // already banked finds them by value at the same slots instead of appending + // duplicates. let mut tag_slot: std::collections::HashMap = std::collections::HashMap::new(); if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys { for (i, k) in index_keys.iter().enumerate() { @@ -1046,12 +1179,46 @@ fn resolve_fmts_key_map( return Err(crate::error::Error::FmtsKeyMissing.into()); } - // Cover the NON-segment content with the base Unit Key: the forensic segments - // (added above with their index keys) carve holes out of the title's content - // extents; every other content unit uses the base UK. Fill the gaps so the map - // is a complete positive list — an LBA in no range is nav and passes through. - let base_gaps = fill_base_key_gaps(&title.extents, &ranges, base_idx); - ranges.extend(base_gaps); + // Cover the NON-segment content with the base Unit Key OF THE CPS UNIT THAT LBA + // BELONGS TO: the forensic segments (added above with their index keys) carve + // holes out of the title's content extents; every other content unit uses its own + // CPS unit's base UK. Fill the gaps so the map is a complete positive list — an + // LBA in no range is nav and passes through. + // + // "The" base key is only well defined when the disc has ONE base CPS Unit Key. + // Hardcoding pool slot 0 keyed every non-forensic LBA of every OTHER CPS unit + // with the first unit's key — and that does not fail loudly, it decrypts to + // garbage with `lost_bytes == 0`. `resolve_mux_key_map_cached` reaches this + // resolver BEFORE its own `single_base_key_slot` short-circuit, so that guard + // never gets to make slot 0 correct here. Resolve it per extent instead, the same + // way the multi-CPS path does: from the extent's own ciphertext. + let base_slots: Vec = match keys { + crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => base_key_slots(unit_keys), + _ => Vec::new(), + }; + if base_slots.len() <= 1 { + // One base CPS unit (the overwhelming majority, incl. every single-key UHD): + // its key covers every non-forensic LBA and NO extent sampling is needed — + // an FMTS disc stays at the anchor + phase probes it already pays for. An + // empty pool keeps slot 0, which is what an empty map keys nothing with. + let base_idx = base_slots.first().copied().unwrap_or(0); + let base_gaps = fill_base_key_gaps(&title.extents, &ranges, base_idx); + ranges.extend(base_gaps); + } else { + let mut last_idx = base_slots[0]; + let mut gaps = Vec::new(); + for ext in &title.extents { + // Cooperative cancel between extents: this samples real content units + // off the live drive, exactly like the multi-CPS loop. + if halt.is_some_and(|h| h.is_cancelled()) { + return Err(crate::error::Error::Halted.into()); + } + let idx = base_slot_for_extent(reader, ext, keys, Some(fetch), format, cps, last_idx)?; + last_idx = idx; + gaps.extend(fill_base_key_gaps(std::slice::from_ref(ext), &ranges, idx)); + } + ranges.extend(gaps); + } Ok(Some(crate::decrypt::AacsKeyMap::from_ranges_phased(ranges))) } @@ -1530,11 +1697,14 @@ pub fn resolve_mux_key_map( /// the same extents are re-sampled off the drive once per playlist: 8 random /// 6144-byte reads each, ~200 ms of seek apiece on a stock BD drive. /// -/// Scope, stated precisely: this memo covers the MULTI-CPS extent-sampling reads -/// and nothing else. On an FMTS (AACS 2.1) disc it removes NO reads at all, because -/// [`resolve_fmts_key_map`] runs first and returns a finished map before the extent -/// loop is ever reached — the per-title cost on those discs is removed by -/// [`FmtsTableCache`] and [`FmtsKeyCache`] instead. +/// Scope, stated precisely: this memo covers the extent-sampling reads that decide +/// which CPS unit an extent belongs to, and nothing else. On an FMTS (AACS 2.1) +/// disc [`resolve_fmts_key_map`] runs first and returns a finished map before the +/// extent loop below is ever reached, so the loop's reads are removed by +/// [`FmtsTableCache`] and [`FmtsKeyCache`] instead — but a MULTI-CPS FMTS disc +/// samples through this same memo from the gap fill ([`base_slot_for_extent`]), and +/// the cached value means the same thing on both paths: the pool slot whose key +/// opens that extent's own ciphertext. pub(crate) type CpsUnitCache = std::collections::HashMap<(ContentFormat, u32, u32), usize>; /// The disc's forensic segment table (`/AACS/IndividualSegment.tbl`), resolved at @@ -1709,10 +1879,6 @@ pub(crate) fn resolve_mux_key_map_cached( halt: Option<&crate::halt::Halt>, cache: &mut DiscKeyCache, ) -> io::Result { - use crate::aacs::content::{ - ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted, decrypt_unit, is_clean, - }; - // Borrow the memos disjointly: the FMTS branch needs its three mutably while the // multi-CPS loop below needs the CPS one. let DiscKeyCache { cps: cache, fmts } = cache; @@ -1731,7 +1897,8 @@ pub(crate) fn resolve_mux_key_map_cached( // front from the configured source and build a per-segment map. Returns `None` // when the disc is not FMTS, or no key source is configured (then the base UK // path below applies and the forensic units garble → demux drops them). - if let Some(map) = resolve_fmts_key_map(reader, title, keys, fetch, format, halt, fmts)? { + if let Some(map) = resolve_fmts_key_map(reader, title, keys, fetch, format, halt, fmts, cache)? + { return Ok(map); } // The single-CPS short-circuit asks about the BASE CPS unit keys only. It must not @@ -1755,40 +1922,13 @@ pub(crate) fn resolve_mux_key_map_cached( // the held key that opens one (the `is_clean` proof is sound HERE — samples // are guaranteed real content, not the authored-bad units that trip the mux). let sample_units = |reader: &mut dyn SectorSource, start: u32, sectors: u32| -> Vec> { - let total_units = sectors / ALIGNED_UNIT_SECTORS; - let mut out = Vec::new(); - if total_units == 0 { - return out; - } - const PROBES: u32 = 8; - for p in 1..=PROBES { - let unit = ((total_units as u64 * p as u64) / (PROBES as u64 + 1)) as u32; - if unit >= total_units { - continue; - } - let lba = start.saturating_add(unit.saturating_mul(ALIGNED_UNIT_SECTORS)); - let mut buf = vec![0u8; ALIGNED_UNIT_LEN]; - if reader - .read_sectors(lba, ALIGNED_UNIT_SECTORS as u16, &mut buf, false) - .is_ok() - && aacs_unit_encrypted(&buf, format) - { - out.push(buf); - } - } - out + sample_encrypted_units(reader, start, sectors, format) }; + // Here the question is "which HELD key opens this extent", so every pool entry + // is a candidate, in pool order. let pick = |samples: &[Vec], pool: &[(u32, [u8; 16])]| -> Option { - for (i, (_, k)) in pool.iter().enumerate() { - if samples.iter().any(|s| { - let mut u = s.clone(); - decrypt_unit(&mut u, k); - is_clean(&u, format) - }) { - return Some(i); - } - } - None + let all: Vec = (0..pool.len()).collect(); + pick_pool_slot(samples, pool, &all, format) }; let mut ranges: Vec<(u32, u32, usize)> = Vec::with_capacity(title.extents.len()); @@ -3845,6 +3985,7 @@ mod tests { ContentFormat::BdTs, None, &mut super::FmtsCache::default(), + &mut super::CpsUnitCache::default(), ); let err = got.expect_err("a transient DiscRead must fail loud, never Ok(None)"); let expected = std::io::Error::from(crate::error::Error::DiscRead { @@ -3883,6 +4024,7 @@ mod tests { ContentFormat::BdTs, None, &mut super::FmtsCache::default(), + &mut super::CpsUnitCache::default(), ) .expect("a structurally non-UDF disc is a clean not-FMTS negative"); assert!(got.is_none(), "not a UDF/FMTS disc → Ok(None)"); @@ -3904,6 +4046,15 @@ mod tests { const FMTS_CONTENT_SECTORS: u32 = 2_000; /// The base CPS Unit Key (pool slot 0) of the synthetic disc. const FMTS_BASE_KEY: [u8; 16] = [0x01u8; 16]; + /// The base Unit Key of a SECOND CPS unit (pool slot 1) on the multi-CPS + /// variant of the synthetic disc — the key of every content sector in + /// [`FMTS_CPS2_LBA`]'s extent. + const FMTS_CPS2_KEY: [u8; 16] = [0x02u8; 16]; + /// First LBA of the second CPS unit's extent, immediately after the + /// forensic clip. + const FMTS_CPS2_LBA: u32 = FMTS_CONTENT_LBA + FMTS_CONTENT_SECTORS; + /// Sectors in the second CPS unit's extent (200 aligned units). + const FMTS_CPS2_SECTORS: u32 = 600; /// The disc's forensic index keys: element i = forensic index i+1. Two, not 32 — /// the resolver sizes itself to whatever the source returns. const FMTS_INDEX_KEYS: [[u8; 16]; 2] = [[0x21u8; 16], [0x22u8; 16]]; @@ -3958,6 +4109,9 @@ mod tests { probe_reads: u32, /// `[start, end)` LBA span whose every read returns `DiscRead`. fault_span: Option<(u32, u32)>, + /// When set, LBAs at/above [`FMTS_CPS2_LBA`] belong to a SECOND CPS + /// unit and are encrypted under [`FMTS_CPS2_KEY`], not the base key. + second_cps: bool, } impl FmtsDisc { @@ -4030,6 +4184,17 @@ mod tests { meta_reads: 0, probe_reads: 0, fault_span: None, + second_cps: false, + } + } + + /// The same disc, plus a second CPS unit occupying the extent at + /// [`FMTS_CPS2_LBA`] — a disc whose `Unit_Key_RO.inf` carries two base + /// Unit Keys, which is what makes "the base key" ambiguous. + fn with_second_cps_unit() -> Self { + Self { + second_cps: true, + ..Self::new() } } @@ -4078,8 +4243,19 @@ mod tests { self.meta_reads += 1; return self.meta_disc.read_sectors(lba, count, buf, recovery); } - self.probe_reads += 1; let want = count as usize * 2048; + // The SECOND CPS unit's extent: ordinary (non-forensic) content, + // encrypted under that unit's own base Unit Key. + if self.second_cps && lba >= FMTS_CPS2_LBA { + self.probe_reads += 1; + let unit = encrypted_clean_unit(&FMTS_CPS2_KEY); + for s in 0..count as usize { + let within = ((lba as usize + s - FMTS_CPS2_LBA as usize) % 3) * 2048; + buf[s * 2048..(s + 1) * 2048].copy_from_slice(&unit[within..within + 2048]); + } + return Ok(want); + } + self.probe_reads += 1; buf[..want].fill(0); for s in 0..count as u32 { let off = (lba + s - FMTS_CONTENT_LBA) as u64; @@ -4130,6 +4306,106 @@ mod tests { } } + /// A play-all title over BOTH CPS units: the forensic clip (CPS unit 1) + /// then the second unit's extent (CPS unit 2). + fn fmts_two_cps_title() -> DiscTitle { + let mut t = DiscTitle::empty(); + t.extents = vec![ + Extent { + start_lba: FMTS_CONTENT_LBA, + sector_count: FMTS_CONTENT_SECTORS, + }, + Extent { + start_lba: FMTS_CPS2_LBA, + sector_count: FMTS_CPS2_SECTORS, + }, + ]; + t + } + + /// The disc's two BASE CPS Unit Keys, in `Unit_Key_RO.inf` order — the + /// pool an AACS 2.1 disc with two CPS units is resolved with. + fn fmts_two_cps_keys() -> DecryptKeys { + DecryptKeys::Aacs { + unit_keys: vec![(1, FMTS_BASE_KEY), (2, FMTS_CPS2_KEY)], + read_data_key: None, + format: ContentFormat::BdTs, + } + } + + /// On an FMTS (AACS 2.1) disc the non-forensic gap fill used to hardcode + /// pool slot 0 as "the" base Unit Key. On a disc carrying MORE THAN ONE + /// base CPS Unit Key in `Unit_Key_RO.inf` that is simply the first CPS + /// unit's key, so every content LBA outside a forensic segment in any + /// OTHER CPS unit was keyed with the wrong key. + /// + /// It does not fail loudly: the mapped decrypt runs the wrong key over + /// those units and emits garbage plaintext with `lost_bytes == 0`. And + /// `resolve_mux_key_map_cached` calls the FMTS resolver BEFORE the + /// `single_base_key_slot` short-circuit, so the guard that makes slot 0 + /// correct on a single-CPS disc is never consulted on this path. + /// + /// The title spans both CPS units; the second extent's every unit is + /// encrypted under the SECOND base key (pool slot 1). Mutation: pinning + /// the gap fill back to slot 0 fails the second extent's asserts, and + /// pinning it to slot 1 fails the first extent's. + #[test] + fn fmts_gap_fill_uses_each_lbas_own_cps_unit_key_not_pool_slot_zero() { + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let fetch = counting_fmts_fetch(calls.clone()); + let mut reader = FmtsDisc::with_second_cps_unit(); + let mut keys = fmts_two_cps_keys(); + let mut cache = super::DiscKeyCache::new(); + let title = fmts_two_cps_title(); + + let map = super::resolve_mux_key_map_cached( + &mut reader, + &title, + &mut keys, + Some(&fetch), + ContentFormat::BdTs, + None, + &mut cache, + ) + .expect("a two-CPS-unit FMTS disc resolves"); + + // CPS unit 1 (the forensic clip): the forensic segments keep their own + // index-key slots, and the gaps between them take unit 1's key, which + // IS pool slot 0 here. + assert_eq!( + map.key_idx_for(10_300), + Some(2), + "segment index 1 keys to its own pool slot (appended after the 2 base keys)" + ); + assert_eq!( + map.key_idx_for(10_600), + Some(3), + "segment index 2 keys to its own pool slot" + ); + assert_eq!( + map.key_idx_for(10_000), + Some(0), + "a non-segment LBA of CPS unit 1 takes CPS unit 1's key" + ); + + // CPS unit 2: every LBA of this extent must take the SECOND base Unit + // Key (pool slot 1). Slot 0 here is CPS unit 1's key — the defect. + for lba in [FMTS_CPS2_LBA, FMTS_CPS2_LBA + 300, FMTS_CPS2_LBA + 599] { + assert_eq!( + map.key_idx_for(lba), + Some(1), + "LBA {lba} is in CPS unit 2 and must take CPS unit 2's key, not unit 1's" + ); + } + + // Nothing outside the title's extents is keyed at all. + assert_eq!( + map.key_idx_for(FMTS_CPS2_LBA + FMTS_CPS2_SECTORS), + None, + "past the last extent is nav/filesystem and passes through" + ); + } + fn pool_of(keys: &DecryptKeys) -> Vec<(u32, [u8; 16])> { match keys { DecryptKeys::Aacs { unit_keys, .. } => unit_keys.clone(), diff --git a/src/udf.rs b/src/udf.rs index 34d3c0c..9f0ad80 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -43,6 +43,22 @@ const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024; /// length cannot force a ~1 GiB zeroed allocation per recursion level. const MAX_DIR_BYTES: u32 = 1024 * 1024; +/// Smallest Main Volume Descriptor Sequence extent that ECMA-167 3/10.2.1 +/// permits an Anchor Volume Descriptor Pointer to record: 16 logical sectors +/// (32 768 bytes). An anchor declaring less is not describing a usable +/// sequence, so its extent is ignored in favour of [`VDS_FALLBACK_START`]. +const VDS_MIN_SECTORS: u32 = 16; + +/// Sectors of the Volume Descriptor Sequence actually swept. The sequence is +/// a short run of descriptors terminated by a Terminating Descriptor (tag 8), +/// so this only bounds the work an oversized/corrupt ExtentLength can force. +const VDS_MAX_SECTORS: u32 = 32; + +/// Where the Main VDS is swept from when the anchor's own extent is unusable. +/// The customary location on optical media, and what this reader assumed +/// unconditionally before it followed the anchor's pointer. +const VDS_FALLBACK_START: u32 = 32; + /// A UDF filesystem parsed from disc. #[derive(Debug)] pub struct UdfFs { @@ -59,6 +75,31 @@ pub struct UdfFs { metadata_sectors: u32, } +/// One allocation extent of a file, as recorded in its ICB. +/// +/// `recorded` distinguishes the two extent types this reader can encounter in +/// an allocation descriptor (ECMA-167 4/14.14.1.1): +/// +/// * type 0 — recorded and allocated: `len` bytes of real data live at `lba`. +/// * type 1 — allocated but NOT recorded: the space belongs to the file and +/// occupies `len` bytes of its byte space, but nothing was ever written +/// there, so its contents are defined to be zeros. Its `lba` is where the +/// space is allocated, not where readable data lives. +/// +/// The distinction is load-bearing: dropping a type-1 descriptor slides every +/// later extent's data down by that hole's length, corrupting the file silently +/// rather than failing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IcbExtent { + /// Partition-relative LBA of the extent. + pub lba: u32, + /// Declared length of the extent in bytes. + pub len: u32, + /// `false` for an ECMA-167 4/14.14.1.1 type-1 (allocated, not recorded) + /// extent, whose bytes are logically zeros and must not be read off media. + pub recorded: bool, +} + /// A directory or file entry. #[derive(Debug, Clone)] pub struct DirEntry { @@ -131,7 +172,7 @@ impl UdfFs { .ok_or_else(|| Error::UdfNotFound { path: path.to_string(), })?; - let (data_lba, _) = self.read_icb_extent(reader, entry.meta_lba)?; + let data_lba = self.read_icb_extent(reader, entry.meta_lba)?.lba; self.partition_start .checked_add(data_lba) .ok_or(Error::DiscRead { @@ -248,7 +289,8 @@ impl UdfFs { .min(MAX_FILE_BYTES as usize); let mut data = Vec::with_capacity(cap_hint); let mut sector = [0u8; 2048]; - 'extents: for (data_lba, data_len) in extents { + 'extents: for ext in extents { + let (data_lba, data_len) = (ext.lba, ext.len); if max_bytes.is_none() { // Anti-DoS guards for the unbounded path: a crafted ICB can // chain many extents whose running total grows `data` into GiB, @@ -264,6 +306,22 @@ impl UdfFs { }); } } + let sector_count = (data_len as u64).div_ceil(2048) as u32; + // ECMA-167 4/14.14.1.1 type 1: allocated but not recorded. The + // extent occupies the file's byte space and its contents are + // defined to be zeros, so emit the zeros WITHOUT reading the media + // (those sectors hold nothing this file wrote). Skipping the extent + // instead would slide every later extent's bytes down by this + // hole's length — silent corruption, no error. + if !ext.recorded { + for _ in 0..sector_count { + if data.len() >= limit { + break 'extents; + } + data.extend_from_slice(&[0u8; 2048]); + } + continue; + } let abs_start = self .partition_start .checked_add(data_lba) @@ -272,7 +330,6 @@ impl UdfFs { status: None, sense: None, })?; - let sector_count = (data_len as u64).div_ceil(2048) as u32; for i in 0..sector_count { if data.len() >= limit { break 'extents; @@ -351,12 +408,16 @@ impl UdfFs { // span multiple extents, and key readers downstream need all // of them (mirror collect_all_file_ranges). if let Ok(extents) = self.read_icb_extents(reader, child.meta_lba) { - for (data_lba, data_len) in extents { - let abs_start = match self.partition_start.checked_add(data_lba) { + for ext in extents { + // An unrecorded extent holds nothing to cache. + if !ext.recorded { + continue; + } + let abs_start = match self.partition_start.checked_add(ext.lba) { Some(v) => v, None => continue, }; - let sector_count = (data_len as u64).div_ceil(2048) as u32; + let sector_count = (ext.len as u64).div_ceil(2048) as u32; ranges.push((abs_start, sector_count)); } } @@ -381,7 +442,7 @@ impl UdfFs { /// Read an Extended File Entry (tag 266) or File Entry (tag 261) /// and return its first allocation extent: (data_lba, data_length). /// The data_lba is partition-relative. - fn read_icb_extent(&self, reader: &mut dyn SectorSource, meta_lba: u32) -> Result<(u32, u32)> { + fn read_icb_extent(&self, reader: &mut dyn SectorSource, meta_lba: u32) -> Result { let extents = self.read_icb_extents(reader, meta_lba)?; extents.first().copied().ok_or(Error::DiscRead { // Diagnostic sector only; meta_to_abs can overflow on a crafted @@ -446,16 +507,20 @@ impl UdfFs { Ok(Some(icb[ad_offset..ad_offset + l_ad].to_vec())) } - /// Read ALL allocation extents for a file from its ICB. - /// Returns Vec of (partition_relative_lba, byte_length) pairs. + /// Read ALL allocation extents for a file from its ICB, in file order. /// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents) /// including files whose allocation descriptors span multiple blocks via /// continuation (extent_type 3) descriptors. + /// + /// Unrecorded (type-1) extents are returned too, flagged `recorded: false` + /// — see [`IcbExtent`]. They carry no readable data but they DO occupy the + /// file's byte space, so a caller reconstructing file contents must emit + /// `len` zeros for them rather than skip them. fn read_icb_extents( &self, reader: &mut dyn SectorSource, meta_lba: u32, - ) -> Result> { + ) -> Result> { let icb_abs = self.meta_to_abs(meta_lba)?; let mut icb = [0u8; 2048]; read_sector(reader, icb_abs, &mut icb)?; @@ -575,8 +640,23 @@ impl UdfFs { // blocks are scanned to the end of the sector, so the // trailing zero padding must not be read as extents). 0 if data_len == 0 => break, - 0 => extents.push((data_lba, data_len)), - 1 => {} // allocated but not recorded (sparse) + 0 => extents.push(IcbExtent { + lba: data_lba, + len: data_len, + recorded: true, + }), + // ECMA-167 4/14.14.1.1 type 1: allocated but NOT recorded. + // The extent is part of the file and occupies `data_len` + // bytes of its byte space — its contents are defined to be + // zeros. It must be KEPT (with `recorded: false`, so no + // caller reads those sectors): dropping it slid every later + // extent's data down by this hole's length, silently + // corrupting the file with no error anywhere. + 1 => extents.push(IcbExtent { + lba: data_lba, + len: data_len, + recorded: false, + }), 3 => { // Continuation: the rest of the ADs live in the block // at data_lba (metadata-partition-relative). Stop @@ -641,6 +721,11 @@ impl UdfFs { /// without re-navigating a path) and preserving the per-extent byte length /// (so the last sector can be trimmed to the file's real size). Resolves /// multi-extent / Long-AD / continuation ICBs. + /// + /// Unrecorded (ECMA-167 4/14.14.1.1 type-1) extents are included: their + /// space is allocated to the file at that location and occupies its byte + /// space, so dropping them would slide every later extent's bytes down by + /// the hole's length in a sequential extraction. pub fn extents_abs_at( &self, reader: &mut dyn SectorSource, @@ -648,22 +733,25 @@ impl UdfFs { ) -> Result> { let alloc = self.read_icb_extents(reader, meta_lba)?; let mut out = Vec::with_capacity(alloc.len()); - for (lba, byte_len) in alloc { + for ext in alloc { let abs = self .partition_start - .checked_add(lba) + .checked_add(ext.lba) .ok_or(Error::DiscRead { sector: self.partition_start as u64, status: None, sense: None, })?; - out.push((abs, byte_len)); + out.push((abs, ext.len)); } Ok(out) } /// Get all absolute disc sector extents for a file. - /// Returns Vec of (absolute_lba, sector_count) covering the entire file. + /// Returns Vec of (absolute_lba, sector_count) covering the entire file, + /// including any unrecorded (ECMA-167 4/14.14.1.1 type-1) extent — the + /// space is allocated to the file and occupies its byte space, so omitting + /// it would misplace every later extent. pub fn file_extents( &self, reader: &mut dyn SectorSource, @@ -698,16 +786,16 @@ impl UdfFs { let alloc_extents = self.read_icb_extents(reader, entry.meta_lba)?; let mut disc_extents = Vec::with_capacity(alloc_extents.len()); - for (lba, byte_len) in alloc_extents { + for ext in alloc_extents { let abs_lba = self .partition_start - .checked_add(lba) + .checked_add(ext.lba) .ok_or(Error::DiscRead { sector: self.partition_start as u64, status: None, sense: None, })?; - let sectors = (byte_len as u64).div_ceil(2048) as u32; + let sectors = (ext.len as u64).div_ceil(2048) as u32; disc_extents.push((abs_lba, sectors)); } Ok(disc_extents) @@ -736,18 +824,37 @@ pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result { return Err(Error::UdfNotFilesystem); } - // Main VDS extent location: bytes [16:20] = LBA, [20:24] = length - // (We use the VDS at sectors 32+, not the reserve copy at sector 32768+) + // Step 2: Read the Main Volume Descriptor Sequence and find the Partition + // Descriptor (tag 5) and Logical Volume Descriptor (tag 6). + // + // ECMA-167 3/10.2.1 DEFINES the Main VDS by the extent_ad the AVDP carries + // — ExtentLength (bytes) at [16:20], ExtentLocation (LBA) at [20:24] — it + // does NOT fix the sequence at sector 32. Sweeping a hardcoded 32..64 + // window therefore fails to mount a conformant volume that records its VDS + // anywhere else (and reads 32 sectors that belong to something else). + // Follow the pointer; fall back to the customary window only when the + // anchor's extent is unusable (zero length, zero location, or a location + // whose sweep would wrap the address space), which is exactly the + // malformed-anchor case the old constant silently papered over. The + // reserve sequence recorded at [24:32] is not consulted here. + let vds_len_bytes = u32::from_le_bytes([avdp[16], avdp[17], avdp[18], avdp[19]]); + let vds_lba = u32::from_le_bytes([avdp[20], avdp[21], avdp[22], avdp[23]]); + let (vds_start, vds_sectors) = match vds_len_bytes.div_ceil(2048) { + // ECMA-167 3/10.2.1 requires the extent to be at least 16 sectors + // (32 768 bytes); a shorter or absent extent is not a usable VDS. + n if n >= VDS_MIN_SECTORS && vds_lba > 0 && vds_lba.checked_add(n).is_some() => { + (vds_lba, n.min(VDS_MAX_SECTORS)) + } + _ => (VDS_FALLBACK_START, VDS_MAX_SECTORS), + }; - // Step 2: Read Volume Descriptor Sequence (sectors 32-37 typically) - // Find Partition Descriptor (tag 5) and Logical Volume Descriptor (tag 6) let mut partition_start: u32 = 0; let mut num_partition_maps: u32 = 0; let mut lvd_sector: Option = None; let mut volume_id = String::new(); let mut metadata_size_bytes: u32 = 0; - for i in 32..64 { + for i in vds_start..vds_start.saturating_add(vds_sectors) { let mut desc = [0u8; 2048]; read_sector(reader, i, &mut desc)?; @@ -1004,13 +1111,24 @@ fn read_directory( ]); (len, pos) } + // ECMA-167 4/14.9 (File Entry, tag 261) and 4/14.17 (Extended File + // Entry, tag 266) are the only descriptors that can be a directory's + // ICB. Any other tag means the FID's ICB pointer landed on something + // that is not a file entry (a corrupt disc, or a descriptor from + // another structure), so this directory's allocation extent — and + // therefore its contents — cannot be located. + // + // That is a FAILURE, and it must be reported as one. Returning an + // empty DirEntry instead makes a corrupt directory indistinguishable + // from a genuinely empty one: a caller enumerating titles then sees a + // disc whose BDMV/PLAYLIST is simply empty and reports success on a + // rip that produced nothing. The same bad tag on a FILE ICB is already + // a hard error in `read_icb_extents`, so a directory cannot be softer. _ => { - return Ok(DirEntry { - name: name.to_string(), - is_dir: true, - meta_lba, - size: 0, - entries: Vec::new(), + return Err(Error::DiscRead { + sector: icb_abs as u64, + status: None, + sense: None, }); } }; @@ -1612,6 +1730,12 @@ mod tests { } } + /// `(lba, len)` of each extent, in file order, for the tests that care + /// about placement rather than about the recorded/unrecorded distinction. + fn tuples(extents: &[IcbExtent]) -> Vec<(u32, u32)> { + extents.iter().map(|e| (e.lba, e.len)).collect() + } + fn file_entry(name: &str, meta_lba: u32, size: u64) -> DirEntry { DirEntry { name: name.to_string(), @@ -1642,7 +1766,7 @@ mod tests { reader.put(meta_start + 50, cont); let fs = fs_with(part_start, meta_start, file_entry("X", 5, 6144)); - let extents = fs.read_icb_extents(&mut reader, 5).expect("extents"); + let extents = tuples(&fs.read_icb_extents(&mut reader, 5).expect("extents")); assert_eq!(extents, vec![(10, 4096), (20, 2048)]); } @@ -1667,7 +1791,7 @@ mod tests { reader.put(5, icb); reader.put(50, cont); let fs = fs_with(0, 0, file_entry("3D", 5, 6144)); - let extents = fs.read_icb_extents(&mut reader, 5).expect("extents"); + let extents = tuples(&fs.read_icb_extents(&mut reader, 5).expect("extents")); assert_eq!( extents, vec![(10, 4096), (20, 2048)], @@ -1698,7 +1822,7 @@ mod tests { let mut reader = MapReader::new(); reader.put(5, icb); let fs = fs_with(0, 0, file_entry("BIG", 5, 4 * 1_000_000_000)); - let extents = fs.read_icb_extents(&mut reader, 5).expect("extents"); + let extents = tuples(&fs.read_icb_extents(&mut reader, 5).expect("extents")); assert_eq!( extents, vec![ @@ -2135,23 +2259,25 @@ mod tests { let mut reader = MapReader::new(); reader.put(5, icb); let fs = fs_with(0, 0, file_entry("EXT", 5, 3 * 2048)); - let extents = fs.read_icb_extents(&mut reader, 5).expect("extents"); + let extents = tuples(&fs.read_icb_extents(&mut reader, 5).expect("extents")); // If the stride were wrong (8 or 16) or lba_off were off+4, the LBAs // would be the 0xDEADBEEF junk or misaligned garbage, not these. assert_eq!(extents, vec![(700, 2048), (800, 2048), (900, 4096)]); } #[test] - fn icb_extents_short_ad_type1_sparse_extent_is_skipped_not_emitted() { - // ECMA-167 §14.14.1.1: extent type 1 = "allocated but not recorded" - // (a sparse hole). It carries no on-disc data, so it must NOT be - // returned as a readable extent. A type-0 extent after it must still + fn icb_extents_short_ad_type1_sparse_extent_is_kept_and_flagged_unrecorded() { + // ECMA-167 4/14.14.1.1: extent type 1 = "allocated but not recorded". + // It carries no on-disc data — but it IS part of the file and occupies + // its declared length of the file's byte space, so it must be returned + // (flagged unrecorded) rather than dropped: dropping it slides every + // later extent down by 2048 bytes. A type-0 extent after it must still // be reached (the loop must continue past a type-1, not break). let icb = build_efe( 6144, &[ (0, 2048, 10), // recorded - (1, 2048, 20), // sparse — allocated, not recorded + (1, 2048, 20), // allocated, not recorded (0, 2048, 30), // recorded, after the hole ], ); @@ -2159,9 +2285,26 @@ mod tests { reader.put(5, icb); let fs = fs_with(0, 0, file_entry("SP", 5, 6144)); let extents = fs.read_icb_extents(&mut reader, 5).expect("extents"); - // The sparse (type-1) middle descriptor must be absent; the two - // recorded extents must both be present and in order. - assert_eq!(extents, vec![(10, 2048), (30, 2048)]); + assert_eq!( + extents, + vec![ + IcbExtent { + lba: 10, + len: 2048, + recorded: true + }, + IcbExtent { + lba: 20, + len: 2048, + recorded: false + }, + IcbExtent { + lba: 30, + len: 2048, + recorded: true + }, + ] + ); } #[test] @@ -2181,7 +2324,7 @@ mod tests { let mut reader = MapReader::new(); reader.put(5, icb); let fs = fs_with(0, 0, file_entry("T", 5, 2048)); - let extents = fs.read_icb_extents(&mut reader, 5).expect("extents"); + let extents = tuples(&fs.read_icb_extents(&mut reader, 5).expect("extents")); assert_eq!( extents, vec![(10, 2048)], @@ -2203,7 +2346,7 @@ mod tests { reader.put(50, cont); let fs = fs_with(0, 0, file_entry("LOOP", 5, 2048)); // Must return Ok (bounded), not hang or panic. - let extents = fs.read_icb_extents(&mut reader, 5).expect("extents"); + let extents = tuples(&fs.read_icb_extents(&mut reader, 5).expect("extents")); // First block contributes extent (10,2048); each revisit of the // self-referential cont block adds (20,2048). The hop bound caps the // total, so the Vec is finite. (256 blocks max → < 600 extents.) @@ -2613,6 +2756,149 @@ mod tests { "cycle entry must be a leaf, not recursed" ); } + + #[test] + fn read_directory_rejects_an_icb_whose_tag_is_not_a_file_entry() { + // ECMA-167 4/14.9 (File Entry, tag 261) and 4/14.17 (Extended File + // Entry, tag 266) are the only descriptors that can be the ICB of a + // directory. Any other tag is a corrupt/foreign descriptor: the + // directory's allocation extent cannot be located, so its contents are + // UNKNOWN — not known to be empty. Returning an empty DirEntry makes a + // corrupt directory indistinguishable from a genuinely empty one, so a + // caller enumerating titles sees a disc that "has no BDMV/PLAYLIST" + // and exits 0. The same tag on a FILE ICB is already a hard error + // (`read_icb_extents`), so this must be one too. + // + // Tag 258 (Allocation Extent Descriptor) is a real UDF descriptor that + // is simply not a File Entry — the exact "pointer landed on the wrong + // structure" shape. + let mut icb = build_efe_icb(2048, 2048, 60); + icb[0..2].copy_from_slice(&258u16.to_le_bytes()); + let mut reader = MemReader::new(); + reader.put(5, icb); + + let err = read_directory(&mut reader, 0, 0, 5, "BDMV", 0, &mut 0, &mut HashSet::new()) + .expect_err("a non-File-Entry directory ICB must fail, not read as an empty directory"); + assert!( + matches!(err, Error::DiscRead { sector: 5, .. }), + "the error must name the ICB sector that carried the bad tag, got {err:?}" + ); + + // And the genuinely-empty case still succeeds: a real Extended File + // Entry (266) whose directory data holds no FID is Ok and empty, so + // this test is about the TAG and not about emptiness. + let mut ok_reader = MemReader::new(); + ok_reader.put(5, build_efe_icb(2048, 2048, 60)); + let dir = read_directory( + &mut ok_reader, + 0, + 0, + 5, + "BDMV", + 0, + &mut 0, + &mut HashSet::new(), + ) + .expect("a real Extended File Entry with no FIDs is a genuinely empty directory"); + assert!(dir.entries.is_empty()); + } + + #[test] + fn read_file_unrecorded_extent_contributes_zeros_and_does_not_shift_later_extents() { + // ECMA-167 4/14.14.1.1: extent type 1 is "extent allocated but not + // recorded" — its bytes are logically zeros that STILL OCCUPY the + // file's byte space. Dropping the descriptor entirely slides every + // later extent's data down by the hole's length, so the file's bytes + // land at the wrong offsets with no error at all. + // + // Three 2048-byte extents: recorded 0xAA, unrecorded (the hole), + // recorded 0xCC. The 0xCC extent's data belongs at byte 4096. + let icb = build_efe( + 3 * 2048, + &[ + (0, 2048, 10), // recorded + (1, 2048, 20), // allocated but NOT recorded + (0, 2048, 30), // recorded, after the hole + ], + ); + let mut reader = MemReader::new(); + reader.put(5, icb); + reader.put(10, [0xAAu8; 2048]); + // LBA 20 is deliberately given NON-zero bytes: an unrecorded extent's + // sectors must never be read, so 0xBB must not appear in the output. + reader.put(20, [0xBBu8; 2048]); + reader.put(30, [0xCCu8; 2048]); + + let fs = fs_with_file(5, 3 * 2048); + let data = fs.read_file(&mut reader, "/F").expect("file reads"); + + assert_eq!(data.len(), 6144, "the hole occupies file space"); + assert!(data[0..2048].iter().all(|&b| b == 0xAA)); + assert!( + data[2048..4096].iter().all(|&b| b == 0x00), + "an unrecorded extent reads as zeros, not as whatever is on the media" + ); + assert!( + data[4096..6144].iter().all(|&b| b == 0xCC), + "the extent after the hole must land at byte 4096, not 2048" + ); + } + + #[test] + fn read_filesystem_follows_the_avdp_main_vds_extent_pointer() { + // ECMA-167 3/10.2.1: the Anchor Volume Descriptor Pointer DEFINES the + // Main Volume Descriptor Sequence by the extent_ad it carries + // (ExtentLength at [16:20], ExtentLocation at [20:24]) — the sequence + // is not fixed at sector 32. A conformant volume that records its VDS + // elsewhere must still mount. + use fixture::{DirSpec, MemDisc, PART_START, build_udf_skeleton, file, lay_dir}; + + // Where this volume really keeps its Volume Descriptor Sequence. + const VDS_LBA: u32 = 300; + + let mut disc = MemDisc::new(); + build_udf_skeleton(&mut disc, 10); + lay_dir( + &mut disc, + &DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: vec![file("INDEX.BDMV", 12, 13, 2048, false)], + subdirs: Vec::new(), + }, + ); + + // Re-point the anchor at the real sequence and MOVE the descriptors + // there, leaving 32..64 blank (as a conformant volume that never used + // sector 32 would). + let mut avdp = vec![0u8; 2048]; + avdp[0..2].copy_from_slice(&2u16.to_le_bytes()); + // 16 sectors is the ECMA-167 3/10.2.1 minimum VDS extent length. + avdp[16..20].copy_from_slice(&(16u32 * 2048).to_le_bytes()); + avdp[20..24].copy_from_slice(&VDS_LBA.to_le_bytes()); + disc.put_bytes(256, &avdp); + + let mut pd = vec![0u8; 2048]; + pd[0..2].copy_from_slice(&5u16.to_le_bytes()); + pd[188..192].copy_from_slice(&PART_START.to_le_bytes()); + let mut lvd = vec![0u8; 2048]; + lvd[0..2].copy_from_slice(&6u16.to_le_bytes()); + lvd[268..272].copy_from_slice(&1u32.to_le_bytes()); + let mut td = vec![0u8; 2048]; + td[0..2].copy_from_slice(&8u16.to_le_bytes()); + disc.put_bytes(VDS_LBA, &pd); + disc.put_bytes(VDS_LBA + 1, &lvd); + disc.put_bytes(VDS_LBA + 2, &td); + // Blank the hardcoded window. + disc.put_bytes(32, &vec![0u8; 3 * 2048]); + + let fs = super::read_filesystem(&mut disc) + .expect("a volume whose AVDP points its VDS elsewhere must still mount"); + assert_eq!(fs.partition_start(), PART_START); + assert_eq!(fs.root.entries.len(), 1); + assert_eq!(fs.root.entries[0].name, "INDEX.BDMV"); + } } /// Shared UDF image fixtures for tests across the `disc::*` format scanners.