dependabot-composer 0.89.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dependabot/dependency"
4
+ require "dependabot/composer/version"
5
+ require "dependabot/file_parsers"
6
+ require "dependabot/file_parsers/base"
7
+ require "dependabot/shared_helpers"
8
+ require "dependabot/errors"
9
+
10
+ module Dependabot
11
+ module Composer
12
+ class FileParser < Dependabot::FileParsers::Base
13
+ require "dependabot/file_parsers/base/dependency_set"
14
+
15
+ DEPENDENCY_GROUP_KEYS = [
16
+ {
17
+ manifest: "require",
18
+ lockfile: "packages",
19
+ group: "runtime"
20
+ },
21
+ {
22
+ manifest: "require-dev",
23
+ lockfile: "packages-dev",
24
+ group: "development"
25
+ }
26
+ ].freeze
27
+
28
+ def parse
29
+ dependency_set = DependencySet.new
30
+ dependency_set += manifest_dependencies
31
+ dependency_set += lockfile_dependencies
32
+ dependency_set.dependencies
33
+ end
34
+
35
+ private
36
+
37
+ def manifest_dependencies
38
+ dependencies = DependencySet.new
39
+
40
+ DEPENDENCY_GROUP_KEYS.each do |keys|
41
+ next unless parsed_composer_json[keys[:manifest]]
42
+
43
+ parsed_composer_json[keys[:manifest]].each do |name, req|
44
+ next unless package?(name)
45
+
46
+ if lockfile
47
+ version = dependency_version(name: name, type: keys[:group])
48
+
49
+ # Ignore dependencies which appear in the composer.json but not
50
+ # the composer.lock.
51
+ next if version.nil?
52
+
53
+ # Ignore dependency versions which are non-numeric, since they
54
+ # can't be compared later in the process.
55
+ next unless version.match?(/^\d/)
56
+ end
57
+
58
+ dependencies <<
59
+ Dependency.new(
60
+ name: name,
61
+ version: dependency_version(name: name, type: keys[:group]),
62
+ requirements: [{
63
+ requirement: req,
64
+ file: "composer.json",
65
+ source: dependency_source(name: name, type: keys[:group]),
66
+ groups: [keys[:group]]
67
+ }],
68
+ package_manager: "composer"
69
+ )
70
+ end
71
+ end
72
+
73
+ dependencies
74
+ end
75
+
76
+ def lockfile_dependencies
77
+ dependencies = DependencySet.new
78
+
79
+ return dependencies unless lockfile
80
+
81
+ DEPENDENCY_GROUP_KEYS.map { |h| h.fetch(:lockfile) }.each do |key|
82
+ next unless parsed_lockfile[key]
83
+
84
+ parsed_lockfile[key].each do |details|
85
+ name = details["name"]
86
+ next unless package?(name)
87
+
88
+ version = details["version"]&.sub(/^v?/, "")
89
+ next if version.nil?
90
+ next unless version.match?(/^\d/)
91
+
92
+ dependencies <<
93
+ Dependency.new(
94
+ name: name,
95
+ version: version,
96
+ requirements: [],
97
+ package_manager: "composer"
98
+ )
99
+ end
100
+ end
101
+
102
+ dependencies
103
+ end
104
+
105
+ def dependency_version(name:, type:)
106
+ return unless lockfile
107
+
108
+ key = lockfile_key(type)
109
+
110
+ parsed_lockfile.
111
+ fetch(key, []).
112
+ find { |d| d["name"] == name }&.
113
+ fetch("version")&.sub(/^v?/, "")
114
+ end
115
+
116
+ def dependency_source(name:, type:)
117
+ return unless lockfile
118
+
119
+ key = lockfile_key(type)
120
+ package = parsed_lockfile.fetch(key).find { |d| d["name"] == name }
121
+
122
+ return unless package
123
+
124
+ if package["source"].nil? && package.dig("dist", "type") == "path"
125
+ return { type: "path" }
126
+ end
127
+
128
+ return unless package.dig("source", "type") == "git"
129
+
130
+ {
131
+ type: "git",
132
+ url: package.dig("source", "url")
133
+ }
134
+ end
135
+
136
+ def lockfile_key(type)
137
+ case type
138
+ when "runtime" then "packages"
139
+ when "development" then "packages-dev"
140
+ else raise "unknown type #{type}"
141
+ end
142
+ end
143
+
144
+ def package?(name)
145
+ # Filter out php, ext-, composer-plugin-api, and other special
146
+ # packages which don't behave as normal
147
+ name.split("/").count == 2
148
+ end
149
+
150
+ def check_required_files
151
+ raise "No composer.json!" unless get_original_file("composer.json")
152
+ end
153
+
154
+ def parsed_lockfile
155
+ return unless lockfile
156
+
157
+ @parsed_lockfile ||= JSON.parse(lockfile.content)
158
+ rescue JSON::ParserError
159
+ raise Dependabot::DependencyFileNotParseable, lockfile.path
160
+ end
161
+
162
+ def parsed_composer_json
163
+ @parsed_composer_json ||= JSON.parse(composer_json.content)
164
+ rescue JSON::ParserError
165
+ raise Dependabot::DependencyFileNotParseable, composer_json.path
166
+ end
167
+
168
+ def composer_json
169
+ @composer_json ||= get_original_file("composer.json")
170
+ end
171
+
172
+ def lockfile
173
+ @lockfile ||= get_original_file("composer.lock")
174
+ end
175
+ end
176
+ end
177
+ end
178
+
179
+ Dependabot::FileParsers.register("composer", Dependabot::Composer::FileParser)
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dependabot/file_updaters"
4
+ require "dependabot/file_updaters/base"
5
+ require "dependabot/shared_helpers"
6
+ require "dependabot/errors"
7
+
8
+ module Dependabot
9
+ module Composer
10
+ class FileUpdater < Dependabot::FileUpdaters::Base
11
+ require_relative "file_updater/manifest_updater"
12
+ require_relative "file_updater/lockfile_updater"
13
+
14
+ def self.updated_files_regex
15
+ [
16
+ /^composer\.json$/,
17
+ /^composer\.lock$/
18
+ ]
19
+ end
20
+
21
+ def updated_dependency_files
22
+ updated_files = []
23
+
24
+ if file_changed?(composer_json)
25
+ updated_files <<
26
+ updated_file(
27
+ file: composer_json,
28
+ content: updated_composer_json_content
29
+ )
30
+ end
31
+
32
+ if lockfile
33
+ updated_files <<
34
+ updated_file(file: lockfile, content: updated_lockfile_content)
35
+ end
36
+
37
+ if updated_files.none? ||
38
+ updated_files.sort_by(&:name) == dependency_files.sort_by(&:name)
39
+ raise "No files have changed!"
40
+ end
41
+
42
+ updated_files
43
+ end
44
+
45
+ private
46
+
47
+ def check_required_files
48
+ raise "No composer.json!" unless get_original_file("composer.json")
49
+ end
50
+
51
+ def updated_composer_json_content
52
+ ManifestUpdater.new(
53
+ dependencies: dependencies,
54
+ manifest: composer_json
55
+ ).updated_manifest_content
56
+ end
57
+
58
+ def updated_lockfile_content
59
+ @updated_lockfile_content ||=
60
+ LockfileUpdater.new(
61
+ dependencies: dependencies,
62
+ dependency_files: dependency_files,
63
+ credentials: credentials
64
+ ).updated_lockfile_content
65
+ end
66
+
67
+ def composer_json
68
+ @composer_json ||= get_original_file("composer.json")
69
+ end
70
+
71
+ def lockfile
72
+ @lockfile ||= get_original_file("composer.lock")
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ Dependabot::FileUpdaters.register("composer", Dependabot::Composer::FileUpdater)
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dependabot/shared_helpers"
4
+ require "dependabot/errors"
5
+ require "dependabot/composer/file_updater"
6
+ require "dependabot/composer/version"
7
+ require "dependabot/composer/native_helpers"
8
+
9
+ module Dependabot
10
+ module Composer
11
+ class FileUpdater
12
+ class LockfileUpdater
13
+ require_relative "manifest_updater"
14
+
15
+ def initialize(dependencies:, dependency_files:, credentials:)
16
+ @dependencies = dependencies
17
+ @dependency_files = dependency_files
18
+ @credentials = credentials
19
+ end
20
+
21
+ def updated_lockfile_content
22
+ base_directory = dependency_files.first.directory
23
+ @updated_lockfile_content ||=
24
+ SharedHelpers.in_a_temporary_directory(base_directory) do
25
+ write_temporary_dependency_files
26
+
27
+ updated_content = run_update_helper.fetch("composer.lock")
28
+
29
+ updated_content = post_process_lockfile(updated_content)
30
+ if lockfile.content == updated_content
31
+ raise "Expected content to change!"
32
+ end
33
+
34
+ updated_content
35
+ end
36
+ rescue SharedHelpers::HelperSubprocessFailed => error
37
+ handle_composer_errors(error)
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :dependencies, :dependency_files, :credentials
43
+
44
+ def dependency
45
+ # For now, we'll only ever be updating a single dependency for PHP
46
+ dependencies.first
47
+ end
48
+
49
+ def run_update_helper
50
+ SharedHelpers.with_git_configured(credentials: credentials) do
51
+ SharedHelpers.run_helper_subprocess(
52
+ command: "php #{php_helper_path}",
53
+ function: "update",
54
+ env: credentials_env,
55
+ args: [
56
+ Dir.pwd,
57
+ dependency.name,
58
+ dependency.version,
59
+ git_credentials,
60
+ registry_credentials
61
+ ]
62
+ )
63
+ end
64
+ end
65
+
66
+ def updated_composer_json_content
67
+ ManifestUpdater.new(
68
+ dependencies: dependencies,
69
+ manifest: composer_json
70
+ ).updated_manifest_content
71
+ end
72
+
73
+ # rubocop:disable Metrics/PerceivedComplexity
74
+ # rubocop:disable Metrics/AbcSize
75
+ # rubocop:disable Metrics/CyclomaticComplexity
76
+ # rubocop:disable Metrics/MethodLength
77
+ def handle_composer_errors(error)
78
+ if error.message.start_with?("Failed to execute git checkout")
79
+ raise git_dependency_reference_error(error)
80
+ end
81
+
82
+ if error.message.start_with?("Failed to execute git clone")
83
+ dependency_url =
84
+ error.message.match(/(?:mirror|checkout) '(?<url>.*?)'/).
85
+ named_captures.fetch("url")
86
+ raise GitDependenciesNotReachable, dependency_url
87
+ end
88
+
89
+ if error.message.start_with?("Failed to clone")
90
+ dependency_url =
91
+ error.message.match(/Failed to clone (?<url>.*?) via/).
92
+ named_captures.fetch("url")
93
+ raise GitDependenciesNotReachable, dependency_url
94
+ end
95
+
96
+ if error.message.start_with?("Could not find a key for ACF PRO")
97
+ raise MissingEnvironmentVariable, "ACF_PRO_KEY"
98
+ end
99
+
100
+ if error.message.start_with?("Unknown downloader type: npm-sign") ||
101
+ error.message.include?("file could not be downloaded") ||
102
+ error.message.include?("configuration does not allow connect")
103
+ raise DependencyFileNotResolvable, error.message
104
+ end
105
+
106
+ if error.message.start_with?("Allowed memory size")
107
+ raise Dependabot::OutOfMemory
108
+ end
109
+
110
+ if error.message.include?("403 Forbidden")
111
+ source = error.message.match(%r{https?://(?<source>[^/]+)/}).
112
+ named_captures.fetch("source")
113
+ raise PrivateSourceAuthenticationFailure, source
114
+ end
115
+
116
+ if error.message.include?("Argument 1 passed to Composer")
117
+ msg = "One of your Composer plugins is not compatible with the "\
118
+ "latest version of Composer. Please update Composer and "\
119
+ "try running `composer update` to debug further."
120
+ raise DependencyFileNotResolvable, msg
121
+ end
122
+
123
+ raise error
124
+ end
125
+ # rubocop:enable Metrics/PerceivedComplexity
126
+ # rubocop:enable Metrics/AbcSize
127
+ # rubocop:enable Metrics/CyclomaticComplexity
128
+ # rubocop:enable Metrics/MethodLength
129
+
130
+ def write_temporary_dependency_files
131
+ path_dependencies.each do |file|
132
+ path = file.name
133
+ FileUtils.mkdir_p(Pathname.new(path).dirname)
134
+ File.write(file.name, file.content)
135
+ end
136
+
137
+ File.write("composer.json", locked_composer_json_content)
138
+ File.write("composer.lock", lockfile.content)
139
+ end
140
+
141
+ def locked_composer_json_content
142
+ dependencies.
143
+ reduce(updated_composer_json_content) do |content, dep|
144
+ updated_req = dep.version
145
+ next content unless Composer::Version.correct?(updated_req)
146
+
147
+ old_req =
148
+ dep.requirements.find { |r| r[:file] == "composer.json" }&.
149
+ fetch(:requirement)
150
+
151
+ # When updating a subdep there won't be an old requirement
152
+ next content unless old_req
153
+
154
+ regex =
155
+ /
156
+ "#{Regexp.escape(dep.name)}"\s*:\s*
157
+ "#{Regexp.escape(old_req)}"
158
+ /x
159
+
160
+ content.gsub(regex) do |declaration|
161
+ declaration.gsub(%("#{old_req}"), %("#{updated_req}"))
162
+ end
163
+ end
164
+ end
165
+
166
+ def git_dependency_reference_error(error)
167
+ ref = error.message.match(/checkout '(?<ref>.*?)'/).
168
+ named_captures.fetch("ref")
169
+ dependency_name =
170
+ JSON.parse(lockfile.content).
171
+ values_at("packages", "packages-dev").flatten(1).
172
+ find { |dep| dep.dig("source", "reference") == ref }&.
173
+ fetch("name")
174
+
175
+ raise unless dependency_name
176
+
177
+ raise GitDependencyReferenceNotFound, dependency_name
178
+ end
179
+
180
+ def post_process_lockfile(content)
181
+ content = replace_patches(content)
182
+ replace_content_hash(content)
183
+ end
184
+
185
+ def replace_patches(updated_content)
186
+ content = updated_content
187
+ %w(packages packages-dev).each do |package_type|
188
+ JSON.parse(lockfile.content).fetch(package_type).each do |details|
189
+ next unless details["extra"].is_a?(Hash)
190
+ next unless (patches = details.dig("extra", "patches_applied"))
191
+
192
+ updated_object = JSON.parse(content)
193
+ updated_object_package =
194
+ updated_object.
195
+ fetch(package_type).
196
+ find { |d| d["name"] == details["name"] }
197
+
198
+ next unless updated_object_package
199
+
200
+ updated_object_package["extra"] ||= {}
201
+ updated_object_package["extra"]["patches_applied"] = patches
202
+
203
+ content =
204
+ JSON.pretty_generate(updated_object, indent: " ").
205
+ gsub(/\[\n\n\s*\]/, "[]").
206
+ gsub(/\}\z/, "}\n")
207
+ end
208
+ end
209
+ content
210
+ end
211
+
212
+ def replace_content_hash(content)
213
+ existing_hash = JSON.parse(content).fetch("content-hash")
214
+ SharedHelpers.in_a_temporary_directory do
215
+ File.write("composer.json", updated_composer_json_content)
216
+
217
+ content_hash =
218
+ SharedHelpers.run_helper_subprocess(
219
+ command: "php #{php_helper_path}",
220
+ function: "get_content_hash",
221
+ env: credentials_env,
222
+ args: [Dir.pwd]
223
+ )
224
+
225
+ content.gsub(existing_hash, content_hash)
226
+ end
227
+ end
228
+
229
+ def php_helper_path
230
+ NativeHelpers.composer_helper_path
231
+ end
232
+
233
+ def credentials_env
234
+ credentials.
235
+ select { |c| c.fetch("type") == "php_environment_variable" }.
236
+ map { |cred| [cred["env-key"], cred["env-value"]] }.
237
+ to_h
238
+ end
239
+
240
+ def git_credentials
241
+ credentials.
242
+ select { |cred| cred.fetch("type") == "git_source" }
243
+ end
244
+
245
+ def registry_credentials
246
+ credentials.
247
+ select { |cred| cred.fetch("type") == "composer_repository" }
248
+ end
249
+
250
+ def composer_json
251
+ @composer_json ||=
252
+ dependency_files.find { |f| f.name == "composer.json" }
253
+ end
254
+
255
+ def lockfile
256
+ @lockfile ||=
257
+ dependency_files.find { |f| f.name == "composer.lock" }
258
+ end
259
+
260
+ def path_dependencies
261
+ @path_dependencies ||=
262
+ dependency_files.select { |f| f.name.end_with?("/composer.json") }
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end