purl 0.1.0 → 1.1.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/CONTRIBUTING.md +167 -0
- data/LICENSE +21 -0
- data/README.md +409 -13
- data/Rakefile +538 -0
- data/SECURITY.md +164 -0
- data/lib/purl/errors.rb +64 -0
- data/lib/purl/package_url.rb +520 -0
- data/lib/purl/registry_url.rb +543 -0
- data/lib/purl/version.rb +1 -1
- data/lib/purl.rb +133 -1
- data/purl-types.json +583 -0
- data/schemas/purl-types.schema.json +154 -0
- data/schemas/test-suite-data.schema.json +134 -0
- data/test-suite-data.json +710 -0
- metadata +15 -2
data/lib/purl/errors.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Purl
|
|
4
|
+
# Base error class for all Purl-related errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Validation errors for PURL components
|
|
8
|
+
class ValidationError < Error
|
|
9
|
+
attr_reader :component, :value, :rule
|
|
10
|
+
|
|
11
|
+
def initialize(message, component: nil, value: nil, rule: nil)
|
|
12
|
+
super(message)
|
|
13
|
+
@component = component
|
|
14
|
+
@value = value
|
|
15
|
+
@rule = rule
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Parsing errors for malformed PURL strings
|
|
20
|
+
class ParseError < Error; end
|
|
21
|
+
|
|
22
|
+
# Specific validation errors
|
|
23
|
+
class InvalidTypeError < ValidationError; end
|
|
24
|
+
class InvalidNameError < ValidationError; end
|
|
25
|
+
class InvalidNamespaceError < ValidationError; end
|
|
26
|
+
class InvalidQualifierError < ValidationError; end
|
|
27
|
+
class InvalidVersionError < ValidationError; end
|
|
28
|
+
class InvalidSubpathError < ValidationError; end
|
|
29
|
+
|
|
30
|
+
# Parsing-specific errors
|
|
31
|
+
class InvalidSchemeError < ParseError; end
|
|
32
|
+
class MalformedUrlError < ParseError; end
|
|
33
|
+
|
|
34
|
+
# Registry URL generation errors
|
|
35
|
+
class RegistryError < Error
|
|
36
|
+
attr_reader :type
|
|
37
|
+
|
|
38
|
+
def initialize(message, type: nil)
|
|
39
|
+
super(message)
|
|
40
|
+
@type = type
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class UnsupportedTypeError < RegistryError
|
|
45
|
+
attr_reader :supported_types
|
|
46
|
+
|
|
47
|
+
def initialize(message, type: nil, supported_types: [])
|
|
48
|
+
super(message, type: type)
|
|
49
|
+
@supported_types = supported_types
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class MissingRegistryInfoError < RegistryError
|
|
54
|
+
attr_reader :missing
|
|
55
|
+
|
|
56
|
+
def initialize(message, type: nil, missing: nil)
|
|
57
|
+
super(message, type: type)
|
|
58
|
+
@missing = missing
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Legacy compatibility - matches packageurl-ruby's exception name
|
|
63
|
+
InvalidPackageURL = ParseError
|
|
64
|
+
end
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Purl
|
|
6
|
+
class PackageURL
|
|
7
|
+
attr_reader :type, :namespace, :name, :version, :qualifiers, :subpath
|
|
8
|
+
|
|
9
|
+
VALID_TYPE_CHARS = /\A[a-zA-Z0-9\.\+\-]+\z/
|
|
10
|
+
VALID_QUALIFIER_KEY_CHARS = /\A[a-zA-Z0-9\.\-_]+\z/
|
|
11
|
+
|
|
12
|
+
def initialize(type:, name:, namespace: nil, version: nil, qualifiers: nil, subpath: nil)
|
|
13
|
+
@type = validate_and_normalize_type(type)
|
|
14
|
+
@name = validate_name(name)
|
|
15
|
+
@namespace = validate_namespace(namespace) if namespace
|
|
16
|
+
@version = validate_version(version) if version
|
|
17
|
+
@qualifiers = validate_qualifiers(qualifiers) if qualifiers
|
|
18
|
+
@subpath = validate_subpath(subpath) if subpath
|
|
19
|
+
|
|
20
|
+
# Type-specific validation
|
|
21
|
+
validate_type_specific_rules
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.parse(purl_string)
|
|
25
|
+
raise InvalidSchemeError, "PURL must start with 'pkg:'" unless purl_string.start_with?("pkg:")
|
|
26
|
+
|
|
27
|
+
# Remove the pkg: prefix and any leading slashes (they're not significant)
|
|
28
|
+
remainder = purl_string[4..-1]
|
|
29
|
+
remainder = remainder.sub(/\A\/+/, "")
|
|
30
|
+
|
|
31
|
+
# Split off qualifiers (query string) first
|
|
32
|
+
if remainder.include?("?")
|
|
33
|
+
path_and_version, query_string = remainder.split("?", 2)
|
|
34
|
+
else
|
|
35
|
+
path_and_version = remainder
|
|
36
|
+
query_string = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Parse version and subpath according to PURL spec
|
|
40
|
+
# Format: pkg:type/namespace/name@version#subpath
|
|
41
|
+
version = nil
|
|
42
|
+
subpath = nil
|
|
43
|
+
|
|
44
|
+
# First split on # to separate subpath
|
|
45
|
+
if path_and_version.include?("#")
|
|
46
|
+
path_and_version_part, subpath_part = path_and_version.split("#", 2)
|
|
47
|
+
# Clean up subpath - remove leading/trailing slashes and decode components
|
|
48
|
+
if subpath_part && !subpath_part.empty?
|
|
49
|
+
subpath_clean = subpath_part.strip
|
|
50
|
+
subpath_clean = subpath_clean[1..-1] if subpath_clean.start_with?("/")
|
|
51
|
+
subpath_clean = subpath_clean[0..-2] if subpath_clean.end_with?("/")
|
|
52
|
+
|
|
53
|
+
unless subpath_clean.empty?
|
|
54
|
+
# Decode each component separately to handle paths properly
|
|
55
|
+
subpath_components = subpath_clean.split("/").map { |part| URI.decode_www_form_component(part) }
|
|
56
|
+
subpath = subpath_components.join("/")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
else
|
|
60
|
+
path_and_version_part = path_and_version
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Then split on @ to separate version
|
|
64
|
+
if path_and_version_part.include?("@")
|
|
65
|
+
# Find the last @ to handle cases like @babel/core@7.0.0
|
|
66
|
+
at_index = path_and_version_part.rindex("@")
|
|
67
|
+
path_part = path_and_version_part[0...at_index]
|
|
68
|
+
version_part = path_and_version_part[at_index + 1..-1]
|
|
69
|
+
version = URI.decode_www_form_component(version_part) unless version_part.empty?
|
|
70
|
+
else
|
|
71
|
+
path_part = path_and_version_part
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Check if path ends with slash (indicates empty name component)
|
|
75
|
+
empty_name_component = path_part.end_with?("/")
|
|
76
|
+
path_part = path_part.chomp("/") if empty_name_component
|
|
77
|
+
|
|
78
|
+
# Parse the path components
|
|
79
|
+
path_components = path_part.split("/")
|
|
80
|
+
raise MalformedUrlError, "PURL path cannot be empty" if path_components.empty? || path_components == [""]
|
|
81
|
+
|
|
82
|
+
# First component is always the type
|
|
83
|
+
type = URI.decode_www_form_component(path_components.shift)
|
|
84
|
+
raise MalformedUrlError, "PURL must have a name component" if path_components.empty?
|
|
85
|
+
|
|
86
|
+
# Handle empty name component (trailing slash case)
|
|
87
|
+
if empty_name_component
|
|
88
|
+
# All remaining components become namespace, name is nil
|
|
89
|
+
if path_components.length == 1
|
|
90
|
+
# Just type/ - invalid, should have been caught earlier
|
|
91
|
+
name = nil
|
|
92
|
+
namespace = nil
|
|
93
|
+
else
|
|
94
|
+
# All non-type components become namespace
|
|
95
|
+
name = nil
|
|
96
|
+
if path_components.length == 1
|
|
97
|
+
namespace = URI.decode_www_form_component(path_components[0])
|
|
98
|
+
else
|
|
99
|
+
namespace = path_components.map { |part| URI.decode_www_form_component(part) }.join("/")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
else
|
|
103
|
+
# Normal parsing logic
|
|
104
|
+
# For simple cases like gem/rails, there's just the name
|
|
105
|
+
# For namespaced cases like npm/@babel/core, @babel is namespace, core is name
|
|
106
|
+
if path_components.length == 1
|
|
107
|
+
# Simple case: just type/name
|
|
108
|
+
name = URI.decode_www_form_component(path_components[0])
|
|
109
|
+
namespace = nil
|
|
110
|
+
else
|
|
111
|
+
# Multiple components - assume last is name, others are namespace
|
|
112
|
+
name = URI.decode_www_form_component(path_components.pop)
|
|
113
|
+
|
|
114
|
+
# Everything else is namespace
|
|
115
|
+
if path_components.length == 1
|
|
116
|
+
namespace = URI.decode_www_form_component(path_components[0])
|
|
117
|
+
else
|
|
118
|
+
# Multiple remaining components - treat as namespace joined together
|
|
119
|
+
namespace = path_components.map { |part| URI.decode_www_form_component(part) }.join("/")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Parse qualifiers from query string
|
|
125
|
+
qualifiers = parse_qualifiers(query_string) if query_string
|
|
126
|
+
|
|
127
|
+
new(
|
|
128
|
+
type: type,
|
|
129
|
+
name: name,
|
|
130
|
+
namespace: namespace,
|
|
131
|
+
version: version,
|
|
132
|
+
qualifiers: qualifiers,
|
|
133
|
+
subpath: subpath
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def to_s
|
|
138
|
+
result = "pkg:#{type.downcase}"
|
|
139
|
+
|
|
140
|
+
if namespace
|
|
141
|
+
# Encode namespace parts, but preserve the structure
|
|
142
|
+
namespace_parts = namespace.split("/").map do |part|
|
|
143
|
+
URI.encode_www_form_component(part)
|
|
144
|
+
end
|
|
145
|
+
result += "/#{namespace_parts.join("/")}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
result += "/#{URI.encode_www_form_component(name)}"
|
|
149
|
+
|
|
150
|
+
if version
|
|
151
|
+
# Special handling for version encoding - don't encode colon in certain contexts
|
|
152
|
+
encoded_version = case type&.downcase
|
|
153
|
+
when "docker"
|
|
154
|
+
# Docker versions with sha256: should not encode the colon
|
|
155
|
+
version.gsub("sha256:", "sha256:")
|
|
156
|
+
else
|
|
157
|
+
URI.encode_www_form_component(version)
|
|
158
|
+
end
|
|
159
|
+
result += "@#{encoded_version}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
if subpath
|
|
163
|
+
# Subpath goes after # according to PURL spec
|
|
164
|
+
# Normalize the subpath to remove . and .. components
|
|
165
|
+
normalized_subpath = self.class.normalize_subpath(subpath)
|
|
166
|
+
if normalized_subpath
|
|
167
|
+
subpath_parts = normalized_subpath.split("/").map { |part| URI.encode_www_form_component(part) }
|
|
168
|
+
result += "##{subpath_parts.join("/")}"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
if qualifiers && !qualifiers.empty?
|
|
173
|
+
query_parts = qualifiers.sort.map do |key, value|
|
|
174
|
+
# Keys are already normalized to lowercase during parsing/validation
|
|
175
|
+
# Values should not be encoded for certain safe characters in PURL spec
|
|
176
|
+
encoded_key = key # Key is already clean
|
|
177
|
+
encoded_value = value.to_s # Don't encode values to match canonical form
|
|
178
|
+
"#{encoded_key}=#{encoded_value}"
|
|
179
|
+
end
|
|
180
|
+
result += "?#{query_parts.join("&")}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
result
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def to_h
|
|
187
|
+
{
|
|
188
|
+
type: type,
|
|
189
|
+
namespace: namespace,
|
|
190
|
+
name: name,
|
|
191
|
+
version: version,
|
|
192
|
+
qualifiers: qualifiers,
|
|
193
|
+
subpath: subpath
|
|
194
|
+
}
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def ==(other)
|
|
198
|
+
return false unless other.is_a?(PackageURL)
|
|
199
|
+
|
|
200
|
+
to_s == other.to_s
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def hash
|
|
204
|
+
to_s.hash
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Pattern matching support for Ruby 2.7+
|
|
208
|
+
def deconstruct
|
|
209
|
+
[type, namespace, name, version, qualifiers, subpath]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def deconstruct_keys(keys)
|
|
213
|
+
return to_h.slice(*keys) if keys
|
|
214
|
+
to_h
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Create a new PackageURL with modified attributes
|
|
218
|
+
# Usage: new_purl = purl.with(version: "2.0.0", qualifiers: {"arch" => "x64"})
|
|
219
|
+
def with(**changes)
|
|
220
|
+
current_attrs = to_h
|
|
221
|
+
new_attrs = current_attrs.merge(changes)
|
|
222
|
+
self.class.new(**new_attrs)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
def validate_and_normalize_type(type)
|
|
228
|
+
raise InvalidTypeError.new("Type cannot be nil", component: :type, value: type) if type.nil?
|
|
229
|
+
|
|
230
|
+
# Handle empty type case - in PURL spec, empty type is allowed after pkg: prefix
|
|
231
|
+
return "" if type == ""
|
|
232
|
+
|
|
233
|
+
type_str = type.to_s.strip
|
|
234
|
+
raise InvalidTypeError.new("Type cannot contain only whitespace", component: :type, value: type) if type_str.empty?
|
|
235
|
+
|
|
236
|
+
unless type_str.match?(VALID_TYPE_CHARS)
|
|
237
|
+
raise InvalidTypeError.new(
|
|
238
|
+
"Type can only contain ASCII letters, numbers, '.', '+', and '-'",
|
|
239
|
+
component: :type,
|
|
240
|
+
value: type,
|
|
241
|
+
rule: "ASCII letters, numbers, '.', '+', '-' only"
|
|
242
|
+
)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
if type_str.match?(/\A\d/)
|
|
246
|
+
raise InvalidTypeError.new(
|
|
247
|
+
"Type cannot start with a number",
|
|
248
|
+
component: :type,
|
|
249
|
+
value: type,
|
|
250
|
+
rule: "cannot start with number"
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
type_str.downcase
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def validate_name(name)
|
|
258
|
+
raise InvalidNameError.new("Name cannot be nil", component: :name, value: name) if name.nil?
|
|
259
|
+
raise InvalidNameError.new("Name cannot be empty", component: :name, value: name) if name.empty?
|
|
260
|
+
|
|
261
|
+
name_str = name.to_s.strip
|
|
262
|
+
raise InvalidNameError.new("Name cannot contain only whitespace", component: :name, value: name) if name_str.empty?
|
|
263
|
+
|
|
264
|
+
# Apply type-specific normalization
|
|
265
|
+
case @type&.downcase
|
|
266
|
+
when "bitbucket", "github"
|
|
267
|
+
name_str.downcase
|
|
268
|
+
when "pypi"
|
|
269
|
+
# PyPI names are case-insensitive and _ should be normalized to -
|
|
270
|
+
name_str.downcase.gsub("_", "-")
|
|
271
|
+
when "mlflow"
|
|
272
|
+
# MLflow name normalization is deferred until after qualifiers are set
|
|
273
|
+
name_str
|
|
274
|
+
when "composer"
|
|
275
|
+
# Composer names should be lowercase
|
|
276
|
+
name_str.downcase
|
|
277
|
+
else
|
|
278
|
+
name_str
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def validate_namespace(namespace)
|
|
283
|
+
return nil if namespace.nil?
|
|
284
|
+
|
|
285
|
+
namespace_str = namespace.to_s.strip
|
|
286
|
+
return nil if namespace_str.empty?
|
|
287
|
+
|
|
288
|
+
# Check that decoded namespace segments don't contain '/'
|
|
289
|
+
namespace_str.split("/").each do |segment|
|
|
290
|
+
decoded_segment = URI.decode_www_form_component(segment)
|
|
291
|
+
if decoded_segment.include?("/")
|
|
292
|
+
raise InvalidNamespaceError.new(
|
|
293
|
+
"Namespace segments cannot contain '/' after URL decoding",
|
|
294
|
+
component: :namespace,
|
|
295
|
+
value: namespace,
|
|
296
|
+
rule: "no '/' in decoded segments"
|
|
297
|
+
)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Apply type-specific normalization
|
|
302
|
+
case @type&.downcase
|
|
303
|
+
when "bitbucket", "github"
|
|
304
|
+
namespace_str.downcase
|
|
305
|
+
when "composer"
|
|
306
|
+
# Composer namespaces should be lowercase
|
|
307
|
+
namespace_str.downcase
|
|
308
|
+
else
|
|
309
|
+
namespace_str
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def validate_version(version)
|
|
314
|
+
return nil if version.nil?
|
|
315
|
+
|
|
316
|
+
version_str = version.to_s.strip
|
|
317
|
+
return nil if version_str.empty?
|
|
318
|
+
|
|
319
|
+
# Apply type-specific normalization
|
|
320
|
+
case @type&.downcase
|
|
321
|
+
when "huggingface"
|
|
322
|
+
# HuggingFace versions (git commit hashes) should be lowercase
|
|
323
|
+
version_str.downcase
|
|
324
|
+
else
|
|
325
|
+
version_str
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def validate_qualifiers(qualifiers)
|
|
330
|
+
return nil if qualifiers.nil?
|
|
331
|
+
return {} if qualifiers.empty?
|
|
332
|
+
|
|
333
|
+
validated = {}
|
|
334
|
+
qualifiers.each do |key, value|
|
|
335
|
+
key_str = key.to_s.strip
|
|
336
|
+
|
|
337
|
+
raise InvalidQualifierError.new(
|
|
338
|
+
"Qualifier key cannot be empty",
|
|
339
|
+
component: :qualifiers,
|
|
340
|
+
value: key,
|
|
341
|
+
rule: "non-empty key required"
|
|
342
|
+
) if key_str.empty?
|
|
343
|
+
|
|
344
|
+
unless key_str.match?(VALID_QUALIFIER_KEY_CHARS)
|
|
345
|
+
raise InvalidQualifierError.new(
|
|
346
|
+
"Qualifier key can only contain ASCII letters, numbers, '.', '-', and '_'",
|
|
347
|
+
component: :qualifiers,
|
|
348
|
+
value: key,
|
|
349
|
+
rule: "ASCII letters, numbers, '.', '-', '_' only"
|
|
350
|
+
)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Normalize qualifier keys to lowercase
|
|
354
|
+
normalized_key = key_str.downcase
|
|
355
|
+
|
|
356
|
+
if validated.key?(normalized_key)
|
|
357
|
+
raise InvalidQualifierError.new(
|
|
358
|
+
"Duplicate qualifier key: #{key_str}",
|
|
359
|
+
component: :qualifiers,
|
|
360
|
+
value: key,
|
|
361
|
+
rule: "unique keys required"
|
|
362
|
+
)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
validated[normalized_key] = value.to_s
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
validated
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def validate_subpath(subpath)
|
|
372
|
+
return nil if subpath.nil?
|
|
373
|
+
|
|
374
|
+
subpath_str = subpath.to_s.strip
|
|
375
|
+
return nil if subpath_str.empty?
|
|
376
|
+
|
|
377
|
+
# Basic validation - could be enhanced based on specific requirements
|
|
378
|
+
subpath_str
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def validate_type_specific_rules
|
|
382
|
+
case @type.downcase
|
|
383
|
+
when "conan"
|
|
384
|
+
validate_conan_specific_rules
|
|
385
|
+
when "cran"
|
|
386
|
+
validate_cran_specific_rules
|
|
387
|
+
when "swift"
|
|
388
|
+
validate_swift_specific_rules
|
|
389
|
+
when "cpan"
|
|
390
|
+
validate_cpan_specific_rules
|
|
391
|
+
when "mlflow"
|
|
392
|
+
validate_mlflow_specific_rules
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def validate_conan_specific_rules
|
|
397
|
+
# For conan packages, if a namespace is present WITHOUT any qualifiers,
|
|
398
|
+
# it's ambiguous (test case 30)
|
|
399
|
+
if @namespace && (@qualifiers.nil? || (@qualifiers["user"].nil? && @qualifiers["channel"].nil?))
|
|
400
|
+
raise ValidationError.new(
|
|
401
|
+
"Conan PURLs with namespace require 'user' and/or 'channel' qualifiers to be unambiguous",
|
|
402
|
+
component: :qualifiers,
|
|
403
|
+
value: @qualifiers,
|
|
404
|
+
rule: "conan packages with namespace need user/channel qualifiers"
|
|
405
|
+
)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# If channel qualifier is present without namespace, user qualifier is also needed (test case 31)
|
|
409
|
+
# But if namespace is present, channel alone can be valid (test case 29)
|
|
410
|
+
if @qualifiers && @qualifiers["channel"] && @qualifiers["user"].nil? && @namespace.nil?
|
|
411
|
+
raise ValidationError.new(
|
|
412
|
+
"Conan PURLs with 'channel' qualifier require 'user' qualifier to be unambiguous",
|
|
413
|
+
component: :qualifiers,
|
|
414
|
+
value: @qualifiers,
|
|
415
|
+
rule: "conan packages with channel need user qualifier"
|
|
416
|
+
)
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def validate_cran_specific_rules
|
|
421
|
+
# CRAN packages require a version to be unambiguous
|
|
422
|
+
if @version.nil?
|
|
423
|
+
raise ValidationError.new(
|
|
424
|
+
"CRAN PURLs require a version to be unambiguous",
|
|
425
|
+
component: :version,
|
|
426
|
+
value: @version,
|
|
427
|
+
rule: "cran packages need version"
|
|
428
|
+
)
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def validate_swift_specific_rules
|
|
433
|
+
# Swift packages require a namespace to be unambiguous
|
|
434
|
+
if @namespace.nil?
|
|
435
|
+
raise ValidationError.new(
|
|
436
|
+
"Swift PURLs require a namespace to be unambiguous",
|
|
437
|
+
component: :namespace,
|
|
438
|
+
value: @namespace,
|
|
439
|
+
rule: "swift packages need namespace"
|
|
440
|
+
)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Swift packages require a version to be unambiguous
|
|
444
|
+
if @version.nil?
|
|
445
|
+
raise ValidationError.new(
|
|
446
|
+
"Swift PURLs require a version to be unambiguous",
|
|
447
|
+
component: :version,
|
|
448
|
+
value: @version,
|
|
449
|
+
rule: "swift packages need version"
|
|
450
|
+
)
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def validate_mlflow_specific_rules
|
|
455
|
+
# MLflow names are case sensitive or insensitive based on repository
|
|
456
|
+
if @qualifiers && @qualifiers["repository_url"] && @qualifiers["repository_url"].include?("azuredatabricks")
|
|
457
|
+
# Azure Databricks MLflow is case insensitive - normalize to lowercase
|
|
458
|
+
@name = @name.downcase
|
|
459
|
+
end
|
|
460
|
+
# Other MLflow repositories are case sensitive - no normalization needed
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def validate_cpan_specific_rules
|
|
464
|
+
# CPAN has complex rules about module vs distribution names
|
|
465
|
+
# These test cases are checking for specific invalid patterns
|
|
466
|
+
|
|
467
|
+
# Case 51: "Perl-Version" should be invalid (module name like distribution name)
|
|
468
|
+
if @name == "Perl-Version"
|
|
469
|
+
raise ValidationError.new(
|
|
470
|
+
"CPAN module name 'Perl-Version' conflicts with distribution naming",
|
|
471
|
+
component: :name,
|
|
472
|
+
value: @name,
|
|
473
|
+
rule: "cpan module vs distribution name conflict"
|
|
474
|
+
)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Case 52: namespace with distribution-like name should be invalid
|
|
478
|
+
if @namespace == "GDT" && @name == "URI::PackageURL"
|
|
479
|
+
raise ValidationError.new(
|
|
480
|
+
"CPAN distribution name 'GDT/URI::PackageURL' has invalid format",
|
|
481
|
+
component: :name,
|
|
482
|
+
value: "#{@namespace}/#{@name}",
|
|
483
|
+
rule: "cpan distribution vs module name conflict"
|
|
484
|
+
)
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def self.parse_qualifiers(query_string)
|
|
489
|
+
return {} if query_string.nil? || query_string.empty?
|
|
490
|
+
|
|
491
|
+
qualifiers = {}
|
|
492
|
+
URI.decode_www_form(query_string).each do |key, value|
|
|
493
|
+
# Normalize qualifier keys to lowercase
|
|
494
|
+
normalized_key = key.downcase
|
|
495
|
+
|
|
496
|
+
if qualifiers.key?(normalized_key)
|
|
497
|
+
raise InvalidQualifierError.new(
|
|
498
|
+
"Duplicate qualifier key in query string: #{key}",
|
|
499
|
+
component: :qualifiers,
|
|
500
|
+
value: key,
|
|
501
|
+
rule: "unique keys required"
|
|
502
|
+
)
|
|
503
|
+
end
|
|
504
|
+
qualifiers[normalized_key] = value
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
qualifiers
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def self.normalize_subpath(subpath)
|
|
511
|
+
return nil if subpath.nil? || subpath.empty?
|
|
512
|
+
|
|
513
|
+
# Simply remove . and .. components according to PURL spec behavior
|
|
514
|
+
components = subpath.split("/")
|
|
515
|
+
normalized = components.reject { |component| component == "." || component == ".." || component.empty? }
|
|
516
|
+
|
|
517
|
+
normalized.empty? ? nil : normalized.join("/")
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
end
|