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}