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}