Skip to main content

forensic_mount/
lib.rs

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