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 the lenient
163// default: an unset bare $VAR expands to the empty string, matching
164// bash. The silent-empty-credential class of bug is prevented by the
165// pure-function error-returning contract of ResolvedEnv, so we don't
166// rely on nounset to catch typo'd variable names. Users who want
167// strict behaviour for a required credential opt in per-reference
168// with ${VAR:?msg}; see TestResolvedEnv_RealShell_ColonQuestionIsStrict.
169func TestResolvedEnv_RealShell_UnsetExpandsEmpty(t *testing.T) {
170 t.Parallel()
171
172 m := MCPConfig{Env: map[string]string{
173 // Intentional typo: user meant $FORGEJO_TOKEN. Under the
174 // lenient default this expands to "" rather than erroring,
175 // matching bash's behaviour on bare $VAR.
176 "FORGEJO_ACCESS_TOKEN": "$FORGJO_TOKEN",
177 }}
178 got, err := m.ResolvedEnv(realShellResolver(nil))
179 require.NoError(t, err, "unset var must expand to empty, not error")
180 require.Equal(t, []string{"FORGEJO_ACCESS_TOKEN="}, got)
181}
182
183// TestResolvedEnv_RealShell_ColonQuestionIsStrict pins the opt-in
184// strictness contract: ${VAR:?msg} must hard-error when VAR is unset,
185// regardless of the global NoUnset toggle. This is the recommended
186// mechanism for required credentials under the lenient default, so a
187// future refactor that accidentally swallows ${VAR:?...} errors would
188// silently ship empty tokens to MCP servers again.
189func TestResolvedEnv_RealShell_ColonQuestionIsStrict(t *testing.T) {
190 t.Parallel()
191
192 m := MCPConfig{Env: map[string]string{
193 "FORGEJO_ACCESS_TOKEN": "${FORGJO_TOKEN:?set FORGJO_TOKEN}",
194 }}
195 got, err := m.ResolvedEnv(realShellResolver(nil))
196 require.Error(t, err, "${VAR:?msg} must error when VAR is unset")
197 require.Nil(t, got)
198 // The resolver wraps with the env key and the user-written
199 // template; the inner shell error carries the :? message so
200 // users learn which credential is missing and why.
201 msg := err.Error()
202 require.Contains(t, msg, "FORGEJO_ACCESS_TOKEN")
203 require.Contains(t, msg, "${FORGJO_TOKEN:?set FORGJO_TOKEN}")
204 require.Contains(t, msg, "set FORGJO_TOKEN")
205}
206
207// TestResolvedEnv_RealShell_FailureDetail pins that a failing inner
208// command surfaces enough detail (exit code + stderr on POSIX, the
209// underlying OS error on Windows where coreutils runs in-process) to
210// diagnose without forcing the user to re-run the command by hand.
211// Also verifies the template is included so they know which Env
212// entry blew up.
213func TestResolvedEnv_RealShell_FailureDetail(t *testing.T) {
214 t.Parallel()
215
216 // Forward slashes so the path survives shell-string injection on
217 // Windows; see TestResolvedEnv_RealShell_Success for the same note.
218 missing := filepath.ToSlash(filepath.Join(t.TempDir(), "definitely-not-here"))
219 m := MCPConfig{Env: map[string]string{
220 "FORGEJO_ACCESS_TOKEN": fmt.Sprintf("$(cat %s)", missing),
221 }}
222
223 _, err := m.ResolvedEnv(realShellResolver(nil))
224 require.Error(t, err)
225 msg := err.Error()
226 require.Contains(t, msg, "FORGEJO_ACCESS_TOKEN", "must identify the failing env var")
227 require.Contains(t, msg, missing, "must include the template so users see what failed")
228
229 // Inner diagnostic detail must survive. POSIX surfaces "exit
230 // status N" + stderr; Windows' in-process coreutils surfaces the
231 // Go OS error instead. Accept either shape so the test is
232 // portable without weakening the intent.
233 lower := strings.ToLower(msg)
234 hasDetail := strings.Contains(lower, "exit status") ||
235 strings.Contains(lower, "no such file") ||
236 strings.Contains(lower, "cannot find")
237 require.True(t, hasDetail, "must surface inner error detail: %s", msg)
238}
239
240// TestResolvedHeaders_RealShell_FailurePreservesOriginal pins two
241// invariants simultaneously: on failure the returned map is nil (not
242// a partially-populated map) and the receiver's Headers map is
243// unchanged. A test that only asserted on the returned value could
244// hide an in-place mutation regression.
245func TestResolvedHeaders_RealShell_FailurePreservesOriginal(t *testing.T) {
246 t.Parallel()
247
248 m := MCPConfig{Headers: map[string]string{
249 "Authorization": "Bearer $(false)",
250 "X-Static": "kept",
251 }}
252 orig := maps.Clone(m.Headers)
253
254 got, err := m.ResolvedHeaders(realShellResolver(nil))
255 require.Error(t, err)
256 require.Nil(t, got, "headers map must be nil on failure")
257 require.Contains(t, err.Error(), "header Authorization")
258 require.Equal(t, orig, m.Headers, "receiver Headers must be preserved")
259}
260
261// TestResolvedHeaders_RealShell_DropEmpty verifies that a header
262// whose value resolves to the empty string is omitted from the
263// returned map. Covers the three ways a value can legitimately land
264// on empty — unset bare $VAR under lenient nounset, a literal "",
265// and a non-failing command whose stdout is empty — and also pins
266// that a failing $(cmd) still errors rather than silently dropping.
267func TestResolvedHeaders_RealShell_DropEmpty(t *testing.T) {
268 t.Parallel()
269
270 t.Run("unset $VAR is absent", func(t *testing.T) {
271 t.Parallel()
272 m := MCPConfig{Headers: map[string]string{
273 "X-Missing": "$MCP_HEADER_NEVER_SET",
274 "X-Kept": "present",
275 }}
276 got, err := m.ResolvedHeaders(realShellResolver(nil))
277 require.NoError(t, err)
278 _, present := got["X-Missing"]
279 require.False(t, present, "unset bare $VAR → empty → header dropped")
280 require.Equal(t, "present", got["X-Kept"])
281 })
282
283 t.Run("literal empty string is absent", func(t *testing.T) {
284 t.Parallel()
285 m := MCPConfig{Headers: map[string]string{
286 "X-Custom": "",
287 "X-Kept": "present",
288 }}
289 got, err := m.ResolvedHeaders(realShellResolver(nil))
290 require.NoError(t, err)
291 _, present := got["X-Custom"]
292 require.False(t, present, "literal empty-string header must be dropped")
293 require.Equal(t, "present", got["X-Kept"])
294 })
295
296 t.Run("$(echo) is absent", func(t *testing.T) {
297 t.Parallel()
298 m := MCPConfig{Headers: map[string]string{
299 "X-Empty": "$(echo)",
300 "X-Kept": "present",
301 }}
302 got, err := m.ResolvedHeaders(realShellResolver(nil))
303 require.NoError(t, err)
304 _, present := got["X-Empty"]
305 require.False(t, present, "$(echo) → empty → header dropped")
306 require.Equal(t, "present", got["X-Kept"])
307 })
308
309 t.Run("$(false) errors and does not mutate", func(t *testing.T) {
310 t.Parallel()
311 m := MCPConfig{Headers: map[string]string{
312 "X-Broken": "$(false)",
313 "X-Kept": "present",
314 }}
315 orig := maps.Clone(m.Headers)
316
317 got, err := m.ResolvedHeaders(realShellResolver(nil))
318 require.Error(t, err)
319 require.Empty(t, got, "map must be nil/empty on failure, not a partial")
320 require.Contains(t, err.Error(), "header X-Broken")
321 require.Equal(t, orig, m.Headers, "receiver Headers must be preserved")
322 })
323}
324
325// TestResolvedArgs_RealShell exercises both success and failure for
326// m.Args symmetrically with Env. Args are ordered so error messages
327// must identify a positional index, not a key.
328func TestResolvedArgs_RealShell(t *testing.T) {
329 t.Parallel()
330
331 t.Run("success expands each element", func(t *testing.T) {
332 t.Parallel()
333 m := MCPConfig{Args: []string{"--token", "$(echo shh)", "--host", "example.com"}}
334 got, err := m.ResolvedArgs(realShellResolver(nil))
335 require.NoError(t, err)
336 require.Equal(t, []string{"--token", "shh", "--host", "example.com"}, got)
337 })
338
339 t.Run("failure identifies offending index", func(t *testing.T) {
340 t.Parallel()
341 m := MCPConfig{Args: []string{"--token", "$(false)"}}
342 orig := slices.Clone(m.Args)
343
344 got, err := m.ResolvedArgs(realShellResolver(nil))
345 require.Error(t, err)
346 require.Nil(t, got)
347 require.Contains(t, err.Error(), "arg 1")
348 require.Equal(t, orig, m.Args, "receiver Args must be preserved")
349 })
350
351 t.Run("nil args returns nil, no error", func(t *testing.T) {
352 t.Parallel()
353 m := MCPConfig{}
354 got, err := m.ResolvedArgs(realShellResolver(nil))
355 require.NoError(t, err)
356 require.Nil(t, got)
357 })
358}
359
360// TestLSPConfig_ResolvedArgs_RealShell exercises both success and
361// failure for l.Args symmetrically with MCP args. Args are ordered so
362// error messages must identify a positional index, not a key.
363func TestLSPConfig_ResolvedArgs_RealShell(t *testing.T) {
364 t.Parallel()
365
366 t.Run("success expands $VAR in each element", func(t *testing.T) {
367 t.Parallel()
368 l := LSPConfig{Args: []string{"--root", "$HOME", "--flag", "literal"}}
369 r := realShellResolver(map[string]string{"HOME": "/home/tester"})
370 got, err := l.ResolvedArgs(r)
371 require.NoError(t, err)
372 require.Equal(t, []string{"--root", "/home/tester", "--flag", "literal"}, got)
373 })
374
375 t.Run("failure identifies offending index", func(t *testing.T) {
376 t.Parallel()
377 l := LSPConfig{Args: []string{"--root", "$(false)"}}
378 orig := slices.Clone(l.Args)
379
380 got, err := l.ResolvedArgs(realShellResolver(nil))
381 require.Error(t, err)
382 require.Nil(t, got)
383 require.Contains(t, err.Error(), "arg 1")
384 require.Equal(t, orig, l.Args, "receiver Args must be preserved")
385 })
386
387 t.Run("nil args returns nil, no error", func(t *testing.T) {
388 t.Parallel()
389 l := LSPConfig{}
390 got, err := l.ResolvedArgs(realShellResolver(nil))
391 require.NoError(t, err)
392 require.Nil(t, got)
393 })
394}
395
396// TestLSPConfig_ResolvedEnv_RealShell pins the LSP env contract:
397// success expands $VAR, failure wraps with the key name, and the
398// receiver map is never mutated. The shape is map[string]string
399// (not the MCP []string form) because powernap.ClientConfig.Environment
400// takes a map directly.
401func TestLSPConfig_ResolvedEnv_RealShell(t *testing.T) {
402 t.Parallel()
403
404 t.Run("success expands $VAR", func(t *testing.T) {
405 t.Parallel()
406 l := LSPConfig{Env: map[string]string{"GOPATH": "$HOME/go"}}
407 r := realShellResolver(map[string]string{"HOME": "/home/tester"})
408 got, err := l.ResolvedEnv(r)
409 require.NoError(t, err)
410 require.Equal(t, map[string]string{"GOPATH": "/home/tester/go"}, got)
411 })
412
413 t.Run("failure identifies offending key", func(t *testing.T) {
414 t.Parallel()
415 l := LSPConfig{Env: map[string]string{
416 "GOPATH": "$(false)",
417 "OTHER": "literal",
418 }}
419 orig := maps.Clone(l.Env)
420
421 got, err := l.ResolvedEnv(realShellResolver(nil))
422 require.Error(t, err)
423 require.Nil(t, got)
424 require.Contains(t, err.Error(), `env "GOPATH"`)
425 require.Equal(t, orig, l.Env, "receiver Env must be preserved")
426 })
427
428 t.Run("idempotent and non-mutating", func(t *testing.T) {
429 t.Parallel()
430 l := LSPConfig{Env: map[string]string{
431 "A": "$(echo one)",
432 "B": "literal",
433 }}
434 orig := maps.Clone(l.Env)
435 r := realShellResolver(nil)
436
437 first, err := l.ResolvedEnv(r)
438 require.NoError(t, err)
439 second, err := l.ResolvedEnv(r)
440 require.NoError(t, err)
441 require.Equal(t, first, second)
442 require.Equal(t, orig, l.Env, "receiver Env must be preserved")
443 })
444}
445
446// TestLSPConfig_IdentityResolver pins the client-mode contract: both
447// ResolvedArgs and ResolvedEnv round-trip the template verbatim under
448// IdentityResolver and never error on unset variables. Local
449// expansion would double-expand when the server does its own — this
450// has to stay a pure pass-through.
451func TestLSPConfig_IdentityResolver(t *testing.T) {
452 t.Parallel()
453
454 l := LSPConfig{
455 Args: []string{"--root", "$LSP_ROOT", "$(vault read -f lsp)"},
456 Env: map[string]string{
457 "GOPATH": "$HOME/go",
458 "TOKEN": "$(cat /run/secrets/x)",
459 },
460 }
461 r := IdentityResolver()
462
463 args, err := l.ResolvedArgs(r)
464 require.NoError(t, err)
465 require.Equal(t, l.Args, args)
466
467 envs, err := l.ResolvedEnv(r)
468 require.NoError(t, err)
469 require.Equal(t, l.Env, envs)
470}
471
472// TestMCPConfig_IdentityResolver pins the client-mode contract: every
473// Resolved* method round-trips the template verbatim and never errors
474// on unset variables. Local expansion would double-expand when the
475// server does its own — this has to stay a pure pass-through.
476func TestMCPConfig_IdentityResolver(t *testing.T) {
477 t.Parallel()
478
479 m := MCPConfig{
480 Command: "$CMD",
481 Args: []string{"--token", "$MCP_MISSING_TOKEN", "$(vault read -f secret)"},
482 Env: map[string]string{
483 "TOKEN": "$(cat /run/secrets/x)",
484 "HOST": "$MCP_MISSING_HOST",
485 },
486 Headers: map[string]string{
487 "Authorization": "Bearer $(vault read -f token)",
488 },
489 URL: "https://$MCP_HOST/$(vault read -f path)",
490 }
491 r := IdentityResolver()
492
493 args, err := m.ResolvedArgs(r)
494 require.NoError(t, err)
495 require.Equal(t, m.Args, args)
496
497 envs, err := m.ResolvedEnv(r)
498 require.NoError(t, err)
499 // Sorted "KEY=value".
500 require.Equal(t, []string{
501 "HOST=$MCP_MISSING_HOST",
502 "TOKEN=$(cat /run/secrets/x)",
503 }, envs)
504
505 headers, err := m.ResolvedHeaders(r)
506 require.NoError(t, err)
507 require.Equal(t, m.Headers, headers)
508
509 u, err := m.ResolvedURL(r)
510 require.NoError(t, err)
511 require.Equal(t, m.URL, u)
512}