Files
libfreemkv/docs/extract.md
T
Matthew Jackson 6e14fc0c0c libfreemkv: 1.7.0 groundwork — sector-recycle soundness + CI hardening
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.
2026-09-02 12:40:55 -07:00

7.8 KiB

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 PlannedFiles 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.