Skip to main content

ext4fs/
lib.rs

1#![forbid(unsafe_code)]
2
3pub mod block;
4pub mod dir;
5pub mod error;
6pub mod forensic;
7pub mod inode;
8pub mod ondisk;
9#[cfg(feature = "vfs")]
10pub mod vfs;
11
12use block::BlockReader;
13use dir::DirReader;
14use error::Result;
15use inode::InodeReader;
16use ondisk::{DirEntry, Inode, Superblock, Timestamp};
17use std::io::{Read, Seek};
18
19/// Full inode metadata for the public API.
20#[derive(Debug, Clone)]
21pub struct InodeMetadata {
22    pub ino: u64,
23    pub file_type: ondisk::FileType,
24    pub mode: u16,
25    pub uid: u32,
26    pub gid: u32,
27    pub size: u64,
28    pub links_count: u16,
29    pub atime: Timestamp,
30    pub mtime: Timestamp,
31    pub ctime: Timestamp,
32    pub crtime: Timestamp,
33    pub dtime: u32,
34    pub flags: ondisk::InodeFlags,
35    pub generation: u32,
36    pub allocated: bool,
37}
38
39/// Forensic-grade ext4 filesystem reader.
40///
41/// Accepts any `Read + Seek` source (raw image file, EWF reader, etc.).
42/// Provides both standard filesystem access (tier 1) and forensic operations (tier 2).
43pub struct Ext4Fs<R: Read + Seek> {
44    dir_reader: DirReader<R>,
45}
46
47impl<R: Read + Seek> Ext4Fs<R> {
48    /// Open an ext4 filesystem from a Read+Seek source.
49    pub fn open(source: R) -> Result<Self> {
50        let block_reader = BlockReader::open(source)?;
51        let inode_reader = InodeReader::new(block_reader);
52        let dir_reader = DirReader::new(inode_reader);
53        Ok(Ext4Fs { dir_reader })
54    }
55
56    // --- Tier 1: Standard filesystem access ---
57
58    /// Reference to the superblock.
59    pub fn superblock(&self) -> &Superblock {
60        self.dir_reader.inode_reader().block_reader().superblock()
61    }
62
63    /// Read a file's contents by path.
64    pub fn read_file(&mut self, path: &str) -> Result<Vec<u8>> {
65        let ino = self.dir_reader.resolve_path(path)?;
66        self.dir_reader.inode_reader_mut().read_inode_data(ino)
67    }
68
69    /// List directory entries by path.
70    pub fn read_dir(&mut self, path: &str) -> Result<Vec<DirEntry>> {
71        let ino = self.dir_reader.resolve_path(path)?;
72        self.dir_reader.read_dir(ino)
73    }
74
75    /// Get full metadata for a path.
76    pub fn metadata(&mut self, path: &str) -> Result<InodeMetadata> {
77        let ino = self.dir_reader.resolve_path(path)?;
78        let inode = self.dir_reader.inode_reader_mut().read_inode(ino)?;
79        let allocated = self.dir_reader.inode_reader_mut().is_inode_allocated(ino)?;
80        Ok(InodeMetadata {
81            ino,
82            file_type: inode.file_type(),
83            mode: inode.mode,
84            uid: inode.uid,
85            gid: inode.gid,
86            size: inode.size,
87            links_count: inode.links_count,
88            atime: inode.atime,
89            mtime: inode.mtime,
90            ctime: inode.ctime,
91            crtime: inode.crtime,
92            dtime: inode.dtime,
93            flags: inode.flags,
94            generation: inode.generation,
95            allocated,
96        })
97    }
98
99    /// Read a symlink's target by path.
100    pub fn symlink_target(&mut self, path: &str) -> Result<Vec<u8>> {
101        let ino = self.dir_reader.resolve_path(path)?;
102        self.dir_reader.read_link(ino)
103    }
104
105    /// Check if a path exists.
106    pub fn exists(&mut self, path: &str) -> Result<bool> {
107        match self.dir_reader.resolve_path(path) {
108            Ok(_) => Ok(true),
109            Err(error::Ext4Error::PathNotFound(_)) => Ok(false),
110            Err(e) => Err(e),
111        }
112    }
113
114    // --- Tier 1b: Inode-based access (for FUSE) ---
115
116    /// List directory entries by inode number.
117    pub fn read_dir_by_ino(&mut self, dir_ino: u64) -> Result<Vec<DirEntry>> {
118        self.dir_reader.read_dir(dir_ino)
119    }
120
121    /// Lookup a name inside a directory by inode number.
122    pub fn lookup_by_ino(&mut self, dir_ino: u64, name: &[u8]) -> Result<Option<u64>> {
123        self.dir_reader.lookup(dir_ino, name)
124    }
125
126    /// Read symlink target by inode number.
127    pub fn read_link_by_ino(&mut self, ino: u64) -> Result<Vec<u8>> {
128        self.dir_reader.read_link(ino)
129    }
130
131    /// Read file data by inode number.
132    pub fn read_inode_data(&mut self, ino: u64) -> Result<Vec<u8>> {
133        self.dir_reader.inode_reader_mut().read_inode_data(ino)
134    }
135
136    /// Read a range of file data by inode number.
137    pub fn read_inode_data_range(&mut self, ino: u64, offset: u64, len: usize) -> Result<Vec<u8>> {
138        self.dir_reader
139            .inode_reader_mut()
140            .read_inode_data_range(ino, offset, len)
141    }
142
143    // --- Tier 2: Forensic access ---
144
145    /// Read any inode by number.
146    pub fn inode(&mut self, ino: u64) -> Result<Inode> {
147        self.dir_reader.inode_reader_mut().read_inode(ino)
148    }
149
150    /// Enumerate all inodes (allocated and deleted).
151    pub fn all_inodes(&mut self) -> Result<Vec<(u64, Inode)>> {
152        self.dir_reader.inode_reader_mut().iter_all_inodes()
153    }
154
155    /// Find all deleted inodes (dtime != 0).
156    pub fn deleted_inodes(&mut self) -> Result<Vec<forensic::DeletedInode>> {
157        forensic::find_deleted_inodes(self.dir_reader.inode_reader_mut())
158    }
159
160    /// Find all orphan inodes (links_count == 0, dtime == 0, mode != 0).
161    pub fn orphan_inodes(&mut self) -> Result<Vec<forensic::DeletedInode>> {
162        forensic::find_orphan_inodes(self.dir_reader.inode_reader_mut())
163    }
164
165    /// Attempt to recover a deleted file by inode number.
166    pub fn recover_file(&mut self, ino: u64) -> Result<forensic::RecoveryResult> {
167        forensic::recovery::recover_file(self.dir_reader.inode_reader_mut(), ino)
168    }
169
170    /// Parse the jbd2 journal.
171    pub fn journal(&mut self) -> Result<forensic::Journal> {
172        forensic::journal::parse_journal(self.dir_reader.inode_reader_mut())
173    }
174
175    /// Generate a forensic timeline of all filesystem events.
176    pub fn timeline(&mut self) -> Result<Vec<forensic::TimelineEvent>> {
177        forensic::timeline::generate_timeline(self.dir_reader.inode_reader_mut())
178    }
179
180    /// Read block-stored extended attributes for an inode.
181    pub fn xattrs(&mut self, ino: u64) -> Result<Vec<forensic::Xattr>> {
182        forensic::xattr::read_xattrs(self.dir_reader.inode_reader_mut(), ino)
183    }
184
185    /// Get all unallocated block ranges.
186    pub fn unallocated_blocks(&mut self) -> Result<Vec<forensic::BlockRange>> {
187        forensic::carving::unallocated_blocks(self.dir_reader.inode_reader_mut())
188    }
189
190    /// Read raw data from an unallocated block range.
191    pub fn read_unallocated(&mut self, range: &forensic::BlockRange) -> Result<Vec<u8>> {
192        forensic::carving::read_unallocated(self.dir_reader.inode_reader_mut(), range)
193    }
194
195    /// Read slack space for a single file inode.
196    pub fn slack_space(&mut self, ino: u64) -> Result<Option<forensic::SlackSpace>> {
197        forensic::slack::read_slack_space(self.dir_reader.inode_reader_mut(), ino)
198    }
199
200    /// Scan all allocated regular file inodes for slack space.
201    pub fn scan_all_slack(&mut self) -> Result<Vec<forensic::SlackSpace>> {
202        forensic::slack::scan_all_slack(self.dir_reader.inode_reader_mut())
203    }
204
205    /// Compute BLAKE3, SHA-256, MD5, and SHA-1 hashes for a file by inode number.
206    #[cfg(feature = "hashing")]
207    pub fn hash_file(&mut self, ino: u64) -> Result<forensic::FileHash> {
208        forensic::hash::hash_file(self.dir_reader.inode_reader_mut(), ino)
209    }
210
211    /// Hash all allocated regular files on the filesystem.
212    #[cfg(feature = "hashing")]
213    pub fn hash_all_files(&mut self) -> Result<Vec<forensic::FileHash>> {
214        forensic::hash::hash_all_files(self.dir_reader.inode_reader_mut())
215    }
216
217    /// Reconstruct the version history of an inode from the journal.
218    pub fn inode_history(&mut self, ino: u64) -> Result<Vec<forensic::HistoryVersion>> {
219        let journal = self.journal()?;
220        forensic::history::inode_history(self.dir_reader.inode_reader_mut(), &journal, ino)
221    }
222
223    /// Recover deleted directory entries from rec_len gaps in a single directory.
224    pub fn recover_dir_entries(
225        &mut self,
226        dir_ino: u64,
227    ) -> Result<Vec<forensic::RecoveredDirEntry>> {
228        forensic::dir_recovery::recover_dir_entries(self.dir_reader.inode_reader_mut(), dir_ino)
229    }
230
231    /// Recover deleted directory entries from all directories on the filesystem.
232    pub fn recover_all_dir_entries(&mut self) -> Result<Vec<forensic::RecoveredDirEntry>> {
233        forensic::dir_recovery::recover_all_dir_entries(self.dir_reader.inode_reader_mut())
234    }
235
236    /// Check if a specific inode is allocated.
237    pub fn is_inode_allocated(&mut self, ino: u64) -> Result<bool> {
238        self.dir_reader.inode_reader_mut().is_inode_allocated(ino)
239    }
240
241    /// Check if a specific block is allocated.
242    pub fn is_block_allocated(&mut self, block: u64) -> Result<bool> {
243        self.dir_reader.inode_reader_mut().is_block_allocated(block)
244    }
245
246    /// Read a raw block by number.
247    pub fn read_block(&mut self, block: u64) -> Result<Vec<u8>> {
248        self.dir_reader
249            .inode_reader_mut()
250            .block_reader_mut()
251            .read_block(block)
252    }
253
254    /// Verify all superblock backups against the primary.
255    pub fn verify_superblock_backups(&mut self) -> Result<Vec<forensic::SuperblockComparison>> {
256        forensic::superblock_verify::verify_superblock_backups(self.dir_reader.inode_reader_mut())
257    }
258
259    /// Search for a byte pattern across filesystem blocks.
260    pub fn search_blocks(
261        &mut self,
262        pattern: &[u8],
263        scope: forensic::SearchScope,
264    ) -> Result<Vec<forensic::SearchHit>> {
265        forensic::search::search_blocks(
266            self.dir_reader.inode_reader_mut(),
267            pattern,
268            scope,
269            32, // default context bytes
270        )
271    }
272}
273
274#[cfg(feature = "ewf")]
275impl Ext4Fs<ewf::EwfReader> {
276    /// Open an ext4 filesystem from an E01/EWF forensic disk image.
277    ///
278    /// This is a convenience method that opens the EWF image and passes
279    /// the reader to `Ext4Fs::open()`.
280    pub fn open_ewf<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
281        let reader = ewf::EwfReader::open(path.as_ref())
282            .map_err(|e| error::Ext4Error::Io(std::io::Error::other(e.to_string())))?;
283        Self::open(reader)
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use std::io::Cursor;
291
292    fn open_minimal() -> Option<Ext4Fs<Cursor<Vec<u8>>>> {
293        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/minimal.img");
294        let data = std::fs::read(path).ok()?;
295        Ext4Fs::open(Cursor::new(data)).ok()
296    }
297
298    #[test]
299    fn open_and_read_superblock() {
300        let fs = if let Some(f) = open_minimal() {
301            f
302        } else {
303            eprintln!("skip");
304            return;
305        };
306        assert_eq!(fs.superblock().magic, 0xEF53);
307    }
308
309    #[test]
310    fn read_file_by_path() {
311        let mut fs = if let Some(f) = open_minimal() {
312            f
313        } else {
314            eprintln!("skip");
315            return;
316        };
317        let data = fs.read_file("/hello.txt").unwrap();
318        assert_eq!(data, b"Hello, ext4!");
319    }
320
321    #[test]
322    fn read_nested_file() {
323        let mut fs = if let Some(f) = open_minimal() {
324            f
325        } else {
326            eprintln!("skip");
327            return;
328        };
329        let data = fs.read_file("/subdir/nested.txt").unwrap();
330        assert_eq!(data, b"Nested file");
331    }
332
333    #[test]
334    fn list_root_directory() {
335        let mut fs = if let Some(f) = open_minimal() {
336            f
337        } else {
338            eprintln!("skip");
339            return;
340        };
341        let entries = fs.read_dir("/").unwrap();
342        let names: Vec<String> = entries
343            .iter()
344            .map(super::ondisk::dir_entry::DirEntry::name_str)
345            .collect();
346        assert!(names.contains(&"hello.txt".to_string()));
347        assert!(names.contains(&"subdir".to_string()));
348    }
349
350    #[test]
351    fn file_metadata() {
352        let mut fs = if let Some(f) = open_minimal() {
353            f
354        } else {
355            eprintln!("skip");
356            return;
357        };
358        let meta = fs.metadata("/hello.txt").unwrap();
359        assert_eq!(meta.file_type, ondisk::FileType::RegularFile);
360        assert_eq!(meta.size, 12);
361        assert!(meta.mtime.seconds > 0);
362    }
363
364    #[test]
365    fn exists_check() {
366        let mut fs = if let Some(f) = open_minimal() {
367            f
368        } else {
369            eprintln!("skip");
370            return;
371        };
372        assert!(fs.exists("/hello.txt").unwrap());
373        assert!(!fs.exists("/nonexistent").unwrap());
374    }
375
376    #[test]
377    fn all_inodes() {
378        let mut fs = if let Some(f) = open_minimal() {
379            f
380        } else {
381            eprintln!("skip");
382            return;
383        };
384        let inodes = fs.all_inodes().unwrap();
385        assert!(!inodes.is_empty());
386    }
387
388    #[test]
389    fn deleted_inodes_on_fresh_image() {
390        let mut fs = if let Some(f) = open_minimal() {
391            f
392        } else {
393            eprintln!("skip");
394            return;
395        };
396        let deleted = fs.deleted_inodes().unwrap();
397        assert!(deleted.is_empty());
398    }
399
400    #[test]
401    fn timeline_generation() {
402        let mut fs = if let Some(f) = open_minimal() {
403            f
404        } else {
405            eprintln!("skip");
406            return;
407        };
408        let events = fs.timeline().unwrap();
409        assert!(!events.is_empty());
410    }
411
412    #[test]
413    fn unallocated_blocks_exist() {
414        let mut fs = if let Some(f) = open_minimal() {
415            f
416        } else {
417            eprintln!("skip");
418            return;
419        };
420        let ranges = fs.unallocated_blocks().unwrap();
421        assert!(!ranges.is_empty());
422    }
423
424    // --- forensic.img tests ---
425
426    fn open_forensic() -> Option<Ext4Fs<Cursor<Vec<u8>>>> {
427        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/forensic.img");
428        let data = std::fs::read(path).ok()?;
429        Ext4Fs::open(Cursor::new(data)).ok()
430    }
431
432    #[test]
433    fn symlink_target_resolves_through_symlink() {
434        // symlink_target() calls resolve_path() which follows symlinks,
435        // so /abs-link resolves to hello.txt (inode 12), not the symlink itself.
436        // Verify the method returns NotASymlink for a followed-through path.
437        let mut fs = if let Some(f) = open_forensic() {
438            f
439        } else {
440            eprintln!("skip: forensic.img not found");
441            return;
442        };
443        let err = fs.symlink_target("/abs-link").unwrap_err();
444        assert!(
445            format!("{err:?}").contains("NotASymlink"),
446            "symlink_target on a followed symlink should return NotASymlink, got: {err:?}"
447        );
448    }
449
450    #[test]
451    fn inode_root_is_directory() {
452        let mut fs = if let Some(f) = open_forensic() {
453            f
454        } else {
455            eprintln!("skip: forensic.img not found");
456            return;
457        };
458        let root = fs.inode(2).unwrap();
459        assert_eq!(
460            root.file_type(),
461            ondisk::FileType::Directory,
462            "inode 2 should be a directory"
463        );
464    }
465
466    #[test]
467    fn orphan_inodes_returns_ok() {
468        let mut fs = if let Some(f) = open_forensic() {
469            f
470        } else {
471            eprintln!("skip: forensic.img not found");
472            return;
473        };
474        let result = fs.orphan_inodes();
475        assert!(result.is_ok(), "orphan_inodes should not error");
476    }
477
478    #[test]
479    fn recover_file_deleted_inode() {
480        let mut fs = if let Some(f) = open_forensic() {
481            f
482        } else {
483            eprintln!("skip: forensic.img not found");
484            return;
485        };
486        let result = fs.recover_file(21);
487        assert!(
488            result.is_ok(),
489            "recover_file(21) should return a RecoveryResult"
490        );
491    }
492
493    #[test]
494    fn journal_has_transactions() {
495        let mut fs = if let Some(f) = open_forensic() {
496            f
497        } else {
498            eprintln!("skip: forensic.img not found");
499            return;
500        };
501        let journal = fs.journal().unwrap();
502        assert!(
503            !journal.transactions.is_empty(),
504            "journal should have transactions"
505        );
506    }
507
508    #[test]
509    fn xattrs_on_hello_txt() {
510        let mut fs = if let Some(f) = open_forensic() {
511            f
512        } else {
513            eprintln!("skip: forensic.img not found");
514            return;
515        };
516        let attrs = fs.xattrs(12).unwrap();
517        let names: Vec<String> = attrs
518            .iter()
519            .map(|a| String::from_utf8_lossy(&a.name).to_string())
520            .collect();
521        assert!(
522            names.iter().any(|n| n.contains("forensic")),
523            "inode 12 should have user.forensic xattr, got: {names:?}"
524        );
525    }
526
527    #[test]
528    fn read_unallocated_first_range() {
529        let mut fs = if let Some(f) = open_forensic() {
530            f
531        } else {
532            eprintln!("skip: forensic.img not found");
533            return;
534        };
535        let ranges = fs.unallocated_blocks().unwrap();
536        assert!(!ranges.is_empty(), "should have unallocated ranges");
537        let data = fs.read_unallocated(&ranges[0]).unwrap();
538        assert!(!data.is_empty(), "unallocated data should not be empty");
539    }
540
541    #[test]
542    fn is_inode_allocated_root() {
543        let mut fs = if let Some(f) = open_forensic() {
544            f
545        } else {
546            eprintln!("skip: forensic.img not found");
547            return;
548        };
549        assert!(
550            fs.is_inode_allocated(2).unwrap(),
551            "root inode 2 should be allocated"
552        );
553        // inode 21 is deleted, may or may not be allocated depending on reuse
554        let _alloc21 = fs.is_inode_allocated(21).unwrap();
555    }
556
557    #[test]
558    fn is_block_allocated_zero() {
559        let mut fs = if let Some(f) = open_forensic() {
560            f
561        } else {
562            eprintln!("skip: forensic.img not found");
563            return;
564        };
565        let allocated = fs.is_block_allocated(0).unwrap();
566        assert!(allocated, "block 0 (superblock) should be allocated");
567    }
568
569    #[test]
570    fn read_block_zero() {
571        let mut fs = if let Some(f) = open_forensic() {
572            f
573        } else {
574            eprintln!("skip: forensic.img not found");
575            return;
576        };
577        let block_size = fs.superblock().block_size as usize;
578        let data = fs.read_block(0).unwrap();
579        assert_eq!(data.len(), block_size, "block 0 should be block_size bytes");
580    }
581
582    #[test]
583    fn read_dir_by_ino_root() {
584        let mut fs = if let Some(f) = open_forensic() {
585            f
586        } else {
587            eprintln!("skip: forensic.img not found");
588            return;
589        };
590        let entries = fs.read_dir_by_ino(2).unwrap();
591        let names: Vec<String> = entries
592            .iter()
593            .map(super::ondisk::dir_entry::DirEntry::name_str)
594            .collect();
595        assert!(
596            names.contains(&"hello.txt".to_string()),
597            "root dir should contain hello.txt, got: {names:?}"
598        );
599    }
600
601    #[test]
602    fn lookup_by_ino_hello_txt() {
603        let mut fs = if let Some(f) = open_forensic() {
604            f
605        } else {
606            eprintln!("skip: forensic.img not found");
607            return;
608        };
609        let result = fs.lookup_by_ino(2, b"hello.txt").unwrap();
610        assert!(result.is_some(), "hello.txt should exist in root dir");
611    }
612
613    #[test]
614    fn read_link_by_ino_abs_link() {
615        let mut fs = if let Some(f) = open_forensic() {
616            f
617        } else {
618            eprintln!("skip: forensic.img not found");
619            return;
620        };
621        // First resolve abs-link's inode
622        let ino = fs
623            .lookup_by_ino(2, b"abs-link")
624            .unwrap()
625            .expect("abs-link should exist in root");
626        let target = fs.read_link_by_ino(ino).unwrap();
627        assert_eq!(target, b"/hello.txt", "abs-link should point to /hello.txt");
628    }
629
630    #[test]
631    fn read_inode_data_hello_txt() {
632        let mut fs = if let Some(f) = open_forensic() {
633            f
634        } else {
635            eprintln!("skip: forensic.img not found");
636            return;
637        };
638        let data = fs.read_inode_data(12).unwrap();
639        let text = std::str::from_utf8(&data).unwrap_or("");
640        assert!(
641            text.contains("Hello"),
642            "inode 12 (hello.txt) should contain 'Hello', got: {text:?}"
643        );
644    }
645
646    #[test]
647    fn read_inode_data_range_hello_txt() {
648        let mut fs = if let Some(f) = open_forensic() {
649            f
650        } else {
651            eprintln!("skip: forensic.img not found");
652            return;
653        };
654        let data = fs.read_inode_data_range(12, 0, 5).unwrap();
655        assert_eq!(
656            data, b"Hello",
657            "first 5 bytes of hello.txt should be 'Hello'"
658        );
659    }
660
661    #[test]
662    fn slack_space_api() {
663        let mut fs = if let Some(f) = open_forensic() {
664            f
665        } else {
666            eprintln!("skip: forensic.img not found");
667            return;
668        };
669        // Find a regular file inode with non-block-aligned size
670        let all = fs.all_inodes().unwrap();
671        let block_size = u64::from(fs.superblock().block_size);
672        let file_ino = all
673            .iter()
674            .find(|(_, inode)| {
675                inode.file_type() == ondisk::FileType::RegularFile
676                    && inode.size > 0
677                    && inode.size % block_size != 0
678            })
679            .map(|(ino, _)| *ino)
680            .expect("forensic.img should have a non-block-aligned regular file");
681        let slack = fs.slack_space(file_ino).unwrap();
682        assert!(
683            slack.is_some(),
684            "non-block-aligned file should have slack space"
685        );
686    }
687
688    #[test]
689    fn hash_file_api() {
690        let mut fs = if let Some(f) = open_forensic() {
691            f
692        } else {
693            eprintln!("skip");
694            return;
695        };
696        let hash = fs.hash_file(12).unwrap();
697        assert_eq!(hash.md5.len(), 32);
698    }
699
700    #[test]
701    fn recover_dir_entries_api() {
702        let mut fs = if let Some(f) = open_forensic() {
703            f
704        } else {
705            eprintln!("skip");
706            return;
707        };
708        let recovered = fs.recover_dir_entries(2).unwrap();
709        let _ = recovered; // may be empty depending on kernel behavior
710    }
711
712    #[test]
713    fn recover_all_dir_entries_api() {
714        let mut fs = if let Some(f) = open_forensic() {
715            f
716        } else {
717            eprintln!("skip");
718            return;
719        };
720        let all = fs.recover_all_dir_entries().unwrap();
721        let _ = all;
722    }
723
724    #[test]
725    fn hash_all_files_api() {
726        let mut fs = if let Some(f) = open_forensic() {
727            f
728        } else {
729            eprintln!("skip");
730            return;
731        };
732        let hashes = fs.hash_all_files().unwrap();
733        assert!(!hashes.is_empty());
734    }
735
736    #[test]
737    fn inode_history_api() {
738        let mut fs = if let Some(f) = open_forensic() {
739            f
740        } else {
741            eprintln!("skip");
742            return;
743        };
744        let versions = fs.inode_history(12).unwrap();
745        // Should not error
746        let _ = versions;
747    }
748
749    #[test]
750    fn scan_all_slack_api() {
751        let mut fs = if let Some(f) = open_forensic() {
752            f
753        } else {
754            eprintln!("skip: forensic.img not found");
755            return;
756        };
757        let slacks = fs.scan_all_slack().unwrap();
758        assert!(
759            !slacks.is_empty(),
760            "forensic.img should have files with slack"
761        );
762    }
763
764    #[test]
765    fn verify_superblock_backups_api() {
766        let mut fs = if let Some(f) = open_forensic() {
767            f
768        } else {
769            eprintln!("skip");
770            return;
771        };
772        let results = fs.verify_superblock_backups().unwrap();
773        let _ = results; // small image may have no backups
774    }
775
776    #[test]
777    fn search_blocks_api() {
778        let mut fs = if let Some(f) = open_forensic() {
779            f
780        } else {
781            eprintln!("skip");
782            return;
783        };
784        let hits = fs
785            .search_blocks(b"Hello", forensic::SearchScope::Allocated)
786            .unwrap();
787        assert!(!hits.is_empty());
788    }
789
790    #[cfg(feature = "ewf")]
791    mod ewf_tests {
792        use super::*;
793
794        #[test]
795        fn open_ewf_with_valid_e01() {
796            // We don't have a real E01 file in test data, so test that the
797            // method exists and returns an appropriate error for a non-E01 file
798            let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/forensic.img");
799            let result = Ext4Fs::open_ewf(path);
800            // forensic.img is a raw image, not E01 — ewf should fail to open it
801            assert!(result.is_err(), "raw image should not open as E01");
802        }
803
804        #[test]
805        fn open_ewf_nonexistent_file() {
806            let result = Ext4Fs::open_ewf("/nonexistent/file.E01");
807            assert!(result.is_err());
808        }
809    }
810}