g1t/services/packages/src/rubygems.rs

742 lines32,380 bytesCodeBlame
1//! What the RubyGems registry needs that does not touch the network: gem
2//! names and versions, the registry's paths, the `Gem::Specification` read
3//! from a `.gem` (a tar holding `metadata.gz`), the compact index
4//! Bundler reads (`versions`, `info/<gem>` and `names`), and the full
5//! index `gem install --source` reads: `specs.4.8.gz` and its latest and
6//! pre-release kin, and each version's `quick/Marshal.4.8` specification.
7//!
8//! A version is keyed by its number and platform as the compact index
9//! writes it (`1.0.0`, `1.0.0-x86_64-linux`), and keeps what its index
10//! line needs as its metadata, made once when it is pushed.
11
12use serde_json::{Value, json};
13
14use std::cmp::Ordering;
15
16use crate::archive;
17use crate::marshal::{self, Value as Ruby};
18use crate::yaml;
19
20/// The longest gem name taken.
21pub const MAX_NAME: usize = 128;
22/// The largest `metadata.gz`, unpacked, read from a gem.
23const MAX_METADATA_BYTES: usize = 4 * 1024 * 1024;
24
25/// A gem's name: letters, digits, `.`, `-` and `_`, with a letter in it.
26pub fn valid_name(name: &str) -> bool {
27 !name.is_empty()
28 && name.len() <= MAX_NAME
29 && name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'))
30 && name.bytes().any(|b| b.is_ascii_alphabetic())
31 && name.as_bytes()[0].is_ascii_alphanumeric()
32}
33
34/// A version as `Gem::Version` takes it: numbers and words joined by dots,
35/// starting with a number (`1.0.0`, `2.0.0.rc1`, `1.0.0-beta.1`).
36pub fn valid_version(version: &str) -> bool {
37 let (core, suffix) = version.split_once('-').map_or((version, None), |(c, s)| (c, Some(s)));
38 let mut parts = core.split('.');
39 let first_ok = parts.next().is_some_and(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
40 first_ok
41 && version.len() <= 128
42 && parts.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_alphanumeric()))
43 && suffix.is_none_or(|s| s.split('.').all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')))
44}
45
46/// A version with a letter in it is a pre-release, as RubyGems decides.
47pub fn is_prerelease(version: &str) -> bool {
48 version.bytes().any(|b| b.is_ascii_alphabetic())
49}
50
51/// The version as the index keys it: `1.0.0`, or `1.0.0-java` for a gem
52/// built for a platform.
53pub fn key(version: &str, platform: &str) -> String {
54 if platform.is_empty() || platform == "ruby" { version.to_owned() } else { format!("{version}-{platform}") }
55}
56
57/// One of the registry's endpoints, under `/-/rubygems/<workspace>/`.
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub enum GemRoute {
60 /// `versions`: every gem and its versions, for Bundler.
61 Versions,
62 /// `info/<gem>`: a gem's versions, dependencies and checksums.
63 Info { name: String },
64 /// `names`: every gem's name.
65 Names,
66 /// `gems/<name>-<version>[-<platform>].gem`; the name and version are
67 /// told apart by the handler, as names may hold `-`.
68 Gem { stem: String },
69 /// `specs.4.8.gz`, `latest_specs.4.8.gz` or `prerelease_specs.4.8.gz`.
70 Specs(Specs),
71 /// `quick/Marshal.4.8/<name>-<version>[-<platform>].gemspec.rz`: one
72 /// version's specification.
73 QuickSpec { stem: String },
74 /// `api/v1/gems`: `gem push`.
75 Push,
76 /// `api/v1/gems/yank`: `gem yank`.
77 Yank,
78}
79
80pub fn route(path: &str) -> Option<(String, GemRoute)> {
81 let rest = path.strip_prefix("/-/rubygems/")?;
82 let (workspace, rest) = rest.split_once('/')?;
83 let workspace = workspace.to_ascii_lowercase();
84 if workspace.is_empty() {
85 return None;
86 }
87 let route = match rest.trim_end_matches('/') {
88 "versions" => GemRoute::Versions,
89 "names" => GemRoute::Names,
90 "api/v1/gems" => GemRoute::Push,
91 "api/v1/gems/yank" => GemRoute::Yank,
92 "specs.4.8.gz" => GemRoute::Specs(Specs::Released),
93 "latest_specs.4.8.gz" => GemRoute::Specs(Specs::Latest),
94 "prerelease_specs.4.8.gz" => GemRoute::Specs(Specs::Prerelease),
95 other => {
96 if let Some(file) = other.strip_prefix("quick/Marshal.4.8/") {
97 let stem = file.strip_suffix(".gemspec.rz")?;
98 if stem.contains('/') || candidates(stem).is_empty() {
99 return None;
100 }
101 return Some((workspace, GemRoute::QuickSpec { stem: stem.to_owned() }));
102 }
103 if let Some(name) = other.strip_prefix("info/") {
104 if !valid_name(name) {
105 return None;
106 }
107 GemRoute::Info { name: name.to_owned() }
108 } else {
109 let file = other.strip_prefix("gems/")?;
110 let stem = file.strip_suffix(".gem")?;
111 if stem.contains('/') || candidates(stem).is_empty() {
112 return None;
113 }
114 GemRoute::Gem { stem: stem.to_owned() }
115 }
116 }
117 };
118 Some((workspace, route))
119}
120
121/// The ways a file's stem splits into a name and a version key: at each
122/// `-` followed by a digit, longest name last (`a-b-1.0` is `a-b` `1.0`).
123pub fn candidates(stem: &str) -> Vec<(String, String)> {
124 stem.char_indices()
125 .filter(|&(i, c)| c == '-' && stem[i + 1..].starts_with(|n: char| n.is_ascii_digit()))
126 .map(|(i, _)| (stem[..i].to_owned(), stem[i + 1..].to_owned()))
127 .filter(|(name, _)| valid_name(name))
128 .collect()
129}
130
131/// What is read from a gem's specification.
132#[derive(Clone, Debug, Default, PartialEq, Eq)]
133pub struct Gemspec {
134 pub name: String,
135 pub version: String,
136 pub platform: String,
137 pub summary: Option<String>,
138 pub description: Option<String>,
139 pub homepage: Option<String>,
140 pub source_code_uri: Option<String>,
141 pub licenses: Vec<String>,
142 pub authors: Vec<String>,
143 /// Runtime dependencies: name and requirement (`>= 2.0&< 4`).
144 pub dependencies: Vec<(String, String)>,
145 pub ruby: Option<String>,
146 pub rubygems: Option<String>,
147}
148
149/// A `Gem::Requirement` as the compact index writes it: `>= 2.0&< 4`.
150pub fn requirement(value: &Value) -> String {
151 value["requirements"]
152 .as_array()
153 .map(|list| {
154 list.iter()
155 .filter_map(|pair| {
156 let op = pair.get(0)?.as_str()?;
157 let version = pair.get(1).map(|v| v.get("version").unwrap_or(v)).and_then(Value::as_str)?;
158 Some(format!("{op} {version}"))
159 })
160 .collect::<Vec<_>>()
161 .join("&")
162 })
163 .unwrap_or_default()
164}
165
166/// A requirement that says nothing (`>= 0`), as left out of the index.
167fn anything(requirement: &str) -> bool {
168 requirement.is_empty() || requirement == ">= 0"
169}
170
171fn text(value: &Value) -> Option<String> {
172 value.as_str().map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned)
173}
174
175fn texts(value: &Value) -> Vec<String> {
176 match value {
177 Value::Array(list) => list.iter().filter_map(text).collect(),
178 Value::String(_) => text(value).into_iter().collect(),
179 _ => Vec::new(),
180 }
181}
182
183/// Reads a specification RubyGems wrote as YAML.
184pub fn read_spec(text_yaml: &str) -> Result<Gemspec, String> {
185 let spec = yaml::parse(text_yaml).map_err(|problem| format!("The gem's metadata is not YAML: {problem}"))?;
186 if !spec.is_object() {
187 return Err("The gem's metadata is not a specification.".to_owned());
188 }
189 let version = &spec["version"];
190 let version = text(version.get("version").unwrap_or(version)).ok_or("The gem's metadata names no version.")?;
191 let platform = match &spec["platform"] {
192 Value::Object(map) => ["cpu", "os", "version"].iter().filter_map(|k| map.get(*k).and_then(text)).collect::<Vec<_>>().join("-"),
193 other => text(other).unwrap_or_else(|| "ruby".to_owned()),
194 };
195 let dependencies = spec["dependencies"]
196 .as_array()
197 .map(|deps| {
198 deps.iter()
199 .filter(|d| d["type"].as_str().is_none_or(|t| t.trim_start_matches(':') == "runtime"))
200 .filter_map(|d| {
201 let name = text(&d["name"])?;
202 let req = d.get("requirement").filter(|r| r.is_object()).or_else(|| d.get("version_requirements")).map(requirement).unwrap_or_default();
203 Some((name, if req.is_empty() { ">= 0".to_owned() } else { req }))
204 })
205 .collect()
206 })
207 .unwrap_or_default();
208 let required = |key: &str| Some(requirement(&spec[key])).filter(|r| !anything(r));
209 Ok(Gemspec {
210 name: text(&spec["name"]).ok_or("The gem's metadata names no gem.")?,
211 version,
212 platform: if platform.is_empty() { "ruby".to_owned() } else { platform },
213 summary: text(&spec["summary"]),
214 description: text(&spec["description"]),
215 homepage: text(&spec["homepage"]),
216 source_code_uri: text(&spec["metadata"]["source_code_uri"]),
217 licenses: texts(&spec["licenses"]),
218 authors: texts(&spec["authors"]),
219 dependencies,
220 ruby: required("required_ruby_version"),
221 rubygems: required("required_rubygems_version"),
222 })
223}
224
225/// Reads a `.gem`: the specification in its `metadata.gz`.
226pub fn read_gem(gem: &[u8]) -> Result<Gemspec, String> {
227 let files = archive::tar_files(gem).map_err(|_| "The file is not a .gem: it is not a tar archive.".to_owned())?;
228 let (_, metadata) = files.iter().find(|(name, _)| name == "metadata.gz").ok_or("The gem has no metadata.gz.")?;
229 let yaml = archive::gunzip(metadata, MAX_METADATA_BYTES)?;
230 read_spec(&String::from_utf8(yaml).map_err(|_| "The gem's metadata is not UTF-8.".to_owned())?)
231}
232
233/// What a version keeps for its index line and the gem's page.
234pub fn stored(spec: &Gemspec) -> Value {
235 json!({
236 "name": spec.name,
237 "number": spec.version,
238 "platform": spec.platform,
239 "summary": spec.summary,
240 "description": spec.description,
241 "homepage": spec.homepage,
242 "source_code_uri": spec.source_code_uri,
243 "licenses": spec.licenses,
244 "authors": spec.authors,
245 "dependencies": spec.dependencies.iter().map(|(name, req)| json!({ "name": name, "requirement": req })).collect::<Vec<_>>(),
246 "ruby": spec.ruby,
247 "rubygems": spec.rubygems,
248 })
249}
250
251/// One line of a gem's info file: `1.0.0 rack:>= 2.0&< 4|checksum:<sha256>,ruby:>= 3.0`.
252pub fn info_line(key: &str, stored: &Value, checksum: &str) -> String {
253 let deps: Vec<String> = stored["dependencies"]
254 .as_array()
255 .map(|deps| {
256 deps.iter()
257 .filter_map(|d| Some(format!("{}:{}", d["name"].as_str()?, d["requirement"].as_str().unwrap_or(">= 0"))))
258 .collect()
259 })
260 .unwrap_or_default();
261 let mut requirements = vec![format!("checksum:{checksum}")];
262 if let Some(ruby) = stored["ruby"].as_str() {
263 requirements.push(format!("ruby:{ruby}"));
264 }
265 if let Some(rubygems) = stored["rubygems"].as_str() {
266 requirements.push(format!("rubygems:{rubygems}"));
267 }
268 format!("{key} {}|{}", deps.join(","), requirements.join(","))
269}
270
271/// A gem's info file, from its versions' lines, oldest first.
272pub fn info(lines: &[String]) -> String {
273 let mut out = String::from("---\n");
274 for line in lines {
275 out.push_str(line);
276 out.push('\n');
277 }
278 out
279}
280
281/// The `versions` file: each gem's versions and its info file's MD5.
282pub fn versions_file(created_at: &str, gems: &[(String, Vec<String>, String)]) -> String {
283 let mut out = format!("created_at: {created_at}\n---\n");
284 for (name, versions, md5) in gems {
285 out.push_str(&format!("{name} {} {md5}\n", versions.join(",")));
286 }
287 out
288}
289
290pub fn names_file(names: &[String]) -> String {
291 let mut out = String::from("---\n");
292 for name in names {
293 out.push_str(name);
294 out.push('\n');
295 }
296 out
297}
298
299/// Which of the full index's files: every released version, the highest
300/// released version of each gem and platform, or every pre-release.
301#[derive(Clone, Copy, Debug, PartialEq, Eq)]
302pub enum Specs {
303 Released,
304 Latest,
305 Prerelease,
306}
307
308/// A version's parts as `Gem::Version` compares them: `1.0.0.rc1` is
309/// `1 0 0 rc 1`, and `1.0.0-beta` is `1.0.0.pre.beta`.
310#[derive(Clone, Debug, PartialEq, Eq)]
311enum Part {
312 Number(u64),
313 Word(String),
314}
315
316fn parts(version: &str) -> Vec<Part> {
317 let version = version.trim().replace('-', ".pre.");
318 let mut out = Vec::new();
319 for piece in version.split('.') {
320 let mut rest = piece;
321 while !rest.is_empty() {
322 let digits = rest.bytes().next().is_some_and(|b| b.is_ascii_digit());
323 let len = rest.bytes().take_while(|b| b.is_ascii_digit() == digits).count();
324 let (run, after) = rest.split_at(len);
325 out.push(if digits { Part::Number(run.parse().unwrap_or(u64::MAX)) } else { Part::Word(run.to_owned()) });
326 rest = after;
327 }
328 }
329 out
330}
331
332/// `Gem::Version`'s order: by part, a missing part is 0, and a word (a
333/// pre-release) is lower than any number.
334pub fn compare(a: &str, b: &str) -> Ordering {
335 let (a, b) = (parts(a), parts(b));
336 for i in 0..a.len().max(b.len()) {
337 let zero = Part::Number(0);
338 let (x, y) = (a.get(i).unwrap_or(&zero), b.get(i).unwrap_or(&zero));
339 let order = match (x, y) {
340 (Part::Number(x), Part::Number(y)) => x.cmp(y),
341 (Part::Word(x), Part::Word(y)) => x.cmp(y),
342 (Part::Word(_), Part::Number(_)) => Ordering::Less,
343 (Part::Number(_), Part::Word(_)) => Ordering::Greater,
344 };
345 if order != Ordering::Equal {
346 return order;
347 }
348 }
349 Ordering::Equal
350}
351
352/// One version in the full index: its gem, number and platform.
353#[derive(Clone, Debug, PartialEq, Eq)]
354pub struct Tuple {
355 pub name: String,
356 pub number: String,
357 pub platform: String,
358}
359
360impl Tuple {
361 /// What a version keeps says its number and platform.
362 pub fn of(name: &str, key: &str, stored: &Value) -> Tuple {
363 let platform = stored["platform"].as_str().filter(|p| !p.is_empty()).unwrap_or("ruby").to_owned();
364 let number = stored["number"].as_str().map(str::to_owned).unwrap_or_else(|| {
365 key.strip_suffix(&format!("-{platform}")).unwrap_or(key).to_owned()
366 });
367 Tuple { name: name.to_owned(), number, platform }
368 }
369}
370
371/// A `Gem::Version`, marshalled as its `marshal_dump`: `[version]`.
372fn gem_version(number: &str) -> Ruby {
373 Ruby::UserMarshal { class: "Gem::Version".into(), data: Box::new(Ruby::Array(vec![Ruby::str(number)])) }
374}
375
376/// A full index file: each version of `tuples` that `which` lists, as
377/// `[name, Gem::Version, platform]`, marshalled and gzipped.
378pub fn specs_file(which: Specs, tuples: &[Tuple]) -> Vec<u8> {
379 let mut chosen: Vec<&Tuple> = match which {
380 Specs::Released => tuples.iter().filter(|t| !is_prerelease(&t.number)).collect(),
381 Specs::Prerelease => tuples.iter().filter(|t| is_prerelease(&t.number)).collect(),
382 Specs::Latest => {
383 let mut highest: Vec<&Tuple> = Vec::new();
384 for tuple in tuples.iter().filter(|t| !is_prerelease(&t.number)) {
385 match highest.iter_mut().find(|h| h.name == tuple.name && h.platform == tuple.platform) {
386 Some(kept) if compare(&tuple.number, &kept.number) == Ordering::Greater => *kept = tuple,
387 Some(_) => {}
388 None => highest.push(tuple),
389 }
390 }
391 highest
392 }
393 };
394 chosen.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| compare(&a.number, &b.number)).then_with(|| a.platform.cmp(&b.platform)));
395 let list = chosen
396 .into_iter()
397 .map(|t| Ruby::Array(vec![Ruby::str(&t.name), gem_version(&t.number), Ruby::str(&t.platform)]))
398 .collect();
399 archive::gzip(&marshal::dump(&Ruby::Array(list)))
400}
401
402/// A `Gem::Requirement` from the index's form (`< 4&>= 2.0`), marshalled
403/// as its `marshal_dump`: `[[[op, Gem::Version], ...]]`.
404fn gem_requirement(text: Option<&str>) -> Ruby {
405 let mut pairs: Vec<Ruby> = text
406 .unwrap_or("")
407 .split('&')
408 .map(str::trim)
409 .filter(|r| !r.is_empty())
410 .map(|r| {
411 let (op, number) = match r.split_once(' ') {
412 Some((op, number)) => (op.trim(), number.trim()),
413 None => ("=", r),
414 };
415 Ruby::Array(vec![Ruby::str(op), gem_version(number)])
416 })
417 .collect();
418 if pairs.is_empty() {
419 pairs.push(Ruby::Array(vec![Ruby::str(">="), gem_version("0")]));
420 }
421 Ruby::UserMarshal { class: "Gem::Requirement".into(), data: Box::new(Ruby::Array(vec![Ruby::Array(pairs)])) }
422}
423
424/// A `Gem::Platform` (`x86_64-linux` is cpu `x86_64`, os `linux`), or the
425/// string `ruby` for a pure-Ruby gem, as RubyGems marshals it.
426fn gem_platform(platform: &str) -> Ruby {
427 if platform == "ruby" {
428 return Ruby::str("ruby");
429 }
430 let parts: Vec<&str> = platform.splitn(3, '-').collect();
431 let (cpu, os, version) = match parts.as_slice() {
432 [os] => (None, *os, None),
433 [cpu, os] => (Some(*cpu), *os, None),
434 [cpu, os, version, ..] => (Some(*cpu), *os, Some(*version)),
435 [] => (None, platform, None),
436 };
437 Ruby::Object {
438 class: "Gem::Platform".into(),
439 ivars: vec![("@cpu".into(), Ruby::opt(cpu)), ("@os".into(), Ruby::str(os)), ("@version".into(), Ruby::opt(version))],
440 }
441}
442
443/// A version's `Gem::Specification`, from what it keeps, marshalled as
444/// RubyGems' `_dump` writes it and deflated: the `.gemspec.rz` that
445/// `gem install` reads before it downloads the gem.
446pub fn quick_spec(name: &str, key: &str, stored: &Value, published_at: &str) -> Vec<u8> {
447 let tuple = Tuple::of(name, key, stored);
448 let text = |key: &str| stored[key].as_str().map(str::trim).filter(|t| !t.is_empty());
449 let strings = |key: &str| Ruby::Array(texts(&stored[key]).into_iter().map(Ruby::Str).collect());
450 let dependencies = stored["dependencies"]
451 .as_array()
452 .map(|deps| {
453 deps.iter()
454 .filter_map(|d| {
455 let name = d["name"].as_str()?;
456 let requirement = gem_requirement(d["requirement"].as_str());
457 Some(Ruby::Object {
458 class: "Gem::Dependency".into(),
459 ivars: vec![
460 ("@name".into(), Ruby::str(name)),
461 ("@requirement".into(), requirement.clone()),
462 ("@type".into(), Ruby::Symbol("runtime".into())),
463 ("@prerelease".into(), Ruby::Bool(false)),
464 ("@version_requirements".into(), requirement),
465 ],
466 })
467 })
468 .collect()
469 })
470 .unwrap_or_default();
471 let mut metadata = Vec::new();
472 if let Some(uri) = text("source_code_uri") {
473 metadata.push((Ruby::str("source_code_uri"), Ruby::str(uri)));
474 }
475 let date = published_at.get(..10).filter(|d| d.len() == 10).unwrap_or("1980-01-02");
476 // The fields of `Gem::Specification#_dump`, in its order.
477 let fields = Ruby::Array(vec![
478 Ruby::str(text("rubygems_version").unwrap_or("3.5.0")),
479 Ruby::Int(4),
480 Ruby::str(&tuple.name),
481 gem_version(&tuple.number),
482 Ruby::str(date),
483 Ruby::str(text("summary").unwrap_or("")),
484 gem_requirement(text("ruby")),
485 gem_requirement(text("rubygems")),
486 Ruby::str(&tuple.platform),
487 Ruby::Array(dependencies),
488 Ruby::str(""),
489 Ruby::Nil,
490 strings("authors"),
491 Ruby::opt(text("description")),
492 Ruby::opt(text("homepage")),
493 Ruby::Bool(true),
494 gem_platform(&tuple.platform),
495 strings("licenses"),
496 Ruby::Hash(metadata),
497 ]);
498 let spec = Ruby::UserDef { class: "Gem::Specification".into(), data: marshal::dump(&fields) };
499 miniz_oxide::deflate::compress_to_vec_zlib(&marshal::dump(&spec), 6)
500}
501
502/// A value from a form body or query string (`gem_name=hello&version=1.0`).
503pub fn form_value(form: &str, key: &str) -> Option<String> {
504 let url = worker::Url::parse(&format!("http://form.invalid/?{form}")).ok()?;
505 url.query_pairs().find(|(k, _)| k == key).map(|(_, v)| v.into_owned()).filter(|v| !v.is_empty())
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
513 fn names_and_versions_follow_rubygems_rules() {
514 for good in ["rails", "hello-world", "net_http2", "a1", "Hello.rb"] {
515 assert!(valid_name(good), "{good}");
516 }
517 for bad in ["", "123", "-a", ".a", "a b", "a/b", &"a".repeat(129)] {
518 assert!(!valid_name(bad), "{bad}");
519 }
520 for good in ["1.0.0", "0.1", "2.0.0.rc1", "1.0.0-beta.1", "3"] {
521 assert!(valid_version(good), "{good}");
522 }
523 for bad in ["", "a.1", "1..0", "1.0 0", "1.0-"] {
524 assert!(!valid_version(bad), "{bad}");
525 }
526 assert!(is_prerelease("2.0.0.rc1") && !is_prerelease("2.0.0"));
527 let mut sorted = vec!["1.10.0", "1.0.0", "1.0.0.rc1", "1.0", "1.2.0-beta.1", "1.2.0", "0.9"];
528 sorted.sort_by(|a, b| compare(a, b));
529 assert_eq!(sorted, ["0.9", "1.0.0.rc1", "1.0.0", "1.0", "1.2.0-beta.1", "1.2.0", "1.10.0"]);
530 assert_eq!(key("1.0.0", "ruby"), "1.0.0");
531 assert_eq!(key("1.0.0", "x86_64-linux"), "1.0.0-x86_64-linux");
532 }
533
534 #[test]
535 fn every_endpoint_is_routed() {
536 let at = |route: GemRoute| Some(("acme".to_owned(), route));
537 assert_eq!(route("/-/rubygems/Acme/versions"), at(GemRoute::Versions));
538 assert_eq!(route("/-/rubygems/acme/names"), at(GemRoute::Names));
539 assert_eq!(route("/-/rubygems/acme/info/hello-world"), at(GemRoute::Info { name: "hello-world".into() }));
540 assert_eq!(route("/-/rubygems/acme/gems/hello-world-0.1.0.gem"), at(GemRoute::Gem { stem: "hello-world-0.1.0".into() }));
541 assert_eq!(route("/-/rubygems/acme/api/v1/gems"), at(GemRoute::Push));
542 assert_eq!(route("/-/rubygems/acme/api/v1/gems/yank"), at(GemRoute::Yank));
543 assert_eq!(route("/-/rubygems/acme/gems/hello.gem"), None, "no version");
544 assert_eq!(route("/-/rubygems/acme/specs.4.8.gz"), at(GemRoute::Specs(Specs::Released)));
545 assert_eq!(route("/-/rubygems/acme/latest_specs.4.8.gz"), at(GemRoute::Specs(Specs::Latest)));
546 assert_eq!(route("/-/rubygems/acme/prerelease_specs.4.8.gz"), at(GemRoute::Specs(Specs::Prerelease)));
547 assert_eq!(
548 route("/-/rubygems/acme/quick/Marshal.4.8/hello-world-0.1.0.gemspec.rz"),
549 at(GemRoute::QuickSpec { stem: "hello-world-0.1.0".into() })
550 );
551 assert_eq!(route("/-/rubygems/acme/quick/Marshal.4.8/hello.gemspec.rz"), None, "no version");
552 assert_eq!(route("/-/rubygems/acme/info/a b"), None);
553 assert_eq!(route("/-/rubygems/acme/other"), None);
554 assert_eq!(route("/-/rubygems/acme"), None);
555 assert_eq!(
556 candidates("hello-world-0.1.0-x86_64-linux"),
557 [("hello-world".to_owned(), "0.1.0-x86_64-linux".to_owned())],
558 "x86_64 starts with a letter"
559 );
560 assert_eq!(candidates("a-2-1.0"), [("a".to_owned(), "2-1.0".to_owned()), ("a-2".to_owned(), "1.0".to_owned())]);
561 }
562
563 const SPEC: &str = r#"--- !ruby/object:Gem::Specification
564name: hello-world
565version: !ruby/object:Gem::Version
566 version: 0.2.0
567platform: ruby
568authors:
569- Ada
570dependencies:
571- !ruby/object:Gem::Dependency
572 name: rack
573 requirement: !ruby/object:Gem::Requirement
574 requirements:
575 - - "<"
576 - !ruby/object:Gem::Version
577 version: '4'
578 - - ">="
579 - !ruby/object:Gem::Version
580 version: '2.0'
581 type: :runtime
582 prerelease: false
583 version_requirements: !ruby/object:Gem::Requirement
584 requirements:
585 - - "<"
586 - !ruby/object:Gem::Version
587 version: '4'
588- !ruby/object:Gem::Dependency
589 name: json
590 requirement: !ruby/object:Gem::Requirement
591 requirements:
592 - - ">="
593 - !ruby/object:Gem::Version
594 version: '0'
595 type: :runtime
596- !ruby/object:Gem::Dependency
597 name: rspec
598 requirement: !ruby/object:Gem::Requirement
599 requirements:
600 - - "~>"
601 - !ruby/object:Gem::Version
602 version: '3.0'
603 type: :development
604description: Says hello.
605homepage: https://g1t.sh/acme/hello-world
606licenses:
607- MIT
608metadata:
609 source_code_uri: https://g1t.sh/acme/hello-world
610required_ruby_version: !ruby/object:Gem::Requirement
611 requirements:
612 - - ">="
613 - !ruby/object:Gem::Version
614 version: 3.0.0
615required_rubygems_version: !ruby/object:Gem::Requirement
616 requirements:
617 - - ">="
618 - !ruby/object:Gem::Version
619 version: '0'
620summary: Says hello
621"#;
622
623 #[test]
624 fn a_gem_is_read_from_its_metadata() {
625 let mut gz = vec![0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 3];
626 gz.extend_from_slice(&miniz_oxide::deflate::compress_to_vec(SPEC.as_bytes(), 6));
627 gz.extend_from_slice(&crate::composer::crc32(SPEC.as_bytes()).to_le_bytes());
628 gz.extend_from_slice(&(SPEC.len() as u32).to_le_bytes());
629 let mut gem = Vec::new();
630 for (name, data) in [("metadata.gz", gz.as_slice()), ("data.tar.gz", b"x".as_slice())] {
631 let mut header = [0u8; 512];
632 header[..name.len()].copy_from_slice(name.as_bytes());
633 header[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes());
634 header[156] = b'0';
635 gem.extend_from_slice(&header);
636 gem.extend_from_slice(data);
637 gem.resize(gem.len().div_ceil(512) * 512, 0);
638 }
639 gem.extend_from_slice(&[0; 1024]);
640 let spec = read_gem(&gem).unwrap();
641 assert_eq!((spec.name.as_str(), spec.version.as_str(), spec.platform.as_str()), ("hello-world", "0.2.0", "ruby"));
642 assert_eq!(spec.dependencies, [("rack".to_owned(), "< 4&>= 2.0".to_owned()), ("json".to_owned(), ">= 0".to_owned())], "runtime only");
643 assert_eq!(spec.ruby.as_deref(), Some(">= 3.0.0"));
644 assert_eq!(spec.rubygems, None, ">= 0 says nothing");
645 assert_eq!(spec.source_code_uri.as_deref(), Some("https://g1t.sh/acme/hello-world"));
646 assert_eq!(spec.licenses, ["MIT"]);
647 assert!(read_gem(b"not a gem").is_err());
648
649 let line = info_line(&key(&spec.version, &spec.platform), &stored(&spec), "abc123");
650 assert_eq!(line, "0.2.0 rack:< 4&>= 2.0,json:>= 0|checksum:abc123,ruby:>= 3.0.0");
651 let bare = info_line("1.0.0", &json!({ "dependencies": [] }), "ff");
652 assert_eq!(bare, "1.0.0 |checksum:ff");
653 }
654
655 #[test]
656 fn a_platform_mapping_reads_as_its_name() {
657 let spec = read_spec("name: native\nversion: !ruby/object:Gem::Version\n version: 1.0.0\nplatform: !ruby/object:Gem::Platform\n cpu: x86_64\n os: linux\n version:\n").unwrap();
658 assert_eq!(spec.platform, "x86_64-linux");
659 assert!(read_spec("name: x\n").is_err(), "no version");
660 }
661
662 #[test]
663 fn the_compact_index_is_bundlers_shape() {
664 let info = info(&["0.1.0 |checksum:aa".to_owned(), "0.2.0 rack:>= 2|checksum:bb".to_owned()]);
665 assert_eq!(info, "---\n0.1.0 |checksum:aa\n0.2.0 rack:>= 2|checksum:bb\n");
666 let versions = versions_file("2026-10-06T00:00:00Z", &[("hello".into(), vec!["0.1.0".into(), "0.2.0".into()], "d41d8".into())]);
667 assert_eq!(versions, "created_at: 2026-10-06T00:00:00Z\n---\nhello 0.1.0,0.2.0 d41d8\n");
668 assert_eq!(names_file(&["a".into(), "b".into()]), "---\na\nb\n");
669 assert_eq!(form_value("gem_name=hello-world&version=0.1.0&platform=", "gem_name").as_deref(), Some("hello-world"));
670 assert_eq!(form_value("gem_name=a%2Bb", "gem_name").as_deref(), Some("a+b"));
671 assert_eq!(form_value("version=1", "platform"), None);
672 }
673
674 fn tuples() -> Vec<Tuple> {
675 let tuple = |name: &str, number: &str, platform: &str| Tuple { name: name.into(), number: number.into(), platform: platform.into() };
676 vec![
677 tuple("hello", "0.2.0", "ruby"),
678 tuple("hello", "0.10.0", "ruby"),
679 tuple("hello", "1.0.0.rc1", "ruby"),
680 tuple("hello", "0.10.0", "java"),
681 tuple("abc", "1.0.0", "ruby"),
682 ]
683 }
684
685 #[test]
686 fn the_full_index_lists_versions_as_tuples() {
687 let file = specs_file(Specs::Latest, &tuples());
688 let bytes = archive::gunzip(&file, 1 << 20).unwrap();
689 // By name, then version and platform: hello's highest of each.
690 let dumped = marshal::dump(&Ruby::Array(vec![
691 Ruby::Array(vec![Ruby::str("abc"), gem_version("1.0.0"), Ruby::str("ruby")]),
692 Ruby::Array(vec![Ruby::str("hello"), gem_version("0.10.0"), Ruby::str("java")]),
693 Ruby::Array(vec![Ruby::str("hello"), gem_version("0.10.0"), Ruby::str("ruby")]),
694 ]));
695 assert_eq!(bytes, dumped);
696 let released = archive::gunzip(&specs_file(Specs::Released, &tuples()), 1 << 20).unwrap();
697 assert_eq!(released.windows(5).filter(|w| *w == b"hello").count(), 3, "every released version");
698 let pre = archive::gunzip(&specs_file(Specs::Prerelease, &tuples()), 1 << 20).unwrap();
699 assert!(pre.windows(9).any(|w| w == b"1.0.0.rc1"));
700 assert!(!pre.windows(6).any(|w| w == b"0.10.0"));
701 }
702
703 #[test]
704 fn a_quick_spec_is_a_deflated_specification() {
705 let stored = json!({
706 "name": "hello-world", "number": "0.2.0", "platform": "ruby", "summary": "Says hello",
707 "authors": ["Ada"], "licenses": ["MIT"], "homepage": "https://g1t.sh/acme/hello-world",
708 "dependencies": [{ "name": "rack", "requirement": "< 4&>= 2.0" }], "ruby": ">= 3.0.0", "rubygems": null,
709 });
710 let rz = quick_spec("hello-world", "0.2.0", &stored, "2026-10-07T01:02:03.000Z");
711 let bytes = miniz_oxide::inflate::decompress_to_vec_zlib(&rz).unwrap();
712 assert_eq!(&bytes[..3], b"\x04\x08u");
713 assert!(bytes.windows(18).any(|w| w == b"Gem::Specification"));
714 assert!(bytes.windows(10).any(|w| w == b"2026-10-07"));
715 assert!(bytes.windows(15).any(|w| w == b"Gem::Dependency"));
716 // A gem built for a platform names it as a Gem::Platform too.
717 let native = quick_spec("native", "1.0.0-x86_64-linux", &json!({ "number": "1.0.0", "platform": "x86_64-linux" }), "");
718 let bytes = miniz_oxide::inflate::decompress_to_vec_zlib(&native).unwrap();
719 assert!(bytes.windows(13).any(|w| w == b"Gem::Platform"));
720 assert_eq!(Tuple::of("native", "1.0.0-x86_64-linux", &json!({})).number, "1.0.0-x86_64-linux", "no platform kept: the key");
721 assert_eq!(Tuple::of("native", "1.0.0-java", &json!({ "platform": "java" })).number, "1.0.0");
722 }
723
724 /// Writes the full index for `tuples()` and a quick spec where a real
725 /// Ruby can read them: `G1T_MARSHAL_OUT=<dir> cargo test marshal_files`,
726 /// then `ruby -e` over the files (see the RubyGems guide's notes).
727 #[test]
728 fn marshal_files_for_ruby() {
729 let Ok(dir) = std::env::var("G1T_MARSHAL_OUT") else { return };
730 let dir = std::path::Path::new(&dir);
731 std::fs::write(dir.join("specs.4.8.gz"), specs_file(Specs::Released, &tuples())).unwrap();
732 std::fs::write(dir.join("latest_specs.4.8.gz"), specs_file(Specs::Latest, &tuples())).unwrap();
733 let stored = json!({
734 "name": "hello-world", "number": "0.2.0", "platform": "ruby", "summary": "Says hello", "description": "Says hello.",
735 "authors": ["Ada"], "licenses": ["MIT"], "homepage": "https://g1t.sh/acme/hello-world", "source_code_uri": "https://g1t.sh/acme/hello-world",
736 "dependencies": [{ "name": "rack", "requirement": "< 4&>= 2.0" }, { "name": "json", "requirement": ">= 0" }], "ruby": ">= 3.0.0",
737 });
738 std::fs::write(dir.join("hello-world-0.2.0.gemspec.rz"), quick_spec("hello-world", "0.2.0", &stored, "2026-10-07T00:00:00.000Z")).unwrap();
739 let native = json!({ "name": "native", "number": "1.0.0", "platform": "x86_64-linux", "dependencies": [] });
740 std::fs::write(dir.join("native-1.0.0-x86_64-linux.gemspec.rz"), quick_spec("native", "1.0.0-x86_64-linux", &native, "2026-10-07T00:00:00.000Z")).unwrap();
741 }
742}