Class: Purl::PackageURL

Inherits:
Object
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(type:, name:, namespace: nil, version: nil, qualifiers: nil, subpath: nil) ⇒ PackageURL

Create a new PackageURL instance

Examples:

purl = PackageURL.new(
  type: "npm",
  namespace: "@babel",
  name: "core",
  version: "7.0.0"
)

Parameters:

  • the package type (required)

  • the package name (required)

  • (defaults to: nil)

    optional namespace/scope

  • (defaults to: nil)

    optional version

  • (defaults to: nil)

    optional key-value qualifier pairs

  • (defaults to: nil)

    optional subpath within package

Raises:

  • if type is invalid

  • if name is invalid

  • if any component fails type-specific validation



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

#nameString (readonly)

Returns the package name.

Returns:

  • the package name



40
41
42
# File 'lib/purl/package_url.rb', line 40

def name
  @name
end

#namespaceString? (readonly)

Returns the package namespace/scope.

Returns:

  • the package namespace/scope



37
38
39
# File 'lib/purl/package_url.rb', line 37

def namespace
  @namespace
end

#qualifiersHash<String, String>? (readonly)

Returns key-value qualifier pairs.

Returns:

  • key-value qualifier pairs



46
47
48
# File 'lib/purl/package_url.rb', line 46

def qualifiers
  @qualifiers
end

#subpathString? (readonly)

Returns subpath within the package.

Returns:

  • subpath within the package



49
50
51
# File 'lib/purl/package_url.rb', line 49

def subpath
  @subpath
end

#typeString (readonly)

Returns the package type (e.g., "gem", "npm", "maven").

Returns:

  • the package type (e.g., "gem", "npm", "maven")



34
35
36
# File 'lib/purl/package_url.rb', line 34

def type
  @type
end

#versionString? (readonly)

Returns the package version.

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_typesObject



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

Examples:

Basic parsing

purl = PackageURL.parse("pkg:gem/[email protected]")
puts purl.type     # "gem"
puts purl.name     # "rails"
puts purl.version  # "7.0.0"

Complex parsing with all components

purl = PackageURL.parse("pkg:npm/@babel/[email protected]?arch=x64#lib/index.js")
puts purl.namespace   # "@babel"
puts purl.qualifiers  # {"arch" => "x64"}
puts purl.subpath     # "lib/index.js"

Parameters:

  • PURL string starting with "pkg:"

Returns:

  • parsed package URL object

Raises:

  • if string doesn't start with "pkg:"

  • if string is malformed

  • if parsed components fail validation



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_dataObject



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.

Examples:

purl1 = PackageURL.parse("pkg:gem/[email protected]")
purl2 = PackageURL.parse("pkg:gem/[email protected]")
puts purl1 == purl2  # true

Parameters:

  • object to compare with

Returns:

  • true if equal, false otherwise



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

Examples:

purl = PackageURL.parse("pkg:npm/[email protected]")
advisories = purl.advisories
advisories.each { |adv| puts adv[:title] }

Parameters:

  • (defaults to: nil)

    User agent string for API requests

  • (defaults to: 10)

    Request timeout in seconds

Returns:

  • Array of advisory hashes, empty if none found

Raises:

  • if the lookup fails due to network or API errors



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

#deconstructArray

Pattern matching support for Ruby 2.7+

Allows destructuring PackageURL in pattern matching.

Examples:

Ruby 2.7+ pattern matching

case purl
in ["gem", nil, name, version, nil, nil]
  puts "Simple gem: #{name} v#{version}"
end

Returns:

  • array of [type, namespace, name, version, qualifiers, subpath]



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)

Examples:

Ruby 2.7+ hash pattern matching

case purl
in {type: "gem", name:, version:}
  puts "Gem #{name} version #{version}"
end

Parameters:

  • keys to extract, or nil for all keys

Returns:

  • hash with requested keys



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_urlObject



90
91
92
# File 'lib/purl/ecosystems_url.rb', line 90

def ecosystems_api_url
  EcosystemsURL.api_url(self)
end

#ecosystems_package_api_urlObject



94
95
96
# File 'lib/purl/ecosystems_url.rb', line 94

def ecosystems_package_api_url
  EcosystemsURL.package_api_url(self)
end

#ecosystems_registryObject



86
87
88
# File 'lib/purl/ecosystems_url.rb', line 86

def ecosystems_registry
  EcosystemsURL.registry_name(self)
end

#ecosystems_version_api_urlObject



98
99
100
# File 'lib/purl/ecosystems_url.rb', line 98

def ecosystems_version_api_url
  EcosystemsURL.version_api_url(self)
end

#hashInteger

Generate hash code for the PackageURL

Returns:

  • hash code based on canonical string representation



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

Examples:

purl = PackageURL.parse("pkg:cargo/[email protected]")
info = purl.lookup
puts info[:package][:name]  # => "rand"
puts info[:version][:published_at] if info[:version]  # => "2025-07-20T17:47:01.870Z"

Parameters:

  • (defaults to: nil)

    User agent string for API requests

  • (defaults to: 10)

    Request timeout in seconds

Returns:

  • Package information hash or nil if not found

Raises:

  • if the lookup fails due to network or API errors



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

Returns:



260
261
262
# File 'lib/purl/download_url.rb', line 260

def supports_download_url?
  DownloadURL.supports?(type)
end

#supports_registry_url?Boolean

Returns:



636
637
638
# File 'lib/purl/registry_url.rb', line 636

def supports_registry_url?
  RegistryURL.supports?(type)
end

#to_hHash<Symbol, Object>

Convert the PackageURL to a hash representation

Examples:

purl = PackageURL.new(type: "gem", name: "rails", version: "7.0.0")
hash = purl.to_h
# => {:type=>"gem", :namespace=>nil, :name=>"rails", :version=>"7.0.0", 
#     :qualifiers=>nil, :subpath=>nil}

Returns:

  • hash with component keys and values



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_sString

Convert the PackageURL to its canonical string representation

Examples:

purl = PackageURL.new(type: "gem", name: "rails", version: "7.0.0")
puts purl.to_s  # "pkg:gem/[email protected]"

Returns:

  • canonical PURL string



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

#versionlessPackageURL

Create a new PackageURL without the version component

Examples:

purl = PackageURL.parse("pkg:gem/[email protected]")
versionless = purl.versionless
puts versionless.to_s  # "pkg:gem/rails"

Returns:

  • new PackageURL instance with version set to nil



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

Examples:

purl = PackageURL.parse("pkg:gem/[email protected]")
new_purl = purl.with(version: "7.1.0", qualifiers: {"arch" => "x64"})
puts new_purl.to_s  # "pkg:gem/[email protected]?arch=x64"

Parameters:

  • attributes to change

Returns:

  • new PackageURL instance with changes applied



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