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