forensic-mount 0.4.0

Mount forensic disk images, archives, and memory dumps as a filesystem on Linux, macOS, and Windows — ext4/NTFS/exFAT/HFS+/APFS/ISO, EWF/VMDK containers, zip/7z/tar, LiME/AVML/crash dumps
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
#![forbid(unsafe_code)]

pub mod archive_tree;
pub mod detect;
pub mod filter;
#[cfg(unix)]
pub mod fusefs;
pub mod inode_map;
pub mod session;
pub mod types;
pub mod win_map;

#[cfg(unix)]
pub mod fuse_unix;
#[cfg(windows)]
pub mod fuse_windows;

#[cfg(feature = "ext4")]
pub mod fs_ext4;

#[cfg(feature = "iso")]
pub mod fs_iso;

#[cfg(feature = "tarball")]
pub mod fs_tar;

#[cfg(feature = "zip")]
pub mod fs_zip;

#[cfg(feature = "sevenz")]
pub mod fs_sevenz;

#[cfg(feature = "ntfs")]
pub mod fs_ntfs;

#[cfg(feature = "hfsplus")]
pub mod fs_hfsplus;

#[cfg(feature = "exfat")]
pub mod fs_exfat;

#[cfg(feature = "apfs")]
pub mod fs_apfs;

#[cfg(feature = "memory")]
pub mod mem;

pub mod fs_raw;

pub use types::*;

use std::io;
use std::path::Path;

/// How the FUSE mount renders a [`ForensicFs`].
///
/// `DiskOverlay` is the disk-image presentation: the mount root lists the
/// `ro/ rw/ deleted/ …` virtual directories and the filesystem tree lives under
/// `ro/`. `Raw` renders the `ForensicFs` tree directly at the mount root with no
/// overlay — used for read-only memory mounts (and any provider that owns its
/// own top level).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MountLayout {
    /// Disk-image overlay: `ro/`, `rw/`, `deleted/`, … virtual directories.
    #[default]
    DiskOverlay,
    /// The `ForensicFs` tree rendered directly at the root, read-only.
    Raw,
}

/// Mount options for the FUSE filesystem.
///
/// Platform-agnostic configuration consumed by both the Unix (fuser)
/// and Windows (Dokan) mount backends.
pub struct MountOptions {
    pub read_only: bool,
    pub daemon: bool,
    pub fs_name: String,
    pub layout: MountLayout,
}

impl Default for MountOptions {
    fn default() -> Self {
        Self {
            read_only: false,
            daemon: false,
            fs_name: "4n6mount".to_string(),
            layout: MountLayout::DiskOverlay,
        }
    }
}

/// The core trait that filesystem crates implement.
///
/// Provides both standard filesystem access (required methods) and
/// forensic operations (optional, with sensible defaults).
pub trait ForensicFs {
    // --- Core filesystem ops (required) ---

    /// The root directory inode number for this filesystem.
    fn root_ino(&self) -> u64;

    /// List directory entries for the given inode.
    fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>>;

    /// Look up a name in a directory, returning the child inode if found.
    fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>>;

    /// Get file/directory metadata for an inode.
    fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata>;

    /// Read the entire contents of a file.
    fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>>;

    /// Read a range of bytes from a file.
    fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>>;

    /// Read the target of a symbolic link.
    fn read_link(&mut self, ino: u64) -> FsResult<Vec<u8>>;

    // --- Forensic ops (optional) ---

    /// List deleted inodes.
    fn deleted_inodes(&mut self) -> FsResult<Vec<FsDeletedInode>> {
        Ok(vec![])
    }

    /// Attempt to recover a deleted file by inode number.
    fn recover_file(&mut self, _ino: u64) -> FsResult<FsRecoveryResult> {
        Err(not_supported("recover_file"))
    }

    /// Generate a forensic timeline of all filesystem events.
    fn timeline(&mut self) -> FsResult<Vec<FsTimelineEvent>> {
        Ok(vec![])
    }

    /// Get all unallocated block ranges.
    fn unallocated_blocks(&mut self) -> FsResult<Vec<FsBlockRange>> {
        Ok(vec![])
    }

    /// Read raw data from an unallocated block range.
    fn read_unallocated(&mut self, _range: &FsBlockRange) -> FsResult<Vec<u8>> {
        Err(not_supported("read_unallocated"))
    }

    /// List journal transactions.
    fn journal_transactions(&mut self) -> FsResult<Vec<FsTransaction>> {
        Ok(vec![])
    }

    /// Get filesystem-specific info as JSON (superblock, volume label, etc.).
    fn fs_info(&self) -> FsResult<serde_json::Value> {
        Ok(serde_json::Value::Null)
    }

    /// The block size of this filesystem.
    fn block_size(&self) -> u64 {
        4096
    }
}

