# -*- coding: utf-8 -*- # # Copyright (c) the purl authors # SPDX-License-Identifier: MIT # # 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. # Visit https://github.com/package-url/packageurl-python for support and # download. from packageurl import PackageURL from packageurl.contrib.route import NoRouteAvailable from packageurl.contrib.route import Router DEFAULT_MAVEN_REPOSITORY = "https://repo.maven.apache.org/maven2" def get_repo_download_url_by_package_type( type, namespace, name, version, archive_extension="tar.gz" ): """ Return the download URL for a hosted git repository given a package type or None. """ if archive_extension not in ("zip", "tar.gz"): raise ValueError("Only zip and tar.gz extensions are supported") download_url_by_type = { "github": f"https://github.com/{namespace}/{name}/archive/{version}.{archive_extension}", "bitbucket": f"https://bitbucket.org/{namespace}/{name}/get/{version}.{archive_extension}", "gitlab": f"https://gitlab.com/{namespace}/{name}/-/archive/{version}/{name}-{version}.{archive_extension}", } return download_url_by_type.get(type) repo_router = Router() download_router = Router() def _get_url_from_router(router, purl): if purl: try: return router.process(purl) except NoRouteAvailable: return def get_repo_url(purl): """ Return a repository URL inferred from the `purl` string. """ return _get_url_from_router(repo_router, purl) def get_download_url(purl): """ Return a download URL inferred from the `purl` string. """ download_url = _get_url_from_router(download_router, purl) if download_url: return download_url # Fallback on the `download_url` qualifier when available. purl_data = PackageURL.from_string(purl) return purl_data.qualifiers.get("download_url", None) def get_inferred_urls(purl): """ Return all inferred URLs (repo, download) from the `purl` string. """ url_functions = ( get_repo_url, get_download_url, ) inferred_urls = [] for url_func in url_functions: url = url_func(purl) if url: inferred_urls.append(url) return inferred_urls # Backward compatibility purl2url = get_repo_url get_url = get_repo_url @repo_router.route("pkg:cargo/.*") def build_cargo_repo_url(purl): """ Return a cargo repo URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) name = purl_data.name version = purl_data.version if name and version: return f"https://crates.io/crates/{name}/{version}" elif name: return f"https://crates.io/crates/{name}" @repo_router.route("pkg:bitbucket/.*") def build_bitbucket_repo_url(purl): """ Return a bitbucket repo URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) namespace = purl_data.namespace name = purl_data.name if name and namespace: return f"https://bitbucket.org/{namespace}/{name}" @repo_router.route("pkg:github/.*") def build_github_repo_url(purl): """ Return a github repo URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) namespace = purl_data.namespace name = purl_data.name version = purl_data.version qualifiers = purl_data.qualifiers if not (name and namespace): return repo_url = f"https://github.com/{namespace}/{name}" if version: version_prefix = qualifiers.get("version_prefix", "") repo_url = f"{repo_url}/tree/{version_prefix}{version}" return repo_url @repo_router.route("pkg:gitlab/.*") def build_gitlab_repo_url(purl): """ Return a gitlab repo URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) namespace = purl_data.namespace name = purl_data.name if name and namespace: return f"https://gitlab.com/{namespace}/{name}" @repo_router.route("pkg:(gem|rubygems)/.*") def build_rubygems_repo_url(purl): """ Return a rubygems repo URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) name = purl_data.name version = purl_data.version if name and version: return f"https://rubygems.org/gems/{name}/versions/{version}" elif name: return f"https://rubygems.org/gems/{name}" @repo_router.route("pkg:cran/.*") def build_cran_repo_url(purl): """ Return a cran repo URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) name = purl_data.name version = purl_data.version return f"https://cran.r-project.org/src/contrib/{name}_{version}.tar.gz" @repo_router.route("pkg:npm/.*") def build_npm_repo_url(purl): """ Return a npm repo URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) namespace = purl_data.namespace name = purl_data.name version = purl_data.version repo_url = "https://www.npmjs.com/package/" if namespace: repo_url += f"{namespace}/" repo_url += f"{name}" if version: repo_url += f"/v/{version}" return repo_url @repo_router.route("pkg:pypi/.*") def build_pypi_repo_url(purl): """ Return a pypi repo URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) name = (purl_data.name or "").replace("_", "-") version = purl_data.version if name and version: return f"https://pypi.org/project/{name}/{version}/" elif name: return f"https://pypi.org/project/{name}/" @repo_router.route("pkg:composer/.*") def build_composer_repo_url(purl): """ Return a composer repo URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) name = purl_data.name version = purl_data.version namespace = purl_data.namespace if name and version: return f"https://packagist.org/packages/{namespace}/{name}#{version}" elif name: return f"https://packagist.org/packages/{namespace}/{name}" @repo_router.route("pkg:nuget/.*") def build_nuget_repo_url(purl): """ Return a nuget repo URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) name = purl_data.name version = purl_data.version if name and version: return f"https://www.nuget.org/packages/{name}/{version}" elif name: return f"https://www.nuget.org/packages/{name}" @repo_router.route("pkg:hackage/.*") def build_hackage_repo_url(purl): """ Return a hackage repo URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) name = purl_data.name version = purl_data.version if name and version: return f"https://hackage.haskell.org/package/{name}-{version}" elif name: return f"https://hackage.haskell.org/package/{name}" @repo_router.route("pkg:golang/.*") def build_golang_repo_url(purl): """ Return a golang repo URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) namespace = purl_data.namespace name = purl_data.name version = purl_data.version if name and version: return f"https://pkg.go.dev/{namespace}/{name}@{version}" elif name: return f"https://pkg.go.dev/{namespace}/{name}" @repo_router.route("pkg:cocoapods/.*") def build_cocoapods_repo_url(purl): """ Return a CocoaPods repo URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) name = purl_data.name return name and f"https://cocoapods.org/pods/{name}" @repo_router.route("pkg:maven/.*") def build_maven_repo_url(purl): """ Return a Maven repo URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) namespace = purl_data.namespace name = purl_data.name version = purl_data.version qualifiers = purl_data.qualifiers base_url = qualifiers.get("repository_url", DEFAULT_MAVEN_REPOSITORY) if namespace and name and version: namespace = namespace.replace(".", "/") return f"{base_url}/{namespace}/{name}/{version}" # Download URLs: @download_router.route("pkg:cargo/.*") def build_cargo_download_url(purl): """ Return a cargo download URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) name = purl_data.name version = purl_data.version if name and version: return f"https://crates.io/api/v1/crates/{name}/{version}/download" @download_router.route("pkg:(gem|rubygems)/.*") def build_rubygems_download_url(purl): """ Return a rubygems download URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) name = purl_data.name version = purl_data.version if name and version: return f"https://rubygems.org/downloads/{name}-{version}.gem" @download_router.route("pkg:npm/.*") def build_npm_download_url(purl): """ Return a npm download URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) namespace = purl_data.namespace name = purl_data.name version = purl_data.version base_url = "https://registry.npmjs.org" if namespace: base_url += f"/{namespace}" if name and version: return f"{base_url}/{name}/-/{name}-{version}.tgz" @download_router.route("pkg:maven/.*") def build_maven_download_url(purl): """ Return a maven download URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) namespace = purl_data.namespace name = purl_data.name version = purl_data.version qualifiers = purl_data.qualifiers base_url = qualifiers.get("repository_url", DEFAULT_MAVEN_REPOSITORY) maven_type = qualifiers.get("type", "jar") # default to "jar" classifier = qualifiers.get("classifier") if namespace and name and version: namespace = namespace.replace(".", "/") classifier = f"-{classifier}" if classifier else "" return f"{base_url}/{namespace}/{name}/{version}/{name}-{version}{classifier}.{maven_type}" @download_router.route("pkg:hackage/.*") def build_hackage_download_url(purl): """ Return a hackage download URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) name = purl_data.name version = purl_data.version if name and version: return f"https://hackage.haskell.org/package/{name}-{version}/{name}-{version}.tar.gz" @download_router.route("pkg:nuget/.*") def build_nuget_download_url(purl): """ Return a nuget download URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) name = purl_data.name version = purl_data.version if name and version: return f"https://www.nuget.org/api/v2/package/{name}/{version}" @download_router.route("pkg:gitlab/.*", "pkg:bitbucket/.*", "pkg:github/.*") def build_repo_download_url(purl): """ Return a gitlab download URL from the `purl` string. """ return get_repo_download_url(purl) @download_router.route("pkg:hex/.*") def build_hex_download_url(purl): """ Return a hex download URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) name = purl_data.name version = purl_data.version if name and version: return f"https://repo.hex.pm/tarballs/{name}-{version}.tar" @download_router.route("pkg:golang/.*") def build_golang_download_url(purl): """ Return a golang download URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) namespace = purl_data.namespace name = purl_data.name version = purl_data.version if not name: return # TODO: https://github.com/package-url/packageurl-python/issues/197 if namespace: name = f"{namespace}/{name}" ename = escape_golang_path(name) eversion = escape_golang_path(version) if not eversion.startswith("v"): eversion = "v" + eversion if name and version: return f"https://proxy.golang.org/{ename}/@v/{eversion}.zip" @download_router.route("pkg:pub/.*") def build_pub_download_url(purl): """ Return a pub download URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) name = purl_data.name version = purl_data.version if name and version: return f"https://pub.dev/api/archives/{name}-{version}.tar.gz" @download_router.route("pkg:swift/.*") def build_swift_download_url(purl): """ Return a Swift Package download URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) name = purl_data.name version = purl_data.version namespace = purl_data.namespace if not (namespace or name or version): return return f"https://{namespace}/{name}/archive/{version}.zip" @download_router.route("pkg:luarocks/.*") def build_luarocks_download_url(purl): """ Return a LuaRocks download URL from the `purl` string. """ purl_data = PackageURL.from_string(purl) qualifiers = purl_data.qualifiers or {} repository_url = qualifiers.get("repository_url", "https://luarocks.org") name = purl_data.name version = purl_data.version if name and version: return f"{repository_url}/{name}-{version}.src.rock" @download_router.route("pkg:conda/.*") def build_conda_download_url(purl): """ Resolve a Conda PURL to a real downloadable URL Supported qualifiers: - channel: e.g., main, conda-forge (required for deterministic base) - subdir: e.g., linux-64, osx-arm64, win-64, noarch - build: exact build string (optional but recommended) - type: 'conda' or 'tar.bz2' (preference; fallback to whichever exists) """ p = PackageURL.from_string(purl) if not p.name or not p.version: return None q = p.qualifiers or {} name = p.name version = p.version build = q.get("build") channel = q.get("channel") or "main" subdir = q.get("subdir") or "noarch" req_type = q.get("type") def _conda_base_for_channel(channel: str) -> str: """ Map a conda channel to its base URL. - 'main' / 'defaults' -> repo.anaconda.com - any other channel -> conda.anaconda.org/ """ ch = (channel or "").lower() if ch in ("main", "defaults"): return "https://repo.anaconda.com/pkgs/main" return f"https://conda.anaconda.org/{ch}" base = _conda_base_for_channel(channel) package_identifier = ( f"{name}-{version}-{build}.{req_type}" if build else f"{name}-{version}.{req_type}" ) download_url = f"{base}/{subdir}/{package_identifier}" return download_url @download_router.route("pkg:alpm/.*") def build_alpm_download_url(purl_str): purl = PackageURL.from_string(purl_str) name = purl.name version = purl.version arch = purl.qualifiers.get("arch", "any") if not name or not version: return None first_letter = name[0] url = f"https://archive.archlinux.org/packages/{first_letter}/{name}/{name}-{version}-{arch}.pkg.tar.zst" return url def normalize_version(version: str) -> str: """ Remove the epoch (if any) from a Debian version. E.g., "1:2.4.47-2" becomes "2.4.47-2" """ if ":" in version: _, v = version.split(":", 1) return v return version @download_router.route("pkg:deb/.*") def build_deb_download_url(purl_str: str) -> str: """ Construct a download URL for a Debian or Ubuntu package PURL. Supports optional 'repository_url' in qualifiers. """ p = PackageURL.from_string(purl_str) name = p.name version = p.version namespace = p.namespace qualifiers = p.qualifiers or {} arch = qualifiers.get("arch") repository_url = qualifiers.get("repository_url") if not name or not version: raise ValueError("Both name and version must be present in deb purl") if not arch: arch = "source" if repository_url: base_url = repository_url.rstrip("/") else: if namespace == "debian": base_url = "https://deb.debian.org/debian" elif namespace == "ubuntu": base_url = "http://archive.ubuntu.com/ubuntu" else: raise NotImplementedError(f"Unsupported distro namespace: {namespace}") norm_version = normalize_version(version) if arch == "source": filename = f"{name}_{norm_version}.dsc" else: filename = f"{name}_{norm_version}_{arch}.deb" pool_path = f"/pool/main/{name[0].lower()}/{name}" return f"{base_url}{pool_path}/{filename}" @download_router.route("pkg:apk/.*") def build_apk_download_url(purl): """ Return a download URL for a fully qualified Alpine Linux package PURL. Example: pkg:apk/acct@6.6.4-r0?arch=x86&alpine_version=v3.11&repo=main """ purl = PackageURL.from_string(purl) name = purl.name version = purl.version arch = purl.qualifiers.get("arch") repo = purl.qualifiers.get("repo") alpine_version = purl.qualifiers.get("alpine_version") if not name or not version or not arch or not repo or not alpine_version: raise ValueError( "All qualifiers (arch, repo, alpine_version) and name/version must be present in apk purl" ) return ( f"https://dl-cdn.alpinelinux.org/alpine/{alpine_version}/{repo}/{arch}/{name}-{version}.apk" ) def get_repo_download_url(purl): """ Return ``download_url`` if present in ``purl`` qualifiers or if ``namespace``, ``name`` and ``version`` are present in ``purl`` else return None. """ purl_data = PackageURL.from_string(purl) namespace = purl_data.namespace type = purl_data.type name = purl_data.name version = purl_data.version qualifiers = purl_data.qualifiers download_url = qualifiers.get("download_url") if download_url: return download_url if not (namespace and name and version): return version_prefix = qualifiers.get("version_prefix", "") version = f"{version_prefix}{version}" return get_repo_download_url_by_package_type( type=type, namespace=namespace, name=name, version=version ) # TODO: https://github.com/package-url/packageurl-python/issues/196 def escape_golang_path(path: str) -> str: """ Return an case-encoded module path or version name. This is done by replacing every uppercase letter with an exclamation mark followed by the corresponding lower-case letter, in order to avoid ambiguity when serving from case-insensitive file systems. See https://golang.org/ref/mod#goproxy-protocol. """ escaped_path = "" for c in path: if c >= "A" and c <= "Z": # replace uppercase with !lowercase escaped_path += "!" + chr(ord(c) + ord("a") - ord("A")) else: escaped_path += c return escaped_path