mod harness;
use harness::{BareRemote, SharedKey, TestRepo};
const CLEAN: i32 = 0;
const CONFIG: i32 = 2;
const EXPOSED: i32 = 5;
const UNDETERMINED: i32 = 6;
const SECRET: &[u8] = b"hunter2\n";
fn expect(label: &str, output: &std::process::Output, wanted: i32) {
let report = String::from_utf8_lossy(&output.stdout);
let diagnostics = String::from_utf8_lossy(&output.stderr);
assert!(
!diagnostics.contains("panicked"),
"{label}: `status` panicked instead of answering:\n{diagnostics}"
);
assert_eq!(
output.status.code(),
Some(wanted),
"{label}: `status` exited {:?} where the frozen table says {wanted}\n\
--- report ---\n{report}\n--- diagnostics ---\n{diagnostics}",
output.status.code()
);
}
fn declared(repo: &TestRepo) {
repo.write_xcrypt_config("secrets/\n");
repo.xcrypt_ok(["sync"]);
repo.write_file("secrets/db.env", SECRET);
repo.write_file("README.md", b"# ordinary project\n");
repo.commit_all("a declared secret");
}
#[test]
fn every_unusual_repository_gets_an_answer_and_the_right_one() {
let sha256 = TestRepo::init_sha256();
sha256.init_xcrypt();
declared(&sha256);
expect(
"a SHA-256 repository with nothing wrong with it",
&sha256.xcrypt(["status"]),
CLEAN,
);
let leaky = TestRepo::init_sha256();
leaky.init_xcrypt();
leaky.write_xcrypt_config("# nothing declared yet\n");
leaky.write_file("secrets/db.env", SECRET);
leaky.commit_all("leak");
leaky.write_xcrypt_config("secrets/\n");
let output = leaky.xcrypt(["status"]);
expect(
"a SHA-256 repository holding a plaintext secret in history",
&output,
EXPOSED,
);
assert!(
String::from_utf8_lossy(&output.stdout).contains("leaked in history"),
"a SHA-256 repository holding a plaintext secret in history: the finding \
must name where it is:\n{}",
String::from_utf8_lossy(&output.stdout)
);
let split = TestRepo::init();
split.init_xcrypt();
declared(&split);
split.git_ok(["update-index", "--split-index"]);
expect(
"a repository using git's split index",
&split.xcrypt(["status"]),
UNDETERMINED,
);
let both = TestRepo::init();
both.init_xcrypt();
both.write_xcrypt_config("# nothing declared yet\n");
both.write_file("secrets/db.env", SECRET);
both.commit_all("the leak");
both.write_xcrypt_config("secrets/\n");
both.xcrypt_ok(["sync"]);
both.git_ok(["update-index", "--split-index"]);
let output = both.xcrypt(["status"]);
let text = String::from_utf8_lossy(&output.stdout).into_owned();
expect(
"a split index over a repository that also leaked a secret",
&output,
EXPOSED,
);
assert!(
text.contains("leaked in history"),
"a split index over a repository that also leaked a secret: the finding \
must survive being reported next to a question:\n{text}"
);
assert!(
text.contains("undetermined"),
"a split index over a repository that also leaked a secret: the question \
must still be reported, or the operator cannot tell the scan was \
partial:\n{text}"
);
let sparse = TestRepo::init();
sparse.init_xcrypt();
sparse.write_xcrypt_config("# nothing declared yet\n");
sparse.write_file("secrets/db.env", SECRET);
sparse.write_file("keep/readme.md", b"# in the cone\n");
sparse.commit_all("the leak, before anything encrypted it");
sparse.write_xcrypt_config("secrets/\n");
sparse.xcrypt_ok(["sync"]);
sparse.commit_all("declare it, too late");
sparse.git_ok(["sparse-checkout", "init", "--cone", "--sparse-index"]);
sparse.git_ok(["sparse-checkout", "set", "keep"]);
let listing =
String::from_utf8_lossy(&sparse.git_ok(["ls-files", "--sparse"]).stdout).into_owned();
assert!(
listing.lines().any(|line| line == "secrets/"),
"a sparse index with the declared subtree collapsed: git did not collapse \
it, so this configuration is no longer the one being tested:\n{listing}"
);
assert!(
!sparse.path().join("secrets").exists(),
"a sparse index with the declared subtree collapsed: the directory is \
still in the working tree, so nothing was excluded"
);
let output = sparse.xcrypt(["status"]);
let text = String::from_utf8_lossy(&output.stdout).into_owned();
expect(
"a sparse index with a leak collapsed out of it",
&output,
EXPOSED,
);
assert!(
text.contains("leaked in history") && text.contains("secrets/db.env"),
"a sparse index with a leak collapsed out of it: the finding must name \
the path even though the index no longer spells it:\n{text}"
);
let key = SharedKey::minted();
let source = TestRepo::init();
source.init_xcrypt_with(&key);
declared(&source);
source.write_file("README.md", b"# a second commit\n");
source.commit_all("two");
let shallow = source.clone_shallow();
shallow.xcrypt_ok(["unlock", &key.as_arg()]);
let output = shallow.xcrypt(["status"]);
expect(
"a shallow clone of a healthy repository",
&output,
UNDETERMINED,
);
assert!(
String::from_utf8_lossy(&output.stdout).contains("shallow clone"),
"a shallow clone of a healthy repository: the report must name why it \
could not answer:\n{}",
String::from_utf8_lossy(&output.stdout)
);
let partial = source.clone_without_filter();
partial.xcrypt_ok(["unlock", &key.as_arg()]);
partial.set_config("extensions.partialclone", "origin");
expect(
"a partial clone, where an absent object is a design decision",
&partial.xcrypt(["status"]),
UNDETERMINED,
);
let untouched = TestRepo::init();
untouched.write_file("README.md", b"# a project that never heard of this tool\n");
untouched.commit_all("an ordinary repository");
expect(
"a repository that never ran `git-xcrypt init`",
&untouched.xcrypt(["status"]),
CONFIG,
);
let undeclared = TestRepo::init();
undeclared.init_xcrypt();
declared(&undeclared);
std::fs::remove_file(undeclared.path().join(".git-xcrypt")).expect("removing the declaration");
let output = undeclared.xcrypt(["status"]);
let text = String::from_utf8_lossy(&output.stdout).into_owned();
expect(
"a configured repository whose .git-xcrypt was deleted",
&output,
CONFIG,
);
assert!(
text.contains("history was NOT scanned"),
"a configured repository whose .git-xcrypt was deleted: the run stopped \
before the scan, and silence about that reads as `nothing found`:\n{text}"
);
let unreadable_declaration = TestRepo::init();
unreadable_declaration.init_xcrypt();
declared(&unreadable_declaration);
std::fs::remove_file(unreadable_declaration.path().join(".git-xcrypt"))
.expect("removing the declaration");
std::fs::create_dir(unreadable_declaration.path().join(".git-xcrypt"))
.expect("a directory where the file belongs");
expect(
"a configured repository whose .git-xcrypt cannot be read",
&unreadable_declaration.xcrypt(["status"]),
CONFIG,
);
let both_kinds = TestRepo::init();
both_kinds.init_xcrypt();
both_kinds.write_xcrypt_config("# nothing declared yet\n");
both_kinds.write_file("secrets/db.env", SECRET);
both_kinds.commit_all("the leak");
both_kinds.write_xcrypt_config("secrets/\n");
both_kinds.xcrypt_ok(["sync"]);
both_kinds.git_ok(["config", "filter.git-xcrypt.required", "false"]);
let output = both_kinds.xcrypt(["status"]);
let text = String::from_utf8_lossy(&output.stdout).into_owned();
expect(
"a repository that both leaked a secret and is misconfigured",
&output,
CONFIG,
);
assert!(
text.contains("leaked in history"),
"a repository that both leaked a secret and is misconfigured: the leak \
must survive the configuration verdict, or `2` buys its clarity by \
hiding the finding:\n{text}"
);
assert!(
text.contains("secrets/db.env"),
"a repository that both leaked a secret and is misconfigured: the leaked \
path must still be named:\n{text}"
);
assert!(
text.contains("ROTATE THE SECRET"),
"a repository that both leaked a secret and is misconfigured: the \
rotate-first procedure must still be printed:\n{text}"
);
both_kinds.git_ok(["config", "filter.git-xcrypt.required", "true"]);
expect(
"the same repository once its configuration is fixed",
&both_kinds.xcrypt(["status"]),
EXPOSED,
);
let never_unlocked = source.clone_shallow();
let output = never_unlocked.xcrypt(["status"]);
let text = String::from_utf8_lossy(&output.stdout).into_owned();
expect(
"a shallow clone nobody unlocked, so both answers apply at once",
&output,
CONFIG,
);
assert!(
text.contains("shallow clone"),
"a shallow clone nobody unlocked: what could not be checked must still \
be reported under the configuration verdict:\n{text}"
);
let main = TestRepo::init();
main.init_xcrypt();
declared(&main);
let linked = main.add_worktree("side");
expect(
"a linked worktree of a healthy repository",
&linked.xcrypt(["status"]),
CLEAN,
);
std::fs::create_dir_all(main.path().join(".git/info")).expect("the info directory");
std::fs::write(
main.path().join(".git/info/attributes"),
b"secrets/** -filter\n",
)
.expect("writing info/attributes");
expect(
"a linked worktree whose shared info/attributes turns the filter off",
&linked.xcrypt(["status"]),
CONFIG,
);
std::fs::remove_file(main.path().join(".git/info/attributes")).expect("removing it again");
let parked = TestRepo::init();
parked.init_xcrypt();
parked.write_xcrypt_config("# nothing declared yet\n");
parked.write_file("README.md", b"start\n");
parked.commit_all("start");
parked.git_ok(["checkout", "-q", "-b", "side"]);
parked.write_file("secrets/parked.env", SECRET);
parked.commit_all("the leak, while nothing was declared");
parked.git_ok(["checkout", "-q", "--detach", "side"]);
parked.git_ok(["branch", "-D", "side"]);
let elsewhere = tempfile::TempDir::new().expect("could not create a temporary directory");
let wt = elsewhere.path().join("wt");
parked.git_ok([
"worktree",
"add",
"-q",
"--detach",
&wt.to_string_lossy(),
"main",
]);
std::fs::write(wt.join(".git-xcrypt"), b"secrets/\n").expect("declaring in the linked tree");
let output = std::process::Command::new(env!("CARGO_BIN_EXE_git-xcrypt"))
.current_dir(&wt)
.arg("status")
.output()
.expect("could not run git-xcrypt");
expect(
"a linked worktree of a repository whose leak only the main HEAD names",
&output,
EXPOSED,
);
assert!(
String::from_utf8_lossy(&output.stdout).contains("secrets/parked.env"),
"a linked worktree of a repository whose leak only the main HEAD names: \
the finding must name the path:\n{}",
String::from_utf8_lossy(&output.stdout)
);
let unlistable = TestRepo::init();
unlistable.init_xcrypt();
declared(&unlistable);
std::fs::write(
unlistable.path().join(".git/worktrees"),
b"not a directory\n",
)
.expect("writing over the registrations");
let output = unlistable.xcrypt(["status"]);
expect(
"a repository whose worktree registrations cannot be listed",
&output,
UNDETERMINED,
);
assert!(
String::from_utf8_lossy(&output.stdout).contains("undetermined"),
"a repository whose worktree registrations cannot be listed: the report \
must say the scan was partial:\n{}",
String::from_utf8_lossy(&output.stdout)
);
let one_bad_ref = TestRepo::init();
one_bad_ref.init_xcrypt();
one_bad_ref.write_xcrypt_config("# nothing declared yet\n");
one_bad_ref.write_file("keep.txt", b"ordinary\n");
one_bad_ref.commit_all("main has nothing to hide");
one_bad_ref.git_ok(["checkout", "-q", "-b", "leak"]);
one_bad_ref.write_file("secrets/db.env", SECRET);
one_bad_ref.commit_all("the leak, reachable only from this branch");
one_bad_ref.git_ok(["checkout", "-q", "main"]);
one_bad_ref.write_xcrypt_config("secrets/\n");
one_bad_ref.xcrypt_ok(["sync"]);
one_bad_ref.commit_all("declare");
one_bad_ref.git_ok(["pack-refs", "--all"]);
let packed_refs = one_bad_ref.path().join(".git/packed-refs");
let intact = std::fs::read_to_string(&packed_refs).expect("packed-refs must be readable");
let corrupted: String = intact
.lines()
.map(|line| {
if line.ends_with("refs/heads/leak") {
"this line names a reference and cannot be read\n".to_string()
} else {
format!("{line}\n")
}
})
.collect();
assert_ne!(intact, corrupted, "the leak branch was not in packed-refs");
std::fs::write(&packed_refs, corrupted).expect("writing packed-refs");
expect(
"a repository with one unreadable reference hiding a leak",
&one_bad_ref.xcrypt(["status"]),
UNDETERMINED,
);
std::fs::write(&packed_refs, &intact).expect("restoring packed-refs");
std::fs::write(
one_bad_ref.path().join(".git/refs/heads/notes.txt"),
b"this is not a reference\n",
)
.expect("writing the stray file");
expect(
"a repository with crash residue under refs/",
&one_bad_ref.xcrypt(["status"]),
EXPOSED,
);
let unreadable = TestRepo::init();
unreadable.init_xcrypt();
unreadable.write_xcrypt_config("# nothing declared yet\n");
unreadable.write_file("secrets/db.env", SECRET);
unreadable.commit_all("leak");
unreadable.write_xcrypt_config("secrets/\n");
unreadable.xcrypt_ok(["sync"]);
unreadable.git_ok(["add", "--renormalize", "."]);
unreadable.commit_all("declare");
unreadable.git_ok(["pack-refs", "--all"]);
let packed = unreadable.path().join(".git/packed-refs");
std::fs::remove_file(&packed).expect("removing packed-refs");
std::fs::create_dir(&packed).expect("a directory where the file was");
let output = unreadable.xcrypt(["status"]);
std::fs::remove_dir(&packed).expect("restoring");
expect(
"a repository whose reference store cannot be enumerated",
&output,
UNDETERMINED,
);
let remote = BareRemote::new();
main.push_to(&remote, "main");
expect(
"a bare repository, which has no working tree at all",
&remote.xcrypt(["status"]),
CONFIG,
);
}
#[test]
fn the_help_names_every_exit_code_status_can_actually_produce() {
let help = String::from_utf8(
std::process::Command::new(env!("CARGO_BIN_EXE_git-xcrypt"))
.args(["status", "--help"])
.output()
.expect("could not run git-xcrypt")
.stdout,
)
.expect("help is text");
let carried = SharedKey::minted();
let source = TestRepo::init();
source.init_xcrypt_with(&carried);
declared(&source);
let remote = BareRemote::new();
source.push_to(&remote, "main");
let unconfigured = remote.clone_to();
let leaked = TestRepo::init();
leaked.init_xcrypt();
leaked.write_xcrypt_config("# nothing declared yet\n");
leaked.write_file("secrets/db.env", SECRET);
leaked.commit_all("the leak");
leaked.write_xcrypt_config("secrets/\n");
leaked.xcrypt_ok(["sync"]);
let shallow = source.clone_shallow();
shallow.xcrypt_ok(["unlock", &carried.as_arg()]);
let healthy = TestRepo::init();
healthy.init_xcrypt();
declared(&healthy);
for (label, repo, code) in [
("a clone that enforces nothing", &unconfigured, CONFIG),
("a repository that leaked", &leaked, EXPOSED),
("a shallow clone", &shallow, UNDETERMINED),
("a healthy repository", &healthy, CLEAN),
] {
let output = repo.xcrypt(["status"]);
expect(label, &output, code);
assert!(
help.contains(&format!("`{code}`")) || help.contains(&format!(" {code} ")),
"{label}: `status` exits {code}, and its own help never mentions \
that number:\n{help}"
);
}
}