internal/fetch: add a ModuleGetter for a proxy-like filesystem

Add fsModuleGetter, which is a ModuleGetter for a directory organized
like proxy URLs.  The go module download cache is in this format.

For golang/go#47780

Change-Id: Ifccdfa3010874adddc0a600593699d454e44c0e3
Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/343220
Trust: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Julie Qiu <julie@golang.org>
TryBot-Result: kokoro <noreply+kokoro@google.com>
diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go
index b54dd6f..91806f7 100644
--- a/internal/fetch/fetch.go
+++ b/internal/fetch/fetch.go
@@ -94,7 +94,6 @@
 	// Info returns basic information about the module.
 	Info(ctx context.Context, path, version string) (*proxy.VersionInfo, error)
 	// Mod returns the contents of the module's go.mod file.
-	// If the file does not exist, it returns a synthesized one.
 	Mod(ctx context.Context, path, version string) ([]byte, error)
 	// Zip returns a reader for the module's zip file.
 	Zip(ctx context.Context, path, version string) (*zip.Reader, error)
diff --git a/internal/fetch/fs.go b/internal/fetch/fs.go
new file mode 100644
index 0000000..7624119
--- /dev/null
+++ b/internal/fetch/fs.go
@@ -0,0 +1,97 @@
+// 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 fetch
+
+import (
+	"archive/zip"
+	"bytes"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+
+	"golang.org/x/mod/module"
+	"golang.org/x/pkgsite/internal/derrors"
+	"golang.org/x/pkgsite/internal/proxy"
+)
+
+// An fsModuleGetter gets modules from a directory in the filesystem
+// that is organized like the proxy, with paths that correspond to proxy
+// URLs. An example of such a directory is $(go env GOMODCACHE)/cache/download.
+type fsModuleGetter struct {
+	dir string
+}
+
+// NewFSModuleGetter return a ModuleGetter that reads modules from a filesystem
+// directory organized like the proxy.
+func NewFSModuleGetter(dir string) ModuleGetter {
+	return &fsModuleGetter{dir: dir}
+}
+
+// Info returns basic information about the module.
+func (g *fsModuleGetter) Info(ctx context.Context, path, version string) (_ *proxy.VersionInfo, err error) {
+	defer derrors.Wrap(&err, "fsModuleGetter.Info(%q, %q)", path, version)
+
+	data, err := g.readFile(path, version, "info")
+	if err != nil {
+		return nil, err
+	}
+	var info proxy.VersionInfo
+	if err := json.Unmarshal(data, &info); err != nil {
+		return nil, err
+	}
+	return &info, nil
+}
+
+// Mod returns the contents of the module's go.mod file.
+func (g *fsModuleGetter) Mod(ctx context.Context, path, version string) (_ []byte, err error) {
+	defer derrors.Wrap(&err, "fsModuleGetter.Mod(%q, %q)", path, version)
+
+	return g.readFile(path, version, "mod")
+}
+
+// Zip returns a reader for the module's zip file.
+func (g *fsModuleGetter) Zip(ctx context.Context, path, version string) (_ *zip.Reader, err error) {
+	defer derrors.Wrap(&err, "fsModuleGetter.Zip(%q, %q)", path, version)
+
+	data, err := g.readFile(path, version, "zip")
+	if err != nil {
+		return nil, err
+	}
+	return zip.NewReader(bytes.NewReader(data), int64(len(data)))
+}
+
+// ZipSize returns the approximate size of the zip file in bytes.
+func (g *fsModuleGetter) ZipSize(ctx context.Context, path, version string) (int64, error) {
+	return 0, errors.New("fsModuleGetter.ZipSize unimplemented")
+}
+
+func (g *fsModuleGetter) readFile(path, version, suffix string) (_ []byte, err error) {
+	epath, err := g.escapedPath(path, version, suffix)
+	if err != nil {
+		return nil, err
+	}
+	f, err := os.Open(epath)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+	return ioutil.ReadAll(f)
+}
+
+func (g *fsModuleGetter) escapedPath(modulePath, version, suffix string) (string, error) {
+	ep, err := module.EscapePath(modulePath)
+	if err != nil {
+		return "", fmt.Errorf("path: %v: %w", err, derrors.InvalidArgument)
+	}
+	ev, err := module.EscapeVersion(version)
+	if err != nil {
+		return "", fmt.Errorf("version: %v: %w", err, derrors.InvalidArgument)
+	}
+	return filepath.Join(g.dir, ep, "@v", fmt.Sprintf("%s.%s", ev, suffix)), nil
+}
diff --git a/internal/fetch/fs_test.go b/internal/fetch/fs_test.go
new file mode 100644
index 0000000..c4e3988
--- /dev/null
+++ b/internal/fetch/fs_test.go
@@ -0,0 +1,102 @@
+// 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 fetch
+
+import (
+	"context"
+	"fmt"
+	"io/ioutil"
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+	"golang.org/x/pkgsite/internal/proxy"
+)
+
+func TestEscapedPath(t *testing.T) {
+	for _, test := range []struct {
+		path, version, suffix string
+		want                  string
+	}{
+		{
+			"m.com", "v1", "info",
+			"dir/m.com/@v/v1.info",
+		},
+		{
+			"github.com/aBc", "v2.3.4", "zip",
+			"dir/github.com/a!bc/@v/v2.3.4.zip",
+		},
+	} {
+		g := NewFSModuleGetter("dir").(*fsModuleGetter)
+		got, err := g.escapedPath(test.path, test.version, test.suffix)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if got != test.want {
+			t.Errorf("%s, %s, %s: got %q, want %q", test.path, test.version, test.suffix, got, test.want)
+		}
+	}
+}
+
+func TestFSGetter(t *testing.T) {
+	ctx := context.Background()
+	const (
+		modulePath = "github.com/jackc/pgio"
+		version    = "v1.0.0"
+		goMod      = "module github.com/jackc/pgio\n\ngo 1.12\n"
+	)
+	ts, err := time.Parse(time.RFC3339, "2019-03-30T17:04:38Z")
+	if err != nil {
+		t.Fatal(err)
+	}
+	g := NewFSModuleGetter("testdata/modcache")
+	t.Run("info", func(t *testing.T) {
+		got, err := g.Info(ctx, modulePath, version)
+		if err != nil {
+			t.Fatal(err)
+		}
+		want := &proxy.VersionInfo{Version: version, Time: ts}
+		if !cmp.Equal(got, want) {
+			t.Errorf("got %+v, want %+v", got, want)
+		}
+	})
+	t.Run("mod", func(t *testing.T) {
+		got, err := g.Mod(ctx, modulePath, version)
+		if err != nil {
+			t.Fatal(err)
+		}
+		want := []byte(goMod)
+		if !cmp.Equal(got, want) {
+			t.Errorf("got %q, want %q", got, want)
+		}
+	})
+	t.Run("zip", func(t *testing.T) {
+		zr, err := g.Zip(ctx, modulePath, version)
+		if err != nil {
+			t.Fatal(err)
+		}
+		// Just check that the go.mod file is there and has the right contents.
+		goModPath := fmt.Sprintf("%s@%s/go.mod", modulePath, version)
+		for _, f := range zr.File {
+			if f.Name == goModPath {
+				f, err := f.Open()
+				if err != nil {
+					t.Fatal(err)
+				}
+				defer f.Close()
+				got, err := ioutil.ReadAll(f)
+				if err != nil {
+					t.Fatal(err)
+				}
+				want := []byte(goMod)
+				if !cmp.Equal(got, want) {
+					t.Errorf("got %q, want %q", got, want)
+				}
+				return
+			}
+		}
+		t.Fatal("go.mod not found")
+	})
+}
diff --git a/internal/fetch/testdata/modcache/github.com/jackc/pgio/@v/v1.0.0.info b/internal/fetch/testdata/modcache/github.com/jackc/pgio/@v/v1.0.0.info
new file mode 100644
index 0000000..2e4d188
--- /dev/null
+++ b/internal/fetch/testdata/modcache/github.com/jackc/pgio/@v/v1.0.0.info
@@ -0,0 +1 @@
+{"Version":"v1.0.0","Time":"2019-03-30T17:04:38Z"}
\ No newline at end of file
diff --git a/internal/fetch/testdata/modcache/github.com/jackc/pgio/@v/v1.0.0.mod b/internal/fetch/testdata/modcache/github.com/jackc/pgio/@v/v1.0.0.mod
new file mode 100644
index 0000000..c1efddd
--- /dev/null
+++ b/internal/fetch/testdata/modcache/github.com/jackc/pgio/@v/v1.0.0.mod
@@ -0,0 +1,3 @@
+module github.com/jackc/pgio
+
+go 1.12
diff --git a/internal/fetch/testdata/modcache/github.com/jackc/pgio/@v/v1.0.0.zip b/internal/fetch/testdata/modcache/github.com/jackc/pgio/@v/v1.0.0.zip
new file mode 100644
index 0000000..9434545
--- /dev/null
+++ b/internal/fetch/testdata/modcache/github.com/jackc/pgio/@v/v1.0.0.zip
Binary files differ