The round-1 tests asserted arithmetic about MAX_CS0_NAME_BYTES and never called plan(), so both would have passed with the guard deleted — which is the failure mode this audit exists to catch, committed by the audit's own fix. They now build a real folder containing a 255-byte name and require the planner to refuse it, plus a companion proving a name at the cap is still accepted so the guard is not merely refusing everything. The subdirectory cap keeps its arithmetic-only test — creating 65,535 directories is not reasonable in a unit test — but now says so instead of implying coverage it does not have.
840 lines
32 KiB
Rust
840 lines
32 KiB
Rust
//! 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;
|
|
|
|
/// Longest OSTA CS0 encoding a File Identifier Descriptor can describe.
|
|
///
|
|
/// The FID's name-length field is one byte, so 255 is the ceiling; 254 leaves
|
|
/// the encoder no way to produce a value that wraps to zero.
|
|
const MAX_CS0_NAME_BYTES: usize = 254;
|
|
|
|
/// Most subdirectories one directory may hold.
|
|
///
|
|
/// A directory File Entry's link count is `u16` and counts one per child
|
|
/// directory plus one for its own entry in the parent, so the last usable
|
|
/// value is `u16::MAX - 1`.
|
|
const MAX_SUBDIRS: usize = (u16::MAX - 1) as usize;
|
|
|
|
/// Largest image this planner will synthesize, in sectors (128 GiB).
|
|
///
|
|
/// A DVD title set records where its VOBS begins as an offset in its own IFO,
|
|
/// and that offset is read verbatim out of a file in the folder. A regenerated
|
|
/// `.BUP`, a tool that rewrote an IFO, or a hand-assembled folder can therefore
|
|
/// name an offset far beyond the content — and the planner honours it, because
|
|
/// honouring it is what makes a real backup readable. Without a ceiling the
|
|
/// image grows to wherever that offset points: a `u32` sector count reaches
|
|
/// ~8.8 TB, and writing one to an `iso://` destination would fill a disk with
|
|
/// zeros before anything noticed.
|
|
///
|
|
/// 128 GiB clears the largest real medium (BD-100) with room to spare, so a
|
|
/// genuine disc folder never meets it.
|
|
const MAX_IMAGE_SECTORS: u32 = (128u64 * 1024 * 1024 * 1024 / SECTOR as u64) as u32;
|
|
|
|
/// Ceiling on the in-memory metadata region (64 MiB).
|
|
///
|
|
/// Every node costs a 2 KiB File Entry sector held for the life of the image,
|
|
/// so the entry cap alone permits ~205 MB of metadata for content of no size at
|
|
/// all — and the mux holds two of these at once while probing. The module
|
|
/// documents a budget of "a few MiB even for a large Blu-ray"; this is what
|
|
/// enforces it rather than merely asserting it.
|
|
const MAX_META_BYTES: u64 = 64 * 1024 * 1024;
|
|
|
|
/// The fan-out cap must bite before the global entry cap, or it never fires.
|
|
const _: () = assert!(MAX_SUBDIRS < MAX_ENTRIES);
|
|
|
|
/// 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 {
|
|
crate::dirimage::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);
|
|
// A File Identifier Descriptor records the encoded name length in ONE
|
|
// byte. A longer name would wrap that field and desynchronise every
|
|
// later entry in the directory, so refuse while planning rather than
|
|
// encode something unreadable. 255 ASCII bytes is a legal name on
|
|
// ext4/APFS/NTFS and already exceeds this once the CS0 compression
|
|
// byte is added, so it is reachable without anything exotic.
|
|
if crate::dirimage::encode::encode_cs0(&name).len() > MAX_CS0_NAME_BYTES {
|
|
return Err(Error::DirNameTooLong { path: child_path });
|
|
}
|
|
*entries += 1;
|
|
if *entries > MAX_ENTRIES {
|
|
return Err(Error::DirImageTooLarge);
|
|
}
|
|
names.push(name.to_ascii_uppercase());
|
|
if ft.is_dir() {
|
|
// A directory's File Entry records its link count in 16 bits: one
|
|
// per child directory plus one for its own entry in the parent.
|
|
// The global entry cap alone permits a single directory holding
|
|
// more than that, which would wrap the count.
|
|
if dirs.len() >= MAX_SUBDIRS {
|
|
return Err(Error::DirImageFanout {
|
|
path: disc_path.to_string(),
|
|
});
|
|
}
|
|
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.
|
|
// Anything else — permission denied, an I/O error on the host
|
|
// volume — is NOT a missing file, and skipping it would drop a
|
|
// real file out of the image with nothing reported.
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
|
|
Err(e) => return Err(Error::from(e)),
|
|
};
|
|
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.
|
|
/// Read the first `n` bytes of an IFO so its placement offsets can be resolved.
|
|
///
|
|
/// Errors propagate. Returning an empty buffer instead would send every offset
|
|
/// through `unwrap_or(0)`, recording NO placement constraint — and a VOB placed
|
|
/// without its constraint yields an image that reads at the wrong offset with
|
|
/// nothing reported. An IFO that cannot be read is a folder that cannot be
|
|
/// planned.
|
|
fn read_head(path: &Path, n: usize) -> Result<Vec<u8>> {
|
|
use std::io::Read;
|
|
let mut buf = vec![0u8; n];
|
|
let got = std::fs::File::open(path)
|
|
.and_then(|mut f| f.read(&mut buf))
|
|
.map_err(Error::from)?;
|
|
buf.truncate(got);
|
|
Ok(buf)
|
|
}
|
|
|
|
/// 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. In the VMG
|
|
// (`VIDEO_TS.IFO`) that offset is TT_SRPT, the title search pointer
|
|
// table — a sector offset INSIDE the IFO, not a pointer to another
|
|
// file, so treating it as one would place `VIDEO_TS.VOB` at a
|
|
// meaningless address. (`ifo.rs::parse_vmg` reads the same field as
|
|
// TT_SRPT; the two must not drift.)
|
|
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)?;
|
|
if total_sectors > MAX_IMAGE_SECTORS {
|
|
return Err(Error::DirImageTooLarge);
|
|
}
|
|
// Metadata is materialized up front and held for the life of the image, so
|
|
// its size is bounded here rather than discovered when memory runs out.
|
|
let meta_bytes = (dir_count as u64 + file_count as u64).saturating_mul(SECTOR as u64);
|
|
if meta_bytes > MAX_META_BYTES {
|
|
return 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"));
|
|
}
|
|
|
|
/// A name too long for the FID's one-byte length field is refused by the
|
|
/// PLANNER, on a real folder.
|
|
///
|
|
/// Audit finding: the length was narrowed with `as u8`, so a 255-byte ASCII
|
|
/// name — POSIX NAME_MAX, legal on ext4/APFS/NTFS — encoded to 256 bytes
|
|
/// with the CS0 compression byte and wrote a length of ZERO, making every
|
|
/// later entry in that directory read from the wrong offset.
|
|
///
|
|
/// An earlier version of this test asserted arithmetic about the constants
|
|
/// and never called `plan`, so it would have passed with the guard deleted.
|
|
#[test]
|
|
fn an_over_long_name_is_refused_by_the_planner() {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"fmkv-longname-{}-{:?}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
std::fs::create_dir_all(dir.join("BDMV/STREAM")).expect("mkdir");
|
|
// 255 bytes: the exact length that used to narrow to zero.
|
|
let name = "a".repeat(255);
|
|
assert_eq!(
|
|
crate::dirimage::encode::encode_cs0(&name).len(),
|
|
256,
|
|
"fixture: NAME_MAX encodes to 256 bytes with the compression byte"
|
|
);
|
|
std::fs::write(dir.join("BDMV/STREAM").join(&name), b"x").expect("write");
|
|
|
|
let err = plan(&dir).expect_err("the planner must refuse this folder");
|
|
assert!(
|
|
matches!(err, Error::DirNameTooLong { .. }),
|
|
"expected DirNameTooLong, got {err:?}"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// A name at the cap is accepted, so the guard rejects only what it must.
|
|
#[test]
|
|
fn a_name_at_the_cap_is_accepted() {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"fmkv-okname-{}-{:?}",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
std::fs::create_dir_all(dir.join("BDMV/STREAM")).expect("mkdir");
|
|
// One byte of name shorter, so the encoding lands exactly on the cap.
|
|
let name = "a".repeat(MAX_CS0_NAME_BYTES - 1);
|
|
assert_eq!(
|
|
crate::dirimage::encode::encode_cs0(&name).len(),
|
|
MAX_CS0_NAME_BYTES
|
|
);
|
|
std::fs::write(dir.join("BDMV/STREAM").join(&name), b"x").expect("write");
|
|
assert!(
|
|
plan(&dir).is_ok(),
|
|
"a name whose encoding equals the cap must be accepted"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// The subdirectory cap must keep a directory's 16-bit link count
|
|
/// representable. Creating 65,535 directories in a test is not reasonable,
|
|
/// so this pins the arithmetic relationship the guard relies on — and says
|
|
/// plainly that it does NOT exercise `walk`.
|
|
#[test]
|
|
fn the_subdir_cap_keeps_the_link_count_representable() {
|
|
assert_eq!(
|
|
(MAX_SUBDIRS as u16).checked_add(1),
|
|
Some(u16::MAX),
|
|
"the largest permitted fan-out must still fit the link count"
|
|
);
|
|
}
|
|
}
|