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 | ||
| 12 | use std::cmp::Ordering; | |
| 13 | ||
| 14 | use crate::xml; | |
| 15 | ||
| 16 | /// The checksum files Maven and Gradle upload and ask for beside a file. | |
| 17 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 18 | pub enum Checksum { | |
| 19 | Md5, | |
| 20 | Sha1, | |
| 21 | Sha256, | |
| 22 | Sha512, | |
| 23 | } | |
| 24 | ||
| 25 | impl 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. | |
| 60 | pub 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. | |
| 71 | pub fn sent_checksum(body: &[u8]) -> String { | |
| 72 | String::from_utf8_lossy(body).split_whitespace().next().unwrap_or("").to_ascii_lowercase() | |
| 73 | } | |
| 74 | ||
| 75 | const METADATA: &str = "maven-metadata.xml"; | |
| 76 | pub const MAX_PART: usize = 128; | |
| 77 | ||
| 78 | /// One directory of a groupId: `com` and `acme` of `com.acme`. | |
| 79 | fn 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 `.`. | |
| 84 | pub 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. | |
| 93 | pub 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 | ||
| 101 | pub fn is_snapshot(version: &str) -> bool { | |
| 102 | version.ends_with("-SNAPSHOT") | |
| 103 | } | |
| 104 | ||
| 105 | /// The package's name: `com.acme:web`. | |
| 106 | pub 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)] | |
| 112 | pub 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. | |
| 128 | pub 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)] | |
| 174 | pub 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 | ||
| 181 | impl 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. | |
| 192 | fn 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 | ||
| 202 | pub 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. | |
| 228 | pub 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)] | |
| 241 | enum 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`. | |
| 249 | fn 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 | ||
| 262 | fn 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`. | |
| 302 | pub 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`. | |
| 323 | pub 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`. | |
| 329 | pub 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. | |
| 352 | pub 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. | |
| 361 | pub 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(×tamp))); | |
| 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)] | |
| 408 | pub 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. | |
| 421 | pub 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. | |
| 443 | pub 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. | |
| 465 | pub 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)] | |
| 472 | pub 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. | |
| 480 | pub 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. | |
| 505 | pub 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)] |
| 520 | mod 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.