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
114 changes: 100 additions & 14 deletions pkg/validator/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,9 @@ func (v *Validator) collectByPatterns(dir string, patterns []string) ([]string,
func (v *Validator) matchPattern(dir, pattern string) ([]string, error) {
var files []string

// Handle "**" for recursive matching
if pattern == "**" || strings.HasPrefix(pattern, "**/") {
// filepath.Glob does not understand "**", so route any pattern that
// contains it through the recursive walker.
if strings.Contains(pattern, "**") {
return v.collectRecursive(dir, pattern)
}

Expand Down Expand Up @@ -177,17 +178,85 @@ func (v *Validator) matchPattern(dir, pattern string) ([]string, error) {
return files, nil
}

// collectRecursive collects all flow files recursively.
// collectRecursive collects flow files matching a pattern that contains "**".
// The pattern is split on the first "**" into an optional prefix (walked from)
// and an optional suffix (matched per-segment against each file's trailing
// path). E.g. "tests/**/*.yaml" walks from dir/tests and keeps files whose
// name matches "*.yaml". A prefix containing shell wildcards is expanded via
// filepath.Glob so patterns like "flows-*/**/*.yaml" walk each matching
// directory. A prefix that resolves to no existing directory is a silent
// no-match.
func (v *Validator) collectRecursive(dir, pattern string) ([]string, error) {
idx := strings.Index(pattern, "**")
if idx < 0 {
return nil, fmt.Errorf("collectRecursive called without ** in pattern %q", pattern)
}
prefix := strings.TrimSuffix(pattern[:idx], "/")
suffix := strings.TrimPrefix(pattern[idx+2:], "/")

roots, err := expandPrefixRoots(dir, prefix)
if err != nil {
return nil, err
}

var files []string
seen := make(map[string]bool)
for _, root := range roots {
matches, err := walkRecursive(root, suffix)
if err != nil {
return nil, err
}
for _, m := range matches {
if !seen[m] {
seen[m] = true
files = append(files, m)
}
}
}
return files, nil
}

// expandPrefixRoots resolves the "**" prefix into concrete walk roots. If the
// prefix contains no wildcards, the single joined path is returned (or nothing
// if it doesn't exist). If the prefix contains "*"/"?"/"[", filepath.Glob is
// used to expand it and only the resulting directories are returned.
func expandPrefixRoots(dir, prefix string) ([]string, error) {
if prefix == "" {
return []string{dir}, nil
}

// Get the suffix after **/ if any
suffix := ""
if strings.HasPrefix(pattern, "**/") {
suffix = pattern[3:]
joined := filepath.Join(dir, prefix)
if !strings.ContainsAny(prefix, "*?[") {
if _, err := os.Stat(joined); err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return []string{joined}, nil
}

err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
matches, err := filepath.Glob(joined)
if err != nil {
return nil, err
}
roots := make([]string, 0, len(matches))
for _, m := range matches {
info, statErr := os.Stat(m)
if statErr != nil || !info.IsDir() {
continue
}
roots = append(roots, m)
}
return roots, nil
}

// walkRecursive walks a single root and returns every flow file whose
// trailing path segments match the suffix (or every flow file if the suffix
// is empty).
func walkRecursive(root, suffix string) ([]string, error) {
var files []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
Expand All @@ -197,22 +266,39 @@ func (v *Validator) collectRecursive(dir, pattern string) ([]string, error) {
if !isFlowFile(path) {
return nil
}

// If there's a suffix pattern, check if filename matches
if suffix != "" {
matched, _ := filepath.Match(suffix, filepath.Base(path))
if !matched {
rel, relErr := filepath.Rel(root, path)
if relErr != nil {
return nil
}
if !matchTail(filepath.ToSlash(rel), suffix) {
return nil
}
}

files = append(files, path)
return nil
})

return files, err
}

// matchTail reports whether the trailing "/"-separated segments of rel match
// the per-segment glob pattern suffix.
func matchTail(rel, suffix string) bool {
suffixParts := strings.Split(suffix, "/")
relParts := strings.Split(rel, "/")
if len(relParts) < len(suffixParts) {
return false
}
tail := relParts[len(relParts)-len(suffixParts):]
for i, seg := range suffixParts {
matched, err := filepath.Match(seg, tail[i])
if err != nil || !matched {
return false
}
}
return true
}

// getTopLevelFlows gets flow files directly in a directory (not recursive).
func (v *Validator) getTopLevelFlows(dir string) ([]string, error) {
var files []string
Expand Down
200 changes: 200 additions & 0 deletions pkg/validator/validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1218,3 +1218,203 @@ func TestValidate_GetTopLevelFlowsWithMixedContent(t *testing.T) {
t.Errorf("expected 3 test cases, got %d: %v", len(result.TestCases), result.TestCases)
}
}

// writeFlow is a shared helper for the recursive-pattern tests below.
// Creates any missing parent directories and writes a minimal-but-valid flow.
func writeFlow(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(`- tapOn: "X"`), 0o644); err != nil {
t.Fatal(err)
}
}

// TestValidate_RecursivePatternWithPrefix verifies that "**" is treated as
// recursive when it appears after a directory prefix, e.g. "tests/**/*.yaml".
// Previously only patterns starting with "**" or "**/" were expanded
// recursively; anything with a prefix fell through to filepath.Glob, which
// treats "**" like a single "*" and therefore matched only one directory
// level, silently skipping deeper flows.
func TestValidate_RecursivePatternWithPrefix(t *testing.T) {
dir := t.TempDir()

files := []string{
"tests/a.yaml",
"tests/sub/b.yaml",
"tests/sub/deep/c.yaml",
"tests/sub/deep/more/d.yaml",
}
for _, p := range files {
writeFlow(t, filepath.Join(dir, p))
}
// Sibling directory that must NOT be picked up.
writeFlow(t, filepath.Join(dir, "other", "skip.yaml"))

config := `flows:
- "tests/**/*.yaml"
`
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(config), 0o644); err != nil {
t.Fatal(err)
}

result := New(nil, nil).Validate(dir)

if !result.IsValid() {
t.Fatalf("expected valid result, got errors: %v", result.Errors)
}
if len(result.TestCases) != len(files) {
t.Errorf("expected %d test cases from tests/**/*.yaml, got %d: %v",
len(files), len(result.TestCases), result.TestCases)
}
for _, tc := range result.TestCases {
if strings.Contains(filepath.ToSlash(tc), "/other/") {
t.Errorf("test case leaked from sibling dir: %s", tc)
}
}
}

// TestValidate_RecursivePatternPrefixOnly verifies that "prefix/**" matches
// every flow file underneath the prefix at any depth, without requiring a
// filename suffix in the pattern.
func TestValidate_RecursivePatternPrefixOnly(t *testing.T) {
dir := t.TempDir()

files := []string{
"flows/a.yaml",
"flows/sub/b.yaml",
"flows/sub/deep/c.yaml",
}
for _, p := range files {
writeFlow(t, filepath.Join(dir, p))
}

config := `flows:
- "flows/**"
`
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(config), 0o644); err != nil {
t.Fatal(err)
}

result := New(nil, nil).Validate(dir)

if !result.IsValid() {
t.Fatalf("expected valid result, got errors: %v", result.Errors)
}
if len(result.TestCases) != len(files) {
t.Errorf("expected %d test cases from flows/**, got %d: %v",
len(files), len(result.TestCases), result.TestCases)
}
}

// TestValidate_RecursivePatternMultiSegmentSuffix verifies that a pattern
// like "a/**/c/*.yaml" walks recursively from dir/a and then matches only
// files whose trailing path is "c/<name>.yaml".
func TestValidate_RecursivePatternMultiSegmentSuffix(t *testing.T) {
dir := t.TempDir()

included := []string{
"a/c/one.yaml",
"a/x/c/two.yaml",
"a/x/y/c/three.yaml",
}
excluded := []string{
"a/d/one.yaml",
"a/x/y/four.yaml",
}
for _, p := range append(included, excluded...) {
writeFlow(t, filepath.Join(dir, p))
}

config := `flows:
- "a/**/c/*.yaml"
`
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(config), 0o644); err != nil {
t.Fatal(err)
}

result := New(nil, nil).Validate(dir)

if !result.IsValid() {
t.Fatalf("expected valid result, got errors: %v", result.Errors)
}
if len(result.TestCases) != len(included) {
t.Errorf("expected %d test cases matching a/**/c/*.yaml, got %d: %v",
len(included), len(result.TestCases), result.TestCases)
}
for _, tc := range result.TestCases {
rel := filepath.ToSlash(tc)
if !strings.Contains(rel, "/c/") {
t.Errorf("unexpected match outside c/ suffix: %s", tc)
}
}
}

