Skip to content

feat(mft): for_each_record — streaming fixed-up targeted record reads#577

Merged
githubrobbi merged 3 commits into
mainfrom
feat/mft-for-each-record
Jul 23, 2026
Merged

feat(mft): for_each_record — streaming fixed-up targeted record reads#577
githubrobbi merged 3 commits into
mainfrom
feat/mft-for-each-record

Conversation

@githubrobbi

Copy link
Copy Markdown
Collaborator

What

The targeted per-candidate MFT re-read (resolve_frs_to_lcn) already reads every requested record's full bytes off the volume — then parses out a single LCN and discards them. Downstream consumers need those bytes (resident $DATA content, reparse targets, forensic attributes) and were paying for the read anyway. This exposes them, streaming and bounded.

API

  • New: uffs_mft::for_each_record(volume, frs_list, visit) — reads each distinct FRS in ascending order, applies the NTFS Update-Sequence-Array fixup, and hands the fixed-up record to the callback as a borrow from one reused scratch buffer: no per-record allocation, retention impossible by construction. Per-record failures (outside $MFT, transient I/O, torn write) fold to None without aborting the pass; Err only when $MFT's extents can't be determined at all. Works on live volumes and VSS snapshot devices — the same VolumeHandle contract as resolve_frs_to_lcn. MftRecordReader stays pub(crate).
  • resolve_frs_to_lcn now delegates to it (identical output contract). Side effect: the LCN parse now runs on fixup-verified bytes — previously it parsed raw record bytes, so a runlist straddling a sector boundary (offset 510–511) could read the USA sentinel instead of data.
  • first_data_lcn is now public — callers derive the LCN from the callback bytes with the exact parser resolve_frs_to_lcn uses, so one pass yields LCN ordering and record contents with no second call and no duplicated runlist parsing.

Hot-path impact: none

Purely additive. The bulk-ingest readers and the search path never touch this module; the only in-repo caller of resolve_frs_to_lcn is the opt-in resolve_lcn_order post-processing pass in the daemon, which is unchanged in behavior and gains only a ~1 KiB memcpy + fixup per candidate — noise against the disk-bound read it wraps. No index schema, wire, or memory-footprint changes.

Tests

  • USA fixup round-trip; torn-write and bad-magic rejection.
  • End-to-end fixture proving fixup is load-bearing: a resident $DATA value straddling the sector-1 boundary parses to real content only after fixup (raw bytes hold the sentinel).
  • Sort/dedup ordering of the want-list.
  • Ignored live-volume parity test (--ignored, elevated Windows): for_each_record + first_data_lcn must reproduce resolve_frs_to_lcn exactly, callbacks ascending + deduplicated, out-of-MFT FRS folds to None.

Gates: full workspace suite (2498), lint-prod, lint-tests, xwin Windows clippy, rustdoc (--document-private-items --locked), doc-tests — all green; full pre-push bundle passed on push.

Implements docs/dev/architecture/UFFS_MFT_RECORD_READ_ASK.md: the
targeted per-candidate MFT re-read (resolve_frs_to_lcn) already reads
every record's full bytes and discarded them. Expose those bytes.

- New public uffs_mft::for_each_record(volume, frs_list, visit):
  reads each distinct FRS in ascending order, applies the NTFS
  Update-Sequence-Array fixup, and hands the bytes to the callback as
  a borrow from one reused scratch buffer — bounded memory, no
  per-record allocation, per-record failures fold to None without
  aborting the pass. Works on live volumes and VSS snapshot devices
  (same VolumeHandle contract as resolve_frs_to_lcn).

- resolve_frs_to_lcn now delegates to for_each_record (identical
  output contract). Side effect: its parse now runs on fixup-verified
  bytes — previously it parsed raw bytes, so a runlist straddling a
  sector boundary could read the USA sentinel instead of data.

- first_data_lcn (runlist → first real $DATA LCN) is now public, so
  one for_each_record pass yields LCN ordering and record content
  without a second call or a consumer-side runlist parser.

Tests: USA fixup round-trip and torn-write/bad-magic rejection, a
resident $DATA value straddling the sector-1 boundary proving fixup
is load-bearing, sort/dedup ordering, and an ignored live-volume
parity test (for_each_record + first_data_lcn == resolve_frs_to_lcn).
@githubrobbi
githubrobbi marked this pull request as draft July 22, 2026 23:55
…ree LCN path

Downstream review of for_each_record, three items:

- first_data_lcn no longer allocates: AttributeRef::data_runs_iter()
  (new, public) decodes mapping pairs lazily via the new DataRunIter,
  and first_data_lcn stops at the first non-sparse run. data_runs() /
  parse_data_runs / extract_data_runs_from_attribute are now collect()
  wrappers over the lazy decoders — identical results, frozen by the
  existing runlist tests.

- The callback now receives RecordOutcome instead of Option<&[u8]>,
  separating NotInMft (benign absence) / Io (transient read failure) /
  Corrupt (record exists, failed USA fixup — itself a forensic signal)
  from verified Bytes. RecordOutcome::bytes() collapses to the old
  Option semantics for callers without forensic interest;
  resolve_frs_to_lcn output is unchanged. Designed in now, while the
  API is unreleased, so the signature never needs reopening.

- Doc precision: the bounded-memory promise is two reused record-sized
  buffers (reader-internal aligned I/O + fixup scratch), not one.
…mask docs

- New AttributeRef::is_unnamed(): const, allocation-free header check
  for the primary-stream test. name().is_none() decoded the full
  UTF-16 name into a Vec just to discard it; first_data_lcn now uses
  the predicate, making the whole per-candidate parse allocation-free
  (the earlier round fixed only the runlist side). Regression test:
  a named $DATA stream (ADS) must be skipped by first_data_lcn.

- Docs: for_each_record / resolve_frs_to_lcn now state that frs_list
  holds 48-bit MFT record numbers — mask the sequence off a full file
  reference (file_reference & 0x0000_FFFF_FFFF_FFFF) or the read
  silently addresses the wrong record. Module-doc buffer wording
  aligned with the two-buffer reality.
@githubrobbi
githubrobbi marked this pull request as ready for review July 23, 2026 00:43
@githubrobbi
githubrobbi added this pull request to the merge queue Jul 23, 2026
Merged via the queue into main with commit e1ba59b Jul 23, 2026
28 checks passed
@githubrobbi
githubrobbi deleted the feat/mft-for-each-record branch July 23, 2026 01:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant