Skip to content

fix(mft): default-pipeline MFT correctness gaps + panic hardening - #569

Merged
githubrobbi merged 7 commits into
mainfrom
fix/mft-default-pipeline-standard-info
Jul 20, 2026
Merged

fix(mft): default-pipeline MFT correctness gaps + panic hardening#569
githubrobbi merged 7 commits into
mainfrom
fix/mft-default-pipeline-standard-info

Conversation

@githubrobbi

Copy link
Copy Markdown
Collaborator

Summary

A completeness/correctness audit of uffs-mft's two production record parsers (io::parser::unified::process_record, the default bulk-load pipeline, and parse::direct_index::parse_record_to_index + direct_index_extension.rs, the live USN-journal incremental-update pipeline) found a recurring bug class: struct fields that were already declared, with the underlying bytes already fully parsed into memory, but never actually copied onto the record on the path that runs in production today. Every one of these was populated correctly by the legacy ParsedRecord pipeline (reachable only via non-default read modes / the DataFrame export path) — the default path silently diverged.

Fixed, in order:

  1. StandardInfo.usn/security_id/owner_id — both parsers only ever read the 36-byte NTFS 1.2 $STANDARD_INFORMATION form regardless of the record's real value_length, so every NTFS 3.0+ record silently lost these three fields. Routed both parsers through the already-correct, already-tested parse_standard_info_full (previously legacy-only) instead of duplicating buggy inline logic.

  2. Panic-on-malformed-input hardening — while investigating whether a third parser (io/parser/index.rs) was dead code, found it was actually the WI-5.2 panic-hardened sibling of the parser that's actually live, and the live one (direct_index.rs) never received that hardening. Reproduced and fixed a real panic (range start index ... out of range) on a crafted malformed record on the daemon's live incremental-update path (the daemon builds with panic = "abort", so this was a real whole-process DoS, not theoretical). io/parser/index.rs + its extension helper (1868 lines) were then deleted as pure duplicated dead code, confirmed zero production call sites in this repo or uffs-products.

  3. IndexStreamInfo/InternalStreamInfo.is_sparse/is_resident — both bits already existed in the packed flags byte; every write site across both parsers (~10 call sites: default $DATA, every ADS, $REPARSE_POINT, non-$I30 index attributes, $OBJECT_ID-family, the unknown-type catch-all) hardcoded them to false regardless of the real attribute. Both bits are free to populate (is_resident from already-branched-on is_non_resident, is_sparse from the attribute header's own already-parsed ATTRIBUTE_FLAG_SPARSE bit).

  4. FileRecord.lsn/namespace/fn_created/fn_modified/fn_accessed/fn_mft_changedlsn (Log File Sequence Number, forensic value) sat unused in the already-parsed record header; namespace and $FILE_NAME's own four timestamps (which diverge from $STANDARD_INFORMATION under timestomping) were decoded from fn_attr and then discarded before ever reaching the record. Also fixed an adjacent pre-existing gap: a record whose $FILE_NAME arrives only via a later extension record never got sequence_number/lsn set at all on the early-return path.

  5. Real 800-LOC-policy fix, not an exception — the fixes above pushed direct_index.rs and unified.rs over the file-size policy threshold. Fixed it properly: extracted four small helpers that were each duplicated verbatim across 4-5 match arms in direct_index.rs (genuine dedup, not line-count padding), and split unified.rs's self-contained NTFS name-decoding cluster into a new sibling module (unified/name_codec.rs) with zero behavior change. Both files are now genuinely under threshold with real margin; no file_size_exceptions.txt entries added.

  6. Extension-record merge path — applying the same audit to direct_index_extension.rs's "base record has no $FILE_NAME, promote the first extension name to primary" path found the identical bug: name text and parent FRS were copied, but namespace/timestamps were decoded and then discarded. Fixed, and applied the same helper-extraction refactor there too (its own pre-existing file-size exception is no longer needed either).

Explicitly out of scope (by design, not oversight)

Confirmed via direct tracing of parse/merger.rs that FileRecord.base_frs/is_extension are correctly always-zero in both pipelines (extension ParsedRecords are fully consumed during merge and never survive as standalone entries) — not a bug. reparse_tag is deliberately not surfaced to the public CompactRecord (already correct in MftIndex, kept out of the free/public search index by design). Per-additional-hardlink $FILE_NAME timestamps, $DATA LCN capture, $REPARSE_POINT target path, $OBJECT_ID, $EA content, and ADS is_compressed all need either new storage or new parsing — deferred to the private rich-index work, not public bugs.

Test plan

  • cargo test -p uffs-mft --lib — 261/261 passing
  • Every fix has a dedicated regression test built from a synthetic MFT record fixture, each verified red-before/green-after against the actual bug
  • just lint-prod / just lint-tests / cargo fmt --check / file-size policy / cargo doc --document-private-items all green on every commit
  • Cross-platform cargo check --workspace (index-reading logic builds off-Windows too)

…sers

StandardInfo already declares usn/security_id/owner_id and StandardInfo::
from_extended already copies them through, but the two record-parsing
pipelines that actually run in production never called it:

- io/parser/unified.rs process_record (the default MftReadMode::Auto ->
  SlidingIocpInline bulk-load path) unconditionally read only the 36-byte
  NTFS 1.2 StandardInformation and built StandardInfo via
  from_raw_ntfs_flags, whose own doc says the caller must set these three
  fields separately -- it never did.
- parse/direct_index.rs parse_record_to_index (the live USN-journal
  incremental-update path, called from usn/windows.rs) had the identical
  bug: read only the 36-byte struct and dropped usn/security_id/owner_id
  before reaching StandardInfo::from_extended.

Only the legacy ParsedRecord pipeline (parse/attribute_helpers.rs
parse_standard_info_full, reachable via non-default read modes / the
DataFrame export path) was already correct -- it branches on
value_length to read the 72-byte NTFS 3.0+ StandardInformationExtended
form when present.

Fix: bump parse_standard_info_full to pub(crate) and call it from both
production parsers instead of duplicating (buggy) inline logic. Net
deletion of duplicated code in favor of the one already-tested
implementation.

These fields have no bearing on ordinary search/filter/display -- only
forensic/security-auditing consumers (timestomping detection via
STD_INFO vs FILE_NAME divergence, ACL/USN correlation) read them -- so
this was a silent, zero-functional-impact gap until now, not a live bug.
Fixing it is free: the fields already exist in StandardInfo's on-disk
layout, the bytes are already read off disk in the same pass, and the
extra cost is one well-predicted branch.

Out of scope for this commit (flagged, not touched):
- io/parser/index.rs's parse_record_to_index has zero production call
  sites (only its own test module calls it) -- looks like dead code,
  left alone to avoid fixing unreachable paths.
- $FILE_NAME's own per-name timestamps: the modern MftIndex per-name
  storage (IndexNameRef/first_name/hard-link chain) has no timestamp
  fields at all, unlike the legacy FileRecord/NameInfo struct. Adding
  them means real, permanent per-hard-link storage growth -- a different
  cost/benefit decision than this free fix.

Added a regression test exercising both production parsers end-to-end
against a synthetic 72-byte StandardInformationExtended record.
…lete superseded dead parser

Investigating whether io/parser/index.rs's parse_record_to_index (zero
production call sites, only its own test module) was legacy/dead code
surfaced a more serious finding: it isn't just dead -- it's the WI-5.2
panic-hardened (checked arithmetic, .get()-only access) sibling of the
parser that's actually live, and the live one never got that hardening.

