Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

server/lib/src/promote/cargo.rs

//! **The cargo door of the intake chain** (lane HOL-CARGO-PUBLISH-DOOR, Holger 3.1.1).
//!
//! `cargo publish`, `cargo install` and `cargo yank` speak to ONE sparse registry. The chain's
//! cargo door is the repository `promotion.intake.cargo` names (the estate's `crates`,
//! `ron_repo_type: "rust"`, no storage of its own). It holds nothing: what it serves is a view
//! of the intake chain, exactly as the npm door ([`super::npm`]) is.
//!
//! ```text
//!   cargo publish ─PUT /crates/api/v1/crates/new─▶ the body's one .crate ─▶ INGESTED in `incoming`
//!                                       (the coordinate `crates/<name>/<name>-<version>.crate`)
//!   … the chain's walk: SCANNED, POLICY_CHECKED, APPROVED (N people), SEALED, PROMOTED …
//!   cargo install ─GET /crates/index/<path>──────▶ the AVAILABLE versions' lines
//!                 ─GET /crates/crates/<n>/<v>/download─▶ the sealed bytes
//!   cargo yank [--undo] ─DELETE|PUT /crates/api/v1/crates/<n>/<v>/(un)yank─▶ the yank record
//! ```
//!
//! What it holds to, and why:
//!
//! * **The crate is the truth.** A version's index line is the sealed crate's own normalised
//!   `Cargo.toml` (read by the scan worker at intake, [`holger_index_rows::crates`]), never the
//!   metadata the publisher sent; `cksum` is the sha256 of the sealed bytes. The publish is
//!   refused (`holger-cargo-2`) when the crate's `Cargo.toml` names another crate or version
//!   than the publish does.
//! * **One publish is one crate** (`holger-cargo-1`): cargo's `[u32 json][json][u32 crate]
//!   [crate]` body, nothing after it.
//! * **The index is the AVAILABLE set.** A version on its walk, held or refused is not listed,
//!   and its `.crate` is not served.
//! * **Yank names AVAILABLE versions only** (`holger-cargo-3`). A yank is audited and recorded
//!   (append only); a yanked version stays downloadable, as cargo expects (a lock file names
//!   it). A sealed version is never published again (`holger-intake-10`).
//!
//! The registry semantics are cargo's (the sparse protocol, RFC 2789, and the web API); the code
//! is Holger's own.

use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::Mutex;

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

use crate::lock::LockOrRecover as _;

// ── the refusals ────────────────────────────────────────────────────────────

/// Why the cargo door refused. Every variant leaves the chain as it was.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CargoError {
    /// The body is not one `cargo publish` of one crate.
    Publish(String),
    /// The crate is not what its publish says it is.
    Crate(String),
    /// A yank or unyank the door does not make.
    Yank { status: u16, why: String },
}

impl CargoError {
    /// The frozen `holger-cargo-<n>` code.
    pub fn code(&self) -> &'static holger_errcode::ErrCode {
        match self {
            CargoError::Publish(_) => &holger_errcode::CARGO_PUBLISH_INVALID,
            CargoError::Crate(_) => &holger_errcode::CARGO_CRATE_MISMATCH,
            CargoError::Yank { .. } => &holger_errcode::CARGO_YANK_REFUSED,
        }
    }

    pub fn tagged(&self) -> String {
        self.code().tag(self)
    }

    pub fn status(&self) -> u16 {
        match self {
            CargoError::Publish(_) | CargoError::Crate(_) => 400,
            CargoError::Yank { status, .. } => *status,
        }
    }
}

impl std::fmt::Display for CargoError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            CargoError::Publish(why) => write!(f, "not a cargo publish of one crate: {why}"),
            CargoError::Crate(why) => {
                write!(f, "the published crate is not what its publish says: {why}")
            }
            CargoError::Yank { why, .. } => write!(f, "yank refused: {why}"),
        }
    }
}

impl std::error::Error for CargoError {}

// ── names, paths, coordinates ───────────────────────────────────────────────

/// A crate name as cargo accepts one (an ASCII letter, then alphanumerics, `-`, `_`; 1..=64).
pub fn valid_name(name: &str) -> bool {
    !name.is_empty()
        && name.len() <= 64
        && name.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
        && name
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}

