g1t/services/packages/src/maven.rs

592 lines26,285 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge branch 'worktree-agent-a6a121745e81f639f'1//! What the Maven repository needs that does not touch the network: the
2//! standard layout's paths (`com/acme/web/1.0.0/web-1.0.0.jar`), the
3//! checksum files beside each file, the `maven-metadata.xml` made for each
4//! artifact and SNAPSHOT version, Maven's version order, and the few
5//! things read from a POM.
6//!
7//! A package is an artifact, named `groupId:artifactId`. A version holds
8//! every file uploaded into its directory, by file name; a SNAPSHOT
9//! version holds each build's timestamped files, and its metadata names
10//! the newest of each.
11
12use std::cmp::Ordering;
13
14use crate::xml;
15
16/// The checksum files Maven and Gradle upload and ask for beside a file.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum Checksum {
19 Md5,
20 Sha1,
21 Sha256,
22 Sha512,
23}
24
25impl Checksum {
26 const ALL: [Checksum; 4] = [Checksum::Md5, Checksum::Sha1, Checksum::Sha256, Checksum::Sha512];
27
28 pub fn suffix(self) -> &'static str {
29 match self {
30 Checksum::Md5 => ".md5",
31 Checksum::Sha1 => ".sha1",
32 Checksum::Sha256 => ".sha256",
33 Checksum::Sha512 => ".sha512",
34 }
35 }
36
37 /// This checksum of `bytes`, in hex.
38 pub fn of(self, bytes: &[u8]) -> String {
39 use sha2::Digest as _;
40 match self {
41 Checksum::Md5 => format!("{:x}", md5::compute(bytes)),
42 Checksum::Sha1 => hex::encode(sha1::Sha1::digest(bytes)),
43 Checksum::Sha256 => hex::encode(sha2::Sha256::digest(bytes)),
44 Checksum::Sha512 => hex::encode(sha2::Sha512::digest(bytes)),
45 }
46 }
47
48 /// This checksum from the ones kept for a file, and its SHA-256 digest.
49 pub fn pick(self, sums: &crate::db::Checksums, sha256: &str) -> String {
50 match self {
51 Checksum::Md5 => sums.md5.clone(),
52 Checksum::Sha1 => sums.sha1.clone(),
53 Checksum::Sha256 => sha256.to_owned(),
54 Checksum::Sha512 => sums.sha512.clone(),
55 }
56 }
57}
58
59/// A file name without its checksum suffix, and which checksum it is.
60pub fn split_checksum(file: &str) -> (&str, Option<Checksum>) {
61 for checksum in Checksum::ALL {
62 if let Some(base) = file.strip_suffix(checksum.suffix()) {
63 return (base, Some(checksum));
64 }
65 }
66 (file, None)
67}
68
69/// The hex a checksum file holds: its first word (some tools add the file
70/// name after it), in lowercase.
71pub fn sent_checksum(body: &[u8]) -> String {
72 String::from_utf8_lossy(body).split_whitespace().next().unwrap_or("").to_ascii_lowercase()
73}
74
75const METADATA: &str = "maven-metadata.xml";
76pub const MAX_PART: usize = 128;
77
78/// One directory of a groupId: `com` and `acme` of `com.acme`.
79fn valid_group_part(part: &str) -> bool {
80 !part.is_empty() && part.len() <= MAX_PART && part.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
81}
82
83/// An artifactId: letters, digits, `-`, `_` and `.`, not starting with `.`.
84pub fn valid_artifact(artifact: &str) -> bool {
85 !artifact.is_empty()
86 && artifact.len() <= MAX_PART
87 && !artifact.starts_with('.')
88 && artifact.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
89}
90
91/// A version: letters, digits, `.`, `-`, `_` and `+`, starting with a
92/// letter or digit.
93pub fn valid_version(version: &str) -> bool {
94 !version.is_empty()
95 && version.len() <= MAX_PART
96 && version.as_bytes()[0].is_ascii_alphanumeric()
97 && version.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'+'))
98 && version != METADATA
99}
100
101pub fn is_snapshot(version: &str) -> bool {
102 version.ends_with("-SNAPSHOT")
103}
104
105/// The package's name: `com.acme:web`.
106pub fn package_name(group: &str, artifact: &str) -> String {
107 format!("{group}:{artifact}")
108}
109
110/// What a path under `/-/maven/<workspace>/` is.
111#[derive(Clone, Debug, PartialEq, Eq)]
112pub enum MavenPath {
113 /// `com/acme/web/maven-metadata.xml`: an artifact's versions.
114 ArtifactMetadata { group: String, artifact: String, checksum: Option<Checksum> },
115 /// `com/acme/web/1.0-SNAPSHOT/maven-metadata.xml`: a SNAPSHOT's builds.
116 VersionMetadata { group: String, artifact: String, version: String, checksum: Option<Checksum> },
117 /// `com/acme/web/1.0.0/web-1.0.0.jar`: one of a version's files.
118 File { group: String, artifact: String, version: String, file: String, checksum: Option<Checksum> },
119}
120
121/// The workspace and what the path is. Names and versions are checked,
122/// and a file's name has to start with its artifactId.
123pub fn route(path: &str) -> Option<(String, MavenPath)> {
124 let rest = path.strip_prefix("/-/maven/")?;
125 let (workspace, rest) = rest.split_once('/')?;
126 let workspace = workspace.to_ascii_lowercase();
127 if workspace.is_empty() {
128 return None;
129 }
130 let parts: Vec<&str> = rest.split('/').collect();
131 let last = *parts.last()?;
132 let (base, checksum) = split_checksum(last);
133 let group_of = |parts: &[&str]| -> Option<String> { (!parts.is_empty() && parts.iter().all(|p| valid_group_part(p))).then(|| parts.join(".")) };
134 let n = parts.len();
135 if base == METADATA {
136 if n >= 4 && is_snapshot(parts[n - 2]) && valid_version(parts[n - 2]) && valid_artifact(parts[n - 3])
137 && let Some(group) = group_of(&parts[..n - 3]) {
138 let (artifact, version) = (parts[n - 3].to_owned(), parts[n - 2].to_owned());
139 return Some((workspace, MavenPath::VersionMetadata { group, artifact, version, checksum }));
140 }
141 if n < 3 || !valid_artifact(parts[n - 2]) {
142 return None;
143 }
144 let group = group_of(&parts[..n - 2])?;
145 return Some((workspace, MavenPath::ArtifactMetadata { group, artifact: parts[n - 2].to_owned(), checksum }));
146 }
147 if n < 4 {
148 return None;
149 }
150 let (artifact, version) = (parts[n - 3], parts[n - 2]);
151 if !valid_artifact(artifact) || !valid_version(version) {
152 return None;
153 }
154 parse_file(artifact, version, base)?;
155 let group = group_of(&parts[..n - 3])?;
156 Some((
157 workspace,
158 MavenPath::File { group, artifact: artifact.to_owned(), version: version.to_owned(), file: base.to_owned(), checksum },
159 ))
160}
161
162/// What a file's name says: `web-1.0.0-sources.jar` is the `sources`
163/// classifier's `jar`; `web-1.0-20261006.120000-3.pom` is build 3 of the
164/// SNAPSHOT `1.0-SNAPSHOT`, made at that time (UTC).
165#[derive(Clone, Debug, PartialEq, Eq)]
166pub struct FileName {
167 pub classifier: Option<String>,
168 pub extension: String,
169 /// For a SNAPSHOT's timestamped file: `20261006.120000` and the build.
170 pub build: Option<(String, u32)>,
171}
172
173impl FileName {
174 /// The version its name holds: the SNAPSHOT's timestamped one.
175 pub fn value(&self, version: &str) -> String {
176 match &self.build {
177 Some((timestamp, build)) => format!("{}-{timestamp}-{build}", version.trim_end_matches("-SNAPSHOT")),
178 None => version.to_owned(),
179 }
180 }
181}
182
183/// `20261006.120000-3`: a SNAPSHOT build's time and number.
184fn snapshot_build(text: &str) -> Option<(String, u32, usize)> {
185 let bytes = text.as_bytes();
186 if bytes.len() < 17 || !bytes[..8].iter().all(u8::is_ascii_digit) || bytes[8] != b'.' || !bytes[9..15].iter().all(u8::is_ascii_digit) || bytes[15] != b'-' {
187 return None;
188 }
189 let digits = bytes[16..].iter().take_while(|b| b.is_ascii_digit()).count();
190 let build = text[16..16 + digits].parse().ok()?;
191 Some((text[..15].to_owned(), build, 16 + digits))
192}
193
194pub fn parse_file(artifact: &str, version: &str, file: &str) -> Option<FileName> {
195 let rest = file.strip_prefix(artifact)?.strip_prefix('-')?;
196 let (after, build) = if let Some(after) = rest.strip_prefix(version) {
197 (after, None)
198 } else {
199 let base = version.strip_suffix("-SNAPSHOT")?;
200 let stamped = rest.strip_prefix(base)?.strip_prefix('-')?;
201 let (timestamp, build, used) = snapshot_build(stamped)?;
202 (&stamped[used..], Some((timestamp, build)))
203 };
204 let (classifier, extension) = if let Some(ext) = after.strip_prefix('.') {
205 (None, ext)
206 } else {
207 let (classifier, ext) = after.strip_prefix('-')?.split_once('.')?;
208 if classifier.is_empty() {
209 return None;
210 }
211 (Some(classifier.to_owned()), ext)
212 };
213 if extension.is_empty() || extension.contains('/') {
214 return None;
215 }
216 Some(FileName { classifier, extension: extension.to_owned(), build })
217}
218
219/// The media type a file is served with.
220pub fn media_type(file: &str) -> &'static str {
221 let ext = file.rsplit('.').next().unwrap_or("");
222 match ext {
223 "pom" | "xml" => "application/xml",
224 "jar" | "war" | "ear" | "aar" => "application/java-archive",
225 "module" | "json" => "application/json",
226 "asc" => "application/pgp-signature",
227 "zip" => "application/zip",
228 _ => "application/octet-stream",
229 }
230}
231
232#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
233enum Item {
234 /// A qualifier, by its rank, then by name for those Maven does not know.
235 Qualifier(u8, String),
236 Number(u64),
237}
238
239/// Maven's order of the qualifiers it knows; a release (no qualifier) is
240/// `ga`, `final` and `release`.
241fn rank(word: &str) -> (u8, String) {
242 match word {
243 "alpha" | "a" => (1, String::new()),
244 "beta" | "b" => (2, String::new()),
245 "milestone" | "m" => (3, String::new()),
246 "rc" | "cr" => (4, String::new()),
247 "snapshot" => (5, String::new()),
248 "" | "ga" | "final" | "release" => (6, String::new()),
249 "sp" => (7, String::new()),
250 other => (8, other.to_owned()),
251 }
252}
253
254fn items(version: &str) -> Vec<Item> {
255 let lower = version.to_ascii_lowercase();
256 let mut out = Vec::new();
257 let mut word = String::new();
258 let mut digits: Option<bool> = None;
259 let flush = |word: &mut String, out: &mut Vec<Item>, number: bool| {
260 if number {
261 out.push(Item::Number(word.parse().unwrap_or(u64::MAX)));
262 } else {
263 let (r, name) = rank(word);
264 out.push(Item::Qualifier(r, name));
265 }
266 word.clear();
267 };
268 for c in lower.chars() {
269 if c == '.' || c == '-' || c == '_' {
270 if let Some(number) = digits.take() {
271 flush(&mut word, &mut out, number);
272 }
273 continue;
274 }
275 let is_digit = c.is_ascii_digit();
276 if digits.is_some_and(|d| d != is_digit) {
277 flush(&mut word, &mut out, digits.unwrap_or(false));
278 }
279 digits = Some(is_digit);
280 word.push(c);
281 }
282 if let Some(number) = digits {
283 flush(&mut word, &mut out, number);
284 }
285 // Trailing zeros and releases say nothing: 1.0 is 1 is 1.0.0-ga.
286 while matches!(out.last(), Some(Item::Number(0)) | Some(Item::Qualifier(6, _))) {
287 out.pop();
288 }
289 out
290}
291
292/// Maven's order of versions: `1.0-alpha` < `1.0-rc1` < `1.0-SNAPSHOT` <
293/// `1.0` < `1.0.1` < `1.1`.
294pub fn compare(a: &str, b: &str) -> Ordering {
295 let (a, b) = (items(a), items(b));
296 let missing = |item: &Item| match item {
297 Item::Number(n) => (*n).cmp(&0),
298 Item::Qualifier(r, _) => r.cmp(&6),
299 };
300 for i in 0..a.len().max(b.len()) {
301 let order = match (a.get(i), b.get(i)) {
302 (Some(x), Some(y)) => x.cmp(y),
303 (Some(x), None) => missing(x),
304 (None, Some(y)) => missing(y).reverse(),
305 (None, None) => Ordering::Equal,
306 };
307 if order != Ordering::Equal {
308 return order;
309 }
310 }
311 Ordering::Equal
312}
313
314/// `yyyyMMddHHmmss` from an RFC 3339 time, as Maven writes `lastUpdated`.
315pub fn last_updated(rfc3339: &str) -> String {
316 rfc3339.chars().filter(char::is_ascii_digit).take(14).collect()
317}
318
319/// An artifact's `maven-metadata.xml`: its versions in Maven's order, the
320/// highest as `latest`, and the highest that is not a SNAPSHOT as `release`.
321pub fn artifact_metadata(group: &str, artifact: &str, versions: &[String], updated: &str) -> String {
322 let mut sorted: Vec<&String> = versions.iter().collect();
323 sorted.sort_by(|a, b| compare(a, b));
324 sorted.dedup();
325 let latest = sorted.last();
326 let release = sorted.iter().rev().find(|v| !is_snapshot(v));
327 let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<metadata>\n");
328 xml.push_str(&format!(" <groupId>{}</groupId>\n <artifactId>{}</artifactId>\n <versioning>\n", xml::escape(group), xml::escape(artifact)));
329 if let Some(latest) = latest {
330 xml.push_str(&format!(" <latest>{}</latest>\n", xml::escape(latest)));
331 }
332 if let Some(release) = release {
333 xml.push_str(&format!(" <release>{}</release>\n", xml::escape(release)));
334 }
335 xml.push_str(" <versions>\n");
336 for version in &sorted {
337 xml.push_str(&format!(" <version>{}</version>\n", xml::escape(version)));
338 }
339 xml.push_str(&format!(" </versions>\n <lastUpdated>{}</lastUpdated>\n </versioning>\n</metadata>\n", last_updated(updated)));
340 xml
341}
342
343/// The newest build among a SNAPSHOT's files: its timestamp and number.
344pub fn newest_build(artifact: &str, version: &str, files: &[String]) -> Option<(String, u32)> {
345 files
346 .iter()
347 .filter_map(|file| parse_file(artifact, version, file)?.build)
348 .max_by(|a, b| (a.1, &a.0).cmp(&(b.1, &b.0)))
349}
350
351/// A SNAPSHOT version's `maven-metadata.xml`, from its files' names: the
352/// newest build, and the newest file of each classifier and extension.
353pub fn snapshot_metadata(group: &str, artifact: &str, version: &str, files: &[String]) -> Option<String> {
354 let mut newest: Vec<(FileName, String)> = Vec::new();
355 for file in files {
356 let Some(name) = parse_file(artifact, version, file) else { continue };
357 let Some(build) = name.build.clone() else { continue };
358 let key = (name.classifier.clone(), name.extension.clone());
359 match newest.iter_mut().find(|(n, _)| (n.classifier.clone(), n.extension.clone()) == key) {
360 Some(kept) => {
361 let had = kept.0.build.clone().unwrap_or_default();
362 if (build.1, &build.0) > (had.1, &had.0) {
363 *kept = (name, file.clone());
364 }
365 }
366 None => newest.push((name, file.clone())),
367 }
368 }
369 let (timestamp, build) = newest.iter().filter_map(|(n, _)| n.build.clone()).max_by(|a, b| (a.1, &a.0).cmp(&(b.1, &b.0)))?;
370 newest.sort_by(|a, b| (&a.0.extension, &a.0.classifier).cmp(&(&b.0.extension, &b.0.classifier)));
371 let updated = |stamp: &str| stamp.replace('.', "");
372 let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<metadata modelVersion=\"1.1.0\">\n");
373 xml.push_str(&format!(
374 " <groupId>{}</groupId>\n <artifactId>{}</artifactId>\n <version>{}</version>\n <versioning>\n",
375 xml::escape(group),
376 xml::escape(artifact),
377 xml::escape(version)
378 ));
379 xml.push_str(&format!(" <snapshot>\n <timestamp>{timestamp}</timestamp>\n <buildNumber>{build}</buildNumber>\n </snapshot>\n"));
380 xml.push_str(&format!(" <lastUpdated>{}</lastUpdated>\n <snapshotVersions>\n", updated(&timestamp)));
381 for (name, _) in &newest {
382 xml.push_str(" <snapshotVersion>\n");
383 if let Some(classifier) = &name.classifier {
384 xml.push_str(&format!(" <classifier>{}</classifier>\n", xml::escape(classifier)));
385 }
386 let stamp = name.build.as_ref().map(|b| b.0.clone()).unwrap_or_default();
387 xml.push_str(&format!(
388 " <extension>{}</extension>\n <value>{}</value>\n <updated>{}</updated>\n </snapshotVersion>\n",
389 xml::escape(&name.extension),
390 xml::escape(&name.value(version)),
391 updated(&stamp)
392 ));
393 }
394 xml.push_str(" </snapshotVersions>\n </versioning>\n</metadata>\n");
395 Some(xml)
396}
397
398/// What is read from a POM.
399#[derive(Clone, Debug, Default, PartialEq, Eq)]
400pub struct Pom {
401 pub group: String,
402 pub artifact: String,
403 pub version: String,
404 pub name: Option<String>,
405 pub description: Option<String>,
406 /// `<scm><url>`, else `<url>`: where its source is.
407 pub source: Option<String>,
408}
409
410/// Reads a POM, with the groupId and version a `<parent>` gives it.
411pub fn read_pom(bytes: &[u8]) -> Result<Pom, String> {
412 let text = std::str::from_utf8(bytes).map_err(|_| "The POM is not UTF-8.".to_owned())?;
413 let project = xml::parse(text).map_err(|problem| format!("The POM is not XML: {problem}"))?;
414 if project.name != "project" {
415 return Err("The POM has no <project>.".to_owned());
416 }
417 let parent = project.child("parent");
418 let inherited = |key: &str| project.child_text(key).or_else(|| parent.and_then(|p| p.child_text(key)));
419 Ok(Pom {
420 group: inherited("groupId").ok_or("The POM names no groupId.")?,
421 artifact: project.child_text("artifactId").ok_or("The POM names no artifactId.")?,
422 version: inherited("version").ok_or("The POM names no version.")?,
423 name: project.child_text("name"),
424 description: project.child_text("description"),
425 source: project.child("scm").and_then(|scm| scm.child_text("url")).or_else(|| project.child_text("url")),
426 })
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 fn file(group: &str, artifact: &str, version: &str, file: &str, checksum: Option<Checksum>) -> Option<(String, MavenPath)> {
434 Some((
435 "acme".to_owned(),
436 MavenPath::File { group: group.into(), artifact: artifact.into(), version: version.into(), file: file.into(), checksum },
437 ))
438 }
439
440 #[test]
441 fn paths_are_the_standard_layout() {
442 assert_eq!(route("/-/maven/Acme/com/acme/web/1.0.0/web-1.0.0.jar"), file("com.acme", "web", "1.0.0", "web-1.0.0.jar", None));
443 assert_eq!(
444 route("/-/maven/acme/com/acme/web/1.0.0/web-1.0.0.pom.sha1"),
445 file("com.acme", "web", "1.0.0", "web-1.0.0.pom", Some(Checksum::Sha1))
446 );
447 assert_eq!(
448 route("/-/maven/acme/io/g1t/core-lib/2.1/core-lib-2.1-sources.jar.asc"),
449 file("io.g1t", "core-lib", "2.1", "core-lib-2.1-sources.jar.asc", None)
450 );
451 assert_eq!(
452 route("/-/maven/acme/com/acme/web/1.0-SNAPSHOT/web-1.0-20261006.120000-3.module.sha512"),
453 file("com.acme", "web", "1.0-SNAPSHOT", "web-1.0-20261006.120000-3.module", Some(Checksum::Sha512))
454 );
455 assert_eq!(
456 route("/-/maven/acme/com/acme/web/maven-metadata.xml"),
457 Some(("acme".into(), MavenPath::ArtifactMetadata { group: "com.acme".into(), artifact: "web".into(), checksum: None }))
458 );
459 assert_eq!(
460 route("/-/maven/acme/com/acme/web/maven-metadata.xml.md5"),
461 Some(("acme".into(), MavenPath::ArtifactMetadata { group: "com.acme".into(), artifact: "web".into(), checksum: Some(Checksum::Md5) }))
462 );
463 assert_eq!(
464 route("/-/maven/acme/com/acme/web/1.0-SNAPSHOT/maven-metadata.xml"),
465 Some((
466 "acme".into(),
467 MavenPath::VersionMetadata { group: "com.acme".into(), artifact: "web".into(), version: "1.0-SNAPSHOT".into(), checksum: None }
468 ))
469 );
470 // Not a file of the artifact, or not in the layout.
471 assert_eq!(route("/-/maven/acme/com/acme/web/1.0.0/other-1.0.0.jar"), None);
472 assert_eq!(route("/-/maven/acme/com/acme/web/1.0.0/web-1.0.1"), None);
473 assert_eq!(route("/-/maven/acme/web/1.0.0/web-1.0.0.jar"), None, "no groupId");
474 assert_eq!(route("/-/maven/acme/com/../web/1.0.0/web-1.0.0.jar"), None);
475 assert_eq!(route("/-/maven/acme/com/acme/web/1.0.0/"), None);
476 assert_eq!(route("/-/maven/acme/maven-metadata.xml"), None);
477 assert_eq!(route("/-/maven/"), None);
478 }
479
480 #[test]
481 fn file_names_say_their_classifier_extension_and_build() {
482 assert_eq!(parse_file("web", "1.0.0", "web-1.0.0.jar"), Some(FileName { classifier: None, extension: "jar".into(), build: None }));
483 assert_eq!(
484 parse_file("web", "1.0.0", "web-1.0.0-javadoc.jar"),
485 Some(FileName { classifier: Some("javadoc".into()), extension: "jar".into(), build: None })
486 );
487 assert_eq!(parse_file("web", "1.0.0", "web-1.0.0.tar.gz").unwrap().extension, "tar.gz");
488 let stamped = parse_file("web", "1.0-SNAPSHOT", "web-1.0-20261006.120000-12-sources.jar").unwrap();
489 assert_eq!(stamped.build, Some(("20261006.120000".into(), 12)));
490 assert_eq!(stamped.classifier.as_deref(), Some("sources"));
491 assert_eq!(stamped.value("1.0-SNAPSHOT"), "1.0-20261006.120000-12");
492 assert_eq!(parse_file("web", "1.0-SNAPSHOT", "web-1.0-SNAPSHOT.jar").unwrap().build, None);
493 assert_eq!(parse_file("web", "1.0.0", "web-1.0.0"), None);
494 assert_eq!(parse_file("web", "1.0.0", "web-1.0.0-.jar"), None);
495 assert_eq!(parse_file("web", "1.0", "web-1.0-20261006.120000-1.jar").unwrap().build, None, "timestamps are a SNAPSHOT's");
496 }
497
498 #[test]
499 fn versions_sort_as_maven_sorts_them() {
500 let mut versions = vec!["1.10", "1.0", "1.0-SNAPSHOT", "1.0-rc1", "1.0-alpha", "1.0.1", "1.2", "1.0-beta-2", "2.0.0-M1", "1.0-sp1"];
501 versions.sort_by(|a, b| compare(a, b));
502 assert_eq!(versions, ["1.0-alpha", "1.0-beta-2", "1.0-rc1", "1.0-SNAPSHOT", "1.0", "1.0-sp1", "1.0.1", "1.2", "1.10", "2.0.0-M1"]);
503 assert_eq!(compare("1.0", "1.0.0"), Ordering::Equal);
504 assert_eq!(compare("1.0-ga", "1"), Ordering::Equal);
505 assert!(valid_version("1.0.0") && valid_version("2024.1+build") && !valid_version("-1") && !valid_version("1 0"));
506 }
507
508 #[test]
509 fn artifact_metadata_lists_versions_with_latest_and_release() {
510 let xml = artifact_metadata("com.acme", "web", &["1.1.0".into(), "1.0.0".into(), "1.2.0-SNAPSHOT".into()], "2026-10-06T12:30:05.000Z");
511 let doc = xml::parse(&xml).unwrap();
512 let versioning = doc.child("versioning").unwrap();
513 assert_eq!(doc.child_text("groupId").as_deref(), Some("com.acme"));
514 assert_eq!(versioning.child_text("latest").as_deref(), Some("1.2.0-SNAPSHOT"));
515 assert_eq!(versioning.child_text("release").as_deref(), Some("1.1.0"));
516 let listed: Vec<String> = versioning.child("versions").unwrap().children_named("version").map(|v| v.text.clone()).collect();
517 assert_eq!(listed, ["1.0.0", "1.1.0", "1.2.0-SNAPSHOT"]);
518 assert_eq!(versioning.child_text("lastUpdated").as_deref(), Some("20261006123005"));
519 }
520
521 #[test]
522 fn snapshot_metadata_names_the_newest_build_of_each_file() {
523 let files: Vec<String> = [
524 "web-1.0-20261006.120000-1.jar",
525 "web-1.0-20261006.120000-1.pom",
526 "web-1.0-20261007.090000-2.jar",
527 "web-1.0-20261007.090000-2.pom",
528 "web-1.0-20261006.120000-1-sources.jar",
529 ]
530 .iter()
531 .map(|s| s.to_string())
532 .collect();
533 let xml = snapshot_metadata("com.acme", "web", "1.0-SNAPSHOT", &files).unwrap();
534 let doc = xml::parse(&xml).unwrap();
535 assert_eq!(doc.child_text("version").as_deref(), Some("1.0-SNAPSHOT"));
536 let versioning = doc.child("versioning").unwrap();
537 let snapshot = versioning.child("snapshot").unwrap();
538 assert_eq!(snapshot.child_text("timestamp").as_deref(), Some("20261007.090000"));
539 assert_eq!(snapshot.child_text("buildNumber").as_deref(), Some("2"));
540 let shown: Vec<(Option<String>, String, String)> = versioning
541 .child("snapshotVersions")
542 .unwrap()
543 .children_named("snapshotVersion")
544 .map(|v| (v.child_text("classifier"), v.child_text("extension").unwrap(), v.child_text("value").unwrap()))
545 .collect();
546 assert_eq!(
547 shown,
548 [
549 (None, "jar".to_owned(), "1.0-20261007.090000-2".to_owned()),
550 (Some("sources".to_owned()), "jar".to_owned(), "1.0-20261006.120000-1".to_owned()),
551 (None, "pom".to_owned(), "1.0-20261007.090000-2".to_owned()),
552 ]
553 );
554 assert_eq!(snapshot_metadata("com.acme", "web", "1.0-SNAPSHOT", &[]), None);
555 assert_eq!(newest_build("web", "1.0-SNAPSHOT", &files), Some(("20261007.090000".to_owned(), 2)));
556 assert_eq!(newest_build("web", "1.0-SNAPSHOT", &["web-1.0-SNAPSHOT.jar".to_owned()]), None);
557 }
558
559 #[test]
560 fn a_pom_says_its_coordinates_and_source() {
561 let pom = read_pom(
562 br#"<?xml version="1.0"?>
563<project xmlns="http://maven.apache.org/POM/4.0.0">
564 <modelVersion>4.0.0</modelVersion>
565 <parent><groupId>com.acme</groupId><artifactId>parent</artifactId><version>1.0.0</version></parent>
566 <artifactId>web</artifactId>
567 <name>Acme web</name>
568 <description>The web client</description>
569 <url>https://acme.example</url>
570 <scm><url>https://g1t.sh/acme/web</url></scm>
571</project>"#,
572 )
573 .unwrap();
574 assert_eq!((pom.group.as_str(), pom.artifact.as_str(), pom.version.as_str()), ("com.acme", "web", "1.0.0"));
575 assert_eq!(pom.description.as_deref(), Some("The web client"));
576 assert_eq!(pom.source.as_deref(), Some("https://g1t.sh/acme/web"));
577 assert!(read_pom(b"<project><artifactId>x</artifactId></project>").is_err(), "no groupId");
578 assert!(read_pom(b"not xml").is_err());
579 }
580
581 #[test]
582 fn checksums_are_hex_of_the_file() {
583 assert_eq!(split_checksum("web-1.0.jar.sha1"), ("web-1.0.jar", Some(Checksum::Sha1)));
584 assert_eq!(split_checksum("web-1.0.jar"), ("web-1.0.jar", None));
585 assert_eq!(Checksum::Md5.of(b"abc"), "900150983cd24fb0d6963f7d28e17f72");
586 assert_eq!(Checksum::Sha1.of(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d");
587 assert_eq!(sent_checksum(b"A9993E364706816ABA3E25717850C26C9CD0D89D web-1.0.jar\n"), "a9993e364706816aba3e25717850c26c9cd0d89d");
588 let sums = crate::db::Checksums::of(b"abc");
589 assert_eq!(Checksum::Sha1.pick(&sums, "x"), Checksum::Sha1.of(b"abc"));
590 assert_eq!(Checksum::Sha512.pick(&sums, "x"), Checksum::Sha512.of(b"abc"));
591 }
592}

This file's history is long; its oldest lines are credited to the oldest commit read.