crates/dwarves-slsa/src/cli.rs
//! ★ The release-build command line: `dwarves release-build <command>` (the same
//! commands as the standalone `dwarves-slsa` binary).
//!
//! ```text
//! spec --name N (--steps F | --steps-from publish-X.sh) --pins F [--image I] [--org repo=org]...
//! [--lock repo/Cargo.toml]... [--binary crate:bin[:feat,feat][:no-default]]... [--out spec.toml]
//! build (--spec S | the spec flags above) [--run-dir D] [--from DIR] [--jobs N] [--rebuild-published] [--keep]
//! show --statement F
//! pae --statement F (the bytes a signer signs, to stdout)
//! verify --bundle B [--key K] [--run-dir D] [--artifact F]... [--builder-id X] [--any-build-type]
//! wait <run> [--key K] [--hours H] (the owner's signature, then verify-run)
//! verify-run <run> [--key K]
//! publish <run> [--key K] [--for-real] (dry run by default)
//! check-published <run> [--key K]
//! attest <run> --repo DIR [--key K]
//! request (--spec S | the spec flags) [--rebuild-published] [--server URL]
//! show <run-id> [--server URL]
//! attach <run-id> <bundle> [--server URL]
//! serve [--config F] [--once | --check] (release mode: the runner, as relbuild)
//! ```
//! A release build is a Runa pipeline (lane RELBUILD-RUNA): `build` and `serve` run it
//! through the dwarves forge's Runa runner ([`Forge::exec`]), and its runs are in the
//! forge's history (`dwarves history`, the Pipeline tab) as pipeline `release-build`.
//! `request`, `show <run-id>` and `attach` go through the dwarves server's API
//! ([`ReleaseApi`]): neither the CLI nor the UI writes the inbox.
//! Release mode (lane RELBUILD-ACCT): the release runner runs as its own account
//! (`relbuild`) on dwarf's host and builds only what a lane asks through the inbox
//! (`request` writes one; a request is data, a release at pinned commits). `serve`
//! refuses the lanes' account and root, and any binary but the installed one; its
//! config is `/etc/dwarves-release/runner.toml`. `build --release-mode` (or
//! `DWARVES_RELEASE_MODE=1`) is refused: in release mode builds come from the inbox.
//! `--key` defaults to `$SLSA_KEY`, then to the built-in release key (the owner's YubiKey key,
//! key id d24d3785…8249e, the same file as `slsa/release-signing-key.pub` in holger and gunnar).
use std::collections::BTreeMap;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use crate::publish::{self, Containers, PublishOptions, Token};
use crate::runner::{self, Executor, RunOptions, DEFAULT_IMAGE};
use crate::spec::{BinaryStep, ReleaseSpec};
use crate::statement::{self, PAYLOAD_TYPE_INTOTO};
use crate::verify::{verify_bundle, Expect};
use crate::{bail, dsse, Error, Result};
pub const USAGE: &str = "usage: dwarves release-build <command>
spec --name N (--steps F | --steps-from publish-X.sh) --pins F [--image I] [--org repo=org]...
[--lock repo/Cargo.toml]... [--binary crate:bin[:feat,feat][:no-default]]... [--out spec.toml]
build (--spec S | the spec flags) [--run-dir D] [--from DIR] [--jobs N] [--rebuild-published] [--keep]
show --statement F
pae --statement F
verify --bundle B [--key K] [--run-dir D] [--artifact F]... [--builder-id X] [--any-build-type]
wait <run> [--key K] [--hours H]
verify-run <run> [--key K]
publish <run> [--key K] [--for-real]
check-published <run> [--key K]
attest <run> --repo DIR [--key K]
request (--spec S | the spec flags) [--rebuild-published] [--server URL] (queued by the dwarves server)
show <run-id> [--server URL] (a release run: state, statement, artifacts, the signing command)
attach <run-id> <bundle> [--server URL] (the owner's signed bundle, verified, then stored)
serve [--config F] [--once | --check] (release mode, as relbuild; default /etc/dwarves-release/runner.toml)";
struct Args(Vec<String>);
/// The flags that take no value.
const SWITCHES: [&str; 7] = [
"--for-real",
"--rebuild-published",
"--keep",
"--any-build-type",
"--release-mode",
"--once",
"--check",
];
impl Args {
fn one(&self, flag: &str) -> Option<String> {
self.0
.iter()
.position(|a| a == flag)
.and_then(|i| self.0.get(i + 1))
.cloned()
}
fn need(&self, flag: &str) -> Result<String> {
self.one(flag)
.ok_or_else(|| Error(format!("{flag} is required\n{USAGE}")))
}
fn all(&self, flag: &str) -> Vec<String> {
self.0
.windows(2)
.filter(|w| w[0] == flag)
.map(|w| w[1].clone())
.collect()
}
fn has(&self, flag: &str) -> bool {
self.0.iter().any(|a| a == flag)
}
/// Every argument after the command that is not a flag or a flag's value.
fn positionals(&self) -> Vec<String> {
let mut out = Vec::new();
let mut it = self.0.iter().skip(1);
while let Some(a) = it.next() {
if a.starts_with("--") {
if !SWITCHES.contains(&a.as_str()) {
it.next();
}
continue;
}
out.push(a.clone());
}
out
}
/// The first argument after the command that is not a flag or a flag's value.
fn positional(&self) -> Result<PathBuf> {
let mut it = self.0.iter().skip(1);
while let Some(a) = it.next() {
if a.starts_with("--") {
if ![
"--for-real",
"--rebuild-published",
"--keep",
"--any-build-type",
"--release-mode",
"--once",
"--check",
]
.contains(&a.as_str())
{
it.next();
}
continue;
}
return Ok(PathBuf::from(a));
}
bail!("the run directory is required\n{USAGE}")
}
}
fn read(p: &str) -> Result<String> {
std::fs::read_to_string(p).map_err(|e| Error(format!("{p}: {e}")))
}
/// `--key`, else `$SLSA_KEY`, else the built-in release key ([`crate::release_key`]).
fn key(a: &Args) -> Result<Vec<u8>> {
crate::release_key::resolve(
a.one("--key").as_deref(),
std::env::var("SLSA_KEY").ok().as_deref(),
)
}
/// The STEPS table of a `publish-*.sh` script.
pub fn steps_of_script(text: &str) -> Result<String> {
let mut on = false;
let mut out = String::new();
for l in text.lines() {
if l.starts_with("STEPS=") {
on = true;
} else if on && l.trim() == "EOF" {
break;
} else if on
&& l.split_whitespace()
.next()
.is_some_and(|n| n.parse::<u32>().is_ok())
{
out.push_str(l);
out.push('\n');
}
}
if out.is_empty() {
bail!("the script has no STEPS table");
}
Ok(out)
}
/// `crate:bin[:feat,feat][:no-default]` (`--binary`).
pub fn binary_step(s: &str) -> Result<BinaryStep> {
let f: Vec<&str> = s.split(':').collect();
if f.len() < 2 {
bail!("--binary {s:?}: crate:bin[:feat,feat][:no-default]");
}
let features = f
.get(2)
.filter(|x| !x.is_empty() && **x != "-")
.map(|x| x.split(',').map(str::to_string).collect())
.unwrap_or_default();
Ok(BinaryStep {
crate_name: f[0].into(),
bin: f[1].into(),
features,
no_default_features: f.get(3) == Some(&"no-default"),
})
}
fn spec_of(a: &Args) -> Result<ReleaseSpec> {
if let Some(s) = a.one("--spec") {
return ReleaseSpec::parse(&read(&s)?);
}
let mut orgs = BTreeMap::new();
for o in a.all("--org") {
let (r, org) = o
.split_once('=')
.ok_or_else(|| Error(format!("--org {o:?}: repo=org")))?;
orgs.insert(r.to_string(), org.to_string());
}
let steps = match (a.one("--steps"), a.one("--steps-from")) {
(Some(f), _) => read(&f)?,
(None, Some(script)) => steps_of_script(&read(&script)?)?,
(None, None) => bail!("--spec, or --steps / --steps-from, is required\n{USAGE}"),
};
let bins = a
.all("--binary")
.iter()
.map(|b| binary_step(b))
.collect::<Result<Vec<_>>>()?;
ReleaseSpec::from_tables(
&a.need("--name")?,
&a.one("--image")
.unwrap_or_else(|| DEFAULT_IMAGE.to_string()),
&steps,
&read(&a.need("--pins")?)?,
&orgs,
"nordisk",
a.all("--lock"),
bins,
)
}
/// `DWARVES_RELEASE_MODE=1`: this process is in release mode.
fn release_mode_env() -> bool {
std::env::var("DWARVES_RELEASE_MODE").is_ok_and(|v| v == "1")
}
/// In release mode a build comes from the inbox, never from a lane's command line.
fn release_mode_refusal() -> Error {
Error(format!(
"release mode builds only from the inbox: the release runner (`dwarves release-build serve`, as {}) takes requests from {}; a lane writes one with `dwarves release-build request`",
crate::release_mode::RELEASE_ACCOUNT,
crate::release_mode::DEFAULT_INBOX
))
}
/// ★ The dwarves server's release API, as the CLI and the UI call it (one API: a Rust
/// library call on the server). The dwarves forge implements it over gRPC.
pub trait ReleaseApi {
/// Queue a request; the answer names its run id.
fn request(&self, r: &crate::release_mode::Request) -> Result<String>;
/// A release run, as the forge shows it (JSON: state, statement, artifacts, the
/// signing command).
fn show(&self, run_id: &str) -> Result<String>;
/// The owner's signed bundle for a run: verified against the release key and the
/// run's statement before it is stored.
fn attach(&self, run_id: &str, bundle: &[u8]) -> Result<String>;
}
/// ★ What the dwarves forge hands the command line: its Runa runner, its container
/// engine and its server API. The standalone `dwarves-slsa` binary has none of them and
/// says which command to use instead.
#[derive(Clone, Copy, Default)]
pub struct Forge<'a> {
pub exec: Option<&'a dyn Executor>,
pub containers: Option<&'a dyn Containers>,
pub api: Option<&'a dyn ReleaseApi>,
}
/// The standalone binary's executor: it builds nothing (serve's checks still run).
struct NoExecutor;
impl Executor for NoExecutor {
fn execute(&self, _: &dwarves_runa::Pipeline, _: std::sync::Arc<runner::ReleaseRun>, _: &str) -> Result<runner::RunRecord> {
Err(not_here("serve"))
}
}
fn not_here(what: &str) -> Error {
Error(format!(
"{what} runs in the dwarves forge: `dwarves release-build {what}` (a release build is a Runa pipeline)"
))
}
fn runs_dir() -> PathBuf {
std::env::var("SLSA_RUNS")
.map(PathBuf::from)
.unwrap_or_else(|_| {
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".into())).join("slsa-runs")
})
}
/// ★ Build a release (the runner), show its statement and the next step. `run_dir`
/// defaults to `$SLSA_RUNS` (`~/slsa-runs`)`/<name>-<utc>`.
pub fn build_release(
spec: &ReleaseSpec,
run_dir: Option<PathBuf>,
from: Option<PathBuf>,
rebuild_published: bool,
keep: bool,
builder_version: &BTreeMap<String, String>,
exec: &dyn Executor,
) -> Result<PathBuf> {
if release_mode_env() {
return Err(release_mode_refusal());
}
let run_dir = run_dir.unwrap_or_else(|| {
runs_dir().join(format!(
"{}-{}",
spec.name,
crate::time::compact(crate::time::now_secs())
))
});
let opts = RunOptions {
run_dir,
from,
jobs: std::env::var("CARGO_BUILD_JOBS")
.ok()
.and_then(|j| j.parse().ok())
.unwrap_or(4),
rebuild_published,
keep,
run_id: None,
builder_version: builder_version.clone(),
};
let st = runner::run(spec, &opts, exec)?;
show(&std::fs::read(&st)?)?;
println!("\nunsigned statement: {}", st.display());
println!(
"next: the owner signs it on the YubiKey: script-sign-release.sh {}",
st.display()
);
println!(
" then: dwarves release-build wait {}",
st.parent().unwrap_or(Path::new(".")).display()
);
Ok(st)
}
fn build(
spec: &ReleaseSpec,
a: &Args,
builder_version: &BTreeMap<String, String>,
exec: &dyn Executor,
) -> Result<PathBuf> {
if let Some(j) = a.one("--jobs") {
j.parse::<u32>()
.map_err(|_| Error(format!("--jobs {j:?}")))?;
std::env::set_var("CARGO_BUILD_JOBS", j);
}
build_release(
spec,
a.one("--run-dir").map(PathBuf::from),
a.one("--from").map(PathBuf::from),
a.has("--rebuild-published"),
a.has("--keep"),
builder_version,
exec,
)
}
pub fn show(payload: &[u8]) -> Result<()> {
let v: serde_json::Value = serde_json::from_slice(payload)?;
let s = statement::summary(&v)?;
println!("release {}", s.release);
println!("builder {}", s.builder_id);
println!("build type {}", s.build_type);
println!("invocation {}", s.invocation_id);
println!("built {} .. {}", s.started_on, s.finished_on);
println!(
"statement sha256 {}",
crate::subjects::sha256_hex(payload)
);
println!("sources:");
for (n, url, c) in &s.repositories {
println!(" {n:<16} {c} {url}");
}
println!("subjects ({}):", s.subjects.len());
for x in &s.subjects {
println!(" {} {}", x.sha256_hex().unwrap_or("?"), x.name);
}
Ok(())
}
fn verify_cmd(a: &Args) -> Result<()> {
let spki = key(a)?;
let mut expect = Expect::default();
if let Some(b) = a.one("--builder-id") {
expect.builder_id = b;
}
if a.has("--any-build-type") {
expect.build_type = None;
}
let v = verify_bundle(&read(&a.need("--bundle")?)?, &spki, &expect)?;
println!("OK signature: release key {}", v.keyid);
println!(
"OK statement: {} by {} ({} subjects)",
v.summary.release,
v.summary.builder_id,
v.summary.subjects.len()
);
if let Some(d) = a.one("--run-dir") {
let n = v.check_dir(&runner::Layout::new(&d).out())?;
println!("OK {d}/out: exactly the {n} subjects");
}
for f in a.all("--artifact") {
let bytes = std::fs::read(&f).map_err(|e| Error(format!("{f}: {e}")))?;
let s = v.check_artifact(&f, &bytes)?;
println!(
"OK {f}: subject {} sha256 {}",
s.name,
s.sha256_hex().unwrap_or("?")
);
}
Ok(())
}
fn verified(v: &crate::verify::Verified, run: &Path) {
println!("OK {}: signed by release key {}, the bundle signs this run's statement, out/ is exactly the {} subjects", run.display(), v.keyid, v.summary.subjects.len());
}
/// ★ One command. `builder_version` is the caller's identity (the dwarves server's
/// version and commit), recorded in every statement it builds.
pub fn run(argv: Vec<String>, builder_version: &BTreeMap<String, String>, forge: Forge<'_>) -> Result<()> {
let a = Args(argv);
match a.0.first().map(String::as_str) {
Some("spec") => {
let s = spec_of(&a)?;
let text = format!(
"# dwarves release-build spec (the build's externalParameters)\n{}",
s.to_toml()?
);
match a.one("--out") {
Some(p) => std::fs::write(&p, text)?,
None => print!("{text}"),
}
Ok(())
}
Some("build") => {
if a.has("--release-mode") {
return Err(release_mode_refusal());
}
let exec = forge.exec.ok_or_else(|| not_here("build"))?;
build(&spec_of(&a)?, &a, builder_version, exec).map(|_| ())
}
Some("request") => {
let r = crate::release_mode::Request {
rebuild_published: a.has("--rebuild-published"),
spec: spec_of(&a)?,
};
let api = forge.api.ok_or_else(|| not_here("request"))?;
println!("{}", api.request(&r)?);
Ok(())
}
Some("attach") => {
let api = forge.api.ok_or_else(|| not_here("attach"))?;
let pos = a.positionals();
let [id, bundle] = pos.as_slice() else {
bail!("attach <run-id> <bundle>\n{USAGE}");
};
let bytes = std::fs::read(bundle).map_err(|e| Error(format!("{bundle}: {e}")))?;
println!("{}", api.attach(id, &bytes)?);
Ok(())
}
Some("serve") => {
let path = a
.one("--config")
.unwrap_or_else(|| crate::release_mode::DEFAULT_CONFIG.to_string());
let cfg = crate::release_mode::Config::load(Path::new(&path))?;
let mode = if a.has("--check") {
crate::release_mode::ServeMode::Check
} else if a.has("--once") {
crate::release_mode::ServeMode::Once
} else {
crate::release_mode::ServeMode::Loop
};
let exec = forge.exec.unwrap_or(&NoExecutor);
// the forge's container engine is the runner's own rootless podman API, where
// container root is the runner's account
std::env::set_var("DOCKER_HOST", &cfg.engine);
if std::env::var_os("DWARVES_RUNA_USER").is_none() {
std::env::set_var("DWARVES_RUNA_USER", "0:0");
}
crate::release_mode::serve(&cfg, mode, builder_version, exec)
}
Some("show") if a.one("--statement").is_none() => {
let api = forge.api.ok_or_else(|| not_here("show"))?;
let pos = a.positionals();
let [id] = pos.as_slice() else {
bail!("show <run-id> | show --statement F\n{USAGE}");
};
println!("{}", api.show(id)?);
Ok(())
}
Some("show") => show(&std::fs::read(a.need("--statement")?)?),
Some("pae") => {
let b = std::fs::read(a.need("--statement")?)?;
std::io::stdout()
.write_all(&dsse::pae(PAYLOAD_TYPE_INTOTO, &b))
.map_err(Error::from)
}
Some("verify") => verify_cmd(&a),
Some("verify-run") => {
let run = a.positional()?;
verified(&publish::verify_run(&run, &key(&a)?)?, &run);
Ok(())
}
Some("wait") => {
let run = a.positional()?;
let hours = a.one("--hours").and_then(|h| h.parse().ok()).unwrap_or(24);
println!(
"waiting for {} (the owner signs {}), up to {hours} h",
runner::Layout::new(&run).bundle().display(),
runner::Layout::new(&run).statement().display()
);
verified(&publish::wait(&run, &key(&a)?, hours)?, &run);
Ok(())
}
Some("publish") => {
let run = a.positional()?;
let for_real = a.has("--for-real");
let token = if !for_real {
Token::None
} else if std::env::var("CARGO_REGISTRY_TOKEN").is_ok() {
Token::EnvByName
} else {
let f = PathBuf::from(std::env::var("CARGO_HOME").unwrap_or_else(|_| {
format!("{}/.cargo", std::env::var("HOME").unwrap_or_default())
}))
.join("credentials.toml");
if !f.exists() {
bail!("cargo has no crates.io token here (cargo login first; this command never takes one)");
}
Token::File(f)
};
if for_real {
println!("token: cargo's own (not shown)");
}
let engine = forge.containers.ok_or_else(|| not_here("publish"))?;
let o = PublishOptions {
for_real,
token,
ignore_crates_io: std::env::var("SLSA_DRY_IGNORE_CRATES_IO")
.is_ok_and(|v| v == "1"),
};
publish::publish(&run, &key(&a)?, &o, engine)?;
println!(
"\npublish ({}): done. next: dwarves release-build check-published {}",
if for_real { "for real" } else { "dry run" },
run.display()
);
Ok(())
}
Some("check-published") => {
let run = a.positional()?;
let n = publish::check_published(&run, &key(&a)?)?;
println!("OK all {n} crates crates.io serves for this release are attested subjects");
Ok(())
}
Some("attest") => {
let run = a.positional()?;
let to = publish::attest(&run, &key(&a)?, Path::new(&a.need("--repo")?))?;
println!(
"copied to {}; commit and push it with the release",
to.display()
);
Ok(())
}
Some("-h" | "--help" | "help") | None => {
println!("{USAGE}");
Ok(())
}
Some(x) => Err(Error(format!("unknown command {x:?}\n{USAGE}"))),
}
}