resolve.go

  1package config
  2
  3import (
  4	"context"
  5	"fmt"
  6	"time"
  7
  8	"github.com/charmbracelet/crush/internal/env"
  9	"github.com/charmbracelet/crush/internal/shell"
 10)
 11
 12// resolveTimeout bounds how long a single ResolveValue call may spend
 13// inside shell expansion (including any command substitution). Matches
 14// the timeout used by the previous hand-rolled parser.
 15const resolveTimeout = 5 * time.Minute
 16
 17type VariableResolver interface {
 18	ResolveValue(value string) (string, error)
 19}
 20
 21// identityResolver is a no-op resolver that returns values unchanged.
 22// Used in client mode where variable resolution is handled server-side.
 23type identityResolver struct{}
 24
 25func (identityResolver) ResolveValue(value string) (string, error) {
 26	return value, nil
 27}
 28
 29// IdentityResolver returns a VariableResolver that passes values through
 30// unchanged.
 31func IdentityResolver() VariableResolver {
 32	return identityResolver{}
 33}
 34
 35// Expander is the single-value shell expansion seam used by
 36// shellVariableResolver. Production wires it to shell.ExpandValue; tests
 37// can inject a fake via WithExpander.
 38type Expander func(ctx context.Context, value string, env []string) (string, error)
 39
 40// ShellResolverOption customizes shell variable resolver construction.
 41type ShellResolverOption func(*shellVariableResolver)
 42
 43// WithExpander overrides the expansion function used by the resolver.
 44// Primarily intended for tests; production callers should not need this.
 45func WithExpander(e Expander) ShellResolverOption {
 46	return func(r *shellVariableResolver) {
 47		if e != nil {
 48			r.expand = e
 49		}
 50	}
 51}
 52
 53type shellVariableResolver struct {
 54	env    env.Env
 55	expand Expander
 56}
 57
 58// NewShellVariableResolver returns a VariableResolver that delegates to
 59// the embedded shell (the same interpreter used by the bash tool and
 60// hooks). Supported constructs match shell.ExpandValue: $VAR, ${VAR},
 61// ${VAR:-default}, $(command), quoting, and escapes. Unset variables
 62// expand to the empty string by default, matching bash; use
 63// ${VAR:?message} to require a value and fail loudly when it is missing.
 64// The stricter "unset is always an error" mode is gated globally by
 65// shell.NoUnset.
 66func NewShellVariableResolver(e env.Env, opts ...ShellResolverOption) VariableResolver {
 67	r := &shellVariableResolver{
 68		env:    e,
 69		expand: shell.ExpandValue,
 70	}
 71	for _, opt := range opts {
 72		opt(r)
 73	}
 74	return r
 75}
 76
 77// ResolveValue resolves shell-style substitution anywhere in the string:
 78//
 79//   - $(command) for command substitution, with full quoting and nesting.
 80//   - $VAR and ${VAR} for environment variables.
 81//   - ${VAR:-default} / ${VAR:+alt} / ${VAR:?msg} for defaulting.
 82//
 83// Unset variables expand to the empty string by default, matching bash.
 84// Command-substitution failures are always a hard error. Required
 85// credentials should use ${VAR:?message} so a missing variable fails
 86// loudly at load time instead of quietly resolving to empty. Global
 87// strict mode is available via shell.NoUnset for callers that want the
 88// old nounset-on behaviour back.
 89func (r *shellVariableResolver) ResolveValue(value string) (string, error) {
 90	// Preserve the historical backward-compat contract: a lone "$" is a
 91	// malformed config value, not a legal literal. The underlying shell
 92	// parser would accept it as a literal; we reject it here so existing
 93	// configs that relied on this validation still fail early.
 94	if value == "$" {
 95		return "", fmt.Errorf("invalid value format: %s", value)
 96	}
 97
 98	ctx, cancel := context.WithTimeout(context.Background(), resolveTimeout)
 99	defer cancel()
100
101	out, err := r.expand(ctx, value, r.env.Env())
102	if err != nil {
103		return "", sanitizeResolveError(value, err)
104	}
105	return out, nil
106}
107
108// maxResolveErrBytes bounds the size of the inner error message surfaced
109// from a resolution failure. Defense-in-depth on top of shell.ExpandValue's
110// own stderr budget: a custom Expander injected via WithExpander, or any
111// future non-shell error path, must still produce a user-safe message.
112const maxResolveErrBytes = 512
113
114// sanitizeResolveError wraps an expansion error with the user-written
115// template (the pre-expansion string — it is what they typed, safe to
116// surface) and a bounded, scrubbed rendering of the inner error message.
117// Contract:
118//
119//   - Never includes the resolved (post-expansion) value. This helper
120//     only receives the template and err, so a successful expansion
121//     result cannot reach it.
122//   - May include the template verbatim.
123//   - Truncates the inner error's message to maxResolveErrBytes and
124//     replaces embedded NULs and other non-printables (except tab and
125//     newline) with '?'.
126//
127// The returned error still unwraps to the original for errors.Is/As so
128// callers can inspect typed sentinels; only the rendered message is
129// scrubbed.
130func sanitizeResolveError(template string, err error) error {
131	if err == nil {
132		return nil
133	}
134	return &resolveError{
135		template: template,
136		msg:      scrubErrorMessage(err.Error()),
137		inner:    err,
138	}
139}
140
141// resolveError is the concrete type returned by sanitizeResolveError.
142// Its Error() method returns the template + scrubbed inner message;
143// Unwrap exposes the original error so errors.Is/As continue to work.
144type resolveError struct {
145	template string
146	msg      string
147	inner    error
148}
149
150func (e *resolveError) Error() string {
151	return fmt.Sprintf("resolving %q: %s", e.template, e.msg)
152}
153
154func (e *resolveError) Unwrap() error { return e.inner }
155
156// scrubErrorMessage bounds the message to maxResolveErrBytes bytes and
157// replaces non-printable bytes (anything outside ASCII printable, tab, or
158// newline) with '?'. Mirrors shell.sanitizeStderr but operates on a
159// string rather than raw command stderr and runs at the config layer,
160// so arbitrary Expander error text is also sanitized.
161func scrubErrorMessage(s string) string {
162	if len(s) > maxResolveErrBytes {
163		s = s[:maxResolveErrBytes]
164	}
165	out := make([]byte, len(s))
166	for i := 0; i < len(s); i++ {
167		c := s[i]
168		if c == '\t' || c == '\n' || (c >= 0x20 && c < 0x7f) {
169			out[i] = c
170			continue
171		}
172		out[i] = '?'
173	}
174	return string(out)
175}
176
177type environmentVariableResolver struct {
178	env env.Env
179}
180
181func NewEnvironmentVariableResolver(env env.Env) VariableResolver {
182	return &environmentVariableResolver{
183		env: env,
184	}
185}
186
187// ResolveValue resolves environment variables from the provided env.Env.
188func (r *environmentVariableResolver) ResolveValue(value string) (string, error) {
189	if len(value) == 0 || value[0] != '$' {
190		return value, nil
191	}
192
193	varName := value[1:]
194	resolvedValue := r.env.Get(varName)
195	if resolvedValue == "" {
196		return "", fmt.Errorf("environment variable %q not set", varName)
197	}
198	return resolvedValue, nil
199}