# frozen_string_literal: true # MIT License # # Copyright (c) 2021 package-url # Portions Copyright 2022 Gitlab B.V. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. module Sbom class PackageUrl class Decoder include StringUtils def initialize(string) @string = string end def decode! raise ArgumentError, "expected String but given #{@string.class}" unless @string.is_a?(::String) decode_subpath! decode_qualifiers! decode_scheme! decode_type! decode_version! decode_name! decode_namespace! begin PackageUrl.new( type: @type, name: @name, namespace: @namespace, version: @version, qualifiers: @qualifiers, subpath: @subpath ) rescue ArgumentError => e raise InvalidPackageUrl, e.message end end private def decode_subpath! # Split the purl string once from right on '#' # Given the string: `scheme:type/namespace/name@version?qualifiers#subpath` # - The left side is the remainder: `scheme:type/namespace/name@version?qualifiers` # - The right side will be parsed as the subpath: `subpath` @subpath, @string = partition(@string, '#', from: :right) do |subpath| decode_segments(subpath) do |segment| # Discard segments which are blank, `.`, or `..` segment.empty? || segment == '.' || segment == '..' end end end def decode_qualifiers! # Split the remainder once from right on '?' # Given string: `scheme:type/namespace/name@version?qualifiers` # - The left side is the remainder: `scheme:type/namespace/name@version` # - The right side is the qualifiers string: `qualifiers` @qualifiers, @string = partition(@string, '?', from: :right) do |qualifiers| parse_qualifiers(qualifiers) end end def decode_scheme! # Split the remainder once from left on ':' # Given the string: `scheme:type/namespace/name@version` # - The left side lowercased is the scheme: `scheme` # - The right side is the remainder: `type/namespace/name@version` @scheme, @string = partition(@string, ':', from: :left) raise InvalidPackageUrl, 'invalid or missing "pkg:" URL scheme' unless @scheme == 'pkg' end def decode_type! # Strip the remainder from leading and trailing '/' @string = strip(@string, '/') # Split this once from left on '/' # Given the string: `type/namespace/name@version` # - The left side lowercased is the type: `type` # - The right side is the remainder: `namespace/name@version` @type, @string = partition(@string, '/', from: :left, &:downcase) end def decode_version! # Split the remainder once from right on '@' # Given the string: `namespace/name@version` # - The left side is the remainder: `namespace/name` # - The right side is the version: `version` # - The version must be URI decoded @version, @string = partition(@string, '@', from: :right) do |version| URI.decode_www_form_component(version) end end def decode_name! # Split the remainder once from right on '/' # Given the string: `namespace/name` # - The left side is the remainder: `namespace` # - The right size is the name: `name` # - The name must be URI decoded @name, @string = partition(@string, '/', from: :right, require_separator: false) do |name| decoded_name = URI.decode_www_form_component(name) Normalizer.new(type: @type, text: decoded_name).normalize_name end end def decode_namespace! # If there is anything remaining, this is the namespace. # The namespace may contain multiple segments delimited by `/`. return if @string.blank? @namespace = decode_segments(@string, &:empty?) @namespace = Normalizer.new(type: @type, text: @namespace).normalize_namespace end def decode_segment(segment) decoded = URI.decode_www_form_component(segment) raise InvalidPackageUrl, 'slash-separated segments may not contain `/`' if decoded.include?('/') decoded end def decode_segments(string) string.split('/').filter_map do |segment| next if block_given? && yield(segment) decode_segment(segment) end.join('/') end def parse_qualifiers(raw_qualifiers) # - 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 # - 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 raw_qualifiers.split('&').each_with_object({}) do |pair, memo| key, separator, value = pair.partition('=') next if separator.empty? key = key.downcase value = URI.decode_www_form_component(value) next if value.empty? memo[key] = case key when 'checksums' value.split(',') else value end end end end end end