imports_test.go: check package dependencies

Add a test to prevent unwanted dependencies.

Change-Id: Ia6755057b1f7fd047ab7c33c3125e417acb04615
Reviewed-on: https://go-review.googlesource.com/c/oscar/+/630356
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/go.mod b/go.mod
index b466639..eca6661 100644
--- a/go.mod
+++ b/go.mod
@@ -11,9 +11,10 @@
 	github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7
 	go.opentelemetry.io/otel v1.32.0
 	go.opentelemetry.io/otel/metric v1.32.0
+	golang.org/x/mod v0.22.0
 	golang.org/x/net v0.31.0
 	golang.org/x/oauth2 v0.24.0
-	golang.org/x/oscar/internal/gcp v0.0.0-20241121213744-9a4fac3173f0
+	golang.org/x/oscar/internal/gcp v0.0.0-20241122155449-dd28296a9ae0
 	golang.org/x/term v0.26.0
 	golang.org/x/tools v0.27.0
 	gopkg.in/yaml.v3 v3.0.1
diff --git a/go.sum b/go.sum
index 4c9ec0d..d0d4972 100644
--- a/go.sum
+++ b/go.sum
@@ -195,6 +195,8 @@
 golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
+golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -209,8 +211,8 @@
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
 golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
-golang.org/x/oscar/internal/gcp v0.0.0-20241121213744-9a4fac3173f0 h1:AcWfPRR5l8xzk+kTPEegvYBNvSK5aA38OVRv5T0vIU0=
-golang.org/x/oscar/internal/gcp v0.0.0-20241121213744-9a4fac3173f0/go.mod h1:O1+YtaRykkq9M83UlGvCcYLPgEwofG6KbD+FwDXwj/s=
+golang.org/x/oscar/internal/gcp v0.0.0-20241122155449-dd28296a9ae0 h1:C/djlnYqFIzAhEl6bswKdJhu6UM/ATByB668fStVx1M=
+golang.org/x/oscar/internal/gcp v0.0.0-20241122155449-dd28296a9ae0/go.mod h1:O1+YtaRykkq9M83UlGvCcYLPgEwofG6KbD+FwDXwj/s=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
diff --git a/internal/importtest/import_test.go b/internal/importtest/import_test.go
new file mode 100644
index 0000000..0012360
--- /dev/null
+++ b/internal/importtest/import_test.go
@@ -0,0 +1,303 @@
+// Copyright 2024 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.
+
+// The test in this file enforces restrictions on package dependencies.
+
+package oscar
+
+import (
+	"errors"
+	"fmt"
+	"maps"
+	"slices"
+	"strings"
+	"testing"
+
+	"golang.org/x/mod/module"
+	"golang.org/x/tools/go/packages"
+)
+
+// A rule describes the allowed dependencies for one or more packages.
+// The patterns in the rule are a subset of the patterns used by the go command.
+// A pattern is either a literal import path, which matches only itself,
+// or of the form "P/...", which matches P and any path with the prefix "P/".
+// As a special case, the pattern "..." matches every path.
+type rule struct {
+	// A pattern describing the packages to which this rule applies.
+	// Paths are relative to the module root (this directory).
+	// For example, "internal/gaby".
+	packages string
+
+	// Patterns describing paths that the above packages can import.
+	// A package to which this rule applies must only import a package
+	// matched by one of these patterns.
+	// These patterns describe full import paths, for example "rsc.io/...".
+	// Standard library packages are always allowed.
+	// A value of nil means only standard library packages are allowed.
+	allow []string
+}
+
+// Packages that are always allowed.
+var alwaysAllowed = []string{
+	"golang.org/...",
+	"google.golang.org/...",
+	"github.com/golang/...",
+	"github.com/google/...",
+	"github.com/cockroachdb/pebble",
+	"rsc.io/markdown",
+	"rsc.io/omap",
+	"rsc.io/ordered",
+	"rsc.io/top",
+	"github.com/shurcooL/githubv4",
+	"github.com/shurcooL/graphql/...",
+	"gopkg.in/yaml.v3",
+}
+
+var anything = []string{"..."}
+
+// rules is the list of rules for this module.
+// For each package, only the first matching rule is used.
+var rules = []*rule{
+	{
+		packages: "internal/syncdb/...",
+		allow:    anything,
+	},
+	{
+		packages: "internal/devtools/...",
+		allow:    anything,
+	},
+	{
+		packages: "internal/gaby/...",
+		allow:    anything,
+	},
+	{
+		packages: "internal/gcp/...",
+		allow:    anything,
+	},
+	{
+		packages: "internal/pebble/...",
+		allow:    anything,
+	},
+	// The remaining packages under internal should not depend on GCP.
+	{
+		packages: "internal/...",
+		allow:    alwaysAllowed,
+	},
+}
+
+const pkgPathPrefix = "golang.org/x/oscar/"
+
+type Package = packages.Package
+
+func TestDependencies(t *testing.T) {
+	for _, r := range rules {
+		if err := r.check(); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	cfg := &packages.Config{
+		Mode:  packages.NeedName | packages.NeedImports | packages.NeedDeps,
+		Tests: true,
+	}
+	pkgs, err := packages.Load(cfg, pkgPathPrefix+"...")
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, pkg := range pkgs {
+		if len(pkg.Errors) > 0 {
+			t.Errorf("package %s has errors: %v", pkg.PkgPath, pkg.Errors)
+			continue
+		}
+		relativePkgPath := strings.TrimPrefix(pkg.PkgPath, pkgPathPrefix)
+		var mr *rule
+		for _, r := range rules {
+			if matchPattern(r.packages, relativePkgPath) {
+				mr = r
+				break
+			}
+		}
+		if mr == nil {
+			t.Fatalf("no rule matches import path %q", relativePkgPath)
+		}
+		deps := dependencies(pkg)
+		for _, d := range deps {
+			if !mr.allowed(d) {
+				t.Errorf("package %s depends on disallowed package %s", relativePkgPath, d)
+			}
+		}
+	}
+}
+
+// dependencies returns all the package paths that pkgdepends on, recursively.
+func dependencies(pkg *Package) []string {
+	depset := map[string]bool{}
+
+	var deps func(*Package, int)
+	deps = func(p *Package, level int) {
+		if inStdLib(p.PkgPath) || depset[p.PkgPath] {
+			return
+		}
+		for _, ip := range p.Imports {
+			deps(ip, level+1)
+			depset[ip.PkgPath] = true
+		}
+	}
+
+	deps(pkg, 1)
+	return slices.Collect(maps.Keys(depset))
+}
+
+func (r *rule) allowed(pkg string) bool {
+	// Standard library packages are always allowed.
+	if inStdLib(pkg) {
+		return true
+	}
+	for _, a := range r.allow {
+		if matchPattern(a, pkg) {
+			return true
+		}
+	}
+	return false
+}
+
+func TestAllowed(t *testing.T) {
+	r := &rule{packages: "...", allow: []string{"a.com/b/...", "x.org/y/z"}}
+	if err := r.check(); err != nil {
+		t.Fatal(err)
+	}
+	for _, tc := range []struct {
+		pkg  string
+		want bool
+	}{
+		{"a.com/b", true},
+		{"a.com/b/c", true},
+		{"a.com/b/c/d/e", true},
+		{"x.org/y", false},
+		{"x.org/y/z", true},
+		{"x.org/y/z/w", false},
+		{"a.org/b/c", false},
+	} {
+		got := r.allowed(tc.pkg)
+		if got != tc.want {
+			t.Errorf("%q: got %t, want %t", tc.pkg, got, tc.want)
+		}
+	}
+}
+
+// inStdLib reports whether the given import path could be part of the Go standard library,
+// by reporting whether the first component lacks a '.'.
+func inStdLib(path string) bool {
+	if i := strings.IndexByte(path, '/'); i != -1 {
+		path = path[:i]
+	}
+	return !strings.Contains(path, ".")
+}
+
+// check validates a rule.
+func (r *rule) check() error {
+	if err := checkPattern(r.packages); err != nil {
+		return err
+	}
+	for _, a := range r.allow {
+		if err := checkPattern(a); err != nil {
+			return err
+		}
+		if inStdLib(a) {
+			return fmt.Errorf("%q matches standard library packages, which are always allowed", a)
+		}
+	}
+	return nil
+}
+
+// checkPattern validates a pattern.
+func checkPattern(pattern string) error {
+	prefix, found := strings.CutSuffix(pattern, "...")
+	if found {
+		if prefix == "" {
+			return nil
+		}
+		if prefix[len(prefix)-1] != '/' {
+			return fmt.Errorf("invalid pattern %q: does not end in '/...'", pattern)
+		}
+		prefix = prefix[:len(prefix)-1]
+	}
+	if strings.Contains(prefix, "...") {
+		return fmt.Errorf("invalid pattern %q: '...' not at the end", pattern)
+	} else if prefix == "" {
+		return errors.New("empty pattern")
+	}
+	return module.CheckImportPath(prefix)
+}
+
+func TestCheckPattern(t *testing.T) {
+	for _, tc := range []struct {
+		in   string
+		want string
+	}{
+		{"a/b/c", ""},
+		{"a/b/c/...", ""},
+		{"...", ""},
+		{"", "empty"},
+		{"a/.../b", "invalid"},
+		{"a/b/", "malformed"},
+		{"a/b/....", "invalid"},
+		{"/...", "empty"},
+		{"/a/b/...", "malformed"},
+	} {
+		got := checkPattern(tc.in)
+		if got == nil {
+			if tc.want != "" {
+				t.Errorf("%q: unexpected success", tc.in)
+			}
+		} else if tc.want == "" {
+			t.Errorf("%q: unexpected error %q", tc.in, got)
+		} else if !strings.Contains(got.Error(), tc.want) {
+			t.Errorf("%q: error %q does not contain %q", tc.in, got, tc.want)
+		}
+	}
+}
+
+// matchPattern reports whether pattern matches target.
+// It assumes pattern has been validated with checkPattern.
+// matchPattern will treat a test package path, with the suffix ".test",
+// as equivalent to the non-test package.
+func matchPattern(pattern, target string) bool {
+	prefix, found := strings.CutSuffix(pattern, "...")
+	if !found {
+		return target == pattern || strings.TrimSuffix(target, ".test") == pattern
+	}
+	if prefix == "" {
+		// The pattern "..." matches everything.
+		return true
+	}
+
+	matches := func(targ string) bool {
+		return targ == prefix[:len(prefix)-1] || strings.HasPrefix(targ, prefix)
+	}
+
+	return matches(target) || matches(strings.TrimSuffix(target, ".test"))
+}
+
+func TestMatchPattern(t *testing.T) {
+	target := "some/import/path.test"
+	for _, tc := range []struct {
+		pattern string
+		want    bool
+	}{
+		{"some/import/path", true},
+		{"some/import/...", true},
+		{"some/...", true},
+		{"...", true},
+		{"some/other/...", false},
+	} {
+		if err := checkPattern(tc.pattern); err != nil {
+			t.Fatal(err)
+		}
+		got := matchPattern(tc.pattern, target)
+		if got != tc.want {
+			t.Errorf("%q: got %t, want %t", tc.pattern, got, tc.want)
+		}
+	}
+}