1package config
2
3import (
4 "context"
5 "errors"
6 "strings"
7 "testing"
8
9 "github.com/charmbracelet/crush/internal/env"
10 "github.com/stretchr/testify/require"
11)
12
13// fakeExpander returns a canned value/error for the last passed value and
14// records the context, raw value, and env slice it was called with. It
15// lets the config-layer tests assert on delegation behaviour without
16// spinning up a real interpreter — real-shell coverage lives in
17// internal/shell/expand_test.go and resolve_real_test.go.
18type fakeExpander struct {
19 expand func(ctx context.Context, value string, env []string) (string, error)
20 lastValue string
21 lastEnv []string
22 calls int
23}
24
25func (f *fakeExpander) Expand(ctx context.Context, value string, env []string) (string, error) {
26 f.calls++
27 f.lastValue = value
28 f.lastEnv = env
29 if f.expand == nil {
30 return value, nil
31 }
32 return f.expand(ctx, value, env)
33}
34
35func TestShellVariableResolver_DelegatesToExpander(t *testing.T) {
36 t.Parallel()
37
38 fe := &fakeExpander{
39 expand: func(_ context.Context, value string, _ []string) (string, error) {
40 if value == "hello $FOO" {
41 return "hello bar", nil
42 }
43 return value, nil
44 },
45 }
46
47 e := env.NewFromMap(map[string]string{"FOO": "bar"})
48 r := NewShellVariableResolver(e, WithExpander(fe.Expand))
49
50 got, err := r.ResolveValue("hello $FOO")
51 require.NoError(t, err)
52 require.Equal(t, "hello bar", got)
53 require.Equal(t, 1, fe.calls)
54 require.Equal(t, "hello $FOO", fe.lastValue)
55 require.Contains(t, fe.lastEnv, "FOO=bar")
56}
57
58func TestShellVariableResolver_LoneDollarIsError(t *testing.T) {
59 t.Parallel()
60
61 // Lone "$" must short-circuit before reaching the expander: the
62 // underlying shell parser would accept it as a literal, but this
63 // resolver has historically rejected it and callers depend on
64 // that early-fail behaviour.
65 fe := &fakeExpander{}
66 r := NewShellVariableResolver(env.NewFromMap(nil), WithExpander(fe.Expand))
67
68 _, err := r.ResolveValue("$")
69 require.Error(t, err)
70 require.Equal(t, 0, fe.calls, "expander must not be called for lone $")
71}
72
73func TestShellVariableResolver_PassesThroughLiterals(t *testing.T) {
74 t.Parallel()
75
76 fe := &fakeExpander{
77 expand: func(_ context.Context, value string, _ []string) (string, error) {
78 return value, nil
79 },
80 }
81 r := NewShellVariableResolver(env.NewFromMap(nil), WithExpander(fe.Expand))
82
83 got, err := r.ResolveValue("plain-string")
84 require.NoError(t, err)
85 require.Equal(t, "plain-string", got)
86}
87
88func TestShellVariableResolver_WrapsErrorsWithTemplate(t *testing.T) {
89 t.Parallel()
90
91 inner := errors.New("cat: /run/secrets/x: permission denied")
92 fe := &fakeExpander{
93 expand: func(_ context.Context, _ string, _ []string) (string, error) {
94 return "", inner
95 },
96 }
97 r := NewShellVariableResolver(env.NewFromMap(nil), WithExpander(fe.Expand))
98
99 _, err := r.ResolveValue("$(cat /run/secrets/x)")
100 require.Error(t, err)
101 require.ErrorIs(t, err, inner)
102 require.Contains(t, err.Error(), "$(cat /run/secrets/x)")
103 require.Contains(t, err.Error(), "permission denied")
104}
105
106func TestSanitizeResolveError(t *testing.T) {
107 t.Parallel()
108
109 t.Run("nil passes through", func(t *testing.T) {
110 t.Parallel()
111 require.NoError(t, sanitizeResolveError("anything", nil))
112 })
113
114 t.Run("includes template and wraps inner", func(t *testing.T) {
115 t.Parallel()
116 inner := errors.New("cat: /run/secrets/x: permission denied")
117 got := sanitizeResolveError("$(cat /run/secrets/x)", inner)
118 require.Error(t, got)
119 require.ErrorIs(t, got, inner)
120 require.Contains(t, got.Error(), "$(cat /run/secrets/x)")
121 require.Contains(t, got.Error(), "permission denied")
122 })
123
124 t.Run("unwrap preserves original for errors.Is", func(t *testing.T) {
125 t.Parallel()
126 inner := errors.New("sentinel")
127 got := sanitizeResolveError("$FOO", inner)
128 require.ErrorIs(t, got, inner)
129 })
130
131 t.Run("truncates over-budget inner message", func(t *testing.T) {
132 t.Parallel()
133 // Inner message holds far more than the budget. After
134 // sanitization the rendered inner portion must not exceed
135 // maxResolveErrBytes, and the characters beyond the budget
136 // (marked by a distinct tail sentinel) must be gone.
137 const tailSentinel = "TAIL_SENTINEL_BEYOND_BUDGET"
138 body := strings.Repeat("x", maxResolveErrBytes)
139 inner := errors.New(body + tailSentinel)
140
141 got := sanitizeResolveError("$TEMPLATE", inner)
142 require.Error(t, got)
143
144 prefix := `resolving "$TEMPLATE": `
145 rendered := got.Error()
146 require.True(
147 t,
148 strings.HasPrefix(rendered, prefix),
149 "rendered error must start with template prefix",
150 )
151 innerRendered := strings.TrimPrefix(rendered, prefix)
152 require.LessOrEqual(
153 t,
154 len(innerRendered),
155 maxResolveErrBytes,
156 "inner message must be bounded to maxResolveErrBytes",
157 )
158 require.NotContains(
159 t,
160 rendered,
161 tailSentinel,
162 "content past the budget must not leak",
163 )
164 })
165
166 t.Run("replaces non-printable bytes", func(t *testing.T) {
167 t.Parallel()
168 // NUL, BEL, ESC, DEL, and a UTF-8 high byte should all be
169 // scrubbed to '?'. Tab and newline are preserved because
170 // they show up legitimately in command stderr.
171 inner := errors.New("ok\x00bad\x07\x1b\x7f\xffend\ttab\nline")
172 got := sanitizeResolveError("$T", inner)
173 rendered := got.Error()
174
175 require.NotContains(t, rendered, "\x00")
176 require.NotContains(t, rendered, "\x07")
177 require.NotContains(t, rendered, "\x1b")
178 require.NotContains(t, rendered, "\x7f")
179 require.NotContains(t, rendered, "\xff")
180 require.Contains(t, rendered, "ok?bad????end\ttab\nline")
181 })
182
183 t.Run("scrubbing does not depend on shell.ExpandValue upstream", func(t *testing.T) {
184 t.Parallel()
185 // A custom Expander can inject arbitrary error text. The
186 // config-layer helper is the single chokepoint; it must
187 // bound + scrub regardless of the error source.
188 nasty := strings.Repeat("A", maxResolveErrBytes+64) + "\x00BEYOND"
189 fe := &fakeExpander{
190 expand: func(_ context.Context, _ string, _ []string) (string, error) {
191 return "", errors.New(nasty)
192 },
193 }
194 r := NewShellVariableResolver(env.NewFromMap(nil), WithExpander(fe.Expand))
195
196 _, err := r.ResolveValue("$T")
197 require.Error(t, err)
198 require.NotContains(t, err.Error(), "BEYOND", "over-budget tail must not leak")
199 require.NotContains(t, err.Error(), "\x00", "non-printables must be scrubbed")
200 })
201}
202
203func TestScrubErrorMessage(t *testing.T) {
204 t.Parallel()
205
206 t.Run("bounds output to maxResolveErrBytes", func(t *testing.T) {
207 t.Parallel()
208 got := scrubErrorMessage(strings.Repeat("a", maxResolveErrBytes*3))
209 require.Len(t, got, maxResolveErrBytes)
210 })
211
212 t.Run("preserves printable ASCII tab and newline", func(t *testing.T) {
213 t.Parallel()
214 require.Equal(t, "a\tb\nc d!", scrubErrorMessage("a\tb\nc d!"))
215 })
216
217 t.Run("replaces control and non-ASCII bytes", func(t *testing.T) {
218 t.Parallel()
219 require.Equal(t, "a?b??c", scrubErrorMessage("a\x01b\x1b\xe2c"))
220 })
221}
222
223func TestNewShellVariableResolver(t *testing.T) {
224 testEnv := env.NewFromMap(map[string]string{"TEST": "value"})
225 resolver := NewShellVariableResolver(testEnv)
226
227 require.NotNil(t, resolver)
228 require.Implements(t, (*VariableResolver)(nil), resolver)
229}