From 45049e085953b1a5ef511538291df8951c8704da Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 18 Oct 2026 20:23:53 +0100 Subject: [PATCH] Sync PURL type metadata with the specification --- .github/dependabot.yml | 8 +++ .github/workflows/ci.yml | 5 ++ .gitmodules | 3 + README.md | 15 ++++- defaults_test.go | 2 + makepurl_test.go | 1 + spec_test.go | 125 +++++++++++++++++++++++++++++++++++++++ testdata/purl-spec | 1 + types.json | 108 ++++++++++++++++++++++++++++++++- types_test.go | 4 +- 10 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 .gitmodules create mode 100644 spec_test.go create mode 160000 testdata/purl-spec diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8a19728..75b5e4a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,13 @@ version: 2 updates: + - package-ecosystem: gitsubmodule + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + cooldown: + default-days: 7 + - package-ecosystem: gomod directory: / schedule: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19f093a..e8a74cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,8 @@ permissions: {} jobs: test: runs-on: ubuntu-latest + permissions: + contents: read strategy: matrix: go-version: ['1.26', '1.27'] @@ -19,6 +21,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + submodules: true - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 @@ -33,6 +36,8 @@ jobs: lint: runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..eb395d1 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "testdata/purl-spec"] + path = testdata/purl-spec + url = https://github.com/package-url/purl-spec.git diff --git a/README.md b/README.md index 981fc14..578e928 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,9 @@ fmt.Println(version) // 1.2.3 ## Type Configuration -Type information comes from an embedded copy of [purl-types.json](https://github.com/andrew/purl/blob/main/purl-types.json) which includes default registries, namespace requirements, and URI templates for registry URL generation. +Type information is embedded in `types.json`. Type names and namespace requirements are checked against the pinned [purl-spec](https://github.com/package-url/purl-spec) submodule. Default registries and URL templates are maintained locally. + +The local types `bioconductor`, `clojars`, `deno`, and `elm` are retained. The legacy `homebrew` type keeps its existing PURLs and registry behavior; use `brew` explicitly for the registered type. Recognition of a type does not imply full support for its normalization or validation rules. ```go purl.KnownTypes() // []string of all known PURL types @@ -54,6 +56,17 @@ purl.IsKnownType("npm") // true purl.DefaultRegistry("npm") // https://registry.npmjs.org ``` +## Development + +Initialize the spec submodule before running tests: + +```sh +git submodule update --init +go test ./... +``` + +Dependabot checks for spec updates weekly. The metadata test reports missing types, namespace differences, and unregistered types without a local allowance. Update `types.json` when those checks fail, preserving local registry settings. + ## License [MIT](LICENSE). diff --git a/defaults_test.go b/defaults_test.go index 3852a30..c17b34c 100644 --- a/defaults_test.go +++ b/defaults_test.go @@ -47,6 +47,8 @@ func TestIsDefaultRegistry(t *testing.T) { // Types with no default registry {"apk", "https://example.com", false}, + {"vcpkg", "https://github.com/azure-sdk/vcpkg", false}, + {"julia", "https://github.com/HolyLab/HolyLabRegistry", false}, {"deb", "https://example.com", false}, // Unknown type diff --git a/makepurl_test.go b/makepurl_test.go index 35f904c..d42c2b0 100644 --- a/makepurl_test.go +++ b/makepurl_test.go @@ -85,6 +85,7 @@ func TestBuildPURLString(t *testing.T) { {"golang normalization", "golang", "GitHub.com/Foo/Bar", "v1.0.0", "", "pkg:golang/github.com/foo/bar@v1.0.0"}, {"mlflow databricks normalization", "mlflow", "TrafficSigns", "1.0", "https://adb-123.4.azuredatabricks.net/api/2.0/mlflow", "pkg:mlflow/trafficsigns@1.0?repository_url=https:%2F%2Fadb-123.4.azuredatabricks.net%2Fapi%2F2.0%2Fmlflow"}, {"mlflow azureml preserves case", "mlflow", "TrafficSigns", "1.0", "https://westus2.api.azureml.ms/mlflow/v1.0", "pkg:mlflow/TrafficSigns@1.0?repository_url=https:%2F%2Fwestus2.api.azureml.ms%2Fmlflow%2Fv1.0"}, + {"vcpkg github registry", "vcpkg", "zlib", "1.3.1", "https://github.com/azure-sdk/vcpkg", "pkg:vcpkg/zlib@1.3.1?repository_url=https:%2F%2Fgithub.com%2Fazure-sdk%2Fvcpkg"}, {"helm https repo", "helm", "postgresql", "12.1.15", "https://charts.bitnami.com/bitnami", "pkg:helm/postgresql@12.1.15"}, {"helm oci repo", "helm", "redis", "17.3.7", "oci://registry-1.docker.io/bitnamicharts", "pkg:helm/redis@17.3.7"}, {"helm local alias", "helm", "metrics-server", "3.12.2", "@internal", "pkg:helm/metrics-server@3.12.2"}, diff --git a/spec_test.go b/spec_test.go new file mode 100644 index 0000000..cb39657 --- /dev/null +++ b/spec_test.go @@ -0,0 +1,125 @@ +package purl_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/git-pkgs/purl" +) + +func TestSpecTypeMetadata(t *testing.T) { + var types []string + readSpecJSON(t, "purl-types-index.json", &types) + if len(types) == 0 { + t.Fatal("spec type index is empty") + } + registered := make(map[string]bool, len(types)) + for _, name := range types { + if registered[name] { + t.Errorf("duplicate spec type %q", name) + } + registered[name] = true + t.Run(name, func(t *testing.T) { + checkSpecTypeMetadata(t, name) + }) + } + + localTypes := map[string]string{ + "bioconductor": "Bioconductor registry support", + "clojars": "Clojars registry support", + "deno": "Deno registry support", + "elm": "Elm registry support", + "homebrew": "Legacy type retained alongside brew for compatibility", + } + for _, name := range purl.KnownTypes() { + if !registered[name] && localTypes[name] == "" { + t.Errorf("unregistered type %q needs an explicit local type allowance", name) + } + } + for name := range localTypes { + if registered[name] || !purl.IsKnownType(name) { + t.Errorf("remove stale local type allowance for %q", name) + } + } +} + +func checkSpecTypeMetadata(t *testing.T, name string) { + t.Helper() + var definition struct { + Type string `json:"type"` + Namespace struct { + Requirement string `json:"requirement"` + } `json:"namespace_definition"` + } + readSpecJSON(t, filepath.Join("types", name+"-definition.json"), &definition) + if definition.Type != name { + t.Fatalf("definition type = %q, want %q", definition.Type, name) + } + requirement := definition.Namespace.Requirement + switch requirement { + case "required", "optional", "prohibited": + default: + t.Fatalf("unknown namespace requirement %q", requirement) + } + cfg := purl.TypeInfo(name) + if cfg == nil { + t.Fatalf("spec type %q is missing from types.json", name) + } + if !purl.IsKnownType(name) || !purl.IsValidEcosystem(name) { + t.Errorf("spec type %q is not recognized", name) + } + if cfg.NamespaceRequirement != requirement { + t.Errorf("namespace requirement = %q, spec requires %q", cfg.NamespaceRequirement, requirement) + } + if cfg.NamespaceRequired() != (requirement == "required") || cfg.NamespaceProhibited() != (requirement == "prohibited") { + t.Errorf("namespace helpers disagree with spec requirement %q", requirement) + } +} + +func readSpecJSON(t *testing.T, name string, value any) { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", "purl-spec", name)) + if err != nil { + t.Fatalf("read spec: %v; initialize it with git submodule update --init", err) + } + if err := json.Unmarshal(data, value); err != nil { + t.Fatalf("decode spec %s: %v", name, err) + } +} + +func TestHomebrewCompatibility(t *testing.T) { + const registry = "https://formulae.brew.sh" + const packageURL = registry + "/formula/wget" + const want = "pkg:homebrew/wget@1.21.3" + for _, got := range []string{ + purl.MakePURL("homebrew", "wget", "1.21.3").String(), + purl.MakePURLString("homebrew", "wget", "1.21.3"), + purl.BuildPURLString("homebrew", "wget", "1.21.3", registry), + } { + if got != want { + t.Errorf("homebrew builder = %q, want %q", got, want) + } + } + p, err := purl.Parse(want) + if err != nil { + t.Fatal(err) + } + if got, err := p.RegistryURL(); err != nil || got != packageURL { + t.Errorf("RegistryURL() = %q, %v", got, err) + } + p, err = purl.ParseRegistryURL(packageURL) + if err != nil { + t.Fatal(err) + } + if got := p.String(); got != "pkg:homebrew/wget" { + t.Errorf("ParseRegistryURL() = %q, want legacy homebrew type", got) + } + if !purl.IsKnownType("brew") || !purl.IsKnownType("homebrew") { + t.Error("both brew and homebrew must remain recognized") + } + if got := purl.MakePURLString("brew", "wget", "1.21.3"); got != "pkg:brew/wget@1.21.3" { + t.Errorf("explicit brew builder = %q", got) + } +} diff --git a/testdata/purl-spec b/testdata/purl-spec new file mode 160000 index 0000000..ec6d7e0 --- /dev/null +++ b/testdata/purl-spec @@ -0,0 +1 @@ +Subproject commit ec6d7e03101ebdb7f79a7e292eb9d38998521ed6 diff --git a/types.json b/types.json index 85f9560..5bc744d 100644 --- a/types.json +++ b/types.json @@ -1,12 +1,13 @@ { "version": "1.2.0", - "description": "Official PURL types with registry configurations", + "description": "PURL type metadata with local registry configurations", "source": "https://github.com/package-url/purl-spec", - "last_updated": "2025-07-27", + "last_updated": "2026-10-18", "types": { "alpm": { "description": "Arch Linux packages and other users of the libalpm/pacman package manager.", "default_registry": null, + "namespace_requirement": "required", "examples": [ "pkg:alpm/arch/pacman@6.0.1-1?arch=x86_64", "pkg:alpm/arch/python-pip@21.0-1?arch=any", @@ -16,14 +17,25 @@ "apk": { "description": "Alpine Linux APK-based packages", "default_registry": null, + "namespace_requirement": "required", "examples": [ "pkg:apk/alpine/curl@7.83.0-r0?arch=x86", "pkg:apk/alpine/apk@2.12.9-r3?arch=x86" ] }, + "bazel": { + "description": "Bazel modules as specified at https://bazel.build/external/module", + "default_registry": "https://bcr.bazel.build", + "namespace_requirement": "prohibited", + "examples": [ + "pkg:bazel/rules_java@7.8.0", + "pkg:bazel/curl@8.8.0.bcr.1" + ] + }, "bitbucket": { "description": "Bitbucket-based packages", "default_registry": "https://bitbucket.org", + "namespace_requirement": "required", "examples": [ "pkg:bitbucket/atlassian/python-bitbucket@0.1.0", "pkg:bitbucket/birkenfeld/pygments-main@2.13.0", @@ -33,6 +45,7 @@ "bitnami": { "description": "Bitnami-based packages", "default_registry": null, + "namespace_requirement": "prohibited", "examples": [ "pkg:bitnami/wordpress?distro=debian-12", "pkg:bitnami/wordpress@6.2.0?distro=debian-12", @@ -40,9 +53,19 @@ "pkg:bitnami/wordpress@6.2.0?arch=arm64&distro=photon-4" ] }, + "brew": { + "description": "Homebrew packages for macOS and Linux", + "default_registry": "https://formulae.brew.sh/", + "namespace_requirement": "optional", + "examples": [ + "pkg:brew/sqlite@3.43.2", + "pkg:brew/go" + ] + }, "cargo": { "description": "Cargo packages for Rust", "default_registry": "https://crates.io", + "namespace_requirement": "prohibited", "examples": [ "pkg:cargo/rand@0.7.2", "pkg:cargo/clap@4.0.32", @@ -58,9 +81,19 @@ } } }, + "chrome-extension": { + "description": "Chrome browser extensions", + "default_registry": "https://chromewebstore.google.com/", + "namespace_requirement": "prohibited", + "examples": [ + "pkg:chrome-extension/hlepfoohegkhhmjieoechaddaejaokhf@25.7.1", + "pkg:chrome-extension/dncgedbnidfkppmdgfgidcepclnokpkb@6.0.2.3611" + ] + }, "cocoapods": { "description": "CocoaPods pods", "default_registry": "https://cdn.cocoapods.org/", + "namespace_requirement": "prohibited", "examples": [ "pkg:cocoapods/Alamofire@5.6.4", "pkg:cocoapods/SwiftyJSON@5.0.1", @@ -99,6 +132,7 @@ "conan": { "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", "default_registry": "https://conan.io/center", + "namespace_requirement": "optional", "examples": [ "pkg:conan/openssl@3.0.3", "pkg:conan/openssl.org/openssl@3.0.3?user=bincrafters&channel=stable", @@ -117,6 +151,7 @@ "conda": { "description": "conda is for Conda packages", "default_registry": "https://repo.anaconda.com", + "namespace_requirement": "prohibited", "examples": [ "pkg:conda/numpy@1.24.1", "pkg:conda/pandas@1.5.2", @@ -135,6 +170,7 @@ "cpan": { "description": "CPAN Perl packages", "default_registry": "https://www.cpan.org/", + "namespace_requirement": "optional", "examples": [ "pkg:cpan/Moose@2.2014", "pkg:cpan/DBI@1.643", @@ -163,6 +199,7 @@ "deb": { "description": "Debian packages, Debian derivatives, and Ubuntu packages", "default_registry": null, + "namespace_requirement": "required", "examples": [ "pkg:deb/debian/curl@7.50.3-1?arch=i386&distro=jessie", "pkg:deb/debian/dpkg@1.19.0.4?arch=amd64&distro=stretch", @@ -174,6 +211,7 @@ "docker": { "description": "for Docker images", "default_registry": "https://hub.docker.com", + "namespace_requirement": "optional", "examples": [ "pkg:docker/nginx@1.21.6", "pkg:docker/ubuntu@20.04", @@ -204,6 +242,7 @@ "generic": { "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.", "default_registry": null, + "namespace_requirement": "optional", "examples": [ "pkg:generic/openssl@1.1.10g", "pkg:generic/openssl@1.1.10g?download_url=https://openssl.org/source/openssl-1.1.0g.tar.gz&checksum=sha256:de4d501267da", @@ -223,6 +262,7 @@ "github": { "description": "GitHub-based packages", "default_registry": "https://github.com", + "namespace_requirement": "required", "examples": [ "pkg:github/torvalds/linux@6.1", "pkg:github/microsoft/vscode@1.74.2", @@ -232,6 +272,7 @@ "golang": { "description": "Go packages", "default_registry": "https://pkg.go.dev", + "namespace_requirement": "required", "ecosystems_registry": "proxy.golang.org", "examples": [ "pkg:golang/github.com/gorilla/context@234fd47e07d1004f0aed9c", @@ -253,6 +294,7 @@ "hackage": { "description": "Haskell packages", "default_registry": "https://hackage.haskell.org", + "namespace_requirement": "prohibited", "examples": [ "pkg:hackage/aeson@2.1.1.0", "pkg:hackage/lens@5.2", @@ -273,6 +315,7 @@ "hex": { "description": "Hex packages", "default_registry": "https://repo.hex.pm", + "namespace_requirement": "optional", "examples": [ "pkg:hex/phoenix@1.6.15", "pkg:hex/ecto@3.9.4", @@ -291,6 +334,7 @@ "huggingface": { "description": "Hugging Face ML models", "default_registry": "https://huggingface.co", + "namespace_requirement": "required", "examples": [ "pkg:huggingface/huggingface/distilbert-base-uncased@043235d6088ecd3dd5fb5ca3592b6913fd516027", "pkg:huggingface/microsoft/deberta-v3-base@559062ad13d311b87b2c455e67dcd5f1c8f65111?repository_url=https://hub-ci.huggingface.co" @@ -307,9 +351,19 @@ } } }, + "julia": { + "description": "Julia packages", + "default_registry": null, + "namespace_requirement": "prohibited", + "examples": [ + "pkg:julia/Dates@1.9.0?uuid=ade2ca70-3891-5945-98fb-dc099432e06a", + "pkg:julia/Dates?uuid=ade2ca70-3891-5945-98fb-dc099432e06a" + ] + }, "luarocks": { "description": "Lua packages installed with LuaRocks", "default_registry": "https://luarocks.org", + "namespace_requirement": "optional", "examples": [ "pkg:luarocks/luasocket@3.1.0-1", "pkg:luarocks/hisham/luafilesystem@1.8.0-1", @@ -353,6 +407,7 @@ "mlflow": { "description": "MLflow ML models (Azure ML, Databricks, etc.)", "default_registry": null, + "namespace_requirement": "prohibited", "examples": [ "pkg:mlflow/creditfraud@3?repository_url=https://westus2.api.azureml.ms/mlflow/v1.0/subscriptions/a50f2011-fab8-4164-af23-c62881ef8c95/resourceGroups/TestResourceGroup/providers/Microsoft.MachineLearningServices/workspaces/TestWorkspace", "pkg:mlflow/trafficsigns@10?model_uuid=36233173b22f4c89b451f1228d700d49&run_id=410a3121-2709-4f88-98dd-dba0ef056b0a&repository_url=https://adb-5245952564735461.0.azuredatabricks.net/api/2.0/mlflow" @@ -387,6 +442,7 @@ "nuget": { "description": "NuGet .NET packages", "default_registry": "https://www.nuget.org", + "namespace_requirement": "prohibited", "examples": [ "pkg:nuget/Newtonsoft.Json@13.0.1", "pkg:nuget/EntityFramework@6.4.4", @@ -407,6 +463,7 @@ "oci": { "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", "default_registry": null, + "namespace_requirement": "prohibited", "examples": [ "pkg:oci/debian@sha256%3A244fd47e07d10?repository_url=docker.io/library/debian&arch=amd64&tag=latest", "pkg:oci/debian@sha256%3A244fd47e07d10?repository_url=ghcr.io/debian&tag=bullseye", @@ -414,9 +471,27 @@ "pkg:oci/hello-wasm@sha256%3A244fd47e07d10?tag=v1" ] }, + "opam": { + "description": "Opam packages", + "default_registry": "https://opam.ocaml.org", + "namespace_requirement": "prohibited", + "examples": [ + "pkg:opam/ocaml-base-compiler@5.2.0" + ] + }, + "otp": { + "description": "BEAM/OTP applications written in Elixir, Erlang, Gleam and other BEAM languages", + "default_registry": null, + "namespace_requirement": "prohibited", + "examples": [ + "pkg:otp/erts@10.6.3?platform=linux&arch=amd64&repository_url=https:%2F%2Fgithub.com%2Ferlang%2Fotp&vcs_url=git%2Bhttps:%2F%2Fgithub.com%2Ferlang%2Fotp.git", + "pkg:otp/stdlib@3.11.2?repository_url=https:%2F%2Fgithub.com%2Ferlang%2Fotp&vcs_url=git%2Bhttps:%2F%2Fgithub.com%2Ferlang%2Fotp.git" + ] + }, "pub": { "description": "Dart and Flutter pub packages", "default_registry": "https://pub.dartlang.org", + "namespace_requirement": "prohibited", "examples": [ "pkg:pub/http@0.13.5", "pkg:pub/flutter@3.3.10", @@ -435,6 +510,7 @@ "pypi": { "description": "Python packages", "default_registry": "https://pypi.org", + "namespace_requirement": "prohibited", "examples": [ "pkg:pypi/django@4.1.4", "pkg:pypi/requests@2.28.1", @@ -456,6 +532,7 @@ "qpkg": { "description": "QNX packages", "default_registry": null, + "namespace_requirement": "required", "examples": [ "pkg:qpkg/blackberry/com.qnx.sdp@7.0.0.SGA201702151847", "pkg:qpkg/blackberry/com.qnx.qnx710.foo.bar.qux@0.0.4.01449T202205040833L" @@ -464,6 +541,7 @@ "rpm": { "description": "RPM packages", "default_registry": null, + "namespace_requirement": "required", "examples": [ "pkg:rpm/fedora/curl@7.50.3-1.fc25?arch=i386&distro=fedora-25", "pkg:rpm/fedora/centerim@4.22.10-1.el6?arch=i686&epoch=1&distro=fedora-25" @@ -472,6 +550,7 @@ "swid": { "description": "PURL type for ISO-IEC 19770-2 Software Identification (SWID) tags.", "default_registry": null, + "namespace_requirement": "optional", "examples": [ "pkg:swid/Acme/example.com/Enterprise+Server@1.0.0?tag_id=75b8c285-fa7b-485b-b199-4745e3004d0d", "pkg:swid/Fedora@29?tag_id=org.fedoraproject.Fedora-29", @@ -497,6 +576,31 @@ } } }, + "vcpkg": { + "description": "Packages from the vcpkg C/C++ package manager.", + "default_registry": null, + "namespace_requirement": "prohibited", + "examples": [ + "pkg:vcpkg/bzip2@1.0.8?port_version=6" + ] + }, + "vscode-extension": { + "description": "VS Code Extension packages", + "default_registry": "https://marketplace.visualstudio.com/vscode", + "namespace_requirement": "required", + "examples": [ + "pkg:vscode-extension/ms-python/python@2023.25.10292213", + "pkg:vscode-extension/muhammad-sammy/csharp@2.15.30?repository_url=https:%2F%2Fopen-vsx.org" + ] + }, + "yocto": { + "description": "Yocto Project recipes", + "default_registry": null, + "namespace_requirement": "optional", + "examples": [ + "pkg:yocto/core/glibc@2.35" + ] + }, "clojars": { "description": "Clojars packages", "default_registry": "https://clojars.org", diff --git a/types_test.go b/types_test.go index d3d8b6c..d1ff3a8 100644 --- a/types_test.go +++ b/types_test.go @@ -126,8 +126,8 @@ func TestNamespaceRequirement(t *testing.T) { {"composer", true, false}, {"gem", false, true}, {"cran", false, true}, - {"npm", false, false}, // optional - {"cargo", false, false}, // no requirement + {"npm", false, false}, // optional + {"cargo", false, true}, } for _, tt := range tests {