mod harness;
use std::fs;
use harness::{MAGIC, OVERHEAD, TestRepo};
use tempfile::TempDir;
const SECRET: &[u8] = b"api_key = do-not-commit-me\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 the_key_survives_every_route_out_of_the_repository_that_ordinary_work_takes() {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("secrets/\n");
repo.xcrypt_ok(["sync"]);
repo.write_file("secrets/db.env", SECRET);
repo.commit_all("a secret");
let vault = TempDir::new().expect("could not create a temporary directory");
let exported = vault.path().join("repo.key");
repo.xcrypt_ok(["export-key", &exported.to_string_lossy()]);
let material = key_material(&exported);
let inside = repo.xcrypt(["export-key", "repo.key"]);
assert_eq!(
inside.status.code(),
Some(2),
"export-key wrote into the working tree:\n{}",
String::from_utf8_lossy(&inside.stderr)
);
assert!(
!repo.path().join("repo.key").exists(),
"the refusal still left a key one `git add -A` from a commit"
);
let linked = repo.add_worktree("side");
let into_neighbour = linked.path().join("repo.key");
let sideways = repo.xcrypt(["export-key", &into_neighbour.to_string_lossy()]);
assert_eq!(
sideways.status.code(),
Some(2),
"export-key wrote into another checkout of the same repository:\n{}",
String::from_utf8_lossy(&sideways.stderr)
);
assert!(
!into_neighbour.exists(),
"the key landed in a checkout somebody else is going to commit from"
);
let into_git_dir = repo.path().join(".git/exported.key");
let inwards = repo.xcrypt(["export-key", &into_git_dir.to_string_lossy()]);
assert_eq!(
inwards.status.code(),
Some(2),
"export-key wrote into the git directory:\n{}",
String::from_utf8_lossy(&inwards.stderr)
);
assert!(
!into_git_dir.exists(),
"the refusal still left a key inside the repository's own directory"
);
let other = vault.path().join("some-other-project.key");
fs::write(&other, b"the only copy of another repository's key\n").expect("writing");
let clobber = repo.xcrypt(["export-key", &other.to_string_lossy()]);
assert_eq!(
clobber.status.code(),
Some(2),
"export-key replaced an existing file without being asked:\n{}",
String::from_utf8_lossy(&clobber.stderr)
);
assert_eq!(
fs::read(&other).expect("reading"),
b"the only copy of another repository's key\n",
"the refusal still overwrote the file"
);
assert!(
String::from_utf8_lossy(&clobber.stderr).contains("--force"),
"the refusal must name the flag that means it:\n{}",
String::from_utf8_lossy(&clobber.stderr)
);
repo.xcrypt_ok(["export-key", "--force", &other.to_string_lossy()]);
assert_eq!(
key_material(&other),
material,
"`--force` did not write this repository's key"
);
let raw = fs::read(&exported).expect("the export must exist");
repo.write_file("secrets/notes.txt", &raw);
let annotated = format!(
"# my laptop\n\n {}\n",
String::from_utf8(raw.clone())
.expect("an export is text")
.trim()
.replace('\n', "\n ")
);
repo.write_file("secrets/annotated.txt", annotated.as_bytes());
let adopter = TestRepo::init();
let carried = vault.path().join("annotated.key");
fs::write(&carried, annotated.as_bytes()).expect("writing");
adopter.xcrypt_ok(["unlock", "--key-only", &carried.to_string_lossy()]);
repo.commit_all("a key nobody meant to commit");
for arguments in [
vec!["--no-pager", "log", "-p"],
vec!["--no-pager", "show", "HEAD"],
vec!["--no-pager", "diff", "HEAD~1", "HEAD"],
] {
let output = repo.git(&arguments);
let rendered = String::from_utf8_lossy(&output.stdout).into_owned();
assert!(
!rendered.contains(&material),
"`git {}` printed the repository key",
arguments.join(" ")
);
}
let key_path = repo.path().join(".git/git-xcrypt/keys/default");
for target in [
key_path.clone(),
exported.clone(),
carried.clone(),
repo.path().join("secrets/notes.txt"),
repo.path().join("secrets/annotated.txt"),
] {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_git-xcrypt"))
.current_dir(vault.path())
.arg("diff")
.arg(&target)
.output()
.expect("could not run git-xcrypt");
assert_eq!(
output.status.code(),
Some(2),
"{}: the driver did not refuse:\n{}",
target.display(),
String::from_utf8_lossy(&output.stderr)
);
assert!(
output.stdout.is_empty(),
"a key reached stdout from {}",
target.display()
);
}
for path in ["secrets/notes.txt", "secrets/annotated.txt"] {
assert!(
!repo
.blob_bytes(path)
.windows(material.len())
.any(|window| window == material.as_bytes()),
"{path}: the committed copy of the key is readable in the object \
database"
);
}
let mut attributes = repo.worktree_bytes(".gitattributes");
attributes.extend_from_slice(b"secrets/** -filter\n");
repo.write_file(".gitattributes", &attributes);
repo.recheckout("secrets/notes.txt");
assert!(
!repo.worktree_bytes("secrets/notes.txt").starts_with(&raw),
"the fixture no longer reproduces the shape it exists to catch: smudge \
still ran, so the driver would receive plaintext anyway"
);
for arguments in [
vec!["--no-pager", "log", "-p"],
vec!["--no-pager", "show", "HEAD"],
] {
let output = repo.git(&arguments);
assert!(
!String::from_utf8_lossy(&output.stdout).contains(&material),
"`git {}` printed the repository key out of its own ciphertext",
arguments.join(" ")
);
}
let output = std::process::Command::new(env!("CARGO_BIN_EXE_git-xcrypt"))
.current_dir(repo.path())
.args(["diff", "secrets/notes.txt"])
.output()
.expect("could not run git-xcrypt");
assert_eq!(
output.status.code(),
Some(2),
"the driver did not refuse the ciphertext of a key file:\n{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
output.stdout.is_empty(),
"a key reached stdout through the driver's own decryption"
);
}
#[test]
fn the_filter_puts_nothing_but_content_on_the_channel_git_reads_as_content() {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("# nothing declared yet\n");
repo.write_file("secrets/db.env", SECRET);
repo.commit_all("committed before it was declared");
repo.write_xcrypt_config("secrets/\n");
repo.xcrypt_ok(["sync"]);
let added = repo.git(["add", "--renormalize", "."]);
assert!(
added.status.success(),
"the add failed: {}",
String::from_utf8_lossy(&added.stderr)
);
let stderr = String::from_utf8_lossy(&added.stderr).into_owned();
assert!(
stderr.contains("HEAD already holds"),
"the fixture must actually make the filter speak, or this test cannot \
tell where it spoke:\n{stderr}"
);
repo.git_ok(["commit", "-q", "-m", "declare it"]);
let blob = repo.blob_bytes("secrets/db.env");
assert!(blob.starts_with(MAGIC), "the filter did not encrypt");
assert_eq!(
blob.len(),
OVERHEAD + SECRET.len(),
"the blob is not header plus content: the filter put a diagnostic on \
stdout and git stored it as part of the file"
);
repo.recheckout("secrets/db.env");
repo.assert_worktree_eq("secrets/db.env", SECRET);
repo.assert_status_clean();
}
#[test]
fn export_key_refuses_a_git_directory_that_sits_outside_the_working_tree() {
let elsewhere = TempDir::new().expect("could not create a temporary directory");
let work_tree = elsewhere.path().join("work");
let git_dir = elsewhere.path().join("git-dir");
let init = std::process::Command::new("git")
.args(["init", "-q", "-b", "main"])
.arg(format!("--separate-git-dir={}", git_dir.display()))
.arg(&work_tree)
.output()
.expect("could not run git init");
assert!(
init.status.success(),
"git init --separate-git-dir failed: {}",
String::from_utf8_lossy(&init.stderr)
);
let xcrypt = |arguments: &[&str]| {
std::process::Command::new(env!("CARGO_BIN_EXE_git-xcrypt"))
.current_dir(&work_tree)
.args(arguments)
.output()
.expect("could not run git-xcrypt")
};
let prepared = xcrypt(&["init"]);
assert!(
prepared.status.success(),
"git-xcrypt init failed in a separate-git-dir repository: {}",
String::from_utf8_lossy(&prepared.stderr)
);
assert!(
git_dir.join("git-xcrypt/keys/default").is_file(),
"the premise is wrong: the key is not in the separate git directory"
);
let destination = git_dir.join("exported.key");
let refused = xcrypt(&["export-key", &destination.to_string_lossy()]);
assert_eq!(
refused.status.code(),
Some(2),
"export-key wrote a key next to the one it protects:\n{}",
String::from_utf8_lossy(&refused.stderr)
);
assert!(
!destination.exists(),
"the refusal still left a key inside the git directory"
);
}
#[cfg(unix)]
#[test]
fn an_exported_key_is_readable_only_by_its_owner() {
use std::os::unix::fs::PermissionsExt as _;
let repo = TestRepo::init();
repo.init_xcrypt();
let vault = TempDir::new().expect("could not create a temporary directory");
let destination = vault.path().join("repo.key");
repo.xcrypt_ok(["export-key", &destination.to_string_lossy()]);
let mode = fs::metadata(&destination)
.expect("the export must exist")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}