forensic-mount 0.6.2

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
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
#![forbid(unsafe_code)]
// Tests assert on known-good fixtures, where a panic on an unexpected value is
// the intended failure mode. Production code stays under the crate's
// `unwrap_used`/`expect_used` denies.
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]

pub mod detect;
pub mod filter;
#[cfg(unix)]
pub mod fusefs;
pub mod inode_map;
// The recovered-deleted marking schema (ADR-0008 v2): the single, platform-
// agnostic source of truth for the values the Unix xattr channel and the
// Windows NTFS-ADS channel both render.
pub mod marking;
pub mod session;
pub mod win_map;

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

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

// 4n6mount owns its FUSE-facing filesystem contract: the `ForensicFs` trait and
// its `Fs*` value types (implemented by both the memory VFS and the disk-image
// `EngineFs` adapter over `forensic-vfs`). The engine now speaks a different,
// inode-enum `FileSystem` trait; `EngineFs` bridges it into this one.
pub mod engine_fs;
pub mod types;

pub use engine_fs::{open_image, open_image_all, EngineFs, MultiPartitionFs};
pub use types::*;

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

/// One mounted, browsable forensic filesystem, in 4n6mount's own `u64`-inode
/// vocabulary. A backend (the memory VFS, or [`EngineFs`] over a disk image)
/// converts its native model into these calls; the FUSE/Dokan mount layer
/// consumes them directly.
///
/// The core navigation ops are required. The forensic ops have default impls so
/// a backend that cannot honor one degrades cleanly (an empty list, or a loud
/// `NotSupported` for the byte-producing ones).
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![])
    }

    /// List deleted/orphan nodes with recovered identity — a readable inode,
    /// the recovered name, parent inode, record id, and MACB times — so the
    /// mount can render each in place (or route it to `$Orphans`) and read its
    /// bytes via [`read_file`](Self::read_file). Default empty: a backend opts
    /// in once it can recover the rich identity (e.g. NTFS `$FILE_NAME` + the
    /// MFT reference). It never fabricates an entry.
    fn deleted_nodes(&mut self) -> FsResult<Vec<FsDeletedNode>> {
        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
    }
}

/// 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,
}

/// How the `deleted/` view surfaces recovered deleted files.
///
/// A deleted file is placed **in-place** (under its recovered parent, at its
/// real name) when its parent is known and no live sibling holds the name;
/// otherwise it is routed to a synthetic `$Orphans` bucket (ADR 0008).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
pub enum DeletedMode {
    /// Newest deleted instance per (parent, name) rendered in-place; older
    /// same-name instances routed to `$Orphans`. The default.
    #[default]
    Latest,
    /// Every deleted instance rendered under `$Orphans`, disambiguated by
    /// recovered mtime and record id.
    All,
    /// Do not surface deleted files at all.
    Off,
}

/// 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,
    /// How the `deleted/` view is populated.
    pub deleted_mode: DeletedMode,
}

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

/// A single-owner `Read + Seek` view over a `forensic-vfs` [`DynSource`], the
/// call-site adapter memf needs (ADR 0011): `memf-format` deliberately does not
/// depend on `forensic-vfs`, so the positioned-read `ImageSource` edge is bridged
/// to `memf_format::DumpReader` (`Read + Seek + Send`) here. The `DynSource`
/// (`Arc<dyn ImageSource>`, `Send + Sync`) makes this `Send`; `memf` reads the
/// whole source once via `read_to_end`.
#[cfg(feature = "memory")]
struct DynSourceDumpReader {
    src: forensic_vfs::DynSource,
    pos: u64,
    len: u64,
}

#[cfg(feature = "memory")]
impl DynSourceDumpReader {
    fn new(src: forensic_vfs::DynSource) -> Self {
        let len = src.len();
        Self { src, pos: 0, len }
    }
}

#[cfg(feature = "memory")]
impl io::Read for DynSourceDumpReader {
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
        let n = self
            .src
            .read_at(self.pos, buf)
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
        self.pos += n as u64;
        Ok(n)
    }
}

#[cfg(feature = "memory")]
impl io::Seek for DynSourceDumpReader {
    fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
        let new = match pos {
            io::SeekFrom::Start(o) => Some(o),
            io::SeekFrom::End(d) => self.len.checked_add_signed(d),
            io::SeekFrom::Current(d) => self.pos.checked_add_signed(d),
        };
        match new {
            Some(p) => {
                self.pos = p;
                Ok(p)
            }
            None => Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "seek to a negative or overflowing offset",
            )),
        }
    }
}

