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;