diff --git a/defaults.go b/defaults.go index adb1c3c..605d717 100644 --- a/defaults.go +++ b/defaults.go @@ -11,8 +11,12 @@ func IsDefaultRegistry(purlType, registryURL string) bool { return true } - cfg := TypeInfo(purlType) - if cfg == nil || cfg.DefaultRegistry == nil { + data, err := loadTypes() + if err != nil { + return false + } + cfg, ok := data.Types[purlType] + if !ok || cfg.DefaultRegistry == nil { return false } @@ -21,8 +25,11 @@ func IsDefaultRegistry(purlType, registryURL string) bool { return false } - // Compare hosts - defaultHost := extractHost(defaultURL) + defaultHost, ok := data.defaultHosts[defaultURL] + if !ok { + // TypeInfo exposes the default URL through a shared pointer. + defaultHost = extractHost(defaultURL) + } givenHost := extractHost(registryURL) if defaultHost == "" || givenHost == "" { diff --git a/defaults_cache_test.go b/defaults_cache_test.go new file mode 100644 index 0000000..4a630f5 --- /dev/null +++ b/defaults_cache_test.go @@ -0,0 +1,96 @@ +package purl + +import ( + "net/url" + "strings" + "testing" +) + +func TestBuildPURLRegistryHostSemantics(t *testing.T) { + for _, tc := range []struct { + registry string + isDefault bool + }{ + {"https://registry.npmjs.org/package/-/package.tgz", true}, + {"http://registry.npmjs.org:8080/package", true}, + {"//registry.npmjs.org/package", true}, + {"https://cdn.registry.npmjs.org/package", true}, + {"https://user:pass@registry.npmjs.org/package", true}, + {"https://evil.example@registry.npmjs.org/package", true}, + {"https://registry.npmjs.org@evil.example/package", false}, + {"https://registry.npmjs.org.evil.example/package", false}, + {"https://notregistry.npmjs.org/package", false}, + {"https://REGISTRY.NPMJS.ORG/package", false}, + {"https://registry.npmjs.org./package", false}, + {"https://registry.npmjs.org:bad/package", false}, + {"https://registry.npmjs.org/%zz", false}, + {"https://[::1]/package", false}, + {"registry.npmjs.org/package", false}, + } { + t.Run(tc.registry, func(t *testing.T) { + got := BuildPURLString("npm", "package", "1.2.3", tc.registry) + if tc.isDefault { + if got != "pkg:npm/package@1.2.3" { + t.Fatalf("default registry retained: %q", got) + } + } else if !strings.HasPrefix(got, "pkg:npm/package@1.2.3?repository_url=") { + t.Fatalf("registry lost: %q", got) + } + }) + } +} + +func TestDefaultRegistryPointerChanges(t *testing.T) { + cfg := TypeInfo("npm") + original := *cfg.DefaultRegistry + t.Cleanup(func() { *cfg.DefaultRegistry = original }) + for _, registry := range []string{"https://custom.example/registry", "https://crates.io", "", "https://[invalid", original} { + *cfg.DefaultRegistry = registry + for _, input := range []string{original, "https://custom.example/package.tgz", "https://crates.io", ""} { + want := uncachedDefaultRegistry("npm", input) + if got := IsDefaultRegistry("npm", input); got != want { + t.Fatalf("default %q, input %q: got %t, want %t", registry, input, got, want) + } + got := BuildPURLString("npm", "package", "1.2.3", input) + if strings.Contains(got, "?repository_url=") == want { + t.Fatalf("default %q, input %q: unexpected PURL %q", registry, input, got) + } + } + } +} + +func uncachedDefaultRegistry(purlType, registry string) bool { + if registry == "" { + return true + } + cfg := TypeInfo(purlType) + if cfg == nil || cfg.DefaultRegistry == nil { + return false + } + standard, err := url.Parse(*cfg.DefaultRegistry) + if err != nil || standard.Hostname() == "" { + return false + } + given, err := url.Parse(registry) + if err != nil || given.Hostname() == "" { + return false + } + return given.Hostname() == standard.Hostname() || strings.HasSuffix(given.Hostname(), "."+standard.Hostname()) +} + +func FuzzDefaultRegistryCompatibility(f *testing.F) { + for _, kind := range append(KnownTypes(), "unknown") { + f.Add(kind, DefaultRegistry(kind)) + f.Add(kind, "https://user@registry.npmjs.org:443/package.tgz") + f.Add(kind, "https://registry.npmjs.org@evil.example/%zz") + } + f.Fuzz(func(t *testing.T, kind, registry string) { + want := uncachedDefaultRegistry(kind, registry) + if got := IsDefaultRegistry(kind, registry); got != want { + t.Fatalf("IsDefaultRegistry(%q, %q) = %t, want %t", kind, registry, got, want) + } + if got := IsNonDefaultRegistry(kind, registry); got == want { + t.Fatalf("IsNonDefaultRegistry(%q, %q) = %t, want %t", kind, registry, got, !want) + } + }) +} diff --git a/makepurl_bench_test.go b/makepurl_bench_test.go new file mode 100644 index 0000000..816eaff --- /dev/null +++ b/makepurl_bench_test.go @@ -0,0 +1,89 @@ +package purl + +import ( + "fmt" + "testing" +) + +var benchmarkRegistryDefault bool + +var resolvedURLCases = []struct { + name string + registry string + want string +}{ + {"none", "", "pkg:npm/%40scope/package@1.2.3"}, + {"default", "https://registry.npmjs.org/@scope/package/-/package-1.2.3.tgz", "pkg:npm/%40scope/package@1.2.3"}, + {"subdomain", "https://cdn.registry.npmjs.org/@scope/package/-/package-1.2.3.tgz", "pkg:npm/%40scope/package@1.2.3"}, + {"private", "https://npm.example.invalid/@scope/package/-/package-1.2.3.tgz", "pkg:npm/%40scope/package@1.2.3?repository_url=https:%2F%2Fnpm.example.invalid%2F%40scope%2Fpackage%2F-%2Fpackage-1.2.3.tgz"}, +} + +func TestBuildPURLResolvedURLs(t *testing.T) { + for _, tc := range resolvedURLCases { + t.Run(tc.name, func(t *testing.T) { + if got := BuildPURLString("npm", "@scope/package", "1.2.3", tc.registry); got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func BenchmarkBuildPURLResolvedURL(b *testing.B) { + for _, tc := range resolvedURLCases { + b.Run(tc.name, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + benchmarkVersionPURLString = BuildPURLString("npm", "@scope/package", "1.2.3", tc.registry) + } + if benchmarkVersionPURLString != tc.want { + b.Fatalf("got %q, want %q", benchmarkVersionPURLString, tc.want) + } + }) + } +} + +func BenchmarkBuildPURLResolvedURLMixed(b *testing.B) { + const count = 1000 + type input struct{ name, url, want string } + inputs := make([]input, count) + for i := range inputs { + name := fmt.Sprintf("@scope/package-%d", i) + inputs[i] = input{ + name: name, + url: "https://registry.npmjs.org/" + name + "/-/package-1.2.3.tgz", + want: fmt.Sprintf("pkg:npm/%%40scope/package-%d@1.2.3", i), + } + if got := BuildPURLString("npm", name, "1.2.3", inputs[i].url); got != inputs[i].want { + b.Fatalf("got %q, want %q", got, inputs[i].want) + } + } + i := 0 + b.ReportAllocs() + for b.Loop() { + in := inputs[i%count] + benchmarkVersionPURLString = BuildPURLString("npm", in.name, "1.2.3", in.url) + i++ + } +} + +func BenchmarkDefaultRegistryResolvedURL(b *testing.B) { + for _, tc := range resolvedURLCases { + b.Run(tc.name, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + benchmarkRegistryDefault = IsDefaultRegistry("npm", tc.registry) + } + }) + } +} + +func BenchmarkCleanVersionNPM(b *testing.B) { + for _, version := range []string{"1.2.3", "1.2.3-beta.1", "^1.2.3"} { + b.Run(version, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + benchmarkVersionPURLString = CleanVersion(version, "npm") + } + }) + } +} diff --git a/types.go b/types.go index 7776524..289d66f 100644 --- a/types.go +++ b/types.go @@ -55,11 +55,12 @@ func (t *TypeConfig) NamespaceProhibited() bool { } type typesData struct { - Version string `json:"version"` - Description string `json:"description"` - Source string `json:"source"` - LastUpdated string `json:"last_updated"` - Types map[string]TypeConfig `json:"types"` + defaultHosts map[string]string + Version string `json:"version"` + Description string `json:"description"` + Source string `json:"source"` + LastUpdated string `json:"last_updated"` + Types map[string]TypeConfig `json:"types"` } var ( @@ -72,6 +73,13 @@ func loadTypes() (*typesData, error) { loadOnce.Do(func() { loadedData = &typesData{} loadErr = json.Unmarshal(typesJSON, loadedData) + loadedData.defaultHosts = make(map[string]string) + for _, cfg := range loadedData.Types { + if cfg.DefaultRegistry != nil { + registry := *cfg.DefaultRegistry + loadedData.defaultHosts[registry] = extractHost(registry) + } + } }) return loadedData, loadErr }