purl 1.0.0 → 1.1.1

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.
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/errors.rb CHANGED
@@ -5,9 +5,31 @@ module Purl
5
5
  class Error < StandardError; end
6
6
 
7
7
  # Validation errors for PURL components
8
+ #
9
+ # Contains additional context about which component failed validation
10
+ # and what rule was violated.
11
+ #
12
+ # @example
13
+ # begin
14
+ # PackageURL.new(type: "123invalid", name: "test")
15
+ # rescue ValidationError => e
16
+ # puts e.component # :type
17
+ # puts e.rule # "cannot start with number"
18
+ # end
8
19
  class ValidationError < Error
9
- attr_reader :component, :value, :rule
20
+ # @return [Symbol, nil] the PURL component that failed validation
21
+ attr_reader :component
22
+
23
+ # @return [Object, nil] the value that failed validation
24
+ attr_reader :value
25
+
26
+ # @return [String, nil] the validation rule that was violated
27
+ attr_reader :rule
10
28
 
29
+ # @param message [String] error message
30
+ # @param component [Symbol, nil] component that failed validation
31
+ # @param value [Object, nil] value that failed validation
32
+ # @param rule [String, nil] validation rule that was violated
11
33
  def initialize(message, component: nil, value: nil, rule: nil)
12
34
  super(message)
13
35
  @component = component
@@ -19,40 +41,71 @@ module Purl
19
41
  # Parsing errors for malformed PURL strings
20
42
  class ParseError < Error; end
21
43
 
22
- # Specific validation errors
44
+ # Specific validation errors for PURL components
45
+
46
+ # Raised when a PURL type is invalid
23
47
  class InvalidTypeError < ValidationError; end
48
+
49
+ # Raised when a PURL name is invalid
24
50
  class InvalidNameError < ValidationError; end
51
+
52
+ # Raised when a PURL namespace is invalid
25
53
  class InvalidNamespaceError < ValidationError; end
54
+
55
+ # Raised when a PURL qualifier is invalid
26
56
  class InvalidQualifierError < ValidationError; end
57
+
58
+ # Raised when a PURL version is invalid
27
59
  class InvalidVersionError < ValidationError; end
60
+
61
+ # Raised when a PURL subpath is invalid
28
62
  class InvalidSubpathError < ValidationError; end
29
63
 
30
64
  # Parsing-specific errors
65
+
66
+ # Raised when a PURL string doesn't start with "pkg:"
31
67
  class InvalidSchemeError < ParseError; end
68
+
69
+ # Raised when a PURL string is malformed
32
70
  class MalformedUrlError < ParseError; end
33
71
 
34
72
  # Registry URL generation errors
73
+ #
74
+ # Contains additional context about which type caused the error.
35
75
  class RegistryError < Error
76
+ # @return [String, nil] the PURL type that caused the error
36
77
  attr_reader :type
37
78
 
79
+ # @param message [String] error message
80
+ # @param type [String, nil] PURL type that caused the error
38
81
  def initialize(message, type: nil)
39
82
  super(message)
40
83
  @type = type
41
84
  end
42
85
  end
43
86
 
87
+ # Raised when trying to generate registry URLs for unsupported types
44
88
  class UnsupportedTypeError < RegistryError
89
+ # @return [Array<String>] list of supported types
45
90
  attr_reader :supported_types
46
91
 
92
+ # @param message [String] error message
93
+ # @param type [String, nil] unsupported type
94
+ # @param supported_types [Array<String>] list of supported types
47
95
  def initialize(message, type: nil, supported_types: [])
48
96
  super(message, type: type)
49
97
  @supported_types = supported_types
50
98
  end
51
99
  end
52
100
 
101
+ # Raised when required registry information is missing
53
102
  class MissingRegistryInfoError < RegistryError
103
+ # @return [String, nil] the missing information (e.g., "namespace")
54
104
  attr_reader :missing
55
105
 
106
+ # @param message [String] error message
107
+ # @param type [String, nil] PURL type
108
+ # @param missing [String, nil] what information is missing
56
109
  def initialize(message, type: nil, missing: nil)
57
110
  super(message, type: type)
58
111
  @missing = missing
@@ -60,5 +113,6 @@ module Purl
60
113
  end
61
114
 
62
115
  # Legacy compatibility - matches packageurl-ruby's exception name
116
+ # @deprecated Use {ParseError} instead
63
117
  InvalidPackageURL = ParseError
64
118
  end
@@ -3,12 +3,74 @@
3
3
  require "uri"
