resolve_real_test.go

  1package config
  2
  3import (
  4	"fmt"
  5	"maps"
  6	"os"
  7	"path/filepath"
  8	"slices"
  9	"strings"
 10	"testing"
 11
 12	"github.com/charmbracelet/crush/internal/env"
 13	"github.com/stretchr/testify/require"
 14)
 15
 16// These tests exercise the full shell-expansion path (no mocks,
 17// no injected Expander) to catch regressions that only surface when
 18// internal/shell actually runs the value. Table-level unit tests with
 19// fake expanders live in resolve_test.go.
 20
 21// realShellResolver builds a resolver backed by a shell env that
 22// contains PATH + the caller-supplied overrides. Production callers
 23// get PATH for free via env.New(); these tests need it so $(cat ...)
 24// and similar inner commands can resolve.
 25func realShellResolver(vars map[string]string) VariableResolver {
 26	m := map[string]string{"PATH": os.Getenv("PATH")}
 27	maps.Copy(m, vars)
 28	return NewShellVariableResolver(env.NewFromMap(m))
 29}
 30
 31func writeTempFile(t *testing.T, content string) string {
 32	t.Helper()
 33	dir := t.TempDir()
 34	p := filepath.Join(dir, "secret")
 35	require.NoError(t, os.WriteFile(p, []byte(content), 0o600))
 36	return p
 37}
 38
 39// TestResolvedEnv_RealShell_Success covers the shell constructs the
 40// PLAN calls out: $(cat tempfile) with and without trailing newline,
 41// ${VAR:-default} for unset vars, literal-space preservation around
 42// $(...), nested parens, quoted args inside $(echo ...), and a
 43// glob-like literal round-tripping unchanged.
 44func TestResolvedEnv_RealShell_Success(t *testing.T) {
 45	t.Parallel()
 46
 47	// filepath.ToSlash so Windows temp paths (C:\Users\...) survive
 48	// being injected into a shell command string — the embedded shell
 49	// treats backslashes as escapes, forward slashes work on every OS.
 50	withNL := filepath.ToSlash(writeTempFile(t, "token-with-nl\n"))
 51	noNL := filepath.ToSlash(writeTempFile(t, "token-no-nl"))
 52
 53	m := MCPConfig{
 54		Env: map[string]string{
 55			// POSIX strips exactly one trailing newline from $(...)
 56			// output, so both forms land on the same value.
 57			"TOK_NL": fmt.Sprintf("$(cat %s)", withNL),
 58			"TOK_NO": fmt.Sprintf("$(cat %s)", noNL),
 59
 60			// ${VAR:-default} must not error on unset: this is the
 61			// opt-in escape hatch for "empty is fine".
 62			"FALLBACK": "${MCP_MISSING:-fallback}",
 63
 64			// Leading/trailing literal spaces around $(...) must be
 65			// preserved — single-value contract, no field splitting.
 66			"PADDED": "  $(echo v)  ",
 67
 68			// ")" inside a quoted arg to echo is a regression guard
 69			// for the old hand-rolled paren matcher.
 70			"PAREN": `$(echo ")")`,
 71
 72			// Embedded space inside a quoted arg must survive
 73			// verbatim; no word-splitting side effect.
 74			"SPACEY": `$(echo "a b")`,
 75
 76			// Glob-like literals must not expand.
 77			"GLOB": "*.go",
 78		},
 79	}
 80
 81	got, err := m.ResolvedEnv(realShellResolver(nil))
 82	require.NoError(t, err)
 83
 84	// ResolvedEnv returns "KEY=value" sorted by key.
 85	want := []string{
 86		"FALLBACK=fallback",
 87		"GLOB=*.go",
 88		"PADDED=  v  ",
 89		"PAREN=)",
 90		"SPACEY=a b",
 91		"TOK_NL=token-with-nl",
 92		"TOK_NO=token-no-nl",
 93	}
 94	require.Equal(t, want, got)
 95}
 96
 97// TestResolvedEnv_RealShell_DoesNotMutate pins that both success and
 98// failure paths leave m.Env untouched. Prior behaviour rewrote the
 99// value in place on error; that was the exact mechanism that shipped
