dependabot-cargo 0.389.0 → 0.390.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 336de17c7a677e7928495324abae0c8f8a5aae4c5356c406b772f5fb14680d09
4
- data.tar.gz: 1bb5fd2e4cee631d59ef78516afec7e89dfc560f74b0433592f91d6fc36c2822
3
+ metadata.gz: 7c5589c34a68938c08db740569195c7374cc0f991efb982533f5525703dcafd8
4
+ data.tar.gz: 47e442e693cbbe9c2dde8882a08f5b52e72e11f1821f72704ce9e5a77b553da1
5
5
  SHA512:
6
- metadata.gz: ee25b6339012047a96906577f0c4b2a43f0b00ad5b065e3e7105aa83d40ef195acb6abdb2555b73c44415b814832e770fdb49e413e420ccff982d70cb46eecb7
7
- data.tar.gz: 94aa126678f0926aab9b953de5dad986d559a5632c98f510dc166419a9dbd8bf23606d48a8c4d9908c6ad5f8a9daa1e68b4bab36874e60445dcd2d857d9a8784
6
+ metadata.gz: 413ed1890c6b9fbaaba4f22315ca2c58d16293b8f50e20edfbc93f18eacea2c9242c5079eb1f6de745e89e08c5be1bae727ea8d466b6b3bffc8e411157f9f552
7
+ data.tar.gz: cc1c93979034f78bd6ac629708d55ff76a3802f0d0d6de6215aa093dfc710cab96caf082c4e50c917c13e9cf30eb0cfe22257764dca1f5087b2fb2406137eb30
@@ -19,7 +19,7 @@ require "dependabot/cargo/package_manager"
19
19
  # - https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html
20
20
  module Dependabot
21
21
  module Cargo
22
- class FileParser < Dependabot::FileParsers::Base
22
+ class FileParser < Dependabot::FileParsers::Base # rubocop:disable Metrics/ClassLength
23
23
  require "dependabot/file_parsers/base/dependency_set"
24
24
 
25
25
  DEPENDENCY_TYPES =
@@ -34,6 +34,7 @@ module Dependabot
34
34
  dependency_set += lockfile_dependencies if lockfile
35
35
 
36
36
  dependencies = dependency_set.dependencies
37
+ add_lockfile_package_metadata(dependencies) if lockfile
37
38
 
38
39
  # TODO: Handle patched dependencies
39
40
  dependencies.reject! { |d| patched_dependencies.include?(d.name) }
@@ -208,8 +209,6 @@ module Dependabot
208
209
  T.cast(lockfile_content.fetch("package", []), T::Array[T::Hash[String, T.anything]]).each do |package_details|
209
210
  next unless T.cast(package_details["source"], T.nilable(String))
210
211
 