parse/direct_index.rs's parse_record_to_index -- the parser actually
wired to the daemon's live USN-journal incremental-update path via
usn/windows.rs:466 -- reads a resident attribute's 4-byte value_length
(offset+16..20) and 2-byte value_offset (offset+20..22) via raw
&data[a..b] slicing in 7 places (StandardInformation, FileName,
ReparsePoint x2, IndexRoot x2, ObjectId/EA/etc, and the unknown-type
catch-all), with no bounds check beyond the outer attribute-length gate.
A resident attribute whose *declared* length is short enough to pass
that gate but too short to cover those fixed fields -- e.g. sitting at
the tail of a truncated or corrupted record -- panics:

  range start index 76 out of range for slice of length 74

reproduced live against a crafted record. The daemon builds with
panic = "abort", so this is a real whole-process DoS on a malformed MFT
record during journal replay, not a theoretical one. The existing
malformed_records_do_not_panic corpus never caught it because (a) it
never exercised this specific parser at all, and (b) RecordBuilder
leaves bytes_in_use at 0, which makes every parser's attribute loop
short-circuit before touching a single attribute byte -- so the corpus
wasn't reaching the code it claimed to stress-test for any parser.

Fix:
- Add rd_u16/rd_u32 (checked, .get()-based, mirroring unified.rs's
  existing helpers) and route all 7 unguarded reads through them.
