From c020cad33cbc00916e28661d1a7aaacdfe0dd305 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Tue, 8 Sep 2026 11:04:25 +0200 Subject: [PATCH] loader: fail-closed allowlist screening on top of the unsupported-attributes check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deny patterns alone reproduce the problem they solve: every attribute the specification gains later falls through silently until someone adds a check. This layers the complementary allowlist onto UnsupportedAttributesCheck, sharing its walk and report: - Supported (or the composable WithSupportedAttributes option) declares the attribute paths the runtime implements; anything matching none of them is reported alongside the deny-pattern findings. Extension keys (x-*) and everything under them are exempt from this rule set (deny patterns still apply below them). - schema.AttributePaths derives the complete attribute-path inventory from the embedded JSON schema (pattern-keyed mappings as '*', sequence items as '[]', $ref cycles terminated, '^x-' pattern properties excluded, since a wildcard there would blanket-accept every undeclared sibling). A runtime builds its declaration by removing what it does not implement from the full specification. - tree.Matcher gives Path pattern sets a first-class matching API: exact Matches — declaring a path accepts that node only, so removing one leaf reliably surfaces it — plus MayContain for walkers that must know when a declared attribute lives deeper. Matching stays exact by design: subtree removal reports once at its root, leaf removal reports each leaf, and declared siblings keep the descent open. Signed-off-by: Nicolas De Loof --- loader/loader.go | 4 +- loader/unsupported_attributes.go | 87 +++++++++++++--- loader/unsupported_attributes_test.go | 133 ++++++++++++++++++++++- schema/paths.go | 145 ++++++++++++++++++++++++++ schema/paths_test.go | 58 +++++++++++ tree/matcher.go | 74 +++++++++++++ tree/matcher_test.go | 50 +++++++++ 7 files changed, 530 insertions(+), 21 deletions(-) create mode 100644 schema/paths.go create mode 100644 schema/paths_test.go create mode 100644 tree/matcher.go create mode 100644 tree/matcher_test.go diff --git a/loader/loader.go b/loader/loader.go index 99e7272af..17a7abc83 100644 --- a/loader/loader.go +++ b/loader/loader.go @@ -602,8 +602,8 @@ func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options, // per `include:`d file): unlike validation.Validate, this is a batch scan // with no per-file/fail-fast need, so it must not ride along on // loadYamlModel's recursive call site or it fires once per included file. - if check := opts.UnsupportedAttributesCheck; check != nil { - check.Report(detectUnsupportedAttributes(dict, check.Patterns)) + if check := opts.UnsupportedAttributesCheck; check != nil && check.Report != nil { + check.Report(detectUnsupportedAttributes(dict, check)) } if !opts.SkipNormalization { diff --git a/loader/unsupported_attributes.go b/loader/unsupported_attributes.go index 163115b7a..1c2daa29d 100644 --- a/loader/unsupported_attributes.go +++ b/loader/unsupported_attributes.go @@ -19,6 +19,7 @@ package loader import ( "cmp" "slices" + "strings" "github.com/compose-spec/compose-go/v2/tree" ) @@ -53,35 +54,77 @@ type UnsupportedAttributePattern struct { Detect func(value any) bool } -// UnsupportedAttributesCheck bundles a set of UnsupportedAttributePattern -// with the callback invoked once, after loading, with every match found -// (possibly empty). +// UnsupportedAttributesCheck bundles the detection rules with the callback +// invoked once, after loading, with every match found (possibly empty). +// +// Detection combines two complementary rule sets over one walk: +// +// - Patterns is a denylist: attributes the caller knows it does not honor, +// with optional value predicates for cases like `ports[].mode: host`. +// - Supported is an allowlist: when non-empty, every attribute matching +// none of its paths is reported too. It makes the screening fail-closed — +// an attribute the specification gains later is reported until the +// runtime deliberately declares it. schema.AttributePaths returns the +// full specification inventory to build it from (remove what the runtime +// does not implement); extension keys (x-*) are never reported by this +// rule set. type UnsupportedAttributesCheck struct { - Patterns []UnsupportedAttributePattern - Report func([]UnsupportedAttribute) + Patterns []UnsupportedAttributePattern + Supported []tree.Path + Report func([]UnsupportedAttribute) } // WithUnsupportedAttributesCheck registers a set of UnsupportedAttributePattern // to be evaluated against the loaded model. report is invoked once, after -// loading, with every match found (possibly empty). +// loading, with every match found (possibly empty). Composable with +// WithSupportedAttributes: both feed the same walk and report. func WithUnsupportedAttributesCheck(patterns []UnsupportedAttributePattern, report func([]UnsupportedAttribute)) func(*Options) { return func(opts *Options) { - opts.UnsupportedAttributesCheck = &UnsupportedAttributesCheck{Patterns: patterns, Report: report} + if opts.UnsupportedAttributesCheck == nil { + opts.UnsupportedAttributesCheck = &UnsupportedAttributesCheck{} + } + opts.UnsupportedAttributesCheck.Patterns = patterns + opts.UnsupportedAttributesCheck.Report = report + } +} + +// WithSupportedAttributes declares the attribute paths the caller's runtime +// implements: every attribute of the loaded model matching none of them is +// reported, alongside any WithUnsupportedAttributesCheck findings, through +// the same report callback (report may be nil when the other option already +// set one). Extension keys (x-*) are never reported. +// +// This is the fail-closed side of the check: built by removing the +// unimplemented paths from schema.AttributePaths, it keeps reporting every +// newly-specified attribute until the runtime deliberately wires it in. +func WithSupportedAttributes(supported []tree.Path, report func([]UnsupportedAttribute)) func(*Options) { + return func(opts *Options) { + if opts.UnsupportedAttributesCheck == nil { + opts.UnsupportedAttributesCheck = &UnsupportedAttributesCheck{} + } + opts.UnsupportedAttributesCheck.Supported = supported + if report != nil { + opts.UnsupportedAttributesCheck.Report = report + } } } -// detectUnsupportedAttributes walks dict and returns every match against -// patterns, ordered by Path. The walk itself visits map keys in Go's +// detectUnsupportedAttributes walks dict once and returns every finding from +// both rule sets, ordered by Path. The walk itself visits map keys in Go's // randomized order, so results are sorted here for deterministic output. -func detectUnsupportedAttributes(dict map[string]any, patterns []UnsupportedAttributePattern) []UnsupportedAttribute { - findings := walkUnsupportedAttributes(dict, tree.NewPath(), patterns) +func detectUnsupportedAttributes(dict map[string]any, check *UnsupportedAttributesCheck) []UnsupportedAttribute { + var supported *tree.Matcher + if len(check.Supported) > 0 { + supported = tree.NewMatcher(check.Supported...) + } + findings := walkUnsupportedAttributes(dict, tree.NewPath(), check.Patterns, supported) slices.SortFunc(findings, func(a, b UnsupportedAttribute) int { return cmp.Compare(a.Path, b.Path) }) return findings } -func walkUnsupportedAttributes(value any, p tree.Path, patterns []UnsupportedAttributePattern) []UnsupportedAttribute { +func walkUnsupportedAttributes(value any, p tree.Path, patterns []UnsupportedAttributePattern, supported *tree.Matcher) []UnsupportedAttribute { matched := false for _, pattern := range patterns { if !p.Matches(pattern.Path) { @@ -102,11 +145,27 @@ func walkUnsupportedAttributes(value any, p tree.Path, patterns []UnsupportedAtt switch v := value.(type) { case map[string]any: for k, e := range v { - findings = append(findings, walkUnsupportedAttributes(e, p.Next(k), patterns)...) + next := p.Next(k) + // Extension keys are specification-blessed escape hatches: the + // allowlist never reports them nor anything underneath (deny + // patterns still apply below, so the walk continues without the + // matcher). + if strings.HasPrefix(k, "x-") { + findings = append(findings, walkUnsupportedAttributes(e, next, patterns, nil)...) + continue + } + // allowlist screening: an attribute neither declared (exact + // match) nor holding declared attributes deeper (MayContain) is + // reported once, undescended + if supported != nil && !supported.Matches(next) && !supported.MayContain(next) { + findings = append(findings, UnsupportedAttribute{Path: next, Value: e}) + continue + } + findings = append(findings, walkUnsupportedAttributes(e, next, patterns, supported)...) } case []any: for _, e := range v { - findings = append(findings, walkUnsupportedAttributes(e, p.Next(tree.PathMatchList), patterns)...) + findings = append(findings, walkUnsupportedAttributes(e, p.Next(tree.PathMatchList), patterns, supported)...) } } return findings diff --git a/loader/unsupported_attributes_test.go b/loader/unsupported_attributes_test.go index 3cf177e07..20e637a59 100644 --- a/loader/unsupported_attributes_test.go +++ b/loader/unsupported_attributes_test.go @@ -18,8 +18,11 @@ package loader import ( "context" + "slices" + "strings" "testing" + "github.com/compose-spec/compose-go/v2/schema" "github.com/compose-spec/compose-go/v2/tree" "github.com/compose-spec/compose-go/v2/types" "gotest.tools/v3/assert" @@ -44,7 +47,7 @@ func TestDetectUnsupportedAttributes_PathPresence(t *testing.T) { {Path: tree.NewPath("services", "*", "deploy", "mode")}, } - findings := detectUnsupportedAttributes(dict, patterns) + findings := detectUnsupportedAttributes(dict, &UnsupportedAttributesCheck{Patterns: patterns}) assert.Assert(t, is.Len(findings, 1)) assert.Equal(t, findings[0].Path, tree.NewPath("services", "web", "deploy", "mode")) @@ -75,7 +78,7 @@ func TestDetectUnsupportedAttributes_ValueConditional(t *testing.T) { }, } - findings := detectUnsupportedAttributes(dict, patterns) + findings := detectUnsupportedAttributes(dict, &UnsupportedAttributesCheck{Patterns: patterns}) assert.Assert(t, is.Len(findings, 1)) assert.Equal(t, findings[0].Path, tree.NewPath("services", "web", "ports", "[]")) @@ -93,7 +96,7 @@ func TestDetectUnsupportedAttributes_NoPatternsMatch(t *testing.T) { {Path: tree.NewPath("services", "*", "deploy", "mode")}, } - findings := detectUnsupportedAttributes(dict, patterns) + findings := detectUnsupportedAttributes(dict, &UnsupportedAttributesCheck{Patterns: patterns}) assert.Assert(t, is.Len(findings, 0)) } @@ -116,7 +119,7 @@ func TestDetectUnsupportedAttributes_DoesNotDescendIntoMatchedNode(t *testing.T) {Path: tree.NewPath("services", "*", "ports", "[]", "mode")}, } - findings := detectUnsupportedAttributes(dict, patterns) + findings := detectUnsupportedAttributes(dict, &UnsupportedAttributesCheck{Patterns: patterns}) assert.Assert(t, is.Len(findings, 1)) assert.Equal(t, findings[0].Path, tree.NewPath("services", "web", "ports", "[]")) @@ -157,7 +160,7 @@ func TestDetectUnsupportedAttributes_AllPatternsAtSamePathAreEvaluated(t *testin }, } - findings := detectUnsupportedAttributes(dict, patterns) + findings := detectUnsupportedAttributes(dict, &UnsupportedAttributesCheck{Patterns: patterns}) assert.Assert(t, is.Len(findings, 1)) assert.Equal(t, findings[0].Path, tree.NewPath("services", "web", "depends_on", "api")) @@ -241,3 +244,123 @@ services: assert.Equal(t, reported[0].Path, tree.NewPath("services", "web", "deploy", "mode")) }) } + +// The allowlist side: the runtime declares the full specification inventory +// minus what it does not implement, and the same report receives every +// undeclared attribute — fail-closed, so a newly-specified attribute keeps +// being reported until it is deliberately wired in. +func TestDetectUnsupportedAttributes_SupportedAllowlist(t *testing.T) { + supported := slices.DeleteFunc(slices.Clone(schema.AttributePaths()), func(p tree.Path) bool { + return strings.HasPrefix(string(p), "services.*.credential_spec") || + p == "services.*.deploy.update_config.failure_action" || + p == "services.*.deploy.update_config.parallelism" + }) + + var reported []UnsupportedAttribute + _, err := LoadWithContext(context.Background(), types.ConfigDetails{ + ConfigFiles: []types.ConfigFile{{Filename: "compose.yaml", Content: []byte(` +services: + app: + image: myapp + environment: + DEBUG: "1" + deploy: + replicas: 2 + update_config: + parallelism: 1 + failure_action: rollback + credential_spec: + file: creds.json + x-custom: + anything: goes +`)}}, + }, func(options *Options) { + options.SetProjectName("screening", true) + options.ResolvePaths = false + }, WithSupportedAttributes(supported, func(found []UnsupportedAttribute) { + reported = found + })) + assert.NilError(t, err) + + paths := make([]string, len(reported)) + for i, f := range reported { + paths[i] = f.Path.String() + } + // subtree removal reports once at its root, leaf removal reports each + // leaf (declared siblings keep the descent open); x-* untouched + assert.DeepEqual(t, paths, []string{ + "services.app.credential_spec", + "services.app.deploy.update_config.failure_action", + "services.app.deploy.update_config.parallelism", + }) + assert.Equal(t, reported[1].Value, "rollback") +} + +// Both rule sets feed one walk and one report: deny patterns (value +// predicates included) and the allowlist compose. +func TestDetectUnsupportedAttributes_CombinedRules(t *testing.T) { + dict := map[string]any{ + "services": map[string]any{ + "web": map[string]any{ + "image": "nginx", + "ports": []any{ + map[string]any{"target": 80, "mode": "host"}, + }, + "credential_spec": map[string]any{"file": "creds.json"}, + }, + }, + } + check := &UnsupportedAttributesCheck{ + Patterns: []UnsupportedAttributePattern{{ + Path: tree.NewPath("services", "*", "ports", "[]", "mode"), + Detect: func(value any) bool { + return value == "host" + }, + }}, + Supported: slices.DeleteFunc(slices.Clone(schema.AttributePaths()), func(p tree.Path) bool { + return strings.HasPrefix(string(p), "services.*.credential_spec") + }), + } + + findings := detectUnsupportedAttributes(dict, check) + + assert.Assert(t, is.Len(findings, 2)) + assert.Equal(t, findings[0].Path, tree.NewPath("services", "web", "credential_spec")) + assert.Equal(t, findings[1].Path, tree.NewPath("services", "web", "ports", "[]", "mode")) + assert.Equal(t, findings[1].Value, "host") +} + +// A fully supported file passes the complete inventory silently: the +// allowlist introduces no false positives on canonical forms. +func TestDetectUnsupportedAttributes_AllowlistNoFalsePositives(t *testing.T) { + var reported []UnsupportedAttribute + _, err := LoadWithContext(context.Background(), types.ConfigDetails{ + ConfigFiles: []types.ConfigFile{{Filename: "compose.yaml", Content: []byte(` +services: + db: + image: mysql:8 + volumes: + - data:/var/lib/mysql + networks: + - backend + healthcheck: + test: ["CMD", "mysqladmin", "ping"] + interval: 10s + deploy: + resources: + limits: + memory: 1g +volumes: + data: +networks: + backend: +`)}}, + }, func(options *Options) { + options.SetProjectName("clean", true) + options.ResolvePaths = false + }, WithSupportedAttributes(schema.AttributePaths(), func(found []UnsupportedAttribute) { + reported = found + })) + assert.NilError(t, err) + assert.Assert(t, is.Len(reported, 0)) +} diff --git a/schema/paths.go b/schema/paths.go new file mode 100644 index 000000000..8c508ec01 --- /dev/null +++ b/schema/paths.go @@ -0,0 +1,145 @@ +/* + Copyright 2020 The Compose Specification Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package schema + +import ( + "encoding/json" + "slices" + "strings" + "sync" + + "github.com/compose-spec/compose-go/v2/tree" +) + +// AttributePaths returns the attribute paths ([tree.Path] patterns) the +// Compose Specification JSON schema declares, one per mapping attribute: +// pattern-keyed mappings (services, networks, per-key mounts…) contribute a +// [tree.PathMatchAll] component and sequence items a [tree.PathMatchList] +// component, matching the convention used across compose-go. +// +// It gives a runtime the complete spec-level inventory to build its +// "supported attributes" declaration from (see loader.Options +// SupportedAttributes): start from the full specification and remove the +// paths the runtime does not implement, so every newly-specified attribute is +// reported as unsupported until it is deliberately wired in. +// +// The result is sorted and stable for a given schema; the slice is shared, +// callers must not mutate it (clone before editing). +func AttributePaths() []tree.Path { + attributePathsOnce.Do(func() { + attributePaths = collectAttributePaths() + }) + return attributePaths +} + +var ( + attributePathsOnce sync.Once + attributePaths []tree.Path +) + +func collectAttributePaths() []tree.Path { + var root map[string]any + // the embedded schema is validated by tests; a broken schema would fail + // Validate long before this point + if err := json.Unmarshal([]byte(Schema), &root); err != nil { + panic(err) + } + defs, _ := root["$defs"].(map[string]any) + c := &pathCollector{defs: defs} + c.walk(root, tree.NewPath(), map[string]bool{}) + + paths := make([]tree.Path, 0, len(c.paths)) + for path := range c.paths { + paths = append(paths, tree.Path(path)) + } + slices.Sort(paths) + return paths +} + +type pathCollector struct { + defs map[string]any + paths map[string]bool +} + +// walk visits a schema node and records the attribute paths its object +// properties declare. active tracks the $refs on the current branch so +// self-referencing definitions (include, service.develop…) terminate. +func (c *pathCollector) walk(node map[string]any, path tree.Path, active map[string]bool) { + if c.paths == nil { + c.paths = map[string]bool{} + } + if ref, ok := node["$ref"].(string); ok { + name := strings.TrimPrefix(ref, "#/$defs/") + if active[name] { + return + } + if def, ok := c.defs[name].(map[string]any); ok { + active[name] = true + c.walk(def, path, active) + delete(active, name) + } + return + } + for _, combinator := range []string{"oneOf", "anyOf", "allOf"} { + if alternatives, ok := node[combinator].([]any); ok { + for _, alternative := range alternatives { + if sub, ok := alternative.(map[string]any); ok { + c.walk(sub, path, active) + } + } + } + } + if properties, ok := node["properties"].(map[string]any); ok { + for name, sub := range properties { + next := path.Next(name) + c.paths[string(next)] = true + if subSchema, ok := sub.(map[string]any); ok { + c.walk(subSchema, next, active) + } + } + } + // pattern-keyed mappings (services, networks, x-* extension points…) + // and typed additionalProperties both accept arbitrary keys: a single + // PathMatchAll component stands for them + for _, keyed := range []string{"patternProperties", "additionalProperties"} { + v, ok := node[keyed].(map[string]any) + if ok { + if keyed == "patternProperties" { + for patternKey, sub := range v { + // extension escape hatches ("^x-") are not attributes of + // the specification: recording them as a wildcard would + // blanket-accept every sibling key + if strings.Contains(patternKey, "x-") { + continue + } + if subSchema, ok := sub.(map[string]any); ok { + next := path.Next(tree.PathMatchAll) + c.paths[string(next)] = true + c.walk(subSchema, next, active) + } + } + } else { + next := path.Next(tree.PathMatchAll) + c.paths[string(next)] = true + c.walk(v, next, active) + } + } + } + if items, ok := node["items"].(map[string]any); ok { + c.walk(items, path.Next(tree.PathMatchList), active) + } +} diff --git a/schema/paths_test.go b/schema/paths_test.go new file mode 100644 index 000000000..3fb66864f --- /dev/null +++ b/schema/paths_test.go @@ -0,0 +1,58 @@ +/* + Copyright 2020 The Compose Specification Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package schema + +import ( + "slices" + "strings" + "testing" + + "github.com/compose-spec/compose-go/v2/tree" + "gotest.tools/v3/assert" +) + +func TestAttributePaths(t *testing.T) { + paths := AttributePaths() + + // representative entries across shapes: top level, pattern-keyed + // mappings, nested objects, sequence items, arbitrary-key mappings + for _, expected := range []tree.Path{ + "services", + "services.*", + "services.*.image", + "services.*.deploy.update_config.failure_action", + "services.*.ports.[].target", + "services.*.environment.*", + "volumes.*.driver_opts.*", + "configs.*.file", + } { + assert.Check(t, slices.Contains(paths, expected), "missing %s", expected) + } + + // extension escape hatches must not leak into the inventory as blanket + // wildcards: they would blanket-accept every undeclared sibling + for _, path := range paths { + last := path.Last() + parent := path.Parent() + if last == tree.PathMatchAll && (parent == "services" || parent == "networks" || parent == "volumes" || parent == "configs" || parent == "secrets" || parent == "models") { + continue // the resource maps themselves are legitimately pattern-keyed + } + assert.Check(t, !strings.HasSuffix(string(path), ".*.*"), "suspicious blanket wildcard: %s", path) + } + + assert.Check(t, slices.IsSorted(paths)) +} diff --git a/tree/matcher.go b/tree/matcher.go new file mode 100644 index 000000000..bb8d01a91 --- /dev/null +++ b/tree/matcher.go @@ -0,0 +1,74 @@ +/* + Copyright 2020 The Compose Specification Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package tree + +// Matcher matches concrete attribute paths against a set of declared path +// patterns. Patterns use the same tokens as [Path.Matches]: [PathMatchAll] +// for any mapping key and [PathMatchList] for sequence items. +// +// Matching is exact by construction — declaring a path accepts that node +// only, not its subtree — so removing one leaf from a declared set reliably +// surfaces it. [Matcher.MayContain] tells a caller walking a nested +// structure when descending can still reach a declared node. +type Matcher struct { + patterns [][]string +} + +// NewMatcher returns a Matcher for the given path patterns. +func NewMatcher(patterns ...Path) *Matcher { + m := &Matcher{patterns: make([][]string, 0, len(patterns))} + for _, pattern := range patterns { + m.patterns = append(m.patterns, pattern.Parts()) + } + return m +} + +// Matches reports whether path matches one of the declared patterns. +func (m *Matcher) Matches(path Path) bool { + parts := path.Parts() + for _, pattern := range m.patterns { + if len(pattern) == len(parts) && matchParts(pattern, parts) { + return true + } + } + return false +} + +// MayContain reports whether path is a strict ancestor of at least one +// pattern: even when the node itself is not declared, a declared attribute +// lives somewhere underneath it. +func (m *Matcher) MayContain(path Path) bool { + parts := path.Parts() + for _, pattern := range m.patterns { + if len(pattern) > len(parts) && matchParts(pattern[:len(parts)], parts) { + return true + } + } + return false +} + +func matchParts(pattern, parts []string) bool { + for i, part := range parts { + switch pattern[i] { + case PathMatchAll, part: + continue + default: + return false + } + } + return true +} diff --git a/tree/matcher_test.go b/tree/matcher_test.go new file mode 100644 index 000000000..3f7e0086d --- /dev/null +++ b/tree/matcher_test.go @@ -0,0 +1,50 @@ +/* + Copyright 2020 The Compose Specification Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package tree + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestMatcherMatches(t *testing.T) { + m := NewMatcher( + "services.*.image", + "services.*.ports.[].target", + "services.*.environment.*", + ) + + assert.Check(t, m.Matches("services.web.image")) + assert.Check(t, m.Matches("services.web.ports.[].target")) + assert.Check(t, m.Matches("services.web.environment.DEBUG")) + + assert.Check(t, !(m.Matches("services.web.command")), "undeclared sibling") + assert.Check(t, !(m.Matches("services.web.image.tag")), "matching is exact, not subtree") + assert.Check(t, !(m.Matches("services.web")), "ancestor of a pattern is not a match") +} + +func TestMatcherMayContain(t *testing.T) { + m := NewMatcher("services.*.deploy.update_config.delay") + + assert.Check(t, m.MayContain("services")) + assert.Check(t, m.MayContain("services.web.deploy")) + assert.Check(t, m.MayContain("services.web.deploy.update_config")) + + assert.Check(t, !(m.MayContain("services.web.deploy.update_config.delay")), "a full match is not a strict ancestor") + assert.Check(t, !(m.MayContain("services.web.build"))) +}