211
- # TODO: This isn't quite right, as it will only give us one
212
- # version of each dependency (when in fact there are many)
213
212
  dependency_set << Dependency.new(
214
213
  name: T.cast(package_details["name"], String),
215
214
  version: version_from_lockfile_details(package_details),
@@ -221,6 +220,29 @@ module Dependabot
221
220
  dependency_set
222
221
  end
223
222
 
223
+ sig { params(dependencies: T::Array[Dependabot::Dependency]).void }
224
+ def add_lockfile_package_metadata(dependencies)
225
+ packages_by_name = lockfile_packages(parsed_file(T.must(lockfile)))
226
+ .select { |package| package_field(package, "source") }
227
+ .group_by { |package| T.must(package_field(package, "name")) }
228
+
229
+ dependencies.each do |dependency|
230
+ packages = packages_by_name.fetch(dependency.name, [])
231
+ next if dependency.top_level?
232
+ next unless packages.length > 1
233
+
234
+ dependency.metadata[:all_versions] = packages.map do |package|
235
+ Dependency.new(
236
+ name: dependency.name,
237
+ version: version_from_lockfile_details(package),
238
+ package_manager: "cargo",
239
+ requirements: [],
240
+ metadata: { cargo_package_source: T.must(package_field(package, "source")) }
241
+ )
242
+ end
243
+ end
244
+ end
245
+
224
246
  sig { returns(T::Array[String]) }
225
247
  def patched_dependencies
226
248
  root_manifest = manifest_files.find { |f| f.name == "Cargo.toml" }
@@ -35,7 +35,8 @@ module Dependabot
35
35
  @dependencies = T.let(dependencies, T::Array[Dependabot::Dependency])
36
36
  @dependency_files = T.let(dependency_files, T::Array[Dependabot::DependencyFile])
37
37
  @credentials = T.let(credentials, T::Array[Dependabot::Credential])
38
- @custom_specification = T.let(nil, T.nilable(String))
38
+ @custom_specifications = T.let({}, T::Hash[String, String])
39
+ @current_dependency = T.let(nil, T.nilable(Dependabot::Dependency))
39
40
  @git_ssh_requirements_to_swap = T.let(nil, T.nilable(T::Hash[String, String]))
40
41
  @manifest_files = T.let(nil, T.nilable(T::Array[Dependabot::DependencyFile]))
41
42
  @path_dependency_files = T.let(nil, T.nilable(T::Array[Dependabot::DependencyFile]))
@@ -49,31 +50,11 @@ module Dependabot
49
50
  base_directory = T.must(dependency_files.first).directory
50
51
  SharedHelpers.in_a_temporary_directory(base_directory) do
51
52
  write_temporary_dependency_files
52
-
53
- SharedHelpers.with_git_configured(credentials: credentials) do
54
- # Shell out to Cargo, which handles everything for us, and does
55
- # so without doing an install (so it's fast).
56
- run_cargo_command("cargo update -p #{dependency_spec}", fingerprint: "cargo update -p <dependency_spec>")
57
- end
53
+ run_updates
58
54
 
59
55
  updated_lockfile = File.read("Cargo.lock")
60
56
  updated_lockfile = post_process_lockfile(updated_lockfile)
61
-
62
- next updated_lockfile if updated_lockfile.include?(desired_lockfile_content)
63
-
64
- # If exact version match fails, accept any update
65
- if dependency_updated?(updated_lockfile, dependency)
66
- actual_version = extract_actual_version(updated_lockfile, dependency.name)
67
- if actual_version && actual_version != dependency.version
68
- Dependabot.logger.info(
69
- "Cargo selected version #{actual_version} instead of #{dependency.version} for #{dependency.name} " \
70
- "due to dependency constraints"
71
- )
72
- end
73
- next updated_lockfile
74
- end
75
-
76
- raise "Failed to update #{dependency.name}!"
57
+ validate_updates(updated_lockfile)
77
58
  end
78
59
  rescue Dependabot::SharedHelpers::HelperSubprocessFailed => e
79
60
  retry if better_specification_needed?(e)
@@ -91,10 +72,67 @@ module Dependabot
91
72
  sig { returns(T::Array[Dependabot::Credential]) }
92
73
  attr_reader :credentials
93
74
 
94
- # Currently, there will only be a single updated dependency
95
75
  sig { returns(Dependabot::Dependency) }
96
76
  def dependency
97
- T.must(dependencies.first)
77
+ @current_dependency || T.must(dependencies.first)
78
+ end
79
+
80
+ sig { void }
81
+ def run_updates
82
+ SharedHelpers.with_git_configured(credentials: credentials) do
83
+ # Shell out to Cargo, which handles everything for us, and does
84
+ # so without doing an install (so it's fast).
85
+ dependencies.each do |dependency_to_update|
86
+ @current_dependency = dependency_to_update
87
+ next if previous_line_already_replaced?
88
+
89
+ run_cargo_command(
90
+ "cargo update -p #{dependency_spec}",
91
+ fingerprint: "cargo update -p <dependency_spec>"
92
+ )
93
+ end
94
+ end
95
+ end
96
+
97
+ # An earlier command in this run may already have resolved this
98
+ # dependency's line, in which case `cargo update -p name:version`
99
+ # would match no package and fail hard. Skip the command when the
100
+ # working lockfile has no entry left at the previous version and let
101
+ # validate_updates check the outcome.
102
+ sig { returns(T::Boolean) }
103
+ def previous_line_already_replaced?
104
+ previous_version = dependency.previous_version
105
+ return false unless previous_version
106
+ return false if previous_version == dependency.version || git_dependency?
107
+ return false unless version_class.correct?(previous_version)
108
+
109
+ package_version_count(File.read("Cargo.lock"), dependency, previous_version).zero?
110
+ end
111
+
112
+ sig { params(updated_lockfile: String).returns(String) }
113
+ def validate_updates(updated_lockfile)
114
+ dependencies.each do |updated_dependency|
115
+ @current_dependency = updated_dependency
116
+ validate_dependency_update(updated_lockfile)
117
+ end
118
+
119
+ updated_lockfile
120
+ end
121
+
122
+ sig { params(updated_lockfile: String).void }
123
+ def validate_dependency_update(updated_lockfile)
124
+ return if desired_version_present?(updated_lockfile)
125
+ return if git_line_resolved_differently?(updated_lockfile)
126
+
127
+ raise "Failed to update #{dependency.name}!" unless dependency_updated?(updated_lockfile, dependency)
128
+
129
+ actual_version = extract_actual_version(updated_lockfile, dependency)
130
+ return unless actual_version && actual_version != dependency.version
131
+
132
+ Dependabot.logger.info(
133
+ "Cargo selected version #{actual_version} instead of #{dependency.version} " \
134
+ "for #{dependency.name} due to dependency constraints"
135
+ )
98
136
  end
99
137
 
100
138
  sig { params(error: StandardError).returns(T.noreturn) }
@@ -144,7 +182,7 @@ module Dependabot
144
182
  # rubocop:disable Metrics/AbcSize
145
183
  sig { params(error: StandardError).returns(T::Boolean) }
146
184
  def better_specification_needed?(error)
147
- return false if @custom_specification
185
+ return false if custom_specification
148
186
  return false unless error.message.match?(/specification .* is ambigu/)
149
187
 
150
188
  spec_options = error.message.gsub(/.*following:\n/m, "")
@@ -157,7 +195,7 @@ module Dependabot
157
195
  end
158
196
 
159
197
  if ver && spec_options.one? { |s| s.end_with?(ver) }
160
- @custom_specification = spec_options.find { |s| s.end_with?(ver) }
198
+ @custom_specifications[dependency_identity] = T.must(spec_options.find { |s| s.end_with?(ver) })
161
199
  return true
162
200
  elsif ver && spec_options.count { |s| s.end_with?(ver) } > 1
163
201
  spec_options.select! { |s| s.end_with?(ver) }
@@ -168,7 +206,7 @@ module Dependabot
168
206
  spec_options.select! { |s| s.include?(T.must(git_source_url)) }
169
207
  end
170
208
 
171
- @custom_specification = spec_options.first
209
+ @custom_specifications[dependency_identity] = T.must(spec_options.first)
172
210
  true
173
211
  end
174
212
  # rubocop:enable Metrics/AbcSize
@@ -177,7 +215,7 @@ module Dependabot
177
215
 
178
216
  sig { returns(String) }
179
217
  def dependency_spec
180
- return @custom_specification if @custom_specification
218
+ return T.must(custom_specification) if custom_specification
181
219
 
182
220
  spec = dependency.name
183
221
 
@@ -190,12 +228,27 @@ module Dependabot
190
228
  spec
191
229
  end
192
230
 
231
+ sig { returns(T.nilable(String)) }
232
+ def custom_specification
233
+ @custom_specifications[dependency_identity]
234
+ end
235
+
236
+ sig { returns(String) }
237
+ def dependency_identity
238
+ [
239
+ dependency.name,
240
+ dependency.previous_version,
241
+ dependency.version,
242
+ dependency.metadata[:cargo_package_source]
243
+ ].join("\0")
244
+ end
245
+
193
246
  sig { returns(T.nilable(String)) }
194
247
  def git_previous_version
195
248
  TomlRB.parse(lockfile.content)
196
249
  .fetch("package", [])
197
250
  .select { |p| p["name"] == dependency.name }
198
- .find { |p| p["source"].end_with?(dependency.previous_version) }
251
+ .find { |p| p["source"]&.end_with?(dependency.previous_version) }
199
252
  &.fetch("version")
200
253
  end
201
254
 
@@ -213,6 +266,24 @@ module Dependabot
213
266
  %(name = "#{dependency.name}"\nversion = "#{dependency.version}")
214
267
  end
215
268
 
269
+ # The desired name/version pair may also appear under another source
270
+ # (e.g. a git fork at the released version), so scope the check to the
271
+ # locked package's source identity for numeric versions. Git
272
+ # dependencies keep the SHA substring check.
273
+ sig { params(updated_lockfile: String).returns(T::Boolean) }
274
+ def desired_version_present?(updated_lockfile)
275
+ version = T.must(dependency.version)
276
+ if git_dependency?
277
+ # Scope to the dependency's own git identity: another ref of the
278
+ # same repository already at the expected commit must not satisfy
279
+ # the check for the targeted line.
280
+ return git_source_shas(updated_lockfile).any? { |sha| sha.start_with?(version) }
281
+ end
282
+ return updated_lockfile.include?(desired_lockfile_content) unless version_class.correct?(version)
283
+
284
+ package_version_count(updated_lockfile, dependency, version).positive?
285
+ end
286
+
216
287
  sig { params(command: String, fingerprint: String).void }
217
288
  def run_cargo_command(command, fingerprint:)
218
289
  start = Time.now
@@ -578,43 +649,149 @@ module Dependabot
578
649
  def dependency_updated?(lockfile_content, dependency)
579
650
  return false unless dependency.previous_version
580
651
 
581
- # For multiple versions case, we need to check the specific entry
582
- # that corresponds to our dependency (the one used by our package)
652
+ target_line_versions(lockfile_content, dependency).any? do |version|
653
+ dependency.version_class.new(version) > dependency.version_class.new(T.must(dependency.previous_version))
654
+ end
655
+ end
656
+
657
+ sig do
658
+ params(lockfile_content: String, dependency: Dependabot::Dependency)
659
+ .returns(T.nilable(String))
660
+ end
661
+ def extract_actual_version(lockfile_content, dependency)
662
+ target_line_versions(lockfile_content, dependency).max_by do |version|
663
+ dependency.version_class.new(version)
664
+ end
665
+ end
666
+
667
+ sig do
668
+ params(lockfile_content: String, dependency: Dependabot::Dependency)
669
+ .returns(T::Array[String])
670
+ end
671
+ def target_line_versions(lockfile_content, dependency)
672
+ target_version = dependency.version
673
+ return [] unless target_version && dependency.version_class.correct?(target_version)
674
+
675
+ requirements = [dependency.previous_version, target_version].compact.uniq.filter_map do |version|
676
+ dependency.requirement_class.new(version) if dependency.version_class.correct?(version)
677
+ end
678
+ dependency_lockfile_entries(lockfile_content, dependency).filter_map do |entry|
679
+ version = entry[/^version = "([^"]+)"$/, 1]
680
+ next unless version && dependency.version_class.correct?(version)
681
+
682
+ parsed_version = dependency.version_class.new(version)
683
+ next unless requirements.any? { |requirement| requirement.satisfied_by?(parsed_version) }
684
+
685
+ version
686
+ end
687
+ end
688
+
689
+ sig do
690
+ params(lockfile_content: String, dependency: Dependabot::Dependency)
691
+ .returns(T::Array[String])
692
+ end
693
+ def dependency_lockfile_entries(lockfile_content, dependency)
583
694
  entries = T.let([], T::Array[String])
