diff --git a/maven/lib/dependabot/maven/package/package_details_fetcher.rb b/maven/lib/dependabot/maven/package/package_details_fetcher.rb index ffd426c4cb2..bed737f6e5c 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 @@ -86,9 +72,24 @@ def releases fetch.releases end - sig { params(version: Dependabot::Version).returns(T::Boolean) } - def released?(version) - released_check?(version) + # 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 +172,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 +186,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 +206,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..923157c5922
--- /dev/null
+++ b/maven/lib/dependabot/maven/shared/shared_maven_repository_client.rb
@@ -0,0 +1,322 @@
+# 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
+ 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) || "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)
+ 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 ||= {}
+ 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)
+ 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([], 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)
+ end
+
+ sig { returns(Utils::AuthHeadersFinder) }
+ 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)
+ @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
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..5ce10a421d1
--- /dev/null
+++ b/maven/spec/dependabot/maven/shared/shared_maven_repository_client_spec.rb
@@ -0,0 +1,640 @@
+# 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"
+
+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
+
+ 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
+ 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 -
+ not-a-version!/ 2017-09-01 10: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")
+ expect(results).not_to have_key("not-a-version!")
+ 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 "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" }
+ 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" => {} }
+
+ other_body = "" \
+ "1.0 " \
+ " "
+ stub_request(:get, other_metadata_url)
+ .to_return(status: 200, body: other_body)
+
+ 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