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"
|
||||
);
|
||||
}
|
||||
@@ -170,6 +170,31 @@ pub const E_DIR_INSUFFICIENT_SPACE: u16 = 9027;
|
||||
pub const E_DIR_NAME_COLLISION: u16 = 9028;
|
||||
/// A `dir://` create_dir_all / file write / rename failed.
|
||||
pub const E_DIR_WRITE_FAILED: u16 = 9029;
|
||||
/// A `dir://` SOURCE folder carries `BDMV/STREAM/SSIF/` (Blu-ray 3D). The
|
||||
/// scanner detects SSIF unconditionally and would rip it as 3D, but the
|
||||
/// synthetic-image planner has no extent-aliasing support (an SSIF interleaves
|
||||
/// the same sectors as the base/dependent `.m2ts`), so the output would be
|
||||
/// silently wrong. Rejected up front instead.
|
||||
pub const E_DIR_IMAGE_SSIF_UNSUPPORTED: u16 = 9061;
|
||||
/// A `dir://` SOURCE `VIDEO_TS` folder's IFO-declared VOB offsets cannot be
|
||||
/// satisfied by any placement: the required start sector of a VOB lies BELOW
|
||||
/// the end of the file that must precede it. Carries the offending file's disc
|
||||
/// path. A silently misplaced VOB would rip the wrong sectors.
|
||||
pub const E_DIR_IMAGE_PLACEMENT: u16 = 9062;
|
||||
/// A `dir://` SOURCE folder still carries live AACS-encrypted content: it has
|
||||
/// an `AACS/` directory AND the sampled content units are genuinely scrambled.
|
||||
/// `dir://` sources are decrypted-folder only.
|
||||
pub const E_DIR_IMAGE_ENCRYPTED: u16 = 9063;
|
||||
/// A `dir://` SOURCE folder holds no disc structure the image synthesizer
|
||||
/// understands (no `BDMV/`, no `VIDEO_TS/`).
|
||||
pub const E_DIR_IMAGE_UNSUPPORTED_TREE: u16 = 9064;
|
||||
/// A file inside a `dir://` SOURCE folder changed (shrank / was removed)
|
||||
/// between planning and reading. Zero-filling the gap would produce corrupt
|
||||
/// output at exit 0, so the read fails instead. Carries the disc path.
|
||||
pub const E_DIR_IMAGE_FILE_CHANGED: u16 = 9065;
|
||||
/// A `dir://` SOURCE folder does not fit a 32-bit sector address space
|
||||
/// (> 2^32 sectors ≈ 8 TiB), or holds more entries than a UDF tree can carry.
|
||||
pub const E_DIR_IMAGE_TOO_LARGE: u16 = 9066;
|
||||
pub const E_M2TS_PACKET_MALFORMED: u16 = 9021;
|
||||
/// A `network://` output target resolved to no address that is safe to
|
||||
/// connect to (every resolved IP was loopback / private / link-local /
|
||||
@@ -733,6 +758,25 @@ pub enum Error {
|
||||
DirWriteFailed {
|
||||
errno: Option<i32>,
|
||||
},
|
||||
/// A `dir://` SOURCE folder carries `BDMV/STREAM/SSIF/` (Blu-ray 3D),
|
||||
/// which the synthetic-image planner cannot represent. See
|
||||
/// [`E_DIR_IMAGE_SSIF_UNSUPPORTED`].
|
||||
DirImageSsifUnsupported,
|
||||
/// A `dir://` SOURCE `VIDEO_TS` placement constraint is unsatisfiable.
|
||||
/// `path` is the disc path of the file that could not be placed.
|
||||
DirImagePlacement {
|
||||
path: String,
|
||||
},
|
||||
/// A `dir://` SOURCE folder still carries live AACS-encrypted content.
|
||||
DirImageEncrypted,
|
||||
/// A `dir://` SOURCE folder holds no recognized disc structure.
|
||||
DirImageUnsupportedTree,
|
||||
/// A file in a `dir://` SOURCE folder changed between plan and read.
|
||||
DirImageFileChanged {
|
||||
path: String,
|
||||
},
|
||||
/// A `dir://` SOURCE folder exceeds the addressable image size.
|
||||
DirImageTooLarge,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
@@ -850,6 +894,12 @@ impl Error {
|
||||
Error::DirInsufficientSpace { .. } => E_DIR_INSUFFICIENT_SPACE,
|
||||
Error::DirNameCollision { .. } => E_DIR_NAME_COLLISION,
|
||||
Error::DirWriteFailed { .. } => E_DIR_WRITE_FAILED,
|
||||
Error::DirImageSsifUnsupported => E_DIR_IMAGE_SSIF_UNSUPPORTED,
|
||||
Error::DirImagePlacement { .. } => E_DIR_IMAGE_PLACEMENT,
|
||||
Error::DirImageEncrypted => E_DIR_IMAGE_ENCRYPTED,
|
||||
Error::DirImageUnsupportedTree => E_DIR_IMAGE_UNSUPPORTED_TREE,
|
||||
Error::DirImageFileChanged { .. } => E_DIR_IMAGE_FILE_CHANGED,
|
||||
Error::DirImageTooLarge => E_DIR_IMAGE_TOO_LARGE,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -956,6 +1006,9 @@ impl std::fmt::Display for Error {
|
||||
},
|
||||
Error::Halted => write!(f, "E{}", self.code()),
|
||||
Error::UdfNotFound { path } => write!(f, "E{}: {}", self.code(), path),
|
||||
Error::DirImagePlacement { path } | Error::DirImageFileChanged { path } => {
|
||||
write!(f, "E{}: {}", self.code(), path)
|
||||
}
|
||||
Error::DiscTitleRange { index, count } => {
|
||||
write!(f, "E{}: {}/{}", self.code(), index, count)
|
||||
}
|
||||
@@ -1107,6 +1160,18 @@ impl From<Error> for std::io::Error {
|
||||
// 9027 insufficient space / 9029 write failed: a filesystem-level
|
||||
// failure, not bad input.
|
||||
E_DIR_INSUFFICIENT_SPACE | E_DIR_WRITE_FAILED => std::io::ErrorKind::Other,
|
||||
// dir:// SOURCE gates (9061–9064, 9066): the folder handed in cannot
|
||||
// be turned into a disc image — a 3D SSIF tree, an unsatisfiable
|
||||
// VIDEO_TS placement, still-encrypted content, an unrecognized tree,
|
||||
// or one too large to address. All are properties of the input.
|
||||
E_DIR_IMAGE_SSIF_UNSUPPORTED
|
||||
| E_DIR_IMAGE_PLACEMENT
|
||||
| E_DIR_IMAGE_ENCRYPTED
|
||||
| E_DIR_IMAGE_UNSUPPORTED_TREE
|
||||
| E_DIR_IMAGE_TOO_LARGE => std::io::ErrorKind::InvalidInput,
|
||||
// 9065: the folder changed underneath a running read. Not bad input
|
||||
// at plan time — a mid-flight mutation of the source.
|
||||
E_DIR_IMAGE_FILE_CHANGED => std::io::ErrorKind::InvalidData,
|
||||
_ => std::io::ErrorKind::Other,
|
||||
};
|
||||
std::io::Error::new(kind, msg)
|
||||
@@ -1445,6 +1510,12 @@ mod tests {
|
||||
.code(),
|
||||
Error::DirNameCollision { host: "x".into() }.code(),
|
||||
Error::DirWriteFailed { errno: Some(28) }.code(),
|
||||
Error::DirImageSsifUnsupported.code(),
|
||||
Error::DirImagePlacement { path: "x".into() }.code(),
|
||||
Error::DirImageEncrypted.code(),
|
||||
Error::DirImageUnsupportedTree.code(),
|
||||
Error::DirImageFileChanged { path: "x".into() }.code(),
|
||||
Error::DirImageTooLarge.code(),
|
||||
];
|
||||
let mut sorted = codes.to_vec();
|
||||
sorted.sort();
|
||||
@@ -1746,6 +1817,12 @@ mod tests {
|
||||
E_M2TS_PACKET_MALFORMED,
|
||||
E_EXTENT_NOT_UNIT_ALIGNED,
|
||||
E_DISC_CAPACITY_MALFORMED,
|
||||
E_DIR_IMAGE_SSIF_UNSUPPORTED,
|
||||
E_DIR_IMAGE_PLACEMENT,
|
||||
E_DIR_IMAGE_ENCRYPTED,
|
||||
E_DIR_IMAGE_UNSUPPORTED_TREE,
|
||||
E_DIR_IMAGE_FILE_CHANGED,
|
||||
E_DIR_IMAGE_TOO_LARGE,
|
||||
];
|
||||
let original_len = codes.len();
|
||||
codes.sort();
|
||||
|
||||
+4
-1
@@ -106,6 +106,7 @@ pub mod consts;
|
||||
pub mod css;
|
||||
pub mod decrypt;
|
||||
pub mod diag;
|
||||
pub mod dirimage;
|
||||
pub mod disc;
|
||||
pub mod drive;
|
||||
pub mod dvdnav;
|
||||
@@ -148,7 +149,8 @@ pub use drive::{Drive, DriveStatus, extract_scsi_context, find_drive};
|
||||
// Owns the `Drive` by value; forwards consumer-built key material into
|
||||
// `ScanOptions` (the library derives no certs — see `KeySpec`).
|
||||
pub use session::{
|
||||
DeviceTarget, DiscSession, KeySourceFactory, KeySpec, ResolvedKeys, resolve_keys_for, scan_iso,
|
||||
DeviceTarget, DiscSession, KeySourceFactory, KeySpec, ResolvedKeys, resolve_keys_for, scan_dir,
|
||||
scan_iso,
|
||||
};
|
||||
|
||||
// ─── Errors ─────────────────────────────────────────────────────────────────
|
||||
@@ -228,6 +230,7 @@ pub use decrypt::{AacsKeyMap, DecryptKeys, decrypt_sectors, decrypt_threads, set
|
||||
// — not the `pes::Stream` trait re-exported below as `PesStream`. Two
|
||||
// different concepts, the same short name; the trait gets the `Pes`
|
||||
// prefix at the crate root to keep both addressable.
|
||||
pub use dirimage::DirImage;
|
||||
pub use disc::{
|
||||
AacsState, AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, Disc,
|
||||
DiscFormat, DiscId, DiscTitle, DriveCredentials, Extent, ExtractOptions, ExtractResult,
|
||||
|
||||
+249
-184
@@ -55,9 +55,15 @@ pub enum StreamUrl {
|
||||
Stdio,
|
||||
/// ISO disc image file.
|
||||
Iso { path: PathBuf },
|
||||
/// Decrypted file-tree output directory (`dir://`). A sink that writes
|
||||
/// per-file decrypted bytes (not muxed PES frames), so it never flows
|
||||
/// through `output()`; the CLI routes a `Dir` dest to `Disc::extract_tree`.
|
||||
/// An extracted disc file tree (`dir://`) — a source AND a sink.
|
||||
///
|
||||
/// As a SINK it writes per-file decrypted bytes rather than muxed PES
|
||||
/// frames, so it never flows through `output()`; the CLI routes a `Dir`
|
||||
/// dest to `Disc::extract_tree`.
|
||||
///
|
||||
/// As a SOURCE (1.6.1) it is an image-level source: `crate::dirimage`
|
||||
/// synthesizes a real UDF volume over the folder, so it reaches the same
|
||||
/// scan/mux path `iso://` does and every destination follows.
|
||||
Dir { path: PathBuf },
|
||||
/// Null sink (write-only, discards data).
|
||||
Null,
|
||||
@@ -142,9 +148,22 @@ impl StreamUrl {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this URL represents a disc source (disc:// or iso://).
|
||||
/// Whether this URL is an IMAGE-level source — one that carries a UDF
|
||||
/// filesystem, so it can be scanned into a title list, have `-t`/`-a`/`-s`
|
||||
/// applied, and feed either a PES sink or an image sink.
|
||||
///
|
||||
/// `dir://` joined in 1.6.1, when `crate::dirimage` gave a folder a real
|
||||
/// synthesized UDF volume.
|
||||
///
|
||||
/// NOT the same predicate as the CLI's `engine::is_disc_source`, which
|
||||
/// means "is a live drive" and drives tray/eject behaviour. That one must
|
||||
/// never gain `Dir` — a directory routed down the live-drive rip path
|
||||
/// would open, lock and eject a drive that has nothing to do with it.
|
||||
pub fn is_disc_source(&self) -> bool {
|
||||
matches!(self, StreamUrl::Disc { .. } | StreamUrl::Iso { .. })
|
||||
matches!(
|
||||
self,
|
||||
StreamUrl::Disc { .. } | StreamUrl::Iso { .. } | StreamUrl::Dir { .. }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,171 +390,27 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
// (so the kernel readahead window widens) and the periodic
|
||||
// DONTNEED page-cache eviction that bounds memory pressure
|
||||
// when the mux output is being written to the same disk.
|
||||
let mut reader = crate::io::file_sector_source::FileSectorSource::open(path)?;
|
||||
let capacity = reader.capacity_sectors();
|
||||
let mut disc = crate::disc::Disc::scan_image(
|
||||
&mut reader,
|
||||
capacity,
|
||||
&crate::disc::ScanOptions::default(),
|
||||
)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
// Apply the caller-resolved keys (lookup-free); decrypt_keys() then
|
||||
// yields them for the stream below. Propagate a failed application
|
||||
// rather than silently muxing an undecryptable stream.
|
||||
if !opts.unit_keys.is_empty() {
|
||||
// These UKs were already resolved AND validated by the caller
|
||||
// (the CLI's keydb loop), so no re-validation sample is needed.
|
||||
disc.decrypt_with(crate::disc::Key::Unit(opts.unit_keys.clone()), &[])
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
}
|
||||
// Pre-flight decrypt gate (the single, system-wide verdict — see
|
||||
// `Disc::ensure_decryptable`). Fails fast BEFORE any mux work when
|
||||
// decryption is needed and unavailable: a scrambled-but-uncracked
|
||||
// CSS disc (`css_error` set), or an AACS-encrypted disc with no
|
||||
// usable key (would mux ~100 MB of garbage — encrypted m2ts → no TS
|
||||
// syncs → demuxer emits nothing → empty/garbage output at exit 0).
|
||||
// `--raw` and unencrypted/CSS-keyless-success discs pass. This is the
|
||||
// disc-wide check; the per-title (multi-VTS CSS) check is below, once
|
||||
// the chosen title's key is resolved.
|
||||
disc.ensure_decryptable(opts.raw)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
if disc.titles.is_empty() {
|
||||
return Err(crate::error::Error::NoStreams.into());
|
||||
}
|
||||
let idx = opts.title_index.unwrap_or(0);
|
||||
if idx >= disc.titles.len() {
|
||||
return Err(crate::error::Error::DiscTitleRange {
|
||||
index: idx,
|
||||
count: disc.titles.len(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
// Prune to the selected audio/subtitle streams now, on the scanned
|
||||
// (pre-`probe_and_remap`) title, so everything downstream — the
|
||||
// TrueHD channel-correction probe, the final title clone, and
|
||||
// `build_iso_pipeline`'s demux/track construction — sees the pruned
|
||||
// list. Video is always kept; a no-op for the default All/All.
|
||||
opts.selection
|
||||
.apply(&mut disc.titles[idx])
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
// Per-title key resolution. DVD CSS is resolved at exactly ONE site —
|
||||
// `build_iso_pipeline`'s per-title crack (below), which decrypts a
|
||||
// crackable title, passes a genuinely-clear one through, and
|
||||
// hard-fails an uncrackable one with CssKeyMissing. So for a DVD we do
|
||||
// NOT pre-crack here: pass `None` and let the pipeline own it.
|
||||
// Pre-cracking would re-open the ISO and re-scan every clear title
|
||||
// (`decrypt_keys_for_title` → None → the pipeline re-cracks anyway).
|
||||
// AACS / unencrypted resolve from `decrypt_keys()` with NO read; `--raw`
|
||||
// (any format) is deliberate ciphertext passthrough — also `None`.
|
||||
let is_dvd = disc.format == crate::disc::DiscFormat::Dvd;
|
||||
let (keys, title_is_clear) = if opts.raw || is_dvd {
|
||||
(crate::decrypt::DecryptKeys::None, false)
|
||||
} else {
|
||||
(disc.decrypt_keys(), false)
|
||||
};
|
||||
// Decrypt gate for the AACS / non-DVD path: a None key means no usable
|
||||
// disc key, which would mux scrambled ciphertext verbatim — fail loudly
|
||||
// (NoDiscKey). The DVD path is gated inside `build_iso_pipeline` (its
|
||||
// CSS hard-fail), and `--raw` passes.
|
||||
if !is_dvd {
|
||||
disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
}
|
||||
// FMTS (AACS 2.1) forensic segments are sourced + fail-loud-checked
|
||||
// downstream by `resolve_mux_key_map`/`resolve_fmts_key_map`, which hold
|
||||
// the key-fetch closure and can actually attempt resolution. (An older
|
||||
// upfront blanket-reject gate lived here; it predated the resolver and
|
||||
// rejected every 2.1 disc before a source could be tried.)
|
||||
// Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1)
|
||||
// by probing the first DECRYPTED access units of the chosen title.
|
||||
// A fresh reader avoids disturbing the mux reader below. Skipped in
|
||||
// --raw mode: the probe would re-open + decrypt for nothing (on an
|
||||
// AACS disc with no key the correction is a no-op on ciphertext, and
|
||||
// raw output isn't decoded anyway).
|
||||
if !opts.raw {
|
||||
match crate::io::file_sector_source::FileSectorSource::open(path) {
|
||||
Ok(mut probe) => {
|
||||
// The probe DECRYPTS the title head, so it needs the SAME
|
||||
// up-front key map the mux read installs below. An AACS
|
||||
// `DecryptingSectorSource` with no map fails loud on the
|
||||
// first unit (`decrypt_sectors_mapped` is the only AACS
|
||||
// decrypt path) — without resolving one here the correction
|
||||
// is silently skipped on every AACS disc and 7.1/Atmos stays
|
||||
// understated as the MPLS-declared 5.1. Resolution failure is
|
||||
// non-fatal (`.ok()`): leave channels uncorrected, never
|
||||
// fail the mux.
|
||||
let mut probe_keys = keys.clone();
|
||||
let probe_title = disc.titles[idx].clone();
|
||||
let probe_map = match &probe_keys {
|
||||
crate::decrypt::DecryptKeys::Aacs { .. } => resolve_mux_key_map(
|
||||
&mut probe,
|
||||
&probe_title,
|
||||
&mut probe_keys,
|
||||
opts.key_fetch.as_ref(),
|
||||
disc.content_format,
|
||||
// File-backed, bounded probe (best-effort `.ok()`);
|
||||
// no live drive to protect from a stuck stop here.
|
||||
None,
|
||||
)
|
||||
.ok()
|
||||
.map(std::sync::Arc::new),
|
||||
_ => None,
|
||||
};
|
||||
let mut dec = crate::sector::DecryptingSectorSource::new(probe, probe_keys);
|
||||
if let Some(map) = probe_map {
|
||||
dec = dec.with_key_map(map);
|
||||
}
|
||||
crate::disc::correct_truehd_channels(&mut dec, &mut disc.titles[idx]);
|
||||
}
|
||||
Err(e) => {
|
||||
// Non-fatal: a failed re-open just leaves MPLS 7.1/Atmos
|
||||
// channel counts uncorrected (understated as 5.1). Log so
|
||||
// the uncorrected path is diagnosable rather than silent.
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"TrueHD channel-correction probe re-open failed: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let title = disc.titles[idx].clone();
|
||||
let format = disc.content_format;
|
||||
// ISO file: 8192-sector batch (16 MiB at 2048 B/sector) —
|
||||
// sequential read from fast storage, no bad sectors. Empirically
|
||||
// optimal; bumping to 16384 sectors (32 MiB) regressed (more cache
|
||||
// pressure, longer per-batch latency starves the consumer between
|
||||
// iterations). Physical drives keep smaller batches for adaptive
|
||||
// error handling.
|
||||
const ISO_MUX_BATCH_SECTORS: u16 = 8192;
|
||||
|
||||
// Pass `DecryptKeys::None` to the decrypt decorator when
|
||||
// --raw is set — the read stack still flows through the
|
||||
// same producer+demux+parse pipeline, just without the
|
||||
// AACS / CSS step. Single highway for all ISO reads.
|
||||
let effective_keys = if opts.raw {
|
||||
crate::decrypt::DecryptKeys::None
|
||||
} else {
|
||||
keys
|
||||
};
|
||||
// Install the shared fetch closure (if the app supplied one) so a
|
||||
// unit no held key decrypts is re-tried via the app's key source.
|
||||
// Suppressed in --raw (no decrypt step to recover).
|
||||
let fetch = if opts.raw {
|
||||
None
|
||||
} else {
|
||||
opts.key_fetch.clone()
|
||||
};
|
||||
let stream = build_iso_pipeline(
|
||||
reader,
|
||||
title,
|
||||
effective_keys,
|
||||
ISO_MUX_BATCH_SECTORS,
|
||||
format,
|
||||
opts.raw,
|
||||
None,
|
||||
None,
|
||||
fetch,
|
||||
)?;
|
||||
let reader = crate::io::file_sector_source::FileSectorSource::open(path)?;
|
||||
let probe_path = path.clone();
|
||||
let stream = image_input(reader, opts, move || {
|
||||
crate::io::file_sector_source::FileSectorSource::open(&probe_path).ok()
|
||||
})?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
// `dir://` as a SOURCE: an extracted disc folder, presented as a
|
||||
// synthetic UDF image (`crate::dirimage`). It reaches EXACTLY the same
|
||||
// body as `iso://` above — scan, title select, key resolution, mux —
|
||||
// because by the time `DirImage` exists it is just another
|
||||
// `SectorSource`. That is the point of synthesizing a real filesystem
|
||||
// rather than faking a tree: every destination follows for free, with
|
||||
// no per-scheme mux path to keep in step.
|
||||
StreamUrl::Dir { ref path } => {
|
||||
validate_file_path(path, "dir")?;
|
||||
let reader = crate::dirimage::DirImage::open(path)?;
|
||||
let probe_path = path.clone();
|
||||
let stream = image_input(reader, opts, move || {
|
||||
crate::dirimage::DirImage::open(&probe_path).ok()
|
||||
})?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
StreamUrl::M2ts { ref path } => {
|
||||
@@ -556,9 +431,6 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
Ok(Box::new(NetworkStream::listen(addr)?))
|
||||
}
|
||||
StreamUrl::Stdio => Ok(Box::new(StdioStream::input())),
|
||||
// `dir://` is an output-only sink (decrypted file tree); it is never a
|
||||
// PES source. Mirror `null://` → write-only.
|
||||
StreamUrl::Dir { .. } => Err(crate::error::Error::StreamWriteOnly.into()),
|
||||
StreamUrl::Null => Err(crate::error::Error::StreamWriteOnly.into()),
|
||||
// `mp4://` as a source: demux a progressive MP4 back into PES frames, so
|
||||
// `mp4://` flows to every sink (mkv://, audio://, json://, …).
|
||||
@@ -578,6 +450,192 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared body of every IMAGE-level PES source.
|
||||
///
|
||||
/// `iso://` and `dir://` differ only in how the sectors are produced — a file
|
||||
/// versus a synthesized UDF volume over a folder — and not at all in what is
|
||||
/// done with them: scan, apply caller-resolved keys, gate on decryptability,
|
||||
/// select the title, prune streams, correct TrueHD channel counts, mux. One
|
||||
/// body means a `dir://` source cannot drift away from the `iso://` behaviour
|
||||
/// that has years of fixes in it.
|
||||
///
|
||||
/// `reopen` yields a SECOND, independent reader for the TrueHD channel probe,
|
||||
/// which must not disturb the mux reader's position. It returns `Option`
|
||||
/// because a failed re-open is non-fatal: the correction is skipped, not the
|
||||
/// mux.
|
||||
fn image_input<S, F>(
|
||||
mut reader: S,
|
||||
opts: &InputOptions,
|
||||
reopen: F,
|
||||
) -> io::Result<PipelinedPesStream>
|
||||
where
|
||||
S: SectorSource + Send + 'static,
|
||||
F: FnOnce() -> Option<S>,
|
||||
{
|
||||
let capacity = reader.capacity_sectors();
|
||||
let mut disc =
|
||||
crate::disc::Disc::scan_image(&mut reader, capacity, &crate::disc::ScanOptions::default())
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
// Apply the caller-resolved keys (lookup-free); decrypt_keys() then
|
||||
// yields them for the stream below. Propagate a failed application
|
||||
// rather than silently muxing an undecryptable stream.
|
||||
if !opts.unit_keys.is_empty() {
|
||||
// These UKs were already resolved AND validated by the caller
|
||||
// (the CLI's keydb loop), so no re-validation sample is needed.
|
||||
disc.decrypt_with(crate::disc::Key::Unit(opts.unit_keys.clone()), &[])
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
}
|
||||
// Pre-flight decrypt gate (the single, system-wide verdict — see
|
||||
// `Disc::ensure_decryptable`). Fails fast BEFORE any mux work when
|
||||
// decryption is needed and unavailable: a scrambled-but-uncracked
|
||||
// CSS disc (`css_error` set), or an AACS-encrypted disc with no
|
||||
// usable key (would mux ~100 MB of garbage — encrypted m2ts → no TS
|
||||
// syncs → demuxer emits nothing → empty/garbage output at exit 0).
|
||||
// `--raw` and unencrypted/CSS-keyless-success discs pass. This is the
|
||||
// disc-wide check; the per-title (multi-VTS CSS) check is below, once
|
||||
// the chosen title's key is resolved.
|
||||
disc.ensure_decryptable(opts.raw)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
if disc.titles.is_empty() {
|
||||
return Err(crate::error::Error::NoStreams.into());
|
||||
}
|
||||
let idx = opts.title_index.unwrap_or(0);
|
||||
if idx >= disc.titles.len() {
|
||||
return Err(crate::error::Error::DiscTitleRange {
|
||||
index: idx,
|
||||
count: disc.titles.len(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
// Prune to the selected audio/subtitle streams now, on the scanned
|
||||
// (pre-`probe_and_remap`) title, so everything downstream — the
|
||||
// TrueHD channel-correction probe, the final title clone, and
|
||||
// `build_iso_pipeline`'s demux/track construction — sees the pruned
|
||||
// list. Video is always kept; a no-op for the default All/All.
|
||||
opts.selection
|
||||
.apply(&mut disc.titles[idx])
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
// Per-title key resolution. DVD CSS is resolved at exactly ONE site —
|
||||
// `build_iso_pipeline`'s per-title crack (below), which decrypts a
|
||||
// crackable title, passes a genuinely-clear one through, and
|
||||
// hard-fails an uncrackable one with CssKeyMissing. So for a DVD we do
|
||||
// NOT pre-crack here: pass `None` and let the pipeline own it.
|
||||
// Pre-cracking would re-open the ISO and re-scan every clear title
|
||||
// (`decrypt_keys_for_title` → None → the pipeline re-cracks anyway).
|
||||
// AACS / unencrypted resolve from `decrypt_keys()` with NO read; `--raw`
|
||||
// (any format) is deliberate ciphertext passthrough — also `None`.
|
||||
let is_dvd = disc.format == crate::disc::DiscFormat::Dvd;
|
||||
let (keys, title_is_clear) = if opts.raw || is_dvd {
|
||||
(crate::decrypt::DecryptKeys::None, false)
|
||||
} else {
|
||||
(disc.decrypt_keys(), false)
|
||||
};
|
||||
// Decrypt gate for the AACS / non-DVD path: a None key means no usable
|
||||
// disc key, which would mux scrambled ciphertext verbatim — fail loudly
|
||||
// (NoDiscKey). The DVD path is gated inside `build_iso_pipeline` (its
|
||||
// CSS hard-fail), and `--raw` passes.
|
||||
if !is_dvd {
|
||||
disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear)
|
||||
.map_err(|e| -> io::Error { e.into() })?;
|
||||
}
|
||||
// FMTS (AACS 2.1) forensic segments are sourced + fail-loud-checked
|
||||
// downstream by `resolve_mux_key_map`/`resolve_fmts_key_map`, which hold
|
||||
// the key-fetch closure and can actually attempt resolution. (An older
|
||||
// upfront blanket-reject gate lived here; it predated the resolver and
|
||||
// rejected every 2.1 disc before a source could be tried.)
|
||||
// Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1)
|
||||
// by probing the first DECRYPTED access units of the chosen title.
|
||||
// A fresh reader avoids disturbing the mux reader below. Skipped in
|
||||
// --raw mode: the probe would re-open + decrypt for nothing (on an
|
||||
// AACS disc with no key the correction is a no-op on ciphertext, and
|
||||
// raw output isn't decoded anyway).
|
||||
if !opts.raw {
|
||||
match reopen() {
|
||||
Some(mut probe) => {
|
||||
// The probe DECRYPTS the title head, so it needs the SAME
|
||||
// up-front key map the mux read installs below. An AACS
|
||||
// `DecryptingSectorSource` with no map fails loud on the
|
||||
// first unit (`decrypt_sectors_mapped` is the only AACS
|
||||
// decrypt path) — without resolving one here the correction
|
||||
// is silently skipped on every AACS disc and 7.1/Atmos stays
|
||||
// understated as the MPLS-declared 5.1. Resolution failure is
|
||||
// non-fatal (`.ok()`): leave channels uncorrected, never
|
||||
// fail the mux.
|
||||
let mut probe_keys = keys.clone();
|
||||
let probe_title = disc.titles[idx].clone();
|
||||
let probe_map = match &probe_keys {
|
||||
crate::decrypt::DecryptKeys::Aacs { .. } => resolve_mux_key_map(
|
||||
&mut probe,
|
||||
&probe_title,
|
||||
&mut probe_keys,
|
||||
opts.key_fetch.as_ref(),
|
||||
disc.content_format,
|
||||
// File-backed, bounded probe (best-effort `.ok()`);
|
||||
// no live drive to protect from a stuck stop here.
|
||||
None,
|
||||
)
|
||||
.ok()
|
||||
.map(std::sync::Arc::new),
|
||||
_ => None,
|
||||
};
|
||||
let mut dec = crate::sector::DecryptingSectorSource::new(probe, probe_keys);
|
||||
if let Some(map) = probe_map {
|
||||
dec = dec.with_key_map(map);
|
||||
}
|
||||
crate::disc::correct_truehd_channels(&mut dec, &mut disc.titles[idx]);
|
||||
}
|
||||
None => {
|
||||
// Non-fatal: a failed re-open just leaves MPLS 7.1/Atmos
|
||||
// channel counts uncorrected (understated as 5.1). Log so
|
||||
// the uncorrected path is diagnosable rather than silent.
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"TrueHD channel-correction probe re-open failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let title = disc.titles[idx].clone();
|
||||
let format = disc.content_format;
|
||||
// ISO file: 8192-sector batch (16 MiB at 2048 B/sector) —
|
||||
// sequential read from fast storage, no bad sectors. Empirically
|
||||
// optimal; bumping to 16384 sectors (32 MiB) regressed (more cache
|
||||
// pressure, longer per-batch latency starves the consumer between
|
||||
// iterations). Physical drives keep smaller batches for adaptive
|
||||
// error handling.
|
||||
const ISO_MUX_BATCH_SECTORS: u16 = 8192;
|
||||
|
||||
// Pass `DecryptKeys::None` to the decrypt decorator when
|
||||
// --raw is set — the read stack still flows through the
|
||||
// same producer+demux+parse pipeline, just without the
|
||||
// AACS / CSS step. Single highway for all ISO reads.
|
||||
let effective_keys = if opts.raw {
|
||||
crate::decrypt::DecryptKeys::None
|
||||
} else {
|
||||
keys
|
||||
};
|
||||
// Install the shared fetch closure (if the app supplied one) so a
|
||||
// unit no held key decrypts is re-tried via the app's key source.
|
||||
// Suppressed in --raw (no decrypt step to recover).
|
||||
let fetch = if opts.raw {
|
||||
None
|
||||
} else {
|
||||
opts.key_fetch.clone()
|
||||
};
|
||||
let stream = build_iso_pipeline(
|
||||
reader,
|
||||
title,
|
||||
effective_keys,
|
||||
ISO_MUX_BATCH_SECTORS,
|
||||
format,
|
||||
opts.raw,
|
||||
None,
|
||||
None,
|
||||
fetch,
|
||||
)?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// Open a PES output stream (consumes PES frames).
|
||||
///
|
||||
/// `source` is the provenance of the material being written — the INPUT the
|
||||
@@ -2547,9 +2605,12 @@ mod tests {
|
||||
}
|
||||
|
||||
/// `dir://PATH/` parses to `StreamUrl::Dir` with the raw remainder as the
|
||||
/// path; it is a SINK (not a disc source), so `is_disc_source()` is false.
|
||||
/// path. Unlike the other directory schemes it IS an image-level source
|
||||
/// (1.6.1): `crate::dirimage` synthesizes a UDF volume over the folder, so
|
||||
/// `is_disc_source()` — "has a filesystem to scan" — is true for it and
|
||||
/// false for the write-only `demux://` / `fvi://` directory sinks.
|
||||
#[test]
|
||||
fn parse_dir_url_is_sink_not_disc_source() {
|
||||
fn parse_dir_url_is_an_image_source_unlike_the_directory_sinks() {
|
||||
match parse_url("dir://out/movie/") {
|
||||
StreamUrl::Dir { path } => {
|
||||
assert_eq!(path, PathBuf::from("out/movie/"));
|
||||
@@ -2565,8 +2626,8 @@ mod tests {
|
||||
"demux:// is a sink, never a disc source"
|
||||
);
|
||||
assert!(
|
||||
!parse_url("dir://x").is_disc_source(),
|
||||
"dir:// is a sink, never a disc source"
|
||||
parse_url("dir://x").is_disc_source(),
|
||||
"dir:// carries a filesystem, so selection flags and image sinks apply"
|
||||
);
|
||||
// fvi:// parses to Fvi with the raw remainder as the path, and is a
|
||||
// sink (never a disc source) — parallel to the demux:// coverage above.
|
||||
@@ -2594,15 +2655,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `dir://` is output-only: `input()` rejects it (StreamWriteOnly →
|
||||
/// Unsupported), and `output()` rejects it too (StreamReadOnly →
|
||||
/// Unsupported) because it is NOT a PES sink — the CLI routes it to
|
||||
/// `Disc::extract_tree` before the mux path.
|
||||
/// `dir://` is never a PES SINK — it writes raw decrypted files, not muxed
|
||||
/// frames, so `output()` still rejects it (StreamReadOnly → Unsupported)
|
||||
/// and the CLI routes a `dir://` dest to `Disc::extract_tree`.
|
||||
///
|
||||
/// As a SOURCE it is no longer rejected out of hand: it is an image source,
|
||||
/// and a missing folder now fails as a missing folder (NotFound) rather
|
||||
/// than as "this scheme cannot be read".
|
||||
#[test]
|
||||
fn dir_url_is_not_a_pes_stream_either_direction() {
|
||||
fn dir_url_is_an_input_but_never_a_pes_sink() {
|
||||
assert_eq!(
|
||||
input_err_kind("dir://out/"),
|
||||
std::io::ErrorKind::Unsupported
|
||||
input_err_kind("dir://definitely/not/here/"),
|
||||
std::io::ErrorKind::NotFound,
|
||||
"a dir:// source that does not exist must report a missing path"
|
||||
);
|
||||
let t = DiscTitle::empty();
|
||||
assert_eq!(
|
||||
|
||||
@@ -439,6 +439,101 @@ pub fn scan_iso(path: &Path, opts: ScanOptions) -> Result<(Disc, Box<dyn SectorS
|
||||
Ok((disc, Box::new(reader)))
|
||||
}
|
||||
|
||||
/// Sampled 6144-byte aligned units when deciding whether a folder that still
|
||||
/// carries `AACS/` actually holds encrypted content. Enough to survive a clip
|
||||
/// whose opening units happen to be unflagged (a clear leader), few enough to
|
||||
/// stay a handful of reads.
|
||||
const AACS_PROBE_UNITS: usize = 8;
|
||||
|
||||
/// [`Disc`] together with a [`SectorSource`] over a synthesized image of an
|
||||
/// extracted disc FOLDER — the `dir://` counterpart to [`scan_iso`].
|
||||
///
|
||||
/// The extra step over `scan_iso` is the encryption verdict.
|
||||
/// `Disc::scan_with` decides `encrypted` STRUCTURALLY, from the presence of an
|
||||
/// `/AACS` or `/BDMV/AACS` directory (`disc/mod.rs:1992-1993`). For the common
|
||||
/// case — a MakeMKV-style backup, which strips `AACS/` — that already gives the
|
||||
/// right answer, and `DecryptKeys::None` is a pass-through. But a folder copied
|
||||
/// verbatim from a decrypted disc keeps `AACS/`, and the tree shape then claims
|
||||
/// encryption over content that is already in the clear: the rip would fail
|
||||
/// asking for a key it does not need.
|
||||
///
|
||||
/// So for a folder, tree shape is not the evidence — CONTENT is. Several
|
||||
/// aligned units at the largest title's start are sampled and judged by
|
||||
/// `aacs_unit_needs_decrypt`, the same authority the mux read path uses:
|
||||
///
|
||||
/// * none need decryption → the folder is decrypted; `encrypted` is forced
|
||||
/// false and the reason is logged.
|
||||
/// * any unit does → the folder is a raw encrypted copy, which `dir://` does
|
||||
/// not support; [`Error::DirImageEncrypted`].
|
||||
///
|
||||
/// This lives HERE and not in `Disc::scan_image`, which is shared with the ISO
|
||||
/// and drive paths: an ISO that carries `AACS/` and clear content is a
|
||||
/// different situation (it may be mid-decrypt, or `--raw` output), and the
|
||||
/// verdict must not change underneath those callers.
|
||||
pub fn scan_dir(path: &Path, opts: ScanOptions) -> Result<(Disc, Box<dyn SectorSource>)> {
|
||||
let mut reader = crate::dirimage::DirImage::open(path)?;
|
||||
let capacity = reader.capacity_sectors();
|
||||
let mut disc = Disc::scan_image(&mut reader, capacity, &opts)?;
|
||||
|
||||
// `css.is_some()` is the DVD path, and that verdict came from actually
|
||||
// cracking scrambled sectors — real evidence about content, not tree shape.
|
||||
// Only the AACS-by-tree-shape verdict is re-judged here.
|
||||
if disc.encrypted && disc.css.is_none() && disc.css_error.is_none() {
|
||||
match probe_folder_encryption(&mut reader, &disc)? {
|
||||
true => return Err(Error::DirImageEncrypted),
|
||||
false => {
|
||||
tracing::warn!(
|
||||
target: "freemkv::scan",
|
||||
phase = "scan_dir",
|
||||
"folder carries an AACS directory but its sampled content units \
|
||||
are already in the clear; treating it as decrypted"
|
||||
);
|
||||
disc.encrypted = false;
|
||||
disc.aacs = None;
|
||||
disc.aacs_error = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((disc, Box::new(reader)))
|
||||
}
|
||||
|
||||
/// `true` when any sampled content unit still needs decryption.
|
||||
///
|
||||
/// Anchored at the largest title's first extent, because AACS unit alignment
|
||||
/// is measured from the clip FILE's start (`aacs::content::is_unit_aligned`),
|
||||
/// not from an absolute `lba % 3` — sampling off a boundary would mis-judge a
|
||||
/// perfectly clear unit.
|
||||
fn probe_folder_encryption(reader: &mut dyn SectorSource, disc: &Disc) -> Result<bool> {
|
||||
use crate::aacs::content::{aacs_unit_needs_decrypt, is_unit_aligned};
|
||||
use crate::consts::SECTOR_BYTES;
|
||||
|
||||
const UNIT_SECTORS: u32 = 3;
|
||||
let Some(extent) = disc
|
||||
.titles
|
||||
.iter()
|
||||
.flat_map(|t| t.extents.iter())
|
||||
.max_by_key(|e| e.sector_count)
|
||||
else {
|
||||
// No content to judge. A folder with an AACS directory and no titles
|
||||
// has nothing to rip either way; leave the structural verdict alone.
|
||||
return Ok(true);
|
||||
};
|
||||
let base = extent.start_lba;
|
||||
let mut unit = vec![0u8; UNIT_SECTORS as usize * SECTOR_BYTES];
|
||||
for i in 0..AACS_PROBE_UNITS as u32 {
|
||||
let lba = base + i * UNIT_SECTORS;
|
||||
if lba + UNIT_SECTORS > base + extent.sector_count {
|
||||
break;
|
||||
}
|
||||
debug_assert!(is_unit_aligned(lba, base));
|
||||
reader.read_sectors(lba, UNIT_SECTORS as u16, &mut unit, false)?;
|
||||
if aacs_unit_needs_decrypt(&unit, disc.content_format) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1553,6 +1553,14 @@ fn parse_dstring(data: &[u8]) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Test-only view of [`parse_dstring`], so the `dirimage` encoder can assert
|
||||
/// that the d-strings it writes are the ones this parser reads back rather
|
||||
/// than re-implementing the decode in its own tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parse_dstring_for_test(data: &[u8]) -> String {
|
||||
parse_dstring(data)
|
||||
}
|
||||
|
||||
/// Buffered sector reader — reduces SCSI round-trips by coalescing
|
||||
/// single-sector reads into `batch`-sized SCSI commands. Per-command
|
||||
/// latency dominates on USB drives, so serving many adjacent single-sector
|
||||
|
||||
Reference in New Issue
Block a user