/// The sparse index path of a crate (`1/a`, `2/ab`, `3/a/abc`, `se/rd/serde`), lower-cased.
pub fn index_path(name: &str) -> String {
    let n = name.to_ascii_lowercase();
    match n.len() {
        1 => format!("1/{n}"),
        2 => format!("2/{n}"),
        3 => format!("3/{}/{n}", &n[..1]),
        _ => format!("{}/{}/{n}", &n[..2], &n[2..4]),
    }
}

/// The crate name an index path names, when the path is the one cargo would ask.
pub fn name_of_index_path(path: &str) -> Option<String> {
    let path = path.trim_start_matches('/');
    let name = path.rsplit('/').next()?;
    (valid_name(name) && index_path(name) == path).then(|| name.to_ascii_lowercase())
}

/// The coordinate a crate stands at below `prefix` (the cargo door's name, a pull-through
/// source's): `<prefix>/<name>/<name>-<version>.crate`, the name lower-cased.
pub fn id(prefix: &str, name: &str, version: &str) -> ArtifactId {
    let n = name.to_ascii_lowercase();
    ArtifactId {
        namespace: None,
        name: format!("{prefix}/{n}/{n}-{version}.crate"),
        version: String::new(),
    }
}

/// The (name, version) a coordinate below `prefix` is the crate of.
pub fn coords_of(prefix: &str, id: &ArtifactId) -> Option<(String, String)> {
    if !id.version.is_empty() || id.namespace.as_deref().is_some_and(|n| !n.is_empty()) {
        return None;
    }
    let rest = id.name.strip_prefix(prefix)?.strip_prefix('/')?;
    let (name, file) = rest.split_once('/')?;
    let version = file
        .strip_suffix(".crate")?
        .strip_prefix(name)?
        .strip_prefix('-')?;
    (valid_name(name) && !version.is_empty() && !file.contains('/'))
        .then(|| (name.to_string(), version.to_string()))
}

// ── the wire ────────────────────────────────────────────────────────────────

/// What a request below the door asks for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Route {
    /// `index/config.json`.
    Config,
    /// `index/<path>`: one crate's index file.
    Index(String),
    /// `crates/<name>/<version>/download`.
    Download { name: String, version: String },
    /// `api/v1/crates/new`.
    Publish,
    /// `api/v1/crates/<name>/<version>/yank`.
    Yank { name: String, version: String },
    /// `api/v1/crates/<name>/<version>/unyank`.
    Unyank { name: String, version: String },
    /// Anything else.
    Other(String),
}

/// The route of `sub`, the path below `/<door>` (its query dropped).
pub fn route(sub: &str) -> Route {
    let path = sub.split('?').next().unwrap_or(sub).trim_start_matches('/');
    let segs: Vec<&str> = path.split('/').collect();
    match segs.as_slice() {
        ["index", "config.json"] => Route::Config,
        ["index", _, ..] => match path.strip_prefix("index/").and_then(name_of_index_path) {
            Some(name) => Route::Index(name),
            None => Route::Other(path.to_string()),
        },
        ["crates", name, version, "download"] if valid_name(name) => Route::Download {
            name: name.to_ascii_lowercase(),
            version: version.to_string(),
        },
        ["api", "v1", "crates", "new"] => Route::Publish,
        ["api", "v1", "crates", name, version, "yank"] if valid_name(name) => Route::Yank {
            name: name.to_ascii_lowercase(),
            version: version.to_string(),
        },
        ["api", "v1", "crates", name, version, "unyank"] if valid_name(name) => Route::Unyank {
            name: name.to_ascii_lowercase(),
            version: version.to_string(),
        },
        _ => Route::Other(path.to_string()),
    }
}

/// **The `config.json` the door serves**: downloads and the web API under the door itself,
/// `auth-required` when reading the door needs a credential.
pub fn config_json(base: &str, door: &str, auth_required: bool) -> Vec<u8> {
    let at = format!("{}/{door}", base.trim_end_matches('/'));
    let mut v = json!({ "dl": format!("{at}/crates"), "api": at });
    if auth_required {
        v["auth-required"] = Value::Bool(true);
    }
    v.to_string().into_bytes()
}

/// The answer cargo prints for a refusal: `{"errors":[{"detail": …}]}`.
pub fn errors_doc(detail: &str) -> Value {
    json!({ "errors": [ { "detail": detail } ] })
}

