From 19d3bf67a559d0c7c74372e6401d08ebef6dce6c Mon Sep 17 00:00:00 2001 From: Abhishek Bhaskar Date: Wed, 15 Apr 2026 01:06:44 -0500 Subject: [PATCH 1/5] extract maven repo interaction logic for JVM ecosystems reuse --- .../maven/package/package_details_fetcher.rb | 332 ++--------------- .../shared/shared_maven_repository_client.rb | 339 ++++++++++++++++++ 2 files changed, 370 insertions(+), 301 deletions(-) create mode 100644 maven/lib/dependabot/maven/shared/shared_maven_repository_client.rb diff --git a/maven/lib/dependabot/maven/package/package_details_fetcher.rb b/maven/lib/dependabot/maven/package/package_details_fetcher.rb index ffd426c4cb2..5570bebee89 100644 --- a/maven/lib/dependabot/maven/package/package_details_fetcher.rb +++ b/maven/lib/dependabot/maven/package/package_details_fetcher.rb @@ -10,20 +10,15 @@ require "dependabot/maven/file_parser/repositories_finder" require "dependabot/maven/version" require "dependabot/maven/requirement" -require "dependabot/maven/utils/auth_headers_finder" +require "dependabot/maven/shared/shared_maven_repository_client" require "sorbet-runtime" module Dependabot module Maven module Package - class PackageDetailsFetcher + class PackageDetailsFetcher < Dependabot::Maven::Shared::SharedMavenRepositoryClient extend T::Sig - META_DATE_XML = T.let("maven-metadata.xml", String) - REPOSITORY_TYPE = T.let("maven_repository", String) - URL_KEY = T.let("url", String) - AUTH_HEADERS_KEY = T.let("auth_headers", String) - sig do params( dependency: Dependabot::Dependency, @@ -31,36 +26,27 @@ class PackageDetailsFetcher credentials: T::Array[Dependabot::Credential] ).void end - def initialize(dependency:, dependency_files:, credentials:) # rubocop:disable Metrics/AbcSize - @dependency = dependency - @dependency_files = dependency_files - @credentials = credentials + def initialize(dependency:, dependency_files:, credentials:) + @dependency = T.let(dependency, Dependabot::Dependency) + @dependency_files = T.let(dependency_files, T::Array[Dependabot::DependencyFile]) + @credentials = T.let(credentials, T::Array[Dependabot::Credential]) - @forbidden_urls = T.let([], T::Array[String]) @pom_repository_details = T.let(nil, T.nilable(T::Array[T::Hash[String, T.untyped]])) - @dependency_metadata = T.let({}, T::Hash[T.untyped, Nokogiri::XML::Document]) - @dependency_metadata_from_html = T.let({}, T::Hash[T.untyped, Nokogiri::HTML::Document]) @repository_finder = T.let(nil, T.nilable(Maven::FileParser::RepositoriesFinder)) - @repositories = T.let(nil, T.nilable(T::Array[T::Hash[String, T.untyped]])) - @released_check = T.let({}, T::Hash[Dependabot::Version, T::Boolean]) - @auth_headers_finder = T.let(nil, T.nilable(Utils::AuthHeadersFinder)) - @dependency_parts = T.let(nil, T.nilable([String, String])) + @repositories_cache = T.let(nil, T.nilable(T::Array[T::Hash[String, T.untyped]])) @version_details = T.let(nil, T.nilable(T::Array[T::Hash[Symbol, T.untyped]])) @package_details = T.let(nil, T.nilable(Dependabot::Package::PackageDetails)) end - sig { returns(Dependabot::Dependency) } + sig { override.returns(Dependabot::Dependency) } attr_reader :dependency - sig { returns(T::Array[T.untyped]) } + sig { returns(T::Array[Dependabot::DependencyFile]) } attr_reader :dependency_files - sig { returns(T::Array[T.untyped]) } + sig { override.returns(T::Array[Dependabot::Credential]) } attr_reader :credentials - sig { returns(T::Array[T.untyped]) } - attr_reader :forbidden_urls - sig { returns(Dependabot::Package::PackageDetails) } def fetch return @package_details if @package_details @@ -88,7 +74,27 @@ def releases sig { params(version: Dependabot::Version).returns(T::Boolean) } def released?(version) - released_check?(version) + super + end + + # Assembles the list of Maven repositories to search: credential repos + POM repos. + sig { override.returns(T::Array[T::Hash[String, T.untyped]]) } + def repositories + return @repositories_cache if @repositories_cache + + @repositories_cache = credentials_repository_details + pom_repository_details.each do |repo| + @repositories_cache << repo unless @repositories_cache.any? do |r| + r[URL_KEY] == repo[URL_KEY] + end + end + @repositories_cache + end + + # Uses the Maven RepositoriesFinder's central URL to support credential-based overrides. + sig { override.returns(String) } + def central_repo_url + repository_finder.central_repo_url end private @@ -171,159 +177,6 @@ def versions_details_hash_from_html versions_detail_hash end - sig { params(version: Dependabot::Version).returns(T::Boolean) } - def released_check?(version) - @released_check[version] ||= - repositories.any? do |repository_details| - url = repository_details.fetch(URL_KEY) - auth_headers = repository_details.fetch(AUTH_HEADERS_KEY) - response = Dependabot::RegistryClient.head( - url: dependency_files_url(url, version), - headers: auth_headers - ) - - response.status < 400 - rescue Excon::Error::Socket, Excon::Error::Timeout, - Excon::Error::TooManyRedirects - false - rescue URI::InvalidURIError => e - raise DependencyFileNotResolvable, e.message - end - end - - # Extracts version details from the HTML document. - sig do - params(html_doc: Nokogiri::HTML::Document) - .returns(T::Hash[String, T::Hash[Symbol, T.untyped]]) - end - def extract_version_details_from_html(html_doc) - versions_detail_hash = T.let({}, T::Hash[String, T::Hash[Symbol, T.untyped]]) - - html_doc.css("a[title]").each do |link| - version_string = link["title"] - version = version_string.gsub(%r{/$}, "") # Remove trailing slash - - # Release date should be located after the version, and it is within the same
 block
