#![allow(
clippy::too_many_lines,
clippy::similar_names,
clippy::items_after_statements,
clippy::manual_let_else,
clippy::map_unwrap_or,
clippy::doc_markdown
)]
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn snapdir_bin() -> PathBuf {
assert_cmd::cargo::cargo_bin("snapdir")
}
fn temp_dir(tag: &str) -> PathBuf {
let mut dir = std::env::temp_dir();
dir.push(format!(
"snapdir-dxerr-{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 nonexistent_path(tag: &str) -> PathBuf {
let mut dir = std::env::temp_dir();
dir.push(format!(
"snapdir-dxerr-MISSING-{tag}-{}-{:?}-no-such-store",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
assert!(!dir.exists(), "the nonexistent path must really be absent");
dir
}
fn file_url(dir: &Path) -> String {
format!("file://{}", dir.display())
}
fn run_raw(args: &[&str], cache: &Path, extra_env: &[(&str, &str)]) -> Output {
let mut cmd = Command::new(snapdir_bin());
cmd.args(args)
.env("SNAPDIR_CACHE_DIR", cache)
.env_remove("SNAPDIR_STORE")
.env_remove("SNAPDIR_OBJECTS_STORE");
for (k, v) in extra_env {
cmd.env(k, v);
}
cmd.output().expect("run snapdir")
}
fn run_ok(args: &[&str], cache: &Path) -> String {
let out = run_raw(args, cache, &[]);
assert!(
out.status.success(),
"snapdir {args:?} exited {:?}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr),
);
String::from_utf8(out.stdout)
.expect("stdout is UTF-8")
.trim_end()
.to_owned()
}
fn stderr_of(out: &Output) -> String {
String::from_utf8_lossy(&out.stderr).into_owned()
}
fn stdout_of(out: &Output) -> String {
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn build_tree(dir: &Path, leaves: &[(&str, &[u8])]) {
for (rel, bytes) in leaves {
let path = dir.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, bytes).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
}
set_dir_perms_recursive(dir);
}
fn set_dir_perms_recursive(dir: &Path) {
fs::set_permissions(dir, fs::Permissions::from_mode(0o755)).unwrap();
for entry in fs::read_dir(dir).unwrap().flatten() {
if entry.file_type().unwrap().is_dir() {
set_dir_perms_recursive(&entry.path());
}
}
}
fn count_objects(dir: &Path) -> usize {
fn walk(dir: &Path, n: &mut usize) {
if let Ok(rd) = fs::read_dir(dir) {
for e in rd.flatten() {
let ft = match e.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if ft.is_dir() {
walk(&e.path(), n);
} else if ft.is_file() {
*n += 1;
}
}
}
}
let mut n = 0;
walk(&dir.join(".objects"), &mut n);
n
}
fn parse_count(line: &str, word: &str) -> Option<usize> {
let idx = line.find(word)?;
line[..idx]
.split_whitespace()
.next_back()
.and_then(|tok| tok.trim_matches(|c: char| !c.is_ascii_digit()).parse().ok())
}
#[test]
fn missing_store_push_names_store_flag_and_env() {
let cache = temp_dir("ms-push-cache");
let src = temp_dir("ms-push-src");
build_tree(&src, &[("a.txt", b"hello")]);
let src_str = src.to_string_lossy().into_owned();
let out = run_raw(&["push", &src_str], &cache, &[]);
assert!(
!out.status.success(),
"push with no --store and no SNAPDIR_STORE must fail; stderr: {}",
stderr_of(&out)
);
let err = stderr_of(&out).to_lowercase();
assert!(
err.contains("--store"),
"the missing-store error must NAME the --store flag; got: {}",
stderr_of(&out)
);
assert!(
err.contains("snapdir_store"),
"the missing-store error must also mention the SNAPDIR_STORE env fallback \
to be actionable; got: {}",
stderr_of(&out)
);
}
#[test]
fn missing_store_fetch_names_store_flag_and_env() {
let cache = temp_dir("ms-fetch-cache");
let out = run_raw(&["fetch", "--id", &"0".repeat(64)], &cache, &[]);
assert!(
!out.status.success(),
"fetch with no --store and no SNAPDIR_STORE must fail; stderr: {}",
stderr_of(&out)
);
let err = stderr_of(&out).to_lowercase();
assert!(
err.contains("--store"),
"the missing-store error must NAME the --store flag; got: {}",
stderr_of(&out)
);
assert!(
err.contains("snapdir_store"),
"the missing-store error must also mention SNAPDIR_STORE; got: {}",
stderr_of(&out)
);
}
#[test]
fn split_snapshot_fetch_without_objects_store_hints_objects_store() {
let cache = temp_dir("split-fetch-cache");
let src = temp_dir("split-fetch-src");
let mani = temp_dir("split-fetch-mani");
let pool = temp_dir("split-fetch-pool");
build_tree(&src, &[("a.txt", b"hello"), ("b.txt", b"world")]);
let src_str = src.to_string_lossy().into_owned();
let mani_url = file_url(&mani);
let pool_url = file_url(&pool);
let id = run_ok(
&[
"push",
"--objects-store",
&pool_url,
"--store",
&mani_url,
&src_str,
],
&cache,
);
assert_eq!(id.len(), 64, "snapshot id is 64 hex chars");
assert_eq!(
count_objects(&mani),
0,
"split push must keep objects out of the manifest store"
);
assert!(count_objects(&pool) > 0, "the pool must hold the blobs");
let fresh = temp_dir("split-fetch-fresh");
let out = run_raw(&["fetch", "--store", &mani_url, "--id", &id], &fresh, &[]);
assert!(
!out.status.success(),
"fetching a split snapshot without --objects-store must fail; stderr: {}",
stderr_of(&out)
);
let err = stderr_of(&out).to_lowercase();
assert!(
err.contains("--objects-store")
|| err.contains("objects-store")
|| err.contains("objects store")
|| err.contains("object pool")
|| err.contains("split"),
"the split-read error must mention the objects-store / split concept \
(name --objects-store), not just a raw 'object not found'; got: {}",
stderr_of(&out)
);
}
#[test]
fn split_snapshot_pull_without_objects_store_hints_objects_store() {
let cache = temp_dir("split-pull-cache");
let src = temp_dir("split-pull-src");
let mani = temp_dir("split-pull-mani");
let pool = temp_dir("split-pull-pool");
build_tree(&src, &[("a.txt", b"hello"), ("b.txt", b"world")]);
let src_str = src.to_string_lossy().into_owned();
let mani_url = file_url(&mani);
let pool_url = file_url(&pool);
let id = run_ok(
&[
"push",
"--objects-store",
&pool_url,
"--store",
&mani_url,
&src_str,
],
&cache,
);
let fresh = temp_dir("split-pull-fresh");
let dest = temp_dir("split-pull-dest");
let dest_str = dest.to_string_lossy().into_owned();
let out = run_raw(
&["pull", "--store", &mani_url, "--id", &id, &dest_str],
&fresh,
&[],
);
assert!(
!out.status.success(),
"pulling a split snapshot without --objects-store must fail; stderr: {}",
stderr_of(&out)
);
let err = stderr_of(&out).to_lowercase();
assert!(
err.contains("--objects-store")
|| err.contains("objects-store")
|| err.contains("objects store")
|| err.contains("object pool")
|| err.contains("split"),
"the split-read error from pull must mention the objects-store / split \
concept; got: {}",
stderr_of(&out)
);
}
#[test]
fn bad_limit_rate_shows_accepted_forms() {
let cache = temp_dir("lr-cache");
let src = temp_dir("lr-src");
build_tree(&src, &[("a.txt", b"x")]);
let src_str = src.to_string_lossy().into_owned();
let store = temp_dir("lr-store");
let store_url = file_url(&store);
let out = run_raw(
&[
"push",
"--store",
&store_url,
"--limit-rate",
"bogus",
&src_str,
],
&cache,
&[],
);
assert!(
!out.status.success(),
"an unparseable --limit-rate must fail nonzero; stderr: {}",
stderr_of(&out)
);
let err = stderr_of(&out).to_lowercase();
assert!(
err.contains("10m") || err.contains("512k") || err.contains("1g") || err.contains("rate"),
"the --limit-rate error must show the accepted forms (e.g. 10M/512K/1G) \
or mention 'rate'; got: {}",
stderr_of(&out)
);
}
#[test]
fn checkout_unknown_id_keeps_fetch_hint() {
let cache = temp_dir("hint-cache");
let dest = temp_dir("hint-dest");
let dest_str = dest.to_string_lossy().into_owned();
let out = run_raw(
&["checkout", "--id", &"0".repeat(64), &dest_str],
&cache,
&[],
);
assert!(
!out.status.success(),
"checkout of an unknown id must fail; stderr: {}",
stderr_of(&out)
);
let err = stderr_of(&out).to_lowercase();
assert!(
err.contains("fetch"),
"the unknown-id error must keep its actionable 'did you ... fetch' hint; \
got: {}",
stderr_of(&out)
);
}
#[test]
fn diff_nonexistent_to_store_errors_not_silent_full_delta() {
let cache = temp_dir("se-diff-nx-cache");
let src = temp_dir("se-diff-nx-src");
build_tree(&src, &[("a.txt", b"hello"), ("b.txt", b"world")]);
let src_str = src.to_string_lossy().into_owned();
let from = temp_dir("se-diff-nx-from");
let from_url = file_url(&from);
run_ok(&["push", "--store", &from_url, &src_str], &cache);
let missing = nonexistent_path("se-diff-nx-to");
let missing_url = file_url(&missing);
let out = run_raw(
&["diff", "--from", &from_url, "--to", &missing_url],
&cache,
&[],
);
assert!(
!out.status.success(),
"diff against a NONEXISTENT --to store must FAIL (not silently print a \
fabricated full delta at exit 0).\nstdout:\n{}\nstderr:\n{}",
stdout_of(&out),
stderr_of(&out)
);
let err = stderr_of(&out).to_lowercase();
let needle = missing
.file_name()
.unwrap()
.to_string_lossy()
.to_lowercase();
assert!(
err.contains(&needle)
|| err.contains("no such")
|| err.contains("not found")
|| err.contains("does not exist")
|| err.contains("unreadable")
|| err.contains("store"),
"the error must name the bad/unreadable --to store; got: {}",
stderr_of(&out)
);
}
#[test]
fn diff_existing_empty_to_store_is_valid_full_deletion() {
let cache = temp_dir("se-diff-empty-cache");
let src = temp_dir("se-diff-empty-src");
build_tree(&src, &[("a.txt", b"hello"), ("b.txt", b"world")]);
let src_str = src.to_string_lossy().into_owned();
let from = temp_dir("se-diff-empty-from");
let from_url = file_url(&from);
run_ok(&["push", "--store", &from_url, &src_str], &cache);
let empty = temp_dir("se-diff-empty-to");
let empty_url = file_url(&empty);
let out = run_raw(
&["diff", "--from", &from_url, "--to", &empty_url],
&cache,
&[],
);
assert!(
out.status.success(),
"diff against an EXISTING-EMPTY --to store must SUCCEED (exit 0); a real \
empty store is not an error.\nstderr:\n{}",
stderr_of(&out)
);
let stdout = stdout_of(&out);
assert!(
stdout
.lines()
.any(|l| l.starts_with('D') && l.contains("./a.txt"))
&& stdout
.lines()
.any(|l| l.starts_with('D') && l.contains("./b.txt")),
"an existing-empty TO must report the FROM files as deleted (D); got:\n{stdout}"
);
}
#[test]
fn sync_nonexistent_from_store_errors_not_silent_success() {
let cache = temp_dir("se-sync-nx-cache");
let id = "0".repeat(64);
let to = temp_dir("se-sync-nx-to");
let to_url = file_url(&to);
let missing = nonexistent_path("se-sync-nx-from");
let missing_url = file_url(&missing);
let out = run_raw(
&["sync", "--id", &id, "--from", &missing_url, "--to", &to_url],
&cache,
&[],
);
assert!(
!out.status.success(),
"sync from a NONEXISTENT --from store must FAIL (not silently succeed \
treating it as empty).\nstdout:\n{}\nstderr:\n{}",
stdout_of(&out),
stderr_of(&out)
);
let manifests = {
let mut n = 0;
fn walk(dir: &Path, n: &mut usize) {
if let Ok(rd) = fs::read_dir(dir) {
for e in rd.flatten() {
if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
walk(&e.path(), n);
} else {
*n += 1;
}
}
}
}
walk(&to.join(".manifests"), &mut n);
n
};
assert_eq!(
manifests, 0,
"a failed sync from a missing source must not publish any dest manifest"
);
}
fn dedup_capture(tag: &str, cache: &Path) -> (PathBuf, String, String, usize) {
let src = temp_dir(&format!("{tag}-src"));
build_tree(
&src,
&[
("one.txt", b"AAAA"),
("two.txt", b"AAAA"),
("three.txt", b"BBBB"),
("four.txt", b"BBBB"),
],
);
let src_str = src.to_string_lossy().into_owned();
let store = temp_dir(&format!("{tag}-store"));
let store_url = file_url(&store);
let id = run_ok(&["push", "--store", &store_url, &src_str], cache);
let unique = count_objects(&store);
assert_eq!(
unique, 2,
"the dedup tree must collapse 4 file refs to 2 unique objects; got {unique}"
);
(store, store_url, id, unique)
}
#[test]
fn sync_counts_unique_objects_and_fresh_dest_skips_zero() {
let cache = temp_dir("mc-live-cache");
let (_from, from_url, id, unique) = dedup_capture("mc-live", &cache);
let to = temp_dir("mc-live-to");
let to_url = file_url(&to);
let out = run_raw(
&["sync", "--id", &id, "--from", &from_url, "--to", &to_url],
&cache,
&[],
);
assert!(
out.status.success(),
"the dedup sync must succeed; stderr: {}",
stderr_of(&out)
);
let stderr = stderr_of(&out);
let summary = stderr
.lines()
.find(|l| l.contains("copied"))
.unwrap_or_else(|| panic!("expected a sync summary line with a copied count:\n{stderr}"));
if let Some(skipped) = parse_count(summary, "skipped") {
assert_eq!(
skipped, 0,
"a FIRST sync into an EMPTY dest must report 0 skipped (nothing \
pre-exists to skip); got:\n{summary}"
);
}
let copied = parse_count(summary, "copied")
.unwrap_or_else(|| panic!("no copied count in summary:\n{summary}"));
assert_eq!(
copied, unique,
"the 'copied' count must be the UNIQUE-OBJECT count ({unique}), not the \
file-reference count (4); got:\n{summary}"
);
assert_eq!(
count_objects(&to),
unique,
"the dest must hold exactly {unique} unique objects after the sync"
);
}
#[test]
fn sync_dryrun_counts_unique_objects_not_file_refs() {
let cache = temp_dir("mc-dry-cache");
let (_from, from_url, id, unique) = dedup_capture("mc-dry", &cache);
let to = temp_dir("mc-dry-to");
let to_url = file_url(&to);
let out = run_raw(
&[
"sync", "--dryrun", "--id", &id, "--from", &from_url, "--to", &to_url,
],
&cache,
&[],
);
assert!(
out.status.success(),
"the dedup dry-run sync must succeed; stderr: {}",
stderr_of(&out)
);
let combined = format!("{}\n{}", stdout_of(&out), stderr_of(&out));
let line = combined
.lines()
.find(|l| {
let lc = l.to_lowercase();
(lc.contains("object") || lc.contains("copy") || lc.contains("copied"))
&& l.chars().any(|c| c.is_ascii_digit())
})
.unwrap_or_else(|| panic!("expected a dry-run object-count line:\n{combined}"));
assert!(
line.contains(&unique.to_string()),
"the dry-run must report the unique-object count ({unique}); got: {line}"
);
assert!(
!line.replace("object(s)", "objects").contains("4 object"),
"the dry-run must NOT report 4 object(s) (that is the file-reference \
miscount; only {unique} unique objects exist); got: {line}"
);
assert_eq!(
count_objects(&to),
0,
"--dryrun must not write any objects to the dest"
);
}
#[test]
fn missing_store_push_names_both_tokens_literally() {
let cache = temp_dir("ms-lit-cache");
let src = temp_dir("ms-lit-src");
build_tree(&src, &[("a.txt", b"hello")]);
let src_str = src.to_string_lossy().into_owned();
let out = run_raw(&["push", &src_str], &cache, &[]);
assert!(!out.status.success(), "push with no store must fail");
let err = stderr_of(&out);
assert!(
err.contains("--store"),
"missing-store error must literally name `--store`; got: {err}"
);
assert!(
err.contains("SNAPDIR_STORE"),
"missing-store error must literally name the `SNAPDIR_STORE` env var \
(uppercase); got: {err}"
);
}
#[test]
fn split_fetch_hint_names_both_objects_flags_literally() {
let cache = temp_dir("split-lit-cache");
let src = temp_dir("split-lit-src");
let mani = temp_dir("split-lit-mani");
let pool = temp_dir("split-lit-pool");
build_tree(&src, &[("a.txt", b"hello"), ("b.txt", b"world")]);
let src_str = src.to_string_lossy().into_owned();
let mani_url = file_url(&mani);
let pool_url = file_url(&pool);
let id = run_ok(
&[
"push",
"--objects-store",
&pool_url,
"--store",
&mani_url,
&src_str,
],
&cache,
);
let fresh = temp_dir("split-lit-fresh");
let out = run_raw(&["fetch", "--store", &mani_url, "--id", &id], &fresh, &[]);
assert!(!out.status.success(), "split fetch without pool must fail");
let err = stderr_of(&out);
assert!(
err.contains("--objects-store"),
"the split hint must literally name `--objects-store`; got: {err}"
);
assert!(
err.contains("--from-objects"),
"the split hint must also literally name `--from-objects` (the sync-side \
flag) so the user learns both names; got: {err}"
);
}
#[test]
fn missing_object_with_objects_store_gives_plain_error_no_split_hint() {
let cache = temp_dir("inv-cache");
let src = temp_dir("inv-src");
let mani = temp_dir("inv-mani");
let pool = temp_dir("inv-pool");
build_tree(&src, &[("a.txt", b"hello"), ("b.txt", b"world")]);
let src_str = src.to_string_lossy().into_owned();
let mani_url = file_url(&mani);
let pool_url = file_url(&pool);
let id = run_ok(
&[
"push",
"--objects-store",
&pool_url,
"--store",
&mani_url,
&src_str,
],
&cache,
);
assert!(count_objects(&pool) > 0, "pool must hold the blobs");
let wrong_pool = temp_dir("inv-wrong-pool");
let wrong_pool_url = file_url(&wrong_pool);
assert_eq!(count_objects(&wrong_pool), 0, "the wrong pool is empty");
let fresh = temp_dir("inv-fresh");
let out = run_raw(
&[
"fetch",
"--store",
&mani_url,
"--objects-store",
&wrong_pool_url,
"--id",
&id,
],
&fresh,
&[],
);
assert!(
!out.status.success(),
"a genuinely missing object must still fail; stderr: {}",
stderr_of(&out)
);
let err = stderr_of(&out).to_lowercase();
assert!(
err.contains("object not found") || err.contains("not found"),
"a genuine missing object (pool supplied) must give the plain \
'object not found' cause; got: {}",
stderr_of(&out)
);
assert!(
!err.contains("re-run with --objects-store") && !err.contains("pushed with a split"),
"the split hint must NOT fire when --objects-store was already supplied \
(it would tell the user to do what they already did); got: {}",
stderr_of(&out)
);
}
#[test]
fn sync_counts_three_unique_objects_across_six_refs() {
let cache = temp_dir("mc3-cache");
let src = temp_dir("mc3-src");
build_tree(
&src,
&[
("a1.txt", b"AAAA"),
("a2.txt", b"AAAA"),
("b1.txt", b"BBBB"),
("b2.txt", b"BBBB"),
("b3.txt", b"BBBB"),
("c1.txt", b"CCCC"),
],
);
let src_str = src.to_string_lossy().into_owned();
let from = temp_dir("mc3-from");
let from_url = file_url(&from);
let id = run_ok(&["push", "--store", &from_url, &src_str], &cache);
let unique = count_objects(&from);
assert_eq!(
unique, 3,
"6 file refs must collapse to 3 unique objects; got {unique}"
);
let to = temp_dir("mc3-to");
let to_url = file_url(&to);
let out = run_raw(
&["sync", "--id", &id, "--from", &from_url, "--to", &to_url],
&cache,
&[],
);
assert!(
out.status.success(),
"the dedup sync must succeed; stderr: {}",
stderr_of(&out)
);
let stderr = stderr_of(&out);
let summary = stderr
.lines()
.find(|l| l.contains("copied"))
.unwrap_or_else(|| panic!("expected a sync summary with a copied count:\n{stderr}"));
if let Some(skipped) = parse_count(summary, "skipped") {
assert_eq!(
skipped, 0,
"a first sync into an EMPTY dest must report 0 skipped; got:\n{summary}"
);
}
let copied = parse_count(summary, "copied")
.unwrap_or_else(|| panic!("no copied count in summary:\n{summary}"));
assert_eq!(
copied, 3,
"the 'copied' count must be the 3 UNIQUE objects, not the 6 file \
references; got:\n{summary}"
);
assert_eq!(
count_objects(&to),
3,
"the dest must hold exactly 3 unique objects after the sync"
);
}