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 = "ntfs")]
34pub mod fs_ntfs;
35
36#[cfg(feature = "hfsplus")]
37pub mod fs_hfsplus;
38
39#[cfg(feature = "exfat")]
40pub mod fs_exfat;
41
42#[cfg(feature = "apfs")]
43pub mod fs_apfs;
44
45#[cfg(feature = "memory")]
46pub mod mem;
47
48pub mod fs_raw;
49
50pub use types::*;
51
52use std::io;
53use std::path::Path;
54
55/// How the FUSE mount renders a [`ForensicFs`].
56///
57/// `DiskOverlay` is the disk-image presentation: the mount root lists the
58/// `ro/ rw/ deleted/ …` virtual directories and the filesystem tree lives under
59/// `ro/`. `Raw` renders the `ForensicFs` tree directly at the mount root with no
60/// overlay — used for read-only memory mounts (and any provider that owns its
61/// own top level).
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum MountLayout {
64    /// Disk-image overlay: `ro/`, `rw/`, `deleted/`, … virtual directories.
65    #[default]
66    DiskOverlay,
67    /// The `ForensicFs` tree rendered directly at the root, read-only.
68    Raw,
69}
70
71/// Mount options for the FUSE filesystem.
72///
73/// Platform-agnostic configuration consumed by both the Unix (fuser)
74/// and Windows (Dokan) mount backends.
75pub struct MountOptions {
76    pub read_only: bool,
77    pub daemon: bool,
78    pub fs_name: String,
79    pub layout: MountLayout,
80}
81
82impl Default for MountOptions {
83    fn default() -> Self {
84        Self {
85            read_only: false,
86            daemon: false,
87            fs_name: "4n6mount".to_string(),
88            layout: MountLayout::DiskOverlay,
89        }
90    }
91}
92
93/// The core trait that filesystem crates implement.
94///
95/// Provides both standard filesystem access (required methods) and
96/// forensic operations (optional, with sensible defaults).
97pub trait ForensicFs {
98    // --- Core filesystem ops (required) ---
99
100    /// The root directory inode number for this filesystem.
101    fn root_ino(&self) -> u64;
102
103    /// List directory entries for the given inode.
104    fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>>;
105
106    /// Look up a name in a directory, returning the child inode if found.
107    fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>>;
108
109    /// Get file/directory metadata for an inode.
110    fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata>;
111
112    /// Read the entire contents of a file.
113    fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>>;
114
115    /// Read a range of bytes from a file.
116    fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>>;
117
118    /// Read the target of a symbolic link.
119    fn read_link(&mut self, ino: u64) -> FsResult<Vec<u8>>;
120
121    // --- Forensic ops (optional) ---
122
123    /// List deleted inodes.
124    fn deleted_inodes(&mut self) -> FsResult<Vec<FsDeletedInode>> {
125        Ok(vec![])
126    }
127
128    /// Attempt to recover a deleted file by inode number.
129    fn recover_file(&mut self, _ino: u64) -> FsResult<FsRecoveryResult> {
130        Err(not_supported("recover_file"))
131    }
132
133    /// Generate a forensic timeline of all filesystem events.
134    fn timeline(&mut self) -> FsResult<Vec<FsTimelineEvent>> {
135        Ok(vec![])
136    }
137
138    /// Get all unallocated block ranges.
139    fn unallocated_blocks(&mut self) -> FsResult<Vec<FsBlockRange>> {
140        Ok(vec![])
141    }
142
143    /// Read raw data from an unallocated block range.
144    fn read_unallocated(&mut self, _range: &FsBlockRange) -> FsResult<Vec<u8>> {
145        Err(not_supported("read_unallocated"))
146    }
147
148    /// List journal transactions.
149    fn journal_transactions(&mut self) -> FsResult<Vec<FsTransaction>> {
150        Ok(vec![])
151    }
152
153    /// Get filesystem-specific info as JSON (superblock, volume label, etc.).
154    fn fs_info(&self) -> FsResult<serde_json::Value> {
155        Ok(serde_json::Value::Null)
156    }
157
158    /// The block size of this filesystem.
159    fn block_size(&self) -> u64 {
160        4096
161    }
162}
163
164/// Construct a [`ForensicFs`] from a seekable byte source and a detected type.
165///
166/// This is the single dispatch point shared by the CLI's outer mount path and
167/// its container (EWF/VMDK) inner-filesystem path, so a new format is wired in
168/// exactly once. `name` is used only to label the single file of a raw
169/// (`FsType::Unknown`) mount.
170///
171/// Archives (`zip`/`7z`/`tar.gz`) and filesystems (`ext4`/`ntfs`/`exfat`/
172/// `hfsplus`/`iso`/`apfs`) all build from the same `Read + Seek` reader.
173/// Container types (`Ewf`/`Vmdk`) are opened by the caller, not here.
174///
175/// # Errors
176///
177/// Returns the parse error (as `InvalidData`) if the source does not match the
178/// claimed type, or `Unsupported` for APFS / a feature that was not compiled in.
179pub fn build_filesystem<R: io::Read + io::Seek + Send + 'static>(
180    reader: R,
181    fs_type: detect::FsType,
182    name: &str,
183) -> io::Result<Box<dyn ForensicFs + Send>> {
184    use detect::FsType;
185    let bad = |e: FsError| io::Error::new(io::ErrorKind::InvalidData, e.to_string());
186    match fs_type {
187        #[cfg(feature = "ext4")]
188        FsType::Ext4 => Ok(Box::new(fs_ext4::Ext4ForensicFs::new(reader).map_err(bad)?)),
189        #[cfg(feature = "iso")]
190        FsType::Iso => Ok(Box::new(fs_iso::IsoForensicFs::new(reader).map_err(bad)?)),
191        #[cfg(feature = "ntfs")]
192        FsType::Ntfs => Ok(Box::new(fs_ntfs::NtfsForensicFs::new(reader).map_err(bad)?)),
193        #[cfg(feature = "hfsplus")]
194        FsType::Hfsplus => Ok(Box::new(
195            fs_hfsplus::HfsPlusForensicFs::new(reader).map_err(bad)?,
196        )),
197        #[cfg(feature = "exfat")]
198        FsType::ExFat => Ok(Box::new(
199            fs_exfat::ExFatForensicFs::new(reader).map_err(bad)?,
200        )),
201        #[cfg(feature = "tarball")]
202        FsType::TarGz => Ok(Box::new(
203            fs_tar::TarballForensicFs::from_gz(reader).map_err(bad)?,
204        )),
205        #[cfg(feature = "tarball")]
206        FsType::TarBz2 => Ok(Box::new(
207            fs_tar::TarballForensicFs::from_bz2(reader).map_err(bad)?,
208        )),
209        #[cfg(feature = "zip")]
210        FsType::Zip => Ok(Box::new(fs_zip::ZipForensicFs::new(reader).map_err(bad)?)),
211        #[cfg(feature = "sevenz")]
212        FsType::SevenZ => Ok(Box::new(
213            fs_sevenz::SevenZForensicFs::new(reader).map_err(bad)?,
214        )),
215        FsType::Unknown => Ok(Box::new(
216            fs_raw::RawForensicFs::new(reader, name.to_string()).map_err(bad)?,
217        )),
218        #[cfg(feature = "apfs")]
219        FsType::Apfs => Ok(Box::new(fs_apfs::ApfsForensicFs::new(reader).map_err(bad)?)),
220        other => Err(io::Error::new(
221            io::ErrorKind::Unsupported,
222            format!(
223                "filesystem '{other}' cannot be built here \
224                 (a container type, or its feature was not compiled in)"
225            ),
226        )),
227    }
228}
229
230/// Open a memory dump and build a [`MemoryFs`] over it, bootstrapping the
231/// analysis context (OS, DTB/CR3, kernel list-heads) via `memf-session`.
232///
233/// `symbols` is an optional ISF/PDB path. A header-bearing Windows crash dump
234/// bootstraps with an empty resolver; raw `.mem` and Linux dumps need symbols.
235///
236/// Fails LOUD on a bootstrap failure (bad dump, undetectable OS, missing
237/// symbols) rather than mounting an empty tree — the memory mount is meaningless
238/// without a valid context.
239///
240/// # Errors
241///
242/// Propagates dump-open, symbol-load, and analysis-bootstrap failures as
243/// `InvalidData`.
244#[cfg(feature = "memory")]
245pub fn build_memory_fs(
246    image: &Path,
247    symbols: Option<&Path>,
248) -> io::Result<Box<dyn ForensicFs + Send>> {
249    let bad = |msg: String| io::Error::new(io::ErrorKind::InvalidData, msg);
250
251    let provider = memf_format::open_dump(image)
252        .map_err(|e| bad(format!("cannot open memory dump {}: {e}", image.display())))?;
253
254    // Load symbols if given; otherwise an empty resolver (sufficient for a
255    // crash dump whose header carries CR3 + list-heads).
256    let resolver: Box<dyn memf_symbols::SymbolResolver> = match symbols {
257        Some(p) => Box::new(
258            memf_symbols::isf::IsfResolver::from_path(p)
259                .map_err(|e| bad(format!("cannot load symbols {}: {e}", p.display())))?,
260        ),
261        None => Box::new(
262            memf_symbols::isf::IsfResolver::from_value(&serde_json::json!({}))
263                .map_err(|e| bad(format!("empty symbol resolver: {e}")))?,
264        ),
265    };
266
267    let metadata = provider.metadata();
268    let ctx = memf_session::build_analysis_context(
269        metadata.as_ref(),
270        resolver.as_ref(),
271        provider.as_ref(),
272    )
273    .map_err(|e| bad(format!("memory analysis bootstrap failed: {e}")))?;
274
275    Ok(Box::new(mem::memoryfs::MemoryFs::new(
276        provider, ctx, resolver,
277    )))
278}
279
280/// Mount a forensic filesystem via FUSE (or Dokan on Windows).
281///
282/// This is the main entry point for consumers.  Pass a `ForensicFs`
283/// implementation and a `MountOptions`, and this dispatches to the
284/// correct platform backend.
285///
286/// On Unix the mount is handled by `fuser`.  On Windows it is handled
287/// by Dokan (the MIT `dokan` crate).
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}