purl 0.1.0 → 1.0.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 +4 -4
- data/CHANGELOG.md +43 -2
- data/CODE_OF_CONDUCT.md +1 -1
- data/LICENSE +21 -0
- data/README.md +273 -13
- data/Rakefile +385 -0
- data/lib/purl/errors.rb +64 -0
- data/lib/purl/package_url.rb +512 -0
- data/lib/purl/registry_url.rb +309 -0
- data/lib/purl/version.rb +1 -1
- data/lib/purl.rb +111 -1
- data/purl-types.json +358 -0
- data/test-suite-data.json +710 -0
- metadata +7 -1
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Purl
|
|
4
|
+
class RegistryURL
|
|
5
|
+
# Load registry patterns from JSON configuration
|
|
6
|
+
def self.load_registry_patterns
|
|
7
|
+
@registry_patterns ||= begin
|
|
8
|
+
# Load JSON config directly to avoid circular dependency
|
|
9
|
+
config_path = File.join(__dir__, "..", "..", "purl-types.json")
|
|
10
|
+
require "json"
|
|
11
|
+
config = JSON.parse(File.read(config_path))
|
|
12
|
+
patterns = {}
|
|
13
|
+
|
|
14
|
+
config["types"].each do |type, type_config|
|
|
15
|
+
# Only process types that have registry_config
|
|
16
|
+
next unless type_config["registry_config"]
|
|
17
|
+
|
|
18
|
+
registry_config = type_config["registry_config"]
|
|
19
|
+
patterns[type] = build_pattern_config(type, registry_config)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
patterns
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.build_pattern_config(type, config)
|
|
27
|
+
{
|
|
28
|
+
base_url: config["base_url"],
|
|
29
|
+
route_patterns: config["route_patterns"] || [],
|
|
30
|
+
reverse_regex: config["reverse_regex"] ? Regexp.new(config["reverse_regex"]) : nil,
|
|
31
|
+
pattern: build_generation_lambda(type, config),
|
|
32
|
+
reverse_parser: config["reverse_regex"] ? build_reverse_parser(type, config) : nil
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.build_generation_lambda(type, config)
|
|
37
|
+
case type
|
|
38
|
+
when "npm"
|
|
39
|
+
->(purl) do
|
|
40
|
+
if purl.namespace
|
|
41
|
+
"#{config["base_url"]}/#{purl.namespace}/#{purl.name}"
|
|
42
|
+
else
|
|
43
|
+
"#{config["base_url"]}/#{purl.name}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
when "composer", "maven", "swift"
|
|
47
|
+
->(purl) do
|
|
48
|
+
if purl.namespace
|
|
49
|
+
"#{config["base_url"]}/#{purl.namespace}/#{purl.name}"
|
|
50
|
+
else
|
|
51
|
+
raise MissingRegistryInfoError.new(
|
|
52
|
+
"#{type.capitalize} packages require a namespace",
|
|
53
|
+
type: purl.type,
|
|
54
|
+
missing: "namespace"
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
when "golang"
|
|
59
|
+
->(purl) do
|
|
60
|
+
if purl.namespace
|
|
61
|
+
"#{config["base_url"]}/#{purl.namespace}/#{purl.name}"
|
|
62
|
+
else
|
|
63
|
+
"#{config["base_url"]}/#{purl.name}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
when "pypi"
|
|
67
|
+
->(purl) { "#{config["base_url"]}/#{purl.name}/" }
|
|
68
|
+
when "hackage"
|
|
69
|
+
->(purl) do
|
|
70
|
+
if purl.version
|
|
71
|
+
"#{config["base_url"]}/#{purl.name}-#{purl.version}"
|
|
72
|
+
else
|
|
73
|
+
"#{config["base_url"]}/#{purl.name}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
when "deno"
|
|
77
|
+
->(purl) do
|
|
78
|
+
if purl.version
|
|
79
|
+
"#{config["base_url"]}/#{purl.name}@#{purl.version}"
|
|
80
|
+
else
|
|
81
|
+
"#{config["base_url"]}/#{purl.name}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
when "clojars"
|
|
85
|
+
->(purl) do
|
|
86
|
+
if purl.namespace
|
|
87
|
+
"#{config["base_url"]}/#{purl.namespace}/#{purl.name}"
|
|
88
|
+
else
|
|
89
|
+
"#{config["base_url"]}/#{purl.name}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
when "elm"
|
|
93
|
+
->(purl) do
|
|
94
|
+
if purl.namespace
|
|
95
|
+
version = purl.version || "latest"
|
|
96
|
+
"#{config["base_url"]}/#{purl.namespace}/#{purl.name}/#{version}"
|
|
97
|
+
else
|
|
98
|
+
raise MissingRegistryInfoError.new(
|
|
99
|
+
"Elm packages require a namespace",
|
|
100
|
+
type: purl.type,
|
|
101
|
+
missing: "namespace"
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
else
|
|
106
|
+
->(purl) { "#{config["base_url"]}/#{purl.name}" }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.build_reverse_parser(type, config)
|
|
111
|
+
case type
|
|
112
|
+
when "npm"
|
|
113
|
+
->(match) do
|
|
114
|
+
namespace = match[1] # @scope or nil
|
|
115
|
+
name = match[2]
|
|
116
|
+
version = match[3] # from /v/version or nil
|
|
117
|
+
{ type: type, namespace: namespace, name: name, version: version }
|
|
118
|
+
end
|
|
119
|
+
when "gem"
|
|
120
|
+
->(match) do
|
|
121
|
+
name = match[1]
|
|
122
|
+
version = match[2] # from /versions/version or nil
|
|
123
|
+
{ type: type, namespace: nil, name: name, version: version }
|
|
124
|
+
end
|
|
125
|
+
when "maven"
|
|
126
|
+
->(match) do
|
|
127
|
+
namespace = match[1]
|
|
128
|
+
name = match[2]
|
|
129
|
+
version = match[3]
|
|
130
|
+
{ type: type, namespace: namespace, name: name, version: version }
|
|
131
|
+
end
|
|
132
|
+
when "pypi"
|
|
133
|
+
->(match) do
|
|
134
|
+
name = match[1]
|
|
135
|
+
version = match[2] unless match[2] == name # avoid duplicate name as version
|
|
136
|
+
{ type: type, namespace: nil, name: name, version: version }
|
|
137
|
+
end
|
|
138
|
+
when "cargo"
|
|
139
|
+
->(match) do
|
|
140
|
+
name = match[1]
|
|
141
|
+
{ type: type, namespace: nil, name: name, version: nil }
|
|
142
|
+
end
|
|
143
|
+
when "golang"
|
|
144
|
+
->(match) do
|
|
145
|
+
if match[1] && match[2]
|
|
146
|
+
# Has namespace: pkg.go.dev/namespace/name
|
|
147
|
+
namespace = match[1]
|
|
148
|
+
name = match[2]
|
|
149
|
+
else
|
|
150
|
+
# No namespace: pkg.go.dev/name
|
|
151
|
+
namespace = nil
|
|
152
|
+
name = match[1] || match[2]
|
|
153
|
+
end
|
|
154
|
+
{ type: type, namespace: namespace, name: name, version: nil }
|
|
155
|
+
end
|
|
156
|
+
when "hackage"
|
|
157
|
+
->(match) do
|
|
158
|
+
name = match[1]
|
|
159
|
+
version = match[2] # from name-version pattern
|
|
160
|
+
{ type: type, namespace: nil, name: name, version: version }
|
|
161
|
+
end
|
|
162
|
+
when "deno"
|
|
163
|
+
->(match) do
|
|
164
|
+
name = match[1]
|
|
165
|
+
version = match[2] # from @version pattern
|
|
166
|
+
{ type: type, namespace: nil, name: name, version: version }
|
|
167
|
+
end
|
|
168
|
+
when "homebrew"
|
|
169
|
+
->(match) do
|
|
170
|
+
name = match[1]
|
|
171
|
+
{ type: type, namespace: nil, name: name, version: nil }
|
|
172
|
+
end
|
|
173
|
+
when "elm"
|
|
174
|
+
->(match) do
|
|
175
|
+
namespace = match[1]
|
|
176
|
+
name = match[2]
|
|
177
|
+
version = match[3] unless match[3] == "latest"
|
|
178
|
+
{ type: type, namespace: namespace, name: name, version: version }
|
|
179
|
+
end
|
|
180
|
+
else
|
|
181
|
+
->(match) do
|
|
182
|
+
{ type: type, namespace: nil, name: match[1], version: nil }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Registry patterns loaded from JSON configuration
|
|
188
|
+
REGISTRY_PATTERNS = load_registry_patterns.freeze
|
|
189
|
+
|
|
190
|
+
def self.generate(purl)
|
|
191
|
+
new(purl).generate
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def self.supported_types
|
|
195
|
+
REGISTRY_PATTERNS.keys.sort
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def self.supports?(type)
|
|
199
|
+
REGISTRY_PATTERNS.key?(type.to_s.downcase)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def self.from_url(registry_url)
|
|
203
|
+
# Try to parse the registry URL back into a PURL
|
|
204
|
+
REGISTRY_PATTERNS.each do |type, config|
|
|
205
|
+
next unless config[:reverse_regex] && config[:reverse_parser]
|
|
206
|
+
|
|
207
|
+
match = registry_url.match(config[:reverse_regex])
|
|
208
|
+
if match
|
|
209
|
+
parsed_data = config[:reverse_parser].call(match)
|
|
210
|
+
return PackageURL.new(
|
|
211
|
+
type: parsed_data[:type],
|
|
212
|
+
namespace: parsed_data[:namespace],
|
|
213
|
+
name: parsed_data[:name],
|
|
214
|
+
version: parsed_data[:version]
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
raise UnsupportedTypeError.new(
|
|
220
|
+
"Unable to parse registry URL: #{registry_url}. No matching pattern found.",
|
|
221
|
+
supported_types: REGISTRY_PATTERNS.keys.select { |k| REGISTRY_PATTERNS[k][:reverse_regex] }
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def self.supported_reverse_types
|
|
226
|
+
REGISTRY_PATTERNS.select { |_, config| config[:reverse_regex] }.keys.sort
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def self.route_patterns_for(type)
|
|
230
|
+
pattern_config = REGISTRY_PATTERNS[type.to_s.downcase]
|
|
231
|
+
return [] unless pattern_config
|
|
232
|
+
|
|
233
|
+
pattern_config[:route_patterns] || []
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def self.all_route_patterns
|
|
237
|
+
result = {}
|
|
238
|
+
REGISTRY_PATTERNS.each do |type, config|
|
|
239
|
+
if config[:route_patterns]
|
|
240
|
+
result[type] = config[:route_patterns]
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
result
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def initialize(purl)
|
|
247
|
+
@purl = purl
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def generate
|
|
251
|
+
pattern_config = REGISTRY_PATTERNS[@purl.type.downcase]
|
|
252
|
+
|
|
253
|
+
unless pattern_config
|
|
254
|
+
raise UnsupportedTypeError.new(
|
|
255
|
+
"No registry URL pattern defined for type '#{@purl.type}'. Supported types: #{self.class.supported_types.join(", ")}",
|
|
256
|
+
type: @purl.type,
|
|
257
|
+
supported_types: self.class.supported_types
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
begin
|
|
262
|
+
pattern_config[:pattern].call(@purl)
|
|
263
|
+
rescue MissingRegistryInfoError
|
|
264
|
+
raise
|
|
265
|
+
rescue => e
|
|
266
|
+
raise RegistryError, "Failed to generate registry URL for #{@purl.type}: #{e.message}"
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def generate_with_version
|
|
271
|
+
base_url = generate
|
|
272
|
+
|
|
273
|
+
case @purl.type.downcase
|
|
274
|
+
when "npm"
|
|
275
|
+
@purl.version ? "#{base_url}/v/#{@purl.version}" : base_url
|
|
276
|
+
when "pypi"
|
|
277
|
+
@purl.version ? "#{base_url}#{@purl.version}/" : base_url
|
|
278
|
+
when "gem"
|
|
279
|
+
@purl.version ? "#{base_url}/versions/#{@purl.version}" : base_url
|
|
280
|
+
when "maven"
|
|
281
|
+
@purl.version ? "#{base_url}/#{@purl.version}" : base_url
|
|
282
|
+
when "nuget"
|
|
283
|
+
@purl.version ? "#{base_url}/#{@purl.version}" : base_url
|
|
284
|
+
else
|
|
285
|
+
# For other types, just return the base URL since version-specific URLs vary
|
|
286
|
+
base_url
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
private
|
|
291
|
+
|
|
292
|
+
attr_reader :purl
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Add registry URL generation methods to PackageURL
|
|
296
|
+
class PackageURL
|
|
297
|
+
def registry_url
|
|
298
|
+
RegistryURL.generate(self)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def registry_url_with_version
|
|
302
|
+
RegistryURL.new(self).generate_with_version
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def supports_registry_url?
|
|
306
|
+
RegistryURL.supports?(type)
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
data/lib/purl/version.rb
CHANGED
data/lib/purl.rb
CHANGED
|
@@ -1,8 +1,118 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "purl/version"
|
|
4
|
+
require_relative "purl/errors"
|
|
5
|
+
require_relative "purl/package_url"
|
|
6
|
+
require_relative "purl/registry_url"
|
|
4
7
|
|
|
5
8
|
module Purl
|
|
6
9
|
class Error < StandardError; end
|
|
7
|
-
|
|
10
|
+
|
|
11
|
+
# Load PURL types configuration from JSON file
|
|
12
|
+
def self.load_types_config
|
|
13
|
+
@types_config ||= begin
|
|
14
|
+
config_path = File.join(__dir__, "..", "purl-types.json")
|
|
15
|
+
require "json"
|
|
16
|
+
JSON.parse(File.read(config_path))
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Known PURL types loaded from JSON configuration
|
|
21
|
+
KNOWN_TYPES = load_types_config["types"].keys.sort.freeze
|
|
22
|
+
|
|
23
|
+
# Convenience method for parsing PURL strings
|
|
24
|
+
def self.parse(purl_string)
|
|
25
|
+
PackageURL.parse(purl_string)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Convenience method for parsing registry URLs back to PURLs
|
|
29
|
+
def self.from_registry_url(registry_url)
|
|
30
|
+
RegistryURL.from_url(registry_url)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns all known PURL types
|
|
34
|
+
def self.known_types
|
|
35
|
+
KNOWN_TYPES.dup
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns types that have registry URL support
|
|
39
|
+
def self.registry_supported_types
|
|
40
|
+
RegistryURL.supported_types
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns types that support reverse parsing from registry URLs
|
|
44
|
+
def self.reverse_parsing_supported_types
|
|
45
|
+
RegistryURL.supported_reverse_types
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Check if a type is known/valid
|
|
49
|
+
def self.known_type?(type)
|
|
50
|
+
KNOWN_TYPES.include?(type.to_s.downcase)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Get type information including registry support
|
|
54
|
+
def self.type_info(type)
|
|
55
|
+
normalized_type = type.to_s.downcase
|
|
56
|
+
{
|
|
57
|
+
type: normalized_type,
|
|
58
|
+
known: known_type?(normalized_type),
|
|
59
|
+
registry_url_generation: RegistryURL.supports?(normalized_type),
|
|
60
|
+
reverse_parsing: RegistryURL.supported_reverse_types.include?(normalized_type),
|
|
61
|
+
route_patterns: RegistryURL.route_patterns_for(normalized_type)
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Get comprehensive information about all types
|
|
66
|
+
def self.all_type_info
|
|
67
|
+
result = {}
|
|
68
|
+
|
|
69
|
+
# Start with known types
|
|
70
|
+
KNOWN_TYPES.each do |type|
|
|
71
|
+
result[type] = type_info(type)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Add any registry-supported types not in known list
|
|
75
|
+
RegistryURL.supported_types.each do |type|
|
|
76
|
+
unless result.key?(type)
|
|
77
|
+
result[type] = type_info(type)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
result
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Get type configuration from JSON
|
|
85
|
+
def self.type_config(type)
|
|
86
|
+
config = load_types_config["types"][type.to_s.downcase]
|
|
87
|
+
return nil unless config
|
|
88
|
+
|
|
89
|
+
config.dup # Return a copy to prevent modification
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Get description for a type
|
|
93
|
+
def self.type_description(type)
|
|
94
|
+
config = type_config(type)
|
|
95
|
+
config ? config["description"] : nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Get registry configuration for a type
|
|
99
|
+
def self.registry_config(type)
|
|
100
|
+
config = type_config(type)
|
|
101
|
+
return nil unless config
|
|
102
|
+
|
|
103
|
+
config["registry_config"]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Get metadata about the types configuration
|
|
107
|
+
def self.types_config_metadata
|
|
108
|
+
config = load_types_config
|
|
109
|
+
{
|
|
110
|
+
version: config["version"],
|
|
111
|
+
description: config["description"],
|
|
112
|
+
source: config["source"],
|
|
113
|
+
last_updated: config["last_updated"],
|
|
114
|
+
total_types: config["types"].keys.length,
|
|
115
|
+
registry_supported_types: config["types"].select { |_, v| v["registry_config"] }.keys.length
|
|
116
|
+
}
|
|
117
|
+
end
|
|
8
118
|
end
|