package apk

import (
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"
	"github.com/google/uuid"
	"github.com/scylladb/go-set/strset"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/anchore/grype/grype/distro"
	"github.com/anchore/grype/grype/match"
	"github.com/anchore/grype/grype/pkg"
	"github.com/anchore/grype/grype/version"
	"github.com/anchore/grype/grype/vulnerability"
	"github.com/anchore/grype/grype/vulnerability/mock"
	"github.com/anchore/syft/syft/artifact"
	"github.com/anchore/syft/syft/cpe"
	syftPkg "github.com/anchore/syft/syft/pkg"
)

func TestSecDBOnlyMatch(t *testing.T) {
	secDbVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			// ID doesn't match - this is the key for comparison in the matcher
			ID:        "CVE-2020-2",
			Namespace: "secdb:distro:alpine:3.12",
		},
		PackageName: "libvncserver",
		Constraint:  version.MustGetConstraint("<= 0.9.11", version.ApkFormat),
	}

	vp := mock.VulnerabilityProvider(secDbVuln)

	m := Matcher{}
	d := distro.New(distro.Alpine, "3.12.0", "")

	p := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "libvncserver",
		Version: "0.9.9",
		Type:    syftPkg.ApkPkg,
		Distro:  d,
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:a:*:libvncserver:0.9.9:*:*:*:*:*:*:*", ""),
		},
	}

	expected := []match.Match{
		{

			Vulnerability: secDbVuln,
			Package:       p,
			Details: []match.Detail{
				{
					Type:       match.ExactDirectMatch,
					Confidence: 1.0,
					SearchedBy: match.DistroParameters{
						Distro: match.DistroIdentification{
							Type:    d.Type.String(),
							Version: d.Version,
						},
						Package: match.PackageParameter{
							Name:    "libvncserver",
							Version: "0.9.9",
						},
						Namespace: "secdb:distro:alpine:3.12",
					},
					Found: match.DistroResult{
						VulnerabilityID:   "CVE-2020-2",
						VersionConstraint: secDbVuln.Constraint.String(),
					},
					Matcher: match.ApkMatcher,
				},
			},
		},
	}

	actual, _, err := m.Match(vp, p)
	assert.NoError(t, err)

	assertMatches(t, expected, actual)
}

func TestCPEConstraintVersionsOtherThanAPK(t *testing.T) {
	nvdVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			ID:        "CVE-2020-1234",
			Namespace: "nvd:cpe",
		},
		PackageName: "libvncserver",
		Constraint:  version.MustGetConstraint("<= 0.9.11", version.GolangFormat),
		CPEs: []cpe.CPE{
			cpe.Must(`cpe:2.3:a:lib_vnc_project-\(server\):libvncserver:*:*:*:*:*:*:*:*`, ""),
		},
	}

	vp := mock.VulnerabilityProvider(nvdVuln)

	m := Matcher{}
	d := distro.New(distro.Alpine, "3.12.0", "")

	p := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "libvncserver",
		Version: "0.9.9",
		Type:    syftPkg.ApkPkg,
		Distro:  d,
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:a:*:libvncserver:0.9.9:*:*:*:*:*:*:*", ""),
		},
	}

	matches, _, err := m.Match(vp, p)
	require.NoError(t, err)
	require.Len(t, matches, 1)
	require.Equal(t, nvdVuln, matches[0].Vulnerability)
}

func TestBothSecdbAndNvdMatches(t *testing.T) {
	// NVD and Alpine's secDB both have the same CVE ID for the package
	nvdVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			ID:        "CVE-2020-1",
			Namespace: "nvd:cpe",
		},
		PackageName: "libvncserver",
		Constraint:  version.MustGetConstraint("<= 0.9.11", version.UnknownFormat),
		CPEs: []cpe.CPE{
			cpe.Must(`cpe:2.3:a:lib_vnc_project-\(server\):libvncserver:*:*:*:*:*:*:*:*`, ""),
		},
	}

	secDbVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			// ID *does* match - this is the key for comparison in the matcher
			ID:        "CVE-2020-1",
			Namespace: "secdb:distro:alpine:3.12",
		},
		PackageName: "libvncserver",
		Constraint:  version.MustGetConstraint("<= 0.9.11", version.ApkFormat),
	}

	vp := mock.VulnerabilityProvider(nvdVuln, secDbVuln)

	m := Matcher{}
	d := distro.New(distro.Alpine, "3.12.0", "")

	p := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "libvncserver",
		Version: "0.9.9",
		Type:    syftPkg.ApkPkg,
		Distro:  d,
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:a:*:libvncserver:0.9.9:*:*:*:*:*:*:*", ""),
		},
	}

	expected := []match.Match{
		{
			// ensure the SECDB record is preferred over the NVD record
			Vulnerability: secDbVuln,
			Package:       p,
			Details: []match.Detail{
				{
					Type:       match.ExactDirectMatch,
					Confidence: 1.0,
					SearchedBy: match.DistroParameters{
						Distro: match.DistroIdentification{
							Type:    d.Type.String(),
							Version: d.Version,
						},
						Package: match.PackageParameter{
							Name:    "libvncserver",
							Version: "0.9.9",
						},
						Namespace: "secdb:distro:alpine:3.12",
					},
					Found: match.DistroResult{
						VulnerabilityID:   "CVE-2020-1",
						VersionConstraint: secDbVuln.Constraint.String(),
					},
					Matcher: match.ApkMatcher,
				},
			},
		},
	}

	actual, _, err := m.Match(vp, p)
	assert.NoError(t, err)

	assertMatches(t, expected, actual)
}

