| 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> }, |
| 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. |
| 123 | pub 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)] |
| 166 | pub 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 | |
| 173 | impl 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. |
| 184 | fn 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 | |
| 194 | pub 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. |
| 220 | pub 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)] |
| 233 | enum 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`. |
| 241 | fn 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 | |
| 254 | fn 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`. |
| 294 | pub 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`. |
| 315 | pub 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`. |
| 321 | pub 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. |
| 344 | pub 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. |
| 353 | pub 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(×tamp))); |
| 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)] |
| 400 | pub 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. |
| 411 | pub 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)] |
| 430 | mod 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 | } |