Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 91 additions & 25 deletions gradle/lib/dependabot/gradle/package/package_details_fetcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
module Dependabot
module Gradle
module Package
# rubocop:disable Metrics/ClassLength
class PackageDetailsFetcher
extend T::Sig

Expand Down Expand Up @@ -99,39 +100,89 @@ def fetch_available_versions
end
# rubocop:enable Metrics/AbcSize,Metrics/PerceivedComplexity

# Extracts release dates from repository metadata to support the cooldown feature.
# Attempts both parsing strategies for all repositories:
# 1. Gradle Plugin Portal style: maven-metadata.xml with lastUpdated timestamp (latest version only)
# 2. Maven repository style: HTML directory listings with per-version dates
# This supports mirrors/proxies of both Maven Central and Gradle Plugin Portal.
sig { returns(T::Hash[String, T::Hash[Symbol, T.untyped]]) }
def release_details
release_date_info = T.let({}, T::Hash[String, T::Hash[Symbol, T.untyped]])

begin
repositories.map do |repository_details|
url = repository_details.fetch("url")
next unless url == Gradle::FileParser::RepositoriesFinder::CENTRAL_REPO_URL
repositories.each do |repository_details|
# Try Gradle Plugin Portal style first (structured metadata)
parse_gradle_plugin_portal_release(repository_details, release_date_info)

release_info_metadata(repository_details).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
# Try Maven repository style (HTML directory listing)
parse_maven_central_releases(repository_details, release_date_info)
end

release_date = begin
Time.parse(raw_date_text)
rescue StandardError
nil
end
release_date_info
rescue StandardError => e
Dependabot.logger.error("Failed to get release date for #{dependency.name}: #{e.class} - #{e.message}")
Dependabot.logger.error(e.backtrace&.join("\n") || "No backtrace available")
{}
end
end

next unless version && version_class.correct?(version)
sig do
params(
repository_details: T::Hash[String, T.untyped],
release_date_info: T::Hash[String, T::Hash[Symbol, T.untyped]]
).void
end
def parse_maven_central_releases(repository_details, release_date_info)
release_info_metadata(repository_details).css("a[title]").each do |link|
title = link["title"]
next unless title

release_date_info[version] = {
release_date: release_date
}
end
version = title.gsub(%r{/$}, "")
next unless version_class.correct?(version)
next if release_date_info.key?(version) # Skip if already found

release_date = begin
raw_date_text = link.next.text.strip.split("\n").last.strip
Time.parse(raw_date_text)
rescue StandardError => e
Dependabot.logger.debug(
"Failed to parse release date for #{dependency.name} version #{version}: #{e.message}"
)
nil
end

release_date_info
rescue StandardError
Dependabot.logger.error("Failed to get release date")
{}
release_date_info[version] = { release_date: release_date }
end
rescue StandardError => e
Dependabot.logger.debug(
"Could not parse Maven-style release dates from #{repository_details.fetch('url')} " \
"for #{dependency.name}: #{e.message}"
)
end

sig do
params(
repository_details: T::Hash[String, T.untyped],
release_date_info: T::Hash[String, T::Hash[Symbol, T.untyped]]
).void
end
def parse_gradle_plugin_portal_release(repository_details, release_date_info)
metadata_xml = dependency_metadata(repository_details)
last_updated = metadata_xml.at_xpath("//metadata/versioning/lastUpdated")&.text&.strip
latest_version = metadata_xml.at_xpath("//metadata/versioning/latest")&.text&.strip

return unless latest_version && version_class.correct?(latest_version)
return if release_date_info.key?(latest_version) # Skip if already found from Maven-style parsing

release_date = parse_gradle_timestamp(last_updated)
Dependabot.logger.info(
"Parsed Gradle Plugin Portal release for #{dependency.name}: #{latest_version} at #{release_date}"
)
release_date_info[latest_version] = { release_date: release_date }
rescue StandardError => e
Dependabot.logger.debug(
"Could not parse Gradle Plugin Portal metadata from #{repository_details.fetch('url')} " \
"for #{dependency.name}: #{e.message}"
)
end

sig { returns(T::Array[T::Hash[String, T.untyped]]) }
Expand Down Expand Up @@ -226,6 +277,8 @@ def dependency_metadata(repository_details)
end
end

# Fetches HTML directory listing from Maven-compatible repositories.
# Uses CSS selector "a[title]" to extract versions and dates. Caches results per repository.
sig { params(repository_details: T::Hash[T.untyped, T.untyped]).returns(T.untyped) }
def release_info_metadata(repository_details)
@release_info_metadata ||= T.let({}, T.nilable(T::Hash[Integer, T.untyped]))
Expand All @@ -237,14 +290,14 @@ def release_info_metadata(repository_details)
)

check_response(response, repository_details.fetch("url"))
Nokogiri::XML(response.body)
Nokogiri::HTML(response.body)
rescue URI::InvalidURIError
Nokogiri::XML("")
Nokogiri::HTML("")
rescue Excon::Error::Socket, Excon::Error::Timeout,
Excon::Error::TooManyRedirects
raise if central_repo_urls.include?(repository_details["url"])

