Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 07:39:19 -05:00
pkg-proxy/internal/inventory/inventory_test.go

337 lines
15 KiB
Go
Raw Permalink Normal View History

package inventory
import (
"errors"
"os"
"slices"
"strings"
"testing"
"github.com/git-pkgs/proxy/internal/packageurl"
)
func TestParseSyftCycloneDX(t *testing.T) {
data, err := os.ReadFile("testdata/syft-cyclonedx.json")
if err != nil {
t.Fatal(err)
}
components, skipped, err := Parse(data)
if err != nil {
t.Fatalf("Parse: %v", err)
}
want := []Component{
{Ecosystem: "npm", Name: "@babel/core", Version: "7.23.0", PackagePURL: "pkg:npm/%40babel/core"},
{Ecosystem: "alpine", Name: "busybox", Version: "1.36.1-r15", PackagePURL: "pkg:apk/alpine/busybox"},
{Ecosystem: "golang", Name: "github.com/inconshreveable/mousetrap", Version: "v1.1.0", PackagePURL: "pkg:golang/github.com/inconshreveable/mousetrap"},
{Ecosystem: "golang", Name: "github.com/spf13/cobra", Version: "v1.8.0", PackagePURL: "pkg:golang/github.com/spf13/cobra"},
{Ecosystem: "golang", Name: "github.com/spf13/pflag", Version: "v1.0.5", PackagePURL: "pkg:golang/github.com/spf13/pflag"},
{Ecosystem: "npm", Name: "lodash", Version: "4.17.20", PackagePURL: "pkg:npm/lodash"},
{Ecosystem: "composer", Name: "monolog/monolog", Version: "3.5.0", PackagePURL: "pkg:composer/monolog/monolog"},
{Ecosystem: "alpine", Name: "musl", Version: "1.2.4-r2", PackagePURL: "pkg:apk/alpine/musl"},
{Ecosystem: "composer", Name: "symfony/console", Version: "v6.4.1", PackagePURL: "pkg:composer/symfony/console"},
}
if !slices.Equal(components, want) {
t.Errorf("components =\n%v\nwant\n%v", components, want)
}
// The operating system and the four lock files syft lists carry no PURL,
// and webapp is the root of package-lock.json, the project itself.
if skipped != 6 {
t.Errorf("skipped = %d, want 6", skipped)
}
}
func TestParseDeduplicatesAndSkipsUnknownTypes(t *testing.T) {
doc := `{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
{"type":"library","name":"lodash","version":"4.17.21","purl":"pkg:npm/lodash@4.17.21"},
{"type":"library","name":"lodash","version":"4.17.21","purl":"pkg:npm/lodash@4.17.21"},
{"type":"library","name":"lodash","version":"4.17.20","purl":"pkg:npm/lodash@4.17.20"},
{"type":"library","name":"checkout","version":"v4","purl":"pkg:github/actions/checkout@v4"},
{"type":"library","name":"Django","version":"4.2.0","purl":"pkg:pypi/Django"},
{"type":"library","name":"guava","version":"32.1.3-jre","purl":"pkg:maven/com.google.guava/guava@32.1.3-jre?type=jar"},
{"type":"library","name":"libc6","version":"2.36-9","purl":"pkg:deb/debian/libc6@2.36-9?arch=amd64&distro=debian-12"},
{"type":"library","name":"broken","purl":"not a purl"}
]}`
components, skipped, err := Parse([]byte(doc))
if err != nil {
t.Fatal(err)
}
want := []Component{
{Ecosystem: "npm", Name: "lodash", Version: "4.17.21", PackagePURL: "pkg:npm/lodash"},
{Ecosystem: "npm", Name: "lodash", Version: "4.17.20", PackagePURL: "pkg:npm/lodash"},
{Ecosystem: "pypi", Name: "django", Version: "4.2.0", PackagePURL: "pkg:pypi/django"},
{Ecosystem: "maven", Name: "com.google.guava:guava", Version: "32.1.3-jre", PackagePURL: "pkg:maven/com.google.guava/guava"},
{Ecosystem: "deb", Name: "libc6", Version: "2.36-9", PackagePURL: "pkg:deb/libc6"},
}
if !slices.Equal(components, want) {
t.Errorf("components =\n%v\nwant\n%v", components, want)
}
if skipped != 2 {
t.Errorf("skipped = %d, want 2", skipped)
}
}
func TestParseRejectsNonSBOM(t *testing.T) {
if _, _, err := Parse([]byte(`{"hello":"world"}`)); err == nil {
t.Error("Parse accepted a document that is not an SBOM")
}
}
func TestFromPURLUnsupported(t *testing.T) {
_, err := FromPURL("pkg:github/actions/checkout")
if !errors.Is(err, ErrUnsupportedPURL) {
t.Errorf("err = %v, want ErrUnsupportedPURL", err)
}
}
// TestFromPURLMatchesHandlerNames checks, for every PURL type the inventory
// maps, that an SBOM's package URL ends up as the package URL the handler
// records when it caches that package. handlerName is the name the handler
// passes to GetOrFetchArtifactFromURL, taken from the handler's code.
func TestFromPURLMatchesHandlerNames(t *testing.T) {
tests := []struct {
purlType string
sbomPURL string
handlerName string
}{
{"alpm", "pkg:alpm/arch/glibc@2.39-1?arch=x86_64", "glibc"},
{"apk", "pkg:apk/alpine/busybox@1.36.1-r15?arch=x86_64", "busybox"},
{"bsd", "pkg:bsd/freebsd/curl@8.7.1", "curl"},
{"cargo", "pkg:cargo/serde@1.0.200", "serde"},
{"chef", "pkg:chef/nginx@12.0.0", "nginx"},
{"composer", "pkg:composer/symfony/console@v6.4.1", "symfony/console"},
{"conan", "pkg:conan/zlib@1.3.1", "zlib"},
{"conda", "pkg:conda/numpy@1.26.4?channel=conda-forge&subdir=linux-64", "conda-forge/numpy"},
{"cran", "pkg:cran/ggplot2@3.5.1", "ggplot2"},
{"deb", "pkg:deb/debian/libc6@2.36-9?arch=amd64&distro=debian-12", "libc6"},
{"gem", "pkg:gem/rails@7.1.3", "rails"},
// The go handler stores the module path with its case; PURLs of
// golang modules are lowercased on both sides.
{"golang", "pkg:golang/github.com/Azure/azure-sdk-for-go@v68.0.0", "github.com/Azure/azure-sdk-for-go"},
{"hex", "pkg:hex/phoenix@1.7.12", "phoenix"},
{"julia", "pkg:julia/Example@0.5.3?uuid=7876af07-990d-54b4-ab0e-23690620f79a", "Example"},
{"maven", "pkg:maven/org.slf4j/slf4j-api@2.0.13?type=jar", "org.slf4j:slf4j-api"},
{"npm", "pkg:npm/%40babel/core@7.23.0", "@babel/core"},
// The NuGet handler lowercases the package ID.
{"nuget", "pkg:nuget/Newtonsoft.Json@13.0.3", "newtonsoft.json"},
{"pub", "pkg:pub/http@1.2.1", "http"},
// The PyPI handler takes the name from the wheel or sdist file name,
// which spells "." and "-" as "_".
{"pypi", "pkg:pypi/zope.interface@6.4", "zope_interface"},
{"rpm", "pkg:rpm/fedora/curl@8.6.0-7.fc40?arch=x86_64", "curl"},
{"swift", "pkg:swift/github.com/apple/swift-argument-parser@1.3.1", "github.com/apple/swift-argument-parser"},
}
covered := map[string]bool{}
for _, tt := range tests {
t.Run(tt.purlType, func(t *testing.T) {
covered[tt.purlType] = true
c, err := FromPURL(tt.sbomPURL)
if err != nil {
t.Fatalf("FromPURL(%q): %v", tt.sbomPURL, err)
}
want, _ := packageurl.MakeCacheStrings(packageurl.HandlerEcosystem(tt.purlType), tt.handlerName, "")
if want == "" {
t.Fatalf("handler name %q has no package URL", tt.handlerName)
}
if c.PackagePURL != want {
t.Errorf("PackagePURL = %q, want %q as the handler records it", c.PackagePURL, want)
}
})
}
for purlType := range servedTypes {
if !covered[purlType] {
t.Errorf("no case for PURL type %q", purlType)
}
}
}
func TestFromPURLNormalizesPyPINames(t *testing.T) {
for _, s := range []string{"pkg:pypi/zope.interface", "pkg:pypi/Zope_Interface", "pkg:pypi/zope--interface", "pkg:pypi/zope._-interface"} {
c, err := FromPURL(s)
if err != nil {
t.Fatal(err)
}
if c.Name != "zope-interface" || c.PackagePURL != "pkg:pypi/zope-interface" {
t.Errorf("FromPURL(%q) = %+v, want zope-interface", s, c)
}
}
}
func TestFromPURLCondaNeedsChannel(t *testing.T) {
if _, err := FromPURL("pkg:conda/numpy@1.26.4"); !errors.Is(err, ErrNoCondaChannel) {
t.Errorf("err = %v, want ErrNoCondaChannel", err)
}
doc := `{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
{"type":"library","name":"numpy","version":"1.26.4","purl":"pkg:conda/numpy@1.26.4"},
{"type":"library","name":"numpy","version":"1.26.4","purl":"pkg:conda/numpy@1.26.4?channel=conda-forge"}
]}`
components, skipped, err := Parse([]byte(doc))
if err != nil {
t.Fatal(err)
}
if len(components) != 1 || components[0].Name != "conda-forge/numpy" || skipped != 1 {
t.Errorf("components = %+v, skipped = %d; want conda-forge/numpy and 1 skipped", components, skipped)
}
}
func TestValidateProjectName(t *testing.T) {
valid := []string{"repo", "group/repo", "group/sub/my_repo-2.0", strings.Repeat("a", MaxProjectNameLength)}
for _, name := range valid {
if err := ValidateProjectName(name); err != nil {
t.Errorf("ValidateProjectName(%q) = %v, want nil", name, err)
}
}
invalid := []string{
"", "/repo", "repo/", "group//repo", ".", "..", "group/../repo", "group/./repo",
"group/re po", "group/repo?x", "group\\repo", "grüppe/repo", "group/repo%2F",
strings.Repeat("a", MaxProjectNameLength+1),
}
for _, name := range invalid {
if err := ValidateProjectName(name); err == nil {
t.Errorf("ValidateProjectName(%q) = nil, want an error", name)
}
}
}
func TestOutdated(t *testing.T) {
tests := []struct {
name string
ecosystem string
version string
other string
outdated bool
}{
{"npm older", "npm", "4.17.20", "4.17.21", true},
{"npm same", "npm", "4.17.21", "4.17.21", false},
{"npm newer", "npm", "4.18.0", "4.17.21", false},
{"composer v prefix", "composer", "v6.4.1", "v6.4.2", true},
{"golang", "golang", "v1.8.0", "v1.10.0", true},
{"alpine revisions", "alpine", "1.36.1-r15", "1.36.1-r2", false},
{"empty version", "npm", "", "1.0.0", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Outdated(tt.ecosystem, tt.version, tt.other); got != tt.outdated {
t.Errorf("Outdated = %v, want %v", got, tt.outdated)
}
})
}
}
func TestParseSkipsTheProjectsOwnPackages(t *testing.T) {
doc := `{"bomFormat":"CycloneDX","specVersion":"1.6","components":[
{"bom-ref":"mod","type":"library","name":"github.com/acme/app","version":"UNKNOWN","purl":"pkg:golang/github.com/acme/app"},
{"bom-ref":"bin","type":"library","name":"github.com/acme/app","version":"v0.0.0-20261027162038-a3e9dd9fd3f4+dirty","purl":"pkg:golang/github.com/acme/app@v0.0.0-20261027162038-a3e9dd9fd3f4%2Bdirty",
"properties":[{"name":"syft:metadata:mainModule","value":"github.com/acme/app"}]},
{"bom-ref":"cobra","type":"library","name":"github.com/spf13/cobra","version":"v1.8.0","purl":"pkg:golang/github.com/spf13/cobra@v1.8.0",
"properties":[{"name":"syft:metadata:mainModule","value":"github.com/acme/app"}]},
{"bom-ref":"root","type":"library","name":"app-frontend","version":"2.1.0","purl":"pkg:npm/app-frontend@2.1.0",
"properties":[{"name":"syft:package:metadataType","value":"javascript-npm-package-lock-entry"},{"name":"syft:location:0:path","value":"/package-lock.json"}]},
{"bom-ref":"nover","type":"library","name":"app-tools","purl":"pkg:npm/app-tools"},
{"bom-ref":"jsdom","type":"library","name":"jsdom","version":"30.1.3","purl":"pkg:npm/jsdom@30.1.3",
"properties":[{"name":"syft:package:metadataType","value":"javascript-npm-package-lock-entry"},{"name":"syft:location:0:path","value":"/package-lock.json"}]}
],"dependencies":[{"ref":"root","dependsOn":["jsdom"]}]}`
components, skipped, err := Parse([]byte(doc))
if err != nil {
t.Fatal(err)
}
want := []Component{
{Ecosystem: "golang", Name: "github.com/spf13/cobra", Version: "v1.8.0", PackagePURL: "pkg:golang/github.com/spf13/cobra"},
{Ecosystem: "npm", Name: "jsdom", Version: "30.1.3", PackagePURL: "pkg:npm/jsdom"},
}
if !slices.Equal(components, want) {
t.Errorf("components =\n%v\nwant\n%v", components, want)
}
if skipped != 4 {
t.Errorf("skipped = %d, want 4", skipped)
}
}
func TestParseKeepsTopLevelDependenciesOfLockFiles(t *testing.T) {
// A poetry.lock doesn't list the project, so requests is a direct
// dependency, not the project's own entry.
doc := `{"bomFormat":"CycloneDX","specVersion":"1.6","components":[
{"bom-ref":"requests","type":"library","name":"requests","version":"2.32.3","purl":"pkg:pypi/requests@2.32.3",
"properties":[{"name":"syft:package:metadataType","value":"python-poetry-lock-entry"},{"name":"syft:location:0:path","value":"/poetry.lock"}]},
{"bom-ref":"urllib3","type":"library","name":"urllib3","version":"2.2.3","purl":"pkg:pypi/urllib3@2.2.3",
"properties":[{"name":"syft:package:metadataType","value":"python-poetry-lock-entry"},{"name":"syft:location:0:path","value":"/poetry.lock"}]}
],"dependencies":[{"ref":"requests","dependsOn":["urllib3"]}]}`
components, skipped, err := Parse([]byte(doc))
if err != nil {
t.Fatal(err)
}
if len(components) != 2 || skipped != 0 {
t.Errorf("components = %+v, skipped = %d; want requests and urllib3, 0 skipped", components, skipped)
}
}
func TestParseKeepsOSPackagesNothingDependsOn(t *testing.T) {
// syft's CycloneDX of an image: metadata.component is the image, with
// no edges, and busybox is something nothing else depends on.
doc := `{"bomFormat":"CycloneDX","specVersion":"1.6",
"metadata":{"component":{"bom-ref":"image","type":"container","name":"alpine:3.20"}},
"components":[
{"bom-ref":"busybox","type":"library","name":"busybox","version":"1.36.1-r29","purl":"pkg:apk/alpine/busybox@1.36.1-r29?distro=alpine-3.20.3",
"properties":[{"name":"syft:package:metadataType","value":"apk-db-entry"}]},
{"bom-ref":"musl","type":"library","name":"musl","version":"1.2.5-r0","purl":"pkg:apk/alpine/musl@1.2.5-r0?distro=alpine-3.20.3",
"properties":[{"name":"syft:package:metadataType","value":"apk-db-entry"}]}
],"dependencies":[{"ref":"busybox","dependsOn":["musl"]}]}`
components, skipped, err := Parse([]byte(doc))
if err != nil {
t.Fatal(err)
}
if len(components) != 2 || skipped != 0 {
t.Errorf("components = %+v, skipped = %d; want busybox and musl, 0 skipped", components, skipped)
}
}
func TestParseSkipsTheDeclaredRoot(t *testing.T) {
doc := `{"bomFormat":"CycloneDX","specVersion":"1.6",
"metadata":{"component":{"bom-ref":"app","type":"application","name":"app","version":"1.0.0","purl":"pkg:npm/app@1.0.0"}},
"components":[
{"bom-ref":"lodash","type":"library","name":"lodash","version":"4.17.21","purl":"pkg:npm/lodash@4.17.21"}
],"dependencies":[{"ref":"app","dependsOn":["lodash"]}]}`
components, _, err := Parse([]byte(doc))
if err != nil {
t.Fatal(err)
}
if len(components) != 1 || components[0].Name != "lodash" {
t.Errorf("components = %+v, want only lodash", components)
}
}
func TestParseSkipsMixedCaseMainModule(t *testing.T) {
// syft keeps the module path's case in mainModule, and so does the
// package URL.
doc := `{"bomFormat":"CycloneDX","specVersion":"1.6","components":[
{"bom-ref":"bin","type":"library","name":"github.com/Azure/foo","version":"v1.2.0","purl":"pkg:golang/github.com/Azure/foo@v1.2.0",
"properties":[{"name":"syft:metadata:mainModule","value":"github.com/Azure/foo"}]},
{"bom-ref":"cobra","type":"library","name":"github.com/spf13/cobra","version":"v1.8.0","purl":"pkg:golang/github.com/spf13/cobra@v1.8.0",
"properties":[{"name":"syft:metadata:mainModule","value":"github.com/Azure/foo"}]}
]}`
components, skipped, err := Parse([]byte(doc))
if err != nil {
t.Fatal(err)
}
if len(components) != 1 || components[0].Name != "github.com/spf13/cobra" || skipped != 1 {
t.Errorf("components = %+v, skipped = %d; want only cobra, 1 skipped", components, skipped)
}
}
func TestParseMainModuleKeepsCase(t *testing.T) {
// Go module paths are case-sensitive, and package URLs keep their case,
// so github.com/azure/foo is another module than the main module
// github.com/Azure/foo, and a dependency.
doc := `{"bomFormat":"CycloneDX","specVersion":"1.6","components":[
{"bom-ref":"bin","type":"library","name":"github.com/Azure/foo","version":"v1.2.0","purl":"pkg:golang/github.com/Azure/foo@v1.2.0",
"properties":[{"name":"syft:metadata:mainModule","value":"github.com/Azure/foo"}]},
{"bom-ref":"other","type":"library","name":"github.com/azure/foo","version":"v0.1.0","purl":"pkg:golang/github.com/azure/foo@v0.1.0",
"properties":[{"name":"syft:metadata:mainModule","value":"github.com/Azure/foo"}]}
]}`
components, skipped, err := Parse([]byte(doc))
if err != nil {
t.Fatal(err)
}
if len(components) != 1 || components[0].Name != "github.com/azure/foo" || skipped != 1 {
t.Errorf("components = %+v, skipped = %d; want only github.com/azure/foo, 1 skipped", components, skipped)
}
}