/
yarn_lockfile_updater.rb
542 lines (443 loc) 路 18.9 KB
/
yarn_lockfile_updater.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
# frozen_string_literal: true
require "dependabot/npm_and_yarn/file_updater"
require "dependabot/npm_and_yarn/file_parser"
require "dependabot/npm_and_yarn/update_checker/registry_finder"
require "dependabot/npm_and_yarn/native_helpers"
require "dependabot/shared_helpers"
require "dependabot/errors"
# rubocop:disable Metrics/ClassLength
module Dependabot
module NpmAndYarn
class FileUpdater
class YarnLockfileUpdater
require_relative "npmrc_builder"
require_relative "package_json_updater"
def initialize(dependencies:, dependency_files:, credentials:)
@dependencies = dependencies
@dependency_files = dependency_files
@credentials = credentials
end
def updated_yarn_lock_content(yarn_lock)
@updated_yarn_lock_content ||= {}
if @updated_yarn_lock_content[yarn_lock.name]
return @updated_yarn_lock_content[yarn_lock.name]
end
new_content = updated_yarn_lock(yarn_lock)
@updated_yarn_lock_content[yarn_lock.name] =
post_process_yarn_lockfile(new_content)
end
private
attr_reader :dependencies, :dependency_files, :credentials
UNREACHABLE_GIT = /ls-remote --tags --heads (?<url>.*)/.freeze
TIMEOUT_FETCHING_PACKAGE =
%r{(?<url>.+)/(?<package>[^/]+): ETIMEDOUT}.freeze
INVALID_PACKAGE = /Can't add "(?<package_req>.*)": invalid/.freeze
def top_level_dependencies
dependencies.select(&:top_level?)
end
def sub_dependencies
dependencies.reject(&:top_level?)
end
def updated_yarn_lock(yarn_lock)
SharedHelpers.in_a_temporary_directory do
write_temporary_dependency_files
lockfile_name = Pathname.new(yarn_lock.name).basename.to_s
path = Pathname.new(yarn_lock.name).dirname.to_s
updated_files = run_current_yarn_update(
path: path,
lockfile_name: lockfile_name
)
updated_files.fetch(lockfile_name)
end
rescue SharedHelpers::HelperSubprocessFailed => e
handle_yarn_lock_updater_error(e, yarn_lock)
end
def run_current_yarn_update(path:, lockfile_name:)
top_level_dependency_updates = top_level_dependencies.map do |d|
{
name: d.name,
version: d.version,
requirements: requirements_for_path(d.requirements, path)
}
end
run_yarn_updater(
path: path,
lockfile_name: lockfile_name,
top_level_dependency_updates: top_level_dependency_updates
)
end
def run_previous_yarn_update(path:, lockfile_name:)
previous_top_level_dependencies = top_level_dependencies.map do |d|
{
name: d.name,
version: d.previous_version,
requirements: requirements_for_path(
d.previous_requirements, path
)
}
end
run_yarn_updater(
path: path,
lockfile_name: lockfile_name,
top_level_dependency_updates: previous_top_level_dependencies
)
end
# rubocop:disable Metrics/PerceivedComplexity
def run_yarn_updater(path:, lockfile_name:,
top_level_dependency_updates:)
SharedHelpers.with_git_configured(credentials: credentials) do
Dir.chdir(path) do
if top_level_dependency_updates.any?
run_yarn_top_level_updater(
top_level_dependency_updates: top_level_dependency_updates
)
else
run_yarn_subdependency_updater(lockfile_name: lockfile_name)
end
end
end
rescue SharedHelpers::HelperSubprocessFailed => e
names = dependencies.map(&:name)
package_missing = names.any? do |name|
e.message.include?("find package \"#{name}")
end
raise unless e.message.include?("The registry may be down") ||
e.message.include?("ETIMEDOUT") ||
e.message.include?("ENOBUFS") ||
package_missing
retry_count ||= 0
retry_count += 1
raise if retry_count > 2
sleep(rand(3.0..10.0)) && retry
end
# rubocop:enable Metrics/PerceivedComplexity
def run_yarn_top_level_updater(top_level_dependency_updates:)
SharedHelpers.run_helper_subprocess(
command: NativeHelpers.helper_path,
function: "yarn:update",
args: [
Dir.pwd,
top_level_dependency_updates
]
)
end
def run_yarn_subdependency_updater(lockfile_name:)
SharedHelpers.run_helper_subprocess(
command: NativeHelpers.helper_path,
function: "yarn:updateSubdependency",
args: [Dir.pwd, lockfile_name, sub_dependencies.first.to_h]
)
end
def requirements_for_path(requirements, path)
return requirements if path.to_s == "."
requirements.map do |r|
next unless r[:file].start_with?("#{path}/")
r.merge(file: r[:file].gsub(/^#{Regexp.quote("#{path}/")}/, ""))
end.compact
end
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/PerceivedComplexity
# rubocop:disable Metrics/MethodLength
def handle_yarn_lock_updater_error(error, yarn_lock)
error_message = error.message
# Invalid package: When package.json doesn't include a name or version
# Local path error: When installing a git dependency which
# is using local file paths for sub-dependencies (e.g. unbuilt yarn
# workspace project)
sub_dep_local_path_err = "Package \"\" refers to a non-existing file"
if error_message.match?(INVALID_PACKAGE) ||
error_message.start_with?(sub_dep_local_path_err)
raise_resolvability_error(error_message, yarn_lock)
end
if error_message.include?("Couldn't find package")
package_name = error_message.match(/package "(?<package_req>.*?)"/).
named_captures["package_req"].
split(/(?<=\w)\@/).first
sanitized_name = sanitize_package_name(package_name)
sanitized_error = error_message.gsub(package_name, sanitized_name)
handle_missing_package(sanitized_name, sanitized_error, yarn_lock)
end
if error_message.match?(%r{/[^/]+: Not found})
package_name = error_message.
match(%r{/(?<package_name>[^/]+): Not found}).
named_captures["package_name"]
sanitized_name = sanitize_package_name(package_name)
sanitized_error = error_message.gsub(package_name, sanitized_name)
handle_missing_package(sanitized_name, sanitized_error, yarn_lock)
end
# TODO: Move this logic to the version resolver and check if a new
# version and all of its subdependencies are resolvable
# Make sure the error in question matches the current list of
# dependencies or matches an existing scoped package, this handles the
# case where a new version (e.g. @angular-devkit/build-angular) relies
# on a added dependency which hasn't been published yet under the same
# scope (e.g. @angular-devkit/build-optimizer)
#
# This seems to happen when big monorepo projects publish all of their
# packages sequentially, which might take enough time for Dependabot
# to hear about a new version before all of its dependencies have been
# published
#
# OR
#
# This happens if a new version has been published but npm is having
# consistency issues and the version isn't fully available on all
# queries
if error_message.start_with?("Couldn't find any versions") &&
dependencies_in_error_message?(error_message) &&
resolvable_before_update?(yarn_lock)
# Raise a bespoke error so we can capture and ignore it if
# we're trying to create a new PR (which will be created
# successfully at a later date)
raise Dependabot::InconsistentRegistryResponse, error_message
end
if error_message.include?("Workspaces can only be enabled in priva")
raise Dependabot::DependencyFileNotEvaluatable, error_message
end
if error_message.match?(UNREACHABLE_GIT)
dependency_url = error_message.match(UNREACHABLE_GIT).
named_captures.fetch("url")
raise Dependabot::GitDependenciesNotReachable, dependency_url
end
if error_message.match?(TIMEOUT_FETCHING_PACKAGE)
handle_timeout(error_message, yarn_lock)
end
if error_message.start_with?("Couldn't find any versions") ||
error_message.include?(": Not found")
unless resolvable_before_update?(yarn_lock)
raise_resolvability_error(error_message, yarn_lock)
end
# Dependabot has probably messed something up with the update and we
# want to hear about it
raise error
end
raise error
end
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/PerceivedComplexity
# rubocop:enable Metrics/MethodLength
def resolvable_before_update?(yarn_lock)
@resolvable_before_update ||= {}
if @resolvable_before_update.key?(yarn_lock.name)
return @resolvable_before_update[yarn_lock.name]
end
@resolvable_before_update[yarn_lock.name] =
begin
SharedHelpers.in_a_temporary_directory do
write_temporary_dependency_files(update_package_json: false)
lockfile_name = Pathname.new(yarn_lock.name).basename.to_s
path = Pathname.new(yarn_lock.name).dirname.to_s
run_previous_yarn_update(path: path,
lockfile_name: lockfile_name)
end
true
rescue SharedHelpers::HelperSubprocessFailed
false
end
end
def dependencies_in_error_message?(message)
names = dependencies.map { |dep| dep.name.split("/").first }
# Example format: Couldn't find any versions for
# "@dependabot/dummy-pkg-b" that matches "^1.3.0"
names.any? do |name|
message.match?(%r{"#{Regexp.quote(name)}["\/]})
end
end
def write_temporary_dependency_files(update_package_json: true)
write_lockfiles
File.write(".npmrc", npmrc_content)
File.write(".yarnrc", yarnrc_content) if yarnrc_specifies_npm_reg?
package_files.each do |file|
path = file.name
FileUtils.mkdir_p(Pathname.new(path).dirname)
updated_content =
if update_package_json && top_level_dependencies.any?
updated_package_json_content(file)
else
file.content
end
updated_content = replace_ssh_sources(updated_content)
# A bug prevents Yarn recognising that a directory is part of a
# workspace if it is specified with a `./` prefix.
updated_content = remove_workspace_path_prefixes(updated_content)
updated_content = sanitized_package_json_content(updated_content)
File.write(file.name, updated_content)
end
end
def write_lockfiles
yarn_locks.each do |f|
FileUtils.mkdir_p(Pathname.new(f.name).dirname)
File.write(f.name, f.content)
end
end
def replace_ssh_sources(content)
updated_content = content
git_ssh_requirements_to_swap.each do |req|
new_req = req.gsub(%r{git\+ssh://git@(.*?)[:/]}, 'https://\1/')
updated_content = updated_content.gsub(req, new_req)
end
updated_content
end
def remove_workspace_path_prefixes(content)
json = JSON.parse(content)
return content unless json.key?("workspaces")
workspace_object = json.fetch("workspaces")
paths_array =
if workspace_object.is_a?(Hash)
workspace_object.values_at("packages", "nohoist").
flatten.compact
elsif workspace_object.is_a?(Array) then workspace_object
else raise "Unexpected workspace object"
end
paths_array.each { |path| path.gsub!(%r{^\./}, "") }
json.to_json
end
def git_ssh_requirements_to_swap
return @git_ssh_requirements_to_swap if @git_ssh_requirements_to_swap
git_dependencies =
dependencies.
select do |dep|
dep.requirements.any? { |r| r.dig(:source, :type) == "git" }
end
@git_ssh_requirements_to_swap = []
package_files.each do |file|
NpmAndYarn::FileParser::DEPENDENCY_TYPES.each do |t|
JSON.parse(file.content).fetch(t, {}).each do |nm, requirement|
next unless git_dependencies.map(&:name).include?(nm)
next unless requirement.start_with?("git+ssh:")
req = requirement.split("#").first
@git_ssh_requirements_to_swap << req
end
end
end
@git_ssh_requirements_to_swap
end
def post_process_yarn_lockfile(lockfile_content)
updated_content = lockfile_content
git_ssh_requirements_to_swap.each do |req|
new_req = req.gsub(%r{git\+ssh://git@(.*?)[:/]}, 'https://\1/')
updated_content = updated_content.gsub(new_req, req)
end
# Enforce https for most common hostnames
updated_content = updated_content.gsub(
%r{http://(.*?(?:yarnpkg\.com|npmjs\.org|npmjs\.com))/},
'https://\1/'
)
if remove_integrity_lines?
updated_content = remove_integrity_lines(updated_content)
end
updated_content
end
def remove_integrity_lines?
yarn_locks.none? { |f| f.content.include?(" integrity sha") }
end
def remove_integrity_lines(content)
content.lines.reject { |l| l.match?(/\s*integrity sha/) }.join
end
def lockfile_dependencies(lockfile)
@lockfile_dependencies ||= {}
@lockfile_dependencies[lockfile.name] ||=
NpmAndYarn::FileParser.new(
dependency_files: [lockfile, *package_files],
source: nil,
credentials: credentials
).parse
end
def handle_missing_package(package_name, error_message, yarn_lock)
missing_dep = lockfile_dependencies(yarn_lock).
find { |dep| dep.name == package_name }
raise_resolvability_error(error_message, yarn_lock) unless missing_dep
reg = NpmAndYarn::UpdateChecker::RegistryFinder.new(
dependency: missing_dep,
credentials: credentials,
npmrc_file: npmrc_file,
yarnrc_file: yarnrc_file
).registry
return if central_registry?(reg) && !package_name.start_with?("@")
raise PrivateSourceAuthenticationFailure, reg
end
def central_registry?(registry)
FileParser::CENTRAL_REGISTRIES.any? { |r| r.include?(registry) }
end
def raise_resolvability_error(error_message, yarn_lock)
dependency_names = dependencies.map(&:name).join(", ")
msg = "Error whilst updating #{dependency_names} in "\
"#{yarn_lock.path}:\n#{error_message}"
raise Dependabot::DependencyFileNotResolvable, msg
end
def handle_timeout(error_message, yarn_lock)
url = error_message.match(TIMEOUT_FETCHING_PACKAGE).
named_captures["url"]
return if url.start_with?("https://registry.npmjs.org")
package_name = error_message.match(TIMEOUT_FETCHING_PACKAGE).
named_captures["package"]
sanitized_name = sanitize_package_name(package_name)
dep = lockfile_dependencies(yarn_lock).
find { |d| d.name == sanitized_name }
return unless dep
raise PrivateSourceTimedOut, url.gsub(%r{https?://}, "")
end
def npmrc_content
NpmrcBuilder.new(
credentials: credentials,
dependency_files: dependency_files
).npmrc_content
end
def updated_package_json_content(file)
@updated_package_json_content ||= {}
@updated_package_json_content[file.name] ||=
PackageJsonUpdater.new(
package_json: file,
dependencies: top_level_dependencies
).updated_package_json.content
end
def npmrc_disables_lockfile?
npmrc_content.match?(/^package-lock\s*=\s*false/)
end
def yarnrc_specifies_npm_reg?
return false unless yarnrc_file
regex = UpdateChecker::RegistryFinder::YARN_GLOBAL_REGISTRY_REGEX
yarnrc_global_registry =
yarnrc_file.content.
lines.find { |line| line.match?(regex) }&.
match(regex)&.
named_captures&.
fetch("registry")
return false unless yarnrc_global_registry
yarnrc_global_registry.include?("registry.npmjs.org")
end
def yarnrc_content
'registry "https://registry.npmjs.org"'
end
def sanitized_package_json_content(content)
updated_content =
content.
gsub(/\{\{[^\}]*?\}\}/, "something"). # {{ nm }} syntax not allowed
gsub(/(?<!\\)\\ /, " "). # escaped whitespace not allowed
gsub(%r{^\s*//.*}, " ") # comments are not allowed
json = JSON.parse(updated_content)
json["name"] = json["name"].delete(" ") if json["name"].is_a?(String)
json.to_json
end
def sanitize_package_name(package_name)
package_name.gsub("%2f", "/").gsub("%2F", "/")
end
def yarn_locks
@yarn_locks ||=
dependency_files.
select { |f| f.name.end_with?("yarn.lock") }
end
def package_files
dependency_files.select { |f| f.name.end_with?("package.json") }
end
def yarnrc_file
dependency_files.find { |f| f.name == ".yarnrc" }
end
def npmrc_file
dependency_files.find { |f| f.name == ".npmrc" }
end
end
end
end
end
# rubocop:enable Metrics/ClassLength