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