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