[resolve] Add option to restrict hostnames of projects

This change adds a new option '--allow-hosts' to jiri resolve to
allow Bots to prevent users using unapproved third_party repositories.
When jiri detects a project is not using a hostname in this allow
list, jiri resolve will fail.

Bug: IN-283
Test: Local and CQ

Change-Id: Ic235098e7255d0f448c5fe514beffddef2e1c81e
diff --git a/cmd/jiri/resolve.go b/cmd/jiri/resolve.go
index 70dca7a..702f357 100644
--- a/cmd/jiri/resolve.go
+++ b/cmd/jiri/resolve.go
@@ -5,6 +5,8 @@
 package main
 
 import (
+	"strings"
+
 	"fuchsia.googlesource.com/jiri"
 	"fuchsia.googlesource.com/jiri/cmdline"
 	"fuchsia.googlesource.com/jiri/project"
@@ -17,6 +19,7 @@
 	enableProjectLock    bool
 	enablePackageVersion bool
 	allowFloatingRefs    bool
+	hostnameAllowList    string
 }
 
 func (r *resolveFlags) AllowFloatingRefs() bool {
@@ -39,8 +42,17 @@
 	return r.enableProjectLock
 }
 
-func (r *resolveFlags) EnablePackageVersion() bool {
-	return r.enablePackageVersion
+func (r *resolveFlags) HostnameAllowList() []string {
+	ret := make([]string, 0)
+	hosts := strings.Split(r.hostnameAllowList, ",")
+	for _, item := range hosts {
+		item = strings.TrimSpace(item)
+		if item == "" {
+			continue
+		}
+		ret = append(ret, item)
+	}
+	return ret
 }
 
 var resolveFlag resolveFlags
@@ -63,9 +75,8 @@
 	flags.BoolVar(&resolveFlag.localManifestFlag, "local-manifest", false, "Use local manifest")
 	flags.BoolVar(&resolveFlag.enablePackageLock, "enable-package-lock", true, "Enable resolving packages in lockfile")
 	flags.BoolVar(&resolveFlag.enableProjectLock, "enable-project-lock", false, "Enable resolving projects in lockfile")
-	// TODO: Remove this placeholder flag after all references are deleted.
-	flags.BoolVar(&resolveFlag.enablePackageVersion, "enable-package-version", true, "Enable version tag for packages in lockfile")
 	flags.BoolVar(&resolveFlag.allowFloatingRefs, "allow-floating-refs", false, "Allow packages to be pinned to floating refs such as \"latest\"")
+	flags.StringVar(&resolveFlag.hostnameAllowList, "allow-hosts", "", "List of hostnames that can be used in the url of a repository, seperated by comma. It will not be enforced if it is left empty.")
 }
 
 func runResolve(jirix *jiri.X, args []string) error {
diff --git a/project/project.go b/project/project.go
index f29bb3e..f7faf4c 100644
--- a/project/project.go
+++ b/project/project.go
@@ -359,7 +359,7 @@
 	LocalManifest() bool
 	EnablePackageLock() bool
 	EnableProjectLock() bool
-	EnablePackageVersion() bool
+	HostnameAllowList() []string
 }
 
 // UnmarshalLockEntries unmarshals project locks and package locks from
@@ -1010,6 +1010,64 @@
 	return nil
 }
 