/// The answer to a publish taken into the chain: cargo prints each `other` warning.
pub fn published_doc(name: &str, version: &str, sha256: &str) -> Value {
    json!({ "warnings": { "invalid_categories": [], "invalid_badges": [], "other": [
        format!("Holger: {name}@{version} is INGESTED into the intake chain (sha256 {sha256}); \
                 it is served once it is AVAILABLE: scanned, checked against the policy and approved")
    ] } })
}

// ── the publish ─────────────────────────────────────────────────────────────

/// One `cargo publish`, read.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Publish {
    pub name: String,
    pub version: String,
    pub crate_bytes: Vec<u8>,
}

/// The largest publish metadata read (cargo's is a few KiB).
pub const MAX_METADATA: usize = 1 << 20;

/// **Read the body `cargo publish` PUTs** to `api/v1/crates/new`: `[u32 LE json length][json
/// metadata][u32 LE crate length][.crate]`, nothing after it; the metadata names the crate
/// (`name`, `vers`).
pub fn parse_publish(body: &[u8]) -> Result<Publish, CargoError> {
    let bad = |why: &str| CargoError::Publish(why.to_string());
    let u32_at = |at: usize| -> Result<usize, CargoError> {
        body.get(at..at + 4)
            .and_then(|b| <[u8; 4]>::try_from(b).ok())
            .map(|b| u32::from_le_bytes(b) as usize)
            .ok_or_else(|| bad("the body is shorter than its length prefixes"))
    };
    let json_len = u32_at(0)?;
    if json_len > MAX_METADATA {
        return Err(bad("the metadata is larger than 1 MiB"));
    }
    let json_end = 4usize
        .checked_add(json_len)
        .filter(|e| *e <= body.len())
        .ok_or_else(|| bad("the body is truncated in its metadata"))?;
    let meta: Value = serde_json::from_slice(&body[4..json_end])
        .map_err(|e| CargoError::Publish(format!("the metadata is not JSON: {e}")))?;
    let crate_len = u32_at(json_end)?;
    let start = json_end + 4;
    let end = start
        .checked_add(crate_len)
        .filter(|e| *e <= body.len())
        .ok_or_else(|| bad("the body is truncated in its crate"))?;
    if end != body.len() {
        return Err(bad("the body carries bytes after its crate"));
    }
    let field = |k: &str| meta.get(k).and_then(Value::as_str).unwrap_or("");
    let (name, version) = (field("name"), field("vers"));
    if !valid_name(name) {
        return Err(CargoError::Publish(format!("'{name}' is not a crate name")));
    }
    if !super::npm::valid_version(version) {
        return Err(CargoError::Publish(format!(
            "'{version}' is not a semantic version"
        )));
    }
    if crate_len == 0 {
        return Err(bad("the body carries no crate"));
    }
    Ok(Publish {
        name: name.to_string(),
        version: version.to_string(),
        crate_bytes: body[start..end].to_vec(),
    })
}

/// **The publish's crate is the crate it says** (`holger-cargo-2`): the row the scan worker
/// read out of it names the same crate and version.
pub fn check_row(p: &Publish, row: Option<&holger_index_rows::IndexRow>) -> Result<(), CargoError> {
    match row {
        Some(holger_index_rows::IndexRow::Crate { name, version, .. })
            if name.eq_ignore_ascii_case(&p.name) && *version == p.version =>
        {
            Ok(())
        }
        Some(holger_index_rows::IndexRow::Crate { name, version, .. }) => {
            Err(CargoError::Crate(format!(
                "its Cargo.toml names {name} {version}, the publish names {} {}",
                p.name, p.version
            )))
        }
        _ => Err(CargoError::Crate(format!(
            "it does not read as a crate: no {}-{}/Cargo.toml naming the package",
            p.name, p.version
        ))),
    }
}

// ── the yank record ─────────────────────────────────────────────────────────

/// One yank or unyank, as recorded.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct YankRecord {
    pub version: String,
    pub yanked: bool,
    pub actor: String,
    pub at: i64,
    /// Made by a Repository Admin (SEC-REVIEW 2026-11-01 P12): only an Admin undoes it.
    #[serde(default)]
    pub by_admin: bool,
}

/// **The yank record**, one JSON-lines file per crate (`lineage/cargo-yanks/<name>.jsonl`),
/// append only.
#[derive(Debug)]
pub struct YankLog {
    root: PathBuf,
    lock: Mutex<()>,
}

