speccoder_runtime/bug/
candidate.rs1use std::path::Path;
7
8use speccoder_core::bug::{Candidate, CandidateOrigin, CandidateRefusal};
9
10use crate::integrity::sha256_hex;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum Spec {
15 Patch(String),
17 Pr(u64),
19}
20
21#[must_use]
23pub fn parse(spec: &str) -> Option<Spec> {
24 let spec = spec.trim();
25 if let Some(n) = spec.strip_prefix("pr:") {
26 return n.trim().trim_start_matches('#').parse().ok().map(Spec::Pr);
27 }
28 (!spec.is_empty()).then(|| Spec::Patch(spec.to_owned()))
29}
30
31#[must_use]
33pub fn record(spec: &Spec, diff: &str, head: Option<String>) -> Candidate {
34 Candidate {
35 origin: match spec {
36 Spec::Patch(_) => CandidateOrigin::Patch,
37 Spec::Pr(_) => CandidateOrigin::Pr,
38 },
39 path: match spec {
40 Spec::Patch(p) => Some(p.clone()),
41 Spec::Pr(_) => None,
42 },
43 number: match spec {
44 Spec::Pr(n) => Some(*n),
45 Spec::Patch(_) => None,
46 },
47 digest: format!("sha256:{}", sha256_hex(diff.as_bytes())),
48 head_sha: head,
49 applied: false,
50 refused: None,
51 }
52}
53
54pub fn read_patch(
62 root: &Path,
63 cwd: &Path,
64 spec: &str,
65 via: speccoder_core::actions::Via,
66) -> Result<String, String> {
67 if via == speccoder_core::actions::Via::Terminal {
68 return std::fs::read_to_string(cwd.join(spec))
69 .or_else(|_| std::fs::read_to_string(spec))
70 .map_err(|e| format!("{spec}: {e}"));
71 }
72 let outside = || {
73 format!(
74 "{spec}: a candidate patch given through {} must be a file in the project",
75 via.as_str()
76 )
77 };
78 let base = std::fs::canonicalize(root).map_err(|e| format!("{}: {e}", root.display()))?;
79 let file = std::fs::canonicalize(root.join(spec)).map_err(|_| outside())?;
80 if !file.starts_with(&base) || !file.is_file() {
81 return Err(outside());
82 }
83 std::fs::read_to_string(&file).map_err(|e| format!("{spec}: {e}"))
84}
85
86pub fn touched(repo: &Path, diff: &str) -> Result<Vec<String>, CandidateRefusal> {
92 let refuse = |paths: Vec<String>| CandidateRefusal {
93 code: "BUG-CANDIDATE-APPLY".to_owned(),
94 paths,
95 };
96 let git = crate::git::Git::at(repo);
97 let mut out: Vec<String> = Vec::new();
98 let mut binary: Vec<String> = Vec::new();
99 for reverse in [false, true] {
100 let mut args = vec!["apply", "--numstat", "-z"];
101 if reverse {
102 args.push("-R");
103 }
104 args.push("-");
105 let o = git
106 .run_with_stdin(&args, diff.as_bytes())
107 .map_err(|e| refuse(vec![e.0]))?;
108 let fields = crate::git::nul_fields(&o);
109 let mut i = 0;
110 while i < fields.len() {
111 let mut parts = fields[i].splitn(3, '\t');
112 let (added, deleted, path) = (
113 parts.next().unwrap_or_default(),
114 parts.next().unwrap_or_default(),
115 parts.next().unwrap_or_default(),
116 );
117 i += 1;
118 let paths: Vec<String> = if path.is_empty() {
120 let p = fields[i..].iter().take(2).cloned().collect();
121 i += 2;
122 p
123 } else {
124 vec![path.to_owned()]
125 };
126 for p in paths.into_iter().filter(|p| !p.is_empty()) {
127 if added == "-" && deleted == "-" && !binary.contains(&p) {
128 binary.push(p.clone());
129 }
130 if !out.contains(&p) {
131 out.push(p);
132 }
133 }
134 }
135 }
136 if out.is_empty() {
137 return Err(refuse(vec!["no file in the patch".to_owned()]));
138 }
139 if !binary.is_empty() {
140 return Err(refuse(
141 binary
142 .into_iter()
143 .map(|p| format!("{p}: a binary hunk"))
144 .collect(),
145 ));
146 }
147 let summary = git
148 .run_with_stdin(&["apply", "--summary", "-"], diff.as_bytes())
149 .map_err(|e| refuse(vec![e.0]))?;
150 let links: Vec<String> = String::from_utf8_lossy(&summary)
151 .lines()
152 .filter(|l| l.contains("120000"))
153 .map(|l| format!("{}: a symbolic link", l.trim()))
154 .collect();
155 if !links.is_empty() {
156 return Err(refuse(links));
157 }
158 Ok(out)
159}
160
161#[must_use]
164pub fn judge_paths(
165 touched: &[String],
166 policy: &crate::tools::policy::Policy,
167 frozen: &[String],
168 held_out: &[String],
169) -> Vec<String> {
170 touched
171 .iter()
172 .filter(|p| {
173 policy.protected().write_class(p).is_some()
174 || frozen.contains(p)
175 || held_out.contains(p)
176 })
177 .cloned()
178 .collect()
179}
180
181pub fn apply(wt_root: &Path, tmp: &Path, diff: &str) -> Result<(), CandidateRefusal> {
184 let refuse = |paths: Vec<String>| CandidateRefusal {
185 code: "BUG-CANDIDATE-APPLY".to_owned(),
186 paths,
187 };
188 let _ = std::fs::create_dir_all(tmp);
189 let file = tmp.join(format!("candidate-{}.patch", std::process::id()));
190 std::fs::write(&file, diff).map_err(|e| refuse(vec![e.to_string()]))?;
191 let path = file.display().to_string();
192 let git = crate::git::Git::at(wt_root);
193 let result = git
194 .run(&["apply", "--check", &path])
195 .and_then(|_| git.run(&["apply", &path]));
196 let _ = std::fs::remove_file(&file);
197 result.map(|_| ()).map_err(|e| {
198 refuse(
199 e.0.lines()
200 .filter(|l| l.contains("error") || l.contains("patch failed"))
201 .map(str::to_owned)
202 .collect(),
203 )
204 })
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn a_candidate_names_its_paths() {
213 assert_eq!(parse("pr:12"), Some(Spec::Pr(12)));
214 assert_eq!(
215 parse("fix.patch"),
216 Some(Spec::Patch("fix.patch".to_owned()))
217 );
218 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
219 let diff = "diff --git a/src/a.rs b/src/a.rs\n--- a/src/a.rs\n+++ b/src/a.rs\n@@ -1 +1 @@\n-x\n+y\ndiff --git a/tests/new.rs b/tests/new.rs\nnew file mode 100644\n--- /dev/null\n+++ b/tests/new.rs\n@@ -0,0 +1 @@\n+t\n";
220 assert_eq!(
221 touched(dir.path(), diff).unwrap_or_else(|e| panic!("{e:?}")),
222 ["src/a.rs", "tests/new.rs"]
223 );
224 let r = record(&Spec::Pr(3), diff, Some("abc".to_owned()));
225 assert_eq!(r.number, Some(3));
226 assert!(r.digest.starts_with("sha256:"));
227 }
228
229 #[test]
230 fn hunks_without_plus_minus_lines_still_name_their_paths() {
231 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
232 let d = dir.path();
233 let paths = |diff: &str| touched(d, diff);
234 let rename = "diff --git a/tests/repro.rs b/src/gone.rs\nsimilarity index 100%\nrename from tests/repro.rs\nrename to src/gone.rs\n";
236 assert_eq!(
237 paths(rename).unwrap_or_else(|e| panic!("{e:?}")),
238 ["src/gone.rs", "tests/repro.rs"]
239 );
240 let mode =
242 "diff --git a/speccoder.toml b/speccoder.toml\nold mode 100644\nnew mode 100755\n";
243 assert_eq!(
244 paths(mode).unwrap_or_else(|e| panic!("{e:?}")),
245 ["speccoder.toml"]
246 );
247 let quoted = "diff --git \"a/tests/sp\\303\\244ce.rs\" \"b/tests/sp\\303\\244ce.rs\"\n--- \"a/tests/sp\\303\\244ce.rs\"\n+++ \"b/tests/sp\\303\\244ce.rs\"\n@@ -1 +1 @@\n-x\n+y\n";
249 assert_eq!(
250 paths(quoted).unwrap_or_else(|e| panic!("{e:?}")),
251 ["tests/sp\u{e4}ce.rs"]
252 );
253 let binary = "diff --git a/b.bin b/b.bin\nindex 88768ef..3e3315e 100644\nGIT binary patch\nliteral 5\nMcmZQzO3KUw00MIXJOBUy\n\nliteral 5\nMcmZQzOv=my00M6TI{*Lx\n\n";
255 assert!(paths(binary).is_err_and(|r| r.code == "BUG-CANDIDATE-APPLY"));
256 let link = "diff --git a/link b/link\nnew file mode 120000\nindex 0000000..8d14cbf\n--- /dev/null\n+++ b/link\n@@ -0,0 +1 @@\n+speccoder.toml\n\\ No newline at end of file\n";
257 assert!(paths(link).is_err_and(|r| r.code == "BUG-CANDIDATE-APPLY"));
258 assert!(paths("not a patch\n").is_err());
259 }
260}