func TestBothSecdbAndNvdMatches_DifferentFixInfo(t *testing.T) {
	// NVD and Alpine's secDB both have the same CVE ID for the package
	nvdVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			ID:        "CVE-2020-1",
			Namespace: "nvd:cpe",
		},
		PackageName: "libvncserver",
		Constraint:  version.MustGetConstraint("< 1.0.0", version.UnknownFormat),
		CPEs: []cpe.CPE{
			cpe.Must(`cpe:2.3:a:lib_vnc_project-\(server\):libvncserver:*:*:*:*:*:*:*:*`, ""),
		},
		Fix: vulnerability.Fix{
			Versions: []string{"1.0.0"},
			State:    vulnerability.FixStateFixed,
		},
	}

	secDbVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			// ID *does* match - this is the key for comparison in the matcher
			ID:        "CVE-2020-1",
			Namespace: "secdb:distro:alpine:3.12",
		},
		PackageName: "libvncserver",
		Constraint:  version.MustGetConstraint("< 0.9.12", version.ApkFormat),
		// SecDB indicates Alpine have backported a fix to v0.9...
		Fix: vulnerability.Fix{
			Versions: []string{"0.9.12"},
			State:    vulnerability.FixStateFixed,
		},
	}
	vp := mock.VulnerabilityProvider(nvdVuln, secDbVuln)
	m := Matcher{}
	d := distro.New(distro.Alpine, "3.12.0", "")

	p := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "libvncserver",
		Version: "0.9.9",
		Type:    syftPkg.ApkPkg,
		Distro:  d,
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:a:*:libvncserver:0.9.9:*:*:*:*:*:*:*", ""),
		},
	}

	expected := []match.Match{
		{
			// ensure the SECDB record is preferred over the NVD record
			Vulnerability: secDbVuln,
			Package:       p,
			Details: []match.Detail{
				{
					Type:       match.ExactDirectMatch,
					Confidence: 1.0,
					SearchedBy: match.DistroParameters{
						Distro: match.DistroIdentification{
							Type:    d.Type.String(),
							Version: d.Version,
						},
						Package: match.PackageParameter{
							Name:    "libvncserver",
							Version: "0.9.9",
						},
						Namespace: "secdb:distro:alpine:3.12",
					},
					Found: match.DistroResult{
						VulnerabilityID:   "CVE-2020-1",
						VersionConstraint: secDbVuln.Constraint.String(),
					},
					Matcher: match.ApkMatcher,
				},
			},
		},
	}

	actual, _, err := m.Match(vp, p)
	assert.NoError(t, err)

	assertMatches(t, expected, actual)
}

func TestBothSecdbAndNvdMatches_DifferentPackageName(t *testing.T) {
	// NVD and Alpine's secDB both have the same CVE ID for the package
	nvdVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			ID:        "CVE-2020-1",
			Namespace: "nvd:cpe",
		},
		PackageName: "libvncserver",
		Constraint:  version.MustGetConstraint("<= 0.9.11", version.UnknownFormat),
		// Note: the product name is NOT the same as the target package name
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:a:lib_vnc_project-(server):libvncumbrellaproject:*:*:*:*:*:*:*:*", ""),
		},
	}

	secDbVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			// ID *does* match - this is the key for comparison in the matcher
			ID:        "CVE-2020-1",
			Namespace: "secdb:distro:alpine:3.12",
		},
		PackageName: "libvncserver",
		Constraint:  version.MustGetConstraint("<= 0.9.11", version.ApkFormat),
	}

	vp := mock.VulnerabilityProvider(nvdVuln, secDbVuln)

	m := Matcher{}
	d := distro.New(distro.Alpine, "3.12.0", "")

	p := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "libvncserver",
		Version: "0.9.9",
		Type:    syftPkg.ApkPkg,
		Distro:  d,
		CPEs: []cpe.CPE{
			// Note: the product name is NOT the same as the package name
			cpe.Must("cpe:2.3:a:*:libvncumbrellaproject:0.9.9:*:*:*:*:*:*:*", ""),
		},
	}

	expected := []match.Match{
		{
			// ensure the SECDB record is preferred over the NVD record
			Vulnerability: secDbVuln,
			Package:       p,
			Details: []match.Detail{
				{
					Type:       match.ExactDirectMatch,
					Confidence: 1.0,
					SearchedBy: match.DistroParameters{
						Distro: match.DistroIdentification{
							Type:    d.Type.String(),
							Version: d.Version,
						},
						Package: match.PackageParameter{
							Name:    "libvncserver",
							Version: "0.9.9",
						},
						Namespace: "secdb:distro:alpine:3.12",
					},
					Found: match.DistroResult{
						VulnerabilityID:   "CVE-2020-1",
						VersionConstraint: secDbVuln.Constraint.String(),
					},
					Matcher: match.ApkMatcher,
				},
			},
		},
	}

	actual, _, err := m.Match(vp, p)
	assert.NoError(t, err)

	assertMatches(t, expected, actual)
}

