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 ¬e_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}