Nokogiri::XML("")
Nokogiri::HTML("")
end
end

Expand Down Expand Up @@ -383,6 +436,18 @@ def central_repo_urls
%w(http:// https://).map { |p| p + central_url_without_protocol }
end

sig { params(timestamp: T.nilable(String)).returns(T.nilable(Time)) }
def parse_gradle_timestamp(timestamp)
return nil if timestamp.nil? || timestamp.empty?

Time.strptime(timestamp, "%Y%m%d%H%M%S") # Parse YYYYMMDDHHmmss format
rescue ArgumentError => e
Dependabot.logger.warn(
"Failed to parse Gradle timestamp for #{dependency.name}: '#{timestamp}' - #{e.message}"
)
nil
end

sig { returns(T.class_of(Dependabot::Version)) }
def version_class
dependency.version_class
Expand All @@ -401,6 +466,7 @@ def auth_headers(maven_repo_url)
auth_headers_finder.auth_headers(maven_repo_url)
end
end
# rubocop:enable Metrics/ClassLength
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,24 @@
"https://repo.maven.apache.org/maven2/org/springframework/boot/" \
"org.springframework.boot.gradle.plugin/maven-metadata.xml"
end
let(:maven_central_html_url) do
"https://repo.maven.apache.org/maven2/org/springframework/boot/" \
"org.springframework.boot.gradle.plugin/"
end

before do
stub_request(:get, gradle_plugin_metadata_url)
.to_return(status: 200, body: gradle_plugin_releases)
stub_request(:get, maven_metadata_url).to_return(status: 404)
# Stub the HTML directory listing request for Maven Central
stub_request(:get, maven_central_html_url).to_return(status: 404)
end

it "populates release_details for the latest version" do
release_info = packagedetailsfetcher.send(:release_details)
expect(release_info).to be_a(Hash)
expect(release_info).to have_key("2.1.4.RELEASE")
expect(release_info["2.1.4.RELEASE"][:release_date]).to eq(Time.utc(2019, 4, 4, 5, 30, 33))
end

describe "the first version" do
Expand All @@ -135,6 +148,11 @@
its([:source_url]) do
is_expected.to eq("https://plugins.gradle.org/m2")
end

its([:released_at]) do
# lastUpdated from fixture: 20190404053033 (2019-04-04 05:30:33 UTC)
is_expected.to eq(Time.utc(2019, 4, 4, 5, 30, 33))
end
end
end

Expand All @@ -161,11 +179,17 @@
"https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/jvm/" \
"org.jetbrains.kotlin.jvm.gradle.plugin/maven-metadata.xml"
end
let(:maven_central_html_url) do
"https://repo.maven.apache.org/maven2/org/jetbrains/kotlin/jvm/" \
"org.jetbrains.kotlin.jvm.gradle.plugin/"
end

before do
stub_request(:get, gradle_plugin_metadata_url)
.to_return(status: 200, body: gradle_plugin_releases)
stub_request(:get, maven_metadata_url).to_return(status: 404)
# Stub the HTML directory listing request for Maven Central
stub_request(:get, maven_central_html_url).to_return(status: 404)
end

describe "the first version" do
Expand All @@ -190,6 +214,11 @@
its([:source_url]) do
is_expected.to eq("https://plugins.gradle.org/m2")
end

its([:released_at]) do
# lastUpdated from fixture: 20201222143435 (2020-12-22 14:34:35 UTC)
is_expected.to eq(Time.utc(2020, 12, 22, 14, 34, 35))
end
end
end

Expand Down Expand Up @@ -337,4 +366,67 @@
end
end
end

describe "#parse_gradle_timestamp" do
subject(:parse_timestamp) { fetcher.send(:parse_gradle_timestamp, timestamp) }

let(:fetcher) do
described_class.new(
dependency: dependency,
dependency_files: dependency_files,
credentials: credentials,
forbidden_urls: []
)
end
let(:dependency_requirements) do
[{
file: "build.gradle",
requirement: "1.0.0",
groups: ["plugins"],
source: nil
}]
end
let(:dependency_name) { "test.plugin" }
let(:dependency_version) { "1.0.0" }

context "with valid timestamp" do
let(:timestamp) { "20191201191459" }

it "parses YYYYMMDDHHmmss format correctly" do
expect(parse_timestamp).to eq(Time.utc(2019, 12, 1, 19, 14, 59))
end
end

context "with nil timestamp" do
let(:timestamp) { nil }

it "returns nil" do
expect(parse_timestamp).to be_nil
end
end

context "with empty timestamp" do
let(:timestamp) { "" }

it "returns nil" do
expect(parse_timestamp).to be_nil
end
end

context "with invalid timestamp format" do
let(:timestamp) { "invalid" }

it "returns nil" do
expect(parse_timestamp).to be_nil
end
end

context "with non-numeric timestamp" do
let(:timestamp) { "abcd1201191459" }

it "returns nil" do
expect(parse_timestamp).to be_nil
end
end
end
end
Loading