impl YankLog {
    pub fn new(root: impl Into<PathBuf>) -> Self {
        Self {
            root: root.into(),
            lock: Mutex::new(()),
        }
    }

    fn file(&self, name: &str) -> PathBuf {
        self.root
            .join(format!("{}.jsonl", name.to_ascii_lowercase()))
    }

    pub fn append(&self, name: &str, rec: &YankRecord) -> std::io::Result<()> {
        use std::io::Write as _;
        let _held = self.lock.lock_or_recover();
        std::fs::create_dir_all(&self.root)?;
        let mut f = std::fs::OpenOptions::new()
            .create(true)
            .append(true)
            .open(self.file(name))?;
        let mut line = serde_json::to_vec(rec).map_err(std::io::Error::other)?;
        line.push(b'\n');
        f.write_all(&line)?;
        f.sync_data()
    }

    /// Every record of `name`, oldest first.
    pub fn records(&self, name: &str) -> Vec<YankRecord> {
        std::fs::read_to_string(self.file(name))
            .unwrap_or_default()
            .lines()
            .filter_map(|l| serde_json::from_str(l).ok())
            .collect()
    }

    /// The versions of `name` yanked now (the last record of each says so).
    pub fn yanked(&self, name: &str) -> BTreeMap<String, bool> {
        let mut out = BTreeMap::new();
        for r in self.records(name) {
            out.insert(r.version, r.yanked);
        }
        out
    }
}

/// **May this caller yank or unyank?** The Importer (who publishes) or the Repository Admin.
pub fn may_yank(
    auth: &crate::auth::AuthConfig,
    identity: Option<&crate::auth::AuthIdentity>,
) -> Result<(), super::duties::DutyError> {
    use super::duties::{duties_of, principal_of, Duty, DutyError};
    if !auth.duties_enabled() {
        return Ok(());
    }
    let held = duties_of(auth, identity);
    if held.contains(&Duty::Importer) || held.contains(&Duty::RepositoryAdmin) {
        return Ok(());
    }
    Err(DutyError::NotPermitted {
        principal: principal_of(identity),
        held: held.into_iter().collect(),
        verb: "yank or unyank a crate version".into(),
        needs: vec![Duty::Importer, Duty::RepositoryAdmin],
    })
}

/// **Does this caller yank as a Repository Admin?** (SEC-REVIEW 2026-11-01 P12): an Admin
/// yanks any crate and undoes any yank; without duties configured every caller is one.
pub fn yanks_as_admin(
    auth: &crate::auth::AuthConfig,
    identity: Option<&crate::auth::AuthIdentity>,
) -> bool {
    use super::duties::{duties_of, Duty};
    !auth.duties_enabled() || duties_of(auth, identity).contains(&Duty::RepositoryAdmin)
}

