Key each FMTS extent from its own CPS unit, and stop a bad ICB tag reading
as an empty directory 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.
This commit is contained in:
+125
-28
@@ -324,14 +324,61 @@ impl Drive {
|
|||||||
0x00,
|
0x00,
|
||||||
];
|
];
|
||||||
let mut buf = [0u8; 8];
|
let mut buf = [0u8; 8];
|
||||||
match self.scsi.as_mut().execute(
|
let reply = self.scsi.as_mut().execute(
|
||||||
&cdb,
|
&cdb,
|
||||||
crate::scsi::DataDirection::FromDevice,
|
crate::scsi::DataDirection::FromDevice,
|
||||||
&mut buf,
|
&mut buf,
|
||||||
5_000,
|
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 => {
|
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
|
// Bits 1-0: door/tray state
|
||||||
// Bit 1: media present, Bit 0: tray open
|
// Bit 1: media present, Bit 0: tray open
|
||||||
match media_status & 0x03 {
|
match media_status & 0x03 {
|
||||||
@@ -346,7 +393,7 @@ impl Drive {
|
|||||||
_ => DriveStatus::Unknown,
|
_ => DriveStatus::Unknown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
None => {
|
||||||
// Fallback: try TUR
|
// Fallback: try TUR
|
||||||
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
|
let tur = [SCSI_TEST_UNIT_READY, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||||
let mut empty = [0u8; 0];
|
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<u8> {
|
||||||
|
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]
|
#[test]
|
||||||
fn drive_status_tray_open_and_media_present_is_not_ready_to_rip() {
|
fn drive_status_tray_open_and_media_present_is_not_ready_to_rip() {
|
||||||
// GET EVENT STATUS reply: byte 5 (media_status) low bits = 0b11
|
// Media Status low bits = 0b11 (tray-open AND media-present,
|
||||||
// (tray-open AND media-present, contradictory). Must NOT report
|
// contradictory). Must NOT report DiscPresent.
|
||||||
// DiscPresent. Buffer is 8 bytes; bytes_transferred >= 6.
|
let mut d = drive_with(media_event_reply(0x03));
|
||||||
let mut buf = vec![0u8; 8];
|
|
||||||
buf[5] = 0x03;
|
|
||||||
let mut d = drive_with(buf);
|
|
||||||
assert_eq!(d.drive_status(), DriveStatus::TrayOpen);
|
assert_eq!(d.drive_status(), DriveStatus::TrayOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drive_status_disc_present_maps_correctly() {
|
fn drive_status_disc_present_maps_correctly() {
|
||||||
let mut buf = vec![0u8; 8];
|
// Media Status 0x02 = media present, tray closed.
|
||||||
buf[5] = 0x02; // media present, tray closed
|
let mut d = drive_with(media_event_reply(0x02));
|
||||||
let mut d = drive_with(buf);
|
|
||||||
assert_eq!(d.drive_status(), DriveStatus::DiscPresent);
|
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 ──
|
// ── Mocks for Drive::read single-shot semantics + CDB encoding ──
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@@ -2009,9 +2114,7 @@ mod command_tests {
|
|||||||
/// 0x00 = NoDisc, etc. Stands in for a real opened drive so the
|
/// 0x00 = NoDisc, etc. Stands in for a real opened drive so the
|
||||||
/// selection policy is testable without hardware.
|
/// selection policy is testable without hardware.
|
||||||
fn drive_with_media_byte(media_status: u8) -> Drive {
|
fn drive_with_media_byte(media_status: u8) -> Drive {
|
||||||
let mut buf = vec![0u8; 8];
|
drive_with(media_event_reply(media_status))
|
||||||
buf[5] = media_status;
|
|
||||||
drive_with(buf)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2056,29 +2159,23 @@ mod command_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn drive_status_no_disc_maps_correctly() {
|
fn drive_status_no_disc_maps_correctly() {
|
||||||
// media_status low bits 0b00 = tray closed, no disc.
|
// media_status low bits 0b00 = tray closed, no disc.
|
||||||
let mut buf = vec![0u8; 8];
|
let mut d = drive_with(media_event_reply(0x00));
|
||||||
buf[5] = 0x00;
|
|
||||||
let mut d = drive_with(buf);
|
|
||||||
assert_eq!(d.drive_status(), DriveStatus::NoDisc);
|
assert_eq!(d.drive_status(), DriveStatus::NoDisc);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drive_status_tray_open_maps_correctly() {
|
fn drive_status_tray_open_maps_correctly() {
|
||||||
// media_status low bits 0b01 = tray open, no media.
|
// media_status low bits 0b01 = tray open, no media.
|
||||||
let mut buf = vec![0u8; 8];
|
let mut d = drive_with(media_event_reply(0x01));
|
||||||
buf[5] = 0x01;
|
|
||||||
let mut d = drive_with(buf);
|
|
||||||
assert_eq!(d.drive_status(), DriveStatus::TrayOpen);
|
assert_eq!(d.drive_status(), DriveStatus::TrayOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drive_status_high_bits_in_media_status_ignored() {
|
fn drive_status_high_bits_in_media_status_ignored() {
|
||||||
// Only the low 2 bits of byte 5 are the door/media state; upper
|
// MMC-6 §6.7: only the low 2 bits of the Media Event Descriptor's
|
||||||
// bits (NEA, etc.) must be masked. 0xFE has low bits 0b10 =
|
// Media Status are the door/media state; the reserved upper bits must
|
||||||
// DiscPresent.
|
// be masked. 0xFE has low bits 0b10 = DiscPresent.
|
||||||
let mut buf = vec![0u8; 8];
|
let mut d = drive_with(media_event_reply(0xFE));
|
||||||
buf[5] = 0xFE;
|
|
||||||
let mut d = drive_with(buf);
|
|
||||||
assert_eq!(d.drive_status(), DriveStatus::DiscPresent);
|
assert_eq!(d.drive_status(), DriveStatus::DiscPresent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+332
-56
@@ -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
|
/// are excluded, so the answer is a property of the DISC and not of how many titles
|
||||||
/// have already resolved through the shared pool.
|
/// have already resolved through the shared pool.
|
||||||
fn single_base_key_slot(unit_keys: &[(u32, [u8; 16])]) -> Option<usize> {
|
fn single_base_key_slot(unit_keys: &[(u32, [u8; 16])]) -> Option<usize> {
|
||||||
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<usize> {
|
||||||
|
unit_keys
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter(|(_, (cps_id, _))| *cps_id < FMTS_POOL_TAG_BASE)
|
.filter(|(_, (cps_id, _))| *cps_id < FMTS_POOL_TAG_BASE)
|
||||||
.map(|(slot, _)| slot);
|
.map(|(slot, _)| slot)
|
||||||
let slot = base.next()?;
|
.collect()
|
||||||
base.next().is_none().then_some(slot)
|
}
|
||||||
|
|
||||||
|
/// 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<Vec<u8>> {
|
||||||
|
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<u8>],
|
||||||
|
pool: &[(u32, [u8; 16])],
|
||||||
|
slots: &[usize],
|
||||||
|
format: ContentFormat,
|
||||||
|
) -> Option<usize> {
|
||||||
|
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<usize> {
|
||||||
|
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
|
/// 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<usize> {
|
|||||||
/// index keys + phases. See [`resolve_mux_key_map_cached`] for why a hit is provably
|
/// 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
|
/// the same answer. What remains per title is the pool→slot mapping, the LBA range
|
||||||
/// arithmetic and the base-key gap fill.
|
/// 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(
|
fn resolve_fmts_key_map(
|
||||||
reader: &mut dyn SectorSource,
|
reader: &mut dyn SectorSource,
|
||||||
title: &DiscTitle,
|
title: &DiscTitle,
|
||||||
@@ -829,6 +962,7 @@ fn resolve_fmts_key_map(
|
|||||||
format: ContentFormat,
|
format: ContentFormat,
|
||||||
halt: Option<&crate::halt::Halt>,
|
halt: Option<&crate::halt::Halt>,
|
||||||
cache: &mut FmtsCache,
|
cache: &mut FmtsCache,
|
||||||
|
cps: &mut CpsUnitCache,
|
||||||
) -> io::Result<Option<crate::decrypt::AacsKeyMap>> {
|
) -> io::Result<Option<crate::decrypt::AacsKeyMap>> {
|
||||||
let FmtsCache {
|
let FmtsCache {
|
||||||
table,
|
table,
|
||||||
@@ -971,11 +1105,10 @@ fn resolve_fmts_key_map(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Map array position → forensic index (element i = index i+1); add each key to
|
// 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).
|
// the pool and remember its slot by tag. Per TITLE, and deliberately so: the
|
||||||
// Per TITLE, and deliberately so: the pool is the caller's and grows across
|
// pool is the caller's and grows across titles, so a title reached with the keys
|
||||||
// titles, so a title reached with the keys already banked finds them by value at
|
// already banked finds them by value at the same slots instead of appending
|
||||||
// the same slots instead of appending duplicates.
|
// duplicates.
|
||||||
let base_idx = 0usize;
|
|
||||||
let mut tag_slot: std::collections::HashMap<u16, usize> = std::collections::HashMap::new();
|
let mut tag_slot: std::collections::HashMap<u16, usize> = std::collections::HashMap::new();
|
||||||
if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys {
|
if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys {
|
||||||
for (i, k) in index_keys.iter().enumerate() {
|
for (i, k) in index_keys.iter().enumerate() {
|
||||||
@@ -1046,12 +1179,46 @@ fn resolve_fmts_key_map(
|
|||||||
return Err(crate::error::Error::FmtsKeyMissing.into());
|
return Err(crate::error::Error::FmtsKeyMissing.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cover the NON-segment content with the base Unit Key: the forensic segments
|
// Cover the NON-segment content with the base Unit Key OF THE CPS UNIT THAT LBA
|
||||||
// (added above with their index keys) carve holes out of the title's content
|
// BELONGS TO: the forensic segments (added above with their index keys) carve
|
||||||
// extents; every other content unit uses the base UK. Fill the gaps so the map
|
// holes out of the title's content extents; every other content unit uses its own
|
||||||
// is a complete positive list — an LBA in no range is nav and passes through.
|
// 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<usize> = 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);
|
let base_gaps = fill_base_key_gaps(&title.extents, &ranges, base_idx);
|
||||||
ranges.extend(base_gaps);
|
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)))
|
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
|
/// 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.
|
/// 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
|
/// Scope, stated precisely: this memo covers the extent-sampling reads that decide
|
||||||
/// and nothing else. On an FMTS (AACS 2.1) disc it removes NO reads at all, because
|
/// which CPS unit an extent belongs to, and nothing else. On an FMTS (AACS 2.1)
|
||||||
/// [`resolve_fmts_key_map`] runs first and returns a finished map before the extent
|
/// disc [`resolve_fmts_key_map`] runs first and returns a finished map before the
|
||||||
/// loop is ever reached — the per-title cost on those discs is removed by
|
/// extent loop below is ever reached, so the loop's reads are removed by
|
||||||
/// [`FmtsTableCache`] and [`FmtsKeyCache`] instead.
|
/// [`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>;
|
pub(crate) type CpsUnitCache = std::collections::HashMap<(ContentFormat, u32, u32), usize>;
|
||||||
|
|
||||||
/// The disc's forensic segment table (`/AACS/IndividualSegment.tbl`), resolved at
|
/// 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>,
|
halt: Option<&crate::halt::Halt>,
|
||||||
cache: &mut DiscKeyCache,
|
cache: &mut DiscKeyCache,
|
||||||
) -> io::Result<crate::decrypt::AacsKeyMap> {
|
) -> io::Result<crate::decrypt::AacsKeyMap> {
|
||||||
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
|
// Borrow the memos disjointly: the FMTS branch needs its three mutably while the
|
||||||
// multi-CPS loop below needs the CPS one.
|
// multi-CPS loop below needs the CPS one.
|
||||||
let DiscKeyCache { cps: cache, fmts } = cache;
|
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`
|
// 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
|
// 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).
|
// 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);
|
return Ok(map);
|
||||||
}
|
}
|
||||||
// The single-CPS short-circuit asks about the BASE CPS unit keys only. It must not
|
// 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
|
// 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).
|
// 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<Vec<u8>> {
|
let sample_units = |reader: &mut dyn SectorSource, start: u32, sectors: u32| -> Vec<Vec<u8>> {
|
||||||
let total_units = sectors / ALIGNED_UNIT_SECTORS;
|
sample_encrypted_units(reader, start, sectors, format)
|
||||||
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
|
|
||||||
};
|
};
|
||||||
|
// Here the question is "which HELD key opens this extent", so every pool entry
|
||||||
|
// is a candidate, in pool order.
|
||||||
let pick = |samples: &[Vec<u8>], pool: &[(u32, [u8; 16])]| -> Option<usize> {
|
let pick = |samples: &[Vec<u8>], pool: &[(u32, [u8; 16])]| -> Option<usize> {
|
||||||
for (i, (_, k)) in pool.iter().enumerate() {
|
let all: Vec<usize> = (0..pool.len()).collect();
|
||||||
if samples.iter().any(|s| {
|
pick_pool_slot(samples, pool, &all, format)
|
||||||
let mut u = s.clone();
|
|
||||||
decrypt_unit(&mut u, k);
|
|
||||||
is_clean(&u, format)
|
|
||||||
}) {
|
|
||||||
return Some(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut ranges: Vec<(u32, u32, usize)> = Vec::with_capacity(title.extents.len());
|
let mut ranges: Vec<(u32, u32, usize)> = Vec::with_capacity(title.extents.len());
|
||||||
@@ -3845,6 +3985,7 @@ mod tests {
|
|||||||
ContentFormat::BdTs,
|
ContentFormat::BdTs,
|
||||||
None,
|
None,
|
||||||
&mut super::FmtsCache::default(),
|
&mut super::FmtsCache::default(),
|
||||||
|
&mut super::CpsUnitCache::default(),
|
||||||
);
|
);
|
||||||
let err = got.expect_err("a transient DiscRead must fail loud, never Ok(None)");
|
let err = got.expect_err("a transient DiscRead must fail loud, never Ok(None)");
|
||||||
let expected = std::io::Error::from(crate::error::Error::DiscRead {
|
let expected = std::io::Error::from(crate::error::Error::DiscRead {
|
||||||
@@ -3883,6 +4024,7 @@ mod tests {
|
|||||||
ContentFormat::BdTs,
|
ContentFormat::BdTs,
|
||||||
None,
|
None,
|
||||||
&mut super::FmtsCache::default(),
|
&mut super::FmtsCache::default(),
|
||||||
|
&mut super::CpsUnitCache::default(),
|
||||||
)
|
)
|
||||||
.expect("a structurally non-UDF disc is a clean not-FMTS negative");
|
.expect("a structurally non-UDF disc is a clean not-FMTS negative");
|
||||||
assert!(got.is_none(), "not a UDF/FMTS disc → Ok(None)");
|
assert!(got.is_none(), "not a UDF/FMTS disc → Ok(None)");
|
||||||
@@ -3904,6 +4046,15 @@ mod tests {
|
|||||||
const FMTS_CONTENT_SECTORS: u32 = 2_000;
|
const FMTS_CONTENT_SECTORS: u32 = 2_000;
|
||||||
/// The base CPS Unit Key (pool slot 0) of the synthetic disc.
|
/// The base CPS Unit Key (pool slot 0) of the synthetic disc.
|
||||||
const FMTS_BASE_KEY: [u8; 16] = [0x01u8; 16];
|
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 disc's forensic index keys: element i = forensic index i+1. Two, not 32 —
|
||||||
/// the resolver sizes itself to whatever the source returns.
|
/// the resolver sizes itself to whatever the source returns.
|
||||||
const FMTS_INDEX_KEYS: [[u8; 16]; 2] = [[0x21u8; 16], [0x22u8; 16]];
|
const FMTS_INDEX_KEYS: [[u8; 16]; 2] = [[0x21u8; 16], [0x22u8; 16]];
|
||||||
@@ -3958,6 +4109,9 @@ mod tests {
|
|||||||
probe_reads: u32,
|
probe_reads: u32,
|
||||||
/// `[start, end)` LBA span whose every read returns `DiscRead`.
|
/// `[start, end)` LBA span whose every read returns `DiscRead`.
|
||||||
fault_span: Option<(u32, u32)>,
|
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 {
|
impl FmtsDisc {
|
||||||
@@ -4030,6 +4184,17 @@ mod tests {
|
|||||||
meta_reads: 0,
|
meta_reads: 0,
|
||||||
probe_reads: 0,
|
probe_reads: 0,
|
||||||
fault_span: None,
|
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;
|
self.meta_reads += 1;
|
||||||
return self.meta_disc.read_sectors(lba, count, buf, recovery);
|
return self.meta_disc.read_sectors(lba, count, buf, recovery);
|
||||||
}
|
}
|
||||||
self.probe_reads += 1;
|
|
||||||
let want = count as usize * 2048;
|
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);
|
buf[..want].fill(0);
|
||||||
for s in 0..count as u32 {
|
for s in 0..count as u32 {
|
||||||
let off = (lba + s - FMTS_CONTENT_LBA) as u64;
|
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])> {
|
fn pool_of(keys: &DecryptKeys) -> Vec<(u32, [u8; 16])> {
|
||||||
match keys {
|
match keys {
|
||||||
DecryptKeys::Aacs { unit_keys, .. } => unit_keys.clone(),
|
DecryptKeys::Aacs { unit_keys, .. } => unit_keys.clone(),
|
||||||
|
|||||||
+330
-44
@@ -43,6 +43,22 @@ const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;
|
|||||||
/// length cannot force a ~1 GiB zeroed allocation per recursion level.
|
/// length cannot force a ~1 GiB zeroed allocation per recursion level.
|
||||||
const MAX_DIR_BYTES: u32 = 1024 * 1024;
|
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.
|
/// A UDF filesystem parsed from disc.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct UdfFs {
|
pub struct UdfFs {
|
||||||
@@ -59,6 +75,31 @@ pub struct UdfFs {
|
|||||||
metadata_sectors: u32,
|
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.
|
/// A directory or file entry.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct DirEntry {
|
pub struct DirEntry {
|
||||||
@@ -131,7 +172,7 @@ impl UdfFs {
|
|||||||
.ok_or_else(|| Error::UdfNotFound {
|
.ok_or_else(|| Error::UdfNotFound {
|
||||||
path: path.to_string(),
|
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
|
self.partition_start
|
||||||
.checked_add(data_lba)
|
.checked_add(data_lba)
|
||||||
.ok_or(Error::DiscRead {
|
.ok_or(Error::DiscRead {
|
||||||
@@ -248,7 +289,8 @@ impl UdfFs {
|
|||||||
.min(MAX_FILE_BYTES as usize);
|
.min(MAX_FILE_BYTES as usize);
|
||||||
let mut data = Vec::with_capacity(cap_hint);
|
let mut data = Vec::with_capacity(cap_hint);
|
||||||
let mut sector = [0u8; 2048];
|
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() {
|
if max_bytes.is_none() {
|
||||||
// Anti-DoS guards for the unbounded path: a crafted ICB can
|
// Anti-DoS guards for the unbounded path: a crafted ICB can
|
||||||
// chain many extents whose running total grows `data` into GiB,
|
// 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
|
let abs_start = self
|
||||||
.partition_start
|
.partition_start
|
||||||
.checked_add(data_lba)
|
.checked_add(data_lba)
|
||||||
@@ -272,7 +330,6 @@ impl UdfFs {
|
|||||||
status: None,
|
status: None,
|
||||||
sense: None,
|
sense: None,
|
||||||
})?;
|
})?;
|
||||||
let sector_count = (data_len as u64).div_ceil(2048) as u32;
|
|
||||||
for i in 0..sector_count {
|
for i in 0..sector_count {
|
||||||
if data.len() >= limit {
|
if data.len() >= limit {
|
||||||
break 'extents;
|
break 'extents;
|
||||||
@@ -351,12 +408,16 @@ impl UdfFs {
|
|||||||
// span multiple extents, and key readers downstream need all
|
// span multiple extents, and key readers downstream need all
|
||||||
// of them (mirror collect_all_file_ranges).
|
// of them (mirror collect_all_file_ranges).
|
||||||
if let Ok(extents) = self.read_icb_extents(reader, child.meta_lba) {
|
if let Ok(extents) = self.read_icb_extents(reader, child.meta_lba) {
|
||||||
for (data_lba, data_len) in extents {
|
for ext in extents {
|
||||||
let abs_start = match self.partition_start.checked_add(data_lba) {
|
// 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,
|
Some(v) => v,
|
||||||
None => continue,
|
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));
|
ranges.push((abs_start, sector_count));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -381,7 +442,7 @@ impl UdfFs {
|
|||||||
/// Read an Extended File Entry (tag 266) or File Entry (tag 261)
|
/// Read an Extended File Entry (tag 266) or File Entry (tag 261)
|
||||||
/// and return its first allocation extent: (data_lba, data_length).
|
/// and return its first allocation extent: (data_lba, data_length).
|
||||||
/// The data_lba is partition-relative.
|
/// 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<IcbExtent> {
|
||||||
let extents = self.read_icb_extents(reader, meta_lba)?;
|
let extents = self.read_icb_extents(reader, meta_lba)?;
|
||||||
extents.first().copied().ok_or(Error::DiscRead {
|
extents.first().copied().ok_or(Error::DiscRead {
|
||||||
// Diagnostic sector only; meta_to_abs can overflow on a crafted
|
// 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()))
|
Ok(Some(icb[ad_offset..ad_offset + l_ad].to_vec()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read ALL allocation extents for a file from its ICB.
|
/// Read ALL allocation extents for a file from its ICB, in file order.
|
||||||
/// Returns Vec of (partition_relative_lba, byte_length) pairs.
|
|
||||||
/// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents)
|
/// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents)
|
||||||
/// including files whose allocation descriptors span multiple blocks via
|
/// including files whose allocation descriptors span multiple blocks via
|
||||||
/// continuation (extent_type 3) descriptors.
|
/// 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(
|
fn read_icb_extents(
|
||||||
&self,
|
&self,
|
||||||
reader: &mut dyn SectorSource,
|
reader: &mut dyn SectorSource,
|
||||||
meta_lba: u32,
|
meta_lba: u32,
|
||||||
) -> Result<Vec<(u32, u32)>> {
|
) -> Result<Vec<IcbExtent>> {
|
||||||
let icb_abs = self.meta_to_abs(meta_lba)?;
|
let icb_abs = self.meta_to_abs(meta_lba)?;
|
||||||
let mut icb = [0u8; 2048];
|
let mut icb = [0u8; 2048];
|
||||||
read_sector(reader, icb_abs, &mut icb)?;
|
read_sector(reader, icb_abs, &mut icb)?;
|
||||||
@@ -575,8 +640,23 @@ impl UdfFs {
|
|||||||
// blocks are scanned to the end of the sector, so the
|
// blocks are scanned to the end of the sector, so the
|
||||||
// trailing zero padding must not be read as extents).
|
// trailing zero padding must not be read as extents).
|
||||||
0 if data_len == 0 => break,
|
0 if data_len == 0 => break,
|
||||||
0 => extents.push((data_lba, data_len)),
|
0 => extents.push(IcbExtent {
|
||||||
1 => {} // allocated but not recorded (sparse)
|
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 => {
|
3 => {
|
||||||
// Continuation: the rest of the ADs live in the block
|
// Continuation: the rest of the ADs live in the block
|
||||||
// at data_lba (metadata-partition-relative). Stop
|
// 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
|
/// 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
|
/// (so the last sector can be trimmed to the file's real size). Resolves
|
||||||
/// multi-extent / Long-AD / continuation ICBs.
|
/// 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(
|
pub fn extents_abs_at(
|
||||||
&self,
|
&self,
|
||||||
reader: &mut dyn SectorSource,
|
reader: &mut dyn SectorSource,
|
||||||
@@ -648,22 +733,25 @@ impl UdfFs {
|
|||||||
) -> Result<Vec<(u32, u32)>> {
|
) -> Result<Vec<(u32, u32)>> {
|
||||||
let alloc = self.read_icb_extents(reader, meta_lba)?;
|
let alloc = self.read_icb_extents(reader, meta_lba)?;
|
||||||
let mut out = Vec::with_capacity(alloc.len());
|
let mut out = Vec::with_capacity(alloc.len());
|
||||||
for (lba, byte_len) in alloc {
|
for ext in alloc {
|
||||||
let abs = self
|
let abs = self
|
||||||
.partition_start
|
.partition_start
|
||||||
.checked_add(lba)
|
.checked_add(ext.lba)
|
||||||
.ok_or(Error::DiscRead {
|
.ok_or(Error::DiscRead {
|
||||||
sector: self.partition_start as u64,
|
sector: self.partition_start as u64,
|
||||||
status: None,
|
status: None,
|
||||||
sense: None,
|
sense: None,
|
||||||
})?;
|
})?;
|
||||||
out.push((abs, byte_len));
|
out.push((abs, ext.len));
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all absolute disc sector extents for a file.
|
/// 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(
|
pub fn file_extents(
|
||||||
&self,
|
&self,
|
||||||
reader: &mut dyn SectorSource,
|
reader: &mut dyn SectorSource,
|
||||||
@@ -698,16 +786,16 @@ impl UdfFs {
|
|||||||
|
|
||||||
let alloc_extents = self.read_icb_extents(reader, entry.meta_lba)?;
|
let alloc_extents = self.read_icb_extents(reader, entry.meta_lba)?;
|
||||||
let mut disc_extents = Vec::with_capacity(alloc_extents.len());
|
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
|
let abs_lba = self
|
||||||
.partition_start
|
.partition_start
|
||||||
.checked_add(lba)
|
.checked_add(ext.lba)
|
||||||
.ok_or(Error::DiscRead {
|
.ok_or(Error::DiscRead {
|
||||||
sector: self.partition_start as u64,
|
sector: self.partition_start as u64,
|
||||||
status: None,
|
status: None,
|
||||||
sense: 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));
|
disc_extents.push((abs_lba, sectors));
|
||||||
}
|
}
|
||||||
Ok(disc_extents)
|
Ok(disc_extents)
|
||||||
@@ -736,18 +824,37 @@ pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result<UdfFs> {
|
|||||||
return Err(Error::UdfNotFilesystem);
|
return Err(Error::UdfNotFilesystem);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main VDS extent location: bytes [16:20] = LBA, [20:24] = length
|
// Step 2: Read the Main Volume Descriptor Sequence and find the Partition
|
||||||
// (We use the VDS at sectors 32+, not the reserve copy at sector 32768+)
|
// 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 partition_start: u32 = 0;
|
||||||
let mut num_partition_maps: u32 = 0;
|
let mut num_partition_maps: u32 = 0;
|
||||||
let mut lvd_sector: Option<u32> = None;
|
let mut lvd_sector: Option<u32> = None;
|
||||||
let mut volume_id = String::new();
|
let mut volume_id = String::new();
|
||||||
let mut metadata_size_bytes: u32 = 0;
|
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];
|
let mut desc = [0u8; 2048];
|
||||||
read_sector(reader, i, &mut desc)?;
|
read_sector(reader, i, &mut desc)?;
|
||||||
|
|
||||||
@@ -1004,13 +1111,24 @@ fn read_directory(
|
|||||||
]);
|
]);
|
||||||
(len, pos)
|
(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 {
|
return Err(Error::DiscRead {
|
||||||
name: name.to_string(),
|
sector: icb_abs as u64,
|
||||||
is_dir: true,
|
status: None,
|
||||||
meta_lba,
|
sense: None,
|
||||||
size: 0,
|
|
||||||
entries: Vec::new(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -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 {
|
fn file_entry(name: &str, meta_lba: u32, size: u64) -> DirEntry {
|
||||||
DirEntry {
|
DirEntry {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
@@ -1642,7 +1766,7 @@ mod tests {
|
|||||||
reader.put(meta_start + 50, cont);
|
reader.put(meta_start + 50, cont);
|
||||||
|
|
||||||
let fs = fs_with(part_start, meta_start, file_entry("X", 5, 6144));
|
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)]);
|
assert_eq!(extents, vec![(10, 4096), (20, 2048)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1667,7 +1791,7 @@ mod tests {
|
|||||||
reader.put(5, icb);
|
reader.put(5, icb);
|
||||||
reader.put(50, cont);
|
reader.put(50, cont);
|
||||||
let fs = fs_with(0, 0, file_entry("3D", 5, 6144));
|
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!(
|
assert_eq!(
|
||||||
extents,
|
extents,
|
||||||
vec![(10, 4096), (20, 2048)],
|
vec![(10, 4096), (20, 2048)],
|
||||||
@@ -1698,7 +1822,7 @@ mod tests {
|
|||||||
let mut reader = MapReader::new();
|
let mut reader = MapReader::new();
|
||||||
reader.put(5, icb);
|
reader.put(5, icb);
|
||||||
let fs = fs_with(0, 0, file_entry("BIG", 5, 4 * 1_000_000_000));
|
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!(
|
assert_eq!(
|
||||||
extents,
|
extents,
|
||||||
vec![
|
vec![
|
||||||
@@ -2135,23 +2259,25 @@ mod tests {
|
|||||||
let mut reader = MapReader::new();
|
let mut reader = MapReader::new();
|
||||||
reader.put(5, icb);
|
reader.put(5, icb);
|
||||||
let fs = fs_with(0, 0, file_entry("EXT", 5, 3 * 2048));
|
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
|
// 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.
|
// would be the 0xDEADBEEF junk or misaligned garbage, not these.
|
||||||
assert_eq!(extents, vec![(700, 2048), (800, 2048), (900, 4096)]);
|
assert_eq!(extents, vec![(700, 2048), (800, 2048), (900, 4096)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn icb_extents_short_ad_type1_sparse_extent_is_skipped_not_emitted() {
|
fn icb_extents_short_ad_type1_sparse_extent_is_kept_and_flagged_unrecorded() {
|
||||||
// ECMA-167 §14.14.1.1: extent type 1 = "allocated but not recorded"
|
// ECMA-167 4/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
|
// It carries no on-disc data — but it IS part of the file and occupies
|
||||||
// returned as a readable extent. A type-0 extent after it must still
|
// 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).
|
// be reached (the loop must continue past a type-1, not break).
|
||||||
let icb = build_efe(
|
let icb = build_efe(
|
||||||
6144,
|
6144,
|
||||||
&[
|
&[
|
||||||
(0, 2048, 10), // recorded
|
(0, 2048, 10), // recorded
|
||||||
(1, 2048, 20), // sparse — allocated, not recorded
|
(1, 2048, 20), // allocated, not recorded
|
||||||
(0, 2048, 30), // recorded, after the hole
|
(0, 2048, 30), // recorded, after the hole
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -2159,9 +2285,26 @@ mod tests {
|
|||||||
reader.put(5, icb);
|
reader.put(5, icb);
|
||||||
let fs = fs_with(0, 0, file_entry("SP", 5, 6144));
|
let fs = fs_with(0, 0, file_entry("SP", 5, 6144));
|
||||||
let extents = fs.read_icb_extents(&mut reader, 5).expect("extents");
|
let extents = fs.read_icb_extents(&mut reader, 5).expect("extents");
|
||||||
// The sparse (type-1) middle descriptor must be absent; the two
|
assert_eq!(
|
||||||
// recorded extents must both be present and in order.
|
extents,
|
||||||
assert_eq!(extents, vec![(10, 2048), (30, 2048)]);
|
vec![
|
||||||
|
IcbExtent {
|
||||||
|
lba: 10,
|
||||||
|
len: 2048,
|
||||||
|
recorded: true
|
||||||
|
},
|
||||||
|
IcbExtent {
|
||||||
|
lba: 20,
|
||||||
|
len: 2048,
|
||||||
|
recorded: false
|
||||||
|
},
|
||||||
|
IcbExtent {
|
||||||
|
lba: 30,
|
||||||
|
len: 2048,
|
||||||
|
recorded: true
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2181,7 +2324,7 @@ mod tests {
|
|||||||
let mut reader = MapReader::new();
|
let mut reader = MapReader::new();
|
||||||
reader.put(5, icb);
|
reader.put(5, icb);
|
||||||
let fs = fs_with(0, 0, file_entry("T", 5, 2048));
|
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!(
|
assert_eq!(
|
||||||
extents,
|
extents,
|
||||||
vec![(10, 2048)],
|
vec![(10, 2048)],
|
||||||
@@ -2203,7 +2346,7 @@ mod tests {
|
|||||||
reader.put(50, cont);
|
reader.put(50, cont);
|
||||||
let fs = fs_with(0, 0, file_entry("LOOP", 5, 2048));
|
let fs = fs_with(0, 0, file_entry("LOOP", 5, 2048));
|
||||||
// Must return Ok (bounded), not hang or panic.
|
// 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
|
// First block contributes extent (10,2048); each revisit of the
|
||||||
// self-referential cont block adds (20,2048). The hop bound caps the
|
// self-referential cont block adds (20,2048). The hop bound caps the
|
||||||
// total, so the Vec is finite. (256 blocks max → < 600 extents.)
|
// 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"
|
"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.
|
/// Shared UDF image fixtures for tests across the `disc::*` format scanners.
|
||||||
|
|||||||
Reference in New Issue
Block a user