/// Construct a [`ForensicFs`] from a seekable byte source and a detected type.
///
/// This is the single dispatch point shared by the CLI's outer mount path and
/// its container (EWF/VMDK) inner-filesystem path, so a new format is wired in
/// exactly once. `name` is used only to label the single file of a raw
/// (`FsType::Unknown`) mount.
///
/// Archives (`zip`/`7z`/`tar.gz`) and filesystems (`ext4`/`ntfs`/`exfat`/
/// `hfsplus`/`iso`/`apfs`) all build from the same `Read + Seek` reader.
/// Container types (`Ewf`/`Vmdk`) are opened by the caller, not here.
///
/// # Errors
///
/// Returns the parse error (as `InvalidData`) if the source does not match the
/// claimed type, or `Unsupported` for APFS / a feature that was not compiled in.
pub fn build_filesystem<R: io::Read + io::Seek + Send + 'static>(
    reader: R,
    fs_type: detect::FsType,
    name: &str,
) -> io::Result<Box<dyn ForensicFs + Send>> {
    use detect::FsType;
    let bad = |e: FsError| io::Error::new(io::ErrorKind::InvalidData, e.to_string());
    match fs_type {
        #[cfg(feature = "ext4")]
        FsType::Ext4 => Ok(Box::new(fs_ext4::Ext4ForensicFs::new(reader).map_err(bad)?)),
        #[cfg(feature = "iso")]
        FsType::Iso => Ok(Box::new(fs_iso::IsoForensicFs::new(reader).map_err(bad)?)),
        #[cfg(feature = "ntfs")]
        FsType::Ntfs => Ok(Box::new(fs_ntfs::NtfsForensicFs::new(reader).map_err(bad)?)),
        #[cfg(feature = "hfsplus")]
        FsType::Hfsplus => Ok(Box::new(
            fs_hfsplus::HfsPlusForensicFs::new(reader).map_err(bad)?,
        )),
        #[cfg(feature = "exfat")]
        FsType::ExFat => Ok(Box::new(
            fs_exfat::ExFatForensicFs::new(reader).map_err(bad)?,
        )),
        #[cfg(feature = "tarball")]
        FsType::TarGz => Ok(Box::new(
            fs_tar::TarballForensicFs::from_gz(reader).map_err(bad)?,
        )),
        #[cfg(feature = "tarball")]
        FsType::TarBz2 => Ok(Box::new(
            fs_tar::TarballForensicFs::from_bz2(reader).map_err(bad)?,
        )),
        #[cfg(feature = "zip")]
        FsType::Zip => Ok(Box::new(fs_zip::ZipForensicFs::new(reader).map_err(bad)?)),
        #[cfg(feature = "sevenz")]
        FsType::SevenZ => Ok(Box::new(
            fs_sevenz::SevenZForensicFs::new(reader).map_err(bad)?,
        )),
        FsType::Unknown => Ok(Box::new(
            fs_raw::RawForensicFs::new(reader, name.to_string()).map_err(bad)?,
        )),
        #[cfg(feature = "apfs")]
        FsType::Apfs => Ok(Box::new(fs_apfs::ApfsForensicFs::new(reader).map_err(bad)?)),
        other => Err(io::Error::new(
            io::ErrorKind::Unsupported,
            format!(
                "filesystem '{other}' cannot be built here \
                 (a container type, or its feature was not compiled in)"
            ),
        )),
    }
}

/// Open a memory dump and build a [`MemoryFs`] over it, bootstrapping the
/// analysis context (OS, DTB/CR3, kernel list-heads) via `memf-session`.
///
/// `symbols` is an optional ISF/PDB path. A header-bearing Windows crash dump
/// bootstraps with an empty resolver; raw `.mem` and Linux dumps need symbols.
///
/// Fails LOUD on a bootstrap failure (bad dump, undetectable OS, missing
/// symbols) rather than mounting an empty tree — the memory mount is meaningless
/// without a valid context.
///
/// # Errors
///
/// Propagates dump-open, symbol-load, and analysis-bootstrap failures as
/// `InvalidData`.
#[cfg(feature = "memory")]
pub fn build_memory_fs(
    image: &Path,
    symbols: Option<&Path>,
) -> io::Result<Box<dyn ForensicFs + Send>> {
    let bad = |msg: String| io::Error::new(io::ErrorKind::InvalidData, msg);

    let provider = memf_format::open_dump(image)
        .map_err(|e| bad(format!("cannot open memory dump {}: {e}", image.display())))?;

    // Load symbols if given; otherwise an empty resolver (sufficient for a
    // crash dump whose header carries CR3 + list-heads).
    let resolver: Box<dyn memf_symbols::SymbolResolver> = match symbols {
        Some(p) => Box::new(
            memf_symbols::isf::IsfResolver::from_path(p)
                .map_err(|e| bad(format!("cannot load symbols {}: {e}", p.display())))?,
        ),
        None => Box::new(
            memf_symbols::isf::IsfResolver::from_value(&serde_json::json!({}))
                .map_err(|e| bad(format!("empty symbol resolver: {e}")))?,
        ),
    };

    let metadata = provider.metadata();
    let ctx = memf_session::build_analysis_context(
        metadata.as_ref(),
        resolver.as_ref(),
        provider.as_ref(),
    )
    .map_err(|e| bad(format!("memory analysis bootstrap failed: {e}")))?;

    Ok(Box::new(mem::memoryfs::MemoryFs::new(
        provider, ctx, resolver,
    )))
}