+// HostnameAllowed determines if hostname is allowed under reference.
+// This function allows a single prefix '*' for wildcard matching E.g.
+// "*.google.com" will match "fuchsia.google.com" but does not match
+// "google.com".
+func HostnameAllowed(reference, hostname string) bool {
+	if strings.Count(reference, "*") > 1 || (strings.Count(reference, "*") == 1 && reference[0] != '*') {
+		return false
+	}
+	if !strings.HasPrefix(reference, "*") {
+		return reference == hostname
+	}
+	reference = reference[1:]
+	i := len(reference) - 1
+	j := len(hostname) - 1
+	for i >= 0 && j >= 0 {
+		if hostname[j] != reference[i] {
+			return false
+		}
+		i--
+		j--
+	}
+	if i >= 0 {
+		return false
+	}
+	return true
+}
+
+// CheckProjectsHostnames checks if the hostname of every project is allowed
+// under allowList. If allowList is empty, the check is skipped.
+func CheckProjectsHostnames(projects Projects, allowList []string) error {
+	if len(allowList) > 0 {
+		for _, item := range allowList {
+			if strings.Count(item, "*") > 1 || (strings.Count(item, "*") == 1 && item[0] != '*') {
+				return fmt.Errorf("failed to process %q. Only a single * at the beginning of a hostname is supported", item)
+			}
+		}
+		for _, proj := range projects {
+			projURL, err := url.Parse(proj.Remote)
+			if err != nil {
+				return fmt.Errorf("URL of project %q cannot be parsed due to error: %v", proj.Name, err)
+			}
+			remoteHost := projURL.Hostname()
+			allowed := false
+			for _, item := range allowList {
+				if HostnameAllowed(item, remoteHost) {
+					allowed = true
+					break
+				}
+			}
+			if !allowed {
+				err := fmt.Errorf("hostname: %s in project %s is not allowed", remoteHost, proj.Name)
+				return err
+			}
+		}
+	}
+	return nil
+}
+
 // GenerateJiriLockFile generates jiri lockfile to lockFilePath using
 // manifests in manifestFiles slice.
 func GenerateJiriLockFile(jirix *jiri.X, manifestFiles []string, resolveConfig ResolveConfig) error {
@@ -1020,6 +1078,10 @@
 		if err != nil {
 			return nil, nil, err
 		}
+		// Check hostnames of projects.
+		if err := CheckProjectsHostnames(projects, resolveConfig.HostnameAllowList()); err != nil {
+			return nil, nil, err
+		}
 		if resolveConfig.EnableProjectLock() {
 			projectLocks, err = resolveProjectLocks(jirix, projects)
 			if err != nil {
diff --git a/project/project_test.go b/project/project_test.go
index 0e10e66..ed41f57 100644
--- a/project/project_test.go
+++ b/project/project_test.go
@@ -2567,3 +2567,91 @@
 		t.Errorf("expected git hash %q, got %q", testHash, currentHash)
 	}
 }
+
+func TestHostnameAllowed(t *testing.T) {
+	tests := map[string]bool{
+		"*.google.com,fuchsia.google.com":       true,
+		"*.google.com,fuchsia.dev.google.com":   true,
+		"google.com,google.com":                 true,
+		"*google.com,fuchsiagoogle.com":         true,
+		"google.com,fuchsiagoogle.com":          false,
+		"google.com,oogle.com":                  false,
+		"fuchsia-internal,fuchsia-internal":     true,
+		"fuchsia-internal,fuchsia":              false,
+		",":                                     true,
+		"*google*.com,fuchsia.googlesource.com": false,
+	}
+	for k, v := range tests {
+		test := strings.Split(k, ",")
+		if len(test) != 2 {
+			t.Errorf("expecting a single ',' in %q", k)
+		}
+		ret := project.HostnameAllowed(test[0], test[1])
+		if v != ret {
+			t.Errorf("expecting %v, got %v from test %q", v, ret, k)
+		}
+	}
+}
+
+func TestCheckProjectsHostnames(t *testing.T) {
+	allowList := []string{
+		"*.googlesource.com",
+		"fuchsia-internal",
+	}
+	allowListIllegal := []string{
+		"*.google*.com",
+		"fuchsia-internal",
+	}
+	testProjectListsTrue := []project.Project{
+		{
+			Name:   "project1",
+			Remote: "https://fuchsia.googlesource.com/project1",
+		},
+		{
+			Name:   "project2",
+			Remote: "https://chromium.googlesource.com/project2",
+		},
+		{
+			Name:   "project4",
+			Remote: "sso://fuchsia-internal/project4",
+		},
+	}
+	testProjectListsFalse := []project.Project{
+		{
+			Name:   "project1",
+			Remote: "https://fuchsia.googlesource.com/project1",
+		},
+		{
+			Name:   "project2",
+			Remote: "https://chromium.googlesource.com/project2",
+		},
+		{
+			Name:   "project3",
+			Remote: "https://test.github.com/project3",
+		},
+		{
+			Name:   "project4",
+			Remote: "sso://fuchsia-internal/project4",
+		},
+	}
+
+	mapTrue := make(project.Projects)
+	for _, item := range testProjectListsTrue {
+		mapTrue[item.Key()] = item
+	}
+	if err := project.CheckProjectsHostnames(mapTrue, allowList); err != nil {
+		t.Errorf("expecting nil from CheckProjectsHostnames, but got: %v", err)
+	}
+
+	mapFalse := make(project.Projects)
+	for _, item := range testProjectListsFalse {
+		mapFalse[item.Key()] = item
+	}
+	if err := project.CheckProjectsHostnames(mapFalse, allowList); err == nil {
+		t.Errorf("expecting error from CheckProjectsHostnames, but got nil")
+	}
+
+	if err := project.CheckProjectsHostnames(mapTrue, allowListIllegal); err == nil {
+		t.Errorf("expecting error from CheckProjectsHostnames, but got nil")
+	}
+}