purl 1.0.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/CONTRIBUTING.md +167 -0
- data/README.md +159 -23
- data/Rakefile +153 -0
- data/SECURITY.md +164 -0
- data/lib/purl/package_url.rb +9 -1
- data/lib/purl/registry_url.rb +274 -40
- data/lib/purl/version.rb +1 -1
- data/lib/purl.rb +25 -3
- data/purl-types.json +242 -17
- data/schemas/purl-types.schema.json +154 -0
- data/schemas/test-suite-data.schema.json +134 -0
- metadata +9 -2
data/SECURITY.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported Versions
|
|
4
|
+
|
|
5
|
+
We actively support and provide security updates for the following versions:
|
|
6
|
+
|
|
7
|
+
| Version | Supported |
|
|
8
|
+
| ------- | ------------------ |
|
|
9
|
+
| 1.x.x | :white_check_mark: |
|
|
10
|
+
| < 1.0 | :x: |
|
|
11
|
+
|
|
12
|
+
## Reporting a Vulnerability
|
|
13
|
+
|
|
14
|
+
The Purl team takes security seriously. If you discover a security vulnerability, please follow these steps:
|
|
15
|
+
|
|
16
|
+
### 1. Do NOT Create a Public Issue
|
|
17
|
+
|
|
18
|
+
Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.
|
|
19
|
+
|
|
20
|
+
### 2. Report Privately
|
|
21
|
+
|
|
22
|
+
Send a detailed report to **andrew@ecosyste.ms** with:
|
|
23
|
+
|
|
24
|
+
- **Subject**: `[SECURITY] Purl Ruby - [Brief Description]`
|
|
25
|
+
- **Description** of the vulnerability
|
|
26
|
+
- **Steps to reproduce** the issue
|
|
27
|
+
- **Potential impact** assessment
|
|
28
|
+
- **Suggested fix** (if you have one)
|
|
29
|
+
- **Your contact information** for follow-up
|
|
30
|
+
|
|
31
|
+
### 3. What to Include
|
|
32
|
+
|
|
33
|
+
Please provide as much information as possible:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
- Affected versions
|
|
37
|
+
- Attack vectors
|
|
38
|
+
- Proof of concept (if safe to share)
|
|
39
|
+
- Environmental details (Ruby version, OS, etc.)
|
|
40
|
+
- Any relevant configuration details
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Response Process
|
|
44
|
+
|
|
45
|
+
### Initial Response
|
|
46
|
+
|
|
47
|
+
- **24-48 hours**: We will acknowledge receipt of your report
|
|
48
|
+
- **Initial assessment**: Within 1 week of acknowledgment
|
|
49
|
+
- **Status updates**: Weekly until resolution
|
|
50
|
+
|
|
51
|
+
### Investigation
|
|
52
|
+
|
|
53
|
+
We will:
|
|
54
|
+
1. **Confirm** the vulnerability exists
|
|
55
|
+
2. **Assess** the severity and impact
|
|
56
|
+
3. **Develop** a fix and mitigation strategy
|
|
57
|
+
4. **Test** the fix thoroughly
|
|
58
|
+
5. **Coordinate** disclosure timeline
|
|
59
|
+
|
|
60
|
+
### Resolution
|
|
61
|
+
|
|
62
|
+
- **High/Critical**: Immediate fix and release
|
|
63
|
+
- **Medium**: Fix within 30 days
|
|
64
|
+
- **Low**: Fix in next regular release cycle
|
|
65
|
+
|
|
66
|
+
## Security Considerations
|
|
67
|
+
|
|
68
|
+
### Input Validation
|
|
69
|
+
|
|
70
|
+
The Purl library processes Package URL strings and performs:
|
|
71
|
+
|
|
72
|
+
- **Scheme validation**: Ensures proper `pkg:` prefix
|
|
73
|
+
- **Component parsing**: Validates type, namespace, name, version
|
|
74
|
+
- **URI encoding**: Handles percent-encoded characters
|
|
75
|
+
- **Qualifier parsing**: Processes key-value parameters
|
|
76
|
+
|
|
77
|
+
### Potential Risk Areas
|
|
78
|
+
|
|
79
|
+
Areas that warrant security attention:
|
|
80
|
+
|
|
81
|
+
1. **URL Parsing**: Malformed URLs could cause parsing errors
|
|
82
|
+
2. **Regular Expressions**: Complex patterns may be vulnerable to ReDoS
|
|
83
|
+
3. **JSON Processing**: Configuration files require safe parsing
|
|
84
|
+
4. **Network Requests**: Registry URL generation involves external URLs
|
|
85
|
+
|
|
86
|
+
### Safe Usage Practices
|
|
87
|
+
|
|
88
|
+
When using Purl in applications:
|
|
89
|
+
|
|
90
|
+
- **Validate input**: Don't trust user-provided PURL strings
|
|
91
|
+
- **Handle errors**: Properly catch and handle parsing exceptions
|
|
92
|
+
- **Sanitize output**: Be careful when displaying parsed components
|
|
93
|
+
- **Rate limiting**: If parsing many PURLs, implement appropriate limits
|
|
94
|
+
|
|
95
|
+
## Disclosure Policy
|
|
96
|
+
|
|
97
|
+
### Coordinated Disclosure
|
|
98
|
+
|
|
99
|
+
We follow coordinated disclosure principles:
|
|
100
|
+
|
|
101
|
+
1. **Private reporting** allows us to fix issues before public disclosure
|
|
102
|
+
2. **Reasonable timeline** for fixes (typically 90 days maximum)
|
|
103
|
+
3. **Credit and recognition** for responsible reporters
|
|
104
|
+
4. **Public disclosure** after fixes are available
|
|
105
|
+
|
|
106
|
+
### Public Disclosure
|
|
107
|
+
|
|
108
|
+
After a fix is released:
|
|
109
|
+
|
|
110
|
+
1. **Security advisory** published on GitHub
|
|
111
|
+
2. **CVE requested** if applicable
|
|
112
|
+
3. **Release notes** include security information
|
|
113
|
+
4. **Community notification** through appropriate channels
|
|
114
|
+
|
|
115
|
+
## Security Updates
|
|
116
|
+
|
|
117
|
+
### Notification Channels
|
|
118
|
+
|
|
119
|
+
Security updates are announced through:
|
|
120
|
+
|
|
121
|
+
- **GitHub Security Advisories**
|
|
122
|
+
- **RubyGems security alerts**
|
|
123
|
+
- **Release notes and CHANGELOG**
|
|
124
|
+
- **Project README updates**
|
|
125
|
+
|
|
126
|
+
### Update Recommendations
|
|
127
|
+
|
|
128
|
+
To stay secure:
|
|
129
|
+
|
|
130
|
+
- **Monitor** our security advisories
|
|
131
|
+
- **Update regularly** to the latest version
|
|
132
|
+
- **Review** release notes for security fixes
|
|
133
|
+
- **Subscribe** to GitHub notifications for this repository
|
|
134
|
+
|
|
135
|
+
## Bug Bounty
|
|
136
|
+
|
|
137
|
+
Currently, we do not offer a formal bug bounty program. However, we deeply appreciate security researchers who help improve the project's security posture.
|
|
138
|
+
|
|
139
|
+
### Recognition
|
|
140
|
+
|
|
141
|
+
Contributors who responsibly disclose security issues will be:
|
|
142
|
+
|
|
143
|
+
- **Credited** in security advisories (with permission)
|
|
144
|
+
- **Mentioned** in release notes
|
|
145
|
+
- **Recognized** in project documentation
|
|
146
|
+
- **Thanked** publicly (unless anonymity is requested)
|
|
147
|
+
|
|
148
|
+
## Contact Information
|
|
149
|
+
|
|
150
|
+
**Security Contact**: andrew@ecosyste.ms
|
|
151
|
+
|
|
152
|
+
**PGP Key**: Available upon request for encrypted communications
|
|
153
|
+
|
|
154
|
+
**Response Time**: We aim to acknowledge security reports within 24-48 hours
|
|
155
|
+
|
|
156
|
+
## Additional Resources
|
|
157
|
+
|
|
158
|
+
- [PURL Specification Security Considerations](https://github.com/package-url/purl-spec)
|
|
159
|
+
- [Ruby Security Best Practices](https://guides.rubyonrails.org/security.html)
|
|
160
|
+
- [OWASP Secure Coding Practices](https://owasp.org/www-project-secure-coding-practices-quick-reference-guide/)
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
Thank you for helping keep Purl and its users safe!
|
data/lib/purl/package_url.rb
CHANGED
|
@@ -210,10 +210,18 @@ module Purl
|
|
|
210
210
|
end
|
|
211
211
|
|
|
212
212
|
def deconstruct_keys(keys)
|
|
213
|
-
to_h.slice(*keys) if keys
|
|
213
|
+
return to_h.slice(*keys) if keys
|
|
214
214
|
to_h
|
|
215
215
|
end
|
|
216
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
|
+
|
|
217
225
|
private
|
|
218
226
|
|
|
219
227
|
def validate_and_normalize_type(type)
|
data/lib/purl/registry_url.rb
CHANGED
|
@@ -24,29 +24,85 @@ module Purl
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def self.build_pattern_config(type, config)
|
|
27
|
+
# Get the default registry for this type from parent config
|
|
28
|
+
type_config = load_types_config["types"][type]
|
|
29
|
+
default_registry = type_config["default_registry"]
|
|
30
|
+
|
|
31
|
+
# Build full URLs from templates if we have a default registry
|
|
32
|
+
route_patterns = []
|
|
33
|
+
if default_registry
|
|
34
|
+
# Add all template variations
|
|
35
|
+
if config["path_template"]
|
|
36
|
+
route_patterns << default_registry + config["path_template"]
|
|
37
|
+
end
|
|
38
|
+
if config["namespace_path_template"]
|
|
39
|
+
route_patterns << default_registry + config["namespace_path_template"]
|
|
40
|
+
end
|
|
41
|
+
if config["version_path_template"]
|
|
42
|
+
route_patterns << default_registry + config["version_path_template"]
|
|
43
|
+
end
|
|
44
|
+
if config["namespace_version_path_template"]
|
|
45
|
+
route_patterns << default_registry + config["namespace_version_path_template"]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
# Fall back to legacy route_patterns if available
|
|
49
|
+
route_patterns = config["route_patterns"] if route_patterns.empty? && config["route_patterns"]
|
|
50
|
+
|
|
51
|
+
# Build reverse regex from template or use legacy format
|
|
52
|
+
reverse_regex = nil
|
|
53
|
+
if config["reverse_regex"]
|
|
54
|
+
if config["reverse_regex"].start_with?("/") && default_registry
|
|
55
|
+
# Domain-agnostic pattern - combine with default registry domain
|
|
56
|
+
domain_pattern = default_registry.sub(/^https?:\/\//, '').gsub('.', '\\.')
|
|
57
|
+
reverse_regex = Regexp.new("^https?://#{domain_pattern}" + config["reverse_regex"])
|
|
58
|
+
else
|
|
59
|
+
# Legacy full pattern
|
|
60
|
+
reverse_regex = Regexp.new(config["reverse_regex"])
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
27
64
|
{
|
|
28
|
-
base_url: config["base_url"],
|
|
29
|
-
route_patterns:
|
|
30
|
-
reverse_regex:
|
|
31
|
-
pattern: build_generation_lambda(type, config),
|
|
32
|
-
reverse_parser:
|
|
65
|
+
base_url: config["base_url"] || (default_registry ? default_registry + config["path_template"]&.split('/:').first : nil),
|
|
66
|
+
route_patterns: route_patterns,
|
|
67
|
+
reverse_regex: reverse_regex,
|
|
68
|
+
pattern: build_generation_lambda(type, config, default_registry),
|
|
69
|
+
reverse_parser: reverse_regex ? build_reverse_parser(type, config) : nil
|
|
33
70
|
}
|
|
34
71
|
end
|
|
35
72
|
|
|
36
|
-
|
|
73
|
+
# Load types config (needed for accessing default_registry)
|
|
74
|
+
def self.load_types_config
|
|
75
|
+
@types_config ||= begin
|
|
76
|
+
config_path = File.join(__dir__, "..", "..", "purl-types.json")
|
|
77
|
+
require "json"
|
|
78
|
+
JSON.parse(File.read(config_path))
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.build_generation_lambda(type, config, default_registry = nil)
|
|
83
|
+
# Use base_url from config, or build from default_registry + path_template base
|
|
84
|
+
if config["base_url"]
|
|
85
|
+
base_url = config["base_url"]
|
|
86
|
+
elsif default_registry && config["path_template"]
|
|
87
|
+
# Extract the base path from the template (everything before first :parameter)
|
|
88
|
+
base_path = config["path_template"].split('/:').first
|
|
89
|
+
base_url = default_registry + base_path
|
|
90
|
+
else
|
|
91
|
+
return nil
|
|
92
|
+
end
|
|
37
93
|
case type
|
|
38
94
|
when "npm"
|
|
39
95
|
->(purl) do
|
|
40
96
|
if purl.namespace
|
|
41
|
-
"#{
|
|
97
|
+
"#{base_url}/#{purl.namespace}/#{purl.name}"
|
|
42
98
|
else
|
|
43
|
-
"#{
|
|
99
|
+
"#{base_url}/#{purl.name}"
|
|
44
100
|
end
|
|
45
101
|
end
|
|
46
102
|
when "composer", "maven", "swift"
|
|
47
103
|
->(purl) do
|
|
48
104
|
if purl.namespace
|
|
49
|
-
"#{
|
|
105
|
+
"#{base_url}/#{purl.namespace}/#{purl.name}"
|
|
50
106
|
else
|
|
51
107
|
raise MissingRegistryInfoError.new(
|
|
52
108
|
"#{type.capitalize} packages require a namespace",
|
|
@@ -58,42 +114,42 @@ module Purl
|
|
|
58
114
|
when "golang"
|
|
59
115
|
->(purl) do
|
|
60
116
|
if purl.namespace
|
|
61
|
-
"#{
|
|
117
|
+
"#{base_url}/#{purl.namespace}/#{purl.name}"
|
|
62
118
|
else
|
|
63
|
-
"#{
|
|
119
|
+
"#{base_url}/#{purl.name}"
|
|
64
120
|
end
|
|
65
121
|
end
|
|
66
122
|
when "pypi"
|
|
67
|
-
->(purl) { "#{
|
|
123
|
+
->(purl) { "#{base_url}/#{purl.name}/" }
|
|
68
124
|
when "hackage"
|
|
69
125
|
->(purl) do
|
|
70
126
|
if purl.version
|
|
71
|
-
"#{
|
|
127
|
+
"#{base_url}/#{purl.name}-#{purl.version}"
|
|
72
128
|
else
|
|
73
|
-
"#{
|
|
129
|
+
"#{base_url}/#{purl.name}"
|
|
74
130
|
end
|
|
75
131
|
end
|
|
76
132
|
when "deno"
|
|
77
133
|
->(purl) do
|
|
78
134
|
if purl.version
|
|
79
|
-
"#{
|
|
135
|
+
"#{base_url}/#{purl.name}@#{purl.version}"
|
|
80
136
|
else
|
|
81
|
-
"#{
|
|
137
|
+
"#{base_url}/#{purl.name}"
|
|
82
138
|
end
|
|
83
139
|
end
|
|
84
140
|
when "clojars"
|
|
85
141
|
->(purl) do
|
|
86
142
|
if purl.namespace
|
|
87
|
-
"#{
|
|
143
|
+
"#{base_url}/#{purl.namespace}/#{purl.name}"
|
|
88
144
|
else
|
|
89
|
-
"#{
|
|
145
|
+
"#{base_url}/#{purl.name}"
|
|
90
146
|
end
|
|
91
147
|
end
|
|
92
148
|
when "elm"
|
|
93
149
|
->(purl) do
|
|
94
150
|
if purl.namespace
|
|
95
151
|
version = purl.version || "latest"
|
|
96
|
-
"#{
|
|
152
|
+
"#{base_url}/#{purl.namespace}/#{purl.name}/#{version}"
|
|
97
153
|
else
|
|
98
154
|
raise MissingRegistryInfoError.new(
|
|
99
155
|
"Elm packages require a namespace",
|
|
@@ -103,7 +159,7 @@ module Purl
|
|
|
103
159
|
end
|
|
104
160
|
end
|
|
105
161
|
else
|
|
106
|
-
->(purl) { "#{
|
|
162
|
+
->(purl) { "#{base_url}/#{purl.name}" }
|
|
107
163
|
end
|
|
108
164
|
end
|
|
109
165
|
|
|
@@ -177,6 +233,67 @@ module Purl
|
|
|
177
233
|
version = match[3] unless match[3] == "latest"
|
|
178
234
|
{ type: type, namespace: namespace, name: name, version: version }
|
|
179
235
|
end
|
|
236
|
+
when "cocoapods"
|
|
237
|
+
->(match) do
|
|
238
|
+
name = match[1]
|
|
239
|
+
{ type: type, namespace: nil, name: name, version: nil }
|
|
240
|
+
end
|
|
241
|
+
when "composer"
|
|
242
|
+
->(match) do
|
|
243
|
+
namespace = match[1]
|
|
244
|
+
name = match[2]
|
|
245
|
+
{ type: type, namespace: namespace, name: name, version: nil }
|
|
246
|
+
end
|
|
247
|
+
when "conda"
|
|
248
|
+
->(match) do
|
|
249
|
+
name = match[1]
|
|
250
|
+
{ type: type, namespace: nil, name: name, version: nil }
|
|
251
|
+
end
|
|
252
|
+
when "cpan"
|
|
253
|
+
->(match) do
|
|
254
|
+
name = match[1]
|
|
255
|
+
{ type: type, namespace: nil, name: name, version: nil }
|
|
256
|
+
end
|
|
257
|
+
when "hex"
|
|
258
|
+
->(match) do
|
|
259
|
+
name = match[1]
|
|
260
|
+
{ type: type, namespace: nil, name: name, version: nil }
|
|
261
|
+
end
|
|
262
|
+
when "nuget"
|
|
263
|
+
->(match) do
|
|
264
|
+
name = match[1]
|
|
265
|
+
version = match[2] # from /version pattern
|
|
266
|
+
{ type: type, namespace: nil, name: name, version: version }
|
|
267
|
+
end
|
|
268
|
+
when "pub"
|
|
269
|
+
->(match) do
|
|
270
|
+
name = match[1]
|
|
271
|
+
{ type: type, namespace: nil, name: name, version: nil }
|
|
272
|
+
end
|
|
273
|
+
when "swift"
|
|
274
|
+
->(match) do
|
|
275
|
+
namespace = match[1]
|
|
276
|
+
name = match[2]
|
|
277
|
+
{ type: type, namespace: namespace, name: name, version: nil }
|
|
278
|
+
end
|
|
279
|
+
when "bioconductor"
|
|
280
|
+
->(match) do
|
|
281
|
+
name = match[1]
|
|
282
|
+
{ type: type, namespace: nil, name: name, version: nil }
|
|
283
|
+
end
|
|
284
|
+
when "clojars"
|
|
285
|
+
->(match) do
|
|
286
|
+
if match[1] && match[2]
|
|
287
|
+
# Has namespace: clojars.org/namespace/name
|
|
288
|
+
namespace = match[1]
|
|
289
|
+
name = match[2]
|
|
290
|
+
else
|
|
291
|
+
# No namespace: clojars.org/name
|
|
292
|
+
namespace = nil
|
|
293
|
+
name = match[1] || match[2]
|
|
294
|
+
end
|
|
295
|
+
{ type: type, namespace: namespace, name: name, version: nil }
|
|
296
|
+
end
|
|
180
297
|
else
|
|
181
298
|
->(match) do
|
|
182
299
|
{ type: type, namespace: nil, name: match[1], version: nil }
|
|
@@ -187,8 +304,8 @@ module Purl
|
|
|
187
304
|
# Registry patterns loaded from JSON configuration
|
|
188
305
|
REGISTRY_PATTERNS = load_registry_patterns.freeze
|
|
189
306
|
|
|
190
|
-
def self.generate(purl)
|
|
191
|
-
new(purl).generate
|
|
307
|
+
def self.generate(purl, base_url: nil)
|
|
308
|
+
new(purl).generate(base_url: base_url)
|
|
192
309
|
end
|
|
193
310
|
|
|
194
311
|
def self.supported_types
|
|
@@ -199,9 +316,49 @@ module Purl
|
|
|
199
316
|
REGISTRY_PATTERNS.key?(type.to_s.downcase)
|
|
200
317
|
end
|
|
201
318
|
|
|
202
|
-
def self.from_url(registry_url)
|
|
203
|
-
#
|
|
204
|
-
|
|
319
|
+
def self.from_url(registry_url, type: nil)
|
|
320
|
+
# If type is specified, try that specific type first with domain-agnostic parsing
|
|
321
|
+
if type
|
|
322
|
+
normalized_type = type.to_s.downcase
|
|
323
|
+
config = REGISTRY_PATTERNS[normalized_type]
|
|
324
|
+
|
|
325
|
+
if config && config[:reverse_regex] && config[:reverse_parser]
|
|
326
|
+
# Create a domain-agnostic version of the regex by replacing the base domain
|
|
327
|
+
original_regex = config[:reverse_regex].source
|
|
328
|
+
|
|
329
|
+
# For simplified JSON patterns that start with /, create domain-agnostic regex
|
|
330
|
+
domain_agnostic_regex = nil
|
|
331
|
+
if original_regex.start_with?("/")
|
|
332
|
+
# Domain-agnostic pattern - match any domain with this path
|
|
333
|
+
domain_agnostic_regex = Regexp.new("^https?://[^/]+" + original_regex)
|
|
334
|
+
else
|
|
335
|
+
# Legacy full regex pattern
|
|
336
|
+
if original_regex =~ /\^https?:\/\/[^\/]+(.+)$/
|
|
337
|
+
path_pattern = $1
|
|
338
|
+
# Create domain-agnostic regex that matches any domain with the same path structure
|
|
339
|
+
domain_agnostic_regex = Regexp.new("^https?://[^/]+" + path_pattern)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
if domain_agnostic_regex
|
|
344
|
+
match = registry_url.match(domain_agnostic_regex)
|
|
345
|
+
if match
|
|
346
|
+
parsed_data = config[:reverse_parser].call(match)
|
|
347
|
+
return PackageURL.new(
|
|
348
|
+
type: parsed_data[:type],
|
|
349
|
+
namespace: parsed_data[:namespace],
|
|
350
|
+
name: parsed_data[:name],
|
|
351
|
+
version: parsed_data[:version]
|
|
352
|
+
)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# If specified type didn't work, fall through to normal domain-matching logic
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Try to parse the registry URL back into a PURL using domain matching
|
|
361
|
+
REGISTRY_PATTERNS.each do |registry_type, config|
|
|
205
362
|
next unless config[:reverse_regex] && config[:reverse_parser]
|
|
206
363
|
|
|
207
364
|
match = registry_url.match(config[:reverse_regex])
|
|
@@ -216,8 +373,15 @@ module Purl
|
|
|
216
373
|
end
|
|
217
374
|
end
|
|
218
375
|
|
|
376
|
+
error_message = if type
|
|
377
|
+
"Unable to parse registry URL: #{registry_url} as type '#{type}'. " +
|
|
378
|
+
"URL structure doesn't match expected pattern for this type."
|
|
379
|
+
else
|
|
380
|
+
"Unable to parse registry URL: #{registry_url}. No matching pattern found."
|
|
381
|
+
end
|
|
382
|
+
|
|
219
383
|
raise UnsupportedTypeError.new(
|
|
220
|
-
|
|
384
|
+
error_message,
|
|
221
385
|
supported_types: REGISTRY_PATTERNS.keys.select { |k| REGISTRY_PATTERNS[k][:reverse_regex] }
|
|
222
386
|
)
|
|
223
387
|
end
|
|
@@ -247,7 +411,7 @@ module Purl
|
|
|
247
411
|
@purl = purl
|
|
248
412
|
end
|
|
249
413
|
|
|
250
|
-
def generate
|
|
414
|
+
def generate(base_url: nil)
|
|
251
415
|
pattern_config = REGISTRY_PATTERNS[@purl.type.downcase]
|
|
252
416
|
|
|
253
417
|
unless pattern_config
|
|
@@ -259,7 +423,13 @@ module Purl
|
|
|
259
423
|
end
|
|
260
424
|
|
|
261
425
|
begin
|
|
262
|
-
|
|
426
|
+
if base_url
|
|
427
|
+
# Use custom base URL with the same URL structure
|
|
428
|
+
generate_with_custom_base_url(base_url, pattern_config)
|
|
429
|
+
else
|
|
430
|
+
# Use default base URL
|
|
431
|
+
pattern_config[:pattern].call(@purl)
|
|
432
|
+
end
|
|
263
433
|
rescue MissingRegistryInfoError
|
|
264
434
|
raise
|
|
265
435
|
rescue => e
|
|
@@ -267,23 +437,87 @@ module Purl
|
|
|
267
437
|
end
|
|
268
438
|
end
|
|
269
439
|
|
|
270
|
-
def generate_with_version
|
|
271
|
-
|
|
440
|
+
def generate_with_version(base_url: nil)
|
|
441
|
+
registry_url = generate(base_url: base_url)
|
|
272
442
|
|
|
273
443
|
case @purl.type.downcase
|
|
274
444
|
when "npm"
|
|
275
|
-
@purl.version ? "#{
|
|
445
|
+
@purl.version ? "#{registry_url}/v/#{@purl.version}" : registry_url
|
|
276
446
|
when "pypi"
|
|
277
|
-
@purl.version ? "#{
|
|
447
|
+
@purl.version ? "#{registry_url}#{@purl.version}/" : registry_url
|
|
278
448
|
when "gem"
|
|
279
|
-
@purl.version ? "#{
|
|
449
|
+
@purl.version ? "#{registry_url}/versions/#{@purl.version}" : registry_url
|
|
280
450
|
when "maven"
|
|
281
|
-
@purl.version ? "#{
|
|
451
|
+
@purl.version ? "#{registry_url}/#{@purl.version}" : registry_url
|
|
282
452
|
when "nuget"
|
|
283
|
-
@purl.version ? "#{
|
|
453
|
+
@purl.version ? "#{registry_url}/#{@purl.version}" : registry_url
|
|
284
454
|
else
|
|
285
455
|
# For other types, just return the base URL since version-specific URLs vary
|
|
286
|
-
|
|
456
|
+
registry_url
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
private
|
|
461
|
+
|
|
462
|
+
def generate_with_custom_base_url(custom_base_url, pattern_config)
|
|
463
|
+
|
|
464
|
+
# Replace the base URL in the pattern lambda
|
|
465
|
+
case @purl.type.downcase
|
|
466
|
+
when "npm"
|
|
467
|
+
if @purl.namespace
|
|
468
|
+
"#{custom_base_url}/#{@purl.namespace}/#{@purl.name}"
|
|
469
|
+
else
|
|
470
|
+
"#{custom_base_url}/#{@purl.name}"
|
|
471
|
+
end
|
|
472
|
+
when "composer", "maven", "swift"
|
|
473
|
+
if @purl.namespace
|
|
474
|
+
"#{custom_base_url}/#{@purl.namespace}/#{@purl.name}"
|
|
475
|
+
else
|
|
476
|
+
raise MissingRegistryInfoError.new(
|
|
477
|
+
"#{@purl.type.capitalize} packages require a namespace",
|
|
478
|
+
type: @purl.type,
|
|
479
|
+
missing: "namespace"
|
|
480
|
+
)
|
|
481
|
+
end
|
|
482
|
+
when "golang"
|
|
483
|
+
if @purl.namespace
|
|
484
|
+
"#{custom_base_url}/#{@purl.namespace}/#{@purl.name}"
|
|
485
|
+
else
|
|
486
|
+
"#{custom_base_url}/#{@purl.name}"
|
|
487
|
+
end
|
|
488
|
+
when "pypi"
|
|
489
|
+
"#{custom_base_url}/#{@purl.name}/"
|
|
490
|
+
when "hackage"
|
|
491
|
+
if @purl.version
|
|
492
|
+
"#{custom_base_url}/#{@purl.name}-#{@purl.version}"
|
|
493
|
+
else
|
|
494
|
+
"#{custom_base_url}/#{@purl.name}"
|
|
495
|
+
end
|
|
496
|
+
when "deno"
|
|
497
|
+
if @purl.version
|
|
498
|
+
"#{custom_base_url}/#{@purl.name}@#{@purl.version}"
|
|
499
|
+
else
|
|
500
|
+
"#{custom_base_url}/#{@purl.name}"
|
|
501
|
+
end
|
|
502
|
+
when "clojars"
|
|
503
|
+
if @purl.namespace
|
|
504
|
+
"#{custom_base_url}/#{@purl.namespace}/#{@purl.name}"
|
|
505
|
+
else
|
|
506
|
+
"#{custom_base_url}/#{@purl.name}"
|
|
507
|
+
end
|
|
508
|
+
when "elm"
|
|
509
|
+
if @purl.namespace
|
|
510
|
+
version = @purl.version || "latest"
|
|
511
|
+
"#{custom_base_url}/#{@purl.namespace}/#{@purl.name}/#{version}"
|
|
512
|
+
else
|
|
513
|
+
raise MissingRegistryInfoError.new(
|
|
514
|
+
"Elm packages require a namespace",
|
|
515
|
+
type: @purl.type,
|
|
516
|
+
missing: "namespace"
|
|
517
|
+
)
|
|
518
|
+
end
|
|
519
|
+
else
|
|
520
|
+
"#{custom_base_url}/#{@purl.name}"
|
|
287
521
|
end
|
|
288
522
|
end
|
|
289
523
|
|
|
@@ -294,12 +528,12 @@ module Purl
|
|
|
294
528
|
|
|
295
529
|
# Add registry URL generation methods to PackageURL
|
|
296
530
|
class PackageURL
|
|
297
|
-
def registry_url
|
|
298
|
-
RegistryURL.generate(self)
|
|
531
|
+
def registry_url(base_url: nil)
|
|
532
|
+
RegistryURL.generate(self, base_url: base_url)
|
|
299
533
|
end
|
|
300
534
|
|
|
301
|
-
def registry_url_with_version
|
|
302
|
-
RegistryURL.new(self).generate_with_version
|
|
535
|
+
def registry_url_with_version(base_url: nil)
|
|
536
|
+
RegistryURL.new(self).generate_with_version(base_url: base_url)
|
|
303
537
|
end
|
|
304
538
|
|
|
305
539
|
def supports_registry_url?
|
data/lib/purl/version.rb
CHANGED
data/lib/purl.rb
CHANGED
|
@@ -26,8 +26,10 @@ module Purl
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
# Convenience method for parsing registry URLs back to PURLs
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
# @param registry_url [String] The registry URL to parse
|
|
30
|
+
# @param type [String, Symbol, nil] Optional type hint for custom domains
|
|
31
|
+
def self.from_registry_url(registry_url, type: nil)
|
|
32
|
+
RegistryURL.from_url(registry_url, type: type)
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
# Returns all known PURL types
|
|
@@ -56,6 +58,9 @@ module Purl
|
|
|
56
58
|
{
|
|
57
59
|
type: normalized_type,
|
|
58
60
|
known: known_type?(normalized_type),
|
|
61
|
+
description: type_description(normalized_type),
|
|
62
|
+
default_registry: default_registry(normalized_type),
|
|
63
|
+
examples: type_examples(normalized_type),
|
|
59
64
|
registry_url_generation: RegistryURL.supports?(normalized_type),
|
|
60
65
|
reverse_parsing: RegistryURL.supported_reverse_types.include?(normalized_type),
|
|
61
66
|
route_patterns: RegistryURL.route_patterns_for(normalized_type)
|
|
@@ -95,6 +100,14 @@ module Purl
|
|
|
95
100
|
config ? config["description"] : nil
|
|
96
101
|
end
|
|
97
102
|
|
|
103
|
+
# Get examples for a type
|
|
104
|
+
def self.type_examples(type)
|
|
105
|
+
config = type_config(type)
|
|
106
|
+
return [] unless config
|
|
107
|
+
|
|
108
|
+
config["examples"] || []
|
|
109
|
+
end
|
|
110
|
+
|
|
98
111
|
# Get registry configuration for a type
|
|
99
112
|
def self.registry_config(type)
|
|
100
113
|
config = type_config(type)
|
|
@@ -103,6 +116,14 @@ module Purl
|
|
|
103
116
|
config["registry_config"]
|
|
104
117
|
end
|
|
105
118
|
|
|
119
|
+
# Get default registry URL for a type
|
|
120
|
+
def self.default_registry(type)
|
|
121
|
+
config = type_config(type)
|
|
122
|
+
return nil unless config
|
|
123
|
+
|
|
124
|
+
config["default_registry"]
|
|
125
|
+
end
|
|
126
|
+
|
|
106
127
|
# Get metadata about the types configuration
|
|
107
128
|
def self.types_config_metadata
|
|
108
129
|
config = load_types_config
|
|
@@ -112,7 +133,8 @@ module Purl
|
|
|
112
133
|
source: config["source"],
|
|
113
134
|
last_updated: config["last_updated"],
|
|
114
135
|
total_types: config["types"].keys.length,
|
|
115
|
-
registry_supported_types: config["types"].select { |_, v| v["registry_config"] }.keys.length
|
|
136
|
+
registry_supported_types: config["types"].select { |_, v| v["registry_config"] }.keys.length,
|
|
137
|
+
types_with_default_registry: config["types"].select { |_, v| v["default_registry"] }.keys.length
|
|
116
138
|
}
|
|
117
139
|
end
|
|
118
140
|
end
|