Skip to main content

git_xcrypt/rules/
decide.rs

1//! The one place where "encrypt this or pass it through" is decided.
2//!
3//! With the catch-all attribute the filter is handed **every** file in the
4//! repository, so pass-through has to be byte-identical for arbitrary input. A
5//! bug here corrupts the whole project, not just the secrets — which is why
6//! `passthrough(x) == x` is a property test rather than a nicety.
7
8use crate::crypto::cipher;
9use crate::crypto::format::{FLAG_LF_NORMALIZED, Header, looks_encrypted};
10use crate::crypto::key::MasterKey;
11use crate::rules::declaration::{self, Config, EolMode};
12use crate::rules::eol;
13use crate::{Error, Result};
14
15use bstr::ByteSlice as _;
16
17/// The result of filtering one file.
18pub struct Outcome {
19    /// The bytes to hand back to git.
20    pub content: Vec<u8>,
21    /// A message for `stderr`, if the caller should say something.
22    ///
23    /// Returned rather than printed so the decision stays testable and so
24    /// nothing on this path can reach `stdout` by accident.
25    pub warning: Option<String>,
26}
27
28/// Reports the size of the content, never the content itself.
29///
30/// A derived `Debug` would put file bytes into any `assert!` message that
31/// mentions an `Outcome` — and a failing test prints to a CI log. "Secrets never
32/// reach the repository, tests and examples included" covers that too.
33impl std::fmt::Debug for Outcome {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        f.debug_struct("Outcome")
36            .field("content", &format_args!("<{} bytes>", self.content.len()))
37            .field("warning", &self.warning)
38            .finish()
39    }
40}
41
42impl Outcome {
43    /// Content with nothing to report.
44    fn plain(content: Vec<u8>) -> Self {
45        Self {
46            content,
47            warning: None,
48        }
49    }
50}
51
52/// The check-in direction: working tree → object database.
53///
54/// Never passes plaintext through for a selected path. Content that already
55/// carries our magic and our key is handed back unchanged, which determinism
56/// makes exactly equal to re-encrypting it.
57///
58/// A pure function of `(key, config, path, content)`, and kept that way. S-06
59/// wanted a warning here — "this path is already in `HEAD` in the clear" — and
60/// it lives in [`crate::commands::filter`] instead, because answering it needs the object
61/// database and a repository handle, which neither this signature nor `lock`,
62/// the other caller, has any business carrying. `lock` depends on this function
63/// producing exactly the bytes git stores; the fewer things it can reach, the
64/// longer that stays true.
65///
66/// # Errors
67///
68/// [`Error::NoKey`] when a path needs encrypting and no key is present,
69/// [`Error::KeyMismatch`] or [`Error::Format`] for content belonging to another
70/// key or another version.
71pub fn clean(
72    key: Option<&MasterKey>,
73    config: &Config,
74    path: &[u8],
75    content: &[u8],
76) -> Result<Outcome> {
77    if declaration::is_never_encrypted(path) {
78        // Checked ahead of everything, including the refusal below: these are
79        // the files a user needs in order to repair the very state that refusal
80        // reports, so they must always be committable.
81        return Ok(Outcome::plain(content.to_vec()));
82    }
83
84    if config.missing {
85        // Without the declaration we cannot tell a secret from a readme, and
86        // "encrypt nothing" is the one answer that loses a secret for good. One
87        // `rm .git-xcrypt` must not be all it takes.
88        return Err(Error::Config(format!(
89            "{}: the file that says what to encrypt is missing, so nothing can be \
90             added safely; restore it from the repository or run `git-xcrypt init`",
91            crate::git::repo::CONFIG_FILE
92        )));
93    }
94
95    let decision = config.decide(path);
96    if !decision.encrypt {
97        return Ok(Outcome::plain(content.to_vec()));
98    }
99
100    if looks_encrypted(content) {
101        return already_encrypted(key, path, content);
102    }
103
104    let key = key.ok_or(Error::NoKey)?;
105    let normalise = eol::should_normalise(decision.text, content);
106
107    let (flags, plaintext) = if normalise {
108        (FLAG_LF_NORMALIZED, eol::normalise_to_lf(content))
109    } else {
110        (0, content.to_vec())
111    };
112
113    Ok(Outcome::plain(cipher::encrypt(key, flags, &plaintext)?))
114}
115
116/// Content that is already encrypted, arriving on the check-in path.
117///
118/// This is the locked-repository and re-add case. Handing it back unchanged is
119/// safe *only* when it is ours **and intact**: determinism means re-encrypting
120/// its plaintext would produce these very bytes, but that argument only holds
121/// for bytes the tag vouches for. Matching the `key_id` alone is not enough —
122/// `key_id` sits in the header, where anyone can write it — so the tag is
123/// verified here too. Anything else has to stop, or a corrupted blob, or one
124/// belonging to a key we do not hold, would be silently adopted.
125fn already_encrypted(key: Option<&MasterKey>, path: &[u8], content: &[u8]) -> Result<Outcome> {
126    let header = Header::parse(content)?;
127    let Some(key) = key else {
128        // Without a key we cannot prove it is ours, but we also cannot damage
129        // it: the bytes are already ciphertext. Passing them through keeps a
130        // locked repository usable.
131        return Ok(Outcome {
132            content: content.to_vec(),
133            warning: Some(format!(
134                "{}: already encrypted and no key is loaded; storing it unchanged",
135                path.as_bstr()
136            )),
137        });
138    };
139
140    if header.key_id != key.key_id() {
141        return Err(Error::KeyMismatch {
142            wanted: header.key_id,
143            have: key.key_id(),
144        });
145    }
146
147    // The plaintext is dropped immediately; only the verdict matters. Wrapped
148    // so the copy it makes does not outlive this line on the heap.
149    drop(zeroize::Zeroizing::new(cipher::decrypt(key, content)?.1));
150
151    Ok(Outcome::plain(content.to_vec()))
152}
153
154/// The check-out direction: object database → working tree.
155///
156/// Decides from the file's own header, never from `.git-xcrypt`. Git does not
157/// promise to write `.git-xcrypt` before the files it filters, so reading the
158/// declaration here would be a race; and a file that was never normalised must
159/// never receive a conversion its content did not go through.
160///
161/// Content that is ours and a repository that holds no key are **not** an
162/// error: the stored bytes are handed back with a warning, which is what keeps
163/// a repository closed by `lock` able to check its own files out. See the arm
164/// itself for what that costs and why it risks nothing.
165///
166/// # Errors
167///
168/// The errors [`cipher::decrypt`] reports — a header this build cannot read,
169/// another key's file, or a failed authentication tag.
170pub fn smudge(
171    key: Option<&MasterKey>,
172    path: &[u8],
173    content: &[u8],
174    selected: bool,
175    declared_eol: Option<EolMode>,
176    autocrlf: Option<&str>,
177    core_eol: Option<&str>,
178) -> Result<Outcome> {
179    if !looks_encrypted(content) {
180        // Committed before the pattern existed. Refusing would make checking out
181        // old history impossible, and plaintext in the working tree is where
182        // plaintext belongs — so this passes through, loudly.
183        //
184        // Loudly only for a path the declaration actually selects, though. The
185        // catch-all attribute sends every file in the repository through here,
186        // so warning unconditionally buries the one message that means something
187        // under one per ordinary file — and a fresh clone becomes a wall of
188        // "whether it leaked". The case this warning exists for is narrow: a
189        // *selected* path found in the clear.
190        let warning = selected.then(|| {
191            format!(
192                "{}: stored in the clear, so it is checked out unchanged; \
193                 run `git-xcrypt status` to see whether it leaked",
194                path.as_bstr()
195            )
196        });
197        return Ok(Outcome {
198            content: content.to_vec(),
199            warning,
200        });
201    }
202
203    let Some(key) = key else {
204        // A locked repository, and the mirror of [`already_encrypted`] on the
205        // check-in side — same state, same answer, and for the same reason:
206        // handing the stored bytes back is what keeps a locked repository
207        // usable. `lock` deliberately leaves the filter registered and
208        // `required = true` set, because that is what turns a `git add` of a
209        // new secret into a refusal instead of a stored plaintext; the cost of
210        // erroring *here* was that the same flag aborted every checkout.
211        //
212        // Measured on git 2.55 before this returned: in a repository closed by
213        // `lock`, `git checkout <branch>` and `git checkout -- <path>` alike
214        // exited **128**, and since git removes the old file before it calls
215        // the filter, the declared file was gone from the working tree — with
216        // `git reset --hard` failing the same way, so nothing could put it
217        // back without the key that had just been deleted.
218        //
219        // Nothing is risked by passing them through. These bytes are
220        // ciphertext, so this writes no plaintext anywhere; they are the bytes
221        // `lock` itself left in the working tree, so `git status` stays clean;
222        // and the next `clean` hands the same bytes back unchanged. This is
223        // also exactly what a clone with no filter registered receives.
224        //
225        // **Only a key file that is not there gets here.** `Context::load`
226        // turns [`Error::NoKey`] — which `keyfile::read` reports for a missing
227        // file and nothing else — into `None`, while an unreadable or corrupt
228        // one fails the whole filter process. So this cannot become a silent
229        // pass-through for a repository that does have a key.
230        return Ok(Outcome {
231            content: content.to_vec(),
232            warning: Some(format!(
233                "{}: encrypted, and this repository holds no key, so it is \
234                 written out as it is stored; `git-xcrypt unlock <key-file>` \
235                 opens it",
236                path.as_bstr()
237            )),
238        });
239    };
240    let (flags, plaintext) = cipher::decrypt(key, content)?;
241
242    if flags & FLAG_LF_NORMALIZED == 0 {
243        return Ok(Outcome::plain(plaintext));
244    }
245
246    let mode = eol::resolve_output(declared_eol, autocrlf, core_eol);
247    Ok(Outcome::plain(eol::apply(&plaintext, mode)))
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::crypto::key::MASTER_KEY_LEN;
254
255    fn key() -> MasterKey {
256        MasterKey::from_bytes([5u8; MASTER_KEY_LEN])
257    }
258
259    fn config() -> Config {
260        Config::parse("secrets/\n*.env\n!secrets/README.md\n").expect("test config")
261    }
262
263    /// The property the whole catch-all construction rests on.
264    #[test]
265    fn pass_through_is_byte_identical_for_arbitrary_content() {
266        let config = config();
267        let samples: Vec<Vec<u8>> = vec![
268            Vec::new(),
269            b"x".to_vec(),
270            b"plain text\r\nwith crlf\r\n".to_vec(),
271            (0u8..=255).collect(),
272            (0u8..=255).cycle().take(100_000).collect(),
273            vec![0u8; 4096],
274            // The full 11-byte magic, so the sample really wears it: an earlier
275            // spelling was ten bytes — one short — and covered nothing, since
276            // `looks_encrypted` said no. What this pins is that an *unselected*
277            // path passes through before the magic is even consulted.
278            b"\0GITXCRYPT\0not actually one of ours".to_vec(),
279        ];
280
281        for content in samples {
282            for path in [&b"README.md"[..], b"src/main.rs", b"secrets/README.md"] {
283                let outcome = clean(Some(&key()), &config, path, &content)
284                    .expect("an unselected path must never fail");
285                assert_eq!(
286                    outcome.content,
287                    content,
288                    "{} was altered on its way into the object database",
289                    path.as_bstr()
290                );
291            }
292        }
293    }
294
295    proptest::proptest! {
296        // `AGENTS.md` calls `passthrough(x) == x` a property test rather than a
297        // nicety, and `zalozenia.md` §Konstrukcja catch-all asks for it over
298        // *arbitrary* bytes: with the catch-all attribute every file in the
299        // repository comes through here, so the blast radius of a bug is the
300        // whole project. The listed shapes above stay; this covers what a list
301        // cannot.
302        #![proptest_config(proptest::prelude::ProptestConfig::with_cases(256))]
303
304        #[test]
305        fn an_unselected_path_is_handed_back_byte_for_byte(
306            content in proptest::collection::vec(proptest::num::u8::ANY, 0..16384),
307            path in proptest::prelude::prop_oneof![
308                proptest::prelude::Just(&b"README.md"[..]),
309                proptest::prelude::Just(&b"src/main.rs"[..]),
310                proptest::prelude::Just(&b"secrets/README.md"[..]),
311                proptest::prelude::Just(&b".gitattributes"[..]),
312                proptest::prelude::Just(&b".git-xcrypt"[..]),
313            ],
314        ) {
315            let outcome = clean(Some(&key()), &config(), path, &content)
316                .expect("an unselected path must never fail");
317            proptest::prop_assert_eq!(&outcome.content, &content);
318            proptest::prop_assert!(outcome.warning.is_none());
319        }
320
321        /// The other half: content git already stores in the clear must reach
322        /// the working tree untouched, whatever it is.
323        #[test]
324        fn content_without_our_magic_reaches_the_working_tree_unchanged(
325            content in proptest::collection::vec(proptest::num::u8::ANY, 0..16384),
326        ) {
327            proptest::prop_assume!(!looks_encrypted(&content));
328            let outcome = smudge(Some(&key()), b"README.md", &content, false, None, None, None)
329                .expect("content that is not ours must pass through");
330            proptest::prop_assert_eq!(&outcome.content, &content);
331        }
332
333        /// The full working-tree round trip, on arbitrary content, for a path
334        /// the declaration does select. Anything the clean path normalises has
335        /// to come back as the bytes git would hand out again — otherwise
336        /// `git status` reports a file nobody edited.
337        #[test]
338        fn a_selected_path_survives_check_in_and_check_out(
339            content in proptest::collection::vec(proptest::num::u8::ANY, 0..16384),
340        ) {
341            proptest::prop_assume!(!looks_encrypted(&content));
342            let stored = clean(Some(&key()), &config(), b"secrets/pw", &content)
343                .expect("encryption must succeed");
344            proptest::prop_assert!(looks_encrypted(&stored.content));
345
346            let back = smudge(
347                Some(&key()),
348                b"secrets/pw",
349                &stored.content,
350                true,
351                Some(EolMode::Lf),
352                None,
353                None,
354            )
355            .expect("decryption must succeed");
356
357            // Equal to the input except where the clean path normalised CRLF,
358            // which is exactly what `normalise_to_lf` did on the way in.
359            let expected = if eol::should_normalise(config().decide(b"secrets/pw").text, &content) {
360                eol::normalise_to_lf(&content)
361            } else {
362                content.clone()
363            };
364            proptest::prop_assert_eq!(&back.content, &expected);
365
366            // And the loop closes: feeding the working tree back in reproduces
367            // the same blob, which is what keeps `git status` quiet.
368            let again = clean(Some(&key()), &config(), b"secrets/pw", &back.content)
369                .expect("re-encryption must succeed");
370            proptest::prop_assert_eq!(&again.content, &stored.content);
371        }
372    }
373}