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
6 changes: 4 additions & 2 deletions cmd/grant/cli/internal/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"

"github.com/mitchellh/go-homedir"

Expand Down Expand Up @@ -53,9 +54,10 @@ func DefaultConfigLocations() []string {
}...)
}

// XDG_CONFIG_DIRS (defaults to /etc/xdg)
// XDG_CONFIG_DIRS (defaults to /etc/xdg, except on windows where there is no default)
configDirs := os.Getenv("XDG_CONFIG_DIRS")
if configDirs == "" {
if configDirs == "" && runtime.GOOS != "windows" {
// on windows "/etc/xdg" resolves to "\etc\xdg" at the drive root, which any local user can create
configDirs = "/etc/xdg"
}

Expand Down
39 changes: 39 additions & 0 deletions cmd/grant/cli/internal/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package internal

import (
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
)

func TestDefaultConfigLocations_SystemConfigDir(t *testing.T) {
// empty is treated the same as unset
t.Setenv("XDG_CONFIG_DIRS", "")
// pin XDG_CONFIG_HOME so the runner's environment cannot produce a stray etc/xdg match
t.Setenv("XDG_CONFIG_HOME", t.TempDir())

hasEtcXDG := false
for _, loc := range DefaultConfigLocations() {
if strings.Contains(filepath.ToSlash(loc), "etc/xdg/grant/") {
hasEtcXDG = true
}
}

// on windows "/etc/xdg" lands at a drive root any user can create, so there must be no default there
want := runtime.GOOS != "windows"
if hasEtcXDG != want {
t.Errorf("etc/xdg in default config locations = %v, want %v (GOOS=%s)", hasEtcXDG, want, runtime.GOOS)
}
}

func TestDefaultConfigLocations_ExplicitConfigDirs(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_DIRS", dir)

want := filepath.Join(dir, "grant", "grant.yaml")
if !slices.Contains(DefaultConfigLocations(), want) {
t.Errorf("explicit XDG_CONFIG_DIRS entry %q missing from default config locations", want)
}
}
16 changes: 11 additions & 5 deletions grant/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@ package grant
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"

"gopkg.in/yaml.v3"
)

// Policy represents a simplified grant policy that can be decoded from YAML
// Policy represents a simplified grant policy that can be decoded from YAML.
//
// Allow and IgnorePackages entries are glob patterns with path.Match syntax on every OS (never filepath.Match,
// so results do not depend on the host): "*" matches within a single "/" segment, a backslash escapes the next character,
// and a malformed pattern never matches. IgnorePackages additionally treats a trailing "/*" as matching any depth
// ("github.com/org/*" matches "github.com/org/a/b").
type Policy struct {
// Allow is a list of permitted licenses (supports glob patterns)
Allow []string `yaml:"allow,omitempty"`
Expand Down Expand Up @@ -37,8 +43,8 @@ func (p *Policy) IsLicensePermitted(license string) bool {
// Convert common regex-style patterns to glob patterns
pattern := convertRegexToGlob(permitted)

// Glob pattern match
if matched, err := filepath.Match(pattern, license); err == nil && matched {
// glob pattern match (see Policy for the syntax)
if matched, err := path.Match(pattern, license); err == nil && matched {
return true
}
}
Expand Down Expand Up @@ -68,8 +74,8 @@ func (p *Policy) IsPackageIgnored(packageName string) bool {
return true
}

// Glob pattern match - handle path-like patterns
if matched, err := filepath.Match(pattern, packageName); err == nil && matched {
// glob pattern match (see Policy for the syntax)
if matched, err := path.Match(pattern, packageName); err == nil && matched {
return true
}

Expand Down
32 changes: 32 additions & 0 deletions grant/policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,35 @@ func stringSlicesEqual(a, b []string) bool {
}
return true
}

// TestPolicy_PatternSyntax pins the pattern contract documented on Policy for both allow and ignore-packages.
// These cases differ between path.Match and filepath.Match on windows, so the windows CI runner catches a regression.
func TestPolicy_PatternSyntax(t *testing.T) {
tests := []struct {
name string
pattern string
input string
want bool
}{
{"star matches within a segment", "github.com/*/pinned", "github.com/org/pinned", true},
{"star does not cross separator", "github.com/*/pinned", "github.com/org/nested/pinned", false},
{"trailing star does not cross separator", "LicenseRef-*", "LicenseRef-a/b", false},
{"backslash escapes star", `a\*`, "a*", true},
{"escaped star is literal", `a\*`, "ab", false},
{"malformed pattern never matches", `foo[`, "foox", false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
allow := &Policy{Allow: []string{tt.pattern}}
if got := allow.IsLicensePermitted(tt.input); got != tt.want {
t.Errorf("IsLicensePermitted(%q) with allow %q = %v, want %v", tt.input, tt.pattern, got, tt.want)
}

ignore := &Policy{IgnorePackages: []string{tt.pattern}}
if got := ignore.IsPackageIgnored(tt.input); got != tt.want {
t.Errorf("IsPackageIgnored(%q) with ignore %q = %v, want %v", tt.input, tt.pattern, got, tt.want)
}
})
}
}
Loading