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    Ewf,
12    Iso,
13    Vmdk,
14    Unknown,
15}
16
17impl std::fmt::Display for FsType {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        match self {
20            FsType::Ext4 => write!(f, "ext4"),
21            FsType::Ntfs => write!(f, "ntfs"),
22            FsType::ExFat => write!(f, "exfat"),
23            FsType::Ewf => write!(f, "ewf"),
24            FsType::Iso => write!(f, "iso9660"),
25            FsType::Vmdk => write!(f, "vmdk"),
26            FsType::Unknown => write!(f, "unknown"),
27        }
28    }
29}
30
31impl std::str::FromStr for FsType {
32    type Err = String;
33    fn from_str(s: &str) -> Result<Self, Self::Err> {
34        match s.to_lowercase().as_str() {
35            "ext4" => Ok(FsType::Ext4),
36            "ntfs" => Ok(FsType::Ntfs),
37            "exfat" => Ok(FsType::ExFat),
38            "ewf" | "e01" => Ok(FsType::Ewf),
39            "iso" | "iso9660" | "cd" | "udf" => Ok(FsType::Iso),
40            _ => Err(format!("unknown filesystem type: {s}")),
41        }
42    }
43}
44
45/// Auto-detect filesystem type from a Read+Seek source.
46///
47/// Checks magic numbers for ext4, NTFS, and exFAT. Returns `FsType::Unknown`
48/// if no known signature matches. The seek position is reset to 0 after detection.
49pub fn detect_filesystem<R: Read + Seek>(source: &mut R) -> io::Result<FsType> {
50    // Seek to start
51    source.seek(SeekFrom::Start(0))?;
52
53    // Read enough bytes for all checks.  The ISO 9660 volume descriptor lives
54    // at sector 16: byte 32769 for 2048-byte sectors, or 37633 for 2352-byte
55    // raw CD sectors.  Read through the raw-mode offset so both are covered.
56    let mut buf = vec![0u8; 37_640];
57    let bytes_read = read_fill(source, &mut buf);
58
59    // Reset seek position to start
60    source.seek(SeekFrom::Start(0))?;
61
62    // Check EWF: signature "EVF\x09\x0d\x0a\xff\x00" at byte 0
63    if bytes_read >= 8 && buf[0..3] == [0x45, 0x56, 0x46] && buf[3] == 0x09 {
64        return Ok(FsType::Ewf);
65    }
66
67    // Check VMDK: sparse/streamOptimized header magic 0x564D444B ("KDMV", LE) at
68    // byte 0, or a text descriptor file. VMDK is a container, like EWF.
69    if bytes_read >= 4 && buf[0..4] == [0x4B, 0x44, 0x4D, 0x56] {
70        return Ok(FsType::Vmdk);
71    }
72    if bytes_read >= 21 && buf[0..21] == *b"# Disk DescriptorFile" {
73        return Ok(FsType::Vmdk);
74    }
75
76    // Check NTFS: "NTFS" at byte 3
77    if bytes_read >= 7 && &buf[3..7] == b"NTFS" {
78        return Ok(FsType::Ntfs);
79    }
80
81    // Check exFAT: "EXFAT" at byte 3
82    if bytes_read >= 8 && &buf[3..8] == b"EXFAT" {
83        return Ok(FsType::ExFat);
84    }
85
86    // Check ext4: magic 0xEF53 at byte 1080 (little-endian)
87    if bytes_read >= 1082 {
88        let magic = u16::from_le_bytes([buf[1080], buf[1081]]);
89        if magic == 0xEF53 {
90            return Ok(FsType::Ext4);
91        }
92    }
93
94    // Check ISO 9660 / UDF: "CD001" at sector 16.
95    //   2048-byte sectors: offset 32769 (16 * 2048 + 1)
96    //   2352-byte raw CD : offset 37633 (16 * 2352 + 16 + 1)
97    if bytes_read >= 32_774 && &buf[32_769..32_774] == b"CD001" {
98        return Ok(FsType::Iso);
99    }
100    if bytes_read >= 37_638 && &buf[37_633..37_638] == b"CD001" {
101        return Ok(FsType::Iso);
102    }
103
104    Ok(FsType::Unknown)
105}
106
107/// Read as many bytes as possible into `buf`, returning total bytes read.
108fn read_fill<R: Read>(source: &mut R, buf: &mut [u8]) -> usize {
109    let mut total = 0;
110    while total < buf.len() {
111        match source.read(&mut buf[total..]) {
112            Ok(0) | Err(_) => break,
113            Ok(n) => total += n,
114        }
115    }
116    total
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use std::io::Cursor;
123
124    #[test]
125    fn detects_vmdk_sparse_magic() {
126        // VMDK sparse/streamOptimized header magic 0x564D444B ("KDMV", LE) at byte 0.
127        let mut data = vec![0u8; 2048];
128        data[0..4].copy_from_slice(b"KDMV");
129        assert_eq!(
130            detect_filesystem(&mut Cursor::new(data)).unwrap(),
131            FsType::Vmdk
132        );
133    }
134
135    #[test]
136    fn detects_vmdk_text_descriptor() {
137        let data = b"# Disk DescriptorFile\nversion=1\n".to_vec();
138        assert_eq!(
139            detect_filesystem(&mut Cursor::new(data)).unwrap(),
140            FsType::Vmdk
141        );
142    }
143
144    fn make_ext4_image() -> Vec<u8> {
145        // ext4 magic 0xEF53 is at byte offset 1080 (0x438) within the superblock
146        // Superblock starts at byte 1024
147        let mut data = vec![0u8; 2048];
148        data[1080] = 0x53; // low byte of 0xEF53
149        data[1081] = 0xEF; // high byte (little-endian)
150        data
151    }
152
153    fn make_ntfs_image() -> Vec<u8> {
154        // NTFS: "NTFS    " (with spaces) at byte offset 3
155        let mut data = vec![0u8; 512];
156        data[3..7].copy_from_slice(b"NTFS");
157        data
158    }
159
160    fn make_exfat_image() -> Vec<u8> {
161        // exFAT: "EXFAT   " at byte offset 3
162        let mut data = vec![0u8; 512];
163        data[3..8].copy_from_slice(b"EXFAT");
164        data
165    }
166
167    #[test]
168    fn detect_ext4() {
169        let data = make_ext4_image();
170        let mut cursor = Cursor::new(data);
171        assert_eq!(detect_filesystem(&mut cursor).unwrap(), FsType::Ext4);
172    }
173
174    #[test]
175    fn detect_ntfs() {
176        let data = make_ntfs_image();
177        let mut cursor = Cursor::new(data);
178        assert_eq!(detect_filesystem(&mut cursor).unwrap(), FsType::Ntfs);
179    }
180
181    #[test]
182    fn detect_exfat() {
183        let data = make_exfat_image();
184        let mut cursor = Cursor::new(data);
185        assert_eq!(detect_filesystem(&mut cursor).unwrap(), FsType::ExFat);
186    }
187
188    #[test]
189    fn detect_unknown() {
190        let data = vec![0u8; 2048];
191        let mut cursor = Cursor::new(data);
192        assert_eq!(detect_filesystem(&mut cursor).unwrap(), FsType::Unknown);
193    }
194
195    /// ISO 9660: "CD001" at byte 1 of sector 16 (offset 16*2048 = 32768).
196    fn make_iso_image() -> Vec<u8> {
197        let mut data = vec![0u8; 18 * 2048];
198        let pvd = 16 * 2048;
199        data[pvd] = 0x01;
200        data[pvd + 1..pvd + 6].copy_from_slice(b"CD001");
201        data[pvd + 6] = 0x01;
202        data
203    }
204
205    #[test]
206    fn detect_iso() {
207        let data = make_iso_image();
208        let mut cursor = Cursor::new(data);
209        assert_eq!(detect_filesystem(&mut cursor).unwrap(), FsType::Iso);
210    }
211
212    #[test]
213    fn iso_fstype_parses_from_str() {
214        assert_eq!("iso".parse::<FsType>().unwrap(), FsType::Iso);
215        assert_eq!("iso9660".parse::<FsType>().unwrap(), FsType::Iso);
216    }
217
218    #[test]
219    fn detect_too_short() {
220        let data = vec![0u8; 10];
221        let mut cursor = Cursor::new(data);
222        // Should not panic, should return Unknown or an error
223        let result = detect_filesystem(&mut cursor);
224        assert!(result.is_ok());
225        assert_eq!(result.unwrap(), FsType::Unknown);
226    }
227
228    #[test]
229    fn detect_real_ext4_image() {
230        let path = "/Users/4n6h4x0r/src/ext4fs-forensic/tests/data/forensic.img";
231        let Ok(data) = std::fs::read(path) else {
232            eprintln!("skip: forensic.img not found");
233            return;
234        };
235        let mut cursor = Cursor::new(data);
236        assert_eq!(detect_filesystem(&mut cursor).unwrap(), FsType::Ext4);
237    }
238
239    #[test]
240    fn fstype_from_str() {
241        assert_eq!("ext4".parse::<FsType>().unwrap(), FsType::Ext4);
242        assert_eq!("NTFS".parse::<FsType>().unwrap(), FsType::Ntfs);
243        assert_eq!("ExFat".parse::<FsType>().unwrap(), FsType::ExFat);
244        assert!("btrfs".parse::<FsType>().is_err());
245    }
246
247    #[test]
248    fn fstype_display() {
249        assert_eq!(FsType::Ext4.to_string(), "ext4");
250        assert_eq!(FsType::Ntfs.to_string(), "ntfs");
251        assert_eq!(FsType::ExFat.to_string(), "exfat");
252        assert_eq!(FsType::Unknown.to_string(), "unknown");
253    }
254
255    #[test]
256    fn detect_ewf_image() {
257        // EWF signature: EVF\x09\x0d\x0a\xff\x00
258        let mut data = vec![0u8; 2048];
259        data[0..8].copy_from_slice(&[0x45, 0x56, 0x46, 0x09, 0x0D, 0x0A, 0xFF, 0x00]);
260        let mut cursor = Cursor::new(data);
261        assert_eq!(detect_filesystem(&mut cursor).unwrap(), FsType::Ewf);
262    }
263
264    #[test]
265    fn fstype_ewf_display() {
266        assert_eq!(FsType::Ewf.to_string(), "ewf");
267    }
268
269    #[test]
270    fn fstype_ewf_from_str() {
271        assert_eq!("ewf".parse::<FsType>().unwrap(), FsType::Ewf);
272        assert_eq!("e01".parse::<FsType>().unwrap(), FsType::Ewf);
273    }
274
275    #[test]
276    fn detect_resets_seek_position() {
277        let data = make_ext4_image();
278        let mut cursor = Cursor::new(data);
279        cursor.seek(SeekFrom::Start(500)).unwrap();
280        assert_eq!(detect_filesystem(&mut cursor).unwrap(), FsType::Ext4);
281        // Seek position should be reset to 0 after detection
282        assert_eq!(cursor.stream_position().unwrap(), 0);
283    }
284}