git_xcrypt/rules/declaration.rs
1//! The `.git-xcrypt` file: which paths are encrypted, and how line endings are
2//! handled.
3//!
4//! Patterns use `.gitignore` syntax and are matched by `gix-glob`, so the
5//! semantics are git's own rather than an imitation of them. A pattern that
6//! contains a space is closed with quotes, exactly as `.gitattributes` closes
7//! one — see [`split_line`] for why the backslash that used to do that job was
8//! taken away from it. Attributes use the `.gitattributes` vocabulary. The two
9//! resolve on **independent axes**, exactly as git splits them across two
10//! files:
11//!
12//! * selection — last matching line wins, `!` turns a path off;
13//! * attributes — a later line overrides only the attributes it names, a line
14//! with no attributes changes nothing.
15//!
16//! That separation is what stops a broad pattern added below a narrow
17//! declaration from silently erasing it.
18
19use bstr::{BStr, ByteSlice};
20use gix_glob::pattern::Case;
21use gix_glob::{Pattern, wildmatch};
22
23use crate::git::repo::{ATTRIBUTES_FILE, CONFIG_FILE, KEY_ENVELOPE_DIR};
24use crate::{Error, Result};
25
26/// How a path's content is treated before encryption.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum TextMode {
29 /// Decide from the content, as git's `text=auto` does. The default.
30 #[default]
31 Auto,
32 /// Always normalise to LF before encrypting.
33 Text,
34 /// Never convert. Covers both `-text` and `binary`.
35 Binary,
36}
37
38/// Which line ending the smudge path writes.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum EolMode {
41 /// Always LF.
42 Lf,
43 /// Always CRLF.
44 Crlf,
45 /// Whatever the platform uses.
46 Native,
47}
48
49/// The attributes one line declares. `None` means "this line says nothing".
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
51struct Declared {
52 text: Option<TextMode>,
53 eol: Option<EolMode>,
54 suppress_diff: bool,
55}
56
57/// What the file says about one path.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct Decision {
60 /// Whether this path is encrypted at all.
61 pub encrypt: bool,
62 /// How its content is treated before encryption.
63 pub text: TextMode,
64 /// An explicit line ending for the smudge path, if one was declared.
65 pub eol: Option<EolMode>,
66 /// Whether `binary` asked for the diff driver to be left off.
67 pub suppress_diff: bool,
68}
69
70impl Default for Decision {
71 fn default() -> Self {
72 Self {
73 encrypt: false,
74 text: TextMode::Auto,
75 eol: None,
76 suppress_diff: false,
77 }
78 }
79}
80
81/// One line of `.git-xcrypt`, as the `.gitattributes` renderer sees it.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct PatternView<'a> {
84 /// The pattern as written, without a leading `!`.
85 pub source: &'a str,
86 /// Whether this line takes paths back out.
87 pub negated: bool,
88 /// Whether it declared `binary`, which also means "no diff driver".
89 pub suppress_diff: bool,
90}
91
92/// One line of the file.
93#[derive(Debug)]
94struct Rule {
95 pattern: Pattern,
96 /// The pattern text as written, for rendering `.gitattributes` in S-02.
97 source: String,
98 declared: Declared,
99}
100
101/// A parsed `.git-xcrypt`.
102#[derive(Debug, Default)]
103pub struct Config {
104 rules: Vec<Rule>,
105 /// Lines that carry `eol=` on a path that is never converted.
106 ///
107 /// Pointless rather than dangerous — git itself lets `-text` win over `eol` —
108 /// so it is a warning the caller prints once, not an error.
109 pub pointless_eol: Vec<String>,
110 /// The file was not on disk at all.
111 ///
112 /// Kept rather than turned into an error at load time because the two filter
113 /// directions need opposite answers: check-in must refuse, since "no
114 /// declaration" is indistinguishable from "the declaration has not been
115 /// checked out yet" and guessing wrong writes a secret in the clear;
116 /// check-out must carry on, because a file's own header already says
117 /// everything smudge needs and git gives no order in which it writes the
118 /// working tree.
119 pub missing: bool,
120}
121
122impl Config {
123 /// Parses the contents of a `.git-xcrypt` file.
124 ///
125 /// # Errors
126 ///
127 /// [`Error::Config`] for an unknown attribute, an attribute on a negation, or
128 /// a pattern `gix-glob` refuses. Fail closed: a file we do not fully
129 /// understand must stop the operation, not be half-applied.
130 pub fn parse(text: &str) -> Result<Self> {
131 let mut config = Self::default();
132
133 // A UTF-8 byte-order mark is what PowerShell 5's `Set-Content
134 // -Encoding UTF8` writes and what no editor shows. It is not
135 // whitespace to `str::trim`, and it is not `#`, so it used to reach
136 // `gix-glob` glued to the first pattern — measured: a `.git-xcrypt`
137 // starting `\u{feff}secrets/` selected nothing, `git add
138 // secrets/db.env` exited 0 and the plaintext went into the object
139 // database with no word from anyone. Git strips one at the head of its
140 // own pattern files, so this parser does the same rather than refuse a
141 // file git itself reads happily.
142 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
143
144 for (number, line) in text.lines().enumerate() {
145 // Deliberately not trimmed: a pattern's own leading whitespace is
146 // significant in `.gitignore`, so `split_line` is left to refuse an
147 // indented line rather than to silently accept a different pattern
148 // than the one written.
149 let number = number + 1;
150 if line.trim().is_empty() || line.trim_start().starts_with('#') {
151 continue;
152 }
153
154 let split = split_line(line, number)?;
155 let declared = parse_attributes(split.attributes, number)?;
156
157 let pattern = if split.negation_syntax {
158 Pattern::from_bytes(split.glob.as_bytes())
159 } else {
160 // A quoted pattern is taken entirely literally, so a `!` that
161 // survived the unquoting is part of a file name and must not be
162 // read as the negation marker — that marker stands *outside*
163 // the quotes.
164 Pattern::from_bytes_without_negation(split.glob.as_bytes())
165 }
166 .ok_or_else(|| {
167 Error::Config(format!(
168 "{CONFIG_FILE}:{number}: `{}` is not a usable pattern",
169 split.source
170 ))
171 })?;
172
173 if pattern.is_negative() && declared != Declared::default() {
174 return Err(Error::Config(format!(
175 "{CONFIG_FILE}:{number}: a negated pattern cannot carry attributes — \
176 the path is not encrypted, so there is nothing to convert"
177 )));
178 }
179
180 if declared.eol.is_some() && declared.text == Some(TextMode::Binary) {
181 config.pointless_eol.push(format!(
182 "{CONFIG_FILE}:{number}: `eol=` has no effect on a path that is never \
183 converted; git lets -text win over eol too"
184 ));
185 }
186
187 config.rules.push(Rule {
188 pattern,
189 source: split.source,
190 declared,
191 });
192 }
193
194 Ok(config)
195 }
196
197 /// Reads and parses the file at `path`, recording an absent file as such.
198 ///
199 /// An unreadable file is an error here and an absent one is flagged, because
200 /// neither may end up meaning "encrypt nothing" on the check-in path.
201 ///
202 /// # Errors
203 ///
204 /// [`Error::Io`] when the file exists but cannot be read, [`Error::Config`]
205 /// when it cannot be understood.
206 ///
207 /// The failure names the file, which is not decoration on this path. It is
208 /// reached from the filter, so every git operation in the repository stops
209 /// until it is fixed, and git's own accompanying `fatal:` line names whatever
210 /// file it was cleaning when this one could not be read — measured on git
211 /// 2.55, that was an innocent `secrets/db.env`. A bare
212 /// `stream did not contain valid UTF-8` beside it left nothing pointing at
213 /// the real culprit.
214 pub fn load(path: &std::path::Path) -> Result<Self> {
215 match std::fs::read_to_string(path) {
216 Ok(text) => Self::parse(&text),
217 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self {
218 missing: true,
219 ..Self::default()
220 }),
221 // The kind is spelled out rather than passed through: `read_to_string`
222 // reports "not valid UTF-8" for a file that is perfectly readable and
223 // simply is not text, and patterns are text. Saying so is the
224 // difference between a remedy and a puzzle.
225 Err(err) if err.kind() == std::io::ErrorKind::InvalidData => {
226 Err(Error::Io(std::io::Error::other(format!(
227 "{}: this file must be UTF-8 text and is not ({err}), so \
228 nothing here declares what to encrypt",
229 path.display()
230 ))))
231 }
232 Err(err) => Err(Error::Io(std::io::Error::other(format!(
233 "{}: could not be read ({err})",
234 path.display()
235 )))),
236 }
237 }
238
239 /// Every pattern in file order, with the `!` stripped from the negations.
240 ///
241 /// File order is what the caller needs rather than a convenience: selection
242 /// is resolved by last match, so a rendered `.gitattributes` only agrees
243 /// with [`Config::decide`] if it keeps the lines in the order they were
244 /// written. Splitting them into "selecting" and "negated" lists loses
245 /// exactly the information that decides the answer.
246 #[must_use]
247 pub fn patterns(&self) -> Vec<PatternView<'_>> {
248 self.rules
249 .iter()
250 .map(|rule| PatternView {
251 // The `!` is already off: it is a marker on the line, not a
252 // character of the pattern, and stripping it here as well would
253 // eat a leading `!` that a quoted pattern means literally.
254 source: rule.source.as_str(),
255 negated: rule.pattern.is_negative(),
256 suppress_diff: rule.declared.suppress_diff,
257 })
258 .collect()
259 }
260
261 /// What this configuration says about `path`, given relative to the root.
262 ///
263 /// The path is bytes, not text: on Unix a path is an arbitrary byte string,
264 /// and lossy decoding would match a file under a name it does not have —
265 /// which in the pass-through direction means storing a secret in the clear.
266 #[must_use]
267 pub fn decide(&self, path: &[u8]) -> Decision {
268 if is_never_encrypted(path) {
269 return Decision::default();
270 }
271 self.decide_ignoring_exclusions(path)
272 }
273
274 /// Whether a negation is what keeps `path` out of the encrypted set.
275 ///
276 /// `status` reports such paths in a section of their own rather than leaving
277 /// them out. A `!secrets/README.md` under a `secrets/` line is a deliberate
278 /// hole in the declaration, and the founding document is explicit that a
279 /// deliberate hole must never be an invisible one — silence here reads as
280 /// "everything under `secrets/` is covered", which is the belief that lets a
281 /// secret be filed under the exception by mistake.
282 ///
283 /// False for a path no pattern reaches at all: that is not an exception, it
284 /// is simply a file nobody declared.
285 #[must_use]
286 pub fn negated(&self, path: &[u8]) -> bool {
287 if is_never_encrypted(path) {
288 // Not an exception either. These are excluded by the tool, not by
289 // anything the user wrote, and listing them as "in the clear by
290 // choice" would attribute a decision nobody made.
291 return false;
292 }
293 self.rules
294 .iter()
295 .rfind(|rule| matches(&rule.pattern, path))
296 .is_some_and(|rule| rule.pattern.is_negative())
297 }
298
299 /// What the patterns alone say, with the bootstrap exclusions set aside.
300 ///
301 /// Only one caller wants this: rendering `.gitattributes` has to know
302 /// whether a pattern reaches a file that [`is_never_encrypted`] then rescues,
303 /// because such a file needs a line putting git's defaults back. Everything
304 /// else must go through [`Config::decide`].
305 #[must_use]
306 pub fn decide_ignoring_exclusions(&self, path: &[u8]) -> Decision {
307 let mut decision = Decision::default();
308 let mut selected = false;
309
310 for rule in &self.rules {
311 if !matches(&rule.pattern, path) {
312 continue;
313 }
314
315 // Selection: last match wins, including a negation turning it off.
316 selected = !rule.pattern.is_negative();
317
318 // Attributes: only what this line names, so a broad selection
319 // pattern below a narrow declaration does not erase it.
320 if let Some(text) = rule.declared.text {
321 decision.text = text;
322 }
323 if let Some(eol) = rule.declared.eol {
324 decision.eol = Some(eol);
325 }
326 if rule.declared.suppress_diff {
327 decision.suppress_diff = true;
328 }
329 }
330
331 decision.encrypt = selected;
332 decision
333 }
334}
335
336/// Paths that are never encrypted, whatever the patterns say.
337///
338/// They are needed to bootstrap: git reads `.gitattributes` to know to call us
339/// at all, we read `.git-xcrypt` to know what to do, and the envelope directory
340/// must stay readable to whoever holds a recipient key. Public because the
341/// check-in path consults it before anything else, including before refusing on
342/// a missing `.git-xcrypt` — otherwise a user who deleted the file could not
343/// commit its replacement.
344///
345/// **Compared with ASCII case folded, like everything else here** — see
346/// [`MATCHING`], and note that this one is not a free consequence of that
347/// decision. On a case-insensitive filesystem `.GITATTRIBUTES` *is* the
348/// attributes file, so encrypting it would replace the catch-all line with
349/// ciphertext and turn the filter off for **every** file in the repository, not
350/// for one. On a case-sensitive filesystem the fold costs the opposite: a file
351/// deliberately named `secrets/.GITATTRIBUTES` stays in the clear. Folding is
352/// the direction taken because the rendered `.gitattributes` lines fold too, and
353/// a filter that encrypted a path those lines put git's defaults back on would
354/// leave ciphertext without `-text` — the shape measured destroying a 2 MB file
355/// at checkout. Recorded in `README.md` §Known limitations.
356#[must_use]
357pub fn is_never_encrypted(path: &[u8]) -> bool {
358 // `.gitattributes` is matched by basename, not by root path: git reads one
359 // per directory, so encrypting `sub/.gitattributes` would leave git unable
360 // to read the attributes for that whole subtree. `.git-xcrypt` is read only
361 // from the root, so there the root path is the right test.
362 let basename = path.rsplit_str("/").next().unwrap_or(path);
363
364 // No `format!` here: this runs once per file in the repository, and the
365 // allocation bought nothing that `strip_prefix` does not.
366 let same = |left: &[u8], right: &str| left.eq_ignore_ascii_case(right.as_bytes());
367
368 same(basename, ATTRIBUTES_FILE)
369 || same(path, CONFIG_FILE)
370 || same(path, KEY_ENVELOPE_DIR)
371 || path
372 .get(..KEY_ENVELOPE_DIR.len())
373 .is_some_and(|head| same(head, KEY_ENVELOPE_DIR))
374 && path.get(KEY_ENVELOPE_DIR.len()) == Some(&b'/')
375}
376
377/// One line, split into the two things it declares.
378struct Split<'a> {
379 /// The pattern as `gix-glob` must read it, negation marker included.
380 glob: String,
381 /// Whether `gix-glob` may read a leading `!` or `\!` in `glob` as syntax.
382 ///
383 /// False for a quoted pattern, whose every character is part of a name.
384 negation_syntax: bool,
385 /// The pattern as a renderer must reproduce it: unquoted, and without the
386 /// `!` that never belonged to the name in the first place.
387 source: String,
388 /// Everything after the pattern.
389 attributes: &'a str,
390}
391
392/// Splits a line into its pattern and its attributes.
393///
394/// Whitespace separates the two, so a pattern that contains whitespace is closed
395/// with **quotes**, and inside them C-style escapes are read exactly as git reads
396/// them in `.gitattributes` — `\"`, `\\`, `\t`, `\n`, `\r` and octal. A negation
397/// keeps its `!` outside the quotes: `!"my secrets/README.md"`.
398///
399/// **Quotes replaced the `\ ` escape on 2026-08-05**, and the reason is not
400/// taste. The backslash carried two meanings at once — an escape for whitespace
401/// at the level of the line, and wildmatch's own escape for a glob metacharacter
402/// inside the pattern — so `\*` and `\ ` had to be told apart by what followed
403/// them. It also never really closed the second shape it was supposed to: a
404/// trailing space had to be written `secrets\ ` at the end of a line, and an
405/// editor that strips trailing whitespace deletes it without a word, leaving a
406/// pattern that means something else. Quotes close both shapes with one
407/// mechanism, and the backslash goes back to being only what a glob says it is.
408fn split_line(line: &str, number: usize) -> Result<Split<'_>> {
409 let (negated, rest) = match line.strip_prefix('!') {
410 Some(rest) => (true, rest),
411 None => (false, line),
412 };
413
414 if let Some(body) = rest.strip_prefix('"') {
415 let (source, after) = unquote(body, number)?;
416 if !after.is_empty() && !after.starts_with(|c: char| c.is_whitespace()) {
417 return Err(Error::Config(format!(
418 "{CONFIG_FILE}:{number}: `{after}` follows the closing quote; the quotes close \
419 the pattern, and any attributes come after a space"
420 )));
421 }
422 refuse_quoted_attributes(&source, negated, number)?;
423 return Ok(Split {
424 glob: if negated {
425 format!("!{source}")
426 } else {
427 source.clone()
428 },
429 negation_syntax: negated,
430 source,
431 attributes: after.trim_start(),
432 });
433 }
434
435 let end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
436 let (source, attributes) = rest.split_at(end);
437
438 if source.is_empty() {
439 return Err(Error::Config(format!(
440 "{CONFIG_FILE}:{number}: there is no pattern here — a line starts with the pattern it \
441 declares, and a name that begins with whitespace is written in quotes"
442 )));
443 }
444 if source.ends_with('\\') {
445 return Err(legacy_escape_error(line, number));
446 }
447
448 Ok(Split {
449 // The token exactly as written, `!` included: an unquoted pattern is
450 // handed to `gix-glob` unaltered, so `\!` and `\#` keep meaning what
451 // `.gitignore` says they mean.
452 glob: if negated {
453 format!("!{source}")
454 } else {
455 source.to_string()
456 },
457 negation_syntax: true,
458 source: source.to_string(),
459 attributes: attributes.trim_start(),
460 })
461}
462
463/// Unwraps a C-quoted pattern, returning it and the rest of the line.
464///
465/// The escapes are git's own set from `unquote_c_style`, including the octal
466/// form, so a name spells the same here as it does in `.gitattributes` and in
467/// git's own output. An escape git does not know is refused rather than passed
468/// through: a pattern nobody agrees on is a pattern that selects the wrong set of
469/// files, and on the check-in path the wrong set means a secret in the clear.
470fn unquote(body: &str, number: usize) -> Result<(String, &str)> {
471 let unterminated = || {
472 Error::Config(format!(
473 "{CONFIG_FILE}:{number}: this pattern opens with `\"` and never closes it"
474 ))
475 };
476
477 let bytes = body.as_bytes();
478 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
479 let mut index = 0;
480
481 while index < bytes.len() {
482 match bytes[index] {
483 b'"' => {
484 let text = String::from_utf8(out).map_err(|_| {
485 Error::Config(format!(
486 "{CONFIG_FILE}:{number}: the escapes in this pattern do not spell UTF-8 \
487 text, and this file is read as text"
488 ))
489 })?;
490 return Ok((text, &body[index + 1..]));
491 }
492 b'\\' => {
493 // The character rather than the byte, so a backslash in front of
494 // a multi-byte character neither slices mid-character nor
495 // reports half of one.
496 let Some(next) = body[index + 1..].chars().next() else {
497 return Err(unterminated());
498 };
499 let escaped = bytes[index + 1];
500 index += 1 + next.len_utf8();
501 match escaped {
502 b'a' => out.push(0x07),
503 b'b' => out.push(0x08),
504 b'f' => out.push(0x0c),
505 b'n' => out.push(b'\n'),
506 b'r' => out.push(b'\r'),
507 b't' => out.push(b'\t'),
508 b'v' => out.push(0x0b),
509 b'\\' | b'"' => out.push(escaped),
510 b'0'..=b'7' => {
511 let mut value = u32::from(escaped - b'0');
512 for _ in 0..2 {
513 match bytes.get(index) {
514 Some(digit @ b'0'..=b'7') => {
515 value = value * 8 + u32::from(digit - b'0');
516 index += 1;
517 }
518 _ => break,
519 }
520 }
521 let byte = u8::try_from(value).map_err(|_| {
522 Error::Config(format!(
523 "{CONFIG_FILE}:{number}: `\\{value:o}` is not a byte; an octal \
524 escape names one byte, from \\0 to \\377"
525 ))
526 })?;
527 out.push(byte);
528 }
529 _ => {
530 return Err(Error::Config(format!(
531 "{CONFIG_FILE}:{number}: `\\{next}` is not an escape git knows inside \
532 a quoted pattern; a literal backslash is written `\\\\`"
533 )));
534 }
535 }
536 }
537 byte => {
538 out.push(byte);
539 index += 1;
540 }
541 }
542 }
543
544 Err(unterminated())
545}
546
547/// Whether `token` is one of the words the attribute vocabulary defines.
548///
549/// The same list [`parse_attributes`] matches on, spelled twice because the two
550/// ask different questions: one parses a line, one recognises a line written for
551/// the syntax that ended on 2026-08-05. Nothing makes the compiler keep them in
552/// step, so a new attribute belongs in both — a word missing from this one only
553/// narrows the net, never widens it.
554fn is_attribute_word(token: &str) -> bool {
555 matches!(
556 token,
557 "text" | "-text" | "binary" | "text=auto" | "eol=lf" | "eol=crlf" | "eol=native"
558 )
559}
560
561/// Refuses a quoted pattern whose tail is the attribute list of an older file.
562///
563/// The net under the migration, and it catches the one shape that would
564/// otherwise change meaning in silence: a line from before 2026-08-05 wrapped in
565/// quotes whole, so `"secrets/*.sh" text` becomes a pattern that matches nothing
566/// and a path that stops being encrypted without a word. Only a *trailing* run of
567/// attribute words counts, so an ordinary directory called `my text files/` is
568/// left alone.
569fn refuse_quoted_attributes(pattern: &str, negated: bool, number: usize) -> Result<()> {
570 let mut head = pattern.trim_end();
571 let mut found = 0usize;
572 while let Some((before, last)) = head.rsplit_once(|c: char| c.is_whitespace()) {
573 if !is_attribute_word(last) {
574 break;
575 }
576 head = before.trim_end();
577 found += 1;
578 }
579 if found == 0 || head.is_empty() {
580 return Ok(());
581 }
582
583 let attributes = pattern[head.len()..].trim();
584 let marker = if negated { "!" } else { "" };
585 Err(Error::Config(format!(
586 "{CONFIG_FILE}:{number}: this quoted pattern ends with the attribute `{attributes}`, and \
587 quotes close the pattern only — attributes stand outside them. The line reads like the \
588 syntax that changed on 2026-08-05. Write it as:\n \
589 {marker}\"{head}\" {attributes}\n\
590 If the path really does end in that word, spell it so it cannot be read as an \
591 attribute — `[t]ext` matches the same names."
592 )))
593}
594
595/// The refusal for a line still written with the `\ ` escape.
596///
597/// Recognising the shape is the whole point. Split by the rule in force today,
598/// `my\ secrets/` falls apart into the pattern `my\` and the unknown attribute
599/// `secrets/`, so the file is refused either way and no secret is stored in the
600/// clear — but "unknown attribute `secrets/`" tells a reader nothing about what
601/// changed, and the change is in a file they wrote once and have not looked at
602/// since. The suggestion is reconstructed with the old rule, so it is the line
603/// they meant rather than a template.
604fn legacy_escape_error(line: &str, number: usize) -> Error {
605 let (intended, attributes) = legacy_split(line);
606 let (marker, intended) = match intended.strip_prefix('!') {
607 Some(rest) => ("!", rest.to_string()),
608 None => ("", intended),
609 };
610
611 let mut quoted = String::with_capacity(intended.len() + 2);
612 for character in intended.chars() {
613 if character == '"' || character == '\\' {
614 quoted.push('\\');
615 }
616 quoted.push(character);
617 }
618
619 let suggestion = if attributes.is_empty() {
620 format!("{marker}\"{quoted}\"")
621 } else {
622 format!("{marker}\"{quoted}\" {attributes}")
623 };
624
625 Error::Config(format!(
626 "{CONFIG_FILE}:{number}: this pattern ends with a backslash, which is how a space in a \
627 path was written until 2026-08-05. A space is now closed with quotes instead, and a \
628 backslash means only what it means in a glob. Write the line as:\n \
629 {suggestion}\n\
630 A negation keeps its `!` outside the quotes: !\"my secrets/README.md\"."
631 ))
632}
633
634/// Splits a line the way this file did before 2026-08-05.
635///
636/// Kept for one purpose: reconstructing what the author of an old line meant, so
637/// the refusal can quote the replacement instead of describing it.
638fn legacy_split(line: &str) -> (String, &str) {
639 let bytes = line.as_bytes();
640 let mut pattern = String::new();
641 let mut index = 0;
642
643 while index < bytes.len() {
644 if bytes[index] == b'\\' {
645 if let Some(escaped) = line[index + 1..].chars().next() {
646 if !escaped.is_whitespace() {
647 pattern.push('\\');
648 }
649 pattern.push(escaped);
650 index += 1 + escaped.len_utf8();
651 continue;
652 }
653 index += 1;
654 continue;
655 }
656 if bytes[index].is_ascii_whitespace() {
657 return (pattern, line[index..].trim());
658 }
659 let character = line[index..].chars().next().unwrap_or('\\');
660 pattern.push(character);
661 index += character.len_utf8();
662 }
663 (pattern, "")
664}
665
666/// Parses the attribute tokens following a pattern.
667fn parse_attributes(text: &str, line: usize) -> Result<Declared> {
668 let mut declared = Declared::default();
669
670 for token in text.split_whitespace() {
671 match token {
672 "text" => declared.text = Some(TextMode::Text),
673 "-text" => declared.text = Some(TextMode::Binary),
674 "text=auto" => declared.text = Some(TextMode::Auto),
675 "binary" => {
676 declared.text = Some(TextMode::Binary);
677 declared.suppress_diff = true;
678 }
679 "eol=lf" => declared.eol = Some(EolMode::Lf),
680 "eol=crlf" => declared.eol = Some(EolMode::Crlf),
681 "eol=native" => declared.eol = Some(EolMode::Native),
682 other => {
683 return Err(Error::Config(format!(
684 "{CONFIG_FILE}:{line}: unknown attribute `{other}`; \
685 expected one of text, -text, binary, text=auto, \
686 eol=lf, eol=crlf, eol=native"
687 )));
688 }
689 }
690 }
691
692 Ok(declared)
693}
694
695/// How every pattern in this file is matched. **Unconditionally**, and that is
696/// the decision rather than a detail of it.
697///
698/// Settled on 2026-08-05 as open decision 13, on the owner's judgement that it
699/// is the safest of the answers available. The failure it closes was measured:
700/// `.git-xcrypt` declares `secrets/`, the user creates `Secrets/db.env`, and on
701/// APFS and on NTFS those are **one** directory — `cd secrets` enters `Secrets`
702/// and `ls` shows a single entry, so nothing in the working tree can show the
703/// mistake. Byte-exact matching left the file unselected, `git add` exited `0`
704/// and the plaintext went into the object database.
705///
706/// **Unconditional is what keeps determinism.** The obvious alternative — fold
707/// when git folds — would read `core.ignorecase`, which git sets for itself by
708/// probing the filesystem and which is not versioned, so the same repository
709/// would encrypt a different set of files on macOS than on Linux. That is the
710/// argument that keeps `core.autocrlf` off the check-in path, and it carries over
711/// unchanged. Folding always reads nothing: the declaration alone decides, on
712/// every machine.
713///
714/// **ASCII, deliberately.** `gix-glob` folds with `to_ascii_lowercase`, and so
715/// does git: measured on git 2.55 with `core.ignorecase=true`, `łąka/**` matches
716/// neither `ŁĄKA/a.env` nor `Łąka/a.env`. Reaching further would also have
717/// nowhere to land — `.gitattributes` matches bytes, so the rendered `[łŁ]` is a
718/// set of four bytes rather than two characters and matches no spelling at all,
719/// and a filter that selected a path the rendered section cannot reach is the
720/// "narrower" half of the rule this project holds hardest. Recorded as a
721/// limitation in `README.md` §Known limitations.
722const MATCHING: Case = Case::Fold;
723
724/// Whether `pattern` matches `path`, honouring directory patterns.
725///
726/// A pattern written as `secrets/` only matches a directory, so git also treats
727/// everything beneath it as matched. `gix-glob` answers about one path at a
728/// time, so the ancestors are offered to it explicitly.
729fn matches(pattern: &Pattern, path: &[u8]) -> bool {
730 if match_one(pattern, path, false, MATCHING) {
731 return true;
732 }
733
734 for (index, byte) in path.iter().enumerate() {
735 if *byte == b'/' && match_one(pattern, &path[..index], true, MATCHING) {
736 return true;
737 }
738 }
739 false
740}
741
742/// One `gix-glob` question.
743fn match_one(pattern: &Pattern, path: &[u8], is_dir: bool, case: Case) -> bool {
744 let bytes: &BStr = path.as_bstr();
745 let basename_start = path.rfind_byte(b'/').map(|index| index + 1);
746 pattern.matches_repo_relative_path(
747 bytes,
748 basename_start,
749 Some(is_dir),
750 case,
751 wildmatch::Mode::NO_MATCH_SLASH_LITERAL,
752 )
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758
759 fn config(text: &str) -> Config {
760 Config::parse(text).expect("the test configuration must parse")
761 }
762
763 #[test]
764 fn bootstrap_files_are_never_encrypted() {
765 let config = config("*\n");
766 for path in [
767 ATTRIBUTES_FILE,
768 // Git reads one .gitattributes per directory; encrypting a nested
769 // one would blind it for that whole subtree.
770 "sub/dir/.gitattributes",
771 CONFIG_FILE,
772 "\u{2e}git-xcrypt-keys/robert.age",
773 // The same three with ASCII case folded, because on APFS and NTFS
774 // `.GITATTRIBUTES` *is* the attributes file — encrypting it would
775 // replace the catch-all line with ciphertext and switch the filter
776 // off for every file in the repository. See `is_never_encrypted`.
777 ".GITATTRIBUTES",
778 "sub/dir/.GitAttributes",
779 ".GIT-XCRYPT",
780 "\u{2e}Git-Xcrypt-Keys/robert.age",
781 ] {
782 assert!(
783 !config.decide(path.as_bytes()).encrypt,
784 "{path} must never be encrypted; it is needed to bootstrap"
785 );
786 }
787 assert!(config.decide(b"anything-else").encrypt);
788 }
789
790 #[test]
791 fn a_byte_order_mark_does_not_glue_itself_to_the_first_pattern() {
792 // What PowerShell 5's `Set-Content -Encoding UTF8` writes and no editor
793 // shows. Measured before this: `\u{feff}secrets/` selected nothing,
794 // `git add secrets/db.env` exited 0 and stored the plaintext. Git
795 // strips one at the head of its own pattern files — measured on 2.55,
796 // a `.gitignore` and a `.gitattributes` each opening with a BOM still
797 // apply their first line — so the declaration follows git.
798 let parsed = config("\u{feff}secrets/\n");
799 assert!(
800 parsed.decide(b"secrets/db.env").encrypt,
801 "the invisible BOM turned the first pattern into one matching nothing"
802 );
803 // Only the head of the file: later lines are unaffected either way.
804 let parsed = config("\u{feff}first/\nsecond/\n");
805 assert!(parsed.decide(b"second/x").encrypt);
806 }
807
808 /// Every shape the line parser refuses, and the word that says why.
809 ///
810 /// A scenario walks one path per run, so it can prove that a good file is
811 /// read correctly and that one bad file is caught — it cannot economically
812 /// cover eleven refusals. Measured on 2026-08-05, when this table did not
813 /// exist: dropping the unterminated-quote guard left all 91 tests green, so
814 /// `.git-xcrypt` could open a quote and never close it and still be read as
815 /// a declaration. The other ten are here because a parser is a table and is
816 /// cheapest to guard as one.
817 ///
818 /// Each row asserts a fragment of the message, not the whole of it. The
819 /// wording is free to improve; what must not change is that the refusal
820 /// happens and names the reason, because the alternative to a refusal here
821 /// is a pattern that matches nothing and a file that stops being encrypted
822 /// without saying so.
823 #[test]
824 fn every_shape_the_parser_refuses_is_refused_and_says_why() {
825 let refused: &[(&str, &str, &str)] = &[
826 ("an unterminated quote", "\"my secrets/\n", "never closes"),
827 (
828 "text after the closing quote",
829 "\"my secrets/\"oops\n",
830 "follows the closing",
831 ),
832 ("nothing but a negation", "!\n", "there is no pattern here"),
833 (
834 "the pre-2026-08-05 backslash escape",
835 "my\\ secrets/\n",
836 "ends with a backslash",
837 ),
838 (
839 "an old line quoted whole",
840 "\"secrets/*.sh text eol=lf\"\n",
841 "ends with the attribute",
842 ),
843 (
844 "an escape that is not one",
845 "\"secrets/\\q.env\"\n",
846 "is not an escape",
847 ),
848 (
849 "an octal escape past a byte",
850 "\"secrets/\\777.env\"\n",
851 "is not a byte",
852 ),
853 (
854 "octal escapes that do not spell UTF-8",
855 "\"secrets/\\377.env\"\n",
856 "do not spell",
857 ),
858 (
859 "an attribute nobody defined",
860 "secrets/ text=maybe\n",
861 "unknown attribute",
862 ),
863 (
864 "attributes on a negation",
865 "!secrets/README.md text\n",
866 "cannot carry",
867 ),
868 ];
869
870 for (label, text, fragment) in refused {
871 let error = Config::parse(text)
872 .err()
873 .unwrap_or_else(|| panic!("{label}: this must not parse, but it did"));
874 let message = error.to_string();
875 assert!(
876 message.contains(fragment),
877 "{label}: the refusal must say `{fragment}`, and says: {message}"
878 );
879 assert!(
880 message.contains(CONFIG_FILE),
881 "{label}: the refusal must name the file it is about: {message}"
882 );
883 }
884 }
885
886 /// Every shape the line parser accepts, and what it makes of it.
887 ///
888 /// The awkward half of this table is not decoration. A leading `!` and a
889 /// leading `#` inside quotes are parts of a name, not syntax — both were
890 /// real defects, found on 2026-08-05, that ended in git dropping the
891 /// generated `.gitattributes` line and taking the file's ciphertext with
892 /// it. A trailing space is the case a backslash never really closed,
893 /// because editors strip it; quotes close it.
894 #[test]
895 fn every_shape_the_parser_accepts_means_what_it_says() {
896 // (label, declaration, path, encrypted?, text mode, eol)
897 type Row<'a> = (&'a str, &'a str, &'a [u8], bool, TextMode, Option<EolMode>);
898 let accepted: &[Row] = &[
899 (
900 "a bare pattern",
901 "secrets/\n",
902 b"secrets/db.env",
903 true,
904 TextMode::Auto,
905 None,
906 ),
907 (
908 "a bare pattern with attributes",
909 "secrets/deploy.ps1 text eol=crlf\n",
910 b"secrets/deploy.ps1",
911 true,
912 TextMode::Text,
913 Some(EolMode::Crlf),
914 ),
915 (
916 "a quoted name holding a space",
917 "\"my secrets/\"\n",
918 b"my secrets/db.env",
919 true,
920 TextMode::Auto,
921 None,
922 ),
923 (
924 "a quoted name with attributes",
925 "\"my secrets/*.sh\" text eol=lf\n",
926 b"my secrets/go.sh",
927 true,
928 TextMode::Text,
929 Some(EolMode::Lf),
930 ),
931 (
932 "a name that ends in a space",
933 "\"secrets /\"\n",
934 b"secrets /a.env",
935 true,
936 TextMode::Auto,
937 None,
938 ),
939 (
940 "a leading ! that is part of the name",
941 "\"!odd.env\"\n",
942 b"!odd.env",
943 true,
944 TextMode::Auto,
945 None,
946 ),
947 (
948 "a leading # that is part of the name",
949 "\"#notes.env\"\n",
950 b"#notes.env",
951 true,
952 TextMode::Auto,
953 None,
954 ),
955 (
956 "a quote inside the name",
957 "\"od\\\"d.env\"\n",
958 b"od\"d.env",
959 true,
960 TextMode::Auto,
961 None,
962 ),
963 (
964 "an octal escape spelling a letter",
965 "\"secrets/\\101.env\"\n",
966 b"secrets/A.env",
967 true,
968 TextMode::Auto,
969 None,
970 ),
971 (
972 "binary suppresses the diff driver",
973 "secrets/key.p12 binary\n",
974 b"secrets/key.p12",
975 true,
976 TextMode::Binary,
977 None,
978 ),
979 (
980 "a negation keeps its ! outside the quotes",
981 "\"my secrets/\"\n!\"my secrets/README.md\"\n",
982 b"my secrets/README.md",
983 false,
984 TextMode::Auto,
985 None,
986 ),
987 (
988 "a bare negation still works",
989 "secrets/\n!secrets/README.md\n",
990 b"secrets/README.md",
991 false,
992 TextMode::Auto,
993 None,
994 ),
995 // Open decision 13, settled 2026-08-05. See `MATCHING`.
996 (
997 "a pattern reaches every ASCII spelling of the name",
998 "secrets/\n",
999 b"SEcrets/db.env",
1000 true,
1001 TextMode::Auto,
1002 None,
1003 ),
1004 (
1005 "…attributes come with it",
1006 "secrets/*.sh text\n",
1007 b"SEcrets/Go.SH",
1008 true,
1009 TextMode::Text,
1010 None,
1011 ),
1012 (
1013 "…and so does a negation, or the hole closes on a rename",
1014 "secrets/\n!secrets/README.md\n",
1015 b"SEcrets/README.MD",
1016 false,
1017 TextMode::Auto,
1018 None,
1019 ),
1020 (
1021 "folding stops at ASCII, exactly where git stops",
1022 "\u{142}\u{105}ka/\n",
1023 "\u{141}\u{104}KA/a.txt".as_bytes(),
1024 false,
1025 TextMode::Auto,
1026 None,
1027 ),
1028 ];
1029
1030 for (label, text, path, encrypt, mode, eol) in accepted {
1031 let parsed = Config::parse(text)
1032 .unwrap_or_else(|error| panic!("{label}: this must parse, and says: {error}"));
1033 let decision = parsed.decide(path);
1034 assert_eq!(
1035 decision.encrypt,
1036 *encrypt,
1037 "{label}: `{}` should{} be encrypted",
1038 String::from_utf8_lossy(path),
1039 if *encrypt { "" } else { " not" }
1040 );
1041 if *encrypt {
1042 assert_eq!(decision.text, *mode, "{label}: wrong text mode");
1043 assert_eq!(decision.eol, *eol, "{label}: wrong end-of-line mode");
1044 }
1045 }
1046 }
1047}