1use std::path::Path;
28
29use kernel::GitOid;
30
31use crate::changes::Head;
32use crate::error::StorageError;
33
34#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct PatchLine {
41 pub number: u32,
42 pub text: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Withheld {
48 pub number: u32,
49 pub reason: String,
52}
53
54#[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
69pub 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
112fn 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 #[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 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 #[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}