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/// # Errors
162///
163/// [`Error::NoKey`] for our content with no key loaded, plus the errors
164/// [`cipher::decrypt`] reports.
165pub fn smudge(
166    key: Option<&MasterKey>,
167    path: &[u8],
168    content: &[u8],
169    selected: bool,
170    declared_eol: Option<EolMode>,
171    autocrlf: Option<&str>,
172    core_eol: Option<&str>,
173) -> Result<Outcome> {
174    if !looks_encrypted(content) {
175        // Committed before the pattern existed. Refusing would make checking out
176        // old history impossible, and plaintext in the working tree is where
177        // plaintext belongs — so this passes through, loudly.
178        //
179        // Loudly only for a path the declaration actually selects, though. The
180        // catch-all attribute sends every file in the repository through here,
181        // so warning unconditionally buries the one message that means something
182        // under one per ordinary file — and a fresh clone becomes a wall of
183        // "whether it leaked". The case this warning exists for is narrow: a
184        // *selected* path found in the clear.
185        let warning = selected.then(|| {
186            format!(
187                "{}: stored in the clear, so it is checked out unchanged; \
188                 run `git-xcrypt status` to see whether it leaked",
189                path.as_bstr()
190            )
191        });
192        return Ok(Outcome {
193            content: content.to_vec(),
194            warning,
195        });
196    }
197
198    let key = key.ok_or(Error::NoKey)?;
199    let (flags, plaintext) = cipher::decrypt(key, content)?;
200
201    if flags & FLAG_LF_NORMALIZED == 0 {
202        return Ok(Outcome::plain(plaintext));
203    }
204
205    let mode = eol::resolve_output(declared_eol, autocrlf, core_eol);
206    Ok(Outcome::plain(eol::apply(&plaintext, mode)))
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::crypto::key::MASTER_KEY_LEN;
213
214    fn key() -> MasterKey {
215        MasterKey::from_bytes([5u8; MASTER_KEY_LEN])
216    }
217
218    fn config() -> Config {
219        Config::parse("secrets/\n*.env\n!secrets/README.md\n").expect("test config")
220    }
221
222    /// The property the whole catch-all construction rests on.
223    #[test]
224    fn pass_through_is_byte_identical_for_arbitrary_content() {
225        let config = config();
226        let samples: Vec<Vec<u8>> = vec![
227            Vec::new(),
228            b"x".to_vec(),
229            b"plain text\r\nwith crlf\r\n".to_vec(),
230            (0u8..=255).collect(),
231            (0u8..=255).cycle().take(100_000).collect(),
232            vec![0u8; 4096],
233            // The full 11-byte magic, so the sample really wears it: an earlier
234            // spelling was ten bytes — one short — and covered nothing, since
235            // `looks_encrypted` said no. What this pins is that an *unselected*
236            // path passes through before the magic is even consulted.
237            b"\0GITXCRYPT\0not actually one of ours".to_vec(),
238        ];
239
240        for content in samples {
241            for path in [&b"README.md"[..], b"src/main.rs", b"secrets/README.md"] {
242                let outcome = clean(Some(&key()), &config, path, &content)
243                    .expect("an unselected path must never fail");
244                assert_eq!(
245                    outcome.content,
246                    content,
247                    "{} was altered on its way into the object database",
248                    path.as_bstr()
249                );
250            }
251        }
252    }
253
254    proptest::proptest! {
255        // `AGENTS.md` calls `passthrough(x) == x` a property test rather than a
256        // nicety, and `zalozenia.md` §Konstrukcja catch-all asks for it over
257        // *arbitrary* bytes: with the catch-all attribute every file in the
258        // repository comes through here, so the blast radius of a bug is the
259        // whole project. The listed shapes above stay; this covers what a list
260        // cannot.
261        #![proptest_config(proptest::prelude::ProptestConfig::with_cases(256))]
262
263        #[test]
264        fn an_unselected_path_is_handed_back_byte_for_byte(
265            content in proptest::collection::vec(proptest::num::u8::ANY, 0..16384),
266            path in proptest::prelude::prop_oneof![
267                proptest::prelude::Just(&b"README.md"[..]),
268                proptest::prelude::Just(&b"src/main.rs"[..]),
269                proptest::prelude::Just(&b"secrets/README.md"[..]),
270                proptest::prelude::Just(&b".gitattributes"[..]),
271                proptest::prelude::Just(&b".git-xcrypt"[..]),
272            ],
273        ) {
274            let outcome = clean(Some(&key()), &config(), path, &content)
275                .expect("an unselected path must never fail");
276            proptest::prop_assert_eq!(&outcome.content, &content);
277            proptest::prop_assert!(outcome.warning.is_none());
278        }
279
280        /// The other half: content git already stores in the clear must reach
281        /// the working tree untouched, whatever it is.
282        #[test]
283        fn content_without_our_magic_reaches_the_working_tree_unchanged(
284            content in proptest::collection::vec(proptest::num::u8::ANY, 0..16384),
285        ) {
286            proptest::prop_assume!(!looks_encrypted(&content));
287            let outcome = smudge(Some(&key()), b"README.md", &content, false, None, None, None)
288                .expect("content that is not ours must pass through");
289            proptest::prop_assert_eq!(&outcome.content, &content);
290        }
291
292        /// The full working-tree round trip, on arbitrary content, for a path
293        /// the declaration does select. Anything the clean path normalises has
294        /// to come back as the bytes git would hand out again — otherwise
295        /// `git status` reports a file nobody edited.
296        #[test]
297        fn a_selected_path_survives_check_in_and_check_out(
298            content in proptest::collection::vec(proptest::num::u8::ANY, 0..16384),
299        ) {
300            proptest::prop_assume!(!looks_encrypted(&content));
301            let stored = clean(Some(&key()), &config(), b"secrets/pw", &content)
302                .expect("encryption must succeed");
303            proptest::prop_assert!(looks_encrypted(&stored.content));
304
305            let back = smudge(
306                Some(&key()),
307                b"secrets/pw",
308                &stored.content,
309                true,
310                Some(EolMode::Lf),
311                None,
312                None,
313            )
314            .expect("decryption must succeed");
315
316            // Equal to the input except where the clean path normalised CRLF,
317            // which is exactly what `normalise_to_lf` did on the way in.
318            let expected = if eol::should_normalise(config().decide(b"secrets/pw").text, &content) {
319                eol::normalise_to_lf(&content)
320            } else {
321                content.clone()
322            };
323            proptest::prop_assert_eq!(&back.content, &expected);
324
325            // And the loop closes: feeding the working tree back in reproduces
326            // the same blob, which is what keeps `git status` quiet.
327            let again = clean(Some(&key()), &config(), b"secrets/pw", &back.content)
328                .expect("re-encryption must succeed");
329            proptest::prop_assert_eq!(&again.content, &stored.content);
330        }
331    }
332}