Skip to main content

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}