git_xcrypt/commands/lock.rs
1//! `git-xcrypt lock` — close an unlocked repository, and delete its key.
2//!
3//! This is the most expensive command in the product to get wrong. `.git/` is
4//! neither versioned nor pushed, so the key file it removes is the **only** copy
5//! anywhere; `unlock` will not undo that, whatever its name suggests. Worse, the
6//! loss is deferred: nothing breaks at the moment it runs, and the truth surfaces
7//! months later at the first attempt to read anything. Most of this module is
8//! therefore refusals, not work.
9//!
10//! **The order of operations is the whole design.** Prove the working tree holds
11//! nothing that would be lost → warn and ask → encrypt every selected file →
12//! *then* remove the key. Reversed, an interruption would leave a working tree in
13//! the clear with no key left to encrypt it with, which is the one state this
14//! command must never produce.
15//!
16//! **Selection is by pattern, not by header** — the mirror image of `unlock`, and
17//! necessarily so: a plaintext file carries no header saying it is a secret, so
18//! `.git-xcrypt` is the only thing that can say. The encryption itself goes
19//! through [`decide::clean`], the very function git calls on the check-in path,
20//! so the bytes this command writes are the bytes that are already committed.
21//! That is what makes `git status` clean afterwards, and it is why line-ending
22//! handling cannot drift between the two.
23//!
24//! **Two refusals, for two different losses.** The key is one; uncommitted work
25//! is the other, and `--yes` waives only the first. Content that is not stored
26//! in the repository exists nowhere but in the file `lock` is about to encrypt,
27//! and after the key is gone that is the same as gone. The founding document is
28//! explicit that this deserves a decision of its own.
29//!
30//! Everything this command cannot verify, it refuses over. A directory it cannot
31//! list might hold a secret; an index it cannot parse cannot vouch for anything.
32//! `unlock` skips such things and says so, because there the cost of skipping is
33//! a file left encrypted. Here it would be a plaintext secret left behind by the
34//! command that promised to remove it, so the two commands lean opposite ways on
35//! purpose.
36
37use std::fmt;
38use std::fs;
39use std::io::{BufRead, Write};
40use std::path::{Path, PathBuf};
41
42use zeroize::Zeroizing;
43
44use crate::crypto::format::KEY_ID_LEN;
45use crate::crypto::key::MasterKey;
46use crate::git::config as gitconfig;
47use crate::git::index;
48use crate::git::repo::{Repo, git_spelling};
49use crate::rules::decide;
50use crate::rules::declaration::Config;
51use crate::util::atomic;
52use crate::{Error, Result};
53
54/// What `lock` did.
55#[derive(Debug)]
56pub struct Report {
57 /// Fingerprint of the key that was removed. Safe to print; the key is not.
58 pub key_id: [u8; KEY_ID_LEN],
59 /// How many working-tree files the declaration selected.
60 ///
61 /// Separate from [`Report::encrypted`] so the closing line can tell "nothing
62 /// to do, everything was already closed" from "nothing matched at all". The
63 /// two look identical through a count of files written, and only one of them
64 /// is a repository that is now safe.
65 pub declared: usize,
66 /// Paths, relative to the working tree, that were encrypted in place.
67 pub encrypted: Vec<PathBuf>,
68 /// Leftover temporary files that were deleted on the way through.
69 ///
70 /// Reported rather than swallowed: each one may have held a decrypted
71 /// secret, and a user is entitled to know one was lying around.
72 pub swept: Vec<PathBuf>,
73 /// The filter registration was written or repaired.
74 pub config_written: bool,
75 /// The diff driver was deregistered — which happens on every healthy lock.
76 ///
77 /// Separate from [`Report::config_written`], which means "something was
78 /// broken and I fixed it". Merging them made every lock claim a repair.
79 pub diff_driver_removed: bool,
80 /// The managed `.gitattributes` section was written or repaired.
81 pub attributes_written: bool,
82 /// The key file is gone.
83 pub key_removed: bool,
84 /// Anything worth saying once, carried out so the binary owns the messages.
85 pub warnings: Vec<String>,
86}
87
88/// How a run ended.
89#[derive(Debug)]
90pub enum Outcome {
91 /// The working tree is encrypted and the key is gone.
92 Locked(Box<Report>),
93 /// The user declined. Nothing was changed at all.
94 Aborted,
95}
96
97/// Everything `lock` says before it does anything irreversible.
98///
99/// A value rather than a printed string so the same text reaches the user in
100/// both modes, and so a test can assert on it without capturing a stream. It
101/// names the key by fingerprint and **never** by material: printing the key here
102/// was considered and rejected — it would survive in scrollback, in a terminal
103/// multiplexer's buffer, in a CI log, and in the working tree the moment someone
104/// redirects this command's output.
105#[derive(Debug)]
106pub struct Warning {
107 /// Fingerprint of the key about to be deleted.
108 pub key_id: [u8; KEY_ID_LEN],
109 /// Where that key lives.
110 pub key_path: PathBuf,
111 /// How many working-tree files the declaration selects.
112 pub declared: usize,
113 /// How many of those are still plaintext, so will actually change.
114 pub still_open: usize,
115 /// Temporary files this run will delete.
116 ///
117 /// Named here rather than only in the closing report, because deleting an
118 /// untracked file is irreversible and disclosing it afterwards is too late
119 /// to be a disclosure.
120 pub sweeping: Vec<PathBuf>,
121}
122
123impl fmt::Display for Warning {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 let key_id = crate::format_key_id(&self.key_id);
126 writeln!(
127 f,
128 "WARNING: lock deletes the only copy of this repository's key.\n\
129 \n \
130 key_id: {key_id}\n \
131 path: {}\n \
132 files: {} declared, {} of them still in the clear\n\
133 \n\
134 After this, decrypting anything — including the entire history — will be\n\
135 possible only from a copy of the key held outside this directory.\n\
136 unlock WILL NOT UNDO THIS.\n\
137 \n\
138 If you do not have a copy, abort and run:\n \
139 git-xcrypt export-key <a path outside this repository>/git-xcrypt-{}.key",
140 self.key_path.display(),
141 self.declared,
142 self.still_open,
143 &key_id[..8],
144 )?;
145
146 if self.declared == 0 {
147 writeln!(
148 f,
149 "\nNothing in this working tree matches {}, so nothing will be encrypted.\n\
150 If you expected secrets to be closed here, abort and check the declaration.",
151 crate::git::repo::CONFIG_FILE
152 )?;
153 }
154
155 if !self.sweeping.is_empty() {
156 writeln!(
157 f,
158 "\nThese temporary files, left by an interrupted run, will be deleted.\n\
159 They are untracked, so deleting them cannot be undone:"
160 )?;
161 for path in &self.sweeping {
162 writeln!(f, " {}", git_spelling(path))?;
163 }
164 }
165 Ok(())
166 }
167}
168
169/// Decides whether the irreversible half of `lock` may go ahead.
170///
171/// An injected decision rather than a flag, so the interactive path is exercised
172/// by tests instead of being the one branch nothing covers.
173pub trait Confirm {
174 /// Presents `warning` and answers whether to proceed.
175 ///
176 /// # Errors
177 ///
178 /// [`Error::Io`] when the warning cannot be shown or the answer cannot be
179 /// read. Refusing is the safe default, so a failure here must not be turned
180 /// into a yes.
181 fn confirm(&mut self, warning: &Warning) -> Result<bool>;
182}
183
184/// `--yes`: shows the warning, asks nothing.
185///
186/// The warning is printed in this mode too, deliberately. A non-interactive run
187/// still has a reader — the CI log, the terminal it scrolled past — and silence
188/// would make the destructive step invisible in exactly the setting where nobody
189/// is watching it happen.
190#[derive(Debug)]
191pub struct Assumed<W> {
192 output: W,
193}
194
195impl<W: Write> Assumed<W> {
196 /// Writes the warning to `output` and proceeds.
197 pub const fn new(output: W) -> Self {
198 Self { output }
199 }
200}
201
202impl<W: Write> Confirm for Assumed<W> {
203 fn confirm(&mut self, warning: &Warning) -> Result<bool> {
204 writeln!(self.output, "{warning}")?;
205 writeln!(
206 self.output,
207 "Proceeding without asking, because --yes was given."
208 )?;
209 self.output.flush()?;
210 Ok(true)
211 }
212}
213
214/// The default: shows the warning and waits for the word `yes`.
215///
216/// The streams are whatever the caller hands over, which in the binary means
217/// `stdin` and `stderr` rather than the controlling terminal. Two consequences
218/// worth knowing rather than discovering: `git-xcrypt lock < answers.txt`
219/// proceeds if the first line is `yes`, and `git-xcrypt lock 2>/dev/null` waits
220/// on a prompt nobody can see. Opening `/dev/tty` instead would fix both on Unix
221/// and has no portable equivalent, and this binary ships on Windows too.
222#[derive(Debug)]
223pub struct Ask<R, W> {
224 input: R,
225 output: W,
226}
227
228impl<R: BufRead, W: Write> Ask<R, W> {
229 /// Asks on `output` and reads the answer from `input`.
230 pub const fn new(input: R, output: W) -> Self {
231 Self { input, output }
232 }
233}
234
235impl<R: BufRead, W: Write> Confirm for Ask<R, W> {
236 /// Accepts the exact word `yes` and nothing else.
237 ///
238 /// Not `y`, not `YES`: the founding document asks for a word typed in full
239 /// because the point of the prompt is to interrupt a reflex. End of input —
240 /// a run with no terminal behind it, `lock < /dev/null` in a script — reads
241 /// as a refusal, which is the direction that changes nothing.
242 fn confirm(&mut self, warning: &Warning) -> Result<bool> {
243 writeln!(self.output, "{warning}")?;
244 write!(self.output, "\nType `yes` to delete the key: ")?;
245 self.output.flush()?;
246
247 // ASCII whitespace only: `str::trim` also strips U+00A0 and friends, and
248 // a non-breaking space is exactly the kind of thing a paste from a web
249 // page carries. `\u{a0}yes` reading as consent is not a risk this
250 // particular prompt should take.
251 let mut answer = String::new();
252 if self.input.read_line(&mut answer)? == 0 {
253 // No newline was echoed, so the next line the user sees would run
254 // into the prompt.
255 writeln!(self.output)?;
256 return Ok(false);
257 }
258 Ok(answer.trim_matches(|c: char| c.is_ascii_whitespace()) == "yes")
259 }
260}
261
262/// Locks `repo`: encrypts every selected file, then removes the key.
263///
264/// # Errors
265///
266/// [`Error::NoKey`] when there is no key to remove — which is also what a second
267/// run reports, harmlessly. [`Error::Config`] when `.git-xcrypt` is missing or
268/// unreadable, when a selected file holds content the repository does not store,
269/// or when something in the way stops this command proving either. [`Error::Format`]
270/// or [`Error::KeyMismatch`] for a file already encrypted under another key.
271/// [`Error::Io`] on a read or write failure.
272pub fn run(repo: &Repo, confirm: &mut dyn Confirm) -> Result<Outcome> {
273 // First, so a repository with nothing to lock says so before anything is
274 // read, walked or asked.
275 let key = repo.load_key()?;
276 let key_id = key.key_id();
277
278 let config = Config::load(&repo.xcrypt_config_path())?;
279 if config.missing {
280 // The check-in path treats this as fatal for the same reason: without
281 // the declaration a secret and a readme are indistinguishable, and the
282 // wrong guess here leaves a secret in the clear in a repository whose
283 // key has just been deleted.
284 return Err(Error::Config(format!(
285 "{}: the file that says what to encrypt is missing, so lock cannot tell \
286 which files to close. Restore it from the repository or run \
287 `git-xcrypt init`. Nothing has been changed.",
288 crate::git::repo::CONFIG_FILE
289 )));
290 }
291
292 // Before anything else that costs work: this repository's key is shared by
293 // every checkout, and the walk below only ever sees one of them.
294 refuse_other_worktrees(repo)?;
295
296 let git_config = gitconfig::open_full(repo.git_dir(), repo.common_dir())?;
297 let hash =
298 index::object_hash(gitconfig::get(&git_config, "extensions.objectformat").as_deref());
299
300 let mut walk = Walk::default();
301 let found = collect(repo, &config, &mut walk, NOTHING_CHANGED)?;
302 let stored = stored_ids(repo, hash, &found.selected, &found.residue)?;
303
304 // Every name the first walk saw, captured before any sweep decision thins
305 // the lists. The late "did the tree move" check compares a fresh walk
306 // against this, and the fresh walk cannot know which residue was sweepable
307 // — so the two sides have to count the same things, or a *tracked* residue
308 // file (excluded from the sweep, and possibly from the selection) reads as
309 // "appeared while lock was running" and refuses for ever over a tree that
310 // never moved.
311 let mut surveyed_names: Vec<Vec<u8>> = found
312 .selected
313 .iter()
314 .chain(&found.residue)
315 .map(|file| file.name.clone())
316 .collect();
317 surveyed_names.sort_unstable();
318 surveyed_names.dedup();
319
320 // The sweep is settled first, because what it takes must not then be
321 // surveyed: residue is untracked by construction, so surveying it would
322 // refuse every lock that has any.
323 let residue = sweepable(&found.residue, &stored.residue, &mut walk);
324 let (selected, stored_selected) = drop_swept(found.selected, stored.selected, &residue);
325
326 let survey = survey(&key, &config, &selected, &stored_selected, hash)?;
327
328 let warning = Warning {
329 key_id,
330 key_path: repo.key_path(),
331 declared: selected.len(),
332 still_open: survey.still_open,
333 sweeping: residue.iter().map(|file| file.relative.clone()).collect(),
334 };
335 if !confirm.confirm(&warning)? {
336 return Ok(Outcome::Aborted);
337 }
338
339 let mut report = Report {
340 key_id,
341 declared: selected.len(),
342 encrypted: Vec::new(),
343 swept: Vec::new(),
344 config_written: false,
345 diff_driver_removed: false,
346 attributes_written: false,
347 key_removed: false,
348 warnings: config.pointless_eol.clone(),
349 };
350 report.warnings.append(&mut walk.warnings);
351 if selected.is_empty() {
352 // Not an error — an empty declaration is legal — but it is also what a
353 // typo in `.git-xcrypt`, or a branch predating it, looks like from here.
354 // Saying "locked" over it would assert a state that does not hold, and
355 // the key is about to be gone, so this is the last chance to notice.
356 report.warnings.push(format!(
357 "no file in the working tree matches {}, so nothing was encrypted. \
358 If you expected secrets to be closed here, check the declaration \
359 before relying on this repository being safe.",
360 crate::git::repo::CONFIG_FILE
361 ));
362 }
363
364 // Nothing above this line has touched anything, so an abort really did
365 // change nothing. From here on it has, which is why the last look at the
366 // working tree happens now: a declared file created while the prompt waited
367 // is not in the selection, and locking around it would delete the key over a
368 // plaintext secret nobody mentioned. Measured before this check: a file
369 // created 1.5 s into the prompt survived a successful lock, in the clear.
370 refuse_if_the_tree_moved(repo, &config, &surveyed_names)?;
371
372 // And the worktrees again, for the same reason and against the same window.
373 // The early call fails fast, so a repository that already has a linked
374 // checkout is never asked the question at all; this one is what closes the
375 // gap between the answer and the deletion. `git worktree add` checks the new
376 // checkout out through the smudge filter, so the file lands there in the
377 // clear — and the walk above cannot see it, because it walks *this* tree.
378 //
379 // Measured on git 2.55, 2026-08-05, before this line existed: `git worktree
380 // add` run 1.5 s into the prompt, `yes` typed at 3 s. `lock` exited **0**,
381 // reported "1 file(s) are now encrypted and key … has been deleted", and
382 // left `../side/secrets/db.env` reading `AWS_SECRET=hunter2` with no key
383 // anywhere able to close it. The same shape as the file-appeared window
384 // above and as the linked-worktree refusal itself; only the two together
385 // were unguarded.
386 refuse_other_worktrees(repo)?;
387
388 // The last command run before the key goes is the last chance to notice that
389 // git has no filter behind the catch-all attribute — and a locked repository
390 // needs it more than an unlocked one, because there the clean path is what
391 // turns "no key" into a refused `git add` instead of a stored plaintext.
392 // Measured on git 2.55: with either half missing, `git add` on a secret in a
393 // locked repository exits 0 and stores the plaintext.
394 //
395 // Only what is *missing*, in both halves. The cosmetic lines are allowed to
396 // be out of date — `.git-xcrypt` is the source of truth and `sync` is what
397 // regenerates them — so rewriting the section here would leave `git status`
398 // dirty after a successful lock, disclosed to nobody and after the key was
399 // already gone.
400 //
401 // The same call takes the diff driver back *out*: with no key, textconv
402 // drags the smudge filter into every `git log -p` and aborts it. See
403 // `init::register_driver_for_lock`.
404 let registration = super::init::register_driver_for_lock(repo)?;
405 report.config_written = registration.repaired;
406 report.diff_driver_removed = registration.diff_driver_removed;
407
408 // A textconv cache holds decrypted copies inside `.git/`, and this is the
409 // command after which nobody can decrypt anything — so it is the last moment
410 // the plaintext left behind is worth naming.
411 report
412 .warnings
413 .extend(super::init::textconv_cache_warning(repo));
414 report.attributes_written = write_catch_all_if_missing(repo, &config)?;
415
416 // Before the encryption pass, so nothing we are about to write is mistaken
417 // for residue, and so a leftover of a file we then encrypt cannot outlive
418 // the command holding that file's plaintext.
419 sweep(&residue, &mut report);
420
421 encrypt_in_place(&key, &config, &selected, &survey, hash, &mut report)
422 .map_err(|err| interrupted(&report, err))?;
423
424 // Every selected file, not only the ones this run rewrote. Git compares the
425 // new size against the one it cached for the plaintext, concludes the file
426 // changed and never runs the filter to find out otherwise — so `git status`
427 // reports every locked secret as modified, for good. Passing only the
428 // rewritten ones left a file encrypted by an *interrupted* earlier run
429 // permanently modified, because the second run skipped it as already closed.
430 // Measured. See `crate::git::index`.
431 let names: Vec<Vec<u8>> = selected.iter().map(|file| file.name.clone()).collect();
432 match index::forget_stat(&repo.git_dir().join("index"), hash, &names)? {
433 index::Outcome::Cleared(cleared) if cleared < names.len() => {
434 // Every selected file was proved tracked by the survey, so a name
435 // the index did not match is a name spelled differently there than
436 // on disk — case folding on macOS and Windows, or NFD against NFC.
437 report.warnings.push(format!(
438 "{} of {} file(s) were not found in the index under the name they \
439 have on disk, so their cached size was left alone. They are \
440 encrypted correctly; if `git status` shows them as modified, \
441 `git add --renormalize .` settles it.",
442 names.len() - cleared,
443 names.len()
444 ));
445 }
446 index::Outcome::Cleared(_) => {}
447 index::Outcome::Skipped(why) => report.warnings.push(why),
448 }
449
450 // The last thing asked before the irreversible step, and the only one asked
451 // of the *index* rather than of the walk. Everything above trusts that a
452 // walk of the working tree sees every declared file; that is true only while
453 // the two agree on how a name is spelled. Measured on git 2.55 and APFS,
454 // where they need not: `mv secrets Secrets` leaves the index saying
455 // `secrets/db.env`, the disk saying `Secrets/`, `git status` saying nothing
456 // at all — and this command saying "no file here is declared for
457 // encryption", exiting 0 with the key deleted over a plaintext secret.
458 // `src/gitindex.rs` described that case and called the outcome safe for
459 // `lock` because it "refuses rather than proceeds". It did not.
460 //
461 // Asked about content, not about spelling, so it settles nothing about what
462 // a pattern ought to mean on such a filesystem — only whether this run has
463 // done what it is about to promise.
464 refuse_if_a_declared_file_is_still_open(repo, &config, hash)?;
465
466 // And the checkouts one last time, because the encryption pass is a window
467 // of its own. The call before it closes the prompt; this one closes the work.
468 // Measured on git 2.55, 2026-08-05, 4000 declared files: `git worktree add`
469 // run once the pass had demonstrably started (the first file on disk was
470 // already ciphertext) took `lock --yes` to exit **0**, "4000 file(s) are now
471 // encrypted and key … has been deleted", with `../side/secrets/s1.env`
472 // reading `AWS_SECRET=hunter2-1` and no key left anywhere to close it. The
473 // same shape as the prompt window, on the other side of the answer — and
474 // unbounded in the same practical sense, since the pass is as long as the
475 // secrets are large.
476 refuse_other_worktrees_with(repo, KEY_KEPT)?;
477
478 // And the tree itself, for the same window and the same reason. The gate
479 // above it reads the *index*, so it says nothing about a file that was never
480 // added — and a file created while the pass ran is exactly that.
481 refuse_if_a_declared_file_appeared(repo, &config, &surveyed_names)?;
482
483 // Last, and only once every file above is ciphertext. A failure before this
484 // point leaves the key in place, so re-running finishes the job.
485 remove_key(repo, &mut report)?;
486
487 Ok(Outcome::Locked(Box::new(report)))
488}
489
490/// Refuses while another checkout of this repository shares the key.
491///
492/// The key lives in the common directory, so every worktree reads the same one,
493/// but the walk below only ever sees the checkout it was run from. Locking one
494/// and deleting the key leaves the others holding **plaintext with no key left
495/// to close them** — the exact state this module exists to make impossible, and
496/// reached on the success path rather than by interruption. Measured on git
497/// 2.55: `lock` in the main worktree left a linked one readable, and `lock`
498/// there then failed with "no repository key".
499///
500/// Refusing rather than locking them all: each checkout has its own index, its
501/// own `HEAD` and possibly its own `.git-xcrypt`, so proving them clean means
502/// running this whole command per worktree, which the user can do.
503///
504/// **The registration directory is the evidence, not the checkout.** An earlier
505/// version asked whether the path in `worktrees/<name>/gitdir` still existed and
506/// treated a miss as a stale registration. Two measured ways that was wrong: the
507/// pointer is sometimes relative, so `exists()` answered against the process's
508/// current directory and the same command gave opposite answers from the
509/// repository root and from a subdirectory of it; and a checkout moved with `mv`
510/// rather than `git worktree move` is fully alive while its back-pointer names
511/// nothing. Both ended with the key deleted and a live checkout left in the
512/// clear. A registration git would really prune costs the user one
513/// `git worktree prune`, which the message names.
514///
515/// **An unreadable registration directory refuses, it does not read as "none".**
516/// This listing *is* the evidence the whole refusal rests on, so a failure to
517/// take it has to be fatal here the way it is in [`collect`] — only the
518/// directory being absent means there are no linked worktrees. Measured before
519/// this distinction existed: `chmod 000 .git/worktrees` over a repository with
520/// one linked checkout took `lock --yes` all the way to "locked; key … has been
521/// deleted", left `../side/secrets/db.env` reading `TOP SECRET`, and `unlock`
522/// there answered "no repository key". The permission is only the cheapest
523/// trigger — an I/O error, an exhausted descriptor table or a stale network
524/// handle reach the same line, and every one of them is a question rather than
525/// an answer. Note the contrast with [`crate::git::repo::Repo::work_trees`], which
526/// swallows the identical failure on purpose: that list is only ever used to
527/// *widen* a refusal, so a short one costs nothing, while a short list here is
528/// what deletes the key.
529fn refuse_other_worktrees(repo: &Repo) -> Result<()> {
530 refuse_other_worktrees_with(repo, NOTHING_CHANGED)
531}
532
533/// [`refuse_other_worktrees`], with the sentence that is true where it is asked.
534///
535/// The question is the same on both sides of the encryption pass; what a refusal
536/// may claim about the repository is not. Before the pass nothing has been
537/// written, after it some files are ciphertext — and a refusal that says
538/// "nothing has been changed" over a half-converted working tree is the kind of
539/// wording this module treats as part of the safeguard rather than as decoration.
540fn refuse_other_worktrees_with(repo: &Repo, tail: &str) -> Result<()> {
541 let mut others = Vec::new();
542
543 let registrations = repo.common_dir().join("worktrees");
544 match fs::read_dir(®istrations) {
545 Ok(entries) => {
546 for entry in entries {
547 let entry = entry.map_err(|err| unverifiable_with(®istrations, &err, tail))?;
548 let registration = entry.path();
549 if same_path(®istration, repo.git_dir()) {
550 continue;
551 }
552 others.push(describe_worktree(repo, ®istration));
553 }
554 }
555 // The ordinary case: a repository that has never had a linked worktree
556 // has no such directory at all.
557 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
558 Err(err) => return Err(unverifiable_with(®istrations, &err, tail)),
559 }
560
561 // And, when this *is* a linked worktree, the main checkout — which is not
562 // listed anywhere under `worktrees/`.
563 if !same_path(repo.git_dir(), repo.common_dir())
564 && let Some(main) = main_checkout(repo)
565 {
566 others.push(main);
567 }
568
569 if others.is_empty() {
570 return Ok(());
571 }
572
573 others.sort();
574 let list = others
575 .iter()
576 .map(|what| format!(" {what}"))
577 .collect::<Vec<_>>()
578 .join("\n");
579 Err(Error::Config(format!(
580 "this repository has {} other checkout(s), and they all read the key lock \
581 would delete:\n\
582 {list}\n\
583 Locking only this one would leave their files in the clear with no key left \
584 to close them. Lock each checkout first, or remove it with \
585 `git worktree remove`; if one of them is already gone, `git worktree prune` \
586 clears the registration. {tail}",
587 others.len()
588 )))
589}
590
591/// Refuses if the working tree stopped matching what was surveyed and agreed to.
592///
593/// The prompt is an unbounded human-scale wait. An edit to a file already in the
594/// selection is caught later, by the object id [`survey`] recorded; this catches
595/// the other half — a declared file that **appeared** or **vanished** meanwhile,
596/// which no per-file check can see because it was never in the list.
597///
598/// `before` is the full name set of the first walk — selection and residue
599/// candidates alike, sorted and deduplicated — and the fresh walk here is
600/// reduced to exactly the same shape. Comparing anything narrower is how a
601/// tracked residue file, which the sweep declines and the selection may not
602/// contain, used to read as a file that "appeared" between the two walks.
603fn refuse_if_the_tree_moved(repo: &Repo, config: &Config, before: &[Vec<u8>]) -> Result<()> {
604 let mut walk = Walk::default();
605 let now = collect(repo, config, &mut walk, NOTHING_CHANGED)?;
606
607 let mut after: Vec<&[u8]> = now
608 .selected
609 .iter()
610 .chain(&now.residue)
611 .map(|file| file.name.as_slice())
612 .collect();
613 after.sort_unstable();
614 after.dedup();
615
616 let before: Vec<&[u8]> = before.iter().map(Vec::as_slice).collect();
617 if before == after {
618 return Ok(());
619 }
620 Err(Error::Config(
621 "the set of declared files changed while lock was running, so what it \
622 checked is no longer what is here. Nothing has been changed and the key \
623 has been kept; run lock again."
624 .into(),
625 ))
626}
627
628/// Refuses when a declared file appeared while the encryption pass was running.
629///
630/// The check above this one asks the same question of the *prompt*, and stops
631/// being able to answer it the moment the pass starts. The pass is not an
632/// instant: it reads, encrypts and rewrites every declared file, so it lasts as
633/// long as the secrets are large — minutes for a repository of big ones. A file
634/// created in that time is in no list this command holds, is not in the index
635/// either, and so is invisible to both gates that ran before it.
636///
637/// Measured on git 2.55, 2026-08-05, 4000 declared files: `printf … >
638/// secrets/late.env` once the pass had demonstrably started took `lock --yes` to
639/// exit **0**, "4000 file(s) are now encrypted and key 4ddee64b4e99741c has been
640/// deleted", with `secrets/late.env` reading `BRAND_NEW=hunter3` and no key left
641/// to close it.
642///
643/// **Additions only, which is what makes it safe to run here.** A name that
644/// disappeared is not a plaintext left behind — the sweep removes residue by
645/// design, and a deleted file holds nothing — so comparing whole sets would
646/// refuse over this command's own work. Only a name that was not there when the
647/// survey looked can hide an unexamined secret.
648fn refuse_if_a_declared_file_appeared(
649 repo: &Repo,
650 config: &Config,
651 before: &[Vec<u8>],
652) -> Result<()> {
653 let mut walk = Walk::default();
654 let now = collect(repo, config, &mut walk, KEY_KEPT)?;
655
656 let mut appeared: Vec<String> = now
657 .selected
658 .iter()
659 .chain(&now.residue)
660 .filter(|file| before.binary_search(&file.name).is_err())
661 .map(|file| git_spelling(&file.relative))
662 .collect();
663 if appeared.is_empty() {
664 return Ok(());
665 }
666
667 appeared.sort();
668 appeared.dedup();
669 Err(Error::Config(format!(
670 "refusing to delete the key: {} declared file(s) appeared while lock was \
671 encrypting, so nothing has looked at them and they are still in the \
672 clear — {}. Commit them, or take them out of the declaration, and run \
673 lock again. {KEY_KEPT}",
674 appeared.len(),
675 appeared.join(", ")
676 )))
677}
678
679/// Refuses while a declared file the index tracks still holds plain text.
680///
681/// The walk this command is built on reads directory entries; the index keeps
682/// whatever spelling a path was added under. On a case-insensitive filesystem —
683/// APFS, and NTFS with `core.ignorecase`, which git sets by default on both —
684/// those two can drift apart with **no signal anywhere**: after `mv secrets
685/// Secrets` the index still says `secrets/db.env`, `git status` is clean, and
686/// the walk used to select nothing, because selection matched bytes. Measured on
687/// git 2.55 before this existed: `lock --yes` printed "no file here is declared
688/// for encryption", exited 0, deleted the key, and left `hunter2-secret`
689/// readable in the working tree. The interactive path did the same after a typed
690/// `yes`.
691///
692/// **Selection folds ASCII case since 2026-08-05, and this gate stays.** The
693/// walk now recognises `Secrets/db.env` as declared, so the refusal usually
694/// comes earlier and from a different sentence — "declared, and not tracked
695/// under this name". That closes one route into the state, not the state: the
696/// index and the directory can still disagree over Unicode normalisation, over
697/// a spelling outside ASCII, or over a path the walk cannot read at all.
698///
699/// Asked last, after the encryption pass, because before it every declared path
700/// legitimately holds plain text — that is what the pass is for. Asked of the
701/// content rather than of the two spellings, which is what keeps it independent
702/// of what a pattern means on such a filesystem: whatever that answer is, a
703/// command that is one statement away from "the key is gone" must not make it
704/// over a file it can still read.
705///
706/// A declared entry with nothing at its path is not a finding: a staged deletion
707/// leaves the index naming a file that is gone, and there is no plain text in a
708/// file that does not exist. That is the **only** read failure treated as an
709/// answer — measured when it was not: with a declared file at mode `000` and the
710/// spellings already drifted apart, an earlier version of this gate skipped it,
711/// the key went, and the plain text was readable again the moment the mode was
712/// put back. Symlinks and gitlinks are skipped for the reason
713/// `index::Tracked::holds_content` gives — their blob is not file content and
714/// no filter ever ran on them.
715///
716/// An index that cannot be read is a refusal too, not a pass. This is the last
717/// gate in front of an irreversible step, and "I could not check" is the one
718/// answer it must never round down.
719fn refuse_if_a_declared_file_is_still_open(
720 repo: &Repo,
721 config: &Config,
722 hash: gix_hash::Kind,
723) -> Result<()> {
724 let index_path = repo.git_dir().join("index");
725 let entries = match index::list(&index_path, hash)? {
726 index::Listed::Read(entries) => entries,
727 index::Listed::Unavailable(why) => {
728 return Err(Error::Config(format!(
729 "{} could not be read ({why}), so lock cannot prove that every \
730 declared file here is closed. The key has been kept and every \
731 file it did encrypt is encrypted; nothing else has changed.",
732 index_path.display()
733 )));
734 }
735 };
736
737 let mut open: Vec<String> = Vec::new();
738 for entry in entries {
739 if !entry.holds_content() || !config.decide(&entry.path).encrypt {
740 continue;
741 }
742 let path = repo
743 .work_tree()
744 .join(crate::git::repo::working_tree_path(&entry.path));
745 // Only the header is needed, and reading the whole file would be a
746 // pointless copy of a secret into this process for the large ones.
747 let mut head = [0u8; crate::crypto::format::MAGIC.len()];
748 let read = std::fs::File::open(&path).and_then(|mut file| {
749 use std::io::Read as _;
750 file.read(&mut head)
751 });
752 match read {
753 Ok(read) if head[..read] == crate::crypto::format::MAGIC => {}
754 // Nothing at that path: a staged deletion leaves the index naming a
755 // file that is gone, and a file that does not exist holds no plain
756 // text. The **only** failure that is an answer rather than a
757 // question — measured before it was the only one: with a declared
758 // file at mode `000` this arm swallowed the refusal, the key went,
759 // and the plain text was readable again the moment the mode was put
760 // back.
761 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
762 Ok(_) => open.push(bstr::BStr::new(&entry.path).to_string()),
763 Err(err) => {
764 return Err(Error::Config(format!(
765 "refusing to delete the key: {} is declared and tracked, and \
766 could not be read ({err}), so lock cannot show it is closed. \
767 The key has been kept and every file lock did encrypt is \
768 encrypted; nothing else has changed.",
769 path.display()
770 )));
771 }
772 }
773 }
774
775 if open.is_empty() {
776 return Ok(());
777 }
778 open.sort();
779 Err(Error::Config(format!(
780 "refusing to delete the key: {} declared file(s) the index tracks still \
781 hold plain text in the working tree, so locking now would leave them \
782 readable with no key left to close them — {}. The usual cause is a name \
783 spelled one way in the index and another on disk, which a \
784 case-insensitive filesystem hides completely: `git ls-files` shows the \
785 index's spelling, `ls` shows the disk's. Rename the file to the \
786 spelling `git ls-files` gives, or declare it as it is on disk, then run \
787 lock again. The key has been kept and every file lock did encrypt is \
788 encrypted.",
789 open.len(),
790 open.join(", ")
791 )))
792}
793
794/// Writes the managed section only when the catch-all line is missing entirely.
795///
796/// The line `* filter=git-xcrypt` is the one thing the whole guarantee hangs on,
797/// and git reads a missing attribute exactly as it reads a missing driver — as
798/// no filter. Everything else in the section is cosmetic and is `sync`'s job, so
799/// a section that is merely out of date is left alone: rewriting it would leave
800/// a modified `.gitattributes` behind a command that reported success and has
801/// already deleted the key.
802fn write_catch_all_if_missing(repo: &Repo, config: &Config) -> Result<bool> {
803 let path = repo.attributes_path();
804 if crate::git::attributes::catch_all_present(&path)? {
805 return Ok(false);
806 }
807 crate::git::attributes::write_section(
808 &path,
809 &crate::git::attributes::render_lines_as_written(&path, config),
810 )
811}
812
813/// Names a linked checkout for the refusal, however little can be read.
814fn describe_worktree(repo: &Repo, registration: &Path) -> String {
815 let name = registration.file_name().map_or_else(
816 || registration.display().to_string(),
817 |name| name.to_string_lossy().into_owned(),
818 );
819
820 // For the message only — never as evidence of whether the checkout is
821 // there. A relative pointer is resolved against the registration, which is
822 // where git measures it from, not against the process's current directory.
823 let Ok(text) = fs::read_to_string(registration.join("gitdir")) else {
824 return name;
825 };
826 let pointer = Path::new(text.trim_end_matches(['\n', '\r']));
827 if pointer.as_os_str().is_empty() {
828 return name;
829 }
830 let absolute = if pointer.is_absolute() {
831 pointer.to_path_buf()
832 } else {
833 crate::git::repo::lexically_normal(®istration.join(pointer))
834 };
835 let checkout = absolute.parent().unwrap_or(&absolute);
836 let _ = repo;
837 format!("{name} at {}", checkout.display())
838}
839
840/// Where the main checkout is, when this command runs in a linked one.
841///
842/// Not "the parent of the common directory": with `git init --separate-git-dir`
843/// the common directory is somewhere else entirely and is not called `.git`.
844/// Git finds the checkout through `core.worktree` in that case, so this does
845/// too. Measured before this: from a linked worktree of a separate-git-dir
846/// repository, `lock` deleted the key and left the main checkout not merely in
847/// the clear but unable to run `git status` at all, since `required = true` and
848/// no key makes every filter call fail.
849///
850/// When neither route answers, the checkout is reported as unknown rather than
851/// assumed absent — this function decides whether to refuse, and guessing "no
852/// checkout" is the guess that deletes the key.
853///
854/// **That applies to a configuration this build cannot read, too.** An earlier
855/// version turned an unparseable or unreadable `config` into `None`, which is
856/// the same guess by another route: a linked worktree would then have found no
857/// main checkout to refuse over. Measured on 2026-08-05, both shapes — a bad
858/// section header and `chmod 000` — happen to be caught a few lines later by
859/// [`gitconfig::open_full`], which reads the same file with `?`, so no run has
860/// ever reached the bad state. That is an accident of ordering rather than a
861/// guarantee, and it is exactly the shape the rest of this module refuses to
862/// rely on. A healthy repository is unaffected: its configuration parses, so
863/// this branch is unreachable there.
864fn main_checkout(repo: &Repo) -> Option<String> {
865 let Ok(config) = gitconfig::open_local(&repo.config_path()) else {
866 return Some(
867 "the main checkout, whose configuration this build could not read \
868 — so whether it has one, and where, is unknown"
869 .into(),
870 );
871 };
872 // `is_true`, not a comparison against one spelling: git accepts `1`, `yes`
873 // and `on` beside `true`, and `gitconfig::is_true` documents that every
874 // caller branching on a git boolean must accept the same set. Measured
875 // with `core.bare = 1`: `lock` from the only worktree of a bare repository
876 // refused over "the main checkout, whose location this build could not
877 // determine" — a checkout that does not exist — while `core.bare = true`
878 // locked the same tree.
879 if gitconfig::get(&config, "core.bare").is_some_and(|value| gitconfig::is_true(&value)) {
880 // A bare repository has no checkout of its own to strand.
881 return None;
882 }
883
884 if let Some(declared) = gitconfig::get(&config, "core.worktree")
885 && !declared.is_empty()
886 {
887 let path = Path::new(&declared);
888 let absolute = if path.is_absolute() {
889 path.to_path_buf()
890 } else {
891 crate::git::repo::lexically_normal(&repo.common_dir().join(path))
892 };
893 return Some(format!("the main checkout at {}", absolute.display()));
894 }
895
896 if repo.common_dir().file_name() == Some(std::ffi::OsStr::new(".git"))
897 && let Some(main) = repo.common_dir().parent()
898 {
899 return Some(format!("the main checkout at {}", main.display()));
900 }
901
902 Some("the main checkout, whose location this build could not determine".into())
903}
904
905/// Whether two paths name the same place, without insisting they exist.
906fn same_path(left: &Path, right: &Path) -> bool {
907 if left == right {
908 return true;
909 }
910 match (left.canonicalize(), right.canonicalize()) {
911 (Ok(left), Ok(right)) => left == right,
912 _ => false,
913 }
914}
915
916/// A working-tree file this command has something to say about.
917#[derive(Debug)]
918struct Candidate {
919 /// Absolute path in the working tree.
920 path: PathBuf,
921 /// Repository-relative path, as the matcher and the index spell it.
922 name: Vec<u8>,
923 /// The same path, for messages.
924 relative: PathBuf,
925}
926
927/// What one walk of the working tree turned up.
928#[derive(Debug, Default)]
929struct Found {
930 /// Files the declaration selects for encryption.
931 selected: Vec<Candidate>,
932 /// Files shaped like the residue of an interrupted run of our own.
933 residue: Vec<Candidate>,
934}
935
936/// What the walk noticed on its way through, besides the files it selected.
937#[derive(Debug, Default)]
938struct Walk {
939 /// Messages for the user.
940 warnings: Vec<String>,
941}
942
943/// Every selected file in the working tree, in a stable order.
944///
945/// Unlike `unlock`'s walk, a path that cannot be read is fatal here. `lock`
946/// promises that no plaintext of a selected path survives it, and a directory it
947/// could not list may hold one — reporting success over that would be a lie in
948/// the one direction that matters. The user can fix the permission and run again;
949/// there is no equivalent repair for a secret left behind.
950///
951/// Symbolic links are left alone: git does not filter them, following one would
952/// write outside the repository, and replacing it would destroy the link.
953///
954/// **A directory holding a `.git` entry is another repository and is not
955/// entered.** A submodule has its own key, its own index and its own
956/// declaration; it needs its own `lock`.
957fn collect(repo: &Repo, config: &Config, walk: &mut Walk, tail: &str) -> Result<Found> {
958 let mut found = Found::default();
959 let mut pending = vec![repo.work_tree().to_path_buf()];
960
961 while let Some(directory) = pending.pop() {
962 let entries =
963 fs::read_dir(&directory).map_err(|err| unverifiable_with(&directory, &err, tail))?;
964
965 for entry in entries {
966 let entry = entry.map_err(|err| unverifiable_with(&directory, &err, tail))?;
967 if entry.file_name() == ".git" {
968 continue;
969 }
970
971 let path = entry.path();
972 let metadata =
973 fs::symlink_metadata(&path).map_err(|err| unverifiable_with(&path, &err, tail))?;
974 if metadata.is_symlink() {
975 continue;
976 }
977 if metadata.is_dir() {
978 if path.join(".git").exists() {
979 walk.warnings.push(format!(
980 "{}: a repository of its own, left to its own `git-xcrypt lock`",
981 git_spelling(&relative_to(repo, &path))
982 ));
983 } else {
984 pending.push(path);
985 }
986 continue;
987 }
988 if !metadata.is_file() {
989 continue;
990 }
991
992 let relative = relative_to(repo, &path);
993 let name = repo_relative_bytes(&relative);
994
995 // Residue of `secrets/db.env` is called
996 // `secrets/db.env.git-xcrypt-<hex>.tmp` and may hold that file's
997 // plaintext, so it is a candidate for deletion — but only a
998 // candidate: `sweepable` adds the condition that makes deleting it
999 // safe. Being a candidate does **not** take it out of the selection
1000 // below, because the filter's answer for this path does not change
1001 // and lock must not encrypt a different set of files from git.
1002 if let Some(target) = atomic::strip_temporary_suffix(&name) {
1003 if config.decide(target).encrypt {
1004 found.residue.push(Candidate {
1005 path: path.clone(),
1006 name: name.clone(),
1007 relative: relative.clone(),
1008 });
1009 } else if atomic::target_may_have_been_shortened(file_name_of(&name)) {
1010 // The declaration says no — but on a name at the ceiling
1011 // that answer was given about a *cut* target, so it means
1012 // nothing. Refusing is the direction this whole command
1013 // leans: everything it cannot verify, it refuses over.
1014 // Warning and carrying on is what it did until 2026-08-11,
1015 // and the measured cost was a deleted key, exit code 0, and
1016 // `AWS_SECRET=hunter2` left in the working tree — untracked,
1017 // and not matching the pattern that would have encrypted it,
1018 // so the next `git add -A` would have committed it in the
1019 // clear.
1020 return Err(Error::Config(format!(
1021 "{}: lock cannot promise this working tree holds no plaintext. \
1022 This is shaped like a temporary file of ours and its name is at \
1023 the length limit, so the name it was built from was cut short \
1024 and no longer identifies anything — which means this file may \
1025 hold the decrypted content of a declared path, and lock cannot \
1026 tell. Look at it, then delete it if it is left over from an \
1027 interrupted run, or move it aside if it is yours, and run lock \
1028 again. {tail}",
1029 git_spelling(&relative)
1030 )));
1031 } else {
1032 walk.warnings.push(format!(
1033 "{}: shaped like a temporary file of ours, but nothing \
1034 declares its target, so it was left alone",
1035 git_spelling(&relative)
1036 ));
1037 }
1038 }
1039
1040 if !config.decide(&name).encrypt {
1041 continue;
1042 }
1043 found.selected.push(Candidate {
1044 path,
1045 name,
1046 relative,
1047 });
1048 }
1049 }
1050
1051 found
1052 .selected
1053 .sort_by(|left, right| left.path.cmp(&right.path));
1054 found
1055 .residue
1056 .sort_by(|left, right| left.path.cmp(&right.path));
1057 Ok(found)
1058}
1059
1060/// The closing sentence of a refusal made before anything has been written.
1061const NOTHING_CHANGED: &str = "Nothing has been changed.";
1062
1063/// The closing sentence of a refusal made on the far side of the encryption pass.
1064///
1065/// Everything before the pass can truthfully say nothing moved; everything after
1066/// it cannot, and the difference is what tells a reader whether to expect a
1067/// half-converted working tree. Running `lock` again from either state finishes
1068/// the job — a file that is already ciphertext is skipped.
1069const KEY_KEPT: &str = "The key has been kept and every file lock did encrypt is \
1070 encrypted; nothing else has changed.";
1071
1072/// The refusal for anything that stops `lock` proving its own promise.
1073fn unverifiable(path: &Path, err: &std::io::Error) -> Error {
1074 unverifiable_with(path, err, NOTHING_CHANGED)
1075}
1076
1077/// [`unverifiable`], with the sentence that is true where it is raised.
1078fn unverifiable_with(path: &Path, err: &std::io::Error, tail: &str) -> Error {
1079 Error::Config(format!(
1080 "{}: lock cannot promise this working tree holds no plaintext, because this \
1081 path could not be read ({err}). {tail}",
1082 path.display()
1083 ))
1084}
1085
1086/// The object ids the index records, for everything this command looked at.
1087#[derive(Debug, Default)]
1088struct Stored {
1089 /// One entry per selected file, in the same order.
1090 selected: Vec<Option<Vec<u8>>>,
1091 /// One entry per residue candidate, in the same order.
1092 residue: Vec<Option<Vec<u8>>>,
1093}
1094
1095/// Asks the index about every path at once, or refuses if it cannot be read.
1096///
1097/// One read rather than two: the index is a snapshot, and asking twice could see
1098/// two different ones. An index this build cannot parse is a hard refusal —
1099/// "cannot tell" must never be mistaken for "nothing is at risk" by a command
1100/// that deletes the key.
1101fn stored_ids(
1102 repo: &Repo,
1103 hash: gix_hash::Kind,
1104 selected: &[Candidate],
1105 residue: &[Candidate],
1106) -> Result<Stored> {
1107 if selected.is_empty() && residue.is_empty() {
1108 return Ok(Stored::default());
1109 }
1110
1111 let index_path = repo.git_dir().join("index");
1112 let names: Vec<Vec<u8>> = selected
1113 .iter()
1114 .chain(residue)
1115 .map(|file| file.name.clone())
1116 .collect();
1117
1118 match index::staged_ids(&index_path, hash, &names)? {
1119 index::Staged::Read(mut ids) => {
1120 let tail = ids.split_off(selected.len());
1121 Ok(Stored {
1122 selected: ids,
1123 residue: tail,
1124 })
1125 }
1126 index::Staged::Unavailable(why) => Err(Error::Config(format!(
1127 "lock cannot tell whether your work is safe: {} could not be used because \
1128 {why}. Nothing has been changed.\n\
1129 For a split index, `git update-index --no-split-index` converts it back.",
1130 index_path.display()
1131 ))),
1132 }
1133}
1134
1135/// Why a file's content is not stored in this repository.
1136///
1137/// Three states, because the remedies are three different commands and telling
1138/// the user the wrong one wastes the only chance they have to notice.
1139#[derive(Debug, Clone, Copy)]
1140enum Unstored {
1141 /// The index has no stage-0 entry: never added, or mid-merge.
1142 Untracked,
1143 /// The index holds this content **in the clear** — an exposure, not an edit.
1144 InTheClear,
1145 /// The index holds something else: an ordinary unsaved change.
1146 Modified,
1147}
1148
1149/// What the pre-flight pass learned about the selected files.
1150#[derive(Debug, Default)]
1151struct Survey {
1152 /// The object id each file's ciphertext hashes to, in selection order.
1153 ///
1154 /// Carried into the encryption pass and compared again there. Without it the
1155 /// window between "proved stored" and "written" is the whole length of an
1156 /// interactive prompt — an unbounded human-scale wait, during which an
1157 /// editor autosave turns proved-safe content into content that exists
1158 /// nowhere, and the key is deleted over it anyway.
1159 expected: Vec<Vec<u8>>,
1160 /// How many selected files are still plaintext and will actually change.
1161 still_open: usize,
1162}
1163
1164/// Proves every selected file's content is already a blob, or refuses.
1165///
1166/// The test is exact and needs no object database: the index records the object
1167/// id of every tracked path's **cleaned** content, encryption is deterministic,
1168/// so hashing the ciphertext the clean path would produce and comparing to that
1169/// id answers "is this exact content already a blob here".
1170///
1171/// `--yes` never reaches this function, and that is the point. Losing the key
1172/// and losing unsaved work are different risks; the founding document gives each
1173/// its own decision, and only the first has a flag.
1174fn survey(
1175 key: &MasterKey,
1176 config: &Config,
1177 selected: &[Candidate],
1178 stored: &[Option<Vec<u8>>],
1179 hash: gix_hash::Kind,
1180) -> Result<Survey> {
1181 let mut survey = Survey::default();
1182 let mut unstored: Vec<(&Path, Unstored)> = Vec::new();
1183
1184 for (file, stored) in selected.iter().zip(stored) {
1185 // Zeroizing: this is the secret, in the clear on the heap.
1186 let content =
1187 Zeroizing::new(fs::read(&file.path).map_err(|err| unverifiable(&file.path, &err))?);
1188 let outcome = decide::clean(Some(key), config, &file.name, &content)
1189 .map_err(|err| named(&file.relative, err))?;
1190
1191 let closed = outcome.content == *content;
1192 if !closed {
1193 survey.still_open += 1;
1194 }
1195
1196 let id = blob_id(hash, &outcome.content, &file.relative)?;
1197 if stored.as_deref() != Some(id.as_slice()) {
1198 let reason = match stored {
1199 None => Unstored::Untracked,
1200 Some(stored)
1201 if !closed
1202 && stored.as_slice()
1203 == blob_id(hash, &content, &file.relative)?.as_slice() =>
1204 {
1205 Unstored::InTheClear
1206 }
1207 Some(_) => Unstored::Modified,
1208 };
1209 unstored.push((&file.relative, reason));
1210 }
1211 survey.expected.push(id);
1212 }
1213
1214 if unstored.is_empty() {
1215 return Ok(survey);
1216 }
1217 Err(refusal(&unstored))
1218}
1219
1220/// The blob id of `content`, or a refusal naming the file it belonged to.
1221fn blob_id(hash: gix_hash::Kind, content: &[u8], relative: &Path) -> Result<Vec<u8>> {
1222 index::blob_id(hash, content).ok_or_else(|| {
1223 Error::Config(format!(
1224 "{}: its object id could not be computed, so lock cannot tell whether it \
1225 is stored. Nothing has been changed.",
1226 git_spelling(relative)
1227 ))
1228 })
1229}
1230
1231/// The refusal, grouped so each group carries the remedy that actually works.
1232fn refusal(unstored: &[(&Path, Unstored)]) -> Error {
1233 let mut message = format!(
1234 "{} file(s) hold content lock cannot prove this repository stores:\n",
1235 unstored.len()
1236 );
1237
1238 let groups = [
1239 (
1240 "not tracked here at all, or in the middle of a merge — `git add` them \
1241 (with `-f` if they are ignored), or take them out of the declaration",
1242 Unstored::Untracked,
1243 ),
1244 (
1245 "stored in the clear: the repository holds this exact plaintext, from \
1246 before the pattern covered it. `git add` re-stages it through the filter; \
1247 the plaintext already in history stays there, so rotate the secret",
1248 Unstored::InTheClear,
1249 ),
1250 (
1251 // `git add` leads, because it is the one remedy that works for all
1252 // of them. `git add -N` records the empty blob, so a path that was
1253 // only ever intent-to-add lands in this group and `git commit`
1254 // refuses it outright — measured on git 2.55.
1255 "changed since they were last added — `git add` them, then commit or \
1256 stash",
1257 Unstored::Modified,
1258 ),
1259 ];
1260
1261 for (explanation, wanted) in groups {
1262 let paths: Vec<&Path> = unstored
1263 .iter()
1264 .filter(|(_, reason)| std::mem::discriminant(reason) == std::mem::discriminant(&wanted))
1265 .map(|(path, _)| *path)
1266 .collect();
1267 if paths.is_empty() {
1268 continue;
1269 }
1270 message.push_str(&format!("\n {explanation}:\n"));
1271 for path in paths {
1272 message.push_str(&format!(" {}\n", git_spelling(path)));
1273 }
1274 }
1275
1276 message.push_str(
1277 "\nlock would leave that content readable only with the key it is about to \
1278 delete.\n\
1279 --yes does not waive this check: losing unsaved work is a different risk from \
1280 losing the key. Nothing has been changed.",
1281 );
1282 Error::Config(message)
1283}
1284
1285/// Encrypts each selected file in place, returning the paths git must re-examine.
1286///
1287/// [`decide::clean`] and nothing else: a second implementation of line-ending
1288/// handling here would drift from the check-in path, and the drift would show up
1289/// as a working tree git reports as modified for reasons nobody can find.
1290///
1291/// A file that is already our ciphertext comes back unchanged — `clean` verifies
1292/// its tag and its `key_id` before saying so — and is skipped, which is what
1293/// makes a second `lock` after an interrupted one finish the job rather than
1294/// double-encrypt.
1295///
1296/// Every file is checked against the id [`survey`] recorded before writing it,
1297/// and a mismatch stops the run before that write. The file is read twice
1298/// because holding every selected file's ciphertext in memory at once is what
1299/// turns a repository of large secrets into a failed allocation; re-checking is
1300/// what keeps the second read from silently replacing the first.
1301fn encrypt_in_place(
1302 key: &MasterKey,
1303 config: &Config,
1304 selected: &[Candidate],
1305 survey: &Survey,
1306 hash: gix_hash::Kind,
1307 report: &mut Report,
1308) -> Result<Vec<Vec<u8>>> {
1309 let mut rewritten = Vec::new();
1310
1311 for (file, expected) in selected.iter().zip(&survey.expected) {
1312 let content = Zeroizing::new(
1313 fs::read(&file.path).map_err(|err| named_io(&file.relative, "read", &err))?,
1314 );
1315 let outcome = decide::clean(Some(key), config, &file.name, &content)
1316 .map_err(|err| named(&file.relative, err))?;
1317
1318 if blob_id(hash, &outcome.content, &file.relative)? != *expected {
1319 return Err(Error::Config(format!(
1320 "{}: it changed while lock was running, so what is in it now is not \
1321 what lock proved this repository stores. The key has been kept.",
1322 git_spelling(&file.relative)
1323 )));
1324 }
1325
1326 if let Some(warning) = outcome.warning {
1327 report.warnings.push(warning);
1328 }
1329 if outcome.content == *content {
1330 continue;
1331 }
1332
1333 // Atomic, and inheriting the file's own mode, so an interruption cannot
1334 // leave a half-written file and an executable stays executable.
1335 atomic::write(&file.path, &outcome.content).map_err(|err| named(&file.relative, err))?;
1336 rewritten.push(file.name.clone());
1337 report.encrypted.push(file.relative.clone());
1338 }
1339
1340 Ok(rewritten)
1341}
1342
1343/// Narrows the residue candidates to the ones it is safe to delete.
1344///
1345/// Two conditions beyond the name, and both exist because this list is a
1346/// **deletion** list. The target has to be a path the declaration selects, which
1347/// [`collect`] has already checked; and the file has to be untracked, which it
1348/// always is for residue of ours — git never saw it. Anything tracked with that
1349/// shape belongs to the user, however unlikely the name, and deleting it would
1350/// both destroy their file and leave `git status` reporting a deletion nobody
1351/// asked for. Measured: without this, a committed
1352/// `build.git-xcrypt-deadbeefcafef00d.tmp` was removed and the tree left dirty.
1353fn sweepable<'a>(
1354 residue: &'a [Candidate],
1355 stored: &[Option<Vec<u8>>],
1356 walk: &mut Walk,
1357) -> Vec<&'a Candidate> {
1358 let mut sweepable = Vec::new();
1359 for (file, stored) in residue.iter().zip(stored) {
1360 if stored.is_some() {
1361 walk.warnings.push(format!(
1362 "{}: shaped like a temporary file of ours, but it is tracked, so it \
1363 was left alone",
1364 git_spelling(&file.relative)
1365 ));
1366 continue;
1367 }
1368 sweepable.push(file);
1369 }
1370 sweepable
1371}
1372
1373/// Takes the files the sweep will remove out of the encryption list.
1374///
1375/// A path cannot be both deleted and encrypted, and residue is untracked by
1376/// construction, so leaving it in would make the unstored check refuse every
1377/// lock that has any. Anything the sweep declined — a *tracked* file of that
1378/// shape — stays, because git's filter still encrypts it and the two must not
1379/// disagree about which files are secrets.
1380///
1381/// The index answers are filtered in the same pass rather than truncated
1382/// afterwards: they are positional, so dropping a file anywhere but the end
1383/// would leave every later file compared against its neighbour's object id.
1384fn drop_swept(
1385 selected: Vec<Candidate>,
1386 stored: Vec<Option<Vec<u8>>>,
1387 residue: &[&Candidate],
1388) -> (Vec<Candidate>, Vec<Option<Vec<u8>>>) {
1389 let doomed: Vec<&[u8]> = residue.iter().map(|file| file.name.as_slice()).collect();
1390 let mut kept = Vec::with_capacity(selected.len());
1391 let mut ids = Vec::with_capacity(selected.len());
1392
1393 for (file, id) in selected.into_iter().zip(stored) {
1394 if doomed.contains(&file.name.as_slice()) {
1395 continue;
1396 }
1397 kept.push(file);
1398 ids.push(id);
1399 }
1400 (kept, ids)
1401}
1402
1403/// Deletes the residue of an earlier, killed run.
1404///
1405/// Best effort by design: one file that will not delete must not stop a lock
1406/// that is otherwise complete, but it does have to be said out loud, because the
1407/// thing left behind may be a decrypted secret.
1408fn sweep(residue: &[&Candidate], report: &mut Report) {
1409 for file in residue {
1410 match fs::remove_file(&file.path) {
1411 Ok(()) => report.swept.push(file.relative.clone()),
1412 Err(err) => report.warnings.push(format!(
1413 "{}: a temporary file left by an interrupted run could not be removed \
1414 ({err}); it may hold a decrypted secret",
1415 git_spelling(&file.relative)
1416 )),
1417 }
1418 }
1419}
1420
1421/// Adds what was already done to an error that stopped the encryption pass.
1422///
1423/// Returning the bare error would drop the report, and with it the only record
1424/// that some files are now ciphertext and some temporary files are gone. The
1425/// key is still here — nothing after this point ran — so the instruction is to
1426/// run the command again.
1427fn interrupted(report: &Report, err: Error) -> Error {
1428 let done = report.encrypted.len();
1429 let swept = report.swept.len();
1430 let context = format!(
1431 "\nlock stopped part way: {done} file(s) were already encrypted and {swept} \
1432 temporary file(s) removed. The key has NOT been deleted, so running lock \
1433 again finishes the job."
1434 );
1435 match err {
1436 Error::Format(message) => Error::Format(message + &context),
1437 Error::Crypto(message) => Error::Crypto(message + &context),
1438 Error::Config(message) => Error::Config(message + &context),
1439 Error::Io(err) => Error::Io(std::io::Error::other(format!("{err}{context}"))),
1440 other => other,
1441 }
1442}
1443
1444/// Puts a path and the operation in front of a bare I/O failure.
1445///
1446/// `Permission denied (os error 13)` on its own names neither the file nor what
1447/// was being done to it, which for a command mid-way through rewriting a working
1448/// tree is the least useful message it could produce.
1449fn named_io(relative: &Path, action: &str, err: &std::io::Error) -> Error {
1450 Error::Io(std::io::Error::other(format!(
1451 "{}: could not {action} it ({err})",
1452 git_spelling(relative)
1453 )))
1454}
1455
1456/// Removes the key file, which is the irreversible half of the command.
1457fn remove_key(repo: &Repo, report: &mut Report) -> Result<()> {
1458 let path = repo.key_path();
1459 match fs::remove_file(&path) {
1460 Ok(()) => {
1461 report.key_removed = true;
1462 Ok(())
1463 }
1464 // Someone else got there first. The working tree is already closed, so
1465 // this is the outcome that was asked for, not a failure.
1466 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1467 report.key_removed = true;
1468 Ok(())
1469 }
1470 Err(err) => Err(Error::Io(std::io::Error::other(format!(
1471 "{}: the working tree is encrypted but the key could not be removed \
1472 ({err}); run lock again once that is fixed",
1473 path.display()
1474 )))),
1475 }
1476}
1477
1478/// Puts a path in front of an error that only knew about content.
1479///
1480/// The exit code is preserved throughout: a foreign `key_id` becomes a format
1481/// error, which is the code [`Error::KeyMismatch`] reports anyway, and gains the
1482/// file name its own message cannot carry — "this file was encrypted with key …"
1483/// names no file. `NoKey` is passed through untouched, because it is about the
1484/// repository rather than about any one path.
1485fn named(relative: &Path, err: Error) -> Error {
1486 let at = git_spelling(relative);
1487 match err {
1488 Error::Format(message) => Error::Format(format!("{at}: {message}")),
1489 Error::Crypto(message) => Error::Crypto(format!("{at}: {message}")),
1490 Error::Config(message) => Error::Config(format!("{at}: {message}")),
1491 Error::Io(err) => Error::Io(std::io::Error::other(format!("{at}: {err}"))),
1492 mismatch @ Error::KeyMismatch { .. } => Error::Format(format!("{at}: {mismatch}")),
1493 other => other,
1494 }
1495}
1496
1497/// A path relative to the working tree, or the path itself if it is outside.
1498fn relative_to(repo: &Repo, path: &Path) -> PathBuf {
1499 repo.relative(path)
1500 .map_or_else(|| path.to_path_buf(), Path::to_path_buf)
1501}
1502
1503/// A repository-relative path as the pattern matcher expects it.
1504///
1505/// Bytes rather than text, and forward slashes: on Unix a path is an arbitrary
1506/// byte string, and decoding it lossily would match a file under a name it does
1507/// not have.
1508fn repo_relative_bytes(relative: &Path) -> Vec<u8> {
1509 os_str_bytes(relative)
1510}
1511
1512/// The last component of a repository-relative name.
1513///
1514/// The length ceiling a temporary name runs into is per **component** —
1515/// `NAME_MAX`, not `PATH_MAX` — so the question about it has to be asked of the
1516/// component and not of the path that carries it. Both platforms spell these
1517/// with `/`; see [`os_str_bytes`], which normalises the Windows form.
1518fn file_name_of(name: &[u8]) -> &[u8] {
1519 name.rsplit(|byte| *byte == b'/').next().unwrap_or(name)
1520}
1521
1522fn os_str_bytes(path: &Path) -> Vec<u8> {
1523 #[cfg(unix)]
1524 {
1525 use std::os::unix::ffi::OsStrExt as _;
1526 path.as_os_str().as_bytes().to_vec()
1527 }
1528 #[cfg(not(unix))]
1529 {
1530 path.to_string_lossy().replace('\\', "/").into_bytes()
1531 }
1532}
1533
1534#[cfg(test)]
1535mod tests {
1536 use super::*;
1537 use std::process::Command;
1538 use tempfile::TempDir;
1539
1540 /// A confirmer that answers as told and keeps what it was shown.
1541 struct Scripted {
1542 answer: bool,
1543 shown: String,
1544 }
1545
1546 impl Scripted {
1547 fn new(answer: bool) -> Self {
1548 Self {
1549 answer,
1550 shown: String::new(),
1551 }
1552 }
1553 }
1554
1555 impl Confirm for Scripted {
1556 fn confirm(&mut self, warning: &Warning) -> Result<bool> {
1557 self.shown = warning.to_string();
1558 Ok(self.answer)
1559 }
1560 }
1561
1562 fn git(dir: &Path, args: &[&str]) -> std::process::Output {
1563 let output = Command::new("git")
1564 .args(args)
1565 .current_dir(dir)
1566 .output()
1567 .expect("git must be on PATH");
1568 assert!(
1569 output.status.success(),
1570 "git {args:?} failed: {}",
1571 String::from_utf8_lossy(&output.stderr)
1572 );
1573 output
1574 }
1575
1576 fn init_repo() -> TempDir {
1577 let dir = TempDir::new().expect("temporary directory");
1578 git(dir.path(), &["init", "-q"]);
1579 git(dir.path(), &["config", "user.name", "t"]);
1580 git(dir.path(), &["config", "user.email", "t@t.invalid"]);
1581 dir
1582 }
1583
1584 /// A repository set up by `init`, with `secrets/` declared.
1585 fn prepared() -> (TempDir, Repo) {
1586 let dir = init_repo();
1587 let repo = Repo::discover(dir.path()).expect("discovery");
1588 super::super::init::run(&repo).expect("init must succeed");
1589 fs::write(repo.xcrypt_config_path(), "secrets/\n").expect("declarations");
1590 (dir, repo)
1591 }
1592
1593 /// Writes `content` at `relative` and stages the bytes it would clean to.
1594 ///
1595 /// Staging goes through `hash-object` and `update-index` rather than
1596 /// `git add`, because `init` registered the *test* binary as the filter and
1597 /// git cannot run it. What lands in the index is exactly what a real
1598 /// `git add` would have put there.
1599 fn write_and_stage(repo: &Repo, dir: &TempDir, relative: &str, content: &[u8]) {
1600 let path = repo.work_tree().join(relative);
1601 fs::create_dir_all(path.parent().expect("a parent")).expect("directories");
1602 fs::write(&path, content).expect("writing");
1603
1604 let config = Config::load(&repo.xcrypt_config_path()).expect("declarations");
1605 let key = repo.load_key().ok();
1606 let cleaned = decide::clean(key.as_ref(), &config, relative.as_bytes(), content)
1607 .expect("the clean path must succeed")
1608 .content;
1609
1610 let blob = dir.path().join("staged.bin");
1611 fs::write(&blob, &cleaned).expect("writing");
1612 let hashed = git(
1613 dir.path(),
1614 &["hash-object", "-w", "--no-filters", "--", "staged.bin"],
1615 );
1616 fs::remove_file(&blob).expect("removing");
1617 let oid = String::from_utf8(hashed.stdout).expect("git printed a hash");
1618 git(
1619 dir.path(),
1620 &[
1621 "update-index",
1622 "--add",
1623 "--cacheinfo",
1624 &format!("100644,{},{relative}", oid.trim()),
1625 ],
1626 );
1627 }
1628
1629 #[test]
1630 fn a_declared_file_that_appears_while_the_prompt_waits_stops_the_run() {
1631 // The other half of the same window. An edit to a file already surveyed
1632 // is caught by its recorded object id; a file that was never in the list
1633 // cannot be, and locking around it would delete the key over a plaintext
1634 // secret nobody mentioned. Measured before this check: a file created
1635 // 1.5 s into the prompt survived a successful lock, in the clear.
1636 struct Meddling(PathBuf);
1637 impl Confirm for Meddling {
1638 fn confirm(&mut self, _warning: &Warning) -> Result<bool> {
1639 fs::write(&self.0, b"brand new secret\n").expect("writing");
1640 Ok(true)
1641 }
1642 }
1643
1644 let (dir, repo) = prepared();
1645 write_and_stage(&repo, &dir, "secrets/db.env", b"hunter2\n");
1646 let late = repo.work_tree().join("secrets").join("late.env");
1647
1648 let error = run(&repo, &mut Meddling(late.clone()))
1649 .expect_err("a secret that appeared must not be locked around");
1650
1651 assert_eq!(error.exit_code(), crate::util::exit::CONFIG);
1652 assert!(repo.has_key(), "the key went over a file nobody checked");
1653 assert_eq!(
1654 fs::read(&late).expect("reading"),
1655 b"brand new secret\n",
1656 "the new file was encrypted without ever being checked"
1657 );
1658 assert_eq!(
1659 fs::read(repo.work_tree().join("secrets/db.env")).expect("reading"),
1660 b"hunter2\n",
1661 "the run wrote something despite refusing"
1662 );
1663 }
1664
1665 /// A file large enough that encrypting it outlasts anything a test does.
1666 ///
1667 /// The synchronisation is the pass's own output, not a timer: the meddling
1668 /// thread waits until the first declared file on disk really is ciphertext,
1669 /// which cannot happen before both pre-flight gates have passed. What the
1670 /// megabyte buys is only the margin on the other side — a debug build cleans
1671 /// roughly 2 MiB a second, so the write that follows the signal lands a
1672 /// thousandfold inside the window rather than racing it.
1673 const SLOW: usize = 1 << 20;
1674
1675 /// Waits for `path` to become ciphertext, then runs `meddle`.
1676 ///
1677 /// Gives up rather than hanging: a run that refuses before it ever encrypts
1678 /// anything must fail the assertions below, not the CI job's timeout.
1679 fn during_the_encryption_pass(
1680 path: PathBuf,
1681 meddle: impl FnOnce() + Send + 'static,
1682 ) -> std::thread::JoinHandle<bool> {
1683 std::thread::spawn(move || {
1684 for _ in 0..30_000 {
1685 if fs::read(&path)
1686 .is_ok_and(|bytes| bytes.starts_with(&crate::crypto::format::MAGIC))
1687 {
1688 meddle();
1689 return true;
1690 }
1691 std::thread::sleep(std::time::Duration::from_millis(1));
1692 }
1693 false
1694 })
1695 }
1696
1697 #[test]
1698 fn a_checkout_that_appears_during_the_encryption_pass_stops_the_run() {
1699 // The window the prompt refusal does not cover. `refuse_other_worktrees`
1700 // ran before the question and once after it, and then the command spent
1701 // the whole encryption pass — as long as the secrets are large — with
1702 // nothing looking at the other checkouts again.
1703 //
1704 // Measured on git 2.55, 2026-08-05, 4000 declared files: `git worktree
1705 // add` once the pass had started took `lock --yes` to exit 0, "4000
1706 // file(s) are now encrypted and key … has been deleted", leaving
1707 // `../side/secrets/s1.env` reading `AWS_SECRET=hunter2-1` with no key
1708 // anywhere able to close it.
1709 //
1710 // The registration is written by hand rather than by `git worktree add`,
1711 // for one reason and with one consequence. The reason: `init` registers
1712 // the *test* binary as the filter here, so a real checkout cannot be made
1713 // in this process. The consequence: what this proves is that the gate is
1714 // consulted after the pass — that a real `git worktree add` produces this
1715 // exact directory, and that the checkout comes out in the clear, is what
1716 // `tests/lock_unlock.rs` measures for the prompt window.
1717 let (dir, repo) = prepared();
1718 write_and_stage(&repo, &dir, "secrets/db.env", b"hunter2\n");
1719 write_and_stage(&repo, &dir, "secrets/zz-slow.bin", &vec![7u8; SLOW]);
1720
1721 let registration = repo.common_dir().join("worktrees").join("side");
1722 let meddling =
1723 during_the_encryption_pass(repo.work_tree().join("secrets/db.env"), move || {
1724 fs::create_dir_all(®istration).expect("the registration directory");
1725 fs::write(registration.join("gitdir"), b"/somewhere/side/.git\n")
1726 .expect("the back-pointer");
1727 });
1728
1729 let error = run(&repo, &mut Scripted::new(true))
1730 .expect_err("a checkout that appeared must not be locked around");
1731
1732 assert!(
1733 meddling.join().expect("the meddling thread"),
1734 "the pass never encrypted anything, so this proves nothing about it"
1735 );
1736 assert_eq!(error.exit_code(), crate::util::exit::CONFIG);
1737 assert!(
1738 repo.has_key(),
1739 "the key went while another checkout read it: {error}"
1740 );
1741 assert!(
1742 error.to_string().contains("other checkout"),
1743 "the refusal does not say what stopped it: {error}"
1744 );
1745 // What separates this from the gates that run before the pass: they
1746 // refuse over an untouched working tree, so a run they stopped would
1747 // have left the file in the clear.
1748 assert!(
1749 fs::read(repo.work_tree().join("secrets/db.env"))
1750 .expect("reading")
1751 .starts_with(&crate::crypto::format::MAGIC),
1752 "an earlier gate caught this, so the window after the pass is untested"
1753 );
1754 }
1755
1756 #[test]
1757 fn a_declared_file_that_appears_during_the_encryption_pass_stops_the_run() {
1758 // The twin of the test above, and of the prompt-window one beside it.
1759 // A file created while the pass runs is in no list this command holds —
1760 // the survey never saw it, `refuse_if_the_tree_moved` ran before it
1761 // existed, and the index gate that runs last reads only tracked paths,
1762 // which an untracked file is not.
1763 //
1764 // Measured on git 2.55, 2026-08-05, 4000 declared files: written once
1765 // the pass had started, `lock --yes` exited 0 reporting "4000 file(s)
1766 // are now encrypted and key 4ddee64b4e99741c has been deleted", with
1767 // `secrets/late.env` still reading `BRAND_NEW=hunter3`.
1768 const LATE: &[u8] = b"BRAND_NEW=written-while-the-pass-ran\n";
1769
1770 let (dir, repo) = prepared();
1771 write_and_stage(&repo, &dir, "secrets/db.env", b"hunter2\n");
1772 write_and_stage(&repo, &dir, "secrets/zz-slow.bin", &vec![7u8; SLOW]);
1773
1774 let late = repo.work_tree().join("secrets").join("late.env");
1775 let written = late.clone();
1776 let meddling =
1777 during_the_encryption_pass(repo.work_tree().join("secrets/db.env"), move || {
1778 fs::write(&written, LATE).expect("writing the late file");
1779 });
1780
1781 let error = run(&repo, &mut Scripted::new(true))
1782 .expect_err("a secret that appeared must not be locked around");
1783
1784 assert!(
1785 meddling.join().expect("the meddling thread"),
1786 "the pass never encrypted anything, so this proves nothing about it"
1787 );
1788 assert_eq!(error.exit_code(), crate::util::exit::CONFIG);
1789 assert!(
1790 repo.has_key(),
1791 "the key went over a file nobody checked: {error}"
1792 );
1793 assert_eq!(
1794 fs::read(&late).expect("reading"),
1795 LATE,
1796 "the new file was encrypted without ever being checked"
1797 );
1798 assert!(
1799 error.to_string().contains("late.env"),
1800 "the refusal does not name what stopped it: {error}"
1801 );
1802 // As above: the gates that run before the pass refuse over an untouched
1803 // working tree, so a run they stopped would have left this in the clear.
1804 assert!(
1805 fs::read(repo.work_tree().join("secrets/db.env"))
1806 .expect("reading")
1807 .starts_with(&crate::crypto::format::MAGIC),
1808 "an earlier gate caught this, so the window after the pass is untested"
1809 );
1810 }
1811
1812 #[test]
1813 fn a_missing_declaration_stops_lock_rather_than_encrypting_nothing() {
1814 let (_dir, repo) = prepared();
1815 fs::remove_file(repo.xcrypt_config_path()).expect("removing the declarations");
1816
1817 let error = run(&repo, &mut Scripted::new(true)).expect_err("lock must refuse");
1818
1819 assert_eq!(error.exit_code(), crate::util::exit::CONFIG);
1820 assert!(repo.has_key());
1821 }
1822}