Class: Purl::PackageURL
- Inherits:
-
Object
- Object
- Purl::PackageURL
- Defined in:
- lib/purl/package_url.rb,
lib/purl/download_url.rb,
lib/purl/registry_url.rb,
lib/purl/ecosystems_url.rb
Overview
Add registry URL generation methods to PackageURL
Constant Summary collapse
- VALID_TYPE_CHARS =
/\A[a-zA-Z0-9\.\+\-]+\z/.freeze
- VALID_QUALIFIER_KEY_CHARS =
/\A[a-zA-Z0-9\.\-_]+\z/.freeze
- STARTS_WITH_DIGIT =
/\A\d/.freeze
Instance Attribute Summary collapse
-
#name ⇒ String
readonly
The package name.
-
#namespace ⇒ String?
readonly
The package namespace/scope.
-
#qualifiers ⇒ Hash<String, String>?
readonly
Key-value qualifier pairs.
-
#subpath ⇒ String?
readonly
Subpath within the package.
-
#type ⇒ String
readonly
The package type (e.g., "gem", "npm", "maven").
-
#version ⇒ String?
readonly
The package version.
Class Method Summary collapse
-
.fast_decode(str) ⇒ Object
Fast-path decode: skip URI.decode_www_form_component when no encoding present.
- .namespace_required_types ⇒ Object
- .normalize_subpath(subpath) ⇒ Object
-
.parse(purl_string) ⇒ PackageURL
Parse a PURL string into a PackageURL object.
- .parse_qualifiers(query_string) ⇒ Object
- .purl_types_data ⇒ Object
Instance Method Summary collapse
-
#==(other) ⇒ Boolean
Compare two PackageURL objects for equality.
-
#advisories(user_agent: nil, timeout: 10) ⇒ Array<Hash>
Look up security advisories using the advisories.ecosyste.ms API.
-
#deconstruct ⇒ Array
Pattern matching support for Ruby 2.7+.
-
#deconstruct_keys(keys) ⇒ Hash<Symbol, Object>
Pattern matching support for Ruby 2.7+ (hash patterns).
- #download_url(base_url: nil) ⇒ Object
- #ecosystems_api_url ⇒ Object
- #ecosystems_package_api_url ⇒ Object
- #ecosystems_registry ⇒ Object
- #ecosystems_version_api_url ⇒ Object
-
#hash ⇒ Integer
Generate hash code for the PackageURL.
-
#initialize(type:, name:, namespace: nil, version: nil, qualifiers: nil, subpath: nil) ⇒ PackageURL
constructor
Create a new PackageURL instance.
-
#lookup(user_agent: nil, timeout: 10) ⇒ Hash?
Look up package information using the ecosyste.ms API.
- #registry_url(base_url: nil) ⇒ Object
- #registry_url_with_version(base_url: nil) ⇒ Object
- #supports_download_url? ⇒ Boolean
- #supports_registry_url? ⇒ Boolean
-
#to_h ⇒ Hash<Symbol, Object>
Convert the PackageURL to a hash representation.
-
#to_s ⇒ String
Convert the PackageURL to its canonical string representation.
-
#versionless ⇒ PackageURL
Create a new PackageURL without the version component.
-
#with(**changes) ⇒ PackageURL
Create a new PackageURL with modified attributes.
Constructor Details
#initialize(type:, name:, namespace: nil, version: nil, qualifiers: nil, subpath: nil) ⇒ PackageURL
Create a new PackageURL instance
80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/purl/package_url.rb', line 80 def initialize(type:, name:, namespace: nil, version: nil, qualifiers: nil, subpath: nil) @type = validate_and_normalize_type(type) @name = validate_name(name) @namespace = validate_namespace(namespace) @version = validate_version(version) if version @qualifiers = validate_qualifiers(qualifiers) if qualifiers @subpath = validate_subpath(subpath) if subpath # Apply post-validation normalization that depends on other components apply_post_validation_normalization end |
Instance Attribute Details
#name ⇒ String (readonly)
Returns the package name.
40 41 42 |
# File 'lib/purl/package_url.rb', line 40 def name @name end |
#namespace ⇒ String? (readonly)
Returns the package namespace/scope.
37 38 39 |
# File 'lib/purl/package_url.rb', line 37 def namespace @namespace end |
#qualifiers ⇒ Hash<String, String>? (readonly)
Returns key-value qualifier pairs.
46 47 48 |
# File 'lib/purl/package_url.rb', line 46 def qualifiers @qualifiers end |
#subpath ⇒ String? (readonly)
Returns subpath within the package.
49 50 51 |
# File 'lib/purl/package_url.rb', line 49 def subpath @subpath end |
#type ⇒ String (readonly)
Returns the package type (e.g., "gem", "npm", "maven").
34 35 36 |
# File 'lib/purl/package_url.rb', line 34 def type @type end |
#version ⇒ String? (readonly)
Returns the package version.
43 44 45 |
# File 'lib/purl/package_url.rb', line 43 def version @version end |
Class Method Details
.fast_decode(str) ⇒ Object
Fast-path decode: skip URI.decode_www_form_component when no encoding present
52 53 54 |
# File 'lib/purl/package_url.rb', line 52 def self.fast_decode(str) str.include?("%") || str.include?("+") ? URI.decode_www_form_component(str) : str end |
.namespace_required_types ⇒ Object
586 587 588 589 590 591 592 |
# File 'lib/purl/package_url.rb', line 586 def self.namespace_required_types @namespace_required_types ||= begin config = Purl.load_types_config types = config["types"].select { |_, v| v["namespace_requirement"] == "required" }.keys Set.new(types).freeze end end |
.normalize_subpath(subpath) ⇒ Object
625 626 627 628 629 630 631 632 633 |
# File 'lib/purl/package_url.rb', line 625 def self.normalize_subpath(subpath) return nil if subpath.nil? || subpath.empty? # Simply remove . and .. components according to PURL spec behavior components = subpath.split("/") normalized = components.reject { |component| component == "." || component == ".." || component.empty? } normalized.empty? ? nil : normalized.join("/") end |
.parse(purl_string) ⇒ PackageURL
Parse a PURL string into a PackageURL object
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 |
# File 'lib/purl/package_url.rb', line 111 def self.parse(purl_string) raise InvalidSchemeError, "PURL must start with 'pkg:'" unless purl_string.start_with?("pkg:") # Remove the pkg: prefix and any leading slashes (they're not significant) remainder = purl_string[4..-1] remainder = remainder.sub(/\A\/+/, "") if remainder.start_with?("/") # Split off qualifiers (query string) first if remainder.include?("?") path_and_version, query_string = remainder.split("?", 2) else path_and_version = remainder query_string = nil end # Parse version and subpath according to PURL spec # Format: pkg:type/namespace/name@version#subpath version = nil subpath = nil # First split on # to separate subpath if path_and_version.include?("#") path_and_version_part, subpath_part = path_and_version.split("#", 2) # Clean up subpath - remove leading/trailing slashes and decode components if subpath_part && !subpath_part.empty? subpath_clean = subpath_part.strip subpath_clean = subpath_clean[1..-1] if subpath_clean.start_with?("/") subpath_clean = subpath_clean[0..-2] if subpath_clean.end_with?("/") unless subpath_clean.empty? # Decode each component separately to handle paths properly subpath_components = subpath_clean.split("/").map { |part| fast_decode(part) } subpath = subpath_components.join("/") end end else path_and_version_part = path_and_version end # Then split on @ to separate version if path_and_version_part.include?("@") # Find the last @ to handle cases like @babel/[email protected] at_index = path_and_version_part.rindex("@") path_part = path_and_version_part[0...at_index] version_part = path_and_version_part[at_index + 1..-1] version = fast_decode(version_part) unless version_part.empty? else path_part = path_and_version_part end # Check if path ends with slash (indicates empty name component) empty_name_component = path_part.end_with?("/") path_part = path_part.chomp("/") if empty_name_component # Parse the path components path_components = path_part.split("/") raise MalformedUrlError, "PURL path cannot be empty" if path_components.empty? || path_components == [""] # First component is always the type type = fast_decode(path_components.shift) raise MalformedUrlError, "PURL must have a name component" if path_components.empty? # Handle empty name component (trailing slash case) if empty_name_component # All remaining components become namespace, name is nil if path_components.length == 1 # Just type/ - invalid, should have been caught earlier name = nil namespace = nil else # All non-type components become namespace name = nil if path_components.length == 1 namespace = fast_decode(path_components[0]) else namespace = path_components.map { |part| fast_decode(part) }.join("/") end end else # Normal parsing logic # For simple cases like gem/rails, there's just the name # For namespaced cases like npm/@babel/core, @babel is namespace, core is name if path_components.length == 1 # Simple case: just type/name name = fast_decode(path_components[0]) namespace = nil else # Multiple components - assume last is name, others are namespace name = fast_decode(path_components.pop) # Everything else is namespace if path_components.length == 1 namespace = fast_decode(path_components[0]) else # Multiple remaining components - treat as namespace joined together namespace = path_components.map { |part| fast_decode(part) }.join("/") end end end # Parse qualifiers from query string qualifiers = parse_qualifiers(query_string) if query_string new( type: type, name: name, namespace: namespace, version: version, qualifiers: qualifiers, subpath: subpath ) end |
.parse_qualifiers(query_string) ⇒ Object
603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 |
# File 'lib/purl/package_url.rb', line 603 def self.parse_qualifiers(query_string) return {} if query_string.nil? || query_string.empty? qualifiers = {} URI.decode_www_form(query_string).each do |key, value| # Normalize qualifier keys to lowercase normalized_key = key.downcase if qualifiers.key?(normalized_key) raise InvalidQualifierError.new( "Duplicate qualifier key in query string: #{key}", component: :qualifiers, value: key, rule: "unique keys required" ) end qualifiers[normalized_key] = value end qualifiers end |
.purl_types_data ⇒ Object
599 600 601 |
# File 'lib/purl/package_url.rb', line 599 def self.purl_types_data Purl.load_types_config end |
Instance Method Details
#==(other) ⇒ Boolean
Compare two PackageURL objects for equality
Two PURLs are equal if their canonical string representations are identical.
312 313 314 315 316 |
# File 'lib/purl/package_url.rb', line 312 def ==(other) return false unless other.is_a?(PackageURL) to_s == other.to_s end |
#advisories(user_agent: nil, timeout: 10) ⇒ Array<Hash>
Look up security advisories using the advisories.ecosyste.ms API
411 412 413 414 415 |
# File 'lib/purl/package_url.rb', line 411 def advisories(user_agent: nil, timeout: 10) require_relative "advisory" advisory_client = Advisory.new(user_agent: user_agent, timeout: timeout) advisory_client.lookup(self) end |
#deconstruct ⇒ Array
Pattern matching support for Ruby 2.7+
Allows destructuring PackageURL in pattern matching.
336 337 338 |
# File 'lib/purl/package_url.rb', line 336 def deconstruct [type, namespace, name, version, qualifiers, subpath] end |
#deconstruct_keys(keys) ⇒ Hash<Symbol, Object>
Pattern matching support for Ruby 2.7+ (hash patterns)
350 351 352 353 |
# File 'lib/purl/package_url.rb', line 350 def deconstruct_keys(keys) return to_h.slice(*keys) if keys to_h end |
#download_url(base_url: nil) ⇒ Object
256 257 258 |
# File 'lib/purl/download_url.rb', line 256 def download_url(base_url: nil) DownloadURL.generate(self, base_url: base_url) end |
#ecosystems_api_url ⇒ Object
90 91 92 |
# File 'lib/purl/ecosystems_url.rb', line 90 def ecosystems_api_url EcosystemsURL.api_url(self) end |
#ecosystems_package_api_url ⇒ Object
94 95 96 |
# File 'lib/purl/ecosystems_url.rb', line 94 def ecosystems_package_api_url EcosystemsURL.package_api_url(self) end |
#ecosystems_registry ⇒ Object
86 87 88 |
# File 'lib/purl/ecosystems_url.rb', line 86 def ecosystems_registry EcosystemsURL.registry_name(self) end |
#ecosystems_version_api_url ⇒ Object
98 99 100 |
# File 'lib/purl/ecosystems_url.rb', line 98 def ecosystems_version_api_url EcosystemsURL.version_api_url(self) end |
#hash ⇒ Integer
Generate hash code for the PackageURL
321 322 323 |
# File 'lib/purl/package_url.rb', line 321 def hash to_s.hash end |
#lookup(user_agent: nil, timeout: 10) ⇒ Hash?
Look up package information using the ecosyste.ms API
394 395 396 397 398 |
# File 'lib/purl/package_url.rb', line 394 def lookup(user_agent: nil, timeout: 10) require_relative "lookup" lookup_client = Lookup.new(user_agent: user_agent, timeout: timeout) lookup_client.package_info(self) end |
#registry_url(base_url: nil) ⇒ Object
628 629 630 |
# File 'lib/purl/registry_url.rb', line 628 def registry_url(base_url: nil) RegistryURL.generate(self, base_url: base_url) end |
#registry_url_with_version(base_url: nil) ⇒ Object
632 633 634 |
# File 'lib/purl/registry_url.rb', line 632 def registry_url_with_version(base_url: nil) RegistryURL.new(self).generate_with_version(base_url: base_url) end |
#supports_download_url? ⇒ Boolean
260 261 262 |
# File 'lib/purl/download_url.rb', line 260 def supports_download_url? DownloadURL.supports?(type) end |
#supports_registry_url? ⇒ Boolean
636 637 638 |
# File 'lib/purl/registry_url.rb', line 636 def supports_registry_url? RegistryURL.supports?(type) end |
#to_h ⇒ Hash<Symbol, Object>
Convert the PackageURL to a hash representation
290 291 292 293 294 295 296 297 298 299 |
# File 'lib/purl/package_url.rb', line 290 def to_h { type: type, namespace: namespace, name: name, version: version, qualifiers: qualifiers, subpath: subpath } end |
#to_s ⇒ String
Convert the PackageURL to its canonical string representation
231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 |
# File 'lib/purl/package_url.rb', line 231 def to_s return @canonical if @canonical parts = ["pkg:", type.downcase] if namespace # Encode namespace parts, but preserve the structure namespace_parts = namespace.split("/").map do |part| URI.encode_www_form_component(part) end parts << "/" << namespace_parts.join("/") end parts << "/" << URI.encode_www_form_component(name) if version encoded_version = case type&.downcase when "docker" # Docker versions with sha256: should not encode the colon version else URI.encode_www_form_component(version) end parts << "@" << encoded_version end if subpath # Subpath goes after # according to PURL spec # Normalize the subpath to remove . and .. components normalized_subpath = self.class.normalize_subpath(subpath) if normalized_subpath subpath_parts = normalized_subpath.split("/").map { |part| URI.encode_www_form_component(part) } parts << "#" << subpath_parts.join("/") end end if qualifiers && !qualifiers.empty? query_parts = qualifiers.sort.map do |key, value| # Keys are already normalized to lowercase during parsing/validation # Values should not be encoded for certain safe characters in PURL spec encoded_key = key # Key is already clean encoded_value = value.to_s # Don't encode values to match canonical form "#{encoded_key}=#{encoded_value}" end parts << "?" << query_parts.join("&") end @canonical = parts.join.freeze end |
#versionless ⇒ PackageURL
Create a new PackageURL without the version component
378 379 380 |
# File 'lib/purl/package_url.rb', line 378 def versionless with(version: nil) end |
#with(**changes) ⇒ PackageURL
Create a new PackageURL with modified attributes
364 365 366 367 368 |
# File 'lib/purl/package_url.rb', line 364 def with(**changes) current_attrs = to_h new_attrs = current_attrs.merge(changes) self.class.new(**new_attrs) end |