use std::collections::HashMap;
use std::path::{Path, PathBuf};
use a3s_box_core::error::{BoxError, Result};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq)]
pub struct FileEntry {
pub path: PathBuf,
pub size: u64,
pub mtime: i64,
pub mode: u32,
pub is_dir: bool,
}
#[derive(Debug, Clone)]
pub struct DirSnapshot {
pub entries: HashMap<PathBuf, FileEntry>,
}
impl DirSnapshot {
pub fn capture(root: &Path) -> Result<Self> {
let mut entries = HashMap::new();
walk_dir(root, root, &mut entries)?;
Ok(DirSnapshot { entries })
}
pub fn diff(&self, after: &DirSnapshot) -> Vec<PathBuf> {
let mut changed = Vec::new();
for (path, after_entry) in &after.entries {
match self.entries.get(path) {
None => {
changed.push(path.clone());
}
Some(before_entry) => {
if before_entry.size != after_entry.size
|| before_entry.mtime != after_entry.mtime
|| before_entry.mode != after_entry.mode
{
changed.push(path.clone());
}
}
}
}
changed.sort();
changed
}
pub fn deletions(&self, after: &DirSnapshot) -> Vec<PathBuf> {
let mut deleted = Vec::new();
for path in self.entries.keys() {
if after.entries.contains_key(path) {
continue; }
let parent_present = match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => {
after.entries.contains_key(parent)
}
_ => true, };
if parent_present {
deleted.push(path.clone());
}
}
deleted.sort();
deleted
}
}
fn walk_dir(root: &Path, current: &Path, entries: &mut HashMap<PathBuf, FileEntry>) -> Result<()> {
let read_dir = std::fs::read_dir(current).map_err(|e| {
BoxError::BuildError(format!(
"Failed to read directory {}: {}",
current.display(),
e
))
})?;
for entry in read_dir {
let entry = entry
.map_err(|e| BoxError::BuildError(format!("Failed to read directory entry: {}", e)))?;
let path = entry.path();
let relative = path
.strip_prefix(root)
.map_err(|e| {
BoxError::BuildError(format!(
"Failed to compute relative path for {}: {}",
path.display(),
e
))
})?
.to_path_buf();
let metadata = entry.metadata().map_err(|e| {
BoxError::BuildError(format!(
"Failed to read metadata for {}: {}",
path.display(),
e
))
})?;
let mtime = metadata
.modified()
.map(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
})
.unwrap_or(0);
#[cfg(unix)]
let mode = {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode()
};
#[cfg(not(unix))]
let mode = 0u32;
entries.insert(
relative.clone(),
FileEntry {
path: relative,
size: metadata.len(),
mtime,
mode,
is_dir: metadata.is_dir(),
},
);
if metadata.is_dir() {
walk_dir(root, &path, entries)?;
}
}
Ok(())
}
pub fn create_layer(
rootfs: &Path,
changed_files: &[PathBuf],
output_path: &Path,
) -> Result<LayerInfo> {
create_layer_with_chown(rootfs, changed_files, &[], output_path, None)
}
pub fn create_layer_with_deletions(
rootfs: &Path,
changed_files: &[PathBuf],
deleted_files: &[PathBuf],
output_path: &Path,
) -> Result<LayerInfo> {
create_layer_with_chown(rootfs, changed_files, deleted_files, output_path, None)
}
pub(super) fn create_layer_with_chown(
rootfs: &Path,
changed_files: &[PathBuf],
deleted_files: &[PathBuf],
output_path: &Path,
chown: Option<(u32, u32)>,
) -> Result<LayerInfo> {
use flate2::write::GzEncoder;
use flate2::Compression;
let file = std::fs::File::create(output_path).map_err(|e| {
BoxError::BuildError(format!(
"Failed to create layer file {}: {}",
output_path.display(),
e
))
})?;
let encoder = GzEncoder::new(file, Compression::default());
let mut builder = tar::Builder::new(encoder);
builder.follow_symlinks(false);
for relative_path in changed_files {
let full_path = rootfs.join(relative_path);
let meta = match std::fs::symlink_metadata(&full_path) {
Ok(meta) => meta,
Err(_) => continue,
};
if meta.is_dir() {
append_dir_with_chown(&mut builder, relative_path, &full_path, chown)?;
} else {
append_file_with_chown(&mut builder, relative_path, &full_path, &meta, chown)?;
}
}
for deleted in deleted_files {
let Some(file_name) = deleted.file_name() else {
continue;
};
let wh_name = format!(".wh.{}", file_name.to_string_lossy());
let wh_path = match deleted.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.join(&wh_name),
_ => PathBuf::from(&wh_name),
};
let mut header = tar::Header::new_gnu();
header.set_size(0);
header.set_entry_type(tar::EntryType::Regular);
header.set_mode(0o644);
header.set_mtime(0);
if let Some((uid, gid)) = chown {
header.set_uid(uid as u64);
header.set_gid(gid as u64);
}
header.set_cksum();
builder
.append_data(&mut header, &wh_path, std::io::empty())
.map_err(|e| {
BoxError::BuildError(format!(
"Failed to append whiteout {}: {}",
wh_path.display(),
e
))
})?;
}
let encoder = builder
.into_inner()
.map_err(|e| BoxError::BuildError(format!("Failed to finalize layer tar: {}", e)))?;
encoder
.finish()
.map_err(|e| BoxError::BuildError(format!("Failed to finalize layer gzip: {}", e)))?;
let digest = sha256_file(output_path)?;
let size = std::fs::metadata(output_path).map(|m| m.len()).unwrap_or(0);
Ok(LayerInfo {
path: output_path.to_path_buf(),
digest,
size,
})
}
pub fn create_layer_from_dir(
src_dir: &Path,
target_prefix: &Path,
output_path: &Path,
) -> Result<LayerInfo> {
create_layer_from_dir_with_chown(src_dir, target_prefix, output_path, None)
}
pub(super) fn create_layer_from_dir_with_chown(
src_dir: &Path,
target_prefix: &Path,
output_path: &Path,
chown: Option<(u32, u32)>,
) -> Result<LayerInfo> {
use flate2::write::GzEncoder;
use flate2::Compression;
let file = std::fs::File::create(output_path).map_err(|e| {
BoxError::BuildError(format!(
"Failed to create layer file {}: {}",
output_path.display(),
e
))
})?;
let encoder = GzEncoder::new(file, Compression::default());
let mut builder = tar::Builder::new(encoder);
builder.follow_symlinks(false);
add_dir_to_tar(&mut builder, src_dir, src_dir, target_prefix, chown)?;
let encoder = builder
.into_inner()
.map_err(|e| BoxError::BuildError(format!("Failed to finalize layer tar: {}", e)))?;
encoder
.finish()
.map_err(|e| BoxError::BuildError(format!("Failed to finalize layer gzip: {}", e)))?;
let digest = sha256_file(output_path)?;
let size = std::fs::metadata(output_path).map(|m| m.len()).unwrap_or(0);
Ok(LayerInfo {
path: output_path.to_path_buf(),
digest,
size,
})
}
fn add_dir_to_tar<W: std::io::Write>(
builder: &mut tar::Builder<W>,
root: &Path,
current: &Path,
target_prefix: &Path,
chown: Option<(u32, u32)>,
) -> Result<()> {
let entries = std::fs::read_dir(current).map_err(|e| {
BoxError::BuildError(format!(
"Failed to read directory {}: {}",
current.display(),
e
))
})?;
for entry in entries {
let entry =
entry.map_err(|e| BoxError::BuildError(format!("Failed to read entry: {}", e)))?;
let path = entry.path();
let relative = path
.strip_prefix(root)
.map_err(|e| BoxError::BuildError(format!("Failed to strip prefix: {}", e)))?;
let tar_path = target_prefix.join(relative);
let file_type = entry
.file_type()
.map_err(|e| BoxError::BuildError(format!("Failed to stat entry: {}", e)))?;
let meta = entry
.metadata()
.map_err(|e| BoxError::BuildError(format!("Failed to stat entry: {}", e)))?;
if file_type.is_dir() {
append_dir_with_chown(builder, &tar_path, &path, chown)?;
add_dir_to_tar(builder, root, &path, target_prefix, chown)?;
} else {
append_file_with_chown(builder, &tar_path, &path, &meta, chown)?;
}
}
Ok(())
}
fn append_dir_with_chown<W: std::io::Write>(
builder: &mut tar::Builder<W>,
tar_path: &Path,
dir_path: &Path,
chown: Option<(u32, u32)>,
) -> Result<()> {
if let Some((uid, gid)) = chown {
let mut header = tar::Header::new_gnu();
let meta = std::fs::symlink_metadata(dir_path).map_err(|e| {
BoxError::BuildError(format!("Failed to stat {}: {}", dir_path.display(), e))
})?;
header.set_metadata_in_mode(&meta, tar::HeaderMode::Complete);
header.set_uid(uid as u64);
header.set_gid(gid as u64);
header.set_username("").ok();
header.set_groupname("").ok();
header.set_cksum();
builder
.append_data(&mut header, tar_path, std::io::empty())
.map_err(|e| BoxError::BuildError(format!("Failed to add dir to layer: {}", e)))
} else {
builder
.append_dir(tar_path, dir_path)
.map_err(|e| BoxError::BuildError(format!("Failed to add directory to layer: {}", e)))
}
}
fn append_file_with_chown<W: std::io::Write>(
builder: &mut tar::Builder<W>,
tar_path: &Path,
file_path: &Path,
meta: &std::fs::Metadata,
chown: Option<(u32, u32)>,
) -> Result<()> {
if let Some((uid, gid)) = chown {
let mut header = tar::Header::new_gnu();
header.set_metadata_in_mode(meta, tar::HeaderMode::Complete);
header.set_uid(uid as u64);
header.set_gid(gid as u64);
header.set_username("").ok();
header.set_groupname("").ok();
header.set_cksum();
let body: Box<dyn std::io::Read> = if meta.file_type().is_symlink() {
Box::new(std::io::empty())
} else {
Box::new(std::fs::File::open(file_path).map_err(|e| {
BoxError::BuildError(format!("Failed to open {}: {}", file_path.display(), e))
})?)
};
builder
.append_data(&mut header, tar_path, body)
.map_err(|e| BoxError::BuildError(format!("Failed to add file to layer: {}", e)))
} else {
builder
.append_path_with_name(file_path, tar_path)
.map_err(|e| BoxError::BuildError(format!("Failed to add file to layer: {}", e)))
}
}
#[derive(Debug, Clone)]
pub struct LayerInfo {
pub path: PathBuf,
pub digest: String,
pub size: u64,
}
impl LayerInfo {
pub fn prefixed_digest(&self) -> String {
format!("sha256:{}", self.digest)
}
}
pub(super) fn sha256_file(path: &Path) -> Result<String> {
use sha2::Digest as _;
use std::io::Read as _;
let mut file = std::fs::File::open(path).map_err(|e| {
BoxError::BuildError(format!(
"Failed to open file for hashing {}: {}",
path.display(),
e
))
})?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 65536];
loop {
let n = file.read(&mut buf).map_err(|e| {
BoxError::BuildError(format!(
"Failed to read file for hashing {}: {}",
path.display(),
e
))
})?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hex::encode(hasher.finalize()))
}
pub(super) fn sha256_bytes(data: &[u8]) -> String {
hex::encode(Sha256::digest(data))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_snapshot_empty_dir() {
let tmp = TempDir::new().unwrap();
let snap = DirSnapshot::capture(tmp.path()).unwrap();
assert!(snap.entries.is_empty());
}
#[test]
fn test_snapshot_with_files() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("a.txt"), "hello").unwrap();
fs::create_dir(tmp.path().join("sub")).unwrap();
fs::write(tmp.path().join("sub").join("b.txt"), "world").unwrap();
let snap = DirSnapshot::capture(tmp.path()).unwrap();
assert!(snap.entries.contains_key(&PathBuf::from("a.txt")));
assert!(snap.entries.contains_key(&PathBuf::from("sub")));
assert!(snap.entries.contains_key(&PathBuf::from("sub/b.txt")));
}
#[test]
fn test_snapshot_diff_new_file() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("a.txt"), "hello").unwrap();
let before = DirSnapshot::capture(tmp.path()).unwrap();
fs::write(tmp.path().join("b.txt"), "world").unwrap();
let after = DirSnapshot::capture(tmp.path()).unwrap();
let diff = before.diff(&after);
assert_eq!(diff, vec![PathBuf::from("b.txt")]);
}
#[test]
fn test_snapshot_deletions_top_level_file() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("keep.txt"), "k").unwrap();
fs::write(tmp.path().join("gone.txt"), "g").unwrap();
let before = DirSnapshot::capture(tmp.path()).unwrap();
fs::remove_file(tmp.path().join("gone.txt")).unwrap();
let after = DirSnapshot::capture(tmp.path()).unwrap();
assert_eq!(before.deletions(&after), vec![PathBuf::from("gone.txt")]);
}
#[test]
fn test_snapshot_deletions_collapses_deleted_dir_to_top_level() {
let tmp = TempDir::new().unwrap();
fs::create_dir(tmp.path().join("d")).unwrap();
fs::write(tmp.path().join("d").join("a.txt"), "a").unwrap();
fs::write(tmp.path().join("d").join("b.txt"), "b").unwrap();
let before = DirSnapshot::capture(tmp.path()).unwrap();
fs::remove_dir_all(tmp.path().join("d")).unwrap();
let after = DirSnapshot::capture(tmp.path()).unwrap();
assert_eq!(before.deletions(&after), vec![PathBuf::from("d")]);
}
#[test]
fn test_create_layer_emits_whiteout_for_deletions() {
use flate2::read::GzDecoder;
let rootfs = TempDir::new().unwrap();
let out = TempDir::new().unwrap();
fs::write(rootfs.path().join("kept.txt"), "kept").unwrap();
let layer_path = out.path().join("layer.tar.gz");
create_layer_with_deletions(
rootfs.path(),
&[PathBuf::from("kept.txt")],
&[PathBuf::from("usr/share/doc/removed.txt")],
&layer_path,
)
.unwrap();
let tar_gz = fs::File::open(&layer_path).unwrap();
let mut archive = tar::Archive::new(GzDecoder::new(tar_gz));
let names: Vec<String> = archive
.entries()
.unwrap()
.map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
.collect();
assert!(
names.iter().any(|n| n == "usr/share/doc/.wh.removed.txt"),
"expected an OCI whiteout marker, got: {names:?}"
);
assert!(names.iter().any(|n| n == "kept.txt"));
}
#[test]
fn test_snapshot_diff_modified_file() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("a.txt"), "hello").unwrap();
let before = DirSnapshot::capture(tmp.path()).unwrap();
fs::write(tmp.path().join("a.txt"), "hello world").unwrap();
let after = DirSnapshot::capture(tmp.path()).unwrap();
let diff = before.diff(&after);
assert_eq!(diff, vec![PathBuf::from("a.txt")]);
}
#[test]
#[cfg(unix)]
fn test_snapshot_diff_detects_chmod_only() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let f = tmp.path().join("entry.sh");
fs::write(&f, "#!/bin/sh\necho hi\n").unwrap();
fs::set_permissions(&f, fs::Permissions::from_mode(0o644)).unwrap();
let before = DirSnapshot::capture(tmp.path()).unwrap();
fs::set_permissions(&f, fs::Permissions::from_mode(0o755)).unwrap();
let after = DirSnapshot::capture(tmp.path()).unwrap();
assert_eq!(before.diff(&after), vec![PathBuf::from("entry.sh")]);
}
#[test]
fn test_snapshot_diff_no_changes() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("a.txt"), "hello").unwrap();
let before = DirSnapshot::capture(tmp.path()).unwrap();
let after = DirSnapshot::capture(tmp.path()).unwrap();
let diff = before.diff(&after);
assert!(diff.is_empty());
}
#[test]
fn test_create_layer_from_files() {
let rootfs = TempDir::new().unwrap();
let output_dir = TempDir::new().unwrap();
fs::write(rootfs.path().join("hello.txt"), "hello").unwrap();
fs::write(rootfs.path().join("world.txt"), "world").unwrap();
let output_path = output_dir.path().join("layer.tar.gz");
let changed = vec![PathBuf::from("hello.txt"), PathBuf::from("world.txt")];
let info = create_layer(rootfs.path(), &changed, &output_path).unwrap();
assert!(info.path.exists());
assert!(info.size > 0);
assert!(!info.digest.is_empty());
assert_eq!(info.digest.len(), 64); }
#[test]
fn test_create_layer_empty() {
let rootfs = TempDir::new().unwrap();
let output_dir = TempDir::new().unwrap();
let output_path = output_dir.path().join("layer.tar.gz");
let info = create_layer(rootfs.path(), &[], &output_path).unwrap();
assert!(info.path.exists());
}
#[test]
fn test_create_layer_digest_matches_completed_file() {
let rootfs = TempDir::new().unwrap();
let output_dir = TempDir::new().unwrap();
fs::write(rootfs.path().join("a.txt"), "AAAA-content").unwrap();
let out = output_dir.path().join("layer.tar.gz");
let info = create_layer(rootfs.path(), &[PathBuf::from("a.txt")], &out).unwrap();
let on_disk = sha256_file(&info.path).unwrap();
let on_disk_size = fs::metadata(&info.path).unwrap().len();
assert_eq!(
info.digest, on_disk,
"recorded digest must match completed file"
);
assert_eq!(
info.size, on_disk_size,
"recorded size must match completed file"
);
assert!(
info.size > 20,
"a real one-file layer is larger than an empty gzip header"
);
}
#[test]
fn test_create_layer_distinct_content_distinct_digest() {
let rootfs = TempDir::new().unwrap();
let out_dir = TempDir::new().unwrap();
fs::write(rootfs.path().join("a.txt"), "AAAA-content").unwrap();
let a = create_layer(
rootfs.path(),
&[PathBuf::from("a.txt")],
&out_dir.path().join("a.tgz"),
)
.unwrap();
fs::write(rootfs.path().join("b.txt"), "BBBB-different-longer").unwrap();
let b = create_layer(
rootfs.path(),
&[PathBuf::from("b.txt")],
&out_dir.path().join("b.tgz"),
)
.unwrap();
assert_ne!(
a.digest, b.digest,
"distinct layer content must yield distinct digests"
);
}
#[test]
fn test_create_layer_from_dir() {
let src = TempDir::new().unwrap();
let output_dir = TempDir::new().unwrap();
fs::write(src.path().join("app.py"), "print('hi')").unwrap();
fs::create_dir(src.path().join("lib")).unwrap();
fs::write(src.path().join("lib").join("util.py"), "pass").unwrap();
let output_path = output_dir.path().join("layer.tar.gz");
let info = create_layer_from_dir(src.path(), Path::new("workspace"), &output_path).unwrap();
assert!(info.path.exists());
assert!(info.size > 0);
let file = fs::File::open(&info.path).unwrap();
let decoder = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
let paths: Vec<String> = archive
.entries()
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path().unwrap().to_string_lossy().to_string())
.collect();
assert!(paths.iter().any(|p| p.contains("workspace/app.py")));
assert!(paths.iter().any(|p| p.contains("workspace/lib")));
}
#[test]
#[cfg(unix)]
fn test_create_layer_from_dir_preserves_symlinks() {
let src = TempDir::new().unwrap();
let output_dir = TempDir::new().unwrap();
fs::write(src.path().join("libfoo.so.1"), "real").unwrap();
std::os::unix::fs::symlink("libfoo.so.1", src.path().join("libfoo.so")).unwrap();
let out = output_dir.path().join("layer.tar.gz");
create_layer_from_dir(src.path(), Path::new("lib"), &out).unwrap();
let file = fs::File::open(&out).unwrap();
let decoder = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
let mut found_symlink = false;
for entry in archive.entries().unwrap() {
let entry = entry.unwrap();
if entry.path().unwrap().to_string_lossy() == "lib/libfoo.so" {
assert_eq!(entry.header().entry_type(), tar::EntryType::Symlink);
assert_eq!(
entry.link_name().unwrap().unwrap().to_string_lossy(),
"libfoo.so.1"
);
found_symlink = true;
}
}
assert!(found_symlink, "symlink entry must be present in the layer");
}
#[test]
fn test_sha256_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("test.txt");
fs::write(&path, "hello").unwrap();
let digest = sha256_file(&path).unwrap();
assert_eq!(digest.len(), 64);
assert_eq!(
digest,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
#[test]
fn test_sha256_bytes() {
let digest = sha256_bytes(b"hello");
assert_eq!(
digest,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
#[test]
fn test_layer_info_prefixed_digest() {
let info = LayerInfo {
path: PathBuf::from("/tmp/layer.tar.gz"),
digest: "abc123".to_string(),
size: 100,
};
assert_eq!(info.prefixed_digest(), "sha256:abc123");
}
}