Commit

Maven: a group's maven-metadata.xml lists its plugins by prefix, so mvn <prefix>:<goal> resolves; Gradle's publish and resolve checked against it

syntaqxcommitted Parent73aad90Browse files
4 files+287−90/4 viewed
+50−4
122122 `credentials(PasswordCredentials::class)` reads `acmeUsername` and
123123 `acmePassword` from `gradle.properties` or from the environment as
124124 `ORG_GRADLE_PROJECT_acmeUsername` and `ORG_GRADLE_PROJECT_acmePassword`.
125−Gradle's Gradle Module Metadata (`.module`) is uploaded and served beside
126−the POM.
127125
126+Gradle uploads the jar, the POM, the sources and javadoc jars if you build
127+them, and its Gradle Module Metadata (`.module`), each with its `.md5`,
128+`.sha1`, `.sha256` and `.sha512`. The `.module` file is served beside the
129+POM, so a Gradle build that depends on the artifact reads its variants
130+(API and runtime dependencies, capabilities) from it, and Maven reads the
131+POM. Gradle's `HEAD` requests, which it makes to check files it has
132+cached (with `--refresh-dependencies`, or for a SNAPSHOT), are answered
133+with each file's size and type.
134+
128135 ## Which repository an artifact belongs to
129136
130137 The first file deployed makes the artifact. When a repository of the
182189 -DremoteRepositories=acme::default::https://g1t.sh/-/maven/acme/
183190 ```
184191
192+## Maven plugins
193+
194+A plugin is deployed like any other artifact, with `<packaging>maven-plugin</packaging>`.
195+g1t lists the plugins of each group in the group's own
196+`maven-metadata.xml` (`com/acme/maven-metadata.xml` for the group
197+`com.acme`), with the prefix each is called by: the `goalPrefix` from the
198+descriptor `maven-plugin-plugin` puts in its jar, else the one Maven
199+works out from its artifactId (`hello-maven-plugin` is `hello`).
200+
201+To call a plugin by its prefix, as `mvn hello:greet`, name its group in
202+`~/.m2/settings.xml` and the repository as a plugin repository:
203+
204+```xml
205+<settings>
206+ <pluginGroups>
207+ <pluginGroup>com.acme</pluginGroup>
208+ </pluginGroups>
209+ <profiles>
210+ <profile>
211+ <id>acme</id>
212+ <pluginRepositories>
213+ <pluginRepository>
214+ <id>acme</id>
215+ <url>https://g1t.sh/-/maven/acme/</url>
216+ </pluginRepository>
217+ </pluginRepositories>
218+ </profile>
219+ </profiles>
220+ <activeProfiles>
221+ <activeProfile>acme</activeProfile>
222+ </activeProfiles>
223+</settings>
224+```
225+
226+The group's metadata lists only the plugins the credentials' owner may
227+see. A plugin's full coordinates (`mvn com.acme:hello-maven-plugin:1.0.0:greet`)
228+work without the plugin group.
229+
185230 ## SNAPSHOTs
186231
187232 A version ending in `-SNAPSHOT` takes a new build each time it is
202247 uploaded are checked against it, and a mismatch is refused with `400`.
203248 - **`maven-metadata.xml` is made by g1t** from the versions there, so it
204249 always lists every version, with the highest as `latest` and the highest
205− that is not a SNAPSHOT as `release`. The one a build uploads is accepted
206− and not kept.
250+ that is not a SNAPSHOT as `release`, and a group's lists its
251+ [plugins](#maven-plugins). The one a build uploads is accepted and not
252+ kept.
207253 - **The deploy's last step publishes it.** Maven and Gradle upload the
208254 artifact's `maven-metadata.xml` after its files. Then each version (or
209255 SNAPSHOT build) whose POM arrived in the deploy is published: it is an
+16−0
11921192 .results()
11931193 }
11941194
1195+ /// A workspace's Maven artifacts of one groupId (`com.acme:*`), by
1196+ /// name: those named from `com.acme:` up to `com.acme;`, the
1197+ /// character after `:`.
1198+ pub async fn maven_group(&self, workspace: &str, group: &str, limit: u32) -> Result<Vec<PackageRow>> {
1199+ self.prepare(
1200+ &format!(
1201+ "SELECT {PACKAGE_COLUMNS} FROM packages WHERE workspace = ? AND ecosystem = 'maven' AND name >= ? AND name < ?
1202+ AND workspace_deleted_at IS NULL ORDER BY name LIMIT {limit}"
1203+ ),
1204+ &[text(workspace), text(&format!("{group}:")), text(&format!("{group};"))],
1205+ )?
1206+ .all()
1207+ .await?
1208+ .results()
1209+ }
1210+
11951211 pub async fn forget_blob(&self, digest: &str) -> Result<()> {
11961212 let d = [text(digest)];
11971213 self.db
+133−0
114114 ArtifactMetadata { group: String, artifact: String, checksum: Option<Checksum> },
115115 /// `com/acme/web/1.0-SNAPSHOT/maven-metadata.xml`: a SNAPSHOT's builds.
116116 VersionMetadata { group: String, artifact: String, version: String, checksum: Option<Checksum> },
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> },
117122 /// `com/acme/web/1.0.0/web-1.0.0.jar`: one of a version's files.
118123 File { group: String, artifact: String, version: String, file: String, checksum: Option<Checksum> },
119124 }
138143 let (artifact, version) = (parts[n - 3].to_owned(), parts[n - 2].to_owned());
139144 return Some((workspace, MavenPath::VersionMetadata { group, artifact, version, checksum }));
140145 }
146+ if n == 2 && valid_group_part(parts[0]) {
147+ return Some((workspace, MavenPath::GroupMetadata { group: parts[0].to_owned(), checksum }));
148+ }
141149 if n < 3 || !valid_artifact(parts[n - 2]) {
142150 return None;
143151 }
405413 pub description: Option<String>,
406414 /// `<scm><url>`, else `<url>`: where its source is.
407415 pub source: Option<String>,
416+ /// `jar` when it names none; `maven-plugin` for a plugin.
417+ pub packaging: String,
408418 }
409419
410420 /// Reads a POM, with the groupId and version a `<parent>` gives it.
423433 name: project.child_text("name"),
424434 description: project.child_text("description"),
425435 source: project.child("scm").and_then(|scm| scm.child_text("url")).or_else(|| project.child_text("url")),
436+ packaging: project.child_text("packaging").unwrap_or_else(|| "jar".to_owned()),
426437 })
427438 }
428439
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+
429519 #[cfg(test)]
430520 mod tests {
431521 use super::*;
474564 assert_eq!(route("/-/maven/acme/com/../web/1.0.0/web-1.0.0.jar"), None);
475565 assert_eq!(route("/-/maven/acme/com/acme/web/1.0.0/"), None);
476566 assert_eq!(route("/-/maven/acme/maven-metadata.xml"), None);
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+ );
477571 assert_eq!(route("/-/maven/"), None);
478572 }
479573
574668 assert_eq!((pom.group.as_str(), pom.artifact.as_str(), pom.version.as_str()), ("com.acme", "web", "1.0.0"));
575669 assert_eq!(pom.description.as_deref(), Some("The web client"));
576670 assert_eq!(pom.source.as_deref(), Some("https://g1t.sh/acme/web"));
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");
577674 assert!(read_pom(b"<project><artifactId>x</artifactId></project>").is_err(), "no groupId");
578675 assert!(read_pom(b"not xml").is_err());
579676 }
580677
581678 #[test]
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]
582715 fn checksums_are_hex_of_the_file() {
583716 assert_eq!(split_checksum("web-1.0.jar.sha1"), ("web-1.0.jar", Some(Checksum::Sha1)));
584717 assert_eq!(split_checksum("web-1.0.jar"), ("web-1.0.jar", None));
+88−5
2525 use worker::{Context, Headers, Method, Request, Response, ResponseBody, Result};
2626
2727 use crate::access::{self, Action};
28+use crate::archive;
2829 use crate::db::{Checksums, NewFile, NewVersion, PackageRow, VersionRow};
2930 use crate::digest::Digest;
3031 use crate::maven::{self, Checksum, MavenPath};
3839 const MAX_VERSIONS: u32 = 5000;
3940 /// The longest POM read for its description and source.
4041 const MAX_POM_BYTES: usize = 1024 * 1024;
42+/// The most artifacts of a group read for its plugins.
43+const MAX_GROUP: u32 = 500;
44+/// Where a plugin's jar keeps its descriptor, and the most read of it.
45+const PLUGIN_DESCRIPTOR: &str = "META-INF/maven/plugin.xml";
46+const MAX_DESCRIPTOR_BYTES: usize = 4 * 1024 * 1024;
4147 const DOCS: &str = "https://docs.g1t.sh/guides/maven/";
4248 const TOKENS: &str = "https://g1t.sh/settings/tokens";
4349
106112 (MavenPath::ArtifactMetadata { group, artifact, checksum }, Method::Get | Method::Head) => {
107113 self.maven_metadata(workspace, &group, &artifact, None, checksum, viewer, head).await
108114 }
115+ (MavenPath::GroupMetadata { group, checksum }, Method::Get | Method::Head) => {
116+ self.maven_group_metadata(workspace, &group, None, checksum, viewer, head).await
117+ }
109118 (MavenPath::VersionMetadata { group, artifact, version, checksum }, Method::Get | Method::Head) => {
110119 self.maven_metadata(workspace, &group, &artifact, Some(&version), checksum, viewer, head).await
111120 }
120129 let name = maven::package_name(&group, &artifact);
121130 self.maven_checksum(&mut request, workspace, &name, &version, &file, checksum, viewer).await
122131 }
123− (path @ (MavenPath::ArtifactMetadata { .. } | MavenPath::VersionMetadata { .. }), Method::Put) => {
132+ (path @ (MavenPath::ArtifactMetadata { .. } | MavenPath::VersionMetadata { .. } | MavenPath::GroupMetadata { .. }), Method::Put) => {
124133 self.maven_metadata_upload(&mut request, workspace, &path, viewer).await
125134 }
126135 _ => error(405, "Not a method this address takes. Versions are deleted on the package's page."),
172181 viewer: Option<&User>,
173182 head: bool,
174183 ) -> Result<Response> {
175− let Some(package) = self.maven_package(workspace, &maven::package_name(group, artifact)).await? else {
184+ let found = self.maven_package(workspace, &maven::package_name(group, artifact)).await?;
185+ // `com/acme/plugins/maven-metadata.xml` is also the group
186+ // `com.acme.plugins`'s, which lists its plugins.
187+ let Some(package) = found else {
188+ if snapshot.is_none() {
189+ return self.maven_group_metadata(workspace, &format!("{group}.{artifact}"), None, checksum, viewer, head).await;
190+ }
176191 return self.maven_absent(workspace, viewer).await;
177192 };
178193 if let Some(refusal) = self.maven_check(viewer, &package, Action::Pull).await? {
184199 if versions.is_empty() {
185200 return self.maven_absent(workspace, viewer).await;
186201 }
187− maven::artifact_metadata(group, artifact, &versions, &package.updated_at)
202+ let xml = maven::artifact_metadata(group, artifact, &versions, &package.updated_at);
203+ return self.maven_group_metadata(workspace, &format!("{group}.{artifact}"), Some(xml), checksum, viewer, head).await;
188204 }
189205 Some(version) => {
190206 let Some(row) = self.db.version_named(&package.id, version).await? else {
203219 }
204220 }
205221
222+ /// A group's `maven-metadata.xml`: the plugins among its artifacts the
223+ /// viewer may see, by prefix, so `mvn <prefix>:<goal>` finds them when
224+ /// the group is one of its `<pluginGroups>`. `artifact` is the
225+ /// metadata of an artifact at the same path, which it is added to.
226+ async fn maven_group_metadata(
227+ &self,
228+ workspace: &str,
229+ group: &str,
230+ artifact: Option<String>,
231+ checksum: Option<Checksum>,
232+ viewer: Option<&User>,
233+ head: bool,
234+ ) -> Result<Response> {
235+ let mut plugins = Vec::new();
236+ for package in self.db.maven_group(workspace, group, MAX_GROUP).await? {
237+ if !access::decide(viewer, &TargetOf::package(&package).view(), Action::Pull).allowed {
238+ continue;
239+ }
240+ let artifact_id = package.name.rsplit(':').next().unwrap_or("").to_owned();
241+ // The highest version that is a plugin says its prefix and name.
242+ let mut versions = self.db.versions(&package.id, MAX_VERSIONS).await?;
243+ versions.sort_by(|a, b| maven::compare(&b.version, &a.version));
244+ let Some(meta) = versions.iter().map(VersionRow::meta).find(|m| m["packaging"] == "maven-plugin" || m["plugin"].is_object()) else {
245+ continue;
246+ };
247+ let text = |value: &Value| value.as_str().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned);
248+ plugins.push(maven::Plugin {
249+ prefix: text(&meta["plugin"]["prefix"]).unwrap_or_else(|| maven::default_prefix(&artifact_id)),
250+ name: text(&meta["name"]).or_else(|| text(&meta["plugin"]["name"])).unwrap_or_else(|| artifact_id.clone()),
251+ artifact: artifact_id,
252+ });
253+ }
254+ let xml = match (artifact, plugins.is_empty()) {
255+ (artifact, false) => maven::group_metadata(artifact, &plugins),
256+ (Some(xml), true) => xml,
257+ (None, true) => return self.maven_absent(workspace, viewer).await,
258+ };
259+ match checksum {
260+ Some(checksum) => serve(checksum.of(xml.as_bytes()).into_bytes(), "text/plain", head, "no-cache"),
261+ None => serve(xml.into_bytes(), "application/xml", head, "no-cache"),
262+ }
263+ }
264+
206265 /// One of a version's files, or a checksum of it.
207266 #[allow(clippy::too_many_arguments)]
208267 async fn maven_file(
360419 None
361420 };
362421
422+ // The main jar of a Maven plugin holds its descriptor.
423+ let plugin = if parsed.classifier.is_none() && parsed.extension == "jar" {
424+ archive::zip_entries(&bytes)
425+ .ok()
426+ .and_then(|entries| entries.into_iter().find(|e| e.name == PLUGIN_DESCRIPTOR))
427+ .and_then(|entry| archive::zip_read(&bytes, &entry, MAX_DESCRIPTOR_BYTES).ok())
428+ .and_then(|xml| maven::plugin_descriptor(&String::from_utf8_lossy(&xml)))
429+ } else {
430+ None
431+ };
432+
363433 let name = maven::package_name(group, artifact);
364434 let found = self.db.package(workspace, MAVEN, &name).await?;
365435 if found.as_ref().is_some_and(PackageRow::hidden) || (found.is_none() && self.db.workspace_hidden(workspace).await?) {
445515
446516 if let Some(pom) = pom {
447517 self.maven_pom(&package, &row, &digest, &pom, viewer).await?;
518+ } else if let Some((prefix, title)) = plugin {
519+ // A plugin's jar names the prefix it is called by.
520+ let mut metadata = self.db.version_named(&package.id, version).await?.map(|v| v.meta()).unwrap_or_default();
521+ if !metadata.is_object() {
522+ metadata = json!({});
523+ }
524+ metadata["plugin"] = json!({ "prefix": prefix, "name": title });
525+ self.db.set_version(&row.id, &row.digest, &metadata.to_string()).await?;
448526 }
449527 created()
450528 }
463541 metadata["name"] = json!(pom.name);
464542 metadata["description"] = json!(pom.description);
465543 metadata["source"] = json!(pom.source);
544+ metadata["packaging"] = json!(pom.packaging);
466545 self.db.set_version(&row.id, &digest.to_string(), &metadata.to_string()).await?;
467546 let versions = self.db.versions(&package.id, MAX_VERSIONS).await?;
468547 let releases: Vec<&str> = versions.iter().map(|v| v.version.as_str()).filter(|v| !maven::is_snapshot(v)).collect();
579658 if let Err(refused) = self.maven_body(request).await? {
580659 return Ok(refused);
581660 }
582− let (MavenPath::ArtifactMetadata { group, artifact, .. } | MavenPath::VersionMetadata { group, artifact, .. } | MavenPath::File { group, artifact, .. }) = path;
583− let found = self.maven_package(workspace, &maven::package_name(group, artifact)).await?;
661+ let found = match path {
662+ MavenPath::ArtifactMetadata { group, artifact, .. } | MavenPath::VersionMetadata { group, artifact, .. } | MavenPath::File { group, artifact, .. } => {
663+ self.maven_package(workspace, &maven::package_name(group, artifact)).await?
664+ }
665+ MavenPath::GroupMetadata { .. } => None,
666+ };
584667 let target = match &found {
585668 Some(package) => TargetOf::package(package),
586669 // A plugin group's metadata names no artifact of its own.