Skip to main content

ext4fs/
lib.rs

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