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}