Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

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}"))),
    }
}