cmd/go: use Join functions instead of adding path separators to strings

Adding a file path separator is incorrect for a file path that may be
the root directory on a Unix platform (such as in a container or
chroot).

Adding a path separator is incorrect for a package path prefix that
may be the empty string (as in the "std" module in GOROOT/src).

And in both cases, a Join function is arguably clearer and simpler
anyway.

Fixes #51506 (maybe).

Change-Id: Id816930811ad5e4d1fbd206cddf219ecd4ad39a2
Reviewed-on: https://go-review.googlesource.com/c/go/+/463178
Reviewed-by: Russ Cox <rsc@golang.org>
Auto-Submit: Bryan Mills <bcmills@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Run-TryBot: Bryan Mills <bcmills@google.com>
diff --git a/src/cmd/go/internal/cfg/cfg.go b/src/cmd/go/internal/cfg/cfg.go
index 5633352..a6ad739 100644
--- a/src/cmd/go/internal/cfg/cfg.go
+++ b/src/cmd/go/internal/cfg/cfg.go
@@ -389,13 +389,17 @@
 }
 
 var (
-	GOROOT       string
-	GOROOTbin    string
-	GOROOTpkg    string
-	GOROOTsrc    string
+	GOROOT string
+
+	// Either empty or produced by filepath.Join(GOROOT, …).
+	GOROOTbin string
+	GOROOTpkg string
+	GOROOTsrc string
+
 	GOROOT_FINAL string
-	GOBIN        = Getenv("GOBIN")
-	GOMODCACHE   = envOr("GOMODCACHE", gopathDir("pkg/mod"))
+
+	GOBIN      = Getenv("GOBIN")
+	GOMODCACHE = envOr("GOMODCACHE", gopathDir("pkg/mod"))
 
 	// Used in envcmd.MkEnv and build ID computations.
 	GOARM    = envOr("GOARM", fmt.Sprint(buildcfg.GOARM))
diff --git a/src/cmd/go/internal/load/pkg.go b/src/cmd/go/internal/load/pkg.go
index 7aee656..9e6b3eb 100644
--- a/src/cmd/go/internal/load/pkg.go
+++ b/src/cmd/go/internal/load/pkg.go
@@ -887,8 +887,9 @@
 			modroot := modload.PackageModRoot(ctx, r.path)
 			if modroot == "" && str.HasPathPrefix(r.dir, cfg.GOROOTsrc) {
 				modroot = cfg.GOROOTsrc
-				if str.HasPathPrefix(r.dir, cfg.GOROOTsrc+string(filepath.Separator)+"cmd") {
-					modroot += string(filepath.Separator) + "cmd"
+				gorootSrcCmd := filepath.Join(cfg.GOROOTsrc, "cmd")
+				if str.HasPathPrefix(r.dir, gorootSrcCmd) {
+					modroot = gorootSrcCmd
 				}
 			}
 			if modroot != "" {
@@ -1784,7 +1785,7 @@
 			return
 		}
 		elem := p.DefaultExecName() + cfg.ExeSuffix
-		full := cfg.BuildContext.GOOS + "_" + cfg.BuildContext.GOARCH + string(filepath.Separator) + elem
+		full := filepath.Join(cfg.BuildContext.GOOS+"_"+cfg.BuildContext.GOARCH, elem)
 		if cfg.BuildContext.GOOS != runtime.GOOS || cfg.BuildContext.GOARCH != runtime.GOARCH {
 			// Install cross-compiled binaries to subdirectories of bin.
 			elem = full
@@ -2086,7 +2087,7 @@
 		}
 
 		// Glob to find matches.
-		match, err := fsys.Glob(str.QuoteGlob(pkgdir) + string(filepath.Separator) + filepath.FromSlash(glob))
+		match, err := fsys.Glob(str.QuoteGlob(str.WithFilePathSeparator(pkgdir)) + filepath.FromSlash(glob))
 		if err != nil {
 			return nil, nil, err
 		}
diff --git a/src/cmd/go/internal/modindex/build.go b/src/cmd/go/internal/modindex/build.go
index afc9765..ba7e47c 100644
--- a/src/cmd/go/internal/modindex/build.go
+++ b/src/cmd/go/internal/modindex/build.go
@@ -10,6 +10,7 @@
 import (
 	"bytes"
 	"cmd/go/internal/fsys"
+	"cmd/go/internal/str"
 	"errors"
 	"fmt"
 	"go/ast"
@@ -166,11 +167,7 @@
 
 // hasSubdir reports if dir is within root by performing lexical analysis only.
 func hasSubdir(root, dir string) (rel string, ok bool) {
-	const sep = string(filepath.Separator)
-	root = filepath.Clean(root)
-	if !strings.HasSuffix(root, sep) {
-		root += sep
-	}
+	root = str.WithFilePathSeparator(filepath.Clean(root))
 	dir = filepath.Clean(dir)
 	if !strings.HasPrefix(dir, root) {
 		return "", false
diff --git a/src/cmd/go/internal/modload/load.go b/src/cmd/go/internal/modload/load.go
index e4f6a95..f450ced 100644
--- a/src/cmd/go/internal/modload/load.go
+++ b/src/cmd/go/internal/modload/load.go
@@ -546,9 +546,9 @@
 	pkgNotFoundLongestPrefix := ""
 	for _, mainModule := range MainModules.Versions() {
 		modRoot := MainModules.ModRoot(mainModule)
-		if modRoot != "" && strings.HasPrefix(absDir, modRoot+string(filepath.Separator)) && !strings.Contains(absDir[len(modRoot):], "@") {
-			suffix := filepath.ToSlash(absDir[len(modRoot):])
-			if pkg, found := strings.CutPrefix(suffix, "/vendor/"); found {
+		if modRoot != "" && str.HasFilePathPrefix(absDir, modRoot) && !strings.Contains(absDir[len(modRoot):], "@") {
+			suffix := filepath.ToSlash(str.TrimFilePathPrefix(absDir, modRoot))
+			if pkg, found := strings.CutPrefix(suffix, "vendor/"); found {
 				if cfg.BuildMod != "vendor" {
 					return "", fmt.Errorf("without -mod=vendor, directory %s has no package path", absDir)
 				}
@@ -562,7 +562,7 @@
 
 			mainModulePrefix := MainModules.PathPrefix(mainModule)
 			if mainModulePrefix == "" {
-				pkg := strings.TrimPrefix(suffix, "/")
+				pkg := suffix
 				if pkg == "builtin" {
 					// "builtin" is a pseudo-package with a real source file.
 					// It's not included in "std", so it shouldn't resolve from "."
@@ -572,7 +572,7 @@
 				return pkg, nil
 			}
 
-			pkg := mainModulePrefix + suffix
+			pkg := pathpkg.Join(mainModulePrefix, suffix)
 			if _, ok, err := dirInModule(pkg, mainModulePrefix, modRoot, true); err != nil {
 				return "", err
 			} else if !ok {
@@ -749,17 +749,17 @@
 		if dir == modRoot {
 			return mms.PathPrefix(v), v
 		}
-		if strings.HasPrefix(dir, modRoot+string(filepath.Separator)) {
+		if str.HasFilePathPrefix(dir, modRoot) {
 			pathPrefix := MainModules.PathPrefix(v)
 			if pathPrefix > longestPrefix {
 				longestPrefix = pathPrefix
 				longestPrefixVersion = v
-				suffix := filepath.ToSlash(dir[len(modRoot):])
-				if strings.HasPrefix(suffix, "/vendor/") {
-					longestPrefixPath = strings.TrimPrefix(suffix, "/vendor/")
+				suffix := filepath.ToSlash(str.TrimFilePathPrefix(dir, modRoot))
+				if strings.HasPrefix(suffix, "vendor/") {
+					longestPrefixPath = strings.TrimPrefix(suffix, "vendor/")
 					continue
 				}
-				longestPrefixPath = mms.PathPrefix(v) + suffix
+				longestPrefixPath = pathpkg.Join(mms.PathPrefix(v), suffix)
 			}
 		}
 	}
diff --git a/src/cmd/go/internal/modload/search.go b/src/cmd/go/internal/modload/search.go
index 1da46a4..36e0532 100644
--- a/src/cmd/go/internal/modload/search.go
+++ b/src/cmd/go/internal/modload/search.go
@@ -78,7 +78,7 @@
 		defer span.Done()
 
 		root = filepath.Clean(root)
-		err := fsys.Walk(root, func(path string, fi fs.FileInfo, err error) error {
+		err := fsys.Walk(root, func(pkgDir string, fi fs.FileInfo, err error) error {
 			if err != nil {
 				m.AddError(err)
 				return nil
@@ -88,30 +88,29 @@
 			elem := ""
 
 			// Don't use GOROOT/src but do walk down into it.
-			if path == root {
+			if pkgDir == root {
 				if importPathRoot == "" {
 					return nil
 				}
 			} else {
 				// Avoid .foo, _foo, and testdata subdirectory trees.
-				_, elem = filepath.Split(path)
+				_, elem = filepath.Split(pkgDir)
 				if strings.HasPrefix(elem, ".") || strings.HasPrefix(elem, "_") || elem == "testdata" {
 					want = false
 				}
 			}
 
-			name := importPathRoot + filepath.ToSlash(path[len(root):])
-			if importPathRoot == "" {
-				name = name[1:] // cut leading slash
-			}
+			rel := strings.TrimPrefix(filepath.ToSlash(pkgDir[len(root):]), "/")
+			name := path.Join(importPathRoot, rel)
+
 			if !treeCanMatch(name) {
 				want = false
 			}
 
 			if !fi.IsDir() {
 				if fi.Mode()&fs.ModeSymlink != 0 && want && strings.Contains(m.Pattern(), "...") {
-					if target, err := fsys.Stat(path); err == nil && target.IsDir() {
-						fmt.Fprintf(os.Stderr, "warning: ignoring symlink %s\n", path)
+					if target, err := fsys.Stat(pkgDir); err == nil && target.IsDir() {
+						fmt.Fprintf(os.Stderr, "warning: ignoring symlink %s\n", pkgDir)
 					}
 				}
 				return nil
@@ -121,8 +120,8 @@
 				return filepath.SkipDir
 			}
 			// Stop at module boundaries.
-			if (prune&pruneGoMod != 0) && path != root {
-				if fi, err := os.Stat(filepath.Join(path, "go.mod")); err == nil && !fi.IsDir() {
+			if (prune&pruneGoMod != 0) && pkgDir != root {
+				if fi, err := os.Stat(filepath.Join(pkgDir, "go.mod")); err == nil && !fi.IsDir() {
 					return filepath.SkipDir
 				}
 			}
@@ -131,7 +130,7 @@
 				have[name] = true
 				if isMatch(name) {
 					q.Add(func() {
-						if _, _, err := scanDir(root, path, tags); err != imports.ErrNoGo {
+						if _, _, err := scanDir(root, pkgDir, tags); err != imports.ErrNoGo {
 							addPkg(name)
 						}
 					})
diff --git a/src/cmd/go/internal/script/cmds.go b/src/cmd/go/internal/script/cmds.go
index e0eaad4..b87a8e2 100644
--- a/src/cmd/go/internal/script/cmds.go
+++ b/src/cmd/go/internal/script/cmds.go
@@ -414,7 +414,7 @@
 				return nil, ErrUsage
 			}
 
-			// Use the script's PATH to look up the command if it contains a separator
+			// Use the script's PATH to look up the command (if it does not contain a separator)
 			// instead of the test process's PATH (see lookPath).
 			// Don't use filepath.Clean, since that changes "./foo" to "foo".
 			name := filepath.FromSlash(args[0])
@@ -497,6 +497,18 @@
 
 	pathEnv, _ := s.LookupEnv(pathEnvName())
 	for _, dir := range strings.Split(pathEnv, string(filepath.ListSeparator)) {
+		if dir == "" {
+			continue
+		}
+
+		// Determine whether dir needs a trailing path separator.
+		// Note: we avoid filepath.Join in this function because it cleans the
+		// result: we want to preserve the exact dir prefix from the environment.
+		sep := string(filepath.Separator)
+		if os.IsPathSeparator(dir[len(dir)-1]) {
+			sep = ""
+		}
+
 		if searchExt {
 			ents, err := os.ReadDir(dir)
 			if err != nil {
@@ -505,12 +517,12 @@
 			for _, ent := range ents {
 				for _, ext := range pathExt {
 					if !ent.IsDir() && strEqual(ent.Name(), command+ext) {
-						return dir + string(filepath.Separator) + ent.Name(), nil
+						return dir + sep + ent.Name(), nil
 					}
 				}
 			}
 		} else {
-			path := dir + string(filepath.Separator) + command
+			path := dir + sep + command
 			if fi, err := os.Stat(path); err == nil && isExecutable(fi) {
 				return path, nil
 			}
diff --git a/src/cmd/go/internal/script/state.go b/src/cmd/go/internal/script/state.go
index a51c504..548f673 100644
--- a/src/cmd/go/internal/script/state.go
+++ b/src/cmd/go/internal/script/state.go
@@ -147,10 +147,14 @@
 // originally created.
 func (s *State) ExtractFiles(ar *txtar.Archive) error {
 	wd := s.workdir
+
 	// Add trailing separator to terminate wd.
 	// This prevents extracting to outside paths which prefix wd,
 	// e.g. extracting to /home/foobar when wd is /home/foo
-	if !strings.HasSuffix(wd, string(filepath.Separator)) {
+	if wd == "" {
+		panic("s.workdir is unexpectedly empty")
+	}
+	if !os.IsPathSeparator(wd[len(wd)-1]) {
 		wd += string(filepath.Separator)
 	}
 
diff --git a/src/cmd/go/internal/search/search.go b/src/cmd/go/internal/search/search.go
index 7ea6493..fad1acb 100644
--- a/src/cmd/go/internal/search/search.go
+++ b/src/cmd/go/internal/search/search.go
@@ -125,7 +125,7 @@
 		if (m.pattern == "std" || m.pattern == "cmd") && src != cfg.GOROOTsrc {
 			continue
 		}
-		src = filepath.Clean(src) + string(filepath.Separator)
+		src = str.WithFilePathSeparator(filepath.Clean(src))
 		root := src
 		if m.pattern == "cmd" {
 			root += "cmd" + string(filepath.Separator)
diff --git a/src/cmd/go/internal/str/path.go b/src/cmd/go/internal/str/path.go
index 0c8f479..83a3d0e 100644
--- a/src/cmd/go/internal/str/path.go
+++ b/src/cmd/go/internal/str/path.go
@@ -105,6 +105,15 @@
 	return trimmed
 }
 
+// WithFilePathSeparator returns s with a trailing path separator, or the empty
+// string if s is empty.
+func WithFilePathSeparator(s string) string {
+	if s == "" || os.IsPathSeparator(s[len(s)-1]) {
+		return s
+	}
+	return s + string(filepath.Separator)
+}
+
 // QuoteGlob returns s with all Glob metacharacters quoted.
 // We don't try to handle backslash here, as that can appear in a
 // file path on Windows.
diff --git a/src/cmd/go/internal/work/action.go b/src/cmd/go/internal/work/action.go
index 8beb134..67d3530 100644
--- a/src/cmd/go/internal/work/action.go
+++ b/src/cmd/go/internal/work/action.go
@@ -25,6 +25,7 @@
 	"cmd/go/internal/cfg"
 	"cmd/go/internal/load"
 	"cmd/go/internal/robustio"
+	"cmd/go/internal/str"
 	"cmd/go/internal/trace"
 	"cmd/internal/buildid"
 )
@@ -370,7 +371,7 @@
 // be called during action graph execution.
 func (b *Builder) NewObjdir() string {
 	b.objdirSeq++
-	return filepath.Join(b.WorkDir, fmt.Sprintf("b%03d", b.objdirSeq)) + string(filepath.Separator)
+	return str.WithFilePathSeparator(filepath.Join(b.WorkDir, fmt.Sprintf("b%03d", b.objdirSeq)))
 }
 
 // readpkglist returns the list of packages that were built into the shared library
diff --git a/src/cmd/go/testdata/addmod.go b/src/cmd/go/testdata/addmod.go
index e378d7f..0045d50 100644
--- a/src/cmd/go/testdata/addmod.go
+++ b/src/cmd/go/testdata/addmod.go
@@ -16,7 +16,6 @@
 //
 // It is acceptable to edit the archive afterward to remove or shorten files.
 // See mod/README for more information.
-//
 package main
 
 import (
@@ -131,7 +130,7 @@
 				if err != nil {
 					return err
 				}
-				a.Files = append(a.Files, txtar.File{Name: strings.TrimPrefix(path, dir+string(filepath.Separator)), Data: data})
+				a.Files = append(a.Files, txtar.File{Name: str.TrimFilePathPrefix(path, dir), Data: data})
 			}
 			return nil
 		})
diff --git a/src/cmd/go/testdata/savedir.go b/src/cmd/go/testdata/savedir.go
index eaafc5e..9a3ed50 100644
--- a/src/cmd/go/testdata/savedir.go
+++ b/src/cmd/go/testdata/savedir.go
@@ -12,7 +12,6 @@
 //	go run savedir.go /path/to/dir >saved.txt
 //
 // Typically the tree is later extracted during a test with tg.extract("testdata/saved.txt").
-//
 package main
 
 import (
@@ -70,7 +69,7 @@
 			log.Printf("%s: ignoring invalid UTF-8 data", path)
 			return nil
 		}
-		a.Files = append(a.Files, txtar.File{Name: strings.TrimPrefix(path, dir+string(filepath.Separator)), Data: data})
+		a.Files = append(a.Files, txtar.File{Name: str.TrimFilePathPrefix(path, dir), Data: data})
 		return nil
 	})