/// Open a (possibly wrapped) memory dump into a physical-memory provider.
///
/// The forensic-vfs resolver first peels any archive/compression/container
/// packaging (`memory.zip`, `memory.dd.gz`, `dump.7z`, nested combinations) down
/// to the innermost raw byte edge (ADR 0011 `resolve_to_source`); that
/// [`DynSource`](forensic_vfs::DynSource) is adapted to a `Read + Seek` source and
/// handed to `memf_format::open_source_with_raw_fallback`, which runs the same
/// format detection as the path-based `open_dump` but also accepts a headerless
/// raw dump (the caller has asserted this is a memory dump). A bare, unwrapped
/// dump peels to itself and takes the identical path.
///
/// # Errors
///
/// Fails LOUD as `InvalidData` on a resolver read/decode error, a packaging bomb
/// that exceeds the recursion cap (no terminal), or a memf format/construction
/// error — never a silently empty provider.
#[cfg(feature = "memory")]
pub fn open_memory_provider(
    image: &Path,
) -> io::Result<Box<dyn memf_format::PhysicalMemoryProvider>> {
    use forensic_vfs_resolver::SourceOpen as _;
    let bad = |msg: String| io::Error::new(io::ErrorKind::InvalidData, msg);

    let base: forensic_vfs::DynSource = std::sync::Arc::new(
        forensic_vfs::adapters::FileSource::open(image)
            .map_err(|e| bad(format!("cannot open memory dump {}: {e}", image.display())))?,
    );
    let base_spec = forensic_vfs::Locator::file(image);

    // A memory dump's only packaging is an archive or compression wrapper
    // (memory.zip / .gz / .7z / .tar, nested) — it is a flat page stream, never a
    // partitioned/encrypted disk, so the disk-container probers (VMDK/VHD/AFF4/…)
    // do not belong here. That scoping is also correct-by-omission: AFF4 is
    // ZIP-based and probes `Maybe` on any PK magic, so including it would let it
    // hard-fail-claim a plain memory.zip before the archive peeler runs.
    let openers = forensic_vfs::Openers::new().archive(archive_core::ArchiveOpener);
    let resolved = openers
        .resolve_to_source(base, base_spec, 0)
        .map_err(|e| bad(format!("unwrapping memory dump {}: {e}", image.display())))?
        .ok_or_else(|| {
            bad(format!(
                "could not unwrap memory dump {} (packaging recursion cap hit)",
                image.display()
            ))
        })?;

    let reader = DynSourceDumpReader::new(resolved.source);
    memf_format::open_source_with_raw_fallback(Box::new(reader))
        .map_err(|e| bad(format!("cannot open memory dump {}: {e}", image.display())))
}

/// 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 = open_memory_provider(image)?;

    // 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(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(0x1a_b000).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();
    }

    /// ADR-0011: a memory dump wrapped in an archive (`.zip`) or nested
    /// compression (`.zip.gz`) must read back the SAME physical pages as the bare
    /// raw dump — proving the `resolve_to_source` (peel) -> `DynSource`/`DumpReader`
    /// adapt -> `open_source_with_raw_fallback` chain in `open_memory_provider`.
    /// A headerless raw dump (Raw format, probe score 5) also exercises the
    /// raw-fallback below-threshold path the resolver hands memf.
    #[test]
    fn wrapped_memory_dump_reads_same_pages_as_raw() {
        use memf_format::PhysicalMemoryProvider;
        use std::io::Write as _;

        // Deterministic headerless raw dump content.
        let page: Vec<u8> = (0..4096u32).map(|i| (i % 251) as u8).collect();

        let dir = std::env::temp_dir().join(format!("4n6mem_wrap_{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();

        // (1) raw baseline
        let raw_path = dir.join("memory.raw");
        std::fs::write(&raw_path, &page).unwrap();

        // (2) .zip-wrapped, Stored so archive-core reads the member in place
        let mut zip_bytes = Vec::new();
        {
            let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_bytes));
            let opts = zip::write::SimpleFileOptions::default()
                .compression_method(zip::CompressionMethod::Stored);
            zw.start_file("memory.raw", opts).unwrap();
            zw.write_all(&page).unwrap();
            zw.finish().unwrap();
        }
        let zip_path = dir.join("memory.zip");
        std::fs::write(&zip_path, &zip_bytes).unwrap();

        // (3) nested .zip.gz (gzip over the zip bytes)
        let gz_path = dir.join("memory.zip.gz");
        {
            let mut enc = flate2::write::GzEncoder::new(
                std::fs::File::create(&gz_path).unwrap(),
                flate2::Compression::default(),
            );
            enc.write_all(&zip_bytes).unwrap();
            enc.finish().unwrap();
        }

        let read_first_page = |p: &std::path::Path| -> Vec<u8> {
            let provider = open_memory_provider(p).expect("open wrapped/raw memory provider");
            assert_eq!(provider.format_name(), "Raw");
            assert_eq!(provider.total_size(), page.len() as u64);
            let mut got = vec![0u8; page.len()];
            let n = provider.read_phys(0, &mut got).unwrap();
            got.truncate(n);
            got
        };

        assert_eq!(read_first_page(&raw_path), page, "raw baseline");
        assert_eq!(
            read_first_page(&zip_path),
            page,
            ".zip-wrapped dump reads the same pages as raw"
        );
        assert_eq!(
            read_first_page(&gz_path),
            page,
            "nested .zip.gz dump reads the same pages as raw"
        );

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