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 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}