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/statement.rs

//! The provenance: one in-toto Statement v1 per release, with a SLSA Provenance v1
//! predicate. The PLATFORM writes it (the runner, after the build container exits);
//! nothing inside the build can add to it.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

use crate::{bail, Result};

/// in-toto Statement v1.
pub const STATEMENT_V1: &str = "https://in-toto.io/Statement/v1";
/// SLSA Provenance v1.
pub const SLSA_PROVENANCE_V1: &str = "https://slsa.dev/provenance/v1";
/// The DSSE payload type of an in-toto statement.
pub const PAYLOAD_TYPE_INTOTO: &str = "application/vnd.in-toto+json";
/// The build type this runner defines: a cargo release (packaged crates, and
/// binaries built from those packages), from pinned git commits, offline.
pub const BUILD_TYPE_CARGO_RELEASE: &str =
    "https://git.gunnar.rs/nordisk/dwarves/slsa/buildtypes/cargo-release/v1";
/// The builder: dwarf's release runner (this crate's `build`).
pub const BUILDER_ID_DWARF: &str =
    "https://git.gunnar.rs/nordisk/dwarves/slsa/builders/dwarf-release-runner/v1";
/// The builder: the dwarves server's release-build mode (owner 2026-10-29: dwarves is
/// the build server). [`BUILDER_ID_DWARF`] is the id before that (dwarves dba839d).
pub const BUILDER_ID_DWARVES_SERVER: &str =
    "https://git.gunnar.rs/nordisk/dwarves/slsa/builders/dwarves-server/v1";
/// ★ THE builder id (lane RELBUILD-RUNA closes SLSA3-BID): every statement the dwarves
/// forge writes names it: a release build (a Runa pipeline, as `relbuild`) and a Build
/// Thing build's provenance step alike. What ran where is in `runDetails.builder.version`
/// (`releaseMode`, `runnerAccount`, `runnerSha256`), and who vouches is the signing key
/// (the owner's YubiKey for releases).
pub const BUILDER_ID: &str = BUILDER_ID_DWARVES_SERVER;

/// One subject: an output and its digests.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Subject {
    pub name: String,
    pub digest: BTreeMap<String, String>,
}

impl Subject {
    /// A subject with a sha256 digest.
    pub fn sha256(name: impl Into<String>, hex: impl Into<String>) -> Self {
        let mut digest = BTreeMap::new();
        digest.insert("sha256".to_string(), hex.into());
        Self {
            name: name.into(),
            digest,
        }
    }

    /// Its sha256, if it has one.
    pub fn sha256_hex(&self) -> Option<&str> {
        self.digest.get("sha256").map(String::as_str)
    }
}

/// A SLSA ResourceDescriptor (resolved dependencies, byproducts).
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct ResourceDescriptor {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub uri: Option<String>,
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub digest: BTreeMap<String, String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub annotations: Option<Value>,
}

impl ResourceDescriptor {
    /// `{name?, uri, digest: {alg: hex}}`.
    pub fn new(name: Option<&str>, uri: &str, alg: &str, hex: &str) -> Self {
        let mut digest = BTreeMap::new();
        digest.insert(alg.to_string(), hex.to_string());
        Self {
            name: name.map(str::to_string),
            uri: Some(uri.to_string()),
            digest,
            annotations: None,
        }
    }
}

/// What the platform knows about one release build.
#[derive(Debug, Clone, PartialEq)]
pub struct Provenance {
    pub build_type: String,
    pub external_parameters: Value,
    pub internal_parameters: Value,
    pub resolved_dependencies: Vec<ResourceDescriptor>,
    pub builder_id: String,
    pub builder_version: BTreeMap<String, String>,
    pub invocation_id: String,
    /// RFC 3339 UTC (`…Z`).
    pub started_on: String,
    pub finished_on: String,
    pub byproducts: Vec<ResourceDescriptor>,
}

