diff options
Diffstat (limited to 'src/cmd/go/internal/script')
-rw-r--r-- | src/cmd/go/internal/script/cmds.go | 1126 | ||||
-rw-r--r-- | src/cmd/go/internal/script/cmds_other.go | 11 | ||||
-rw-r--r-- | src/cmd/go/internal/script/cmds_posix.go | 16 | ||||
-rw-r--r-- | src/cmd/go/internal/script/conds.go | 205 | ||||
-rw-r--r-- | src/cmd/go/internal/script/engine.go | 788 | ||||
-rw-r--r-- | src/cmd/go/internal/script/errors.go | 64 | ||||
-rw-r--r-- | src/cmd/go/internal/script/scripttest/scripttest.go | 143 | ||||
-rw-r--r-- | src/cmd/go/internal/script/state.go | 236 |
8 files changed, 2589 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..ecd35ff --- /dev/null +++ b/src/cmd/go/internal/script/cmds.go @@ -0,0 +1,1126 @@ +// 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/cfg" + "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 = cfg.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..ba82171 --- /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 &) +} + +// An 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..6d7bd78 --- /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/cfg" + "cmd/go/internal/script" + "errors" + "io" + "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 := cfg.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() +} |