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(stores.build_log().records_path(), line.trim_end_matches('\n'))?;
397 use std::io::Write as _;
398 let mut file = std::fs::OpenOptions::new()
399 .create(true)
400 .append(true)
401 .open(stores.build_log().records_path())?;
402 file.write_all(line.as_bytes())?;
403
404 Ok(Built { record, workdir })
405}
406
407/// **The one constructor of every process a build runs.**
408///
409/// GUN-006. `build_commit` built its `Command` with `env_clear`,
410/// `current_dir(&workdir)` and `pre_exec(enter_sandbox)`, and then
411/// [`toolchain`] ran the SAME attacker-named program a second time with
412/// `Command::new(command).arg("--version")` — no cleared environment, no
413/// working directory, no sandbox. One hardened exec and one bare one, of a
414/// `recipe.command` read out of the pushed tree and validated against nothing
415/// but `!is_empty()`.
416///
417/// The escape was worth more than the build: the bare child ran as the server's
418/// uid with the network up, so it could read `$GUNNAR_SECRET_KEK` or
419/// `secrets.kek` from disk and decrypt the sealed store — the SSH host key and
420/// the provenance signing key — and then sign `BuildRecord`s of its own.
421///
422/// So the hardening is a function both call sites must go through, and
423/// `the_runner_has_exactly_one_process_constructor` holds a third from being
424/// added beside them. What is NOT here is the build's own environment
425/// (`CARGO_NET_OFFLINE`, `GUNNAR_BUILD_*`): those are facts about a build and
426/// `toolchain` is not one, and adding them here would make the version probe
427/// claim to be a build step.
428fn hardened_command(program: &str, workdir: &Path, allow_network: bool) -> std::process::Command {
429 // 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.
430 let mut command = std::process::Command::new(program);
431 command
432 .current_dir(workdir)
433 // A clean-ish environment: the build's inputs should be the commit, not
434 // the operator's shell. `PATH` and `HOME` stay because a toolchain
435 // needs to be findable and cargo needs somewhere to put its caches.
436 .env_clear()
437 .env("PATH", std::env::var_os("PATH").unwrap_or_default())
438 .env("HOME", std::env::var_os("HOME").unwrap_or_default());
439 if !allow_network {
440 use std::os::unix::process::CommandExt as _;
441 // The hook is bound OUTSIDE the `unsafe` block on purpose: an `unsafe`
442 // block extends lexically into a closure written inside it, so nesting
443 // the two made the inner one redundant (`unused_unsafe`) and its SAFETY
444 // note unenforced. Bound here, each `unsafe` covers exactly one call.
445 let enter_sandbox = || {
446 // SAFETY: `pre_exec` runs in the forked child, single-threaded,
447 // before `exec`.
448 if let Err(err) = unsafe { crate::sandbox::enter() } {
449 crate::sandbox::leave_child(crate::sandbox::exit_code(err));
450 }
451 Ok(())
452 };
453 // SAFETY: `sandbox::enter` allocates nothing and takes no lock, which
454 // is the whole requirement `pre_exec` imposes.
455 unsafe {
456 command.pre_exec(enter_sandbox);
457 }
458 }
459 command
460}
461
462/// What the toolchain says it is, or an honest blank.
463///
464/// Asked of the tool that ran, not read from a config: a `rust-toolchain.toml`
465/// says what was *requested* and this says what answered. Empty when the tool
466/// will not say, because a made-up version in a signed record is worse than no
467/// version at all.
468///
469/// Through [`hardened_command`], because this runs the pushed tree's own
470/// `recipe.command` — see GUN-006 — and its stdout goes verbatim into the
471/// SIGNED `toolchain` field of the build record.
472fn toolchain(command: &str, workdir: &Path, allow_network: bool) -> String {
473 hardened_command(command, workdir, allow_network)
474 .arg("--version")
475 .output()
476 .ok()
477 .filter(|out| out.status.success())
478 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned())
479 .unwrap_or_default()
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485
486 /// ★ **A watch that ends is said** (A28 M18 follow-up: serve.rs dropped the result with
487 /// `let _ =`). An error is an ALERT at ERROR with its cause; any other end is a WARN that
488 /// says how many pushes were built and that no more are.
489 #[test]
490 fn a_watch_that_ends_is_said_and_an_error_is_an_alert() {
491 let (level, words) = said_end(&Err(anyhow::anyhow!("building a runtime for the runner's subscription: no threads")));
492 assert_eq!(level, tracing::Level::ERROR);
493 assert!(words.starts_with("ALERT runner"), "{words}");
494 assert!(words.contains("no threads") && words.contains("no longer built"), "{words}");
495 let (level, words) = said_end(&Ok(3));
496 assert_eq!(level, tracing::Level::WARN);
497 assert!(words.contains("3 build") && words.contains("no longer built"), "{words}");
498 }
499
500 /// ★ The watch's own end goes through [`said_end`]: a watch bounded to no builds ends at
501 /// once, and what the server says of it is the WARN, not nothing.
502 #[test]
503 fn the_watch_returns_its_end_for_the_server_to_say() {
504 let dir = tempfile::tempdir().expect("a temp dir");
505 let stores = ServerStores::open(dir.path()).expect("stores");
506 let bus = gunnar_grpc::EventBus::new(4);
507 let config = WatchConfig { workspace: dir.path().join("ws"), builder: "runner".into(), allow_network: true, refs: Vec::new(), max_builds: Some(0) };
508 let end = watch(&stores, &bus, &config, |_| {});
509 assert_eq!(said_end(&end).0, tracing::Level::WARN, "{:?}", said_end(&end));
510 }
511
512 #[test]
513 fn a_recipe_parses_and_one_with_no_command_is_refused() {
514 let recipe = Recipe::parse(
515 r#"
516 name = "the appliance"
517 command = "make"
518 args = ["-j", "4"]
519 artifacts = ["out/gunnar"]
520 "#,
521 )
522 .expect("a usable recipe");
523 assert_eq!(recipe.name, "the appliance");
524 assert_eq!(recipe.command, "make");
525 assert_eq!(recipe.args, vec!["-j", "4"]);
526 assert_eq!(recipe.artifacts, vec!["out/gunnar"]);
527
528 // A recipe that builds nothing must be refused rather than treated as a
529 // smaller one: it would report success having done nothing.
530 let err = Recipe::parse("name = \"empty\"").expect_err("no command");
531 assert!(format!("{err}").contains("command"), "{err}");
532 }
533
534 #[test]
535 fn a_recipe_with_no_name_is_named_after_its_command() {
536 let recipe = Recipe::parse("command = \"cargo\"").expect("usable");
537 assert_eq!(recipe.name, "cargo");
538 }
539
540 /// **GUN-006: every process a build runs goes through one constructor.**
541 ///
542 /// The finding was not that `toolchain` was written carelessly — it was
543 /// that there were TWO places that exec a `recipe.command` read out of the
544 /// pushed tree, and only one of them was hardened. A fix that hardened the
545 /// second one would leave a third free to forget again, which is why the
546 /// hardening became a function and why this counts.
547 ///
548 /// Source-level on purpose. The property is "there is one constructor",
549 /// and no behaviour of a single call can assert that; the file is the
550 /// evidence. `include_str!` reads THIS file at compile time, so the guard
551 /// cannot drift from what it guards.
552 ///
553 /// Mutation-proven 2026-09-11: put `Command::new` back in `toolchain` and
554 /// the count is 2.
555 #[test]
556 fn the_runner_has_exactly_one_process_constructor() {
557 let source = include_str!("runner.rs");
558 let constructors = source
559 .lines()
560 // A doc comment quoting the old line is not a call site, and
561 // neither is a string literal — including the needle on this very
562 // line, which is why the quote character is spelled rather than
563 // written.
564 .filter(|line| !line.trim_start().starts_with("//"))
565 .filter(|line| !line.contains(char::from(34)))
566 .filter(|line| line.contains("std::process::Command::new("))
567 .count();
568 assert_eq!(
569 constructors, 1,
570 "every exec a build performs must go through `hardened_command`; \
571 found {constructors} fully-qualified process constructors in runner.rs"
572 );
573 // …and the spelling cannot be dodged by importing the type, which
574 // would make the count above read 1 while a second exec sat beside it.
575 // Spelled in two halves so the needle is not in the file it searches.
576 // A guard that trips on its own source proves nothing about the code
577 // it guards.
578 assert!(
579 !source.contains(concat!("use std::process::", "Command;")),
580 "runner.rs imports the process builder by name; the count above \
581 only sees the fully-qualified spelling"
582 );
583 }
584
585 /// The hardening is applied, and it is applied to a process that can be
586 /// watched: a cleared environment and the working directory the build was
587 /// checked out into.
588 ///
589 /// `/usr/bin/env` with no arguments prints the environment it was given,
590 /// and `/bin/pwd` prints the directory it was started in. Both are
591 /// coreutils and both are already assumed by the suites that drive `git`
592 /// and `ssh`; neither needs a shell.
593 #[test]
594 fn the_hardened_command_clears_the_environment_and_sets_the_directory() {
595 let dir = tempfile::tempdir().expect("a temp dir");
596 // A marker the caller's environment carries and the child must not.
597 // Set on the CHILD-to-be's parent — this process — through the same
598 // `Command` API, so no global state is mutated under cargo's test
599 // threads.
600 let seen = |program: &str| -> Option<String> {
601 let out = hardened_command(program, dir.path(), true)
602 .env("GUNNAR_TEST_LEAK", "this must not cross")
603 .output()
604 .ok()?;
605 out.status
606 .success()
607 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
608 };
609 // `.env()` after `env_clear()` puts the marker back deliberately: it
610 // proves the child is readable at all, so an empty answer below cannot
611 // be mistaken for a cleared environment.
612 let Some(environment) = seen("/usr/bin/env") else {
613 return; // no coreutils on this box; nothing to measure
614 };
615 assert!(
616 environment.contains("GUNNAR_TEST_LEAK=this must not cross"),
617 "the fixture cannot see the child's environment at all: {environment:?}"
618 );
619 // …and nothing else of the caller's. `PATH` and `HOME` are the two
620 // `hardened_command` puts back by name.
621 for line in environment.lines().filter(|l| !l.is_empty()) {
622 let name = line.split('=').next().unwrap_or_default();
623 assert!(
624 matches!(name, "PATH" | "HOME" | "GUNNAR_TEST_LEAK"),
625 "the build child inherited {name} from the operator's shell"
626 );
627 }
628
629 if let Some(cwd) = seen("/bin/pwd") {
630 assert_eq!(
631 std::fs::canonicalize(cwd.trim()).ok(),
632 std::fs::canonicalize(dir.path()).ok(),
633 "the build child did not start in the checkout"
634 );
635 }
636 }
637
638 #[test]
639 fn the_default_recipe_locks_its_dependencies() {
640 // `--locked` is the whole point of the default: without it the build
641 // may re-resolve, and the SBOM read from the lockfile then describes a
642 // different build with nothing saying so.
643 assert!(
644 Recipe::cargo_default()
645 .args
646 .contains(&"--locked".to_owned()),
647 "the default cargo recipe does not pass --locked"
648 );
649 }
650}
651
652// ───────────────────────────────────────────────────────────────────────────
653// Watching
654// ───────────────────────────────────────────────────────────────────────────
655
656/// How the runner is configured when it watches rather than being told.
657#[derive(Debug, Clone)]
658pub struct WatchConfig {
659 /// Where checkouts and build output go.
660 pub workspace: PathBuf,
661 /// The ACL principal every record is attributed to.
662 pub builder: String,
663 /// Build with the network up. Off by default; recorded when on.
664 pub allow_network: bool,
665 /// Only build these refs. Empty means every branch.
666 ///
667 /// A list and not a pattern: a pattern language here would be a second
668 /// ref-matching implementation beside `gunnar-policy`'s, and two of those
669 /// can disagree about which branch is protected.
670 pub refs: Vec<String>,
671 /// Stop after this many builds. `None` watches for ever; a bound is what a
672 /// test uses, and what an operator uses to drain one queue and stop.
673 pub max_builds: Option<usize>,
674}
675
676/// What one watched build turned into.
677#[derive(Debug)]
678pub enum Watched {
679 /// A build ran and attested.
680 Built(Box<Built>),
681 /// A ref moved and the runner did not build it.
682 Skipped {
683 repo: String,
684 r#ref: String,
685 why: String,
686 },
687 /// A build was attempted and failed. **Not fatal to the watch**: one
688 /// repository's broken build must not stop every other repository's.
689 Failed {
690 repo: String,
691 commit: String,
692 why: String,
693 },
694}
695
696/// Should this ref update be built?
697fn wanted(config: &WatchConfig, ref_name: &str) -> bool {
698 // Branches only. A tag move is not a new state of the code — the commit it
699 // points at was already pushed and already built — and a runner that built
700 // both would attest twice to one commit under two recipes' worth of noise.
701 if !ref_name.starts_with("refs/heads/") {
702 return false;
703 }
704 config.refs.is_empty() || config.refs.iter().any(|want| want == ref_name)
705}
706
707/// **How the watch ended, as the server says it** (A28 M18 follow-up): the server runs
708/// [`watch`] for its whole life, so ANY end while the server runs means pushes are no longer
709/// built. Never silent: an error is an `ALERT runner` at ERROR, any other end a WARN.
710pub fn said_end(end: &Result<usize>) -> (tracing::Level, String) {
711 match end {
712 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")),
713 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")),
714 }
715}
716
717/// **Watch the event bus and build what lands.**
718///
719/// Blocking, and deliberately so: the runner is one build at a time in the
720/// process that watched the event. A queue and a pool are the phase-5 problem;
721/// this is the part that has to be right, and "right" is easier to see when
722/// there is one of it.
723///
724/// Every outcome — built, skipped, failed — is handed to `report` rather than
725/// logged and dropped, so a caller can assert on what happened. A build that
726/// fails does **not** stop the watch: one repository's broken recipe must not
727/// take every other repository's builds with it.
728pub fn watch(
729 stores: &ServerStores,
730 bus: &gunnar_grpc::EventBus,
731 config: &WatchConfig,
732 mut report: impl FnMut(Watched),
733) -> Result<usize> {
734 // `tokio_stream`, not `futures_util`: the stream is a `BroadcastStream` and
735 // tokio-stream is already resolved here. One stream crate, not two.
736 use tokio_stream::StreamExt as _;
737
738 let filter = gunnar_grpc::EventFilter {
739 namespaces: Vec::new(),
740 repos: Vec::new(),
741 kinds: vec![gunnar_proto::EventKind::RefUpdated],
742 include_attestation_object_ids: false,
743 };
744 let mut stream = bus.subscribe(filter);
745 let runtime = tokio::runtime::Builder::new_current_thread()
746 .enable_all()
747 .build()
748 .context("building a runtime for the runner's subscription")?;
749
750 let mut built = 0usize;
751 while config.max_builds.is_none_or(|max| built < max) {
752 let Some(event) = runtime.block_on(stream.next()) else {
753 break;
754 };
755 // The stream yields a `Result` because a gRPC subscriber's does. An
756 // error here is the subscription itself ending, not one event going
757 // wrong, so it stops the watch rather than being skipped past.
758 let Ok(event) = event else { break };
759 let Some(gunnar_proto::event::Payload::RefUpdated(update)) = &event.payload else {
760 continue;
761 };
762 let Some(repo) = update.repo.as_ref() else {
763 continue;
764 };
765 let key = format!("{}/{}", repo.namespace, repo.repo);
766 if !wanted(config, &update.ref_name) {
767 report(Watched::Skipped {
768 repo: key,
769 r#ref: update.ref_name.clone(),
770 why: "not a branch this runner builds".to_owned(),
771 });
772 continue;
773 }
774 let Ok(commit) = gix_hash::ObjectId::from_hex(update.new_oid.as_bytes()) else {
775 report(Watched::Skipped {
776 repo: key,
777 r#ref: update.ref_name.clone(),
778 why: format!("{} is not an object id", update.new_oid),
779 });
780 continue;
781 };
782 let Ok(repo_ref) = gunnar_core::RepoRef::new(&repo.namespace, &repo.repo) else {
783 continue;
784 };
785 match build_commit(
786 stores,
787 &repo_ref,
788 commit,
789 &config.workspace,
790 &config.builder,
791 config.allow_network,
792 ) {
793 Ok(outcome) => {
794 built += 1;
795 report(Watched::Built(Box::new(outcome)));
796 }
797 Err(err) => report(Watched::Failed {
798 repo: key,
799 commit: commit.to_string(),
800 why: format!("{err:#}"),
801 }),
802 }
803 }
804 Ok(built)
805}