/// ★ The statement: subjects sorted by name, refused when there is no subject, a
/// name twice, a subject without a 64-hex sha256, an empty builder id, build type
/// or invocation id, or a finish before the start.
pub fn statement(subjects: &[Subject], p: &Provenance) -> Result<Value> {
    if subjects.is_empty() {
        bail!("no subject: a release without outputs has nothing to attest");
    }
    let mut sorted = subjects.to_vec();
    sorted.sort_by(|a, b| a.name.cmp(&b.name));
    for w in sorted.windows(2) {
        if w[0].name == w[1].name {
            bail!("subject {:?} is named twice", w[0].name);
        }
    }
    for s in &sorted {
        if s.name.is_empty() {
            bail!("a subject without a name");
        }
        match s.sha256_hex() {
            Some(h) if is_hex64(h) => {}
            _ => bail!("subject {:?} has no 64-hex lowercase sha256", s.name),
        }
    }
    for (what, v) in [
        ("builder id", &p.builder_id),
        ("build type", &p.build_type),
        ("invocation id", &p.invocation_id),
    ] {
        if v.trim().is_empty() {
            bail!("the provenance has an empty {what}");
        }
    }
    for (what, t) in [("startedOn", &p.started_on), ("finishedOn", &p.finished_on)] {
        if !crate::time::is_rfc3339_utc(t) {
            bail!("{what} {t:?} is not RFC 3339 UTC (YYYY-MM-DDTHH:MM:SSZ)");
        }
    }
    if p.finished_on < p.started_on {
        bail!(
            "finishedOn {} is before startedOn {}",
            p.finished_on,
            p.started_on
        );
    }
    Ok(json!({
        "_type": STATEMENT_V1,
        "subject": sorted,
        "predicateType": SLSA_PROVENANCE_V1,
        "predicate": {
            "buildDefinition": {
                "buildType": p.build_type,
                "externalParameters": p.external_parameters,
                "internalParameters": p.internal_parameters,
                "resolvedDependencies": p.resolved_dependencies,
            },
            "runDetails": {
                "builder": {"id": p.builder_id, "version": p.builder_version},
                "metadata": {"invocationId": p.invocation_id, "startedOn": p.started_on, "finishedOn": p.finished_on},
                "byproducts": p.byproducts,
            },
        },
    }))
}

fn is_hex64(h: &str) -> bool {
    h.len() == 64
        && h.bytes()
            .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
}

/// The exact bytes the owner signs: pretty JSON and a newline (readable on review).
pub fn payload(statement: &Value) -> Vec<u8> {
    let mut out = serde_json::to_vec_pretty(statement).unwrap_or_default();
    out.push(b'\n');
    out
}

/// What a reviewer (and the verifier) reads off a statement.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Summary {
    pub builder_id: String,
    pub build_type: String,
    pub invocation_id: String,
    pub release: String,
    pub started_on: String,
    pub finished_on: String,
    pub subjects: Vec<Subject>,
    /// `(name, repository, commit)` from `externalParameters.repositories`.
    pub repositories: Vec<(String, String, String)>,
}

/// Read a statement (v1, SLSA Provenance v1) into a [`Summary`]; refused otherwise.
pub fn summary(statement: &Value) -> Result<Summary> {
    let st = |ptr: &str| {
        statement
            .pointer(ptr)
            .and_then(Value::as_str)
            .unwrap_or("")
            .to_string()
    };
    if st("/_type") != STATEMENT_V1 {
        bail!("_type {:?} is not {STATEMENT_V1}", st("/_type"));
    }
    if st("/predicateType") != SLSA_PROVENANCE_V1 {
        bail!(
            "predicateType {:?} is not {SLSA_PROVENANCE_V1}",
            st("/predicateType")
        );
    }
    let subjects: Vec<Subject> =
        serde_json::from_value(statement.get("subject").cloned().unwrap_or(Value::Null))
            .map_err(|e| crate::Error(format!("subject: {e}")))?;
    if subjects.is_empty() {
        bail!("no subject");
    }
    let x = "/predicate/buildDefinition/externalParameters";
    let repositories = statement
        .pointer(&format!("{x}/repositories"))
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .map(|r| {
            let g = |k: &str| r.get(k).and_then(Value::as_str).unwrap_or("").to_string();
            (g("name"), g("repository"), g("commit"))
        })
        .collect();
    Ok(Summary {
        builder_id: st("/predicate/runDetails/builder/id"),
        build_type: st("/predicate/buildDefinition/buildType"),
        invocation_id: st("/predicate/runDetails/metadata/invocationId"),
        release: st(&format!("{x}/release")),
        started_on: st("/predicate/runDetails/metadata/startedOn"),
        finished_on: st("/predicate/runDetails/metadata/finishedOn"),
        subjects,
        repositories,
    })
}