expand.go

  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}