Class: Purl::DownloadURL

Inherits:
Object
  • Object
show all
Defined in:
lib/purl/download_url.rb

Constant Summary collapse

DOWNLOAD_PATTERNS =
{
  "gem" => {
    base_url: "https://rubygems.org/downloads",
    pattern: ->(purl) { "#{purl.name}-#{purl.version}.gem" }
  },
  "npm" => {
    base_url: "https://registry.npmjs.org",
    pattern: ->(purl) do
      # npm: /{name}/-/{basename}-{version}.tgz
      # For scoped packages: /@scope/name/-/name-version.tgz
      basename = purl.name.split("/").last
      if purl.namespace
        "#{purl.namespace}/#{purl.name}/-/#{basename}-#{purl.version}.tgz"
      else
        "#{purl.name}/-/#{purl.name}-#{purl.version}.tgz"
      end
    end
  },
  "cargo" => {
    base_url: "https://static.crates.io/crates",
    pattern: ->(purl) { "#{purl.name}/#{purl.name}-#{purl.version}.crate" }
  },
  "nuget" => {
    base_url: "https://api.nuget.org/v3-flatcontainer",
    pattern: ->(purl) do
      name_lower = purl.name.downcase
      "#{name_lower}/#{purl.version}/#{name_lower}.#{purl.version}.nupkg"
    end
  },
  "hex" => {
    base_url: "https://repo.hex.pm/tarballs",
    pattern: ->(purl) { "#{purl.name}-#{purl.version}.tar" }
  },
  "hackage" => {
    base_url: "https://hackage.haskell.org/package",
    pattern: ->(purl) { "#{purl.name}-#{purl.version}/#{purl.name}-#{purl.version}.tar.gz" }
  },
  "pub" => {
    base_url: "https://pub.dev/packages",
    pattern: ->(purl) { "#{purl.name}/versions/#{purl.version}.tar.gz" }
  },
  "golang" => {
    base_url: "https://proxy.golang.org",
    pattern: ->(purl) do
      # Go module proxy requires encoding capital letters as !lowercase
      full_path = purl.namespace ? "#{purl.namespace}/#{purl.name}" : purl.name
      encoded = full_path.gsub(/[A-Z]/) { |s| "!#{s.downcase}" }
      "#{encoded}/@v/#{purl.version}.zip"
    end
  },
  "maven" => {
    base_url: "https://repo.maven.apache.org/maven2",
    pattern: ->(purl) do
      # Maven: /{group_path}/{artifact}/{version}/{artifact}-{version}.jar
      # group_id uses dots, path uses slashes
      group_path = purl.namespace.gsub(".", "/")
      "#{group_path}/#{purl.name}/#{purl.version}/#{purl.name}-#{purl.version}.jar"
    end
  },
  "cran" => {
    base_url: "https://cran.r-project.org/src/contrib",
    pattern: ->(purl) { "#{purl.name}_#{purl.version}.tar.gz" }
  },
  "bioconductor" => {
    base_url: "https://bioconductor.org/packages/release/bioc/src/contrib",
    pattern: ->(purl) { "#{purl.name}_#{purl.version}.tar.gz" }
  },
  "clojars" => {
    base_url: "https://repo.clojars.org",
    pattern: ->(purl) do
      # Clojars uses maven-style paths
      # namespace is group_id, name is artifact_id
      # If no namespace, group_id = artifact_id
      group_id = purl.namespace || purl.name
      artifact_id = purl.name
      group_path = group_id.gsub(".", "/")
      "#{group_path}/#{artifact_id}/#{purl.version}/#{artifact_id}-#{purl.version}.jar"
    end
  },
  "elm" => {
    base_url: "https://github.com",
    pattern: ->(purl) do
      # Elm packages are hosted on GitHub
      # namespace/name format maps to GitHub user/repo
      return nil unless purl.namespace
      "#{purl.namespace}/#{purl.name}/archive/#{purl.version}.zip"
    end
  },
  "github" => {
    base_url: "https://github.com",
    pattern: ->(purl) do
      return nil unless purl.namespace
      "#{purl.namespace}/#{purl.name}/archive/refs/tags/#{purl.version}.tar.gz"
    end
  },
  "gitlab" => {
    base_url: "https://gitlab.com",
    pattern: ->(purl) do
      return nil unless purl.namespace
      "#{purl.namespace}/#{purl.name}/-/archive/#{purl.version}/#{purl.name}-#{purl.version}.tar.gz"
    end
  },
  "bitbucket" => {
    base_url: "https://bitbucket.org",
    pattern: ->(purl) do
      return nil unless purl.namespace
      "#{purl.namespace}/#{purl.name}/get/#{purl.version}.tar.gz"
    end
  },
  "luarocks" => {
    base_url: "https://luarocks.org/manifests",
    pattern: ->(purl) do
      return nil unless purl.namespace
      "#{purl.namespace}/#{purl.name}-#{purl.version}.src.rock"
    end
  },
  "swift" => {
    base_url: nil,
    pattern: ->(purl) do
      # Swift namespace is like "github.com/owner", name is repo
      # e.g. pkg:swift/github.com/Alamofire/[email protected]
      return nil unless purl.namespace
      parts = purl.namespace.split("/", 2)
      host = parts[0]
      owner = parts[1]
      return nil unless host && owner

      case host
      when "github.com"
        "https://github.com/#{owner}/#{purl.name}/archive/refs/tags/#{purl.version}.tar.gz"
      when "gitlab.com"
        "https://gitlab.com/#{owner}/#{purl.name}/-/archive/#{purl.version}/#{purl.name}-#{purl.version}.tar.gz"
      when "bitbucket.org"
        "https://bitbucket.org/#{owner}/#{purl.name}/get/#{purl.version}.tar.gz"
      end
    end
  },
  "composer" => {
    base_url: nil,
    pattern: ->(purl) { nil },
    note: "Composer packages are downloaded from source repositories, not Packagist"
  },
  "cocoapods" => {
    base_url: nil,
    pattern: ->(purl) { nil },
    note: "CocoaPods packages are downloaded from source repositories"
  }
}.freeze
NAMESPACE_REQUIRED_TYPES =