// TestValidate_RecursivePatternMissingPrefixDir verifies that a pattern whose
// prefix directory does not exist (e.g. "subflows/**/*.yaml" with no
// subflows/) is a silent no-match rather than a walk error.
func TestValidate_RecursivePatternMissingPrefixDir(t *testing.T) {
dir := t.TempDir()

// Only a top-level flow that should NOT match the prefixed pattern.
writeFlow(t, filepath.Join(dir, "top.yaml"))

config := `flows:
- "subflows/**/*.yaml"
`
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(config), 0o644); err != nil {
t.Fatal(err)
}

result := New(nil, nil).Validate(dir)

if !result.IsValid() {
t.Fatalf("expected valid result for missing prefix dir, got errors: %v", result.Errors)
}
if len(result.TestCases) != 0 {
t.Errorf("expected 0 test cases, got %d: %v", len(result.TestCases), result.TestCases)
}
}

// TestValidate_RecursivePatternWildcardPrefix verifies that a shell-wildcard
// in the prefix (before "**") is expanded via filepath.Glob, so
// "flows-*/**/*.yaml" walks each matching directory.
func TestValidate_RecursivePatternWildcardPrefix(t *testing.T) {
dir := t.TempDir()

included := []string{
"flows-alpha/a.yaml",
"flows-alpha/sub/b.yaml",
"flows-beta/c.yaml",
}
// Must NOT match — prefix wildcard "flows-*" doesn't include "other-dir".
excluded := []string{
"other-dir/skip.yaml",
}
for _, p := range append(included, excluded...) {
writeFlow(t, filepath.Join(dir, p))
}

config := `flows:
- "flows-*/**/*.yaml"
`
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(config), 0o644); err != nil {
t.Fatal(err)
}

result := New(nil, nil).Validate(dir)

if !result.IsValid() {
t.Fatalf("expected valid result, got errors: %v", result.Errors)
}
if len(result.TestCases) != len(included) {
t.Errorf("expected %d test cases matching flows-*/**/*.yaml, got %d: %v",
len(included), len(result.TestCases), result.TestCases)
}
for _, tc := range result.TestCases {
if strings.Contains(filepath.ToSlash(tc), "/other-dir/") {
t.Errorf("test case leaked from sibling dir: %s", tc)
}
}
}
Loading