Skip to main content

forensic_vfs/
fs.rs

1//! The filesystem navigation contract and its unified forensic metadata.
2//!
3//! One mounted, read-only filesystem is an [`Arc<dyn FileSystem>`]: all reads are
4//! `&self` so N workers share one handle (no per-thread MFT re-parse), and bulk
5//! enumerations are **owned, `Send`, `'static` streams** — never `&self`-borrowing
6//! iterators (which cannot cross a thread boundary) and never eager `Vec`s (which
7//! OOM on WinSxS-scale directories). [`FileId`] is a filesystem-specific identity,
8//! not a bare inode, because a reallocated NTFS record or a reused ext inode must
9//! never be confused with the original.
10
11use std::sync::Arc;
12
13use crate::error::VfsResult;
14
15/// Filesystem-specific stable identity, re-exported from `forensicnomicon-core`
16/// (ADR 0009). The type moved down to the zero-dep KNOWLEDGE leaf so
17/// `state-history-forensic` can reuse it verbatim in the `[P]` evidential-address
18/// key without a wrong-direction dependency on this VFS layer. The re-export keeps
19/// every existing `forensic_vfs::FileId` import working unchanged — the address
20/// domain still matches each FS's real identity primitive, so a reused slot is
21/// never confused with the original.
22pub use forensicnomicon_core::FileId;
23
24/// A named data stream on a node: the default `$DATA`, an NTFS ADS, an HFS+
25/// resource fork, an xattr, or synthetic slack. Metadata only — the actual runs
26/// come from [`FileSystem::extents`], lazily.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct StreamInfo {
29    pub id: StreamId,
30    pub name: Option<Vec<u8>>,
31    pub size: u64,
32    pub residency: ResidencyKind,
33    pub kind: StreamKind,
34}
35
36/// Which stream of a node an operation addresses.
37#[non_exhaustive]
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum StreamId {
40    Default,
41    Named(u16),
42    ResourceFork,
43    Xattr(u16),
44    Slack,
45}
46
47/// Stream taxonomy — not every named stream is an NTFS ADS; a consumer needs to
48/// know what it is reading rather than flattening all streams into one bucket.
49#[non_exhaustive]
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum StreamKind {
52    NtfsData,
53    NtfsAds,
54    HfsResourceFork,
55    ApfsNamed,
56    Xattr,
57    SyntheticSlack,
58}
59
60/// Whether a stream's bytes live inline in the metadata record or out in runs.
61#[non_exhaustive]
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum ResidencyKind {
64    Resident { inline_len: u32 },
65    NonResident,
66}
67
68/// Name/metadata-layer allocation status (TSK's name-vs-meta split). Run-level
69/// allocation is tracked separately on [`RunInfo`].
70#[non_exhaustive]
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum Allocation {
73    Allocated,
74    Deleted,
75    Orphan,
76}
77
78/// What kind of node this is.
79#[non_exhaustive]
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum NodeKind {
82    File,
83    Dir,
84    Symlink,
85    Device,
86    Other,
87}
88
89/// Where a timestamp came from — an NTFS `$STANDARD_INFORMATION` time and a
90/// `$FILE_NAME` time disagreeing is a tamper signal, so the source is preserved.
91#[non_exhaustive]
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum TimeSource {
94    /// NTFS `$STANDARD_INFORMATION`.
95    Si,
96    /// NTFS `$FILE_NAME`.
97    Fn,
98    /// A Unix inode table (ext/APFS).
99    InodeTable,
100    /// A directory entry (FAT/exFAT).
101    DirEntry,
102    /// Source not distinguished by the reader.
103    Unspecified,
104}
105
106/// Native granularity of a timestamp — a UTC/seconds assumption silently loses
107/// NTFS's 100 ns resolution (a tamper signal) or FAT's 2-second quantization.
108#[non_exhaustive]
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum TimeResolution {
111    /// Windows FILETIME, 100 ns ticks.
112    WinFileTime,
113    Nanos,
114    Micros,
115    Seconds,
116    /// FAT last-modified is quantized to 2 seconds.
117    TwoSeconds,
118}
119
120/// One timestamp with its provenance.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct TimeStamp {
123    /// Nanoseconds since the Unix epoch (i128 spans FILETIME's range).
124    pub unix_nanos: i128,
125    pub source: TimeSource,
126    pub resolution: TimeResolution,
127}
128
129/// MAC(B) times. `None` for a field means "not present in this FS", which is
130/// forensically distinct from an epoch-zero value.
131#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
132pub struct MacbTimes {
133    pub modified: Option<TimeStamp>,
134    pub accessed: Option<TimeStamp>,
135    /// Metadata-change (ctime).
136    pub changed: Option<TimeStamp>,
137    /// Creation (crtime / born).
138    pub born: Option<TimeStamp>,
139}
140
141/// How a volume's timestamps are anchored. FAT/exFAT are volume-local, so a UTC
142/// assumption shifts every MAC time.
143#[non_exhaustive]
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum TimeZonePolicy {
146    Utc,
147    LocalUnknown,
148    Local { minutes_east: i16 },
149}
150
151/// Logical/physical sector and cluster/block sizes, with per-layer provenance.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub struct SectorSizes {
154    pub logical: u32,
155    pub physical: u32,
156    pub cluster_or_block: u32,
157}
158
159/// Flags on one data run.
160#[allow(clippy::struct_excessive_bools)] // a domain flags record, not state
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
162pub struct RunFlags {
163    pub sparse: bool,
164    pub encrypted: bool,
165    pub compressed: bool,
166    /// A filler/placeholder run (e.g. a sparse hole rendered as zeros).
167    pub filler: bool,
168}
169
170/// A physical byte run in the underlying image.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub struct ByteRun {
173    pub image_offset: u64,
174    pub len: u64,
175    pub flags: RunFlags,
176}
177
178/// Run-level allocation — a deleted file can have partly-reallocated clusters; an
179/// allocated file can have sparse holes. Independent of [`FsMeta::allocated`].
180#[non_exhaustive]
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum RunAlloc {
183    Allocated,
184    Unallocated,
185    Overwritten,
186    Unknown,
187}
188
189/// One run plus its allocation provenance.
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub struct RunInfo {
192    pub run: ByteRun,
193    pub alloc: RunAlloc,
194}
195
196/// One directory entry.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct DirEntry {
199    pub name: Vec<u8>,
200    pub id: FileId,
201    pub kind: NodeKind,
202}
203
204/// A hardlink back-reference: a parent directory plus the name under it.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct HardLink {
207    pub parent: FileId,
208    pub name: Vec<u8>,
209}
210
211/// The unified forensic metadata record — TSK's name-layer vs meta-layer split,
212/// ADS/residency, and per-timestamp provenance, **without** the eager run-list
213/// (runs come from [`FileSystem::extents`] on demand).
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct FsMeta {
216    /// Metadata address (MFT reference / inode number).
217    pub ino: u64,
218    pub kind: NodeKind,
219    /// Name/metadata-layer status.
220    pub allocated: Allocation,
221    pub size: u64,
222    pub nlink: u32,
223    pub uid: Option<u32>,
224    pub gid: Option<u32>,
225    pub mode: Option<u32>,
226    pub times: MacbTimes,
227    /// Default `$DATA` plus ADS / resource forks (metadata only).
228    pub streams: Vec<StreamInfo>,
229    pub residency: ResidencyKind,
230    pub link_target: Option<Vec<u8>>,
231}
232
233/// A recovered deleted (or orphaned) node: the identity a consumer needs to
234/// render it. Unlike the bare [`FsMeta`] that [`FileSystem::deleted`] yields,
235/// this carries a readable [`FileId`] (so its bytes read via
236/// [`FileSystem::read_at`] / [`FileSystem::extents`]), the recovered `name`
237/// (possibly partial — a filesystem may destroy part of the name on delete,
238/// e.g. FAT's `0xE5` first byte), and the `parent` directory (`None` = orphan:
239/// the parent is unknown or unrecoverable). `meta` carries allocation status
240/// and MACB times.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct DeletedNode {
243    /// Readable identity — usable with `read_at` / `extents` / `meta`.
244    pub id: FileId,
245    /// The recovered name. May be empty or partial when the filesystem
246    /// destroyed it on delete; never fabricated.
247    pub name: Vec<u8>,
248    /// The parent directory, or `None` for an orphan (unrecoverable parent).
249    pub parent: Option<FileId>,
250    /// Allocation status ([`Allocation::Deleted`] or [`Allocation::Orphan`])
251    /// plus size and MACB times.
252    pub meta: FsMeta,
253}
254
255/// An owned, `Send`, `'static` stream of directory entries — holds no borrow of
256/// `&self` and no lock across `next()`, so it moves to a worker thread freely.
257pub struct DirStream(Box<dyn Iterator<Item = VfsResult<DirEntry>> + Send>);
258/// An owned stream of allocation-tagged runs.
259pub struct ExtentStream(Box<dyn Iterator<Item = VfsResult<RunInfo>> + Send>);
260/// An owned stream of nodes (for deleted/orphan enumeration).
261pub struct NodeStream(Box<dyn Iterator<Item = VfsResult<FsMeta>> + Send>);
262
263impl DirStream {
264    /// Wrap any `Send + 'static` iterator of entries.
265    pub fn new(it: impl Iterator<Item = VfsResult<DirEntry>> + Send + 'static) -> Self {
266        Self(Box::new(it))
267    }
268    /// An empty stream.
269    #[must_use]
270    pub fn empty() -> Self {
271        Self(Box::new(std::iter::empty()))
272    }
273}
274impl ExtentStream {
275    pub fn new(it: impl Iterator<Item = VfsResult<RunInfo>> + Send + 'static) -> Self {
276        Self(Box::new(it))
277    }
278    #[must_use]
279    pub fn empty() -> Self {
280        Self(Box::new(std::iter::empty()))
281    }
282}
283impl NodeStream {
284    pub fn new(it: impl Iterator<Item = VfsResult<FsMeta>> + Send + 'static) -> Self {
285        Self(Box::new(it))
286    }
287    #[must_use]
288    pub fn empty() -> Self {
289        Self(Box::new(std::iter::empty()))
290    }
291}
292impl Iterator for DirStream {
293    type Item = VfsResult<DirEntry>;
294    fn next(&mut self) -> Option<Self::Item> {
295        self.0.next()
296    }
297}
298impl Iterator for ExtentStream {
299    type Item = VfsResult<RunInfo>;
300    fn next(&mut self) -> Option<Self::Item> {
301        self.0.next()
302    }
303}
304impl Iterator for NodeStream {
305    type Item = VfsResult<FsMeta>;
306    fn next(&mut self) -> Option<Self::Item> {
307        self.0.next()
308    }
309}
310
311/// An owned, `Send`, `'static` stream of [`DeletedNode`]s — the rich
312/// deleted-enumeration surface (identity + name + parent), distinct from the
313/// bare-[`FsMeta`] [`NodeStream`].
314pub struct DeletedStream(Box<dyn Iterator<Item = VfsResult<DeletedNode>> + Send>);
315impl DeletedStream {
316    /// Wrap any `Send + 'static` iterator of deleted nodes.
317    pub fn new(it: impl Iterator<Item = VfsResult<DeletedNode>> + Send + 'static) -> Self {
318        Self(Box::new(it))
319    }
320    /// An empty stream — the default for a reader that cannot recover deleted
321    /// identities.
322    #[must_use]
323    pub fn empty() -> Self {
324        Self(Box::new(std::iter::empty()))
325    }
326}
327impl Iterator for DeletedStream {
328    type Item = VfsResult<DeletedNode>;
329    fn next(&mut self) -> Option<Self::Item> {
330        self.0.next()
331    }
332}
333
334/// The filesystem family — the canonical identity newtype from
335/// forensicnomicon-core (`FsKind::NTFS`, `FsKind::EXT`, …).
336pub use forensicnomicon_core::filesystems::FsKind;
337
338/// One mounted, read-only filesystem. Inode-addressed; `&self` reads share one
339/// handle across workers; internal caches use interior mutability, never
340/// `&mut self`.
341pub trait FileSystem: Send + Sync {
342    fn kind(&self) -> FsKind;
343    fn root(&self) -> FileId;
344    fn sector_sizes(&self) -> SectorSizes;
345    fn timestamp_zone(&self) -> TimeZonePolicy;
346
347    /// The filesystem's own volume label / name, decoded per the filesystem's defined
348    /// encoding (NTFS `$VOLUME_NAME` UTF-16LE, FAT/exFAT label, ext4 `s_volume_name`,
349    /// APFS volume name), or `None` when the volume is unlabeled or the reader does not
350    /// extract it. This is the *filesystem* label (e.g. "System Reserved"), distinct
351    /// from a partition-table name (`VolumeDesc.label`).
352    fn volume_label(&self) -> Option<String> {
353        None
354    }
355
356    /// Stream the children of a directory (owned, `Send`).
357    fn read_dir(&self, ino: FileId) -> VfsResult<DirStream>;
358    /// Stream the runs of one data stream of a node (owned, `Send`).
359    fn extents(&self, ino: FileId, stream: StreamId) -> VfsResult<ExtentStream>;
360
361    fn lookup(&self, parent: FileId, name: &[u8]) -> VfsResult<Option<FileId>>;
362    fn meta(&self, ino: FileId) -> VfsResult<FsMeta>;
363    fn read_at(&self, ino: FileId, stream: StreamId, off: u64, buf: &mut [u8]) -> VfsResult<usize>;
364    /// Read a symlink target, capped so a hostile symlink cannot allocate without
365    /// bound.
366    fn read_link(&self, ino: FileId, cap: usize) -> VfsResult<Vec<u8>>;
367
368    // --- Forensic surface (default-empty / streamed) ---
369
370    fn data_streams(&self, ino: FileId) -> VfsResult<Vec<StreamInfo>> {
371        let _ = ino;
372        Ok(Vec::new())
373    }
374    /// Hardlink back-references (capped by the implementation).
375    fn hardlinks(&self, ino: FileId) -> VfsResult<Vec<HardLink>> {
376        let _ = ino;
377        Ok(Vec::new())
378    }
379    /// Deleted/orphan nodes, streamed (never an eager `Vec`).
380    fn deleted(&self) -> VfsResult<NodeStream>;
381    /// Deleted/orphan nodes with recovered identity — a readable [`FileId`],
382    /// name, and parent — so a consumer can render a deleted file in place (or
383    /// route an orphan to a bucket) and read its bytes. The default is an
384    /// **empty** stream: a reader opts in by overriding this once it can
385    /// recover the name + parent + id from its on-disk structures (e.g. NTFS
386    /// `$FILE_NAME` + the MFT reference). It never fabricates an entry. This is
387    /// the surface [`deleted`](Self::deleted) cannot provide — that one yields
388    /// bare [`FsMeta`] with no id to read the node.
389    fn deleted_nodes(&self) -> VfsResult<DeletedStream> {
390        Ok(DeletedStream::empty())
391    }
392    /// Unallocated runs, streamed.
393    fn unallocated(&self) -> VfsResult<ExtentStream>;
394    /// File slack for a stream, if the FS exposes it.
395    fn slack(&self, ino: FileId, stream: StreamId) -> VfsResult<Option<ByteRun>> {
396        let _ = (ino, stream);
397        Ok(None)
398    }
399
400    /// Findings raised while mounting/navigating this filesystem. Behind the
401    /// `findings` feature so a bare reader does not inherit forensicnomicon.
402    #[cfg(feature = "findings")]
403    fn findings(&self) -> VfsResult<Vec<forensicnomicon::report::Finding>> {
404        Ok(Vec::new())
405    }
406}
407
408/// The object-safe shared filesystem handle.
409pub type DynFs = Arc<dyn FileSystem>;