-            raw_date_text = link.next.text.strip.split("\n").last.strip # Extract the last part of the text
-
-            # Parse the date and time properly (YYYY-MM-DD HH:MM)
-            release_date = begin
-              Time.parse(raw_date_text)
-            rescue StandardError
-              nil
-            end
-
-            next unless version && version_class.correct?(version)
-
-            versions_detail_hash[version] = {
-              release_date: release_date
-            }
-          end
-          versions_detail_hash
-        end
-
-        # Extracts version details from the XML document.
-        sig do
-          params(
-            xml: Nokogiri::XML::Document,
-            url: String
-          ).returns(T::Array[T::Hash[Symbol, T.untyped]])
-        end
-        def extract_metadata_from_xml(xml, url)
-          xml.css("versions > version")
-             .select { |node| version_class.correct?(node.content) }
-             .map { |node| version_class.new(node.content) }
-             .map { |version| { version: version, source_url: url } }
-        end
-
-        sig { params(repository_details: T::Hash[String, T.untyped]).returns(T.nilable(Nokogiri::XML::Document)) }
-        def fetch_dependency_metadata(repository_details)
-          url = repository_details.fetch(URL_KEY)
-          auth_headers = repository_details.fetch(AUTH_HEADERS_KEY)
-          response = Dependabot::RegistryClient.get(
-            url: dependency_metadata_url(url),
-            headers: auth_headers
-          )
-          check_response(response, url)
-          return unless response.status < 400
-
-          Nokogiri::XML(response.body)
-        rescue URI::InvalidURIError
-          nil
-        rescue Excon::Error::Socket, Excon::Error::Timeout,
-               Excon::Error::TooManyRedirects => e
-          handle_registry_error(url, e, response)
-          nil
-        end
-
-        sig { returns(T::Array[T::Hash[String, T.untyped]]) }
-        def repositories
-          return @repositories if @repositories
-
-          @repositories = credentials_repository_details
-          pom_repository_details.each do |repo|
-            @repositories << repo unless @repositories.any? do |r|
-              r[URL_KEY] == repo[URL_KEY]
-            end
-          end
-          @repositories
-        end
-
-        sig { params(repository_details: T::Hash[String, T.untyped]).returns(T.nilable(Nokogiri::XML::Document)) }
-        def dependency_metadata(repository_details)
-          repository_key = repository_details.hash
-          return @dependency_metadata[repository_key] if @dependency_metadata.key?(repository_key)
-
-          xml_document = fetch_dependency_metadata(repository_details)
-
-          @dependency_metadata[repository_key] ||= xml_document if xml_document
-          @dependency_metadata[repository_key]
-        end
-
-        sig { params(repository_details: T::Hash[String, T.untyped]).returns(T.nilable(Nokogiri::HTML::Document)) }
-        def dependency_metadata_from_html(repository_details)
-          repository_key = repository_details.hash
-          return @dependency_metadata_from_html[repository_key] if @dependency_metadata_from_html.key?(repository_key)
-
-          html_document = fetch_dependency_metadata_from_html(repository_details)
-
-          @dependency_metadata_from_html[repository_key] ||= html_document if html_document
-          @dependency_metadata_from_html[repository_key]
-        end
-
-        sig { params(response: Excon::Response, repository_url: String).void }
-        def check_response(response, repository_url)
-          return unless [401, 403].include?(response.status)
-          return if @forbidden_urls.include?(repository_url)
-          return if central_repo_urls.include?(repository_url)
-
-          @forbidden_urls << repository_url
-        end
-
-        sig do
-          params(
-            repository_details: T::Hash[String, T.untyped]
-          ).returns(T.nilable(Nokogiri::HTML::Document))
-        end
-        def fetch_dependency_metadata_from_html(repository_details)
-          url = repository_details.fetch(URL_KEY)
-          auth_headers = repository_details.fetch(AUTH_HEADERS_KEY)
-          response = Dependabot::RegistryClient.get(
-            url: dependency_base_url(url),
-            headers: auth_headers
-          )
-          check_response(response, url)
-          return unless response.status < 400
-
-          Nokogiri::HTML(response.body)
-        rescue URI::InvalidURIError
-          nil
-        rescue Excon::Error::Socket, Excon::Error::Timeout,
-               Excon::Error::TooManyRedirects => e
-          handle_registry_error(url, e, response)
-          nil
-        end
-
         sig { returns(Maven::FileParser::RepositoriesFinder) }
         def repository_finder
           return @repository_finder if @repository_finder
@@ -338,9 +191,6 @@ def repository_finder
         end
 
         # Returns the repository details for the POM file.
-        # Example:
-        #  repository_url: https://repo.maven.apache.org/maven2
-        #  returns: [{ "url" => "https://repo.maven.apache.org/maven2", "auth_headers" => {} }]
         sig { returns(T::Array[T::Hash[String, T.untyped]]) }
         def pom_repository_details
           return @pom_repository_details if @pom_repository_details
@@ -361,126 +211,6 @@ def pom
                      dependency.requirements.first&.dig(:metadata, :pom_file)
           dependency_files.find { |f| f.name == filename }
         end
