1#![forbid(unsafe_code)]
2#![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#[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
42pub struct Ext4Fs<R: Read + Seek> {
47 dir_reader: DirReader<R>,
48}
49
50impl<R: Read + Seek> Ext4Fs<R> {
51 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 pub fn superblock(&self) -> &Superblock {
63 self.dir_reader.inode_reader().block_reader().superblock()
64 }
65
66 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 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 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 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 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 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 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 pub fn read_link_by_ino(&mut self, ino: u64) -> Result<Vec<u8>> {
131 self.dir_reader.read_link(ino)
132 }
133
134 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 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 pub fn inode(&mut self, ino: u64) -> Result<Inode> {
150 self.dir_reader.inode_reader_mut().read_inode(ino)
151 }
152
153 pub fn all_inodes(&mut self) -> Result<Vec<(u64, Inode)>> {
155 self.dir_reader.inode_reader_mut().iter_all_inodes()
156 }
157
158 pub fn deleted_inodes(&mut self) -> Result<Vec<forensic::DeletedInode>> {
160 forensic::find_deleted_inodes(self.dir_reader.inode_reader_mut())
161 }
162
163 pub fn orphan_inodes(&mut self) -> Result<Vec<forensic::DeletedInode>> {
165 forensic::find_orphan_inodes(self.dir_reader.inode_reader_mut())
166 }
167
168 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 pub fn journal(&mut self) -> Result<forensic::Journal> {
175 forensic::journal::parse_journal(self.dir_reader.inode_reader_mut())
176 }
177
178 pub fn timeline(&mut self) -> Result<Vec<forensic::TimelineEvent>> {
180 forensic::timeline::generate_timeline(self.dir_reader.inode_reader_mut())
181 }
182
183 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 pub fn unallocated_blocks(&mut self) -> Result<Vec<forensic::BlockRange>> {
190 forensic::carving::unallocated_blocks(self.dir_reader.inode_reader_mut())
191 }
192
193 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 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 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 #[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 #[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 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 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 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 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 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 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 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 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, )
274 }
275}
276
277#[cfg(feature = "ewf")]
278impl Ext4Fs<ewf::EwfReader> {
279 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 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 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 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 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 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; }
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 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; }
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 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/forensic.img");
802 let result = Ext4Fs::open_ewf(path);
803 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}