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