4
4
 
5
5
  module Purl
6
+ # Represents a Package URL (PURL) - a mostly universal standard to reference
7
+ # a software package in a uniform way across many tools, programming languages
8
+ # and ecosystems.
9
+ #
10
+ # A PURL has the following components:
11
+ # - +type+: the package type (e.g., "gem", "npm", "maven")
12
+ # - +namespace+: optional namespace/scope (e.g., "@babel" for npm)
13
+ # - +name+: the package name (required)
14
+ # - +version+: optional version
15
+ # - +qualifiers+: optional key-value pairs
16
+ # - +subpath+: optional path within the package
17
+ #
18
+ # @example Creating a PackageURL
19
+ # purl = PackageURL.new(
20
+ # type: "gem",
21
+ # name: "rails",
22
+ # version: "7.0.0"
23
+ # )
24
+ # puts purl.to_s # "pkg:gem/rails@7.0.0"
25
+ #
26
+ # @example Parsing a PURL string
27
+ # purl = PackageURL.parse("pkg:npm/@babel/core@7.0.0")
28
+ # puts purl.namespace # "@babel"
29
+ # puts purl.name # "core"
30
+ #
31
+ # @see https://github.com/package-url/purl-spec PURL Specification
6
32
  class PackageURL
7
- attr_reader :type, :namespace, :name, :version, :qualifiers, :subpath
33
+ # @return [String] the package type (e.g., "gem", "npm", "maven")
34
+ attr_reader :type
35
+
36
+ # @return [String, nil] the package namespace/scope
37
+ attr_reader :namespace
38
+
39
+ # @return [String] the package name
40
+ attr_reader :name
41
+
42
+ # @return [String, nil] the package version
43
+ attr_reader :version
44
+
45
+ # @return [Hash<String, String>, nil] key-value qualifier pairs
46
+ attr_reader :qualifiers
47
+
48
+ # @return [String, nil] subpath within the package
49
+ attr_reader :subpath
8
50
 
9
51
  VALID_TYPE_CHARS = /\A[a-zA-Z0-9\.\+\-]+\z/
10
52
  VALID_QUALIFIER_KEY_CHARS = /\A[a-zA-Z0-9\.\-_]+\z/
11
53
 
54
+ # Create a new PackageURL instance
55
+ #
56
+ # @param type [String, Symbol] the package type (required)
57
+ # @param name [String] the package name (required)
58
+ # @param namespace [String, nil] optional namespace/scope
59
+ # @param version [String, nil] optional version
60
+ # @param qualifiers [Hash, nil] optional key-value qualifier pairs
61
+ # @param subpath [String, nil] optional subpath within package
62
+ #
63
+ # @raise [InvalidTypeError] if type is invalid
64
+ # @raise [InvalidNameError] if name is invalid
65
+ # @raise [ValidationError] if any component fails type-specific validation
66
+ #
67
+ # @example
68
+ # purl = PackageURL.new(
69
+ # type: "npm",
70
+ # namespace: "@babel",
71
+ # name: "core",
72
+ # version: "7.0.0"
73
+ # )
12
74
  def initialize(type:, name:, namespace: nil, version: nil, qualifiers: nil, subpath: nil)
13
75
  @type = validate_and_normalize_type(type)
14
76
  @name = validate_name(name)
@@ -21,6 +83,25 @@ module Purl
21
83
  validate_type_specific_rules
22
84
  end
23
85
 
86
+ # Parse a PURL string into a PackageURL object
87
+ #
88
+ # @param purl_string [String] PURL string starting with "pkg:"
89
+ # @return [PackageURL] parsed package URL object
90
+ # @raise [InvalidSchemeError] if string doesn't start with "pkg:"
91
+ # @raise [MalformedUrlError] if string is malformed
92
+ # @raise [ValidationError] if parsed components fail validation
93
+ #
94
+ # @example Basic parsing
95
+ # purl = PackageURL.parse("pkg:gem/rails@7.0.0")
96
+ # puts purl.type # "gem"
97
+ # puts purl.name # "rails"
98
+ # puts purl.version # "7.0.0"
99
+ #
100
+ # @example Complex parsing with all components
101
+ # purl = PackageURL.parse("pkg:npm/@babel/core@7.0.0?arch=x64#lib/index.js")
102
+ # puts purl.namespace # "@babel"
103
+ # puts purl.qualifiers # {"arch" => "x64"}
104
+ # puts purl.subpath # "lib/index.js"
24
105
  def self.parse(purl_string)
