#![allow(dead_code)]
use std::ffi::{OsStr, OsString};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use tempfile::TempDir;
const BIN: &str = env!("CARGO_BIN_EXE_git-xcrypt");
pub struct TestRepo {
_dir: TempDir,
path: PathBuf,
env: Vec<(OsString, Option<OsString>)>,
}
const PIN_ON_CLONE: [&str; 4] = ["-c", "core.autocrlf=false", "-c", "core.eol=lf"];
fn pin_line_endings(repo: &TestRepo) {
repo.git_ok(["config", "core.autocrlf", "false"]);
repo.git_ok(["config", "core.eol", "lf"]);
}
impl TestRepo {
pub fn init() -> Self {
Self::init_with(&[])
}
pub fn init_sha256() -> Self {
Self::init_with(&["--object-format=sha256"])
}
pub fn init_with(extra: &[&str]) -> Self {
require_git();
let dir = TempDir::new().expect("could not create a temporary directory");
let path = dir.path().to_path_buf();
let repo = Self {
_dir: dir,
path,
env: Vec::new(),
};
let mut args = vec!["init", "-q", "-b", "main"];
args.extend_from_slice(extra);
repo.git_ok(args);
repo.git_ok(["config", "user.name", "git-xcrypt tests"]);
repo.git_ok(["config", "user.email", "tests@git-xcrypt.invalid"]);
pin_line_endings(&repo);
repo
}
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub fn with_home(mut self, home: &Path) -> Self {
fs::create_dir_all(home).expect("could not create the home directory");
self.env
.push(("HOME".into(), Some(home.as_os_str().to_owned())));
self.env
.push(("USERPROFILE".into(), Some(home.as_os_str().to_owned())));
self.env.push(("XDG_CONFIG_HOME".into(), None));
self.env.push(("HOMEDRIVE".into(), None));
self.env.push(("HOMEPATH".into(), None));
self
}
fn with_environment<'c>(&self, command: &'c mut Command) -> &'c mut Command {
for (name, value) in &self.env {
match value {
Some(value) => command.env(name, value),
None => command.env_remove(name),
};
}
command
}
pub fn xcrypt<I, S>(&self, args: I) -> Output
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.with_environment(Command::new(BIN).current_dir(&self.path).args(args))
.output()
.expect("could not run git-xcrypt")
}
pub fn xcrypt_with_stdin<I, S>(&self, args: I, input: &[u8]) -> Output
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let mut child = self
.with_environment(Command::new(BIN).current_dir(&self.path).args(args))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("could not run git-xcrypt");
child
.stdin
.take()
.expect("git-xcrypt stdin was not captured")
.write_all(input)
.expect("could not write to git-xcrypt stdin");
child
.wait_with_output()
.expect("could not collect git-xcrypt output")
}
pub fn xcrypt_ok<I, S>(&self, args: I) -> Output
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let output = self.xcrypt(args);
assert!(
output.status.success(),
"git-xcrypt failed with {:?}\nstderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
output
}
pub fn init_xcrypt(&self) {
self.xcrypt_ok(["init"]);
}
pub fn write_xcrypt_config(&self, contents: &str) {
self.write_file(".git-xcrypt", contents.as_bytes());
}
pub fn break_filter(&self) {
let missing = self.path.join("no-such-binary");
self.git_ok([
"config",
"filter.git-xcrypt.process",
&format!("'{}' process", missing.display()),
]);
}
pub fn check_attr(&self, attribute: &str, relative_path: &str) -> String {
let output = self.git_ok(["check-attr", attribute, "--", relative_path]);
let text = String::from_utf8(output.stdout).expect("git printed non-UTF-8 attributes");
text.rsplit(": ")
.next()
.expect("check-attr always prints a value")
.trim()
.to_string()
}
pub fn write_file(&self, relative_path: &str, contents: &[u8]) {
let target = self.path.join(relative_path);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).expect("could not create parent directories");
}
fs::write(&target, contents).expect("could not write the file");
}
pub fn commit_all(&self, message: &str) {
self.git_ok(["add", "-A"]);
self.git_ok(["commit", "-q", "-m", message]);
}
pub fn blob_bytes(&self, relative_path: &str) -> Vec<u8> {
let output = self.git_ok(["cat-file", "blob", &format!("HEAD:{relative_path}")]);
output.stdout
}
pub fn worktree_bytes(&self, relative_path: &str) -> Vec<u8> {
fs::read(self.path.join(relative_path)).expect("could not read the working tree file")
}
pub fn object_exists_for(&self, contents: &[u8]) -> bool {
let hashed = self.git_with_stdin(["hash-object", "-t", "blob", "--stdin"], contents);
assert!(
hashed.status.success(),
"git hash-object failed: {}",
String::from_utf8_lossy(&hashed.stderr)
);
let hash = String::from_utf8(hashed.stdout).expect("git printed a non-UTF-8 hash");
self.git(["cat-file", "-e", &format!("{}^{{blob}}", hash.trim())])
.status
.success()
}
pub fn push_to(&self, remote: &BareRemote, branch: &str) {
self.git_ok(["push", "-q", &remote.url(), &format!("{branch}:{branch}")]);
}
pub fn clone_without_filter(&self) -> Self {
let dir = TempDir::new().expect("could not create a temporary directory");
let path = dir.path().join("clone");
let output = Command::new("git")
.args(PIN_ON_CLONE)
.arg("clone")
.arg("-q")
.arg(&self.path)
.arg(&path)
.output()
.expect("could not run git clone");
assert!(
output.status.success(),
"git clone failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let clone = Self {
_dir: dir,
path,
env: self.env.clone(),
};
clone.git_ok(["config", "user.name", "git-xcrypt tests"]);
clone.git_ok(["config", "user.email", "tests@git-xcrypt.invalid"]);
pin_line_endings(&clone);
clone
}
pub fn clone_shallow(&self) -> Self {
let dir = TempDir::new().expect("could not create a temporary directory");
let path = dir.path().join("clone");
let output = Command::new("git")
.args(PIN_ON_CLONE)
.arg("clone")
.arg("-q")
.arg("--depth")
.arg("1")
.arg(format!("file://{}", self.path.display()))
.arg(&path)
.output()
.expect("could not run git clone");
assert!(
output.status.success(),
"git clone --depth 1 failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let clone = Self {
_dir: dir,
path,
env: self.env.clone(),
};
clone.git_ok(["config", "user.name", "git-xcrypt tests"]);
clone.git_ok(["config", "user.email", "tests@git-xcrypt.invalid"]);
pin_line_endings(&clone);
clone
}
pub fn add_worktree(&self, name: &str) -> Self {
let dir = TempDir::new().expect("could not create a temporary directory");
let path = dir.path().join(name);
self.git_ok(["worktree", "add", "-q", "-b", name, &path.to_string_lossy()]);
Self {
_dir: dir,
path,
env: self.env.clone(),
}
}
pub fn git<I, S>(&self, args: I) -> Output
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
self.with_environment(Command::new("git").current_dir(&self.path).args(args))
.output()
.expect("could not run git")
}
pub fn git_with_stdin<I, S>(&self, args: I, input: &[u8]) -> Output
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let mut child = self
.with_environment(Command::new("git").current_dir(&self.path).args(args))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("could not run git");
child
.stdin
.take()
.expect("git stdin was not captured")
.write_all(input)
.expect("could not write to git stdin");
child
.wait_with_output()
.expect("could not collect git output")
}
pub fn git_ok<I, S>(&self, args: I) -> Output
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let output = self.git(args);
assert!(
output.status.success(),
"git command failed with {:?}\nstderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
output
}
pub fn assert_status_clean(&self) {
let output = self.git_ok(["status", "--porcelain"]);
assert!(
output.stdout.is_empty(),
"expected a clean working tree, git reported:\n{}",
String::from_utf8_lossy(&output.stdout)
);
}
pub fn assert_blob_differs_from_worktree(&self, relative_path: &str) {
let blob = self.blob_bytes(relative_path);
let worktree = self.worktree_bytes(relative_path);
assert_ne!(
blob, worktree,
"{relative_path}: the blob is identical to the working tree file, \
so the filter never ran"
);
}
pub fn assert_worktree_eq(&self, relative_path: &str, expected: &[u8]) {
assert_bytes_eq(&self.worktree_bytes(relative_path), expected, relative_path);
}
pub fn assert_blob_eq(&self, relative_path: &str, expected: &[u8]) {
assert_bytes_eq(&self.blob_bytes(relative_path), expected, relative_path);
}
pub fn assert_not_staged(&self, relative_path: &str) {
let output = self.git_ok(["ls-files", "--stage", "--", relative_path]);
assert!(
output.stdout.is_empty(),
"{relative_path} reached the index although the filter failed"
);
}
pub fn set_config(&self, key: &str, value: &str) {
self.git_ok(["config", key, value]);
}
pub fn set_eol_config(&self, autocrlf: &str, eol: &str) {
self.set_config("core.autocrlf", autocrlf);
self.set_config("core.eol", eol);
}
pub fn init_xcrypt_with(&self, key: &SharedKey) {
self.xcrypt_ok(["unlock", "--key-only", &key.as_arg()]);
self.init_xcrypt();
}
pub fn recheckout(&self, relative_path: &str) {
fs::remove_file(self.path.join(relative_path)).expect("could not remove the file");
self.git_ok(["checkout", "--", relative_path]);
}
pub fn blob_is_encrypted(&self, relative_path: &str) -> bool {
self.blob_bytes(relative_path).starts_with(MAGIC)
}
pub fn blob_records_normalisation(&self, relative_path: &str) -> bool {
let blob = self.blob_bytes(relative_path);
assert!(
blob.starts_with(MAGIC),
"{relative_path} is not encrypted, so it records no verdict"
);
blob[13] & 1 == 1
}
pub fn pull_from(&self, remote: &BareRemote, branch: &str) {
self.git_ok(["pull", "-q", "--ff-only", &remote.url(), branch]);
}
}
pub const MAGIC: &[u8] = b"\0GITXCRYPT\0";
pub const OVERHEAD: usize = 38;
pub struct SharedKey {
_dir: TempDir,
path: PathBuf,
}
impl SharedKey {
pub fn minted() -> Self {
let dir = TempDir::new().expect("could not create a temporary directory");
let path = dir.path().join("shared.key");
let source = TestRepo::init();
source.init_xcrypt();
source.xcrypt_ok(["export-key", &path.to_string_lossy()]);
Self { _dir: dir, path }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn as_arg(&self) -> String {
self.path.to_string_lossy().into_owned()
}
}
fn assert_bytes_eq(actual: &[u8], expected: &[u8], label: &str) {
if actual == expected {
return;
}
let divergence = actual
.iter()
.zip(expected)
.position(|(a, b)| a != b)
.map_or_else(
|| {
format!(
"one is a prefix of the other at offset {}",
actual.len().min(expected.len())
)
},
|offset| format!("first difference at offset {offset}"),
);
panic!(
"{label}: content mismatch — {} bytes vs {} expected, {divergence}",
actual.len(),
expected.len()
);
}
pub struct BareRemote {
_dir: TempDir,
path: PathBuf,
}
impl BareRemote {
pub fn new() -> Self {
require_git();
let dir = TempDir::new().expect("could not create a temporary directory");
let path = dir.path().join("remote.git");
let output = Command::new("git")
.args(["init", "-q", "--bare", "-b", "main"])
.arg(&path)
.output()
.expect("could not run git init --bare");
assert!(
output.status.success(),
"git init --bare failed: {}",
String::from_utf8_lossy(&output.stderr)
);
Self { _dir: dir, path }
}
pub fn url(&self) -> String {
self.path.display().to_string()
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn xcrypt<I, S>(&self, args: I) -> Output
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
Command::new(BIN)
.current_dir(&self.path)
.args(args)
.output()
.expect("could not run git-xcrypt")
}
pub fn blob_bytes(&self, revision: &str, relative_path: &str) -> Vec<u8> {
let output = Command::new("git")
.arg("-C")
.arg(&self.path)
.args(["cat-file", "blob", &format!("{revision}:{relative_path}")])
.output()
.expect("could not run git cat-file");
assert!(
output.status.success(),
"git cat-file blob {revision}:{relative_path} failed in the remote: {}",
String::from_utf8_lossy(&output.stderr)
);
output.stdout
}
pub fn object_exists_for(&self, contents: &[u8]) -> bool {
let mut child = Command::new("git")
.arg("-C")
.arg(&self.path)
.args(["hash-object", "-t", "blob", "--stdin"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("could not run git hash-object");
child
.stdin
.take()
.expect("stdin was piped")
.write_all(contents)
.expect("could not write to git hash-object");
let hashed = child
.wait_with_output()
.expect("git hash-object never ended");
assert!(
hashed.status.success(),
"git hash-object failed: {}",
String::from_utf8_lossy(&hashed.stderr)
);
let hash = String::from_utf8(hashed.stdout).expect("git printed a non-UTF-8 hash");
Command::new("git")
.arg("-C")
.arg(&self.path)
.args(["cat-file", "-e", &format!("{}^{{blob}}", hash.trim())])
.output()
.expect("could not run git cat-file -e")
.status
.success()
}
pub fn clone_to(&self) -> TestRepo {
let dir = TempDir::new().expect("could not create a temporary directory");
let path = dir.path().join("clone");
let output = Command::new("git")
.args(PIN_ON_CLONE)
.args(["clone", "-q"])
.arg(&self.path)
.arg(&path)
.output()
.expect("could not run git clone");
assert!(
output.status.success(),
"git clone from the bare remote failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let clone = TestRepo {
_dir: dir,
path,
env: Vec::new(),
};
clone.git_ok(["config", "user.name", "git-xcrypt tests"]);
clone.git_ok(["config", "user.email", "tests@git-xcrypt.invalid"]);
pin_line_endings(&clone);
clone
}
}
fn require_git() {
let available = Command::new("git")
.arg("--version")
.output()
.is_ok_and(|output| output.status.success());
assert!(
available,
"git was not found on PATH; the integration tests cannot run without it"
);
}