crates/dwarves-slsa/src/spec.rs
//! The release spec: what to build, from which pinned commits, in which image.
//! It is the build's `externalParameters`.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::{bail, Result};
pub const DEFAULT_FORGE: &str = "ssh://git@git.gunnar.rs:2222";
pub const DEFAULT_WEB: &str = "https://git.gunnar.rs";
fn default_forge() -> String {
DEFAULT_FORGE.to_string()
}
fn default_web() -> String {
DEFAULT_WEB.to_string()
}
/// One pinned repository.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Repo {
pub name: String,
pub org: String,
/// The full commit id (40 hex for sha1 repositories, 64 for sha256 ones).
pub commit: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
}
/// One crate to package, in dependency order.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CrateStep {
pub name: String,
pub version: String,
pub repo: String,
/// The crate's directory in the repository.
pub path: String,
}
/// One binary, built from a crate of the release AS PACKAGED (what `cargo install
/// --locked` builds).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BinaryStep {
#[serde(rename = "crate")]
pub crate_name: String,
pub bin: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub features: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub no_default_features: bool,
}
/// One binary built from the release's SOURCES (a workspace member, as a Build Thing
/// variant builds it), not from a packaged crate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBinary {
/// The workspace's manifest in the snapshot (`gunnar/Cargo.toml`).
pub manifest: String,
pub package: String,
pub bin: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub features: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub no_default_features: bool,
}
/// ★ One example of a workspace run from the release's SOURCES (`cargo run --release
/// --locked --offline --example <example> -- <args>`) in a sealed, network-off build
/// container; the files it writes into `{out}` are the release's subjects (Heimdall's
/// verify-bundle guest: its `dist` example builds the three wasm components). The
/// example's own cargo inherits the vendored, offline cargo home.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceExample {
/// The workspace's manifest in the snapshot (`heimdall/guests/verify-bundle/Cargo.toml`).
pub manifest: String,
pub example: String,
/// The example's arguments; `{out}` (alone or as `{out}/<path>`) is the directory
/// whose files become subjects.
#[serde(default)]
pub args: Vec<String>,
/// Every file the example must write, relative to `{out}` (and to the release's
/// `out/`): exactly these become subjects.
pub outputs: Vec<String>,
}
/// The placeholder of a [`WorkspaceExample`]'s output directory in its arguments.
pub const OUT_PLACEHOLDER: &str = "{out}";
/// ★ A release.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReleaseSpec {
/// The release's name (`holger-2.0.1`).
pub name: String,
/// The build image BY DIGEST (`name@sha256:<64 hex>`).
pub image: String,
/// Where the runner clones from.
#[serde(default = "default_forge")]
pub forge: String,
/// The repositories' public base (the provenance URIs).
#[serde(default = "default_web")]
pub web: String,
/// Where an org's repositories are cloned from instead of `forge` (release mode:
/// the runner's config sets it; a request cannot).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub forge_by_org: BTreeMap<String, String>,
#[serde(default, rename = "repo")]
pub repos: Vec<Repo>,
/// The workspaces whose locks are vendored and recorded (`holger/Cargo.toml`).
#[serde(default, rename = "lock")]
pub locks: Vec<String>,
#[serde(default, rename = "crate")]
pub crates: Vec<CrateStep>,
#[serde(default, rename = "binary")]
pub binaries: Vec<BinaryStep>,
#[serde(default, rename = "workspace_binary")]
pub workspace_binaries: Vec<WorkspaceBinary>,
#[serde(default, rename = "workspace_example", skip_serializing_if = "Vec::is_empty")]
pub workspace_examples: Vec<WorkspaceExample>,
}
fn is_hex(s: &str) -> bool {
s.bytes()
.all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
}
/// A repository, crate or bin name: letters, digits, `-`, `_`, `.`, not starting with `.`.
fn safe_name(s: &str) -> bool {
!s.is_empty()
&& !s.starts_with('.')
&& s.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"-_.+".contains(&c))
}
/// A relative path that stays inside the snapshot.
fn safe_rel(p: &str) -> bool {
!p.is_empty()
&& !p.starts_with('/')
&& !p.contains('\\')
&& p.split('/')
.all(|c| !c.is_empty() && c != ".." && c != "." && !c.contains(char::is_whitespace))
}
impl ReleaseSpec {
/// Parse and [`validate`](Self::validate).
pub fn parse(text: &str) -> Result<Self> {
let s: Self =
toml::from_str(text).map_err(|e| crate::Error(format!("release spec: {e}")))?;
s.validate()?;
Ok(s)
}
/// ★ Refuse what cannot be pinned or would reach outside the snapshot: an image
/// not by digest, a short or non-hex commit, an unsafe or doubled repository
/// name, a crate of an unpinned repository, a path that is absolute or climbs
/// with `..`, no lock, nothing to build, a binary of a crate not in the release.
pub fn validate(&self) -> Result<()> {
if !safe_name(&self.name) {
bail!(
"release name {:?}: letters, digits, '-', '_', '.' only",
self.name
);
}
match self.image.rsplit_once("@sha256:") {
Some((n, d)) if !n.is_empty() && d.len() == 64 && is_hex(d) => {}
_ => bail!(
"image {:?} is not pinned by digest (name@sha256:<64 hex>)",
self.image
),
}
let mut seen = std::collections::BTreeSet::new();
for r in &self.repos {
if !safe_name(&r.name) || !safe_name(&r.org) {
bail!(
"repository name {:?}/{:?}: letters, digits, '-', '_', '.' only",
r.org,
r.name
);
}
if !seen.insert(r.name.as_str()) {
bail!("repository {:?} is pinned twice", r.name);
}
if !((r.commit.len() == 40 || r.commit.len() == 64) && is_hex(&r.commit)) {
bail!(
"repository {}: commit {:?} is not a full commit id (40 or 64 lowercase hex)",
r.name,
r.commit
);
}
}
if self.locks.is_empty() {
bail!("no lock: name the workspaces whose Cargo.lock the release resolves from");
}
for l in &self.locks {
if !safe_rel(l) || !l.ends_with("Cargo.toml") {
bail!("lock {l:?}: a relative path to a Cargo.toml inside the snapshot");
}
let repo = l.split('/').next().unwrap_or("");
if self.repo(repo).is_none() {
bail!("lock {l:?}: repository {repo:?} is not pinned");
}
}
if self.crates.is_empty() && self.workspace_binaries.is_empty() && self.workspace_examples.is_empty() {
bail!("nothing to build: no crate, no workspace binary and no workspace example");
}
let mut outputs = std::collections::BTreeSet::new();
for w in &self.workspace_examples {
if !safe_rel(&w.manifest) || !w.manifest.ends_with("/Cargo.toml") {
bail!("workspace example {:?}: manifest {:?} is not a relative path to a Cargo.toml inside the snapshot", w.example, w.manifest);
}
let repo = w.manifest.split('/').next().unwrap_or("");
if self.repo(repo).is_none() {
bail!("workspace example {:?}: repository {repo:?} is not pinned", w.example);
}
if !safe_name(&w.example) {
bail!("workspace example {:?}: the example is a name only", w.example);
}
for a in &w.args {
if let Some(rest) = a.strip_prefix(OUT_PLACEHOLDER) {
if !(rest.is_empty() || rest.strip_prefix('/').is_some_and(safe_rel)) {
bail!("workspace example {:?}: argument {a:?}: {OUT_PLACEHOLDER} alone or {OUT_PLACEHOLDER}/<relative path>", w.example);
}
} else if a.contains('\0') || a.split('/').any(|c| c == "..") {
bail!("workspace example {:?}: argument {a:?} climbs with '..'", w.example);
}
}
if w.outputs.is_empty() {
bail!("workspace example {:?}: no output declared (the files it writes into {OUT_PLACEHOLDER})", w.example);
}
for o in &w.outputs {
if !safe_rel(o) {
bail!("workspace example {:?}: output {o:?} is not a relative path inside out/", w.example);
}
if !outputs.insert(o.as_str()) {
bail!("output {o:?} is declared twice");
}
}
}
for w in &self.workspace_binaries {
if !safe_rel(&w.manifest) || !w.manifest.ends_with("Cargo.toml") {
bail!("workspace binary {}: manifest {:?} is not a relative path to a Cargo.toml inside the snapshot", w.bin, w.manifest);
}
let repo = w.manifest.split('/').next().unwrap_or("");
if self.repo(repo).is_none() {
bail!(
"workspace binary {}: repository {repo:?} is not pinned",
w.bin
);
}
if !safe_name(&w.package)
|| !safe_name(&w.bin)
|| w.features.iter().any(|f| !safe_name(&f.replace('/', "_")))
{
bail!(
"workspace binary {:?}: package, bin and feature names are names only",
w.bin
);
}
}
let mut steps = std::collections::BTreeSet::new();
for c in &self.crates {
if !safe_name(&c.name) || !safe_name(&c.version) {
bail!(
"crate {:?} {:?}: not a crate name and version",
c.name,
c.version
);
}
if self.repo(&c.repo).is_none() {
bail!("crate {}: repository {:?} is not pinned", c.name, c.repo);
}
if !safe_rel(&c.path) {
bail!(
"crate {}: path {:?} is not a relative path inside the repository",
c.name,
c.path
);
}
if !steps.insert(c.name.as_str()) {
bail!("crate {} is a step twice", c.name);
}
}
for b in &self.binaries {
if !steps.contains(b.crate_name.as_str()) {
bail!("binary {}: crate {:?} is not a crate of this release (binaries are built from the packaged crates)", b.bin, b.crate_name);
}
if !safe_name(&b.bin) || b.features.iter().any(|f| !safe_name(&f.replace('/', "_"))) {
bail!("binary {:?}: bin and feature names are names only", b.bin);
}
}
for f in std::iter::once(&self.forge).chain(self.forge_by_org.values()) {
if !f.starts_with("ssh://") && !f.starts_with("https://") {
bail!("forge {f:?}: ssh:// or https://");
}
}
Ok(())
}
/// The hex digest of the image.
pub fn image_digest(&self) -> &str {
self.image
.rsplit_once("@sha256:")
.map(|(_, d)| d)
.unwrap_or("")
}
/// The image's repository name, without the digest.
pub fn image_name(&self) -> &str {
self.image
.rsplit_once("@sha256:")
.map(|(n, _)| n)
.unwrap_or(&self.image)
}
pub fn repo(&self, name: &str) -> Option<&Repo> {
self.repos.iter().find(|r| r.name == name)
}
/// `<forge>/<org>/<name>.git` (the org's own forge if `forge_by_org` names one).
pub fn clone_url(&self, r: &Repo) -> String {
format!(
"{}/{}/{}.git",
self.forge_by_org
.get(&r.org)
.unwrap_or(&self.forge)
.trim_end_matches('/'),
r.org,
r.name
)
}
/// `<web>/<org>/<name>`.
pub fn web_url(&self, r: &Repo) -> String {
format!("{}/{}/{}", self.web.trim_end_matches('/'), r.org, r.name)
}
pub fn to_toml(&self) -> Result<String> {
toml::to_string(self).map_err(|e| crate::Error(format!("release spec: {e}")))
}
/// The provenance's `externalParameters`.
pub fn external_parameters(&self) -> Value {
json!({
"release": self.name,
"repositories": self.repos.iter().map(|r| json!({
"name": r.name,
"repository": self.web_url(r),
"commit": r.commit,
})).collect::<Vec<_>>(),
"locks": self.locks,
"crates": self.crates,
"binaries": self.binaries,
"workspaceBinaries": self.workspace_binaries,
"workspaceExamples": self.workspace_examples,
})
}
/// ★ A spec from the publish scripts' own tables: the STEPS lines
/// (`n crate version repo path verify`) and a pins file (`repo full-sha`).
/// `orgs` names a repository's org where it is not `default_org`.
#[allow(clippy::too_many_arguments)]
pub fn from_tables(
name: &str,
image: &str,
steps: &str,
pins: &str,
orgs: &BTreeMap<String, String>,
default_org: &str,
locks: Vec<String>,
binaries: Vec<BinaryStep>,
) -> Result<Self> {
let mut repos = Vec::new();
for l in pins
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
{
let f: Vec<&str> = l.split_whitespace().collect();
if f.len() < 2 {
bail!("pins line {l:?}: '<repo> <full sha>'");
}
let org = orgs.get(f[0]).map(String::as_str).unwrap_or(default_org);
repos.push(Repo {
name: f[0].into(),
org: org.into(),
commit: f[1].into(),
branch: None,
});
}
let mut crates = Vec::new();
for l in steps
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
{
let f: Vec<&str> = l.split_whitespace().collect();
if f.len() < 5 || f[0].parse::<u32>().is_err() {
bail!("steps line {l:?}: 'n crate version repo path [verify]'");
}
if !repos.iter().any(|r: &Repo| r.name == f[3]) {
bail!(
"step {} names repository {:?}, which the pins file has not pinned",
f[1],
f[3]
);
}
crates.push(CrateStep {
name: f[1].into(),
version: f[2].into(),
repo: f[3].into(),
path: f[4].into(),
});
}
let s = Self {
name: name.into(),
image: image.into(),
forge: default_forge(),
web: default_web(),
forge_by_org: BTreeMap::new(),
repos,
locks,
crates,
binaries,
workspace_binaries: Vec::new(),
workspace_examples: Vec::new(),
};
s.validate()?;
Ok(s)
}
}