buildlet: add Path option to ExecOpts

Change-Id: I1353ecc14dfe232886dcfadc47547bbcf91f0b6d
Reviewed-on: https://go-review.googlesource.com/10303
Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
diff --git a/buildlet/buildletclient.go b/buildlet/buildletclient.go
index 65d6b21..f555045 100644
--- a/buildlet/buildletclient.go
+++ b/buildlet/buildletclient.go
@@ -206,6 +206,16 @@
 	// process's environment.
 	ExtraEnv []string
 
+	// Path, if non-nil, specifies the PATH variable of the executed
+	// process's environment. A non-nil empty list clears the path.
+	// The following expansions apply:
+	//   - the string "$PATH" expands to any existing PATH element(s)
+	//   - the substring "$WORKDIR" expands to buildlet's temp workdir
+	// After expansions, the list is joined with an OS-specific list
+	// separator and supplied to the executed process as its PATH
+	// environment variable.
+	Path []string
+
 	// SystemLevel controls whether the command is run outside of
 	// the buildlet's environment.
 	SystemLevel bool
@@ -232,12 +242,19 @@
 	if opts.SystemLevel {
 		mode = "sys"
 	}
+	path := opts.Path
+	if len(path) == 0 && path != nil {
+		// url.Values doesn't distinguish between a nil slice and
+		// a non-nil zero-length slice, so use this sentinel value.
+		path = []string{"$EMPTY"}
+	}
 	form := url.Values{
 		"cmd":    {cmd},
 		"mode":   {mode},
 		"dir":    {opts.Dir},
 		"cmdArg": opts.Args,
 		"env":    opts.ExtraEnv,
+		"path":   path,
 		"debug":  {fmt.Sprint(opts.Debug)},
 	}
 	req, err := http.NewRequest("POST", c.URL()+"/exec", strings.NewReader(form.Encode()))
diff --git a/cmd/buildlet/buildlet.go b/cmd/buildlet/buildlet.go
index 2c512f0..3e2f191 100644
--- a/cmd/buildlet/buildlet.go
+++ b/cmd/buildlet/buildlet.go
@@ -38,6 +38,7 @@
 	"time"
 
 	"golang.org/x/build/buildlet"
+	"golang.org/x/build/envutil"
 	"google.golang.org/cloud/compute/metadata"
 )
 
@@ -620,12 +621,16 @@
 		f.Flush()
 	}
 
+	env := append(baseEnv(), r.PostForm["env"]...)
+	env = envutil.Dedup(runtime.GOOS == "windows", env)
+	env = setPathEnv(env, r.PostForm["path"], *workDir)
+
 	cmd := exec.Command(absCmd, r.PostForm["cmdArg"]...)
 	cmd.Dir = dir
 	cmdOutput := flushWriter{w}
 	cmd.Stdout = cmdOutput
 	cmd.Stderr = cmdOutput
