use std::io::Write;
use std::process::Command;
use fstool::block::{BlockDevice, FileBackend};
use fstool::fs::ext::{Ext, FormatOpts, FsKind};
use fstool::fs::{FileMeta, FileSource};
use tempfile::NamedTempFile;
fn which(tool: &str) -> Option<std::path::PathBuf> {
let out = Command::new("sh")
.arg("-c")
.arg(format!("command -v {tool}"))
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?;
let p = s.trim();
if p.is_empty() { None } else { Some(p.into()) }
}
#[test]
fn read_default_mke2fs_ext4_image() {
use std::io::Read;
let Some(_) = which("mke2fs") else {
eprintln!("skipping: mke2fs not installed");
return;
};
let srcdir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(srcdir.path().join("etc")).unwrap();
std::fs::write(srcdir.path().join("readme"), b"default ext4\n").unwrap();
std::fs::write(srcdir.path().join("etc/conf"), b"x=1\n").unwrap();
let tmp = NamedTempFile::new().unwrap();
let out = Command::new("mke2fs")
.args([
"-F",
"-t",
"ext4",
"-b",
"1024",
"-L",
"",
"-U",
"00000000-0000-0000-0000-000000000000",
"-E",
"nodiscard",
"-d",
])
.arg(srcdir.path())
.arg(tmp.path())
.arg("8192")
.output()
.unwrap();
assert!(
out.status.success(),
"mke2fs failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
let mut dev = FileBackend::open(tmp.path()).unwrap();
let ext = Ext::open(&mut dev).unwrap();
assert_eq!(ext.kind, FsKind::Ext4);
assert_eq!(ext.sb.group_desc_size(), 64);
let root = ext.list_inode(&mut dev, 2).unwrap();
let names: std::collections::HashSet<_> = root.iter().map(|e| e.name.clone()).collect();
assert!(names.contains("readme"), "missing /readme: {names:?}");
assert!(names.contains("etc"), "missing /etc: {names:?}");
let ino = ext.path_to_inode(&mut dev, "/readme").unwrap();
let mut reader = ext.open_file_reader(&mut dev, ino).unwrap();
let mut body = Vec::new();
reader.read_to_end(&mut body).unwrap();
assert_eq!(body, b"default ext4\n");
let ino = ext.path_to_inode(&mut dev, "/etc/conf").unwrap();
let mut reader = ext.open_file_reader(&mut dev, ino).unwrap();
let mut body = Vec::new();
reader.read_to_end(&mut body).unwrap();
assert_eq!(body, b"x=1\n");
}
#[test]
fn ext4_sparse_file_uses_holes() {
use std::io::Read;
let Some(_) = which("e2fsck") else {
eprintln!("skipping: e2fsck not installed");
return;
};
let mut body = vec![b'A'; 4096];
body.extend(std::iter::repeat_n(0u8, 248 * 1024));
body.extend(std::iter::repeat_n(b'B', 4096));
let srcdir = tempfile::tempdir().unwrap();
std::fs::write(srcdir.path().join("hole.bin"), &body).unwrap();
let opts = FormatOpts {
kind: FsKind::Ext4,
blocks_count: 8192,
inodes_count: 64,
journal_blocks: 1024,
sparse: true,
..FormatOpts::default()
};
let tmp = NamedTempFile::new().unwrap();
let mut dev = FileBackend::create(
tmp.path(),
opts.blocks_count as u64 * opts.block_size as u64,
)
.unwrap();
let mut ext = Ext::format_with(&mut dev, &opts).unwrap();
ext.add_file_to(
&mut dev,
2,
b"hole.bin",
FileSource::HostPath(srcdir.path().join("hole.bin")),
FileMeta::with_mode(0o644),
)
.unwrap();
ext.flush(&mut dev).unwrap();
dev.sync().unwrap();
let ino = ext.path_to_inode(&mut dev, "/hole.bin").unwrap();
let mut got = Vec::new();
ext.open_file_reader(&mut dev, ino)
.unwrap()
.read_to_end(&mut got)
.unwrap();
assert_eq!(got, body, "sparse file content mismatch");
let inode = ext.read_inode(&mut dev, ino).unwrap();
assert!(
inode.blocks_512 < 64,
"sparse file used {} sectors, expected far fewer than the dense 512",
inode.blocks_512
);
drop(dev);
let out = Command::new("e2fsck")
.arg("-fn")
.arg(tmp.path())
.output()
.unwrap();
assert!(
out.status.success(),
"e2fsck failed on sparse ext4:\n{}",
String::from_utf8_lossy(&out.stdout)
);
}
#[test]
fn ext4_passes_e2fsck_and_advertises_features() {
let Some(_) = which("e2fsck") else {
eprintln!("skipping: e2fsck not installed");
return;
};
let Some(_) = which("dumpe2fs") else {
eprintln!("skipping: dumpe2fs not installed");
return;
};
let Some(_) = which("debugfs") else {
eprintln!("skipping: debugfs not installed");
return;
};
let tmp = NamedTempFile::new().unwrap();
let opts = FormatOpts {
kind: FsKind::Ext4,
blocks_count: 8192,
inodes_count: 64,
journal_blocks: 1024,
..FormatOpts::default()
};
let size = opts.blocks_count as u64 * opts.block_size as u64;
let mut dev = FileBackend::create(tmp.path(), size).unwrap();
let mut ext = Ext::format_with(&mut dev, &opts).unwrap();
let mut src = NamedTempFile::new().unwrap();
src.as_file_mut()
.write_all(b"the quick brown fox\n")
.unwrap();
ext.add_file_to(
&mut dev,
2,
b"fox.txt",
FileSource::HostPath(src.path().to_path_buf()),
FileMeta::with_mode(0o644),
)
.unwrap();
ext.flush(&mut dev).unwrap();
dev.sync().unwrap();
drop(dev);
let out = Command::new("e2fsck")
.arg("-fn")
.arg(tmp.path())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"e2fsck failed:\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
let out = Command::new("dumpe2fs")
.arg("-h")
.arg(tmp.path())
.output()
.unwrap();
let dump = String::from_utf8_lossy(&out.stdout);
assert!(dump.contains("extent"), "missing `extent` feature:\n{dump}");
assert!(dump.contains("has_journal"), "missing has_journal:\n{dump}");
let out = Command::new("debugfs")
.arg("-R")
.arg("stat /fox.txt")
.arg(tmp.path())
.output()
.unwrap();
let stat = String::from_utf8_lossy(&out.stdout);
assert!(
stat.contains("EXTENTS") || stat.contains("Extents"),
"expected extent-mode inode:\n{stat}"
);
let out = Command::new("debugfs")
.arg("-R")
.arg("cat /fox.txt")
.arg(tmp.path())
.output()
.unwrap();
let body = String::from_utf8_lossy(&out.stdout);
assert!(
body.contains("the quick brown fox"),
"wrong file body via debugfs:\n{body}"
);
}
#[test]
fn ext4_open_reads_extent_file() {
use std::io::Read;
let tmp = NamedTempFile::new().unwrap();
let opts = FormatOpts {
kind: FsKind::Ext4,
blocks_count: 8192,
inodes_count: 64,
journal_blocks: 1024,
..FormatOpts::default()
};
let size = opts.blocks_count as u64 * opts.block_size as u64;
let mut dev = FileBackend::create(tmp.path(), size).unwrap();
{
let mut ext = Ext::format_with(&mut dev, &opts).unwrap();
let mut src = NamedTempFile::new().unwrap();
src.as_file_mut()
.write_all(b"extent-encoded payload\n")
.unwrap();
ext.add_file_to(
&mut dev,
2,
b"payload.bin",
FileSource::HostPath(src.path().to_path_buf()),
FileMeta::with_mode(0o644),
)
.unwrap();
ext.flush(&mut dev).unwrap();
dev.sync().unwrap();
}
let ext = Ext::open(&mut dev).unwrap();
assert_eq!(ext.kind, FsKind::Ext4);
let ino = ext.path_to_inode(&mut dev, "/payload.bin").unwrap();
let mut reader = ext.open_file_reader(&mut dev, ino).unwrap();
let mut body = Vec::new();
reader.read_to_end(&mut body).unwrap();
assert_eq!(body, b"extent-encoded payload\n");
}
#[test]
fn ext4_sparse_super_skips_non_backup_groups() {
let Some(_) = which("e2fsck") else {
eprintln!("skipping: e2fsck not installed");
return;
};
let Some(_) = which("dumpe2fs") else {
eprintln!("skipping: dumpe2fs not installed");
return;
};
let opts = FormatOpts {
kind: FsKind::Ext4,
blocks_count: 32 * 1024,
inodes_count: 64,
journal_blocks: 1024,
sparse_super: true,
..FormatOpts::default()
};
let tmp = NamedTempFile::new().unwrap();
let size = opts.blocks_count as u64 * opts.block_size as u64;
let mut dev = FileBackend::create(tmp.path(), size).unwrap();
Ext::format_with(&mut dev, &opts).unwrap();
dev.sync().unwrap();
drop(dev);
let fsck = Command::new("e2fsck")
.arg("-fn")
.arg(tmp.path())
.output()
.unwrap();
assert!(
fsck.status.success(),
"e2fsck failed on sparse_super image:\n{}",
String::from_utf8_lossy(&fsck.stdout)
);
let dump = Command::new("dumpe2fs")
.arg("-h")
.arg(tmp.path())
.output()
.unwrap();
let header = String::from_utf8_lossy(&dump.stdout);
assert!(
header.contains("sparse_super"),
"sparse_super flag missing from dumpe2fs:\n{header}"
);
let dump = Command::new("dumpe2fs").arg(tmp.path()).output().unwrap();
let body = String::from_utf8_lossy(&dump.stdout);
let mut g2_has_sb = false;
let mut g3_has_sb = false;
let mut current_group: Option<u32> = None;
for line in body.lines() {
if let Some(rest) = line.strip_prefix("Group ") {
let num: u32 = rest
.split_whitespace()
.next()
.unwrap()
.trim_end_matches(':')
.parse()
.unwrap_or(0);
current_group = Some(num);
}
if matches!(current_group, Some(2)) && line.contains("superblock at") {
g2_has_sb = true;
}
if matches!(current_group, Some(3)) && line.contains("superblock at") {
g3_has_sb = true;
}
}
assert!(
!g2_has_sb,
"group 2 should NOT have a backup superblock with sparse_super:\n{body}"
);
assert!(
g3_has_sb,
"group 3 SHOULD have a backup superblock (3 is a power of 3):\n{body}"
);
}