Skip to main content

gunnar_server/
runner.rs

1//! **The build runner** (roadmap phase 3).
2//!
3//! A push lands. The runner materialises exactly that commit, builds it with no
4//! network, hashes what came out, and signs a [`BuildRecord`] naming the commit
5//! it was made from — which is the join that lets `gunnar verify` walk from an
6//! artifact to a line of source.
7//!
8//! ```text
9//!   RefUpdated ──▶ checkout(commit) ──▶ sandbox(build) ──▶ artifacts ──▶ signed record ──▶ log
10//! ```
11//!
12//! # The recipe is in the commit, on purpose
13//!
14//! `gunnar-build.toml` is read **out of the tree being built**, not off the
15//! runner's disk. So the recipe is part of what was pushed, part of what was
16//! signed, and part of what a verifier can go back and look at. A recipe kept
17//! beside the runner would be a build input nobody attested to — the one input
18//! that decides what every other input does.
19//!
20//! # `network = false` is a fact here, not a claim
21//!
22//! `crate::sandbox` puts the build in a network namespace of its own. The
23//! runner **refuses to build** when it cannot, rather than building anyway and
24//! recording `network: true`: a server that quietly downgrades is a server whose
25//! records mean different things on different machines. `--allow-network` is the
26//! explicit opt-out, and it is recorded in the signed record.
27//!
28//! # What it is not
29//!
30//! Not a scheduler, not a queue, not a fleet. One build at a time, in the
31//! process that watched the event. Scaling it horizontally is the phase-5 item
32//! and it is a different problem: this is the part that has to be *right*.
33
34use std::path::{Path, PathBuf};
35
36use anyhow::{Context, Result};
37use gunnar_store::git_storage_trait::{GitServe, ObjType};
38
39use crate::stores::ServerStores;
40
41/// The file a repository carries to say how it is built.
42pub const RECIPE_FILE: &str = "gunnar-build.toml";
43
44/// How a repository is built.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct Recipe {
47    /// What this build is called, in the record.
48    pub name: String,
49    /// The program to run, in the checkout.
50    pub command: String,
51    pub args: Vec<String>,
52    /// Paths, relative to the checkout, that the build produces.
53    ///
54    /// Empty means **discover**: every ordinary file directly under
55    /// `target/release` that is executable. That is the default recipe's shape,
56    /// and naming them explicitly is what a repository does when it wants
57    /// something else.
58    pub artifacts: Vec<String>,
59}
60
61impl Recipe {
62    /// **The default recipe**, for a cargo workspace that carries none.
63    ///
64    /// `--locked` above all: a build that may re-resolve is a build whose
65    /// inputs are not the ones in the commit, and the SBOM this pairs with is
66    /// read from the same lockfile. Without it the two describe different
67    /// builds and nothing says so.
68    pub fn cargo_default() -> Recipe {
69        Recipe {
70            name: "cargo build --release --locked".to_owned(),
71            command: "cargo".to_owned(),
72            args: vec![
73                "build".to_owned(),
74                "--release".to_owned(),
75                "--locked".to_owned(),
76            ],
77            artifacts: Vec::new(),
78        }
79    }
80
81    /// Parse `gunnar-build.toml`.
82    ///
83    /// A **small, total** reader rather than a TOML dependency, for the reason
84    /// `gunnar_core::sbom`'s lockfile reader is one: this crate is in the
85    /// appliance graph. `key = "value"` and `key = ["a", "b"]`, and anything it
86    /// does not understand is skipped rather than guessed at.
87    pub fn parse(text: &str) -> Result<Recipe> {
88        let mut recipe = Recipe {
89            name: String::new(),
90            command: String::new(),
91            args: Vec::new(),
92            artifacts: Vec::new(),
93        };
94        for line in text.lines() {
95            let line = line.trim();
96            if line.is_empty() || line.starts_with('#') || line.starts_with('[') {
97                continue;
98            }
99            let Some((key, value)) = line.split_once('=') else {
100                continue;
101            };
102            let value = value.trim();
103            let list = |value: &str| -> Vec<String> {
104                value
105                    .trim_start_matches('[')
106                    .trim_end_matches(']')
107                    .split(',')
108                    .map(|item| item.trim().trim_matches('"').to_owned())
109                    .filter(|item| !item.is_empty())
110                    .collect()
111            };
112            match key.trim() {
113                "name" => recipe.name = value.trim_matches('"').to_owned(),
114                "command" => recipe.command = value.trim_matches('"').to_owned(),
115                "args" => recipe.args = list(value),
116                "artifacts" => recipe.artifacts = list(value),
117                _ => {}
118            }
119        }
120        anyhow::ensure!(
121            !recipe.command.is_empty(),
122            "{RECIPE_FILE} names no `command`, so there is nothing to run. A recipe that builds \
123             nothing is not a smaller recipe — it is a build that reports success having done \
124             nothing."
125        );
126        if recipe.name.is_empty() {
127            recipe.name = recipe.command.clone();
128        }
129        Ok(recipe)
130    }
131}
132
133/// What one build did.
134#[derive(Debug, Clone)]
135pub struct Built {
136    pub record: gunnar_core::BuildRecord,
137    /// Where the checkout and its output are, for a caller that wants to look.
138    pub workdir: PathBuf,
139}
140
141/// Materialise `tree` into `into`, from the store and nothing else.
142///
143/// No `git checkout`, no worktree, no gix: the tree is read through `GitServe`,
144/// which is the one contract every engine has. That is also why the runner can
145/// build a repository the archive engine stores, which has no working copy on
146/// disk to check out from.
147fn checkout(
148    git: &dyn GitServe,
149    hash: gix_hash::Kind,
150    tree: gix_hash::ObjectId,
151    into: &Path,
152) -> Result<usize> {
153    std::fs::create_dir_all(into).with_context(|| format!("creating {}", into.display()))?;
154    let (kind, bytes) = git
155        .read(tree.as_bytes())?
156        .with_context(|| format!("tree {tree} is not in this repository"))?;
157    anyhow::ensure!(kind == ObjType::Tree, "{tree} is not a tree");
158
159    let mut files = 0usize;
160    for entry in gunnar_store::object::TreeIter::from_bytes(&bytes, hash).named() {
161        let entry = entry.with_context(|| format!("decoding tree {tree}"))?;
162        let name = String::from_utf8_lossy(entry.name).into_owned();
163        // The sandbox boundary starts here: a tree entry named `..` or with a
164        // separator in it would write outside the checkout, and a repository is
165        // something anybody with push access controls.
166        anyhow::ensure!(
167            name != "." && name != ".." && !name.contains('/'),
168            "tree {tree} carries an entry named {name:?}, which would write outside the checkout"
169        );
170        let path = into.join(&name);
171        match entry.kind {
172            gunnar_store::object::EntryKind::Tree => {
173                files += checkout(git, hash, entry.oid, &path)?;
174            }
175            gunnar_store::object::EntryKind::Commit => {
176                // A gitlink names a commit in another repository. There is
177                // nothing here to write, and writing an empty directory would
178                // make the build look like it had a submodule checked out.
179                continue;
180            }
181            kind => {
182                let Some((ObjType::Blob, blob)) = git.read(entry.oid.as_bytes())? else {
183                    anyhow::bail!("{} is not a blob in this repository", entry.oid);
184                };
185                if kind == gunnar_store::object::EntryKind::Link {
186                    // A symlink's blob is its target. Recreated as a symlink so
187                    // a build that follows one behaves as it did for the author.
188                    let target = String::from_utf8_lossy(&blob).into_owned();
189                    let _ = std::fs::remove_file(&path);
190                    std::os::unix::fs::symlink(&target, &path)
191                        .with_context(|| format!("linking {}", path.display()))?;
192                } else {
193                    std::fs::write(&path, &blob)
194                        .with_context(|| format!("writing {}", path.display()))?;
195                    if kind == gunnar_store::object::EntryKind::BlobExecutable {
196                        use std::os::unix::fs::PermissionsExt as _;
197                        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
198                            .with_context(|| format!("chmod {}", path.display()))?;
199                    }
200                }
201                files += 1;
202            }
203        }
204    }
205    Ok(files)
206}
207
208/// The recipe a checkout carries, or the default one it qualifies for.
209fn recipe_for(checkout: &Path) -> Result<Recipe> {
210    let path = checkout.join(RECIPE_FILE);
211    if path.exists() {
212        let text = std::fs::read_to_string(&path)
213            .with_context(|| format!("reading {}", path.display()))?;
214        return Recipe::parse(&text)
215            .with_context(|| format!("{} is not a usable recipe", path.display()));
216    }
217    anyhow::ensure!(
218        checkout.join("Cargo.toml").exists(),
219        "this repository carries no {RECIPE_FILE} and is not a cargo workspace, so there is no \
220         recipe and no default that fits. Add a {RECIPE_FILE} naming a `command`."
221    );
222    Ok(Recipe::cargo_default())
223}
224
225/// Every artifact the recipe produced.
226fn collect(checkout: &Path, recipe: &Recipe) -> Result<Vec<PathBuf>> {
227    if !recipe.artifacts.is_empty() {
228        let mut out = Vec::new();
229        for named in &recipe.artifacts {
230            let path = checkout.join(named);
231            anyhow::ensure!(
232                path.is_file(),
233                "the recipe names {named} and the build did not produce it. A build that \
234                 reports success without its own declared output is a build nobody checked."
235            );
236            out.push(path);
237        }
238        return Ok(out);
239    }
240    // Discovery, for the default recipe: executables directly under
241    // `target/release`. Not recursive — `target/release/deps` and `build/` are
242    // cargo's own scratch, and shipping them as artifacts would attest to
243    // hundreds of files nobody asked for.
244    use std::os::unix::fs::PermissionsExt as _;
245    let dir = checkout.join("target").join("release");
246    let mut out = Vec::new();
247    if let Ok(entries) = std::fs::read_dir(&dir) {
248        for entry in entries.flatten() {
249            let path = entry.path();
250            let Ok(meta) = entry.metadata() else { continue };
251            if meta.is_file() && meta.permissions().mode() & 0o111 != 0 {
252                out.push(path);
253            }
254        }
255    }
256    out.sort();
257    anyhow::ensure!(
258        !out.is_empty(),
259        "the build produced no executable in {}. Either it built nothing, or its output is \
260         somewhere the default recipe does not look — name the artifacts in {RECIPE_FILE}.",
261        dir.display()
262    );
263    Ok(out)
264}
265
266/// **Build one commit and attest to what came out.**
267///
268/// `allow_network` is the explicit opt-out from hermeticity, and it goes into
269/// the signed record. Without it a runner that cannot enter the sandbox
270/// **fails** rather than building: a record that says `network: false` must mean
271/// it on every machine, or it means nothing on any of them.
272pub fn build_commit(
273    stores: &ServerStores,
274    repo: &gunnar_core::RepoRef,
275    commit: gix_hash::ObjectId,
276    workspace: &Path,
277    builder: &str,
278    allow_network: bool,
279) -> Result<Built> {
280    let entry = stores
281        .catalog()
282        .get(repo)?
283        .with_context(|| format!("no such repository: {repo}"))?;
284    let store = stores.repo_store(&entry)?;
285    let git = store.serve();
286    let hash = store.hash_kind();
287
288    let (kind, bytes) = git
289        .read(commit.as_bytes())?
290        .with_context(|| format!("commit {commit} is not in {repo}"))?;
291    anyhow::ensure!(kind == ObjType::Commit, "{commit} is not a commit");
292    let tree = gunnar_store::object::commit(&bytes, hash)
293        .with_context(|| format!("decoding commit {commit}"))?
294        .tree;
295
296    let workdir = workspace
297        .join(repo.namespace())
298        .join(repo.name())
299        .join(commit.to_string());
300    // A fresh checkout every time. Reusing one would make the build depend on
301    // what the *last* build left behind, which is the first thing that makes a
302    // "reproducible" build not one.
303    let _ = std::fs::remove_dir_all(&workdir);
304    let files = checkout(git, hash, tree, &workdir)?;
305    anyhow::ensure!(
306        files > 0,
307        "commit {commit} has an empty tree; there is nothing to build"
308    );
309
310    let recipe = recipe_for(&workdir)?;
311
312    if !allow_network {
313        if let Err(err) = crate::sandbox::available() {
314            anyhow::bail!(
315                "this build was to be hermetic and the sandbox cannot be entered: {}\n\n\
316                 Refusing rather than building with the network up. A record that says \
317                 `network: false` has to mean it on every machine. Pass --allow-network to \
318                 build anyway; the record will say so.",
319                err.explain()
320            );
321        }
322    }
323
324    let started = gunnar_grpc::bus::now_unix_ms();
325    let mut command = hardened_command(&recipe.command, &workdir, allow_network);
326    command
327        .args(&recipe.args)
328        .env("CARGO_NET_OFFLINE", "true")
329        .env("GUNNAR_BUILD_COMMIT", commit.to_string())
330        .env("GUNNAR_BUILD_REPO", repo.to_string());
331    let output = command
332        .output()
333        .with_context(|| format!("running {}", recipe.command))?;
334    let finished = gunnar_grpc::bus::now_unix_ms();
335
336    if let Some(err) = output
337        .status
338        .code()
339        .and_then(crate::sandbox::error_from_exit)
340    {
341        anyhow::bail!("the build never started: {}", err.explain());
342    }
343    anyhow::ensure!(
344        output.status.success(),
345        "the build failed ({}):\n{}",
346        output.status,
347        String::from_utf8_lossy(&output.stderr)
348    );
349
350    let artifacts = collect(&workdir, &recipe)?;
351    let subjects = artifacts
352        .iter()
353        .map(|path| -> Result<gunnar_core::Subject> {
354            Ok(gunnar_core::Subject {
355                name: path
356                    .strip_prefix(&workdir)
357                    .unwrap_or(path)
358                    .display()
359                    .to_string(),
360                sha256: gunnar_core::provenance::digest_file(path)
361                    .with_context(|| format!("hashing {}", path.display()))?,
362            })
363        })
364        .collect::<Result<Vec<_>>>()?;
365
366    // The lockfile is a **material**: it is what decided every dependency, and
367    // it is in the commit, so naming it here is what lets the SBOM beside this
368    // record be checked against the same bytes.
369    let mut materials = Vec::new();
370    let lock = workdir.join("Cargo.lock");
371    if lock.is_file() {
372        materials.push(gunnar_core::Subject {
373            name: "Cargo.lock".to_owned(),
374            sha256: gunnar_core::provenance::digest_file(&lock)?,
375        });
376    }
377
378    let record = gunnar_core::BuildRecord {
379        builder: builder.to_owned(),
380        source_repo: repo.to_string(),
381        source_commit: commit.to_string(),
382        recipe: recipe.name.clone(),
383        toolchain: toolchain(&recipe.command, &workdir, allow_network),
384        network: allow_network,
385        materials,
386        subjects,
387        started_at_unix_ms: started,
388        finished_at_unix_ms: finished,
389    };
390
391    let key = stores.provenance_key()?;
392    let signed = gunnar_core::provenance::sign_build(&key, record.clone())
393        .context("signing the build record")?;
394    let mut line = serde_json::to_string(&signed)?;
395    line.push('\n');
396    let _framed = gunnar_core::txlog::sink::append_line(
397        stores.build_log().records_path(),
398        line.trim_end_matches('\n'),
399    )?;
400    use std::io::Write as _;
401    let mut file = std::fs::OpenOptions::new()
402        .create(true)
403        .append(true)
404        .open(stores.build_log().records_path())?;
405    file.write_all(line.as_bytes())?;
406
407    Ok(Built { record, workdir })
408}
409
410/// **The one constructor of every process a build runs.**
411///
412/// GUN-006. `build_commit` built its `Command` with `env_clear`,
413/// `current_dir(&workdir)` and `pre_exec(enter_sandbox)`, and then
414/// [`toolchain`] ran the SAME attacker-named program a second time with
415/// `Command::new(command).arg("--version")` — no cleared environment, no
416/// working directory, no sandbox. One hardened exec and one bare one, of a
417/// `recipe.command` read out of the pushed tree and validated against nothing
418/// but `!is_empty()`.
419///
420/// The escape was worth more than the build: the bare child ran as the server's
421/// uid with the network up, so it could read `$GUNNAR_SECRET_KEK` or
422/// `secrets.kek` from disk and decrypt the sealed store — the SSH host key and
423/// the provenance signing key — and then sign `BuildRecord`s of its own.
424///
425/// So the hardening is a function both call sites must go through, and
426/// `the_runner_has_exactly_one_process_constructor` holds a third from being
427/// added beside them. What is NOT here is the build's own environment
428/// (`CARGO_NET_OFFLINE`, `GUNNAR_BUILD_*`): those are facts about a build and
429/// `toolchain` is not one, and adding them here would make the version probe
430/// claim to be a build step.
431fn hardened_command(program: &str, workdir: &Path, allow_network: bool) -> std::process::Command {
432    // A32-14, a process by design: the build runner runs a repository's own declared build program, hardened (cleared environment, no network unless allowed); it is the feature, and no cargo or sh of Gunnar's own.
433    let mut command = std::process::Command::new(program);
434    command
435        .current_dir(workdir)
436        // A clean-ish environment: the build's inputs should be the commit, not
437        // the operator's shell. `PATH` and `HOME` stay because a toolchain
438        // needs to be findable and cargo needs somewhere to put its caches.
439        .env_clear()
440        .env("PATH", std::env::var_os("PATH").unwrap_or_default())
441        .env("HOME", std::env::var_os("HOME").unwrap_or_default());
442    if !allow_network {
443        use std::os::unix::process::CommandExt as _;
444        // The hook is bound OUTSIDE the `unsafe` block on purpose: an `unsafe`
445        // block extends lexically into a closure written inside it, so nesting
446        // the two made the inner one redundant (`unused_unsafe`) and its SAFETY
447        // note unenforced. Bound here, each `unsafe` covers exactly one call.
448        let enter_sandbox = || {
449            // SAFETY: `pre_exec` runs in the forked child, single-threaded,
450            // before `exec`.
451            if let Err(err) = unsafe { crate::sandbox::enter() } {
452                crate::sandbox::leave_child(crate::sandbox::exit_code(err));
453            }
454            Ok(())
455        };
456        // SAFETY: `sandbox::enter` allocates nothing and takes no lock, which
457        // is the whole requirement `pre_exec` imposes.
458        unsafe {
459            command.pre_exec(enter_sandbox);
460        }
461    }
462    command
463}
464
465/// What the toolchain says it is, or an honest blank.
466///
467/// Asked of the tool that ran, not read from a config: a `rust-toolchain.toml`
468/// says what was *requested* and this says what answered. Empty when the tool
469/// will not say, because a made-up version in a signed record is worse than no
470/// version at all.
471///
472/// Through [`hardened_command`], because this runs the pushed tree's own
473/// `recipe.command` — see GUN-006 — and its stdout goes verbatim into the
474/// SIGNED `toolchain` field of the build record.
475fn toolchain(command: &str, workdir: &Path, allow_network: bool) -> String {
476    hardened_command(command, workdir, allow_network)
477        .arg("--version")
478        .output()
479        .ok()
480        .filter(|out| out.status.success())
481        .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned())
482        .unwrap_or_default()
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    /// ★ **A watch that ends is said** (A28 M18 follow-up: serve.rs dropped the result with
490    /// `let _ =`). An error is an ALERT at ERROR with its cause; any other end is a WARN that
491    /// says how many pushes were built and that no more are.
492    #[test]
493    fn a_watch_that_ends_is_said_and_an_error_is_an_alert() {
494        let (level, words) = said_end(&Err(anyhow::anyhow!(
495            "building a runtime for the runner's subscription: no threads"
496        )));
497        assert_eq!(level, tracing::Level::ERROR);
498        assert!(words.starts_with("ALERT runner"), "{words}");
499        assert!(
500            words.contains("no threads") && words.contains("no longer built"),
501            "{words}"
502        );
503        let (level, words) = said_end(&Ok(3));
504        assert_eq!(level, tracing::Level::WARN);
505        assert!(
506            words.contains("3 build") && words.contains("no longer built"),
507            "{words}"
508        );
509    }
510
511    /// ★ The watch's own end goes through [`said_end`]: a watch bounded to no builds ends at
512    /// once, and what the server says of it is the WARN, not nothing.
513    #[test]
514    fn the_watch_returns_its_end_for_the_server_to_say() {
515        let dir = tempfile::tempdir().expect("a temp dir");
516        let stores = ServerStores::open(dir.path()).expect("stores");
517        let bus = gunnar_grpc::EventBus::new(4);
518        let config = WatchConfig {
519            workspace: dir.path().join("ws"),
520            builder: "runner".into(),
521            allow_network: true,
522            refs: Vec::new(),
523            max_builds: Some(0),
524        };
525        let end = watch(&stores, &bus, &config, |_| {});
526        assert_eq!(
527            said_end(&end).0,
528            tracing::Level::WARN,
529            "{:?}",
530            said_end(&end)
531        );
532    }
533
534    #[test]
535    fn a_recipe_parses_and_one_with_no_command_is_refused() {
536        let recipe = Recipe::parse(
537            r#"
538            name = "the appliance"
539            command = "make"
540            args = ["-j", "4"]
541            artifacts = ["out/gunnar"]
542            "#,
543        )
544        .expect("a usable recipe");
545        assert_eq!(recipe.name, "the appliance");
546        assert_eq!(recipe.command, "make");
547        assert_eq!(recipe.args, vec!["-j", "4"]);
548        assert_eq!(recipe.artifacts, vec!["out/gunnar"]);
549
550        // A recipe that builds nothing must be refused rather than treated as a
551        // smaller one: it would report success having done nothing.
552        let err = Recipe::parse("name = \"empty\"").expect_err("no command");
553        assert!(format!("{err}").contains("command"), "{err}");
554    }
555
556    #[test]
557    fn a_recipe_with_no_name_is_named_after_its_command() {
558        let recipe = Recipe::parse("command = \"cargo\"").expect("usable");
559        assert_eq!(recipe.name, "cargo");
560    }
561
562    /// **GUN-006: every process a build runs goes through one constructor.**
563    ///
564    /// The finding was not that `toolchain` was written carelessly — it was
565    /// that there were TWO places that exec a `recipe.command` read out of the
566    /// pushed tree, and only one of them was hardened. A fix that hardened the
567    /// second one would leave a third free to forget again, which is why the
568    /// hardening became a function and why this counts.
569    ///
570    /// Source-level on purpose. The property is "there is one constructor",
571    /// and no behaviour of a single call can assert that; the file is the
572    /// evidence. `include_str!` reads THIS file at compile time, so the guard
573    /// cannot drift from what it guards.
574    ///
575    /// Mutation-proven 2026-09-11: put `Command::new` back in `toolchain` and
576    /// the count is 2.
577    #[test]
578    fn the_runner_has_exactly_one_process_constructor() {
579        let source = include_str!("runner.rs");
580        let constructors = source
581            .lines()
582            // A doc comment quoting the old line is not a call site, and
583            // neither is a string literal — including the needle on this very
584            // line, which is why the quote character is spelled rather than
585            // written.
586            .filter(|line| !line.trim_start().starts_with("//"))
587            .filter(|line| !line.contains(char::from(34)))
588            .filter(|line| line.contains("std::process::Command::new("))
589            .count();
590        assert_eq!(
591            constructors, 1,
592            "every exec a build performs must go through `hardened_command`; \
593             found {constructors} fully-qualified process constructors in runner.rs"
594        );
595        // …and the spelling cannot be dodged by importing the type, which
596        // would make the count above read 1 while a second exec sat beside it.
597        // Spelled in two halves so the needle is not in the file it searches.
598        // A guard that trips on its own source proves nothing about the code
599        // it guards.
600        assert!(
601            !source.contains(concat!("use std::process::", "Command;")),
602            "runner.rs imports the process builder by name; the count above \
603             only sees the fully-qualified spelling"
604        );
605    }
606
607    /// The hardening is applied, and it is applied to a process that can be
608    /// watched: a cleared environment and the working directory the build was
609    /// checked out into.
610    ///
611    /// `/usr/bin/env` with no arguments prints the environment it was given,
612    /// and `/bin/pwd` prints the directory it was started in. Both are
613    /// coreutils and both are already assumed by the suites that drive `git`
614    /// and `ssh`; neither needs a shell.
615    #[test]
616    fn the_hardened_command_clears_the_environment_and_sets_the_directory() {
617        let dir = tempfile::tempdir().expect("a temp dir");
618        // A marker the caller's environment carries and the child must not.
619        // Set on the CHILD-to-be's parent — this process — through the same
620        // `Command` API, so no global state is mutated under cargo's test
621        // threads.
622        let seen = |program: &str| -> Option<String> {
623            let out = hardened_command(program, dir.path(), true)
624                .env("GUNNAR_TEST_LEAK", "this must not cross")
625                .output()
626                .ok()?;
627            out.status
628                .success()
629                .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
630        };
631        // `.env()` after `env_clear()` puts the marker back deliberately: it
632        // proves the child is readable at all, so an empty answer below cannot
633        // be mistaken for a cleared environment.
634        let Some(environment) = seen("/usr/bin/env") else {
635            return; // no coreutils on this box; nothing to measure
636        };
637        assert!(
638            environment.contains("GUNNAR_TEST_LEAK=this must not cross"),
639            "the fixture cannot see the child's environment at all: {environment:?}"
640        );
641        // …and nothing else of the caller's. `PATH` and `HOME` are the two
642        // `hardened_command` puts back by name.
643        for line in environment.lines().filter(|l| !l.is_empty()) {
644            let name = line.split('=').next().unwrap_or_default();
645            assert!(
646                matches!(name, "PATH" | "HOME" | "GUNNAR_TEST_LEAK"),
647                "the build child inherited {name} from the operator's shell"
648            );
649        }
650
651        if let Some(cwd) = seen("/bin/pwd") {
652            assert_eq!(
653                std::fs::canonicalize(cwd.trim()).ok(),
654                std::fs::canonicalize(dir.path()).ok(),
655                "the build child did not start in the checkout"
656            );
657        }
658    }
659
660    #[test]
661    fn the_default_recipe_locks_its_dependencies() {
662        // `--locked` is the whole point of the default: without it the build
663        // may re-resolve, and the SBOM read from the lockfile then describes a
664        // different build with nothing saying so.
665        assert!(
666            Recipe::cargo_default()
667                .args
668                .contains(&"--locked".to_owned()),
669            "the default cargo recipe does not pass --locked"
670        );
671    }
672}
673
674// ───────────────────────────────────────────────────────────────────────────
675// Watching
676// ───────────────────────────────────────────────────────────────────────────
677
678/// How the runner is configured when it watches rather than being told.
679#[derive(Debug, Clone)]
680pub struct WatchConfig {
681    /// Where checkouts and build output go.
682    pub workspace: PathBuf,
683    /// The ACL principal every record is attributed to.
684    pub builder: String,
685    /// Build with the network up. Off by default; recorded when on.
686    pub allow_network: bool,
687    /// Only build these refs. Empty means every branch.
688    ///
689    /// A list and not a pattern: a pattern language here would be a second
690    /// ref-matching implementation beside `gunnar-policy`'s, and two of those
691    /// can disagree about which branch is protected.
692    pub refs: Vec<String>,
693    /// Stop after this many builds. `None` watches for ever; a bound is what a
694    /// test uses, and what an operator uses to drain one queue and stop.
695    pub max_builds: Option<usize>,
696}
697
698/// What one watched build turned into.
699#[derive(Debug)]
700pub enum Watched {
701    /// A build ran and attested.
702    Built(Box<Built>),
703    /// A ref moved and the runner did not build it.
704    Skipped {
705        repo: String,
706        r#ref: String,
707        why: String,
708    },
709    /// A build was attempted and failed. **Not fatal to the watch**: one
710    /// repository's broken build must not stop every other repository's.
711    Failed {
712        repo: String,
713        commit: String,
714        why: String,
715    },
716}
717
718/// Should this ref update be built?
719fn wanted(config: &WatchConfig, ref_name: &str) -> bool {
720    // Branches only. A tag move is not a new state of the code — the commit it
721    // points at was already pushed and already built — and a runner that built
722    // both would attest twice to one commit under two recipes' worth of noise.
723    if !ref_name.starts_with("refs/heads/") {
724        return false;
725    }
726    config.refs.is_empty() || config.refs.iter().any(|want| want == ref_name)
727}
728
729/// **How the watch ended, as the server says it** (A28 M18 follow-up): the server runs
730/// [`watch`] for its whole life, so ANY end while the server runs means pushes are no longer
731/// built. Never silent: an error is an `ALERT runner` at ERROR, any other end a WARN.
732pub fn said_end(end: &Result<usize>) -> (tracing::Level, String) {
733    match end {
734        Err(err) => (tracing::Level::ERROR, format!("ALERT runner: the build runner stopped watching this server's pushes: {err:#}; pushes are no longer built until the server restarts")),
735        Ok(built) => (tracing::Level::WARN, format!("the build runner stopped watching this server's pushes after {built} build(s); pushes are no longer built until the server restarts")),
736    }
737}
738
739/// **Watch the event bus and build what lands.**
740///
741/// Blocking, and deliberately so: the runner is one build at a time in the
742/// process that watched the event. A queue and a pool are the phase-5 problem;
743/// this is the part that has to be right, and "right" is easier to see when
744/// there is one of it.
745///
746/// Every outcome — built, skipped, failed — is handed to `report` rather than
747/// logged and dropped, so a caller can assert on what happened. A build that
748/// fails does **not** stop the watch: one repository's broken recipe must not
749/// take every other repository's builds with it.
750pub fn watch(
751    stores: &ServerStores,
752    bus: &gunnar_grpc::EventBus,
753    config: &WatchConfig,
754    mut report: impl FnMut(Watched),
755) -> Result<usize> {
756    // `tokio_stream`, not `futures_util`: the stream is a `BroadcastStream` and
757    // tokio-stream is already resolved here. One stream crate, not two.
758    use tokio_stream::StreamExt as _;
759
760    let filter = gunnar_grpc::EventFilter {
761        namespaces: Vec::new(),
762        repos: Vec::new(),
763        kinds: vec![gunnar_proto::EventKind::RefUpdated],
764        include_attestation_object_ids: false,
765    };
766    let mut stream = bus.subscribe(filter);
767    let runtime = tokio::runtime::Builder::new_current_thread()
768        .enable_all()
769        .build()
770        .context("building a runtime for the runner's subscription")?;
771
772    let mut built = 0usize;
773    while config.max_builds.is_none_or(|max| built < max) {
774        let Some(event) = runtime.block_on(stream.next()) else {
775            break;
776        };
777        // The stream yields a `Result` because a gRPC subscriber's does. An
778        // error here is the subscription itself ending, not one event going
779        // wrong, so it stops the watch rather than being skipped past.
780        let Ok(event) = event else { break };
781        let Some(gunnar_proto::event::Payload::RefUpdated(update)) = &event.payload else {
782            continue;
783        };
784        let Some(repo) = update.repo.as_ref() else {
785            continue;
786        };
787        let key = format!("{}/{}", repo.namespace, repo.repo);
788        if !wanted(config, &update.ref_name) {
789            report(Watched::Skipped {
790                repo: key,
791                r#ref: update.ref_name.clone(),
792                why: "not a branch this runner builds".to_owned(),
793            });
794            continue;
795        }
796        let Ok(commit) = gix_hash::ObjectId::from_hex(update.new_oid.as_bytes()) else {
797            report(Watched::Skipped {
798                repo: key,
799                r#ref: update.ref_name.clone(),
800                why: format!("{} is not an object id", update.new_oid),
801            });
802            continue;
803        };
804        let Ok(repo_ref) = gunnar_core::RepoRef::new(&repo.namespace, &repo.repo) else {
805            continue;
806        };
807        match build_commit(
808            stores,
809            &repo_ref,
810            commit,
811            &config.workspace,
812            &config.builder,
813            config.allow_network,
814        ) {
815            Ok(outcome) => {
816                built += 1;
817                report(Watched::Built(Box::new(outcome)));
818            }
819            Err(err) => report(Watched::Failed {
820                repo: key,
821                commit: commit.to_string(),
822                why: format!("{err:#}"),
823            }),
824        }
825    }
826    Ok(built)
827}