Add support for publishing maven-metadata.xml (#1260)

Maven repositories normally have a maven-metadata.xml file that indicate to the Maven system what versions are available and which is to be considered the latest version.

```xml
<metadata modelVersion="1.1.0">
    <groupId>com.mycompany.app</groupId>
    <artifactId>my-app</artifactId>
    <versioning>
        <latest>1.0</latest>
        <release>1.0</release>
        <versions>
            <version>1.0</version>
        </versions>
        <lastUpdated>20200731090423</lastUpdated>
    </versioning>
</metadata>
```

At Confluent, we use AWS Code Artifactory which does not mark a Maven package as "published" unless a new maven-metadata.xml is uploaded indicating so.

Add support for reading existing maven-metadata.xml
Add support for adding the new version to the metadata object
Add support to upload the file for http, file, s3 protocols

Co-authored-by: Vince Rose <vrose@confluent.io>
Co-authored-by: Na Lou <nlou@confluent.io>
diff --git a/MODULE.bazel b/MODULE.bazel
index c7b09f1..79a0038 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -81,6 +81,7 @@
         "org.apache.maven:maven-core:%s" % _MAVEN_VERSION,
         "org.apache.maven:maven-model:%s" % _MAVEN_VERSION,
         "org.apache.maven:maven-model-builder:%s" % _MAVEN_VERSION,
+        "org.apache.maven:maven-repository-metadata:%s" % _MAVEN_VERSION,
         "org.apache.maven:maven-settings:%s" % _MAVEN_VERSION,
         "org.apache.maven:maven-settings-builder:%s" % _MAVEN_VERSION,
         "org.apache.maven:maven-resolver-provider:%s" % _MAVEN_VERSION,
@@ -99,6 +100,7 @@
         "org.slf4j:log4j-over-slf4j:2.0.12",
         "org.slf4j:slf4j-simple:2.0.12",
         "software.amazon.awssdk:s3:2.26.12",
+        "software.amazon.awssdk:sdk-core:2.26.12",
         "org.bouncycastle:bcprov-jdk15on:1.68",
         "org.bouncycastle:bcpg-jdk15on:1.68",
         "org.gradle:gradle-tooling-api:%s" % _GRADLE_VERSION,
diff --git a/docs/api.md b/docs/api.md
index 4ea73d7..de01aef 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -50,7 +50,7 @@
 
 java_export(<a href="#java_export-name">name</a>, <a href="#java_export-maven_coordinates">maven_coordinates</a>, <a href="#java_export-manifest_entries">manifest_entries</a>, <a href="#java_export-deploy_env">deploy_env</a>, <a href="#java_export-excluded_workspaces">excluded_workspaces</a>, <a href="#java_export-exclusions">exclusions</a>,
             <a href="#java_export-pom_template">pom_template</a>, <a href="#java_export-allowed_duplicate_names">allowed_duplicate_names</a>, <a href="#java_export-visibility">visibility</a>, <a href="#java_export-tags">tags</a>, <a href="#java_export-testonly">testonly</a>, <a href="#java_export-classifier_artifacts">classifier_artifacts</a>,
-            <a href="#java_export-kwargs">kwargs</a>)
+            <a href="#java_export-publish_maven_metadata">publish_maven_metadata</a>, <a href="#java_export-kwargs">kwargs</a>)
 </pre>
 
 Extends `java_library` to allow maven artifacts to be uploaded.
@@ -113,6 +113,7 @@
 | <a id="java_export-tags"></a>tags |  <p align="center"> - </p>   |  `[]` |
 | <a id="java_export-testonly"></a>testonly |  <p align="center"> - </p>   |  `None` |
 | <a id="java_export-classifier_artifacts"></a>classifier_artifacts |  A dict of classifier -> artifact of additional artifacts to publish to Maven.   |  `{}` |
+| <a id="java_export-publish_maven_metadata"></a>publish_maven_metadata |  Whether to publish a maven-metadata.xml to remote repository. Some repositories (like AWS CodeArtifact) require the client to publish this file. It is disabled by default.   |  `False` |
 | <a id="java_export-kwargs"></a>kwargs |  <p align="center"> - </p>   |  none |
 
 
diff --git a/private/dependency_tree_parser.bzl b/private/dependency_tree_parser.bzl
index bfb9433..7d93543 100644
--- a/private/dependency_tree_parser.bzl
+++ b/private/dependency_tree_parser.bzl
@@ -29,7 +29,7 @@
     "strip_packaging_and_classifier_and_version",
     "to_repository_name",
 )
