Repository: bazelbuild/bazelisk
Pull request #472 (2023-06-01 11:13:21)

go_netrc: switch back to gazelle style import
This enables other projects to depend on bazelisk using a gazelle
generated go_repository for com_github_bgentry_go_netrc, instead of
having to use the archive strip_prefix go_repository that Bazelisk
handcrafted.



Patch:
diff --git a/WORKSPACE b/WORKSPACE
index 4304243..73033ce 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -44,10 +44,9 @@ go_repository(
 
 go_repository(
     name = "com_github_bgentry_go_netrc",
-    importpath = "github.com/bgentry/go-netrc/netrc",
-    urls = ["https://github.com/bgentry/go-netrc/archive/9fd32a8.zip"],
-    type = "zip",
-    strip_prefix = "go-netrc-9fd32a8b3d3d3f9d43c341bfe098430e07609480/netrc",
+    importpath = "github.com/bgentry/go-netrc",
+    sum = "h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=",
+    version = "v0.0.0-20140422174119-9fd32a8b3d3d",
 )
 
 go_rules_dependencies()
diff --git a/bazelisk.go b/bazelisk.go
index 5cdee07..a25041c 100644
--- a/bazelisk.go
+++ b/bazelisk.go
@@ -25,11 +25,12 @@ import (
 
 func main() {
 	gcs := &repositories.GCSRepo{}
-	gitHub := repositories.CreateGitHubRepo(core.GetEnvOrConfig("BAZELISK_GITHUB_TOKEN"))
+	config := core.MakeDefaultConfig()
+	gitHub := repositories.CreateGitHubRepo(config.Get("BAZELISK_GITHUB_TOKEN"))
 	// Fetch LTS releases, release candidates, rolling releases and Bazel-at-commits from GCS, forks from GitHub.
 	repos := core.CreateRepositories(gcs, gcs, gitHub, gcs, gcs, true)
 
-	exitCode, err := core.RunBazelisk(os.Args[1:], repos)
+	exitCode, err := core.RunBazeliskWithArgsFuncAndConfig(func(string) []string { return os.Args[1:] }, repos, config)
 	if err != nil {
 		log.Fatal(err)
 	}
diff --git a/config/BUILD b/config/BUILD
new file mode 100644
index 0000000..4752410
--- /dev/null
+++ b/config/BUILD
@@ -0,0 +1,12 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["config.go"],
+    importpath = "github.com/bazelbuild/bazelisk/config",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//ws:go_default_library",
+        "@com_github_mitchellh_go_homedir//:go_default_library",
+    ],
+)
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..25afbe4
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,130 @@
+package config
+
+import (
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/bazelbuild/bazelisk/ws"
+)
+
+const rcFileName = ".bazeliskrc"
+
+// Config allows getting Bazelisk configuration values.
+type Config interface {
+	Get(name string) string
+}
+
+// FromEnv returns a Config which gets config values from environment variables.
+func FromEnv() Config {
+	return &fromEnv{}
+}
+
+type fromEnv struct{}
+
+func (c *fromEnv) Get(name string) string {
+	return os.Getenv(name)
+}
+
+// FromFile returns a Config which gets config values from a Bazelisk config file.
+func FromFile(path string) (Config, error) {
+	values, err := parseFileConfig(path)
+	if err != nil {
+		return nil, err
+	}
+	return &static{
+		values: values,
+	}, nil
+}
+
+type static struct {
+	values map[string]string
+}
+
+func (c *static) Get(name string) string {
+	return c.values[name]
+}
+
+// parseFileConfig parses a .bazeliskrc file as a map of key-value configuration values.
+func parseFileConfig(rcFilePath string) (map[string]string, error) {
+	config := make(map[string]string)
+
+	contents, err := ioutil.ReadFile(rcFilePath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			// Non-critical error.
+			return config, nil
+		}
+		return nil, err
+	}
+
+	for _, line := range strings.Split(string(contents), "\n") {
+		if strings.HasPrefix(line, "#") {
+			// comments
+			continue
+		}
+		parts := strings.SplitN(line, "=", 2)
+		if len(parts) < 2 {
+			continue
+		}
+		key := strings.TrimSpace(parts[0])
+		config[key] = strings.TrimSpace(parts[1])
+	}
+
+	return config, nil
+}
+
+// LocateUserConfigFile locates a .bazeliskrc file in the user's home directory.
+func LocateUserConfigFile() (string, error) {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return "", err
+	}
+	return filepath.Join(home, rcFileName), nil
+}
+
+// LocateWorkspaceConfigFile locates a .bazeliskrc file in the current workspace root.
+func LocateWorkspaceConfigFile() (string, error) {
+	workingDirectory, err := os.Getwd()
+	if err != nil {
+		return "", err
+	}
+	workspaceRoot := ws.FindWorkspaceRoot(workingDirectory)
+	if workspaceRoot == "" {
+		return "", err
+	}
+	return filepath.Join(workspaceRoot, rcFileName), nil
+}
+
+// Layered returns a Config which gets config values from the first of a series of other Config values which sets the config.
+func Layered(configs ...Config) Config {
+	return &layered{
+		configs: configs,
+	}
+}
+
+type layered struct {
+	configs []Config
+}
+
+func (c *layered) Get(name string) string {
+	for _, config := range c.configs {
+		if value := config.Get(name); value != "" {
+			return value
+		}
+	}
+	return ""
+}
+
+// Null returns a Config with no config values.
+func Null() Config {
+	return &static{}
+}
+
+// Static returns a Config with static values.
+func Static(values map[string]string) Config {
+	return &static{
+		values: values,
+	}
+}
diff --git a/core/BUILD b/core/BUILD
index b941e3d..1458516 100644
--- a/core/BUILD
+++ b/core/BUILD
@@ -10,9 +10,11 @@ go_library(
     visibility = ["//visibility:public"],
     x_defs = {"BazeliskVersion": "{STABLE_VERSION}"},
     deps = [
+        "//config:go_default_library",
         "//httputil:go_default_library",
         "//platforms:go_default_library",
         "//versions:go_default_library",
+        "//ws:go_default_library",
         "@com_github_mitchellh_go_homedir//:go_default_library",
     ],
 )
@@ -24,4 +26,7 @@ go_test(
         "repositories_test.go",
     ],
     embed = [":go_default_library"],
+    deps = [
+        "//config:go_default_library",
+    ],
 )