- assert_all_parsers_survive now patches bytes_in_use to the record's
  real length before running each parser, so the existing corpus
  actually reaches attribute-body code instead of trivially short-
  circuiting; added crate::parse::parse_record_to_index (the real,
  live parser) to the parsers it exercises.
- Added a dedicated regression case reproducing the exact bug shape
  (short declared length at the tail of the buffer) across all 6
  affected attribute types. Verified red before the fix (panics with
  the exact message above), green after.

With direct_index.rs now at parity, io/parser/index.rs (+ its
index_extension.rs helper, 1671 lines total) is pure duplicated dead
code, not a fallback worth keeping -- deleted both, along with the
index_helpers.rs functions (InternalStreamChain, ExtensionSnapshot,
merge_extension_streams, merge_extension_names) that existed only to
serve them. Updated io.rs / io/parser/mod.rs re-exports accordingly
(parse_record_to_index is no longer reachable via uffs_mft::io::*;
confirmed zero consumers anywhere in this repo or uffs-products).
Fixed docs/architecture/engine/03-ntfs-parsing.md's two source-path
references, which had pointed at the dead file all along.
…ction parsers

IndexStreamInfo and InternalStreamInfo both already declare bit0=is_sparse,
bit1=is_resident in their packed flags byte -- but every write site in both
production parsers (unified.rs's process_record and direct_index.rs's
parse_record_to_index) hardcoded those two bits to 0/false regardless of the
real attribute, for every stream type: the default $DATA stream, every named
$DATA (ADS), $REPARSE_POINT, non-$I30 $INDEX_ROOT/$INDEX_ALLOCATION/$BITMAP,
$OBJECT_ID and friends, and the unknown-type catch-all. Every ADS reported as
non-sparse/non-resident no matter what it actually was.

Both bits are free to populate: is_resident is already known everywhere
(attr_header.is_non_resident == 0, already read to pick the size-calc branch),
and is_sparse lives in the attribute record header's own ATTRIBUTE_FLAG_SPARSE
bit (0x8000), already-parsed data with no new I/O. No change to any on-disk
struct layout or size.

While auditing this, found and fixed the same panic-on-malformed-input gap in
parse/direct_index_extension.rs (the extension-record sibling of
direct_index.rs) that was fixed for direct_index.rs itself in the previous
commit: unguarded 16-byte value_length / 2-byte value_offset reads via raw
&data[a..b] slicing, now routed through checked rd_u16/rd_u32 helpers. This
file processes extension records on the same live USN-journal path and had
never received the WI-5.2 hardening pass either.

