diff --git a/crates/uffs-mft/src/io/readers/basic.rs b/crates/uffs-mft/src/io/readers/basic.rs index 6b5838f59..ed84c9585 100644 --- a/crates/uffs-mft/src/io/readers/basic.rs +++ b/crates/uffs-mft/src/io/readers/basic.rs @@ -22,6 +22,15 @@ pub(crate) struct MftRecordReader { } impl MftRecordReader { + /// Whether `frs` falls inside the mapped `$MFT` extents, i.e. a + /// targeted read of it can be attempted at all. Lets callers + /// distinguish "no such record on this volume" (benign) from a + /// failed read of a record that does exist. + #[must_use] + pub(crate) fn covers(&self, frs: u64) -> bool { + self.extent_map.physical_offset(frs).is_some() + } + /// Creates a new MFT record reader with explicit extent mapping. /// /// This constructor should be used when the MFT is fragmented. diff --git a/crates/uffs-mft/src/lcn_resolve.rs b/crates/uffs-mft/src/lcn_resolve.rs index 3a39595d5..4d8b1a7d1 100644 --- a/crates/uffs-mft/src/lcn_resolve.rs +++ b/crates/uffs-mft/src/lcn_resolve.rs @@ -1,9 +1,20 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2025-2026 SKY, LLC. +//! Targeted MFT record reads for bulk content-read jobs. +//! //! Resolves NTFS file references (FRS) to their on-disk physical -//! location (LCN) — the read-order optimization for bulk content-read -//! jobs (`uffs-content`). +//! location (LCN), and streams whole fixed-up record bytes to a caller +//! callback — the read-order optimization and record-byte source for +//! `uffs-content`. +//! +//! Two public entry points share one targeted-read loop: +//! +//! * `for_each_record` — reads each requested record once, applies the NTFS +//! Update-Sequence-Array fixup, and hands the bytes to a callback (bounded +//! memory: two reused record-sized buffers, borrow-only delivery). +//! * `resolve_frs_to_lcn` — the original LCN-only convenience, now a thin +//! adapter over `for_each_record`. //! //! Real-hardware benchmarking found that reading matched candidates in //! whatever order a search happened to return them in (or even sorted by @@ -45,16 +56,25 @@ use crate::platform::VolumeHandle; /// every offset computed from it (see that method's own doc comment). /// /// `None` in the returned map for a given FRS means one of: the record -/// couldn't be read (outside the MFT, transient I/O failure), its -/// `$DATA` is resident (a small file — no physical location to speak -/// of, and cheap to read regardless of order), or it has no data runs -/// at all (an empty file). Callers should treat `None` as "no seek-order -/// preference" (e.g. sort first), not as an error. +/// couldn't be read (outside the MFT, transient I/O failure, corrupt at +/// a sector boundary), its `$DATA` is resident (a small file — no +/// physical location to speak of, and cheap to read regardless of +/// order), or it has no data runs at all (an empty file). Callers +/// should treat `None` as "no seek-order preference" (e.g. sort first), +/// not as an error. /// -/// `frs_list` is de-duplicated and read in ascending order internally — -/// this keeps the targeted record reads this performs close to -/// sequential within `$MFT` itself, which is typically far less -/// fragmented than the volume at large, not merely for tidiness. +/// `frs_list` holds **48-bit MFT record numbers**, not full 64-bit file +/// references — mask the sequence number off first +/// (`file_reference & 0x0000_FFFF_FFFF_FFFF`). The list is +/// de-duplicated and read in ascending order internally — this keeps +/// the targeted record reads this performs close to sequential within +/// `$MFT` itself, which is typically far less fragmented than the +/// volume at large, not merely for tidiness. +/// +/// Record bytes are USA-fixup-verified before parsing (via +/// `for_each_record`, which this delegates to) — a torn record folds +/// to `None` instead of being parsed with sector-boundary bytes still +/// holding the update-sequence sentinel. /// /// # Errors /// Returns an error only if `$MFT`'s own extents can't be determined at @@ -68,8 +88,108 @@ pub fn resolve_frs_to_lcn( frs_list: &[u64], ) -> Result>> { let mut result = HashMap::with_capacity(frs_list.len()); + for_each_record(volume, frs_list, |frs, outcome| { + result.insert(frs, outcome.bytes().and_then(first_data_lcn)); + })?; + Ok(result) +} + +/// Per-record outcome delivered to `for_each_record`'s callback. +/// +/// Success carries the fixed-up bytes; the three failure variants +/// preserve *why* a record yielded no bytes, because the distinction is +/// itself a signal: a [`Self::Corrupt`] record **exists and is marked +/// torn/damaged** (forensically interesting), while [`Self::NotInMft`] +/// is a benign "no such record on this volume". Callers that only care +/// about "bytes or not" (the content service) collapse the variants via +/// [`Self::bytes`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecordOutcome<'a> { + /// The record was read and its Update-Sequence-Array fixup + /// verified — the full fixed-up record bytes. + Bytes(&'a [u8]), + /// The FRS lies outside `$MFT`'s mapped extents (or in a sparse + /// region): no such record exists on this volume. Benign. + NotInMft, + /// The record's location is known but reading it failed + /// (transient I/O error, short read). Worth a retry elsewhere. + Io, + /// The record was read but failed fixup verification — a torn + /// write or corrupt sector-boundary sentinel. The record exists + /// and is damaged; its unverified bytes are deliberately withheld. + Corrupt, +} + +impl<'a> RecordOutcome<'a> { + /// The verified record bytes, or `None` for any non-success + /// outcome — the "fall back to the filesystem" collapse used by + /// callers without forensic interest. + #[must_use] + pub const fn bytes(self) -> Option<&'a [u8]> { + match self { + Self::Bytes(record) => Some(record), + Self::NotInMft | Self::Io | Self::Corrupt => None, + } + } +} + +/// Reads the raw MFT record for each FRS in `frs_list`, applies the NTFS +/// Update-Sequence-Array fixup, and hands the fixed-up bytes to `visit` +/// one record at a time. +/// +/// This is the byte-returning generalisation of `resolve_frs_to_lcn`: +/// the same targeted, snapshot-capable read, but the caller receives the +/// whole record and derives whatever it needs (LCN, resident `$DATA`, +/// reparse target, object id, …) instead of this crate pre-parsing one +/// field. +/// +/// # Memory +/// Bounded by design. Exactly **two reused record-sized buffers** exist +/// for the whole pass — the reader's internal aligned I/O buffer and +/// the fixup scratch copy — never one allocation per record. `visit` +/// receives a **borrow** valid only for the duration of that call — the +/// borrow makes accidental whole-want-list retention impossible and +/// forces the caller to copy out only the distilled bytes it keeps. +/// `frs_list` is de-duplicated and read in ascending FRS order, keeping +/// the targeted reads near-sequential within `$MFT`. +/// +/// # Fixup (mandatory) +/// The fixup is applied before `visit` sees any bytes. It is not +/// hygiene: NTFS overwrites the last two bytes of every 512-byte sector +/// with an update-sequence sentinel, so any field straddling offset +/// 510–511 (a resident `$DATA` value, a runlist) reads the sentinel +/// instead of data without it. A record that fails fixup verification +/// is reported as [`RecordOutcome::Corrupt`] — its unverified bytes are +/// withheld. +/// +/// # Arguments +/// * `volume` — must be open against the **same device** the FRS values came +/// from: a live drive via [`VolumeHandle::open`], or a VSS snapshot device +/// via [`VolumeHandle::open_device_path`] (the content-service case). Same +/// requirement as `resolve_frs_to_lcn`. +/// * `frs_list` — **48-bit MFT record numbers**, not full 64-bit file +/// references: mask the sequence number off first (`file_reference & +/// 0x0000_FFFF_FFFF_FFFF`). A raw file reference would silently address the +/// wrong record. +/// * `visit(frs, outcome)` — called once per **distinct** FRS, in ascending +/// order, with a [`RecordOutcome`]: verified fixed-up bytes on success, or a +/// classified failure (`NotInMft` / `Io` / `Corrupt`). No failure aborts the +/// rest of the pass. Callers without forensic interest collapse it with +/// [`RecordOutcome::bytes`]. +/// +/// # Errors +/// Returns `Err` only if `$MFT`'s own extents can't be determined at +/// all (e.g. a bad handle) — i.e. the whole pass cannot start. +/// Per-record failures are delivered as [`RecordOutcome`] variants to +/// `visit`, never propagated. +#[cfg(windows)] +pub fn for_each_record)>( + volume: &VolumeHandle, + frs_list: &[u64], + mut visit: V, +) -> Result<()> { if frs_list.is_empty() { - return Ok(result); + return Ok(()); } let extents = volume.get_mft_extents()?; @@ -81,48 +201,100 @@ pub fn resolve_frs_to_lcn( let mut reader = MftRecordReader::new_with_extents(extent_map); let handle = volume.raw_handle(); - let mut sorted: Vec = frs_list.to_vec(); - sorted.sort_unstable(); - sorted.dedup(); + // One scratch copy for the whole pass — records are copied into it + // so the fixup can mutate them without touching the reader's own + // aligned I/O buffer. No per-record allocation. + let mut scratch: Vec = Vec::new(); - for frs in sorted { - let lcn = reader + for frs in sorted_deduped(frs_list) { + if !reader.covers(frs) { + visit(frs, RecordOutcome::NotInMft); + continue; + } + // `None` = read failed; `Some(bool)` = read succeeded, bool = + // fixup verified. Splitting the fallible step out keeps the + // scratch borrow out of the Result combinators. + let read_and_fixed = reader .read_record(handle, frs) - .ok() - .and_then(first_data_lcn); - result.insert(frs, lcn); + .map(|raw| fix_into_scratch(raw, &mut scratch)) + .ok(); + let outcome = match read_and_fixed { + Some(true) => RecordOutcome::Bytes(&scratch), + Some(false) => RecordOutcome::Corrupt, + None => RecordOutcome::Io, + }; + visit(frs, outcome); } - Ok(result) + Ok(()) +} + +/// Sorts and de-duplicates a caller's FRS want-list into the ascending +/// read order both public entry points guarantee. +#[cfg_attr( + all(not(windows), not(test)), + expect( + dead_code, + reason = "only called by for_each_record (Windows-only) outside tests" + ) +)] +fn sorted_deduped(frs_list: &[u64]) -> Vec { + let mut sorted: Vec = frs_list.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + sorted } -/// Finds the primary (unnamed) `$DATA` attribute in a raw record buffer -/// and returns the starting LCN of its first real (non-sparse) data run -/// — `None` for resident data, an unparseable record, or a wholly-sparse -/// file. +/// Copies `raw` into the reused `scratch` buffer and applies the NTFS +/// Update-Sequence-Array fixup in place. +/// +/// Returns `true` when the fixup verified and `scratch` now holds the +/// fixed-up record bytes; `false` when the record is structurally +/// invalid or corrupt at a sector boundary (torn write) — callers must +/// then treat the record as unreadable and must not parse `scratch`. /// -/// Pure, cross-platform byte parsing (no Windows dependency), kept +/// Pure, cross-platform byte manipulation (no Windows dependency), kept /// testable without Windows even though its only non-test caller, -/// `resolve_frs_to_lcn` (Windows-only — not in scope on this platform's -/// rustdoc build), is Windows-only. +/// `for_each_record`, is Windows-only. #[cfg_attr( all(not(windows), not(test)), expect( dead_code, - reason = "only called by resolve_frs_to_lcn (Windows-only) outside tests" + reason = "only called by for_each_record (Windows-only) outside tests" ) )] -fn first_data_lcn(record: &[u8]) -> Option { +fn fix_into_scratch(raw: &[u8], scratch: &mut Vec) -> bool { + scratch.clear(); + scratch.extend_from_slice(raw); + crate::parse::apply_fixup(scratch) +} + +/// Extracts the first real `$DATA` LCN from a raw record buffer. +/// +/// Finds the primary (unnamed) `$DATA` attribute and returns the +/// starting LCN of its first real (non-sparse) data run — `None` for +/// resident data, an unparseable record, or a wholly-sparse file. +/// +/// Public so `for_each_record` callers can derive the LCN from the +/// record bytes they already hold — one pass yields LCN ordering *and* +/// record contents, with no second `resolve_frs_to_lcn` call and no +/// consumer-side runlist parser. Pure, cross-platform byte parsing (no +/// Windows dependency); expects fixed-up bytes (which is what +/// `for_each_record` delivers). +#[must_use] +pub fn first_data_lcn(record: &[u8]) -> Option { let attrs = AttributeIterator::new(record)?; let data_attr = attrs .filter(|attr| attr.attribute_type() == Some(AttributeType::Data)) - .find(|attr| attr.name().is_none())?; + .find(crate::ntfs::AttributeRef::is_unnamed)?; if !data_attr.is_non_resident() { return None; } + // Lazy runlist decode: stops at the first non-sparse run without + // materialising the full runlist — this is the per-candidate LCN + // path that runs on every content job, so it must not allocate. data_attr - .data_runs() - .into_iter() + .data_runs_iter() .find(|run| !run.is_sparse()) .map(|run| run.lcn) } @@ -264,6 +436,41 @@ mod tests { ); } + #[test] + fn first_data_lcn_skips_named_data_streams() { + // Same record shape as the resident test, but the $DATA carries + // a name (an alternate data stream) — the primary-stream lookup + // must skip it, via the allocation-free is_unnamed() check. + let attr_offset = size_of::(); + let attr_len = size_of::() + size_of::() + 4; + let end_marker_offset = attr_offset + attr_len; + let mut record = vec![0_u8; end_marker_offset + size_of::()]; + + write_record_header(&mut record, end_marker_offset); + + write_u32_le( + &mut record, + attr_offset, + crate::ntfs::AttributeType::DATA_TYPE, + ); + write_u32_le(&mut record, attr_offset + 4, crate::len_to_u32(attr_len)); + record[attr_offset + 8] = 1; // non-resident (would have runs) + record[attr_offset + 9] = 3; // name_length = 3 → named stream + write_u16_le(&mut record, attr_offset + 12, 0); + write_u16_le(&mut record, attr_offset + 14, 1); + write_u32_le( + &mut record, + end_marker_offset, + crate::ntfs::AttributeType::END_MARKER, + ); + + assert_eq!( + first_data_lcn(&record), + None, + "a named $DATA stream is not the primary stream and must be skipped" + ); + } + #[test] fn first_data_lcn_returns_none_when_no_data_attribute_present() { let attr_offset = size_of::(); @@ -279,4 +486,221 @@ mod tests { assert_eq!(first_data_lcn(&record), None); } + + // ── for_each_record: fixup + ordering plumbing ─────────────────── + + use super::{fix_into_scratch, sorted_deduped}; + + /// Installs a valid two-sector multi-sector scaffold into a + /// 1024-byte record buffer, exactly as it would look **on disk + /// before fixup**: `FILE` magic, USA at offset 48 with 3 entries + /// (check value + one saved word per sector), the check-value + /// sentinel overwriting the last two bytes of each 512-byte sector, + /// and the real bytes for those positions parked in the USA. + fn write_multi_sector_scaffold( + record: &mut [u8], + check: u16, + real_sector1: [u8; 2], + real_sector2: [u8; 2], + ) { + assert!(record.len() >= 1024, "scaffold needs a two-sector record"); + record[0..4].copy_from_slice(b"FILE"); + write_u16_le(record, 4, 48); // usa_offset + write_u16_le(record, 6, 3); // usa_count (check + 2 sectors) + write_u16_le(record, 48, check); + record[50..52].copy_from_slice(&real_sector1); + record[52..54].copy_from_slice(&real_sector2); + write_u16_le(record, 510, check); + write_u16_le(record, 1022, check); + } + + #[test] + fn fix_into_scratch_restores_sector_boundary_bytes() { + let mut record = vec![0_u8; 1024]; + write_multi_sector_scaffold(&mut record, 0xBEEF, [0xCD, 0xEF], [0x12, 0x34]); + + let mut scratch = Vec::new(); + assert!( + fix_into_scratch(&record, &mut scratch), + "valid USA must verify" + ); + assert_eq!(scratch.len(), record.len()); + assert_eq!( + &scratch[510..512], + &[0xCD, 0xEF], + "sector-1 boundary must hold the real bytes, not the sentinel" + ); + assert_eq!( + &scratch[1022..1024], + &[0x12, 0x34], + "sector-2 boundary must hold the real bytes, not the sentinel" + ); + // The caller's raw bytes are untouched — fixup mutates only the + // scratch copy. + assert_eq!(u16::from_le_bytes([record[510], record[511]]), 0xBEEF); + } + + #[test] + fn fix_into_scratch_rejects_torn_write() { + let mut record = vec![0_u8; 1024]; + write_multi_sector_scaffold(&mut record, 0xBEEF, [0xCD, 0xEF], [0x12, 0x34]); + // A torn write: sector 2 was written under a different update + // sequence, so its sentinel doesn't match the USA check value. + write_u16_le(&mut record, 1022, 0xDEAD); + + let mut scratch = Vec::new(); + assert!( + !fix_into_scratch(&record, &mut scratch), + "mismatched sector sentinel is a torn write and must fail verification" + ); + } + + #[test] + fn fix_into_scratch_rejects_non_file_magic() { + let mut record = vec![0_u8; 1024]; + write_multi_sector_scaffold(&mut record, 0xBEEF, [0xCD, 0xEF], [0x12, 0x34]); + record[0..4].copy_from_slice(b"BAAD"); + + let mut scratch = Vec::new(); + assert!(!fix_into_scratch(&record, &mut scratch)); + } + + #[test] + fn record_outcome_bytes_collapses_failures_to_none() { + use super::RecordOutcome; + let payload = [1_u8, 2, 3]; + assert_eq!(RecordOutcome::Bytes(&payload).bytes(), Some(&payload[..])); + assert_eq!(RecordOutcome::NotInMft.bytes(), None); + assert_eq!(RecordOutcome::Io.bytes(), None); + assert_eq!(RecordOutcome::Corrupt.bytes(), None); + } + + #[test] + fn sorted_deduped_yields_ascending_distinct_frs() { + assert_eq!(sorted_deduped(&[5, 3, 5, 1, 3]), vec![1, 3, 5]); + assert_eq!(sorted_deduped(&[]), Vec::::new()); + } + + /// The reason fixup is mandatory, demonstrated end-to-end: a + /// resident `$DATA` value that straddles the sector-1 boundary + /// (bytes 510–511) parses to the **real** content only after + /// `fix_into_scratch` — parsed raw, those two bytes are the USA + /// sentinel. + #[test] + fn resident_data_straddling_sector_boundary_needs_fixup() { + let attr_offset = 56_usize; // 48-byte FRS header + 6-byte USA, aligned to 8 + let value_rel = size_of::() + size_of::(); + let value_start = attr_offset + value_rel; + let value_len = 516 - value_start; // value covers 510..512 and beyond + let attr_len = value_rel + value_len; + let end_marker_offset = attr_offset + attr_len; + let mut record = vec![0_u8; 1024]; + + // FRS header fields (same offsets as `write_record_header`, but + // with the first attribute at 56 to leave room for the USA). + write_u16_le(&mut record, 20, crate::len_to_u16(attr_offset)); + write_u16_le(&mut record, 22, 0x0001); // in-use + write_u32_le( + &mut record, + 24, + crate::len_to_u32(end_marker_offset + size_of::()), + ); + let record_len = record.len(); + write_u32_le(&mut record, 28, crate::len_to_u32(record_len)); + + // Resident unnamed $DATA whose value spans the sector boundary. + write_u32_le( + &mut record, + attr_offset, + crate::ntfs::AttributeType::DATA_TYPE, + ); + write_u32_le(&mut record, attr_offset + 4, crate::len_to_u32(attr_len)); + record[attr_offset + 8] = 0; // resident + record[attr_offset + 9] = 0; // unnamed + write_u16_le(&mut record, attr_offset + 12, 0); + write_u16_le(&mut record, attr_offset + 14, 1); + write_u32_le(&mut record, attr_offset + 16, crate::len_to_u32(value_len)); + write_u16_le(&mut record, attr_offset + 20, crate::len_to_u16(value_rel)); + for byte in &mut record[value_start..value_start + value_len] { + *byte = 0xAB; + } + write_u32_le( + &mut record, + end_marker_offset, + crate::ntfs::AttributeType::END_MARKER, + ); + + // On-disk state: the sentinel overwrites the two value bytes at + // 510–511; the real value bytes live in the USA. + write_multi_sector_scaffold(&mut record, 0xBEEF, [0xAB, 0xAB], [0x00, 0x00]); + + let mut scratch = Vec::new(); + assert!(fix_into_scratch(&record, &mut scratch)); + + let value = crate::ntfs::AttributeIterator::new(&scratch) + .expect("valid record header") + .find(|attr| attr.attribute_type() == Some(crate::ntfs::AttributeType::Data)) + .expect("resident $DATA present") + .resident_value() + .expect("resident value slice"); + assert_eq!(value.len(), value_len); + assert!( + value.iter().all(|&byte| byte == 0xAB), + "fixed-up value must hold the real bytes across the sector boundary" + ); + + // Contrast: parsed raw (no fixup), the same two bytes read the + // USA sentinel — the corruption this API is required to prevent. + assert!(record.len() >= 512); + assert_eq!( + &record[510..512], + 0xBEEF_u16.to_le_bytes().as_slice(), + "pre-fixup bytes at the boundary are the sentinel, not data" + ); + } + + /// Live-volume parity: `for_each_record` + [`first_data_lcn`] must + /// reproduce `resolve_frs_to_lcn`'s exact output, with callbacks in + /// ascending deduplicated order. Requires Windows + admin (or the + /// Access Broker) — run with `cargo test -- --ignored` elevated. + #[test] + #[ignore = "requires Windows with an openable C: volume (elevated or brokered)"] + #[cfg(windows)] + fn for_each_record_parity_with_resolve_frs_to_lcn_live() { + let volume = + crate::platform::VolumeHandle::open(crate::platform::DriveLetter::C).expect("open C:"); + // Shuffled with duplicates on purpose: 0 ($MFT) and low + // metafiles always exist; u64::MAX never does (must classify as + // NotInMft without aborting the pass). + let want = [16_u64, 0, 5, u64::MAX, 5, 0, 11]; + + let mut seen: Vec = Vec::new(); + let mut derived = std::collections::HashMap::new(); + let mut absent_outcome = None; + super::for_each_record(&volume, &want, |frs, outcome| { + seen.push(frs); + if frs == u64::MAX { + absent_outcome = Some(outcome == super::RecordOutcome::NotInMft); + } + derived.insert(frs, outcome.bytes().and_then(first_data_lcn)); + }) + .expect("for_each_record"); + + assert_eq!( + seen, + vec![0, 5, 11, 16, u64::MAX], + "ascending + deduplicated" + ); + assert_eq!( + absent_outcome, + Some(true), + "an FRS outside the MFT classifies as NotInMft without aborting the pass" + ); + + let resolved = super::resolve_frs_to_lcn(&volume, &want).expect("resolve_frs_to_lcn"); + assert_eq!( + derived, resolved, + "one-pass derivation must match the LCN-only API" + ); + } } diff --git a/crates/uffs-mft/src/lib.rs b/crates/uffs-mft/src/lib.rs index 1bebd41d4..d2e108184 100644 --- a/crates/uffs-mft/src/lib.rs +++ b/crates/uffs-mft/src/lib.rs @@ -312,17 +312,18 @@ pub use io::{ ParsedRecord, ReadChunk, StdInfoParse, apply_fixup, generate_read_chunks, parse_record_full, parse_record_zero_alloc, }; +pub use lcn_resolve::{RecordOutcome, first_data_lcn}; #[cfg(windows)] -pub use lcn_resolve::resolve_frs_to_lcn; +pub use lcn_resolve::{for_each_record, resolve_frs_to_lcn}; // Re-export NTFS constants and types (pure Rust data structures, cross-platform) pub use ntfs::SECTOR_SIZE; pub use ntfs::{ AttributeIterator, AttributeListEntry, AttributeRecordHeader, AttributeRef, AttributeType, - DataRun, ExtendedStandardInfo, FileNameAttribute, FileRecordSegmentHeader, IndexHeader, - IndexRoot, MultiSectorHeader, NameInfo, NonResidentAttributeData, NtfsBootSector, + DataRun, DataRunIter, ExtendedStandardInfo, FileNameAttribute, FileRecordSegmentHeader, + IndexHeader, IndexRoot, MultiSectorHeader, NameInfo, NonResidentAttributeData, NtfsBootSector, ReparseMountPointBuffer, ReparsePointHeader, ReparseTag, ResidentAttributeData, - StandardInformation, StreamInfo, apply_usa_fixup, extract_data_runs_from_attribute, - fixup_file_record, parse_data_runs, + StandardInformation, StreamInfo, apply_usa_fixup, data_runs_iter_from_attribute, + extract_data_runs_from_attribute, fixup_file_record, parse_data_runs, parse_data_runs_iter, }; // Caller's effective uid (Unix-only) — daemon-management uses it to decide // whether managing the *running* daemon needs elevation (owner comparison). diff --git a/crates/uffs-mft/src/ntfs/data_runs.rs b/crates/uffs-mft/src/ntfs/data_runs.rs index d78cc3db0..c81112a4d 100644 --- a/crates/uffs-mft/src/ntfs/data_runs.rs +++ b/crates/uffs-mft/src/ntfs/data_runs.rs @@ -60,61 +60,105 @@ impl DataRun { } /// Parses data runs (mapping pairs) from a non-resident attribute. +/// +/// Convenience over [`parse_data_runs_iter`] that collects the whole +/// runlist. Prefer the iterator on hot paths that only need a prefix of +/// the runs (e.g. the first non-sparse LCN) — it decodes lazily and +/// never allocates. #[must_use] -#[expect(clippy::similar_names, reason = "vcn and lcn are standard NTFS terms")] pub fn parse_data_runs(data: &[u8], lowest_vcn: i64) -> Vec { - let mut runs = Vec::new(); - let mut offset = 0; - let mut current_vcn = lowest_vcn; - let mut current_lcn: i64 = 0; + parse_data_runs_iter(data, lowest_vcn).collect() +} - while let Some(&header) = data.get(offset) { +/// Lazily parses data runs (mapping pairs) from a non-resident +/// attribute's mapping-pairs bytes. +/// +/// Decodes one run per `next()` call with no heap allocation. Stops at +/// the runlist terminator or at the first malformed/truncated field +/// (identical stopping behaviour to [`parse_data_runs`], which is this +/// iterator collected). +#[must_use] +pub const fn parse_data_runs_iter(data: &[u8], lowest_vcn: i64) -> DataRunIter<'_> { + DataRunIter { + data, + offset: 0, + current_vcn: lowest_vcn, + current_lcn: 0, + } +} + +/// Lazy decoder over NTFS mapping-pairs bytes — see +/// [`parse_data_runs_iter`]. +#[derive(Debug, Clone)] +pub struct DataRunIter<'a> { + /// The mapping-pairs bytes (starting at the first run header). + data: &'a [u8], + /// Byte offset of the next run header; pushed past the end of + /// `data` on termination so the iterator is fused. + offset: usize, + /// Running VCN total across decoded runs. + current_vcn: i64, + /// Running LCN total across decoded runs (deltas are relative). + current_lcn: i64, +} + +impl Iterator for DataRunIter<'_> { + type Item = DataRun; + + fn next(&mut self) -> Option { + let &header = self.data.get(self.offset)?; if header == 0 { - break; + self.offset = self.data.len(); + return None; } let length_size = (header & 0x0F) as usize; let offset_size = ((header >> 4_i32) & 0x0F) as usize; - offset += 1; + let mut offset = self.offset + 1; - let Some(length_bytes) = data.get(offset..offset + length_size) else { - break; + let Some(length_bytes) = self.data.get(offset..offset + length_size) else { + self.offset = self.data.len(); + return None; }; let run_length = parse_variable_length_unsigned(length_bytes); offset += length_size; let lcn_delta = if offset_size > 0 { - let Some(offset_bytes) = data.get(offset..offset + offset_size) else { - break; + let Some(offset_bytes) = self.data.get(offset..offset + offset_size) else { + self.offset = self.data.len(); + return None; }; parse_variable_length_signed(offset_bytes) } else { 0 }; offset += offset_size; - current_lcn += lcn_delta; + self.offset = offset; + self.current_lcn += lcn_delta; - runs.push(DataRun { - vcn: current_vcn, + let run = DataRun { + vcn: self.current_vcn, cluster_count: run_length, // A run with no encoded LCN offset is sparse; emit // `Lcn::ZERO` so `is_sparse()` keeps returning true // without consulting `offset_size` downstream. lcn: if offset_size > 0 { - crate::platform::Lcn::new(current_lcn) + crate::platform::Lcn::new(self.current_lcn) } else { crate::platform::Lcn::ZERO }, - }); + }; if let Ok(len_i64) = i64::try_from(run_length) { - current_vcn += len_i64; + self.current_vcn += len_i64; } - } - runs + Some(run) + } } +impl core::iter::FusedIterator for DataRunIter<'_> {} + #[expect( clippy::single_call_fn, reason = "extracted for clarity alongside parse_variable_length_signed" @@ -156,34 +200,53 @@ fn parse_variable_length_signed(data: &[u8]) -> i64 { } /// Extracts data runs from a non-resident attribute record. +/// +/// Convenience over [`data_runs_iter_from_attribute`] that collects the +/// whole runlist. #[must_use] pub fn extract_data_runs_from_attribute(attr_data: &[u8]) -> Vec { + data_runs_iter_from_attribute(attr_data).collect() +} + +/// Lazily extracts data runs from a non-resident attribute record. +/// +/// Returns an empty iterator for a resident, truncated, or otherwise +/// unparseable attribute — the same inputs for which +/// [`extract_data_runs_from_attribute`] returns an empty `Vec`. +/// Allocation-free; decodes one run per `next()` call. +#[must_use] +pub fn data_runs_iter_from_attribute(attr_data: &[u8]) -> DataRunIter<'_> { + /// The shared "nothing to decode" result for malformed inputs. + const fn empty() -> DataRunIter<'static> { + parse_data_runs_iter(&[], 0) + } + let header_size = size_of::(); let Some(header_slice) = attr_data.get(0..header_size) else { - return Vec::new(); + return empty(); }; let Some(header) = parse_attribute_record_header(header_slice) else { - return Vec::new(); + return empty(); }; if header.is_non_resident == 0 { - return Vec::new(); + return empty(); } let nr_size = size_of::(); let Some(nr_slice) = attr_data.get(header_size..header_size + nr_size) else { - return Vec::new(); + return empty(); }; let Some(nr_data) = parse_non_resident_attribute_data(nr_slice) else { - return Vec::new(); + return empty(); }; let mapping_pairs_offset = nr_data.mapping_pairs_offset as usize; let Some(mapping_pairs_data) = attr_data.get(mapping_pairs_offset..) else { - return Vec::new(); + return empty(); }; - parse_data_runs(mapping_pairs_data, nr_data.lowest_vcn) + parse_data_runs_iter(mapping_pairs_data, nr_data.lowest_vcn) } #[cfg(test)] diff --git a/crates/uffs-mft/src/ntfs/mod.rs b/crates/uffs-mft/src/ntfs/mod.rs index 7e39a0090..3d0dfe601 100644 --- a/crates/uffs-mft/src/ntfs/mod.rs +++ b/crates/uffs-mft/src/ntfs/mod.rs @@ -37,7 +37,10 @@ mod tests; // (kept as items here but not part of the crate's public surface). pub use self::boot_sector::NtfsBootSector; -pub use self::data_runs::{DataRun, extract_data_runs_from_attribute, parse_data_runs}; +pub use self::data_runs::{ + DataRun, DataRunIter, data_runs_iter_from_attribute, extract_data_runs_from_attribute, + parse_data_runs, parse_data_runs_iter, +}; pub use self::metadata::{ AttributeListEntry, ExtendedStandardInfo, FileNameAttribute, IndexHeader, IndexRoot, NameInfo, ReparseMountPointBuffer, ReparsePointHeader, ReparseTag, StandardInformation, StreamInfo, diff --git a/crates/uffs-mft/src/ntfs/records.rs b/crates/uffs-mft/src/ntfs/records.rs index 94449d35e..50f9c8784 100644 --- a/crates/uffs-mft/src/ntfs/records.rs +++ b/crates/uffs-mft/src/ntfs/records.rs @@ -7,8 +7,6 @@ use core::mem::size_of; use zerocopy::{FromBytes, Immutable, KnownLayout}; -use crate::ntfs::extract_data_runs_from_attribute; - /// Magic number for FILE records ("FILE" in little-endian). pub(crate) const FILE_RECORD_MAGIC: u32 = 0x454C_4946; @@ -513,20 +511,42 @@ impl<'a> AttributeRef<'a> { } /// Parses data runs from a non-resident attribute. + /// + /// Collects the whole runlist; prefer [`Self::data_runs_iter`] on + /// hot paths that only need a prefix of the runs. #[must_use] pub fn data_runs(&self) -> Vec { - if !self.is_non_resident() { - return Vec::new(); - } + self.data_runs_iter().collect() + } - extract_data_runs_from_attribute(self.data) + /// Lazily parses data runs from a non-resident attribute — + /// allocation-free, one run decoded per `next()` call. + /// + /// Empty for resident or unparseable attributes, mirroring + /// [`Self::data_runs`]. + #[must_use] + pub fn data_runs_iter(&self) -> crate::ntfs::DataRunIter<'a> { + crate::ntfs::data_runs_iter_from_attribute(self.data) + } + + /// Whether this attribute has no name — i.e. it is the primary + /// stream of its type (the unnamed `$DATA` is the file's main + /// content stream). + /// + /// Allocation-free header check; prefer this over + /// `name().is_none()`, which decodes the name into a `Vec` + /// just to discard it. + #[must_use] + pub const fn is_unnamed(&self) -> bool { + self.header.name_length == 0 } /// Returns the decoded attribute name (UTF-16 code units), if present. /// /// The name is stored as UTF-16LE bytes at an offset that carries no /// alignment guarantee, so the units are decoded into an owned `Vec` - /// rather than reinterpreted in place. + /// rather than reinterpreted in place. To merely test for a name, + /// use the allocation-free [`Self::is_unnamed`]. #[must_use] pub fn name(&self) -> Option> { if self.header.name_length == 0 {