mod harness;
use harness::TestRepo;
const SECRET: &[u8] = b"api_key = do-not-commit-me\n";
fn repo_with_secret() -> TestRepo {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("*.env\n");
repo.write_file("secrets.env", SECRET);
repo.commit_all("add a secret");
repo
}
#[test]
fn a_per_worktree_config_git_has_not_written_yet_does_not_stop_the_filter() {
let repo = repo_with_secret();
repo.git_ok(["config", "extensions.worktreeConfig", "true"]);
assert!(
!repo.path().join(".git/config.worktree").exists(),
"the fixture no longer reproduces the shape it exists to catch: git \
wrote the per-worktree file by itself"
);
repo.write_file("second.env", b"api_key = also-a-secret\n");
let added = repo.git(["add", "-A"]);
assert!(
added.status.success(),
"the filter could not start over a per-worktree config git has not \
written yet, so every git operation in this repository fails: {}",
String::from_utf8_lossy(&added.stderr)
);
repo.commit_all("a secret, with the extension enabled and no file yet");
assert!(
repo.blob_is_encrypted("second.env"),
"the repository kept working but stopped encrypting, which is worse \
than the outage this replaced"
);
std::fs::write(repo.path().join(".git/config.worktree"), b"[unterminated\n")
.expect("could not write the per-worktree config");
repo.write_file("third.env", b"api_key = one-more\n");
let refused = repo.git(["add", "-A"]);
let complaint = String::from_utf8_lossy(&refused.stderr).into_owned();
assert!(
!refused.status.success(),
"`git add` went through over a per-worktree config nothing could parse"
);
assert!(
complaint.contains("config.worktree"),
"the refusal does not name the file nobody could parse:\n{complaint}"
);
}
#[test]
fn a_failing_filter_aborts_the_add() {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("*.env\n");
repo.break_filter();
repo.write_file("secrets.env", SECRET);
let output = repo.git(["add", "secrets.env"]);
assert!(
!output.status.success(),
"git add succeeded although the filter failed — \
the plaintext would have been committed"
);
repo.assert_not_staged("secrets.env");
}
#[test]
fn a_failing_filter_leaves_no_plaintext_object_behind() {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("*.env\n");
repo.break_filter();
repo.write_file("secrets.env", SECRET);
let _ = repo.git(["add", "secrets.env"]);
assert!(
!repo.object_exists_for(SECRET),
"the object database holds the plaintext although the filter failed"
);
}
#[test]
fn a_second_init_never_replaces_the_key() {
let repo = repo_with_secret();
let before = std::fs::read(repo.path().join(".git/git-xcrypt/keys/default"))
.expect("the key must exist");
repo.xcrypt_ok(["init"]);
let after = std::fs::read(repo.path().join(".git/git-xcrypt/keys/default"))
.expect("the key must still exist");
assert_eq!(before, after, "a repeated init replaced the repository key");
repo.assert_status_clean();
}
#[test]
fn deleting_the_declaration_stops_the_commit_instead_of_leaking() {
let repo = repo_with_secret();
std::fs::remove_file(repo.path().join(".git-xcrypt")).expect("could not remove the config");
repo.write_file("another.env", b"api_key = second-secret\n");
let output = repo.git(["add", "another.env"]);
assert!(
!output.status.success(),
"git add succeeded without a declaration, so the plaintext was committed"
);
assert!(
!repo.object_exists_for(b"api_key = second-secret\n"),
"the object database holds the plaintext"
);
repo.assert_not_staged("another.env");
}
#[test]
fn a_dos_end_of_file_marker_is_classified_the_way_git_classifies_it() {
let shapes: [(&str, &[u8]); 4] = [
("a trailing SUB", b"a\r\n\x1a"),
("two trailing SUBs", b"a\r\n\x1a\x1a"),
("a SUB in the middle", b"a\x1ab\r\n"),
("a trailing SUB spent on a control", b"a\x01\r\n\x1a"),
];
for (label, content) in shapes {
let reference = TestRepo::init();
reference.write_file(".gitattributes", b"* text=auto\n");
reference.git_ok(["config", "core.autocrlf", "true"]);
reference.write_file("subject.txt", content);
reference.commit_all("the subject");
let stored = reference.blob_bytes("subject.txt");
let git_called_it_text = stored != content;
let ours = TestRepo::init();
ours.init_xcrypt();
ours.write_xcrypt_config("*.env\n");
ours.write_file("subject.env", content);
ours.commit_all("the subject");
let blob = ours.blob_bytes("subject.env");
assert!(
blob.starts_with(b"\0GITXCRYPT\0"),
"{label}: the filter did not run"
);
let we_called_it_text = blob[13] & 1 == 1;
assert_eq!(
we_called_it_text,
git_called_it_text,
"{label}: git says {}, git-xcrypt says {} — the boundary has moved",
if git_called_it_text { "text" } else { "binary" },
if we_called_it_text { "text" } else { "binary" }
);
assert_eq!(
blob.len(),
38 + stored.len(),
"{label}: the encrypted plaintext is not the plaintext git would store"
);
std::fs::remove_file(ours.path().join("subject.env")).expect("could not remove");
ours.git_ok(["checkout", "--", "subject.env"]);
ours.assert_status_clean();
}
}
#[cfg(target_os = "linux")]
#[test]
fn a_file_name_that_is_not_utf8_survives_the_whole_life_cycle() {
use std::ffi::{OsStr, OsString};
use std::os::unix::ffi::{OsStrExt as _, OsStringExt as _};
const NAME: &[u8] = b"secrets/pa\xffssword.env";
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("secrets/\n");
repo.xcrypt_ok(["sync"]);
let path = repo.path().join(OsStr::from_bytes(NAME));
std::fs::create_dir_all(path.parent().expect("a parent")).expect("could not create secrets/");
std::fs::write(&path, SECRET).expect("ext4 must accept this name");
repo.commit_all("a secret whose name is not text");
let mut spec = OsString::from_vec(b"HEAD:".to_vec());
spec.push(OsStr::from_bytes(NAME));
let blob = repo
.git_ok([OsStr::new("cat-file"), OsStr::new("blob"), &spec])
.stdout;
assert!(
blob.starts_with(b"\0GITXCRYPT\0"),
"the filter judged the file under a name it does not have, and the \
plaintext was committed"
);
assert_eq!(blob.len(), 38 + SECRET.len());
assert_eq!(
std::fs::read(&path).expect("reading"),
SECRET,
"the working tree must still hold the plain text"
);
let status = repo.xcrypt(["status"]);
assert_eq!(
status.status.code(),
Some(0),
"status did not come back clean:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&status.stdout),
String::from_utf8_lossy(&status.stderr)
);
let vault = tempfile::TempDir::new().expect("could not create a temporary directory");
let key = vault.path().join("repo.key");
repo.xcrypt_ok([OsStr::new("export-key"), key.as_os_str()]);
repo.xcrypt_ok(["lock", "--yes"]);
assert!(
std::fs::read(&path)
.expect("reading")
.starts_with(b"\0GITXCRYPT\0"),
"lock left the file in the clear and deleted the key over it"
);
repo.xcrypt_ok([OsStr::new("unlock"), key.as_os_str()]);
assert_eq!(
std::fs::read(&path).expect("reading"),
SECRET,
"unlock did not find the file it had just encrypted"
);
repo.assert_status_clean();
}