on main help sign in
refactor(shell): extract stateless run entrypoint
f6ef43eChristian Rocha
internal/shell/run.gonew file+152
@@ -0,0 +1,152 @@
1+package shell
2+
3+import (
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.
22+type 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].
55+func 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.
91+func newRunner(cwd string, env []string, stdin io.Reader, stdout, stderr io.Writer, blockFuncs []BlockFunc) (*interp.Runner, error) {
92+ return interp.New(
93+ interp.StdIO(stdin, stdout, stderr),
94+ interp.Interactive(false),
95+ interp.Env(expand.ListEnviron(env...)),
96+ interp.Dir(cwd),
97+ interp.ExecHandlers(standardHandlers(blockFuncs)...),
98+ )
99+}
100+
101+// standardHandlers returns the exec-handler middleware chain used by both
102+// [Run] and [Shell]. Order matters: builtins first (so Crush's in-process
103+// jq wins over any PATH binary), then the block list, then optional Go
104+// coreutils. Future middleware (shebang dispatch, etc.) inserts here.
105+func standardHandlers(blockFuncs []BlockFunc) []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
106+ handlers := []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc{
107+ builtinHandler(),
108+ blockHandler(blockFuncs),
109+ }
110+ if useGoCoreUtils {
111+ handlers = append(handlers, coreutils.ExecHandler)
112+ }
113+ return handlers
114+}
115+
116+// builtinHandler returns middleware that dispatches recognized Crush
117+// builtins to their in-process Go implementations. Currently: jq.
118+func builtinHandler() func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
119+ return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
120+ return func(ctx context.Context, args []string) error {
121+ if len(args) == 0 {
122+ return next(ctx, args)
123+ }
124+ switch args[0] {
125+ case "jq":
126+ hc := interp.HandlerCtx(ctx)
127+ return handleJQ(args, hc.Stdin, hc.Stdout, hc.Stderr)
128+ default:
129+ return next(ctx, args)
130+ }
131+ }
132+ }
133+}
134+
135+// blockHandler returns middleware that rejects commands matched by any of
136+// the provided [BlockFunc]s before they reach the underlying exec path.
137+// A nil or empty blockFuncs slice is a no-op.
138+func blockHandler(blockFuncs []BlockFunc) func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
139+ return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
140+ return func(ctx context.Context, args []string) error {
141+ if len(args) == 0 {
142+ return next(ctx, args)
143+ }
144+ for _, blockFunc := range blockFuncs {
145+ if blockFunc(args) {
146+ return fmt.Errorf("command is not allowed for security reasons: %q", args[0])
147+ }
148+ }
149+ return next(ctx, args)
150+ }
151+ }
152+}
internal/shell/run_test.gonew file+252
@@ -0,0 +1,252 @@
1+package shell
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+)
13+
14+func TestRun_Echo(t *testing.T) {
15+ var stdout, stderr bytes.Buffer
16+ err := Run(t.Context(), RunOptions{
17+ Command: "echo hi",
18+ Cwd: t.TempDir(),
19+ Stdout: &stdout,
20+ Stderr: &stderr,
21+ })
22+ if err != nil {
23+ t.Fatalf("Run returned error: %v (stderr=%q)", err, stderr.String())
24+ }
25+ if got := stdout.String(); got != "hi\n" {
26+ t.Fatalf("stdout = %q, want %q", got, "hi\n")
27+ }
28+}
29+
30+func TestRun_ExitCode(t *testing.T) {
31+ err := Run(t.Context(), RunOptions{
32+ Command: "exit 7",
33+ Cwd: t.TempDir(),
34+ })
35+ if err == nil {
36+ t.Fatal("expected error for exit 7, got nil")
37+ }
38+ if code := ExitCode(err); code != 7 {
39+ t.Fatalf("ExitCode = %d, want 7", code)
40+ }
41+}
42+
43+func TestRun_Stdin(t *testing.T) {
44+ // Use the `read` shell builtin so the test doesn't depend on any
45+ // external binary being on PATH (we pass an empty Env here).
46+ var stdout bytes.Buffer
47+ err := Run(t.Context(), RunOptions{
48+ Command: "read line; echo got:$line",
49+ Cwd: t.TempDir(),
50+ Stdin: strings.NewReader("hello\n"),
51+ Stdout: &stdout,
52+ })
53+ if err != nil {
54+ t.Fatalf("Run returned error: %v", err)
55+ }
56+ if got := stdout.String(); got != "got:hello\n" {
57+ t.Fatalf("stdout = %q, want %q", got, "got:hello\n")
58+ }
59+}
60+
61+func TestRun_Env(t *testing.T) {
62+ var stdout bytes.Buffer
63+ err := Run(t.Context(), RunOptions{
64+ Command: `echo "$FOO"`,
65+ Cwd: t.TempDir(),
66+ Env: []string{"FOO=bar"},
67+ Stdout: &stdout,
68+ })
69+ if err != nil {
70+ t.Fatalf("Run returned error: %v", err)
71+ }
72+ if got := stdout.String(); got != "bar\n" {
73+ t.Fatalf("stdout = %q, want %q", got, "bar\n")
74+ }
75+}
76+
77+func TestRun_Cwd(t *testing.T) {
78+ dir := t.TempDir()
79+ var stdout bytes.Buffer
80+ err := Run(t.Context(), RunOptions{
81+ Command: "pwd",
82+ Cwd: dir,
83+ Stdout: &stdout,
84+ })
85+ if err != nil {
86+ t.Fatalf("Run returned error: %v", err)
87+ }
88+ // mvdan's pwd builtin resolves symlinks (e.g. /var -> /private/var on
89+ // macOS). Compare against a suffix so we don't get bitten by that.
90+ got := strings.TrimRight(stdout.String(), "\n")
91+ if !strings.HasSuffix(got, dir) && !strings.HasSuffix(dir, got) {
92+ t.Fatalf("pwd = %q, want it to match %q", got, dir)
93+ }
94+}
95+
96+func TestRun_JqBuiltin(t *testing.T) {
97+ var stdout bytes.Buffer
98+ err := Run(t.Context(), RunOptions{
99+ Command: `echo '{"a":1}' | jq .a`,
100+ Cwd: t.TempDir(),
101+ Stdout: &stdout,
102+ })
103+ if err != nil {
104+ t.Fatalf("Run returned error: %v", err)
105+ }
106+ if got := stdout.String(); got != "1\n" {
107+ t.Fatalf("stdout = %q, want %q", got, "1\n")
108+ }
109+}
110+
111+func TestRun_ParallelIsolation(t *testing.T) {
112+ const n = 10
113+ var wg sync.WaitGroup
114+ wg.Add(n)
115+ errs := make([]error, n)
116+ outs := make([]string, n)
117+ dirs := make([]string, n)
118+ for i := range n {
119+ dirs[i] = t.TempDir()
120+ go func(i int) {
121+ defer wg.Done()
122+ var stdout bytes.Buffer
123+ errs[i] = Run(t.Context(), RunOptions{
124+ Command: `echo "$MARKER"`,
125+ Cwd: dirs[i],
126+ Env: []string{fmt.Sprintf("MARKER=id-%d", i)},
127+ Stdout: &stdout,
128+ })
129+ outs[i] = stdout.String()
130+ }(i)
131+ }
132+ wg.Wait()
133+ for i := range n {
134+ if errs[i] != nil {
135+ t.Errorf("goroutine %d: err = %v", i, errs[i])
136+ continue
137+ }
138+ want := fmt.Sprintf("id-%d\n", i)
139+ if outs[i] != want {
140+ t.Errorf("goroutine %d: stdout = %q, want %q", i, outs[i], want)
141+ }
142+ }
143+}
144+
145+// TestRun_CtxCancel_BusyLoop verifies that a pure-shell loop respects ctx
146+// cancellation. mvdan's interpreter checks ctx between statements, so this
147+// should return quickly even without any external command. The test bounds
148+// its own wait via a select so a regression can't hang CI.
149+func TestRun_CtxCancel_BusyLoop(t *testing.T) {
150+ ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond)
151+ t.Cleanup(cancel)
152+
153+ done := make(chan error, 1)
154+ go func() {
155+ done <- Run(ctx, RunOptions{
156+ Command: "while true; do :; done",
157+ Cwd: t.TempDir(),
158+ })
159+ }()
160+
161+ select {
162+ case err := <-done:
163+ if !IsInterrupt(err) && !errors.Is(err, context.DeadlineExceeded) {
164+ t.Fatalf("expected interrupt/deadline error, got: %v", err)
165+ }
166+ case <-time.After(1500 * time.Millisecond):
167+ t.Fatal("Run did not return within 1.5s after ctx cancel")
168+ }
169+}
170+
171+// TestRun_CtxCancel_ExternalSleep verifies ctx cancellation reaches an
172+// external process via mvdan's default exec. Uses sleep, which lives in
173+// coreutils on Windows and /bin on Unix.
174+func TestRun_CtxCancel_ExternalSleep(t *testing.T) {
175+ ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
176+ t.Cleanup(cancel)
177+
178+ done := make(chan error, 1)
179+ start := time.Now()
180+ go func() {
181+ done <- Run(ctx, RunOptions{
182+ Command: "sleep 30",
183+ Cwd: t.TempDir(),
184+ })
185+ }()
186+
187+ select {
188+ case err := <-done:
189+ elapsed := time.Since(start)
190+ if elapsed > time.Second {
191+ t.Fatalf("sleep took too long to cancel: %v", elapsed)
192+ }
193+ if err == nil {
194+ t.Fatal("expected non-nil error from cancelled sleep")
195+ }
196+ case <-time.After(time.Second):
197+ t.Fatal("Run did not return within 1s after ctx cancel")
198+ }
199+}
200+
201+func TestRun_ParseError(t *testing.T) {
202+ err := Run(t.Context(), RunOptions{
203+ Command: "echo 'unterminated",
204+ Cwd: t.TempDir(),
205+ })
206+ if err == nil {
207+ t.Fatal("expected parse error, got nil")
208+ }
209+ if !strings.Contains(err.Error(), "parse") {
210+ t.Fatalf("error should mention parse: %v", err)
211+ }
212+}
213+
214+func TestRun_BlockFuncs(t *testing.T) {
215+ block := CommandsBlocker([]string{"forbidden"})
216+ var stderr bytes.Buffer
217+ err := Run(t.Context(), RunOptions{
218+ Command: "forbidden",
219+ Cwd: t.TempDir(),
220+ Stderr: &stderr,
221+ BlockFuncs: []BlockFunc{block},
222+ })
223+ if err == nil {
224+ t.Fatal("expected error when running blocked command")
225+ }
226+ if !strings.Contains(err.Error(), "not allowed") {
227+ t.Fatalf("expected 'not allowed' error, got: %v", err)
228+ }
229+}
230+
231+func TestRun_RequiresCwd(t *testing.T) {
232+ err := Run(t.Context(), RunOptions{
233+ Command: "echo hi",
234+ })
235+ if err == nil {
236+ t.Fatal("expected error when Cwd is empty, got nil")
237+ }
238+ if !strings.Contains(err.Error(), "Cwd is required") {
239+ t.Fatalf("error should mention Cwd requirement: %v", err)
240+ }
241+}
242+
243+func TestRun_DiscardsNilWriters(t *testing.T) {
244+ // No panic when Stdout/Stderr are nil.
245+ err := Run(t.Context(), RunOptions{
246+ Command: "echo hi; echo err >&2",
247+ Cwd: t.TempDir(),
248+ })
249+ if err != nil {
250+ t.Fatalf("Run returned error: %v", err)
251+ }
252+}
internal/shell/shell.go+5−60
@@ -21,8 +21,6 @@ import (
21 "sync"
21 "sync"
22
22
23 "github.com/charmbracelet/x/exp/slice"
23 "github.com/charmbracelet/x/exp/slice"
24− "mvdan.cc/sh/moreinterp/coreutils"
25− "mvdan.cc/sh/v3/expand"
26 "mvdan.cc/sh/v3/interp"
24 "mvdan.cc/sh/v3/interp"
27 "mvdan.cc/sh/v3/syntax"
25 "mvdan.cc/sh/v3/syntax"
28)
26)
@@ -226,52 +224,10 @@ func splitArgsFlags(parts []string) (args []string, flags []string) {
226 return args, flags
224 return args, flags
227}
225}
228
226
229−func (s *Shell) blockHandler() func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
230− return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
231− return func(ctx context.Context, args []string) error {
232− if len(args) == 0 {
233− return next(ctx, args)
234− }
235−
236− for _, blockFunc := range s.blockFuncs {
237− if blockFunc(args) {
238− return fmt.Errorf("command is not allowed for security reasons: %q", args[0])
239− }
240− }
241−
242− return next(ctx, args)
243− }
244− }
245−}
246−
247−func (s *Shell) builtinHandler() func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
248− return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
249− return func(ctx context.Context, args []string) error {
250− if len(args) == 0 {
251− return next(ctx, args)
252− }
253−
254− // Builtins.
255− switch args[0] {
256− case "jq":
257− hc := interp.HandlerCtx(ctx)
258− return handleJQ(args, hc.Stdin, hc.Stdout, hc.Stderr)
259− default:
260− return next(ctx, args)
261− }
262− }
263− }
264−}
265−
266−// newInterp creates a new interpreter with the current shell state
227+// newInterp creates a new interpreter with the current shell state. A nil
228+// stdin is equivalent to an empty input stream.
267−func (s *Shell) newInterp(stdout, stderr io.Writer) (*interp.Runner, error) {
229+func (s *Shell) newInterp(stdin io.Reader, stdout, stderr io.Writer) (*interp.Runner, error) {
268− return interp.New(
230+ return newRunner(s.cwd, s.env, stdin, stdout, stderr, s.blockFuncs)
269− interp.StdIO(nil, stdout, stderr),
270− interp.Interactive(false),
271− interp.Env(expand.ListEnviron(s.env...)),
272− interp.Dir(s.cwd),
273− interp.ExecHandlers(s.execHandlers()...),
274− )
275}
231}
276
232
277// updateShellFromRunner updates the shell from the interpreter after execution.
233// updateShellFromRunner updates the shell from the interpreter after execution.
@@ -303,7 +259,7 @@ func (s *Shell) execCommon(ctx context.Context, command string, stdout, stderr i
303 return fmt.Errorf("could not parse command: %w", err)
259 return fmt.Errorf("could not parse command: %w", err)
304 }
260 }
305
261
306− runner, err = s.newInterp(stdout, stderr)
262+ runner, err = s.newInterp(nil, stdout, stderr)
307 if err != nil {
263 if err != nil {
308 return fmt.Errorf("could not run command: %w", err)
264 return fmt.Errorf("could not run command: %w", err)
309 }
265 }
@@ -324,17 +280,6 @@ func (s *Shell) execStream(ctx context.Context, command string, stdout, stderr i
324 return s.execCommon(ctx, command, stdout, stderr)
280 return s.execCommon(ctx, command, stdout, stderr)
325}
281}
326
282
327−func (s *Shell) execHandlers() []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
328− handlers := []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc{
329− s.builtinHandler(),
330− s.blockHandler(),
331− }
332− if useGoCoreUtils {
333− handlers = append(handlers, coreutils.ExecHandler)
334− }
335− return handlers
336−}
337−
338// IsInterrupt checks if an error is due to interruption
283// IsInterrupt checks if an error is due to interruption
339func IsInterrupt(err error) bool {
284func IsInterrupt(err error) bool {
340 return errors.Is(err, context.Canceled) ||
285 return errors.Is(err, context.Canceled) ||