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