- index_helpers.rs: add_stream_to_index now takes is_sparse/is_resident and
  bakes them into the flags byte alongside the existing type_name_id bits.
  Added a StreamEntry type alias ((name, size, allocated, is_sparse,
  is_resident)) to keep the SmallVec tuple under clippy::type_complexity.
- direct_index.rs / direct_index_extension.rs: compute is_sparse/is_resident
  at every one of the ~10 stream-producing sites (Data/ADS, ReparsePoint,
  IndexRoot family, ObjectId family, catch-all) and thread them through;
  set the default stream's first_stream.flags, which neither file did before
  (it was left at its zero default, losing type_name_id too).
- unified.rs: computed once per attribute in the shared catch-all dispatch
  arm and applied to all 4 write sites ($I30, default $DATA, ADS,
  internal-stream).

Added a regression test building a synthetic non-resident, sparse-flagged
ADS and asserting IndexStreamInfo::is_sparse()/is_resident() come out
correct (not hardcoded false) on both process_record and
parse_record_to_index.
…roduction parsers

FileRecord already declares lsn (Log File Sequence Number, from the header's
own log_file_sequence_number), namespace (the primary name's $FILE_NAME
namespace), and fn_created/fn_modified/fn_accessed/fn_mft_changed ($FILE_NAME's
own timestamps, which often differ from $STANDARD_INFORMATION -- e.g.
timestomping alters STD_INFO but leaves FILE_NAME original). All five fields
are exactly the same bug class as usn/security_id/owner_id and is_sparse/
is_resident fixed in the previous two commits: already declared on the
struct, already fully decoded in memory (the header and $FILE_NAME attribute
are both read in full regardless), but never copied onto the record by
either unified.rs's process_record or direct_index.rs's parse_record_to_index
-- so every record silently read back lsn=0, namespace=0, and all four
fn_* timestamps=0, no matter what the disk actually held.

- unified.rs: lsn set alongside the existing base-record-only
  sequence_number (extension records carry their own, differently-scoped
  LSN, matching the sequence_number precedent already established there).
  namespace/fn_* set at the same point $FILE_NAME's name/parent_frs are
  already written, matching the file's existing "push-to-front, most recent
  $FILE_NAME wins" model.
- direct_index.rs: same fields threaded through via new locals
  (primary_fn_created/modified/accessed/mft_changed), captured whenever a
  name is chosen as primary (the existing Win32 > POSIX > DOS priority
  logic), written to the record alongside sequence_number.

While auditing this, found and fixed a related pre-existing gap in
direct_index.rs: the "no $FILE_NAME in base record" early-return path (the
name arrives later via an extension record) never set sequence_number or lsn
at all -- and nothing else in the pipeline would either, since an extension
record's header carries its own, different-meaning sequence/LSN. A record
whose name lands in an extension record was silently missing its own
identity fields.

Added regression tests: one asserting lsn/namespace/fn_* reach both
production parsers from a real $FILE_NAME attribute, one asserting
sequence_number/lsn are still set via the no-name early-return path.
… policy for real

The previous three commits' fixes pushed both files over the file-size
policy threshold. Fixed it properly instead of adding policy exceptions:

- direct_index.rs: extracted four small helpers
  (is_primary_attribute/extract_attr_name/read_size_allocated/
  resident_and_sparse) that were each duplicated verbatim across 4-5
  match arms (Data, ReparsePoint, IndexRoot-family, ObjectId-family,
  catch-all) -- genuine deduplication, not just fewer lines. 856 -> 724
  lines, and every one of those match arms is now shorter and less
  repetitive to read, not just smaller on a line-count report.

- unified.rs: split the NTFS UTF-16/WTF-8 name-decoding cluster
  (decode_utf16le_into, decode_name_u16, wtf8_from_utf16le,
  store_name_lossless, the LOSSY_NAME_COUNT tally) into a new sibling
  module, unified/name_codec.rs. That cluster has zero dependency on
  process_record's attribute-loop state -- it only needs MftIndex -- so
  it was always a separable concern, just never separated. Re-exported
  decode_name_u16/lossy_name_count from unified.rs so the ~9 other
  modules that call them via crate::io::parser::unified::* keep working
  unchanged. 803 -> 559 lines in unified.rs; name_codec.rs is 270 lines,
  comfortably under the threshold on its own.

