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.
151 lines
7.8 KiB
Markdown
151 lines
7.8 KiB
Markdown
# `src/disc/extract.rs` — decrypted file-tree extraction
|
|
|
|
## Relation to the ISO sweep/patch recovery passes
|
|
|
|
`extract_tree` is a sibling of the disc→ISO sector dump (the sweep/patch
|
|
recovery passes, which now live in the `freemkv-engine` crate), specialized
|
|
to write **per file** rather than a whole image, applying decryption on the
|
|
way out, and **without** any multipass / recovery orchestration — it is
|
|
1-shot, decrypt-only.
|
|
|
|
No new error codes are introduced: it reuses the existing `Error` set
|
|
(`error.rs` is numeric-only, no English).
|
|
|
|
## `extract_tree` contract details
|
|
|
|
`reader` is consumed for content reads (it is moved into a
|
|
`DecryptingSectorSource`); the structure + extent metadata are read first
|
|
with the raw reader. `dest` receives the tree STRAIGHT IN (no auto-named
|
|
subfolder). The caller must have run the pre-flight decrypt gate
|
|
(`Disc::ensure_decryptable`); this method resolves per-VTS CSS keys itself
|
|
for DVD.
|
|
|
|
Bad sectors become recorded zero-filled holes in the affected file (the run
|
|
does not abort); undecryptable units are counted as loss. Files are written
|
|
`<name>.partial` and renamed on success, so an interrupted run never leaves
|
|
a half-written file that looks complete.
|
|
|
|
## `resolve_vts_key` — why a failed crack must be a hard error
|
|
|
|
FALLIBLE, because the three outcomes of a crack are not two.
|
|
[`crate::css::CrackOutcome`] exists precisely to separate "no scrambled
|
|
sector was seen, so there is nothing to decrypt" from "scrambled sectors
|
|
were seen and no key came out", and its doc says callers MUST surface the
|
|
second as a hard error. An earlier version of this function used the
|
|
`Option`-returning `crack_key`, which collapses both into `None`, and then
|
|
fell back to the disc-wide key — descrambling this VTS with ANOTHER VTS's
|
|
title key. The hazard is described in the ordering note in the function
|
|
body, which was written about the same fallback; ordering makes the crack
|
|
far more likely to succeed, but it cannot make a failed crack safe.
|
|
|
|
## `Borrowed` — why a borrowing `SectorSource` wrapper
|
|
|
|
Lets the decrypting decorator "own" an inner source for its lifetime while
|
|
the caller keeps the underlying `&mut dyn SectorSource` (the decorator is a
|
|
`DecryptingSectorSource<S>` generic over `S`, so it does NOT require a
|
|
`'static` boxed inner — unlike the mux highway, which takes the reader by
|
|
value). The decorator is dropped before `extract_tree` returns, so the
|
|
borrow never escapes.
|
|
|
|
## `whole_unit_batch` — the trailing-partial rule
|
|
|
|
`remaining` is the sectors left in the current extent; the batch is capped
|
|
at `READ_BATCH_SECTORS` and rounded DOWN to a whole number of 3-sector units
|
|
UNLESS it is the extent's final (possibly short) tail — the tail always
|
|
begins on a unit boundary, so a 1-2 sector partial there is handled by
|
|
`decrypt_sectors`' trailing-partial contract. Used by `extract_one_file` so
|
|
this rounding rule lives in exactly one place.
|
|
|
|
## `available_space` (windows) — why the free-space gate matters
|
|
|
|
Windows has no `statvfs`. This once returned `None` unconditionally, which
|
|
does not merely skip a test — it skipped the free-space GATE, so a Windows
|
|
user extracting a disc to a full volume got a confusing failure part-way
|
|
through instead of a clear refusal up front. libfreemkv ships a Windows
|
|
GUI, so that is the platform where the friendly error matters most.
|
|
`FreeBytesAvailableToCaller` (not `TotalNumberOfFreeBytes`) is used because
|
|
it accounts for per-user quotas, matching what `statvfs`'s `f_bavail` gives
|
|
on the unix side.
|
|
|
|
## `sanitize_component` — why reserved names are substituted, not rejected
|
|
|
|
A name whose base matches a Windows reserved device name (`NUL`, `CON`,
|
|
`COM1`..) is **substituted** (prefixed with `_`) rather than rejected — a
|
|
Linux-authored disc may legally carry such a file and a single reserved
|
|
name must not abort the whole tree walk. An empty result after stripping
|
|
is an error.
|
|
|
|
## `names_differing_only_by_case_are_a_collision` — why this must be an error
|
|
|
|
Two disc names differing only by CASE are one host file on macOS APFS and
|
|
Windows NTFS, both case-insensitive by default. Keyed by the case-preserving
|
|
path, the collision map would see two entries, raise nothing, and the
|
|
second file extracted would overwrite the first — while both `PlannedFile`s
|
|
report `complete: true`. Silent data loss reported as a clean extract is
|
|
the one outcome this crate must never produce, so the host-equivalence key
|
|
has to model the host's namespace, not the disc's.
|
|
|
|
## `a_files_partial_path_colliding_with_another_files_final_name_is_an_error`
|
|
|
|
A disc carrying both `X` and `X.partial` plans two distinct final names, so
|
|
nothing collides in the final-name check — but extracting `X` writes
|
|
through `X.partial`, the same host path the other file owns. Whichever
|
|
lands second truncates the other, and both entries would still be reported
|
|
complete without this guard.
|
|
|
|
## `multi_extent_aacs_anchors_unit_base_per_extent` — the alignment bug
|
|
|
|
Regression (rc.6 audit, finding #449): a MULTI-EXTENT AACS file must
|
|
re-anchor the unit-alignment base PER extent, not once at the first extent.
|
|
The two extents in this test start at absolute LBAs whose difference is NOT
|
|
a multiple of 3 (`PART_START+5000` vs `PART_START+5004` — a 4-sector delta).
|
|
With a single first-extent unit base the second extent's first read is off
|
|
the unit grid (offset 4, `4 % 3 == 1`) → `is_unit_aligned` fails →
|
|
`DecryptFailed` → the whole extent becomes a zero-filled (false) hole. With
|
|
per-extent anchoring both extents read clean: `bytes_unreadable == 0` and
|
|
the file content survives. The fixture content is clear (no TS syncs) so
|
|
the decrypt step restores each unit verbatim — this isolates the GATE from
|
|
the cipher math.
|
|
|
|
## `extract_tree_zero_fills_an_unrecorded_extent_instead_of_reading_it`
|
|
|
|
An ECMA-167 4/14.14.1.1 type-1 extent is ALLOCATED BUT NOT RECORDED: the
|
|
space belongs to the file and occupies its byte range, but nothing was ever
|
|
written there, and the standard defines its contents as zeros.
|
|
`read_icb_extents` keeps the flag (`IcbExtent::recorded`) and
|
|
`read_file_limited` honours it by emitting zeros WITHOUT touching the
|
|
media. The tree extractor reads the same ICBs and must agree: reading those
|
|
sectors returns whatever the media happens to hold there — on an AACS disc,
|
|
ciphertext that decrypts to noise — and writes it into the extracted file
|
|
as if the disc had recorded it.
|
|
|
|
## `per_extent_base_is_aligned_first_extent_base_is_not`
|
|
|
|
Focused alignment-computation check underpinning the per-extent fix: when
|
|
each extent anchors its OWN start as the unit base, the extent's own batch
|
|
starts are always unit-aligned; anchoring a later extent against the FIRST
|
|
extent's base mis-aligns whenever the extents' starts differ by a
|
|
non-multiple of 3 sectors. This is the exact arithmetic the decrypt-on-read
|
|
gate (`aacs::content::is_unit_aligned`) performs.
|
|
|
|
## `css_two_vts_groups_do_not_cross_contaminate_keys`
|
|
|
|
Regression: the per-VTS key crack in `resolve_vts_key` must gather ONLY
|
|
this VTS's own title-VOB extents. Two VTS groups here carry DISTINCT
|
|
scrambling keys; if the group filter (`vts_group_of(..) == Some(vts) &&
|
|
is_title_vob(..)`) is loosened (`!=`, or `&&` -> `||`), one VTS's resolve
|
|
gathers the OTHER VTS's extents, cracks the wrong key, and that VTS's own
|
|
content silently fails to descramble to the expected plaintext.
|
|
|
|
## `a_scrambled_vts_that_cannot_be_cracked_fails_instead_of_borrowing_a_key`
|
|
|
|
A VTS that IS scrambled but whose key could not be recovered must FAIL, not
|
|
borrow another VTS's key. `crack_key` returns `Option`, which collapses "no
|
|
scrambled sector was seen" with "scrambled sectors were seen and no key
|
|
came out"; the fallback then descrambled this VTS under the disc-wide key
|
|
and `extract_tree` reported `complete = true` at exit 0, because no read
|
|
had failed. `CrackOutcome` exists to keep those two apart, and every
|
|
sibling path in the crate already honours it. The fixture: VTS_01 is
|
|
crackable, VTS_02 is scrambled with NO periodic crib, so its crack
|
|
genuinely fails.
|