func TestNvdOnlyMatches(t *testing.T) {
	nvdVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			ID:        "CVE-2020-1",
			Namespace: "nvd:cpe",
		},
		PackageName: "libvncserver",
		Constraint:  version.MustGetConstraint("<= 0.9.11", version.UnknownFormat),
		CPEs: []cpe.CPE{
			cpe.Must(`cpe:2.3:a:lib_vnc_project-\(server\):lib/vncserver:*:*:*:*:*:*:*:*`, ""),
		},
	}
	vp := mock.VulnerabilityProvider(nvdVuln)

	m := Matcher{}
	d := distro.New(distro.Alpine, "3.12.0", "")

	p := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "libvncserver",
		Version: "0.9.9",
		Type:    syftPkg.ApkPkg,
		Distro:  d,
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:a:*:lib/vncserver:0.9.9:*:*:*:*:*:*:*", ""),
		},
	}

	expected := []match.Match{
		{

			Vulnerability: nvdVuln,
			Package:       p,
			Details: []match.Detail{
				{
					Type:       match.CPEMatch,
					Confidence: 0.9,
					SearchedBy: match.CPEParameters{
						CPEs:      []string{"cpe:2.3:a:*:lib\\/vncserver:0.9.9:*:*:*:*:*:*:*"},
						Namespace: "nvd:cpe",
						Package: match.PackageParameter{
							Name:    "libvncserver",
							Version: "0.9.9",
						},
					},
					Found: match.CPEResult{
						// use .String() for proper escaping
						CPEs:              []string{nvdVuln.CPEs[0].Attributes.String()},
						VersionConstraint: nvdVuln.Constraint.String(),
						VulnerabilityID:   "CVE-2020-1",
					},
					Matcher: match.ApkMatcher,
				},
			},
		},
	}

	actual, _, err := m.Match(vp, p)
	assert.NoError(t, err)

	assertMatches(t, expected, actual)
}

func TestNvdOnlyMatches_FixInNvd(t *testing.T) {
	nvdVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			ID:        "CVE-2020-1",
			Namespace: "nvd:cpe",
		},
		PackageName: "libvncserver",
		Constraint:  version.MustGetConstraint("< 0.9.11", version.UnknownFormat),
		CPEs: []cpe.CPE{
			cpe.Must(`cpe:2.3:a:lib_vnc_project-\(server\):libvncserver:*:*:*:*:*:*:*:*`, ""),
		},
		Fix: vulnerability.Fix{
			Versions: []string{"0.9.12"},
			State:    vulnerability.FixStateFixed,
		},
	}
	vp := mock.VulnerabilityProvider(nvdVuln)

	m := Matcher{}
	d := distro.New(distro.Alpine, "3.12.0", "")

	p := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "libvncserver",
		Version: "0.9.9",
		Type:    syftPkg.ApkPkg,
		Distro:  d,
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:a:*:libvncserver:0.9.9:*:*:*:*:*:*:*", ""),
		},
	}

	vulnFound := nvdVuln
	// Important: for alpine matcher, fix version can come from secDB but _not_ from
	// NVD data.
	vulnFound.Fix = vulnerability.Fix{State: vulnerability.FixStateUnknown}

	expected := []match.Match{
		{
			Vulnerability: vulnFound,
			Package:       p,
			Details: []match.Detail{
				{
					Type:       match.CPEMatch,
					Confidence: 0.9,
					SearchedBy: match.CPEParameters{
						CPEs:      []string{"cpe:2.3:a:*:libvncserver:0.9.9:*:*:*:*:*:*:*"},
						Namespace: "nvd:cpe",
						Package: match.PackageParameter{
							Name:    "libvncserver",
							Version: "0.9.9",
						},
					},
					Found: match.CPEResult{
						CPEs:              []string{vulnFound.CPEs[0].Attributes.String()},
						VersionConstraint: vulnFound.Constraint.String(),
						VulnerabilityID:   "CVE-2020-1",
					},
					Matcher: match.ApkMatcher,
				},
			},
		},
	}

	actual, _, err := m.Match(vp, p)
	assert.NoError(t, err)

	assertMatches(t, expected, actual)
}

func TestNvdMatchesProperVersionFiltering(t *testing.T) {
	nvdVulnMatch := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			ID:        "CVE-2020-1",
			Namespace: "nvd:cpe",
		},
		PackageName: "libvncserver",
		Constraint:  version.MustGetConstraint("<= 0.9.11", version.UnknownFormat),
		CPEs: []cpe.CPE{
			cpe.Must(`cpe:2.3:a:lib_vnc_project-\(server\):libvncserver:*:*:*:*:*:*:*:*`, ""),
		},
	}
	nvdVulnNoMatch := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			ID:        "CVE-2020-2",
			Namespace: "nvd:cpe",
		},
		PackageName: "libvncserver",
		Constraint:  version.MustGetConstraint("< 0.9.11", version.UnknownFormat),
		CPEs: []cpe.CPE{
			cpe.Must(`cpe:2.3:a:lib_vnc_project-\(server\):libvncserver:*:*:*:*:*:*:*:*`, ""),
		},
	}
	vp := mock.VulnerabilityProvider(nvdVulnMatch, nvdVulnNoMatch)

	m := Matcher{}
	d := distro.New(distro.Alpine, "3.12.0", "")

	p := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "libvncserver",
		Version: "0.9.11-r10",
		Type:    syftPkg.ApkPkg,
		Distro:  d,
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:a:*:libvncserver:0.9.11:*:*:*:*:*:*:*", ""),
		},
	}

	expected := []match.Match{
		{
			Vulnerability: nvdVulnMatch,
			Package:       p,
			Details: []match.Detail{
				{
					Type:       match.CPEMatch,
					Confidence: 0.9,
					SearchedBy: match.CPEParameters{
						CPEs:      []string{"cpe:2.3:a:*:libvncserver:0.9.11:*:*:*:*:*:*:*"},
						Namespace: "nvd:cpe",
						Package: match.PackageParameter{
							Name:    "libvncserver",
							Version: "0.9.11-r10",
						},
					},
					Found: match.CPEResult{
						CPEs:              []string{nvdVulnMatch.CPEs[0].Attributes.String()},
						VersionConstraint: nvdVulnMatch.Constraint.String(),
						VulnerabilityID:   "CVE-2020-1",
					},
					Matcher: match.ApkMatcher,
				},
			},
		},
	}

	actual, _, err := m.Match(vp, p)
	assert.NoError(t, err)

	assertMatches(t, expected, actual)
}

