fix(extract): anchor AACS unit base per-extent + crash-safety hardening

The dir:// extractor set the AACS unit-alignment base ONCE to the first
extent's start, then read every extent against that single base. For a
multi-extent (fragmented / Long-AD / continuation-ICB) file the second
and later extents start at arbitrary LBAs whose offset from the first
extent is generally not a multiple of 3 sectors, so the first read of
each later extent failed the decrypt-on-read gate
(is_unit_aligned(lba, unit_base)), returned DecryptFailed, and recorded
the whole extent as a zero-filled hole even though the data was readable.
Re-anchor the unit base PER extent (matching mux/disc.rs and
sector/prefetched.rs), so each clip's encrypted region gates on its own
unit grid. Same bug class as the rc.5.2 clip-anchor fix.

Also harden the extract + keydb write paths:
- finalize_file: fsync the .partial after set_len (the truncation runs
  on a second handle the content fsync never touched) and fsync the
  parent dir after rename so the new dirent is crash-durable.
- keydb write_atomic: fsync the parent dir after rename (POSIX dirent
  durability), matching the finalize_file pattern.
- AACS tail batch: document that decrypt_sectors' trailing-partial
  contract already handles the short final unit; no math change.
- decrypt-loss delta loads use Acquire (defensive happens-before if
  file extraction is ever parallelised).
- is_windows_reserved: add CONIN$/CONOUT$/CLOCK$; reserved names are
  now substituted (prefix _) instead of aborting the whole tree walk, so
  a legal Linux-authored NUL.cfg extracts.
- http_get header cap: >= MAX_HEADER_BYTES (was > , one byte over).

Regression tests: multi-extent AACS file (Δ4-sector extents) extracts
both extents with zero loss; focused per-extent alignment-arithmetic
test; reserved-name substitution assertions.
This commit is contained in:
Matthew Jackson
2026-06-24 23:20:15 -07:00
parent d4a0f5b786
commit f407c4c693
2 changed files with 292 additions and 20 deletions
+10 -2
View File
@@ -176,6 +176,12 @@ fn write_atomic(path: &std::path::Path, text: &str) -> Result<()> {
tracing::warn!(error = %e, path = %path.display(), "keydb rename failed; keydb unchanged");
return Err(werr());
}
// Durably commit the new dirent: on POSIX filesystems (ext2, some NFS) a
// crash right after the rename can lose the directory entry even though the
// rename returned. Best-effort (swallowed on failure); no-op on Windows.
if let Some(dir) = path.parent() {
crate::io::fsync::dir(dir);
}
Ok(())
}
@@ -241,9 +247,11 @@ fn http_get(url: &str) -> Result<Vec<u8>> {
if header_buf.ends_with(b"\r\n\r\n") {
break;
}
if header_buf.len() > MAX_HEADER_BYTES {
if header_buf.len() >= MAX_HEADER_BYTES {
// Oversized header block from the server: a protocol-level
// fault, not a keydb content parse failure.
// fault, not a keydb content parse failure. `>=` caps the
// buffer at exactly MAX_HEADER_BYTES (the `>` form admitted one
// extra byte before tripping).
return Err(Error::KeydbConnect { host: host.clone() });
}
}