1// Copyright 2013 The Go Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style
3// license that can be found in the LICENSE file.
4
5// Use an external test to avoid os/exec -> internal/testenv -> os/exec
6// circular dependency.
7
8package exec_test
9
10import (
11	"errors"
12	"fmt"
13	"internal/testenv"
14	"io"
15	"io/fs"
16	"os"
17	"os/exec"
18	"path/filepath"
19	"slices"
20	"strings"
21	"testing"
22)
23
24func init() {
25	registerHelperCommand("printpath", cmdPrintPath)
26}
27
28func cmdPrintPath(args ...string) {
29	exe, err := os.Executable()
30	if err != nil {
31		fmt.Fprintf(os.Stderr, "Executable: %v\n", err)
32		os.Exit(1)
33	}
34	fmt.Println(exe)
35}
36
37// makePATH returns a PATH variable referring to the
38// given directories relative to a root directory.
39//
40// The empty string results in an empty entry.
41// Paths beginning with . are kept as relative entries.
42func makePATH(root string, dirs []string) string {
43	paths := make([]string, 0, len(dirs))
44	for _, d := range dirs {
45		switch {
46		case d == "":
47			paths = append(paths, "")
48		case d == "." || (len(d) >= 2 && d[0] == '.' && os.IsPathSeparator(d[1])):
49			paths = append(paths, filepath.Clean(d))
50		default:
51			paths = append(paths, filepath.Join(root, d))
52		}
53	}
54	return strings.Join(paths, string(os.PathListSeparator))
55}
56
57// installProgs creates executable files (or symlinks to executable files) at
58// multiple destination paths. It uses root as prefix for all destination files.
59func installProgs(t *testing.T, root string, files []string) {
60	for _, f := range files {
61		dstPath := filepath.Join(root, f)
62
63		dir := filepath.Dir(dstPath)
64		if err := os.MkdirAll(dir, 0755); err != nil {
65			t.Fatal(err)
66		}
67
68		if os.IsPathSeparator(f[len(f)-1]) {
69			continue // directory and PATH entry only.
70		}
71		if strings.EqualFold(filepath.Ext(f), ".bat") {
72			installBat(t, dstPath)
73		} else {
74			installExe(t, dstPath)
75		}
76	}
77}
78
79// installExe installs a copy of the test executable
80// at the given location, creating directories as needed.
81//
82// (We use a copy instead of just a symlink to ensure that os.Executable
83// always reports an unambiguous path, regardless of how it is implemented.)
84func installExe(t *testing.T, dstPath string) {
85	src, err := os.Open(exePath(t))
86	if err != nil {
87		t.Fatal(err)
88	}
89	defer src.Close()
90
91	dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o777)
92	if err != nil {
93		t.Fatal(err)
94	}
95	defer func() {
96		if err := dst.Close(); err != nil {
97			t.Fatal(err)
98		}
99	}()
100
101	_, err = io.Copy(dst, src)
102	if err != nil {
103		t.Fatal(err)
104	}
105}
106
107// installBat creates a batch file at dst that prints its own
108// path when run.
109func installBat(t *testing.T, dstPath string) {
110	dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o777)
111	if err != nil {
112		t.Fatal(err)
113	}
114	defer func() {
115		if err := dst.Close(); err != nil {
116			t.Fatal(err)
117		}
118	}()
119
120	if _, err := fmt.Fprintf(dst, "@echo %s\r\n", dstPath); err != nil {
121		t.Fatal(err)
122	}
123}
124
125type lookPathTest struct {
126	name            string
127	PATHEXT         string // empty to use default
128	files           []string
129	PATH            []string // if nil, use all parent directories from files
130	searchFor       string
131	want            string
132	wantErr         error
133	skipCmdExeCheck bool // if true, do not check want against the behavior of cmd.exe
134}
135
136var lookPathTests = []lookPathTest{
137	{
138		name:      "first match",
139		files:     []string{`p1\a.exe`, `p2\a.exe`, `p2\a`},
140		searchFor: `a`,
141		want:      `p1\a.exe`,
142	},
143	{
144		name:      "dirs with extensions",
145		files:     []string{`p1.dir\a`, `p2.dir\a.exe`},
146		searchFor: `a`,
147		want:      `p2.dir\a.exe`,
148	},
149	{
150		name:      "first with extension",
151		files:     []string{`p1\a.exe`, `p2\a.exe`},
152		searchFor: `a.exe`,
153		want:      `p1\a.exe`,
154	},
155	{
156		name:      "specific name",
157		files:     []string{`p1\a.exe`, `p2\b.exe`},
158		searchFor: `b`,
159		want:      `p2\b.exe`,
160	},
161	{
162		name:      "no extension",
163		files:     []string{`p1\b`, `p2\a`},
164		searchFor: `a`,
165		wantErr:   exec.ErrNotFound,
166	},
167	{
168		name:      "directory, no extension",
169		files:     []string{`p1\a.exe`, `p2\a.exe`},
170		searchFor: `p2\a`,
171		want:      `p2\a.exe`,
172	},
173	{
174		name:      "no match",
175		files:     []string{`p1\a.exe`, `p2\a.exe`},
176		searchFor: `b`,
177		wantErr:   exec.ErrNotFound,
178	},
179	{
180		name:      "no match with dir",
181		files:     []string{`p1\b.exe`, `p2\a.exe`},
182		searchFor: `p2\b`,
183		wantErr:   exec.ErrNotFound,
184	},
185	{
186		name:      "extensionless file in CWD ignored",
187		files:     []string{`a`, `p1\a.exe`, `p2\a.exe`},
188		searchFor: `a`,
189		want:      `p1\a.exe`,
190	},
191	{
192		name:      "extensionless file in PATH ignored",
193		files:     []string{`p1\a`, `p2\a.exe`},
194		searchFor: `a`,
195		want:      `p2\a.exe`,
196	},
197	{
198		name:      "specific extension",
199		files:     []string{`p1\a.exe`, `p2\a.bat`},
200		searchFor: `a.bat`,
201		want:      `p2\a.bat`,
202	},
203	{
204		name:      "mismatched extension",
205		files:     []string{`p1\a.exe`, `p2\a.exe`},
206		searchFor: `a.com`,
207		wantErr:   exec.ErrNotFound,
208	},
209	{
210		name:      "doubled extension",
211		files:     []string{`p1\a.exe.exe`},
212		searchFor: `a.exe`,
213		want:      `p1\a.exe.exe`,
214	},
215	{
216		name:      "extension not in PATHEXT",
217		PATHEXT:   `.COM;.BAT`,
218		files:     []string{`p1\a.exe`, `p2\a.exe`},
219		searchFor: `a.exe`,
220		want:      `p1\a.exe`,
221	},
222	{
223		name:      "first allowed by PATHEXT",
224		PATHEXT:   `.COM;.EXE`,
225		files:     []string{`p1\a.bat`, `p2\a.exe`},
226		searchFor: `a`,
227		want:      `p2\a.exe`,
228	},
229	{
230		name:      "first directory containing a PATHEXT match",
231		PATHEXT:   `.COM;.EXE;.BAT`,
232		files:     []string{`p1\a.bat`, `p2\a.exe`},
233		searchFor: `a`,
234		want:      `p1\a.bat`,
235	},
236	{
237		name:      "first PATHEXT entry",
238		PATHEXT:   `.COM;.EXE;.BAT`,
239		files:     []string{`p1\a.bat`, `p1\a.exe`, `p2\a.bat`, `p2\a.exe`},
240		searchFor: `a`,
241		want:      `p1\a.exe`,
242	},
243	{
244		name:      "ignore dir with PATHEXT extension",
245		files:     []string{`a.exe\`},
246		searchFor: `a`,
247		wantErr:   exec.ErrNotFound,
248	},
249	{
250		name:      "ignore empty PATH entry",
251		files:     []string{`a.bat`, `p\a.bat`},
252		PATH:      []string{`p`},
253		searchFor: `a`,
254		want:      `p\a.bat`,
255		// If cmd.exe is too old it might not respect NoDefaultCurrentDirectoryInExePath,
256		// so skip that check.
257		skipCmdExeCheck: true,
258	},
259	{
260		name:      "return ErrDot if found by a different absolute path",
261		files:     []string{`p1\a.bat`, `p2\a.bat`},
262		PATH:      []string{`.\p1`, `p2`},
263		searchFor: `a`,
264		want:      `p1\a.bat`,
265		wantErr:   exec.ErrDot,
266	},
267	{
268		name:      "suppress ErrDot if also found in absolute path",
269		files:     []string{`p1\a.bat`, `p2\a.bat`},
270		PATH:      []string{`.\p1`, `p1`, `p2`},
271		searchFor: `a`,
272		want:      `p1\a.bat`,
273	},
274}
275
276func TestLookPathWindows(t *testing.T) {
277	// Not parallel: uses Chdir and Setenv.
278
279	// We are using the "printpath" command mode to test exec.Command here,
280	// so we won't be calling helperCommand to resolve it.
281	// That may cause it to appear to be unused.
282	maySkipHelperCommand("printpath")
283
284	// Before we begin, find the absolute path to cmd.exe.
285	// In non-short mode, we will use it to check the ground truth
286	// of the test's "want" field.
287	cmdExe, err := exec.LookPath("cmd")
288	if err != nil {
289		t.Fatal(err)
290	}
291
292	for _, tt := range lookPathTests {
293		t.Run(tt.name, func(t *testing.T) {
294			if tt.want == "" && tt.wantErr == nil {
295				t.Fatalf("test must specify either want or wantErr")
296			}
297
298			root := t.TempDir()
299			installProgs(t, root, tt.files)
300
301			if tt.PATHEXT != "" {
302				t.Setenv("PATHEXT", tt.PATHEXT)
303				t.Logf("set PATHEXT=%s", tt.PATHEXT)
304			}
305
306			var pathVar string
307			if tt.PATH == nil {
308				paths := make([]string, 0, len(tt.files))
309				for _, f := range tt.files {
310					dir := filepath.Join(root, filepath.Dir(f))
311					if !slices.Contains(paths, dir) {
312						paths = append(paths, dir)
313					}
314				}
315				pathVar = strings.Join(paths, string(os.PathListSeparator))
316			} else {
317				pathVar = makePATH(root, tt.PATH)
318			}
319			t.Setenv("PATH", pathVar)
320			t.Logf("set PATH=%s", pathVar)
321
322			chdir(t, root)
323
324			if !testing.Short() && !(tt.skipCmdExeCheck || errors.Is(tt.wantErr, exec.ErrDot)) {
325				// Check that cmd.exe, which is our source of ground truth,
326				// agrees that our test case is correct.
327				cmd := testenv.Command(t, cmdExe, "/c", tt.searchFor, "printpath")
328				out, err := cmd.Output()
329				if err == nil {
330					gotAbs := strings.TrimSpace(string(out))
331					wantAbs := ""
332					if tt.want != "" {
333						wantAbs = filepath.Join(root, tt.want)
334					}
335					if gotAbs != wantAbs {
336						// cmd.exe disagrees. Probably the test case is wrong?
337						t.Fatalf("%v\n\tresolved to %s\n\twant %s", cmd, gotAbs, wantAbs)
338					}
339				} else if tt.wantErr == nil {
340					if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
341						t.Fatalf("%v: %v\n%s", cmd, err, ee.Stderr)
342					}
343					t.Fatalf("%v: %v", cmd, err)
344				}
345			}
346
347			got, err := exec.LookPath(tt.searchFor)
348			if filepath.IsAbs(got) {
349				got, err = filepath.Rel(root, got)
350				if err != nil {
351					t.Fatal(err)
352				}
353			}
354			if got != tt.want {
355				t.Errorf("LookPath(%#q) = %#q; want %#q", tt.searchFor, got, tt.want)
356			}
357			if !errors.Is(err, tt.wantErr) {
358				t.Errorf("LookPath(%#q): %v; want %v", tt.searchFor, err, tt.wantErr)
359			}
360		})
361	}
362}
363
364type commandTest struct {
365	name       string
366	PATH       []string
367	files      []string
368	dir        string
369	arg0       string
370	want       string
371	wantPath   string // the resolved c.Path, if different from want
372	wantErrDot bool
373	wantRunErr error
374}
375
376var commandTests = []commandTest{
377	// testing commands with no slash, like `a.exe`
378	{
379		name:       "current directory",
380		files:      []string{`a.exe`},
381		PATH:       []string{"."},
382		arg0:       `a.exe`,
383		want:       `a.exe`,
384		wantErrDot: true,
385	},
386	{
387		name:       "with extra PATH",
388		files:      []string{`a.exe`, `p\a.exe`, `p2\a.exe`},
389		PATH:       []string{".", "p2", "p"},
390		arg0:       `a.exe`,
391		want:       `a.exe`,
392		wantErrDot: true,
393	},
394	{
395		name:       "with extra PATH and no extension",
396		files:      []string{`a.exe`, `p\a.exe`, `p2\a.exe`},
397		PATH:       []string{".", "p2", "p"},
398		arg0:       `a`,
399		want:       `a.exe`,
400		wantErrDot: true,
401	},
402	// testing commands with slash, like `.\a.exe`
403	{
404		name:  "with dir",
405		files: []string{`p\a.exe`},
406		PATH:  []string{"."},
407		arg0:  `p\a.exe`,
408		want:  `p\a.exe`,
409	},
410	{
411		name:  "with explicit dot",
412		files: []string{`p\a.exe`},
413		PATH:  []string{"."},
414		arg0:  `.\p\a.exe`,
415		want:  `p\a.exe`,
416	},
417	{
418		name:  "with irrelevant PATH",
419		files: []string{`p\a.exe`, `p2\a.exe`},
420		PATH:  []string{".", "p2"},
421		arg0:  `p\a.exe`,
422		want:  `p\a.exe`,
423	},
424	{
425		name:  "with slash and no extension",
426		files: []string{`p\a.exe`, `p2\a.exe`},
427		PATH:  []string{".", "p2"},
428		arg0:  `p\a`,
429		want:  `p\a.exe`,
430	},
431	// tests commands, like `a.exe`, with c.Dir set
432	{
433		// should not find a.exe in p, because LookPath(`a.exe`) will fail when
434		// called by Command (before Dir is set), and that error is sticky.
435		name:       "not found before Dir",
436		files:      []string{`p\a.exe`},
437		PATH:       []string{"."},
438		dir:        `p`,
439		arg0:       `a.exe`,
440		want:       `p\a.exe`,
441		wantRunErr: exec.ErrNotFound,
442	},
443	{
444		// LookPath(`a.exe`) will resolve to `.\a.exe`, but prefixing that with
445		// dir `p\a.exe` will refer to a non-existent file
446		name:       "resolved before Dir",
447		files:      []string{`a.exe`, `p\not_important_file`},
448		PATH:       []string{"."},
449		dir:        `p`,
450		arg0:       `a.exe`,
451		want:       `a.exe`,
452		wantErrDot: true,
453		wantRunErr: fs.ErrNotExist,
454	},
455	{
456		// like above, but making test succeed by installing file
457		// in referred destination (so LookPath(`a.exe`) will still
458		// find `.\a.exe`, but we successfully execute `p\a.exe`)
459		name:       "relative to Dir",
460		files:      []string{`a.exe`, `p\a.exe`},
461		PATH:       []string{"."},
462		dir:        `p`,
463		arg0:       `a.exe`,
464		want:       `p\a.exe`,
465		wantErrDot: true,
466	},
467	{
468		// like above, but add PATH in attempt to break the test
469		name:       "relative to Dir with extra PATH",
470		files:      []string{`a.exe`, `p\a.exe`, `p2\a.exe`},
471		PATH:       []string{".", "p2", "p"},
472		dir:        `p`,
473		arg0:       `a.exe`,
474		want:       `p\a.exe`,
475		wantErrDot: true,
476	},
477	{
478		// like above, but use "a" instead of "a.exe" for command
479		name:       "relative to Dir with extra PATH and no extension",
480		files:      []string{`a.exe`, `p\a.exe`, `p2\a.exe`},
481		PATH:       []string{".", "p2", "p"},
482		dir:        `p`,
483		arg0:       `a`,
484		want:       `p\a.exe`,
485		wantErrDot: true,
486	},
487	{
488		// finds `a.exe` in the PATH regardless of Dir because Command resolves the
489		// full path (using LookPath) before Dir is set.
490		name:  "from PATH with no match in Dir",
491		files: []string{`p\a.exe`, `p2\a.exe`},
492		PATH:  []string{".", "p2", "p"},
493		dir:   `p`,
494		arg0:  `a.exe`,
495		want:  `p2\a.exe`,
496	},
497	// tests commands, like `.\a.exe`, with c.Dir set
498	{
499		// should use dir when command is path, like ".\a.exe"
500		name:  "relative to Dir with explicit dot",
501		files: []string{`p\a.exe`},
502		PATH:  []string{"."},
503		dir:   `p`,
504		arg0:  `.\a.exe`,
505		want:  `p\a.exe`,
506	},
507	{
508		// like above, but with PATH added in attempt to break it
509		name:  "relative to Dir with dot and extra PATH",
510		files: []string{`p\a.exe`, `p2\a.exe`},
511		PATH:  []string{".", "p2"},
512		dir:   `p`,
513		arg0:  `.\a.exe`,
514		want:  `p\a.exe`,
515	},
516	{
517		// LookPath(".\a") will fail before Dir is set, and that error is sticky.
518		name:  "relative to Dir with dot and extra PATH and no extension",
519		files: []string{`p\a.exe`, `p2\a.exe`},
520		PATH:  []string{".", "p2"},
521		dir:   `p`,
522		arg0:  `.\a`,
523		want:  `p\a.exe`,
524	},
525	{
526		// LookPath(".\a") will fail before Dir is set, and that error is sticky.
527		name:  "relative to Dir with different extension",
528		files: []string{`a.exe`, `p\a.bat`},
529		PATH:  []string{"."},
530		dir:   `p`,
531		arg0:  `.\a`,
532		want:  `p\a.bat`,
533	},
534}
535
536func TestCommand(t *testing.T) {
537	// Not parallel: uses Chdir and Setenv.
538
539	// We are using the "printpath" command mode to test exec.Command here,
540	// so we won't be calling helperCommand to resolve it.
541	// That may cause it to appear to be unused.
542	maySkipHelperCommand("printpath")
543
544	for _, tt := range commandTests {
545		t.Run(tt.name, func(t *testing.T) {
546			if tt.PATH == nil {
547				t.Fatalf("test must specify PATH")
548			}
549
550			root := t.TempDir()
551			installProgs(t, root, tt.files)
552
553			pathVar := makePATH(root, tt.PATH)
554			t.Setenv("PATH", pathVar)
555			t.Logf("set PATH=%s", pathVar)
556
557			chdir(t, root)
558
559			cmd := exec.Command(tt.arg0, "printpath")
560			cmd.Dir = filepath.Join(root, tt.dir)
561			if tt.wantErrDot {
562				if errors.Is(cmd.Err, exec.ErrDot) {
563					cmd.Err = nil
564				} else {
565					t.Fatalf("cmd.Err = %v; want ErrDot", cmd.Err)
566				}
567			}
568
569			out, err := cmd.Output()
570			if err != nil {
571				if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
572					t.Logf("%v: %v\n%s", cmd, err, ee.Stderr)
573				} else {
574					t.Logf("%v: %v", cmd, err)
575				}
576				if !errors.Is(err, tt.wantRunErr) {
577					t.Errorf("want %v", tt.wantRunErr)
578				}
579				return
580			}
581
582			got := strings.TrimSpace(string(out))
583			if filepath.IsAbs(got) {
584				got, err = filepath.Rel(root, got)
585				if err != nil {
586					t.Fatal(err)
587				}
588			}
589			if got != tt.want {
590				t.Errorf("\nran  %#q\nwant %#q", got, tt.want)
591			}
592
593			gotPath := cmd.Path
594			wantPath := tt.wantPath
595			if wantPath == "" {
596				if strings.Contains(tt.arg0, `\`) {
597					wantPath = tt.arg0
598				} else if tt.wantErrDot {
599					wantPath = strings.TrimPrefix(tt.want, tt.dir+`\`)
600				} else {
601					wantPath = filepath.Join(root, tt.want)
602				}
603			}
604			if gotPath != wantPath {
605				t.Errorf("\ncmd.Path = %#q\nwant       %#q", gotPath, wantPath)
606			}
607		})
608	}
609}
610
611func TestAbsCommandWithDoubledExtension(t *testing.T) {
612	t.Parallel()
613
614	// We expect that ".com" is always included in PATHEXT, but it may also be
615	// found in the import path of a Go package. If it is at the root of the
616	// import path, the resulting executable may be named like "example.com.exe".
617	//
618	// Since "example.com" looks like a proper executable name, it is probably ok
619	// for exec.Command to try to run it directly without re-resolving it.
620	// However, exec.LookPath should try a little harder to figure it out.
621
622	comPath := filepath.Join(t.TempDir(), "example.com")
623	batPath := comPath + ".bat"
624	installBat(t, batPath)
625
626	cmd := exec.Command(comPath)
627	out, err := cmd.CombinedOutput()
628	t.Logf("%v: %v\n%s", cmd, err, out)
629	if !errors.Is(err, fs.ErrNotExist) {
630		t.Errorf("Command(%#q).Run: %v\nwant fs.ErrNotExist", comPath, err)
631	}
632
633	resolved, err := exec.LookPath(comPath)
634	if err != nil || resolved != batPath {
635		t.Fatalf("LookPath(%#q) = %v, %v; want %#q, <nil>", comPath, resolved, err, batPath)
636	}
637}
638