mod harness;
use std::fs;
use harness::{MAGIC, OVERHEAD, TestRepo};
use tempfile::TempDir;
const CRLF: &[u8] = b"line one\r\nline two\r\n";
const LF: &[u8] = b"line one\nline two\n";
const BINARY: &[u8] = b"\x00\x90raw\r\nbytes\r\n\x00";
struct Case {
path: &'static str,
written: &'static [u8],
stored: &'static [u8],
normalised: bool,
diff: &'static str,
checked_out: &'static [u8],
}
#[test]
fn every_declared_attribute_reaches_the_header_the_rendered_line_and_the_round_trip() {
let repo = TestRepo::init();
repo.set_eol_config("false", "lf");
repo.init_xcrypt();
repo.write_xcrypt_config(
"secrets/\n\
secrets/always.txt text\n\
secrets/never.bin -text\n\
secrets/store.p12 binary\n\
secrets/auto.txt text=auto\n\
secrets/unix.sh text eol=lf\n\
secrets/dos.ps1 text eol=crlf\n\
!secrets/README.md\n",
);
repo.xcrypt_ok(["sync"]);
let cases = [
Case {
path: "secrets/always.txt",
written: CRLF,
stored: LF,
normalised: true,
diff: "git-xcrypt",
checked_out: LF,
},
Case {
path: "secrets/never.bin",
written: CRLF,
stored: CRLF,
normalised: false,
diff: "git-xcrypt",
checked_out: CRLF,
},
Case {
path: "secrets/store.p12",
written: BINARY,
stored: BINARY,
normalised: false,
diff: "unset",
checked_out: BINARY,
},
Case {
path: "secrets/auto.txt",
written: CRLF,
stored: LF,
normalised: true,
diff: "git-xcrypt",
checked_out: LF,
},
Case {
path: "secrets/keystore.env",
written: BINARY,
stored: BINARY,
normalised: false,
diff: "git-xcrypt",
checked_out: BINARY,
},
Case {
path: "secrets/unix.sh",
written: CRLF,
stored: LF,
normalised: true,
diff: "git-xcrypt",
checked_out: LF,
},
Case {
path: "secrets/dos.ps1",
written: CRLF,
stored: LF,
normalised: true,
diff: "git-xcrypt",
checked_out: CRLF,
},
];
for case in &cases {
repo.write_file(case.path, case.written);
}
repo.write_file("secrets/README.md", b"nothing secret here\n");
repo.commit_all("one file per attribute");
repo.assert_status_clean();
for case in &cases {
let path = case.path;
let blob = repo.blob_bytes(path);
assert!(blob.starts_with(MAGIC), "{path}: the filter did not run");
assert_eq!(
blob.len(),
OVERHEAD + case.stored.len(),
"{path}: the encrypted plaintext is not what the declaration asks \
clean to store"
);
assert_eq!(
repo.blob_records_normalisation(path),
case.normalised,
"{path}: the header records the wrong verdict, so smudge will \
convert a file it must not — or fail to convert one it must"
);
assert_eq!(
repo.check_attr("filter", path),
"git-xcrypt",
"{path}: git would not run the filter for a declared path"
);
assert_eq!(
repo.check_attr("text", path),
"unset",
"{path}: git may convert this ciphertext, which destroys it"
);
assert_eq!(
repo.check_attr("diff", path),
case.diff,
"{path}: the rendered diff attribute is not what the declaration says"
);
repo.recheckout(path);
assert_eq!(
repo.worktree_bytes(path),
case.checked_out,
"{path}: the checkout did not honour the declaration"
);
}
let readable = repo.blob_bytes("secrets/README.md");
assert!(
!readable.starts_with(MAGIC),
"a negated path was encrypted anyway"
);
assert_eq!(readable, b"nothing secret here\n");
assert_eq!(
repo.check_attr("text", "secrets/README.md"),
"unspecified",
"a file stored in the clear must be git's to manage, `-text` included"
);
assert_eq!(
repo.check_attr("diff", "secrets/README.md"),
"unspecified",
"a decrypting diff driver has nothing to do on a plaintext file"
);
repo.assert_status_clean();
repo.git_ok(["add", "-A"]);
repo.assert_status_clean();
}
const REACHED: &[&str] = &[
"secrets/a.txt",
"app/secrets/a.txt",
"a/b/secrets/c/d.txt",
"deep/one.env",
"config.env/inner.txt",
];
const UNTOUCHED: &str = "notsecrets/a.txt";
#[test]
fn a_forgotten_sync_fails_the_gate_and_running_it_reaches_the_whole_subtree() {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("secrets/\n");
repo.xcrypt_ok(["sync"]);
let checked = repo.xcrypt(["sync", "--check"]);
assert_eq!(
checked.status.code(),
Some(0),
"a section that was just written must satisfy its own check:\n{}",
String::from_utf8_lossy(&checked.stderr)
);
let before = repo.worktree_bytes(".gitattributes");
repo.write_xcrypt_config("secrets/\n*.env\n");
let stale = repo.xcrypt(["sync", "--check"]);
assert_eq!(
stale.status.code(),
Some(2),
"a stale section passed the gate, so CI would never notice:\n{}",
String::from_utf8_lossy(&stale.stderr)
);
let code = stale.status.code().expect("sync --check reports a code");
let help = String::from_utf8(repo.xcrypt(["sync", "--help"]).stdout).expect("help is text");
assert!(
help.contains(&format!("`{code}`")),
"`sync --check` exits {code} on a stale section and its own help never \
mentions that number:\n{help}"
);
let seen = repo.xcrypt(["status"]);
assert_eq!(
seen.status.code(),
Some(2),
"`sync --check` calls this section stale and `status` does not — one \
tool, one state, two verdicts, and a CI job gets whichever it ran:\n{}",
String::from_utf8_lossy(&seen.stdout)
);
let said = String::from_utf8_lossy(&seen.stdout).into_owned();
assert!(
said.contains("no longer matches") && said.contains("sync"),
"the gap has to name the file and the one command that settles it:\n{said}"
);
assert_eq!(
repo.worktree_bytes(".gitattributes"),
before,
"`--check` wrote to the working tree, which is the one thing a check \
must never do"
);
repo.write_file("deep/one.env", b"api_key = deep\n");
repo.commit_all("a secret under the undeclared-in-gitattributes pattern");
assert!(
repo.blob_is_encrypted("deep/one.env"),
"the filter reads `.git-xcrypt` directly, so this must not wait for sync"
);
repo.xcrypt_ok(["sync"]);
assert_eq!(
repo.xcrypt(["sync", "--check"]).status.code(),
Some(0),
"`sync` left a section its own check still calls stale"
);
for path in REACHED {
assert_eq!(
repo.check_attr("filter", path),
"git-xcrypt",
"{path}: the filter encrypts this path, so git must run it here"
);
assert_eq!(
repo.check_attr("text", path),
"unset",
"{path}: the filter encrypts this path and the rendered line does \
not reach it, so git may convert its ciphertext and destroy it"
);
}
assert_eq!(
repo.check_attr("text", UNTOUCHED),
"unspecified",
"{UNTOUCHED}: the line reaches past what the filter encrypts, so a file \
stored in the clear is carrying `-text`"
);
for path in REACHED {
repo.write_file(path, b"api_key = nested\n");
}
repo.write_file(UNTOUCHED, b"nothing secret here\n");
repo.commit_all("one secret per depth");
repo.assert_status_clean();
for path in REACHED {
assert!(
repo.blob_is_encrypted(path),
"{path}: a declared path was stored in the clear"
);
repo.recheckout(path);
repo.assert_worktree_eq(path, b"api_key = nested\n");
}
assert!(
!repo.blob_is_encrypted(UNTOUCHED),
"{UNTOUCHED}: an undeclared path was encrypted"
);
repo.assert_status_clean();
}
#[test]
fn a_section_that_cannot_be_compared_is_never_reported_as_current() {
for (label, break_it) in [
(
"a merge that kept both sides",
&(|section: &[u8]| [section, section].concat()) as &dyn Fn(&[u8]) -> Vec<u8>,
),
(
"an opening marker with no closing one",
&|section: &[u8]| {
let text = String::from_utf8(section.to_vec()).expect("the section is text");
text.lines()
.filter(|line| line.trim_end() != "# <<< git-xcrypt <<<")
.fold(String::new(), |mut out, line| {
out.push_str(line);
out.push('\n');
out
})
.into_bytes()
},
),
] {
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");
assert_eq!(
repo.xcrypt(["status"]).status.code(),
Some(0),
"{label}: the repository was not clean before the section was broken"
);
let section = repo.worktree_bytes(".gitattributes");
let broken = break_it(§ion);
assert_ne!(
broken, section,
"{label}: the section came back unchanged, so this run asks nothing"
);
repo.write_file(".gitattributes", &broken);
let checked = repo.xcrypt(["sync", "--check"]);
assert_eq!(
checked.status.code(),
Some(2),
"{label}: `sync --check` stopped calling this a state conflict, so \
this test no longer asks anything"
);
let seen = repo.xcrypt(["status"]);
let said = String::from_utf8_lossy(&seen.stdout).into_owned();
assert_ne!(
seen.status.code(),
Some(0),
"{label}: `status` passed the gate over a section it could not \
compare, while every command that writes the file refuses over \
it:\n{said}"
);
assert!(
said.contains("undetermined"),
"{label}: a check that could not run has to be said out loud, or \
the report reads as a clean bill of health it did not earn:\n{said}"
);
assert!(
said.contains("git-xcrypt"),
"{label}: the reason has to name the section, or a reader has \
nothing to act on:\n{said}"
);
assert!(
!said.contains("stores it in the clear"),
"{label}: a section this build cannot read is not evidence that \
anything was stored in the clear:\n{said}"
);
}
}
#[test]
fn the_filter_names_a_stale_section_without_refusing_over_it() {
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.write_file("notes.txt", b"an ordinary file\n");
repo.commit_all("a secret and an ordinary file");
std::thread::sleep(std::time::Duration::from_millis(1100));
repo.git_ok(["update-index", "--refresh"]);
repo.write_file("secrets/db.env", b"api_key = second\n");
let quiet = repo.git(["add", "-A"]);
assert!(
quiet.status.success(),
"a healthy repository was refused:\n{}",
String::from_utf8_lossy(&quiet.stderr)
);
assert!(
!String::from_utf8_lossy(&quiet.stderr).contains("no longer matches"),
"a current section was called stale:\n{}",
String::from_utf8_lossy(&quiet.stderr)
);
repo.commit_all("second");
repo.write_xcrypt_config("secrets/\nvault/\n");
repo.git_ok(["add", ".git-xcrypt"]);
repo.git_ok(["commit", "-q", "-m", "declare more"]);
std::thread::sleep(std::time::Duration::from_millis(1100));
repo.git_ok(["update-index", "--refresh"]);
repo.write_file("notes.txt", b"an ordinary file, edited\n");
let unencrypted = repo.git(["add", "notes.txt"]);
assert!(
!String::from_utf8_lossy(&unencrypted.stderr).contains("no longer matches"),
"an operation that encrypted nothing was charged for the answer:\n{}",
String::from_utf8_lossy(&unencrypted.stderr)
);
repo.write_file("secrets/db.env", b"api_key = third\n");
let warned = repo.git(["add", "-A"]);
let said = String::from_utf8_lossy(&warned.stderr).into_owned();
assert!(
warned.status.success(),
"a stale section refused a `git add`, which it must never do:\n{said}"
);
assert_eq!(
said.matches("no longer matches").count(),
1,
"the warning must be said once per operation, not once per file:\n{said}"
);
assert!(
said.contains("git-xcrypt sync"),
"the warning must name the command that settles it:\n{said}"
);
assert!(
repo.blob_is_encrypted("secrets/db.env"),
"the warning came instead of the encryption rather than beside it"
);
repo.xcrypt_ok(["sync"]);
repo.commit_all("sync");
std::thread::sleep(std::time::Duration::from_millis(1100));
repo.git_ok(["update-index", "--refresh"]);
repo.write_file("secrets/db.env", b"api_key = fourth\n");
let settled = repo.git(["add", "-A"]);
assert!(
!String::from_utf8_lossy(&settled.stderr).contains("no longer matches"),
"the warning survived the command that was supposed to settle it:\n{}",
String::from_utf8_lossy(&settled.stderr)
);
}
const SECRET: &[u8] = b"AWS_SECRET=hunter2\n";
const OTHER_SPELLINGS: &[&str] = &[
"SEcrets/db.txt",
"top.ENV",
"app/nested/Deploy.Env",
];
#[test]
fn a_case_spelled_attributes_file_is_read_exactly_where_git_reads_it() {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("secrets/\n");
repo.xcrypt_ok(["sync"]);
repo.write_file("secrets/.GITATTRIBUTES", b"* text\n");
let honoured = repo.check_attr("text", "secrets/db.env") == "set";
repo.write_file("secrets/store.p12", &two_megabytes());
let add = repo.git(["add", "secrets/store.p12"]);
assert_eq!(
add.status.success(),
!honoured,
"git {} the case-spelled attributes file, and the gate read the stack \
differently:\n{}",
if honoured { "honours" } else { "ignores" },
String::from_utf8_lossy(&add.stderr)
);
}
#[test]
fn a_pattern_reaches_every_ascii_spelling_of_a_name_and_the_rendered_line_keeps_up() {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config(
"secrets/\n\
*.env\n\
!secrets/README.md\n\
\u{142}\u{105}ka/\n",
);
repo.xcrypt_ok(["sync", "--ignorecase"]);
for path in OTHER_SPELLINGS {
repo.write_file(path, SECRET);
}
repo.write_file("SEcrets/README.MD", b"nothing secret here\n");
repo.write_file("notsecrets/a.txt", b"nothing secret here\n");
repo.write_file(
"\u{141}\u{104}KA/a.env.txt",
b"outside the ASCII boundary\n",
);
repo.commit_all("one file per spelling");
repo.assert_status_clean();
for path in OTHER_SPELLINGS {
assert!(
repo.blob_is_encrypted(path),
"{path}: a declared path spelled in another case was stored in the \
clear, which is the whole failure open decision 13 closed"
);
assert!(
!repo.object_exists_for(SECRET),
"{path}: the plaintext of a declared secret reached the object database"
);
}
assert!(
!repo.blob_is_encrypted("SEcrets/README.MD"),
"a negation stopped applying because the file is spelled differently"
);
assert!(
!repo.blob_is_encrypted("notsecrets/a.txt"),
"the folded pattern reached past what it declares"
);
assert!(
!repo.blob_is_encrypted("\u{141}\u{104}KA/a.env.txt"),
"folding reached beyond ASCII, which git does not do and \
`.gitattributes` cannot express"
);
for ignore_case in ["false", "true"] {
repo.set_config("core.ignorecase", ignore_case);
for path in OTHER_SPELLINGS {
assert_eq!(
repo.check_attr("filter", path),
"git-xcrypt",
"{path}: with core.ignorecase={ignore_case} git would not run the \
filter for a path the filter encrypts"
);
assert_eq!(
repo.check_attr("text", path),
"unset",
"{path}: with core.ignorecase={ignore_case} the rendered line does \
not reach a path the filter encrypts, so git may convert its \
ciphertext and destroy it"
);
assert_eq!(
repo.check_attr("diff", path),
"git-xcrypt",
"{path}: with core.ignorecase={ignore_case} `git diff` would show \
ciphertext for a path the filter encrypts"
);
}
assert_eq!(
repo.check_attr("text", "SEcrets/README.MD"),
"unspecified",
"with core.ignorecase={ignore_case} a file stored in the clear by a \
negation is carrying `-text`"
);
assert_eq!(
repo.check_attr("text", "notsecrets/a.txt"),
"unspecified",
"with core.ignorecase={ignore_case} the rendered line reaches past \
what the filter encrypts"
);
assert_eq!(
repo.check_attr("text", "\u{141}\u{104}KA/a.env.txt"),
"unspecified",
"with core.ignorecase={ignore_case} the rendered line folds beyond \
ASCII while the filter does not, so a file stored in the clear is \
carrying `-text`"
);
}
repo.set_config("core.ignorecase", "false");
for path in OTHER_SPELLINGS {
repo.recheckout(path);
repo.assert_worktree_eq(path, SECRET);
}
repo.assert_status_clean();
}
#[test]
fn the_default_section_is_one_line_and_needs_no_sync() {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("*.env\n");
let section = String::from_utf8(repo.worktree_bytes(".gitattributes")).expect("text");
assert!(
section.contains("* filter=git-xcrypt") && section.contains("* -text diff=git-xcrypt"),
"the default section is not the two global lines:\n{section}"
);
assert!(
!section.contains("*.env"),
"the default section names a declared pattern, so it can go stale:\n{section}"
);
repo.set_config("core.ignorecase", "false");
repo.write_file("db.env", SECRET);
repo.write_file("TOP.ENV", SECRET);
repo.write_file("notes.txt", b"an ordinary file\n");
repo.commit_all("a declared path, another spelling of it, and neither");
for path in ["db.env", "TOP.ENV"] {
assert!(
repo.blob_is_encrypted(path),
"{path}: a declared path was stored in the clear"
);
assert_eq!(
repo.check_attr("text", path),
"unset",
"{path}: the ciphertext is not protected from git's CRLF conversion"
);
assert_eq!(
repo.check_attr("diff", path),
"git-xcrypt",
"{path}: `git diff` would show ciphertext"
);
}
assert!(
!repo.object_exists_for(SECRET),
"the plaintext of a declared secret reached the object database"
);
assert!(
!repo.blob_is_encrypted("notes.txt"),
"an undeclared file was encrypted"
);
assert_eq!(
repo.check_attr("text", "notes.txt"),
"unset",
"git still normalises an undeclared file, so this test no longer \
describes what the global section costs"
);
assert_eq!(
repo.check_attr("diff", "notes.txt"),
"git-xcrypt",
"the diff driver stopped covering undeclared files, so the measured \
cost above no longer applies"
);
repo.recheckout("db.env");
repo.recheckout("TOP.ENV");
repo.assert_worktree_eq("db.env", SECRET);
repo.assert_worktree_eq("TOP.ENV", SECRET);
repo.assert_worktree_eq("notes.txt", b"an ordinary file\n");
repo.assert_status_clean();
}
fn two_megabytes() -> Vec<u8> {
(0..2 * 1024 * 1024u32)
.map(|index| u8::try_from(index % 251).expect("a byte"))
.collect()
}
const DANGEROUS: [(&[u8], &str); 4] = [
(b"secrets/** text\n", "set"),
(b"secrets/** !text\nsecrets/** eol=lf\n", "unspecified"),
(b"secrets/** text=input\n", "input"),
(b"secrets/** !text\nsecrets/** crlf\n", "unspecified"),
];
#[test]
fn a_foreign_text_line_below_the_managed_section_is_refused_before_the_file_is_lost() {
for (foreign, expected_text) in DANGEROUS {
let secret = two_megabytes();
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("secrets/\n");
repo.xcrypt_ok(["sync"]);
repo.write_file("secrets/store.p12", &secret);
repo.commit_all("a secret, while the attributes are still right");
assert_eq!(
repo.blob_bytes("secrets/store.p12").len(),
OVERHEAD + secret.len(),
"the fixture did not store an intact ciphertext to begin with"
);
let mut attributes = repo.worktree_bytes(".gitattributes");
attributes.extend_from_slice(foreign);
repo.write_file(".gitattributes", &attributes);
assert_eq!(
repo.check_attr("text", "secrets/store.p12"),
expected_text,
"the fixture no longer reproduces the shape it exists to catch"
);
let mut modified = secret.clone();
modified.extend_from_slice(b"one more line\r\n");
repo.write_file("secrets/store.p12", &modified);
let add = repo.git(["add", "-A"]);
let complaint = String::from_utf8_lossy(&add.stderr).into_owned();
assert!(
!add.status.success(),
"`git add` stored a ciphertext git is about to convert, and exited \
{:?}:\n{complaint}",
add.status.code()
);
assert!(
complaint.contains("secrets/store.p12"),
"the refusal must name the path it is about:\n{complaint}"
);
assert!(
complaint.contains(".gitattributes:"),
"the refusal must name the file and line that outrank the managed \
section, or nobody can find it:\n{complaint}"
);
assert!(
complaint.contains("-text"),
"the refusal must name the attribute that prevents it:\n{complaint}"
);
assert_eq!(
repo.blob_bytes("secrets/store.p12").len(),
OVERHEAD + secret.len(),
"the committed ciphertext was damaged after all"
);
std::fs::remove_file(repo.path().join("secrets/store.p12")).expect("could not remove");
repo.git_ok(["checkout", "--", "secrets/store.p12"]);
assert_eq!(
repo.worktree_bytes("secrets/store.p12"),
secret,
"the file did not survive the round trip the refusal exists to protect"
);
let output = repo.xcrypt(["status"]);
let text = String::from_utf8_lossy(&output.stdout).into_owned();
assert_eq!(
output.status.code(),
Some(CONFIG_ERROR),
"a repository whose ciphertext git converts must fail the gate as a \
configuration problem:\n{text}"
);
assert!(
text.contains("secrets/store.p12"),
"the report must name the path whose ciphertext git converts:\n{text}"
);
assert!(
text.contains("-text"),
"the report must name the attribute that prevents it:\n{text}"
);
}
}
fn stage_attributes_copy(repo: &TestRepo, contents: &[u8]) {
let hashed = repo.git_with_stdin(["hash-object", "-w", "-t", "blob", "--stdin"], contents);
assert!(
hashed.status.success(),
"git hash-object failed: {}",
String::from_utf8_lossy(&hashed.stderr)
);
let id = String::from_utf8(hashed.stdout)
.expect("git prints a hash")
.trim()
.to_string();
repo.git_ok([
"update-index",
"--add",
"--cacheinfo",
&format!("100644,{id},.gitattributes"),
]);
}
fn check_attr_cached(repo: &TestRepo, attribute: &str, path: &str) -> String {
let output = repo.git(["check-attr", "--cached", attribute, "--", path]);
String::from_utf8(output.stdout)
.expect("check-attr prints text")
.rsplit(": ")
.next()
.expect("check-attr always prints a value")
.trim()
.to_string()
}
#[test]
fn a_dangerous_line_kept_only_in_the_index_still_refuses_after_the_file_is_deleted() {
let (repo, secret, blob) = repository_with_one_intact_secret();
let mut dangerous = repo.worktree_bytes(".gitattributes");
dangerous.extend_from_slice(b"secrets/** text\n");
stage_attributes_copy(&repo, &dangerous);
fs::remove_file(repo.path().join(".gitattributes")).expect("could not remove the file");
assert_eq!(
check_attr_cached(&repo, "text", "secrets/store.p12"),
"set",
"the fixture no longer stages the shape it exists to catch"
);
let mut modified = secret.clone();
modified.extend_from_slice(b"one more line\r\n");
repo.write_file("secrets/store.p12", &modified);
let add = repo.git(["add", "secrets/store.p12"]);
let complaint = String::from_utf8_lossy(&add.stderr).into_owned();
assert!(
!add.status.success(),
"`git add` stored a ciphertext git converts via the index copy of a \
deleted `.gitattributes`, and exited {:?}:\n{complaint}",
add.status.code()
);
assert!(
complaint.contains(".gitattributes:") && complaint.contains("secrets/** text"),
"the refusal must name the staged line even though the file is gone \
from the working tree:\n{complaint}"
);
assert_eq!(
repo.blob_bytes("secrets/store.p12"),
blob,
"the committed ciphertext was damaged after all"
);
}
#[test]
fn a_deleted_or_outranked_index_copy_never_provokes_a_refusal() {
{
let (repo, secret, _blob) = repository_with_one_intact_secret();
fs::remove_file(repo.path().join(".gitattributes")).expect("could not remove the file");
let mut modified = secret.clone();
modified.extend_from_slice(b"one more line\r\n");
repo.write_file("secrets/store.p12", &modified);
let add = repo.git(["add", "secrets/store.p12"]);
assert!(
add.status.success(),
"a healthy index copy provoked a refusal:\n{}",
String::from_utf8_lossy(&add.stderr)
);
let staged = repo.git_ok(["cat-file", "blob", ":secrets/store.p12"]);
assert_eq!(
staged.stdout.len(),
OVERHEAD + modified.len(),
"the ciphertext was converted after all, so the fallback did not \
reproduce what git reads"
);
}
{
let (repo, secret, _blob) = repository_with_one_intact_secret();
let mut dangerous = repo.worktree_bytes(".gitattributes");
dangerous.extend_from_slice(b"secrets/** text\n");
stage_attributes_copy(&repo, &dangerous);
assert_eq!(
check_attr_cached(&repo, "text", "secrets/store.p12"),
"set",
"the fixture no longer stages the shape whose precedence it checks"
);
assert_eq!(
repo.check_attr("text", "secrets/store.p12"),
"unset",
"git no longer lets the working-tree file outrank the index copy, \
so this fixture proves the wrong thing"
);
let mut modified = secret.clone();
modified.extend_from_slice(b"one more line\r\n");
repo.write_file("secrets/store.p12", &modified);
let add = repo.git(["add", "secrets/store.p12"]);
assert!(
add.status.success(),
"the staged copy outranked the working-tree file in the refusal, \
which git does not let it do on check-in:\n{}",
String::from_utf8_lossy(&add.stderr)
);
}
}
const GLOBAL_SOURCES: [&str; 3] = ["xdg-default", "tilde", "absolute"];
#[test]
fn a_text_line_in_the_users_global_attributes_file_is_refused_like_any_other() {
for source in GLOBAL_SOURCES {
let home = TempDir::new().expect("could not create a home directory");
let secret = two_megabytes();
let repo = TestRepo::init().with_home(home.path());
repo.init_xcrypt();
repo.write_xcrypt_config("secrets/\n");
repo.xcrypt_ok(["sync"]);
repo.write_xcrypt_config("secrets/\nvault/\n");
repo.write_file("vault/deploy.sh", &secret);
repo.commit_all("a secret, while nothing yet converts it");
assert_eq!(
repo.blob_bytes("vault/deploy.sh").len(),
OVERHEAD + secret.len(),
"the fixture did not store an intact ciphertext to begin with"
);
let line = b"vault/** text\n";
let global = match source {
"xdg-default" => {
let dir = home.path().join(".config").join("git");
fs::create_dir_all(&dir).expect("could not create the XDG directory");
dir.join("attributes")
}
"tilde" => {
repo.git_ok(["config", "--global", "core.attributesFile", "~/attrs"]);
home.path().join("attrs")
}
_ => {
let path = home.path().join("attrs");
repo.git_ok([
"config",
"--global",
"core.attributesFile",
&path.to_string_lossy(),
]);
path
}
};
fs::write(&global, line).expect("could not write the global attributes file");
assert_eq!(
repo.check_attr("text", "vault/deploy.sh"),
"set",
"[{source}] the fixture no longer reproduces the shape it exists to catch"
);
let mut modified = secret.clone();
modified.extend_from_slice(b"one more line\r\n");
repo.write_file("vault/deploy.sh", &modified);
let add = repo.git(["add", "-A"]);
assert!(
!add.status.success(),
"[{source}] `git add` stored a ciphertext git is about to convert, \
and exited {:?}:\n{}",
add.status.code(),
String::from_utf8_lossy(&add.stderr)
);
let complaint = String::from_utf8_lossy(&add.stderr).into_owned();
let named = global.file_name().expect("the global file has a name");
assert!(
complaint.contains(&named.to_string_lossy().into_owned()),
"[{source}] the refusal does not name the file that caused it \
({}):\n{complaint}",
global.display()
);
assert!(
complaint.contains("vault/** text"),
"[{source}] the refusal does not quote the winning line:\n{complaint}"
);
let status = repo.xcrypt(["status"]);
let text = String::from_utf8_lossy(&status.stdout).into_owned();
assert_eq!(
status.status.code(),
Some(CONFIG_ERROR),
"[{source}] the gate passed a repository whose ciphertext git \
converts:\n{text}"
);
assert!(
text.contains("vault/deploy.sh"),
"[{source}] the report must name the path git converts:\n{text}"
);
}
}
#[test]
fn a_linked_worktrees_own_config_is_the_one_that_counts() {
let home = TempDir::new().expect("could not create a home directory");
let repo = TestRepo::init().with_home(home.path());
repo.init_xcrypt();
repo.write_xcrypt_config("secrets/\n");
repo.xcrypt_ok(["sync"]);
repo.write_xcrypt_config("secrets/\nvault/\n");
let secret = two_megabytes();
repo.write_file("vault/deploy.sh", &secret);
repo.commit_all("a secret, while nothing yet converts it");
let side = repo.add_worktree("side");
let attributes = home.path().join("side-attrs");
fs::write(&attributes, b"vault/** text\n").expect("could not write the attributes file");
side.git_ok(["config", "extensions.worktreeConfig", "true"]);
side.git_ok([
"config",
"--worktree",
"core.attributesFile",
&attributes.to_string_lossy(),
]);
assert_eq!(
side.check_attr("text", "vault/deploy.sh"),
"set",
"the fixture no longer reproduces the shape it exists to catch"
);
assert_eq!(
repo.check_attr("text", "vault/deploy.sh"),
"unspecified",
"the per-worktree setting leaked into the main checkout, so this proves \
nothing about which file was read"
);
let mut modified = secret.clone();
modified.extend_from_slice(b"one more line\r\n");
side.write_file("vault/deploy.sh", &modified);
let added = side.git(["add", "-A"]);
assert!(
!added.status.success(),
"`git add` in the linked worktree stored a ciphertext git is about to \
convert, and exited {:?}:\n{}",
added.status.code(),
String::from_utf8_lossy(&added.stderr)
);
repo.write_file("vault/deploy.sh", &modified);
let untouched = repo.git(["add", "-A"]);
assert!(
untouched.status.success(),
"the main checkout was refused over a setting that belongs to another \
one:\n{}",
String::from_utf8_lossy(&untouched.stderr)
);
}
#[test]
fn a_global_attributes_file_that_is_harmless_never_provokes_a_refusal() {
let home = TempDir::new().expect("could not create a home directory");
let dir = home.path().join(".config").join("git");
fs::create_dir_all(&dir).expect("could not create the XDG directory");
fs::write(dir.join("attributes"), b"* text=auto\n*.md text\n")
.expect("could not write attributes");
let repo = TestRepo::init().with_home(home.path());
repo.init_xcrypt();
repo.write_xcrypt_config("secrets/\nvault/\n");
let secret = two_megabytes();
repo.write_file("vault/deploy.sh", &secret);
repo.commit_all("an ordinary machine with an ordinary global file");
assert_eq!(
repo.blob_bytes("vault/deploy.sh").len(),
OVERHEAD + secret.len(),
"a harmless global attributes file changed the stored ciphertext"
);
repo.recheckout("vault/deploy.sh");
repo.assert_worktree_eq("vault/deploy.sh", &secret);
let status = repo.xcrypt(["status"]);
assert_eq!(
status.status.code(),
Some(0),
"a harmless global attributes file failed the gate:\n{}",
String::from_utf8_lossy(&status.stdout)
);
}
#[test]
fn the_shapes_git_leaves_alone_never_provoke_a_refusal() {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("secrets/\nvault/ binary\n");
repo.xcrypt_ok(["sync"]);
let mut attributes = repo.worktree_bytes(".gitattributes");
attributes.extend_from_slice(
b"# `text=auto` winning outright on a declared path: git keeps binary\n\
# detection, and the leading NUL of our magic answers it\n\
vault/** text=auto\n\
# a foreign driver on a path of its own is the ordinary case\n\
*.psd filter=lfs\n\
# one restating what the managed section already says\n\
secrets/** -text\n\
# and the shape that matters most: a bare `eol=`, the very assignment\n\
# that is fatal over a ciphertext, on a path stored in the clear,\n\
# where it is git doing exactly its job. `notes/` is in no declaration,\n\
# so a gate that asked about every file instead of every *encrypted*\n\
# file would refuse here and take the repository down with it.\n\
notes/** eol=lf\n",
);
repo.write_file(".gitattributes", &attributes);
repo.set_eol_config("true", "");
let secret = two_megabytes();
repo.write_file("secrets/store.p12", &secret);
repo.write_file("vault/keys.bin", BINARY);
repo.write_file("notes/readme.txt", CRLF);
repo.commit_all("ordinary attribute lines everywhere");
assert_eq!(
repo.check_attr("text", "vault/keys.bin"),
"auto",
"`text=auto` no longer wins on the declared path it is here to cover"
);
assert_eq!(
repo.check_attr("eol", "notes/readme.txt"),
"lf",
"the bare `eol=` no longer reaches the path stored in the clear"
);
assert_eq!(
repo.check_attr("text", "notes/readme.txt"),
"unspecified",
"something now sets `text` on that path, so it is no longer the shape \
that would be fatal over a ciphertext"
);
assert!(
repo.blob_is_encrypted("secrets/store.p12"),
"a healthy repository stopped encrypting"
);
assert!(
repo.blob_is_encrypted("vault/keys.bin"),
"the path `text=auto` reaches stopped being encrypted"
);
assert_eq!(
repo.blob_bytes("vault/keys.bin").len(),
OVERHEAD + BINARY.len(),
"the ciphertext under `text=auto` was converted after all"
);
assert_eq!(
repo.blob_bytes("secrets/store.p12").len(),
OVERHEAD + secret.len(),
"the ciphertext was converted after all, so one of these lines is not \
as harmless as it looks"
);
repo.recheckout("secrets/store.p12");
repo.assert_worktree_eq("secrets/store.p12", &secret);
repo.assert_status_clean();
repo.set_eol_config("input", "");
repo.write_file("secrets/store.p12", &secret);
repo.git_ok(["add", "-A"]);
repo.assert_status_clean();
}
const CONFIG_ERROR: i32 = 2;
const SPACED: &[u8] = b"DATABASE_URL=postgres://user:hunter2@localhost/app\n";
fn key_material(path: &std::path::Path) -> String {
let text = std::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 a_name_with_a_space_is_declared_in_quotes_and_lives_the_whole_cycle() {
let repo = TestRepo::init();
repo.set_eol_config("false", "lf");
repo.init_xcrypt();
repo.write_xcrypt_config("my\\ secrets/\n");
let refused = repo.xcrypt(["sync"]);
let complaint = String::from_utf8_lossy(&refused.stderr).into_owned();
assert_eq!(
refused.status.code(),
Some(CONFIG_ERROR),
"the old spelling was accepted, so a declared path silently stopped \
being encrypted:\n{complaint}"
);
assert!(
complaint.contains("2026-08-05") && complaint.contains("\"my secrets/\""),
"the refusal must say that the syntax changed and how the line reads \
now, or it is indistinguishable from a typo:\n{complaint}"
);
repo.write_file("my secrets/db.env", SPACED);
let added = repo.git(["add", "-A"]);
assert!(
!added.status.success(),
"`git add` went through on an unparsable declaration:\n{}",
String::from_utf8_lossy(&added.stderr)
);
assert!(
!repo.object_exists_for(SPACED),
"the plaintext of a declared secret reached the object database while \
the declaration could not be read"
);
repo.write_xcrypt_config("\"my secrets/*.sh text eol=lf\"\n");
let wrapped = repo.xcrypt(["sync"]);
let complaint = String::from_utf8_lossy(&wrapped.stderr).into_owned();
assert_eq!(
wrapped.status.code(),
Some(CONFIG_ERROR),
"an old line quoted whole was accepted as a pattern, so it matches \
nothing and the path it named is stored in the clear:\n{complaint}"
);
assert!(
complaint.contains("\"my secrets/*.sh\" text eol=lf"),
"the refusal must show where the quotes belong:\n{complaint}"
);
repo.write_xcrypt_config(
"\"my secrets/\"\n\
\"my secrets/*.sh\" text eol=lf\n\
!\"my secrets/README.md\"\n\
\"!weird.env\"\n",
);
repo.xcrypt_ok(["sync"]);
repo.write_file("my secrets/deploy.sh", CRLF);
repo.write_file("app/my secrets/nested.env", SPACED);
repo.write_file("my secrets/README.md", b"nothing secret here\n");
repo.write_file("!weird.env", SPACED);
repo.commit_all("a secret under a name with a space");
repo.assert_status_clean();
for path in [
"my secrets/db.env",
"app/my secrets/nested.env",
"!weird.env",
] {
assert!(
repo.blob_is_encrypted(path),
"{path}: a declared path was stored in the clear"
);
assert_eq!(
repo.check_attr("filter", path),
"git-xcrypt",
"{path}: git would not run the filter for a declared path"
);
assert_eq!(
repo.check_attr("text", path),
"unset",
"{path}: the rendered line does not reach a path the filter \
encrypts, so git may convert its ciphertext and destroy it"
);
}
assert!(repo.blob_records_normalisation("my secrets/deploy.sh"));
assert_eq!(
repo.blob_bytes("my secrets/deploy.sh").len(),
OVERHEAD + LF.len(),
"the CRLF was not normalised, so the attributes were lost behind the \
quotes"
);
assert!(
!repo.blob_is_encrypted("my secrets/README.md"),
"a negated path was encrypted anyway"
);
assert_eq!(
repo.check_attr("text", "my secrets/README.md"),
"unspecified"
);
let vault = TempDir::new().expect("could not create a temporary directory");
let key_file = vault.path().join("repo.key");
repo.xcrypt_ok(["export-key", &key_file.to_string_lossy()]);
let secret = key_material(&key_file);
let locked = repo.xcrypt_ok(["lock", "--yes"]);
assert!(
!String::from_utf8_lossy(&locked.stderr).contains(&secret),
"the key itself appeared in `lock`'s own warning"
);
assert!(
repo.worktree_bytes("my secrets/db.env").starts_with(MAGIC),
"a path with a space in it was left in the clear behind a command that \
deleted the key"
);
repo.xcrypt_ok(["unlock", &key_file.to_string_lossy()]);
repo.assert_worktree_eq("my secrets/db.env", SPACED);
repo.assert_worktree_eq("my secrets/deploy.sh", LF);
repo.assert_worktree_eq("app/my secrets/nested.env", SPACED);
repo.assert_worktree_eq("!weird.env", SPACED);
repo.assert_status_clean();
}
#[test]
#[cfg(unix)]
fn a_name_that_ends_in_a_space_is_expressible_at_last() {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("\"secrets /\"\n");
repo.xcrypt_ok(["sync"]);
repo.write_file("secrets /db.env", SPACED);
repo.write_file("secrets/db.env", b"nothing secret here\n");
repo.commit_all("a secret under a name that ends in a space");
repo.assert_status_clean();
assert!(
repo.blob_is_encrypted("secrets /db.env"),
"the trailing space was lost, so the declared path is stored in the clear"
);
assert!(
!repo.blob_is_encrypted("secrets/db.env"),
"the pattern reached past the name it declares"
);
assert_eq!(
repo.check_attr("text", "secrets /db.env"),
"unset",
"the rendered line does not reach the path the filter encrypts, so git \
may convert its ciphertext and destroy it"
);
assert_eq!(
repo.check_attr("text", "secrets/db.env"),
"unspecified",
"the rendered line reaches past what the filter encrypts, so a file \
stored in the clear is carrying `-text`"
);
repo.recheckout("secrets /db.env");
repo.assert_worktree_eq("secrets /db.env", SPACED);
repo.assert_status_clean();
}
fn eight_kilobytes() -> Vec<u8> {
(0..8 * 1024u32)
.map(|index| u8::try_from(index % 251).expect("a byte"))
.collect()
}
fn lone_line_feeds(bytes: &[u8]) -> usize {
bytes
.iter()
.enumerate()
.filter(|&(index, &byte)| byte == b'\n' && (index == 0 || bytes[index - 1] != b'\r'))
.count()
}
fn expand_lone_line_feeds(bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len());
for (index, &byte) in bytes.iter().enumerate() {
if byte == b'\n' && (index == 0 || bytes[index - 1] != b'\r') {
out.push(b'\r');
}
out.push(byte);
}
out
}
fn repository_with_one_intact_secret() -> (TestRepo, Vec<u8>, Vec<u8>) {
let repo = TestRepo::init();
let vault = TempDir::new().expect("could not create a temporary directory");
let key_file = vault.path().join("fixed.key");
fs::write(
&key_file,
"git-xcrypt-key-v1 8f72bb7e5c3ab4dd\n\
ERERERERERERERERERERERERERERERERERERERERERE=\n",
)
.expect("could not write the fixed key");
repo.xcrypt_ok(["unlock", "--key-only", &key_file.to_string_lossy()]);
repo.init_xcrypt();
repo.write_xcrypt_config("secrets/\n");
repo.xcrypt_ok(["sync"]);
let secret = eight_kilobytes();
repo.write_file("secrets/store.p12", &secret);
repo.commit_all("a secret, while the attributes are still right");
let blob = repo.blob_bytes("secrets/store.p12");
assert_eq!(
blob.len(),
OVERHEAD + secret.len(),
"the fixture did not store an intact ciphertext to begin with"
);
assert!(
lone_line_feeds(&blob) > 0,
"the fixture's ciphertext holds no lone `LF`, so git's check-out \
conversion would have nothing to expand and the shape under test \
cannot occur"
);
(repo, secret, blob)
}
fn append_attribute_line(repo: &TestRepo, line: &[u8]) {
let mut attributes = repo.worktree_bytes(".gitattributes");
attributes.extend_from_slice(line);
repo.write_file(".gitattributes", &attributes);
}
fn failing_checkout(repo: &TestRepo, path: &str) -> String {
std::fs::remove_file(repo.path().join(path)).expect("could not remove the file");
let checkout = repo.git(["checkout", "--", path]);
let complaint = String::from_utf8_lossy(&checkout.stderr).into_owned();
assert!(
!checkout.status.success(),
"the checkout succeeded, so the fixture no longer reproduces a failing \
authentication tag:\n{complaint}"
);
complaint
}
fn commit_blob_verbatim(repo: &TestRepo, path: &str, blob: &[u8]) {
let hashed = repo.git_with_stdin(["hash-object", "-w", "-t", "blob", "--stdin"], blob);
assert!(
hashed.status.success(),
"git hash-object failed: {}",
String::from_utf8_lossy(&hashed.stderr)
);
let id = String::from_utf8(hashed.stdout)
.expect("git prints a hash")
.trim()
.to_string();
repo.git_ok([
"update-index",
"--add",
"--cacheinfo",
&format!("100644,{id},{path}"),
]);
repo.git_ok(["commit", "-q", "-m", "a ciphertext nobody can decrypt"]);
}
#[test]
fn a_check_out_git_converted_names_the_line_instead_of_accusing_the_file() {
let (repo, secret, blob) = repository_with_one_intact_secret();
append_attribute_line(&repo, b"secrets/** text\n");
repo.set_eol_config("true", "");
assert_eq!(
repo.check_attr("text", "secrets/store.p12"),
"set",
"the fixture no longer reproduces the shape it exists to catch"
);
let complaint = failing_checkout(&repo, "secrets/store.p12");
assert!(
!complaint.contains("the file has been altered"),
"the checkout still accuses a file that is intact:\n{complaint}"
);
assert!(
complaint.contains("Nothing is lost"),
"the message must say outright that nothing was lost, because the user \
has every reason to believe otherwise:\n{complaint}"
);
assert!(
complaint.contains(".gitattributes:") && complaint.contains("secrets/** text"),
"the message must name the file, the line and the assignment, the same \
way the check-in refusal does:\n{complaint}"
);
assert!(
complaint.contains("git-xcrypt sync"),
"the message must say what to do about it:\n{complaint}"
);
assert_eq!(
repo.blob_bytes("secrets/store.p12"),
blob,
"the blob changed under a checkout, which only reads"
);
let attributes = repo.worktree_bytes(".gitattributes");
let repaired = attributes
.split(|&byte| byte == b'\n')
.filter(|line| line != b"secrets/** text")
.map(<[u8]>::to_vec)
.collect::<Vec<_>>()
.join(&b'\n');
repo.write_file(".gitattributes", &repaired);
assert_eq!(
repo.check_attr("text", "secrets/store.p12"),
"unset",
"the repair did not put the managed `-text` back in charge"
);
repo.git_ok(["checkout", "--", "secrets/store.p12"]);
repo.assert_worktree_eq("secrets/store.p12", &secret);
}
#[test]
fn a_ciphertext_that_really_was_altered_is_still_reported_as_altered() {
{
let (repo, _secret, blob) = repository_with_one_intact_secret();
let mut altered = blob.clone();
altered[OVERHEAD + 5] ^= 0xff;
commit_blob_verbatim(&repo, "secrets/store.p12", &altered);
let complaint = failing_checkout(&repo, "secrets/store.p12");
assert!(
complaint.contains("the file has been altered"),
"a genuinely altered ciphertext must still be called altered:\n{complaint}"
);
assert!(
!complaint.contains("Nothing is lost"),
"a genuinely altered ciphertext was reported as safe:\n{complaint}"
);
}
{
let (repo, _secret, blob) = repository_with_one_intact_secret();
let unexpandable: Vec<u8> = blob
.iter()
.map(|&byte| if byte == b'\n' { 0x0b } else { byte })
.collect();
assert_eq!(
lone_line_feeds(&unexpandable),
0,
"the fixture still holds an `LF`, so git would expand something"
);
commit_blob_verbatim(&repo, "secrets/store.p12", &unexpandable);
append_attribute_line(&repo, b"secrets/** text\n");
repo.set_eol_config("true", "");
let complaint = failing_checkout(&repo, "secrets/store.p12");
assert!(
complaint.contains("the file has been altered"),
"a ciphertext git could not have expanded was blamed on git:\n{complaint}"
);
assert!(
!complaint.contains("Nothing is lost"),
"an altered ciphertext was reported as safe because a `text` line \
happened to be present:\n{complaint}"
);
}
{
let (repo, _secret, blob) = repository_with_one_intact_secret();
let already_expanded = expand_lone_line_feeds(&blob);
assert_eq!(
lone_line_feeds(&already_expanded),
0,
"the fixture does not wear the fingerprint it exists to wear"
);
commit_blob_verbatim(&repo, "secrets/store.p12", &already_expanded);
append_attribute_line(&repo, b"secrets/** text eol=lf\n");
repo.set_eol_config("true", "");
assert_eq!(
repo.check_attr("eol", "secrets/store.p12"),
"lf",
"the fixture no longer pins the check-out direction"
);
assert_eq!(
repo.check_attr("text", "secrets/store.p12"),
"set",
"the fixture no longer makes the check-in verdict say `convert`"
);
let complaint = failing_checkout(&repo, "secrets/store.p12");
assert!(
complaint.contains("the file has been altered"),
"an altered ciphertext was blamed on a line that does not convert \
at check-out:\n{complaint}"
);
assert!(
!complaint.contains("Nothing is lost"),
"the check-in verdict was reused for the check-out direction, so a \
file whose plaintext really is gone was reported as safe:\n{complaint}"
);
}
{
let (repo, _secret, blob) = repository_with_one_intact_secret();
let mut foreign = blob.clone();
foreign[14] ^= 0xff;
commit_blob_verbatim(&repo, "secrets/store.p12", &foreign);
append_attribute_line(&repo, b"secrets/** text\n");
repo.set_eol_config("true", "");
let complaint = failing_checkout(&repo, "secrets/store.p12");
assert!(
complaint.contains("was encrypted with key"),
"a file belonging to another key must still say so:\n{complaint}"
);
assert!(
!complaint.contains("Nothing is lost"),
"a foreign key's file was reported as a configuration problem:\n{complaint}"
);
}
}
#[test]
fn sync_says_when_a_line_outside_its_section_might_outrank_it() {
const SPEAKS: [&str; 4] = [
"secrets/** -filter",
"*.psd filter=lfs",
"*.md text",
"*.sh eol=lf",
];
const SILENT: [&str; 3] = ["*.png -diff", "# just a note", ""];
for line in SPEAKS.into_iter().chain(SILENT) {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("secrets/\n");
if !line.is_empty() {
let mut attributes = repo.worktree_bytes(".gitattributes");
attributes.extend_from_slice(line.as_bytes());
attributes.push(b'\n');
repo.write_file(".gitattributes", &attributes);
}
let said = String::from_utf8_lossy(&repo.xcrypt_ok(["sync"]).stderr).into_owned();
let mentioned = said.contains("outside the managed section");
if SPEAKS.contains(&line) {
assert!(
mentioned,
"{line:?}: a line that can outrank the managed section went unmentioned:\n{said}"
);
assert!(
said.contains("git-xcrypt status"),
"{line:?}: the sentence must name the command that can actually \
answer:\n{said}"
);
} else {
assert!(
!mentioned,
"{line:?}: an ordinary line provoked a warning, which is how a \
warning stops being read:\n{said}"
);
}
}
}
#[test]
fn the_note_about_foreign_filter_lines_sees_a_directory_the_index_does_not_hold() {
let repo = TestRepo::init();
repo.init_xcrypt();
repo.write_xcrypt_config("secrets/\n");
repo.xcrypt_ok(["sync"]);
repo.write_file("secrets/db.env", b"api_key = value\n");
repo.commit_all("a secret");
repo.write_file("vendor/.gitattributes", b"*.blob filter=lfs\n");
let output = repo.xcrypt(["status"]);
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
output.status.code(),
Some(0),
"an untracked foreign line reaching no declared path is a note, \
never a finding:\n{text}"
);
assert!(
text.contains("vendor/.gitattributes"),
"the note lost sight of an attributes file outside every tracked \
chain:\n{text}"
);
}