-
-        # Constructs the URL for the dependency's metadata file (maven-metadata.xml).
-        #
-        # Example:
-        #   repository_url: https://repo.maven.apache.org/maven2
-        #   returns: https://repo.maven.apache.org/maven2/com/google/guava/guava/maven-metadata.xml
-        sig { params(repository_url: String).returns(String) }
-        def dependency_metadata_url(repository_url)
-          "#{dependency_base_url(repository_url)}/#{META_DATE_XML}"
-        end
-
-        # Constructs the URL for the dependency files, including version and artifact information.
-        #
-        # Example:
-        #   repository_url: https://repo.maven.apache.org/maven2
-        #   version: 23.6-jre
-        #   artifact_id: guava
-        #   group_id: com.google.guava
-        #   classifier: nil
-        #   type: jar
-        #   returns: https://repo.maven.apache.org/maven2/com/google/guava/guava/23.6-jre/guava-23.6-jre.jar
-        #            https://repo.maven.apache.org/maven2/com/google/guava/guava/23.7-jre/-23.7-jre.jar
-        sig { params(repository_url: String, version: Dependabot::Version).returns(String) }
-        def dependency_files_url(repository_url, version)
-          _, artifact_id = dependency_parts
-          base_url = dependency_base_url(repository_url)
-          type = dependency.requirements.first&.dig(:metadata, :packaging_type)
-          classifier = dependency.requirements.first&.dig(:metadata, :classifier)
-          actual_classifier = classifier.nil? ? "" : "-#{classifier}"
-
-          "#{base_url}/#{version}/" \
-            "#{artifact_id}-#{version}#{actual_classifier}.#{type}"
-        end
-
-        #           # Constructs the full URL by combining the repository URL, group path, and artifact ID
-        #
-        # Example:
-        #   repository_url: https://repo.maven.apache.org/maven2
-        #   group_path: com/google/guava
-        #   artifact_id: guava
-        #   returns: https://repo.maven.apache.org/maven2/com/google/guava/guava
-        sig { params(repository_url: String).returns(String) }
-        def dependency_base_url(repository_url)
-          group_path, artifact_id = dependency_parts
-
-          "#{repository_url}/#{group_path}/#{artifact_id}"
-        end
-
-        # Splits the dependency name into its group path and artifact ID.
-        #
-        # Example:
-        #   dependency.name: com.google.guava:guava
-        #   returns: ["com/google/guava", "guava"]
-        sig { returns(T.nilable([String, String])) }
-        def dependency_parts
-          return @dependency_parts if @dependency_parts
-
-          group_id, artifact_id = dependency.name.split(":")
-          group_path = group_id&.tr(".", "/")
-          @dependency_parts = [T.must(group_path), T.must(artifact_id)]
-          @dependency_parts
-        end
-
-        sig { returns(T::Array[T.untyped]) }
-        def credentials_repository_details
-          credentials
-            .select { |cred| cred["type"] == REPOSITORY_TYPE && cred[URL_KEY] }
-            .map do |cred|
-              url_value = cred.fetch(URL_KEY).gsub(%r{/+$}, "")
-              {
-                URL_KEY => url_value,
-                AUTH_HEADERS_KEY => auth_headers(url_value)
-              }
-            end
-        end
-
-        sig { returns(T.class_of(Dependabot::Version)) }
-        def version_class
-          dependency.version_class
-        end
-
-        sig { returns(T::Array[String]) }
-        def central_repo_urls
-          central_url_without_protocol = repository_finder.central_repo_url.gsub(%r{^.*://}, "")
-
-          %w(http:// https://).map { |p| p + central_url_without_protocol }
-        end
-
-        sig { returns(Utils::AuthHeadersFinder) }
-        def auth_headers_finder
-          return @auth_headers_finder if @auth_headers_finder
-
-          @auth_headers_finder = Utils::AuthHeadersFinder.new(credentials)
-          @auth_headers_finder
-        end
-
-        sig { params(maven_repo_url: String).returns(T::Hash[String, String]) }
-        def auth_headers(maven_repo_url)
-          auth_headers_finder.auth_headers(maven_repo_url)
-        end
-
-        sig do
-          params(
-            url: String,
-            error: Excon::Error,
-            response: T.nilable(Excon::Response)
-          ).void
-        end
-        def handle_registry_error(url, error, response)
-          return unless central_repo_urls.include?(url)
-
-          response_status = response&.status || 0
-          response_body = if response
-                            "RegistryError: #{response.status} response status with body #{response.body}"
-                          else
-                            "RegistryError: #{error.message}"
-                          end
-
-          raise RegistryError.new(response_status, response_body)
-        end
       end
     end
   end
diff --git a/maven/lib/dependabot/maven/shared/shared_maven_repository_client.rb b/maven/lib/dependabot/maven/shared/shared_maven_repository_client.rb
new file mode 100644
index 00000000000..895e219e4b6
--- /dev/null
+++ b/maven/lib/dependabot/maven/shared/shared_maven_repository_client.rb
@@ -0,0 +1,339 @@
+# typed: strict
+# frozen_string_literal: true
+
+require "time"
+require "excon"
+require "nokogiri"
+require "sorbet-runtime"
+require "dependabot/registry_client"
+require "dependabot/maven/utils/auth_headers_finder"
+
+module Dependabot
+  module Maven
+    module Shared
+      # Shared logic for interacting with Maven repositories.
+      #
+      # Provides URL construction, maven-metadata.xml fetching/parsing,
+      # HTML directory listing for release dates, auth header handling,
+      # forbidden URL tracking, and artifact existence checks.
+      #
+      # Used by Maven::Package::PackageDetailsFetcher and intended for
+      # reuse by SBT and other JVM ecosystems that resolve from Maven repos.
+      #
+      # Subclasses must provide:
+      #   - `dependency` (Dependabot::Dependency)
+      #   - `credentials` (Array[Dependabot::Credential])
+      #   - `repositories` (Array[Hash]) — list of { "url" => ..., "auth_headers" => ... }
+      #
+      # Subclasses may override:
+      #   - `dependency_parts` — for ecosystems with non-standard naming (e.g., Gradle plugins)
+      #   - `central_repo_url` — to customize the default Maven Central URL
+      class SharedMavenRepositoryClient
+        extend T::Sig
+        extend T::Helpers
+
+        abstract!
+
+        MAVEN_METADATA_XML = T.let("maven-metadata.xml", String)
+        REPOSITORY_TYPE = T.let("maven_repository", String)
+        URL_KEY = T.let("url", String)
+        AUTH_HEADERS_KEY = T.let("auth_headers", String)
+        DEFAULT_CENTRAL_REPO_URL = T.let("https://repo.maven.apache.org/maven2", String)
+
+        sig { abstract.returns(Dependabot::Dependency) }
+        def dependency; end
+
+        sig { abstract.returns(T::Array[Dependabot::Credential]) }
+        def credentials; end
+
+        # Subclasses must define how repositories are assembled.
+        # Typically: credentials_repository_details + ecosystem-specific repos.
+        sig { abstract.returns(T::Array[T::Hash[String, T.untyped]]) }
+        def repositories; end
+
+        # -- URL Construction --
+
+        # Splits the dependency name (group_id:artifact_id) into [group_path, artifact_id].
+        #
+        # Example:
+        #   "com.google.guava:guava" → ["com/google/guava", "guava"]
+        sig { returns([String, String]) }
+        def dependency_parts
+          @dependency_parts = T.let(@dependency_parts, T.nilable([String, String]))
+          return @dependency_parts if @dependency_parts
+
+          group_id, artifact_id = dependency.name.split(":")
+          group_path = T.must(group_id).tr(".", "/")
+          @dependency_parts = [group_path, T.must(artifact_id)]
+        end
+
+        # Base URL for a dependency: repo_url/group_path/artifact_id
+        #
+        # Example:
+        #   "https://repo.maven.apache.org/maven2/com/google/guava/guava"
+        sig { params(repository_url: String).returns(String) }
+        def dependency_base_url(repository_url)
+          group_path, artifact_id = dependency_parts
+          "#{repository_url}/#{group_path}/#{artifact_id}"
+        end
+
+        # URL for maven-metadata.xml
+        sig { params(repository_url: String).returns(String) }
+        def dependency_metadata_url(repository_url)
+          "#{dependency_base_url(repository_url)}/#{MAVEN_METADATA_XML}"
+        end
+
+        # URL for a specific artifact file (JAR/POM).
+        #
+        # Example:
+        #   "https://repo.maven.apache.org/maven2/com/google/guava/guava/23.6-jre/guava-23.6-jre.jar"
+        sig { params(repository_url: String, version: Dependabot::Version).returns(String) }
+        def dependency_files_url(repository_url, version)
+          _, artifact_id = dependency_parts
+          base_url = dependency_base_url(repository_url)
+          type = dependency.requirements.first&.dig(:metadata, :packaging_type)
+          classifier = dependency.requirements.first&.dig(:metadata, :classifier)
+          actual_classifier = classifier.nil? ? "" : "-#{classifier}"
+
+          "#{base_url}/#{version}/#{artifact_id}-#{version}#{actual_classifier}.#{type}"
+        end
+
+        # -- Metadata Fetching (XML) --
+
+        # Fetches and parses maven-metadata.xml from a repository.
+        sig { params(repository_details: T::Hash[String, T.untyped]).returns(T.nilable(Nokogiri::XML::Document)) }
+        def fetch_dependency_metadata(repository_details)
+          url = repository_details.fetch(URL_KEY)
+          headers = repository_details.fetch(AUTH_HEADERS_KEY)
+          response = Dependabot::RegistryClient.get(
+            url: dependency_metadata_url(url),
+            headers: headers
+          )
+          check_response(response, url)
+          return unless response.status < 400
+
+          Nokogiri::XML(response.body)
+        rescue URI::InvalidURIError
+          nil
+        rescue Excon::Error::Socket, Excon::Error::Timeout,
+               Excon::Error::TooManyRedirects => e
+          handle_registry_error(url, e, response)
+          nil
+        end
+
+        # Extracts version objects from a parsed maven-metadata.xml document.
+        sig do
+          params(
+            xml: Nokogiri::XML::Document,
+            url: String
+          ).returns(T::Array[T::Hash[Symbol, T.untyped]])
+        end
+        def extract_metadata_from_xml(xml, url)
+          xml.css("versions > version")
+             .select { |node| version_class.correct?(node.content) }
+             .map { |node| version_class.new(node.content) }
+             .map { |version| { version: version, source_url: url } }
+        end
+
+        # -- Metadata Fetching (HTML directory listing) --
+
+        # Fetches an HTML directory listing page from a repository.
+        sig do
+          params(repository_details: T::Hash[String, T.untyped]).returns(T.nilable(Nokogiri::HTML::Document))
+        end
+        def fetch_dependency_metadata_from_html(repository_details)
+          url = repository_details.fetch(URL_KEY)
+          headers = repository_details.fetch(AUTH_HEADERS_KEY)
+          response = Dependabot::RegistryClient.get(
+            url: dependency_base_url(url),
+            headers: headers
+          )
+          check_response(response, url)
+          return unless response.status < 400
+
+          Nokogiri::HTML(response.body)
+        rescue URI::InvalidURIError
+          nil
+        rescue Excon::Error::Socket, Excon::Error::Timeout,
+               Excon::Error::TooManyRedirects => e
+          handle_registry_error(url, e, response)
+          nil
+        end
+
+        # Parses release dates from an HTML directory listing page.
+        sig do
+          params(html_doc: Nokogiri::HTML::Document)
+            .returns(T::Hash[String, T::Hash[Symbol, T.untyped]])
+        end
+        def extract_version_details_from_html(html_doc)
+          versions_detail_hash = T.let({}, T::Hash[String, T::Hash[Symbol, T.untyped]])
+
+          html_doc.css("a[title]").each do |link|
+            version_string = link["title"]
+            version = version_string.gsub(%r{/$}, "")
+
+            raw_date_text = link.next.text.strip.split("\n").last.strip
+
+            release_date = begin
+              Time.parse(raw_date_text)
+            rescue StandardError
+              nil
+            end
+
+            next unless version && version_class.correct?(version)
+
+            versions_detail_hash[version] = { release_date: release_date }
+          end
+
+          versions_detail_hash
+        end
+
+        # -- Response Checking & Error Handling --
+
+        # Tracks forbidden URLs when receiving 401/403 responses (except for central repo).
+        sig { params(response: Excon::Response, repository_url: String).void }
+        def check_response(response, repository_url)
+          return unless [401, 403].include?(response.status)
+          return if forbidden_urls.include?(repository_url)
+          return if central_repo_urls.include?(repository_url)
+
+          forbidden_urls << repository_url
+        end
+
+        # Raises RegistryError for failures hitting the central repo.
+        sig do
+          params(
+            url: String,
+            error: Excon::Error,
+            response: T.nilable(Excon::Response)
+          ).void
+        end
+        def handle_registry_error(url, error, response)
+          return unless central_repo_urls.include?(url)
+
+          response_status = response&.status || 0
+          response_body = if response
+                            "RegistryError: #{response.status} response status with body #{response.body}"
+                          else
+                            "RegistryError: #{error.message}"
+                          end
+
+          raise RegistryError.new(response_status, response_body)
+        end
+
+        # -- Release Check --
+
+        # Checks whether a specific version of the dependency has been published
+        # by issuing HEAD requests to each repository.
+        sig { params(version: Dependabot::Version).returns(T::Boolean) }
+        def released?(version)
+          @released_check = T.let(@released_check, T.nilable(T::Hash[Dependabot::Version, T::Boolean]))
+          @released_check ||= {}
+          @released_check[version] ||=
+            repositories.any? do |repository_details|
+              url = repository_details.fetch(URL_KEY)
+              headers = repository_details.fetch(AUTH_HEADERS_KEY)
+              response = Dependabot::RegistryClient.head(
+                url: dependency_files_url(url, version),
+                headers: headers
+              )
+              response.status < 400
+            rescue Excon::Error::Socket, Excon::Error::Timeout,
+                   Excon::Error::TooManyRedirects
+              false
+            rescue URI::InvalidURIError => e
+              raise DependencyFileNotResolvable, e.message
+            end
+        end
+
+        # -- Credential & Repository Helpers --
+
+        # Builds repository details from credentials of type "maven_repository".
+        sig { returns(T::Array[T::Hash[String, T.untyped]]) }
+        def credentials_repository_details
+          credentials
+            .select { |cred| cred["type"] == REPOSITORY_TYPE && cred[URL_KEY] }
+            .map do |cred|
+              url_value = cred.fetch(URL_KEY).gsub(%r{/+$}, "")
+              {
+                URL_KEY => url_value,
+                AUTH_HEADERS_KEY => auth_headers(url_value)
+              }
+            end
+        end
+
+        # The default central repo URL. Subclasses may override (e.g., if credentials
+        # provide a replacement base repo).
+        sig { returns(String) }
+        def central_repo_url
+          DEFAULT_CENTRAL_REPO_URL
+        end
+
+        # Both HTTP and HTTPS variants of the central repo URL, for comparison.
+        sig { returns(T::Array[String]) }
+        def central_repo_urls
+          central_url_without_protocol = central_repo_url.gsub(%r{^.*://}, "")
+          %w(http:// https://).map { |p| p + central_url_without_protocol }
+        end
+
+        sig { returns(T::Array[String]) }
+        def forbidden_urls
+          @forbidden_urls = T.let(@forbidden_urls, T.nilable(T::Array[String]))
+          @forbidden_urls ||= []
+        end
+
+        # -- Auth --
+
+        sig { params(maven_repo_url: String).returns(T::Hash[String, String]) }
+        def auth_headers(maven_repo_url)
+          auth_headers_finder.auth_headers(maven_repo_url)
+        end
+
+        sig { returns(Utils::AuthHeadersFinder) }
+        def auth_headers_finder
+          @auth_headers_finder = T.let(@auth_headers_finder, T.nilable(Utils::AuthHeadersFinder))
+          @auth_headers_finder ||= Utils::AuthHeadersFinder.new(credentials)
+        end
+
+        # -- Metadata Caching --
+
+        # Fetches and caches XML metadata per repository.
+        sig { params(repository_details: T::Hash[String, T.untyped]).returns(T.nilable(Nokogiri::XML::Document)) }
+        def dependency_metadata(repository_details)
+          @dependency_metadata = T.let(
+            @dependency_metadata, T.nilable(T::Hash[T.untyped, Nokogiri::XML::Document])
+          )
+          @dependency_metadata ||= {}
+          repository_key = repository_details.hash
+          return @dependency_metadata[repository_key] if @dependency_metadata.key?(repository_key)
+
+          xml_document = fetch_dependency_metadata(repository_details)
+          @dependency_metadata[repository_key] ||= xml_document if xml_document
+          @dependency_metadata[repository_key]
+        end
+
+        # Fetches and caches HTML metadata per repository.
+        sig { params(repository_details: T::Hash[String, T.untyped]).returns(T.nilable(Nokogiri::HTML::Document)) }
+        def dependency_metadata_from_html(repository_details)
+          @dependency_metadata_from_html = T.let(
+            @dependency_metadata_from_html, T.nilable(T::Hash[T.untyped, Nokogiri::HTML::Document])
+          )
+          @dependency_metadata_from_html ||= {}
+          repository_key = repository_details.hash
+          return @dependency_metadata_from_html[repository_key] if @dependency_metadata_from_html.key?(repository_key)
+
+          html_document = fetch_dependency_metadata_from_html(repository_details)
+          @dependency_metadata_from_html[repository_key] ||= html_document if html_document
+          @dependency_metadata_from_html[repository_key]
+        end
+
+        # -- Version Class --
+
+        sig { returns(T.class_of(Dependabot::Version)) }
+        def version_class
+          dependency.version_class
+        end
+      end
+    end
+  end
+end

From 5409bb5112debcfb8573b0c93d807444c979d895 Mon Sep 17 00:00:00 2001
From: Abhishek Bhaskar 
Date: Wed, 15 Apr 2026 12:45:45 -0500
Subject: [PATCH 2/5] add specs for shared maven repository client file

---
 .../maven/package/package_details_fetcher.rb  |   5 -
 .../shared_maven_repository_client_spec.rb    | 600 ++++++++++++++++++
 2 files changed, 600 insertions(+), 5 deletions(-)
 create mode 100644 maven/spec/dependabot/maven/shared/shared_maven_repository_client_spec.rb

diff --git a/maven/lib/dependabot/maven/package/package_details_fetcher.rb b/maven/lib/dependabot/maven/package/package_details_fetcher.rb
index 5570bebee89..bed737f6e5c 100644
--- a/maven/lib/dependabot/maven/package/package_details_fetcher.rb
+++ b/maven/lib/dependabot/maven/package/package_details_fetcher.rb
@@ -72,11 +72,6 @@ def releases
           fetch.releases
         end
 
-        sig { params(version: Dependabot::Version).returns(T::Boolean) }
-        def released?(version)
-          super
-        end
-
         # Assembles the list of Maven repositories to search: credential repos + POM repos.
         sig { override.returns(T::Array[T::Hash[String, T.untyped]]) }
         def repositories
diff --git a/maven/spec/dependabot/maven/shared/shared_maven_repository_client_spec.rb b/maven/spec/dependabot/maven/shared/shared_maven_repository_client_spec.rb
new file mode 100644
index 00000000000..9f7cc0bc181
--- /dev/null
+++ b/maven/spec/dependabot/maven/shared/shared_maven_repository_client_spec.rb
@@ -0,0 +1,600 @@
+# typed: false
+# frozen_string_literal: true
+
+require "spec_helper"
+require "dependabot/credential"
+require "dependabot/dependency"
+require "dependabot/maven/shared/shared_maven_repository_client"
+require "dependabot/maven/version"
+
+# Concrete test subclass that implements the abstract methods.
+class TestMavenRepositoryClient < Dependabot::Maven::Shared::SharedMavenRepositoryClient
+  attr_reader :dependency
+  attr_reader :credentials
+
+  def initialize(dependency:, credentials:, repositories:)
+    @dependency = dependency
+    @credentials = credentials
+    @test_repositories = repositories
+  end
+
+  def repositories
+    @test_repositories
+  end
+end
+
+RSpec.describe Dependabot::Maven::Shared::SharedMavenRepositoryClient do
+  subject(:client) do
+    TestMavenRepositoryClient.new(
+      dependency: dependency,
+      credentials: credentials,
+      repositories: repositories
+    )
+  end
+
+  let(:dependency_name) { "com.google.guava:guava" }
+  let(:dependency_version) { "23.3-jre" }
+  let(:dependency) do
+    Dependabot::Dependency.new(
+      name: dependency_name,
+      version: dependency_version,
+      requirements: [{
+        requirement: "23.3-jre",
+        file: "pom.xml",
+        groups: ["dependencies"],
+        source: nil,
+        metadata: { packaging_type: "jar" }
+      }],
+      package_manager: "maven"
+    )
+  end
+  let(:credentials) { [] }
+  let(:maven_central) { "https://repo.maven.apache.org/maven2" }
+  let(:repositories) do
+    [{ "url" => maven_central, "auth_headers" => {} }]
+  end
+
+  describe "#dependency_parts" do
+    it "splits the dependency name into group path and artifact ID" do
+      group_path, artifact_id = client.dependency_parts
+
+      expect(group_path).to eq("com/google/guava")
+      expect(artifact_id).to eq("guava")
+    end
+
+    context "with a deeply nested group ID" do
+      let(:dependency_name) { "org.apache.commons:commons-lang3" }
+
+      it "converts dots to slashes in the group path" do
+        group_path, artifact_id = client.dependency_parts
+
+        expect(group_path).to eq("org/apache/commons")
+        expect(artifact_id).to eq("commons-lang3")
+      end
+    end
+
+    it "caches the result" do
+      first_result = client.dependency_parts
+      second_result = client.dependency_parts
+
+      expect(first_result).to equal(second_result)
+    end
+  end
+
+  describe "#dependency_base_url" do
+    it "constructs the base URL from repo URL, group path, and artifact ID" do
+      url = client.dependency_base_url(maven_central)
+
+      expect(url).to eq("#{maven_central}/com/google/guava/guava")
+    end
+  end
+
+  describe "#dependency_metadata_url" do
+    it "appends maven-metadata.xml to the base URL" do
+      url = client.dependency_metadata_url(maven_central)
+
+      expect(url).to eq("#{maven_central}/com/google/guava/guava/maven-metadata.xml")
+    end
+  end
+
+  describe "#dependency_files_url" do
+    let(:version) { Dependabot::Maven::Version.new("23.6-jre") }
+
+    it "constructs the artifact file URL" do
+      url = client.dependency_files_url(maven_central, version)
+
+      expect(url).to eq("#{maven_central}/com/google/guava/guava/23.6-jre/guava-23.6-jre.jar")
+    end
+
+    context "with a classifier" do
+      let(:dependency) do
+        Dependabot::Dependency.new(
+          name: dependency_name,
+          version: dependency_version,
+          requirements: [{
+            requirement: "23.3-jre",
+            file: "pom.xml",
+            groups: ["dependencies"],
+            source: nil,
+            metadata: { packaging_type: "jar", classifier: "sources" }
+          }],
+          package_manager: "maven"
+        )
+      end
+
+      it "includes the classifier in the URL" do
+        url = client.dependency_files_url(maven_central, version)
+
+        expect(url).to eq("#{maven_central}/com/google/guava/guava/23.6-jre/guava-23.6-jre-sources.jar")
+      end
+    end
+  end
+
+  describe "#extract_metadata_from_xml" do
+    let(:xml_body) do
+      <<~XML
+        
+          
+            
+              23.0
+              23.3-jre
+              23.6-jre
+              not-a-version!
+            
+          
+        
+      XML
+    end
+    let(:xml) { Nokogiri::XML(xml_body) }
+
+    it "extracts valid versions from the XML document" do
+      results = client.extract_metadata_from_xml(xml, maven_central)
+
+      versions = results.map { |r| r[:version].to_s }
+      expect(versions).to contain_exactly("23.0", "23.3-jre", "23.6-jre")
+    end
+
+    it "includes the source URL for each version" do
+      results = client.extract_metadata_from_xml(xml, maven_central)
+
+      results.each do |result|
+        expect(result[:source_url]).to eq(maven_central)
+      end
+    end
+
+    it "returns Version objects" do
+      results = client.extract_metadata_from_xml(xml, maven_central)
+
+      results.each do |result|
+        expect(result[:version]).to be_a(Dependabot::Maven::Version)
+      end
+    end
+
+    context "with an empty versions list" do
+      let(:xml_body) do
+        <<~XML
+          
+            
+              
+            
+          
+        XML
+      end
+
+      it "returns an empty array" do
+        results = client.extract_metadata_from_xml(xml, maven_central)
+
+        expect(results).to eq([])
+      end
+    end
+  end
+
+  describe "#extract_version_details_from_html" do
+    let(:html_body) do
+      <<~HTML
+        
+        ../
+        23.0/                  2017-08-04 12:00         -
+        23.3-jre/      2017-09-27 14:30         -
+        23.6-jre/      2017-11-22 16:45         -
+        
+ HTML + end + let(:html) { Nokogiri::HTML(html_body) } + + it "extracts version strings and release dates from the HTML listing" do + results = client.extract_version_details_from_html(html) + + expect(results.keys).to contain_exactly("23.0", "23.3-jre", "23.6-jre") + end + + it "parses release dates" do + results = client.extract_version_details_from_html(html) + + expect(results["23.0"][:release_date]).to be_a(Time) + expect(results["23.6-jre"][:release_date]).to be_a(Time) + end + + context "with unparseable dates" do + let(:html_body) do + <<~HTML +
+          1.0/                  not-a-date         -
+          
+ HTML + end + + it "sets release_date to nil" do + results = client.extract_version_details_from_html(html) + + expect(results["1.0"][:release_date]).to be_nil + end + end + + context "with invalid version strings" do + let(:html_body) do + <<~HTML +
+          23.0/                    2017-08-04 12:00         -
+          
+ HTML + end + + it "only includes versions that pass version_class.correct?" do + results = client.extract_version_details_from_html(html) + + expect(results).to have_key("23.0") + end + end + end + + describe "#check_response" do + let(:repository_url) { "https://private.repo.example.com/maven2" } + + context "when the response status is 200" do + let(:response) { instance_double(Excon::Response, status: 200) } + + it "does not add to forbidden URLs" do + client.check_response(response, repository_url) + + expect(client.forbidden_urls).to be_empty + end + end + + context "when the response status is 401" do + let(:response) { instance_double(Excon::Response, status: 401) } + + it "adds the URL to forbidden URLs" do + client.check_response(response, repository_url) + + expect(client.forbidden_urls).to include(repository_url) + end + + it "does not add duplicates" do + client.check_response(response, repository_url) + client.check_response(response, repository_url) + + expect(client.forbidden_urls.count(repository_url)).to eq(1) + end + end + + context "when the response status is 403" do + let(:response) { instance_double(Excon::Response, status: 403) } + + it "adds the URL to forbidden URLs" do + client.check_response(response, repository_url) + + expect(client.forbidden_urls).to include(repository_url) + end + end + + context "when the URL is the central repo" do + let(:response) { instance_double(Excon::Response, status: 401) } + let(:repository_url) { "https://repo.maven.apache.org/maven2" } + + it "does not add central repo to forbidden URLs" do + client.check_response(response, repository_url) + + expect(client.forbidden_urls).to be_empty + end + end + end + + describe "#handle_registry_error" do + context "when the URL is not the central repo" do + let(:url) { "https://private.repo.example.com/maven2" } + let(:error) { Excon::Error::Timeout.new("timeout") } + + it "does not raise" do + expect { client.handle_registry_error(url, error, nil) }.not_to raise_error + end + end + + context "when the URL is the central repo" do + let(:url) { "https://repo.maven.apache.org/maven2" } + let(:error) { Excon::Error::Timeout.new("connection timed out") } + + it "raises a RegistryError with the error message" do + expect { client.handle_registry_error(url, error, nil) } + .to raise_error(Dependabot::RegistryError) + end + + context "with a response object" do + let(:response) { instance_double(Excon::Response, status: 503, body: "Service Unavailable") } + + it "raises a RegistryError with response details" do + expect { client.handle_registry_error(url, error, response) } + .to raise_error(Dependabot::RegistryError) { |e| + expect(e.status).to eq(503) + } + end + end + end + end + + describe "#fetch_dependency_metadata" do + let(:metadata_url) { "#{maven_central}/com/google/guava/guava/maven-metadata.xml" } + let(:repository_details) { { "url" => maven_central, "auth_headers" => {} } } + + context "when the registry returns a valid XML response" do + before do + stub_request(:get, metadata_url) + .to_return(status: 200, body: fixture("maven_central_metadata", "with_release.xml")) + end + + it "returns a parsed Nokogiri XML document" do + result = client.fetch_dependency_metadata(repository_details) + + expect(result).to be_a(Nokogiri::XML::Document) + expect(result.css("versions > version").count).to be > 0 + end + end + + context "when the registry returns a 404" do + before do + stub_request(:get, metadata_url).to_return(status: 404) + end + + it "returns nil" do + result = client.fetch_dependency_metadata(repository_details) + + expect(result).to be_nil + end + end + + context "when the request times out" do + before do + stub_request(:get, metadata_url).to_raise(Excon::Error::Timeout) + end + + it "returns nil for non-central repos" do + non_central_details = { "url" => "https://private.repo.example.com", "auth_headers" => {} } + # Need to stub the non-central URL too + stub_request(:get, "https://private.repo.example.com/com/google/guava/guava/maven-metadata.xml") + .to_raise(Excon::Error::Timeout) + + result = client.fetch_dependency_metadata(non_central_details) + + expect(result).to be_nil + end + end + + context "when the URI is invalid" do + let(:repository_details) { { "url" => "ht!tp://bad url", "auth_headers" => {} } } + + before do + stub_request(:get, /bad%20url/).to_raise(URI::InvalidURIError) + end + + it "returns nil" do + result = client.fetch_dependency_metadata(repository_details) + + expect(result).to be_nil + end + end + end + + describe "#fetch_dependency_metadata_from_html" do + let(:base_url) { "#{maven_central}/com/google/guava/guava" } + let(:repository_details) { { "url" => maven_central, "auth_headers" => {} } } + + context "when the registry returns a valid HTML response" do + before do + stub_request(:get, base_url) + .to_return(status: 200, body: fixture("maven_central_metadata", "with_release.html")) + end + + it "returns a parsed Nokogiri HTML document" do + result = client.fetch_dependency_metadata_from_html(repository_details) + + expect(result).to be_a(Nokogiri::HTML::Document) + end + end + + context "when the registry returns a 404" do + before do + stub_request(:get, base_url).to_return(status: 404) + end + + it "returns nil" do + result = client.fetch_dependency_metadata_from_html(repository_details) + + expect(result).to be_nil + end + end + end + + describe "#released?" do + let(:version) { Dependabot::Maven::Version.new("23.6-jre") } + let(:artifact_url) { "#{maven_central}/com/google/guava/guava/23.6-jre/guava-23.6-jre.jar" } + + context "when the artifact exists" do + before do + stub_request(:head, artifact_url).to_return(status: 200) + end + + it "returns true" do + expect(client.released?(version)).to be(true) + end + end + + context "when the artifact does not exist" do + before do + stub_request(:head, artifact_url).to_return(status: 404) + end + + it "returns false" do + expect(client.released?(version)).to be(false) + end + end + + context "when the request times out" do + before do + stub_request(:head, artifact_url).to_raise(Excon::Error::Timeout) + end + + it "returns false" do + expect(client.released?(version)).to be(false) + end + end + + context "when the result is cached" do + before do + stub_request(:head, artifact_url).to_return(status: 200) + end + + it "returns the cached result on subsequent calls" do + first_result = client.released?(version) + # Remove the stub — if it hits the network again, it would fail + WebMock.reset! + second_result = client.released?(version) + + expect(first_result).to eq(second_result) + end + end + + context "with multiple repositories" do + let(:private_repo) { "https://private.repo.example.com/maven2" } + let(:private_artifact_url) { "#{private_repo}/com/google/guava/guava/23.6-jre/guava-23.6-jre.jar" } + let(:repositories) do + [ + { "url" => private_repo, "auth_headers" => {} }, + { "url" => maven_central, "auth_headers" => {} } + ] + end + + before do + stub_request(:head, private_artifact_url).to_return(status: 404) + stub_request(:head, artifact_url).to_return(status: 200) + end + + it "returns true if any repository has the artifact" do + expect(client.released?(version)).to be(true) + end + end + end + + describe "#credentials_repository_details" do + let(:credentials) do + [ + Dependabot::Credential.new({ "type" => "maven_repository", "url" => "https://repo.example.com/maven2/" }), + Dependabot::Credential.new({ "type" => "git_source", "host" => "github.com" }), + Dependabot::Credential.new({ "type" => "maven_repository", "url" => "https://repo2.example.com/maven2" }) + ] + end + + it "returns only maven_repository credentials" do + result = client.credentials_repository_details + + expect(result.length).to eq(2) + end + + it "strips trailing slashes from URLs" do + result = client.credentials_repository_details + + urls = result.map { |r| r["url"] } + expect(urls).to include("https://repo.example.com/maven2") + expect(urls).not_to include("https://repo.example.com/maven2/") + end + + it "includes auth headers for each repository" do + result = client.credentials_repository_details + + expect(result).to all(have_key("auth_headers")) + end + end + + describe "#central_repo_url" do + it "returns the default Maven Central URL" do + expect(client.central_repo_url).to eq("https://repo.maven.apache.org/maven2") + end + end + + describe "#central_repo_urls" do + it "returns both HTTP and HTTPS variants" do + urls = client.central_repo_urls + + expect(urls).to contain_exactly( + "http://repo.maven.apache.org/maven2", + "https://repo.maven.apache.org/maven2" + ) + end + end + + describe "#dependency_metadata" do + let(:metadata_url) { "#{maven_central}/com/google/guava/guava/maven-metadata.xml" } + let(:repository_details) { { "url" => maven_central, "auth_headers" => {} } } + + before do + stub_request(:get, metadata_url) + .to_return(status: 200, body: fixture("maven_central_metadata", "with_release.xml")) + end + + it "caches the result per repository" do + first_result = client.dependency_metadata(repository_details) + # Reset stubs — second call should use cache + WebMock.reset! + second_result = client.dependency_metadata(repository_details) + + expect(first_result).to equal(second_result) + end + + it "fetches separately for different repositories" do + other_repo = "https://other.repo.example.com/maven2" + other_metadata_url = "#{other_repo}/com/google/guava/guava/maven-metadata.xml" + other_details = { "url" => other_repo, "auth_headers" => {} } + + stub_request(:get, other_metadata_url) + .to_return(status: 200, body: "1.0") + + result1 = client.dependency_metadata(repository_details) + result2 = client.dependency_metadata(other_details) + + expect(result1).not_to equal(result2) + end + end + + describe "#dependency_metadata_from_html" do + let(:base_url) { "#{maven_central}/com/google/guava/guava" } + let(:repository_details) { { "url" => maven_central, "auth_headers" => {} } } + + before do + stub_request(:get, base_url) + .to_return(status: 200, body: fixture("maven_central_metadata", "with_release.html")) + end + + it "caches the result per repository" do + first_result = client.dependency_metadata_from_html(repository_details) + WebMock.reset! + second_result = client.dependency_metadata_from_html(repository_details) + + expect(first_result).to equal(second_result) + end + end + + describe "#version_class" do + it "delegates to the dependency" do + expect(client.version_class).to eq(Dependabot::Maven::Version) + end + end +end From 8d858e26f622ad2043ca7dcf03cc0ccc52c8a1a6 Mon Sep 17 00:00:00 2001 From: Abhishek Bhaskar Date: Wed, 15 Apr 2026 13:09:22 -0500 Subject: [PATCH 3/5] fix lint errors --- .../maven/shared/shared_maven_repository_client_spec.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/maven/spec/dependabot/maven/shared/shared_maven_repository_client_spec.rb b/maven/spec/dependabot/maven/shared/shared_maven_repository_client_spec.rb index 9f7cc0bc181..4a90859ff08 100644 --- a/maven/spec/dependabot/maven/shared/shared_maven_repository_client_spec.rb +++ b/maven/spec/dependabot/maven/shared/shared_maven_repository_client_spec.rb @@ -564,8 +564,11 @@ def repositories other_metadata_url = "#{other_repo}/com/google/guava/guava/maven-metadata.xml" other_details = { "url" => other_repo, "auth_headers" => {} } + other_body = "" \ + "1.0" \ + "" stub_request(:get, other_metadata_url) - .to_return(status: 200, body: "1.0") + .to_return(status: 200, body: other_body) result1 = client.dependency_metadata(repository_details) result2 = client.dependency_metadata(other_details) From 90627c2d5b5276580efa8108c4df35a670da1a0d Mon Sep 17 00:00:00 2001 From: Abhishek Bhaskar Date: Wed, 15 Apr 2026 13:31:59 -0500 Subject: [PATCH 4/5] fix caching --- .../shared/shared_maven_repository_client.rb | 60 ++----------------- .../shared_maven_repository_client_spec.rb | 39 +++++++++++- 2 files changed, 44 insertions(+), 55 deletions(-) diff --git a/maven/lib/dependabot/maven/shared/shared_maven_repository_client.rb b/maven/lib/dependabot/maven/shared/shared_maven_repository_client.rb index 895e219e4b6..74dbc8d24f1 100644 --- a/maven/lib/dependabot/maven/shared/shared_maven_repository_client.rb +++ b/maven/lib/dependabot/maven/shared/shared_maven_repository_client.rb @@ -11,23 +11,6 @@ module Dependabot module Maven module Shared - # Shared logic for interacting with Maven repositories. - # - # Provides URL construction, maven-metadata.xml fetching/parsing, - # HTML directory listing for release dates, auth header handling, - # forbidden URL tracking, and artifact existence checks. - # - # Used by Maven::Package::PackageDetailsFetcher and intended for - # reuse by SBT and other JVM ecosystems that resolve from Maven repos. - # - # Subclasses must provide: - # - `dependency` (Dependabot::Dependency) - # - `credentials` (Array[Dependabot::Credential]) - # - `repositories` (Array[Hash]) — list of { "url" => ..., "auth_headers" => ... } - # - # Subclasses may override: - # - `dependency_parts` — for ecosystems with non-standard naming (e.g., Gradle plugins) - # - `central_repo_url` — to customize the default Maven Central URL class SharedMavenRepositoryClient extend T::Sig extend T::Helpers @@ -51,8 +34,6 @@ def credentials; end sig { abstract.returns(T::Array[T::Hash[String, T.untyped]]) } def repositories; end - # -- URL Construction -- - # Splits the dependency name (group_id:artifact_id) into [group_path, artifact_id]. # # Example: @@ -68,9 +49,6 @@ def dependency_parts end # Base URL for a dependency: repo_url/group_path/artifact_id - # - # Example: - # "https://repo.maven.apache.org/maven2/com/google/guava/guava" sig { params(repository_url: String).returns(String) } def dependency_base_url(repository_url) group_path, artifact_id = dependency_parts @@ -84,22 +62,17 @@ def dependency_metadata_url(repository_url) end # URL for a specific artifact file (JAR/POM). - # - # Example: - # "https://repo.maven.apache.org/maven2/com/google/guava/guava/23.6-jre/guava-23.6-jre.jar" sig { params(repository_url: String, version: Dependabot::Version).returns(String) } def dependency_files_url(repository_url, version) _, artifact_id = dependency_parts base_url = dependency_base_url(repository_url) - type = dependency.requirements.first&.dig(:metadata, :packaging_type) + type = dependency.requirements.first&.dig(:metadata, :packaging_type) || "jar" classifier = dependency.requirements.first&.dig(:metadata, :classifier) actual_classifier = classifier.nil? ? "" : "-#{classifier}" "#{base_url}/#{version}/#{artifact_id}-#{version}#{actual_classifier}.#{type}" end - # -- Metadata Fetching (XML) -- - # Fetches and parses maven-metadata.xml from a repository. sig { params(repository_details: T::Hash[String, T.untyped]).returns(T.nilable(Nokogiri::XML::Document)) } def fetch_dependency_metadata(repository_details) @@ -135,9 +108,6 @@ def extract_metadata_from_xml(xml, url) .map { |version| { version: version, source_url: url } } end - # -- Metadata Fetching (HTML directory listing) -- - - # Fetches an HTML directory listing page from a repository. sig do params(repository_details: T::Hash[String, T.untyped]).returns(T.nilable(Nokogiri::HTML::Document)) end @@ -160,7 +130,6 @@ def fetch_dependency_metadata_from_html(repository_details) nil end - # Parses release dates from an HTML directory listing page. sig do params(html_doc: Nokogiri::HTML::Document) .returns(T::Hash[String, T::Hash[Symbol, T.untyped]]) @@ -188,9 +157,6 @@ def extract_version_details_from_html(html_doc) versions_detail_hash end - # -- Response Checking & Error Handling -- - - # Tracks forbidden URLs when receiving 401/403 responses (except for central repo). sig { params(response: Excon::Response, repository_url: String).void } def check_response(response, repository_url) return unless [401, 403].include?(response.status) @@ -200,7 +166,6 @@ def check_response(response, repository_url) forbidden_urls << repository_url end - # Raises RegistryError for failures hitting the central repo. sig do params( url: String, @@ -221,15 +186,15 @@ def handle_registry_error(url, error, response) raise RegistryError.new(response_status, response_body) end - # -- Release Check -- - # Checks whether a specific version of the dependency has been published # by issuing HEAD requests to each repository. sig { params(version: Dependabot::Version).returns(T::Boolean) } def released?(version) @released_check = T.let(@released_check, T.nilable(T::Hash[Dependabot::Version, T::Boolean])) @released_check ||= {} - @released_check[version] ||= + return T.must(@released_check[version]) if @released_check.key?(version) + + @released_check[version] = repositories.any? do |repository_details| url = repository_details.fetch(URL_KEY) headers = repository_details.fetch(AUTH_HEADERS_KEY) @@ -246,9 +211,6 @@ def released?(version) end end - # -- Credential & Repository Helpers -- - - # Builds repository details from credentials of type "maven_repository". sig { returns(T::Array[T::Hash[String, T.untyped]]) } def credentials_repository_details credentials @@ -262,8 +224,6 @@ def credentials_repository_details end end - # The default central repo URL. Subclasses may override (e.g., if credentials - # provide a replacement base repo). sig { returns(String) } def central_repo_url DEFAULT_CENTRAL_REPO_URL @@ -278,12 +238,9 @@ def central_repo_urls sig { returns(T::Array[String]) } def forbidden_urls - @forbidden_urls = T.let(@forbidden_urls, T.nilable(T::Array[String])) - @forbidden_urls ||= [] + @forbidden_urls ||= T.let([], T.nilable(T::Array[String])) end - # -- Auth -- - sig { params(maven_repo_url: String).returns(T::Hash[String, String]) } def auth_headers(maven_repo_url) auth_headers_finder.auth_headers(maven_repo_url) @@ -291,12 +248,9 @@ def auth_headers(maven_repo_url) sig { returns(Utils::AuthHeadersFinder) } def auth_headers_finder - @auth_headers_finder = T.let(@auth_headers_finder, T.nilable(Utils::AuthHeadersFinder)) - @auth_headers_finder ||= Utils::AuthHeadersFinder.new(credentials) + @auth_headers_finder ||= T.let(Utils::AuthHeadersFinder.new(credentials), T.nilable(Utils::AuthHeadersFinder)) end - # -- Metadata Caching -- - # Fetches and caches XML metadata per repository. sig { params(repository_details: T::Hash[String, T.untyped]).returns(T.nilable(Nokogiri::XML::Document)) } def dependency_metadata(repository_details) @@ -327,8 +281,6 @@ def dependency_metadata_from_html(repository_details) @dependency_metadata_from_html[repository_key] end - # -- Version Class -- - sig { returns(T.class_of(Dependabot::Version)) } def version_class dependency.version_class diff --git a/maven/spec/dependabot/maven/shared/shared_maven_repository_client_spec.rb b/maven/spec/dependabot/maven/shared/shared_maven_repository_client_spec.rb index 4a90859ff08..5ce10a421d1 100644 --- a/maven/spec/dependabot/maven/shared/shared_maven_repository_client_spec.rb +++ b/maven/spec/dependabot/maven/shared/shared_maven_repository_client_spec.rb @@ -7,7 +7,6 @@ require "dependabot/maven/shared/shared_maven_repository_client" require "dependabot/maven/version" -# Concrete test subclass that implements the abstract methods. class TestMavenRepositoryClient < Dependabot::Maven::Shared::SharedMavenRepositoryClient attr_reader :dependency attr_reader :credentials @@ -128,6 +127,29 @@ def repositories expect(url).to eq("#{maven_central}/com/google/guava/guava/23.6-jre/guava-23.6-jre-sources.jar") end end + + context "without packaging_type metadata" do + let(:dependency) do + Dependabot::Dependency.new( + name: dependency_name, + version: dependency_version, + requirements: [{ + requirement: "23.3-jre", + file: "pom.xml", + groups: ["dependencies"], + source: nil, + metadata: {} + }], + package_manager: "maven" + ) + end + + it "defaults to jar" do + url = client.dependency_files_url(maven_central, version) + + expect(url).to eq("#{maven_central}/com/google/guava/guava/23.6-jre/guava-23.6-jre.jar") + end + end end describe "#extract_metadata_from_xml" do @@ -236,6 +258,7 @@ def repositories <<~HTML
           23.0/                    2017-08-04 12:00         -
+          not-a-version!/  2017-09-01 10:00         -
           
HTML end @@ -244,6 +267,7 @@ def repositories results = client.extract_version_details_from_html(html) expect(results).to have_key("23.0") + expect(results).not_to have_key("not-a-version!") end end end @@ -473,6 +497,19 @@ def repositories end end + context "when the result is false" do + before do + stub_request(:head, artifact_url).to_return(status: 404) + end + + it "caches false results without re-requesting" do + expect(client.released?(version)).to be(false) + # Remove the stub — second call should use cache, not network + WebMock.reset! + expect(client.released?(version)).to be(false) + end + end + context "with multiple repositories" do let(:private_repo) { "https://private.repo.example.com/maven2" } let(:private_artifact_url) { "#{private_repo}/com/google/guava/guava/23.6-jre/guava-23.6-jre.jar" } From cdaf18a159af5fb065ba619a38d6314b0aac922a Mon Sep 17 00:00:00 2001 From: Abhishek Bhaskar Date: Wed, 15 Apr 2026 15:11:49 -0500 Subject: [PATCH 5/5] revert removing important comments --- .../shared/shared_maven_repository_client.rb | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/maven/lib/dependabot/maven/shared/shared_maven_repository_client.rb b/maven/lib/dependabot/maven/shared/shared_maven_repository_client.rb index 74dbc8d24f1..923157c5922 100644 --- a/maven/lib/dependabot/maven/shared/shared_maven_repository_client.rb +++ b/maven/lib/dependabot/maven/shared/shared_maven_repository_client.rb @@ -34,6 +34,8 @@ def credentials; end sig { abstract.returns(T::Array[T::Hash[String, T.untyped]]) } def repositories; end + # -- URL Construction -- + # Splits the dependency name (group_id:artifact_id) into [group_path, artifact_id]. # # Example: @@ -49,6 +51,9 @@ def dependency_parts end # Base URL for a dependency: repo_url/group_path/artifact_id + # + # Example: + # "https://repo.maven.apache.org/maven2/com/google/guava/guava" sig { params(repository_url: String).returns(String) } def dependency_base_url(repository_url) group_path, artifact_id = dependency_parts @@ -62,6 +67,9 @@ def dependency_metadata_url(repository_url) end # URL for a specific artifact file (JAR/POM). + # + # Example: + # "https://repo.maven.apache.org/maven2/com/google/guava/guava/23.6-jre/guava-23.6-jre.jar" sig { params(repository_url: String, version: Dependabot::Version).returns(String) } def dependency_files_url(repository_url, version) _, artifact_id = dependency_parts @@ -73,6 +81,8 @@ def dependency_files_url(repository_url, version) "#{base_url}/#{version}/#{artifact_id}-#{version}#{actual_classifier}.#{type}" end + # -- Metadata Fetching (XML) -- + # Fetches and parses maven-metadata.xml from a repository. sig { params(repository_details: T::Hash[String, T.untyped]).returns(T.nilable(Nokogiri::XML::Document)) } def fetch_dependency_metadata(repository_details) @@ -108,6 +118,9 @@ def extract_metadata_from_xml(xml, url) .map { |version| { version: version, source_url: url } } end + # -- Metadata Fetching (HTML directory listing) -- + + # Fetches an HTML directory listing page from a repository. sig do params(repository_details: T::Hash[String, T.untyped]).returns(T.nilable(Nokogiri::HTML::Document)) end @@ -130,6 +143,7 @@ def fetch_dependency_metadata_from_html(repository_details) nil end + # Parses release dates from an HTML directory listing page. sig do params(html_doc: Nokogiri::HTML::Document) .returns(T::Hash[String, T::Hash[Symbol, T.untyped]]) @@ -157,6 +171,9 @@ def extract_version_details_from_html(html_doc) versions_detail_hash end + # -- Response Checking & Error Handling -- + + # Tracks forbidden URLs when receiving 401/403 responses (except for central repo). sig { params(response: Excon::Response, repository_url: String).void } def check_response(response, repository_url) return unless [401, 403].include?(response.status) @@ -166,6 +183,7 @@ def check_response(response, repository_url) forbidden_urls << repository_url end + # Raises RegistryError for failures hitting the central repo. sig do params( url: String, @@ -186,6 +204,8 @@ def handle_registry_error(url, error, response) raise RegistryError.new(response_status, response_body) end + # -- Release Check -- + # Checks whether a specific version of the dependency has been published # by issuing HEAD requests to each repository. sig { params(version: Dependabot::Version).returns(T::Boolean) } @@ -211,6 +231,9 @@ def released?(version) end end + # -- Credential & Repository Helpers -- + + # Builds repository details from credentials of type "maven_repository". sig { returns(T::Array[T::Hash[String, T.untyped]]) } def credentials_repository_details credentials @@ -224,6 +247,8 @@ def credentials_repository_details end end + # The default central repo URL. Subclasses may override (e.g., if credentials + # provide a replacement base repo). sig { returns(String) } def central_repo_url DEFAULT_CENTRAL_REPO_URL @@ -241,6 +266,8 @@ def forbidden_urls @forbidden_urls ||= T.let([], T.nilable(T::Array[String])) end + # -- Auth -- + sig { params(maven_repo_url: String).returns(T::Hash[String, String]) } def auth_headers(maven_repo_url) auth_headers_finder.auth_headers(maven_repo_url) @@ -251,6 +278,8 @@ def auth_headers_finder @auth_headers_finder ||= T.let(Utils::AuthHeadersFinder.new(credentials), T.nilable(Utils::AuthHeadersFinder)) end + # -- Metadata Caching -- + # Fetches and caches XML metadata per repository. sig { params(repository_details: T::Hash[String, T.untyped]).returns(T.nilable(Nokogiri::XML::Document)) } def dependency_metadata(repository_details) @@ -281,6 +310,8 @@ def dependency_metadata_from_html(repository_details) @dependency_metadata_from_html[repository_key] end + # -- Version Class -- + sig { returns(T.class_of(Dependabot::Version)) } def version_class dependency.version_class