func TestNvdMatchesWithSecDBFix(t *testing.T) {
	nvdVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			ID:        "CVE-2020-1",
			Namespace: "nvd:cpe",
		},
		PackageName: "libvncserver",
		Constraint:  version.MustGetConstraint("> 0.9.0, < 0.10.0", version.UnknownFormat), // note: this is not normal NVD configuration, but has the desired effect of a "wide net" for vulnerable indication
		CPEs: []cpe.CPE{
			cpe.Must(`cpe:2.3:a:lib_vnc_project-\(server\):libvncserver:*:*:*:*:*:*:*:*`, ""),
		},
	}

	secDbVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			ID:        "CVE-2020-1",
			Namespace: "secdb:distro:alpine:3.12",
		},
		PackageName: "libvncserver",
		Constraint:  version.MustGetConstraint("< 0.9.11", version.ApkFormat), // note: this does NOT include 0.9.11, so NVD and SecDB mismatch here... secDB should trump in this case
	}

	vp := mock.VulnerabilityProvider(nvdVuln, secDbVuln)

	m := Matcher{}
	d := distro.New(distro.Alpine, "3.12.0", "")

	p := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "libvncserver",
		Version: "0.9.11",
		Type:    syftPkg.ApkPkg,
		Distro:  d,
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:a:*:libvncserver:0.9.9:*:*:*:*:*:*:*", ""),
		},
	}

	var expected []match.Match

	actual, _, err := m.Match(vp, p)
	assert.NoError(t, err)

	assertMatches(t, expected, actual)
}

func TestNvdMatchesNoConstraintWithSecDBFix(t *testing.T) {
	nvdVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			ID:        "CVE-2020-1",
			Namespace: "nvd:cpe",
		},
		PackageName: "libvncserver",
		Constraint:  version.MustGetConstraint("", version.UnknownFormat), // note: empty value indicates that all versions are vulnerable
		CPEs: []cpe.CPE{
			cpe.Must(`cpe:2.3:a:lib_vnc_project-\(server\):libvncserver:*:*:*:*:*:*:*:*`, ""),
		},
	}

	secDbVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			ID:        "CVE-2020-1",
			Namespace: "secdb:distro:alpine:3.12",
		},
		PackageName: "libvncserver",
		Constraint:  version.MustGetConstraint("< 0.9.11", version.ApkFormat),
	}

	vp := mock.VulnerabilityProvider(nvdVuln, secDbVuln)

	m := Matcher{}
	d := distro.New(distro.Alpine, "3.12.0", "")

	p := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "libvncserver",
		Version: "0.9.11",
		Type:    syftPkg.ApkPkg,
		Distro:  d,
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:a:*:libvncserver:0.9.9:*:*:*:*:*:*:*", ""),
		},
	}

	var expected []match.Match

	actual, _, err := m.Match(vp, p)
	assert.NoError(t, err)

	assertMatches(t, expected, actual)
}

func TestNVDMatchCanceledByOriginPackageInSecDB(t *testing.T) {
	nvdVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			ID:        "CVE-2015-3211",
			Namespace: "nvd:cpe",
		},
		PackageName: "php-fpm",
		Constraint:  version.MustGetConstraint("", version.UnknownFormat),
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:a:php-fpm:php-fpm:-:*:*:*:*:*:*:*", ""),
		},
	}
	secDBVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			ID:        "CVE-2015-3211",
			Namespace: "wolfi:distro:wolfi:rolling",
		},
		PackageName: "php-8.3",
		Constraint:  version.MustGetConstraint("< 0", version.ApkFormat),
	}
	vp := mock.VulnerabilityProvider(nvdVuln, secDBVuln)

	m := Matcher{}
	d := distro.New(distro.Wolfi, "", "")

	p := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "php-8.3-fpm", // the package will not match anything
		Version: "8.3.11-r0",
		Type:    syftPkg.ApkPkg,
		Distro:  d,
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:a:php-fpm:php-fpm:8.3.11-r0:*:*:*:*:*:*:*", ""),
		},
		Upstreams: []pkg.UpstreamPackage{
			{
				Name:    "php-8.3", // this upstream should match
				Version: "8.3.11-r0",
			},
		},
	}

	var expected []match.Match

	actual, _, err := m.Match(vp, p)
	assert.NoError(t, err)

	assertMatches(t, expected, actual)
}

