diff --git a/pkg/workflow/compiler_filters_validation.go b/pkg/workflow/compiler_filters_validation.go index 74af2a64e14..a596a80aad6 100644 --- a/pkg/workflow/compiler_filters_validation.go +++ b/pkg/workflow/compiler_filters_validation.go @@ -1,4 +1,5 @@ -// This file provides validation for GitHub Actions event filter mutual exclusivity. +// This file provides validation for GitHub Actions event filter mutual exclusivity +// and glob pattern validity. // // # Filter Validation // @@ -7,10 +8,21 @@ // - branches and branches-ignore in the same event // - paths and paths-ignore in the same event // +// # Glob Pattern Validation +// +// This file also validates that glob patterns used in event filters are syntactically valid +// according to GitHub Actions glob syntax, using the glob validator in glob_validation.go: +// - Branch and tag patterns use validateRefGlob +// - Path patterns use validatePathGlob +// +// A notable check is that path patterns starting with "./" are always invalid in GitHub Actions. +// // # Validation Functions // -// - ValidateEventFilters() - Main entry point for filter validation +// - ValidateEventFilters() - Main entry point for filter mutual-exclusivity validation +// - ValidateGlobPatterns() - Main entry point for glob pattern syntax validation // - validateFilterExclusivity() - Validates a single event's filter configuration +// - validateGlobList() - Validates a list of glob patterns for a given filter key // // # GitHub Actions Requirements // @@ -26,6 +38,7 @@ // - It validates event filter configurations // - It checks for GitHub Actions filter requirements // - It validates mutual exclusivity of filter options +// - It validates glob pattern syntax in event filters // // For general validation, see validation.go. // For detailed documentation, see scratchpad/validation-architecture.md @@ -33,7 +46,9 @@ package workflow import ( + "errors" "fmt" + "strings" ) var filterValidationLog = newValidationLogger("filter") @@ -103,3 +118,111 @@ func validateFilterExclusivity(eventVal any, eventName string) error { filterValidationLog.Printf("Event '%s' filters are valid", eventName) return nil } + +// refFilterKeys are the event filter keys whose patterns must be valid Git ref globs. +var refFilterKeys = []string{"branches", "branches-ignore", "tags", "tags-ignore"} + +// pathFilterKeys are the event filter keys whose patterns must be valid path globs. +var pathFilterKeys = []string{"paths", "paths-ignore"} + +// globValidationEvents are the GitHub Actions event types that support branch/tag/path filters. +var globValidationEvents = []string{"push", "pull_request", "pull_request_target", "workflow_run"} + +// ValidateGlobPatterns validates branch, tag, and path glob patterns in the 'on' section +// of a workflow's frontmatter. It returns the first validation error encountered, if any. +func ValidateGlobPatterns(frontmatter map[string]any) error { + filterValidationLog.Print("Validating glob patterns in event filters") + + on, exists := frontmatter["on"] + if !exists { + return nil + } + + onMap, ok := on.(map[string]any) + if !ok { + return nil + } + + for _, eventName := range globValidationEvents { + eventVal, exists := onMap[eventName] + if !exists { + continue + } + eventMap, ok := eventVal.(map[string]any) + if !ok { + continue + } + + // Validate ref globs (branches, tags, branches-ignore, tags-ignore) + for _, key := range refFilterKeys { + if err := validateGlobList(eventMap, eventName, key, false); err != nil { + return err + } + } + + // Validate path globs (paths, paths-ignore) + for _, key := range pathFilterKeys { + if err := validateGlobList(eventMap, eventName, key, true); err != nil { + return err + } + } + } + + filterValidationLog.Print("Glob pattern validation completed successfully") + return nil +} + +// validateGlobList validates each pattern in a filter list (e.g. branches, paths). +// When isPath is true, validatePathGlob is used; otherwise validateRefGlob. +func validateGlobList(eventMap map[string]any, eventName, filterKey string, isPath bool) error { + val, exists := eventMap[filterKey] + if !exists { + return nil + } + + patterns, err := toStringSlice(val) + if err != nil { + // Non-string-list values are skipped; schema validation handles type errors separately + return nil + } + + for _, pat := range patterns { + var errs []invalidGlobPattern + if isPath { + errs = validatePathGlob(pat) + } else { + errs = validateRefGlob(pat) + } + if len(errs) > 0 { + msgs := make([]string, 0, len(errs)) + for _, e := range errs { + msgs = append(msgs, e.Message) + } + filterValidationLog.Printf("ERROR: invalid glob pattern %q in %s.%s: %s", pat, eventName, filterKey, strings.Join(msgs, "; ")) + return fmt.Errorf("invalid glob pattern %q in on.%s.%s: %s", pat, eventName, filterKey, strings.Join(msgs, "; ")) + } + } + return nil +} + +// toStringSlice converts an any value to a []string, supporting []string, []any, and string. +func toStringSlice(val any) ([]string, error) { + switch v := val.(type) { + case []string: + return v, nil + case []any: + result := make([]string, 0, len(v)) + for _, item := range v { + s, ok := item.(string) + if !ok { + return nil, errors.New("non-string item in list") + } + result = append(result, s) + } + return result, nil + case string: + return []string{v}, nil + default: + return nil, fmt.Errorf("unsupported type %T", val) + } +} diff --git a/pkg/workflow/compiler_filters_validation_test.go b/pkg/workflow/compiler_filters_validation_test.go index 9b356b8e878..1e498cbf2e3 100644 --- a/pkg/workflow/compiler_filters_validation_test.go +++ b/pkg/workflow/compiler_filters_validation_test.go @@ -327,3 +327,519 @@ func TestValidateFilterExclusivity(t *testing.T) { }) } } + +func TestValidateGlobPatterns(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + wantErr bool + errContains string + }{ + // ---- valid ref globs ---- + { + name: "valid branch pattern main", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "branches": []string{"main"}, + }, + }, + }, + wantErr: false, + }, + { + name: "valid branch wildcard", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "branches": []string{"release/**"}, + }, + }, + }, + wantErr: false, + }, + { + name: "valid tag pattern v*", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "tags": []string{"v*"}, + }, + }, + }, + wantErr: false, + }, + { + name: "valid semver tag pattern", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "tags": []string{"v[0-9]+.[0-9]+.[0-9]+"}, + }, + }, + }, + wantErr: false, + }, + // ---- valid path globs ---- + { + name: "valid path src/**", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "paths": []string{"src/**"}, + }, + }, + }, + wantErr: false, + }, + { + name: "valid paths-ignore docs/**", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "paths-ignore": []string{"docs/**"}, + }, + }, + }, + wantErr: false, + }, + { + name: "valid negated path glob", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "paths": []string{"!docs/**"}, + }, + }, + }, + wantErr: false, + }, + // ---- no 'on' section ---- + { + name: "no on section", + frontmatter: map[string]any{}, + wantErr: false, + }, + // ---- invalid ref glob ---- + { + name: "invalid branch pattern with space", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "branches": []string{"main branch"}, + }, + }, + }, + wantErr: true, + errContains: "on.push.branches", + }, + { + name: "invalid tag pattern with colon", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "tags": []string{"v1:0"}, + }, + }, + }, + wantErr: true, + errContains: "on.push.tags", + }, + // ---- ./ prefix path glob (always invalid in GitHub Actions) ---- + { + name: "invalid path glob with ./ prefix", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "paths": []string{"./src/**/*.go"}, + }, + }, + }, + wantErr: true, + errContains: "on.push.paths", + }, + { + name: "invalid paths-ignore with ./ prefix", + frontmatter: map[string]any{ + "on": map[string]any{ + "pull_request": map[string]any{ + "paths-ignore": []string{"./docs/**"}, + }, + }, + }, + wantErr: true, + errContains: "on.pull_request.paths-ignore", + }, + // ---- pull_request event ---- + { + name: "valid pull_request branch pattern", + frontmatter: map[string]any{ + "on": map[string]any{ + "pull_request": map[string]any{ + "branches": []string{"main", "release/**"}, + }, + }, + }, + wantErr: false, + }, + { + name: "invalid pull_request branch with tilde", + frontmatter: map[string]any{ + "on": map[string]any{ + "pull_request": map[string]any{ + "branches": []string{"~invalid"}, + }, + }, + }, + wantErr: true, + errContains: "on.pull_request.branches", + }, + // ---- non-glob on section ---- + { + name: "on section is a string (not a map)", + frontmatter: map[string]any{ + "on": "push", + }, + wantErr: false, + }, + // ---- []any pattern list ---- + { + name: "valid branch list as []any", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "branches": []any{"main", "develop"}, + }, + }, + }, + wantErr: false, + }, + { + name: "invalid path in []any list", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "paths": []any{"./bad/**"}, + }, + }, + }, + wantErr: true, + errContains: "on.push.paths", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateGlobPatterns(tt.frontmatter) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateGlobPatterns() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err != nil && tt.errContains != "" { + if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("ValidateGlobPatterns() error = %v, should contain %q", err, tt.errContains) + } + } + }) + } +} + +// TestValidateGlobPatternsExtendedEvents verifies that glob validation is applied to all +// supported GitHub Actions events (pull_request_target, workflow_run) and all filter keys +// (branches-ignore, tags, tags-ignore, paths-ignore). +func TestValidateGlobPatternsExtendedEvents(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]any + wantErr bool + errContains string + }{ + // ---- pull_request_target event ---- + { + name: "valid pull_request_target branches", + frontmatter: map[string]any{ + "on": map[string]any{ + "pull_request_target": map[string]any{ + "branches": []string{"main", "release/**"}, + }, + }, + }, + wantErr: false, + }, + { + name: "invalid pull_request_target branch with space", + frontmatter: map[string]any{ + "on": map[string]any{ + "pull_request_target": map[string]any{ + "branches": []string{"main branch"}, + }, + }, + }, + wantErr: true, + errContains: "on.pull_request_target.branches", + }, + { + name: "invalid pull_request_target path with ./ prefix", + frontmatter: map[string]any{ + "on": map[string]any{ + "pull_request_target": map[string]any{ + "paths": []string{"./src/**"}, + }, + }, + }, + wantErr: true, + errContains: "on.pull_request_target.paths", + }, + // ---- workflow_run event ---- + { + name: "valid workflow_run branches", + frontmatter: map[string]any{ + "on": map[string]any{ + "workflow_run": map[string]any{ + "branches": []string{"main"}, + }, + }, + }, + wantErr: false, + }, + { + name: "invalid workflow_run branch with tilde", + frontmatter: map[string]any{ + "on": map[string]any{ + "workflow_run": map[string]any{ + "branches": []string{"~bad"}, + }, + }, + }, + wantErr: true, + errContains: "on.workflow_run.branches", + }, + // ---- branches-ignore filter key ---- + { + name: "valid branches-ignore pattern", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "branches-ignore": []string{"dependabot/**"}, + }, + }, + }, + wantErr: false, + }, + { + name: "invalid branches-ignore pattern with colon", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "branches-ignore": []string{"feat:bad"}, + }, + }, + }, + wantErr: true, + errContains: "on.push.branches-ignore", + }, + // ---- tags filter key ---- + { + name: "valid tags-ignore pattern", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "tags-ignore": []string{"v*-beta"}, + }, + }, + }, + wantErr: false, + }, + { + name: "invalid tags-ignore pattern with space", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "tags-ignore": []string{"bad tag"}, + }, + }, + }, + wantErr: true, + errContains: "on.push.tags-ignore", + }, + // ---- second pattern in a list is invalid ---- + { + name: "second branch in list is invalid", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "branches": []string{"main", "bad branch"}, + }, + }, + }, + wantErr: true, + errContains: "on.push.branches", + }, + { + name: "second path in list is invalid", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": map[string]any{ + "paths": []string{"src/**", "./bad"}, + }, + }, + }, + wantErr: true, + errContains: "on.push.paths", + }, + // ---- non-map event value is gracefully skipped ---- + { + name: "push event with null value is skipped", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": nil, + }, + }, + wantErr: false, + }, + { + name: "push event with string value is skipped", + frontmatter: map[string]any{ + "on": map[string]any{ + "push": "simple-string", + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateGlobPatterns(tt.frontmatter) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateGlobPatterns() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err != nil && tt.errContains != "" { + if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("ValidateGlobPatterns() error = %v, should contain %q", err, tt.errContains) + } + } + }) + } +} + +// TestValidateRefGlob exercises the low-level validateRefGlob helper directly. +func TestValidateRefGlob(t *testing.T) { + tests := []struct { + name string + pattern string + wantErr bool + }{ + // valid patterns + {name: "simple branch name", pattern: "main", wantErr: false}, + {name: "wildcard branch", pattern: "release/*", wantErr: false}, + {name: "double wildcard", pattern: "feature/**", wantErr: false}, + {name: "negated pattern", pattern: "!dependabot/**", wantErr: false}, + {name: "version tag", pattern: "v1.*", wantErr: false}, + {name: "character class", pattern: "release/v[0-9]*", wantErr: false}, + // invalid patterns + {name: "empty string", pattern: "", wantErr: true}, + {name: "contains space", pattern: "feature branch", wantErr: true}, + {name: "contains tilde", pattern: "~bad", wantErr: true}, + {name: "contains caret", pattern: "bad^name", wantErr: true}, + {name: "contains colon", pattern: "bad:name", wantErr: true}, + {name: "starts with slash", pattern: "/branch", wantErr: true}, + {name: "ends with slash", pattern: "branch/", wantErr: true}, + {name: "ends with dot", pattern: "branch.", wantErr: true}, + {name: "empty character class", pattern: "feat/[]bad", wantErr: true}, + {name: "unclosed character class", pattern: "feat/[a-z", wantErr: true}, + {name: "bare exclamation", pattern: "!", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := validateRefGlob(tt.pattern) + gotErr := len(errs) > 0 + if gotErr != tt.wantErr { + t.Errorf("validateRefGlob(%q): got errors=%v, wantErr=%v", tt.pattern, errs, tt.wantErr) + } + }) + } +} + +// TestValidatePathGlob exercises the low-level validatePathGlob helper directly. +func TestValidatePathGlob(t *testing.T) { + tests := []struct { + name string + pattern string + wantErr bool + }{ + // valid patterns + {name: "simple filename", pattern: "README.md", wantErr: false}, + {name: "subdirectory wildcard", pattern: "src/**/*.go", wantErr: false}, + {name: "negated path", pattern: "!docs/**", wantErr: false}, + {name: "root wildcard", pattern: "*.go", wantErr: false}, + {name: "deep path", pattern: "a/b/c/**", wantErr: false}, + {name: "negated dot-path is valid", pattern: "!./ignored", wantErr: true}, // ./ after ! still invalid + // invalid: ./ and ../ prefixes + {name: "./ prefix", pattern: "./src/**", wantErr: true}, + {name: "../ prefix", pattern: "../other", wantErr: true}, + {name: "bare dot", pattern: ".", wantErr: true}, + {name: "bare double-dot", pattern: "..", wantErr: true}, + {name: "negated ./ prefix", pattern: "!./bad", wantErr: true}, + // invalid: leading/trailing spaces + {name: "leading space", pattern: " src/**", wantErr: true}, + {name: "trailing space", pattern: "src/** ", wantErr: true}, + // invalid: empty and bare exclamation + {name: "empty string", pattern: "", wantErr: true}, + {name: "bare exclamation", pattern: "!", wantErr: true}, + // invalid: unclosed bracket + {name: "unclosed bracket", pattern: "src/[a-z", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := validatePathGlob(tt.pattern) + gotErr := len(errs) > 0 + if gotErr != tt.wantErr { + t.Errorf("validatePathGlob(%q): got errors=%v, wantErr=%v", tt.pattern, errs, tt.wantErr) + } + }) + } +} + +// TestToStringSlice tests the toStringSlice conversion helper. +func TestToStringSlice(t *testing.T) { + tests := []struct { + name string + input any + want []string + wantErr bool + }{ + {name: "[]string", input: []string{"a", "b"}, want: []string{"a", "b"}, wantErr: false}, + {name: "[]any strings", input: []any{"a", "b"}, want: []string{"a", "b"}, wantErr: false}, + {name: "single string", input: "hello", want: []string{"hello"}, wantErr: false}, + {name: "[]any with int", input: []any{"a", 42}, wantErr: true}, + {name: "integer type", input: 123, wantErr: true}, + {name: "nil", input: nil, wantErr: true}, + {name: "empty []string", input: []string{}, want: []string{}, wantErr: false}, + {name: "empty []any", input: []any{}, want: []string{}, wantErr: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := toStringSlice(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("toStringSlice(%v): err = %v, wantErr %v", tt.input, err, tt.wantErr) + return + } + if !tt.wantErr { + if len(got) != len(tt.want) { + t.Errorf("toStringSlice(%v): got %v, want %v", tt.input, got, tt.want) + return + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("toStringSlice(%v)[%d]: got %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + } + }) + } +} diff --git a/pkg/workflow/compiler_orchestrator_frontmatter.go b/pkg/workflow/compiler_orchestrator_frontmatter.go index 9e533a957da..8ed1e70f389 100644 --- a/pkg/workflow/compiler_orchestrator_frontmatter.go +++ b/pkg/workflow/compiler_orchestrator_frontmatter.go @@ -116,6 +116,12 @@ func (c *Compiler) parseFrontmatterSection(markdownPath string) (*frontmatterPar return nil, err } + // Validate glob pattern syntax in event filters (branches, tags, paths, etc.) + if err := ValidateGlobPatterns(frontmatterForValidation); err != nil { + orchestratorFrontmatterLog.Printf("Glob pattern validation failed: %v", err) + return nil, err + } + // Validate that the runs-on field does not specify unsupported runner types (e.g. macOS) if err := validateRunsOn(frontmatterForValidation, cleanPath); err != nil { orchestratorFrontmatterLog.Printf("runs-on validation failed: %v", err) diff --git a/pkg/workflow/glob_validation.go b/pkg/workflow/glob_validation.go new file mode 100644 index 00000000000..06d14c5a8c3 --- /dev/null +++ b/pkg/workflow/glob_validation.go @@ -0,0 +1,245 @@ +// This file contains glob pattern validation logic for GitHub Actions workflow filters. +// +// The validation code is adapted from github.com/rhysd/actionlint (MIT License). +// Source: https://github.com/rhysd/actionlint/blob/v1.7.11/glob.go +// +// It is maintained here as a self-contained copy to avoid importing the full actionlint +// package (which has transitive dependency compatibility constraints). The glob validator +// depends only on standard library packages. +// +// # Functions +// +// - validateRefGlob() - Validates a glob pattern for Git ref names (branches/tags) +// - validatePathGlob() - Validates a glob pattern for file paths +// +// Both return a slice of invalidGlobPattern describing any violations found. + +package workflow + +import ( + "fmt" + "strings" + "text/scanner" + "unicode" +) + +// invalidGlobPattern describes a single validation error within a glob pattern. +type invalidGlobPattern struct { + // Message is a human-readable description of the problem. + Message string + // Column is the 1-based column of the error within the pattern (0 when unknown). + Column int +} + +func (e invalidGlobPattern) Error() string { + return fmt.Sprintf("%d: %s", e.Column, e.Message) +} + +// globValidator holds the state for iterative glob scanning. +type globValidator struct { + isRef bool + prec bool + errs []invalidGlobPattern + scan scanner.Scanner +} + +func (v *globValidator) error(msg string) { + p := v.scan.Pos() + c := p.Column - 1 + if p.Line > 1 { + c = 0 + } + v.errs = append(v.errs, invalidGlobPattern{msg, c}) +} + +func (v *globValidator) unexpected(char rune, what, why string) { + unexpected := "unexpected EOF" + if char != scanner.EOF { + unexpected = fmt.Sprintf("unexpected character %q", char) + } + while := "" + if what != "" { + while = " while checking " + what + } + v.error(fmt.Sprintf("invalid glob pattern. %s%s. %s", unexpected, while, why)) +} + +func (v *globValidator) invalidRefChar(c rune, why string) { + cfmt := "%q" + if unicode.IsPrint(c) { + cfmt = "'%c'" + } + format := "character " + cfmt + " is invalid for branch and tag names. %s. see `man git-check-ref-format` for more details. note that regular expression is unavailable" + v.error(fmt.Sprintf(format, c, why)) +} + +func (v *globValidator) init(pat string) { + v.errs = []invalidGlobPattern{} + v.prec = false + v.scan.Init(strings.NewReader(pat)) + v.scan.Error = func(s *scanner.Scanner, m string) { + v.error(fmt.Sprintf("error while scanning glob pattern %q: %s", pat, m)) + } +} + +//nolint:cyclop,gocognit // complexity mirrors the upstream actionlint implementation +func (v *globValidator) validateNext() bool { + c := v.scan.Next() + prec := true + + switch c { + case '\\': + switch v.scan.Peek() { + case '[', '?', '*': + c = v.scan.Next() + if v.isRef { + v.invalidRefChar(c, "ref name cannot contain spaces, ~, ^, :, [, ?, *") + } + case '+', '\\', '!': + c = v.scan.Next() + default: + if v.isRef { + v.invalidRefChar('\\', "only special characters [, ?, +, *, \\, ! can be escaped with \\") + c = v.scan.Next() + } + } + case '?': + if !v.prec { + v.unexpected('?', "special character ? (zero or one)", "the preceding character must not be special character") + } + prec = false + case '+': + if !v.prec { + v.unexpected('+', "special character + (one or more)", "the preceding character must not be special character") + } + prec = false + case '*': + prec = false + case '[': + if v.scan.Peek() == ']' { + c = v.scan.Next() + v.unexpected(']', "content of character match []", "character match must not be empty") + break + } + chars := 0 + Loop: + for { + c = v.scan.Next() + switch c { + case ']': + break Loop + case scanner.EOF: + v.unexpected(c, "end of character match []", "missing ]") + return false + default: + if v.scan.Peek() != '-' { + chars++ + continue Loop + } + chars += 2 + s := c + _ = v.scan.Next() // eat '-'; return value not needed + switch v.scan.Peek() { + case ']': + c = v.scan.Next() + v.unexpected(c, "character range in []", "end of range is missing") + break Loop + case scanner.EOF: + // do nothing + default: + c = v.scan.Next() + if s > c { + why := fmt.Sprintf("start of range %q (%d) is larger than end of range %q (%d)", s, s, c, c) + v.unexpected(c, "character range in []", why) + } + } + } + } + if chars == 1 { + v.unexpected(c, "character match []", "character match with single character is useless. simply use x instead of [x]") + } + case '\r': + if v.scan.Peek() == '\n' { + c = v.scan.Next() + } + v.unexpected(c, "", "newline cannot be contained") + case '\n': + v.unexpected('\n', "", "newline cannot be contained") + case ' ', '\t', '~', '^', ':': + if v.isRef { + v.invalidRefChar(c, "ref name cannot contain spaces, ~, ^, :, [, ?, *") + } + default: + } + v.prec = prec + + if v.scan.Peek() == scanner.EOF { + if v.isRef && (c == '/' || c == '.') { + v.invalidRefChar(c, "ref name must not end with / and .") + } + return false + } + return true +} + +func (v *globValidator) validate(pat string) { + v.init(pat) + if pat == "" { + v.error("glob pattern cannot be empty") + return + } + switch v.scan.Peek() { + case '/': + if v.isRef { + v.scan.Next() + v.invalidRefChar('/', "ref name must not start with /") + v.prec = true + } + case '!': + v.scan.Next() + if v.scan.Peek() == scanner.EOF { + v.unexpected('!', "! at first character (negate pattern)", "at least one character must follow !") + return + } + v.prec = false + } + for v.validateNext() { + } +} + +func runGlobValidation(pat string, isRef bool) []invalidGlobPattern { + v := globValidator{} + v.isRef = isRef + v.validate(pat) + return v.errs +} + +// validateRefGlob validates a GitHub Actions ref filter glob (branch or tag pattern). +// It returns a non-empty slice of invalidGlobPattern when the pattern is invalid. +func validateRefGlob(pat string) []invalidGlobPattern { + return runGlobValidation(pat, true) +} + +// validatePathGlob validates a GitHub Actions path filter glob. +// It returns a non-empty slice of invalidGlobPattern when the pattern is invalid. +// Path patterns starting with "./" or "../" are explicitly rejected. +func validatePathGlob(pat string) []invalidGlobPattern { + p := strings.TrimSpace(pat) + + var errs []invalidGlobPattern + if pat != p { + errs = append(errs, invalidGlobPattern{"leading and trailing spaces are not allowed in glob path", 0}) + } + + // Reject '.', '..', './', and '../' (#521 in actionlint) + stripped := strings.TrimPrefix(p, "!") + if stripped == "." || stripped == ".." || strings.HasPrefix(stripped, "./") || strings.HasPrefix(stripped, "../") { + errs = append(errs, invalidGlobPattern{"'.', '..', and paths starting with './' or '../' are not allowed in glob path", 0}) + } + + if len(errs) > 0 { + return errs + } + + return runGlobValidation(pat, false) +}