git_xcrypt/git/history.rs
1//! Walking every reachable commit, looking for declared paths stored in the clear.
2//!
3//! This is the answer to the product's largest real risk: a secret committed
4//! **before** the pattern that covers it existed. Nothing in the working tree
5//! shows it, `HEAD` need not show it either — deleting the file does not delete
6//! the blob — and it is still sitting at the hosting provider. A shallow check
7//! would report such a repository as clean, which is worse than no check at all.
8//!
9//! Three properties shape the implementation.
10//!
11//! **No decryption, and no key.** The verdict per blob is the eleven bytes of
12//! magic at its start, so `status` works in a locked repository and in a clone
13//! that was never unlocked — which is exactly where a user most needs to ask.
14//!
15//! **Every object is looked at once.** A tree shared by a thousand commits is
16//! walked once, a blob appearing under a thousand commits is read once. Without
17//! that the cost would be quadratic in the history rather than linear in the
18//! object count, and the founding document's premise — "the cost depends on the
19//! number of objects, not their size" — would not hold. The premise holds only
20//! *approximately*, and the gap is worth naming: reading a blob through the
21//! object database decompresses all of it, not the first eleven bytes, because
22//! neither a loose object nor a packed delta can be truncated part way. The
23//! deduplication is what keeps that bounded.
24//!
25//! **Nothing here fails the scan over one bad object.** A repository with a
26//! missing object is broken in a way this command did not cause and cannot fix,
27//! and refusing outright would withhold the findings from every object that
28//! *did* read. Such objects are counted and reported, so "nothing found" and
29//! "nothing found in what I could read" never look the same. A **reference**
30//! that will not resolve is counted separately and weighs more, because it is a
31//! whole branch unvisited rather than one file unjudged.
32//!
33//! Known limits, recorded rather than hidden:
34//!
35//! * **The walk state is unbounded.** `seen_trees` holds one entry per
36//! `(tree, path)` pair over all reachable history, with the path cloned. It is
37//! comfortable for ordinary repositories and there is no cap, no progress
38//! output and no way to interrupt it part way. If that ever bites, interning
39//! the path prefixes and keying on `(ObjectId, usize)` cuts the dominant term.
40//! * **A path mid-merge is invisible to the index half** of `status`, which
41//! reads stage 0 only. The history scan still sees the conflicting blobs,
42//! because they come from commits; what is missing is a statement about what
43//! the *next* commit would store, which is genuinely undecided until the merge
44//! is resolved.
45//! * **[`HeadLookup`] resolves `HEAD` once per filter process.** A long-running
46//! filter outlives a `git rebase` that moves it, so the warning can be judged
47//! against the tree `HEAD` had at startup. It is advisory either way.
48//! * **The reflog and the other pseudo-references are out of scope.** Every
49//! worktree's `HEAD` and `refs/` are walked, but not `ORIG_HEAD`,
50//! `MERGE_HEAD`, `FETCH_HEAD` or `logs/`. So the canonical "oops": commit a
51//! secret, `git reset --hard HEAD~1`, then declare the pattern — the blob
52//! stays in the object database until `gc.reflogExpire` (90 days by default)
53//! and this scan reports nothing. The boundary is deliberate: those objects
54//! are local and no push carries them, so they are lost work rather than
55//! published exposure. `git reflog expire --expire=now --all` followed by
56//! `git gc --prune=now` clears them. The scan also says nothing about what a
57//! *remote* already holds, which no local command can.
58//! * **A declared blob is decompressed whole to be judged.** Only 11 bytes are
59//! needed, but the object database hands over the whole object, so one
60//! multi-gigabyte declared blob in history is a whole-file allocation.
61
62use std::collections::{HashMap, HashSet};
63use std::path::Path;
64
65use gix_hash::ObjectId;
66use gix_object::{Find as _, FindExt as _, FindHeader as _};
67use gix_ref::file::ReferenceExt as _;
68
69use crate::crypto::format;
70use crate::rules::declaration::Config;
71use crate::{Error, Result};
72
73/// One declared path that reachable history holds in the clear.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct Exposure {
76 /// Repository-relative path, exactly as the tree spells it.
77 pub path: Vec<u8>,
78 /// Distinct plaintext blobs stored under it, with a commit holding each.
79 pub sightings: Vec<Sighting>,
80}
81
82/// One plaintext blob, and a commit that contains it.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Sighting {
85 /// The blob itself.
86 pub blob: ObjectId,
87 /// A commit whose tree contains it.
88 ///
89 /// *A* commit, not the list of them: trees are deduplicated across the walk,
90 /// so the first commit to reach a given tree is the one recorded. Naming one
91 /// is what makes the finding checkable; the remedy is per path anyway, since
92 /// history rewriting takes paths and not commits.
93 pub commit: ObjectId,
94}
95
96/// What one scan found.
97#[derive(Debug, Default)]
98pub struct Scan {
99 /// Declared paths held in the clear, sorted by path.
100 pub exposed: Vec<Exposure>,
101 /// How many commits were visited.
102 pub commits: usize,
103 /// How many distinct blobs under a declared path were inspected.
104 pub blobs: usize,
105 /// Objects that could not be read, so could not be judged.
106 ///
107 /// Separate from [`Scan::warnings`] because the count changes what the
108 /// report means: a scan that skipped something has not proved anything about
109 /// it, and must not be summarised as though it had.
110 pub unreadable: usize,
111 /// References the walk could not start from.
112 ///
113 /// Counted apart from the objects for the same reason and a sharper one: a
114 /// reference store that cannot be read at all yields **no tips**, so the
115 /// scan visits nothing and finds nothing. Measured on the build before this
116 /// existed — `chmod 000 .git/packed-refs` and a removed loose branch left
117 /// `status` reporting a repository with a plaintext blob in its history as
118 /// clean, exit code 0. A warning on `stderr` is not enough: a CI gate reads
119 /// the code.
120 pub unresolved_refs: usize,
121 /// Names of the references the walk could not start from, capped.
122 ///
123 /// The count alone reaches `stdout` as "1 reference(s) could not be
124 /// resolved", which tells an operator reading a CI log nothing they can act
125 /// on. The names are what turn it into an instruction.
126 pub unresolved_names: Vec<String>,
127 /// The reference store could not be enumerated at all, so nothing here
128 /// covers anything.
129 pub refs_unavailable: bool,
130 /// This repository fetches objects lazily, so some were never downloaded.
131 ///
132 /// The twin of [`Scan::shallow`], and it was making the same mistake: a
133 /// promisor object is absent by design, and reporting it as unreadable sent
134 /// the user to `git fsck`, which exits 0 on a partial clone and reports
135 /// nothing at all.
136 pub partial: bool,
137 /// This is a shallow clone, so history stops at the graft points.
138 ///
139 /// Reported rather than treated as corruption. A shallow clone is an
140 /// ordinary, healthy state — and before this was honoured, the walk queued
141 /// the parents git deliberately did not fetch, failed to read them, and told
142 /// the user that objects were missing and to run `git fsck`, which is happy
143 /// with a shallow clone and would have reported nothing. The finding still
144 /// stands, because a history that was never fetched genuinely cannot be
145 /// vouched for; only the explanation was wrong.
146 pub shallow: bool,
147 /// Things worth stating that are not findings — a reference deliberately
148 /// not walked, a file under `refs/` that git ignores too.
149 pub notes: Vec<String>,
150 /// Anything worth saying once, carried out so the binary owns the messages.
151 pub warnings: Vec<String>,
152}
153
154/// The most detail any single message carries about unreadable objects.
155///
156/// One line per missing object in a repository whose pack is gone would be the
157/// whole terminal; the count in [`Scan::unreadable`] carries the rest.
158const MAX_UNREADABLE_WARNINGS: usize = 5;
159
160/// Scans everything reachable in the repository at `git_dir` / `common_dir`.
161///
162/// `config` decides which paths are worth reading a blob for; everything else is
163/// skipped without touching the object database.
164///
165/// # Errors
166///
167/// [`Error::Config`] when the object database or the reference store cannot be
168/// opened at all — "cannot tell" must never be reported as "nothing is wrong" by
169/// the one command whose whole job is to tell.
170pub fn scan(
171 objects: &gix_odb::Handle,
172 git_dir: &Path,
173 common_dir: &Path,
174 hash: gix_hash::Kind,
175 config: &Config,
176 partial: bool,
177) -> Result<Scan> {
178 let mut scan = Scan::default();
179 let tips = tips(git_dir, common_dir, hash, objects, &mut scan);
180 let grafts = grafts(git_dir, common_dir);
181 scan.shallow = !grafts.is_empty();
182 scan.partial = partial;
183
184 let mut queue: Vec<ObjectId> = tips;
185 let mut seen_commits: HashSet<ObjectId> = HashSet::new();
186 // Keyed by (tree, path it sits at): the same tree object can appear at two
187 // different paths — two directories with identical contents is ordinary —
188 // and the path is half of what the patterns match on.
189 let mut seen_trees: HashSet<(ObjectId, Vec<u8>)> = HashSet::new();
190 let mut verdicts: HashMap<ObjectId, bool> = HashMap::new();
191 let mut found: HashMap<Vec<u8>, Vec<Sighting>> = HashMap::new();
192
193 let mut buffer = Vec::new();
194 while let Some(commit) = queue.pop() {
195 if !seen_commits.insert(commit) {
196 continue;
197 }
198
199 let mut iter = match objects.find_commit_iter(&commit, &mut buffer) {
200 Ok(iter) => iter,
201 Err(err) => {
202 note_unreadable(&mut scan, &commit, &err.to_string());
203 continue;
204 }
205 };
206 let Ok(tree) = iter.tree_id() else {
207 note_unreadable(&mut scan, &commit, "its tree could not be read");
208 continue;
209 };
210 // Collected before the tree walk, which reuses the buffer this iterator
211 // borrows.
212 let parents: Vec<ObjectId> = iter.parent_ids().collect();
213
214 scan.commits += 1;
215 walk_tree(
216 objects,
217 config,
218 tree,
219 commit,
220 &mut seen_trees,
221 &mut verdicts,
222 &mut found,
223 &mut scan,
224 );
225 // A graft point is where a shallow clone stops. Its parents were never
226 // fetched, so queuing them would be asking the object database for
227 // objects git knows are absent — which read as corruption.
228 if !grafts.contains(&commit) {
229 queue.extend(parents);
230 }
231 }
232
233 scan.blobs = verdicts.len();
234 scan.exposed = collect(found);
235 Ok(scan)
236}
237
238/// The commits a shallow clone was cut off at, if it is one.
239///
240/// `$GIT_COMMON_DIR/shallow` holds one object id per line. Anything unparsable
241/// is skipped rather than reported: this file only ever makes the walk stop
242/// earlier, so misreading it costs coverage that is already absent, never a
243/// false clean bill of health.
244fn grafts(git_dir: &Path, common_dir: &Path) -> HashSet<ObjectId> {
245 let mut found = HashSet::new();
246 for directory in [common_dir, git_dir] {
247 let Ok(text) = std::fs::read_to_string(directory.join("shallow")) else {
248 continue;
249 };
250 for line in text.lines() {
251 if let Ok(id) = ObjectId::from_hex(line.trim().as_bytes()) {
252 found.insert(id);
253 }
254 }
255 }
256 found
257}
258
259/// Opens the repository's object database.
260///
261/// Shared rather than opened per question: `status` asks about the index and
262/// about history in one run, and two handles would mean two sets of open packs
263/// for the same objects.
264///
265/// **`hash` is not optional and `gix_odb::at` is not usable.** That convenience
266/// wrapper takes the default hash, which is SHA-1, and the store *asserts* the
267/// hash of every id handed to it. Measured on git 2.55 in a repository created
268/// with `--object-format=sha256`: `git-xcrypt status` panicked inside
269/// `gix-odb`, and so did the filter on the check-in path — which with
270/// `required = true` aborts every git operation in the repository. A tool this
271/// build already reads SHA-256 indexes for must not fall over on the object
272/// database.
273///
274/// # Errors
275///
276/// [`Error::Config`] when the database cannot be opened at all.
277pub fn objects(common_dir: &Path, hash: gix_hash::Kind) -> Result<gix_odb::Handle> {
278 let path = common_dir.join("objects");
279 gix_odb::at_opts(
280 &path,
281 Vec::new(),
282 gix_odb::store::init::Options {
283 object_hash: hash,
284 ..gix_odb::store::init::Options::default()
285 },
286 )
287 .map_err(|err| {
288 Error::Config(format!(
289 "the object database at {} could not be opened ({err}), so this \
290 repository cannot be inspected",
291 path.display()
292 ))
293 })
294}
295
296/// Whether the blob `id` is stored without our magic.
297///
298/// `None` when the object is not there to be judged, which a caller has to
299/// report rather than read as "fine": the whole point of this command is that
300/// silence and safety are different things.
301#[must_use]
302pub fn stored_in_the_clear(objects: &gix_odb::Handle, id: &gix_hash::oid) -> Option<bool> {
303 let mut buffer = Vec::new();
304 match objects.try_find(id, &mut buffer) {
305 Ok(Some(data)) => Some(!format::looks_encrypted(data.data)),
306 Ok(None) | Err(_) => None,
307 }
308}
309
310/// One cheap question, asked on the check-in path: is this path already in
311/// `HEAD` in the clear?
312///
313/// The founding document gives the filter this job because the filter is the one
314/// mechanism that runs whatever client is driving git. A `pre-commit` hook is
315/// bypassed by `--no-verify`, switched off by a checkbox in an IDE, and does not
316/// survive a clone; the attribute mechanism git enforces itself.
317///
318/// The constraint that shapes it is that this runs while `git add` waits. A full
319/// history scan here would stall every commit, so the question is deliberately
320/// the narrow one — one path, one tree chain, one blob — and the answer is only
321/// ever a message. **It must never make the filter exit non-zero.** With
322/// `required = true` a non-zero exit aborts the whole operation, and this is a
323/// warning about the past, not a reason to refuse the present.
324///
325/// Everything is resolved lazily and cached, so a repository where nothing is
326/// declared never opens the object database at all, and one where a hundred
327/// secrets live in the same directory reads that directory's tree once.
328pub struct HeadLookup {
329 objects: gix_odb::Handle,
330 /// The tree `HEAD` points at, resolved on the first question.
331 root: Option<ObjectId>,
332 /// Directory path to the tree it names, `None` where there is no such
333 /// directory in `HEAD`.
334 directories: HashMap<Vec<u8>, Option<ObjectId>>,
335}
336
337impl HeadLookup {
338 /// Prepares the lookup, or gives up quietly.
339 ///
340 /// `None` when there is nothing to look in — an unborn branch, an
341 /// unreadable object database. Quiet is right: this whole facility is an
342 /// extra, and a repository that cannot answer the question is not a
343 /// repository that should stop accepting commits over it.
344 #[must_use]
345 pub fn open(git_dir: &Path, common_dir: &Path, hash: gix_hash::Kind) -> Option<Self> {
346 let objects = objects(common_dir, hash).ok()?;
347 let options = gix_ref::store::init::Options {
348 object_hash: hash,
349 ..gix_ref::store::init::Options::default()
350 };
351 let store = if git_dir == common_dir {
352 gix_ref::file::Store::at(git_dir.to_path_buf(), options)
353 } else {
354 gix_ref::file::Store::for_linked_worktree(
355 git_dir.to_path_buf(),
356 common_dir.to_path_buf(),
357 options,
358 )
359 };
360
361 let mut head = store.try_find("HEAD").ok().flatten()?;
362 let commit = head.peel_to_id(&store, &objects).ok()?;
363
364 let mut buffer = Vec::new();
365 let root = objects
366 .find_commit_iter(&commit, &mut buffer)
367 .ok()
368 .and_then(|mut iter| iter.tree_id().ok());
369
370 Some(Self {
371 objects,
372 root,
373 directories: HashMap::new(),
374 })
375 }
376
377 /// Whether `HEAD` holds `path` as content without our magic.
378 ///
379 /// False for anything it cannot answer, deliberately: a false negative here
380 /// costs a message, a false positive costs the user's trust in every message
381 /// this tool prints.
382 pub fn holds_in_the_clear(&mut self, path: &[u8]) -> bool {
383 let Some(root) = self.root else {
384 return false;
385 };
386
387 let (directory, filename) = match path.iter().rposition(|byte| *byte == b'/') {
388 Some(at) => (&path[..at], &path[at + 1..]),
389 None => (&path[..0], path),
390 };
391 let Some(tree) = self.directory(root, directory) else {
392 return false;
393 };
394
395 let mut buffer = Vec::new();
396 let Ok(entries) = self.objects.find_tree_iter(&tree, &mut buffer) else {
397 return false;
398 };
399 for entry in entries {
400 let Ok(entry) = entry else { return false };
401 if entry.filename != filename {
402 continue;
403 }
404 if !entry.mode.is_blob() {
405 return false;
406 }
407 return stored_in_the_clear(&self.objects, entry.oid).unwrap_or(false);
408 }
409 false
410 }
411
412 /// The tree `directory` names under `root`, walking one component at a time.
413 fn directory(&mut self, root: ObjectId, directory: &[u8]) -> Option<ObjectId> {
414 if directory.is_empty() {
415 return Some(root);
416 }
417 if let Some(cached) = self.directories.get(directory) {
418 return *cached;
419 }
420
421 let mut current = root;
422 for component in directory.split(|byte| *byte == b'/') {
423 let mut buffer = Vec::new();
424 let Ok(entries) = self.objects.find_tree_iter(¤t, &mut buffer) else {
425 self.directories.insert(directory.to_vec(), None);
426 return None;
427 };
428 let mut next = None;
429 for entry in entries.flatten() {
430 if entry.filename == component && entry.mode.is_tree() {
431 next = Some(entry.oid.to_owned());
432 break;
433 }
434 }
435 match next {
436 Some(id) => current = id,
437 None => {
438 self.directories.insert(directory.to_vec(), None);
439 return None;
440 }
441 }
442 }
443
444 self.directories.insert(directory.to_vec(), Some(current));
445 Some(current)
446 }
447}
448
449/// Turns the gathered sightings into a stable, sorted report.
450fn collect(found: HashMap<Vec<u8>, Vec<Sighting>>) -> Vec<Exposure> {
451 let mut exposed: Vec<Exposure> = found
452 .into_iter()
453 .map(|(path, mut sightings)| {
454 sightings.sort_by_key(|sighting| sighting.blob);
455 Exposure { path, sightings }
456 })
457 .collect();
458 exposed.sort_by(|left, right| left.path.cmp(&right.path));
459 exposed
460}
461
462/// Every commit reachable from a reference, `HEAD` included.
463///
464/// Failures are per reference: a repository with one broken tag still has a
465/// history worth scanning, and refusing the whole command over it would hide
466/// every finding in the rest.
467///
468/// **Every worktree's references, not only this one's.** A linked worktree has a
469/// `HEAD` and a private `refs/` of its own under `.git/worktrees/<name>/`, and
470/// git counts both as reachability: measured on git 2.55, a commit named only by
471/// a detached `worktrees/wt/HEAD` survived `git gc --prune=now`. Scanning only
472/// the store this command was run from reported `VERDICT: no findings` and exit
473/// `0` for a repository whose object database still held a declared path in the
474/// clear — naming the same commit with an ordinary branch flipped it to `5`.
475fn tips(
476 git_dir: &Path,
477 common_dir: &Path,
478 hash: gix_hash::Kind,
479 objects: &gix_odb::Handle,
480 scan: &mut Scan,
481) -> Vec<ObjectId> {
482 let options = || gix_ref::store::init::Options {
483 object_hash: hash,
484 ..gix_ref::store::init::Options::default()
485 };
486 // A linked worktree keeps its own `HEAD` beside the shared `refs/`, so the
487 // store has to be told about both. Opening it at the git directory alone
488 // would leave every branch invisible and report a repository with plenty of
489 // history as having none.
490 let store = if git_dir == common_dir {
491 gix_ref::file::Store::at(git_dir.to_path_buf(), options())
492 } else {
493 gix_ref::file::Store::for_linked_worktree(
494 git_dir.to_path_buf(),
495 common_dir.to_path_buf(),
496 options(),
497 )
498 };
499
500 let mut tips = Vec::new();
501 collect_tips(&store, objects, scan, &mut tips);
502
503 // The main checkout, when this scan runs in a linked one. `worktrees/`
504 // below only ever holds *linked* registrations, so nothing else visits the
505 // main checkout's `HEAD` — and its worktree-private references live
506 // directly in the common directory, which is the store this arm opens.
507 // Measured on git 2.55, 2026-08-05: with the main checkout detached at a
508 // commit holding a plain-text secret no branch names, `status` from a
509 // linked worktree said `VERDICT: no findings` and exited 0, while the same
510 // command from the main checkout exited 5. The shared references are
511 // collected twice on this path; the sort at the bottom deduplicates them,
512 // exactly as it does for the registrations underneath.
513 if git_dir != common_dir {
514 let main = gix_ref::file::Store::at(common_dir.to_path_buf(), options());
515 collect_tips(&main, objects, scan, &mut tips);
516 }
517
518 // The other checkouts. Their shared references are already in `tips`, so
519 // what this adds is each one's own `HEAD` and its worktree-private
520 // categories — `refs/bisect/*` above all, which is where a bisect in
521 // progress parks the commits it is testing.
522 //
523 // A registration directory that cannot be listed is a store that cannot be
524 // enumerated, not an empty one: every other checkout's `HEAD` would go
525 // unvisited, and this scan's silence would read as a clean bill of health.
526 // Measured on git 2.55, 2026-08-05: `chmod 000 .git/worktrees` over a
527 // repository whose only path to a plain-text secret was a linked worktree's
528 // detached `HEAD` turned exit 5 into `VERDICT: no findings`, exit 0. The
529 // same rule `packed-refs` already follows, one directory over. Only the
530 // directory being absent means there are no linked worktrees.
531 match std::fs::read_dir(common_dir.join("worktrees")) {
532 Ok(entries) => {
533 for entry in entries {
534 let registration = match entry {
535 Ok(entry) => entry.path(),
536 Err(err) => {
537 scan.refs_unavailable = true;
538 scan.warnings.push(format!(
539 "a worktree registration could not be read ({err}), so that \
540 checkout's references were not scanned"
541 ));
542 continue;
543 }
544 };
545 if registration == git_dir || !registration.join("HEAD").is_file() {
546 continue;
547 }
548 let other = gix_ref::file::Store::for_linked_worktree(
549 registration,
550 common_dir.to_path_buf(),
551 options(),
552 );
553 collect_tips(&other, objects, scan, &mut tips);
554 }
555 }
556 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
557 Err(err) => {
558 scan.refs_unavailable = true;
559 scan.warnings.push(format!(
560 "the worktree registrations could not be listed ({err}), so no other \
561 checkout's references were scanned"
562 ));
563 }
564 }
565
566 tips.sort();
567 tips.dedup();
568 tips
569}
570
571/// Adds every commit one reference store names to `tips`.
572///
573/// Split out so the same reading applies to this checkout's store and to every
574/// other worktree's, rather than the second being a second implementation.
575fn collect_tips(
576 store: &gix_ref::file::Store,
577 objects: &gix_odb::Handle,
578 scan: &mut Scan,
579 tips: &mut Vec<ObjectId>,
580) {
581 let mut push = |mut reference: gix_ref::Reference, scan: &mut Scan| {
582 let name = reference.name.as_bstr().to_string();
583 // A symbolic reference whose target does not exist yet is an unborn
584 // branch — the state of every repository between `git init` and its
585 // first commit, and of `git checkout --orphan`. It is not a failure and
586 // must not be reported as one: a fresh repository greeting the user with
587 // "HEAD could not be resolved" is a bug report waiting to be filed.
588 if let Some(target) = reference.target.try_name()
589 && matches!(store.try_find(target), Ok(None))
590 {
591 return;
592 }
593 match reference.peel_to_id(store, objects) {
594 // A tag on a blob or a tree is a real thing — git.git carries
595 // `junio-gpg-pub` — and it names no history at all. Queuing it would
596 // make the commit walk fail to read a "commit" that was never one,
597 // which counted as an unreadable object and turned a healthy
598 // repository into a permanently red gate advising `git fsck`, which
599 // would then report nothing wrong.
600 Ok(id) => match objects.try_header(&id) {
601 Ok(Some(header)) if header.kind == gix_object::Kind::Commit => tips.push(id),
602 // A tag on a tree or a blob names no history, so there is
603 // nothing here to scan — but silence would make "nothing found"
604 // and "nothing looked at" identical, which is the one thing this
605 // module refuses to do.
606 Ok(Some(header)) => scan.notes.push(format!(
607 "{name} points at a {} and was not walked",
608 header.kind
609 )),
610 Ok(None) | Err(_) => {
611 note_unresolved(scan, &name, &format!("{id} could not be read"));
612 }
613 },
614 Err(err) => {
615 note_unresolved(scan, &name, &format!("it could not be resolved ({err})"));
616 }
617 }
618 };
619
620 match store.iter() {
621 Ok(platform) => match platform.all() {
622 Ok(references) => {
623 for reference in references {
624 match reference {
625 Ok(reference) => push(reference, scan),
626 // Git prints `warning: ignoring broken ref` and carries
627 // on for a file under `refs/` that is not a reference —
628 // crash residue, a stray `notes.txt`. Such a file names
629 // no history, so nothing goes unscanned because of it,
630 // and failing the gate over one was a false alarm on a
631 // state git itself shrugs at. A reference that *is* one
632 // and will not resolve is caught when it is peeled.
633 //
634 // **Only that one variant, though.** The other three mean
635 // a reference exists and was *not* walked: a ref file
636 // that could not be read, a directory traversal that
637 // failed, a `packed-refs` line that would not parse.
638 // Measured before this split, with `chmod 000
639 // .git/refs/heads/leak` over a branch holding a
640 // plain-text `secrets/db.env`: `VERDICT: no findings.`
641 // and exit 0, under a note claiming the file "is not a
642 // reference" when gix had said it "could not be read in
643 // full". That is the packed-refs failure this module was
644 // already fixed for, one file over.
645 Err(gix_ref::file::iter::loose_then_packed::Error::ReferenceCreation {
646 source,
647 relative_path,
648 }) => scan.notes.push(format!(
649 "a file under refs/ is not a reference \
650 ({relative_path:?}: {source})"
651 )),
652 Err(err) => note_unresolved(
653 scan,
654 "a reference under refs/",
655 &format!("it could not be read ({err})"),
656 ),
657 }
658 }
659 }
660 Err(err) => {
661 scan.refs_unavailable = true;
662 scan.warnings
663 .push(format!("the references could not be listed ({err})"));
664 }
665 },
666 Err(err) => {
667 scan.refs_unavailable = true;
668 scan.warnings
669 .push(format!("packed-refs could not be read ({err})"));
670 }
671 }
672
673 // `HEAD` is a pseudo-reference and is not part of `all()`. On a detached
674 // checkout it is the only thing naming the current commit, so leaving it out
675 // would make exactly the state a bisect leaves you in unscannable.
676 match store.try_find("HEAD") {
677 Ok(Some(head)) => push(head, scan),
678 // An unborn branch: a fresh repository with no commit yet.
679 Ok(None) => {}
680 // One reference, not the store: everything under `refs/` was still
681 // enumerated, so claiming "no history was scanned at all" would
682 // contradict the commit count printed three lines later.
683 Err(err) => note_unresolved(scan, "HEAD", &format!("it could not be read ({err})")),
684 }
685}
686
687/// Walks one tree, descending into subtrees and judging declared blobs.
688///
689/// Iterative rather than recursive: a repository is free to contain a path
690/// thousands of directories deep, and a stack overflow in a diagnostic command
691/// would be a crash where a report belongs.
692#[expect(
693 clippy::too_many_arguments,
694 reason = "one walk with one set of caches; splitting the state would mean \
695 threading a struct that exists only to satisfy the count"
696)]
697fn walk_tree(
698 objects: &gix_odb::Handle,
699 config: &Config,
700 root: ObjectId,
701 commit: ObjectId,
702 seen_trees: &mut HashSet<(ObjectId, Vec<u8>)>,
703 verdicts: &mut HashMap<ObjectId, bool>,
704 found: &mut HashMap<Vec<u8>, Vec<Sighting>>,
705 scan: &mut Scan,
706) {
707 let mut pending = vec![(root, Vec::new())];
708
709 while let Some((tree, prefix)) = pending.pop() {
710 if !seen_trees.insert((tree, prefix.clone())) {
711 continue;
712 }
713
714 let mut buffer = Vec::new();
715 let entries = match objects.find_tree_iter(&tree, &mut buffer) {
716 Ok(entries) => entries,
717 Err(err) => {
718 note_unreadable(scan, &tree, &err.to_string());
719 continue;
720 }
721 };
722
723 for entry in entries {
724 let Ok(entry) = entry else {
725 note_unreadable(scan, &tree, "one of its entries did not parse");
726 break;
727 };
728
729 let mut path = prefix.clone();
730 if !path.is_empty() {
731 path.push(b'/');
732 }
733 path.extend_from_slice(entry.filename);
734
735 if entry.mode.is_tree() {
736 pending.push((entry.oid.to_owned(), path));
737 continue;
738 }
739 // Symlinks and submodule gitlinks are never filtered by git, so
740 // there is nothing about them a declaration could have enforced.
741 if !entry.mode.is_blob() {
742 continue;
743 }
744 if !config.decide(&path).encrypt {
745 continue;
746 }
747
748 let id = entry.oid.to_owned();
749 let clear = match verdicts.get(&id) {
750 Some(clear) => *clear,
751 None => {
752 let Some(clear) = is_clear(objects, &id, scan) else {
753 continue;
754 };
755 verdicts.insert(id, clear);
756 clear
757 }
758 };
759
760 if clear {
761 let sightings = found.entry(path).or_default();
762 if !sightings.iter().any(|sighting| sighting.blob == id) {
763 sightings.push(Sighting { blob: id, commit });
764 }
765 }
766 }
767 }
768}
769
770/// Whether a blob is stored without our magic, or `None` if it could not be read.
771fn is_clear(objects: &gix_odb::Handle, id: &ObjectId, scan: &mut Scan) -> Option<bool> {
772 let mut buffer = Vec::new();
773 match objects.try_find(id, &mut buffer) {
774 Ok(Some(data)) => Some(!format::looks_encrypted(data.data)),
775 Ok(None) => {
776 note_unreadable(scan, id, "it is not in this repository's object database");
777 None
778 }
779 Err(err) => {
780 note_unreadable(scan, id, &err.to_string());
781 None
782 }
783 }
784}
785
786/// Records a reference the walk could not start from.
787///
788/// Budgeted like the objects, and separately from them: a repository with a
789/// thousand broken references would otherwise flood `stderr`, and before the
790/// budgets were split the references consumed the objects' allowance.
791fn note_unresolved(scan: &mut Scan, name: &str, why: &str) {
792 scan.unresolved_refs += 1;
793 if scan.unresolved_refs <= MAX_UNREADABLE_WARNINGS {
794 scan.unresolved_names.push(name.to_string());
795 scan.warnings.push(format!("{name}: not scanned, {why}"));
796 }
797}
798
799/// Records an object the scan could not judge.
800///
801/// The budget counts the messages this function has produced, not the whole
802/// warning list: sharing it with the per-reference messages meant five bad refs
803/// left every unreadable object unnamed, counted but never identified.
804fn note_unreadable(scan: &mut Scan, id: &ObjectId, why: &str) {
805 scan.unreadable += 1;
806 if scan.unreadable <= MAX_UNREADABLE_WARNINGS {
807 scan.warnings.push(format!("{id}: not scanned, {why}"));
808 }
809}
810
811#[cfg(test)]
812mod tests {
813 use super::*;
814 use std::fs;
815 use std::process::Command;
816 use tempfile::TempDir;
817
818 /// Drives a real repository: only git's own objects prove any of this.
819 struct Fixture {
820 dir: TempDir,
821 }
822
823 impl Fixture {
824 fn new() -> Self {
825 let dir = TempDir::new().expect("temporary directory");
826 let fixture = Self { dir };
827 fixture.git(&["init", "-q", "-b", "main"]);
828 fixture.git(&["config", "user.name", "t"]);
829 fixture.git(&["config", "user.email", "t@t.invalid"]);
830 fixture
831 }
832
833 fn git(&self, args: &[&str]) -> std::process::Output {
834 let output = Command::new("git")
835 .args(args)
836 .current_dir(self.dir.path())
837 .output()
838 .expect("git must be on PATH");
839 assert!(
840 output.status.success(),
841 "git {args:?} failed: {}",
842 String::from_utf8_lossy(&output.stderr)
843 );
844 output
845 }
846
847 fn write(&self, relative: &str, content: &[u8]) {
848 let path = self.dir.path().join(relative);
849 fs::create_dir_all(path.parent().expect("a parent")).expect("directories");
850 fs::write(path, content).expect("writing");
851 }
852
853 fn commit(&self, message: &str) {
854 self.git(&["add", "-A"]);
855 self.git(&["commit", "-q", "-m", message]);
856 }
857
858 fn scan(&self, declarations: &str) -> Scan {
859 let config = Config::parse(declarations).expect("the declarations must parse");
860 let git_dir = self.dir.path().join(".git");
861 let objects = super::objects(&git_dir, gix_hash::Kind::Sha1)
862 .expect("the object database must open");
863 super::scan(
864 &objects,
865 &git_dir,
866 &git_dir,
867 gix_hash::Kind::Sha1,
868 &config,
869 false,
870 )
871 .expect("the scan must succeed")
872 }
873 }
874
875 fn paths(scan: &Scan) -> Vec<String> {
876 scan.exposed
877 .iter()
878 .map(|exposure| String::from_utf8_lossy(&exposure.path).into_owned())
879 .collect()
880 }
881
882 #[test]
883 fn a_secret_named_only_by_another_worktrees_head_is_found() {
884 // A linked worktree's `HEAD` is reachability to git — measured on 2.55,
885 // a commit named only by `worktrees/wt/HEAD` survives
886 // `git gc --prune=now`. Before this, the scan opened only the store of
887 // the checkout it was run from, so the same repository reported
888 // `VERDICT: no findings` and exit 0 with the plaintext still in the
889 // object database.
890 let fixture = Fixture::new();
891 fixture.write("README.md", b"start\n");
892 fixture.commit("start");
893 fixture.git(&["checkout", "-q", "-b", "side"]);
894 fixture.write("secrets/parked.env", b"hunter2\n");
895 fixture.commit("on the side");
896 let head = fixture.git(&["rev-parse", "HEAD"]);
897 let head = String::from_utf8(head.stdout).expect("a hash");
898 let head = head.trim().to_string();
899 fixture.git(&["checkout", "-q", "main"]);
900
901 let elsewhere = tempfile::TempDir::new().expect("temporary directory");
902 let checkout = elsewhere.path().join("wt");
903 fixture.git(&[
904 "worktree",
905 "add",
906 "-q",
907 "--detach",
908 checkout.to_str().expect("a path"),
909 &head,
910 ]);
911 // The branch goes, so nothing under `refs/` names the commit any more.
912 fixture.git(&["branch", "-D", "side"]);
913
914 let scan = fixture.scan("secrets/\n");
915
916 assert_eq!(paths(&scan), ["secrets/parked.env"]);
917 }
918
919 #[test]
920 fn a_secret_reachable_only_through_a_tag_is_found() {
921 // An annotated tag is an object of its own; without peeling it, the
922 // commit behind it would never enter the walk.
923 let fixture = Fixture::new();
924 fixture.write("README.md", b"start\n");
925 fixture.commit("start");
926 fixture.write("secrets/tagged.env", b"hunter2\n");
927 fixture.commit("tagged");
928 fixture.git(&["tag", "-a", "v1", "-m", "release"]);
929 fixture.git(&["reset", "-q", "--hard", "HEAD~1"]);
930
931 let scan = fixture.scan("secrets/\n");
932
933 assert_eq!(paths(&scan), ["secrets/tagged.env"]);
934 }
935}