mod harness;
use harness::{BareRemote, MAGIC, OVERHEAD, TestRepo};
const PASSWORD: &[u8] = b"correct horse battery staple\n";
const DOTENV: &[u8] = b"DATABASE_URL=postgres://user:hunter2@localhost/app\n";
#[test]
fn the_six_step_acceptance_scenario_passes_end_to_end() {
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(".env", DOTENV);
repo.write_file("README.md", b"# ordinary project\n");
repo.commit_all("a secret and a dotenv");
let remote = BareRemote::new();
repo.push_to(&remote, "main");
for (path, plaintext) in [("secrets/password.txt", PASSWORD), (".env", DOTENV)] {
let stored = remote.blob_bytes("main", path);
assert!(
stored.starts_with(b"\0GITXCRYPT\0"),
"{path} did not arrive at the remote encrypted"
);
assert_eq!(
stored.len(),
plaintext.len() + 38,
"{path}: the remote's blob is not header plus content"
);
assert!(
!remote.object_exists_for(plaintext),
"{path}: the plaintext itself is an object in the remote"
);
}
for path in [".git-xcrypt", ".gitattributes", "README.md"] {
let stored = remote.blob_bytes("main", path);
assert!(
!stored.starts_with(b"\0GITXCRYPT\0"),
"{path} must stay readable in the remote: without it a clone cannot \
bootstrap, and the criteria say so explicitly"
);
}
let key = std::fs::read(repo.path().join(".git/git-xcrypt/keys/default"))
.expect("the repository key must be on disk");
assert!(
!remote.object_exists_for(&key),
"the key file reached the remote's object database"
);
let carried = TestRepo::init(); let key_file = carried.path().join("carried.key");
repo.xcrypt_ok(["export-key", &key_file.to_string_lossy()]);
let clone = remote.clone_to();
assert_eq!(
clone.worktree_bytes("secrets/password.txt")[..11],
*b"\0GITXCRYPT\0",
"before `unlock` a clone must show ciphertext, not plaintext"
);
clone.xcrypt_ok(["unlock", &key_file.to_string_lossy()]);
clone.assert_worktree_eq("secrets/password.txt", PASSWORD);
clone.assert_worktree_eq(".env", DOTENV);
clone.assert_worktree_eq("README.md", b"# ordinary project\n");
clone.assert_status_clean();
clone.git_ok(["add", "-A"]);
clone.assert_status_clean();
}
#[test]
fn the_life_of_a_secret_against_a_remote_keeps_its_content_and_its_determinism() {
const FIRST: &[u8] = b"api_key = one\nregion = eu\n";
const SECOND: &[u8] = b"api_key = two\nregion = eu\n";
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("secrets/\n");
repo.xcrypt_ok(["sync"]);
let remote = BareRemote::new();
repo.write_file("secrets/db.env", FIRST);
repo.write_file("README.md", b"# ordinary project\n");
repo.commit_all("declare a secret");
repo.push_to(&remote, "main");
repo.assert_status_clean();
let first_blob = remote.blob_bytes("main", "secrets/db.env");
assert!(
first_blob.starts_with(MAGIC),
"the first push put the secret in the remote in the clear"
);
assert_eq!(
first_blob.len(),
OVERHEAD + FIRST.len(),
"the remote's blob is not the plaintext plus the frozen header"
);
assert!(
!remote.object_exists_for(FIRST),
"the plaintext itself is an object in the remote"
);
repo.assert_worktree_eq("secrets/db.env", FIRST);
assert!(
!repo.blob_is_encrypted("README.md"),
"an undeclared file must stay readable"
);
repo.write_file("secrets/db.env", SECOND);
repo.commit_all("rotate the key");
repo.push_to(&remote, "main");
repo.assert_status_clean();
let second_blob = remote.blob_bytes("main", "secrets/db.env");
assert!(second_blob.starts_with(MAGIC));
assert_ne!(
second_blob, first_blob,
"an edited secret stored the same bytes, so the remote holds the old one"
);
assert!(
!remote.object_exists_for(SECOND),
"the edited plaintext is an object in the remote"
);
repo.write_file("secrets/db.env", FIRST);
repo.commit_all("put it back");
repo.push_to(&remote, "main");
repo.assert_status_clean();
assert_eq!(
remote.blob_bytes("main", "secrets/db.env"),
first_blob,
"the same content encrypted to different bytes the second time round: \
encryption is not deterministic, so git would report every unchanged \
secret as modified"
);
repo.recheckout("secrets/db.env");
repo.assert_worktree_eq("secrets/db.env", FIRST);
repo.assert_status_clean();
let clone = remote.clone_to();
let seen = clone.worktree_bytes("secrets/db.env");
assert!(seen.starts_with(MAGIC));
assert!(
!seen.windows(FIRST.len()).any(|window| window == FIRST),
"a clone without the key shows the secret"
);
}
#[test]
fn a_clone_without_the_key_reports_it_readably_rather_than_panicking() {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("secrets/\n");
repo.write_file("secrets/password.txt", PASSWORD);
repo.commit_all("secret");
let remote = BareRemote::new();
repo.push_to(&remote, "main");
let clone = remote.clone_to();
let seen = clone.worktree_bytes("secrets/password.txt");
assert!(seen.starts_with(b"\0GITXCRYPT\0"));
assert!(
!seen.windows(PASSWORD.len()).any(|w| w == PASSWORD),
"the plaintext is visible in a clone that holds no key"
);
let output = clone.xcrypt(["unlock"]);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(
output.status.code(),
Some(3),
"the frozen table gives a missing key its own code:\n{stderr}"
);
assert!(
!stderr.contains("panicked"),
"a missing key must not be a panic:\n{stderr}"
);
assert!(
stderr.contains("key"),
"the message must at least name what is missing:\n{stderr}"
);
assert!(
stderr.contains("unlock") || stderr.contains("export-key"),
"the message must point at the command that fixes it, or it is a \
diagnosis with no cure:\n{stderr}"
);
}
#[test]
fn declaring_everything_still_leaves_a_clone_able_to_read_its_own_setup() {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("*\n");
repo.xcrypt_ok(["sync"]);
repo.write_file("secrets/password.txt", PASSWORD);
repo.write_file("README.md", b"# ordinary project\n");
repo.write_file("sub/.gitattributes", b"*.md diff\n");
repo.commit_all("everything is a secret");
let remote = BareRemote::new();
repo.push_to(&remote, "main");
for path in [".git-xcrypt", ".gitattributes", "sub/.gitattributes"] {
let stored = remote.blob_bytes("main", path);
assert!(
!stored.starts_with(MAGIC),
"{path} was encrypted, so a clone cannot read the setup it needs in \
order to decrypt anything — including this file"
);
}
for path in ["secrets/password.txt", "README.md"] {
assert!(
remote.blob_bytes("main", path).starts_with(MAGIC),
"{path} is declared by `*` and reached the remote in the clear"
);
}
let carried = TestRepo::init();
let key_file = carried.path().join("carried.key");
repo.xcrypt_ok(["export-key", &key_file.to_string_lossy()]);
let clone = remote.clone_to();
assert_eq!(
clone.worktree_bytes(".gitattributes"),
repo.worktree_bytes(".gitattributes"),
"the clone's attributes file is not the one the repository wrote"
);
clone.xcrypt_ok(["unlock", &key_file.to_string_lossy()]);
clone.assert_worktree_eq("secrets/password.txt", PASSWORD);
clone.assert_worktree_eq("README.md", b"# ordinary project\n");
clone.assert_status_clean();
}