-	cmd.Env = append(baseEnv(), r.PostForm["env"]...)
+	cmd.Env = env
 
 	if debug {
 		fmt.Fprintf(cmdOutput, ":: Running %s with args %q and env %q in dir %s\n\n",
@@ -659,6 +664,59 @@
 	log.Printf("Run = %s", state)
 }
 
+// setPathEnv returns a copy of the provided environment with any existing
+// PATH variables replaced by the user-provided path.
+// These substitutions are applied to user-supplied path elements:
+//   - the string "$PATH" expands to the original PATH elements
+//   - the substring "$WORKDIR" expands to the provided workDir
+// A path of just ["$EMPTY"] removes the PATH variable from the environment.
+func setPathEnv(env, path []string, workDir string) []string {
+	if len(path) == 0 {
+		return env
+	}
+
+	var (
+		pathIdx  = -1
+		pathOrig = ""
+	)
+	for i, s := range env {
+		if strings.HasPrefix(s, "PATH=") {
+			pathIdx = i
+			pathOrig = strings.TrimPrefix(s, "PATH=")
+			break
+		}
+	}
+	if len(path) == 1 && path[0] == "$EMPTY" {
+		// Remove existing path variable if it exists.
+		if pathIdx >= 0 {
+			env = append(env[:pathIdx], env[pathIdx+1:]...)
+		}
+		return env
+	}
+
+	// Apply substitions to a copy of the path argument.
+	path = append([]string{}, path...)
+	for i, s := range path {
+		if s == "$PATH" {
+			path[i] = pathOrig // ok if empty
+		} else {
+			path[i] = strings.Replace(s, "$WORKDIR", workDir, -1)
+		}
+	}
+
+	// Put the new PATH in env.
+	env = append([]string{}, env...)
+	// TODO(adg): check that this works on plan 9
+	pathEnv := "PATH=" + strings.Join(path, string(filepath.ListSeparator))
+	if pathIdx >= 0 {
+		env[pathIdx] = pathEnv
+	} else {
+		env = append(env, pathEnv)
+	}
+
+	return env
+}
+
 func baseEnv() []string {
 	if runtime.GOOS == "windows" {
 		return windowsBaseEnv()
diff --git a/cmd/buildlet/buildlet_test.go b/cmd/buildlet/buildlet_test.go
new file mode 100644
index 0000000..5b9f243
--- /dev/null
+++ b/cmd/buildlet/buildlet_test.go
@@ -0,0 +1,61 @@
+// Copyright 2015 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.
+
+package main
+
+import (
+	"fmt"
+	"runtime"
+	"testing"
+)
+
+func TestSetPathEnv(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("TODO(adg): make this test work on windows")
+	}
+
+	const workDir = "/workdir"
+
+	for _, c := range []struct {
+		env  []string
+		path []string
+		want []string
+	}{
+		{ // No change to PATH
+			[]string{"A=1", "PATH=/bin:/usr/bin", "B=2"},
+			[]string{},
+			[]string{"A=1", "PATH=/bin:/usr/bin", "B=2"},
+		},
+		{ // Test sentinel $EMPTY value to clear PATH
+			[]string{"A=1", "PATH=/bin:/usr/bin", "B=2"},
+			[]string{"$EMPTY"},
+			[]string{"A=1", "B=2"},
+		},
+		{ // Test $WORKDIR expansion
+			[]string{"A=1", "PATH=/bin:/usr/bin", "B=2"},
+			[]string{"/go/bin", "$WORKDIR/foo"},
+			[]string{"A=1", "PATH=/go/bin:/workdir/foo", "B=2"},
+		},
+		{ // Test $PATH expansion
+			[]string{"A=1", "PATH=/bin:/usr/bin", "B=2"},
+			[]string{"/go/bin", "$PATH", "$WORKDIR/foo"},
+			[]string{"A=1", "PATH=/go/bin:/bin:/usr/bin:/workdir/foo", "B=2"},
+		},
+		{ // Test $PATH expansion (prepend only)
+			[]string{"A=1", "PATH=/bin:/usr/bin", "B=2"},
+			[]string{"/go/bin", "/a/b", "$PATH"},
+			[]string{"A=1", "PATH=/go/bin:/a/b:/bin:/usr/bin", "B=2"},
+		},
+		{ // Test $PATH expansion (append only)
+			[]string{"A=1", "PATH=/bin:/usr/bin", "B=2"},
+			[]string{"$PATH", "/go/bin", "/a/b"},
+			[]string{"A=1", "PATH=/bin:/usr/bin:/go/bin:/a/b", "B=2"},
+		},
+	} {
+		got := setPathEnv(c.env, c.path, workDir)
+		if g, w := fmt.Sprint(got), fmt.Sprint(c.want); g != w {
+			t.Errorf("setPathEnv(%q, %q) = %q, want %q", c.env, c.path, g, w)
+		}
+	}
+}