1package shell
2
3import (
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.
19const 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.
44func 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.
98func 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.
109func 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}