internal/proxy: add ModuleExistsAtTaggedVersion and refactor proxy package

Add a function, ModuleExistsAtTaggedVersion that will be used to check
if "+incompatible" versions exist.

Change-Id: I446454722cc046c1ac0a0824040e6caa16397570
Reviewed-on: https://go-review.googlesource.com/c/vulndb/+/528235
Reviewed-by: Damien Neil <dneil@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go
index a04bf3c..d4d46ef 100644
--- a/internal/proxy/proxy.go
+++ b/internal/proxy/proxy.go
@@ -14,10 +14,12 @@
 	"net/http"
 	"os"
 	urlpath "path"
+	"regexp"
 	"sort"
 	"strings"
 	"sync"
 
+	"golang.org/x/exp/slices"
 	"golang.org/x/mod/modfile"
 	"golang.org/x/mod/module"
 	"golang.org/x/vulndb/internal/derrors"
@@ -83,6 +85,40 @@
 	return b, nil
 }
 
+func (c *Client) list(path string) ([]byte, error) {
+	escaped, err := module.EscapePath(path)
+	if err != nil {
+		return nil, err
+	}
+	return c.lookup(fmt.Sprintf("%s/@v/list", escaped))
+}
+
+func (c *Client) latest(path string) ([]byte, error) {
+	escaped, err := module.EscapePath(path)
+	if err != nil {
+		return nil, err
+	}
+	return c.lookup(fmt.Sprintf("%s/@latest", escaped))
+}
+
+func (c *Client) info(path string, ver string) ([]byte, error) {
+	ep, ev, err := escapePathAndVersion(path, ver)
+	if err != nil {
+		return nil, err
+	}
+	return c.lookup(fmt.Sprintf("%s/@v/%v.info", ep, ev))
+}
+
+func (c *Client) mod(path string, ver string) ([]byte, error) {
+	ep, ev, err := escapePathAndVersion(path, ver)
+	if err != nil {
+		return nil, err
+	}
+	return c.lookup(fmt.Sprintf("%s/@v/%v.mod", ep, ev))
+}
+
+var commitHashRegex = regexp.MustCompile(`^[a-f0-9]+$`)
+
 func escapePathAndVersion(path, ver string) (ePath, eVersion string, err error) {
 	ePath, err = module.EscapePath(path)
 	if err != nil {
@@ -102,11 +138,7 @@
 }
 
 func (c *Client) CanonicalModulePath(path, version string) (_ string, err error) {
-	ep, ev, err := escapePathAndVersion(path, version)
-	if err != nil {
-		return "", err
-	}
-	b, err := c.lookup(fmt.Sprintf("%s/@v/%s.mod", ep, ev))
+	b, err := c.mod(path, version)
 	if err != nil {
 		return "", err
 	}
@@ -115,19 +147,30 @@
 		return "", err
 	}
 	if m.Module == nil {
-		return "", fmt.Errorf("unable to retrieve module information for %s, %s", path, string(b))
+		return "", fmt.Errorf("unable to retrieve module information for %s", path)
 	}
 	return m.Module.Mod.Path, nil
 }
 
