From ae5c338f765b64ddaa388ac4bd148b9bc6667f3a Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 21 Jul 2026 03:22:46 -0700 Subject: [PATCH 1/8] fix(docs): drop intra-doc links to feature-gated emit_build_env `emit_build_env` is behind `#[cfg(feature = "build")]`, so the two [`emit_build_env`] links in uffs-version's docs do not resolve when the crate is documented without that feature. The pre-push rustdoc gate runs `cargo doc --workspace --no-deps --document-private-items --locked` with `-D rustdoc::broken-intra-doc-links`, so this failed on main. Demote both to plain code spans that name the feature gate. The workspace rustdoc gate now passes. --- crates/uffs-version/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/uffs-version/src/lib.rs b/crates/uffs-version/src/lib.rs index 3df755783..098025b42 100644 --- a/crates/uffs-version/src/lib.rs +++ b/crates/uffs-version/src/lib.rs @@ -13,8 +13,8 @@ //! The macros expand **in the calling crate**, so `CARGO_PKG_VERSION` resolves //! to that binary's version and `option_env!("UFFS_GIT_SHA")` (and the other //! `UFFS_*` build vars) resolve to what its `build.rs` stamped via -//! [`emit_build_env`]. On Windows the name carries the real `.exe` suffix so -//! `--version` matches the on-disk filename. +//! `emit_build_env` (gated behind the `build` feature). On Windows the name +//! carries the real `.exe` suffix so `--version` matches the on-disk filename. /// Executable-name suffix for the compiled target. /// @@ -46,7 +46,7 @@ macro_rules! version_short { /// Multi-line build fingerprint for bug reports (`--version --verbose` / `-v`). /// /// The short line followed by `commit` (sha + date), `rustc`, `target`, and -/// `profile`, each read from the `UFFS_*` env this crate's [`emit_build_env`] +/// `profile`, each read from the `UFFS_*` env this crate's `emit_build_env` /// stamps at build time (`"unknown"` when a field could not be resolved). #[macro_export] macro_rules! version_long { From 0c58eed1c5944f4ce5c86913e3a9736ec592195c Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 21 Jul 2026 03:23:56 -0700 Subject: [PATCH 2/8] feat(mft): carry the remaining $SI 3.0+ fields on ExtendedStandardInfo The NTFS 3.0+ 72-byte $STANDARD_INFORMATION carries four fields past the timestamps that the parser read into `StandardInformationExtended` and then dropped: `quota_charged`, `max_versions`, `version_number`, and `class_id`. Downstream consumers that build a full forensic record had to re-read them from the raw bytes with their own hard-coded offset table. Add them to `ExtendedStandardInfo` and populate them in the NTFS 3.0+ branch of `parse_standard_info_full`, the single source of truth for $SI parsing. The NTFS 1.2 branch is unchanged: that 36-byte layout has no such fields, and `..from_attributes(...)` correctly leaves them zero. Costs nothing persistent. `ExtendedStandardInfo` is a stack temporary in both production parsers, converted immediately via `StandardInfo::from_extended` and dropped; the 56-byte `StandardInfo` that lands in `MftIndex` is untouched, so no index, cache, or on-disk format changes. The values are already materialized by the zerocopy cast, so this is three u32 moves and one u64. Additive and semver-minor: every construction site uses `::default()` or `..from_attributes(...)`, so struct-update syntax covers the new fields. --- crates/uffs-mft/src/ntfs/metadata.rs | 16 +++++++ .../uffs-mft/src/parse/attribute_helpers.rs | 8 +++- crates/uffs-mft/src/parse/tests.rs | 44 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/crates/uffs-mft/src/ntfs/metadata.rs b/crates/uffs-mft/src/ntfs/metadata.rs index 9f378385c..6bad6df3c 100644 --- a/crates/uffs-mft/src/ntfs/metadata.rs +++ b/crates/uffs-mft/src/ntfs/metadata.rs @@ -292,6 +292,22 @@ pub struct ExtendedStandardInfo { pub security_id: u32, /// Owner ID - for quota tracking. pub owner_id: u32, + /// Quota charged - bytes charged to the owner's quota. + /// + /// Zero on NTFS 1.2 records and on volumes without quotas enabled. + pub quota_charged: u64, + /// Maximum number of versions (usually 0). + /// + /// Zero on NTFS 1.2 records, which have no such field. + pub max_versions: u32, + /// Version number (usually 0). + /// + /// Zero on NTFS 1.2 records, which have no such field. + pub version_number: u32, + /// Class ID (usually 0). + /// + /// Zero on NTFS 1.2 records, which have no such field. + pub class_id: u32, /// Read-only flag. pub is_readonly: bool, /// Hidden flag. diff --git a/crates/uffs-mft/src/parse/attribute_helpers.rs b/crates/uffs-mft/src/parse/attribute_helpers.rs index 5530b75cf..2cb937af9 100644 --- a/crates/uffs-mft/src/parse/attribute_helpers.rs +++ b/crates/uffs-mft/src/parse/attribute_helpers.rs @@ -11,7 +11,9 @@ use crate::ntfs::{ExtendedStandardInfo, NameInfo, StreamInfo}; /// Parses `$STANDARD_INFORMATION` into `ExtendedStandardInfo`. /// /// Handles both NTFS 1.2 (36 bytes) and NTFS 3.0+ (72 bytes) formats. -/// For NTFS 3.0+, also extracts `usn`, `security_id`, and `owner_id`. +/// For NTFS 3.0+, also extracts `usn`, `security_id`, `owner_id`, +/// `quota_charged`, `max_versions`, `version_number`, and `class_id`; these +/// stay zero for NTFS 1.2, whose 36-byte layout has no such fields. /// /// `pub(crate)`: this is the single source of truth for `$STANDARD_INFORMATION` /// parsing and is also called directly from `crate::io::parser::unified`, which @@ -59,6 +61,10 @@ pub(crate) fn parse_standard_info_full( usn: si.usn, security_id: si.security_id, owner_id: si.owner_id, + quota_charged: si.quota_charged, + max_versions: si.max_versions, + version_number: si.version_number, + class_id: si.class_id, ..ExtendedStandardInfo::from_attributes(si.file_attributes) }; } else if value_length >= STANDARD_INFO_SIZE_V12 diff --git a/crates/uffs-mft/src/parse/tests.rs b/crates/uffs-mft/src/parse/tests.rs index 06aa1cfef..fe549d596 100644 --- a/crates/uffs-mft/src/parse/tests.rs +++ b/crates/uffs-mft/src/parse/tests.rs @@ -251,6 +251,50 @@ fn parse_standard_info_full_reads_unaligned_v30_payload() { assert_eq!(result.usn, usn); } +/// The NTFS 3.0+ `$SI` fields that live past the timestamps — quota, version +/// and class bookkeeping — must survive the parse, and must stay zero for the +/// 36-byte NTFS 1.2 layout, which has no such fields. +#[test] +fn parse_standard_info_full_reads_quota_and_version_fields() { + let attr_offset = 0_usize; + let value_offset = 24_u16; + let si_offset = attr_offset + usize::from(value_offset); + let mut data = vec![0_u8; si_offset + 72]; + let max_versions = 3_u32; + let version_number = 2_u32; + let class_id = 9_u32; + let quota_charged = 4_096_u64; + + write_u32_le(&mut data, attr_offset + 16, 72); + write_u16_le(&mut data, attr_offset + 20, value_offset); + write_u32_le(&mut data, si_offset + 36, max_versions); + write_u32_le(&mut data, si_offset + 40, version_number); + write_u32_le(&mut data, si_offset + 44, class_id); + write_u64_le(&mut data, si_offset + 56, quota_charged); + + let mut result = ExtendedStandardInfo::default(); + parse_standard_info_full(&data, attr_offset, &mut result); + + assert_eq!(result.max_versions, max_versions); + assert_eq!(result.version_number, version_number); + assert_eq!(result.class_id, class_id); + assert_eq!(result.quota_charged, quota_charged); + + // NTFS 1.2: the same byte positions are past the end of the attribute + // value, so all four must remain zero. + let mut v12 = vec![0_u8; si_offset + 36]; + write_u32_le(&mut v12, attr_offset + 16, 36); + write_u16_le(&mut v12, attr_offset + 20, value_offset); + + let mut v12_result = ExtendedStandardInfo::default(); + parse_standard_info_full(&v12, attr_offset, &mut v12_result); + + assert_eq!(v12_result.max_versions, 0); + assert_eq!(v12_result.version_number, 0); + assert_eq!(v12_result.class_id, 0); + assert_eq!(v12_result.quota_charged, 0); +} + #[test] fn parse_file_name_full_reads_unaligned_payload() { let attr_offset = 1_usize; From d04f09fb23c928e7e64ee389771c5708f6c86d39 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 21 Jul 2026 03:29:56 -0700 Subject: [PATCH 3/8] fix(mft): stop zeroing usn/security_id/owner_id in the fragment parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parse_record_to_fragment` hand-rolled its own $STANDARD_INFORMATION read and unconditionally parsed the 36-byte NTFS 1.2 layout, ignoring the attribute's real value_length. On any NTFS 3.0+ volume — i.e. all of them — it therefore returned usn, security_id and owner_id as zero while looking like it had parsed the attribute successfully. Route it through `parse_standard_info_full`, the same single source of truth the two live parsers already use, so all three paths agree. The directory flag is applied after the attribute loop, so moving the read does not disturb it. Malformed $SI now leaves `std_info` at its default and continues the loop instead of aborting the whole attribute walk, matching `unified::process_record`. The parser is deprecated and has no callers in the workspace, so this is latent rather than user-visible today; it is kept for a planned carving consumer, which makes correctness now cheaper than archaeology later. Also replaces the `too_many_lines` expect reason, which claimed this was a "performance-critical hot path" — false since the fragment-merge rewrite, and precisely why the bug went unexamined this long. Adds a regression pin on the fragment path alongside the existing two-parser one; verified to fail (usn 0 != 66) against the pre-fix code. --- crates/uffs-mft/src/io/parser/fragment.rs | 42 ++++++------- crates/uffs-mft/src/io/parser/mod.rs | 77 ++++++++++++++++++----- 2 files changed, 82 insertions(+), 37 deletions(-) diff --git a/crates/uffs-mft/src/io/parser/fragment.rs b/crates/uffs-mft/src/io/parser/fragment.rs index fea418cf7..edfa2306f 100644 --- a/crates/uffs-mft/src/io/parser/fragment.rs +++ b/crates/uffs-mft/src/io/parser/fragment.rs @@ -31,7 +31,7 @@ use crate::index::{ }; use crate::ntfs::{ AttributeRecordHeader, AttributeType, FileNameAttribute, FileRecordSegmentHeader, - StandardInformation, file_reference_to_frs, + file_reference_to_frs, }; /// Parses a record directly into an `MftIndexFragment` (for parallel parsing). @@ -46,7 +46,13 @@ use crate::ntfs::{ #[expect( clippy::too_many_lines, clippy::cognitive_complexity, - reason = "monolithic parser kept for performance-critical hot path" + reason = "monolithic parser retained verbatim from when this was the bulk-load \ + path. It is NOT a hot path any more — it is deprecated and has no \ + callers in the workspace; the live parsers are \ + `io::parser::unified::process_record` (bulk) and \ + `parse::direct_index::parse_record_to_index` (USN). Kept, not \ + restructured, because a future carving/forensics consumer wants \ + exactly this untrusted-byte handling; see the removal-decision issue." )] #[expect( clippy::indexing_slicing, @@ -128,27 +134,17 @@ pub fn parse_record_to_fragment( match AttributeType::from_u32(attr_header.type_code) { Some(AttributeType::StandardInformation) if attr_header.is_non_resident == 0 => { - let value_offset = usize::from(rd_u16(data, offset.saturating_add(20))); - // `offset + value_offset` is byte-derived; checked, then re-validated - // by the `.get()` below. - if let Some(si_offset) = offset.checked_add(value_offset) - && let Some(si_slice) = si_offset - .checked_add(size_of::()) - .filter(|end| *end <= data.len()) - .and_then(|_| data.get(si_offset..)) - { - let Ok((si, _)) = StandardInformation::read_from_prefix(si_slice) else { - break; - }; - let ext = - crate::ntfs::ExtendedStandardInfo::from_attributes(si.file_attributes); - let mut info = StandardInfo::from_extended(&ext); - info.created = si.creation_time; - info.modified = si.modification_time; - info.accessed = si.access_time; - info.mft_changed = si.mft_change_time; - std_info = info; - } + // Shared with the unified and direct-index pipelines: reads + // the 72-byte NTFS 3.0+ `StandardInformationExtended` form + // (usn/security_id/owner_id) when `value_length` says it's + // present, falling back to the 36-byte NTFS 1.2 form + // otherwise — see `parse::attribute_helpers` for the + // single-source-of-truth rationale. This path used to parse + // the v1.2 layout unconditionally, which zeroed those three + // fields on every modern NTFS volume. + let mut ext = crate::ntfs::ExtendedStandardInfo::default(); + crate::parse::parse_standard_info_full(data, offset, &mut ext); + std_info = StandardInfo::from_extended(&ext); } Some(AttributeType::FileName) if attr_header.is_non_resident == 0 => { let value_offset = usize::from(rd_u16(data, offset.saturating_add(20))); diff --git a/crates/uffs-mft/src/io/parser/mod.rs b/crates/uffs-mft/src/io/parser/mod.rs index 26835751b..81e8e4418 100644 --- a/crates/uffs-mft/src/io/parser/mod.rs +++ b/crates/uffs-mft/src/io/parser/mod.rs @@ -72,24 +72,27 @@ mod tests { ); } - /// Regression pin: both production `$STANDARD_INFORMATION` parsers — - /// `process_record` (the default bulk-load pipeline) and - /// `crate::parse::parse_record_to_index` (the live USN-journal - /// incremental-update pipeline, wired from `usn::windows`) — must - /// recognize the NTFS 3.0+ 72-byte `StandardInformationExtended` form - /// and populate `usn`/`security_id`/`owner_id`, not just the 4 - /// timestamps. Before this fix both silently treated every record as - /// NTFS 1.2 (36 bytes) and left those three fields at zero. - #[test] - fn standard_information_extended_fields_reach_both_production_parsers() { - let creation_time = 1_i64; + /// Creation timestamp stamped into [`v30_std_info_record`]. + const V30_CREATION_TIME: i64 = 1; + /// Owner ID stamped into [`v30_std_info_record`]. + const V30_OWNER_ID: u32 = 44; + /// Security ID stamped into [`v30_std_info_record`]. + const V30_SECURITY_ID: u32 = 55; + /// USN stamped into [`v30_std_info_record`]. + const V30_USN: u64 = 66; + + /// Builds a minimal in-use base record whose `$STANDARD_INFORMATION` uses + /// the 72-byte NTFS 3.0+ layout, plus a 1-char `$FILE_NAME` (the + /// direct-index parser only accepts records that have a name). + fn v30_std_info_record() -> Vec { + let creation_time = V30_CREATION_TIME; let modification_time = 2_i64; let mft_change_time = 3_i64; let access_time = 4_i64; let file_attributes = 0x20_u32; // FILE_ATTRIBUTE_ARCHIVE - let owner_id = 44_u32; - let security_id = 55_u32; - let usn = 66_u64; + let owner_id = V30_OWNER_ID; + let security_id = V30_SECURITY_ID; + let usn = V30_USN; // 72-byte StandardInformationExtended payload, field order per // `ntfs::metadata::StandardInformationExtended`. @@ -163,6 +166,25 @@ mod tests { .expect("record is well over 28 bytes long") .copy_from_slice(&total_len.to_le_bytes()); + record + } + + /// Regression pin: both production `$STANDARD_INFORMATION` parsers — + /// `process_record` (the default bulk-load pipeline) and + /// `crate::parse::parse_record_to_index` (the live USN-journal + /// incremental-update pipeline, wired from `usn::windows`) — must + /// recognize the NTFS 3.0+ 72-byte `StandardInformationExtended` form + /// and populate `usn`/`security_id`/`owner_id`, not just the 4 + /// timestamps. Before this fix both silently treated every record as + /// NTFS 1.2 (36 bytes) and left those three fields at zero. + #[test] + fn standard_information_extended_fields_reach_both_production_parsers() { + let record = v30_std_info_record(); + let creation_time = V30_CREATION_TIME; + let owner_id = V30_OWNER_ID; + let security_id = V30_SECURITY_ID; + let usn = V30_USN; + // Path 1: process_record — the default bulk-load pipeline. let mut unified_index = MftIndex::new(crate::platform::DriveLetter::C); let mut name_buf = String::new(); @@ -194,6 +216,33 @@ mod tests { assert_eq!(direct_rec.stdinfo.owner_id, owner_id); } + /// Regression pin for the same bug on the third parser: the deprecated, + /// currently caller-less `parse_record_to_fragment` hand-rolled its own + /// `$STANDARD_INFORMATION` read and always used the 36-byte NTFS 1.2 + /// layout, so `usn`/`security_id`/`owner_id` came back zero on every + /// modern volume. It now shares `parse_standard_info_full` with the two + /// production parsers. + #[test] + #[expect(deprecated, reason = "testing deprecated parse_record_to_fragment API")] + fn standard_information_extended_fields_reach_the_fragment_parser() { + let record = v30_std_info_record(); + + let mut fragment = MftIndexFragment::with_capacity(1); + assert!( + parse_record_to_fragment(&record, 42, &mut fragment), + "the fixture record must be accepted by the fragment parser" + ); + let frag_rec = fragment + .records + .first() + .expect("an accepted record must land in the fragment"); + + assert_eq!(frag_rec.stdinfo.created, V30_CREATION_TIME); + assert_eq!(frag_rec.stdinfo.usn, V30_USN); + assert_eq!(frag_rec.stdinfo.security_id, V30_SECURITY_ID); + assert_eq!(frag_rec.stdinfo.owner_id, V30_OWNER_ID); + } + /// Regression pin: a named `$DATA` (ADS) attribute's real `is_sparse`/ /// `is_resident` status must reach the index's `IndexStreamInfo`, on /// both production parsers. Both fields already existed in the struct From 768a08a8332f0e4af933440c77399aaa2a70e7f0 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 21 Jul 2026 03:43:06 -0700 Subject: [PATCH 4/8] test(mft): pin the extended $SI fields on parse_record_full, not just the helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing pins assert `parse_standard_info_full` directly, but the public API consumers actually call is `parse_record_full`, which reaches those fields through two hops: a local `ExtendedStandardInfo` in `full.rs` that is later moved onto `ParsedRecord::std_info`. A refactor giving `full.rs` its own $SI read would keep the helper-level tests green while silently zeroing the fields for every external caller. Assert quota_charged/max_versions/version_number/class_id — plus the pre-existing usn/security_id/owner_id, which ride the same two hops — on a `ParsedRecord` returned by `parse_record_full`. Verified to fail when the second hop is broken (quota_charged 0 != 65536). The addition pushed parse/tests.rs to 839 LOC, past the 800-LOC policy limit. Split rather than granted an exception: tests.rs becomes tests/mod.rs (698 LOC, helpers and everything else) plus tests/std_info.rs (152 LOC, all three $STANDARD_INFORMATION pins). Also drops the now-false `single_call_fn` expectation on `create_file_name_value`, which the new test makes a second caller of. --- .../src/parse/{tests.rs => tests/mod.rs} | 86 +--------- crates/uffs-mft/src/parse/tests/std_info.rs | 152 ++++++++++++++++++ 2 files changed, 154 insertions(+), 84 deletions(-) rename crates/uffs-mft/src/parse/{tests.rs => tests/mod.rs} (87%) create mode 100644 crates/uffs-mft/src/parse/tests/std_info.rs diff --git a/crates/uffs-mft/src/parse/tests.rs b/crates/uffs-mft/src/parse/tests/mod.rs similarity index 87% rename from crates/uffs-mft/src/parse/tests.rs rename to crates/uffs-mft/src/parse/tests/mod.rs index fe549d596..5f2a76b2e 100644 --- a/crates/uffs-mft/src/parse/tests.rs +++ b/crates/uffs-mft/src/parse/tests/mod.rs @@ -7,6 +7,8 @@ use super::*; use crate::frs::{Frs, ParentFrs}; use crate::ntfs::{AttributeType, ExtendedStandardInfo, FILE_RECORD_MAGIC, NameInfo, ReparseTag}; +mod std_info; + fn write_u16_le(buffer: &mut [u8], offset: usize, value: u16) { buffer[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); } @@ -103,10 +105,6 @@ fn create_resident_attribute(attr_type: AttributeType, value: &[u8]) -> Vec attr } -#[expect( - clippy::single_call_fn, - reason = "test helper isolates FileName attribute layout for one targeted regression" -)] fn create_file_name_value(parent_directory: u64, name: &str, namespace: u8) -> Vec { let name_utf16: Vec = name.encode_utf16().collect(); let mut value = vec![0_u8; 66 + name_utf16.len() * 2]; @@ -215,86 +213,6 @@ fn apply_fixup_valid_record_on_unaligned_slice() { assert_eq!(&storage[1023..1025], &0x5678_u16.to_le_bytes()); } -#[test] -fn parse_standard_info_full_reads_unaligned_v30_payload() { - let attr_offset = 1_usize; - let value_offset = 24_u16; - let si_offset = attr_offset + usize::from(value_offset); - let mut data = vec![0_u8; si_offset + 72]; - let creation_time = 116_444_736_000_000_010_i64; - let modification_time = 116_444_736_000_000_020_i64; - let mft_change_time = 116_444_736_000_000_030_i64; - let access_time = 116_444_736_000_000_040_i64; - let owner_id = 44_u32; - let security_id = 55_u32; - let usn = 66_u64; - - write_u32_le(&mut data, attr_offset + 16, 72); - write_u16_le(&mut data, attr_offset + 20, value_offset); - write_i64_le(&mut data, si_offset, creation_time); - write_i64_le(&mut data, si_offset + 8, modification_time); - write_i64_le(&mut data, si_offset + 16, mft_change_time); - write_i64_le(&mut data, si_offset + 24, access_time); - write_u32_le(&mut data, si_offset + 48, owner_id); - write_u32_le(&mut data, si_offset + 52, security_id); - write_u64_le(&mut data, si_offset + 64, usn); - - let mut result = ExtendedStandardInfo::default(); - parse_standard_info_full(&data, attr_offset, &mut result); - - assert_eq!(result.created, creation_time); - assert_eq!(result.modified, modification_time); - assert_eq!(result.mft_changed, mft_change_time); - assert_eq!(result.accessed, access_time); - assert_eq!(result.owner_id, owner_id); - assert_eq!(result.security_id, security_id); - assert_eq!(result.usn, usn); -} - -/// The NTFS 3.0+ `$SI` fields that live past the timestamps — quota, version -/// and class bookkeeping — must survive the parse, and must stay zero for the -/// 36-byte NTFS 1.2 layout, which has no such fields. -#[test] -fn parse_standard_info_full_reads_quota_and_version_fields() { - let attr_offset = 0_usize; - let value_offset = 24_u16; - let si_offset = attr_offset + usize::from(value_offset); - let mut data = vec![0_u8; si_offset + 72]; - let max_versions = 3_u32; - let version_number = 2_u32; - let class_id = 9_u32; - let quota_charged = 4_096_u64; - - write_u32_le(&mut data, attr_offset + 16, 72); - write_u16_le(&mut data, attr_offset + 20, value_offset); - write_u32_le(&mut data, si_offset + 36, max_versions); - write_u32_le(&mut data, si_offset + 40, version_number); - write_u32_le(&mut data, si_offset + 44, class_id); - write_u64_le(&mut data, si_offset + 56, quota_charged); - - let mut result = ExtendedStandardInfo::default(); - parse_standard_info_full(&data, attr_offset, &mut result); - - assert_eq!(result.max_versions, max_versions); - assert_eq!(result.version_number, version_number); - assert_eq!(result.class_id, class_id); - assert_eq!(result.quota_charged, quota_charged); - - // NTFS 1.2: the same byte positions are past the end of the attribute - // value, so all four must remain zero. - let mut v12 = vec![0_u8; si_offset + 36]; - write_u32_le(&mut v12, attr_offset + 16, 36); - write_u16_le(&mut v12, attr_offset + 20, value_offset); - - let mut v12_result = ExtendedStandardInfo::default(); - parse_standard_info_full(&v12, attr_offset, &mut v12_result); - - assert_eq!(v12_result.max_versions, 0); - assert_eq!(v12_result.version_number, 0); - assert_eq!(v12_result.class_id, 0); - assert_eq!(v12_result.quota_charged, 0); -} - #[test] fn parse_file_name_full_reads_unaligned_payload() { let attr_offset = 1_usize; diff --git a/crates/uffs-mft/src/parse/tests/std_info.rs b/crates/uffs-mft/src/parse/tests/std_info.rs new file mode 100644 index 000000000..f005ce380 --- /dev/null +++ b/crates/uffs-mft/src/parse/tests/std_info.rs @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Tests for `$STANDARD_INFORMATION` parsing, from the shared helper up to +//! the public `parse_record_full` API. +//! +//! Split out of the parent `tests` module to keep both files under the +//! 800-LOC policy limit; the record-building helpers stay in the parent. + +use super::{ + create_file_name_value, create_resident_attribute, create_test_record_with_attributes, + write_i64_le, write_u16_le, write_u32_le, write_u64_le, +}; +use crate::ntfs::{AttributeType, ExtendedStandardInfo}; +use crate::parse::{ParseResult, parse_record_full, parse_standard_info_full}; + +#[test] +fn parse_standard_info_full_reads_unaligned_v30_payload() { + let attr_offset = 1_usize; + let value_offset = 24_u16; + let si_offset = attr_offset + usize::from(value_offset); + let mut data = vec![0_u8; si_offset + 72]; + let creation_time = 116_444_736_000_000_010_i64; + let modification_time = 116_444_736_000_000_020_i64; + let mft_change_time = 116_444_736_000_000_030_i64; + let access_time = 116_444_736_000_000_040_i64; + let owner_id = 44_u32; + let security_id = 55_u32; + let usn = 66_u64; + + write_u32_le(&mut data, attr_offset + 16, 72); + write_u16_le(&mut data, attr_offset + 20, value_offset); + write_i64_le(&mut data, si_offset, creation_time); + write_i64_le(&mut data, si_offset + 8, modification_time); + write_i64_le(&mut data, si_offset + 16, mft_change_time); + write_i64_le(&mut data, si_offset + 24, access_time); + write_u32_le(&mut data, si_offset + 48, owner_id); + write_u32_le(&mut data, si_offset + 52, security_id); + write_u64_le(&mut data, si_offset + 64, usn); + + let mut result = ExtendedStandardInfo::default(); + parse_standard_info_full(&data, attr_offset, &mut result); + + assert_eq!(result.created, creation_time); + assert_eq!(result.modified, modification_time); + assert_eq!(result.mft_changed, mft_change_time); + assert_eq!(result.accessed, access_time); + assert_eq!(result.owner_id, owner_id); + assert_eq!(result.security_id, security_id); + assert_eq!(result.usn, usn); +} + +/// The NTFS 3.0+ `$SI` fields that live past the timestamps — quota, version +/// and class bookkeeping — must survive the parse, and must stay zero for the +/// 36-byte NTFS 1.2 layout, which has no such fields. +#[test] +fn parse_standard_info_full_reads_quota_and_version_fields() { + let attr_offset = 0_usize; + let value_offset = 24_u16; + let si_offset = attr_offset + usize::from(value_offset); + let mut data = vec![0_u8; si_offset + 72]; + let max_versions = 3_u32; + let version_number = 2_u32; + let class_id = 9_u32; + let quota_charged = 4_096_u64; + + write_u32_le(&mut data, attr_offset + 16, 72); + write_u16_le(&mut data, attr_offset + 20, value_offset); + write_u32_le(&mut data, si_offset + 36, max_versions); + write_u32_le(&mut data, si_offset + 40, version_number); + write_u32_le(&mut data, si_offset + 44, class_id); + write_u64_le(&mut data, si_offset + 56, quota_charged); + + let mut result = ExtendedStandardInfo::default(); + parse_standard_info_full(&data, attr_offset, &mut result); + + assert_eq!(result.max_versions, max_versions); + assert_eq!(result.version_number, version_number); + assert_eq!(result.class_id, class_id); + assert_eq!(result.quota_charged, quota_charged); + + // NTFS 1.2: the same byte positions are past the end of the attribute + // value, so all four must remain zero. + let mut v12 = vec![0_u8; si_offset + 36]; + write_u32_le(&mut v12, attr_offset + 16, 36); + write_u16_le(&mut v12, attr_offset + 20, value_offset); + + let mut v12_result = ExtendedStandardInfo::default(); + parse_standard_info_full(&v12, attr_offset, &mut v12_result); + + assert_eq!(v12_result.max_versions, 0); + assert_eq!(v12_result.version_number, 0); + assert_eq!(v12_result.class_id, 0); + assert_eq!(v12_result.quota_charged, 0); +} + +/// End-to-end pin on the **public** API, not just the helper. +/// +/// `parse_record_full` is what external consumers call, and it reaches the +/// NTFS 3.0+ `$SI` fields through two hops: `full.rs` calls +/// `parse_standard_info_full` into a local `ExtendedStandardInfo`, which is +/// then moved onto `ParsedRecord::std_info`. A refactor that gave `full.rs` +/// its own `$SI` read would keep the helper-level tests green while silently +/// zeroing these fields for every caller of the public API, so assert them +/// on a `ParsedRecord` that came out of `parse_record_full` itself. +#[test] +fn parse_record_full_surfaces_extended_std_info_on_parsed_record() { + let quota_charged = 65_536_u64; + let max_versions = 7_u32; + let version_number = 3_u32; + let class_id = 11_u32; + let owner_id = 44_u32; + let security_id = 55_u32; + let usn = 66_u64; + + // 72-byte NTFS 3.0+ $SI value, field order per + // `ntfs::metadata::StandardInformationExtended`. + let mut si_value = vec![0_u8; 72]; + write_i64_le(&mut si_value, 0, 116_444_736_000_000_010); // creation_time + write_i64_le(&mut si_value, 8, 116_444_736_000_000_020); // modification_time + write_i64_le(&mut si_value, 16, 116_444_736_000_000_030); // mft_change_time + write_i64_le(&mut si_value, 24, 116_444_736_000_000_040); // access_time + write_u32_le(&mut si_value, 32, 0x20); // file_attributes = ARCHIVE + write_u32_le(&mut si_value, 36, max_versions); + write_u32_le(&mut si_value, 40, version_number); + write_u32_le(&mut si_value, 44, class_id); + write_u32_le(&mut si_value, 48, owner_id); + write_u32_le(&mut si_value, 52, security_id); + write_u64_le(&mut si_value, 56, quota_charged); + write_u64_le(&mut si_value, 64, usn); + + let data = create_test_record_with_attributes(5, true, false, 0, &[ + create_resident_attribute(AttributeType::StandardInformation, &si_value), + create_resident_attribute( + AttributeType::FileName, + &create_file_name_value(5, "quota.txt", 1), + ), + ]); + + let ParseResult::Base(record) = parse_record_full(&data, 5) else { + panic!("an in-use base record with a $FILE_NAME must parse as ParseResult::Base"); + }; + + assert_eq!(record.std_info.quota_charged, quota_charged); + assert_eq!(record.std_info.max_versions, max_versions); + assert_eq!(record.std_info.version_number, version_number); + assert_eq!(record.std_info.class_id, class_id); + // The three pre-existing NTFS 3.0+ fields ride the same two hops. + assert_eq!(record.std_info.owner_id, owner_id); + assert_eq!(record.std_info.security_id, security_id); + assert_eq!(record.std_info.usn, usn); +} From dd099001c89dba8c74e30a8fbf0543772ffe578b Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 21 Jul 2026 03:58:32 -0700 Subject: [PATCH 5/8] docs(mft): complete the forensic_flags bit table; split index/convert.rs out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment above the forensic-flag accessors listed only bits 0-2 and stopped, which reads as "bit 3 onwards is free". It is not: bits 3-5 are has_default_data, has_i30_stream and is_unified, written by their own setters, which is why `set_forensic_flags` masks them through with `& 0b11_1000`. Bit 3 feeds `compute_tree_metrics`, where it separates "empty $DATA" from "no $DATA", so a new flag landing there would surface as wrong treesize/tree_allocated far from the cause. Only bits 6-7 are free. The table is now complete and says so. types.rs sat at exactly 800 LOC, so the full comment pushed it over the policy limit. Rather than trim the docs to fit or take a size exception, lift the "Safe Casting Helpers" section — 15 numeric conversions with no dependency on the record structs, and no callers inside types.rs — into a new `index/convert.rs` (211 LOC), leaving types.rs at 610 with room to document itself. Re-exported from `index.rs` under the same public paths, so no call site outside the crate changes; `index/base.rs` was the one in-crate user of a `types::`-qualified path and now points at `convert`. --- crates/uffs-mft/src/index.rs | 10 +- crates/uffs-mft/src/index/base.rs | 2 +- crates/uffs-mft/src/index/convert.rs | 211 +++++++++++++++++++++++++ crates/uffs-mft/src/index/types.rs | 226 +++------------------------ 4 files changed, 237 insertions(+), 212 deletions(-) create mode 100644 crates/uffs-mft/src/index/convert.rs diff --git a/crates/uffs-mft/src/index.rs b/crates/uffs-mft/src/index.rs index 8cf52ad2f..ef4e6acca 100644 --- a/crates/uffs-mft/src/index.rs +++ b/crates/uffs-mft/src/index.rs @@ -28,6 +28,7 @@ mod base; mod builder; mod child_order; +mod convert; mod dataframe; mod extensions; mod fragment; @@ -42,6 +43,11 @@ mod tree; mod types; mod usn; +pub use self::convert::{ + bytes_to_mb_f64, f64_to_u64, f64_to_usize, frs_to_usize, len_to_u16, len_to_u32, micros_to_i64, + millis_to_u64, nanos_to_u64, nonneg_to_u64, u32_as_usize, u32_to_f64, u64_to_f64, usize_to_f64, + usize_to_u64, +}; pub use self::extensions::{ExtensionIndex, ExtensionTable}; pub use self::fragment::MftIndexFragment; pub use self::model::{ChildInfo, MftIndex}; @@ -53,9 +59,7 @@ pub use self::storage::IndexHeader; pub(crate) use self::types::cmp_ascii_case_insensitive; pub use self::types::{ FileRecord, IndexNameRef, IndexStreamInfo, InternalStreamInfo, LinkInfo, NO_ENTRY, ROOT_FRS, - SizeInfo, bytes_to_mb_f64, f64_to_u64, f64_to_usize, frs_to_usize, len_to_u16, len_to_u32, - micros_to_i64, millis_to_u64, nanos_to_u64, nonneg_to_u64, u32_as_usize, u32_to_f64, - u64_to_f64, usize_to_f64, usize_to_u64, + SizeInfo, }; pub use self::usn::UsnApplyStats; diff --git a/crates/uffs-mft/src/index/base.rs b/crates/uffs-mft/src/index/base.rs index 56fbb52e2..eec7ceb71 100644 --- a/crates/uffs-mft/src/index/base.rs +++ b/crates/uffs-mft/src/index/base.rs @@ -562,7 +562,7 @@ impl MftIndex { pub fn display_stats(&self) { use std::io::Write as _; - use super::types::u64_to_f64; + use super::convert::u64_to_f64; let mut out = std::io::stdout().lock(); let sep = "═══════════════════════════════════════════════════════════════"; diff --git a/crates/uffs-mft/src/index/convert.rs b/crates/uffs-mft/src/index/convert.rs new file mode 100644 index 000000000..88dd8f1e4 --- /dev/null +++ b/crates/uffs-mft/src/index/convert.rs @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2025-2026 SKY, LLC. + +//! Safe numeric conversions used across the index. +//! +//! Every cast that could truncate, wrap, or lose sign lives here rather than +//! at its call sites, so the saturating/clamping behaviour is stated once and +//! the `clippy::cast_*` expects stay in one place. Split out of `types.rs`, +//! which holds the record structs themselves. + +/// Convert an FRS number (`u64`) to a `usize` index. +/// +/// NTFS File Record Segment numbers are `u64` on disk, but our index uses +/// `Vec` which is addressed by `usize`. On 64-bit platforms (the only +/// supported targets) this conversion is lossless. +/// +/// On hypothetical 32-bit targets, saturates to `usize::MAX` instead of +/// silently truncating. +#[inline] +#[must_use] +pub fn frs_to_usize(frs: u64) -> usize { + usize::try_from(frs).unwrap_or(usize::MAX) +} + +/// Convert a `Vec::len()` (`usize`) to a `u32` linked-list index. +/// +/// The index uses `u32` for `next_entry` fields to halve memory usage +/// compared to `usize` on 64-bit. MFT indexes never exceed `u32::MAX` +/// entries (each entry is 40+ bytes → would require 160+ GB of RAM). +/// +/// Saturates to `u32::MAX` if the value overflows. +#[inline] +#[must_use] +pub fn len_to_u32(len: usize) -> u32 { + u32::try_from(len).unwrap_or(u32::MAX) +} + +/// Convert a `Vec::len()` (`usize`) to a `u16` count. +/// +/// Used for name counts and stream counts per record, which are bounded +/// by the NTFS record size (max ~64 attributes per 4 KB record). +/// +/// Saturates to `u16::MAX` if the value overflows. +#[inline] +#[must_use] +pub fn len_to_u16(len: usize) -> u16 { + u16::try_from(len).unwrap_or(u16::MAX) +} + +/// Widen a `u32` NTFS header field to `usize` for array indexing. +/// +/// On any platform where `usize >= 32 bits` (all supported targets) this +/// is a lossless widening. On hypothetical 16-bit targets it saturates. +#[inline] +#[must_use] +pub const fn u32_as_usize(val: u32) -> usize { + val as usize +} + +/// Clamp a signed NTFS field to non-negative and convert to `u64`. +/// +/// NTFS data sizes and allocated sizes are stored as `i64` on disk but +/// represent non-negative byte counts. Negative values indicate +/// corruption or uninitialised data and are clamped to `0`. +#[inline] +#[must_use] +pub const fn nonneg_to_u64(val: i64) -> u64 { + if val > 0 { + // `val > 0` guard makes the reinterpret lossless; + // `i64::cast_unsigned` is the stable Rust 1.87 exact-bit-pattern + // converter that does not require a `cast_sign_loss` expect. + val.cast_unsigned() + } else { + 0 + } +} + +/// Convert a `u128` duration (e.g. from `Duration::as_micros()`) to `i64`. +/// +/// Saturates at `i64::MAX` (year ~292,277) instead of truncating. +#[inline] +#[must_use] +pub fn micros_to_i64(us: u128) -> i64 { + i64::try_from(us).unwrap_or(i64::MAX) +} + +/// Convert a `u128` nanosecond count (e.g. from `Duration::as_nanos()`) to +/// `u64`. +/// +/// Saturates at `u64::MAX` (~584 years of nanoseconds) instead of truncating. +/// Used wherever per-phase elapsed time is reported as a `u64` ns counter. +#[inline] +#[must_use] +pub fn nanos_to_u64(ns: u128) -> u64 { + u64::try_from(ns).unwrap_or(u64::MAX) +} + +/// Convert a `u128` millisecond count (e.g. from `Duration::as_millis()`) to +/// `u64`. +/// +/// Saturates at `u64::MAX` (~584 million years) instead of truncating. +#[inline] +#[must_use] +pub fn millis_to_u64(ms: u128) -> u64 { + u64::try_from(ms).unwrap_or(u64::MAX) +} + +/// Convert a `usize` to `u64`. +/// +/// On every supported target (`usize` ≤ 64 bits) this is a lossless widening. +/// On hypothetical >64-bit targets it saturates at `u64::MAX`. +#[inline] +#[must_use] +pub fn usize_to_u64(val: usize) -> u64 { + u64::try_from(val).unwrap_or(u64::MAX) +} + +/// Convert bytes (`u64`) to megabytes as `f64` for display purposes. +/// +/// Precision loss beyond 2^53 bytes (~8 PB) is acceptable for human display. +#[inline] +#[must_use] +#[expect( + clippy::cast_precision_loss, + reason = "display-only: sub-byte precision irrelevant for MB/GB formatting" +)] +#[expect(clippy::float_arithmetic, reason = "display-only MB conversion")] +pub fn bytes_to_mb_f64(bytes: u64) -> f64 { + bytes as f64 / (1_024.0_f64 * 1_024.0_f64) +} + +/// Convert a `u64` to `f64` for display ratios and percentages. +/// +/// Precision loss beyond 2^53 is acceptable for display. +#[inline] +#[must_use] +#[expect( + clippy::cast_precision_loss, + reason = "display-only: sub-unit precision irrelevant for ratios" +)] +pub const fn u64_to_f64(val: u64) -> f64 { + val as f64 +} + +/// Convert a `usize` to `f64` for display ratios and percentages. +/// +/// Precision loss beyond 2^53 is acceptable for display. +#[inline] +#[must_use] +#[expect( + clippy::cast_precision_loss, + reason = "display-only: sub-unit precision irrelevant for ratios" +)] +pub const fn usize_to_f64(val: usize) -> f64 { + val as f64 +} + +/// Convert a `u32` to `f64` for display. +/// +/// This is actually lossless (u32 fits exactly in f64) but provided +/// for API consistency. +#[inline] +#[must_use] +pub fn u32_to_f64(val: u32) -> f64 { + f64::from(val) +} + +/// Convert a non-negative `f64` to `u64`, clamping negative values to 0 +/// and values exceeding `u64::MAX` to `u64::MAX`. +/// +/// Used for memory budget calculations where floating-point arithmetic +/// produces approximate results that need to be stored as integers. +#[inline] +#[must_use] +#[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + reason = "centralized f64→u64 conversion for budget/estimation math; precision loss in boundary check is acceptable since u64::MAX as f64 rounds up" +)] +pub fn f64_to_u64(val: f64) -> u64 { + if val <= 0.0 { + 0 + } else if val >= u64::MAX as f64 { + u64::MAX + } else { + val as u64 + } +} + +/// Convert a non-negative `f64` to `usize`, clamping negative values to 0. +/// +/// Used for capacity estimation where floating-point arithmetic produces +/// approximate results that need to be stored as `usize`. +#[inline] +#[must_use] +#[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + reason = "centralized f64→usize conversion for capacity estimation; precision loss in boundary check is acceptable since usize::MAX as f64 rounds up" +)] +pub fn f64_to_usize(val: f64) -> usize { + if val <= 0.0 { + 0 + } else if val >= usize::MAX as f64 { + usize::MAX + } else { + val as usize + } +} diff --git a/crates/uffs-mft/src/index/types.rs b/crates/uffs-mft/src/index/types.rs index 99944d10c..97a3ccb95 100644 --- a/crates/uffs-mft/src/index/types.rs +++ b/crates/uffs-mft/src/index/types.rs @@ -19,212 +19,6 @@ pub const NO_ENTRY: u32 = u32::MAX; /// format, fixtures). Internal code should prefer the typed [`Frs::ROOT`]. pub const ROOT_FRS: u64 = 5; -// ============================================================================ -// Safe Casting Helpers -// ============================================================================ - -/// Convert an FRS number (`u64`) to a `usize` index. -/// -/// NTFS File Record Segment numbers are `u64` on disk, but our index uses -/// `Vec` which is addressed by `usize`. On 64-bit platforms (the only -/// supported targets) this conversion is lossless. -/// -/// On hypothetical 32-bit targets, saturates to `usize::MAX` instead of -/// silently truncating. -#[inline] -#[must_use] -pub fn frs_to_usize(frs: u64) -> usize { - usize::try_from(frs).unwrap_or(usize::MAX) -} - -/// Convert a `Vec::len()` (`usize`) to a `u32` linked-list index. -/// -/// The index uses `u32` for `next_entry` fields to halve memory usage -/// compared to `usize` on 64-bit. MFT indexes never exceed `u32::MAX` -/// entries (each entry is 40+ bytes → would require 160+ GB of RAM). -/// -/// Saturates to `u32::MAX` if the value overflows. -#[inline] -#[must_use] -pub fn len_to_u32(len: usize) -> u32 { - u32::try_from(len).unwrap_or(u32::MAX) -} - -/// Convert a `Vec::len()` (`usize`) to a `u16` count. -/// -/// Used for name counts and stream counts per record, which are bounded -/// by the NTFS record size (max ~64 attributes per 4 KB record). -/// -/// Saturates to `u16::MAX` if the value overflows. -#[inline] -#[must_use] -pub fn len_to_u16(len: usize) -> u16 { - u16::try_from(len).unwrap_or(u16::MAX) -} - -/// Widen a `u32` NTFS header field to `usize` for array indexing. -/// -/// On any platform where `usize >= 32 bits` (all supported targets) this -/// is a lossless widening. On hypothetical 16-bit targets it saturates. -#[inline] -#[must_use] -pub const fn u32_as_usize(val: u32) -> usize { - val as usize -} - -/// Clamp a signed NTFS field to non-negative and convert to `u64`. -/// -/// NTFS data sizes and allocated sizes are stored as `i64` on disk but -/// represent non-negative byte counts. Negative values indicate -/// corruption or uninitialised data and are clamped to `0`. -#[inline] -#[must_use] -pub const fn nonneg_to_u64(val: i64) -> u64 { - if val > 0 { - // `val > 0` guard makes the reinterpret lossless; - // `i64::cast_unsigned` is the stable Rust 1.87 exact-bit-pattern - // converter that does not require a `cast_sign_loss` expect. - val.cast_unsigned() - } else { - 0 - } -} - -/// Convert a `u128` duration (e.g. from `Duration::as_micros()`) to `i64`. -/// -/// Saturates at `i64::MAX` (year ~292,277) instead of truncating. -#[inline] -#[must_use] -pub fn micros_to_i64(us: u128) -> i64 { - i64::try_from(us).unwrap_or(i64::MAX) -} - -/// Convert a `u128` nanosecond count (e.g. from `Duration::as_nanos()`) to -/// `u64`. -/// -/// Saturates at `u64::MAX` (~584 years of nanoseconds) instead of truncating. -/// Used wherever per-phase elapsed time is reported as a `u64` ns counter. -#[inline] -#[must_use] -pub fn nanos_to_u64(ns: u128) -> u64 { - u64::try_from(ns).unwrap_or(u64::MAX) -} - -/// Convert a `u128` millisecond count (e.g. from `Duration::as_millis()`) to -/// `u64`. -/// -/// Saturates at `u64::MAX` (~584 million years) instead of truncating. -#[inline] -#[must_use] -pub fn millis_to_u64(ms: u128) -> u64 { - u64::try_from(ms).unwrap_or(u64::MAX) -} - -/// Convert a `usize` to `u64`. -/// -/// On every supported target (`usize` ≤ 64 bits) this is a lossless widening. -/// On hypothetical >64-bit targets it saturates at `u64::MAX`. -#[inline] -#[must_use] -pub fn usize_to_u64(val: usize) -> u64 { - u64::try_from(val).unwrap_or(u64::MAX) -} - -/// Convert bytes (`u64`) to megabytes as `f64` for display purposes. -/// -/// Precision loss beyond 2^53 bytes (~8 PB) is acceptable for human display. -#[inline] -#[must_use] -#[expect( - clippy::cast_precision_loss, - reason = "display-only: sub-byte precision irrelevant for MB/GB formatting" -)] -#[expect(clippy::float_arithmetic, reason = "display-only MB conversion")] -pub fn bytes_to_mb_f64(bytes: u64) -> f64 { - bytes as f64 / (1_024.0_f64 * 1_024.0_f64) -} - -/// Convert a `u64` to `f64` for display ratios and percentages. -/// -/// Precision loss beyond 2^53 is acceptable for display. -#[inline] -#[must_use] -#[expect( - clippy::cast_precision_loss, - reason = "display-only: sub-unit precision irrelevant for ratios" -)] -pub const fn u64_to_f64(val: u64) -> f64 { - val as f64 -} - -/// Convert a `usize` to `f64` for display ratios and percentages. -/// -/// Precision loss beyond 2^53 is acceptable for display. -#[inline] -#[must_use] -#[expect( - clippy::cast_precision_loss, - reason = "display-only: sub-unit precision irrelevant for ratios" -)] -pub const fn usize_to_f64(val: usize) -> f64 { - val as f64 -} - -/// Convert a `u32` to `f64` for display. -/// -/// This is actually lossless (u32 fits exactly in f64) but provided -/// for API consistency. -#[inline] -#[must_use] -pub fn u32_to_f64(val: u32) -> f64 { - f64::from(val) -} - -/// Convert a non-negative `f64` to `u64`, clamping negative values to 0 -/// and values exceeding `u64::MAX` to `u64::MAX`. -/// -/// Used for memory budget calculations where floating-point arithmetic -/// produces approximate results that need to be stored as integers. -#[inline] -#[must_use] -#[expect( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - clippy::cast_precision_loss, - reason = "centralized f64→u64 conversion for budget/estimation math; precision loss in boundary check is acceptable since u64::MAX as f64 rounds up" -)] -pub fn f64_to_u64(val: f64) -> u64 { - if val <= 0.0 { - 0 - } else if val >= u64::MAX as f64 { - u64::MAX - } else { - val as u64 - } -} - -/// Convert a non-negative `f64` to `usize`, clamping negative values to 0. -/// -/// Used for capacity estimation where floating-point arithmetic produces -/// approximate results that need to be stored as `usize`. -#[inline] -#[must_use] -#[expect( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - clippy::cast_precision_loss, - reason = "centralized f64→usize conversion for capacity estimation; precision loss in boundary check is acceptable since usize::MAX as f64 rounds up" -)] -pub fn f64_to_usize(val: f64) -> usize { - if val <= 0.0 { - 0 - } else if val >= usize::MAX as f64 { - usize::MAX - } else { - val as usize - } -} - // ============================================================================ // IndexNameRef - Reference into names buffer // ============================================================================ @@ -664,8 +458,24 @@ impl FileRecord { } // ===== P3 Forensic Flag Accessors ===== - // forensic_flags bit layout: bit 0 = is_deleted, bit 1 = is_corrupt, bit 2 = - // is_extension + // + // `forensic_flags` bit layout — the whole byte, not just the forensic bits: + // + // bit 0 is_deleted bit 3 has_default_data + // bit 1 is_corrupt bit 4 has_i30_stream + // bit 2 is_extension bit 5 is_unified + // bits 6-7 free + // + // Only bits 0-2 are forensic. Bits 3-5 are parser bookkeeping written by + // their own setters (`set_has_default_data`, `set_has_i30_stream`, + // `set_unified`), which is exactly why `set_forensic_flags` masks them + // through with `& 0b11_1000` instead of overwriting the byte. + // + // A new flag must take bit 6 or 7. Bit 3 in particular feeds + // `compute_tree_metrics`, where it distinguishes "empty $DATA" from "no + // $DATA" — clobbering it surfaces as wrong `treesize` / `tree_allocated`, + // far from the cause. Keep this list complete: the previous version stopped + // after bit 2 and read as "bit 3 onwards is free". /// Returns true if this record is deleted (MFT record not in use). /// Only meaningful when parsed with `--forensic` flag. From c1c317ff58adc2c44801cd62b4387c0ee961dd26 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 21 Jul 2026 04:22:51 -0700 Subject: [PATCH 6/8] feat(mft): report $STANDARD_INFORMATION parse status via StdInfoParse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When $SI could not be decoded, the parser returned silently and left `ExtendedStandardInfo` at its defaults — every timestamp `0`, i.e. FILETIME 1601-01-01, which is a value a real record can legitimately hold. Nothing recorded that the attribute had failed, so a consumer building a forensic record could only report the default as fact. `is_corrupt` does not cover this: it is set solely by the caller on fixup/USA failure, and there was no attribute-level damage representation anywhere in the crate. `parse_standard_info_full` now returns `StdInfoParse`: V30 decoded as the 72-byte NTFS 3.0+ layout V12 decoded as the 36-byte NTFS 1.2 layout (missing extended fields are correct, not damage) Malformed present but undecodable — resident header past the end of the record, declared length under the 36-byte minimum, or payload overrunning the buffer Absent no $SI attribute at all; unusual, but not evidence of damage, and deliberately distinct from Malformed Surfaced on `ParsedRecord::std_info_parse` so it reaches the public API (`parse_record_full`, `parse_record_forensic`), which is where an external index reads from. Deliberately not `#[must_use]`: the bulk pipelines (unified, direct-index, fragment) have nowhere to record it, and dropping it there is the correct choice, not an oversight worth a lint. Costs nothing measurable. `ParsedRecord` stays at 256 bytes — the field lands in existing padding — and `StdInfoParse` is 1 byte returned in a register. The variant is just which branch the parser already took, so no new work is done; the three bulk callers discard it and the value is dead. No index, cache, or on-disk format change. Groundwork only: representation (columns, DataFrame projection, the daemon's shmem row) is deliberately left to the consumer, which builds its own index. Closes #573. --- crates/uffs-mft/src/io.rs | 2 +- crates/uffs-mft/src/io/parser/fragment.rs | 2 + crates/uffs-mft/src/io/parser/mod.rs | 2 +- crates/uffs-mft/src/io/parser/unified.rs | 2 + crates/uffs-mft/src/lib.rs | 2 +- crates/uffs-mft/src/parse.rs | 1 + .../uffs-mft/src/parse/attribute_helpers.rs | 82 ++++++++++++- crates/uffs-mft/src/parse/direct_index.rs | 2 + crates/uffs-mft/src/parse/forensic/base.rs | 4 +- crates/uffs-mft/src/parse/full.rs | 4 +- crates/uffs-mft/src/parse/placeholders.rs | 5 +- crates/uffs-mft/src/parse/tests/mod.rs | 2 + crates/uffs-mft/src/parse/tests/std_info.rs | 110 +++++++++++++++++- crates/uffs-mft/src/parse/types.rs | 8 ++ 14 files changed, 213 insertions(+), 15 deletions(-) diff --git a/crates/uffs-mft/src/io.rs b/crates/uffs-mft/src/io.rs index 152715f12..298633f3d 100644 --- a/crates/uffs-mft/src/io.rs +++ b/crates/uffs-mft/src/io.rs @@ -44,7 +44,7 @@ pub use merger::MftRecordMerger; )] pub use parser::parse_record_to_fragment; pub use parser::{ - ExtensionAttributes, ParseResult, ParsedColumns, ParsedRecord, + ExtensionAttributes, ParseResult, ParsedColumns, ParsedRecord, StdInfoParse, add_missing_parent_placeholders_to_vec, create_placeholder_record, parse_record, parse_record_full, parse_record_zero_alloc, process_record, }; diff --git a/crates/uffs-mft/src/io/parser/fragment.rs b/crates/uffs-mft/src/io/parser/fragment.rs index edfa2306f..51b703688 100644 --- a/crates/uffs-mft/src/io/parser/fragment.rs +++ b/crates/uffs-mft/src/io/parser/fragment.rs @@ -143,6 +143,8 @@ pub fn parse_record_to_fragment( // the v1.2 layout unconditionally, which zeroed those three // fields on every modern NTFS volume. let mut ext = crate::ntfs::ExtendedStandardInfo::default(); + // Fragment records have no status field, so `StdInfoParse` + // is dropped here. crate::parse::parse_standard_info_full(data, offset, &mut ext); std_info = StandardInfo::from_extended(&ext); } diff --git a/crates/uffs-mft/src/io/parser/mod.rs b/crates/uffs-mft/src/io/parser/mod.rs index 81e8e4418..43e3d3866 100644 --- a/crates/uffs-mft/src/io/parser/mod.rs +++ b/crates/uffs-mft/src/io/parser/mod.rs @@ -17,7 +17,7 @@ pub use fragment::parse_record_to_fragment; pub use unified::process_record; pub use crate::parse::{ - ExtensionAttributes, ParseResult, ParsedColumns, ParsedRecord, + ExtensionAttributes, ParseResult, ParsedColumns, ParsedRecord, StdInfoParse, add_missing_parent_placeholders_to_vec, create_placeholder_record, parse_record, parse_record_full, parse_record_zero_alloc, }; diff --git a/crates/uffs-mft/src/io/parser/unified.rs b/crates/uffs-mft/src/io/parser/unified.rs index 951933ef6..7502ae1b6 100644 --- a/crates/uffs-mft/src/io/parser/unified.rs +++ b/crates/uffs-mft/src/io/parser/unified.rs @@ -157,6 +157,8 @@ pub fn process_record(data: &[u8], frs: u64, index: &mut MftIndex, name_buf: &mu // otherwise — see `parse::attribute_helpers` for the // single-source-of-truth rationale. let mut ext = crate::ntfs::ExtendedStandardInfo::default(); + // Bulk path: `StdInfoParse` has nowhere to live on the + // index record, so the status is dropped here. crate::parse::parse_standard_info_full(data, offset, &mut ext); let mut info = crate::index::StandardInfo::from_extended(&ext); if is_directory { diff --git a/crates/uffs-mft/src/lib.rs b/crates/uffs-mft/src/lib.rs index ff051aeda..1bebd41d4 100644 --- a/crates/uffs-mft/src/lib.rs +++ b/crates/uffs-mft/src/lib.rs @@ -309,7 +309,7 @@ pub use index::{ #[cfg(windows)] pub use io::{ AlignedBuffer, ExtensionAttributes, MftExtentMap, MftRecordMerger, ParseResult, ParsedColumns, - ParsedRecord, ReadChunk, apply_fixup, generate_read_chunks, parse_record_full, + ParsedRecord, ReadChunk, StdInfoParse, apply_fixup, generate_read_chunks, parse_record_full, parse_record_zero_alloc, }; #[cfg(windows)] diff --git a/crates/uffs-mft/src/parse.rs b/crates/uffs-mft/src/parse.rs index 3a1368eb2..c55e15384 100644 --- a/crates/uffs-mft/src/parse.rs +++ b/crates/uffs-mft/src/parse.rs @@ -59,6 +59,7 @@ mod zero_alloc; // tree) calls this directly to share the one extended-aware // $STANDARD_INFORMATION parser with the legacy and direct-index pipelines, // instead of duplicating it. +pub use attribute_helpers::StdInfoParse; pub(crate) use attribute_helpers::parse_standard_info_full; use attribute_helpers::{parse_data_attribute_full, parse_file_name_full}; pub use columns::ParsedColumns; diff --git a/crates/uffs-mft/src/parse/attribute_helpers.rs b/crates/uffs-mft/src/parse/attribute_helpers.rs index 2cb937af9..65dfd0783 100644 --- a/crates/uffs-mft/src/parse/attribute_helpers.rs +++ b/crates/uffs-mft/src/parse/attribute_helpers.rs @@ -8,6 +8,63 @@ use zerocopy::FromBytes as _; use crate::index::nonneg_to_u64; use crate::ntfs::{ExtendedStandardInfo, NameInfo, StreamInfo}; +/// Outcome of a `$STANDARD_INFORMATION` parse. +/// +/// Exists so a failed parse is distinguishable from a record whose `$SI` +/// genuinely reads 1601-01-01. When the attribute cannot be decoded, the +/// caller's [`ExtendedStandardInfo`] is left untouched — every timestamp +/// stays `0`, i.e. FILETIME 1601-01-01 — which is a legitimate value a real +/// record can hold. Without this status the two are identical, and a +/// consumer building a forensic record would report the default as fact. +/// +/// Callers that do not care may simply ignore the returned value; it costs +/// nothing, because the variant is just which branch the parser already took. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum StdInfoParse { + /// No `$STANDARD_INFORMATION` attribute was seen at all. + /// + /// Never returned by `parse_standard_info_full` (crate-private, hence no + /// doc link), which is only called + /// once an `$SI` attribute has been found: it is the initial state a + /// record-level parser starts from, and what survives if the attribute + /// loop never encounters one. + #[default] + Absent, + /// Decoded as the 72-byte NTFS 3.0+ layout. All fields are populated. + V30, + /// Decoded as the 36-byte NTFS 1.2 layout. `usn`, `security_id`, + /// `owner_id`, `quota_charged`, `max_versions`, `version_number` and + /// `class_id` stay zero because that layout has no such fields — this is + /// correct, not damage. + V12, + /// The attribute is present but could not be decoded: its resident + /// header ran past the end of the record, its declared value length was + /// under the 36-byte minimum, or its payload overran the buffer. The + /// timestamps left behind are defaults, **not** data from the volume. + Malformed, +} + +impl StdInfoParse { + /// Whether the attribute was present but undecodable. + /// + /// Distinct from [`Self::Absent`]: a record with no `$SI` at all is + /// unusual but not evidence of damage, while a malformed one is. + #[must_use] + pub const fn is_malformed(self) -> bool { + matches!(self, Self::Malformed) + } + + /// Whether real on-disk values reached the caller's + /// [`ExtendedStandardInfo`]. + /// + /// `false` for both [`Self::Absent`] and [`Self::Malformed`], where the + /// timestamps are defaults rather than volume data. + #[must_use] + pub const fn is_decoded(self) -> bool { + matches!(self, Self::V30 | Self::V12) + } +} + /// Parses `$STANDARD_INFORMATION` into `ExtendedStandardInfo`. /// /// Handles both NTFS 1.2 (36 bytes) and NTFS 3.0+ (72 bytes) formats. @@ -15,6 +72,13 @@ use crate::ntfs::{ExtendedStandardInfo, NameInfo, StreamInfo}; /// `quota_charged`, `max_versions`, `version_number`, and `class_id`; these /// stay zero for NTFS 1.2, whose 36-byte layout has no such fields. /// +/// Returns which layout was decoded, or [`StdInfoParse::Malformed`] when the +/// attribute could not be decoded and `result` was left untouched. See +/// [`StdInfoParse`] for why that distinction matters; callers with no use for +/// it can discard the value by ignoring it — deliberately not `#[must_use]`, +/// since the bulk pipelines have nowhere to record it and dropping it is the +/// correct, zero-cost choice there. +/// /// `pub(crate)`: this is the single source of truth for `$STANDARD_INFORMATION` /// parsing and is also called directly from `crate::io::parser::unified`, which /// sits outside the `parse` module tree. @@ -22,7 +86,7 @@ pub(crate) fn parse_standard_info_full( data: &[u8], attr_offset: usize, result: &mut ExtendedStandardInfo, -) { +) -> StdInfoParse { use core::mem::size_of; use crate::ntfs::{ @@ -35,12 +99,12 @@ pub(crate) fn parse_standard_info_full( // truncated or malformed record. Slice with `get` so fuzzed / corrupt // input yields an early return instead of an out-of-bounds panic. let Some(value_length_bytes) = data.get(attr_offset + 16..attr_offset + 20) else { - return; + return StdInfoParse::Malformed; }; let value_length = u32::from_le_bytes(value_length_bytes.try_into().unwrap_or([0, 0, 0, 0])) as usize; let Some(value_offset_bytes) = data.get(attr_offset + 20..attr_offset + 22) else { - return; + return StdInfoParse::Malformed; }; let value_offset = u16::from_le_bytes(value_offset_bytes.try_into().unwrap_or([0, 0])) as usize; @@ -50,7 +114,7 @@ pub(crate) fn parse_standard_info_full( && si_offset + size_of::() <= data.len() { let Ok((si, _)) = StandardInformationExtended::read_from_prefix(&data[si_offset..]) else { - return; + return StdInfoParse::Malformed; }; *result = ExtendedStandardInfo { @@ -67,11 +131,13 @@ pub(crate) fn parse_standard_info_full( class_id: si.class_id, ..ExtendedStandardInfo::from_attributes(si.file_attributes) }; + + StdInfoParse::V30 } else if value_length >= STANDARD_INFO_SIZE_V12 && si_offset + size_of::() <= data.len() { let Ok((si, _)) = StandardInformation::read_from_prefix(&data[si_offset..]) else { - return; + return StdInfoParse::Malformed; }; *result = ExtendedStandardInfo { @@ -84,6 +150,12 @@ pub(crate) fn parse_standard_info_full( owner_id: 0, ..ExtendedStandardInfo::from_attributes(si.file_attributes) }; + + StdInfoParse::V12 + } else { + // Attribute present, but its declared length is under the 36-byte + // minimum or its payload overruns the record. + StdInfoParse::Malformed } } diff --git a/crates/uffs-mft/src/parse/direct_index.rs b/crates/uffs-mft/src/parse/direct_index.rs index 862451a6c..298968877 100644 --- a/crates/uffs-mft/src/parse/direct_index.rs +++ b/crates/uffs-mft/src/parse/direct_index.rs @@ -267,6 +267,8 @@ pub fn parse_record_to_index(data: &[u8], frs: u64, index: &mut crate::index::Mf // otherwise — see `attribute_helpers::parse_standard_info_full` // for the single-source-of-truth rationale. let mut ext = crate::ntfs::ExtendedStandardInfo::default(); + // Bulk path: `StdInfoParse` has nowhere to live on the + // index record, so the status is dropped here. super::parse_standard_info_full(data, offset, &mut ext); std_info = StandardInfo::from_extended(&ext); } diff --git a/crates/uffs-mft/src/parse/forensic/base.rs b/crates/uffs-mft/src/parse/forensic/base.rs index e0e66556d..39d3fe229 100644 --- a/crates/uffs-mft/src/parse/forensic/base.rs +++ b/crates/uffs-mft/src/parse/forensic/base.rs @@ -47,6 +47,7 @@ pub(super) fn parse_base_record( let mut names: Vec = Vec::new(); let mut streams: Vec = Vec::new(); let mut std_info = ExtendedStandardInfo::default(); + let mut std_info_parse = crate::parse::StdInfoParse::Absent; let mut primary = PrimaryNameTracker::default(); let mut reparse_tag: u32 = 0; let mut reparse_size: u64 = 0; // Size of $REPARSE_POINT attribute (for junctions/symlinks) @@ -71,7 +72,7 @@ pub(super) fn parse_base_record( match AttributeType::from_u32(attr_header.type_code) { Some(AttributeType::StandardInformation) if attr_header.is_non_resident == 0 => { - parse_standard_info_full(data, offset, &mut std_info); + std_info_parse = parse_standard_info_full(data, offset, &mut std_info); } Some(AttributeType::FileName) if attr_header.is_non_resident == 0 => { if let Some(name_info) = parse_file_name_full(data, offset, frs) @@ -570,6 +571,7 @@ pub(super) fn parse_base_record( fn_accessed: primary.fn_accessed, fn_mft_changed: primary.fn_mft_changed, reparse_tag, + std_info_parse, // P3 forensic fields is_deleted, is_corrupt: false, diff --git a/crates/uffs-mft/src/parse/full.rs b/crates/uffs-mft/src/parse/full.rs index 874056531..07fd0e9e1 100644 --- a/crates/uffs-mft/src/parse/full.rs +++ b/crates/uffs-mft/src/parse/full.rs @@ -93,6 +93,7 @@ pub fn parse_record_full(data: &[u8], frs: u64) -> ParseResult { let mut names: Vec = Vec::new(); let mut streams: Vec = Vec::new(); let mut std_info = ExtendedStandardInfo::default(); + let mut std_info_parse = crate::parse::StdInfoParse::Absent; let mut primary = PrimaryNameTracker::default(); let mut reparse_tag: u32 = 0; let mut reparse_size: u64 = 0; // Size of $REPARSE_POINT attribute (for junctions/symlinks) @@ -117,7 +118,7 @@ pub fn parse_record_full(data: &[u8], frs: u64) -> ParseResult { match AttributeType::from_u32(attr_header.type_code) { Some(AttributeType::StandardInformation) if attr_header.is_non_resident == 0 => { - parse_standard_info_full(data, offset, &mut std_info); + std_info_parse = parse_standard_info_full(data, offset, &mut std_info); } Some(AttributeType::FileName) if attr_header.is_non_resident == 0 => { if let Some(name_info) = parse_file_name_full(data, offset, frs) @@ -675,6 +676,7 @@ pub fn parse_record_full(data: &[u8], frs: u64) -> ParseResult { fn_accessed: primary.fn_accessed, fn_mft_changed: primary.fn_mft_changed, reparse_tag, + std_info_parse, // P3 forensic fields (not populated in normal mode) is_deleted: false, is_corrupt: false, diff --git a/crates/uffs-mft/src/parse/placeholders.rs b/crates/uffs-mft/src/parse/placeholders.rs index 1da69d9d3..445fc078f 100644 --- a/crates/uffs-mft/src/parse/placeholders.rs +++ b/crates/uffs-mft/src/parse/placeholders.rs @@ -5,7 +5,7 @@ use tracing::{debug, info, warn}; -use super::ParsedRecord; +use super::{ParsedRecord, StdInfoParse}; use crate::frs::{Frs, ParentFrs}; use crate::ntfs::ExtendedStandardInfo; @@ -47,6 +47,9 @@ pub fn create_placeholder_record(frs: u64) -> ParsedRecord { fn_accessed: 0, fn_mft_changed: 0, reparse_tag: 0, + // Synthetic parent placeholder: there is no MFT record behind it, + // so there is no $SI to have parsed. + std_info_parse: StdInfoParse::Absent, is_deleted: false, is_corrupt: false, is_extension: false, diff --git a/crates/uffs-mft/src/parse/tests/mod.rs b/crates/uffs-mft/src/parse/tests/mod.rs index 5f2a76b2e..fdfa2f630 100644 --- a/crates/uffs-mft/src/parse/tests/mod.rs +++ b/crates/uffs-mft/src/parse/tests/mod.rs @@ -561,6 +561,7 @@ fn extension_merge_with_empty_base_name() { fn_accessed: 0, fn_mft_changed: 0, reparse_tag: 0, + std_info_parse: StdInfoParse::Absent, is_deleted: false, is_corrupt: false, is_extension: false, @@ -656,6 +657,7 @@ fn extension_before_base_merge() { fn_accessed: 0, fn_mft_changed: 0, reparse_tag: 0, + std_info_parse: StdInfoParse::Absent, is_deleted: false, is_corrupt: false, is_extension: false, diff --git a/crates/uffs-mft/src/parse/tests/std_info.rs b/crates/uffs-mft/src/parse/tests/std_info.rs index f005ce380..3b3f00261 100644 --- a/crates/uffs-mft/src/parse/tests/std_info.rs +++ b/crates/uffs-mft/src/parse/tests/std_info.rs @@ -12,7 +12,7 @@ use super::{ write_i64_le, write_u16_le, write_u32_le, write_u64_le, }; use crate::ntfs::{AttributeType, ExtendedStandardInfo}; -use crate::parse::{ParseResult, parse_record_full, parse_standard_info_full}; +use crate::parse::{ParseResult, StdInfoParse, parse_record_full, parse_standard_info_full}; #[test] fn parse_standard_info_full_reads_unaligned_v30_payload() { @@ -39,8 +39,9 @@ fn parse_standard_info_full_reads_unaligned_v30_payload() { write_u64_le(&mut data, si_offset + 64, usn); let mut result = ExtendedStandardInfo::default(); - parse_standard_info_full(&data, attr_offset, &mut result); + let status = parse_standard_info_full(&data, attr_offset, &mut result); + assert_eq!(status, StdInfoParse::V30); assert_eq!(result.created, creation_time); assert_eq!(result.modified, modification_time); assert_eq!(result.mft_changed, mft_change_time); @@ -72,8 +73,9 @@ fn parse_standard_info_full_reads_quota_and_version_fields() { write_u64_le(&mut data, si_offset + 56, quota_charged); let mut result = ExtendedStandardInfo::default(); - parse_standard_info_full(&data, attr_offset, &mut result); + let status = parse_standard_info_full(&data, attr_offset, &mut result); + assert_eq!(status, StdInfoParse::V30); assert_eq!(result.max_versions, max_versions); assert_eq!(result.version_number, version_number); assert_eq!(result.class_id, class_id); @@ -86,8 +88,9 @@ fn parse_standard_info_full_reads_quota_and_version_fields() { write_u16_le(&mut v12, attr_offset + 20, value_offset); let mut v12_result = ExtendedStandardInfo::default(); - parse_standard_info_full(&v12, attr_offset, &mut v12_result); + let v12_status = parse_standard_info_full(&v12, attr_offset, &mut v12_result); + assert_eq!(v12_status, StdInfoParse::V12); assert_eq!(v12_result.max_versions, 0); assert_eq!(v12_result.version_number, 0); assert_eq!(v12_result.class_id, 0); @@ -150,3 +153,102 @@ fn parse_record_full_surfaces_extended_std_info_on_parsed_record() { assert_eq!(record.std_info.security_id, security_id); assert_eq!(record.std_info.usn, usn); } + +/// The whole point of the status: a `$SI` that cannot be decoded must be +/// distinguishable from one that genuinely reads 1601-01-01. Both leave the +/// timestamps at `0`, so the value alone can never tell them apart. +#[test] +fn undecodable_standard_info_reports_malformed_not_a_1601_timestamp() { + let attr_offset = 0_usize; + let value_offset = 24_u16; + let si_offset = attr_offset + usize::from(value_offset); + + // 1. Declared value length under the 36-byte NTFS 1.2 minimum. + let mut too_short = vec![0_u8; si_offset + 72]; + write_u32_le(&mut too_short, attr_offset + 16, 8); + write_u16_le(&mut too_short, attr_offset + 20, value_offset); + + let mut result = ExtendedStandardInfo::default(); + let status = parse_standard_info_full(&too_short, attr_offset, &mut result); + + assert_eq!(status, StdInfoParse::Malformed); + assert!(!status.is_decoded()); + assert!(status.is_malformed()); + // The trap this exists to close: the timestamps look like a real record. + assert_eq!(result.created, 0); + + // 2. Declared length is fine, but the payload overruns the buffer. + let mut truncated = vec![0_u8; si_offset + 8]; + write_u32_le(&mut truncated, attr_offset + 16, 72); + write_u16_le(&mut truncated, attr_offset + 20, value_offset); + + let mut truncated_result = ExtendedStandardInfo::default(); + let truncated_status = parse_standard_info_full(&truncated, attr_offset, &mut truncated_result); + + assert_eq!(truncated_status, StdInfoParse::Malformed); + + // 3. The resident header itself runs past the end of the record: the + // value-length field at +16..+20 does not fit in an 18-byte buffer. + let header_overrun = vec![0_u8; 18]; + + let mut header_result = ExtendedStandardInfo::default(); + let header_status = parse_standard_info_full(&header_overrun, attr_offset, &mut header_result); + + assert_eq!(header_status, StdInfoParse::Malformed); + + // 4. A genuine 1601-01-01 record: same zero timestamps, decoded status. This is + // the pair the status exists to separate. + let mut epoch = vec![0_u8; si_offset + 72]; + write_u32_le(&mut epoch, attr_offset + 16, 72); + write_u16_le(&mut epoch, attr_offset + 20, value_offset); + + let mut epoch_result = ExtendedStandardInfo::default(); + let epoch_status = parse_standard_info_full(&epoch, attr_offset, &mut epoch_result); + + assert_eq!(epoch_status, StdInfoParse::V30); + assert!(epoch_status.is_decoded()); + assert_eq!(epoch_result.created, 0); +} + +/// The status has to survive to the public API, or external consumers still +/// cannot tell the two cases apart. +#[test] +fn parse_record_full_reports_the_standard_info_parse_status() { + // A record whose $STANDARD_INFORMATION declares a value length below the + // 36-byte minimum, plus a valid $FILE_NAME so the record still parses. + let mut short_si = create_resident_attribute(AttributeType::StandardInformation, &[0_u8; 72]); + write_u32_le(&mut short_si, 16, 8); + + let data = create_test_record_with_attributes(5, true, false, 0, &[ + short_si, + create_resident_attribute( + AttributeType::FileName, + &create_file_name_value(5, "damaged.txt", 1), + ), + ]); + + let ParseResult::Base(record) = parse_record_full(&data, 5) else { + panic!("a record with a valid $FILE_NAME must still parse as ParseResult::Base"); + }; + + assert_eq!(record.std_info_parse, StdInfoParse::Malformed); + assert_eq!( + record.std_info.created, 0, + "the defaults are what make the status necessary", + ); + + // A record with no $STANDARD_INFORMATION at all is Absent, not Malformed: + // unusual, but not evidence of damage. + let no_si = + create_test_record_with_attributes(5, true, false, 0, &[create_resident_attribute( + AttributeType::FileName, + &create_file_name_value(5, "nosi.txt", 1), + )]); + + let ParseResult::Base(no_si_record) = parse_record_full(&no_si, 5) else { + panic!("a record with a valid $FILE_NAME must still parse as ParseResult::Base"); + }; + + assert_eq!(no_si_record.std_info_parse, StdInfoParse::Absent); + assert!(!no_si_record.std_info_parse.is_malformed()); +} diff --git a/crates/uffs-mft/src/parse/types.rs b/crates/uffs-mft/src/parse/types.rs index 7851761bf..b69314cb1 100644 --- a/crates/uffs-mft/src/parse/types.rs +++ b/crates/uffs-mft/src/parse/types.rs @@ -60,6 +60,14 @@ pub struct ParsedRecord { /// Common values: symlink (0xA000000C), junction (0xA0000003), `OneDrive`, /// etc. pub reparse_tag: u32, + /// How `$STANDARD_INFORMATION` decoded for this record. + /// + /// Consult this before trusting [`Self::std_info`]: on + /// [`Malformed`](crate::parse::StdInfoParse::Malformed) and + /// [`Absent`](crate::parse::StdInfoParse::Absent) its timestamps are + /// struct defaults (FILETIME `0` = 1601-01-01), which is + /// indistinguishable from a real 1601-01-01 record by value alone. + pub std_info_parse: crate::parse::StdInfoParse, // P3 Forensic fields (populated when ParseOptions::forensic is true) /// True if this record is deleted (`FRH_IN_USE` flag not set). From a506a33d3100149bb31dd8c93a846411044d7b5c Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:11:55 -0700 Subject: [PATCH 7/8] fix(build): stop compiling macOS arm64 as generic ARMv8-A MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `-C target-cpu=native` resolves to bare `generic` on every GitHub macOS runner: they report `Apple M1 (Virtual)`, and LLVM's host detection cannot identify the virtualised CPU, so it falls back to the ARMv8-A baseline. Measured on the runners themselves — `native` yields exactly one feature, `neon`, identical to `-C target-cpu=generic`. Two consequences, one loud and one silent: - Loud: `ring` asserts at compile time that `aes` and `sha2` are statically present on aarch64-apple-darwin, so it refuses to build. This is the macOS Nightly Canary failure (#553), red weekly since at least 2026-06-08. It was diagnosed as an upstream `ring 0.17.14` regression to wait out; it is neither upstream nor a regression. Reproduced locally on the pinned toolchain with `-C target-cpu=generic`: byte-identical E0080s at darwin.rs:44 and :51. `ring` is right to assert — the build was lying about the target. - Silent: `release.yml` builds the shipped macOS arm64 binary on `macos-latest` with this same flag. That runner is `macos-26-arm64`, also `Apple M1 (Virtual)`, and resolves `native` to `neon` on the *pinned* toolchain. Every macOS binary shipped so far was compiled without AES, SHA2, CRC, dotprod or fp16. Nothing errored; it was just quietly slower than intended. `apple-m1` is the correct floor for this target: it is the oldest CPU the target can run on, and it carries the features the platform guarantees. Verified on the runner (aes + sha2 present) and locally (`cargo check -p ring --target aarch64-apple-darwin` builds). Cost on real hardware: on an M4, `native` reports 46 features and `apple-m1` 34. The 12 dropped are bf16, bti, ecv, i8mm, sme, sme2, sme-f64f64, sme-i16i64, v8.5a, v8.6a, v8.7a, wfxt — SME matrix and ML dot-product extensions plus later architecture baselines, none of which the MFT parse or Polars query paths use. aes, sha2, sha3, crc and dotprod are all retained. In exchange, macOS builds become reproducible instead of silently varying with whichever machine ran the build. x86_64-apple-darwin still uses `native` and is untouched: nothing builds or ships on it here, so there is no evidence to act on. --- .cargo/config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index 02fb1c867..f6d6d8751 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -43,7 +43,7 @@ git-fetch-with-cli = true # ============================================================================= [target.aarch64-apple-darwin] linker = "clang" -rustflags = ["-C", "target-cpu=native", "-C", "link-arg=-Wl,-dead_strip"] +rustflags = ["-C", "target-cpu=apple-m1", "-C", "link-arg=-Wl,-dead_strip"] # ============================================================================= # macOS Intel From 9ae67a9a597d41cecb5f15aa518e6cb3654bf1a7 Mon Sep 17 00:00:00 2001 From: Robert M1 <50460704+githubrobbi@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:12:13 -0700 Subject: [PATCH 8/8] =?UTF-8?q?chore(toolchain):=20bump=20pin=20nightly-20?= =?UTF-8?q?26-07-14=20=E2=86=92=20nightly-2026-07-21?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Landed via `just toolchain-sync`, which accepted today's nightly on the first candidate — no step-back needed. rustc 1.99.0-nightly (87e5904f5 2026-07-20). All three lanes the recipe gates on passed before the pin moved: host clippy --all-features -D warnings ✅ host clippy --no-default-features -D warnings ✅ windows cargo xwin clippy x86_64-pc-windows-msvc ✅ linux cargo zigbuild x86_64-unknown-linux-gnu ✅ Re-validated on the new pin afterwards: 2395 tests pass, lint-prod and lint-tests clean, rustdoc clean with --document-private-items --locked. This bump has been deferred since 2026-06-29 on the theory that an upstream `ring 0.17.14` regression blocked it. That theory was wrong — see the preceding commit. The blocker was our own `-C target-cpu=native` on aarch64-apple-darwin, and nothing about the toolchain ever gated it. --- rust-toolchain.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 1d107358f..5ead2c230 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -32,7 +32,7 @@ # Run `just toolchain-sync` to re-attempt a channel bump; the CI # pipeline auto-refreshes on `ship --fresh` unless `--skip-toolchain-sync` # is passed. -channel = "nightly-2026-07-14" +channel = "nightly-2026-07-21" # Specify components that should always be available components = [