Skip to main content

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(&registrations) {
545        Ok(entries) => {
546            for entry in entries {
547                let entry = entry.map_err(|err| unverifiable_with(&registrations, &err, tail))?;
548                let registration = entry.path();
549                if same_path(&registration, repo.git_dir()) {
550                    continue;
551                }
552                others.push(describe_worktree(repo, &registration));
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(&registrations, &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(&registration.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(&registration).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}