100// empty credentials to MCP servers.
101func TestResolvedEnv_RealShell_DoesNotMutate(t *testing.T) {
102	t.Parallel()
103
104	t.Run("success path leaves Env untouched", func(t *testing.T) {
105		t.Parallel()
106		m := MCPConfig{Env: map[string]string{"TOKEN": "$(echo shh)"}}
107		orig := maps.Clone(m.Env)
108
109		_, err := m.ResolvedEnv(realShellResolver(nil))
110		require.NoError(t, err)
111		require.Equal(t, orig, m.Env)
112	})
113
114	t.Run("failure path leaves Env untouched", func(t *testing.T) {
115		t.Parallel()
116		m := MCPConfig{Env: map[string]string{"BROKEN": "$(false)"}}
117		orig := maps.Clone(m.Env)
118
119		_, err := m.ResolvedEnv(realShellResolver(nil))
120		require.Error(t, err)
121		require.Equal(t, orig, m.Env, "map must be preserved on error")
122	})
123}
124
125// TestResolvedEnv_RealShell_Idempotent pins the pure-function contract:
126// two calls on the same config return deeply-equal slices.
127func TestResolvedEnv_RealShell_Idempotent(t *testing.T) {
128	t.Parallel()
129
130	m := MCPConfig{
131		Env: map[string]string{
132			"A": "$(echo one)",
133			"B": "$(echo two)",
134			"C": "literal",
135		},
136	}
137	r := realShellResolver(nil)
138
139	first, err := m.ResolvedEnv(r)
140	require.NoError(t, err)
141	second, err := m.ResolvedEnv(r)
142	require.NoError(t, err)
143	require.Equal(t, first, second)
144}
145
146// TestResolvedEnv_RealShell_Deterministic guards against Go's
147// randomized map iteration leaking into the returned slice order.
148func TestResolvedEnv_RealShell_Deterministic(t *testing.T) {
149	t.Parallel()
150
151	m := MCPConfig{Env: map[string]string{
152		"Z": "z",
153		"A": "a",
154		"M": "m",
155	}}
156
157	got, err := m.ResolvedEnv(realShellResolver(nil))
158	require.NoError(t, err)
159	require.True(t, slices.IsSorted(got), "env slice must be sorted; got %v", got)
160}
161
162// TestResolvedEnv_RealShell_UnsetExpandsEmpty pins Phase 2's lenient
163// default: an unset bare $VAR expands to the empty string, matching
164// bash. The silent-empty-credential class of bug that motivated Phase
165// 1's nounset-on default is already prevented by the pure-function
166// error-returning contract of ResolvedEnv, so we no longer rely on
167// nounset to catch typo'd variable names. Users who want strict
168// behaviour for a required credential opt in per-reference with
169// ${VAR:?msg}; see TestResolvedEnv_RealShell_ColonQuestionIsStrict.
170func TestResolvedEnv_RealShell_UnsetExpandsEmpty(t *testing.T) {
171	t.Parallel()
172
173	m := MCPConfig{Env: map[string]string{
174		// Intentional typo: user meant $FORGEJO_TOKEN. Under Phase 2
175		// defaults this expands to "" rather than erroring, matching
176		// bash's behaviour on bare $VAR.
177		"FORGEJO_ACCESS_TOKEN": "$FORGJO_TOKEN",
178	}}
179	got, err := m.ResolvedEnv(realShellResolver(nil))
180	require.NoError(t, err, "unset var must expand to empty, not error")
181	require.Equal(t, []string{"FORGEJO_ACCESS_TOKEN="}, got)
182}
183
184// TestResolvedEnv_RealShell_ColonQuestionIsStrict pins the opt-in
185// strictness contract: ${VAR:?msg} must hard-error when VAR is unset,
186// regardless of the global NoUnset toggle. This is the recommended
187// mechanism for required credentials under the lenient default, so a
188// future refactor that accidentally swallows ${VAR:?...} errors would
189// silently ship empty tokens to MCP servers again.
190func TestResolvedEnv_RealShell_ColonQuestionIsStrict(t *testing.T) {
191	t.Parallel()
192
193	m := MCPConfig{Env: map[string]string{
194		"FORGEJO_ACCESS_TOKEN": "${FORGJO_TOKEN:?set FORGJO_TOKEN}",
195	}}
196	got, err := m.ResolvedEnv(realShellResolver(nil))
197	require.Error(t, err, "${VAR:?msg} must error when VAR is unset")
198	require.Nil(t, got)
199	// The resolver wraps with the env key and the user-written
200	// template; the inner shell error carries the :? message so
201	// users learn which credential is missing and why.
202	msg := err.Error()
203	require.Contains(t, msg, "FORGEJO_ACCESS_TOKEN")
204	require.Contains(t, msg, "${FORGJO_TOKEN:?set FORGJO_TOKEN}")
205	require.Contains(t, msg, "set FORGJO_TOKEN")
206}
207
208// TestResolvedEnv_RealShell_FailureDetail pins that a failing inner
209// command surfaces enough detail (exit code + stderr on POSIX, the
210// underlying OS error on Windows where coreutils runs in-process) to
211// diagnose without forcing the user to re-run the command by hand.
212// Also verifies the template is included so they know which Env
213// entry blew up.
214func TestResolvedEnv_RealShell_FailureDetail(t *testing.T) {
215	t.Parallel()
216
217	// Forward slashes so the path survives shell-string injection on
218	// Windows; see TestResolvedEnv_RealShell_Success for the same note.
219	missing := filepath.ToSlash(filepath.Join(t.TempDir(), "definitely-not-here"))
220	m := MCPConfig{Env: map[string]string{
221		"FORGEJO_ACCESS_TOKEN": fmt.Sprintf("$(cat %s)", missing),
222	}}
223
224	_, err := m.ResolvedEnv(realShellResolver(nil))
225	require.Error(t, err)
226	msg := err.Error()
227	require.Contains(t, msg, "FORGEJO_ACCESS_TOKEN", "must identify the failing env var")
228	require.Contains(t, msg, missing, "must include the template so users see what failed")
229
230	// Inner diagnostic detail must survive. POSIX surfaces "exit
231	// status N" + stderr; Windows' in-process coreutils surfaces the
232	// Go OS error instead. Accept either shape so the test is
233	// portable without weakening the intent.
234	lower := strings.ToLower(msg)
235	hasDetail := strings.Contains(lower, "exit status") ||
236		strings.Contains(lower, "no such file") ||
237		strings.Contains(lower, "cannot find")
238	require.True(t, hasDetail, "must surface inner error detail: %s", msg)
239}
240
241// TestResolvedHeaders_RealShell_FailurePreservesOriginal pins two
242// invariants simultaneously: on failure the returned map is nil (not
243// a partially-populated map) and the receiver's Headers map is
244// unchanged. A test that only asserted on the returned value could
245// hide an in-place mutation regression.
246func TestResolvedHeaders_RealShell_FailurePreservesOriginal(t *testing.T) {
247	t.Parallel()
248
249	m := MCPConfig{Headers: map[string]string{
250		"Authorization": "Bearer $(false)",
251		"X-Static":      "kept",
252	}}
253	orig := maps.Clone(m.Headers)
254
255	got, err := m.ResolvedHeaders(realShellResolver(nil))
256	require.Error(t, err)
257	require.Nil(t, got, "headers map must be nil on failure")
258	require.Contains(t, err.Error(), "header Authorization")
259	require.Equal(t, orig, m.Headers, "receiver Headers must be preserved")
260}
261
262// TestResolvedHeaders_RealShell_DropEmpty pins Phase 2 design
263// decision #18 on the MCP side: a header whose value resolves to the
264// empty string is omitted from the returned map. Covers the three
265// ways a value can legitimately land on empty — unset bare $VAR
266// under lenient nounset, a literal "", and a non-failing command
267// whose stdout is empty — and also pins that a failing $(cmd) still
268// errors rather than silently dropping.
269func TestResolvedHeaders_RealShell_DropEmpty(t *testing.T) {
270	t.Parallel()
271
272	t.Run("unset $VAR is absent", func(t *testing.T) {
273		t.Parallel()
274		m := MCPConfig{Headers: map[string]string{
275			"X-Missing": "$MCP_HEADER_NEVER_SET",
276			"X-Kept":    "present",
277		}}
278		got, err := m.ResolvedHeaders(realShellResolver(nil))
279		require.NoError(t, err)
280		_, present := got["X-Missing"]
281		require.False(t, present, "unset bare $VAR → empty → header dropped")
282		require.Equal(t, "present", got["X-Kept"])
283	})
284
285	t.Run("literal empty string is absent", func(t *testing.T) {
286		t.Parallel()
287		m := MCPConfig{Headers: map[string]string{
288			"X-Custom": "",
289			"X-Kept":   "present",
290		}}
291		got, err := m.ResolvedHeaders(realShellResolver(nil))
292		require.NoError(t, err)
293		_, present := got["X-Custom"]
294		require.False(t, present, "literal empty-string header must be dropped")
295		require.Equal(t, "present", got["X-Kept"])
296	})
297
298	t.Run("$(echo) is absent", func(t *testing.T) {
299		t.Parallel()
300		m := MCPConfig{Headers: map[string]string{
301			"X-Empty": "$(echo)",
302			"X-Kept":  "present",
303		}}
304		got, err := m.ResolvedHeaders(realShellResolver(nil))
305		require.NoError(t, err)
306		_, present := got["X-Empty"]
307		require.False(t, present, "$(echo) → empty → header dropped")
308		require.Equal(t, "present", got["X-Kept"])
309	})
310
311	t.Run("$(false) errors and does not mutate", func(t *testing.T) {
312		t.Parallel()
313		m := MCPConfig{Headers: map[string]string{
314			"X-Broken": "$(false)",
315			"X-Kept":   "present",
316		}}
317		orig := maps.Clone(m.Headers)
318
319		got, err := m.ResolvedHeaders(realShellResolver(nil))
320		require.Error(t, err)
321		require.Empty(t, got, "map must be nil/empty on failure, not a partial")
322		require.Contains(t, err.Error(), "header X-Broken")
323		require.Equal(t, orig, m.Headers, "receiver Headers must be preserved")
324	})
325}
326
327// TestResolvedArgs_RealShell exercises both success and failure for
328// m.Args symmetrically with Env. Args are ordered so error messages
329// must identify a positional index, not a key.
330func TestResolvedArgs_RealShell(t *testing.T) {
331	t.Parallel()
332
333	t.Run("success expands each element", func(t *testing.T) {
334		t.Parallel()
335		m := MCPConfig{Args: []string{"--token", "$(echo shh)", "--host", "example.com"}}
336		got, err := m.ResolvedArgs(realShellResolver(nil))
337		require.NoError(t, err)
338		require.Equal(t, []string{"--token", "shh", "--host", "example.com"}, got)
339	})
340
341	t.Run("failure identifies offending index", func(t *testing.T) {
342		t.Parallel()
343		m := MCPConfig{Args: []string{"--token", "$(false)"}}
344		orig := slices.Clone(m.Args)
345
346		got, err := m.ResolvedArgs(realShellResolver(nil))
347		require.Error(t, err)
348		require.Nil(t, got)
349		require.Contains(t, err.Error(), "arg 1")
350		require.Equal(t, orig, m.Args, "receiver Args must be preserved")
351	})
352
353	t.Run("nil args returns nil, no error", func(t *testing.T) {
354		t.Parallel()
355		m := MCPConfig{}
356		got, err := m.ResolvedArgs(realShellResolver(nil))
357		require.NoError(t, err)
358		require.Nil(t, got)
359	})
360}
361
362// TestLSPConfig_ResolvedArgs_RealShell exercises both success and
363// failure for l.Args symmetrically with MCP args. Args are ordered so
364// error messages must identify a positional index, not a key.
365func TestLSPConfig_ResolvedArgs_RealShell(t *testing.T) {
366	t.Parallel()
367
368	t.Run("success expands $VAR in each element", func(t *testing.T) {
369		t.Parallel()
370		l := LSPConfig{Args: []string{"--root", "$HOME", "--flag", "literal"}}
371		r := realShellResolver(map[string]string{"HOME": "/home/tester"})
372		got, err := l.ResolvedArgs(r)
373		require.NoError(t, err)
374		require.Equal(t, []string{"--root", "/home/tester", "--flag", "literal"}, got)
375	})
376
377	t.Run("failure identifies offending index", func(t *testing.T) {
378		t.Parallel()
379		l := LSPConfig{Args: []string{"--root", "$(false)"}}
380		orig := slices.Clone(l.Args)
381
382		got, err := l.ResolvedArgs(realShellResolver(nil))
383		require.Error(t, err)
384		require.Nil(t, got)
385		require.Contains(t, err.Error(), "arg 1")
386		require.Equal(t, orig, l.Args, "receiver Args must be preserved")
387	})
388
389	t.Run("nil args returns nil, no error", func(t *testing.T) {
390		t.Parallel()
391		l := LSPConfig{}
392		got, err := l.ResolvedArgs(realShellResolver(nil))
393		require.NoError(t, err)
394		require.Nil(t, got)
395	})
396}
397
398// TestLSPConfig_ResolvedEnv_RealShell pins the LSP env contract:
399// success expands $VAR, failure wraps with the key name, and the
400// receiver map is never mutated. The shape is map[string]string
401// (not the MCP []string form) because powernap.ClientConfig.Environment
402// takes a map directly.
403func TestLSPConfig_ResolvedEnv_RealShell(t *testing.T) {
404	t.Parallel()
405
406	t.Run("success expands $VAR", func(t *testing.T) {
407		t.Parallel()
408		l := LSPConfig{Env: map[string]string{"GOPATH": "$HOME/go"}}
409		r := realShellResolver(map[string]string{"HOME": "/home/tester"})
410		got, err := l.ResolvedEnv(r)
411		require.NoError(t, err)
412		require.Equal(t, map[string]string{"GOPATH": "/home/tester/go"}, got)
413	})
414
415	t.Run("failure identifies offending key", func(t *testing.T) {
416		t.Parallel()
417		l := LSPConfig{Env: map[string]string{
418			"GOPATH": "$(false)",
419			"OTHER":  "literal",
420		}}
421		orig := maps.Clone(l.Env)
422
423		got, err := l.ResolvedEnv(realShellResolver(nil))
424		require.Error(t, err)
425		require.Nil(t, got)
426		require.Contains(t, err.Error(), `env "GOPATH"`)
427		require.Equal(t, orig, l.Env, "receiver Env must be preserved")
428	})
429
430	t.Run("idempotent and non-mutating", func(t *testing.T) {
431		t.Parallel()
432		l := LSPConfig{Env: map[string]string{
433			"A": "$(echo one)",
434			"B": "literal",
435		}}
436		orig := maps.Clone(l.Env)
437		r := realShellResolver(nil)
438
439		first, err := l.ResolvedEnv(r)
440		require.NoError(t, err)
441		second, err := l.ResolvedEnv(r)
442		require.NoError(t, err)
443		require.Equal(t, first, second)
444		require.Equal(t, orig, l.Env, "receiver Env must be preserved")
445	})
446}
447
448// TestLSPConfig_IdentityResolver pins the client-mode contract: both
449// ResolvedArgs and ResolvedEnv round-trip the template verbatim under
450// IdentityResolver and never error on unset variables. Local
451// expansion would double-expand when the server does its own — this
452// has to stay a pure pass-through.
453func TestLSPConfig_IdentityResolver(t *testing.T) {
454	t.Parallel()
455
456	l := LSPConfig{
457		Args: []string{"--root", "$LSP_ROOT", "$(vault read -f lsp)"},
458		Env: map[string]string{
459			"GOPATH": "$HOME/go",
460			"TOKEN":  "$(cat /run/secrets/x)",
461		},
462	}
463	r := IdentityResolver()
464
465	args, err := l.ResolvedArgs(r)
466	require.NoError(t, err)
467	require.Equal(t, l.Args, args)
468
469	envs, err := l.ResolvedEnv(r)
470	require.NoError(t, err)
471	require.Equal(t, l.Env, envs)
472}
473
474// TestMCPConfig_IdentityResolver pins the client-mode contract: every
475// Resolved* method round-trips the template verbatim and never errors
476// on unset variables. Local expansion would double-expand when the
477// server does its own — this has to stay a pure pass-through.
478func TestMCPConfig_IdentityResolver(t *testing.T) {
479	t.Parallel()
480
481	m := MCPConfig{
482		Command: "$CMD",
483		Args:    []string{"--token", "$MCP_MISSING_TOKEN", "$(vault read -f secret)"},
484		Env: map[string]string{
485			"TOKEN": "$(cat /run/secrets/x)",
486			"HOST":  "$MCP_MISSING_HOST",
487		},
488		Headers: map[string]string{
489			"Authorization": "Bearer $(vault read -f token)",
490		},
491		URL: "https://$MCP_HOST/$(vault read -f path)",
492	}
493	r := IdentityResolver()
494
495	args, err := m.ResolvedArgs(r)
496	require.NoError(t, err)
497	require.Equal(t, m.Args, args)
498
499	envs, err := m.ResolvedEnv(r)
500	require.NoError(t, err)
501	// Sorted "KEY=value".
502	require.Equal(t, []string{
503		"HOST=$MCP_MISSING_HOST",
504		"TOKEN=$(cat /run/secrets/x)",
505	}, envs)
506
507	headers, err := m.ResolvedHeaders(r)
508	require.NoError(t, err)
509	require.Equal(t, m.Headers, headers)
510
511	u, err := m.ResolvedURL(r)
512	require.NoError(t, err)
513	require.Equal(t, m.URL, u)
514}