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#[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
37pub struct Ext4Fs<R: Read + Seek> {
42 dir_reader: DirReader<R>,
43}
44
45impl<R: Read + Seek> Ext4Fs<R> {
46 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 pub fn superblock(&self) -> &Superblock {
58 self.dir_reader.inode_reader().block_reader().superblock()
59 }
60
61 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 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 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 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 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 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 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 pub fn read_link_by_ino(&mut self, ino: u64) -> Result<Vec<u8>> {
126 self.dir_reader.read_link(ino)
127 }
128
129 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 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 pub fn inode(&mut self, ino: u64) -> Result<Inode> {
145 self.dir_reader.inode_reader_mut().read_inode(ino)
146 }
147
148 pub fn all_inodes(&mut self) -> Result<Vec<(u64, Inode)>> {
150 self.dir_reader.inode_reader_mut().iter_all_inodes()
151 }
152
153 pub fn deleted_inodes(&mut self) -> Result<Vec<forensic::DeletedInode>> {
155 forensic::find_deleted_inodes(self.dir_reader.inode_reader_mut())
156 }
157
158 pub fn orphan_inodes(&mut self) -> Result<Vec<forensic::DeletedInode>> {
160 forensic::find_orphan_inodes(self.dir_reader.inode_reader_mut())
161 }
162
163 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 pub fn journal(&mut self) -> Result<forensic::Journal> {
170 forensic::journal::parse_journal(self.dir_reader.inode_reader_mut())
171 }
172
173 pub fn timeline(&mut self) -> Result<Vec<forensic::TimelineEvent>> {
175 forensic::timeline::generate_timeline(self.dir_reader.inode_reader_mut())
176 }
177
178 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 pub fn unallocated_blocks(&mut self) -> Result<Vec<forensic::BlockRange>> {
185 forensic::carving::unallocated_blocks(self.dir_reader.inode_reader_mut())
186 }
187
188 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 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 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 #[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 #[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 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 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 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 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 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 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 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 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, )
269 }
270}
271
272#[cfg(feature = "ewf")]
273impl Ext4Fs<ewf::EwfReader> {
274 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 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 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 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 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 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; }
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 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; }
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 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/forensic.img");
801 let result = Ext4Fs::open_ewf(path);
802 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}