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}