crates/dwarves-slsa/src/publish.rs
//! The publish half of the release-build mode: what crates.io gets is the attested bytes.
/// What to do with one crate of a signed release.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
/// crates.io has it with the attested sha256.
Skip,
/// crates.io does not have that version.
Publish,
/// crates.io has it with other bytes.
Red(String),
}
/// ★ By the attested sha256 against the index's checksum (if the index has the version).
pub fn decide(attested: &str, index: Option<&str>) -> Decision {
match index {
None => Decision::Publish,
Some(h) if h == attested => Decision::Skip,
Some(h) => Decision::Red(format!(
"crates.io has it with sha256 {h}, not the attested {attested}"
)),
}
}
/// crates.io's "Please try again after Wed, 28 Oct 2026 17:56:18 GMT", in seconds since the epoch.
pub fn parse_retry_after(output: &str) -> Option<u64> {
let i = output.rfind("try again after ")? + "try again after ".len();
let f: Vec<&str> = output[i..].split_whitespace().take(5).collect();
// [Wed,] 28 Oct 2026 17:56:18 GMT
let (d, mon, y, hms) = (f.get(1)?, f.get(2)?, f.get(3)?, f.get(4)?);
let month = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
]
.iter()
.position(|m| m == mon)? as i64
+ 1;
let (d, y): (i64, i64) = (d.parse().ok()?, y.parse().ok()?);
let t: Vec<i64> = hms
.split(':')
.map(|x| x.parse().ok())
.collect::<Option<Vec<_>>>()?;
if t.len() != 3 {
return None;
}
// days_from_civil (Howard Hinnant)
let (yy, mm) = if month <= 2 {
(y - 1, month + 9)
} else {
(y, month - 3)
};
let era = yy.div_euclid(400);
let yoe = yy - era * 400;
let doy = (153 * mm + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
u64::try_from(days * 86_400 + t[0] * 3600 + t[1] * 60 + t[2]).ok()
}
use std::path::{Path, PathBuf};
use crate::runner::{crates_io_cksum, Bind, Layout, Sealed};
use crate::spec::ReleaseSpec;
use crate::subjects::sha256_file;
use crate::verify::{verify_bundle, Expect, Verified};
use crate::{bail, Error, Result};
/// How the publish container gets cargo's crates.io token. Never a value on a
/// command line: a variable passed through BY NAME, or the credentials file mounted
/// read-only as one file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Token {
/// A dry run: no token at all.
None,
/// `CARGO_REGISTRY_TOKEN`, from this process's environment into the container's.
EnvByName,
/// `~/.cargo/credentials.toml`, mounted read-only.
File(PathBuf),
}
/// One crate of the release, from the run's plan.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlanStep {
pub name: String,
pub version: String,
pub repo: String,
pub path: String,
/// `package` (built by this run) or `published` (staged from crates.io).
pub mode: String,
}
/// The run's `plan/steps`.
pub fn plan_steps(run: &Path) -> Result<Vec<PlanStep>> {
let text = std::fs::read_to_string(Layout::new(run).plan().join("steps"))?;
text.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
let f: Vec<&str> = l.split_whitespace().collect();
match f.as_slice() {
[n, v, r, p, m] => Ok(PlanStep {
name: n.to_string(),
version: v.to_string(),
repo: r.to_string(),
path: p.to_string(),
mode: m.to_string(),
}),
_ => Err(Error(format!("plan/steps line {l:?}"))),
}
})
.collect()
}
/// ★ The publish container: the ci4 image of the run, the network on (the index), the
/// run's sources read-only, its own target and cargo home, the token as [`Token`] says.
/// Only `cargo package --no-verify` and `cargo publish --no-verify` run in it: no build
/// script of the release ever runs beside the token. It runs sealed, through the forge's
/// one container engine ([`Containers`]).
pub fn publish_container(
layout: &Layout,
image: &str,
workdir: &str,
token: &Token,
cargo: &[&str],
) -> Result<Sealed> {
if !cargo.contains(&"--no-verify") || !cargo.contains(&"--locked") {
bail!("the publish container runs cargo with --locked --no-verify only (no build beside the token)");
}
if !matches!(cargo.first(), Some(&"package") | Some(&"publish")) {
bail!("the publish container runs cargo package or cargo publish only");
}
let p = layout.root.join("publish");
let mut binds = vec![
Bind { host: layout.src(), path: "/src".into(), read_only: true },
Bind { host: p.join("target"), path: "/target".into(), read_only: false },
Bind { host: p.join("cargo-home"), path: "/cargo-home".into(), read_only: false },
];
let mut env: Vec<(String, String)> = [
("CARGO_HOME", "/cargo-home"),
("HOME", "/tmp"),
("CARGO_TERM_COLOR", "never"),
("GIT_CONFIG_COUNT", "2"),
("GIT_CONFIG_KEY_0", "safe.directory"),
("GIT_CONFIG_VALUE_0", "*"),
("GIT_CONFIG_KEY_1", "lfs.storage"),
("GIT_CONFIG_VALUE_1", "/lfs"),
]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
match token {
Token::None => {}
Token::EnvByName => {
let t = std::env::var("CARGO_REGISTRY_TOKEN")
.map_err(|_| Error("CARGO_REGISTRY_TOKEN is not set".into()))?;
env.push(("CARGO_REGISTRY_TOKEN".into(), t));
}
Token::File(f) => binds.push(Bind {
host: f.clone(),
path: "/cargo-home/credentials.toml".into(),
read_only: true,
}),
}
let mut cmd = vec!["cargo".to_string()];
cmd.extend(cargo.iter().map(|s| s.to_string()));
cmd.extend(["--target-dir".into(), "/target".into()]);
Ok(Sealed {
image: image.to_string(),
cmd,
env,
binds,
tmpfs: vec![("/tmp".into(), 1024), ("/lfs".into(), 2048)],
network: true,
workdir: workdir.to_string(),
})
}
/// ★ The forge's one container engine, as the publish step sees it: run a sealed
/// container to its end; `(green, its output)`. The dwarves forge implements it
/// (draupnir, the engine Runa's `Sandbox` steps run on); nothing here spawns podman.
pub trait Containers {
fn run(&self, c: &Sealed) -> Result<(bool, String)>;
}
/// ★ A run's signed bundle: it verifies with the release key, it signs THIS run's
/// statement byte for byte, and `out/` is exactly its subjects.
pub fn verify_run(run: &Path, spki: &[u8]) -> Result<Verified> {
let l = Layout::new(run);
let text = std::fs::read_to_string(l.bundle()).map_err(|_| {
Error(format!(
"no signed bundle at {} yet (the owner signs {} on the YubiKey)",
l.bundle().display(),
l.statement().display()
))
})?;
verify_bundle_for_run(run, text.as_bytes(), spki)
}
/// ★ A bundle (not yet stored) for the run at `run`: it verifies with `spki`, it signs
/// this run's statement byte for byte, and `out/` is exactly its subjects.
pub fn verify_bundle_for_run(run: &Path, bundle: &[u8], spki: &[u8]) -> Result<Verified> {
let l = Layout::new(run);
let text = std::str::from_utf8(bundle).map_err(|_| Error("the bundle is not UTF-8 text".into()))?;
let v = verify_bundle(text, spki, &Expect::default())?;
let env = &crate::dsse::parse_bundle(text)?[0];
if env.payload_bytes()? != std::fs::read(l.statement())? {
bail!(
"the bundle signs another statement than {}",
l.statement().display()
);
}
v.check_dir(&l.out())?;
Ok(v)
}
/// Wait (every 30 s, up to `hours`) for the owner's bundle, then [`verify_run`].
pub fn wait(run: &Path, spki: &[u8], hours: u64) -> Result<Verified> {
let until = crate::time::now_secs() + hours * 3600;
let b = Layout::new(run).bundle();
while !b.metadata().map(|m| m.len() > 0).unwrap_or(false) {
if crate::time::now_secs() > until {
bail!("no signed bundle at {} after {hours} h", b.display());
}
std::thread::sleep(std::time::Duration::from_secs(30));
}
verify_run(run, spki)
}
/// The publish run's switches.
#[derive(Debug, Clone)]
pub struct PublishOptions {
pub for_real: bool,
pub token: Token,
/// Dry run only: package again as if crates.io had none of the release's versions
/// (a check of this flow on a `--rebuild-published` run).
pub ignore_crates_io: bool,
}
/// ★ Publish a signed run, in order: crates.io with the attested sha256 → skip; with
/// other bytes → red; else package again in the publish container, which must give the
/// attested bytes, then `cargo publish --no-verify --locked`, then the index checksum
/// must be the attested one too.
pub fn publish(run: &Path, spki: &[u8], o: &PublishOptions, engine: &dyn Containers) -> Result<()> {
let l = Layout::new(run);
verify_run(run, spki)?;
let spec = ReleaseSpec::parse(&std::fs::read_to_string(l.plan().join("spec.toml"))?)?;
let p = run.join("publish");
std::fs::create_dir_all(p.join("target"))?;
std::fs::create_dir_all(p.join("cargo-home"))?;
let token = if o.for_real {
o.token.clone()
} else {
Token::None
};
if o.for_real && token == Token::None {
bail!("a real publish needs cargo's token (CARGO_REGISTRY_TOKEN, or ~/.cargo/credentials.toml)");
}
for s in plan_steps(run)? {
println!("\n== {} {}", s.name, s.version);
if s.mode == "published" {
println!("SKIP staged from crates.io in the build (not this release's output)");
continue;
}
let file = format!("{}-{}.crate", s.name, s.version);
let want = sha256_file(&l.out().join("crates").join(&file))?;
let ignore = !o.for_real && o.ignore_crates_io;
let have = if ignore {
None
} else {
crates_io_cksum(&s.name, &s.version)?
};
match decide(&want, have.as_deref()) {
Decision::Skip => {
println!("SKIP on crates.io with the attested sha256 {want}");
continue;
}
Decision::Red(m) => bail!("{} {}: {m}", s.name, s.version),
Decision::Publish => {}
}
let wd = format!("/src/{}/{}", s.repo, s.path);
let pa = publish_container(
&l,
&spec.image,
&wd,
&Token::None,
&["package", "--locked", "--no-verify", "-q", "-p", &s.name],
)?;
if !engine.run(&pa)?.0 {
if !o.for_real {
println!("DRY cargo package needs an earlier step on crates.io first (checked again before the upload)");
continue;
}
bail!("{}: cargo package failed", s.name);
}
let got = sha256_file(&p.join("target/package").join(&file))?;
if got != want {
if ignore {
println!("DIFF packaged again sha256 {got}, attested {want} (expected where it names an earlier step crates.io holds with other bytes)");
continue;
}
bail!("{}: packaged again it is sha256 {got}, the attested subject is {want}: NOT published", s.name);
}
println!("OK packaged again: the attested sha256 {want}");
if !o.for_real {
println!("DRY would publish {} {}", s.name, s.version);
continue;
}
let pa = publish_container(
&l,
&spec.image,
&wd,
&token,
&["publish", "--locked", "--no-verify", "-p", &s.name],
)?;
let mut ok = false;
for attempt in 1..=12 {
let (green, err) = engine.run(&pa)?;
eprint!("{err}");
if green {
ok = true;
break;
}
let lower = err.to_ascii_lowercase();
if !(lower.contains("429")
|| lower.contains("too many requests")
|| lower.contains("rate limit"))
{
break;
}
let now = crate::time::now_secs();
let until = parse_retry_after(&err)
.map(|t| t + 30)
.unwrap_or(now + 600)
.max(now + 30);
println!(
" (crates.io rate limit, try {attempt} of 12: the next try at {}; waiting)",
crate::time::rfc3339_secs(until)
);
while crate::time::now_secs() < until {
std::thread::sleep(std::time::Duration::from_secs(30));
}
}
if !ok {
bail!("{}: cargo publish failed", s.name);
}
let mut have = None;
for _ in 0..60 {
have = crates_io_cksum(&s.name, &s.version)?;
if have.is_some() {
break;
}
std::thread::sleep(std::time::Duration::from_secs(10));
}
if have.as_deref() != Some(want.as_str()) {
bail!(
"{} {}: the index says sha256 {have:?}, the attested subject is {want}",
s.name,
s.version
);
}
println!(
"PUBLISHED {} {} sha256 {want} (the index agrees)",
s.name, s.version
);
}
Ok(())
}
/// ★ Every crate the run built, as crates.io serves it, is an attested subject.
pub fn check_published(run: &Path, spki: &[u8]) -> Result<usize> {
let v = verify_run(run, spki)?;
let dir = run.join("publish/served");
std::fs::create_dir_all(&dir)?;
let mut n = 0;
for s in plan_steps(run)?.into_iter().filter(|s| s.mode == "package") {
let file = format!("{}-{}.crate", s.name, s.version);
let url = format!(
"https://static.crates.io/crates/{0}/{0}-{1}.crate",
s.name, s.version
);
let f = dir.join(&file);
crate::runner::http_fetch(&url, &f)?;
let sub = v.check_artifact(&file, &std::fs::read(&f)?)?;
println!(
"OK crates.io {file}: subject {} sha256 {}",
sub.name,
sub.sha256_hex().unwrap_or("?")
);
n += 1;
}
Ok(n)
}
/// Copy the run's bundle to `<repo>/attestations/<release>.intoto.jsonl` (the commit
/// and push are the release's own step).
pub fn attest(run: &Path, spki: &[u8], repo: &Path) -> Result<PathBuf> {
let v = verify_run(run, spki)?;
let dir = repo.join("attestations");
std::fs::create_dir_all(&dir)?;
let to = dir.join(format!("{}.intoto.jsonl", v.summary.release));
std::fs::copy(Layout::new(run).bundle(), &to)?;
Ok(to)
}