// 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, ", comPath, resolved, err, batPath) } }