diff --git a/README.md b/README.md index 5e3a742..b89949d 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ output stays stable for callers that store it: 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. `bsd` follows the FreeBSD type proposed in [purl-spec#496](https://github.com/package-url/purl-spec/pull/496); the `freebsd` ecosystem maps to `pkg:bsd/freebsd/...`. 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. +The local types `bioconductor`, `clojars`, `deno`, and `elm` are retained. `bsd` follows the FreeBSD type proposed in [purl-spec#496](https://github.com/package-url/purl-spec/pull/496); the `freebsd` ecosystem maps to `pkg:bsd/freebsd/...`. `terraform` follows the type proposed in [purl-spec#889](https://github.com/package-url/purl-spec/pull/889): a provider address `namespace/type` maps to `pkg:terraform/{namespace}/{type}`, a module address `namespace/name/system` to `pkg:terraform/{namespace}/{name}/{system}`, and the default registry is `https://registry.terraform.io`. 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 diff --git a/ecosystem.go b/ecosystem.go index 6400cb4..74f7f91 100644 --- a/ecosystem.go +++ b/ecosystem.go @@ -21,6 +21,7 @@ const ( purlTypeGitHubActions = "githubactions" purlTypeVSCodeExtension = "vscode-extension" purlTypeBSD = "bsd" + purlTypeTerraform = "terraform" ) // purlTypeForEcosystem maps ecosystem names to PURL types. diff --git a/makepurl.go b/makepurl.go index fa3cddf..95b11e1 100644 --- a/makepurl.go +++ b/makepurl.go @@ -241,6 +241,8 @@ func splitNamespace(ecosystem, name string) (namespace, pkgName string, ok bool) } case purlTypeVSCodeExtension: return splitVSCodeExtension(name) + case purlTypeTerraform: + return splitTerraform(name) case ecosystemSwift: return splitSwift(name) } @@ -269,6 +271,18 @@ func splitSwift(name string) (namespace, pkgName string, ok bool) { } // splitVSCodeExtension splits VS Code's publisher.name extension identifier. +// splitTerraform splits a Terraform address without its registry hostname: +// namespace/type for a provider, namespace/name/system for a module, whose +// namespace is then namespace/name. The type requires a namespace, so an +// address without one, or with an empty segment, is rejected. +func splitTerraform(name string) (namespace, pkgName string, ok bool) { + i := strings.LastIndexByte(name, '/') + if i <= 0 || i == len(name)-1 || name[0] == '/' || strings.Contains(name, "//") { + return "", "", false + } + return name[:i], name[i+1:], true +} + func splitVSCodeExtension(name string) (publisher, extension string, ok bool) { publisher, extension, ok = strings.Cut(name, ".") if !ok || publisher == "" || extension == "" { diff --git a/makepurl_test.go b/makepurl_test.go index 86feb7d..1970800 100644 --- a/makepurl_test.go +++ b/makepurl_test.go @@ -95,6 +95,18 @@ func TestBuildPURLString(t *testing.T) { {"swift registry identity", "swift", "apple.swift-argument-parser", "1.8.2", "", "pkg:swift/apple/swift-argument-parser@1.8.2"}, {"swift registry path", "swift", "apple/swift-argument-parser", "1.8.2", "", ""}, {"swift registry URL path", "swift", "/apple/swift-argument-parser", "1.8.2", "", ""}, + {"terraform provider", "terraform", "hashicorp/random", "3.6.0", "", "pkg:terraform/hashicorp/random@3.6.0"}, + {"terraform module", "terraform", "hashicorp/consul/aws", "0.11.0", "", "pkg:terraform/hashicorp/consul/aws@0.11.0"}, + {"terraform case-sensitive", "terraform", "HashiCorp/Random", "3.6.0", "", "pkg:terraform/HashiCorp/Random@3.6.0"}, + {"terraform default registry ignored", "terraform", "hashicorp/random", "3.6.0", "https://registry.terraform.io", "pkg:terraform/hashicorp/random@3.6.0"}, + {"terraform other registry", "terraform", "hashicorp/random", "3.6.0", "https://registry.opentofu.org", "pkg:terraform/hashicorp/random@3.6.0?repository_url=https:%2F%2Fregistry.opentofu.org"}, + {"terraform without namespace", "terraform", "random", "3.6.0", "", ""}, + {"terraform empty name", "terraform", "hashicorp/", "3.6.0", "", ""}, + {"terraform empty namespace", "terraform", "/random", "3.6.0", "", ""}, + {"terraform slash-only namespace", "terraform", "//random", "3.6.0", "", ""}, + {"terraform leading slash", "terraform", "/hashicorp/random", "3.6.0", "", ""}, + {"terraform empty segment", "terraform", "hashicorp//random", "3.6.0", "", ""}, + {"terraform module empty segment", "terraform", "hashicorp/consul//aws", "0.11.0", "", ""}, } for _, tt := range tests { @@ -126,6 +138,9 @@ func TestBuildPURLStringMatchesMakePURL(t *testing.T) { {"npm", "lodash", "^1.0.0", ""}, {"npm", "pkg", "1.0.0", "https://custom.registry.com"}, {"swift", "github.com/apple/swift-argument-parser", "1.8.2", ""}, + {"terraform", "hashicorp/random", "3.6.0", ""}, + {"terraform", "hashicorp/consul/aws", "0.11.0", ""}, + {"terraform", "hashicorp/random", "3.6.0", "https://registry.opentofu.org"}, } for _, tt := range cases { diff --git a/spec_test.go b/spec_test.go index 75cd1be..928d3cf 100644 --- a/spec_test.go +++ b/spec_test.go @@ -33,6 +33,7 @@ func TestSpecTypeMetadata(t *testing.T) { "deno": "Deno registry support", "elm": "Elm registry support", "homebrew": "Legacy type retained alongside brew for compatibility", + "terraform": "Terraform providers and modules, proposed in purl-spec#889", } for _, name := range purl.KnownTypes() { if !registered[name] && localTypes[name] == "" { diff --git a/types.json b/types.json index 1c77a64..43aca02 100644 --- a/types.json +++ b/types.json @@ -585,6 +585,15 @@ } } }, + "terraform": { + "description": "Terraform providers and modules, proposed in purl-spec#889", + "default_registry": "https://registry.terraform.io", + "namespace_requirement": "required", + "examples": [ + "pkg:terraform/hashicorp/aws@6.0.0", + "pkg:terraform/terraform-aws-modules/vpc@6.0.1" + ] + }, "vcpkg": { "description": "Packages from the vcpkg C/C++ package manager.", "default_registry": null, diff --git a/types_test.go b/types_test.go index d1ff3a8..2593a93 100644 --- a/types_test.go +++ b/types_test.go @@ -81,6 +81,7 @@ func TestIsKnownType(t *testing.T) { {"npm", true}, {"pypi", true}, {"cargo", true}, + {"terraform", true}, {"unknown-type", false}, {"", false}, } @@ -102,6 +103,7 @@ func TestDefaultRegistry(t *testing.T) { {"npm", "https://registry.npmjs.org"}, {"pypi", "https://pypi.org"}, {"cargo", "https://crates.io"}, + {"terraform", "https://registry.terraform.io"}, {"apk", ""}, // no default registry {"deb", ""}, // no default registry {"unknown", ""}, @@ -128,6 +130,7 @@ func TestNamespaceRequirement(t *testing.T) { {"cran", false, true}, {"npm", false, false}, // optional {"cargo", false, true}, + {"terraform", true, false}, } for _, tt := range tests {