blob: a92a29799f35f187d5734c7a4cb98b5438a59627 [file] [log] [blame]
// Copyright 2013 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.
// Use an external test to avoid os/exec -> internal/testenv -> os/exec
// circular dependency.
package exec_test
import (
"errors"
"fmt"
"internal/testenv"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
)
func init() {
registerHelperCommand("printpath", cmdPrintPath)
}
func cmdPrintPath(args ...string) {
exe, err := os.Executable()
if err != nil {
fmt.Fprintf(os.Stderr, "Executable: %v\n", err)
os.Exit(1)
}
fmt.Println(exe)
}
// makePATH returns a PATH variable referring to the
// given directories relative to a root directory.
//
// The empty string results in an empty entry.
// Paths beginning with . are kept as relative entries.
func makePATH(root string, dirs []string) string {
paths := make([]string, 0, len(dirs))
for _, d := range dirs {
switch {
case d == "":
paths = append(paths, "")
case d == "." || (len(d) >= 2 && d[0] == '.' && os.IsPathSeparator(d[1])):
paths = append(paths, filepath.Clean(d))
default:
paths = append(paths, filepath.Join(root, d))
}
}
return strings.Join(paths, string(os.PathListSeparator))
}
// installProgs creates executable files (or symlinks to executable files) at
// multiple destination paths. It uses root as prefix for all destination files.
func installProgs(t *testing.T, root string, files []string) {
for _, f := range files {
dstPath := filepath.Join(root, f)
dir := filepath.Dir(dstPath)
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
if os.IsPathSeparator(f[len(f)-1]) {
continue // directory and PATH entry only.
}
if strings.EqualFold(filepath.Ext(f), ".bat") {
installBat(t, dstPath)
} else {
installExe(t, dstPath)
}
}
}
// installExe installs a copy of the test executable
// at the given location, creating directories as needed.
//
// (We use a copy instead of just a symlink to ensure that os.Executable
// always reports an unambiguous path, regardless of how it is implemented.)
func installExe(t *testing.T, dstPath string) {
src, err := os.Open(exePath(t))
if err != nil {
t.Fatal(err)
}
defer src.Close()
dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o777)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := dst.Close(); err != nil {
t.Fatal(err)
}
}()
_, err = io.Copy(dst, src)
if err != nil {
t.Fatal(err)
}
}
// installBat creates a batch file at dst that prints its own
// path when run.
func installBat(t *testing.T, dstPath string) {
dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o777)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := dst.Close(); err != nil {
t.Fatal(err)
}
}()
if _, err := fmt.Fprintf(dst, "@echo %s\r\n", dstPath); err != nil {
t.Fatal(err)
}
}
type lookPathTest struct {
name string
PATHEXT string // empty to use default
files []string
PATH []string // if nil, use all parent directories from files
searchFor string
want string
wantErr error
skipCmdExeCheck bool // if true, do not check want against the behavior of cmd.exe
}
var lookPathTests = []lookPathTest{
{
name: "first match",
files: []string{`p1\a.exe`, `p2\a.exe`, `p2\a`},
searchFor: `a`,
want: `p1\a.exe`,
},
{
name: "dirs with extensions",
files: []string{`p1.dir\a`, `p2.dir\a.exe`},
searchFor: `a`,
want: `p2.dir\a.exe`,
},
{
name: "first with extension",
files: []string{`p1\a.exe`, `p2\a.exe`},
searchFor: `a.exe`,
want: `p1\a.exe`,
},
{
name: "specific name",
files: []string{`p1\a.exe`, `p2\b.exe`},
searchFor: `b`,
want: `p2\b.exe`,
},
{
name: "no extension",
files: []string{`p1\b`, `p2\a`},
searchFor: `a`,
wantErr: exec.ErrNotFound,
},
{
name: "directory, no extension",
files: []string{`p1\a.exe`, `p2\a.exe`},
searchFor: `p2\a`,
want: `p2\a.exe`,
},
{
name: "no match",
files: []string{`p1\a.exe`, `p2\a.exe`},
searchFor: `b`,
wantErr: exec.ErrNotFound,
},
{
name: "no match with dir",
files: []string{`p1\b.exe`, `p2\a.exe`},
searchFor: `p2\b`,
wantErr: exec.ErrNotFound,
},
{
name: "extensionless file in CWD ignored",
files: []string{`a`, `p1\a.exe`, `p2\a.exe`},
searchFor: `a`,
want: `p1\a.exe`,
},
{
name: "extensionless file in PATH ignored",
files: []string{`p1\a`, `p2\a.exe`},
searchFor: `a`,
want: `p2\a.exe`,
},
{
name: "specific extension",
files: []string{`p1\a.exe`, `p2\a.bat`},
searchFor: `a.bat`,
want: `p2\a.bat`,
},
{
name: "mismatched extension",
files: []string{`p1\a.exe`, `p2\a.exe`},
searchFor: `a.com`,
wantErr: exec.ErrNotFound,
},
{
name: "doubled extension",
files: []string{`p1\a.exe.exe`},
searchFor: `a.exe`,
want: `p1\a.exe.exe`,
},
{
name: "extension not in PATHEXT",
PATHEXT: `.COM;.BAT`,
files: []string{`p1\a.exe`, `p2\a.exe`},
searchFor: `a.exe`,
want: `p1\a.exe`,
},
{
name: "first allowed by PATHEXT",
PATHEXT: `.COM;.EXE`,
files: []string{`p1\a.bat`, `p2\a.exe`},
searchFor: `a`,
want: `p2\a.exe`,
},
{
name: "first directory containing a PATHEXT match",
PATHEXT: `.COM;.EXE;.BAT`,
files: []string{`p1\a.bat`, `p2\a.exe`},
searchFor: `a`,
want: `p1\a.bat`,
},
{
name: "first PATHEXT entry",
PATHEXT: `.COM;.EXE;.BAT`,
files: []string{`p1\a.bat`, `p1\a.exe`, `p2\a.bat`, `p2\a.exe`},
searchFor: `a`,
want: `p1\a.exe`,
},
{
name: "ignore dir with PATHEXT extension",
files: []string{`a.exe\`},
searchFor: `a`,
wantErr: exec.ErrNotFound,
},
{
name: "ignore empty PATH entry",
files: []string{`a.bat`, `p\a.bat`},
PATH: []string{`p`},
searchFor: `a`,
want: `p\a.bat`,
// If cmd.exe is too old it might not respect NoDefaultCurrentDirectoryInExePath,
// so skip that check.
skipCmdExeCheck: true,
},
{
name: "return ErrDot if found by a different absolute path",
files: []string{`p1\a.bat`, `p2\a.bat`},
PATH: []string{`.\p1`, `p2`},
searchFor: `a`,
want: `p1\a.bat`,
wantErr: exec.ErrDot,
},
{
name: "suppress ErrDot if also found in absolute path",
files: []string{`p1\a.bat`, `p2\a.bat`},
PATH: []string{`.\p1`, `p1`, `p2`},
searchFor: `a`,
want: `p1\a.bat`,
},
}
func TestLookPathWindows(t *testing.T) {
// Not parallel: uses Chdir and Setenv.
// We are using the "printpath" command mode to test exec.Command here,
// so we won't be calling helperCommand to resolve it.
// That may cause it to appear to be unused.
maySkipHelperCommand("printpath")
// Before we begin, find the absolute path to cmd.exe.
// In non-short mode, we will use it to check the ground truth
// of the test's "want" field.
cmdExe, err := exec.LookPath("cmd")
if err != nil {
t.Fatal(err)
}
for _, tt := range lookPathTests {
t.Run(tt.name, func(t *testing.T) {
if tt.want == "" && tt.wantErr == nil {
t.Fatalf("test must specify either want or wantErr")
}
root := t.TempDir()
installProgs(t, root, tt.files)
if tt.PATHEXT != "" {
t.Setenv("PATHEXT", tt.PATHEXT)
t.Logf("set PATHEXT=%s", tt.PATHEXT)
}
var pathVar string
if tt.PATH == nil {
paths := make([]string, 0, len(tt.files))
for _, f := range tt.files {
dir := filepath.Join(root, filepath.Dir(f))
if !slices.Contains(paths, dir) {
paths = append(paths, dir)
}
}
pathVar = strings.Join(paths, string(os.PathListSeparator))
} else {
pathVar = makePATH(root, tt.PATH)
}
t.Setenv("PATH", pathVar)
t.Logf("set PATH=%s", pathVar)
chdir(t, root)
if !testing.Short() && !(tt.skipCmdExeCheck || errors.Is(tt.wantErr, exec.ErrDot)) {
// Check that cmd.exe, which is our source of ground truth,
// agrees that our test case is correct.
cmd := testenv.Command(t, cmdExe, "/c", tt.searchFor, "printpath")
out, err := cmd.Output()
if err == nil {
gotAbs := strings.TrimSpace(string(out))
wantAbs := ""
if tt.want != "" {
wantAbs = filepath.Join(root, tt.want)
}
if gotAbs != wantAbs {
// cmd.exe disagrees. Probably the test case is wrong?
t.Fatalf("%v\n\tresolved to %s\n\twant %s", cmd, gotAbs, wantAbs)
}
} else if tt.wantErr == nil {
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
t.Fatalf("%v: %v\n%s", cmd, err, ee.Stderr)
}
t.Fatalf("%v: %v", cmd, err)
}
}
got, err := exec.LookPath(tt.searchFor)
if filepath.IsAbs(got) {
got, err = filepath.Rel(root, got)
if err != nil {
t.Fatal(err)
}
}
if got != tt.want {
t.Errorf("LookPath(%#q) = %#q; want %#q", tt.searchFor, got, tt.want)
}
if !errors.Is(err, tt.wantErr) {
t.Errorf("LookPath(%#q): %v; want %v", tt.searchFor, err, tt.wantErr)
}
})
}
}
type commandTest struct {
name string
PATH []string
files []string
dir string
arg0 string
want string
wantPath string // the resolved c.Path, if different from want
wantErrDot bool
wantRunErr error
}
var commandTests = []commandTest{
// testing commands with no slash, like `a.exe`
{
name: "current directory",
files: []string{`a.exe`},
PATH: []string{"."},
arg0: `a.exe`,
want: `a.exe`,
wantErrDot: true,
},
{
name: "with extra PATH",
files: []string{`a.exe`, `p\a.exe`, `p2\a.exe`},
PATH: []string{".", "p2", "p"},
arg0: `a.exe`,
want: `a.exe`,
wantErrDot: true,
},
{
name: "with extra PATH and no extension",
files: []string{`a.exe`, `p\a.exe`, `p2\a.exe`},
PATH: []string{".", "p2", "p"},
arg0: `a`,
want: `a.exe`,
wantErrDot: true,
},
// testing commands with slash, like `.\a.exe`
{
name: "with dir",
files: []string{`p\a.exe`},
PATH: []string{"."},
arg0: `p\a.exe`,
want: `p\a.exe`,
},
{
name: "with explicit dot",
files: []string{`p\a.exe`},
PATH: []string{"."},
arg0: `.\p\a.exe`,
want: `p\a.exe`,
},
{
name: "with irrelevant PATH",
files: []string{`p\a.exe`, `p2\a.exe`},
PATH: []string{".", "p2"},
arg0: `p\a.exe`,
want: `p\a.exe`,
},
{
name: "with slash and no extension",
files: []string{`p\a.exe`, `p2\a.exe`},
PATH: []string{".", "p2"},
arg0: `p\a`,
want: `p\a.exe`,
},
// tests commands, like `a.exe`, with c.Dir set
{
// should not find a.exe in p, because LookPath(`a.exe`) will fail when
// called by Command (before Dir is set), and that error is sticky.
name: "not found before Dir",
files: []string{`p\a.exe`},
PATH: []string{"."},
dir: `p`,
arg0: `a.exe`,
want: `p\a.exe`,
wantRunErr: exec.ErrNotFound,
},
{
// LookPath(`a.exe`) will resolve to `.\a.exe`, but prefixing that with
// dir `p\a.exe` will refer to a non-existent file
name: "resolved before Dir",
files: []string{`a.exe`, `p\not_important_file`},
PATH: []string{"."},
dir: `p`,
arg0: `a.exe`,
want: `a.exe`,
wantErrDot: true,
wantRunErr: fs.ErrNotExist,
},
{
// like above, but making test succeed by installing file
// in referred destination (so LookPath(`a.exe`) will still
// find `.\a.exe`, but we successfully execute `p\a.exe`)
name: "relative to Dir",
files: []string{`a.exe`, `p\a.exe`},
PATH: []string{"."},
dir: `p`,
arg0: `a.exe`,
want: `p\a.exe`,
wantErrDot: true,
},
{
// like above, but add PATH in attempt to break the test
name: "relative to Dir with extra PATH",
files: []string{`a.exe`, `p\a.exe`, `p2\a.exe`},
PATH: []string{".", "p2", "p"},
dir: `p`,
arg0: `a.exe`,
want: `p\a.exe`,
wantErrDot: true,
},
{
// like above, but use "a" instead of "a.exe" for command
name: "relative to Dir with extra PATH and no extension",
files: []string{`a.exe`, `p\a.exe`, `p2\a.exe`},
PATH: []string{".", "p2", "p"},
dir: `p`,
arg0: `a`,
want: `p\a.exe`,
wantErrDot: true,
},
{
// finds `a.exe` in the PATH regardless of Dir because Command resolves the
// full path (using LookPath) before Dir is set.
name: "from PATH with no match in Dir",
files: []string{`p\a.exe`, `p2\a.exe`},
PATH: []string{".", "p2", "p"},
dir: `p`,
arg0: `a.exe`,
want: `p2\a.exe`,
},
// tests commands, like `.\a.exe`, with c.Dir set
{
// should use dir when command is path, like ".\a.exe"
name: "relative to Dir with explicit dot",
files: []string{`p\a.exe`},
PATH: []string{"."},
dir: `p`,
arg0: `.\a.exe`,
want: `p\a.exe`,
},
{
// like above, but with PATH added in attempt to break it
name: "relative to Dir with dot and extra PATH",
files: []string{`p\a.exe`, `p2\a.exe`},
PATH: []string{".", "p2"},
dir: `p`,
arg0: `.\a.exe`,
want: `p\a.exe`,
},
{
// LookPath(".\a") will fail before Dir is set, and that error is sticky.
name: "relative to Dir with dot and extra PATH and no extension",
files: []string{`p\a.exe`, `p2\a.exe`},
PATH: []string{".", "p2"},
dir: `p`,
arg0: `.\a`,
want: `p\a.exe`,
},
{
// LookPath(".\a") will fail before Dir is set, and that error is sticky.
name: "relative to Dir with different extension",
files: []string{`a.exe`, `p\a.bat`},
PATH: []string{"."},
dir: `p`,
arg0: `.\a`,
want: `p\a.bat`,
},
}
func TestCommand(t *testing.T) {
// Not parallel: uses Chdir and Setenv.
// We are using the "printpath" command mode to test exec.Command here,
// so we won't be calling helperCommand to resolve it.
// That may cause it to appear to be unused.
maySkipHelperCommand("printpath")
for _, tt := range commandTests {
t.Run(tt.name, func(t *testing.T) {
if tt.PATH == nil {
t.Fatalf("test must specify PATH")
}
root := t.TempDir()
installProgs(t, root, tt.files)
pathVar := makePATH(root, tt.PATH)
t.Setenv("PATH", pathVar)
t.Logf("set PATH=%s", pathVar)
chdir(t, root)
cmd := exec.Command(tt.arg0, "printpath")
cmd.Dir = filepath.Join(root, tt.dir)
if tt.wantErrDot {
if errors.Is(cmd.Err, exec.ErrDot) {
cmd.Err = nil
} else {
t.Fatalf("cmd.Err = %v; want ErrDot", cmd.Err)
}
}
out, err := cmd.Output()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
t.Logf("%v: %v\n%s", cmd, err, ee.Stderr)
} else {
t.Logf("%v: %v", cmd, err)
}
if !errors.Is(err, tt.wantRunErr) {
t.Errorf("want %v", tt.wantRunErr)
}
return
}
got := strings.TrimSpace(string(out))
if filepath.IsAbs(got) {
got, err = filepath.Rel(root, got)
if err != nil {
t.Fatal(err)
}
}
if got != tt.want {
t.Errorf("\nran %#q\nwant %#q", got, tt.want)
}
gotPath := cmd.Path
wantPath := tt.wantPath
if wantPath == "" {
if strings.Contains(tt.arg0, `\`) {
wantPath = tt.arg0
} else if tt.wantErrDot {
wantPath = strings.TrimPrefix(tt.want, tt.dir+`\`)
} else {
wantPath = filepath.Join(root, tt.want)
}
}
if gotPath != wantPath {
t.Errorf("\ncmd.Path = %#q\nwant %#q", gotPath, wantPath)
}
})
}
}
func TestAbsCommandWithDoubledExtension(t *testing.T) {
t.Parallel()
// We expect that ".com" is always included in PATHEXT, but it may also be
// found in the import path of a Go package. If it is at the root of the
// import path, the resulting executable may be named like "example.com.exe".
//
// Since "example.com" looks like a proper executable name, it is probably ok
// for exec.Command to try to run it directly without re-resolving it.
// However, exec.LookPath should try a little harder to figure it out.
comPath := filepath.Join(t.TempDir(), "example.com")
batPath := comPath + ".bat"
installBat(t, batPath)
cmd := exec.Command(comPath)
out, err := cmd.CombinedOutput()
t.Logf("%v: %v\n%s", cmd, err, out)
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("Command(%#q).Run: %v\nwant fs.ErrNotExist", comPath, err)
}
resolved, err := exec.LookPath(comPath)
if err != nil || resolved != batPath {
t.Fatalf("LookPath(%#q) = %v, %v; want %#q, <nil>", comPath, resolved, err, batPath)
}
}