From d9439253fd26070e029ca5e9eb4d76ab519130f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Wed, 9 Sep 2026 12:27:51 +0000 Subject: [PATCH] test: cover the store and the exit codes before moving any of it --- tests/link_store.rs | 262 +++++++++++++++++++++++++++++++++++++++++++ tests/output_io.rs | 99 ++++++++++++++++ tests/support/mod.rs | 207 ++++++++++++++++++++++++++++++++++ 3 files changed, 568 insertions(+) create mode 100644 tests/link_store.rs create mode 100644 tests/output_io.rs create mode 100644 tests/support/mod.rs diff --git a/tests/link_store.rs b/tests/link_store.rs new file mode 100644 index 0000000..95c36b2 --- /dev/null +++ b/tests/link_store.rs @@ -0,0 +1,262 @@ +// what the store is for: a path a sandbox should not read living somewhere it +// cannot. every case here is one that was once wrong. +mod support; + +use std::fs; +use support::Case; + +#[test] +fn a_path_moves_into_the_store_and_reads_back_through_the_link() { + let case = Case::new("roundtrip"); + case.write(".env", b"SECRET=1\n"); + case.write("secrets/token", b"tok\n"); + case.gitignore(".env\nsecrets/\n"); + + case.ahab(&["link", "add", ".env", "secrets"]).ok(); + + assert!(case.is_symlink(".env")); + assert_eq!(fs::read(case.path(".env")).unwrap(), b"SECRET=1\n"); + assert_eq!(fs::read(case.path("secrets/token")).unwrap(), b"tok\n"); + + case.ahab(&["link", "check", "--exit-code"]).ok(); + case.ahab(&["link", "restore", "--all"]).ok(); + + assert!(!case.is_symlink(".env")); + assert_eq!(fs::read(case.path(".env")).unwrap(), b"SECRET=1\n"); +} + +#[test] +fn add_refuses_a_symlink_that_already_leads_out_of_the_repository() { + let case = Case::new("launder"); + fs::write(case.outside.join("key"), b"KEY\n").unwrap(); + case.link(&case.path("cache"), &case.outside); + + // moving the link would move the pointer and leave the contents there, + // and check would then see a link into the store and call it clean + case.ahab(&["link", "add", "cache"]) + .failed() + .says("outside the repository"); + + case.ahab(&["link", "check", "--exit-code"]).code_is(4); +} + +#[test] +fn check_reports_a_store_entry_that_leads_back_out() { + let case = Case::new("poisoned"); + fs::create_dir_all(&case.store).unwrap(); + fs::write(case.outside.join("key"), b"KEY\n").unwrap(); + // the state an older ahab left: the store holds a way back out + case.link(&case.store.join("cache"), &case.outside); + case.link(&case.path("cache"), &case.store.join("cache")); + + case.ahab(&["link", "check", "--exit-code"]).code_is(4); +} + +#[test] +fn check_reports_a_tracked_symlink_leading_out() { + let case = Case::new("tracked-link"); + fs::create_dir_all(case.outside.join("aws")).unwrap(); + case.link(&case.path("awsdir"), &case.outside.join("aws")); + case.git(&["add", "-f", "awsdir"]); + case.git(&["commit", "-qm", "commit a symlink out"]); + + // git tracks symlinks, so this is in no untracked or ignored listing + case.ahab(&["link", "check", "--exit-code"]).code_is(4); + case.ahab(&["link", "check", "--porcelain"]).says("T>"); + // and add cannot fix it, so saying so is all check can do + case.ahab(&["link", "add", "awsdir"]) + .failed() + .says("tracked by git"); +} + +#[test] +fn a_stored_symlink_is_one_entry_rather_than_a_tree_to_walk() { + let case = Case::new("walk-out"); + fs::create_dir_all(case.outside.join("private")).unwrap(); + fs::write(case.outside.join("private/diary"), b"x\n").unwrap(); + fs::write(case.outside.join(".netrc"), b"x\n").unwrap(); + fs::create_dir_all(&case.store).unwrap(); + case.link(&case.store.join("cache"), &case.outside); + + let run = case.ahab(&["link", "list"]); + run.ok().says("cache"); + // is_dir() would follow the link and enumerate what is behind it + run.silent_about(".netrc"); + run.silent_about("diary"); +} + +#[test] +fn a_store_inside_the_repository_is_refused_however_it_is_spelled() { + let case = Case::new("store-inside"); + case.write(".env", b"SECRET=1\n"); + case.gitignore(".env\n"); + + // named directly + case.ahab(&["link", "add", "--store", "./within", ".env"]) + .failed() + .says("inside the repository"); + + // and reached through a symlink, which a prefix test does not catch + case.mkdir("within"); + let sneaky = case.repo.parent().unwrap().join("sneaky"); + case.link(&sneaky, &case.path("within")); + let run = case.ahab(&[ + "link".as_ref(), + "add".as_ref(), + "--store".as_ref(), + sneaky.as_os_str(), + ".env".as_ref(), + ]); + run.failed().says("inside the repository"); + assert!(!case.is_symlink(".env")); +} + +#[test] +fn a_relative_store_root_still_resolves_from_anywhere() { + let case = Case::new("relative-store"); + case.write("sub/.env", b"SECRET=1\n"); + case.gitignore("sub/.env\n"); + + case.ahab(&["link", "add", "--store", "../store", "sub/.env"]) + .ok(); + + // the target is written into the symlink, so a relative one would resolve + // from the link's own directory rather than the working one + assert!(case.is_symlink("sub/.env")); + assert_eq!(fs::read(case.path("sub/.env")).unwrap(), b"SECRET=1\n"); +} + +#[test] +fn the_store_directories_are_the_owners_alone() { + let case = Case::new("modes"); + case.write("deep/nested/.env", b"SECRET=1\n"); + case.gitignore("deep\n"); + + case.ahab(&["link", "add", "deep/nested/.env"]).ok(); + + for dir in [ + case.store.as_path(), + &case.store.join("deep"), + &case.store.join("deep/nested"), + ] { + assert_eq!(case.mode(dir), 0o700, "{}", dir.display()); + } +} + +#[test] +fn a_failed_link_puts_the_payload_back() { + let case = Case::new("rollback"); + case.write("x.env", b"SECRET=1\n"); + // ahab's own temp name, but a real file: it must not be removed, and the + // payload must not be left in the store with nothing pointing at it + case.write("x.env.ahab-tmp", b"THE PROJECT OWNS THIS\n"); + case.gitignore("x.env\n"); + + case.ahab(&["link", "add", "x.env"]).failed(); + + assert!(!case.is_symlink("x.env")); + assert_eq!(fs::read(case.path("x.env")).unwrap(), b"SECRET=1\n"); + assert_eq!( + fs::read(case.path("x.env.ahab-tmp")).unwrap(), + b"THE PROJECT OWNS THIS\n" + ); + assert!(!case.store.join("x.env").exists()); +} + +#[test] +fn a_directory_of_symlinks_out_keeps_its_destinations() { + let case = Case::new("collapse"); + fs::write(case.outside.join("a"), b"A\n").unwrap(); + case.link(&case.path("bundle/one"), &case.outside.join("a")); + + // collapsing to `bundle/` would drop both the destination and the code + let run = case.ahab(&["link", "check", "--porcelain"]); + run.code_is(0).says("?>").says("bundle/one"); +} + +#[test] +fn a_filename_that_is_not_utf_8_is_reported_and_moved_as_itself() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let case = Case::new("latin1"); + let name = OsStr::from_bytes(b"caf\xe9.env"); + fs::write(case.repo.join(name), b"SECRET=1\n").unwrap(); + + // the whole listing used to fail on the one entry + let run = case.ahab(&["link", "check", "-z"]); + run.code_is(0); + assert!( + run.stdout.windows(4).any(|w| w == b"caf\xe9"), + "-z must write the bytes the name actually has" + ); + + case.gitignore("caf\u{e9}.env\n"); + case.ahab(&["link".as_ref(), "add".as_ref(), name]).ok(); + assert!( + fs::symlink_metadata(case.repo.join(name)) + .unwrap() + .is_symlink() + ); +} + +#[test] +fn a_tracked_filename_that_is_not_utf_8_is_still_refused() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let case = Case::new("latin1-tracked"); + let name = OsStr::from_bytes(b"caf\xe9.env"); + fs::write(case.repo.join(name), b"TRACKED\n").unwrap(); + case.git(&["add".as_ref(), "-f".as_ref(), name]); + case.git(&["commit", "-qm", "track a latin-1 name"]); + + // asked about lossily, git answers about a path nothing has and says + // "not tracked", and a tracked file leaves the working tree + case.ahab(&["link".as_ref(), "add".as_ref(), name]) + .failed() + .says("tracked by git"); + + assert!( + !fs::symlink_metadata(case.repo.join(name)) + .unwrap() + .is_symlink() + ); +} + +#[test] +fn a_pathspec_is_asked_about_as_a_name_not_a_pattern() { + let case = Case::new("pathspec"); + // git reads pathspec magic after `--` too, so this used to have git list + // every tracked file *except* the named one, which read as "it is tracked" + case.write(":!untracked.env", b"SECRET=1\n"); + case.gitignore(":!untracked.env\n"); + + case.ahab(&["link", "add", ":!untracked.env"]).ok(); + assert!(case.is_symlink(":!untracked.env")); +} + +#[test] +fn distinct_remotes_do_not_share_one_store_directory() { + let case = Case::new("remotes"); + + case.git(&[ + "remote", + "set-url", + "origin", + "https://git.example.org/a/my_api", + ]); + let plain = case.ahab(&["link", "list"]).out(); + + case.git(&[ + "remote", + "set-url", + "origin", + "https://git.example.org/a/my~api", + ]); + let awkward = case.ahab(&["link", "list"]).out(); + + assert_ne!(plain, awkward); + // the ordinary remote keeps the path it already had + assert!(plain.contains("/a/my_api"), "{plain}"); +} diff --git a/tests/output_io.rs b/tests/output_io.rs new file mode 100644 index 0000000..f0f43c4 --- /dev/null +++ b/tests/output_io.rs @@ -0,0 +1,99 @@ +// what a command reports and how it exits, including when the write fails. +mod support; + +use std::fs; +use support::Case; + +#[test] +fn findings_are_reported_through_the_exit_code() { + let case = Case::new("findings"); + case.write("loose.txt", b"x\n"); + + case.ahab(&["link", "check"]).ok(); + case.ahab(&["link", "check", "--exit-code"]).code_is(4); +} + +#[test] +fn an_argument_error_exits_two() { + let case = Case::new("usage"); + + case.ahab(&["link", "restore"]).code_is(2); + case.ahab(&["link", "restore", "--all", "some/path"]) + .code_is(2); + case.ahab(&["postgres", "dump", "-F", "directory", "-z", "d"]) + .code_is(2); + case.ahab(&["nonsense"]).code_is(2); +} + +#[test] +fn a_write_that_cannot_be_delivered_is_a_failure_rather_than_a_success() { + let case = Case::new("devfull"); + for n in 0..200 { + case.write(&format!("file-{n}.txt"), b"x\n"); + } + + // /dev/full accepts the open and fails the write. exiting 0 there would + // report a listing that was never delivered + let Ok(full) = fs::File::create("/dev/full") else { + eprintln!("skipped: this system has no /dev/full to fail a write against"); + return; + }; + + let out = std::process::Command::new(env!("CARGO_BIN_EXE_ahab")) + .current_dir(&case.repo) + .env("XDG_DATA_HOME", case.repo.join("../xdg")) + .args(["link", "check", "--exit-code", "--porcelain"]) + .stdout(full) + .output() + .expect("running ahab"); + + assert_eq!(out.status.code(), Some(1), "{:?}", out.status); + assert!( + String::from_utf8_lossy(&out.stderr).contains("writing to stdout failed"), + "{}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn every_named_path_is_moved_even_when_the_reader_leaves() { + let case = Case::new("partial"); + for name in ["a.env", "b.env", "c.env"] { + case.write(name, b"x\n"); + } + case.gitignore("a.env\nb.env\nc.env\n"); + + // a closed stdout must stop the writing, not the moving: the command had + // planned to move all three and reported success for doing so + let run = case.ahab(&["link", "add", "a.env", "b.env", "c.env"]); + run.ok(); + + for name in ["a.env", "b.env", "c.env"] { + assert!(case.is_symlink(name), "{name} was left behind"); + } +} + +#[test] +fn quiet_keeps_the_reason_a_path_failed() { + let case = Case::new("quiet"); + case.write("ok.env", b"x\n"); + case.gitignore("ok.env\n"); + + let run = case.ahab(&["-q", "link", "add", "ok.env", "missing.env"]); + run.failed() + .says("1 of 2 paths failed") + // the line saying *which* and *why* is a result, not progress + .says("missing.env"); +} + +#[test] +fn a_dry_run_changes_nothing_on_disk() { + let case = Case::new("dryrun"); + case.write(".env", b"SECRET=1\n"); + case.gitignore(".env\n"); + + case.ahab(&["--dry-run", "link", "add", ".env"]).ok(); + + assert!(!case.is_symlink(".env")); + assert!(!case.store.join(".env").exists()); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs new file mode 100644 index 0000000..5a66cea --- /dev/null +++ b/tests/support/mod.rs @@ -0,0 +1,207 @@ +// enough scaffolding to run ahab against a throwaway repository, hand-rolled +// rather than pulled in: the crate has no dependencies it does not need, and a +// dev-dependency is still something to trust and keep current. + +// each integration test file compiles this module separately, so a helper only +// one of them needs looks unused to the others +#![allow(dead_code)] +use std::ffi::OsStr; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::{env, fs}; + +// unique per case even with the suite running in parallel +static NEXT: AtomicU32 = AtomicU32::new(0); + +pub struct Case { + root: PathBuf, + pub repo: PathBuf, + pub store: PathBuf, + pub outside: PathBuf, +} + +impl Case { + // a repository with an origin remote, a store of its own, and one commit + pub fn new(label: &str) -> Self { + let root = env::temp_dir().join(format!( + "ahab-{label}-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let _ = fs::remove_dir_all(&root); + + let case = Self { + repo: root.join("repo"), + store: root + .join("xdg/ahab/git.example.org/acme/proj") + .to_path_buf(), + outside: root.join("outside"), + root, + }; + + for dir in [&case.repo, &case.outside] { + fs::create_dir_all(dir).expect("creating the case directories"); + } + + case.git(&["init", "-q", "."]); + case.git(&["config", "user.email", "test@example.org"]); + case.git(&["config", "user.name", "test"]); + case.git(&["config", "commit.gpgsign", "false"]); + case.git(&[ + "remote", + "add", + "origin", + "https://git.example.org/acme/proj.git", + ]); + case.write("tracked.txt", b"tracked\n"); + case.git(&["add", "tracked.txt"]); + case.git(&["commit", "-qm", "init"]); + + case + } + + pub fn git>(&self, args: &[S]) -> Output { + let out = Command::new("git") + .current_dir(&self.repo) + .args(args) + .output() + .expect("running git"); + + assert!( + out.status.success(), + "git {:?} failed: {}", + args.iter() + .map(|a| a.as_ref().to_string_lossy()) + .collect::>(), + String::from_utf8_lossy(&out.stderr) + ); + out + } + + // ahab, in the repository, with a store nothing else shares + pub fn ahab>(&self, args: &[S]) -> Run { + let out = Command::new(env!("CARGO_BIN_EXE_ahab")) + .current_dir(&self.repo) + .env("XDG_DATA_HOME", self.root.join("xdg")) + // the git commands ahab runs must not read the developer's own config + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .args(args) + .output() + .expect("running ahab"); + + Run { + code: out.status.code(), + stdout: out.stdout, + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + } + } + + pub fn path(&self, rel: &str) -> PathBuf { + self.repo.join(rel) + } + + pub fn write(&self, rel: &str, contents: &[u8]) { + let path = self.path(rel); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("creating a parent directory"); + } + fs::write(path, contents).expect("writing a file"); + } + + pub fn mkdir(&self, rel: &str) { + fs::create_dir_all(self.path(rel)).expect("creating a directory"); + } + + pub fn link(&self, at: &Path, to: &Path) { + if let Some(parent) = at.parent() { + fs::create_dir_all(parent).expect("creating a parent directory"); + } + std::os::unix::fs::symlink(to, at).expect("creating a symlink"); + } + + pub fn gitignore(&self, lines: &str) { + self.write(".gitignore", lines.as_bytes()); + self.git(&["add", ".gitignore"]); + self.git(&["commit", "-qm", "ignore"]); + } + + pub fn is_symlink(&self, rel: &str) -> bool { + fs::symlink_metadata(self.path(rel)).is_ok_and(|meta| meta.is_symlink()) + } + + pub fn mode(&self, path: &Path) -> u32 { + use std::os::unix::fs::PermissionsExt; + + fs::symlink_metadata(path) + .expect("reading a mode") + .permissions() + .mode() + & 0o777 + } +} + +impl Drop for Case { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +pub struct Run { + pub code: Option, + pub stdout: Vec, + pub stderr: String, +} + +impl Run { + pub fn out(&self) -> String { + String::from_utf8_lossy(&self.stdout).into_owned() + } + + pub fn ok(&self) -> &Self { + assert_eq!(self.code, Some(0), "{}", self.report()); + self + } + + pub fn failed(&self) -> &Self { + assert_ne!(self.code, Some(0), "{}", self.report()); + self + } + + pub fn code_is(&self, want: i32) -> &Self { + assert_eq!(self.code, Some(want), "{}", self.report()); + self + } + + pub fn says(&self, needle: &str) -> &Self { + assert!( + self.out().contains(needle) || self.stderr.contains(needle), + "expected {needle:?}\n{}", + self.report() + ); + self + } + + pub fn silent_about(&self, needle: &str) -> &Self { + assert!( + !self.out().contains(needle) && !self.stderr.contains(needle), + "did not expect {needle:?}\n{}", + self.report() + ); + self + } + + fn report(&self) -> String { + let mut out = String::new(); + let _ = write!( + out, + "exit: {:?}\n--- stdout\n{}--- stderr\n{}", + self.code, + self.out(), + self.stderr + ); + out + } +}