crates/nornir-release-core/src/registry.rs
//! Registry-state check for `release doctor --publish` (release-improvements
//! item #10).
//!
//! Today's preflight verify-BUILDS a crate against its currently-published
//! siblings (check 3), but it never asks the registry the ONE direct question:
//! *"what version is actually published?"*. So a local-vs-published version
//! DESYNC slips through: the local tree carries `gatling 0.1.4` while crates.io
//! already serves `0.1.6` (a teammate's / a prior partial cascade's upload).
//! A dependent then verify-builds against the NEWER published `gatling` API and
//! breaks — and the cascade tears mid-flight, leaving some crates live-and-
//! immutable and the rest unshipped.
//!
//! This module adds a PLUGGABLE [`RegistryBackend`] answering exactly that
//! question (the max published, non-yanked version per crate), plus a pure
//! [`classify`] that maps local-vs-published into a [`RegistryState`]. Preflight
//! turns a [`RegistryState::Desync`] into a blocking issue and feeds it into the
//! topological root-cause report, so a desync is blamed at its root.
//!
//! Backends (all best-effort — an unreachable registry degrades to "unknown",
//! never a false block):
//! * [`CratesIoBackend`] (DEFAULT) — the crates.io SPARSE INDEX
//! (`https://index.crates.io/<path>/<crate>`), falling back to the JSON API
//! (`https://crates.io/api/v1/crates/<crate>`). crates.io returns HTTP 403
//! to a request with NO `User-Agent`, so a descriptive UA is baked in.
//! * [`CustomBackend`] — a configurable sparse-index base URL, so the operator
//! can point the check at a private registry / holger mirror.
//!
//! Uses the already-vendored pure-Rust `ureq` client (the same one the publish
//! flow's `wait_for_index` uses) — no reqwest/openssl/native-tls, matching the
//! constellation's pure-Rust charter.
use std::cmp::Ordering;
use std::time::Duration;
/// The ONE canonical crates.io User-Agent for every release HTTP call (index poll,
/// probe, post-publish index wait). crates.io 403s a request that sends none, and its
/// crawler policy asks for a contact address. Defined here — beside the only pure-read
/// registry client — and re-exported by the conductor's pacing module, so the two
/// divergent strings (`"nordisk-release …"` vs `"nornir-release-bot/0.1 …"`) can never
/// re-fork.
pub const RELEASE_USER_AGENT: &str = "nordisk-release (+https://git.gunnar.rs/nordisk/edda)";
/// Descriptive User-Agent — crates.io 403s a request that sends none. ONE source of
/// truth: the canonical release UA (reconciling `publish.rs` + `registry.rs`).
const USER_AGENT: &str = RELEASE_USER_AGENT;
/// The default crates.io sparse-index base.
const CRATES_IO_SPARSE_BASE: &str = "https://index.crates.io";
/// A pluggable registry backend answering the question preflight was missing:
/// the MAX published (non-yanked) version of a crate.
pub trait RegistryBackend {
/// Max published, non-yanked version of `krate`, or `None` when the crate is
/// unpublished OR the registry is unreachable/unparsable (best-effort — a
/// probe miss becomes [`RegistryState::Unpublished`]/[`Unknown`], never a
/// false block).
///
/// [`Unknown`]: RegistryState::Unknown
fn published_max_version(&self, krate: &str) -> Option<String>;
/// Is `version` of `krate` currently AVAILABLE (published, non-yanked) on this
/// registry? `Some(true)`/`Some(false)` when the crate's index could be read
/// and parsed; `None` when the registry is unreachable/unparsable — INCONCLUSIVE,
/// which callers MUST degrade safely on (never a hard-fail on a `None`).
///
/// This backs the cross-repo lock reconcile's SKIP-IF-LIVE rule: a locked pin
/// whose version is still available on the target registry is publishable as-is
/// and must NOT be force-moved (the real crates.io path, where every published
/// version stays live). A shadowed/absent version (`Some(false)` — the Gate-B
/// `/sparring` rehearsal, where a local publish hides the crates.io pull-through
/// of the old version) genuinely needs moving to the shipped one.
fn has_version(&self, krate: &str, version: &str) -> Option<bool>;
/// EVERY published, non-yanked version of `krate`, or an empty vec when the crate
/// is unpublished OR the registry is unreachable/unparsable (best-effort, exactly
/// like [`published_max_version`](RegistryBackend::published_max_version) — a probe
/// miss must never become a false block).
///
/// `has_version` answers "is THIS one there?" and `published_max_version` answers
/// "what is the newest?"; neither can answer **"what is the LOWEST version that
/// satisfies a declared requirement?"** — and that is the question a
/// minimum-requirement gate has to ask. Measured 2026-08-03: seven crates shipped
/// declaring a sibling requirement that did not contain the API they call
/// (`znippy-zip-core`, `lgz`, `ljar`, `lzip-parallel` all declared
/// `linflate = "0.1.9"` while calling `clamp_expected_size`, which first appeared in
/// `linflate 0.1.11`). A `path = …` dep masks it completely — cargo resolves through
/// the path locally, so the workspace is green while the manifest understates its
/// own floor. Checking the published MAX cannot see it: the max does export the
/// symbol. Only the lowest admitted version can.
///
/// DEFAULTS TO EMPTY — "this backend cannot enumerate versions", which every caller
/// must read as INCONCLUSIVE and degrade on, never as "no versions exist". That is
/// deliberate: a backend that has not opted in (a test fake, a registry with no
/// listing endpoint) must not be able to manufacture a block, and an empty vec is
/// already the shape a probe miss returns.
fn published_versions(&self, _krate: &str) -> Vec<String> {
Vec::new()
}
}
/// crates.io backend (DEFAULT): sparse index first, JSON API fallback.
pub struct CratesIoBackend;
impl RegistryBackend for CratesIoBackend {
fn published_max_version(&self, krate: &str) -> Option<String> {
// 1. SPARSE INDEX (preferred — cheap, cache-friendly, one crate per file).
let url = sparse_index_url(CRATES_IO_SPARSE_BASE, krate);
if let Some(body) = http_get(&url) {
if let Some(v) = parse_sparse_index_max_version(&body) {
return Some(v);
}
}
// 2. JSON API fallback (needs the UA or crates.io answers 403).
let url = format!("https://crates.io/api/v1/crates/{krate}");
let body = http_get(&url)?;
parse_json_api_max_version(&body)
}
fn has_version(&self, krate: &str, version: &str) -> Option<bool> {
// SPARSE INDEX first: a 200 body lists every published version, so a hit or a
// miss is CONCLUSIVE. A transport error / 404 (`http_get` → None) falls through
// to the JSON API; if THAT also fails we return None (inconclusive), never a
// false "absent".
let url = sparse_index_url(CRATES_IO_SPARSE_BASE, krate);
if let Some(body) = http_get(&url) {
return Some(parse_sparse_index_versions(&body).iter().any(|v| v == version));
}
let url = format!("https://crates.io/api/v1/crates/{krate}");
let body = http_get(&url)?;
Some(parse_json_api_versions(&body).iter().any(|v| v == version))
}
fn published_versions(&self, krate: &str) -> Vec<String> {
// SPARSE INDEX first (same precedence as `has_version`), JSON API as fallback.
let url = sparse_index_url(CRATES_IO_SPARSE_BASE, krate);
if let Some(body) = http_get(&url) {
let vs = parse_sparse_index_versions(&body);
if !vs.is_empty() {
return vs;
}
}
let url = format!("https://crates.io/api/v1/crates/{krate}");
match http_get(&url) {
Some(body) => parse_json_api_versions(&body),
None => Vec::new(),
}
}
}
/// A custom/other backend: a configurable SPARSE-INDEX base URL (private
/// registry / holger). crates.io is the default when this is unset.
pub struct CustomBackend {
/// Sparse-index base, e.g. `https://holger.internal/index` (no trailing
/// slash needed — trimmed).
pub base_url: String,
}
impl RegistryBackend for CustomBackend {
fn published_max_version(&self, krate: &str) -> Option<String> {
let url = sparse_index_url(self.base_url.trim_end_matches('/'), krate);
let body = http_get(&url)?;
parse_sparse_index_max_version(&body)
}
fn has_version(&self, krate: &str, version: &str) -> Option<bool> {
// The served `/sparring` index (rehearsal): a fetchable body enumerates exactly
// what the index serves, so a shadowed old version is CONCLUSIVELY absent. An
// unreachable index → None (inconclusive), degrade-safe.
let url = sparse_index_url(self.base_url.trim_end_matches('/'), krate);
let body = http_get(&url)?;
Some(parse_sparse_index_versions(&body).iter().any(|v| v == version))
}
fn published_versions(&self, krate: &str) -> Vec<String> {
let url = sparse_index_url(self.base_url.trim_end_matches('/'), krate);
match http_get(&url) {
Some(body) => parse_sparse_index_versions(&body),
None => Vec::new(),
}
}
}
/// The resolved backend choice — the pure decision behind [`resolve`], split out
/// so the precedence rule is unit-testable without constructing trait objects.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BackendChoice {
/// crates.io (sparse index + JSON fallback).
CratesIo,
/// A custom sparse-index base URL.
Custom(String),
}
/// Pure precedence rule: an optional `--registry-url` CLI override ALWAYS wins
/// (→ custom); otherwise `backend = "custom"` with a non-empty `url` selects
/// custom, and everything else (including the default `crates-io`, and a
/// `custom` with an empty url) selects crates.io.
pub fn choose(backend: &str, url: &str, flag_url: Option<&str>) -> BackendChoice {
if let Some(u) = flag_url.filter(|u| !u.trim().is_empty()) {
return BackendChoice::Custom(u.trim().to_string());
}
match backend {
"custom" if !url.trim().is_empty() => BackendChoice::Custom(url.trim().to_string()),
_ => BackendChoice::CratesIo,
}
}
/// Build a backend from the resolved config `[registry]` values plus an optional
/// `--registry-url` CLI override (see [`choose`] for the precedence rule).
pub fn resolve(backend: &str, url: &str, flag_url: Option<&str>) -> Box<dyn RegistryBackend> {
match choose(backend, url, flag_url) {
BackendChoice::Custom(base_url) => Box::new(CustomBackend { base_url }),
BackendChoice::CratesIo => Box::new(CratesIoBackend),
}
}
/// Build a crate's sparse-index URL under `base`, following cargo's index-path
/// convention: `1/{n}`, `2/{n}`, `3/{a}/{n}`, else `{ab}/{cd}/{n}` (crate names
/// are lower-cased for the path).
pub fn sparse_index_url(base: &str, krate: &str) -> String {
let name = krate.to_lowercase();
let path = match name.chars().count() {
0 => name.clone(),
1 => format!("1/{name}"),
2 => format!("2/{name}"),
3 => format!("3/{}/{}", &name[..1], name),
_ => format!("{}/{}/{}", &name[..2], &name[2..4], name),
};
format!("{base}/{path}")
}
/// Parse the crates.io SPARSE-INDEX body — newline-delimited JSON, one object
/// per published version (`vers`, `yanked`). Returns the semver-MAX of the
/// NON-YANKED versions (robust to out-of-order patch backports; the last
/// non-yanked line is the common case). `None` when nothing parses / all yanked.
pub fn parse_sparse_index_max_version(body: &str) -> Option<String> {
let mut best: Option<(semver::Version, String)> = None;
for line in body.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if v.get("yanked").and_then(|y| y.as_bool()).unwrap_or(false) {
continue;
}
let Some(vers) = v.get("vers").and_then(|s| s.as_str()) else {
continue;
};
let Ok(parsed) = semver::Version::parse(vers) else {
continue;
};
if best.as_ref().map(|(b, _)| parsed > *b).unwrap_or(true) {
best = Some((parsed, vers.to_string()));
}
}
best.map(|(_, raw)| raw)
}
/// Every published, NON-YANKED version in a sparse-index body (newline-delimited
/// JSON, one object per version). Order as read; yanked and unparsable lines are
/// dropped. Used by [`RegistryBackend::has_version`]'s exact-version presence check
/// (a yanked version can no longer be selected, so it counts as absent).
pub fn parse_sparse_index_versions(body: &str) -> Vec<String> {
let mut out = Vec::new();
for line in body.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if v.get("yanked").and_then(|y| y.as_bool()).unwrap_or(false) {
continue;
}
if let Some(vers) = v.get("vers").and_then(|s| s.as_str()) {
out.push(vers.to_string());
}
}
out
}
/// Every published, NON-YANKED `num` in a JSON-API body (the `has_version` fallback
/// when the sparse index is unreachable). Order as read; yanked/unparsable dropped.
pub fn parse_json_api_versions(body: &str) -> Vec<String> {
let Ok(json) = serde_json::from_str::<serde_json::Value>(body) else {
return Vec::new();
};
let Some(arr) = json.get("versions").and_then(|v| v.as_array()) else {
return Vec::new();
};
arr.iter()
.filter(|e| !e.get("yanked").and_then(|y| y.as_bool()).unwrap_or(false))
.filter_map(|e| e.get("num").and_then(|n| n.as_str()).map(str::to_string))
.collect()
}
/// Parse the crates.io JSON API body (`{"versions":[{"num","yanked"},…],
/// "crate":{"max_stable_version"}}`). Semver-max of the non-yanked `num`s;
/// falls back to `crate.max_stable_version` / `crate.max_version`.
pub fn parse_json_api_max_version(body: &str) -> Option<String> {
let json: serde_json::Value = serde_json::from_str(body).ok()?;
if let Some(arr) = json.get("versions").and_then(|v| v.as_array()) {
let mut best: Option<(semver::Version, String)> = None;
for e in arr {
if e.get("yanked").and_then(|y| y.as_bool()).unwrap_or(false) {
continue;
}
let Some(num) = e.get("num").and_then(|n| n.as_str()) else {
continue;
};
let Ok(parsed) = semver::Version::parse(num) else {
continue;
};
if best.as_ref().map(|(b, _)| parsed > *b).unwrap_or(true) {
best = Some((parsed, num.to_string()));
}
}
if best.is_some() {
return best.map(|(_, raw)| raw);
}
}
json.get("crate")
.and_then(|c| c.get("max_stable_version").or_else(|| c.get("max_version")))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
/// How a crate's LOCAL version relates to what the registry has published.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RegistryState {
/// local > published — the bump leads the registry → WILL PUBLISH (good).
WillPublish,
/// local == published — nothing new to ship; the cascade will SKIP it.
AlreadyPublished,
/// local < published — the LOCAL tree is BEHIND the registry. THE BUG: a
/// dependent verify-builds against the newer published API and breaks. A
/// blocker.
Desync,
/// The registry has no published version — this is a first publish.
Unpublished,
/// A version string didn't parse (local or published) — degrade to unknown,
/// never a false block.
Unknown,
}
/// Pure local-vs-published classification. `published = None` ⇒ the crate is
/// [`Unpublished`](RegistryState::Unpublished) (first publish). An unparsable
/// version ⇒ [`Unknown`](RegistryState::Unknown).
pub fn classify(local: &str, published: Option<&str>) -> RegistryState {
let Some(published) = published else {
return RegistryState::Unpublished;
};
let (Ok(l), Ok(p)) = (semver::Version::parse(local), semver::Version::parse(published))
else {
return RegistryState::Unknown;
};
match l.cmp(&p) {
Ordering::Greater => RegistryState::WillPublish,
Ordering::Equal => RegistryState::AlreadyPublished,
Ordering::Less => RegistryState::Desync,
}
}
/// A live probe over any [`RegistryBackend`]: fetch the published max version and
/// classify `local` against it. Handy for callers that only need the verdict.
pub fn state_of(backend: &dyn RegistryBackend, krate: &str, local: &str) -> RegistryState {
classify(local, backend.published_max_version(krate).as_deref())
}
/// A ureq 3 agent with ureq 2's defaults, so the bump changes no behaviour:
/// ureq 2 (built without `proxy-from-env`) never read `HTTP(S)_PROXY` and
/// followed at most 5 redirects; ureq 3 reads the environment and allows 10.
/// (ureq 2 -> 3 bump, 2026-10-02.)
fn http_agent(timeout: Duration) -> ureq::Agent {
ureq::Agent::config_builder()
.timeout_global(Some(timeout))
.proxy(None)
.max_redirects(5)
.build()
.new_agent()
}
/// Thin ureq GET with the baked-in UA + a 10s timeout, body as a `String`.
/// `None` on any transport/decoding error (best-effort probe).
fn http_get(url: &str) -> Option<String> {
http_agent(Duration::from_secs(10))
.get(url)
.header("User-Agent", USER_AGENT)
.call()
.ok()?
.into_body()
.read_to_string()
.ok()
}
// ─────────────────── crate-name OWNERSHIP preflight ───────────────────
//
// Publishing a crate whose NAME already exists on crates.io owned by someone
// ELSE returns `403 Forbidden: this crate exists but you don't seem to be an
// owner`. `cargo publish --dry-run` does NOT catch it (it never contacts the
// ownership endpoint), so a name collision (the real `gatling`/`rotary` traps —
// both squatted by third parties) only surfaced mid-cascade as a fail-stop. This
// probe answers "is this name free, or ours?" so preflight (incl. the dry-run)
// refuses BEFORE the irreversible cascade.
/// Read the crates.io API token: `$CARGO_REGISTRY_TOKEN`, else `[registry].token`
/// (or `[registries.crates-io].token`) in `~/.cargo/credentials.toml`. `None`
/// when no token is configured (→ the ownership check degrades to a warning).
pub fn read_crates_io_token() -> Option<String> {
if let Ok(t) = std::env::var("CARGO_REGISTRY_TOKEN") {
if !t.trim().is_empty() {
return Some(t);
}
}
let home = std::env::var_os("HOME")?;
let txt = std::fs::read_to_string(std::path::Path::new(&home).join(".cargo/credentials.toml"))
.ok()?;
let doc: toml::Value = txt.parse::<toml::Table>().map(toml::Value::Table).ok()?;
let pick = |t: &toml::Value| t.get("token").and_then(|v| v.as_str()).map(str::to_string);
doc.get("registry")
.and_then(pick)
.or_else(|| doc.get("registries").and_then(|r| r.get("crates-io")).and_then(pick))
}
/// Our own crates.io login, resolved from the API `token` via `GET /me`. `None`
/// if the call fails (→ the ownership check is skipped, never a false block).
pub fn crates_io_login(token: &str) -> Option<String> {
let body = http_agent(Duration::from_secs(10))
.get("https://crates.io/api/v1/me")
.header("User-Agent", USER_AGENT)
.header("Authorization", token)
.call()
.ok()?
.into_body()
.read_to_string()
.ok()?;
let json: serde_json::Value = serde_json::from_str(&body).ok()?;
json.get("user")?.get("login")?.as_str().map(str::to_string)
}
/// The owner logins of `krate` on crates.io (public endpoint, no auth). `None`
/// when the crate does not exist (free to claim) or the registry is unreachable.
pub fn crate_owner_logins(krate: &str) -> Option<Vec<String>> {
let json: serde_json::Value =
serde_json::from_str(&http_get(&format!("https://crates.io/api/v1/crates/{krate}/owners"))?)
.ok()?;
let logins: Vec<String> = json
.get("users")?
.as_array()?
.iter()
.filter_map(|u| u.get("login").and_then(|l| l.as_str()).map(str::to_string))
.collect();
(!logins.is_empty()).then_some(logins)
}
/// A name we intend to publish that ALREADY EXISTS on crates.io owned by someone
/// other than us — publishing it will 403.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OwnershipBlock {
pub krate: String,
pub owners: Vec<String>,
}
/// For each name we plan to publish, flag the ones that exist on crates.io but are
/// NOT owned by `my_login` (case-insensitive). Degrade-safe: a name that doesn't
/// exist (free) or is unreachable is NOT flagged; `my_login = None` returns empty
/// (the caller warns that the check couldn't run). Pure decision given the probe
/// via the injected `owners_of` (real calls in production, deterministic in test).
pub fn ownership_blocks_with(
names: &[String],
my_login: Option<&str>,
owners_of: impl Fn(&str) -> Option<Vec<String>>,
) -> Vec<OwnershipBlock> {
let Some(me) = my_login else { return Vec::new() };
names
.iter()
.filter_map(|n| {
let owners = owners_of(n)?; // None ⇒ free/unreachable ⇒ not a block.
(!owners.iter().any(|o| o.eq_ignore_ascii_case(me)))
.then(|| OwnershipBlock { krate: n.clone(), owners })
})
.collect()
}
/// Live wrapper over [`ownership_blocks_with`] using the real crates.io owners API.
pub fn ownership_blocks(names: &[String], my_login: Option<&str>) -> Vec<OwnershipBlock> {
ownership_blocks_with(names, my_login, crate_owner_logins)
}
#[cfg(test)]
mod tests {
use super::*;
/// The ownership gate flags ONLY names that exist on crates.io owned by
/// someone else — a free name or one we own passes; no login → skip (never a
/// false block). Deterministic via an injected owners probe (no network).
#[test]
fn ownership_blocks_flags_only_foreign_existing_names() {
let owners = |k: &str| -> Option<Vec<String>> {
match k {
"gatling" => Some(vec!["AbdelStark".to_string()]), // exists, not ours
"rotary" => Some(vec!["udoprog".to_string()]), // exists, not ours
"minigun" => None, // free to claim
"znippy-common" => Some(vec!["Nordisk".to_string(), "alice".to_string()]), // ours (ci)
_ => None,
}
};
let names: Vec<String> =
["gatling", "rotary", "minigun", "znippy-common"].iter().map(|s| s.to_string()).collect();
let flagged: Vec<String> =
ownership_blocks_with(&names, Some("alice"), owners).into_iter().map(|b| b.krate).collect();
assert_eq!(flagged, vec!["gatling", "rotary"], "only foreign existing names block");
// No login → the check can't run → nothing blocked (degrade-safe).
assert!(ownership_blocks_with(&names, None, owners).is_empty());
}
/// The sparse-index line parser: newline-delimited JSON, semver-MAX of the
/// non-yanked `vers` — the last non-yanked line in the common in-order case.
#[test]
fn sparse_index_parses_max_non_yanked_version() {
// Real crates.io shape (trimmed): three published lines, in order.
let body = concat!(
r#"{"name":"gatling","vers":"0.1.3","yanked":false}"#,
"\n",
r#"{"name":"gatling","vers":"0.1.4","yanked":false}"#,
"\n",
);
assert_eq!(parse_sparse_index_max_version(body).as_deref(), Some("0.1.4"));
}
/// A YANKED newest line must be ignored — the max is the newest NON-yanked.
#[test]
fn sparse_index_skips_yanked_and_is_order_independent() {
// 0.2.0 is yanked; lines are OUT of order — max must still be 0.1.9.
let body = concat!(
r#"{"name":"c","vers":"0.1.9","yanked":false}"#,
"\n",
r#"{"name":"c","vers":"0.2.0","yanked":true}"#,
"\n",
r#"{"name":"c","vers":"0.1.4","yanked":false}"#,
"\n",
);
assert_eq!(parse_sparse_index_max_version(body).as_deref(), Some("0.1.9"));
}
/// Blank lines, garbage lines, and an all-yanked crate degrade to `None`
/// rather than panicking or picking a yanked version.
#[test]
fn sparse_index_tolerates_junk_and_all_yanked() {
assert_eq!(parse_sparse_index_max_version(""), None);
assert_eq!(parse_sparse_index_max_version("not json\n\n{bad"), None);
let all_yanked = concat!(
r#"{"name":"z","vers":"1.0.0","yanked":true}"#,
"\n",
r#"{"name":"z","vers":"1.1.0","yanked":true}"#,
"\n",
);
assert_eq!(parse_sparse_index_max_version(all_yanked), None);
}
/// `has_version` presence check (backs the reconcile SKIP-IF-LIVE rule): the
/// full non-yanked version list from a sparse-index body, and that a yanked
/// version reads as ABSENT (it can no longer be selected).
#[test]
fn sparse_index_lists_all_non_yanked_versions_for_presence() {
let body = concat!(
r#"{"name":"nornir-testmatrix","vers":"0.2.6","yanked":false}"#,
"\n",
r#"{"name":"nornir-testmatrix","vers":"0.2.7","yanked":false}"#,
"\n",
r#"{"name":"nornir-testmatrix","vers":"0.2.5","yanked":true}"#,
"\n",
);
let vs = parse_sparse_index_versions(body);
assert_eq!(vs, vec!["0.2.6".to_string(), "0.2.7".to_string()]);
// The skade fail-stop: the LIVE locked 0.2.7 is present ⇒ never move it.
assert!(vs.iter().any(|v| v == "0.2.7"));
// A yanked version is absent (unselectable) ⇒ would need moving.
assert!(!vs.iter().any(|v| v == "0.2.5"));
// JSON-API fallback lists the same way.
let api = r#"{"versions":[{"num":"0.2.7","yanked":false},{"num":"0.2.5","yanked":true}]}"#;
assert_eq!(parse_json_api_versions(api), vec!["0.2.7".to_string()]);
}
/// JSON API fallback: semver-max of the non-yanked `num`s, then the
/// `crate.max_stable_version` field.
#[test]
fn json_api_parses_versions_then_max_stable() {
let body = r#"{
"crate": {"max_stable_version": "0.9.9"},
"versions": [
{"num": "0.1.4", "yanked": false},
{"num": "0.2.0", "yanked": true},
{"num": "0.1.9", "yanked": false}
]
}"#;
assert_eq!(parse_json_api_max_version(body).as_deref(), Some("0.1.9"));
// No versions array → fall back to crate.max_stable_version.
let only_crate = r#"{"crate": {"max_stable_version": "3.2.1"}}"#;
assert_eq!(parse_json_api_max_version(only_crate).as_deref(), Some("3.2.1"));
}
/// The classify table (release-improvements item #10, requirement 2).
#[test]
fn classify_covers_the_full_table() {
assert_eq!(classify("0.1.7", Some("0.1.4")), RegistryState::WillPublish);
assert_eq!(classify("0.1.4", Some("0.1.4")), RegistryState::AlreadyPublished);
assert_eq!(classify("0.1.4", Some("0.1.6")), RegistryState::Desync);
assert_eq!(classify("0.1.0", None), RegistryState::Unpublished);
assert_eq!(classify("not-a-semver", Some("0.1.0")), RegistryState::Unknown);
}
/// The exact incident this feature exists to catch: local `gatling` behind
/// the published registry version — DESYNC blocker.
#[test]
fn classify_flags_the_gatling_desync() {
// Local tree still at 0.1.4 while crates.io already serves 0.1.6.
assert_eq!(classify("0.1.4", Some("0.1.6")), RegistryState::Desync);
}
/// LIVE (network) smoke: the default crates.io backend resolves the real
/// published max of `gatling` (currently 0.1.4) and classifies a behind-local
/// as a desync. `#[ignore]` — run with `--ignored` when a network is present.
#[test]
#[ignore]
fn live_cratesio_backend_resolves_gatling() {
let v = CratesIoBackend.published_max_version("gatling").expect("gatling published");
eprintln!("live gatling published max = {v}");
let published = semver::Version::parse(&v).expect("valid semver");
// A local tree behind the published max is a desync (the incident class).
let behind = format!("{}.{}.{}", published.major, published.minor, published.patch.saturating_sub(1));
assert_eq!(classify(&behind, Some(&v)), RegistryState::Desync);
}
/// cargo index-path convention for the sparse URL.
#[test]
fn sparse_index_url_follows_cargo_path_convention() {
assert_eq!(sparse_index_url("https://index.crates.io", "a"), "https://index.crates.io/1/a");
assert_eq!(sparse_index_url("https://index.crates.io", "ab"), "https://index.crates.io/2/ab");
assert_eq!(sparse_index_url("https://index.crates.io", "abc"), "https://index.crates.io/3/a/abc");
assert_eq!(
sparse_index_url("https://index.crates.io", "gatling"),
"https://index.crates.io/ga/tl/gatling"
);
// Trailing slash on a custom base is trimmed by the caller (CustomBackend).
assert_eq!(sparse_index_url("https://reg/idx", "Serde"), "https://reg/idx/se/rd/serde");
}
/// `choose`: the CLI `--registry-url` flag beats config; `custom`+url selects
/// custom; default (and `custom` with an empty url) → crates.io.
#[test]
fn choose_precedence_flag_then_config_then_default() {
// Flag wins over everything (even an explicit crates-io config).
assert_eq!(
choose("crates-io", "", Some("https://flag/idx")),
BackendChoice::Custom("https://flag/idx".into())
);
// custom + url → custom.
assert_eq!(
choose("custom", "https://cfg/idx", None),
BackendChoice::Custom("https://cfg/idx".into())
);
// default (crates-io, no url, no flag) → crates.io.
assert_eq!(choose("crates-io", "", None), BackendChoice::CratesIo);
// custom but EMPTY url → falls back to crates.io.
assert_eq!(choose("custom", "", None), BackendChoice::CratesIo);
// empty/whitespace flag is ignored (falls through to config).
assert_eq!(choose("custom", "https://cfg/idx", Some(" ")), BackendChoice::Custom("https://cfg/idx".into()));
}
}
// ─── the injectable registry PROBE seam ─────────────────────────────────────
//
// Relocated here from the conductor's `preflight` (which now re-exports it): the
// probe is a pure READ over the registry, the same question this module already
// answers, and the doctor's order analysis needs it without pulling the whole
// constructive preflight engine.
/// Injectable registry probe so the version/consistency check is deterministically
/// testable without hitting crates.io. `Some(true)` = version live, `Some(false)` =
/// absent, `None` = couldn't determine (network down) → the check degrades to a note.
pub trait RegistryProbe {
fn version_live(&self, krate: &str, version: &str) -> Option<bool>;
/// Whether crates.io ALREADY serves a NON-yanked published version satisfying
/// `req` (a semver [`semver::VersionReq`] string, e.g. `"0.1"` ⇒ `^0.1`). This
/// answers "can the consumer resolve this dep from the registry as-is?" — the
/// basis for treating a cross-repo edge as non-order-gating. `Some(false)` = no
/// live version matches (must publish first); `None` = couldn't determine
/// (network / unparsable `req`) → the caller stays conservative and keeps the
/// edge gating. The default returns `None` (a probe that only knows exact-version
/// liveness cannot answer); [`CratesIoProbe`] overrides it.
fn req_satisfied(&self, _krate: &str, _req: &str) -> Option<bool> {
None
}
}
/// Live crates.io probe (sparse index) reusing the vendored `ureq` client — same
/// pattern as the release conductor's post-publish index wait.
pub struct CratesIoProbe;
impl CratesIoProbe {
/// The published versions of `krate` as `(num, yanked)`. `None` on any
/// network/parse failure (the callers degrade to a conservative answer).
fn fetch_versions(krate: &str) -> Option<Vec<(String, bool)>> {
let url = format!("https://crates.io/api/v1/crates/{krate}");
let agent = "nornir-release-bot/0.1 (cargo-pipeline; +https://crates.io)";
let resp = http_agent(std::time::Duration::from_secs(10))
.get(&url)
.header("User-Agent", agent)
.call()
.ok()?;
let body = resp.into_body().read_to_string().ok()?;
let json: serde_json::Value = serde_json::from_str(&body).ok()?;
let versions = json.get("versions").and_then(|v| v.as_array())?;
Some(
versions
.iter()
.filter_map(|e| {
let num = e.get("num").and_then(|n| n.as_str())?.to_string();
let yanked = e.get("yanked").and_then(|y| y.as_bool()).unwrap_or(false);
Some((num, yanked))
})
.collect(),
)
}
}
impl RegistryProbe for CratesIoProbe {
fn version_live(&self, krate: &str, version: &str) -> Option<bool> {
let versions = Self::fetch_versions(krate)?;
Some(versions.iter().any(|(num, _yanked)| num == version))
}
fn req_satisfied(&self, krate: &str, req: &str) -> Option<bool> {
// An unparsable requirement can't be reasoned about → conservative (gating).
let req = semver::VersionReq::parse(req).ok()?;
let versions = Self::fetch_versions(krate)?;
Some(versions.iter().any(|(num, yanked)| {
!*yanked
&& semver::Version::parse(num).map(|v| req.matches(&v)).unwrap_or(false)
}))
}
}