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