mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
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.
466 lines
28 KiB
Markdown
466 lines
28 KiB
Markdown
# HD-DVD title scanning (`src/disc/hddvd.rs`)
|
|
|
|
HD-DVD is a **tree-level peer** of DVD and Blu-ray (not a stream variant like
|
|
FMTS): its content lives in `HVDVD_TS/` as `.evo` clips — Enhanced VOB, an
|
|
MPEG **program** stream — each with a small `.map` timemap sidecar, navigated
|
|
by `.xpl`/`.ifo` playlists in `HVDVD_TS/` and `ADV_OBJ/`. Because it is a
|
|
different tree with a different playlist format, it gets its OWN scanner
|
|
(this file), a peer to `Disc::scan_bluray_titles` — the two-format design
|
|
rule: a genuinely different format is a new enumerator, not an extension
|
|
bolted into the BD path.
|
|
|
|
## Title composition (authoritative path)
|
|
|
|
Title composition is authoritative, from the Advanced-Content playlist. HD-DVD
|
|
ships a real player playlist at `ADV_OBJ/VPLST000.XPL` (DVD-Forum
|
|
`HDDVDVideo/Playlist` XML). The scanner parses it (with a real XML parser,
|
|
`roxmltree`) into one `DiscTitle` per `<Title>`: its `<PrimaryAudioVideoClip>`
|
|
clips in playback order (each an EVO, referenced via its `.MAP` sidecar), the
|
|
`titleDuration`, the `displayName`, and the `<ChapterList>`. A layer-break
|
|
split (`FEATURE_1` + `FEATURE_2`, or `feature`/`feature_Divide`) is composed
|
|
into ONE title with the two parts as clips, carrying each clip's title-time
|
|
in/out points (45 kHz ticks) so a seamless join can be spliced onto one
|
|
timeline. Container is `ContentFormat::MpegPs`, so the existing PS mux path
|
|
handles it. Per-clip streams are enumerated by demuxing the clip head and
|
|
building one `Stream` per distinct elementary stream (video + DD+ audio),
|
|
codec sniffed from the ES bytes.
|
|
|
|
## Fallback path
|
|
|
|
When no playlist is present (or it fails to parse), the scanner falls back to
|
|
the older clip-name heuristic: parse the `HVA*.VTI` clip table, join the
|
|
`feature*`-named clips into one title, and emit every other clip on its own.
|
|
|
|
## Known gaps
|
|
|
|
Not parsed yet: subtitles (8-bit RLC on `0xBD` sub `0x20..=0x3F`) and per-track
|
|
audio languages (the XPL carries `<Audio description=...>` but they are not yet
|
|
wired onto the streams). Extents and size are real (the ripper images the
|
|
clips).
|
|
|
|
## VTI parsing
|
|
|
|
`parse_vti_clip_order` parses the `ADVANCED-VTS` VTI clip-name table. The
|
|
table is a run of `VTI_CLIP_ENTRY_STRIDE`-spaced records, each carrying a
|
|
NUL-terminated `<name>.EVO`. Rather than trust the (imprecise) header pointer,
|
|
it collects every NUL-terminated `*.EVO` name and keeps the largest group
|
|
sharing one residue modulo the stride — the clip table — in offset order.
|
|
Returns empty for a non-VTI blob or one with no recognizable table.
|
|
|
|
## Codec sniffing
|
|
|
|
`sniff_video_codec` reads MPEG / Annex-B start codes: `00 00 01 B3` → MPEG-2
|
|
(sequence_header), `00 00 01 0F` → VC-1 (BD/HD-DVD sequence-header BDU),
|
|
`00 00 01 [x7]` H.264 SPS NAL (type 7, forbidden_zero_bit clear) → H.264.
|
|
Returns `None` when no recognizable start code is present. The scan prefers
|
|
the unambiguous MPEG-2 / VC-1 sequence headers; H.264 is inferred from an SPS
|
|
NAL so a stray slice/picture code can't be mistaken for a different codec.
|
|
|
|
`sniff_audio_codec` recognizes only Dolby Digital Plus (E-AC-3) today, via its
|
|
`0x0B77` syncword — what real retail HD-DVD titles carry on sub-ids
|
|
`0xC0..=0xC7`. Returns `None` for an unrecognized sample so the caller drops
|
|
the stream rather than mislabeling it.
|
|
|
|
## probe_evo_streams
|
|
|
|
Demuxes the head of an `.evo` clip (through the disc's `SectorSource`) and
|
|
builds one `Stream` per distinct elementary stream found: the video track
|
|
(mapped to the canonical `DVD_VIDEO_PID` for plain 0xE0-0xEF video, or the
|
|
extended-stream-id PID for HD-DVD VC-1) and every DD+ audio sub-stream
|
|
(mapped via `dvd_audio_pid`). Codec is sniffed from the demuxed ES bytes.
|
|
|
|
Mirrors the stream construction in `Disc::scan_dvd_titles`; resolution /
|
|
language / channels use sane HD-DVD defaults (the muxer reads the true pixel
|
|
dimensions from the H.264 SPS, and E-AC-3 channel counts are not decoded
|
|
here). Returns an empty vec when the clip cannot be read or carries no
|
|
recognizable stream (e.g. an AACS-encrypted clip probed as ciphertext).
|
|
|
|
Cancellation is checked before every chunk read and is the ONE condition that
|
|
returns `Err` rather than an empty vec: a probe cut short by a Stop has not
|
|
established that the clip carries no streams, and reporting it as if it had
|
|
would enumerate a stream-less title as a scanned fact. A read that fails for
|
|
any other reason keeps the existing best-effort behaviour.
|
|
|
|
## collect_es
|
|
|
|
VC-1 video rides extended-stream-id `0xFD` with `stream_id_extension` `0x55`.
|
|
HD audio (MLP/TrueHD) can also use `0xFD` with other extensions — routing
|
|
those to their own audio tracks is deferred (see the HD-DVD program-chain
|
|
follow-up); until then only the VC-1 extension is treated as video, so an
|
|
audio `0xFD` sub-stream can never mis-stamp the video PID.
|
|
|
|
## XPL amplification bounds
|
|
|
|
The XPL parser guards against a crafted `ADV_OBJ/VPLST000.XPL` amplifying a
|
|
small file into an enormous amount of work or memory. Four separate axes are
|
|
bounded, each independently, because they are separate attacks:
|
|
|
|
- **`MAX_XPL_DEPTH` (32)** bounds element NESTING. A real `VPLST000.XPL` is
|
|
about six levels deep at its deepest
|
|
(`Playlist`/`TitleSet`/`Title`/`PrimaryAudioVideoClip`/`Video`, or
|
|
`.../ChapterList/Chapter`); 32 leaves a 5x margin for authoring tools that
|
|
wrap extra `ApplicationSegment`/`ObjectMappingList` layers, while staying
|
|
trivially stack-safe.
|
|
|
|
- **`MAX_XPL_TITLES` (512)** bounds how many `<Title>` elements one playlist
|
|
may declare — BREADTH, separate from the depth guard: a flat playlist
|
|
passes the depth check trivially and can still declare a title per 51
|
|
bytes, so a 64 MiB `VPLST000.XPL` (exactly `udf::MAX_FILE_BYTES`) reaches
|
|
~1.3 million of them. This matters here and not on the Blu-ray side because
|
|
of where the count comes from: `bluray.rs` learns its playlists from the
|
|
MPLS files actually present in the UDF directory, so the medium bounds it —
|
|
an XPL DECLARES its titles, so nothing does. The cost is drive time, not
|
|
memory: `compose_xpl_titles` runs `probe_evo_streams` once per title, and
|
|
each probe reads real sectors, so an uncapped count turns a `Disc::scan()`
|
|
the operator expects to take seconds into tens of terabytes of optical
|
|
reads on a disc that is only pretending to be large. The bound that matters
|
|
is `cap x EVO_PROBE_SECTORS`, since `compose_xpl_titles` memoizes on the
|
|
title's RESOLVED extent list, and distinct titles built from different
|
|
combinations of the same clips have different lists, so each one misses the
|
|
memo — at 4096 that product was 64 GiB of optical reads, the same worst
|
|
case, and the same contradiction with "seconds", as the sibling
|
|
`MAX_HDDVD_CLIPS` path. 512 keeps it at 8 GiB and remains far above any
|
|
real disc (retail HD-DVDs carry tens of titles).
|
|
|
|
- **`MAX_HDDVD_CLIPS` (512)** caps `.evo` clips taken from the `HVDVD_TS/`
|
|
directory listing. `MAX_XPL_TITLES` and `xpl_depth_within_limit` bound the
|
|
`ADV_OBJ/*.XPL` path, but a crafted disc simply omits `/ADV_OBJ`; control
|
|
then reaches the clip-name fallback in `Disc::scan_hddvd_titles`, which
|
|
emits one title per directory entry and probes each — the same unbounded-
|
|
probe cost, reached without the playlist. The directory is the only bound
|
|
the medium supplies, and `udf::MAX_DIR_BYTES` (1 MiB) still leaves room for
|
|
~24,000 FIDs. Memoizing the probe (`EvoProbeCache`) collapses entries that
|
|
resolve to the SAME extents, closing the many-names-one-File-Entry case —
|
|
but not this one: a File Entry is a single sector, so an attacker can give
|
|
every FID its own, each declaring its own extent, for ~48 MiB of image;
|
|
every probe then misses the memo and costs a full `EVO_PROBE_SECTORS`
|
|
(16 MiB) read, hundreds of GiB on a scan the operator expects to take
|
|
seconds. The cap's job is to bound `cap x EVO_PROBE_SECTORS`; it was 4096
|
|
(matching `MAX_XPL_TITLES`, which bounds a different, no-per-item-read-cost
|
|
path) — 4096 x 16 MiB is 64 GiB, more than dual-layer HD-DVD media
|
|
physically holds (reachable anyway, since distinct extent lists may overlap
|
|
on the medium). 512 keeps the product at 8 GiB and still leaves a wide
|
|
margin over any real disc (retail HD-DVDs carry TENS of `.evo` clips, ~10x
|
|
the real-world maximum, the sizing convention `MAX_XPL_CHAPTERS_PER_TITLE`
|
|
also uses against the 99-chapter authoring ceiling). Pinned by
|
|
`the_clip_cap_and_probe_budget_bound_a_scans_worst_case_read_volume`, which
|
|
asserts the PRODUCT rather than either constant, so raising one without
|
|
re-examining the other fails.
|
|
|
|
- **`MAX_XPL_CLIPS_PER_TITLE` (256)** bounds how many
|
|
`<PrimaryAudioVideoClip>` elements a SINGLE `<Title>` may contribute — the
|
|
fourth amplification axis, the one the other three guards leave open. A
|
|
64 MiB XPL holds ~1.7 million `<PrimaryAudioVideoClip src="A.EVO"/>`
|
|
elements at ~38 bytes each, and the collector is `descendants()`, not
|
|
`children()`, so a clip nested inside N ancestor `<Title>` elements is
|
|
collected by EVERY one of them — the depth cap still permits 32 such
|
|
ancestors (measured: a bare `<Title>` root nests 32 deep and still parses,
|
|
because the guard counts the self-closing clip element as non-nesting).
|
|
The product is ~1.7M x 32 ≈ 56 million heap-allocated `XplClip`s, each with
|
|
its own `String`, and then as many `Clip`s again in `compose_xpl_titles`.
|
|
Unlike the title cap, the cost here is MEMORY rather than drive time:
|
|
`compose_xpl_titles` de-duplicates by `.evo` name before probing
|
|
(`seen_evos`), so repeats cost no extra sector reads, but the `Vec`s are
|
|
built and held regardless. 256 is far above any real disc — a feature is
|
|
one clip, or two across a layer break; even seamless-branching/multi-angle
|
|
is tens — and keeps the aggregate bounded at `MAX_XPL_TITLES` x 256 clips
|
|
rather than the tens of millions above.
|
|
|
|
- **`MAX_XPL_CHAPTERS_PER_TITLE` (1024)** bounds `<Chapter>` elements per
|
|
`<Title>` — the same `descendants()` amplification, on the second unbounded
|
|
`collect()` in the same loop, bounded separately so capping clips doesn't
|
|
simply move the attack next door. A chapter is a bare `f64` rather than a
|
|
`String`-carrying struct so it's cheaper, but a
|
|
`<Chapter titleTimeBegin="…"/>` element is also smaller, so the element
|
|
count an XPL can reach is comparable. 1024 is far above any real disc: the
|
|
DVD/HD-DVD authoring convention tops out at 99 chapters per title, a 10x
|
|
margin.
|
|
|
|
## scan_hddvd_titles
|
|
|
|
Scans HD-DVD titles from the `HVDVD_TS/` `.evo` clips. The main feature is
|
|
authored as one or more `.evo` clips (a layer-break split —
|
|
`FEATURE_1`/`FEATURE_2` or `feature`/`feature_Divide`). The `HVA*.VTI`
|
|
navigation file names every clip in authored order; this parses it to
|
|
concatenate the feature clips into ONE title (so the largest-title pick gets
|
|
the whole movie, not just part 1), emitting every other clip as its own
|
|
title. Falls back to one title per clip when the VTI is absent or
|
|
unparseable, so a disc with no readable navigation still enumerates.
|
|
`chapters`/duration are left empty pending deeper VTI parsing.
|
|
|
|
Cancellation: `halt` is polled once per clip and again before every probe
|
|
chunk, and a read that fails with `Error::Halted` — how a live drive reports
|
|
a Stop, since `Drive::checked_exec` fails every command once its flag is set
|
|
— is propagated rather than swallowed. `Halted` is the only error this
|
|
returns; every other read failure keeps its best-effort behaviour. It has to
|
|
be an error and not a short title list: a cancelled enumeration that
|
|
returned `Ok` would be indistinguishable from a disc that genuinely holds
|
|
fewer titles, and the caller would cache and act on it.
|
|
|
|
## xpl_depth_within_limit
|
|
|
|
Rejects a disc-supplied XML blob whose element nesting exceeds
|
|
`MAX_XPL_DEPTH`, BEFORE it reaches the parser. `roxmltree` is
|
|
recursive-descent and its depth-10 limit applies only to ENTITY expansion, so
|
|
element nesting is unbounded: a few hundred KB of well-formed XML (far under
|
|
the read cap) overflows the thread stack. That is a process ABORT, not an
|
|
`Err` and not an unwind, so neither the `Document::parse` fallback nor
|
|
`catch_unwind` can contain it; the only fix is to not hand the document over
|
|
at all. A byte-size cap is deliberately NOT added: file size is already
|
|
bounded by the UDF read path, and a size cap does not close this class (a
|
|
180 KB document already aborts) — depth is the bound that matters.
|
|
|
|
This is a conservative scan, not a validator: it tracks `<name …>` /
|
|
`</name>` while skipping comments, CDATA, processing instructions and
|
|
declarations, and treats `<… />` as non-nesting. A miscount can only cost a
|
|
pathological document its fast path, and the failure mode is the existing
|
|
one — fall back to the clip-name heuristic.
|
|
|
|
## Test rationale
|
|
|
|
Detailed rationale for tests in `src/disc/hddvd.rs`'s `#[cfg(test)]` module,
|
|
kept here so the in-code doc can stay within the internal-comment cap.
|
|
|
|
**`scan_hddvd_feature_composition_saturates_absurd_disc_declared_sizes`** —
|
|
The SECOND size accumulator. `compose_xpl_titles` sums an XPL's clip sizes;
|
|
this one sums the composed FEATURE title's parts, and it reads the same
|
|
disc-declared `u64` Information Length from the same `clip_extents` map. A
|
|
split feature whose two parts each declare a size near `u64::MAX` overflows
|
|
it exactly as the XPL path did. Nothing reconciles the declared size against
|
|
the extents that back it — here each part declares `u64::MAX` while
|
|
occupying ten sectors — because the allocation descriptor's length field is
|
|
only 32 bits wide, so the disc can always claim more than it holds.
|
|
Mutation: restore `size_bytes += *size` in the feature-composition path and
|
|
this goes red (debug: attempt to add with overflow).
|
|
|
|
**`scan_hddvd_does_not_compose_a_feature_over_an_unrecorded_part`** — A split
|
|
feature (FEATURE_1 + FEATURE_2) where ONE part carries an unrecorded extent
|
|
must not be composed into a FEATURE title at all. Composing around the
|
|
unusable part is the dangerous outcome: the title would play through as if
|
|
it were the whole movie while silently missing that part's runtime, and its
|
|
size still counts it. The other clips remain available as their own titles
|
|
— refusing the composition is not the same as refusing the disc.
|
|
|
|
**`scan_hddvd_logs_an_unrecorded_feature_part_with_its_own_code`** — The
|
|
refusal above must also be ACCOUNTED, with the unrecorded extent's own code.
|
|
This warn site carried a hardcoded `code = 6017` literal — in the very file
|
|
whose sibling arm was changed to stop doing exactly that — and nothing
|
|
tested it, so neither the literal nor its absence broke anything. Refusing a
|
|
feature and saying nothing leaves the operator with a disc that scanned
|
|
"fine" and is quietly missing its main title. Mutations: deleting the
|
|
`tracing::warn!` (no event to find); restoring the literal `6017` still
|
|
passes here BY VALUE, which is the point — what is pinned is
|
|
`E_UDF_UNRECORDED_EXTENT`'s value reaching the log, so a renumbering that
|
|
the literal could not follow goes red; logging a neighbouring code (e.g.
|
|
`E_UDF_NO_USABLE_EXTENT`) goes red immediately.
|
|
|
|
**`scan_hddvd_does_not_compose_a_feature_over_a_part_with_no_usable_extent`**
|
|
— The `Ok`-but-empty twin of the test above, and the one that actually bites
|
|
on an ordinary disc: a feature part whose `file_extents` call SUCCEEDS and
|
|
yields nothing usable — here a zero-byte `FEATURE_2.EVO`, no crafted ICB
|
|
required — must refuse the composition too, and must say so in the log.
|
|
Before this, the empty-`Ok` clip entered neither `clip_extents` nor
|
|
`unusable`, so the `any(|n| unusable.contains(..))` guard missed it and the
|
|
`filter(|n| clip_extents.contains_key(..))` beside it quietly DELETED the
|
|
part: the scan composed a `FEATURE` title out of part one alone, still
|
|
advertising itself as the feature, at rc=0, with no diagnostic anywhere.
|
|
Half a movie presented as a whole one — strictly worse than the `Err` case
|
|
the guard was built for, and reachable without a hostile disc. Mutations
|
|
this catches, all of which the pre-fix code exhibited: dropping the
|
|
`extents.is_empty()` branch entirely (a `FEATURE` title appears with one
|
|
extent); inserting into `clip_extents` anyway (same); marking the clip
|
|
unusable but NOT logging (the log assertion fails — absence of a log is
|
|
itself the defect); logging the neighbouring `E_UDF_UNRECORDED_EXTENT`
|
|
instead of `E_UDF_NO_USABLE_EXTENT` (the code assertion fails, because a
|
|
zero-length file is not an authoring hole and triaging the two together
|
|
sends whoever reads it at the wrong population).
|
|
|
|
**`parse_xpl_titles_caps_a_playlist_declaring_absurdly_many_titles`** — A
|
|
disc-supplied playlist nested far deeper than any real one must be REFUSED
|
|
BEFORE the XML parser sees it. `roxmltree` is recursive-descent and its
|
|
depth-10 limit covers only ENTITY expansion, so unbounded element nesting
|
|
blows the thread stack — an ABORT, which no `catch_unwind` and no `Err`
|
|
fallback can contain. ~50k levels is a few hundred KB of well-formed XML,
|
|
far under the read cap. The correct outcome is the same as any other
|
|
unusable playlist: an empty `Vec`, so the caller falls back to the clip-name
|
|
heuristic. Separately, this test also exercises BREADTH, not depth: a flat
|
|
playlist passes the nesting guard trivially and can still declare a title
|
|
per ~51 bytes, so a 64 MiB XPL (exactly `udf::MAX_FILE_BYTES`) reaches ~1.3
|
|
million of them. The cost is drive time, not memory: `compose_xpl_titles`
|
|
runs `probe_evo_streams` once per title and each probe reads real sectors,
|
|
so an uncapped count turns a scan the operator expects to take seconds into
|
|
tens of terabytes of optical reads. `bluray.rs` is not exposed to this
|
|
because its playlist count comes from the MPLS files present in the UDF
|
|
directory; an XPL DECLARES its titles, so nothing bounds them. Mutation:
|
|
delete the `.take(MAX_XPL_TITLES)` and this goes red at the declared count.
|
|
|
|
**`nested_title_xpl`** — Builds a playlist that nests `nesting` `<Title>`
|
|
elements inside one another and puts `clips` self-closing
|
|
`<PrimaryAudioVideoClip>` elements at the innermost level, plus `chapters`
|
|
`<Chapter>` elements. `parse_xpl_titles` collects a title's clips with
|
|
`descendants()`, not `children()`, so EVERY one of the `nesting` ancestors
|
|
collects the SAME innermost clip list — the amplification this exercises.
|
|
|
|
**`parse_xpl_titles_caps_clips_per_title_against_descendant_amplification`**
|
|
— CLIPS PER TITLE is the axis the title cap, the depth cap and the
|
|
directory cap all leave open. `MAX_XPL_TITLES` bounds how many titles a
|
|
playlist may declare and `MAX_XPL_DEPTH` bounds how deeply it may nest, but
|
|
NEITHER bounds how many `<PrimaryAudioVideoClip>` elements a single title
|
|
collects. Worse, the collector is `descendants()`, so one clip nested inside
|
|
N ancestor `<Title>`s is collected N times over — the clip count MULTIPLIES
|
|
by the nesting the depth guard still permits. A 64 MiB XPL
|
|
(`udf::MAX_FILE_BYTES`) holds ~1.7M clip elements at ~38 bytes each; with
|
|
the 32 `<Title>` ancestors the depth cap still permits, that is ~56 million
|
|
heap-allocated `XplClip`s, and then as many `Clip`s again in
|
|
`compose_xpl_titles`. This test uses small numbers that exercise the same
|
|
multiplication without a 64 MiB fixture: pre-fix it collected 756 clips per
|
|
title, 18,900 across the 25 nested titles, from a document declaring only
|
|
756 clip elements — an exact 25x, the nesting count. Mutation: delete the
|
|
`clips.len() >= MAX_XPL_CLIPS_PER_TITLE` break and this goes red at the
|
|
unamplified per-title count.
|
|
|
|
**`parse_xpl_titles_caps_chapters_per_title`** — CHAPTERS PER TITLE is the
|
|
same `descendants()` amplification on the second unbounded `collect()` in
|
|
the same loop. Bounded separately so closing the clip axis does not simply
|
|
move the attack next door. Mutation: delete the
|
|
`.take(MAX_XPL_CHAPTERS_PER_TITLE)` and this goes red at the declared count.
|
|
|
|
**`parse_xpl_titles_keeps_every_clip_of_a_realistic_title`** — The control: a
|
|
realistic multi-clip title still resolves EVERY one of its clips and
|
|
chapters. `SYNTH_XPL`'s main movie is a layer-break split — two clips on one
|
|
timeline — plus two chapters; losing either to the cap would cost a genuine
|
|
disc half its feature. Mutation: set `MAX_XPL_CLIPS_PER_TITLE` to 1 and this
|
|
goes red.
|
|
|
|
**`parse_xpl_titles_accepts_real_world_nesting_depth`** — The depth guard
|
|
must not reject real discs. A genuine `VPLST000.XPL` is a handful of levels
|
|
deep; this is the same document the parsing test uses, plus self-closing
|
|
tags, a comment and a processing instruction that the pre-parse scanner has
|
|
to account for without inflating its depth count.
|
|
|
|
**`scan_hddvd_composes_titles_from_xpl_playlist`** — The authoritative path:
|
|
when a VPLST000.XPL is present, titles come from the playlist — the
|
|
layer-break split (FEATURE_1 + FEATURE_2) is composed into ONE title with
|
|
the real duration, name, chapters, and per-clip title-time offsets — not the
|
|
clip-name heuristic.
|
|
|
|
**`compose_xpl_titles_saturates_absurd_disc_declared_clip_sizes`** — A
|
|
title's `size_bytes` is a running sum of DISC-DECLARED clip sizes: the `u64`
|
|
UDF File Entry Information Length, which nothing cross-checks against the
|
|
extent list that file actually occupies. Two clips can therefore each
|
|
declare a size near `u64::MAX`. Summed with a plain `+=`, that overflows: a
|
|
panic in a debug build — a crash on untrusted disc content, which this
|
|
library must never do — and a wrap to a small garbage total in release,
|
|
which misreports the title's size while looking entirely normal. Mutation:
|
|
restore `size_bytes += *size` in `compose_xpl_titles` and this goes red
|
|
(debug: attempt to add with overflow).
|
|
|
|
**`compose_xpl_titles_dedups_repeated_clip_references_by_evo`** — A crafted
|
|
Advanced-Content playlist can name the SAME `.evo` in
|
|
`<PrimaryAudioVideoClip>` many times over (the XML has no fixed element
|
|
count, unlike MPLS's binary PlayItem count). Each repeat must NOT push
|
|
another copy of that clip's extent list onto the title — mirroring
|
|
bluray.rs's `first_ref = seen_clips.insert(...)` gate, which pushes a clip's
|
|
extents only the first time its id is seen. Here the analogous key is the
|
|
`.evo` filename (this file has no clip_id; `.evo` is what `clip_extents` is
|
|
keyed by). Without the gate, extents grows by the referenced clip's full
|
|
extent list on EVERY repetition — unbounded by the number of on-disc files,
|
|
bounded only by playlist size.
|
|
|
|
**`scan_hddvd_titles_drops_a_split_feature_whose_part_cannot_be_read`** — A
|
|
clip whose extents cannot be resolved must drop the SPLIT FEATURE. Catches
|
|
reverting the `Err(e) => { warn; unusable.insert(..) }` arm in
|
|
`Disc::scan_hddvd_titles` back to the bare `Err(_) => {}` it replaced. RED
|
|
BEFORE GREEN, and TWO earlier attempts at this test did NOT go red. The
|
|
first asserted "no title names the broken clip" — that passes either way,
|
|
because a clip that resolves to no extents is never inserted into
|
|
`clip_extents` and so yields no per-clip title regardless. The second fixed
|
|
that but shipped no `.vti`: `order` is built solely from
|
|
`parse_vti_clip_order` of the navigation file, so with no `.vti` it is
|
|
EMPTY, `feature` is empty, and no composed title is ever built — the
|
|
assertion held vacuously with the fix reverted. Hence the synthetic
|
|
`HVA00001.VTI` fixture: it is what makes the composer run at all. The
|
|
defect lives one level up from the per-clip titles, in the composed
|
|
feature: `unusable` is what tells the composer that a part is MISSING
|
|
rather than merely absent, and the old bare `Err(_) => {}` populated it for
|
|
nothing but an unrecorded extent. So a scratched sector under FEATURE_2's
|
|
ICB (`Error::DiscRead`) left FEATURE_1 composing a title named "FEATURE" by
|
|
itself — half a movie offered as the whole one.
|
|
|
|
**`scan_hddvd_titles_excludes_a_clip_with_zero_sectors`** — A clip whose
|
|
file has a zero-byte size (a degenerate/empty allocation: its ICB's
|
|
allocation descriptor has `data_len == 0`, the UDF AD-list terminator, so
|
|
`file_extents` yields no extent at all) must not produce a title. NOTE: this
|
|
exercises the *upstream* `data_len == 0` terminator path in
|
|
`crate::udf::UdfFs::file_extents`, not the `sectors > 0 && lba > 0` guard in
|
|
`scan_hddvd_titles` itself — with this fixture (`file_extents` never returns
|
|
a `(lba, 0)` tuple, and `PART_START` unconditionally makes every resolved
|
|
`lba` positive) that guard is unreachable in a divergent way; kept here as a
|
|
regression check on the zero-byte-file behavior in its own right.
|
|
|
|
**`scan_hddvd_titles_probes_once_for_entries_resolving_to_identical_extents`**
|
|
— THE THIRD AMPLIFICATION AXIS on HD-DVD title composition, past both
|
|
existing guards. `xpl_depth_within_limit` bounds XML NESTING;
|
|
`MAX_XPL_TITLES` + the `.evo` de-dup bound what an XPL can DECLARE. Both
|
|
live on the playlist path — and an attacker simply omits `/ADV_OBJ`, so
|
|
`read_adv_obj_xpl` returns `None` and neither guard is ever consulted.
|
|
Control then reaches the clip-name FALLBACK, which emits one title per
|
|
`.evo` DIRECTORY ENTRY and probes each. Nothing de-duplicates a FID's ICB
|
|
LBA, so every name in a 1 MiB directory (`udf::MAX_DIR_BYTES`, ~24,000 FIDs)
|
|
can point at ONE File Entry. Each resolves to the SAME extents and each
|
|
costs a full `EVO_PROBE_SECTORS` (16 MiB) probe: ~375 GiB of optical reads
|
|
from a directory that describes a single file. Memoizing the probe on the
|
|
resolved extent list collapses that to ONE probe, because all of those
|
|
names resolve to one extent list. Mutation: drop the memo lookup in
|
|
`EvoProbeCache::streams` (always probe) and this goes red at `ENTRIES`
|
|
probes.
|
|
|
|
**`the_clip_cap_and_probe_budget_bound_a_scans_worst_case_read_volume`** —
|
|
The clip cap and the per-probe read budget are ONE bound, not two, and only
|
|
their product is meaningful: `MAX_HDDVD_CLIPS` probes each cost up to
|
|
`EVO_PROBE_SECTORS`, so the pair fixes the worst-case drive time of a
|
|
`Disc::scan()`. `EvoProbeCache` does not help against a crafted disc here:
|
|
it keys on the RESOLVED extent list, and distinct-but-overlapping extent
|
|
lists all reading one physical region miss the memo every time while every
|
|
read succeeds. So the product is genuinely reachable, and it is the number
|
|
that has to stay within what a scan can absorb. 8 GiB is the ceiling
|
|
asserted here. It is far above any genuine disc — retail HD-DVDs carry tens
|
|
of `.evo` clips, i.e. well under 1 GiB of probing — and far below the 64 GiB
|
|
the pair used to permit, which exceeded the capacity of the dual-layer media
|
|
this format tops out at and took tens of minutes of optical reads. This is
|
|
a relationship, not a magic number: raising EITHER constant without
|
|
re-examining the other trips it.
|
|
|
|
**`scan_hddvd_titles_still_probes_each_distinct_clip`** — CONTROL:
|
|
memoization must not silently collapse DISTINCT clips. A legitimate disc
|
|
with several different `.evo` files still probes each one, and each title
|
|
keeps the streams of ITS OWN clip. Mutation: make `EvoProbeCache`'s key a
|
|
constant (ignore the extents) and this goes red — the junk clip inherits
|
|
the first clip's H.264 streams.
|
|
|
|
**`scan_hddvd_titles_caps_a_directory_declaring_absurdly_many_clips`** —
|
|
Memoization alone is NOT the whole fix. Collapsing identical extent lists
|
|
bounds the one-File-Entry attack, but distinct extent lists are only as
|
|
bounded as the File Entries an attacker cares to lay down — and a File
|
|
Entry is ONE sector. A 1 MiB directory's ~24,000 FIDs can each point at
|
|
their own 1-sector File Entry (a ~48 MiB image) declaring its own huge
|
|
extent, so every probe misses the memo and the amplification is back.
|
|
`MAX_HDDVD_CLIPS` is the bound that closes that. Mutation: drop the
|
|
`clips.len() < MAX_HDDVD_CLIPS` gate in the directory scan and this goes
|
|
red at `ENTRIES`.
|
|
|
|
## EvoProbeCache
|
|
|
|
`EvoProbeCache` memoizes `probe_evo_streams`, keyed on the RESOLVED extent
|
|
list. Probing is the expensive half of title composition: each pass reads up
|
|
to `EVO_PROBE_SECTORS` (16 MiB) off the medium. Both composition paths can
|
|
reach the same physical clip many times over — an XPL naming one `.evo` from
|
|
many `<Title>`s, or a directory whose FIDs all point at one File Entry — and
|
|
a probe is a pure function of the extents it reads, so the second and later
|
|
passes over an identical extent list are re-reads of bytes already seen. The
|
|
key is the extent list itself, NOT the clip name or title: two names for one
|
|
File Entry share a key (one probe), while two genuinely different clips have
|
|
different `start_lba`s and so keep their own probes and streams. Cache size
|
|
is bounded by the caller's title/clip cap (`MAX_XPL_TITLES` /
|
|
`MAX_HDDVD_CLIPS`).
|