From c63dafcf1a5468e7c3698763c42e33e315e2f91a Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:15:52 -0700 Subject: [PATCH] Key each FMTS extent from its own CPS unit, and stop a bad ICB tag reading as an empty directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On an AACS 2.1 disc the non-forensic gap fill hardcoded pool slot 0 as "the" base Unit Key, so every content LBA outside a forensic segment was keyed with CPS unit 1's key even on a disc carrying several CPS units. It does not fail loudly — it produces garbage plaintext. The gap fill now resolves each extent's own base key from its ciphertext, sharing the sampling and slot-picking the multi-CPS path already had rather than adding a second copy, and memoised in the existing per-disc cache. A disc with one base key still short-circuits with zero extra reads, which the existing probe-cost test pins. Forcing the slot back to a constant fails four tests, so the choice is load-bearing rather than incidental. A directory ICB whose descriptor tag is neither File Entry nor Extended File Entry (ECMA-167 4/14.9, 4/14.17) was turned into a successfully-read EMPTY directory, indistinguishable from a genuinely empty one, while the same tag on a file ICB was already a hard error. Fifth instance in this audit of a failure converted into a plausible success value, and the second in this very function — round 5 fixed a read error becoming a file size of zero here. An unrecorded extent (ECMA-167 4/14.14.1.1: allocated but not recorded, logically zeros that still occupy file space) was dropped entirely rather than contributing its length, so every later extent landed at the wrong file offset. Silent corruption, not an error. Extents now carry a recorded flag and the hole emits zeros without touching the media. The Volume Descriptor Sequence was swept at hardcoded sectors 32..64 while the anchor's own Main VDS Extent pointer was parsed into a comment and ignored; ECMA-167 3/10.2.1 defines that extent by the field, not by position, so a conformant volume placing it elsewhere failed to mount. drive_status decoded byte 5 as Media Status without checking the event header's NEA bit or notification class (MMC-6 §6.7), so a reply carrying no media event descriptor decoded as "no disc". The drive is untrusted input here, and this is the works-on-my-drive class. Two more tests were found asserting the defects they sit next to — one requiring unrecorded extents to be dropped, one that four drive fixtures built non-conformant replies the corrected decoder rightly rejects. Both rewritten. Combined with the DTS one in the previous commit that makes three tests this round that locked a bug in as intended behaviour, which is a different and worse failure than the tautological tests found so far: a tautology fails to catch a regression, these actively defend the defect. Not fixed, adjacent: extract_one_file streams extents sequentially and will now READ an unrecorded extent's sectors rather than writing guaranteed zeros. Offsets are right, and pressed media reads as zeros there, but it is not zero-guaranteed the way read_file now is; that needs a recorded flag through PlannedFile. --- src/drive/mod.rs | 153 ++++++++++++++---- src/mux/resolve.rs | 392 ++++++++++++++++++++++++++++++++++++++------- src/udf.rs | 374 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 789 insertions(+), 130 deletions(-) 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.