Skip to main content

forensic_mount/
lib.rs

1#![forbid(unsafe_code)]
2
3pub mod archive_tree;
4pub mod detect;
5pub mod filter;
6#[cfg(unix)]
7pub mod fusefs;
8pub mod inode_map;
9pub mod session;
10pub mod types;
11
12#[cfg(unix)]
13pub mod fuse_unix;
14#[cfg(windows)]
15pub mod fuse_windows;
16
17#[cfg(feature = "ext4")]
18pub mod fs_ext4;
19
20#[cfg(feature = "iso")]
21pub mod fs_iso;
22
23#[cfg(feature = "tarball")]
24pub mod fs_tar;
25
26#[cfg(feature = "zip")]
27pub mod fs_zip;
28
29#[cfg(feature = "sevenz")]
30pub mod fs_sevenz;
31
32#[cfg(feature = "ntfs")]
33pub mod fs_ntfs;
34
35#[cfg(feature = "hfsplus")]
36pub mod fs_hfsplus;
37
38#[cfg(feature = "exfat")]
39pub mod fs_exfat;
40
41#[cfg(feature = "apfs")]
42pub mod fs_apfs;
43
44#[cfg(feature = "memory")]
45pub mod mem;
46
47pub mod fs_raw;
48
49pub use types::*;
50
51use std::io;
52use std::path::Path;
53
54/// How the FUSE mount renders a [`ForensicFs`].
55///
56/// `DiskOverlay` is the disk-image presentation: the mount root lists the
57/// `ro/ rw/ deleted/ …` virtual directories and the filesystem tree lives under
58/// `ro/`. `Raw` renders the `ForensicFs` tree directly at the mount root with no
59/// overlay — used for read-only memory mounts (and any provider that owns its
60/// own top level).
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62pub enum MountLayout {
63    /// Disk-image overlay: `ro/`, `rw/`, `deleted/`, … virtual directories.
64    #[default]
65    DiskOverlay,
66    /// The `ForensicFs` tree rendered directly at the root, read-only.
67    Raw,
68}
69
70/// Mount options for the FUSE filesystem.
71///
72/// Platform-agnostic configuration consumed by both the Unix (fuser)
73/// and Windows (`WinFSP`) mount backends.
74pub struct MountOptions {
75    pub read_only: bool,
76    pub daemon: bool,
77    pub fs_name: String,
78    pub layout: MountLayout,
79}
80
81impl Default for MountOptions {
82    fn default() -> Self {
83        Self {
84            read_only: false,
85            daemon: false,
86            fs_name: "4n6mount".to_string(),
87            layout: MountLayout::DiskOverlay,
88        }
89    }
90}
91
92/// The core trait that filesystem crates implement.
93///
94/// Provides both standard filesystem access (required methods) and
95/// forensic operations (optional, with sensible defaults).
96pub trait ForensicFs {
97    // --- Core filesystem ops (required) ---
98
99    /// The root directory inode number for this filesystem.
100    fn root_ino(&self) -> u64;
101
102    /// List directory entries for the given inode.
103    fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>>;
104
105    /// Look up a name in a directory, returning the child inode if found.
106    fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>>;
107
108    /// Get file/directory metadata for an inode.
109    fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata>;
110
111    /// Read the entire contents of a file.
112    fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>>;
113
114    /// Read a range of bytes from a file.
115    fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>>;
116
117    /// Read the target of a symbolic link.
118    fn read_link(&mut self, ino: u64) -> FsResult<Vec<u8>>;
119
120    // --- Forensic ops (optional) ---
121
122    /// List deleted inodes.
123    fn deleted_inodes(&mut self) -> FsResult<Vec<FsDeletedInode>> {
124        Ok(vec![])
125    }
126
127    /// Attempt to recover a deleted file by inode number.
128    fn recover_file(&mut self, _ino: u64) -> FsResult<FsRecoveryResult> {
129        Err(not_supported("recover_file"))
130    }
131
132    /// Generate a forensic timeline of all filesystem events.
133    fn timeline(&mut self) -> FsResult<Vec<FsTimelineEvent>> {
134        Ok(vec![])
135    }
136
137    /// Get all unallocated block ranges.
138    fn unallocated_blocks(&mut self) -> FsResult<Vec<FsBlockRange>> {
139        Ok(vec![])
140    }
141
142    /// Read raw data from an unallocated block range.
143    fn read_unallocated(&mut self, _range: &FsBlockRange) -> FsResult<Vec<u8>> {
144        Err(not_supported("read_unallocated"))
145    }
146
147    /// List journal transactions.
148    fn journal_transactions(&mut self) -> FsResult<Vec<FsTransaction>> {
149        Ok(vec![])
150    }
151
152    /// Get filesystem-specific info as JSON (superblock, volume label, etc.).
153    fn fs_info(&self) -> FsResult<serde_json::Value> {
154        Ok(serde_json::Value::Null)
155    }
156
157    /// The block size of this filesystem.
158    fn block_size(&self) -> u64 {
159        4096
160    }
161}
162
163/// Construct a [`ForensicFs`] from a seekable byte source and a detected type.
164///
165/// This is the single dispatch point shared by the CLI's outer mount path and
166/// its container (EWF/VMDK) inner-filesystem path, so a new format is wired in
167/// exactly once. `name` is used only to label the single file of a raw
168/// (`FsType::Unknown`) mount.
169///
170/// Archives (`zip`/`7z`/`tar.gz`) and filesystems (`ext4`/`ntfs`/`exfat`/
171/// `hfsplus`/`iso`/`apfs`) all build from the same `Read + Seek` reader.
172/// Container types (`Ewf`/`Vmdk`) are opened by the caller, not here.
173///
174/// # Errors
175///
176/// Returns the parse error (as `InvalidData`) if the source does not match the
177/// claimed type, or `Unsupported` for APFS / a feature that was not compiled in.
178pub fn build_filesystem<R: io::Read + io::Seek + Send + 'static>(
179    reader: R,
180    fs_type: detect::FsType,
181    name: &str,
182) -> io::Result<Box<dyn ForensicFs + Send>> {
183    use detect::FsType;
184    let bad = |e: FsError| io::Error::new(io::ErrorKind::InvalidData, e.to_string());
185    match fs_type {
186        #[cfg(feature = "ext4")]
187        FsType::Ext4 => Ok(Box::new(fs_ext4::Ext4ForensicFs::new(reader).map_err(bad)?)),
188        #[cfg(feature = "iso")]
189        FsType::Iso => Ok(Box::new(fs_iso::IsoForensicFs::new(reader).map_err(bad)?)),
190        #[cfg(feature = "ntfs")]
191        FsType::Ntfs => Ok(Box::new(fs_ntfs::NtfsForensicFs::new(reader).map_err(bad)?)),
192        #[cfg(feature = "hfsplus")]
193        FsType::Hfsplus => Ok(Box::new(
194            fs_hfsplus::HfsPlusForensicFs::new(reader).map_err(bad)?,
195        )),
196        #[cfg(feature = "exfat")]
197        FsType::ExFat => Ok(Box::new(
198            fs_exfat::ExFatForensicFs::new(reader).map_err(bad)?,
199        )),
200        #[cfg(feature = "tarball")]
201        FsType::TarGz => Ok(Box::new(
202            fs_tar::TarballForensicFs::from_gz(reader).map_err(bad)?,
203        )),
204        #[cfg(feature = "tarball")]
205        FsType::TarBz2 => Ok(Box::new(
206            fs_tar::TarballForensicFs::from_bz2(reader).map_err(bad)?,
207        )),
208        #[cfg(feature = "zip")]
209        FsType::Zip => Ok(Box::new(fs_zip::ZipForensicFs::new(reader).map_err(bad)?)),
210        #[cfg(feature = "sevenz")]
211        FsType::SevenZ => Ok(Box::new(
212            fs_sevenz::SevenZForensicFs::new(reader).map_err(bad)?,
213        )),
214        FsType::Unknown => Ok(Box::new(
215            fs_raw::RawForensicFs::new(reader, name.to_string()).map_err(bad)?,
216        )),
217        #[cfg(feature = "apfs")]
218        FsType::Apfs => Ok(Box::new(fs_apfs::ApfsForensicFs::new(reader).map_err(bad)?)),
219        other => Err(io::Error::new(
220            io::ErrorKind::Unsupported,
221            format!(
222                "filesystem '{other}' cannot be built here \
223                 (a container type, or its feature was not compiled in)"
224            ),
225        )),
226    }
227}
228
229/// Open a memory dump and build a [`MemoryFs`] over it, bootstrapping the
230/// analysis context (OS, DTB/CR3, kernel list-heads) via `memf-session`.
231///
232/// `symbols` is an optional ISF/PDB path. A header-bearing Windows crash dump
233/// bootstraps with an empty resolver; raw `.mem` and Linux dumps need symbols.
234///
235/// Fails LOUD on a bootstrap failure (bad dump, undetectable OS, missing
236/// symbols) rather than mounting an empty tree — the memory mount is meaningless
237/// without a valid context.
238///
239/// # Errors
240///
241/// Propagates dump-open, symbol-load, and analysis-bootstrap failures as
242/// `InvalidData`.
243#[cfg(feature = "memory")]
244pub fn build_memory_fs(
245    image: &Path,
246    symbols: Option<&Path>,
247) -> io::Result<Box<dyn ForensicFs + Send>> {
248    let bad = |msg: String| io::Error::new(io::ErrorKind::InvalidData, msg);
249
250    let provider = memf_format::open_dump(image)
251        .map_err(|e| bad(format!("cannot open memory dump {}: {e}", image.display())))?;
252
253    // Load symbols if given; otherwise an empty resolver (sufficient for a
254    // crash dump whose header carries CR3 + list-heads).
255    let resolver: Box<dyn memf_symbols::SymbolResolver> = match symbols {
256        Some(p) => Box::new(
257            memf_symbols::isf::IsfResolver::from_path(p)
258                .map_err(|e| bad(format!("cannot load symbols {}: {e}", p.display())))?,
259        ),
260        None => Box::new(
261            memf_symbols::isf::IsfResolver::from_value(&serde_json::json!({}))
262                .map_err(|e| bad(format!("empty symbol resolver: {e}")))?,
263        ),
264    };
265
266    let metadata = provider.metadata();
267    let ctx = memf_session::build_analysis_context(
268        metadata.as_ref(),
269        resolver.as_ref(),
270        provider.as_ref(),
271    )
272    .map_err(|e| bad(format!("memory analysis bootstrap failed: {e}")))?;
273
274    Ok(Box::new(mem::memoryfs::MemoryFs::new(
275        provider, ctx, resolver,
276    )))
277}
278
279/// Mount a forensic filesystem via FUSE (or `WinFSP` on Windows).
280///
281/// This is the main entry point for consumers.  Pass a `ForensicFs`
282/// implementation and a `MountOptions`, and this dispatches to the
283/// correct platform backend.
284///
285/// On Unix the mount is handled by `fuser`.  On Windows it will be
286/// handled by `winfsp-wrs` (currently a stub that returns
287/// `Unsupported`).
288pub fn mount(
289    fs: Box<dyn ForensicFs + Send>,
290    mountpoint: &Path,
291    session: Option<session::Session>,
292    options: &MountOptions,
293) -> io::Result<()> {
294    #[cfg(unix)]
295    {
296        fuse_unix::mount_unix(fs, mountpoint, session, options)
297    }
298    #[cfg(windows)]
299    {
300        fuse_windows::mount_windows(fs, mountpoint, session, options)
301    }
302    #[cfg(not(any(unix, windows)))]
303    {
304        let _ = (fs, mountpoint, session, options);
305        Err(io::Error::new(
306            io::ErrorKind::Unsupported,
307            "no FUSE support on this platform",
308        ))
309    }
310}
311
312#[cfg(test)]
313mod dispatch_tests {
314    use super::*;
315    use std::io::Cursor;
316
317    #[test]
318    fn apfs_garbage_errors_loud_not_silent() {
319        // A non-APFS source must fail loud (InvalidData), never silently mount empty.
320        match build_filesystem(Cursor::new(vec![0u8; 64]), detect::FsType::Apfs, "x") {
321            Err(e) => assert_eq!(e.kind(), io::ErrorKind::InvalidData),
322            Ok(_) => panic!("garbage must error, not mount"),
323        }
324    }
325
326    #[cfg(feature = "apfs")]
327    #[test]
328    fn apfs_dispatches_to_module() {
329        let img = "/Users/4n6h4x0r/src/apfs-forensic/tests/data/apfs_fstree.bin";
330        let Ok(data) = std::fs::read(img) else {
331            eprintln!("skip: apfs_fstree.bin unavailable");
332            return;
333        };
334        let fs = build_filesystem(Cursor::new(data), detect::FsType::Apfs, "x").unwrap();
335        assert_eq!(fs.fs_info().unwrap()["type"], "apfs");
336    }
337
338    #[test]
339    fn unknown_builds_raw() {
340        let fs = build_filesystem(
341            Cursor::new(b"hello".to_vec()),
342            detect::FsType::Unknown,
343            "evidence.bin",
344        )
345        .unwrap();
346        assert_eq!(fs.fs_info().unwrap()["filesystem"], "raw");
347    }
348
349    #[cfg(feature = "hfsplus")]
350    #[test]
351    fn hfsplus_dispatches_to_module() {
352        let img = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/hfsplus.img");
353        let Ok(data) = std::fs::read(img) else {
354            eprintln!("skip: hfsplus.img unavailable");
355            return;
356        };
357        let fs = build_filesystem(Cursor::new(data), detect::FsType::Hfsplus, "x").unwrap();
358        assert_eq!(fs.fs_info().unwrap()["type"], "hfsplus");
359    }
360
361    #[cfg(feature = "exfat")]
362    #[test]
363    fn exfat_dispatches_to_module() {
364        let img = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/exfat.img");
365        let Ok(data) = std::fs::read(img) else {
366            eprintln!("skip: exfat.img unavailable");
367            return;
368        };
369        let fs = build_filesystem(Cursor::new(data), detect::FsType::ExFat, "x").unwrap();
370        assert_eq!(fs.fs_info().unwrap()["type"], "exfat");
371    }
372}
373
374#[cfg(all(test, feature = "memory"))]
375mod memory_tests {
376    use super::*;
377
378    /// build_memory_fs bootstraps a synthetic Windows crash dump (header carries
379    /// CR3 + machine type, so no symbols are required) and renders sys/os-info.
380    #[test]
381    fn build_memory_fs_bootstraps_crashdump() {
382        use memf_format::test_builders::CrashDumpBuilder;
383        let bytes = CrashDumpBuilder::new().cr3(0x1ab000).build();
384
385        let dir = std::env::temp_dir().join(format!("4n6mem_{}", std::process::id()));
386        std::fs::create_dir_all(&dir).unwrap();
387        let path = dir.join("crash.dmp");
388        std::fs::write(&path, &bytes).unwrap();
389
390        let mut fs = build_memory_fs(&path, None).expect("crash dump must bootstrap");
391        // Root is the Raw memory tree: sys/ present, os-info.txt renders the OS.
392        let sys = fs
393            .lookup(mem::inode::ROOT_INO, b"sys")
394            .unwrap()
395            .expect("sys");
396        let oi = fs
397            .lookup(sys, b"os-info.txt")
398            .unwrap()
399            .expect("os-info.txt");
400        let text = String::from_utf8(fs.read_file(oi).unwrap()).unwrap();
401        assert!(text.contains("OS: Windows"), "got: {text}");
402
403        std::fs::remove_dir_all(&dir).ok();
404    }
405}