//! Talking to crates.io: what is already published, and what the registry will //! refuse before `cargo publish` gets there. use anyhow::{Context as _, Result}; /// A crate's publish-relevant metadata, read from `cargo metadata`. #[derive(Debug, Clone)] pub(super) struct CrateMeta { pub name: String, pub version: String, pub repository: Option, pub description: Option, pub licensed: bool, } /// Parse the fields that matter for publishing out of `cargo metadata` JSON. pub(super) fn crate_meta_from_json(raw: &str) -> Result { let v: serde_json::Value = serde_json::from_str(raw).context("parsing cargo metadata")?; let p = v .get("packages") .and_then(|p| p.as_array()) .and_then(|a| a.first()) .context("cargo metadata reported no package")?; let str_field = |k: &str| { p.get(k) .and_then(|x| x.as_str()) .filter(|s| !s.is_empty()) .map(str::to_string) }; Ok(CrateMeta { name: str_field("name").context("package has no name")?, version: str_field("version").context("package has no version")?, repository: str_field("repository"), description: str_field("description"), licensed: str_field("license").is_some() || str_field("license_file").is_some(), }) } /// Everything wrong with a crate's metadata, as messages. Empty means publishable. /// /// Checks only what crates.io records permanently. A published version cannot /// be edited, only yanked, and yanking does not correct a wrong URL — so these /// are the last moment any of it can be fixed. pub(super) fn crate_publish_problems( meta: &CrateMeta, repo_clonable: bool, published: &[String], credentials_present: bool, ) -> Vec { let mut out = Vec::new(); if !credentials_present { out.push( "no crates.io credentials on the publishing host: `cargo login` there first. \ Checked now rather than at the upload, so this fails in seconds instead of \ after a full build and verify." .to_string(), ); } match &meta.repository { None => out.push( "no `repository` field: the crates.io page will show no source link, permanently" .to_string(), ), Some(url) if !repo_clonable => out.push(format!( "`repository` is not publicly clonable: {url} \ (wrong URL, or the repo is private)" )), Some(_) => {} } if meta.description.is_none() { out.push("no `description`: crates.io requires one".to_string()); } if !meta.licensed { out.push("no `license` or `license-file`".to_string()); } if published.iter().any(|v| v == &meta.version) { out.push(format!( "version {} is already published; bump it", meta.version )); } out } /// Versions of `name` already on crates.io. A network failure yields an /// empty list: preflight then cannot claim a version is a duplicate, and /// `cargo publish` still refuses one, so the check degrades to advisory /// rather than blocking a release on registry availability. pub(super) fn published_versions(name: &str) -> Vec { let url = format!("https://crates.io/api/v1/crates/{name}"); let Ok(out) = std::process::Command::new("curl") .args([ "-sS", "--max-time", "15", "-H", "User-Agent: bento-preflight", &url, ]) .output() else { return Vec::new(); }; let Ok(v) = serde_json::from_slice::(&out.stdout) else { return Vec::new(); }; v.get("versions") .and_then(|x| x.as_array()) .map(|a| { a.iter() .filter_map(|x| x.get("num").and_then(|n| n.as_str()).map(str::to_string)) .collect() }) .unwrap_or_default() } #[cfg(test)] mod tests { use super::*; /// The two failures that actually shipped, as regression cases. #[test] fn preflight_catches_a_dead_repository_url() { // pter 0.1.0: repository pointed at a URL that does not exist. It // published clean and the link is now permanent for that version. let meta = CrateMeta { name: "pter".into(), version: "0.1.0".into(), repository: Some("https://github.com/maxjacobson/pter".into()), description: Some("d".into()), licensed: true, }; let problems = crate_publish_problems(&meta, false, &[], true); assert_eq!(problems.len(), 1, "{problems:?}"); assert!( problems[0].contains("not publicly clonable"), "{problems:?}" ); // Same metadata, reachable URL: nothing to report. assert!(crate_publish_problems(&meta, true, &[], true).is_empty()); } #[test] fn preflight_requires_the_fields_crates_io_bakes_in() { let bare = CrateMeta { name: "x".into(), version: "0.1.0".into(), repository: None, description: None, licensed: false, }; let problems = crate_publish_problems(&bare, false, &[], true); assert_eq!(problems.len(), 3, "{problems:?}"); assert!(problems.iter().any(|p| p.contains("repository"))); assert!(problems.iter().any(|p| p.contains("description"))); assert!(problems.iter().any(|p| p.contains("license"))); } #[test] fn preflight_rejects_republishing_the_same_version() { let meta = CrateMeta { name: "makeover".into(), version: "0.10.0".into(), repository: Some("https://git.sr.ht/~maxmj/makeover".into()), description: Some("d".into()), licensed: true, }; let problems = crate_publish_problems(&meta, true, &["0.9.0".into(), "0.10.0".into()], true); assert_eq!(problems.len(), 1, "{problems:?}"); assert!(problems[0].contains("already published"), "{problems:?}"); // An unreleased version against the same history is fine. let mut next = meta.clone(); next.version = "0.11.0".into(); assert!(crate_publish_problems(&next, true, &["0.10.0".into()], true).is_empty()); } /// Missing credentials must surface at preflight, not at the upload. The /// publish step is the irreversible one and runs last, after a full build /// and verify; discovering there that cargo cannot authenticate wastes the /// whole run. #[test] fn preflight_reports_missing_credentials_up_front() { let meta = CrateMeta { name: "makeover".into(), version: "0.11.0".into(), repository: Some("https://git.sr.ht/~maxmj/makeover".into()), description: Some("d".into()), licensed: true, }; // Metadata is perfect; only the token is absent. let problems = crate_publish_problems(&meta, true, &[], false); assert_eq!(problems.len(), 1, "{problems:?}"); assert!(problems[0].contains("credentials"), "{problems:?}"); assert!( problems[0].contains("cargo login"), "should say how to fix it" ); // Present: nothing to report. assert!(crate_publish_problems(&meta, true, &[], true).is_empty()); } #[test] fn crate_meta_reads_cargo_metadata_json() { let raw = r#"{"packages":[{"name":"makeover","version":"0.10.0", "repository":"https://git.sr.ht/~maxmj/makeover","description":"themes", "license":"MIT"}]}"#; let m = crate_meta_from_json(raw).unwrap(); assert_eq!(m.name, "makeover"); assert_eq!(m.version, "0.10.0"); assert!(m.licensed); assert_eq!( m.repository.as_deref(), Some("https://git.sr.ht/~maxmj/makeover") ); // license_file alone also counts as licensed; empty strings do not // count as present. let lf = r#"{"packages":[{"name":"x","version":"0.1.0","license":"", "license_file":"LICENSE","description":""}]}"#; let m = crate_meta_from_json(lf).unwrap(); assert!(m.licensed); assert!(m.description.is_none()); } }