+// ModuleExistsAtTaggedVersion returns whether the given module path exists
+// at the given version.
+// The module need not be canonical, but the version must be an unprefixed
+// canonical tagged version (e.g. 1.2.3 or 1.2.3+incompatible).
+func (c *Client) ModuleExistsAtTaggedVersion(path, version string) bool {
+	// Use this strategy to take advantage of caching.
+	// Some reports would cause this function to be called for many versions
+	// on the same module.
+	vs, err := c.versions(path)
+	if err != nil {
+		return false
+	}
+	return slices.Contains(vs, version)
+}
+
 // CanonicalModuleVersion returns the canonical version string (with no leading "v" prefix)
 // for the given module path and version string.
 func (c *Client) CanonicalModuleVersion(path, ver string) (_ string, err error) {
-	ep, ev, err := escapePathAndVersion(path, ver)
-	if err != nil {
-		return "", err
-	}
-	b, err := c.lookup(fmt.Sprintf("%s/@v/%v.info", ep, ev))
+	b, err := c.info(path, ver)
 	if err != nil {
 		return "", err
 	}
@@ -145,11 +188,7 @@
 // Latest returns the latest version of the module, with no leading "v"
 // prefix.
 func (c *Client) Latest(path string) (string, error) {
-	escaped, err := module.EscapePath(path)
-	if err != nil {
-		return "", err
-	}
-	b, err := c.lookup(fmt.Sprintf("%s/@latest", escaped))
+	b, err := c.latest(path)
 	if err != nil {
 		return "", err
 	}
@@ -167,11 +206,19 @@
 // Versions returns a list of module versions (with no leading "v" prefix),
 // sorted in ascending order.
 func (c *Client) Versions(path string) ([]string, error) {
-	escaped, err := module.EscapePath(path)
+	vs, err := c.versions(path)
 	if err != nil {
 		return nil, err
 	}
-	b, err := c.lookup(fmt.Sprintf("%s/@v/list", escaped))
+	sort.SliceStable(vs, func(i, j int) bool {
+		return version.Before(vs[i], vs[j])
+	})
+	return vs, nil
+}
+
+// versions returns an unsorted list of module versions (with no leading "v" prefix).
+func (c *Client) versions(path string) ([]string, error) {
+	b, err := c.list(path)
 	if err != nil {
 		return nil, err
 	}
@@ -182,9 +229,6 @@
 	for _, v := range strings.Split(strings.TrimSpace(string(b)), "\n") {
 		vs = append(vs, version.TrimPrefix(v))
 	}
-	sort.SliceStable(vs, func(i, j int) bool {
-		return version.Before(vs[i], vs[j])
-	})
 	return vs, nil
 }
 
@@ -195,35 +239,18 @@
 func (c *Client) FindModule(path string) (modPath string, err error) {
 	derrors.Wrap(&err, "FindModule(%s)", path)
 
-	escaped, err := module.EscapePath(path)
-	if err != nil {
-		return "", err
-	}
-
-	for candidate := escaped; candidate != "."; candidate = urlpath.Dir(candidate) {
-		if c.moduleExists(candidate) {
-			unescaped, err := module.UnescapePath(candidate)
-			if err != nil {
-				return "", err
-			}
-			return unescaped, nil
+	for candidate := path; candidate != "."; candidate = urlpath.Dir(candidate) {
+		if c.ModuleExists(candidate) {
+			return candidate, nil
 		}
 	}
 
 	return "", errNoModuleFound
 }
 
-// ModuleExists returns true if modPath is a recognized module.
-func (c *Client) ModuleExists(modPath string) bool {
-	escaped, err := module.EscapePath(modPath)
-	if err != nil {
-		return false
-	}
-	return c.moduleExists(escaped)
-}
-
-func (c *Client) moduleExists(escaped string) bool {
-	_, err := c.lookup(fmt.Sprintf("%s/@v/list", escaped))
+// ModuleExists returns true if path is a recognized module.
+func (c *Client) ModuleExists(path string) bool {
+	_, err := c.list(path)
 	return err == nil
 }
 
diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go
index c4e0e6a..7414ce7 100644
--- a/internal/proxy/proxy_test.go
+++ b/internal/proxy/proxy_test.go
@@ -99,6 +99,59 @@
 	}
 }
 
+func TestModuleExistsAtTaggedVersion(t *testing.T) {
+	c, err := NewTestClient(t, *realProxy)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	tcs := []struct {
+		name    string
+		path    string
+		version string
+		want    bool
+	}{
+		{
+			name:    "exists",
+			path:    "golang.org/x/vuln",
+			version: "0.1.0",
+			want:    true,
+		},
+		{
+			name:    "non-canonical module ok",
+			path:    "github.com/golang/vuln",
+			version: "0.1.0",
+			want:    true,
+		},
+		{
+			name:    "non-canonical version not OK",
+			path:    "golang.org/x/vulndb",
+			version: "0cbf4ffdb4e70fce663ec8d59198745b04e7801b",
+			want:    false,
+		},
+		{
+			name:    "module exists, version does not",
+			path:    "golang.org/x/vulndb",
+			version: "1.0.0",
+			want:    false,
+		},
+		{
+			name:    "neither exist",
+			path:    "golang.org/x/notamod",
+			version: "1.0.0",
+			want:    false,
+		},
+	}
+
+	for _, tc := range tcs {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := c.ModuleExistsAtTaggedVersion(tc.path, tc.version); got != tc.want {
+				t.Errorf("ModuleExistsAtTaggedVersion() = %v, want %v", got, tc.want)
+			}
+		})
+	}
+}
+
 func TestVersions(t *testing.T) {
 	c, err := NewTestClient(t, *realProxy)
 	if err != nil {
diff --git a/internal/proxy/testdata/proxy/TestModuleExistsAtTaggedVersion.json b/internal/proxy/testdata/proxy/TestModuleExistsAtTaggedVersion.json
new file mode 100644
index 0000000..32a6da7
--- /dev/null
+++ b/internal/proxy/testdata/proxy/TestModuleExistsAtTaggedVersion.json
@@ -0,0 +1,16 @@
+{
+	"github.com/golang/vuln/@v/list": {
+		"body": "v1.0.0\nv0.1.0\nv1.0.1\nv0.2.0\n",
+		"status_code": 200
+	},
+	"golang.org/x/notamod/@v/list": {
+		"status_code": 404
+	},
+	"golang.org/x/vuln/@v/list": {
+		"body": "v1.0.0\nv0.1.0\nv1.0.1\nv0.2.0\n",
+		"status_code": 200
+	},
+	"golang.org/x/vulndb/@v/list": {
+		"status_code": 200
+	}
+}
\ No newline at end of file