mirror of
https://github.com/charmbracelet/crush.git
synced 2026-05-30 18:47:33 +00:00
Missing env vars in crush.json used to fail loudly. They now quietly
expand to an empty string, the same as bash. If you want the old
strict behavior for a specific value, write it as ${VAR:?message}
and Crush will still complain when the variable is not set.
Co-Authored-By: Charm Crush <crush@charm.land>
242 lines
5.8 KiB
Go
242 lines
5.8 KiB
Go
package shell
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestExpandValue_Success(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
value string
|
|
env []string
|
|
want string
|
|
}{
|
|
{
|
|
name: "plain string round trip",
|
|
value: "hello world",
|
|
want: "hello world",
|
|
},
|
|
{
|
|
name: "plain var from env",
|
|
value: "$FOO",
|
|
env: []string{"FOO=bar"},
|
|
want: "bar",
|
|
},
|
|
{
|
|
name: "braced var from env",
|
|
value: "pre-${FOO}-post",
|
|
env: []string{"FOO=bar"},
|
|
want: "pre-bar-post",
|
|
},
|
|
{
|
|
name: "default syntax on unset",
|
|
value: "${MISSING:-fallback}",
|
|
want: "fallback",
|
|
},
|
|
{
|
|
name: "default syntax on set preserves value",
|
|
value: "${SET:-fallback}",
|
|
env: []string{"SET=used"},
|
|
want: "used",
|
|
},
|
|
{
|
|
name: "command substitution",
|
|
value: "$(echo hi)",
|
|
want: "hi",
|
|
},
|
|
{
|
|
name: "command substitution preserves internal spaces",
|
|
value: `$(echo "a b")`,
|
|
want: "a b",
|
|
},
|
|
{
|
|
name: "command substitution strips only trailing newline",
|
|
value: "$(printf 'a\\nb\\n')",
|
|
want: "a\nb",
|
|
},
|
|
{
|
|
name: "literal spaces around cmdsubst are preserved",
|
|
value: " $(echo v) ",
|
|
want: " v ",
|
|
},
|
|
{
|
|
name: "paren inside quoted arg to echo",
|
|
value: `$(echo ")")`,
|
|
want: ")",
|
|
},
|
|
{
|
|
name: "nested command substitution",
|
|
value: "$(echo $(echo hi))",
|
|
want: "hi",
|
|
},
|
|
{
|
|
name: "glob-like input round trips unchanged",
|
|
value: "*.go",
|
|
want: "*.go",
|
|
},
|
|
{
|
|
name: "tilde round trips unchanged",
|
|
value: "~",
|
|
want: "~",
|
|
},
|
|
{
|
|
name: "env var inside cmdsubst",
|
|
value: `$(printf '%s' "$FOO")`,
|
|
env: []string{"FOO=bar"},
|
|
want: "bar",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := ExpandValue(t.Context(), tc.value, tc.env)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tc.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExpandValue_Errors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("unset var expands to empty under lenient default", func(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := ExpandValue(t.Context(), "$MISSING", nil)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "", got)
|
|
})
|
|
|
|
t.Run("unset var inside braces expands to empty", func(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := ExpandValue(t.Context(), "${MISSING}", nil)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "", got)
|
|
})
|
|
|
|
t.Run("unset var inside cmdsubst expands to empty", func(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := ExpandValue(t.Context(), `$(printf '%s' "$MISSING")`, nil)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "", got)
|
|
})
|
|
|
|
t.Run("bad syntax returns error", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := ExpandValue(t.Context(), "$(", nil)
|
|
require.Error(t, err)
|
|
})
|
|
|
|
t.Run("inner non-zero exit returns error with exit code", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := ExpandValue(t.Context(), "$(false)", nil)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "exit status 1")
|
|
})
|
|
|
|
t.Run("inner explicit exit code is surfaced", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := ExpandValue(t.Context(), "$(exit 7)", nil)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "exit status 7")
|
|
})
|
|
|
|
t.Run("inner stderr is surfaced", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, err := ExpandValue(
|
|
t.Context(),
|
|
`$(printf 'boom\n' 1>&2; exit 1)`,
|
|
nil,
|
|
)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "boom")
|
|
})
|
|
|
|
t.Run("inner stderr is truncated to byte budget", func(t *testing.T) {
|
|
t.Parallel()
|
|
// Emit more than maxInnerStderrBytes bytes of 'X' on stderr.
|
|
long := strings.Repeat("X", maxInnerStderrBytes*2)
|
|
_, err := ExpandValue(
|
|
t.Context(),
|
|
`$(printf '`+long+`' 1>&2; exit 1)`,
|
|
nil,
|
|
)
|
|
require.Error(t, err)
|
|
require.NotContains(
|
|
t,
|
|
err.Error(),
|
|
strings.Repeat("X", maxInnerStderrBytes+1),
|
|
"stderr should be bounded",
|
|
)
|
|
})
|
|
}
|
|
|
|
// TestExpandValue_StrictToggle pins the NoUnset escape hatch: when a
|
|
// caller flips strict mode on, bare $UNSET must error instead of
|
|
// expanding to the empty string. Must not run in parallel: it mutates
|
|
// the package-level NoUnset atomic, so a parallel peer observing the
|
|
// flipped value would break the lenient default other tests assume.
|
|
func TestExpandValue_StrictToggle(t *testing.T) {
|
|
NoUnset.Store(true)
|
|
t.Cleanup(func() { NoUnset.Store(false) })
|
|
|
|
_, err := ExpandValue(t.Context(), "$UNSET", nil)
|
|
require.Error(t, err)
|
|
|
|
_, err = ExpandValue(t.Context(), "${UNSET}", nil)
|
|
require.Error(t, err)
|
|
|
|
_, err = ExpandValue(t.Context(), `$(printf '%s' "$UNSET")`, nil)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
// TestExpandValue_RequiredOptIn pins the per-reference opt-in strict
|
|
// idiom ${VAR:?msg}: it must error whether or not the global NoUnset
|
|
// toggle is on, so config authors can mark individual credentials as
|
|
// required without flipping the global default.
|
|
func TestExpandValue_RequiredOptIn(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, err := ExpandValue(t.Context(), "${REQUIRED:?must be set}", nil)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "must be set")
|
|
|
|
got, err := ExpandValue(
|
|
t.Context(),
|
|
"${REQUIRED:?must be set}",
|
|
[]string{"REQUIRED=ok"},
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "ok", got)
|
|
}
|
|
|
|
func TestSanitizeStderr(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("trims trailing newlines", func(t *testing.T) {
|
|
t.Parallel()
|
|
require.Equal(t, "hi", sanitizeStderr([]byte("hi\n\n")))
|
|
})
|
|
|
|
t.Run("preserves tabs and embedded newlines", func(t *testing.T) {
|
|
t.Parallel()
|
|
require.Equal(t, "a\tb\nc", sanitizeStderr([]byte("a\tb\nc")))
|
|
})
|
|
|
|
t.Run("replaces control characters", func(t *testing.T) {
|
|
t.Parallel()
|
|
require.Equal(t, "a?b", sanitizeStderr([]byte{'a', 0x01, 'b'}))
|
|
})
|
|
|
|
t.Run("bounds output", func(t *testing.T) {
|
|
t.Parallel()
|
|
got := sanitizeStderr([]byte(strings.Repeat("x", maxInnerStderrBytes*2)))
|
|
require.Len(t, got, maxInnerStderrBytes)
|
|
})
|
|
}
|