-load("//private/lib:coordinates.bzl", "unpack_coordinates", "to_purl")
+load("//private/lib:coordinates.bzl", "to_purl", "unpack_coordinates")
 load("//private/lib:urls.bzl", "scheme_and_host")
 
 def _genrule_copy_artifact_from_http_file(artifact, visibilities):
diff --git a/private/rules/java_export.bzl b/private/rules/java_export.bzl
index 4fc8b37..055b33f 100644
--- a/private/rules/java_export.bzl
+++ b/private/rules/java_export.bzl
@@ -19,6 +19,7 @@
         tags = [],
         testonly = None,
         classifier_artifacts = {},
+        publish_maven_metadata = False,
         **kwargs):
     """Extends `java_library` to allow maven artifacts to be uploaded.
 
@@ -92,6 +93,8 @@
         end of the package name. For example, `com.example.*` will include all the subpackages of `com.example`, while
         `com.example` will include only the files directly in `com.example`
       visibility: The visibility of the target
+      publish_maven_metadata: Whether to publish a maven-metadata.xml to remote repository. Some repositories
+            (like AWS CodeArtifact) require the client to publish this file. It is disabled by default.
       kwargs: These are passed to [`java_library`](https://bazel.build/reference/be/java#java_library),
         and so may contain any valid parameter for that rule.
     """
@@ -137,6 +140,7 @@
         testonly = testonly,
         javadocopts = javadocopts,
         classifier_artifacts = classifier_artifacts,
+        publish_maven_metadata = publish_maven_metadata,
         doc_deps = doc_deps,
         doc_url = doc_url,
         doc_resources = doc_resources,
@@ -167,6 +171,7 @@
         doc_resources = [],
         doc_excluded_packages = [],
         doc_included_packages = [],
+        publish_maven_metadata = False,
         toolchains = None):
     """
     All arguments are the same as java_export with the addition of:
@@ -237,6 +242,8 @@
         end of the package name. For example, `com.example.*` will include all the subpackages of `com.example`, while
         `com.example` will include only the files directly in `com.example`
       visibility: The visibility of the target
+      publish_maven_metadata: Whether to publish a maven-metadata.xml to remote repository. Some repositories
+            (like AWS CodeArtifact) require the client to publish this file. It is disabled by default.
       kwargs: These are passed to [`java_library`](https://bazel.build/reference/be/java#java_library),
         and so may contain any valid parameter for that rule.
     """
@@ -350,6 +357,7 @@
         tags = tags,
         testonly = testonly,
         toolchains = toolchains,
+        publish_maven_metadata = publish_maven_metadata,
     )
 
     # We may want to aggregate several `java_export` targets into a single Maven BOM POM
diff --git a/private/rules/maven_publish.bzl b/private/rules/maven_publish.bzl
index bb68795..28f045a 100644
--- a/private/rules/maven_publish.bzl
+++ b/private/rules/maven_publish.bzl
@@ -18,7 +18,7 @@
 export PGP_SIGNING_KEY="${{PGP_SIGNING_KEY:-{pgp_signing_key}}}"
 export PGP_SIGNING_PWD="${{PGP_SIGNING_PWD:-{pgp_signing_pwd}}}"
 echo Uploading "{coordinates}" to "${{MAVEN_REPO}}"
