use std::io::{Read, Seek, SeekFrom};
use crate::tree::TreeNode;
const EXT_MAGIC: u16 = 0xEF53;
const SUPERBLOCK_OFFSET: u64 = 1024;
const S_IFMT: u16 = 0xF000;
const S_IFREG: u16 = 0x8000;
const S_IFDIR: u16 = 0x4000;
const S_IFLNK: u16 = 0xA000;
const EXT4_EXTENTS_FL: u32 = 0x0008_0000;
const EXT4_INLINE_DATA_FL: u32 = 0x1000_0000;
const INCOMPAT_FILETYPE: u32 = 0x0002;
const INCOMPAT_64BIT: u32 = 0x0080;
const EXTENT_MAGIC: u16 = 0xF30A;
const MAX_DEPTH: usize = 32;
#[derive(Debug)]
pub enum Error {
TooShort,
BadSuperblock,
Io(std::io::Error),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::TooShort => write!(f, "image too short to contain an ext superblock"),
Error::BadSuperblock => write!(f, "ext superblock magic 0xEF53 missing"),
Error::Io(e) => write!(f, "ext I/O error: {e}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Io(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Error::Io(e)
}
}
#[derive(Debug, Clone)]
struct Superblock {
inodes_per_group: u32,
first_data_block: u32, log_block_size: u32, inode_size: u16, feature_incompat: u32,
desc_size: u16, }
impl Superblock {
fn block_size(&self) -> u64 {
1024u64 << self.log_block_size
}
fn has_incompat(&self, flag: u32) -> bool {
self.feature_incompat & flag != 0
}
fn desc_size_effective(&self) -> u64 {
if self.has_incompat(INCOMPAT_64BIT) && self.desc_size >= 64 {
self.desc_size as u64
} else {
32
}
}
fn bgd_table_offset(&self, base_offset: u64) -> u64 {
let first_bgd_block = self.first_data_block as u64 + 1;
base_offset + first_bgd_block * self.block_size()
}
}
fn read_superblock<R: Read + Seek>(file: &mut R, base_offset: u64) -> Result<Superblock, Error> {
file.seek(SeekFrom::Start(base_offset + SUPERBLOCK_OFFSET))?;
let mut sb = [0u8; 264]; file.read_exact(&mut sb).map_err(|e| {
if e.kind() == std::io::ErrorKind::UnexpectedEof {
Error::TooShort
} else {
Error::Io(e)
}
})?;
let magic = u16::from_le_bytes([sb[56], sb[57]]);
if magic != EXT_MAGIC {
return Err(Error::BadSuperblock);
}
let blocks_count_lo = u32::from_le_bytes(sb[4..8].try_into().unwrap());
let first_data_block = u32::from_le_bytes(sb[20..24].try_into().unwrap());
let log_block_size = u32::from_le_bytes(sb[24..28].try_into().unwrap());
let blocks_per_group = u32::from_le_bytes(sb[32..36].try_into().unwrap());
let inodes_per_group = u32::from_le_bytes(sb[40..44].try_into().unwrap());
if log_block_size > 6 {
return Err(Error::BadSuperblock);
}
let rev_level = u32::from_le_bytes(sb[76..80].try_into().unwrap());
if blocks_per_group == 0 {
return Err(Error::BadSuperblock);
}
if blocks_count_lo == 0 {
return Err(Error::BadSuperblock);
}
if inodes_per_group == 0 {
return Err(Error::BadSuperblock);
}
let (inode_size, feature_incompat, desc_size) = if rev_level >= 1 {
let inode_size = u16::from_le_bytes([sb[88], sb[89]]);
let feature_incompat = u32::from_le_bytes(sb[96..100].try_into().unwrap());
let desc_size = u16::from_le_bytes([sb[236], sb[237]]);
(inode_size, feature_incompat, desc_size)
} else {
(128, 0, 32)
};
let bs = 1024u64 << log_block_size;
let eff_inode_size = if inode_size < 128 { 128u16 } else { inode_size };
if eff_inode_size as u64 > bs {
return Err(Error::BadSuperblock);
}
Ok(Superblock {
inodes_per_group,
first_data_block,
log_block_size,
inode_size: eff_inode_size,
feature_incompat,
desc_size,
})
}
struct Bgd {
inode_table: u64, }
fn read_bgd<R: Read + Seek>(
file: &mut R,
sb: &Superblock,
base_offset: u64,
group: u64,
) -> Result<Bgd, Error> {
let desc_size = sb.desc_size_effective();
let offset = sb.bgd_table_offset(base_offset) + group * desc_size;
file.seek(SeekFrom::Start(offset))?;
let mut buf = vec![0u8; desc_size as usize];
file.read_exact(&mut buf)?;
let inode_table_lo = u32::from_le_bytes(buf[8..12].try_into().unwrap()) as u64;
let inode_table = if sb.has_incompat(INCOMPAT_64BIT) && desc_size >= 64 {
let hi = u32::from_le_bytes(buf[40..44].try_into().unwrap()) as u64;
(hi << 32) | inode_table_lo
} else {
inode_table_lo
};
Ok(Bgd { inode_table })
}
struct Inode {
mode: u16,
size: u64, flags: u32,
i_block: [u32; 15], }
impl Inode {
fn file_type_char(&self) -> u8 {
((self.mode & S_IFMT) >> 12) as u8
}
fn is_dir(&self) -> bool {
self.mode & S_IFMT == S_IFDIR
}
fn is_reg(&self) -> bool {
self.mode & S_IFMT == S_IFREG
}
fn is_symlink(&self) -> bool {
self.mode & S_IFMT == S_IFLNK
}
fn uses_extents(&self) -> bool {
self.flags & EXT4_EXTENTS_FL != 0
}
fn is_inline(&self) -> bool {
self.flags & EXT4_INLINE_DATA_FL != 0
}
}
fn read_inode<R: Read + Seek>(
file: &mut R,
sb: &Superblock,
base_offset: u64,
inode_num: u32,
) -> Result<Inode, Error> {
if inode_num == 0 {
return Err(Error::BadSuperblock);
}
let group = (inode_num as u64 - 1) / sb.inodes_per_group as u64;
let local_index = (inode_num as u64 - 1) % sb.inodes_per_group as u64;
let bgd = read_bgd(file, sb, base_offset, group)?;
let inode_offset =
base_offset + bgd.inode_table * sb.block_size() + local_index * sb.inode_size as u64;
file.seek(SeekFrom::Start(inode_offset))?;
let read_len = (sb.inode_size as usize).max(112);
let mut buf = vec![0u8; read_len];
file.read_exact(&mut buf)?;
let mode = u16::from_le_bytes([buf[0], buf[1]]);
let size_lo = u32::from_le_bytes(buf[4..8].try_into().unwrap());
let flags = u32::from_le_bytes(buf[32..36].try_into().unwrap());
let mut i_block = [0u32; 15];
for (i, slot) in i_block.iter_mut().enumerate() {
let off = 40 + i * 4;
*slot = u32::from_le_bytes(buf[off..off + 4].try_into().unwrap());
}
let size_high = u32::from_le_bytes(buf[108..112].try_into().unwrap());
let size = if mode & S_IFMT == S_IFREG {
(size_high as u64) << 32 | size_lo as u64
} else {
size_lo as u64
};
Ok(Inode {
mode,
size,
flags,
i_block,
})
}
fn read_block<R: Read + Seek>(
file: &mut R,
sb: &Superblock,
base_offset: u64,
block_num: u64,
buf: &mut Vec<u8>,
) -> Result<(), Error> {
buf.resize(sb.block_size() as usize, 0);
file.seek(SeekFrom::Start(base_offset + block_num * sb.block_size()))?;
file.read_exact(buf)?;
Ok(())
}
#[derive(Debug, Clone, Copy)]
struct Extent {
len: u16, phys: u64, unwritten: bool, }
#[derive(Debug, Clone, Copy)]
struct ExtentIdx {
leaf: u64,
}
fn parse_extent_header(data: &[u8]) -> Option<(u16, u16)> {
if data.len() < 12 {
return None;
}
let magic = u16::from_le_bytes([data[0], data[1]]);
if magic != EXTENT_MAGIC {
return None;
}
let entries = u16::from_le_bytes([data[2], data[3]]);
let depth = u16::from_le_bytes([data[6], data[7]]);
Some((entries, depth))
}
fn parse_leaf_extents(data: &[u8], entries: u16) -> Vec<Extent> {
let mut out = Vec::with_capacity(entries as usize);
for i in 0..entries as usize {
let off = 12 + i * 12;
if off + 12 > data.len() {
break;
}
let ee_len = u16::from_le_bytes([data[off + 4], data[off + 5]]);
let ee_start_hi = u16::from_le_bytes([data[off + 6], data[off + 7]]) as u64;
let ee_start_lo = u32::from_le_bytes(data[off + 8..off + 12].try_into().unwrap()) as u64;
let phys = (ee_start_hi << 32) | ee_start_lo;
let unwritten = ee_len & 0x8000 != 0;
let len = ee_len & 0x7FFF;
out.push(Extent {
len,
phys,
unwritten,
});
}
out
}
fn parse_idx_entries(data: &[u8], entries: u16) -> Vec<ExtentIdx> {
let mut out = Vec::with_capacity(entries as usize);
for i in 0..entries as usize {
let off = 12 + i * 12;
if off + 12 > data.len() {
break;
}
let leaf_lo = u32::from_le_bytes(data[off + 4..off + 8].try_into().unwrap()) as u64;
let leaf_hi = u16::from_le_bytes([data[off + 8], data[off + 9]]) as u64;
let leaf = (leaf_hi << 32) | leaf_lo;
out.push(ExtentIdx { leaf });
}
out
}
fn collect_extents<R: Read + Seek>(
file: &mut R,
sb: &Superblock,
base_offset: u64,
node_data: &[u8],
remaining_depth: u8,
) -> Result<Vec<Extent>, Error> {
let Some((entries, depth)) = parse_extent_header(node_data) else {
return Ok(Vec::new());
};
if depth == 0 {
return Ok(parse_leaf_extents(node_data, entries));
}
if remaining_depth == 0 {
return Ok(Vec::new());
}
let idx_entries = parse_idx_entries(node_data, entries);
let mut block_buf = Vec::new();
let mut all_extents = Vec::new();
for idx in idx_entries {
read_block(file, sb, base_offset, idx.leaf, &mut block_buf)?;
let child_extents =
collect_extents(file, sb, base_offset, &block_buf, remaining_depth - 1)?;
all_extents.extend(child_extents);
}
Ok(all_extents)
}
fn read_ptr_block<R: Read + Seek>(
file: &mut R,
sb: &Superblock,
base_offset: u64,
block_num: u64,
) -> Result<Vec<u32>, Error> {
let bs = sb.block_size() as usize;
let mut buf = vec![0u8; bs];
file.seek(SeekFrom::Start(base_offset + block_num * sb.block_size()))?;
file.read_exact(&mut buf)?;
Ok(buf
.chunks_exact(4)
.map(|c| u32::from_le_bytes(c.try_into().unwrap()))
.collect())
}
fn collect_classical_blocks<R: Read + Seek>(
file: &mut R,
sb: &Superblock,
base_offset: u64,
inode: &Inode,
size: u64,
) -> Result<Vec<u64>, Error> {
let bs = sb.block_size();
let mut blocks: Vec<u64> = Vec::new();
let mut covered: u64 = 0;
for &blk in &inode.i_block[0..12] {
if blk == 0 || covered >= size {
break;
}
blocks.push(blk as u64);
covered += bs;
}
if covered >= size {
return Ok(blocks);
}
let si = inode.i_block[12];
if si != 0 {
let ptrs = read_ptr_block(file, sb, base_offset, si as u64)?;
for blk in ptrs {
if blk == 0 || covered >= size {
break;
}
blocks.push(blk as u64);
covered += bs;
}
}
if covered >= size {
return Ok(blocks);
}
let di = inode.i_block[13];
if di != 0 {
let l1 = read_ptr_block(file, sb, base_offset, di as u64)?;
'di_outer: for l1ptr in l1 {
if l1ptr == 0 || covered >= size {
break;
}
let l2 = read_ptr_block(file, sb, base_offset, l1ptr as u64)?;
for blk in l2 {
if blk == 0 || covered >= size {
break 'di_outer;
}
blocks.push(blk as u64);
covered += bs;
}
}
}
if covered >= size {
return Ok(blocks);
}
let ti = inode.i_block[14];
if ti != 0 {
let l1 = read_ptr_block(file, sb, base_offset, ti as u64)?;
'ti_outer: for l1ptr in l1 {
if l1ptr == 0 || covered >= size {
break;
}
let l2 = read_ptr_block(file, sb, base_offset, l1ptr as u64)?;
'ti_middle: for l2ptr in l2 {
if l2ptr == 0 || covered >= size {
break 'ti_outer;
}
let l3 = read_ptr_block(file, sb, base_offset, l2ptr as u64)?;
for blk in l3 {
if blk == 0 || covered >= size {
break 'ti_middle;
}
blocks.push(blk as u64);
covered += bs;
}
}
}
}
Ok(blocks)
}
#[derive(Debug)]
struct DirEntry {
inode: u32,
name: String,
}
fn scan_dir_block(data: &[u8], has_filetype: bool, out: &mut Vec<DirEntry>) {
let mut pos = 0usize;
while pos + 8 <= data.len() {
let inode = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap());
let rec_len = u16::from_le_bytes([data[pos + 4], data[pos + 5]]) as usize;
let name_len = data[pos + 6] as usize;
if rec_len < 8 || pos + rec_len > data.len() {
break;
}
if inode != 0 && name_len > 0 && pos + 8 + name_len <= data.len() {
let raw = &data[pos + 8..pos + 8 + name_len];
let name = String::from_utf8_lossy(raw).into_owned();
if name != "." && name != ".." {
let _file_type = if has_filetype { data[pos + 7] } else { 0 };
out.push(DirEntry { inode, name });
}
}
pos += rec_len.max(1); }
}
fn read_dir_entries<R: Read + Seek>(
file: &mut R,
sb: &Superblock,
base_offset: u64,
inode: &Inode,
) -> Result<Vec<DirEntry>, Error> {
let has_filetype = sb.has_incompat(INCOMPAT_FILETYPE);
let mut entries = Vec::new();
let mut block_buf = Vec::new();
if inode.uses_extents() {
let root_bytes: Vec<u8> = inode
.i_block
.iter()
.flat_map(|&w| w.to_le_bytes())
.collect();
let extents = collect_extents(file, sb, base_offset, &root_bytes, 5)?;
for ext in extents {
for i in 0..ext.len as u64 {
read_block(file, sb, base_offset, ext.phys + i, &mut block_buf)?;
scan_dir_block(&block_buf, has_filetype, &mut entries);
}
}
} else {
let blk_nums = collect_classical_blocks(file, sb, base_offset, inode, inode.size)?;
for blk in blk_nums {
read_block(file, sb, base_offset, blk, &mut block_buf)?;
scan_dir_block(&block_buf, has_filetype, &mut entries);
}
}
Ok(entries)
}
fn single_run_location<R: Read + Seek>(
file: &mut R,
sb: &Superblock,
base_offset: u64,
inode: &Inode,
) -> Result<Option<u64>, Error> {
if inode.size == 0 {
return Ok(None);
}
if inode.is_inline() {
return Ok(None);
}
if inode.uses_extents() {
let root_bytes: Vec<u8> = inode
.i_block
.iter()
.flat_map(|&w| w.to_le_bytes())
.collect();
let extents = collect_extents(file, sb, base_offset, &root_bytes, 5)?;
if extents.len() == 1 {
let ext = &extents[0];
if !ext.unwritten {
let needed_blocks = inode.size.div_ceil(sb.block_size());
if ext.len as u64 >= needed_blocks {
return Ok(Some(base_offset + ext.phys * sb.block_size()));
}
}
}
return Ok(None);
}
let bs = sb.block_size();
let needed_blocks = inode.size.div_ceil(bs);
if needed_blocks > 12 {
return Ok(None);
}
let first = inode.i_block[0] as u64;
if first == 0 {
return Ok(None);
}
for i in 1..needed_blocks as usize {
if inode.i_block[i] as u64 != first + i as u64 {
return Ok(None);
}
}
Ok(Some(base_offset + first * bs))
}
fn build_tree<R: Read + Seek>(
file: &mut R,
sb: &Superblock,
base_offset: u64,
name: String,
inode_num: u32,
depth: usize,
) -> Result<Option<TreeNode>, Error> {
if depth > MAX_DEPTH {
return Ok(None);
}
let inode = read_inode(file, sb, base_offset, inode_num)?;
if inode.is_dir() {
let mut node = TreeNode::new_directory(name);
let entries = read_dir_entries(file, sb, base_offset, &inode)?;
for entry in entries {
if let Some(child) =
build_tree(file, sb, base_offset, entry.name, entry.inode, depth + 1)?
{
node.add_child(child);
}
}
Ok(Some(node))
} else if inode.is_reg() {
if inode.is_inline() {
return Ok(Some(TreeNode::new_file(name, inode.size)));
}
let loc = single_run_location(file, sb, base_offset, &inode)?;
let node = match loc {
Some(offset) => TreeNode::new_file_with_location(name, inode.size, offset, inode.size),
None => TreeNode::new_file(name, inode.size),
};
Ok(Some(node))
} else if inode.is_symlink() {
Ok(Some(TreeNode::new_file(name, inode.size)))
} else {
let _ = inode.file_type_char(); Ok(None)
}
}
pub fn detect<R: Read + Seek>(file: &mut R) -> bool {
let saved = match file.stream_position() {
Ok(p) => p,
Err(_) => return false,
};
let ok = detect_inner(file);
let _ = file.seek(SeekFrom::Start(saved));
ok
}
fn detect_inner<R: Read + Seek>(file: &mut R) -> bool {
let base = match file.stream_position() {
Ok(p) => p,
Err(_) => return false,
};
let magic_offset = base + SUPERBLOCK_OFFSET + 56;
if file.seek(SeekFrom::Start(magic_offset)).is_err() {
return false;
}
let mut magic_buf = [0u8; 2];
if file.read_exact(&mut magic_buf).is_err() {
return false;
}
u16::from_le_bytes(magic_buf) == EXT_MAGIC
}
pub fn detect_and_parse<R: Read + Seek>(file: &mut R) -> Result<TreeNode, Error> {
let base_offset = file.stream_position()?;
let sb = read_superblock(file, base_offset)?;
let mut root =
build_tree(file, &sb, base_offset, "/".to_string(), 2, 0)?.ok_or(Error::BadSuperblock)?;
root.calculate_directory_size();
Ok(root)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn make_ext2_image() -> Vec<u8> {
const BS: usize = 1024;
const TOTAL_BLOCKS: usize = 256;
const IMAGE_SIZE: usize = TOTAL_BLOCKS * BS;
const INODE_SIZE: usize = 128;
const INODES_PER_GROUP: usize = 256;
const BLOCK_BITMAP_BLK: usize = 3;
const INODE_BITMAP_BLK: usize = 4;
const INODE_TABLE_BLK: usize = 5;
const ROOT_DATA_BLK: usize = 6;
const FILE_DATA_BLK: usize = 7;
const ROOT_INUM: usize = 2;
const FILE_INUM: usize = 3;
let mut img = vec![0u8; IMAGE_SIZE];
{
let sb = &mut img[1024..1024 + 264];
sb[0..4].copy_from_slice(&(INODES_PER_GROUP as u32).to_le_bytes());
sb[4..8].copy_from_slice(&(TOTAL_BLOCKS as u32).to_le_bytes());
sb[20..24].copy_from_slice(&1u32.to_le_bytes());
sb[24..28].copy_from_slice(&0u32.to_le_bytes());
sb[32..36].copy_from_slice(&(TOTAL_BLOCKS as u32).to_le_bytes());
sb[40..44].copy_from_slice(&(INODES_PER_GROUP as u32).to_le_bytes());
sb[56..58].copy_from_slice(&EXT_MAGIC.to_le_bytes());
sb[76..80].copy_from_slice(&1u32.to_le_bytes());
sb[84..88].copy_from_slice(&11u32.to_le_bytes());
sb[88..90].copy_from_slice(&(INODE_SIZE as u16).to_le_bytes());
sb[92..96].copy_from_slice(&0u32.to_le_bytes());
sb[96..100].copy_from_slice(&INCOMPAT_FILETYPE.to_le_bytes());
sb[100..104].copy_from_slice(&0u32.to_le_bytes());
sb[236..238].copy_from_slice(&32u16.to_le_bytes());
}
{
let bgd = &mut img[2 * BS..2 * BS + 32];
bgd[0..4].copy_from_slice(&(BLOCK_BITMAP_BLK as u32).to_le_bytes());
bgd[4..8].copy_from_slice(&(INODE_BITMAP_BLK as u32).to_le_bytes());
bgd[8..12].copy_from_slice(&(INODE_TABLE_BLK as u32).to_le_bytes());
}
{
let inode_base = INODE_TABLE_BLK * BS;
let root_off = inode_base + (ROOT_INUM - 1) * INODE_SIZE;
let ino = &mut img[root_off..root_off + INODE_SIZE];
let mode: u16 = S_IFDIR | 0o755;
ino[0..2].copy_from_slice(&mode.to_le_bytes());
ino[4..8].copy_from_slice(&(BS as u32).to_le_bytes());
ino[26..28].copy_from_slice(&2u16.to_le_bytes());
ino[32..36].copy_from_slice(&0u32.to_le_bytes());
ino[40..44].copy_from_slice(&(ROOT_DATA_BLK as u32).to_le_bytes());
}
{
let inode_base = INODE_TABLE_BLK * BS;
let file_off = inode_base + (FILE_INUM - 1) * INODE_SIZE;
let ino = &mut img[file_off..file_off + INODE_SIZE];
let mode: u16 = S_IFREG | 0o644;
ino[0..2].copy_from_slice(&mode.to_le_bytes());
ino[4..8].copy_from_slice(&12u32.to_le_bytes());
ino[26..28].copy_from_slice(&1u16.to_le_bytes());
ino[32..36].copy_from_slice(&0u32.to_le_bytes());
ino[40..44].copy_from_slice(&(FILE_DATA_BLK as u32).to_le_bytes());
}
{
let dblk = &mut img[ROOT_DATA_BLK * BS..ROOT_DATA_BLK * BS + BS];
let e0_rec: u16 = 12;
dblk[0..4].copy_from_slice(&(ROOT_INUM as u32).to_le_bytes());
dblk[4..6].copy_from_slice(&e0_rec.to_le_bytes());
dblk[6] = 1; dblk[7] = 2; dblk[8] = b'.';
let e1_off = 12;
let e1_rec: u16 = 12;
dblk[e1_off..e1_off + 4].copy_from_slice(&(ROOT_INUM as u32).to_le_bytes());
dblk[e1_off + 4..e1_off + 6].copy_from_slice(&e1_rec.to_le_bytes());
dblk[e1_off + 6] = 2; dblk[e1_off + 7] = 2; dblk[e1_off + 8] = b'.';
dblk[e1_off + 9] = b'.';
let e2_off = 24;
let e2_rec: u16 = (BS - e2_off) as u16;
dblk[e2_off..e2_off + 4].copy_from_slice(&(FILE_INUM as u32).to_le_bytes());
dblk[e2_off + 4..e2_off + 6].copy_from_slice(&e2_rec.to_le_bytes());
dblk[e2_off + 6] = 9; dblk[e2_off + 7] = 1; dblk[e2_off + 8..e2_off + 17].copy_from_slice(b"hello.txt");
}
{
let fblk = &mut img[FILE_DATA_BLK * BS..FILE_DATA_BLK * BS + 12];
fblk.copy_from_slice(b"hello world\n");
}
img
}
fn cursor_of(img: &[u8]) -> Cursor<Vec<u8>> {
Cursor::new(img.to_vec())
}
#[test]
fn detect_valid_ext2() {
let img = make_ext2_image();
let mut c = cursor_of(&img);
assert!(detect(&mut c), "should detect valid ext2 image");
}
#[test]
fn detect_restores_position() {
let img = make_ext2_image();
let mut c = cursor_of(&img);
c.seek(SeekFrom::Start(42)).unwrap();
let _ = detect(&mut c);
assert_eq!(
c.stream_position().unwrap(),
42,
"detect() must restore stream position"
);
}
#[test]
fn detect_restores_position_on_failure() {
let img = vec![0u8; 512];
let mut c = Cursor::new(img);
c.seek(SeekFrom::Start(7)).unwrap();
let _ = detect(&mut c);
assert_eq!(c.stream_position().unwrap(), 7);
}
#[test]
fn detect_rejects_bad_magic() {
let mut img = make_ext2_image();
img[1024 + 56] = 0xDE;
img[1024 + 57] = 0xAD;
let mut c = cursor_of(&img);
assert!(!detect(&mut c), "corrupted magic should not detect as ext");
}
#[test]
fn detect_rejects_too_short() {
let img = vec![0u8; 512];
let mut c = Cursor::new(img);
assert!(!detect(&mut c));
}
#[test]
fn detect_rejects_fat_image() {
let mut img = vec![0u8; 2048];
img[0] = 0xEB;
img[1] = 0x58;
img[2] = 0x90;
img[3..8].copy_from_slice(b"FAT12");
let mut c = Cursor::new(img);
assert!(!detect(&mut c), "FAT image should not be detected as ext");
}
#[test]
fn parse_ext2_tree_shape() {
let img = make_ext2_image();
let mut c = cursor_of(&img);
let root = detect_and_parse(&mut c).expect("parse ext2 image");
assert_eq!(root.name, "/");
assert!(root.is_directory);
assert_eq!(
root.children.len(),
1,
"root should have exactly 1 child (hello.txt)"
);
assert_eq!(root.children[0].name, "hello.txt");
assert!(!root.children[0].is_directory);
assert_eq!(root.children[0].size, 12);
}
#[test]
fn parse_ext2_file_location() {
let img = make_ext2_image();
let mut c = cursor_of(&img);
let root = detect_and_parse(&mut c).expect("parse");
let file = root
.children
.iter()
.find(|n| n.name == "hello.txt")
.unwrap();
assert!(
file.file_location.is_some(),
"hello.txt should have a file_location"
);
let expected = 7 * 1024u64;
assert_eq!(
file.file_location.unwrap(),
expected,
"file_location should point to block 7"
);
}
#[test]
fn parse_ext2_file_contents() {
let img = make_ext2_image();
let mut c = cursor_of(&img);
let root = detect_and_parse(&mut c).expect("parse");
let file = root
.children
.iter()
.find(|n| n.name == "hello.txt")
.unwrap();
let loc = file.file_location.unwrap();
let len = file.size as usize;
c.seek(SeekFrom::Start(loc)).unwrap();
let mut buf = vec![0u8; len];
c.read_exact(&mut buf).unwrap();
assert_eq!(buf, b"hello world\n");
}
#[test]
fn ext2_empty_root_ok() {
let mut img = make_ext2_image();
let root_data_start = 6 * 1024 + 24; img[root_data_start..root_data_start + 4].copy_from_slice(&0u32.to_le_bytes());
let mut c = cursor_of(&img);
let root = detect_and_parse(&mut c).expect("parse should succeed even with no children");
assert_eq!(root.name, "/");
assert!(root.is_directory);
}
#[test]
fn ext2_large_file_no_crash() {
let mut img = make_ext2_image();
let inode_table_start = 5 * 1024;
let file_inode_off = inode_table_start + (3 - 1) * 128; let new_size: u32 = 1024 * 1024;
img[file_inode_off + 4..file_inode_off + 8].copy_from_slice(&new_size.to_le_bytes());
img[file_inode_off + 40 + 12 * 4..file_inode_off + 40 + 13 * 4]
.copy_from_slice(&8u32.to_le_bytes());
let mut c = cursor_of(&img);
let root = detect_and_parse(&mut c).expect("parse should not crash");
let file = root
.children
.iter()
.find(|n| n.name == "hello.txt")
.unwrap();
assert_eq!(
file.file_location, None,
"large file has no single-run location"
);
}
#[test]
fn error_display_too_short() {
let msg = format!("{}", Error::TooShort);
assert!(
msg.contains("short") || msg.contains("superblock"),
"unexpected: {msg}"
);
}
#[test]
fn error_display_bad_superblock() {
let msg = format!("{}", Error::BadSuperblock);
assert!(
msg.contains("superblock") || msg.contains("0xEF53"),
"unexpected: {msg}"
);
}
#[test]
fn error_display_io() {
let io = std::io::Error::other("disk fail");
let msg = format!("{}", Error::Io(io));
assert!(msg.contains("disk fail"), "unexpected: {msg}");
}
#[test]
fn error_source_io() {
use std::error::Error as StdError;
let io = std::io::Error::other("src");
assert!(Error::Io(io).source().is_some());
}
#[test]
fn error_source_non_io() {
use std::error::Error as StdError;
assert!(Error::TooShort.source().is_none());
assert!(Error::BadSuperblock.source().is_none());
}
#[test]
fn error_from_io_error() {
let io = std::io::Error::other("disk");
let err = Error::from(io);
assert!(matches!(err, Error::Io(_)));
}
#[test]
fn desc_size_effective_returns_desc_size_when_64bit() {
let sb = Superblock {
inodes_per_group: 256,
first_data_block: 0,
log_block_size: 2, inode_size: 256,
feature_incompat: INCOMPAT_64BIT,
desc_size: 64,
};
assert_eq!(sb.desc_size_effective(), 64);
}
#[test]
fn detect_and_parse_too_short_for_superblock() {
let mut img = vec![0u8; 1100];
img[1024 + 56..1024 + 58].copy_from_slice(&EXT_MAGIC.to_le_bytes());
let mut c = Cursor::new(img);
assert!(matches!(detect_and_parse(&mut c), Err(Error::TooShort)));
}
#[test]
fn detect_and_parse_bad_superblock_magic() {
let img = vec![0u8; 2048]; let mut c = Cursor::new(img);
assert!(matches!(
detect_and_parse(&mut c),
Err(Error::BadSuperblock)
));
}
#[test]
fn inode_file_type_char_char_device() {
let inode = Inode {
mode: 0x2000,
size: 0,
flags: 0,
i_block: [0; 15],
};
assert_eq!(inode.file_type_char(), 2);
}
#[test]
fn read_inode_with_zero_num_returns_bad_superblock() {
let img = make_ext2_image();
let mut c = Cursor::new(img);
let sb = Superblock {
inodes_per_group: 256,
first_data_block: 1,
log_block_size: 0,
inode_size: 128,
feature_incompat: INCOMPAT_FILETYPE,
desc_size: 32,
};
let result = read_inode(&mut c, &sb, 0, 0);
assert!(matches!(result, Err(Error::BadSuperblock)));
}
fn make_corrupt_image<F: Fn(&mut Vec<u8>)>(corrupt: F) -> Vec<u8> {
let mut img = make_ext2_image();
corrupt(&mut img);
img
}
#[test]
fn rejects_log_block_size_too_large() {
let img = make_corrupt_image(|img| {
img[1024 + 24..1024 + 28].copy_from_slice(&7u32.to_le_bytes());
});
let mut c = cursor_of(&img);
assert!(detect_and_parse(&mut c).is_err());
}
#[test]
fn rejects_blocks_per_group_zero() {
let img = make_corrupt_image(|img| {
img[1024 + 32..1024 + 36].copy_from_slice(&0u32.to_le_bytes());
});
let mut c = cursor_of(&img);
assert!(detect_and_parse(&mut c).is_err());
}
#[test]
fn rejects_blocks_count_zero() {
let img = make_corrupt_image(|img| {
img[1024 + 4..1024 + 8].copy_from_slice(&0u32.to_le_bytes());
});
let mut c = cursor_of(&img);
assert!(detect_and_parse(&mut c).is_err());
}
#[test]
fn rejects_inodes_per_group_zero() {
let img = make_corrupt_image(|img| {
img[1024 + 40..1024 + 44].copy_from_slice(&0u32.to_le_bytes());
});
let mut c = cursor_of(&img);
assert!(detect_and_parse(&mut c).is_err());
}
#[test]
fn rev_level_zero_uses_defaults() {
let mut img = make_ext2_image();
img[1024 + 76..1024 + 80].copy_from_slice(&0u32.to_le_bytes());
img[1024 + 96..1024 + 100].copy_from_slice(&0u32.to_le_bytes());
let mut c = cursor_of(&img);
let root = detect_and_parse(&mut c).expect("rev_level 0 should still parse");
assert_eq!(root.name, "/");
}
#[test]
fn rejects_inode_size_larger_than_block_size() {
let img = make_corrupt_image(|img| {
img[1024 + 88..1024 + 90].copy_from_slice(&4096u16.to_le_bytes());
});
let mut c = cursor_of(&img);
assert!(detect_and_parse(&mut c).is_err());
}
fn make_ext4_extent_image() -> Vec<u8> {
const BS: usize = 1024;
const INODE_SIZE: usize = 128;
const INODE_TABLE_BLK: usize = 5;
const FILE_DATA_BLK: usize = 7;
const FILE_INUM: usize = 3;
let mut img = make_ext2_image();
let inode_base = INODE_TABLE_BLK * BS;
let file_off = inode_base + (FILE_INUM - 1) * INODE_SIZE;
img[file_off + 32..file_off + 36].copy_from_slice(&0x0008_0000u32.to_le_bytes());
let extent_area = &mut img[file_off + 40..file_off + 100];
extent_area[..2].copy_from_slice(&0xF30Au16.to_le_bytes()); extent_area[2..4].copy_from_slice(&1u16.to_le_bytes()); extent_area[4..6].copy_from_slice(&4u16.to_le_bytes()); extent_area[6..8].copy_from_slice(&0u16.to_le_bytes()); extent_area[8..12].copy_from_slice(&0u32.to_le_bytes()); extent_area[12..16].copy_from_slice(&0u32.to_le_bytes()); extent_area[16..18].copy_from_slice(&1u16.to_le_bytes()); extent_area[18..20].copy_from_slice(&0u16.to_le_bytes()); extent_area[20..24].copy_from_slice(&(FILE_DATA_BLK as u32).to_le_bytes());
img
}
#[test]
fn parse_ext4_extent_tree_file() {
let img = make_ext4_extent_image();
let mut c = cursor_of(&img);
let root = detect_and_parse(&mut c).expect("extent tree parse failed");
let file = root
.children
.iter()
.find(|n| n.name == "hello.txt")
.unwrap();
assert_eq!(file.size, 12);
assert!(
file.file_location.is_some(),
"single-extent file should have file_location"
);
}
#[test]
fn parse_extent_header_bad_magic_returns_none() {
let mut data = vec![0u8; 60];
data[0..2].copy_from_slice(&0xDEADu16.to_le_bytes()); assert!(parse_extent_header(&data).is_none());
}
#[test]
fn parse_extent_header_too_short_returns_none() {
let data = vec![0u8; 4]; assert!(parse_extent_header(&data).is_none());
}
#[test]
fn inline_data_file_has_no_location() {
let mut img = make_ext2_image();
const INODE_TABLE_BLK: usize = 5;
const BS: usize = 1024;
const INODE_SIZE: usize = 128;
const FILE_INUM: usize = 3;
let file_off = INODE_TABLE_BLK * BS + (FILE_INUM - 1) * INODE_SIZE;
let flags: u32 = 0x1000_0000;
img[file_off + 32..file_off + 36].copy_from_slice(&flags.to_le_bytes());
let mut c = cursor_of(&img);
let root = detect_and_parse(&mut c).expect("inline data parse failed");
let file = root
.children
.iter()
.find(|n| n.name == "hello.txt")
.unwrap();
assert!(
file.file_location.is_none(),
"inline-data file should have no file_location"
);
}
fn make_ext2_with_symlink() -> Vec<u8> {
const BS: usize = 1024;
const INODE_TABLE_BLK: usize = 5;
const INODE_SIZE: usize = 128;
const ROOT_DATA_BLK: usize = 6;
const SYMLINK_INUM: usize = 4;
let mut img = make_ext2_image();
let symlink_off = INODE_TABLE_BLK * BS + (SYMLINK_INUM - 1) * INODE_SIZE;
let mode: u16 = S_IFLNK | 0o777;
img[symlink_off..symlink_off + 2].copy_from_slice(&mode.to_le_bytes());
img[symlink_off + 4..symlink_off + 8].copy_from_slice(&7u32.to_le_bytes());
let dir_base = ROOT_DATA_BLK * BS;
let new_rec_len: u16 = 20;
img[dir_base + 24 + 4..dir_base + 24 + 6].copy_from_slice(&new_rec_len.to_le_bytes());
let e_off = 44;
let e_rec: u16 = (BS - e_off) as u16;
img[dir_base + e_off..dir_base + e_off + 4]
.copy_from_slice(&(SYMLINK_INUM as u32).to_le_bytes());
img[dir_base + e_off + 4..dir_base + e_off + 6].copy_from_slice(&e_rec.to_le_bytes());
img[dir_base + e_off + 6] = 4; img[dir_base + e_off + 7] = 7; img[dir_base + e_off + 8..dir_base + e_off + 12].copy_from_slice(b"link");
img
}
#[test]
fn symlink_appears_in_tree() {
let img = make_ext2_with_symlink();
let mut c = cursor_of(&img);
let root = detect_and_parse(&mut c).expect("symlink image parse failed");
let link = root.find_node("/link");
assert!(link.is_some(), "symlink should appear in the tree");
let link = link.unwrap();
assert!(!link.is_directory);
assert!(link.file_location.is_none());
}
#[test]
fn discontiguous_blocks_no_file_location() {
let mut img = make_ext2_image();
const INODE_TABLE_BLK: usize = 5;
const BS: usize = 1024;
const INODE_SIZE: usize = 128;
const FILE_INUM: usize = 3;
let file_off = INODE_TABLE_BLK * BS + (FILE_INUM - 1) * INODE_SIZE;
img[file_off + 4..file_off + 8].copy_from_slice(&(2048u32).to_le_bytes()); img[file_off + 44..file_off + 48].copy_from_slice(&9u32.to_le_bytes()); let mut c = cursor_of(&img);
let root = detect_and_parse(&mut c).expect("parse failed");
let file = root
.children
.iter()
.find(|n| n.name == "hello.txt")
.unwrap();
assert!(
file.file_location.is_none(),
"discontiguous blocks should yield no file_location"
);
}
#[test]
fn read_bgd_uses_64bit_inode_table_hi() {
let sb = Superblock {
inodes_per_group: 256,
first_data_block: 1,
log_block_size: 0,
inode_size: 128,
feature_incompat: INCOMPAT_64BIT,
desc_size: 64,
};
let mut img = vec![0u8; 3072];
img[2048 + 8..2048 + 12].copy_from_slice(&5u32.to_le_bytes());
img[2048 + 40..2048 + 44].copy_from_slice(&2u32.to_le_bytes());
let mut c = Cursor::new(img);
let bgd = read_bgd(&mut c, &sb, 0, 0).expect("read_bgd should succeed");
assert_eq!(bgd.inode_table, (2u64 << 32) | 5u64);
}
#[test]
fn parse_idx_entries_one_entry() {
let mut data = vec![0u8; 24];
data[16..20].copy_from_slice(&7u32.to_le_bytes()); data[20..22].copy_from_slice(&0u16.to_le_bytes()); let entries = parse_idx_entries(&data, 1);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].leaf, 7);
}
#[test]
fn parse_idx_entries_overflow_breaks() {
let data = vec![0u8; 24]; let entries = parse_idx_entries(&data, 2);
assert_eq!(entries.len(), 1);
}
#[test]
fn collect_extents_follows_internal_node_to_leaf() {
const BS: usize = 1024;
let mut img = vec![0u8; 5 * BS];
let em = EXTENT_MAGIC.to_le_bytes();
let mut root = vec![0u8; 60];
root[0..2].copy_from_slice(&em); root[2..4].copy_from_slice(&1u16.to_le_bytes()); root[4..6].copy_from_slice(&4u16.to_le_bytes()); root[6..8].copy_from_slice(&1u16.to_le_bytes()); root[16..20].copy_from_slice(&3u32.to_le_bytes());
img[3 * BS..3 * BS + 2].copy_from_slice(&em);
img[3 * BS + 2..3 * BS + 4].copy_from_slice(&1u16.to_le_bytes()); img[3 * BS + 4..3 * BS + 6].copy_from_slice(&4u16.to_le_bytes()); img[3 * BS + 6..3 * BS + 8].copy_from_slice(&0u16.to_le_bytes()); img[3 * BS + 16..3 * BS + 18].copy_from_slice(&1u16.to_le_bytes()); img[3 * BS + 20..3 * BS + 24].copy_from_slice(&10u32.to_le_bytes());
let sb = Superblock {
inodes_per_group: 256,
first_data_block: 1,
log_block_size: 0,
inode_size: 128,
feature_incompat: INCOMPAT_FILETYPE,
desc_size: 32,
};
let mut c = Cursor::new(img);
let extents = collect_extents(&mut c, &sb, 0, &root, 5).expect("collect_extents");
assert_eq!(extents.len(), 1);
assert_eq!(extents[0].phys, 10);
assert_eq!(extents[0].len, 1);
assert!(!extents[0].unwritten);
}
#[test]
fn single_run_location_returns_none_for_empty_inode() {
let inode = Inode {
mode: S_IFREG,
size: 0,
flags: 0,
i_block: [0; 15],
};
let sb = Superblock {
inodes_per_group: 256,
first_data_block: 1,
log_block_size: 0,
inode_size: 128,
feature_incompat: 0,
desc_size: 32,
};
let mut c = Cursor::new(vec![0u8; 0]);
let loc = single_run_location(&mut c, &sb, 0, &inode).unwrap();
assert!(loc.is_none());
}
#[test]
fn single_run_location_returns_none_for_inline_inode() {
let inode = Inode {
mode: S_IFREG,
size: 10,
flags: EXT4_INLINE_DATA_FL,
i_block: [0; 15],
};
let sb = Superblock {
inodes_per_group: 256,
first_data_block: 1,
log_block_size: 0,
inode_size: 128,
feature_incompat: 0,
desc_size: 32,
};
let mut c = Cursor::new(vec![0u8; 0]);
let loc = single_run_location(&mut c, &sb, 0, &inode).unwrap();
assert!(loc.is_none());
}
#[test]
fn single_run_location_extent_unwritten_returns_none() {
let em = EXTENT_MAGIC.to_le_bytes();
let mut i_block_bytes = [0u8; 60];
i_block_bytes[0..2].copy_from_slice(&em); i_block_bytes[2..4].copy_from_slice(&1u16.to_le_bytes()); i_block_bytes[4..6].copy_from_slice(&4u16.to_le_bytes()); i_block_bytes[6..8].copy_from_slice(&0u16.to_le_bytes()); i_block_bytes[16..18].copy_from_slice(&0x8001u16.to_le_bytes()); i_block_bytes[20..24].copy_from_slice(&5u32.to_le_bytes());
let mut i_block = [0u32; 15];
for (i, slot) in i_block.iter_mut().enumerate() {
let off = i * 4;
*slot = u32::from_le_bytes(i_block_bytes[off..off + 4].try_into().unwrap());
}
let inode = Inode {
mode: S_IFREG,
size: 1024,
flags: EXT4_EXTENTS_FL,
i_block,
};
let sb = Superblock {
inodes_per_group: 256,
first_data_block: 1,
log_block_size: 0,
inode_size: 128,
feature_incompat: INCOMPAT_FILETYPE,
desc_size: 32,
};
let mut c = Cursor::new(vec![0u8; 0]);
let loc = single_run_location(&mut c, &sb, 0, &inode).unwrap();
assert!(loc.is_none(), "unwritten extent should yield no location");
}
#[test]
fn collect_extents_bad_magic_returns_empty() {
let data = vec![0u8; 60]; let sb = Superblock {
inodes_per_group: 256,
first_data_block: 1,
log_block_size: 0,
inode_size: 128,
feature_incompat: 0,
desc_size: 32,
};
let mut c = Cursor::new(vec![0u8; 0]);
let extents = collect_extents(&mut c, &sb, 0, &data, 5).unwrap();
assert!(extents.is_empty());
}
#[test]
fn collect_extents_remaining_depth_zero_returns_empty() {
let em = EXTENT_MAGIC.to_le_bytes();
let mut data = vec![0u8; 60];
data[0..2].copy_from_slice(&em); data[2..4].copy_from_slice(&1u16.to_le_bytes()); data[6..8].copy_from_slice(&1u16.to_le_bytes());
let sb = Superblock {
inodes_per_group: 256,
first_data_block: 1,
log_block_size: 0,
inode_size: 128,
feature_incompat: 0,
desc_size: 32,
};
let mut c = Cursor::new(vec![0u8; 0]);
let extents = collect_extents(&mut c, &sb, 0, &data, 0).unwrap();
assert!(extents.is_empty());
}
#[test]
fn parse_leaf_extents_overflow_breaks() {
let mut data = vec![0u8; 36]; data[16..18].copy_from_slice(&1u16.to_le_bytes()); data[28..30].copy_from_slice(&1u16.to_le_bytes()); let extents = parse_leaf_extents(&data, 3);
assert_eq!(extents.len(), 2, "should stop at 2 due to buffer overflow");
}
fn make_ext2_single_indirect_dir() -> Vec<u8> {
const BS: usize = 1024;
const INODE_SIZE: usize = 128;
const INODES_PER_GROUP: usize = 256;
const TOTAL_BLOCKS: usize = 256;
const INODE_TABLE_BLK: usize = 5;
const DIR_DATA_BLK: usize = 6;
const FILE_DATA_BLK: usize = 7;
const SI_BLK: usize = 8; const ROOT_INUM: usize = 2;
const FILE_INUM: usize = 3;
let mut img = vec![0u8; (TOTAL_BLOCKS + 4) * BS];
{
let sb = &mut img[BS..BS + 264];
sb[0..4].copy_from_slice(&(INODES_PER_GROUP as u32).to_le_bytes());
sb[4..8].copy_from_slice(&(TOTAL_BLOCKS as u32).to_le_bytes());
sb[20..24].copy_from_slice(&1u32.to_le_bytes()); sb[24..28].copy_from_slice(&0u32.to_le_bytes()); sb[32..36].copy_from_slice(&(TOTAL_BLOCKS as u32).to_le_bytes());
sb[40..44].copy_from_slice(&(INODES_PER_GROUP as u32).to_le_bytes());
sb[56..58].copy_from_slice(&EXT_MAGIC.to_le_bytes());
sb[76..80].copy_from_slice(&1u32.to_le_bytes()); sb[84..88].copy_from_slice(&11u32.to_le_bytes()); sb[88..90].copy_from_slice(&(INODE_SIZE as u16).to_le_bytes());
sb[96..100].copy_from_slice(&INCOMPAT_FILETYPE.to_le_bytes());
sb[236..238].copy_from_slice(&32u16.to_le_bytes()); }
{
let bgd = &mut img[2 * BS..2 * BS + 32];
bgd[0..4].copy_from_slice(&3u32.to_le_bytes()); bgd[4..8].copy_from_slice(&4u32.to_le_bytes()); bgd[8..12].copy_from_slice(&(INODE_TABLE_BLK as u32).to_le_bytes());
}
{
let off = INODE_TABLE_BLK * BS + (ROOT_INUM - 1) * INODE_SIZE;
let ino = &mut img[off..off + INODE_SIZE];
ino[0..2].copy_from_slice(&(S_IFDIR | 0o755).to_le_bytes());
ino[4..8].copy_from_slice(&(BS as u32).to_le_bytes()); ino[26..28].copy_from_slice(&2u16.to_le_bytes());
ino[32..36].copy_from_slice(&0u32.to_le_bytes()); let si_offset = 40 + 12 * 4;
ino[si_offset..si_offset + 4].copy_from_slice(&(SI_BLK as u32).to_le_bytes());
}
{
let off = INODE_TABLE_BLK * BS + (FILE_INUM - 1) * INODE_SIZE;
let ino = &mut img[off..off + INODE_SIZE];
ino[0..2].copy_from_slice(&(S_IFREG | 0o644).to_le_bytes());
ino[4..8].copy_from_slice(&12u32.to_le_bytes());
ino[26..28].copy_from_slice(&1u16.to_le_bytes());
ino[40..44].copy_from_slice(&(FILE_DATA_BLK as u32).to_le_bytes());
}
{
let off = SI_BLK * BS;
img[off..off + 4].copy_from_slice(&(DIR_DATA_BLK as u32).to_le_bytes());
}
{
let d = &mut img[DIR_DATA_BLK * BS..DIR_DATA_BLK * BS + BS];
d[0..4].copy_from_slice(&(ROOT_INUM as u32).to_le_bytes());
d[4..6].copy_from_slice(&12u16.to_le_bytes());
d[6] = 1;
d[7] = 2;
d[8] = b'.';
d[12..16].copy_from_slice(&(ROOT_INUM as u32).to_le_bytes());
d[16..18].copy_from_slice(&12u16.to_le_bytes());
d[18] = 2;
d[19] = 2;
d[20] = b'.';
d[21] = b'.';
let name = b"hello.txt";
d[24..28].copy_from_slice(&(FILE_INUM as u32).to_le_bytes());
d[28..30].copy_from_slice(&((BS - 24) as u16).to_le_bytes());
d[30] = name.len() as u8;
d[31] = 1; d[32..32 + name.len()].copy_from_slice(name);
}
img[FILE_DATA_BLK * BS..FILE_DATA_BLK * BS + 12].copy_from_slice(b"hello world\n");
img
}
#[test]
fn single_indirect_dir_parsed_correctly() {
let img = make_ext2_single_indirect_dir();
let mut c = cursor_of(&img);
let root = detect_and_parse(&mut c).expect("SI dir image should parse");
assert_eq!(root.name, "/");
let child = root.children.iter().find(|n| n.name == "hello.txt");
assert!(child.is_some(), "hello.txt should appear via SI dir");
}
fn make_ext4_extent_dir() -> Vec<u8> {
const BS: usize = 1024;
let mut img = make_ext2_single_indirect_dir();
const INODE_SIZE: usize = 128;
const INODE_TABLE_BLK: usize = 5;
const ROOT_INUM: usize = 2;
const DIR_DATA_BLK: usize = 6;
let off = INODE_TABLE_BLK * BS + (ROOT_INUM - 1) * INODE_SIZE;
img[off + 32..off + 36].copy_from_slice(&EXT4_EXTENTS_FL.to_le_bytes());
img[off + 40..off + 100].fill(0);
let ea = &mut img[off + 40..off + 100];
ea[0..2].copy_from_slice(&EXTENT_MAGIC.to_le_bytes());
ea[2..4].copy_from_slice(&1u16.to_le_bytes()); ea[4..6].copy_from_slice(&4u16.to_le_bytes()); ea[6..8].copy_from_slice(&0u16.to_le_bytes()); ea[12..16].copy_from_slice(&0u32.to_le_bytes());
ea[16..18].copy_from_slice(&1u16.to_le_bytes()); ea[18..20].copy_from_slice(&0u16.to_le_bytes()); ea[20..24].copy_from_slice(&(DIR_DATA_BLK as u32).to_le_bytes());
img
}
#[test]
fn extent_dir_read_entries_extent_path() {
let img = make_ext4_extent_dir();
let mut c = cursor_of(&img);
let root = detect_and_parse(&mut c).expect("extent dir image should parse");
assert_eq!(root.name, "/");
let child = root.children.iter().find(|n| n.name == "hello.txt");
assert!(child.is_some(), "hello.txt should appear via extent dir");
}
#[test]
fn scan_dir_block_bad_rec_len_breaks() {
let mut data = vec![0u8; 64];
data[0..4].copy_from_slice(&2u32.to_le_bytes()); data[4..6].copy_from_slice(&4u16.to_le_bytes()); data[6] = 1; let mut entries: Vec<DirEntry> = Vec::new();
scan_dir_block(&data, false, &mut entries);
assert!(entries.is_empty(), "bad rec_len should break immediately");
}
#[test]
fn single_run_location_first_block_zero_returns_none() {
let inode = Inode {
mode: S_IFREG | 0o644,
size: 12,
flags: 0,
i_block: [0; 15], };
let sb = Superblock {
inodes_per_group: 256,
first_data_block: 1,
log_block_size: 0,
inode_size: 128,
feature_incompat: INCOMPAT_FILETYPE,
desc_size: 32,
};
let mut c = Cursor::new(vec![0u8; 0]);
let loc = single_run_location(&mut c, &sb, 0, &inode).unwrap();
assert!(loc.is_none(), "zero first block → no location");
}
#[test]
fn block_device_inode_skipped_in_tree() {
const BS: usize = 1024;
const INODE_SIZE: usize = 128;
const INODE_TABLE_BLK: usize = 5;
const ROOT_DATA_BLK: usize = 6;
const DEVNODE_INUM: usize = 4;
let mut img = make_ext2_image();
let dev_off = INODE_TABLE_BLK * BS + (DEVNODE_INUM - 1) * INODE_SIZE;
img[dev_off..dev_off + 2].copy_from_slice(&0x6000u16.to_le_bytes()); img[dev_off + 26..dev_off + 28].copy_from_slice(&1u16.to_le_bytes());
let dir = ROOT_DATA_BLK * BS;
img[dir + 24 + 4..dir + 24 + 6].copy_from_slice(&20u16.to_le_bytes()); let e_off = 44;
img[dir + e_off..dir + e_off + 4].copy_from_slice(&(DEVNODE_INUM as u32).to_le_bytes());
img[dir + e_off + 4..dir + e_off + 6].copy_from_slice(&((BS - e_off) as u16).to_le_bytes());
img[dir + e_off + 6] = 7; img[dir + e_off + 7] = 6; img[dir + e_off + 8..dir + e_off + 15].copy_from_slice(b"devnode");
let mut c = cursor_of(&img);
let root = detect_and_parse(&mut c).expect("image with device inode should parse");
assert!(
root.find_node("/devnode").is_none(),
"device inode should be skipped"
);
assert!(root.find_node("/hello.txt").is_some());
}
#[test]
fn classical_blocks_si_covered_before_use() {
let img = make_ext2_image();
let mut c = cursor_of(&img);
let root = detect_and_parse(&mut c).expect("parse");
assert!(root.find_node("/hello.txt").is_some());
}
#[test]
fn classical_blocks_double_indirect() {
const BS: usize = 1024;
let mut img = vec![0u8; 20 * BS];
img[14 * BS..14 * BS + 4].copy_from_slice(&15u32.to_le_bytes());
img[15 * BS..15 * BS + 4].copy_from_slice(&16u32.to_le_bytes());
let sb = Superblock {
inodes_per_group: 256,
first_data_block: 1,
log_block_size: 0, inode_size: 128,
feature_incompat: INCOMPAT_FILETYPE,
desc_size: 32,
};
let mut i_block = [0u32; 15];
for (i, b) in i_block[0..12].iter_mut().enumerate() {
*b = (i + 1) as u32; }
i_block[12] = 13; i_block[13] = 14; let inode = Inode {
mode: S_IFREG,
size: 12289,
flags: 0,
i_block,
};
let mut c = Cursor::new(img);
let blocks = collect_classical_blocks(&mut c, &sb, 0, &inode, 12289).unwrap();
assert_eq!(blocks.len(), 13);
assert_eq!(blocks[0], 1);
assert_eq!(blocks[12], 16); }
#[test]
fn classical_blocks_triple_indirect() {
const BS: usize = 1024;
let mut img = vec![0u8; 25 * BS];
img[17 * BS..17 * BS + 4].copy_from_slice(&18u32.to_le_bytes());
img[18 * BS..18 * BS + 4].copy_from_slice(&19u32.to_le_bytes());
img[19 * BS..19 * BS + 4].copy_from_slice(&20u32.to_le_bytes());
let sb = Superblock {
inodes_per_group: 256,
first_data_block: 1,
log_block_size: 0,
inode_size: 128,
feature_incompat: INCOMPAT_FILETYPE,
desc_size: 32,
};
let mut i_block = [0u32; 15];
for (i, b) in i_block[0..12].iter_mut().enumerate() {
*b = (i + 1) as u32; }
i_block[14] = 17; let inode = Inode {
mode: S_IFREG,
size: 12289,
flags: 0,
i_block,
};
let mut c = Cursor::new(img);
let blocks = collect_classical_blocks(&mut c, &sb, 0, &inode, 12289).unwrap();
assert_eq!(blocks.len(), 13);
assert_eq!(blocks[12], 20); }
#[test]
fn build_tree_exceeds_max_depth_returns_none() {
let img = make_ext2_image();
let sb = Superblock {
inodes_per_group: 256,
first_data_block: 1,
log_block_size: 0,
inode_size: 128,
feature_incompat: INCOMPAT_FILETYPE,
desc_size: 32,
};
let mut c = cursor_of(&img);
let result = build_tree(&mut c, &sb, 0, "deep".to_string(), 2, MAX_DEPTH + 1);
assert!(
result.unwrap().is_none(),
"depth > MAX_DEPTH should return None"
);
}
}