internal/frontend: move vulns utilities to internal/vulns

The tests in internal/frontend require special environment setup
as described in doc/postgres.md. When the environment is
not configured correctly, all tests are quietly skipped.
(See TestMain in internal/frontend/server_test.go)

This makes it difficult to test small utility functions
that do not require database backend. Move vulnerability
page related small utilities to a separate internal package
for easier testing.

Change-Id: Ib46a4549bf5a7bf22a3993a1c40f1033ce6a7ce7
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/429677
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
Reviewed-by: Jamal Carvalho <jamal@golang.org>
TryBot-Result: kokoro <noreply+kokoro@google.com>
diff --git a/internal/frontend/vulns.go b/internal/frontend/vulns.go
index 511eddb..e6c9eda 100644
--- a/internal/frontend/vulns.go
+++ b/internal/frontend/vulns.go
@@ -14,7 +14,7 @@
 	"golang.org/x/mod/semver"
 	"golang.org/x/pkgsite/internal"
 	"golang.org/x/pkgsite/internal/derrors"
-	"golang.org/x/pkgsite/internal/stdlib"
+	"golang.org/x/pkgsite/internal/vulns"
 	"golang.org/x/sync/errgroup"
 	vulnc "golang.org/x/vuln/client"
 	"golang.org/x/vuln/osv"
@@ -81,16 +81,11 @@
 type VulnPage struct {
 	basePage
 	Entry            OSVEntry
-	AffectedPackages []*AffectedPackage
+	AffectedPackages []*vulns.AffectedPackage
 	AliasLinks       []link
 	AdvisoryLinks    []link
 }
 
-type AffectedPackage struct {
-	PackagePath string
-	Versions    string
-}
-
 // OSVEntry holds an OSV entry and provides additional methods.
 type OSVEntry struct {
 	*osv.Entry
@@ -207,7 +202,7 @@
 	}
 	return &VulnPage{
 		Entry:            OSVEntry{entry},
-		AffectedPackages: affectedPackages(entry),
+		AffectedPackages: vulns.AffectedPackages(entry),
 		AliasLinks:       aliasLinks(entry),
 		AdvisoryLinks:    advisoryLinks(entry),
 	}, nil
@@ -246,83 +241,6 @@
 	return &VulnListPage{Entries: entries}, nil
 }
 
-// A pair is like an osv.Range, but each pair is a self-contained 2-tuple
-// (introduced version, fixed version).
-type pair struct {
-	intro, fixed string
-}
-
-// collectRangePairs turns a slice of osv Ranges into a more manageable slice of
-// formatted version pairs.
-func collectRangePairs(a osv.Affected) []pair {
-	var (
-		ps     []pair
-		p      pair
-		prefix string
-	)
-	if stdlib.Contains(a.Package.Name) {
-		prefix = "go"
-	} else {
-		prefix = "v"
-	}
-	for _, r := range a.Ranges {
-		isSemver := r.Type == osv.TypeSemver
-		for _, v := range r.Events {
-			if v.Introduced != "" {
-				// We expected Introduced and Fixed to alternate, but if
-				// p.intro != "", then they they don't.
-				// Keep going in that case, ignoring the first Introduced.
-				p.intro = v.Introduced
-				if p.intro == "0" {
-					p.intro = ""
-				}
-				if isSemver && p.intro != "" {
-					p.intro = prefix + p.intro
-				}
-			}
-			if v.Fixed != "" {
-				p.fixed = v.Fixed
-				if isSemver && p.fixed != "" {
-					p.fixed = prefix + p.fixed
-				}
-				ps = append(ps, p)
-				p = pair{}
-			}
-		}
-	}
-	return ps
-}
-
-func affectedPackages(e *osv.Entry) []*AffectedPackage {
-	var affs []*AffectedPackage
-	for _, a := range e.Affected {
-		pairs := collectRangePairs(a)
-		var vs []string
-		for _, p := range pairs {
-			var s string
-			if p.intro == "" && p.fixed == "" {
-				// If neither field is set, the vuln applies to all versions.
-				// Leave it blank, the template will render it properly.
-				s = ""
-			} else if p.intro == "" {
-				s = "before " + p.fixed
-			} else if p.fixed == "" {
-				s = p.intro + " and later"
-			} else {
-				s = "from " + p.intro + " before " + p.fixed
-			}
-			vs = append(vs, s)
-		}
-		for _, p := range a.EcosystemSpecific.Imports {
-			affs = append(affs, &AffectedPackage{
-				PackagePath: p.Path,
-				Versions:    strings.Join(vs, ", "),
-			})
-		}
-	}
-	return affs
-}
-
 // aliasLinks generates links to reference pages for vuln aliases.
 func aliasLinks(e *osv.Entry) []link {
 	var links []link
diff --git a/internal/frontend/vulns_test.go b/internal/frontend/vulns_test.go
index 2fc2fb2..cff7812 100644
--- a/internal/frontend/vulns_test.go
+++ b/internal/frontend/vulns_test.go
@@ -8,7 +8,6 @@
 	"context"
 	"errors"
 	"fmt"
-	"reflect"
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
@@ -131,31 +130,6 @@
 	return ids, nil
 }
 
