Skip to main content

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}