-{uploader} "{coordinates}" '{pom}' '{artifact}' '{classifier_artifacts}' $@
+{uploader} "{coordinates}" '{pom}' '{artifact}' '{publish_maven_metadata}' '{classifier_artifacts}' $@
 """
 
 def _escape_arg(str):
@@ -68,6 +68,7 @@
             pom = ctx.file.pom.short_path,
             artifact = artifacts_short_path,
             classifier_artifacts = ",".join(["{}={}".format(classifier, file.short_path) for (classifier, file) in classifier_artifacts_dict.items()]),
+            publish_maven_metadata = ctx.attr.publish_maven_metadata,
         ),
     )
 
@@ -125,6 +126,10 @@
             allow_single_file = True,
         ),
         "classifier_artifacts": attr.label_keyed_string_dict(allow_files = True),
+        "publish_maven_metadata": attr.bool(
+            default = False,
+            doc = "Whether to publish a maven-metadata.xml to the Maven repository",
+        ),
         "_uploader": attr.label(
             executable = True,
             cfg = "exec",
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/maven/BUILD b/private/tools/java/com/github/bazelbuild/rules_jvm_external/maven/BUILD
index 972b662..54c482f 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/maven/BUILD
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/maven/BUILD
@@ -11,6 +11,13 @@
     visibility = ["//visibility:public"],
     deps = [
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external",
+        "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/netrc",
+        "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote",
+        "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/ui",
+        artifact(
+            "software.amazon.awssdk:sdk-core",
+            repository_name = "rules_jvm_external_deps",
+        ),
         artifact(
             "com.google.auth:google-auth-library-oauth2-http",
             repository_name = "rules_jvm_external_deps",
@@ -43,6 +50,10 @@
             "org.bouncycastle:bcpg-jdk15on",
             repository_name = "rules_jvm_external_deps",
         ),
+        artifact(
+            "org.apache.maven:maven-repository-metadata",
+            repository_name = "rules_jvm_external_deps",
+        ),
     ],
 )
 
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/maven/MavenPublisher.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/maven/MavenPublisher.java
index 55125d4..2ec0f8c 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/maven/MavenPublisher.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/maven/MavenPublisher.java
@@ -24,6 +24,8 @@
 import static java.util.concurrent.TimeUnit.MINUTES;
 
 import com.github.bazelbuild.rules_jvm_external.ByteStreams;
+import com.github.bazelbuild.rules_jvm_external.resolver.netrc.Netrc;
+import com.github.bazelbuild.rules_jvm_external.resolver.remote.HttpDownloader;
 import com.google.auth.Credentials;
 import com.google.auth.oauth2.GoogleCredentials;
 import com.google.cloud.WriteChannel;
@@ -31,26 +33,34 @@
 import com.google.cloud.storage.Storage;
 import com.google.cloud.storage.StorageOptions;
 import com.google.common.base.Splitter;
+import com.google.common.io.CharStreams;
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.InputStreamReader;
 import java.io.OutputStream;
+import java.io.StringReader;
+import java.io.UncheckedIOException;
 import java.math.BigInteger;
 import java.net.HttpURLConnection;
 import java.net.URI;
 import java.net.URL;
 import java.nio.channels.Channels;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
-import java.time.Instant;
+import java.text.SimpleDateFormat;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Base64;
 import java.util.Collections;
+import java.util.Date;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.concurrent.Callable;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutionException;
@@ -58,8 +68,17 @@
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeoutException;
 import java.util.logging.Logger;
+import java.util.stream.Collectors;
+import org.apache.maven.artifact.repository.metadata.Metadata;
+import org.apache.maven.artifact.repository.metadata.Versioning;
+import org.apache.maven.artifact.repository.metadata.io.xpp3.MetadataXpp3Reader;
+import org.apache.maven.artifact.repository.metadata.io.xpp3.MetadataXpp3Writer;
+import software.amazon.awssdk.core.ResponseInputStream;
 import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
 import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.model.S3Exception;
 
 public class MavenPublisher {
 
@@ -68,16 +87,25 @@
   private static final String[] SUPPORTED_SCHEMES = {
     "file:/", "https://", "gs://", "s3://", "artifactregistry://"
   };
+  private static final String[] SUPPORTED_UPLOAD_SCHEMES = {"file:/", "https://", "s3://"};
 
   public static void main(String[] args)
       throws IOException, InterruptedException, ExecutionException, TimeoutException {
     String repo = System.getenv("MAVEN_REPO");
+    boolean publishMavenMetadata = Boolean.parseBoolean(args[3]);
+
     if (!isSchemeSupported(repo)) {
       throw new IllegalArgumentException(
           "Repository must be accessed via the supported schemes: "
               + Arrays.toString(SUPPORTED_SCHEMES));
     }
 
+    if (!isUploadSchemeSupported(repo)) {
+      throw new IllegalArgumentException(
+          "Repository must be uploaded to via the supported schemes: "
+              + Arrays.toString(SUPPORTED_UPLOAD_SCHEMES));
+    }
+
     boolean gpgSign = Boolean.parseBoolean(System.getenv("GPG_SIGN"));
     Credentials credentials =
         new BasicAuthCredentials(System.getenv("MAVEN_USER"), System.getenv("MAVEN_PASSWORD"));
@@ -99,9 +127,6 @@
     Path pom = Paths.get(args[1]);
     Path mainArtifact = getPathIfSet(args[2]);
 
-    Path maven_metadata_dir = null;
-    Path maven_metadata_xml = null;
-
     try {
       List<CompletableFuture<Void>> futures = new ArrayList<>();
       futures.add(upload(repo, credentials, coords, ".pom", pom, signingMetadata));
@@ -112,41 +137,8 @@
         futures.add(upload(repo, credentials, coords, "." + ext, mainArtifact, signingMetadata));
       }
 
-      // Update maven-metadata for local maven repositories.
-      // This makes it so the target maven repository can be directly used without further steps.
-      if (repo.startsWith("file:/")) {
-        maven_metadata_dir = Files.createTempDirectory("maven-metadata");
-        maven_metadata_xml = maven_metadata_dir.resolve("maven-metadata.xml");
-        String template =
-            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
-                + "<metadata>\n"
-                + "  <groupId>{groupId}</groupId>\n"
-                + "  <artifactId>{artifactId}</artifactId>\n"
-                + "  <versioning>\n"
-                + "    <latest>{version}</latest>\n"
-                + "    <release>{version}</release>\n"
-                + "    <versions>\n"
-                + "      <version>{version}</version>\n"
-                + "    </versions>\n"
-                + "    <lastUpdated>{lastUpdated}</lastUpdated>\n"
-                + "  </versioning>\n"
-                + "</metadata>\n";
-        template = template.replace("{groupId}", coords.groupId);
-        template = template.replace("{artifactId}", coords.artifactId);
-        template = template.replace("{version}", coords.version);
-        template =
-            template.replace("{lastUpdated}", String.valueOf(Instant.now().getEpochSecond()));
-        Files.writeString(maven_metadata_xml, template);
-
-        String metadata_url =
-            String.format(
-                "%s/%s/%s/maven-metadata.xml",
-                repo.replaceAll("/$", ""), coords.groupId.replace('.', '/'), coords.artifactId);
-        futures.add(upload(metadata_url, credentials, maven_metadata_xml));
-      }
-
-      if (args.length > 3 && !args[3].isEmpty()) {
-        List<String> extraArtifactTuples = Splitter.onPattern(",").splitToList(args[3]);
+      if (args.length > 4 && !args[4].isEmpty()) {
+        List<String> extraArtifactTuples = Splitter.onPattern(",").splitToList(args[4]);
         for (String artifactTuple : extraArtifactTuples) {
           String[] splits = artifactTuple.split("=");
           String classifier = splits[0];
@@ -165,16 +157,18 @@
 
       CompletableFuture<Void> all =
           CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
+
+      // uploading the maven-metadata.xml signals to cut over to the new version, so it must be at
+      // the end.
+      // publishing the file is opt-in for remote repositories, but always done for local file
+      // repositories.
+      if (publishMavenMetadata || repo.startsWith("file:/")) {
+        all = all.thenCompose(Void -> uploadMavenMetadata(repo, credentials, coords));
+      }
+
       all.get(30, MINUTES);
     } finally {
       EXECUTOR.shutdown();
-
-      if (maven_metadata_xml != null) {
-        Files.delete(maven_metadata_xml);
-      }
-      if (maven_metadata_dir != null) {
-        Files.delete(maven_metadata_dir);
-      }
     }
   }
 
@@ -194,6 +188,90 @@
     return false;
   }
 
+  private static boolean isUploadSchemeSupported(String repo) {
+    for (String scheme : SUPPORTED_UPLOAD_SCHEMES) {
+      if (repo.startsWith(scheme)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Download the pre-existing maven-metadata.xml file if it exists. If no such file exists, create
+   * a default Metadata with the Coordinates provided.
+   */
+  private static CompletableFuture<Metadata> downloadExistingMavenMetadata(
+      String repo, Coordinates coords) {
+    String mavenMetadataUrl =
+        String.format(
+            "%s/%s/%s/maven-metadata.xml",
+            repo.replaceAll("/$", ""), coords.groupId.replace('.', '/'), coords.artifactId);
+
+    return download(mavenMetadataUrl)
+        .thenApply(
+            optionalFileContents -> {
+              try {
+                if (optionalFileContents.isEmpty()) {
+                  // no file so just upload a new one
+                  // we must bootstrap
+                  Metadata metadata = new Metadata();
+                  metadata.setGroupId(coords.groupId);
+                  metadata.setArtifactId(coords.artifactId);
+                  metadata.setVersioning(new Versioning());
+                  return metadata;
+                }
+                return new MetadataXpp3Reader()
+                    .read(new StringReader(optionalFileContents.get()), false);
+              } catch (Exception e) {
+                throw new RuntimeException(e);
+              }
+            });
+  }
+
+  /**
+   * Upload the new maven-metadata.xml with the new version included in the version list & set the
+   * latest and release tags in the Metadata XML object. This function will first download the
+   * pre-existing metadata-xml and augment. If no maven-metadata.xml exists, a new one will be
+   * hydrated.
+   */
+  private static CompletableFuture<Void> uploadMavenMetadata(
+      String repo, Credentials credentials, Coordinates coords) {
+
+    String mavenMetadataUrl =
+        String.format(
+            "%s/%s/%s/maven-metadata.xml",
+            repo.replaceAll("/$", ""), coords.groupId.replace('.', '/'), coords.artifactId);
+    return downloadExistingMavenMetadata(repo, coords)
+        .thenCompose(
+            metadata -> {
+              try {
+
+                // There is a chance versioning is null; handle it by creating the empty object.
+                Versioning versioning =
+                    Optional.ofNullable(metadata.getVersioning()).orElse(new Versioning());
+                versioning.setLatest(coords.version);
+                versioning.setRelease(coords.version);
+                // This may be needed for SNAPSHOT support
+                String timestamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
+                versioning.setLastUpdated("20200731090423");
+                versioning.getVersions().add(coords.version);
+                // Let's handle adding multiple versions many times by turning it back to a set
+                versioning.setVersions(
+                    versioning.getVersions().stream().distinct().collect(Collectors.toList()));
+                metadata.setVersioning(versioning);
+
+                Path newMavenMetadataXml = Files.createTempFile("maven-metadata", ".xml");
+                ByteArrayOutputStream os = new ByteArrayOutputStream();
+                new MetadataXpp3Writer().write(os, metadata);
+                Files.write(newMavenMetadataXml, os.toByteArray());
+                return upload(mavenMetadataUrl, credentials, newMavenMetadataXml);
+              } catch (Exception e) {
+                throw new RuntimeException(e);
+              }
+            });
+  }
+
   private static CompletableFuture<Void> upload(
       String repo,
       Credentials credentials,
@@ -274,6 +352,79 @@
     }
   }
 
+  /**
+   * Attempts to download the file at the given targetUrl. Valid protocols are: http(s), file, and
+   * s3 at the moment.
+   */
+  private static CompletableFuture<Optional<String>> download(String targetUrl) {
+    if (targetUrl.startsWith("http")) {
+      return httpDownload(targetUrl);
+    } else if (targetUrl.startsWith("file:/")) {
+      return fileDownload(targetUrl);
+    } else if (targetUrl.startsWith("s3://")) {
+      return s3Download(targetUrl);
+    } else {
+      throw new IllegalArgumentException("Unsupported protocol for download: " + targetUrl);
+    }
+  }
+
+  private static CompletableFuture<Optional<String>> s3Download(String targetUrl) {
+    return CompletableFuture.supplyAsync(
+        () -> {
+          S3Client s3Client = S3Client.create();
+          try {
+            URI s3Uri = URI.create(targetUrl);
+            String bucketName = s3Uri.getHost();
+            String key = s3Uri.getPath().substring(1);
+            GetObjectRequest request =
+                GetObjectRequest.builder().bucket(bucketName).key(key).build();
+            ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(request);
+            return Optional.of(
+                CharStreams.toString(new InputStreamReader(s3Object, StandardCharsets.UTF_8)));
+          } catch (IOException e) {
+            throw new UncheckedIOException(e);
+          } catch (S3Exception e) {
+            if (e.statusCode() == 404) {
+              return Optional.empty();
+            } else {
+              throw new RuntimeException(e);
+            }
+          }
+        });
+  }
+
+  private static CompletableFuture<Optional<String>> fileDownload(String targetUrl) {
+    return CompletableFuture.supplyAsync(
+        () -> {
+          try {
+            Path path = Paths.get(URI.create(targetUrl));
+            if (!Files.exists(path)) {
+              return Optional.empty();
+            }
+            return Optional.of(Files.readString(path, StandardCharsets.UTF_8));
+          } catch (IOException e) {
+            throw new UncheckedIOException(e);
+          }
+        });
+  }
+
+  private static CompletableFuture<Optional<String>> httpDownload(String targetUrl) {
+    return CompletableFuture.supplyAsync(
+        () -> {
+          HttpDownloader downloader = new HttpDownloader(Netrc.fromUserHome());
+
+          Path path = downloader.get(URI.create(targetUrl));
+          if (path == null || !Files.exists(path)) {
+            return Optional.empty();
+          }
+          try {
+            return Optional.of(Files.readString(path, StandardCharsets.UTF_8));
+          } catch (IOException e) {
+            throw new UncheckedIOException(e);
+          }
+        });
+  }
+
   private static CompletableFuture<Void> upload(
       String targetUrl, Credentials credentials, Path toUpload) {
     Callable<Void> callable;
@@ -359,7 +510,7 @@
   private static Callable<Void> writeFile(String targetUrl, Path toUpload) {
     return () -> {
       LOG.info(String.format("Copying %s to %s", toUpload, targetUrl));
-      Path path = Paths.get(new URL(targetUrl).toURI());
+      Path path = Paths.get(URI.create(targetUrl));
       Files.createDirectories(path.getParent());
       Files.deleteIfExists(path);
       Files.copy(toUpload, path);
@@ -371,7 +522,7 @@
   private static Callable<Void> gcsUpload(String targetUrl, Path toUpload) {
     return () -> {
       Storage storage = StorageOptions.getDefaultInstance().getService();
-      URI gsUri = new URI(targetUrl);
+      URI gsUri = URI.create(targetUrl);
       String bucketName = gsUri.getHost();
       String path = gsUri.getPath().substring(1);
 
@@ -389,7 +540,7 @@
   private static Callable<Void> s3upload(String targetUrl, Path toUpload) {
     return () -> {
       S3Client s3Client = S3Client.create();
-      URI s3Uri = new URI(targetUrl);
+      URI s3Uri = URI.create(targetUrl);
       String bucketName = s3Uri.getHost();
       String path = s3Uri.getPath().substring(1);
 
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/Main.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/Main.java
index 72fb87e..3f7a653 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/Main.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/Main.java
@@ -30,10 +30,8 @@
 import com.github.bazelbuild.rules_jvm_external.resolver.lockfile.V2LockFile;
 import com.github.bazelbuild.rules_jvm_external.resolver.remote.DownloadResult;
 import com.github.bazelbuild.rules_jvm_external.resolver.remote.Downloader;
+import com.github.bazelbuild.rules_jvm_external.resolver.remote.HttpDownloader;
 import com.github.bazelbuild.rules_jvm_external.resolver.remote.UriNotFoundException;
-import com.github.bazelbuild.rules_jvm_external.resolver.ui.AnsiConsoleListener;
-import com.github.bazelbuild.rules_jvm_external.resolver.ui.NullListener;
-import com.github.bazelbuild.rules_jvm_external.resolver.ui.PlainConsoleListener;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.graph.Graph;
@@ -49,7 +47,6 @@
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.Set;
 import java.util.TreeMap;
 import java.util.TreeSet;
@@ -63,7 +60,7 @@
 
   public static void main(String[] args) throws IOException {
     Set<DependencyInfo> infos;
-    try (EventListener listener = createEventListener()) {
+    try (EventListener listener = HttpDownloader.defaultEventListener()) {
       ResolverConfig config = new ResolverConfig(listener, args);
 
       ResolutionRequest request = config.getResolutionRequest();
@@ -83,17 +80,6 @@
     }
   }
 
-  private static EventListener createEventListener() {
-    boolean termAvailable = !Objects.equals(System.getenv().get("TERM"), "dumb");
-    boolean consoleAvailable = System.console() != null;
-    if (System.getenv("RJE_VERBOSE") != null) {
-      return new PlainConsoleListener();
-    } else if (termAvailable && consoleAvailable) {
-      return new AnsiConsoleListener();
-    }
-    return new NullListener();
-  }
-
   private static Set<DependencyInfo> fulfillDependencyInfos(
       EventListener listener, ResolverConfig config, Graph<Coordinates> resolved) {
     listener.onEvent(new PhaseEvent("Downloading dependencies"));
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/netrc/BUILD b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/netrc/BUILD
index 0fd8382..28b86f0 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/netrc/BUILD
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/netrc/BUILD
@@ -5,6 +5,7 @@
     name = "netrc",
     srcs = glob(["*.java"]),
     visibility = [
+        "//private/tools/java/com/github/bazelbuild/rules_jvm_external/maven:__pkg__",
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver:__subpackages__",
         "//tests/com/github/bazelbuild/rules_jvm_external:__subpackages__",
     ],
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote/BUILD b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote/BUILD
index 7ce876b..f8b6a86 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote/BUILD
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote/BUILD
@@ -5,6 +5,7 @@
     name = "remote",
     srcs = glob(["*.java"]),
     visibility = [
+        "//private/tools/java/com/github/bazelbuild/rules_jvm_external/maven:__pkg__",
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver:__subpackages__",
         "//tests/com/github/bazelbuild/rules_jvm_external/resolver:__subpackages__",
     ],
@@ -13,6 +14,7 @@
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver",
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/events",
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/netrc",
+        "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/ui",
         artifact(
             "com.google.guava:guava",
             repository_name = "rules_jvm_external_deps",
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote/HttpDownloader.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote/HttpDownloader.java
index 882fc3a..7d65b45 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote/HttpDownloader.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote/HttpDownloader.java
@@ -23,6 +23,9 @@
 import com.github.bazelbuild.rules_jvm_external.resolver.events.EventListener;
 import com.github.bazelbuild.rules_jvm_external.resolver.events.LogEvent;
 import com.github.bazelbuild.rules_jvm_external.resolver.netrc.Netrc;
+import com.github.bazelbuild.rules_jvm_external.resolver.ui.AnsiConsoleListener;
+import com.github.bazelbuild.rules_jvm_external.resolver.ui.NullListener;
+import com.github.bazelbuild.rules_jvm_external.resolver.ui.PlainConsoleListener;
 import java.io.IOException;
 import java.io.UncheckedIOException;
 import java.net.Authenticator;
@@ -39,6 +42,7 @@
 import java.time.Duration;
 import java.util.Collections;
 import java.util.HashSet;
+import java.util.Objects;
 import java.util.Set;
 import java.util.logging.Logger;
 
@@ -86,6 +90,21 @@
     this.client = builder.build();
   }
 
+  public HttpDownloader(Netrc netrc) {
+    this(netrc, defaultEventListener());
+  }
+
+  public static EventListener defaultEventListener() {
+    boolean termAvailable = !Objects.equals(System.getenv().get("TERM"), "dumb");
+    boolean consoleAvailable = System.console() != null;
+    if (System.getenv("RJE_VERBOSE") != null) {
+      return new PlainConsoleListener();
+    } else if (termAvailable && consoleAvailable) {
+      return new AnsiConsoleListener();
+    }
+    return new NullListener();
+  }
+
   public Path get(URI uriToGet) {
     if ("file".equals(uriToGet.getScheme())) {
       Path path = Paths.get(uriToGet);
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/ui/BUILD b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/ui/BUILD
index c821198..f686ad6 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/ui/BUILD
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/ui/BUILD
@@ -5,6 +5,7 @@
     name = "ui",
     srcs = glob(["*.java"]),
     visibility = [
+        "//private/tools/java/com/github/bazelbuild/rules_jvm_external/maven:__pkg__",
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver:__subpackages__",
         "//tests/com/github/bazelbuild/rules_jvm_external:__subpackages__",
     ],
diff --git a/repositories.bzl b/repositories.bzl
index 0e72eed..652a609 100644
--- a/repositories.bzl
+++ b/repositories.bzl
@@ -136,6 +136,7 @@
             "org.apache.maven:maven-core:%s" % _MAVEN_VERSION,
             "org.apache.maven:maven-model:%s" % _MAVEN_VERSION,
             "org.apache.maven:maven-model-builder:%s" % _MAVEN_VERSION,
+            "org.apache.maven:maven-repository-metadata:%s" % _MAVEN_VERSION,
             "org.apache.maven:maven-settings:%s" % _MAVEN_VERSION,
             "org.apache.maven:maven-settings-builder:%s" % _MAVEN_VERSION,
             "org.apache.maven:maven-resolver-provider:%s" % _MAVEN_VERSION,
@@ -154,6 +155,7 @@
             "org.slf4j:log4j-over-slf4j:2.0.12",
             "org.slf4j:slf4j-simple:2.0.12",
             "software.amazon.awssdk:s3:2.26.12",
+            "software.amazon.awssdk:sdk-core:2.26.12",
             "org.bouncycastle:bcprov-jdk15on:1.68",
             "org.bouncycastle:bcpg-jdk15on:1.68",
             "org.gradle:gradle-tooling-api:%s" % _GRADLE_VERSION,
diff --git a/rules_jvm_external_deps_install.json b/rules_jvm_external_deps_install.json
index e3fc024..94e5183 100644
--- a/rules_jvm_external_deps_install.json
+++ b/rules_jvm_external_deps_install.json
@@ -1,7 +1,7 @@
 {
   "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
-  "__INPUT_ARTIFACTS_HASH": 1321988192,
-  "__RESOLVED_ARTIFACTS_HASH": -1298063869,
+  "__INPUT_ARTIFACTS_HASH": -731442651,
+  "__RESOLVED_ARTIFACTS_HASH": 968256901,
   "conflict_resolution": {
     "com.google.guava:guava:33.2.1-jre": "com.google.guava:guava:33.4.8-jre",
     "org.codehaus.plexus:plexus-utils:3.5.1": "org.codehaus.plexus:plexus-utils:3.6.0"
@@ -205,8 +205,7 @@
     },
     "com.google.guava:listenablefuture": {
       "shasums": {
-        "jar": "b372a037d4230aa57fbeffdef30fd6123f9c0c2db85d0aced00c91b974f33f99",
-        "sources": null
+        "jar": "b372a037d4230aa57fbeffdef30fd6123f9c0c2db85d0aced00c91b974f33f99"
       },
       "version": "9999.0-empty-to-avoid-conflict-with-guava"
     },
@@ -3242,7 +3241,7 @@
     ]
   },
   "repositories": {
-    "https://repo1.maven.org/maven2/": [
+    "https://repo.gradle.org/gradle/libs-releases/": [
       "aopalliance:aopalliance",
       "aopalliance:aopalliance:jar:sources",
       "com.fasterxml.jackson.core:jackson-core",
@@ -3539,7 +3538,7 @@
       "software.amazon.eventstream:eventstream",
       "software.amazon.eventstream:eventstream:jar:sources"
     ],
-    "https://repo.gradle.org/gradle/libs-releases/": [
+    "https://repo1.maven.org/maven2/": [
       "aopalliance:aopalliance",
       "aopalliance:aopalliance:jar:sources",
       "com.fasterxml.jackson.core:jackson-core",
diff --git a/tests/integration/java_export/PublishShapeTest.java b/tests/integration/java_export/PublishShapeTest.java
index ff6f56e..0e9062e 100644
--- a/tests/integration/java_export/PublishShapeTest.java
+++ b/tests/integration/java_export/PublishShapeTest.java
@@ -97,6 +97,7 @@
                 coordinates,
                 pomXml.getAbsolutePath(),
                 stubJar.getAbsolutePath(),
+                "false",
                 String.format(
                     "javadoc=%s,sources=%s", stubJar.getAbsolutePath(), stubJar.getAbsolutePath()))
             .redirectErrorStream(true);
diff --git a/tests/integration/pom_file/OtherLibrary.java b/tests/integration/pom_file/OtherLibrary.java
index 7532f95..b909799 100644
--- a/tests/integration/pom_file/OtherLibrary.java
+++ b/tests/integration/pom_file/OtherLibrary.java
@@ -1,4 +1,3 @@
 package tests.integration.pom_file;
 
-public class OtherLibrary {
-}
+public class OtherLibrary {}
diff --git a/tests/unit/jvm_import/jvm_import_test.bzl b/tests/unit/jvm_import/jvm_import_test.bzl
index 75a6293..fa6278d 100644
--- a/tests/unit/jvm_import/jvm_import_test.bzl
+++ b/tests/unit/jvm_import/jvm_import_test.bzl
@@ -55,13 +55,13 @@
     for t in direct:
         if PackageMetadataInfo not in t:
             continue
-        
+
         return [
             PackageMetadataInfoCollectionInfo(
                 info = t[PackageMetadataInfo],
             ),
         ]
-    
+
     return []
 
 package_metadata_info_propagator = aspect(
@@ -100,7 +100,7 @@
     asserts.true(env, PackageMetadataInfoCollectionInfo in ctx.attr.src)
     info = ctx.attr.src[PackageMetadataInfoCollectionInfo]
     asserts.true(env, info.info)
-    
+
     return analysistest.end(env)
 
 does_jvm_import_have_applicable_licenses_test = analysistest.make(