Types that require a namespace for download URLs

%w[maven elm github gitlab bitbucket luarocks swift].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(purl) ⇒ DownloadURL

Returns a new instance of DownloadURL.



188
189
190
# File 'lib/purl/download_url.rb', line 188

def initialize(purl)
  @purl = purl
end

Instance Attribute Details

#purlObject (readonly)

Returns the value of attribute purl.



241
242
243
# File 'lib/purl/download_url.rb', line 241

def purl
  @purl
end

Class Method Details

.generate(purl, base_url: nil) ⇒ Object



154
155
156
# File 'lib/purl/download_url.rb', line 154

def self.generate(purl, base_url: nil)
  new(purl).generate(base_url: base_url)
end

.supported_typesObject



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/purl/download_url.rb', line 161

def self.supported_types
  @supported_types ||= DOWNLOAD_PATTERNS.keys.select do |k|
    pattern = DOWNLOAD_PATTERNS[k]
    next false if pattern[:note]

    namespace = if NAMESPACE_REQUIRED_TYPES.include?(k)
      k == "swift" ? "github.com/test" : "test"
    end
    begin
      result = pattern[:pattern].call(Purl::PackageURL.new(type: k, name: "test", version: "1.0", namespace: namespace))
      !result.nil?
    rescue
      false
    end
  end.sort.freeze
end

.supports?(type) ⇒ Boolean

Returns:



178
179
180
181
182
183
184
185
186
# File 'lib/purl/download_url.rb', line 178

def self.supports?(type)
  pattern = DOWNLOAD_PATTERNS[type.to_s.downcase]
  return false unless pattern
  # Types with base_url are supported, or types that return full URLs (like swift)
  return true if pattern[:base_url]
  # Check if this type returns full URLs by testing with a sample
  return false if pattern[:note] # Has a note means it's not really supported
  true
end

Instance Method Details

#generate(base_url: nil) ⇒ Object



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
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
# File 'lib/purl/download_url.rb', line 192

def generate(base_url: nil)
  unless @purl.version
    raise MissingVersionError.new(
      "Download URL requires a version",
      type: @purl.type
    )
  end

  pattern_config = DOWNLOAD_PATTERNS[@purl.type.downcase]

  unless pattern_config
    raise UnsupportedTypeError.new(
      "No download URL pattern defined for type '#{@purl.type}'. Supported types: #{self.class.supported_types.join(", ")}",
      type: @purl.type,
      supported_types: self.class.supported_types
    )
  end

  # Check for repository_url qualifier in the PURL
  qualifier_base_url = @purl.qualifiers&.dig("repository_url")

  # Generate the path/URL from the pattern
  path = pattern_config[:pattern].call(@purl)

  if path.nil?
    raise UnsupportedTypeError.new(
      "Could not generate download URL for '#{@purl.type}'. #{pattern_config[:note]}",
      type: @purl.type,
      supported_types: self.class.supported_types
    )
  end

  # If the pattern returns a full URL, use it directly
  return path if path.start_with?("http://", "https://")

  # For relative paths, we need a base_url
  effective_base_url = base_url || qualifier_base_url || pattern_config[:base_url]

  unless effective_base_url
    raise UnsupportedTypeError.new(
      "Download URLs are not available for type '#{@purl.type}'. #{pattern_config[:note]}",
      type: @purl.type,
      supported_types: self.class.supported_types
    )
  end

  "#{effective_base_url}/#{path}"
end