Skip to main content

storage/checkpoint/
scan.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Checkpoint scans: staged secrets and scoped commits.
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use kernel::TimeMs;
11use kernel::secret::scan;
12
13use crate::error::StorageError;
14
15use super::commit::{Checkpoint, git_err};
16use super::provenance::Provenance;
17
18pub(crate) mod pathspec;
19mod stage_filter;
20use stage_filter::{StageFilter, workdir};
21
22/// One commit, decided before anything is written.
23///
24/// Four values that are meaningless apart — a subject without the
25/// provenance that signs it, an instant without the commit it stamps —
26/// so they arrive as one value rather than as four parameters a caller
27/// can put in the wrong order.
28pub(crate) struct CommitPlan<'a> {
29    pub(crate) t: TimeMs,
30    pub(crate) of: &'a Provenance,
31    pub(crate) subject: &'a str,
32    /// Whether the branch follows this commit. A wave checkpoint says no: it
33    /// is filed under its own reference so a person's `git log` does not
34    /// grow a line per tool wave. A base commit and a landing
35    /// say yes, because a worktree branches from a branch and offered
36    /// work has to be on one.
37    pub(crate) onto_head: bool,
38}
39
40impl Checkpoint {
41    /// Scans what this checkpoint would newly write into the tree.
42    /// Reports how many shapes matched and where, never what matched.
43    pub fn scan_staged(&mut self) -> Result<(), StorageError> {
44        let index = self.repo.index().map_err(git_err("read index"))?;
45        let mut hits: Vec<String> = Vec::new();
46        match self.last_tree()? {
47            // No previous tree to compare against: this checkpoint is
48            // writing all of it, so all of it is read.
49            None => {
50                for entry in index.iter() {
51                    self.scan_blob(entry.id, &String::from_utf8_lossy(&entry.path), &mut hits);
52                }
53            }
54            // git is asked what changed, the same way `wave_post` asks it
55            // what went missing, so "what counts as a change" has one
56            // answer in this module rather than two.
57            Some(tree) => {
58                let diff = self
59                    .repo
60                    .diff_tree_to_index(Some(&tree), Some(&index), None)
61                    .map_err(git_err("diff checkpoint against the staged tree"))?;
62                for delta in diff.deltas() {
63                    let staged = delta.new_file();
64                    let Some(path) = staged.path().and_then(|p| p.to_str()) else {
65                        continue;
66                    };
67                    self.scan_blob(staged.id(), &path.replace('\\', "/"), &mut hits);
68                }
69            }
70        }
71        if hits.is_empty() {
72            return Ok(());
73        }
74        Err(StorageError::SecretEgress {
75            locations: hits.join(" "),
76        })
77    }
78
79    /// One blob against the secret shapes, appending `path:start+len` per
80    /// match. An id the object database will not hand back as a blob is
81    /// skipped: a deletion names no new content, and a submodule is not
82    /// this repository's to read.
83    fn scan_blob(&self, id: git2::Oid, path: &str, hits: &mut Vec<String>) {
84        let Ok(blob) = self.repo.find_blob(id) else {
85            return;
86        };
87        for span in scan(blob.content()) {
88            hits.push(format!("{path}:{}+{}", span.start, span.len));
89        }
90    }
91
92    /// The tree the last checkpoint committed, or `None` when this city
93    /// has never been checkpointed. An unborn HEAD is a state, not a failure -
94    /// it is what an empty repository looks like.
95    ///
96    /// A wave checkpoint does not move HEAD, so the last
97    /// checkpoint is remembered here rather than read off the branch. A
98    /// process that has just opened this repository remembers nothing
99    /// and falls back to HEAD, which is older: the scan then re-reads
100    /// blobs it has already cleared, which costs time and gives up no
101    /// safety.
102    fn last_tree(&self) -> Result<Option<git2::Tree<'_>>, StorageError> {
103        let commit = match self.last {
104            Some(oid) => self
105                .repo
106                .find_commit(oid)
107                .map_err(git_err("read the last checkpoint"))?,
108            None => match Self::head_commit(&self.repo)? {
109                Some(commit) => commit,
110                None => return Ok(None),
111            },
112        };
113        commit
114            .tree()
115            .map(Some)
116            .map_err(git_err("read the last checkpoint's tree"))
117    }
118
119    /// The commit HEAD names, or `None` when the city has none yet.
120    ///
121    /// Only an unborn branch and a missing reference mean "none yet";
122    /// anything else that stops HEAD being read is an error, because
123    /// reading it as "none" would turn the next checkpoint into a parentless
124    /// root commit cut off from the history before it (`crates/storage/spec/Checkpoint/Provenance.lean` §8-17).
125    fn head_commit(repo: &git2::Repository) -> Result<Option<git2::Commit<'_>>, StorageError> {
126        match repo.head() {
127            Ok(head) => head
128                .peel_to_commit()
129                .map(Some)
130                .map_err(git_err("read HEAD")),
131            Err(err)
132                if matches!(
133                    err.code(),
134                    git2::ErrorCode::UnbornBranch | git2::ErrorCode::NotFound
135                ) =>
136            {
137                Ok(None)
138            }
139            Err(err) => Err(git_err("read HEAD")(err)),
140        }
141    }
142
143    /// Stages every file under `scope`, including deletions. Paths
144    /// outside the scope are never touched — the write domain is the
145    /// boundary, and a wider `add` would stage what the Run never held.
146    ///
147    /// One `add_all`, which stages removals as well: libgit2 walks the
148    /// index against the working tree once and skips each unchanged file
149    /// by its stat data, and a second `update_all` walk repaid nothing.
150    ///
151    /// **A session slice is never staged.** It is a disposable
152    /// projection the city's own accounting thread appends to while the
153    /// wave runs, and a checkpoint that staged it would ask git to read a
154    /// workdir file it believes it already knows; a file still growing
155    /// under an open handle makes that read refuse the whole wave
156    /// (`crates/storage/Spec.lean` §8-8, §8-24). Nor does one ever stage protected
157    /// metadata or an alias - [`StageFilter`] owns that rule.
158    ///
159    /// Returns the paths whose staged blob this call added, changed or
160    /// removed, in byte order: the difference between the index it found
161    /// and the index it wrote, which is what the checkpoint touched and not
162    /// what the city holds.
163    pub(crate) fn stage_scopes(&mut self, scopes: &[String]) -> Result<Vec<String>, StorageError> {
164        let files = self.stage_scopes_held(scopes)?;
165        write_index(&mut self.repo.index().map_err(git_err("read index"))?)?;
166        Ok(files)
167    }
168
169    /// Stages `scopes` into the repository's index in memory and leaves
170    /// the file on disk as it was, for a caller whose objects are not on
171    /// disk yet: an index written before them names blobs a failure
172    /// would drop, and later checkpoints skip those entries by their stat data.
173    pub(crate) fn stage_scopes_held(
174        &mut self,
175        scopes: &[String],
176    ) -> Result<Vec<String>, StorageError> {
177        let mut index = self.repo.index().map_err(git_err("read index"))?;
178        let found: BTreeMap<Vec<u8>, git2::Oid> =
179            index.iter().map(|entry| (entry.path, entry.id)).collect();
180        let root = workdir(&self.repo)?;
181        let (files, prefixes): (Vec<&String>, Vec<&String>) = scopes
182            .iter()
183            .partition(|scope| names_a_file(root, &index, scope));
184        let mut filter = StageFilter::new(root);
185        for file in files {
186            self.stage_file(&mut index, &mut filter, std::path::Path::new(file.as_str()))?;
187        }
188        if scopes.is_empty() || !prefixes.is_empty() {
189            let patterns = pathspec::of(&prefixes.into_iter().cloned().collect::<Vec<_>>());
190            let specs: Vec<&str> = patterns.iter().map(String::as_str).collect();
191            let mut admit = |path: &std::path::Path, _matched: &[u8]| -> i32 { filter.admit(path) };
192            index
193                .add_all(
194                    specs.iter(),
195                    git2::IndexAddOption::DEFAULT,
196                    Some(&mut admit),
197                )
198                .map_err(git_err("stage scope"))?;
199        }
200        filter.refused()?;
201        let mut changed: BTreeSet<Vec<u8>> = BTreeSet::new();
202        let mut present = 0usize;
203        for entry in index.iter() {
204            let unchanged = match found.get(&entry.path) {
205                Some(id) => {
206                    present = present.saturating_add(1);
207                    *id == entry.id
208                }
209                None => false,
210            };
211            if !unchanged {
212                changed.insert(entry.path);
213            }
214        }
215        // Only a removal leaves a found path unmatched, so the second
216        // pass is paid by the waves that deleted something.
217        if present < found.len() {
218            let staged: BTreeSet<Vec<u8>> = index.iter().map(|entry| entry.path).collect();
219            changed.extend(found.into_keys().filter(|path| !staged.contains(path)));
220        }
221        Ok(changed
222            .into_iter()
223            .map(|path| String::from_utf8_lossy(&path).into_owned())
224            .collect())
225    }
226
227    /// Stages one file the wave wrote, or its removal, by its literal
228    /// path: a pathspec walk would cost the whole domain for one file.
229    /// The same admission and the same ignore rule as the walk.
230    fn stage_file(
231        &self,
232        index: &mut git2::Index,
233        filter: &mut StageFilter,
234        file: &std::path::Path,
235    ) -> Result<(), StorageError> {
236        let ignored = self
237            .repo
238            .is_path_ignored(file)
239            .map_err(git_err("stage scope"))?;
240        if filter.admit(file) != 0 || (ignored && index.get_path(file, 0).is_none()) {
241            return Ok(());
242        }
243        if workdir(&self.repo)?.join(file).is_file() {
244            index.add_path(file)
245        } else {
246            index.remove_path(file)
247        }
248        .map_err(git_err("stage scope"))
249    }
250
251    /// Commits the index at the injected time. An unchanged tree still
252    /// commits: a rebuildable chain is worth more than a saved object.
253    ///
254    /// The signature is the session's, not a fixed machine identity: the
255    /// author is the resident's address at a mailbox naming the city,
256    /// and the five `Sprawling-*` trailers say which run, which model
257    /// and which effort produced it.
258    pub(crate) fn commit(&mut self, plan: &CommitPlan<'_>) -> Result<git2::Oid, StorageError> {
259        let mut index = self.repo.index().map_err(git_err("read index"))?;
260        let tree_oid = index.write_tree().map_err(git_err("write tree"))?;
261        let tree = self
262            .repo
263            .find_tree(tree_oid)
264            .map_err(git_err("find staged tree"))?;
265        let seconds = i64::try_from(plan.t.value().saturating_div(1000)).map_err(|_| {
266            StorageError::Checkpoint {
267                op: "stamp the commit",
268                detail: format!("{} ms is past what git can date", plan.t.value()),
269            }
270        })?;
271        let when = git2::Time::new(seconds, 0);
272        let email = plan.of.email();
273        let signature = git2::Signature::new(plan.of.actor().as_str(), &email, &when)
274            .map_err(git_err("build signature"))?;
275        let parents: Vec<git2::Commit> = Self::head_commit(&self.repo)?.into_iter().collect();
276        let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
277        let full_message = format!("{}\n\n{}", plan.subject, plan.of.trailers());
278        let update = if plan.onto_head { Some("HEAD") } else { None };
279        let oid = self
280            .repo
281            .commit(
282                update,
283                &signature,
284                &signature,
285                &full_message,
286                &tree,
287                &parent_refs,
288            )
289            .map_err(git_err("commit checkpoint"))?;
290        self.last = Some(oid);
291        Ok(oid)
292    }
293
294    /// Puts this tree on the branch, under the session that produced it.
295    ///
296    /// This is what a reviewing run does when it offers its work: the
297    /// wave checkpoints behind it are dangling commits nobody merges, and
298    /// what a verifier judges has to be a commit on the run's own
299    /// branch. Time stays a parameter here as everywhere else — the
300    /// signature carries the injected instant, so the same script lands
301    /// the same oid.
302    ///
303    /// Only `scopes` is staged, as a checkpoint stages it: a kept tree is
304    /// checked out again over its scope alone, so its files outside the
305    /// scope may trail the branch, and staging them would take back
306    /// what the trunk changed there (`crates/storage/spec/Worktree.lean` §8-9). Entries outside
307    /// the scope go into the commit as the index holds them. No scope
308    /// stages the whole tree except the reserved subtree, whose rule
309    /// [`StageFilter`] owns.
310    ///
311    /// # Errors
312    /// Propagates staging, the staged-secret scan, and the commit.
313    pub fn land(
314        &mut self,
315        scopes: &[String],
316        t: TimeMs,
317        of: &Provenance,
318        subject: &str,
319    ) -> Result<String, StorageError> {
320        self.stage_scopes(scopes)?;
321        self.scan_staged()?;
322        let oid = self.commit(&CommitPlan {
323            t,
324            of,
325            subject,
326            onto_head: true,
327        })?;
328        Ok(oid.to_string())
329    }
330}
331
332/// Writes the index, waiting out a lock another run of this city holds.
333///
334/// **`.git/index.lock` is taken for the length of one write, and two runs
335/// of one building stage their scopes at the same time by design** -
336/// `accounting::worker::plans::pursuing` drives every node of a ready set at once,
337/// in one repository. The run that loses that race waits and tries
338/// again, because the concurrent process is this city and the lock is
339/// held for microseconds; refusing would end the run as cancelled and
340/// hand its node back as though its own done check had failed.
341///
342/// **Bounded, because a lock held by a dead process is a different
343/// fact.** Twenty-five milliseconds apart, twenty times: a genuinely
344/// stuck lock still surfaces as the refusal it is, and that refusal is
345/// what a person can act on.
346///
347/// Retrying is safe because the index being written is the in-memory one
348/// this call built, unchanged by a failed write: the second attempt
349/// states the same thing as the first.
350pub(super) fn write_index(index: &mut git2::Index) -> Result<(), StorageError> {
351    const ATTEMPTS: u32 = 20;
352    const WAIT: std::time::Duration = std::time::Duration::from_millis(25);
353    let mut attempt: u32 = 0;
354    loop {
355        attempt = attempt.saturating_add(1);
356        match index.write() {
357            Ok(()) => return Ok(()),
358            Err(err) if concurrent(&err) && attempt < ATTEMPTS => {}
359            Err(err) => return Err(git_err("write index")(err)),
360        }
361        std::thread::sleep(WAIT);
362    }
363}
364
365/// Whether git refused because somebody else holds the index lock.
366///
367/// libgit2 reports that as an index error whose message names the lock
368/// file; anything else - a permission problem, a damaged index - is not a
369/// collision and is refused on the first attempt.
370fn concurrent(err: &git2::Error) -> bool {
371    err.class() == git2::ErrorClass::Index && err.message().contains("index.lock")
372}
373
374/// Whether a scope names one file rather than a prefix: a file in the
375/// working tree, or a path the index holds as a file and the tree no
376/// longer has.
377fn names_a_file(root: &std::path::Path, index: &git2::Index, scope: &str) -> bool {
378    match std::fs::symlink_metadata(root.join(scope)) {
379        Ok(meta) => meta.is_file(),
380        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
381            index.get_path(std::path::Path::new(scope), 0).is_some()
382        }
383        // Any other failure is left to the walk, which reports it.
384        Err(_) => false,
385    }
386}
387
388#[cfg(test)]
389#[allow(
390    clippy::unwrap_used,
391    clippy::expect_used,
392    clippy::panic,
393    clippy::indexing_slicing,
394    reason = "test code"
395)]
396mod tests;