use std::process::Command;
use tempfile::NamedTempFile;
const FSTOOL: &str = env!("CARGO_BIN_EXE_fstool");
fn which(tool: &str) -> bool {
Command::new("sh")
.arg("-c")
.arg(format!("command -v {tool}"))
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[test]
fn cli_build_ls_cat_add_roundtrip() {
if !which("e2fsck") {
eprintln!("skipping: e2fsck not installed");
return;
}
let srcdir = tempfile::tempdir().unwrap();
std::fs::write(srcdir.path().join("one.txt"), b"first\n").unwrap();
let spec = NamedTempFile::new().unwrap();
std::fs::write(
spec.path(),
format!(
"[filesystem]\ntype = \"ext4\"\nsource = \"{}\"\nblock_size = 1024\n",
srcdir.path().display()
),
)
.unwrap();
let img = NamedTempFile::new().unwrap();
let out = Command::new(FSTOOL)
.arg("build")
.arg(spec.path())
.arg("-o")
.arg(img.path())
.output()
.unwrap();
assert!(
out.status.success(),
"build failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = Command::new(FSTOOL)
.args(["ls"])
.arg(img.path())
.arg("/")
.output()
.unwrap();
assert!(out.status.success());
let listing = String::from_utf8_lossy(&out.stdout);
assert!(
listing.contains("one.txt"),
"ls missing one.txt:\n{listing}"
);
let out = Command::new(FSTOOL)
.args(["cat"])
.arg(img.path())
.arg("/one.txt")
.output()
.unwrap();
assert!(out.status.success());
assert_eq!(out.stdout, b"first\n");
let extra = NamedTempFile::new().unwrap();
std::fs::write(extra.path(), b"added via cli\n").unwrap();
let out = Command::new(FSTOOL)
.arg("add")
.arg(img.path())
.arg(extra.path())
.arg("/two.txt")
.output()
.unwrap();
assert!(
out.status.success(),
"add failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let fsck = Command::new("e2fsck")
.arg("-fn")
.arg(img.path())
.output()
.unwrap();
assert!(
fsck.status.success(),
"e2fsck failed after add:\n{}",
String::from_utf8_lossy(&fsck.stdout)
);
let out = Command::new(FSTOOL)
.args(["cat"])
.arg(img.path())
.arg("/two.txt")
.output()
.unwrap();
assert!(out.status.success());
assert_eq!(out.stdout, b"added via cli\n");
}
#[test]
fn cli_rm_file_and_empty_dir() {
if !which("e2fsck") {
eprintln!("skipping: e2fsck not installed");
return;
}
let srcdir = tempfile::tempdir().unwrap();
std::fs::write(srcdir.path().join("doomed.txt"), b"bye\n").unwrap();
std::fs::create_dir(srcdir.path().join("emptydir")).unwrap();
std::fs::create_dir(srcdir.path().join("fulldir")).unwrap();
std::fs::write(srcdir.path().join("fulldir/keep"), b"k\n").unwrap();
let img = NamedTempFile::new().unwrap();
let out = Command::new(FSTOOL)
.args(["ext-build", "--kind", "ext4"])
.arg(srcdir.path())
.arg("-o")
.arg(img.path())
.output()
.unwrap();
assert!(out.status.success());
let out = Command::new(FSTOOL)
.arg("rm")
.arg(img.path())
.arg("/doomed.txt")
.output()
.unwrap();
assert!(
out.status.success(),
"rm file failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = Command::new(FSTOOL)
.arg("rm")
.arg(img.path())
.arg("/emptydir")
.output()
.unwrap();
assert!(
out.status.success(),
"rm empty dir failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = Command::new(FSTOOL)
.arg("rm")
.arg(img.path())
.arg("/fulldir")
.output()
.unwrap();
assert!(!out.status.success(), "rm non-empty dir should have failed");
let fsck = Command::new("e2fsck")
.arg("-fn")
.arg(img.path())
.output()
.unwrap();
assert!(
fsck.status.success(),
"e2fsck failed after rm:\n{}",
String::from_utf8_lossy(&fsck.stdout)
);
let out = Command::new(FSTOOL)
.args(["ls"])
.arg(img.path())
.arg("/")
.output()
.unwrap();
let listing = String::from_utf8_lossy(&out.stdout);
assert!(!listing.contains("doomed.txt"), "doomed.txt still present");
assert!(!listing.contains("emptydir"), "emptydir still present");
assert!(listing.contains("fulldir"), "fulldir wrongly removed");
}
#[test]
fn cli_info_reports_ext4() {
let srcdir = tempfile::tempdir().unwrap();
std::fs::write(srcdir.path().join("x"), b"y\n").unwrap();
let img = NamedTempFile::new().unwrap();
let out = Command::new(FSTOOL)
.args(["ext-build", "--kind", "ext4"])
.arg(srcdir.path())
.arg("-o")
.arg(img.path())
.output()
.unwrap();
assert!(out.status.success());
let out = Command::new(FSTOOL)
.arg("info")
.arg(img.path())
.output()
.unwrap();
assert!(out.status.success());
let info = String::from_utf8_lossy(&out.stdout);
assert!(info.contains("ext4"), "info missing kind:\n{info}");
assert!(
info.contains("block size"),
"info missing block size:\n{info}"
);
}
#[test]
fn cli_partition_target_syntax() {
if !which("e2fsck") {
eprintln!("skipping: e2fsck not installed");
return;
}
let srcdir = tempfile::tempdir().unwrap();
std::fs::write(srcdir.path().join("hello"), b"in a partition\n").unwrap();
std::fs::create_dir(srcdir.path().join("etc")).unwrap();
std::fs::write(srcdir.path().join("etc/app.conf"), b"mode=on\n").unwrap();
let spec = NamedTempFile::new().unwrap();
std::fs::write(
spec.path(),
format!(
r#"
[image]
size = "128MiB"
partition_table = "gpt"
[[partitions]]
name = "EFI"
type = "esp"
size = "48MiB"
[partitions.filesystem]
type = "fat32"
volume_label = "EFI"
[[partitions]]
name = "root"
type = "linux"
size = "remaining"
[partitions.filesystem]
type = "ext4"
source = "{}"
block_size = 1024
"#,
srcdir.path().display()
),
)
.unwrap();
let img = NamedTempFile::new().unwrap();
let out = Command::new(FSTOOL)
.arg("build")
.arg(spec.path())
.arg("-o")
.arg(img.path())
.output()
.unwrap();
assert!(
out.status.success(),
"build failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
let out = Command::new(FSTOOL)
.arg("info")
.arg(img.path())
.output()
.unwrap();
assert!(out.status.success());
let s = String::from_utf8_lossy(&out.stdout);
assert!(s.contains("partition table:"), "missing table header:\n{s}");
assert!(s.contains("gpt"), "expected gpt label:\n{s}");
assert!(s.contains("EFI"), "expected EFI name:\n{s}");
assert!(s.contains("root"), "expected root name:\n{s}");
let out = Command::new(FSTOOL)
.arg("info")
.arg(format!("{}:1", img.path().display()))
.output()
.unwrap();
assert!(
out.status.success(),
"info :1 failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
let s = String::from_utf8_lossy(&out.stdout);
assert!(s.contains("fat32"), "expected fat32 fs:\n{s}");
let out = Command::new(FSTOOL)
.arg("info")
.arg(format!("{}:2", img.path().display()))
.output()
.unwrap();
assert!(out.status.success());
let s = String::from_utf8_lossy(&out.stdout);
assert!(s.contains("ext4"), "expected ext4 fs:\n{s}");
let out = Command::new(FSTOOL)
.arg("ls")
.arg(format!("{}:2", img.path().display()))
.arg("/")
.output()
.unwrap();
assert!(out.status.success());
let s = String::from_utf8_lossy(&out.stdout);
assert!(s.contains("hello"));
assert!(s.contains("etc"));
let out = Command::new(FSTOOL)
.arg("cat")
.arg(format!("{}:2", img.path().display()))
.arg("/etc/app.conf")
.output()
.unwrap();
assert!(out.status.success());
assert_eq!(out.stdout, b"mode=on\n");
let out = Command::new(FSTOOL)
.arg("ls")
.arg(format!("{}:9", img.path().display()))
.arg("/")
.output()
.unwrap();
assert!(!out.status.success());
let s = String::from_utf8_lossy(&out.stderr);
assert!(
s.contains("out of range"),
"expected out-of-range error:\n{s}"
);
let extra = NamedTempFile::new().unwrap();
std::fs::write(extra.path(), b"added to root partition\n").unwrap();
let out = Command::new(FSTOOL)
.arg("add")
.arg(format!("{}:2", img.path().display()))
.arg(extra.path())
.arg("/new.txt")
.output()
.unwrap();
assert!(
out.status.success(),
"add :2 failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
if which("sgdisk") {
let p = Command::new("sgdisk")
.arg("-p")
.arg(img.path())
.output()
.unwrap();
let pout = String::from_utf8_lossy(&p.stdout);
let root_line = pout
.lines()
.find(|l| l.trim_start().starts_with("2 "))
.expect("partition 2 line");
let nums: Vec<u64> = root_line
.split_whitespace()
.skip(1)
.take(2)
.map(|s| s.parse().unwrap())
.collect();
let (start, end) = (nums[0], nums[1]);
let part = NamedTempFile::new().unwrap();
let dd = Command::new("dd")
.arg(format!("if={}", img.path().display()))
.arg(format!("of={}", part.path().display()))
.arg("bs=512")
.arg(format!("skip={start}"))
.arg(format!("count={}", end - start + 1))
.arg("status=none")
.output()
.unwrap();
assert!(dd.status.success());
let fsck = Command::new("e2fsck")
.arg("-fn")
.arg(part.path())
.output()
.unwrap();
assert!(
fsck.status.success(),
"e2fsck on root partition failed after :2 add:\n{}",
String::from_utf8_lossy(&fsck.stdout)
);
}
}
#[test]
fn cli_shell_navigates_and_mutates() {
if !which("e2fsck") {
eprintln!("skipping: e2fsck not installed");
return;
}
let srcdir = tempfile::tempdir().unwrap();
std::fs::create_dir(srcdir.path().join("docs")).unwrap();
std::fs::write(srcdir.path().join("docs/readme"), b"deep body\n").unwrap();
std::fs::write(srcdir.path().join("top.txt"), b"top body\n").unwrap();
let img = NamedTempFile::new().unwrap();
let out = Command::new(FSTOOL)
.args(["ext-build", "--kind", "ext4"])
.arg(srcdir.path())
.arg("-o")
.arg(img.path())
.output()
.unwrap();
assert!(out.status.success(), "ext-build failed");
let extra = NamedTempFile::new().unwrap();
std::fs::write(extra.path(), b"shell-added\n").unwrap();
let script = format!(
"pwd\n\
ls /\n\
cd docs\n\
pwd\n\
cat readme\n\
cd ..\n\
mkdir /new\n\
put {} /new/copy.txt\n\
cat /new/copy.txt\n\
rm /top.txt\n\
ls /\n\
quit\n",
extra.path().display()
);
let mut child = std::process::Command::new(FSTOOL)
.arg("shell")
.arg(img.path())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
{
use std::io::Write;
child
.stdin
.as_mut()
.unwrap()
.write_all(script.as_bytes())
.unwrap();
}
let out = child.wait_with_output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
out.status.success(),
"shell exited non-zero:\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
stdout.contains("top.txt"),
"missing top.txt in /:\n{stdout}"
);
assert!(stdout.contains("docs/"), "missing docs/ in /:\n{stdout}");
assert!(stdout.contains("/docs"), "pwd after cd docs:\n{stdout}");
assert!(stdout.contains("deep body"), "cat readme output:\n{stdout}");
assert!(
stdout.contains("shell-added"),
"cat of added file:\n{stdout}"
);
let final_listing = Command::new(FSTOOL)
.args(["ls"])
.arg(img.path())
.arg("/")
.output()
.unwrap();
let s = String::from_utf8_lossy(&final_listing.stdout);
assert!(!s.contains("top.txt"), "top.txt should be gone:\n{s}");
assert!(s.contains("new"), "/new should exist:\n{s}");
let fsck = Command::new("e2fsck")
.arg("-fn")
.arg(img.path())
.output()
.unwrap();
assert!(
fsck.status.success(),
"e2fsck failed after shell mutations:\n{}",
String::from_utf8_lossy(&fsck.stdout)
);
}
#[test]
fn cli_fat32_add_and_rm() {
if !which("fsck.vfat") {
eprintln!("skipping: fsck.vfat not installed");
return;
}
let srcdir = tempfile::tempdir().unwrap();
std::fs::write(srcdir.path().join("keep.txt"), b"keep\n").unwrap();
std::fs::write(srcdir.path().join("goodbye.txt"), b"bye\n").unwrap();
let img = NamedTempFile::new().unwrap();
let out = Command::new(FSTOOL)
.args(["fat-build", "--size", "64MiB", "--label", "CLIRM"])
.arg(srcdir.path())
.arg("-o")
.arg(img.path())
.output()
.unwrap();
assert!(
out.status.success(),
"fat-build failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let extra = NamedTempFile::new().unwrap();
std::fs::write(extra.path(), b"added via cli\n").unwrap();
let out = Command::new(FSTOOL)
.arg("add")
.arg(img.path())
.arg(extra.path())
.arg("/added.txt")
.output()
.unwrap();
assert!(
out.status.success(),
"add failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = Command::new(FSTOOL)
.args(["cat"])
.arg(img.path())
.arg("/added.txt")
.output()
.unwrap();
assert!(out.status.success());
assert_eq!(out.stdout, b"added via cli\n");
let out = Command::new(FSTOOL)
.arg("rm")
.arg(img.path())
.arg("/goodbye.txt")
.output()
.unwrap();
assert!(
out.status.success(),
"rm failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let res = Command::new("fsck.vfat")
.args(["-n", "-v"])
.arg(img.path())
.output()
.unwrap();
assert!(
res.status.success(),
"fsck.vfat failed after add/rm:\n{}",
String::from_utf8_lossy(&res.stdout)
);
let out = Command::new(FSTOOL)
.args(["ls"])
.arg(img.path())
.arg("/")
.output()
.unwrap();
let listing = String::from_utf8_lossy(&out.stdout);
assert!(listing.contains("keep.txt"));
assert!(listing.contains("added.txt"));
assert!(!listing.contains("goodbye.txt"));
}
#[test]
fn cli_fat32_build_ls_cat_info_roundtrip() {
let srcdir = tempfile::tempdir().unwrap();
std::fs::write(srcdir.path().join("short.txt"), b"short body\n").unwrap();
std::fs::create_dir(srcdir.path().join("nest")).unwrap();
std::fs::write(
srcdir.path().join("nest/A Long Name.md"),
b"long-name body\n",
)
.unwrap();
let img = NamedTempFile::new().unwrap();
let out = Command::new(FSTOOL)
.args(["fat-build", "--size", "64MiB", "--label", "CLIFAT"])
.arg(srcdir.path())
.arg("-o")
.arg(img.path())
.output()
.unwrap();
assert!(
out.status.success(),
"fat-build failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
let out = Command::new(FSTOOL)
.arg("info")
.arg(img.path())
.output()
.unwrap();
assert!(out.status.success());
let info = String::from_utf8_lossy(&out.stdout);
assert!(info.contains("fat32"), "info missing fat32:\n{info}");
assert!(info.contains("CLIFAT"), "info missing label:\n{info}");
let out = Command::new(FSTOOL)
.args(["ls"])
.arg(img.path())
.arg("/")
.output()
.unwrap();
assert!(out.status.success());
let listing = String::from_utf8_lossy(&out.stdout);
assert!(listing.contains("short.txt"));
assert!(listing.contains("nest"));
let out = Command::new(FSTOOL)
.args(["ls"])
.arg(img.path())
.arg("/nest")
.output()
.unwrap();
assert!(out.status.success());
let nest = String::from_utf8_lossy(&out.stdout);
assert!(
nest.contains("A Long Name.md"),
"long-name entry missing from /nest:\n{nest}"
);
let out = Command::new(FSTOOL)
.args(["cat"])
.arg(img.path())
.arg("/nest/A Long Name.md")
.output()
.unwrap();
assert!(
out.status.success(),
"cat failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(out.stdout, b"long-name body\n");
}
#[test]
fn cli_convert_raw_qcow2_roundtrip() {
if !which("qemu-img") {
eprintln!("skipping: qemu-img not installed");
return;
}
let srcdir = tempfile::tempdir().unwrap();
std::fs::write(srcdir.path().join("hello"), b"hello convert\n").unwrap();
let raw = NamedTempFile::new().unwrap();
let out = Command::new(FSTOOL)
.args(["ext-build", "--kind", "ext4"])
.arg(srcdir.path())
.arg("-o")
.arg(raw.path())
.output()
.unwrap();
assert!(out.status.success(), "ext-build failed");
let dir = tempfile::tempdir().unwrap();
let qcow = dir.path().join("disk.qcow2");
let raw2 = dir.path().join("disk2.img");
let r = Command::new(FSTOOL)
.arg("convert")
.arg(raw.path())
.arg(&qcow)
.output()
.unwrap();
assert!(
r.status.success(),
"convert raw→qcow2 failed:\n{}",
String::from_utf8_lossy(&r.stderr)
);
let chk = Command::new("qemu-img")
.arg("check")
.arg(&qcow)
.output()
.unwrap();
assert!(chk.status.success(), "qemu-img check failed");
let r = Command::new(FSTOOL)
.arg("convert")
.arg(&qcow)
.arg(&raw2)
.output()
.unwrap();
assert!(r.status.success(), "convert qcow2→raw failed");
let a = std::fs::read(raw.path()).unwrap();
let b = std::fs::read(&raw2).unwrap();
assert_eq!(a, b, "round-tripped raw differs from original");
let big = dir.path().join("big.qcow2");
let r = Command::new(FSTOOL)
.arg("convert")
.arg(raw.path())
.arg(&big)
.args(["--size", "64MiB"])
.output()
.unwrap();
assert!(r.status.success(), "convert --size grow failed");
let ls = Command::new(FSTOOL)
.arg("ls")
.arg(&big)
.arg("/")
.output()
.unwrap();
assert!(ls.status.success());
let s = String::from_utf8_lossy(&ls.stdout);
assert!(s.contains("hello"), "grown image lost source content:\n{s}");
let small = dir.path().join("small.qcow2");
let r = Command::new(FSTOOL)
.arg("convert")
.arg(raw.path())
.arg(&small)
.args(["--size", "1KiB"])
.output()
.unwrap();
assert!(!r.status.success(), "shrink should have been rejected");
let s = String::from_utf8_lossy(&r.stderr);
assert!(
s.contains("repack"),
"rejection should point to repack:\n{s}"
);
}
#[test]
fn cli_repack_shrink() {
if !which("e2fsck") || !which("mke2fs") {
eprintln!("skipping: e2fsck/mke2fs missing");
return;
}
let srcdir = tempfile::tempdir().unwrap();
std::fs::write(srcdir.path().join("keep.txt"), b"keep this\n").unwrap();
std::fs::create_dir(srcdir.path().join("etc")).unwrap();
std::fs::write(srcdir.path().join("etc/conf"), b"v=1\n").unwrap();
let big = NamedTempFile::new().unwrap();
Command::new("truncate")
.args(["-s", "16M"])
.arg(big.path())
.output()
.unwrap();
let mk = Command::new("mke2fs")
.args(["-F", "-t", "ext4", "-d"])
.arg(srcdir.path())
.arg("-L")
.arg("src")
.arg(big.path())
.output()
.unwrap();
assert!(
mk.status.success(),
"mke2fs failed:\n{}",
String::from_utf8_lossy(&mk.stderr)
);
let big_len = std::fs::metadata(big.path()).unwrap().len();
let shrunk = NamedTempFile::new().unwrap();
let r = Command::new(FSTOOL)
.arg("repack")
.arg(big.path())
.arg(shrunk.path())
.arg("--shrink")
.output()
.unwrap();
assert!(
r.status.success(),
"repack --shrink failed:\n{}",
String::from_utf8_lossy(&r.stderr)
);
let shrunk_len = std::fs::metadata(shrunk.path()).unwrap().len();
assert!(
shrunk_len < big_len,
"repack didn't shrink: {shrunk_len} vs {big_len}"
);
let fsck = Command::new("e2fsck")
.arg("-fn")
.arg(shrunk.path())
.output()
.unwrap();
assert!(
fsck.status.success(),
"e2fsck on shrunk image failed:\n{}",
String::from_utf8_lossy(&fsck.stdout)
);
let cat = Command::new(FSTOOL)
.arg("cat")
.arg(shrunk.path())
.arg("/etc/conf")
.output()
.unwrap();
assert!(cat.status.success());
assert_eq!(cat.stdout, b"v=1\n");
}
#[test]
fn cli_repack_ext_to_fat32() {
if !which("fsck.vfat") || !which("mke2fs") {
eprintln!("skipping: fsck.vfat/mke2fs missing");
return;
}
let srcdir = tempfile::tempdir().unwrap();
std::fs::write(srcdir.path().join("README.txt"), b"cross-fs content\n").unwrap();
let src = NamedTempFile::new().unwrap();
Command::new("truncate")
.args(["-s", "8M"])
.arg(src.path())
.output()
.unwrap();
Command::new("mke2fs")
.args(["-F", "-t", "ext4", "-d"])
.arg(srcdir.path())
.arg(src.path())
.output()
.unwrap();
let fat = NamedTempFile::new().unwrap();
let r = Command::new(FSTOOL)
.arg("repack")
.arg(src.path())
.arg(fat.path())
.args(["--fs-type", "fat32", "--size", "64MiB"])
.output()
.unwrap();
assert!(
r.status.success(),
"repack ext→fat32 failed:\n{}",
String::from_utf8_lossy(&r.stderr)
);
let chk = Command::new("fsck.vfat")
.args(["-n", "-v"])
.arg(fat.path())
.output()
.unwrap();
assert!(
chk.status.success(),
"fsck.vfat failed on repacked FAT32:\n{}",
String::from_utf8_lossy(&chk.stdout)
);
let ls = Command::new(FSTOOL)
.arg("ls")
.arg(fat.path())
.arg("/")
.output()
.unwrap();
let s = String::from_utf8_lossy(&ls.stdout);
assert!(
s.to_ascii_lowercase().contains("readme.txt"),
"missing README.txt in FAT32 output:\n{s}"
);
}
#[test]
fn cli_repack_preserves_symlinks_and_mode() {
if !which("e2fsck") || !which("mke2fs") || !which("debugfs") {
eprintln!("skipping: e2fsprogs missing");
return;
}
let srcdir = tempfile::tempdir().unwrap();
std::fs::write(srcdir.path().join("regular.txt"), b"keep me\n").unwrap();
std::os::unix::fs::symlink("regular.txt", srcdir.path().join("link-rel")).unwrap();
std::os::unix::fs::symlink("/etc/passwd", srcdir.path().join("link-abs")).unwrap();
use std::os::unix::fs::PermissionsExt;
let p = srcdir.path().join("regular.txt");
let mut perms = std::fs::metadata(&p).unwrap().permissions();
perms.set_mode(0o640);
std::fs::set_permissions(&p, perms).unwrap();
let src = NamedTempFile::new().unwrap();
Command::new("truncate")
.args(["-s", "16M"])
.arg(src.path())
.output()
.unwrap();
Command::new("mke2fs")
.args(["-F", "-t", "ext4", "-d"])
.arg(srcdir.path())
.arg(src.path())
.output()
.unwrap();
let dst = NamedTempFile::new().unwrap();
let r = Command::new(FSTOOL)
.arg("repack")
.arg(src.path())
.arg(dst.path())
.arg("--shrink")
.output()
.unwrap();
assert!(
r.status.success(),
"repack failed:\n{}",
String::from_utf8_lossy(&r.stderr)
);
let ls = Command::new("debugfs")
.arg("-R")
.arg("ls -l /")
.arg(dst.path())
.output()
.unwrap();
let listing = String::from_utf8_lossy(&ls.stdout);
assert!(
listing.contains("100640"),
"mode 0640 not preserved on regular.txt:\n{listing}"
);
let abs = Command::new("debugfs")
.arg("-R")
.arg("stat /link-abs")
.arg(dst.path())
.output()
.unwrap();
let abs_out = String::from_utf8_lossy(&abs.stdout);
assert!(
abs_out.contains("/etc/passwd"),
"absolute symlink target lost:\n{abs_out}"
);
let rel = Command::new("debugfs")
.arg("-R")
.arg("stat /link-rel")
.arg(dst.path())
.output()
.unwrap();
let rel_out = String::from_utf8_lossy(&rel.stdout);
assert!(
rel_out.contains("regular.txt"),
"relative symlink target lost:\n{rel_out}"
);
let fsck = Command::new("e2fsck")
.arg("-fn")
.arg(dst.path())
.output()
.unwrap();
assert!(
fsck.status.success(),
"e2fsck failed after repack:\n{}",
String::from_utf8_lossy(&fsck.stdout)
);
}
#[test]
fn cli_repack_preserves_xattrs() {
if !which("mke2fs") || !which("debugfs") || !which("e2fsck") {
eprintln!("skipping: e2fsprogs missing");
return;
}
let srcdir = tempfile::tempdir().unwrap();
std::fs::write(srcdir.path().join("withxattr.txt"), b"hi\n").unwrap();
let src = NamedTempFile::new().unwrap();
Command::new("truncate")
.args(["-s", "16M"])
.arg(src.path())
.output()
.unwrap();
Command::new("mke2fs")
.args(["-F", "-t", "ext4", "-d"])
.arg(srcdir.path())
.arg(src.path())
.output()
.unwrap();
for cmd in [
r#"ea_set /withxattr.txt user.foo "hello-xattr""#,
r#"ea_set /withxattr.txt security.selinux "system_u:object_r:demo_t:s0""#,
r#"ea_set /withxattr.txt trusted.opaque "blob""#,
] {
let out = Command::new("debugfs")
.args(["-w", "-R"])
.arg(cmd)
.arg(src.path())
.output()
.unwrap();
assert!(
out.status.success(),
"debugfs ea_set failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
}
let dst = NamedTempFile::new().unwrap();
let r = Command::new(FSTOOL)
.arg("repack")
.arg(src.path())
.arg(dst.path())
.arg("--shrink")
.output()
.unwrap();
assert!(
r.status.success(),
"repack failed:\n{}",
String::from_utf8_lossy(&r.stderr)
);
let fsck = Command::new("e2fsck")
.arg("-fn")
.arg(dst.path())
.output()
.unwrap();
assert!(
fsck.status.success(),
"e2fsck failed after repack:\n{}",
String::from_utf8_lossy(&fsck.stdout)
);
let cases = [
("user.foo", "hello-xattr"),
("security.selinux", "system_u:object_r:demo_t:s0"),
("trusted.opaque", "blob"),
];
for (name, expected) in cases {
let out = Command::new("debugfs")
.args(["-R"])
.arg(format!("ea_get /withxattr.txt {name}"))
.arg(dst.path())
.output()
.unwrap();
let s = String::from_utf8_lossy(&out.stdout);
assert!(
s.contains(expected),
"xattr {name:?} lost or wrong:\nstdout:\n{s}\nstderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
}
}