#![cfg(unix)]
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(["create", "-t", "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(["create", "-t", "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(["create", "-t", "ext4"])
.arg(srcdir.path())
.arg("-o")
.arg(img.path())
.output()
.unwrap();
assert!(out.status.success(), "create 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([
"create", "-t", "fat32", "--size", "64MiB", "--label", "CLIRM",
])
.arg(srcdir.path())
.arg("-o")
.arg(img.path())
.output()
.unwrap();
assert!(
out.status.success(),
"create 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([
"create", "-t", "fat32", "--size", "64MiB", "--label", "CLIFAT",
])
.arg(srcdir.path())
.arg("-o")
.arg(img.path())
.output()
.unwrap();
assert!(
out.status.success(),
"create 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(["create", "-t", "ext4"])
.arg(srcdir.path())
.arg("-o")
.arg(raw.path())
.output()
.unwrap();
assert!(out.status.success(), "create 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)
);
}
}
#[test]
fn cli_repack_ext_tar_ext_preserves_metadata() {
if !which("mke2fs") || !which("debugfs") || !which("e2fsck") {
eprintln!("skipping: e2fsprogs missing");
return;
}
let srcdir = tempfile::tempdir().unwrap();
std::fs::write(srcdir.path().join("regular.txt"), b"file body\n").unwrap();
std::os::unix::fs::symlink("regular.txt", srcdir.path().join("link.txt")).unwrap();
std::fs::create_dir(srcdir.path().join("sub")).unwrap();
std::fs::write(srcdir.path().join("sub/inside.txt"), b"nested\n").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();
Command::new("debugfs")
.args(["-w", "-R"])
.arg(r#"ea_set /regular.txt user.tag "from-ext""#)
.arg(src.path())
.output()
.unwrap();
let dir = tempfile::tempdir().unwrap();
let tar = dir.path().join("intermediate.tar");
let r = Command::new(FSTOOL)
.arg("repack")
.arg(src.path())
.arg(&tar)
.output()
.unwrap();
assert!(
r.status.success(),
"ext → tar failed:\n{}",
String::from_utf8_lossy(&r.stderr)
);
let f = std::fs::read(&tar).unwrap();
assert_eq!(&f[257..262], b"ustar");
let ls = Command::new(FSTOOL)
.arg("ls")
.arg(&tar)
.arg("/")
.output()
.unwrap();
assert!(ls.status.success());
let s = String::from_utf8_lossy(&ls.stdout);
assert!(s.contains("regular.txt"));
assert!(s.contains("link.txt"));
assert!(s.contains("sub"));
let back = NamedTempFile::new().unwrap();
let r = Command::new(FSTOOL)
.arg("repack")
.arg(&tar)
.arg(back.path())
.arg("--shrink")
.output()
.unwrap();
assert!(
r.status.success(),
"tar → ext failed:\n{}",
String::from_utf8_lossy(&r.stderr)
);
let listing = Command::new("debugfs")
.arg("-R")
.arg("ls -l /")
.arg(back.path())
.output()
.unwrap();
let l = String::from_utf8_lossy(&listing.stdout);
assert!(l.contains("100640"), "mode 0640 not preserved:\n{l}");
let symlink = Command::new("debugfs")
.arg("-R")
.arg("stat /link.txt")
.arg(back.path())
.output()
.unwrap();
let s = String::from_utf8_lossy(&symlink.stdout);
assert!(s.contains("regular.txt"), "symlink target lost:\n{s}");
let xattr = Command::new("debugfs")
.arg("-R")
.arg("ea_get /regular.txt user.tag")
.arg(back.path())
.output()
.unwrap();
let s = String::from_utf8_lossy(&xattr.stdout);
assert!(
s.contains("from-ext"),
"user.tag xattr lost through ext → tar → ext:\n{s}"
);
let fsck = Command::new("e2fsck")
.arg("-fn")
.arg(back.path())
.output()
.unwrap();
assert!(
fsck.status.success(),
"e2fsck failed after ext → tar → ext:\n{}",
String::from_utf8_lossy(&fsck.stdout)
);
}
#[test]
fn cli_tar_archive_readable_by_system_tar() {
if !which("mke2fs") || !which("tar") {
eprintln!("skipping: mke2fs/tar missing");
return;
}
let srcdir = tempfile::tempdir().unwrap();
std::fs::write(srcdir.path().join("hello"), 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();
let dir = tempfile::tempdir().unwrap();
let tar = dir.path().join("out.tar");
Command::new(FSTOOL)
.arg("repack")
.arg(src.path())
.arg(&tar)
.output()
.unwrap();
let out = Command::new("tar")
.args(["-tf"])
.arg(&tar)
.output()
.unwrap();
assert!(
out.status.success(),
"system tar failed to list our archive"
);
let s = String::from_utf8_lossy(&out.stdout);
assert!(s.contains("hello"), "system tar didn't see /hello:\n{s}");
}
#[test]
fn cli_analyze_reports_counts_and_sizes() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.txt"), b"hello").unwrap();
std::fs::write(dir.path().join("b.bin"), vec![0u8; 2000]).unwrap();
std::fs::create_dir(dir.path().join("sub")).unwrap();
std::fs::write(dir.path().join("sub/c.txt"), b"0123456789").unwrap();
let out = Command::new(FSTOOL)
.arg("analyze")
.arg(dir.path())
.output()
.unwrap();
assert!(out.status.success(), "analyze failed: {out:?}");
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("files: 3"), "human output:\n{text}");
assert!(text.contains("dirs: 1"), "human output:\n{text}");
let out = Command::new(FSTOOL)
.arg("analyze")
.arg(dir.path())
.arg("--json")
.output()
.unwrap();
assert!(out.status.success(), "analyze --json failed: {out:?}");
let v: serde_json::Value = serde_json::from_slice(&out.stdout).expect("valid JSON");
assert_eq!(v["files"], 3);
assert_eq!(v["dirs"], 1);
assert_eq!(v["symlinks"], 0);
assert_eq!(v["total_file_bytes"], 2015);
assert!(v["recommended_size"]["ext4"].is_u64());
assert!(v["recommended_size"]["fat32"].is_u64());
assert!(v["recommended_size"].get("tar").is_none());
}
#[test]
fn cli_mutators_refuse_compressed_inputs() {
let bin = env!("CARGO_BIN_EXE_fstool");
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir(&src).unwrap();
std::fs::write(src.join("a.txt"), b"a\n").unwrap();
let img = dir.path().join("e.img");
let r = Command::new(bin)
.args(["create", "-t", "ext2", "-o"])
.arg(&img)
.arg(&src)
.output()
.unwrap();
assert!(
r.status.success(),
"create failed: {}",
String::from_utf8_lossy(&r.stderr)
);
if !Command::new("sh")
.arg("-c")
.arg("command -v gzip")
.status()
.map(|s| s.success())
.unwrap_or(false)
{
eprintln!("skipping: gzip not installed");
return;
}
let st = Command::new("gzip").arg("-k").arg(&img).status().unwrap();
assert!(st.success(), "gzip failed");
let img_gz = dir.path().join("e.img.gz");
let host = dir.path().join("host.txt");
std::fs::write(&host, b"h\n").unwrap();
let r = Command::new(bin)
.arg("add")
.arg(&img_gz)
.arg(&host)
.arg("/added.txt")
.output()
.unwrap();
assert!(!r.status.success(), "add on .gz should have failed");
let err = String::from_utf8_lossy(&r.stderr);
assert!(err.contains("cannot mutate a gzip"), "wrong error: {err}");
let r = Command::new(bin)
.arg("rm")
.arg(&img_gz)
.arg("/a.txt")
.output()
.unwrap();
assert!(!r.status.success(), "rm on .gz should have failed");
assert!(
String::from_utf8_lossy(&r.stderr).contains("cannot mutate a gzip"),
"wrong error: {}",
String::from_utf8_lossy(&r.stderr)
);
let r = Command::new(bin)
.arg("shell")
.arg(&img_gz)
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert!(!r.status.success(), "shell on .gz should have failed");
assert!(
String::from_utf8_lossy(&r.stderr).contains("cannot mutate a gzip"),
"wrong error: {}",
String::from_utf8_lossy(&r.stderr)
);
let ls = Command::new(bin)
.arg("ls")
.arg(&img)
.arg("/")
.output()
.unwrap();
assert!(ls.status.success());
let listing = String::from_utf8_lossy(&ls.stdout);
assert!(
!listing.contains("added.txt"),
"added.txt leaked into original: {listing}"
);
}
#[test]
fn cli_shell_refuses_streaming_filesystem() {
let bin = env!("CARGO_BIN_EXE_fstool");
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir(&src).unwrap();
std::fs::write(src.join("a.txt"), b"a\n").unwrap();
let tar = dir.path().join("t.tar");
let st = Command::new("tar")
.arg("-cf")
.arg(&tar)
.arg("-C")
.arg(dir.path())
.arg("src")
.status()
.unwrap();
assert!(st.success(), "tar failed");
let r = Command::new(bin)
.arg("shell")
.arg(&tar)
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert!(!r.status.success(), "shell on tar should have failed");
let err = String::from_utf8_lossy(&r.stderr);
assert!(err.contains("streaming"), "wrong error: {err}");
assert!(err.contains("fstool ls"), "missing browse hint: {err}");
}
#[test]
fn cli_shell_ro_browses_immutable_and_keeps_image_bytes() {
let bin = env!("CARGO_BIN_EXE_fstool");
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir(&src).unwrap();
std::fs::write(src.join("a.txt"), b"a\n").unwrap();
let ext_img = dir.path().join("e.img");
let r = Command::new(bin)
.args(["create", "-t", "ext2", "-o"])
.arg(&ext_img)
.arg(&src)
.output()
.unwrap();
assert!(
r.status.success(),
"create failed: {}",
String::from_utf8_lossy(&r.stderr)
);
let tar_gz = dir.path().join("t.tar.gz");
let st = Command::new("tar")
.arg("-czf")
.arg(&tar_gz)
.arg("-C")
.arg(dir.path())
.arg("src")
.status()
.unwrap();
assert!(st.success(), "tar failed");
let host = dir.path().join("host.txt");
std::fs::write(&host, b"x\n").unwrap();
let r = Command::new(bin)
.arg("shell")
.arg("--ro")
.arg(&tar_gz)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let mut child = r;
use std::io::Write as _;
child
.stdin
.take()
.unwrap()
.write_all(b"ls\nquit\n")
.unwrap();
let out = child.wait_with_output().unwrap();
assert!(
out.status.success(),
"shell --ro on tar.gz failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
String::from_utf8_lossy(&out.stdout).contains("src"),
"no src/ in listing"
);
let bytes_before = std::fs::read(&ext_img).unwrap();
let mut child = Command::new(bin)
.arg("shell")
.arg("--ro")
.arg(&ext_img)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
{
let mut stdin = child.stdin.take().unwrap();
writeln!(stdin, "ls").unwrap();
writeln!(stdin, "put {} /attempted.txt", host.display()).unwrap();
writeln!(stdin, "rm /a.txt").unwrap();
writeln!(stdin, "mkdir /newdir").unwrap();
writeln!(stdin, "quit").unwrap();
}
let out = child.wait_with_output().unwrap();
assert!(out.status.success(), "shell --ro on ext.img failed");
let stdout = String::from_utf8_lossy(&out.stdout);
for cmd in ["put", "rm", "mkdir"] {
assert!(
stdout.contains(&format!("{cmd}: shell is read-only")),
"{cmd} not refused: {stdout}"
);
}
let bytes_after = std::fs::read(&ext_img).unwrap();
assert_eq!(bytes_before, bytes_after, "shell --ro mutated the image");
let r = Command::new(bin)
.arg("shell")
.arg(&tar_gz)
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert!(
!r.status.success(),
"shell w/o --ro on tar.gz should still fail"
);
}
#[test]
fn cli_ls_recursive_walks_tree_and_terminates() {
let bin = FSTOOL;
let src = tempfile::tempdir().unwrap();
std::fs::create_dir_all(src.path().join("a/b/c")).unwrap();
std::fs::create_dir_all(src.path().join("d")).unwrap();
std::fs::write(src.path().join("top.txt"), b"hi\n").unwrap();
std::fs::write(src.path().join("a/f1"), b"x\n").unwrap();
std::fs::write(src.path().join("a/b/f2"), b"y\n").unwrap();
std::fs::write(src.path().join("a/b/c/f3"), b"z\n").unwrap();
std::fs::write(src.path().join("d/f4"), b"w\n").unwrap();
let img = NamedTempFile::new().unwrap();
let out = Command::new(bin)
.arg("create")
.arg("-t")
.arg("ext4")
.arg(src.path())
.arg("-o")
.arg(img.path())
.output()
.unwrap();
assert!(
out.status.success(),
"create -t ext4 failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let out = Command::new(bin)
.arg("ls")
.arg("-R")
.arg(img.path())
.arg("/")
.output()
.unwrap();
assert!(
out.status.success(),
"ls -R failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let s = String::from_utf8_lossy(&out.stdout);
for header in ["/:", "/a:", "/a/b:", "/a/b/c:", "/d:"] {
assert!(s.contains(header), "missing {header} header in:\n{s}");
}
for f in ["top.txt", "f1", "f2", "f3", "f4"] {
assert!(s.contains(f), "missing file {f} in:\n{s}");
}
assert!(!s.contains("/.:"), "recursed into '.':\n{s}");
assert!(!s.contains("/..:"), "recursed into '..':\n{s}");
}
#[test]
fn cli_path_style_flag_is_a_noop_on_ext() {
let bin = FSTOOL;
let src = tempfile::tempdir().unwrap();
std::fs::create_dir_all(src.path().join("dir")).unwrap();
std::fs::write(src.path().join("dir/f"), b"x\n").unwrap();
let img = NamedTempFile::new().unwrap();
let out = Command::new(bin)
.args(["create", "-t", "ext4"])
.arg(src.path())
.arg("-o")
.arg(img.path())
.output()
.unwrap();
assert!(out.status.success(), "create failed");
let run = |style: &str| {
let o = Command::new(bin)
.args(["ls", "-R", "--path-style", style])
.arg(img.path())
.arg("/")
.output()
.unwrap();
assert!(o.status.success(), "ls -R --path-style {style} failed");
String::from_utf8_lossy(&o.stdout).into_owned()
};
assert_eq!(run("unix"), run("native"));
let default = Command::new(bin)
.args(["ls", "-R"])
.arg(img.path())
.arg("/")
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&default.stdout), run("unix"));
}