Skip to main content

git_xcrypt/commands/
filter.rs

1//! The long-running filter: one process for a whole git operation.
2//!
3//! Registering `filter.git-xcrypt.process` rather than `clean`/`smudge` is not
4//! an optimisation. With the catch-all attribute git hands us every file in the
5//! repository, and a process per file measured 12 105 ms against 596 ms for one
6//! long-running process on the same 2000 files.
7//!
8//! Everything the protocol says goes over `stdout`, which makes the rule from
9//! AGENTS.md absolute here: no `println!` anywhere beneath this module.
10
11use std::io::{Read, Write};
12
13use bstr::ByteSlice as _;
14
15use crate::crypto::key::MasterKey;
16use crate::git::attributes;
17use crate::git::pktline::{self, Packet};
18use crate::git::repo::Repo;
19use crate::rules::decide;
20use crate::rules::declaration::Config;
21use crate::{Error, Result};
22
23/// What the repository can tell the filter, resolved once per process.
24///
25/// Loading this once is the point of the long-running protocol: the
26/// configuration and the key are read on startup, not per file.
27pub struct Context {
28    config: Config,
29    /// Where `config` came from, so an absent one can be looked for again.
30    config_path: std::path::PathBuf,
31    key: Option<MasterKey>,
32    autocrlf: Option<String>,
33    core_eol: Option<String>,
34    /// Where to look for a path's earlier, plain-text self.
35    ///
36    /// `None` means "not tried yet"; `Some(None)` means "tried, nothing to look
37    /// in". Lazy on purpose: a repository that declares nothing must not pay for
38    /// opening the object database on every git operation, and the question is
39    /// only ever asked about a file that is actually being encrypted.
40    head: Option<Option<crate::git::history::HeadLookup>>,
41    /// What [`crate::git::history::HeadLookup::open`] needs, kept so it can be built
42    /// later.
43    location: Option<Location>,
44    /// Git's own attribute stack, built on first use.
45    ///
46    /// Lazy for the same reason as `head`, and it costs more: building it walks
47    /// the working tree for every `.gitattributes` in it. A repository that
48    /// encrypts nothing must never pay for that, so it is built only when a path
49    /// is actually about to be encrypted — and then once, for the whole
50    /// operation, which is what the long-running protocol is for.
51    attributes: Option<attributes::AttributeResolver>,
52    /// Whether the managed section has been checked for staleness yet.
53    ///
54    /// Once per process, and only once something is actually encrypted — a
55    /// repository that stores nothing as ciphertext is not hurt by a stale
56    /// section and must not pay for the answer. Measured at 2.6 ms for the whole
57    /// render-and-compare, which the long-running protocol spends once per git
58    /// operation rather than once per file.
59    section_checked: bool,
60    /// What the `HEAD` lookup answered for each path this process has seen.
61    ///
62    /// Both answers are kept, and the negative one matters more. One process
63    /// serves one git operation and git cleans the same file several times
64    /// within one — measured on git 2.55, a single `git status` filtered one
65    /// path four times. Remembering only the positive answer would fix the
66    /// duplicated warning and leave the *healthy* repository, where the answer
67    /// is always `false`, decompressing that path's `HEAD` blob in full on every
68    /// one of those four calls, for ever. So `true` suppresses the repeat
69    /// message and `false` suppresses the repeat work.
70    answered: std::collections::HashMap<Vec<u8>, bool>,
71}
72
73/// Where this repository's objects, references and attribute sources live.
74struct Location {
75    git_dir: std::path::PathBuf,
76    common_dir: std::path::PathBuf,
77    work_tree: std::path::PathBuf,
78    hash: gix_hash::Kind,
79    /// `core.attributesFile`, the global attribute source.
80    attributes_file: Option<std::path::PathBuf>,
81    /// `core.ignorecase`, which git applies to attribute matching too.
82    ignore_case: bool,
83}
84
85impl Context {
86    /// Gathers everything the filter needs from `repo`.
87    ///
88    /// A missing key is not fatal here: a locked repository still has to check
89    /// out its ciphertext and pass unselected files through.
90    ///
91    /// # Errors
92    ///
93    /// [`Error::Config`] when `.git-xcrypt` cannot be understood — that must
94    /// stop the operation rather than silently encrypt nothing.
95    pub fn load(repo: &Repo) -> Result<Self> {
96        let config_path = repo.xcrypt_config_path();
97        let config = Config::load(&config_path)?;
98        for warning in &config.pointless_eol {
99            eprintln!("git-xcrypt: {warning}");
100        }
101
102        let key = match repo.load_key() {
103            Ok(key) => Some(key),
104            Err(Error::NoKey) => None,
105            Err(err) => return Err(err),
106        };
107
108        // Full precedence, not just `.git/config`: `core.autocrlf` lives in the
109        // user's global file on nearly every machine that sets it at all.
110        //
111        // Both directories, because git splits them: `config` is shared and
112        // comes from the common one — a linked worktree has a git dir of its own
113        // with no `config` in it — while `config.worktree` belongs to *this*
114        // checkout. Reading that one from the common directory handed a linked
115        // worktree the main checkout's settings, and a `core.attributesFile` set
116        // there cost a file at checkout.
117        let git_config = crate::git::config::open_full(repo.git_dir(), repo.common_dir())?;
118        Ok(Self {
119            config,
120            config_path,
121            key,
122            autocrlf: crate::git::config::get(&git_config, "core.autocrlf"),
123            core_eol: crate::git::config::get(&git_config, "core.eol"),
124            head: None,
125            attributes: None,
126            location: Some(Location {
127                git_dir: repo.git_dir().to_path_buf(),
128                // The common directory, not this worktree's: `info/attributes`
129                // is shared by every checkout — see [`attributes::AttributeResolver::new`].
130                common_dir: repo.common_dir().to_path_buf(),
131                work_tree: repo.work_tree().to_path_buf(),
132                hash: crate::git::index::object_hash(
133                    crate::git::config::get(&git_config, "extensions.objectformat").as_deref(),
134                ),
135                // Resolved, not read verbatim — see
136                // [`crate::git::config::global_attributes_file`]. A `text` line in
137                // the global file reached the ciphertext with `git add` exiting
138                // 0 and the file gone at checkout.
139                attributes_file: crate::git::config::global_attributes_file(&git_config),
140                ignore_case: crate::git::config::get(&git_config, "core.ignorecase")
141                    .is_some_and(|value| crate::git::config::is_true(&value)),
142            }),
143            section_checked: false,
144            answered: std::collections::HashMap::new(),
145        })
146    }
147
148    /// Says so, once, when the managed `.gitattributes` section is out of date.
149    ///
150    /// **A warning and never a refusal.** With `required = true` a non-zero exit
151    /// aborts every git operation in the repository, and a stale section is not
152    /// a reason to stop anyone working: nothing is stored in the clear over it.
153    /// What it costs is the `-text` that keeps git's own CRLF conversion off the
154    /// ciphertext of any path some other attribute calls `text` — measured at 34
155    /// `CR` bytes gone from a 2 MB blob, the commit succeeding, and the file
156    /// unrecoverable at checkout. So it is worth a line on `stderr` and nothing
157    /// stronger.
158    ///
159    /// **Nothing is rewritten here, deliberately.** Measured on git 2.55,
160    /// 2026-08-06 with a stand-in filter: git reads `.gitattributes` **once per
161    /// operation**, so a rewrite from this path would take effect only from the
162    /// next command — the very drift the catch-all construction exists to remove,
163    /// moved one invocation along. It would also dirty a tracked file in the
164    /// middle of `git add`, which is not something a command that only adds a
165    /// file may do.
166    ///
167    /// Any shape this build writes counts as current, so a repository that ran
168    /// `sync --global` is not nagged for it.
169    fn warn_if_the_section_is_stale(&mut self) {
170        if self.section_checked {
171            return;
172        }
173        self.section_checked = true;
174        let Some(location) = self.location.as_ref() else {
175            return;
176        };
177        let path = location.work_tree.join(crate::git::repo::ATTRIBUTES_FILE);
178        let current = attributes::ACCEPTED.into_iter().any(|rendering| {
179            let lines = attributes::render_lines(&self.config, rendering);
180            attributes::desired(&path, &lines).is_ok_and(|(existing, wanted)| existing == wanted)
181        });
182        if !current {
183            // An unreadable section answers "not current" above, and that is the
184            // safe direction for a warning: `status` is the gate, and it reports
185            // the same file as a state conflict rather than a note.
186            eprintln!(
187                "git-xcrypt: {} no longer matches {} — run `git-xcrypt sync`. \
188                 Nothing is stored in the clear over this, but the missing \
189                 `-text` lets git convert the ciphertext of any path another \
190                 attribute calls `text`, which costs the file at checkout.",
191                crate::git::repo::ATTRIBUTES_FILE,
192                crate::git::repo::CONFIG_FILE
193            );
194        }
195    }
196
197    /// Says so when normalising this file throws away the working tree's own
198    /// line endings, so the next checkout cannot restore it byte for byte.
199    ///
200    /// This is git's `core.safecrlf`, which we did not have — Open Decision 8,
201    /// closed 2026-08-06. Two shapes reach it, and neither is exotic:
202    ///
203    /// * **mixed `CRLF` and lone `LF`**, under the default `text=auto` as much as
204    ///   under an explicit `text`. The file comes back with every ending the same
205    ///   and `git status` stays **clean**, because the new bytes normalise to the
206    ///   plaintext already stored. Nothing at all signals it today.
207    /// * **`CR` immediately before `CRLF`** under an explicit `text`, which
208    ///   collapses a byte per pass and does show up as modified. Measured on git
209    ///   2.55: git is silent here even with `safecrlf=warn`, because its two
210    ///   counters cannot see a lone `CR`.
211    ///
212    /// **Narrower than git's**, deliberately. `core.safecrlf` asks whether the
213    /// bytes will change, so on a machine with `core.autocrlf=true` it warns
214    /// about every LF-only file; git can afford that because the setting is
215    /// opt-in and defaults to false. This one is always on and has no knob, so it
216    /// asks whether the original is *recoverable* — which leaves a uniform file
217    /// alone whichever ending it uses. A warning that fires on healthy content
218    /// would be worse than none here.
219    ///
220    /// **A warning and never a refusal**, unlike the conversion check above.
221    /// Git's own `safecrlf=true` refuses with `rc=128`, and we cannot: with
222    /// `required = true` that would stop every git operation in the repository
223    /// over content that is converted, not lost. The remedy is inside this tool —
224    /// declaring the path `binary` in `.git-xcrypt` stores it verbatim — so the
225    /// message names it.
226    ///
227    /// Asked on `clean` rather than reported by `status` for the reason the
228    /// conversion refusal is: `status` resolves only paths the index already
229    /// knows, so on a new file it says nothing until the checkout that changes it.
230    fn warn_if_the_round_trip_loses_bytes(
231        &self,
232        path: &[u8],
233        decision: &crate::rules::declaration::Decision,
234        content: &[u8],
235    ) {
236        if crate::rules::eol::normalisation_is_reversible(decision.text, content) {
237            return;
238        }
239        eprintln!(
240            "git-xcrypt: {}: its line endings are mixed, so they do not survive a \
241             round trip — after the next checkout this file will not be \
242             byte-for-byte what it is now. Give it one kind of line ending, or \
243             declare it `binary` in {} to store it verbatim.",
244            path.as_bstr(),
245            crate::git::repo::CONFIG_FILE
246        );
247    }
248
249    /// Says so when a declared `eol=` will not reach this file after all.
250    ///
251    /// `eol=` only ever applies to content the check-in path normalised: the
252    /// header records that in bit 0, and `smudge` leaves on the spot when it is
253    /// clear, without so much as looking at the declaration. Under the default
254    /// `text=auto` that verdict comes from the **content**, so one pattern can
255    /// honour `eol=crlf` for one file and silently ignore it for the next one in
256    /// the same directory. Measured before this existed: `blobs/ eol=crlf` over
257    /// binary content checked out with `LF`, and nothing said a word.
258    ///
259    /// The parser already refuses the shape it *can* see — `-text` or `binary`
260    /// beside an `eol=`, which is a contradiction on the line itself — and that
261    /// warning fires once, at load. This one cannot live there: at parse time
262    /// there is no content to classify. So it is asked here, on the same gate as
263    /// the two warnings above, and only for `TextMode::Auto`; repeating the
264    /// parser's answer per file would be noise on a repository that already got
265    /// the message.
266    ///
267    /// A warning, never a refusal, for the reason every warning on this path is
268    /// one: with `required = true` a non-zero exit stops every git operation in
269    /// the repository, and nothing here is lost — the file is stored verbatim,
270    /// which is the safe direction.
271    fn warn_if_the_declared_eol_will_not_apply(
272        &self,
273        path: &[u8],
274        decision: &crate::rules::declaration::Decision,
275        content: &[u8],
276    ) {
277        use crate::rules::declaration::{EolMode, TextMode};
278
279        let Some(eol) = decision.eol else { return };
280        if decision.text != TextMode::Auto
281            || crate::rules::eol::should_normalise(TextMode::Auto, content)
282        {
283            return;
284        }
285
286        let spelling = match eol {
287            EolMode::Lf => "lf",
288            EolMode::Crlf => "crlf",
289            EolMode::Native => "native",
290        };
291        eprintln!(
292            "git-xcrypt: {}: `eol={spelling}` does not reach this file — its \
293             content reads as binary by git's own rule, so it is stored verbatim \
294             and every checkout writes back exactly those bytes. Declare the path \
295             `text` in {} if it should be converted anyway.",
296            path.as_bstr(),
297            crate::git::repo::CONFIG_FILE
298        );
299    }
300
301    /// Whether `path` needs the "already in `HEAD` in the clear" warning.
302    ///
303    /// Built on first use and kept for the rest of the process, which is what the
304    /// long-running protocol makes worth doing: one `git add -A` asks this once
305    /// per newly encrypted file, over the same trees.
306    ///
307    /// **A path already answered gets `false`, whatever the answer was.** Both
308    /// halves of that are deliberate: a repeat `true` would print the same
309    /// message a second time in the same git operation, and a repeat `false`
310    /// would re-walk the trees and decompress the `HEAD` blob again to reach the
311    /// same conclusion.
312    fn head_holds_in_the_clear(&mut self, path: &[u8]) -> bool {
313        if self.answered.contains_key(path) {
314            return false;
315        }
316        if self.head.is_none() {
317            let opened = self.location.as_ref().and_then(|location| {
318                crate::git::history::HeadLookup::open(
319                    &location.git_dir,
320                    &location.common_dir,
321                    location.hash,
322                )
323            });
324            self.head = Some(opened);
325        }
326        let found = self
327            .head
328            .as_mut()
329            .and_then(Option::as_mut)
330            .is_some_and(|head| head.holds_in_the_clear(path));
331        self.answered.insert(path.to_vec(), found);
332        found
333    }
334
335    /// Whether git would run **its own** line-ending conversion over the
336    /// ciphertext this filter is about to hand back, and which line decides it.
337    ///
338    /// Git's order on the check-in side is `clean` → blob → git's conversion, so
339    /// the conversion lands on the filter's *output*. On a path where some other
340    /// attribute source resolves `text` to `set`, or leaves it unspecified while
341    /// a bare `eol=` is in force, that eats the `CR` bytes out of a ciphertext:
342    /// measured on git 2.55, 32 bytes gone from a 2 MB blob, `git add` and
343    /// `git commit` both exit 0, and the checkout fails the authentication tag
344    /// and leaves no file at all.
345    ///
346    /// Asked of git's own attribute stack, never of the managed section alone:
347    /// the managed `-text` is one line among many and git takes the last match,
348    /// so the only thing that answers this is a full resolution.
349    fn ciphertext_would_be_converted(&mut self, path: &[u8]) -> Option<attributes::Culprit> {
350        match self.attribute_stack()?.resolve(path).conversion {
351            attributes::EolConversion::On(culprit) => Some(culprit),
352            attributes::EolConversion::Off => None,
353        }
354    }
355
356    /// Git's attribute stack, built on first use and kept for the process.
357    ///
358    /// `None` only when there is no repository behind this process, which is the
359    /// unit-test shape; every caller then skips its check rather than guessing.
360    fn attribute_stack(&mut self) -> Option<&mut attributes::AttributeResolver> {
361        if self.attributes.is_none() {
362            let location = self.location.as_ref()?;
363            // The index copies git falls back to for a deleted `.gitattributes`
364            // — without them the refusal went blind the moment the user deleted
365            // the file the refusal itself told them to edit, and git converted
366            // the ciphertext with exit 0. Measured; see `staged_fallbacks`.
367            let staged = attributes::staged_fallbacks(
368                &location.work_tree,
369                &location.git_dir.join("index"),
370                &location.common_dir,
371                location.hash,
372                location.ignore_case,
373            );
374            let resolver = attributes::AttributeResolver::new(
375                &location.work_tree,
376                &location.common_dir,
377                location.attributes_file.as_deref(),
378                location.ignore_case,
379                staged,
380            );
381            self.attributes = Some(resolver);
382        }
383        self.attributes.as_mut()
384    }
385
386    /// Turns "the file has been altered" into the truth when **git** altered it.
387    ///
388    /// The check-in side refuses before anything is stored; this is the other end
389    /// of the same mistake, on a repository where the line arrived after the
390    /// commit. Git's check-out order is blob, then git's own conversion, then
391    /// smudge, so a `text` line outranking the managed `-text` hands the
392    /// authentication tag bytes that were never stored. Measured on git 2.55 with
393    /// a filter that copied its stdin aside: a 4118-byte blob holding 18 lone
394    /// `LF` and no `CRLF` arrived as 4136 bytes holding 18 `CRLF` and no lone
395    /// `LF`. The tag is right to refuse that — but the blob is intact to the
396    /// byte, and `the file has been altered` reads as "your repository is corrupt
397    /// and the data is gone".
398    ///
399    /// **Asked only after the tag has already failed**, which is what makes it
400    /// free. The smudge path runs for every file of every checkout and every
401    /// clone, so a question asked before the failure would be paid for by every
402    /// healthy repository; a failed tag is rare enough that building the whole
403    /// attribute stack here costs nothing measurable.
404    ///
405    /// Three things have to agree before this claims anything, and the cheapest
406    /// is asked first so the stack is built only for content that already looks
407    /// converted:
408    ///
409    /// 1. the bytes carry the shape git's expansion leaves behind;
410    /// 2. git's attribute stack really does convert this path;
411    /// 3. git's check-out direction on **this machine** is `CRLF`.
412    ///
413    /// What it deliberately does not do is prove the stored blob would decrypt.
414    /// It cannot: the expansion is not invertible, since an output `CRLF` may
415    /// have come from a stored `CRLF` or from a stored lone `LF`. The residual
416    /// case is a ciphertext damaged by this same conversion running on the way
417    /// *in*, under a build older than 2026-08-05, in a repository that still
418    /// carries the line. Two things keep it honest: nothing this message claims
419    /// is false there either — a checkout only reads — and the state is
420    /// self-correcting, because once the line is gone the predicate stops firing
421    /// and the very next checkout says `the file has been altered` after all.
422    fn conversion_explains_a_failed_tag(
423        &mut self,
424        path: &[u8],
425        content: &[u8],
426    ) -> Option<attributes::Culprit> {
427        if !bears_the_mark_of_an_expansion(content) {
428            return None;
429        }
430
431        let resolved = self.attribute_stack()?.resolve(path);
432        resolved
433            .expands_on_checkout(self.autocrlf.as_deref(), self.core_eol.as_deref())
434            .cloned()
435    }
436
437    /// Replaces a smudge failure's message when git's conversion explains it.
438    ///
439    /// Only [`Error::Crypto`], which on this path is the authentication tag and
440    /// nothing else: [`Error::Format`] is a header this build cannot read and
441    /// [`Error::KeyMismatch`] is another key's file, and no `.gitattributes` line
442    /// can cause either. Re-explaining those would be the same lie in the other
443    /// direction.
444    fn explain_a_failed_smudge(&mut self, path: &[u8], content: &[u8], err: Error) -> Error {
445        if !matches!(err, Error::Crypto(_)) {
446            return err;
447        }
448        match self.conversion_explains_a_failed_tag(path, content) {
449            Some(culprit) => self.report_conversion_at_checkout(&culprit),
450            None => err,
451        }
452    }
453
454    /// The message a checkout git converted gets instead of "altered".
455    ///
456    /// Three things it has to carry, in this order, because they are the three a
457    /// reader is missing: that nothing was lost, which line did it, and what to
458    /// do. The first is the load-bearing one — everything the user can see says
459    /// the opposite.
460    fn report_conversion_at_checkout(&self, culprit: &attributes::Culprit) -> Error {
461        Error::Config(format!(
462            "git rewrote this file's line endings on the way out of the object \
463             database, before this filter saw a byte of it, because this line \
464             outranks the managed `-text`:\n  {}\nGit's check-out order is blob, \
465             then git's own conversion, then smudge, so what reached the \
466             authentication tag is not what was stored: every lone `LF` in the \
467             ciphertext arrived here as `CRLF`. Refusing that is correct.\n\
468             Nothing is lost. A checkout only reads: the object database still \
469             holds exactly the blob that was committed, this command changed \
470             nothing on disk, and no key and no history were touched — what \
471             failed is a copy made in flight.\nDelete or narrow that line so the \
472             managed `-text` wins, run `git-xcrypt sync`, and check this file out \
473             again. If it still fails once that line is gone, the stored \
474             ciphertext itself was altered and `git-xcrypt status` will name it",
475            self.spell(culprit)
476        ))
477    }
478
479    /// Turns that answer into the refusal git aborts the operation with.
480    ///
481    /// A refusal nobody can act on is only half of one: the stack has four
482    /// levels, and the one that outranks the rest — `$GIT_DIR/info/attributes` —
483    /// is not versioned and cannot be seen in a pull request at all. So the
484    /// message names the file, the line, the pattern and the assignment, spelled
485    /// relative to the working tree where there is one.
486    fn refuse_conversion(&self, culprit: &attributes::Culprit) -> Error {
487        let shown = self.spell(culprit);
488
489        Error::Config(format!(
490            "git would convert this path's line endings itself, because this line \
491             outranks the managed `-text`:\n  {shown}\nThat conversion runs over \
492             the **ciphertext** this filter produces, not over the plain text: it \
493             eats the `CR` bytes out of it, `git add` and `git commit` both exit 0, \
494             and the next checkout fails the authentication tag and leaves no file \
495             at all — measured on git 2.55, 32 bytes gone from a 2 MB blob, \
496             unrecoverable with any key. Nothing has been stored, so nothing is \
497             lost. Delete or narrow that line so the managed `-text` wins, run \
498             `git-xcrypt sync`, and try again"
499        ))
500    }
501
502    /// Names an attribute line relative to the working tree, where there is one.
503    ///
504    /// A refusal nobody can act on is only half of one, and the level that
505    /// outranks the rest — `$GIT_DIR/info/attributes` — is not versioned and
506    /// cannot be seen in a pull request at all. Both directions print it the same
507    /// way on purpose: the reader is looking for one line, not for two dialects.
508    fn spell(&self, culprit: &attributes::Culprit) -> String {
509        match (&culprit.source, self.location.as_ref()) {
510            (Some(source), Some(location)) => attributes::Culprit {
511                source: Some(
512                    source
513                        .strip_prefix(&location.work_tree)
514                        .unwrap_or(source)
515                        .to_path_buf(),
516                ),
517                ..culprit.clone()
518            }
519            .to_string(),
520            _ => culprit.to_string(),
521        }
522    }
523
524    /// Looks for `.git-xcrypt` again if it was absent when the process started.
525    ///
526    /// One process serves a whole git operation, and a `git checkout` that
527    /// restores `.git-xcrypt` filters other files in the same run. Latching the
528    /// absence would make the check-in refusal outlive its own cause and leave
529    /// the repository unrepairable from inside git.
530    ///
531    /// # Errors
532    ///
533    /// [`Error::Config`] when the file has reappeared but cannot be understood.
534    fn refresh_config_if_absent(&mut self) -> Result<()> {
535        if !self.config.missing {
536            return Ok(());
537        }
538
539        let reloaded = Config::load(&self.config_path)?;
540        if !reloaded.missing {
541            for warning in &reloaded.pointless_eol {
542                eprintln!("git-xcrypt: {warning}");
543            }
544            self.config = reloaded;
545        }
546        Ok(())
547    }
548}
549
550/// Whether `content` carries the shape git's check-out conversion leaves behind.
551///
552/// Git's `crlf_to_worktree` rewrites every **lone** `LF` as `CRLF` and leaves an
553/// existing `CRLF` alone, so its output has no lone `LF` left anywhere. That
554/// makes the fingerprint exact in both halves:
555///
556/// * **no lone `LF`** — bytes git expanded cannot contain one. The damage the
557///   *other* direction does is excluded by the same clause: conversion on the way
558///   in strips `CR`, so a blob it broke is full of lone `LF`, and after a
559///   round trip through this clause it is correctly called altered.
560/// * **at least one `CRLF`** — with nothing to expand, git hands the blob over
561///   untouched and a failing tag is the file's own doing, not git's.
562///
563/// Measured on git 2.55, 2026-08-05, with a filter that copied its stdin aside: a
564/// 4118-byte blob holding 18 lone `LF` and no `CRLF` arrived as 4136 bytes
565/// holding 18 `CRLF` and no lone `LF`.
566///
567/// One pass, no allocation, and only ever on a path whose tag has already failed.
568fn bears_the_mark_of_an_expansion(content: &[u8]) -> bool {
569    let mut expanded = false;
570    for (index, &byte) in content.iter().enumerate() {
571        if byte == b'\n' {
572            if index == 0 || content[index - 1] != b'\r' {
573                return false;
574            }
575            expanded = true;
576        }
577    }
578    expanded
579}
580
581/// Runs the protocol to completion on the given streams.
582///
583/// # Errors
584///
585/// [`Error::Io`] or [`Error::Format`] when the handshake itself fails. A failure
586/// on a single file is reported to git as `status=error` instead, which with
587/// `required = true` aborts the operation.
588pub fn run(context: &mut Context, input: &mut impl Read, output: &mut impl Write) -> Result<()> {
589    handshake(input, output)?;
590    negotiate_capabilities(input, output)?;
591
592    loop {
593        let Some(request) = read_request(input)? else {
594            return Ok(());
595        };
596        serve(context, &request, output)?;
597    }
598}
599
600/// One `command=` / `pathname=` pair and the content that followed it.
601struct Request {
602    command: String,
603    /// Kept as bytes: on Unix a path is an arbitrary byte string, and decoding
604    /// it lossily would hand the decision a name the file does not have.
605    pathname: Vec<u8>,
606    content: Vec<u8>,
607}
608
609/// Agrees on the protocol version.
610fn handshake(input: &mut impl Read, output: &mut impl Write) -> Result<()> {
611    let greeting = pktline::read_until_flush(input)?;
612    let announces_version_2 = greeting
613        .iter()
614        .any(|item| item.as_slice() == b"version=2\n");
615    if !announces_version_2 {
616        return Err(Error::Format(
617            "git asked for a filter protocol version this build does not speak".into(),
618        ));
619    }
620
621    pktline::write_data(output, b"git-filter-server\n")?;
622    pktline::write_data(output, b"version=2\n")?;
623    pktline::write_flush(output)?;
624    Ok(())
625}
626
627/// Tells git which operations we handle.
628fn negotiate_capabilities(input: &mut impl Read, output: &mut impl Write) -> Result<()> {
629    let _offered = pktline::read_until_flush(input)?;
630    pktline::write_data(output, b"capability=clean\n")?;
631    pktline::write_data(output, b"capability=smudge\n")?;
632    pktline::write_flush(output)?;
633    Ok(())
634}
635
636/// Reads one request, or `None` when git closed the stream.
637fn read_request(input: &mut impl Read) -> Result<Option<Request>> {
638    let mut command = None;
639    let mut pathname = None;
640
641    loop {
642        match pktline::read_packet(input) {
643            Ok(Packet::Flush) => break,
644            Ok(Packet::Data(payload)) => {
645                // Exactly the one terminating newline comes off, never
646                // `trim_end`: a filename may legally end in a space, and
647                // trimming it matches the file under a different name — which
648                // on the check-in side means storing a secret in the clear.
649                let value = payload.strip_suffix(b"\n").unwrap_or(&payload);
650                if let Some(rest) = value.strip_prefix(b"command=") {
651                    command = Some(String::from_utf8_lossy(rest).into_owned());
652                } else if let Some(rest) = value.strip_prefix(b"pathname=") {
653                    pathname = Some(rest.to_vec());
654                }
655            }
656            // git closes the stream when the operation is over, which arrives
657            // as an unexpected end of file rather than as a message. Only a
658            // clean boundary counts: an end of file *after* a field has arrived
659            // is a truncated request, and treating it as a shutdown would exit
660            // zero having answered nothing — which tells git the file was
661            // handled. `read_content` already treats the same condition as an
662            // error, so this keeps the two halves in step.
663            Err(Error::Io(err))
664                if err.kind() == std::io::ErrorKind::UnexpectedEof
665                    && command.is_none()
666                    && pathname.is_none() =>
667            {
668                return Ok(None);
669            }
670            Err(err) => return Err(err),
671        }
672    }
673
674    let (command, pathname) = match (command, pathname) {
675        (Some(command), Some(pathname)) => (command, pathname),
676        // Neither field is how a clean shutdown looks; one of the two is a
677        // request we did not understand, and answering nothing while exiting
678        // zero would tell git the file was handled.
679        (None, None) => return Ok(None),
680        (command, _) => {
681            return Err(Error::Format(format!(
682                "git sent an incomplete filter request (command={}, pathname missing or absent)",
683                command.as_deref().unwrap_or("<none>")
684            )));
685        }
686    };
687
688    Ok(Some(Request {
689        command,
690        pathname,
691        content: pktline::read_content(input)?,
692    }))
693}
694
695/// Answers one request.
696fn serve(context: &mut Context, request: &Request, output: &mut impl Write) -> Result<()> {
697    let mut first_encryption = None;
698    let outcome = match request.command.as_str() {
699        "clean" => context.refresh_config_if_absent().and_then(|()| {
700            // Gated before anything is looked up. The catch-all attribute sends
701            // every file in the repository through here, so an ungated check
702            // would open the object database for a repository that encrypts
703            // nothing, and would ask about files no pattern selects — the same
704            // shape of mistake that once turned a 301-file checkout into 301
705            // warnings. Content that already carries our magic is not a first
706            // encryption either: that is a re-add of something already stored.
707            let decision = context.config.decide(&request.pathname);
708            let stored_as_ciphertext =
709                !crate::rules::declaration::is_never_encrypted(&request.pathname)
710                    && decision.encrypt;
711
712            // Before the encryption, not after it, and unlike the warning below
713            // this one *does* refuse. Git converts the filter's output, so what
714            // this repository is one `git add` away from is not a leaked secret
715            // but a blob nobody can ever decrypt again. At this instant nothing
716            // is damaged yet: with `required = true` a `status=error` costs a
717            // refused `git add`, which is the cheapest outcome on offer. The
718            // question is asked only of a path that is genuinely about to become
719            // ciphertext — a path stored in the clear is git's to convert, and
720            // refusing over that would be an outage in a healthy repository.
721            if stored_as_ciphertext
722                && let Some(culprit) = context.ciphertext_would_be_converted(&request.pathname)
723            {
724                return Err(context.refuse_conversion(&culprit));
725            }
726
727            if stored_as_ciphertext {
728                // Here rather than at start-up: a repository that encrypts
729                // nothing must not pay for an answer it cannot act on.
730                context.warn_if_the_section_is_stale();
731            }
732            if stored_as_ciphertext && !crate::crypto::format::looks_encrypted(&request.content) {
733                // Both of these are about content on its way from plaintext to
734                // ciphertext. Content that already carries our magic is a re-add
735                // of something stored, and asking either question about
736                // ciphertext would be nonsense — worse than nonsense for the
737                // line endings, since an explicit `text` normalises whatever it
738                // is handed and would report a locked repository's own blobs.
739                first_encryption = Some(request.pathname.clone());
740                context.warn_if_the_round_trip_loses_bytes(
741                    &request.pathname,
742                    &decision,
743                    &request.content,
744                );
745                context.warn_if_the_declared_eol_will_not_apply(
746                    &request.pathname,
747                    &decision,
748                    &request.content,
749                );
750            }
751            decide::clean(
752                context.key.as_ref(),
753                &context.config,
754                &request.pathname,
755                &request.content,
756            )
757        }),
758        "smudge" => {
759            let decision = context.config.decide(&request.pathname);
760            // The failure is where the diagnosis happens, and nowhere earlier:
761            // this path runs for every file of every checkout and every clone,
762            // so a healthy repository must not pay a byte for it.
763            decide::smudge(
764                context.key.as_ref(),
765                &request.pathname,
766                &request.content,
767                decision.encrypt,
768                decision.eol,
769                context.autocrlf.as_deref(),
770                context.core_eol.as_deref(),
771            )
772            .map_err(|err| {
773                context.explain_a_failed_smudge(&request.pathname, &request.content, err)
774            })
775        }
776        other => Err(Error::Format(format!(
777            "git asked for the unknown filter command `{other}`"
778        ))),
779    };
780
781    match outcome {
782        Ok(outcome) => {
783            if let Some(warning) = outcome.warning {
784                eprintln!("git-xcrypt: {warning}");
785            }
786            // After the encryption succeeded, and never as a reason to fail it.
787            // With `required = true` a non-zero exit would abort the whole
788            // operation, and this is news about the past, not a refusal of the
789            // present — so it is a line on `stderr` and nothing more.
790            if let Some(path) = first_encryption
791                && context.head_holds_in_the_clear(&path)
792            {
793                eprintln!(
794                    "git-xcrypt: {}: this is the first time it is being encrypted, \
795                     and HEAD already holds it in the clear. The plain text stays in \
796                     history; run `git-xcrypt status` to see what is exposed, and \
797                     rotate the secret if it was ever pushed.",
798                    path.as_bstr()
799                );
800            }
801            pktline::write_data(output, b"status=success\n")?;
802            pktline::write_flush(output)?;
803            pktline::write_data(output, &outcome.content)?;
804            pktline::write_flush(output)?;
805            pktline::write_flush(output)?;
806        }
807        Err(err) => {
808            // With `required = true` this aborts the whole git operation, which
809            // is the point: better a refused commit than a leaked secret.
810            eprintln!("git-xcrypt: {}: {err}", request.pathname.as_bstr());
811            pktline::write_data(output, b"status=error\n")?;
812            pktline::write_flush(output)?;
813        }
814    }
815
816    Ok(())
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822    use crate::crypto::key::MASTER_KEY_LEN;
823    use crate::git::pktline::{write_data, write_flush};
824
825    fn context() -> Context {
826        Context {
827            config: Config::parse("*.env\n").expect("test config"),
828            config_path: std::path::PathBuf::from(".git-xcrypt"),
829            key: Some(MasterKey::from_bytes([11u8; MASTER_KEY_LEN])),
830            autocrlf: None,
831            core_eol: None,
832            // No repository behind these unit tests, so there is nothing for the
833            // first-encryption warning to look in; `head_holds_in_the_clear`
834            // answers `false` and the protocol is exercised on its own. The
835            // conversion check needs `location` for the same reason and skips
836            // itself without it — what it does when there *is* a repository is
837            // a question only a real git can answer, and
838            // `tests/attributes.rs` asks it there.
839            head: Some(None),
840            attributes: None,
841            location: None,
842            section_checked: true,
843            answered: std::collections::HashMap::new(),
844        }
845    }
846
847    /// Builds the byte stream git would send for one request.
848    fn conversation(command: &str, pathname: &str, content: &[u8]) -> Vec<u8> {
849        let mut buffer = Vec::new();
850        write_data(&mut buffer, b"git-filter-client\n").expect("writing");
851        write_data(&mut buffer, b"version=2\n").expect("writing");
852        write_flush(&mut buffer).expect("writing");
853        write_data(&mut buffer, b"capability=clean\n").expect("writing");
854        write_data(&mut buffer, b"capability=smudge\n").expect("writing");
855        write_flush(&mut buffer).expect("writing");
856        write_data(&mut buffer, format!("command={command}\n").as_bytes()).expect("writing");
857        write_data(&mut buffer, format!("pathname={pathname}\n").as_bytes()).expect("writing");
858        write_flush(&mut buffer).expect("writing");
859        write_data(&mut buffer, content).expect("writing");
860        write_flush(&mut buffer).expect("writing");
861        buffer
862    }
863
864    /// Pulls the payload of the reply that follows `status=success`.
865    fn reply_content(reply: &[u8]) -> Vec<u8> {
866        let mut cursor = reply;
867        // server greeting, capabilities, then the status list.
868        pktline::read_until_flush(&mut cursor).expect("greeting");
869        pktline::read_until_flush(&mut cursor).expect("capabilities");
870        let status = pktline::read_until_flush(&mut cursor).expect("status");
871        assert_eq!(
872            status[0], b"status=success\n",
873            "the filter reported a failure"
874        );
875        pktline::read_content(&mut cursor).expect("content")
876    }
877
878    #[test]
879    fn a_pathname_keeps_the_space_it_legally_ends_in() {
880        // `trim_end()` here once matched a file against a pattern it does not
881        // match, and in the pass-through direction that stores a secret in the
882        // clear. The integration test that guards the same rule builds the file
883        // on disk, which Windows cannot do — NTFS strips a trailing space from
884        // a name — so this drives the request parser directly instead, and runs
885        // everywhere. `a.env ` is not `a.env`: the declaration is `*.env`, so a
886        // parser that trims hands back ciphertext and one that does not hands
887        // back the bytes it was given.
888        let mut reply = Vec::new();
889        let input = conversation("clean", "a.env ", b"secret\n");
890        run(&mut context(), &mut input.as_slice(), &mut reply).expect("the protocol must complete");
891        assert_eq!(
892            reply_content(&reply),
893            b"secret\n",
894            "the trailing space was trimmed, so the file matched `*.env` under a \
895             name it does not have"
896        );
897    }
898}