1package shell
2
3import (
4 "bytes"
5 "context"
6 "fmt"
7 "io"
8 "os"
9 "strings"
10 "sync/atomic"
11
12 "mvdan.cc/sh/v3/expand"
13 "mvdan.cc/sh/v3/interp"
14 "mvdan.cc/sh/v3/syntax"
15)
16
17// maxInnerStderrBytes bounds how much stderr from a failing $(...) is
18// surfaced in the returned error, to avoid leaking a secret that happened
19// to be embedded in a failing inner command.
20const maxInnerStderrBytes = 512
21
22// NoUnset controls whether ExpandValue treats unset variables as an
23// error. Default false matches bash: $UNSET expands to "". Store true
24// to re-enable strict mode globally. Not exposed in crush.json; this is
25// an internal escape hatch in case the lenient default turns out to be
26// the wrong call.
27//
28// Declared atomic because ExpandValue is invoked concurrently (multiple
29// MCP / LSP / provider loads in flight at startup, hook execution, etc.)
30// and an unsynchronised read/write pair is a data race under the Go
31// memory model regardless of test-level happens-before reasoning. The
32// atomic load on the hot path is negligible against the cost of parsing
33// and running through mvdan.
34//
35// See PLAN.md Phase 2 design decisions #11 and #12 for the full
36// rationale.
37var NoUnset atomic.Bool
38
39// ExpandValue expands shell-style substitutions in a single config value.
40//
41// Supported constructs match the bash tool:
42//
43// - $VAR and ${VAR}.
44// - ${VAR:-default} / ${VAR:+alt} / ${VAR:?msg}.
45// - $(command) with full quoting and nesting.
46// - escaped and quoted strings ("...", '...').
47//
48// Contract:
49//
50// - Returns exactly one string. No field splitting, no globbing, no
51// pathname generation. Multi-word command output is preserved
52// verbatim; it is never split into multiple values.
53// - Nounset is off by default, matching bash: unset variables expand
54// to "". Opt in to strict behaviour per-reference with
55// ${VAR:?msg}, which errors loudly when VAR is unset regardless of
56// the global toggle. Flip the global default via
57// shell.NoUnset.Store(true) as an internal escape hatch.
58// - Embedded whitespace and newlines in the input are preserved
59// verbatim. Command substitution strips trailing newlines only
60// (POSIX), never leading or internal whitespace.
61// - Errors wrap the failing inner command's exit code and a bounded
62// prefix of its stderr. Callers that surface the error to users
63// should additionally scrub it for the original template text.
64func ExpandValue(ctx context.Context, value string, env []string) (string, error) {
65 // Parse the value as a here-doc style word: no word splitting, no
66 // globbing, but full support for $VAR, ${VAR...}, $(...), and
67 // quoted/escaped strings.
68 word, err := syntax.NewParser().Document(strings.NewReader(value))
69 if err != nil {
70 return "", fmt.Errorf("parse: %w", err)
71 }
72
73 // Build a minimal Shell value purely to reuse its handler chain
74 // (builtins, block funcs, optional Go coreutils) inside $(...).
75 // We deliberately skip NewShell so the passed-in env is used
76 // verbatim, with no CRUSH/AGENT/AI_AGENT injection: callers of
77 // ExpandValue control the env, and nounset must treat any name
78 // not in env as unset.
79 cwd, _ := os.Getwd()
80 s := &Shell{
81 cwd: cwd,
82 env: env,
83 logger: noopLogger{},
84 }
85
86 strict := NoUnset.Load()
87
88 var stderrBuf bytes.Buffer
89 cfg := &expand.Config{
90 Env: expand.ListEnviron(env...),
91 NoUnset: strict,
92 CmdSubst: func(w io.Writer, cs *syntax.CmdSubst) error {
93 stderrBuf.Reset()
94 runnerOpts := []interp.RunnerOption{
95 interp.StdIO(nil, w, &stderrBuf),
96 interp.Interactive(false),
97 interp.Env(expand.ListEnviron(env...)),
98 interp.Dir(s.cwd),
99 interp.ExecHandlers(standardHandlers(s.blockFuncs)...),
100 }
101 if strict {
102 // Match the outer NoUnset: an unset $VAR inside
103 // $(...) is also an error, not a silent empty.
104 runnerOpts = append(runnerOpts, interp.Params("-u"))
105 }
106 runner, rerr := interp.New(runnerOpts...)
107 if rerr != nil {
108 return rerr
109 }
110 if rerr := runner.Run(ctx, &syntax.File{Stmts: cs.Stmts}); rerr != nil {
111 return wrapCmdSubstErr(rerr, stderrBuf.Bytes())
112 }
113 return nil
114 },
115 // ReadDir / ReadDir2 left nil: globbing is disabled.
116 }
117
118 return expand.Document(cfg, word)
119}
120
121// wrapCmdSubstErr attaches a bounded prefix of the inner command's stderr
122// to the original error, if any.
123func wrapCmdSubstErr(err error, stderrBytes []byte) error {
124 msg := sanitizeStderr(stderrBytes)
125 if msg == "" {
126 return err
127 }
128 return fmt.Errorf("%w: %s", err, msg)
129}
130
131// sanitizeStderr trims, bounds, and scrubs non-printable bytes from the
132// stderr of a failing command so the result is safe to include in an
133// error message shown to the user.
134func sanitizeStderr(b []byte) string {
135 b = bytes.TrimRight(b, "\n")
136 if len(b) > maxInnerStderrBytes {
137 b = b[:maxInnerStderrBytes]
138 }
139 out := make([]byte, len(b))
140 for i, c := range b {
141 if c == '\t' || c == '\n' || (c >= 0x20 && c < 0x7f) {
142 out[i] = c
143 } else {
144 out[i] = '?'
145 }
146 }
147 return string(out)
148}