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::{CommandRunner as _, 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 }];
79 }
80 };
81 let gates: Vec<_> = hooks
82 .iter()
83 .filter(|h| h.at() == Some(facts.point))
84 .collect();
85 if gates.is_empty() {
86 return Vec::new();
87 }
88 let dir = super::dir(state, worktree);
89 let (runner, _) = runner.clone().carve(&[], &hidden_state(state, worktree));
90 let mut out = Vec::new();
91 for def in gates {
92 if out
93 .iter()
94 .any(|g: &GateRecord| g.outcome == GateState::Blocked)
95 {
96 out.push(GateRecord {
97 name: def.name.clone(),
98 point: facts.point,
99 outcome: GateState::NotRun,
100 reason: None,
101 code: None,
102 duration_ms: 0,
103 });
104 super::record_run(&dir, &def.name, run_record(&facts, "not_run", 0, None));
105 continue;
106 }
107 out.push(judge(def, &runner, worktree, &dir, &facts));
108 }
109 out
110}
111
112fn judge(
115 def: &speccoder_core::hooks::HookDef,
116 runner: &Runner,
117 worktree: &Path,
118 dir: &Path,
119 facts: &Facts<'_>,
120) -> GateRecord {
121 let digested = super::digest(def, worktree);
122 let trust = super::trust::state(dir, &def.name, &digested.digest);
123 let began = Instant::now();
124 let (outcome, code) = if trust == TrustState::Trusted {
125 let ended = if runner.hides() {
128 run_one(def, runner, worktree, facts)
129 } else {
130 GateOutcome::Blocked(
131 "no sandbox is enforced here that hides speccoder's state, and a gate runs only in one"
132 .to_owned(),
133 )
134 };
135 (ended, "HOOK-BLOCKED")
136 } else {
137 let why = if trust == TrustState::Changed {
138 "changed since it was trusted: trust it again (speccoder hooks trust)"
139 } else {
140 "waits for your trust (speccoder hooks trust)"
141 };
142 (GateOutcome::Blocked(why.to_owned()), "HOOK-UNTRUSTED")
143 };
144 let duration_ms = u64::try_from(began.elapsed().as_millis()).unwrap_or(u64::MAX);
145 let record = match outcome {
146 GateOutcome::Passed => GateRecord {
147 name: def.name.clone(),
148 point: facts.point,
149 outcome: GateState::Passed,
150 reason: None,
151 code: None,
152 duration_ms,
153 },
154 GateOutcome::Blocked(reason) => GateRecord {
155 name: def.name.clone(),
156 point: facts.point,
157 outcome: GateState::Blocked,
158 reason: Some(reason),
159 code: Some(code.to_owned()),
160 duration_ms,
161 },
162 };
163 super::record_run(
164 dir,
165 &def.name,
166 run_record(
167 facts,
168 record.outcome.as_str(),
169 duration_ms,
170 record.reason.clone(),
171 ),
172 );
173 record
174}
175
176#[must_use]
179pub fn test(def: &speccoder_core::hooks::HookDef, root: &Path, state: &Path) -> GateRecord {
180 let point = def.at().unwrap_or(Point::Tick);
181 let facts = Facts {
182 point,
183 feature: "TEST",
184 task: (point == Point::Tick).then_some("TEST"),
185 branch: (point == Point::Pr).then_some("TEST"),
186 };
187 let runner = match crate::sandbox::runner_at(root) {
188 Ok((r, _)) => r.carve(&[], &hidden_state(state, root)).0,
189 Err(e) => {
190 return GateRecord {
191 name: def.name.clone(),
192 point,
193 outcome: GateState::Blocked,
194 reason: Some(speccoder_core::hooks::clean(&e)),
195 code: Some("HOOK-BLOCKED".to_owned()),
196 duration_ms: 0,
197 };
198 }
199 };
200 judge(def, &runner, root, &super::dir(state, root), &facts)
201}
202
203pub(super) fn hidden_state(state: &Path, worktree: &Path) -> Vec<std::path::PathBuf> {
207 let project = crate::worktree::project_state_dir(state, worktree);
208 let entries = |d: &Path| -> Vec<std::path::PathBuf> {
209 std::fs::read_dir(d)
210 .into_iter()
211 .flatten()
212 .flatten()
213 .map(|e| e.path())
214 .collect()
215 };
216 let mut hidden: Vec<_> = entries(state)
217 .into_iter()
218 .filter(|p| *p != project)
219 .collect();
220 hidden.extend(entries(&project).into_iter().filter(|p| {
221 !matches!(
222 p.file_name().and_then(|n| n.to_str()),
223 Some("worktrees" | "tmp")
224 )
225 }));
226 hidden
227}
228
229fn run_record(
230 facts: &Facts<'_>,
231 outcome: &str,
232 duration_ms: u64,
233 error: Option<String>,
234) -> HookRun {
235 HookRun {
236 at: crate::attest::now_rfc3339(),
237 what: facts.what(),
238 outcome: outcome.to_owned(),
239 duration_ms,
240 error,
241 }
242}
243
244fn run_one(
245 def: &speccoder_core::hooks::HookDef,
246 runner: &Runner,
247 worktree: &Path,
248 facts: &Facts<'_>,
249) -> GateOutcome {
250 let Some(argv) = def.argv() else {
251 return GateOutcome::Blocked("a gate runs `argv`".to_owned());
252 };
253 let network = matches!(
254 def.role,
255 speccoder_core::hooks::Role::Gate { network: true, .. }
256 );
257 let mut env = crate::verify::process::scrubbed_env(std::env::vars());
258 for name in &def.env {
261 if let Ok(v) = std::env::var(name) {
262 env.retain(|(k, _)| k != name);
263 env.push((name.clone(), v));
264 }
265 }
266 env.extend(facts.env());
267 let inv = Invocation {
268 argv: argv.to_vec(),
269 cwd: worktree.to_path_buf(),
270 env,
271 timeout: Duration::from_secs(def.timeout),
272 output_cap: OUTPUT_CAP,
273 };
274 let Some(before) = dirty(worktree) else {
275 return GateOutcome::Blocked("git could not say what the tick would commit".to_owned());
276 };
277 let ended = if network {
278 runner.run_with(&inv, true)
279 } else {
280 runner.run(&inv)
281 };
282 let Some(after) = dirty(worktree) else {
283 return GateOutcome::Blocked("git could not say what the tick would commit".to_owned());
284 };
285 let mut changed: Vec<String> = before
286 .keys()
287 .chain(after.keys())
288 .filter(|p| before.get(*p) != after.get(*p))
289 .cloned()
290 .collect();
291 changed.sort();
292 changed.dedup();
293 let stdout = String::from_utf8_lossy(&ended.stdout.kept);
294 let stderr = String::from_utf8_lossy(&ended.stderr.kept);
295 GateOutcome::of(&GateExit {
296 exit_code: ended.exit_code,
297 timed_out: ended.timed_out,
298 spawn_error: ended.spawn_error.as_deref(),
299 stdout: &stdout,
300 stderr: &stderr,
301 changed: &changed,
302 timeout: def.timeout,
303 })
304}
305
306fn dirty(worktree: &Path) -> Option<BTreeMap<String, String>> {
309 let git = crate::git::Git::at(worktree);
310 let top = PathBuf::from(git.line(&["rev-parse", "--show-toplevel"]).ok()?.trim());
311 let listing = git.run(&["status", "--porcelain=v1", "-z", "-uall"]).ok()?;
312 let listing = String::from_utf8_lossy(&listing);
313 let mut out = BTreeMap::new();
314 let mut entries = listing.split('\0').filter(|e| !e.is_empty());
315 while let Some(e) = entries.next() {
316 if e.len() < 4 {
317 continue;
318 }
319 let (status, path) = e.split_at(3);
320 if status.starts_with('R') || status.starts_with('C') {
322 let _ = entries.next();
323 }
324 let hash = std::fs::read(top.join(path)).map_or_else(
325 |_| "deleted".to_owned(),
326 |bytes| {
327 use sha2::{Digest as _, Sha256};
328 Sha256::digest(&bytes)
329 .iter()
330 .map(|b| format!("{b:02x}"))
331 .collect()
332 },
333 );
334 out.insert(path.to_owned(), hash);
335 }
336 Some(out)
337}