mod harness;
use std::fs;
use harness::{MAGIC, TestRepo};
use tempfile::TempDir;
const PASSWORD: &[u8] = b"correct horse battery staple\n";
const DOTENV: &[u8] = b"DATABASE_URL=postgres://user:hunter2@localhost/app\n";
const EDITED: &[u8] = b"DATABASE_URL=postgres://user:swordfish@db/app\n";
fn key_material(path: &std::path::Path) -> String {
let text = fs::read_to_string(path).expect("the export must be readable text");
text.lines()
.nth(1)
.expect("an export has a header and a key")
.to_string()
}
#[test]
fn a_repository_opened_worked_in_closed_and_opened_again_gives_every_byte_back() {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("secrets/\n*.env\n");
repo.xcrypt_ok(["sync"]);
repo.write_file("secrets/password.txt", PASSWORD);
repo.write_file("db.env", DOTENV);
repo.write_file("README.md", b"# ordinary project\n");
repo.commit_all("a secret and a dotenv");
repo.assert_status_clean();
let vault = TempDir::new().expect("could not create a temporary directory");
let key_file = vault.path().join("repo.key");
repo.xcrypt_ok(["export-key", &key_file.to_string_lossy()]);
let secret = key_material(&key_file);
let key_path = repo.path().join(".git/git-xcrypt/keys/default");
assert!(key_path.is_file(), "the repository must hold its key");
repo.xcrypt_ok(["unlock"]);
repo.assert_worktree_eq("secrets/password.txt", PASSWORD);
repo.assert_status_clean();
repo.write_file("db.env", EDITED);
for arguments in [vec!["lock"], vec!["lock", "--yes"]] {
let refused = repo.xcrypt(&arguments);
let stderr = String::from_utf8_lossy(&refused.stderr).into_owned();
assert_eq!(
refused.status.code(),
Some(2),
"`{}` did not refuse over an unsaved change:\n{stderr}",
arguments.join(" ")
);
assert!(
stderr.contains("db.env"),
"the refusal must name the file that would be destroyed:\n{stderr}"
);
assert!(
key_path.is_file(),
"`{}` deleted the key even though it refused",
arguments.join(" ")
);
assert_eq!(
repo.worktree_bytes("db.env"),
EDITED,
"the refusal still overwrote the unsaved edit"
);
}
repo.commit_all("rotate the database password");
repo.assert_status_clean();
let locked = repo.xcrypt_ok(["lock", "--yes"]);
let stderr = String::from_utf8_lossy(&locked.stderr).into_owned();
assert!(
locked.stdout.is_empty(),
"`lock` wrote to stdout, which a redirect inside the repository would \
capture into the working tree: {}",
String::from_utf8_lossy(&locked.stdout)
);
assert!(
!stderr.contains(&secret),
"the key itself appeared in `lock`'s own warning"
);
assert!(
stderr.contains("export-key"),
"the warning must point at the command that makes a copy:\n{stderr}"
);
assert!(
!key_path.exists(),
"`lock` reported success and left the key behind"
);
for path in ["secrets/password.txt", "db.env"] {
assert!(
repo.worktree_bytes(path).starts_with(MAGIC),
"{path} was left in the clear behind a command that deleted the key"
);
}
repo.assert_worktree_eq("README.md", b"# ordinary project\n");
repo.assert_status_clean();
let stranded = repo.xcrypt(["unlock"]);
assert_eq!(
stranded.status.code(),
Some(3),
"a locked repository with no key must report the key missing:\n{}",
String::from_utf8_lossy(&stranded.stderr)
);
const FORGOTTEN: &[u8] = b"API_TOKEN=written-into-a-locked-repository\n";
repo.write_file("secrets/forgotten.txt", FORGOTTEN);
let added = repo.git(["add", "secrets/forgotten.txt"]);
assert!(
!added.status.success(),
"`git add` succeeded in a repository whose filter cannot run, so the \
plaintext was committed: {}",
String::from_utf8_lossy(&added.stderr)
);
assert!(
!repo.object_exists_for(FORGOTTEN),
"the object database holds the plaintext of a secret added while the \
repository was locked"
);
repo.assert_not_staged("secrets/forgotten.txt");
fs::remove_file(repo.path().join("secrets/forgotten.txt")).expect("could not remove");
for path in ["secrets/password.txt", "db.env"] {
repo.recheckout(path);
assert!(
repo.worktree_bytes(path).starts_with(MAGIC),
"{path} did not come back as the ciphertext `lock` left here"
);
}
repo.assert_status_clean();
repo.xcrypt_ok(["unlock", &key_file.to_string_lossy()]);
repo.assert_worktree_eq("secrets/password.txt", PASSWORD);
repo.assert_worktree_eq("db.env", EDITED);
repo.assert_worktree_eq("README.md", b"# ordinary project\n");
repo.assert_status_clean();
repo.git_ok(["add", "-A"]);
repo.assert_status_clean();
let old = repo.git_ok(["show", "HEAD~1:db.env"]).stdout;
assert!(old.starts_with(MAGIC), "the old blob is not ours");
}
#[test]
fn lock_refuses_over_every_checkout_and_every_file_it_cannot_account_for() {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("secrets/\n");
repo.xcrypt_ok(["sync"]);
repo.write_file("secrets/db.env", PASSWORD);
repo.commit_all("a secret");
let vault = TempDir::new().expect("could not create a temporary directory");
let key_file = vault.path().join("repo.key");
repo.xcrypt_ok(["export-key", &key_file.to_string_lossy()]);
let key_path = repo.path().join(".git/git-xcrypt/keys/default");
let linked = repo.add_worktree("side");
for (label, from) in [("the main checkout", &repo), ("the linked one", &linked)] {
let refused = from.xcrypt(["lock", "--yes"]);
let complaint = String::from_utf8_lossy(&refused.stderr).into_owned();
assert_eq!(
refused.status.code(),
Some(2),
"`lock` in {label} closed a repository another checkout is reading \
from:\n{complaint}"
);
assert!(
key_path.is_file(),
"`lock` in {label} deleted the shared key anyway"
);
assert!(
complaint.contains("other checkout"),
"`lock` in {label} did not say why:\n{complaint}"
);
}
repo.assert_worktree_eq("secrets/db.env", PASSWORD);
repo.git_ok([
"worktree",
"remove",
"--force",
&linked.path().to_string_lossy(),
]);
let registrations = repo.path().join(".git/worktrees");
if registrations.exists() {
fs::remove_dir_all(®istrations).expect("the registration directory must be removable");
}
fs::write(®istrations, b"not a directory\n").expect("writing over the registrations");
let blinded = repo.xcrypt(["lock", "--yes"]);
let complaint = String::from_utf8_lossy(&blinded.stderr).into_owned();
assert_eq!(
blinded.status.code(),
Some(2),
"`lock` could not tell whether another checkout shares this key and \
deleted it anyway:\n{complaint}"
);
assert!(
key_path.is_file(),
"`lock` deleted the shared key over a question it could not answer"
);
repo.assert_worktree_eq("secrets/db.env", PASSWORD);
fs::remove_file(®istrations).expect("removing the blockage");
const LATE: &[u8] = b"API_TOKEN=saved-while-the-prompt-was-waiting\n";
let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_git-xcrypt"))
.current_dir(repo.path())
.arg("lock")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("could not run git-xcrypt");
let mut stderr = child.stderr.take().expect("stderr was piped");
let seen = read_until(&mut stderr, "Type `yes`");
repo.write_file("secrets/late.env", LATE);
use std::io::Write as _;
child
.stdin
.take()
.expect("stdin was piped")
.write_all(b"yes\n")
.expect("could not answer the prompt");
let mut rest = Vec::new();
std::io::Read::read_to_end(&mut stderr, &mut rest).expect("could not read the rest");
let finished = child.wait_with_output().expect("git-xcrypt never ended");
let complaint = format!("{seen}{}", String::from_utf8_lossy(&rest));
assert_eq!(
finished.status.code(),
Some(2),
"`lock` deleted the key over a secret it had never looked at:\n{complaint}"
);
assert!(
key_path.is_file(),
"the key went while a declared file nobody surveyed lay in the clear"
);
repo.assert_worktree_eq("secrets/late.env", LATE);
repo.assert_worktree_eq("secrets/db.env", PASSWORD);
std::fs::remove_file(repo.path().join("secrets/late.env")).expect("could not remove");
let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_git-xcrypt"))
.current_dir(repo.path())
.arg("lock")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("could not run git-xcrypt");
let mut stderr = child.stderr.take().expect("stderr was piped");
let seen = read_until(&mut stderr, "Type `yes`");
let late = repo.add_worktree("late");
assert_eq!(
std::fs::read(late.path().join("secrets/db.env")).expect("the new checkout has the file"),
PASSWORD,
"the fixture no longer reproduces the shape it exists to catch: the new \
checkout did not come out in the clear"
);
child
.stdin
.take()
.expect("stdin was piped")
.write_all(b"yes\n")
.expect("could not answer the prompt");
let mut rest = Vec::new();
std::io::Read::read_to_end(&mut stderr, &mut rest).expect("could not read the rest");
let finished = child.wait_with_output().expect("git-xcrypt never ended");
let complaint = format!("{seen}{}", String::from_utf8_lossy(&rest));
assert_eq!(
finished.status.code(),
Some(2),
"`lock` deleted the key over a checkout that appeared while it was \
asking:\n{complaint}"
);
assert!(
key_path.is_file(),
"the key went while a whole checkout lay in the clear:\n{complaint}"
);
assert_eq!(
std::fs::read(late.path().join("secrets/db.env")).expect("reading the new checkout"),
PASSWORD,
"the late checkout was left holding plain text behind a finished command"
);
assert!(
complaint.contains("late"),
"the refusal does not name the checkout that stopped it:\n{complaint}"
);
drop(late);
repo.git_ok(["worktree", "prune"]);
repo.xcrypt_ok(["lock", "--yes"]);
assert!(
repo.worktree_bytes("secrets/db.env").starts_with(MAGIC),
"the secret was left in the clear behind a successful lock"
);
assert!(
!key_path.exists(),
"`lock` reported success and kept the key"
);
}
#[test]
fn a_failed_stat_refresh_still_reports_what_unlock_decrypted() {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("secrets/\n");
repo.xcrypt_ok(["sync"]);
repo.write_file("secrets/db.env", DOTENV);
repo.commit_all("a secret");
let vault = TempDir::new().expect("could not create a temporary directory");
let key_file = vault.path().join("repo.key");
repo.xcrypt_ok(["export-key", &key_file.to_string_lossy()]);
repo.xcrypt_ok(["lock", "--yes"]);
let index = repo.path().join(".git/index");
fs::remove_file(&index).expect("removing the index");
fs::create_dir(&index).expect("a directory where the index belongs");
let output = repo.xcrypt(["unlock", &key_file.to_string_lossy()]);
let said = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(
output.status.success(),
"unlock decrypted the tree and then reported failure over the stat \
cache:\n{said}"
);
repo.assert_worktree_eq("secrets/db.env", DOTENV);
assert!(
said.contains("decrypted secrets/db.env"),
"the report of what changed on disk was thrown away:\n{said}"
);
assert!(
said.contains("git add --renormalize"),
"the warning must carry the remedy for the stale stat cache:\n{said}"
);
}
#[test]
fn the_sweep_takes_residue_and_leaves_the_users_tracked_file_alone() {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("secrets/\n");
repo.xcrypt_ok(["sync"]);
repo.write_file("secrets/db.env", DOTENV);
repo.write_file("secrets/build.git-xcrypt-deadbeefcafef00d.tmp", PASSWORD);
repo.commit_all("a secret, and a user file with an unlucky name");
repo.write_file(
"secrets/db.env.git-xcrypt-0123456789abcdef.tmp",
b"leftover plaintext of an interrupted run\n",
);
let output = repo.xcrypt_ok(["lock", "--yes"]);
let said = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(
!repo
.path()
.join("secrets/db.env.git-xcrypt-0123456789abcdef.tmp")
.exists(),
"the residue of an interrupted run was left holding a decrypted secret"
);
assert!(
said.contains("removed secrets/db.env.git-xcrypt-0123456789abcdef.tmp"),
"deleting an untracked file must be announced, not silent:\n{said}"
);
assert!(
repo.worktree_bytes("secrets/build.git-xcrypt-deadbeefcafef00d.tmp")
.starts_with(MAGIC),
"the user's tracked file must survive the sweep, encrypted like any \
other declared file"
);
assert!(
said.contains("tracked, so it was left alone"),
"leaving the tracked look-alike alone must be said out loud:\n{said}"
);
repo.assert_status_clean();
}
#[test]
fn a_bare_stores_sole_worktree_locks_whatever_spelling_bare_uses() {
let store = TestRepo::init_with(&["--bare"]);
store.git_ok(["config", "core.bare", "1"]);
let elsewhere = TempDir::new().expect("could not create a temporary directory");
let work = elsewhere.path().join("work");
store.git_ok(["worktree", "add", "-q", &work.to_string_lossy()]);
let xcrypt = |args: &[&str]| {
std::process::Command::new(env!("CARGO_BIN_EXE_git-xcrypt"))
.current_dir(&work)
.args(args)
.output()
.expect("could not run git-xcrypt")
};
let ok = |args: &[&str]| {
let output = xcrypt(args);
assert!(
output.status.success(),
"`git-xcrypt {}` failed with {:?}:\n{}",
args.join(" "),
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
};
ok(&["init"]);
fs::write(work.join(".git-xcrypt"), b"secrets/\n").expect("declaring");
ok(&["sync"]);
fs::create_dir_all(work.join("secrets")).expect("the secrets directory");
fs::write(work.join("secrets/db.env"), PASSWORD).expect("the secret");
let git = |args: &[&str]| {
let output = std::process::Command::new("git")
.current_dir(&work)
.args(args)
.output()
.expect("could not run git");
assert!(
output.status.success(),
"`git {}` failed:\n{}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
};
git(&["add", "-A"]);
git(&["commit", "-q", "-m", "a secret in the only checkout"]);
ok(&["lock", "--yes"]);
assert!(
fs::read(work.join("secrets/db.env"))
.expect("reading")
.starts_with(MAGIC),
"lock reported success and left the secret in the clear"
);
assert!(
!store.path().join("git-xcrypt/keys/default").exists(),
"lock reported success and kept the key"
);
}
fn read_until(stream: &mut impl std::io::Read, marker: &str) -> String {
let mut seen = String::new();
let mut byte = [0u8; 1];
while !seen.contains(marker) {
match stream.read(&mut byte) {
Ok(0) => panic!("`lock` ended without ever asking for confirmation:\n{seen}"),
Ok(_) => seen.push_str(&String::from_utf8_lossy(&byte)),
Err(err) => panic!("could not read what `lock` was saying ({err}):\n{seen}"),
}
}
seen
}
#[test]
fn residue_whose_name_was_cut_short_refuses_and_an_ordinary_look_alike_does_not() {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("secrets/\n");
repo.xcrypt_ok(["sync"]);
repo.write_file("secrets/db.env", DOTENV);
repo.commit_all("one ordinary secret");
repo.write_file(
"notes.txt.git-xcrypt-0123456789abcdef.tmp",
b"a file that merely looks like ours\n",
);
let cut = "c".repeat(223);
let residue = format!("{cut}.git-xcrypt-0123456789abcdef.tmp");
assert_eq!(
residue.len(),
255,
"the fixture must sit exactly on NAME_MAX, or it is not the shape at issue"
);
repo.write_file(&residue, b"AWS_SECRET=hunter2\n");
let refused = repo.xcrypt(["lock", "--yes"]);
let said = String::from_utf8_lossy(&refused.stderr).into_owned();
assert_eq!(
refused.status.code(),
Some(CONFIG_ERROR),
"lock proceeded over a file it cannot identify:\n{said}"
);
assert!(
said.contains("may hold the decrypted content"),
"the refusal has to say what is at stake, not just that it stopped:\n{said}"
);
assert!(
said.contains("Nothing has been changed"),
"a refusal before any work must say so, or the user cannot tell whether \
the tree was half-rewritten:\n{said}"
);
assert!(
repo.path().join(".git/git-xcrypt/keys/default").exists(),
"the key was deleted by a run that refused"
);
assert_eq!(
repo.worktree_bytes("secrets/db.env"),
DOTENV,
"the tree was rewritten by a run that refused"
);
fs::remove_file(repo.path().join(&residue)).expect("could not remove the residue");
let locked = repo.xcrypt_ok(["lock", "--yes"]);
let said = String::from_utf8_lossy(&locked.stderr).into_owned();
assert!(
repo.worktree_bytes("secrets/db.env").starts_with(MAGIC),
"lock did not finish its job once the unidentifiable file was gone"
);
assert!(
said.contains("nothing declares its target, so it was left alone"),
"the ordinary look-alike must be reported and left, not swept and not \
refused over:\n{said}"
);
assert!(
repo.path()
.join("notes.txt.git-xcrypt-0123456789abcdef.tmp")
.exists(),
"a file that is not ours was deleted"
);
}
const CONFIG_ERROR: i32 = 2;