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