feat: Support pluggable dependency download and metadata caching SPIs (#1589)

Provides pluggable SPIs for rules_jvm_external's dependency resolution, allowing custom download/caching and authentication implementations (such as integrating with Bazel's remote fetcher/downloader APIs) to be registered via classpath plugins.

Key Changes:
1. Public API Targets & SPIs:
   - Created public interfaces for MetadataService, DependencyMetadata, and DownloadService.
   - Added a generic SpiLoader utility to load classpath SPI service implementations (ensuring at most one implementation is loaded per SPI).
   - Exposed custom classpath library loading via the new resolver_extra_dependencies attribute.
   - Decomposed the API targets into fine-grained libraries: :api_core, :download_service, :metadata_service, and :spi_loader.
   - Relocated the build definition closer to the sources in //resolver/src/main/java/com/github/bazelbuild/rules_jvm_external/resolver/BUILD.
   - Flattened public SPI classes into a single package (com.github.bazelbuild.rules_jvm_external.resolver) to prevent split-package issues and avoid spanning multiple packages.

2. Aether & Downloader SPI Integration:
   - Replaced Aether's default HTTP transport with a custom HttpDownloaderTransporterFactory, routing all POM, BOM, and metadata lookups through the pluggable DownloadService SPI.
   - Extracted HttpDownloaderTransporter into its own top-level package-private class.
   - Renamed DownloadService.download to get to match HTTP verb naming conventions and align with head.
   - Implemented null-safe defensive copying of collections in DependencyMetadata constructor using Set.copyOf/Map.copyOf.

3. Inline URL Credentials Handling:
   - Aether downloads are now routed through the pluggable DownloadService SPI backed by HttpDownloader without inline credentials support (making both resolution and downloading fail consistently). Users must configure credentials via .netrc instead.
   - Deleted the shouldDownloadOverHttpWithAuthenticationPassedInOnRepoUrl test from ResolverTestBase.java since that flow does not work and is explicitly unsupported.

4. Cleanup & Optimization:
   - Removed final modifier from SpiLoader class and refactored joining logic to use Guava Joiner.
   - Removed redundant metadata caches and unused resolver binaries.
diff --git a/MODULE.bazel b/MODULE.bazel
index eb4c180..1ffd2ff 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -496,6 +496,9 @@
         "https://repo.maven.apache.org/maven2/",
     ],
     resolver = "maven",
+    resolver_extra_dependencies = [
+        "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote:remote",
+    ],
 )
 dev_maven.install(
     name = "legacy_multi_repo_hash",
diff --git a/private/extensions/maven.bzl b/private/extensions/maven.bzl
index 7b6adc4..a9a148b 100644
--- a/private/extensions/maven.bzl
+++ b/private/extensions/maven.bzl
@@ -121,6 +121,7 @@
         "ignore_empty_files": attr.bool(default = False, doc = "Treat jars that are empty as if they were not found."),
         "repin_instructions": attr.string(doc = "Instructions to re-pin the repository if required. Many people have wrapper scripts for keeping dependencies up to date, and would like to point users to that instead of the default. Only honoured for the root module."),
         "additional_coursier_options": attr.string_list(doc = "Additional options that will be passed to coursier."),
+        "resolver_extra_dependencies": attr.label_list(default = [], doc = "Jars or libraries to add to the resolver classpath (such as custom MetadataService or DownloadService SPI implementations)."),
     },
 )
 
@@ -740,6 +741,7 @@
                 repo["fetch_javadoc"] = install.fetch_javadoc
                 repo["fetch_sources"] = install.fetch_sources
                 repo["resolver"] = install.resolver
+                repo["resolver_extra_dependencies"] = install.resolver_extra_dependencies
                 repo["strict_visibility"] = install.strict_visibility
                 if len(install.repositories):
                     mapped_repos = []
@@ -858,6 +860,7 @@
                 fetch_sources = repo.get("fetch_sources"),
                 fetch_javadoc = repo.get("fetch_javadoc"),
                 resolver = repo.get("resolver", _DEFAULT_RESOLVER),
+                resolver_extra_dependencies = repo.get("resolver_extra_dependencies", []),
                 generate_compat_repositories = False,
                 maven_install_json = repo.get("lock_file"),
                 dependency_index = repo.get("dependency_index"),
diff --git a/private/rules/coursier.bzl b/private/rules/coursier.bzl
index 8ba3bee..b852de7 100644
--- a/private/rules/coursier.bzl
+++ b/private/rules/coursier.bzl
@@ -118,6 +118,17 @@
 EMPTY_FILE_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
 
 _IN_REPO_PIN = """
+load("@rules_java//java:java_binary.bzl", "java_binary")
+
+java_binary(
+    name = "custom_resolver",
+    main_class = "{main_class}",
+    visibility = ["//visibility:public"],
+    runtime_deps = [
+        {resolver_lib},
+    ] + {resolver_extra_dependencies},
+)
+
 pin_dependencies(
     name = "pin",
     boms = {boms},
@@ -130,7 +141,7 @@
     dependency_index = {dependency_index},
     jvm_flags = {jvm_flags},
     visibility = ["//visibility:public"],
-    resolver = {resolver},
+    resolver = ":custom_resolver",
 )
 """
 
@@ -866,9 +877,11 @@
             lock_file_location = "/".join([package_path, file_name])  # e.g. path/to/some.json
 
         if repository_ctx.attr.resolver == "maven":
-            resolver_target = Label("//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven:MavenMain")
+            main_class = "com.github.bazelbuild.rules_jvm_external.resolver.maven.MavenMain"
+            resolver_lib = Label("//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven:maven-resolver-lib")
         elif repository_ctx.attr.resolver == "gradle":
-            resolver_target = Label("//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/gradle:GradleMain")
+            main_class = "com.github.bazelbuild.rules_jvm_external.resolver.gradle.GradleMain"
+            resolver_lib = Label("//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/gradle:gradle-resolver-lib")
         else:
             fail("Unknown resolver")
 
