git_xcrypt/commands/init.rs
1//! `git-xcrypt init` — make a repository ready to encrypt.
2//!
3//! The hard part is not setting things up, it is deciding whether to. Four
4//! independent pieces of state exist (the key, the filter registration, the
5//! config file, the managed attributes section) and getting the decision wrong
6//! in one direction destroys the key. Three rules replace the sixteen cases:
7//!
8//! * a key exists → never touch it, repair the rest;
9//! * no key but traces of an earlier setup → refuse, this is a clone or a locked
10//! repository and a fresh key would strand every existing blob forever;
11//! * no key and no traces → initialise.
12
13use std::fs;
14
15use crate::crypto::key::MasterKey;
16use crate::crypto::keyfile;
17use crate::git::attributes;
18use crate::git::config as gitconfig;
19use crate::git::repo::{DRIVER, Repo};
20use crate::rules::declaration::Config;
21use crate::{Error, Result};
22
23/// What `init` changed, so it can tell the user rather than work in silence.
24#[derive(Debug, Default, PartialEq, Eq)]
25pub struct Report {
26 /// A key was generated. False when an existing one was left alone.
27 pub key_created: bool,
28 /// The filter registration was written or repaired.
29 pub config_written: bool,
30 /// The managed section in `.gitattributes` was written or repaired.
31 pub attributes_written: bool,
32 /// The `.git-xcrypt` file was created.
33 pub config_file_created: bool,
34 /// Lines of `.git-xcrypt` that declare something pointless.
35 ///
36 /// Carried out rather than printed here so the binary owns every message.
37 pub warnings: Vec<String>,
38}
39
40impl Report {
41 /// Whether anything at all changed.
42 #[must_use]
43 pub fn changed_anything(&self) -> bool {
44 self.key_created
45 || self.config_written
46 || self.attributes_written
47 || self.config_file_created
48 }
49}
50
51/// The starting contents of `.git-xcrypt`.
52///
53/// Comments only: an empty file encrypts nothing, which is the safe default, and
54/// the comments show the syntax without the user having to find the manual.
55const CONFIG_TEMPLATE: &str = "\
56# git-xcrypt — which paths leave this machine encrypted, and how line endings
57# are handled. Patterns use .gitignore syntax; attributes use .gitattributes
58# vocabulary. Without an attribute a path is treated as `text=auto`.
59#
60# Whitespace ends the pattern, so a name that contains a space is closed with
61# quotes, exactly as .gitattributes closes one. A backslash is only what a glob
62# says it is, and a negation keeps its `!` outside the quotes.
63#
64# secrets/
65# *.env
66# secrets/deploy.ps1 text eol=crlf
67# secrets/key.p12 binary
68# \"my secrets/\"
69# \"my secrets/*.sh\" text eol=lf
70# !secrets/README.md
71# !\"my secrets/README.md\"
72";
73
74/// Runs `init` in `repo`.
75///
76/// # Errors
77///
78/// [`Error::Config`] when the repository carries traces of an earlier setup but
79/// no key — generating one would make existing blobs undecryptable forever.
80pub fn run(repo: &Repo) -> Result<Report> {
81 let mut report = Report::default();
82
83 if !repo.has_key() {
84 refuse_if_previously_configured(repo)?;
85 keyfile::write(&repo.key_path(), &MasterKey::generate()?)?;
86 report.key_created = true;
87 }
88
89 report.config_written = register_driver(repo)?;
90 report.config_file_created = create_config_file(repo)?;
91
92 // The managed section is rendered by the same code `sync` runs, so a fresh
93 // repository and a synchronised one are byte-identical. Doing it here rather
94 // than leaving it to a later `sync` is what keeps "run one command" true.
95 //
96 // A `.git-xcrypt` that cannot be parsed stops `init` at this point, on
97 // purpose: the same file stops every `git add` too, and the registration
98 // above has already been saved, so the repair still lands and the message
99 // names the offending line.
100 let config = Config::load(&repo.xcrypt_config_path())?;
101 // Whichever shape is already there, because this command was not asked to
102 // change it — see `render_lines_as_written`. A fresh repository has none, so
103 // it gets the global line: correct with no `sync` in the flow at all, which
104 // is the whole point of writing it here.
105 let lines = attributes::render_lines_as_written(&repo.attributes_path(), &config);
106 report.warnings = config.pointless_eol;
107 report.warnings.extend(textconv_cache_warning(repo));
108 report.attributes_written = attributes::write_section(&repo.attributes_path(), &lines)?;
109
110 Ok(report)
111}
112
113/// Refuses to generate a key in a repository that already used one.
114///
115/// The traces we look for are the ones a clone inherits through history: the
116/// managed attributes section and the versioned config file. Both survive
117/// cloning; the key does not.
118fn refuse_if_previously_configured(repo: &Repo) -> Result<()> {
119 // A `.gitattributes` we cannot read is not evidence of absence. Treating a
120 // read failure as "no traces" is the one direction that generates a fresh
121 // key over a repository that already has one — the irreversible outcome
122 // this whole function exists to prevent.
123 let attributes = match fs::read_to_string(repo.attributes_path()) {
124 Ok(text) => text,
125 Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
126 Err(err) => {
127 return Err(Error::Config(format!(
128 "cannot tell whether this repository was already set up: {} could not be \
129 read ({err}). Refusing rather than risk generating a second key.",
130 repo.attributes_path().display()
131 )));
132 }
133 };
134 let has_section = attributes::has_section(&attributes);
135 let has_config = repo.xcrypt_config_path().is_file();
136
137 if !has_section && !has_config {
138 return Ok(());
139 }
140
141 Err(Error::Config(format!(
142 "this repository was already set up for git-xcrypt but its key is missing.\n\
143 Generating a new one would make every file encrypted so far impossible to \
144 read, for good.\n\
145 If this is a clone, run `git-xcrypt unlock <key-file>`.\n\
146 To put the key in place without decrypting anything, add `--key-only`.\n\
147 If this repository never used git-xcrypt and you wrote {} by hand, delete it \
148 and run `init` again.\n\
149 (found: {})",
150 crate::git::repo::CONFIG_FILE,
151 match (has_section, has_config) {
152 (true, true) => "a managed .gitattributes section and .git-xcrypt",
153 (true, false) => "a managed .gitattributes section",
154 _ => ".git-xcrypt",
155 }
156 )))
157}
158
159/// Registers the filter driver, reporting whether anything changed.
160///
161/// `required = true` is what makes a failing filter abort the operation. Without
162/// it git treats the failure as harmless and commits the unfiltered content with
163/// exit code 0 — for this product, a secret in the clear.
164///
165/// The filter is registered as `process`, the long-running protocol: a process
166/// per file was measured 22× slower, which the catch-all construction cannot
167/// afford.
168///
169/// The `diff` driver is registered alongside it, so the cosmetic
170/// `diff=git-xcrypt` lines S-02 renders have something behind them and `git
171/// diff` compares plaintext.
172///
173/// `cachetextconv` is written as an explicit `false` rather than merely left
174/// out. It makes git keep every *decrypted* file as a blob under
175/// `refs/notes/textconv/git-xcrypt`, inside `.git/`, where it survives `lock` —
176/// the plaintext this product exists to hide, back on disk after the key is
177/// gone. Merely unsetting the local key was measured to be no defence at all: a
178/// `[diff "git-xcrypt"] cachetextconv = true` in `~/.gitconfig` is inherited,
179/// and only a local `false` overrides it. The key is namespaced under our own
180/// driver name, so nothing a user configured for anything else is touched.
181///
182/// Shared with `unlock` rather than copied: a clone has the
183/// catch-all line in `.gitattributes` and no driver behind it, and every command
184/// that puts a key into such a repository has to close that gap the same way.
185///
186/// # Errors
187///
188/// [`Error::Config`] when `.git/config` cannot be read or written.
189pub(crate) fn register_driver(repo: &Repo) -> Result<bool> {
190 let path = repo.config_path();
191 let mut config = gitconfig::open_local(&path)?;
192 let binary = current_executable()?;
193
194 let wanted = [
195 (
196 format!("filter.{DRIVER}.process"),
197 format!("{binary} process"),
198 ),
199 (format!("filter.{DRIVER}.required"), "true".to_string()),
200 (format!("diff.{DRIVER}.textconv"), format!("{binary} diff")),
201 (format!("diff.{DRIVER}.cachetextconv"), "false".to_string()),
202 ];
203
204 let mut changed = false;
205 for (key, value) in wanted {
206 if gitconfig::get(&config, &key).as_deref() != Some(value.as_str()) {
207 gitconfig::set(&mut config, &key, &value)?;
208 changed = true;
209 }
210 }
211
212 if changed {
213 gitconfig::save_local(&path, &config)?;
214 }
215 Ok(changed)
216}
217
218/// Settles the registration for a repository that is about to lose its key.
219///
220/// Registers only what is missing, never repoints a working driver, and takes
221/// the diff driver back out.
222///
223/// **The diff driver has to go.** Measured on git 2.55: `diff.<driver>.textconv`
224/// makes git materialise each side of a diff through
225/// `convert_to_working_tree` — the smudge filter — before handing it over. In a
226/// locked repository that filter has no key, `required = true` turns its refusal
227/// into `fatal: smudge filter git-xcrypt failed`, and `git log -p` over any
228/// declared path stops working entirely. Without the driver git falls back to
229/// `Binary files differ`, which is the honest answer for a repository nobody can
230/// read. `unlock` puts it back, through
231/// [`register_driver`].
232///
233/// For `lock`, which is the one command after which the user has no key left to
234/// run `unlock` again — and `init` deliberately refuses in a repository that
235/// carries traces but no key, so there is no second repair either. Measured:
236/// [`register_driver`] rewrites `process` to whatever binary is running, so
237/// locking with a copy under `target/debug`, in `~/Downloads` or on a container
238/// mount repointed a working registration at a path that then disappeared, and
239/// left a repository in which every `git add` aborts and nothing can fix it.
240///
241/// So an existing `process` value is left exactly as it is, whatever it names.
242/// `required` is still set whenever it is not already `true`: that flag is what
243/// turns a failing filter into an aborted operation instead of a stored
244/// plaintext, and setting it can only ever refuse more.
245///
246/// # Errors
247///
248/// [`Error::Config`] when `.git/config` cannot be read or written.
249pub(crate) fn register_driver_for_lock(repo: &Repo) -> Result<LockRegistration> {
250 let path = repo.config_path();
251 let mut config = gitconfig::open_local(&path)?;
252
253 // Kept apart from `repaired` on purpose. This one happens on every healthy
254 // lock, and folding it in made the command announce "repaired the filter
255 // registration" every single time — noise in the one output a user scans
256 // for signs of trouble before the key disappears, and a repair that no
257 // longer proves anything if the real one stops working.
258 //
259 // Only `textconv` goes. The `cachetextconv = false` line stays, because with
260 // no driver there is nothing to cache and because removing it would let a
261 // `true` in `~/.gitconfig` back through if the repository is ever unlocked
262 // by a build that does not write it.
263 let mut diff_driver_removed = false;
264 let textconv = format!("diff.{DRIVER}.textconv");
265 if gitconfig::get(&config, &textconv).is_some() {
266 gitconfig::unset(&mut config, &textconv)?;
267 diff_driver_removed = true;
268 }
269
270 let mut changed = false;
271 let process = format!("filter.{DRIVER}.process");
272 if gitconfig::get(&config, &process).is_none_or(|value| value.trim().is_empty()) {
273 gitconfig::set(
274 &mut config,
275 &process,
276 &format!("{} process", current_executable()?),
277 )?;
278 changed = true;
279 }
280
281 let required = format!("filter.{DRIVER}.required");
282 if gitconfig::get(&config, &required).as_deref() != Some("true") {
283 gitconfig::set(&mut config, &required, "true")?;
284 changed = true;
285 }
286
287 if changed || diff_driver_removed {
288 gitconfig::save_local(&path, &config)?;
289 }
290 Ok(LockRegistration {
291 repaired: changed,
292 diff_driver_removed,
293 })
294}
295
296/// Warns when a textconv cache is already sitting in this repository.
297///
298/// `cachetextconv` makes git store every *decrypted* file as a blob under
299/// `refs/notes/textconv/git-xcrypt`. [`register_driver`] now writes an explicit
300/// `false`, so no new cache can appear — but one made by an earlier build, or by
301/// hand, is still there, and it outlives `lock`.
302///
303/// Reported rather than deleted, deliberately. Removing the ref would leave the
304/// objects it points at in the database, so a message saying "cleaned up" would
305/// be false; the same reason `status` reports plaintext in history and prints
306/// the procedure instead of rewriting it.
307pub(crate) fn textconv_cache_warning(repo: &Repo) -> Option<String> {
308 let reference = format!("refs/notes/textconv/{DRIVER}");
309
310 let present = repo.common_dir().join(&reference).is_file()
311 || fs::read_to_string(repo.common_dir().join("packed-refs"))
312 .unwrap_or_default()
313 .lines()
314 .any(|line| line.split_whitespace().nth(1) == Some(reference.as_str()));
315
316 present.then(|| {
317 format!(
318 "{reference} exists: git's textconv cache holds decrypted copies of files \
319 from this repository in its object database, and they outlive `lock`. \
320 Remove them with `git update-ref -d {reference}` followed by \
321 `git gc --prune=now`."
322 )
323 })
324}
325
326/// What [`register_driver_for_lock`] changed.
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
328pub(crate) struct LockRegistration {
329 /// Something was missing and was put back — the abnormal case.
330 pub(crate) repaired: bool,
331 /// The diff driver was taken out — the normal case, on every lock.
332 pub(crate) diff_driver_removed: bool,
333}
334
335/// Creates `.git-xcrypt` if it is absent, reporting whether it did.
336fn create_config_file(repo: &Repo) -> Result<bool> {
337 let path = repo.xcrypt_config_path();
338 if path.exists() {
339 return Ok(false);
340 }
341 fs::write(&path, CONFIG_TEMPLATE)?;
342 Ok(true)
343}
344
345/// The command git should run, quoted so a space in the path survives.
346///
347/// Git hands the value to a shell, so a path containing a space or a quote would
348/// otherwise be split. Single quotes stop the shell expanding anything; a
349/// literal quote is closed, escaped and reopened.
350/// Only the **native** separator is rewritten, and that is the whole of it.
351/// Git wants forward slashes in a value it hands to a shell on Windows, but on
352/// Unix a backslash is an ordinary character in a file name — rewriting one
353/// there names a different file, exactly as `repo::git_spelling` says.
354///
355/// # Errors
356///
357/// [`Error::Config`] when this binary's own path is not text — see
358/// [`shell_quoted`].
359fn current_executable() -> Result<String> {
360 shell_quoted(
361 &std::env::current_exe()?,
362 crate::git::repo::NATIVE_SEPARATOR,
363 )
364}
365
366/// The platform-independent core, so both spellings are testable from either
367/// platform.
368///
369/// **A path that is not text is refused rather than approximated**, and that is
370/// the second way this function once named a binary that does not exist. The
371/// first was the separator, fixed in `33e30c2` and pinned by the test below; the
372/// decode beside it stayed lossy until 2026-08-06. On Unix a path is an
373/// arbitrary byte string, so a binary installed under `/opt/wersja-\xb3/` — a
374/// perfectly legal ext4 directory — came through `to_string_lossy` as
375/// `/opt/wersja-\u{fffd}/`, and *that* is what `init` wrote into
376/// `filter.git-xcrypt.process`. The outcome is the one the separator bug had:
377/// `init` reports success, and because it also sets `required = true`, every
378/// later `git add`, `git checkout` and `git status` in the repository aborts
379/// with `fatal: cannot run …` and nothing points at the config value. A second
380/// `init` cannot repair it either — [`register_driver`] compares the same lossy
381/// string, finds it equal to what is stored and reports nothing to do.
382///
383/// Refusing is the whole fix, deliberately, rather than carrying bytes through
384/// `.git/config`: the value has to survive being handed to a shell by git, the
385/// configuration layer here is `&str` end to end, and widening it for this would
386/// touch the one write that decides whether git filters at all. A named refusal
387/// at `init` costs a user with such an install path a move of the binary; the
388/// silent version cost them every git command in the repository, with no way to
389/// see why.
390///
391/// # Errors
392///
393/// [`Error::Config`] when `path` is not valid UTF-8.
394fn shell_quoted(path: &std::path::Path, separator: char) -> Result<String> {
395 let text = path.to_str().ok_or_else(|| {
396 Error::Config(format!(
397 "{}: this binary's own path is not valid UTF-8, so it cannot be \
398 written into .git/config as a command git could run. Approximating \
399 it would register a path that does not exist, and with \
400 `filter.{DRIVER}.required` set every later git operation in this \
401 repository would abort. Move or reinstall git-xcrypt somewhere \
402 whose name is text, then run this again.",
403 path.display()
404 ))
405 })?;
406 let text = crate::git::repo::with_separator(text, separator);
407 Ok(format!("'{}'", text.replace('\'', r"'\''")))
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use std::process::Command;
414 use tempfile::TempDir;
415
416 fn init_repo() -> TempDir {
417 let dir = TempDir::new().expect("temporary directory");
418 let ok = Command::new("git")
419 .args(["init", "-q"])
420 .current_dir(dir.path())
421 .status()
422 .expect("git must be on PATH")
423 .success();
424 assert!(ok, "git init failed");
425 dir
426 }
427
428 /// Both halves of the filter command's spelling, exercised from any platform.
429 ///
430 /// The rewrite exists for Windows, where git wants forward slashes in a
431 /// config value it hands to a shell. It used to run unconditionally, and on
432 /// Unix a backslash is an ordinary character in a file name — so a binary
433 /// under `/opt/a\b/git-xcrypt` was registered as `/opt/a/b/git-xcrypt`, a
434 /// path that does not exist. `init` still reported success, and with
435 /// `required = true` every later `git add`, `git checkout` and `git status`
436 /// in that repository aborted with no way to see why from the message.
437 ///
438 /// `repo::git_spelling` already carried this rule, with a test of its own;
439 /// this is the same core, so the two cannot drift.
440 #[test]
441 fn the_registered_command_rewrites_a_separator_and_never_a_file_name() {
442 use std::path::Path;
443
444 let quoted = |path: &Path, separator| shell_quoted(path, separator).expect("a text path");
445
446 // Windows: the separator is a separator, and git gets slashes.
447 assert_eq!(
448 quoted(Path::new(r"C:\Program Files\xc\git-xcrypt.exe"), '\\'),
449 "'C:/Program Files/xc/git-xcrypt.exe'"
450 );
451
452 // Unix: a backslash is part of the name and must survive untouched.
453 assert_eq!(
454 quoted(Path::new(r"/opt/a\b/git-xcrypt"), '/'),
455 r"'/opt/a\b/git-xcrypt'",
456 "the registered command named a binary that does not exist"
457 );
458
459 // A quote is still closed, escaped and reopened, on both.
460 assert_eq!(
461 quoted(Path::new("/opt/it's/git-xcrypt"), '/'),
462 r"'/opt/it'\''s/git-xcrypt'"
463 );
464
465 // And whatever this platform is, the real one round-trips: what `init`
466 // writes has to name the binary that is running.
467 let registered = current_executable().expect("the running binary has a path");
468 assert!(registered.starts_with('\'') && registered.ends_with('\''));
469 }
470
471 /// The other way this function named a binary that does not exist.
472 ///
473 /// The separator above was one; a lossy decode is the other, and it stayed
474 /// until 2026-08-06. A path that is not text must be **refused**, because
475 /// `to_string_lossy` turns it into a path that exists nowhere, `init`
476 /// reports success over it, and `required = true` then aborts every git
477 /// operation in the repository with nothing pointing at the cause.
478 ///
479 /// Built in memory rather than on disk, which is what lets this run
480 /// anywhere: APFS rejects a non-UTF-8 name at `open` and a Windows name is
481 /// UTF-16, so neither platform can *create* the case — but both can be asked
482 /// what this function does with it. The Windows arm uses an unpaired
483 /// surrogate, which is the only shape a `PathBuf` there can hold that
484 /// `to_str` refuses.
485 #[cfg(any(unix, windows))]
486 #[test]
487 fn a_path_that_is_not_text_is_refused_rather_than_approximated() {
488 #[cfg(unix)]
489 let not_text = {
490 use std::os::unix::ffi::OsStrExt as _;
491 std::ffi::OsStr::from_bytes(b"/opt/wersja-\xb3/git-xcrypt").to_os_string()
492 };
493 #[cfg(windows)]
494 let not_text = {
495 use std::os::windows::ffi::OsStringExt as _;
496 std::ffi::OsString::from_wide(&[0x43, 0x3a, 0x5c, 0xd800, 0x5c, 0x78, 0x63])
497 };
498
499 let path = std::path::PathBuf::from(¬_text);
500 assert!(
501 path.to_str().is_none(),
502 "the fixture decodes cleanly, so this test asks nothing"
503 );
504
505 let error = shell_quoted(&path, crate::git::repo::NATIVE_SEPARATOR)
506 .expect_err("a path that is not text must not be approximated");
507 assert_eq!(error.exit_code(), crate::util::exit::CONFIG);
508 assert!(
509 error.to_string().contains("not valid UTF-8"),
510 "the refusal must name what is wrong with the path: {error}"
511 );
512 }
513
514 #[test]
515 fn the_textconv_cache_is_switched_off_rather_than_merely_left_out() {
516 // With `cachetextconv` on, git keeps every decrypted file in a notes ref
517 // inside `.git/` — plaintext that outlives `lock`. Measured: unsetting
518 // the local key is no defence, because a `true` in `~/.gitconfig` is
519 // inherited. Only a local `false` overrides it.
520 let dir = init_repo();
521 let repo = Repo::discover(dir.path()).expect("discovery");
522 run(&repo).expect("first init");
523
524 let path = repo.config_path();
525 let key = format!("diff.{DRIVER}.cachetextconv");
526 assert_eq!(
527 gitconfig::get(&gitconfig::open_local(&path).expect("config"), &key).as_deref(),
528 Some("false"),
529 "an inherited `true` would go unopposed"
530 );
531
532 let mut config = gitconfig::open_local(&path).expect("config");
533 gitconfig::set(&mut config, &key, "true").expect("setting");
534 gitconfig::save_local(&path, &config).expect("saving");
535
536 let report = run(&repo).expect("init must repair");
537
538 assert!(report.config_written, "the repair went unreported");
539 assert_eq!(
540 gitconfig::get(&gitconfig::open_local(&path).expect("config"), &key).as_deref(),
541 Some("false"),
542 "the textconv cache survived init"
543 );
544 }
545}