/// Mount a forensic filesystem via FUSE (or Dokan on Windows).
///
/// This is the main entry point for consumers.  Pass a `ForensicFs`
/// implementation and a `MountOptions`, and this dispatches to the
/// correct platform backend.
///
/// On Unix the mount is handled by `fuser`.  On Windows it is handled
/// by Dokan (the MIT `dokan` crate).
pub fn mount(
    fs: Box<dyn ForensicFs + Send>,
    mountpoint: &Path,
    session: Option<session::Session>,
    options: &MountOptions,
) -> io::Result<()> {
    #[cfg(unix)]
    {
        fuse_unix::mount_unix(fs, mountpoint, session, options)
    }
    #[cfg(windows)]
    {
        fuse_windows::mount_windows(fs, mountpoint, session, options)
    }
    #[cfg(not(any(unix, windows)))]
    {
        let _ = (fs, mountpoint, session, options);
        Err(io::Error::new(
            io::ErrorKind::Unsupported,
            "no FUSE support on this platform",
        ))
    }
}

#[cfg(test)]
mod dispatch_tests {
    use super::*;
    use std::io::Cursor;

    #[test]
    fn apfs_garbage_errors_loud_not_silent() {
        // A non-APFS source must fail loud (InvalidData), never silently mount empty.
        match build_filesystem(Cursor::new(vec![0u8; 64]), detect::FsType::Apfs, "x") {
            Err(e) => assert_eq!(e.kind(), io::ErrorKind::InvalidData),
            Ok(_) => panic!("garbage must error, not mount"),
        }
    }

    #[cfg(feature = "apfs")]
    #[test]
    fn apfs_dispatches_to_module() {
        let img = "/Users/4n6h4x0r/src/apfs-forensic/tests/data/apfs_fstree.bin";
        let Ok(data) = std::fs::read(img) else {
            eprintln!("skip: apfs_fstree.bin unavailable");
            return;
        };
        let fs = build_filesystem(Cursor::new(data), detect::FsType::Apfs, "x").unwrap();
        assert_eq!(fs.fs_info().unwrap()["type"], "apfs");
    }

    #[test]
    fn unknown_builds_raw() {
        let fs = build_filesystem(
            Cursor::new(b"hello".to_vec()),
            detect::FsType::Unknown,
            "evidence.bin",
        )
        .unwrap();
        assert_eq!(fs.fs_info().unwrap()["filesystem"], "raw");
    }

    #[cfg(feature = "hfsplus")]
    #[test]
    fn hfsplus_dispatches_to_module() {
        let img = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/hfsplus.img");
        let Ok(data) = std::fs::read(img) else {
            eprintln!("skip: hfsplus.img unavailable");
            return;
        };
        let fs = build_filesystem(Cursor::new(data), detect::FsType::Hfsplus, "x").unwrap();
        assert_eq!(fs.fs_info().unwrap()["type"], "hfsplus");
    }

    #[cfg(feature = "exfat")]
    #[test]
    fn exfat_dispatches_to_module() {
        let img = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/exfat.img");
        let Ok(data) = std::fs::read(img) else {
            eprintln!("skip: exfat.img unavailable");
            return;
        };
        let fs = build_filesystem(Cursor::new(data), detect::FsType::ExFat, "x").unwrap();
        assert_eq!(fs.fs_info().unwrap()["type"], "exfat");
    }
}

#[cfg(all(test, feature = "memory"))]
mod memory_tests {
    use super::*;

    /// build_memory_fs bootstraps a synthetic Windows crash dump (header carries
    /// CR3 + machine type, so no symbols are required) and renders sys/os-info.
    #[test]
    fn build_memory_fs_bootstraps_crashdump() {
        use memf_format::test_builders::CrashDumpBuilder;
        let bytes = CrashDumpBuilder::new().cr3(0x1ab000).build();

        let dir = std::env::temp_dir().join(format!("4n6mem_{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("crash.dmp");
        std::fs::write(&path, &bytes).unwrap();

        let mut fs = build_memory_fs(&path, None).expect("crash dump must bootstrap");
        // Root is the Raw memory tree: sys/ present, os-info.txt renders the OS.
        let sys = fs
            .lookup(mem::inode::ROOT_INO, b"sys")
            .unwrap()
            .expect("sys");
        let oi = fs
            .lookup(sys, b"os-info.txt")
            .unwrap()
            .expect("os-info.txt");
        let text = String::from_utf8(fs.read_file(oi).unwrap()).unwrap();
        assert!(text.contains("OS: Windows"), "got: {text}");

        std::fs::remove_dir_all(&dir).ok();
    }
}