Class: PackageURL

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

Overview

A package URL, or purl, is a URL string used to identify and locate a software package in a mostly universal and uniform way across programing languages, package managers, packaging conventions, tools, APIs and databases.

A purl is a URL composed of seven components:

“` scheme:type/namespace/[email protected]?qualifiers#subpath “`

For example, the package URL for this Ruby package at version 0.1.0 is `pkg:ruby/mattt/[email protected]`.

Defined Under Namespace

Classes: InvalidPackageURL

Constant Summary collapse

VERSION =

:nodoc:

'0.1.0'

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

Constructs a package URL from its components

Parameters:

  • type (String)

    The package type or protocol.

  • namespace (String) (defaults to: nil)

    A name prefix, specific to the type of package.

  • name (String)

    The name of the package.

  • version (String) (defaults to: nil)

    The version of the package.

  • qualifiers (Hash) (defaults to: nil)

    Extra qualifying data for a package, specific to the type of package.

  • subpath (String) (defaults to: nil)

    An extra subpath within a package, relative to the package root.

Raises:

  • (ArgumentError)

58
59
60
61
62
63
64
65
66
67
68
# File 'lib/package_url.rb', line 58

def initialize(type:, name:, namespace: nil, version: nil, qualifiers: nil, subpath: nil)
  raise ArgumentError, 'type is required' if type.nil? || type.empty?
  raise ArgumentError, 'name is required' if name.nil? || name.empty?

  @type = type.downcase
  @namespace = namespace
  @name = name
  @version = version
  @qualifiers = qualifiers
  @subpath = subpath
end

Instance Attribute Details

#nameObject (readonly)

The name of the package.


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

def name
  @name
end

#namespaceObject (readonly)

A name prefix, specific to the type of package. For example, an npm scope, a Docker image owner, or a GitHub user.


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

def namespace
  @namespace
end

#qualifiersObject (readonly)

Extra qualifying data for a package, specific to the type of package. For example, the operating system or architecture.


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

def qualifiers
  @qualifiers
end

#subpathObject (readonly)

An extra subpath within a package, relative to the package root.


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

def subpath
  @subpath
end

#typeObject (readonly)

The package type or protocol, such as `“gem”`, `“npm”`, and `“github”`.


32
33
34
# File 'lib/package_url.rb', line 32

def type
  @type
end

#versionObject (readonly)

The version of the package.


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

def version
  @version
end

Class Method Details

.parse(string) ⇒ PackageURL

Creates a new PackageURL from a string.

Parameters:

  • string (String)

    The package URL string.

Returns:

Raises:


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

def self.parse(string)
  components = {}

  # Split the purl string once from right on '#'
  # - The left side is the remainder
  # - Strip the right side from leading and trailing '/'
  # - Split this on '/'
  # - Discard any empty string segment from that split
  # - Discard any '.' or '..' segment from that split
  # - Percent-decode each segment
  # - UTF-8-decode each segment if needed in your programming language
  # - Join segments back with a '/'
  # - This is the subpath
  case string.rpartition('#')
  in String => remainder, separator, String => subpath unless separator.empty?
    components[:subpath] = subpath.split('/').select do |segment|
      !segment.empty? && segment != '.' && segment != '..'
    end.compact.join('/')

    string = remainder
  else
    components[:subpath] = nil
  end

  # Split the remainder once from right on '?'
  # - The left side is the remainder
  # - The right side is the qualifiers string
  # - Split the qualifiers on '&'. Each part is a key=value pair
  # - For each pair, split the key=value once from left on '=':
  # - The key is the lowercase left side
  # - The value is the percent-decoded right side
  # - UTF-8-decode the value if needed in your programming language
  # - Discard any key/value pairs where the value is empty
  # - If the key is checksums,
  #   split the value on ',' to create a list of checksums
  # - This list of key/value is the qualifiers object
  case string.rpartition('?')
  in String => remainder, separator, String => qualifiers unless separator.empty?
    components[:qualifiers] = {}

    qualifiers.split('&').each do |pair|
      case pair.partition('=')
      in String => key, separator, String => value unless separator.empty?
        key = key.downcase
        value = URI.decode_www_form_component(value)
        next if value.empty?

        case key
        when 'checksums'
          components[:qualifiers][key] = value.split(',')
        else
          components[:qualifiers][key] = value
        end
      else
        next
      end
    end

    string = remainder
  else
    components[:qualifiers] = nil
  end

  # Split the remainder once from left on ':'
  # - The left side lowercased is the scheme
  # - The right side is the remainder
  case string.partition(':')
  in 'pkg', separator, String => remainder unless separator.empty?
    string = remainder
  else
    raise InvalidPackageURL, 'invalid or missing "pkg:" URL scheme'
  end

  # Strip the remainder from leading and trailing '/'
  # - Split this once from left on '/'
  # - The left side lowercased is the type
  # - The right side is the remainder
  string = string.delete_suffix('/')
  case string.partition('/')
  in String => type, separator, remainder unless separator.empty?
    components[:type] = type

    string = remainder
  else
    raise InvalidPackageURL, 'invalid or missing package type'
  end

  # Split the remainder once from right on '@'
  # - The left side is the remainder
  # - Percent-decode the right side. This is the version.
  # - UTF-8-decode the version if needed in your programming language
  # - This is the version
  case string.rpartition('@')
  in String => remainder, separator, String => version unless separator.empty?
    components[:version] = URI.decode_www_form_component(version)

    string = remainder
  else
    components[:version] = nil
  end

  # Split the remainder once from right on '/'
  # - The left side is the remainder
  # - Percent-decode the right side. This is the name
  # - UTF-8-decode this name if needed in your programming language
  # - Apply type-specific normalization to the name if needed
  # - This is the name
  case string.rpartition('/')
  in String => remainder, separator, String => name unless separator.empty?
    components[:name] = URI.decode_www_form_component(name)

    # Split the remainder on '/'
    # - Discard any empty segment from that split
    # - Percent-decode each segment
    # - UTF-8-decode the each segment if needed in your programming language
    # - Apply type-specific normalization to each segment if needed
    # - Join segments back with a '/'
    # - This is the namespace
    components[:namespace] = remainder.split('/').map { |s| URI.decode_www_form_component(s) }.compact.join('/')
  in _, _, String => name
    components[:name] = URI.decode_www_form_component(name)
    components[:namespace] = nil
  end

  new(type: components[:type],
      name: components[:name],
      namespace: components[:namespace],
      version: components[:version],
      qualifiers: components[:qualifiers],
      subpath: components[:subpath])