Also extracted a small resident_value_offset(data, attr_offset) helper
in unified.rs for the value-offset-field read duplicated between the
$FILE_NAME and $REPARSE_POINT arms.

All prior documentation is preserved verbatim -- this is a structural
split, not a comment trim. cargo test -p uffs-mft: 260/260 passing,
identical to before the split (the extraction is behavior-preserving by
construction: same logic, moved, not rewritten). Neither file needs a
scripts/ci/file_size_exceptions.txt entry anymore.
… via an extension record

FileRecord.base_frs audit: confirmed correct as-is. Traced parse/merger.rs
(the legacy pipeline) directly -- extension ParsedRecords are fully merged
into their base record and never survive into the final Vec<ParsedRecord>,
so base_frs is genuinely Frs::ZERO for every record that reaches MftIndex,
in both the legacy and modern pipelines. Not a bug; no change needed.

Continuing the same audit surfaced a real one: direct_index_extension.rs's
"base record has no $FILE_NAME, promote the first extension name to
primary" merge path (the case where a file has enough attributes to
overflow its base MFT record and $FILE_NAME lands in an extension record)
copied only the name text and parent FRS into first_name, silently
dropping namespace and all four $FILE_NAME timestamps -- even though
`$FILE_NAME`'s own attribute bytes were already fully decoded and even
already read once by the same function. Every such record read back
namespace=0 and fn_created/fn_modified/fn_accessed/fn_mft_changed=0
forever, with nothing else in the pipeline able to fix it later.

Root cause: the `names` collection only ever carried (name, parent_frs) --
namespace/timestamps were decoded from `fn_attr` and then discarded before
even reaching the promotion site. Extended it to a 7-field ExtNameEntry
tuple (name, parent_frs, namespace, fn_created, fn_modified, fn_accessed,
fn_mft_changed) and copy those fields onto the record alongside the name
whenever it's promoted to primary. `LinkInfo` (storage for non-primary
hard links) still has no room for these fields, so this only applies to
whichever name ends up primary -- the same structural constraint as
FileRecord's own fn_* fields.

While touching this file, applied the same helper-extraction refactor
already done for direct_index.rs (is_primary_attribute/extract_attr_name/
read_size_allocated/resident_and_sparse) -- same duplicated logic, same
fix. 821 -> 762 lines, genuinely under the file-size policy threshold now
instead of relying on its pre-existing PERMANENT exception, which is
removed along with the stale "same reasoning as direct_index.rs" claim
(direct_index.rs no longer needs one either).

Added a regression test: base record with zero $FILE_NAME attributes,
followed by an extension record carrying the file's only name, asserting
namespace/fn_* land on the merged record. Verified red before the fix,
green after.
Found during this same MFT-parser audit: CLAUDE.md described a fast/full
mode toggle where the default path "skips extension MFT records" and
--full "merges" them. Neither exists anymore -- confirmed via direct
grep that no CLI flag, no fast_mode/full_mode concept, and no
skip-extension-records behavior exists anywhere in uffs-cli or the
current reader code. Both production parsers (unified.rs, direct_index.rs
+ direct_index_extension.rs) always merge extension records in the same
pass; this was pre-unification legacy behavior that the "one function
processes ALL records" consolidation (unified.rs's own module doc)
already superseded.
@githubrobbi
githubrobbi added this pull request to the merge queue Jul 20, 2026
Merged via the queue into main with commit aee2364 Jul 20, 2026
27 checks passed
@githubrobbi
githubrobbi deleted the fix/mft-default-pipeline-standard-info branch July 20, 2026 18:02
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