test: cover the store and the exit codes before moving any of it

This commit is contained in:
2026-09-09 12:27:51 +00:00
parent 2563d56595
commit d9439253fd
3 changed files with 568 additions and 0 deletions

262
tests/link_store.rs Normal file
View File

@@ -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}");
}

99
tests/output_io.rs Normal file
View File

@@ -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());
}

207
tests/support/mod.rs Normal file
View File

@@ -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<S: AsRef<OsStr>>(&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::<Vec<_>>(),
String::from_utf8_lossy(&out.stderr)
);
out
}
// ahab, in the repository, with a store nothing else shares
pub fn ahab<S: AsRef<OsStr>>(&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<i32>,
pub stdout: Vec<u8>,
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
}
}