func TestDistroMatchBySourceIndirection(t *testing.T) {

	secDbVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			// ID doesn't match - this is the key for comparison in the matcher
			ID:        "CVE-2020-2",
			Namespace: "secdb:distro:alpine:3.12",
		},
		PackageName: "musl",
		Constraint:  version.MustGetConstraint("<= 1.3.3-r0", version.ApkFormat),
	}
	vp := mock.VulnerabilityProvider(secDbVuln)

	m := Matcher{}
	d := distro.New(distro.Alpine, "3.12.0", "")

	p := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "musl-utils",
		Version: "1.3.2-r0",
		Type:    syftPkg.ApkPkg,
		Distro:  d,
		Upstreams: []pkg.UpstreamPackage{
			{
				Name: "musl",
			},
		},
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:a:musl-utils:musl-utils:*:*:*:*:*:*:*:*", cpe.GeneratedSource),
		},
	}

	expected := []match.Match{
		{

			Vulnerability: secDbVuln,
			Package:       p,
			Details: []match.Detail{
				{
					Type:       match.ExactIndirectMatch,
					Confidence: 1.0,
					SearchedBy: match.DistroParameters{
						Distro: match.DistroIdentification{
							Type:    d.Type.String(),
							Version: d.Version,
						},
						Package: match.PackageParameter{
							Name:    "musl",
							Version: p.Version,
						},
						Namespace: "secdb:distro:alpine:3.12",
					},
					Found: match.DistroResult{
						VulnerabilityID:   "CVE-2020-2",
						VersionConstraint: secDbVuln.Constraint.String(),
					},
					Matcher: match.ApkMatcher,
				},
			},
		},
	}

	actual, _, err := m.Match(vp, p)
	assert.NoError(t, err)

	assertMatches(t, expected, actual)
}

func TestSecDBMatchesStillCountedWithCpeErrors(t *testing.T) {
	// this should match the test package
	// the test package will have no CPE causing an error,
	// but the error should not cause the secDB matches to fail
	secDbVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			ID:        "CVE-2020-2",
			Namespace: "secdb:distro:alpine:3.12",
		},
		PackageName: "musl",
		Constraint:  version.MustGetConstraint("<= 1.3.3-r0", version.ApkFormat),
	}

	vp := mock.VulnerabilityProvider(secDbVuln)

	m := Matcher{}
	d := distro.New(distro.Alpine, "3.12.0", "")

	p := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "musl-utils",
		Version: "1.3.2-r0",
		Type:    syftPkg.ApkPkg,
		Distro:  d,
		Upstreams: []pkg.UpstreamPackage{
			{
				Name: "musl",
			},
		},
		CPEs: []cpe.CPE{},
	}

	expected := []match.Match{
		{

			Vulnerability: secDbVuln,
			Package:       p,
			Details: []match.Detail{
				{
					Type:       match.ExactIndirectMatch,
					Confidence: 1.0,
					SearchedBy: match.DistroParameters{
						Distro: match.DistroIdentification{
							Type:    d.Type.String(),
							Version: d.Version,
						},
						Package: match.PackageParameter{
							Name:    "musl",
							Version: p.Version,
						},
						Namespace: "secdb:distro:alpine:3.12",
					},
					Found: match.DistroResult{
						VulnerabilityID:   "CVE-2020-2",
						VersionConstraint: secDbVuln.Constraint.String(),
					},
					Matcher: match.ApkMatcher,
				},
			},
		},
	}

	actual, _, err := m.Match(vp, p)
	assert.NoError(t, err)

	assertMatches(t, expected, actual)
}

func TestNVDMatchBySourceIndirection(t *testing.T) {
	nvdVuln := vulnerability.Vulnerability{
		Reference: vulnerability.Reference{
			ID:        "CVE-2020-1",
			Namespace: "nvd:cpe",
		},
		PackageName: "musl",
		Constraint:  version.MustGetConstraint("<= 1.3.3-r0", version.UnknownFormat),
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:a:musl:musl:*:*:*:*:*:*:*:*", ""),
		},
	}
	vp := mock.VulnerabilityProvider(nvdVuln)

	m := Matcher{}
	d := distro.New(distro.Alpine, "3.12.0", "")

	p := pkg.Package{
		ID:      pkg.ID(uuid.NewString()),
		Name:    "musl-utils",
		Version: "1.3.2-r0",
		Type:    syftPkg.ApkPkg,
		Distro:  d,
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:a:musl-utils:musl-utils:*:*:*:*:*:*:*:*", ""),
			cpe.Must("cpe:2.3:a:musl-utils:musl-utils:*:*:*:*:*:*:*:*", ""),
		},
		Upstreams: []pkg.UpstreamPackage{
			{
				Name: "musl",
			},
		},
	}

	expected := []match.Match{
		{
			Vulnerability: nvdVuln,
			Package:       p,
			Details: []match.Detail{
				{
					Type:       match.CPEMatch,
					Confidence: 0.9,
					SearchedBy: match.CPEParameters{
						CPEs:      []string{"cpe:2.3:a:musl:musl:1.3.2-r0:*:*:*:*:*:*:*"},
						Namespace: "nvd:cpe",
						Package: match.PackageParameter{
							Name:    "musl",
							Version: "1.3.2-r0",
						},
					},
					Found: match.CPEResult{
						CPEs:              []string{nvdVuln.CPEs[0].Attributes.String()},
						VersionConstraint: nvdVuln.Constraint.String(),
						VulnerabilityID:   "CVE-2020-1",
					},
					Matcher: match.ApkMatcher,
				},
			},
		},
	}

	actual, _, err := m.Match(vp, p)
	assert.NoError(t, err)

	assertMatches(t, expected, actual)
}