diff --git a/core/core.go b/core/core.go
index ef4a66f..cab98d9 100644
--- a/core/core.go
+++ b/core/core.go
@@ -5,6 +5,7 @@ package core
 
 import (
 	"bufio"
+	"crypto/rand"
 	"crypto/sha256"
 	"encoding/json"
 	"fmt"
@@ -23,9 +24,11 @@ import (
 	"sync"
 	"syscall"
 
+	"github.com/bazelbuild/bazelisk/config"
 	"github.com/bazelbuild/bazelisk/httputil"
 	"github.com/bazelbuild/bazelisk/platforms"
 	"github.com/bazelbuild/bazelisk/versions"
+	"github.com/bazelbuild/bazelisk/ws"
 	"github.com/mitchellh/go-homedir"
 )
 
@@ -33,7 +36,6 @@ const (
 	bazelReal      = "BAZEL_REAL"
 	skipWrapperEnv = "BAZELISK_SKIP_WRAPPER"
 	wrapperPath    = "./tools/bazel"
-	rcFileName     = ".bazeliskrc"
 	maxDirLength   = 255
 )
 
@@ -49,6 +51,29 @@ var (
 // Bazel with.
 type ArgsFunc func(resolvedBazelVersion string) []string
 
+func MakeDefaultConfig() config.Config {
+	configs := []config.Config{config.FromEnv()}
+
+	workspaceConfigPath, err := config.LocateWorkspaceConfigFile()
+	if err == nil {
+		c, err := config.FromFile(workspaceConfigPath)
+		if err != nil {
+			log.Fatal(err)
+		}
+		configs = append(configs, c)
+	}
+
+	userConfigPath, err := config.LocateUserConfigFile()
+	if err == nil {
+		c, err := config.FromFile(userConfigPath)
+		if err != nil {
+			log.Fatal(err)
+		}
+		configs = append(configs, c)
+	}
+	return config.Layered(configs...)
+}
+
 // RunBazelisk runs the main Bazelisk logic for the given arguments and Bazel repositories.
 func RunBazelisk(args []string, repos *Repositories) (int, error) {
 	return RunBazeliskWithArgsFunc(func(_ string) []string { return args }, repos)
@@ -57,9 +82,16 @@ func RunBazelisk(args []string, repos *Repositories) (int, error) {
 // RunBazeliskWithArgsFunc runs the main Bazelisk logic for the given ArgsFunc and Bazel
 // repositories.
 func RunBazeliskWithArgsFunc(argsFunc ArgsFunc, repos *Repositories) (int, error) {
-	httputil.UserAgent = getUserAgent()
 
-	bazeliskHome := GetEnvOrConfig("BAZELISK_HOME")
+	return RunBazeliskWithArgsFuncAndConfig(argsFunc, repos, MakeDefaultConfig())
+}
+
+// RunBazeliskWithArgsFuncAndConfig runs the main Bazelisk logic for the given ArgsFunc and Bazel
+// repositories and config.
+func RunBazeliskWithArgsFuncAndConfig(argsFunc ArgsFunc, repos *Repositories, config config.Config) (int, error) {
+	httputil.UserAgent = getUserAgent(config)
+
+	bazeliskHome := config.Get("BAZELISK_HOME")
 	if len(bazeliskHome) == 0 {
 		userCacheDir, err := os.UserCacheDir()
 		if err != nil {
@@ -74,7 +106,7 @@ func RunBazeliskWithArgsFunc(argsFunc ArgsFunc, repos *Repositories) (int, error
 		return -1, fmt.Errorf("could not create directory %s: %v", bazeliskHome, err)
 	}
 
-	bazelVersionString, err := getBazelVersion()
+	bazelVersionString, err := getBazelVersion(config)
 	if err != nil {
 		return -1, fmt.Errorf("could not get Bazel version: %v", err)
 	}
@@ -91,7 +123,7 @@ func RunBazeliskWithArgsFunc(argsFunc ArgsFunc, repos *Repositories) (int, error
 	// If we aren't using a local Bazel binary, we'll have to parse the version string and
 	// download the version that the user wants.
 	if !filepath.IsAbs(bazelPath) {
-		bazelPath, err = downloadBazel(bazelVersionString, bazeliskHome, repos)
+		bazelPath, err = downloadBazel(bazelVersionString, bazeliskHome, repos, config)
 		if err != nil {
 			return -1, fmt.Errorf("could not download Bazel: %v", err)
 		}
@@ -108,7 +140,7 @@ func RunBazeliskWithArgsFunc(argsFunc ArgsFunc, repos *Repositories) (int, error
 	// --print_env must be the first argument.
 	if len(args) > 0 && args[0] == "--print_env" {
 		// print environment variables for sub-processes
-		cmd := makeBazelCmd(bazelPath, args, nil)
+		cmd := makeBazelCmd(bazelPath, args, nil, config)
 		for _, val := range cmd.Env {
 			fmt.Println(val)
 		}
@@ -121,12 +153,12 @@ func RunBazeliskWithArgsFunc(argsFunc ArgsFunc, repos *Repositories) (int, error
 		if err != nil {
 			return -1, err
 		}
-		newFlags, err := getIncompatibleFlags(bazelPath, cmd)
+		newFlags, err := getIncompatibleFlags(bazelPath, cmd, config)
 		if err != nil {
 			return -1, fmt.Errorf("could not get the list of incompatible flags: %v", err)
 		}
 		if args[0] == "--migrate" {
-			migrate(bazelPath, args[1:], newFlags)
+			migrate(bazelPath, args[1:], newFlags, config)
 		} else {
 			// When --strict is present, it expands to the list of --incompatible_ flags
 			// that should be enabled for the given Bazel version.
@@ -140,7 +172,7 @@ func RunBazeliskWithArgsFunc(argsFunc ArgsFunc, repos *Repositories) (int, error
 		value := args[0][len("--bisect="):]
 		commits := strings.Split(value, "..")
 		if len(commits) == 2 {
-			bisect(commits[0], commits[1], args[1:], bazeliskHome, repos)
+			bisect(commits[0], commits[1], args[1:], bazeliskHome, repos, config)
 		} else {
 			return -1, fmt.Errorf("Error: Invalid format for --bisect. Expected format: '--bisect=<good bazel commit>..<bad bazel commit>'")
 		}
@@ -166,7 +198,7 @@ func RunBazeliskWithArgsFunc(argsFunc ArgsFunc, repos *Repositories) (int, error
 		}
 	}
 
-	exitCode, err := runBazel(bazelPath, args, nil)
+	exitCode, err := runBazel(bazelPath, args, nil, config)
 	if err != nil {
 		return -1, fmt.Errorf("could not run Bazel: %v", err)
 	}
@@ -182,130 +214,14 @@ func getBazelCommand(args []string) (string, error) {
 	return "", fmt.Errorf("could not find a valid Bazel command in %q. Please run `bazel help` if you need help on how to use Bazel", strings.Join(args, " "))
 }
 
-func getUserAgent() string {
-	agent := GetEnvOrConfig("BAZELISK_USER_AGENT")
+func getUserAgent(config config.Config) string {
+	agent := config.Get("BAZELISK_USER_AGENT")
 	if len(agent) > 0 {
 		return agent
 	}
 	return fmt.Sprintf("Bazelisk/%s", BazeliskVersion)
 }
 
-// GetEnvOrConfig reads a configuration value from the environment, but fall back to reading it from .bazeliskrc in the workspace root.
-func GetEnvOrConfig(name string) string {
-	if val := os.Getenv(name); val != "" {
-		return val
-	}
-
-	fileConfigOnce.Do(loadFileConfig)
-
-	return fileConfig[name]
-}
-
-// loadFileConfig locates available .bazeliskrc configuration files, parses them with a precedence order preference,
-// and updates a global configuration map with their contents. This routine should be executed exactly once.
-func loadFileConfig() {
-	var rcFilePaths []string
-
-	if userRC, err := locateUserConfigFile(); err == nil {
-		rcFilePaths = append(rcFilePaths, userRC)
-	}
-	if workspaceRC, err := locateWorkspaceConfigFile(); err == nil {
-		rcFilePaths = append(rcFilePaths, workspaceRC)
-	}
-
-	fileConfig = make(map[string]string)
-	for _, rcPath := range rcFilePaths {
-		config, err := parseFileConfig(rcPath)
-		if err != nil {
-			log.Fatal(err)
-		}
-
-		for key, value := range config {
-			fileConfig[key] = value
-		}
-	}
-}
-
-// locateWorkspaceConfigFile locates a .bazeliskrc file in the current workspace root.
-func locateWorkspaceConfigFile() (string, error) {
-	workingDirectory, err := os.Getwd()
-	if err != nil {
-		return "", err
-	}
-	workspaceRoot := findWorkspaceRoot(workingDirectory)
-	if workspaceRoot == "" {
-		return "", err
-	}
-	return filepath.Join(workspaceRoot, rcFileName), nil
-}
-
-// locateUserConfigFile locates a .bazeliskrc file in the user's home directory.
-func locateUserConfigFile() (string, error) {
-	home, err := os.UserHomeDir()
-	if err != nil {
-		return "", err
-	}
-	return filepath.Join(home, rcFileName), nil
-}
-
-// parseFileConfig parses a .bazeliskrc file as a map of key-value configuration values.
-func parseFileConfig(rcFilePath string) (map[string]string, error) {
-	config := make(map[string]string)
-
-	contents, err := ioutil.ReadFile(rcFilePath)
-	if err != nil {
-		if os.IsNotExist(err) {
-			// Non-critical error.
-			return config, nil
-		}
-		return nil, err
-	}
-
-	for _, line := range strings.Split(string(contents), "\n") {
-		if strings.HasPrefix(line, "#") {
-			// comments
-			continue
-		}
-		parts := strings.SplitN(line, "=", 2)
-		if len(parts) < 2 {
-			continue
-		}
-		key := strings.TrimSpace(parts[0])
-		config[key] = strings.TrimSpace(parts[1])
-	}
-
-	return config, nil
-}
-
-// isValidWorkspace returns true iff the supplied path is the workspace root, defined by the presence of
-// a file named WORKSPACE or WORKSPACE.bazel
-// see https://github.com/bazelbuild/bazel/blob/8346ea4cfdd9fbd170d51a528fee26f912dad2d5/src/main/cpp/workspace_layout.cc#L37
-func isValidWorkspace(path string) bool {
-	info, err := os.Stat(path)
-	if err != nil {
-		return false
-	}
-
-	return !info.IsDir()
-}
-
-func findWorkspaceRoot(root string) string {
-	if isValidWorkspace(filepath.Join(root, "WORKSPACE")) {
-		return root
-	}
-
-	if isValidWorkspace(filepath.Join(root, "WORKSPACE.bazel")) {
-		return root
-	}
-
-	parentDirectory := filepath.Dir(root)
-	if parentDirectory == root {
-		return ""
-	}
-
-	return findWorkspaceRoot(parentDirectory)
-}
-
 // TODO(go 1.18): remove backport of strings.Cut
 func cutString(s, sep string) (before, after string, found bool) {
 	if i := strings.Index(s, sep); i >= 0 {
@@ -314,7 +230,7 @@ func cutString(s, sep string) (before, after string, found bool) {
 	return s, "", false
 }
 
-func getBazelVersion() (string, error) {
+func getBazelVersion(config config.Config) (string, error) {
 	// Check in this order:
 	// - env var "USE_BAZEL_VERSION" is set to a specific version.
 	// - workspace_root/.bazeliskrc exists -> read contents, in contents:
@@ -330,7 +246,7 @@ func getBazelVersion() (string, error) {
 	// - workspace_root/.bazeliskrc exists -> read contents, in contents:
 	//   var "USE_BAZEL_FALLBACK_VERSION" is set to a fallback version format.
 	// - fallback version format "silent:latest"
-	bazelVersion := GetEnvOrConfig("USE_BAZEL_VERSION")
+	bazelVersion := config.Get("USE_BAZEL_VERSION")
 	if len(bazelVersion) != 0 {
 		return bazelVersion, nil
 	}
@@ -340,7 +256,7 @@ func getBazelVersion() (string, error) {
 		return "", fmt.Errorf("could not get working directory: %v", err)
 	}
 
-	workspaceRoot := findWorkspaceRoot(workingDirectory)
+	workspaceRoot := ws.FindWorkspaceRoot(workingDirectory)
 	if len(workspaceRoot) != 0 {
 		bazelVersionPath := filepath.Join(workspaceRoot, ".bazelversion")
 		if _, err := os.Stat(bazelVersionPath); err == nil {
@@ -363,7 +279,7 @@ func getBazelVersion() (string, error) {
 		}
 	}
 
-	fallbackVersionFormat := GetEnvOrConfig("USE_BAZEL_FALLBACK_VERSION")
+	fallbackVersionFormat := config.Get("USE_BAZEL_FALLBACK_VERSION")
 	fallbackVersionMode, fallbackVersion, hasFallbackVersionMode := cutString(fallbackVersionFormat, ":")
 	if !hasFallbackVersionMode {
 		fallbackVersionMode, fallbackVersion, hasFallbackVersionMode = "silent", fallbackVersionMode, true
@@ -400,7 +316,7 @@ func parseBazelForkAndVersion(bazelForkAndVersion string) (string, string, error
 	return bazelFork, bazelVersion, nil
 }
 
-func downloadBazel(bazelVersionString string, bazeliskHome string, repos *Repositories) (string, error) {
+func downloadBazel(bazelVersionString string, bazeliskHome string, repos *Repositories, config config.Config) (string, error) {
 	bazelFork, bazelVersion, err := parseBazelForkAndVersion(bazelVersionString)
 	if err != nil {
 		return "", fmt.Errorf("could not parse Bazel fork and version: %v", err)
@@ -411,78 +327,129 @@ func downloadBazel(bazelVersionString string, bazeliskHome string, repos *Reposi
 		return "", fmt.Errorf("could not resolve the version '%s' to an actual version number: %v", bazelVersion, err)
 	}
 
-	bazelForkOrURL := dirForURL(GetEnvOrConfig(BaseURLEnv))
+	bazelForkOrURL := dirForURL(config.Get(BaseURLEnv))
 	if len(bazelForkOrURL) == 0 {
 		bazelForkOrURL = bazelFork
 	}
 
-	baseDirectory := filepath.Join(bazeliskHome, "downloads", bazelForkOrURL)
-	bazelPath, err := downloadBazelIfNecessary(resolvedBazelVersion, baseDirectory, repos, downloader)
+	bazelPath, err := downloadBazelIfNecessary(resolvedBazelVersion, bazeliskHome, bazelForkOrURL, repos, config, downloader)
 	return bazelPath, err
 }
 
-func downloadBazelIfNecessary(version string, baseDirectory string, repos *Repositories, downloader DownloadFunc) (string, error) {
+// downloadBazelIfNecessary returns a path to a bazel which can be run, which may have been cached.
+// The directory it returns may depend on version and bazeliskHome, but does not depend on bazelForkOrURLDirName.
+// This is important, as the directory may be added to $PATH, and varying the path for equivalent files may cause unnecessary repository rule cache invalidations.
+// Where a file was downloaded from shouldn't affect cache behaviour of Bazel invocations.
+//
+// The structure of the downloads directory is as follows ([]s indicate variables):
+//
+//	downloads/metadata/[fork-or-url]/bazel-[version-os-etc] is a text file containing a hex sha256 of the contents of the downloaded bazel file.
+//	downloads/sha256/[sha256]/bin/bazel[extension] contains the bazel with a particular sha256.
+func downloadBazelIfNecessary(version string, bazeliskHome string, bazelForkOrURLDirName string, repos *Repositories, config config.Config, downloader DownloadFunc) (string, error) {
 	pathSegment, err := platforms.DetermineBazelFilename(version, false)
 	if err != nil {
 		return "", fmt.Errorf("could not determine path segment to use for Bazel binary: %v", err)
 	}
 
-	destDir := filepath.Join(baseDirectory, pathSegment, "bin")
-	expectedSha256 := strings.ToLower(GetEnvOrConfig("BAZELISK_VERIFY_SHA256"))
-
-	tmpDestFile := "bazel-tmp" + platforms.DetermineExecutableFilenameSuffix()
 	destFile := "bazel" + platforms.DetermineExecutableFilenameSuffix()
 
-	destPath := filepath.Join(destDir, destFile)
-	if _, err := os.Stat(destPath); err == nil {
-		return destPath, nil
+	mappingPath := filepath.Join(bazeliskHome, "downloads", "metadata", bazelForkOrURLDirName, pathSegment)
+	digestFromMappingFile, err := os.ReadFile(mappingPath)
+	if err == nil {
+		pathToBazelInCAS := filepath.Join(bazeliskHome, "downloads", "sha256", string(digestFromMappingFile), "bin", destFile)
+		if _, err := os.Stat(pathToBazelInCAS); err == nil {
+			return pathToBazelInCAS, nil
+		}
+	}
+
+	pathToBazelInCAS, downloadedDigest, err := downloadBazelToCAS(version, bazeliskHome, repos, config, downloader)
+	if err != nil {
+		return "", fmt.Errorf("failed to download bazel: %w", err)
+	}
+
+	expectedSha256 := strings.ToLower(config.Get("BAZELISK_VERIFY_SHA256"))
+	if len(expectedSha256) > 0 {
+		if expectedSha256 != downloadedDigest {
+			return "", fmt.Errorf("%s has sha256=%s but need sha256=%s", pathToBazelInCAS, downloadedDigest, expectedSha256)
+		}
+	}
+
+	if err := atomicWriteFile(mappingPath, []byte(downloadedDigest), 0644); err != nil {
+		return "", fmt.Errorf("failed to write mapping file after downloading bazel: %w", err)
 	}
 
+	return pathToBazelInCAS, nil
+}
+
+func atomicWriteFile(path string, contents []byte, perm os.FileMode) error {
+	if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+		return fmt.Errorf("failed to MkdirAll parent of %s: %w", path, err)
+	}
+	tmpPath := path + ".tmp"
+	if err := os.WriteFile(tmpPath, contents, perm); err != nil {
+		return fmt.Errorf("failed to write file %s: %w", tmpPath, err)
+	}
+	if err := os.Rename(tmpPath, path); err != nil {
+		return fmt.Errorf("failed to rename %s to %s: %w", tmpPath, path, err)
+	}
+	return nil
+}
+
+func downloadBazelToCAS(version string, bazeliskHome string, repos *Repositories, config config.Config, downloader DownloadFunc) (string, string, error) {
+	downloadsDir := filepath.Join(bazeliskHome, "downloads")
+	temporaryDownloadDir := filepath.Join(downloadsDir, "_tmp")
+	casDir := filepath.Join(bazeliskHome, "downloads", "sha256")
+
+	tmpDestFileBytes := make([]byte, 32)
+	if _, err := rand.Read(tmpDestFileBytes); err != nil {
+		return "", "", fmt.Errorf("failed to generate temporary file name: %w", err)
+	}
+	tmpDestFile := fmt.Sprintf("%x", tmpDestFileBytes)
+
 	var tmpDestPath string
-	baseURL := GetEnvOrConfig(BaseURLEnv)
-	formatURL := GetEnvOrConfig(FormatURLEnv)
+	var err error
+	baseURL := config.Get(BaseURLEnv)
+	formatURL := config.Get(FormatURLEnv)
 	if baseURL != "" && formatURL != "" {
-		return "", fmt.Errorf("cannot set %s and %s at once", BaseURLEnv, FormatURLEnv)
+		return "", "", fmt.Errorf("cannot set %s and %s at once", BaseURLEnv, FormatURLEnv)
 	} else if formatURL != "" {
-		tmpDestPath, err = repos.DownloadFromFormatURL(formatURL, version, destDir, tmpDestFile)
+		tmpDestPath, err = repos.DownloadFromFormatURL(config, formatURL, version, temporaryDownloadDir, tmpDestFile)
 	} else if baseURL != "" {
-		tmpDestPath, err = repos.DownloadFromBaseURL(baseURL, version, destDir, tmpDestFile)
+		tmpDestPath, err = repos.DownloadFromBaseURL(baseURL, version, temporaryDownloadDir, tmpDestFile)
 	} else {
-		tmpDestPath, err = downloader(destDir, tmpDestFile)
+		tmpDestPath, err = downloader(temporaryDownloadDir, tmpDestFile)
 	}
 	if err != nil {
-		return "", err
+		return "", "", fmt.Errorf("failed to download bazel: %w", err)
 	}
 
-	if len(expectedSha256) > 0 {
-		f, err := os.Open(tmpDestPath)
-		if err != nil {
-			os.Remove(tmpDestPath)
-			return "", fmt.Errorf("cannot open %s after download: %v", tmpDestPath, err)
-		}
-		defer os.Remove(tmpDestPath)
-		// We cannot defer f.Close() because keeping the handle open when we try to do the
-		// rename later on fails on Windows.
+	f, err := os.Open(tmpDestPath)
+	if err != nil {
+		return "", "", fmt.Errorf("failed to open downloaded bazel to digest it: %w", err)
+	}
 
-		h := sha256.New()
-		if _, err := io.Copy(h, f); err != nil {
-			f.Close()
-			return "", fmt.Errorf("cannot compute sha256 of %s after download: %v", tmpDestPath, err)
-		}
+	h := sha256.New()
+	if _, err := io.Copy(h, f); err != nil {
 		f.Close()
+		return "", "", fmt.Errorf("cannot compute sha256 of %s after download: %v", tmpDestPath, err)
+	}
+	f.Close()
+	actualSha256 := strings.ToLower(fmt.Sprintf("%x", h.Sum(nil)))
 
-		actualSha256 := strings.ToLower(fmt.Sprintf("%x", h.Sum(nil)))
-		if expectedSha256 != actualSha256 {
-			return "", fmt.Errorf("%s has sha256=%s but need sha256=%s", tmpDestPath, actualSha256, expectedSha256)
-		}
+	pathToBazelInCAS := filepath.Join(casDir, actualSha256, "bin", "bazel"+platforms.DetermineExecutableFilenameSuffix())
+	if err := os.MkdirAll(filepath.Dir(pathToBazelInCAS), 0755); err != nil {
+		return "", "", fmt.Errorf("failed to MkdirAll parent of %s: %w", pathToBazelInCAS, err)
 	}
 
-	// Only place the downloaded binary in its final location once we know it is fully downloaded
-	// and valid, to prevent invalid files from ever being executed.
-	if err = os.Rename(tmpDestPath, destPath); err != nil {
-		return "", fmt.Errorf("cannot rename %s to %s: %v", tmpDestPath, destPath, err)
+	tmpPathInCorrectDirectory := pathToBazelInCAS + ".tmp"
+	if err := os.Rename(tmpDestPath, tmpPathInCorrectDirectory); err != nil {
+		return "", "", fmt.Errorf("failed to move %s to %s: %w", tmpDestPath, tmpPathInCorrectDirectory, err)
 	}
-	return destPath, nil
+	if err := os.Rename(tmpPathInCorrectDirectory, pathToBazelInCAS); err != nil {
+		return "", "", fmt.Errorf("failed to move %s to %s: %w", tmpPathInCorrectDirectory, pathToBazelInCAS, err)
+	}
+
+	return pathToBazelInCAS, actualSha256, nil
 }
 
 func copyFile(src, dst string, perm os.FileMode) error {
@@ -524,24 +491,24 @@ func linkLocalBazel(baseDirectory string, bazelPath string) (string, error) {
 	return destinationPath, nil
 }
 
-func maybeDelegateToWrapperFromDir(bazel string, wd string, ignoreEnv bool) string {
-	if !ignoreEnv && GetEnvOrConfig(skipWrapperEnv) != "" {
+func maybeDelegateToWrapperFromDir(bazel string, wd string, config config.Config) string {
+	if config.Get(skipWrapperEnv) != "" {
 		return bazel
 	}
 
-	root := findWorkspaceRoot(wd)
+	root := ws.FindWorkspaceRoot(wd)
 	wrapper := filepath.Join(root, wrapperPath)
 	if stat, err := os.Stat(wrapper); err == nil && !stat.Mode().IsDir() && stat.Mode().Perm()&0111 != 0 {
 		return wrapper
 	}
 
 	if runtime.GOOS == "windows" {
-		powershellWrapper := filepath.Join(root, wrapperPath + ".ps1")
+		powershellWrapper := filepath.Join(root, wrapperPath+".ps1")
 		if stat, err := os.Stat(powershellWrapper); err == nil && !stat.Mode().IsDir() {
 			return powershellWrapper
 		}
 
-		batchWrapper := filepath.Join(root, wrapperPath + ".bat")
+		batchWrapper := filepath.Join(root, wrapperPath+".bat")
 		if stat, err := os.Stat(batchWrapper); err == nil && !stat.Mode().IsDir() {
 			return batchWrapper
 		}
@@ -550,13 +517,13 @@ func maybeDelegateToWrapperFromDir(bazel string, wd string, ignoreEnv bool) stri
 	return bazel
 }
 
-func maybeDelegateToWrapper(bazel string) string {
+func maybeDelegateToWrapper(bazel string, config config.Config) string {
 	wd, err := os.Getwd()
 	if err != nil {
 		return bazel
 	}
 
-	return maybeDelegateToWrapperFromDir(bazel, wd, false)
+	return maybeDelegateToWrapperFromDir(bazel, wd, config)
 }
 
 func prependDirToPathList(cmd *exec.Cmd, dir string) {
@@ -578,8 +545,8 @@ func prependDirToPathList(cmd *exec.Cmd, dir string) {
 	}
 }
 
-func makeBazelCmd(bazel string, args []string, out io.Writer) *exec.Cmd {
-	execPath := maybeDelegateToWrapper(bazel)
+func makeBazelCmd(bazel string, args []string, out io.Writer, config config.Config) *exec.Cmd {
+	execPath := maybeDelegateToWrapper(bazel, config)
 
 	cmd := exec.Command(execPath, args...)
 	cmd.Env = append(os.Environ(), skipWrapperEnv+"=true")
@@ -597,8 +564,8 @@ func makeBazelCmd(bazel string, args []string, out io.Writer) *exec.Cmd {
 	return cmd
 }
 
-func runBazel(bazel string, args []string, out io.Writer) (int, error) {
-	cmd := makeBazelCmd(bazel, args, out)
+func runBazel(bazel string, args []string, out io.Writer, config config.Config) (int, error) {
+	cmd := makeBazelCmd(bazel, args, out, config)
 	err := cmd.Start()
 	if err != nil {
 		return 1, fmt.Errorf("could not start Bazel: %v", err)
@@ -627,14 +594,14 @@ func runBazel(bazel string, args []string, out io.Writer) (int, error) {
 }
 
 // getIncompatibleFlags returns all incompatible flags for the current Bazel command in alphabetical order.
-func getIncompatibleFlags(bazelPath, cmd string) ([]string, error) {
-	var incompatibleFlagsStr = GetEnvOrConfig("BAZELISK_INCOMPATIBLE_FLAGS")
+func getIncompatibleFlags(bazelPath, cmd string, config config.Config) ([]string, error) {
+	var incompatibleFlagsStr = config.Get("BAZELISK_INCOMPATIBLE_FLAGS")
 	if len(incompatibleFlagsStr) > 0 {
 		return strings.Split(incompatibleFlagsStr, ","), nil
 	}
 
 	out := strings.Builder{}
-	if _, err := runBazel(bazelPath, []string{"help", cmd, "--short"}, &out); err != nil {
+	if _, err := runBazel(bazelPath, []string{"help", cmd, "--short"}, &out, config); err != nil {
 		return nil, fmt.Errorf("unable to determine incompatible flags with binary %s: %v", bazelPath, err)
 	}
 
@@ -670,27 +637,27 @@ func insertArgs(baseArgs []string, newArgs []string) []string {
 func parseStartupOptions(baseArgs []string) []string {
 	var result []string
 	var bazelCommands = map[string]bool{
-		"analyze-profile": true,
-		"aquery": true,
-		"build": true,
+		"analyze-profile":    true,
+		"aquery":             true,
+		"build":              true,
 		"canonicalize-flags": true,
-		"clean": true,
-		"coverage": true,
-		"cquery": true,
-		"dump": true,
-		"fetch": true,
-		"help": true,
-		"info": true,
-		"license": true,
-		"mobile-install": true,
-		"mod": true,
-		"print_action": true,
-		"query": true,
-		"run": true,
-		"shutdown": true,
-		"sync": true,
-		"test": true,
-		"version": true,
+		"clean":              true,
+		"coverage":           true,
+		"cquery":             true,
+		"dump":               true,
+		"fetch":              true,
+		"help":               true,
+		"info":               true,
+		"license":            true,
+		"mobile-install":     true,
+		"mod":                true,
+		"print_action":       true,
+		"query":              true,
+		"run":                true,
+		"shutdown":           true,
+		"sync":               true,
+		"test":               true,
+		"version":            true,
 	}
 	// Arguments before a Bazel command are startup options.
 	for _, arg := range baseArgs {
@@ -702,15 +669,15 @@ func parseStartupOptions(baseArgs []string) []string {
 	return result
 }
 
-func shutdownIfNeeded(bazelPath string, startupOptions []string) {
-	bazeliskClean := GetEnvOrConfig("BAZELISK_SHUTDOWN")
+func shutdownIfNeeded(bazelPath string, startupOptions []string, config config.Config) {
+	bazeliskClean := config.Get("BAZELISK_SHUTDOWN")
 	if len(bazeliskClean) == 0 {
 		return
 	}
 
 	args := append(startupOptions, "shutdown")
 	fmt.Printf("bazel %s\n", strings.Join(args, " "))
-	exitCode, err := runBazel(bazelPath, args, nil)
+	exitCode, err := runBazel(bazelPath, args, nil, config)
 	fmt.Printf("\n")
 	if err != nil {
 		log.Fatalf("failed to run bazel shutdown: %v", err)
@@ -721,15 +688,15 @@ func shutdownIfNeeded(bazelPath string, startupOptions []string) {
 	}
 }
 
-func cleanIfNeeded(bazelPath string, startupOptions []string) {
-	bazeliskClean := GetEnvOrConfig("BAZELISK_CLEAN")
+func cleanIfNeeded(bazelPath string, startupOptions []string, config config.Config) {
+	bazeliskClean := config.Get("BAZELISK_CLEAN")
 	if len(bazeliskClean) == 0 {
 		return
 	}
 
 	args := append(startupOptions, "clean", "--expunge")
 	fmt.Printf("bazel %s\n", strings.Join(args, " "))
-	exitCode, err := runBazel(bazelPath, args, nil)
+	exitCode, err := runBazel(bazelPath, args, nil, config)
 	fmt.Printf("\n")
 	if err != nil {
 		log.Fatalf("failed to run clean: %v", err)
@@ -745,17 +712,17 @@ type ParentCommit struct {
 }
 
 type Commit struct {
-	SHA string `json:"sha"`
+	SHA     string         `json:"sha"`
 	PARENTS []ParentCommit `json:"parents"`
 }
 
 type CompareResponse struct {
-	Commits []Commit `json:"commits"`
-	BaseCommit Commit `json:"base_commit"`
-	MergeBaseCommit Commit `json:"merge_base_commit"`
+	Commits         []Commit `json:"commits"`
+	BaseCommit      Commit   `json:"base_commit"`
+	MergeBaseCommit Commit   `json:"merge_base_commit"`
 }
 
-func sendRequest(url string) (*http.Response, error) {
+func sendRequest(url string, config config.Config) (*http.Response, error) {
 	client := &http.Client{}
 
 	req, err := http.NewRequest("GET", url, nil)
@@ -763,7 +730,7 @@ func sendRequest(url string) (*http.Response, error) {
 		return nil, err
 	}
 
-	githubToken := GetEnvOrConfig("BAZELISK_GITHUB_TOKEN")
+	githubToken := config.Get("BAZELISK_GITHUB_TOKEN")
 	if len(githubToken) != 0 {
 		req.Header.Set("Authorization", fmt.Sprintf("token %s", githubToken))
 	}
@@ -771,7 +738,7 @@ func sendRequest(url string) (*http.Response, error) {
 	return client.Do(req)
 }
 
-func getBazelCommitsBetween(goodCommit string, badCommit string) (string, []string, error) {
+func getBazelCommitsBetween(goodCommit string, badCommit string, config config.Config) (string, []string, error) {
 	commitList := make([]string, 0)
 	page := 1
 	perPage := 250 // 250 is the maximum number of commits per page
@@ -779,7 +746,7 @@ func getBazelCommitsBetween(goodCommit string, badCommit string) (string, []stri
 	for {
 		url := fmt.Sprintf("https://api.github.com/repos/bazelbuild/bazel/compare/%s...%s?page=%d&per_page=%d", goodCommit, badCommit, page, perPage)
 
-		response, err := sendRequest(url)
+		response, err := sendRequest(url, config)
 		if err != nil {
 			return goodCommit, nil, fmt.Errorf("Error fetching commit data: %v", err)
 		}
@@ -836,11 +803,11 @@ func getBazelCommitsBetween(goodCommit string, badCommit string) (string, []stri
 	return goodCommit, commitList, nil
 }
 
-func bisect(goodCommit string, badCommit string, args []string, bazeliskHome string, repos *Repositories) {
+func bisect(goodCommit string, badCommit string, args []string, bazeliskHome string, repos *Repositories, config config.Config) {
 
 	// 1. Get the list of commits between goodCommit and badCommit
 	fmt.Printf("\n\n--- Getting the list of commits between %s and %s\n\n", goodCommit, badCommit)
-	goodCommit, commitList, err := getBazelCommitsBetween(goodCommit, badCommit)
+	goodCommit, commitList, err := getBazelCommitsBetween(goodCommit, badCommit, config)
 	if err != nil {
 		log.Fatalf("Failed to get commits: %v", err)
 		os.Exit(1)
@@ -848,7 +815,7 @@ func bisect(goodCommit string, badCommit string, args []string, bazeliskHome str
 
 	// 2. Check if goodCommit is actually good
 	fmt.Printf("\n\n--- Verifying if the given good Bazel commit (%s) is actually good\n\n", goodCommit)
-	bazelExitCode, err := testWithBazelAtCommit(goodCommit, args, bazeliskHome, repos)
+	bazelExitCode, err := testWithBazelAtCommit(goodCommit, args, bazeliskHome, repos, config)
 	if err != nil {
 		log.Fatalf("could not run Bazel: %v", err)
 		os.Exit(1)
@@ -865,8 +832,8 @@ func bisect(goodCommit string, badCommit string, args []string, bazeliskHome str
 	for left < right {
 		mid := (left + right) / 2
 		midCommit := commitList[mid]
-		fmt.Printf("\n\n--- Testing with Bazel built at %s, %d commits remaining...\n\n", midCommit, right -left)
-		bazelExitCode, err := testWithBazelAtCommit(midCommit, args, bazeliskHome, repos)
+		fmt.Printf("\n\n--- Testing with Bazel built at %s, %d commits remaining...\n\n", midCommit, right-left)
+		bazelExitCode, err := testWithBazelAtCommit(midCommit, args, bazeliskHome, repos, config)
 		if err != nil {
 			log.Fatalf("could not run Bazel: %v", err)
 			os.Exit(1)
@@ -892,16 +859,16 @@ func bisect(goodCommit string, badCommit string, args []string, bazeliskHome str
 	os.Exit(0)
 }
 
-func testWithBazelAtCommit(bazelCommit string, args []string, bazeliskHome string, repos *Repositories) (int, error) {
-	bazelPath, err := downloadBazel(bazelCommit, bazeliskHome, repos)
+func testWithBazelAtCommit(bazelCommit string, args []string, bazeliskHome string, repos *Repositories, config config.Config) (int, error) {
+	bazelPath, err := downloadBazel(bazelCommit, bazeliskHome, repos, config)
 	if err != nil {
 		return 1, fmt.Errorf("could not download Bazel: %v", err)
 	}
 	startupOptions := parseStartupOptions(args)
-	shutdownIfNeeded(bazelPath, startupOptions)
-	cleanIfNeeded(bazelPath, startupOptions)
+	shutdownIfNeeded(bazelPath, startupOptions, config)
+	cleanIfNeeded(bazelPath, startupOptions, config)
 	fmt.Printf("bazel %s\n", strings.Join(args, " "))
-	bazelExitCode, err := runBazel(bazelPath, args, nil)
+	bazelExitCode, err := runBazel(bazelPath, args, nil, config)
 	if err != nil {
 		return -1, fmt.Errorf("could not run Bazel: %v", err)
 	}
@@ -909,16 +876,16 @@ func testWithBazelAtCommit(bazelCommit string, args []string, bazeliskHome strin
 }
 
 // migrate will run Bazel with each flag separately and report which ones are failing.
-func migrate(bazelPath string, baseArgs []string, flags []string) {
+func migrate(bazelPath string, baseArgs []string, flags []string, config config.Config) {
 	var startupOptions = parseStartupOptions(baseArgs)
 
 	// 1. Try with all the flags.
 	args := insertArgs(baseArgs, flags)
 	fmt.Printf("\n\n--- Running Bazel with all incompatible flags\n\n")
-	shutdownIfNeeded(bazelPath, startupOptions)
-	cleanIfNeeded(bazelPath, startupOptions)
+	shutdownIfNeeded(bazelPath, startupOptions, config)
+	cleanIfNeeded(bazelPath, startupOptions, config)
 	fmt.Printf("bazel %s\n", strings.Join(args, " "))
-	exitCode, err := runBazel(bazelPath, args, nil)
+	exitCode, err := runBazel(bazelPath, args, nil, config)
 	if err != nil {
 		log.Fatalf("could not run Bazel: %v", err)
 	}
@@ -930,10 +897,10 @@ func migrate(bazelPath string, baseArgs []string, flags []string) {
 	// 2. Try with no flags, as a sanity check.
 	args = baseArgs
 	fmt.Printf("\n\n--- Running Bazel with no incompatible flags\n\n")
-	shutdownIfNeeded(bazelPath, startupOptions)
-	cleanIfNeeded(bazelPath, startupOptions)
+	shutdownIfNeeded(bazelPath, startupOptions, config)
+	cleanIfNeeded(bazelPath, startupOptions, config)
 	fmt.Printf("bazel %s\n", strings.Join(args, " "))
-	exitCode, err = runBazel(bazelPath, args, nil)
+	exitCode, err = runBazel(bazelPath, args, nil, config)
 	if err != nil {
 		log.Fatalf("could not run Bazel: %v", err)
 	}
@@ -948,10 +915,10 @@ func migrate(bazelPath string, baseArgs []string, flags []string) {
 	for _, arg := range flags {
 		args = insertArgs(baseArgs, []string{arg})
 		fmt.Printf("\n\n--- Running Bazel with %s\n\n", arg)
-		shutdownIfNeeded(bazelPath, startupOptions)
-		cleanIfNeeded(bazelPath, startupOptions)
+		shutdownIfNeeded(bazelPath, startupOptions, config)
+		cleanIfNeeded(bazelPath, startupOptions, config)
 		fmt.Printf("bazel %s\n", strings.Join(args, " "))
-		exitCode, err = runBazel(bazelPath, args, nil)
+		exitCode, err = runBazel(bazelPath, args, nil, config)
 		if err != nil {
 			log.Fatalf("could not run Bazel: %v", err)
 		}
diff --git a/core/repositories.go b/core/repositories.go
index 7b5b4be..e11e446 100644
--- a/core/repositories.go
+++ b/core/repositories.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"strings"
 
+	"github.com/bazelbuild/bazelisk/config"
 	"github.com/bazelbuild/bazelisk/httputil"
 	"github.com/bazelbuild/bazelisk/platforms"
 	"github.com/bazelbuild/bazelisk/versions"
@@ -235,7 +236,7 @@ func (r *Repositories) DownloadFromBaseURL(baseURL, version, destDir, destFile s
 	return httputil.DownloadBinary(url, destDir, destFile)
 }
 
-func BuildURLFromFormat(formatURL, version string) (string, error) {
+func BuildURLFromFormat(config config.Config, formatURL, version string) (string, error) {
 	osName, err := platforms.DetermineOperatingSystem()
 	if err != nil {
 		return "", err
@@ -261,7 +262,7 @@ func BuildURLFromFormat(formatURL, version string) (string, error) {
 			case 'e':
 				b.WriteString(platforms.DetermineExecutableFilenameSuffix())
 			case 'h':
-				b.WriteString(GetEnvOrConfig("BAZELISK_VERIFY_SHA256"))
+				b.WriteString(config.Get("BAZELISK_VERIFY_SHA256"))
 			case 'm':
 				b.WriteString(machineName)
 			case 'o':
@@ -281,12 +282,12 @@ func BuildURLFromFormat(formatURL, version string) (string, error) {
 }
 
 // DownloadFromFormatURL can download Bazel binaries from a specific URL while ignoring the predefined repositories.
-func (r *Repositories) DownloadFromFormatURL(formatURL, version, destDir, destFile string) (string, error) {
+func (r *Repositories) DownloadFromFormatURL(config config.Config, formatURL, version, destDir, destFile string) (string, error) {
 	if formatURL == "" {
 		return "", fmt.Errorf("%s is not set", FormatURLEnv)
 	}
 
-	url, err := BuildURLFromFormat(formatURL, version)
+	url, err := BuildURLFromFormat(config, formatURL, version)
 	if err != nil {
 		return "", err
 	}
diff --git a/httputil/BUILD b/httputil/BUILD
index d48f290..4c7ac89 100644
--- a/httputil/BUILD
+++ b/httputil/BUILD
@@ -13,7 +13,7 @@ go_library(
     importpath = "github.com/bazelbuild/bazelisk/httputil",
     visibility = ["//visibility:public"],
     deps = [
-        "@com_github_bgentry_go_netrc//:go_default_library",
+        "@com_github_bgentry_go_netrc//netrc:go_default_library",
         "@com_github_mitchellh_go_homedir//:go_default_library",
     ],
 )
diff --git a/ws/BUILD b/ws/BUILD
new file mode 100644
index 0000000..8b3ebbf
--- /dev/null
+++ b/ws/BUILD
@@ -0,0 +1,11 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["ws.go"],
+    importpath = "github.com/bazelbuild/bazelisk/ws",
+    visibility = ["//visibility:public"],
+    deps = [
+        "@com_github_mitchellh_go_homedir//:go_default_library",
+    ],
+)
diff --git a/ws/ws.go b/ws/ws.go
new file mode 100644
index 0000000..653090d
--- /dev/null
+++ b/ws/ws.go
@@ -0,0 +1,36 @@
+package ws
+
+import (
+	"os"
+	"path/filepath"
+)
+
+// FindWorkspaceRoot returns the root directory of the Bazel workspace in which the passed root exists, if any.
+func FindWorkspaceRoot(root string) string {
+	if isValidWorkspace(filepath.Join(root, "WORKSPACE")) {
+		return root
+	}
+
+	if isValidWorkspace(filepath.Join(root, "WORKSPACE.bazel")) {
+		return root
+	}
+
+	parentDirectory := filepath.Dir(root)
+	if parentDirectory == root {
+		return ""
+	}
+
+	return FindWorkspaceRoot(parentDirectory)
+}
+
+// isValidWorkspace returns true iff the supplied path is the workspace root, defined by the presence of
+// a file named WORKSPACE or WORKSPACE.bazel
+// see https://github.com/bazelbuild/bazel/blob/8346ea4cfdd9fbd170d51a528fee26f912dad2d5/src/main/cpp/workspace_layout.cc#L37
+func isValidWorkspace(path string) bool {
+	info, err := os.Stat(path)
+	if err != nil {
+		return false
+	}
+
+	return !info.IsDir()
+}


Test patch:
diff --git a/bazelisk_test.sh b/bazelisk_test.sh
index 2ce2c90..95b51d6 100755
--- a/bazelisk_test.sh
+++ b/bazelisk_test.sh
@@ -299,6 +299,52 @@ EOF
       (echo "FAIL: Expected to find 'BAZELISK_SKIP_WRAPPER=true' in the output of 'bazelisk version'"; exit 1)
 }
 
+function test_path_is_consistent_regardless_of_base_url() {
+  setup
+
+  echo 6.2.0 > .bazelversion
+
+  cat >WORKSPACE <<EOF
+load("//:print_path.bzl", "print_path")
+
+print_path(name = "print_path")
+
+load("@print_path//:defs.bzl", "noop")
+
+noop()
+EOF
+
+cat >print_path.bzl <<EOF
+def _print_path_impl(rctx):
+    print("PATH is: {}".format(rctx.os.environ["PATH"]))
+
+    rctx.file("WORKSPACE", "")
+    rctx.file("BUILD", "")
+    rctx.file("defs.bzl", "def noop(): pass")
+
+print_path = repository_rule(
+    implementation = _print_path_impl,
+)
+EOF
+
+  BAZELISK_HOME="$BAZELISK_HOME" bazelisk sync --only=print_path 2>&1 | tee log1
+
+  BAZELISK_HOME="$BAZELISK_HOME" bazelisk clean --expunge 2>&1
+
+  # We need a separate mirror of bazel binaries, which has identical files.
+  # Ideally we wouldn't depend on sourceforge for test runtime, but hey, it exists and it works.
+  BAZELISK_HOME="$BAZELISK_HOME" BAZELISK_BASE_URL=https://downloads.sourceforge.net/project/bazel.mirror bazelisk sync --only=print_path 2>&1 | tee log2
+
+  path1="$(grep "PATH is:" log1)"
+  path2="$(grep "PATH is:" log2)"
+
+  [[ -n "${path1}" && -n "${path2}" ]] || \
+      (echo "FAIL: Expected PATH to be non-empty, got path1=${path1}, path2=${path2}"; exit 1)
+
+  [[ "${path1}" == "${path2}" ]] || \
+      (echo "FAIL: Expected PATH to be the same regardless of which mirror was used, got path1=${path1}, path2=${path2}"; exit 1)
+}
+
 function test_skip_wrapper() {
   setup
 
@@ -327,10 +373,10 @@ function test_bazel_download_path_go() {
   BAZELISK_HOME="$BAZELISK_HOME" \
       bazelisk version 2>&1 | tee log
 
-  find "$BAZELISK_HOME/downloads/bazelbuild" 2>&1 | tee log
+  find "$BAZELISK_HOME/downloads/metadata/bazelbuild" 2>&1 | tee log
 
-  grep "^$BAZELISK_HOME/downloads/bazelbuild/bazel-[0-9][0-9]*.[0-9][0-9]*.[0-9][0-9]*-[a-z0-9_-]*/bin/bazel\(.exe\)\?$" log || \
-      (echo "FAIL: Expected to download bazel binary into specific path."; exit 1)
+  grep "^$BAZELISK_HOME/downloads/metadata/bazelbuild/bazel-[0-9][0-9]*.[0-9][0-9]*.[0-9][0-9]*-[a-z0-9_-]*$" log || \
+      (echo "FAIL: Expected to download bazel metadata in specific path."; exit 1)
 }
 
 function test_bazel_verify_sha256() {
@@ -395,8 +441,26 @@ function test_bazel_prepend_binary_directory_to_path_go() {
   BAZELISK_HOME="$BAZELISK_HOME" \
       bazelisk --print_env 2>&1 | tee log
 
-  PATTERN=$(echo "^PATH=$BAZELISK_HOME/downloads/bazelbuild/bazel-[0-9][0-9]*.[0-9][0-9]*.[0-9][0-9]*-[a-z0-9_-]*/bin[:;]" | sed -e 's/\//\[\/\\\\\]/g')
-  grep "$PATTERN" log || \
+  local os="$(uname -s | tr A-Z a-z)"
+  case "${os}" in
+      darwin|linux)
+        path_entry_delimiter=":"
+        path_delimiter="/"
+        extension=""
+        ;;
+      msys*|mingw*|cygwin*)
+        path_entry_delimiter=";"
+        path_delimiter="\\"
+        extension=".exe"
+        ;;
+      *)
+        echo "FAIL: Unknown OS ${os} in test"
+        exit 1
+        ;;
+    esac
+  path_entry="$(grep "^PATH=" log | cut -d= -f2- | cut -d"${path_entry_delimiter}" -f1)"
+
+  [[ -x "${path_entry}${path_delimiter}bazel${extension}" ]] || \
       (echo "FAIL: Expected PATH to contains bazel binary directory."; exit 1)
 }
 
@@ -480,6 +544,10 @@ if [[ $BAZELISK_VERSION == "GO" ]]; then
   test_bazel_prepend_binary_directory_to_path_go
   echo
 
+  echo "# test_path_is_consistent_regardless_of_base_url"
+  test_path_is_consistent_regardless_of_base_url
+  echo
+
   case "$(uname -s)" in
     MSYS*)
       # The tests are currently not compatible with Windows.
diff --git a/core/core_test.go b/core/core_test.go
index 9f1dea8..75424f2 100644
--- a/core/core_test.go
+++ b/core/core_test.go
@@ -7,6 +7,8 @@ import (
 	"path/filepath"
 	"runtime"
 	"testing"
+
+	"github.com/bazelbuild/bazelisk/config"
 )
 
 func TestMaybeDelegateToNoWrapper(t *testing.T) {
@@ -16,11 +18,11 @@ func TestMaybeDelegateToNoWrapper(t *testing.T) {
 	}
 	defer os.RemoveAll(tmpDir)
 
-	os.MkdirAll(tmpDir, os.ModeDir | 0700)
+	os.MkdirAll(tmpDir, os.ModeDir|0700)
 	ioutil.WriteFile(filepath.Join(tmpDir, "WORKSPACE"), []byte(""), 0600)
 	ioutil.WriteFile(filepath.Join(tmpDir, "BUILD"), []byte(""), 0600)
 
-	entrypoint := maybeDelegateToWrapperFromDir("bazel_real", tmpDir, true)
+	entrypoint := maybeDelegateToWrapperFromDir("bazel_real", tmpDir, config.Null())
 	expected := "bazel_real"
 
 	if entrypoint != expected {
@@ -35,21 +37,21 @@ func TestMaybeDelegateToNoNonExecutableWrapper(t *testing.T) {
 	if runtime.GOOS == "windows" {
 		return
 	}
-	
+
 	tmpDir, err := ioutil.TempDir("", "TestMaybeDelegateToNoNonExecutableWrapper")
 	if err != nil {
 		log.Fatal(err)
 	}
 	defer os.RemoveAll(tmpDir)
 
-	os.MkdirAll(tmpDir, os.ModeDir | 0700)
+	os.MkdirAll(tmpDir, os.ModeDir|0700)
 	ioutil.WriteFile(filepath.Join(tmpDir, "WORKSPACE"), []byte(""), 0600)
 	ioutil.WriteFile(filepath.Join(tmpDir, "BUILD"), []byte(""), 0600)
 
-	os.MkdirAll(filepath.Join(tmpDir, "tools"), os.ModeDir | 0700)
+	os.MkdirAll(filepath.Join(tmpDir, "tools"), os.ModeDir|0700)
 	ioutil.WriteFile(filepath.Join(tmpDir, "tools", "bazel"), []byte(""), 0600)
 
-	entrypoint := maybeDelegateToWrapperFromDir("bazel_real", tmpDir, true)
+	entrypoint := maybeDelegateToWrapperFromDir("bazel_real", tmpDir, config.Null())
 	expected := "bazel_real"
 
 	if entrypoint != expected {
@@ -71,14 +73,14 @@ func TestMaybeDelegateToStandardWrapper(t *testing.T) {
 	}
 	defer os.RemoveAll(tmpDir)
 
-	os.MkdirAll(tmpDir, os.ModeDir | 0700)
+	os.MkdirAll(tmpDir, os.ModeDir|0700)
 	ioutil.WriteFile(filepath.Join(tmpDir, "WORKSPACE"), []byte(""), 0600)
 	ioutil.WriteFile(filepath.Join(tmpDir, "BUILD"), []byte(""), 0600)
 
-	os.MkdirAll(filepath.Join(tmpDir, "tools"), os.ModeDir | 0700)
+	os.MkdirAll(filepath.Join(tmpDir, "tools"), os.ModeDir|0700)
 	ioutil.WriteFile(filepath.Join(tmpDir, "tools", "bazel"), []byte(""), 0700)
 
-	entrypoint := maybeDelegateToWrapperFromDir("bazel_real", tmpDir, true)
+	entrypoint := maybeDelegateToWrapperFromDir("bazel_real", tmpDir, config.Null())
 	expected := filepath.Join(tmpDir, "tools", "bazel")
 
 	if entrypoint != expected {
@@ -93,14 +95,14 @@ func TestMaybeDelegateToPowershellWrapper(t *testing.T) {
 	}
 	defer os.RemoveAll(tmpDir)
 
-	os.MkdirAll(tmpDir, os.ModeDir | 0700)
+	os.MkdirAll(tmpDir, os.ModeDir|0700)
 	ioutil.WriteFile(filepath.Join(tmpDir, "WORKSPACE"), []byte(""), 0600)
 	ioutil.WriteFile(filepath.Join(tmpDir, "BUILD"), []byte(""), 0600)
 
-	os.MkdirAll(filepath.Join(tmpDir, "tools"), os.ModeDir | 0700)
+	os.MkdirAll(filepath.Join(tmpDir, "tools"), os.ModeDir|0700)
 	ioutil.WriteFile(filepath.Join(tmpDir, "tools", "bazel.ps1"), []byte(""), 0700)
 
-	entrypoint := maybeDelegateToWrapperFromDir("bazel_real", tmpDir, true)
+	entrypoint := maybeDelegateToWrapperFromDir("bazel_real", tmpDir, config.Null())
 	expected := filepath.Join(tmpDir, "tools", "bazel.ps1")
 
 	// Only windows platforms use powershell wrappers
@@ -120,14 +122,14 @@ func TestMaybeDelegateToBatchWrapper(t *testing.T) {
 	}
 	defer os.RemoveAll(tmpDir)
 
-	os.MkdirAll(tmpDir, os.ModeDir | 0700)
+	os.MkdirAll(tmpDir, os.ModeDir|0700)
 	ioutil.WriteFile(filepath.Join(tmpDir, "WORKSPACE"), []byte(""), 0600)
 	ioutil.WriteFile(filepath.Join(tmpDir, "BUILD"), []byte(""), 0600)
 
-	os.MkdirAll(filepath.Join(tmpDir, "tools"), os.ModeDir | 0700)
+	os.MkdirAll(filepath.Join(tmpDir, "tools"), os.ModeDir|0700)
 	ioutil.WriteFile(filepath.Join(tmpDir, "tools", "bazel.bat"), []byte(""), 0700)
 
-	entrypoint := maybeDelegateToWrapperFromDir("bazel_real", tmpDir, true)
+	entrypoint := maybeDelegateToWrapperFromDir("bazel_real", tmpDir, config.Null())
 	expected := filepath.Join(tmpDir, "tools", "bazel.bat")
 
 	// Only windows platforms use batch wrappers
@@ -147,15 +149,15 @@ func TestMaybeDelegateToPowershellOverBatchWrapper(t *testing.T) {
 	}
 	defer os.RemoveAll(tmpDir)
 
-	os.MkdirAll(tmpDir, os.ModeDir | 0700)
+	os.MkdirAll(tmpDir, os.ModeDir|0700)
 	ioutil.WriteFile(filepath.Join(tmpDir, "WORKSPACE"), []byte(""), 0600)
 	ioutil.WriteFile(filepath.Join(tmpDir, "BUILD"), []byte(""), 0600)
 
-	os.MkdirAll(filepath.Join(tmpDir, "tools"), os.ModeDir | 0700)
+	os.MkdirAll(filepath.Join(tmpDir, "tools"), os.ModeDir|0700)
 	ioutil.WriteFile(filepath.Join(tmpDir, "tools", "bazel.ps1"), []byte(""), 0700)
 	ioutil.WriteFile(filepath.Join(tmpDir, "tools", "bazel.bat"), []byte(""), 0700)
 
-	entrypoint := maybeDelegateToWrapperFromDir("bazel_real", tmpDir, true)
+	entrypoint := maybeDelegateToWrapperFromDir("bazel_real", tmpDir, config.Null())
 	expected := filepath.Join(tmpDir, "tools", "bazel.ps1")
 
 	// Only windows platforms use powershell or batch wrappers
diff --git a/core/repositories_test.go b/core/repositories_test.go
index f644244..27ee65b 100644
--- a/core/repositories_test.go
+++ b/core/repositories_test.go
@@ -3,9 +3,9 @@ package core
 import (
 	"errors"
 	"fmt"
-	"os"
 	"testing"
 
+	"github.com/bazelbuild/bazelisk/config"
 	"github.com/bazelbuild/bazelisk/platforms"
 )
 
@@ -24,18 +24,10 @@ func TestBuildURLFromFormat(t *testing.T) {
 
 	suffix := platforms.DetermineExecutableFilenameSuffix()
 
-	previousSha256, hadSha256 := os.LookupEnv("BAZELISK_VERIFY_SHA256")
 	sha256 := "SomeSha256ValueThatIsIrrelevant"
-	if err := os.Setenv("BAZELISK_VERIFY_SHA256", sha256); err != nil {
-		t.Fatalf("Failed to set BAZELISK_VERIFY_SHA256")
-	}
-	defer func() {
-		if hadSha256 {
-			os.Setenv("BAZELISK_VERIFY_SHA256", previousSha256)
-		} else {
-			os.Unsetenv("BAZELISK_VERIFY_SHA256")
-		}
-	}()
+	config := config.Static(map[string]string{
+		"BAZELISK_VERIFY_SHA256": sha256,
+	})
 
 	type test struct {
 		format  string
@@ -65,7 +57,7 @@ func TestBuildURLFromFormat(t *testing.T) {
 	}
 
 	for _, tc := range tests {
-		got, err := BuildURLFromFormat(tc.format, version)
+		got, err := BuildURLFromFormat(config, tc.format, version)
 		if fmt.Sprintf("%v", err) != fmt.Sprintf("%v", tc.wantErr) {
 			if got != "" {
 				t.Errorf("format '%s': got non-empty '%s' on error", tc.format, got)