Skip to main content

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}