Class: Purl::PackageURL

Inherits:
Object
  • Object
show all
Defined in:
lib/purl/package_url.rb,
lib/purl/registry_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

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:

  • type (String, Symbol)

    the package type (required)

  • name (String)

    the package name (required)

  • namespace (String, nil) (defaults to: nil)

    optional namespace/scope

  • version (String, nil) (defaults to: nil)

    optional version

  • qualifiers (Hash, nil) (defaults to: nil)

    optional key-value qualifier pairs

  • subpath (String, nil) (defaults to: nil)

    optional subpath within package

Raises:



74
75
76
77
78
79
80
81
82
83
84
# File 'lib/purl/package_url.rb', line 74

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:

  • (String)

    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:

  • (String, nil)

    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:

  • (Hash<String, String>, nil)

    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:

  • (String, nil)

    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:

  • (String)

    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:

  • (String, nil)

    the package version



43
44
45
# File 'lib/purl/package_url.rb', line 43

def version
  @version
end

Class Method Details

.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 (String)

    PURL string starting with “pkg:”

Returns:

Raises:



105
106
107
108
109
110
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
# File 'lib/purl/package_url.rb', line 105

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| URI.decode_www_form_component(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 = URI.decode_www_form_component(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 = URI.decode_www_form_component(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 = URI.decode_www_form_component(path_components[0])
      else
        namespace = path_components.map { |part| URI.decode_www_form_component(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 = URI.decode_www_form_component(path_components[0])
      namespace = nil
    else
      # Multiple components - assume last is name, others are namespace
      name = URI.decode_www_form_component(path_components.pop)
      
      # Everything else is namespace
      if path_components.length == 1
        namespace = URI.decode_www_form_component(path_components[0])
      else
        # Multiple remaining components - treat as namespace joined together
        namespace = path_components.map { |part| URI.decode_www_form_component(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

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:

  • other (Object)

    object to compare with

Returns:

  • (Boolean)

    true if equal, false otherwise



305
306
307
308
309
# File 'lib/purl/package_url.rb', line 305

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:

  • user_agent (String) (defaults to: nil)

    User agent string for API requests

  • timeout (Integer) (defaults to: 10)

    Request timeout in seconds

Returns:

  • (Array<Hash>)

    Array of advisory hashes, empty if none found

Raises:

  • (AdvisoryError)

    if the lookup fails due to network or API errors



404
405
406
407
408
# File 'lib/purl/package_url.rb', line 404

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)

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



329
330
331
# File 'lib/purl/package_url.rb', line 329

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 (Array<Symbol>, nil)

    keys to extract, or nil for all keys

Returns:

  • (Hash<Symbol, Object>)

    hash with requested keys



343
344
345
346
# File 'lib/purl/package_url.rb', line 343

def deconstruct_keys(keys)
  return to_h.slice(*keys) if keys
  to_h
end

#hashInteger

Generate hash code for the PackageURL

Returns:

  • (Integer)

    hash code based on canonical string representation



314
315
316
# File 'lib/purl/package_url.rb', line 314

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:

  • user_agent (String) (defaults to: nil)

    User agent string for API requests

  • timeout (Integer) (defaults to: 10)

    Request timeout in seconds

Returns:

  • (Hash, nil)

    Package information hash or nil if not found

Raises:

  • (LookupError)

    if the lookup fails due to network or API errors



387
388
389
390
391
# File 'lib/purl/package_url.rb', line 387

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



638
639
640
# File 'lib/purl/registry_url.rb', line 638

def registry_url(base_url: nil)
  RegistryURL.generate(self, base_url: base_url)
end

#registry_url_with_version(base_url: nil) ⇒ Object



642
643
644
# File 'lib/purl/registry_url.rb', line 642

def registry_url_with_version(base_url: nil)
  RegistryURL.new(self).generate_with_version(base_url: base_url)
end

#supports_registry_url?Boolean

Returns:

  • (Boolean)


646
647
648
# File 'lib/purl/registry_url.rb', line 646

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<Symbol, Object>)

    hash with component keys and values



283
284
285
286
287
288
289
290
291
292
# File 'lib/purl/package_url.rb', line 283

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:

  • (String)

    canonical PURL string



225
226
227
228
229
230
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
# File 'lib/purl/package_url.rb', line 225

def to_s
  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
    # Special handling for version encoding - don't encode colon in certain contexts
    encoded_version = case type&.downcase
    when "docker"
      # Docker versions with sha256: should not encode the colon
      version.gsub("sha256:", "sha256:")
    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
  
  parts.join
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:

  • (PackageURL)

    new PackageURL instance with version set to nil



371
372
373
# File 'lib/purl/package_url.rb', line 371

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:

  • changes (Hash)

    attributes to change

Returns:

  • (PackageURL)

    new PackageURL instance with changes applied



357
358
359
360
361
# File 'lib/purl/package_url.rb', line 357

def with(**changes)
  current_attrs = to_h
  new_attrs = current_attrs.merge(changes)
  self.class.new(**new_attrs)
end