execabs: create execabs package

This package provides an alternative to os/exec that guarantees
never to satisfy a PATH lookup using an executable in the current
directory.

Change-Id: Id978f8039fe21687059c788836c79ae8e0930e92
Reviewed-on: https://team-review.git.corp.google.com/c/golang/go-private/+/949732
Reviewed-by: Katie Hockman <katiehockman@google.com>
Reviewed-by: Russ Cox <rsc@google.com>
Reviewed-on: https://go-review.googlesource.com/c/sys/+/284753
Trust: Roland Shoemaker <roland@golang.org>
Run-TryBot: Roland Shoemaker <roland@golang.org>
Reviewed-by: Russ Cox <rsc@golang.org>
Reviewed-by: Katie Hockman <katie@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
diff --git a/execabs/execabs.go b/execabs/execabs.go
new file mode 100644
index 0000000..7819249
--- /dev/null
+++ b/execabs/execabs.go
@@ -0,0 +1,102 @@
+// Copyright 2020 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 execabs is a drop-in replacement for os/exec
+// that requires PATH lookups to find absolute paths.
+// That is, execabs.Command("cmd") runs the same PATH lookup
+// as exec.Command("cmd"), but if the result is a path
+// which is relative, the Run and Start methods will report
+// an error instead of running the executable.
+//
+// See https://blog.golang.org/path-security for more information
+// about when it may be necessary or appropriate to use this package.
+package execabs
+
+import (
+	"context"
+	"fmt"
+	"os/exec"
+	"path/filepath"
+	"reflect"
+	"unsafe"
+)
+
+// ErrNotFound is the error resulting if a path search failed to find an executable file.
+// It is an alias for exec.ErrNotFound.
+var ErrNotFound = exec.ErrNotFound
+
+// Cmd represents an external command being prepared or run.
+// It is an alias for exec.Cmd.
+type Cmd = exec.Cmd
+
+// Error is returned by LookPath when it fails to classify a file as an executable.
+// It is an alias for exec.Error.
+type Error = exec.Error
+
+// An ExitError reports an unsuccessful exit by a command.
+// It is an alias for exec.ExitError.
+type ExitError = exec.ExitError
+
+func relError(file, path string) error {
+	return fmt.Errorf("%s resolves to executable in current directory (.%c%s)", file, filepath.Separator, path)
+}
+
+// LookPath searches for an executable named file in the directories
+// named by the PATH environment variable. If file contains a slash,
+// it is tried directly and the PATH is not consulted. The result will be
+// an absolute path.
+//
+// LookPath differs from exec.LookPath in its handling of PATH lookups,
+// which are used for file names without slashes. If exec.LookPath's
+// PATH lookup would have returned an executable from the current directory,
+// LookPath instead returns an error.
+func LookPath(file string) (string, error) {
+	path, err := exec.LookPath(file)
+	if err != nil {
+		return "", err
+	}
+	if filepath.Base(file) == file && !filepath.IsAbs(path) {
+		return "", relError(file, path)
+	}
+	return path, nil
+}
+
+func fixCmd(name string, cmd *exec.Cmd) {
+	if filepath.Base(name) == name && !filepath.IsAbs(cmd.Path) {
+		// exec.Command was called with a bare binary name and
+		// exec.LookPath returned a path which is not absolute.
+		// Set cmd.lookPathErr and clear cmd.Path so that it
+		// cannot be run.
+		lookPathErr := (*error)(unsafe.Pointer(reflect.ValueOf(cmd).Elem().FieldByName("lookPathErr").Addr().Pointer()))
+		if *lookPathErr == nil {
+			*lookPathErr = relError(name, cmd.Path)
+		}
+		cmd.Path = ""
+	}
+}
+
+// CommandContext is like Command but includes a context.
+//
+// The provided context is used to kill the process (by calling os.Process.Kill)
+// if the context becomes done before the command completes on its own.
+func CommandContext(ctx context.Context, name string, arg ...string) *exec.Cmd {
+	cmd := exec.CommandContext(ctx, name, arg...)
+	fixCmd(name, cmd)
+	return cmd
+
+}
+
+// Command returns the Cmd struct to execute the named program with the given arguments.
+// See exec.Command for most details.
+//
+// Command differs from exec.Command in its handling of PATH lookups,
+// which are used when the program name contains no slashes.
+// If exec.Command would have returned an exec.Cmd configured to run an
+// executable from the current directory, Command instead
+// returns an exec.Cmd that will return an error from Start or Run.
+func Command(name string, arg ...string) *exec.Cmd {
+	cmd := exec.Command(name, arg...)
+	fixCmd(name, cmd)
+	return cmd
+}
diff --git a/execabs/execabs_test.go b/execabs/execabs_test.go
new file mode 100644
index 0000000..a0b88dd
--- /dev/null
+++ b/execabs/execabs_test.go
@@ -0,0 +1,107 @@
+// Copyright 2020 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 execabs
+
+import (
+	"context"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+	"testing"
+)
+
+func TestFixCmd(t *testing.T) {
+	cmd := &exec.Cmd{Path: "hello"}
+	fixCmd("hello", cmd)
+	if cmd.Path != "" {
+		t.Errorf("fixCmd didn't clear cmd.Path")
+	}
+	expectedErr := fmt.Sprintf("hello resolves to executable in current directory (.%chello)", filepath.Separator)
+	if err := cmd.Run(); err == nil {
+		t.Fatal("Command.Run didn't fail")
+	} else if err.Error() != expectedErr {
+		t.Fatalf("Command.Run returned unexpected error: want %q, got %q", expectedErr, err.Error())
+	}
+}
+
+func TestCommand(t *testing.T) {
+	for _, cmd := range []func(string) *Cmd{
+		func(s string) *Cmd { return Command(s) },
+		func(s string) *Cmd { return CommandContext(context.Background(), s) },
+	} {
+		tmpDir, err := ioutil.TempDir("", "execabs-test")
+		if err != nil {
+			t.Fatalf("ioutil.TempDir failed: %s", err)
+		}
+		defer os.RemoveAll(tmpDir)
+		executable := "execabs-test"
+		if runtime.GOOS == "windows" {
+			executable += ".exe"
+		}
+		if err = ioutil.WriteFile(filepath.Join(tmpDir, executable), []byte{1, 2, 3}, 0111); err != nil {
+			t.Fatalf("ioutil.WriteFile failed: %s", err)
+		}
+		cwd, err := os.Getwd()
+		if err != nil {
+			t.Fatalf("os.Getwd failed: %s", err)
+		}
+		defer os.Chdir(cwd)
+		if err = os.Chdir(tmpDir); err != nil {
+			t.Fatalf("os.Chdir failed: %s", err)
+		}
+		if runtime.GOOS != "windows" {
+			// add "." to PATH so that exec.LookPath looks in the current directory on
+			// non-windows platforms as well
+			origPath := os.Getenv("PATH")
+			defer os.Setenv("PATH", origPath)
+			os.Setenv("PATH", fmt.Sprintf(".:%s", origPath))
+		}
+		expectedErr := fmt.Sprintf("execabs-test resolves to executable in current directory (.%c%s)", filepath.Separator, executable)
+		if err = cmd("execabs-test").Run(); err == nil {
+			t.Fatalf("Command.Run didn't fail when exec.LookPath returned a relative path")
+		} else if err.Error() != expectedErr {
+			t.Errorf("Command.Run returned unexpected error: want %q, got %q", expectedErr, err.Error())
+		}
+	}
+}
+
+func TestLookPath(t *testing.T) {
+	tmpDir, err := ioutil.TempDir("", "execabs-test")
+	if err != nil {
+		t.Fatalf("ioutil.TempDir failed: %s", err)
+	}
+	defer os.RemoveAll(tmpDir)
+	executable := "execabs-test"
+	if runtime.GOOS == "windows" {
+		executable += ".exe"
+	}
+	if err = ioutil.WriteFile(filepath.Join(tmpDir, executable), []byte{1, 2, 3}, 0111); err != nil {
+		t.Fatalf("ioutil.WriteFile failed: %s", err)
+	}
+	cwd, err := os.Getwd()
+	if err != nil {
+		t.Fatalf("os.Getwd failed: %s", err)
+	}
+	defer os.Chdir(cwd)
+	if err = os.Chdir(tmpDir); err != nil {
+		t.Fatalf("os.Chdir failed: %s", err)
+	}
+	if runtime.GOOS != "windows" {
+		// add "." to PATH so that exec.LookPath looks in the current directory on
+		// non-windows platforms as well
+		origPath := os.Getenv("PATH")
+		defer os.Setenv("PATH", origPath)
+		os.Setenv("PATH", fmt.Sprintf(".:%s", origPath))
+	}
+	expectedErr := fmt.Sprintf("execabs-test resolves to executable in current directory (.%c%s)", filepath.Separator, executable)
+	if _, err := LookPath("execabs-test"); err == nil {
+		t.Fatalf("LookPath didn't fail when finding a non-relative path")
+	} else if err.Error() != expectedErr {
+		t.Errorf("LookPath returned unexpected error: want %q, got %q", expectedErr, err.Error())
+	}
+}