1use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9use std::time::{Duration, Instant};
10
11use speccoder_core::hooks::{GateExit, GateOutcome, GateRecord, GateState, Point};
12
13use super::{HookRun, TrustState};
14use crate::sandbox::Runner;
15use crate::verify::process::{Invocation, OUTPUT_CAP};
16
17pub const AWAITS_TRUST: &str = "waits for your trust";
19pub const CHANGED: &str = "changed since it was trusted";
21
22#[derive(Debug, Clone, Copy)]
24pub struct Facts<'a> {
25 pub point: Point,
27 pub feature: &'a str,
29 pub task: Option<&'a str>,
31 pub branch: Option<&'a str>,
33}
34
35impl Facts<'_> {
36 fn what(&self) -> String {
37 match (self.point, self.task) {
38 (Point::Tick, Some(t)) => format!("tick {t}"),
39 (Point::Tick, None) => "tick".to_owned(),
40 (Point::Pr, _) => "pr".to_owned(),
41 }
42 }
43
44 fn env(&self) -> Vec<(String, String)> {
45 let mut v = vec![
46 ("SPECCODER_POINT".to_owned(), self.point.as_str().to_owned()),
47 ("SPECCODER_FEATURE".to_owned(), self.feature.to_owned()),
48 ];
49 if let Some(t) = self.task {
50 v.push(("SPECCODER_TASK".to_owned(), t.to_owned()));
51 }
52 if let Some(b) = self.branch {
53 v.push(("SPECCODER_BRANCH".to_owned(), b.to_owned()));
54 }
55 v
56 }
57}
58
59#[must_use]
66pub fn run(worktree: &Path, runner: &Runner, state: &Path, facts: Facts<'_>) -> Vec<GateRecord> {
67 let hooks = match super::load(worktree) {
68 Ok(h) => h,
69 Err(e) => {
71 return vec![GateRecord {
72 name: "hooks".to_owned(),
73 point: facts.point,
74 outcome: GateState::Blocked,
75 reason: Some(speccoder_core::hooks::clean(&e)),
76 code: Some("HOOK-MISCONFIGURED".to_owned()),
77 duration_ms: 0,
78 findings: Vec::new(),
79 }];
80 }
81 };
82 let gates: Vec<_> = hooks
83 .iter()
84 .filter(|h| h.at() == Some(facts.point))
85 .collect();
86 if gates.is_empty() {
87 return Vec::new();
88 }
89 let dir = super::dir(state, worktree);
90 let (runner, _) = runner.clone().carve(&[], &hidden_state(state, worktree));
91 let mut out = Vec::new();
92 for def in gates {
93 if out
94 .iter()
95 .any(|g: &GateRecord| g.outcome == GateState::Blocked)
96 {
97 out.push(GateRecord {
98 name: def.name.clone(),
99 point: facts.point,
100 outcome: GateState::NotRun,
101 reason: None,
102 code: None,
103 duration_ms: 0,
104 findings: Vec::new(),
105 });
106 super::record_run(&dir, &def.name, run_record(&facts, "not_run", 0, None));
107 continue;
108 }
109 out.push(judge(def, &runner, worktree, &dir, &facts));
110 }
111 out
112}
113
114fn judge(
117 def: &speccoder_core::hooks::HookDef,
118 runner: &Runner,
119 worktree: &Path,
120 dir: &Path,
121 facts: &Facts<'_>,
122) -> GateRecord {
123 let digested = super::digest(def, worktree);
124 let trust = digested.trust(dir);
125 let began = Instant::now();
126 let mut findings = Left::default();
127 let (outcome, code) = if trust == TrustState::Trusted {
128 let ended = if runner.hides() {
131 let (outcome, left) = run_one(def, runner, worktree, facts);
132 findings = left;
133 outcome
134 } else {
135 GateOutcome::Blocked(
136 "no sandbox is enforced here that hides speccoder's state, and a gate runs only in one"
137 .to_owned(),
138 )
139 };
140 (ended, "HOOK-BLOCKED")
141 } else {
142 let why = match (&def.extension, trust == TrustState::Changed) {
143 (Some((ext, _)), true) => format!(
144 "{CHANGED}: the extension {ext} changed — trust it again (speccoder extensions trust {ext})"
145 ),
146 (Some((ext, _)), false) => {
147 format!("{AWAITS_TRUST} of the extension {ext} (speccoder extensions trust {ext})")
148 }
149 (None, true) => format!("{CHANGED}: trust it again (speccoder hooks trust)"),
150 (None, false) => format!("{AWAITS_TRUST} (speccoder hooks trust)"),
151 };
152 (GateOutcome::Blocked(why), "HOOK-UNTRUSTED")
153 };
154 let duration_ms = u64::try_from(began.elapsed().as_millis()).unwrap_or(u64::MAX);
155 let record = match outcome {
156 GateOutcome::Passed => GateRecord {
157 name: def.name.clone(),
158 point: facts.point,
159 outcome: GateState::Passed,
160 reason: None,
161 code: None,
162 duration_ms,
163 findings: Vec::new(),
164 },
165 GateOutcome::Blocked(reason) => GateRecord {
166 name: def.name.clone(),
167 point: facts.point,
168 outcome: GateState::Blocked,
169 reason: Some(summary_of(&findings.findings).unwrap_or(reason)),
171 code: Some(code.to_owned()),
172 duration_ms,
173 findings: findings.findings.clone(),
174 },
175 };
176 let mut run = run_record(
177 facts,
178 record.outcome.as_str(),
179 duration_ms,
180 record.reason.clone(),
181 );
182 run.findings = findings.findings;
183 run.findings_error = findings.error;
184 super::record_run(dir, &def.name, run);
185 record
186}
187
188#[derive(Debug, Default)]
191struct Left {
192 findings: Vec<speccoder_core::extensions::SarifFinding>,
193 error: Option<String>,
194}
195
196#[must_use]
199pub fn test(def: &speccoder_core::hooks::HookDef, root: &Path, state: &Path) -> GateRecord {
200 let point = def.at().unwrap_or(Point::Tick);
201 let current = crate::format::open(root, None)
203 .ok()
204 .and_then(|o| o.snapshot.selection.feature);
205 let facts = Facts {
206 point,
207 feature: current.as_deref().unwrap_or("TEST"),
208 task: (point == Point::Tick).then_some("TEST"),
209 branch: (point == Point::Pr).then_some("TEST"),
210 };
211 let runner = match crate::sandbox::runner_at(root) {
212 Ok((r, _)) => r.carve(&[], &hidden_state(state, root)).0,
213 Err(e) => {
214 return GateRecord {
215 name: def.name.clone(),
216 point,
217 outcome: GateState::Blocked,
218 reason: Some(speccoder_core::hooks::clean(&e)),
219 code: Some("HOOK-BLOCKED".to_owned()),
220 duration_ms: 0,
221 findings: Vec::new(),
222 };
223 }
224 };
225 judge(def, &runner, root, &super::dir(state, root), &facts)
226}
227
228pub(crate) fn hidden_state(state: &Path, worktree: &Path) -> Vec<std::path::PathBuf> {
232 let project = crate::worktree::project_state_dir(state, worktree);
233 let entries = |d: &Path| -> Vec<std::path::PathBuf> {
234 std::fs::read_dir(d)
235 .into_iter()
236 .flatten()
237 .flatten()
238 .map(|e| e.path())
239 .collect()
240 };
241 let mut hidden: Vec<_> = entries(state)
242 .into_iter()
243 .filter(|p| *p != project)
244 .collect();
245 hidden.extend(entries(&project).into_iter().filter(|p| {
246 !matches!(
247 p.file_name().and_then(|n| n.to_str()),
248 Some("worktrees" | "tmp")
249 )
250 }));
251 hidden
252}
253
254fn run_record(
255 facts: &Facts<'_>,
256 outcome: &str,
257 duration_ms: u64,
258 error: Option<String>,
259) -> HookRun {
260 HookRun {
261 at: crate::attest::now_rfc3339(),
262 what: facts.what(),
263 outcome: outcome.to_owned(),
264 duration_ms,
265 error,
266 findings: Vec::new(),
267 findings_error: None,
268 }
269}
270
271fn run_one(
272 def: &speccoder_core::hooks::HookDef,
273 runner: &Runner,
274 worktree: &Path,
275 facts: &Facts<'_>,
276) -> (GateOutcome, Left) {
277 let Some(argv) = def.argv() else {
278 return (
279 GateOutcome::Blocked("a gate runs `argv`".to_owned()),
280 Left::default(),
281 );
282 };
283 let findings_file = runner.scratch().map(|d| {
285 d.join(format!(
286 "findings-{}-{}.sarif",
287 std::process::id(),
288 std::time::SystemTime::now()
289 .duration_since(std::time::UNIX_EPOCH)
290 .map_or(0, |t| t.as_nanos())
291 ))
292 });
293 let findings_text = findings_file
294 .as_ref()
295 .map(|f| f.to_string_lossy().into_owned())
296 .unwrap_or_default();
297 let feature_dir = crate::extensions::feature_dir(worktree, facts.feature)
298 .map(|d| d.to_string_lossy().into_owned())
299 .unwrap_or_default();
300 let folder = def.extension.as_ref().map(|(_, f)| PathBuf::from(f));
301 let argv = crate::extensions::resolve(
302 argv,
303 folder.as_deref(),
304 &[
305 ("SPECCODER_FEATURE_DIR", feature_dir.clone()),
306 ("SPECCODER_FINDINGS", findings_text.clone()),
307 ],
308 );
309 let network = matches!(
310 def.role,
311 speccoder_core::hooks::Role::Gate { network: true, .. }
312 );
313 let mut env = crate::verify::process::scrubbed_env(std::env::vars());
314 for name in &def.env {
317 if let Ok(v) = std::env::var(name) {
318 env.retain(|(k, _)| k != name);
319 env.push((name.clone(), v));
320 }
321 }
322 env.extend(facts.env());
323 env.push(("SPECCODER_FEATURE_DIR".to_owned(), feature_dir));
324 env.push(("PYTHONDONTWRITEBYTECODE".to_owned(), "1".to_owned()));
326 if !findings_text.is_empty() {
327 env.push(("SPECCODER_FINDINGS".to_owned(), findings_text));
328 }
329 let inv = Invocation {
330 argv,
331 cwd: worktree.to_path_buf(),
332 env,
333 timeout: Duration::from_secs(def.timeout),
334 output_cap: OUTPUT_CAP,
335 };
336 let Some(before) = dirty(worktree) else {
337 return (
338 GateOutcome::Blocked("git could not say what the tick would commit".to_owned()),
339 Left::default(),
340 );
341 };
342 let ended = if network {
344 runner.run_with(&inv, true)
345 } else {
346 runner.run_offline(&inv)
347 };
348 let left = findings_file.map_or_else(Left::default, |f| read_findings(&f, worktree));
349 let Some(after) = dirty(worktree) else {
350 return (
351 GateOutcome::Blocked("git could not say what the tick would commit".to_owned()),
352 left,
353 );
354 };
355 let mut changed: Vec<String> = before
356 .keys()
357 .chain(after.keys())
358 .filter(|p| before.get(*p) != after.get(*p))
359 .cloned()
360 .collect();
361 changed.sort();
362 changed.dedup();
363 let stdout = String::from_utf8_lossy(&ended.stdout.kept);
364 let stderr = String::from_utf8_lossy(&ended.stderr.kept);
365 let outcome = GateOutcome::of(&GateExit {
366 exit_code: ended.exit_code,
367 timed_out: ended.timed_out,
368 spawn_error: ended.spawn_error.as_deref(),
369 stdout: &stdout,
370 stderr: &stderr,
371 changed: &changed,
372 timeout: def.timeout,
373 });
374 (outcome, left)
375}
376
377fn summary_of(findings: &[speccoder_core::extensions::SarifFinding]) -> Option<String> {
380 let first = findings
381 .iter()
382 .find(|f| f.level == "error")
383 .or_else(|| findings.first())?;
384 let errors = findings.iter().filter(|f| f.level == "error").count();
385 let count = if findings.len() == 1 {
386 "1 finding".to_owned()
387 } else {
388 format!("{} findings", findings.len())
389 };
390 let errors = match errors {
391 0 => String::new(),
392 1 => ", 1 error".to_owned(),
393 n => format!(", {n} errors"),
394 };
395 Some(speccoder_core::hooks::clean(&format!(
396 "{count}{errors} — {}",
397 first.message
398 )))
399}
400
401fn read_findings(file: &Path, worktree: &Path) -> Left {
405 use std::io::Read as _;
406 let Ok(meta) = std::fs::symlink_metadata(file) else {
407 return Left::default();
408 };
409 let max = speccoder_core::extensions::MAX_SARIF;
410 let text = if !meta.file_type().is_file() {
411 Err("they are not a regular file".to_owned())
412 } else {
413 let mut buf = Vec::new();
414 std::fs::File::open(file)
415 .and_then(|f| f.take(max as u64 + 1).read_to_end(&mut buf))
416 .map_err(|e| e.to_string())
417 .and_then(|n| {
418 if n > max {
419 Err(format!("the findings are more than {} KB", max / 1024))
420 } else {
421 String::from_utf8(buf).map_err(|_| "they are not UTF-8".to_owned())
422 }
423 })
424 };
425 let _ = std::fs::remove_file(file);
426 let project = crate::extensions::project(worktree);
427 match text.and_then(|t| {
428 speccoder_core::extensions::sarif(&t, |p| crate::extensions::relative_to(&project, p))
429 }) {
430 Ok(findings) => Left {
431 findings,
432 error: None,
433 },
434 Err(e) => Left {
435 findings: Vec::new(),
436 error: Some(format!("its findings could not be shown: {e}")),
437 },
438 }
439}
440
441fn dirty(worktree: &Path) -> Option<BTreeMap<String, String>> {
444 let git = crate::git::Git::at(worktree);
445 let top = PathBuf::from(git.line(&["rev-parse", "--show-toplevel"]).ok()?.trim());
446 let listing = git.run(&["status", "--porcelain=v1", "-z", "-uall"]).ok()?;
447 let listing = String::from_utf8_lossy(&listing);
448 let mut out = BTreeMap::new();
449 let mut entries = listing.split('\0').filter(|e| !e.is_empty());
450 while let Some(e) = entries.next() {
451 if e.len() < 4 {
452 continue;
453 }
454 let (status, path) = e.split_at(3);
455 if status.starts_with('R') || status.starts_with('C') {
457 let _ = entries.next();
458 }
459 let hash = std::fs::read(top.join(path)).map_or_else(
460 |_| "deleted".to_owned(),
461 |bytes| {
462 use sha2::{Digest as _, Sha256};
463 Sha256::digest(&bytes)
464 .iter()
465 .map(|b| format!("{b:02x}"))
466 .collect()
467 },
468 );
469 out.insert(path.to_owned(), hash);
470 }
471 Some(out)
472}