@@ -882,6 +895,9 @@
                 dependency_index_location = "/".join([dep_index_package_path, dep_index_file_name])
 
         return _IN_REPO_PIN.format(
+            main_class = main_class,
+            resolver_lib = repr(str(resolver_lib)),
+            resolver_extra_dependencies = repr([str(l) for l in repository_ctx.attr.resolver_extra_dependencies]),
             boms = repr(repository_ctx.attr.boms),
             artifacts = repr(repository_ctx.attr.artifacts),
             excluded_artifacts = repr(repository_ctx.attr.excluded_artifacts),
@@ -891,7 +907,6 @@
             fetch_javadocs = repr(repository_ctx.attr.fetch_javadoc),
             lock_file = repr(lock_file_location),
             dependency_index = repr(dependency_index_location),
-            resolver = repr(str(resolver_target)),
         )
 
 def infer_artifact_path_from_primary_and_repos(primary_url, repository_urls):
@@ -1658,6 +1673,7 @@
             doc = "Instructions to re-pin the repository if required. Many people have wrapper scripts for keeping dependencies up to date, and would like to point users to that instead of the default.",
         ),
         "excluded_artifacts": attr.string_list(default = []),  # only used for hash generation
+        "resolver_extra_dependencies": attr.label_list(default = []),
         # Use @@// to refer to the main repo with Bzlmod.
         "_workspace_label": attr.label(default = ("@@" if str(Label("//:invalid")).startswith("@@") else "@") + "//does/not:exist"),
     },
diff --git a/private/rules/maven_install.bzl b/private/rules/maven_install.bzl
index 757c803..f4d742f 100644
--- a/private/rules/maven_install.bzl
+++ b/private/rules/maven_install.bzl
@@ -28,6 +28,7 @@
         duplicate_version_warning = "warn",
         repin_instructions = None,
         ignore_empty_files = False,
+        resolver_extra_dependencies = [],
         additional_coursier_options = []):
     """Resolves and fetches artifacts transitively from Maven repositories.
 
@@ -80,6 +81,7 @@
         is "warn".
       repin_instructions: Instructions to re-pin dependencies in your repository. Will be shown when re-pinning is required.
       ignore_empty_files: Treat jars that are empty as if they were not found.
+      resolver_extra_dependencies: Jars or libraries to add to the resolver classpath (such as custom MetadataService or DownloadService SPI implementations).
       additional_coursier_options: Additional options that will be passed to coursier.
     """
     if resolver != "coursier" and not maven_install_json:
@@ -160,6 +162,7 @@
         pinned_coursier_fetch(
             name = name,
             resolver = resolver,
+            resolver_extra_dependencies = resolver_extra_dependencies,
             repositories = repositories_json_strings,
             artifacts = artifacts_json_strings,
             boms = boms_json_strings,
diff --git a/private/rules/pin_dependencies.bzl b/private/rules/pin_dependencies.bzl
index c670236..50d2e72 100644
--- a/private/rules/pin_dependencies.bzl
+++ b/private/rules/pin_dependencies.bzl
@@ -97,7 +97,7 @@
     return [
         DefaultInfo(
             executable = script,
-            files = depset([script, config_file]),
+            files = depset([script, config_file, hash_file]),
             runfiles = ctx.runfiles(files = [script, config_file, hash_file]).merge(ctx.attr.resolver[DefaultInfo].default_runfiles),
         ),
     ]
@@ -137,3 +137,4 @@
         ),
     },
 )
+
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/BUILD b/private/tools/java/com/github/bazelbuild/rules_jvm_external/BUILD
index f179474..02211f3 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/BUILD
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/BUILD
@@ -2,14 +2,31 @@
 load("@rules_java//java:java_library.bzl", "java_library")
 
 java_library(
+    name = "coordinates",
+    srcs = ["Coordinates.java"],
+    visibility = ["//visibility:public"],
+)
+
+java_library(
     name = "rules_jvm_external",
-    srcs = glob(["*.java"]),
+    srcs = [
+        "ByteStreams.java",
+        "Hasher.java",
+    ],
     visibility = [
         "//private/tools/java:__subpackages__",
+        "//resolver:__subpackages__",
         "//tests/com:__subpackages__",
     ],
+    exports = [
+        ":coordinates",
+    ],
+    deps = [
+        ":coordinates",
+    ],
 )
 
