use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
fn snapdir_bin() -> std::path::PathBuf {
assert_cmd::cargo::cargo_bin("snapdir")
}
fn temp_dir(tag: &str) -> PathBuf {
let mut dir = std::env::temp_dir();
dir.push(format!(
"snapdir-cli-pathnorm-{tag}-{}-{:?}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn build_foo_tree(parent: &Path) -> PathBuf {
let foo_dir = parent.join("foo");
fs::create_dir(&foo_dir).unwrap();
fs::write(foo_dir.join("a.txt"), b"hello").unwrap();
fs::write(foo_dir.join("bar.txt"), b"").unwrap();
fs::create_dir(foo_dir.join("sub")).unwrap();
fs::write(foo_dir.join("sub").join("b.txt"), b"world!!").unwrap();
for (rel, mode) in [
("a.txt", 0o644),
("bar.txt", 0o644),
("sub/b.txt", 0o644),
("sub", 0o755),
] {
fs::set_permissions(foo_dir.join(rel), fs::Permissions::from_mode(mode)).unwrap();
}
fs::set_permissions(&foo_dir, fs::Permissions::from_mode(0o755)).unwrap();
fs::set_permissions(parent, fs::Permissions::from_mode(0o755)).unwrap();
foo_dir
}
const FORMS: [&str; 4] = ["foo", "./foo", "foo/", "./foo/"];
fn run_from(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> String {
let mut cmd = Command::new(snapdir_bin());
cmd.args(args).current_dir(cwd);
for (k, v) in env {
cmd.env(k, v);
}
let output = cmd.output().expect("run snapdir");
assert!(
output.status.success(),
"snapdir {args:?} (cwd={}) exited with {:?}\nstderr: {}",
cwd.display(),
output.status.code(),
String::from_utf8_lossy(&output.stderr),
);
String::from_utf8(output.stdout).expect("stdout is UTF-8")
}
#[test]
fn path_normalize_id_four_forms_are_byte_equal() {
let parent = temp_dir("id");
build_foo_tree(&parent);
let mut ids = Vec::new();
for form in FORMS {
let id = run_from(&parent, &["id", form], &[]);
let id = id.trim_end().to_owned();
assert_eq!(
id.len(),
64,
"snapshot id should be 64 hex chars (form {form:?})"
);
ids.push(id);
}
for form_id in &ids[1..] {
assert_eq!(&ids[0], form_id, "all four id forms must match: {ids:?}");
}
fs::remove_dir_all(&parent).ok();
}
#[test]
fn path_normalize_manifest_four_forms_byte_equal_and_spec_conformant() {
let parent = temp_dir("manifest");
build_foo_tree(&parent);
let parent_str = parent.to_string_lossy().into_owned();
let mut outputs = Vec::new();
for form in FORMS {
outputs.push(run_from(&parent, &["manifest", form], &[]));
}
for (i, out) in outputs.iter().enumerate().skip(1) {
assert_eq!(
outputs[0], *out,
"manifest must be byte-identical across forms (diff at {:?})",
FORMS[i]
);
}
let manifest = &outputs[0];
let lines: Vec<&str> = manifest.lines().collect();
assert!(!lines.is_empty(), "manifest must not be empty");
let (head, root_path) = lines[0].rsplit_once(' ').unwrap();
assert!(
head.starts_with("D "),
"root line must be a directory: {:?}",
lines[0]
);
assert_eq!(
root_path, "./",
"root entry path must be `./`: {:?}",
lines[0]
);
for line in &lines {
let (_head, path) = line.rsplit_once(' ').unwrap();
assert!(
!path.starts_with('/'),
"relative manifest must not leak absolute paths: {line:?}"
);
assert!(
!path.starts_with(&parent_str),
"relative manifest must not leak the parent prefix: {line:?}"
);
assert!(
path.starts_with("./"),
"every relative entry must start with `./`: {line:?}"
);
}
assert!(
manifest.contains(" ./bar.txt\n") || manifest.ends_with(" ./bar.txt"),
"bar.txt must render as `./bar.txt`, not a `.bar.txt` artifact:\n{manifest}"
);
fs::remove_dir_all(&parent).ok();
}
#[test]
fn path_normalize_absolute_four_forms_byte_equal() {
let parent = temp_dir("abs");
build_foo_tree(&parent);
let mut outputs = Vec::new();
for form in FORMS {
outputs.push(run_from(&parent, &["manifest", "--absolute", form], &[]));
}
for (i, out) in outputs.iter().enumerate().skip(1) {
assert_eq!(
outputs[0], *out,
"--absolute manifest must be byte-identical across forms (diff at {:?})",
FORMS[i]
);
}
assert!(
!outputs[0].contains("/./"),
"--absolute manifest must not contain a `/./` artifact:\n{}",
outputs[0]
);
fs::remove_dir_all(&parent).ok();
}
#[test]
fn path_normalize_push_reid_four_forms_byte_equal() {
let parent = temp_dir("push-parent");
build_foo_tree(&parent);
let cache = temp_dir("push-cache");
let cache_str = cache.to_string_lossy().into_owned();
let mut ids = Vec::new();
for form in FORMS {
let store = temp_dir("push-store");
let store_url = format!("file://{}", store.display());
let pushed = run_from(
&parent,
&["push", "--store", &store_url, form],
&[("SNAPDIR_CACHE_DIR", &cache_str)],
);
let pushed = pushed.trim_end().to_owned();
assert_eq!(
pushed.len(),
64,
"push must print a 64-hex id (form {form:?})"
);
let reid = run_from(&parent, &["id", form], &[("SNAPDIR_CACHE_DIR", &cache_str)]);
let reid = reid.trim_end().to_owned();
assert_eq!(pushed, reid, "push id must equal re-id (form {form:?})");
ids.push(pushed);
fs::remove_dir_all(&store).ok();
}
for form_id in &ids[1..] {
assert_eq!(
&ids[0], form_id,
"all four push/re-id forms must match: {ids:?}"
);
}
fs::remove_dir_all(&parent).ok();
fs::remove_dir_all(&cache).ok();
}
#[test]
fn path_normalize_canonical_foo_id_pinned() {
let parent = temp_dir("pinned");
build_foo_tree(&parent);
let id = run_from(&parent, &["id", "foo"], &[]);
let id = id.trim_end();
assert_eq!(
id, PINNED_FOO_ID,
"canonical `foo` snapshot id must be byte-stable (invariant guard)"
);
fs::remove_dir_all(&parent).ok();
}
const PINNED_FOO_ID: &str = "ff83fc0387da6480304710b52094f1d137736908661b01cb4e838116dc37d231";