internal/frontend: collect vulns for versions page

Store the vulnerabilities for each version in the
structs that are handed to the rendering templates.

Later CLs will display them on the versions page.

For golang/go#48223

Change-Id: Icbc541b5d981ea84d5b97b142c48d312219f3aba
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/347971
Trust: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Julie Qiu <julie@golang.org>
diff --git a/internal/frontend/server.go b/internal/frontend/server.go
index 570f9f7..e4b536e 100644
--- a/internal/frontend/server.go
+++ b/internal/frontend/server.go
@@ -33,6 +33,7 @@
 	"golang.org/x/pkgsite/internal/static"
 	"golang.org/x/pkgsite/internal/version"
 	vulndbc "golang.org/x/vulndb/client"
+	"golang.org/x/vulndb/osv"
 )
 
 // Server can be installed to serve the go discovery frontend.
@@ -51,7 +52,7 @@
 	serveStats           bool
 	reportingClient      *errorreporting.Client
 	fileMux              *http.ServeMux
-	vulndbClient         *vulndbc.Client
+	getVulnEntries       vulnEntriesFunc
 
 	mu        sync.Mutex // Protects all fields below
 	templates map[string]*template.Template
@@ -98,7 +99,7 @@
 		serveStats:           scfg.ServeStats,
 		reportingClient:      scfg.ReportingClient,
 		fileMux:              http.NewServeMux(),