end

Instance Method Details

#deconstructObject

Returns an array containing the scheme, type, namespace, name, version, qualifiers, and subpath components of the package URL.


348
349
350
# File 'lib/package_url.rb', line 348

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

#deconstruct_keys(_keys) ⇒ Object

Returns a hash containing the scheme, type, namespace, name, version, qualifiers, and subpath components of the package URL.


355
356
357
# File 'lib/package_url.rb', line 355

def deconstruct_keys(_keys)
  to_h
end

#schemeObject

The URL scheme, which has a constant value of `“pkg”`.


27
28
29
# File 'lib/package_url.rb', line 27

def scheme
  'pkg'
end

#to_hObject

Returns a hash containing the scheme, type, namespace, name, version, qualifiers, and subpath components of the package URL.


209
210
211
212
213
214
215
216
217
218
219
# File 'lib/package_url.rb', line 209

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

#to_sObject

Returns a string representation of the package URL. Package URL representations are created according to the instructions from github.com/package-url/purl-spec/blob/0b1559f76b79829e789c4f20e6d832c7314762c5/PURL-SPECIFICATION.rst#how-to-build-purl-string-from-its-components.


224
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
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# File 'lib/package_url.rb', line 224

def to_s
  # Start a purl string with the "pkg:" scheme as a lowercase ASCII string
  purl = 'pkg:'

  # Append the type string to the purl as a lowercase ASCII string
  # Append '/' to the purl

  purl += @type
  purl += '/'

  # If the namespace is not empty:
  # - Strip the namespace from leading and trailing '/'
  # - Split on '/' as segments
  # - Apply type-specific normalization to each segment if needed
  # - UTF-8-encode each segment if needed in your programming language
  # - Percent-encode each segment
  # - Join the segments with '/'
  # - Append this to the purl
  # - Append '/' to the purl
  # - Strip the name from leading and trailing '/'
  # - Apply type-specific normalization to the name if needed
  # - UTF-8-encode the name if needed in your programming language
  # - Append the percent-encoded name to the purl
  #
  # If the namespace is empty:
  # - Apply type-specific normalization to the name if needed
  # - UTF-8-encode the name if needed in your programming language
  # - Append the percent-encoded name to the purl
  case @namespace
  in String => namespace unless namespace.empty?
    segments = []
    @namespace.delete_prefix('/').delete_suffix('/').split('/').each do |segment|
      next if segment.empty?

      segments << URI.encode_www_form_component(segment)
    end
    purl += segments.join('/')

    purl += '/'
    purl += URI.encode_www_form_component(@name.delete_prefix('/').delete_suffix('/'))
  else
    purl += URI.encode_www_form_component(@name)
  end

  # If the version is not empty:
  # - Append '@' to the purl
  # - UTF-8-encode the version if needed in your programming language
  # - Append the percent-encoded version to the purl
  case @version
  in String => version unless version.empty?
    purl += '@'
    purl += URI.encode_www_form_component(@version)
  else
    nil
  end

  # If the qualifiers are not empty and not composed only of key/value pairs
  # where the value is empty:
  # - Append '?' to the purl
  # - Build a list from all key/value pair:
  # - discard any pair where the value is empty.
  # - UTF-8-encode each value if needed in your programming language
  # - If the key is checksums and this is a list of checksums
  #   join this list with a ',' to create this qualifier value
  # - create a string by joining the lowercased key,
  #   the equal '=' sign and the percent-encoded value to create a qualifier
  # - sort this list of qualifier strings lexicographically
  # - join this list of qualifier strings with a '&' ampersand
  # - Append this string to the purl
  case @qualifiers
  in Hash => qualifiers unless qualifiers.empty?
    list = []
    qualifiers.each do |key, value|
      next if value.empty?

      case [key, value]
      in 'checksums', Array => checksums
        list << "#{key.downcase}=#{checksums.join(',')}"
      else
        list << "#{key.downcase}=#{URI.encode_www_form_component(value)}"
      end
    end

    unless list.empty?
      purl += '?'
      purl += list.sort.join('&')
    end
  else
    nil
  end

  # If the subpath is not empty and not composed only of
  # empty, '.' and '..' segments:
  # - Append '#' to the purl
  # - Strip the subpath from leading and trailing '/'
  # - Split this on '/' as segments
  # - Discard empty, '.' and '..' segments
  # - Percent-encode each segment
  # - UTF-8-encode each segment if needed in your programming language
  # - Join the segments with '/'
  # - Append this to the purl
  case @subpath
  in String => subpath unless subpath.empty?
    segments = []
    subpath.delete_prefix('/').delete_suffix('/').split('/').each do |segment|
      next if segment.empty? || segment == '.' || segment == '..'

      segments << URI.encode_www_form_component(segment)
    end

    unless segments.empty?
      purl += '#'
      purl += segments.join('/')
    end
  else
    nil
  end

  purl
end