use std::io::{Read, Seek};
use forensic_vfs::{
Allocation, ByteRun, DirEntry as VfsDirEntry, DirStream, ExtentStream, FileId, FileSystem,
FsKind, FsMeta, MacbTimes, NodeKind, NodeStream, ResidencyKind, RunAlloc, RunFlags, RunInfo,
SectorSizes, SmallHex, StreamId, TimeResolution, TimeSource, TimeStamp, TimeZonePolicy,
VfsError, VfsResult,
};
use crate::error::Ext4Error;
use crate::ondisk::{DirEntryType, FileType, GroupDescFlags, Timestamp};
use crate::Ext4Fs;
const ROOT_INO: u64 = 2;
fn ino_of(id: FileId) -> VfsResult<u64> {
match id {
FileId::ExtInode { ino, .. } => Ok(ino),
other => Err(VfsError::Unsupported {
layer: "ext4 file-id",
scheme: format!("{other:?}"),
}),
}
}
fn require_default_stream(stream: StreamId) -> VfsResult<()> {
match stream {
StreamId::Default => Ok(()),
other => Err(VfsError::Unsupported {
layer: "ext4 stream",
scheme: format!("{other:?}"),
}),
}
}
fn map_err(e: Ext4Error) -> VfsError {
match e {
Ext4Error::Io(source) => VfsError::Io {
op: "ext4 read",
source,
},
Ext4Error::InodeOutOfRange { ino, max } => VfsError::OutOfRange {
what: "ext4 inode",
offset: ino,
len: 1,
bound: max,
},
Ext4Error::BlockOutOfRange { block, max } => VfsError::OutOfRange {
what: "ext4 block",
offset: block,
len: 1,
bound: max,
},
other => VfsError::Decode {
layer: "ext4",
offset: 0,
detail: other.to_string(),
bytes: SmallHex::new(&[]),
},
}
}
fn node_kind(ft: FileType) -> NodeKind {
match ft {
FileType::RegularFile => NodeKind::File,
FileType::Directory => NodeKind::Dir,
FileType::Symlink => NodeKind::Symlink,
FileType::CharDevice | FileType::BlockDevice => NodeKind::Device,
FileType::Fifo | FileType::Socket | FileType::Unknown => NodeKind::Other,
}
}
fn dirent_kind(dt: DirEntryType) -> NodeKind {
match dt {
DirEntryType::RegularFile => NodeKind::File,
DirEntryType::Directory => NodeKind::Dir,
DirEntryType::Symlink => NodeKind::Symlink,
DirEntryType::CharDevice | DirEntryType::BlockDevice => NodeKind::Device,
DirEntryType::Fifo | DirEntryType::Socket | DirEntryType::Unknown => NodeKind::Other,
}
}
struct GroupBitmap<'a> {
bitmap: Option<&'a [u8]>,
first_block: u64,
blocks_in_group: u64,
}
fn free_runs(bitmap: &[u8], blocks_in_group: u64) -> Vec<(u64, u64)> {
let mut runs = Vec::new();
let mut run_start: Option<u64> = None;
for bit in 0..blocks_in_group {
let allocated = match bitmap.get((bit / 8) as usize) {
Some(byte) => (byte >> (bit % 8)) & 1 == 1,
None => true, };
if allocated {
if let Some(start) = run_start.take() {
runs.push((start, bit - start));
}
} else if run_start.is_none() {
run_start = Some(bit);
}
}
if let Some(start) = run_start {
runs.push((start, blocks_in_group - start));
}
runs
}
fn unallocated_runs(groups: &[GroupBitmap<'_>], block_size: u64) -> Vec<ByteRun> {
let mut out = Vec::new();
for g in groups {
let group_runs = match g.bitmap {
Some(bm) => free_runs(bm, g.blocks_in_group),
None if g.blocks_in_group > 0 => vec![(0, g.blocks_in_group)],
None => Vec::new(),
};
for (start_bit, len) in group_runs {
let first_block = g.first_block.saturating_add(start_bit);
out.push(ByteRun {
image_offset: first_block.saturating_mul(block_size),
len: len.saturating_mul(block_size),
flags: RunFlags::default(),
});
}
}
out
}
impl<R: Read + Seek + Send> FileSystem for Ext4Fs<R> {
fn kind(&self) -> FsKind {
FsKind::EXT
}
fn root(&self) -> FileId {
let generation = self
.dir_reader
.inode_reader()
.read_inode(ROOT_INO)
.map_or(0, |i| i.generation);
FileId::ExtInode {
ino: ROOT_INO,
gen: generation,
}
}
fn sector_sizes(&self) -> SectorSizes {
let block_size = self.superblock().block_size;
SectorSizes {
logical: 512,
physical: 512,
cluster_or_block: block_size,
}
}
fn timestamp_zone(&self) -> TimeZonePolicy {
TimeZonePolicy::Utc
}
fn read_dir(&self, ino: FileId) -> VfsResult<DirStream> {
let dir_ino = ino_of(ino)?;
let entries = self.dir_reader.read_dir(dir_ino).map_err(map_err)?;
let out: Vec<VfsResult<VfsDirEntry>> = entries
.into_iter()
.map(|e| {
Ok(VfsDirEntry {
name: e.name,
id: FileId::ExtInode {
ino: u64::from(e.inode),
gen: 0,
},
kind: dirent_kind(e.file_type),
})
})
.collect();
Ok(DirStream::new(out.into_iter()))
}
fn extents(&self, ino: FileId, stream: StreamId) -> VfsResult<ExtentStream> {
let inode = ino_of(ino)?;
require_default_stream(stream)?;
let block_size = u64::from(self.superblock().block_size);
let map = self
.dir_reader
.inode_reader()
.inode_block_map(inode)
.map_err(map_err)?;
let out: Vec<VfsResult<RunInfo>> = map
.into_iter()
.map(|m| {
Ok(RunInfo {
run: ByteRun {
image_offset: m.physical_block.saturating_mul(block_size),
len: m.length.saturating_mul(block_size),
flags: RunFlags::default(),
},
alloc: RunAlloc::Allocated,
})
})
.collect();
Ok(ExtentStream::new(out.into_iter()))
}
fn lookup(&self, parent: FileId, name: &[u8]) -> VfsResult<Option<FileId>> {
let dir_ino = ino_of(parent)?;
let found = self.dir_reader.lookup(dir_ino, name).map_err(map_err)?;
Ok(found.map(|ino| FileId::ExtInode { ino, gen: 0 }))
}
fn meta(&self, ino: FileId) -> VfsResult<FsMeta> {
let inode_no = ino_of(ino)?;
let ir = self.dir_reader.inode_reader();
let inode = ir.read_inode(inode_no).map_err(map_err)?;
let allocated = if ir.is_inode_allocated(inode_no).map_err(map_err)? {
Allocation::Allocated
} else {
Allocation::Deleted
};
let resolution = if inode.extra_isize >= 28 {
TimeResolution::Nanos
} else {
TimeResolution::Seconds
};
let ts = |t: &Timestamp| TimeStamp {
unix_nanos: i128::from(t.seconds) * 1_000_000_000 + i128::from(t.nanoseconds),
source: TimeSource::InodeTable,
resolution,
};
let born = if inode.crtime.seconds != 0 {
Some(ts(&inode.crtime))
} else {
None
};
let residency = if inode.has_inline_data() {
ResidencyKind::Resident {
inline_len: inode.size.min(60) as u32,
}
} else {
ResidencyKind::NonResident
};
Ok(FsMeta {
ino: inode_no,
kind: node_kind(inode.file_type()),
allocated,
size: inode.size,
nlink: u32::from(inode.links_count),
uid: Some(inode.uid),
gid: Some(inode.gid),
mode: Some(u32::from(inode.mode)),
times: MacbTimes {
modified: Some(ts(&inode.mtime)),
accessed: Some(ts(&inode.atime)),
changed: Some(ts(&inode.ctime)),
born,
},
streams: Vec::new(),
residency,
link_target: None,
})
}
fn read_at(&self, ino: FileId, stream: StreamId, off: u64, buf: &mut [u8]) -> VfsResult<usize> {
let inode = ino_of(ino)?;
require_default_stream(stream)?;
let data = self
.dir_reader
.inode_reader()
.read_inode_data_range(inode, off, buf.len())
.map_err(map_err)?;
let n = data.len().min(buf.len());
buf[..n].copy_from_slice(&data[..n]);
Ok(n)
}
fn read_link(&self, ino: FileId, cap: usize) -> VfsResult<Vec<u8>> {
let inode = ino_of(ino)?;
match self.dir_reader.read_link(inode) {
Ok(mut target) => {
target.truncate(cap);
Ok(target)
}
Err(Ext4Error::NotASymlink(_)) => Ok(Vec::new()),
Err(e) => Err(map_err(e)),
}
}
fn deleted(&self) -> VfsResult<NodeStream> {
Ok(NodeStream::empty())
}
fn unallocated(&self) -> VfsResult<ExtentStream> {
let br = self.dir_reader.inode_reader().block_reader();
let sb = br.superblock();
let block_size = u64::from(sb.block_size);
let blocks_per_group = u64::from(sb.blocks_per_group);
let blocks_count = sb.blocks_count;
let mut group_first = u64::from(sb.first_data_block);
let descriptors = br.group_descriptors();
let mut owned: Vec<(Option<Vec<u8>>, u64, u64)> = Vec::with_capacity(descriptors.len());
for gd in descriptors {
let blocks_in_group = blocks_count
.saturating_sub(group_first)
.min(blocks_per_group);
if blocks_in_group > 0 {
let bitmap = if gd.flags.contains(GroupDescFlags::BLOCK_UNINIT) {
None
} else {
Some(br.read_block(gd.block_bitmap).map_err(map_err)?)
};
owned.push((bitmap, group_first, blocks_in_group));
}
group_first = group_first.saturating_add(blocks_per_group);
}
let groups: Vec<GroupBitmap<'_>> = owned
.iter()
.map(|(bitmap, first_block, blocks_in_group)| GroupBitmap {
bitmap: bitmap.as_deref(),
first_block: *first_block,
blocks_in_group: *blocks_in_group,
})
.collect();
let out: Vec<VfsResult<RunInfo>> = unallocated_runs(&groups, block_size)
.into_iter()
.map(|run| {
Ok(RunInfo {
run,
alloc: RunAlloc::Unallocated,
})
})
.collect();
Ok(ExtentStream::new(out.into_iter()))
}
}
#[cfg(test)]
mod tests {
use super::{dirent_kind, free_runs, map_err, node_kind, unallocated_runs, GroupBitmap};
use crate::error::Ext4Error;
use crate::ondisk::{dir_entry::DirEntryType, inode::FileType};
use forensic_vfs::{ByteRun, NodeKind, RunFlags, VfsError};
#[test]
fn free_runs_all_free() {
assert_eq!(free_runs(&[0x00], 8), vec![(0, 8)]);
}
#[test]
fn free_runs_all_allocated() {
assert_eq!(free_runs(&[0xFF], 8), Vec::<(u64, u64)>::new());
}
#[test]
fn free_runs_low_nibble_allocated() {
assert_eq!(free_runs(&[0x0F], 8), vec![(4, 4)]);
}
#[test]
fn free_runs_alternating() {
assert_eq!(free_runs(&[0xAA], 8), vec![(0, 1), (2, 1), (4, 1), (6, 1)]);
}
#[test]
fn free_runs_spans_byte_boundary() {
assert_eq!(free_runs(&[0x00, 0x00], 12), vec![(0, 12)]);
}
#[test]
fn free_runs_honors_blocks_in_group() {
assert_eq!(free_runs(&[0x00], 3), vec![(0, 3)]);
}
#[test]
fn free_runs_zero_blocks() {
assert_eq!(free_runs(&[0x00], 0), Vec::<(u64, u64)>::new());
}
#[test]
fn free_runs_short_bitmap_treats_missing_as_allocated() {
assert_eq!(free_runs(&[0x00], 16), vec![(0, 8)]);
}
#[test]
fn unallocated_runs_maps_bits_to_absolute_offsets() {
let bm_a = [0x0Fu8]; let bm_c = [0x00u8]; let groups = [
GroupBitmap {
bitmap: Some(&bm_a),
first_block: 0,
blocks_in_group: 8,
},
GroupBitmap {
bitmap: None, first_block: 8,
blocks_in_group: 8,
},
GroupBitmap {
bitmap: Some(&bm_c),
first_block: 16,
blocks_in_group: 3, },
];
let run = |off: u64, len: u64| ByteRun {
image_offset: off,
len,
flags: RunFlags::default(),
};
assert_eq!(
unallocated_runs(&groups, 1024),
vec![
run(4 * 1024, 4 * 1024), run(8 * 1024, 8 * 1024), run(16 * 1024, 3 * 1024), ]
);
}
#[test]
fn unallocated_over_minimal_image_yields_runs() {
use crate::Ext4Fs;
use forensic_vfs::{FileSystem, RunAlloc};
use std::io::Cursor;
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/minimal.img");
let Ok(data) = std::fs::read(path) else {
eprintln!("skip: minimal.img not found");
return;
};
let fs = Ext4Fs::open(Cursor::new(data)).unwrap();
let runs: Vec<_> = fs
.unallocated()
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(!runs.is_empty(), "expected unallocated runs on minimal.img");
for r in &runs {
assert_eq!(r.alloc, RunAlloc::Unallocated);
assert!(r.run.len > 0);
}
}
#[test]
fn map_err_io_is_io() {
let e = Ext4Error::Io(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe"));
assert!(matches!(
map_err(e),
VfsError::Io {
op: "ext4 read",
..
}
));
}
fn out_of_range(e: &VfsError) -> Option<(&'static str, u64, u64)> {
if let VfsError::OutOfRange {
what,
offset,
bound,
..
} = e
{
Some((what, *offset, *bound))
} else {
None
}
}
#[test]
fn map_err_inode_range_is_out_of_range() {
let e = Ext4Error::InodeOutOfRange { ino: 999, max: 100 };
assert_eq!(out_of_range(&map_err(e)), Some(("ext4 inode", 999, 100)));
}
#[test]
fn map_err_block_range_is_out_of_range() {
let e = Ext4Error::BlockOutOfRange { block: 50, max: 10 };
assert_eq!(out_of_range(&map_err(e)), Some(("ext4 block", 50, 10)));
}
#[test]
fn out_of_range_helper_rejects_non_range_errors() {
let io = map_err(Ext4Error::Io(std::io::Error::other("x")));
assert_eq!(out_of_range(&io), None);
}
#[test]
fn map_err_other_is_decode() {
let e = Ext4Error::CorruptMetadata {
structure: "inode",
detail: "bad".into(),
};
assert!(matches!(map_err(e), VfsError::Decode { layer: "ext4", .. }));
}
#[test]
fn node_kind_covers_every_file_type() {
assert_eq!(node_kind(FileType::RegularFile), NodeKind::File);
assert_eq!(node_kind(FileType::Directory), NodeKind::Dir);
assert_eq!(node_kind(FileType::Symlink), NodeKind::Symlink);
assert_eq!(node_kind(FileType::CharDevice), NodeKind::Device);
assert_eq!(node_kind(FileType::BlockDevice), NodeKind::Device);
assert_eq!(node_kind(FileType::Fifo), NodeKind::Other);
assert_eq!(node_kind(FileType::Socket), NodeKind::Other);
assert_eq!(node_kind(FileType::Unknown), NodeKind::Other);
}
#[test]
fn dirent_kind_covers_every_dir_entry_type() {
assert_eq!(dirent_kind(DirEntryType::RegularFile), NodeKind::File);
assert_eq!(dirent_kind(DirEntryType::Directory), NodeKind::Dir);
assert_eq!(dirent_kind(DirEntryType::Symlink), NodeKind::Symlink);
assert_eq!(dirent_kind(DirEntryType::CharDevice), NodeKind::Device);
assert_eq!(dirent_kind(DirEntryType::BlockDevice), NodeKind::Device);
assert_eq!(dirent_kind(DirEntryType::Fifo), NodeKind::Other);
assert_eq!(dirent_kind(DirEntryType::Socket), NodeKind::Other);
assert_eq!(dirent_kind(DirEntryType::Unknown), NodeKind::Other);
}
}