summaryrefslogtreecommitdiffstats
path: root/src/cmd/go/internal/script
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-16 19:19:13 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-16 19:19:13 +0000
commitccd992355df7192993c666236047820244914598 (patch)
treef00fea65147227b7743083c6148396f74cd66935 /src/cmd/go/internal/script
parentInitial commit. (diff)
downloadgolang-1.21-ccd992355df7192993c666236047820244914598.tar.xz
golang-1.21-ccd992355df7192993c666236047820244914598.zip
Adding upstream version 1.21.8.upstream/1.21.8
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'src/cmd/go/internal/script')
-rw-r--r--src/cmd/go/internal/script/cmds.go1125
-rw-r--r--src/cmd/go/internal/script/cmds_other.go11
-rw-r--r--src/cmd/go/internal/script/cmds_posix.go16
-rw-r--r--src/cmd/go/internal/script/conds.go205
-rw-r--r--src/cmd/go/internal/script/engine.go788
-rw-r--r--src/cmd/go/internal/script/errors.go64
-rw-r--r--src/cmd/go/internal/script/scripttest/scripttest.go143
-rw-r--r--src/cmd/go/internal/script/state.go236
8 files changed, 2588 insertions, 0 deletions
diff --git a/src/cmd/go/internal/script/cmds.go b/src/cmd/go/internal/script/cmds.go
new file mode 100644
index 0000000..36e16c5
--- /dev/null
+++ b/src/cmd/go/internal/script/cmds.go
@@ -0,0 +1,1125 @@
+// Copyright 2022 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 script
+
+import (
+ "cmd/go/internal/robustio"
+ "errors"
+ "fmt"
+ "internal/diff"
+ "io/fs"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+// DefaultCmds returns a set of broadly useful script commands.
+//
+// Run the 'help' command within a script engine to view a list of the available
+// commands.
+func DefaultCmds() map[string]Cmd {
+ return map[string]Cmd{
+ "cat": Cat(),
+ "cd": Cd(),
+ "chmod": Chmod(),
+ "cmp": Cmp(),
+ "cmpenv": Cmpenv(),
+ "cp": Cp(),
+ "echo": Echo(),
+ "env": Env(),
+ "exec": Exec(func(cmd *exec.Cmd) error { return cmd.Process.Signal(os.Interrupt) }, 100*time.Millisecond), // arbitrary grace period
+ "exists": Exists(),
+ "grep": Grep(),
+ "help": Help(),
+ "mkdir": Mkdir(),
+ "mv": Mv(),
+ "rm": Rm(),
+ "replace": Replace(),
+ "sleep": Sleep(),
+ "stderr": Stderr(),
+ "stdout": Stdout(),
+ "stop": Stop(),
+ "symlink": Symlink(),
+ "wait": Wait(),
+ }
+}
+
+// Command returns a new Cmd with a Usage method that returns a copy of the
+// given CmdUsage and a Run method calls the given function.
+func Command(usage CmdUsage, run func(*State, ...string) (WaitFunc, error)) Cmd {
+ return &funcCmd{
+ usage: usage,
+ run: run,
+ }
+}
+
+// A funcCmd implements Cmd using a function value.
+type funcCmd struct {
+ usage CmdUsage
+ run func(*State, ...string) (WaitFunc, error)
+}
+
+func (c *funcCmd) Run(s *State, args ...string) (WaitFunc, error) {
+ return c.run(s, args...)
+}
+
+func (c *funcCmd) Usage() *CmdUsage { return &c.usage }
+
+// firstNonFlag returns a slice containing the index of the first argument in
+// rawArgs that is not a flag, or nil if all arguments are flags.
+func firstNonFlag(rawArgs ...string) []int {
+ for i, arg := range rawArgs {
+ if !strings.HasPrefix(arg, "-") {
+ return []int{i}
+ }
+ if arg == "--" {
+ return []int{i + 1}
+ }
+ }
+ return nil
+}
+
+// Cat writes the concatenated contents of the named file(s) to the script's
+// stdout buffer.
+func Cat() Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "concatenate files and print to the script's stdout buffer",
+ Args: "files...",
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ if len(args) == 0 {
+ return nil, ErrUsage
+ }
+
+ paths := make([]string, 0, len(args))
+ for _, arg := range args {
+ paths = append(paths, s.Path(arg))
+ }
+
+ var buf strings.Builder
+ errc := make(chan error, 1)
+ go func() {
+ for _, p := range paths {
+ b, err := os.ReadFile(p)
+ buf.Write(b)
+ if err != nil {
+ errc <- err
+ return
+ }
+ }
+ errc <- nil
+ }()
+
+ wait := func(*State) (stdout, stderr string, err error) {
+ err = <-errc
+ return buf.String(), "", err
+ }
+ return wait, nil
+ })
+}
+
+// Cd changes the current working directory.
+func Cd() Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "change the working directory",
+ Args: "dir",
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ if len(args) != 1 {
+ return nil, ErrUsage
+ }
+ return nil, s.Chdir(args[0])
+ })
+}
+
+// Chmod changes the permissions of a file or a directory..
+func Chmod() Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "change file mode bits",
+ Args: "perm paths...",
+ Detail: []string{
+ "Changes the permissions of the named files or directories to be equal to perm.",
+ "Only numerical permissions are supported.",
+ },
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ if len(args) < 2 {
+ return nil, ErrUsage
+ }
+
+ perm, err := strconv.ParseUint(args[0], 0, 32)
+ if err != nil || perm&uint64(fs.ModePerm) != perm {
+ return nil, fmt.Errorf("invalid mode: %s", args[0])
+ }
+
+ for _, arg := range args[1:] {
+ err := os.Chmod(s.Path(arg), fs.FileMode(perm))
+ if err != nil {
+ return nil, err
+ }
+ }
+ return nil, nil
+ })
+}
+
+// Cmp compares the contents of two files, or the contents of either the
+// "stdout" or "stderr" buffer and a file, returning a non-nil error if the
+// contents differ.
+func Cmp() Cmd {
+ return Command(
+ CmdUsage{
+ Args: "[-q] file1 file2",
+ Summary: "compare files for differences",
+ Detail: []string{
+ "By convention, file1 is the actual data and file2 is the expected data.",
+ "The command succeeds if the file contents are identical.",
+ "File1 can be 'stdout' or 'stderr' to compare the stdout or stderr buffer from the most recent command.",
+ },
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ return nil, doCompare(s, false, args...)
+ })
+}
+
+// Cmpenv is like Compare, but also performs environment substitutions
+// on the contents of both arguments.
+func Cmpenv() Cmd {
+ return Command(
+ CmdUsage{
+ Args: "[-q] file1 file2",
+ Summary: "compare files for differences, with environment expansion",
+ Detail: []string{
+ "By convention, file1 is the actual data and file2 is the expected data.",
+ "The command succeeds if the file contents are identical after substituting variables from the script environment.",
+ "File1 can be 'stdout' or 'stderr' to compare the script's stdout or stderr buffer.",
+ },
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ return nil, doCompare(s, true, args...)
+ })
+}
+
+func doCompare(s *State, env bool, args ...string) error {
+ quiet := false
+ if len(args) > 0 && args[0] == "-q" {
+ quiet = true
+ args = args[1:]
+ }
+ if len(args) != 2 {
+ return ErrUsage
+ }
+
+ name1, name2 := args[0], args[1]
+ var text1, text2 string
+ switch name1 {
+ case "stdout":
+ text1 = s.Stdout()
+ case "stderr":
+ text1 = s.Stderr()
+ default:
+ data, err := os.ReadFile(s.Path(name1))
+ if err != nil {
+ return err
+ }
+ text1 = string(data)
+ }
+
+ data, err := os.ReadFile(s.Path(name2))
+ if err != nil {
+ return err
+ }
+ text2 = string(data)
+
+ if env {
+ text1 = s.ExpandEnv(text1, false)
+ text2 = s.ExpandEnv(text2, false)
+ }
+
+ if text1 != text2 {
+ if !quiet {
+ diffText := diff.Diff(name1, []byte(text1), name2, []byte(text2))
+ s.Logf("%s\n", diffText)
+ }
+ return fmt.Errorf("%s and %s differ", name1, name2)
+ }
+ return nil
+}
+
+// Cp copies one or more files to a new location.
+func Cp() Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "copy files to a target file or directory",
+ Args: "src... dst",
+ Detail: []string{
+ "src can include 'stdout' or 'stderr' to copy from the script's stdout or stderr buffer.",
+ },
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ if len(args) < 2 {
+ return nil, ErrUsage
+ }
+
+ dst := s.Path(args[len(args)-1])
+ info, err := os.Stat(dst)
+ dstDir := err == nil && info.IsDir()
+ if len(args) > 2 && !dstDir {
+ return nil, &fs.PathError{Op: "cp", Path: dst, Err: errors.New("destination is not a directory")}
+ }
+
+ for _, arg := range args[:len(args)-1] {
+ var (
+ src string
+ data []byte
+ mode fs.FileMode
+ )
+ switch arg {
+ case "stdout":
+ src = arg
+ data = []byte(s.Stdout())
+ mode = 0666
+ case "stderr":
+ src = arg
+ data = []byte(s.Stderr())
+ mode = 0666
+ default:
+ src = s.Path(arg)
+ info, err := os.Stat(src)
+ if err != nil {
+ return nil, err
+ }
+ mode = info.Mode() & 0777
+ data, err = os.ReadFile(src)
+ if err != nil {
+ return nil, err
+ }
+ }
+ targ := dst
+ if dstDir {
+ targ = filepath.Join(dst, filepath.Base(src))
+ }
+ err := os.WriteFile(targ, data, mode)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return nil, nil
+ })
+}
+
+// Echo writes its arguments to stdout, followed by a newline.
+func Echo() Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "display a line of text",
+ Args: "string...",
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ var buf strings.Builder
+ for i, arg := range args {
+ if i > 0 {
+ buf.WriteString(" ")
+ }
+ buf.WriteString(arg)
+ }
+ buf.WriteString("\n")
+ out := buf.String()
+
+ // Stuff the result into a callback to satisfy the OutputCommandFunc
+ // interface, even though it isn't really asynchronous even if run in the
+ // background.
+ //
+ // Nobody should be running 'echo' as a background command, but it's not worth
+ // defining yet another interface, and also doesn't seem worth shoehorning
+ // into a SimpleCommand the way we did with Wait.
+ return func(*State) (stdout, stderr string, err error) {
+ return out, "", nil
+ }, nil
+ })
+}
+
+// Env sets or logs the values of environment variables.
+//
+// With no arguments, Env reports all variables in the environment.
+// "key=value" arguments set variables, and arguments without "="
+// cause the corresponding value to be printed to the stdout buffer.
+func Env() Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "set or log the values of environment variables",
+ Args: "[key[=value]...]",
+ Detail: []string{
+ "With no arguments, print the script environment to the log.",
+ "Otherwise, add the listed key=value pairs to the environment or print the listed keys.",
+ },
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ out := new(strings.Builder)
+ if len(args) == 0 {
+ for _, kv := range s.env {
+ fmt.Fprintf(out, "%s\n", kv)
+ }
+ } else {
+ for _, env := range args {
+ i := strings.Index(env, "=")
+ if i < 0 {
+ // Display value instead of setting it.
+ fmt.Fprintf(out, "%s=%s\n", env, s.envMap[env])
+ continue
+ }
+ if err := s.Setenv(env[:i], env[i+1:]); err != nil {
+ return nil, err
+ }
+ }
+ }
+ var wait WaitFunc
+ if out.Len() > 0 || len(args) == 0 {
+ wait = func(*State) (stdout, stderr string, err error) {
+ return out.String(), "", nil
+ }
+ }
+ return wait, nil
+ })
+}
+
+// Exec runs an arbitrary executable as a subprocess.
+//
+// When the Script's context is canceled, Exec sends the interrupt signal, then
+// waits for up to the given delay for the subprocess to flush output before
+// terminating it with os.Kill.
+func Exec(cancel func(*exec.Cmd) error, waitDelay time.Duration) Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "run an executable program with arguments",
+ Args: "program [args...]",
+ Detail: []string{
+ "Note that 'exec' does not terminate the script (unlike Unix shells).",
+ },
+ Async: true,
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ if len(args) < 1 {
+ return nil, ErrUsage
+ }
+
+ // Use the script's PATH to look up the command (if it does not contain a separator)
+ // instead of the test process's PATH (see lookPath).
+ // Don't use filepath.Clean, since that changes "./foo" to "foo".
+ name := filepath.FromSlash(args[0])
+ path := name
+ if !strings.Contains(name, string(filepath.Separator)) {
+ var err error
+ path, err = lookPath(s, name)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return startCommand(s, name, path, args[1:], cancel, waitDelay)
+ })
+}
+
+func startCommand(s *State, name, path string, args []string, cancel func(*exec.Cmd) error, waitDelay time.Duration) (WaitFunc, error) {
+ var (
+ cmd *exec.Cmd
+ stdoutBuf, stderrBuf strings.Builder
+ )
+ for {
+ cmd = exec.CommandContext(s.Context(), path, args...)
+ if cancel == nil {
+ cmd.Cancel = nil
+ } else {
+ cmd.Cancel = func() error { return cancel(cmd) }
+ }
+ cmd.WaitDelay = waitDelay
+ cmd.Args[0] = name
+ cmd.Dir = s.Getwd()
+ cmd.Env = s.env
+ cmd.Stdout = &stdoutBuf
+ cmd.Stderr = &stderrBuf
+ err := cmd.Start()
+ if err == nil {
+ break
+ }
+ if isETXTBSY(err) {
+ // If the script (or its host process) just wrote the executable we're
+ // trying to run, a fork+exec in another thread may be holding open the FD
+ // that we used to write the executable (see https://go.dev/issue/22315).
+ // Since the descriptor should have CLOEXEC set, the problem should
+ // resolve as soon as the forked child reaches its exec call.
+ // Keep retrying until that happens.
+ } else {
+ return nil, err
+ }
+ }
+
+ wait := func(s *State) (stdout, stderr string, err error) {
+ err = cmd.Wait()
+ return stdoutBuf.String(), stderrBuf.String(), err
+ }
+ return wait, nil
+}
+
+// lookPath is (roughly) like exec.LookPath, but it uses the script's current
+// PATH to find the executable.
+func lookPath(s *State, command string) (string, error) {
+ var strEqual func(string, string) bool
+ if runtime.GOOS == "windows" || runtime.GOOS == "darwin" {
+ // Using GOOS as a proxy for case-insensitive file system.
+ // TODO(bcmills): Remove this assumption.
+ strEqual = strings.EqualFold
+ } else {
+ strEqual = func(a, b string) bool { return a == b }
+ }
+
+ var pathExt []string
+ var searchExt bool
+ var isExecutable func(os.FileInfo) bool
+ if runtime.GOOS == "windows" {
+ // Use the test process's PathExt instead of the script's.
+ // If PathExt is set in the command's environment, cmd.Start fails with
+ // "parameter is invalid". Not sure why.
+ // If the command already has an extension in PathExt (like "cmd.exe")
+ // don't search for other extensions (not "cmd.bat.exe").
+ pathExt = strings.Split(os.Getenv("PathExt"), string(filepath.ListSeparator))
+ searchExt = true
+ cmdExt := filepath.Ext(command)
+ for _, ext := range pathExt {
+ if strEqual(cmdExt, ext) {
+ searchExt = false
+ break
+ }
+ }
+ isExecutable = func(fi os.FileInfo) bool {
+ return fi.Mode().IsRegular()
+ }
+ } else {
+ isExecutable = func(fi os.FileInfo) bool {
+ return fi.Mode().IsRegular() && fi.Mode().Perm()&0111 != 0
+ }
+ }
+
+ pathEnv, _ := s.LookupEnv(pathEnvName())
+ for _, dir := range strings.Split(pathEnv, string(filepath.ListSeparator)) {
+ if dir == "" {
+ continue
+ }
+
+ // Determine whether dir needs a trailing path separator.
+ // Note: we avoid filepath.Join in this function because it cleans the
+ // result: we want to preserve the exact dir prefix from the environment.
+ sep := string(filepath.Separator)
+ if os.IsPathSeparator(dir[len(dir)-1]) {
+ sep = ""
+ }
+
+ if searchExt {
+ ents, err := os.ReadDir(dir)
+ if err != nil {
+ continue
+ }
+ for _, ent := range ents {
+ for _, ext := range pathExt {
+ if !ent.IsDir() && strEqual(ent.Name(), command+ext) {
+ return dir + sep + ent.Name(), nil
+ }
+ }
+ }
+ } else {
+ path := dir + sep + command
+ if fi, err := os.Stat(path); err == nil && isExecutable(fi) {
+ return path, nil
+ }
+ }
+ }
+ return "", &exec.Error{Name: command, Err: exec.ErrNotFound}
+}
+
+// pathEnvName returns the platform-specific variable used by os/exec.LookPath
+// to look up executable names (either "PATH" or "path").
+//
+// TODO(bcmills): Investigate whether we can instead use PATH uniformly and
+// rewrite it to $path when executing subprocesses.
+func pathEnvName() string {
+ switch runtime.GOOS {
+ case "plan9":
+ return "path"
+ default:
+ return "PATH"
+ }
+}
+
+// Exists checks that the named file(s) exist.
+func Exists() Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "check that files exist",
+ Args: "[-readonly] [-exec] file...",
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ var readonly, exec bool
+ loop:
+ for len(args) > 0 {
+ switch args[0] {
+ case "-readonly":
+ readonly = true
+ args = args[1:]
+ case "-exec":
+ exec = true
+ args = args[1:]
+ default:
+ break loop
+ }
+ }
+ if len(args) == 0 {
+ return nil, ErrUsage
+ }
+
+ for _, file := range args {
+ file = s.Path(file)
+ info, err := os.Stat(file)
+ if err != nil {
+ return nil, err
+ }
+ if readonly && info.Mode()&0222 != 0 {
+ return nil, fmt.Errorf("%s exists but is writable", file)
+ }
+ if exec && runtime.GOOS != "windows" && info.Mode()&0111 == 0 {
+ return nil, fmt.Errorf("%s exists but is not executable", file)
+ }
+ }
+
+ return nil, nil
+ })
+}
+
+// Grep checks that file content matches a regexp.
+// Like stdout/stderr and unlike Unix grep, it accepts Go regexp syntax.
+//
+// Grep does not modify the State's stdout or stderr buffers.
+// (Its output goes to the script log, not stdout.)
+func Grep() Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "find lines in a file that match a pattern",
+ Args: matchUsage + " file",
+ Detail: []string{
+ "The command succeeds if at least one match (or the exact count, if given) is found.",
+ "The -q flag suppresses printing of matches.",
+ },
+ RegexpArgs: firstNonFlag,
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ return nil, match(s, args, "", "grep")
+ })
+}
+
+const matchUsage = "[-count=N] [-q] 'pattern'"
+
+// match implements the Grep, Stdout, and Stderr commands.
+func match(s *State, args []string, text, name string) error {
+ n := 0
+ if len(args) >= 1 && strings.HasPrefix(args[0], "-count=") {
+ var err error
+ n, err = strconv.Atoi(args[0][len("-count="):])
+ if err != nil {
+ return fmt.Errorf("bad -count=: %v", err)
+ }
+ if n < 1 {
+ return fmt.Errorf("bad -count=: must be at least 1")
+ }
+ args = args[1:]
+ }
+ quiet := false
+ if len(args) >= 1 && args[0] == "-q" {
+ quiet = true
+ args = args[1:]
+ }
+
+ isGrep := name == "grep"
+
+ wantArgs := 1
+ if isGrep {
+ wantArgs = 2
+ }
+ if len(args) != wantArgs {
+ return ErrUsage
+ }
+
+ pattern := `(?m)` + args[0]
+ re, err := regexp.Compile(pattern)
+ if err != nil {
+ return err
+ }
+
+ if isGrep {
+ name = args[1] // for error messages
+ data, err := os.ReadFile(s.Path(args[1]))
+ if err != nil {
+ return err
+ }
+ text = string(data)
+ }
+
+ if n > 0 {
+ count := len(re.FindAllString(text, -1))
+ if count != n {
+ return fmt.Errorf("found %d matches for %#q in %s", count, pattern, name)
+ }
+ return nil
+ }
+
+ if !re.MatchString(text) {
+ return fmt.Errorf("no match for %#q in %s", pattern, name)
+ }
+
+ if !quiet {
+ // Print the lines containing the match.
+ loc := re.FindStringIndex(text)
+ for loc[0] > 0 && text[loc[0]-1] != '\n' {
+ loc[0]--
+ }
+ for loc[1] < len(text) && text[loc[1]] != '\n' {
+ loc[1]++
+ }
+ lines := strings.TrimSuffix(text[loc[0]:loc[1]], "\n")
+ s.Logf("matched: %s\n", lines)
+ }
+ return nil
+}
+
+// Help writes command documentation to the script log.
+func Help() Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "log help text for commands and conditions",
+ Args: "[-v] name...",
+ Detail: []string{
+ "To display help for a specific condition, enclose it in brackets: 'help [amd64]'.",
+ "To display complete documentation when listing all commands, pass the -v flag.",
+ },
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ if s.engine == nil {
+ return nil, errors.New("no engine configured")
+ }
+
+ verbose := false
+ if len(args) > 0 {
+ verbose = true
+ if args[0] == "-v" {
+ args = args[1:]
+ }
+ }
+
+ var cmds, conds []string
+ for _, arg := range args {
+ if strings.HasPrefix(arg, "[") && strings.HasSuffix(arg, "]") {
+ conds = append(conds, arg[1:len(arg)-1])
+ } else {
+ cmds = append(cmds, arg)
+ }
+ }
+
+ out := new(strings.Builder)
+
+ if len(conds) > 0 || (len(args) == 0 && len(s.engine.Conds) > 0) {
+ if conds == nil {
+ out.WriteString("conditions:\n\n")
+ }
+ s.engine.ListConds(out, s, conds...)
+ }
+
+ if len(cmds) > 0 || len(args) == 0 {
+ if len(args) == 0 {
+ out.WriteString("\ncommands:\n\n")
+ }
+ s.engine.ListCmds(out, verbose, cmds...)
+ }
+
+ wait := func(*State) (stdout, stderr string, err error) {
+ return out.String(), "", nil
+ }
+ return wait, nil
+ })
+}
+
+// Mkdir creates a directory and any needed parent directories.
+func Mkdir() Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "create directories, if they do not already exist",
+ Args: "path...",
+ Detail: []string{
+ "Unlike Unix mkdir, parent directories are always created if needed.",
+ },
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ if len(args) < 1 {
+ return nil, ErrUsage
+ }
+ for _, arg := range args {
+ if err := os.MkdirAll(s.Path(arg), 0777); err != nil {
+ return nil, err
+ }
+ }
+ return nil, nil
+ })
+}
+
+// Mv renames an existing file or directory to a new path.
+func Mv() Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "rename a file or directory to a new path",
+ Args: "old new",
+ Detail: []string{
+ "OS-specific restrictions may apply when old and new are in different directories.",
+ },
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ if len(args) != 2 {
+ return nil, ErrUsage
+ }
+ return nil, os.Rename(s.Path(args[0]), s.Path(args[1]))
+ })
+}
+
+// Program returns a new command that runs the named program, found from the
+// host process's PATH (not looked up in the script's PATH).
+func Program(name string, cancel func(*exec.Cmd) error, waitDelay time.Duration) Cmd {
+ var (
+ shortName string
+ summary string
+ lookPathOnce sync.Once
+ path string
+ pathErr error
+ )
+ if filepath.IsAbs(name) {
+ lookPathOnce.Do(func() { path = filepath.Clean(name) })
+ shortName = strings.TrimSuffix(filepath.Base(path), ".exe")
+ summary = "run the '" + shortName + "' program provided by the script host"
+ } else {
+ shortName = name
+ summary = "run the '" + shortName + "' program from the script host's PATH"
+ }
+
+ return Command(
+ CmdUsage{
+ Summary: summary,
+ Args: "[args...]",
+ Async: true,
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ lookPathOnce.Do(func() {
+ path, pathErr = exec.LookPath(name)
+ })
+ if pathErr != nil {
+ return nil, pathErr
+ }
+ return startCommand(s, shortName, path, args, cancel, waitDelay)
+ })
+}
+
+// Replace replaces all occurrences of a string in a file with another string.
+func Replace() Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "replace strings in a file",
+ Args: "[old new]... file",
+ Detail: []string{
+ "The 'old' and 'new' arguments are unquoted as if in quoted Go strings.",
+ },
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ if len(args)%2 != 1 {
+ return nil, ErrUsage
+ }
+
+ oldNew := make([]string, 0, len(args)-1)
+ for _, arg := range args[:len(args)-1] {
+ s, err := strconv.Unquote(`"` + arg + `"`)
+ if err != nil {
+ return nil, err
+ }
+ oldNew = append(oldNew, s)
+ }
+
+ r := strings.NewReplacer(oldNew...)
+ file := s.Path(args[len(args)-1])
+
+ data, err := os.ReadFile(file)
+ if err != nil {
+ return nil, err
+ }
+ replaced := r.Replace(string(data))
+
+ return nil, os.WriteFile(file, []byte(replaced), 0666)
+ })
+}
+
+// Rm removes a file or directory.
+//
+// If a directory, Rm also recursively removes that directory's
+// contents.
+func Rm() Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "remove a file or directory",
+ Args: "path...",
+ Detail: []string{
+ "If the path is a directory, its contents are removed recursively.",
+ },
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ if len(args) < 1 {
+ return nil, ErrUsage
+ }
+ for _, arg := range args {
+ if err := removeAll(s.Path(arg)); err != nil {
+ return nil, err
+ }
+ }
+ return nil, nil
+ })
+}
+
+// removeAll removes dir and all files and directories it contains.
+//
+// Unlike os.RemoveAll, removeAll attempts to make the directories writable if
+// needed in order to remove their contents.
+func removeAll(dir string) error {
+ // module cache has 0444 directories;
+ // make them writable in order to remove content.
+ filepath.WalkDir(dir, func(path string, info fs.DirEntry, err error) error {
+ // chmod not only directories, but also things that we couldn't even stat
+ // due to permission errors: they may also be unreadable directories.
+ if err != nil || info.IsDir() {
+ os.Chmod(path, 0777)
+ }
+ return nil
+ })
+ return robustio.RemoveAll(dir)
+}
+
+// Sleep sleeps for the given Go duration or until the script's context is
+// cancelled, whichever happens first.
+func Sleep() Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "sleep for a specified duration",
+ Args: "duration",
+ Detail: []string{
+ "The duration must be given as a Go time.Duration string.",
+ },
+ Async: true,
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ if len(args) != 1 {
+ return nil, ErrUsage
+ }
+
+ d, err := time.ParseDuration(args[0])
+ if err != nil {
+ return nil, err
+ }
+
+ timer := time.NewTimer(d)
+ wait := func(s *State) (stdout, stderr string, err error) {
+ ctx := s.Context()
+ select {
+ case <-ctx.Done():
+ timer.Stop()
+ return "", "", ctx.Err()
+ case <-timer.C:
+ return "", "", nil
+ }
+ }
+ return wait, nil
+ })
+}
+
+// Stderr searches for a regular expression in the stderr buffer.
+func Stderr() Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "find lines in the stderr buffer that match a pattern",
+ Args: matchUsage + " file",
+ Detail: []string{
+ "The command succeeds if at least one match (or the exact count, if given) is found.",
+ "The -q flag suppresses printing of matches.",
+ },
+ RegexpArgs: firstNonFlag,
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ return nil, match(s, args, s.Stderr(), "stderr")
+ })
+}
+
+// Stdout searches for a regular expression in the stdout buffer.
+func Stdout() Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "find lines in the stdout buffer that match a pattern",
+ Args: matchUsage + " file",
+ Detail: []string{
+ "The command succeeds if at least one match (or the exact count, if given) is found.",
+ "The -q flag suppresses printing of matches.",
+ },
+ RegexpArgs: firstNonFlag,
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ return nil, match(s, args, s.Stdout(), "stdout")
+ })
+}
+
+// Stop returns a sentinel error that causes script execution to halt
+// and s.Execute to return with a nil error.
+func Stop() Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "stop execution of the script",
+ Args: "[msg]",
+ Detail: []string{
+ "The message is written to the script log, but no error is reported from the script engine.",
+ },
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ if len(args) > 1 {
+ return nil, ErrUsage
+ }
+ // TODO(bcmills): The argument passed to stop seems redundant with comments.
+ // Either use it systematically or remove it.
+ if len(args) == 1 {
+ return nil, stopError{msg: args[0]}
+ }
+ return nil, stopError{}
+ })
+}
+
+// stopError is the sentinel error type returned by the Stop command.
+type stopError struct {
+ msg string
+}
+
+func (s stopError) Error() string {
+ if s.msg == "" {
+ return "stop"
+ }
+ return "stop: " + s.msg
+}
+
+// Symlink creates a symbolic link.
+func Symlink() Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "create a symlink",
+ Args: "path -> target",
+ Detail: []string{
+ "Creates path as a symlink to target.",
+ "The '->' token (like in 'ls -l' output on Unix) is required.",
+ },
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ if len(args) != 3 || args[1] != "->" {
+ return nil, ErrUsage
+ }
+
+ // Note that the link target args[2] is not interpreted with s.Path:
+ // it will be interpreted relative to the directory file is in.
+ return nil, os.Symlink(filepath.FromSlash(args[2]), s.Path(args[0]))
+ })
+}
+
+// Wait waits for the completion of background commands.
+//
+// When Wait returns, the stdout and stderr buffers contain the concatenation of
+// the background commands' respective outputs in the order in which those
+// commands were started.
+func Wait() Cmd {
+ return Command(
+ CmdUsage{
+ Summary: "wait for completion of background commands",
+ Args: "",
+ Detail: []string{
+ "Waits for all background commands to complete.",
+ "The output (and any error) from each command is printed to the log in the order in which the commands were started.",
+ "After the call to 'wait', the script's stdout and stderr buffers contain the concatenation of the background commands' outputs.",
+ },
+ },
+ func(s *State, args ...string) (WaitFunc, error) {
+ if len(args) > 0 {
+ return nil, ErrUsage
+ }
+
+ var stdouts, stderrs []string
+ var errs []*CommandError
+ for _, bg := range s.background {
+ stdout, stderr, err := bg.wait(s)
+
+ beforeArgs := ""
+ if len(bg.args) > 0 {
+ beforeArgs = " "
+ }
+ s.Logf("[background] %s%s%s\n", bg.name, beforeArgs, quoteArgs(bg.args))
+
+ if stdout != "" {
+ s.Logf("[stdout]\n%s", stdout)
+ stdouts = append(stdouts, stdout)
+ }
+ if stderr != "" {
+ s.Logf("[stderr]\n%s", stderr)
+ stderrs = append(stderrs, stderr)
+ }
+ if err != nil {
+ s.Logf("[%v]\n", err)
+ }
+ if cmdErr := checkStatus(bg.command, err); cmdErr != nil {
+ errs = append(errs, cmdErr.(*CommandError))
+ }
+ }
+
+ s.stdout = strings.Join(stdouts, "")
+ s.stderr = strings.Join(stderrs, "")
+ s.background = nil
+ if len(errs) > 0 {
+ return nil, waitError{errs: errs}
+ }
+ return nil, nil
+ })
+}
+
+// A waitError wraps one or more errors returned by background commands.
+type waitError struct {
+ errs []*CommandError
+}
+
+func (w waitError) Error() string {
+ b := new(strings.Builder)
+ for i, err := range w.errs {
+ if i != 0 {
+ b.WriteString("\n")
+ }
+ b.WriteString(err.Error())
+ }
+ return b.String()
+}
+
+func (w waitError) Unwrap() error {
+ if len(w.errs) == 1 {
+ return w.errs[0]
+ }
+ return nil
+}
diff --git a/src/cmd/go/internal/script/cmds_other.go b/src/cmd/go/internal/script/cmds_other.go
new file mode 100644
index 0000000..847b225
--- /dev/null
+++ b/src/cmd/go/internal/script/cmds_other.go
@@ -0,0 +1,11 @@
+// Copyright 2023 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.
+
+//go:build !(unix || windows)
+
+package script
+
+func isETXTBSY(err error) bool {
+ return false
+}
diff --git a/src/cmd/go/internal/script/cmds_posix.go b/src/cmd/go/internal/script/cmds_posix.go
new file mode 100644
index 0000000..2525f6e
--- /dev/null
+++ b/src/cmd/go/internal/script/cmds_posix.go
@@ -0,0 +1,16 @@
+// Copyright 2023 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.
+
+//go:build unix || windows
+
+package script
+
+import (
+ "errors"
+ "syscall"
+)
+
+func isETXTBSY(err error) bool {
+ return errors.Is(err, syscall.ETXTBSY)
+}
diff --git a/src/cmd/go/internal/script/conds.go b/src/cmd/go/internal/script/conds.go
new file mode 100644
index 0000000..d70f274
--- /dev/null
+++ b/src/cmd/go/internal/script/conds.go
@@ -0,0 +1,205 @@
+// Copyright 2022 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 script
+
+import (
+ "cmd/go/internal/imports"
+ "fmt"
+ "os"
+ "runtime"
+ "sync"
+)
+
+// DefaultConds returns a set of broadly useful script conditions.
+//
+// Run the 'help' command within a script engine to view a list of the available
+// conditions.
+func DefaultConds() map[string]Cond {
+ conds := make(map[string]Cond)
+
+ conds["GOOS"] = PrefixCondition(
+ "runtime.GOOS == <suffix>",
+ func(_ *State, suffix string) (bool, error) {
+ if suffix == runtime.GOOS {
+ return true, nil
+ }
+ if _, ok := imports.KnownOS[suffix]; !ok {
+ return false, fmt.Errorf("unrecognized GOOS %q", suffix)
+ }
+ return false, nil
+ })
+
+ conds["GOARCH"] = PrefixCondition(
+ "runtime.GOARCH == <suffix>",
+ func(_ *State, suffix string) (bool, error) {
+ if suffix == runtime.GOARCH {
+ return true, nil
+ }
+ if _, ok := imports.KnownArch[suffix]; !ok {
+ return false, fmt.Errorf("unrecognized GOOS %q", suffix)
+ }
+ return false, nil
+ })
+
+ conds["compiler"] = PrefixCondition(
+ "runtime.Compiler == <suffix>",
+ func(_ *State, suffix string) (bool, error) {
+ if suffix == runtime.Compiler {
+ return true, nil
+ }
+ switch suffix {
+ case "gc", "gccgo":
+ return false, nil
+ default:
+ return false, fmt.Errorf("unrecognized compiler %q", suffix)
+ }
+ })
+
+ conds["root"] = BoolCondition("os.Geteuid() == 0", os.Geteuid() == 0)
+
+ return conds
+}
+
+// Condition returns a Cond with the given summary and evaluation function.
+func Condition(summary string, eval func(*State) (bool, error)) Cond {
+ return &funcCond{eval: eval, usage: CondUsage{Summary: summary}}
+}
+
+type funcCond struct {
+ eval func(*State) (bool, error)
+ usage CondUsage
+}
+
+func (c *funcCond) Usage() *CondUsage { return &c.usage }
+
+func (c *funcCond) Eval(s *State, suffix string) (bool, error) {
+ if suffix != "" {
+ return false, ErrUsage
+ }
+ return c.eval(s)
+}
+
+// PrefixCondition returns a Cond with the given summary and evaluation function.
+func PrefixCondition(summary string, eval func(*State, string) (bool, error)) Cond {
+ return &prefixCond{eval: eval, usage: CondUsage{Summary: summary, Prefix: true}}
+}
+
+type prefixCond struct {
+ eval func(*State, string) (bool, error)
+ usage CondUsage
+}
+
+func (c *prefixCond) Usage() *CondUsage { return &c.usage }
+
+func (c *prefixCond) Eval(s *State, suffix string) (bool, error) {
+ return c.eval(s, suffix)
+}
+
+// BoolCondition returns a Cond with the given truth value and summary.
+// The Cond rejects the use of condition suffixes.
+func BoolCondition(summary string, v bool) Cond {
+ return &boolCond{v: v, usage: CondUsage{Summary: summary}}
+}
+
+type boolCond struct {
+ v bool
+ usage CondUsage
+}
+
+func (b *boolCond) Usage() *CondUsage { return &b.usage }
+
+func (b *boolCond) Eval(s *State, suffix string) (bool, error) {
+ if suffix != "" {
+ return false, ErrUsage
+ }
+ return b.v, nil
+}
+
+// OnceCondition returns a Cond that calls eval the first time the condition is
+// evaluated. Future calls reuse the same result.
+//
+// The eval function is not passed a *State because the condition is cached
+// across all execution states and must not vary by state.
+func OnceCondition(summary string, eval func() (bool, error)) Cond {
+ return &onceCond{eval: eval, usage: CondUsage{Summary: summary}}
+}
+
+type onceCond struct {
+ once sync.Once
+ v bool
+ err error
+ eval func() (bool, error)
+ usage CondUsage
+}
+
+func (l *onceCond) Usage() *CondUsage { return &l.usage }
+
+func (l *onceCond) Eval(s *State, suffix string) (bool, error) {
+ if suffix != "" {
+ return false, ErrUsage
+ }
+ l.once.Do(func() { l.v, l.err = l.eval() })
+ return l.v, l.err
+}
+
+// CachedCondition is like Condition but only calls eval the first time the
+// condition is evaluated for a given suffix.
+// Future calls with the same suffix reuse the earlier result.
+//
+// The eval function is not passed a *State because the condition is cached
+// across all execution states and must not vary by state.
+func CachedCondition(summary string, eval func(string) (bool, error)) Cond {
+ return &cachedCond{eval: eval, usage: CondUsage{Summary: summary, Prefix: true}}
+}
+
+type cachedCond struct {
+ m sync.Map
+ eval func(string) (bool, error)
+ usage CondUsage
+}
+
+func (c *cachedCond) Usage() *CondUsage { return &c.usage }
+
+func (c *cachedCond) Eval(_ *State, suffix string) (bool, error) {
+ for {
+ var ready chan struct{}
+
+ v, loaded := c.m.Load(suffix)
+ if !loaded {
+ ready = make(chan struct{})
+ v, loaded = c.m.LoadOrStore(suffix, (<-chan struct{})(ready))
+
+ if !loaded {
+ inPanic := true
+ defer func() {
+ if inPanic {
+ c.m.Delete(suffix)
+ }
+ close(ready)
+ }()
+
+ b, err := c.eval(suffix)
+ inPanic = false
+
+ if err == nil {
+ c.m.Store(suffix, b)
+ return b, nil
+ } else {
+ c.m.Store(suffix, err)
+ return false, err
+ }
+ }
+ }
+
+ switch v := v.(type) {
+ case bool:
+ return v, nil
+ case error:
+ return false, v
+ case <-chan struct{}:
+ <-v
+ }
+ }
+}
diff --git a/src/cmd/go/internal/script/engine.go b/src/cmd/go/internal/script/engine.go
new file mode 100644
index 0000000..43054a2
--- /dev/null
+++ b/src/cmd/go/internal/script/engine.go
@@ -0,0 +1,788 @@
+// Copyright 2022 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 script implements a small, customizable, platform-agnostic scripting
+// language.
+//
+// Scripts are run by an [Engine] configured with a set of available commands
+// and conditions that guard those commands. Each script has an associated
+// working directory and environment, along with a buffer containing the stdout
+// and stderr output of a prior command, tracked in a [State] that commands can
+// inspect and modify.
+//
+// The default commands configured by [NewEngine] resemble a simplified Unix
+// shell.
+//
+// # Script Language
+//
+// Each line of a script is parsed into a sequence of space-separated command
+// words, with environment variable expansion within each word and # marking an
+// end-of-line comment. Additional variables named ':' and '/' are expanded
+// within script arguments (expanding to the value of os.PathListSeparator and
+// os.PathSeparator respectively) but are not inherited in subprocess
+// environments.
+//
+// Adding single quotes around text keeps spaces in that text from being treated
+// as word separators and also disables environment variable expansion.
+// Inside a single-quoted block of text, a repeated single quote indicates
+// a literal single quote, as in:
+//
+// 'Don''t communicate by sharing memory.'
+//
+// A line beginning with # is a comment and conventionally explains what is
+// being done or tested at the start of a new section of the script.
+//
+// Commands are executed one at a time, and errors are checked for each command;
+// if any command fails unexpectedly, no subsequent commands in the script are
+// executed. The command prefix ! indicates that the command on the rest of the
+// line (typically go or a matching predicate) must fail instead of succeeding.
+// The command prefix ? indicates that the command may or may not succeed, but
+// the script should continue regardless.
+//
+// The command prefix [cond] indicates that the command on the rest of the line
+// should only run when the condition is satisfied.
+//
+// A condition can be negated: [!root] means to run the rest of the line only if
+// the user is not root. Multiple conditions may be given for a single command,
+// for example, '[linux] [amd64] skip'. The command will run if all conditions
+// are satisfied.
+package script
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "sort"
+ "strings"
+ "time"
+)
+
+// An Engine stores the configuration for executing a set of scripts.
+//
+// The same Engine may execute multiple scripts concurrently.
+type Engine struct {
+ Cmds map[string]Cmd
+ Conds map[string]Cond
+
+ // If Quiet is true, Execute deletes log prints from the previous
+ // section when starting a new section.
+ Quiet bool
+}
+
+// NewEngine returns an Engine configured with a basic set of commands and conditions.
+func NewEngine() *Engine {
+ return &Engine{
+ Cmds: DefaultCmds(),
+ Conds: DefaultConds(),
+ }
+}
+
+// A Cmd is a command that is available to a script.
+type Cmd interface {
+ // Run begins running the command.
+ //
+ // If the command produces output or can be run in the background, run returns
+ // a WaitFunc that will be called to obtain the result of the command and
+ // update the engine's stdout and stderr buffers.
+ //
+ // Run itself and the returned WaitFunc may inspect and/or modify the State,
+ // but the State's methods must not be called concurrently after Run has
+ // returned.
+ //
+ // Run may retain and access the args slice until the WaitFunc has returned.
+ Run(s *State, args ...string) (WaitFunc, error)
+
+ // Usage returns the usage for the command, which the caller must not modify.
+ Usage() *CmdUsage
+}
+
+// A WaitFunc is a function called to retrieve the results of a Cmd.
+type WaitFunc func(*State) (stdout, stderr string, err error)
+
+// A CmdUsage describes the usage of a Cmd, independent of its name
+// (which can change based on its registration).
+type CmdUsage struct {
+ Summary string // in the style of the Name section of a Unix 'man' page, omitting the name
+ Args string // a brief synopsis of the command's arguments (only)
+ Detail []string // zero or more sentences in the style of the Description section of a Unix 'man' page
+
+ // If Async is true, the Cmd is meaningful to run in the background, and its
+ // Run method must return either a non-nil WaitFunc or a non-nil error.
+ Async bool
+
+ // RegexpArgs reports which arguments, if any, should be treated as regular
+ // expressions. It takes as input the raw, unexpanded arguments and returns
+ // the list of argument indices that will be interpreted as regular
+ // expressions.
+ //
+ // If RegexpArgs is nil, all arguments are assumed not to be regular
+ // expressions.
+ RegexpArgs func(rawArgs ...string) []int
+}
+
+// A Cond is a condition deciding whether a command should be run.
+type Cond interface {
+ // Eval reports whether the condition applies to the given State.
+ //
+ // If the condition's usage reports that it is a prefix,
+ // the condition must be used with a suffix.
+ // Otherwise, the passed-in suffix argument is always the empty string.
+ Eval(s *State, suffix string) (bool, error)
+
+ // Usage returns the usage for the condition, which the caller must not modify.
+ Usage() *CondUsage
+}
+
+// A CondUsage describes the usage of a Cond, independent of its name
+// (which can change based on its registration).
+type CondUsage struct {
+ Summary string // a single-line summary of when the condition is true
+
+ // If Prefix is true, the condition is a prefix and requires a
+ // colon-separated suffix (like "[GOOS:linux]" for the "GOOS" condition).
+ // The suffix may be the empty string (like "[prefix:]").
+ Prefix bool
+}
+
+// Execute reads and executes script, writing the output to log.
+//
+// Execute stops and returns an error at the first command that does not succeed.
+// The returned error's text begins with "file:line: ".
+//
+// If the script runs to completion or ends by a 'stop' command,
+// Execute returns nil.
+//
+// Execute does not stop background commands started by the script
+// before returning. To stop those, use [State.CloseAndWait] or the
+// [Wait] command.
+func (e *Engine) Execute(s *State, file string, script *bufio.Reader, log io.Writer) (err error) {
+ defer func(prev *Engine) { s.engine = prev }(s.engine)
+ s.engine = e
+
+ var sectionStart time.Time
+ // endSection flushes the logs for the current section from s.log to log.
+ // ok indicates whether all commands in the section succeeded.
+ endSection := func(ok bool) error {
+ var err error
+ if sectionStart.IsZero() {
+ // We didn't write a section header or record a timestamp, so just dump the
+ // whole log without those.
+ if s.log.Len() > 0 {
+ err = s.flushLog(log)
+ }
+ } else if s.log.Len() == 0 {
+ // Adding elapsed time for doing nothing is meaningless, so don't.
+ _, err = io.WriteString(log, "\n")
+ } else {
+ // Insert elapsed time for section at the end of the section's comment.
+ _, err = fmt.Fprintf(log, " (%.3fs)\n", time.Since(sectionStart).Seconds())
+
+ if err == nil && (!ok || !e.Quiet) {
+ err = s.flushLog(log)
+ } else {
+ s.log.Reset()
+ }
+ }
+
+ sectionStart = time.Time{}
+ return err
+ }
+
+ var lineno int
+ lineErr := func(err error) error {
+ if errors.As(err, new(*CommandError)) {
+ return err
+ }
+ return fmt.Errorf("%s:%d: %w", file, lineno, err)
+ }
+
+ // In case of failure or panic, flush any pending logs for the section.
+ defer func() {
+ if sErr := endSection(false); sErr != nil && err == nil {
+ err = lineErr(sErr)
+ }
+ }()
+
+ for {
+ if err := s.ctx.Err(); err != nil {
+ // This error wasn't produced by any particular command,
+ // so don't wrap it in a CommandError.
+ return lineErr(err)
+ }
+
+ line, err := script.ReadString('\n')
+ if err == io.EOF {
+ if line == "" {
+ break // Reached the end of the script.
+ }
+ // If the script doesn't end in a newline, interpret the final line.
+ } else if err != nil {
+ return lineErr(err)
+ }
+ line = strings.TrimSuffix(line, "\n")
+ lineno++
+
+ // The comment character "#" at the start of the line delimits a section of
+ // the script.
+ if strings.HasPrefix(line, "#") {
+ // If there was a previous section, the fact that we are starting a new
+ // one implies the success of the previous one.
+ //
+ // At the start of the script, the state may also contain accumulated logs
+ // from commands executed on the State outside of the engine in order to
+ // set it up; flush those logs too.
+ if err := endSection(true); err != nil {
+ return lineErr(err)
+ }
+
+ // Log the section start without a newline so that we can add
+ // a timestamp for the section when it ends.
+ _, err = fmt.Fprintf(log, "%s", line)
+ sectionStart = time.Now()
+ if err != nil {
+ return lineErr(err)
+ }
+ continue
+ }
+
+ cmd, err := parse(file, lineno, line)
+ if cmd == nil && err == nil {
+ continue // Ignore blank lines.
+ }
+ s.Logf("> %s\n", line)
+ if err != nil {
+ return lineErr(err)
+ }
+
+ // Evaluate condition guards.
+ ok, err := e.conditionsActive(s, cmd.conds)
+ if err != nil {
+ return lineErr(err)
+ }
+ if !ok {
+ s.Logf("[condition not met]\n")
+ continue
+ }
+
+ impl := e.Cmds[cmd.name]
+
+ // Expand variables in arguments.
+ var regexpArgs []int
+ if impl != nil {
+ usage := impl.Usage()
+ if usage.RegexpArgs != nil {
+ // First join rawArgs without expansion to pass to RegexpArgs.
+ rawArgs := make([]string, 0, len(cmd.rawArgs))
+ for _, frags := range cmd.rawArgs {
+ var b strings.Builder
+ for _, frag := range frags {
+ b.WriteString(frag.s)
+ }
+ rawArgs = append(rawArgs, b.String())
+ }
+ regexpArgs = usage.RegexpArgs(rawArgs...)
+ }
+ }
+ cmd.args = expandArgs(s, cmd.rawArgs, regexpArgs)
+
+ // Run the command.
+ err = e.runCommand(s, cmd, impl)
+ if err != nil {
+ if stop := (stopError{}); errors.As(err, &stop) {
+ // Since the 'stop' command halts execution of the entire script,
+ // log its message separately from the section in which it appears.
+ err = endSection(true)
+ s.Logf("%v\n", stop)
+ if err == nil {
+ return nil
+ }
+ }
+ return lineErr(err)
+ }
+ }
+
+ if err := endSection(true); err != nil {
+ return lineErr(err)
+ }
+ return nil
+}
+
+// A command is a complete command parsed from a script.
+type command struct {
+ file string
+ line int
+ want expectedStatus
+ conds []condition // all must be satisfied
+ name string // the name of the command; must be non-empty
+ rawArgs [][]argFragment
+ args []string // shell-expanded arguments following name
+ background bool // command should run in background (ends with a trailing &)
+}
+
+// A expectedStatus describes the expected outcome of a command.
+// Script execution halts when a command does not match its expected status.
+type expectedStatus string
+
+const (
+ success expectedStatus = ""
+ failure expectedStatus = "!"
+ successOrFailure expectedStatus = "?"
+)
+
+type argFragment struct {
+ s string
+ quoted bool // if true, disable variable expansion for this fragment
+}
+
+type condition struct {
+ want bool
+ tag string
+}
+
+const argSepChars = " \t\r\n#"
+
+// parse parses a single line as a list of space-separated arguments.
+// subject to environment variable expansion (but not resplitting).
+// Single quotes around text disable splitting and expansion.
+// To embed a single quote, double it:
+//
+// 'Don''t communicate by sharing memory.'
+func parse(filename string, lineno int, line string) (cmd *command, err error) {
+ cmd = &command{file: filename, line: lineno}
+ var (
+ rawArg []argFragment // text fragments of current arg so far (need to add line[start:i])
+ start = -1 // if >= 0, position where current arg text chunk starts
+ quoted = false // currently processing quoted text
+ )
+
+ flushArg := func() error {
+ if len(rawArg) == 0 {
+ return nil // Nothing to flush.
+ }
+ defer func() { rawArg = nil }()
+
+ if cmd.name == "" && len(rawArg) == 1 && !rawArg[0].quoted {
+ arg := rawArg[0].s
+
+ // Command prefix ! means negate the expectations about this command:
+ // go command should fail, match should not be found, etc.
+ // Prefix ? means allow either success or failure.
+ switch want := expectedStatus(arg); want {
+ case failure, successOrFailure:
+ if cmd.want != "" {
+ return errors.New("duplicated '!' or '?' token")
+ }
+ cmd.want = want
+ return nil
+ }
+
+ // Command prefix [cond] means only run this command if cond is satisfied.
+ if strings.HasPrefix(arg, "[") && strings.HasSuffix(arg, "]") {
+ want := true
+ arg = strings.TrimSpace(arg[1 : len(arg)-1])
+ if strings.HasPrefix(arg, "!") {
+ want = false
+ arg = strings.TrimSpace(arg[1:])
+ }
+ if arg == "" {
+ return errors.New("empty condition")
+ }
+ cmd.conds = append(cmd.conds, condition{want: want, tag: arg})
+ return nil
+ }
+
+ if arg == "" {
+ return errors.New("empty command")
+ }
+ cmd.name = arg
+ return nil
+ }
+
+ cmd.rawArgs = append(cmd.rawArgs, rawArg)
+ return nil
+ }
+
+ for i := 0; ; i++ {
+ if !quoted && (i >= len(line) || strings.ContainsRune(argSepChars, rune(line[i]))) {
+ // Found arg-separating space.
+ if start >= 0 {
+ rawArg = append(rawArg, argFragment{s: line[start:i], quoted: false})
+ start = -1
+ }
+ if err := flushArg(); err != nil {
+ return nil, err
+ }
+ if i >= len(line) || line[i] == '#' {
+ break
+ }
+ continue
+ }
+ if i >= len(line) {
+ return nil, errors.New("unterminated quoted argument")
+ }
+ if line[i] == '\'' {
+ if !quoted {
+ // starting a quoted chunk
+ if start >= 0 {
+ rawArg = append(rawArg, argFragment{s: line[start:i], quoted: false})
+ }
+ start = i + 1
+ quoted = true
+ continue
+ }
+ // 'foo''bar' means foo'bar, like in rc shell and Pascal.
+ if i+1 < len(line) && line[i+1] == '\'' {
+ rawArg = append(rawArg, argFragment{s: line[start:i], quoted: true})
+ start = i + 1
+ i++ // skip over second ' before next iteration
+ continue
+ }
+ // ending a quoted chunk
+ rawArg = append(rawArg, argFragment{s: line[start:i], quoted: true})
+ start = i + 1
+ quoted = false
+ continue
+ }
+ // found character worth saving; make sure we're saving
+ if start < 0 {
+ start = i
+ }
+ }
+
+ if cmd.name == "" {
+ if cmd.want != "" || len(cmd.conds) > 0 || len(cmd.rawArgs) > 0 || cmd.background {
+ // The line contains a command prefix or suffix, but no actual command.
+ return nil, errors.New("missing command")
+ }
+
+ // The line is blank, or contains only a comment.
+ return nil, nil
+ }
+
+ if n := len(cmd.rawArgs); n > 0 {
+ last := cmd.rawArgs[n-1]
+ if len(last) == 1 && !last[0].quoted && last[0].s == "&" {
+ cmd.background = true
+ cmd.rawArgs = cmd.rawArgs[:n-1]
+ }
+ }
+ return cmd, nil
+}
+
+// expandArgs expands the shell variables in rawArgs and joins them to form the
+// final arguments to pass to a command.
+func expandArgs(s *State, rawArgs [][]argFragment, regexpArgs []int) []string {
+ args := make([]string, 0, len(rawArgs))
+ for i, frags := range rawArgs {
+ isRegexp := false
+ for _, j := range regexpArgs {
+ if i == j {
+ isRegexp = true
+ break
+ }
+ }
+
+ var b strings.Builder
+ for _, frag := range frags {
+ if frag.quoted {
+ b.WriteString(frag.s)
+ } else {
+ b.WriteString(s.ExpandEnv(frag.s, isRegexp))
+ }
+ }
+ args = append(args, b.String())
+ }
+ return args
+}
+
+// quoteArgs returns a string that parse would parse as args when passed to a command.
+//
+// TODO(bcmills): This function should have a fuzz test.
+func quoteArgs(args []string) string {
+ var b strings.Builder
+ for i, arg := range args {
+ if i > 0 {
+ b.WriteString(" ")
+ }
+ if strings.ContainsAny(arg, "'"+argSepChars) {
+ // Quote the argument to a form that would be parsed as a single argument.
+ b.WriteString("'")
+ b.WriteString(strings.ReplaceAll(arg, "'", "''"))
+ b.WriteString("'")
+ } else {
+ b.WriteString(arg)
+ }
+ }
+ return b.String()
+}
+
+func (e *Engine) conditionsActive(s *State, conds []condition) (bool, error) {
+ for _, cond := range conds {
+ var impl Cond
+ prefix, suffix, ok := strings.Cut(cond.tag, ":")
+ if ok {
+ impl = e.Conds[prefix]
+ if impl == nil {
+ return false, fmt.Errorf("unknown condition prefix %q", prefix)
+ }
+ if !impl.Usage().Prefix {
+ return false, fmt.Errorf("condition %q cannot be used with a suffix", prefix)
+ }
+ } else {
+ impl = e.Conds[cond.tag]
+ if impl == nil {
+ return false, fmt.Errorf("unknown condition %q", cond.tag)
+ }
+ if impl.Usage().Prefix {
+ return false, fmt.Errorf("condition %q requires a suffix", cond.tag)
+ }
+ }
+ active, err := impl.Eval(s, suffix)
+
+ if err != nil {
+ return false, fmt.Errorf("evaluating condition %q: %w", cond.tag, err)
+ }
+ if active != cond.want {
+ return false, nil
+ }
+ }
+
+ return true, nil
+}
+
+func (e *Engine) runCommand(s *State, cmd *command, impl Cmd) error {
+ if impl == nil {
+ return cmdError(cmd, errors.New("unknown command"))
+ }
+
+ async := impl.Usage().Async
+ if cmd.background && !async {
+ return cmdError(cmd, errors.New("command cannot be run in background"))
+ }
+
+ wait, runErr := impl.Run(s, cmd.args...)
+ if wait == nil {
+ if async && runErr == nil {
+ return cmdError(cmd, errors.New("internal error: async command returned a nil WaitFunc"))
+ }
+ return checkStatus(cmd, runErr)
+ }
+ if runErr != nil {
+ return cmdError(cmd, errors.New("internal error: command returned both an error and a WaitFunc"))
+ }
+
+ if cmd.background {
+ s.background = append(s.background, backgroundCmd{
+ command: cmd,
+ wait: wait,
+ })
+ // Clear stdout and stderr, since they no longer correspond to the last
+ // command executed.
+ s.stdout = ""
+ s.stderr = ""
+ return nil
+ }
+
+ if wait != nil {
+ stdout, stderr, waitErr := wait(s)
+ s.stdout = stdout
+ s.stderr = stderr
+ if stdout != "" {
+ s.Logf("[stdout]\n%s", stdout)
+ }
+ if stderr != "" {
+ s.Logf("[stderr]\n%s", stderr)
+ }
+ if cmdErr := checkStatus(cmd, waitErr); cmdErr != nil {
+ return cmdErr
+ }
+ if waitErr != nil {
+ // waitErr was expected (by cmd.want), so log it instead of returning it.
+ s.Logf("[%v]\n", waitErr)
+ }
+ }
+ return nil
+}
+
+func checkStatus(cmd *command, err error) error {
+ if err == nil {
+ if cmd.want == failure {
+ return cmdError(cmd, ErrUnexpectedSuccess)
+ }
+ return nil
+ }
+
+ if s := (stopError{}); errors.As(err, &s) {
+ // This error originated in the Stop command.
+ // Propagate it as-is.
+ return cmdError(cmd, err)
+ }
+
+ if w := (waitError{}); errors.As(err, &w) {
+ // This error was surfaced from a background process by a call to Wait.
+ // Add a call frame for Wait itself, but ignore its "want" field.
+ // (Wait itself cannot fail to wait on commands or else it would leak
+ // processes and/or goroutines — so a negative assertion for it would be at
+ // best ambiguous.)
+ return cmdError(cmd, err)
+ }
+
+ if cmd.want == success {
+ return cmdError(cmd, err)
+ }
+
+ if cmd.want == failure && (errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)) {
+ // The command was terminated because the script is no longer interested in
+ // its output, so we don't know what it would have done had it run to
+ // completion — for all we know, it could have exited without error if it
+ // ran just a smidge faster.
+ return cmdError(cmd, err)
+ }
+
+ return nil
+}
+
+// ListCmds prints to w a list of the named commands,
+// annotating each with its arguments and a short usage summary.
+// If verbose is true, ListCmds prints full details for each command.
+//
+// Each of the name arguments should be a command name.
+// If no names are passed as arguments, ListCmds lists all the
+// commands registered in e.
+func (e *Engine) ListCmds(w io.Writer, verbose bool, names ...string) error {
+ if names == nil {
+ names = make([]string, 0, len(e.Cmds))
+ for name := range e.Cmds {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ }
+
+ for _, name := range names {
+ cmd := e.Cmds[name]
+ usage := cmd.Usage()
+
+ suffix := ""
+ if usage.Async {
+ suffix = " [&]"
+ }
+
+ _, err := fmt.Fprintf(w, "%s %s%s\n\t%s\n", name, usage.Args, suffix, usage.Summary)
+ if err != nil {
+ return err
+ }
+
+ if verbose {
+ if _, err := io.WriteString(w, "\n"); err != nil {
+ return err
+ }
+ for _, line := range usage.Detail {
+ if err := wrapLine(w, line, 60, "\t"); err != nil {
+ return err
+ }
+ }
+ if _, err := io.WriteString(w, "\n"); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+func wrapLine(w io.Writer, line string, cols int, indent string) error {
+ line = strings.TrimLeft(line, " ")
+ for len(line) > cols {
+ bestSpace := -1
+ for i, r := range line {
+ if r == ' ' {
+ if i <= cols || bestSpace < 0 {
+ bestSpace = i
+ }
+ if i > cols {
+ break
+ }
+ }
+ }
+ if bestSpace < 0 {
+ break
+ }
+
+ if _, err := fmt.Fprintf(w, "%s%s\n", indent, line[:bestSpace]); err != nil {
+ return err
+ }
+ line = line[bestSpace+1:]
+ }
+
+ _, err := fmt.Fprintf(w, "%s%s\n", indent, line)
+ return err
+}
+
+// ListConds prints to w a list of conditions, one per line,
+// annotating each with a description and whether the condition
+// is true in the state s (if s is non-nil).
+//
+// Each of the tag arguments should be a condition string of
+// the form "name" or "name:suffix". If no tags are passed as
+// arguments, ListConds lists all conditions registered in
+// the engine e.
+func (e *Engine) ListConds(w io.Writer, s *State, tags ...string) error {
+ if tags == nil {
+ tags = make([]string, 0, len(e.Conds))
+ for name := range e.Conds {
+ tags = append(tags, name)
+ }
+ sort.Strings(tags)
+ }
+
+ for _, tag := range tags {
+ if prefix, suffix, ok := strings.Cut(tag, ":"); ok {
+ cond := e.Conds[prefix]
+ if cond == nil {
+ return fmt.Errorf("unknown condition prefix %q", prefix)
+ }
+ usage := cond.Usage()
+ if !usage.Prefix {
+ return fmt.Errorf("condition %q cannot be used with a suffix", prefix)
+ }
+
+ activeStr := ""
+ if s != nil {
+ if active, _ := cond.Eval(s, suffix); active {
+ activeStr = " (active)"
+ }
+ }
+ _, err := fmt.Fprintf(w, "[%s]%s\n\t%s\n", tag, activeStr, usage.Summary)
+ if err != nil {
+ return err
+ }
+ continue
+ }
+
+ cond := e.Conds[tag]
+ if cond == nil {
+ return fmt.Errorf("unknown condition %q", tag)
+ }
+ var err error
+ usage := cond.Usage()
+ if usage.Prefix {
+ _, err = fmt.Fprintf(w, "[%s:*]\n\t%s\n", tag, usage.Summary)
+ } else {
+ activeStr := ""
+ if s != nil {
+ if ok, _ := cond.Eval(s, ""); ok {
+ activeStr = " (active)"
+ }
+ }
+ _, err = fmt.Fprintf(w, "[%s]%s\n\t%s\n", tag, activeStr, usage.Summary)
+ }
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/src/cmd/go/internal/script/errors.go b/src/cmd/go/internal/script/errors.go
new file mode 100644
index 0000000..7f43e72
--- /dev/null
+++ b/src/cmd/go/internal/script/errors.go
@@ -0,0 +1,64 @@
+// Copyright 2022 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 script
+
+import (
+ "errors"
+ "fmt"
+)
+
+// ErrUnexpectedSuccess indicates that a script command that was expected to
+// fail (as indicated by a "!" prefix) instead completed successfully.
+var ErrUnexpectedSuccess = errors.New("unexpected success")
+
+// A CommandError describes an error resulting from attempting to execute a
+// specific command.
+type CommandError struct {
+ File string
+ Line int
+ Op string
+ Args []string
+ Err error
+}
+
+func cmdError(cmd *command, err error) *CommandError {
+ return &CommandError{
+ File: cmd.file,
+ Line: cmd.line,
+ Op: cmd.name,
+ Args: cmd.args,
+ Err: err,
+ }
+}
+
+func (e *CommandError) Error() string {
+ if len(e.Args) == 0 {
+ return fmt.Sprintf("%s:%d: %s: %v", e.File, e.Line, e.Op, e.Err)
+ }
+ return fmt.Sprintf("%s:%d: %s %s: %v", e.File, e.Line, e.Op, quoteArgs(e.Args), e.Err)
+}
+
+func (e *CommandError) Unwrap() error { return e.Err }
+
+// A UsageError reports the valid arguments for a command.
+//
+// It may be returned in response to invalid arguments.
+type UsageError struct {
+ Name string
+ Command Cmd
+}
+
+func (e *UsageError) Error() string {
+ usage := e.Command.Usage()
+ suffix := ""
+ if usage.Async {
+ suffix = " [&]"
+ }
+ return fmt.Sprintf("usage: %s %s%s", e.Name, usage.Args, suffix)
+}
+
+// ErrUsage may be returned by a Command to indicate that it was called with
+// invalid arguments; its Usage method may be called to obtain details.
+var ErrUsage = errors.New("invalid usage")
diff --git a/src/cmd/go/internal/script/scripttest/scripttest.go b/src/cmd/go/internal/script/scripttest/scripttest.go
new file mode 100644
index 0000000..0696624
--- /dev/null
+++ b/src/cmd/go/internal/script/scripttest/scripttest.go
@@ -0,0 +1,143 @@
+// Copyright 2022 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 scripttest adapts the script engine for use in tests.
+package scripttest
+
+import (
+ "bufio"
+ "cmd/go/internal/script"
+ "errors"
+ "io"
+ "os/exec"
+ "strings"
+ "testing"
+)
+
+// DefaultCmds returns a set of broadly useful script commands.
+//
+// This set includes all of the commands in script.DefaultCmds,
+// as well as a "skip" command that halts the script and causes the
+// testing.TB passed to Run to be skipped.
+func DefaultCmds() map[string]script.Cmd {
+ cmds := script.DefaultCmds()
+ cmds["skip"] = Skip()
+ return cmds
+}
+
+// DefaultConds returns a set of broadly useful script conditions.
+//
+// This set includes all of the conditions in script.DefaultConds,
+// as well as:
+//
+// - Conditions of the form "exec:foo" are active when the executable "foo" is
+// found in the test process's PATH, and inactive when the executable is
+// not found.
+//
+// - "short" is active when testing.Short() is true.
+//
+// - "verbose" is active when testing.Verbose() is true.
+func DefaultConds() map[string]script.Cond {
+ conds := script.DefaultConds()
+ conds["exec"] = CachedExec()
+ conds["short"] = script.BoolCondition("testing.Short()", testing.Short())
+ conds["verbose"] = script.BoolCondition("testing.Verbose()", testing.Verbose())
+ return conds
+}
+
+// Run runs the script from the given filename starting at the given initial state.
+// When the script completes, Run closes the state.
+func Run(t testing.TB, e *script.Engine, s *script.State, filename string, testScript io.Reader) {
+ t.Helper()
+ err := func() (err error) {
+ log := new(strings.Builder)
+ log.WriteString("\n") // Start output on a new line for consistent indentation.
+
+ // Defer writing to the test log in case the script engine panics during execution,
+ // but write the log before we write the final "skip" or "FAIL" line.
+ t.Helper()
+ defer func() {
+ t.Helper()
+
+ if closeErr := s.CloseAndWait(log); err == nil {
+ err = closeErr
+ }
+
+ if log.Len() > 0 {
+ t.Log(strings.TrimSuffix(log.String(), "\n"))
+ }
+ }()
+
+ if testing.Verbose() {
+ // Add the environment to the start of the script log.
+ wait, err := script.Env().Run(s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if wait != nil {
+ stdout, stderr, err := wait(s)
+ if err != nil {
+ t.Fatalf("env: %v\n%s", err, stderr)
+ }
+ if len(stdout) > 0 {
+ s.Logf("%s\n", stdout)
+ }
+ }
+ }
+
+ return e.Execute(s, filename, bufio.NewReader(testScript), log)
+ }()
+
+ if skip := (skipError{}); errors.As(err, &skip) {
+ if skip.msg == "" {
+ t.Skip("SKIP")
+ } else {
+ t.Skipf("SKIP: %v", skip.msg)
+ }
+ }
+ if err != nil {
+ t.Errorf("FAIL: %v", err)
+ }
+}
+
+// Skip returns a sentinel error that causes Run to mark the test as skipped.
+func Skip() script.Cmd {
+ return script.Command(
+ script.CmdUsage{
+ Summary: "skip the current test",
+ Args: "[msg]",
+ },
+ func(_ *script.State, args ...string) (script.WaitFunc, error) {
+ if len(args) > 1 {
+ return nil, script.ErrUsage
+ }
+ if len(args) == 0 {
+ return nil, skipError{""}
+ }
+ return nil, skipError{args[0]}
+ })
+}
+
+type skipError struct {
+ msg string
+}
+
+func (s skipError) Error() string {
+ if s.msg == "" {
+ return "skip"
+ }
+ return s.msg
+}
+
+// CachedExec returns a Condition that reports whether the PATH of the test
+// binary itself (not the script's current environment) contains the named
+// executable.
+func CachedExec() script.Cond {
+ return script.CachedCondition(
+ "<suffix> names an executable in the test binary's PATH",
+ func(name string) (bool, error) {
+ _, err := exec.LookPath(name)
+ return err == nil, nil
+ })
+}
diff --git a/src/cmd/go/internal/script/state.go b/src/cmd/go/internal/script/state.go
new file mode 100644
index 0000000..548f673
--- /dev/null
+++ b/src/cmd/go/internal/script/state.go
@@ -0,0 +1,236 @@
+// Copyright 2022 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 script
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "internal/txtar"
+ "io"
+ "io/fs"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+// A State encapsulates the current state of a running script engine,
+// including the script environment and any running background commands.
+type State struct {
+ engine *Engine // the engine currently executing the script, if any
+
+ ctx context.Context
+ cancel context.CancelFunc
+ file string
+ log bytes.Buffer
+
+ workdir string // initial working directory
+ pwd string // current working directory during execution
+ env []string // environment list (for os/exec)
+ envMap map[string]string // environment mapping (matches env)
+ stdout string // standard output from last 'go' command; for 'stdout' command
+ stderr string // standard error from last 'go' command; for 'stderr' command
+
+ background []backgroundCmd
+}
+
+type backgroundCmd struct {
+ *command
+ wait WaitFunc
+}
+
+// NewState returns a new State permanently associated with ctx, with its
+// initial working directory in workdir and its initial environment set to
+// initialEnv (or os.Environ(), if initialEnv is nil).
+//
+// The new State also contains pseudo-environment-variables for
+// ${/} and ${:} (for the platform's path and list separators respectively),
+// but does not pass those to subprocesses.
+func NewState(ctx context.Context, workdir string, initialEnv []string) (*State, error) {
+ absWork, err := filepath.Abs(workdir)
+ if err != nil {
+ return nil, err
+ }
+
+ ctx, cancel := context.WithCancel(ctx)
+
+ // Make a fresh copy of the env slice to avoid aliasing bugs if we ever
+ // start modifying it in place; this also establishes the invariant that
+ // s.env contains no duplicates.
+ env := cleanEnv(initialEnv, absWork)
+
+ envMap := make(map[string]string, len(env))
+
+ // Add entries for ${:} and ${/} to make it easier to write platform-independent
+ // paths in scripts.
+ envMap["/"] = string(os.PathSeparator)
+ envMap[":"] = string(os.PathListSeparator)
+
+ for _, kv := range env {
+ if k, v, ok := strings.Cut(kv, "="); ok {
+ envMap[k] = v
+ }
+ }
+
+ s := &State{
+ ctx: ctx,
+ cancel: cancel,
+ workdir: absWork,
+ pwd: absWork,
+ env: env,
+ envMap: envMap,
+ }
+ s.Setenv("PWD", absWork)
+ return s, nil
+}
+
+// CloseAndWait cancels the State's Context and waits for any background commands to
+// finish. If any remaining background command ended in an unexpected state,
+// Close returns a non-nil error.
+func (s *State) CloseAndWait(log io.Writer) error {
+ s.cancel()
+ wait, err := Wait().Run(s)
+ if wait != nil {
+ panic("script: internal error: Wait unexpectedly returns its own WaitFunc")
+ }
+ if flushErr := s.flushLog(log); err == nil {
+ err = flushErr
+ }
+ return err
+}
+
+// Chdir changes the State's working directory to the given path.
+func (s *State) Chdir(path string) error {
+ dir := s.Path(path)
+ if _, err := os.Stat(dir); err != nil {
+ return &fs.PathError{Op: "Chdir", Path: dir, Err: err}
+ }
+ s.pwd = dir
+ s.Setenv("PWD", dir)
+ return nil
+}
+
+// Context returns the Context with which the State was created.
+func (s *State) Context() context.Context {
+ return s.ctx
+}
+
+// Environ returns a copy of the current script environment,
+// in the form "key=value".
+func (s *State) Environ() []string {
+ return append([]string(nil), s.env...)
+}
+
+// ExpandEnv replaces ${var} or $var in the string according to the values of
+// the environment variables in s. References to undefined variables are
+// replaced by the empty string.
+func (s *State) ExpandEnv(str string, inRegexp bool) string {
+ return os.Expand(str, func(key string) string {
+ e := s.envMap[key]
+ if inRegexp {
+ // Quote to literal strings: we want paths like C:\work\go1.4 to remain
+ // paths rather than regular expressions.
+ e = regexp.QuoteMeta(e)
+ }
+ return e
+ })
+}
+
+// ExtractFiles extracts the files in ar to the state's current directory,
+// expanding any environment variables within each name.
+//
+// The files must reside within the working directory with which the State was
+// originally created.
+func (s *State) ExtractFiles(ar *txtar.Archive) error {
+ wd := s.workdir
+
+ // Add trailing separator to terminate wd.
+ // This prevents extracting to outside paths which prefix wd,
+ // e.g. extracting to /home/foobar when wd is /home/foo
+ if wd == "" {
+ panic("s.workdir is unexpectedly empty")
+ }
+ if !os.IsPathSeparator(wd[len(wd)-1]) {
+ wd += string(filepath.Separator)
+ }
+
+ for _, f := range ar.Files {
+ name := s.Path(s.ExpandEnv(f.Name, false))
+
+ if !strings.HasPrefix(name, wd) {
+ return fmt.Errorf("file %#q is outside working directory", f.Name)
+ }
+
+ if err := os.MkdirAll(filepath.Dir(name), 0777); err != nil {
+ return err
+ }
+ if err := os.WriteFile(name, f.Data, 0666); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// Getwd returns the directory in which to run the next script command.
+func (s *State) Getwd() string { return s.pwd }
+
+// Logf writes output to the script's log without updating its stdout or stderr
+// buffers. (The output log functions as a kind of meta-stderr.)
+func (s *State) Logf(format string, args ...any) {
+ fmt.Fprintf(&s.log, format, args...)
+}
+
+// flushLog writes the contents of the script's log to w and clears the log.
+func (s *State) flushLog(w io.Writer) error {
+ _, err := w.Write(s.log.Bytes())
+ s.log.Reset()
+ return err
+}
+
+// LookupEnv retrieves the value of the environment variable in s named by the key.
+func (s *State) LookupEnv(key string) (string, bool) {
+ v, ok := s.envMap[key]
+ return v, ok
+}
+
+// Path returns the absolute path in the host operating system for a
+// script-based (generally slash-separated and relative) path.
+func (s *State) Path(path string) string {
+ if filepath.IsAbs(path) {
+ return filepath.Clean(path)
+ }
+ return filepath.Join(s.pwd, path)
+}
+
+// Setenv sets the value of the environment variable in s named by the key.
+func (s *State) Setenv(key, value string) error {
+ s.env = cleanEnv(append(s.env, key+"="+value), s.pwd)
+ s.envMap[key] = value
+ return nil
+}
+
+// Stdout returns the stdout output of the last command run,
+// or the empty string if no command has been run.
+func (s *State) Stdout() string { return s.stdout }
+
+// Stderr returns the stderr output of the last command run,
+// or the empty string if no command has been run.
+func (s *State) Stderr() string { return s.stderr }
+
+// cleanEnv returns a copy of env with any duplicates removed in favor of
+// later values and any required system variables defined.
+//
+// If env is nil, cleanEnv copies the environment from os.Environ().
+func cleanEnv(env []string, pwd string) []string {
+ // There are some funky edge-cases in this logic, especially on Windows (with
+ // case-insensitive environment variables and variables with keys like "=C:").
+ // Rather than duplicating exec.dedupEnv here, cheat and use exec.Cmd directly.
+ cmd := &exec.Cmd{Env: env}
+ cmd.Dir = pwd
+ return cmd.Environ()
+}