Skip to main content

storage/
hunks.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//! One file's patch text between two checkpoints.
7//!
8//! **This is the separate request `storage::changes` says it is not.**
9//! That module answers which files moved and by how much, and its header
10//! states why patch text is not its business: patch text is file content
11//! on a socket, so it has to be asked for one file at a time and put
12//! through the same credential scan a checkpoint puts a staged blob
13//! through. This module is that request. The two are not a short and a
14//! long version of one answer โ€” one costs what the number of changed
15//! files costs, the other costs what one file costs, and merging them
16//! would charge every "what changed here" with a whole batch of patches.
17//!
18//! **The scan is the same scan, not a second copy of it.**
19//! `checkpoint::scan_staged` judges a staged blob with
20//! `kernel::secret::scan`, and so does this. A line that scan matches is
21//! never echoed: the answer carries its line number and the reason, for
22//! the reason `scan_staged` gives about its own hits โ€” printing the bytes
23//! to prove a leak is the leak.
24//!
25//! Specified by `crates/storage/spec/Hunks.lean` ยง8-19.
26
27use std::path::Path;
28
29use kernel::GitOid;
30
31use crate::changes::Head;
32use crate::error::StorageError;
33
34/// One line of patch text, as a reader may see it.
35///
36/// `number` is the position inside the patch, counted from one, so a
37/// withheld line and the lines around it line up in one list without the
38/// reader joining two lists by index.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct PatchLine {
41    pub number: u32,
42    pub text: String,
43}
44
45/// One line that was not echoed, and why.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Withheld {
48    pub number: u32,
49    /// What matched: a provider's own key shape by name, or the entropy
50    /// judgement when no shape claimed it. Never the bytes.
51    pub reason: String,
52}
53
54/// One file's patch, with what could not be shown named rather than
55/// silently dropped.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct FilePatch {
58    pub lines: Vec<PatchLine>,
59    pub withheld: Vec<Withheld>,
60}
61
62fn git_err(op: &'static str) -> impl FnOnce(git2::Error) -> StorageError {
63    move |err| StorageError::Checkpoint {
64        op,
65        detail: err.message().to_owned(),
66    }
67}
68
69/// The patch text of one file between `base` and `head`.
70///
71/// One path per call, and the path is required: there is no shape here
72/// that answers for a whole batch, because a batch of patches is the
73/// thing this module's neighbour refuses to put on a socket.
74///
75/// A file that did not move between the two commits answers with an
76/// empty patch and nothing withheld, which is a different answer from a
77/// commit this city never wrote โ€” that one is the caller's to report,
78/// because only the caller knows what it asked about.
79///
80/// # Errors
81/// Propagates opening the repository, finding either commit, and walking
82/// the difference.
83pub fn of_file(
84    city_root: &Path,
85    base: GitOid,
86    head: Head,
87    path: &str,
88) -> Result<FilePatch, StorageError> {
89    let repo = git2::Repository::open(city_root).map_err(git_err("open the city repository"))?;
90    let find = |oid: GitOid| -> Result<git2::Tree<'_>, StorageError> {
91        let parsed =
92            git2::Oid::from_str(&oid.to_string()).map_err(git_err("parse a checkpoint"))?;
93        repo.find_commit(parsed)
94            .map_err(git_err("find a checkpoint"))?
95            .tree()
96            .map_err(git_err("read a checkpoint tree"))
97    };
98    let old = find(base)?;
99    let mut options = git2::DiffOptions::new();
100    options.pathspec(path);
101    let diff = match head {
102        Head::Commit(oid) => {
103            let new = find(oid)?;
104            repo.diff_tree_to_tree(Some(&old), Some(&new), Some(&mut options))
105        }
106        Head::WorkingTree => repo.diff_tree_to_workdir_with_index(Some(&old), Some(&mut options)),
107    }
108    .map_err(git_err("compare two checkpoints"))?;
109    Ok(read(&diff))
110}
111
112/// Reads a prepared one-file diff into lines, withholding what the
113/// credential scan matched.
114///
115/// Separate from [`of_file`] because everything above it is git and
116/// everything here is this module's own judgement about what a reader
117/// may see.
118fn read(diff: &git2::Diff<'_>) -> FilePatch {
119    let mut lines: Vec<PatchLine> = Vec::new();
120    let mut withheld: Vec<Withheld> = Vec::new();
121    let mut number: u32 = 0;
122    let _walked = diff.foreach(
123        &mut |_, _| true,
124        None,
125        None,
126        Some(&mut |_, _, line| {
127            number = number.saturating_add(1);
128            let origin = line.origin();
129            let content = String::from_utf8_lossy(line.content());
130            let body = content.trim_end_matches(['\r', '\n']);
131            let hits = kernel::secret::scan(body.as_bytes());
132            match hits.first() {
133                Some(hit) => withheld.push(Withheld {
134                    number,
135                    reason: hit.provider.unwrap_or("high entropy").to_owned(),
136                }),
137                None => lines.push(PatchLine {
138                    number,
139                    text: format!("{origin}{body}"),
140                }),
141            }
142            true
143        }),
144    );
145    FilePatch { lines, withheld }
146}
147
148#[cfg(test)]
149#[allow(
150    clippy::unwrap_used,
151    clippy::expect_used,
152    clippy::panic,
153    clippy::indexing_slicing,
154    reason = "test code"
155)]
156mod tests {
157    use super::*;
158    use kernel::TimeMs;
159
160    use crate::checkpoint::Checkpoint;
161
162    fn write(root: &Path, rel: &str, body: &str) {
163        let path = root.join(rel);
164        if let Some(parent) = path.parent() {
165            std::fs::create_dir_all(parent).unwrap();
166        }
167        std::fs::write(path, body).unwrap();
168    }
169
170    fn resident() -> crate::checkpoint::Provenance {
171        crate::checkpoint::Provenance::new(
172            kernel::RunId::CITY,
173            kernel::Address::parse("lab/parser").unwrap(),
174            kernel::B3Hash::digest(b"a city"),
175            crate::checkpoint::ModelChoice {
176                id: "test-model".to_owned(),
177                effort: None,
178            },
179        )
180    }
181
182    fn oid_of(payload: &kernel::Payload) -> GitOid {
183        let raw = serde_json::to_value(payload).unwrap()["oid"]
184            .as_str()
185            .unwrap()
186            .to_owned();
187        GitOid::parse(&raw).expect("a checkpoint names a git object")
188    }
189
190    /// A person reviewing a change reads the patch of one file, and the
191    /// line that carried a key is reported by number and reason rather
192    /// than echoed.
193    #[test]
194    fn one_file_answers_with_its_patch_and_a_credential_line_is_named_not_echoed() {
195        let dir = tempfile::tempdir().unwrap();
196        let root = dir.path();
197        write(root, "lab/lex.rs", "one\ntwo\n");
198        let mut checkpoint = Checkpoint::open(root).unwrap();
199        let base = oid_of(
200            &checkpoint
201                .wave_pre(&["lab".to_owned()], TimeMs::new(1_000), &resident())
202                .unwrap(),
203        );
204
205        // Against the working tree, because a checkpoint refuses to
206        // commit a blob carrying a key at all: the tree on disk is the
207        // one place this line can exist, and it is exactly the state a
208        // person reviewing a wave in flight is looking at.
209        let key = format!("sk-ant-api03-{}", "a1B2c3D4e5".repeat(9));
210        write(root, "lab/lex.rs", &format!("one\ntwo\nthree\n{key}\n"));
211        write(root, "lab/other.rs", "not asked for\n");
212
213        let patch = of_file(root, base, Head::WorkingTree, "lab/lex.rs").unwrap();
214        let shown = patch
215            .lines
216            .iter()
217            .map(|line| line.text.as_str())
218            .collect::<Vec<&str>>()
219            .join("\n");
220        assert!(shown.contains("+three"), "the patch text is answered");
221        assert!(
222            !shown.contains("sk-ant-api03"),
223            "a line matching a credential shape is never echoed: {shown}"
224        );
225        assert!(
226            !shown.contains("not asked for"),
227            "one file per request, and this was not it"
228        );
229        assert_eq!(patch.withheld.len(), 1, "one line was held back");
230        assert!(
231            patch.withheld[0].number > 0,
232            "a withheld line says where it was"
233        );
234        assert!(
235            !patch.withheld[0].reason.is_empty(),
236            "and why it was held back"
237        );
238    }
239
240    /// A file nobody touched between the two checkpoints has an empty patch
241    /// rather than a failure: "it did not move" is an answer.
242    #[test]
243    fn a_file_that_did_not_move_answers_with_an_empty_patch() {
244        let dir = tempfile::tempdir().unwrap();
245        let root = dir.path();
246        write(root, "lab/keep.rs", "unchanged\n");
247        let mut checkpoint = Checkpoint::open(root).unwrap();
248        let base = oid_of(
249            &checkpoint
250                .wave_pre(&["lab".to_owned()], TimeMs::new(1_000), &resident())
251                .unwrap(),
252        );
253        write(root, "lab/moved.rs", "new\n");
254        let head = oid_of(
255            &checkpoint
256                .wave_pre(&["lab".to_owned()], TimeMs::new(2_000), &resident())
257                .unwrap(),
258        );
259
260        let patch = of_file(root, base, Head::Commit(head), "lab/keep.rs").unwrap();
261        assert!(patch.lines.is_empty());
262        assert!(patch.withheld.is_empty());
263    }
264}