mod harness;
use std::fs;
use harness::{BareRemote, MAGIC, OVERHEAD, TestRepo};
use tempfile::TempDir;
const CONFIG: i32 = 2;
const FIRST: &[u8] = b"DATABASE_URL=postgres://user:hunter2@localhost/app\n";
const FROM_THE_OTHER_MACHINE: &[u8] = b"DATABASE_URL=postgres://user:swordfish@db/app\n";
#[test]
fn a_clone_becomes_a_working_second_machine_and_its_edits_come_home() {
let first = TestRepo::init();
first.init_xcrypt();
first.write_xcrypt_config("secrets/\n*.env\n");
first.xcrypt_ok(["sync"]);
first.write_file("secrets/db.env", FIRST);
first.write_file("README.md", b"# ordinary project\n");
first.commit_all("declare a secret");
let remote = BareRemote::new();
first.push_to(&remote, "main");
first.assert_status_clean();
let second = remote.clone_to();
let seen = second.worktree_bytes("secrets/db.env");
assert!(
seen.starts_with(MAGIC),
"a clone with no key showed the secret in the clear"
);
assert!(
!seen.windows(FIRST.len()).any(|window| window == FIRST),
"the plaintext is readable in a clone that holds no key"
);
let unfiltered = second.xcrypt(["status"]);
let text = String::from_utf8_lossy(&unfiltered.stdout).into_owned();
assert_eq!(
unfiltered.status.code(),
Some(CONFIG),
"a clone that cannot filter must not pass the gate:\n{text}"
);
assert!(
text.contains("filter.git-xcrypt.process"),
"the report must name the registration that is missing:\n{text}"
);
assert!(
text.contains("stores it in the clear"),
"the report must say what committing from an unconfigured clone does:\n{text}"
);
let courier = TempDir::new().expect("could not create a temporary directory");
let key_file = courier.path().join("repo.key");
first.xcrypt_ok(["export-key", &key_file.to_string_lossy()]);
let mangled = courier.path().join("mangled.key");
fs::write(&mangled, "git-xcrypt-key-v1 a€€€€€\nAAAA\n").expect("writing");
let refused = second.xcrypt(["unlock", "--key-only", &mangled.to_string_lossy()]);
assert_eq!(
refused.status.code(),
Some(4),
"a mangled key file must be a format refusal, not a crash:\n{}",
String::from_utf8_lossy(&refused.stderr)
);
assert!(
!second.path().join(".git/git-xcrypt/keys/default").exists(),
"a refused import installed something anyway"
);
let stranger = TestRepo::init();
stranger.init_xcrypt();
let wrong_key = courier.path().join("some-other-project.key");
stranger.xcrypt_ok(["export-key", &wrong_key.to_string_lossy()]);
let before = second.worktree_bytes("secrets/db.env");
let refused = second.xcrypt(["unlock", &wrong_key.to_string_lossy()]);
let complaint = String::from_utf8_lossy(&refused.stderr).into_owned();
assert_eq!(
refused.status.code(),
Some(4),
"unlocking with a key from another repository was not refused:\n{complaint}"
);
assert!(
complaint.contains("secrets/db.env"),
"the refusal must name the file it stopped on:\n{complaint}"
);
assert_eq!(
second.worktree_bytes("secrets/db.env"),
before,
"a refused unlock still rewrote a file"
);
assert!(
!second.path().join(".git/git-xcrypt/keys/default").exists(),
"a refused unlock installed the wrong key anyway"
);
second.assert_status_clean();
let imported = second.xcrypt(["unlock", "--key-only", &wrong_key.to_string_lossy()]);
let complaint = String::from_utf8_lossy(&imported.stderr).into_owned();
assert_eq!(
imported.status.code(),
Some(4),
"importing a key every header in the tree contradicts was not refused:\n{complaint}"
);
assert!(
complaint.contains("secrets/db.env"),
"the refusal must name the file that is the evidence:\n{complaint}"
);
assert!(
!second.path().join(".git/git-xcrypt/keys/default").exists(),
"a refused import installed the wrong key anyway"
);
let before = second.worktree_bytes("secrets/db.env");
second.xcrypt_ok(["unlock", "--key-only", &key_file.to_string_lossy()]);
assert_eq!(
second.worktree_bytes("secrets/db.env"),
before,
"`--key-only` decrypted the working tree anyway"
);
assert!(
second.path().join(".git/git-xcrypt/keys/default").is_file(),
"`--key-only` did not put the key in place"
);
second.xcrypt_ok(["unlock", &key_file.to_string_lossy()]);
second.assert_worktree_eq("secrets/db.env", FIRST);
second.assert_worktree_eq("README.md", b"# ordinary project\n");
second.assert_status_clean();
let healthy = second.xcrypt(["status"]);
assert_eq!(
healthy.status.code(),
Some(0),
"an unlocked clone must pass the gate:\n{}\n{}",
String::from_utf8_lossy(&healthy.stdout),
String::from_utf8_lossy(&healthy.stderr)
);
second.write_file("secrets/db.env", FROM_THE_OTHER_MACHINE);
second.commit_all("rotate the database password");
second.push_to(&remote, "main");
second.assert_status_clean();
let stored = remote.blob_bytes("main", "secrets/db.env");
assert!(
stored.starts_with(MAGIC),
"the second machine pushed the secret in the clear"
);
assert_eq!(stored.len(), OVERHEAD + FROM_THE_OTHER_MACHINE.len());
assert!(
!remote.object_exists_for(FROM_THE_OTHER_MACHINE),
"the second machine's plaintext is an object in the remote"
);
first.pull_from(&remote, "main");
first.assert_worktree_eq("secrets/db.env", FROM_THE_OTHER_MACHINE);
first.assert_status_clean();
first.git_ok(["add", "--renormalize", "."]);
first.assert_status_clean();
assert_eq!(
first.blob_bytes("secrets/db.env"),
stored,
"the two machines store different bytes for the same secret"
);
second.git_ok(["checkout", "-q", "HEAD~1", "--", "secrets/db.env"]);
second.assert_worktree_eq("secrets/db.env", FIRST);
}
#[test]
fn a_clone_of_a_repository_that_never_committed_its_attributes_still_encrypts() {
let first = TestRepo::init();
first.init_xcrypt();
first.write_xcrypt_config("secrets/\n");
std::fs::write(
first.path().join(".git").join("info").join("exclude"),
b".gitattributes\n",
)
.expect("writing the exclude file");
first.write_file("secrets/db.env", FIRST);
first.commit_all("a secret, with no attributes file behind it");
assert!(first.blob_is_encrypted("secrets/db.env"));
let remote = BareRemote::new();
first.push_to(&remote, "main");
let courier = TempDir::new().expect("could not create a temporary directory");
let key_file = courier.path().join("repo.key");
first.xcrypt_ok(["export-key", &key_file.to_string_lossy()]);
let second = remote.clone_to();
assert!(
!second.path().join(".gitattributes").exists(),
"the fixture is wrong: the clone inherited the attributes file"
);
second.xcrypt_ok(["unlock", &key_file.to_string_lossy()]);
second.assert_worktree_eq("secrets/db.env", FIRST);
second.write_file("secrets/db.env", FROM_THE_OTHER_MACHINE);
second.commit_all("rotate the database password");
second.push_to(&remote, "main");
assert!(
remote
.blob_bytes("main", "secrets/db.env")
.starts_with(MAGIC),
"the clone pushed the secret to the remote in the clear"
);
assert!(
!remote.object_exists_for(FROM_THE_OTHER_MACHINE),
"the plaintext the clone committed is an object in the remote"
);
}
#[test]
fn a_key_travels_from_stdout_to_the_command_line_without_touching_the_disk() {
let first = TestRepo::init();
first.init_xcrypt();
first.write_xcrypt_config("secrets/\n");
first.xcrypt_ok(["sync"]);
first.write_file("secrets/db.env", FIRST);
first.commit_all("a secret");
let remote = BareRemote::new();
first.push_to(&remote, "main");
let exported = first.xcrypt_ok(["export-key", "--stdout"]);
let material = String::from_utf8(exported.stdout).expect("an export is text");
assert!(
material.starts_with("git-xcrypt-key-v1 "),
"stdout must carry the same text the file form writes: {material:?}"
);
assert!(
!String::from_utf8_lossy(&exported.stderr).contains(material.trim()),
"the key was echoed to stderr as well, so a CI log would capture it"
);
let clone = remote.clone_to();
let (header, body) = material
.split_once('\n')
.expect("an export is a header and a key");
let truncated = format!("{header}\n{}=\n", &body.trim()[..body.trim().len() - 3]);
let swapped = {
let stranger = TestRepo::init();
stranger.init_xcrypt();
let other = String::from_utf8(stranger.xcrypt_ok(["export-key", "--stdout"]).stdout)
.expect("an export is text");
let other_body = other
.split_once('\n')
.expect("an export is a header and a key")
.1;
format!("{header}\n{other_body}")
};
for (shape, offered, because) in [
("truncated", &truncated, "base64"),
("swapped", &swapped, "in transit"),
] {
let refused = clone.xcrypt(["unlock", "--key", offered]);
let complaint = String::from_utf8_lossy(&refused.stderr).into_owned();
assert_eq!(
refused.status.code(),
Some(4),
"a {shape} key was accepted:\n{complaint}"
);
assert!(
complaint.contains(because),
"a {shape} key was refused, but by something other than the check that should have caught it — expected {because:?}:\n{complaint}"
);
assert!(
!clone.path().join(".git/git-xcrypt/keys/default").exists(),
"a refused {shape} key was installed anyway"
);
}
let unlocked = clone.xcrypt_ok(["unlock", "--key", &material]);
let said = String::from_utf8_lossy(&unlocked.stderr).into_owned();
clone.assert_worktree_eq("secrets/db.env", FIRST);
clone.assert_status_clean();
assert!(
said.contains("process list") && said.contains("shell"),
"the command did not name what handing it a key on the command line \
costs:\n{said}"
);
assert!(
!said.contains(material.trim()),
"the warning printed the key it was warning about"
);
}