crates/dwarves-slsa/src/release_mode.rs
//! ★ Release mode (lane RELBUILD-ACCT, owner 2026-10-29): the release runner runs as
//! its own account on dwarf's host, never as the lanes' account.
//!
//! The lanes' account (`rickard`) could otherwise alter the dwarves binary or a run
//! directory before the owner signs. In release mode:
//!
//! * the runner runs only as [`Config::account`] (`relbuild`): never as root, never as
//! the lanes' account, never as any account its config refuses ([`check_account`]);
//! * it runs only as the installed binary, root-owned and read-only, with the sha256
//! its config names ([`check_binary_meta`]);
//! * it takes requests only from the inbox ([`Config::inbox`]), which a lane can write.
//! A request is DATA: a release at pinned commits ([`Request`]). The runner's config,
//! not the request, decides the image and where the sources are cloned from
//! ([`parse_request`]);
//! * it writes runs only under [`Config::runs`], which it owns and nobody else can
//! write ([`check_runs_meta`], [`run_dir_for`]); each request's bytes and status sit in
//! `runs/requests/<sha256>.{request.toml,status}`;
//! * the provenance's builder details name the account, its uid, the mode, the binary
//! and the request's sha256 ([`release_builder_version`]); `runnerSha256` is the
//! binary's sha256 (every run records it).
//!
//! The account is made on the host by `script-relbuild-user.sh` (the owner runs it as
//! root); signing stays on the owner's YubiKey.
use std::collections::BTreeMap;
use std::io::Read as _;
use std::os::unix::fs::MetadataExt as _;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::runner::{self, Executor, RunOptions, DEFAULT_IMAGE};
use crate::spec::{ReleaseSpec, DEFAULT_FORGE, DEFAULT_WEB};
use crate::subjects::{sha256_file, sha256_hex};
use crate::{bail, Error, Result};
/// The account the release runner runs as.
pub const RELEASE_ACCOUNT: &str = "relbuild";
/// The lanes' account: never the release runner.
pub const LANES_ACCOUNT: &str = "rickard";
/// The runner's config on the host (root-owned; written by `script-relbuild-user.sh`).
pub const DEFAULT_CONFIG: &str = "/etc/dwarves-release/runner.toml";
/// Where lanes write requests (data only).
pub const DEFAULT_INBOX: &str = "/srv/relbuild/inbox";
/// Where the runner writes runs (the lanes can read, not write).
pub const DEFAULT_RUNS: &str = "/srv/relbuild/runs";
/// Public repositories, cloned without a key.
pub const DEFAULT_RELEASE_FORGE: &str = "ssh://anonymous@git.gunnar.rs:2222";
/// The release runner's container engine (its own podman API socket).
pub const DEFAULT_ENGINE: &str = "unix:///run/dwarves-release/podman.sock";
/// Where the owner's signed bundles come back (`<inbox>/bundles/<request sha256>.intoto.jsonl`).
pub const BUNDLES: &str = "bundles";
/// The release runner's Runa history (the forge's warehouse layout) under the runs.
pub const WAREHOUSE: &str = "warehouse";
/// The Runa project every release run is recorded under.
pub const PROJECT: &str = "release";
/// ★ The Runa run id of a request: `<release>-<first 12 hex of the request's sha256>`,
/// known as soon as the request is (the forge's history shows it from then on).
pub fn run_id_for(release: &str, request_sha256: &str) -> String {
format!("{release}-{}", &request_sha256[..request_sha256.len().min(12)])
}
/// The `releaseMode` the builder details record.
pub const MODE_DEDICATED_ACCOUNT: &str = "dedicated-account";
/// An account: uid and name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identity {
pub uid: u32,
pub name: String,
}
/// The owner and mode (`st_mode`, type bits included) of a file or directory.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FileMeta {
pub uid: u32,
pub mode: u32,
}
impl FileMeta {
pub fn of(p: &Path) -> Result<Self> {
let m = std::fs::symlink_metadata(p).map_err(|e| Error(format!("{}: {e}", p.display())))?;
Ok(Self {
uid: m.uid(),
mode: m.mode(),
})
}
fn is_dir(&self) -> bool {
self.mode & 0o170000 == 0o040000
}
fn is_file(&self) -> bool {
self.mode & 0o170000 == 0o100000
}
fn others_can_write(&self) -> bool {
self.mode & 0o022 != 0
}
}
/// ★ The runner's config (`/etc/dwarves-release/runner.toml`). Unknown keys are refused.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
/// The only account the runner runs as.
pub account: String,
/// Accounts refused even if named in `account` (the lanes' account and root).
pub refuse_accounts: Vec<String>,
pub inbox: PathBuf,
pub runs: PathBuf,
/// The build image, by digest: every request must name exactly this one.
pub image: String,
/// The installed runner binary (release mode refuses any other).
pub binary: Option<PathBuf>,
/// Its sha256, as the install script printed it.
pub binary_sha256: Option<String>,
/// Where the sources are cloned from (the request cannot choose).
pub forge: String,
/// Per org (private repositories: the runner's own read key).
pub org_forge: BTreeMap<String, String>,
/// The container engine's socket (the release runner's own rootless podman API,
/// `unix:///run/dwarves-release/podman.sock`): every sealed container of a release
/// pipeline runs there. Exported as `DOCKER_HOST` for the forge's engine.
pub engine: String,
pub poll_seconds: u64,
pub jobs: u32,
pub max_request_bytes: u64,
}
impl Default for Config {
fn default() -> Self {
Self {
account: RELEASE_ACCOUNT.into(),
refuse_accounts: vec![LANES_ACCOUNT.into(), "root".into()],
inbox: DEFAULT_INBOX.into(),
runs: DEFAULT_RUNS.into(),
image: DEFAULT_IMAGE.into(),
binary: None,
binary_sha256: None,
forge: DEFAULT_RELEASE_FORGE.into(),
org_forge: BTreeMap::new(),
engine: DEFAULT_ENGINE.into(),
poll_seconds: 30,
jobs: 4,
max_request_bytes: 1 << 20,
}
}
}
impl Config {
pub fn parse(text: &str) -> Result<Self> {
let c: Self =
toml::from_str(text).map_err(|e| Error(format!("release-mode config: {e}")))?;
for f in std::iter::once(&c.forge).chain(c.org_forge.values()) {
if !f.starts_with("ssh://") && !f.starts_with("https://") {
bail!("release-mode config: forge {f:?}: ssh:// or https://");
}
}
if !c.engine.starts_with("unix:///") {
bail!("release-mode config: engine {:?}: a unix:/// socket", c.engine);
}
Ok(c)
}
pub fn load(path: &Path) -> Result<Self> {
Self::parse(
&std::fs::read_to_string(path)
.map_err(|e| Error(format!("{}: {e}", path.display())))?,
)
}
}
/// This process's account: the effective uid (equal to the real one) and its name.
pub fn current_identity() -> Result<Identity> {
let status = std::fs::read_to_string("/proc/self/status")?;
let uids: Vec<u32> = status
.lines()
.find_map(|l| l.strip_prefix("Uid:"))
.map(|l| {
l.split_whitespace()
.filter_map(|x| x.parse().ok())
.collect()
})
.unwrap_or_default();
let (real, eff) = match uids.as_slice() {
[r, e, ..] => (*r, *e),
_ => bail!("/proc/self/status names no uid"),
};
if real != eff {
bail!("real uid {real} and effective uid {eff} differ");
}
let name = std::fs::read_to_string("/etc/passwd")
.ok()
.and_then(|p| {
p.lines().find_map(|l| {
let f: Vec<&str> = l.split(':').collect();
(f.len() > 2 && f[2].parse() == Ok(eff)).then(|| f[0].to_string())
})
})
.unwrap_or_else(|| eff.to_string());
Ok(Identity { uid: eff, name })
}
/// ★ Release mode runs as the configured account only: never root (uid 0), never the
/// lanes' account, never one the config refuses, and the config cannot name those.
pub fn check_account(cfg: &Config, id: &Identity) -> Result<()> {
if id.uid == 0 {
bail!(
"release mode never runs as root (uid 0); it runs as {}",
cfg.account
);
}
for a in [cfg.account.as_str(), id.name.as_str()] {
if a == LANES_ACCOUNT || a == "root" || cfg.refuse_accounts.iter().any(|r| r == a) {
bail!(
"release mode refuses account {a:?} (the lanes' account {LANES_ACCOUNT:?}, root and the config's refused accounts never run release builds); it runs as {RELEASE_ACCOUNT:?}"
);
}
}
if id.name != cfg.account {
bail!(
"release mode runs as {:?} only; this is {:?} (uid {})",
cfg.account,
id.name,
id.uid
);
}
Ok(())
}
/// ★ The running binary is the installed one: the config's path, a regular file owned
/// by root that only root can write, in a directory only root can write, with the
/// config's sha256.
pub fn check_binary_meta(
cfg: &Config,
exe: &Path,
file: &FileMeta,
dir: &FileMeta,
sha256: &str,
) -> Result<()> {
let Some(want) = &cfg.binary else {
bail!("release mode needs `binary` (the installed runner) in its config");
};
if exe != want {
bail!(
"this runner is {}, not the installed release runner {}",
exe.display(),
want.display()
);
}
if !file.is_file() || file.uid != 0 || file.others_can_write() {
bail!(
"{}: the release runner must be a regular file owned by root and writable by root only (uid {}, mode {:o})",
exe.display(),
file.uid,
file.mode
);
}
if !dir.is_dir() || dir.uid != 0 || dir.others_can_write() {
bail!(
"{}'s directory must be owned by root and writable by root only (uid {}, mode {:o})",
exe.display(),
dir.uid,
dir.mode
);
}
if let Some(h) = &cfg.binary_sha256 {
if h != sha256 {
bail!(
"{}: sha256 {sha256} is not the installed runner's {h}",
exe.display()
);
}
}
Ok(())
}
/// ★ The runs directory is a directory the runner owns and nobody else can write.
pub fn check_runs_meta(id: &Identity, m: &FileMeta) -> Result<()> {
if !m.is_dir() || m.uid != id.uid || m.others_can_write() {
bail!(
"the runs directory must be a directory owned by {} (uid {}) and writable by it only (uid {}, mode {:o})",
id.name,
id.uid,
m.uid,
m.mode
);
}
Ok(())
}
/// ★ A request: build release X at commits Y. Data only.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
#[serde(default)]
pub rebuild_published: bool,
pub spec: ReleaseSpec,
}
impl Request {
pub fn to_toml(&self) -> Result<String> {
toml::to_string(self).map_err(|e| Error(format!("request: {e}")))
}
}
/// ★ Parse a request against the runner's config: text, small, only the request's
/// keys, a valid spec, the runner's image; the forge and web are the defaults or
/// unset, and the runner sets the forge from its config.
pub fn parse_request(bytes: &[u8], cfg: &Config) -> Result<Request> {
if bytes.len() as u64 > cfg.max_request_bytes {
bail!(
"the request is {} bytes; at most {}",
bytes.len(),
cfg.max_request_bytes
);
}
let text =
std::str::from_utf8(bytes).map_err(|_| Error("the request is not UTF-8 text".into()))?;
let table: toml::Table = toml::from_str(text).map_err(|e| Error(format!("request: {e}")))?;
if let Some(spec) = table.get("spec").and_then(|s| s.as_table()) {
for (key, default) in [("forge", DEFAULT_FORGE), ("web", DEFAULT_WEB)] {
if let Some(v) = spec.get(key) {
if v.as_str() != Some(default) {
bail!("the request sets {key} = {v}: the runner's config decides the {key}");
}
}
}
if spec.contains_key("forge_by_org") {
bail!("the request sets forge_by_org: the runner's config decides the forge");
}
}
let mut r: Request = toml::from_str(text).map_err(|e| Error(format!("request: {e}")))?;
r.spec.validate()?;
if r.spec.image != cfg.image {
bail!(
"the request's image {:?} is not the runner's image {:?}",
r.spec.image,
cfg.image
);
}
r.spec.forge = cfg.forge.clone();
r.spec.forge_by_org = cfg.org_forge.clone();
r.spec.validate()?;
Ok(r)
}
/// ★ A request file: a regular file (never a link or a directory), the file that was
/// checked is the file that is read, at most `max` bytes.
pub fn read_request_file(path: &Path, max: u64) -> Result<Vec<u8>> {
let l =
std::fs::symlink_metadata(path).map_err(|e| Error(format!("{}: {e}", path.display())))?;
if !l.file_type().is_file() {
bail!("{}: not a regular file", path.display());
}
let f = std::fs::File::open(path)?;
let m = f.metadata()?;
if m.dev() != l.dev() || m.ino() != l.ino() || !m.is_file() {
bail!(
"{}: not a regular file (it changed while opened)",
path.display()
);
}
let mut b = Vec::new();
f.take(max + 1).read_to_end(&mut b)?;
if b.len() as u64 > max {
bail!("{}: more than {max} bytes", path.display());
}
Ok(b)
}
fn requests_dir(cfg: &Config) -> PathBuf {
cfg.runs.join("requests")
}
fn status_path(cfg: &Config, sha: &str) -> PathBuf {
requests_dir(cfg).join(format!("{sha}.status"))
}
fn request_name_ok(n: &str) -> bool {
n.ends_with(".toml")
&& !n.starts_with('.')
&& n.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"-_.+".contains(&c))
}
/// ★ The inbox's new requests, by name: `*.toml` (not hidden, plain names), whose bytes
/// have no status yet.
pub fn pending(cfg: &Config) -> Result<Vec<PathBuf>> {
let mut out = Vec::new();
for e in
std::fs::read_dir(&cfg.inbox).map_err(|e| Error(format!("{}: {e}", cfg.inbox.display())))?
{
let e = e?;
let name = e.file_name();
let Some(n) = name.to_str() else { continue };
if !request_name_ok(n) {
continue;
}
let p = e.path();
let Ok(b) = read_request_file(&p, cfg.max_request_bytes) else {
continue;
};
if !status_path(cfg, &sha256_hex(&b)).exists() {
out.push(p);
}
}
out.sort();
Ok(out)
}
/// ★ A run's directory: `<runs>/<release>-<utc>`, always directly under the runs directory.
pub fn run_dir_for(cfg: &Config, spec: &ReleaseSpec, now: u64) -> PathBuf {
cfg.runs
.join(format!("{}-{}", spec.name, crate::time::compact(now)))
}
/// ★ Release mode's builder details: the caller's (`base`: dwarves' version and
/// commit), the mode, the account and uid, the installed binary and the request's
/// sha256. The runner adds `runnerSha256` (the binary's sha256).
pub fn release_builder_version(
cfg: &Config,
id: &Identity,
request_sha256: &str,
base: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
let mut v = base.clone();
v.insert("releaseMode".into(), MODE_DEDICATED_ACCOUNT.into());
v.insert("runnerAccount".into(), id.name.clone());
v.insert("runnerUid".into(), id.uid.to_string());
if let Some(b) = &cfg.binary {
v.insert("runnerBinary".into(), b.display().to_string());
}
v.insert("requestSha256".into(), request_sha256.into());
v
}
/// What became of one request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequestState {
/// Built: the unsigned statement.
Built(PathBuf),
/// Not a request the runner takes (nothing built).
Refused(String),
/// Taken, and the build went red.
Red(String),
}
/// One request handled.
#[derive(Debug, Clone)]
pub struct Outcome {
pub file: PathBuf,
pub sha256: String,
pub state: RequestState,
}
/// ★ A request's status file (`runs/requests/<sha256>.status`). `state`: `refused`,
/// `building`, `built` (waiting for the owner's signature), `red`, `signed`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Status {
pub state: String,
pub request: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub release: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub run: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub statement: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub statement_sha256: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// Why the last bundle that came back was not taken.
#[serde(skip_serializing_if = "Option::is_none")]
pub bundle_error: Option<String>,
pub at: String,
}
impl Status {
pub fn parse(text: &str) -> Result<Self> {
toml::from_str(text).map_err(|e| Error(format!("status: {e}")))
}
}
/// A request's status, if the runner wrote one.
pub fn read_status(runs: &Path, sha: &str) -> Option<Status> {
std::fs::read_to_string(runs.join("requests").join(format!("{sha}.status")))
.ok()
.and_then(|t| Status::parse(&t).ok())
}
fn write_status(cfg: &Config, sha: &str, s: &Status) -> Result<()> {
let text = toml::to_string(s).map_err(|e| Error(format!("status: {e}")))?;
let p = status_path(cfg, sha);
let tmp = p.with_extension("status.tmp");
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, &p)?;
Ok(())
}
/// ★ One pass over the inbox: each new request is copied beside its status, refused
/// or built (one at a time), and its status written. Builds go only under the runs
/// directory, from the runner's forge, with the runner's image and container program.
pub fn serve_once(
cfg: &Config,
id: &Identity,
exe_sha256: &str,
base: &BTreeMap<String, String>,
exec: &dyn Executor,
) -> Result<Vec<Outcome>> {
check_account(cfg, id)?;
std::fs::create_dir_all(requests_dir(cfg))?;
let mut out = Vec::new();
for file in pending(cfg)? {
let bytes = read_request_file(&file, cfg.max_request_bytes)?;
let sha = sha256_hex(&bytes);
std::fs::write(
requests_dir(cfg).join(format!("{sha}.request.toml")),
&bytes,
)?;
let request = file
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let now = || crate::time::rfc3339_secs(crate::time::now_secs());
let mut status = Status {
state: "refused".into(),
request,
at: now(),
..Default::default()
};
let state = match parse_request(&bytes, cfg) {
Err(e) => {
status.error = Some(e.0.clone());
write_status(cfg, &sha, &status)?;
RequestState::Refused(e.0)
}
Ok(r) => {
let run_dir = run_dir_for(cfg, &r.spec, crate::time::now_secs());
let run_id = run_id_for(&r.spec.name, &sha);
status.state = "building".into();
status.run_id = Some(run_id.clone());
status.release = Some(r.spec.name.clone());
status.run = Some(run_dir.display().to_string());
write_status(cfg, &sha, &status)?;
// The binary serve checked (the runner records the same sha256 itself).
let mut builder_version = release_builder_version(cfg, id, &sha, base);
builder_version.insert("runnerSha256".into(), exe_sha256.into());
let opts = RunOptions {
run_dir,
from: None,
jobs: cfg.jobs,
rebuild_published: r.rebuild_published,
keep: false,
run_id: Some(run_id),
builder_version,
};
let res = runner::run(&r.spec, &opts, exec);
status.at = now();
match res {
Ok(st) => {
status.state = "built".into();
status.statement = Some(st.display().to_string());
status.statement_sha256 = sha256_file(&st).ok();
write_status(cfg, &sha, &status)?;
RequestState::Built(st)
}
Err(e) => {
status.state = "red".into();
status.error = Some(e.0.clone());
write_status(cfg, &sha, &status)?;
RequestState::Red(e.0)
}
}
}
};
out.push(Outcome {
file,
sha256: sha,
state,
});
}
take_bundles(cfg, &mut |m| println!("{m}"))?;
Ok(out)
}
/// ★ The owner's signed bundles that came back (`<inbox>/bundles/<request sha256>.intoto.jsonl`,
/// put there by the forge after it verified them): each is verified AGAIN here against
/// the release key and the run's own statement and `out/`, then stored as the run's
/// `release.intoto.jsonl`, and the request is `signed`. A bundle that does not verify is
/// not taken; its status says why.
pub fn take_bundles(cfg: &Config, say: &mut dyn FnMut(&str)) -> Result<usize> {
take_bundles_with(cfg, &crate::release_key::spki()?, say)
}
/// [`take_bundles`] against `spki` (a test's key).
pub fn take_bundles_with(cfg: &Config, spki: &[u8], say: &mut dyn FnMut(&str)) -> Result<usize> {
let dir = cfg.inbox.join(BUNDLES);
let Ok(rd) = std::fs::read_dir(&dir) else {
return Ok(0);
};
let mut n = 0;
for e in rd {
let p = e?.path();
let Some(sha) = p
.file_name()
.and_then(|f| f.to_str())
.and_then(|f| f.strip_suffix(".intoto.jsonl"))
.filter(|s| s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()))
.map(str::to_string)
else {
continue;
};
let Some(mut st) = read_status(&cfg.runs, &sha) else { continue };
if st.state != "built" {
continue;
}
let Some(run) = st.run.clone().map(PathBuf::from) else { continue };
let bytes = match read_request_file(&p, cfg.max_request_bytes) {
Ok(b) => b,
Err(e) => {
st.bundle_error = Some(e.0);
write_status(cfg, &sha, &st)?;
continue;
}
};
let fp = std::fs::read_to_string(run.join("release.intoto.jsonl")).ok();
if fp.as_deref().map(str::as_bytes) == Some(&bytes[..]) {
continue;
}
match crate::publish::verify_bundle_for_run(&run, &bytes, spki) {
Ok(v) => {
std::fs::write(run.join("release.intoto.jsonl"), &bytes)?;
st.state = "signed".into();
st.bundle_error = None;
st.at = crate::time::rfc3339_secs(crate::time::now_secs());
write_status(cfg, &sha, &st)?;
say(&format!("signed {} ({sha}): release key {}", run.display(), v.keyid));
n += 1;
}
Err(e) => {
if st.bundle_error.as_deref() != Some(e.0.as_str()) {
st.bundle_error = Some(e.0.clone());
write_status(cfg, &sha, &st)?;
say(&format!("bundle {} ({sha}) not taken: {e}", p.display()));
}
}
}
}
Ok(n)
}
/// How `serve` runs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServeMode {
/// Every `poll_seconds`, for ever (the system unit).
Loop,
/// One pass over the inbox.
Once,
/// Only the checks (account, binary, runs directory, inbox); nothing is built.
Check,
}
/// ★ The release runner (`dwarves release-build serve`): the account, the installed
/// binary and the runs directory are checked first; then the inbox is served, once or
/// every `poll_seconds`.
pub fn serve(cfg: &Config, mode: ServeMode, base: &BTreeMap<String, String>, exec: &dyn Executor) -> Result<()> {
let id = current_identity()?;
check_account(cfg, &id)?;
let exe = std::env::current_exe()?;
let exe = std::fs::canonicalize(&exe).unwrap_or(exe);
let dir = exe
.parent()
.ok_or_else(|| Error("the runner has no directory".into()))?;
let exe_sha = sha256_file(&exe)?;
check_binary_meta(
cfg,
&exe,
&FileMeta::of(&exe)?,
&FileMeta::of(dir)?,
&exe_sha,
)?;
check_runs_meta(&id, &FileMeta::of(&cfg.runs)?)?;
if !cfg.inbox.is_dir() {
bail!("the inbox {} is not a directory", cfg.inbox.display());
}
println!(
"release runner: account {} (uid {}), binary {} sha256 {exe_sha}, inbox {}, runs {}",
id.name,
id.uid,
exe.display(),
cfg.inbox.display(),
cfg.runs.display()
);
if mode == ServeMode::Check {
println!("check: OK (nothing built)");
return Ok(());
}
loop {
for o in serve_once(cfg, &id, &exe_sha, base, exec)? {
match &o.state {
RequestState::Built(st) => println!(
"built {} ({}): {}",
o.file.display(),
o.sha256,
st.display()
),
RequestState::Refused(e) => {
println!("refused {} ({}): {e}", o.file.display(), o.sha256)
}
RequestState::Red(e) => {
println!("RED {} ({}): {e}", o.file.display(), o.sha256)
}
}
}
if mode == ServeMode::Once {
return Ok(());
}
std::thread::sleep(std::time::Duration::from_secs(cfg.poll_seconds.max(1)));
}
}
/// ★ A lane writes a request into the inbox (`dwarves release-build request`): the
/// file appears whole (written hidden, then renamed). Returns its path.
pub fn write_request(inbox: &Path, r: &Request) -> Result<PathBuf> {
let text = format!(
"# dwarves release-build request: build release {} at the pinned commits (data only)\n{}",
r.spec.name,
r.to_toml()?
);
let mut rnd = [0u8; 4];
if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
let _ = f.read_exact(&mut rnd);
}
let name = format!(
"{}-{}-{}.toml",
r.spec.name,
crate::time::compact(crate::time::now_secs()),
hex::encode(rnd)
);
let tmp = inbox.join(format!(".{name}.tmp"));
std::fs::write(&tmp, text).map_err(|e| Error(format!("{}: {e}", tmp.display())))?;
let p = inbox.join(&name);
std::fs::rename(&tmp, &p)?;
Ok(p)
}
// ───────────────────── the forge's side: queue, show, attach ─────────────────────
/// ★ A release run as the dwarves forge shows it (`dwarves release-build show`, the
/// Pipeline tab's run view). `state`: `queued`, `building`, `waiting for signature`,
/// `signed`, `failed`, `refused`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReleaseView {
pub run_id: String,
pub release: String,
pub state: String,
pub request_sha256: String,
pub run: Option<String>,
pub statement: Option<String>,
pub statement_sha256: Option<String>,
pub error: Option<String>,
pub bundle_error: Option<String>,
pub at: String,
/// `(name, sha256)` of every subject of the statement.
pub artifacts: Vec<(String, String)>,
/// `(repository, commit)`.
pub sources: Vec<(String, String)>,
pub builder_id: Option<String>,
pub runner_account: Option<String>,
pub runner_sha256: Option<String>,
/// What the owner runs (on loki, with the YubiKey) while the run waits for him.
pub sign: Option<String>,
}
/// The word a status file's state is shown as.
pub fn state_word(state: &str) -> &'static str {
match state {
"building" => "building",
"built" => "waiting for signature",
"signed" => "signed",
"red" => "failed",
"refused" => "refused",
_ => "queued",
}
}
/// The owner's signing command for a run that waits for his signature.
pub fn sign_command(run: &str, statement_sha256: &str, run_id: &str) -> String {
format!(
"on loki: scp dwarf:{run}/release.statement.json . && ~/script-sign-release.sh release.statement.json (statement sha256 {statement_sha256}); then: dwarves release-build attach {run_id} release.intoto.jsonl"
)
}
fn view_of(sha: &str, st: &Status) -> ReleaseView {
let release = st.release.clone().unwrap_or_default();
let mut v = ReleaseView {
run_id: st.run_id.clone().unwrap_or_else(|| run_id_for(&release, sha)),
release,
state: state_word(&st.state).to_string(),
request_sha256: sha.to_string(),
run: st.run.clone(),
statement: st.statement.clone(),
statement_sha256: st.statement_sha256.clone(),
error: st.error.clone(),
bundle_error: st.bundle_error.clone(),
at: st.at.clone(),
..Default::default()
};
if let Some(p) = &st.statement {
if let Ok(bytes) = std::fs::read(p) {
if let Ok(val) = serde_json::from_slice::<serde_json::Value>(&bytes) {
if let Ok(s) = crate::statement::summary(&val) {
v.artifacts = s
.subjects
.iter()
.map(|x| (x.name.clone(), x.sha256_hex().unwrap_or("").to_string()))
.collect();
v.sources = s.repositories.iter().map(|(n, _, c)| (n.clone(), c.clone())).collect();
v.builder_id = Some(s.builder_id.clone());
}
let bv = val.pointer("/predicate/runDetails/builder/version");
let get = |k: &str| bv.and_then(|b| b.get(k)).and_then(|x| x.as_str()).map(str::to_string);
v.runner_account = get("runnerAccount");
v.runner_sha256 = get("runnerSha256");
}
}
}
if st.state == "built" {
if let (Some(run), Some(sum)) = (&st.run, &st.statement_sha256) {
v.sign = Some(sign_command(run, sum, &v.run_id));
}
}
v
}
/// ★ Every release request the forge knows, newest first: those the runner has taken
/// (their status) and those still in the inbox (`queued`).
pub fn release_views(inbox: &Path, runs: &Path) -> Vec<ReleaseView> {
let mut out: Vec<ReleaseView> = Vec::new();
let mut seen = std::collections::BTreeSet::new();
if let Ok(rd) = std::fs::read_dir(runs.join("requests")) {
for e in rd.flatten() {
let n = e.file_name().to_string_lossy().into_owned();
let Some(sha) = n.strip_suffix(".status") else { continue };
if let Some(st) = read_status(runs, sha) {
seen.insert(sha.to_string());
out.push(view_of(sha, &st));
}
}
}
if let Ok(rd) = std::fs::read_dir(inbox) {
for e in rd.flatten() {
let n = e.file_name().to_string_lossy().into_owned();
if !request_name_ok(&n) {
continue;
}
let Ok(b) = read_request_file(&e.path(), 1 << 20) else { continue };
let sha = sha256_hex(&b);
if seen.contains(&sha) {
continue;
}
let release = std::str::from_utf8(&b)
.ok()
.and_then(|t| toml::from_str::<Request>(t).ok())
.map(|r| r.spec.name)
.unwrap_or_default();
let at = e
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| crate::time::rfc3339_secs(d.as_secs()))
.unwrap_or_default();
out.push(ReleaseView {
run_id: run_id_for(&release, &sha),
release,
state: "queued".into(),
request_sha256: sha,
at,
..Default::default()
});
}
}
out.sort_by(|a, b| b.at.cmp(&a.at).then(a.run_id.cmp(&b.run_id)));
out
}
/// One release run by its run id.
pub fn release_view(inbox: &Path, runs: &Path, run_id: &str) -> Option<ReleaseView> {
release_views(inbox, runs).into_iter().find(|v| v.run_id == run_id)
}
/// ★ The forge queues a request (the only writer of the inbox): checked as data first
/// (a valid spec, the pinned image, the forge left to the runner), then written whole.
/// Returns `(run id, the request file's sha256)`.
pub fn queue_request(inbox: &Path, r: &Request) -> Result<(String, String)> {
r.spec.validate()?;
if r.spec.image != DEFAULT_IMAGE {
bail!("the request's image {:?} is not the release runner's image {DEFAULT_IMAGE:?}", r.spec.image);
}
let p = write_request(inbox, r)?;
let sha = sha256_file(&p)?;
Ok((run_id_for(&r.spec.name, &sha), sha))
}
/// ★ The owner's signed bundle for `run_id`, verified against `spki` (the release key),
/// the run's own statement and `out/` BEFORE it is stored for the runner
/// (`<inbox>/bundles/<request sha256>.intoto.jsonl`; the runner verifies it again and
/// marks the run signed). Returns the signing key id.
pub fn store_bundle(inbox: &Path, runs: &Path, run_id: &str, bundle: &[u8], spki: &[u8]) -> Result<String> {
if bundle.len() > 1 << 20 {
bail!("the bundle is {} bytes; at most 1 MiB", bundle.len());
}
let v = release_view(inbox, runs, run_id).ok_or_else(|| Error(format!("no release run {run_id}")))?;
if v.state != "waiting for signature" {
bail!("release run {run_id} is {}, not waiting for a signature", v.state);
}
let run = v.run.ok_or_else(|| Error(format!("release run {run_id} has no run directory")))?;
let ok = crate::publish::verify_bundle_for_run(Path::new(&run), bundle, spki)?;
let dir = inbox.join(BUNDLES);
std::fs::create_dir_all(&dir)?;
let name = format!("{}.intoto.jsonl", v.request_sha256);
let tmp = dir.join(format!(".{name}.tmp"));
std::fs::write(&tmp, bundle)?;
std::fs::rename(&tmp, dir.join(&name))?;
Ok(ok.keyid)
}
/// ★ A release request as the UI sends it: the same inputs as the command line's spec
/// flags, the files' TEXT instead of their paths. The forge turns it into a [`Request`]
/// (the UI holds no logic).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RequestInputs {
/// `--name`
pub name: String,
/// The STEPS table: a steps file's text, or a `publish-*.sh` script's (`--steps` /
/// `--steps-from`).
pub steps: String,
/// `--pins`: the pins file's text.
pub pins: String,
/// `--org repo=org`
pub orgs: Vec<String>,
/// `--lock repo/Cargo.toml`
pub locks: Vec<String>,
/// `--binary crate:bin[:feat,feat][:no-default]`
pub binaries: Vec<String>,
/// `--rebuild-published`
pub rebuild_published: bool,
}
impl RequestInputs {
/// The request these inputs name (the runner's image).
pub fn to_request(&self) -> Result<Request> {
let mut orgs = BTreeMap::new();
for o in &self.orgs {
let (r, org) = o
.split_once('=')
.ok_or_else(|| Error(format!("org {o:?}: repo=org")))?;
orgs.insert(r.trim().to_string(), org.trim().to_string());
}
let steps = if self.steps.contains("STEPS=") {
crate::cli::steps_of_script(&self.steps)?
} else {
self.steps.clone()
};
let bins = self
.binaries
.iter()
.map(|b| crate::cli::binary_step(b))
.collect::<Result<Vec<_>>>()?;
Ok(Request {
rebuild_published: self.rebuild_published,
spec: ReleaseSpec::from_tables(&self.name, DEFAULT_IMAGE, &steps, &self.pins, &orgs, "nordisk", self.locks.clone(), bins)?,
})
}
}
/// The text a `Trigger` carries for `release-build`: a whole [`Request`] (`[spec]`), or
/// [`RequestInputs`] (`[inputs]`), in TOML; or `{"inputs": {…}}` in JSON (the UI's).
pub fn parse_inputs_or_request(text: &str) -> Result<Request> {
if text.trim_start().starts_with('{') {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct J {
inputs: RequestInputs,
}
let j: J = serde_json::from_str(text).map_err(|e| Error(format!("request inputs: {e}")))?;
return j.inputs.to_request();
}
let table: toml::Table = toml::from_str(text).map_err(|e| Error(format!("request: {e}")))?;
if let Some(i) = table.get("inputs") {
let inputs: RequestInputs = i
.clone()
.try_into()
.map_err(|e| Error(format!("request inputs: {e}")))?;
return inputs.to_request();
}
toml::from_str(text).map_err(|e| Error(format!("request: {e}")))
}