forensic_mount/lib.rs
1#![forbid(unsafe_code)]
2
3pub mod detect;
4pub mod filter;
5#[cfg(unix)]
6pub mod fusefs;
7pub mod inode_map;
8// The recovered-deleted marking schema (ADR-0008 v2): the single, platform-
9// agnostic source of truth for the values the Unix xattr channel and the
10// Windows NTFS-ADS channel both render.
11pub mod marking;
12pub mod session;
13pub mod win_map;
14
15#[cfg(unix)]
16pub mod fuse_unix;
17#[cfg(windows)]
18pub mod fuse_windows;
19
20#[cfg(feature = "memory")]
21pub mod mem;
22
23// 4n6mount owns its FUSE-facing filesystem contract: the `ForensicFs` trait and
24// its `Fs*` value types (implemented by both the memory VFS and the disk-image
25// `EngineFs` adapter over `forensic-vfs`). The engine now speaks a different,
26// inode-enum `FileSystem` trait; `EngineFs` bridges it into this one.
27pub mod engine_fs;
28pub mod types;
29
30pub use engine_fs::{open_image, open_image_all, EngineFs, MultiPartitionFs};
31pub use types::*;
32
33use std::io;
34use std::path::Path;
35
36/// One mounted, browsable forensic filesystem, in 4n6mount's own `u64`-inode
37/// vocabulary. A backend (the memory VFS, or [`EngineFs`] over a disk image)
38/// converts its native model into these calls; the FUSE/Dokan mount layer
39/// consumes them directly.
40///
41/// The core navigation ops are required. The forensic ops have default impls so
42/// a backend that cannot honor one degrades cleanly (an empty list, or a loud
43/// `NotSupported` for the byte-producing ones).
44pub trait ForensicFs {
45 // --- Core filesystem ops (required) ---
46
47 /// The root directory inode number for this filesystem.
48 fn root_ino(&self) -> u64;
49
50 /// List directory entries for the given inode.
51 fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>>;
52
53 /// Look up a name in a directory, returning the child inode if found.
54 fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>>;
55
56 /// Get file/directory metadata for an inode.
57 fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata>;
58
59 /// Read the entire contents of a file.
60 fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>>;
61
62 /// Read a range of bytes from a file.
63 fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>>;
64
65 /// Read the target of a symbolic link.
66 fn read_link(&mut self, ino: u64) -> FsResult<Vec<u8>>;
67
68 // --- Forensic ops (optional) ---
69
70 /// List deleted inodes.
71 fn deleted_inodes(&mut self) -> FsResult<Vec<FsDeletedInode>> {
72 Ok(vec![])
73 }
74
75 /// List deleted/orphan nodes with recovered identity — a readable inode,
76 /// the recovered name, parent inode, record id, and MACB times — so the
77 /// mount can render each in place (or route it to `$Orphans`) and read its
78 /// bytes via [`read_file`](Self::read_file). Default empty: a backend opts
79 /// in once it can recover the rich identity (e.g. NTFS `$FILE_NAME` + the
80 /// MFT reference). It never fabricates an entry.
81 fn deleted_nodes(&mut self) -> FsResult<Vec<FsDeletedNode>> {
82 Ok(vec![])
83 }
84
85 /// Attempt to recover a deleted file by inode number.
86 fn recover_file(&mut self, _ino: u64) -> FsResult<FsRecoveryResult> {
87 Err(not_supported("recover_file"))
88 }
89
90 /// Generate a forensic timeline of all filesystem events.
91 fn timeline(&mut self) -> FsResult<Vec<FsTimelineEvent>> {
92 Ok(vec![])
93 }
94
95 /// Get all unallocated block ranges.
96 fn unallocated_blocks(&mut self) -> FsResult<Vec<FsBlockRange>> {
97 Ok(vec![])
98 }
99
100 /// Read raw data from an unallocated block range.
101 fn read_unallocated(&mut self, _range: &FsBlockRange) -> FsResult<Vec<u8>> {
102 Err(not_supported("read_unallocated"))
103 }
104
105 /// List journal transactions.
106 fn journal_transactions(&mut self) -> FsResult<Vec<FsTransaction>> {
107 Ok(vec![])
108 }
109
110 /// Get filesystem-specific info as JSON (superblock, volume label, etc.).
111 fn fs_info(&self) -> FsResult<serde_json::Value> {
112 Ok(serde_json::Value::Null)
113 }
114
115 /// The block size of this filesystem.
116 fn block_size(&self) -> u64 {
117 4096
118 }
119}
120
121/// How the FUSE mount renders a [`ForensicFs`].
122///
123/// `DiskOverlay` is the disk-image presentation: the mount root lists the
124/// `ro/ rw/ deleted/ …` virtual directories and the filesystem tree lives under
125/// `ro/`. `Raw` renders the `ForensicFs` tree directly at the mount root with no
126/// overlay — used for read-only memory mounts (and any provider that owns its
127/// own top level).
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
129pub enum MountLayout {
130 /// Disk-image overlay: `ro/`, `rw/`, `deleted/`, … virtual directories.
131 #[default]
132 DiskOverlay,
133 /// The `ForensicFs` tree rendered directly at the root, read-only.
134 Raw,
135}
136
137/// How the `deleted/` view surfaces recovered deleted files.
138///
139/// A deleted file is placed **in-place** (under its recovered parent, at its
140/// real name) when its parent is known and no live sibling holds the name;
141/// otherwise it is routed to a synthetic `$Orphans` bucket (ADR 0008).
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
143pub enum DeletedMode {
144 /// Newest deleted instance per (parent, name) rendered in-place; older
145 /// same-name instances routed to `$Orphans`. The default.
146 #[default]
147 Latest,
148 /// Every deleted instance rendered under `$Orphans`, disambiguated by
149 /// recovered mtime and record id.
150 All,
151 /// Do not surface deleted files at all.
152 Off,
153}
154
155/// Mount options for the FUSE filesystem.
156///
157/// Platform-agnostic configuration consumed by both the Unix (fuser)
158/// and Windows (Dokan) mount backends.
159pub struct MountOptions {
160 pub read_only: bool,
161 pub daemon: bool,
162 pub fs_name: String,
163 pub layout: MountLayout,
164 /// How the `deleted/` view is populated.
165 pub deleted_mode: DeletedMode,
166}
167
168impl Default for MountOptions {
169 fn default() -> Self {
170 Self {
171 read_only: false,
172 daemon: false,
173 fs_name: "4n6mount".to_string(),
174 layout: MountLayout::DiskOverlay,
175 deleted_mode: DeletedMode::default(),
176 }
177 }
178}
179
180/// A single-owner `Read + Seek` view over a `forensic-vfs` [`DynSource`], the
181/// call-site adapter memf needs (ADR 0011): `memf-format` deliberately does not
182/// depend on `forensic-vfs`, so the positioned-read `ImageSource` edge is bridged
183/// to `memf_format::DumpReader` (`Read + Seek + Send`) here. The `DynSource`
184/// (`Arc<dyn ImageSource>`, `Send + Sync`) makes this `Send`; `memf` reads the
185/// whole source once via `read_to_end`.
186#[cfg(feature = "memory")]
187struct DynSourceDumpReader {
188 src: forensic_vfs::DynSource,
189 pos: u64,
190 len: u64,
191}
192
193#[cfg(feature = "memory")]
194impl DynSourceDumpReader {
195 fn new(src: forensic_vfs::DynSource) -> Self {
196 let len = src.len();
197 Self { src, pos: 0, len }
198 }
199}
200
201#[cfg(feature = "memory")]
202impl io::Read for DynSourceDumpReader {
203 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
204 let n = self
205 .src
206 .read_at(self.pos, buf)
207 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
208 self.pos += n as u64;
209 Ok(n)
210 }
211}
212
213#[cfg(feature = "memory")]
214impl io::Seek for DynSourceDumpReader {
215 fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
216 let new = match pos {
217 io::SeekFrom::Start(o) => Some(o),
218 io::SeekFrom::End(d) => self.len.checked_add_signed(d),
219 io::SeekFrom::Current(d) => self.pos.checked_add_signed(d),
220 };
221 match new {
222 Some(p) => {
223 self.pos = p;
224 Ok(p)
225 }
226 None => Err(io::Error::new(
227 io::ErrorKind::InvalidInput,
228 "seek to a negative or overflowing offset",
229 )),
230 }
231 }
232}
233
234/// Open a (possibly wrapped) memory dump into a physical-memory provider.
235///
236/// The forensic-vfs resolver first peels any archive/compression/container
237/// packaging (`memory.zip`, `memory.dd.gz`, `dump.7z`, nested combinations) down
238/// to the innermost raw byte edge (ADR 0011 `resolve_to_source`); that
239/// [`DynSource`](forensic_vfs::DynSource) is adapted to a `Read + Seek` source and
240/// handed to `memf_format::open_source_with_raw_fallback`, which runs the same
241/// format detection as the path-based `open_dump` but also accepts a headerless
242/// raw dump (the caller has asserted this is a memory dump). A bare, unwrapped
243/// dump peels to itself and takes the identical path.
244///
245/// # Errors
246///
247/// Fails LOUD as `InvalidData` on a resolver read/decode error, a packaging bomb
248/// that exceeds the recursion cap (no terminal), or a memf format/construction
249/// error — never a silently empty provider.
250#[cfg(feature = "memory")]
251pub fn open_memory_provider(
252 image: &Path,
253) -> io::Result<Box<dyn memf_format::PhysicalMemoryProvider>> {
254 use forensic_vfs_resolver::SourceOpen as _;
255 let bad = |msg: String| io::Error::new(io::ErrorKind::InvalidData, msg);
256
257 let base: forensic_vfs::DynSource = std::sync::Arc::new(
258 forensic_vfs::adapters::FileSource::open(image)
259 .map_err(|e| bad(format!("cannot open memory dump {}: {e}", image.display())))?,
260 );
261 let base_spec = forensic_vfs::Locator::file(image);
262
263 // A memory dump's only packaging is an archive or compression wrapper
264 // (memory.zip / .gz / .7z / .tar, nested) — it is a flat page stream, never a
265 // partitioned/encrypted disk, so the disk-container probers (VMDK/VHD/AFF4/…)
266 // do not belong here. That scoping is also correct-by-omission: AFF4 is
267 // ZIP-based and probes `Maybe` on any PK magic, so including it would let it
268 // hard-fail-claim a plain memory.zip before the archive peeler runs.
269 let openers = forensic_vfs::Openers::new().archive(archive_core::ArchiveOpener);
270 let resolved = openers
271 .resolve_to_source(base, base_spec, 0)
272 .map_err(|e| bad(format!("unwrapping memory dump {}: {e}", image.display())))?
273 .ok_or_else(|| {
274 bad(format!(
275 "could not unwrap memory dump {} (packaging recursion cap hit)",
276 image.display()
277 ))
278 })?;
279
280 let reader = DynSourceDumpReader::new(resolved.source);
281 memf_format::open_source_with_raw_fallback(Box::new(reader))
282 .map_err(|e| bad(format!("cannot open memory dump {}: {e}", image.display())))
283}
284
285/// Open a memory dump and build a [`MemoryFs`] over it, bootstrapping the
286/// analysis context (OS, DTB/CR3, kernel list-heads) via `memf-session`.
287///
288/// `symbols` is an optional ISF/PDB path. A header-bearing Windows crash dump
289/// bootstraps with an empty resolver; raw `.mem` and Linux dumps need symbols.
290///
291/// Fails LOUD on a bootstrap failure (bad dump, undetectable OS, missing
292/// symbols) rather than mounting an empty tree — the memory mount is meaningless
293/// without a valid context.
294///
295/// # Errors
296///
297/// Propagates dump-open, symbol-load, and analysis-bootstrap failures as
298/// `InvalidData`.
299#[cfg(feature = "memory")]
300pub fn build_memory_fs(
301 image: &Path,
302 symbols: Option<&Path>,
303) -> io::Result<Box<dyn ForensicFs + Send>> {
304 let bad = |msg: String| io::Error::new(io::ErrorKind::InvalidData, msg);
305
306 let provider = open_memory_provider(image)?;
307
308 // Load symbols if given; otherwise an empty resolver (sufficient for a
309 // crash dump whose header carries CR3 + list-heads).
310 let resolver: Box<dyn memf_symbols::SymbolResolver> = match symbols {
311 Some(p) => Box::new(
312 memf_symbols::isf::IsfResolver::from_path(p)
313 .map_err(|e| bad(format!("cannot load symbols {}: {e}", p.display())))?,
314 ),
315 None => Box::new(
316 memf_symbols::isf::IsfResolver::from_value(&serde_json::json!({}))
317 .map_err(|e| bad(format!("empty symbol resolver: {e}")))?,
318 ),
319 };
320
321 let metadata = provider.metadata();
322 let ctx = memf_session::build_analysis_context(
323 metadata.as_ref(),
324 resolver.as_ref(),
325 provider.as_ref(),
326 )
327 .map_err(|e| bad(format!("memory analysis bootstrap failed: {e}")))?;
328
329 Ok(Box::new(mem::memoryfs::MemoryFs::new(
330 provider, ctx, resolver,
331 )))
332}
333
334/// Mount a forensic filesystem via FUSE (or Dokan on Windows).
335///
336/// This is the main entry point for consumers. Pass a `ForensicFs`
337/// implementation and a `MountOptions`, and this dispatches to the
338/// correct platform backend.
339///
340/// On Unix the mount is handled by `fuser`. On Windows it is handled
341/// by Dokan (the MIT `dokan` crate).
342pub fn mount(
343 fs: Box<dyn ForensicFs + Send>,
344 mountpoint: &Path,
345 session: Option<session::Session>,
346 options: &MountOptions,
347) -> io::Result<()> {
348 #[cfg(unix)]
349 {
350 fuse_unix::mount_unix(fs, mountpoint, session, options)
351 }
352 #[cfg(windows)]
353 {
354 fuse_windows::mount_windows(fs, mountpoint, session, options)
355 }
356 #[cfg(not(any(unix, windows)))]
357 {
358 let _ = (fs, mountpoint, session, options);
359 Err(io::Error::new(
360 io::ErrorKind::Unsupported,
361 "no FUSE support on this platform",
362 ))
363 }
364}
365
366#[cfg(all(test, feature = "memory"))]
367mod memory_tests {
368 use super::*;
369
370 /// `build_memory_fs` bootstraps a synthetic Windows crash dump (header carries
371 /// CR3 + machine type, so no symbols are required) and renders sys/os-info.
372 #[test]
373 fn build_memory_fs_bootstraps_crashdump() {
374 use memf_format::test_builders::CrashDumpBuilder;
375 let bytes = CrashDumpBuilder::new().cr3(0x1a_b000).build();
376
377 let dir = std::env::temp_dir().join(format!("4n6mem_{}", std::process::id()));
378 std::fs::create_dir_all(&dir).unwrap();
379 let path = dir.join("crash.dmp");
380 std::fs::write(&path, &bytes).unwrap();
381
382 let mut fs = build_memory_fs(&path, None).expect("crash dump must bootstrap");
383 // Root is the Raw memory tree: sys/ present, os-info.txt renders the OS.
384 let sys = fs
385 .lookup(mem::inode::ROOT_INO, b"sys")
386 .unwrap()
387 .expect("sys");
388 let oi = fs
389 .lookup(sys, b"os-info.txt")
390 .unwrap()
391 .expect("os-info.txt");
392 let text = String::from_utf8(fs.read_file(oi).unwrap()).unwrap();
393 assert!(text.contains("OS: Windows"), "got: {text}");
394
395 std::fs::remove_dir_all(&dir).ok();
396 }
397
398 /// ADR-0011: a memory dump wrapped in an archive (`.zip`) or nested
399 /// compression (`.zip.gz`) must read back the SAME physical pages as the bare
400 /// raw dump — proving the `resolve_to_source` (peel) -> `DynSource`/`DumpReader`
401 /// adapt -> `open_source_with_raw_fallback` chain in `open_memory_provider`.
402 /// A headerless raw dump (Raw format, probe score 5) also exercises the
403 /// raw-fallback below-threshold path the resolver hands memf.
404 #[test]
405 fn wrapped_memory_dump_reads_same_pages_as_raw() {
406 use memf_format::PhysicalMemoryProvider;
407 use std::io::Write as _;
408
409 // Deterministic headerless raw dump content.
410 let page: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();
411
412 let dir = std::env::temp_dir().join(format!("4n6mem_wrap_{}", std::process::id()));
413 std::fs::create_dir_all(&dir).unwrap();
414
415 // (1) raw baseline
416 let raw_path = dir.join("memory.raw");
417 std::fs::write(&raw_path, &page).unwrap();
418
419 // (2) .zip-wrapped, Stored so archive-core reads the member in place
420 let mut zip_bytes = Vec::new();
421 {
422 let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_bytes));
423 let opts = zip::write::SimpleFileOptions::default()
424 .compression_method(zip::CompressionMethod::Stored);
425 zw.start_file("memory.raw", opts).unwrap();
426 zw.write_all(&page).unwrap();
427 zw.finish().unwrap();
428 }
429 let zip_path = dir.join("memory.zip");
430 std::fs::write(&zip_path, &zip_bytes).unwrap();
431
432 // (3) nested .zip.gz (gzip over the zip bytes)
433 let gz_path = dir.join("memory.zip.gz");
434 {
435 let mut enc = flate2::write::GzEncoder::new(
436 std::fs::File::create(&gz_path).unwrap(),
437 flate2::Compression::default(),
438 );
439 enc.write_all(&zip_bytes).unwrap();
440 enc.finish().unwrap();
441 }
442
443 let read_first_page = |p: &std::path::Path| -> Vec<u8> {
444 let provider = open_memory_provider(p).expect("open wrapped/raw memory provider");
445 assert_eq!(provider.format_name(), "Raw");
446 assert_eq!(provider.total_size(), page.len() as u64);
447 let mut got = vec![0u8; page.len()];
448 let n = provider.read_phys(0, &mut got).unwrap();
449 got.truncate(n);
450 got
451 };
452
453 assert_eq!(read_first_page(&raw_path), page, "raw baseline");
454 assert_eq!(
455 read_first_page(&zip_path),
456 page,
457 ".zip-wrapped dump reads the same pages as raw"
458 );
459 assert_eq!(
460 read_first_page(&gz_path),
461 page,
462 "nested .zip.gz dump reads the same pages as raw"
463 );
464
465 std::fs::remove_dir_all(&dir).ok();
466 }
467}