dependabot-nuget 0.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dependabot/nuget/file_parser"
4
+ require "dependabot/nuget/update_checker"
5
+
6
+ module Dependabot
7
+ module Nuget
8
+ class UpdateChecker
9
+ class PropertyUpdater
10
+ require_relative "version_finder"
11
+ require_relative "requirements_updater"
12
+
13
+ def initialize(dependency:, dependency_files:, credentials:,
14
+ target_version_details:, ignored_versions:)
15
+ @dependency = dependency
16
+ @dependency_files = dependency_files
17
+ @credentials = credentials
18
+ @ignored_versions = ignored_versions
19
+ @target_version = target_version_details&.fetch(:version)
20
+ @source_details = target_version_details&.
21
+ slice(:nuspec_url, :repo_url, :source_url)
22
+ end
23
+
24
+ def update_possible?
25
+ return false unless target_version
26
+
27
+ @update_possible ||=
28
+ dependencies_using_property.all? do |dep|
29
+ versions = VersionFinder.new(
30
+ dependency: dep,
31
+ dependency_files: dependency_files,
32
+ credentials: credentials,
33
+ ignored_versions: ignored_versions
34
+ ).versions.map { |v| v.fetch(:version) }
35
+
36
+ versions.include?(target_version) || versions.none?
37
+ end
38
+ end
39
+
40
+ def updated_dependencies
41
+ raise "Update not possible!" unless update_possible?
42
+
43
+ @updated_dependencies ||=
44
+ dependencies_using_property.map do |dep|
45
+ Dependency.new(
46
+ name: dep.name,
47
+ version: target_version.to_s,
48
+ requirements: updated_requirements(dep),
49
+ previous_version: dep.version,
50
+ previous_requirements: dep.requirements,
51
+ package_manager: dep.package_manager
52
+ )
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :dependency, :dependency_files, :target_version,
59
+ :source_details, :credentials, :ignored_versions
60
+
61
+ def dependencies_using_property
62
+ @dependencies_using_property ||=
63
+ Nuget::FileParser.new(
64
+ dependency_files: dependency_files,
65
+ source: nil
66
+ ).parse.select do |dep|
67
+ dep.requirements.any? do |r|
68
+ r.dig(:metadata, :property_name) == property_name
69
+ end
70
+ end
71
+ end
72
+
73
+ def property_name
74
+ @property_name ||= dependency.requirements.
75
+ find { |r| r.dig(:metadata, :property_name) }&.
76
+ dig(:metadata, :property_name)
77
+
78
+ raise "No requirement with a property name!" unless @property_name
79
+
80
+ @property_name
81
+ end
82
+
83
+ def updated_requirements(dep)
84
+ @updated_requirements ||= {}
85
+ @updated_requirements[dep.name] ||=
86
+ RequirementsUpdater.new(
87
+ requirements: dep.requirements,
88
+ latest_version: target_version,
89
+ source_details: source_details
90
+ ).updated_requirements
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "excon"
4
+ require "nokogiri"
5
+ require "dependabot/errors"
6
+ require "dependabot/nuget/update_checker"
7
+ require "dependabot/shared_helpers"
8
+
9
+ module Dependabot
10
+ module Nuget
11
+ class UpdateChecker
12
+ class RepositoryFinder
13
+ DEFAULT_REPOSITORY_URL = "https://api.nuget.org/v3/index.json"
14
+
15
+ def initialize(dependency:, credentials:, config_file: nil)
16
+ @dependency = dependency
17
+ @credentials = credentials
18
+ @config_file = config_file
19
+ end
20
+
21
+ def dependency_urls
22
+ find_dependency_urls
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :dependency, :credentials, :config_file
28
+
29
+ def find_dependency_urls
30
+ @find_dependency_urls ||=
31
+ known_repositories.flat_map do |details|
32
+ if details.fetch(:url) == DEFAULT_REPOSITORY_URL
33
+ # Save a request for the default URL, since we already how
34
+ # it addresses packages
35
+ next default_repository_details
36
+ end
37
+
38
+ build_url_for_details(details)
39
+ end.compact.uniq
40
+ end
41
+
42
+ def build_url_for_details(repo_details)
43
+ response = get_repo_metadata(repo_details)
44
+ check_repo_reponse(response, repo_details)
45
+ return unless response.status == 200
46
+
47
+ base_url = base_url_from_v3_metadata(JSON.parse(response.body))
48
+ search_url = search_url_from_v3_metadata(JSON.parse(response.body))
49
+
50
+ details = {
51
+ repository_url: repo_details.fetch(:url),
52
+ auth_header: auth_header_for_token(repo_details.fetch(:token)),
53
+ repository_type: "v3"
54
+ }
55
+ if base_url
56
+ details[:versions_url] =
57
+ File.join(base_url, dependency.name.downcase, "index.json")
58
+ end
59
+ if search_url
60
+ details[:search_url] =
61
+ search_url + "?q=#{dependency.name.downcase}&prerelease=true"
62
+ end
63
+ details
64
+ rescue JSON::ParserError
65
+ build_v2_url(response, repo_details)
66
+ rescue Excon::Error::Timeout, Excon::Error::Socket
67
+ handle_timeout(repo_metadata_url: repo_details.fetch(:url))
68
+ end
69
+
70
+ def get_repo_metadata(repo_details)
71
+ Excon.get(
72
+ repo_details.fetch(:url),
73
+ headers: auth_header_for_token(repo_details.fetch(:token)),
74
+ idempotent: true,
75
+ **SharedHelpers.excon_defaults
76
+ )
77
+ end
78
+
79
+ def base_url_from_v3_metadata(metadata)
80
+ metadata.
81
+ fetch("resources", []).
82
+ find { |r| r.fetch("@type") == "PackageBaseAddress/3.0.0" }&.
83
+ fetch("@id")
84
+ end
85
+
86
+ def search_url_from_v3_metadata(metadata)
87
+ metadata.
88
+ fetch("resources", []).
89
+ find { |r| r.fetch("@type") == "SearchQueryService" }&.
90
+ fetch("@id")
91
+ end
92
+
93
+ def build_v2_url(response, repo_details)
94
+ doc = Nokogiri::XML(response.body)
95
+ doc.remove_namespaces!
96
+ base_url = doc.at_xpath("service")&.attributes&.fetch("base")&.value
97
+ return unless base_url
98
+
99
+ {
100
+ repository_url: base_url,
101
+ versions_url: File.join(
102
+ base_url,
103
+ "FindPackagesById()?id='#{dependency.name}'"
104
+ ),
105
+ auth_header: auth_header_for_token(repo_details.fetch(:token)),
106
+ repository_type: "v2"
107
+ }
108
+ end
109
+
110
+ def check_repo_reponse(response, details)
111
+ return unless [401, 402, 403].include?(response.status)
112
+ raise if details.fetch(:url) == DEFAULT_REPOSITORY_URL
113
+
114
+ raise PrivateSourceAuthenticationFailure, details.fetch(:url)
115
+ end
116
+
117
+ def handle_timeout(repo_metadata_url:)
118
+ raise if repo_metadata_url == DEFAULT_REPOSITORY_URL
119
+
120
+ raise PrivateSourceTimedOut, repo_metadata_url
121
+ end
122
+
123
+ def known_repositories
124
+ return @known_repositories if @known_repositories
125
+
126
+ @known_repositories = []
127
+ @known_repositories += credential_repositories
128
+ @known_repositories += config_file_repositories
129
+
130
+ if @known_repositories.empty?
131
+ @known_repositories << { url: DEFAULT_REPOSITORY_URL, token: nil }
132
+ end
133
+
134
+ @known_repositories.uniq
135
+ end
136
+
137
+ def credential_repositories
138
+ @credential_repositories ||=
139
+ credentials.
140
+ select { |cred| cred["type"] == "nuget_feed" }.
141
+ map { |c| { url: c.fetch("url"), token: c["token"] } }
142
+ end
143
+
144
+ def config_file_repositories
145
+ return [] unless config_file
146
+
147
+ doc = Nokogiri::XML(config_file.content)
148
+ doc.remove_namespaces!
149
+ sources =
150
+ doc.css("configuration > packageSources > add").map do |node|
151
+ {
152
+ key:
153
+ node.attribute("key")&.value&.strip ||
154
+ node.at_xpath("./key")&.content&.strip,
155
+ url:
156
+ node.attribute("value")&.value&.strip ||
157
+ node.at_xpath("./value")&.content&.strip
158
+ }
159
+ end
160
+
161
+ sources.reject! do |s|
162
+ known_urls = credential_repositories.map { |cr| cr.fetch(:url) }
163
+ known_urls.include?(s.fetch(:url))
164
+ end
165
+
166
+ add_config_file_credentials(sources: sources, doc: doc)
167
+ sources.each { |details| details.delete(:key) }
168
+
169
+ sources
170
+ end
171
+
172
+ def default_repository_details
173
+ {
174
+ repository_url: DEFAULT_REPOSITORY_URL,
175
+ versions_url: "https://api.nuget.org/v3-flatcontainer/"\
176
+ "#{dependency.name.downcase}/index.json",
177
+ search_url: "https://api-v2v3search-0.nuget.org/query"\
178
+ "?q=#{dependency.name.downcase}&prerelease=true",
179
+ auth_header: {},
180
+ repository_type: "v3"
181
+ }
182
+ end
183
+
184
+ def add_config_file_credentials(sources:, doc:)
185
+ sources.each do |source_details|
186
+ key = source_details.fetch(:key)
187
+ next source_details[:token] = nil unless key
188
+ next source_details[:token] = nil if key.match?(/^\d/)
189
+
190
+ tag = key.gsub(" ", "_x0020_")
191
+ creds_nodes = doc.css("configuration > packageSourceCredentials "\
192
+ "> #{tag} > add")
193
+
194
+ username =
195
+ creds_nodes.
196
+ find { |n| n.attribute("key")&.value == "Username" }&.
197
+ attribute("value")&.value
198
+ password =
199
+ creds_nodes.
200
+ find { |n| n.attribute("key")&.value == "ClearTextPassword" }&.
201
+ attribute("value")&.value
202
+
203
+ # Note: We have to look for plain text passwords, as we have no
204
+ # way of decrypting encrypted passwords. For the same reason we
205
+ # don't fetch API keys from the nuget.config at all.
206
+ next source_details[:token] = nil unless username && password
207
+
208
+ source_details[:token] = "#{username}:#{password}"
209
+ end
210
+
211
+ sources
212
+ end
213
+
214
+ def auth_header_for_token(token)
215
+ return {} unless token
216
+
217
+ if token.include?(":")
218
+ encoded_token = Base64.encode64(token).delete("\n")
219
+ { "Authorization" => "Basic #{encoded_token}" }
220
+ elsif Base64.decode64(token).ascii_only? &&
221
+ Base64.decode64(token).include?(":")
222
+ { "Authorization" => "Basic #{token.delete("\n")}" }
223
+ else
224
+ { "Authorization" => "Bearer #{token}" }
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ #######################################################################
4
+ # For more details on Dotnet version constraints, see: #
5
+ # https://docs.microsoft.com/en-us/nuget/reference/package-versioning #
6
+ #######################################################################
7
+
8
+ require "dependabot/nuget/update_checker"
9
+ require "dependabot/nuget/version"
10
+
11
+ module Dependabot
12
+ module Nuget
13
+ class UpdateChecker
14
+ class RequirementsUpdater
15
+ VERSION_REGEX = /[0-9a-zA-Z]+(?:\.[a-zA-Z0-9\-]+)*/.freeze
16
+
17
+ def initialize(requirements:, latest_version:, source_details:)
18
+ @requirements = requirements
19
+ @source_details = source_details
20
+ return unless latest_version
21
+
22
+ @latest_version = version_class.new(latest_version)
23
+ end
24
+
25
+ def updated_requirements
26
+ return requirements unless latest_version
27
+
28
+ # Note: Order is important here. The FileUpdater needs the updated
29
+ # requirement at index `i` to correspond to the previous requirement
30
+ # at the same index.
31
+ requirements.map do |req|
32
+ next req if req.fetch(:requirement).nil?
33
+ next req if req.fetch(:requirement).include?(",")
34
+
35
+ new_req =
36
+ if req.fetch(:requirement).include?("*")
37
+ update_wildcard_requirement(req.fetch(:requirement))
38
+ else
39
+ # Since range requirements are excluded by the line above we
40
+ # can just do a `gsub` on anything that looks like a version
41
+ req[:requirement].gsub(VERSION_REGEX, latest_version.to_s)
42
+ end
43
+
44
+ next req if new_req == req.fetch(:requirement)
45
+
46
+ req.merge(requirement: new_req, source: updated_source)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :requirements, :latest_version, :source_details
53
+
54
+ def version_class
55
+ Nuget::Version
56
+ end
57
+
58
+ def update_wildcard_requirement(req_string)
59
+ precision = req_string.split("*").first.split(/\.|\-/).count
60
+ wilcard_section = req_string.partition(/(?=[.\-]\*)/).last
61
+
62
+ version_parts = latest_version.segments.first(precision)
63
+ version = version_parts.join(".")
64
+
65
+ version + wilcard_section
66
+ end
67
+
68
+ def updated_source
69
+ {
70
+ type: "nuget_repo",
71
+ url: source_details.fetch(:repo_url),
72
+ nuspec_url: source_details.fetch(:nuspec_url),
73
+ source_url: source_details.fetch(:source_url)
74
+ }
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "excon"
4
+ require "nokogiri"
5
+
6
+ require "dependabot/nuget/version"
7
+ require "dependabot/nuget/requirement"
8
+ require "dependabot/nuget/update_checker"
9
+ require "dependabot/shared_helpers"
10
+
11
+ module Dependabot
12
+ module Nuget
13
+ class UpdateChecker
14
+ class VersionFinder
15
+ require_relative "repository_finder"
16
+
17
+ def initialize(dependency:, dependency_files:, credentials:,
18
+ ignored_versions: [])
19
+ @dependency = dependency
20
+ @dependency_files = dependency_files
21
+ @credentials = credentials
22
+ @ignored_versions = ignored_versions
23
+ end
24
+
25
+ def latest_version_details
26
+ @latest_version_details ||=
27
+ begin
28
+ tmp_versions = versions
29
+ unless wants_prerelease?
30
+ tmp_versions.reject! { |d| d.fetch(:version).prerelease? }
31
+ end
32
+ tmp_versions.reject! do |hash|
33
+ ignore_reqs.any? { |r| r.satisfied_by?(hash.fetch(:version)) }
34
+ end
35
+ tmp_versions.max_by { |hash| hash.fetch(:version) }
36
+ end
37
+ end
38
+
39
+ def versions
40
+ available_v3_versions + available_v2_versions
41
+ end
42
+
43
+ attr_reader :dependency, :dependency_files, :credentials,
44
+ :ignored_versions
45
+
46
+ private
47
+
48
+ def available_v3_versions
49
+ v3_nuget_listings.flat_map do |listing|
50
+ listing.
51
+ fetch("versions", []).
52
+ map do |v|
53
+ nuspec_url =
54
+ listing.fetch("listing_details").
55
+ fetch(:versions_url).
56
+ gsub(/index\.json$/, "#{v}/#{sanitized_name}.nuspec")
57
+
58
+ {
59
+ version: version_class.new(v),
60
+ nuspec_url: nuspec_url,
61
+ source_url: nil,
62
+ repo_url:
63
+ listing.fetch("listing_details").fetch(:repository_url)
64
+ }
65
+ end
66
+ end
67
+ end
68
+
69
+ def available_v2_versions
70
+ v2_nuget_listings.flat_map do |listing|
71
+ body = listing.fetch("xml_body", [])
72
+ doc = Nokogiri::XML(body)
73
+ doc.remove_namespaces!
74
+
75
+ doc.xpath("/feed/entry").map do |entry|
76
+ listed = entry.at_xpath("./properties/Listed")&.content&.strip
77
+ next if listed&.casecmp("false")&.zero?
78
+
79
+ entry_details = dependency_details_from_v2_entry(entry)
80
+ entry_details.merge(
81
+ repo_url: listing.fetch("listing_details").
82
+ fetch(:repository_url)
83
+ )
84
+ end.compact
85
+ end
86
+ end
87
+
88
+ def dependency_details_from_v2_entry(entry)
89
+ version = entry.at_xpath("./properties/Version").content.strip
90
+ source_urls = []
91
+ [
92
+ entry.at_xpath("./properties/ProjectUrl").content,
93
+ entry.at_xpath("./properties/ReleaseNotes").content
94
+ ].join(" ").scan(Source::SOURCE_REGEX) do
95
+ source_urls << Regexp.last_match.to_s
96
+ end
97
+
98
+ source_url = source_urls.find { |url| Source.from_url(url) }
99
+ source_url = Source.from_url(source_url)&.url if source_url
100
+
101
+ {
102
+ version: version_class.new(version),
103
+ nuspec_url: nil,
104
+ source_url: source_url
105
+ }
106
+ end
107
+
108
+ def wants_prerelease?
109
+ if dependency.version &&
110
+ version_class.correct?(dependency.version) &&
111
+ version_class.new(dependency.version).prerelease?
112
+ return true
113
+ end
114
+
115
+ dependency.requirements.any? do |req|
116
+ reqs = (req.fetch(:requirement) || "").split(",").map(&:strip)
117
+ reqs.any? { |r| r.include?("-") }
118
+ end
119
+ end
120
+
121
+ def v3_nuget_listings
122
+ return @v3_nuget_listings unless @v3_nuget_listings.nil?
123
+
124
+ dependency_urls.
125
+ select { |details| details.fetch(:repository_type) == "v3" }.
126
+ map do |url_details|
127
+ versions = versions_for_v3_repository(url_details)
128
+ next unless versions
129
+
130
+ { "versions" => versions, "listing_details" => url_details }
131
+ end.compact
132
+ end
133
+
134
+ def v2_nuget_listings
135
+ return @v2_nuget_listings unless @v2_nuget_listings.nil?
136
+
137
+ dependency_urls.
138
+ select { |details| details.fetch(:repository_type) == "v2" }.
139
+ map do |url_details|
140
+ response = Excon.get(
141
+ url_details[:versions_url],
142
+ headers: url_details[:auth_header],
143
+ idempotent: true,
144
+ **excon_defaults
145
+ )
146
+ next unless response.status == 200
147
+
148
+ {
149
+ "xml_body" => response.body,
150
+ "listing_details" => url_details
151
+ }
152
+ end.compact
153
+ end
154
+
155
+ def versions_for_v3_repository(repository_details)
156
+ # If we have a search URL we use it (since it will exclude unlisted
157
+ # versions)
158
+ if repository_details[:search_url]
159
+ response = Excon.get(
160
+ repository_details[:search_url],
161
+ headers: repository_details[:auth_header],
162
+ idempotent: true,
163
+ **excon_defaults
164
+ )
165
+ return unless response.status == 200
166
+
167
+ JSON.parse(response.body).fetch("data").
168
+ find { |d| d.fetch("id").casecmp(sanitized_name).zero? }&.
169
+ fetch("versions")&.
170
+ map { |d| d.fetch("version") }
171
+ # Otherwise, use the versions URL
172
+ elsif repository_details[:versions_url]
173
+ response = Excon.get(
174
+ repository_details[:versions_url],
175
+ headers: repository_details[:auth_header],
176
+ idempotent: true,
177
+ **excon_defaults
178
+ )
179
+ return unless response.status == 200
180
+
181
+ JSON.parse(response.body).fetch("versions")
182
+ end
183
+ end
184
+
185
+ def dependency_urls
186
+ @dependency_urls ||=
187
+ RepositoryFinder.new(
188
+ dependency: dependency,
189
+ credentials: credentials,
190
+ config_file: nuget_config
191
+ ).dependency_urls
192
+ end
193
+
194
+ def ignore_reqs
195
+ ignored_versions.map { |req| requirement_class.new(req.split(",")) }
196
+ end
197
+
198
+ def nuget_config
199
+ @nuget_config ||=
200
+ dependency_files.find { |f| f.name.casecmp("nuget.config").zero? }
201
+ end
202
+
203
+ def sanitized_name
204
+ dependency.name.downcase
205
+ end
206
+
207
+ def version_class
208
+ Nuget::Version
209
+ end
210
+
211
+ def requirement_class
212
+ Nuget::Requirement
213
+ end
214
+
215
+ def excon_defaults
216
+ # For large JSON files we sometimes need a little longer than for
217
+ # other languages. For example, see:
218
+ # https://dotnet.myget.org/F/aspnetcore-dev/api/v3/query?
219
+ # q=microsoft.aspnetcore.mvc&prerelease=true
220
+ SharedHelpers.excon_defaults.merge(
221
+ connect_timeout: 10,
222
+ write_timeout: 10,
223
+ read_timeout: 10
224
+ )
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end