Skip to main content

max / makenotwork

8.2 KB · 229 lines History Blame Raw
1 //! Talking to crates.io: what is already published, and what the registry will
2 //! refuse before `cargo publish` gets there.
3
4 use anyhow::{Context as _, Result};
5
6 /// A crate's publish-relevant metadata, read from `cargo metadata`.
7 #[derive(Debug, Clone)]
8 pub(super) struct CrateMeta {
9 pub name: String,
10 pub version: String,
11 pub repository: Option<String>,
12 pub description: Option<String>,
13 pub licensed: bool,
14 }
15
16 /// Parse the fields that matter for publishing out of `cargo metadata` JSON.
17 pub(super) fn crate_meta_from_json(raw: &str) -> Result<CrateMeta> {
18 let v: serde_json::Value = serde_json::from_str(raw).context("parsing cargo metadata")?;
19 let p = v
20 .get("packages")
21 .and_then(|p| p.as_array())
22 .and_then(|a| a.first())
23 .context("cargo metadata reported no package")?;
24 let str_field = |k: &str| {
25 p.get(k)
26 .and_then(|x| x.as_str())
27 .filter(|s| !s.is_empty())
28 .map(str::to_string)
29 };
30 Ok(CrateMeta {
31 name: str_field("name").context("package has no name")?,
32 version: str_field("version").context("package has no version")?,
33 repository: str_field("repository"),
34 description: str_field("description"),
35 licensed: str_field("license").is_some() || str_field("license_file").is_some(),
36 })
37 }
38
39 /// Everything wrong with a crate's metadata, as messages. Empty means publishable.
40 ///
41 /// Checks only what crates.io records permanently. A published version cannot
42 /// be edited, only yanked, and yanking does not correct a wrong URL — so these
43 /// are the last moment any of it can be fixed.
44 pub(super) fn crate_publish_problems(
45 meta: &CrateMeta,
46 repo_clonable: bool,
47 published: &[String],
48 credentials_present: bool,
49 ) -> Vec<String> {
50 let mut out = Vec::new();
51 if !credentials_present {
52 out.push(
53 "no crates.io credentials on the publishing host: `cargo login` there first. \
54 Checked now rather than at the upload, so this fails in seconds instead of \
55 after a full build and verify."
56 .to_string(),
57 );
58 }
59 match &meta.repository {
60 None => out.push(
61 "no `repository` field: the crates.io page will show no source link, permanently"
62 .to_string(),
63 ),
64 Some(url) if !repo_clonable => out.push(format!(
65 "`repository` is not publicly clonable: {url} \
66 (wrong URL, or the repo is private)"
67 )),
68 Some(_) => {}
69 }
70 if meta.description.is_none() {
71 out.push("no `description`: crates.io requires one".to_string());
72 }
73 if !meta.licensed {
74 out.push("no `license` or `license-file`".to_string());
75 }
76 if published.iter().any(|v| v == &meta.version) {
77 out.push(format!(
78 "version {} is already published; bump it",
79 meta.version
80 ));
81 }
82 out
83 }
84
85 /// Versions of `name` already on crates.io. A network failure yields an
86 /// empty list: preflight then cannot claim a version is a duplicate, and
87 /// `cargo publish` still refuses one, so the check degrades to advisory
88 /// rather than blocking a release on registry availability.
89 pub(super) fn published_versions(name: &str) -> Vec<String> {
90 let url = format!("https://crates.io/api/v1/crates/{name}");
91 let Ok(out) = std::process::Command::new("curl")
92 .args([
93 "-sS",
94 "--max-time",
95 "15",
96 "-H",
97 "User-Agent: bento-preflight",
98 &url,
99 ])
100 .output()
101 else {
102 return Vec::new();
103 };
104 let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
105 return Vec::new();
106 };
107 v.get("versions")
108 .and_then(|x| x.as_array())
109 .map(|a| {
110 a.iter()
111 .filter_map(|x| x.get("num").and_then(|n| n.as_str()).map(str::to_string))
112 .collect()
113 })
114 .unwrap_or_default()
115 }
116
117 #[cfg(test)]
118 mod tests {
119 use super::*;
120
121 /// The two failures that actually shipped, as regression cases.
122 #[test]
123 fn preflight_catches_a_dead_repository_url() {
124 // pter 0.1.0: repository pointed at a URL that does not exist. It
125 // published clean and the link is now permanent for that version.
126 let meta = CrateMeta {
127 name: "pter".into(),
128 version: "0.1.0".into(),
129 repository: Some("https://github.com/maxjacobson/pter".into()),
130 description: Some("d".into()),
131 licensed: true,
132 };
133 let problems = crate_publish_problems(&meta, false, &[], true);
134 assert_eq!(problems.len(), 1, "{problems:?}");
135 assert!(
136 problems[0].contains("not publicly clonable"),
137 "{problems:?}"
138 );
139
140 // Same metadata, reachable URL: nothing to report.
141 assert!(crate_publish_problems(&meta, true, &[], true).is_empty());
142 }
143
144 #[test]
145 fn preflight_requires_the_fields_crates_io_bakes_in() {
146 let bare = CrateMeta {
147 name: "x".into(),
148 version: "0.1.0".into(),
149 repository: None,
150 description: None,
151 licensed: false,
152 };
153 let problems = crate_publish_problems(&bare, false, &[], true);
154 assert_eq!(problems.len(), 3, "{problems:?}");
155 assert!(problems.iter().any(|p| p.contains("repository")));
156 assert!(problems.iter().any(|p| p.contains("description")));
157 assert!(problems.iter().any(|p| p.contains("license")));
158 }
159
160 #[test]
161 fn preflight_rejects_republishing_the_same_version() {
162 let meta = CrateMeta {
163 name: "makeover".into(),
164 version: "0.10.0".into(),
165 repository: Some("https://git.sr.ht/~maxmj/makeover".into()),
166 description: Some("d".into()),
167 licensed: true,
168 };
169 let problems =
170 crate_publish_problems(&meta, true, &["0.9.0".into(), "0.10.0".into()], true);
171 assert_eq!(problems.len(), 1, "{problems:?}");
172 assert!(problems[0].contains("already published"), "{problems:?}");
173
174 // An unreleased version against the same history is fine.
175 let mut next = meta.clone();
176 next.version = "0.11.0".into();
177 assert!(crate_publish_problems(&next, true, &["0.10.0".into()], true).is_empty());
178 }
179
180 /// Missing credentials must surface at preflight, not at the upload. The
181 /// publish step is the irreversible one and runs last, after a full build
182 /// and verify; discovering there that cargo cannot authenticate wastes the
183 /// whole run.
184 #[test]
185 fn preflight_reports_missing_credentials_up_front() {
186 let meta = CrateMeta {
187 name: "makeover".into(),
188 version: "0.11.0".into(),
189 repository: Some("https://git.sr.ht/~maxmj/makeover".into()),
190 description: Some("d".into()),
191 licensed: true,
192 };
193 // Metadata is perfect; only the token is absent.
194 let problems = crate_publish_problems(&meta, true, &[], false);
195 assert_eq!(problems.len(), 1, "{problems:?}");
196 assert!(problems[0].contains("credentials"), "{problems:?}");
197 assert!(
198 problems[0].contains("cargo login"),
199 "should say how to fix it"
200 );
201
202 // Present: nothing to report.
203 assert!(crate_publish_problems(&meta, true, &[], true).is_empty());
204 }
205
206 #[test]
207 fn crate_meta_reads_cargo_metadata_json() {
208 let raw = r#"{"packages":[{"name":"makeover","version":"0.10.0",
209 "repository":"https://git.sr.ht/~maxmj/makeover","description":"themes",
210 "license":"MIT"}]}"#;
211 let m = crate_meta_from_json(raw).unwrap();
212 assert_eq!(m.name, "makeover");
213 assert_eq!(m.version, "0.10.0");
214 assert!(m.licensed);
215 assert_eq!(
216 m.repository.as_deref(),
217 Some("https://git.sr.ht/~maxmj/makeover")
218 );
219
220 // license_file alone also counts as licensed; empty strings do not
221 // count as present.
222 let lf = r#"{"packages":[{"name":"x","version":"0.1.0","license":"",
223 "license_file":"LICENSE","description":""}]}"#;
224 let m = crate_meta_from_json(lf).unwrap();
225 assert!(m.licensed);
226 assert!(m.description.is_none());
227 }
228 }
229