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#[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
39pub struct Ext4Fs<R: Read + Seek> {
44 dir_reader: DirReader<R>,
45}
46
47impl<R: Read + Seek> Ext4Fs<R> {
48 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 pub fn superblock(&self) -> &Superblock {
60 self.dir_reader.inode_reader().block_reader().superblock()
61 }
62
63 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 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 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 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 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 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 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 pub fn read_link_by_ino(&mut self, ino: u64) -> Result<Vec<u8>> {
128 self.dir_reader.read_link(ino)
129 }
130
131 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 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 pub fn inode(&mut self, ino: u64) -> Result<Inode> {
147 self.dir_reader.inode_reader_mut().read_inode(ino)
148 }
149
150 pub fn all_inodes(&mut self) -> Result<Vec<(u64, Inode)>> {
152 self.dir_reader.inode_reader_mut().iter_all_inodes()
153 }
154
155 pub fn deleted_inodes(&mut self) -> Result<Vec<forensic::DeletedInode>> {
157 forensic::find_deleted_inodes(self.dir_reader.inode_reader_mut())
158 }
159
160 pub fn orphan_inodes(&mut self) -> Result<Vec<forensic::DeletedInode>> {
162 forensic::find_orphan_inodes(self.dir_reader.inode_reader_mut())
163 }
164
165 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 pub fn journal(&mut self) -> Result<forensic::Journal> {
172 forensic::journal::parse_journal(self.dir_reader.inode_reader_mut())
173 }
174
175 pub fn timeline(&mut self) -> Result<Vec<forensic::TimelineEvent>> {
177 forensic::timeline::generate_timeline(self.dir_reader.inode_reader_mut())
178 }
179
180 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 pub fn unallocated_blocks(&mut self) -> Result<Vec<forensic::BlockRange>> {
187 forensic::carving::unallocated_blocks(self.dir_reader.inode_reader_mut())
188 }
189
190 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 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 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 #[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 #[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 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 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 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 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 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 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 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 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, )
271 }
272}
273
274#[cfg(feature = "ewf")]
275impl Ext4Fs<ewf::EwfReader> {
276 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 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 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 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 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 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; }
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 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; }
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 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/forensic.img");
799 let result = Ext4Fs::open_ewf(path);
800 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}