run.go

  1package shell
  2
  3import (
  4	"context"
  5	"fmt"
  6	"io"
  7	"strings"
  8
  9	"mvdan.cc/sh/moreinterp/coreutils"
 10	"mvdan.cc/sh/v3/expand"
 11	"mvdan.cc/sh/v3/interp"
 12	"mvdan.cc/sh/v3/syntax"
 13)
 14
 15// RunOptions configures a single stateless shell execution via [Run].
 16//
 17// The zero value is not useful; at minimum Command must be set. Stdin,
 18// Stdout, and Stderr may be nil (nil readers/writers are treated as
 19// empty/discard). BlockFuncs may be nil to disable block-list enforcement —
 20// hooks use this to run user-authored commands with the same trust level as
 21// a shell alias.
 22type RunOptions struct {
 23	// Command is the shell source to parse and execute.
 24	Command string
 25	// Cwd is the working directory for the execution. Required: callers
 26	// must supply a non-empty value. Run does not silently fall back to
 27	// the Crush process cwd — hooks and the bash tool have different
 28	// notions of "default" and each owns that decision.
 29	Cwd string
 30	// Env is the full environment visible to the command. The caller is
 31	// responsible for inheriting from os.Environ() if that's desired.
 32	Env []string
 33	// Stdin is the command's standard input. nil is equivalent to an empty
 34	// input stream.
 35	Stdin io.Reader
 36	// Stdout receives the command's standard output. nil discards output.
 37	Stdout io.Writer
 38	// Stderr receives the command's standard error. nil discards output.
 39	Stderr io.Writer
 40	// BlockFuncs is an optional list of deny-list matchers applied before
 41	// each command reaches the exec layer. nil disables blocking entirely.
 42	BlockFuncs []BlockFunc
 43}
 44
 45// Run parses and executes a shell command using the same mvdan.cc/sh
 46// interpreter stack that the stateful [Shell] type uses (builtins,
 47// optional block list, optional Go coreutils). It is safe to call
 48// concurrently from multiple goroutines: each call builds its own
 49// [interp.Runner] and shares no state with other callers or with any
 50// [Shell] instance.
 51//
 52// Errors returned from the command itself (non-zero exit, context
 53// cancellation, parse failures) follow the same conventions as
 54// [Shell.Exec]: inspect with [IsInterrupt] and [ExitCode].
 55func Run(ctx context.Context, opts RunOptions) (err error) {
 56	defer func() {
 57		if r := recover(); r != nil {
 58			err = fmt.Errorf("command execution panic: %v", r)
 59		}
 60	}()
 61
 62	if opts.Cwd == "" {
 63		return fmt.Errorf("shell.Run: Cwd is required")
 64	}
 65
 66	stdout := opts.Stdout
 67	if stdout == nil {
 68		stdout = io.Discard
 69	}
 70	stderr := opts.Stderr
 71	if stderr == nil {
 72		stderr = io.Discard
 73	}
 74
 75	line, err := syntax.NewParser().Parse(strings.NewReader(opts.Command), "")
 76	if err != nil {
 77		return fmt.Errorf("could not parse command: %w", err)
 78	}
 79
 80	runner, err := newRunner(opts.Cwd, opts.Env, opts.Stdin, stdout, stderr, opts.BlockFuncs)
 81	if err != nil {
 82		return fmt.Errorf("could not run command: %w", err)
 83	}
 84
 85	return runner.Run(ctx, line)
 86}
 87
 88// newRunner constructs an [interp.Runner] configured with the standard
 89// Crush handler stack. Shared by the stateless [Run] entrypoint and the
 90// stateful [Shell] so the two surfaces cannot drift.
 91func newRunner(cwd string, env []string, stdin io.Reader, stdout, stderr io.Writer, blockFuncs []BlockFunc) (*interp.Runner, error) {
 92	env = withNonInteractiveEnv(env)
 93	return interp.New(
 94		interp.StdIO(stdin, stdout, stderr),
 95		interp.Interactive(false),
 96		interp.Env(expand.ListEnviron(env...)),
 97		interp.Dir(cwd),
 98		interp.ExecHandlers(standardHandlers(blockFuncs)...),
 99	)
100}
101
102// nonInteractiveEnvVars are forced on every shell execution to prevent
103// commands from hanging on a nonexistent TTY. These are always applied
104// regardless of the caller's environment because Crush shells are never
105// interactive — preserving user preferences like EDITOR=nvim only causes
106// hangs, not useful behavior.
107var nonInteractiveEnvVars = []string{
108	"TERM=xterm-256color",
109	"GIT_EDITOR=false",
110	"EDITOR=false",
111	"VISUAL=false",
112	"JJ_EDITOR=false",
113	"JJ_PAGER=cat",
114	"GIT_PAGER=cat",
115	"PAGER=cat",
116}
117
118// withNonInteractiveEnv returns env with nonInteractiveEnvVars forced in,
119// replacing any existing values for those keys. The returned slice is a
120// new allocation safe to use concurrently with the input.
121func withNonInteractiveEnv(env []string) []string {
122	// Build a set of override keys for fast lookup.
123	overrideKeys := make(map[string]bool, len(nonInteractiveEnvVars))
124	for _, kv := range nonInteractiveEnvVars {
125		if key, _, ok := strings.Cut(kv, "="); ok {
126			overrideKeys[key] = true
127		}
128	}
129
130	// Copy env, filtering out any keys we will override.
131	result := make([]string, 0, len(env)+len(nonInteractiveEnvVars))
132	for _, e := range env {
133		if key, _, ok := strings.Cut(e, "="); ok && overrideKeys[key] {
134			continue
135		}
136		result = append(result, e)
137	}
138
139	return append(result, nonInteractiveEnvVars...)
140}
141
142// standardHandlers returns the exec-handler middleware chain used by both
143// [Run] and [Shell]. Order matters:
144//  1. builtins first (so Crush's in-process jq wins over any PATH binary);
145//  2. script dispatch (shebang / binary / shell-source for path-prefixed
146//     argv[0], no-op for bare commands) — runs before the block list so
147//     that deny rules see the already-resolved argv of anything the
148//     script exec's rather than the outer path-prefixed wrapper;
149//  3. block list;
150//  4. optional Go coreutils (only when useGoCoreUtils is on).
151func standardHandlers(blockFuncs []BlockFunc) []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
152	handlers := []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc{
153		builtinHandler(),
154		scriptDispatchHandler(blockFuncs),
155		blockHandler(blockFuncs),
156	}
157	if useGoCoreUtils {
158		handlers = append(handlers, coreutils.ExecHandler)
159	}
160	return handlers
161}
162
163// builtinHandler returns middleware that dispatches recognized Crush
164// builtins to their in-process Go implementations. Currently: jq.
165func builtinHandler() func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
166	return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
167		return func(ctx context.Context, args []string) error {
168			if len(args) == 0 {
169				return next(ctx, args)
170			}
171			switch args[0] {
172			case "jq":
173				hc := interp.HandlerCtx(ctx)
174				return handleJQ(ctx, args, hc.Stdin, hc.Stdout, hc.Stderr)
175			default:
176				return next(ctx, args)
177			}
178		}
179	}
180}
181
182// blockHandler returns middleware that rejects commands matched by any of
183// the provided [BlockFunc]s before they reach the underlying exec path.
184// A nil or empty blockFuncs slice is a no-op.
185func blockHandler(blockFuncs []BlockFunc) func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
186	return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
187		return func(ctx context.Context, args []string) error {
188			if len(args) == 0 {
189				return next(ctx, args)
190			}
191			for _, blockFunc := range blockFuncs {
192				if blockFunc(args) {
193					return fmt.Errorf("command is not allowed for security reasons: %q", args[0])
194				}
195			}
196			return next(ctx, args)
197		}
198	}
199}