dependabot-bundler 0.138.1 → 0.138.6

Sign up to get free protection for your applications and to get access to all the features.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/helpers/v1/build +2 -1
  3. data/helpers/v1/run.rb +16 -0
  4. data/helpers/v1/spec/native_spec_helper.rb +3 -0
  5. data/helpers/v2/.gitignore +1 -2
  6. data/helpers/v2/build +3 -1
  7. data/helpers/v2/lib/functions.rb +141 -13
  8. data/helpers/v2/lib/functions/conflicting_dependency_resolver.rb +86 -0
  9. data/helpers/v2/lib/functions/dependency_source.rb +86 -0
  10. data/helpers/v2/lib/functions/file_parser.rb +106 -0
  11. data/helpers/v2/lib/functions/force_updater.rb +167 -0
  12. data/helpers/v2/lib/functions/lockfile_updater.rb +224 -0
  13. data/helpers/v2/lib/functions/version_resolver.rb +140 -0
  14. data/helpers/v2/monkey_patches/definition_bundler_version_patch.rb +15 -0
  15. data/helpers/v2/monkey_patches/definition_ruby_version_patch.rb +20 -0
  16. data/helpers/v2/monkey_patches/git_source_patch.rb +62 -0
  17. data/helpers/v2/run.rb +15 -1
  18. data/helpers/v2/spec/functions/conflicting_dependency_resolver_spec.rb +133 -0
  19. data/helpers/v2/spec/functions/dependency_source_spec.rb +185 -0
  20. data/helpers/v2/spec/functions/file_parser_spec.rb +142 -0
  21. data/helpers/v2/spec/functions/version_resolver_spec.rb +97 -0
  22. data/helpers/v2/spec/functions_spec.rb +15 -27
  23. data/helpers/v2/spec/native_spec_helper.rb +5 -2
  24. data/helpers/v2/spec/shared_contexts.rb +60 -0
  25. data/lib/dependabot/bundler/file_updater/requirement_replacer.rb +4 -2
  26. data/lib/dependabot/bundler/update_checker/requirements_updater.rb +2 -2
  27. data/lib/dependabot/bundler/update_checker/shared_bundler_helpers.rb +3 -1
  28. metadata +18 -5
  29. data/helpers/v2/.bundle/config +0 -2
