on main help sign in
feat(shell): add ExpandValue for config value shell expansion

Groundwork for replacing the hand-rolled parser in internal/config/resolve.go. Introduces shell.ExpandValue, a single-value expansion entry point built on mvdan.cc/sh/v3's syntax + expand packages. Runs with nounset on and globbing off, preserves internal whitespace, strips only trailing newlines from command substitution output, and bounds/scrubs inner stderr surfaced in errors. Shares the builtin/block/coreutils handler chain with NewShell but uses the caller-provided env verbatim.

commit711d3a384f27bdf4548579292f8ae80d34e5e30f authorChristian Rocha <christian@rocha.is> dateSat 2 May 2026, 09:26 (6 months ago) parent56b192e
2 files changed+322
internal/shell/expand.go+123 internal/shell/expand_test.go+199
internal/shell/expand.gonew file+123
@@ -0,0 +1,123 @@
1+package shell
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "io"
8+ "os"
9+ "strings"
10+
11+ "mvdan.cc/sh/v3/expand"
12+ "mvdan.cc/sh/v3/interp"
13+ "mvdan.cc/sh/v3/syntax"
14+)
15+
16+// maxInnerStderrBytes bounds how much stderr from a failing $(...) is
17+// surfaced in the returned error, to avoid leaking a secret that happened
18+// to be embedded in a failing inner command.
19+const maxInnerStderrBytes = 512
20+
21+// ExpandValue expands shell-style substitutions in a single config value.
22+//
23+// Supported constructs match the bash tool:
24+//
25+// - $VAR and ${VAR} (unset is an error; see nounset below).
26+// - ${VAR:-default} / ${VAR:+alt} / ${VAR:?msg}.
27+// - $(command) with full quoting and nesting.
28+// - escaped and quoted strings ("...", '...').
29+//
30+// Contract:
31+//
32+// - Returns exactly one string. No field splitting, no globbing, no
33+// pathname generation. Multi-word command output is preserved
34+// verbatim; it is never split into multiple values.
35+// - Nounset is on: unset variables produce an error instead of
36+// expanding to the empty string. Use ${VAR:-default} to opt in to
37+// an empty fallback.
38+// - Embedded whitespace and newlines in the input are preserved
39+// verbatim. Command substitution strips trailing newlines only
40+// (POSIX), never leading or internal whitespace.
41+// - Errors wrap the failing inner command's exit code and a bounded
42+// prefix of its stderr. Callers that surface the error to users
43+// should additionally scrub it for the original template text.
44+func ExpandValue(ctx context.Context, value string, env []string) (string, error) {
45+ // Parse the value as a here-doc style word: no word splitting, no
46+ // globbing, but full support for $VAR, ${VAR...}, $(...), and
47+ // quoted/escaped strings.
48+ word, err := syntax.NewParser().Document(strings.NewReader(value))
49+ if err != nil {
50+ return "", fmt.Errorf("parse: %w", err)
51+ }
52+
53+ // Build a minimal Shell value purely to reuse its handler chain
54+ // (builtins, block funcs, optional Go coreutils) inside $(...).
55+ // We deliberately skip NewShell so the passed-in env is used
56+ // verbatim, with no CRUSH/AGENT/AI_AGENT injection: callers of
57+ // ExpandValue control the env, and nounset must treat any name
58+ // not in env as unset.
59+ cwd, _ := os.Getwd()
60+ s := &Shell{
61+ cwd: cwd,
62+ env: env,
63+ logger: noopLogger{},
64+ }
65+
66+ var stderrBuf bytes.Buffer
67+ cfg := &expand.Config{
68+ Env: expand.ListEnviron(env...),
69+ NoUnset: true,
70+ CmdSubst: func(w io.Writer, cs *syntax.CmdSubst) error {
71+ stderrBuf.Reset()
72+ runner, rerr := interp.New(
73+ interp.StdIO(nil, w, &stderrBuf),
74+ interp.Interactive(false),
75+ interp.Env(expand.ListEnviron(env...)),
76+ interp.Dir(s.cwd),
77+ interp.ExecHandlers(s.execHandlers()...),
78+ // Match the outer NoUnset: an unset $VAR inside
79+ // $(...) is also an error, not a silent empty.
80+ interp.Params("-u"),
81+ )
82+ if rerr != nil {
83+ return rerr
84+ }
85+ if rerr := runner.Run(ctx, &syntax.File{Stmts: cs.Stmts}); rerr != nil {
86+ return wrapCmdSubstErr(rerr, stderrBuf.Bytes())
87+ }
88+ return nil
89+ },
90+ // ReadDir / ReadDir2 left nil: globbing is disabled.
91+ }
92+
93+ return expand.Document(cfg, word)
94+}
95+
96+// wrapCmdSubstErr attaches a bounded prefix of the inner command's stderr
97+// to the original error, if any.
98+func wrapCmdSubstErr(err error, stderrBytes []byte) error {
99+ msg := sanitizeStderr(stderrBytes)
100+ if msg == "" {
101+ return err
102+ }
103+ return fmt.Errorf("%w: %s", err, msg)
104+}
105+
106+// sanitizeStderr trims, bounds, and scrubs non-printable bytes from the
107+// stderr of a failing command so the result is safe to include in an
108+// error message shown to the user.
109+func sanitizeStderr(b []byte) string {
110+ b = bytes.TrimRight(b, "\n")
111+ if len(b) > maxInnerStderrBytes {
112+ b = b[:maxInnerStderrBytes]
113+ }
114+ out := make([]byte, len(b))
115+ for i, c := range b {
116+ if c == '\t' || c == '\n' || (c >= 0x20 && c < 0x7f) {
117+ out[i] = c
118+ } else {
119+ out[i] = '?'
120+ }
121+ }
122+ return string(out)
123+}
internal/shell/expand_test.gonew file+199
@@ -0,0 +1,199 @@
1+package shell
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestExpandValue_Success(t *testing.T) {
11+ t.Parallel()
12+
13+ tests := []struct {
14+ name string
15+ value string
16+ env []string
17+ want string
18+ }{
19+ {
20+ name: "plain string round trip",
21+ value: "hello world",
22+ want: "hello world",
23+ },
24+ {
25+ name: "plain var from env",
26+ value: "$FOO",
27+ env: []string{"FOO=bar"},
28+ want: "bar",
29+ },
30+ {
31+ name: "braced var from env",
32+ value: "pre-${FOO}-post",
33+ env: []string{"FOO=bar"},
34+ want: "pre-bar-post",
35+ },
36+ {
37+ name: "default syntax on unset",
38+ value: "${MISSING:-fallback}",
39+ want: "fallback",
40+ },
41+ {
42+ name: "default syntax on set preserves value",
43+ value: "${SET:-fallback}",
44+ env: []string{"SET=used"},
45+ want: "used",
46+ },
47+ {
48+ name: "command substitution",
49+ value: "$(echo hi)",
50+ want: "hi",
51+ },
52+ {
53+ name: "command substitution preserves internal spaces",
54+ value: `$(echo "a b")`,
55+ want: "a b",
56+ },
57+ {
58+ name: "command substitution strips only trailing newline",
59+ value: "$(printf 'a\\nb\\n')",
60+ want: "a\nb",
61+ },
62+ {
63+ name: "literal spaces around cmdsubst are preserved",
64+ value: " $(echo v) ",
65+ want: " v ",
66+ },
67+ {
68+ name: "paren inside quoted arg to echo",
69+ value: `$(echo ")")`,
70+ want: ")",
71+ },
72+ {
73+ name: "nested command substitution",
74+ value: "$(echo $(echo hi))",
75+ want: "hi",
76+ },
77+ {
78+ name: "glob-like input round trips unchanged",
79+ value: "*.go",
80+ want: "*.go",
81+ },
82+ {
83+ name: "tilde round trips unchanged",
84+ value: "~",
85+ want: "~",
86+ },
87+ {
88+ name: "env var inside cmdsubst",
89+ value: `$(printf '%s' "$FOO")`,
90+ env: []string{"FOO=bar"},
91+ want: "bar",
92+ },
93+ }
94+
95+ for _, tc := range tests {
96+ t.Run(tc.name, func(t *testing.T) {
97+ t.Parallel()
98+ got, err := ExpandValue(t.Context(), tc.value, tc.env)
99+ require.NoError(t, err)
100+ require.Equal(t, tc.want, got)
101+ })
102+ }
103+}
104+
105+func TestExpandValue_Errors(t *testing.T) {
106+ t.Parallel()
107+
108+ t.Run("unset var is an error, not empty", func(t *testing.T) {
109+ t.Parallel()
110+ _, err := ExpandValue(t.Context(), "$MISSING", nil)
111+ require.Error(t, err)
112+ })
113+
114+ t.Run("unset var inside braces is an error", func(t *testing.T) {
115+ t.Parallel()
116+ _, err := ExpandValue(t.Context(), "${MISSING}", nil)
117+ require.Error(t, err)
118+ })
119+
120+ t.Run("unset var inside cmdsubst is an error", func(t *testing.T) {
121+ t.Parallel()
122+ _, err := ExpandValue(t.Context(), `$(printf '%s' "$MISSING")`, nil)
123+ require.Error(t, err)
124+ })
125+
126+ t.Run("bad syntax returns error", func(t *testing.T) {
127+ t.Parallel()
128+ _, err := ExpandValue(t.Context(), "$(", nil)
129+ require.Error(t, err)
130+ })
131+
132+ t.Run("inner non-zero exit returns error with exit code", func(t *testing.T) {
133+ t.Parallel()
134+ _, err := ExpandValue(t.Context(), "$(false)", nil)
135+ require.Error(t, err)
136+ require.Contains(t, err.Error(), "exit status 1")
137+ })
138+
139+ t.Run("inner explicit exit code is surfaced", func(t *testing.T) {
140+ t.Parallel()
141+ _, err := ExpandValue(t.Context(), "$(exit 7)", nil)
142+ require.Error(t, err)
143+ require.Contains(t, err.Error(), "exit status 7")
144+ })
145+
146+ t.Run("inner stderr is surfaced", func(t *testing.T) {
147+ t.Parallel()
148+ _, err := ExpandValue(
149+ t.Context(),
150+ `$(printf 'boom\n' 1>&2; exit 1)`,
151+ nil,
152+ )
153+ require.Error(t, err)
154+ require.Contains(t, err.Error(), "boom")
155+ })
156+
157+ t.Run("inner stderr is truncated to byte budget", func(t *testing.T) {
158+ t.Parallel()
159+ // Emit more than maxInnerStderrBytes bytes of 'X' on stderr.
160+ long := strings.Repeat("X", maxInnerStderrBytes*2)
161+ _, err := ExpandValue(
162+ t.Context(),
163+ `$(printf '`+long+`' 1>&2; exit 1)`,
164+ nil,
165+ )
166+ require.Error(t, err)
167+ require.NotContains(
168+ t,
169+ err.Error(),
170+ strings.Repeat("X", maxInnerStderrBytes+1),
171+ "stderr should be bounded",
172+ )
173+ })
174+}
175+
176+func TestSanitizeStderr(t *testing.T) {
177+ t.Parallel()
178+
179+ t.Run("trims trailing newlines", func(t *testing.T) {
180+ t.Parallel()
181+ require.Equal(t, "hi", sanitizeStderr([]byte("hi\n\n")))
182+ })
183+
184+ t.Run("preserves tabs and embedded newlines", func(t *testing.T) {
185+ t.Parallel()
186+ require.Equal(t, "a\tb\nc", sanitizeStderr([]byte("a\tb\nc")))
187+ })
188+
189+ t.Run("replaces control characters", func(t *testing.T) {
190+ t.Parallel()
191+ require.Equal(t, "a?b", sanitizeStderr([]byte{'a', 0x01, 'b'}))
192+ })
193+
194+ t.Run("bounds output", func(t *testing.T) {
195+ t.Parallel()
196+ got := sanitizeStderr([]byte(strings.Repeat("x", maxInnerStderrBytes*2)))
197+ require.Len(t, got, maxInnerStderrBytes)
198+ })
199+}