g1t/services/packages/src/maven.rs

725 lines32,068 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> },
Merge branch 'worktree-agent-ac1de8a731938ed81'117 /// `acme/maven-metadata.xml`: a one-part group's plugins, by prefix.
118 /// A deeper group's (`com/acme/plugins/maven-metadata.xml`) reads as an
119 /// artifact's, and is answered with the plugins of the group the whole
120 /// path names when there is no such artifact.
121 GroupMetadata { group: String, checksum: Option<Checksum> },
Merge branch 'worktree-agent-a6a121745e81f639f'122 /// `com/acme/web/1.0.0/web-1.0.0.jar`: one of a version's files.
123 File { group: String, artifact: String, version: String, file: String, checksum: Option<Checksum> },
124}
125
126/// The workspace and what the path is. Names and versions are checked,
127/// and a file's name has to start with its artifactId.
128pub fn route(path: &str) -> Option<(String, MavenPath)> {
129 let rest = path.strip_prefix("/-/maven/")?;
130 let (workspace, rest) = rest.split_once('/')?;
131 let workspace = workspace.to_ascii_lowercase();
132 if workspace.is_empty() {
133 return None;
134 }
135 let parts: Vec<&str> = rest.split('/').collect();
136 let last = *parts.last()?;
137 let (base, checksum) = split_checksum(last);
138 let group_of = |parts: &[&str]| -> Option<String> { (!parts.is_empty() && parts.iter().all(|p| valid_group_part(p))).then(|| parts.join(".")) };
139 let n = parts.len();
140 if base == METADATA {
141 if n >= 4 && is_snapshot(parts[n - 2]) && valid_version(parts[n - 2]) && valid_artifact(parts[n - 3])
142 && let Some(group) = group_of(&parts[..n - 3]) {
143 let (artifact, version) = (parts[n - 3].to_owned(), parts[n - 2].to_owned());
144 return Some((workspace, MavenPath::VersionMetadata { group, artifact, version, checksum }));
145 }
Merge branch 'worktree-agent-ac1de8a731938ed81'146 if n == 2 && valid_group_part(parts[0]) {
147 return Some((workspace, MavenPath::GroupMetadata { group: parts[0].to_owned(), checksum }));
148 }
Merge branch 'worktree-agent-a6a121745e81f639f'149 if n < 3 || !valid_artifact(parts[n - 2]) {
150 return None;
151 }
152 let group = group_of(&parts[..n - 2])?;
153 return Some((workspace, MavenPath::ArtifactMetadata { group, artifact: parts[n - 2].to_owned(), checksum }));
154 }
155 if n < 4 {
156 return None;
157 }
158 let (artifact, version) = (parts[n - 3], parts[n - 2]);
159 if !valid_artifact(artifact) || !valid_version(version) {
160 return None;
161 }
162 parse_file(artifact, version, base)?;
163 let group = group_of(&parts[..n - 3])?;
164 Some((
165 workspace,
166 MavenPath::File { group, artifact: artifact.to_owned(), version: version.to_owned(), file: base.to_owned(), checksum },
167 ))
168}
169
170/// What a file's name says: `web-1.0.0-sources.jar` is the `sources`
171/// classifier's `jar`; `web-1.0-20261006.120000-3.pom` is build 3 of the
172/// SNAPSHOT `1.0-SNAPSHOT`, made at that time (UTC).
173#[derive(Clone, Debug, PartialEq, Eq)]
174pub struct FileName {
175 pub classifier: Option<String>,
176 pub extension: String,
177 /// For a SNAPSHOT's timestamped file: `20261006.120000` and the build.
178 pub build: Option<(String, u32)>,
179}
180
181impl FileName {
182 /// The version its name holds: the SNAPSHOT's timestamped one.
183 pub fn value(&self, version: &str) -> String {
184 match &self.build {
185 Some((timestamp, build)) => format!("{}-{timestamp}-{build}", version.trim_end_matches("-SNAPSHOT")),
186 None => version.to_owned(),
187 }
188 }
189}
190
191/// `20261006.120000-3`: a SNAPSHOT build's time and number.
192fn snapshot_build(text: &str) -> Option<(String, u32, usize)> {
193 let bytes = text.as_bytes();
194 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'-' {
195 return None;
196 }
197 let digits = bytes[16..].iter().take_while(|b| b.is_ascii_digit()).count();
198 let build = text[16..16 + digits].parse().ok()?;
199 Some((text[..15].to_owned(), build, 16 + digits))
200}
201
202pub fn parse_file(artifact: &str, version: &str, file: &str) -> Option<FileName> {
203 let rest = file.strip_prefix(artifact)?.strip_prefix('-')?;
204 let (after, build) = if let Some(after) = rest.strip_prefix(version) {
205 (after, None)
206 } else {
207 let base = version.strip_suffix("-SNAPSHOT")?;
208 let stamped = rest.strip_prefix(base)?.strip_prefix('-')?;
209 let (timestamp, build, used) = snapshot_build(stamped)?;
210 (&stamped[used..], Some((timestamp, build)))
211 };
212 let (classifier, extension) = if let Some(ext) = after.strip_prefix('.') {
213 (None, ext)
214 } else {
215 let (classifier, ext) = after.strip_prefix('-')?.split_once('.')?;
216 if classifier.is_empty() {
217 return None;
218 }
219 (Some(classifier.to_owned()), ext)
220 };
221 if extension.is_empty() || extension.contains('/') {
222 return None;
223 }
224 Some(FileName { classifier, extension: extension.to_owned(), build })
225}
226
227/// The media type a file is served with.
228pub fn media_type(file: &str) -> &'static str {
229 let ext = file.rsplit('.').next().unwrap_or("");
230 match ext {
231 "pom" | "xml" => "application/xml",
232 "jar" | "war" | "ear" | "aar" => "application/java-archive",
233 "module" | "json" => "application/json",
234 "asc" => "application/pgp-signature",
235 "zip" => "application/zip",
236 _ => "application/octet-stream",
237 }
238}
239
240#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
241enum Item {
242 /// A qualifier, by its rank, then by name for those Maven does not know.
243 Qualifier(u8, String),
244 Number(u64),
245}
246
247/// Maven's order of the qualifiers it knows; a release (no qualifier) is
248/// `ga`, `final` and `release`.
249fn rank(word: &str) -> (u8, String) {
250 match word {
251 "alpha" | "a" => (1, String::new()),
252 "beta" | "b" => (2, String::new()),
253 "milestone" | "m" => (3, String::new()),
254 "rc" | "cr" => (4, String::new()),
255 "snapshot" => (5, String::new()),
256 "" | "ga" | "final" | "release" => (6, String::new()),
257 "sp" => (7, String::new()),
258 other => (8, other.to_owned()),
259 }
260}
261
262fn items(version: &str) -> Vec<Item> {
263 let lower = version.to_ascii_lowercase();
264 let mut out = Vec::new();
265 let mut word = String::new();
266 let mut digits: Option<bool> = None;
267 let flush = |word: &mut String, out: &mut Vec<Item>, number: bool| {
268 if number {
269 out.push(Item::Number(word.parse().unwrap_or(u64::MAX)));
270 } else {
271 let (r, name) = rank(word);
272 out.push(Item::Qualifier(r, name));
273 }
274 word.clear();
275 };
276 for c in lower.chars() {
277 if c == '.' || c == '-' || c == '_' {
278 if let Some(number) = digits.take() {
279 flush(&mut word, &mut out, number);
280 }
281 continue;
282 }
283 let is_digit = c.is_ascii_digit();
284 if digits.is_some_and(|d| d != is_digit) {
285 flush(&mut word, &mut out, digits.unwrap_or(false));
286 }
287 digits = Some(is_digit);
288 word.push(c);
289 }
290 if let Some(number) = digits {
291 flush(&mut word, &mut out, number);
292 }
293 // Trailing zeros and releases say nothing: 1.0 is 1 is 1.0.0-ga.
294 while matches!(out.last(), Some(Item::Number(0)) | Some(Item::Qualifier(6, _))) {
295 out.pop();
296 }
297 out
298}
299
300/// Maven's order of versions: `1.0-alpha` < `1.0-rc1` < `1.0-SNAPSHOT` <
301/// `1.0` < `1.0.1` < `1.1`.
302pub fn compare(a: &str, b: &str) -> Ordering {
303 let (a, b) = (items(a), items(b));
304 let missing = |item: &Item| match item {
305 Item::Number(n) => (*n).cmp(&0),
306 Item::Qualifier(r, _) => r.cmp(&6),
307 };
308 for i in 0..a.len().max(b.len()) {
309 let order = match (a.get(i), b.get(i)) {
310 (Some(x), Some(y)) => x.cmp(y),
311 (Some(x), None) => missing(x),
312 (None, Some(y)) => missing(y).reverse(),
313 (None, None) => Ordering::Equal,
314 };
315 if order != Ordering::Equal {
316 return order;
317 }
318 }
319 Ordering::Equal
320}
321
322/// `yyyyMMddHHmmss` from an RFC 3339 time, as Maven writes `lastUpdated`.
323pub fn last_updated(rfc3339: &str) -> String {
324 rfc3339.chars().filter(char::is_ascii_digit).take(14).collect()
325}
326
327/// An artifact's `maven-metadata.xml`: its versions in Maven's order, the
328/// highest as `latest`, and the highest that is not a SNAPSHOT as `release`.
329pub fn artifact_metadata(group: &str, artifact: &str, versions: &[String], updated: &str) -> String {
330 let mut sorted: Vec<&String> = versions.iter().collect();
331 sorted.sort_by(|a, b| compare(a, b));
332 sorted.dedup();
333 let latest = sorted.last();
334 let release = sorted.iter().rev().find(|v| !is_snapshot(v));
335 let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<metadata>\n");
336 xml.push_str(&format!(" <groupId>{}</groupId>\n <artifactId>{}</artifactId>\n <versioning>\n", xml::escape(group), xml::escape(artifact)));
337 if let Some(latest) = latest {
338 xml.push_str(&format!(" <latest>{}</latest>\n", xml::escape(latest)));
339 }
340 if let Some(release) = release {
341 xml.push_str(&format!(" <release>{}</release>\n", xml::escape(release)));
342 }
343 xml.push_str(" <versions>\n");
344 for version in &sorted {
345 xml.push_str(&format!(" <version>{}</version>\n", xml::escape(version)));
346 }
347 xml.push_str(&format!(" </versions>\n <lastUpdated>{}</lastUpdated>\n </versioning>\n</metadata>\n", last_updated(updated)));
348 xml
349}
350
351/// The newest build among a SNAPSHOT's files: its timestamp and number.
352pub fn newest_build(artifact: &str, version: &str, files: &[String]) -> Option<(String, u32)> {
353 files
354 .iter()
355 .filter_map(|file| parse_file(artifact, version, file)?.build)
356 .max_by(|a, b| (a.1, &a.0).cmp(&(b.1, &b.0)))
357}
358
359/// A SNAPSHOT version's `maven-metadata.xml`, from its files' names: the
360/// newest build, and the newest file of each classifier and extension.
361pub fn snapshot_metadata(group: &str, artifact: &str, version: &str, files: &[String]) -> Option<String> {
362 let mut newest: Vec<(FileName, String)> = Vec::new();
363 for file in files {
364 let Some(name) = parse_file(artifact, version, file) else { continue };
365 let Some(build) = name.build.clone() else { continue };
366 let key = (name.classifier.clone(), name.extension.clone());
367 match newest.iter_mut().find(|(n, _)| (n.classifier.clone(), n.extension.clone()) == key) {
368 Some(kept) => {
369 let had = kept.0.build.clone().unwrap_or_default();
370 if (build.1, &build.0) > (had.1, &had.0) {
371 *kept = (name, file.clone());
372 }
373 }
374 None => newest.push((name, file.clone())),
375 }
376 }
377 let (timestamp, build) = newest.iter().filter_map(|(n, _)| n.build.clone()).max_by(|a, b| (a.1, &a.0).cmp(&(b.1, &b.0)))?;
378 newest.sort_by(|a, b| (&a.0.extension, &a.0.classifier).cmp(&(&b.0.extension, &b.0.classifier)));
379 let updated = |stamp: &str| stamp.replace('.', "");
380 let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<metadata modelVersion=\"1.1.0\">\n");
381 xml.push_str(&format!(
382 " <groupId>{}</groupId>\n <artifactId>{}</artifactId>\n <version>{}</version>\n <versioning>\n",
383 xml::escape(group),
384 xml::escape(artifact),
385 xml::escape(version)
386 ));
387 xml.push_str(&format!(" <snapshot>\n <timestamp>{timestamp}</timestamp>\n <buildNumber>{build}</buildNumber>\n </snapshot>\n"));
388 xml.push_str(&format!(" <lastUpdated>{}</lastUpdated>\n <snapshotVersions>\n", updated(&timestamp)));
389 for (name, _) in &newest {
390 xml.push_str(" <snapshotVersion>\n");
391 if let Some(classifier) = &name.classifier {
392 xml.push_str(&format!(" <classifier>{}</classifier>\n", xml::escape(classifier)));
393 }
394 let stamp = name.build.as_ref().map(|b| b.0.clone()).unwrap_or_default();
395 xml.push_str(&format!(
396 " <extension>{}</extension>\n <value>{}</value>\n <updated>{}</updated>\n </snapshotVersion>\n",
397 xml::escape(&name.extension),
398 xml::escape(&name.value(version)),
399 updated(&stamp)
400 ));
401 }
402 xml.push_str(" </snapshotVersions>\n </versioning>\n</metadata>\n");
403 Some(xml)
404}
405
406/// What is read from a POM.
407#[derive(Clone, Debug, Default, PartialEq, Eq)]
408pub struct Pom {
409 pub group: String,
410 pub artifact: String,
411 pub version: String,
412 pub name: Option<String>,
413 pub description: Option<String>,
414 /// `<scm><url>`, else `<url>`: where its source is.
415 pub source: Option<String>,
Merge branch 'worktree-agent-ac1de8a731938ed81'416 /// `jar` when it names none; `maven-plugin` for a plugin.
417 pub packaging: String,
Merge branch 'worktree-agent-a6a121745e81f639f'418}
419
420/// Reads a POM, with the groupId and version a `<parent>` gives it.
421pub fn read_pom(bytes: &[u8]) -> Result<Pom, String> {
422 let text = std::str::from_utf8(bytes).map_err(|_| "The POM is not UTF-8.".to_owned())?;
423 let project = xml::parse(text).map_err(|problem| format!("The POM is not XML: {problem}"))?;
424 if project.name != "project" {
425 return Err("The POM has no <project>.".to_owned());
426 }
427 let parent = project.child("parent");
428 let inherited = |key: &str| project.child_text(key).or_else(|| parent.and_then(|p| p.child_text(key)));
429 Ok(Pom {
430 group: inherited("groupId").ok_or("The POM names no groupId.")?,
431 artifact: project.child_text("artifactId").ok_or("The POM names no artifactId.")?,
432 version: inherited("version").ok_or("The POM names no version.")?,
433 name: project.child_text("name"),
434 description: project.child_text("description"),
435 source: project.child("scm").and_then(|scm| scm.child_text("url")).or_else(|| project.child_text("url")),
Merge branch 'worktree-agent-ac1de8a731938ed81'436 packaging: project.child_text("packaging").unwrap_or_else(|| "jar".to_owned()),
Merge branch 'worktree-agent-a6a121745e81f639f'437 })
438}
439
Merge branch 'worktree-agent-ac1de8a731938ed81'440/// The prefix Maven gives a plugin that names none: its artifactId without
441/// `maven` and `plugin` (`acme-maven-plugin` and `maven-acme-plugin` are
442/// `acme`), as `mvn acme:<goal>` calls it.
443pub fn default_prefix(artifact: &str) -> String {
444 if artifact == "maven-plugin-plugin" {
445 return "plugin".to_owned();
446 }
447 let strip = |text: &str, word: &str| -> String {
448 // `-?word-?`, as Maven's regular expression removes it.
449 let mut out = text.to_owned();
450 while let Some(at) = out.find(word) {
451 let start = if at > 0 && out.as_bytes()[at - 1] == b'-' { at - 1 } else { at };
452 let mut end = at + word.len();
453 if out.as_bytes().get(end) == Some(&b'-') {
454 end += 1;
455 }
456 out.replace_range(start..end, "");
457 }
458 out
459 };
460 strip(&strip(artifact, "maven"), "plugin")
461}
462
463/// The plugin descriptor's prefix and name, from the
464/// `META-INF/maven/plugin.xml` that `maven-plugin-plugin` puts in the jar.
465pub fn plugin_descriptor(text: &str) -> Option<(Option<String>, Option<String>)> {
466 let plugin = xml::parse(text).ok()?;
467 (plugin.name == "plugin").then(|| (plugin.child_text("goalPrefix"), plugin.child_text("name")))
468}
469
470/// One plugin as a group's `maven-metadata.xml` lists it.
471#[derive(Clone, Debug, PartialEq, Eq)]
472pub struct Plugin {
473 pub prefix: String,
474 pub artifact: String,
475 pub name: String,
476}
477
478/// The `<plugins>` of a group's `maven-metadata.xml`, by prefix, which is
479/// how Maven finds `mvn <prefix>:<goal>` among the groups it is told of.
480pub fn plugins_block(plugins: &[Plugin]) -> String {
481 let mut sorted: Vec<&Plugin> = plugins.iter().collect();
482 sorted.sort_by(|a, b| (&a.prefix, &a.artifact).cmp(&(&b.prefix, &b.artifact)));
483 let mut xml = String::from(" <plugins>
484");
485 for plugin in sorted {
486 xml.push_str(&format!(
487 " <plugin>
488 <name>{}</name>
489 <prefix>{}</prefix>
490 <artifactId>{}</artifactId>
491 </plugin>
492",
493 xml::escape(&plugin.name),
494 xml::escape(&plugin.prefix),
495 xml::escape(&plugin.artifact)
496 ));
497 }
498 xml.push_str(" </plugins>
499");
500 xml
501}
502
503/// A group's `maven-metadata.xml`: its plugins alone, or added to an
504/// artifact's metadata when the path is both.
505pub fn group_metadata(artifact_xml: Option<String>, plugins: &[Plugin]) -> String {
506 let block = plugins_block(plugins);
507 match artifact_xml {
508 Some(xml) => match xml.rfind("</metadata>") {
509 Some(at) => format!("{}{block}{}", &xml[..at], &xml[at..]),
510 None => xml,
511 },
512 None => format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>
513<metadata>
514{block}</metadata>
515"),
516 }
517}
518
Merge branch 'worktree-agent-a6a121745e81f639f'519#[cfg(test)]
520mod tests {
521 use super::*;
522
523 fn file(group: &str, artifact: &str, version: &str, file: &str, checksum: Option<Checksum>) -> Option<(String, MavenPath)> {
524 Some((
525 "acme".to_owned(),
526 MavenPath::File { group: group.into(), artifact: artifact.into(), version: version.into(), file: file.into(), checksum },
527 ))
528 }
529
530 #[test]
531 fn paths_are_the_standard_layout() {
532 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));
533 assert_eq!(
534 route("/-/maven/acme/com/acme/web/1.0.0/web-1.0.0.pom.sha1"),
535 file("com.acme", "web", "1.0.0", "web-1.0.0.pom", Some(Checksum::Sha1))
536 );
537 assert_eq!(
538 route("/-/maven/acme/io/g1t/core-lib/2.1/core-lib-2.1-sources.jar.asc"),
539 file("io.g1t", "core-lib", "2.1", "core-lib-2.1-sources.jar.asc", None)
540 );
541 assert_eq!(
542 route("/-/maven/acme/com/acme/web/1.0-SNAPSHOT/web-1.0-20261006.120000-3.module.sha512"),
543 file("com.acme", "web", "1.0-SNAPSHOT", "web-1.0-20261006.120000-3.module", Some(Checksum::Sha512))
544 );
545 assert_eq!(
546 route("/-/maven/acme/com/acme/web/maven-metadata.xml"),
547 Some(("acme".into(), MavenPath::ArtifactMetadata { group: "com.acme".into(), artifact: "web".into(), checksum: None }))
548 );
549 assert_eq!(
550 route("/-/maven/acme/com/acme/web/maven-metadata.xml.md5"),
551 Some(("acme".into(), MavenPath::ArtifactMetadata { group: "com.acme".into(), artifact: "web".into(), checksum: Some(Checksum::Md5) }))
552 );
553 assert_eq!(
554 route("/-/maven/acme/com/acme/web/1.0-SNAPSHOT/maven-metadata.xml"),
555 Some((
556 "acme".into(),
557 MavenPath::VersionMetadata { group: "com.acme".into(), artifact: "web".into(), version: "1.0-SNAPSHOT".into(), checksum: None }
558 ))
559 );
560 // Not a file of the artifact, or not in the layout.
561 assert_eq!(route("/-/maven/acme/com/acme/web/1.0.0/other-1.0.0.jar"), None);
562 assert_eq!(route("/-/maven/acme/com/acme/web/1.0.0/web-1.0.1"), None);
563 assert_eq!(route("/-/maven/acme/web/1.0.0/web-1.0.0.jar"), None, "no groupId");
564 assert_eq!(route("/-/maven/acme/com/../web/1.0.0/web-1.0.0.jar"), None);
565 assert_eq!(route("/-/maven/acme/com/acme/web/1.0.0/"), None);
566 assert_eq!(route("/-/maven/acme/maven-metadata.xml"), None);
Merge branch 'worktree-agent-ac1de8a731938ed81'567 assert_eq!(
568 route("/-/maven/acme/acme/maven-metadata.xml.sha1"),
569 Some(("acme".into(), MavenPath::GroupMetadata { group: "acme".into(), checksum: Some(Checksum::Sha1) }))
570 );
Merge branch 'worktree-agent-a6a121745e81f639f'571 assert_eq!(route("/-/maven/"), None);
572 }
573
574 #[test]
575 fn file_names_say_their_classifier_extension_and_build() {
576 assert_eq!(parse_file("web", "1.0.0", "web-1.0.0.jar"), Some(FileName { classifier: None, extension: "jar".into(), build: None }));
577 assert_eq!(
578 parse_file("web", "1.0.0", "web-1.0.0-javadoc.jar"),
579 Some(FileName { classifier: Some("javadoc".into()), extension: "jar".into(), build: None })
580 );
581 assert_eq!(parse_file("web", "1.0.0", "web-1.0.0.tar.gz").unwrap().extension, "tar.gz");
582 let stamped = parse_file("web", "1.0-SNAPSHOT", "web-1.0-20261006.120000-12-sources.jar").unwrap();
583 assert_eq!(stamped.build, Some(("20261006.120000".into(), 12)));
584 assert_eq!(stamped.classifier.as_deref(), Some("sources"));
585 assert_eq!(stamped.value("1.0-SNAPSHOT"), "1.0-20261006.120000-12");
586 assert_eq!(parse_file("web", "1.0-SNAPSHOT", "web-1.0-SNAPSHOT.jar").unwrap().build, None);
587 assert_eq!(parse_file("web", "1.0.0", "web-1.0.0"), None);
588 assert_eq!(parse_file("web", "1.0.0", "web-1.0.0-.jar"), None);
589 assert_eq!(parse_file("web", "1.0", "web-1.0-20261006.120000-1.jar").unwrap().build, None, "timestamps are a SNAPSHOT's");
590 }
591
592 #[test]
593 fn versions_sort_as_maven_sorts_them() {
594 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"];
595 versions.sort_by(|a, b| compare(a, b));
596 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"]);
597 assert_eq!(compare("1.0", "1.0.0"), Ordering::Equal);
598 assert_eq!(compare("1.0-ga", "1"), Ordering::Equal);
599 assert!(valid_version("1.0.0") && valid_version("2024.1+build") && !valid_version("-1") && !valid_version("1 0"));
600 }
601
602 #[test]
603 fn artifact_metadata_lists_versions_with_latest_and_release() {
604 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");
605 let doc = xml::parse(&xml).unwrap();
606 let versioning = doc.child("versioning").unwrap();
607 assert_eq!(doc.child_text("groupId").as_deref(), Some("com.acme"));
608 assert_eq!(versioning.child_text("latest").as_deref(), Some("1.2.0-SNAPSHOT"));
609 assert_eq!(versioning.child_text("release").as_deref(), Some("1.1.0"));
610 let listed: Vec<String> = versioning.child("versions").unwrap().children_named("version").map(|v| v.text.clone()).collect();
611 assert_eq!(listed, ["1.0.0", "1.1.0", "1.2.0-SNAPSHOT"]);
612 assert_eq!(versioning.child_text("lastUpdated").as_deref(), Some("20261006123005"));
613 }
614
615 #[test]
616 fn snapshot_metadata_names_the_newest_build_of_each_file() {
617 let files: Vec<String> = [
618 "web-1.0-20261006.120000-1.jar",
619 "web-1.0-20261006.120000-1.pom",
620 "web-1.0-20261007.090000-2.jar",
621 "web-1.0-20261007.090000-2.pom",
622 "web-1.0-20261006.120000-1-sources.jar",
623 ]
624 .iter()
625 .map(|s| s.to_string())
626 .collect();
627 let xml = snapshot_metadata("com.acme", "web", "1.0-SNAPSHOT", &files).unwrap();
628 let doc = xml::parse(&xml).unwrap();
629 assert_eq!(doc.child_text("version").as_deref(), Some("1.0-SNAPSHOT"));
630 let versioning = doc.child("versioning").unwrap();
631 let snapshot = versioning.child("snapshot").unwrap();
632 assert_eq!(snapshot.child_text("timestamp").as_deref(), Some("20261007.090000"));
633 assert_eq!(snapshot.child_text("buildNumber").as_deref(), Some("2"));
634 let shown: Vec<(Option<String>, String, String)> = versioning
635 .child("snapshotVersions")
636 .unwrap()
637 .children_named("snapshotVersion")
638 .map(|v| (v.child_text("classifier"), v.child_text("extension").unwrap(), v.child_text("value").unwrap()))
639 .collect();
640 assert_eq!(
641 shown,
642 [
643 (None, "jar".to_owned(), "1.0-20261007.090000-2".to_owned()),
644 (Some("sources".to_owned()), "jar".to_owned(), "1.0-20261006.120000-1".to_owned()),
645 (None, "pom".to_owned(), "1.0-20261007.090000-2".to_owned()),
646 ]
647 );
648 assert_eq!(snapshot_metadata("com.acme", "web", "1.0-SNAPSHOT", &[]), None);
649 assert_eq!(newest_build("web", "1.0-SNAPSHOT", &files), Some(("20261007.090000".to_owned(), 2)));
650 assert_eq!(newest_build("web", "1.0-SNAPSHOT", &["web-1.0-SNAPSHOT.jar".to_owned()]), None);
651 }
652
653 #[test]
654 fn a_pom_says_its_coordinates_and_source() {
655 let pom = read_pom(
656 br#"<?xml version="1.0"?>
657<project xmlns="http://maven.apache.org/POM/4.0.0">
658 <modelVersion>4.0.0</modelVersion>
659 <parent><groupId>com.acme</groupId><artifactId>parent</artifactId><version>1.0.0</version></parent>
660 <artifactId>web</artifactId>
661 <name>Acme web</name>
662 <description>The web client</description>
663 <url>https://acme.example</url>
664 <scm><url>https://g1t.sh/acme/web</url></scm>
665</project>"#,
666 )
667 .unwrap();
668 assert_eq!((pom.group.as_str(), pom.artifact.as_str(), pom.version.as_str()), ("com.acme", "web", "1.0.0"));
669 assert_eq!(pom.description.as_deref(), Some("The web client"));
670 assert_eq!(pom.source.as_deref(), Some("https://g1t.sh/acme/web"));
Merge branch 'worktree-agent-ac1de8a731938ed81'671 assert_eq!(pom.packaging, "jar");
672 let plugin = read_pom(b"<project><groupId>com.acme</groupId><artifactId>acme-maven-plugin</artifactId><version>1</version><packaging>maven-plugin</packaging></project>").unwrap();
673 assert_eq!(plugin.packaging, "maven-plugin");
Merge branch 'worktree-agent-a6a121745e81f639f'674 assert!(read_pom(b"<project><artifactId>x</artifactId></project>").is_err(), "no groupId");
675 assert!(read_pom(b"not xml").is_err());
676 }
677
678 #[test]
Merge branch 'worktree-agent-ac1de8a731938ed81'679 fn plugins_are_listed_by_prefix() {
680 assert_eq!(default_prefix("acme-maven-plugin"), "acme");
681 assert_eq!(default_prefix("maven-acme-plugin"), "acme");
682 assert_eq!(default_prefix("hello-plugin"), "hello");
683 assert_eq!(default_prefix("maven-plugin-plugin"), "plugin");
684 assert_eq!(default_prefix("tools"), "tools");
685 assert_eq!(
686 plugin_descriptor("<plugin><name>Acme</name><groupId>com.acme</groupId><goalPrefix>acme</goalPrefix><mojos/></plugin>"),
687 Some((Some("acme".to_owned()), Some("Acme".to_owned())))
688 );
689 assert_eq!(plugin_descriptor("<project/>"), None);
690 let plugins = [
691 Plugin { prefix: "zed".into(), artifact: "zed-maven-plugin".into(), name: "Zed".into() },
692 Plugin { prefix: "acme".into(), artifact: "acme-maven-plugin".into(), name: "Acme & co".into() },
693 ];
694 let doc = xml::parse(&group_metadata(None, &plugins)).unwrap();
695 let listed: Vec<(String, String, String)> = doc
696 .child("plugins")
697 .unwrap()
698 .children_named("plugin")
699 .map(|p| (p.child_text("prefix").unwrap(), p.child_text("artifactId").unwrap(), p.child_text("name").unwrap()))
700 .collect();
701 assert_eq!(
702 listed,
703 [
704 ("acme".to_owned(), "acme-maven-plugin".to_owned(), "Acme & co".to_owned()),
705 ("zed".to_owned(), "zed-maven-plugin".to_owned(), "Zed".to_owned())
706 ]
707 );
708 // A path that is an artifact and a group says both.
709 let both = group_metadata(Some(artifact_metadata("com", "acme", &["1.0".into()], "2026-10-06T00:00:00Z")), &plugins[..1]);
710 let doc = xml::parse(&both).unwrap();
711 assert!(doc.child("versioning").is_some() && doc.child("plugins").is_some());
712 }
713
714 #[test]
Merge branch 'worktree-agent-a6a121745e81f639f'715 fn checksums_are_hex_of_the_file() {
716 assert_eq!(split_checksum("web-1.0.jar.sha1"), ("web-1.0.jar", Some(Checksum::Sha1)));
717 assert_eq!(split_checksum("web-1.0.jar"), ("web-1.0.jar", None));
718 assert_eq!(Checksum::Md5.of(b"abc"), "900150983cd24fb0d6963f7d28e17f72");
719 assert_eq!(Checksum::Sha1.of(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d");
720 assert_eq!(sent_checksum(b"A9993E364706816ABA3E25717850C26C9CD0D89D web-1.0.jar\n"), "a9993e364706816aba3e25717850c26c9cd0d89d");
721 let sums = crate::db::Checksums::of(b"abc");
722 assert_eq!(Checksum::Sha1.pick(&sums, "x"), Checksum::Sha1.of(b"abc"));
723 assert_eq!(Checksum::Sha512.pick(&sums, "x"), Checksum::Sha512.of(b"abc"));
724 }
725}

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