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}