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