dependabot-cargo 0.81.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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