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 are
62// an error; use ${VAR:-} to opt in to an empty fallback.
63func NewShellVariableResolver(e env.Env, opts ...ShellResolverOption) VariableResolver {
64 r := &shellVariableResolver{
65 env: e,
66 expand: shell.ExpandValue,
67 }
68 for _, opt := range opts {
69 opt(r)
70 }
71 return r
72}
73
74// ResolveValue resolves shell-style substitution anywhere in the string:
75//
76// - $(command) for command substitution, with full quoting and nesting.
77// - $VAR and ${VAR} for environment variables.
78// - ${VAR:-default} / ${VAR:+alt} / ${VAR:?msg} for defaulting.
79//
80// Unset variables are a hard error (nounset), mirroring the historical
81// behaviour of this resolver: silently expanding an unset variable to the
82// empty string is exactly how broken credentials reach MCP servers.
83func (r *shellVariableResolver) ResolveValue(value string) (string, error) {
84 // Preserve the historical backward-compat contract: a lone "$" is a
85 // malformed config value, not a legal literal. The underlying shell
86 // parser would accept it as a literal; we reject it here so existing
87 // configs that relied on this validation still fail early.
88 if value == "$" {
89 return "", fmt.Errorf("invalid value format: %s", value)
90 }
91
92 ctx, cancel := context.WithTimeout(context.Background(), resolveTimeout)
93 defer cancel()
94
95 out, err := r.expand(ctx, value, r.env.Env())
96 if err != nil {
97 return "", sanitizeResolveError(value, err)
98 }
99 return out, nil
100}
101
102// maxResolveErrBytes bounds the size of the inner error message surfaced
103// from a resolution failure. Defense-in-depth on top of shell.ExpandValue's
104// own stderr budget: a custom Expander injected via WithExpander, or any
105// future non-shell error path, must still produce a user-safe message.
106const maxResolveErrBytes = 512
107
108// sanitizeResolveError wraps an expansion error with the user-written
109// template (the pre-expansion string — it is what they typed, safe to
110// surface) and a bounded, scrubbed rendering of the inner error message.
111// Contract:
112//
113// - Never includes the resolved (post-expansion) value. This helper
114// only receives the template and err, so a successful expansion
115// result cannot reach it.
116// - May include the template verbatim.
117// - Truncates the inner error's message to maxResolveErrBytes and
118// replaces embedded NULs and other non-printables (except tab and
119// newline) with '?'.
120//
121// The returned error still unwraps to the original for errors.Is/As so
122// callers can inspect typed sentinels; only the rendered message is
123// scrubbed.
124func sanitizeResolveError(template string, err error) error {
125 if err == nil {
126 return nil
127 }
128 return &resolveError{
129 template: template,
130 msg: scrubErrorMessage(err.Error()),
131 inner: err,
132 }
133}
134
135// resolveError is the concrete type returned by sanitizeResolveError.
136// Its Error() method returns the template + scrubbed inner message;
137// Unwrap exposes the original error so errors.Is/As continue to work.
138type resolveError struct {
139 template string
140 msg string
141 inner error
142}
143
144func (e *resolveError) Error() string {
145 return fmt.Sprintf("resolving %q: %s", e.template, e.msg)
146}
147
148func (e *resolveError) Unwrap() error { return e.inner }
149
150// scrubErrorMessage bounds the message to maxResolveErrBytes bytes and
151// replaces non-printable bytes (anything outside ASCII printable, tab, or
152// newline) with '?'. Mirrors shell.sanitizeStderr but operates on a
153// string rather than raw command stderr and runs at the config layer,
154// so arbitrary Expander error text is also sanitized.
155func scrubErrorMessage(s string) string {
156 if len(s) > maxResolveErrBytes {
157 s = s[:maxResolveErrBytes]
158 }
159 out := make([]byte, len(s))
160 for i := 0; i < len(s); i++ {
161 c := s[i]
162 if c == '\t' || c == '\n' || (c >= 0x20 && c < 0x7f) {
163 out[i] = c
164 continue
165 }
166 out[i] = '?'
167 }
168 return string(out)
169}
170
171type environmentVariableResolver struct {
172 env env.Env
173}
174
175func NewEnvironmentVariableResolver(env env.Env) VariableResolver {
176 return &environmentVariableResolver{
177 env: env,
178 }
179}
180
181// ResolveValue resolves environment variables from the provided env.Env.
182func (r *environmentVariableResolver) ResolveValue(value string) (string, error) {
183 if len(value) == 0 || value[0] != '$' {
184 return value, nil
185 }
186
187 varName := value[1:]
188 resolvedValue := r.env.Get(varName)
189 if resolvedValue == "" {
190 return "", fmt.Errorf("environment variable %q not set", varName)
191 }
192 return resolvedValue, nil
193}