Files
velero/pkg/util/wildcard/expand_test.go
T
Joseph Antony VaikathandClaude Sonnet 4.5 43b926a58b Support all glob wildcard characters in namespace validation (#9502)
* Support all glob wildcard characters in namespace validation

Expand namespace validation to allow all valid glob pattern characters
(*, ?, {}, [], ,) by replacing them with valid characters during RFC 1123
validation. The actual glob pattern validation is handled separately by
the wildcard package.

Also add validation to reject unsupported characters (|, (), !) that are
not valid in glob patterns, and update terminology from "regex" to "glob"
for clarity since this implementation uses glob patterns, not regex.

Changes:
- Replace all glob wildcard characters in validateNamespaceName
- Add test coverage for valid glob patterns in includes/excludes
- Add test coverage for unsupported characters
- Reject exclamation mark (!) in wildcard patterns
- Clarify comments and error messages about glob vs regex

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: Joseph <jvaikath@redhat.com>

* Changelog

Signed-off-by: Joseph <jvaikath@redhat.com>

* Add documentation: glob patterns are now accepted

Signed-off-by: Joseph <jvaikath@redhat.com>

* Error message fix

Signed-off-by: Joseph <jvaikath@redhat.com>

* Remove negation glob char test

Signed-off-by: Joseph <jvaikath@redhat.com>

* Add bracket pattern validation for namespace glob patterns

Extends wildcard validation to support square bracket patterns [] used in glob character classes. Validates bracket syntax including empty brackets, unclosed brackets, and unmatched brackets. Extracts ValidateNamespaceName as a public function to enable reuse in namespace validation logic.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: Joseph <jvaikath@redhat.com>

* Reduce scope to *, ?, [ and ]

Signed-off-by: Joseph <jvaikath@redhat.com>

* Fix tests

Signed-off-by: Joseph <jvaikath@redhat.com>

* Add namespace glob patterns documentation page

Adds dedicated documentation explaining supported glob patterns
for namespace include/exclude filtering to help users understand
the wildcard syntax.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: Joseph <jvaikath@redhat.com>

* Fix build-image Dockerfile envtest download

Replace inaccessible go.kubebuilder.io URL with setup-envtest and update envtest version to 1.33.0 to match Kubernetes v0.33.3 dependencies.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: Joseph <jvaikath@redhat.com>

* kubebuilder binaries mv

Signed-off-by: Joseph <jvaikath@redhat.com>

* Reject brace patterns and update documentation

Add {, }, and , to unsupported characters list to explicitly reject
brace expansion patterns. Remove { from wildcard detection since these
patterns are not supported in the 1.18 release.

Update all documentation to show supported patterns inline (*, ?, [abc])
with clickable links to the detailed namespace-glob-patterns page.
Simplify YAML comments by removing non-clickable URLs.

Update tests to expect errors when brace patterns are used.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: Joseph <jvaikath@redhat.com>

* Document brace expansion as unsupported

Add {} and , to the unsupported patterns section to clarify that
brace expansion patterns like {a,b,c} are not supported.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: Joseph <jvaikath@redhat.com>

* Update tests to expect brace pattern rejection

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Signed-off-by: Joseph <jvaikath@redhat.com>

---------

Signed-off-by: Joseph <jvaikath@redhat.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 15:14:06 -05:00

623 lines
18 KiB
Go

package wildcard
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestShouldExpandWildcards(t *testing.T) {
tests := []struct {
name string
includes []string
excludes []string
expected bool
}{
{
name: "no wildcards",
includes: []string{"ns1", "ns2"},
excludes: []string{"ns3", "ns4"},
expected: false,
},
{
name: "includes has star - should not expand",
includes: []string{"*"},
excludes: []string{"ns1"},
expected: false,
},
{
name: "includes has star after a wildcard pattern - should not expand",
includes: []string{"ns*", "*"},
excludes: []string{"ns1"},
expected: false,
},
{
name: "includes has wildcard pattern",
includes: []string{"ns*"},
excludes: []string{"ns1"},
expected: true,
},
{
name: "excludes has wildcard pattern",
includes: []string{"ns1"},
excludes: []string{"ns*"},
expected: true,
},
{
name: "both have wildcard patterns",
includes: []string{"app-*"},
excludes: []string{"test-*"},
expected: true,
},
{
name: "includes has star and wildcard - star takes precedence",
includes: []string{"*", "ns*"},
excludes: []string{},
expected: false,
},
{
name: "double asterisk should be detected as wildcard",
includes: []string{"**"},
excludes: []string{},
expected: true, // ** is a wildcard pattern (but will error during validation)
},
{
name: "empty slices",
includes: []string{},
excludes: []string{},
expected: false,
},
{
name: "complex wildcard patterns",
includes: []string{"*-prod"},
excludes: []string{"test-*-staging"},
expected: true,
},
{
name: "question mark wildcard",
includes: []string{"ns?"},
excludes: []string{},
expected: true, // question mark is now considered a wildcard
},
{
name: "character class wildcard",
includes: []string{"ns[abc]"},
excludes: []string{},
expected: true, // character class is considered wildcard
},
{
name: "brace alternatives wildcard",
includes: []string{"ns{prod,staging}"},
excludes: []string{},
expected: false, // brace alternatives are not supported
},
{
name: "dot is literal - not wildcard",
includes: []string{"app.prod"},
excludes: []string{},
expected: false, // dot is literal, not wildcard
},
{
name: "plus is literal - not wildcard",
includes: []string{"app+"},
excludes: []string{},
expected: false, // plus is literal, not wildcard
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ShouldExpandWildcards(tt.includes, tt.excludes)
assert.Equal(t, tt.expected, result)
})
}
}
func TestExpandWildcards(t *testing.T) {
tests := []struct {
name string
activeNamespaces []string
includes []string
excludes []string
expectedIncludes []string
expectedExcludes []string
expectError bool
}{
{
name: "no wildcards",
activeNamespaces: []string{"ns1", "ns2", "ns3"},
includes: []string{"ns1", "ns4"},
excludes: []string{"ns2"},
expectedIncludes: []string{"ns1", "ns4"},
expectedExcludes: []string{"ns2"},
expectError: false,
},
{
name: "wildcard in includes",
activeNamespaces: []string{"app-prod", "app-staging", "db-prod", "test-ns"},
includes: []string{"app-*"},
excludes: []string{"test-ns"},
expectedIncludes: []string{"app-prod", "app-staging"},
expectedExcludes: []string{"test-ns"},
expectError: false,
},
{
name: "wildcard in excludes",
activeNamespaces: []string{"app-prod", "app-staging", "db-prod", "test-ns"},
includes: []string{"app-prod"},
excludes: []string{"*-staging"},
expectedIncludes: []string{"app-prod"},
expectedExcludes: []string{"app-staging"},
expectError: false,
},
{
name: "wildcards in both",
activeNamespaces: []string{"app-prod", "app-staging", "db-prod", "db-staging", "test-ns"},
includes: []string{"*-prod"},
excludes: []string{"*-staging"},
expectedIncludes: []string{"app-prod", "db-prod"},
expectedExcludes: []string{"app-staging", "db-staging"},
expectError: false,
},
{
name: "star pattern in includes",
activeNamespaces: []string{"ns1", "ns2", "ns3"},
includes: []string{"*"},
excludes: []string{},
expectedIncludes: []string{"ns1", "ns2", "ns3"},
expectedExcludes: nil,
expectError: false,
},
{
name: "empty active namespaces",
activeNamespaces: []string{},
includes: []string{"app-*"},
excludes: []string{"test-*"},
expectedIncludes: nil,
expectedExcludes: nil,
expectError: false,
},
{
name: "empty includes and excludes",
activeNamespaces: []string{"ns1", "ns2"},
includes: []string{},
excludes: []string{},
expectedIncludes: nil,
expectedExcludes: nil,
expectError: false,
},
{
name: "complex patterns",
activeNamespaces: []string{"my-app-prod", "my-app-staging", "your-app-prod", "system-ns"},
includes: []string{"*-app-*"},
excludes: []string{"*-staging"},
expectedIncludes: []string{"my-app-prod", "my-app-staging", "your-app-prod"},
expectedExcludes: []string{"my-app-staging"},
expectError: false,
},
{
name: "double asterisk should error",
activeNamespaces: []string{"ns1", "ns2", "ns3"},
includes: []string{"**"},
excludes: []string{},
expectedIncludes: nil,
expectedExcludes: nil,
expectError: true, // ** is invalid
},
{
name: "double asterisk in pattern should error",
activeNamespaces: []string{"ns1", "ns2", "ns3"},
includes: []string{"app-**"},
excludes: []string{},
expectedIncludes: nil,
expectedExcludes: nil,
expectError: true, // app-** contains ** which is invalid
},
{
name: "question mark patterns",
activeNamespaces: []string{"ns1", "ns2", "ns10", "test"},
includes: []string{"ns?"},
excludes: []string{},
expectedIncludes: []string{"ns1", "ns2"}, // ? matches single character
expectedExcludes: nil,
expectError: false,
},
{
name: "character class patterns",
activeNamespaces: []string{"nsa", "nsb", "nsc", "nsx", "ns1"},
includes: []string{"ns[abc]"},
excludes: []string{},
expectedIncludes: []string{"nsa", "nsb", "nsc"}, // [abc] matches a, b, or c
expectedExcludes: nil,
expectError: false,
},
{
name: "brace alternative patterns",
activeNamespaces: []string{"app-prod", "app-staging", "app-dev", "db-prod"},
includes: []string{"app-{prod,staging}"},
excludes: []string{},
expectedIncludes: nil,
expectedExcludes: nil,
expectError: true,
},
{
name: "literal dot and plus patterns",
activeNamespaces: []string{"app.prod", "app-prod", "app+", "app"},
includes: []string{"app.prod", "app+"},
excludes: []string{},
expectedIncludes: []string{"app.prod", "app+"}, // . and + are literal
expectedExcludes: nil,
expectError: false,
},
{
name: "unsupported regex patterns should error",
activeNamespaces: []string{"ns1", "ns2"},
includes: []string{"ns(1|2)"},
excludes: []string{},
expectedIncludes: nil,
expectedExcludes: nil,
expectError: true, // |, (, ) are not supported
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
includes, excludes, err := ExpandWildcards(tt.activeNamespaces, tt.includes, tt.excludes)
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.ElementsMatch(t, tt.expectedIncludes, includes)
assert.ElementsMatch(t, tt.expectedExcludes, excludes)
})
}
}
func TestExpandWildcardsPrivate(t *testing.T) {
tests := []struct {
name string
patterns []string
activeNamespaces []string
expected []string
expectError bool
}{
{
name: "empty patterns",
patterns: []string{},
activeNamespaces: []string{"ns1", "ns2"},
expected: nil,
expectError: false,
},
{
name: "non-wildcard patterns",
patterns: []string{"ns1", "ns3"},
activeNamespaces: []string{"ns1", "ns2"},
expected: []string{"ns1", "ns3"}, // includes ns3 even if not in active
expectError: false,
},
{
name: "star pattern",
patterns: []string{"*"},
activeNamespaces: []string{"ns1", "ns2", "ns3"},
expected: []string{"ns1", "ns2", "ns3"},
expectError: false,
},
{
name: "simple wildcard",
patterns: []string{"app-*"},
activeNamespaces: []string{"app-prod", "app-staging", "db-prod"},
expected: []string{"app-prod", "app-staging"},
expectError: false,
},
{
name: "multiple patterns",
patterns: []string{"app-*", "db-prod", "*-test"},
activeNamespaces: []string{"app-prod", "app-staging", "db-prod", "service-test", "other"},
expected: []string{"app-prod", "app-staging", "db-prod", "service-test"},
expectError: false,
},
{
name: "wildcard with no matches",
patterns: []string{"missing-*"},
activeNamespaces: []string{"app-prod", "db-staging"},
expected: []string{}, // returns empty slice, not nil
expectError: false,
},
{
name: "duplicate matches from multiple patterns",
patterns: []string{"app-*", "*-prod"},
activeNamespaces: []string{"app-prod", "app-staging", "db-prod"},
expected: []string{"app-prod", "app-staging", "db-prod"}, // no duplicates
expectError: false,
},
{
name: "question mark pattern - glob wildcard",
patterns: []string{"ns?"},
activeNamespaces: []string{"ns1", "ns2", "ns10"},
expected: []string{"ns1", "ns2"}, // ? is a glob pattern for single character
expectError: false,
},
{
name: "character class patterns",
patterns: []string{"ns[12]"},
activeNamespaces: []string{"ns1", "ns2", "ns3", "nsa"},
expected: []string{"ns1", "ns2"}, // [12] matches 1 or 2
expectError: false,
},
{
name: "character range patterns",
patterns: []string{"ns[a-c]"},
activeNamespaces: []string{"nsa", "nsb", "nsc", "nsd", "ns1"},
expected: []string{"nsa", "nsb", "nsc"}, // [a-c] matches a to c
expectError: false,
},
{
name: "double asterisk should error",
patterns: []string{"**"},
activeNamespaces: []string{"app-prod", "app.staging", "db/prod"},
expected: nil,
expectError: true, // ** is not allowed
},
{
name: "unsupported regex symbols should error",
patterns: []string{"ns(1|2)"},
activeNamespaces: []string{"ns1", "ns2"},
expected: nil,
expectError: true, // |, (, ) not supported
},
{
name: "double asterisk should error",
patterns: []string{"**"},
activeNamespaces: []string{"ns1", "ns2"},
expected: nil,
expectError: true, // ** not allowed
},
{
name: "double asterisk in pattern should error",
patterns: []string{"app-**-prod"},
activeNamespaces: []string{"app-prod"},
expected: nil,
expectError: true, // ** not allowed anywhere
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := expandWildcards(tt.patterns, tt.activeNamespaces)
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
if tt.expected == nil {
assert.Nil(t, result)
} else if len(tt.expected) == 0 {
assert.Empty(t, result)
} else {
assert.ElementsMatch(t, tt.expected, result)
}
})
}
}
func TestValidateBracePatterns(t *testing.T) {
tests := []struct {
name string
pattern string
expectError bool
errorMsg string
}{
// Valid square bracket patterns
{
name: "valid square bracket pattern",
pattern: "ns[abc]",
expectError: false,
},
{
name: "valid square bracket pattern with range",
pattern: "ns[a-z]",
expectError: false,
},
{
name: "valid square bracket pattern with numbers",
pattern: "ns[0-9]",
expectError: false,
},
{
name: "valid square bracket pattern with mixed",
pattern: "ns[a-z0-9]",
expectError: false,
},
{
name: "valid square bracket pattern with single character",
pattern: "ns[a]",
expectError: false,
},
{
name: "valid square bracket pattern with text before and after",
pattern: "prefix-[abc]-suffix",
expectError: false,
},
// Unclosed opening brackets
{
name: "unclosed opening bracket at end",
pattern: "ns[abc",
expectError: true,
errorMsg: "unclosed bracket",
},
{
name: "unclosed opening bracket at start",
pattern: "[abc",
expectError: true,
errorMsg: "unclosed bracket",
},
{
name: "unclosed opening bracket in middle",
pattern: "ns[abc-test",
expectError: true,
errorMsg: "unclosed bracket",
},
// Unmatched closing brackets
{
name: "unmatched closing bracket at end",
pattern: "ns-abc]",
expectError: true,
errorMsg: "unmatched closing bracket",
},
{
name: "unmatched closing bracket at start",
pattern: "]ns-abc",
expectError: true,
errorMsg: "unmatched closing bracket",
},
{
name: "unmatched closing bracket in middle",
pattern: "ns-]abc",
expectError: true,
errorMsg: "unmatched closing bracket",
},
{
name: "extra closing bracket after valid pair",
pattern: "ns[abc]]",
expectError: true,
errorMsg: "unmatched closing bracket",
},
// Empty bracket patterns
{
name: "completely empty brackets",
pattern: "ns[]",
expectError: true,
errorMsg: "empty bracket pattern",
},
{
name: "empty brackets at start",
pattern: "[]ns",
expectError: true,
errorMsg: "empty bracket pattern",
},
{
name: "empty brackets standalone",
pattern: "[]",
expectError: true,
errorMsg: "empty bracket pattern",
},
// Edge cases
{
name: "empty pattern",
pattern: "",
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateBracePatterns(tt.pattern)
if tt.expectError {
require.Error(t, err, "Expected error for pattern: %s", tt.pattern)
if tt.errorMsg != "" {
assert.Contains(t, err.Error(), tt.errorMsg, "Error message should contain: %s", tt.errorMsg)
}
} else {
assert.NoError(t, err, "Expected no error for pattern: %s", tt.pattern)
}
})
}
}
// Edge case tests
func TestExpandWildcardsEdgeCases(t *testing.T) {
t.Run("nil inputs", func(t *testing.T) {
includes, excludes, err := ExpandWildcards(nil, nil, nil)
require.NoError(t, err)
assert.Nil(t, includes)
assert.Nil(t, excludes)
})
t.Run("empty string patterns", func(t *testing.T) {
activeNamespaces := []string{"ns1", "ns2"}
result, err := expandWildcards([]string{""}, activeNamespaces)
require.NoError(t, err)
assert.ElementsMatch(t, []string{""}, result) // empty string is treated as literal
})
t.Run("whitespace patterns", func(t *testing.T) {
activeNamespaces := []string{"ns1", " ", "ns2"}
result, err := expandWildcards([]string{" "}, activeNamespaces)
require.NoError(t, err)
assert.ElementsMatch(t, []string{" "}, result)
})
t.Run("special characters in namespace names", func(t *testing.T) {
activeNamespaces := []string{"ns-1", "ns_2", "ns.3", "ns@4"}
result, err := expandWildcards([]string{"ns*"}, activeNamespaces)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"ns-1", "ns_2", "ns.3", "ns@4"}, result)
})
t.Run("mixed literal and wildcard patterns", func(t *testing.T) {
activeNamespaces := []string{"app.prod", "app-prod", "app_prod", "test.ns"}
result, err := expandWildcards([]string{"app.prod", "app?prod"}, activeNamespaces)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"app.prod", "app-prod", "app_prod"}, result)
})
t.Run("conservative asterisk validation", func(t *testing.T) {
tests := []struct {
name string
pattern string
shouldError bool
}{
{"single asterisk", "*", false},
{"double asterisk", "**", true},
{"triple asterisk", "***", true},
{"quadruple asterisk", "****", true},
{"mixed with double", "app-**", true},
{"double in middle", "app-**-prod", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := expandWildcards([]string{tt.pattern}, []string{"test"})
if tt.shouldError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
})
t.Run("malformed pattern validation", func(t *testing.T) {
tests := []struct {
name string
pattern string
shouldError bool
}{
{"unclosed bracket", "ns[abc", true},
{"valid bracket", "ns[abc]", false},
{"empty bracket", "ns[]", true}, // empty brackets are invalid
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := expandWildcards([]string{tt.pattern}, []string{"test"})
if tt.shouldError {
assert.Error(t, err, "Expected error for pattern: %s", tt.pattern)
} else {
assert.NoError(t, err, "Expected no error for pattern: %s", tt.pattern)
}
})
}
})
}