|
| 1 | +# typed: strict |
| 2 | +# frozen_string_literal: true |
| 3 | + |
| 4 | +require "json" |
| 5 | +require "time" |
| 6 | +require "cgi" |
| 7 | +require "excon" |
| 8 | +require "nokogiri" |
| 9 | +require "sorbet-runtime" |
| 10 | +require "dependabot/registry_client" |
| 11 | +require "dependabot/python/name_normaliser" |
| 12 | +require "dependabot/package/package_release" |
| 13 | +require "dependabot/package/package_details" |
| 14 | +require "dependabot/python/package/package_registry_finder" |
| 15 | + |
| 16 | +# Stores metadata for a package, including all its available versions |
| 17 | +module Dependabot |
| 18 | + module Maven |
| 19 | + module Package |
| 20 | + class PackageDetailsFetcher |
| 21 | + extend T::Sig |
| 22 | + |
| 23 | + sig do |
| 24 | + params( |
| 25 | + dependency: Dependabot::Dependency, |
| 26 | + dependency_files: T::Array[Dependabot::DependencyFile], |
| 27 | + credentials: T::Array[Dependabot::Credential] |
| 28 | + ).void |
| 29 | + end |
| 30 | + def initialize( |
| 31 | + dependency:, |
| 32 | + dependency_files:, |
| 33 | + credentials: |
| 34 | + ) |
| 35 | + @dependency = dependency |
| 36 | + @dependency_files = dependency_files |
| 37 | + @credentials = credentials |
| 38 | + |
| 39 | + @registry_urls = T.let(nil, T.nilable(T::Array[String])) |
| 40 | + @forbidden_urls = T.let([], T::Array[String]) |
| 41 | + @pom_repository_details = T.let(nil, T.nilable(T::Array[T::Hash[String, T.untyped]])) |
| 42 | + @dependency_metadata = T.let({}, T::Hash[T.untyped, Nokogiri::XML::Document]) |
| 43 | + @repository_finder = T.let(nil, T.nilable(Maven::FileParser::RepositoriesFinder)) |
| 44 | + @repositories = T.let(nil, T.nilable(T::Array[T::Hash[String, T.untyped]])) |
| 45 | + @released_check = T.let({}, T::Hash[Version, T::Boolean]) |
| 46 | + @auth_headers_finder = T.let(nil, T.nilable(Utils::AuthHeadersFinder)) |
| 47 | + end |
| 48 | + |
| 49 | + sig { returns(Dependabot::Dependency) } |
| 50 | + attr_reader :dependency |
| 51 | + |
| 52 | + sig { returns(T::Array[T.untyped]) } |
| 53 | + attr_reader :dependency_files |
| 54 | + |
| 55 | + sig { returns(T::Array[T.untyped]) } |
| 56 | + attr_reader :credentials |
| 57 | + sig { returns(T::Array[T.untyped]) } |
| 58 | + attr_reader :forbidden_urls |
| 59 | + |
| 60 | + sig { returns(T::Array[T.untyped]) } |
| 61 | + def versions |
| 62 | + version_details = |
| 63 | + repositories.map do |repository_details| |
| 64 | + url = repository_details.fetch("url") |
| 65 | + xml = dependency_metadata(repository_details) |
| 66 | + next [] if xml.nil? |
| 67 | + |
| 68 | + break xml.css("versions > version") |
| 69 | + .select { |node| version_class.correct?(node.content) } |
| 70 | + .map { |node| version_class.new(node.content) } |
| 71 | + .map { |version| { version: version, source_url: url } } |
| 72 | + end.flatten |
| 73 | + |
| 74 | + raise PrivateSourceAuthenticationFailure, forbidden_urls.first if version_details.none? && forbidden_urls.any? |
| 75 | + |
| 76 | + version_details.sort_by { |details| details.fetch(:version) } |
| 77 | + end |
| 78 | + |
| 79 | + sig { params(version: Version).returns(T::Boolean) } |
| 80 | + def released?(version) |
| 81 | + @released_check[version] ||= |
| 82 | + repositories.any? do |repository_details| |
| 83 | + url = repository_details.fetch("url") |
| 84 | + response = Dependabot::RegistryClient.head( |
| 85 | + url: dependency_files_url(url, version), |
| 86 | + headers: repository_details.fetch("auth_headers") |
| 87 | + ) |
| 88 | + |
| 89 | + response.status < 400 |
| 90 | + rescue Excon::Error::Socket, Excon::Error::Timeout, |
| 91 | + Excon::Error::TooManyRedirects |
| 92 | + false |
| 93 | + rescue URI::InvalidURIError => e |
| 94 | + raise DependencyFileNotResolvable, e.message |
| 95 | + end |
| 96 | + end |
| 97 | + |
| 98 | + private |
| 99 | + |
| 100 | + sig { returns(T::Array[T::Hash[String, T.untyped]]) } |
| 101 | + def repositories |
| 102 | + return @repositories if @repositories |
| 103 | + |
| 104 | + @repositories = credentials_repository_details |
| 105 | + pom_repository_details.each do |repo| |
| 106 | + @repositories << repo unless @repositories.any? { |r| r["url"] == repo["url"] } |
| 107 | + end |
| 108 | + @repositories |
| 109 | + end |
| 110 | + |
| 111 | + sig { params(repository_details: T::Hash[String, T.untyped]).returns(T.nilable(Nokogiri::XML::Document)) } |
| 112 | + def dependency_metadata(repository_details) |
| 113 | + repository_key = repository_details.hash |
| 114 | + return @dependency_metadata[repository_key] if @dependency_metadata.key?(repository_key) |
| 115 | + |
| 116 | + xml_document = fetch_dependency_metadata(repository_details) |
| 117 | + |
| 118 | + @dependency_metadata[repository_key] ||= xml_document if xml_document |
| 119 | + @dependency_metadata[repository_key] |
| 120 | + end |
| 121 | + |
| 122 | + sig { params(repository_details: T::Hash[String, T.untyped]).returns(T.nilable(Nokogiri::XML::Document)) } |
| 123 | + def fetch_dependency_metadata(repository_details) |
| 124 | + response = Dependabot::RegistryClient.get( |
| 125 | + url: dependency_metadata_url(repository_details.fetch("url")), |
| 126 | + headers: repository_details.fetch("auth_headers") |
| 127 | + ) |
| 128 | + check_response(response, repository_details.fetch("url")) |
| 129 | + return unless response.status < 400 |
| 130 | + |
| 131 | + Nokogiri::XML(response.body) |
| 132 | + rescue URI::InvalidURIError |
| 133 | + nil |
| 134 | + rescue Excon::Error::Socket, Excon::Error::Timeout, |
| 135 | + Excon::Error::TooManyRedirects => e |
| 136 | + |
| 137 | + if central_repo_urls.include?(repository_details["url"]) |
| 138 | + response_status = response&.status || 0 |
| 139 | + response_body = if response |
| 140 | + "RegistryError: #{response.status} response status with body #{response.body}" |
| 141 | + else |
| 142 | + "RegistryError: #{e.message}" |
| 143 | + end |
| 144 | + |
| 145 | + raise RegistryError.new(response_status, response_body) |
| 146 | + end |
| 147 | + |
| 148 | + nil |
| 149 | + end |
| 150 | + |
| 151 | + sig { params(response: Excon::Response, repository_url: String).void } |
| 152 | + def check_response(response, repository_url) |
| 153 | + return unless [401, 403].include?(response.status) |
| 154 | + return if @forbidden_urls.include?(repository_url) |
| 155 | + return if central_repo_urls.include?(repository_url) |
| 156 | + |
| 157 | + @forbidden_urls << repository_url |
| 158 | + end |
| 159 | + |
| 160 | + sig { returns(Maven::FileParser::RepositoriesFinder) } |
| 161 | + def repository_finder |
| 162 | + return @repository_finder if @repository_finder |
| 163 | + |
| 164 | + @repository_finder = |
| 165 | + Maven::FileParser::RepositoriesFinder.new( |
| 166 | + pom_fetcher: Maven::FileParser::PomFetcher.new(dependency_files: dependency_files), |
| 167 | + dependency_files: dependency_files, |
| 168 | + credentials: credentials |
| 169 | + ) |
| 170 | + @repository_finder |
| 171 | + end |
| 172 | + |
| 173 | + sig { returns(T::Array[T::Hash[String, T.untyped]]) } |
| 174 | + def pom_repository_details |
| 175 | + return @pom_repository_details if @pom_repository_details |
| 176 | + |
| 177 | + @pom_repository_details = |
| 178 | + repository_finder |
| 179 | + .repository_urls(pom: pom) |
| 180 | + .map do |url| |
| 181 | + { "url" => url, "auth_headers" => {} } |
| 182 | + end |
| 183 | + @pom_repository_details |
| 184 | + end |
| 185 | + |
| 186 | + sig { returns(T.nilable(Dependabot::DependencyFile)) } |
| 187 | + def pom |
| 188 | + filename = dependency.requirements.first&.fetch(:file) |
| 189 | + dependency_files.find { |f| f.name == filename } |
| 190 | + end |
| 191 | + |
| 192 | + sig { params(repository_url: String).returns(String) } |
| 193 | + def dependency_metadata_url(repository_url) |
| 194 | + group_id, artifact_id = dependency.name.split(":") |
| 195 | + |
| 196 | + "#{repository_url}/" \ |
| 197 | + "#{group_id&.tr('.', '/')}/" \ |
| 198 | + "#{artifact_id}/" \ |
| 199 | + "maven-metadata.xml" |
| 200 | + end |
| 201 | + |
| 202 | + sig { params(repository_url: String, version: Version).returns(String) } |
| 203 | + def dependency_files_url(repository_url, version) |
| 204 | + group_id, artifact_id = dependency.name.split(":") |
| 205 | + type = dependency.requirements.first&.dig(:metadata, :packaging_type) |
| 206 | + classifier = dependency.requirements.first&.dig(:metadata, :classifier) |
| 207 | + |
| 208 | + actual_classifier = classifier.nil? ? "" : "-#{classifier}" |
| 209 | + "#{repository_url}/" \ |
| 210 | + "#{group_id&.tr('.', '/')}/" \ |
| 211 | + "#{artifact_id}/" \ |
| 212 | + "#{version}/" \ |
| 213 | + "#{artifact_id}-#{version}#{actual_classifier}.#{type}" |
| 214 | + end |
| 215 | + |
| 216 | + sig { returns(T::Array[T.untyped]) } |
| 217 | + def credentials_repository_details |
| 218 | + credentials |
| 219 | + .select { |cred| cred["type"] == "maven_repository" && cred["url"] } |
| 220 | + .map do |cred| |
| 221 | + { |
| 222 | + "url" => cred.fetch("url").gsub(%r{/+$}, ""), |
| 223 | + "auth_headers" => auth_headers(cred.fetch("url").gsub(%r{/+$}, "")) |
| 224 | + } |
| 225 | + end |
| 226 | + end |
| 227 | + |
| 228 | + sig { returns(T.class_of(Dependabot::Version)) } |
| 229 | + def version_class |
| 230 | + dependency.version_class |
| 231 | + end |
| 232 | + |
| 233 | + sig { returns(T::Array[String]) } |
| 234 | + def central_repo_urls |
| 235 | + central_url_without_protocol = repository_finder.central_repo_url.gsub(%r{^.*://}, "") |
| 236 | + |
| 237 | + %w(http:// https://).map { |p| p + central_url_without_protocol } |
| 238 | + end |
| 239 | + |
| 240 | + sig { returns(Utils::AuthHeadersFinder) } |
| 241 | + def auth_headers_finder |
| 242 | + return @auth_headers_finder if @auth_headers_finder |
| 243 | + |
| 244 | + @auth_headers_finder = Utils::AuthHeadersFinder.new(credentials) |
| 245 | + @auth_headers_finder |
| 246 | + end |
| 247 | + |
| 248 | + sig { params(maven_repo_url: String).returns(T::Hash[String, String]) } |
| 249 | + def auth_headers(maven_repo_url) |
| 250 | + auth_headers_finder.auth_headers(maven_repo_url) |
| 251 | + end |
| 252 | + end |
| 253 | + end |
| 254 | + end |
| 255 | +end |
0 commit comments