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>;