#![cfg(unix)]
use std::collections::HashSet;
use std::fs;
use std::io::Read;
use std::path::Path;
use std::process::Command;
use fstool::block::{BlockDevice, FileBackend};
use fstool::fs::DeviceKind;
use fstool::fs::squashfs::{Compression, EntryMeta, FormatOpts, Squashfs, Xattr};
use fstool::fs::{FileSource, ReadSeek};
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()) }
}
fn tool_present(name: &str, version_arg: &str) -> bool {
if which(name).is_none() {
return false;
}
match Command::new(name).arg(version_arg).output() {
Ok(out) => !out.stdout.is_empty() || !out.stderr.is_empty(),
Err(_) => false,
}
}
fn trim_image_to(path: &Path, len: u64) {
let f = fs::OpenOptions::new().write(true).open(path).unwrap();
f.set_len(len).unwrap();
}
fn build_image<F: FnOnce(&mut FileBackend, &mut Squashfs)>(
path: &Path,
compression: Compression,
builder: F,
) -> u64 {
let capacity: u64 = 16 * 1024 * 1024;
let mut dev = FileBackend::create(path, capacity).unwrap();
let mut sq = Squashfs::format(
&mut dev,
&FormatOpts {
block_size: 4096,
compression,
},
)
.unwrap();
builder(&mut dev, &mut sq);
sq.flush(&mut dev).unwrap();
let used = sq.total_bytes();
dev.sync().unwrap();
drop(dev);
trim_image_to(path, used);
used
}
fn populate_rich_tree(dev: &mut FileBackend, sq: &mut Squashfs) {
sq.create_dir(
dev,
"/etc",
EntryMeta {
mode: 0o755,
uid: 0,
gid: 0,
mtime: 100,
},
Vec::new(),
)
.unwrap();
sq.create_file(
dev,
"/etc/hosts",
FileSource::Reader {
reader: Box::new(std::io::Cursor::new(b"127.0.0.1 localhost\n".to_vec()))
as Box<dyn ReadSeek + Send>,
len: 20,
},
EntryMeta {
mode: 0o644,
uid: 0,
gid: 0,
mtime: 200,
},
vec![Xattr {
key: "user.kind".into(),
value: b"file".to_vec(),
}],
)
.unwrap();
sq.create_file(
dev,
"/etc/greeting",
FileSource::Reader {
reader: Box::new(std::io::Cursor::new(b"hi there\n".to_vec()))
as Box<dyn ReadSeek + Send>,
len: 9,
},
EntryMeta {
mode: 0o644,
uid: 0,
gid: 0,
mtime: 201,
},
Vec::new(),
)
.unwrap();
sq.create_hardlink(dev, "/etc/hosts", "/etc/hosts.bak")
.unwrap();
sq.create_symlink(
dev,
"/sym",
"etc/hosts",
EntryMeta {
mode: 0o777,
uid: 0,
gid: 0,
mtime: 300,
},
Vec::new(),
)
.unwrap();
sq.create_device(
dev,
"/dev/null",
DeviceKind::Char,
1,
3,
EntryMeta {
mode: 0o666,
uid: 0,
gid: 0,
mtime: 400,
},
Vec::new(),
)
.unwrap();
sq.create_device(
dev,
"/dev/sda",
DeviceKind::Block,
8,
0,
EntryMeta {
mode: 0o600,
uid: 0,
gid: 0,
mtime: 500,
},
Vec::new(),
)
.unwrap();
sq.create_device(
dev,
"/run/fifo",
DeviceKind::Fifo,
0,
0,
EntryMeta {
mode: 0o600,
uid: 0,
gid: 0,
mtime: 600,
},
Vec::new(),
)
.unwrap();
sq.create_device(
dev,
"/run/sock",
DeviceKind::Socket,
0,
0,
EntryMeta {
mode: 0o600,
uid: 0,
gid: 0,
mtime: 700,
},
vec![Xattr {
key: "user.purpose".into(),
value: b"unix-socket".to_vec(),
}],
)
.unwrap();
}
fn codec_info(c: Compression) -> Option<(&'static str, bool)> {
match c {
Compression::Gzip => Some(("gzip", cfg!(feature = "gzip"))),
Compression::Xz => Some(("xz", cfg!(feature = "xz"))),
Compression::Lzma => Some(("lzma", cfg!(feature = "lzma"))),
Compression::Lz4 => Some(("lz4", cfg!(feature = "lz4"))),
Compression::Zstd => Some(("zstd", cfg!(feature = "zstd"))),
Compression::Lzo => Some(("lzo", cfg!(feature = "lzo"))),
_ => None,
}
}
fn mksquashfs_supports_codec(codec: &str) -> bool {
let out = match Command::new("mksquashfs")
.args(["-help-comp", codec])
.output()
{
Ok(o) => o,
Err(_) => return false,
};
out.status.success()
&& (String::from_utf8_lossy(&out.stdout)
.to_lowercase()
.contains(codec)
|| String::from_utf8_lossy(&out.stderr)
.to_lowercase()
.contains(codec))
}
#[test]
fn writer_image_passes_unsquashfs_round_trip() {
if !tool_present("unsquashfs", "-version") {
eprintln!("skipping: unsquashfs not installed");
return;
}
let workdir = tempfile::tempdir().unwrap();
let img = workdir.path().join("rich.sqfs");
let codec = if cfg!(feature = "gzip") {
Compression::Gzip
} else if cfg!(feature = "zstd") {
Compression::Zstd
} else {
Compression::Unknown(0)
};
build_image(&img, codec, populate_rich_tree);
let out = Command::new("unsquashfs")
.arg("-lc")
.arg(&img)
.output()
.unwrap();
assert!(
out.status.success(),
"unsquashfs -lc failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let listing = String::from_utf8_lossy(&out.stdout);
for must_have in [
"etc/hosts",
"etc/hosts.bak",
"etc/greeting",
"sym",
"dev/null",
"dev/sda",
"run/fifo",
"run/sock",
] {
assert!(
listing.contains(must_have),
"unsquashfs -lc missed {must_have:?}:\n{listing}"
);
}
let extract = workdir.path().join("extract");
let out = Command::new("unsquashfs")
.arg("-no-xattrs")
.arg("-ignore-errors")
.arg("-d")
.arg(&extract)
.arg(&img)
.output()
.unwrap();
if !out.status.success() {
let _ = std::fs::remove_dir_all(&extract);
let _ = Command::new("unsquashfs")
.arg("-no-xattrs")
.arg("-d")
.arg(&extract)
.arg(&img)
.output();
}
assert!(
extract.exists(),
"unsquashfs -d produced no output dir:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
assert_eq!(
fs::read(extract.join("etc/hosts")).unwrap(),
b"127.0.0.1 localhost\n"
);
assert_eq!(
fs::read(extract.join("etc/greeting")).unwrap(),
b"hi there\n"
);
assert_eq!(
fs::read(extract.join("etc/hosts.bak")).unwrap(),
b"127.0.0.1 localhost\n"
);
let link_target = fs::read_link(extract.join("sym")).unwrap();
assert_eq!(link_target.to_string_lossy(), "etc/hosts");
assert_eq!(
fs::read(extract.join("sym")).unwrap(),
b"127.0.0.1 localhost\n"
);
}
fn assert_fstool_tree_matches_disk(
sq: &Squashfs,
dev: &mut dyn BlockDevice,
src_root: &Path,
sq_path: &str,
) {
let entries = sq.list_path(dev, sq_path).unwrap();
let listed_names: HashSet<String> = entries.iter().map(|e| e.name.clone()).collect();
let on_disk: HashSet<String> = fs::read_dir(src_root)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(
listed_names, on_disk,
"fstool listing mismatch at {sq_path:?}: fstool={listed_names:?} disk={on_disk:?}"
);
for e in entries {
let child_sq = if sq_path == "/" {
format!("/{}", e.name)
} else {
format!("{sq_path}/{}", e.name)
};
let child_disk = src_root.join(&e.name);
let md = fs::symlink_metadata(&child_disk).unwrap();
let ty = md.file_type();
if ty.is_dir() {
assert_eq!(
e.kind,
fstool::fs::EntryKind::Dir,
"{child_sq:?} kind mismatch"
);
assert_fstool_tree_matches_disk(sq, dev, &child_disk, &child_sq);
} else if ty.is_file() {
assert_eq!(
e.kind,
fstool::fs::EntryKind::Regular,
"{child_sq:?} kind mismatch"
);
let want = fs::read(&child_disk).unwrap();
let mut got = Vec::new();
sq.open_file_reader(dev, &child_sq)
.unwrap()
.read_to_end(&mut got)
.unwrap();
assert_eq!(got, want, "byte mismatch for {child_sq:?}");
} else if ty.is_symlink() {
assert_eq!(
e.kind,
fstool::fs::EntryKind::Symlink,
"{child_sq:?} kind mismatch"
);
let want = fs::read_link(&child_disk)
.unwrap()
.to_string_lossy()
.into_owned();
let got = sq.read_symlink(dev, &child_sq).unwrap();
assert_eq!(got, want, "symlink target mismatch for {child_sq:?}");
}
}
}
#[test]
fn mksquashfs_image_opens_with_fstool_gzip() {
if !tool_present("mksquashfs", "-version") {
eprintln!("skipping: mksquashfs not installed");
return;
}
if !cfg!(feature = "gzip") {
eprintln!("skipping: fstool built without gzip feature");
return;
}
let workdir = tempfile::tempdir().unwrap();
let src = workdir.path().join("srctree");
fs::create_dir_all(src.join("dir1/dir2")).unwrap();
fs::write(src.join("top.txt"), b"top-level content\n").unwrap();
fs::write(src.join("dir1/mid.bin"), b"middle\x00\x01\x02bin\n").unwrap();
let big: Vec<u8> = (0..8192).map(|i| (i % 251) as u8).collect();
fs::write(src.join("dir1/dir2/big.bin"), &big).unwrap();
std::os::unix::fs::symlink("../top.txt", src.join("dir1/link")).unwrap();
let img = workdir.path().join("from-mksquashfs.sqfs");
let out = Command::new("mksquashfs")
.arg(&src)
.arg(&img)
.args(["-comp", "gzip", "-no-xattrs", "-noappend", "-quiet"])
.output()
.unwrap();
assert!(
out.status.success(),
"mksquashfs failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let mut dev = FileBackend::open(&img).unwrap();
let sq = Squashfs::open(&mut dev).unwrap();
assert_eq!(sq.compression(), Compression::Gzip);
assert_fstool_tree_matches_disk(&sq, &mut dev, &src, "/");
}
#[test]
fn mksquashfs_image_opens_with_fstool_zstd() {
if !tool_present("mksquashfs", "-version") {
eprintln!("skipping: mksquashfs not installed");
return;
}
if !cfg!(feature = "zstd") {
eprintln!("skipping: fstool built without zstd feature");
return;
}
if !mksquashfs_supports_codec("zstd") {
eprintln!("skipping: local mksquashfs has no zstd compressor");
return;
}
let workdir = tempfile::tempdir().unwrap();
let src = workdir.path().join("srctree");
fs::create_dir_all(src.join("nested")).unwrap();
fs::write(src.join("nested/a.txt"), b"zstd payload A\n").unwrap();
fs::write(
src.join("nested/b.txt"),
b"zstd payload B with more bytes\n",
)
.unwrap();
let img = workdir.path().join("from-mksquashfs-zstd.sqfs");
let out = Command::new("mksquashfs")
.arg(&src)
.arg(&img)
.args(["-comp", "zstd", "-no-xattrs", "-noappend", "-quiet"])
.output()
.unwrap();
assert!(
out.status.success(),
"mksquashfs -comp zstd failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let mut dev = FileBackend::open(&img).unwrap();
let sq = Squashfs::open(&mut dev).unwrap();
assert_eq!(sq.compression(), Compression::Zstd);
assert_fstool_tree_matches_disk(&sq, &mut dev, &src, "/");
}
fn unsquashfs_accepts_codec(c: Compression) {
if !tool_present("unsquashfs", "-version") {
eprintln!("skipping: unsquashfs not installed");
return;
}
let Some((name, enabled)) = codec_info(c) else {
eprintln!("skipping: codec not driveable from tests");
return;
};
if !enabled {
eprintln!("skipping: fstool built without {name} feature");
return;
}
let workdir = tempfile::tempdir().unwrap();
let img = workdir.path().join(format!("rich-{name}.sqfs"));
build_image(&img, c, populate_rich_tree);
let out = Command::new("unsquashfs")
.arg("-lc")
.arg(&img)
.output()
.unwrap();
assert!(
out.status.success(),
"unsquashfs -lc rejected {name} image:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
let listing = String::from_utf8_lossy(&out.stdout);
assert!(
listing.contains("etc/hosts"),
"unsquashfs -lc listing for {name} missed etc/hosts:\n{listing}"
);
}
#[test]
fn writer_image_unsquashfs_lc_gzip() {
unsquashfs_accepts_codec(Compression::Gzip);
}
#[test]
fn writer_image_unsquashfs_lc_xz() {
unsquashfs_accepts_codec(Compression::Xz);
}
#[test]
fn writer_image_unsquashfs_lc_lz4() {
unsquashfs_accepts_codec(Compression::Lz4);
}
#[test]
fn writer_image_unsquashfs_lc_zstd() {
unsquashfs_accepts_codec(Compression::Zstd);
}
#[test]
#[ignore]
fn writer_image_unsquashfs_lc_lzma() {
unsquashfs_accepts_codec(Compression::Lzma);
}
#[test]
fn writer_image_unsquashfs_lc_lzo() {
unsquashfs_accepts_codec(Compression::Lzo);
}