Class: Purl::PackageURL

Inherits:
Object
  • Object
show all
Defined in:
lib/purl/registry_url.rb,
lib/purl/package_url.rb

Overview

Add registry URL generation methods to PackageURL

Constant Summary collapse

VALID_TYPE_CHARS =
/\A[a-zA-Z0-9\.\+\-]+\z/
VALID_QUALIFIER_KEY_CHARS =
/\A[a-zA-Z0-9\.\-_]+\z/

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

Returns a new instance of PackageURL.



12
13
14
15
16
17
18
19
20
21
22
# File 'lib/purl/package_url.rb', line 12

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) if namespace
  @version = validate_version(version) if version
  @qualifiers = validate_qualifiers(qualifiers) if qualifiers
  @subpath = validate_subpath(subpath) if subpath
  
  # Type-specific validation
  validate_type_specific_rules
end

Instance Attribute Details

#nameObject (readonly)

Returns the value of attribute name.



7
8
9
# File 'lib/purl/package_url.rb', line 7

def name
  @name
end

#namespaceObject (readonly)

Returns the value of attribute namespace.



7
8
9
# File 'lib/purl/package_url.rb', line 7

def namespace
  @namespace
end

#qualifiersObject (readonly)

Returns the value of attribute qualifiers.



7
8
9
# File 'lib/purl/package_url.rb', line 7

def qualifiers
  @qualifiers
end

#subpathObject (readonly)

Returns the value of attribute subpath.



7
8
9
# File 'lib/purl/package_url.rb', line 7

def subpath
  @subpath
end

#typeObject (readonly)

Returns the value of attribute type.



7
8
9
# File 'lib/purl/package_url.rb', line 7

def type
  @type
end

#versionObject (readonly)

Returns the value of attribute version.



7
8
9
# File 'lib/purl/package_url.rb', line 7

def version
  @version
end

Class Method Details

.parse(purl_string) ⇒ Object

Raises:



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
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
# File 'lib/purl/package_url.rb', line 24

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\/+/, "")
  
  # 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) ⇒ Object



197
198
199
200
201
# File 'lib/purl/package_url.rb', line 197

def ==(other)
  return false unless other.is_a?(PackageURL)
  
  to_s == other.to_s
end

#deconstructObject

Pattern matching support for Ruby 2.7+



208
209
210
# File 'lib/purl/package_url.rb', line 208

def deconstruct
  [type, namespace, name, version, qualifiers, subpath]
end

#deconstruct_keys(keys) ⇒ Object



212
213
214
215
# File 'lib/purl/package_url.rb', line 212

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

#hashObject



203
204
205
# File 'lib/purl/package_url.rb', line 203

def hash
  to_s.hash
end

#registry_url(base_url: nil) ⇒ Object



531
532
533
# File 'lib/purl/registry_url.rb', line 531

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

#registry_url_with_version(base_url: nil) ⇒ Object



535
536
537
# File 'lib/purl/registry_url.rb', line 535

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)


539
540
541
# File 'lib/purl/registry_url.rb', line 539

def supports_registry_url?
  RegistryURL.supports?(type)
end

#to_hObject



186
187
188
189
190
191
192
193
194
195
# File 'lib/purl/package_url.rb', line 186

def to_h
  {
    type: type,
    namespace: namespace,
    name: name,
    version: version,
    qualifiers: qualifiers,
    subpath: subpath
  }
end

#to_sObject



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
# File 'lib/purl/package_url.rb', line 137

def to_s
  result = "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
    result += "/#{namespace_parts.join("/")}"
  end
  
  result += "/#{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
    result += "@#{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) }
      result += "##{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
    result += "?#{query_parts.join("&")}"
  end
  
  result
end

#with(**changes) ⇒ Object

Create a new PackageURL with modified attributes Usage: new_purl = purl.with(version: “2.0.0”, qualifiers: => “x64”)



219
220
221
222
223
# File 'lib/purl/package_url.rb', line 219

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