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}