Skip to content

Commit 09f25d0

Browse files
authored
Add unknown validation state for intrinsic functions in composite validators (#4384)
- Add unknown field to ValidationError and unresolvable_function_mode to Context - Update composite validators (if, oneOf, anyOf, allOf, not, contains) to propagate unknown errors when functions cannot be resolved - Add default function validators in _keywords_cfn.py that return unknown in unresolvable mode, overridden by full function rules at runtime - Filter unknown errors in Base.py and CfnLintJsonSchema - Return unknown from BaseFn.validate() in unresolvable mode - Fix oneOf to collect valid schemas in single pass
1 parent 6e5bf6a commit 09f25d0

File tree

11 files changed

+425
-59
lines changed

11 files changed

+425
-59
lines changed

src/cfnlint/context/context.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,11 @@ class Context:
164164

165165
strict_types: bool = field(init=True, default=True)
166166

167+
# When True, function validators should return unknown errors
168+
# instead of validating. Used in composite validators
169+
# (if/then/else, oneOf, anyOf) to handle unresolvable functions
170+
unresolvable_function_mode: bool = field(init=True, default=False)
171+
167172
pseudo_parameters: Set[str] = field(
168173
init=True, default_factory=lambda: set(PSEUDOPARAMS)
169174
)

src/cfnlint/jsonschema/_keywords.py

Lines changed: 145 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,33 @@ def allOf(
8585
validator = validator.evolve(
8686
function_filter=validator.function_filter.evolve(
8787
add_cfn_lint_keyword=False,
88-
)
88+
),
89+
context=validator.context.evolve(
90+
unresolvable_function_mode=True,
91+
),
8992
)
93+
has_unknown = False
94+
known_errors = []
95+
9096
for index, subschema in enumerate(allOf):
91-
yield from validator.descend(instance, subschema, schema_path=index)
97+
errs = list(validator.descend(instance, subschema, schema_path=index))
98+
99+
if any(getattr(err, "unknown", False) for err in errs):
100+
has_unknown = True
101+
else:
102+
known_errors.extend(errs)
103+
104+
# If we have unknown branches, we can't determine if allOf is satisfied
105+
if has_unknown:
106+
yield ValidationError(
107+
f"Cannot determine allOf for {instance!r}",
108+
unknown=True,
109+
)
110+
return
111+
112+
# Yield all known errors
113+
for err in known_errors:
114+
yield err
92115

93116

94117
def anyOf(
@@ -97,10 +120,16 @@ def anyOf(
97120
validator = validator.evolve(
98121
function_filter=validator.function_filter.evolve(
99122
add_cfn_lint_keyword=False,
100-
)
123+
),
124+
context=validator.context.evolve(
125+
unresolvable_function_mode=True,
126+
),
101127
)
102128
all_errors = []
103129
other_errors = []
130+
has_valid = False
131+
has_unknown = False
132+
104133
for index, subschema in enumerate(anyOf):
105134
errs = []
106135
# warning and informational shouldn't count towards if anyOf is
@@ -110,14 +139,32 @@ def anyOf(
110139
other_errors.append(err)
111140
continue
112141
errs.append(err)
113-
if not errs:
142+
143+
if any(getattr(err, "unknown", False) for err in errs):
144+
has_unknown = True
145+
elif not errs:
146+
has_valid = True
114147
break
115-
all_errors.extend(errs)
116-
else:
148+
else:
149+
all_errors.extend(errs)
150+
151+
# If we found a valid branch, we're done
152+
if has_valid:
153+
return
154+
155+
# If we have unknown branches, we can't determine
156+
if has_unknown:
117157
yield ValidationError(
118-
f"{instance!r} is not valid under any of the given schemas",
119-
context=all_errors + other_errors,
158+
f"Cannot determine anyOf for {instance!r}",
159+
unknown=True,
120160
)
161+
return
162+
163+
# All known branches failed
164+
yield ValidationError(
165+
f"{instance!r} is not valid under any of the given schemas",
166+
context=all_errors + other_errors,
167+
)
121168

122169

123170
def const(
@@ -134,22 +181,39 @@ def contains(
134181
return
135182

136183
matches = 0
184+
unknown_count = 0
137185
min_contains = schema.get("minContains", 1)
138186
max_contains = schema.get("maxContains", len(instance))
139187

140188
contains_validator = validator.evolve(schema=contains)
141189

142190
for each in instance:
143-
if contains_validator.is_valid(each):
144-
matches += 1
145-
if matches > max_contains:
146-
yield ValidationError(
147-
"Too many items match the given schema "
148-
f"(expected at most {max_contains})",
149-
validator="maxContains",
150-
validator_value=max_contains,
151-
)
152-
return
191+
errs = list(contains_validator.iter_errors(each))
192+
# Filter unknown errors
193+
non_unknown_errs = [err for err in errs if not err.unknown]
194+
195+
if not non_unknown_errs:
196+
# If no non-unknown errors, it's either valid or unknown
197+
if any(err.unknown for err in errs):
198+
unknown_count += 1
199+
else:
200+
matches += 1
201+
if matches > max_contains:
202+
yield ValidationError(
203+
"Too many items match the given schema "
204+
f"(expected at most {max_contains})",
205+
validator="maxContains",
206+
validator_value=max_contains,
207+
)
208+
return
209+
210+
# If we have unknown items, we can't determine if contains is satisfied
211+
if unknown_count > 0 and matches < min_contains:
212+
yield ValidationError(
213+
"Cannot determine if contains constraint is satisfied",
214+
unknown=True,
215+
)
216+
return
153217

154218
if matches < min_contains:
155219
if not matches:
@@ -314,10 +378,22 @@ def if_(
314378
if_validator = validator.evolve(
315379
context=validator.context.evolve(
316380
allow_exceptions=False,
381+
unresolvable_function_mode=True,
317382
)
318383
)
319384

320-
if if_validator.evolve(schema=if_schema).is_valid(instance):
385+
if_errors = list(if_validator.evolve(schema=if_schema).iter_errors(instance))
386+
387+
# If any error is unknown, we can't determine the condition
388+
if any(getattr(err, "unknown", False) for err in if_errors):
389+
yield ValidationError(
390+
f"Cannot determine if condition for {instance!r}",
391+
unknown=True,
392+
)
393+
return
394+
395+
# Original logic
396+
if not if_errors:
321397
if "then" in schema:
322398
then = schema["then"]
323399
yield from validator.descend(instance, then, schema_path="then")
@@ -477,9 +553,24 @@ def not_(
477553
validator = validator.evolve(
478554
function_filter=validator.function_filter.evolve(
479555
add_cfn_lint_keyword=False,
480-
)
556+
),
557+
context=validator.context.evolve(
558+
unresolvable_function_mode=True,
559+
),
481560
)
482-
if validator.evolve(schema=not_schema).is_valid(instance):
561+
562+
errs = list(validator.evolve(schema=not_schema).iter_errors(instance))
563+
564+
# If there are unknown errors, we can't determine if not is satisfied
565+
if any(getattr(err, "unknown", False) for err in errs):
566+
yield ValidationError(
567+
f"Cannot determine not for {instance!r}",
568+
unknown=True,
569+
)
570+
return
571+
572+
# If no errors, the schema is valid, so 'not' fails
573+
if not errs:
483574
message = f"{instance!r} should not be valid under {not_schema!r}"
484575
yield ValidationError(message)
485576

@@ -490,30 +581,48 @@ def oneOf(
490581
validator = validator.evolve(
491582
function_filter=validator.function_filter.evolve(
492583
add_cfn_lint_keyword=False,
493-
)
584+
),
585+
context=validator.context.evolve(
586+
unresolvable_function_mode=True,
587+
),
494588
)
495589
subschemas = enumerate(oneOf)
496590
all_errors = []
591+
valid_count = 0
592+
has_unknown = False
593+
valid_schemas = []
594+
497595
for index, subschema in subschemas:
498596
errs = list(validator.descend(instance, subschema, schema_path=index))
499-
if not errs:
500-
first_valid = subschema
501-
break
502-
all_errors.extend(errs)
503-
else:
597+
598+
if any(getattr(err, "unknown", False) for err in errs):
599+
has_unknown = True
600+
elif not errs:
601+
valid_count += 1
602+
valid_schemas.append(subschema)
603+
else:
604+
all_errors.extend(errs)
605+
606+
# If we can't determine, yield unknown error
607+
if has_unknown and valid_count < 2:
608+
yield ValidationError(
609+
f"Cannot determine oneOf for {instance!r}",
610+
unknown=True,
611+
)
612+
return
613+
614+
# Definitive results
615+
if valid_count == 1:
616+
return
617+
618+
if valid_count == 0:
504619
yield ValidationError(
505620
f"{instance!r} is not valid under any of the given schemas",
506621
context=all_errors,
507622
)
508-
509-
more_valid = [
510-
each
511-
for _, each in subschemas
512-
if validator.evolve(schema=each).is_valid(instance)
513-
]
514-
if more_valid:
515-
more_valid.append(first_valid)
516-
reprs = ", ".join(repr(schema) for schema in more_valid)
623+
else:
624+
# Multiple valid schemas
625+
reprs = ", ".join(repr(schema) for schema in valid_schemas)
517626
yield ValidationError(f"{instance!r} is valid under each of {reprs}")
518627

519628

src/cfnlint/jsonschema/_keywords_cfn.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,40 @@ def cfn_type(validator: Validator, tS: Any, instance: Any, schema: Any):
223223
yield ValidationError(f"{instance!r} is not of type {reprs}")
224224

225225

226+
def _function_unknown(
227+
validator: Validator, s: Any, instance: Any, schema: dict[str, Any]
228+
) -> ValidationResult:
229+
"""Default validator for CloudFormation intrinsic functions.
230+
Returns unknown error in unresolvable mode to prevent incorrect validation.
231+
Overridden by function rules in full validation environments."""
232+
if validator.context.unresolvable_function_mode:
233+
yield ValidationError(
234+
"Cannot resolve function in composite validation",
235+
unknown=True,
236+
)
237+
# In normal mode, do nothing - let the function pass through
238+
return
239+
240+
226241
cfn_validators: dict[str, V] = {
227242
"additionalProperties": additionalProperties,
228243
"cfnContext": cfnContext,
229244
"dynamicValidation": dynamicValidation,
230245
"type": cfn_type,
246+
# CloudFormation intrinsic functions - default to unknown
247+
"ref": _function_unknown,
248+
"fn_base64": _function_unknown,
249+
"fn_cidr": _function_unknown,
250+
"fn_findinmap": _function_unknown,
251+
"fn_getatt": _function_unknown,
252+
"fn_getazs": _function_unknown,
253+
"fn_importvalue": _function_unknown,
254+
"fn_join": _function_unknown,
255+
"fn_select": _function_unknown,
256+
"fn_split": _function_unknown,
257+
"fn_sub": _function_unknown,
258+
"fn_transform": _function_unknown,
259+
"fn_tojsonstring": _function_unknown,
260+
"fn_length": _function_unknown,
261+
"fn_if": _function_unknown,
231262
}

src/cfnlint/jsonschema/exceptions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def __init__(
5656
extra_args=None,
5757
rule: CloudFormationLintRule | None = None,
5858
path_override: Deque | Tuple = (),
59+
unknown: bool = False,
5960
):
6061
super().__init__(
6162
message,
@@ -83,6 +84,7 @@ def __init__(
8384
self.extra_args = extra_args or {}
8485
self.rule = rule
8586
self.path_override = deque(path_override)
87+
self.unknown = unknown
8688

8789
for error in context:
8890
error.parent = self

src/cfnlint/rules/functions/_BaseFn.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,14 @@ def validate(
174174
instance: Any,
175175
schema: Any,
176176
) -> ValidationResult:
177+
# If in unresolvable mode, return unknown error immediately
178+
if validator.context.unresolvable_function_mode:
179+
yield ValidationError(
180+
f"Cannot resolve {self.fn.name} in composite validation",
181+
unknown=True,
182+
)
183+
return
184+
177185
# validate this function will return the correct type
178186
errs = list(
179187
self.fix_errors(self.validate_fn_output_types(validator, s, instance))

src/cfnlint/rules/jsonschema/Base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ def _convert_validation_errors_to_matches(
3232
path: Sequence[str],
3333
e: ValidationError,
3434
):
35+
# Don't convert unknown errors to matches
36+
if getattr(e, "unknown", False):
37+
return []
38+
3539
matches = []
3640
kwargs: dict[Any, Any] = {}
3741

src/cfnlint/rules/jsonschema/CfnLintJsonSchema.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def message(self, instance: Any, err: ValidationError) -> str:
4545
return self.shortdesc
4646

4747
def _iter_errors(self, validator, instance):
48-
errs = list(validator.iter_errors(instance))
48+
errs = [err for err in validator.iter_errors(instance) if not err.unknown]
4949
if not self.all_matches:
5050
err = best_match(errs)
5151
if err is not None:
@@ -75,9 +75,10 @@ def validate(
7575
),
7676
schema=schema,
7777
context=validator.context.evolve(
78-
functions=[],
79-
strict_types=True,
78+
unresolvable_function_mode=True,
8079
),
8180
)
8281

83-
yield from self._iter_errors(cfn_validator, instance)
82+
for err in self._iter_errors(cfn_validator, instance):
83+
if not getattr(err, "unknown", False):
84+
yield err

0 commit comments

Comments
 (0)