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;