vulndb/govulncheck: move cache from osv client to govulncheck

This CL copies cache implementation from osv client package to
govulncheck. This CL will be followed by a CL that removes cache
implementation from osv client package.

Change-Id: I2b7b53f08a9c2c6ceb5e9f1a66825e4cc65b6281
Reviewed-on: https://go-review.googlesource.com/c/exp/+/347169
Run-TryBot: Zvonimir Pavlinovic <zpavlinovic@google.com>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Guodong Li <guodongli@google.com>
Reviewed-by: Tim King <taking@google.com>
Trust: Zvonimir Pavlinovic <zpavlinovic@google.com>
diff --git a/vulndb/govulncheck/cache.go b/vulndb/govulncheck/cache.go
new file mode 100644
index 0000000..7021d7c
--- /dev/null
+++ b/vulndb/govulncheck/cache.go
@@ -0,0 +1,96 @@
+// Copyright 2021 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 main
+
+import (
+	"encoding/json"
+	"go/build"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"time"
+
+	"golang.org/x/vulndb/osv"
+)
+
+// fsCache is file-system cache implementing osv.Cache
+type fsCache struct {
+	rootDir string
+}
+
+// use cfg.GOMODCACHE available in cmd/go/internal?
+var defaultCacheRoot = filepath.Join(build.Default.GOPATH, "/pkg/mod/cache/download/vulndb")
+
+func defaultCache() *fsCache {
+	return &fsCache{rootDir: defaultCacheRoot}
+}
+
+type cachedIndex struct {
+	Retrieved time.Time
+	Index     osv.DBIndex
+}
+
+func (c *fsCache) ReadIndex(dbName string) (osv.DBIndex, time.Time, error) {
+	b, err := ioutil.ReadFile(filepath.Join(c.rootDir, dbName, "index.json"))
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil, time.Time{}, nil
+		}
+		return nil, time.Time{}, err
+	}
+	var index cachedIndex
+	if err := json.Unmarshal(b, &index); err != nil {
+		return nil, time.Time{}, err
+	}
+	return index.Index, index.Retrieved, nil
+}
+
+func (c *fsCache) WriteIndex(dbName string, index osv.DBIndex, retrieved time.Time) error {
+	path := filepath.Join(c.rootDir, dbName)
+	if err := os.MkdirAll(path, 0755); err != nil {
+		return err
+	}
+	j, err := json.Marshal(cachedIndex{
+		Index:     index,
+		Retrieved: retrieved,
+	})
+	if err != nil {
+		return err
+	}
+	if err := ioutil.WriteFile(filepath.Join(path, "index.json"), j, 0666); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (c *fsCache) ReadEntries(dbName string, p string) ([]*osv.Entry, error) {
+	b, err := ioutil.ReadFile(filepath.Join(c.rootDir, dbName, p, "vulns.json"))
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil, nil
+		}
+		return nil, err
+	}
+	var entries []*osv.Entry
+	if err := json.Unmarshal(b, &entries); err != nil {
+		return nil, err
+	}
+	return entries, nil
+}
+
+func (c *fsCache) WriteEntries(dbName string, p string, entries []*osv.Entry) error {
+	path := filepath.Join(c.rootDir, dbName, p)
+	if err := os.MkdirAll(path, 0777); err != nil {
+		return err
+	}
+	j, err := json.Marshal(entries)
+	if err != nil {
+		return err
+	}
+	if err := ioutil.WriteFile(filepath.Join(path, "vulns.json"), j, 0666); err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/vulndb/govulncheck/cache_test.go b/vulndb/govulncheck/cache_test.go
new file mode 100644
index 0000000..1d3d4a2
--- /dev/null
+++ b/vulndb/govulncheck/cache_test.go
@@ -0,0 +1,77 @@
+// Copyright 2021 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 main
+
+import (
+	"os"
+	"path/filepath"
+	"reflect"
+	"testing"
+	"time"
+
+	"golang.org/x/vulndb/osv"
+)
+
+func TestCache(t *testing.T) {
+	tmpDir := t.TempDir()
+
+	cache := &fsCache{rootDir: tmpDir}
+	dbName := "vulndb.golang.org"
+
+	_, _, err := cache.ReadIndex(dbName)
+	if err != nil {
+		t.Fatalf("ReadIndex failed for non-existent database: %v", err)
+	}
+
+	if err = os.Mkdir(filepath.Join(tmpDir, dbName), 0777); err != nil {
+		t.Fatalf("os.Mkdir failed: %v", err)
+	}
+	_, _, err = cache.ReadIndex(dbName)
+	if err != nil {
+		t.Fatalf("ReadIndex failed for database without cached index: %v", err)
+	}
+
+	now := time.Now()
+	expectedIdx := osv.DBIndex{
+		"a.vuln.example.com": time.Time{}.Add(time.Hour),
+		"b.vuln.example.com": time.Time{}.Add(time.Hour * 2),
+		"c.vuln.example.com": time.Time{}.Add(time.Hour * 3),
+	}
+	if err = cache.WriteIndex(dbName, expectedIdx, now); err != nil {
+		t.Fatalf("WriteIndex failed to write index: %v", err)
+	}
+
+	idx, retrieved, err := cache.ReadIndex(dbName)
+	if err != nil {
+		t.Fatalf("ReadIndex failed for database with cached index: %v", err)
+	}
+	if !reflect.DeepEqual(idx, expectedIdx) {
+		t.Errorf("ReadIndex returned unexpected index, got:\n%s\nwant:\n%s", idx, expectedIdx)
+	}
+	if !retrieved.Equal(now) {
+		t.Errorf("ReadIndex returned unexpected retrieved: got %s, want %s", retrieved, now)
+	}
+
+	if _, err = cache.ReadEntries(dbName, "vuln.example.com"); err != nil {
+		t.Fatalf("ReadEntires failed for non-existent package: %v", err)
+	}
+
+	expectedEntries := []*osv.Entry{
+		&osv.Entry{ID: "001"},
+		&osv.Entry{ID: "002"},
+		&osv.Entry{ID: "003"},
+	}
+	if err := cache.WriteEntries(dbName, "vuln.example.com", expectedEntries); err != nil {
+		t.Fatalf("WriteEntries failed: %v", err)
+	}
+
+	entries, err := cache.ReadEntries(dbName, "vuln.example.com")
+	if err != nil {
+		t.Fatalf("ReadEntries failed for cached package: %v", err)
+	}
+	if !reflect.DeepEqual(entries, expectedEntries) {
+		t.Errorf("ReadEntries returned unexpected entries, got:\n%v\nwant:\n%v", entries, expectedEntries)
+	}
+}
diff --git a/vulndb/govulncheck/main.go b/vulndb/govulncheck/main.go
index 78d35f0..a50e426 100644
--- a/vulndb/govulncheck/main.go
+++ b/vulndb/govulncheck/main.go
@@ -11,8 +11,7 @@
 // WARNING WARNING WARNING
 //
 // govulncheck is still experimental and neither its output or the vulnerability
-// database should be relied on to be stable or comprehensive. It also performs no
-// caching of vulnerability database entries.
+// database should be relied on to be stable or comprehensive.
 package main
 
 import (
@@ -94,7 +93,7 @@
 	if GOVULNDB := os.Getenv("GOVULNDB"); GOVULNDB != "" {
 		dbs = strings.Split(GOVULNDB, ",")
 	}
-	dbClient, err := client.NewClient(dbs, client.Options{HTTPCache: client.NewFsCache()})
+	dbClient, err := client.NewClient(dbs, client.Options{HTTPCache: defaultCache()})
 	if err != nil {
 		fmt.Fprintf(os.Stderr, "govulncheck: %s\n", err)
 		os.Exit(1)
diff --git a/vulndb/govulncheck/main_test.go b/vulndb/govulncheck/main_test.go
index 6cb3987..0fe79cd 100644
--- a/vulndb/govulncheck/main_test.go
+++ b/vulndb/govulncheck/main_test.go
@@ -171,10 +171,6 @@
 		t.Logf("failed to get %s: %s", hashiVaultOkta+"@v1.6.3", out)
 		t.Fatal(err)
 	}
-	// if out, err := execCmd(e.Config.Dir, env, "go", "mod", "tidy"); err != nil {
-	// 	t.Logf("failed to mod tidy: %s", out)
-	// 	t.Fatal(err)
-	// }
 
 	// run goaudit.
 	cfg := &packages.Config{
@@ -198,6 +194,8 @@
 		// list of packages whose vulns should be addded to source
 		toAdd []string
 		want  []finding
+		// "" indicates no cache should be used
+		cacheRoot string
 	}{
 		// test local db without yaml, which should result in no findings.
 		{source: "file://" + dbPath, want: nil,
@@ -215,6 +213,12 @@
 				{"github.com/go-yaml/yaml.decoder.unmarshal", 6},
 				{"github.com/go-yaml/yaml.yaml_parser_fetch_more_tokens", 12}},
 		},
+		// repeat the last test but with cache
+		{source: "http://localhost:8080", cacheRoot: filepath.Join(e.Config.Dir, "/pkg/mod/cache/download/vulndb"),
+			want: []finding{
+				{"github.com/go-yaml/yaml.decoder.unmarshal", 6},
+				{"github.com/go-yaml/yaml.yaml_parser_fetch_more_tokens", 12}},
+		},
 	} {
 		for _, add := range test.toAdd {
 			if strings.HasPrefix(test.source, "file://") {
@@ -224,8 +228,11 @@
 			}
 		}
 
-		// TODO: add caching
-		dbClient, err := client.NewClient([]string{test.source}, client.Options{})
+		var opts client.Options
+		if test.cacheRoot != "" {
+			opts.HTTPCache = &fsCache{rootDir: test.cacheRoot}
+		}
+		dbClient, err := client.NewClient([]string{test.source}, opts)
 		if err != nil {
 			t.Error(err)
 		}
@@ -351,6 +358,8 @@
 		// list of packages whose vulns should be addded to source
 		toAdd []string
 		want  []finding
+		// "" indicates no cache should be used
+		cacheRoot string
 	}{
 		// test local db with only apiserver vuln, which should result in a single finding.
 		{source: "file://" + dbPath, toAdd: []string{"github.com/go-yaml/yaml.json", "k8s.io/apiextensions-apiserver/pkg/apiserver.json"},
@@ -372,6 +381,14 @@
 				{"golang.org/x/crypto/ssh.NewPublicKey", 4},
 				{"golang.org/x/crypto/ssh.parseED25519", 9},
 			}},
+		//repeat the last test but with a cache
+		{source: "http://localhost:8080", cacheRoot: filepath.Join(e.Config.Dir, "/pkg/mod/cache/download/vulndb"),
+			want: []finding{
+				{"golang.org/x/crypto/ssh.NewPublicKey", 1},
+				{"k8s.io/apiextensions-apiserver/pkg/apiserver.NewCustomResourceDefinitionHandler", 3},
+				{"golang.org/x/crypto/ssh.NewPublicKey", 4},
+				{"golang.org/x/crypto/ssh.parseED25519", 9},
+			}},
 	} {
 		for _, add := range test.toAdd {
 			if strings.HasPrefix(test.source, "file://") {
@@ -381,8 +398,11 @@
 			}
 		}
 
-		// TODO: add caching
-		dbClient, err := client.NewClient([]string{test.source}, client.Options{})
+		var opts client.Options
+		if test.cacheRoot != "" {
+			opts.HTTPCache = &fsCache{rootDir: test.cacheRoot}
+		}
+		dbClient, err := client.NewClient([]string{test.source}, opts)
 		if err != nil {
 			t.Error(err)
 		}