// 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(_ ...string) {
	fmt.Println(testenv.Executable(nil))
}

// 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(testenv.Executable(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)

			t.Chdir(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)

			t.Chdir(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)
	}
}
