os/exec: return error when PATH lookup would use current directory

Following discussion on #43724, change os/exec to take the
approach of golang.org/x/sys/execabs, refusing to respect
path entries mentioning relative paths by default.

Code that insists on being able to find executables in relative
directories in the path will need to add a couple lines to override the error.

See the updated package docs in exec.go for more details.

Fixes #43724.
Fixes #43947.

Change-Id: I73c1214f322b60b4167a23e956e933d50470fe13
Reviewed-on: https://go-review.googlesource.com/c/go/+/381374
Reviewed-by: Bryan Mills <bcmills@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Run-TryBot: Bryan Mills <bcmills@google.com>
diff --git a/api/next/43724.txt b/api/next/43724.txt
new file mode 100644
index 0000000..1030a80
--- /dev/null
+++ b/api/next/43724.txt
@@ -0,0 +1,2 @@
+pkg os/exec, type Cmd struct, Err error #43724
+pkg os/exec, var ErrDot error #43724
diff --git a/src/cmd/dist/build.go b/src/cmd/dist/build.go
index bbaf595..87e2421 100644
--- a/src/cmd/dist/build.go
+++ b/src/cmd/dist/build.go
@@ -27,6 +27,7 @@
 var (
 	goarch           string
 	gorootBin        string
+	gorootBinGo      string
 	gohostarch       string
 	gohostos         string
 	goos             string
@@ -114,6 +115,12 @@
 	goroot = filepath.Clean(b)
 	gorootBin = pathf("%s/bin", goroot)
 
+	// Don't run just 'go' because the build infrastructure
+	// runs cmd/dist inside go/bin often, and on Windows
+	// it will be found in the current directory and refuse to exec.
+	// All exec calls rewrite "go" into gorootBinGo.
+	gorootBinGo = pathf("%s/bin/go", goroot)
+
 	b = os.Getenv("GOROOT_FINAL")
 	if b == "" {
 		b = goroot
diff --git a/src/cmd/dist/test.go b/src/cmd/dist/test.go
index 7c8f1ea..2b6b1e5 100644
--- a/src/cmd/dist/test.go
+++ b/src/cmd/dist/test.go
@@ -27,6 +27,7 @@
 	gogcflags = os.Getenv("GO_GCFLAGS")
 
 	var t tester
+
 	var noRebuild bool
 	flag.BoolVar(&t.listMode, "list", false, "list available tests")
 	flag.BoolVar(&t.rebuild, "rebuild", false, "rebuild everything first")
@@ -96,15 +97,9 @@
 func (t *tester) run() {
 	timelog("start", "dist test")
 
-	var exeSuffix string
-	if goos == "windows" {
-		exeSuffix = ".exe"
-	}
-	if _, err := os.Stat(filepath.Join(gorootBin, "go"+exeSuffix)); err == nil {
-		os.Setenv("PATH", fmt.Sprintf("%s%c%s", gorootBin, os.PathListSeparator, os.Getenv("PATH")))
-	}
+	os.Setenv("PATH", fmt.Sprintf("%s%c%s", gorootBin, os.PathListSeparator, os.Getenv("PATH")))
 
-	cmd := exec.Command("go", "env", "CGO_ENABLED")
+	cmd := exec.Command(gorootBinGo, "env", "CGO_ENABLED")
 	cmd.Stderr = new(bytes.Buffer)
 	slurp, err := cmd.Output()
 	if err != nil {
@@ -419,7 +414,7 @@
 				args = append(args, "-run=^$")
 			}
 			args = append(args, stdMatches...)
-			cmd := exec.Command("go", args...)
+			cmd := exec.Command(gorootBinGo, args...)
 			cmd.Stdout = os.Stdout
 			cmd.Stderr = os.Stderr
 			return cmd.Run()
@@ -456,7 +451,7 @@
 				args = append(args, "-bench=.*")
 			}
 			args = append(args, benchMatches...)
-			cmd := exec.Command("go", args...)
+			cmd := exec.Command(gorootBinGo, args...)
 			cmd.Stdout = os.Stdout
 			cmd.Stderr = os.Stderr
 			return cmd.Run()
@@ -484,7 +479,7 @@
 	} else {
 		// Use a format string to only list packages and commands that have tests.
 		const format = "{{if (or .TestGoFiles .XTestGoFiles)}}{{.ImportPath}}{{end}}"
-		cmd := exec.Command("go", "list", "-f", format)
+		cmd := exec.Command(gorootBinGo, "list", "-f", format)
 		if t.race {
 			cmd.Args = append(cmd.Args, "-tags=race")
 		}
@@ -619,7 +614,7 @@
 					fmt.Println("skipping terminal test; stdout/stderr not terminals")
 					return nil
 				}
-				cmd := exec.Command("go", "test")
+				cmd := exec.Command(gorootBinGo, "test")
 				setDir(cmd, filepath.Join(os.Getenv("GOROOT"), "src/cmd/go/testdata/testterminal18153"))
 				cmd.Stdout = os.Stdout
 				cmd.Stderr = os.Stderr
@@ -1003,7 +998,11 @@
 	}
 	list = out
 