func assertMatches(t *testing.T, expected, actual []match.Match) {
	t.Helper()
	var opts = []cmp.Option{
		cmpopts.IgnoreFields(vulnerability.Vulnerability{}, "Constraint"),
		cmpopts.IgnoreFields(pkg.Package{}, "Locations"),
		cmpopts.IgnoreUnexported(distro.Distro{}),
	}

	if diff := cmp.Diff(expected, actual, opts...); diff != "" {
		t.Errorf("mismatch (-want +got):\n%s", diff)
	}
}

func Test_nakConstraint(t *testing.T) {
	tests := []struct {
		name    string
		input   vulnerability.Vulnerability
		wantErr require.ErrorAssertionFunc
		matches bool
	}{
		{
			name: "matches apk",
			input: vulnerability.Vulnerability{
				Constraint: version.MustGetConstraint("< 0", version.ApkFormat),
			},
			matches: true,
		},
		{
			name: "not match due to type",
			input: vulnerability.Vulnerability{
				Constraint: version.MustGetConstraint("< 0", version.SemanticFormat),
			},
			matches: false,
		},
		{
			name: "not match",
			input: vulnerability.Vulnerability{
				Constraint: version.MustGetConstraint("< 2.0", version.SemanticFormat),
			},
			matches: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if tt.wantErr == nil {
				tt.wantErr = require.NoError
			}

			matches, _, err := nakConstraint.MatchesVulnerability(tt.input)
			tt.wantErr(t, err)
			require.Equal(t, tt.matches, matches)
		})
	}
}

