use std::fs;
use std::io;
use std::path::Path;
use cargoless_proto::ContentHash;
use crate::sha256::sha256_hex;
pub const EXCLUDED_DIRS: &[&str] = &["target", ".git", "dist", ".cargoless"];
#[derive(PartialEq, Eq, PartialOrd, Ord)]
enum Kind {
File,
Symlink,
}
struct Entry {
rel: String,
kind: Kind,
payload: Vec<u8>,
}
fn walk(root: &Path, dir: &Path, out: &mut Vec<Entry>) -> io::Result<()> {
let mut children: Vec<fs::DirEntry> = fs::read_dir(dir)?.collect::<io::Result<Vec<_>>>()?;
children.sort_by_key(std::fs::DirEntry::file_name);
for child in children {
let path = child.path();
let meta = fs::symlink_metadata(&path)?;
let name = child.file_name();
let name = name.to_string_lossy();
if meta.file_type().is_symlink() {
let target = fs::read_link(&path)?;
out.push(Entry {
rel: rel_path(root, &path),
kind: Kind::Symlink,
payload: target.to_string_lossy().into_owned().into_bytes(),
});
} else if meta.is_dir() {
if EXCLUDED_DIRS.contains(&name.as_ref()) {
continue;
}
walk(root, &path, out)?;
} else if meta.is_file() {
let bytes = fs::read(&path)?;
out.push(Entry {
rel: rel_path(root, &path),
kind: Kind::File,
payload: sha256_hex(&bytes).into_bytes(),
});
}
}
Ok(())
}
fn rel_path(root: &Path, path: &Path) -> String {
let rel = path.strip_prefix(root).unwrap_or(path);
rel.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<String>>()
.join("/")
}
pub fn hash_source_tree(root: impl AsRef<Path>) -> io::Result<ContentHash> {
let root = root.as_ref();
let mut entries = Vec::new();
walk(root, root, &mut entries)?;
entries.sort_by(|a, b| a.rel.cmp(&b.rel).then_with(|| a.kind.cmp(&b.kind)));
let mut buf = Vec::new();
buf.extend_from_slice(b"tf-cas/source-tree/v1\n");
for e in &entries {
let tag: u8 = match e.kind {
Kind::File => b'F',
Kind::Symlink => b'L',
};
buf.push(tag);
buf.extend_from_slice(&(e.rel.len() as u64).to_be_bytes());
buf.extend_from_slice(e.rel.as_bytes());
buf.extend_from_slice(&(e.payload.len() as u64).to_be_bytes());
buf.extend_from_slice(&e.payload);
}
Ok(ContentHash::new(sha256_hex(&buf)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
fn scratch(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("cargoless-cas-tree-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn identical_trees_hash_equal_and_order_independent() {
let a = scratch("eq-a");
fs::create_dir_all(a.join("src")).unwrap();
fs::write(a.join("src/main.rs"), b"fn main() {}").unwrap();
fs::write(a.join("Cargo.toml"), b"[package]").unwrap();
let b = scratch("eq-b");
fs::write(b.join("Cargo.toml"), b"[package]").unwrap();
fs::create_dir_all(b.join("src")).unwrap();
fs::write(b.join("src/main.rs"), b"fn main() {}").unwrap();
assert_eq!(
hash_source_tree(&a).unwrap(),
hash_source_tree(&b).unwrap(),
"same content+layout ⇒ same hash, regardless of write order"
);
let _ = fs::remove_dir_all(&a);
let _ = fs::remove_dir_all(&b);
}
#[test]
fn any_source_byte_change_changes_the_hash() {
let d = scratch("byte");
fs::write(d.join("a.rs"), b"fn a() {}").unwrap();
let before = hash_source_tree(&d).unwrap();
fs::write(d.join("a.rs"), b"fn a() { }").unwrap();
let after = hash_source_tree(&d).unwrap();
assert_ne!(before, after, "one byte must move the source-tree hash");
fs::write(d.join("a.rs"), b"fn a() {}").unwrap();
assert_eq!(before, hash_source_tree(&d).unwrap());
let _ = fs::remove_dir_all(&d);
}
#[test]
fn target_and_git_are_excluded() {
let d = scratch("excl");
fs::write(d.join("lib.rs"), b"pub fn x() {}").unwrap();
let clean = hash_source_tree(&d).unwrap();
fs::create_dir_all(d.join("target/debug")).unwrap();
fs::write(d.join("target/debug/app.wasm"), b"BUILD OUTPUT").unwrap();
fs::create_dir_all(d.join(".git")).unwrap();
fs::write(d.join(".git/HEAD"), b"ref: refs/heads/main").unwrap();
fs::create_dir_all(d.join("dist")).unwrap();
fs::write(d.join("dist/index.html"), b"<html>built</html>").unwrap();
fs::write(d.join("dist/app_bg.wasm"), b"\0asm BUILD OUTPUT").unwrap();
fs::create_dir_all(d.join(".cargoless")).unwrap();
fs::write(
d.join(".cargoless/latest-green"),
b"cargoless-latest-green/v1\ninput_hash=deadbeef\n",
)
.unwrap();
assert_eq!(
clean,
hash_source_tree(&d).unwrap(),
"target/, .git/, dist/ and .cargoless/ must not affect the source-tree hash"
);
fs::write(d.join("new.rs"), b"// added").unwrap();
assert_ne!(clean, hash_source_tree(&d).unwrap());
let _ = fs::remove_dir_all(&d);
}
#[test]
fn file_add_and_remove_change_the_hash() {
let d = scratch("addrm");
fs::write(d.join("one.rs"), b"1").unwrap();
let one = hash_source_tree(&d).unwrap();
fs::write(d.join("two.rs"), b"2").unwrap();
let two = hash_source_tree(&d).unwrap();
assert_ne!(one, two, "adding a file must invalidate");
fs::remove_file(d.join("two.rs")).unwrap();
assert_eq!(one, hash_source_tree(&d).unwrap(), "removal restores it");
let _ = fs::remove_dir_all(&d);
}
#[cfg(unix)]
#[test]
fn symlink_retarget_invalidates_without_being_followed() {
use std::os::unix::fs::symlink;
let d = scratch("link");
fs::write(d.join("real_a.txt"), b"A").unwrap();
fs::write(d.join("real_b.txt"), b"B").unwrap();
symlink("real_a.txt", d.join("link")).unwrap();
let to_a = hash_source_tree(&d).unwrap();
fs::remove_file(d.join("link")).unwrap();
symlink("real_b.txt", d.join("link")).unwrap();
let to_b = hash_source_tree(&d).unwrap();
assert_ne!(to_a, to_b, "repointing a symlink must invalidate the hash");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn missing_root_is_an_io_error_not_a_panic() {
let mut p = std::env::temp_dir();
p.push(format!(
"cargoless-cas-does-not-exist-{}",
std::process::id()
));
assert!(hash_source_tree(&p).is_err());
}
}