@@ -0,0 +1,106 @@
1
+ module Functions
2
+ class FileParser
3
+ def initialize(lockfile_name:)
4
+ @lockfile_name = lockfile_name
5
+ end
6
+
7
+ attr_reader :lockfile_name
8
+
9
+ def parsed_gemfile(gemfile_name:)
10
+ Bundler::Definition.build(gemfile_name, nil, {}).
11
+ dependencies.select(&:current_platform?).
12
+ reject { |dep| dep.source.is_a?(Bundler::Source::Gemspec) }.
13
+ map(&method(:serialize_bundler_dependency))
14
+ end
15
+
16
+ def parsed_gemspec(gemspec_name:)
17
+ Bundler.load_gemspec_uncached(gemspec_name).
18
+ dependencies.
19
+ map(&method(:serialize_bundler_dependency))
20
+ end
21
+
22
+ private
23
+
24
+ def lockfile
25
+ return @lockfile if defined?(@lockfile)
26
+
27
+ @lockfile =
28
+ begin
29
+ return unless lockfile_name && File.exist?(lockfile_name)
30
+
31
+ File.read(lockfile_name)
32
+ end
33
+ end
34
+
35
+ def parsed_lockfile
36
+ return unless lockfile
37
+
38
+ @parsed_lockfile ||= Bundler::LockfileParser.new(lockfile)
39
+ end
40
+
41
+ def source_from_lockfile(dependency_name)
42
+ parsed_lockfile&.specs.find { |s| s.name == dependency_name }&.source
43
+ end
44
+
45
+ def source_for(dependency)
46
+ source = dependency.source
47
+ if lockfile && default_rubygems?(source)
48
+ # If there's a lockfile and the Gemfile doesn't have anything
49
+ # interesting to say about the source, check that.
50
+ source = source_from_lockfile(dependency.name)
51
+ end
52
+ raise "Bad source: #{source}" unless sources.include?(source.class)
53
+
54
+ return nil if default_rubygems?(source)
55
+
56
+ details = { type: source.class.name.split("::").last.downcase }
57
+ if source.is_a?(Bundler::Source::Git)
58
+ details.merge!(git_source_details(source))
59
+ end
60
+ if source.is_a?(Bundler::Source::Rubygems)
61
+ details[:url] = source.remotes.first.to_s
62
+ end
63
+ details
64
+ end
65
+
66
+ # TODO: Remove default `master` branch
67
+ def git_source_details(source)
68
+ {
69
+ url: source.uri,
70
+ branch: source.branch || "master",
71
+ ref: source.ref || "master"
72
+ }
73
+ end
74
+
75
+ def default_rubygems?(source)
76
+ return true if source.nil?
77
+ return false unless source.is_a?(Bundler::Source::Rubygems)
78
+
79
+ source.remotes.any? { |r| r.to_s.include?("rubygems.org") }
80
+ end
81
+
82
+ def serialize_bundler_dependency(dependency)
83
+ {
84
+ name: dependency.name,
85
+ requirement: dependency.requirement,
86
+ groups: dependency.groups,
87
+ source: source_for(dependency),
88
+ type: dependency.type
89
+ }
90
+ end
91
+
92
+ # Can't be a constant because some of these don't exist in bundler
93
+ # 1.15, which used to cause issues on Heroku (causing exception on boot).
94
+ # TODO: Check if this will be an issue with multiple bundler versions
95
+ def sources
96
+ [
97
+ NilClass,
98
+ Bundler::Source::Rubygems,
99
+ Bundler::Source::Git,
100
+ Bundler::Source::Path,
101
+ Bundler::Source::Gemspec,
102
+ Bundler::Source::Metadata
103
+ ]
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,167 @@
1
+ module Functions
2
+ class ForceUpdater
3
+ class TransitiveDependencyError < StandardError; end
4
+
5
+ def initialize(dependency_name:, target_version:, gemfile_name:,
6
+ lockfile_name:, update_multiple_dependencies:)
7
+ @dependency_name = dependency_name
8
+ @target_version = target_version
9
+ @gemfile_name = gemfile_name
10
+ @lockfile_name = lockfile_name
11
+ @update_multiple_dependencies = update_multiple_dependencies
12
+ end
13
+
14
+ def run
15
+ # Only allow upgrades. Otherwise it's unlikely that this
16
+ # resolution will be found by the FileUpdater
17
+ Bundler.settings.set_command_option(
18
+ "only_update_to_newer_versions",
19
+ true
20
+ )
21
+
22
+ dependencies_to_unlock = []
23
+
24
+ begin
25
+ definition = build_definition(dependencies_to_unlock: dependencies_to_unlock)
26
+ definition.resolve_remotely!
27
+ specs = definition.resolve
28
+ updates = [{ name: dependency_name }] +
29
+ dependencies_to_unlock.map { |dep| { name: dep.name } }
30
+ specs = specs.map do |dep|
31
+ {
32
+ name: dep.name,
33
+ version: dep.version
34
+ }
35
+ end
36
+ [updates, specs]
37
+ rescue Bundler::VersionConflict => e
38
+ raise unless update_multiple_dependencies?
39
+
40
+ # TODO: Not sure this won't unlock way too many things...
41
+ new_dependencies_to_unlock =
42
+ new_dependencies_to_unlock_from(
43
+ error: e,
44
+ already_unlocked: dependencies_to_unlock
45
+ )
46
+
47
+ raise if new_dependencies_to_unlock.none?
48
+
49
+ dependencies_to_unlock += new_dependencies_to_unlock
50
+ retry
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ attr_reader :dependency_name, :target_version, :gemfile_name,
57
+ :lockfile_name, :credentials,
58
+ :update_multiple_dependencies
59
+ alias update_multiple_dependencies? update_multiple_dependencies
60
+
61
+ def new_dependencies_to_unlock_from(error:, already_unlocked:)
62
+ potentials_deps =
63
+ relevant_conflicts(error, already_unlocked).
64
+ flat_map(&:requirement_trees).
65
+ reject do |tree|
66
+ # If the final requirement wasn't specific, it can't be binding
67
+ next true if tree.last.requirement == Gem::Requirement.new(">= 0")
68
+
69
+ # If the conflict wasn't for the dependency we're updating then
70
+ # we don't have enough info to reject it
71
+ next false unless tree.last.name == dependency_name
72
+
73
+ # If the final requirement *was* for the dependency we're updating
74
+ # then we can ignore the tree if it permits the target version
75
+ tree.last.requirement.satisfied_by?(
76
+ Gem::Version.new(target_version)
77
+ )
78
+ end.map(&:first)
79
+
80
+ potentials_deps.
81
+ reject { |dep| already_unlocked.map(&:name).include?(dep.name) }.
82
+ reject { |dep| [dependency_name, "ruby\0"].include?(dep.name) }.
83
+ uniq
84
+ end
85
+
86
+ def relevant_conflicts(error, dependencies_being_unlocked)
87
+ names = [*dependencies_being_unlocked.map(&:name), dependency_name]
88
+
89
+ # For a conflict to be relevant to the updates we're making it must be
90
+ # 1) caused by a new requirement introduced by our unlocking, or
91
+ # 2) caused by an old requirement that prohibits the update.
92
+ # Hence, we look at the beginning and end of the requirement trees
93
+ error.cause.conflicts.values.
94
+ select do |conflict|
95
+ conflict.requirement_trees.any? do |t|
96
+ names.include?(t.last.name) || names.include?(t.first.name)
97
+ end
98
+ end
99
+ end
100
+
101
+ def build_definition(dependencies_to_unlock:)
102
+ gems_to_unlock = dependencies_to_unlock.map(&:name) + [dependency_name]
103
+ definition = Bundler::Definition.build(
104
+ gemfile_name,
105
+ lockfile_name,
106
+ gems: gems_to_unlock + subdependencies,
107
+ lock_shared_dependencies: true
108
+ )
109
+
110
+ # Remove the Gemfile / gemspec requirements on the gems we're
111
+ # unlocking (i.e., completely unlock them)
112
+ gems_to_unlock.each do |gem_name|
113
+ unlock_gem(definition: definition, gem_name: gem_name)
114
+ end
115
+
116
+ dep = definition.dependencies.
117
+ find { |d| d.name == dependency_name }
118
+
119
+ # If the dependency is not found in the Gemfile it means this is a
120
+ # transitive dependency that we can't force update.
121
+ raise TransitiveDependencyError unless dep
122
+
123
+ # Set the requirement for the gem we're forcing an update of
124
+ new_req = Gem::Requirement.create("= #{target_version}")
125
+ dep.instance_variable_set(:@requirement, new_req)
126
+ dep.source = nil if dep.source.is_a?(Bundler::Source::Git)
127
+
128
+ definition
129
+ end
130
+
131
+ def lockfile
132
+ return @lockfile if defined?(@lockfile)
133
+
134
+ @lockfile =
135
+ begin
136
+ return unless lockfile_name && File.exist?(lockfile_name)
137
+
138
+ File.read(lockfile_name)
139
+ end
140
+ end
141
+
142
+ def subdependencies
143
+ # If there's no lockfile we don't need to worry about
144
+ # subdependencies
145
+ return [] unless lockfile
146
+
147
+ all_deps = Bundler::LockfileParser.new(lockfile).
148
+ specs.map(&:name).map(&:to_s)
149
+ top_level = Bundler::Definition.
150
+ build(gemfile_name, lockfile_name, {}).
151
+ dependencies.map(&:name).map(&:to_s)
152
+
153
+ all_deps - top_level
154
+ end
155
+
156
+ def unlock_gem(definition:, gem_name:)
157
+ dep = definition.dependencies.find { |d| d.name == gem_name }
158
+ version = definition.locked_gems.specs.
159
+ find { |d| d.name == gem_name }.version
160
+
161
+ dep&.instance_variable_set(
162
+ :@requirement,
163
+ Gem::Requirement.create(">= #{version}")
164
+ )
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Functions
4
+ class LockfileUpdater
5
+ RETRYABLE_ERRORS = [Bundler::HTTPError].freeze
6
+ GEM_NOT_FOUND_ERROR_REGEX =
7
+ /
8
+ locked\sto\s(?<name>[^\s]+)\s\(|
9
+ not\sfind\s(?<name>[^\s]+)-\d|
10
+ has\s(?<name>[^\s]+)\slocked\sat
11
+ /x.freeze
12
+
13
+ def initialize(gemfile_name:, lockfile_name:, dependencies:)
14
+ @gemfile_name = gemfile_name
15
+ @lockfile_name = lockfile_name
16
+ @dependencies = dependencies
17
+ end
18
+
19
+ def run
20
+ generate_lockfile
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :gemfile_name, :lockfile_name, :dependencies
26
+
27
+ def generate_lockfile
28
+ dependencies_to_unlock = dependencies.map { |d| d.fetch("name") }
29
+
30
+ begin
31
+ definition = build_definition(dependencies_to_unlock)
32
+
33
+ old_reqs = lock_deps_being_updated_to_exact_versions(definition)
34
+
35
+ definition.resolve_remotely!
36
+
37
+ old_reqs.each do |dep_name, old_req|
38
+ d_dep = definition.dependencies.find { |d| d.name == dep_name }
39
+ if old_req == :none then definition.dependencies.delete(d_dep)
40
+ else
41
+ d_dep.instance_variable_set(:@requirement, old_req)
42
+ end
43
+ end
44
+
45
+ cache_vendored_gems(definition) if Bundler.app_cache.exist?
46
+
47
+ definition.to_lock
48
+ rescue Bundler::GemNotFound => e
49
+ unlock_yanked_gem(dependencies_to_unlock, e) && retry
50
+ rescue Bundler::VersionConflict => e
51
+ unlock_blocking_subdeps(dependencies_to_unlock, e) && retry
52
+ rescue *RETRYABLE_ERRORS
53
+ raise if @retrying
54
+
55
+ @retrying = true
56
+ sleep(rand(1.0..5.0))
57
+ retry
58
+ end
59
+ end
60
+
61
+ def cache_vendored_gems(definition)
62
+ # Dependencies that have been unlocked for the update (including
63
+ # sub-dependencies)
64
+ unlocked_gems = definition.instance_variable_get(:@unlock).
65
+ fetch(:gems).reject { |gem| __keep_on_prune?(gem) }
66
+ bundler_opts = {
67
+ cache_all: true,
68
+ cache_all_platforms: true,
69
+ no_prune: true
70
+ }
71
+
72
+ Bundler.settings.temporary(**bundler_opts) do
73
+ # Fetch and cache gems on all platforms without pruning
74
+ Bundler::Runtime.new(nil, definition).cache
75
+
76
+ # Only prune unlocked gems (the original implementation is in
77
+ # Bundler::Runtime)
78
+ cache_path = Bundler.app_cache
79
+ resolve = definition.resolve
80
+ prune_gem_cache(resolve, cache_path, unlocked_gems)
81
+ prune_git_and_path_cache(resolve, cache_path)
82
+ end
83
+ end
84
+
85
+ # This is not officially supported and may be removed without notice.
86
+ def __keep_on_prune?(spec_name)
87
+ unless (specs = Bundler.settings[:persistent_gems_after_clean])
88
+ return false
89
+ end
90
+
91
+ specs.include?(spec_name)
92
+ end
93
+
94
+ # Copied from Bundler::Runtime: Modified to only prune gems that have
95
+ # been unlocked
96
+ def prune_gem_cache(resolve, cache_path, unlocked_gems)
97
+ cached_gems = Dir["#{cache_path}/*.gem"]
98
+
99
+ outdated_gems = cached_gems.reject do |path|
100
+ spec = Bundler.rubygems.spec_from_gem path
101
+
102
+ !unlocked_gems.include?(spec.name) || resolve.any? do |s|
103
+ s.name == spec.name && s.version == spec.version &&
104
+ !s.source.is_a?(Bundler::Source::Git)
105
+ end
106
+ end
107
+
108
+ return unless outdated_gems.any?
109
+
110
+ outdated_gems.each do |path|
111
+ File.delete(path)
112
+ end
113
+ end
114
+
115
+ # Copied from Bundler::Runtime
116
+ def prune_git_and_path_cache(resolve, cache_path)
117
+ cached_git_and_path = Dir["#{cache_path}/*/.bundlecache"]
118
+
119
+ outdated_git_and_path = cached_git_and_path.reject do |path|
120
+ name = File.basename(File.dirname(path))
121
+
122
+ resolve.any? do |s|
123
+ s.source.respond_to?(:app_cache_dirname) &&
124
+ s.source.app_cache_dirname == name
125
+ end
126
+ end
127
+
128
+ return unless outdated_git_and_path.any?
129
+
130
+ outdated_git_and_path.each do |path|
131
+ path = File.dirname(path)
132
+ FileUtils.rm_rf(path)
133
+ end
134
+ end
135
+
136
+ def unlock_yanked_gem(dependencies_to_unlock, error)
137
+ raise unless error.message.match?(GEM_NOT_FOUND_ERROR_REGEX)
138
+
139
+ gem_name = error.message.match(GEM_NOT_FOUND_ERROR_REGEX).
140
+ named_captures["name"]
141
+ raise if dependencies_to_unlock.include?(gem_name)
142
+
143
+ dependencies_to_unlock << gem_name
144
+ end
145
+
146
+ # rubocop:disable Metrics/PerceivedComplexity
147
+ def unlock_blocking_subdeps(dependencies_to_unlock, error)
148
+ all_deps = Bundler::LockfileParser.new(lockfile).
149
+ specs.map(&:name).map(&:to_s)
150
+ top_level = build_definition([]).dependencies.
151
+ map(&:name).map(&:to_s)
152
+ allowed_new_unlocks = all_deps - top_level - dependencies_to_unlock
153
+
154
+ raise if allowed_new_unlocks.none?
155
+
156
+ # Unlock any sub-dependencies that Bundler reports caused the
157
+ # conflict
158
+ potentials_deps =
159
+ error.cause.conflicts.values.
160
+ flat_map(&:requirement_trees).
161
+ map do |tree|
162
+ tree.find { |req| allowed_new_unlocks.include?(req.name) }
163
+ end.compact.map(&:name)
164
+
165
+ # If there are specific dependencies we can unlock, unlock them
166
+ return dependencies_to_unlock.append(*potentials_deps) if potentials_deps.any?
167
+
168
+ # Fall back to unlocking *all* sub-dependencies. This is required
169
+ # because Bundler's VersionConflict objects don't include enough
170
+ # information to chart the full path through all conflicts unwound
171
+ dependencies_to_unlock.append(*allowed_new_unlocks)
172
+ end
173
+ # rubocop:enable Metrics/PerceivedComplexity
174
+
175
+ def build_definition(dependencies_to_unlock)
176
+ defn = Bundler::Definition.build(
177
+ gemfile_name,
178
+ lockfile_name,
179
+ gems: dependencies_to_unlock
180
+ )
181
+
182
+ # Bundler unlocks the sub-dependencies of gems it is passed even
183
+ # if those sub-deps are top-level dependencies. We only want true
184
+ # subdeps unlocked, like they were in the UpdateChecker, so we
185
+ # mutate the unlocked gems array.
186
+ unlocked = defn.instance_variable_get(:@unlock).fetch(:gems)
187
+ must_not_unlock = defn.dependencies.map(&:name).map(&:to_s) -
188
+ dependencies_to_unlock
189
+ unlocked.reject! { |n| must_not_unlock.include?(n) }
190
+
191
+ defn
192
+ end
193
+
194
+ def lock_deps_being_updated_to_exact_versions(definition)
195
+ dependencies.each_with_object({}) do |dep, old_reqs|
196
+ defn_dep = definition.dependencies.find do |d|
197
+ d.name == dep.fetch("name")
198
+ end
199
+
200
+ if defn_dep.nil?
201
+ definition.dependencies <<
202
+ Bundler::Dependency.new(dep.fetch("name"), dep.fetch("version"))
203
+ old_reqs[dep.fetch("name")] = :none
204
+ elsif git_dependency?(dep) &&
205
+ defn_dep.source.is_a?(Bundler::Source::Git)
206
+ defn_dep.source.unlock!
207
+ elsif Gem::Version.correct?(dep.fetch("version"))
208
+ new_req = Gem::Requirement.create("= #{dep.fetch('version')}")
209
+ old_reqs[dep.fetch("name")] = defn_dep.requirement
210
+ defn_dep.instance_variable_set(:@requirement, new_req)
211
+ end
212
+ end
213
+ end
214
+
215
+ def git_dependency?(dep)
216
+ sources = dep.fetch("requirements").map { |r| r.fetch("source") }
217
+ sources.all? { |s| s&.fetch("type", nil) == "git" }
218
+ end
219
+
220
+ def lockfile
221
+ @lockfile ||= File.read(lockfile_name)
222
+ end
223
+ end
224
+ end