dependabot-cargo 0.81.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,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "toml-rb"
4
+ require "dependabot/dependency_file"
5
+ require "dependabot/cargo/file_parser"
6
+ require "dependabot/cargo/update_checker"
7
+
8
+ module Dependabot
9
+ module Cargo
10
+ class UpdateChecker
11
+ # This class takes a set of dependency files and sanitizes them for use
12
+ # in UpdateCheckers::Rust::Cargo.
13
+ class FilePreparer
14
+ def initialize(dependency_files:, dependency:,
15
+ unlock_requirement: true,
16
+ replacement_git_pin: nil,
17
+ latest_allowable_version: nil)
18
+ @dependency_files = dependency_files
19
+ @dependency = dependency
20
+ @unlock_requirement = unlock_requirement
21
+ @replacement_git_pin = replacement_git_pin
22
+ @latest_allowable_version = latest_allowable_version
23
+ end
24
+
25
+ def prepared_dependency_files
26
+ files = []
27
+ files += manifest_files.map do |file|
28
+ DependencyFile.new(
29
+ name: file.name,
30
+ content: manifest_content_for_update_check(file),
31
+ directory: file.directory
32
+ )
33
+ end
34
+ files << lockfile if lockfile
35
+ files << toolchain if toolchain
36
+ files
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :dependency_files, :dependency, :replacement_git_pin,
42
+ :latest_allowable_version
43
+
44
+ def unlock_requirement?
45
+ @unlock_requirement
46
+ end
47
+
48
+ def replace_git_pin?
49
+ !replacement_git_pin.nil?
50
+ end
51
+
52
+ def manifest_content_for_update_check(file)
53
+ content = file.content
54
+
55
+ unless file.support_file?
56
+ content = replace_version_constraint(content, file.name)
57
+ content = replace_git_pin(content) if replace_git_pin?
58
+ end
59
+
60
+ content = replace_ssh_urls(content)
61
+
62
+ content
63
+ end
64
+
65
+ # Note: We don't need to care about formatting in this method, since
66
+ # we're only using the manifest to find the latest resolvable version
67
+ def replace_version_constraint(content, filename)
68
+ parsed_manifest = TomlRB.parse(content)
69
+
70
+ Cargo::FileParser::DEPENDENCY_TYPES.each do |type|
71
+ next unless (req = parsed_manifest.dig(type, dependency.name))
72
+
73
+ updated_req = temporary_requirement_for_resolution(filename)
74
+
75
+ if req.is_a?(Hash)
76
+ parsed_manifest[type][dependency.name]["version"] = updated_req
77
+ else
78
+ parsed_manifest[type][dependency.name] = updated_req
79
+ end
80
+ end
81
+
82
+ TomlRB.dump(parsed_manifest)
83
+ end
84
+
85
+ def replace_git_pin(content)
86
+ parsed_manifest = TomlRB.parse(content)
87
+
88
+ Cargo::FileParser::DEPENDENCY_TYPES.each do |type|
89
+ next unless (req = parsed_manifest.dig(type, dependency.name))
90
+ next unless req.is_a?(Hash)
91
+ next unless [req["tag"], req["rev"]].compact.uniq.count == 1
92
+
93
+ if req["tag"]
94
+ parsed_manifest[type][dependency.name]["tag"] =
95
+ replacement_git_pin
96
+ end
97
+
98
+ if req["rev"]
99
+ parsed_manifest[type][dependency.name]["rev"] =
100
+ replacement_git_pin
101
+ end
102
+ end
103
+
104
+ TomlRB.dump(parsed_manifest)
105
+ end
106
+
107
+ def replace_ssh_urls(content)
108
+ parsed_manifest = TomlRB.parse(content)
109
+
110
+ Cargo::FileParser::DEPENDENCY_TYPES.each do |type|
111
+ (parsed_manifest[type] || {}).each do |_, details|
112
+ next unless details.is_a?(Hash)
113
+ next unless details["git"]
114
+
115
+ details["git"] = details["git"].
116
+ gsub(%r{ssh://git@(.*?)/}, 'https://\1/')
117
+ end
118
+ end
119
+
120
+ TomlRB.dump(parsed_manifest)
121
+ end
122
+
123
+ def temporary_requirement_for_resolution(filename)
124
+ original_req = dependency.requirements.
125
+ find { |r| r.fetch(:file) == filename }&.
126
+ fetch(:requirement)
127
+
128
+ lower_bound_req =
129
+ if original_req && !unlock_requirement?
130
+ original_req
131
+ else
132
+ ">= #{lower_bound_version}"
133
+ end
134
+
135
+ unless Cargo::Version.correct?(latest_allowable_version) &&
136
+ Cargo::Version.new(latest_allowable_version) >=
137
+ Cargo::Version.new(lower_bound_version)
138
+ return lower_bound_req
139
+ end
140
+
141
+ lower_bound_req + ", <= #{latest_allowable_version}"
142
+ end
143
+
144
+ def lower_bound_version
145
+ @lower_bound_version ||=
146
+ if git_dependency? && git_dependency_version
147
+ git_dependency_version
148
+ elsif !git_dependency? && dependency.version
149
+ dependency.version
150
+ else
151
+ version_from_requirement =
152
+ dependency.requirements.map { |r| r.fetch(:requirement) }.
153
+ compact.
154
+ flat_map { |req_str| Cargo::Requirement.new(req_str) }.
155
+ flat_map(&:requirements).
156
+ reject { |req_array| req_array.first.start_with?("<") }.
157
+ map(&:last).
158
+ max&.to_s
159
+
160
+ version_from_requirement || 0
161
+ end
162
+ end
163
+
164
+ def git_dependency_version
165
+ return unless lockfile
166
+
167
+ TomlRB.parse(lockfile.content).
168
+ fetch("package", []).
169
+ select { |p| p["name"] == dependency.name }.
170
+ find { |p| p["source"].end_with?(dependency.version) }.
171
+ fetch("version")
172
+ end
173
+
174
+ def manifest_files
175
+ @manifest_files ||=
176
+ dependency_files.select { |f| f.name.end_with?("Cargo.toml") }
177
+
178
+ raise "No Cargo.toml!" if @manifest_files.none?
179
+
180
+ @manifest_files
181
+ end
182
+
183
+ def lockfile
184
+ @lockfile ||= dependency_files.find { |f| f.name == "Cargo.lock" }
185
+ end
186
+
187
+ def toolchain
188
+ @toolchain ||=
189
+ dependency_files.find { |f| f.name == "rust-toolchain" }
190
+ end
191
+
192
+ def git_dependency?
193
+ GitCommitChecker.
194
+ new(dependency: dependency, credentials: []).
195
+ git_dependency?
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ ################################################################################
4
+ # For more details on rust version constraints, see: #
5
+ # - https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html #
6
+ # - https://steveklabnik.github.io/semver/semver/index.html #
7
+ ################################################################################
8
+
9
+ require "dependabot/cargo/update_checker"
10
+ require "dependabot/cargo/requirement"
11
+ require "dependabot/cargo/version"
12
+
13
+ module Dependabot
14
+ module Cargo
15
+ class UpdateChecker
16
+ class RequirementsUpdater
17
+ class UnfixableRequirement < StandardError; end
18
+
19
+ VERSION_REGEX = /[0-9]+(?:\.[A-Za-z0-9\-*]+)*/.freeze
20
+ ALLOWED_UPDATE_STRATEGIES =
21
+ %i(bump_versions bump_versions_if_necessary).freeze
22
+
23
+ def initialize(requirements:, updated_source:, update_strategy:,
24
+ library:, latest_version:, latest_resolvable_version:)
25
+ @requirements = requirements
26
+ @updated_source = updated_source
27
+ @update_strategy = update_strategy
28
+ @library = library
29
+
30
+ check_update_strategy
31
+
32
+ if latest_version && version_class.correct?(latest_version)
33
+ @latest_version = version_class.new(latest_version)
34
+ end
35
+
36
+ return unless latest_resolvable_version
37
+ return unless version_class.correct?(latest_resolvable_version)
38
+
39
+ @latest_resolvable_version =
40
+ version_class.new(latest_resolvable_version)
41
+ end
42
+
43
+ def updated_requirements
44
+ # Note: Order is important here. The FileUpdater needs the updated
45
+ # requirement at index `i` to correspond to the previous requirement
46
+ # at the same index.
47
+ requirements.map do |req|
48
+ req = req.merge(source: updated_source)
49
+ next req unless latest_resolvable_version
50
+ next req if req[:requirement].nil?
51
+
52
+ # TODO: Add a widen_ranges options
53
+ if update_strategy == :bump_versions_if_necessary
54
+ update_version_requirement_if_needed(req)
55
+ else
56
+ update_version_requirement(req)
57
+ end
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ attr_reader :requirements, :updated_source, :update_strategy,
64
+ :latest_version, :latest_resolvable_version
65
+
66
+ def library?
67
+ @library
68
+ end
69
+
70
+ def check_update_strategy
71
+ return if ALLOWED_UPDATE_STRATEGIES.include?(update_strategy)
72
+
73
+ raise "Unknown update strategy: #{update_strategy}"
74
+ end
75
+
76
+ def target_version
77
+ library? ? latest_version : latest_resolvable_version
78
+ end
79
+
80
+ def update_version_requirement(req)
81
+ string_reqs = req[:requirement].split(",").map(&:strip)
82
+
83
+ new_requirement =
84
+ if (exact_req = exact_req(string_reqs))
85
+ # If there's an exact version, just return that
86
+ # (it will dominate any other requirements)
87
+ update_version_string(exact_req)
88
+ elsif (req_to_update = non_range_req(string_reqs)) &&
89
+ update_version_string(req_to_update) != req_to_update
90
+ # If a ~, ^, or * range needs to be updated, just return that
91
+ # (it will dominate any other requirements)
92
+ update_version_string(req_to_update)
93
+ else
94
+ # Otherwise, we must have a range requirement that needs
95
+ # updating. Update it, but keep other requirements too
96
+ update_range_requirements(string_reqs)
97
+ end
98
+
99
+ req.merge(requirement: new_requirement)
100
+ end
101
+
102
+ def update_version_requirement_if_needed(req)
103
+ string_reqs = req[:requirement].split(",").map(&:strip)
104
+ ruby_reqs = string_reqs.map { |r| Cargo::Requirement.new(r) }
105
+
106
+ return req if ruby_reqs.all? { |r| r.satisfied_by?(target_version) }
107
+
108
+ update_version_requirement(req)
109
+ end
110
+
111
+ def update_version_string(req_string)
112
+ req_string.sub(VERSION_REGEX) do |old_version|
113
+ # For pre-release versions, just use the full version string
114
+ next target_version.to_s if old_version.match?(/\d-/)
115
+
116
+ old_parts = old_version.split(".")
117
+ new_parts = target_version.to_s.split(".").
118
+ first(old_parts.count)
119
+ new_parts.map.with_index do |part, i|
120
+ old_parts[i] == "*" ? "*" : part
121
+ end.join(".")
122
+ end
123
+ end
124
+
125
+ def non_range_req(string_reqs)
126
+ string_reqs.find { |r| r.include?("*") || r.match?(/^[\d~^]/) }
127
+ end
128
+
129
+ def exact_req(string_reqs)
130
+ string_reqs.find { |r| Cargo::Requirement.new(r).exact? }
131
+ end
132
+
133
+ def update_range_requirements(string_reqs)
134
+ string_reqs.map do |req|
135
+ next req unless req.match?(/[<>]/)
136
+
137
+ ruby_req = Cargo::Requirement.new(req)
138
+ next req if ruby_req.satisfied_by?(target_version)
139
+
140
+ raise UnfixableRequirement if req.start_with?(">")
141
+
142
+ req.sub(VERSION_REGEX) do |old_version|
143
+ update_greatest_version(old_version, target_version)
144
+ end
145
+ end.join(", ")
146
+ rescue UnfixableRequirement
147
+ :unfixable
148
+ end
149
+
150
+ def update_greatest_version(old_version, version_to_be_permitted)
151
+ version = version_class.new(old_version)
152
+ version = version.release if version.prerelease?
153
+
154
+ index_to_update =
155
+ version.segments.map.with_index { |seg, i| seg.zero? ? 0 : i }.max
156
+
157
+ version.segments.map.with_index do |_, index|
158
+ if index < index_to_update
159
+ version_to_be_permitted.segments[index]
160
+ elsif index == index_to_update
161
+ version_to_be_permitted.segments[index] + 1
162
+ else 0
163
+ end
164
+ end.join(".")
165
+ end
166
+
167
+ def version_class
168
+ Cargo::Version
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "toml-rb"
4
+ require "dependabot/shared_helpers"
5
+ require "dependabot/cargo/update_checker"
6
+ require "dependabot/cargo/version"
7
+ require "dependabot/errors"
8
+
9
+ module Dependabot
10
+ module Cargo
11
+ class UpdateChecker
12
+ class VersionResolver
13
+ BRANCH_NOT_FOUND_REGEX =
14
+ /failed to find branch `(?<branch>[^`]+)`/.freeze
15
+
16
+ def initialize(dependency:, credentials:,
17
+ original_dependency_files:, prepared_dependency_files:)
18
+ @dependency = dependency
19
+ @prepared_dependency_files = prepared_dependency_files
20
+ @original_dependency_files = original_dependency_files
21
+ @credentials = credentials
22
+ end
23
+
24
+ def latest_resolvable_version
25
+ @latest_resolvable_version ||= fetch_latest_resolvable_version
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :dependency, :credentials,
31
+ :prepared_dependency_files, :original_dependency_files
32
+
33
+ def fetch_latest_resolvable_version
34
+ base_directory = prepared_dependency_files.first.directory
35
+ SharedHelpers.in_a_temporary_directory(base_directory) do
36
+ write_temporary_dependency_files
37
+
38
+ SharedHelpers.with_git_configured(credentials: credentials) do
39
+ # Shell out to Cargo, which handles everything for us, and does
40
+ # so without doing an install (so it's fast).
41
+ command = "cargo update -p #{dependency_spec} --verbose"
42
+ run_cargo_command(command)
43
+ end
44
+
45
+ new_lockfile_content = File.read("Cargo.lock")
46
+ updated_version = get_version_from_lockfile(new_lockfile_content)
47
+
48
+ return if updated_version.nil?
49
+ return updated_version if git_dependency?
50
+
51
+ version_class.new(updated_version)
52
+ end
53
+ rescue SharedHelpers::HelperSubprocessFailed => error
54
+ handle_cargo_errors(error)
55
+ end
56
+
57
+ def get_version_from_lockfile(lockfile_content)
58
+ versions = TomlRB.parse(lockfile_content).fetch("package").
59
+ select { |p| p["name"] == dependency.name }
60
+
61
+ updated_version =
62
+ if dependency.top_level?
63
+ versions.max_by { |p| version_class.new(p.fetch("version")) }
64
+ else
65
+ versions.min_by { |p| version_class.new(p.fetch("version")) }
66
+ end
67
+
68
+ if git_dependency?
69
+ updated_version.fetch("source").split("#").last
70
+ else
71
+ updated_version.fetch("version")
72
+ end
73
+ end
74
+
75
+ def dependency_spec
76
+ spec = dependency.name
77
+
78
+ if git_dependency?
79
+ spec += ":#{git_dependency_version}" if git_dependency_version
80
+ elsif dependency.version
81
+ spec += ":#{dependency.version}"
82
+ end
83
+
84
+ spec
85
+ end
86
+
87
+ def run_cargo_command(command)
88
+ raw_response = nil
89
+ IO.popen(command, err: %i(child out)) do |process|
90
+ raw_response = process.read
91
+ end
92
+
93
+ # Raise an error with the output from the shell session if Cargo
94
+ # returns a non-zero status
95
+ return if $CHILD_STATUS.success?
96
+
97
+ raise SharedHelpers::HelperSubprocessFailed.new(
98
+ raw_response,
99
+ command
100
+ )
101
+ end
102
+
103
+ def write_temporary_dependency_files(prepared: true)
104
+ write_manifest_files(prepared: prepared)
105
+
106
+ File.write(lockfile.name, lockfile.content) if lockfile
107
+ File.write(toolchain.name, toolchain.content) if toolchain
108
+ end
109
+
110
+ def handle_cargo_errors(error)
111
+ if error.message.include?("does not have these features")
112
+ # TODO: Ideally we should update the declaration not to ask
113
+ # for the specified features
114
+ return nil
115
+ end
116
+
117
+ if error.message.match?(BRANCH_NOT_FOUND_REGEX)
118
+ branch = error.message.match(BRANCH_NOT_FOUND_REGEX).
119
+ named_captures.fetch("branch")
120
+ raise Dependabot::BranchNotFound, branch
121
+ end
122
+
123
+ if resolvability_error?(error.message)
124
+ raise Dependabot::DependencyFileNotResolvable, error.message
125
+ end
126
+
127
+ raise error
128
+ end
129
+
130
+ def resolvability_error?(message)
131
+ return true if message.include?("failed to parse lock")
132
+ return true if message.include?("believes it's in a workspace")
133
+ return true if message.include?("wasn't a root")
134
+ return true if message.include?("requires a nightly version")
135
+ return true if message.match?(/feature `[^\`]+` is required/)
136
+
137
+ !original_requirements_resolvable?
138
+ end
139
+
140
+ def original_requirements_resolvable?
141
+ base_directory = original_dependency_files.first.directory
142
+ SharedHelpers.in_a_temporary_directory(base_directory) do
143
+ write_temporary_dependency_files(prepared: false)
144
+
145
+ SharedHelpers.with_git_configured(credentials: credentials) do
146
+ command = "cargo update -p #{dependency_spec} --verbose"
147
+ run_cargo_command(command)
148
+ end
149
+ end
150
+
151
+ true
152
+ rescue SharedHelpers::HelperSubprocessFailed => error
153
+ raise unless error.message.include?("no matching version") ||
154
+ error.message.include?("failed to select a version")
155
+
156
+ false
157
+ end
158
+
159
+ def write_manifest_files(prepared: true)
160
+ manifest_files = if prepared then prepared_manifest_files
161
+ else original_manifest_files
162
+ end
163
+
164
+ manifest_files.each do |file|
165
+ path = file.name
166
+ dir = Pathname.new(path).dirname
167
+ FileUtils.mkdir_p(dir)
168
+ File.write(file.name, sanitized_manifest_content(file.content))
169
+
170
+ FileUtils.mkdir_p(File.join(dir, "src"))
171
+ File.write(File.join(dir, "src/lib.rs"), dummy_app_content)
172
+ File.write(File.join(dir, "src/main.rs"), dummy_app_content)
173
+ end
174
+ end
175
+
176
+ def git_dependency_version
177
+ return unless lockfile
178
+
179
+ TomlRB.parse(lockfile.content).
180
+ fetch("package", []).
181
+ select { |p| p["name"] == dependency.name }.
182
+ find { |p| p["source"].end_with?(dependency.version) }.
183
+ fetch("version")
184
+ end
185
+
186
+ def dummy_app_content
187
+ %{fn main() {\nprintln!("Hello, world!");\n}}
188
+ end
189
+
190
+ def sanitized_manifest_content(content)
191
+ object = TomlRB.parse(content)
192
+
193
+ package_name = object.dig("package", "name")
194
+ return content unless package_name&.match?(/[\{\}]/)
195
+
196
+ if lockfile
197
+ raise "Sanitizing name for pkg with lockfile. Investigate!"
198
+ end
199
+
200
+ object["package"]["name"] = "sanitized"
201
+ TomlRB.dump(object)
202
+ end
203
+
204
+ def prepared_manifest_files
205
+ @prepared_manifest_files ||=
206
+ prepared_dependency_files.
207
+ select { |f| f.name.end_with?("Cargo.toml") }
208
+ end
209
+
210
+ def original_manifest_files
211
+ @original_manifest_files ||=
212
+ original_dependency_files.
213
+ select { |f| f.name.end_with?("Cargo.toml") }
214
+ end
215
+
216
+ def lockfile
217
+ @lockfile ||= prepared_dependency_files.
218
+ find { |f| f.name == "Cargo.lock" }
219
+ end
220
+
221
+ def toolchain
222
+ @toolchain ||= prepared_dependency_files.
223
+ find { |f| f.name == "rust-toolchain" }
224
+ end
225
+
226
+ def git_dependency?
227
+ GitCommitChecker.new(
228
+ dependency: dependency,
229
+ credentials: credentials
230
+ ).git_dependency?
231
+ end
232
+
233
+ def version_class
234
+ Cargo::Version
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end