g1t/services/packages/src/cargo.rs

426 lines19,772 bytesCodeBlame
1//! What the Cargo registry needs that does not touch the network: crate
2//! names, where a crate's index file is, the publish body cargo sends, and
3//! the index line each version is.
4//!
5//! A version keeps its index entry (without `yanked`, which is a column of
6//! its own) as its metadata, made once when it is published; the index
7//! file is those entries, one JSON line each, oldest first.
8
9use serde_json::{Map, Value, json};
10
11/// The longest crate name crates.io allows.
12pub const MAX_NAME: usize = 64;
13
14/// Names Windows keeps for devices: a crate named so could not be checked
15/// out of a git index, and cargo refuses them too.
16const RESERVED: [&str; 22] = [
17 "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", "lpt1", "lpt2", "lpt3",
18 "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
19];
20
21/// Checks crates.io's rules for a crate name: ASCII letters, digits, `-`
22/// and `_`, starting with a letter, at most 64 characters.
23pub fn valid_name(name: &str) -> Result<(), String> {
24 if name.is_empty() {
25 return Err("The crate has no name.".to_owned());
26 }
27 if name.len() > MAX_NAME {
28 return Err(format!("A crate name is at most {MAX_NAME} characters."));
29 }
30 if !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') {
31 return Err(format!("{name} is not a valid crate name: ASCII letters, digits, `-` and `_` only."));
32 }
33 if !name.as_bytes()[0].is_ascii_alphabetic() {
34 return Err(format!("{name} is not a valid crate name: it must start with a letter."));
35 }
36 if RESERVED.contains(&name.to_ascii_lowercase().as_str()) {
37 return Err(format!("{name} is a reserved name."));
38 }
39 Ok(())
40}
41
42/// The name two crates may not share: case and `-` against `_` aside, as
43/// crates.io decides whether a name is taken.
44pub fn folded(name: &str) -> String {
45 name.to_ascii_lowercase().replace('_', "-")
46}
47
48/// Where a crate's file is in a sparse index, lowercased: `1/a`, `2/ab`,
49/// `3/a/abc`, `se/rd/serde`.
50pub fn index_path(name: &str) -> String {
51 let name = name.to_ascii_lowercase();
52 match name.len() {
53 1 => format!("1/{name}"),
54 2 => format!("2/{name}"),
55 3 => format!("3/{}/{name}", &name[..1]),
56 _ => format!("{}/{}/{name}", &name[..2], &name[2..4]),
57 }
58}
59
60/// The crate an index path names, when it is where that crate's file is.
61/// Cargo asks with the lowercased path; any case is taken.
62pub fn name_of_index_path(path: &str) -> Option<String> {
63 let name = path.rsplit('/').next()?;
64 valid_name(name).ok()?;
65 (index_path(name) == path.to_ascii_lowercase()).then(|| name.to_owned())
66}
67
68/// One of the registry's endpoints, under `/-/cargo/<workspace>/`.
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub enum CargoRoute {
71 /// `index/config.json`.
72 Config,
73 /// `index/<path>`: a crate's index file.
74 Index { name: String },
75 /// `api/v1/crates/new`.
76 Publish,
77 /// `api/v1/crates?q=`: `cargo search`.
78 Search,
79 Yank { name: String, version: String },
80 Unyank { name: String, version: String },
81 Download { name: String, version: String },
82 Owners { name: String },
83}
84
85/// The workspace and endpoint a path is. Names are checked; versions are not.
86pub fn route(path: &str) -> Option<(String, CargoRoute)> {
87 let rest = path.strip_prefix("/-/cargo/")?;
88 let (workspace, rest) = rest.split_once('/')?;
89 let workspace = workspace.to_ascii_lowercase();
90 if workspace.is_empty() {
91 return None;
92 }
93 if let Some(index) = rest.strip_prefix("index/") {
94 if index == "config.json" {
95 return Some((workspace, CargoRoute::Config));
96 }
97 return name_of_index_path(index).map(|name| (workspace, CargoRoute::Index { name }));
98 }
99 let crates = rest.strip_prefix("api/v1/crates")?;
100 if crates.is_empty() || crates == "/" {
101 return Some((workspace, CargoRoute::Search));
102 }
103 let parts: Vec<&str> = crates.strip_prefix('/')?.split('/').collect();
104 let named = |name: &str| valid_name(name).is_ok().then(|| name.to_owned());
105 let route = match parts.as_slice() {
106 ["new"] => CargoRoute::Publish,
107 [name, "owners"] => CargoRoute::Owners { name: named(name)? },
108 [name, version, action] if !version.is_empty() => {
109 let (name, version) = (named(name)?, (*version).to_owned());
110 match *action {
111 "yank" => CargoRoute::Yank { name, version },
112 "unyank" => CargoRoute::Unyank { name, version },
113 "download" => CargoRoute::Download { name, version },
114 _ => return None,
115 }
116 }
117 _ => return None,
118 };
119 Some((workspace, route))
120}
121
122/// The two parts of `cargo publish`'s body: a little-endian `u32` length
123/// and the JSON metadata, then a `u32` length and the `.crate` file.
124pub fn parse_publish(body: &[u8]) -> Result<(Value, &[u8]), String> {
125 let take = |at: usize| -> Result<(usize, usize), String> {
126 let length = body.get(at..at + 4).ok_or("The publish ends early.")?;
127 let length = u32::from_le_bytes([length[0], length[1], length[2], length[3]]) as usize;
128 let start = at + 4;
129 if body.len() < start + length {
130 return Err("The publish ends early.".to_owned());
131 }
132 Ok((start, start + length))
133 };
134 let (json_start, json_end) = take(0)?;
135 let metadata: Value = serde_json::from_slice(&body[json_start..json_end]).map_err(|_| "The publish's metadata is not JSON.")?;
136 if !metadata.is_object() {
137 return Err("The publish's metadata is not a JSON object.".to_owned());
138 }
139 let (crate_start, crate_end) = take(json_end)?;
140 if crate_end != body.len() {
141 return Err("The publish has bytes after the .crate file.".to_owned());
142 }
143 Ok((metadata, &body[crate_start..crate_end]))
144}
145
146/// The version without its build metadata: `1.0.0+abc` is `1.0.0`. Two
147/// versions that differ only in it may not both be published.
148pub fn without_build(version: &str) -> &str {
149 version.split_once('+').map_or(version, |(core, _)| core)
150}
151
152/// Whether a feature's list uses the syntax only newer cargo reads
153/// (`dep:name`, `name?/feature`), so it belongs in `features2`.
154fn new_syntax(values: &Value) -> bool {
155 values
156 .as_array()
157 .is_some_and(|values| values.iter().filter_map(Value::as_str).any(|v| v.starts_with("dep:") || v.contains("?/")))
158}
159
160/// A dependency as the index lists it, from how `cargo publish` sends it:
161/// `version_req` is `req`, and a renamed one (`explicit_name_in_toml`) is
162/// listed by its new name with `package` naming the crate.
163fn index_dependency(sent: &Value) -> Result<Value, String> {
164 let name = sent["name"].as_str().ok_or("A dependency has no name.")?;
165 let req = sent["version_req"].as_str().ok_or_else(|| format!("The dependency {name} has no version requirement."))?;
166 let mut dep = Map::new();
167 let renamed = sent["explicit_name_in_toml"].as_str().filter(|n| !n.is_empty());
168 dep.insert("name".into(), json!(renamed.unwrap_or(name)));
169 dep.insert("req".into(), json!(req));
170 dep.insert("features".into(), sent.get("features").filter(|f| f.is_array()).cloned().unwrap_or_else(|| json!([])));
171 dep.insert("optional".into(), json!(sent["optional"].as_bool().unwrap_or(false)));
172 dep.insert("default_features".into(), json!(sent["default_features"].as_bool().unwrap_or(true)));
173 dep.insert("target".into(), sent.get("target").filter(|t| t.is_string()).cloned().unwrap_or(Value::Null));
174 dep.insert("kind".into(), json!(sent["kind"].as_str().unwrap_or("normal")));
175 if let Some(registry) = sent["registry"].as_str() {
176 dep.insert("registry".into(), json!(registry));
177 }
178 if renamed.is_some() {
179 dep.insert("package".into(), json!(name));
180 }
181 Ok(Value::Object(dep))
182}
183
184/// A version's index entry, from the publish's metadata and the `.crate`
185/// file's SHA-256 in hex. `yanked` is left out: it is added on each read.
186pub fn index_entry(metadata: &Value, cksum: &str) -> Result<Value, String> {
187 let name = metadata["name"].as_str().ok_or("The publish names no crate.")?;
188 let vers = metadata["vers"].as_str().ok_or("The publish names no version.")?;
189 let deps = match &metadata["deps"] {
190 Value::Null => Vec::new(),
191 Value::Array(deps) => deps.iter().map(index_dependency).collect::<Result<Vec<_>, _>>()?,
192 _ => return Err("The publish's dependencies are not a list.".to_owned()),
193 };
194 let (mut features, mut features2) = (Map::new(), Map::new());
195 match &metadata["features"] {
196 Value::Null => {}
197 Value::Object(sent) => {
198 for (feature, values) in sent {
199 if !values.is_array() {
200 return Err(format!("The feature {feature} is not a list."));
201 }
202 let into = if new_syntax(values) { &mut features2 } else { &mut features };
203 into.insert(feature.clone(), values.clone());
204 }
205 }
206 _ => return Err("The publish's features are not an object.".to_owned()),
207 }
208 let mut entry = Map::new();
209 entry.insert("name".into(), json!(name));
210 entry.insert("vers".into(), json!(vers));
211 entry.insert("deps".into(), Value::Array(deps));
212 entry.insert("cksum".into(), json!(cksum));
213 entry.insert("features".into(), Value::Object(features));
214 entry.insert("links".into(), metadata.get("links").filter(|l| l.is_string()).cloned().unwrap_or(Value::Null));
215 if !features2.is_empty() {
216 entry.insert("features2".into(), Value::Object(features2));
217 entry.insert("v".into(), json!(2));
218 }
219 if let Some(rust_version) = metadata["rust_version"].as_str() {
220 entry.insert("rust_version".into(), json!(rust_version));
221 }
222 Ok(Value::Object(entry))
223}
224
225/// One line of a crate's index file: the version's entry as kept, with
226/// whether it is yanked.
227pub fn index_line(entry: &Value, yanked: bool) -> String {
228 let mut entry = match entry {
229 Value::Object(map) => map.clone(),
230 _ => Map::new(),
231 };
232 entry.insert("yanked".into(), json!(yanked));
233 Value::Object(entry).to_string()
234}
235
236/// What `index/config.json` says: where crates are downloaded from and the
237/// web API is, under `base` (`https://g1t.sh/-/cargo/acme`), and whether
238/// cargo must send its token for every request.
239pub fn config(base: &str, auth_required: bool) -> Value {
240 json!({ "dl": format!("{base}/api/v1/crates"), "api": base, "auth-required": auth_required })
241}
242
243/// The token in cargo's `Authorization` header, which is the token alone;
244/// `Bearer <token>` is taken too.
245pub fn token(header: &str) -> Option<&str> {
246 let header = header.trim();
247 let token = match header.split_once(' ') {
248 Some((scheme, rest)) if scheme.eq_ignore_ascii_case("bearer") => rest.trim(),
249 Some(_) => return None,
250 None => header,
251 };
252 (!token.is_empty()).then_some(token)
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn names_follow_crates_io_rules() {
261 for good in ["serde", "serde_json", "tokio-util", "a", "A1", "Inflector", &"a".repeat(64)] {
262 assert!(valid_name(good).is_ok(), "{good}");
263 }
264 for bad in ["", "1abc", "-abc", "_abc", "a.b", "a b", "naïve", "a/b", "nul", "COM1", &"a".repeat(65)] {
265 assert!(valid_name(bad).is_err(), "{bad}");
266 }
267 assert_eq!(folded("Serde_Json"), folded("serde-json"), "case and -/_ are one name");
268 assert_ne!(folded("serde"), folded("serde-json"));
269 }
270
271 #[test]
272 fn index_paths_are_the_standard_sparse_ones_in_lowercase() {
273 assert_eq!(index_path("a"), "1/a");
274 assert_eq!(index_path("ab"), "2/ab");
275 assert_eq!(index_path("abc"), "3/a/abc");
276 assert_eq!(index_path("serde"), "se/rd/serde");
277 assert_eq!(index_path("Inflector"), "in/fl/inflector");
278 assert_eq!(index_path("cargo"), "ca/rg/cargo");
279 assert_eq!(name_of_index_path("se/rd/serde").as_deref(), Some("serde"));
280 assert_eq!(name_of_index_path("3/a/abc").as_deref(), Some("abc"));
281 assert_eq!(name_of_index_path("in/fl/Inflector").as_deref(), Some("Inflector"));
282 assert_eq!(name_of_index_path("xx/rd/serde"), None, "not where serde's file is");
283 assert_eq!(name_of_index_path("3/b/abc"), None);
284 assert_eq!(name_of_index_path("1/ab"), None);
285 assert_eq!(name_of_index_path("se/rd/se.de"), None);
286 }
287
288 #[test]
289 fn every_endpoint_is_routed() {
290 let at = |route: CargoRoute| Some(("acme".to_owned(), route));
291 let nv = |name: &str, version: &str| (name.to_owned(), version.to_owned());
292 assert_eq!(route("/-/cargo/acme/index/config.json"), at(CargoRoute::Config));
293 assert_eq!(route("/-/cargo/Acme/index/se/rd/serde"), at(CargoRoute::Index { name: "serde".into() }));
294 assert_eq!(route("/-/cargo/acme/index/1/a"), at(CargoRoute::Index { name: "a".into() }));
295 assert_eq!(route("/-/cargo/acme/api/v1/crates/new"), at(CargoRoute::Publish));
296 assert_eq!(route("/-/cargo/acme/api/v1/crates"), at(CargoRoute::Search));
297 let (name, version) = nv("serde", "1.0.0");
298 assert_eq!(
299 route("/-/cargo/acme/api/v1/crates/serde/1.0.0/yank"),
300 at(CargoRoute::Yank { name: name.clone(), version: version.clone() })
301 );
302 assert_eq!(
303 route("/-/cargo/acme/api/v1/crates/serde/1.0.0/unyank"),
304 at(CargoRoute::Unyank { name: name.clone(), version: version.clone() })
305 );
306 assert_eq!(route("/-/cargo/acme/api/v1/crates/serde/1.0.0/download"), at(CargoRoute::Download { name, version }));
307 assert_eq!(route("/-/cargo/acme/api/v1/crates/serde/owners"), at(CargoRoute::Owners { name: "serde".into() }));
308 assert_eq!(route("/-/cargo/acme/api/v1/crates/serde/1.0.0/other"), None);
309 assert_eq!(route("/-/cargo/acme/api/v1/crates/se.de/1.0.0/download"), None);
310 assert_eq!(route("/-/cargo/acme/index/xx/yy/serde"), None);
311 assert_eq!(route("/-/cargo/acme"), None);
312 assert_eq!(route("/-/npm/@acme/web"), None);
313 }
314
315 fn body(metadata: &[u8], krate: &[u8]) -> Vec<u8> {
316 let mut body = (metadata.len() as u32).to_le_bytes().to_vec();
317 body.extend_from_slice(metadata);
318 body.extend_from_slice(&(krate.len() as u32).to_le_bytes());
319 body.extend_from_slice(krate);
320 body
321 }
322
323 #[test]
324 fn the_publish_body_is_two_length_prefixed_parts() {
325 let sent = body(br#"{"name":"web","vers":"1.0.0"}"#, b"\x1f\x8bcrate");
326 let (metadata, krate) = parse_publish(&sent).unwrap();
327 assert_eq!(metadata["name"], "web");
328 assert_eq!(krate, b"\x1f\x8bcrate");
329 let empty = body(br#"{"name":"web"}"#, b"");
330 assert_eq!(parse_publish(&empty).unwrap().1, b"");
331
332 assert!(parse_publish(b"").is_err());
333 assert!(parse_publish(&[10, 0, 0, 0, b'{']).is_err(), "shorter than it says");
334 assert!(parse_publish(&body(b"not json", b"x")).is_err());
335 assert!(parse_publish(&body(b"[1]", b"x")).is_err());
336 let mut cut = sent.clone();
337 cut.pop();
338 assert!(parse_publish(&cut).is_err(), "the crate is cut short");
339 let mut long = sent.clone();
340 long.push(0);
341 assert!(parse_publish(&long).is_err(), "bytes after the crate");
342 let no_crate = br#"{"name":"web"}"#;
343 let mut half = (no_crate.len() as u32).to_le_bytes().to_vec();
344 half.extend_from_slice(no_crate);
345 assert!(parse_publish(&half).is_err(), "no crate length");
346 }
347
348 #[test]
349 fn index_lines_are_cargos_shape() {
350 let metadata = json!({
351 "name": "Web",
352 "vers": "1.2.0",
353 "deps": [
354 { "name": "serde", "version_req": "^1", "features": ["derive"], "optional": false, "default_features": true, "target": null, "kind": "normal", "registry": "https://github.com/rust-lang/crates.io-index" },
355 { "name": "core-lib", "version_req": "=0.3.0", "features": [], "optional": true, "default_features": false, "target": "cfg(unix)", "kind": "normal", "explicit_name_in_toml": "core" },
356 { "name": "tempfile", "version_req": "^3", "kind": "dev" }
357 ],
358 "features": { "default": ["std"], "std": [], "derive": ["dep:core", "serde?/derive"] },
359 "links": null,
360 "rust_version": "1.75",
361 "description": "kept elsewhere",
362 });
363 let entry = index_entry(&metadata, "ab12").unwrap();
364 let line: Value = serde_json::from_str(&index_line(&entry, false)).unwrap();
365 assert_eq!(line["name"], "Web");
366 assert_eq!(line["vers"], "1.2.0");
367 assert_eq!(line["cksum"], "ab12");
368 assert_eq!(line["yanked"], false);
369 assert_eq!(line["links"], Value::Null);
370 assert_eq!(line["rust_version"], "1.75");
371 assert!(line.get("description").is_none(), "the index holds what resolving needs");
372 assert_eq!(line["features"], json!({ "default": ["std"], "std": [] }));
373 assert_eq!(line["features2"], json!({ "derive": ["dep:core", "serde?/derive"] }));
374 assert_eq!(line["v"], 2);
375
376 let deps = line["deps"].as_array().unwrap();
377 assert_eq!(deps[0]["name"], "serde");
378 assert_eq!(deps[0]["req"], "^1");
379 assert_eq!(deps[0]["features"], json!(["derive"]));
380 assert_eq!(deps[0]["registry"], "https://github.com/rust-lang/crates.io-index");
381 assert!(deps[0].get("package").is_none());
382 assert_eq!(deps[1]["name"], "core", "a renamed dependency by its new name");
383 assert_eq!(deps[1]["package"], "core-lib");
384 assert_eq!(deps[1]["optional"], true);
385 assert_eq!(deps[1]["default_features"], false);
386 assert_eq!(deps[1]["target"], "cfg(unix)");
387 assert!(deps[1].get("registry").is_none(), "this registry");
388 assert_eq!(deps[2]["kind"], "dev");
389 assert_eq!(deps[2]["features"], json!([]));
390 assert_eq!(deps[2]["default_features"], true);
391
392 let yanked: Value = serde_json::from_str(&index_line(&entry, true)).unwrap();
393 assert_eq!(yanked["yanked"], true);
394 assert!(!index_line(&entry, true).contains('\n'), "one line");
395 }
396
397 #[test]
398 fn a_crate_without_new_feature_syntax_is_index_version_1() {
399 let entry = index_entry(&json!({ "name": "a", "vers": "0.1.0", "deps": [], "features": { "x": ["a/b"] }, "links": "z" }), "00").unwrap();
400 assert!(entry.get("v").is_none());
401 assert!(entry.get("features2").is_none());
402 assert_eq!(entry["links"], "z");
403 assert!(index_entry(&json!({ "vers": "0.1.0" }), "00").is_err());
404 assert!(index_entry(&json!({ "name": "a", "vers": "0.1.0", "deps": [{ "name": "b" }] }), "00").is_err());
405 assert!(index_entry(&json!({ "name": "a", "vers": "0.1.0", "features": { "x": "y" } }), "00").is_err());
406 }
407
408 #[test]
409 fn config_names_the_download_and_api_addresses() {
410 let config = config("https://g1t.sh/-/cargo/acme", true);
411 assert_eq!(config["dl"], "https://g1t.sh/-/cargo/acme/api/v1/crates");
412 assert_eq!(config["api"], "https://g1t.sh/-/cargo/acme");
413 assert_eq!(config["auth-required"], true);
414 }
415
416 #[test]
417 fn the_token_is_the_whole_header() {
418 assert_eq!(token("g1t_abc"), Some("g1t_abc"));
419 assert_eq!(token(" g1t_abc "), Some("g1t_abc"));
420 assert_eq!(token("Bearer g1t_abc"), Some("g1t_abc"));
421 assert_eq!(token("Basic YTpi"), None);
422 assert_eq!(token(""), None);
423 assert_eq!(without_build("1.0.0+build.1"), "1.0.0");
424 assert_eq!(without_build("1.0.0-rc.1"), "1.0.0-rc.1");
425 }
426}