resolve_test.go

  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 TestEnvironmentVariableResolver_ResolveValue(t *testing.T) {
224	tests := []struct {
225		name        string
226		value       string
227		envVars     map[string]string
228		expected    string
229		expectError bool
230	}{
231		{
232			name:     "non-variable string returns as-is",
233			value:    "plain-string",
234			expected: "plain-string",
235		},
236		{
237			name:     "environment variable resolution",
238			value:    "$HOME",
239			envVars:  map[string]string{"HOME": "/home/user"},
240			expected: "/home/user",
241		},
242		{
243			name:     "environment variable with complex value",
244			value:    "$PATH",
245			envVars:  map[string]string{"PATH": "/usr/bin:/bin:/usr/local/bin"},
246			expected: "/usr/bin:/bin:/usr/local/bin",
247		},
248		{
249			name:        "missing environment variable returns error",
250			value:       "$MISSING_VAR",
251			envVars:     map[string]string{},
252			expectError: true,
253		},
254		{
255			name:        "empty environment variable returns error",
256			value:       "$EMPTY_VAR",
257			envVars:     map[string]string{"EMPTY_VAR": ""},
258			expectError: true,
259		},
260	}
261
262	for _, tt := range tests {
263		t.Run(tt.name, func(t *testing.T) {
264			testEnv := env.NewFromMap(tt.envVars)
265			resolver := NewEnvironmentVariableResolver(testEnv)
266
267			result, err := resolver.ResolveValue(tt.value)
268
269			if tt.expectError {
270				require.Error(t, err)
271			} else {
272				require.NoError(t, err)
273				require.Equal(t, tt.expected, result)
274			}
275		})
276	}
277}
278
279func TestNewShellVariableResolver(t *testing.T) {
280	testEnv := env.NewFromMap(map[string]string{"TEST": "value"})
281	resolver := NewShellVariableResolver(testEnv)
282
283	require.NotNil(t, resolver)
284	require.Implements(t, (*VariableResolver)(nil), resolver)
285}
286
287func TestNewEnvironmentVariableResolver(t *testing.T) {
288	testEnv := env.NewFromMap(map[string]string{"TEST": "value"})
289	resolver := NewEnvironmentVariableResolver(testEnv)
290
291	require.NotNil(t, resolver)
292	require.Implements(t, (*VariableResolver)(nil), resolver)
293}