use std::collections::HashMap;
use std::fs::File;
use std::io::Read;
use std::os::unix::fs::{FileTypeExt, MetadataExt};
use std::path::{Path, PathBuf};
use backhand::compression::Compressor;
use backhand::{FilesystemCompressor, FilesystemWriter, NodeHeader};
pub(crate) enum Ownership {
AllRoot,
OciLayer(HashMap<String, (u32, u32, u16)>),
FromMetadata,
}
pub(crate) fn write_squashfs(
src_dir: &Path,
out_path: &Path,
ownership: &Ownership,
) -> Result<(), String> {
let mut fs = FilesystemWriter::default();
let compressor = FilesystemCompressor::new(Compressor::Zstd, None)
.map_err(|e| format!("squashfs zstd compressor: {e}"))?;
fs.set_compressor(compressor);
let mut entries: Vec<PathBuf> = Vec::new();
collect_tree(src_dir, &mut entries)?;
entries.sort();
for abs in &entries {
let rel = abs
.strip_prefix(src_dir)
.map_err(|e| format!("strip prefix {}: {e}", abs.display()))?;
let meta = match std::fs::symlink_metadata(abs) {
Ok(m) => m,
Err(_) => continue,
};
let header = node_header(rel, &meta, ownership);
let sq_path = Path::new("/").join(rel);
let ft = meta.file_type();
let push = if ft.is_dir() {
fs.push_dir(&sq_path, header)
} else if ft.is_symlink() {
let target = std::fs::read_link(abs)
.map_err(|e| format!("read symlink {}: {e}", abs.display()))?;
fs.push_symlink(target, &sq_path, header)
} else if ft.is_file() {
fs.push_file(LazyFile::new(abs.clone()), &sq_path, header)
} else if ft.is_char_device() {
fs.push_char_device(meta.rdev() as u32, &sq_path, header)
} else if ft.is_block_device() {
fs.push_block_device(meta.rdev() as u32, &sq_path, header)
} else if ft.is_fifo() {
fs.push_fifo(&sq_path, header)
} else if ft.is_socket() {
fs.push_socket(&sq_path, header)
} else {
continue;
};
push.map_err(|e| format!("squashfs add {}: {e}", sq_path.display()))?;
}
let out = File::create(out_path).map_err(|e| format!("create {}: {e}", out_path.display()))?;
let mut bw = std::io::BufWriter::new(out);
fs.write(&mut bw)
.map_err(|e| format!("write squashfs {}: {e}", out_path.display()))?;
Ok(())
}
fn node_header(rel: &Path, meta: &std::fs::Metadata, ownership: &Ownership) -> NodeHeader {
let on_disk_perms = (meta.mode() & 0o7777) as u16;
let mtime = meta.mtime().clamp(0, u32::MAX as i64) as u32;
let (perms, uid, gid) = match ownership {
Ownership::AllRoot => (on_disk_perms, 0, 0),
Ownership::FromMetadata => (on_disk_perms, meta.uid(), meta.gid()),
Ownership::OciLayer(overrides) => {
let key = rel.to_string_lossy();
match overrides.get(key.as_ref()) {
Some(&(uid, gid, perms)) => (perms, uid, gid),
None => (on_disk_perms, 0, 0),
}
}
};
NodeHeader::new(perms, uid, gid, mtime)
}
fn collect_tree(root: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(rd) => rd,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
out.push(path.clone());
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
stack.push(path);
}
}
}
Ok(())
}
struct LazyFile {
path: PathBuf,
file: Option<File>,
eof: bool,
}
impl LazyFile {
fn new(path: PathBuf) -> Self {
Self {
path,
file: None,
eof: false,
}
}
}
impl Read for LazyFile {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.eof {
return Ok(0);
}
if self.file.is_none() {
self.file = Some(File::open(&self.path)?);
}
let n = self.file.as_mut().unwrap().read(buf)?;
if n == 0 {
self.file = None;
self.eof = true;
}
Ok(n)
}
}
#[cfg(test)]
mod tests {
use super::*;
use backhand::FilesystemReader;
#[test]
fn multiblock_file_and_symlinks_do_not_balloon() {
let dir = std::env::temp_dir().join(format!("sm-sqfs-many-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("bin")).unwrap();
std::fs::write(dir.join("bin/busybox"), vec![0xabu8; 800 * 1024]).unwrap();
for i in 0..400 {
std::os::unix::fs::symlink("/bin/busybox", dir.join(format!("bin/applet{i}"))).unwrap();
}
std::fs::create_dir_all(dir.join("var")).unwrap();
std::os::unix::fs::symlink("../run", dir.join("var/run")).unwrap();
let out = dir.with_extension("squashfs");
let _ = std::fs::remove_file(&out);
write_squashfs(&dir, &out, &Ownership::AllRoot).unwrap();
let sz = std::fs::metadata(&out).unwrap().len();
assert!(sz < 8 * 1024 * 1024, "squashfs ballooned to {sz} bytes");
let bytes = std::fs::read(&out).unwrap();
let reader = FilesystemReader::from_reader(std::io::Cursor::new(bytes)).unwrap();
let paths: Vec<String> = reader
.files()
.map(|n| n.fullpath.to_string_lossy().into_owned())
.collect();
assert!(paths.iter().any(|p| p == "/bin/busybox"), "file present");
assert!(paths.iter().any(|p| p == "/bin/applet0"), "symlink present");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_file(&out);
}
#[test]
fn roundtrip_tree_with_ownership_and_symlink() {
let dir = std::env::temp_dir().join(format!("sm-sqfs-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("etc")).unwrap();
std::fs::write(dir.join("etc/hello.txt"), b"hi there").unwrap();
std::fs::write(dir.join("rootfile"), b"root-owned").unwrap();
std::os::unix::fs::symlink("hello.txt", dir.join("etc/link")).unwrap();
let mut overrides = HashMap::new();
overrides.insert("etc/hello.txt".to_owned(), (1000u32, 1000u32, 0o640u16));
let out = dir.with_extension("squashfs");
let _ = std::fs::remove_file(&out);
write_squashfs(&dir, &out, &Ownership::OciLayer(overrides)).unwrap();
let bytes = std::fs::read(&out).unwrap();
let reader = FilesystemReader::from_reader(std::io::Cursor::new(bytes)).unwrap();
let mut saw_hello = false;
let mut saw_root = false;
let mut saw_link = false;
for node in reader.files() {
let p = node.fullpath.to_string_lossy().into_owned();
match p.as_str() {
"/etc/hello.txt" => {
assert_eq!(node.header.uid, 1000, "override uid");
assert_eq!(node.header.gid, 1000, "override gid");
assert_eq!(node.header.permissions & 0o7777, 0o640);
saw_hello = true;
}
"/rootfile" => {
assert_eq!(node.header.uid, 0, "default root uid");
assert_eq!(node.header.gid, 0, "default root gid");
saw_root = true;
}
"/etc/link" => saw_link = true,
_ => {}
}
}
assert!(
saw_hello && saw_root && saw_link,
"expected all nodes present"
);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_file(&out);
}
fn unique_dir(tag: &str) -> PathBuf {
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let d = std::env::temp_dir().join(format!("sm-sqfs-{tag}-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn read_back(out: &Path) -> Vec<(String, u32, u32, u16)> {
let bytes = std::fs::read(out).unwrap();
let reader = FilesystemReader::from_reader(std::io::Cursor::new(bytes)).unwrap();
reader
.files()
.map(|n| {
(
n.fullpath.to_string_lossy().into_owned(),
n.header.uid,
n.header.gid,
n.header.permissions & 0o7777,
)
})
.collect()
}
#[test]
fn from_metadata_uses_on_disk_ownership() {
let dir = unique_dir("meta");
std::fs::write(dir.join("f"), b"x").unwrap();
let me = std::fs::metadata(dir.join("f")).unwrap();
let (uid, gid) = (me.uid(), me.gid());
let out = dir.with_extension("squashfs");
write_squashfs(&dir, &out, &Ownership::FromMetadata).unwrap();
let f = read_back(&out)
.into_iter()
.find(|(p, ..)| p == "/f")
.expect("/f present");
assert_eq!((f.1, f.2), (uid, gid), "FromMetadata keeps on-disk owner");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_file(&out);
}
#[test]
fn deep_nesting_roundtrips() {
let dir = unique_dir("deep");
let nested = dir.join("a/b/c/d/e/f/g");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("leaf"), b"deep").unwrap();
let out = dir.with_extension("squashfs");
write_squashfs(&dir, &out, &Ownership::AllRoot).unwrap();
let paths: Vec<String> = read_back(&out).into_iter().map(|(p, ..)| p).collect();
assert!(
paths.iter().any(|p| p == "/a/b/c/d/e/f/g/leaf"),
"deep leaf present, got {paths:?}"
);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_file(&out);
}
#[test]
fn unicode_path_roundtrips() {
let dir = unique_dir("uni");
std::fs::write(dir.join("café-η-日本.txt"), b"u").unwrap();
let out = dir.with_extension("squashfs");
write_squashfs(&dir, &out, &Ownership::AllRoot).unwrap();
let paths: Vec<String> = read_back(&out).into_iter().map(|(p, ..)| p).collect();
assert!(
paths
.iter()
.any(|p| p.contains("日本.txt") && p.contains("caf")),
"unicode file present, got {paths:?}"
);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_file(&out);
}
#[test]
fn lazy_file_releases_fd_at_eof_and_stays_eof() {
let dir = unique_dir("lazy");
let p = dir.join("data");
std::fs::write(&p, b"hello world").unwrap();
let mut lf = LazyFile::new(p.clone());
assert!(lf.file.is_none(), "no FD before first read");
let mut buf = Vec::new();
lf.read_to_end(&mut buf).unwrap();
assert_eq!(buf, b"hello world");
assert!(lf.file.is_none(), "FD released at EOF");
let mut buf2 = Vec::new();
lf.read_to_end(&mut buf2).unwrap();
assert!(buf2.is_empty(), "consumed reader stays at EOF (no replay)");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn many_files_build() {
let dir = unique_dir("many");
for i in 0..400u32 {
std::fs::write(dir.join(format!("f{i:04}")), format!("file {i}")).unwrap();
}
let out = dir.with_extension("squashfs");
write_squashfs(&dir, &out, &Ownership::AllRoot).unwrap();
let n = read_back(&out)
.into_iter()
.filter(|(p, ..)| p.starts_with("/f"))
.count();
assert_eq!(n, 400, "all 400 files recorded");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_file(&out);
}
}