dependabot-composer 0.397.0 → 0.399.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.
@@ -2,6 +2,8 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "dependabot/composer/version"
5
+ require "dependabot/composer/manifest_document"
6
+ require "dependabot/composer/lockfile_document"
5
7
  require "sorbet-runtime"
6
8
 
7
9
  module Dependabot
@@ -31,20 +33,20 @@ module Dependabot
31
33
 
32
34
  sig do
33
35
  params(
34
- composer_json: T::Hash[String, T.untyped],
35
- parsed_lockfile: T.nilable(T::Hash[String, T.untyped])
36
+ composer_json: ManifestDocument,
37
+ parsed_lockfile: T.nilable(LockfileDocument)
36
38
  )
37
39
  .returns(String)
38
40
  end
39
41
  def self.composer_version(composer_json, parsed_lockfile = nil)
40
42
  # If the parsed lockfile has a plugin API version, always use V2.
41
43
  # V1 helpers have been removed, so we run with Composer V2 regardless.
42
- if parsed_lockfile && parsed_lockfile[PackageManager::PLUGIN_API_VERSION_KEY]
43
- version = Composer::Version.new(parsed_lockfile[PackageManager::PLUGIN_API_VERSION_KEY])
44
+ plugin_api_version = parsed_lockfile&.plugin_api_version
45
+ if plugin_api_version
46
+ version = Composer::Version.new(plugin_api_version)
44
47
  major_version = version.canonical_segments.first
45
48
 
46
49
  if major_version && major_version <= 1
