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