module: add a MatchPrefixPatterns function, for matching GOPRIVATE, etc.

This CL exposes a new MatchPrefixPatterns function, extracted from
GlobsMatchPath in src/cmd/go/internal/str. Tool authors can use this to
identify non-public modules by matching against GOPRIVATE, as is
explicitly suggested by `go help module-private`.

Fixes golang/go#38725

Change-Id: I5be79b49c2c13f2d68c7421a06747a297f48f21a
Reviewed-on: https://go-review.googlesource.com/c/mod/+/239797
Run-TryBot: Robert Findley <rfindley@google.com>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Jay Conrod <jayconrod@google.com>
diff --git a/module/module.go b/module/module.go
index 6cd3728..3a8b080 100644
--- a/module/module.go
+++ b/module/module.go
@@ -97,6 +97,7 @@
 
 import (
 	"fmt"
+	"path"
 	"sort"
 	"strings"
 	"unicode"
@@ -716,3 +717,49 @@
 	}
 	return string(buf), true
 }
+
+// MatchPrefixPatterns reports whether any path prefix of target matches one of
+// the glob patterns (as defined by path.Match) in the comma-separated globs
+// list. This implements the algorithm used when matching a module path to the
+// GOPRIVATE environment variable, as described by 'go help module-private'.
+//
+// It ignores any empty or malformed patterns in the list.
+func MatchPrefixPatterns(globs, target string) bool {
+	for globs != "" {
+		// Extract next non-empty glob in comma-separated list.
+		var glob string
+		if i := strings.Index(globs, ","); i >= 0 {
+			glob, globs = globs[:i], globs[i+1:]
+		} else {
+			glob, globs = globs, ""
+		}
+		if glob == "" {
+			continue
+		}
+
+		// A glob with N+1 path elements (N slashes) needs to be matched
+		// against the first N+1 path elements of target,
+		// which end just before the N+1'th slash.
+		n := strings.Count(glob, "/")
+		prefix := target
+		// Walk target, counting slashes, truncating at the N+1'th slash.
+		for i := 0; i < len(target); i++ {
+			if target[i] == '/' {
+				if n == 0 {
+					prefix = target[:i]
+					break
+				}
+				n--
+			}
+		}
+		if n > 0 {
+			// Not enough prefix elements.
+			continue
+		}
+		matched, _ := path.Match(glob, prefix)
+		if matched {
+			return true
+		}
+	}
+	return false
+}
diff --git a/module/module_test.go b/module/module_test.go
index bdf38c3..1a6115f 100644
--- a/module/module_test.go
+++ b/module/module_test.go
@@ -340,3 +340,37 @@
 		}
 	}
 }
+
+func TestMatchPrefixPatterns(t *testing.T) {
+	for _, test := range []struct {
+		globs, target string
+		want          bool
+	}{
+		{"*/quote", "rsc.io/quote", true},
+		{"*/quo", "rsc.io/quote", false},
+		{"*/quo??", "rsc.io/quote", true},
+		{"*/quo*", "rsc.io/quote", true},
+		{"*quo*", "rsc.io/quote", false},
+		{"rsc.io", "rsc.io/quote", true},
+		{"*.io", "rsc.io/quote", true},
+		{"rsc.io/", "rsc.io/quote", false},
+		{"rsc", "rsc.io/quote", false},
+		{"rsc*", "rsc.io/quote", true},
+
+		{"rsc.io", "rsc.io/quote/v3", true},
+		{"*/quote", "rsc.io/quote/v3", true},
+		{"*/quote/*", "rsc.io/quote/v3", true},
+		{"*/v3", "rsc.io/quote/v3", false},
+		{"*/*/v3", "rsc.io/quote/v3", true},
+		{"*/*/*", "rsc.io/quote/v3", true},
+		{"*/*/*", "rsc.io/quote", false},
+
+		{"*/*/*,,", "rsc.io/quote", false},
+		{"*/*/*,,*/quote", "rsc.io/quote", true},
+		{",,*/quote", "rsc.io/quote", true},
+	} {
+		if got := MatchPrefixPatterns(test.globs, test.target); got != test.want {
+			t.Errorf("MatchPrefixPatterns(%q, %q) = %t, want %t", test.globs, test.target, got, test.want)
+		}
+	}
+}