/// **One crate's index file** from its AVAILABLE versions: `(version, line from the row,
/// sealed sha256)`, each line's `cksum` the sealed bytes' and `yanked` the record's. Sorted by
/// version.
pub fn index_file(
    versions: &mut [(String, Value, String)],
    yanked: &BTreeMap<String, bool>,
) -> String {
    versions.sort_by(|a, b| super::npm::compare_versions(&a.0, &b.0));
    let mut out = String::new();
    for (version, line, sha256) in versions.iter() {
        let mut l = line.clone();
        l["cksum"] = Value::String(sha256.clone());
        l["yanked"] = Value::Bool(yanked.get(version).copied().unwrap_or(false));
        out.push_str(&l.to_string());
        out.push('\n');
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    fn body(meta: &str, krate: &[u8]) -> Vec<u8> {
        let mut b = Vec::new();
        b.extend_from_slice(&(meta.len() as u32).to_le_bytes());
        b.extend_from_slice(meta.as_bytes());
        b.extend_from_slice(&(krate.len() as u32).to_le_bytes());
        b.extend_from_slice(krate);
        b
    }

    #[test]
    fn a_cargo_publish_body_reads_and_a_bad_one_is_refused_by_name() {
        let p = parse_publish(&body(r#"{"name":"demo-crate","vers":"0.1.0"}"#, b"CRATE")).unwrap();
        assert_eq!(
            (p.name.as_str(), p.version.as_str()),
            ("demo-crate", "0.1.0")
        );
        assert_eq!(p.crate_bytes, b"CRATE");
        let mut trailing = body(r#"{"name":"demo","vers":"0.1.0"}"#, b"C");
        trailing.push(0);
        for (b, why) in [
            (vec![1, 0], "shorter"),
            (body("{", b"C"), "not JSON"),
            (
                body(r#"{"name":"1bad","vers":"0.1.0"}"#, b"C"),
                "not a crate name",
            ),
            (
                body(r#"{"name":"demo","vers":"1.0"}"#, b"C"),
                "not a semantic version",
            ),
            (body(r#"{"name":"demo","vers":"0.1.0"}"#, b""), "no crate"),
            (trailing, "after its crate"),
        ] {
            let e = parse_publish(&b).expect_err(why);
            assert!(
                e.tagged().starts_with("[holger-cargo-1]") && e.to_string().contains(why),
                "{e}"
            );
        }
    }

    #[test]
    fn the_routes_are_cargos() {
        assert_eq!(route("/index/config.json"), Route::Config);
        assert_eq!(route("/index/se/rd/serde"), Route::Index("serde".into()));
        assert_eq!(
            route("/index/xx/rd/serde"),
            Route::Other("index/xx/rd/serde".into())
        );
        assert_eq!(
            route("/crates/Serde/1.0.0/download"),
            Route::Download {
                name: "serde".into(),
                version: "1.0.0".into()
            }
        );
        assert_eq!(route("/api/v1/crates/new"), Route::Publish);
        assert_eq!(
            route("/api/v1/crates/serde/1.0.0/yank"),
            Route::Yank {
                name: "serde".into(),
                version: "1.0.0".into()
            }
        );
        assert_eq!(
            route("/api/v1/crates/serde/1.0.0/unyank"),
            Route::Unyank {
                name: "serde".into(),
                version: "1.0.0".into()
            }
        );
    }

    #[test]
    fn a_coordinate_reads_back_and_the_config_names_the_door() {
        let i = id("crates", "Cfg-If", "1.0.5");
        assert_eq!(i.name, "crates/cfg-if/cfg-if-1.0.5.crate");
        assert_eq!(
            coords_of("crates", &i),
            Some(("cfg-if".into(), "1.0.5".into()))
        );
        assert_eq!(coords_of("other", &i), None);
        let c: Value =
            serde_json::from_slice(&config_json("https://repo.example/", "crates", false)).unwrap();
        assert_eq!(
            c,
            json!({ "dl": "https://repo.example/crates/crates", "api": "https://repo.example/crates" })
        );
        let c: Value = serde_json::from_slice(&config_json("https://r", "crates", true)).unwrap();
        assert_eq!(c["auth-required"], true);
    }

    #[test]
    fn the_index_file_carries_the_sealed_cksum_and_the_yank_record() {
        let line = |v: &str| json!({ "name": "demo", "vers": v, "deps": [], "features": {} });
        let mut versions = vec![
            ("0.10.0".to_string(), line("0.10.0"), "b".repeat(64)),
            ("0.2.0".to_string(), line("0.2.0"), "a".repeat(64)),
        ];
        let yanked = BTreeMap::from([("0.2.0".to_string(), true)]);
        let text = index_file(&mut versions, &yanked);
        let lines: Vec<Value> = text
            .lines()
            .map(|l| serde_json::from_str(l).unwrap())
            .collect();
        assert_eq!(lines[0]["vers"], "0.2.0", "sorted by version, not as text");
        assert_eq!(lines[0]["yanked"], true);
        assert_eq!(lines[0]["cksum"], "a".repeat(64));
        assert_eq!(lines[1]["yanked"], false);
    }

    #[test]
    fn a_publish_whose_crate_names_another_crate_is_refused() {
        let p = Publish {
            name: "demo".into(),
            version: "0.1.0".into(),
            crate_bytes: vec![],
        };
        let row = |n: &str, v: &str| holger_index_rows::IndexRow::Crate {
            name: n.into(),
            version: v.into(),
            line: json!({ "name": n, "vers": v }),
            pinned: vec![],
        };
        check_row(&p, Some(&row("Demo", "0.1.0"))).unwrap();
        for r in [
            Some(row("other", "0.1.0")),
            Some(row("demo", "0.2.0")),
            None,
        ] {
            let e = check_row(&p, r.as_ref()).unwrap_err();
            assert!(e.tagged().starts_with("[holger-cargo-2]"), "{e}");
        }
    }
}