25
106
  raise InvalidSchemeError, "PURL must start with 'pkg:'" unless purl_string.start_with?("pkg:")
26
107
 
@@ -134,6 +215,13 @@ module Purl
134
215
  )
135
216
  end
136
217
 
218
+ # Convert the PackageURL to its canonical string representation
219
+ #
220
+ # @return [String] canonical PURL string
221
+ #
222
+ # @example
223
+ # purl = PackageURL.new(type: "gem", name: "rails", version: "7.0.0")
224
+ # puts purl.to_s # "pkg:gem/rails@7.0.0"
137
225
  def to_s
138
226
  result = "pkg:#{type.downcase}"
139
227
 
@@ -183,6 +271,15 @@ module Purl
183
271
  result
184
272
  end
185
273
 
274
+ # Convert the PackageURL to a hash representation
275
+ #
276
+ # @return [Hash<Symbol, Object>] hash with component keys and values
277
+ #
278
+ # @example
279
+ # purl = PackageURL.new(type: "gem", name: "rails", version: "7.0.0")
280
+ # hash = purl.to_h
281
+ # # => {:type=>"gem", :namespace=>nil, :name=>"rails", :version=>"7.0.0",
282
+ # # :qualifiers=>nil, :subpath=>nil}
186
283
  def to_h
187
284
  {
188
285
  type: type,
@@ -194,26 +291,75 @@ module Purl
194
291
  }
195
292
  end
196
293
 
294
+ # Compare two PackageURL objects for equality
295
+ #
296
+ # Two PURLs are equal if their canonical string representations are identical.
297
+ #
298
+ # @param other [Object] object to compare with
299
+ # @return [Boolean] true if equal, false otherwise
300
+ #
301
+ # @example
302
+ # purl1 = PackageURL.parse("pkg:gem/rails@7.0.0")
303
+ # purl2 = PackageURL.parse("pkg:gem/rails@7.0.0")
304
+ # puts purl1 == purl2 # true
197
305
  def ==(other)
198
306
  return false unless other.is_a?(PackageURL)
199
307
 
200
308
  to_s == other.to_s
201
309
  end
202
310
 
311
+ # Generate hash code for the PackageURL
312
+ #
313
+ # @return [Integer] hash code based on canonical string representation
203
314
  def hash
204
315
  to_s.hash
205
316
  end
206
317
 
207
318
  # Pattern matching support for Ruby 2.7+
319
+ #
320
+ # Allows destructuring PackageURL in pattern matching.
321
+ #
322
+ # @return [Array] array of [type, namespace, name, version, qualifiers, subpath]
323
+ #
324
+ # @example Ruby 2.7+ pattern matching
325
+ # case purl
326
+ # in ["gem", nil, name, version, nil, nil]
327
+ # puts "Simple gem: #{name} v#{version}"
328
+ # end
208
329
  def deconstruct
209
330
  [type, namespace, name, version, qualifiers, subpath]
210
331
  end
211
332
 
333
+ # Pattern matching support for Ruby 2.7+ (hash patterns)
334
+ #
335
+ # @param keys [Array<Symbol>, nil] keys to extract, or nil for all keys
336
+ # @return [Hash<Symbol, Object>] hash with requested keys
337
+ #
338
+ # @example Ruby 2.7+ hash pattern matching
339
+ # case purl
340
+ # in {type: "gem", name:, version:}
341
+ # puts "Gem #{name} version #{version}"
342
+ # end
212
343
  def deconstruct_keys(keys)
213
- to_h.slice(*keys) if keys
344
+ return to_h.slice(*keys) if keys
214
345
  to_h
215
346
  end
216
347
 
348
+ # Create a new PackageURL with modified attributes
349
+ #
350
+ # @param changes [Hash] attributes to change
351
+ # @return [PackageURL] new PackageURL instance with changes applied
352
+ #
353
+ # @example
354
+ # purl = PackageURL.parse("pkg:gem/rails@7.0.0")
355
+ # new_purl = purl.with(version: "7.1.0", qualifiers: {"arch" => "x64"})
356
+ # puts new_purl.to_s # "pkg:gem/rails@7.1.0?arch=x64"
357
+ def with(**changes)
358
+ current_attrs = to_h
359
+ new_attrs = current_attrs.merge(changes)
360
+ self.class.new(**new_attrs)
361
+ end
362
+
217
363
  private
218
364
 
219
365
  def validate_and_normalize_type(type)