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 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. 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 fea418cf7..51b703688 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,19 @@ 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(); + // 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); } 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..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, }; @@ -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 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/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.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 5530b75cf..65dfd0783 100644 --- a/crates/uffs-mft/src/parse/attribute_helpers.rs +++ b/crates/uffs-mft/src/parse/attribute_helpers.rs @@ -8,10 +8,76 @@ 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. -/// 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. +/// +/// 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 @@ -20,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::{ @@ -33,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; @@ -48,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 { @@ -59,13 +125,19 @@ 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) }; + + 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 { @@ -78,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.rs b/crates/uffs-mft/src/parse/tests/mod.rs similarity index 93% rename from crates/uffs-mft/src/parse/tests.rs rename to crates/uffs-mft/src/parse/tests/mod.rs index 06aa1cfef..fdfa2f630 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,42 +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); -} - #[test] fn parse_file_name_full_reads_unaligned_payload() { let attr_offset = 1_usize; @@ -599,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, @@ -694,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 new file mode 100644 index 000000000..3b3f00261 --- /dev/null +++ b/crates/uffs-mft/src/parse/tests/std_info.rs @@ -0,0 +1,254 @@ +// 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, StdInfoParse, 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(); + 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); + 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(); + 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); + 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(); + 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); + 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); +} + +/// 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). 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 { 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 = [