-		vulndbClient:         scfg.VulndbClient,
+		getVulnEntries:       func(m string) ([]*osv.Entry, error) { return scfg.VulndbClient.Get([]string{m}) },
 	}
 	errorPageBytes, err := s.renderErrorPage(context.Background(), http.StatusInternalServerError, "error", nil)
 	if err != nil {
diff --git a/internal/frontend/tabs.go b/internal/frontend/tabs.go
index 89cee21..972f6b6 100644
--- a/internal/frontend/tabs.go
+++ b/internal/frontend/tabs.go
@@ -76,14 +76,15 @@
 // fetchDetailsForPackage returns tab details by delegating to the correct detail
 // handler.
 func fetchDetailsForUnit(ctx context.Context, r *http.Request, tab string, ds internal.DataSource, um *internal.UnitMeta,
-	requestedVersion string, bc internal.BuildContext) (_ interface{}, err error) {
+	requestedVersion string, bc internal.BuildContext,
+	getVulnEntries vulnEntriesFunc) (_ interface{}, err error) {
 	defer derrors.Wrap(&err, "fetchDetailsForUnit(r, %q, ds, um=%q,%q,%q)", tab, um.Path, um.ModulePath, um.Version)
 	switch tab {
 	case tabMain:
 		_, expandReadme := r.URL.Query()["readme"]
 		return fetchMainDetails(ctx, ds, um, requestedVersion, expandReadme, bc)
 	case tabVersions:
-		return fetchVersionsDetails(ctx, ds, um)
+		return fetchVersionsDetails(ctx, ds, um, getVulnEntries)
 	case tabImports:
 		return fetchImportsDetails(ctx, ds, um.Path, um.ModulePath, um.Version)
 	case tabImportedBy:
diff --git a/internal/frontend/unit.go b/internal/frontend/unit.go
index 1c4e610..873455e 100644
--- a/internal/frontend/unit.go
+++ b/internal/frontend/unit.go
@@ -22,7 +22,6 @@
 	"golang.org/x/pkgsite/internal/middleware"
 	"golang.org/x/pkgsite/internal/stdlib"
 	"golang.org/x/pkgsite/internal/version"
-	"golang.org/x/vulndb/osv"
 )
 
 // UnitPage contains data needed to render the unit template.
@@ -123,7 +122,7 @@
 	// It's also okay to provide just one (e.g. GOOS=windows), which will select
 	// the first doc with that value, ignoring the other one.
 	bc := internal.BuildContext{GOOS: r.FormValue("GOOS"), GOARCH: r.FormValue("GOARCH")}
-	d, err := fetchDetailsForUnit(ctx, r, tab, ds, um, info.requestedVersion, bc)
+	d, err := fetchDetailsForUnit(ctx, r, tab, ds, um, info.requestedVersion, bc, s.getVulnEntries)
 	if err != nil {
 		return err
 	}
@@ -212,9 +211,8 @@
 
 	// Get vulnerability information.
 	var vulns []Vuln
-	if s.vulndbClient != nil && experiment.IsActive(ctx, internal.ExperimentVulns) {
-		getEntries := func(m string) ([]*osv.Entry, error) { return s.vulndbClient.Get([]string{m}) }
-		vulns, err = Vulns(um.ModulePath, um.Version, um.Path, getEntries)
+	if s.getVulnEntries != nil && experiment.IsActive(ctx, internal.ExperimentVulns) {
+		vulns, err = Vulns(um.ModulePath, um.Version, um.Path, s.getVulnEntries)
 		if err != nil {
 			vulns = []Vuln{{Details: fmt.Sprintf("could not get vulnerability data: %v", err)}}
 		}
diff --git a/internal/frontend/versions.go b/internal/frontend/versions.go
index a784731..01de31f 100644
--- a/internal/frontend/versions.go
+++ b/internal/frontend/versions.go
@@ -14,6 +14,7 @@
 
 	"golang.org/x/mod/semver"
 	"golang.org/x/pkgsite/internal"
+	"golang.org/x/pkgsite/internal/experiment"
 	"golang.org/x/pkgsite/internal/log"
 	"golang.org/x/pkgsite/internal/postgres"
 	"golang.org/x/pkgsite/internal/stdlib"
@@ -78,9 +79,10 @@
 	RetractionRationale string
 	IsMinor             bool
 	Symbols             [][]*Symbol
+	Vulns               []Vuln
 }
 
-func fetchVersionsDetails(ctx context.Context, ds internal.DataSource, um *internal.UnitMeta) (*VersionsDetails, error) {
+func fetchVersionsDetails(ctx context.Context, ds internal.DataSource, um *internal.UnitMeta, getVulnEntries vulnEntriesFunc) (*VersionsDetails, error) {
 	db, ok := ds.(*postgres.DB)
 	if !ok {
 		// The proxydatasource does not support the imported by page.
@@ -109,7 +111,7 @@
 		}
 		return constructUnitURL(versionPath, mi.ModulePath, linkVersion(mi.ModulePath, mi.Version, mi.Version))
 	}
-	return buildVersionDetails(um.ModulePath, versions, sh, linkify), nil
+	return buildVersionDetails(ctx, um.ModulePath, versions, sh, linkify, getVulnEntries), nil
 }
 
 // pathInVersion constructs the full import path of the package corresponding
@@ -136,10 +138,12 @@
 // versions tab, organizing major versions into those that have the same module
 // path as the package version under consideration, and those that don't.  The
 // given versions MUST be sorted first by module path and then by semver.
-func buildVersionDetails(currentModulePath string,
+func buildVersionDetails(ctx context.Context, currentModulePath string,
 	modInfos []*internal.ModuleInfo,
 	sh *internal.SymbolHistory,
-	linkify func(v *internal.ModuleInfo) string) *VersionsDetails {
+	linkify func(v *internal.ModuleInfo) string,
+	getVulnEntries vulnEntriesFunc,
+) *VersionsDetails {
 	// lists organizes versions by VersionListKey. Note that major version isn't
 	// sufficient as a key: there are packages contained in the same major
 	// version of different modules, for example github.com/hashicorp/vault/api,
@@ -190,6 +194,13 @@
 		if sv := sh.SymbolsAtVersion(mi.Version); sv != nil {
 			vs.Symbols = symbolsForVersion(linkify(mi), sv)
 		}
+		if experiment.IsActive(ctx, internal.ExperimentVulns) {
+			vulns, err := Vulns(mi.ModulePath, mi.Version, "", getVulnEntries)
+			if err != nil {
+				vulns = []Vuln{{Details: fmt.Sprintf("could not get vulnerability data: %v", err)}}
+			}
+			vs.Vulns = vulns
+		}
 		if _, ok := lists[key]; !ok {
 			seenLists = append(seenLists, key)
 		}
diff --git a/internal/frontend/versions_test.go b/internal/frontend/versions_test.go
index 7358900..8283136 100644
--- a/internal/frontend/versions_test.go
+++ b/internal/frontend/versions_test.go
@@ -10,10 +10,12 @@
 
 	"github.com/google/go-cmp/cmp"
 	"golang.org/x/pkgsite/internal"
+	"golang.org/x/pkgsite/internal/experiment"
 	"golang.org/x/pkgsite/internal/postgres"
 	"golang.org/x/pkgsite/internal/stdlib"
 	"golang.org/x/pkgsite/internal/testing/sample"
 	"golang.org/x/pkgsite/internal/version"
+	"golang.org/x/vulndb/osv"
 )
 
 var (
@@ -87,6 +89,23 @@
 		}
 	}
 
+	vulnEntry := &osv.Entry{
+		Details: "vuln",
+		Affects: osv.Affects{
+			Ranges: []osv.AffectsRange{{
+				Type:       osv.TypeSemver,
+				Introduced: "1.2.0",
+				Fixed:      "1.2.3",
+			}},
+		},
+	}
+	getVulnEntries := func(m string) ([]*osv.Entry, error) {
+		if m == modulePath1 {
+			return []*osv.Entry{vulnEntry}, nil
+		}
+		return nil, nil
+	}
+
 	for _, tc := range []struct {
 		name        string
 		pkg         *internal.Unit
@@ -121,7 +140,14 @@
 			},
 			wantDetails: &VersionsDetails{
 				ThisModule: []*VersionList{
-					makeList(v1Path, modulePath1, "v1", []string{"v1.3.0", "v1.2.3", "v1.2.1"}, false),
+					func() *VersionList {
+						vl := makeList(v1Path, modulePath1, "v1", []string{"v1.3.0", "v1.2.3", "v1.2.1"}, false)
+						vl.Versions[2].Vulns = []Vuln{{
+							Details:      vulnEntry.Details,
+							FixedVersion: "v" + vulnEntry.Affects.Ranges[0].Fixed,
+						}}
+						return vl
+					}(),
 				},
 				IncompatibleModules: []*VersionList{
 					makeList(v1Path, modulePath1, "v2", []string{"v2.1.0+incompatible"}, true),
@@ -162,13 +188,14 @@
 		t.Run(tc.name, func(t *testing.T) {
 			ctx, cancel := context.WithTimeout(context.Background(), testTimeout*2)
 			defer cancel()
+			ctx = experiment.NewContext(ctx, internal.ExperimentVulns)
 			defer postgres.ResetTestDB(testDB, t)
 
 			for _, v := range tc.modules {
 				postgres.MustInsertModule(ctx, t, testDB, v)
 			}
 
-			got, err := fetchVersionsDetails(ctx, testDB, &tc.pkg.UnitMeta)
+			got, err := fetchVersionsDetails(ctx, testDB, &tc.pkg.UnitMeta, getVulnEntries)
 			if err != nil {
 				t.Fatalf("fetchVersionsDetails(ctx, db, %q, %q): %v", tc.pkg.Path, tc.pkg.ModulePath, err)
 			}
diff --git a/internal/frontend/vulns.go b/internal/frontend/vulns.go
index e6aeabe..66b18b7 100644
--- a/internal/frontend/vulns.go
+++ b/internal/frontend/vulns.go
@@ -18,11 +18,13 @@
 	FixedVersion string
 }
 
+type vulnEntriesFunc func(string) ([]*osv.Entry, error)
+
 // Vulns obtains vulnerability information for the given package.
-// the getVulnEntries function should have the same signature and
-// behavior as golang.org/x/vulndb/client.Client.Get.
+// 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.
-func Vulns(modulePath, version, packagePath string, getVulnEntries func(string) ([]*osv.Entry, error)) (_ []Vuln, err error) {
+func Vulns(modulePath, version, packagePath string, getVulnEntries vulnEntriesFunc) (_ []Vuln, err error) {
 	defer derrors.Wrap(&err, "Vulns(%q, %q)", modulePath, version)
 
 	// Get all the vulns for this module.
@@ -34,7 +36,7 @@
 	// package at this version.
 	var vulns []Vuln
 	for _, e := range entries {
-		if e.Package.Name == packagePath && e.Affects.AffectsSemver(version) {
+		if (packagePath == "" || e.Package.Name == packagePath) && e.Affects.AffectsSemver(version) {
 			// Choose the latest fixed version, if any.
 			var fixed string
 			for _, r := range e.Affects.Ranges {