Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions cmd/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,18 @@ func newThemeShowCmd(load themeLoadFunc, list themeListFunc) *cobra.Command {
// a stale config value can't stop the TUI from launching. That is
// wrong for "show", where silently reporting the default theme
// hides the typo, so reject unknown names up front.
//
// Path forms are exempt: theme.Load reads those straight from disk
// and they never appear in ListThemes, so checking them against the
// built-in list would reject every custom theme file. Let Load
// handle them and surface its error.
name := args[0]
available := list()
if !slices.Contains(available, name) {
return fmt.Errorf("unknown theme %q (available: %s)",
name, strings.Join(available, ", "))
if !theme.LooksLikePath(name) {
available := list()
if !slices.Contains(available, name) {
return fmt.Errorf("unknown theme %q (available: %s)",
name, strings.Join(available, ", "))
}
}
resolved, err := load(name)
if err != nil {
Expand Down
53 changes: 53 additions & 0 deletions cmd/theme_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"bytes"
"encoding/json"
"errors"
"testing"

"github.com/jongio/grut/internal/theme"
Expand Down Expand Up @@ -156,6 +157,58 @@ func TestThemeShowCommandUnknownThemeErrors(t *testing.T) {
assert.False(t, loadCalled, "load should not run for an unknown theme")
}

// Custom theme files are loaded by path and never appear in ListThemes, so
// validating a path form against the built-in list would reject every custom
// theme. Assert path arguments reach load instead of being rejected.
func TestThemeShowCommandAllowsPathForms(t *testing.T) {
paths := []string{
"my-theme.toml",
"./themes/custom.toml",
"/etc/grut/theme.toml",
`C:\themes\custom.toml`,
}

for _, p := range paths {
t.Run(p, func(t *testing.T) {
var gotName string
cmd := newThemeShowCmd(func(name string) (*theme.Theme, error) {
gotName = name
return &theme.Theme{Name: "custom", Variant: "dark", Mode: theme.ModeColor}, nil
}, func() []string { return []string{"default", "gruvbox"} })
cmd.SetArgs([]string{p})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SilenceUsage = true

err := cmd.Execute()

require.NoError(t, err, "path form must not be rejected as an unknown theme")
assert.Equal(t, p, gotName, "the path should be passed through to load unchanged")
assert.Contains(t, out.String(), "Name: custom")
})
}
}

// A path that load rejects must surface load's error, not a misleading
// "unknown theme" message listing the built-in names.
func TestThemeShowCommandPropagatesPathLoadError(t *testing.T) {
cmd := newThemeShowCmd(func(string) (*theme.Theme, error) {
return nil, errors.New("open missing.toml: no such file or directory")
}, func() []string { return []string{"default", "gruvbox"} })
cmd.SetArgs([]string{"missing.toml"})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SilenceUsage = true

err := cmd.Execute()

require.Error(t, err)
assert.Contains(t, err.Error(), "no such file or directory")
assert.NotContains(t, err.Error(), "unknown theme")
}

func TestRootRegistersThemeListCommand(t *testing.T) {
root, cleanup := newRootCommand()
defer cleanup()
Expand Down
10 changes: 6 additions & 4 deletions internal/theme/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ func (tf *themeFile) toColors() Colors {
// theme and logs a warning.
func Load(name string) (*Theme, error) {
// File path — read directly from disk.
if looksLikePath(name) {
if LooksLikePath(name) {
// Defence-in-depth: clean the path and reject directory
// traversal so a malicious or misconfigured theme value
// cannot read arbitrary files outside intended directories.
Expand Down Expand Up @@ -464,10 +464,12 @@ func validateColors(c Colors) error {
return nil
}

// looksLikePath returns true if the name contains a path separator or
// LooksLikePath returns true if the name contains a path separator or
// common file extension, suggesting it is a file path rather than a
// built-in theme name.
func looksLikePath(name string) bool {
// built-in theme name. Callers that validate a theme name against the
// built-in list must use this to let path forms through, since those
// will never appear in ListThemes.
func LooksLikePath(name string) bool {
return strings.ContainsAny(name, `/\`) || strings.HasSuffix(name, ".toml")
}

Expand Down
4 changes: 2 additions & 2 deletions internal/theme/theme_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ func TestBuiltinNamesReturnsCopy(t *testing.T) {
}

// ---------------------------------------------------------------------------
// looksLikePath
// LooksLikePath
// ---------------------------------------------------------------------------

func TestLooksLikePath(t *testing.T) {
Expand All @@ -285,7 +285,7 @@ func TestLooksLikePath(t *testing.T) {

for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
assert.Equal(t, tt.want, looksLikePath(tt.input))
assert.Equal(t, tt.want, LooksLikePath(tt.input))
})
}
}
Expand Down
Loading