-func TestCollectRangePairs(t *testing.T) {
-	in := osv.Affected{
-		Package: osv.Package{Name: "github.com/a/b"},
-		Ranges: osv.Affects{
-			{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Introduced: "", Fixed: "0.5"}}},
-			{Type: osv.TypeSemver, Events: []osv.RangeEvent{
-				{Introduced: "1.2"}, {Fixed: "1.5"},
-				{Introduced: "2.1", Fixed: "2.3"},
-			}},
-			{Type: osv.TypeGit, Events: []osv.RangeEvent{{Introduced: "a", Fixed: "b"}}},
-		},
-	}
-	got := collectRangePairs(in)
-	want := []pair{
-		{"", "v0.5"},
-		{"v1.2", "v1.5"},
-		{"v2.1", "v2.3"},
-		{"a", "b"},
-	}
-	if !reflect.DeepEqual(got, want) {
-		t.Errorf("\ngot  %+v\nwant %+v", got, want)
-	}
-
-}
-
 func Test_aliasLinks(t *testing.T) {
 	type args struct {
 		e *osv.Entry
@@ -220,57 +194,3 @@
 		})
 	}
 }
-
-func TestAffectedPackages(t *testing.T) {
-	for _, test := range []struct {
-		name string
-		in   []osv.RangeEvent
-		want string
-	}{
-		{
-			"no intro or fixed",
-			nil,
-			"",
-		},
-		{
-			"no intro",
-			[]osv.RangeEvent{{Fixed: "1.5"}},
-			"before v1.5",
-		},
-		{
-			"both",
-			[]osv.RangeEvent{{Introduced: "1.5"}, {Fixed: "1.10"}},
-			"from v1.5 before v1.10",
-		},
-		{
-			"multiple",
-			[]osv.RangeEvent{
-				{Introduced: "1.5", Fixed: "1.10"},
-				{Fixed: "2.3"},
-			},
-			"from v1.5 before v1.10, before v2.3",
-		},
-	} {
-		t.Run(test.name, func(t *testing.T) {
-			entry := &osv.Entry{
-				Affected: []osv.Affected{{
-					Package: osv.Package{Name: "example.com/p"},
-					EcosystemSpecific: osv.EcosystemSpecific{
-						Imports: []osv.EcosystemSpecificImport{{
-							Path: "example.com/p",
-						}},
-					},
-					Ranges: osv.Affects{{
-						Type:   osv.TypeSemver,
-						Events: test.in,
-					}},
-				}},
-			}
-			out := affectedPackages(entry)
-			got := out[0].Versions
-			if got != test.want {
-				t.Errorf("got %q, want %q\n", got, test.want)
-			}
-		})
-	}
-}
diff --git a/internal/vulns/vulns.go b/internal/vulns/vulns.go
new file mode 100644
index 0000000..2190269
--- /dev/null
+++ b/internal/vulns/vulns.go
@@ -0,0 +1,210 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package vulns provides utilities to interact with vuln APIs.
+package vulns
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"golang.org/x/mod/semver"
+	"golang.org/x/pkgsite/internal/derrors"
+	"golang.org/x/pkgsite/internal/stdlib"
+	"golang.org/x/vuln/osv"
+)
+
+// A Vuln contains information to display about a vulnerability.
+type Vuln struct {
+	// The vulndb ID.
+	ID string
+	// A description of the vulnerability, or the problem in obtaining it.
+	Details string
+}
+
+type vulnEntriesFunc func(context.Context, string) ([]*osv.Entry, error)
+
+// VulnsForPackage obtains vulnerability information for the given package.
+// If packagePath is empty, it returns all entries for the module at version.
+// The getVulnEntries function should retrieve all entries for the given module path.
+// It is passed to facilitate testing.
+// If there is an error, VulnsForPackage returns a single Vuln that describes the error.
+func VulnsForPackage(ctx context.Context, modulePath, version, packagePath string, getVulnEntries vulnEntriesFunc) []Vuln {
+	vs, err := vulnsForPackage(ctx, modulePath, version, packagePath, getVulnEntries)
+	if err != nil {
+		return []Vuln{{Details: fmt.Sprintf("could not get vulnerability data: %v", err)}}
+	}
+	return vs
+}
+
+func vulnsForPackage(ctx context.Context, modulePath, version, packagePath string, getVulnEntries vulnEntriesFunc) (_ []Vuln, err error) {
+	defer derrors.Wrap(&err, "vulns(%q, %q, %q)", modulePath, version, packagePath)
+
+	if getVulnEntries == nil {
+		return nil, nil
+	}
+	// Get all the vulns for this module.
+	entries, err := getVulnEntries(ctx, modulePath)
+	if err != nil {
+		return nil, err
+	}
+	// Each entry describes a single vuln. Select the ones that apply to this
+	// package at this version.
+	var vulns []Vuln
+	for _, e := range entries {
+		if vuln, ok := entryVuln(e, packagePath, version); ok {
+			vulns = append(vulns, vuln)
+		}
+	}
+	return vulns, nil
+}
+
+// AffectedPackage holds information about a package affected by a certain vulnerability.
+type AffectedPackage struct {
+	PackagePath string
+	Versions    string
+}
+
+// OSVEntry holds an OSV entry and provides additional methods.
+type OSVEntry struct {
+	*osv.Entry
+}
+
+// AffectedModulesAndPackages returns a list of names affected by a vuln.
+func (e OSVEntry) AffectedModulesAndPackages() []string {
+	var affected []string
+	for _, a := range e.Affected {
+		switch a.Package.Name {
+		case "stdlib", "toolchain":
+			// Name specific standard library packages and tools.
+			for _, p := range a.EcosystemSpecific.Imports {
+				affected = append(affected, p.Path)
+			}
+		default:
+			// Outside the standard library, name the module.
+			affected = append(affected, a.Package.Name)
+		}
+	}
+	return affected
+}
+
+func entryVuln(e *osv.Entry, packagePath, version string) (Vuln, bool) {
+	for _, a := range e.Affected {
+		if !a.Ranges.AffectsSemver(version) {
+			continue
+		}
+		if packageMatches := func() bool {
+			if packagePath == "" {
+				return true //  match module only
+			}
+			if len(a.EcosystemSpecific.Imports) == 0 {
+				return true // no package info available, so match on module
+			}
+			for _, p := range a.EcosystemSpecific.Imports {
+				if packagePath == p.Path {
+					return true // package matches
+				}
+			}
+			return false
+		}(); !packageMatches {
+			continue
+		}
+		// Choose the latest fixed version, if any.
+		var fixed string
+		for _, r := range a.Ranges {
+			if r.Type == osv.TypeGit {
+				continue
+			}
+			for _, re := range r.Events {
+				if re.Fixed != "" && (fixed == "" || semver.Compare(re.Fixed, fixed) > 0) {
+					fixed = re.Fixed
+				}
+			}
+		}
+		return Vuln{
+			ID:      e.ID,
+			Details: e.Details,
+		}, true
+	}
+	return Vuln{}, false
+}
+
+// A pair is like an osv.Range, but each pair is a self-contained 2-tuple
+// (introduced version, fixed version).
+type pair struct {
+	intro, fixed string
+}
+
+// collectRangePairs turns a slice of osv Ranges into a more manageable slice of
+// formatted version pairs.
+func collectRangePairs(a osv.Affected) []pair {
+	var (
+		ps     []pair
+		p      pair
+		prefix string
+	)
+	if stdlib.Contains(a.Package.Name) {
+		prefix = "go"
+	} else {
+		prefix = "v"
+	}
+	for _, r := range a.Ranges {
+		isSemver := r.Type == osv.TypeSemver
+		for _, v := range r.Events {
+			if v.Introduced != "" {
+				// We expected Introduced and Fixed to alternate, but if
+				// p.intro != "", then they they don't.
+				// Keep going in that case, ignoring the first Introduced.
+				p.intro = v.Introduced
+				if p.intro == "0" {
+					p.intro = ""
+				}
+				if isSemver && p.intro != "" {
+					p.intro = prefix + p.intro
+				}
+			}
+			if v.Fixed != "" {
+				p.fixed = v.Fixed
+				if isSemver && p.fixed != "" {
+					p.fixed = prefix + p.fixed
+				}
+				ps = append(ps, p)
+				p = pair{}
+			}
+		}
+	}
+	return ps
+}
+
+// AffectedPackages extracts information about affected packages from the given osv.Entry.
+func AffectedPackages(e *osv.Entry) []*AffectedPackage {
+	var affs []*AffectedPackage
+	for _, a := range e.Affected {
+		pairs := collectRangePairs(a)
+		var vs []string
+		for _, p := range pairs {
+			var s string
+			if p.intro == "" && p.fixed == "" {
+				// If neither field is set, the vuln applies to all versions.
+				// Leave it blank, the template will render it properly.
+				s = ""
+			} else if p.intro == "" {
+				s = "before " + p.fixed
+			} else if p.fixed == "" {
+				s = p.intro + " and later"
+			} else {
+				s = "from " + p.intro + " before " + p.fixed
+			}
+			vs = append(vs, s)
+		}
+		for _, p := range a.EcosystemSpecific.Imports {
+			affs = append(affs, &AffectedPackage{
+				PackagePath: p.Path,
+				Versions:    strings.Join(vs, ", "),
+			})
+		}
+	}
+	return affs
+}
diff --git a/internal/vulns/vulns_test.go b/internal/vulns/vulns_test.go
new file mode 100644
index 0000000..2b3d249
--- /dev/null
+++ b/internal/vulns/vulns_test.go
@@ -0,0 +1,141 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package vulns
+
+import (
+	"context"
+	"fmt"
+	"reflect"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"golang.org/x/vuln/osv"
+)
+
+func TestVulnsForPackage(t *testing.T) {
+	ctx := context.Background()
+	e := osv.Entry{
+		Details: "bad",
+		Affected: []osv.Affected{{
+			Package: osv.Package{Name: "bad.com"},
+			Ranges: []osv.AffectsRange{{
+				Type:   osv.TypeSemver,
+				Events: []osv.RangeEvent{{Introduced: "0"}, {Fixed: "1.2.3"}},
+			}},
+			EcosystemSpecific: osv.EcosystemSpecific{
+				Imports: []osv.EcosystemSpecificImport{{
+					Path: "bad.com",
+				}},
+			},
+		}},
+	}
+
+	get := func(_ context.Context, modulePath string) ([]*osv.Entry, error) {
+		switch modulePath {
+		case "good.com":
+			return nil, nil
+		case "bad.com":
+			return []*osv.Entry{&e}, nil
+		default:
+			return nil, fmt.Errorf("unknown module %q", modulePath)
+		}
+	}
+
+	got := VulnsForPackage(ctx, "good.com", "v1.0.0", "good.com", get)
+	if got != nil {
+		t.Errorf("got %v, want nil", got)
+	}
+	got = VulnsForPackage(ctx, "bad.com", "v1.0.0", "bad.com", get)
+	want := []Vuln{{
+		Details: "bad",
+	}}
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Errorf("mismatch (-want, +got):\n%s", diff)
+	}
+
+	got = VulnsForPackage(ctx, "bad.com", "v1.3.0", "bad.com", get)
+	if got != nil {
+		t.Errorf("got %v, want nil", got)
+	}
+}
+
+func TestCollectRangePairs(t *testing.T) {
+	in := osv.Affected{
+		Package: osv.Package{Name: "github.com/a/b"},
+		Ranges: osv.Affects{
+			{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Introduced: "", Fixed: "0.5"}}},
+			{Type: osv.TypeSemver, Events: []osv.RangeEvent{
+				{Introduced: "1.2"}, {Fixed: "1.5"},
+				{Introduced: "2.1", Fixed: "2.3"},
+			}},
+			{Type: osv.TypeGit, Events: []osv.RangeEvent{{Introduced: "a", Fixed: "b"}}},
+		},
+	}
+	got := collectRangePairs(in)
+	want := []pair{
+		{"", "v0.5"},
+		{"v1.2", "v1.5"},
+		{"v2.1", "v2.3"},
+		{"a", "b"},
+	}
+	if !reflect.DeepEqual(got, want) {
+		t.Errorf("\ngot  %+v\nwant %+v", got, want)
+	}
+
+}
+
+func TestAffectedPackages(t *testing.T) {
+	for _, test := range []struct {
+		name string
+		in   []osv.RangeEvent
+		want string
+	}{
+		{
+			"no intro or fixed",
+			nil,
+			"",
+		},
+		{
+			"no intro",
+			[]osv.RangeEvent{{Fixed: "1.5"}},
+			"before v1.5",
+		},
+		{
+			"both",
+			[]osv.RangeEvent{{Introduced: "1.5"}, {Fixed: "1.10"}},
+			"from v1.5 before v1.10",
+		},
+		{
+			"multiple",
+			[]osv.RangeEvent{
+				{Introduced: "1.5", Fixed: "1.10"},
+				{Fixed: "2.3"},
+			},
+			"from v1.5 before v1.10, before v2.3",
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			entry := &osv.Entry{
+				Affected: []osv.Affected{{
+					Package: osv.Package{Name: "example.com/p"},
+					EcosystemSpecific: osv.EcosystemSpecific{
+						Imports: []osv.EcosystemSpecificImport{{
+							Path: "example.com/p",
+						}},
+					},
+					Ranges: osv.Affects{{
+						Type:   osv.TypeSemver,
+						Events: test.in,
+					}},
+				}},
+			}
+			out := AffectedPackages(entry)
+			got := out[0].Versions
+			if got != test.want {
+				t.Errorf("got %q, want %q\n", got, test.want)
+			}
+		})
+	}
+}