Read a disc folder as an input: dir:// becomes a source
Users keep discs as extracted folders — a DVD VIDEO_TS or a Blu-ray BDMV, usually a backup that is already decrypted. dir:// could only ever be a destination, so those folders could be produced and never read back. Everything above the sector layer wants a UdfFs over a SectorSource, and every UdfFs read re-reads the ICB off that source at call time, so a folder has to present itself as sectors. It does: dirimage plans a block layout over the real files, encodes a UDF 1.02 filesystem for the metadata, and serves data straight from disk. read_filesystem then parses it exactly as it parses a disc, so nothing above changes — and the iso:// arm of input() is now shared rather than duplicated, so dir:// inherits its decrypt gates, title selection and stream pruning. The encoder is validated by more than its own reader: macOS mounts the synthesized image and the mounted files compare byte-identical to the originals. A round-trip through our own parser could not have shown that — the tag CRC seeds at zero, and a wrong seed would satisfy us and no real driver. DVD placement is not free packing: a VTS IFO records where its title VOBS begins relative to itself, so the VOB has to land exactly there. Unsatisfiable marks fail loudly rather than misplace the file. 3D folders are refused for now: the scanner detects SSIF and the planner cannot alias its extents yet, so accepting them would produce quiet nonsense. Left for later: metadata capture, HD-DVD, FMTS, encrypted folders.
This commit is contained in:
@@ -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<u32, Box<[u8; SECTOR]>>;
|
||||
|
||||
/// 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<u8> {
|
||||
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<Box<[u8; SECTOR]>> {
|
||||
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<u8>, name: &str, icb_lba: u32, is_dir: bool, is_parent: bool) {
|
||||
let start = buf.len();
|
||||
let name_field: Vec<u8> = 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<u8> {
|
||||
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<MetaSectors> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<Extent>,
|
||||
}
|
||||
|
||||
/// 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<DirNode>,
|
||||
pub(super) files: Vec<FileNode>,
|
||||
}
|
||||
|
||||
/// 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<DirNode> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(Error::DirImageTooLarge);
|
||||
}
|
||||
let mut dirs = Vec::new();
|
||||
let mut files = Vec::new();
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
|
||||
let mut read: Vec<_> = std::fs::read_dir(dir)
|
||||
.map_err(Error::from)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.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<u32> {
|
||||
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<u32> {
|
||||
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<u8> {
|
||||
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<u32> {
|
||||
let mut order: Vec<usize> = (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<u32, u32> = std::collections::HashMap::new();
|
||||
let mut title_req: std::collections::HashMap<u32, u32> = 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<Class> {
|
||||
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<Layout> {
|
||||
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::<u64>(),
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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<DataRange>,
|
||||
files: Vec<FileRef>,
|
||||
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<Self> {
|
||||
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<usize> {
|
||||
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;
|
||||
@@ -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<u8> {
|
||||
(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<u8>, Vec<u8>) {
|
||||
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::<u64>(),
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user