handler.go

  1// Copyright (c) 2017, Daniel MartΓ­ <mvdan@mvdan.cc>
  2// See LICENSE for licensing information
  3
  4package interp
  5
  6import (
  7	"context"
  8	"fmt"
  9	"io"
 10	"io/fs"
 11	"io/ioutil"
 12	"os"
 13	"os/exec"
 14	"path/filepath"
 15	"runtime"
 16	"strings"
 17	"time"
 18
 19	"mvdan.cc/sh/v3/expand"
 20)
 21
 22// HandlerCtx returns HandlerContext value stored in ctx.
 23// It panics if ctx has no HandlerContext stored.
 24func HandlerCtx(ctx context.Context) HandlerContext {
 25	hc, ok := ctx.Value(handlerCtxKey{}).(HandlerContext)
 26	if !ok {
 27		panic("interp.HandlerCtx: no HandlerContext in ctx")
 28	}
 29	return hc
 30}
 31
 32type handlerCtxKey struct{}
 33
 34// HandlerContext is the data passed to all the handler functions via [context.WithValue].
 35// It contains some of the current state of the [Runner].
 36type HandlerContext struct {
 37	// Env is a read-only version of the interpreter's environment,
 38	// including environment variables, global variables, and local function
 39	// variables.
 40	Env expand.Environ
 41
 42	// Dir is the interpreter's current directory.
 43	Dir string
 44
 45	// TODO(v4): use an os.File for stdin below directly.
 46
 47	// Stdin is the interpreter's current standard input reader.
 48	// It is always an [*os.File], but the type here remains an [io.Reader]
 49	// due to backwards compatibility.
 50	Stdin io.Reader
 51	// Stdout is the interpreter's current standard output writer.
 52	Stdout io.Writer
 53	// Stderr is the interpreter's current standard error writer.
 54	Stderr io.Writer
 55}
 56
 57// CallHandlerFunc is a handler which runs on every [syntax.CallExpr].
 58// It is called once variable assignments and field expansion have occurred.
 59// The call's arguments are replaced by what the handler returns,
 60// and then the call is executed by the Runner as usual.
 61// At this time, returning an empty slice without an error is not supported.
 62//
 63// This handler is similar to [ExecHandlerFunc], but has two major differences:
 64//
 65// First, it runs for all simple commands, including function calls and builtins.
 66//
 67// Second, it is not expected to execute the simple command, but instead to
 68// allow running custom code which allows replacing the argument list.
 69// Shell builtins touch on many internals of the Runner, after all.
 70//
 71// Returning a non-nil error will halt the Runner.
 72type CallHandlerFunc func(ctx context.Context, args []string) ([]string, error)
 73
 74// TODO: consistently treat handler errors as non-fatal by default,
 75// but have an interface or API to specify fatal errors which should make
 76// the shell exit with a particular status code.
 77
 78// ExecHandlerFunc is a handler which executes simple commands.
 79// It is called for all [syntax.CallExpr] nodes
 80// where the first argument is neither a declared function nor a builtin.
 81//
 82// Returning a nil error means a zero exit status.
 83// Other exit statuses can be set with [NewExitStatus].
 84// Any other error will halt the Runner.
 85type ExecHandlerFunc func(ctx context.Context, args []string) error
 86
 87// DefaultExecHandler returns the [ExecHandlerFunc] used by default.
 88// It finds binaries in PATH and executes them.
 89// When context is cancelled, an interrupt signal is sent to running processes.
 90// killTimeout is a duration to wait before sending the kill signal.
 91// A negative value means that a kill signal will be sent immediately.
 92//
 93// On Windows, the kill signal is always sent immediately,
 94// because Go doesn't currently support sending Interrupt on Windows.
 95// [Runner] defaults to a killTimeout of 2 seconds.
 96func DefaultExecHandler(killTimeout time.Duration) ExecHandlerFunc {
 97	return func(ctx context.Context, args []string) error {
 98		hc := HandlerCtx(ctx)
 99		path, err := LookPathDir(hc.Dir, hc.Env, args[0])
100		if err != nil {
101			fmt.Fprintln(hc.Stderr, err)
102			return NewExitStatus(127)
103		}
104		cmd := exec.Cmd{
105			Path:   path,
106			Args:   args,
107			Env:    execEnv(hc.Env),
108			Dir:    hc.Dir,
109			Stdin:  hc.Stdin,
110			Stdout: hc.Stdout,
111			Stderr: hc.Stderr,
112		}
113
114		err = cmd.Start()
115		if err == nil {
116			stopf := context.AfterFunc(ctx, func() {
117				if killTimeout <= 0 || runtime.GOOS == "windows" {
118					_ = cmd.Process.Signal(os.Kill)
119					return
120				}
121				_ = cmd.Process.Signal(os.Interrupt)
122				// TODO: don't sleep in this goroutine if the program
123				// stops itself with the interrupt above.
124				time.Sleep(killTimeout)
125				_ = cmd.Process.Signal(os.Kill)
126			})
127			defer stopf()
128
129			err = cmd.Wait()
130		}
131
132		switch err := err.(type) {
133		case *exec.ExitError:
134			// Windows and Plan9 do not have support for [syscall.WaitStatus]
135			// with methods like Signaled and Signal, so for those, [waitStatus] is a no-op.
136			// Note: [waitStatus] is an alias [syscall.WaitStatus]
137			if status, ok := err.Sys().(waitStatus); ok && status.Signaled() {
138				if ctx.Err() != nil {
139					return ctx.Err()
140				}
141				return NewExitStatus(uint8(128 + status.Signal()))
142			}
143			return NewExitStatus(uint8(err.ExitCode()))
144		case *exec.Error:
145			// did not start
146			fmt.Fprintf(hc.Stderr, "%v\n", err)
147			return NewExitStatus(127)
148		default:
149			return err
150		}
151	}
152}
153
154func checkStat(dir, file string, checkExec bool) (string, error) {
155	if !filepath.IsAbs(file) {
156		file = filepath.Join(dir, file)
157	}
158	info, err := os.Stat(file)
159	if err != nil {
160		return "", err
161	}
162	m := info.Mode()
163	if m.IsDir() {
164		return "", fmt.Errorf("is a directory")
165	}
166	if checkExec && runtime.GOOS != "windows" && m&0o111 == 0 {
167		return "", fmt.Errorf("permission denied")
168	}
169	return file, nil
170}
171
172func winHasExt(file string) bool {
173	i := strings.LastIndex(file, ".")
174	if i < 0 {
175		return false
176	}
177	return strings.LastIndexAny(file, `:\/`) < i
178}
179
180// findExecutable returns the path to an existing executable file.
181func findExecutable(dir, file string, exts []string) (string, error) {
182	if len(exts) == 0 {
183		// non-windows
184		return checkStat(dir, file, true)
185	}
186	if winHasExt(file) {
187		if file, err := checkStat(dir, file, true); err == nil {
188			return file, nil
189		}
190	}
191	for _, e := range exts {
192		f := file + e
193		if f, err := checkStat(dir, f, true); err == nil {
194			return f, nil
195		}
196	}
197	return "", fmt.Errorf("not found")
198}
199
200// findFile returns the path to an existing file.
201func findFile(dir, file string, _ []string) (string, error) {
202	return checkStat(dir, file, false)
203}
204
205// LookPath is deprecated; see [LookPathDir].
206func LookPath(env expand.Environ, file string) (string, error) {
207	return LookPathDir(env.Get("PWD").String(), env, file)
208}
209
210// LookPathDir is similar to [os/exec.LookPath], with the difference that it uses the
211// provided environment. env is used to fetch relevant environment variables
212// such as PWD and PATH.
213//
214// If no error is returned, the returned path must be valid.
215func LookPathDir(cwd string, env expand.Environ, file string) (string, error) {
216	return lookPathDir(cwd, env, file, findExecutable)
217}
218
219// findAny defines a function to pass to [lookPathDir].
220type findAny = func(dir string, file string, exts []string) (string, error)
221
222func lookPathDir(cwd string, env expand.Environ, file string, find findAny) (string, error) {
223	if find == nil {
224		panic("no find function found")
225	}
226
227	pathList := filepath.SplitList(env.Get("PATH").String())
228	if len(pathList) == 0 {
229		pathList = []string{""}
230	}
231	chars := `/`
232	if runtime.GOOS == "windows" {
233		chars = `:\/`
234	}
235	exts := pathExts(env)
236	if strings.ContainsAny(file, chars) {
237		return find(cwd, file, exts)
238	}
239	for _, elem := range pathList {
240		var path string
241		switch elem {
242		case "", ".":
243			// otherwise "foo" won't be "./foo"
244			path = "." + string(filepath.Separator) + file
245		default:
246			path = filepath.Join(elem, file)
247		}
248		if f, err := find(cwd, path, exts); err == nil {
249			return f, nil
250		}
251	}
252	return "", fmt.Errorf("%q: executable file not found in $PATH", file)
253}
254
255// scriptFromPathDir is similar to [LookPathDir], with the difference that it looks
256// for both executable and non-executable files.
257func scriptFromPathDir(cwd string, env expand.Environ, file string) (string, error) {
258	return lookPathDir(cwd, env, file, findFile)
259}
260
261func pathExts(env expand.Environ) []string {
262	if runtime.GOOS != "windows" {
263		return nil
264	}
265	pathext := env.Get("PATHEXT").String()
266	if pathext == "" {
267		return []string{".com", ".exe", ".bat", ".cmd"}
268	}
269	var exts []string
270	for _, e := range strings.Split(strings.ToLower(pathext), `;`) {
271		if e == "" {
272			continue
273		}
274		if e[0] != '.' {
275			e = "." + e
276		}
277		exts = append(exts, e)
278	}
279	return exts
280}
281
282// OpenHandlerFunc is a handler which opens files.
283// It is called for all files that are opened directly by the shell,
284// such as in redirects, except for named pipes created by process substitutions.
285// Files opened by executed programs are not included.
286//
287// The path parameter may be relative to the current directory,
288// which can be fetched via [HandlerCtx].
289//
290// Use a return error of type [*os.PathError] to have the error printed to
291// stderr and the exit status set to 1. If the error is of any other type, the
292// interpreter will come to a stop.
293//
294// Note that implementations which do not return [os.File] will cause
295// extra files and goroutines for input redirections; see [StdIO].
296type OpenHandlerFunc func(ctx context.Context, path string, flag int, perm os.FileMode) (io.ReadWriteCloser, error)
297
298// TODO: paths passed to [OpenHandlerFunc] should be cleaned.
299
300// DefaultOpenHandler returns the [OpenHandlerFunc] used by default.
301// It uses [os.OpenFile] to open files.
302//
303// For the sake of portability, /dev/null opens NUL on Windows.
304func DefaultOpenHandler() OpenHandlerFunc {
305	return func(ctx context.Context, path string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) {
306		mc := HandlerCtx(ctx)
307		if runtime.GOOS == "windows" && path == "/dev/null" {
308			path = "NUL"
309			// Work around https://go.dev/issue/71752, where Go 1.24 started giving
310			// "Invalid handle" errors when opening "NUL" with O_TRUNC.
311			// TODO: hopefully remove this in the future once the bug is fixed.
312			flag &^= os.O_TRUNC
313		} else if path != "" && !filepath.IsAbs(path) {
314			path = filepath.Join(mc.Dir, path)
315		}
316		return os.OpenFile(path, flag, perm)
317	}
318}
319
320// TODO(v4): if this is kept in v4, it most likely needs to use [io/fs.DirEntry] for efficiency
321
322// ReadDirHandlerFunc is a handler which reads directories. It is called during
323// shell globbing, if enabled.
324type ReadDirHandlerFunc func(ctx context.Context, path string) ([]fs.FileInfo, error)
325
326type ReadDirHandlerFunc2 func(ctx context.Context, path string) ([]fs.DirEntry, error)
327
328// DefaultReadDirHandler returns the [ReadDirHandlerFunc] used by default.
329// It makes use of [ioutil.ReadDir].
330func DefaultReadDirHandler() ReadDirHandlerFunc {
331	return func(ctx context.Context, path string) ([]fs.FileInfo, error) {
332		return ioutil.ReadDir(path)
333	}
334}
335
336// DefaultReadDirHandler2 returns the [ReadDirHandlerFunc2] used by default.
337// It uses [os.ReadDir].
338func DefaultReadDirHandler2() ReadDirHandlerFunc2 {
339	return func(ctx context.Context, path string) ([]fs.DirEntry, error) {
340		return os.ReadDir(path)
341	}
342}
343
344// StatHandlerFunc is a handler which gets a file's information.
345type StatHandlerFunc func(ctx context.Context, name string, followSymlinks bool) (fs.FileInfo, error)
346
347// DefaultStatHandler returns the [StatHandlerFunc] used by default.
348// It makes use of [os.Stat] and [os.Lstat], depending on followSymlinks.
349func DefaultStatHandler() StatHandlerFunc {
350	return func(ctx context.Context, path string, followSymlinks bool) (fs.FileInfo, error) {
351		if !followSymlinks {
352			return os.Lstat(path)
353		} else {
354			return os.Stat(path)
355		}
356	}
357}