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}");
}
}
}