use bstr::{BStr, ByteSlice};
use gix_glob::pattern::Case;
use gix_glob::{Pattern, wildmatch};
use crate::git::repo::{ATTRIBUTES_FILE, CONFIG_FILE, KEY_ENVELOPE_DIR};
use crate::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TextMode {
#[default]
Auto,
Text,
Binary,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EolMode {
Lf,
Crlf,
Native,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct Declared {
text: Option<TextMode>,
eol: Option<EolMode>,
suppress_diff: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Decision {
pub encrypt: bool,
pub text: TextMode,
pub eol: Option<EolMode>,
pub suppress_diff: bool,
}
impl Default for Decision {
fn default() -> Self {
Self {
encrypt: false,
text: TextMode::Auto,
eol: None,
suppress_diff: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PatternView<'a> {
pub source: &'a str,
pub negated: bool,
pub suppress_diff: bool,
}
#[derive(Debug)]
struct Rule {
pattern: Pattern,
source: String,
declared: Declared,
}
#[derive(Debug, Default)]
pub struct Config {
rules: Vec<Rule>,
pub pointless_eol: Vec<String>,
pub missing: bool,
}
impl Config {
pub fn parse(text: &str) -> Result<Self> {
let mut config = Self::default();
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
for (number, line) in text.lines().enumerate() {
let number = number + 1;
if line.trim().is_empty() || line.trim_start().starts_with('#') {
continue;
}
let split = split_line(line, number)?;
let declared = parse_attributes(split.attributes, number)?;
let pattern = if split.negation_syntax {
Pattern::from_bytes(split.glob.as_bytes())
} else {
Pattern::from_bytes_without_negation(split.glob.as_bytes())
}
.ok_or_else(|| {
Error::Config(format!(
"{CONFIG_FILE}:{number}: `{}` is not a usable pattern",
split.source
))
})?;
if pattern.is_negative() && declared != Declared::default() {
return Err(Error::Config(format!(
"{CONFIG_FILE}:{number}: a negated pattern cannot carry attributes — \
the path is not encrypted, so there is nothing to convert"
)));
}
if declared.eol.is_some() && declared.text == Some(TextMode::Binary) {
config.pointless_eol.push(format!(
"{CONFIG_FILE}:{number}: `eol=` has no effect on a path that is never \
converted; git lets -text win over eol too"
));
}
config.rules.push(Rule {
pattern,
source: split.source,
declared,
});
}
Ok(config)
}
pub fn load(path: &std::path::Path) -> Result<Self> {
match std::fs::read_to_string(path) {
Ok(text) => Self::parse(&text),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self {
missing: true,
..Self::default()
}),
Err(err) if err.kind() == std::io::ErrorKind::InvalidData => {
Err(Error::Io(std::io::Error::other(format!(
"{}: this file must be UTF-8 text and is not ({err}), so \
nothing here declares what to encrypt",
path.display()
))))
}
Err(err) => Err(Error::Io(std::io::Error::other(format!(
"{}: could not be read ({err})",
path.display()
)))),
}
}
#[must_use]
pub fn patterns(&self) -> Vec<PatternView<'_>> {
self.rules
.iter()
.map(|rule| PatternView {
source: rule.source.as_str(),
negated: rule.pattern.is_negative(),
suppress_diff: rule.declared.suppress_diff,
})
.collect()
}
#[must_use]
pub fn decide(&self, path: &[u8]) -> Decision {
if is_never_encrypted(path) {
return Decision::default();
}
self.decide_ignoring_exclusions(path)
}
#[must_use]
pub fn negated(&self, path: &[u8]) -> bool {
if is_never_encrypted(path) {
return false;
}
self.rules
.iter()
.rfind(|rule| matches(&rule.pattern, path))
.is_some_and(|rule| rule.pattern.is_negative())
}
#[must_use]
pub fn decide_ignoring_exclusions(&self, path: &[u8]) -> Decision {
let mut decision = Decision::default();
let mut selected = false;
for rule in &self.rules {
if !matches(&rule.pattern, path) {
continue;
}
selected = !rule.pattern.is_negative();
if let Some(text) = rule.declared.text {
decision.text = text;
}
if let Some(eol) = rule.declared.eol {
decision.eol = Some(eol);
}
if rule.declared.suppress_diff {
decision.suppress_diff = true;
}
}
decision.encrypt = selected;
decision
}
}
#[must_use]
pub fn is_never_encrypted(path: &[u8]) -> bool {
let basename = path.rsplit_str("/").next().unwrap_or(path);
let same = |left: &[u8], right: &str| left.eq_ignore_ascii_case(right.as_bytes());
same(basename, ATTRIBUTES_FILE)
|| same(path, CONFIG_FILE)
|| same(path, KEY_ENVELOPE_DIR)
|| path
.get(..KEY_ENVELOPE_DIR.len())
.is_some_and(|head| same(head, KEY_ENVELOPE_DIR))
&& path.get(KEY_ENVELOPE_DIR.len()) == Some(&b'/')
}
struct Split<'a> {
glob: String,
negation_syntax: bool,
source: String,
attributes: &'a str,
}
fn split_line(line: &str, number: usize) -> Result<Split<'_>> {
let (negated, rest) = match line.strip_prefix('!') {
Some(rest) => (true, rest),
None => (false, line),
};
if let Some(body) = rest.strip_prefix('"') {
let (source, after) = unquote(body, number)?;
if !after.is_empty() && !after.starts_with(|c: char| c.is_whitespace()) {
return Err(Error::Config(format!(
"{CONFIG_FILE}:{number}: `{after}` follows the closing quote; the quotes close \
the pattern, and any attributes come after a space"
)));
}
refuse_quoted_attributes(&source, negated, number)?;
return Ok(Split {
glob: if negated {
format!("!{source}")
} else {
source.clone()
},
negation_syntax: negated,
source,
attributes: after.trim_start(),
});
}
let end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
let (source, attributes) = rest.split_at(end);
if source.is_empty() {
return Err(Error::Config(format!(
"{CONFIG_FILE}:{number}: there is no pattern here — a line starts with the pattern it \
declares, and a name that begins with whitespace is written in quotes"
)));
}
if source.ends_with('\\') {
return Err(legacy_escape_error(line, number));
}
Ok(Split {
glob: if negated {
format!("!{source}")
} else {
source.to_string()
},
negation_syntax: true,
source: source.to_string(),
attributes: attributes.trim_start(),
})
}
fn unquote(body: &str, number: usize) -> Result<(String, &str)> {
let unterminated = || {
Error::Config(format!(
"{CONFIG_FILE}:{number}: this pattern opens with `\"` and never closes it"
))
};
let bytes = body.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'"' => {
let text = String::from_utf8(out).map_err(|_| {
Error::Config(format!(
"{CONFIG_FILE}:{number}: the escapes in this pattern do not spell UTF-8 \
text, and this file is read as text"
))
})?;
return Ok((text, &body[index + 1..]));
}
b'\\' => {
let Some(next) = body[index + 1..].chars().next() else {
return Err(unterminated());
};
let escaped = bytes[index + 1];
index += 1 + next.len_utf8();
match escaped {
b'a' => out.push(0x07),
b'b' => out.push(0x08),
b'f' => out.push(0x0c),
b'n' => out.push(b'\n'),
b'r' => out.push(b'\r'),
b't' => out.push(b'\t'),
b'v' => out.push(0x0b),
b'\\' | b'"' => out.push(escaped),
b'0'..=b'7' => {
let mut value = u32::from(escaped - b'0');
for _ in 0..2 {
match bytes.get(index) {
Some(digit @ b'0'..=b'7') => {
value = value * 8 + u32::from(digit - b'0');
index += 1;
}
_ => break,
}
}
let byte = u8::try_from(value).map_err(|_| {
Error::Config(format!(
"{CONFIG_FILE}:{number}: `\\{value:o}` is not a byte; an octal \
escape names one byte, from \\0 to \\377"
))
})?;
out.push(byte);
}
_ => {
return Err(Error::Config(format!(
"{CONFIG_FILE}:{number}: `\\{next}` is not an escape git knows inside \
a quoted pattern; a literal backslash is written `\\\\`"
)));
}
}
}
byte => {
out.push(byte);
index += 1;
}
}
}
Err(unterminated())
}
fn is_attribute_word(token: &str) -> bool {
matches!(
token,
"text" | "-text" | "binary" | "text=auto" | "eol=lf" | "eol=crlf" | "eol=native"
)
}
fn refuse_quoted_attributes(pattern: &str, negated: bool, number: usize) -> Result<()> {
let mut head = pattern.trim_end();
let mut found = 0usize;
while let Some((before, last)) = head.rsplit_once(|c: char| c.is_whitespace()) {
if !is_attribute_word(last) {
break;
}
head = before.trim_end();
found += 1;
}
if found == 0 || head.is_empty() {
return Ok(());
}
let attributes = pattern[head.len()..].trim();
let marker = if negated { "!" } else { "" };
Err(Error::Config(format!(
"{CONFIG_FILE}:{number}: this quoted pattern ends with the attribute `{attributes}`, and \
quotes close the pattern only — attributes stand outside them. The line reads like the \
syntax that changed on 2026-08-05. Write it as:\n \
{marker}\"{head}\" {attributes}\n\
If the path really does end in that word, spell it so it cannot be read as an \
attribute — `[t]ext` matches the same names."
)))
}
fn legacy_escape_error(line: &str, number: usize) -> Error {
let (intended, attributes) = legacy_split(line);
let (marker, intended) = match intended.strip_prefix('!') {
Some(rest) => ("!", rest.to_string()),
None => ("", intended),
};
let mut quoted = String::with_capacity(intended.len() + 2);
for character in intended.chars() {
if character == '"' || character == '\\' {
quoted.push('\\');
}
quoted.push(character);
}
let suggestion = if attributes.is_empty() {
format!("{marker}\"{quoted}\"")
} else {
format!("{marker}\"{quoted}\" {attributes}")
};
Error::Config(format!(
"{CONFIG_FILE}:{number}: this pattern ends with a backslash, which is how a space in a \
path was written until 2026-08-05. A space is now closed with quotes instead, and a \
backslash means only what it means in a glob. Write the line as:\n \
{suggestion}\n\
A negation keeps its `!` outside the quotes: !\"my secrets/README.md\"."
))
}
fn legacy_split(line: &str) -> (String, &str) {
let bytes = line.as_bytes();
let mut pattern = String::new();
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'\\' {
if let Some(escaped) = line[index + 1..].chars().next() {
if !escaped.is_whitespace() {
pattern.push('\\');
}
pattern.push(escaped);
index += 1 + escaped.len_utf8();
continue;
}
index += 1;
continue;
}
if bytes[index].is_ascii_whitespace() {
return (pattern, line[index..].trim());
}
let character = line[index..].chars().next().unwrap_or('\\');
pattern.push(character);
index += character.len_utf8();
}
(pattern, "")
}
fn parse_attributes(text: &str, line: usize) -> Result<Declared> {
let mut declared = Declared::default();
for token in text.split_whitespace() {
match token {
"text" => declared.text = Some(TextMode::Text),
"-text" => declared.text = Some(TextMode::Binary),
"text=auto" => declared.text = Some(TextMode::Auto),
"binary" => {
declared.text = Some(TextMode::Binary);
declared.suppress_diff = true;
}
"eol=lf" => declared.eol = Some(EolMode::Lf),
"eol=crlf" => declared.eol = Some(EolMode::Crlf),
"eol=native" => declared.eol = Some(EolMode::Native),
other => {
return Err(Error::Config(format!(
"{CONFIG_FILE}:{line}: unknown attribute `{other}`; \
expected one of text, -text, binary, text=auto, \
eol=lf, eol=crlf, eol=native"
)));
}
}
}
Ok(declared)
}
const MATCHING: Case = Case::Fold;
fn matches(pattern: &Pattern, path: &[u8]) -> bool {
if match_one(pattern, path, false, MATCHING) {
return true;
}
for (index, byte) in path.iter().enumerate() {
if *byte == b'/' && match_one(pattern, &path[..index], true, MATCHING) {
return true;
}
}
false
}
fn match_one(pattern: &Pattern, path: &[u8], is_dir: bool, case: Case) -> bool {
let bytes: &BStr = path.as_bstr();
let basename_start = path.rfind_byte(b'/').map(|index| index + 1);
pattern.matches_repo_relative_path(
bytes,
basename_start,
Some(is_dir),
case,
wildmatch::Mode::NO_MATCH_SLASH_LITERAL,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn config(text: &str) -> Config {
Config::parse(text).expect("the test configuration must parse")
}
#[test]
fn bootstrap_files_are_never_encrypted() {
let config = config("*\n");
for path in [
ATTRIBUTES_FILE,
"sub/dir/.gitattributes",
CONFIG_FILE,
"\u{2e}git-xcrypt-keys/robert.age",
".GITATTRIBUTES",
"sub/dir/.GitAttributes",
".GIT-XCRYPT",
"\u{2e}Git-Xcrypt-Keys/robert.age",
] {
assert!(
!config.decide(path.as_bytes()).encrypt,
"{path} must never be encrypted; it is needed to bootstrap"
);
}
assert!(config.decide(b"anything-else").encrypt);
}
#[test]
fn a_byte_order_mark_does_not_glue_itself_to_the_first_pattern() {
let parsed = config("\u{feff}secrets/\n");
assert!(
parsed.decide(b"secrets/db.env").encrypt,
"the invisible BOM turned the first pattern into one matching nothing"
);
let parsed = config("\u{feff}first/\nsecond/\n");
assert!(parsed.decide(b"second/x").encrypt);
}
#[test]
fn every_shape_the_parser_refuses_is_refused_and_says_why() {
let refused: &[(&str, &str, &str)] = &[
("an unterminated quote", "\"my secrets/\n", "never closes"),
(
"text after the closing quote",
"\"my secrets/\"oops\n",
"follows the closing",
),
("nothing but a negation", "!\n", "there is no pattern here"),
(
"the pre-2026-08-05 backslash escape",
"my\\ secrets/\n",
"ends with a backslash",
),
(
"an old line quoted whole",
"\"secrets/*.sh text eol=lf\"\n",
"ends with the attribute",
),
(
"an escape that is not one",
"\"secrets/\\q.env\"\n",
"is not an escape",
),
(
"an octal escape past a byte",
"\"secrets/\\777.env\"\n",
"is not a byte",
),
(
"octal escapes that do not spell UTF-8",
"\"secrets/\\377.env\"\n",
"do not spell",
),
(
"an attribute nobody defined",
"secrets/ text=maybe\n",
"unknown attribute",
),
(
"attributes on a negation",
"!secrets/README.md text\n",
"cannot carry",
),
];
for (label, text, fragment) in refused {
let error = Config::parse(text)
.err()
.unwrap_or_else(|| panic!("{label}: this must not parse, but it did"));
let message = error.to_string();
assert!(
message.contains(fragment),
"{label}: the refusal must say `{fragment}`, and says: {message}"
);
assert!(
message.contains(CONFIG_FILE),
"{label}: the refusal must name the file it is about: {message}"
);
}
}
#[test]
fn every_shape_the_parser_accepts_means_what_it_says() {
type Row<'a> = (&'a str, &'a str, &'a [u8], bool, TextMode, Option<EolMode>);
let accepted: &[Row] = &[
(
"a bare pattern",
"secrets/\n",
b"secrets/db.env",
true,
TextMode::Auto,
None,
),
(
"a bare pattern with attributes",
"secrets/deploy.ps1 text eol=crlf\n",
b"secrets/deploy.ps1",
true,
TextMode::Text,
Some(EolMode::Crlf),
),
(
"a quoted name holding a space",
"\"my secrets/\"\n",
b"my secrets/db.env",
true,
TextMode::Auto,
None,
),
(
"a quoted name with attributes",
"\"my secrets/*.sh\" text eol=lf\n",
b"my secrets/go.sh",
true,
TextMode::Text,
Some(EolMode::Lf),
),
(
"a name that ends in a space",
"\"secrets /\"\n",
b"secrets /a.env",
true,
TextMode::Auto,
None,
),
(
"a leading ! that is part of the name",
"\"!odd.env\"\n",
b"!odd.env",
true,
TextMode::Auto,
None,
),
(
"a leading # that is part of the name",
"\"#notes.env\"\n",
b"#notes.env",
true,
TextMode::Auto,
None,
),
(
"a quote inside the name",
"\"od\\\"d.env\"\n",
b"od\"d.env",
true,
TextMode::Auto,
None,
),
(
"an octal escape spelling a letter",
"\"secrets/\\101.env\"\n",
b"secrets/A.env",
true,
TextMode::Auto,
None,
),
(
"binary suppresses the diff driver",
"secrets/key.p12 binary\n",
b"secrets/key.p12",
true,
TextMode::Binary,
None,
),
(
"a negation keeps its ! outside the quotes",
"\"my secrets/\"\n!\"my secrets/README.md\"\n",
b"my secrets/README.md",
false,
TextMode::Auto,
None,
),
(
"a bare negation still works",
"secrets/\n!secrets/README.md\n",
b"secrets/README.md",
false,
TextMode::Auto,
None,
),
(
"a pattern reaches every ASCII spelling of the name",
"secrets/\n",
b"SEcrets/db.env",
true,
TextMode::Auto,
None,
),
(
"…attributes come with it",
"secrets/*.sh text\n",
b"SEcrets/Go.SH",
true,
TextMode::Text,
None,
),
(
"…and so does a negation, or the hole closes on a rename",
"secrets/\n!secrets/README.md\n",
b"SEcrets/README.MD",
false,
TextMode::Auto,
None,
),
(
"folding stops at ASCII, exactly where git stops",
"\u{142}\u{105}ka/\n",
"\u{141}\u{104}KA/a.txt".as_bytes(),
false,
TextMode::Auto,
None,
),
];
for (label, text, path, encrypt, mode, eol) in accepted {
let parsed = Config::parse(text)
.unwrap_or_else(|error| panic!("{label}: this must parse, and says: {error}"));
let decision = parsed.decide(path);
assert_eq!(
decision.encrypt,
*encrypt,
"{label}: `{}` should{} be encrypted",
String::from_utf8_lossy(path),
if *encrypt { "" } else { " not" }
);
if *encrypt {
assert_eq!(decision.text, *mode, "{label}: wrong text mode");
assert_eq!(decision.eol, *eol, "{label}: wrong end-of-line mode");
}
}
}
}