func Test_ignoreFilters(t *testing.T) {
	cases := []struct {
		name            string
		pkgs            []pkg.Package
		vulns           []vulnerability.Vulnerability
		expectedIgnores []match.IgnoreFilter
	}{
		{
			name: "NAK in wolfi package",
			pkgs: []pkg.Package{
				{
					ID:     "foo-id",
					Name:   "foo",
					Distro: &distro.Distro{Type: distro.Wolfi},
					Metadata: pkg.ApkMetadata{Files: []pkg.ApkFileRecord{
						{
							Path: "/bin/foo-binary",
						},
					}},
				},
			},
			vulns: []vulnerability.Vulnerability{
				{
					Reference: vulnerability.Reference{
						ID:        "GHSA-2014-fake-3",
						Namespace: "wolfi:distro:wolfi:rolling",
					},
					PackageName: "foo",
					Constraint:  version.MustGetConstraint("< 0", version.ApkFormat),
				},
			},
			expectedIgnores: []match.IgnoreFilter{
				match.IgnoreRelatedPackage{
					Reason:           "Explicit APK NAK",
					RelationshipType: artifact.OwnershipByFileOverlapRelationship,
					VulnerabilityID:  "GHSA-2014-fake-3",
					RelatedPackageID: "foo-id",
				},
			},
		},
		{
			name: "NAK in upstream wolfi package",
			pkgs: []pkg.Package{
				{
					ID:     "subpackage-foo-id",
					Name:   "subpackage-foo",
					Distro: &distro.Distro{Type: distro.Wolfi},
					Metadata: pkg.ApkMetadata{Files: []pkg.ApkFileRecord{
						{
							Path: "/bin/foo-subpackage-binary",
						},
					}},
					Upstreams: []pkg.UpstreamPackage{
						{
							Name: "origin-foo",
						},
					},
				},
			},
			vulns: []vulnerability.Vulnerability{
				{
					Reference: vulnerability.Reference{
						ID:        "GHSA-2014-fake-3",
						Namespace: "wolfi:distro:wolfi:rolling",
					},
					PackageName: "origin-foo",
					Constraint:  version.MustGetConstraint("< 0", version.ApkFormat),
				},
			},
			expectedIgnores: []match.IgnoreFilter{
				match.IgnoreRelatedPackage{
					Reason:           "Explicit APK NAK",
					RelationshipType: artifact.OwnershipByFileOverlapRelationship,
					VulnerabilityID:  "GHSA-2014-fake-3",
					RelatedPackageID: "subpackage-foo-id",
				},
			},
		},
		{
			name: "fixed vuln (not a NAK) in wolfi package",
			pkgs: []pkg.Package{
				{
					ID:      "foo-id",
					Name:    "foo",
					Version: "1.2.4",
					Distro:  &distro.Distro{Type: distro.Wolfi},
					Metadata: pkg.ApkMetadata{Files: []pkg.ApkFileRecord{
						{
							Path: "/bin/foo-binary",
						},
					}},
				},
			},
			vulns: []vulnerability.Vulnerability{
				{
					Reference: vulnerability.Reference{
						ID:        "GHSA-2014-fake-3",
						Namespace: "wolfi:distro:wolfi:rolling",
					},
					PackageName: "foo",
					Constraint:  version.MustGetConstraint("< 1.2.3-r4", version.ApkFormat),
				},
			},
			expectedIgnores: []match.IgnoreFilter{
				match.IgnoreRelatedPackage{
					Reason:           "DistroPackageFixed",
					RelationshipType: artifact.OwnershipByFileOverlapRelationship,
					VulnerabilityID:  "GHSA-2014-fake-3",
					RelatedPackageID: "foo-id",
				},
			},
		},
		{
			name: "fixed vuln (not a NAK) in upstream wolfi package",
			pkgs: []pkg.Package{
				{
					ID:      "foo-id",
					Name:    "foo",
					Version: "1.2.4",
					Distro:  &distro.Distro{Type: distro.Wolfi},
					Metadata: pkg.ApkMetadata{Files: []pkg.ApkFileRecord{
						{
							Path: "/bin/foo-binary",
						},
					}},
					Upstreams: []pkg.UpstreamPackage{
						{
							Name: "origin-foo",
						},
					},
				},
			},
			vulns: []vulnerability.Vulnerability{
				{
					Reference: vulnerability.Reference{
						ID:        "GHSA-2014-fake-3",
						Namespace: "wolfi:distro:wolfi:rolling",
					},
					PackageName: "origin-foo",
					Constraint:  version.MustGetConstraint("< 1.2.3-r4", version.ApkFormat),
				},
			},
			expectedIgnores: []match.IgnoreFilter{
				match.IgnoreRelatedPackage{
					Reason:           "DistroPackageFixed",
					RelationshipType: artifact.OwnershipByFileOverlapRelationship,
					VulnerabilityID:  "GHSA-2014-fake-3",
					RelatedPackageID: "foo-id",
				},
			},
		},
		{
			name: "vulnerable (not a NAK or fixed) in wolfi package",
			pkgs: []pkg.Package{
				{
					Name:    "foo",
					Distro:  &distro.Distro{Type: distro.Wolfi},
					Version: "1.2.2",
					Metadata: pkg.ApkMetadata{Files: []pkg.ApkFileRecord{
						{
							Path: "/bin/foo-binary",
						},
					}},
				},
			},
			vulns: []vulnerability.Vulnerability{
				{
					Reference: vulnerability.Reference{
						ID:        "GHSA-2014-fake-2",
						Namespace: "wolfi:distro:wolfi:rolling",
					},
					PackageName: "not-foo",
					Constraint:  version.MustGetConstraint("< 0", version.ApkFormat),
				},
				{
					Reference: vulnerability.Reference{
						ID:        "GHSA-2014-fake-3",
						Namespace: "wolfi:distro:wolfi:rolling",
					},
					PackageName: "foo",
					Constraint:  version.MustGetConstraint("< 1.2.3-r4", version.ApkFormat),
				},
			},
			expectedIgnores: nil,
		},
		{
			name: "no NAK or vulns for wolfi package",
			pkgs: []pkg.Package{
				{
					Name:   "foo",
					Distro: &distro.Distro{Type: distro.Wolfi},
					Metadata: pkg.ApkMetadata{Files: []pkg.ApkFileRecord{
						{
							Path: "/bin/foo-binary",
						},
					}},
				},
			},
			vulns: []vulnerability.Vulnerability{
				{
					Reference: vulnerability.Reference{
						ID:        "GHSA-2014-fake-2",
						Namespace: "wolfi:distro:wolfi:rolling",
					},
					PackageName: "not-foo",
					Constraint:  version.MustGetConstraint("< 0", version.ApkFormat),
				},
				{
					Reference: vulnerability.Reference{
						ID:        "GHSA-2014-fake-3",
						Namespace: "wolfi:distro:wolfi:rolling",
					},
					PackageName: "not-foo",
					Constraint:  version.MustGetConstraint("< 1", version.ApkFormat),
				},
			},
			expectedIgnores: nil,
		},
	}

	for _, tt := range cases {
		t.Run(tt.name, func(t *testing.T) {
			// create mock vulnerability provider
			vp := mock.VulnerabilityProvider(tt.vulns...)
			apkMatcher := &Matcher{}

			var actualResult []match.IgnoreFilter
			for _, p := range tt.pkgs {
				_, ignores, err := apkMatcher.Match(vp, p)
				require.NoError(t, err)
				actualResult = append(actualResult, ignores...)
			}

			require.ElementsMatch(t, tt.expectedIgnores, actualResult)
		})
	}
}

