Skip to main content

git_xcrypt/commands/
status.rs

1//! `git-xcrypt status` — whether the declarations are actually enforced.
2//!
3//! The boundary is worth stating before anything else, because it is easy to
4//! read this command as more than it is: it answers **"are my declarations
5//! enforced"**, not **"are there secrets in this repository"**. A file that never
6//! matched a pattern is invisible here, by construction.
7//!
8//! Within that boundary it has two jobs, and the first is the one nothing else
9//! covers. A clone inherits `.gitattributes` through history but not
10//! `.git/config`, so it carries the catch-all line with no driver behind it —
11//! and git reads an undefined filter exactly as it reads no filter, which means
12//! the next `git add` on a secret exits 0 and stores the plaintext. Nothing in
13//! that sequence produces a signal. Asking for one is what this command is for.
14//!
15//! The second job is `--fix`, and there is a measured reason it has to exist.
16//! The founding document says a pattern added to `.git-xcrypt` "works
17//! immediately, with no synchronising command", and that is true of the filter:
18//! it re-reads the declaration on every call. It is **not** true of git. Git
19//! decides from its cached `stat` whether to call the filter at all, so a file
20//! that was already committed and is not then edited is skipped — measured on
21//! git 2.55, past the racy-clean window:
22//!
23//! ```text
24//! git add -A && git commit            # before the pattern existed
25//! printf 'secrets/\n' > .git-xcrypt
26//! git add -A && git commit            # exit 0, no warning
27//! git cat-file blob HEAD:secrets/db.env  → hunter2
28//! ```
29//!
30//! Nothing in that sequence is wrong from git's point of view, and nothing in
31//! it tells the user. So this command reports the state and `--fix` repairs it,
32//! which is the whole reason the fix operates on the index rather than merely
33//! printing advice.
34//!
35//! The exit code is part of the contract: `5` on a finding, so the command works
36//! as a CI gate and so "the repository has a problem" is distinguishable from
37//! "the tool broke". Since 2026-08-04 there is a third answer, `6`, for the runs
38//! that could not tell — a shallow or partial clone, an index that will not
39//! parse. Collapsing that into `5` failed the gate on a healthy `git clone
40//! --depth 1`, which is what `actions/checkout` produces unless it is given
41//! `fetch-depth: 0`.
42//!
43//! Since 2026-08-05 there is a fourth, and it outranks the other two: `2`, the
44//! frozen table's "configuration or a state conflict", for a repository where
45//! git is not set up to enforce anything — an unregistered filter, a missing
46//! catch-all line, a missing declaration. **Configuration comes before data**,
47//! because without a configuration that enforces anything the data here is worth
48//! nothing, and `5` used to tell a repository that had never run `init` that an
49//! exposure had been found. It hides nothing: every section is printed under
50//! every verdict, so a misconfigured repository that also leaked still names the
51//! leak and still prints the rotate-first procedure. See [`Verdict`].
52
53use std::fmt;
54use std::path::PathBuf;
55
56use crate::git::repo::{DRIVER, Repo, git_spelling};
57use crate::rules::declaration::Config;
58use gix_object::Write as _;
59
60use crate::Result;
61use crate::git::attributes;
62use crate::git::config as gitconfig;
63use crate::git::history;
64use crate::git::index;
65
66/// One reason git would not be filtering this repository.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum SetupGap {
69    /// A `filter.git-xcrypt.*` key is not set anywhere git reads.
70    MissingKey(String),
71    /// The key is set, but not to anything git reads as true.
72    NotTrue {
73        /// The dotted key.
74        key: String,
75        /// What it is set to.
76        value: String,
77    },
78    /// `.gitattributes` carries no `* filter=git-xcrypt` line.
79    CatchAllMissing,
80    /// Git resolves `filter` to something other than this tool for declared paths.
81    ///
82    /// The catch-all is one line among many and git takes the **last** match, so
83    /// an attribute line below the managed section, a `.gitattributes` in a
84    /// subdirectory, or `$GIT_DIR/info/attributes` — which is not versioned and
85    /// outranks everything — turns this tool off for paths it believes it
86    /// protects. Measured on git 2.55: `git check-attr filter` then answers
87    /// `unset`, the next `git add` stores the plaintext, and every other check in
88    /// this command passes.
89    ///
90    /// Until 2026-08-04 this was a **note**: the report named the files and the
91    /// lines and left the reader to run `git check-attr`. That was the last route
92    /// to a green report on a repository that does not encrypt, because a note
93    /// does not fail a CI gate.
94    FilterUnresolved {
95        /// The declared paths git would not filter, capped for the message.
96        paths: Vec<String>,
97        /// How many there are altogether.
98        total: usize,
99        /// What git resolves instead, spelled as `git check-attr` spells it.
100        resolved: String,
101    },
102    /// Git converts the line endings of declared paths itself.
103    ///
104    /// The twin of [`SetupGap::FilterUnresolved`], on the second attribute the
105    /// managed section sets, and it costs more rather than less. The section
106    /// writes `-text` on every encrypted path precisely so that git's own CRLF
107    /// conversion never touches the ciphertext; an attribute line that outranks
108    /// it puts the conversion back.
109    ///
110    /// Measured on git 2.55, with `sync` freshly run so nothing else in this
111    /// command had anything to say: a 2 MB file under `secrets/** text` lost 34
112    /// `CR` bytes out of its **ciphertext**, `git add` exited 0, `git commit`
113    /// exited 0, and the checkout failed the authentication tag and left no file
114    /// at all. Nobody can decrypt what was committed — not the author, not with
115    /// the key, not ever. `status` printed `VERDICT: no findings.` and exited 0.
116    ///
117    /// A gap rather than a note for the reason the unresolved filter is one:
118    /// both mean the declaration is not enforced, and a note does not fail a CI
119    /// gate.
120    CiphertextConverted {
121        /// The declared paths git would convert, capped for the message.
122        paths: Vec<String>,
123        /// How many there are altogether.
124        total: usize,
125        /// The attribute line that decides it, with the file and line it sits in.
126        culprit: String,
127    },
128    /// `.git-xcrypt` is not there, so nothing declares what to encrypt.
129    ///
130    /// Filed as a gap rather than only as a question since 2026-08-05. It is not
131    /// an exposure — the check-in path refuses on this state, so no `git add`
132    /// stores anything in the clear over it — but it is precisely a
133    /// configuration that enforces nothing, and the remedy is a file, not a
134    /// rotated secret. It still puts the rest of the run in `undetermined`,
135    /// because without the declaration neither the index nor history can be
136    /// judged at all.
137    DeclarationMissing,
138    /// The managed `.gitattributes` section no longer matches `.git-xcrypt`.
139    ///
140    /// **A gap since 2026-08-06, and a note before that** (open decision 11).
141    /// What forced the change was not the severity but a contradiction: on one
142    /// and the same stale section `sync --check` exited `1` and `status` exited
143    /// `0` printing `VERDICT: no findings.` — two commands of one tool
144    /// disagreeing about one state, so the answer a CI job got depended on which
145    /// one it happened to run.
146    ///
147    /// **The argument for keeping it a note is recorded rather than erased,
148    /// because it is true:** it takes a *foreign* `text` attribute to make this
149    /// cost anything, since our own magic starts with NUL and `text=auto` or
150    /// `core.autocrlf` alone see binary and leave the ciphertext be. So this is
151    /// the one gap in this list that is conditional. It was resolved the other
152    /// way because the condition is invisible from here — the foreign attribute
153    /// may arrive in a `.gitattributes` a subdirectory away, or in
154    /// `$GIT_DIR/info/attributes`, which is not versioned — and because a
155    /// declaration whose `-text` does not reach every declared path is, in the
156    /// plain sense of what this command answers, not being enforced.
157    ///
158    /// What it costs when the condition is met is measured, on git 2.55: a 2 MB
159    /// file lost 34 `CR` bytes out of its **ciphertext**, `git add` exited 0,
160    /// the commit succeeded, and the checkout failed the authentication tag and
161    /// left no file at all. Nobody can decrypt that blob, ever.
162    ///
163    /// Every rendering this build writes counts as current, so a repository that
164    /// ran `sync --global` or `sync --ignorecase` is not sent to run the very
165    /// command that produced its section.
166    SectionStale,
167    /// A file the whole mechanism bootstraps from is not tracked.
168    ///
169    /// `.gitattributes` is what makes git call the filter and `.git-xcrypt` is
170    /// what the filter reads. Neither is any use to a clone unless it is
171    /// committed, and `init` creates them without committing them — so a
172    /// repository can look perfectly configured locally and publish nothing that
173    /// enforces anything. The clone finds out; the machine that pushed does not.
174    Untracked(String),
175}
176
177impl fmt::Display for SetupGap {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        match self {
180            Self::MissingKey(key) => write!(
181                f,
182                "{key} is not set, so git has no filter to run for this repository"
183            ),
184            Self::NotTrue { key, value } => write!(
185                f,
186                "{key} is `{value}`, not true — without it a failing filter is \
187                 ignored and git stores the unfiltered content with exit code 0"
188            ),
189            Self::CatchAllMissing => write!(
190                f,
191                "{} carries no `{}` line, so git never calls the filter",
192                crate::git::repo::ATTRIBUTES_FILE,
193                attributes::CATCH_ALL
194            ),
195            Self::FilterUnresolved {
196                paths,
197                total,
198                resolved,
199            } => {
200                write!(
201                    f,
202                    "git resolves `filter` to `{resolved}` for {total} declared path(s), \
203                     not `{DRIVER}` — so committing them stores the plain text, whatever \
204                     the `{catch_all}` line says. Some attribute line outranks it; \
205                     `git check-attr filter -- <path>` shows which, and the notes below \
206                     name every file carrying a `filter` line. Deleting or narrowing that \
207                     line is the fix — `git-xcrypt init` will not remove it. Reached: {}",
208                    paths.join(", "),
209                    catch_all = attributes::CATCH_ALL
210                )?;
211                if *total > paths.len() {
212                    write!(f, ", … and {} more", total - paths.len())?;
213                }
214                Ok(())
215            }
216            Self::CiphertextConverted {
217                paths,
218                total,
219                culprit,
220            } => {
221                write!(
222                    f,
223                    "git converts the line endings of {total} declared path(s) itself, \
224                     because this line outranks the managed `-text`:\n      {culprit}\n    \
225                     That conversion runs over the **ciphertext**: `git add` and \
226                     `git commit` both exit 0, the damaged blob is committed, and the \
227                     next checkout fails the authentication tag and leaves no file at \
228                     all — measured on git 2.55, 34 `CR` bytes eaten out of a 2 MB blob. \
229                     What is committed cannot be decrypted again by anyone, with any \
230                     key. Delete or narrow that line so the managed `-text` wins, then \
231                     run `git-xcrypt sync`; anything already committed under it has to \
232                     be re-added from a copy of the plain text. Reached: {}",
233                    paths.join(", ")
234                )?;
235                if *total > paths.len() {
236                    write!(f, ", … and {} more", total - paths.len())?;
237                }
238                Ok(())
239            }
240            Self::DeclarationMissing => write!(
241                f,
242                "{config} is missing, so nothing here declares what to encrypt. \
243                 Nothing is stored in the clear over this — every `git add` in \
244                 this repository refuses until it is back — and nothing is \
245                 enforced either. `git-xcrypt init` creates one; a clone gets it \
246                 from the commit that carries it",
247                config = crate::git::repo::CONFIG_FILE
248            ),
249            Self::SectionStale => write!(
250                f,
251                "{} no longer matches {} — the per-pattern lines are out of \
252                 date, so the `-text` that keeps git's own CRLF conversion off \
253                 the ciphertext does not reach every declared path. Nothing is \
254                 stored in the clear over this, and it costs nothing until some \
255                 other attribute source declares one of those paths `text` — at \
256                 which point git corrupts the blob silently and the file is gone \
257                 at checkout, unrecoverably. A clone's `unlock` will also \
258                 rewrite the section and leave `git status` dirty. \
259                 `git-xcrypt sync` settles both",
260                crate::git::repo::ATTRIBUTES_FILE,
261                crate::git::repo::CONFIG_FILE
262            ),
263            Self::Untracked(path) => write!(
264                f,
265                "{path} is not committed, so no clone of this repository gets it \
266                 — and without it a clone filters nothing. `git add {path}` and \
267                 commit it"
268            ),
269        }
270    }
271}
272
273/// What `status` found.
274///
275/// Four sections, deliberately separate, because the remedies are four different
276/// things and a single list would hide which one applies.
277#[derive(Debug, Default)]
278pub struct Report {
279    /// Reasons git is not filtering here. Any of these means the guarantee is off.
280    pub setup: Vec<SetupGap>,
281    /// Whether a repository key is present at all.
282    pub has_key: bool,
283    /// Declared paths the index already stores as ciphertext. The good case.
284    pub encrypted: Vec<Vec<u8>>,
285    /// Declared paths the index stores **in the clear** — what a commit made now
286    /// would push. This is the set `--fix` repairs.
287    pub in_the_clear: Vec<Vec<u8>>,
288    /// Declared paths that reachable history holds in the clear.
289    ///
290    /// Nothing local repairs this. The report says so in as many words.
291    pub leaked: Vec<crate::git::history::Exposure>,
292    /// Paths a negation deliberately keeps in the clear.
293    ///
294    /// Listed rather than left out: a hole a user wrote on purpose must not be
295    /// invisible, or the declaration reads as covering more than it does.
296    pub by_choice: Vec<Vec<u8>>,
297    /// Paths `--fix` re-staged through the filter.
298    ///
299    /// Named separately from [`Report::encrypted`] so the sentence that follows
300    /// them — that this changes the next commit and nothing about the past — has
301    /// something to attach to.
302    pub fixed: Vec<Vec<u8>>,
303    /// Things this build could not determine, and why.
304    ///
305    /// These fail the gate. "I could not tell" reported as a pass is the one
306    /// answer a command like this must never give.
307    pub undetermined: Vec<String>,
308    /// How much history was walked, for the closing line.
309    pub scanned: Scanned,
310    /// Whether the history scan ran at all.
311    ///
312    /// Without it the closing line printed "scanned 0 commit(s)" for a run that
313    /// returned before the scan, which reads as "I looked and there was nothing"
314    /// in a repository with five hundred commits.
315    pub scan_ran: bool,
316    /// Whether `--fix` was asked for.
317    ///
318    /// The advice under "in the clear" tells a user to run `--fix`; printing
319    /// that in the output of `--fix` itself points at the command that has just
320    /// declined, and says nothing about the attempt.
321    pub fix_requested: bool,
322    /// Notes that describe a lesser problem and never change the exit code.
323    pub notes: Vec<String>,
324    /// Anything worth saying once, carried out so the binary owns the messages.
325    pub warnings: Vec<String>,
326}
327
328/// How much of the repository the scan covered.
329#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
330pub struct Scanned {
331    /// Commits visited.
332    pub commits: usize,
333    /// Distinct blobs under a declared path that were read.
334    pub blobs: usize,
335}
336
337/// What a run concluded, as the exit code reports it.
338///
339/// Four values, and each one asks the operator for something different: fix the
340/// configuration, rotate a secret, fix the checkout, or nothing at all. That is
341/// the only reason they are separate — a gate is read as a number, and a number
342/// that carries two questions gets the wrong answer to one of them. Both splits
343/// were made after measuring a case where the shared code sent a reader the
344/// wrong way: `6` because a healthy `git clone --depth 1` failed the gate like a
345/// leaking repository, and `2` because a repository that had never run `init`
346/// was told an exposure had been found. See [`crate::util::exit::UNDETERMINED`] and
347/// [`crate::util::exit::CONFIG`].
348#[derive(Debug, Clone, Copy, PartialEq, Eq)]
349pub enum Verdict {
350    /// Everything was checked and nothing was found.
351    Clean,
352    /// Nothing was found, but part of the question could not be answered.
353    Undetermined,
354    /// Something was found.
355    Exposed,
356    /// Git is not set up to enforce the declarations here.
357    ///
358    /// The highest of the four since 2026-08-05, and the only one that reversed
359    /// a precedence — see [`Report::verdict`].
360    Misconfigured,
361}
362
363impl Report {
364    /// What this run concluded.
365    ///
366    /// **Configuration, then a finding, then a question.** The owner's reason for
367    /// putting configuration first, 2026-08-05: *without a working configuration
368    /// the data in the repository is worth nothing* — a checkout where git is not
369    /// running the filter cannot be judged clean, cannot be trusted about what it
370    /// stores next, and above all cannot be repaired by acting on anything this
371    /// report says about its data. So the operator is sent to the one repair that
372    /// makes the rest meaningful, and asks again afterwards.
373    ///
374    /// It is a reversal in exactly one place. Until 2026-08-05 a setup gap was
375    /// [`Verdict::Exposed`], which handed `5` — "an exposure was found, rotate
376    /// the secret" — to a repository that had never run `init` and had nothing in
377    /// it to rotate, while the one thing genuinely wrong with it read as a
378    /// detail. `2` is the frozen table's "configuration or a state conflict",
379    /// used here exactly as `init` and `lock` already use it.
380    ///
381    /// Everything else stands: a finding still outranks an unanswered question,
382    /// so a run that hit an unreadable index *and* found a leak has found a leak.
383    /// And no verdict withholds a section — a misconfigured repository that also
384    /// leaked prints the leak, the paths and the rotate-first procedure exactly
385    /// as it did before, because the code changes the order of the work and not
386    /// what the reader is told.
387    #[must_use]
388    pub fn verdict(&self) -> Verdict {
389        if !self.setup.is_empty() {
390            Verdict::Misconfigured
391        } else if !self.in_the_clear.is_empty() || !self.leaked.is_empty() {
392            Verdict::Exposed
393        } else if self.undetermined.is_empty() {
394            Verdict::Clean
395        } else {
396            Verdict::Undetermined
397        }
398    }
399
400    /// Whether this run found plain text where ciphertext was expected.
401    ///
402    /// Read off the findings rather than off [`Report::verdict`], and that is
403    /// deliberate: since 2026-08-05 a setup gap outranks a finding, so a
404    /// repository that both leaked and is misconfigured answers
405    /// [`Verdict::Misconfigured`] while its leak is every bit as real. Deriving
406    /// this from the verdict would make it say no.
407    #[must_use]
408    pub fn exposed(&self) -> bool {
409        !self.in_the_clear.is_empty() || !self.leaked.is_empty()
410    }
411
412    /// Whether any setup gap means git stores declared content unfiltered
413    /// **on this machine**.
414    ///
415    /// Three of them do not, and they are different failures rather than milder
416    /// ones. [`SetupGap::CiphertextConverted`]: git runs the filter and then
417    /// damages what it produced, so what is lost is the file, not the secret.
418    /// [`SetupGap::DeclarationMissing`]: the check-in path refuses outright, so
419    /// nothing is stored at all. [`SetupGap::Untracked`]: git enforces the
420    /// declarations *here* — the attributes and the declaration are read from
421    /// the working tree — and publishes nothing that enforces them anywhere
422    /// else; the exposure is a clone's, not this checkout's. Same exit code,
423    /// four different remedies — and only the first calls for rotating
424    /// anything. Telling a user whose repository filters correctly that
425    /// "committing a declared file stores it in the clear" sends them to
426    /// rotate secrets that were never exposed, which is the failure mode the
427    /// 2026-08-05 precedence change was made to remove.
428    fn stores_in_the_clear(&self) -> bool {
429        self.setup.iter().any(|gap| {
430            !matches!(
431                gap,
432                SetupGap::CiphertextConverted { .. }
433                    | SetupGap::DeclarationMissing
434                    | SetupGap::Untracked(_)
435            )
436        })
437    }
438
439    /// Whether the only thing wrong is that nothing declares what to encrypt.
440    fn only_the_declaration_is_missing(&self) -> bool {
441        self.setup
442            .iter()
443            .all(|gap| matches!(gap, SetupGap::DeclarationMissing))
444    }
445
446    /// Whether the only thing wrong is that the bootstrap files are uncommitted.
447    fn only_the_bootstrap_is_untracked(&self) -> bool {
448        self.setup
449            .iter()
450            .all(|gap| matches!(gap, SetupGap::Untracked(_)))
451    }
452}
453
454/// A repository-relative path as a message shows it.
455///
456/// Lossy on purpose and only here: a path is arbitrary bytes on Unix, so the
457/// decision paths — matching, hashing, index lookup — keep the bytes, and only
458/// the moment of printing gives up on them.
459/// How many paths the good-news section prints before summarising.
460const MAX_LISTED: usize = 10;
461
462/// How many plaintext blobs are shown per exposed path.
463const MAX_SIGHTINGS: usize = 3;
464
465fn show(path: &[u8]) -> String {
466    bstr::BStr::new(path).to_string()
467}
468
469/// A path as a shell argument, for the command the report tells a user to run.
470///
471/// Single quotes stop the shell expanding anything; a literal quote is closed,
472/// escaped and reopened — the same escape `init` uses for the binary path it
473/// registers. Without it a file called `it's.env` would produce a command that
474/// either fails to parse or, worse, parses as something else. The report is
475/// printed and never executed by this tool, which makes correctness here a
476/// matter of not handing a user a broken instruction, not of injection.
477fn shell_quoted(path: &[u8]) -> String {
478    format!("'{}'", show(path).replace('\'', r"'\''"))
479}
480
481/// Renders the whole report, sections and remedies included.
482///
483/// A `Display` rather than a pile of `eprintln!` in the binary: the wording here
484/// *is* part of the safeguard — a user must never read "fixed" as "the secret is
485/// safe" — so it has to be assertable from a test.
486impl fmt::Display for Report {
487    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
488        self.write_verdict(f)?;
489        if self.setup.is_empty() {
490            writeln!(
491                f,
492                "setup: git is configured to run the filter in this repository."
493            )?;
494        } else {
495            // Three different sentences, because the gaps have three different
496            // outcomes and one wording cannot be true of all of them. A
497            // registration gap stores the plain text; a conversion gap destroys
498            // the ciphertext instead; a missing declaration stores nothing at
499            // all because the check-in path refuses. Telling a user their
500            // secrets are in the clear when they are not sends them to rotate
501            // credentials that were never exposed, while telling them to run
502            // `init` fixes nothing.
503            if self.stores_in_the_clear() {
504                writeln!(
505                    f,
506                    "setup: git is NOT filtering this repository. Until this is fixed, \
507                     committing a declared file stores it in the clear, with exit code 0 \
508                     and no warning."
509                )?;
510            } else if self.only_the_declaration_is_missing() {
511                writeln!(
512                    f,
513                    "setup: git calls the filter here, but the filter has nothing to \
514                     read. Nothing is stored in the clear over this — every `git add` \
515                     in this repository refuses until the declaration is back — and \
516                     nothing below was checked, because there is no way to tell which \
517                     paths should have been."
518                )?;
519            } else if self.only_the_bootstrap_is_untracked() {
520                writeln!(
521                    f,
522                    "setup: git enforces the declarations on this machine — the files \
523                     below are read from the working tree — but they are not \
524                     committed, so no clone gets them and nothing published enforces \
525                     anything. Commits made *here* store ciphertext; commits made \
526                     from a clone would not."
527                )?;
528            } else {
529                writeln!(
530                    f,
531                    "setup: git runs the filter here, but does not leave its output \
532                     alone. Nothing is stored in the clear over this; what it costs is \
533                     the ciphertext, and with it the file."
534                )?;
535            }
536            for gap in &self.setup {
537                writeln!(f, "  - {gap}")?;
538            }
539            // Only where it is the remedy. Neither command touches
540            // `.gitattributes` lines a user wrote, so offering them against a
541            // conversion gap would send a reader round a loop that changes
542            // nothing; that gap carries its own instruction instead.
543            if self.stores_in_the_clear() {
544                writeln!(f, "\n  Fix it with one of:")?;
545                if self.has_key {
546                    writeln!(f, "    git-xcrypt init      # the key here is kept")?;
547                } else {
548                    writeln!(f, "    git-xcrypt unlock <key-file>")?;
549                }
550            } else if self.only_the_bootstrap_is_untracked() {
551                // Neither `init` nor `unlock` commits anything, so offering
552                // them here would send a reader round a loop that changes
553                // nothing — the same rule the comment above states for the
554                // conversion gap. The remedy is a commit.
555                writeln!(f, "\n  Fix it by committing the files:")?;
556                writeln!(
557                    f,
558                    "    git add {} {} && git commit",
559                    crate::git::repo::ATTRIBUTES_FILE,
560                    crate::git::repo::CONFIG_FILE
561                )?;
562            }
563        }
564
565        self.write_undetermined(f)?;
566        self.write_fixed(f)?;
567        self.write_encrypted(f)?;
568        self.write_in_the_clear(f)?;
569        self.write_leaked(f)?;
570        self.write_by_choice(f)?;
571
572        for note in &self.notes {
573            writeln!(f, "\nnote: {note}")?;
574        }
575
576        if self.scan_ran {
577            writeln!(
578                f,
579                "\nscanned {} commit(s) and {} distinct blob(s) under a declared \
580                 path. `status` answers whether your declarations are enforced, not \
581                 whether this repository holds secrets: a path no pattern ever \
582                 matched is invisible to it.",
583                self.scanned.commits, self.scanned.blobs
584            )
585        } else {
586            // "scanned 0 commit(s)" reads as "I looked and there was nothing",
587            // which in a five-hundred-commit repository is the opposite of true.
588            writeln!(
589                f,
590                "\nhistory was NOT scanned — see `undetermined` above. `status` \
591                 answers whether your declarations are enforced, not whether this \
592                 repository holds secrets: a path no pattern ever matched is \
593                 invisible to it."
594            )
595        }
596    }
597}
598
599impl Report {
600    /// One line, first, saying whether anything was found.
601    ///
602    /// Not decoration. `write_encrypted` lists every declared path, and a
603    /// repository with three hundred secrets and one leak put the leak — and the
604    /// instruction to rotate it — three hundred lines below the fold, under a
605    /// solid wall of good news. The founding document is explicit that the
606    /// wording here *is* the safeguard; a safeguard nobody scrolls to is not one.
607    fn write_verdict(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
608        match self.verdict() {
609            Verdict::Clean => return writeln!(f, "VERDICT: no findings.\n"),
610            // Deliberately not phrased as a finding. An operator reading this in
611            // a CI log has to act on the checkout, not on the repository, and
612            // the sentence has to be readable as such without the exit code.
613            Verdict::Undetermined => {
614                return writeln!(
615                    f,
616                    "VERDICT: undetermined — {} thing(s) could not be checked. \
617                     NOTHING WAS FOUND, and nothing is ruled out either.\n",
618                    self.undetermined.len()
619                );
620            }
621            Verdict::Exposed | Verdict::Misconfigured => {}
622        }
623
624        let mut parts: Vec<String> = Vec::new();
625        if !self.leaked.is_empty() {
626            parts.push(format!("{} path(s) leaked in history", self.leaked.len()));
627        }
628        if !self.in_the_clear.is_empty() {
629            parts.push(format!(
630                "{} path(s) stored in the clear now",
631                self.in_the_clear.len()
632            ));
633        }
634        if !self.undetermined.is_empty() {
635            parts.push(format!("{} thing(s) undetermined", self.undetermined.len()));
636        }
637
638        // The configuration verdict leads with the repair that makes every other
639        // line here mean something, and then says what else is on the page. It
640        // must never read as "and nothing else was found": a leak reported under
641        // code `2` is the same leak it would be under `5`, and an operator who
642        // stops reading at the first line has to know there is more below.
643        if self.verdict() == Verdict::Misconfigured {
644            write!(
645                f,
646                "VERDICT: {} setup gap(s) — git is not enforcing the declarations \
647                 in this repository. Fix the setup first and ask again; until then \
648                 nothing here can be called clean.",
649                self.setup.len()
650            )?;
651            if parts.is_empty() {
652                return writeln!(f, "\n");
653            }
654            return writeln!(
655                f,
656                " Also found, and NOT cancelled by the above — see the sections \
657                 below: {}.\n",
658                parts.join(", ")
659            );
660        }
661
662        writeln!(f, "VERDICT: {}.\n", parts.join(", "))
663    }
664
665    /// What could not be determined, and therefore what nothing here proves.
666    fn write_undetermined(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
667        if self.undetermined.is_empty() {
668            return Ok(());
669        }
670        writeln!(
671            f,
672            "\nundetermined: this run could not answer the following, so nothing \
673             below is a clean bill of health."
674        )?;
675        for reason in &self.undetermined {
676            writeln!(f, "  - {reason}")?;
677        }
678        // Said only when it is the whole story. Beside a real finding the
679        // sentence would soften the finding, which is the opposite of what this
680        // section is for.
681        if self.verdict() == Verdict::Undetermined {
682            writeln!(
683                f,
684                "\n  This is exit code {undetermined}, not {exposed}: settle the reasons above \
685                 and ask again. Nothing here was found — it was not looked at.",
686                undetermined = crate::util::exit::UNDETERMINED,
687                exposed = crate::util::exit::EXPOSED
688            )?;
689        }
690        Ok(())
691    }
692
693    /// What `--fix` did, and — at least as important — what it did not.
694    ///
695    /// The closing sentence is the safeguard, not decoration. `--fix` repairs the
696    /// future and nothing else, and a user who reads "fixed" as "the secret is
697    /// safe now" has been actively misled by this command.
698    fn write_fixed(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
699        if self.fixed.is_empty() {
700            return Ok(());
701        }
702        writeln!(
703            f,
704            "\nfixed: {} path(s) were re-staged through the filter, so the NEXT \
705             commit stores them encrypted.",
706            self.fixed.len()
707        )?;
708        for path in &self.fixed {
709            writeln!(f, "  {}", show(path))?;
710        }
711        writeln!(
712            f,
713            "\n  What is staged for each of them is its **working-tree** content, \
714             the same as `git add` would stage — so any edit you had not staged \
715             yet is staged now. Check `git diff --cached` before committing.\n\
716             \n  \
717             No file was rewritten and NO HISTORY WAS REWRITTEN. Nothing was \
718             un-leaked: every plain-text version already committed is still in \
719             this repository and in every clone of it. If any of these files held \
720             a secret that has been pushed, rotate the secret — that is the only \
721             step that revokes it."
722        )
723    }
724
725    /// The good case: declared and already stored as ciphertext.
726    fn write_encrypted(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
727        if self.encrypted.is_empty() {
728            return Ok(());
729        }
730        writeln!(
731            f,
732            "\nencrypted: {} declared path(s) are stored as ciphertext.",
733            self.encrypted.len()
734        )?;
735        // Capped, unlike every other section. This is the one list that grows
736        // with the size of a healthy repository, and it is the only one a reader
737        // does not need in full — while the sections below it are the ones they
738        // came for.
739        for path in self.encrypted.iter().take(MAX_LISTED) {
740            writeln!(f, "  {}", show(path))?;
741        }
742        if self.encrypted.len() > MAX_LISTED {
743            writeln!(f, "  … and {} more", self.encrypted.len() - MAX_LISTED)?;
744        }
745        Ok(())
746    }
747
748    /// Declared, and stored in the clear right now.
749    fn write_in_the_clear(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
750        if self.in_the_clear.is_empty() {
751            return Ok(());
752        }
753        writeln!(
754            f,
755            "\nin the clear: {} declared path(s) are stored unencrypted right now, \
756             so a commit made from here would push the plain text.",
757            self.in_the_clear.len()
758        )?;
759        for path in &self.in_the_clear {
760            writeln!(f, "  {}", show(path))?;
761        }
762        if self.fix_requested {
763            // --fix already ran and left these behind; the reason is in the
764            // warnings on stderr. Repeating "run --fix" here would point at the
765            // command whose output the reader is holding.
766            return writeln!(
767                f,
768                "\n  `--fix` was asked for and did not re-stage these — the reason for \
769                 each is on stderr. `git add` on them by hand does the same job."
770            );
771        }
772        writeln!(
773            f,
774            "\n  `git add` on each of them re-stages the content through the filter, \
775             and `git-xcrypt status --fix` does exactly that for all of them at once. \
776             It changes what the NEXT commit stores. It does not touch history, and \
777             any plain text already committed stays where it is."
778        )
779    }
780
781    /// Declared, and somewhere in reachable history in the clear.
782    ///
783    /// The wording is load-bearing. Rewriting history does not undo a leak — the
784    /// plaintext is in every clone, fork, cache and CI log that ever saw it — so
785    /// the procedure has to open with rotation and say why. A user who reads
786    /// "cleaned up" here has been told the wrong thing.
787    fn write_leaked(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
788        if self.leaked.is_empty() {
789            return Ok(());
790        }
791        writeln!(
792            f,
793            "\nleaked in history: {} declared path(s) sat in this repository in the \
794             clear at some point, and the blobs are still here.",
795            self.leaked.len()
796        )?;
797        // Every path is listed — they are the actionable unit and the thing a
798        // rewrite takes. The per-blob detail is capped: it is evidence, not
799        // instruction, and a path with forty revisions would bury the procedure
800        // below it under forty lines nobody reads.
801        for exposure in &self.leaked {
802            writeln!(
803                f,
804                "  {} — {} plaintext blob(s)",
805                show(&exposure.path),
806                exposure.sightings.len()
807            )?;
808            for sighting in exposure.sightings.iter().take(MAX_SIGHTINGS) {
809                writeln!(
810                    f,
811                    "      blob {} in commit {}",
812                    sighting.blob, sighting.commit
813                )?;
814            }
815            if exposure.sightings.len() > MAX_SIGHTINGS {
816                writeln!(
817                    f,
818                    "      … and {} more",
819                    exposure.sightings.len() - MAX_SIGHTINGS
820                )?;
821            }
822        }
823
824        writeln!(
825            f,
826            "\n  Rewriting history does NOT undo this. If the repository was ever \
827             pushed, the plain text is in every clone, fork, cache and CI log that \
828             saw it. In order:"
829        )?;
830        writeln!(
831            f,
832            "\n  1. ROTATE THE SECRET. This is the only step that actually revokes \
833             the exposure, and it is worth doing even if you do nothing else."
834        )?;
835        if self.fix_requested && self.in_the_clear.is_empty() {
836            writeln!(
837                f,
838                "  2. Already done: the current content is re-staged, so future \
839                 commits are encrypted."
840            )?;
841        } else {
842            writeln!(
843                f,
844                "  2. Re-stage the current content so future commits are encrypted:\n\
845                 \x20      git-xcrypt status --fix"
846            )?;
847        }
848        writeln!(
849            f,
850            "  3. Only then, and only if you also want the old blobs gone, rewrite \
851             history with the external git-filter-repo. git-xcrypt does not rewrite \
852             history and will not pretend to:"
853        )?;
854        // One `--path` each is fine for a handful and unusable for hundreds, so
855        // past a point the command switches to the form git-filter-repo provides
856        // for exactly this.
857        if self.leaked.len() > MAX_LISTED {
858            writeln!(
859                f,
860                "\x20      # {} paths — put them in a file, one per line, then:\n\
861                 \x20      git filter-repo --invert-paths --paths-from-file leaked.txt",
862                self.leaked.len()
863            )?;
864        } else {
865            write!(f, "\x20      git filter-repo --invert-paths")?;
866            for exposure in &self.leaked {
867                write!(f, " --path {}", shell_quoted(&exposure.path))?;
868            }
869            writeln!(f)?;
870        }
871        writeln!(
872            f,
873            "     That deletes the file from every commit. To keep the file and drop \
874             only its history, remove it, rewrite, then add it back through the \
875             filter. Either way everyone with a clone has to re-clone."
876        )?;
877        // Everything above this command is decided on bytes; the command itself
878        // is text, and there the two part company. On Linux any byte string
879        // without `/` or NUL is a file name, so a path can reach here that no
880        // string can spell — and the rendering turns the stray bytes into U+FFFD,
881        // which git-filter-repo then matches against nothing while exiting 0. The
882        // finding is still right and the name above still identifies the file to
883        // a human; it is the instruction that has quietly stopped being one.
884        if self
885            .leaked
886            .iter()
887            .any(|exposure| std::str::from_utf8(&exposure.path).is_err())
888        {
889            writeln!(
890                f,
891                "\n     One or more of these paths is not valid UTF-8, so the names \
892                 above are shown with replacement characters and the command WILL \
893                 NOT match them — it would rewrite history and remove nothing, \
894                 reporting success. Take the exact bytes from `git log --all \
895                 --name-only -z` (or `git ls-tree -z -r <commit>`) and pass them \
896                 through `--paths-from-file`."
897            )?;
898        }
899        Ok(())
900    }
901
902    /// Paths a negation keeps in the clear on purpose.
903    fn write_by_choice(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
904        if self.by_choice.is_empty() {
905            return Ok(());
906        }
907        writeln!(
908            f,
909            "\nin the clear by choice: {} path(s) a `!` line in {} takes back out, \
910             so they are stored unencrypted on purpose.",
911            self.by_choice.len(),
912            crate::git::repo::CONFIG_FILE
913        )?;
914        for path in &self.by_choice {
915            writeln!(f, "  {}", show(path))?;
916        }
917        Ok(())
918    }
919}
920
921/// Inspects `repo` and reports what it found.
922///
923/// # Errors
924///
925/// [`Error::Config`] when git's configuration or `.gitattributes` cannot be
926/// read — "cannot tell" must never be reported as "nothing is wrong" by the one
927/// command whose whole job is to tell.
928///
929/// [`Error::Config`]: crate::Error::Config
930pub fn run(repo: &Repo, fix: bool) -> Result<Report> {
931    let mut report = Report {
932        has_key: repo.has_key(),
933        fix_requested: fix,
934        ..Report::default()
935    };
936
937    // The full cascade, not `.git/config` alone: git resolves a driver through
938    // system, global and local files alike, so a registration in `~/.gitconfig`
939    // is a working registration and reporting it as missing would be wrong.
940    // The common directory, because a linked worktree has a `config` file git
941    // ignores — the same resolution `init` had to be taught.
942    let config = gitconfig::open_full(repo.git_dir(), repo.common_dir())?;
943
944    for key in attributes::driver_keys() {
945        match gitconfig::get(&config, &key) {
946            None => report.setup.push(SetupGap::MissingKey(key)),
947            Some(value) if key.ends_with(".required") && !gitconfig::is_true(&value) => {
948                report.setup.push(SetupGap::NotTrue { key, value });
949            }
950            Some(value) if value.trim().is_empty() && key.ends_with(".process") => {
951                // An empty command is not a command. Git would try to run it and
952                // fail, which with `required` set aborts everything — but the
953                // honest report is that nothing is registered.
954                report.setup.push(SetupGap::MissingKey(key));
955            }
956            Some(_) => {}
957        }
958    }
959
960    // The `?` would surface an unreadable `.gitattributes` as `Error::Io`, which
961    // the frozen table gives code 1 — the same code a typo produces, and not
962    // what this function's own contract promises. It is a state conflict.
963    let catch_all = attributes::catch_all_present(&repo.attributes_path()).map_err(|err| {
964        crate::Error::Config(format!(
965            "{} could not be read ({err}), so whether git filters this repository \
966             at all cannot be determined",
967            repo.attributes_path().display()
968        ))
969    })?;
970    if !catch_all {
971        report.setup.push(SetupGap::CatchAllMissing);
972    }
973
974    report.notes.extend(diff_driver_note(repo, &config));
975    if !report.has_key {
976        // Otherwise a locked repository and a healthy one render identically,
977        // and "no findings" reads as "everything is fine here" to someone who
978        // has just lost the ability to read any of it.
979        report.notes.push(
980            "there is no key in this repository, so nothing here can be decrypted. \
981             That is the expected state after `lock` and in a fresh clone; \
982             `git-xcrypt unlock <key-file>` opens it."
983                .into(),
984        );
985    }
986
987    // The same wrap as `.gitattributes` above, for the same reason: `?` alone
988    // surfaces an unreadable `.git-xcrypt` as `Error::Io`, code 1 — a bare
989    // "could not be read" with no verdict, no sections and no code a gate can
990    // act on, indistinguishable from a typo. Measured with `chmod 000
991    // .git-xcrypt`. It is a state conflict: a declaration nobody can read
992    // enforces nothing this command can prove, exactly like a missing one —
993    // and like there, nothing is stored in the clear over it, because the
994    // check-in path refuses on the same state.
995    let declarations = Config::load(&repo.xcrypt_config_path()).map_err(|err| match err {
996        crate::Error::Io(err) => crate::Error::Config(format!(
997            "{err}; status cannot tell which paths were meant to be encrypted, so \
998             nothing was checked. The check-in path refuses over the same state, \
999             so nothing is being stored in the clear; make {} readable and ask \
1000             again",
1001            crate::git::repo::CONFIG_FILE
1002        )),
1003        other => other,
1004    })?;
1005    if declarations.missing {
1006        // Both, and they are two different statements. The gap is the state:
1007        // nothing here declares what to encrypt, so the configuration enforces
1008        // nothing — a `2`, not a `5`, because the check-in path refuses on this
1009        // state and no secret has been stored in the clear over it. The
1010        // undetermined entry is the consequence: the run stops here, so every
1011        // section below is empty for want of a question rather than for want of
1012        // a finding, and saying so is the difference between "I checked" and "I
1013        // could not". Silence there would be worse than either code.
1014        report.setup.push(SetupGap::DeclarationMissing);
1015        report.undetermined.push(format!(
1016            "nothing below was checked: without {} there is no way to tell which \
1017             paths should be encrypted, so neither the index nor the history was \
1018             scanned. This says nothing about what is in this repository.",
1019            crate::git::repo::CONFIG_FILE
1020        ));
1021        return Ok(report);
1022    }
1023    report.warnings.extend(declarations.pointless_eol.clone());
1024    match section_verdict(repo, &declarations) {
1025        SectionVerdict::Current => {}
1026        SectionVerdict::Stale => report.setup.push(SetupGap::SectionStale),
1027        // Said out loud rather than rounded down to "current". The reason
1028        // carries `upsert`'s own message, which already names the file and the
1029        // repair — every command that writes this file prints the same one.
1030        SectionVerdict::Unanswerable(why) => report.undetermined.push(format!(
1031            "{why} — so whether the managed section still covers every declared \
1032             path could not be determined. Nothing above rules it out: every \
1033             command that writes this file refuses over the same state."
1034        )),
1035    }
1036
1037    let hash = index::object_hash(gitconfig::get(&config, "extensions.objectformat").as_deref());
1038    let objects = history::objects(repo.common_dir(), hash)?;
1039
1040    // Git's own attribute stack, not a search for suspicious lines: the question
1041    // is what `git check-attr filter` answers for each declared path, and only a
1042    // resolution answers it. Built once for the whole run; discovery is lazy —
1043    // the resolver probes `.gitattributes` on the ancestor chain of each path it
1044    // is asked about, so this command no longer pays for a build directory's
1045    // worth of entries it was never going to consult.
1046    //
1047    // `core.ignorecase` belongs here and **only** here. This resolver reproduces
1048    // what git does, so it has to obey the setting git obeys; selection folds
1049    // ASCII case unconditionally and reads no configuration at all (see
1050    // `config::MATCHING`). Confusing the two axes would break the very thing this
1051    // resolver exists to detect.
1052    let ignore_case =
1053        gitconfig::get(&config, "core.ignorecase").is_some_and(|value| gitconfig::is_true(&value));
1054    // Resolved, not read verbatim: `~/` and the XDG default are sources git
1055    // honours, and a `text` line in one of them converts the ciphertext.
1056    let global_attributes = gitconfig::global_attributes_file(&config);
1057    let mut filters = attributes::AttributeResolver::new(
1058        repo.work_tree(),
1059        // The common directory: `info/` is shared by every checkout, so a linked
1060        // worktree resolves the *main* `info/attributes` — see the resolver.
1061        repo.common_dir(),
1062        global_attributes.as_deref(),
1063        ignore_case,
1064        // The index copies git falls back to for a deleted `.gitattributes`:
1065        // check-in reads them, so the verdict has to as well.
1066        attributes::staged_fallbacks(
1067            repo.work_tree(),
1068            &repo.git_dir().join("index"),
1069            repo.common_dir(),
1070            hash,
1071            ignore_case,
1072        ),
1073    );
1074
1075    inspect_index(
1076        repo,
1077        &declarations,
1078        &objects,
1079        hash,
1080        &mut filters,
1081        &mut report,
1082    )?;
1083    // The note's list is the whole tree, walked deliberately — not the
1084    // resolver's consulted sources. The note exists to name an attributes file
1085    // that reaches paths the index does not hold yet, and such a file can sit
1086    // in a directory with no tracked path — exactly the file a lazy resolver
1087    // never visits. The walk's cost lands on this diagnostic command alone;
1088    // the filter's hot path never runs it.
1089    let mut note_sources = attributes::attribute_files_under(repo.work_tree());
1090    note_sources.push(repo.common_dir().join("info").join("attributes"));
1091    note_sources.extend(global_attributes.clone());
1092    report.notes.extend(foreign_source_note(
1093        repo,
1094        &note_sources,
1095        report
1096            .setup
1097            .iter()
1098            .any(|gap| matches!(gap, SetupGap::FilterUnresolved { .. })),
1099    ));
1100    if fix {
1101        restage(repo, &declarations, hash, &mut report)?;
1102    }
1103
1104    let scan = history::scan(
1105        &objects,
1106        repo.git_dir(),
1107        repo.common_dir(),
1108        hash,
1109        &declarations,
1110        is_partial_clone(&config),
1111    )?;
1112    report.scan_ran = true;
1113    report.scanned = Scanned {
1114        commits: scan.commits,
1115        blobs: scan.blobs,
1116    };
1117    report.warnings.extend(scan.warnings);
1118    if scan.partial {
1119        // The twin of the shallow case, and it was making the same mistake:
1120        // a promisor object is absent by design, so reporting it as unreadable
1121        // sent the user to `git fsck`, which exits 0 here and finds nothing.
1122        report.undetermined.push(
1123            "this is a partial clone, so some objects were never downloaded and \
1124             could not be judged. `git fetch --refetch --filter=blob:none` or a \
1125             full clone brings them down; `git fsck` will not report them missing."
1126                .into(),
1127        );
1128    }
1129    if scan.shallow {
1130        // Named before the object count, and separately: a shallow clone is not
1131        // a damaged one, and telling a user to run `git fsck` over a graft point
1132        // sends them after a problem that is not there.
1133        report.undetermined.push(
1134            "this is a shallow clone, so the history before its graft point was \
1135             never fetched and could not be scanned. `git fetch --unshallow` \
1136             brings the rest down; until then nothing here covers it."
1137                .into(),
1138        );
1139    }
1140    if scan.unreadable > 0 {
1141        report.undetermined.push(format!(
1142            "{} object(s) in this repository could not be read, so they were not \
1143             judged. A history scan that skipped something has proved nothing \
1144             about it; `git fsck` says what is missing.",
1145            scan.unreadable
1146        ));
1147    }
1148    // A reference the walk could not start from is not one skipped object — it
1149    // is a whole branch's history unvisited, and if the store as a whole cannot
1150    // be enumerated the scan visited nothing at all and found nothing for that
1151    // reason alone. Measured before this: `chmod 000 .git/packed-refs` left a
1152    // repository with a plaintext blob in its history reporting clean, exit 0.
1153    if scan.refs_unavailable {
1154        report.undetermined.push(
1155            "this repository's references could not be listed, so no history was \
1156             scanned at all. Nothing above says anything about what is in it."
1157                .into(),
1158        );
1159    } else if scan.unresolved_refs > 0 {
1160        // Named, not counted. "1 reference(s) could not be resolved" in a CI log
1161        // gives an operator nothing to act on.
1162        let mut named = scan.unresolved_names.join(", ");
1163        if scan.unresolved_refs > scan.unresolved_names.len() {
1164            named.push_str(", …");
1165        }
1166        report.undetermined.push(format!(
1167            "{} reference(s) could not be resolved, so whatever is reachable only \
1168             through them was not scanned: {named}",
1169            scan.unresolved_refs
1170        ));
1171    }
1172    report.notes.extend(scan.notes);
1173    report.leaked = scan.exposed;
1174
1175    Ok(report)
1176}
1177
1178/// Whether this repository fetches objects lazily.
1179///
1180/// Git marks a partial clone with `remote.<name>.promisor` and
1181/// `extensions.partialclone`; either is enough to know an absent object is a
1182/// design decision rather than damage.
1183fn is_partial_clone(config: &gix_config::File) -> bool {
1184    if gitconfig::get(config, "extensions.partialclone").is_some() {
1185        return true;
1186    }
1187    config
1188        .sections_by_name("remote")
1189        .into_iter()
1190        .flatten()
1191        .any(|section| {
1192            section
1193                .value("promisor")
1194                .is_some_and(|value| gitconfig::is_true(&value.to_string()))
1195        })
1196}
1197
1198/// Reads what the index would have the next commit store.
1199///
1200/// The index rather than `HEAD`, because that is the question with a remedy: a
1201/// declared path whose staged blob is plain text is what a commit made now would
1202/// push, and `git add` fixes exactly that. `HEAD` is covered by the history scan,
1203/// which reaches it along with everything else.
1204fn inspect_index(
1205    repo: &Repo,
1206    declarations: &Config,
1207    objects: &gix_odb::Handle,
1208    hash: gix_hash::Kind,
1209    filters: &mut attributes::AttributeResolver,
1210    report: &mut Report,
1211) -> Result<()> {
1212    let index_path = repo.git_dir().join("index");
1213    // An I/O failure reading the index is the same *answer* as an index that
1214    // will not parse — "nothing is known about what the next commit would
1215    // store" — and used to be a different outcome: the `?` propagated out of
1216    // `run`, so `status` printed no verdict at all and exited 1, "usage error or
1217    // unclassified". Measured with `chmod 000 .git/index` on a repository that
1218    // was genuinely exposed: no verdict, no leaked section, exit 1, and the
1219    // message did not even name the file. A `.git` written by `sudo git` or a
1220    // read-only mount reaches it.
1221    let listed = index::list(&index_path, hash)
1222        .unwrap_or_else(|err| index::Listed::Unavailable(format!("it could not be read ({err})")));
1223    let entries = match listed {
1224        index::Listed::Read(entries) => entries,
1225        index::Listed::Unavailable(why) => {
1226            // Refusing outright would withhold the history scan, which needs no
1227            // index at all and carries the finding that matters most. Failing
1228            // the gate over it keeps "could not tell" from reading as "fine".
1229            report.undetermined.push(format!(
1230                "{} could not be used because {why}, so nothing is known about what \
1231                 the next commit would store. For a split index, \
1232                 `git update-index --no-split-index` converts it back.",
1233                index_path.display()
1234            ));
1235            return Ok(());
1236        }
1237    };
1238
1239    // `init` creates these two and does not commit them. A repository where
1240    // they were never staged looks perfectly configured from inside and
1241    // publishes nothing that enforces anything — the clone finds out, the
1242    // machine that pushed does not. Both are checked against the index rather
1243    // than the disk, because being on disk is exactly what is not in question.
1244    //
1245    // Only once something else is tracked, though. Between `git init` and the
1246    // first `git add` everything is untracked, and complaining then is a
1247    // complaint about a repository that has not published anything yet.
1248    if !entries.is_empty() {
1249        let mut tracked_bootstrap = [false, false];
1250        for entry in &entries {
1251            if entry.path == crate::git::repo::ATTRIBUTES_FILE.as_bytes() {
1252                tracked_bootstrap[0] = true;
1253            } else if entry.path == crate::git::repo::CONFIG_FILE.as_bytes() {
1254                tracked_bootstrap[1] = true;
1255            }
1256        }
1257        for (present, name) in tracked_bootstrap.iter().zip([
1258            crate::git::repo::ATTRIBUTES_FILE,
1259            crate::git::repo::CONFIG_FILE,
1260        ]) {
1261            if !present {
1262                report.setup.push(SetupGap::Untracked(name.to_string()));
1263            }
1264        }
1265    }
1266
1267    // Declared paths git resolves to something other than our driver, with what
1268    // it resolves instead. Collected rather than reported one by one: a
1269    // subdirectory `.gitattributes` reaches every file under it, and three
1270    // hundred identical gaps would bury every other finding.
1271    let mut unfiltered: Vec<(String, String)> = Vec::new();
1272    // Declared paths whose stored bytes git converts itself, with the line that
1273    // decides it. Grouped the same way and for the same reason.
1274    let mut converted: Vec<(String, String)> = Vec::new();
1275
1276    for entry in entries {
1277        // A symbolic link and a submodule gitlink are not file content, so git
1278        // never filters them and no declaration could have applied. Measured on
1279        // the build that skipped this check: a tracked symlink read as "in the
1280        // clear", `--fix` followed it, encrypted the file it pointed at — one
1281        // no pattern selected — and left a symlink whose target was the first
1282        // NUL of a ciphertext. `history::walk_tree` had the check all along.
1283        // A `git add -N` placeholder is the third case: mode 100644 and the
1284        // empty blob, so it reads as content in the clear — and repointing it
1285        // announced a repair the next commit did not make, because git still
1286        // treats the path as unstaged.
1287        if !entry.holds_content() {
1288            continue;
1289        }
1290        let index::Tracked { path: name, id, .. } = entry;
1291
1292        if declarations.negated(&name) {
1293            report.by_choice.push(name);
1294            continue;
1295        }
1296        if !declarations.decide(&name).encrypt {
1297            // No entry here about a name that differs only in case. Until
1298            // 2026-08-05 there was one, reported as *undetermined*, because
1299            // selection matched bytes while git folded case — so the managed
1300            // section reached paths the filter did not, and which spelling won
1301            // was an open decision nothing was allowed to guess. Selection now
1302            // folds ASCII case unconditionally (`config::MATCHING`), so the two
1303            // answers cannot differ and the note could only ever have been
1304            // false. What folding still does not reach is spelled out in
1305            // `README.md` §Known limitations rather than reported per path: it
1306            // is a property of the declaration, identical on every machine and
1307            // in every repository, so a scan has nothing to add to it.
1308            continue;
1309        }
1310
1311        // What git would actually do with this path, asked of git's own rules.
1312        // A declared path git does not resolve to our driver is stored in the
1313        // clear on the next `git add`, with exit code 0 and no warning — and
1314        // every other check in this command passes while it happens.
1315        let resolved = filters.resolve(&name);
1316        if !resolved.filter.is_ours() {
1317            unfiltered.push((show(&name), resolved.filter.to_string()));
1318        }
1319        // The second question of the same stack. A path git filters correctly
1320        // and then converts is not half-protected: the ciphertext is destroyed,
1321        // which costs more than storing the plain text would have.
1322        if let attributes::EolConversion::On(culprit) = resolved.conversion {
1323            converted.push((show(&name), display_culprit(repo, &culprit)));
1324        }
1325
1326        let Ok(id) = gix_hash::oid::try_from_bytes(&id) else {
1327            report.undetermined.push(format!(
1328                "{}: the index records an object id this build cannot read",
1329                show(&name)
1330            ));
1331            continue;
1332        };
1333        match history::stored_in_the_clear(objects, id) {
1334            Some(true) => report.in_the_clear.push(name),
1335            Some(false) => report.encrypted.push(name),
1336            None => report.undetermined.push(format!(
1337                "{}: the index names object {id}, which is not in this repository's \
1338                 object database, so what it holds is unknown",
1339                show(&name)
1340            )),
1341        }
1342    }
1343
1344    report.encrypted.sort();
1345    report.in_the_clear.sort();
1346    report.by_choice.sort();
1347
1348    if !unfiltered.is_empty() {
1349        unfiltered.sort();
1350        let resolved = unfiltered
1351            .first()
1352            .map(|(_, resolved)| resolved.clone())
1353            .unwrap_or_default();
1354        report.setup.push(SetupGap::FilterUnresolved {
1355            paths: unfiltered
1356                .iter()
1357                .take(MAX_LISTED)
1358                .map(|(path, _)| path.clone())
1359                .collect(),
1360            total: unfiltered.len(),
1361            resolved,
1362        });
1363    }
1364
1365    if !converted.is_empty() {
1366        converted.sort();
1367        let culprit = converted
1368            .first()
1369            .map(|(_, culprit)| culprit.clone())
1370            .unwrap_or_default();
1371        report.setup.push(SetupGap::CiphertextConverted {
1372            paths: converted
1373                .iter()
1374                .take(MAX_LISTED)
1375                .map(|(path, _)| path.clone())
1376                .collect(),
1377            total: converted.len(),
1378            culprit,
1379        });
1380    }
1381
1382    Ok(())
1383}
1384
1385/// The attribute line behind a verdict, with its path made repository-relative.
1386///
1387/// An absolute path out of a temporary directory tells a reader nothing they can
1388/// act on, and `$GIT_DIR/info/attributes` has to keep enough of its path to be
1389/// recognisable as the unversioned source it is.
1390fn display_culprit(repo: &Repo, culprit: &attributes::Culprit) -> String {
1391    let Some(source) = &culprit.source else {
1392        return culprit.to_string();
1393    };
1394    let shown = repo.relative(source).unwrap_or(source);
1395    attributes::Culprit {
1396        source: Some(shown.to_path_buf()),
1397        ..culprit.clone()
1398    }
1399    .to_string()
1400}
1401
1402/// Names the attribute files carrying `filter` lines, once resolution has run.
1403///
1404/// Kept as a **note** and emitted only when there is something to attach it to,
1405/// which is the change 2026-08-04 brought. Before it, the mere presence of a
1406/// foreign `filter` line produced this note on every run — and `*.psd
1407/// filter=lfs` in a subdirectory is entirely ordinary, so the note fired in
1408/// repositories where nothing was wrong and taught a reader to skip it.
1409///
1410/// It still says what the resolution cannot: these lines exist, and a path they
1411/// reach which the index does not yet hold would not be filtered either. That is
1412/// the honest boundary of a check that resolves only the paths git is tracking.
1413fn foreign_source_note(
1414    repo: &Repo,
1415    sources: &[PathBuf],
1416    reached_a_declared_path: bool,
1417) -> Vec<String> {
1418    let mut notes = Vec::new();
1419    for source in sources {
1420        let Ok(lines) = attributes::foreign_lines_touching(source, &["filter"]) else {
1421            continue;
1422        };
1423        if lines.is_empty() {
1424            continue;
1425        }
1426        let shown = git_spelling(repo.relative(source).unwrap_or(source));
1427        let verdict = if reached_a_declared_path {
1428            "and git takes the LAST match. Some of them reach a declared path — \
1429             see the setup gap above, which is the finding."
1430        } else {
1431            "and git takes the LAST match. Checked against every declared path the \
1432             index holds: git still resolves `filter=git-xcrypt` for all of them, \
1433             so nothing tracked is unprotected by these. A path they reach which \
1434             the index does not yet hold would be."
1435        };
1436        notes.push(format!(
1437            "{shown} carries {} line(s) of its own that set or unset `filter`, \
1438             {verdict} Check with `git check-attr filter -- <path>`:\n    {}",
1439            lines.len(),
1440            lines.join("\n    ")
1441        ));
1442    }
1443    notes
1444}
1445
1446/// Re-stages every declared path the index holds in the clear.
1447///
1448/// This is `git add` on those paths, done without spawning git: the working-tree
1449/// content goes through [`decide::clean`] — the very function git calls on the
1450/// check-in path, so the bytes are the bytes a real `git add` would store — the
1451/// resulting blob is written to the object database, and the index entry is
1452/// pointed at it.
1453///
1454/// **The working tree is not touched.** Encrypting the files in place is what
1455/// `lock` does, and doing it here would take a user's own secrets away from them
1456/// in the name of a repair. What changes is what the *next commit* stores.
1457///
1458/// A path whose working-tree file is gone is left alone: there is nothing to
1459/// clean, and inventing content for it would be worse than saying so.
1460///
1461/// The blob is written before the index is locked, so a run that then finds the
1462/// lock held — or an index it will not patch — leaves an unreferenced ciphertext
1463/// object behind. Harmless, and `git gc` collects it; worth knowing only because
1464/// "nothing was re-staged" does not mean "nothing was written".
1465fn restage(
1466    repo: &Repo,
1467    declarations: &Config,
1468    hash: gix_hash::Kind,
1469    report: &mut Report,
1470) -> Result<()> {
1471    if report.in_the_clear.is_empty() {
1472        return Ok(());
1473    }
1474
1475    // A missing key stops the repair, not the report. `--fix` is the one part of
1476    // this command that needs a key, and propagating the error here would throw
1477    // away the setup findings and the whole history scan — leaving a user who
1478    // typed one flag too many with less information than if they had not.
1479    // Every failure here, not only a missing key: an unreadable or corrupt key
1480    // file used to propagate and throw away the setup findings and the whole
1481    // history scan, which is the same loss the missing-key case was fixed for.
1482    let key = match repo.load_key() {
1483        Ok(key) => key,
1484        Err(err) => {
1485            let what = match err {
1486                crate::Error::NoKey => "there is none here".to_string(),
1487                other => format!("it could not be read ({other})"),
1488            };
1489            report.undetermined.push(format!(
1490                "--fix needs the repository key in order to re-encrypt, and {what}, \
1491                 so nothing was re-staged. `git-xcrypt unlock <key-file>` puts one \
1492                 in place. The {} path(s) reported below are still in the clear.",
1493                report.in_the_clear.len()
1494            ));
1495            return Ok(());
1496        }
1497    };
1498    let loose = gix_odb::loose::Store::at(
1499        repo.common_dir().join("objects"),
1500        gix_odb::loose::Options {
1501            object_hash: hash,
1502            ..gix_odb::loose::Options::default()
1503        },
1504    );
1505
1506    let mut updates: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
1507    let mut kept: Vec<Vec<u8>> = Vec::new();
1508
1509    for name in std::mem::take(&mut report.in_the_clear) {
1510        let path = repo
1511            .work_tree()
1512            .join(crate::git::repo::working_tree_path(&name));
1513        // The working-tree twin of `holds_content`. The index entry says
1514        // regular file, but the disk decides what `fs::read` returns, and a
1515        // path replaced by a symlink since it was staged would be read through
1516        // — encrypting a file no pattern declared and repointing the entry at
1517        // it, while `git add` would stage the typechange instead. `lock`,
1518        // `unlock` and the history walk all decline symlinks; this is the one
1519        // other working-tree read. A missing file falls through to the read
1520        // below, whose message already covers it.
1521        if let Ok(metadata) = std::fs::symlink_metadata(&path)
1522            && !metadata.is_file()
1523        {
1524            report.warnings.push(format!(
1525                "{}: not re-staged, it is no longer a regular file on disk, so \
1526                 reading it would take content from somewhere else. The index \
1527                 still holds it in the clear; `git add` records what is really \
1528                 there.",
1529                show(&name)
1530            ));
1531            kept.push(name);
1532            continue;
1533        }
1534        let content = match std::fs::read(&path) {
1535            Ok(content) => zeroize::Zeroizing::new(content),
1536            Err(err) => {
1537                report.warnings.push(format!(
1538                    "{}: not re-staged, its working-tree file could not be read \
1539                     ({err}). The index still holds it in the clear.",
1540                    show(&name)
1541                ));
1542                kept.push(name);
1543                continue;
1544            }
1545        };
1546
1547        // One file this build cannot clean — ciphertext under a key this
1548        // repository does not hold, a truncated header — must not take the
1549        // report with it. Propagating here printed nothing at all and exited 4
1550        // over a run that had already established two secrets sitting in
1551        // history in the clear, so the gate read "the tool broke" and lost every
1552        // finding. The two failures either side of this one were already handled
1553        // this way; this one was the odd case out.
1554        let outcome = match crate::rules::decide::clean(Some(&key), declarations, &name, &content) {
1555            Ok(outcome) => outcome,
1556            Err(err) => {
1557                report.warnings.push(format!(
1558                    "{}: not re-staged ({}). The index still holds it in the clear.",
1559                    show(&name),
1560                    named(&name, err)
1561                ));
1562                kept.push(name);
1563                continue;
1564            }
1565        };
1566        if let Some(warning) = outcome.warning {
1567            // The filter prints this; the second implementation of the check-in
1568            // path must not be the one that swallows it.
1569            report.warnings.push(warning);
1570        }
1571        match loose.write_buf(gix_object::Kind::Blob, &outcome.content) {
1572            Ok(id) => updates.push((name, id.as_slice().to_vec())),
1573            Err(err) => {
1574                report.warnings.push(format!(
1575                    "{}: not re-staged, its encrypted form could not be written to \
1576                     the object database ({err})",
1577                    show(&name)
1578                ));
1579                kept.push(name);
1580            }
1581        }
1582    }
1583
1584    // Same reasoning as the read above: a lock that cannot be taken, or an index
1585    // that cannot be replaced, means `--fix` did nothing — which is a warning
1586    // beside a report that still has a history scan to deliver, not a reason to
1587    // print nothing and exit 1. Measured with `chmod a-w .git`: the whole report
1588    // vanished, exposures included.
1589    let restaged = index::restage(&repo.git_dir().join("index"), hash, &updates)
1590        .unwrap_or_else(|err| index::Restaged::Skipped(err.to_string()));
1591    match restaged {
1592        index::Restaged::Done(patched) => {
1593            // Which, not how many. A path the index spells differently than the
1594            // directory does — case folding on macOS and Windows, NFD against
1595            // NFC — is simply not found, and subtracting counts would name the
1596            // wrong file as fixed while the real one disappeared from both
1597            // lists. Everything that was asked for and did not come back stays
1598            // in `in_the_clear`, where it belongs.
1599            let missed: Vec<Vec<u8>> = updates
1600                .into_iter()
1601                .map(|(name, _)| name)
1602                .filter(|name| !patched.contains(name))
1603                .collect();
1604            if !missed.is_empty() {
1605                report.warnings.push(format!(
1606                    "{} path(s) were not found in the index under the name they \
1607                     have on disk, so they were left as they were: {}. `git add` \
1608                     on them by hand settles it.",
1609                    missed.len(),
1610                    missed
1611                        .iter()
1612                        .map(|name| show(name))
1613                        .collect::<Vec<_>>()
1614                        .join(", ")
1615                ));
1616                kept.extend(missed);
1617            }
1618            report.fixed = patched;
1619        }
1620        index::Restaged::Skipped(why) => {
1621            report.warnings.push(why);
1622            kept.extend(updates.into_iter().map(|(name, _)| name));
1623        }
1624    }
1625
1626    report.in_the_clear = kept;
1627    report.in_the_clear.sort();
1628    report.fixed.sort();
1629    Ok(())
1630}
1631
1632/// Puts a path in front of an error that only knew about content.
1633fn named(name: &[u8], err: crate::Error) -> crate::Error {
1634    use crate::Error;
1635    let at = show(name);
1636    match err {
1637        Error::Format(message) => Error::Format(format!("{at}: {message}")),
1638        Error::Crypto(message) => Error::Crypto(format!("{at}: {message}")),
1639        Error::Config(message) => Error::Config(format!("{at}: {message}")),
1640        Error::Io(err) => Error::Io(std::io::Error::other(format!("{at}: {err}"))),
1641        mismatch @ Error::KeyMismatch { .. } => Error::Format(format!("{at}: {mismatch}")),
1642        other => other,
1643    }
1644}
1645
1646/// What this run could make of the managed `.gitattributes` section.
1647enum SectionVerdict {
1648    /// It matches one of the shapes this build writes.
1649    Current,
1650    /// It matches none of them, which is what a changed declaration leaves.
1651    Stale,
1652    /// It could not be compared at all, and why.
1653    Unanswerable(String),
1654}
1655
1656/// Compares the managed `.gitattributes` section against `.git-xcrypt`.
1657///
1658/// A [`SetupGap::SectionStale`] since 2026-08-06, where the reasoning lives; it
1659/// was a note until then, and `sync --check` disagreed with it.
1660///
1661/// **A refusal is a third answer, not a second "matches".** [`attributes::upsert`]
1662/// declines to say what the file should hold when the markers are doubled or
1663/// unbalanced — a merge conflict resolved by keeping both sides produces exactly
1664/// that — and this function used to take the refusal as "does not match" and then
1665/// ask again for the reason with `.ok()?`, which threw it away and returned "no
1666/// gap". Measured on git 2.55: over a doubled section `sync --check`, `sync`,
1667/// `init` and `unlock` all exit `2`, `unlock` cannot open such a clone at all,
1668/// and `status` printed `VERDICT: no findings.` and exited `0`. That is the one
1669/// answer this command may never give.
1670///
1671/// It is reported as **undetermined** rather than as a setup gap, and the
1672/// difference is not a technicality. Two identical sections enforce exactly what
1673/// one does — `git check-attr` gives the same answers — so a gap would over-claim,
1674/// and [`Report::stores_in_the_clear`] would then print "committing a declared
1675/// file stores it in the clear", sending a user to rotate secrets that were never
1676/// exposed. What is provably true is that this run could not compare the section.
1677fn section_verdict(repo: &Repo, declarations: &Config) -> SectionVerdict {
1678    // Every rendering counts as current. `sync --ignorecase` writes the folded
1679    // form deliberately, and calling it stale would send its user to run the very
1680    // command that produced it — a loop with no exit.
1681    let path = repo.attributes_path();
1682    let mut refusal = None;
1683    for rendering in attributes::ACCEPTED {
1684        let lines = attributes::render_lines(declarations, rendering);
1685        match attributes::desired(&path, &lines) {
1686            Ok((existing, desired)) if existing == desired => return SectionVerdict::Current,
1687            Ok(_) => {}
1688            // The renderings differ only in what goes *inside* the markers, so
1689            // they all ask `upsert` the same structural question and the first
1690            // refusal is the whole answer; keeping it is about naming the reason.
1691            Err(err) => {
1692                if refusal.is_none() {
1693                    refusal = Some(err.to_string());
1694                }
1695            }
1696        }
1697    }
1698    refusal.map_or(SectionVerdict::Stale, SectionVerdict::Unanswerable)
1699}
1700
1701/// Mentions an absent diff driver, without letting it fail the gate.
1702///
1703/// Deliberately outside [`attributes::driver_keys`] and outside the exit
1704/// code. A missing `diff.git-xcrypt.textconv` costs a readable `git diff` and
1705/// nothing else — no secret reaches the object database over it — and `lock`
1706/// removes it **on purpose**, because with no key the driver drags a failing
1707/// smudge filter into every `git log -p`. Counting it as a finding would make
1708/// every correctly locked repository report itself broken, which is the fastest
1709/// way to teach a user to ignore this command.
1710///
1711/// So it is said only where it is actionable: a repository that holds a key, and
1712/// therefore could be showing plaintext diffs, and is not.
1713fn diff_driver_note(repo: &Repo, config: &gix_config::File) -> Option<String> {
1714    if !repo.has_key() {
1715        return None;
1716    }
1717    if gitconfig::get(config, &format!("diff.{DRIVER}.textconv")).is_some() {
1718        return None;
1719    }
1720    Some(format!(
1721        "diff.{DRIVER}.textconv is not registered, so `git diff` on an encrypted \
1722         file reports `Binary files differ` instead of comparing the plain text. \
1723         `git-xcrypt init` registers it. Nothing is stored in the clear over this."
1724    ))
1725}
1726
1727#[cfg(test)]
1728mod tests {
1729    use super::*;
1730
1731    /// One leak, spelled the way the history scan spells one.
1732    fn a_leak() -> crate::git::history::Exposure {
1733        crate::git::history::Exposure {
1734            path: b"secrets/db.env".to_vec(),
1735            sightings: Vec::new(),
1736        }
1737    }
1738
1739    #[test]
1740    fn a_question_left_unanswered_is_its_own_verdict_and_never_masks_a_finding() {
1741        // The two codes are the whole point of the split: `6` says fix the
1742        // checkout, `5` says rotate a secret. A run that hit both has found a
1743        // secret, so the stronger answer has to win — measured here rather than
1744        // trusted, because the direction of that precedence is the one thing
1745        // that would quietly weaken the gate.
1746        let clean = Report::default();
1747        assert_eq!(clean.verdict(), Verdict::Clean);
1748
1749        let undetermined = Report {
1750            undetermined: vec!["a shallow clone".into()],
1751            ..Report::default()
1752        };
1753        assert_eq!(undetermined.verdict(), Verdict::Undetermined);
1754        assert!(
1755            undetermined.to_string().contains("NOTHING WAS FOUND"),
1756            "the verdict line must not read as a finding: {undetermined}"
1757        );
1758
1759        let both = Report {
1760            undetermined: vec!["a shallow clone".into()],
1761            in_the_clear: vec![b"secrets/db.env".to_vec()],
1762            ..Report::default()
1763        };
1764        assert_eq!(both.verdict(), Verdict::Exposed);
1765        assert!(
1766            !both.to_string().contains("NOTHING WAS FOUND"),
1767            "an exposure must not be softened by what could not be checked: {both}"
1768        );
1769    }
1770
1771    #[test]
1772    fn configuration_outranks_both_other_answers_and_conceals_neither() {
1773        // The precedence added 2026-08-05, in the four combinations that decide
1774        // it. A setup gap wins over a finding and over a question alike, because
1775        // a repository git is not filtering cannot be repaired by acting on what
1776        // this report says about its data — the configuration is what makes the
1777        // rest mean anything.
1778        let gap = || {
1779            vec![SetupGap::MissingKey(
1780                "filter.git-xcrypt.process".to_string(),
1781            )]
1782        };
1783
1784        let misconfigured = Report {
1785            setup: gap(),
1786            ..Report::default()
1787        };
1788        assert_eq!(misconfigured.verdict(), Verdict::Misconfigured);
1789
1790        let over_a_question = Report {
1791            setup: gap(),
1792            undetermined: vec!["a shallow clone".into()],
1793            ..Report::default()
1794        };
1795        assert_eq!(over_a_question.verdict(), Verdict::Misconfigured);
1796
1797        // The one that matters most. A leak reported under code `2` is the same
1798        // leak it would be under `5`, so the verdict may reorder the work and
1799        // must not take a section off the page — an operator who fixes the setup
1800        // and never learns there was a leak has been failed by the gate that
1801        // told them the truth about their configuration.
1802        let over_a_finding = Report {
1803            setup: gap(),
1804            leaked: vec![a_leak()],
1805            in_the_clear: vec![b"secrets/late.env".to_vec()],
1806            ..Report::default()
1807        };
1808        assert_eq!(over_a_finding.verdict(), Verdict::Misconfigured);
1809        let text = over_a_finding.to_string();
1810        for expected in [
1811            "leaked in history",
1812            "secrets/db.env",
1813            "ROTATE THE SECRET",
1814            "in the clear:",
1815            "secrets/late.env",
1816            // And the verdict line itself has to point at them, or the first
1817            // line of the report contradicts the rest of it.
1818            "Also found",
1819        ] {
1820            assert!(
1821                text.contains(expected),
1822                "the configuration verdict swallowed `{expected}`:\n{text}"
1823            );
1824        }
1825        assert!(
1826            over_a_finding.exposed(),
1827            "a leak under a configuration verdict is still a leak:\n{text}"
1828        );
1829
1830        // And with the configuration settled, the very same findings answer `5`.
1831        let settled = Report {
1832            leaked: vec![a_leak()],
1833            in_the_clear: vec![b"secrets/late.env".to_vec()],
1834            ..Report::default()
1835        };
1836        assert_eq!(settled.verdict(), Verdict::Exposed);
1837    }
1838
1839    #[test]
1840    fn a_missing_declaration_is_a_configuration_gap_that_still_admits_it_checked_nothing() {
1841        // It is not an exposure — the check-in path refuses on this state, so
1842        // nothing was ever stored in the clear over it — and it is not merely an
1843        // unanswered question either: a repository that declares nothing
1844        // enforces nothing. Both halves have to reach the reader, and the second
1845        // is the one silence would cost most.
1846        let report = Report {
1847            setup: vec![SetupGap::DeclarationMissing],
1848            undetermined: vec!["nothing below was checked".into()],
1849            ..Report::default()
1850        };
1851        assert_eq!(report.verdict(), Verdict::Misconfigured);
1852        let text = report.to_string();
1853        assert!(
1854            text.contains("history was NOT scanned"),
1855            "a run that stopped before the scan must say so: {text}"
1856        );
1857        assert!(
1858            !text.contains("stores it in the clear"),
1859            "nothing is stored in the clear over a refused `git add`, and saying \
1860             otherwise sends a user to rotate a secret that was never exposed: {text}"
1861        );
1862    }
1863}