Skip to main content

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(&current, &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}