-	return list[0], list[1:]
+	bin = list[0]
+	if bin == "go" {
+		bin = gorootBinGo
+	}
+	return bin, list[1:]
 }
 
 func (t *tester) addCmd(dt *distTest, dir string, cmdline ...interface{}) *exec.Cmd {
@@ -1157,7 +1156,7 @@
 }
 
 func (t *tester) runHostTest(dir, pkg string) error {
-	out, err := exec.Command("go", "env", "GOEXE", "GOTMPDIR").Output()
+	out, err := exec.Command(gorootBinGo, "env", "GOEXE", "GOTMPDIR").Output()
 	if err != nil {
 		return err
 	}
diff --git a/src/cmd/dist/util.go b/src/cmd/dist/util.go
index 8856f46..ee8ba91 100644
--- a/src/cmd/dist/util.go
+++ b/src/cmd/dist/util.go
@@ -71,7 +71,11 @@
 		errprintf("run: %s\n", strings.Join(cmd, " "))
 	}
 
-	xcmd := exec.Command(cmd[0], cmd[1:]...)
+	bin := cmd[0]
+	if bin == "go" {
+		bin = gorootBinGo
+	}
+	xcmd := exec.Command(bin, cmd[1:]...)
 	setDir(xcmd, dir)
 	var data []byte
 	var err error
diff --git a/src/cmd/go/testdata/script/cgo_path.txt b/src/cmd/go/testdata/script/cgo_path.txt
index be9609e..1f84dbc 100644
--- a/src/cmd/go/testdata/script/cgo_path.txt
+++ b/src/cmd/go/testdata/script/cgo_path.txt
@@ -14,7 +14,7 @@
 [windows] exists -exec p/gcc.bat p/clang.bat
 ! exists p/bug.txt
 ! go build -x
-stderr '^cgo: exec (clang|gcc): (clang|gcc) resolves to executable relative to current directory \(.[/\\](clang|gcc)(.bat)?\)$'
+stderr '^cgo: C compiler "(clang|gcc)" not found: exec: "(clang|gcc)": cannot run executable found relative to current directory'
 ! exists p/bug.txt
 
 -- go.mod --
diff --git a/src/internal/execabs/execabs.go b/src/internal/execabs/execabs.go
index 9a05d97..5f60fbb 100644
--- a/src/internal/execabs/execabs.go
+++ b/src/internal/execabs/execabs.go
@@ -12,11 +12,7 @@
 
 import (
 	"context"
-	"fmt"
 	"os/exec"
-	"path/filepath"
-	"reflect"
-	"unsafe"
 )
 
 var ErrNotFound = exec.ErrNotFound
@@ -27,44 +23,14 @@
 	ExitError = exec.ExitError
 )
 
-func relError(file, path string) error {
-	return fmt.Errorf("%s resolves to executable relative to current directory (.%c%s)", file, filepath.Separator, path)
-}
-
 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 = ""