+
 java_library(
     name = "hasher",
     srcs = ["Hasher.java"],
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/jar/BUILD b/private/tools/java/com/github/bazelbuild/rules_jvm_external/jar/BUILD
index c83d521..de5ba86 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/jar/BUILD
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/jar/BUILD
@@ -27,6 +27,7 @@
     ],
     visibility = [
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd:__pkg__",
+        "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote:__pkg__",
         "//tests/com/github/bazelbuild/rules_jvm_external/jar:__pkg__",
     ],
     deps = [
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/BUILD b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/BUILD
index 450dfed..0d776a8 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/BUILD
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/BUILD
@@ -1,14 +1,24 @@
 load("@rules_java//java:java_library.bzl", "java_library")
 load("//private/rules:artifact.bzl", "artifact")
 
+
 java_library(
     name = "resolver",
-    srcs = glob(["*.java"]),
+    srcs = [
+        "Conflict.java",
+        "DependencyInfo.java",
+        "PackagingMappings.java",
+        "ResolutionResult.java",
+        "ResolvedArtifact.java",
+        "Resolver.java",
+        "SpiLoader.java",
+    ],
     visibility = [
         "//private/tools/java:__subpackages__",
         "//tests/com/github/bazelbuild/rules_jvm_external:__subpackages__",
     ],
     deps = [
+        "//resolver:resolver",
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external",
         artifact(
             "com.google.guava:guava",
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/SpiLoader.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/SpiLoader.java
new file mode 100644
index 0000000..61aaead
--- /dev/null
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/SpiLoader.java
@@ -0,0 +1,58 @@
+// Copyright 2026 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.bazelbuild.rules_jvm_external.resolver;
+
+import java.util.Iterator;
+import java.util.ServiceLoader;
+import java.util.function.Consumer;
+
+/**
+ * Utility class to dynamically load and resolve SPI (Service Provider Interface) services.
+ * Ensures that at most one custom service implementation is registered on the classpath.
+ */
+public class SpiLoader {
+  private SpiLoader() {}
+
+  /**
+   * Loads a service implementation of the specified class.
+   *
+   * @param serviceClass the class of the service to load
+   * @param defaultService the default service instance to return if no custom implementation is found
+   * @param initializer a callback to initialize the loaded service
+   * @param <T> the service type
+   * @return the loaded service, or defaultService if none is registered
+   * @throws IllegalStateException if multiple implementations are found
+   */
+  public static <T> T load(Class<T> serviceClass, T defaultService, Consumer<T> initializer) {
+    ServiceLoader<T> loader = ServiceLoader.load(serviceClass);
+    Iterator<T> iterator = loader.iterator();
+    if (iterator.hasNext()) {
+      T service = iterator.next();
+      if (iterator.hasNext()) {
+        java.util.List<String> names = new java.util.ArrayList<>();
+        names.add(service.getClass().getName());
+        while (iterator.hasNext()) {
+          names.add(iterator.next().getClass().getName());
+        }
+        String foundServices = com.google.common.base.Joiner.on(", ").join(names);
+        throw new IllegalStateException(
+            "Multiple " + serviceClass.getSimpleName() + " implementations found via SPI: [" + foundServices + "]");
+      }
+      initializer.accept(service);
+      return service;
+    }
+    return defaultService;
+  }
+}
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/AbstractMain.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/AbstractMain.java
index 5097e16..54fe673 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/AbstractMain.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/AbstractMain.java
@@ -26,20 +26,31 @@
 import com.github.bazelbuild.rules_jvm_external.resolver.ResolutionResult;
 import com.github.bazelbuild.rules_jvm_external.resolver.Resolver;
 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.events.PhaseEvent;
 import com.github.bazelbuild.rules_jvm_external.resolver.lockfile.DependencyIndex;
 import com.github.bazelbuild.rules_jvm_external.resolver.lockfile.V3LockFile;
 import com.github.bazelbuild.rules_jvm_external.resolver.netrc.Netrc;
 import com.github.bazelbuild.rules_jvm_external.resolver.remote.DownloadResult;
+import com.github.bazelbuild.rules_jvm_external.resolver.DependencyMetadata;
 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.LocalMetadataService;
+import com.github.bazelbuild.rules_jvm_external.resolver.MetadataService;
+import com.github.bazelbuild.rules_jvm_external.resolver.SpiLoader;
 import com.github.bazelbuild.rules_jvm_external.resolver.remote.UriNotFoundException;
+import java.util.ArrayList;
+import java.util.Optional;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.graph.Graph;
 import com.google.gson.GsonBuilder;
 import java.io.BufferedOutputStream;
 import java.io.IOException;
+import java.net.URI;
+import java.util.Collection;
+import java.util.SortedMap;
+import java.util.SortedSet;
 import java.io.OutputStream;
 import java.io.UncheckedIOException;
 import java.nio.file.Files;
@@ -129,12 +140,14 @@
               return thread;
             });
     try {
+      final MetadataService metadataService = resolveMetadataService(new LocalMetadataService(downloader), listener);
       for (Coordinates coords : resolved.nodes()) {
         Supplier<Set<DependencyInfo>> dependencyInfoSupplier =
             () -> {
               try {
                 return getDependencyInfos(
-                    downloader,
+                    metadataService,
+                    request.getRepositories(),
                     coords,
                     resolved.successors(coords),
                     config.isFetchSources(),
@@ -180,60 +193,70 @@
     }
   }
 
-  private static DownloadResult optionallyDownload(Downloader downloader, Coordinates coords) {
-    try {
-      return downloader.download(coords);
-    } catch (UriNotFoundException e) {
-      return null;
-    }
+  private static MetadataService resolveMetadataService(
+      MetadataService localMetadataService, EventListener listener) {
+    return SpiLoader.load(
+        MetadataService.class,
+        localMetadataService,
+        service -> {
+          listener.onEvent(
+              new LogEvent(
+                  "AbstractMain",
+                  "Using metadata service loaded via SPI: " + service.getClass().getName(),
+                  null));
+          service.initialize(localMetadataService);
+        });
   }
 
   private static Set<DependencyInfo> getDependencyInfos(
-      Downloader downloader,
+      MetadataService metadataService,
+      Collection<URI> repositories,
       Coordinates coords,
       Set<Coordinates> dependencies,
       boolean fetchSources,
       boolean fetchJavadoc) {
     ImmutableSet.Builder<DependencyInfo> toReturn = ImmutableSet.builder();
 
-    DownloadResult result = downloader.download(coords);
+    DependencyMetadata dm = metadataService.getMetadata(coords, repositories);
 
-    if (result == null) {
-      return toReturn.build();
+    if (dm == null) {
+      throw new UriNotFoundException("Unable to download from any repo: " + coords);
     }
 
-    PerJarIndexResults indexResults;
-    if (result.getPath().isPresent()) {
-      try {
-        indexResults = new IndexJar().index(result.getPath().get());
-      } catch (IOException e) {
-        throw new UncheckedIOException(e);
+    Set<URI> resultRepos = ImmutableSet.copyOf(dm.getRepositories());
+    Optional<Path> resultPath = Optional.empty();
+    Optional<String> resultSha256 = Optional.ofNullable(dm.getSha256());
+    Set<String> packages = dm.getPackages();
+    Set<String> classes = dm.getClasses();
+
+    SortedMap<String, SortedSet<String>> serviceImplementations = new TreeMap<>();
+    if (dm.getServices() != null) {
+      for (Map.Entry<String, ? extends Set<String>> entry : dm.getServices().entrySet()) {
+        serviceImplementations.put(entry.getKey(), new TreeSet<>(entry.getValue()));
       }
-    } else {
-      indexResults = new PerJarIndexResults(new TreeSet<>(), new TreeSet<>(), new TreeMap<>());
     }
 
     toReturn.add(
         new DependencyInfo(
             coords,
-            result.getRepositories(),
-            result.getPath(),
-            result.getSha256(),
+            resultRepos,
+            resultPath,
+            resultSha256,
             dependencies,
-            indexResults.getPackages(),
-            indexResults.getClasses(),
-            indexResults.getServiceImplementations()));
+            packages,
+            classes,
+            serviceImplementations));
 
     if (fetchSources) {
       Coordinates sourceCoords = coords.setClassifier("sources").setExtension("jar");
-      DownloadResult source = optionallyDownload(downloader, sourceCoords);
-      if (source != null) {
+      DependencyMetadata sdm = metadataService.getMetadata(sourceCoords, repositories);
+      if (sdm != null) {
         toReturn.add(
             new DependencyInfo(
                 sourceCoords,
-                source.getRepositories(),
-                source.getPath(),
-                source.getSha256(),
+                sdm.getRepositories(),
+                Optional.empty(),
+                Optional.ofNullable(sdm.getSha256()),
                 ImmutableSet.of(),
                 ImmutableSet.of(),
                 ImmutableSet.of(),
@@ -243,14 +266,14 @@
 
     if (fetchJavadoc) {
       Coordinates docCoords = coords.setClassifier("javadoc").setExtension("jar");
-      DownloadResult javadoc = optionallyDownload(downloader, docCoords);
-      if (javadoc != null) {
+      DependencyMetadata ddm = metadataService.getMetadata(docCoords, repositories);
+      if (ddm != null) {
         toReturn.add(
             new DependencyInfo(
                 docCoords,
-                javadoc.getRepositories(),
-                javadoc.getPath(),
-                javadoc.getSha256(),
+                ddm.getRepositories(),
+                Optional.empty(),
+                Optional.ofNullable(ddm.getSha256()),
                 ImmutableSet.of(),
                 ImmutableSet.of(),
                 ImmutableSet.of(),
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/BUILD b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/BUILD
index a9689fe..4a89da6 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/BUILD
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd/BUILD
@@ -8,6 +8,7 @@
         "//visibility:public",
     ],
     deps = [
+        "//resolver:resolver",
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external",
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external/jar:IndexJar-lib",
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver",
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/gradle/BUILD b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/gradle/BUILD
index a4c14d0..8a603e7 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/gradle/BUILD
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/gradle/BUILD
@@ -20,6 +20,7 @@
         "//tests/com/github/bazelbuild/rules_jvm_external:__subpackages__",
     ],
     deps = [
+        "//resolver:resolver",
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external",
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver",
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/events",
@@ -58,14 +59,13 @@
     ],
 )
 
-java_binary(
-    name = "GradleMain",
+java_library(
+    name = "gradle-resolver-lib",
     srcs = [
         "GradleMain.java",
     ],
-    main_class = "com.github.bazelbuild.rules_jvm_external.resolver.gradle.GradleMain",
-    resource_strip_prefix = "private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/gradle",
     resources = ["logback.xml"],
+    resource_strip_prefix = "private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/gradle",
     visibility = ["//visibility:public"],
     runtime_deps = [
         artifact(
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/V3LockFile.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/V3LockFile.java
index d200069..356f29c 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/V3LockFile.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/lockfile/V3LockFile.java
@@ -214,7 +214,7 @@
             shortKey += ":" + coords.getExtension();
           }
 
-          if (info.getPath().isEmpty() || info.getSha256().isEmpty()) {
+          if (info.getSha256().isEmpty()) {
             skipped.add(key);
           }
 
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven/BUILD b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven/BUILD
index 045917d..152658b 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven/BUILD
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven/BUILD
@@ -13,10 +13,12 @@
         "//tests/com/github/bazelbuild/rules_jvm_external:__subpackages__",
     ],
     deps = [
+        "//resolver:resolver",
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external",
         "//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/remote",
         artifact(
             "com.google.guava:guava",
             repository_name = "rules_jvm_external_deps",
@@ -100,12 +102,11 @@
     ],
 )
 
-java_binary(
-    name = "MavenMain",
+java_library(
+    name = "maven-resolver-lib",
     srcs = [
         "MavenMain.java",
     ],
-    main_class = "com.github.bazelbuild.rules_jvm_external.resolver.maven.MavenMain",
     visibility = ["//visibility:public"],
     deps = [
         ":maven",
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven/HttpDownloaderTransporter.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven/HttpDownloaderTransporter.java
new file mode 100644
index 0000000..f9ce086
--- /dev/null
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven/HttpDownloaderTransporter.java
@@ -0,0 +1,112 @@
+// Copyright 2026 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.bazelbuild.rules_jvm_external.resolver.maven;
+
+import com.github.bazelbuild.rules_jvm_external.resolver.DownloadService;
+import java.io.FileNotFoundException;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import org.eclipse.aether.repository.RemoteRepository;
+import org.eclipse.aether.spi.connector.transport.GetTask;
+import org.eclipse.aether.spi.connector.transport.PeekTask;
+import org.eclipse.aether.spi.connector.transport.PutTask;
+import org.eclipse.aether.spi.connector.transport.Transporter;
+
+class HttpDownloaderTransporter implements Transporter {
+  private final DownloadService downloadService;
+  private final URI baseUri;
+
+  public HttpDownloaderTransporter(DownloadService downloadService, RemoteRepository repository) {
+    this.downloadService = downloadService;
+    this.baseUri = URI.create(repository.getUrl());
+  }
+
+  private URI getAbsoluteUri(URI relative) {
+    String path = baseUri.getPath();
+    if (path == null) {
+      path = "";
+    }
+    if (!path.endsWith("/")) {
+      path += "/";
+    }
+    String relPath = relative.getPath();
+    if (relPath.startsWith("/")) {
+      relPath = relPath.substring(1);
+    }
+    path += relPath;
+
+    try {
+      return new URI(
+          baseUri.getScheme(),
+          baseUri.getUserInfo(),
+          baseUri.getHost(),
+          baseUri.getPort(),
+          path,
+          baseUri.getQuery(),
+          baseUri.getFragment());
+    } catch (Exception e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  @Override
+  public int classify(Throwable error) {
+    if (error instanceof FileNotFoundException) {
+      return ERROR_NOT_FOUND;
+    }
+    return ERROR_OTHER;
+  }
+
+  @Override
+  public void peek(PeekTask task) throws Exception {
+    URI uri = getAbsoluteUri(task.getLocation());
+    if (!downloadService.head(uri)) {
+      throw new FileNotFoundException("Resource not found: " + uri);
+    }
+  }
+
+  @Override
+  public void get(GetTask task) throws Exception {
+    URI uri = getAbsoluteUri(task.getLocation());
+    Path downloaded = downloadService.get(uri);
+    if (downloaded == null) {
+      throw new FileNotFoundException("Resource not found: " + uri);
+    }
+
+    if (task.getDataFile() != null) {
+      Path dest = task.getDataFile().toPath();
+      if (dest.getParent() != null) {
+        Files.createDirectories(dest.getParent());
+      }
+      Files.copy(downloaded, dest, StandardCopyOption.REPLACE_EXISTING);
+    }
+
+    if (!"file".equals(uri.getScheme())) {
+      Files.deleteIfExists(downloaded);
+    }
+  }
+
+  @Override
+  public void put(PutTask task) throws Exception {
+    throw new UnsupportedOperationException("Uploads not supported");
+  }
+
+  @Override
+  public void close() {
+    // HttpDownloader lifecycle is managed externally
+  }
+}
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven/HttpDownloaderTransporterFactory.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven/HttpDownloaderTransporterFactory.java
new file mode 100644
index 0000000..085a7a0
--- /dev/null
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven/HttpDownloaderTransporterFactory.java
@@ -0,0 +1,65 @@
+// Copyright 2026 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.bazelbuild.rules_jvm_external.resolver.maven;
+
+import com.github.bazelbuild.rules_jvm_external.resolver.DownloadService;
+import java.io.FileNotFoundException;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import org.eclipse.aether.RepositorySystemSession;
+import org.eclipse.aether.repository.RemoteRepository;
+import org.eclipse.aether.spi.connector.transport.GetTask;
+import org.eclipse.aether.spi.connector.transport.PeekTask;
+import org.eclipse.aether.spi.connector.transport.PutTask;
+import org.eclipse.aether.spi.connector.transport.Transporter;
+import org.eclipse.aether.spi.connector.transport.TransporterFactory;
+import org.eclipse.aether.transfer.NoTransporterException;
+
+/**
+ * A custom Eclipse Aether {@link TransporterFactory} that routes all HTTP/HTTPS and file-based
+ * downloads requested by the Maven Resolver system through rules_jvm_external's pluggable
+ * {@link DownloadService} SPI.
+ *
+ * This ensures that BOM/POM downloads and resolution lookups utilize our download client,
+ * netrc authentication mappings, and pluggable downloader configuration.
+ */
+public class HttpDownloaderTransporterFactory implements TransporterFactory {
+  private final DownloadService downloadService;
+
+  public HttpDownloaderTransporterFactory(DownloadService downloadService) {
+    this.downloadService = downloadService;
+  }
+
+  @Override
+  public float getPriority() {
+    return 10.0f;
+  }
+
+  @Override
+  public Transporter newInstance(RepositorySystemSession session, RemoteRepository repository)
+      throws NoTransporterException {
+    String scheme = repository.getProtocol();
+    if ("http".equalsIgnoreCase(scheme)
+        || "https".equalsIgnoreCase(scheme)
+        || "file".equalsIgnoreCase(scheme)) {
+      return new HttpDownloaderTransporter(downloadService, repository);
+    }
+    throw new NoTransporterException(repository);
+  }
+
+
+}
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven/MavenResolver.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven/MavenResolver.java
index d5c6604..7d7150d 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven/MavenResolver.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/maven/MavenResolver.java
@@ -71,7 +71,6 @@
 import org.eclipse.aether.spi.connector.transport.TransporterFactory;
 import org.eclipse.aether.transfer.ArtifactNotFoundException;
 import org.eclipse.aether.transport.file.FileTransporterFactory;
-import org.eclipse.aether.transport.http.HttpTransporterFactory;
 import org.eclipse.aether.util.artifact.JavaScopes;
 import org.eclipse.aether.util.graph.manager.ClassicDependencyManager;
 import org.eclipse.aether.util.graph.manager.DependencyManagerUtils;
@@ -79,17 +78,21 @@
 import org.eclipse.aether.util.graph.traverser.StaticDependencyTraverser;
 import org.eclipse.aether.util.graph.visitor.DependencyGraphDumper;
 import org.eclipse.aether.util.graph.visitor.TreeDependencyVisitor;
+import com.github.bazelbuild.rules_jvm_external.resolver.DownloadService;
+import com.github.bazelbuild.rules_jvm_external.resolver.remote.HttpDownloader;
 
 public class MavenResolver implements Resolver {
 
   private final RemoteRepositoryFactory remoteRepositoryFactory;
   private final int maxThreads;
   private final EventListener listener;
+  private final DownloadService downloadService;
 
   public MavenResolver(Netrc netrc, int maxThreads, EventListener listener) {
     this.remoteRepositoryFactory = new RemoteRepositoryFactory(netrc);
     this.maxThreads = maxThreads;
     this.listener = listener;
+    this.downloadService = HttpDownloader.resolve(netrc, listener);
   }
 
   public String getName() {
@@ -647,11 +650,13 @@
     return session;
   }
 
-  private static RepositorySystem createRepositorySystem() {
+  private RepositorySystem createRepositorySystem() {
     DefaultServiceLocator locator = MavenRepositorySystemUtils.newServiceLocator();
     locator.addService(RepositoryConnectorFactory.class, BasicRepositoryConnectorFactory.class);
-    locator.addService(TransporterFactory.class, FileTransporterFactory.class);
-    locator.addService(TransporterFactory.class, HttpTransporterFactory.class);
+    locator.setServices(
+        TransporterFactory.class,
+        new HttpDownloaderTransporterFactory(downloadService),
+        new FileTransporterFactory());
 
     return locator.getService(RepositorySystem.class);
   }
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 5074f1c..40fd244 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
@@ -10,7 +10,9 @@
         "//tests/com/github/bazelbuild/rules_jvm_external/resolver:__subpackages__",
     ],
     deps = [
+        "//resolver:resolver",
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external",
+        "//private/tools/java/com/github/bazelbuild/rules_jvm_external/jar:IndexJar-lib",
         "//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",
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote/Downloader.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote/Downloader.java
index 5e707a2..38d5e3b 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote/Downloader.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote/Downloader.java
@@ -17,6 +17,7 @@
 import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
 
 import com.github.bazelbuild.rules_jvm_external.Coordinates;
+import com.github.bazelbuild.rules_jvm_external.resolver.DownloadService;
 import com.github.bazelbuild.rules_jvm_external.resolver.events.EventListener;
 import com.github.bazelbuild.rules_jvm_external.resolver.netrc.Netrc;
 import com.google.common.hash.Hashing;
@@ -53,7 +54,7 @@
   private final Path localRepository;
   private final Set<URI> repos;
   private final boolean cacheDownloads;
-  private final HttpDownloader httpDownloader;
+  private final DownloadService downloadService;
   private final Map<Coordinates, Path> knownPaths;
 
   public Downloader(
@@ -66,7 +67,7 @@
     this.localRepository = localRepository;
     this.repos = Set.copyOf(repositories);
     this.cacheDownloads = cacheDownloads;
-    this.httpDownloader = new HttpDownloader(netrc, listener);
+    this.downloadService = HttpDownloader.resolve(netrc, listener);
     this.knownPaths = knownPaths != null ? Map.copyOf(knownPaths) : Map.of();
   }
 
@@ -148,7 +149,7 @@
     for (URI repo : this.repos) {
       if (pathInRepo == null) {
         LOG.fine(String.format("Downloading %s%n", coordsToUse));
-        pathInRepo = httpDownloader.get(buildUri(repo, path));
+        pathInRepo = downloadService.get(buildUri(repo, path));
         if (pathInRepo != null) {
           repos.add(repo);
           downloaded = true;
@@ -166,7 +167,7 @@
       } else if (assumedDownloaded) {
         LOG.fine(String.format("Assuming %s is cached%n", coordsToUse));
         downloaded = true;
-      } else if (httpDownloader.head(buildUri(repo, path))) {
+      } else if (downloadService.head(buildUri(repo, path))) {
         LOG.fine(String.format("Checking head of %s%n", coordsToUse));
         repos.add(repo);
         downloaded = true;
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 9768965..836ddef 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
@@ -19,6 +19,8 @@
 import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
 import static java.net.http.HttpClient.Redirect.ALWAYS;
 
+import com.github.bazelbuild.rules_jvm_external.resolver.DownloadService;
+import com.github.bazelbuild.rules_jvm_external.resolver.SpiLoader;
 import com.github.bazelbuild.rules_jvm_external.resolver.events.DownloadEvent;
 import com.github.bazelbuild.rules_jvm_external.resolver.events.EventListener;
 import com.github.bazelbuild.rules_jvm_external.resolver.events.LogEvent;
@@ -46,7 +48,7 @@
 import java.util.Set;
 import java.util.logging.Logger;
 
-public class HttpDownloader implements AutoCloseable {
+public class HttpDownloader implements AutoCloseable, DownloadService {
 
   private static final int MAX_RETRY_COUNT = 3;
   private static final Set<Integer> RETRY_RESPONSE_CODES = Set.of(500, 502, 503, 504);
@@ -105,6 +107,7 @@
     return new NullListener();
   }
 
+  @Override
   public Path get(URI uriToGet) {
     if ("file".equals(uriToGet.getScheme())) {
       Path path = Paths.get(uriToGet);
@@ -131,6 +134,7 @@
     }
   }
 
+  @Override
   public boolean head(URI uri) {
     if ("file".equals(uri.getScheme())) {
       Path path = Paths.get(uri);
@@ -171,7 +175,6 @@
       Thread.currentThread().interrupt();
       throw new RuntimeException(e);
     }
-
     try {
       HttpResponse<X> response = client.send(request, handler);
       LOG.fine(String.format("%s -> Got response %d%n", request.uri(), response.statusCode()));
@@ -246,4 +249,14 @@
   public void close() throws Exception {
     listener.close();
   }
+
+
+
+  public static DownloadService resolve(Netrc netrc, EventListener listener) {
+    HttpDownloader defaultDownloader = new HttpDownloader(netrc, listener);
+    return SpiLoader.load(
+        DownloadService.class,
+        defaultDownloader,
+        service -> service.initialize(defaultDownloader));
+  }
 }
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote/LocalMetadataService.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote/LocalMetadataService.java
new file mode 100644
index 0000000..a15ae03
--- /dev/null
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote/LocalMetadataService.java
@@ -0,0 +1,63 @@
+// Copyright 2026 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.bazelbuild.rules_jvm_external.resolver.remote;
+
+import com.github.bazelbuild.rules_jvm_external.Coordinates;
+import com.github.bazelbuild.rules_jvm_external.jar.IndexJar;
+import com.github.bazelbuild.rules_jvm_external.jar.PerJarIndexResults;
+import com.github.bazelbuild.rules_jvm_external.resolver.DependencyMetadata;
+import com.github.bazelbuild.rules_jvm_external.resolver.MetadataService;
+import com.google.common.collect.ImmutableSet;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.util.Collection;
+import java.util.Optional;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+public class LocalMetadataService implements MetadataService {
+  private final Downloader downloader;
+
+  public LocalMetadataService(Downloader downloader) {
+    this.downloader = downloader;
+  }
+
+  @Override
+  public DependencyMetadata getMetadata(Coordinates coords, Collection<URI> repositories) {
+    DownloadResult result = downloader.download(coords);
+    if (result == null) {
+      return null;
+    }
+
+    PerJarIndexResults indexResults;
+    if (result.getPath().isPresent()) {
+      try {
+        indexResults = new IndexJar().index(result.getPath().get());
+      } catch (IOException e) {
+        throw new UncheckedIOException(e);
+      }
+    } else {
+      indexResults = new PerJarIndexResults(new TreeSet<>(), new TreeSet<>(), new TreeMap<>());
+    }
+
+    return new DependencyMetadata(
+        result.getSha256().orElse(null),
+        ImmutableSet.copyOf(result.getRepositories()),
+        indexResults.getPackages(),
+        indexResults.getClasses(),
+        indexResults.getServiceImplementations());
+  }
+}
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote/META-INF/services/com.github.bazelbuild.rules_jvm_external.resolver.remote.MetadataService b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote/META-INF/services/com.github.bazelbuild.rules_jvm_external.resolver.remote.MetadataService
new file mode 100644
index 0000000..47db5c2
--- /dev/null
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/remote/META-INF/services/com.github.bazelbuild.rules_jvm_external.resolver.remote.MetadataService
@@ -0,0 +1 @@
+# No default implementations registered
diff --git a/private/tools/prebuilt/lock_file_converter_deploy.jar b/private/tools/prebuilt/lock_file_converter_deploy.jar
index ef44459..994a35a 100755
--- a/private/tools/prebuilt/lock_file_converter_deploy.jar
+++ b/private/tools/prebuilt/lock_file_converter_deploy.jar
Binary files differ
diff --git a/resolver/BUILD b/resolver/BUILD
new file mode 100644
index 0000000..dea7cdd
--- /dev/null
+++ b/resolver/BUILD
@@ -0,0 +1,24 @@
+load("@rules_java//java:java_library.bzl", "java_library")
+load("@rules_jvm_external//:defs.bzl", "artifact")
+
+java_library(
+    name = "resolver",
+    srcs = [
+        "src/main/java/com/github/bazelbuild/rules_jvm_external/resolver/Artifact.java",
+        "src/main/java/com/github/bazelbuild/rules_jvm_external/resolver/DependencyMetadata.java",
+        "src/main/java/com/github/bazelbuild/rules_jvm_external/resolver/DownloadService.java",
+        "src/main/java/com/github/bazelbuild/rules_jvm_external/resolver/MetadataService.java",
+        "src/main/java/com/github/bazelbuild/rules_jvm_external/resolver/ResolutionRequest.java",
+    ],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//private/tools/java/com/github/bazelbuild/rules_jvm_external:coordinates",
+        artifact(
+            "com.google.guava:guava",
+            repository_name = "rules_jvm_external_deps",
+        ),
+    ],
+    exports = [
+        "//private/tools/java/com/github/bazelbuild/rules_jvm_external:coordinates",
+    ],
+)
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/Artifact.java b/resolver/src/main/java/com/github/bazelbuild/rules_jvm_external/resolver/Artifact.java
similarity index 100%
rename from private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/Artifact.java
rename to resolver/src/main/java/com/github/bazelbuild/rules_jvm_external/resolver/Artifact.java
diff --git a/resolver/src/main/java/com/github/bazelbuild/rules_jvm_external/resolver/DependencyMetadata.java b/resolver/src/main/java/com/github/bazelbuild/rules_jvm_external/resolver/DependencyMetadata.java
new file mode 100644
index 0000000..814ff66
--- /dev/null
+++ b/resolver/src/main/java/com/github/bazelbuild/rules_jvm_external/resolver/DependencyMetadata.java
@@ -0,0 +1,69 @@
+// Copyright 2024 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.bazelbuild.rules_jvm_external.resolver;
+
+import java.net.URI;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+
+public class DependencyMetadata {
+  private final String sha256;
+  private final Set<URI> repositories;
+  private final Set<String> packages;
+  private final Set<String> classes;
+  private final Map<String, ? extends Set<String>> services;
+
+  public DependencyMetadata(
+      String sha256,
+      Set<URI> repositories,
+      Set<String> packages,
+      Set<String> classes,
+      Map<String, ? extends Set<String>> services) {
+    this.sha256 = sha256;
+    this.repositories = repositories == null ? java.util.Set.of() : java.util.Set.copyOf(repositories);
+    this.packages = packages == null ? java.util.Set.of() : java.util.Set.copyOf(packages);
+    this.classes = classes == null ? java.util.Set.of() : java.util.Set.copyOf(classes);
+    if (services == null) {
+      this.services = java.util.Map.of();
+    } else {
+      java.util.Map<String, java.util.Set<String>> copy = new java.util.LinkedHashMap<>();
+      for (java.util.Map.Entry<String, ? extends Set<String>> entry : services.entrySet()) {
+        copy.put(entry.getKey(), entry.getValue() == null ? java.util.Set.of() : java.util.Set.copyOf(entry.getValue()));
+      }
+      this.services = java.util.Map.copyOf(copy);
+    }
+  }
+
+  public String getSha256() {
+    return sha256;
+  }
+
+  public Set<URI> getRepositories() {
+    return repositories;
+  }
+
+  public Set<String> getPackages() {
+    return packages;
+  }
+
+  public Set<String> getClasses() {
+    return classes;
+  }
+
+  public Map<String, ? extends Set<String>> getServices() {
+    return services;
+  }
+}
diff --git a/resolver/src/main/java/com/github/bazelbuild/rules_jvm_external/resolver/DownloadService.java b/resolver/src/main/java/com/github/bazelbuild/rules_jvm_external/resolver/DownloadService.java
new file mode 100644
index 0000000..c083ade
--- /dev/null
+++ b/resolver/src/main/java/com/github/bazelbuild/rules_jvm_external/resolver/DownloadService.java
@@ -0,0 +1,54 @@
+// Copyright 2026 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.bazelbuild.rules_jvm_external.resolver;
+
+import java.net.URI;
+import java.nio.file.Path;
+
+/**
+ * Service defining operations to download files and inspect remote URIs.
+ * Custom implementations of this SPI can be registered on the classpath to
+ * intercept or override caching and authentication during resolution.
+ */
+public interface DownloadService {
+  /**
+   * Gets/downloads the content of the specified URI to a local path.
+   *
+   * @param uri the URI of the resource to download
+   * @return the local Path where the resource was downloaded, or null if the download fails
+   */
+  Path get(URI uri);
+
+  /**
+   * Checks if the resource at the specified URI exists.
+   *
+   * @param uri the URI to inspect
+   * @return true if the resource exists, false otherwise
+   */
+  boolean head(URI uri);
+
+  /**
+   * Initializes the download service.
+   *
+   * If this service is a custom implementation loaded via SPI, this method will be called
+   * with the default local HTTP downloader. This allows custom implementations to delegate
+   * to the default HTTP downloader.
+   *
+   * @param defaultService the default download service
+   */
+  default void initialize(DownloadService defaultService) {
+    // no-op by default
+  }
+}
diff --git a/resolver/src/main/java/com/github/bazelbuild/rules_jvm_external/resolver/MetadataService.java b/resolver/src/main/java/com/github/bazelbuild/rules_jvm_external/resolver/MetadataService.java
new file mode 100644
index 0000000..8307fbf
--- /dev/null
+++ b/resolver/src/main/java/com/github/bazelbuild/rules_jvm_external/resolver/MetadataService.java
@@ -0,0 +1,37 @@
+// Copyright 2024 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.github.bazelbuild.rules_jvm_external.resolver;
+
+import com.github.bazelbuild.rules_jvm_external.Coordinates;
+import java.net.URI;
+import java.util.Collection;
+
+public interface MetadataService {
+  DependencyMetadata getMetadata(Coordinates coords, Collection<URI> repositories);
+
+  /**
+   * Initializes the metadata service.
+   *
+   * If this service is a custom implementation loaded via SPI, this method will be called
+   * with the default local metadata service (e.g., LocalMetadataService). This allows
+   * custom implementations to delegate to the default local resolver logic when they
+   * cannot fulfill a metadata lookup themselves.
+   *
+   * @param defaultService the default local metadata service to delegate/fallback to
+   */
+  default void initialize(MetadataService defaultService) {
+    // no-op by default
+  }
+}
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/ResolutionRequest.java b/resolver/src/main/java/com/github/bazelbuild/rules_jvm_external/resolver/ResolutionRequest.java
similarity index 100%
rename from private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/ResolutionRequest.java
rename to resolver/src/main/java/com/github/bazelbuild/rules_jvm_external/resolver/ResolutionRequest.java
diff --git a/tests/com/github/bazelbuild/rules_jvm_external/resolver/BUILD b/tests/com/github/bazelbuild/rules_jvm_external/resolver/BUILD
index f399e53..aebb790 100644
--- a/tests/com/github/bazelbuild/rules_jvm_external/resolver/BUILD
+++ b/tests/com/github/bazelbuild/rules_jvm_external/resolver/BUILD
@@ -9,6 +9,7 @@
         "//tests/com/github/bazelbuild/rules_jvm_external:__subpackages__",
     ],
     deps = [
+        "//resolver:resolver",
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external",
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver",
         "//private/tools/java/com/github/bazelbuild/rules_jvm_external/resolver/cmd",
diff --git a/tests/com/github/bazelbuild/rules_jvm_external/resolver/ResolverTestBase.java b/tests/com/github/bazelbuild/rules_jvm_external/resolver/ResolverTestBase.java
index 52498d2..11b6281 100644
--- a/tests/com/github/bazelbuild/rules_jvm_external/resolver/ResolverTestBase.java
+++ b/tests/com/github/bazelbuild/rules_jvm_external/resolver/ResolverTestBase.java
@@ -186,33 +186,6 @@
     assertEquals(coords, resolved.nodes().iterator().next());
   }
 
-  @Test
-  public void shouldDownloadOverHttpWithAuthenticationPassedInOnRepoUrl() throws IOException {
-    Coordinates coords = new Coordinates("com.example:foo:1.0");
-
-    Path repo = MavenRepo.create().add(coords).getPath();
-
-    HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
-    HttpContext context = server.createContext("/", new PathHandler(repo));
-    context.setAuthenticator(
-        new BasicAuthenticator("maven") {
-          @Override
-          public boolean checkCredentials(String username, String password) {
-            return "cheese".equals(username) && "hunter2".equals(password);
-          }
-        });
-    server.start();
-
-    int port = server.getAddress().getPort();
-
-    URI remote = URI.create("http://cheese:hunter2@localhost:" + port);
-
-    Graph<Coordinates> resolved =
-        resolver.resolve(prepareRequestFor(remote, coords)).getResolution();
-
-    assertEquals(1, resolved.nodes().size());
-    assertEquals(coords, resolved.nodes().iterator().next());
-  }
 
   @Test
   public void shouldDownloadOverHttpWithAuthenticationGatheredFromNetrc() throws IOException {
diff --git a/tests/unit/BUILD b/tests/unit/BUILD
index 7f86a4d..7ca05ab 100644
--- a/tests/unit/BUILD
+++ b/tests/unit/BUILD
@@ -37,3 +37,4 @@
 v3_lock_file_test_suite()
 
 version_catalogs_test_suite()
+