Security - Remove the unsafe Vec::set_len over recycled sector buffers — UB reachable from safe code (GHSA-j8ww-f5fg-9pmh, low). Uses Vec::resize; pooling unchanged. CI / tooling - clippy on stable with the MSRV single-sourced from Cargo.toml; cargo-deny dependency-audit gate with first-party git sources allowed (#15); audience-based comment-guard (#17). Docs - README (keydb ownership, BD-J parser count), drive-access transport/reset notes, mux timeline module-doc links.
24 KiB
UDF 2.50 Filesystem Parser
What is UDF?
UDF (Universal Disc Format) is the filesystem standard for optical media. It is defined by ECMA-167 with extensions from the OSTA (Optical Storage Technology Association). BD-ROM discs use UDF revision 2.50, which introduces the metadata partition -- a critical feature that separates file metadata from file content on disc.
Why UDF 2.50 for Blu-ray?
Older UDF revisions (1.02, 1.50) scatter ICBs (file metadata) and file data across the same partition. On a high-capacity Blu-ray disc (25-100 GB), this creates excessive seeking when the drive needs to read a directory listing or locate a file. UDF 2.50 solves this by placing all metadata into a contiguous region near the beginning of the disc. The drive reads metadata from one compact area and streams file data from another -- no interleaved seeks.
BD-ROM Part 3 of the Blu-ray specification mandates UDF 2.50.
Metadata Partitions
A UDF 2.50 BD-ROM has two logical partitions:
-
Partition 0 (Type 1) -- the physical partition. Contains actual file data (m2ts streams, playlist files, etc.). Mapped directly to disc sectors starting at the Partition Descriptor's
partitionStartingLocation. -
Partition 1 (Type 2) -- the metadata partition. Contains all ICBs (Inode-like structures), directory data, and the File Set Descriptor. The metadata partition is itself stored as a file within the physical partition. Its location is found by reading an Extended File Entry at LBA 0 of the physical partition.
The key rule: ICBs and directory data live in the metadata partition. File content lives in the physical partition. When an ICB's allocation descriptor gives an LBA, the partition it refers to depends on what the LBA describes -- metadata-relative for directory entries, physical-partition-relative for file data extents.
Pointer Chain
Reading a UDF 2.50 filesystem follows a fixed chain of pointers. Each step reads one or two 2048-byte sectors:
Sector 256: AVDP (Anchor Volume Descriptor Pointer, tag 2)
|
v
Sectors 32-63: VDS (Volume Descriptor Sequence)
|-- Partition Descriptor (tag 5) --> partition_start (physical sector)
|-- Logical Volume Descriptor (tag 6) --> partition maps, FSD location
|
v
Partition Maps in LVD (offset 440):
|-- Map 0: Type 1 (physical partition)
|-- Map 1: Type 2 (metadata partition, identified by "*UDF Metadata Partition")
|
v
Metadata file ICB at partition_start + 0 (Extended File Entry, tag 266)
|-- Allocation descriptor --> metadata content location
|
v
metadata_start = partition_start + allocation_position
|
v
FSD at metadata_start + 0 (File Set Descriptor, tag 256)
|-- Root Directory ICB: long_ad at offset 400 --> root_lba (metadata-relative)
|
v
Root Directory ICB at metadata_start + root_lba (Extended File Entry, tag 266)
|-- Allocation descriptor --> directory data location (metadata-relative)
|
v
Directory data: File Identifier Descriptors (tag 257)
|-- Each FID names a file/subdirectory and points to its ICB
|-- Recurse into subdirectories to build the full file tree
AVDP (Sector 256)
The Anchor Volume Descriptor Pointer is always at sector 256 (ECMA-167 section 10.2). It points to the Main Volume Descriptor Sequence. Tag identifier = 2.
VDS (Sectors 32+)
The Volume Descriptor Sequence contains:
- Partition Descriptor (tag 5): byte offset 188 holds
partitionStartingLocation-- the absolute sector where the physical partition begins. - Logical Volume Descriptor (tag 6): byte offset 268 holds the number of partition maps. The partition maps themselves start at offset 440. For BD-ROM, map 0 is Type 1 (physical) and map 1 is Type 2 (metadata).
- Terminating Descriptor (tag 8): signals the end of the VDS.
Metadata File
When two partition maps exist and the second is Type 2, the metadata partition content is located by reading the Extended File Entry at the first sector of the physical partition (partition_start + 0). This ICB's allocation descriptor gives the offset and length of the metadata content within the physical partition.
File Set Descriptor
The FSD (tag 256) sits at metadata-relative LBA 0 (the first sector of the metadata content). It contains a long allocation descriptor at offset 400 pointing to the root directory ICB. The LBA in this long_ad is at bytes 404-407.
Directory Traversal
Each directory is an ICB (Extended File Entry, tag 266, or File Entry, tag 261) whose allocation extent points to directory data. The directory data is a sequence of File Identifier Descriptors (FIDs, tag 257):
| FID Field | Offset | Size | Description |
|---|---|---|---|
| Tag | 0 | 2 | Always 257 |
| File characteristics | 18 | 1 | Bit 1 = directory, bit 3 = parent |
| L_FI (name length) | 19 | 1 | Length of filename |
| ICB (long_ad) | 20 | 16 | Points to the entry's ICB |
| L_IU | 36 | 2 | Implementation use length |
| Filename | 38 + L_IU | L_FI | UDF-encoded filename |
FIDs are 4-byte aligned. The parser advances by (38 + L_IU + L_FI + 3) & !3 bytes per entry.
ICB Layout
Both File Entry (tag 261) and Extended File Entry (tag 266) share the same info_length field:
| Field | Tag 261 Offset | Tag 266 Offset |
|---|---|---|
| info_length (u64) | 56 | 56 |
| L_EA (u32) | 168 | 208 |
| L_AD (u32) | 172 | 212 |
| Allocation descriptors | 176 + L_EA | 216 + L_EA |
Allocation descriptors use the Short Allocation Descriptor format: 4 bytes extent length (upper 2 bits = type), 4 bytes extent position (LBA).
How read_filesystem() Works
The read_filesystem() function in src/udf.rs follows the pointer chain above:
- Reads sector 256, validates AVDP (tag 2).
- Scans sectors 32-63 for the Partition Descriptor and Logical Volume Descriptor.
- If two partition maps exist and the second is Type 2, reads the metadata file ICB at partition_start to find metadata_start.
- Reads the FSD at metadata_start, extracts the root directory ICB LBA.
- Calls
read_directory()recursively (max depthMAX_DIR_DEPTH= 8) to build the full file tree.
Each directory read involves two sector reads: one for the ICB, then one or more for the directory data. File sizes are read from info_length in each file's ICB.
read_file() reads a file by navigating the directory tree, reading the file's ICB to get its data extent, then reading the data sector by sector from the physical partition (partition_start + LBA, not metadata_start).
Buffered Sector Reads
USB optical drives have ~500ms round-trip latency per SCSI command. Since read_filesystem() and read_file() issue one SCSI READ per sector, a full disc scan can require hundreds of commands -- taking 10+ minutes on USB.
Disc::scan() wraps the drive in a BufferedSectorReader before reading. On a single-sector read, the buffer prefetches a batch of sectors (sized from the kernel's max_hw_sectors_kb for the device) and caches them. Subsequent reads to nearby LBAs return from cache with zero SCSI overhead. After parsing the UDF directory structure, the entire metadata partition is pre-read into the cache, so all ICB lookups during title scanning and encryption resolution are instant.
prefetch_ranges pre-reads multiple sector ranges into the permanent cache,
in batch-sized chunks, stored per-sector in a HashMap. This is used to
bulk-load all small files (AACS, MPLS, CLPI, META) before scanning. The
permanent cache holds one ~2 KB Vec per sector in the HashMap, so the
total sector count bounds RAM; a crafted UDF (a bogus metadata-file size in
metadata_sector_ranges) could otherwise drive that count to billions. The
cumulative prefetched sectors are capped at MAX_PREFETCH_SECTORS (~1 GiB
of cache); once exceeded, prefetching stops seeding the cache. The
sliding-window read path still serves any LBA on demand, so this only
forgoes the bulk speed-up — it never loses data.
The buffer is transparent -- read_filesystem(), read_file(), and all downstream code still call read_sectors(lba, 1, buf) as before. The batching happens inside the SectorSource implementation.
UDF Filename Encoding
UDF filenames use a compression ID as the first byte:
8= 8-bit characters (ASCII)16= 16-bit big-endian Unicode (UTF-16BE)
The parser handles both encodings. All path lookups are case-insensitive.
BD-ROM Directory Structure
A typical Blu-ray disc has this directory layout:
/
+-- BDMV/
| +-- index.bdmv Disc index (title list, first play)
| +-- MovieObject.bdmv Movie objects (navigation commands)
| +-- PLAYLIST/
| | +-- 00000.mpls Main movie playlist
| | +-- 00001.mpls Director's commentary
| | +-- ...
| +-- CLIPINF/
| | +-- 00001.clpi Clip info for 00001.m2ts
| | +-- 00002.clpi
| | +-- ...
| +-- STREAM/
| | +-- 00001.m2ts Transport stream (video/audio/subtitle data)
| | +-- 00002.m2ts
| | +-- ...
| +-- BACKUP/ Duplicate of index, MovieObject, playlists, clip info
|
+-- AACS/ AACS encryption data (encrypted discs only)
| +-- Unit_Key_RO.inf Unit key file (encrypted)
| +-- MKB_RW.inf Media Key Block
| +-- Content000.cer Content certificate
| +-- DUPLICATE/ Backup copies
|
+-- CERTIFICATE/ BD+ certificate data (some discs)
The parser reads from BDMV/PLAYLIST/ and BDMV/CLIPINF/ to discover titles and their sector layouts. The BDMV/STREAM/ directory contains the actual transport streams but is not parsed by the UDF layer -- stream data is read by LBA directly using extents computed from CLPI EP maps.
metadata_sector_ranges() skip policy
metadata_sector_ranges() skips directories named STREAM (case-insensitive)
and individual files larger than 50 MB. Nothing else is filtered by name —
BACKUP/DUPLICATE are traversed, and MKB_RO.inf is excluded only because
it exceeds the 50 MB cap.
metadata_file_location() and read_directory() internals
metadata_file_location returns None when the map does not fit wholly
inside the descriptor, or when its partition type identifier is not
"*UDF Metadata Partition" — a Virtual (UDF 2.50 2.2.8) or Sparable (2.2.9)
partition map is also ECMA-167 3/10.7.3 Type 2 and records unrelated fields
at that offset, so its bytes must never be read as a location.
read_directory walks each directory's ICB (Extended File Entry) to find
directory data containing File Identifier Descriptors (FIDs); each FID names
a file/subdir and points to its ICB. Directories deeper than MAX_DIR_DEPTH
are recorded as entries but not descended into. budget tracks total FID
entries consumed across the whole tree walk and the walk aborts with
Error::DiscRead once it exceeds MAX_TOTAL_DIR_ENTRIES. visited is the
set of metadata-relative ICB LBAs already opened as directories; a repeated
LBA is a cycle and is skipped. The function's wide argument list (reader,
partition/meta offsets, depth, the global entry budget, and the
cycle-detection visited-set) is inherent to the walk, not a refactor smell.
extents_abs_at(), file_extents(), and file_extents_addressing()
extents_abs_at resolves multi-extent / Long-AD / continuation ICBs to
absolute disc extents. Unrecorded (ECMA-167 4/14.14.1.1 type-1) extents are
included: their space is allocated to the file and occupies its byte space,
so dropping them would slide every later extent's bytes down by the hole's
length in a sequential extraction. They are returned FLAGGED
(AbsExtent::recorded == false), because keeping the extent while losing
the flag is the same bug the other way round: the caller would then read
sectors the file never wrote and ship whatever the media holds there.
read_file_limited emits zeros for such an extent; every consumer of this
list has to be able to do the same. It is pub(crate) because it returns
AbsExtent, itself internal.
file_extents refuses a file that contains an unrecorded extent because a
(lba, sector_count) pair cannot say "this range is a hole": reading it
splices undefined sectors into the stream as content, and dropping it
slides every later extent's byte space. Until an extent can carry the flag
end-to-end, refusing is the only truthful answer at this signature — and a
title that is not offered is not a title that was silently mis-ripped.
file_extents_addressing is the byte-space-only counterpart: it includes
unrecorded extents UNFLAGGED, in a shape identical to file_extents's safe
return, because omitting one would misaddress the rest of the file and
nothing on this path treats the returned sectors as stream bytes. It stays
pub(crate) rather than public: an external consumer reaching for the more
general-sounding name would silently obtain a read plan over a hole, and a
doc comment alone is not a guard against that — scoping makes the misuse a
compile error.
read_icb_extent(): first RECORDED extent, not extents.first()
read_icb_extents retains ECMA-167 4/14.14.1.1 type-1 (allocated, not
recorded) descriptors, because dropping one would slide every later extent's
data down by the hole's length. But a type-1 extent's lba is where SPACE is
allocated, not where bytes live, and read_icb_extent's one caller —
file_start_lba — hands its result out as "the absolute starting LBA of a
file's first data extent". ifo.rs then uses that as the base for every VTS
VOB extent (file_start_lba(IFO) + vtstt_vobs + cell.first_sector), so a
file whose FIRST descriptor is type-1 would put the entire video title set
at the wrong place on disc, with no error anywhere. read_icb_extent
therefore returns the first extent with recorded == true, not
extents.first().
read_inline_data() and the AACS .inf regression
Tiny files such as the AACS *.inf key files are routinely embedded
directly in the ICB (ICB Tag flags low 3 bits == 3) rather than via
out-of-line extents. read_icb_extents finds no real extents for them (it
would misparse the embedded payload as allocation descriptors), so
read_file must read the inline payload via read_inline_data instead. A
0.31.0 regression added a per-extent MAX_FILE_BYTES cap that turned the
misparsed-embedded case into a hard error, which surfaced as autorip "could
not read this disc's key files" on discs whose AACS .inf files are
ICB-embedded — the keyserver was then never called.
MAX_FILE_BYTES and the AACS MKB
read_file() caps a single unbounded read at MAX_FILE_BYTES (64 MiB) to
bound the allocation a crafted ICB info_length/extent length can force.
The one legitimately huge file on disc, the AACS MKB_RO.inf, is allocated
to a fixed ~128 MiB and zero-padded, so it is NOT read through this unbounded
path: read_aacs_inputs_from_reader instead reads a bounded prefix via
read_file_prefix and trims to the real record length, so it never trips
the cap (and never reads 100+ MiB of padding). A 0.31.0 regression read the
MKB through the unbounded path instead, so the cap rejected it,
read_aacs_inputs failed, and autorip reported "could not read this disc's
key files" without ever contacting the keyserver.
merge_ranges() half-open semantics
merge_ranges takes HALF-OPEN (start, count) ranges, so start + count
is the EXCLUSIVE end and two ranges touch exactly when the next start
equals the previous end. A range that starts one sector LATER than that has
a genuine, untouched sector between them, and merging across it claims
coverage of a sector neither input ever described. That matters because the
merged output is a boundary, not a hint: disc::merged_extents feeds
Disc::encrypted_content_ranges, which decides which sectors the
decrypting source treats as content. A sector in a real gap belongs to no
title extent — it is nav/UDF/padding — and must stay outside the content
gate, not be folded into it.
build_dir_icb_tagged() offsets
build_dir_icb_tagged builds a directory ICB with an explicit descriptor
tag, extended attribute length, RAW (unmasked) allocation-descriptor length
field and allocation position. ECMA-167 4/14.17 Extended File Entry (tag
266): L_EA at byte 208, L_AD at 212, allocation descriptors start at
216 + L_EA. ECMA-167 4/14.9 File Entry (tag 261): L_EA at 168, L_AD at 172,
allocation descriptors start at 176 + L_EA. The extended-attribute area is
filled with a recognisable non-zero pattern (0xA5) so that reading the
allocation descriptor from the wrong offset cannot silently produce a
usable value.
Test fixture builders
build_efe_long builds a tag-266 ICB whose allocation descriptors are LONG
ADs (16 bytes: len(4) | lba(4) | part_ref(2) | impl_use(6)); the trailing
part_ref/impl_use bytes are left zero, which is what trips the old
8-byte-stride parser into reading a bogus zero-length terminator.
build_entry_ads builds a tag-261/266 entry with short ADs and every
disc-controlled field of the descriptor area exposed: l_ea (extended
attribute length — descriptors begin at the entry type's own base, 176 for
261 or 216 for 266, plus this; the attribute area is filled with 0xA5 so a
descriptor read from the wrong offset cannot silently produce a usable
value), l_ad (the DECLARED descriptor-area length, independent of how many
descriptors are actually written), and extra (descriptors written
immediately after the declared area, i.e. bytes the entry does not claim are
descriptors at all).
VDS anchor shape-vs-content fallback tests
An anchor whose recorded VDS extent passes every SHAPE check (length >= 16 sectors, non-zero location, no address wrap) but holds no sequence must still mount, by sweeping the customary location. Shape is a property of the FIELD, not of what is there: a mastering tool that wrote the reserve location, a stale anchor on a rewritten volume, or deliberate corruption all produce this. Selecting the fallback on shape alone would leave the branch a damaged disc actually takes with no recovery path at all — the sibling Metadata File Location chain in the same function already treats its recorded value as a CANDIDATE and falls back on outcome; this is that principle applied one level up.
The recorded extent and the customary fallback are two SWEEPS, not two
locations: an anchor may point at the customary LBA and still declare a
window narrower than the fallback's. De-duplicating them on start LBA alone
then throws the wider sweep away and the sequence is never reached, even
though it is exactly where the fallback would have looked. The regression
test's fixture records (LBA 32, 16 sectors) — the minimum ECMA-167
3/10.2.1 permits, so it passes every shape check — while the Partition and
Logical Volume Descriptors sit at 50/51, inside the customary 32-sector
window but past the declared 16. No Terminating Descriptor lies in 32..48,
or both sweeps would stop at the same sector and the wider one would buy
nothing.
conformant_meta_vol's extent length and position are given distinct byte
patterns, and distinct patterns from one another, so a descriptor byte
sourced from the wrong offset — or the two fields transposed — changes the
reported metadata partition rather than landing on a value that happens to
work. The length is an exact multiple of the 2048-byte logical sector, as a
real metadata partition is: its only observable is a sector COUNT, which
rounds up, so an off-by-one byte in the low half is visible only when the
true value sits exactly on the boundary.
u32::MAX overflow guards in BufferedSectorReader
prefetch advances the read LBA with start_lba + offset. A UDF whose
metadata descriptor declares a partition near the top of the LBA space
(ECMA-167 §14.1: extent_location is an unconstrained Uint32) drove that
add past u32::MAX — an 'attempt to add with overflow' panic in debug
inside the public Disc::scan, and in release a wrap to a low LBA that
filled the sliding cache with a completely different region while
cache_start still claimed the high one.
prefetch_ranges walks each disc-derived (start_lba, sector_count) range
with start + offset + i. collect_file_ranges permits any LBA up to
u32::MAX (ECMA-167 §14.14.1 allocation descriptors are unconstrained
Uint32s) and nothing bounds start + count, so a range within one batch of
the top of the space overflowed: debug panic inside Disc::scan, or in
release a wrap that seeded the PERMANENT cache with this file's bytes keyed
at LBA 0 — every later single-sector read of those low LBAs (the AVDP/VDS
re-reads) then returned the wrong sector.
The sliding-cache hit test computed cache_start + cache_sectors. Once
cache_start is a disc-controlled LBA near u32::MAX (set by a batch read)
that add overflowed: debug panic inside SectorSource::read_sectors,
release wrap to a small value that silently disabled the cache.
file_extents() refusal and continuation-cycle test rationale
file_extents builds the extent list a Blu-ray / HD-DVD TITLE is actually
ripped through (disc/bluray.rs, disc/hddvd.rs, mux/resolve.rs). Its
(lba, sector_count) tuple cannot say "this range is a hole", and both ways
of pretending otherwise are wrong: folding an unrecorded extent in makes the
mux read undefined sectors and splice whatever the media holds there into
the stream as content, while dropping it slides every later extent's byte
space (that list IS a byte-space map, aacs::segment::clip_byte_to_lba). So
at this signature the only truthful answer is refusal.
icb_extents_exhausted_continuation_budget_is_an_error_not_a_short_list
covers hostile input: a type-3 continuation descriptor whose continuation
block points back at itself (a cycle). The MAX_AD_BLOCKS bound must make
this TERMINATE rather than loop forever, and terminating by exhausting the
budget is not the same as reaching the end of the chain. It used to fall
through to Ok(extents) with the list silently cut short at whatever the
256th hop had collected; disc/extract.rs then zero-pads the missing tail
out to the entry's declared size, sets complete = true with
bytes_unreadable = 0, and renames off .partial — a mostly-zero file
delivered as a verified complete extraction. Exhausting the budget means "I
do not know the rest of this file", and the only honest answer is an error.
Embedded-data and zero-length-hole test fixtures
AD type 3 is EMBEDDED data, not a descriptor list. RED BEFORE GREEN: with
the old _ => 8 fallback, read_icb_extents returned
Ok([IcbExtent { lba: 999, len: 2048 }]) — an extent decoded out of the
file's own CONTENT bytes. A rip would then read sector 999, which holds
something else entirely, and emit it as this title's stream at rc=0. That is
the failure the refusal exists to prevent, so the
icb_extents_embedded_data_is_refused_not_decoded_as_descriptors fixture
deliberately makes the content decode as a PLAUSIBLE extent rather than as
garbage: garbage would have been caught by the terminator check anyway, and
would prove nothing.
file_extents refuses a file whose extent list contains an unrecorded hole,
because reading one splices undefined sectors into the rip and dropping one
slides every later extent's byte space. Neither is true of a hole with
LENGTH ZERO: it displaces nothing, every later extent sits at the same
offset with or without it, and the callers' own sectors > 0 && lba > 0
filter already discards it. Refusing on !recorded alone therefore dropped
whole titles off discs that ripped correctly before — the reverse of the
defect the refusal exists for, and just as silent, since the title simply
vanishes. file_extents_accepts_a_zero_length_unrecorded_extent covers this.
build_efe_ext() extended allocation descriptor layout
build_efe_ext builds an Extended File Entry (tag 266) ICB whose allocation
descriptors are EXTENDED ADs (ECMA-167 §14.14.3, 20 bytes each):
ExtentLength(4) | RecordedLength(4) | InformationLength(4) | ExtentLocation lb_addr { logicalBlockNumber(4) | partitionRef(2) } | impl_use(2). The
30-bit length + 2-bit type live in ExtentLength (offset +0); the logical
block number lives in ExtentLocation at offset +12. It sets ICB Tag flags
(abs offset 34) low bits to 2 = Extended AD so the parser must select the
20-byte stride and read the LBA from off+12, not off+4.
References
- ECMA-167: Volume and File Structure of Write-Once and Rewritable Media
- UDF 2.50 (OSTA): Universal Disk Format Specification
- BD-ROM Part 3: Blu-ray Disc Read-Only Format, File System Specifications