expand.go

  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}