From 162e35615d10353d7b61b55983c808ac36dbcb47 Mon Sep 17 00:00:00 2001 From: Tushar Goel Date: Wed, 20 Aug 2025 14:12:44 +0530 Subject: [PATCH 1/8] Add validation function Signed-off-by: Tushar Goel --- spec | 2 +- src/packageurl/__init__.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/spec b/spec index a627e02..5748295 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit a627e02e97b3a43de3938c3d8f67da7a51395578 +Subproject commit 57482954eaa1529d681a04a3345ac9f8bef3db45 diff --git a/src/packageurl/__init__.py b/src/packageurl/__init__.py index a2c445e..945a390 100644 --- a/src/packageurl/__init__.py +++ b/src/packageurl/__init__.py @@ -24,6 +24,7 @@ from __future__ import annotations +import re import string from collections import namedtuple from collections.abc import Mapping @@ -469,6 +470,40 @@ def to_string(self, encode: bool | None = True) -> str: return "".join(purl) + + @classmethod + def validate_alpm(cls): + """Type-specific validation for ALPM PURLs.""" + if cls.namespace.lower() != cls.namespace: + return False, "Namespace must be lowercase." + if cls.name.lower() != cls.name: + return False, "Package name must be lowercase." + if not re.match(r"^[0-9]*:?[\w\.\+\-]+$", cls.version): + return False, f"Invalid version format '{cls.version}'." + + if cls.qualifiers: + for key in cls.qualifiers: + if key != "arch": + return False, f"Unknown qualifier '{key}', only 'arch' is allowed." + if not cls.qualifiers[key]: + return False, "Qualifier 'arch' cannot be empty." + + + @classmethod + def validate(cls): + """ + Main validation function. + Runs basic validation first, then dispatches to type-specific validators. + Yields error messages only. + """ + yield from cls.validate_basic() + + validator_by_type: dict[str, Callable[[str], Iterable[str]]] = { + "alpm": cls.validate_alpm, + } + + yield from validator_by_type() + @classmethod def from_string(cls, purl: str) -> Self: """ From 0a165e25f78fa1331f19116b5d32a3c3b8776126 Mon Sep 17 00:00:00 2001 From: Tushar Goel Date: Sun, 24 Aug 2025 16:54:07 +0530 Subject: [PATCH 2/8] Add validation generator script and validate function Signed-off-by: Tushar Goel --- etc/scripts/generate_validators.py | 314 ++++++++++++++ spec | 2 +- src/packageurl/__init__.py | 42 +- src/packageurl/validate.py | 636 +++++++++++++++++++++++++++++ 4 files changed, 962 insertions(+), 32 deletions(-) create mode 100644 etc/scripts/generate_validators.py create mode 100644 src/packageurl/validate.py diff --git a/etc/scripts/generate_validators.py b/etc/scripts/generate_validators.py new file mode 100644 index 0000000..3ededc2 --- /dev/null +++ b/etc/scripts/generate_validators.py @@ -0,0 +1,314 @@ +# Generate a simple script based on provided list for package types + +""" +{ + "$schema": "https://packageurl.org/schemas/purl-type-definition.schema-1.0.json", + "$id": "https://packageurl.org/types/pypi-definition.json", + "type": "pypi", + "type_name": "PyPI", + "description": "Python packages", + "repository": { + "use_repository": true, + "default_repository_url": "https://pypi.org", + "note": "Previously https://pypi.python.org" + }, + "namespace_definition": { + "requirement": "prohibited", + "note": "there is no namespace" + }, + "name_definition": { + "native_name": "name", + "case_sensitive": false, + "normalization_rules": [ + "Replace underscore _ with dash -", + "Replace dot . with underscore _ when used in distribution (sdist, wheel) names" + ], + "note": "PyPI treats - and _ as the same character and is not case sensitive. Therefore a PyPI package name must be lowercased and underscore _ replaced with a dash -. Note that PyPI itself is preserving the case of package names. When used in distribution and wheel names, the dot . is replaced with an underscore _" + }, + "version_definition": { + "case_sensitive": false, + "native_name": "version" + }, + "qualifiers_definition": [ + { + "key": "file_name", + "requirement": "optional", + "description": "The file_name qualifier selects a particular distribution file (case-sensitive). For naming convention, see the Python Packaging User Guide on source distributions https://packaging.python.org/en/latest/specifications/source-distribution-format/#source-distribution-file-name and on binary distributions https://packaging.python.org/en/latest/specifications/binary-distribution-format/#file-name-convention and the rules for platform compatibility tags https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/" + } + ], + "examples": [ + "pkg:pypi/django@1.11.1", + "pkg:pypi/django@1.11.1?filename=Django-1.11.1.tar.gz", + "pkg:pypi/django@1.11.1?filename=Django-1.11.1-py2.py3-none-any.whl", + "pkg:pypi/django-allauth@12.23" + ] +} +""" +from packageurl import PackageURL +from pathlib import Path +import json + +HEADER = '''# 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 import normalize +from packageurl.contrib.route import Router + +""" +Validate each type according to the PURL spec type definitions +""" + +class TypeValidator: + @classmethod + def validate(cls, purl: PackageURL, strict=False): + if not strict: + purl = cls.normalize(purl) + + if cls.namespace_requirement == "prohibited" and purl.namespace: + yield f"Namespace is prohibited for purl type: {cls.type!r}" + + if not cls.namespace_case_sensitive and purl.namespace and purl.namespace.lower() != purl.name: + yield f"Namespace is not lowercased for purl type: {cls.type!r}" + + if not cls.name_case_sensitive and purl.name and purl.name.lower() != purl.name: + yield f"Name is not lowercased for purl type: {cls.type!r}" + + if not cls.version_case_sensitive and purl.version and purl.version.lower() != purl.version: + yield f"Version is not lowercased for purl type: {cls.type!r}" + + yield from cls.validate_type(purl) + + @classmethod + def normalize_type(cls, type: str): + return type + + @classmethod + def normalize_namespace(cls, namespace: str): + return namespace + + @classmethod + def normalize_name(cls, name: str): + return name + + @classmethod + def normalize_version(cls, version: str): + return version + + @classmethod + def normalize_qualifiers(cls, qualifiers: dict): + return qualifiers + + @classmethod + def normalize_subpath(cls, subpath: str): + return subpath + + @classmethod + def normalize(cls, purl: PackageURL): + type_norm, namespace_norm, name_norm, version_norm, qualifiers_norm, subpath_norm = normalize(purl.type, + purl.namespace, + purl.name, + purl.version, + purl.qualifiers, + purl.subpath, + encode=False, + ) + + return PackageURL( + type = type_norm, + namespace = namespace_norm, + name = name_norm, + version = version_norm, + qualifiers = qualifiers_norm, + subpath = subpath_norm, + ) + + @classmethod + def validate_type(cls, purl: PackageURL): + yield from cls.validate_qualifiers(purl=purl) + + @classmethod + def validate_qualifiers(cls, purl: PackageURL): + if not purl.qualifiers: + return + + purl_qualifiers_keys = set(purl.qualifiers.keys()) + allowed_qualifiers_set = cls.allowed_qualifiers + + disallowed = purl_qualifiers_keys - allowed_qualifiers_set + + if disallowed: + yield (f"Invalid qualifiers found: {', '.join(disallowed)}. " + f"Allowed qualifiers are: {', '.join(allowed_qualifiers_set)}" + ) +''' + +def validate_qualifiers(allowed_qualifiers, purl: PackageURL): + if not purl.qualifiers: + return True + + purl_qualifiers_keys = set(purl.qualifiers.keys()) + allowed_qualifiers_set = set(allowed_qualifiers) + + disallowed = purl_qualifiers_keys - allowed_qualifiers_set + + if disallowed: + yield (f"Invalid qualifiers found: {', '.join(disallowed)}. " + f"Allowed qualifiers are: {', '.join(allowed_qualifiers_set)}" + ) + + + +TEMPLATE = """ +class {class_name}({validator_class}): + type = "{type}" + type_name = "{type_name}" + description = '''{description}''' + use_repository = {use_repository} + default_repository_url = "{default_repository_url}" + namespace_requirement = "{namespace_requirement}" + allowed_qualifiers = {allowed_qualifiers} + namespace_case_sensitive = {namespace_case_sensitive} + name_case_sensitive = {name_case_sensitive} + version_case_sensitive = {version_case_sensitive} + purl_pattern = "{purl_pattern}" +""" + +TEMPLATE_NAME_RULES = ''' + @override + @classmethod + def normalize_name(cls, name: str): + """ + Normalize name according to type rules + {rules} + """ + raise NotImplementedError() +''' + +def generate_validators(): + """ + Generate validators for all package types defined in the packageurl specification. + """ + + base_dir = Path(__file__).parent.parent.parent + + types_dir = base_dir / "spec" / "types" + + script_parts = [HEADER] + + validators_by_type = {} + + for type in sorted(types_dir.glob("*.json")): + type_def = json.loads(type.read_text()) + + _type = type_def["type"] + standard_validator_class = "TypeValidator" + + class_prefix = _type.capitalize() + class_name = f"{class_prefix}{standard_validator_class}" + validators_by_type[_type] = class_name + name_normalization_rules=type_def["name_definition"].get("normalization_rules") or [] + allowed_qualifiers = [defintion.get("key") for defintion in type_def.get("qualifiers_definition") or []] + namespace_case_sensitive = type_def["namespace_definition"].get("case_sensitive") or False + name_case_sensitive = type_def["name_definition"].get("case_sensitive") or False + version_definition = type_def.get("version_definition") or {} + version_case_sensitive = version_definition.get("case_sensitive") or False + repository = type_def.get("repository") + use_repository_url = repository.get("use_repository") or False + + if use_repository_url and "repsitory_url" not in allowed_qualifiers: + allowed_qualifiers.append("repository_url") + + allowed_qualifiers = set(allowed_qualifiers) + + type_validator = TEMPLATE.format(**dict( + class_name=class_name, + validator_class=standard_validator_class, + type=_type, + type_name=type_def["type_name"], + description=type_def["description"], + use_repository=type_def["repository"]["use_repository"], + default_repository_url=type_def["repository"].get("default_repository_url") or "", + namespace_requirement=type_def["namespace_definition"]["requirement"], + name_normalization_rules=name_normalization_rules, + allowed_qualifiers=allowed_qualifiers or [], + namespace_case_sensitive=namespace_case_sensitive, + name_case_sensitive=name_case_sensitive, + version_case_sensitive=version_case_sensitive, + purl_pattern=f"pkg:{_type}/.*" + )) + + script_parts.append(type_validator) + + # if name_normalization_rules: + # name_overrides = get_name_norm_rules(name_normalization_rules) + # script_parts.append(name_overrides) + + script_parts.append(generate_validators_by_type(validators_by_type=validators_by_type)) + script_parts.append(attach_router(validators_by_type.values())) + + validate_script = base_dir / "src" / "packageurl" / "validate.py" + + validate_script.write_text("\n".join(script_parts)) + + +def get_name_norm_rules(name_normalization_rules): + rules = "\n".join(name_normalization_rules) + return TEMPLATE_NAME_RULES.format(rules=rules) + + +def generate_validators_by_type(validators_by_type): + """ + Return a python snippet that maps a type to it's TypeValidator class + """ + snippets = [] + for type, class_name in validators_by_type.items(): + snippet = f" {type!r} : {class_name}," + snippets.append(snippet) + + snippets = "\n".join(snippets) + start = "VALIDATORS_BY_TYPE = {" + end = "}" + return f"{start}\n{snippets}\n{end}" + +def attach_router(classes): + snippets = [] + for class_name in classes: + snippet = f" {class_name}," + snippets.append(snippet) + snippets = "\n".join(snippets) + start = "PACKAGE_REGISTRY = [ \n" + end = "\n ]" + classes = f"{start}{snippets}{end}" + router_code = ''' +validate_router = Router() + +for pkg_class in PACKAGE_REGISTRY: + validate_router.append(pattern=pkg_class.purl_pattern, endpoint=pkg_class.validate) + ''' + return f"{classes}{router_code}" + + +if __name__ == "__main__": + generate_validators() \ No newline at end of file diff --git a/spec b/spec index 5748295..455f432 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 57482954eaa1529d681a04a3345ac9f8bef3db45 +Subproject commit 455f432be0be2747ae6afd411bc9171574049a3c diff --git a/src/packageurl/__init__.py b/src/packageurl/__init__.py index 945a390..520689a 100644 --- a/src/packageurl/__init__.py +++ b/src/packageurl/__init__.py @@ -37,6 +37,9 @@ from urllib.parse import unquote as _percent_unquote from urllib.parse import urlsplit as _urlsplit +from packageurl.contrib.route import NoRouteAvailable +from packageurl.validate import validate_router + if TYPE_CHECKING: from collections.abc import Callable from collections.abc import Iterable @@ -470,39 +473,16 @@ def to_string(self, encode: bool | None = True) -> str: return "".join(purl) - - @classmethod - def validate_alpm(cls): - """Type-specific validation for ALPM PURLs.""" - if cls.namespace.lower() != cls.namespace: - return False, "Namespace must be lowercase." - if cls.name.lower() != cls.name: - return False, "Package name must be lowercase." - if not re.match(r"^[0-9]*:?[\w\.\+\-]+$", cls.version): - return False, f"Invalid version format '{cls.version}'." - - if cls.qualifiers: - for key in cls.qualifiers: - if key != "arch": - return False, f"Unknown qualifier '{key}', only 'arch' is allowed." - if not cls.qualifiers[key]: - return False, "Qualifier 'arch' cannot be empty." - - - @classmethod - def validate(cls): + def validate(self) -> list[str]: """ - Main validation function. - Runs basic validation first, then dispatches to type-specific validators. - Yields error messages only. + Validate this PackageURL object and return a list of validation error messages. """ - yield from cls.validate_basic() - - validator_by_type: dict[str, Callable[[str], Iterable[str]]] = { - "alpm": cls.validate_alpm, - } - - yield from validator_by_type() + if self: + try: + messages = list(validate_router.process(self)) + return messages + except NoRouteAvailable: + return [f"Given type: {self.type} can not be validated"] @classmethod def from_string(cls, purl: str) -> Self: diff --git a/src/packageurl/validate.py b/src/packageurl/validate.py new file mode 100644 index 0000000..0894932 --- /dev/null +++ b/src/packageurl/validate.py @@ -0,0 +1,636 @@ +# 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 import normalize +from packageurl.contrib.route import Router + +""" +Validate each type according to the PURL spec type definitions +""" + +class TypeValidator: + @classmethod + def validate(cls, purl: PackageURL, strict=False): + if not strict: + purl = cls.normalize(purl) + + if cls.namespace_requirement == "prohibited" and purl.namespace: + yield f"Namespace is prohibited for purl type: {cls.type!r}" + + if not cls.namespace_case_sensitive and purl.namespace and purl.namespace.lower() != purl.name: + yield f"Namespace is not lowercased for purl type: {cls.type!r}" + + if not cls.name_case_sensitive and purl.name and purl.name.lower() != purl.name: + yield f"Name is not lowercased for purl type: {cls.type!r}" + + if not cls.version_case_sensitive and purl.version and purl.version.lower() != purl.version: + yield f"Version is not lowercased for purl type: {cls.type!r}" + + yield from cls.validate_type(purl) + + @classmethod + def normalize_type(cls, type: str): + return type + + @classmethod + def normalize_namespace(cls, namespace: str): + return namespace + + @classmethod + def normalize_name(cls, name: str): + return name + + @classmethod + def normalize_version(cls, version: str): + return version + + @classmethod + def normalize_qualifiers(cls, qualifiers: dict): + return qualifiers + + @classmethod + def normalize_subpath(cls, subpath: str): + return subpath + + @classmethod + def normalize(cls, purl: PackageURL): + type_norm, namespace_norm, name_norm, version_norm, qualifiers_norm, subpath_norm = normalize(purl.type, + purl.namespace, + purl.name, + purl.version, + purl.qualifiers, + purl.subpath, + encode=False, + ) + + return PackageURL( + type = type_norm, + namespace = namespace_norm, + name = name_norm, + version = version_norm, + qualifiers = qualifiers_norm, + subpath = subpath_norm, + ) + + @classmethod + def validate_type(cls, purl: PackageURL): + yield from cls.validate_qualifiers(purl=purl) + + @classmethod + def validate_qualifiers(cls, purl: PackageURL): + if not purl.qualifiers: + return + + purl_qualifiers_keys = set(purl.qualifiers.keys()) + allowed_qualifiers_set = cls.allowed_qualifiers + + disallowed = purl_qualifiers_keys - allowed_qualifiers_set + + if disallowed: + yield (f"Invalid qualifiers found: {', '.join(disallowed)}. " + f"Allowed qualifiers are: {', '.join(allowed_qualifiers_set)}" + ) + + +class AlpmTypeValidator(TypeValidator): + type = "alpm" + type_name = "Arch Linux package" + description = '''Arch Linux packages and other users of the libalpm/pacman package manager.''' + use_repository = True + default_repository_url = "" + namespace_requirement = "required" + allowed_qualifiers = {'arch', 'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = True + purl_pattern = "pkg:alpm/.*" + + +class ApkTypeValidator(TypeValidator): + type = "apk" + type_name = "APK-based packages" + description = '''Alpine Linux APK-based packages''' + use_repository = True + default_repository_url = "" + namespace_requirement = "required" + allowed_qualifiers = {'arch', 'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:apk/.*" + + +class BitbucketTypeValidator(TypeValidator): + type = "bitbucket" + type_name = "Bitbucket" + description = '''Bitbucket-based packages''' + use_repository = True + default_repository_url = "https://bitbucket.org" + namespace_requirement = "required" + allowed_qualifiers = {'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:bitbucket/.*" + + +class BitnamiTypeValidator(TypeValidator): + type = "bitnami" + type_name = "Bitnami" + description = '''Bitnami-based packages''' + use_repository = True + default_repository_url = "https://downloads.bitnami.com/files/stacksmith" + namespace_requirement = "prohibited" + allowed_qualifiers = {'distro', 'arch', 'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:bitnami/.*" + + +class CargoTypeValidator(TypeValidator): + type = "cargo" + type_name = "Cargo" + description = '''Cargo packages for Rust''' + use_repository = True + default_repository_url = "https://crates.io/" + namespace_requirement = "prohibited" + allowed_qualifiers = {'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:cargo/.*" + + +class CocoapodsTypeValidator(TypeValidator): + type = "cocoapods" + type_name = "CocoaPods" + description = '''CocoaPods pods''' + use_repository = True + default_repository_url = "https://cdn.cocoapods.org/" + namespace_requirement = "prohibited" + allowed_qualifiers = {'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = True + version_case_sensitive = False + purl_pattern = "pkg:cocoapods/.*" + + +class ComposerTypeValidator(TypeValidator): + type = "composer" + type_name = "Composer" + description = '''Composer PHP packages''' + use_repository = True + default_repository_url = "https://packagist.org" + namespace_requirement = "required" + allowed_qualifiers = {'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:composer/.*" + + +class ConanTypeValidator(TypeValidator): + type = "conan" + type_name = "Conan C/C++ packages" + description = '''Conan C/C++ packages. The purl is designed to closely resemble the Conan-native /@/ syntax for package references as specified in https://docs.conan.io/en/1.46/cheatsheet.html#package-terminology''' + use_repository = True + default_repository_url = "https://center.conan.io" + namespace_requirement = "optional" + allowed_qualifiers = {'channel', 'rrev', 'prev', 'user', 'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:conan/.*" + + +class CondaTypeValidator(TypeValidator): + type = "conda" + type_name = "Conda" + description = '''conda is for Conda packages''' + use_repository = True + default_repository_url = "https://repo.anaconda.com" + namespace_requirement = "prohibited" + allowed_qualifiers = {'channel', 'build', 'subdir', 'type', 'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:conda/.*" + + +class CpanTypeValidator(TypeValidator): + type = "cpan" + type_name = "CPAN" + description = '''CPAN Perl packages''' + use_repository = True + default_repository_url = "https://www.cpan.org/" + namespace_requirement = "optional" + allowed_qualifiers = {'vcs_url', 'ext', 'download_url', 'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = True + version_case_sensitive = False + purl_pattern = "pkg:cpan/.*" + + +class CranTypeValidator(TypeValidator): + type = "cran" + type_name = "CRAN" + description = '''CRAN R packages''' + use_repository = True + default_repository_url = "https://cran.r-project.org" + namespace_requirement = "prohibited" + allowed_qualifiers = {'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:cran/.*" + + +class DebTypeValidator(TypeValidator): + type = "deb" + type_name = "Debian package" + description = '''Debian packages, Debian derivatives, and Ubuntu packages''' + use_repository = True + default_repository_url = "" + namespace_requirement = "required" + allowed_qualifiers = {'arch', 'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:deb/.*" + + +class DockerTypeValidator(TypeValidator): + type = "docker" + type_name = "Docker image" + description = '''for Docker images''' + use_repository = True + default_repository_url = "https://hub.docker.com" + namespace_requirement = "optional" + allowed_qualifiers = {'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:docker/.*" + + +class GemTypeValidator(TypeValidator): + type = "gem" + type_name = "RubyGems" + description = '''RubyGems''' + use_repository = True + default_repository_url = "https://rubygems.org" + namespace_requirement = "prohibited" + allowed_qualifiers = {'platform', 'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:gem/.*" + + +class GenericTypeValidator(TypeValidator): + type = "generic" + type_name = "Generic Package" + description = '''The generic type is for plain, generic packages that do not fit anywhere else such as for "upstream-from-distro" packages. In particular this is handy for a plain version control repository such as a bare git repo in combination with a vcs_url.''' + use_repository = False + default_repository_url = "" + namespace_requirement = "optional" + allowed_qualifiers = {'download_url', 'checksum'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:generic/.*" + + +class GithubTypeValidator(TypeValidator): + type = "github" + type_name = "GitHub" + description = '''GitHub-based packages''' + use_repository = True + default_repository_url = "https://github.com" + namespace_requirement = "required" + allowed_qualifiers = {'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:github/.*" + + +class GolangTypeValidator(TypeValidator): + type = "golang" + type_name = "Go package" + description = '''Go packages''' + use_repository = True + default_repository_url = "" + namespace_requirement = "required" + allowed_qualifiers = {'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:golang/.*" + + +class HackageTypeValidator(TypeValidator): + type = "hackage" + type_name = "Haskell package" + description = '''Haskell packages''' + use_repository = True + default_repository_url = "https://hackage.haskell.org" + namespace_requirement = "prohibited" + allowed_qualifiers = {'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = True + version_case_sensitive = False + purl_pattern = "pkg:hackage/.*" + + +class HexTypeValidator(TypeValidator): + type = "hex" + type_name = "Hex" + description = '''Hex packages''' + use_repository = True + default_repository_url = "https://repo.hex.pm" + namespace_requirement = "optional" + allowed_qualifiers = {'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:hex/.*" + + +class HuggingfaceTypeValidator(TypeValidator): + type = "huggingface" + type_name = "HuggingFace models" + description = '''Hugging Face ML models''' + use_repository = True + default_repository_url = "" + namespace_requirement = "required" + allowed_qualifiers = {'repository_url'} + namespace_case_sensitive = True + name_case_sensitive = True + version_case_sensitive = False + purl_pattern = "pkg:huggingface/.*" + + +class LuarocksTypeValidator(TypeValidator): + type = "luarocks" + type_name = "LuaRocks" + description = '''Lua packages installed with LuaRocks''' + use_repository = True + default_repository_url = "" + namespace_requirement = "optional" + allowed_qualifiers = {'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = True + purl_pattern = "pkg:luarocks/.*" + + +class MavenTypeValidator(TypeValidator): + type = "maven" + type_name = "Maven" + description = '''PURL type for Maven JARs and related artifacts.''' + use_repository = True + default_repository_url = "https://repo.maven.apache.org/maven2/" + namespace_requirement = "required" + allowed_qualifiers = {'classifier', 'repository_url', 'type'} + namespace_case_sensitive = True + name_case_sensitive = True + version_case_sensitive = True + purl_pattern = "pkg:maven/.*" + + +class MlflowTypeValidator(TypeValidator): + type = "mlflow" + type_name = "" + description = '''MLflow ML models (Azure ML, Databricks, etc.)''' + use_repository = True + default_repository_url = "" + namespace_requirement = "prohibited" + allowed_qualifiers = {'model_uuid', 'run_id', 'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:mlflow/.*" + + +class NpmTypeValidator(TypeValidator): + type = "npm" + type_name = "Node NPM packages" + description = '''PURL type for npm packages.''' + use_repository = True + default_repository_url = "https://registry.npmjs.org/" + namespace_requirement = "optional" + allowed_qualifiers = {'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = True + purl_pattern = "pkg:npm/.*" + + +class NugetTypeValidator(TypeValidator): + type = "nuget" + type_name = "NuGet" + description = '''NuGet .NET packages''' + use_repository = True + default_repository_url = "https://www.nuget.org" + namespace_requirement = "prohibited" + allowed_qualifiers = {'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = True + version_case_sensitive = False + purl_pattern = "pkg:nuget/.*" + + +class OciTypeValidator(TypeValidator): + type = "oci" + type_name = "OCI image" + description = '''For artifacts stored in registries that conform to the OCI Distribution Specification https://github.com/opencontainers/distribution-spec including container images built by Docker and others''' + use_repository = True + default_repository_url = "" + namespace_requirement = "prohibited" + allowed_qualifiers = {'arch', 'tag', 'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:oci/.*" + + +class PubTypeValidator(TypeValidator): + type = "pub" + type_name = "Pub" + description = '''Dart and Flutter pub packages''' + use_repository = True + default_repository_url = "https://pub.dartlang.org" + namespace_requirement = "prohibited" + allowed_qualifiers = {'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:pub/.*" + + +class PypiTypeValidator(TypeValidator): + type = "pypi" + type_name = "PyPI" + description = '''Python packages''' + use_repository = True + default_repository_url = "https://pypi.org" + namespace_requirement = "prohibited" + allowed_qualifiers = {'file_name', 'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:pypi/.*" + + +class QpkgTypeValidator(TypeValidator): + type = "qpkg" + type_name = "QNX package" + description = '''QNX packages''' + use_repository = True + default_repository_url = "" + namespace_requirement = "required" + allowed_qualifiers = {'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = False + version_case_sensitive = False + purl_pattern = "pkg:qpkg/.*" + + +class RpmTypeValidator(TypeValidator): + type = "rpm" + type_name = "RPM" + description = '''RPM packages''' + use_repository = True + default_repository_url = "" + namespace_requirement = "required" + allowed_qualifiers = {'epoch', 'arch', 'repository_url'} + namespace_case_sensitive = False + name_case_sensitive = True + version_case_sensitive = False + purl_pattern = "pkg:rpm/.*" + + +class SwidTypeValidator(TypeValidator): + type = "swid" + type_name = "Software Identification (SWID) Tag" + description = '''PURL type for ISO-IEC 19770-2 Software Identification (SWID) tags.''' + use_repository = False + default_repository_url = "" + namespace_requirement = "optional" + allowed_qualifiers = {'tag_version', 'tag_creator_name', 'tag_id', 'patch', 'tag_creator_regid'} + namespace_case_sensitive = True + name_case_sensitive = True + version_case_sensitive = True + purl_pattern = "pkg:swid/.*" + + +class SwiftTypeValidator(TypeValidator): + type = "swift" + type_name = "Swift packages" + description = '''Swift packages''' + use_repository = True + default_repository_url = "" + namespace_requirement = "required" + allowed_qualifiers = {'repository_url'} + namespace_case_sensitive = True + name_case_sensitive = True + version_case_sensitive = True + purl_pattern = "pkg:swift/.*" + +VALIDATORS_BY_TYPE = { + 'alpm' : AlpmTypeValidator, + 'apk' : ApkTypeValidator, + 'bitbucket' : BitbucketTypeValidator, + 'bitnami' : BitnamiTypeValidator, + 'cargo' : CargoTypeValidator, + 'cocoapods' : CocoapodsTypeValidator, + 'composer' : ComposerTypeValidator, + 'conan' : ConanTypeValidator, + 'conda' : CondaTypeValidator, + 'cpan' : CpanTypeValidator, + 'cran' : CranTypeValidator, + 'deb' : DebTypeValidator, + 'docker' : DockerTypeValidator, + 'gem' : GemTypeValidator, + 'generic' : GenericTypeValidator, + 'github' : GithubTypeValidator, + 'golang' : GolangTypeValidator, + 'hackage' : HackageTypeValidator, + 'hex' : HexTypeValidator, + 'huggingface' : HuggingfaceTypeValidator, + 'luarocks' : LuarocksTypeValidator, + 'maven' : MavenTypeValidator, + 'mlflow' : MlflowTypeValidator, + 'npm' : NpmTypeValidator, + 'nuget' : NugetTypeValidator, + 'oci' : OciTypeValidator, + 'pub' : PubTypeValidator, + 'pypi' : PypiTypeValidator, + 'qpkg' : QpkgTypeValidator, + 'rpm' : RpmTypeValidator, + 'swid' : SwidTypeValidator, + 'swift' : SwiftTypeValidator, +} +PACKAGE_REGISTRY = [ + AlpmTypeValidator, + ApkTypeValidator, + BitbucketTypeValidator, + BitnamiTypeValidator, + CargoTypeValidator, + CocoapodsTypeValidator, + ComposerTypeValidator, + ConanTypeValidator, + CondaTypeValidator, + CpanTypeValidator, + CranTypeValidator, + DebTypeValidator, + DockerTypeValidator, + GemTypeValidator, + GenericTypeValidator, + GithubTypeValidator, + GolangTypeValidator, + HackageTypeValidator, + HexTypeValidator, + HuggingfaceTypeValidator, + LuarocksTypeValidator, + MavenTypeValidator, + MlflowTypeValidator, + NpmTypeValidator, + NugetTypeValidator, + OciTypeValidator, + PubTypeValidator, + PypiTypeValidator, + QpkgTypeValidator, + RpmTypeValidator, + SwidTypeValidator, + SwiftTypeValidator, + ] +validate_router = Router() + +for pkg_class in PACKAGE_REGISTRY: + validate_router.append(pattern=pkg_class.purl_pattern, endpoint=pkg_class.validate) + \ No newline at end of file From 785bc22a275727d51937d4cf2e279e43776bd84a Mon Sep 17 00:00:00 2001 From: Tushar Goel Date: Thu, 28 Aug 2025 17:57:58 +0530 Subject: [PATCH 3/8] Change validation structure Signed-off-by: Tushar Goel --- etc/scripts/generate_validators.py | 131 ++++-------- spec | 2 +- src/packageurl/__init__.py | 10 +- src/packageurl/validate.py | 328 ++++++++++++----------------- tests/test_purl_spec.py | 20 ++ 5 files changed, 211 insertions(+), 280 deletions(-) diff --git a/etc/scripts/generate_validators.py b/etc/scripts/generate_validators.py index 3ededc2..63e48bb 100644 --- a/etc/scripts/generate_validators.py +++ b/etc/scripts/generate_validators.py @@ -72,8 +72,6 @@ # Visit https://github.com/package-url/packageurl-python for support and # download. -from packageurl import PackageURL -from packageurl import normalize from packageurl.contrib.route import Router """ @@ -82,103 +80,79 @@ class TypeValidator: @classmethod - def validate(cls, purl: PackageURL, strict=False): + def validate(cls, purl, strict=False): if not strict: purl = cls.normalize(purl) if cls.namespace_requirement == "prohibited" and purl.namespace: yield f"Namespace is prohibited for purl type: {cls.type!r}" - - if not cls.namespace_case_sensitive and purl.namespace and purl.namespace.lower() != purl.name: + + elif cls.namespace_requirement == "required" and not purl.namespace: + yield f"Namespace is required for purl type: {cls.type!r}" + + if ( + cls.namespace_case_sensitive + and purl.namespace + and purl.namespace.lower() != purl.namespace + ): yield f"Namespace is not lowercased for purl type: {cls.type!r}" - - if not cls.name_case_sensitive and purl.name and purl.name.lower() != purl.name: + + if cls.name_case_sensitive and purl.name and purl.name.lower() != purl.name: yield f"Name is not lowercased for purl type: {cls.type!r}" if not cls.version_case_sensitive and purl.version and purl.version.lower() != purl.version: yield f"Version is not lowercased for purl type: {cls.type!r}" - - yield from cls.validate_type(purl) - - @classmethod - def normalize_type(cls, type: str): - return type - - @classmethod - def normalize_namespace(cls, namespace: str): - return namespace - - @classmethod - def normalize_name(cls, name: str): - return name - @classmethod - def normalize_version(cls, version: str): - return version - - @classmethod - def normalize_qualifiers(cls, qualifiers: dict): - return qualifiers - - @classmethod - def normalize_subpath(cls, subpath: str): - return subpath + yield from cls.validate_type(purl, strict=strict) @classmethod - def normalize(cls, purl: PackageURL): - type_norm, namespace_norm, name_norm, version_norm, qualifiers_norm, subpath_norm = normalize(purl.type, - purl.namespace, - purl.name, - purl.version, - purl.qualifiers, - purl.subpath, - encode=False, + def normalize(cls, purl): + from packageurl import PackageURL + from packageurl import normalize + + type_norm, namespace_norm, name_norm, version_norm, qualifiers_norm, subpath_norm = ( + normalize( + purl.type, + purl.namespace, + purl.name, + purl.version, + purl.qualifiers, + purl.subpath, + encode=False, + ) ) return PackageURL( - type = type_norm, - namespace = namespace_norm, - name = name_norm, - version = version_norm, - qualifiers = qualifiers_norm, - subpath = subpath_norm, + type=type_norm, + namespace=namespace_norm, + name=name_norm, + version=version_norm, + qualifiers=qualifiers_norm, + subpath=subpath_norm, ) @classmethod - def validate_type(cls, purl: PackageURL): - yield from cls.validate_qualifiers(purl=purl) - + def validate_type(cls, purl, strict=False): + if strict: + yield from cls.validate_qualifiers(purl=purl) + @classmethod - def validate_qualifiers(cls, purl: PackageURL): + def validate_qualifiers(cls, purl): if not purl.qualifiers: return - + purl_qualifiers_keys = set(purl.qualifiers.keys()) allowed_qualifiers_set = cls.allowed_qualifiers disallowed = purl_qualifiers_keys - allowed_qualifiers_set - + if disallowed: - yield (f"Invalid qualifiers found: {', '.join(disallowed)}. " + yield ( + f"Invalid qualifiers found: {', '.join(disallowed)}. " f"Allowed qualifiers are: {', '.join(allowed_qualifiers_set)}" ) ''' -def validate_qualifiers(allowed_qualifiers, purl: PackageURL): - if not purl.qualifiers: - return True - - purl_qualifiers_keys = set(purl.qualifiers.keys()) - allowed_qualifiers_set = set(allowed_qualifiers) - - disallowed = purl_qualifiers_keys - allowed_qualifiers_set - - if disallowed: - yield (f"Invalid qualifiers found: {', '.join(disallowed)}. " - f"Allowed qualifiers are: {', '.join(allowed_qualifiers_set)}" - ) - - TEMPLATE = """ class {class_name}({validator_class}): @@ -195,16 +169,6 @@ class {class_name}({validator_class}): purl_pattern = "{purl_pattern}" """ -TEMPLATE_NAME_RULES = ''' - @override - @classmethod - def normalize_name(cls, name: str): - """ - Normalize name according to type rules - {rules} - """ - raise NotImplementedError() -''' def generate_validators(): """ @@ -260,24 +224,15 @@ def generate_validators(): )) script_parts.append(type_validator) - - # if name_normalization_rules: - # name_overrides = get_name_norm_rules(name_normalization_rules) - # script_parts.append(name_overrides) script_parts.append(generate_validators_by_type(validators_by_type=validators_by_type)) - script_parts.append(attach_router(validators_by_type.values())) + # script_parts.append(attach_router(validators_by_type.values())) validate_script = base_dir / "src" / "packageurl" / "validate.py" validate_script.write_text("\n".join(script_parts)) -def get_name_norm_rules(name_normalization_rules): - rules = "\n".join(name_normalization_rules) - return TEMPLATE_NAME_RULES.format(rules=rules) - - def generate_validators_by_type(validators_by_type): """ Return a python snippet that maps a type to it's TypeValidator class diff --git a/spec b/spec index 455f432..4f6afc2 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 455f432be0be2747ae6afd411bc9171574049a3c +Subproject commit 4f6afc2539ff0c5d3265e61e2df86c8c16be1897 diff --git a/src/packageurl/__init__.py b/src/packageurl/__init__.py index 520689a..e7dc5c7 100644 --- a/src/packageurl/__init__.py +++ b/src/packageurl/__init__.py @@ -38,7 +38,6 @@ from urllib.parse import urlsplit as _urlsplit from packageurl.contrib.route import NoRouteAvailable -from packageurl.validate import validate_router if TYPE_CHECKING: from collections.abc import Callable @@ -473,13 +472,18 @@ def to_string(self, encode: bool | None = True) -> str: return "".join(purl) - def validate(self) -> list[str]: + def validate(self, strict: bool = False) -> list[str]: """ Validate this PackageURL object and return a list of validation error messages. """ + from packageurl.validate import VALIDATORS_BY_TYPE + if self: try: - messages = list(validate_router.process(self)) + validator_class = VALIDATORS_BY_TYPE.get(self.type) + if not validator_class: + return [f"Given type: {self.type} can not be validated"] + messages = list(validator_class.validate(self, strict)) # type: ignore[no-untyped-call] return messages except NoRouteAvailable: return [f"Given type: {self.type} can not be validated"] diff --git a/src/packageurl/validate.py b/src/packageurl/validate.py index 0894932..651bb2c 100644 --- a/src/packageurl/validate.py +++ b/src/packageurl/validate.py @@ -22,94 +22,84 @@ # Visit https://github.com/package-url/packageurl-python for support and # download. -from packageurl import PackageURL -from packageurl import normalize from packageurl.contrib.route import Router """ Validate each type according to the PURL spec type definitions """ + class TypeValidator: @classmethod - def validate(cls, purl: PackageURL, strict=False): + def validate(cls, purl, strict=False): if not strict: purl = cls.normalize(purl) if cls.namespace_requirement == "prohibited" and purl.namespace: yield f"Namespace is prohibited for purl type: {cls.type!r}" - - if not cls.namespace_case_sensitive and purl.namespace and purl.namespace.lower() != purl.name: + + elif cls.namespace_requirement == "required" and not purl.namespace: + yield f"Namespace is required for purl type: {cls.type!r}" + + if ( + cls.namespace_case_sensitive + and purl.namespace + and purl.namespace.lower() != purl.namespace + ): yield f"Namespace is not lowercased for purl type: {cls.type!r}" - - if not cls.name_case_sensitive and purl.name and purl.name.lower() != purl.name: + + if cls.name_case_sensitive and purl.name and purl.name.lower() != purl.name: yield f"Name is not lowercased for purl type: {cls.type!r}" if not cls.version_case_sensitive and purl.version and purl.version.lower() != purl.version: yield f"Version is not lowercased for purl type: {cls.type!r}" - - yield from cls.validate_type(purl) - - @classmethod - def normalize_type(cls, type: str): - return type - - @classmethod - def normalize_namespace(cls, namespace: str): - return namespace - - @classmethod - def normalize_name(cls, name: str): - return name - @classmethod - def normalize_version(cls, version: str): - return version - - @classmethod - def normalize_qualifiers(cls, qualifiers: dict): - return qualifiers - - @classmethod - def normalize_subpath(cls, subpath: str): - return subpath + yield from cls.validate_type(purl, strict=strict) @classmethod - def normalize(cls, purl: PackageURL): - type_norm, namespace_norm, name_norm, version_norm, qualifiers_norm, subpath_norm = normalize(purl.type, - purl.namespace, - purl.name, - purl.version, - purl.qualifiers, - purl.subpath, - encode=False, + def normalize(cls, purl): + from packageurl import PackageURL + from packageurl import normalize + + type_norm, namespace_norm, name_norm, version_norm, qualifiers_norm, subpath_norm = ( + normalize( + purl.type, + purl.namespace, + purl.name, + purl.version, + purl.qualifiers, + purl.subpath, + encode=False, + ) ) return PackageURL( - type = type_norm, - namespace = namespace_norm, - name = name_norm, - version = version_norm, - qualifiers = qualifiers_norm, - subpath = subpath_norm, + type=type_norm, + namespace=namespace_norm, + name=name_norm, + version=version_norm, + qualifiers=qualifiers_norm, + subpath=subpath_norm, ) @classmethod - def validate_type(cls, purl: PackageURL): - yield from cls.validate_qualifiers(purl=purl) - + def validate_type(cls, purl, strict=False): + if strict: + yield from cls.validate_qualifiers(purl=purl) + @classmethod - def validate_qualifiers(cls, purl: PackageURL): + def validate_qualifiers(cls, purl): if not purl.qualifiers: return - + purl_qualifiers_keys = set(purl.qualifiers.keys()) allowed_qualifiers_set = cls.allowed_qualifiers disallowed = purl_qualifiers_keys - allowed_qualifiers_set - + if disallowed: - yield (f"Invalid qualifiers found: {', '.join(disallowed)}. " + yield ( + f"Invalid qualifiers found: {', '.join(disallowed)}. " f"Allowed qualifiers are: {', '.join(allowed_qualifiers_set)}" ) @@ -117,11 +107,11 @@ def validate_qualifiers(cls, purl: PackageURL): class AlpmTypeValidator(TypeValidator): type = "alpm" type_name = "Arch Linux package" - description = '''Arch Linux packages and other users of the libalpm/pacman package manager.''' + description = """Arch Linux packages and other users of the libalpm/pacman package manager.""" use_repository = True default_repository_url = "" namespace_requirement = "required" - allowed_qualifiers = {'arch', 'repository_url'} + allowed_qualifiers = {"arch", "repository_url"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = True @@ -131,11 +121,11 @@ class AlpmTypeValidator(TypeValidator): class ApkTypeValidator(TypeValidator): type = "apk" type_name = "APK-based packages" - description = '''Alpine Linux APK-based packages''' + description = """Alpine Linux APK-based packages""" use_repository = True default_repository_url = "" namespace_requirement = "required" - allowed_qualifiers = {'arch', 'repository_url'} + allowed_qualifiers = {"arch", "repository_url"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -145,11 +135,11 @@ class ApkTypeValidator(TypeValidator): class BitbucketTypeValidator(TypeValidator): type = "bitbucket" type_name = "Bitbucket" - description = '''Bitbucket-based packages''' + description = """Bitbucket-based packages""" use_repository = True default_repository_url = "https://bitbucket.org" namespace_requirement = "required" - allowed_qualifiers = {'repository_url'} + allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -159,11 +149,11 @@ class BitbucketTypeValidator(TypeValidator): class BitnamiTypeValidator(TypeValidator): type = "bitnami" type_name = "Bitnami" - description = '''Bitnami-based packages''' + description = """Bitnami-based packages""" use_repository = True default_repository_url = "https://downloads.bitnami.com/files/stacksmith" namespace_requirement = "prohibited" - allowed_qualifiers = {'distro', 'arch', 'repository_url'} + allowed_qualifiers = {"arch", "repository_url", "distro"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -173,11 +163,11 @@ class BitnamiTypeValidator(TypeValidator): class CargoTypeValidator(TypeValidator): type = "cargo" type_name = "Cargo" - description = '''Cargo packages for Rust''' + description = """Cargo packages for Rust""" use_repository = True default_repository_url = "https://crates.io/" namespace_requirement = "prohibited" - allowed_qualifiers = {'repository_url'} + allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -187,11 +177,11 @@ class CargoTypeValidator(TypeValidator): class CocoapodsTypeValidator(TypeValidator): type = "cocoapods" type_name = "CocoaPods" - description = '''CocoaPods pods''' + description = """CocoaPods pods""" use_repository = True default_repository_url = "https://cdn.cocoapods.org/" namespace_requirement = "prohibited" - allowed_qualifiers = {'repository_url'} + allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = True version_case_sensitive = False @@ -201,11 +191,11 @@ class CocoapodsTypeValidator(TypeValidator): class ComposerTypeValidator(TypeValidator): type = "composer" type_name = "Composer" - description = '''Composer PHP packages''' + description = """Composer PHP packages""" use_repository = True default_repository_url = "https://packagist.org" namespace_requirement = "required" - allowed_qualifiers = {'repository_url'} + allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -215,11 +205,11 @@ class ComposerTypeValidator(TypeValidator): class ConanTypeValidator(TypeValidator): type = "conan" type_name = "Conan C/C++ packages" - description = '''Conan C/C++ packages. The purl is designed to closely resemble the Conan-native /@/ syntax for package references as specified in https://docs.conan.io/en/1.46/cheatsheet.html#package-terminology''' + description = """Conan C/C++ packages. The purl is designed to closely resemble the Conan-native /@/ syntax for package references as specified in https://docs.conan.io/en/1.46/cheatsheet.html#package-terminology""" use_repository = True default_repository_url = "https://center.conan.io" namespace_requirement = "optional" - allowed_qualifiers = {'channel', 'rrev', 'prev', 'user', 'repository_url'} + allowed_qualifiers = {"rrev", "channel", "prev", "user", "repository_url"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -229,11 +219,11 @@ class ConanTypeValidator(TypeValidator): class CondaTypeValidator(TypeValidator): type = "conda" type_name = "Conda" - description = '''conda is for Conda packages''' + description = """conda is for Conda packages""" use_repository = True default_repository_url = "https://repo.anaconda.com" namespace_requirement = "prohibited" - allowed_qualifiers = {'channel', 'build', 'subdir', 'type', 'repository_url'} + allowed_qualifiers = {"type", "build", "subdir", "channel", "repository_url"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -243,11 +233,11 @@ class CondaTypeValidator(TypeValidator): class CpanTypeValidator(TypeValidator): type = "cpan" type_name = "CPAN" - description = '''CPAN Perl packages''' + description = """CPAN Perl packages""" use_repository = True default_repository_url = "https://www.cpan.org/" namespace_requirement = "optional" - allowed_qualifiers = {'vcs_url', 'ext', 'download_url', 'repository_url'} + allowed_qualifiers = {"vcs_url", "ext", "repository_url", "download_url"} namespace_case_sensitive = False name_case_sensitive = True version_case_sensitive = False @@ -257,11 +247,11 @@ class CpanTypeValidator(TypeValidator): class CranTypeValidator(TypeValidator): type = "cran" type_name = "CRAN" - description = '''CRAN R packages''' + description = """CRAN R packages""" use_repository = True default_repository_url = "https://cran.r-project.org" namespace_requirement = "prohibited" - allowed_qualifiers = {'repository_url'} + allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -271,11 +261,11 @@ class CranTypeValidator(TypeValidator): class DebTypeValidator(TypeValidator): type = "deb" type_name = "Debian package" - description = '''Debian packages, Debian derivatives, and Ubuntu packages''' + description = """Debian packages, Debian derivatives, and Ubuntu packages""" use_repository = True default_repository_url = "" namespace_requirement = "required" - allowed_qualifiers = {'arch', 'repository_url'} + allowed_qualifiers = {"arch", "repository_url"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -285,11 +275,11 @@ class DebTypeValidator(TypeValidator): class DockerTypeValidator(TypeValidator): type = "docker" type_name = "Docker image" - description = '''for Docker images''' + description = """for Docker images""" use_repository = True default_repository_url = "https://hub.docker.com" namespace_requirement = "optional" - allowed_qualifiers = {'repository_url'} + allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -299,11 +289,11 @@ class DockerTypeValidator(TypeValidator): class GemTypeValidator(TypeValidator): type = "gem" type_name = "RubyGems" - description = '''RubyGems''' + description = """RubyGems""" use_repository = True default_repository_url = "https://rubygems.org" namespace_requirement = "prohibited" - allowed_qualifiers = {'platform', 'repository_url'} + allowed_qualifiers = {"repository_url", "platform"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -313,11 +303,11 @@ class GemTypeValidator(TypeValidator): class GenericTypeValidator(TypeValidator): type = "generic" type_name = "Generic Package" - description = '''The generic type is for plain, generic packages that do not fit anywhere else such as for "upstream-from-distro" packages. In particular this is handy for a plain version control repository such as a bare git repo in combination with a vcs_url.''' + description = """The generic type is for plain, generic packages that do not fit anywhere else such as for "upstream-from-distro" packages. In particular this is handy for a plain version control repository such as a bare git repo in combination with a vcs_url.""" use_repository = False default_repository_url = "" namespace_requirement = "optional" - allowed_qualifiers = {'download_url', 'checksum'} + allowed_qualifiers = {"checksum", "download_url"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -327,11 +317,11 @@ class GenericTypeValidator(TypeValidator): class GithubTypeValidator(TypeValidator): type = "github" type_name = "GitHub" - description = '''GitHub-based packages''' + description = """GitHub-based packages""" use_repository = True default_repository_url = "https://github.com" namespace_requirement = "required" - allowed_qualifiers = {'repository_url'} + allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -341,11 +331,11 @@ class GithubTypeValidator(TypeValidator): class GolangTypeValidator(TypeValidator): type = "golang" type_name = "Go package" - description = '''Go packages''' + description = """Go packages""" use_repository = True default_repository_url = "" namespace_requirement = "required" - allowed_qualifiers = {'repository_url'} + allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -355,11 +345,11 @@ class GolangTypeValidator(TypeValidator): class HackageTypeValidator(TypeValidator): type = "hackage" type_name = "Haskell package" - description = '''Haskell packages''' + description = """Haskell packages""" use_repository = True default_repository_url = "https://hackage.haskell.org" namespace_requirement = "prohibited" - allowed_qualifiers = {'repository_url'} + allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = True version_case_sensitive = False @@ -369,11 +359,11 @@ class HackageTypeValidator(TypeValidator): class HexTypeValidator(TypeValidator): type = "hex" type_name = "Hex" - description = '''Hex packages''' + description = """Hex packages""" use_repository = True default_repository_url = "https://repo.hex.pm" namespace_requirement = "optional" - allowed_qualifiers = {'repository_url'} + allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -383,11 +373,11 @@ class HexTypeValidator(TypeValidator): class HuggingfaceTypeValidator(TypeValidator): type = "huggingface" type_name = "HuggingFace models" - description = '''Hugging Face ML models''' + description = """Hugging Face ML models""" use_repository = True default_repository_url = "" namespace_requirement = "required" - allowed_qualifiers = {'repository_url'} + allowed_qualifiers = {"repository_url"} namespace_case_sensitive = True name_case_sensitive = True version_case_sensitive = False @@ -397,11 +387,11 @@ class HuggingfaceTypeValidator(TypeValidator): class LuarocksTypeValidator(TypeValidator): type = "luarocks" type_name = "LuaRocks" - description = '''Lua packages installed with LuaRocks''' + description = """Lua packages installed with LuaRocks""" use_repository = True default_repository_url = "" namespace_requirement = "optional" - allowed_qualifiers = {'repository_url'} + allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = True @@ -411,11 +401,11 @@ class LuarocksTypeValidator(TypeValidator): class MavenTypeValidator(TypeValidator): type = "maven" type_name = "Maven" - description = '''PURL type for Maven JARs and related artifacts.''' + description = """PURL type for Maven JARs and related artifacts.""" use_repository = True default_repository_url = "https://repo.maven.apache.org/maven2/" namespace_requirement = "required" - allowed_qualifiers = {'classifier', 'repository_url', 'type'} + allowed_qualifiers = {"type", "classifier", "repository_url"} namespace_case_sensitive = True name_case_sensitive = True version_case_sensitive = True @@ -425,11 +415,11 @@ class MavenTypeValidator(TypeValidator): class MlflowTypeValidator(TypeValidator): type = "mlflow" type_name = "" - description = '''MLflow ML models (Azure ML, Databricks, etc.)''' + description = """MLflow ML models (Azure ML, Databricks, etc.)""" use_repository = True default_repository_url = "" namespace_requirement = "prohibited" - allowed_qualifiers = {'model_uuid', 'run_id', 'repository_url'} + allowed_qualifiers = {"run_id", "repository_url", "model_uuid"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -439,11 +429,11 @@ class MlflowTypeValidator(TypeValidator): class NpmTypeValidator(TypeValidator): type = "npm" type_name = "Node NPM packages" - description = '''PURL type for npm packages.''' + description = """PURL type for npm packages.""" use_repository = True default_repository_url = "https://registry.npmjs.org/" namespace_requirement = "optional" - allowed_qualifiers = {'repository_url'} + allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = True @@ -453,11 +443,11 @@ class NpmTypeValidator(TypeValidator): class NugetTypeValidator(TypeValidator): type = "nuget" type_name = "NuGet" - description = '''NuGet .NET packages''' + description = """NuGet .NET packages""" use_repository = True default_repository_url = "https://www.nuget.org" namespace_requirement = "prohibited" - allowed_qualifiers = {'repository_url'} + allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = True version_case_sensitive = False @@ -467,11 +457,11 @@ class NugetTypeValidator(TypeValidator): class OciTypeValidator(TypeValidator): type = "oci" type_name = "OCI image" - description = '''For artifacts stored in registries that conform to the OCI Distribution Specification https://github.com/opencontainers/distribution-spec including container images built by Docker and others''' + description = """For artifacts stored in registries that conform to the OCI Distribution Specification https://github.com/opencontainers/distribution-spec including container images built by Docker and others""" use_repository = True default_repository_url = "" namespace_requirement = "prohibited" - allowed_qualifiers = {'arch', 'tag', 'repository_url'} + allowed_qualifiers = {"arch", "repository_url", "tag"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -481,11 +471,11 @@ class OciTypeValidator(TypeValidator): class PubTypeValidator(TypeValidator): type = "pub" type_name = "Pub" - description = '''Dart and Flutter pub packages''' + description = """Dart and Flutter pub packages""" use_repository = True default_repository_url = "https://pub.dartlang.org" namespace_requirement = "prohibited" - allowed_qualifiers = {'repository_url'} + allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -495,11 +485,11 @@ class PubTypeValidator(TypeValidator): class PypiTypeValidator(TypeValidator): type = "pypi" type_name = "PyPI" - description = '''Python packages''' + description = """Python packages""" use_repository = True default_repository_url = "https://pypi.org" namespace_requirement = "prohibited" - allowed_qualifiers = {'file_name', 'repository_url'} + allowed_qualifiers = {"repository_url", "file_name"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -509,11 +499,11 @@ class PypiTypeValidator(TypeValidator): class QpkgTypeValidator(TypeValidator): type = "qpkg" type_name = "QNX package" - description = '''QNX packages''' + description = """QNX packages""" use_repository = True default_repository_url = "" namespace_requirement = "required" - allowed_qualifiers = {'repository_url'} + allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = False @@ -523,11 +513,11 @@ class QpkgTypeValidator(TypeValidator): class RpmTypeValidator(TypeValidator): type = "rpm" type_name = "RPM" - description = '''RPM packages''' + description = """RPM packages""" use_repository = True default_repository_url = "" namespace_requirement = "required" - allowed_qualifiers = {'epoch', 'arch', 'repository_url'} + allowed_qualifiers = {"arch", "repository_url", "epoch"} namespace_case_sensitive = False name_case_sensitive = True version_case_sensitive = False @@ -537,11 +527,11 @@ class RpmTypeValidator(TypeValidator): class SwidTypeValidator(TypeValidator): type = "swid" type_name = "Software Identification (SWID) Tag" - description = '''PURL type for ISO-IEC 19770-2 Software Identification (SWID) tags.''' + description = """PURL type for ISO-IEC 19770-2 Software Identification (SWID) tags.""" use_repository = False default_repository_url = "" namespace_requirement = "optional" - allowed_qualifiers = {'tag_version', 'tag_creator_name', 'tag_id', 'patch', 'tag_creator_regid'} + allowed_qualifiers = {"patch", "tag_id", "tag_creator_regid", "tag_creator_name", "tag_version"} namespace_case_sensitive = True name_case_sensitive = True version_case_sensitive = True @@ -551,86 +541,48 @@ class SwidTypeValidator(TypeValidator): class SwiftTypeValidator(TypeValidator): type = "swift" type_name = "Swift packages" - description = '''Swift packages''' + description = """Swift packages""" use_repository = True default_repository_url = "" namespace_requirement = "required" - allowed_qualifiers = {'repository_url'} + allowed_qualifiers = {"repository_url"} namespace_case_sensitive = True name_case_sensitive = True version_case_sensitive = True purl_pattern = "pkg:swift/.*" + VALIDATORS_BY_TYPE = { - 'alpm' : AlpmTypeValidator, - 'apk' : ApkTypeValidator, - 'bitbucket' : BitbucketTypeValidator, - 'bitnami' : BitnamiTypeValidator, - 'cargo' : CargoTypeValidator, - 'cocoapods' : CocoapodsTypeValidator, - 'composer' : ComposerTypeValidator, - 'conan' : ConanTypeValidator, - 'conda' : CondaTypeValidator, - 'cpan' : CpanTypeValidator, - 'cran' : CranTypeValidator, - 'deb' : DebTypeValidator, - 'docker' : DockerTypeValidator, - 'gem' : GemTypeValidator, - 'generic' : GenericTypeValidator, - 'github' : GithubTypeValidator, - 'golang' : GolangTypeValidator, - 'hackage' : HackageTypeValidator, - 'hex' : HexTypeValidator, - 'huggingface' : HuggingfaceTypeValidator, - 'luarocks' : LuarocksTypeValidator, - 'maven' : MavenTypeValidator, - 'mlflow' : MlflowTypeValidator, - 'npm' : NpmTypeValidator, - 'nuget' : NugetTypeValidator, - 'oci' : OciTypeValidator, - 'pub' : PubTypeValidator, - 'pypi' : PypiTypeValidator, - 'qpkg' : QpkgTypeValidator, - 'rpm' : RpmTypeValidator, - 'swid' : SwidTypeValidator, - 'swift' : SwiftTypeValidator, + "alpm": AlpmTypeValidator, + "apk": ApkTypeValidator, + "bitbucket": BitbucketTypeValidator, + "bitnami": BitnamiTypeValidator, + "cargo": CargoTypeValidator, + "cocoapods": CocoapodsTypeValidator, + "composer": ComposerTypeValidator, + "conan": ConanTypeValidator, + "conda": CondaTypeValidator, + "cpan": CpanTypeValidator, + "cran": CranTypeValidator, + "deb": DebTypeValidator, + "docker": DockerTypeValidator, + "gem": GemTypeValidator, + "generic": GenericTypeValidator, + "github": GithubTypeValidator, + "golang": GolangTypeValidator, + "hackage": HackageTypeValidator, + "hex": HexTypeValidator, + "huggingface": HuggingfaceTypeValidator, + "luarocks": LuarocksTypeValidator, + "maven": MavenTypeValidator, + "mlflow": MlflowTypeValidator, + "npm": NpmTypeValidator, + "nuget": NugetTypeValidator, + "oci": OciTypeValidator, + "pub": PubTypeValidator, + "pypi": PypiTypeValidator, + "qpkg": QpkgTypeValidator, + "rpm": RpmTypeValidator, + "swid": SwidTypeValidator, + "swift": SwiftTypeValidator, } -PACKAGE_REGISTRY = [ - AlpmTypeValidator, - ApkTypeValidator, - BitbucketTypeValidator, - BitnamiTypeValidator, - CargoTypeValidator, - CocoapodsTypeValidator, - ComposerTypeValidator, - ConanTypeValidator, - CondaTypeValidator, - CpanTypeValidator, - CranTypeValidator, - DebTypeValidator, - DockerTypeValidator, - GemTypeValidator, - GenericTypeValidator, - GithubTypeValidator, - GolangTypeValidator, - HackageTypeValidator, - HexTypeValidator, - HuggingfaceTypeValidator, - LuarocksTypeValidator, - MavenTypeValidator, - MlflowTypeValidator, - NpmTypeValidator, - NugetTypeValidator, - OciTypeValidator, - PubTypeValidator, - PypiTypeValidator, - QpkgTypeValidator, - RpmTypeValidator, - SwidTypeValidator, - SwiftTypeValidator, - ] -validate_router = Router() - -for pkg_class in PACKAGE_REGISTRY: - validate_router.append(pattern=pkg_class.purl_pattern, endpoint=pkg_class.validate) - \ No newline at end of file diff --git a/tests/test_purl_spec.py b/tests/test_purl_spec.py index 1d02b1f..6569cd3 100644 --- a/tests/test_purl_spec.py +++ b/tests/test_purl_spec.py @@ -151,3 +151,23 @@ def run_test_case(case, test_type, desc): subpath=input_data.get("subpath"), ) assert purl.to_string() == case["expected_output"] + + elif test_type == "validation": + input_data = case["input"] + purl = PackageURL( + type=input_data["type"], + namespace=input_data["namespace"], + name=input_data["name"], + version=input_data["version"], + qualifiers=input_data.get("qualifiers"), + subpath=input_data.get("subpath"), + ) + test_group = case.get("test_group") + strict=True + if test_group == "advanced": + strict=False + messages = purl.validate(strict=strict) + if case.get("expected_messages"): + assert messages == case["expected_messages"] + else: + assert not messages From 4ed42569c0ea84171b64b3b660eb86a5d1f54905 Mon Sep 17 00:00:00 2001 From: Tushar Goel Date: Thu, 28 Aug 2025 17:59:17 +0530 Subject: [PATCH 4/8] Fix linting issues Signed-off-by: Tushar Goel --- tests/test_purl_spec.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_purl_spec.py b/tests/test_purl_spec.py index 6569cd3..78f02a3 100644 --- a/tests/test_purl_spec.py +++ b/tests/test_purl_spec.py @@ -151,7 +151,7 @@ def run_test_case(case, test_type, desc): subpath=input_data.get("subpath"), ) assert purl.to_string() == case["expected_output"] - + elif test_type == "validation": input_data = case["input"] purl = PackageURL( @@ -163,9 +163,9 @@ def run_test_case(case, test_type, desc): subpath=input_data.get("subpath"), ) test_group = case.get("test_group") - strict=True + strict = True if test_group == "advanced": - strict=False + strict = False messages = purl.validate(strict=strict) if case.get("expected_messages"): assert messages == case["expected_messages"] From 44af98410639161e5588b990c388558259869611 Mon Sep 17 00:00:00 2001 From: Tushar Goel Date: Fri, 29 Aug 2025 14:54:18 +0530 Subject: [PATCH 5/8] Add failing tests Signed-off-by: Tushar Goel --- etc/scripts/generate_validators.py | 10 ++-- spec | 2 +- src/packageurl/__init__.py | 72 ++++++++++++++++++------ src/packageurl/validate.py | 90 +++++++++++++++--------------- tests/test_purl_spec.py | 11 ++-- 5 files changed, 112 insertions(+), 73 deletions(-) diff --git a/etc/scripts/generate_validators.py b/etc/scripts/generate_validators.py index 63e48bb..18affb6 100644 --- a/etc/scripts/generate_validators.py +++ b/etc/scripts/generate_validators.py @@ -91,13 +91,13 @@ def validate(cls, purl, strict=False): yield f"Namespace is required for purl type: {cls.type!r}" if ( - cls.namespace_case_sensitive + not cls.namespace_case_sensitive and purl.namespace and purl.namespace.lower() != purl.namespace ): yield f"Namespace is not lowercased for purl type: {cls.type!r}" - if cls.name_case_sensitive and purl.name and purl.name.lower() != purl.name: + if not cls.name_case_sensitive and purl.name and purl.name.lower() != purl.name: yield f"Name is not lowercased for purl type: {cls.type!r}" if not cls.version_case_sensitive and purl.version and purl.version.lower() != purl.version: @@ -148,8 +148,8 @@ def validate_qualifiers(cls, purl): if disallowed: yield ( - f"Invalid qualifiers found: {', '.join(disallowed)}. " - f"Allowed qualifiers are: {', '.join(allowed_qualifiers_set)}" + f"Invalid qualifiers found: {', '.join(sorted(disallowed))}. " + f"Allowed qualifiers are: {', '.join(sorted(allowed_qualifiers_set))}" ) ''' @@ -197,7 +197,7 @@ def generate_validators(): namespace_case_sensitive = type_def["namespace_definition"].get("case_sensitive") or False name_case_sensitive = type_def["name_definition"].get("case_sensitive") or False version_definition = type_def.get("version_definition") or {} - version_case_sensitive = version_definition.get("case_sensitive") or False + version_case_sensitive = version_definition.get("case_sensitive") or True repository = type_def.get("repository") use_repository_url = repository.get("use_repository") or False diff --git a/spec b/spec index 4f6afc2..53f88e7 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 4f6afc2539ff0c5d3265e61e2df86c8c16be1897 +Subproject commit 53f88e7127561cead1fc9b456f27a7675cb4f383 diff --git a/src/packageurl/__init__.py b/src/packageurl/__init__.py index e7dc5c7..ee1bf2c 100644 --- a/src/packageurl/__init__.py +++ b/src/packageurl/__init__.py @@ -120,8 +120,21 @@ def normalize_namespace( namespace_str = namespace if isinstance(namespace, str) else namespace.decode("utf-8") namespace_str = namespace_str.strip().strip("/") - if ptype in ("bitbucket", "github", "pypi", "gitlab", "composer"): + if ptype in ( + "bitbucket", + "github", + "pypi", + "gitlab", + "composer", + "luarocks", + "qpkg", + "alpm", + "apk", + "hex", + ): namespace_str = namespace_str.lower() + if ptype in ("cpan"): + namespace_str = namespace_str.upper() segments = [seg for seg in namespace_str.split("/") if seg.strip()] segments_quoted = map(get_quoter(encode), segments) return "/".join(segments_quoted) or None @@ -162,10 +175,23 @@ def normalize_name( name_str = name_str.strip().strip("/") if ptype and ptype in ("mlflow"): return normalize_mlflow_name(name_str, qualifiers) - if ptype in ("bitbucket", "github", "pypi", "gitlab", "composer"): + if ptype in ( + "bitbucket", + "github", + "pypi", + "gitlab", + "composer", + "luarocks", + "oci", + "npm", + "alpm", + "apk", + "bitnami", + "hex", + ): name_str = name_str.lower() if ptype == "pypi": - name_str = name_str.replace("_", "-") + name_str = name_str.replace("_", "-").lower() return name_str or None @@ -178,7 +204,7 @@ def normalize_version( version_str = version if isinstance(version, str) else version.decode("utf-8") quoter = get_quoter(encode) version_str = quoter(version_str.strip()) - if ptype and isinstance(ptype, str) and ptype in ("huggingface"): + if ptype and isinstance(ptype, str) and ptype in ("huggingface", "oci"): return version_str.lower() return version_str or None @@ -369,6 +395,7 @@ def __new__( version: AnyStr | None = None, qualifiers: AnyStr | dict[str, str] | None = None, subpath: AnyStr | None = None, + normalize_purl=True, ) -> Self: required = dict(type=type, name=name) for key, value in required.items(): @@ -394,23 +421,32 @@ def __new__( f"Invalid purl: qualifiers argument must be a dict or a string: {qualifiers!r}." ) - ( - type_norm, - namespace_norm, - name_norm, - version_norm, - qualifiers_norm, - subpath_norm, - ) = normalize(type, namespace, name, version, qualifiers, subpath, encode=None) + if normalize_purl: + ( + type_final, + namespace_final, + name_final, + version_final, + qualifiers_final, + subpath_final, + ) = normalize(type, namespace, name, version, qualifiers, subpath, encode=None) + + else: + type_final = type + namespace_final = namespace + name_final = name + version_final = version + qualifiers_final = qualifiers + subpath_final = subpath return super().__new__( cls, - type=type_norm, - namespace=namespace_norm, - name=name_norm, - version=version_norm, - qualifiers=qualifiers_norm, - subpath=subpath_norm, + type=type_final, + namespace=namespace_final, + name=name_final, + version=version_final, + qualifiers=qualifiers_final, + subpath=subpath_final, ) def __str__(self, *args: Any, **kwargs: Any) -> str: diff --git a/src/packageurl/validate.py b/src/packageurl/validate.py index 651bb2c..1cd08e9 100644 --- a/src/packageurl/validate.py +++ b/src/packageurl/validate.py @@ -42,13 +42,13 @@ def validate(cls, purl, strict=False): yield f"Namespace is required for purl type: {cls.type!r}" if ( - cls.namespace_case_sensitive + not cls.namespace_case_sensitive and purl.namespace and purl.namespace.lower() != purl.namespace ): yield f"Namespace is not lowercased for purl type: {cls.type!r}" - if cls.name_case_sensitive and purl.name and purl.name.lower() != purl.name: + if not cls.name_case_sensitive and purl.name and purl.name.lower() != purl.name: yield f"Name is not lowercased for purl type: {cls.type!r}" if not cls.version_case_sensitive and purl.version and purl.version.lower() != purl.version: @@ -99,8 +99,8 @@ def validate_qualifiers(cls, purl): if disallowed: yield ( - f"Invalid qualifiers found: {', '.join(disallowed)}. " - f"Allowed qualifiers are: {', '.join(allowed_qualifiers_set)}" + f"Invalid qualifiers found: {', '.join(sorted(disallowed))}. " + f"Allowed qualifiers are: {', '.join(sorted(allowed_qualifiers_set))}" ) @@ -111,7 +111,7 @@ class AlpmTypeValidator(TypeValidator): use_repository = True default_repository_url = "" namespace_requirement = "required" - allowed_qualifiers = {"arch", "repository_url"} + allowed_qualifiers = {"repository_url", "arch"} namespace_case_sensitive = False name_case_sensitive = False version_case_sensitive = True @@ -125,10 +125,10 @@ class ApkTypeValidator(TypeValidator): use_repository = True default_repository_url = "" namespace_requirement = "required" - allowed_qualifiers = {"arch", "repository_url"} + allowed_qualifiers = {"repository_url", "arch"} namespace_case_sensitive = False name_case_sensitive = False - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:apk/.*" @@ -142,7 +142,7 @@ class BitbucketTypeValidator(TypeValidator): allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:bitbucket/.*" @@ -153,10 +153,10 @@ class BitnamiTypeValidator(TypeValidator): use_repository = True default_repository_url = "https://downloads.bitnami.com/files/stacksmith" namespace_requirement = "prohibited" - allowed_qualifiers = {"arch", "repository_url", "distro"} + allowed_qualifiers = {"distro", "repository_url", "arch"} namespace_case_sensitive = False name_case_sensitive = False - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:bitnami/.*" @@ -169,8 +169,8 @@ class CargoTypeValidator(TypeValidator): namespace_requirement = "prohibited" allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False - name_case_sensitive = False - version_case_sensitive = False + name_case_sensitive = True + version_case_sensitive = True purl_pattern = "pkg:cargo/.*" @@ -184,7 +184,7 @@ class CocoapodsTypeValidator(TypeValidator): allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = True - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:cocoapods/.*" @@ -198,7 +198,7 @@ class ComposerTypeValidator(TypeValidator): allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:composer/.*" @@ -209,10 +209,10 @@ class ConanTypeValidator(TypeValidator): use_repository = True default_repository_url = "https://center.conan.io" namespace_requirement = "optional" - allowed_qualifiers = {"rrev", "channel", "prev", "user", "repository_url"} + allowed_qualifiers = {"channel", "rrev", "user", "repository_url", "prev"} namespace_case_sensitive = False name_case_sensitive = False - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:conan/.*" @@ -223,10 +223,10 @@ class CondaTypeValidator(TypeValidator): use_repository = True default_repository_url = "https://repo.anaconda.com" namespace_requirement = "prohibited" - allowed_qualifiers = {"type", "build", "subdir", "channel", "repository_url"} + allowed_qualifiers = {"channel", "build", "subdir", "repository_url", "type"} namespace_case_sensitive = False name_case_sensitive = False - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:conda/.*" @@ -237,10 +237,10 @@ class CpanTypeValidator(TypeValidator): use_repository = True default_repository_url = "https://www.cpan.org/" namespace_requirement = "optional" - allowed_qualifiers = {"vcs_url", "ext", "repository_url", "download_url"} + allowed_qualifiers = {"repository_url", "ext", "vcs_url", "download_url"} namespace_case_sensitive = False name_case_sensitive = True - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:cpan/.*" @@ -253,8 +253,8 @@ class CranTypeValidator(TypeValidator): namespace_requirement = "prohibited" allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False - name_case_sensitive = False - version_case_sensitive = False + name_case_sensitive = True + version_case_sensitive = True purl_pattern = "pkg:cran/.*" @@ -265,10 +265,10 @@ class DebTypeValidator(TypeValidator): use_repository = True default_repository_url = "" namespace_requirement = "required" - allowed_qualifiers = {"arch", "repository_url"} + allowed_qualifiers = {"repository_url", "arch"} namespace_case_sensitive = False name_case_sensitive = False - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:deb/.*" @@ -282,7 +282,7 @@ class DockerTypeValidator(TypeValidator): allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:docker/.*" @@ -296,7 +296,7 @@ class GemTypeValidator(TypeValidator): allowed_qualifiers = {"repository_url", "platform"} namespace_case_sensitive = False name_case_sensitive = False - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:gem/.*" @@ -310,7 +310,7 @@ class GenericTypeValidator(TypeValidator): allowed_qualifiers = {"checksum", "download_url"} namespace_case_sensitive = False name_case_sensitive = False - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:generic/.*" @@ -324,7 +324,7 @@ class GithubTypeValidator(TypeValidator): allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:github/.*" @@ -338,7 +338,7 @@ class GolangTypeValidator(TypeValidator): allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:golang/.*" @@ -352,7 +352,7 @@ class HackageTypeValidator(TypeValidator): allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = True - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:hackage/.*" @@ -366,7 +366,7 @@ class HexTypeValidator(TypeValidator): allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:hex/.*" @@ -380,7 +380,7 @@ class HuggingfaceTypeValidator(TypeValidator): allowed_qualifiers = {"repository_url"} namespace_case_sensitive = True name_case_sensitive = True - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:huggingface/.*" @@ -405,7 +405,7 @@ class MavenTypeValidator(TypeValidator): use_repository = True default_repository_url = "https://repo.maven.apache.org/maven2/" namespace_requirement = "required" - allowed_qualifiers = {"type", "classifier", "repository_url"} + allowed_qualifiers = {"repository_url", "type", "classifier"} namespace_case_sensitive = True name_case_sensitive = True version_case_sensitive = True @@ -419,10 +419,10 @@ class MlflowTypeValidator(TypeValidator): use_repository = True default_repository_url = "" namespace_requirement = "prohibited" - allowed_qualifiers = {"run_id", "repository_url", "model_uuid"} + allowed_qualifiers = {"repository_url", "run_id", "model_uuid"} namespace_case_sensitive = False name_case_sensitive = False - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:mlflow/.*" @@ -450,7 +450,7 @@ class NugetTypeValidator(TypeValidator): allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = True - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:nuget/.*" @@ -461,10 +461,10 @@ class OciTypeValidator(TypeValidator): use_repository = True default_repository_url = "" namespace_requirement = "prohibited" - allowed_qualifiers = {"arch", "repository_url", "tag"} + allowed_qualifiers = {"repository_url", "tag", "arch"} namespace_case_sensitive = False name_case_sensitive = False - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:oci/.*" @@ -478,7 +478,7 @@ class PubTypeValidator(TypeValidator): allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:pub/.*" @@ -489,10 +489,10 @@ class PypiTypeValidator(TypeValidator): use_repository = True default_repository_url = "https://pypi.org" namespace_requirement = "prohibited" - allowed_qualifiers = {"repository_url", "file_name"} + allowed_qualifiers = {"file_name", "repository_url"} namespace_case_sensitive = False name_case_sensitive = False - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:pypi/.*" @@ -506,7 +506,7 @@ class QpkgTypeValidator(TypeValidator): allowed_qualifiers = {"repository_url"} namespace_case_sensitive = False name_case_sensitive = False - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:qpkg/.*" @@ -517,10 +517,10 @@ class RpmTypeValidator(TypeValidator): use_repository = True default_repository_url = "" namespace_requirement = "required" - allowed_qualifiers = {"arch", "repository_url", "epoch"} + allowed_qualifiers = {"repository_url", "arch", "epoch"} namespace_case_sensitive = False name_case_sensitive = True - version_case_sensitive = False + version_case_sensitive = True purl_pattern = "pkg:rpm/.*" @@ -531,7 +531,7 @@ class SwidTypeValidator(TypeValidator): use_repository = False default_repository_url = "" namespace_requirement = "optional" - allowed_qualifiers = {"patch", "tag_id", "tag_creator_regid", "tag_creator_name", "tag_version"} + allowed_qualifiers = {"tag_creator_name", "tag_creator_regid", "tag_version", "tag_id", "patch"} namespace_case_sensitive = True name_case_sensitive = True version_case_sensitive = True diff --git a/tests/test_purl_spec.py b/tests/test_purl_spec.py index 78f02a3..de036f4 100644 --- a/tests/test_purl_spec.py +++ b/tests/test_purl_spec.py @@ -154,6 +154,12 @@ def run_test_case(case, test_type, desc): elif test_type == "validation": input_data = case["input"] + test_group = case.get("test_group") + if test_group not in ("base", "advanced"): + raise Exception(test_group) + strict = True + if test_group == "advanced": + strict = False purl = PackageURL( type=input_data["type"], namespace=input_data["namespace"], @@ -161,11 +167,8 @@ def run_test_case(case, test_type, desc): version=input_data["version"], qualifiers=input_data.get("qualifiers"), subpath=input_data.get("subpath"), + normalize_purl=not strict, ) - test_group = case.get("test_group") - strict = True - if test_group == "advanced": - strict = False messages = purl.validate(strict=strict) if case.get("expected_messages"): assert messages == case["expected_messages"] From e33eceee8abb43884e00251851926bfeeac930d9 Mon Sep 17 00:00:00 2001 From: Tushar Goel Date: Fri, 29 Aug 2025 14:55:09 +0530 Subject: [PATCH 6/8] Add failing tests Signed-off-by: Tushar Goel --- src/packageurl/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/packageurl/__init__.py b/src/packageurl/__init__.py index ee1bf2c..14e997a 100644 --- a/src/packageurl/__init__.py +++ b/src/packageurl/__init__.py @@ -430,7 +430,7 @@ def __new__( qualifiers_final, subpath_final, ) = normalize(type, namespace, name, version, qualifiers, subpath, encode=None) - + else: type_final = type namespace_final = namespace From 0deca77c3f8727f34cf0f42387a442f7b27b7f2b Mon Sep 17 00:00:00 2001 From: Tushar Goel Date: Fri, 29 Aug 2025 17:40:52 +0530 Subject: [PATCH 7/8] Fix linting issues Signed-off-by: Tushar Goel --- etc/scripts/generate_validators.py | 2 -- spec | 2 +- src/packageurl/__init__.py | 31 ++++++++++++++++++-------- src/packageurl/utils.py | 11 ++++++++++ src/packageurl/validate.py | 35 +++++++++++++++++++++++++++--- 5 files changed, 66 insertions(+), 15 deletions(-) diff --git a/etc/scripts/generate_validators.py b/etc/scripts/generate_validators.py index 18affb6..d3dddcb 100644 --- a/etc/scripts/generate_validators.py +++ b/etc/scripts/generate_validators.py @@ -72,8 +72,6 @@ # Visit https://github.com/package-url/packageurl-python for support and # download. -from packageurl.contrib.route import Router - """ Validate each type according to the PURL spec type definitions """ diff --git a/spec b/spec index 53f88e7..8da5b08 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 53f88e7127561cead1fc9b456f27a7675cb4f383 +Subproject commit 8da5b08fe85f5b15edb4bcf0a2df1fff554b8547 diff --git a/src/packageurl/__init__.py b/src/packageurl/__init__.py index 14e997a..9d6e3e6 100644 --- a/src/packageurl/__init__.py +++ b/src/packageurl/__init__.py @@ -133,7 +133,7 @@ def normalize_namespace( "hex", ): namespace_str = namespace_str.lower() - if ptype in ("cpan"): + if ptype and ptype in ("cpan"): namespace_str = namespace_str.upper() segments = [seg for seg in namespace_str.split("/") if seg.strip()] segments_quoted = map(get_quoter(encode), segments) @@ -192,6 +192,8 @@ def normalize_name( name_str = name_str.lower() if ptype == "pypi": name_str = name_str.replace("_", "-").lower() + if ptype == "hackage": + name_str = name_str.replace("_", "-") return name_str or None @@ -395,7 +397,7 @@ def __new__( version: AnyStr | None = None, qualifiers: AnyStr | dict[str, str] | None = None, subpath: AnyStr | None = None, - normalize_purl=True, + normalize_purl: bool = True, ) -> Self: required = dict(type=type, name=name) for key, value in required.items(): @@ -421,6 +423,13 @@ def __new__( f"Invalid purl: qualifiers argument must be a dict or a string: {qualifiers!r}." ) + type_final: str + namespace_final: Optional[str] + name_final: str + version_final: Optional[str] + qualifiers_final: dict[str, str] + subpath_final: Optional[str] + if normalize_purl: ( type_final, @@ -430,14 +439,18 @@ def __new__( qualifiers_final, subpath_final, ) = normalize(type, namespace, name, version, qualifiers, subpath, encode=None) - else: - type_final = type - namespace_final = namespace - name_final = name - version_final = version - qualifiers_final = qualifiers - subpath_final = subpath + from packageurl.utils import ensure_str + + type_final = ensure_str(type) or "" + namespace_final = ensure_str(namespace) + name_final = ensure_str(name) or "" + version_final = ensure_str(version) + if isinstance(qualifiers, dict): + qualifiers_final = qualifiers + else: + qualifiers_final = {} + subpath_final = ensure_str(subpath) return super().__new__( cls, diff --git a/src/packageurl/utils.py b/src/packageurl/utils.py index 46e3022..855d672 100644 --- a/src/packageurl/utils.py +++ b/src/packageurl/utils.py @@ -24,6 +24,9 @@ # Visit https://github.com/package-url/packageurl-python for support and # download. +from typing import Optional +from typing import Union + from packageurl import PackageURL @@ -51,3 +54,11 @@ def get_golang_purl(go_package: str): name = parts[-1] namespace = "/".join(parts[:-1]) return PackageURL(type="golang", namespace=namespace, name=name, version=version) + + +def ensure_str(value: Optional[Union[str, bytes]]) -> Optional[str]: + if value is None: + return None + if isinstance(value, bytes): + return value.decode("utf-8") # or whatever encoding is right + return value diff --git a/src/packageurl/validate.py b/src/packageurl/validate.py index 1cd08e9..87a6e10 100644 --- a/src/packageurl/validate.py +++ b/src/packageurl/validate.py @@ -22,8 +22,6 @@ # Visit https://github.com/package-url/packageurl-python for support and # download. -from packageurl.contrib.route import Router - """ Validate each type according to the PURL spec type definitions """ @@ -41,7 +39,10 @@ def validate(cls, purl, strict=False): elif cls.namespace_requirement == "required" and not purl.namespace: yield f"Namespace is required for purl type: {cls.type!r}" - if ( + if purl.type == "cpan": + if purl.namespace and purl.namespace != purl.namespace.upper(): + yield f"Namespace must be uppercase for purl type: {cls.type!r}" + elif ( not cls.namespace_case_sensitive and purl.namespace and purl.namespace.lower() != purl.namespace @@ -243,6 +244,14 @@ class CpanTypeValidator(TypeValidator): version_case_sensitive = True purl_pattern = "pkg:cpan/.*" + @classmethod + def validate_type(cls, purl, strict=False): + if purl.namespace and "::" in purl.name: + yield f"Name must not contain '::' when Namespace is absent for purl type: {cls.type!r}" + if not purl.namespace and "-" in purl.name: + yield f"Name must not contain '-' when Namespace is absent for purl type: {cls.type!r}" + yield from super().validate_type(purl, strict) + class CranTypeValidator(TypeValidator): type = "cran" @@ -355,6 +364,12 @@ class HackageTypeValidator(TypeValidator): version_case_sensitive = True purl_pattern = "pkg:hackage/.*" + @classmethod + def validate_type(cls, purl, strict=False): + if "_" in purl.name: + yield f"Name contains underscores but should be kebab-case for purl type: {cls.type!r}" + yield from super().validate_type(purl, strict) + class HexTypeValidator(TypeValidator): type = "hex" @@ -481,6 +496,14 @@ class PubTypeValidator(TypeValidator): version_case_sensitive = True purl_pattern = "pkg:pub/.*" + @classmethod + def validate_type(cls, purl, strict=False): + if any(not (c.islower() or c.isdigit() or c == "_") for c in purl.name): + yield f"Name contains invalid characters but should only contain lowercase letters, digits, or underscores for purl type: {cls.type!r}" + if " " in purl.name: + yield f"Name contains spaces but should use underscores instead for purl type: {cls.type!r}" + yield from super().validate_type(purl, strict) + class PypiTypeValidator(TypeValidator): type = "pypi" @@ -495,6 +518,12 @@ class PypiTypeValidator(TypeValidator): version_case_sensitive = True purl_pattern = "pkg:pypi/.*" + @classmethod + def validate_type(cls, purl, strict=False): + if "_" in purl.name: + yield f"Name cannot contain `_` for purl type:{cls.type!r}" + yield from super().validate_type(purl, strict) + class QpkgTypeValidator(TypeValidator): type = "qpkg" From ceb97bc37412f4f170e29f7dbc9accb07878493c Mon Sep 17 00:00:00 2001 From: Tushar Goel Date: Sun, 31 Aug 2025 09:10:26 +0530 Subject: [PATCH 8/8] update spec Signed-off-by: Tushar Goel --- spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec b/spec index 8da5b08..ce67457 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 8da5b08fe85f5b15edb4bcf0a2df1fff554b8547 +Subproject commit ce6745797a85a3121f2f1aef718d52f26d3f6a84