47
- plugin_api_version = parsed_lockfile[PackageManager::PLUGIN_API_VERSION_KEY]
48
50
  Dependabot.logger.warn(
49
51
  "Composer V1 lockfile detected (plugin-api-version: #{plugin_api_version}). " \
50
52
  "Dependabot no longer supports Composer V1. Running with Composer V2."
@@ -56,7 +58,8 @@ module Dependabot
56
58
 
57
59
  # Check if the composer name does not follow the Composer V2 naming conventions.
58
60
  # This happens if "name" is present in composer.json but doesn't match the required pattern.
59
- composer_name_invalid = composer_json["name"] && composer_json["name"] !~ COMPOSER_V2_NAME_REGEX
61
+ composer_name = composer_json.name
62
+ composer_name_invalid = composer_name && composer_name !~ COMPOSER_V2_NAME_REGEX
60
63
 
61
64
  # If the name is invalid returns the fallback version.
62
65
  return V2 if composer_name_invalid
@@ -141,34 +144,32 @@ module Dependabot
141
144
  end
142
145
 
143
146
  # Capture the platform PHP version from composer.json
144
- sig { params(parsed_composer_json: T::Hash[String, T.untyped]).returns(T.nilable(String)) }
147
+ sig { params(parsed_composer_json: ManifestDocument).returns(T.nilable(String)) }
145
148
  def self.capture_platform_php(parsed_composer_json)
146
149
  capture_platform(parsed_composer_json, Language::NAME)
147
150
  end
148
151
 
149
152
  # Capture the platform extension from composer.json
150
- sig { params(parsed_composer_json: T::Hash[String, T.untyped], name: String).returns(T.nilable(String)) }
153
+ sig { params(parsed_composer_json: ManifestDocument, name: String).returns(T.nilable(String)) }
151
154
  def self.capture_platform(parsed_composer_json, name)
152
- parsed_composer_json.dig(PackageManager::CONFIG_KEY, PackageManager::PLATFORM_KEY, name)
155
+ parsed_composer_json.platform(name)
153
156
  end
154
157
 
155
158
  # Capture PHP version constraint from composer.json
156
- sig { params(parsed_composer_json: T::Hash[String, T.untyped]).returns(T.nilable(String)) }
159
+ sig { params(parsed_composer_json: ManifestDocument).returns(T.nilable(String)) }
157
160
  def self.php_constraint(parsed_composer_json)
158
161
  dependency_constraint(parsed_composer_json, Language::NAME)
159
162
  end
160
163
 
161
164
  # Capture extension version constraint from composer.json
162
- sig { params(parsed_composer_json: T::Hash[String, T.untyped], name: String).returns(T.nilable(String)) }
165
+ sig { params(parsed_composer_json: ManifestDocument, name: String).returns(T.nilable(String)) }
163
166
  def self.dependency_constraint(parsed_composer_json, name)
164
- parsed_composer_json.dig(PackageManager::REQUIRE_KEY, name)
167
+ parsed_composer_json.dependency_constraint(name)
165
168
  end
166
169
 
167
- sig { params(composer_json: T::Hash[String, T.untyped]).returns(T::Boolean) }
170
+ sig { params(composer_json: ManifestDocument).returns(T::Boolean) }
168
171
  def self.invalid_v2_requirement?(composer_json)
169
- return false unless composer_json.key?(PackageManager::REQUIRE_KEY)
170
-
171
- composer_json[PackageManager::REQUIRE_KEY].keys.any? do |key|
172
+ composer_json.required_dependency_names.any? do |key|
172
173
  key !~ PLATFORM_PACKAGE_REGEX && key !~ COMPOSER_V2_NAME_REGEX
173
174
  end
174
175
  end
@@ -0,0 +1,180 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "sorbet-runtime"
6
+
7
+ require "dependabot/dependency_file"
8
+ require "dependabot/dependency_requirement"
9
+ require "dependabot/composer/document_value_parser"
10
+
11
+ module Dependabot
12
+ module Composer
13
+ class LockfileDocument
14
+ extend T::Sig
15
+
16
+ class PackageRecord
17
+ extend T::Sig
18
+
19
+ sig { params(data: DocumentValueParser::ObjectHash, context: String).void }
20
+ def initialize(data:, context:)
21
+ @data = data
22
+ @context = context
23
+ end
24
+
25
+ sig { returns(T.nilable(String)) }
26
+ def name
27
+ value = @data["name"]
28
+ value if value.is_a?(String)
29
+ end
30
+
31
+ sig { returns(T.nilable(String)) }
32
+ def version
33
+ @data["version"]&.to_s
34
+ end
35
+
36
+ sig { returns(T.nilable(String)) }
37
+ def required_version
38
+ @data.fetch("version")&.to_s
39
+ end
40
+
41
+ sig { returns(T::Boolean) }
42
+ def path_source?
43
+ @data["source"].nil? && path_distribution?
44
+ end
45
+
46
+ sig { returns(T::Boolean) }
47
+ def path_distribution?
48
+ section("dist")&.[]("type") == "path"
49
+ end
50
+
51
+ sig { params(path: String).returns(T::Boolean) }
52
+ def matches_distribution_path?(path)
53
+ section("dist")&.[]("url") == path
54
+ end
55
+
56
+ sig { params(prefix: String).returns(T::Boolean) }
57
+ def dist_url_starts_with?(prefix)
58
+ section("dist")&.[]("url").to_s.start_with?(prefix)
59
+ end
60
+
61
+ sig { returns(String) }
62
+ def dist_url
63
+ DocumentValueParser.string(section("dist")&.[]("url"), "#{@context} dist.url")
64
+ end
65
+
66
+ sig { returns(String) }
67
+ def to_manifest_json
68
+ @data.to_json
69
+ end
70
+
71
+ sig { returns(T.nilable(String)) }
72
+ def source_reference
73
+ DocumentValueParser.optional_string(section("source")&.[]("reference"), "#{@context} source.reference")
74
+ end
75
+
76
+ sig { returns(T.nilable(Dependabot::DependencyRequirement::ObjectHash)) }
77
+ def git_source
78
+ source = section("source")
79
+ return unless source && source["type"] == "git"
80
+
81
+ # The URL is passed through as source metadata, not interpreted by the file parser.
82
+ { type: "git", url: source["url"] }
83
+ end
84
+
85
+ private
86
+
87
+ sig { params(key: String).returns(T.nilable(DocumentValueParser::ObjectHash)) }
88
+ def section(key)
89
+ value = @data[key]
90
+ return if value.nil?
91
+
92
+ DocumentValueParser.object_hash(value, "#{@context} #{key}")
93
+ end
94
+ end
95
+
96
+ sig { params(file: Dependabot::DependencyFile).returns(LockfileDocument) }
97
+ def self.from_file(file)
98
+ new(data: T.cast(JSON.parse(T.must(file.content)), Object), context: file.path)
99
+ end
100
+
101
+ sig { params(data: Object, context: String).void }
102
+ def initialize(data:, context:)
103
+ @data = T.let(DocumentValueParser.object_hash(data, context), DocumentValueParser::ObjectHash)
104
+ @context = context
105
+ end
106
+
107
+ sig { returns(T.nilable(T.any(String, Integer))) }
108
+ def plugin_api_version
109
+ value = @data["plugin-api-version"]
110
+ return unless value
111
+ return value if value.is_a?(String) || value.is_a?(Integer)
112
+
113
+ raise TypeError, "#{@context} plugin-api-version must be a string or integer"
114
+ end
115
+
116
+ sig { params(group: String).returns(T::Array[PackageRecord]) }
117
+ def packages(group)
118
+ entries = @data[group]
119
+ return [] unless entries.is_a?(Array)
120
+
121
+ entries.each_with_index.filter_map { |entry, index| package_record(T.cast(entry, Object), group, index) }
122
+ end
123
+
124
+ sig { params(group: String, name: String).returns(T.nilable(PackageRecord)) }
125
+ def find_package(group, name)
126
+ entries = @data.fetch(group, [])
127
+ return if entries.nil?
128
+ raise TypeError, "#{@context} #{group} must be an array" unless entries.is_a?(Array)
129
+
130
+ entries.each_with_index do |entry, index|
131
+ package = package_record(T.cast(entry, Object), group, index)
132
+ return package if package&.name == name
133
+ end
134
+ nil
135
+ end
136
+
137
+ sig { params(group: String).returns(T::Array[PackageRecord]) }
138
+ def path_packages(group)
139
+ distribution_entries(group).each_with_index.filter_map do |entry, index|
140
+ package = strict_package_record(entry, group, index)
141
+ package if package.path_distribution?
142
+ end
143
+ end
144
+
145
+ sig { params(group: String, path: String).returns(T.nilable(PackageRecord)) }
146
+ def find_path_package(group, path)
147
+ distribution_entries(group).each_with_index do |entry, index|
148
+ package = strict_package_record(entry, group, index)
149
+ return package if package.matches_distribution_path?(path)
150
+ end
151
+ nil
152
+ end
153
+
154
+ private
155
+
156
+ sig { params(group: String).returns(T::Array[Object]) }
157
+ def distribution_entries(group)
158
+ entries = @data[group]
159
+ return [] unless entries
160
+ return [] if entries.is_a?(Hash) && entries.empty?
161
+ raise TypeError, "#{@context} #{group} must be an array" unless entries.is_a?(Array)
162
+
163
+ entries.map { |entry| T.cast(entry, Object) }
164
+ end
165
+
166
+ sig { params(value: Object, group: String, index: Integer).returns(T.nilable(PackageRecord)) }
167
+ def package_record(value, group, index)
168
+ return if value.is_a?(String)
169
+
170
+ strict_package_record(value, group, index)
171
+ end
172
+
173
+ sig { params(value: Object, group: String, index: Integer).returns(PackageRecord) }
174
+ def strict_package_record(value, group, index)
175
+ context = "#{@context} #{group}[#{index}]"
176
+ PackageRecord.new(data: DocumentValueParser.object_hash(value, context), context: context)
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,145 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "sorbet-runtime"
6
+
7
+ require "dependabot/dependency_file"
8
+ require "dependabot/composer/document_value_parser"
9
+
10
+ module Dependabot
11
+ module Composer
12
+ class ManifestDocument
13
+ extend T::Sig
14
+
15
+ class DependencyEntry
16
+ extend T::Sig
17
+
18
+ sig { params(name: String, value: Object, context: String).void }
19
+ def initialize(name:, value:, context:)
20
+ @name = name
21
+ @value = value
22
+ @context = context
23
+ end
24
+
25
+ sig { returns(String) }
26
+ attr_reader :name
27
+
28
+ sig { returns(T.nilable(String)) }
29
+ def string_requirement
30
+ value = @value
31
+ value if value.is_a?(String)
32
+ end
33
+
34
+ sig { returns(String) }
35
+ def requirement
36
+ DocumentValueParser.string(@value, @context)
37
+ end
38
+ end
39
+
40
+ class Repository
41
+ extend T::Sig
42
+
43
+ sig { params(type: String, url: Object, context: String).void }
44
+ def initialize(type:, url:, context:)
45
+ @type = type
46
+ @url = url
47
+ @context = context
48
+ end
49
+
50
+ sig { returns(String) }
51
+ attr_reader :type
52
+
53
+ sig { returns(String) }
54
+ def url
55
+ DocumentValueParser.string(@url, "#{@context} url")
56
+ end
57
+ end
58
+
59
+ sig { params(file: Dependabot::DependencyFile).returns(ManifestDocument) }
60
+ def self.from_file(file)
61
+ new(data: T.cast(JSON.parse(T.must(file.content)), Object), context: file.path)
62
+ end
63
+
64
+ sig { params(data: Object, context: String).void }
65
+ def initialize(data:, context:)
66
+ @data = T.let(DocumentValueParser.object_hash(data, context), DocumentValueParser::ObjectHash)
67
+ @context = context
68
+ end
69
+
70
+ sig { returns(T.nilable(String)) }
71
+ def name
72
+ value = @data["name"]
73
+ return unless value
74
+
75
+ DocumentValueParser.string(value, "#{@context} name")
76
+ end
77
+
78
+ sig { params(group: String).returns(T::Array[DependencyEntry]) }
79
+ def requirements(group)
80
+ value = @data[group]
81
+ return [] unless value.is_a?(Hash)
82
+
83
+ DocumentValueParser.object_hash(value, "#{@context} #{group}").map do |name, requirement|
84
+ DependencyEntry.new(name: name, value: requirement, context: "#{@context} #{group} requirement")
85
+ end
86
+ end
87
+
88
+ sig { returns(T::Array[String]) }
89
+ def required_dependency_names
90
+ return [] unless @data.key?("require")
91
+
92
+ DocumentValueParser.object_hash(@data.fetch("require"), "#{@context} require").keys
93
+ end
94
+
95
+ sig { params(name: String).returns(T.nilable(String)) }
96
+ def platform(name)
97
+ config = section(@data, "config", "config")
98
+ platform = config && section(config, "platform", "config.platform")
99
+ DocumentValueParser.optional_string(platform&.[](name), "#{@context} config.platform.#{name}")
100
+ end
101
+
102
+ sig { params(name: String).returns(T.nilable(String)) }
103
+ def dependency_constraint(name)
104
+ requirements = section(@data, "require", "require")
105
+ DocumentValueParser.optional_string(requirements&.[](name), "#{@context} require.#{name}")
106
+ end
107
+
108
+ sig { returns(T::Array[Repository]) }
109
+ def repositories
110
+ value = @data["repositories"]
111
+ entries =
112
+ case value
113
+ when Hash then value.values
114
+ when Array then value
115
+ else return []
116
+ end
117
+
118
+ entries.each_with_index.filter_map do |raw_entry, index|
119
+ entry = T.cast(raw_entry, Object)
120
+ next unless entry.is_a?(Hash)
121
+
122
+ context = "#{@context} repositories[#{index}]"
123
+ details = DocumentValueParser.object_hash(entry, context)
124
+ type = details["type"]
125
+ next unless type.is_a?(String) && (type == "path" || type == "artifact")
126
+
127
+ Repository.new(type: type, url: details["url"], context: context)
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ sig do
134
+ params(data: DocumentValueParser::ObjectHash, key: String, field: String)
135
+ .returns(T.nilable(DocumentValueParser::ObjectHash))
136
+ end
137
+ def section(data, key, field)
138
+ value = data[key]
139
+ return if value.nil?
140
+
141
+ DocumentValueParser.object_hash(value, "#{@context} #{field}")
142
+ end
143
+ end
144
+ end
145
+ end
@@ -2,13 +2,13 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "json"
5
- require "time"
6
5
  require "excon"
7
6
  require "nokogiri"
8
7
  require "sorbet-runtime"
9
8
  require "dependabot/registry_client"
10
9
  require "dependabot/package/package_release"
11
10
  require "dependabot/package/package_details"
11
+ require "dependabot/composer/package/registry_package"
12
12
 
13
13
  # Stores metadata for a package, including all its available versions
14
14
  module Dependabot
@@ -46,7 +46,7 @@ module Dependabot
46
46
  @security_advisories = security_advisories
47
47
 
48
48
  @registry_urls = T.let(nil, T.nilable(T::Array[String]))
49
- @registry_version_details = T.let(nil, T.nilable(T::Array[T::Hash[String, T.untyped]]))
49
+ @registry_version_details = T.let(nil, T.nilable(T::Array[RegistryPackage::Release]))
50
50
  end
51
51
 
52
52
  sig { returns(Dependabot::Dependency) }
@@ -66,38 +66,18 @@ module Dependabot
66
66
 
67
67
  sig { returns(T::Array[Dependabot::Package::PackageRelease]) }
68
68
  def fetch_releases
69
- available_version_details = registry_version_details
70
- .select do |version_details|
71
- version = version_details.fetch("version")
72
- version && version_class.correct?(version.gsub(/^v/, ""))
73
- end
74
-
75
- releases = available_version_details.map do |version_details|
76
- format_version_release(version_details)
77
- end
78
- releases
69
+ registry_version_details.filter_map { |release| format_version_release(release) }
79
70
  end
80
71
 
81
72
  sig { returns(Dependabot::Package::PackageDetails) }
82
73
  def fetch
83
- available_version_details = registry_version_details
84
- .select do |version_details|
85
- version = version_details.fetch("version")
86
- version && version_class.correct?(version.gsub(/^v/, ""))
87
- end
88
-
89
- releases = available_version_details.map do |version_details|
90
- format_version_release(version_details)
91
- end
92
74
  Dependabot::Package::PackageDetails.new(
93
75
  dependency: dependency,
94
- releases: releases.reverse.uniq(&:version)
76
+ releases: fetch_releases.reverse.uniq(&:version)
95
77
  )
96
78
  end
97
79
 
98
- sig do
99
- returns(T::Array[T::Hash[String, T.untyped]])
100
- end
80
+ sig { returns(T::Array[RegistryPackage::Release]) }
101
81
  def registry_version_details
102
82
  return @registry_version_details unless @registry_version_details.nil?
103
83
 
@@ -114,16 +94,15 @@ module Dependabot
114
94
  urls << "https://repo.packagist.org/p2/#{dependency.name.downcase}.json"
115
95
  end
116
96
 
117
- @registry_version_details = []
97
+ releases = T.let([], T::Array[RegistryPackage::Release])
118
98
  urls.each do |url|
119
- @registry_version_details += fetch_registry_versions_from_url(url)
99
+ releases.concat(fetch_registry_versions_from_url(url))
120
100
  end
121
101
 
122
- @registry_version_details.uniq! { |version_details| version_details["version"] }
123
- @registry_version_details
102
+ @registry_version_details = releases.uniq(&:version_string)
124
103
  end
125
104
 
126
- sig { params(url: String).returns(T::Array[T::Hash[String, T.untyped]]) }
105
+ sig { params(url: String).returns(T::Array[RegistryPackage::Release]) }
127
106
  def fetch_registry_versions_from_url(url)
128
107
  url_host = URI(url).host
129
108
  cred = registry_credentials.find do |c|
@@ -146,60 +125,25 @@ module Dependabot
146
125
  []
147
126
  end
148
127
 
149
- sig { params(response: T.untyped, url: String).returns(T::Array[T::Hash[String, T.untyped]]) }
128
+ sig { params(response: Excon::Response, url: String).returns(T::Array[RegistryPackage::Release]) }
150
129
  def parse_registry_response(response, url)
151
130
  return [] unless response.status == 200
152
131
 
153
- listing = JSON.parse(response.body)
154
- return [] if listing.nil?
155
- return [] unless listing.is_a?(Hash)
156
- return [] if listing.fetch("packages", []) == []
157
- return [] unless listing.dig("packages", dependency.name.downcase)
158
-
159
- extract_versions(listing)
160
- rescue JSON::ParserError
161
- msg = "'#{url}' does not contain valid JSON"
162
- raise DependencyFileNotResolvable, msg
163
- end
164
-
165
- sig { params(listing: T::Hash[String, T.untyped]).returns(T::Array[T::Hash[String, T.untyped]]) }
166
- def extract_versions(listing)
167
- # Packagist's Metadata API format:
168
- # v1: "packages": {<package name>: {<version_number>: {hash of metadata for a particular release version}}}
169
- # v2: "packages": {<package name>: [{hash of metadata for a particular release version}]}
170
- version_listings = listing.dig("packages", dependency.name.downcase)
171
-
172
- if version_listings.is_a?(Hash) # some private registries are still using the v1 format
173
- # Regardless of API version, composer always reads the version from the metadata hash. So for the v1 API,
174
- # ignore the keys as repositories other than packagist.org could be using different keys. Instead, coerce
175
- # to an array of metadata hashes to match v2 format.
176
- version_listings = version_listings.values
177
- end
178
-
179
- if version_listings.is_a?(Array)
180
- version_listings
181
- else
182
- []
183
- end
132
+ RegistryPackage.from_json(response.body, package_name: dependency.name, source: url).releases
184
133
  end
185
134
 
186
135
  sig do
187
- params(
188
- release_data: T::Hash[String, T.untyped]
189
- )
190
- .returns(Dependabot::Package::PackageRelease)
136
+ params(release_data: RegistryPackage::Release).returns(T.nilable(Dependabot::Package::PackageRelease))
191
137
  end
192
138
  def format_version_release(release_data)
193
- version = release_data["version"].gsub(/^v/, "")
194
- released_at = release_data["time"] ? Time.parse(release_data["time"]) : nil # this will return nil if the time key is missing, avoiding error # rubocop:disable Layout/LineLength
195
- url = release_data["dist"] ? release_data["dist"]["url"] : nil
196
- package_type = PACKAGE_TYPE
139
+ version = release_data.version_string
140
+ return unless version && version_class.correct?(version.gsub(/^v/, ""))
197
141
 
198
142
  package_release(
199
- version: version,
200
- released_at: released_at,
201
- url: url,
202
- package_type: package_type
143
+ version: version.gsub(/^v/, ""),
144
+ released_at: release_data.released_at,
145
+ url: release_data.url,
146
+ package_type: PACKAGE_TYPE
203
147
  )
204
148
  end
205
149