584
695
  lockfile_content.scan(LOCKFILE_ENTRY_REGEX) do
585
696
  entries << Regexp.last_match.to_s
586
697
  end
587
- entries.select! { |entry| entry.include?("name = \"#{dependency.name}\"") }
698
+ entries.select! { |entry| entry.match?(/^name = "#{Regexp.escape(dependency.name)}"$/) }
699
+
700
+ source = dependency.metadata[:cargo_package_source]
701
+ if source
702
+ entries.select! { |entry| entry.include?(%(source = "#{source}")) }
703
+ else
704
+ # Without an exact source identity, restrict to registry-style
705
+ # entries: git-sourced copies and source-less workspace or path
706
+ # packages must not satisfy the checks. Registry entries of any
707
+ # protocol (registry+, sparse+) stay visible.
708
+ entries.select! { |entry| entry.match?(/^source = "(?!git\+)/) }
709
+ end
710
+ entries
711
+ end
588
712
 
589
- # Check if any entry has a version newer than the previous version
590
- entries.any? do |entry|
591
- version_match = entry.match(/version = "([^"]+)"/)
592
- next false unless version_match
713
+ # A git dependency can legitimately resolve to a different commit than
714
+ # the checker expected (e.g. the tracked branch advanced between the
715
+ # check and this update). Accept the update when the dependency's git
716
+ # line moved off the previous commit, mirroring the version tolerance
717
+ # for registry dependencies.
718
+ sig { params(updated_lockfile: String).returns(T::Boolean) }
719
+ def git_line_resolved_differently?(updated_lockfile)
720
+ return false unless git_dependency?
593
721
 
594
- new_version = version_match[1]
595
- # Only consider it updated if it's newer than the previous version
596
- # and either matches our expected version or is at least newer than previous
597
- dependency.version_class.new(new_version) > dependency.version_class.new(dependency.previous_version)
598
- end
722
+ previous_sha = dependency.previous_version
723
+ return false unless previous_sha
724
+
725
+ shas = git_source_shas(updated_lockfile)
726
+ # SHA comparisons use prefix semantics, matching the update
727
+ # checkers: previous_version may be an abbreviated commit.
728
+ return false if shas.empty? || shas.any? { |sha| sha.start_with?(previous_sha) }
729
+
730
+ Dependabot.logger.info(
731
+ "Cargo resolved #{dependency.name} to #{shas.join(', ')} " \
732
+ "instead of #{dependency.version} for the tracked git reference"
733
+ )
734
+ true
599
735
  end
600
736
 
601
- sig { params(lockfile_content: String, dependency_name: String).returns(T.nilable(String)) }
602
- def extract_actual_version(lockfile_content, dependency_name)
737
+ sig { params(lockfile_content: String).returns(T::Array[String]) }
738
+ def git_source_shas(lockfile_content)
603
739
  entries = T.let([], T::Array[String])
604
740
  lockfile_content.scan(LOCKFILE_ENTRY_REGEX) do
605
741
  entries << Regexp.last_match.to_s
606
742
  end
607
- entries.select! { |entry| entry.include?("name = \"#{dependency_name}\"") }
743
+ entries.select! { |entry| entry.match?(/^name = "#{Regexp.escape(dependency.name)}"$/) }
744
+ entries.select! { |entry| entry.match?(/^source = "git\+/) }
608
745
 
609
- # Get the highest version from matching entries
610
- versions = entries.filter_map do |entry|
611
- version_match = entry.match(/version = "([^"]+)"/)
612
- version_match&.captures&.first
613
- end
746
+ scope_entries_to_git_identity(entries)
747
+ .filter_map { |entry| entry[/^source = "git\+[^"]*#([0-9a-f]+)"$/, 1] }
748
+ end
614
749
 
615
- return nil if versions.empty?
750
+ # Fail closed: the current identity serializes deterministically into
751
+ # the lockfile source string, so entries that don't match it are other
752
+ # identities, never fallbacks. An empty result means the expected
753
+ # identity is absent and validation must fail.
754
+ sig { params(entries: T::Array[String]).returns(T::Array[String]) }
755
+ def scope_entries_to_git_identity(entries)
756
+ url = git_source_detail(:url)
757
+ return entries unless url
758
+
759
+ entries.select { |entry| entry.match?(git_identity_pattern(url)) }
760
+ end
616
761
 
617
- versions.max_by { |v| version_class.new(v) }
762
+ # Anchors the URL and ref together at the start of the source value so
763
+ # a prefix-related repository URL cannot satisfy the match.
764
+ sig { params(url: String).returns(Regexp) }
765
+ def git_identity_pattern(url)
766
+ base = "^source = \"git\\+#{Regexp.escape(url)}"
767
+ branch = git_source_detail(:branch)
768
+ return /#{base}\?branch=#{Regexp.escape(branch)}#/ if branch
769
+
770
+ ref = git_source_detail(:ref)
771
+ return /#{base}\?(?:tag|rev)=#{Regexp.escape(ref)}#/ if ref
772
+
773
+ /#{base}#/
774
+ end
775
+
776
+ # The updated lockfile is serialized with the dependency's current git
777
+ # identity (a ref change updates the source string), so prefer the
778
+ # current requirement's git source as a whole and use the previous one
779
+ # only when no current git source exists.
780
+ sig { params(key: Symbol).returns(T.nilable(String)) }
781
+ def git_source_detail(key)
782
+ source = dependency.requirements.find { |r| r.dig(:source, :type) == "git" }&.dig(:source) ||
783
+ dependency.previous_requirements&.find { |r| r.dig(:source, :type) == "git" }&.dig(:source)
784
+ source&.dig(key)
785
+ end
786
+
787
+ sig do
788
+ params(lockfile_content: String, dependency: Dependabot::Dependency, version: String)
789
+ .returns(Integer)
790
+ end
791
+ def package_version_count(lockfile_content, dependency, version)
792
+ dependency_lockfile_entries(lockfile_content, dependency).count do |entry|
793
+ entry.match?(/^version = "#{Regexp.escape(version)}"$/)
794
+ end
618
795
  end
619
796
  end
620
797
  # rubocop:enable Metrics/ClassLength
@@ -106,22 +106,140 @@ module Dependabot
106
106
  versions = TomlRB.parse(lockfile_content).fetch("package")
107
107
  .select { |p| p["name"] == dependency.name }
108
108
 
109
- updated_version =
110
- if dependency.top_level?
111
- versions.max_by { |p| version_class.new(p.fetch("version")) }
112
- else
113
- versions.min_by { |p| version_class.new(p.fetch("version")) }
114
- end
109
+ updated_version = updated_package(versions)
115
110
 
116
111
  return unless updated_version
117
112
 
118
113
  if git_dependency?
119
- updated_version.fetch("source").split("#").last
114
+ T.cast(updated_version.fetch("source"), String).split("#").last
120
115
  else
121
- updated_version.fetch("version")
116
+ T.cast(updated_version.fetch("version"), String)
122
117
  end
123
118
  end
124
119
 
120
+ sig do
121
+ params(
122
+ packages: T::Array[T::Hash[String, T.anything]]
123
+ ).returns(T.nilable(T::Hash[String, T.anything]))
124
+ end
125
+ def updated_package(packages)
126
+ if dependency.top_level?
127
+ packages.max_by do |package|
128
+ version_class.new(T.cast(package.fetch("version"), String))
129
+ end
130
+ else
131
+ updated_package_for_locked_identity(packages) ||
132
+ packages.min_by do |package|
133
+ version_class.new(T.cast(package.fetch("version"), String))
134
+ end
135
+ end
136
+ end
137
+
138
+ sig do
139
+ params(
140
+ updated_packages: T::Array[T::Hash[String, T.anything]]
141
+ ).returns(T.nilable(T::Hash[String, T.anything]))
142
+ end
143
+ def updated_package_for_locked_identity(updated_packages)
144
+ target = original_target_package
145
+ return unless target
146
+
147
+ source = T.cast(target["source"], T.nilable(String))
148
+ candidates = updated_package_candidates(updated_packages, target)
149
+
150
+ # Cargo can split a unified line across a version boundary, adding a
151
+ # new package while retaining the old one for dependents that still
152
+ # need it. A newly introduced identity is the update; the retained
153
+ # line only answers when nothing new appeared.
154
+ fresh = candidates.reject { |package| package_identity(package) == package_identity(target) }
155
+ return fresh.max_by { |package| version_class.new(T.cast(package.fetch("version"), String)) } if fresh.any?
156
+
157
+ candidates.find { |package| version_from_package(package) == dependency.version } ||
158
+ compatible_packages(updated_packages, source).max_by do |package|
159
+ version_class.new(T.cast(package.fetch("version"), String))
160
+ end
161
+ end
162
+
163
+ sig { returns(T.nilable(T::Hash[String, T.anything])) }
164
+ def original_target_package
165
+ return unless dependency.version
166
+
167
+ targets = original_packages_for_dependency.select do |package|
168
+ version_from_package(package) == dependency.version
169
+ end
170
+
171
+ package_source = T.cast(dependency.metadata[:cargo_package_source], T.nilable(String))
172
+ if package_source
173
+ targets.select! do |package|
174
+ T.cast(package["source"], T.nilable(String)) == package_source
175
+ end
176
+ end
177
+ targets.first if targets.one?
178
+ end
179
+
180
+ sig do
181
+ params(
182
+ updated_packages: T::Array[T::Hash[String, T.anything]],
183
+ target: T::Hash[String, T.anything]
184
+ ).returns(T::Array[T::Hash[String, T.anything]])
185
+ end
186
+ def updated_package_candidates(updated_packages, target)
187
+ target_source = T.cast(target["source"], T.nilable(String))
188
+ other_identities = original_packages_for_dependency
189
+ .map { |package| package_identity(package) }
190
+ target_index = other_identities.index(package_identity(target))
191
+ other_identities.delete_at(target_index) if target_index
192
+
193
+ updated_packages
194
+ .select { |package| T.cast(package["source"], T.nilable(String)) == target_source }
195
+ .reject { |package| other_identities.include?(package_identity(package)) }
196
+ end
197
+
198
+ sig { returns(T::Array[T::Hash[String, T.anything]]) }
199
+ def original_packages_for_dependency
200
+ original_lockfile_packages.select do |package|
201
+ T.cast(package["name"], T.nilable(String)) == dependency.name
202
+ end
203
+ end
204
+
205
+ sig { returns(T::Array[T::Hash[String, T.anything]]) }
206
+ def original_lockfile_packages
207
+ original_lockfile = original_dependency_files.find { |file| file.name == "Cargo.lock" }
208
+ return [] unless original_lockfile
209
+
210
+ T.cast(
211
+ TomlRB.parse(T.must(original_lockfile.content)).fetch("package", []),
212
+ T::Array[T::Hash[String, T.anything]]
213
+ )
214
+ end
215
+
216
+ sig do
217
+ params(
218
+ packages: T::Array[T::Hash[String, T.anything]],
219
+ source: T.nilable(String)
220
+ ).returns(T::Array[T::Hash[String, T.anything]])
221
+ end
222
+ def compatible_packages(packages, source)
223
+ requirement = Requirement.new(T.must(dependency.version))
224
+ packages.select do |package|
225
+ T.cast(package["source"], T.nilable(String)) == source &&
226
+ requirement.satisfied_by?(version_class.new(T.cast(package.fetch("version"), String)))
227
+ end
228
+ end
229
+
230
+ sig { params(package: T::Hash[String, T.anything]).returns(T::Array[T.anything]) }
231
+ def package_identity(package)
232
+ [package["name"], package["version"], package["source"]]
233
+ end
234
+
235
+ sig { params(package: T::Hash[String, T.anything]).returns(String) }
236
+ def version_from_package(package)
237
+ source = T.cast(package["source"], T.nilable(String))
238
+ return T.must(source.split("#").last) if source&.start_with?("git+")
239
+
240
+ T.cast(package.fetch("version"), String)
241
+ end
242
+
125
243
  # rubocop:disable Metrics/PerceivedComplexity
126
244
  # rubocop:disable Metrics/CyclomaticComplexity
127
245
  # rubocop:disable Metrics/AbcSize
@@ -491,8 +609,8 @@ module Dependabot
491
609
  TomlRB.parse(T.must(lockfile).content)
492
610
  .fetch("package", [])
493
611
  .select { |p| p["name"] == dependency.name }
494
- .find { |p| p["source"].end_with?(dependency.version) }
495
- .fetch("version")
612
+ .find { |p| p["source"]&.end_with?(dependency.version) }
613
+ &.fetch("version")
496
614
  end
497
615
 
498
616
  sig { returns(T.nilable(String)) }
@@ -10,7 +10,7 @@ require "dependabot/update_checkers/base"
10
10
 
11
11
  module Dependabot
12
12
  module Cargo
13
- class UpdateChecker < Dependabot::UpdateCheckers::Base
13
+ class UpdateChecker < Dependabot::UpdateCheckers::Base # rubocop:disable Metrics/ClassLength
14
14
  extend T::Sig
15
15
 
16
16
  require_relative "update_checker/latest_version_finder"
@@ -18,10 +18,50 @@ module Dependabot
18
18
  require_relative "update_checker/version_resolver"
19
19
  require_relative "update_checker/file_preparer"
20
20
 
21
+ sig { override.returns(T::Boolean) }
22
+ def up_to_date?
23
+ return super unless multiple_locked_versions?
24
+
25
+ raise Dependabot::AllVersionsIgnored if only_ignored_updates_remaining?
26
+
27
+ all_lines_current?
28
+ end
29
+
30
+ sig { override.params(requirements_to_unlock: T.nilable(Symbol)).returns(T::Boolean) }
31
+ def can_update?(requirements_to_unlock:)
32
+ return super unless multiple_locked_versions?
33
+
34
+ results, ignored_count = locked_version_results(false) do |checker|
35
+ checker.can_update?(requirements_to_unlock: requirements_to_unlock)
36
+ end
37
+ updatable = results.any?
38
+ raise Dependabot::AllVersionsIgnored if ignored_count.positive? && !updatable
39
+
40
+ updatable
41
+ end
42
+
43
+ sig do
44
+ override.params(requirements_to_unlock: T.nilable(Symbol))
45
+ .returns(T::Array[Dependabot::Dependency])
46
+ end
47
+ def updated_dependencies(requirements_to_unlock:)
48
+ return super unless multiple_locked_versions?
49
+
50
+ results, ignored_count = locked_version_results([]) do |checker|
51
+ checker.updated_dependencies(requirements_to_unlock: requirements_to_unlock)
52
+ end
53
+ updates = results.flatten
54
+ raise Dependabot::AllVersionsIgnored if ignored_count.positive? && updates.empty?
55
+
56
+ updates
57
+ end
58
+
21
59
  sig { override.returns(T.nilable(T.any(String, Gem::Version))) }
22
60
  def latest_version
23
61
  return if path_dependency?
24
62
 
63
+ raise Dependabot::AllVersionsIgnored if multiple_locked_versions? && only_ignored_updates_remaining?
64
+
25
65
  @latest_version = T.let(
26
66
  if git_dependency?
27
67
  latest_version_for_git_dependency
@@ -57,6 +97,8 @@ module Dependabot
57
97
 
58
98
  sig { override.returns(T.nilable(Gem::Version)) }
59
99
  def lowest_security_fix_version
100
+ raise Dependabot::AllVersionsIgnored if multiple_locked_versions? && only_ignored_updates_remaining?
101
+
60
102
  latest_version_finder.lowest_security_fix_version
61
103
  end
62
104
 
@@ -349,6 +391,113 @@ module Dependabot
349
391
  dependency.source_type == "path"
350
392
  end
351
393
 
394
+ sig { returns(T::Boolean) }
395
+ def multiple_locked_versions?
396
+ sources = locked_version_dependencies.map { |candidate| candidate.metadata[:cargo_package_source] }.uniq
397
+ !dependency.top_level? && locked_version_dependencies.length > 1 && sources.one?
398
+ end
399
+
400
+ sig { returns(T::Array[Dependabot::Dependency]) }
401
+ def locked_version_dependencies
402
+ @locked_version_dependencies ||= T.let(
403
+ begin
404
+ all_versions = dependency.metadata[:all_versions]
405
+ candidates = all_versions.is_a?(Array) ? all_versions.grep(Dependabot::Dependency) : []
406
+ numeric_candidates = candidates.select do |candidate|
407
+ !candidate.top_level? && version_class.correct?(candidate.version)
408
+ end
409
+ numeric_candidates.uniq { |candidate| [candidate.version, candidate.metadata[:cargo_package_source]] }
410
+ end,
411
+ T.nilable(T::Array[Dependabot::Dependency])
412
+ )
413
+ end
414
+
415
+ sig { returns(T::Array[Dependabot::Cargo::UpdateChecker]) }
416
+ def locked_version_checkers
417
+ @locked_version_checkers ||= T.let(
418
+ locked_version_dependencies_to_check.map do |locked_dependency|
419
+ self.class.new(
420
+ dependency: locked_dependency,
421
+ dependency_files: dependency_files,
422
+ repo_contents_path: repo_contents_path,
423
+ credentials: credentials,
424
+ ignored_versions: ignored_versions,
425
+ security_advisories: security_advisories,
426
+ raise_on_ignored: raise_on_ignored,
427
+ requirements_update_strategy: requirements_update_strategy,
428
+ dependency_group: dependency_group,
429
+ update_cooldown: update_cooldown,
430
+ options: options
431
+ )
432
+ end,
433
+ T.nilable(T::Array[Dependabot::Cargo::UpdateChecker])
434
+ )
435
+ end
436
+
437
+ # The operations layer only rescues AllVersionsIgnored around its
438
+ # preflight probes (latest_version, and lowest_security_fix_version for
439
+ # security jobs); anywhere else the error halts the whole update run.
440
+ # These helpers let those probes raise when ignored lines leave nothing
441
+ # actionable, so a mixed ignored/at-ceiling dependency surfaces as a
442
+ # clean ignored skip, and the aggregate overrides below can never be
443
+ # the first place the error appears.
444
+ sig { returns(T::Boolean) }
445
+ def only_ignored_updates_remaining?
446
+ evaluate_locked_line_currency
447
+ T.must(@only_ignored_updates_remaining)
448
+ end
449
+
450
+ sig { returns(T::Boolean) }
451
+ def all_lines_current?
452
+ evaluate_locked_line_currency
453
+ T.must(@all_lines_current)
454
+ end
455
+
456
+ sig { void }
457
+ def evaluate_locked_line_currency
458
+ return if defined?(@all_lines_current)
459
+
460
+ results, ignored_count = locked_version_results(true) do |checker|
461
+ checker.up_to_date? || checker.latest_resolvable_version&.to_s == checker.dependency.version
462
+ end
463
+ @all_lines_current = T.let(results.all?, T.nilable(T::Boolean))
464
+ @only_ignored_updates_remaining = T.let(ignored_count.positive? && results.all?, T.nilable(T::Boolean))
465
+ end
466
+
467
+ # Evaluates the block for every locked-line checker, substituting
468
+ # ignored_result for lines whose updates are all ignored. Returns the
469
+ # results alongside the ignored-line count so callers can re-raise
470
+ # AllVersionsIgnored when ignored lines exist and no other line yields
471
+ # an actionable result.
472
+ sig do
473
+ type_parameters(:U)
474
+ .params(
475
+ ignored_result: T.type_parameter(:U),
476
+ _blk: T.proc.params(checker: Dependabot::Cargo::UpdateChecker).returns(T.type_parameter(:U))
477
+ ).returns([T::Array[T.type_parameter(:U)], Integer])
478
+ end
479
+ def locked_version_results(ignored_result, &_blk)
480
+ ignored_count = 0
481
+ results = locked_version_checkers.map do |checker|
482
+ yield(checker)
483
+ rescue Dependabot::AllVersionsIgnored
484
+ ignored_count += 1
485
+ ignored_result
486
+ end
487
+
488
+ [results, ignored_count]
489
+ end
490
+
491
+ sig { returns(T::Array[Dependabot::Dependency]) }
492
+ def locked_version_dependencies_to_check
493
+ return locked_version_dependencies if security_advisories.empty?
494
+
495
+ locked_version_dependencies.select do |candidate|
496
+ version = version_class.new(T.must(candidate.version))
497
+ security_advisories.any? { |advisory| advisory.vulnerable?(version) }
498
+ end
499
+ end
500
+
352
501
  sig { returns(GitCommitChecker) }
353
502
  def git_commit_checker
354
503
  @git_commit_checker = T.let(
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dependabot-cargo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.389.0
4
+ version: 0.390.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dependabot
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.389.0
18
+ version: 0.390.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.389.0
25
+ version: 0.390.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: debug
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -266,7 +266,7 @@ licenses:
266
266
  - MIT
267
267
  metadata:
268
268
  bug_tracker_uri: https://github.com/dependabot/dependabot-core/issues
269
- changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.389.0
269
+ changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.390.0
270
270
  rdoc_options: []
271
271
  require_paths:
272
272
  - lib