diff --git a/src/dirimage/encode.rs b/src/dirimage/encode.rs new file mode 100644 index 0000000..f7f834f --- /dev/null +++ b/src/dirimage/encode.rs @@ -0,0 +1,725 @@ +//! ECMA-167 / UDF 1.02 descriptor encoder. +//! +//! Turns a [`Layout`](super::layout::Layout) — a directory tree with every +//! ICB, directory-data and file-data block already assigned — into the set of +//! metadata sectors a real UDF volume would carry. Nothing here touches the +//! filesystem: it is a pure function from layout to sectors, which is what +//! makes it testable against the production parser in `udf.rs`. +//! +//! What is emitted, in volume order: +//! +//! | sector | descriptor | +//! |---|---| +//! | 16, 17, 18 | Volume Recognition Sequence — `BEA01`, `NSR02`, `TEA01` (ECMA-167 2/9.1) | +//! | 32… | Main Volume Descriptor Sequence — PVD, IUVD, PD, LVD, USD, TD | +//! | 48… | Reserve VDS (byte-identical but for the tag locations) | +//! | 64, 65 | Logical Volume Integrity Sequence — LVID, TD | +//! | 256 | Anchor Volume Descriptor Pointer | +//! | `part_start` + 0, +1 | File Set Descriptor, TD | +//! | `part_start` + … | File Entries (ICBs) and directory data (FIDs) | +//! | last sector | Anchor Volume Descriptor Pointer (copy) | +//! +//! UDF revision 1.02 with a single Type-1 partition map is deliberate: it is +//! the DVD-Video profile, it is the shape `read_filesystem` takes when +//! `num_partition_maps < 2`, and it avoids the UDF 2.50 Metadata Partition +//! entirely. That also means a synthetic image never exercises the Metadata +//! Partition path in `udf.rs` (`:946-991`) — see the module docs on `dirimage`. + +use super::layout::{DirNode, Layout}; +use crate::error::{Error, Result}; +use std::collections::BTreeMap; + +/// Logical block / sector size. Fixed for every optical profile this crate reads. +pub(super) const SECTOR: usize = 2048; + +/// Descriptor version recorded in every tag. 2 = ECMA-167 2nd edition, which +/// is what UDF revisions up to and including 2.00 require. +const DESC_VERSION: u16 = 2; + +/// UDF revision recorded in the domain EntityID suffix (1.02, BCD-ish u16). +const UDF_REVISION: u16 = 0x0102; + +/// A fixed recording timestamp, so an image synthesized from the same folder +/// twice is byte-identical. Real mtimes would make every test golden-file +/// comparison and every `dir:// -> iso://` re-run differ for no benefit. +const FIXED_TIME: Timestamp = Timestamp { + year: 2000, + month: 1, + day: 1, +}; + +struct Timestamp { + year: i16, + month: u8, + day: u8, +} + +/// The synthesized metadata: absolute LBA → sector contents. Data sectors are +/// NOT here; they are served from the backing files. +pub(super) type MetaSectors = BTreeMap>; + +/// The descriptor-tag CRC of ECMA-167 7.2.4: polynomial 0x1021, initial value +/// ZERO, no reflection, no final XOR — the variant catalogued as CRC-16/XMODEM +/// (check value 0x31C3), NOT CCITT-FALSE, which seeds at 0xFFFF and would make +/// every descriptor this crate writes fail a conformant driver's validation. +fn crc16(data: &[u8]) -> u16 { + let mut crc: u16 = 0; + for &b in data { + crc ^= (b as u16) << 8; + for _ in 0..8 { + crc = if crc & 0x8000 != 0 { + (crc << 1) ^ 0x1021 + } else { + crc << 1 + }; + } + } + crc +} + +/// Write an ECMA-167 3/7.2 descriptor tag over `buf[0..16]`. +/// +/// `tag_loc` is the block number of the sector holding the descriptor — +/// ABSOLUTE for the volume-space descriptors (AVDP, VDS, LVID) and +/// PARTITION-RELATIVE for everything inside the partition (FSD, File Entries). +/// Getting that wrong is the classic reason a hand-built volume mounts nowhere: +/// a driver that validates the tag location rejects the descriptor outright. +/// +/// `desc_len` is the descriptor's total length including the tag; the CRC +/// covers `buf[16..desc_len]`. +fn finish_tag(buf: &mut [u8], tag_id: u16, tag_loc: u32, desc_len: usize) { + buf[0..2].copy_from_slice(&tag_id.to_le_bytes()); + buf[2..4].copy_from_slice(&DESC_VERSION.to_le_bytes()); + buf[4] = 0; // checksum, filled below + buf[5] = 0; // reserved + buf[6..8].copy_from_slice(&0u16.to_le_bytes()); // tag serial number + let crc_len = desc_len - 16; + let crc = crc16(&buf[16..desc_len]); + buf[8..10].copy_from_slice(&crc.to_le_bytes()); + buf[10..12].copy_from_slice(&(crc_len as u16).to_le_bytes()); + buf[12..16].copy_from_slice(&tag_loc.to_le_bytes()); + // ECMA-167 3/7.2.3: sum of bytes 0..16 EXCLUDING byte 4, modulo 256. + let sum: u32 = buf[0..16] + .iter() + .enumerate() + .filter(|(i, _)| *i != 4) + .map(|(_, b)| *b as u32) + .sum(); + buf[4] = (sum % 256) as u8; +} + +/// ECMA-167 1/7.2.1 charspec: type 0 (CS0) + "OSTA Compressed Unicode". +fn put_charspec(buf: &mut [u8]) { + buf[0] = 0; + let id = b"OSTA Compressed Unicode"; + buf[1..1 + id.len()].copy_from_slice(id); +} + +/// ECMA-167 1/7.4 EntityID: flags byte, 23 identifier bytes, 8 suffix bytes. +fn put_entity_id(buf: &mut [u8], id: &[u8], suffix: &[u8]) { + buf[0] = 0; + let n = id.len().min(23); + buf[1..1 + n].copy_from_slice(&id[..n]); + let m = suffix.len().min(8); + buf[24..24 + m].copy_from_slice(&suffix[..m]); +} + +/// The `*OSTA UDF Compliant` domain EntityID suffix: UDF revision, domain +/// flags (0 = neither hard nor soft write-protected), reserved. +fn domain_suffix() -> [u8; 8] { + let mut s = [0u8; 8]; + s[0..2].copy_from_slice(&UDF_REVISION.to_le_bytes()); + s +} + +/// This crate's implementation EntityID suffix: OS class / OS identifier +/// (0 = undefined, deliberately — the image is not OS-specific) + 6 free bytes. +fn impl_suffix() -> [u8; 8] { + [0u8; 8] +} + +fn put_impl_id(buf: &mut [u8]) { + put_entity_id(buf, b"*freemkv", &impl_suffix()); +} + +fn put_domain_id(buf: &mut [u8]) { + put_entity_id(buf, b"*OSTA UDF Compliant", &domain_suffix()); +} + +/// OSTA CS0 d-string: a compression-ID byte, the characters, then the used +/// length in the FIELD'S LAST byte (ECMA-167 1/7.2.12 + UDF 2.1.3). An +/// all-zero field is the empty string. +fn put_dstring(buf: &mut [u8], s: &str) { + if s.is_empty() { + return; + } + let encoded = encode_cs0(s); + // Leave room for the trailing length byte. + let room = buf.len() - 1; + let n = encoded.len().min(room); + buf[..n].copy_from_slice(&encoded[..n]); + buf[buf.len() - 1] = n as u8; +} + +/// OSTA CS0: compression ID 8 (one byte per character) when every character +/// is ASCII, otherwise compression ID 16 (UTF-16BE). +/// +/// ASCII rather than Latin-1 for the 8-bit form on purpose: `parse_udf_name` +/// (`udf.rs:1467`) decodes a compression-8 name with `from_utf8_lossy`, so a +/// 0x80-0xFF byte — legal CS0 — would come back as U+FFFD. Every character +/// above 0x7F therefore takes the 16-bit form, which that parser decodes +/// correctly. +pub(super) fn encode_cs0(s: &str) -> Vec { + if s.is_ascii() { + let mut v = Vec::with_capacity(1 + s.len()); + v.push(8u8); + v.extend_from_slice(s.as_bytes()); + v + } else { + let mut v = vec![16u8]; + for u in s.encode_utf16() { + v.extend_from_slice(&u.to_be_bytes()); + } + v + } +} + +/// ECMA-167 1/7.3 timestamp, 12 bytes. Type 1 (local time) with a zero +/// offset, i.e. UTC. +fn put_timestamp(buf: &mut [u8]) { + buf[0..2].copy_from_slice(&0x1000u16.to_le_bytes()); + buf[2..4].copy_from_slice(&FIXED_TIME.year.to_le_bytes()); + buf[4] = FIXED_TIME.month; + buf[5] = FIXED_TIME.day; +} + +/// ECMA-167 3/7.1 extent_ad: length in BYTES, then location. +fn put_extent_ad(buf: &mut [u8], len_bytes: u32, lba: u32) { + buf[0..4].copy_from_slice(&len_bytes.to_le_bytes()); + buf[4..8].copy_from_slice(&lba.to_le_bytes()); +} + +/// ECMA-167 4/14.14.2 long_ad: length+type, then lb_addr (block, partition +/// reference), then 6 implementation-use bytes. +fn put_long_ad(buf: &mut [u8], len_bytes: u32, lba: u32) { + buf[0..4].copy_from_slice(&len_bytes.to_le_bytes()); + buf[4..8].copy_from_slice(&lba.to_le_bytes()); + buf[8..10].copy_from_slice(&0u16.to_le_bytes()); // partition reference 0 +} + +/// ECMA-167 4/14.14.1 short_ad. The top two bits of the length word are the +/// extent TYPE (0 = recorded and allocated), which is exactly why `udf.rs` +/// masks with `0x3FFF_FFFF` when it reads one back — the mask is the field +/// boundary, not a truncation bug. +fn put_short_ad(buf: &mut [u8], len_bytes: u32, lba: u32) { + debug_assert!(len_bytes <= 0x3FFF_FFFF, "AD length must fit 30 bits"); + buf[0..4].copy_from_slice(&len_bytes.to_le_bytes()); + buf[4..8].copy_from_slice(&lba.to_le_bytes()); +} + +fn blank() -> Box<[u8; SECTOR]> { + Box::new([0u8; SECTOR]) +} + +// ── Volume-space descriptors ──────────────────────────────────────────────── + +/// ECMA-167 2/9.1 Volume Structure Descriptor: the three-sector recognition +/// sequence an OS looks for before it will even consider the volume UDF. +fn volume_recognition(id: &[u8; 5]) -> Box<[u8; SECTOR]> { + let mut s = blank(); + s[0] = 0; // structure type + s[1..6].copy_from_slice(id); + s[6] = 1; // structure version + s +} + +/// ECMA-167 3/10.1 Primary Volume Descriptor. +fn primary_volume(volume_id: &str, lba: u32, seq: u32) -> Box<[u8; SECTOR]> { + let mut s = blank(); + s[16..20].copy_from_slice(&seq.to_le_bytes()); + s[20..24].copy_from_slice(&0u32.to_le_bytes()); // PVD number + put_dstring(&mut s[24..56], volume_id); + s[56..58].copy_from_slice(&1u16.to_le_bytes()); // volume sequence number + s[58..60].copy_from_slice(&1u16.to_le_bytes()); // max volume sequence number + s[60..62].copy_from_slice(&2u16.to_le_bytes()); // interchange level + s[62..64].copy_from_slice(&2u16.to_le_bytes()); // max interchange level + s[64..68].copy_from_slice(&1u32.to_le_bytes()); // character set list + s[68..72].copy_from_slice(&1u32.to_le_bytes()); // max character set list + // UDF 2.2.2.5: the first 8 characters of the volume set identifier must be + // unique. A fixed hex prefix plus the volume id is sufficient here — the + // image is single-volume and never joins a real volume set. + put_dstring(&mut s[72..200], &format!("46524D4B{volume_id}")); + put_charspec(&mut s[200..264]); // descriptor character set + put_charspec(&mut s[264..328]); // explanatory character set + put_timestamp(&mut s[376..388]); + put_impl_id(&mut s[388..420]); + finish_tag(&mut s[..], 1, lba, 512); + s +} + +/// ECMA-167 3/10.4 + UDF 2.2.7 Implementation Use Volume Descriptor +/// (`*UDF LV Info`). Not read by `udf.rs`, required by the spec. +fn impl_use_volume(volume_id: &str, lba: u32, seq: u32) -> Box<[u8; SECTOR]> { + let mut s = blank(); + s[16..20].copy_from_slice(&seq.to_le_bytes()); + put_entity_id(&mut s[20..52], b"*UDF LV Info", &domain_suffix()); + put_charspec(&mut s[52..116]); // LVI charset + put_dstring(&mut s[116..244], volume_id); // logical volume identifier + put_impl_id(&mut s[352..384]); + finish_tag(&mut s[..], 4, lba, 512); + s +} + +/// ECMA-167 3/10.5 Partition Descriptor — the descriptor `read_filesystem` +/// takes `partition_start` from (offset 188). +fn partition(part_start: u32, part_sectors: u32, lba: u32, seq: u32) -> Box<[u8; SECTOR]> { + let mut s = blank(); + s[16..20].copy_from_slice(&seq.to_le_bytes()); + s[20..22].copy_from_slice(&1u16.to_le_bytes()); // partition flags: allocated + s[22..24].copy_from_slice(&0u16.to_le_bytes()); // partition number + put_entity_id(&mut s[24..56], b"+NSR02", &[]); + // s[56..184] partition contents use = Partition Header Descriptor. All + // zero: a read-only partition records no unallocated/freed space tables. + s[184..188].copy_from_slice(&1u32.to_le_bytes()); // access type: read only + s[188..192].copy_from_slice(&part_start.to_le_bytes()); + s[192..196].copy_from_slice(&part_sectors.to_le_bytes()); + put_impl_id(&mut s[196..228]); + finish_tag(&mut s[..], 5, lba, 512); + s +} + +/// ECMA-167 3/10.6 Logical Volume Descriptor. Carries the FSD long_ad and the +/// partition map table; `read_filesystem` reads `num_partition_maps` at 268 +/// and takes the single-partition path when it is 1. +fn logical_volume( + volume_id: &str, + fsd_lba: u32, + integrity_lba: u32, + integrity_sectors: u32, + lba: u32, + seq: u32, +) -> Box<[u8; SECTOR]> { + let mut s = blank(); + s[16..20].copy_from_slice(&seq.to_le_bytes()); + put_charspec(&mut s[20..84]); + put_dstring(&mut s[84..212], volume_id); + s[212..216].copy_from_slice(&(SECTOR as u32).to_le_bytes()); // logical block size + put_domain_id(&mut s[216..248]); + // Logical volume contents use = long_ad of the File Set Descriptor, + // partition-relative. One sector. + put_long_ad(&mut s[248..264], SECTOR as u32, fsd_lba); + s[264..268].copy_from_slice(&6u32.to_le_bytes()); // map table length + s[268..272].copy_from_slice(&1u32.to_le_bytes()); // number of partition maps + put_impl_id(&mut s[272..304]); + put_extent_ad( + &mut s[432..440], + integrity_sectors * SECTOR as u32, + integrity_lba, + ); + // ECMA-167 3/10.7.2 Type 1 partition map. + s[440] = 1; // map type + s[441] = 6; // map length + s[442..444].copy_from_slice(&1u16.to_le_bytes()); // volume sequence number + s[444..446].copy_from_slice(&0u16.to_le_bytes()); // partition number + finish_tag(&mut s[..], 6, lba, 446); + s +} + +/// ECMA-167 3/10.8 Unallocated Space Descriptor with zero extents — the whole +/// volume is accounted for by the partition. +fn unallocated_space(lba: u32, seq: u32) -> Box<[u8; SECTOR]> { + let mut s = blank(); + s[16..20].copy_from_slice(&seq.to_le_bytes()); + s[20..24].copy_from_slice(&0u32.to_le_bytes()); + finish_tag(&mut s[..], 7, lba, 24); + s +} + +/// ECMA-167 3/10.9 Terminating Descriptor. +fn terminating(lba: u32) -> Box<[u8; SECTOR]> { + let mut s = blank(); + finish_tag(&mut s[..], 8, lba, 512); + s +} + +/// ECMA-167 3/10.10 + UDF 2.2.6 Logical Volume Integrity Descriptor, closed. +fn integrity( + part_sectors: u32, + files: u32, + dirs: u32, + next_uid: u64, + lba: u32, +) -> Box<[u8; SECTOR]> { + let mut s = blank(); + put_timestamp(&mut s[16..28]); + s[28..32].copy_from_slice(&1u32.to_le_bytes()); // integrity type: close + // s[32..40] next integrity extent: none. + s[40..48].copy_from_slice(&next_uid.to_le_bytes()); // logical volume contents use: next unique id + s[72..76].copy_from_slice(&1u32.to_le_bytes()); // number of partitions + s[76..80].copy_from_slice(&46u32.to_le_bytes()); // length of implementation use + s[80..84].copy_from_slice(&0u32.to_le_bytes()); // free space: none (read-only) + s[84..88].copy_from_slice(&part_sectors.to_le_bytes()); // size table + put_impl_id(&mut s[88..120]); + s[120..124].copy_from_slice(&files.to_le_bytes()); + s[124..128].copy_from_slice(&dirs.to_le_bytes()); + s[128..130].copy_from_slice(&UDF_REVISION.to_le_bytes()); // min read revision + s[130..132].copy_from_slice(&UDF_REVISION.to_le_bytes()); // min write revision + s[132..134].copy_from_slice(&UDF_REVISION.to_le_bytes()); // max write revision + finish_tag(&mut s[..], 9, lba, 134); + s +} + +/// ECMA-167 3/10.2 Anchor Volume Descriptor Pointer. `read_filesystem` reads +/// the main VDS extent from offsets 16..24 and sweeps it. +fn anchor(main_lba: u32, reserve_lba: u32, vds_sectors: u32, lba: u32) -> Box<[u8; SECTOR]> { + let mut s = blank(); + put_extent_ad(&mut s[16..24], vds_sectors * SECTOR as u32, main_lba); + put_extent_ad(&mut s[24..32], vds_sectors * SECTOR as u32, reserve_lba); + finish_tag(&mut s[..], 2, lba, 512); + s +} + +/// ECMA-167 4/14.1 File Set Descriptor. `read_filesystem` requires tag 256 at +/// the first block of the (metadata =) partition and reads the root ICB block +/// from offset 404. +fn file_set(volume_id: &str, root_icb: u32, lba: u32) -> Box<[u8; SECTOR]> { + let mut s = blank(); + put_timestamp(&mut s[16..28]); + s[28..30].copy_from_slice(&3u16.to_le_bytes()); // interchange level + s[30..32].copy_from_slice(&3u16.to_le_bytes()); // max interchange level + s[32..36].copy_from_slice(&1u32.to_le_bytes()); // character set list + s[36..40].copy_from_slice(&1u32.to_le_bytes()); // max character set list + s[40..44].copy_from_slice(&0u32.to_le_bytes()); // file set number + s[44..48].copy_from_slice(&0u32.to_le_bytes()); // file set descriptor number + put_charspec(&mut s[48..112]); + put_dstring(&mut s[112..240], volume_id); + put_charspec(&mut s[240..304]); + put_dstring(&mut s[304..336], volume_id); + put_long_ad(&mut s[400..416], SECTOR as u32, root_icb); + put_domain_id(&mut s[416..448]); + finish_tag(&mut s[..], 256, lba, 512); + s +} + +// ── Partition-space descriptors ───────────────────────────────────────────── + +/// UDF permission word: read + execute for owner, group and other. No write +/// bit anywhere — the volume is read-only. +const PERM_R_X: u32 = 0x0000_1000 | 0x0000_0400 | 0x0000_0080 | 0x0000_0020 | 0x4 | 0x1; + +/// ECMA-167 4/14.9 File Entry (tag 261). +/// +/// Tag 261 rather than the Extended File Entry (266) real BD-ROMs use: an EFE +/// requires UDF 2.00+, and this image declares 1.02. `udf.rs` reads both — the +/// 261 field offsets it uses (l_ea 168, l_ad 172, ADs at 176 + l_ea) are the +/// ones written here. +/// +/// `extents` are partition-relative (block, byte-length) pairs, already split +/// so no single one exceeds the 30-bit AD length field. +fn file_entry( + is_dir: bool, + info_len: u64, + extents: &[(u32, u32)], + link_count: u16, + unique_id: u64, + lba: u32, +) -> Result> { + let mut s = blank(); + // ICB tag (ECMA-167 4/14.6) at offset 16. + s[16..20].copy_from_slice(&0u32.to_le_bytes()); // prior recorded direct entries + s[20..22].copy_from_slice(&4u16.to_le_bytes()); // strategy type 4 + s[24..26].copy_from_slice(&1u16.to_le_bytes()); // max number of entries + s[27] = if is_dir { 4 } else { 5 }; // file type: directory / byte sequence + // s[28..34] parent ICB location: not recorded (permitted). + // s[34..36] ICB flags: 0 => short allocation descriptors. `udf.rs:601` + // reads exactly this word to pick its AD stride. + s[34..36].copy_from_slice(&0u16.to_le_bytes()); + s[36..40].copy_from_slice(&0u32.to_le_bytes()); // uid: invalid/none + s[40..44].copy_from_slice(&0u32.to_le_bytes()); // gid: invalid/none + s[44..48].copy_from_slice(&PERM_R_X.to_le_bytes()); + s[48..50].copy_from_slice(&link_count.to_le_bytes()); + s[56..64].copy_from_slice(&info_len.to_le_bytes()); + let blocks: u64 = extents + .iter() + .map(|(_, len)| (*len as u64).div_ceil(SECTOR as u64)) + .sum(); + s[64..72].copy_from_slice(&blocks.to_le_bytes()); // logical blocks recorded + put_timestamp(&mut s[72..84]); // access + put_timestamp(&mut s[84..96]); // modification + put_timestamp(&mut s[96..108]); // attribute + s[108..112].copy_from_slice(&1u32.to_le_bytes()); // checkpoint + put_impl_id(&mut s[128..160]); + s[160..168].copy_from_slice(&unique_id.to_le_bytes()); + s[168..172].copy_from_slice(&0u32.to_le_bytes()); // length of EAs + let l_ad = extents.len() * 8; + // A short AD is 8 bytes and the entry has 2048 - 176 = 1872 bytes for + // them, i.e. 234 extents — over 200 GiB at the per-AD ceiling. Beyond + // that an Allocation Extent Descriptor chain would be required; refuse + // rather than write a truncated list. + if 176 + l_ad > SECTOR { + return Err(Error::DirImageTooLarge); + } + s[172..176].copy_from_slice(&(l_ad as u32).to_le_bytes()); + for (i, (elba, len)) in extents.iter().enumerate() { + let off = 176 + i * 8; + put_short_ad(&mut s[off..off + 8], *len, *elba); + } + finish_tag(&mut s[..], 261, lba, 176 + l_ad); + Ok(s) +} + +/// ECMA-167 4/14.4 File Identifier Descriptor, appended to `buf`. +/// +/// FIDs are packed with no inter-descriptor padding beyond the 4-byte +/// alignment the spec mandates, and they are allowed to span logical blocks — +/// which is also what `read_directory` (`udf.rs:1312`) assumes: it walks the +/// directory extent as one flat byte run and STOPS at the first non-257 tag, +/// so any block-alignment gap would truncate the directory. +fn push_fid(buf: &mut Vec, name: &str, icb_lba: u32, is_dir: bool, is_parent: bool) { + let start = buf.len(); + let name_field: Vec = if is_parent { + Vec::new() + } else { + encode_cs0(name) + }; + let l_fi = name_field.len(); + let mut fid = vec![0u8; 38]; + fid[16..18].copy_from_slice(&1u16.to_le_bytes()); // file version number + let mut chars = 0u8; + if is_dir { + chars |= 0x02; + } + if is_parent { + chars |= 0x08; + } + fid[18] = chars; + fid[19] = l_fi as u8; + put_long_ad(&mut fid[20..36], SECTOR as u32, icb_lba); + fid[36..38].copy_from_slice(&0u16.to_le_bytes()); // length of implementation use + buf.extend_from_slice(&fid); + buf.extend_from_slice(&name_field); + let unpadded = buf.len() - start; + let padded = unpadded.div_ceil(4) * 4; + buf.resize(start + padded, 0); + // The tag is written last: its CRC covers the descriptor body, which the + // padding is not part of (ECMA-167 4/14.4.9 counts padding outside the + // CRC'd length). + let tag_loc_placeholder = 0; + finish_tag( + &mut buf[start..start + unpadded], + 257, + tag_loc_placeholder, + unpadded, + ); +} + +/// Serialize one directory's FID list (parent entry first, then children). +pub(super) fn dir_fids(dir: &DirNode) -> Vec { + let mut buf = Vec::new(); + push_fid(&mut buf, "", dir.parent_icb_lba, true, true); + for sub in &dir.dirs { + push_fid(&mut buf, &sub.name, sub.icb_lba, true, false); + } + for f in &dir.files { + push_fid(&mut buf, &f.name, f.icb_lba, false, false); + } + buf +} + +/// Patch every FID's tag location to the block it actually lands in. ECMA-167 +/// 3/7.2.2 makes the tag location the block of the descriptor, and a FID that +/// spans two blocks records the block it STARTS in. +fn fix_fid_tag_locations(buf: &mut [u8], first_block: u32) { + let mut pos = 0usize; + while pos + 38 <= buf.len() { + let l_fi = buf[pos + 19] as usize; + let l_iu = u16::from_le_bytes([buf[pos + 36], buf[pos + 37]]) as usize; + let unpadded = 38 + l_iu + l_fi; + if pos + unpadded > buf.len() { + break; + } + let block = first_block + (pos / SECTOR) as u32; + finish_tag(&mut buf[pos..pos + unpadded], 257, block, unpadded); + pos += unpadded.div_ceil(4) * 4; + } +} + +// ── Whole-image assembly ──────────────────────────────────────────────────── + +/// Volume-space block of the Volume Recognition Sequence. +const VRS_START: u32 = 16; +/// Volume-space block of the Main Volume Descriptor Sequence. +pub(super) const MAIN_VDS_START: u32 = 32; +/// Volume-space block of the Reserve Volume Descriptor Sequence. +pub(super) const RESERVE_VDS_START: u32 = 48; +/// Sectors reserved for each VDS. ECMA-167 3/10.2.1 requires an anchor to +/// record at least 16. +pub(super) const VDS_SECTORS: u32 = 16; +/// Volume-space block of the Logical Volume Integrity Sequence. +pub(super) const LVID_START: u32 = 64; +/// Sectors reserved for the integrity sequence (LVID + TD). +pub(super) const LVID_SECTORS: u32 = 2; +/// The mandatory anchor block (ECMA-167 3/10.2). +pub(super) const ANCHOR_LBA: u32 = 256; +/// First block a partition may start at. Everything above is volume space. +pub(super) const MIN_PART_START: u32 = 320; + +/// Emit the six-descriptor Volume Descriptor Sequence at `start`. +fn write_vds(out: &mut MetaSectors, layout: &Layout, start: u32) { + let vid = &layout.volume_id; + out.insert(start, primary_volume(vid, start, 1)); + out.insert(start + 1, impl_use_volume(vid, start + 1, 2)); + out.insert( + start + 2, + partition(layout.part_start, layout.part_sectors, start + 2, 3), + ); + out.insert( + start + 3, + logical_volume(vid, 0, LVID_START, LVID_SECTORS, start + 3, 4), + ); + out.insert(start + 4, unallocated_space(start + 4, 5)); + out.insert(start + 5, terminating(start + 5)); +} + +/// Recursively emit one directory's File Entry and FID list, then its +/// children's. +fn write_dir(out: &mut MetaSectors, layout: &Layout, dir: &DirNode) -> Result<()> { + let mut fids = dir_fids(dir); + fix_fid_tag_locations(&mut fids, dir.data_lba); + debug_assert_eq!(fids.len(), dir.data_bytes as usize); + + // A directory's link count is 1 (its own FID in the parent) plus one for + // each child directory's parent FID pointing back at it. + let link_count = 1 + dir.dirs.len() as u16; + let fe = file_entry( + true, + fids.len() as u64, + &[(dir.data_lba, fids.len() as u32)], + link_count, + dir.unique_id, + dir.icb_lba, + )?; + out.insert(layout.part_start + dir.icb_lba, fe); + + for (i, chunk) in fids.chunks(SECTOR).enumerate() { + let mut s = blank(); + s[..chunk.len()].copy_from_slice(chunk); + out.insert(layout.part_start + dir.data_lba + i as u32, s); + } + + for f in &dir.files { + let extents: Vec<(u32, u32)> = f.extents.iter().map(|e| (e.lba, e.bytes)).collect(); + let fe = file_entry(false, f.size, &extents, 1, f.unique_id, f.icb_lba)?; + out.insert(layout.part_start + f.icb_lba, fe); + } + + for sub in &dir.dirs { + write_dir(out, layout, sub)?; + } + Ok(()) +} + +/// Build every metadata sector of the synthesized volume. +pub(super) fn encode(layout: &Layout) -> Result { + let mut out = MetaSectors::new(); + + out.insert(VRS_START, volume_recognition(b"BEA01")); + out.insert(VRS_START + 1, volume_recognition(b"NSR02")); + out.insert(VRS_START + 2, volume_recognition(b"TEA01")); + + write_vds(&mut out, layout, MAIN_VDS_START); + write_vds(&mut out, layout, RESERVE_VDS_START); + + out.insert( + LVID_START, + integrity( + layout.part_sectors, + layout.file_count, + layout.dir_count, + layout.next_unique_id, + LVID_START, + ), + ); + out.insert(LVID_START + 1, terminating(LVID_START + 1)); + + let avdp = anchor(MAIN_VDS_START, RESERVE_VDS_START, VDS_SECTORS, ANCHOR_LBA); + out.insert(ANCHOR_LBA, avdp); + let last = layout.total_sectors - 1; + out.insert( + last, + anchor(MAIN_VDS_START, RESERVE_VDS_START, VDS_SECTORS, last), + ); + + // Partition block 0 must hold the File Set Descriptor: `read_filesystem` + // reads exactly `metadata_start` (== partition start on a single-partition + // volume) and rejects the volume outright if the tag there is not 256. + out.insert( + layout.part_start, + file_set(&layout.volume_id, layout.root.icb_lba, 0), + ); + out.insert(layout.part_start + 1, terminating(1)); + + write_dir(&mut out, layout, &layout.root)?; + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The reference check value for CRC-16/XMODEM — poly 0x1021 seeded at 0, + /// which is what ECMA-167 7.2.4 specifies: "123456789" → 0x31C3. Seeding + /// at 0xFFFF instead (CCITT-FALSE) yields 0x29B1, and that mutant is + /// invisible to `udf.rs`, which never verifies a tag CRC — it would only + /// show up as a volume no operating system will mount. + #[test] + fn crc16_matches_the_ecma167_check_value() { + assert_eq!(crc16(b"123456789"), 0x31C3); + assert_ne!(crc16(b"123456789"), 0x29B1, "not the 0xFFFF-seeded variant"); + } + + /// ECMA-167 3/7.2.3: the checksum is the sum of the tag's first 16 bytes + /// EXCLUDING the checksum byte itself, modulo 256. + #[test] + fn tag_checksum_excludes_its_own_byte() { + let mut buf = [0u8; 512]; + buf[16..24].copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]); + finish_tag(&mut buf, 261, 0x1234, 512); + let sum: u32 = buf[0..16] + .iter() + .enumerate() + .filter(|(i, _)| *i != 4) + .map(|(_, b)| *b as u32) + .sum(); + assert_eq!(buf[4] as u32, sum % 256); + // And the recorded CRC covers the body, not the tag. + let crc = u16::from_le_bytes([buf[8], buf[9]]); + assert_eq!(crc, crc16(&buf[16..512])); + assert_eq!(u16::from_le_bytes([buf[10], buf[11]]), 496); + assert_eq!( + u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]), + 0x1234 + ); + } + + /// ASCII takes compression ID 8; anything above takes 16 (UTF-16BE), + /// because `parse_udf_name` decodes compression-8 bytes as UTF-8. + #[test] + fn cs0_picks_the_encoding_the_parser_can_decode() { + assert_eq!(encode_cs0("AB"), vec![8, b'A', b'B']); + let e = encode_cs0("Ä"); + assert_eq!(e[0], 16); + assert_eq!(&e[1..], &[0x00, 0xC4]); + assert_eq!(crate::udf::parse_udf_name(&e), "Ä"); + } + + /// A d-string records its used length in the field's LAST byte, and the + /// production parser must read the same string back. + #[test] + fn dstring_round_trips_through_the_production_parser() { + let mut field = [0u8; 32]; + put_dstring(&mut field, "FREEMKV"); + assert_eq!(field[31], 8, "compid byte + 7 characters"); + assert_eq!(crate::udf::parse_dstring_for_test(&field), "FREEMKV"); + } +} diff --git a/src/dirimage/layout.rs b/src/dirimage/layout.rs new file mode 100644 index 0000000..ec6819e --- /dev/null +++ b/src/dirimage/layout.rs @@ -0,0 +1,682 @@ +//! Layout planner: a host directory tree in, a full block assignment out. +//! +//! Two phases, kept apart because they fail for different reasons: +//! +//! 1. **Walk** the folder into a tree of names and sizes. Rejects what the +//! image model cannot represent (3D SSIF, an unrecognized tree, a +//! case-collision inside one directory). +//! 2. **Assign** blocks. Metadata first (File Set Descriptor, one File Entry +//! per node, then the directory FID lists), then file data. +//! +//! Data placement is the part with real constraints. For a Blu-ray there are +//! none — `.mpls`/`.clpi` address clips by name, never by LBA — so files are +//! packed sequentially. For a DVD the IFOs record VOB positions as sector +//! offsets from the IFO's own start, and `ifo.rs` re-derives every title extent +//! from them, so the placement must reproduce those offsets exactly or the rip +//! reads the wrong sectors. See [`place_video_ts`]. + +use super::encode::{ANCHOR_LBA, MIN_PART_START, SECTOR}; +use crate::error::{Error, Result}; +use std::path::{Path, PathBuf}; + +/// First block any FILE DATA may occupy, absolute. +/// +/// Well clear of the volume-space descriptors, and — the load-bearing part — +/// far from zero: `disc/bluray.rs:137` drops any clip extent whose LBA is 0, +/// so a file that landed at block 0 would vanish from the title with no error. +const DATA_FLOOR: u32 = 4096; + +/// Largest byte length a single allocation descriptor may record. +/// +/// The AD length field is 30 bits (the top two are the ECMA-167 4/14.14.1.1 +/// extent type), so 0x3FFF_FFFF is the arithmetic ceiling — but a non-final +/// extent must be a whole number of blocks, so the usable ceiling is the +/// largest multiple of 2048 below it. Files larger than this are split across +/// several ADs; `udf.rs` reads them back as a multi-extent file, which is the +/// same shape a dual-layer disc produces. +pub(super) const MAX_AD_BYTES: u64 = 0x3FFF_F800; + +/// Deepest directory nesting represented. Matches `udf.rs`'s `MAX_DIR_DEPTH`: +/// anything deeper would be recorded but never descended into, so the files +/// under it would be invisible to every consumer. Refuse instead of silently +/// dropping. +const MAX_DEPTH: u32 = 8; + +/// Upper bound on entries in the synthesized tree, mirroring `udf.rs`'s +/// `MAX_TOTAL_DIR_ENTRIES`. +const MAX_ENTRIES: usize = 100_000; + +/// One contiguous run of a file's bytes at a partition-relative block. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct Extent { + pub(super) lba: u32, + pub(super) bytes: u32, +} + +/// A planned file: where its bytes come from on the host, and where they live +/// in the synthesized image. +#[derive(Debug)] +pub(super) struct FileNode { + pub(super) name: String, + /// Disc path (`/BDMV/STREAM/00000.m2ts`) — used only for error reporting. + pub(super) disc_path: String, + pub(super) host: PathBuf, + pub(super) size: u64, + pub(super) icb_lba: u32, + pub(super) unique_id: u64, + pub(super) extents: Vec, +} + +/// A planned directory. +#[derive(Debug)] +pub(super) struct DirNode { + pub(super) name: String, + pub(super) icb_lba: u32, + pub(super) parent_icb_lba: u32, + pub(super) data_lba: u32, + pub(super) data_bytes: u32, + pub(super) unique_id: u64, + pub(super) dirs: Vec, + pub(super) files: Vec, +} + +/// A complete image plan. +#[derive(Debug)] +pub(super) struct Layout { + pub(super) part_start: u32, + pub(super) part_sectors: u32, + pub(super) total_sectors: u32, + pub(super) volume_id: String, + pub(super) file_count: u32, + pub(super) dir_count: u32, + pub(super) next_unique_id: u64, + pub(super) root: DirNode, +} + +/// Serialized length of one File Identifier Descriptor, before its 4-byte +/// alignment padding. Shared with the encoder so the planner's directory size +/// and the encoder's output cannot drift apart. +fn fid_len(name: &str, is_parent: bool) -> usize { + let l_fi = if is_parent { + 0 + } else { + super::encode::encode_cs0(name).len() + }; + (38 + l_fi).div_ceil(4) * 4 +} + +/// Total FID bytes a directory's data extent must hold. +fn dir_bytes(dirs: &[DirNode], files: &[FileNode]) -> usize { + let mut n = fid_len("", true); + for d in dirs { + n += fid_len(&d.name, false); + } + for f in files { + n += fid_len(&f.name, false); + } + n +} + +// ── Phase 1: walk ─────────────────────────────────────────────────────────── + +/// Whether a host directory entry belongs in the synthesized image. +/// +/// Dot-files are host artefacts, never disc content: macOS sprays `.DS_Store` +/// and `._*` resource forks through any folder a Finder window has touched, +/// and including them would put files on the "disc" that were never on the +/// disc. `.partial` is freemkv's own in-flight extraction suffix (see +/// `disc/extract.rs`) — picking one up would mean planning an extent over a +/// file that is still being written. +fn is_excluded(name: &str) -> bool { + name.starts_with('.') || name.ends_with(".partial") +} + +fn walk(dir: &Path, disc_path: &str, depth: u32, entries: &mut usize) -> Result { + if depth > MAX_DEPTH { + return Err(Error::DirImageTooLarge); + } + let mut dirs = Vec::new(); + let mut files = Vec::new(); + let mut names: Vec = Vec::new(); + + let mut read: Vec<_> = std::fs::read_dir(dir) + .map_err(Error::from)? + .collect::, _>>() + .map_err(Error::from)?; + // Deterministic order: the same folder must always produce the same image + // (readdir order is filesystem- and even mount-dependent). + read.sort_by_key(|e| e.file_name()); + + for entry in read { + let name = entry.file_name().to_string_lossy().into_owned(); + if is_excluded(&name) { + continue; + } + // `file_type()` does NOT follow symlinks; `metadata()` does. A symlink + // to a file is materialized as that file (a legitimate way to assemble + // a folder), a symlink to a directory is skipped — following one can + // loop, and UDF has no link this maps onto. + let ft = entry.file_type().map_err(Error::from)?; + let child_path = format!("{}/{}", disc_path.trim_end_matches('/'), name); + *entries += 1; + if *entries > MAX_ENTRIES { + return Err(Error::DirImageTooLarge); + } + names.push(name.to_ascii_uppercase()); + if ft.is_dir() { + dirs.push(walk(&entry.path(), &child_path, depth + 1, entries)?); + } else { + let meta = match std::fs::metadata(entry.path()) { + Ok(m) => m, + // A broken symlink or a file that vanished between readdir and + // stat: skip it rather than plan an extent that cannot be read. + Err(_) => continue, + }; + if !meta.is_file() { + continue; + } + files.push(FileNode { + name, + disc_path: child_path, + host: entry.path(), + size: meta.len(), + icb_lba: 0, + unique_id: 0, + extents: Vec::new(), + }); + } + } + + // `UdfFs::find_dir` / `read_file` match path components case-insensitively, + // so two entries in one directory differing only in case are + // indistinguishable to every consumer — the second would silently shadow + // the first. Only reachable on a case-sensitive host volume. + names.sort(); + for pair in names.windows(2) { + if pair[0] == pair[1] { + return Err(Error::DirNameCollision { + host: format!("{disc_path}/{}", pair[0]), + }); + } + } + + Ok(DirNode { + name: dir + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(), + icb_lba: 0, + parent_icb_lba: 0, + data_lba: 0, + data_bytes: 0, + unique_id: 0, + dirs, + files, + }) +} + +/// Find a child directory by ASCII-case-insensitive name. +fn child_dir<'a>(dir: &'a DirNode, name: &str) -> Option<&'a DirNode> { + dir.dirs.iter().find(|d| d.name.eq_ignore_ascii_case(name)) +} + +// ── Phase 2: block assignment ─────────────────────────────────────────────── + +/// Assign metadata blocks depth-first: this node's File Entry, then its +/// children's, then the directory data extents. Returns the next free block. +fn assign_metadata(dir: &mut DirNode, parent_icb: u32, next: &mut u32, uid: &mut u64) { + dir.icb_lba = *next; + *next += 1; + dir.parent_icb_lba = parent_icb; + dir.unique_id = *uid; + *uid += 1; + for f in &mut dir.files { + f.icb_lba = *next; + *next += 1; + f.unique_id = *uid; + *uid += 1; + } + let icb = dir.icb_lba; + for sub in &mut dir.dirs { + assign_metadata(sub, icb, next, uid); + } + // Directory data after every File Entry of this subtree, so a directory's + // FIDs can name ICBs that were assigned after it. + let bytes = dir_bytes(&dir.dirs, &dir.files); + dir.data_bytes = bytes as u32; + dir.data_lba = *next; + *next += bytes.div_ceil(SECTOR) as u32; +} + +/// Split a file into allocation descriptors and place them starting at `lba`. +/// Returns the first free block after the file. +fn place_file(f: &mut FileNode, lba: u32) -> Result { + f.extents.clear(); + if f.size == 0 { + return Ok(lba); + } + let mut remaining = f.size; + let mut cur = lba; + while remaining > 0 { + let chunk = remaining.min(MAX_AD_BYTES); + f.extents.push(Extent { + lba: cur, + bytes: chunk as u32, + }); + let blocks = chunk.div_ceil(SECTOR as u64); + cur = u32::try_from(cur as u64 + blocks).map_err(|_| Error::DirImageTooLarge)?; + remaining -= chunk; + } + Ok(cur) +} + +/// Big-endian u32 at `off`, as every DVD-Video structure field is stored. +fn be_u32(buf: &[u8], off: usize) -> Option { + let b = buf.get(off..off + 4)?; + Some(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) +} + +/// Read the first `n` bytes of a host file. +fn read_head(path: &Path, n: usize) -> Vec { + use std::io::Read; + let mut buf = vec![0u8; n]; + match std::fs::File::open(path).and_then(|mut f| f.read(&mut buf)) { + Ok(got) => { + buf.truncate(got); + buf + } + Err(_) => Vec::new(), + } +} + +/// Placement order and constraints for a `VIDEO_TS` folder. +/// +/// DVD-Video records VOB positions INSIDE the IFOs, as sector offsets from the +/// IFO file's own first sector: +/// +/// * `VIDEO_TS.IFO` + 0xC0 (`VMGM_VOBS`) → `VIDEO_TS.VOB` +/// * `VTS_nn_0.IFO` + 0xC0 (`vtsm_vobs`) → `VTS_nn_0.VOB` (the VTS menu) +/// * `VTS_nn_0.IFO` + 0xC4 (`vtstt_vobs`) → `VTS_nn_1.VOB` (the title stream) +/// +/// `ifo.rs:554-556` re-derives every title extent as +/// `file_start_lba(IFO) + vtstt_vobs + cell.first_sector`, so only the third +/// of those is load-bearing for freemkv itself — but the other two are +/// load-bearing for DVD PLAYERS, which read the menus freemkv ignores. All +/// three are honoured. +/// +/// `VTS_nn_1.VOB … VTS_nn_9.VOB` are one logical stream split at the 1 GB +/// file-size limit, and the cell sector addresses run continuously across the +/// split, so they are placed back-to-back with no gap. Laying the group out in +/// its canonical on-disc order gives exactly that for free; the constraint is +/// then a CHECK, not a search. +/// +/// The check can fail — a regenerated `.BUP`, a tool that rewrote an IFO, a +/// folder assembled by hand — and when it does the required position lies +/// below the end of the file that must precede it. There is no placement that +/// satisfies it, so it is a typed error naming the file rather than a silent +/// misplacement. +fn place_video_ts(vts: &mut DirNode, start: u32) -> Result { + let mut order: Vec = (0..vts.files.len()).collect(); + // Canonical on-disc order. Files the naming scheme does not cover (stray + // extras) sort last and are placed unconstrained. + order.sort_by_key(|&i| { + let r = classify(&vts.files[i].name); + ( + r.is_none(), + r.map(|c| (c.group, c.order)) + .unwrap_or((u32::MAX, u32::MAX)), + i, + ) + }); + + // Required start blocks, resolved as each IFO is placed. + let mut menu_req: std::collections::HashMap = std::collections::HashMap::new(); + let mut title_req: std::collections::HashMap = std::collections::HashMap::new(); + + let mut cursor = start; + for &i in &order { + let class = classify(&vts.files[i].name); + let required = match class.map(|c| (c.group, c.role)) { + Some((g, Role::MenuVob)) => menu_req.get(&g).copied(), + Some((g, Role::TitleVob)) => title_req.get(&g).copied(), + _ => None, + }; + let lba = match required { + // No placement satisfies this: the VOB must begin below the end of + // the file that precedes it. Naming the file is the whole point — + // the alternative is an image freemkv reads at the wrong offset. + Some(req) if req < cursor => { + return Err(Error::DirImagePlacement { + path: vts.files[i].disc_path.clone(), + }); + } + Some(req) => req, + None => cursor, + }; + cursor = place_file(&mut vts.files[i], lba)?; + + // An IFO just landed: resolve the offsets it declares. Both are + // relative to the IFO's own first sector. + if let Some(c) = class + && c.role == Role::Ifo + { + let head = read_head(&vts.files[i].host, 0xC8); + let menu = be_u32(&head, 0xC0).unwrap_or(0); + if menu != 0 { + menu_req.insert(c.group, lba.saturating_add(menu)); + } + // Only a VTS IFO carries a title VOBS pointer at 0xC4; the VMG's + // 0xC4 is a different field entirely (VMGM_C_ADT). + if c.group > 0 { + let title = be_u32(&head, 0xC4).unwrap_or(0); + if title != 0 { + title_req.insert(c.group, lba.saturating_add(title)); + } + } + } + } + Ok(cursor) +} + +/// What a DVD-Video filename is, for placement purposes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Role { + /// `VIDEO_TS.IFO` / `VTS_nn_0.IFO` — the file the offsets are relative to. + Ifo, + /// `VIDEO_TS.VOB` / `VTS_nn_0.VOB` — constrained by the 0xC0 offset. + MenuVob, + /// `VTS_nn_1.VOB` — constrained by the 0xC4 offset. `_2 … _9` follow it + /// with no gap by virtue of sorting immediately after. + TitleVob, + /// Backups and continuation VOBs: placed sequentially, no constraint. + Sequential, +} + +#[derive(Debug, Clone, Copy)] +struct Class { + /// 0 = the Video Manager (`VIDEO_TS.*`), n = title set n. + group: u32, + /// Sort key within the group. + order: u32, + role: Role, +} + +fn classify(name: &str) -> Option { + let up = name.to_ascii_uppercase(); + if let Some(ext) = up.strip_prefix("VIDEO_TS.") { + let (order, role) = match ext { + "IFO" => (0, Role::Ifo), + "VOB" => (1, Role::MenuVob), + "BUP" => (2, Role::Sequential), + _ => return None, + }; + return Some(Class { + group: 0, + order, + role, + }); + } + let rest = up.strip_prefix("VTS_")?; + let (num, rest) = rest.split_once('_')?; + let set: u32 = num.parse().ok()?; + let (part, ext) = rest.split_once('.')?; + let part: u32 = part.parse().ok()?; + let (order, role) = match (ext, part) { + ("IFO", 0) => (0, Role::Ifo), + ("VOB", 0) => (1, Role::MenuVob), + ("VOB", 1) => (2, Role::TitleVob), + ("VOB", n) => (1 + n, Role::Sequential), + ("BUP", 0) => (100, Role::Sequential), + _ => return None, + }; + Some(Class { + group: set + 1, + order, + role, + }) +} + +/// Place every file's data, depth-first, packed with no gaps. +fn place_generic(dir: &mut DirNode, cursor: &mut u32) -> Result<()> { + for f in &mut dir.files { + *cursor = place_file(f, *cursor)?; + } + for sub in &mut dir.dirs { + place_generic(sub, cursor)?; + } + Ok(()) +} + +fn count_nodes(dir: &DirNode, dirs: &mut u32, files: &mut u32) { + *dirs += 1; + *files += dir.files.len() as u32; + for sub in &dir.dirs { + count_nodes(sub, dirs, files); + } +} + +/// Total blocks the metadata region needs: File Set Descriptor, its +/// Terminating Descriptor, one File Entry per node, and each directory's FID +/// list. +fn metadata_blocks(dir: &DirNode) -> u64 { + let mut n = 1 + dir.files.len() as u64; + n += dir_bytes(&dir.dirs, &dir.files).div_ceil(SECTOR) as u64; + for sub in &dir.dirs { + n += metadata_blocks(sub); + } + n +} + +/// Reject a Blu-ray 3D tree. +/// +/// `disc/bluray.rs:124-130` probes `/BDMV/STREAM/SSIF/{clip}.ssif` and sets +/// `is_3d` the moment one resolves — unconditionally, before any capability +/// check. On a real 3D disc the `.ssif` and the base/dependent `.m2ts` files +/// ALIAS the same sectors (the SSIF interleave IS the two m2ts streams), which +/// this planner cannot express: it would allocate three disjoint copies, so +/// the clip's extents and the m2ts's extents would disagree and the rip would +/// read the wrong bytes at exit 0. Reject the folder instead. +fn reject_ssif(root: &DirNode) -> Result<()> { + let Some(bdmv) = child_dir(root, "BDMV") else { + return Ok(()); + }; + let Some(stream) = child_dir(bdmv, "STREAM") else { + return Ok(()); + }; + if child_dir(stream, "SSIF").is_some() { + return Err(Error::DirImageSsifUnsupported); + } + Ok(()) +} + +/// Plan an image over `root`. +/// +/// # Capacity, and what it changes +/// +/// `total_sectors` becomes the `Disc`'s `capacity_sectors` / `capacity_bytes`, +/// and `capacity_bytes` is not inert: `canonical_title_order`'s oversize gate +/// (`disc/mod.rs:2039-2040`, body `:2209-2210`) demotes any title whose +/// `size_bytes` exceeds it, which decides which title sorts first and therefore +/// what `-t 1` selects. +/// +/// The capacity reported here is **the synthesized image's own size** — what an +/// ISO built from this folder would report — and nothing else. Specifically it +/// is NOT padded up to a media tier (BD25/BD50/…), which was considered and +/// does not work: a folder does not record the tier of the disc it came from, +/// so a 22 GB folder off a BD50 would pad to BD25 and diverge anyway, and the +/// padding would inflate any pre-sized output written from the image. +/// +/// The consequence, stated plainly: **for a folder that is missing files the +/// source disc had — a selective MakeMKV backup being the normal case — the +/// capacity is smaller than the source disc's, the oversize gate is therefore +/// stricter, and a borderline "play-all" composite title that the ISO kept can +/// be demoted here. `-t 1` can select a different title from `dir://FOLDER` +/// than from an `iso://` of the same disc.** For a complete folder the two +/// agree, because the capacities agree. +/// +/// This is chosen over the alternatives because it is the only one that is +/// self-consistent: `dir://X` and an `iso://` built from `X` describe the same +/// image and must answer the same way. +pub(super) fn plan(root: &Path) -> Result { + let mut entries = 0usize; + let mut tree = walk(root, "", 0, &mut entries)?; + tree.name = String::new(); + + if child_dir(&tree, "BDMV").is_none() && child_dir(&tree, "VIDEO_TS").is_none() { + return Err(Error::DirImageUnsupportedTree); + } + reject_ssif(&tree)?; + + // Metadata: block 0 is the FSD, block 1 its Terminating Descriptor. + let mut next = 2u32; + let mut uid = 0u64; + assign_metadata(&mut tree, 0, &mut next, &mut uid); + tree.parent_icb_lba = tree.icb_lba; // root's parent FID points at itself + + let part_start = MIN_PART_START; + debug_assert!(part_start > ANCHOR_LBA); + // Data starts after the metadata region AND above the floor, so nothing + // lands at a low LBA a consumer treats as "no extent". + let meta_end = part_start as u64 + next as u64; + let data_start_abs = meta_end.max(DATA_FLOOR as u64); + let mut cursor = + u32::try_from(data_start_abs - part_start as u64).map_err(|_| Error::DirImageTooLarge)?; + + if let Some(idx) = tree + .dirs + .iter() + .position(|d| d.name.eq_ignore_ascii_case("VIDEO_TS")) + { + cursor = place_video_ts(&mut tree.dirs[idx], cursor)?; + for f in &mut tree.files { + cursor = place_file(f, cursor)?; + } + for (i, sub) in tree.dirs.iter_mut().enumerate() { + if i != idx { + place_generic(sub, &mut cursor)?; + } + } + } else { + place_generic(&mut tree, &mut cursor)?; + } + + let mut dir_count = 0; + let mut file_count = 0; + count_nodes(&tree, &mut dir_count, &mut file_count); + + let part_sectors = cursor; + let total = part_start as u64 + part_sectors as u64 + 1; // + trailing anchor + let total_sectors = u32::try_from(total).map_err(|_| Error::DirImageTooLarge)?; + + let volume_id = root + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "FREEMKV".to_string()); + // UDF volume identifiers are a 32-byte d-string: one compression byte, the + // characters, and a trailing length byte. Trim rather than let + // `put_dstring` cut a multi-byte character in half. + let volume_id: String = volume_id.chars().take(30).collect(); + + Ok(Layout { + part_start, + part_sectors, + total_sectors, + volume_id, + file_count, + dir_count, + next_unique_id: uid, + root: tree, + }) +} + +/// Total bytes of file data the plan covers — the honest "how big is this +/// folder" number, used for progress and for the capacity the scan sees. +pub(super) fn total_data_bytes(dir: &DirNode) -> u64 { + let mut n: u64 = dir.files.iter().map(|f| f.size).sum(); + for sub in &dir.dirs { + n += total_data_bytes(sub); + } + n +} + +/// Every file in the tree, depth-first, paired with its extents. The read path +/// turns this into a sorted LBA → (file, offset) map. +pub(super) fn flatten<'a>(dir: &'a DirNode, out: &mut Vec<&'a FileNode>) { + for f in &dir.files { + out.push(f); + } + for sub in &dir.dirs { + flatten(sub, out); + } +} + +/// Metadata footprint in blocks, for diagnostics. +pub(super) fn metadata_block_count(root: &DirNode) -> u64 { + 2 + metadata_blocks(root) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A file above the 30-bit AD ceiling must split into MULTIPLE + /// descriptors, and every non-final one must be a whole number of blocks — + /// otherwise the next extent's bytes start mid-sector and the file + /// reassembles wrong. Kills a mutant that emits one oversized AD (whose + /// length would collide with the extent-TYPE bits `udf.rs:642` reads). + #[test] + fn a_file_past_the_ad_ceiling_splits_on_a_block_boundary() { + let mut f = FileNode { + name: "BIG.M2TS".into(), + disc_path: "/BIG.M2TS".into(), + host: PathBuf::new(), + size: MAX_AD_BYTES + 4096, + icb_lba: 0, + unique_id: 0, + extents: Vec::new(), + }; + let end = place_file(&mut f, 1000).unwrap(); + assert_eq!(f.extents.len(), 2); + assert_eq!(f.extents[0].bytes as u64, MAX_AD_BYTES); + assert_eq!(f.extents[0].bytes % SECTOR as u32, 0, "block multiple"); + assert!(f.extents[0].bytes <= 0x3FFF_FFFF); + assert_eq!(f.extents[1].bytes, 4096); + assert_eq!( + f.extents[1].lba, + 1000 + (MAX_AD_BYTES / SECTOR as u64) as u32, + "the second extent starts where the first ends" + ); + assert_eq!(end, f.extents[1].lba + 2); + assert_eq!( + f.extents.iter().map(|e| e.bytes as u64).sum::(), + f.size, + "no bytes lost or invented" + ); + } + + /// A zero-byte file records no allocation descriptors at all and consumes + /// no blocks. + #[test] + fn an_empty_file_gets_no_extents() { + let mut f = FileNode { + name: "EMPTY".into(), + disc_path: "/EMPTY".into(), + host: PathBuf::new(), + size: 0, + icb_lba: 0, + unique_id: 0, + extents: Vec::new(), + }; + assert_eq!(place_file(&mut f, 500).unwrap(), 500); + assert!(f.extents.is_empty()); + } + + #[test] + fn host_artefacts_are_not_disc_content() { + assert!(is_excluded(".DS_Store")); + assert!(is_excluded("._00000.m2ts")); + assert!(is_excluded("00000.m2ts.partial")); + assert!(!is_excluded("00000.m2ts")); + assert!(!is_excluded("VTS_01_1.VOB")); + } +} diff --git a/src/dirimage/mod.rs b/src/dirimage/mod.rs new file mode 100644 index 0000000..aa16a28 --- /dev/null +++ b/src/dirimage/mod.rs @@ -0,0 +1,291 @@ +//! `dir://` as an image-level SOURCE: a synthetic UDF volume over a folder. +//! +//! A user's extracted disc — a DVD `VIDEO_TS/` or a Blu-ray `BDMV/`, typically +//! a MakeMKV-style backup — has files but no sectors, and everything above the +//! sector layer in this crate wants sectors: `Disc::scan_image`, `UdfFs`, +//! `ifo.rs`, `mpls.rs`, `clpi.rs` and the mux all read through a +//! [`SectorSource`]. [`DirImage`] supplies one. +//! +//! The trick is that nothing is emulated. A real, minimal, valid UDF 1.02 +//! volume is synthesized over the folder: +//! +//! * **Metadata sectors** (anchors, the volume descriptor sequences, the File +//! Set Descriptor, every File Entry, every directory's FID list) are encoded +//! into RAM by [`encode`] — a few MiB even for a large Blu-ray. +//! * **Data sectors** are not materialized at all. Each one maps to a byte +//! range of a real file, read on demand. +//! +//! So `udf::read_filesystem` parses this image by exactly the same code path it +//! parses a real disc with, and every consumer above it is unchanged. The cost +//! is that a single-partition synthetic volume never exercises the UDF 2.50 +//! Metadata Partition path (`udf.rs:946-991`) that every real BD-ROM uses — +//! this module's tests do not cover that block and must not be read as if they +//! did. +//! +//! What this module deliberately does NOT do: +//! +//! * **3D / SSIF** — rejected up front ([`Error::DirImageSsifUnsupported`]). +//! An SSIF aliases the same sectors as its base and dependent `.m2ts`; the +//! planner allocates disjoint extents, so a 3D folder would produce silently +//! wrong output. +//! * **HD-DVD `HVDVD_TS/`** — no title enumerator constraint is modelled. +//! * **Encrypted folders** — a folder whose content is still AACS-scrambled is +//! rejected by the caller-side probe, not decrypted here. + +mod encode; +mod layout; + +use crate::error::{Error, Result}; +use crate::sector::SectorSource; +use encode::{MetaSectors, SECTOR}; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; + +/// How many host files may be held open at once. +/// +/// A Blu-ray `BDMV/` can exceed a thousand files while macOS `RLIMIT_NOFILE` +/// defaults to 256, so "open every file up front" is not available. Reads are +/// overwhelmingly sequential through one large stream file at a time, so a +/// small LRU keeps the hit rate near 1 while bounding descriptors. +const HANDLE_CACHE: usize = 16; + +/// One file's bytes at one place in the image. +#[derive(Debug, Clone)] +struct DataRange { + /// Absolute first block. + start_lba: u32, + /// Blocks covered (the last one may be partially used, and is zero-padded). + sectors: u32, + /// Index into [`DirImage::files`]. + file: usize, + /// Byte offset within the file at which this range's bytes begin. + offset: u64, + /// Byte length of the range. + bytes: u64, +} + +/// A file the image reads through. +#[derive(Debug)] +struct FileRef { + host: PathBuf, + disc_path: String, + size: u64, +} + +/// A synthesized UDF disc image over a host directory. +/// +/// Owns everything it reads through (`PathBuf`s and its own file handles), so +/// it is `Send + 'static` and can be moved into `build_iso_pipeline`, which +/// hands it to `PrefetchedSectorSource`'s producer thread. +pub struct DirImage { + meta: MetaSectors, + /// Sorted by `start_lba`, non-overlapping. + ranges: Vec, + files: Vec, + open: Vec<(usize, File)>, + total_sectors: u32, + volume_id: String, + data_bytes: u64, +} + +impl std::fmt::Debug for DirImage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DirImage") + .field("volume_id", &self.volume_id) + .field("total_sectors", &self.total_sectors) + .field("files", &self.files.len()) + .field("meta_sectors", &self.meta.len()) + .finish() + } +} + +impl DirImage { + /// Plan and encode an image over `root`. + /// + /// Every error is decided here, at plan time, where it can name the file + /// responsible — the read path is deliberately left with nothing to decide + /// except "this file changed underneath me". + pub fn open(root: &Path) -> Result { + let plan = layout::plan(root)?; + let meta = encode::encode(&plan)?; + + let mut nodes = Vec::new(); + layout::flatten(&plan.root, &mut nodes); + + let mut files = Vec::with_capacity(nodes.len()); + let mut ranges = Vec::new(); + for (idx, node) in nodes.iter().enumerate() { + files.push(FileRef { + host: node.host.clone(), + disc_path: node.disc_path.clone(), + size: node.size, + }); + let mut offset = 0u64; + for e in &node.extents { + ranges.push(DataRange { + start_lba: plan.part_start + e.lba, + sectors: (e.bytes as u64).div_ceil(SECTOR as u64) as u32, + file: idx, + offset, + bytes: e.bytes as u64, + }); + offset += e.bytes as u64; + } + } + ranges.sort_by_key(|r| r.start_lba); + debug_assert!( + ranges + .windows(2) + .all(|w| w[0].start_lba + w[0].sectors <= w[1].start_lba), + "planned data ranges must not overlap" + ); + + let data_bytes = layout::total_data_bytes(&plan.root); + tracing::info!( + target: "freemkv::dirimage", + volume_id = %plan.volume_id, + files = files.len(), + dirs = plan.dir_count, + meta_blocks = layout::metadata_block_count(&plan.root), + total_sectors = plan.total_sectors, + "synthesized UDF image over directory" + ); + + Ok(Self { + meta, + ranges, + files, + open: Vec::new(), + total_sectors: plan.total_sectors, + volume_id: plan.volume_id, + data_bytes, + }) + } + + /// UDF volume identifier the image declares (the folder's own name). + pub fn volume_id(&self) -> &str { + &self.volume_id + } + + /// Total bytes of real file content the image carries — the folder's size, + /// not the image's (which also counts metadata and inter-file gaps). + pub fn data_bytes(&self) -> u64 { + self.data_bytes + } + + /// The range covering `lba`, if any. + fn range_at(&self, lba: u32) -> Option<&DataRange> { + let i = self.ranges.partition_point(|r| r.start_lba <= lba); + let r = self.ranges.get(i.checked_sub(1)?)?; + (lba < r.start_lba + r.sectors).then_some(r) + } + + /// Borrow an open handle for `file`, opening it (and evicting the + /// least-recently-used handle) if necessary. + /// + /// Opening is also where the plan is revalidated. A folder is not a disc: + /// a file can be shortened or replaced between planning and reading, and + /// zero-filling the difference would turn "the user deleted something" + /// into corrupt output at exit 0. The size is re-checked here, and a + /// truncation that happens while the handle is already open is caught by + /// the short read in [`Self::fill`]. + fn handle(&mut self, file: usize) -> Result<&mut File> { + if let Some(pos) = self.open.iter().position(|(i, _)| *i == file) { + // `open` is ordered most-recently-used first. + let entry = self.open.remove(pos); + self.open.insert(0, entry); + return Ok(&mut self.open[0].1); + } + let f = File::open(&self.files[file].host).map_err(Error::from)?; + let live = f.metadata().map_err(Error::from)?.len(); + if live != self.files[file].size { + return Err(Error::DirImageFileChanged { + path: self.files[file].disc_path.clone(), + }); + } + if self.open.len() >= HANDLE_CACHE { + self.open.pop(); + } + self.open.insert(0, (file, f)); + Ok(&mut self.open[0].1) + } + + /// Fill `out` (a whole number of sectors) from one data range, starting at + /// `lba`. `out` is already zeroed, so a file's tail sector comes back + /// zero-padded — which is exactly what `file_extents`' `div_ceil(2048)` + /// (`udf.rs:816`) makes every consumer expect. + fn fill(&mut self, r: &DataRange, lba: u32, out: &mut [u8]) -> Result<()> { + let within = (lba - r.start_lba) as u64 * SECTOR as u64; + let want = (r.bytes.saturating_sub(within)).min(out.len() as u64) as usize; + if want == 0 { + return Ok(()); + } + let at = r.offset + within; + let file = r.file; + let h = self.handle(file)?; + h.seek(SeekFrom::Start(at)).map_err(Error::from)?; + match h.read_exact(&mut out[..want]) { + Ok(()) => Ok(()), + // The file shrank while the handle was open. Same verdict as the + // size check in `handle`, reached the other way. + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + Err(Error::DirImageFileChanged { + path: self.files[file].disc_path.clone(), + }) + } + Err(e) => Err(Error::from(e)), + } + } +} + +impl SectorSource for DirImage { + fn capacity_sectors(&self) -> u32 { + self.total_sectors + } + + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + _recovery: bool, + ) -> Result { + let need = count as usize * SECTOR; + if buf.len() < need { + return Err(Error::UdfBufferTooSmall); + } + buf[..need].fill(0); + // Walk the request in RUNS, not sector by sector. A mux batch is 8192 + // sectors and almost always lands entirely inside one stream file's + // extent; per-sector seek+read would issue 8192 syscalls for what is + // one 16 MiB sequential read. + let mut i = 0u32; + while i < count as u32 { + let at = lba + i; + let off = i as usize * SECTOR; + if let Some(s) = self.meta.get(&at) { + buf[off..off + SECTOR].copy_from_slice(&s[..]); + i += 1; + continue; + } + // Metadata blocks all sit below the data floor, so a data range is + // never interrupted by one. + match self.range_at(at).cloned() { + Some(r) => { + let run = (r.start_lba + r.sectors - at).min(count as u32 - i); + let end = off + run as usize * SECTOR; + self.fill(&r, at, &mut buf[off..end])?; + i += run; + } + // A gap between planned extents. Reads as zeros, exactly as an + // unrecorded sector of a real image does. + None => i += 1, + } + } + Ok(need) + } +} + +#[cfg(test)] +mod tests; diff --git a/src/dirimage/tests.rs b/src/dirimage/tests.rs new file mode 100644 index 0000000..4fb7533 --- /dev/null +++ b/src/dirimage/tests.rs @@ -0,0 +1,681 @@ +//! Tests for the synthetic-image source. +//! +//! Round-tripping through `udf::read_filesystem` is a REGRESSION NET, not an +//! oracle: it proves `parse(write(x)) == x`, which any assumption shared by +//! writer and parser (tag checksum convention, AD stride, descriptor +//! placement) is invisible to. The external check — writing the image to a +//! file and asking the operating system to mount it — is the part that can +//! fail independently, and it is `write_and_mount_externally` below (ignored +//! by default: it shells out and needs a mountable host). + +use super::*; +use crate::udf; +use std::io::Write; + +/// A scratch directory that removes itself. +struct Scratch(PathBuf); + +impl Scratch { + fn new(tag: &str) -> Self { + let mut p = std::env::temp_dir(); + p.push(format!( + "freemkv-dirimage-{tag}-{}-{:?}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&p).unwrap(); + Self(p) + } + fn path(&self) -> &Path { + &self.0 + } + fn file(&self, rel: &str, bytes: &[u8]) { + let p = self.0.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + let mut f = std::fs::File::create(&p).unwrap(); + f.write_all(bytes).unwrap(); + } + fn dir(&self, rel: &str) { + std::fs::create_dir_all(self.0.join(rel)).unwrap(); + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Deterministic filler so a mis-offset read is visible as wrong CONTENT, not +/// just a wrong length. +fn pattern(seed: u8, len: usize) -> Vec { + (0..len) + .map(|i| (i as u32).wrapping_mul(31).wrapping_add(seed as u32) as u8) + .collect() +} + +/// A minimal but structurally real Blu-ray folder. +fn bdmv_scratch() -> (Scratch, Vec, Vec) { + let s = Scratch::new("bdmv"); + let index = pattern(1, 100); + let clip = pattern(7, 5000); + s.file("BDMV/index.bdmv", &index); + s.file("BDMV/PLAYLIST/00000.mpls", &pattern(3, 300)); + s.file("BDMV/CLIPINF/00000.clpi", &pattern(5, 700)); + s.file("BDMV/STREAM/00000.m2ts", &clip); + (s, index, clip) +} + +// ── The de-risking spike ──────────────────────────────────────────────────── + +/// THE load-bearing assertion of the whole design: metadata synthesized here +/// must be parseable by the PRODUCTION `read_filesystem`, unmodified. If this +/// fails, nothing above the sector layer can consume a `dir://` source and the +/// approach is wrong. +#[test] +fn production_parser_reads_the_synthesized_tree() { + let (s, index, clip) = bdmv_scratch(); + let mut img = DirImage::open(s.path()).unwrap(); + let fs = + udf::read_filesystem(&mut img).expect("read_filesystem must mount the synthetic image"); + + assert!(fs.find_dir("/BDMV").is_some(), "BDMV must be a directory"); + assert!(fs.find_dir("/BDMV/PLAYLIST").is_some()); + assert!(fs.find_dir("/BDMV/CLIPINF").is_some()); + assert!(fs.find_dir("/BDMV/STREAM").is_some()); + + let stream = fs.find_dir("/BDMV/STREAM").unwrap(); + let names: Vec<&str> = stream.entries.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, vec!["00000.m2ts"]); + assert_eq!(stream.entries[0].size, clip.len() as u64); + + // Bytes, not just shape: a wrong AD offset or a wrong partition base would + // still produce a plausible tree. + assert_eq!( + fs.read_file(&mut img, "/BDMV/index.bdmv").unwrap(), + index, + "read_file must return the host file's bytes" + ); + assert_eq!( + fs.read_file(&mut img, "/BDMV/STREAM/00000.m2ts").unwrap(), + clip + ); +} + +/// `file_extents` is what the rip pipeline actually reads a title through, so +/// the extents must be absolute, in range, and cover the file exactly. +#[test] +fn file_extents_are_absolute_and_readable() { + let (s, _, clip) = bdmv_scratch(); + let mut img = DirImage::open(s.path()).unwrap(); + let fs = udf::read_filesystem(&mut img).unwrap(); + + let exts = fs + .file_extents(&mut img, "/BDMV/STREAM/00000.m2ts") + .unwrap(); + assert_eq!(exts.len(), 1); + let (lba, sectors) = exts[0]; + assert!(lba > 0, "bluray.rs:137 drops any extent at LBA 0"); + assert_eq!(sectors as usize, clip.len().div_ceil(SECTOR)); + assert!(lba + sectors <= img.capacity_sectors()); + + // Read them the way the mux does and compare against the file. + let mut buf = vec![0u8; sectors as usize * SECTOR]; + img.read_sectors(lba, sectors as u16, &mut buf, false) + .unwrap(); + assert_eq!(&buf[..clip.len()], &clip[..]); + assert!( + buf[clip.len()..].iter().all(|&b| b == 0), + "the tail sector must be zero-padded" + ); + + // And `file_start_lba` — the term `ifo.rs` adds its IFO offsets to — must + // agree with the first extent. + assert_eq!( + fs.file_start_lba(&mut img, "/BDMV/STREAM/00000.m2ts") + .unwrap(), + lba + ); +} + +/// A file past the 30-bit allocation-descriptor ceiling comes back as MULTIPLE +/// extents whose lengths sum to the file size — the multi-extent case a real +/// dual-layer disc produces, and the one the single-AD fixture in `udf.rs` +/// never covered. Uses a sparse file so the test costs no real disk space. +#[test] +fn a_file_past_the_ad_ceiling_reads_back_as_multiple_extents() { + let s = Scratch::new("big"); + s.file("BDMV/index.bdmv", &pattern(1, 16)); + s.dir("BDMV/STREAM"); + let big = s.path().join("BDMV/STREAM/00000.m2ts"); + let size = super::layout::MAX_AD_BYTES + 3 * SECTOR as u64; + let f = std::fs::File::create(&big).unwrap(); + f.set_len(size).unwrap(); + drop(f); + + let mut img = DirImage::open(s.path()).unwrap(); + let fs = udf::read_filesystem(&mut img).unwrap(); + let entry = fs + .find_dir("/BDMV/STREAM") + .unwrap() + .entries + .iter() + .find(|e| e.name == "00000.m2ts") + .unwrap(); + assert_eq!(entry.size, size, "declared size survives the split"); + + let exts = fs + .file_extents(&mut img, "/BDMV/STREAM/00000.m2ts") + .unwrap(); + assert_eq!(exts.len(), 2, "one AD cannot hold a 1 GiB+ file"); + assert_eq!( + exts.iter().map(|(_, s)| *s as u64).sum::(), + size.div_ceil(SECTOR as u64), + "the extents must cover the whole file" + ); + assert_eq!( + exts[1].0, + exts[0].0 + exts[0].1, + "the second extent starts where the first ends" + ); +} + +/// Reads outside any planned extent are zeros, not errors — a real image has +/// unrecorded sectors too, and `read_filesystem` probes fixed LBAs (256, the +/// VDS window) before it knows what is there. +#[test] +fn gaps_and_out_of_range_reads_are_zero_filled() { + let (s, _, _) = bdmv_scratch(); + let mut img = DirImage::open(s.path()).unwrap(); + let cap = img.capacity_sectors(); + let mut buf = [0xAAu8; SECTOR * 2]; + let n = img.read_sectors(cap + 10, 2, &mut buf, false).unwrap(); + assert_eq!(n, SECTOR * 2); + assert!(buf.iter().all(|&b| b == 0)); +} + +/// Two runs over the same unchanged folder must produce the same image, byte +/// for byte. Non-determinism here would make `dir:// -> iso://` output differ +/// run to run for no reason, and would make every golden test flaky. +#[test] +fn the_same_folder_synthesizes_the_same_image() { + let (s, _, _) = bdmv_scratch(); + let mut a = DirImage::open(s.path()).unwrap(); + let mut b = DirImage::open(s.path()).unwrap(); + assert_eq!(a.capacity_sectors(), b.capacity_sectors()); + let mut buf_a = vec![0u8; SECTOR * 64]; + let mut buf_b = vec![0u8; SECTOR * 64]; + for start in [0u32, 256, 320, 4096] { + a.read_sectors(start, 64, &mut buf_a, false).unwrap(); + b.read_sectors(start, 64, &mut buf_b, false).unwrap(); + assert_eq!(buf_a, buf_b, "sectors from {start} differ between runs"); + } +} + +/// A directory with enough children that its FID list spans several 2048-byte +/// blocks, read back through the production parser. +/// +/// This is the case the block-alignment question turns on: FIDs are packed +/// contiguously and a descriptor may straddle a block boundary, because +/// `read_directory` (`udf.rs:1312`) walks the directory extent as one flat byte +/// run and BREAKS at the first non-257 tag. Padding each block would truncate +/// the directory at the first pad. 200 entries also exceeds the 16-handle LRU +/// several times over, so it exercises handle eviction on the read path. +#[test] +fn a_directory_spanning_many_blocks_reads_back_whole() { + const N: usize = 200; + let s = Scratch::new("wide"); + s.file("BDMV/index.bdmv", &pattern(1, 16)); + for i in 0..N { + s.file( + &format!("BDMV/STREAM/{i:05}.m2ts"), + &pattern(i as u8, 1000 + i), + ); + } + + let mut img = DirImage::open(s.path()).unwrap(); + let fs = udf::read_filesystem(&mut img).unwrap(); + let stream = fs.find_dir("/BDMV/STREAM").unwrap(); + assert_eq!( + stream.entries.len(), + N, + "every FID must survive the block boundaries" + ); + assert!( + stream.size > SECTOR as u64, + "the fixture must actually span blocks, or it proves nothing" + ); + + // Read every file back, in an order that thrashes the handle cache. + for i in (0..N).rev() { + let path = format!("/BDMV/STREAM/{i:05}.m2ts"); + assert_eq!( + fs.read_file(&mut img, &path).unwrap(), + pattern(i as u8, 1000 + i), + "{path} came back wrong" + ); + } +} + +// ── Rejection gates ───────────────────────────────────────────────────────── + +/// A 3D folder must be REFUSED, not planned. `bluray.rs:127` sets `is_3d` the +/// moment an `.ssif` resolves, and this planner has no extent aliasing, so a +/// planned 3D image would rip the wrong bytes and report success. +#[test] +fn a_3d_folder_is_rejected_rather_than_mis_planned() { + let s = Scratch::new("ssif"); + s.file("BDMV/index.bdmv", &pattern(1, 16)); + s.file("BDMV/STREAM/00000.m2ts", &pattern(2, 4096)); + s.file("BDMV/STREAM/SSIF/00000.ssif", &pattern(3, 8192)); + let err = DirImage::open(s.path()).unwrap_err(); + assert_eq!(err.code(), crate::error::E_DIR_IMAGE_SSIF_UNSUPPORTED); +} + +/// A folder with no disc structure at all is not an image. +#[test] +fn a_folder_with_no_disc_structure_is_rejected() { + let s = Scratch::new("empty"); + s.file("readme.txt", b"not a disc"); + let err = DirImage::open(s.path()).unwrap_err(); + assert_eq!(err.code(), crate::error::E_DIR_IMAGE_UNSUPPORTED_TREE); +} + +/// A file that shrinks between planning and reading is an ERROR. Zero-filling +/// the difference would produce a truncated rip that exits 0. +#[test] +fn a_file_that_shrinks_after_planning_fails_the_read() { + let (s, _, clip) = bdmv_scratch(); + let mut img = DirImage::open(s.path()).unwrap(); + let fs = udf::read_filesystem(&mut img).unwrap(); + let (lba, sectors) = fs + .file_extents(&mut img, "/BDMV/STREAM/00000.m2ts") + .unwrap()[0]; + assert!(clip.len() > SECTOR); + + // Truncate the backing file behind the image's back. + let f = std::fs::OpenOptions::new() + .write(true) + .open(s.path().join("BDMV/STREAM/00000.m2ts")) + .unwrap(); + f.set_len(16).unwrap(); + drop(f); + + let mut buf = vec![0u8; sectors as usize * SECTOR]; + let err = img + .read_sectors(lba, sectors as u16, &mut buf, false) + .unwrap_err(); + assert_eq!(err.code(), crate::error::E_DIR_IMAGE_FILE_CHANGED); +} + +// ── DVD placement ─────────────────────────────────────────────────────────── + +/// Build a `VTS_01_0.IFO` body whose VOB pointers are the given sector +/// offsets. Only the fields the planner and `ifo.rs` read are filled. +fn vts_ifo(len: usize, vtsm_vobs: u32, vtstt_vobs: u32) -> Vec { + let mut v = vec![0u8; len.max(0xC8)]; + v[0..12].copy_from_slice(b"DVDVIDEO-VTS"); + v[0xC0..0xC4].copy_from_slice(&vtsm_vobs.to_be_bytes()); + v[0xC4..0xC8].copy_from_slice(&vtstt_vobs.to_be_bytes()); + v +} + +/// THE DVD invariant. `ifo.rs:554-556` computes +/// `vob_start_sector = file_start_lba(VTS_01_0.IFO) + vtstt_vobs`, and the +/// title extents are built on top of that, so the planner must place +/// `VTS_01_1.VOB` at exactly that sector. Anything else rips the wrong bytes +/// with no error anywhere. +#[test] +fn vtstt_vobs_lands_on_the_first_sector_of_the_title_vob() { + let s = Scratch::new("dvd"); + // IFO is 2 sectors; menu VOB 3 sectors; so the natural, gap-free layout + // puts the title VOB 5 sectors past the IFO. Declare exactly that. + let ifo_sectors = 2u32; + let menu_sectors = 3u32; + s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]); + s.file( + "VIDEO_TS/VTS_01_0.IFO", + &vts_ifo( + ifo_sectors as usize * SECTOR, + ifo_sectors, + ifo_sectors + menu_sectors, + ), + ); + s.file( + "VIDEO_TS/VTS_01_0.VOB", + &pattern(9, menu_sectors as usize * SECTOR), + ); + let title = pattern(11, 4 * SECTOR); + s.file("VIDEO_TS/VTS_01_1.VOB", &title); + + let mut img = DirImage::open(s.path()).unwrap(); + let fs = udf::read_filesystem(&mut img).unwrap(); + + let ifo_lba = fs + .file_start_lba(&mut img, "/VIDEO_TS/VTS_01_0.IFO") + .unwrap(); + let vob_lba = fs + .file_start_lba(&mut img, "/VIDEO_TS/VTS_01_1.VOB") + .unwrap(); + let menu_lba = fs + .file_start_lba(&mut img, "/VIDEO_TS/VTS_01_0.VOB") + .unwrap(); + + assert_eq!( + ifo_lba + ifo_sectors + menu_sectors, + vob_lba, + "vtstt_vobs must resolve to VTS_01_1.VOB's first sector" + ); + assert_eq!( + ifo_lba + ifo_sectors, + menu_lba, + "vtsm_vobs must resolve to VTS_01_0.VOB's first sector" + ); + + // And the bytes at that sector really are the title VOB's. + let mut buf = vec![0u8; SECTOR]; + img.read_sectors(vob_lba, 1, &mut buf, false).unwrap(); + assert_eq!(buf, title[..SECTOR]); +} + +/// Continuation VOBs are one logical stream split at the 1 GB file limit, and +/// cell sector addresses run continuously across the split, so they must be +/// back-to-back with ZERO gap. +#[test] +fn continuation_vobs_are_contiguous() { + let s = Scratch::new("dvdmulti"); + s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]); + s.file("VIDEO_TS/VTS_01_0.IFO", &vts_ifo(SECTOR, 0, 1)); + s.file("VIDEO_TS/VTS_01_1.VOB", &pattern(1, 3 * SECTOR)); + s.file("VIDEO_TS/VTS_01_2.VOB", &pattern(2, 2 * SECTOR)); + s.file("VIDEO_TS/VTS_01_3.VOB", &pattern(3, SECTOR)); + + let mut img = DirImage::open(s.path()).unwrap(); + let fs = udf::read_filesystem(&mut img).unwrap(); + let at = |img: &mut DirImage, n: u32| { + fs.file_start_lba(img, &format!("/VIDEO_TS/VTS_01_{n}.VOB")) + .unwrap() + }; + let (a, b, c) = (at(&mut img, 1), at(&mut img, 2), at(&mut img, 3)); + assert_eq!(b, a + 3, "VTS_01_2.VOB immediately follows _1"); + assert_eq!(c, b + 2, "VTS_01_3.VOB immediately follows _2"); +} + +/// The negative case: an offset that cannot be satisfied must be a typed +/// error naming the file, NOT a silent misplacement. Here `vtstt_vobs` is 1, +/// which would put the title VOB inside the 2-sector IFO. +#[test] +fn an_unsatisfiable_vob_offset_errors_instead_of_misplacing() { + let s = Scratch::new("dvdbad"); + s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]); + s.file("VIDEO_TS/VTS_01_0.IFO", &vts_ifo(2 * SECTOR, 0, 1)); + s.file("VIDEO_TS/VTS_01_1.VOB", &pattern(1, SECTOR)); + + let err = DirImage::open(s.path()).unwrap_err(); + assert_eq!(err.code(), crate::error::E_DIR_IMAGE_PLACEMENT); + assert!( + err.to_string().contains("VTS_01_1.VOB"), + "the error must name the file it could not place, got {err}" + ); +} + +/// A gap-inducing offset (bigger than the natural packing) is legal: the +/// planner leaves a hole rather than failing. Real discs pad between title +/// sets. +#[test] +fn an_oversized_vob_offset_leaves_a_gap_rather_than_failing() { + let s = Scratch::new("dvdgap"); + s.file("VIDEO_TS/VIDEO_TS.IFO", &vec![0u8; SECTOR]); + s.file("VIDEO_TS/VTS_01_0.IFO", &vts_ifo(SECTOR, 0, 64)); + s.file("VIDEO_TS/VTS_01_1.VOB", &pattern(1, SECTOR)); + + let mut img = DirImage::open(s.path()).unwrap(); + let fs = udf::read_filesystem(&mut img).unwrap(); + let ifo = fs + .file_start_lba(&mut img, "/VIDEO_TS/VTS_01_0.IFO") + .unwrap(); + let vob = fs + .file_start_lba(&mut img, "/VIDEO_TS/VTS_01_1.VOB") + .unwrap(); + assert_eq!(vob, ifo + 64); + // The hole reads as zeros. + let mut buf = vec![0u8; SECTOR]; + img.read_sectors(ifo + 10, 1, &mut buf, false).unwrap(); + assert!(buf.iter().all(|&b| b == 0)); +} + +/// End to end through the real scanner: a DVD folder must enumerate titles the +/// same way an ISO of the same disc would, which is the whole point of +/// synthesizing a real filesystem rather than faking a tree. +#[test] +fn scan_image_enumerates_a_bdmv_folder() { + let (s, _, _) = bdmv_scratch(); + let mut img = DirImage::open(s.path()).unwrap(); + let cap = img.capacity_sectors(); + // The playlist here is filler, so no titles are expected — what is being + // asserted is that the scan reaches the BD enumerator at all (it would + // return `UdfNotFilesystem` if the synthesized volume did not parse) and + // reports the structure it found. + let disc = crate::disc::Disc::scan_image(&mut img, cap, &crate::disc::ScanOptions::default()) + .expect("scan_image must accept a synthesized BDMV image"); + assert!(!disc.encrypted, "a decrypted folder has no AACS directory"); + assert_eq!(disc.content_format, crate::disc::ContentFormat::BdTs); + assert_eq!(disc.capacity_sectors, cap); +} + +// ── Folder-level encryption verdict (`session::scan_dir`) ─────────────────── + +/// A one-PlayItem MPLS long enough that `parse_playlist` keeps it (it drops +/// anything under 30 s as a menu stub). +fn one_item_mpls(clip_id: &[u8; 5]) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(b"MPLS0200"); + buf.extend_from_slice(&40u32.to_be_bytes()); // playlist_start + buf.extend_from_slice(&[0u8; 28]); // mark_start placeholder + pad to 40 + + let pl = buf.len(); + buf.extend_from_slice(&[0u8; 4]); // length placeholder + buf.extend_from_slice(&[0u8; 2]); // reserved + buf.extend_from_slice(&1u16.to_be_bytes()); // num_play_items + buf.extend_from_slice(&[0u8; 2]); // num_sub_paths + + let mut item = Vec::new(); + item.extend_from_slice(clip_id); + item.extend_from_slice(b"M2TS"); + item.push(0); // connection condition + item.extend_from_slice(&[0u8; 2]); + item.extend_from_slice(&0u32.to_be_bytes()); // in_time + item.extend_from_slice(&(45_000u32 * 120).to_be_bytes()); // out_time: 2 min + item.extend_from_slice(&[0u8; 8]); // UO mask + item.push(0); + item.push(0); + item.extend_from_slice(&[0u8; 2]); + // Empty STN table: length, reserved, eight zero counts, reserved. + item.extend_from_slice(&16u16.to_be_bytes()); + item.extend_from_slice(&[0u8; 16]); + buf.extend_from_slice(&(item.len() as u16).to_be_bytes()); + buf.extend_from_slice(&item); + + let pl_len = (buf.len() - pl - 4) as u32; + buf[pl..pl + 4].copy_from_slice(&pl_len.to_be_bytes()); + + let mark_start = buf.len() as u32; + buf[12..16].copy_from_slice(&mark_start.to_be_bytes()); + buf.extend_from_slice(&2u32.to_be_bytes()); + buf.extend_from_slice(&0u16.to_be_bytes()); // no marks + buf +} + +/// A CLPI with only the fields `clpi::parse` needs: magic, zeroed section +/// starts, and the source packet count at 56. +fn minimal_clpi(source_packets: u32) -> Vec { + let mut d = vec![0u8; 60]; + d[0..4].copy_from_slice(b"HDMV"); + d[4..8].copy_from_slice(b"0200"); + d[56..60].copy_from_slice(&source_packets.to_be_bytes()); + d +} + +/// A BD folder that really enumerates a title, so the AACS content probe has +/// an extent to sample. +/// +/// The `.m2ts` is built as real 192-byte BD source packets, because that is +/// what the probe judges: byte 0 carries the AACS Copy Permission Indicator in +/// its top two bits, and byte 4 is the MPEG-TS sync. `scrambled` sets the CPI +/// and withholds the sync — "flagged and not structurally clean", which is +/// exactly `aacs_unit_needs_decrypt`. An all-zero payload would prove nothing +/// either way: `is_clean_ts` skips zero payloads as padding. +fn playable_bdmv(tag: &str, scrambled: bool) -> Scratch { + let s = Scratch::new(tag); + let packets = 4096u32; + let mut m2ts = vec![0x5Au8; packets as usize * 192]; + for p in m2ts.chunks_mut(192) { + p[0] = if scrambled { 0xC0 } else { 0x00 }; + p[4] = if scrambled { 0xAB } else { 0x47 }; + } + s.file("BDMV/index.bdmv", &pattern(1, 64)); + s.file("BDMV/PLAYLIST/00000.mpls", &one_item_mpls(b"00000")); + s.file("BDMV/CLIPINF/00000.clpi", &minimal_clpi(packets)); + s.file("BDMV/STREAM/00000.m2ts", &m2ts); + s +} + +/// The folder must enumerate a title through the real BD scanner — the whole +/// reason for synthesizing a filesystem instead of faking a tree. +#[test] +fn a_bd_folder_enumerates_its_title_through_scan_dir() { + let s = playable_bdmv("play", false); + let (disc, _reader) = + crate::session::scan_dir(s.path(), crate::disc::ScanOptions::default()).unwrap(); + assert_eq!(disc.titles.len(), 1, "the playlist must produce one title"); + assert!(!disc.titles[0].extents.is_empty(), "with real extents"); + assert!(!disc.encrypted); +} + +/// A folder that kept its `AACS/` directory but whose content is in the clear +/// must be treated as DECRYPTED. Tree shape claims encryption +/// (`disc/mod.rs:1992`); the content is the evidence that overrides it. +#[test] +fn an_aacs_directory_over_clear_content_is_treated_as_decrypted() { + let s = playable_bdmv("aacsclear", false); + s.file("AACS/Unit_Key_RO.inf", &[0u8; 64]); + s.file("AACS/MKB_RO.inf", &[0u8; 64]); + + // Without the probe this is what the scan alone concludes. + let mut img = DirImage::open(s.path()).unwrap(); + let cap = img.capacity_sectors(); + let raw = + crate::disc::Disc::scan_image(&mut img, cap, &crate::disc::ScanOptions::default()).unwrap(); + assert!(raw.encrypted, "tree shape alone says encrypted"); + + let (disc, _reader) = + crate::session::scan_dir(s.path(), crate::disc::ScanOptions::default()).unwrap(); + assert!( + !disc.encrypted, + "sampled content units are clear, so the folder is decrypted" + ); + assert!(disc.aacs_error.is_none(), "and no key is demanded"); +} + +/// The other verdict: a folder whose content units really are flagged and +/// scrambled is a raw encrypted copy, which `dir://` does not support. It must +/// be a typed error, not a rip that emits garbage. +#[test] +fn an_aacs_folder_with_scrambled_content_is_rejected() { + let s = playable_bdmv("aacsenc", true); + s.file("AACS/Unit_Key_RO.inf", &[0u8; 64]); + s.file("AACS/MKB_RO.inf", &[0u8; 64]); + let err = match crate::session::scan_dir(s.path(), crate::disc::ScanOptions::default()) { + Ok(_) => panic!("a scrambled folder must not scan clean"), + Err(e) => e, + }; + assert_eq!(err.code(), crate::error::E_DIR_IMAGE_ENCRYPTED); +} + +// ── External oracle ───────────────────────────────────────────────────────── + +/// Write a synthesized image to a real file and ask the OS to mount it. +/// +/// This is the only check here that is not circular: `read_filesystem` shares +/// every assumption with the encoder, an operating system's UDF driver shares +/// none of them. Ignored by default because it shells out to `hdiutil` and +/// needs a host that can attach an image; run with +/// `cargo test -- --ignored write_and_mount_externally --nocapture`. +#[test] +#[ignore = "external: shells out to hdiutil/mount"] +fn write_and_mount_externally() { + let s = Scratch::new("mount"); + s.file("BDMV/index.bdmv", &pattern(1, 100)); + s.file("BDMV/PLAYLIST/00000.mpls", &pattern(3, 300)); + s.file("BDMV/STREAM/00000.m2ts", &pattern(7, 5000)); + + let mut img = DirImage::open(s.path()).unwrap(); + // `.iso`, not `.udf`: hdiutil dispatches on the extension and answers + // "image not recognized" for a raw sector image it has no handler for — + // which is a statement about the filename, not about the volume. + let out = s + .path() + .parent() + .unwrap() + .join(format!("freemkv-dirimage-{}.iso", std::process::id())); + let mut f = std::fs::File::create(&out).unwrap(); + let cap = img.capacity_sectors(); + let mut buf = vec![0u8; SECTOR * 64]; + let mut lba = 0u32; + while lba < cap { + let n = (cap - lba).min(64) as u16; + img.read_sectors(lba, n, &mut buf, false).unwrap(); + f.write_all(&buf[..n as usize * SECTOR]).unwrap(); + lba += n as u32; + } + f.sync_all().unwrap(); + drop(f); + + println!("image written to {}", out.display()); + let attach = std::process::Command::new("hdiutil") + .args(["attach", "-nobrowse", "-readonly", "-noverify"]) + .arg(&out) + .output() + .expect("hdiutil must be runnable"); + println!( + "hdiutil attach status={} stdout={} stderr={}", + attach.status, + String::from_utf8_lossy(&attach.stdout), + String::from_utf8_lossy(&attach.stderr) + ); + let stdout = String::from_utf8_lossy(&attach.stdout).into_owned(); + let mount = stdout + .lines() + .find_map(|l| { + l.split_whitespace() + .last() + .filter(|p| p.starts_with("/Volumes/")) + }) + .map(PathBuf::from); + // Content, not just "it mounted": the OS's own UDF driver must hand back + // the same bytes the host file holds, which is the assertion that shares + // nothing with this crate's parser. + let same = mount + .as_ref() + .map(|m| std::fs::read(m.join("BDMV/STREAM/00000.m2ts")).ok() == Some(pattern(7, 5000))); + if attach.status.success() + && let Some(dev) = stdout.split_whitespace().next() + { + let _ = std::process::Command::new("hdiutil") + .args(["detach", dev]) + .output(); + } + let _ = std::fs::remove_file(&out); + assert!( + attach.status.success(), + "the OS refused to mount the synthesized image" + ); + assert_eq!( + same, + Some(true), + "the OS mounted the image but read back different bytes" + ); +} diff --git a/src/error.rs b/src/error.rs index 931b61e..e66c001 100644 --- a/src/error.rs +++ b/src/error.rs @@ -170,6 +170,31 @@ pub const E_DIR_INSUFFICIENT_SPACE: u16 = 9027; pub const E_DIR_NAME_COLLISION: u16 = 9028; /// A `dir://` create_dir_all / file write / rename failed. pub const E_DIR_WRITE_FAILED: u16 = 9029; +/// A `dir://` SOURCE folder carries `BDMV/STREAM/SSIF/` (Blu-ray 3D). The +/// scanner detects SSIF unconditionally and would rip it as 3D, but the +/// synthetic-image planner has no extent-aliasing support (an SSIF interleaves +/// the same sectors as the base/dependent `.m2ts`), so the output would be +/// silently wrong. Rejected up front instead. +pub const E_DIR_IMAGE_SSIF_UNSUPPORTED: u16 = 9061; +/// A `dir://` SOURCE `VIDEO_TS` folder's IFO-declared VOB offsets cannot be +/// satisfied by any placement: the required start sector of a VOB lies BELOW +/// the end of the file that must precede it. Carries the offending file's disc +/// path. A silently misplaced VOB would rip the wrong sectors. +pub const E_DIR_IMAGE_PLACEMENT: u16 = 9062; +/// A `dir://` SOURCE folder still carries live AACS-encrypted content: it has +/// an `AACS/` directory AND the sampled content units are genuinely scrambled. +/// `dir://` sources are decrypted-folder only. +pub const E_DIR_IMAGE_ENCRYPTED: u16 = 9063; +/// A `dir://` SOURCE folder holds no disc structure the image synthesizer +/// understands (no `BDMV/`, no `VIDEO_TS/`). +pub const E_DIR_IMAGE_UNSUPPORTED_TREE: u16 = 9064; +/// A file inside a `dir://` SOURCE folder changed (shrank / was removed) +/// between planning and reading. Zero-filling the gap would produce corrupt +/// output at exit 0, so the read fails instead. Carries the disc path. +pub const E_DIR_IMAGE_FILE_CHANGED: u16 = 9065; +/// A `dir://` SOURCE folder does not fit a 32-bit sector address space +/// (> 2^32 sectors ≈ 8 TiB), or holds more entries than a UDF tree can carry. +pub const E_DIR_IMAGE_TOO_LARGE: u16 = 9066; pub const E_M2TS_PACKET_MALFORMED: u16 = 9021; /// A `network://` output target resolved to no address that is safe to /// connect to (every resolved IP was loopback / private / link-local / @@ -733,6 +758,25 @@ pub enum Error { DirWriteFailed { errno: Option, }, + /// A `dir://` SOURCE folder carries `BDMV/STREAM/SSIF/` (Blu-ray 3D), + /// which the synthetic-image planner cannot represent. See + /// [`E_DIR_IMAGE_SSIF_UNSUPPORTED`]. + DirImageSsifUnsupported, + /// A `dir://` SOURCE `VIDEO_TS` placement constraint is unsatisfiable. + /// `path` is the disc path of the file that could not be placed. + DirImagePlacement { + path: String, + }, + /// A `dir://` SOURCE folder still carries live AACS-encrypted content. + DirImageEncrypted, + /// A `dir://` SOURCE folder holds no recognized disc structure. + DirImageUnsupportedTree, + /// A file in a `dir://` SOURCE folder changed between plan and read. + DirImageFileChanged { + path: String, + }, + /// A `dir://` SOURCE folder exceeds the addressable image size. + DirImageTooLarge, } impl Error { @@ -850,6 +894,12 @@ impl Error { Error::DirInsufficientSpace { .. } => E_DIR_INSUFFICIENT_SPACE, Error::DirNameCollision { .. } => E_DIR_NAME_COLLISION, Error::DirWriteFailed { .. } => E_DIR_WRITE_FAILED, + Error::DirImageSsifUnsupported => E_DIR_IMAGE_SSIF_UNSUPPORTED, + Error::DirImagePlacement { .. } => E_DIR_IMAGE_PLACEMENT, + Error::DirImageEncrypted => E_DIR_IMAGE_ENCRYPTED, + Error::DirImageUnsupportedTree => E_DIR_IMAGE_UNSUPPORTED_TREE, + Error::DirImageFileChanged { .. } => E_DIR_IMAGE_FILE_CHANGED, + Error::DirImageTooLarge => E_DIR_IMAGE_TOO_LARGE, } } } @@ -956,6 +1006,9 @@ impl std::fmt::Display for Error { }, Error::Halted => write!(f, "E{}", self.code()), Error::UdfNotFound { path } => write!(f, "E{}: {}", self.code(), path), + Error::DirImagePlacement { path } | Error::DirImageFileChanged { path } => { + write!(f, "E{}: {}", self.code(), path) + } Error::DiscTitleRange { index, count } => { write!(f, "E{}: {}/{}", self.code(), index, count) } @@ -1107,6 +1160,18 @@ impl From for std::io::Error { // 9027 insufficient space / 9029 write failed: a filesystem-level // failure, not bad input. E_DIR_INSUFFICIENT_SPACE | E_DIR_WRITE_FAILED => std::io::ErrorKind::Other, + // dir:// SOURCE gates (9061–9064, 9066): the folder handed in cannot + // be turned into a disc image — a 3D SSIF tree, an unsatisfiable + // VIDEO_TS placement, still-encrypted content, an unrecognized tree, + // or one too large to address. All are properties of the input. + E_DIR_IMAGE_SSIF_UNSUPPORTED + | E_DIR_IMAGE_PLACEMENT + | E_DIR_IMAGE_ENCRYPTED + | E_DIR_IMAGE_UNSUPPORTED_TREE + | E_DIR_IMAGE_TOO_LARGE => std::io::ErrorKind::InvalidInput, + // 9065: the folder changed underneath a running read. Not bad input + // at plan time — a mid-flight mutation of the source. + E_DIR_IMAGE_FILE_CHANGED => std::io::ErrorKind::InvalidData, _ => std::io::ErrorKind::Other, }; std::io::Error::new(kind, msg) @@ -1445,6 +1510,12 @@ mod tests { .code(), Error::DirNameCollision { host: "x".into() }.code(), Error::DirWriteFailed { errno: Some(28) }.code(), + Error::DirImageSsifUnsupported.code(), + Error::DirImagePlacement { path: "x".into() }.code(), + Error::DirImageEncrypted.code(), + Error::DirImageUnsupportedTree.code(), + Error::DirImageFileChanged { path: "x".into() }.code(), + Error::DirImageTooLarge.code(), ]; let mut sorted = codes.to_vec(); sorted.sort(); @@ -1746,6 +1817,12 @@ mod tests { E_M2TS_PACKET_MALFORMED, E_EXTENT_NOT_UNIT_ALIGNED, E_DISC_CAPACITY_MALFORMED, + E_DIR_IMAGE_SSIF_UNSUPPORTED, + E_DIR_IMAGE_PLACEMENT, + E_DIR_IMAGE_ENCRYPTED, + E_DIR_IMAGE_UNSUPPORTED_TREE, + E_DIR_IMAGE_FILE_CHANGED, + E_DIR_IMAGE_TOO_LARGE, ]; let original_len = codes.len(); codes.sort(); diff --git a/src/lib.rs b/src/lib.rs index 411b81d..0ae1d5a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -106,6 +106,7 @@ pub mod consts; pub mod css; pub mod decrypt; pub mod diag; +pub mod dirimage; pub mod disc; pub mod drive; pub mod dvdnav; @@ -148,7 +149,8 @@ pub use drive::{Drive, DriveStatus, extract_scsi_context, find_drive}; // Owns the `Drive` by value; forwards consumer-built key material into // `ScanOptions` (the library derives no certs — see `KeySpec`). pub use session::{ - DeviceTarget, DiscSession, KeySourceFactory, KeySpec, ResolvedKeys, resolve_keys_for, scan_iso, + DeviceTarget, DiscSession, KeySourceFactory, KeySpec, ResolvedKeys, resolve_keys_for, scan_dir, + scan_iso, }; // ─── Errors ───────────────────────────────────────────────────────────────── @@ -228,6 +230,7 @@ pub use decrypt::{AacsKeyMap, DecryptKeys, decrypt_sectors, decrypt_threads, set // — not the `pes::Stream` trait re-exported below as `PesStream`. Two // different concepts, the same short name; the trait gets the `Pes` // prefix at the crate root to keep both addressable. +pub use dirimage::DirImage; pub use disc::{ AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, Disc, DiscFormat, DiscId, DiscTitle, DriveCredentials, Extent, ExtractOptions, ExtractResult, diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 0874115..65184db 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -55,9 +55,15 @@ pub enum StreamUrl { Stdio, /// ISO disc image file. Iso { path: PathBuf }, - /// Decrypted file-tree output directory (`dir://`). A sink that writes - /// per-file decrypted bytes (not muxed PES frames), so it never flows - /// through `output()`; the CLI routes a `Dir` dest to `Disc::extract_tree`. + /// An extracted disc file tree (`dir://`) — a source AND a sink. + /// + /// As a SINK it writes per-file decrypted bytes rather than muxed PES + /// frames, so it never flows through `output()`; the CLI routes a `Dir` + /// dest to `Disc::extract_tree`. + /// + /// As a SOURCE (1.6.1) it is an image-level source: `crate::dirimage` + /// synthesizes a real UDF volume over the folder, so it reaches the same + /// scan/mux path `iso://` does and every destination follows. Dir { path: PathBuf }, /// Null sink (write-only, discards data). Null, @@ -142,9 +148,22 @@ impl StreamUrl { } } - /// Whether this URL represents a disc source (disc:// or iso://). + /// Whether this URL is an IMAGE-level source — one that carries a UDF + /// filesystem, so it can be scanned into a title list, have `-t`/`-a`/`-s` + /// applied, and feed either a PES sink or an image sink. + /// + /// `dir://` joined in 1.6.1, when `crate::dirimage` gave a folder a real + /// synthesized UDF volume. + /// + /// NOT the same predicate as the CLI's `engine::is_disc_source`, which + /// means "is a live drive" and drives tray/eject behaviour. That one must + /// never gain `Dir` — a directory routed down the live-drive rip path + /// would open, lock and eject a drive that has nothing to do with it. pub fn is_disc_source(&self) -> bool { - matches!(self, StreamUrl::Disc { .. } | StreamUrl::Iso { .. }) + matches!( + self, + StreamUrl::Disc { .. } | StreamUrl::Iso { .. } | StreamUrl::Dir { .. } + ) } } @@ -371,171 +390,27 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result io::Error { e.into() })?; - // Apply the caller-resolved keys (lookup-free); decrypt_keys() then - // yields them for the stream below. Propagate a failed application - // rather than silently muxing an undecryptable stream. - if !opts.unit_keys.is_empty() { - // These UKs were already resolved AND validated by the caller - // (the CLI's keydb loop), so no re-validation sample is needed. - disc.decrypt_with(crate::disc::Key::Unit(opts.unit_keys.clone()), &[]) - .map_err(|e| -> io::Error { e.into() })?; - } - // Pre-flight decrypt gate (the single, system-wide verdict — see - // `Disc::ensure_decryptable`). Fails fast BEFORE any mux work when - // decryption is needed and unavailable: a scrambled-but-uncracked - // CSS disc (`css_error` set), or an AACS-encrypted disc with no - // usable key (would mux ~100 MB of garbage — encrypted m2ts → no TS - // syncs → demuxer emits nothing → empty/garbage output at exit 0). - // `--raw` and unencrypted/CSS-keyless-success discs pass. This is the - // disc-wide check; the per-title (multi-VTS CSS) check is below, once - // the chosen title's key is resolved. - disc.ensure_decryptable(opts.raw) - .map_err(|e| -> io::Error { e.into() })?; - if disc.titles.is_empty() { - return Err(crate::error::Error::NoStreams.into()); - } - let idx = opts.title_index.unwrap_or(0); - if idx >= disc.titles.len() { - return Err(crate::error::Error::DiscTitleRange { - index: idx, - count: disc.titles.len(), - } - .into()); - } - // Prune to the selected audio/subtitle streams now, on the scanned - // (pre-`probe_and_remap`) title, so everything downstream — the - // TrueHD channel-correction probe, the final title clone, and - // `build_iso_pipeline`'s demux/track construction — sees the pruned - // list. Video is always kept; a no-op for the default All/All. - opts.selection - .apply(&mut disc.titles[idx]) - .map_err(|e| -> io::Error { e.into() })?; - // Per-title key resolution. DVD CSS is resolved at exactly ONE site — - // `build_iso_pipeline`'s per-title crack (below), which decrypts a - // crackable title, passes a genuinely-clear one through, and - // hard-fails an uncrackable one with CssKeyMissing. So for a DVD we do - // NOT pre-crack here: pass `None` and let the pipeline own it. - // Pre-cracking would re-open the ISO and re-scan every clear title - // (`decrypt_keys_for_title` → None → the pipeline re-cracks anyway). - // AACS / unencrypted resolve from `decrypt_keys()` with NO read; `--raw` - // (any format) is deliberate ciphertext passthrough — also `None`. - let is_dvd = disc.format == crate::disc::DiscFormat::Dvd; - let (keys, title_is_clear) = if opts.raw || is_dvd { - (crate::decrypt::DecryptKeys::None, false) - } else { - (disc.decrypt_keys(), false) - }; - // Decrypt gate for the AACS / non-DVD path: a None key means no usable - // disc key, which would mux scrambled ciphertext verbatim — fail loudly - // (NoDiscKey). The DVD path is gated inside `build_iso_pipeline` (its - // CSS hard-fail), and `--raw` passes. - if !is_dvd { - disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear) - .map_err(|e| -> io::Error { e.into() })?; - } - // FMTS (AACS 2.1) forensic segments are sourced + fail-loud-checked - // downstream by `resolve_mux_key_map`/`resolve_fmts_key_map`, which hold - // the key-fetch closure and can actually attempt resolution. (An older - // upfront blanket-reject gate lived here; it predated the resolver and - // rejected every 2.1 disc before a source could be tried.) - // Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1) - // by probing the first DECRYPTED access units of the chosen title. - // A fresh reader avoids disturbing the mux reader below. Skipped in - // --raw mode: the probe would re-open + decrypt for nothing (on an - // AACS disc with no key the correction is a no-op on ciphertext, and - // raw output isn't decoded anyway). - if !opts.raw { - match crate::io::file_sector_source::FileSectorSource::open(path) { - Ok(mut probe) => { - // The probe DECRYPTS the title head, so it needs the SAME - // up-front key map the mux read installs below. An AACS - // `DecryptingSectorSource` with no map fails loud on the - // first unit (`decrypt_sectors_mapped` is the only AACS - // decrypt path) — without resolving one here the correction - // is silently skipped on every AACS disc and 7.1/Atmos stays - // understated as the MPLS-declared 5.1. Resolution failure is - // non-fatal (`.ok()`): leave channels uncorrected, never - // fail the mux. - let mut probe_keys = keys.clone(); - let probe_title = disc.titles[idx].clone(); - let probe_map = match &probe_keys { - crate::decrypt::DecryptKeys::Aacs { .. } => resolve_mux_key_map( - &mut probe, - &probe_title, - &mut probe_keys, - opts.key_fetch.as_ref(), - disc.content_format, - // File-backed, bounded probe (best-effort `.ok()`); - // no live drive to protect from a stuck stop here. - None, - ) - .ok() - .map(std::sync::Arc::new), - _ => None, - }; - let mut dec = crate::sector::DecryptingSectorSource::new(probe, probe_keys); - if let Some(map) = probe_map { - dec = dec.with_key_map(map); - } - crate::disc::correct_truehd_channels(&mut dec, &mut disc.titles[idx]); - } - Err(e) => { - // Non-fatal: a failed re-open just leaves MPLS 7.1/Atmos - // channel counts uncorrected (understated as 5.1). Log so - // the uncorrected path is diagnosable rather than silent. - tracing::debug!( - target: "mux", - "TrueHD channel-correction probe re-open failed: {e}" - ); - } - } - } - let title = disc.titles[idx].clone(); - let format = disc.content_format; - // ISO file: 8192-sector batch (16 MiB at 2048 B/sector) — - // sequential read from fast storage, no bad sectors. Empirically - // optimal; bumping to 16384 sectors (32 MiB) regressed (more cache - // pressure, longer per-batch latency starves the consumer between - // iterations). Physical drives keep smaller batches for adaptive - // error handling. - const ISO_MUX_BATCH_SECTORS: u16 = 8192; - - // Pass `DecryptKeys::None` to the decrypt decorator when - // --raw is set — the read stack still flows through the - // same producer+demux+parse pipeline, just without the - // AACS / CSS step. Single highway for all ISO reads. - let effective_keys = if opts.raw { - crate::decrypt::DecryptKeys::None - } else { - keys - }; - // Install the shared fetch closure (if the app supplied one) so a - // unit no held key decrypts is re-tried via the app's key source. - // Suppressed in --raw (no decrypt step to recover). - let fetch = if opts.raw { - None - } else { - opts.key_fetch.clone() - }; - let stream = build_iso_pipeline( - reader, - title, - effective_keys, - ISO_MUX_BATCH_SECTORS, - format, - opts.raw, - None, - None, - fetch, - )?; + let reader = crate::io::file_sector_source::FileSectorSource::open(path)?; + let probe_path = path.clone(); + let stream = image_input(reader, opts, move || { + crate::io::file_sector_source::FileSectorSource::open(&probe_path).ok() + })?; + Ok(Box::new(stream)) + } + // `dir://` as a SOURCE: an extracted disc folder, presented as a + // synthetic UDF image (`crate::dirimage`). It reaches EXACTLY the same + // body as `iso://` above — scan, title select, key resolution, mux — + // because by the time `DirImage` exists it is just another + // `SectorSource`. That is the point of synthesizing a real filesystem + // rather than faking a tree: every destination follows for free, with + // no per-scheme mux path to keep in step. + StreamUrl::Dir { ref path } => { + validate_file_path(path, "dir")?; + let reader = crate::dirimage::DirImage::open(path)?; + let probe_path = path.clone(); + let stream = image_input(reader, opts, move || { + crate::dirimage::DirImage::open(&probe_path).ok() + })?; Ok(Box::new(stream)) } StreamUrl::M2ts { ref path } => { @@ -556,9 +431,6 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result Ok(Box::new(StdioStream::input())), - // `dir://` is an output-only sink (decrypted file tree); it is never a - // PES source. Mirror `null://` → write-only. - StreamUrl::Dir { .. } => Err(crate::error::Error::StreamWriteOnly.into()), StreamUrl::Null => Err(crate::error::Error::StreamWriteOnly.into()), // `mp4://` as a source: demux a progressive MP4 back into PES frames, so // `mp4://` flows to every sink (mkv://, audio://, json://, …). @@ -578,6 +450,192 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result( + mut reader: S, + opts: &InputOptions, + reopen: F, +) -> io::Result +where + S: SectorSource + Send + 'static, + F: FnOnce() -> Option, +{ + let capacity = reader.capacity_sectors(); + let mut disc = + crate::disc::Disc::scan_image(&mut reader, capacity, &crate::disc::ScanOptions::default()) + .map_err(|e| -> io::Error { e.into() })?; + // Apply the caller-resolved keys (lookup-free); decrypt_keys() then + // yields them for the stream below. Propagate a failed application + // rather than silently muxing an undecryptable stream. + if !opts.unit_keys.is_empty() { + // These UKs were already resolved AND validated by the caller + // (the CLI's keydb loop), so no re-validation sample is needed. + disc.decrypt_with(crate::disc::Key::Unit(opts.unit_keys.clone()), &[]) + .map_err(|e| -> io::Error { e.into() })?; + } + // Pre-flight decrypt gate (the single, system-wide verdict — see + // `Disc::ensure_decryptable`). Fails fast BEFORE any mux work when + // decryption is needed and unavailable: a scrambled-but-uncracked + // CSS disc (`css_error` set), or an AACS-encrypted disc with no + // usable key (would mux ~100 MB of garbage — encrypted m2ts → no TS + // syncs → demuxer emits nothing → empty/garbage output at exit 0). + // `--raw` and unencrypted/CSS-keyless-success discs pass. This is the + // disc-wide check; the per-title (multi-VTS CSS) check is below, once + // the chosen title's key is resolved. + disc.ensure_decryptable(opts.raw) + .map_err(|e| -> io::Error { e.into() })?; + if disc.titles.is_empty() { + return Err(crate::error::Error::NoStreams.into()); + } + let idx = opts.title_index.unwrap_or(0); + if idx >= disc.titles.len() { + return Err(crate::error::Error::DiscTitleRange { + index: idx, + count: disc.titles.len(), + } + .into()); + } + // Prune to the selected audio/subtitle streams now, on the scanned + // (pre-`probe_and_remap`) title, so everything downstream — the + // TrueHD channel-correction probe, the final title clone, and + // `build_iso_pipeline`'s demux/track construction — sees the pruned + // list. Video is always kept; a no-op for the default All/All. + opts.selection + .apply(&mut disc.titles[idx]) + .map_err(|e| -> io::Error { e.into() })?; + // Per-title key resolution. DVD CSS is resolved at exactly ONE site — + // `build_iso_pipeline`'s per-title crack (below), which decrypts a + // crackable title, passes a genuinely-clear one through, and + // hard-fails an uncrackable one with CssKeyMissing. So for a DVD we do + // NOT pre-crack here: pass `None` and let the pipeline own it. + // Pre-cracking would re-open the ISO and re-scan every clear title + // (`decrypt_keys_for_title` → None → the pipeline re-cracks anyway). + // AACS / unencrypted resolve from `decrypt_keys()` with NO read; `--raw` + // (any format) is deliberate ciphertext passthrough — also `None`. + let is_dvd = disc.format == crate::disc::DiscFormat::Dvd; + let (keys, title_is_clear) = if opts.raw || is_dvd { + (crate::decrypt::DecryptKeys::None, false) + } else { + (disc.decrypt_keys(), false) + }; + // Decrypt gate for the AACS / non-DVD path: a None key means no usable + // disc key, which would mux scrambled ciphertext verbatim — fail loudly + // (NoDiscKey). The DVD path is gated inside `build_iso_pipeline` (its + // CSS hard-fail), and `--raw` passes. + if !is_dvd { + disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear) + .map_err(|e| -> io::Error { e.into() })?; + } + // FMTS (AACS 2.1) forensic segments are sourced + fail-loud-checked + // downstream by `resolve_mux_key_map`/`resolve_fmts_key_map`, which hold + // the key-fetch closure and can actually attempt resolution. (An older + // upfront blanket-reject gate lived here; it predated the resolver and + // rejected every 2.1 disc before a source could be tried.) + // Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1) + // by probing the first DECRYPTED access units of the chosen title. + // A fresh reader avoids disturbing the mux reader below. Skipped in + // --raw mode: the probe would re-open + decrypt for nothing (on an + // AACS disc with no key the correction is a no-op on ciphertext, and + // raw output isn't decoded anyway). + if !opts.raw { + match reopen() { + Some(mut probe) => { + // The probe DECRYPTS the title head, so it needs the SAME + // up-front key map the mux read installs below. An AACS + // `DecryptingSectorSource` with no map fails loud on the + // first unit (`decrypt_sectors_mapped` is the only AACS + // decrypt path) — without resolving one here the correction + // is silently skipped on every AACS disc and 7.1/Atmos stays + // understated as the MPLS-declared 5.1. Resolution failure is + // non-fatal (`.ok()`): leave channels uncorrected, never + // fail the mux. + let mut probe_keys = keys.clone(); + let probe_title = disc.titles[idx].clone(); + let probe_map = match &probe_keys { + crate::decrypt::DecryptKeys::Aacs { .. } => resolve_mux_key_map( + &mut probe, + &probe_title, + &mut probe_keys, + opts.key_fetch.as_ref(), + disc.content_format, + // File-backed, bounded probe (best-effort `.ok()`); + // no live drive to protect from a stuck stop here. + None, + ) + .ok() + .map(std::sync::Arc::new), + _ => None, + }; + let mut dec = crate::sector::DecryptingSectorSource::new(probe, probe_keys); + if let Some(map) = probe_map { + dec = dec.with_key_map(map); + } + crate::disc::correct_truehd_channels(&mut dec, &mut disc.titles[idx]); + } + None => { + // Non-fatal: a failed re-open just leaves MPLS 7.1/Atmos + // channel counts uncorrected (understated as 5.1). Log so + // the uncorrected path is diagnosable rather than silent. + tracing::debug!( + target: "mux", + "TrueHD channel-correction probe re-open failed" + ); + } + } + } + let title = disc.titles[idx].clone(); + let format = disc.content_format; + // ISO file: 8192-sector batch (16 MiB at 2048 B/sector) — + // sequential read from fast storage, no bad sectors. Empirically + // optimal; bumping to 16384 sectors (32 MiB) regressed (more cache + // pressure, longer per-batch latency starves the consumer between + // iterations). Physical drives keep smaller batches for adaptive + // error handling. + const ISO_MUX_BATCH_SECTORS: u16 = 8192; + + // Pass `DecryptKeys::None` to the decrypt decorator when + // --raw is set — the read stack still flows through the + // same producer+demux+parse pipeline, just without the + // AACS / CSS step. Single highway for all ISO reads. + let effective_keys = if opts.raw { + crate::decrypt::DecryptKeys::None + } else { + keys + }; + // Install the shared fetch closure (if the app supplied one) so a + // unit no held key decrypts is re-tried via the app's key source. + // Suppressed in --raw (no decrypt step to recover). + let fetch = if opts.raw { + None + } else { + opts.key_fetch.clone() + }; + let stream = build_iso_pipeline( + reader, + title, + effective_keys, + ISO_MUX_BATCH_SECTORS, + format, + opts.raw, + None, + None, + fetch, + )?; + Ok(stream) +} + /// Open a PES output stream (consumes PES frames). /// /// `source` is the provenance of the material being written — the INPUT the @@ -2547,9 +2605,12 @@ mod tests { } /// `dir://PATH/` parses to `StreamUrl::Dir` with the raw remainder as the - /// path; it is a SINK (not a disc source), so `is_disc_source()` is false. + /// path. Unlike the other directory schemes it IS an image-level source + /// (1.6.1): `crate::dirimage` synthesizes a UDF volume over the folder, so + /// `is_disc_source()` — "has a filesystem to scan" — is true for it and + /// false for the write-only `demux://` / `fvi://` directory sinks. #[test] - fn parse_dir_url_is_sink_not_disc_source() { + fn parse_dir_url_is_an_image_source_unlike_the_directory_sinks() { match parse_url("dir://out/movie/") { StreamUrl::Dir { path } => { assert_eq!(path, PathBuf::from("out/movie/")); @@ -2565,8 +2626,8 @@ mod tests { "demux:// is a sink, never a disc source" ); assert!( - !parse_url("dir://x").is_disc_source(), - "dir:// is a sink, never a disc source" + parse_url("dir://x").is_disc_source(), + "dir:// carries a filesystem, so selection flags and image sinks apply" ); // fvi:// parses to Fvi with the raw remainder as the path, and is a // sink (never a disc source) — parallel to the demux:// coverage above. @@ -2594,15 +2655,19 @@ mod tests { ); } - /// `dir://` is output-only: `input()` rejects it (StreamWriteOnly → - /// Unsupported), and `output()` rejects it too (StreamReadOnly → - /// Unsupported) because it is NOT a PES sink — the CLI routes it to - /// `Disc::extract_tree` before the mux path. + /// `dir://` is never a PES SINK — it writes raw decrypted files, not muxed + /// frames, so `output()` still rejects it (StreamReadOnly → Unsupported) + /// and the CLI routes a `dir://` dest to `Disc::extract_tree`. + /// + /// As a SOURCE it is no longer rejected out of hand: it is an image source, + /// and a missing folder now fails as a missing folder (NotFound) rather + /// than as "this scheme cannot be read". #[test] - fn dir_url_is_not_a_pes_stream_either_direction() { + fn dir_url_is_an_input_but_never_a_pes_sink() { assert_eq!( - input_err_kind("dir://out/"), - std::io::ErrorKind::Unsupported + input_err_kind("dir://definitely/not/here/"), + std::io::ErrorKind::NotFound, + "a dir:// source that does not exist must report a missing path" ); let t = DiscTitle::empty(); assert_eq!( diff --git a/src/session.rs b/src/session.rs index e0e72fe..d24107e 100644 --- a/src/session.rs +++ b/src/session.rs @@ -439,6 +439,101 @@ pub fn scan_iso(path: &Path, opts: ScanOptions) -> Result<(Disc, Box Result<(Disc, Box)> { + let mut reader = crate::dirimage::DirImage::open(path)?; + let capacity = reader.capacity_sectors(); + let mut disc = Disc::scan_image(&mut reader, capacity, &opts)?; + + // `css.is_some()` is the DVD path, and that verdict came from actually + // cracking scrambled sectors — real evidence about content, not tree shape. + // Only the AACS-by-tree-shape verdict is re-judged here. + if disc.encrypted && disc.css.is_none() && disc.css_error.is_none() { + match probe_folder_encryption(&mut reader, &disc)? { + true => return Err(Error::DirImageEncrypted), + false => { + tracing::warn!( + target: "freemkv::scan", + phase = "scan_dir", + "folder carries an AACS directory but its sampled content units \ + are already in the clear; treating it as decrypted" + ); + disc.encrypted = false; + disc.aacs = None; + disc.aacs_error = None; + } + } + } + Ok((disc, Box::new(reader))) +} + +/// `true` when any sampled content unit still needs decryption. +/// +/// Anchored at the largest title's first extent, because AACS unit alignment +/// is measured from the clip FILE's start (`aacs::content::is_unit_aligned`), +/// not from an absolute `lba % 3` — sampling off a boundary would mis-judge a +/// perfectly clear unit. +fn probe_folder_encryption(reader: &mut dyn SectorSource, disc: &Disc) -> Result { + use crate::aacs::content::{aacs_unit_needs_decrypt, is_unit_aligned}; + use crate::consts::SECTOR_BYTES; + + const UNIT_SECTORS: u32 = 3; + let Some(extent) = disc + .titles + .iter() + .flat_map(|t| t.extents.iter()) + .max_by_key(|e| e.sector_count) + else { + // No content to judge. A folder with an AACS directory and no titles + // has nothing to rip either way; leave the structural verdict alone. + return Ok(true); + }; + let base = extent.start_lba; + let mut unit = vec![0u8; UNIT_SECTORS as usize * SECTOR_BYTES]; + for i in 0..AACS_PROBE_UNITS as u32 { + let lba = base + i * UNIT_SECTORS; + if lba + UNIT_SECTORS > base + extent.sector_count { + break; + } + debug_assert!(is_unit_aligned(lba, base)); + reader.read_sectors(lba, UNIT_SECTORS as u16, &mut unit, false)?; + if aacs_unit_needs_decrypt(&unit, disc.content_format) { + return Ok(true); + } + } + Ok(false) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/udf.rs b/src/udf.rs index 932943b..a5533d2 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -1553,6 +1553,14 @@ fn parse_dstring(data: &[u8]) -> String { } } +/// Test-only view of [`parse_dstring`], so the `dirimage` encoder can assert +/// that the d-strings it writes are the ones this parser reads back rather +/// than re-implementing the decode in its own tests. +#[cfg(test)] +pub(crate) fn parse_dstring_for_test(data: &[u8]) -> String { + parse_dstring(data) +} + /// Buffered sector reader — reduces SCSI round-trips by coalescing /// single-sector reads into `batch`-sized SCSI commands. Per-command /// latency dominates on USB drives, so serving many adjacent single-sector