func TestMatcherApk_DistroFixedIgnoreRules(t *testing.T) {
	apkNamespace := "secdb:distro:wolfi:rolling"

	apkFiles := pkg.ApkMetadata{Files: []pkg.ApkFileRecord{
		{Path: "/usr/bin/kyverno"},
		{Path: "/usr/lib/kyverno/config"},
	}}

	tests := []struct {
		name                  string
		p                     pkg.Package
		vulnerabilities       []vulnerability.Vulnerability
		expectedIgnoreVulnIDs []string
		expectedMatchIDs      []string
	}{
		{
			name: "package already at fixed version - should produce location-scoped ignore rules but no matches",
			p: pkg.Package{
				ID:       pkg.ID(uuid.NewString()),
				Name:     "kyverno",
				Version:  "1.15.3-r0",
				Type:     syftPkg.ApkPkg,
				Distro:   distro.New(distro.Wolfi, "", ""),
				Metadata: apkFiles,
			},
			vulnerabilities: []vulnerability.Vulnerability{
				{
					PackageName: "kyverno",
					Constraint:  version.MustGetConstraint("< 1.15.3-r0", version.ApkFormat),
					Reference:   vulnerability.Reference{ID: "CVE-2026-22039", Namespace: apkNamespace},
				},
			},
			// one rule per owned path
			expectedIgnoreVulnIDs: []string{"CVE-2026-22039"},
			expectedMatchIDs:      nil,
		},
		{
			name: "package still vulnerable - should produce matches but no ignore rules",
			p: pkg.Package{
				ID:       pkg.ID(uuid.NewString()),
				Name:     "kyverno",
				Version:  "1.14.5-r0",
				Type:     syftPkg.ApkPkg,
				Distro:   distro.New(distro.Wolfi, "", ""),
				Metadata: apkFiles,
			},
			vulnerabilities: []vulnerability.Vulnerability{
				{
					PackageName: "kyverno",
					Constraint:  version.MustGetConstraint("< 1.15.3-r0", version.ApkFormat),
					Reference:   vulnerability.Reference{ID: "CVE-2026-22039", Namespace: apkNamespace},
				},
			},
			expectedIgnoreVulnIDs: nil,
			expectedMatchIDs:      []string{"CVE-2026-22039"},
		},
		{
			name: "no distro data for the package - no ignore rules (search miss allows GHSA to stand)",
			p: pkg.Package{
				ID:       pkg.ID(uuid.NewString()),
				Name:     "something-obscure",
				Version:  "1.0.0-r0",
				Type:     syftPkg.ApkPkg,
				Distro:   distro.New(distro.Wolfi, "", ""),
				Metadata: apkFiles,
			},
			vulnerabilities: []vulnerability.Vulnerability{
				{
					PackageName: "kyverno",
					Constraint:  version.MustGetConstraint("< 1.15.3-r0", version.ApkFormat),
					Reference:   vulnerability.Reference{ID: "CVE-2026-22039", Namespace: apkNamespace},
				},
			},
			expectedIgnoreVulnIDs: nil,
			expectedMatchIDs:      nil,
		},
		{
			name: "upstream package is fixed - should produce location-scoped ignore rules",
			p: pkg.Package{
				ID:       pkg.ID(uuid.NewString()),
				Name:     "kyverno-cli",
				Version:  "1.15.3-r0",
				Type:     syftPkg.ApkPkg,
				Distro:   distro.New(distro.Wolfi, "", ""),
				Metadata: apkFiles,
				Upstreams: []pkg.UpstreamPackage{
					{
						Name:    "kyverno",
						Version: "1.15.3-r0",
					},
				},
			},
			vulnerabilities: []vulnerability.Vulnerability{
				{
					PackageName: "kyverno",
					Constraint:  version.MustGetConstraint("< 1.15.3-r0", version.ApkFormat),
					Reference:   vulnerability.Reference{ID: "CVE-2026-22039", Namespace: apkNamespace},
				},
			},
			// one rule per owned path
			expectedIgnoreVulnIDs: []string{"CVE-2026-22039"},
			expectedMatchIDs:      nil,
		},
		{
			name: "fixed CVE with related GHSA - ignore rules include both IDs at all paths",
			p: pkg.Package{
				ID:       pkg.ID(uuid.NewString()),
				Name:     "kyverno",
				Version:  "1.15.3-r0",
				Type:     syftPkg.ApkPkg,
				Distro:   distro.New(distro.Wolfi, "", ""),
				Metadata: apkFiles,
			},
			vulnerabilities: []vulnerability.Vulnerability{
				{
					PackageName: "kyverno",
					Constraint:  version.MustGetConstraint("< 1.15.3-r0", version.ApkFormat),
					Reference:   vulnerability.Reference{ID: "CVE-2026-22039", Namespace: apkNamespace},
					RelatedVulnerabilities: []vulnerability.Reference{
						{ID: "GHSA-8p9x-46gm-qfx2", Namespace: "github:language:go"},
					},
				},
			},
			// 2 IDs × 2 paths = 4 rules
			expectedIgnoreVulnIDs: []string{"CVE-2026-22039", "GHSA-8p9x-46gm-qfx2"},
			expectedMatchIDs:      nil,
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			matcher := Matcher{}

			store := mock.VulnerabilityProvider(test.vulnerabilities...)
			matches, ignoreFilters, err := matcher.Match(store, test.p)
			require.NoError(t, err)

			// verify matches
			var gotMatchIDs []string
			for _, m := range matches {
				gotMatchIDs = append(gotMatchIDs, m.Vulnerability.ID)
			}
			if test.expectedMatchIDs == nil {
				assert.Empty(t, gotMatchIDs, "expected no matches")
			} else {
				assert.ElementsMatch(t, test.expectedMatchIDs, gotMatchIDs, "unexpected match IDs")
			}

			// verify ignore rules - filter to only DistroPackageFixed rules (not NAK rules)
			gotIgnoreIDs := strset.New()
			for _, filter := range ignoreFilters {
				related, ok := filter.(match.IgnoreRelatedPackage)
				if ok {
					gotIgnoreIDs.Add(related.VulnerabilityID)
					continue
				}
				rule, ok := filter.(match.IgnoreRule)
				require.True(t, ok, "expected IgnoreRule or IgnoreRelatedPackage types")
				gotIgnoreIDs.Add(rule.Vulnerability)
				assert.True(t, rule.IncludeAliases, "expected IncludeAliases to be true")
				assert.NotEmpty(t, rule.Package.Location, "expected location to be set on DistroPackageFixed rule")
			}
			if test.expectedIgnoreVulnIDs == nil {
				assert.Empty(t, gotIgnoreIDs.List(), "expected no ignore rules")
			} else {
				assert.ElementsMatch(t, test.expectedIgnoreVulnIDs, gotIgnoreIDs.List(), "unexpected ignore rule vulnerability IDs")
			}
		})
	}
}
