Skip to main content

forensic_mount/
fs_ext4.rs

1#![forbid(unsafe_code)]
2
3//! ext4 filesystem support via the ext4fs crate.
4//! Enabled with the `ext4` feature flag.
5
6use crate::{
7    ForensicFs, FsBlockRange, FsDeletedInode, FsDirEntry, FsError, FsEventType, FsFileType,
8    FsMetadata, FsRecoveryResult, FsResult, FsTimelineEvent, FsTimestamp, FsTransaction,
9};
10use ext4fs::Ext4Fs;
11use std::io::{Read, Seek};
12
13/// `ForensicFs` implementation for ext4 filesystems.
14pub struct Ext4ForensicFs<R: Read + Seek> {
15    fs: Ext4Fs<R>,
16}
17
18impl<R: Read + Seek> Ext4ForensicFs<R> {
19    pub fn new(source: R) -> Result<Self, FsError> {
20        let fs = Ext4Fs::open(source).map_err(map_err)?;
21        Ok(Self { fs })
22    }
23}
24
25impl<R: Read + Seek> ForensicFs for Ext4ForensicFs<R> {
26    fn root_ino(&self) -> u64 {
27        2 // ext4 root is always inode 2
28    }
29
30    fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>> {
31        let entries = self.fs.read_dir_by_ino(ino).map_err(map_err)?;
32        Ok(entries
33            .iter()
34            .map(|e| FsDirEntry {
35                inode: u64::from(e.inode),
36                name: e.name.clone(),
37                file_type: map_dir_entry_type(e.file_type),
38            })
39            .collect())
40    }
41
42    fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>> {
43        self.fs.lookup_by_ino(parent_ino, name).map_err(map_err)
44    }
45
46    fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata> {
47        let inode = self.fs.inode(ino).map_err(map_err)?;
48        let allocated = self.fs.is_inode_allocated(ino).unwrap_or(false);
49        Ok(FsMetadata {
50            ino,
51            file_type: map_file_type(inode.file_type()),
52            mode: inode.mode,
53            uid: inode.uid,
54            gid: inode.gid,
55            size: inode.size,
56            links_count: inode.links_count,
57            atime: map_ts(&inode.atime),
58            mtime: map_ts(&inode.mtime),
59            ctime: map_ts(&inode.ctime),
60            crtime: map_ts(&inode.crtime),
61            allocated,
62        })
63    }
64
65    fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>> {
66        self.fs.read_inode_data(ino).map_err(map_err)
67    }
68
69    fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>> {
70        self.fs
71            .read_inode_data_range(ino, offset, len as usize)
72            .map_err(map_err)
73    }
74
75    fn read_link(&mut self, ino: u64) -> FsResult<Vec<u8>> {
76        self.fs.read_link_by_ino(ino).map_err(map_err)
77    }
78
79    fn deleted_inodes(&mut self) -> FsResult<Vec<FsDeletedInode>> {
80        let deleted = self.fs.deleted_inodes().map_err(map_err)?;
81        Ok(deleted
82            .into_iter()
83            .map(|d| FsDeletedInode {
84                ino: d.ino,
85                file_type: map_file_type(d.file_type),
86                size: d.size,
87                dtime: d.dtime,
88                recoverability: d.recoverability,
89            })
90            .collect())
91    }
92
93    fn recover_file(&mut self, ino: u64) -> FsResult<FsRecoveryResult> {
94        let r = self.fs.recover_file(ino).map_err(map_err)?;
95        let pct = r.recovery_percentage();
96        Ok(FsRecoveryResult {
97            ino,
98            data: r.data,
99            expected_size: r.expected_size,
100            recovered_bytes: r.recovered_size,
101            recovery_percentage: pct,
102        })
103    }
104
105    fn timeline(&mut self) -> FsResult<Vec<FsTimelineEvent>> {
106        let events = self.fs.timeline().map_err(map_err)?;
107        Ok(events
108            .into_iter()
109            .map(|e| FsTimelineEvent {
110                timestamp: map_ts(&e.timestamp),
111                event_type: match e.event_type {
112                    ext4fs::forensic::EventType::Created => FsEventType::Created,
113                    ext4fs::forensic::EventType::Modified => FsEventType::Modified,
114                    ext4fs::forensic::EventType::Accessed => FsEventType::Accessed,
115                    ext4fs::forensic::EventType::Changed => FsEventType::Changed,
116                    ext4fs::forensic::EventType::Deleted => FsEventType::Deleted,
117                    ext4fs::forensic::EventType::Mounted => FsEventType::Mounted,
118                },
119                inode: e.inode,
120                size: e.size,
121                uid: e.uid,
122                gid: e.gid,
123            })
124            .collect())
125    }
126
127    fn unallocated_blocks(&mut self) -> FsResult<Vec<FsBlockRange>> {
128        let blocks = self.fs.unallocated_blocks().map_err(map_err)?;
129        Ok(blocks
130            .into_iter()
131            .map(|b| FsBlockRange {
132                start: b.start,
133                length: b.length,
134            })
135            .collect())
136    }
137
138    fn read_unallocated(&mut self, range: &FsBlockRange) -> FsResult<Vec<u8>> {
139        let ext4_range = ext4fs::forensic::BlockRange {
140            start: range.start,
141            length: range.length,
142        };
143        self.fs.read_unallocated(&ext4_range).map_err(map_err)
144    }
145
146    fn journal_transactions(&mut self) -> FsResult<Vec<FsTransaction>> {
147        let journal = self.fs.journal().map_err(map_err)?;
148        Ok(journal
149            .transactions
150            .into_iter()
151            .map(|t| FsTransaction {
152                sequence: u64::from(t.sequence),
153                commit_seconds: t.commit_seconds as u64,
154                commit_nanoseconds: t.commit_nanoseconds,
155            })
156            .collect())
157    }
158
159    fn fs_info(&self) -> FsResult<serde_json::Value> {
160        let sb = self.fs.superblock();
161        Ok(serde_json::json!({
162            "filesystem": "ext4",
163            "label": sb.label(),
164            "uuid": sb.uuid_string(),
165            "block_size": sb.block_size,
166            "blocks_count": sb.blocks_count,
167            "inodes_count": sb.inodes_count,
168        }))
169    }
170
171    fn block_size(&self) -> u64 {
172        u64::from(self.fs.superblock().block_size)
173    }
174}
175
176// --- Type mapping helpers ---
177
178fn map_err(e: ext4fs::error::Ext4Error) -> FsError {
179    FsError::Other(e.to_string())
180}
181
182fn map_file_type(ft: ext4fs::ondisk::FileType) -> FsFileType {
183    match ft {
184        ext4fs::ondisk::FileType::RegularFile => FsFileType::RegularFile,
185        ext4fs::ondisk::FileType::Directory => FsFileType::Directory,
186        ext4fs::ondisk::FileType::Symlink => FsFileType::Symlink,
187        ext4fs::ondisk::FileType::CharDevice => FsFileType::CharDevice,
188        ext4fs::ondisk::FileType::BlockDevice => FsFileType::BlockDevice,
189        ext4fs::ondisk::FileType::Fifo => FsFileType::Fifo,
190        ext4fs::ondisk::FileType::Socket => FsFileType::Socket,
191        ext4fs::ondisk::FileType::Unknown => FsFileType::Unknown,
192    }
193}
194
195fn map_dir_entry_type(dt: ext4fs::ondisk::DirEntryType) -> FsFileType {
196    match dt {
197        ext4fs::ondisk::DirEntryType::RegularFile => FsFileType::RegularFile,
198        ext4fs::ondisk::DirEntryType::Directory => FsFileType::Directory,
199        ext4fs::ondisk::DirEntryType::Symlink => FsFileType::Symlink,
200        ext4fs::ondisk::DirEntryType::CharDevice => FsFileType::CharDevice,
201        ext4fs::ondisk::DirEntryType::BlockDevice => FsFileType::BlockDevice,
202        ext4fs::ondisk::DirEntryType::Fifo => FsFileType::Fifo,
203        ext4fs::ondisk::DirEntryType::Socket => FsFileType::Socket,
204        ext4fs::ondisk::DirEntryType::Unknown => FsFileType::Unknown,
205    }
206}
207
208fn map_ts(ts: &ext4fs::ondisk::Timestamp) -> FsTimestamp {
209    FsTimestamp {
210        seconds: ts.seconds,
211        nanoseconds: ts.nanoseconds,
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use std::io::Cursor;
219
220    fn open_forensic() -> Option<Ext4ForensicFs<Cursor<Vec<u8>>>> {
221        let path = "/Users/4n6h4x0r/src/ext4fs-forensic/tests/data/forensic.img";
222        let data = std::fs::read(path).ok()?;
223        Ext4ForensicFs::new(Cursor::new(data)).ok()
224    }
225
226    #[test]
227    fn root_ino_is_2() {
228        let Some(fs) = open_forensic() else {
229            eprintln!("skip");
230            return;
231        };
232        assert_eq!(fs.root_ino(), 2);
233    }
234
235    #[test]
236    fn read_dir_root_has_entries() {
237        let Some(mut fs) = open_forensic() else {
238            eprintln!("skip");
239            return;
240        };
241        let entries = fs.read_dir(2).unwrap();
242        let names: Vec<String> = entries
243            .iter()
244            .map(super::super::types::FsDirEntry::name_str)
245            .collect();
246        assert!(names.contains(&"hello.txt".to_string()));
247    }
248
249    #[test]
250    fn lookup_finds_file() {
251        let Some(mut fs) = open_forensic() else {
252            eprintln!("skip");
253            return;
254        };
255        let ino = fs.lookup(2, b"hello.txt").unwrap();
256        assert!(ino.is_some());
257    }
258
259    #[test]
260    fn metadata_root_is_directory() {
261        let Some(mut fs) = open_forensic() else {
262            eprintln!("skip");
263            return;
264        };
265        let meta = fs.metadata(2).unwrap();
266        assert_eq!(meta.file_type, FsFileType::Directory);
267        assert!(meta.allocated);
268    }
269
270    #[test]
271    fn read_file_returns_content() {
272        let Some(mut fs) = open_forensic() else {
273            eprintln!("skip");
274            return;
275        };
276        let ino = fs.lookup(2, b"hello.txt").unwrap().unwrap();
277        let data = fs.read_file(ino).unwrap();
278        assert!(String::from_utf8_lossy(&data).contains("Hello"));
279    }
280
281    #[test]
282    fn read_file_range_returns_prefix() {
283        let Some(mut fs) = open_forensic() else {
284            eprintln!("skip");
285            return;
286        };
287        let ino = fs.lookup(2, b"hello.txt").unwrap().unwrap();
288        let data = fs.read_file_range(ino, 0, 5).unwrap();
289        assert_eq!(&data, b"Hello");
290    }
291
292    #[test]
293    fn read_link_returns_target() {
294        let Some(mut fs) = open_forensic() else {
295            eprintln!("skip");
296            return;
297        };
298        let ino = fs.lookup(2, b"abs-link").unwrap().unwrap();
299        let target = fs.read_link(ino).unwrap();
300        assert_eq!(String::from_utf8_lossy(&target), "/hello.txt");
301    }
302
303    #[test]
304    fn deleted_inodes_found() {
305        let Some(mut fs) = open_forensic() else {
306            eprintln!("skip");
307            return;
308        };
309        let deleted = fs.deleted_inodes().unwrap();
310        assert!(deleted.len() >= 2);
311    }
312
313    #[test]
314    fn timeline_has_events() {
315        let Some(mut fs) = open_forensic() else {
316            eprintln!("skip");
317            return;
318        };
319        let events = fs.timeline().unwrap();
320        assert!(!events.is_empty());
321    }
322
323    #[test]
324    fn fs_info_is_ext4() {
325        let Some(fs) = open_forensic() else {
326            eprintln!("skip");
327            return;
328        };
329        let info = fs.fs_info().unwrap();
330        assert_eq!(info["filesystem"], "ext4");
331    }
332
333    #[test]
334    fn block_size_is_4096() {
335        let Some(fs) = open_forensic() else {
336            eprintln!("skip");
337            return;
338        };
339        assert_eq!(fs.block_size(), 4096);
340    }
341}