@@ -3,260 +3,221 @@ package config
8 "github.com/charmbracelet/crush/internal/env"
9 "github.com/charmbracelet/crush/internal/env"
9 "github.com/stretchr/testify/require"
10 "github.com/stretchr/testify/require"
12−// mockShell implements the Shell interface for testing
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.
13−type mockShell struct {
18+type fakeExpander struct {
14− execFunc func(ctx context.Context, command string) (stdout, stderr string, err error)
19+ expand func(ctx context.Context, value string, env []string) (string, error)
17−func (m *mockShell) Exec(ctx context.Context, command string) (stdout, stderr string, err error) {
25+func (f *fakeExpander) Expand(ctx context.Context, value string, env []string) (string, error) {
18− if m.execFunc != nil {
26+ f.calls++
19− return m.execFunc(ctx, command)
27+ f.lastValue = value
21− return "", "", nil
32+ return f.expand(ctx, value, env)
24−func TestShellVariableResolver_ResolveValue(t *testing.T) {
35+func TestShellVariableResolver_DelegatesToExpander(t *testing.T) {
25− tests := []struct {
36+ t.Parallel()
28− envVars map[string]string
29− shellFunc func(ctx context.Context, command string) (stdout, stderr string, err error)
34− name: "non-variable string returns as-is",
35− value: "plain-string",
36− expected: "plain-string",
39− name: "environment variable resolution",
41− envVars: map[string]string{"HOME": "/home/user"},
42− expected: "/home/user",
45− name: "missing environment variable returns error",
46− value: "$MISSING_VAR",
47− envVars: map[string]string{},
51− {
38+ fe := &fakeExpander{
52− name: "shell command with whitespace trimming",
53− value: "$(echo ' spaced ')",
54− shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
39+ expand: func(_ context.Context, value string, _ []string) (string, error) {
55− if command == "echo ' spaced '" {
40+ if value == "hello $FOO" {
56− return " spaced \n", "", nil
41+ return "hello bar", nil
58− return "", "", errors.New("unexpected command")
63− name: "shell command execution error",
65− shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
66− return "", "", errors.New("command failed")
71− name: "invalid format returns error",
72− value: "$",
43+ return value, nil
77− for _, tt := range tests {
78− t.Run(tt.name, func(t *testing.T) {
79− testEnv := env.NewFromMap(tt.envVars)
47+ e := env.NewFromMap(map[string]string{"FOO": "bar"})
80− resolver := &shellVariableResolver{
48+ r := NewShellVariableResolver(e, WithExpander(fe.Expand))
81− shell: &mockShell{execFunc: tt.shellFunc},
85− result, err := resolver.ResolveValue(tt.value)
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")
87− if tt.expectError {
58+func TestShellVariableResolver_LoneDollarIsError(t *testing.T) {
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))
68+ _, err := r.ResolveValue("$")
88− require.Error(t, err)
69+ require.Error(t, err)
89− } else {
70+ require.Equal(t, 0, fe.calls, "expander must not be called for lone $")
90− require.NoError(t, err)
91− require.Equal(t, tt.expected, result)
97−func TestShellVariableResolver_EnhancedResolveValue(t *testing.T) {
73+func TestShellVariableResolver_PassesThroughLiterals(t *testing.T) {
98− tests := []struct {
74+ t.Parallel()
100− value string
76+ fe := &fakeExpander{
101− envVars map[string]string
77+ expand: func(_ context.Context, value string, _ []string) (string, error) {
102− shellFunc func(ctx context.Context, command string) (stdout, stderr string, err error)
107− name: "command substitution within string",
108− value: "Bearer $(echo token123)",
109− shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
110− if command == "echo token123" {
111− return "token123\n", "", nil
113− return "", "", errors.New("unexpected command")
115− expected: "Bearer token123",
118− name: "environment variable within string",
119− value: "Bearer $TOKEN",
120− envVars: map[string]string{"TOKEN": "sk-ant-123"},
121− expected: "Bearer sk-ant-123",
124− name: "environment variable with braces within string",
125− value: "Bearer ${TOKEN}",
126− envVars: map[string]string{"TOKEN": "sk-ant-456"},
127− expected: "Bearer sk-ant-456",
130− name: "mixed command and environment substitution",
131− value: "$USER-$(date +%Y)-$HOST",
132− envVars: map[string]string{
134− "HOST": "localhost",
136− shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
137− if command == "date +%Y" {
138− return "2024\n", "", nil
140− return "", "", errors.New("unexpected command")
142− expected: "testuser-2024-localhost",
145− name: "multiple command substitutions",
146− value: "$(echo hello) $(echo world)",
147− shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
150− return "hello\n", "", nil
78+ return value, nil
152− return "world\n", "", nil
154− return "", "", errors.New("unexpected command")
156− expected: "hello world",
159− name: "nested parentheses in command",
160− value: "$(echo $(echo inner))",
161− shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
162− if command == "echo $(echo inner)" {
163− return "nested\n", "", nil
165− return "", "", errors.New("unexpected command")
170− name: "lone dollar with non-variable chars",
171− value: "prefix$123suffix", // Numbers can't start variable names
175− name: "dollar with special chars",
176− value: "a$@b$#c", // Special chars aren't valid in variable names
180− name: "empty environment variable substitution",
181− value: "Bearer $EMPTY_VAR",
182− envVars: map[string]string{},
186− name: "unmatched command substitution opening",
187− value: "Bearer $(echo test",
191− name: "unmatched environment variable braces",
192− value: "Bearer ${TOKEN",
196− name: "command substitution with error",
197− value: "Bearer $(false)",
198− shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
199− return "", "", errors.New("command failed")
204− name: "complex real-world example",
205− value: "Bearer $(cat /tmp/token.txt | base64 -w 0)",
206− shellFunc: func(ctx context.Context, command string) (stdout, stderr string, err error) {
207− if command == "cat /tmp/token.txt | base64 -w 0" {
208− return "c2stYW50LXRlc3Q=\n", "", nil
210− return "", "", errors.New("unexpected command")
212− expected: "Bearer c2stYW50LXRlc3Q=",
215− name: "environment variable with underscores and numbers",
216− value: "Bearer $API_KEY_V2",
217− envVars: map[string]string{"API_KEY_V2": "sk-test-123"},
218− expected: "Bearer sk-test-123",
221− name: "no substitution needed",
222− value: "Bearer sk-ant-static-token",
223− expected: "Bearer sk-ant-static-token",
226− name: "incomplete variable at end",
231− name: "variable with invalid character",
232− value: "Bearer $VAR-NAME", // Hyphen not allowed in variable names
236− name: "multiple invalid variables",
81+ r := NewShellVariableResolver(env.NewFromMap(nil), WithExpander(fe.Expand))
242− for _, tt := range tests {
83+ got, err := r.ResolveValue("plain-string")
243− t.Run(tt.name, func(t *testing.T) {
84+ require.NoError(t, err)
244− testEnv := env.NewFromMap(tt.envVars)
85+ require.Equal(t, "plain-string", got)
245− resolver := &shellVariableResolver{
246− shell: &mockShell{execFunc: tt.shellFunc},
250− result, err := resolver.ResolveValue(tt.value)
88+func TestShellVariableResolver_WrapsErrorsWithTemplate(t *testing.T) {
252− if tt.expectError {
91+ inner := errors.New("cat: /run/secrets/x: permission denied")
253− require.Error(t, err)
92+ fe := &fakeExpander{
254− } else {
93+ expand: func(_ context.Context, _ string, _ []string) (string, error) {
255− require.NoError(t, err)
94+ return "", inner
256− require.Equal(t, tt.expected, result)
97+ r := NewShellVariableResolver(env.NewFromMap(nil), WithExpander(fe.Expand))
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")
106+func TestSanitizeResolveError(t *testing.T) {
109+ t.Run("nil passes through", func(t *testing.T) {
111+ require.NoError(t, sanitizeResolveError("anything", nil))
114+ t.Run("includes template and wraps inner", func(t *testing.T) {
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")
124+ t.Run("unwrap preserves original for errors.Is", func(t *testing.T) {
126+ inner := errors.New("sentinel")
127+ got := sanitizeResolveError("$FOO", inner)
128+ require.ErrorIs(t, got, inner)
131+ t.Run("truncates over-budget inner message", func(t *testing.T) {
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)
141+ got := sanitizeResolveError("$TEMPLATE", inner)
142+ require.Error(t, got)
144+ prefix := `resolving "$TEMPLATE": `
145+ rendered := got.Error()
148+ strings.HasPrefix(rendered, prefix),
149+ "rendered error must start with template prefix",
151+ innerRendered := strings.TrimPrefix(rendered, prefix)
152+ require.LessOrEqual(
156+ "inner message must be bounded to maxResolveErrBytes",
158+ require.NotContains(
162+ "content past the budget must not leak",
166+ t.Run("replaces non-printable bytes", func(t *testing.T) {
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()
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")
183+ t.Run("scrubbing does not depend on shell.ExpandValue upstream", func(t *testing.T) {
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)
194+ r := NewShellVariableResolver(env.NewFromMap(nil), WithExpander(fe.Expand))
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")
203+func TestScrubErrorMessage(t *testing.T) {
206+ t.Run("bounds output to maxResolveErrBytes", func(t *testing.T) {
208+ got := scrubErrorMessage(strings.Repeat("a", maxResolveErrBytes*3))
209+ require.Len(t, got, maxResolveErrBytes)
212+ t.Run("preserves printable ASCII tab and newline", func(t *testing.T) {
214+ require.Equal(t, "a\tb\nc d!", scrubErrorMessage("a\tb\nc d!"))
217+ t.Run("replaces control and non-ASCII bytes", func(t *testing.T) {
219+ require.Equal(t, "a?b??c", scrubErrorMessage("a\x01b\x1b\xe2c"))
262func TestEnvironmentVariableResolver_ResolveValue(t *testing.T) {
223func TestEnvironmentVariableResolver_ResolveValue(t *testing.T) {