#![forbid(unsafe_code)]
pub mod block;
pub mod dir;
pub mod error;
pub mod forensic;
pub mod inode;
pub mod ondisk;
#[cfg(feature = "vfs")]
pub mod vfs;
use block::BlockReader;
use dir::DirReader;
use error::Result;
use inode::InodeReader;
use ondisk::{DirEntry, Inode, Superblock, Timestamp};
use std::io::{Read, Seek};
#[derive(Debug, Clone)]
pub struct InodeMetadata {
pub ino: u64,
pub file_type: ondisk::FileType,
pub mode: u16,
pub uid: u32,
pub gid: u32,
pub size: u64,
pub links_count: u16,
pub atime: Timestamp,
pub mtime: Timestamp,
pub ctime: Timestamp,
pub crtime: Timestamp,
pub dtime: u32,
pub flags: ondisk::InodeFlags,
pub generation: u32,
pub allocated: bool,
}
pub struct Ext4Fs<R: Read + Seek> {
dir_reader: DirReader<R>,
}
impl<R: Read + Seek> Ext4Fs<R> {
pub fn open(source: R) -> Result<Self> {
let block_reader = BlockReader::open(source)?;
let inode_reader = InodeReader::new(block_reader);
let dir_reader = DirReader::new(inode_reader);
Ok(Ext4Fs { dir_reader })
}
pub fn superblock(&self) -> &Superblock {
self.dir_reader.inode_reader().block_reader().superblock()
}
pub fn read_file(&mut self, path: &str) -> Result<Vec<u8>> {
let ino = self.dir_reader.resolve_path(path)?;
self.dir_reader.inode_reader_mut().read_inode_data(ino)
}
pub fn read_dir(&mut self, path: &str) -> Result<Vec<DirEntry>> {
let ino = self.dir_reader.resolve_path(path)?;
self.dir_reader.read_dir(ino)
}
pub fn metadata(&mut self, path: &str) -> Result<InodeMetadata> {
let ino = self.dir_reader.resolve_path(path)?;
let inode = self.dir_reader.inode_reader_mut().read_inode(ino)?;
let allocated = self.dir_reader.inode_reader_mut().is_inode_allocated(ino)?;
Ok(InodeMetadata {
ino,
file_type: inode.file_type(),
mode: inode.mode,
uid: inode.uid,
gid: inode.gid,
size: inode.size,
links_count: inode.links_count,
atime: inode.atime,
mtime: inode.mtime,
ctime: inode.ctime,
crtime: inode.crtime,
dtime: inode.dtime,
flags: inode.flags,
generation: inode.generation,
allocated,
})
}
pub fn symlink_target(&mut self, path: &str) -> Result<Vec<u8>> {
let ino = self.dir_reader.resolve_path(path)?;
self.dir_reader.read_link(ino)
}
pub fn exists(&mut self, path: &str) -> Result<bool> {
match self.dir_reader.resolve_path(path) {
Ok(_) => Ok(true),
Err(error::Ext4Error::PathNotFound(_)) => Ok(false),
Err(e) => Err(e),
}
}
pub fn read_dir_by_ino(&mut self, dir_ino: u64) -> Result<Vec<DirEntry>> {
self.dir_reader.read_dir(dir_ino)
}
pub fn lookup_by_ino(&mut self, dir_ino: u64, name: &[u8]) -> Result<Option<u64>> {
self.dir_reader.lookup(dir_ino, name)
}
pub fn read_link_by_ino(&mut self, ino: u64) -> Result<Vec<u8>> {
self.dir_reader.read_link(ino)
}
pub fn read_inode_data(&mut self, ino: u64) -> Result<Vec<u8>> {
self.dir_reader.inode_reader_mut().read_inode_data(ino)
}
pub fn read_inode_data_range(&mut self, ino: u64, offset: u64, len: usize) -> Result<Vec<u8>> {
self.dir_reader
.inode_reader_mut()
.read_inode_data_range(ino, offset, len)
}
pub fn inode(&mut self, ino: u64) -> Result<Inode> {
self.dir_reader.inode_reader_mut().read_inode(ino)
}
pub fn all_inodes(&mut self) -> Result<Vec<(u64, Inode)>> {
self.dir_reader.inode_reader_mut().iter_all_inodes()
}
pub fn deleted_inodes(&mut self) -> Result<Vec<forensic::DeletedInode>> {
forensic::find_deleted_inodes(self.dir_reader.inode_reader_mut())
}
pub fn orphan_inodes(&mut self) -> Result<Vec<forensic::DeletedInode>> {
forensic::find_orphan_inodes(self.dir_reader.inode_reader_mut())
}
pub fn recover_file(&mut self, ino: u64) -> Result<forensic::RecoveryResult> {
forensic::recovery::recover_file(self.dir_reader.inode_reader_mut(), ino)
}
pub fn journal(&mut self) -> Result<forensic::Journal> {
forensic::journal::parse_journal(self.dir_reader.inode_reader_mut())
}
pub fn timeline(&mut self) -> Result<Vec<forensic::TimelineEvent>> {
forensic::timeline::generate_timeline(self.dir_reader.inode_reader_mut())
}
pub fn xattrs(&mut self, ino: u64) -> Result<Vec<forensic::Xattr>> {
forensic::xattr::read_xattrs(self.dir_reader.inode_reader_mut(), ino)
}
pub fn unallocated_blocks(&mut self) -> Result<Vec<forensic::BlockRange>> {
forensic::carving::unallocated_blocks(self.dir_reader.inode_reader_mut())
}
pub fn read_unallocated(&mut self, range: &forensic::BlockRange) -> Result<Vec<u8>> {
forensic::carving::read_unallocated(self.dir_reader.inode_reader_mut(), range)
}
pub fn slack_space(&mut self, ino: u64) -> Result<Option<forensic::SlackSpace>> {
forensic::slack::read_slack_space(self.dir_reader.inode_reader_mut(), ino)
}
pub fn scan_all_slack(&mut self) -> Result<Vec<forensic::SlackSpace>> {
forensic::slack::scan_all_slack(self.dir_reader.inode_reader_mut())
}
#[cfg(feature = "hashing")]
pub fn hash_file(&mut self, ino: u64) -> Result<forensic::FileHash> {
forensic::hash::hash_file(self.dir_reader.inode_reader_mut(), ino)
}
#[cfg(feature = "hashing")]
pub fn hash_all_files(&mut self) -> Result<Vec<forensic::FileHash>> {
forensic::hash::hash_all_files(self.dir_reader.inode_reader_mut())
}
pub fn inode_history(&mut self, ino: u64) -> Result<Vec<forensic::HistoryVersion>> {
let journal = self.journal()?;
forensic::history::inode_history(self.dir_reader.inode_reader_mut(), &journal, ino)
}
pub fn recover_dir_entries(
&mut self,
dir_ino: u64,
) -> Result<Vec<forensic::RecoveredDirEntry>> {
forensic::dir_recovery::recover_dir_entries(self.dir_reader.inode_reader_mut(), dir_ino)
}
pub fn recover_all_dir_entries(&mut self) -> Result<Vec<forensic::RecoveredDirEntry>> {
forensic::dir_recovery::recover_all_dir_entries(self.dir_reader.inode_reader_mut())
}
pub fn is_inode_allocated(&mut self, ino: u64) -> Result<bool> {
self.dir_reader.inode_reader_mut().is_inode_allocated(ino)
}
pub fn is_block_allocated(&mut self, block: u64) -> Result<bool> {
self.dir_reader.inode_reader_mut().is_block_allocated(block)
}
pub fn read_block(&mut self, block: u64) -> Result<Vec<u8>> {
self.dir_reader
.inode_reader_mut()
.block_reader_mut()
.read_block(block)
}
pub fn verify_superblock_backups(&mut self) -> Result<Vec<forensic::SuperblockComparison>> {
forensic::superblock_verify::verify_superblock_backups(self.dir_reader.inode_reader_mut())
}
pub fn search_blocks(
&mut self,
pattern: &[u8],
scope: forensic::SearchScope,
) -> Result<Vec<forensic::SearchHit>> {
forensic::search::search_blocks(
self.dir_reader.inode_reader_mut(),
pattern,
scope,
32, )
}
}
#[cfg(feature = "ewf")]
impl Ext4Fs<ewf::EwfReader> {
pub fn open_ewf<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
let reader = ewf::EwfReader::open(path.as_ref())
.map_err(|e| error::Ext4Error::Io(std::io::Error::other(e.to_string())))?;
Self::open(reader)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn open_minimal() -> Option<Ext4Fs<Cursor<Vec<u8>>>> {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/minimal.img");
let data = std::fs::read(path).ok()?;
Ext4Fs::open(Cursor::new(data)).ok()
}
#[test]
fn open_and_read_superblock() {
let fs = if let Some(f) = open_minimal() {
f
} else {
eprintln!("skip");
return;
};
assert_eq!(fs.superblock().magic, 0xEF53);
}
#[test]
fn read_file_by_path() {
let mut fs = if let Some(f) = open_minimal() {
f
} else {
eprintln!("skip");
return;
};
let data = fs.read_file("/hello.txt").unwrap();
assert_eq!(data, b"Hello, ext4!");
}
#[test]
fn read_nested_file() {
let mut fs = if let Some(f) = open_minimal() {
f
} else {
eprintln!("skip");
return;
};
let data = fs.read_file("/subdir/nested.txt").unwrap();
assert_eq!(data, b"Nested file");
}
#[test]
fn list_root_directory() {
let mut fs = if let Some(f) = open_minimal() {
f
} else {
eprintln!("skip");
return;
};
let entries = fs.read_dir("/").unwrap();
let names: Vec<String> = entries
.iter()
.map(super::ondisk::dir_entry::DirEntry::name_str)
.collect();
assert!(names.contains(&"hello.txt".to_string()));
assert!(names.contains(&"subdir".to_string()));
}
#[test]
fn file_metadata() {
let mut fs = if let Some(f) = open_minimal() {
f
} else {
eprintln!("skip");
return;
};
let meta = fs.metadata("/hello.txt").unwrap();
assert_eq!(meta.file_type, ondisk::FileType::RegularFile);
assert_eq!(meta.size, 12);
assert!(meta.mtime.seconds > 0);
}
#[test]
fn exists_check() {
let mut fs = if let Some(f) = open_minimal() {
f
} else {
eprintln!("skip");
return;
};
assert!(fs.exists("/hello.txt").unwrap());
assert!(!fs.exists("/nonexistent").unwrap());
}
#[test]
fn all_inodes() {
let mut fs = if let Some(f) = open_minimal() {
f
} else {
eprintln!("skip");
return;
};
let inodes = fs.all_inodes().unwrap();
assert!(!inodes.is_empty());
}
#[test]
fn deleted_inodes_on_fresh_image() {
let mut fs = if let Some(f) = open_minimal() {
f
} else {
eprintln!("skip");
return;
};
let deleted = fs.deleted_inodes().unwrap();
assert!(deleted.is_empty());
}
#[test]
fn timeline_generation() {
let mut fs = if let Some(f) = open_minimal() {
f
} else {
eprintln!("skip");
return;
};
let events = fs.timeline().unwrap();
assert!(!events.is_empty());
}
#[test]
fn unallocated_blocks_exist() {
let mut fs = if let Some(f) = open_minimal() {
f
} else {
eprintln!("skip");
return;
};
let ranges = fs.unallocated_blocks().unwrap();
assert!(!ranges.is_empty());
}
fn open_forensic() -> Option<Ext4Fs<Cursor<Vec<u8>>>> {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/forensic.img");
let data = std::fs::read(path).ok()?;
Ext4Fs::open(Cursor::new(data)).ok()
}
#[test]
fn symlink_target_resolves_through_symlink() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip: forensic.img not found");
return;
};
let err = fs.symlink_target("/abs-link").unwrap_err();
assert!(
format!("{err:?}").contains("NotASymlink"),
"symlink_target on a followed symlink should return NotASymlink, got: {err:?}"
);
}
#[test]
fn inode_root_is_directory() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip: forensic.img not found");
return;
};
let root = fs.inode(2).unwrap();
assert_eq!(
root.file_type(),
ondisk::FileType::Directory,
"inode 2 should be a directory"
);
}
#[test]
fn orphan_inodes_returns_ok() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip: forensic.img not found");
return;
};
let result = fs.orphan_inodes();
assert!(result.is_ok(), "orphan_inodes should not error");
}
#[test]
fn recover_file_deleted_inode() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip: forensic.img not found");
return;
};
let result = fs.recover_file(21);
assert!(
result.is_ok(),
"recover_file(21) should return a RecoveryResult"
);
}
#[test]
fn journal_has_transactions() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip: forensic.img not found");
return;
};
let journal = fs.journal().unwrap();
assert!(
!journal.transactions.is_empty(),
"journal should have transactions"
);
}
#[test]
fn xattrs_on_hello_txt() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip: forensic.img not found");
return;
};
let attrs = fs.xattrs(12).unwrap();
let names: Vec<String> = attrs
.iter()
.map(|a| String::from_utf8_lossy(&a.name).to_string())
.collect();
assert!(
names.iter().any(|n| n.contains("forensic")),
"inode 12 should have user.forensic xattr, got: {names:?}"
);
}
#[test]
fn read_unallocated_first_range() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip: forensic.img not found");
return;
};
let ranges = fs.unallocated_blocks().unwrap();
assert!(!ranges.is_empty(), "should have unallocated ranges");
let data = fs.read_unallocated(&ranges[0]).unwrap();
assert!(!data.is_empty(), "unallocated data should not be empty");
}
#[test]
fn is_inode_allocated_root() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip: forensic.img not found");
return;
};
assert!(
fs.is_inode_allocated(2).unwrap(),
"root inode 2 should be allocated"
);
let _alloc21 = fs.is_inode_allocated(21).unwrap();
}
#[test]
fn is_block_allocated_zero() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip: forensic.img not found");
return;
};
let allocated = fs.is_block_allocated(0).unwrap();
assert!(allocated, "block 0 (superblock) should be allocated");
}
#[test]
fn read_block_zero() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip: forensic.img not found");
return;
};
let block_size = fs.superblock().block_size as usize;
let data = fs.read_block(0).unwrap();
assert_eq!(data.len(), block_size, "block 0 should be block_size bytes");
}
#[test]
fn read_dir_by_ino_root() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip: forensic.img not found");
return;
};
let entries = fs.read_dir_by_ino(2).unwrap();
let names: Vec<String> = entries
.iter()
.map(super::ondisk::dir_entry::DirEntry::name_str)
.collect();
assert!(
names.contains(&"hello.txt".to_string()),
"root dir should contain hello.txt, got: {names:?}"
);
}
#[test]
fn lookup_by_ino_hello_txt() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip: forensic.img not found");
return;
};
let result = fs.lookup_by_ino(2, b"hello.txt").unwrap();
assert!(result.is_some(), "hello.txt should exist in root dir");
}
#[test]
fn read_link_by_ino_abs_link() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip: forensic.img not found");
return;
};
let ino = fs
.lookup_by_ino(2, b"abs-link")
.unwrap()
.expect("abs-link should exist in root");
let target = fs.read_link_by_ino(ino).unwrap();
assert_eq!(target, b"/hello.txt", "abs-link should point to /hello.txt");
}
#[test]
fn read_inode_data_hello_txt() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip: forensic.img not found");
return;
};
let data = fs.read_inode_data(12).unwrap();
let text = std::str::from_utf8(&data).unwrap_or("");
assert!(
text.contains("Hello"),
"inode 12 (hello.txt) should contain 'Hello', got: {text:?}"
);
}
#[test]
fn read_inode_data_range_hello_txt() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip: forensic.img not found");
return;
};
let data = fs.read_inode_data_range(12, 0, 5).unwrap();
assert_eq!(
data, b"Hello",
"first 5 bytes of hello.txt should be 'Hello'"
);
}
#[test]
fn slack_space_api() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip: forensic.img not found");
return;
};
let all = fs.all_inodes().unwrap();
let block_size = u64::from(fs.superblock().block_size);
let file_ino = all
.iter()
.find(|(_, inode)| {
inode.file_type() == ondisk::FileType::RegularFile
&& inode.size > 0
&& inode.size % block_size != 0
})
.map(|(ino, _)| *ino)
.expect("forensic.img should have a non-block-aligned regular file");
let slack = fs.slack_space(file_ino).unwrap();
assert!(
slack.is_some(),
"non-block-aligned file should have slack space"
);
}
#[test]
fn hash_file_api() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip");
return;
};
let hash = fs.hash_file(12).unwrap();
assert_eq!(hash.md5.len(), 32);
}
#[test]
fn recover_dir_entries_api() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip");
return;
};
let recovered = fs.recover_dir_entries(2).unwrap();
let _ = recovered; }
#[test]
fn recover_all_dir_entries_api() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip");
return;
};
let all = fs.recover_all_dir_entries().unwrap();
let _ = all;
}
#[test]
fn hash_all_files_api() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip");
return;
};
let hashes = fs.hash_all_files().unwrap();
assert!(!hashes.is_empty());
}
#[test]
fn inode_history_api() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip");
return;
};
let versions = fs.inode_history(12).unwrap();
let _ = versions;
}
#[test]
fn scan_all_slack_api() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip: forensic.img not found");
return;
};
let slacks = fs.scan_all_slack().unwrap();
assert!(
!slacks.is_empty(),
"forensic.img should have files with slack"
);
}
#[test]
fn verify_superblock_backups_api() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip");
return;
};
let results = fs.verify_superblock_backups().unwrap();
let _ = results; }
#[test]
fn search_blocks_api() {
let mut fs = if let Some(f) = open_forensic() {
f
} else {
eprintln!("skip");
return;
};
let hits = fs
.search_blocks(b"Hello", forensic::SearchScope::Allocated)
.unwrap();
assert!(!hits.is_empty());
}
#[cfg(feature = "ewf")]
mod ewf_tests {
use super::*;
#[test]
fn open_ewf_with_valid_e01() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/forensic.img");
let result = Ext4Fs::open_ewf(path);
assert!(result.is_err(), "raw image should not open as E01");
}
#[test]
fn open_ewf_nonexistent_file() {
let result = Ext4Fs::open_ewf("/nonexistent/file.E01");
assert!(result.is_err());
}
}
}