Skip to main content

forensic_mount/
detect.rs

1#![forbid(unsafe_code)]
2
3use std::io::{self, Read, Seek, SeekFrom};
4
5/// Detected filesystem type.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum FsType {
8    Ext4,
9    Ntfs,
10    ExFat,
11    Hfsplus,
12    Apfs,
13    Ewf,
14    Iso,
15    Vmdk,
16    Zip,
17    SevenZ,
18    TarGz,
19    TarBz2,
20    Unknown,
21}
22
23impl std::fmt::Display for FsType {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            FsType::Ext4 => write!(f, "ext4"),
27            FsType::Ntfs => write!(f, "ntfs"),
28            FsType::ExFat => write!(f, "exfat"),
29            FsType::Hfsplus => write!(f, "hfsplus"),
30            FsType::Apfs => write!(f, "apfs"),
31            FsType::Ewf => write!(f, "ewf"),
32            FsType::Iso => write!(f, "iso9660"),
33            FsType::Vmdk => write!(f, "vmdk"),
34            FsType::Zip => write!(f, "zip"),
35            FsType::SevenZ => write!(f, "7z"),
36            FsType::TarGz => write!(f, "tar.gz"),
37            FsType::TarBz2 => write!(f, "tar.bz2"),
38            FsType::Unknown => write!(f, "unknown"),
39        }
40    }
41}
42
43impl std::str::FromStr for FsType {
44    type Err = String;
45    fn from_str(s: &str) -> Result<Self, Self::Err> {
46        match s.to_lowercase().as_str() {
47            "ext4" => Ok(FsType::Ext4),
48            "ntfs" => Ok(FsType::Ntfs),
49            "exfat" => Ok(FsType::ExFat),
50            "hfsplus" | "hfs+" | "hfsx" => Ok(FsType::Hfsplus),
51            "apfs" => Ok(FsType::Apfs),
52            "ewf" | "e01" => Ok(FsType::Ewf),
53            "vmdk" => Ok(FsType::Vmdk),
54            "iso" | "iso9660" | "cd" | "udf" => Ok(FsType::Iso),
55            "zip" => Ok(FsType::Zip),
56            "7z" | "sevenz" | "7zip" => Ok(FsType::SevenZ),
57            "targz" | "tar.gz" | "tgz" | "gz" | "gzip" => Ok(FsType::TarGz),
58            "tarbz2" | "tar.bz2" | "tbz2" | "tbz" | "bz2" | "bzip2" => Ok(FsType::TarBz2),
59            _ => Err(format!("unknown filesystem type: {s}")),
60        }
61    }
62}
63
64/// Auto-detect filesystem type from a Read+Seek source.
65///
66/// Checks magic numbers for ext4, NTFS, exFAT, HFS+, APFS, ISO9660, the EWF and
67/// VMDK containers, and the zip/7z/gzip archive formats. Returns `FsType::Unknown`
68/// if no known signature matches. The seek position is reset to 0 after detection.
69pub fn detect_filesystem<R: Read + Seek>(source: &mut R) -> io::Result<FsType> {
70    // Seek to start
71    source.seek(SeekFrom::Start(0))?;
72
73    // Read enough bytes for all checks.  The ISO 9660 volume descriptor lives
74    // at sector 16: byte 32769 for 2048-byte sectors, or 37633 for 2352-byte
75    // raw CD sectors.  Read through the raw-mode offset so both are covered.
76    let mut buf = vec![0u8; 37_640];
77    let bytes_read = read_fill(source, &mut buf);
78
79    // Reset seek position to start
80    source.seek(SeekFrom::Start(0))?;
81
82    // Check EWF: signature "EVF\x09\x0d\x0a\xff\x00" at byte 0
83    if bytes_read >= 8 && buf[0..3] == [0x45, 0x56, 0x46] && buf[3] == 0x09 {
84        return Ok(FsType::Ewf);
85    }
86
87    // Check VMDK: sparse/streamOptimized header magic 0x564D444B ("KDMV", LE) at
88    // byte 0, or a text descriptor file. VMDK is a container, like EWF.
89    if bytes_read >= 4 && buf[0..4] == [0x4B, 0x44, 0x4D, 0x56] {
90        return Ok(FsType::Vmdk);
91    }
92    if bytes_read >= 21 && buf[0..21] == *b"# Disk DescriptorFile" {
93        return Ok(FsType::Vmdk);
94    }
95
96    // Check archives by their byte-0 signatures. These are terminal containers
97    // that expose a file tree directly (no inner filesystem to recurse into).
98    //   gzip: 1f 8b  (a .tar.gz / .tgz; a bare .gz is decoded as a 1-file tar)
99    //   zip : "PK\x03\x04" (local file header) or "PK\x05\x06" (empty archive)
100    //   7z  : 37 7a bc af 27 1c
101    if bytes_read >= 2 && buf[0] == 0x1F && buf[1] == 0x8B {
102        return Ok(FsType::TarGz);
103    }
104    // bzip2: "BZh" (a .tar.bz2 / .tbz2; a bare .bz2 is decoded as a 1-file tar)
105    if bytes_read >= 3 && &buf[0..3] == b"BZh" {
106        return Ok(FsType::TarBz2);
107    }
108    if bytes_read >= 4
109        && buf[0..2] == [0x50, 0x4B]
110        && matches!(buf[2..4], [0x03, 0x04] | [0x05, 0x06] | [0x07, 0x08])
111    {
112        return Ok(FsType::Zip);
113    }
114    if bytes_read >= 6 && buf[0..6] == [0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C] {
115        return Ok(FsType::SevenZ);
116    }
117
118    // Check APFS: container superblock magic "NXSB" at byte 32 (after the
119    // 32-byte obj_phys_t object header of block 0).
120    if bytes_read >= 36 && &buf[32..36] == b"NXSB" {
121        return Ok(FsType::Apfs);
122    }
123
124    // Check NTFS: "NTFS" at byte 3
125    if bytes_read >= 7 && &buf[3..7] == b"NTFS" {
126        return Ok(FsType::Ntfs);
127    }
128
129    // Check exFAT: "EXFAT" at byte 3
130    if bytes_read >= 8 && &buf[3..8] == b"EXFAT" {
131        return Ok(FsType::ExFat);
132    }
133
134    // Check HFS+/HFSX: volume header at byte 1024; signature "H+" (0x482B) for
135    // HFS+, "HX" (0x4858) for the case-sensitive HFSX variant.
136    if bytes_read >= 1026 && buf[1024] == 0x48 && (buf[1025] == 0x2B || buf[1025] == 0x58) {
137        return Ok(FsType::Hfsplus);
138    }
139
140    // Check ext4: magic 0xEF53 at byte 1080 (little-endian)
141    if bytes_read >= 1082 {
142        let magic = u16::from_le_bytes([buf[1080], buf[1081]]);
143        if magic == 0xEF53 {
144            return Ok(FsType::Ext4);
145        }
146    }
147
148    // Check ISO 9660 / UDF: "CD001" at sector 16.
149    //   2048-byte sectors: offset 32769 (16 * 2048 + 1)
150    //   2352-byte raw CD : offset 37633 (16 * 2352 + 16 + 1)
151    if bytes_read >= 32_774 && &buf[32_769..32_774] == b"CD001" {
152        return Ok(FsType::Iso);
153    }
154    if bytes_read >= 37_638 && &buf[37_633..37_638] == b"CD001" {
155        return Ok(FsType::Iso);
156    }
157
158    Ok(FsType::Unknown)
159}
160
161/// A recognized memory-dump container format. These route to the memory mount
162/// path (a `MemoryFs` over memf), not the disk-filesystem dispatch.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum MemDumpFormat {
165    /// `LiME` (Linux Memory Extractor) — magic `EMiL`.
166    Lime,
167    /// AVML (Acquisition of Volatile Memory for Linux) v2 — magic `AVML`.
168    Avml,
169    /// ELF core dump (`ET_CORE`).
170    ElfCore,
171    /// Windows kernel crash dump (64-bit) — magic `PAGEDU64`.
172    WinCrashDump,
173}
174
175impl std::fmt::Display for MemDumpFormat {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        match self {
178            MemDumpFormat::Lime => write!(f, "lime"),
179            MemDumpFormat::Avml => write!(f, "avml"),
180            MemDumpFormat::ElfCore => write!(f, "elf-core"),
181            MemDumpFormat::WinCrashDump => write!(f, "win-crashdump"),
182        }
183    }
184}
185
186/// Detect a memory-dump container by its header magic.
187///
188/// Returns `Ok(None)` for non-dumps — including raw/headerless dumps, which
189/// carry no signature and must be selected explicitly (`--fs memory`). The seek
190/// position is reset to 0. Magics mirror `memf-format`'s plugins (`LiME`
191/// `0x4C694D45`, AVML `0x4C4D5641`, ELF `ET_CORE`, crash `PAGE` + `DU64`).
192pub fn detect_memory_dump<R: Read + Seek>(source: &mut R) -> io::Result<Option<MemDumpFormat>> {
193    source.seek(SeekFrom::Start(0))?;
194    let mut buf = [0u8; 18];
195    let n = read_fill(source, &mut buf);
196    source.seek(SeekFrom::Start(0))?;
197
198    // LiME header: magic 0x4C694D45 ("EMiL" little-endian) at byte 0.
199    if n >= 4 && &buf[0..4] == b"EMiL" {
200        return Ok(Some(MemDumpFormat::Lime));
201    }
202    // AVML v2: magic 0x4C4D5641 ("AVML" little-endian) at byte 0.
203    if n >= 4 && &buf[0..4] == b"AVML" {
204        return Ok(Some(MemDumpFormat::Avml));
205    }
206    // Windows kernel crash dump (64-bit): "PAGE" + "DU64" = "PAGEDU64" at byte 0.
207    if n >= 8 && &buf[0..8] == b"PAGEDU64" {
208        return Ok(Some(MemDumpFormat::WinCrashDump));
209    }
210    // ELF core dump: ELF magic at byte 0 and e_type == ET_CORE (4) at byte 16.
211    if n >= 18 && buf[0..4] == [0x7F, b'E', b'L', b'F'] {
212        let e_type = u16::from_le_bytes([buf[16], buf[17]]);
213        if e_type == 4 {
214            return Ok(Some(MemDumpFormat::ElfCore));
215        }
216    }
217    Ok(None)
218}
219
220/// Read as many bytes as possible into `buf`, returning total bytes read.
221fn read_fill<R: Read>(source: &mut R, buf: &mut [u8]) -> usize {
222    let mut total = 0;
223    while total < buf.len() {
224        match source.read(&mut buf[total..]) {
225            Ok(0) | Err(_) => break,
226            Ok(n) => total += n,
227        }
228    }
229    total
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use std::io::Cursor;
236
237    #[test]
238    fn detects_vmdk_sparse_magic() {
239        // VMDK sparse/streamOptimized header magic 0x564D444B ("KDMV", LE) at byte 0.
240        let mut data = vec![0u8; 2048];
241        data[0..4].copy_from_slice(b"KDMV");
242        assert_eq!(
243            detect_filesystem(&mut Cursor::new(data)).unwrap(),
244            FsType::Vmdk
245        );
246    }
247
248    #[test]
249    fn detects_vmdk_text_descriptor() {
250        let data = b"# Disk DescriptorFile\nversion=1\n".to_vec();
251        assert_eq!(
252            detect_filesystem(&mut Cursor::new(data)).unwrap(),
253            FsType::Vmdk
254        );
255    }
256
257    fn make_ext4_image() -> Vec<u8> {
258        // ext4 magic 0xEF53 is at byte offset 1080 (0x438) within the superblock
259        // Superblock starts at byte 1024
260        let mut data = vec![0u8; 2048];
261        data[1080] = 0x53; // low byte of 0xEF53
262        data[1081] = 0xEF; // high byte (little-endian)
263        data
264    }
265
266    fn make_ntfs_image() -> Vec<u8> {
267        // NTFS: "NTFS    " (with spaces) at byte offset 3
268        let mut data = vec![0u8; 512];
269        data[3..7].copy_from_slice(b"NTFS");
270        data
271    }
272
273    fn make_exfat_image() -> Vec<u8> {
274        // exFAT: "EXFAT   " at byte offset 3
275        let mut data = vec![0u8; 512];
276        data[3..8].copy_from_slice(b"EXFAT");
277        data
278    }
279
280    #[test]
281    fn detect_ext4() {
282        let data = make_ext4_image();
283        let mut cursor = Cursor::new(data);
284        assert_eq!(detect_filesystem(&mut cursor).unwrap(), FsType::Ext4);
285    }
286
287    #[test]
288    fn detect_ntfs() {
289        let data = make_ntfs_image();
290        let mut cursor = Cursor::new(data);
291        assert_eq!(detect_filesystem(&mut cursor).unwrap(), FsType::Ntfs);
292    }
293
294    #[test]
295    fn detect_exfat() {
296        let data = make_exfat_image();
297        let mut cursor = Cursor::new(data);
298        assert_eq!(detect_filesystem(&mut cursor).unwrap(), FsType::ExFat);
299    }
300
301    #[test]
302    fn detect_unknown() {
303        let data = vec![0u8; 2048];
304        let mut cursor = Cursor::new(data);
305        assert_eq!(detect_filesystem(&mut cursor).unwrap(), FsType::Unknown);
306    }
307
308    /// ISO 9660: "CD001" at byte 1 of sector 16 (offset 16*2048 = 32768).
309    fn make_iso_image() -> Vec<u8> {
310        let mut data = vec![0u8; 18 * 2048];
311        let pvd = 16 * 2048;
312        data[pvd] = 0x01;
313        data[pvd + 1..pvd + 6].copy_from_slice(b"CD001");
314        data[pvd + 6] = 0x01;
315        data
316    }
317
318    #[test]
319    fn detect_iso() {
320        let data = make_iso_image();
321        let mut cursor = Cursor::new(data);
322        assert_eq!(detect_filesystem(&mut cursor).unwrap(), FsType::Iso);
323    }
324
325    #[test]
326    fn iso_fstype_parses_from_str() {
327        assert_eq!("iso".parse::<FsType>().unwrap(), FsType::Iso);
328        assert_eq!("iso9660".parse::<FsType>().unwrap(), FsType::Iso);
329    }
330
331    #[test]
332    fn detect_too_short() {
333        let data = vec![0u8; 10];
334        let mut cursor = Cursor::new(data);
335        // Should not panic, should return Unknown or an error
336        let result = detect_filesystem(&mut cursor);
337        assert!(result.is_ok());
338        assert_eq!(result.unwrap(), FsType::Unknown);
339    }
340
341    #[test]
342    fn detect_real_ext4_image() {
343        let path = "/Users/4n6h4x0r/src/ext4fs-forensic/tests/data/forensic.img";
344        let Ok(data) = std::fs::read(path) else {
345            eprintln!("skip: forensic.img not found");
346            return;
347        };
348        let mut cursor = Cursor::new(data);
349        assert_eq!(detect_filesystem(&mut cursor).unwrap(), FsType::Ext4);
350    }
351
352    #[test]
353    fn fstype_from_str() {
354        assert_eq!("ext4".parse::<FsType>().unwrap(), FsType::Ext4);
355        assert_eq!("NTFS".parse::<FsType>().unwrap(), FsType::Ntfs);
356        assert_eq!("ExFat".parse::<FsType>().unwrap(), FsType::ExFat);
357        assert!("btrfs".parse::<FsType>().is_err());
358    }
359
360    #[test]
361    fn fstype_display() {
362        assert_eq!(FsType::Ext4.to_string(), "ext4");
363        assert_eq!(FsType::Ntfs.to_string(), "ntfs");
364        assert_eq!(FsType::ExFat.to_string(), "exfat");
365        assert_eq!(FsType::Unknown.to_string(), "unknown");
366    }
367
368    #[test]
369    fn detect_ewf_image() {
370        // EWF signature: EVF\x09\x0d\x0a\xff\x00
371        let mut data = vec![0u8; 2048];
372        data[0..8].copy_from_slice(&[0x45, 0x56, 0x46, 0x09, 0x0D, 0x0A, 0xFF, 0x00]);
373        let mut cursor = Cursor::new(data);
374        assert_eq!(detect_filesystem(&mut cursor).unwrap(), FsType::Ewf);
375    }
376
377    #[test]
378    fn fstype_ewf_display() {
379        assert_eq!(FsType::Ewf.to_string(), "ewf");
380    }
381
382    #[test]
383    fn fstype_ewf_from_str() {
384        assert_eq!("ewf".parse::<FsType>().unwrap(), FsType::Ewf);
385        assert_eq!("e01".parse::<FsType>().unwrap(), FsType::Ewf);
386    }
387
388    #[test]
389    fn detect_resets_seek_position() {
390        let data = make_ext4_image();
391        let mut cursor = Cursor::new(data);
392        cursor.seek(SeekFrom::Start(500)).unwrap();
393        assert_eq!(detect_filesystem(&mut cursor).unwrap(), FsType::Ext4);
394        // Seek position should be reset to 0 after detection
395        assert_eq!(cursor.stream_position().unwrap(), 0);
396    }
397
398    #[test]
399    fn detect_gzip_as_targz() {
400        // gzip magic 1f 8b at byte 0.
401        let mut data = vec![0u8; 64];
402        data[0] = 0x1F;
403        data[1] = 0x8B;
404        data[2] = 0x08; // deflate
405        assert_eq!(
406            detect_filesystem(&mut Cursor::new(data)).unwrap(),
407            FsType::TarGz
408        );
409    }
410
411    #[test]
412    fn detect_mem_lime() {
413        let mut data = vec![0u8; 64];
414        data[0..4].copy_from_slice(b"EMiL"); // LIME_MAGIC 0x4C694D45 little-endian
415        assert_eq!(
416            detect_memory_dump(&mut Cursor::new(data)).unwrap(),
417            Some(MemDumpFormat::Lime)
418        );
419    }
420
421    #[test]
422    fn detect_mem_avml() {
423        let mut data = vec![0u8; 64];
424        data[0..4].copy_from_slice(b"AVML");
425        assert_eq!(
426            detect_memory_dump(&mut Cursor::new(data)).unwrap(),
427            Some(MemDumpFormat::Avml)
428        );
429    }
430
431    #[test]
432    fn detect_mem_elf_core() {
433        let mut data = vec![0u8; 64];
434        data[0..4].copy_from_slice(&[0x7F, b'E', b'L', b'F']);
435        data[16..18].copy_from_slice(&4u16.to_le_bytes()); // e_type = ET_CORE
436        assert_eq!(
437            detect_memory_dump(&mut Cursor::new(data)).unwrap(),
438            Some(MemDumpFormat::ElfCore)
439        );
440    }
441
442    #[test]
443    fn detect_mem_elf_exec_is_not_a_dump() {
444        // A normal ELF executable (ET_EXEC) is not a core dump.
445        let mut data = vec![0u8; 64];
446        data[0..4].copy_from_slice(&[0x7F, b'E', b'L', b'F']);
447        data[16..18].copy_from_slice(&2u16.to_le_bytes()); // ET_EXEC
448        assert_eq!(detect_memory_dump(&mut Cursor::new(data)).unwrap(), None);
449    }
450
451    #[test]
452    fn detect_mem_win_crashdump() {
453        let mut data = vec![0u8; 64];
454        data[0..8].copy_from_slice(b"PAGEDU64");
455        assert_eq!(
456            detect_memory_dump(&mut Cursor::new(data)).unwrap(),
457            Some(MemDumpFormat::WinCrashDump)
458        );
459    }
460
461    #[test]
462    fn detect_mem_none_for_non_dump() {
463        let mut data = vec![0u8; 64];
464        assert_eq!(detect_memory_dump(&mut Cursor::new(data)).unwrap(), None);
465    }
466
467    #[test]
468    fn detect_bzip2_as_tarbz2() {
469        // bzip2 magic "BZh" (0x42 0x5A 0x68) at byte 0.
470        let mut data = vec![0u8; 64];
471        data[0..3].copy_from_slice(b"BZh");
472        data[3] = b'9'; // block-size digit
473        assert_eq!(
474            detect_filesystem(&mut Cursor::new(data)).unwrap(),
475            FsType::TarBz2
476        );
477    }
478
479    #[test]
480    fn detect_zip_local_file_header() {
481        let mut data = vec![0u8; 64];
482        data[0..4].copy_from_slice(b"PK\x03\x04");
483        assert_eq!(
484            detect_filesystem(&mut Cursor::new(data)).unwrap(),
485            FsType::Zip
486        );
487    }
488
489    #[test]
490    fn detect_zip_empty_archive() {
491        // An empty zip is just the end-of-central-directory record: PK\x05\x06.
492        let mut data = vec![0u8; 64];
493        data[0..4].copy_from_slice(b"PK\x05\x06");
494        assert_eq!(
495            detect_filesystem(&mut Cursor::new(data)).unwrap(),
496            FsType::Zip
497        );
498    }
499
500    #[test]
501    fn detect_7z_signature() {
502        let mut data = vec![0u8; 64];
503        data[0..6].copy_from_slice(&[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C]);
504        assert_eq!(
505            detect_filesystem(&mut Cursor::new(data)).unwrap(),
506            FsType::SevenZ
507        );
508    }
509
510    #[test]
511    fn detect_hfsplus_signature() {
512        // HFS+ volume header at byte 1024; signature "H+" (0x482B).
513        let mut data = vec![0u8; 2048];
514        data[1024] = 0x48; // 'H'
515        data[1025] = 0x2B; // '+'
516        assert_eq!(
517            detect_filesystem(&mut Cursor::new(data)).unwrap(),
518            FsType::Hfsplus
519        );
520    }
521
522    #[test]
523    fn detect_hfsx_signature() {
524        // HFSX (case-sensitive) uses "HX" (0x4858) at the same offset.
525        let mut data = vec![0u8; 2048];
526        data[1024] = 0x48; // 'H'
527        data[1025] = 0x58; // 'X'
528        assert_eq!(
529            detect_filesystem(&mut Cursor::new(data)).unwrap(),
530            FsType::Hfsplus
531        );
532    }
533
534    #[test]
535    fn detect_apfs_nxsb() {
536        // APFS container superblock: 32-byte obj header, then magic "NXSB".
537        let mut data = vec![0u8; 4096];
538        data[32..36].copy_from_slice(b"NXSB");
539        assert_eq!(
540            detect_filesystem(&mut Cursor::new(data)).unwrap(),
541            FsType::Apfs
542        );
543    }
544
545    #[test]
546    fn new_fstypes_parse_and_display() {
547        assert_eq!("hfsplus".parse::<FsType>().unwrap(), FsType::Hfsplus);
548        assert_eq!("apfs".parse::<FsType>().unwrap(), FsType::Apfs);
549        assert_eq!("zip".parse::<FsType>().unwrap(), FsType::Zip);
550        assert_eq!("7z".parse::<FsType>().unwrap(), FsType::SevenZ);
551        assert_eq!("tar.gz".parse::<FsType>().unwrap(), FsType::TarGz);
552        assert_eq!(FsType::Hfsplus.to_string(), "hfsplus");
553        assert_eq!(FsType::SevenZ.to_string(), "7z");
554        assert_eq!(FsType::TarGz.to_string(), "tar.gz");
555    }
556}