-	}
+	return exec.LookPath(file)
 }
 
 func CommandContext(ctx context.Context, name string, arg ...string) *exec.Cmd {
-	cmd := exec.CommandContext(ctx, name, arg...)
-	fixCmd(name, cmd)
-	return cmd
-
+	return exec.CommandContext(ctx, name, arg...)
 }
 
 func Command(name string, arg ...string) *exec.Cmd {
-	cmd := exec.Command(name, arg...)
-	fixCmd(name, cmd)
-	return cmd
+	return exec.Command(name, arg...)
 }
diff --git a/src/internal/execabs/execabs_test.go b/src/internal/execabs/execabs_test.go
deleted file mode 100644
index 97a3f39..0000000
--- a/src/internal/execabs/execabs_test.go
+++ /dev/null
@@ -1,103 +0,0 @@
-// 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"
-	"internal/testenv"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"runtime"
-	"testing"
-)
-
-func TestFixCmd(t *testing.T) {
-	cmd := &exec.Cmd{Path: "hello"}
-	fixCmd("hello", cmd)
-	if cmd.Path != "" {
-		t.Error("fixCmd didn't clear cmd.Path")
-	}
-	expectedErr := fmt.Sprintf("hello resolves to executable relative to 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) {
-	testenv.MustHaveExec(t)
-
-	for _, cmd := range []func(string) *Cmd{
-		func(s string) *Cmd { return Command(s) },
-		func(s string) *Cmd { return CommandContext(context.Background(), s) },
-	} {
-		tmpDir := t.TempDir()
-		executable := "execabs-test"
-		if runtime.GOOS == "windows" {
-			executable += ".exe"
-		}
-		if err := os.WriteFile(filepath.Join(tmpDir, executable), []byte{1, 2, 3}, 0111); err != nil {
-			t.Fatalf("os.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 relative to 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) {
-	testenv.MustHaveExec(t)
-
-	tmpDir := t.TempDir()
-	executable := "execabs-test"
-	if runtime.GOOS == "windows" {
-		executable += ".exe"
-	}
-	if err := os.WriteFile(filepath.Join(tmpDir, executable), []byte{1, 2, 3}, 0111); err != nil {
-		t.Fatalf("os.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 relative to 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())
-	}
-}
diff --git a/src/os/exec/dot_test.go b/src/os/exec/dot_test.go
new file mode 100644
index 0000000..ca6b095
--- /dev/null
+++ b/src/os/exec/dot_test.go
@@ -0,0 +1,88 @@
+// 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 exec_test
+
+import (
+	"errors"
+	"internal/testenv"
+	"io/ioutil"
+	"os"
+	. "os/exec"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"testing"
+)
+
+func TestLookPath(t *testing.T) {
+	testenv.MustHaveExec(t)
+
+	tmpDir := filepath.Join(t.TempDir(), "testdir")
+	if err := os.Mkdir(tmpDir, 0777); err != nil {
+		t.Fatal(err)
+	}
+
+	executable := "execabs-test"
+	if runtime.GOOS == "windows" {
+		executable += ".exe"
+	}
+	if err := ioutil.WriteFile(filepath.Join(tmpDir, executable), []byte{1, 2, 3}, 0777); err != nil {
+		t.Fatal(err)
+	}
+	cwd, err := os.Getwd()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer func() {
+		if err := os.Chdir(cwd); err != nil {
+			panic(err)
+		}
+	}()
+	if err = os.Chdir(tmpDir); err != nil {
+		t.Fatal(err)
+	}
+	origPath := os.Getenv("PATH")
+	defer os.Setenv("PATH", origPath)
+
+	// Add "." to PATH so that exec.LookPath looks in the current directory on all systems.
+	// And try to trick it with "../testdir" too.
+	for _, dir := range []string{".", "../testdir"} {
+		os.Setenv("PATH", dir+string(filepath.ListSeparator)+origPath)
+		t.Run("PATH="+dir, func(t *testing.T) {
+			good := dir + "/execabs-test"
+			if found, err := LookPath(good); err != nil || !strings.HasPrefix(found, good) {
+				t.Fatalf("LookPath(%q) = %q, %v, want \"%s...\", nil", good, found, err, good)
+			}
+			if runtime.GOOS == "windows" {
+				good = dir + `\execabs-test`
+				if found, err := LookPath(good); err != nil || !strings.HasPrefix(found, good) {
+					t.Fatalf("LookPath(%q) = %q, %v, want \"%s...\", nil", good, found, err, good)
+				}
+			}
+
+			if _, err := LookPath("execabs-test"); err == nil {
+				t.Fatalf("LookPath didn't fail when finding a non-relative path")
+			} else if !errors.Is(err, ErrDot) {
+				t.Fatalf("LookPath returned unexpected error: want Is ErrDot, got %q", err)
+			}
+
+			cmd := Command("execabs-test")
+			if cmd.Err == nil {
+				t.Fatalf("Command didn't fail when finding a non-relative path")
+			} else if !errors.Is(cmd.Err, ErrDot) {
+				t.Fatalf("Command returned unexpected error: want Is ErrDot, got %q", cmd.Err)
+			}
+			cmd.Err = nil
+
+			// Clearing cmd.Err should let the execution proceed,
+			// and it should fail because it's not a valid binary.
+			if err := cmd.Run(); err == nil {
+				t.Fatalf("Run did not fail: expected exec error")
+			} else if errors.Is(err, ErrDot) {
+				t.Fatalf("Run returned unexpected error ErrDot: want error like ENOEXEC: %q", err)
+			}
+		})
+	}
+}
diff --git a/src/os/exec/exec.go b/src/os/exec/exec.go
index 91c2e00..7b72ffe 100644
--- a/src/os/exec/exec.go
+++ b/src/os/exec/exec.go
@@ -18,6 +18,71 @@
 // Note that the examples in this package assume a Unix system.
 // They may not run on Windows, and they do not run in the Go Playground
 // used by golang.org and godoc.org.
+//
+// Executables in the current directory
+//
+// The functions Command and LookPath look for a program
+// in the directories listed in the current path, following the
+// conventions of the host operating system.
+// Operating systems have for decades included the current
+// directory in this search, sometimes implicitly and sometimes
+// configured explicitly that way by default.
+// Modern practice is that including the current directory
+// is usually unexpected and often leads to security problems.
+//
+// To avoid those security problems, as of Go 1.19, this package will not resolve a program
+// using an implicit or explicit path entry relative to the current directory.
+// That is, if you run exec.LookPath("go"), it will not successfully return
+// ./go on Unix nor .\go.exe on Windows, no matter how the path is configured.
+// Instead, if the usual path algorithms would result in that answer,
+// these functions return an error err satisfying errors.Is(err, ErrDot).
+//
+// For example, consider these two program snippets:
+//
+//	path, err := exec.LookPath("prog")
+//	if err != nil {
+//		log.Fatal(err)
+//	}
+//	use(path)
+//
+// and
+//
+//	cmd := exec.Command("prog")
+//	if err := cmd.Run(); err != nil {
+//		log.Fatal(err)
+//	}
+//
+// These will not find and run ./prog or .\prog.exe,
+// no matter how the current path is configured.
+//
+// Code that always wants to run a program from the current directory
+// can be rewritten to say "./prog" instead of "prog".
+//
+// Code that insists on including results from relative path entries
+// can instead override the error using an errors.Is check:
+//
+//	path, err := exec.LookPath("prog")
+//	if errors.Is(err, exec.ErrDot) {
+//		err = nil
+//	}
+//	if err != nil {
+//		log.Fatal(err)
+//	}
+//	use(path)
+//
+// and
+//
+//	cmd := exec.Command("prog")
+//	if errors.Is(cmd.Err, exec.ErrDot) {
+//		cmd.Err = nil
+//	}
+//	if err := cmd.Run(); err != nil {
+//		log.Fatal(err)
+//	}
+//
+// Before adding such overrides, make sure you understand the
+// security implications of doing so.
+// See https://go.dev/blog/path-security for more information.
 package exec
 
 import (
@@ -134,7 +199,7 @@
 	ProcessState *os.ProcessState
 
 	ctx             context.Context // nil means none
-	lookPathErr     error           // LookPath error, if any.
+	Err             error           // LookPath error, if any.
 	finished        bool            // when Wait was called
 	childFiles      []*os.File
 	closeAfterStart []io.Closer
@@ -173,7 +238,7 @@
 	}
 	if filepath.Base(name) == name {
 		if lp, err := LookPath(name); err != nil {
-			cmd.lookPathErr = err
+			cmd.Err = err
 		} else {
 			cmd.Path = lp
 		}
@@ -200,7 +265,7 @@
 // In particular, it is not suitable for use as input to a shell.
 // The output of String may vary across Go releases.
 func (c *Cmd) String() string {
-	if c.lookPathErr != nil {
+	if c.Err != nil {
 		// failed to resolve path; report the original requested path (plus args)
 		return strings.Join(c.Args, " ")
 	}
@@ -335,7 +400,7 @@
 // lookExtensions does not search PATH, instead it converts `prog` into `.\prog`.
 func lookExtensions(path, dir string) (string, error) {
 	if filepath.Base(path) == path {
-		path = filepath.Join(".", path)
+		path = "." + string(filepath.Separator) + path
 	}
 	if dir == "" {
 		return LookPath(path)
@@ -363,10 +428,10 @@
 // The Wait method will return the exit code and release associated resources
 // once the command exits.
 func (c *Cmd) Start() error {
-	if c.lookPathErr != nil {
+	if c.Err != nil {
 		c.closeDescriptors(c.closeAfterStart)
 		c.closeDescriptors(c.closeAfterWait)
-		return c.lookPathErr
+		return c.Err
 	}
 	if runtime.GOOS == "windows" {
 		lp, err := lookExtensions(c.Path, c.Dir)
@@ -845,3 +910,12 @@
 	}
 	return append(env, "SYSTEMROOT="+os.Getenv("SYSTEMROOT"))
 }
+
+// ErrDot indicates that a path lookup resolved to an executable
+// in the current directory due to ‘.’ being in the path, either
+// implicitly or explicitly. See the package documentation for details.
+//
+// Note that functions in this package do not return ErrDot directly.
+// Code should use errors.Is(err, ErrDot), not err == ErrDot,
+// to test whether a returned error err is due to this condition.
+var ErrDot = errors.New("cannot run executable found relative to current directory")
diff --git a/src/os/exec/lp_plan9.go b/src/os/exec/lp_plan9.go
index e8826a5..6822481 100644
--- a/src/os/exec/lp_plan9.go
+++ b/src/os/exec/lp_plan9.go
@@ -30,7 +30,11 @@
 // directories named by the path environment variable.
 // If file begins with "/", "#", "./", or "../", it is tried
 // directly and the path is not consulted.
-// The result may be an absolute path or a path relative to the current directory.
+// On success, the result is an absolute path.
+//
+// In older versions of Go, LookPath could return a path relative to the current directory.
+// As of Go 1.19, LookPath will instead return that path along with an error satisfying
+// errors.Is(err, ErrDot). See the package documentation for more details.
 func LookPath(file string) (string, error) {
 	// skip the path lookup for these prefixes
 	skip := []string{"/", "#", "./", "../"}
@@ -49,6 +53,9 @@
 	for _, dir := range filepath.SplitList(path) {
 		path := filepath.Join(dir, file)
 		if err := findExecutable(path); err == nil {
+			if !filepath.IsAbs(path) {
+				return path, &Error{file, ErrDot}
+			}
 			return path, nil
 		}
 	}
diff --git a/src/os/exec/lp_unix.go b/src/os/exec/lp_unix.go
index 5db6c5e..9833205 100644
--- a/src/os/exec/lp_unix.go
+++ b/src/os/exec/lp_unix.go
@@ -31,7 +31,11 @@
 // 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 may be an absolute path or a path relative to the current directory.
+// Otherwise, on success, the result is an absolute path.
+//
+// In older versions of Go, LookPath could return a path relative to the current directory.
+// As of Go 1.19, LookPath will instead return that path along with an error satisfying
+// errors.Is(err, ErrDot). See the package documentation for more details.
 func LookPath(file string) (string, error) {
 	// NOTE(rsc): I wish we could use the Plan 9 behavior here
 	// (only bypass the path if file begins with / or ./ or ../)
@@ -52,6 +56,9 @@
 		}
 		path := filepath.Join(dir, file)
 		if err := findExecutable(path); err == nil {
+			if !filepath.IsAbs(path) {
+				return path, &Error{file, ErrDot}
+			}
 			return path, nil
 		}
 	}
diff --git a/src/os/exec/lp_windows.go b/src/os/exec/lp_windows.go
index e7a2cdf..dab5770 100644
--- a/src/os/exec/lp_windows.go
+++ b/src/os/exec/lp_windows.go
@@ -10,6 +10,7 @@
 	"os"
 	"path/filepath"
 	"strings"
+	"syscall"
 )
 
 // ErrNotFound is the error resulting if a path search failed to find an executable file.
@@ -53,10 +54,14 @@
 
 // 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.
 // LookPath also uses PATHEXT environment variable to match
 // a suitable candidate.
-// The result may be an absolute path or a path relative to the current directory.
+// If file contains a slash, it is tried directly and the PATH is not consulted.
+// Otherwise, on success, the result is an absolute path.
+//
+// In older versions of Go, LookPath could return a path relative to the current directory.
+// As of Go 1.19, LookPath will instead return that path along with an error satisfying
+// errors.Is(err, ErrDot). See the package documentation for more details.
 func LookPath(file string) (string, error) {
 	var exts []string
 	x := os.Getenv(`PATHEXT`)
@@ -75,18 +80,34 @@
 	}
 
 	if strings.ContainsAny(file, `:\/`) {
-		if f, err := findExecutable(file, exts); err == nil {
+		f, err := findExecutable(file, exts)
+		if err == nil {
 			return f, nil
-		} else {
-			return "", &Error{file, err}
+		}
+		return "", &Error{file, err}
+	}
+
+	// On Windows, creating the NoDefaultCurrentDirectoryInExePath
+	// environment variable (with any value or no value!) signals that
+	// path lookups should skip the current directory.
+	// In theory we are supposed to call NeedCurrentDirectoryForExePathW
+	// "as the registry location of this environment variable can change"
+	// but that seems exceedingly unlikely: it would break all users who
+	// have configured their environment this way!
+	// https://docs.microsoft.com/en-us/windows/win32/api/processenv/nf-processenv-needcurrentdirectoryforexepathw
+	// See also go.dev/issue/43947.
+	if _, found := syscall.Getenv("NoDefaultCurrentDirectoryInExePath"); !found {
+		if f, err := findExecutable(filepath.Join(".", file), exts); err == nil {
+			return f, &Error{file, ErrDot}
 		}
 	}
-	if f, err := findExecutable(filepath.Join(".", file), exts); err == nil {
-		return f, nil
-	}
+
 	path := os.Getenv("path")
 	for _, dir := range filepath.SplitList(path) {
 		if f, err := findExecutable(filepath.Join(dir, file), exts); err == nil {
+			if !filepath.IsAbs(f) {
+				return f, &Error{file, ErrDot}
+			}
 			return f, nil
 		}
 	}
diff --git a/src/os/exec/lp_windows_test.go b/src/os/exec/lp_windows_test.go
index 34abe09..1f609ff 100644
--- a/src/os/exec/lp_windows_test.go
+++ b/src/os/exec/lp_windows_test.go
@@ -8,6 +8,7 @@
 package exec_test
 
 import (
+	"errors"
 	"fmt"
 	"internal/testenv"
 	"io"
@@ -36,6 +37,9 @@
 func cmdExec(args ...string) {
 	cmd := exec.Command(args[1])
 	cmd.Dir = args[0]
+	if errors.Is(cmd.Err, exec.ErrDot) {
+		cmd.Err = nil
+	}
 	output, err := cmd.CombinedOutput()
 	if err != nil {
 		fmt.Fprintf(os.Stderr, "Child: %s %s", err, string(output))
@@ -329,7 +333,7 @@
 	},
 }
 
-func TestLookPath(t *testing.T) {
+func TestLookPathWindows(t *testing.T) {
 	tmp := t.TempDir()
 	printpathExe := buildPrintPathExe(t, tmp)