Skip to main content

speccoder_runtime/hooks/
gate.rs

1//! Gates at their point: each runs where the checks run, in the same
2//! sandbox, and only a clean exit 0 lets the point through. A gate that is
3//! not trusted, cannot run sandboxed, crashes, times out or changes a file
4//! the tick would commit blocks — never passes. Its words are for the
5//! person; nothing here reaches a model.
6
7use 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
17/// The words of a gate that waits for a person's trust.
18pub const AWAITS_TRUST: &str = "waits for your trust";
19/// The words of a gate whose bytes changed since they were trusted.
20pub const CHANGED: &str = "changed since it was trusted";
21
22/// Where a point stands: what a gate is told in `SPECCODER_*`.
23#[derive(Debug, Clone, Copy)]
24pub struct Facts<'a> {
25    /// The point.
26    pub point: Point,
27    /// The unit.
28    pub feature: &'a str,
29    /// The task, at a tick.
30    pub task: Option<&'a str>,
31    /// The branch to push, at a pull request.
32    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/// Runs the gates of `facts.point` declared in the configuration of the
60/// project at `worktree` (the project's folder in the worktree, where its
61/// checks run),
62/// in name order, through `runner` (the checks' runner); trust and runs are
63/// read from speccoder's state directory `state`. The first block stops the
64/// point; the gates after it are `not_run`. No gates: empty.
65#[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        // A configuration no gate can be read from: the point does not pass.
70        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
112/// One gate at its point: trusted and sandboxed, it runs; else it blocks.
113/// The run is recorded in its recent runs.
114fn 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        // Only where the sandbox enforces what it hides: speccoder's state
126        // and the held-out tests stay out of a gate's reach.
127        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/// `speccoder hooks test`: the gate `def` alone, on `root`, as it would
177/// stand at its point.
178#[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
203/// speccoder's state a gate may not read: other projects', and this one's
204/// journal, trust, approvals and held-out tests — everything but the
205/// worktrees (the gate runs in one) and the scratch directory.
206pub(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    // The names the hook asks for, even secret-looking ones: the person put
259    // them there.
260    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
306/// Every file git would commit that differs from `HEAD` (tracked changes
307/// and untracked files git does not ignore), with its content's hash.
308fn 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        // A rename lists its source next; both count.
321        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}