1use crate::compiled::{CompiledSchema, ConditionSchema, PropSchema, PropType, SubSchema};
2use crate::store::CompiledSchemaStore;
3use data_source::compiled_schema::{RefSiblings as BuildRefSiblings, compile_condition_schema};
4use diagnostics::{Diagnostic, Phase, RegisteredDiagnostic, ViolationContext, resolve_section_span};
5use rules::format_rule_for_format;
6use std::borrow::Cow;
7use std::collections::hash_map::Entry;
8use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
9use std::sync::{Arc, LazyLock};
10use template_model::coercion::{CoerceResult, coerce_to_number, coerce_to_string, coerce_value, scalar_eq};
11use template_model::conditions::Satisfiability;
12use template_model::consts::{
13 FN_CONDITION, FN_FOR_EACH_KEY_PREFIX, FN_IF, FN_REF, INTRINSIC_FN_PATH_SEGMENTS, KEY_PROPERTIES, KEY_TYPE,
14 MAX_COMPLETE_SCENARIO_PRODUCT, MAX_REQUIRED_PROPERTY_COMBINATIONS, MAX_SCENARIO_PRODUCT_NODES,
15 MAX_SCHEMA_MATCH_DEPTH as MAX_MATCH_DEPTH, MAX_SCHEMA_SCENARIO_ASSIGNMENTS as MAX_GROUP_SCENARIO_ASSIGNMENTS,
16 MAX_SCHEMA_SCENARIO_MERGE_ATTEMPTS as MAX_GROUP_SCENARIO_MERGE_ATTEMPTS, PARAM_TYPE_COMMA_DELIMITED_LIST,
17 PARAM_TYPE_NUMBER, PARAM_TYPE_STRING, PSEUDO_NO_VALUE, SAM_FUNCTION_TYPE, SAM_SERVERLESS_TYPE_PREFIX,
18};
19use template_model::member_presence::{ContainerMember, MemberPresence, classify_members, extend_assignment};
20use template_model::message::{render_str_list, render_value, render_value_list};
21use template_model::model::ResolvedResource;
22use template_model::region_enums;
23use template_model::resolved_value::{
24 collect_scenarios_with_covering, contains_dynamic_resolved, effective_path_from_scenario_source_path,
25 is_resolved_null,
26};
27use template_model::resolver::{RefKind, ResolvedValue};
28use template_model::{
29 BudgetKind, CompiledPattern, IAM_ROLE_ARN_PATTERN, IAM_ROLE_NAME_PATTERN, PSEUDO_PARAMETERS,
30 SECURITY_GROUP_NAME_PATTERN, SemanticModel, compile_pattern, is_custom_resource_type, resolved_value_to_json,
31};
32
33const PACKAGING_PROPERTY_PATHS: &[(&str, &str)] = &[
36 ("AWS::Lambda::Function", "Properties.Code"),
37 ("AWS::Lambda::LayerVersion", "Properties.Content"),
38 ("AWS::ApiGateway::RestApi", "Properties.BodyS3Location"),
39 ("AWS::ElasticBeanstalk::ApplicationVersion", "Properties.SourceBundle"),
40 ("AWS::StepFunctions::StateMachine", "Properties.DefinitionS3Location"),
41 ("AWS::AppSync::GraphQLSchema", "Properties.DefinitionS3Location"),
42 ("AWS::AppSync::Resolver", "Properties.RequestMappingTemplateS3Location"),
43 ("AWS::AppSync::Resolver", "Properties.ResponseMappingTemplateS3Location"),
44 ("AWS::AppSync::FunctionConfiguration", "Properties.RequestMappingTemplateS3Location"),
45 ("AWS::AppSync::FunctionConfiguration", "Properties.ResponseMappingTemplateS3Location"),
46 ("AWS::CloudFormation::Stack", "Properties.TemplateURL"),
47 ("AWS::CodeCommit::Repository", "Properties.Code.S3"),
48];
49
50const TYPE_CHECK_EXEMPT_PATHS: &[(&str, &str)] = &[
53 ("AWS::Lambda::Function", "Properties.Environment.Variables"),
54 ("AWS::Lambda::Function", "Properties.Environment"),
55];
56
57fn is_intrinsic_key(key: &str) -> bool {
64 INTRINSIC_FN_PATH_SEGMENTS.contains(&key)
65 || key == FN_REF
66 || key == FN_CONDITION
67 || key.starts_with(FN_FOR_EACH_KEY_PREFIX)
68}
69
70fn is_unresolved_intrinsic(val: &serde_json::Value) -> bool {
71 let Some(obj) = val.as_object() else { return false };
72 if obj.len() != 1 {
73 return false;
74 }
75 is_intrinsic_key(obj.keys().next().unwrap())
76}
77
78fn condition_context_matches_scenario(
79 m: &Arc<SemanticModel>,
80 context: Option<&str>,
81 scenario: &HashMap<String, bool>,
82) -> bool {
83 let Some(context) = context else {
84 return true;
85 };
86 let mut combined = scenario.clone();
87 for assumption in context.split(',') {
88 let (condition, expected) = assumption.strip_prefix('!').map_or((assumption, true), |name| (name, false));
89 if let Some(actual) = combined.insert(condition.to_string(), expected)
90 && actual != expected
91 {
92 return false;
93 }
94 }
95 is_satisfiable(m, &combined)
96}
97
98fn source_path_matches_effective_property(source_path: &str, prop_path: &str) -> bool {
99 let Some(effective_path) = effective_path_from_scenario_source_path(source_path) else {
100 return false;
101 };
102 let candidate_segments: Vec<&str> = effective_path.split('.').collect();
103 let property_segments: Vec<&str> = prop_path.split('.').collect();
104 candidate_segments.len() >= property_segments.len()
105 && property_segments.iter().zip(candidate_segments).all(|(property, candidate)| {
106 property == &candidate || (*property == "{}" && candidate.parse::<usize>().is_ok())
107 })
108}
109
110fn value_depends_on_parameter_in_scenario(
115 m: &Arc<SemanticModel>,
116 rid: &str,
117 prop_path: &str,
118 scenario: &HashMap<String, bool>,
119) -> bool {
120 m.graph.outgoing_edges(rid).any(|edge| {
121 let is_unresolved_template_parameter =
122 m.parameters.contains_key(&edge.target) && !m.parameter_has_override(&edge.target);
123 let is_pseudo_parameter = edge.target != PSEUDO_NO_VALUE && PSEUDO_PARAMETERS.contains(&edge.target.as_str());
124 source_path_matches_effective_property(&edge.source_path, prop_path)
125 && (is_unresolved_template_parameter || is_pseudo_parameter)
126 && condition_context_matches_scenario(m, edge.condition_context.as_deref(), scenario)
127 })
128}
129
130fn value_depends_on_overridable_template_parameter_in_scenario(
131 m: &Arc<SemanticModel>,
132 rid: &str,
133 prop_path: &str,
134 scenario: &HashMap<String, bool>,
135) -> bool {
136 m.graph.outgoing_edges(rid).any(|edge| {
137 source_path_matches_effective_property(&edge.source_path, prop_path)
138 && m.parameters.contains_key(&edge.target)
139 && !m.parameter_has_override(&edge.target)
140 && condition_context_matches_scenario(m, edge.condition_context.as_deref(), scenario)
141 })
142}
143
144fn defer_value_constraints(
145 m: &Arc<SemanticModel>,
146 rid: &str,
147 prop_path: &str,
148 val: &serde_json::Value,
149 scenario: &HashMap<String, bool>,
150) -> bool {
151 if m.scenario_uses_pseudo_parameter(rid, prop_path, scenario)
152 || value_depends_on_parameter_in_scenario(m, rid, prop_path, scenario)
153 {
154 return true;
155 }
156
157 let mut pending = vec![(prop_path.to_string(), val)];
158 while let Some((path, candidate)) = pending.pop() {
159 if is_unresolved_intrinsic(candidate)
160 || (m.is_from_intrinsic(rid, &path)
161 && coerce_to_string(candidate).is_some_and(|value| value.contains("${")))
162 {
163 return true;
164 }
165
166 match candidate {
167 serde_json::Value::Array(items) => {
168 pending.extend(
169 items
170 .iter()
171 .enumerate()
172 .map(|(index, item)| (append_property_path(&path, &index.to_string()), item)),
173 );
174 }
175 serde_json::Value::Object(properties) => {
176 pending
177 .extend(properties.iter().map(|(property, value)| (append_property_path(&path, property), value)));
178 }
179 _ => {}
180 }
181 }
182 false
183}
184
185pub fn validate_all_resources(
186 store: &CompiledSchemaStore,
187 model: &Arc<SemanticModel>,
188 region: Option<&str>,
189) -> Vec<Diagnostic> {
190 reset_scenario_filter();
191 reset_schema_budget_exhaustions();
192 let mut out = Vec::new();
193 let relevant: HashSet<&str> = model.resources_by_type.keys().map(String::as_str).collect();
194
195 validate_lifecycle(&mut out, store, model);
196
197 for rtype in &relevant {
198 if rtype.ends_with("::MODULE") || rtype.starts_with("Custom::") || rtype.starts_with(SAM_SERVERLESS_TYPE_PREFIX)
203 {
204 continue;
205 }
206
207 if let Some(region) = region
216 && store.is_known_in_any_region(rtype)
217 && !store.is_available_in_region(rtype, region)
218 {
219 for rid in model.resources_of_type(rtype) {
220 if let Some(res) = model.resources.get(rid.as_str())
229 && let Some(cond) = res.condition.as_deref()
230 && !model.conditions.is_satisfiable_in_region(&[(cond.to_string(), true)], region)
231 {
232 continue;
233 }
234 out.push(build_diagnostic(
237 "F3006",
238 &format!("Resource type '{}' does not exist in '{}'", rtype, region),
239 model,
240 rid,
241 KEY_TYPE,
242 None,
243 ));
244 }
245 continue;
246 }
247
248 let Some(schema) = store.get(rtype) else {
249 continue;
250 };
251 for rid in model.resources_of_type(rtype) {
252 let Some(res) = model.resources.get(rid.as_str()) else {
253 continue;
254 };
255 if !is_valid_logical_id(rid) {
261 continue;
262 }
263 if rtype.starts_with(SAM_SERVERLESS_TYPE_PREFIX) {
269 continue;
270 }
271 validate_resource(&mut out, store, model, rid, res, schema, region);
272 validate_extensions(&mut out, store, model, rid, res);
273 }
274 }
275 drain_schema_budget_exhaustions(model);
276 out
277}
278
279fn is_valid_logical_id(rid: &str) -> bool {
283 !rid.is_empty() && rid.bytes().all(|b| b.is_ascii_alphanumeric())
284}
285
286pub fn enrich_schema_context(diagnostics: &mut [Diagnostic], store: &CompiledSchemaStore, model: &Arc<SemanticModel>) {
287 reset_scenario_filter();
288 for d in diagnostics.iter_mut() {
289 if d.phase != Some(Phase::Schema) {
290 continue;
291 }
292 let Some(rid) = d.resource_logical_id().map(String::from) else {
293 continue;
294 };
295 let Some(res) = model.resources.get(rid.as_str()) else {
296 continue;
297 };
298 let Some(schema) = store.get(&res.resource_type) else {
299 continue;
300 };
301
302 if d.documentation_url.is_none() {
303 if let Some(ref url) = schema.documentation_url {
304 d.documentation_url = Some(url.clone());
305 } else if let Some(ref url) = schema.source_url {
306 d.documentation_url = Some(url.clone());
307 }
308 }
309
310 if let Some(source) = describe_resolution(model, &rid, d.property_path.as_deref().unwrap_or("")) {
311 let ctx = d.context.get_or_insert_with(|| ViolationContext {
312 actual_value: None,
313 expected_constraint: None,
314 property: None,
315 lifecycle: None,
316 resolution_source: None,
317 extra: None,
318 });
319 if ctx.resolution_source.is_none() {
320 ctx.resolution_source = Some(source);
321 }
322 }
323
324 let pp = d.property_path.as_deref().unwrap_or("");
325 let prop_path = pp.strip_prefix("Properties.").unwrap_or(pp);
326 let prop_schema = find_prop_schema_deep(prop_path, schema);
327
328 macro_rules! ensure_ctx {
329 ($d:expr) => {
330 $d.context.get_or_insert_with(|| ViolationContext {
331 actual_value: None,
332 expected_constraint: None,
333 property: None,
334 lifecycle: None,
335 resolution_source: None,
336 extra: None,
337 })
338 };
339 }
340
341 match d.rule_id.as_str() {
342 "F3012" => {
343 if let Some(ps) = prop_schema
344 && let Some(ref pt) = ps.prop_type
345 {
346 ensure_ctx!(d).expected_constraint = Some(pt.primary().unwrap_or("unknown").to_string());
347 }
348 }
349 "F3030" | "W3030" => {
350 if let Some(ps) = prop_schema {
351 let allowed = if !ps.enum_values.is_empty() { &ps.enum_values } else { &ps.enum_case_insensitive };
352 if !allowed.is_empty() {
353 ensure_ctx!(d)
354 .extra
355 .get_or_insert_with(HashMap::new)
356 .insert("allowed_values".into(), serde_json::json!(allowed).into());
357 }
358 }
359 }
360 "F3031" => {
361 if let Some(ps) = prop_schema
362 && let Some(ref pat) = ps.pattern
363 {
364 ensure_ctx!(d).expected_constraint = Some(pat.clone());
365 }
366 }
367 "F3034" => {
368 if let Some(ps) = prop_schema {
369 let ctx = ensure_ctx!(d);
370 if let Some(v) = ps.minimum {
371 ctx.extra
372 .get_or_insert_with(HashMap::new)
373 .insert("minimum".into(), serde_json::json!(v).into());
374 }
375 if let Some(v) = ps.maximum {
376 ctx.extra
377 .get_or_insert_with(HashMap::new)
378 .insert("maximum".into(), serde_json::json!(v).into());
379 }
380 if let Some(v) = ps.exclusive_minimum {
381 ctx.extra
382 .get_or_insert_with(HashMap::new)
383 .insert("exclusive_minimum".into(), serde_json::json!(v).into());
384 }
385 if let Some(v) = ps.exclusive_maximum {
386 ctx.extra
387 .get_or_insert_with(HashMap::new)
388 .insert("exclusive_maximum".into(), serde_json::json!(v).into());
389 }
390 }
391 }
392 "F3033" => {
393 if let Some(ps) = prop_schema {
394 let ctx = ensure_ctx!(d);
395 if let Some(v) = ps.min_length {
396 ctx.extra
397 .get_or_insert_with(HashMap::new)
398 .insert("min_length".into(), serde_json::json!(v).into());
399 }
400 if let Some(v) = ps.max_length {
401 ctx.extra
402 .get_or_insert_with(HashMap::new)
403 .insert("max_length".into(), serde_json::json!(v).into());
404 }
405 }
406 }
407 "F3032" => {
408 if let Some(ps) = prop_schema {
409 let ctx = ensure_ctx!(d);
410 if let Some(v) = ps.min_items {
411 ctx.extra
412 .get_or_insert_with(HashMap::new)
413 .insert("min_items".into(), serde_json::json!(v).into());
414 }
415 if let Some(v) = ps.max_items {
416 ctx.extra
417 .get_or_insert_with(HashMap::new)
418 .insert("max_items".into(), serde_json::json!(v).into());
419 }
420 }
421 }
422 "F3002" => {
423 let mut allowed: Vec<&str> = schema.properties.keys().map(|s| s.as_str()).collect();
424 allowed.sort();
425 ensure_ctx!(d)
426 .extra
427 .get_or_insert_with(HashMap::new)
428 .insert("allowed_properties".into(), serde_json::json!(allowed).into());
429 }
430 "W9009" => {
431 ensure_ctx!(d).lifecycle = Some("deprecated".into());
432 }
433 "I9001" => {
434 let ctx = ensure_ctx!(d);
435 let lifecycle = if schema.create_only_properties.iter().any(|pointer| pointer == prop_path) {
436 Some("create-only")
437 } else if schema.conditional_create_only_properties.iter().any(|pointer| pointer == prop_path) {
438 Some("conditional-create-only")
439 } else {
440 None
441 };
442 ctx.lifecycle = lifecycle.map(String::from);
443 if let Some(ref rs) = schema.replacement_strategy {
444 ctx.extra
445 .get_or_insert_with(HashMap::new)
446 .insert("replacement_strategy".into(), serde_json::json!(rs).into());
447 }
448 }
449 "W9054" => {
450 ensure_ctx!(d).lifecycle = Some("write-only".into());
451 }
452 _ => {}
453 }
454 }
455 drain_schema_budget_exhaustions(model);
458}
459
460pub fn enrich_schema_context_standalone(diagnostics: &mut [Diagnostic], model: &Arc<SemanticModel>) {
461 let store = CompiledSchemaStore::new();
462 enrich_schema_context(diagnostics, &store, model);
463}
464
465fn descend<'a>(
470 resolved: Cow<'a, PropSchema>,
471 rest: Option<&str>,
472 defs: &'a HashMap<String, PropSchema>,
473) -> Option<Cow<'a, PropSchema>> {
474 let Some(rest) = rest else {
475 return Some(resolved);
476 };
477 let mut segments = rest.splitn(2, '.');
478 let next = segments.next()?;
479 let remaining = segments.next().filter(|value| !value.is_empty());
480 let enters_array_item = next == "{}" || next.parse::<usize>().is_ok();
481 match resolved {
482 Cow::Borrowed(schema) if enters_array_item => descend(schema.items.as_deref()?.resolve(defs), remaining, defs),
483 Cow::Owned(schema) if enters_array_item => {
484 let item = *schema.items?;
485 let resolved_item = item.resolve(defs).into_owned();
486 descend(Cow::Owned(resolved_item), remaining, defs)
487 }
488 Cow::Borrowed(schema) => find_prop_schema(rest, &schema.properties, defs),
489 Cow::Owned(schema) => {
490 find_prop_schema(rest, &schema.properties, defs).map(|found| Cow::Owned(found.into_owned()))
491 }
492 }
493}
494
495fn find_prop_schema<'a>(
496 path: &str,
497 props: &'a HashMap<String, PropSchema>,
498 defs: &'a HashMap<String, PropSchema>,
499) -> Option<Cow<'a, PropSchema>> {
500 let mut segments = path.splitn(2, '.');
501 let top = segments.next()?;
502 let rest = segments.next().filter(|r| !r.is_empty());
503
504 if let Some(ps) = props.get(top) {
505 return descend(ps.resolve(defs), rest, defs);
506 }
507 for (pat, ps) in props.iter() {
508 if compile_pattern(pat).is_some_and(|re| re.is_match(top)) {
509 return descend(ps.resolve(defs), rest, defs);
510 }
511 }
512 None
513}
514
515pub(crate) fn find_prop_schema_deep<'a>(path: &str, schema: &'a CompiledSchema) -> Option<Cow<'a, PropSchema>> {
516 if let Some(ps) = find_prop_schema(path, &schema.properties, &schema.definitions) {
517 return Some(ps);
518 }
519 for sub in schema.one_of.iter().chain(schema.any_of.iter()).chain(schema.all_of.iter()) {
520 if let Some(ps) = find_prop_schema(path, &sub.properties, &schema.definitions) {
521 return Some(ps);
522 }
523 }
524 for ite in &schema.if_then_else {
525 if let Some(ref then_s) = ite.then_schema
526 && let Some(ps) = find_prop_schema(path, &then_s.properties, &schema.definitions)
527 {
528 return Some(ps);
529 }
530 if let Some(ref else_s) = ite.else_schema
531 && let Some(ps) = find_prop_schema(path, &else_s.properties, &schema.definitions)
532 {
533 return Some(ps);
534 }
535 }
536 None
537}
538
539fn present_lifecycle_pointer(m: &Arc<SemanticModel>, rid: &str, base: &str, pointer: &str) -> Option<String> {
550 let resolver_path = pointer.replace(".*.", ".{}.");
551 m.resolve_deep(rid, &format!("{}.{}", base, resolver_path)).map(|_| pointer.to_string())
552}
553
554fn validate_resource(
555 out: &mut Vec<Diagnostic>,
556 store: &CompiledSchemaStore,
557 m: &Arc<SemanticModel>,
558 rid: &str,
559 res: &ResolvedResource,
560 schema: &CompiledSchema,
561 region: Option<&str>,
562) {
563 let base = KEY_PROPERTIES;
564 let defs = &schema.definitions;
565
566 if m.is_authored_ref_at(rid, base, PSEUDO_NO_VALUE) {
567 let mut actual_value = serde_json::Map::new();
568 actual_value.insert(FN_REF.to_string(), serde_json::Value::String(PSEUDO_NO_VALUE.to_string()));
569 let mut diagnostic = build_diagnostic(
570 "F3012",
571 &format!("{{'Ref': '{}'}} is not of type 'object'", PSEUDO_NO_VALUE),
572 m,
573 rid,
574 base,
575 None,
576 );
577 diagnostic.context = Some(ViolationContext {
578 actual_value: Some(serde_json::Value::Object(actual_value).into()),
579 expected_constraint: Some("object".to_string()),
580 property: None,
581 lifecycle: None,
582 resolution_source: None,
583 extra: None,
584 });
585 out.push(diagnostic);
586 }
587
588 for dp in &schema.deprecated_properties {
594 if let Some(pointer) = present_lifecycle_pointer(m, rid, base, dp) {
595 out.push(build_diagnostic(
596 "W9009",
597 &format!("Property '{}' is deprecated", pointer),
598 m,
599 rid,
600 &format!("{}.{}", base, pointer),
601 None,
602 ));
603 }
604 }
605
606 for cp in &schema.create_only_properties {
607 if let Some(pointer) = present_lifecycle_pointer(m, rid, base, cp) {
608 out.push(build_diagnostic(
609 "I9001",
610 &format!("Property '{}' is create-only; updating it will cause resource replacement", pointer),
611 m,
612 rid,
613 &format!("{}.{}", base, pointer),
614 None,
615 ));
616 }
617 }
618
619 for conditional_pointer in &schema.conditional_create_only_properties {
620 if schema.create_only_properties.contains(conditional_pointer) {
622 continue;
623 }
624 if let Some(pointer) = present_lifecycle_pointer(m, rid, base, conditional_pointer) {
625 out.push(build_diagnostic(
626 "I9001",
627 &format!(
628 "Property '{}' is conditionally create-only; updating it may cause resource replacement",
629 pointer
630 ),
631 m,
632 rid,
633 &format!("{}.{}", base, pointer),
634 None,
635 ));
636 }
637 }
638
639 for wo in &schema.write_only_properties {
640 for edge in m.graph.incoming(rid) {
641 if let RefKind::GetAtt { attr } = &edge.kind
642 && attr == wo
643 && edge.source_resource.starts_with("__output__")
644 {
645 let output_name = edge.source_resource.strip_prefix("__output__").unwrap_or(&edge.source_resource);
646 out.push(build_diagnostic(
647 "W9054",
648 &format!("Write-only property '{}' of '{}' is referenced in output '{}'", wo, rid, output_name),
649 m,
650 rid,
651 &format!("{}.{}", base, wo),
652 None,
653 ));
654 }
655 }
656 }
657
658 let key_scenarios = if res.properties_dynamic { Vec::new() } else { resource_property_key_scenarios(m, rid, res) };
662 for (actual_keys, conds) in &key_scenarios {
663 let scenario = if conds.is_empty() { None } else { Some(conds) };
664 validate_object_keys_inner(
665 out,
666 m,
667 rid,
668 &res.resource_type,
669 &schema.properties,
670 defs,
671 &schema.required,
672 schema.additional_properties,
673 &HashMap::new(),
674 &schema.dependent_required,
675 &schema.dependent_excluded,
676 &schema.required_or,
677 &schema.required_xor,
678 &schema.all_of,
679 &schema.any_of,
680 &schema.one_of,
681 actual_keys,
682 base,
683 scenario,
684 );
685 }
686
687 for prop_name in res.properties.keys() {
688 let Some(prop_schema) = schema.properties.get(prop_name) else {
689 continue;
690 };
691 let resolved = prop_schema.resolve(defs);
692 let prop_path = format!("{}.{}", base, prop_name);
693 validate_prop(out, store, m, rid, &res.resource_type, &prop_path, &resolved, defs, region);
694 }
695
696 let branch_property_names: HashSet<String> =
700 key_scenarios.iter().flat_map(|(keys, _)| keys.iter().cloned()).collect();
701 for prop_name in &branch_property_names {
702 if res.properties.contains_key(prop_name) {
703 continue;
704 }
705 let Some(prop_schema) = schema.properties.get(prop_name) else {
706 continue;
707 };
708 let resolved = prop_schema.resolve(defs);
709 let prop_path = format!("{}.{}", base, prop_name);
710 validate_prop(out, store, m, rid, &res.resource_type, &prop_path, &resolved, defs, region);
711 }
712
713 let actual_keys: Vec<String> = res.properties.keys().cloned().collect();
714 for ite in &schema.if_then_else {
715 if ite.condition.states_nothing() {
719 continue;
720 }
721 let Some(assignments) = conditional_scenario_assignments(m, rid, &ite.condition, base) else {
727 continue;
728 };
729 for assignment in assignments {
730 let _scenario_filter_scope = ScenarioFilterScope::enter(assignment.clone());
731 let effective_keys: Vec<String> = actual_keys
732 .iter()
733 .filter(|key| property_present_under(m, rid, base, key, &assignment))
734 .cloned()
735 .collect();
736 let matches = condition_matches(&ite.condition, &effective_keys, m, rid, defs);
737 let sub = if matches { &ite.then_schema } else { &ite.else_schema };
738 let Some(sub) = sub else {
739 continue;
740 };
741 if ite.enforce_full_branch {
742 validate_sub(out, m, rid, &res.resource_type, &effective_keys, sub, defs, base, 0);
747 } else {
748 validate_sub_dependencies(out, m, rid, &effective_keys, sub, base);
754 validate_owned_conditional_value_constraints(out, m, rid, &res.resource_type, sub, defs, base);
755 }
756 }
757 }
758}
759
760fn conditional_scenario_assignments(
765 m: &Arc<SemanticModel>,
766 rid: &str,
767 condition: &ConditionSchema,
768 base_path: &str,
769) -> Option<Vec<HashMap<String, bool>>> {
770 let mut property_names = BTreeSet::new();
771 collect_condition_property_names(condition, &mut property_names);
772 let paths: Vec<String> = property_names.into_iter().map(|name| format!("{}.{}", base_path, name)).collect();
773 let refs: Vec<&str> = paths.iter().map(String::as_str).collect();
774 property_scenario_assignments(m, rid, &refs)
775}
776
777fn collect_condition_property_names(condition: &ConditionSchema, names: &mut BTreeSet<String>) {
778 names.extend(condition.properties.keys().cloned());
779 names.extend(condition.required.iter().cloned());
780 names.extend(condition.absent.iter().cloned());
781 for sub_condition in condition.all_of.iter().chain(condition.any_of.iter()).chain(condition.one_of.iter()) {
782 collect_condition_property_names(sub_condition, names);
783 }
784 if let Some(negated) = &condition.not {
785 collect_condition_property_names(negated, names);
786 }
787}
788
789fn validate_required_groups(
790 out: &mut Vec<Diagnostic>,
791 m: &Arc<SemanticModel>,
792 rid: &str,
793 actual_keys: &[String],
794 required_or: &[String],
795 required_xor: &[String],
796 base_path: &str,
797) {
798 if required_or.is_empty() && required_xor.is_empty() {
799 return;
800 }
801
802 let members: Vec<&str> = required_or.iter().chain(required_xor.iter()).map(String::as_str).collect();
808 let Some(assignments) = required_group_scenario_assignments(m, rid, &members, base_path) else {
809 validate_required_groups_budget_fallback(out, m, rid, actual_keys, required_or, required_xor, base_path);
812 return;
813 };
814
815 if !required_or.is_empty() {
816 for assignment in &assignments {
817 let any_present = required_or.iter().any(|property| {
818 actual_keys.iter().any(|actual| actual == property)
819 && property_present_under(m, rid, base_path, property, assignment)
820 });
821 if !any_present {
822 let names = required_or.iter().map(|name| format!("'{name}'")).collect::<Vec<_>>().join(", ");
823 out.push(build_diagnostic_conditional(
824 "F3058",
825 &format!("One of [{names}] is a required property"),
826 m,
827 rid,
828 base_path,
829 None,
830 assignment_condition_map(assignment),
831 ));
832 }
833 }
834 }
835
836 if !required_xor.is_empty() {
837 for assignment in &assignments {
838 let count = required_xor
839 .iter()
840 .filter(|property| {
841 actual_keys.iter().any(|actual| actual == property.as_str())
842 && property_present_under(m, rid, base_path, property, assignment)
843 })
844 .count();
845 if count != 1 {
846 let names = required_xor.iter().map(|name| format!("'{name}'")).collect::<Vec<_>>().join(", ");
847 out.push(build_diagnostic_conditional(
848 "F3014",
849 &format!("Exactly one of [{names}] must be specified"),
850 m,
851 rid,
852 base_path,
853 None,
854 assignment_condition_map(assignment),
855 ));
856 }
857 }
858 }
859}
860
861fn all_members_absent_witness(
866 m: &Arc<SemanticModel>,
867 rid: &str,
868 actual_keys: &[String],
869 members: &[String],
870 base_path: &str,
871) -> Option<HashMap<String, bool>> {
872 let seed = active_scenario_filter(m, rid)?;
873
874 let mut alternatives = Vec::new();
875 for property in members {
876 if !actual_keys.iter().any(|actual| actual == property) {
877 continue;
878 }
879 let property_alternatives = property_presence_alternatives(m, rid, base_path, property, false, &seed);
880 if property_alternatives.is_empty() {
881 return None;
882 }
883 alternatives.push(property_alternatives);
884 }
885 alternatives.sort_by_key(Vec::len);
886 let mut merge_attempts = 0;
887 compatible_alternative_witness(m, &alternatives, 0, &seed, &mut merge_attempts)
888}
889
890fn multiple_members_present_witness(
894 m: &Arc<SemanticModel>,
895 rid: &str,
896 actual_keys: &[String],
897 members: &[String],
898 base_path: &str,
899) -> Option<HashMap<String, bool>> {
900 let seed = active_scenario_filter(m, rid)?;
901
902 let present_alternatives: Vec<Vec<HashMap<String, bool>>> = members
903 .iter()
904 .filter(|property| actual_keys.iter().any(|actual| actual == property.as_str()))
905 .map(|property| property_presence_alternatives(m, rid, base_path, property, true, &seed))
906 .collect();
907
908 let mut merge_attempts = 0;
909 for left in 0..present_alternatives.len() {
910 for right in (left + 1)..present_alternatives.len() {
911 for left_assignment in &present_alternatives[left] {
912 let Some(left_merged) = try_merge_assignments(&seed, left_assignment) else {
913 continue;
914 };
915 if !assignment_is_proven_satisfiable(m, &left_merged) {
916 continue;
917 }
918 for right_assignment in &present_alternatives[right] {
919 if merge_attempts >= MAX_GROUP_WITNESS_MERGE_ATTEMPTS {
920 return None;
921 }
922 merge_attempts += 1;
923 let Some(merged) = try_merge_assignments(&left_merged, right_assignment) else {
924 continue;
925 };
926 if assignment_is_proven_satisfiable(m, &merged) {
927 return Some(merged);
928 }
929 }
930 }
931 }
932 }
933 None
934}
935
936fn active_scenario_filter(m: &Arc<SemanticModel>, rid: &str) -> Option<HashMap<String, bool>> {
940 let mut seed = SCENARIO_FILTER.with(|filter| filter.borrow().clone().unwrap_or_default());
941 if m.resource_condition_is_valid(rid)
942 && let Some(condition) = m.resources.get(rid).and_then(|resource| resource.condition.as_ref())
943 && let Some(previous) = seed.insert(condition.clone(), true)
944 && !previous
945 {
946 return None;
947 }
948 assignment_is_proven_satisfiable(m, &seed).then_some(seed)
949}
950
951fn property_presence_alternatives(
956 m: &Arc<SemanticModel>,
957 rid: &str,
958 base_path: &str,
959 property: &str,
960 want_present: bool,
961 filter: &HashMap<String, bool>,
962) -> Vec<HashMap<String, bool>> {
963 if m.scenario_budget_exhausted() {
964 return Vec::new();
965 }
966 let (scenarios, was_curtailed) =
967 m.resolve_scenarios_with_limit_shared(rid, &format!("{base_path}.{property}"), MAX_GROUP_PROPERTY_SCENARIOS);
968 if was_curtailed && !m.scenario_budget_exhausted() {
969 m.record_budget_exhaustion(BudgetKind::SchemaScenarioAssignments);
970 }
971 let mut alternatives = Vec::new();
972 let mut seen = HashSet::new();
973 for (value, conditions) in scenarios.iter() {
974 let present = !matches!(value, ResolvedValue::Concrete { value } if value.is_null());
975 if present != want_present {
976 continue;
977 }
978 let Some(merged) = try_merge_assignments(filter, conditions) else {
979 continue;
980 };
981 if assignment_is_proven_satisfiable(m, &merged) && seen.insert(canonical_assignment(conditions)) {
982 alternatives.push(conditions.clone());
983 }
984 }
985 alternatives
986}
987
988fn compatible_alternative_witness(
989 m: &Arc<SemanticModel>,
990 alternatives: &[Vec<HashMap<String, bool>>],
991 index: usize,
992 assignment: &HashMap<String, bool>,
993 merge_attempts: &mut usize,
994) -> Option<HashMap<String, bool>> {
995 let Some(group) = alternatives.get(index) else {
996 return Some(assignment.clone());
997 };
998 for alternative in group {
999 if *merge_attempts >= MAX_GROUP_WITNESS_MERGE_ATTEMPTS {
1000 return None;
1001 }
1002 *merge_attempts += 1;
1003 let Some(merged) = try_merge_assignments(assignment, alternative) else {
1004 continue;
1005 };
1006 if !assignment_is_proven_satisfiable(m, &merged) {
1007 continue;
1008 }
1009 if let Some(witness) = compatible_alternative_witness(m, alternatives, index + 1, &merged, merge_attempts) {
1010 return Some(witness);
1011 }
1012 }
1013 None
1014}
1015
1016fn assignment_uses_only_defined_conditions(m: &Arc<SemanticModel>, assignment: &HashMap<String, bool>) -> bool {
1017 assignment.keys().all(|condition| m.conditions.get(condition).is_some())
1018}
1019
1020fn assignment_is_proven_satisfiable(m: &Arc<SemanticModel>, assignment: &HashMap<String, bool>) -> bool {
1025 if assignment.is_empty() {
1026 return true;
1027 }
1028 if !assignment_uses_only_defined_conditions(m, assignment) {
1029 return false;
1030 }
1031 let assumptions: Vec<(String, bool)> = assignment.iter().map(|(name, value)| (name.clone(), *value)).collect();
1032 matches!(m.conditions.satisfiability(&assumptions), Satisfiability::Satisfiable)
1033}
1034
1035fn validate_required_groups_budget_fallback(
1039 out: &mut Vec<Diagnostic>,
1040 m: &Arc<SemanticModel>,
1041 rid: &str,
1042 actual_keys: &[String],
1043 required_or: &[String],
1044 required_xor: &[String],
1045 base_path: &str,
1046) {
1047 if !required_or.is_empty()
1048 && let Some(witness) = all_members_absent_witness(m, rid, actual_keys, required_or, base_path)
1049 {
1050 let names = required_or.iter().map(|name| format!("'{name}'")).collect::<Vec<_>>().join(", ");
1051 out.push(build_diagnostic_conditional(
1052 "F3058",
1053 &format!("One of [{names}] is a required property"),
1054 m,
1055 rid,
1056 base_path,
1057 None,
1058 assignment_condition_map(&witness),
1059 ));
1060 }
1061
1062 if required_xor.is_empty() {
1063 return;
1064 }
1065 let names = required_xor.iter().map(|name| format!("'{name}'")).collect::<Vec<_>>().join(", ");
1066 if let Some(witness) = all_members_absent_witness(m, rid, actual_keys, required_xor, base_path) {
1067 out.push(build_diagnostic_conditional(
1068 "F3014",
1069 &format!("Exactly one of [{names}] must be specified"),
1070 m,
1071 rid,
1072 base_path,
1073 None,
1074 assignment_condition_map(&witness),
1075 ));
1076 }
1077 if let Some(witness) = multiple_members_present_witness(m, rid, actual_keys, required_xor, base_path) {
1078 out.push(build_diagnostic_conditional(
1079 "F3014",
1080 &format!("Exactly one of [{names}] must be specified"),
1081 m,
1082 rid,
1083 base_path,
1084 None,
1085 assignment_condition_map(&witness),
1086 ));
1087 }
1088}
1089
1090fn required_group_scenario_assignments(
1094 m: &Arc<SemanticModel>,
1095 rid: &str,
1096 members: &[&str],
1097 base_path: &str,
1098) -> Option<Vec<HashMap<String, bool>>> {
1099 let paths: Vec<String> = members.iter().map(|name| format!("{}.{}", base_path, name)).collect();
1100 let refs: Vec<&str> = paths.iter().map(String::as_str).collect();
1101 property_scenario_assignments(m, rid, &refs)
1102}
1103
1104const MAX_GROUP_PROPERTY_SCENARIOS: usize = MAX_GROUP_SCENARIO_ASSIGNMENTS + 1;
1105const MAX_GROUP_WITNESS_MERGE_ATTEMPTS: usize = MAX_GROUP_SCENARIO_MERGE_ATTEMPTS;
1106
1107fn property_scenario_assignments(
1118 m: &Arc<SemanticModel>,
1119 rid: &str,
1120 property_paths: &[&str],
1121) -> Option<Vec<HashMap<String, bool>>> {
1122 let Some(seed) = active_scenario_filter(m, rid) else {
1123 return Some(Vec::new());
1124 };
1125 let mut assignments: Vec<HashMap<String, bool>> = vec![seed.clone()];
1126
1127 for property_path in property_paths {
1128 let (scenarios, was_curtailed) =
1129 m.resolve_scenarios_with_limit_shared(rid, property_path, MAX_GROUP_PROPERTY_SCENARIOS);
1130 if was_curtailed {
1131 if !m.scenario_budget_exhausted() {
1132 m.record_budget_exhaustion(BudgetKind::SchemaScenarioAssignments);
1133 }
1134 return None;
1135 }
1136 if scenarios.is_empty() {
1137 continue;
1138 }
1139
1140 let mut property_assignments = Vec::new();
1141 let mut seen_property_assignments = HashSet::new();
1142 for (_, conditions) in scenarios.iter() {
1143 if !is_satisfiable(m, conditions) || !scenario_consistent_with_filter(m, conditions) {
1144 continue;
1145 }
1146 if seen_property_assignments.insert(canonical_assignment(conditions)) {
1147 if property_assignments.len() == MAX_GROUP_SCENARIO_ASSIGNMENTS {
1148 m.record_budget_exhaustion(BudgetKind::SchemaScenarioAssignments);
1149 return None;
1150 }
1151 property_assignments.push(conditions.clone());
1152 }
1153 }
1154
1155 if property_assignments.is_empty() {
1156 continue;
1157 }
1158
1159 let mut next_assignments = Vec::new();
1160 let mut seen_assignments = HashSet::new();
1161 let mut merge_attempts = 0;
1162 for existing in &assignments {
1163 for alternative in &property_assignments {
1164 merge_attempts += 1;
1165 if merge_attempts > MAX_GROUP_SCENARIO_MERGE_ATTEMPTS {
1166 m.record_budget_exhaustion(BudgetKind::SchemaScenarioMergeAttempts);
1167 return None;
1168 }
1169 let Some(merged) = try_merge_assignments(existing, alternative) else {
1170 continue;
1171 };
1172 if !is_satisfiable(m, &merged) || !scenario_consistent_with_filter(m, &merged) {
1173 continue;
1174 }
1175 if seen_assignments.insert(canonical_assignment(&merged)) {
1176 if next_assignments.len() == MAX_GROUP_SCENARIO_ASSIGNMENTS {
1177 m.record_budget_exhaustion(BudgetKind::SchemaScenarioAssignments);
1178 return None;
1179 }
1180 next_assignments.push(merged);
1181 }
1182 }
1183 }
1184
1185 if !next_assignments.is_empty() {
1186 assignments = next_assignments;
1187 }
1188 }
1189
1190 Some(if assignments.is_empty() { vec![seed] } else { assignments })
1191}
1192
1193fn canonical_assignment(assignment: &HashMap<String, bool>) -> Vec<(String, bool)> {
1194 let mut canonical: Vec<(String, bool)> = assignment.iter().map(|(name, value)| (name.clone(), *value)).collect();
1195 canonical.sort_unstable();
1196 canonical
1197}
1198
1199fn try_merge_assignments(a: &HashMap<String, bool>, b: &HashMap<String, bool>) -> Option<HashMap<String, bool>> {
1202 let mut merged = a.clone();
1203 for (name, val) in b {
1204 if let Some(existing) = merged.get(name) {
1205 if existing != val {
1206 return None; }
1208 } else {
1209 merged.insert(name.clone(), *val);
1210 }
1211 }
1212 Some(merged)
1213}
1214
1215fn property_present_under(
1227 m: &Arc<SemanticModel>,
1228 rid: &str,
1229 base: &str,
1230 prop: &str,
1231 assignment: &HashMap<String, bool>,
1232) -> bool {
1233 let scenarios = outer_resolved_scenarios(m, rid, &format!("{}.{}", base, prop));
1237 if scenarios.is_empty() {
1238 return true;
1239 }
1240 scenarios.iter().any(|(val, conds)| {
1241 if !is_satisfiable(m, conds) {
1242 return false;
1243 }
1244 if !scenario_consistent_with_filter(m, conds) {
1247 return false;
1248 }
1249 for (name, value) in assignment {
1251 if let Some(scenario_value) = conds.get(name)
1252 && scenario_value != value
1253 {
1254 return false;
1255 }
1256 }
1257 if !assignment.is_empty() {
1259 let mut merged = conds.clone();
1260 for (name, value) in assignment {
1261 merged.insert(name.clone(), *value);
1262 }
1263 if !is_satisfiable(m, &merged) {
1264 return false;
1265 }
1266 }
1267 !matches!(val, ResolvedValue::Concrete { value } if value.is_null())
1268 })
1269}
1270
1271#[allow(clippy::too_many_arguments)]
1276fn validate_object_keys_inner(
1277 out: &mut Vec<Diagnostic>,
1278 m: &Arc<SemanticModel>,
1279 rid: &str,
1280 rtype: &str,
1281 schema_props: &HashMap<String, PropSchema>,
1282 defs: &HashMap<String, PropSchema>,
1283 required: &[String],
1284 additional_properties: Option<bool>,
1285 pattern_props: &HashMap<String, PropSchema>,
1286 dep_req: &HashMap<String, Vec<String>>,
1287 dep_excl: &HashMap<String, Vec<String>>,
1288 req_or: &[String],
1289 req_xor: &[String],
1290 all_of: &[SubSchema],
1291 any_of: &[SubSchema],
1292 one_of: &[SubSchema],
1293 actual_keys: &[String],
1294 base_path: &str,
1295 scenario: Option<&HashMap<String, bool>>,
1296) {
1297 let _scenario_filter_scope = match scenario {
1301 Some(outer_conditions) => {
1302 let Some(merged) = merged_scenario_filter(outer_conditions) else {
1303 return;
1304 };
1305 Some(ScenarioFilterScope::enter(merged))
1306 }
1307 None => None,
1308 };
1309
1310 let before_len = out.len();
1311 for req in required {
1312 if !actual_keys.contains(req) {
1313 out.push(build_diagnostic(
1314 "F3003",
1315 &format!("'{}' is a required property", req),
1316 m,
1317 rid,
1318 base_path,
1319 Some(&format!("Add the required property '{}'", req)),
1320 ));
1321 } else if base_path == KEY_PROPERTIES {
1322 check_required_not_null(out, m, rid, base_path, req);
1323 }
1324 }
1325
1326 if additional_properties == Some(false) && !is_custom_resource_type(rtype) {
1327 let known: HashSet<&str> = schema_props.keys().map(|s| s.as_str()).collect();
1328
1329 let pattern_matchers: Vec<Option<std::sync::Arc<CompiledPattern>>> =
1330 pattern_props.keys().map(|p| compile_pattern(p)).collect();
1331 for key in actual_keys {
1332 if known.contains(key.as_str()) {
1333 continue;
1334 }
1335 let allowed_by_pattern =
1336 pattern_matchers.iter().any(|matcher| matcher.as_ref().is_none_or(|re| re.is_match(key)));
1337 if allowed_by_pattern {
1338 continue;
1339 }
1340 let suggestion = find_similar(key, &known);
1341 let msg = match suggestion {
1342 Some(s) => {
1343 format!("Additional properties are not allowed ('{}' was unexpected. Did you mean '{}'?)", key, s)
1344 }
1345 None => format!("Additional properties are not allowed ('{}' was unexpected)", key),
1346 };
1347 out.push(build_diagnostic("F3002", &msg, m, rid, &format!("{}.{}", base_path, key), None));
1348 }
1349 }
1350
1351 for (trigger, excluded) in dep_excl {
1352 if actual_keys.contains(trigger) {
1353 for dep in excluded {
1354 if actual_keys.contains(dep) {
1355 out.push(build_diagnostic(
1356 "F3020",
1357 &format!("'{}' should not be included with '{}'", dep, trigger),
1358 m,
1359 rid,
1360 &format!("{}.{}", base_path, dep),
1361 None,
1362 ));
1363 }
1364 }
1365 }
1366 }
1367
1368 for (trigger, deps) in dep_req {
1369 if actual_keys.contains(trigger) {
1370 for dep in deps {
1371 if !actual_keys.contains(dep) {
1372 out.push(build_diagnostic(
1373 "F3021",
1374 &format!("'{}' is a dependency of '{}'", dep, trigger),
1375 m,
1376 rid,
1377 base_path,
1378 Some(&format!("Add '{}' when '{}' is specified", dep, trigger)),
1379 ));
1380 }
1381 }
1382 }
1383 }
1384
1385 validate_required_groups(out, m, rid, actual_keys, req_or, req_xor, base_path);
1386
1387 for sub in all_of {
1388 validate_sub(out, m, rid, rtype, actual_keys, sub, defs, base_path, 0);
1389 }
1390
1391 let group_assignments = if any_of.is_empty() && one_of.is_empty() {
1399 Some(Vec::new())
1400 } else {
1401 branch_scenario_assignments(m, rid, any_of.iter().chain(one_of.iter()), defs, base_path)
1402 }
1403 .unwrap_or_default();
1404
1405 if !any_of.is_empty() {
1406 for assignment in &group_assignments {
1407 let evaluations =
1408 evaluate_object_composition_branches(m, rid, rtype, actual_keys, any_of, defs, base_path, assignment);
1409 if evaluations.iter().all(|evaluation| !evaluation.matched) {
1410 out.push(build_composition_diagnostic(
1411 "F3017",
1412 CompositionKind::AnyOf,
1413 &evaluations,
1414 m,
1415 rid,
1416 base_path,
1417 Some(rtype),
1418 None,
1419 assignment_condition_map(assignment),
1420 ));
1421 }
1422 }
1423 }
1424
1425 if !one_of.is_empty() {
1426 for assignment in &group_assignments {
1427 let evaluations =
1428 evaluate_object_composition_branches(m, rid, rtype, actual_keys, one_of, defs, base_path, assignment);
1429 let match_count = evaluations.iter().filter(|evaluation| evaluation.matched).count();
1430 if match_count != 1 {
1431 out.push(build_composition_diagnostic(
1432 "F3018",
1433 CompositionKind::OneOf,
1434 &evaluations,
1435 m,
1436 rid,
1437 base_path,
1438 Some(rtype),
1439 None,
1440 assignment_condition_map(assignment),
1441 ));
1442 }
1443 }
1444 }
1445
1446 if let Some(conds) = scenario {
1450 for diag in out.iter_mut().skip(before_len) {
1451 if diag.condition_scenario.is_none() {
1452 diag.condition_scenario = Some(conds.clone());
1453 }
1454 }
1455 }
1456}
1457
1458fn resolved_object_keys(value: &ResolvedValue) -> Option<Vec<String>> {
1460 let mut keys: Vec<String> = match value {
1461 ResolvedValue::Map { entries } => {
1462 if entries.len() == 1 && is_intrinsic_key(&entries[0].key) {
1463 return None;
1464 }
1465 entries.iter().filter(|entry| !is_resolved_null(&entry.value)).map(|entry| entry.key.clone()).collect()
1466 }
1467 ResolvedValue::Concrete { value } if is_unresolved_intrinsic(value) => return None,
1468 ResolvedValue::Concrete { value } => value
1469 .as_object()?
1470 .iter()
1471 .filter(|(_, property_value)| !property_value.is_null())
1472 .map(|(property_name, _)| property_name.clone())
1473 .collect(),
1474 _ => return None,
1475 };
1476 keys.sort();
1477 Some(keys)
1478}
1479
1480fn object_key_scenarios(
1483 m: &Arc<SemanticModel>,
1484 rid: &str,
1485 prop_path: &str,
1486) -> Vec<(Vec<String>, HashMap<String, bool>)> {
1487 let mut out: Vec<(Vec<String>, HashMap<String, bool>)> = Vec::new();
1488 for (value, conditions) in m.resolve_scenarios_shared(rid, prop_path).iter() {
1489 if !is_satisfiable(m, conditions) {
1490 continue;
1491 }
1492 if let Some(keys) = resolved_object_keys(value) {
1493 out.push((keys, conditions.clone()));
1494 }
1495 }
1496 let mut seen_keysets: BTreeMap<Vec<String>, HashMap<String, bool>> = BTreeMap::new();
1500 for (keys, conds) in out.drain(..) {
1501 seen_keysets
1502 .entry(keys)
1503 .and_modify(|existing| {
1504 existing.retain(|k, v| conds.get(k) == Some(v));
1508 })
1509 .or_insert(conds);
1510 }
1511 seen_keysets.into_iter().collect()
1512}
1513
1514fn resource_property_key_scenarios(
1521 m: &Arc<SemanticModel>,
1522 rid: &str,
1523 res: &ResolvedResource,
1524) -> Vec<(Vec<String>, HashMap<String, bool>)> {
1525 let keys: Vec<&str> = res.properties.keys().map(String::as_str).collect();
1526 if keys.len() != 1 || keys[0] != FN_IF {
1527 let mut ks: Vec<String> = res.properties.keys().cloned().collect();
1528 ks.sort();
1529 return vec![(ks, HashMap::new())];
1530 }
1531 let path = format!("{}.{}", KEY_PROPERTIES, FN_IF);
1534 let scenarios = object_key_scenarios(m, rid, &path);
1535 if scenarios.is_empty() {
1536 return vec![(Vec::new(), HashMap::new())];
1537 }
1538 scenarios
1539}
1540
1541fn validate_sub(
1542 out: &mut Vec<Diagnostic>,
1543 m: &Arc<SemanticModel>,
1544 rid: &str,
1545 rtype: &str,
1546 actual_keys: &[String],
1547 sub: &SubSchema,
1548 defs: &HashMap<String, PropSchema>,
1549 base_path: &str,
1550 depth: usize,
1551) {
1552 if depth > MAX_MATCH_DEPTH {
1556 m.record_budget_exhaustion(BudgetKind::SchemaMatchDepth);
1557 return;
1558 }
1559 let resolved;
1563 let effective_sub = if sub.ref_name.is_some() {
1564 resolved = sub.resolve(defs);
1565 if sub.ref_name.is_some() && resolved.ref_name.is_some() {
1566 out.push(build_diagnostic(
1568 "F3003",
1569 &format!(
1570 "Composition branch references undefined definition '{}'",
1571 sub.ref_name.as_deref().unwrap_or("")
1572 ),
1573 m,
1574 rid,
1575 base_path,
1576 None,
1577 ));
1578 return;
1579 }
1580 &*resolved
1581 } else {
1582 sub
1583 };
1584
1585 for req in &effective_sub.required {
1586 if !actual_keys.contains(req) {
1587 if extension_required_covered_by_dedicated_rule(rtype, req) {
1592 continue;
1593 }
1594 out.push(build_diagnostic(
1595 "F3003",
1596 &format!("'{}' is a required property", req),
1597 m,
1598 rid,
1599 base_path,
1600 Some(&format!("Add '{}'", req)),
1601 ));
1602 } else {
1603 check_required_not_null(out, m, rid, base_path, req);
1604 }
1605 }
1606
1607 if effective_sub.additional_properties == Some(false) && !effective_sub.properties.is_empty() {
1609 let known: std::collections::HashSet<&str> = effective_sub.properties.keys().map(|s| s.as_str()).collect();
1610 let pattern_matchers: Vec<Option<std::sync::Arc<CompiledPattern>>> =
1611 effective_sub.pattern_properties.keys().map(|p| compile_pattern(p)).collect();
1612 for key in actual_keys {
1613 if known.contains(key.as_str()) {
1614 continue;
1615 }
1616 let allowed_by_pattern =
1617 pattern_matchers.iter().any(|matcher| matcher.as_ref().is_none_or(|re| re.is_match(key)));
1618 if allowed_by_pattern {
1619 continue;
1620 }
1621 out.push(build_diagnostic(
1622 "F3002",
1623 &format!("Additional properties are not allowed ('{}' was unexpected)", key),
1624 m,
1625 rid,
1626 &format!("{}.{}", base_path, key),
1627 None,
1628 ));
1629 }
1630 }
1631
1632 validate_sub_dependencies(out, m, rid, actual_keys, effective_sub, base_path);
1633
1634 validate_required_groups(
1635 out,
1636 m,
1637 rid,
1638 actual_keys,
1639 &effective_sub.required_or,
1640 &effective_sub.required_xor,
1641 base_path,
1642 );
1643
1644 validate_sub_value_constraints(out, m, rid, effective_sub, defs, base_path);
1648
1649 for ite in &effective_sub.if_then_else {
1656 let matches = condition_matches_at(&ite.condition, actual_keys, m, rid, defs, base_path);
1657 let branch = if matches { &ite.then_schema } else { &ite.else_schema };
1658 if let Some(branch_schema) = branch {
1659 if ite.enforce_full_branch {
1660 validate_sub(out, m, rid, rtype, actual_keys, branch_schema, defs, base_path, depth + 1);
1661 } else {
1662 validate_sub_dependencies(out, m, rid, actual_keys, branch_schema, base_path);
1663 }
1664 }
1665 }
1666}
1667
1668fn schema_value_matches(
1679 value: &serde_json::Value,
1680 schema: &PropSchema,
1681 defs: &HashMap<String, PropSchema>,
1682 depth: usize,
1683) -> bool {
1684 schema_value_failure_reasons(value, schema, defs, depth, "").is_empty()
1685}
1686
1687fn evaluate_value_composition_branches(
1688 value: &serde_json::Value,
1689 branches: &[SubSchema],
1690 defs: &HashMap<String, PropSchema>,
1691 property_path: &str,
1692) -> Vec<CompositionBranchEvaluation> {
1693 branches
1694 .iter()
1695 .enumerate()
1696 .map(|(index, branch)| {
1697 let failure_reasons = schema_value_failure_reasons(value, branch, defs, 0, property_path);
1698 let matched = failure_reasons.is_empty();
1699 CompositionBranchEvaluation::new(
1700 index + 1,
1701 matched,
1702 required_property_combinations(branch, defs, None),
1703 failure_reasons,
1704 )
1705 })
1706 .collect()
1707}
1708
1709fn schema_value_failure_reasons(
1710 value: &serde_json::Value,
1711 schema: &PropSchema,
1712 defs: &HashMap<String, PropSchema>,
1713 depth: usize,
1714 property_path: &str,
1715) -> Vec<CompositionFailureReason> {
1716 if depth > MAX_MATCH_DEPTH || value.is_null() {
1717 if depth > MAX_MATCH_DEPTH {
1718 record_schema_budget_exhaustion(BudgetKind::SchemaMatchDepth);
1719 }
1720 return Vec::new();
1721 }
1722
1723 let resolved;
1724 let effective = if schema.ref_name.is_some() {
1725 resolved = schema.resolve(defs);
1726 if resolved.ref_name.is_some() {
1727 return vec![CompositionFailureReason::new(
1728 format!(
1729 "Composition branch references undefined definition '{}'",
1730 schema.ref_name.as_deref().unwrap_or("")
1731 ),
1732 property_path,
1733 )];
1734 }
1735 &*resolved
1736 } else {
1737 schema
1738 };
1739
1740 let mut reasons = Vec::new();
1741 if let Some(ref expected_type) = effective.prop_type
1742 && !type_matches(value, expected_type)
1743 {
1744 let coercible = expected_type
1745 .primary()
1746 .is_some_and(|expected| matches!(coerce_value(value, expected), CoerceResult::Coerced(_, _)));
1747 if !coercible {
1748 reasons.push(CompositionFailureReason::new(
1749 format!(
1750 "{} has type '{}', expected type '{}'",
1751 format_value(value),
1752 json_value_type(value),
1753 expected_type.names().collect::<Vec<_>>().join("|")
1754 ),
1755 property_path,
1756 ));
1757 return reasons;
1758 }
1759 }
1760
1761 if !effective.enum_values.is_empty() && !enum_matches(value, &effective.enum_values) {
1762 reasons.push(CompositionFailureReason::new(
1763 format!("{} is not one of {}", format_value(value), format_allowed_values(&effective.enum_values)),
1764 property_path,
1765 ));
1766 }
1767 if !effective.enum_case_insensitive.is_empty()
1768 && !enum_matches_case_insensitive(value, &effective.enum_case_insensitive)
1769 {
1770 reasons.push(CompositionFailureReason::new(
1771 format!(
1772 "{} is not one of {} (case-insensitive)",
1773 format_value(value),
1774 format_allowed_values(&effective.enum_case_insensitive)
1775 ),
1776 property_path,
1777 ));
1778 }
1779 if !effective.not_enum.is_empty() && enum_matches(value, &effective.not_enum) {
1780 reasons.push(CompositionFailureReason::new(
1781 format!("{} must not be one of {}", format_value(value), format_allowed_values(&effective.not_enum)),
1782 property_path,
1783 ));
1784 }
1785 if let Some(ref expected) = effective.const_value
1786 && !scalar_eq(value, expected)
1787 {
1788 reasons.push(CompositionFailureReason::new(
1789 format!("{} must equal {}", format_value(value), format_value(expected)),
1790 property_path,
1791 ));
1792 }
1793 if let Some(ref pattern) = effective.pattern
1794 && let Some(compiled) = compile_pattern(pattern)
1795 && let Some(actual) = coerce_to_string(value)
1796 && !compiled.is_match(&actual)
1797 {
1798 reasons.push(CompositionFailureReason::new(
1799 format!("{} does not match pattern '{pattern}'", format_value(value)),
1800 property_path,
1801 ));
1802 }
1803 if let Some(ref format) = effective.format
1804 && let Some(actual) = coerce_to_string(value)
1805 && !format_value_matches(&actual, format)
1806 {
1807 reasons.push(CompositionFailureReason::new(
1808 format!("{} does not match format '{format}'", format_value(value)),
1809 property_path,
1810 ));
1811 }
1812
1813 if let Some(number) = coerce_to_number(value) {
1814 if let Some(maximum) = effective.maximum
1815 && number > maximum
1816 {
1817 reasons.push(CompositionFailureReason::new(format!("{number} exceeds maximum {maximum}"), property_path));
1818 }
1819 if let Some(minimum) = effective.minimum
1820 && number < minimum
1821 {
1822 reasons.push(CompositionFailureReason::new(format!("{number} is below minimum {minimum}"), property_path));
1823 }
1824 if let Some(maximum) = effective.exclusive_maximum
1825 && number >= maximum
1826 {
1827 reasons.push(CompositionFailureReason::new(format!("{number} must be less than {maximum}"), property_path));
1828 }
1829 if let Some(minimum) = effective.exclusive_minimum
1830 && number <= minimum
1831 {
1832 reasons
1833 .push(CompositionFailureReason::new(format!("{number} must be greater than {minimum}"), property_path));
1834 }
1835 if let Some(multiple) = effective.multiple_of
1836 && multiple > 0.0
1837 {
1838 let remainder = (number / multiple).round() * multiple - number;
1839 let epsilon = multiple * 1e-9;
1840 if remainder.abs() > epsilon && (multiple - remainder.abs()).abs() > epsilon {
1841 reasons.push(CompositionFailureReason::new(
1842 format!("{number} is not a multiple of {multiple}"),
1843 property_path,
1844 ));
1845 }
1846 }
1847 }
1848
1849 if (effective.min_length.is_some() || effective.max_length.is_some())
1850 && let Some(actual) = coerce_to_string(value)
1851 {
1852 let length = actual.chars().count() as u64;
1853 if let Some(maximum) = effective.max_length
1854 && length > maximum
1855 {
1856 reasons.push(CompositionFailureReason::new(
1857 format!("String length {length} exceeds maximum {maximum}"),
1858 property_path,
1859 ));
1860 }
1861 if let Some(minimum) = effective.min_length
1862 && length < minimum
1863 {
1864 reasons.push(CompositionFailureReason::new(
1865 format!("String length {length} is below minimum {minimum}"),
1866 property_path,
1867 ));
1868 }
1869 }
1870
1871 if let Some(items) = value.as_array() {
1872 let length = items.iter().filter(|item| !item.is_null()).count() as u64;
1873 if let Some(maximum) = effective.max_items
1874 && length > maximum
1875 {
1876 reasons.push(CompositionFailureReason::new(
1877 format!("Array length {length} exceeds maximum {maximum}"),
1878 property_path,
1879 ));
1880 }
1881 if let Some(minimum) = effective.min_items
1882 && length < minimum
1883 {
1884 reasons.push(CompositionFailureReason::new(
1885 format!("Array length {length} is below minimum {minimum}"),
1886 property_path,
1887 ));
1888 }
1889 if effective.unique_items == Some(true) {
1890 let mut duplicate_found = false;
1891 for (index, item) in items.iter().enumerate() {
1892 if item.is_null() {
1893 continue;
1894 }
1895 if items[..index].iter().any(|previous| !previous.is_null() && previous == item) {
1896 duplicate_found = true;
1897 break;
1898 }
1899 }
1900 if duplicate_found {
1901 reasons.push(CompositionFailureReason::new("Array items must be unique", property_path));
1902 }
1903 }
1904 if let Some(ref item_schema) = effective.items {
1905 for (index, item) in items.iter().enumerate() {
1906 if !schema_value_matches(item, item_schema, defs, depth + 1) {
1907 let item_path = append_property_path(property_path, &index.to_string());
1908 reasons.extend(schema_value_failure_reasons(item, item_schema, defs, depth + 1, &item_path));
1909 }
1910 }
1911 }
1912 }
1913
1914 if let Some(object) = value.as_object() {
1915 let property_present = |property: &str| object.get(property).is_some_and(|candidate| !candidate.is_null());
1916 let property_count = object.values().filter(|candidate| !candidate.is_null()).count() as u64;
1917 if let Some(maximum) = effective.max_properties
1918 && property_count > maximum
1919 {
1920 reasons.push(CompositionFailureReason::new(
1921 format!("Object has {property_count} properties, maximum is {maximum}"),
1922 property_path,
1923 ));
1924 }
1925 if let Some(minimum) = effective.min_properties
1926 && property_count < minimum
1927 {
1928 reasons.push(CompositionFailureReason::new(
1929 format!("Object has {property_count} properties, minimum is {minimum}"),
1930 property_path,
1931 ));
1932 }
1933 for required in &effective.required {
1934 if !property_present(required) {
1935 reasons
1936 .push(CompositionFailureReason::new(format!("'{required}' is a required property"), property_path));
1937 }
1938 }
1939 for (trigger, dependencies) in &effective.dependent_required {
1940 if property_present(trigger) {
1941 for dependency in dependencies {
1942 if !property_present(dependency) {
1943 reasons.push(CompositionFailureReason::new(
1944 format!("'{dependency}' is a dependency of '{trigger}'"),
1945 property_path,
1946 ));
1947 }
1948 }
1949 }
1950 }
1951 for (trigger, excluded) in &effective.dependent_excluded {
1952 if property_present(trigger) {
1953 for property in excluded {
1954 if property_present(property) {
1955 reasons.push(CompositionFailureReason::new(
1956 format!("'{property}' should not be included with '{trigger}'"),
1957 append_property_path(property_path, property),
1958 ));
1959 }
1960 }
1961 }
1962 }
1963 if !effective.required_or.is_empty() && !effective.required_or.iter().any(|property| property_present(property))
1964 {
1965 reasons.push(CompositionFailureReason::new(
1966 format!(
1967 "At least one of {} is required",
1968 effective.required_or.iter().map(|property| format!("'{property}'")).collect::<Vec<_>>().join(", ")
1969 ),
1970 property_path,
1971 ));
1972 }
1973 if !effective.required_xor.is_empty() {
1974 let present = effective.required_xor.iter().filter(|property| property_present(property)).count();
1975 if present != 1 {
1976 reasons.push(CompositionFailureReason::new(
1977 format!(
1978 "Exactly one of {} is required, but {present} are present",
1979 effective
1980 .required_xor
1981 .iter()
1982 .map(|property| format!("'{property}'"))
1983 .collect::<Vec<_>>()
1984 .join(", ")
1985 ),
1986 property_path,
1987 ));
1988 }
1989 }
1990 if effective.additional_properties == Some(false)
1991 && (!effective.properties.is_empty() || !effective.pattern_properties.is_empty())
1992 {
1993 let pattern_matchers: Vec<Option<Arc<CompiledPattern>>> =
1994 effective.pattern_properties.keys().map(|pattern| compile_pattern(pattern)).collect();
1995 for (property, property_value) in object {
1996 if property_value.is_null() || effective.properties.contains_key(property) {
1997 continue;
1998 }
1999 let allowed = pattern_matchers
2000 .iter()
2001 .any(|matcher| matcher.as_ref().is_none_or(|compiled| compiled.is_match(property)));
2002 if !allowed {
2003 reasons.push(CompositionFailureReason::new(
2004 format!("Additional property '{property}' is not allowed"),
2005 append_property_path(property_path, property),
2006 ));
2007 }
2008 }
2009 }
2010 for (property, property_schema) in &effective.properties {
2011 if let Some(property_value) = object.get(property)
2012 && !schema_value_matches(property_value, property_schema, defs, depth + 1)
2013 {
2014 let child_path = append_property_path(property_path, property);
2015 reasons.extend(schema_value_failure_reasons(
2016 property_value,
2017 property_schema,
2018 defs,
2019 depth + 1,
2020 &child_path,
2021 ));
2022 }
2023 }
2024 }
2025
2026 for branch in &effective.all_of {
2027 if !schema_value_matches(value, branch, defs, depth + 1) {
2028 reasons.extend(schema_value_failure_reasons(value, branch, defs, depth + 1, property_path));
2029 }
2030 }
2031 if !effective.any_of.is_empty()
2032 && !effective.any_of.iter().any(|branch| schema_value_matches(value, branch, defs, depth + 1))
2033 {
2034 for branch in &effective.any_of {
2035 reasons.extend(schema_value_failure_reasons(value, branch, defs, depth + 1, property_path));
2036 }
2037 }
2038 if !effective.one_of.is_empty() {
2039 let matching: Vec<usize> = effective
2040 .one_of
2041 .iter()
2042 .enumerate()
2043 .filter_map(|(index, branch)| schema_value_matches(value, branch, defs, depth + 1).then_some(index + 1))
2044 .collect();
2045 if matching.is_empty() {
2046 for branch in &effective.one_of {
2047 reasons.extend(schema_value_failure_reasons(value, branch, defs, depth + 1, property_path));
2048 }
2049 } else if matching.len() > 1 {
2050 reasons.push(CompositionFailureReason::new(
2051 format!(
2052 "Value matches nested oneOf branches {}; exactly one must match",
2053 render_branch_numbers(&matching)
2054 ),
2055 property_path,
2056 ));
2057 }
2058 }
2059 for conditional in effective.if_then_else.iter().filter(|conditional| conditional.enforce_full_branch) {
2060 if let Some(object) = value.as_object() {
2061 let object_keys: Vec<String> = object
2062 .iter()
2063 .filter(|(_, candidate)| !candidate.is_null())
2064 .map(|(property, _)| property.clone())
2065 .collect();
2066 let condition_matches =
2067 condition_schema_value_matches(&conditional.condition, object, &object_keys, defs, depth + 1);
2068 let branch = if condition_matches { &conditional.then_schema } else { &conditional.else_schema };
2069 if let Some(branch_schema) = branch
2070 && !schema_value_matches(value, branch_schema, defs, depth + 1)
2071 {
2072 reasons.extend(schema_value_failure_reasons(value, branch_schema, defs, depth + 1, property_path));
2073 }
2074 }
2075 }
2076
2077 deduplicate_composition_reasons(reasons)
2078}
2079
2080fn append_property_path(base: &str, segment: &str) -> String {
2081 if base.is_empty() { segment.to_string() } else { format!("{base}.{segment}") }
2082}
2083
2084fn json_value_type(value: &serde_json::Value) -> &'static str {
2085 match value {
2086 serde_json::Value::Null => "null",
2087 serde_json::Value::Bool(_) => "boolean",
2088 serde_json::Value::Number(number) if number.is_i64() || number.is_u64() => "integer",
2089 serde_json::Value::Number(_) => "number",
2090 serde_json::Value::String(_) => "string",
2091 serde_json::Value::Array(_) => "array",
2092 serde_json::Value::Object(_) => "object",
2093 }
2094}
2095
2096fn condition_schema_value_matches(
2100 cond: &ConditionSchema,
2101 obj: &serde_json::Map<String, serde_json::Value>,
2102 obj_keys: &[String],
2103 defs: &HashMap<String, PropSchema>,
2104 depth: usize,
2105) -> bool {
2106 if depth > MAX_MATCH_DEPTH {
2107 record_schema_budget_exhaustion(BudgetKind::SchemaMatchDepth);
2108 return true; }
2110
2111 if !cond.all_of.is_empty()
2112 && !cond.all_of.iter().all(|sub| condition_schema_value_matches(sub, obj, obj_keys, defs, depth + 1))
2113 {
2114 return false;
2115 }
2116 if !cond.any_of.is_empty()
2117 && !cond.any_of.iter().any(|sub| condition_schema_value_matches(sub, obj, obj_keys, defs, depth + 1))
2118 {
2119 return false;
2120 }
2121 if !cond.one_of.is_empty()
2122 && cond.one_of.iter().filter(|sub| condition_schema_value_matches(sub, obj, obj_keys, defs, depth + 1)).count()
2123 != 1
2124 {
2125 return false;
2126 }
2127 if let Some(negated) = &cond.not
2128 && condition_schema_value_matches(negated, obj, obj_keys, defs, depth + 1)
2129 {
2130 return false;
2131 }
2132
2133 if let Some(ref required_type) = cond.prop_type
2136 && !required_type.names().any(|name| name == "object")
2137 {
2138 return false;
2139 }
2140
2141 for req in &cond.required {
2143 if !obj_keys.iter().any(|k| k == req) {
2144 return false;
2145 }
2146 }
2147 for absent in &cond.absent {
2148 if obj_keys.iter().any(|key| key == absent) {
2149 return false;
2150 }
2151 }
2152
2153 for (prop_name, prop_schema) in &cond.properties {
2155 let resolved = prop_schema.resolve(defs);
2156 if let Some(prop_value) = obj.get(prop_name) {
2157 if !schema_value_matches(prop_value, &resolved, defs, depth + 1) {
2158 return false;
2159 }
2160 } else {
2161 let has_value_constraint = !resolved.enum_values.is_empty()
2164 || !resolved.enum_case_insensitive.is_empty()
2165 || resolved.const_value.is_some()
2166 || resolved.pattern.is_some();
2167 if has_value_constraint {
2168 return false;
2169 }
2170 }
2171 }
2172
2173 true
2174}
2175
2176fn validate_sub_value_constraints(
2186 out: &mut Vec<Diagnostic>,
2187 m: &Arc<SemanticModel>,
2188 rid: &str,
2189 sub: &SubSchema,
2190 defs: &HashMap<String, PropSchema>,
2191 base_path: &str,
2192) {
2193 let has_value_constraints =
2199 sub.properties.values().any(|ps| ps.resolve(defs).constrains_value()) || sub_self_constrains_value(sub);
2200 if !has_value_constraints {
2201 return;
2202 }
2203
2204 for (prop_name, prop_schema) in &sub.properties {
2206 let resolved = prop_schema.resolve(defs);
2207 let prop_path = format!("{}.{}", base_path, prop_name);
2208 let shared_scenarios = m.resolve_scenarios_json_shared(rid, &prop_path);
2212 let scenarios: Vec<_> =
2213 shared_scenarios.iter().filter(|(_, conds)| scenario_consistent_with_filter(m, conds)).collect();
2214
2215 if scenarios.is_empty() {
2218 continue;
2219 }
2220
2221 let any_scenario_matches = scenarios.iter().any(|(val, conds)| {
2223 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, &prop_path, val, conds) {
2224 return true; }
2226 schema_value_matches(val, &resolved, defs, 0)
2227 });
2228
2229 if !any_scenario_matches {
2230 let (offending, failure_detail) = scenarios
2234 .iter()
2235 .find(|(val, conds)| {
2236 is_satisfiable(m, conds)
2237 && !val.is_null()
2238 && !defer_value_constraints(m, rid, &prop_path, val, conds)
2239 })
2240 .map(|(val, _)| {
2241 let reasons = schema_value_failure_reasons(val, &resolved, defs, 0, &prop_path);
2242 (format_value(val), render_composition_reasons(&reasons, &prop_path))
2243 })
2244 .unwrap_or_else(|| ("Value".to_string(), describe_prop_constraints(&resolved)));
2245 out.push(build_diagnostic(
2246 "F3017",
2247 &format!(
2248 "{offending} at '{prop_name}' does not satisfy the composition branch constraint ({}): {failure_detail}",
2249 describe_prop_constraints(&resolved)
2250 ),
2251 m,
2252 rid,
2253 &prop_path,
2254 None,
2255 ));
2256 }
2257 }
2258
2259 if sub_self_constrains_value(sub) {
2263 let self_schema = schema_for_self_value_constraints(sub);
2264 let shared_scenarios = m.resolve_scenarios_json_shared(rid, base_path);
2265 let scenarios: Vec<_> =
2266 shared_scenarios.iter().filter(|(_, conds)| scenario_consistent_with_filter(m, conds)).collect();
2267 if !scenarios.is_empty() {
2268 let any_scenario_matches = scenarios.iter().any(|(val, conds)| {
2269 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, base_path, val, conds)
2270 {
2271 return true;
2272 }
2273 schema_value_matches(val, &self_schema, defs, 0)
2274 });
2275 if !any_scenario_matches {
2276 let (offending, failure_detail) = scenarios
2277 .iter()
2278 .find(|(val, conds)| {
2279 is_satisfiable(m, conds)
2280 && !val.is_null()
2281 && !defer_value_constraints(m, rid, base_path, val, conds)
2282 })
2283 .map(|(val, _)| {
2284 let reasons = schema_value_failure_reasons(val, &self_schema, defs, 0, base_path);
2285 (format_value(val), render_composition_reasons(&reasons, base_path))
2286 })
2287 .unwrap_or_else(|| ("Value".to_string(), describe_prop_constraints(&self_schema)));
2288 out.push(build_diagnostic(
2289 "F3017",
2290 &format!(
2291 "{offending} does not satisfy the composition branch constraint ({}): {failure_detail}",
2292 describe_prop_constraints(&self_schema)
2293 ),
2294 m,
2295 rid,
2296 base_path,
2297 None,
2298 ));
2299 }
2300 }
2301 }
2302}
2303
2304fn branch_scenario_assignments<'a>(
2308 m: &Arc<SemanticModel>,
2309 rid: &str,
2310 branches: impl Iterator<Item = &'a SubSchema>,
2311 defs: &HashMap<String, PropSchema>,
2312 base_path: &str,
2313) -> Option<Vec<HashMap<String, bool>>> {
2314 let mut property_names = BTreeSet::new();
2315 for branch in branches {
2316 let effective_branch = branch.resolve(defs);
2317 property_names.extend(effective_branch.properties.keys().cloned());
2318 property_names.extend(effective_branch.required.iter().cloned());
2319 property_names.extend(effective_branch.required_or.iter().cloned());
2320 property_names.extend(effective_branch.required_xor.iter().cloned());
2321 }
2322
2323 let paths: Vec<String> = property_names.into_iter().map(|name| format!("{}.{}", base_path, name)).collect();
2324 let refs: Vec<&str> = paths.iter().map(String::as_str).collect();
2325 property_scenario_assignments(m, rid, &refs)
2326}
2327
2328fn assignment_condition_map(assignment: &HashMap<String, bool>) -> Option<HashMap<String, bool>> {
2332 if assignment.is_empty() { None } else { Some(assignment.clone()) }
2333}
2334
2335#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2336enum CompositionKind {
2337 AnyOf,
2338 OneOf,
2339}
2340
2341impl CompositionKind {
2342 fn name(self) -> &'static str {
2343 match self {
2344 CompositionKind::AnyOf => "anyOf",
2345 CompositionKind::OneOf => "oneOf",
2346 }
2347 }
2348
2349 fn expected_constraint(self) -> &'static str {
2350 match self {
2351 CompositionKind::AnyOf => "at least one anyOf branch",
2352 CompositionKind::OneOf => "exactly one oneOf branch",
2353 }
2354 }
2355}
2356
2357#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
2358struct CompositionFailureReason {
2359 message: String,
2360 property_path: String,
2361}
2362
2363impl CompositionFailureReason {
2364 fn new(message: impl Into<String>, property_path: impl Into<String>) -> Self {
2365 Self { message: message.into(), property_path: property_path.into() }
2366 }
2367}
2368
2369#[derive(Clone, Debug)]
2370struct CompositionBranchEvaluation {
2371 branch: usize,
2372 matched: bool,
2373 required_property_combinations: Vec<Vec<String>>,
2374 required_property_combinations_curtailed: bool,
2375 failure_reasons: Vec<CompositionFailureReason>,
2376}
2377
2378impl CompositionBranchEvaluation {
2379 fn new(
2380 branch: usize,
2381 matched: bool,
2382 required_property_combinations: (Vec<Vec<String>>, bool),
2383 failure_reasons: Vec<CompositionFailureReason>,
2384 ) -> Self {
2385 let (mut required_property_combinations, required_property_combinations_curtailed) =
2386 required_property_combinations;
2387 for combination in &mut required_property_combinations {
2388 combination.sort();
2389 combination.dedup();
2390 }
2391 required_property_combinations.retain(|combination| !combination.is_empty());
2392 required_property_combinations.sort();
2393 required_property_combinations.dedup();
2394 Self {
2395 branch,
2396 matched,
2397 required_property_combinations,
2398 required_property_combinations_curtailed,
2399 failure_reasons: deduplicate_composition_reasons(failure_reasons),
2400 }
2401 }
2402}
2403
2404fn deduplicate_composition_reasons(mut reasons: Vec<CompositionFailureReason>) -> Vec<CompositionFailureReason> {
2405 reasons.sort();
2406 reasons.dedup();
2407 reasons
2408}
2409
2410#[allow(clippy::too_many_arguments)]
2411fn evaluate_object_composition_branches(
2412 m: &Arc<SemanticModel>,
2413 rid: &str,
2414 rtype: &str,
2415 actual_keys: &[String],
2416 branches: &[SubSchema],
2417 defs: &HashMap<String, PropSchema>,
2418 base_path: &str,
2419 assignment: &HashMap<String, bool>,
2420) -> Vec<CompositionBranchEvaluation> {
2421 branches
2422 .iter()
2423 .enumerate()
2424 .map(|(index, branch)| {
2425 let mut branch_diagnostics = Vec::new();
2426 validate_sub_under_assignment(
2427 &mut branch_diagnostics,
2428 m,
2429 rid,
2430 rtype,
2431 actual_keys,
2432 branch,
2433 defs,
2434 base_path,
2435 assignment,
2436 );
2437 let matched = branch_diagnostics.is_empty();
2438 let mut failure_reasons: Vec<CompositionFailureReason> = branch_diagnostics
2439 .into_iter()
2440 .map(|diagnostic| {
2441 CompositionFailureReason::new(
2442 diagnostic.message,
2443 diagnostic.property_path.unwrap_or_else(|| base_path.to_string()),
2444 )
2445 })
2446 .collect();
2447 if !matched && failure_reasons.is_empty() {
2448 failure_reasons.push(CompositionFailureReason::new("The branch schema was not satisfied", base_path));
2449 }
2450 CompositionBranchEvaluation::new(
2451 index + 1,
2452 matched,
2453 required_property_combinations(branch, defs, Some(rtype)),
2454 failure_reasons,
2455 )
2456 })
2457 .collect()
2458}
2459
2460fn required_property_combinations(
2465 branch: &SubSchema,
2466 defs: &HashMap<String, PropSchema>,
2467 rtype: Option<&str>,
2468) -> (Vec<Vec<String>>, bool) {
2469 let resolved;
2470 let effective = if branch.ref_name.is_some() {
2471 resolved = branch.resolve(defs);
2472 if resolved.ref_name.is_some() {
2473 return (Vec::new(), false);
2474 }
2475 &*resolved
2476 } else {
2477 branch
2478 };
2479
2480 let mut base: Vec<String> = effective
2481 .required
2482 .iter()
2483 .filter(|property| {
2484 rtype.is_none_or(|resource_type| !extension_required_covered_by_dedicated_rule(resource_type, property))
2485 })
2486 .cloned()
2487 .collect();
2488 base.sort();
2489 base.dedup();
2490
2491 if effective.required.is_empty() && effective.required_or.is_empty() && effective.required_xor.is_empty() {
2492 return (Vec::new(), false);
2493 }
2494
2495 let mut combinations = vec![base];
2496 let mut curtailed = expand_required_choice_group(&mut combinations, &effective.required_or, false);
2497 curtailed |= expand_required_choice_group(&mut combinations, &effective.required_xor, true);
2498 (combinations, curtailed)
2499}
2500
2501fn expand_required_choice_group(combinations: &mut Vec<Vec<String>>, choices: &[String], exactly_one: bool) -> bool {
2504 if choices.is_empty() {
2505 return false;
2506 }
2507
2508 let mut expanded = Vec::new();
2509 let mut curtailed = false;
2510 'combinations: for combination in combinations.iter() {
2511 let present = choices.iter().filter(|choice| combination.contains(choice)).count();
2512 if present > 0 {
2513 if !exactly_one || present == 1 {
2514 if expanded.len() == MAX_REQUIRED_PROPERTY_COMBINATIONS {
2515 curtailed = true;
2516 break;
2517 }
2518 expanded.push(combination.clone());
2519 }
2520 continue;
2521 }
2522 for choice in choices {
2523 if expanded.len() == MAX_REQUIRED_PROPERTY_COMBINATIONS {
2524 curtailed = true;
2525 break 'combinations;
2526 }
2527 let mut candidate = combination.clone();
2528 candidate.push(choice.clone());
2529 expanded.push(candidate);
2530 }
2531 }
2532 *combinations = expanded;
2533 curtailed
2534}
2535
2536#[allow(clippy::too_many_arguments)]
2537fn build_composition_diagnostic(
2538 rule_id: &str,
2539 kind: CompositionKind,
2540 evaluations: &[CompositionBranchEvaluation],
2541 m: &Arc<SemanticModel>,
2542 rid: &str,
2543 property_path: &str,
2544 resource_type: Option<&str>,
2545 actual_value: Option<&serde_json::Value>,
2546 condition_scenario: Option<HashMap<String, bool>>,
2547) -> Diagnostic {
2548 let matching_branches: Vec<usize> =
2549 evaluations.iter().filter(|evaluation| evaluation.matched).map(|evaluation| evaluation.branch).collect();
2550 let match_count = matching_branches.len();
2551 let branch_count = evaluations.len();
2552 let match_outcome = if match_count == 0 { "zeroMatches" } else { "multipleMatches" };
2553
2554 let target = resource_type.map(|rtype| format!(" for {rtype}")).unwrap_or_default();
2555 let mut message = match (kind, match_count) {
2556 (CompositionKind::AnyOf, _) => format!(
2557 "Value is not valid under any of the {branch_count} anyOf schemas{target} (0 branches matched; at least one is required)."
2558 ),
2559 (CompositionKind::OneOf, 0) => format!(
2560 "Value is not valid under any of the {branch_count} oneOf schemas{target} (0 branches matched; exactly one is required)."
2561 ),
2562 (CompositionKind::OneOf, _) => format!(
2563 "Value is valid under more than one of the {branch_count} oneOf schemas{target} ({match_count} branches matched; exactly one is required). Matching branches: {}.",
2564 render_branch_numbers(&matching_branches)
2565 ),
2566 };
2567
2568 let mut valid_combination_set = BTreeSet::new();
2569 let mut combinations_curtailed =
2570 evaluations.iter().any(|evaluation| evaluation.required_property_combinations_curtailed);
2571 'evaluations: for evaluation in evaluations {
2572 for combination in &evaluation.required_property_combinations {
2573 if valid_combination_set.contains(combination) {
2574 continue;
2575 }
2576 if valid_combination_set.len() == MAX_REQUIRED_PROPERTY_COMBINATIONS {
2577 combinations_curtailed = true;
2578 break 'evaluations;
2579 }
2580 valid_combination_set.insert(combination.clone());
2581 }
2582 }
2583 let valid_combinations: Vec<Vec<String>> = valid_combination_set.into_iter().collect();
2584 if !valid_combinations.is_empty() {
2585 message.push_str(" Required property combinations: ");
2586 message.push_str(
2587 &valid_combinations
2588 .iter()
2589 .map(|combination| {
2590 format!(
2591 "[{}]",
2592 combination.iter().map(|property| format!("'{property}'")).collect::<Vec<_>>().join(", ")
2593 )
2594 })
2595 .collect::<Vec<_>>()
2596 .join(" or "),
2597 );
2598 message.push('.');
2599 }
2600 if combinations_curtailed {
2601 m.record_budget_exhaustion(BudgetKind::RequiredPropertyCombinations);
2602 message.push_str(&format!(
2603 " Additional required-property combinations were omitted after the deterministic limit of {MAX_REQUIRED_PROPERTY_COMBINATIONS}."
2604 ));
2605 }
2606
2607 if match_count == 0 {
2608 let failure_summaries: Vec<String> = evaluations
2609 .iter()
2610 .filter(|evaluation| !evaluation.matched)
2611 .map(|evaluation| {
2612 let reasons = render_composition_reasons(&evaluation.failure_reasons, property_path);
2613 format!("branch {}: {reasons}", evaluation.branch)
2614 })
2615 .collect();
2616 if !failure_summaries.is_empty() {
2617 message.push_str(" Branch failures: ");
2618 message.push_str(&failure_summaries.join("; "));
2619 message.push('.');
2620 }
2621 }
2622
2623 let mut extra = HashMap::new();
2624 extra.insert("compositionKind".to_string(), serde_json::json!(kind.name()).into());
2625 extra.insert("matchOutcome".to_string(), serde_json::json!(match_outcome).into());
2626 extra.insert("branchCount".to_string(), serde_json::json!(branch_count).into());
2627 extra.insert("matchCount".to_string(), serde_json::json!(match_count).into());
2628 if !valid_combinations.is_empty() {
2629 extra.insert("validPropertyCombinations".to_string(), serde_json::json!(valid_combinations).into());
2630 }
2631 if combinations_curtailed {
2632 extra.insert("validPropertyCombinationsCurtailed".to_string(), serde_json::json!(true).into());
2633 extra.insert(
2634 "validPropertyCombinationsLimit".to_string(),
2635 serde_json::json!(MAX_REQUIRED_PROPERTY_COMBINATIONS).into(),
2636 );
2637 }
2638 if !matching_branches.is_empty() {
2639 extra.insert("matchingBranches".to_string(), serde_json::json!(matching_branches).into());
2640 }
2641 let branch_failures: Vec<serde_json::Value> = evaluations
2642 .iter()
2643 .filter(|evaluation| !evaluation.matched)
2644 .map(|evaluation| {
2645 let reasons: Vec<serde_json::Value> = evaluation
2646 .failure_reasons
2647 .iter()
2648 .map(|reason| {
2649 serde_json::json!({
2650 "message": reason.message,
2651 "propertyPath": reason.property_path,
2652 })
2653 })
2654 .collect();
2655 serde_json::json!({
2656 "branch": evaluation.branch,
2657 "reasons": reasons,
2658 })
2659 })
2660 .collect();
2661 if !branch_failures.is_empty() {
2662 extra.insert("branchFailures".to_string(), serde_json::json!(branch_failures).into());
2663 }
2664
2665 let mut diagnostic =
2666 build_diagnostic_conditional(rule_id, &message, m, rid, property_path, None, condition_scenario);
2667 diagnostic.context = Some(ViolationContext {
2668 actual_value: actual_value.cloned().map(Into::into),
2669 expected_constraint: Some(kind.expected_constraint().to_string()),
2670 property: None,
2671 lifecycle: None,
2672 resolution_source: None,
2673 extra: Some(extra),
2674 });
2675 diagnostic
2676}
2677
2678fn render_branch_numbers(branches: &[usize]) -> String {
2679 branches.iter().map(usize::to_string).collect::<Vec<_>>().join(", ")
2680}
2681
2682fn render_composition_reasons(reasons: &[CompositionFailureReason], default_path: &str) -> String {
2683 reasons
2684 .iter()
2685 .map(|reason| {
2686 if reason.property_path.is_empty() || reason.property_path == default_path {
2687 reason.message.clone()
2688 } else {
2689 format!("at '{}': {}", reason.property_path, reason.message)
2690 }
2691 })
2692 .collect::<Vec<_>>()
2693 .join(", ")
2694}
2695
2696#[allow(clippy::too_many_arguments)]
2704fn validate_sub_under_assignment(
2705 out: &mut Vec<Diagnostic>,
2706 m: &Arc<SemanticModel>,
2707 rid: &str,
2708 rtype: &str,
2709 actual_keys: &[String],
2710 sub: &SubSchema,
2711 defs: &HashMap<String, PropSchema>,
2712 base_path: &str,
2713 assignment: &HashMap<String, bool>,
2714) {
2715 let Some(merged) = merged_scenario_filter(assignment) else {
2716 return;
2717 };
2718 let _scenario_filter_scope = ScenarioFilterScope::enter(merged.clone());
2719 let effective_keys: Vec<String> =
2720 actual_keys.iter().filter(|key| property_present_under(m, rid, base_path, key, &merged)).cloned().collect();
2721 validate_sub(out, m, rid, rtype, &effective_keys, sub, defs, base_path, 0);
2722}
2723
2724struct ScenarioFilterScope {
2725 previous: Option<HashMap<String, bool>>,
2726}
2727
2728impl ScenarioFilterScope {
2729 fn enter(assignment: HashMap<String, bool>) -> Self {
2730 let previous = SCENARIO_FILTER.with(|filter| filter.replace(Some(assignment)));
2731 Self { previous }
2732 }
2733}
2734
2735impl Drop for ScenarioFilterScope {
2736 fn drop(&mut self) {
2737 let previous = self.previous.take();
2738 SCENARIO_FILTER.with(|filter| {
2739 filter.replace(previous);
2740 });
2741 }
2742}
2743
2744fn merged_scenario_filter(assignment: &HashMap<String, bool>) -> Option<HashMap<String, bool>> {
2745 SCENARIO_FILTER.with(|filter| {
2746 let mut merged = filter.borrow().clone().unwrap_or_default();
2747 for (name, value) in assignment {
2748 if merged.get(name).is_some_and(|previous| previous != value) {
2749 return None;
2750 }
2751 merged.insert(name.clone(), *value);
2752 }
2753 Some(merged)
2754 })
2755}
2756
2757fn reset_scenario_filter() {
2758 SCENARIO_FILTER.with(|filter| {
2759 filter.replace(None);
2760 });
2761}
2762
2763thread_local! {
2764 static SCENARIO_FILTER: std::cell::RefCell<Option<HashMap<String, bool>>> = const { std::cell::RefCell::new(None) };
2771
2772 static SCHEMA_BUDGET_EXHAUSTIONS: std::cell::RefCell<BTreeSet<BudgetKind>> =
2776 const { std::cell::RefCell::new(BTreeSet::new()) };
2777}
2778
2779fn record_schema_budget_exhaustion(kind: BudgetKind) {
2780 SCHEMA_BUDGET_EXHAUSTIONS.with(|exhaustions| {
2781 exhaustions.borrow_mut().insert(kind);
2782 });
2783}
2784
2785fn reset_schema_budget_exhaustions() {
2786 SCHEMA_BUDGET_EXHAUSTIONS.with(|exhaustions| exhaustions.borrow_mut().clear());
2787}
2788
2789fn drain_schema_budget_exhaustions(model: &Arc<SemanticModel>) {
2790 SCHEMA_BUDGET_EXHAUSTIONS.with(|exhaustions| {
2791 for kind in std::mem::take(&mut *exhaustions.borrow_mut()) {
2792 model.record_budget_exhaustion(kind);
2793 }
2794 });
2795}
2796
2797fn scenario_consistent_with_filter(m: &Arc<SemanticModel>, conds: &HashMap<String, bool>) -> bool {
2800 SCENARIO_FILTER.with(|filter| {
2801 let borrowed = filter.borrow();
2802 let Some(assignment) = borrowed.as_ref() else {
2803 return true;
2804 };
2805 for (name, value) in assignment {
2806 if let Some(scenario_value) = conds.get(name)
2807 && scenario_value != value
2808 {
2809 return false;
2810 }
2811 }
2812 let mut merged = conds.clone();
2813 for (name, value) in assignment {
2814 merged.insert(name.clone(), *value);
2815 }
2816 is_satisfiable(m, &merged)
2817 })
2818}
2819
2820fn sub_self_constrains_value(sub: &PropSchema) -> bool {
2830 let PropSchema {
2831 ref_name: _,
2832 prop_type,
2833 enum_values,
2834 enum_case_insensitive,
2835 not_enum,
2836 const_value,
2837 pattern,
2838 minimum,
2839 maximum,
2840 exclusive_minimum,
2841 exclusive_maximum,
2842 multiple_of,
2843 min_length,
2844 max_length,
2845 min_items,
2846 max_items,
2847 unique_items,
2848 min_properties,
2849 max_properties,
2850 format,
2851 properties: _,
2852 required: _,
2853 required_present: _,
2854 additional_properties: _,
2855 pattern_properties: _,
2856 items,
2857 all_of,
2858 any_of,
2859 one_of,
2860 if_then_else,
2861 dependent_required: _,
2862 dependent_excluded: _,
2863 required_or: _,
2864 required_xor: _,
2865 } = sub;
2866 prop_type.is_some()
2867 || !enum_values.is_empty()
2868 || !enum_case_insensitive.is_empty()
2869 || !not_enum.is_empty()
2870 || const_value.is_some()
2871 || pattern.is_some()
2872 || minimum.is_some()
2873 || maximum.is_some()
2874 || exclusive_minimum.is_some()
2875 || exclusive_maximum.is_some()
2876 || multiple_of.is_some()
2877 || min_length.is_some()
2878 || max_length.is_some()
2879 || min_items.is_some()
2880 || max_items.is_some()
2881 || unique_items == &Some(true)
2882 || min_properties.is_some()
2883 || max_properties.is_some()
2884 || format.is_some()
2885 || items.is_some()
2886 || !all_of.is_empty()
2887 || !any_of.is_empty()
2888 || !one_of.is_empty()
2889 || !if_then_else.is_empty()
2890}
2891
2892fn schema_for_self_value_constraints(sub: &PropSchema) -> PropSchema {
2893 PropSchema {
2894 ref_name: None,
2895 properties: HashMap::new(),
2896 required: Vec::new(),
2897 required_present: false,
2898 additional_properties: None,
2899 pattern_properties: HashMap::new(),
2900 dependent_required: HashMap::new(),
2901 dependent_excluded: HashMap::new(),
2902 required_or: Vec::new(),
2903 required_xor: Vec::new(),
2904 ..sub.clone()
2905 }
2906}
2907
2908fn describe_prop_constraints(schema: &PropSchema) -> String {
2912 let mut parts: Vec<String> = Vec::new();
2913 if let Some(ref pt) = schema.prop_type {
2914 parts.push(format!("type '{}'", pt.names().collect::<Vec<_>>().join("|")));
2915 }
2916 if !schema.enum_values.is_empty() {
2917 parts.push(format!("one of {}", format_allowed_values(&schema.enum_values)));
2918 }
2919 if !schema.enum_case_insensitive.is_empty() {
2920 parts.push(format!("one of {} (case-insensitive)", format_allowed_values(&schema.enum_case_insensitive)));
2921 }
2922 if !schema.not_enum.is_empty() {
2923 parts.push(format!("none of {}", format_allowed_values(&schema.not_enum)));
2924 }
2925 if let Some(ref cv) = schema.const_value {
2926 parts.push(format!("exactly {cv}"));
2927 }
2928 if let Some(ref pattern) = schema.pattern {
2929 parts.push(format!("pattern '{pattern}'"));
2930 }
2931 if let Some(min) = schema.minimum {
2932 parts.push(format!("minimum {min}"));
2933 }
2934 if let Some(max) = schema.maximum {
2935 parts.push(format!("maximum {max}"));
2936 }
2937 if let Some(min) = schema.exclusive_minimum {
2938 parts.push(format!("exclusiveMinimum {min}"));
2939 }
2940 if let Some(max) = schema.exclusive_maximum {
2941 parts.push(format!("exclusiveMaximum {max}"));
2942 }
2943 if let Some(mult) = schema.multiple_of {
2944 parts.push(format!("multipleOf {mult}"));
2945 }
2946 if let Some(min) = schema.min_length {
2947 parts.push(format!("minLength {min}"));
2948 }
2949 if let Some(max) = schema.max_length {
2950 parts.push(format!("maxLength {max}"));
2951 }
2952 if let Some(min) = schema.min_items {
2953 parts.push(format!("minItems {min}"));
2954 }
2955 if let Some(max) = schema.max_items {
2956 parts.push(format!("maxItems {max}"));
2957 }
2958 if let Some(min) = schema.min_properties {
2959 parts.push(format!("minProperties {min}"));
2960 }
2961 if let Some(max) = schema.max_properties {
2962 parts.push(format!("maxProperties {max}"));
2963 }
2964 if !schema.required.is_empty() {
2965 parts.push(format!(
2966 "requires {}",
2967 schema.required.iter().map(|p| format!("'{p}'")).collect::<Vec<_>>().join(", ")
2968 ));
2969 }
2970 if schema.items.is_some() {
2971 parts.push("an item schema".to_string());
2972 }
2973 if !schema.properties.is_empty() {
2974 parts.push("nested property constraints".to_string());
2975 }
2976 if !schema.all_of.is_empty() || !schema.any_of.is_empty() || !schema.one_of.is_empty() {
2977 parts.push("nested composition".to_string());
2978 }
2979 if !schema.if_then_else.is_empty() {
2980 parts.push("a conditional constraint".to_string());
2981 }
2982 if parts.is_empty() { "the branch schema".to_string() } else { parts.join(", ") }
2983}
2984
2985fn validate_sub_dependencies(
2993 out: &mut Vec<Diagnostic>,
2994 m: &Arc<SemanticModel>,
2995 rid: &str,
2996 actual_keys: &[String],
2997 sub: &SubSchema,
2998 base_path: &str,
2999) {
3000 for (trigger, deps) in &sub.dependent_required {
3001 if actual_keys.contains(trigger) {
3002 for dep in deps {
3003 if !actual_keys.contains(dep) {
3004 out.push(build_diagnostic(
3005 "F3021",
3006 &format!("'{}' is a dependency of '{}'", dep, trigger),
3007 m,
3008 rid,
3009 base_path,
3010 None,
3011 ));
3012 }
3013 }
3014 }
3015 }
3016 for (trigger, excluded) in &sub.dependent_excluded {
3017 if actual_keys.contains(trigger) {
3018 for dep in excluded {
3019 if actual_keys.contains(dep) {
3020 out.push(build_diagnostic(
3021 "F3020",
3022 &format!("'{}' should not be included with '{}'", dep, trigger),
3023 m,
3024 rid,
3025 &format!("{}.{}", base_path, dep),
3026 None,
3027 ));
3028 }
3029 }
3030 }
3031 }
3032}
3033
3034struct ConditionalConstraintRule {
3045 resource_type: &'static str,
3046 property_name: &'static str,
3047 rule_id: &'static str,
3048 context: Option<&'static str>,
3054}
3055
3056const CONDITIONAL_CONSTRAINT_RULES: [ConditionalConstraintRule; 9] = [
3057 ConditionalConstraintRule {
3058 resource_type: "AWS::Cognito::UserPoolDomain",
3059 property_name: "Domain",
3060 rule_id: "E3031",
3061 context: None,
3062 },
3063 ConditionalConstraintRule {
3064 resource_type: "AWS::DynamoDB::GlobalTable",
3065 property_name: "AttributeDefinitions",
3066 rule_id: "E3032",
3067 context: Some("for a table with local secondary indexes"),
3068 },
3069 ConditionalConstraintRule {
3070 resource_type: "AWS::DynamoDB::GlobalTable",
3071 property_name: "KeySchema",
3072 rule_id: "E3032",
3073 context: Some("for a table with local secondary indexes"),
3074 },
3075 ConditionalConstraintRule {
3076 resource_type: "AWS::DynamoDB::Table",
3077 property_name: "AttributeDefinitions",
3078 rule_id: "E3032",
3079 context: Some("for a table with local secondary indexes"),
3080 },
3081 ConditionalConstraintRule {
3082 resource_type: "AWS::DynamoDB::Table",
3083 property_name: "KeySchema",
3084 rule_id: "E3032",
3085 context: Some("for a table with local secondary indexes"),
3086 },
3087 ConditionalConstraintRule {
3088 resource_type: "AWS::Lambda::Function",
3089 property_name: "Timeout",
3090 rule_id: "E3717",
3091 context: Some("for a function without CapacityProviderConfig"),
3092 },
3093 ConditionalConstraintRule {
3094 resource_type: "AWS::ApiGateway::Authorizer",
3095 property_name: "AuthorizerResultTtlInSeconds",
3096 rule_id: "E3718",
3097 context: Some("for a TOKEN or REQUEST authorizer"),
3098 },
3099 ConditionalConstraintRule {
3100 resource_type: "AWS::RDS::DBCluster",
3101 property_name: "MasterUsername",
3102 rule_id: "E3002",
3103 context: Some("for a PostgreSQL-compatible engine, where it is a reserved word"),
3104 },
3105 ConditionalConstraintRule {
3106 resource_type: "AWS::RDS::DBInstance",
3107 property_name: "BackupRetentionPeriod",
3108 rule_id: "E3719",
3109 context: Some("for a non-Aurora engine"),
3110 },
3111];
3112
3113fn conditional_constraint_rule(resource_type: &str, property_name: &str) -> Option<&'static ConditionalConstraintRule> {
3114 CONDITIONAL_CONSTRAINT_RULES
3115 .iter()
3116 .find(|rule| rule.resource_type == resource_type && rule.property_name == property_name)
3117}
3118
3119fn schema_for_conditional_value_constraints(resolved: &PropSchema) -> PropSchema {
3123 PropSchema {
3124 pattern: resolved.pattern.clone(),
3125 format: resolved.format.clone(),
3126 const_value: resolved.const_value.clone(),
3127 not_enum: resolved.not_enum.clone(),
3128 minimum: resolved.minimum,
3129 maximum: resolved.maximum,
3130 exclusive_minimum: resolved.exclusive_minimum,
3131 exclusive_maximum: resolved.exclusive_maximum,
3132 multiple_of: resolved.multiple_of,
3133 min_length: resolved.min_length,
3134 max_length: resolved.max_length,
3135 min_items: resolved.min_items,
3136 max_items: resolved.max_items,
3137 unique_items: resolved.unique_items,
3138 min_properties: resolved.min_properties,
3139 max_properties: resolved.max_properties,
3140 ..Default::default()
3141 }
3142}
3143
3144fn validate_owned_conditional_value_constraints(
3149 out: &mut Vec<Diagnostic>,
3150 m: &Arc<SemanticModel>,
3151 rid: &str,
3152 resource_type: &str,
3153 branch: &SubSchema,
3154 defs: &HashMap<String, PropSchema>,
3155 base_path: &str,
3156) {
3157 for (property_name, property_schema) in &branch.properties {
3158 let Some(rule) = conditional_constraint_rule(resource_type, property_name) else {
3159 continue;
3160 };
3161 let constraint = schema_for_conditional_value_constraints(&property_schema.resolve(defs));
3162 if !constraint.constrains_value() {
3163 continue;
3164 }
3165 let property_path = format!("{}.{}", base_path, property_name);
3166 let mut reported_messages: HashSet<String> = HashSet::new();
3167 for (value, conditions) in m.resolve_scenarios_json_shared(rid, &property_path).iter() {
3168 if value.is_null()
3169 || !is_satisfiable(m, conditions)
3170 || !scenario_consistent_with_filter(m, conditions)
3171 || defer_value_constraints(m, rid, &property_path, value, conditions)
3172 || schema_value_matches(value, &constraint, defs, 0)
3173 {
3174 continue;
3175 }
3176 let reasons = schema_value_failure_reasons(value, &constraint, defs, 0, &property_path);
3177 let failure = render_composition_reasons(&reasons, &property_path);
3178 let message = match rule.context {
3179 Some(context) => format!("{failure} {context}"),
3180 None => failure,
3181 };
3182 if reported_messages.insert(message.clone()) {
3183 let world = merged_scenario_filter(conditions).unwrap_or_else(|| conditions.clone());
3186 out.push(build_diagnostic_conditional(
3187 rule.rule_id,
3188 &message,
3189 m,
3190 rid,
3191 &property_path,
3192 None,
3193 condition_map(&world),
3194 ));
3195 }
3196 }
3197 }
3198}
3199
3200fn collect_outer_resolved_scenarios(
3201 value: &ResolvedValue,
3202 assumptions: &HashMap<String, bool>,
3203 scenarios: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
3204) {
3205 match value {
3206 ResolvedValue::Conditional { condition, if_true, if_false } => match assumptions.get(condition) {
3207 Some(true) => collect_outer_resolved_scenarios(if_true, assumptions, scenarios),
3208 Some(false) => collect_outer_resolved_scenarios(if_false, assumptions, scenarios),
3209 None => {
3210 let mut true_assumptions = assumptions.clone();
3211 true_assumptions.insert(condition.clone(), true);
3212 collect_outer_resolved_scenarios(if_true, &true_assumptions, scenarios);
3213 let mut false_assumptions = assumptions.clone();
3214 false_assumptions.insert(condition.clone(), false);
3215 collect_outer_resolved_scenarios(if_false, &false_assumptions, scenarios);
3216 }
3217 },
3218 ResolvedValue::Enum { variants } => {
3219 for variant in variants {
3220 collect_outer_resolved_scenarios(variant, assumptions, scenarios);
3221 }
3222 }
3223 _ => scenarios.push((value.clone(), assumptions.clone())),
3224 }
3225}
3226
3227fn outer_resolved_scenarios(
3228 model: &Arc<SemanticModel>,
3229 resource_id: &str,
3230 property_path: &str,
3231) -> Vec<(ResolvedValue, HashMap<String, bool>)> {
3232 let Some(value) = model.resolve_unexpanded(resource_id, property_path) else {
3233 return Vec::new();
3234 };
3235 let mut scenarios = Vec::new();
3236 collect_outer_resolved_scenarios(&value, &HashMap::new(), &mut scenarios);
3237 scenarios
3238}
3239
3240fn collect_outer_value_scenarios(
3241 value: &ResolvedValue,
3242 assumptions: &HashMap<String, bool>,
3243 scenarios: &mut Vec<(serde_json::Value, HashMap<String, bool>)>,
3244) {
3245 match value {
3246 ResolvedValue::Conditional { condition, if_true, if_false } => match assumptions.get(condition) {
3247 Some(true) => collect_outer_value_scenarios(if_true, assumptions, scenarios),
3248 Some(false) => collect_outer_value_scenarios(if_false, assumptions, scenarios),
3249 None => {
3250 let mut true_assumptions = assumptions.clone();
3251 true_assumptions.insert(condition.clone(), true);
3252 collect_outer_value_scenarios(if_true, &true_assumptions, scenarios);
3253 let mut false_assumptions = assumptions.clone();
3254 false_assumptions.insert(condition.clone(), false);
3255 collect_outer_value_scenarios(if_false, &false_assumptions, scenarios);
3256 }
3257 },
3258 ResolvedValue::Enum { variants } => {
3259 for variant in variants {
3260 collect_outer_value_scenarios(variant, assumptions, scenarios);
3261 }
3262 }
3263 ResolvedValue::Reference { .. } | ResolvedValue::Dynamic { .. } | ResolvedValue::TypedDynamic { .. } => {}
3264 ResolvedValue::Concrete { .. } | ResolvedValue::List { .. } | ResolvedValue::Map { .. } => {
3265 scenarios.push((resolved_value_to_json(value), assumptions.clone()));
3266 }
3267 }
3268}
3269
3270fn schema_requires_nested_value_scenarios(schema: &PropSchema) -> bool {
3275 let has_composite_value = |value: &serde_json::Value| value.is_array() || value.is_object();
3276 schema.min_properties.is_some()
3277 || schema.max_properties.is_some()
3278 || schema.enum_values.iter().any(has_composite_value)
3279 || schema.enum_case_insensitive.iter().any(has_composite_value)
3280 || schema.not_enum.iter().any(has_composite_value)
3281 || schema.const_value.as_ref().is_some_and(has_composite_value)
3282}
3283
3284fn validation_scenarios(
3285 model: &Arc<SemanticModel>,
3286 resource_id: &str,
3287 property_path: &str,
3288 schema: &PropSchema,
3289) -> Vec<(serde_json::Value, HashMap<String, bool>)> {
3290 if schema_requires_nested_value_scenarios(schema) {
3291 return model.resolve_scenarios_json(resource_id, property_path);
3292 }
3293 let Some(value) =
3294 model.resolve_deep(resource_id, property_path).or_else(|| model.resolve(resource_id, property_path).cloned())
3295 else {
3296 return model.resolve_scenarios_json(resource_id, property_path);
3297 };
3298 let mut scenarios = Vec::new();
3299 collect_outer_value_scenarios(&value, &HashMap::new(), &mut scenarios);
3300 scenarios
3301}
3302
3303fn validate_prop(
3304 out: &mut Vec<Diagnostic>,
3305 store: &CompiledSchemaStore,
3306 m: &Arc<SemanticModel>,
3307 rid: &str,
3308 rtype: &str,
3309 prop_path: &str,
3310 schema: &PropSchema,
3311 defs: &HashMap<String, PropSchema>,
3312 region: Option<&str>,
3313) {
3314 let scenarios = validation_scenarios(m, rid, prop_path, schema);
3315
3316 let is_type_exempt = TYPE_CHECK_EXEMPT_PATHS.iter().any(|(rt, pp)| *rt == rtype && *pp == prop_path);
3317
3318 if scenarios.is_empty() && !is_type_exempt {
3319 validate_reference_type(out, store, m, rid, prop_path, schema);
3320 }
3321
3322 let res_suffix = describe_resolution(m, rid, prop_path).map(|s| format!(" (from {})", s)).unwrap_or_default();
3323
3324 if let Some(ref pt) = schema.prop_type
3328 && !is_type_exempt
3329 {
3330 let is_packaging_path = PACKAGING_PROPERTY_PATHS.iter().any(|(rt, pp)| *rt == rtype && *pp == prop_path);
3331 let from_intrinsic = m.is_from_intrinsic(rid, prop_path)
3334 || prop_path
3335 .rsplit_once('.')
3336 .and_then(|(parent, seg)| seg.parse::<usize>().ok().map(|_| parent))
3337 .is_some_and(|parent| m.is_from_intrinsic(rid, parent));
3338 let mut emitted_type_errors: HashSet<(&str, &str)> = HashSet::new();
3345 for (val, conds) in &scenarios {
3346 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
3347 continue;
3348 }
3349 if is_unresolved_intrinsic(val) {
3351 continue;
3352 }
3353 if is_packaging_path && val.is_string() {
3355 continue;
3356 }
3357 if from_intrinsic {
3359 continue;
3360 }
3361 if !type_matches(val, pt) {
3362 let expected = pt.primary().unwrap_or("unknown");
3363 match coerce_value(val, expected) {
3364 CoerceResult::Coerced(_, ref description) => {
3365 if emitted_type_errors.insert(("W9003", expected)) {
3366 out.push(build_diagnostic_conditional(
3367 "W9003",
3368 &format!(
3369 "{}{} is not of type '{}' - automatically coerced ({})",
3370 format_value(val),
3371 res_suffix,
3372 expected,
3373 description
3374 ),
3375 m,
3376 rid,
3377 prop_path,
3378 None,
3379 condition_map(conds),
3380 ));
3381 }
3382 }
3383 _ => {
3384 if emitted_type_errors.insert(("F3012", expected)) {
3385 out.push(build_diagnostic_conditional(
3386 "F3012",
3387 &format!("{}{} is not of type '{}'", format_value(val), res_suffix, expected),
3388 m,
3389 rid,
3390 prop_path,
3391 None,
3392 condition_map(conds),
3393 ));
3394 }
3395 }
3396 }
3397 }
3398 }
3399 }
3400
3401 if !schema.enum_values.is_empty() {
3402 let prop_name = prop_path.strip_prefix("Properties.").unwrap_or(prop_path);
3403 let regional = store.region_enums().allowed_values(rtype, prop_name, region);
3407 for (val, conds) in &scenarios {
3408 if !is_satisfiable(m, conds)
3409 || val.is_null()
3410 || value_depends_on_overridable_template_parameter_in_scenario(m, rid, prop_path, conds)
3411 {
3412 continue;
3413 }
3414 let matches = if let Some(regional_vals) = ®ional {
3415 val.as_str().map(|s| regional_vals.contains(&s)).unwrap_or(false)
3416 || enum_matches(val, &schema.enum_values)
3417 } else {
3418 enum_matches(val, &schema.enum_values)
3419 };
3420 if !matches {
3421 let enum_desc = if regional.is_some() {
3427 format!("allowed values for region '{}'", region_enums::region_label(region))
3428 } else {
3429 format_allowed_values(&schema.enum_values)
3430 };
3431 out.push(build_diagnostic_conditional(
3432 "W3030",
3433 &format!("{}{} is not one of {}", format_value(val), res_suffix, enum_desc),
3434 m,
3435 rid,
3436 prop_path,
3437 None,
3438 condition_map(conds),
3439 ));
3440 }
3441 }
3442 }
3443
3444 if !schema.enum_case_insensitive.is_empty() {
3445 for (val, conds) in &scenarios {
3446 if !is_satisfiable(m, conds)
3447 || val.is_null()
3448 || value_depends_on_overridable_template_parameter_in_scenario(m, rid, prop_path, conds)
3449 {
3450 continue;
3451 }
3452 if !enum_matches_case_insensitive(val, &schema.enum_case_insensitive) {
3453 out.push(build_diagnostic_conditional(
3457 "W3030",
3458 &format!(
3459 "{}{} is not one of {} (case-insensitive)",
3460 format_value(val),
3461 res_suffix,
3462 format_allowed_values(&schema.enum_case_insensitive)
3463 ),
3464 m,
3465 rid,
3466 prop_path,
3467 None,
3468 condition_map(conds),
3469 ));
3470 }
3471 }
3472 }
3473
3474 if !schema.not_enum.is_empty() {
3475 for (val, conds) in &scenarios {
3476 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
3477 continue;
3478 }
3479 if enum_matches(val, &schema.not_enum) {
3480 out.push(build_diagnostic_conditional(
3481 "F3030",
3482 &format!("{} must not be one of {}", format_value(val), format_allowed_values(&schema.not_enum)),
3483 m,
3484 rid,
3485 prop_path,
3486 None,
3487 condition_map(conds),
3488 ));
3489 }
3490 }
3491 }
3492
3493 if let Some(ref cv) = schema.const_value {
3494 for (val, conds) in &scenarios {
3495 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
3496 continue;
3497 }
3498 if !scalar_eq(val, cv) {
3499 out.push(build_diagnostic_conditional(
3500 "F3030",
3501 &format!("{} was expected", cv),
3502 m,
3503 rid,
3504 prop_path,
3505 None,
3506 condition_map(conds),
3507 ));
3508 }
3509 }
3510 }
3511
3512 let mut schema_pattern_failures = Vec::new();
3513 if let Some(ref pat) = schema.pattern
3514 && let Some(re) = compile_pattern(pat)
3515 {
3516 for (val, conds) in &scenarios {
3523 if !is_satisfiable(m, conds)
3524 || val.is_null()
3525 || m.is_from_intrinsic(rid, prop_path)
3526 || defer_value_constraints(m, rid, prop_path, val, conds)
3527 {
3528 continue;
3529 }
3530 if let Some(s) = coerce_to_string(val) {
3531 if s.contains("{{") && s.contains("resolve") {
3532 continue;
3533 }
3534 if !re.is_match(&s) {
3535 out.push(build_diagnostic_conditional(
3536 "F3031",
3537 &format!("{} does not match pattern '{}'", format_value(val), pat),
3538 m,
3539 rid,
3540 prop_path,
3541 None,
3542 condition_map(conds),
3543 ));
3544 schema_pattern_failures.push((val.clone(), conds.clone()));
3545 }
3546 }
3547 }
3548 }
3549
3550 if let Some(ref fmt) = schema.format {
3551 validate_format(out, m, rid, prop_path, fmt, &scenarios, &schema_pattern_failures);
3552 }
3553
3554 for (val, conds) in &scenarios {
3555 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
3556 continue;
3557 }
3558 let Some(n) = coerce_to_number(val) else {
3559 continue;
3560 };
3561 if let Some(max) = schema.maximum
3562 && n > max
3563 {
3564 out.push(build_diagnostic_conditional(
3565 "F3034",
3566 &format!("{} is greater than the maximum of {}", n, max),
3567 m,
3568 rid,
3569 prop_path,
3570 None,
3571 condition_map(conds),
3572 ));
3573 }
3574 if let Some(min) = schema.minimum
3575 && n < min
3576 {
3577 out.push(build_diagnostic_conditional(
3578 "F3034",
3579 &format!("{} is less than the minimum of {}", n, min),
3580 m,
3581 rid,
3582 prop_path,
3583 None,
3584 condition_map(conds),
3585 ));
3586 }
3587 if let Some(emax) = schema.exclusive_maximum
3588 && n >= emax
3589 {
3590 out.push(build_diagnostic_conditional(
3591 "F3034",
3592 &format!("{} is >= exclusive maximum {}", n, emax),
3593 m,
3594 rid,
3595 prop_path,
3596 None,
3597 condition_map(conds),
3598 ));
3599 }
3600 if let Some(emin) = schema.exclusive_minimum
3601 && n <= emin
3602 {
3603 out.push(build_diagnostic_conditional(
3604 "F3034",
3605 &format!("{} is <= exclusive minimum {}", n, emin),
3606 m,
3607 rid,
3608 prop_path,
3609 None,
3610 condition_map(conds),
3611 ));
3612 }
3613 if let Some(mult) = schema.multiple_of
3614 && mult > 0.0
3615 {
3616 let remainder = (n / mult).round() * mult - n;
3620 let epsilon = mult * 1e-9;
3621 if remainder.abs() > epsilon && (mult - remainder.abs()).abs() > epsilon {
3622 out.push(build_diagnostic_conditional(
3623 "F3034",
3624 &format!("{} is not a multiple of {}", n, mult),
3625 m,
3626 rid,
3627 prop_path,
3628 None,
3629 condition_map(conds),
3630 ));
3631 }
3632 }
3633 }
3634
3635 if schema.min_length.is_some() || schema.max_length.is_some() {
3636 for (val, conds) in &scenarios {
3637 if !is_satisfiable(m, conds)
3638 || val.is_null()
3639 || m.is_from_intrinsic(rid, prop_path)
3640 || defer_value_constraints(m, rid, prop_path, val, conds)
3641 {
3642 continue;
3643 }
3644 let Some(s) = coerce_to_string(val) else {
3645 continue;
3646 };
3647 let len = s.chars().count() as u64;
3648 if let Some(max) = schema.max_length
3649 && len > max
3650 {
3651 out.push(build_diagnostic_conditional(
3652 "F3033",
3653 &format!("length {} exceeds maximum {}", len, max),
3654 m,
3655 rid,
3656 prop_path,
3657 None,
3658 condition_map(conds),
3659 ));
3660 }
3661 if let Some(min) = schema.min_length
3662 && len < min
3663 {
3664 out.push(build_diagnostic_conditional(
3665 "F3033",
3666 &format!("length {} is below minimum {}", len, min),
3667 m,
3668 rid,
3669 prop_path,
3670 None,
3671 condition_map(conds),
3672 ));
3673 }
3674 }
3675 }
3676
3677 if schema.min_items.is_some() || schema.max_items.is_some() || schema.unique_items == Some(true) {
3678 validate_item_cardinality(out, m, rid, prop_path, schema);
3679 }
3680
3681 let has_nested_constraints = !schema.properties.is_empty()
3682 || !schema.required.is_empty()
3683 || !schema.dependent_required.is_empty()
3684 || !schema.dependent_excluded.is_empty()
3685 || !schema.required_or.is_empty()
3686 || !schema.required_xor.is_empty()
3687 || !schema.all_of.is_empty()
3688 || !schema.any_of.is_empty()
3689 || !schema.one_of.is_empty()
3690 || !schema.if_then_else.is_empty()
3691 || schema.additional_properties.is_some();
3692 if has_nested_constraints {
3693 validate_nested_required_properties(out, m, rid, prop_path, &schema.required);
3694 let nested_keys = collect_keys_deep(m, rid, prop_path);
3695 if !nested_keys.is_empty() {
3696 validate_object_keys_inner(
3697 out,
3698 m,
3699 rid,
3700 rtype,
3701 &schema.properties,
3702 defs,
3703 &[],
3704 schema.additional_properties,
3705 &schema.pattern_properties,
3706 &schema.dependent_required,
3707 &schema.dependent_excluded,
3708 &schema.required_or,
3709 &schema.required_xor,
3710 &schema.all_of,
3711 &schema.any_of,
3712 &schema.one_of,
3713 &nested_keys,
3714 prop_path,
3715 None,
3716 );
3717 for ite in &schema.if_then_else {
3718 let matches = condition_matches_at(&ite.condition, &nested_keys, m, rid, defs, prop_path);
3719 let sub = if matches { &ite.then_schema } else { &ite.else_schema };
3720 if let Some(sub) = sub {
3721 if ite.enforce_full_branch {
3722 validate_sub(out, m, rid, rtype, &nested_keys, sub, defs, prop_path, 0);
3723 } else {
3724 validate_sub_dependencies(out, m, rid, &nested_keys, sub, prop_path);
3725 }
3726 }
3727 }
3728 } else if !matches!(m.resolve_deep(rid, prop_path), Some(ResolvedValue::Conditional { .. })) {
3729 for (val, _conds) in m.resolve_scenarios_shared(rid, prop_path).iter() {
3733 if let ResolvedValue::Concrete { value } = val
3734 && let Some(obj) = value.as_object()
3735 {
3736 let keys: Vec<String> = obj.keys().cloned().collect();
3737 validate_object_keys_inner(
3738 out,
3739 m,
3740 rid,
3741 rtype,
3742 &schema.properties,
3743 defs,
3744 &[],
3745 schema.additional_properties,
3746 &schema.pattern_properties,
3747 &schema.dependent_required,
3748 &schema.dependent_excluded,
3749 &schema.required_or,
3750 &schema.required_xor,
3751 &schema.all_of,
3752 &schema.any_of,
3753 &schema.one_of,
3754 &keys,
3755 prop_path,
3756 None,
3757 );
3758 }
3759 }
3760 }
3761
3762 let has_scalar_composition = !schema.all_of.is_empty()
3770 || !schema.any_of.is_empty()
3771 || !schema.one_of.is_empty()
3772 || !schema.if_then_else.is_empty();
3773 if has_scalar_composition && nested_keys.is_empty() {
3774 let has_only_scalars = scenarios.iter().all(|(val, conds)| {
3775 !is_satisfiable(m, conds)
3776 || val.is_null()
3777 || is_unresolved_intrinsic(val)
3778 || (!val.is_object() && !val.is_array())
3779 });
3780 if has_only_scalars {
3781 validate_prop_composition(out, m, rid, prop_path, schema, defs, &scenarios);
3782 }
3783 }
3784 for (pn, ps) in &schema.properties {
3785 let resolved = ps.resolve(defs);
3786 let sub_path = format!("{}.{}", prop_path, pn);
3787 if m.resolve_deep(rid, &sub_path).is_some() || !m.resolve_scenarios_json(rid, &sub_path).is_empty() {
3791 validate_prop(out, store, m, rid, rtype, &sub_path, &resolved, defs, region);
3792 }
3793 }
3794 }
3795
3796 if let Some(ref item_schema) = schema.items {
3797 let resolved = item_schema.resolve(defs);
3798 let mut did_per_index = false;
3800 {
3801 let arr_len = match m.resolve_deep(rid, prop_path).or_else(|| m.resolve(rid, prop_path).cloned()) {
3802 Some(ResolvedValue::List { items }) => Some(items.len()),
3803 Some(ResolvedValue::Concrete { value: ref v }) if v.is_array() => Some(v.as_array().unwrap().len()),
3804 _ => None,
3805 };
3806 if let Some(len) = arr_len {
3807 did_per_index = true;
3808 for idx in 0..len {
3809 let idx_path = format!("{}.{}", prop_path, idx);
3810 validate_prop(out, store, m, rid, rtype, &idx_path, &resolved, defs, region);
3811 }
3812 } else {
3813 validate_prop(out, store, m, rid, rtype, &format!("{}.{{}}", prop_path), &resolved, defs, region);
3814 }
3815 }
3816 if !did_per_index && (!resolved.dependent_excluded.is_empty() || !resolved.dependent_required.is_empty()) {
3817 validate_array_item_constraints(out, m, rid, prop_path, &resolved);
3818 }
3819 }
3820}
3821
3822fn validate_item_cardinality(
3830 out: &mut Vec<Diagnostic>,
3831 m: &Arc<SemanticModel>,
3832 rid: &str,
3833 prop_path: &str,
3834 schema: &PropSchema,
3835) {
3836 for (container, outer_conditions) in outer_resolved_scenarios(m, rid, prop_path) {
3837 if !is_satisfiable(m, &outer_conditions) {
3838 continue;
3839 }
3840 match &container {
3841 ResolvedValue::List { items } => {
3842 let members = classify_members(items);
3843 validate_member_item_counts(out, m, rid, prop_path, schema, &members, &outer_conditions);
3844 if schema.unique_items == Some(true) {
3845 validate_member_uniqueness(out, m, rid, prop_path, &members, &outer_conditions);
3846 }
3847 }
3848 ResolvedValue::Concrete { value } if value.is_array() => {
3849 let value = value.0.clone();
3850 validate_item_cardinality_of_value(out, m, rid, prop_path, schema, &value, &outer_conditions);
3851 }
3852 _ => {}
3853 }
3854 }
3855}
3856
3857fn validate_member_item_counts(
3858 out: &mut Vec<Diagnostic>,
3859 m: &Arc<SemanticModel>,
3860 rid: &str,
3861 prop_path: &str,
3862 schema: &PropSchema,
3863 members: &[ContainerMember<'_>],
3864 outer_conditions: &HashMap<String, bool>,
3865) {
3866 let bounds: Vec<ItemCountBound> = ItemCountBound::of(schema).collect();
3867 if bounds.is_empty() {
3868 return;
3869 }
3870 let Some(slots) = ItemSlots::from_members(members) else {
3871 m.record_budget_exhaustion(BudgetKind::SchemaScenarioAssignments);
3872 let always_present = members.iter().filter(|member| member.presence == MemberPresence::Always).count() as u64;
3873 for bound in bounds {
3874 if matches!(bound, ItemCountBound::Maximum(maximum) if always_present > maximum)
3875 && assignment_is_proven_satisfiable(m, outer_conditions)
3876 {
3877 out.push(build_diagnostic_conditional(
3878 "F3032",
3879 &bound.violation_message(always_present),
3880 m,
3881 rid,
3882 prop_path,
3883 None,
3884 condition_map(outer_conditions),
3885 ));
3886 }
3887 }
3888 return;
3889 };
3890 for bound in bounds {
3891 match slots.find_violation(m, bound, outer_conditions) {
3892 WitnessSearch::Found((witness, count)) => out.push(build_diagnostic_conditional(
3893 "F3032",
3894 &bound.violation_message(count),
3895 m,
3896 rid,
3897 prop_path,
3898 None,
3899 condition_map(&witness),
3900 )),
3901 WitnessSearch::NotFound => {}
3902 WitnessSearch::BudgetExhausted => m.record_budget_exhaustion(BudgetKind::SchemaScenarioMergeAttempts),
3903 }
3904 }
3905}
3906
3907#[derive(Clone, Copy)]
3909enum ItemCountBound {
3910 Maximum(u64),
3911 Minimum(u64),
3912}
3913
3914impl ItemCountBound {
3915 fn of(schema: &PropSchema) -> impl Iterator<Item = Self> {
3916 [schema.max_items.map(Self::Maximum), schema.min_items.map(Self::Minimum)].into_iter().flatten()
3917 }
3918
3919 fn is_violated_by(self, count: u64) -> bool {
3920 self.is_reachable(count, 0)
3921 }
3922
3923 fn is_reachable(self, count: u64, undecided: u64) -> bool {
3926 match self {
3927 Self::Maximum(maximum) => count.saturating_add(undecided) > maximum,
3928 Self::Minimum(minimum) => count < minimum,
3929 }
3930 }
3931
3932 fn prefers_present_members(self) -> bool {
3934 matches!(self, Self::Maximum(_))
3935 }
3936
3937 fn violation_message(self, count: u64) -> String {
3938 match self {
3939 Self::Maximum(maximum) => format!("expected maximum item count: {maximum}, found: {count}"),
3940 Self::Minimum(minimum) => format!("expected minimum item count: {minimum}, found: {count}"),
3941 }
3942 }
3943}
3944
3945enum WitnessSearch<W> {
3947 Found(W),
3948 NotFound,
3949 BudgetExhausted,
3950}
3951
3952struct ItemSlots {
3955 always_present: u64,
3956 optional_members: Vec<MemberDecisions>,
3957}
3958
3959impl ItemSlots {
3960 fn from_members(members: &[ContainerMember<'_>]) -> Option<Self> {
3962 let mut slots = Self { always_present: 0, optional_members: Vec::new() };
3963 for member in members {
3964 match &member.presence {
3965 MemberPresence::Always => slots.always_present += 1,
3966 MemberPresence::Never => {}
3967 MemberPresence::When(literals) => {
3968 slots.optional_members.push(MemberDecisions::from_presence_literals(literals));
3969 }
3970 MemberPresence::Complex => slots.optional_members.push(MemberDecisions::from_branches(member.value)?),
3971 }
3972 }
3973 Some(slots)
3974 }
3975
3976 fn find_violation(
3982 &self,
3983 m: &Arc<SemanticModel>,
3984 bound: ItemCountBound,
3985 seed: &HashMap<String, bool>,
3986 ) -> WitnessSearch<(HashMap<String, bool>, u64)> {
3987 if !bound.is_reachable(self.always_present, self.optional_members.len() as u64)
3988 || !assignment_is_proven_satisfiable(m, seed)
3989 {
3990 return WitnessSearch::NotFound;
3991 }
3992 let mut frames = vec![DecisionFrame {
3993 assignment: seed.clone(),
3994 count: self.always_present,
3995 decided_members: 0,
3996 next_decision: 0,
3997 }];
3998 let mut merge_attempts = 0;
3999 while let Some(frame) = frames.last_mut() {
4000 let undecided = (self.optional_members.len() - frame.decided_members) as u64;
4001 if !bound.is_reachable(frame.count, undecided) {
4002 frames.pop();
4003 continue;
4004 }
4005 let Some(member) = self.optional_members.get(frame.decided_members) else {
4006 return WitnessSearch::Found((frame.assignment.clone(), frame.count));
4007 };
4008 let Some((literals, keeps_member)) = member.decision(bound, frame.next_decision) else {
4009 frames.pop();
4010 continue;
4011 };
4012 frame.next_decision += 1;
4013 if merge_attempts == MAX_GROUP_WITNESS_MERGE_ATTEMPTS {
4014 return WitnessSearch::BudgetExhausted;
4015 }
4016 merge_attempts += 1;
4017 let mut assignment = frame.assignment.clone();
4018 if !extend_assignment(&mut assignment, literals)
4019 || (assignment.len() > frame.assignment.len() && !assignment_is_proven_satisfiable(m, &assignment))
4020 {
4021 continue;
4022 }
4023 let child = DecisionFrame {
4024 assignment,
4025 count: frame.count + u64::from(keeps_member),
4026 decided_members: frame.decided_members + 1,
4027 next_decision: 0,
4028 };
4029 frames.push(child);
4030 }
4031 WitnessSearch::NotFound
4032 }
4033}
4034
4035struct DecisionFrame {
4037 assignment: HashMap<String, bool>,
4038 count: u64,
4039 decided_members: usize,
4040 next_decision: usize,
4041}
4042
4043struct MemberDecisions {
4047 present: Vec<Vec<(String, bool)>>,
4048 absent: Vec<Vec<(String, bool)>>,
4049}
4050
4051impl MemberDecisions {
4052 fn from_presence_literals(literals: &[(String, bool)]) -> Self {
4055 Self {
4056 present: vec![literals.to_vec()],
4057 absent: literals.iter().map(|(condition, holds)| vec![(condition.clone(), !holds)]).collect(),
4058 }
4059 }
4060
4061 fn from_branches(value: &ResolvedValue) -> Option<Self> {
4066 let mut decisions = Self { present: Vec::new(), absent: Vec::new() };
4067 decisions.collect_branches(value, &mut Vec::new()).then_some(decisions)
4068 }
4069
4070 fn collect_branches(&mut self, value: &ResolvedValue, path: &mut Vec<(String, bool)>) -> bool {
4071 match value {
4072 ResolvedValue::Conditional { condition, if_true, if_false } => {
4073 if let Some(holds) = path.iter().find(|(name, _)| name == condition).map(|(_, holds)| *holds) {
4074 return self.collect_branches(if holds { if_true } else { if_false }, path);
4075 }
4076 for (branch, holds) in [(if_true, true), (if_false, false)] {
4077 path.push((condition.clone(), holds));
4078 let within_limit = self.collect_branches(branch, path);
4079 path.pop();
4080 if !within_limit {
4081 return false;
4082 }
4083 }
4084 true
4085 }
4086 ResolvedValue::Enum { variants } => variants.iter().all(|variant| self.collect_branches(variant, path)),
4087 branch => {
4088 let branch_count = self.present.len() + self.absent.len();
4089 let decisions = if is_resolved_null(branch) { &mut self.absent } else { &mut self.present };
4090 if decisions.contains(path) {
4091 return true;
4092 }
4093 if branch_count == MAX_GROUP_PROPERTY_SCENARIOS {
4094 return false;
4095 }
4096 decisions.push(path.clone());
4097 true
4098 }
4099 }
4100 }
4101
4102 fn decision(&self, bound: ItemCountBound, index: usize) -> Option<(&[(String, bool)], bool)> {
4105 let keeps_first = bound.prefers_present_members();
4106 let (first, second) = if keeps_first { (&self.present, &self.absent) } else { (&self.absent, &self.present) };
4107 match first.get(index) {
4108 Some(literals) => Some((literals.as_slice(), keeps_first)),
4109 None => second.get(index - first.len()).map(|literals| (literals.as_slice(), !keeps_first)),
4110 }
4111 }
4112}
4113
4114fn validate_member_uniqueness(
4120 out: &mut Vec<Diagnostic>,
4121 m: &Arc<SemanticModel>,
4122 rid: &str,
4123 prop_path: &str,
4124 members: &[ContainerMember<'_>],
4125 outer_conditions: &HashMap<String, bool>,
4126) {
4127 let alternative_groups = equal_value_alternative_groups(members, outer_conditions);
4128 if alternative_groups.was_incomplete {
4129 m.record_budget_exhaustion(BudgetKind::SchemaScenarioAssignments);
4130 }
4131 if alternative_groups.used_covering {
4132 m.record_budget_exhaustion(BudgetKind::ScenarioProductCovering);
4133 }
4134 match duplicate_member_witness(m, &alternative_groups.groups) {
4135 WitnessSearch::Found(witness) => out.push(build_diagnostic_conditional(
4136 "F3037",
4137 "Array items are not unique",
4138 m,
4139 rid,
4140 prop_path,
4141 None,
4142 condition_map(&witness),
4143 )),
4144 WitnessSearch::NotFound => {}
4145 WitnessSearch::BudgetExhausted => m.record_budget_exhaustion(BudgetKind::SchemaScenarioMergeAttempts),
4146 }
4147}
4148
4149struct EqualValueAlternativeGroups {
4152 groups: Vec<Vec<MemberAlternative>>,
4153 was_incomplete: bool,
4154 used_covering: bool,
4155}
4156
4157struct MemberAlternative {
4159 member_index: usize,
4160 conditions: HashMap<String, bool>,
4161}
4162
4163fn equal_value_alternative_groups(
4169 members: &[ContainerMember<'_>],
4170 outer_conditions: &HashMap<String, bool>,
4171) -> EqualValueAlternativeGroups {
4172 let mut groups: HashMap<String, Vec<MemberAlternative>> = HashMap::new();
4173 let mut value_order = Vec::new();
4174 let mut was_incomplete = false;
4175 let mut used_covering = false;
4176 for (member_index, member) in members.iter().enumerate() {
4177 if member.presence == MemberPresence::Never {
4178 continue;
4179 }
4180 if scenario_expansion_may_use_covering(member.value) {
4181 was_incomplete = true;
4182 }
4183 let mut scenarios = Vec::new();
4184 if collect_scenarios_with_covering(
4185 member.value,
4186 outer_conditions,
4187 MAX_GROUP_PROPERTY_SCENARIOS,
4188 &mut scenarios,
4189 &mut used_covering,
4190 ) {
4191 was_incomplete = true;
4192 }
4193 let mut seen = HashSet::new();
4194 for (value, conditions) in scenarios {
4195 if is_resolved_null(&value) || contains_dynamic_resolved(&value) {
4196 continue;
4197 }
4198 let value_identity = canonical_json_key(&resolved_value_to_json(&value));
4199 if !seen.insert((value_identity.clone(), canonical_assignment(&conditions))) {
4200 continue;
4201 }
4202 let alternative = MemberAlternative { member_index, conditions };
4203 match groups.entry(value_identity) {
4204 Entry::Occupied(mut group) => group.get_mut().push(alternative),
4205 Entry::Vacant(group) => {
4206 value_order.push(group.key().clone());
4207 group.insert(vec![alternative]);
4208 }
4209 }
4210 }
4211 }
4212 EqualValueAlternativeGroups {
4213 groups: value_order.iter().filter_map(|value_identity| groups.remove(value_identity)).collect(),
4214 was_incomplete,
4215 used_covering,
4216 }
4217}
4218
4219fn scenario_expansion_may_use_covering(value: &ResolvedValue) -> bool {
4224 #[derive(Clone, Copy)]
4225 struct ScenarioShape {
4226 combinations: usize,
4227 nodes_per_combination: usize,
4228 nested_covering: bool,
4229 }
4230
4231 fn shape(value: &ResolvedValue) -> ScenarioShape {
4232 let combination_cap = MAX_COMPLETE_SCENARIO_PRODUCT.saturating_add(1);
4233 match value {
4234 ResolvedValue::Conditional { if_true, if_false, .. } => {
4235 let left = shape(if_true);
4236 let right = shape(if_false);
4237 ScenarioShape {
4238 combinations: left.combinations.saturating_add(right.combinations).min(combination_cap),
4239 nodes_per_combination: left.nodes_per_combination.max(right.nodes_per_combination),
4240 nested_covering: left.nested_covering || right.nested_covering,
4241 }
4242 }
4243 ResolvedValue::Enum { variants } => {
4244 let mut combined = ScenarioShape { combinations: 0, nodes_per_combination: 1, nested_covering: false };
4245 for variant in variants {
4246 let variant_shape = shape(variant);
4247 combined.combinations =
4248 combined.combinations.saturating_add(variant_shape.combinations).min(combination_cap);
4249 combined.nodes_per_combination =
4250 combined.nodes_per_combination.max(variant_shape.nodes_per_combination);
4251 combined.nested_covering |= variant_shape.nested_covering;
4252 }
4253 combined.combinations = combined.combinations.max(1);
4254 combined
4255 }
4256 ResolvedValue::List { items } => container_shape(items.iter().map(shape)),
4257 ResolvedValue::Map { entries } => container_shape(entries.iter().map(|entry| shape(&entry.value))),
4258 _ => ScenarioShape { combinations: 1, nodes_per_combination: 1, nested_covering: false },
4259 }
4260 }
4261
4262 fn container_shape(children: impl Iterator<Item = ScenarioShape>) -> ScenarioShape {
4263 let combination_cap = MAX_COMPLETE_SCENARIO_PRODUCT.saturating_add(1);
4264 let node_cap = MAX_SCENARIO_PRODUCT_NODES.saturating_add(1);
4265 let mut container = ScenarioShape { combinations: 1, nodes_per_combination: 1, nested_covering: false };
4266 for child in children {
4267 container.combinations =
4268 container.combinations.saturating_mul(child.combinations.max(1)).min(combination_cap);
4269 container.nodes_per_combination =
4270 container.nodes_per_combination.saturating_add(child.nodes_per_combination).min(node_cap);
4271 container.nested_covering |= child.nested_covering;
4272 }
4273 let materialized_nodes = container.combinations.saturating_mul(container.nodes_per_combination);
4274 container.nested_covering |=
4275 container.combinations > MAX_COMPLETE_SCENARIO_PRODUCT || materialized_nodes > MAX_SCENARIO_PRODUCT_NODES;
4276 container
4277 }
4278
4279 shape(value).nested_covering
4280}
4281
4282fn duplicate_member_witness(
4286 m: &Arc<SemanticModel>,
4287 groups: &[Vec<MemberAlternative>],
4288) -> WitnessSearch<HashMap<String, bool>> {
4289 let mut merge_attempts = 0;
4290 for group in groups {
4291 for (position, left) in group.iter().enumerate() {
4292 let later_members =
4293 group.iter().skip(position + 1).skip_while(|right| right.member_index == left.member_index);
4294 for right in later_members {
4295 if merge_attempts == MAX_GROUP_WITNESS_MERGE_ATTEMPTS {
4296 return WitnessSearch::BudgetExhausted;
4297 }
4298 merge_attempts += 1;
4299 if let Some(witness) = try_merge_assignments(&left.conditions, &right.conditions)
4300 && assignment_is_proven_satisfiable(m, &witness)
4301 {
4302 return WitnessSearch::Found(witness);
4303 }
4304 }
4305 }
4306 }
4307 WitnessSearch::NotFound
4308}
4309
4310fn canonical_json_key(value: &serde_json::Value) -> String {
4313 key_ordered_json(value).to_string()
4314}
4315
4316fn key_ordered_json(value: &serde_json::Value) -> serde_json::Value {
4317 match value {
4318 serde_json::Value::Array(items) => serde_json::Value::Array(items.iter().map(key_ordered_json).collect()),
4319 serde_json::Value::Object(entries) => {
4320 let mut ordered: Vec<(&String, &serde_json::Value)> = entries.iter().collect();
4321 ordered.sort_unstable_by_key(|(name, _)| *name);
4322 serde_json::Value::Object(
4323 ordered.into_iter().map(|(name, entry)| (name.clone(), key_ordered_json(entry))).collect(),
4324 )
4325 }
4326 serde_json::Value::Number(number) if number.is_f64() => match number.as_f64() {
4330 Some(value) if value.fract() == 0.0 && value >= 0.0 && value < u64::MAX as f64 => {
4331 serde_json::Value::from(value as u64)
4332 }
4333 Some(value) if value.fract() == 0.0 && value >= i64::MIN as f64 && value < 0.0 => {
4334 serde_json::Value::from(value as i64)
4335 }
4336 _ => value.clone(),
4337 },
4338 scalar => scalar.clone(),
4339 }
4340}
4341
4342fn validate_item_cardinality_of_value(
4343 out: &mut Vec<Diagnostic>,
4344 m: &Arc<SemanticModel>,
4345 rid: &str,
4346 prop_path: &str,
4347 schema: &PropSchema,
4348 value: &serde_json::Value,
4349 conditions: &HashMap<String, bool>,
4350) {
4351 let Some(items) = value.as_array() else {
4352 return;
4353 };
4354 let surviving: Vec<&serde_json::Value> = items.iter().filter(|item| !item.is_null()).collect();
4357 let count = surviving.len() as u64;
4358 for bound in ItemCountBound::of(schema) {
4359 if bound.is_violated_by(count) {
4360 out.push(build_diagnostic_conditional(
4361 "F3032",
4362 &bound.violation_message(count),
4363 m,
4364 rid,
4365 prop_path,
4366 None,
4367 condition_map(conditions),
4368 ));
4369 }
4370 }
4371 if schema.unique_items == Some(true) {
4372 let mut seen = HashSet::new();
4373 for item in surviving {
4374 if !seen.insert(canonical_json_key(item)) {
4375 out.push(build_diagnostic_conditional(
4376 "F3037",
4377 "Array items are not unique",
4378 m,
4379 rid,
4380 prop_path,
4381 None,
4382 condition_map(conditions),
4383 ));
4384 break;
4385 }
4386 }
4387 }
4388}
4389
4390fn validate_array_item_constraints(
4391 out: &mut Vec<Diagnostic>,
4392 m: &Arc<SemanticModel>,
4393 rid: &str,
4394 array_path: &str,
4395 item_schema: &PropSchema,
4396) {
4397 let arr = match m.resolve_deep(rid, array_path).or_else(|| m.resolve(rid, array_path).cloned()) {
4398 Some(ResolvedValue::List { items }) => items,
4399 Some(ResolvedValue::Concrete { value: v }) => match v.into_inner() {
4400 serde_json::Value::Array(items) => {
4401 items.into_iter().map(|i| ResolvedValue::Concrete { value: i.into() }).collect()
4402 }
4403 _ => return,
4404 },
4405 _ => return,
4406 };
4407 for (idx, item) in arr.iter().enumerate() {
4408 let keys: Vec<String> = match item {
4409 ResolvedValue::Map { entries } => entries.iter().map(|e| e.key.clone()).collect(),
4410 ResolvedValue::Concrete { value: v } if v.is_object() => v.as_object().unwrap().keys().cloned().collect(),
4411 _ => continue,
4412 };
4413 let item_path = format!("{}.{}", array_path, idx);
4414 for (trigger, excluded) in &item_schema.dependent_excluded {
4415 if keys.iter().any(|k| k == trigger) {
4416 for dep in excluded {
4417 if keys.iter().any(|k| k == dep) {
4418 out.push(build_diagnostic(
4419 "F3020",
4420 &format!("'{}' should not be included with '{}'", dep, trigger),
4421 m,
4422 rid,
4423 &format!("{}.{}", item_path, dep),
4424 None,
4425 ));
4426 }
4427 }
4428 }
4429 }
4430 for (trigger, deps) in &item_schema.dependent_required {
4431 if keys.iter().any(|k| k == trigger) {
4432 for dep in deps {
4433 if !keys.iter().any(|k| k == dep) {
4434 out.push(build_diagnostic(
4435 "F3021",
4436 &format!("'{}' is a dependency of '{}'", dep, trigger),
4437 m,
4438 rid,
4439 &item_path,
4440 Some(&format!("Add '{}' when '{}' is specified", dep, trigger)),
4441 ));
4442 }
4443 }
4444 }
4445 }
4446 }
4447}
4448
4449fn type_matches(val: &serde_json::Value, pt: &PropType) -> bool {
4450 match pt {
4451 PropType::Single(s) => single_type(val, s),
4452 PropType::Multi(types) => types.iter().any(|t| single_type(val, t)),
4453 }
4454}
4455
4456fn single_type(val: &serde_json::Value, expected: &str) -> bool {
4457 match expected {
4458 "string" => val.is_string(),
4459 "integer" => {
4460 val.is_i64() || val.is_u64() || (val.is_f64() && val.as_f64().map(|f| f.fract() == 0.0).unwrap_or(false))
4461 }
4462 "number" | "double" | "float" => val.is_number(),
4463 "boolean" => val.is_boolean(),
4464 "array" => val.is_array(),
4465 "object" => val.is_object(),
4466 "null" => val.is_null(),
4467 _ => true,
4468 }
4469}
4470
4471fn enum_matches(val: &serde_json::Value, allowed: &[serde_json::Value]) -> bool {
4472 allowed.iter().any(|a| scalar_eq(a, val))
4473}
4474
4475fn enum_matches_case_insensitive(val: &serde_json::Value, allowed: &[serde_json::Value]) -> bool {
4478 allowed.iter().any(|a| match (a.as_str(), val.as_str()) {
4479 (Some(allowed_str), Some(val_str)) => allowed_str.eq_ignore_ascii_case(val_str),
4480 _ => scalar_eq(a, val),
4481 })
4482}
4483
4484fn check_required_not_null(out: &mut Vec<Diagnostic>, m: &Arc<SemanticModel>, rid: &str, base: &str, req: &str) {
4485 for (value, conds) in &outer_resolved_scenarios(m, rid, &format!("{}.{}", base, req)) {
4486 if !is_satisfiable(m, conds) || !scenario_consistent_with_filter(m, conds) {
4487 continue;
4488 }
4489 if matches!(value, ResolvedValue::Concrete { value } if value.is_null()) {
4490 out.push(build_diagnostic_conditional(
4491 "F3003",
4492 &format!("'{}' is a required property", req),
4493 m,
4494 rid,
4495 base,
4496 Some(&format!("Add the required property '{}'", req)),
4497 condition_map(conds),
4498 ));
4499 }
4500 }
4501}
4502
4503fn validate_nested_required_properties(
4504 out: &mut Vec<Diagnostic>,
4505 m: &Arc<SemanticModel>,
4506 rid: &str,
4507 base_path: &str,
4508 required: &[String],
4509) {
4510 for required_property in required {
4511 for (parent, parent_conditions) in outer_resolved_scenarios(m, rid, base_path) {
4512 if !is_satisfiable(m, &parent_conditions)
4513 || !scenario_consistent_with_filter(m, &parent_conditions)
4514 || is_resolved_null(&parent)
4515 {
4516 continue;
4517 }
4518 let child = match &parent {
4519 ResolvedValue::Map { entries } => {
4520 entries.iter().find(|entry| entry.key == *required_property).map(|entry| entry.value.clone())
4521 }
4522 ResolvedValue::Concrete { value } => {
4523 let Some(properties) = value.as_object() else {
4524 continue;
4525 };
4526 properties
4527 .get(required_property)
4528 .cloned()
4529 .map(|value| ResolvedValue::Concrete { value: value.into() })
4530 }
4531 _ => continue,
4532 };
4533 let Some(child) = child else {
4534 out.push(build_diagnostic_conditional(
4535 "F3003",
4536 &format!("'{}' is a required property", required_property),
4537 m,
4538 rid,
4539 base_path,
4540 Some(&format!("Add the required property '{}'", required_property)),
4541 condition_map(&parent_conditions),
4542 ));
4543 continue;
4544 };
4545 let mut child_scenarios = Vec::new();
4546 collect_outer_resolved_scenarios(&child, &parent_conditions, &mut child_scenarios);
4547 for (value, conditions) in child_scenarios {
4548 if is_satisfiable(m, &conditions)
4549 && scenario_consistent_with_filter(m, &conditions)
4550 && is_resolved_null(&value)
4551 {
4552 out.push(build_diagnostic_conditional(
4553 "F3003",
4554 &format!("'{}' is a required property", required_property),
4555 m,
4556 rid,
4557 base_path,
4558 Some(&format!("Add the required property '{}'", required_property)),
4559 condition_map(&conditions),
4560 ));
4561 }
4562 }
4563 }
4564 }
4565}
4566
4567fn collect_keys_deep(m: &Arc<SemanticModel>, rid: &str, path: &str) -> Vec<String> {
4568 let mut keys = HashSet::new();
4569 match m.resolve_deep(rid, path).or_else(|| m.resolve(rid, path).cloned()) {
4570 Some(ResolvedValue::Map { entries }) => {
4571 if entries.len() == 1 && is_intrinsic_key(&entries[0].key) {
4572 return Vec::new();
4573 }
4574 for entry in &entries {
4575 keys.insert(entry.key.clone());
4576 }
4577 }
4578 Some(ResolvedValue::Concrete { value }) if is_unresolved_intrinsic(&value) => return Vec::new(),
4579 Some(ResolvedValue::Concrete { value }) if value.is_object() => {
4580 if let Some(properties) = value.as_object() {
4581 for property in properties.keys() {
4582 keys.insert(property.clone());
4583 }
4584 }
4585 }
4586 _ => {}
4587 }
4588 if keys.is_empty() {
4589 for (value, conditions) in &m.resolve_scenarios_json(rid, path) {
4590 if !is_satisfiable(m, conditions) || value.is_null() || is_unresolved_intrinsic(value) {
4591 continue;
4592 }
4593 if let Some(properties) = value.as_object() {
4594 for property in properties.keys() {
4595 keys.insert(property.clone());
4596 }
4597 }
4598 }
4599 }
4600 keys.into_iter().collect()
4601}
4602
4603fn is_satisfiable(m: &Arc<SemanticModel>, conds: &HashMap<String, bool>) -> bool {
4604 if conds.is_empty() {
4605 return true;
4606 }
4607 assignment_uses_only_defined_conditions(m, conds)
4608 && m.conditions.is_satisfiable(&conds.iter().map(|(k, v)| (k.clone(), *v)).collect::<Vec<_>>())
4609}
4610
4611fn condition_map(conds: &HashMap<String, bool>) -> Option<HashMap<String, bool>> {
4612 if conds.is_empty() { None } else { Some(conds.clone()) }
4613}
4614
4615fn find_similar<'a>(key: &str, known: &HashSet<&'a str>) -> Option<&'a str> {
4616 let kl = key.to_lowercase();
4617 known
4618 .iter()
4619 .find(|k| {
4620 let l = k.to_lowercase();
4621 let max = kl.len().max(l.len());
4622 if max == 0 {
4623 return true;
4624 }
4625 let d = levenshtein_distance(&kl, &l);
4626 1.0 - (d as f64 / max as f64) > 0.8
4627 })
4628 .copied()
4629}
4630
4631fn levenshtein_distance(a: &str, b: &str) -> usize {
4632 let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
4633 let mut dp = vec![vec![0usize; b.len() + 1]; a.len() + 1];
4634 for (i, row) in dp.iter_mut().enumerate() {
4635 row[0] = i;
4636 }
4637 for (j, cell) in dp[0].iter_mut().enumerate() {
4638 *cell = j;
4639 }
4640 for i in 1..=a.len() {
4641 for j in 1..=b.len() {
4642 let c = if a[i - 1] == b[j - 1] { 0 } else { 1 };
4643 dp[i][j] = (dp[i - 1][j] + 1).min(dp[i][j - 1] + 1).min(dp[i - 1][j - 1] + c);
4644 }
4645 }
4646 dp[a.len()][b.len()]
4647}
4648
4649fn format_value(val: &serde_json::Value) -> String {
4650 render_value(val)
4651}
4652
4653fn format_allowed_values(values: &[serde_json::Value]) -> String {
4654 render_value_list(values)
4655}
4656
4657fn condition_matches(
4658 cond: &ConditionSchema,
4659 actual_keys: &[String],
4660 m: &Arc<SemanticModel>,
4661 rid: &str,
4662 defs: &HashMap<String, PropSchema>,
4663) -> bool {
4664 condition_matches_at(cond, actual_keys, m, rid, defs, KEY_PROPERTIES)
4665}
4666
4667fn condition_matches_at(
4675 cond: &ConditionSchema,
4676 actual_keys: &[String],
4677 m: &Arc<SemanticModel>,
4678 rid: &str,
4679 defs: &HashMap<String, PropSchema>,
4680 base_path: &str,
4681) -> bool {
4682 if !cond.all_of.is_empty()
4683 && !cond.all_of.iter().all(|sub| condition_matches_at(sub, actual_keys, m, rid, defs, base_path))
4684 {
4685 return false;
4686 }
4687 if !cond.any_of.is_empty()
4688 && !cond.any_of.iter().any(|sub| condition_matches_at(sub, actual_keys, m, rid, defs, base_path))
4689 {
4690 return false;
4691 }
4692 if !cond.one_of.is_empty()
4693 && cond.one_of.iter().filter(|sub| condition_matches_at(sub, actual_keys, m, rid, defs, base_path)).count() != 1
4694 {
4695 return false;
4696 }
4697 if let Some(negated) = &cond.not
4698 && condition_matches_at(negated, actual_keys, m, rid, defs, base_path)
4699 {
4700 return false;
4701 }
4702 if let Some(ref required_type) = cond.prop_type
4703 && !required_type.names().any(|name| name == "object")
4704 {
4705 return false;
4706 }
4707 for required_property in &cond.required {
4708 if !actual_keys.iter().any(|key| key == required_property)
4709 || !property_present_under(m, rid, base_path, required_property, &HashMap::new())
4710 {
4711 return false;
4712 }
4713 }
4714 for absent_property in &cond.absent {
4718 if actual_keys.iter().any(|key| key == absent_property)
4719 && property_present_under(m, rid, base_path, absent_property, &HashMap::new())
4720 {
4721 return false;
4722 }
4723 }
4724 for (property_name, property_schema) in &cond.properties {
4725 let resolved_schema = property_schema.resolve(defs);
4726 let property_path = format!("{}.{}", base_path, property_name);
4727 let scenarios = m.resolve_scenarios_json(rid, &property_path);
4728 let has_concrete_constraint = resolved_schema.pattern.is_some()
4729 || !resolved_schema.enum_values.is_empty()
4730 || !resolved_schema.not_enum.is_empty()
4731 || resolved_schema.const_value.is_some();
4732 if scenarios.is_empty() {
4733 if has_concrete_constraint {
4734 return false;
4735 }
4736 continue;
4737 }
4738 let reachable_scenarios: Vec<_> = scenarios
4739 .iter()
4740 .filter(|(_, conditions)| is_satisfiable(m, conditions) && scenario_consistent_with_filter(m, conditions))
4741 .collect();
4742 if reachable_scenarios.is_empty() || reachable_scenarios.iter().all(|(value, _)| value.is_null()) {
4743 continue;
4744 }
4745 for nested_required_property in &resolved_schema.required {
4746 let nested_path = format!("{}.{}", property_path, nested_required_property);
4747 let nested_property_exists =
4748 m.resolve_scenarios_json(rid, &nested_path).iter().any(|(value, conditions)| {
4749 is_satisfiable(m, conditions) && scenario_consistent_with_filter(m, conditions) && !value.is_null()
4750 });
4751 if !nested_property_exists {
4752 return false;
4753 }
4754 }
4755 let compiled_pattern = resolved_schema.pattern.as_ref().and_then(|pattern| compile_pattern(pattern));
4756 if resolved_schema.pattern.is_some() && compiled_pattern.is_none() {
4757 return false;
4758 }
4759 let any_match = reachable_scenarios.iter().any(|(value, _)| {
4760 if value.is_null() {
4761 return true;
4762 }
4763 if !resolved_schema.enum_values.is_empty() && !enum_matches(value, &resolved_schema.enum_values) {
4764 return false;
4765 }
4766 if !resolved_schema.not_enum.is_empty() && enum_matches(value, &resolved_schema.not_enum) {
4767 return false;
4768 }
4769 if let Some(ref expected) = resolved_schema.const_value
4770 && !scalar_eq(value, expected)
4771 {
4772 return false;
4773 }
4774 if let Some(ref pattern) = compiled_pattern
4775 && !coerce_to_string(value).map(|text| pattern.is_match(&text)).unwrap_or(false)
4776 {
4777 return false;
4778 }
4779 if let Some(ref expected_type) = resolved_schema.prop_type
4780 && !type_matches(value, expected_type)
4781 {
4782 return false;
4783 }
4784 condition_bounds_match(value, &resolved_schema)
4785 });
4786 if !any_match {
4787 return false;
4788 }
4789 }
4790 true
4791}
4792
4793fn condition_bounds_match(val: &serde_json::Value, schema: &PropSchema) -> bool {
4799 if let Some(number) = coerce_to_number(val) {
4800 if schema.minimum.is_some_and(|minimum| number < minimum)
4801 || schema.maximum.is_some_and(|maximum| number > maximum)
4802 || schema.exclusive_minimum.is_some_and(|minimum| number <= minimum)
4803 || schema.exclusive_maximum.is_some_and(|maximum| number >= maximum)
4804 {
4805 return false;
4806 }
4807 if let Some(multiple) = schema.multiple_of
4808 && multiple > 0.0
4809 {
4810 let remainder = (number / multiple).round() * multiple - number;
4811 let epsilon = multiple * 1e-9;
4812 if remainder.abs() > epsilon && (multiple - remainder.abs()).abs() > epsilon {
4813 return false;
4814 }
4815 }
4816 }
4817 if let Some(items) = val.as_array() {
4818 let len = items.iter().filter(|item| !item.is_null()).count() as u64;
4819 if schema.min_items.is_some_and(|min| len < min) || schema.max_items.is_some_and(|max| len > max) {
4820 return false;
4821 }
4822 }
4823 if let Some(text) = val.as_str() {
4824 let len = text.chars().count() as u64;
4825 if schema.min_length.is_some_and(|min| len < min) || schema.max_length.is_some_and(|max| len > max) {
4826 return false;
4827 }
4828 }
4829 if let Some(members) = val.as_object() {
4830 let len = members.len() as u64;
4831 if schema.min_properties.is_some_and(|min| len < min) || schema.max_properties.is_some_and(|max| len > max) {
4832 return false;
4833 }
4834 }
4835 true
4836}
4837
4838fn validate_reference_type(
4841 out: &mut Vec<Diagnostic>,
4842 store: &CompiledSchemaStore,
4843 m: &Arc<SemanticModel>,
4844 rid: &str,
4845 prop_path: &str,
4846 schema: &PropSchema,
4847) {
4848 let Some(ref expected_type) = schema.prop_type else {
4849 return;
4850 };
4851 let raw = m.resolve(rid, prop_path).cloned().or_else(|| m.resolve_deep(rid, prop_path));
4852 let Some(raw) = raw else { return };
4853
4854 match &raw {
4855 ResolvedValue::Reference { target, kind } => {
4856 let target_type = m.resources.get(target.as_str()).map(|r| r.resource_type.as_str());
4857 let Some(target_rtype) = target_type else {
4858 return;
4859 };
4860
4861 let source_type = match kind {
4862 RefKind::Ref => store.ref_types().ref_type_for(target_rtype),
4863 RefKind::GetAtt { attr } => store.ref_types().getatt_type_for(target_rtype, attr),
4864 _ => None,
4865 };
4866 let Some(source) = source_type else { return };
4867
4868 if !types_compatible(source, expected_type) {
4869 let ref_desc = match kind {
4870 RefKind::Ref => format!("Ref to '{}'", target),
4871 RefKind::GetAtt { attr: a } => {
4872 format!("GetAtt {}.{}", target, a)
4873 }
4874 _ => return,
4875 };
4876 let mut diagnostic = build_diagnostic(
4877 "F3012",
4878 &format!(
4879 "{} ({}) returns '{}', but property expects '{}'",
4880 ref_desc,
4881 target_rtype,
4882 source,
4883 expected_type.primary().unwrap_or("unknown")
4884 ),
4885 m,
4886 rid,
4887 prop_path,
4888 None,
4889 );
4890 if let Some(intrinsic_name) = m.authored_intrinsic_name_at(rid, prop_path) {
4891 diagnostic.location = Some(m.resource_span(rid, &format!("{prop_path}.{intrinsic_name}")));
4892 }
4893 out.push(diagnostic);
4894 }
4895 }
4896 ResolvedValue::TypedDynamic { reason: _name, param_type } => {
4897 let source = cfn_param_type_to_schema_type(param_type);
4898 if !types_compatible(source, expected_type) {
4899 let expected = expected_type.primary().unwrap_or("unknown");
4900 out.push(build_diagnostic(
4902 "W9003",
4903 &format!("Parameter type '{}' may not be compatible with expected type '{}'", param_type, expected),
4904 m,
4905 rid,
4906 prop_path,
4907 None,
4908 ));
4909 }
4910 }
4911 _ => {}
4912 }
4913}
4914
4915fn cfn_param_type_to_schema_type(param_type: &str) -> &str {
4916 match param_type {
4917 PARAM_TYPE_NUMBER => "number",
4918 PARAM_TYPE_STRING => "string",
4919 PARAM_TYPE_COMMA_DELIMITED_LIST => "array",
4920 t if t.starts_with("List<") => "array",
4921 t if t.starts_with("AWS::SSM::Parameter::") => "string",
4922 _ => "string",
4923 }
4924}
4925
4926fn types_compatible(source: &str, expected: &PropType) -> bool {
4927 match expected {
4928 PropType::Single(e) => single_type_compatible(source, e),
4929 PropType::Multi(types) => types.iter().any(|e| single_type_compatible(source, e)),
4930 }
4931}
4932
4933fn single_type_compatible(source: &str, expected: &str) -> bool {
4934 match (source, expected) {
4935 (s, e) if s == e => true,
4936 ("integer", "number") | ("number", "integer") => true,
4937 ("string", "number") | ("string", "integer") => true, ("number", "string") | ("integer", "string") | ("boolean", "string") => true, (_, "null") => true,
4940 _ => false,
4941 }
4942}
4943
4944const EC2_IMAGE_ID_FORMAT: &str = "AWS::EC2::Image.Id";
4945const EC2_INSTANCE_RESOURCE_TYPE: &str = "AWS::EC2::Instance";
4946const EC2_INSTANCE_IMAGE_ID_PATH: &str = "Properties.ImageId";
4947const EC2_LAUNCH_TEMPLATE_RESOURCE_TYPE: &str = "AWS::EC2::LaunchTemplate";
4948const EC2_LAUNCH_TEMPLATE_IMAGE_ID_PATH: &str = "Properties.LaunchTemplateData.ImageId";
4949const EC2_SSM_IMAGE_ALIAS_PREFIX: &str = "resolve:ssm:";
4950
4951pub(crate) fn prewarm_statics() {
4956 LazyLock::force(&FORMAT_PATTERNS);
4957 LazyLock::force(&BRANCH_FORMAT_PATTERNS);
4958}
4959
4960static FORMAT_PATTERNS: LazyLock<HashMap<&'static str, Arc<CompiledPattern>>> = LazyLock::new(|| {
4961 let sources: [(&str, &str); 14] = [
4962 ("AWS::EC2::VPC.Id", r"^vpc-[a-f0-9]{8,17}$"),
4963 ("AWS::EC2::Subnet.Id", r"^subnet-[a-f0-9]{8,17}$"),
4964 ("AWS::EC2::SecurityGroup.Id", r"^sg-[a-f0-9]{8,17}$"),
4965 (EC2_IMAGE_ID_FORMAT, r"^ami-([0-9a-z]{8}|[0-9a-z]{17})$"),
4966 ("AWS::IAM::Role.Arn", IAM_ROLE_ARN_PATTERN),
4967 ("AWS::IAM::Role.Name", IAM_ROLE_NAME_PATTERN),
4968 ("AWS::Logs::LogGroup.Name", r"^[\.\-_/#A-Za-z0-9]{1,512}$"),
4969 ("AWS::EC2::SecurityGroup.Name", SECURITY_GROUP_NAME_PATTERN),
4970 ("AWS::EC2::KeyPair.KeyName", r"^[\x20-\x7E]{1,255}$"),
4971 ("AWS::EC2::AvailabilityZone.Name", r"^[a-z]{2}(-gov|-iso[a-z]*)?-[a-z]+-\d[a-z]$"),
4972 ("AWS::Route53::HostedZone.Id", r"^Z[A-Z0-9]{1,32}$"),
4973 ("AWS::EC2::Volume.Id", r"^vol-[a-f0-9]{8,17}$"),
4974 ("AWS::EC2::NetworkInterface.Id", r"^eni-[a-f0-9]{8,17}$"),
4975 ("AWS::SSM::Parameter.Name", r"^[a-zA-Z0-9_./-]{1,2048}$"),
4976 ];
4977 sources.into_iter().filter_map(|(fmt, pat)| compile_pattern(pat).map(|re| (fmt, re))).collect()
4978});
4979
4980static BRANCH_FORMAT_PATTERNS: LazyLock<HashMap<&'static str, Arc<CompiledPattern>>> = LazyLock::new(|| {
4985 let sources: &[(&str, &str)] = &[
4986 (
4992 "AWS::KMS::Key.Arn",
4993 r"^arn:aws[a-zA-Z-]*:kms:[a-z0-9-]+:\d{12}:(key/(mrk-[a-f0-9]{32}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})|alias/[a-zA-Z0-9:/_-]{1,250})$",
4994 ),
4995 (
4998 "AWS::KMS::Key.Id",
4999 r"^(mrk-[a-f0-9]{32}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}|alias/[a-zA-Z0-9:/_-]{1,250})$",
5000 ),
5001 ("AWS::KMS::Alias.AliasName", r"^alias/[a-zA-Z0-9:/_-]{1,250}$"),
5003 ("AWS::EC2::SecurityGroup.Name", SECURITY_GROUP_NAME_PATTERN),
5005 ("ipv4-network", r"^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$"),
5007 ("ipv6-network", r"^[0-9a-fA-F:]+/\d{1,3}$"),
5009 ("date-time", r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$"),
5011 ("timestamp", r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$"),
5013 ];
5014 sources.iter().filter_map(|(fmt, pat)| compile_pattern(pat).map(|re| (*fmt, re))).collect()
5015});
5016
5017fn format_value_matches(value: &str, format: &str) -> bool {
5026 if let Some(re) = FORMAT_PATTERNS.get(format) {
5028 return re.is_match(value);
5029 }
5030 if let Some(re) = BRANCH_FORMAT_PATTERNS.get(format) {
5032 return re.is_match(value);
5033 }
5034 match format {
5036 "AWS::EC2::SecurityGroup.Ids" => {
5037 FORMAT_PATTERNS.get("AWS::EC2::SecurityGroup.Id").is_none_or(|re| re.is_match(value))
5038 }
5039 "AWS::EC2::SecurityGroup.Names" => {
5040 FORMAT_PATTERNS.get("AWS::EC2::SecurityGroup.Name").is_none_or(|re| re.is_match(value))
5041 }
5042 "json" => serde_json::from_str::<serde_json::Value>(value).is_ok(),
5044 _ => true,
5046 }
5047}
5048
5049fn sns_kms_identifier_is_runtime_validated(model: &SemanticModel, resource_id: &str, property_path: &str) -> bool {
5050 property_path == "Properties.KmsMasterKeyId"
5051 && model.resource(resource_id).is_some_and(|resource| resource.resource_type == "AWS::SNS::Topic")
5052}
5053
5054fn ec2_resolves_ssm_image_alias(
5055 model: &SemanticModel,
5056 resource_id: &str,
5057 property_path: &str,
5058 format: &str,
5059 value: &str,
5060) -> bool {
5061 format == EC2_IMAGE_ID_FORMAT
5062 && value.starts_with(EC2_SSM_IMAGE_ALIAS_PREFIX)
5063 && model.resource(resource_id).is_some_and(|resource| {
5064 (resource.resource_type == EC2_INSTANCE_RESOURCE_TYPE && property_path == EC2_INSTANCE_IMAGE_ID_PATH)
5065 || (resource.resource_type == EC2_LAUNCH_TEMPLATE_RESOURCE_TYPE
5066 && property_path == EC2_LAUNCH_TEMPLATE_IMAGE_ID_PATH)
5067 })
5068}
5069
5070fn validate_prop_composition(
5077 out: &mut Vec<Diagnostic>,
5078 m: &Arc<SemanticModel>,
5079 rid: &str,
5080 prop_path: &str,
5081 schema: &PropSchema,
5082 defs: &HashMap<String, PropSchema>,
5083 scenarios: &[(serde_json::Value, HashMap<String, bool>)],
5084) {
5085 for branch in &schema.all_of {
5087 for (val, conds) in scenarios {
5088 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
5089 continue;
5090 }
5091 if !schema_value_matches(val, branch, defs, 0) {
5092 out.push(build_diagnostic_conditional(
5093 "F3017",
5094 "Value does not satisfy allOf constraint",
5095 m,
5096 rid,
5097 prop_path,
5098 None,
5099 condition_map(conds),
5100 ));
5101 break;
5102 }
5103 }
5104 }
5105
5106 if !schema.any_of.is_empty() && !sns_kms_identifier_is_runtime_validated(m, rid, prop_path) {
5108 for (val, conds) in scenarios {
5109 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
5110 continue;
5111 }
5112 let evaluations = evaluate_value_composition_branches(val, &schema.any_of, defs, prop_path);
5113 if evaluations.iter().all(|evaluation| !evaluation.matched) {
5114 out.push(build_composition_diagnostic(
5115 "F3017",
5116 CompositionKind::AnyOf,
5117 &evaluations,
5118 m,
5119 rid,
5120 prop_path,
5121 None,
5122 Some(val),
5123 condition_map(conds),
5124 ));
5125 validate_failed_any_of_formats(out, m, rid, prop_path, schema, defs, val, conds);
5126 }
5127 }
5128 }
5129
5130 if !schema.one_of.is_empty() {
5132 for (val, conds) in scenarios {
5133 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
5134 continue;
5135 }
5136 let evaluations = evaluate_value_composition_branches(val, &schema.one_of, defs, prop_path);
5137 let match_count = evaluations.iter().filter(|evaluation| evaluation.matched).count();
5138 if match_count != 1 {
5139 out.push(build_composition_diagnostic(
5140 "F3018",
5141 CompositionKind::OneOf,
5142 &evaluations,
5143 m,
5144 rid,
5145 prop_path,
5146 None,
5147 Some(val),
5148 condition_map(conds),
5149 ));
5150 }
5151 }
5152 }
5153
5154 for ite in schema.if_then_else.iter().filter(|ite| ite.enforce_full_branch) {
5158 for (val, conds) in scenarios {
5159 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
5160 continue;
5161 }
5162 let cond_matches = if let Some(obj) = val.as_object() {
5166 let obj_keys: Vec<String> = obj
5167 .iter()
5168 .filter(|(_, candidate)| !candidate.is_null())
5169 .map(|(property, _)| property.clone())
5170 .collect();
5171 condition_schema_value_matches(&ite.condition, obj, &obj_keys, defs, 0)
5172 } else {
5173 true
5177 };
5178 let branch = if cond_matches { &ite.then_schema } else { &ite.else_schema };
5179 if let Some(branch_schema) = branch
5180 && !schema_value_matches(val, branch_schema, defs, 0)
5181 {
5182 out.push(build_diagnostic_conditional(
5183 "F3017",
5184 "Value does not satisfy conditional schema constraint",
5185 m,
5186 rid,
5187 prop_path,
5188 None,
5189 condition_map(conds),
5190 ));
5191 }
5192 }
5193 }
5194}
5195
5196fn validate_failed_any_of_formats(
5197 out: &mut Vec<Diagnostic>,
5198 m: &Arc<SemanticModel>,
5199 rid: &str,
5200 prop_path: &str,
5201 schema: &PropSchema,
5202 defs: &HashMap<String, PropSchema>,
5203 value: &serde_json::Value,
5204 conditions: &HashMap<String, bool>,
5205) {
5206 let mut reported_formats = HashSet::new();
5207 if let Some(format) = &schema.format {
5208 reported_formats.insert(format.clone());
5209 }
5210
5211 for branch in &schema.any_of {
5212 let resolved_branch = branch.resolve(defs);
5213 let Some(format) = resolved_branch.format.as_deref() else {
5214 continue;
5215 };
5216 if reported_formats.insert(format.to_string()) {
5217 validate_format_scenario(out, m, rid, prop_path, format, value, conditions, false);
5218 }
5219 }
5220}
5221
5222fn validate_format(
5223 out: &mut Vec<Diagnostic>,
5224 m: &Arc<SemanticModel>,
5225 rid: &str,
5226 prop_path: &str,
5227 format: &str,
5228 scenarios: &[(serde_json::Value, HashMap<String, bool>)],
5229 schema_pattern_failures: &[(serde_json::Value, HashMap<String, bool>)],
5230) {
5231 for (value, conditions) in scenarios {
5232 let schema_pattern_failed = schema_pattern_failures
5233 .iter()
5234 .any(|(pattern_value, pattern_conditions)| pattern_value == value && pattern_conditions == conditions);
5235 validate_format_scenario(out, m, rid, prop_path, format, value, conditions, schema_pattern_failed);
5236 }
5237}
5238
5239#[allow(clippy::too_many_arguments)]
5240fn validate_format_scenario(
5241 out: &mut Vec<Diagnostic>,
5242 m: &Arc<SemanticModel>,
5243 rid: &str,
5244 prop_path: &str,
5245 format: &str,
5246 value: &serde_json::Value,
5247 conditions: &HashMap<String, bool>,
5248 schema_pattern_failed: bool,
5249) {
5250 let Some(pattern) = FORMAT_PATTERNS.get(format) else {
5251 return;
5252 };
5253 if !is_satisfiable(m, conditions)
5254 || value.is_null()
5255 || m.is_from_intrinsic(rid, prop_path)
5256 || defer_value_constraints(m, rid, prop_path, value, conditions)
5257 {
5258 return;
5259 }
5260 let Some(string_value) = coerce_to_string(value) else {
5261 return;
5262 };
5263 if ec2_resolves_ssm_image_alias(m, rid, prop_path, format, &string_value) || pattern.is_match(&string_value) {
5264 return;
5265 }
5266
5267 let rule_id = format_rule_for_format(format).unwrap_or("E1103");
5268 if rule_id == "E1155" && schema_pattern_failed {
5269 return;
5270 }
5271 out.push(build_diagnostic_conditional(
5272 rule_id,
5273 &format!("{} does not match format '{}'", format_value(value), format),
5274 m,
5275 rid,
5276 prop_path,
5277 None,
5278 condition_map(conditions),
5279 ));
5280}
5281
5282fn validate_lifecycle(out: &mut Vec<Diagnostic>, store: &CompiledSchemaStore, model: &Arc<SemanticModel>) {
5283 let lifecycle = store.lifecycle();
5284 for (rid, res) in &model.resources {
5285 if let Some(entry) = lifecycle.resource_lifecycle(&res.resource_type) {
5286 let (rule_id, msg) = match (entry.status.as_str(), entry.date.as_deref()) {
5287 ("shutdown", Some(d)) => (
5288 "E3710",
5289 format!("Resource type '{}' is from a service that was shut down on {}", res.resource_type, d),
5290 ),
5291 ("shutdown", None) => (
5292 "E3710",
5293 format!("Resource type '{}' is from a service that has been shut down", res.resource_type),
5294 ),
5295 ("sunset", Some(d)) => (
5296 "W3696",
5297 format!(
5298 "Resource type '{}' is from a service that will be shut down on {}. Plan to migrate to an alternative",
5299 res.resource_type, d
5300 ),
5301 ),
5302 ("sunset", None) => {
5303 ("W3696", format!("Resource type '{}' is from a service that is sunsetting", res.resource_type))
5304 }
5305 ("maintenance", Some(d)) => (
5306 "W3697",
5307 format!(
5308 "Resource type '{}' is from a service in maintenance mode since {}. Consider migrating to an alternative",
5309 res.resource_type, d
5310 ),
5311 ),
5312 ("maintenance", None) => {
5313 ("W3697", format!("Resource type '{}' is from a service in maintenance mode", res.resource_type))
5314 }
5315 _ => continue,
5316 };
5317 out.push(build_diagnostic(rule_id, &msg, model, rid, "", None));
5318 }
5319
5320 if (res.resource_type == "AWS::Lambda::Function" || res.resource_type == SAM_FUNCTION_TYPE)
5321 && !model.is_from_intrinsic(rid, "Properties.Runtime")
5326 {
5327 for (val, _) in &model.resolve_scenarios_json(rid, "Properties.Runtime") {
5328 let Some(runtime) = val.as_str() else {
5329 continue;
5330 };
5331 let (rule_id, band) = if lifecycle.is_runtime_eol(runtime) {
5335 ("E2533", Some(RuntimeLifecycleBand::EndOfLife))
5336 } else if lifecycle.is_runtime_create_blocked(runtime) {
5337 ("E2531", Some(RuntimeLifecycleBand::CreateBlocked))
5338 } else if lifecycle.is_runtime_deprecated(runtime) {
5339 ("W2531", Some(RuntimeLifecycleBand::Deprecated))
5340 } else {
5341 ("", None)
5342 };
5343 if let Some(band) = band {
5344 out.push(build_diagnostic(
5345 rule_id,
5346 &runtime_deprecation_message(lifecycle, runtime, band),
5347 model,
5348 rid,
5349 "Properties.Runtime",
5350 None,
5351 ));
5352 }
5353 }
5354 }
5355 }
5356}
5357
5358#[derive(Clone, Copy)]
5359enum RuntimeLifecycleBand {
5360 Deprecated,
5361 CreateBlocked,
5362 EndOfLife,
5363}
5364
5365fn runtime_deprecation_message(
5369 lifecycle: &crate::store::LifecycleStore,
5370 runtime: &str,
5371 band: RuntimeLifecycleBand,
5372) -> String {
5373 let Some(dates) = lifecycle.runtime_lifecycle(runtime) else {
5374 return format!("Runtime '{}' is deprecated", runtime);
5375 };
5376 let successor = match &dates.successor {
5377 Some(s) => format!("'{}'", s),
5378 None => "None".to_string(),
5379 };
5380 let (creation_status, update_status) = match band {
5381 RuntimeLifecycleBand::Deprecated => ("will be disabled", "will be disabled"),
5382 RuntimeLifecycleBand::CreateBlocked => ("was disabled", "will be disabled"),
5383 RuntimeLifecycleBand::EndOfLife => ("was disabled", "were disabled"),
5384 };
5385 format!(
5386 "Runtime '{}' was deprecated on '{}'. Creation {} on '{}', and updates {} on '{}'. Please consider updating to {}",
5387 runtime, dates.deprecated, creation_status, dates.create_block, update_status, dates.update_block, successor
5388 )
5389}
5390
5391fn validate_extensions(
5392 out: &mut Vec<Diagnostic>,
5393 store: &CompiledSchemaStore,
5394 model: &Arc<SemanticModel>,
5395 rid: &str,
5396 res: &ResolvedResource,
5397) {
5398 let Some(exts) = store.extensions().get(&res.resource_type) else {
5399 return;
5400 };
5401 for ext in exts {
5402 if ext.get("cfnGather").is_some() {
5403 validate_cfn_gather(out, model, rid, res, ext);
5404 } else if ext.get("if").is_some() {
5405 validate_extension_if_then_else(out, model, rid, res, ext);
5406 } else if let Some(all_of) = ext.get("allOf").and_then(|v| v.as_array()) {
5407 for sub_ext in all_of {
5408 if sub_ext.get("if").is_some() {
5409 validate_extension_if_then_else(out, model, rid, res, sub_ext);
5410 }
5411 }
5412 }
5413 }
5414}
5415
5416fn validate_cfn_gather(
5417 out: &mut Vec<Diagnostic>,
5418 model: &Arc<SemanticModel>,
5419 rid: &str,
5420 res: &ResolvedResource,
5421 ext: &serde_json::Value,
5422) {
5423 let Some(gather_def) = ext.get("cfnGather") else {
5424 return;
5425 };
5426 let Some(gather_slots) = gather_def.get("gather").and_then(|v| v.as_object()) else {
5427 return;
5428 };
5429 let Some(schema) = gather_def.get("schema") else {
5430 return;
5431 };
5432
5433 let mut context = serde_json::Map::new();
5434 for (slot_name, slot_def) in gather_slots {
5435 let slot_obj = match slot_def.as_object() {
5436 Some(o) => o,
5437 None => continue,
5438 };
5439 let properties = slot_obj.get("properties").and_then(|v| v.as_object());
5440 let reference_path = slot_obj.get("reference").and_then(|v| v.as_str());
5441
5442 let target_rid = if let Some(ref_path) = reference_path {
5443 let prop_key = ref_path.trim_start_matches('/').replace('/', ".");
5447 model.follow_ref(rid, &format!("Properties.{}", prop_key)).map(String::from)
5448 } else {
5449 Some(rid.to_string())
5450 };
5451
5452 let Some(target) = target_rid else { continue };
5453
5454 if let Some(filter) = slot_obj.get("filter").and_then(|v| v.as_object())
5455 && let Some(expected_type) = filter.get("type").and_then(|v| v.as_str())
5456 {
5457 let actual_type = model.resources.get(&target).map(|r| r.resource_type.as_str());
5458 if actual_type != Some(expected_type) {
5459 continue;
5460 }
5461 }
5462
5463 let mut slot_values = serde_json::Map::new();
5464 if let Some(props) = properties {
5465 for (prop_name, prop_def) in props {
5466 let (path, default_val) = match prop_def {
5472 serde_json::Value::String(p) => (Some(p.as_str()), None),
5473 _ => (prop_def.get("path").and_then(|v| v.as_str()), prop_def.get("default").cloned()),
5474 };
5475 let resolved_path = path.map(|p| format!("Properties.{}", p.trim_start_matches('/').replace('/', ".")));
5476
5477 let resolved = resolved_path
5478 .as_ref()
5479 .and_then(|p| model.resolve_scenarios_json(&target, p).into_iter().next().map(|(v, _)| v));
5480 let value = resolved.or(default_val).unwrap_or(serde_json::Value::Null);
5481 slot_values.insert(prop_name.clone(), value);
5482 }
5483 }
5484 context.insert(slot_name.clone(), serde_json::Value::Object(slot_values));
5485 }
5486
5487 let context_val = serde_json::Value::Object(context);
5488
5489 let resolved_schema = resolve_data_in_schema(schema, &context_val);
5490 evaluate_gather_schema(out, model, rid, res, &resolved_schema, &context_val);
5491}
5492
5493fn extension_required_covered_by_dedicated_rule(resource_type: &str, prop_name: &str) -> bool {
5507 matches!(
5508 (resource_type, prop_name),
5509 ("AWS::S3::Bucket", "OwnershipControls")
5510 | ("AWS::ElasticLoadBalancingV2::Listener", "Certificates")
5511 | ("AWS::RDS::DBInstance", "BackupRetentionPeriod")
5512 )
5513}
5514
5515fn extension_ignored_covered_by_dedicated_rule(resource_type: &str, property_name: &str) -> bool {
5516 matches!(
5517 (resource_type, property_name),
5518 ("AWS::Route53::RecordSet", "TTL")
5519 | ("AWS::RDS::DBCluster", "MasterUsername")
5520 | ("AWS::RDS::DBCluster", "StorageEncrypted")
5521 )
5522}
5523
5524fn extension_branch_requires_validation(resource_type: &str, branch: &serde_json::Value) -> bool {
5525 let has_unowned_requirement = branch.get("required").and_then(|value| value.as_array()).is_some_and(|required| {
5526 required
5527 .iter()
5528 .filter_map(|value| value.as_str())
5529 .any(|property_name| !extension_required_covered_by_dedicated_rule(resource_type, property_name))
5530 });
5531 let has_unowned_property_constraint =
5532 branch.get("properties").and_then(|value| value.as_object()).is_some_and(|properties| {
5533 properties.iter().any(|(property_name, constraint)| {
5534 if constraint == &serde_json::Value::Bool(false) {
5535 !extension_ignored_covered_by_dedicated_rule(resource_type, property_name)
5536 } else {
5537 constraint.get("enum").is_some()
5538 }
5539 })
5540 });
5541 has_unowned_requirement || has_unowned_property_constraint
5542}
5543
5544fn validate_extension_if_then_else(
5545 out: &mut Vec<Diagnostic>,
5546 model: &Arc<SemanticModel>,
5547 rid: &str,
5548 res: &ResolvedResource,
5549 ext: &serde_json::Value,
5550) {
5551 if res.properties_dynamic
5552 || !ext
5553 .get("then")
5554 .into_iter()
5555 .chain(ext.get("else"))
5556 .any(|branch| extension_branch_requires_validation(&res.resource_type, branch))
5557 {
5558 return;
5559 }
5560 let Some(if_schema) = ext.get("if") else {
5561 return;
5562 };
5563 let condition: ConditionSchema = compile_condition_schema(if_schema, BuildRefSiblings::Ignore).into();
5564 if condition.states_nothing() {
5565 return;
5566 }
5567
5568 let mut property_names = BTreeSet::new();
5569 collect_condition_property_names(&condition, &mut property_names);
5570 for branch in ext.get("then").into_iter().chain(ext.get("else")) {
5571 if let Some(required) = branch.get("required").and_then(|value| value.as_array()) {
5572 property_names.extend(required.iter().filter_map(|value| value.as_str().map(String::from)));
5573 }
5574 if let Some(properties) = branch.get("properties").and_then(|value| value.as_object()) {
5575 property_names.extend(properties.keys().cloned());
5576 }
5577 }
5578
5579 let property_paths: Vec<String> = property_names.iter().map(|name| format!("Properties.{name}")).collect();
5580 let property_path_refs: Vec<&str> = property_paths.iter().map(String::as_str).collect();
5581 let Some(assignments) = property_scenario_assignments(model, rid, &property_path_refs) else {
5582 return;
5583 };
5584
5585 for assignment in assignments {
5586 let _scenario_filter_scope = ScenarioFilterScope::enter(assignment.clone());
5587 let effective_keys: Vec<String> = property_names
5588 .iter()
5589 .filter(|property| {
5590 let property_path = format!("Properties.{property}");
5591 model.has_property(rid, &property_path)
5592 && property_present_under(model, rid, KEY_PROPERTIES, property, &assignment)
5593 })
5594 .cloned()
5595 .collect();
5596 let branch = if condition_matches(&condition, &effective_keys, model, rid, &HashMap::new()) {
5597 ext.get("then")
5598 } else {
5599 ext.get("else")
5600 };
5601 if let Some(branch_schema) = branch {
5602 validate_extension_branch(out, model, rid, res, branch_schema, &effective_keys, &assignment);
5603 }
5604 }
5605}
5606
5607fn extension_constraint_rule_id(resource_type: &str, property_name: &str) -> Option<&'static str> {
5608 match (resource_type, property_name) {
5609 ("AWS::RDS::DBInstance", "StorageEncrypted") => Some("E3720"),
5610 _ => None,
5611 }
5612}
5613
5614fn validate_extension_branch(
5615 out: &mut Vec<Diagnostic>,
5616 model: &Arc<SemanticModel>,
5617 rid: &str,
5618 res: &ResolvedResource,
5619 branch_schema: &serde_json::Value,
5620 effective_keys: &[String],
5621 assignment: &HashMap<String, bool>,
5622) {
5623 if let Some(required) = branch_schema.get("required").and_then(|value| value.as_array()) {
5624 for property_name in required.iter().filter_map(|value| value.as_str()) {
5625 if effective_keys.iter().any(|key| key == property_name)
5626 || extension_required_covered_by_dedicated_rule(&res.resource_type, property_name)
5627 {
5628 continue;
5629 }
5630 let rule_id = extension_constraint_rule_id(&res.resource_type, property_name).unwrap_or("F3003");
5631 if rule_id == "F3003"
5632 && out.iter().any(|diagnostic| {
5633 diagnostic.rule_id == "F3003"
5634 && diagnostic.resource_logical_id() == Some(rid)
5635 && diagnostic.message.contains(&format!("'{property_name}' is a required property"))
5636 })
5637 {
5638 continue;
5639 }
5640 let message = if rule_id == "E3720" {
5641 "StorageEncrypted must be set to true when KmsKeyId is specified".to_string()
5642 } else {
5643 format!("'{property_name}' is a required property (from extension)")
5644 };
5645 out.push(build_diagnostic_conditional(
5646 rule_id,
5647 &message,
5648 model,
5649 rid,
5650 KEY_PROPERTIES,
5651 Some(&format!("Add '{property_name}'")),
5652 assignment_condition_map(assignment),
5653 ));
5654 }
5655 }
5656
5657 let Some(properties) = branch_schema.get("properties").and_then(|value| value.as_object()) else {
5658 return;
5659 };
5660 for (property_name, constraint) in properties {
5661 let property_path = format!("Properties.{property_name}");
5662 if constraint == &serde_json::Value::Bool(false) {
5663 if effective_keys.iter().any(|key| key == property_name)
5664 && !extension_ignored_covered_by_dedicated_rule(&res.resource_type, property_name)
5665 {
5666 out.push(build_diagnostic_conditional(
5667 "I9002",
5668 &format!("'{property_name}' is ignored in this configuration (from extension)"),
5669 model,
5670 rid,
5671 &property_path,
5672 None,
5673 assignment_condition_map(assignment),
5674 ));
5675 }
5676 continue;
5677 }
5678
5679 let Some(enum_values) = constraint.get("enum").and_then(|value| value.as_array()) else {
5680 continue;
5681 };
5682 for (value, conditions) in model.resolve_scenarios_json_shared(rid, &property_path).iter() {
5683 if value.is_null()
5684 || !is_satisfiable(model, conditions)
5685 || !scenario_consistent_with_filter(model, conditions)
5686 || defer_value_constraints(model, rid, &property_path, value, conditions)
5687 {
5688 continue;
5689 }
5690 let matches_enum = enum_values.iter().any(|expected| {
5691 scalar_eq(expected, value)
5692 || expected
5693 .as_str()
5694 .zip(value.as_str())
5695 .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right))
5696 });
5697 if matches_enum {
5698 continue;
5699 }
5700 let Some(world) = try_merge_assignments(assignment, conditions) else {
5701 continue;
5702 };
5703 let rule_id = extension_constraint_rule_id(&res.resource_type, property_name).unwrap_or("E9006");
5704 let message = if rule_id == "E3720" {
5705 format!("StorageEncrypted must be true when KmsKeyId is specified, found {}", render_value(value))
5706 } else {
5707 let allowed = render_str_list(enum_values.iter().filter_map(|allowed| allowed.as_str()));
5708 format!("'{}' is not one of {allowed}", coerce_to_string(value).unwrap_or_default())
5709 };
5710 out.push(build_diagnostic_conditional(
5711 rule_id,
5712 &message,
5713 model,
5714 rid,
5715 &property_path,
5716 None,
5717 assignment_condition_map(&world),
5718 ));
5719 }
5720 }
5721}
5722
5723fn resolve_data_in_schema(schema: &serde_json::Value, context: &serde_json::Value) -> serde_json::Value {
5724 match schema {
5725 serde_json::Value::Object(obj) => {
5726 if let Some(lookup) = obj.get("$lookup").and_then(|v| v.as_object()) {
5727 let key = lookup
5728 .get("key")
5729 .map(|k| resolve_data_in_schema(k, context))
5730 .and_then(|v| v.as_str().map(String::from));
5731 let map = lookup.get("map").and_then(|v| v.as_object());
5732 if let (Some(k), Some(m)) = (key, map) {
5733 return m.get(&k).cloned().unwrap_or(serde_json::Value::Null);
5734 }
5735 return serde_json::Value::Null;
5736 }
5737 if let Some(pointer) = obj.get("$data").and_then(|v| v.as_str()) {
5738 return resolve_json_pointer(context, pointer);
5739 }
5740 let mut result = serde_json::Map::new();
5741 for (k, v) in obj {
5742 result.insert(k.clone(), resolve_data_in_schema(v, context));
5743 }
5744 serde_json::Value::Object(result)
5745 }
5746 serde_json::Value::Array(arr) => {
5747 serde_json::Value::Array(arr.iter().map(|v| resolve_data_in_schema(v, context)).collect())
5748 }
5749 other => other.clone(),
5750 }
5751}
5752
5753fn resolve_json_pointer(root: &serde_json::Value, pointer: &str) -> serde_json::Value {
5754 let parts: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
5755 let mut current = root;
5756 for part in &parts {
5757 if part.is_empty() {
5758 continue;
5759 }
5760 current = match current {
5761 serde_json::Value::Object(m) => match m.get(*part) {
5762 Some(v) => v,
5763 None => return serde_json::Value::Null,
5764 },
5765 serde_json::Value::Array(a) => match part.parse::<usize>() {
5766 Ok(idx) => match a.get(idx) {
5767 Some(v) => v,
5768 None => return serde_json::Value::Null,
5769 },
5770 Err(_) => return serde_json::Value::Null,
5771 },
5772 _ => return serde_json::Value::Null,
5773 };
5774 }
5775 current.clone()
5776}
5777
5778fn evaluate_gather_schema(
5779 out: &mut Vec<Diagnostic>,
5780 model: &Arc<SemanticModel>,
5781 rid: &str,
5782 _res: &ResolvedResource,
5783 schema: &serde_json::Value,
5784 context: &serde_json::Value,
5785) {
5786 let Some(obj) = schema.as_object() else {
5787 return;
5788 };
5789 if let Some(if_val) = obj.get("if") {
5798 let matches = gather_condition_matches(if_val, context);
5799 let branch = if matches { obj.get("then") } else { obj.get("else") };
5800 if let Some(branch_val) = branch {
5801 evaluate_gather_constraints(out, model, rid, branch_val, context);
5802 }
5803 }
5804}
5805
5806fn gather_condition_matches(condition: &serde_json::Value, context: &serde_json::Value) -> bool {
5807 let Some(obj) = condition.as_object() else {
5808 return false;
5809 };
5810
5811 if let Some(required) = obj.get("required").and_then(|v| v.as_array()) {
5812 for req in required {
5813 if let Some(name) = req.as_str() {
5814 let val = resolve_json_pointer(context, &format!("/{}", name));
5815 if val.is_null() {
5816 return false;
5817 }
5818 }
5819 }
5820 }
5821
5822 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
5823 for (prop_name, constraint) in props {
5824 let actual = resolve_json_pointer(context, &format!("/{}", prop_name));
5825 if actual.is_null() {
5826 return false;
5827 }
5828 if !gather_prop_matches(&actual, constraint) {
5829 return false;
5830 }
5831 }
5832 }
5833 true
5834}
5835
5836fn gather_prop_matches(actual: &serde_json::Value, constraint: &serde_json::Value) -> bool {
5837 let Some(obj) = constraint.as_object() else {
5838 return true;
5839 };
5840
5841 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
5842 for (k, v) in props {
5843 let child = match actual.get(k) {
5844 Some(c) => c,
5845 None => return false,
5846 };
5847 if !gather_prop_matches(child, v) {
5848 return false;
5849 }
5850 }
5851 }
5852 if let Some(required) = obj.get("required").and_then(|v| v.as_array()) {
5853 for req in required {
5854 if let Some(name) = req.as_str()
5855 && actual.get(name).is_none()
5856 {
5857 return false;
5858 }
5859 }
5860 }
5861 if let Some(cv) = obj.get("const") {
5862 return scalar_eq(actual, cv);
5863 }
5864 if let Some(enum_vals) = obj.get("enum").and_then(|v| v.as_array()) {
5865 return enum_vals.iter().any(|e| e == actual);
5866 }
5867 true
5868}
5869
5870fn evaluate_gather_constraints(
5871 out: &mut Vec<Diagnostic>,
5872 model: &Arc<SemanticModel>,
5873 rid: &str,
5874 schema: &serde_json::Value,
5875 context: &serde_json::Value,
5876) {
5877 let Some(obj) = schema.as_object() else {
5878 return;
5879 };
5880 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
5881 for (slot_name, slot_constraints) in props {
5882 let slot_val = resolve_json_pointer(context, &format!("/{}", slot_name));
5883 check_gather_property_constraints(out, model, rid, slot_name, &slot_val, slot_constraints);
5884 }
5885 }
5886}
5887
5888fn check_gather_property_constraints(
5889 out: &mut Vec<Diagnostic>,
5890 model: &Arc<SemanticModel>,
5891 rid: &str,
5892 slot_name: &str,
5893 actual: &serde_json::Value,
5894 constraint: &serde_json::Value,
5895) {
5896 let Some(obj) = constraint.as_object() else {
5897 return;
5898 };
5899 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
5900 for (prop_name, prop_constraint) in props {
5901 let prop_val = actual.get(prop_name).unwrap_or(&serde_json::Value::Null);
5902 let pc = match prop_constraint.as_object() {
5903 Some(o) => o,
5904 None => continue,
5905 };
5906 if let Some(cv) = pc.get("const")
5907 && !cv.is_null()
5908 && !scalar_eq(prop_val, cv)
5909 {
5910 out.push(build_diagnostic(
5911 "E3030",
5912 &format!(
5913 "Cross-resource constraint: {}.{} is {} but must be {} (from referenced resource)",
5914 slot_name,
5915 prop_name,
5916 format_value(prop_val),
5917 format_value(cv)
5918 ),
5919 model,
5920 rid,
5921 KEY_PROPERTIES,
5922 None,
5923 ));
5924 }
5925 }
5932 }
5933}
5934
5935fn describe_resolution(m: &Arc<SemanticModel>, rid: &str, prop_path: &str) -> Option<String> {
5936 let top_level_path;
5937 let (val, value_path) = match m.resolve(rid, prop_path) {
5938 Some(val) => (val, prop_path),
5939 None => {
5940 let stripped = prop_path.strip_prefix("Properties.")?;
5941 let top = stripped.split('.').next()?;
5942 let val = m.resources.get(rid)?.properties.get(top)?;
5943 top_level_path = format!("Properties.{}", top);
5944 (val, top_level_path.as_str())
5945 }
5946 };
5947 match val {
5948 ResolvedValue::Reference { target, kind } => {
5949 let kind_str = match kind {
5950 RefKind::Ref => "Ref",
5951 RefKind::GetAtt { attr: a } => {
5952 return Some(format!("GetAtt {}.{}", target, a));
5953 }
5954 RefKind::Sub { var: _ } => "Sub",
5955 RefKind::DependsOn => return None,
5956 };
5957 Some(format!("{} to '{}'", kind_str, target))
5958 }
5959 ResolvedValue::Enum { variants: _ } => Some("parameter with AllowedValues".into()),
5960 ResolvedValue::Conditional { condition: cond, if_true: _, if_false: _ } => {
5961 Some(format!("Fn::If on condition '{}'", cond))
5962 }
5963 ResolvedValue::Dynamic { reason: desc } => Some(format!("dynamic ({})", desc)),
5964 ResolvedValue::TypedDynamic { reason: desc, param_type: typ } => match m.parameter_name_at(rid, value_path) {
5970 Some(parameter) => Some(format!("parameter '{}' (type {})", parameter, typ)),
5971 None => Some(format!("dynamic ({})", desc)),
5972 },
5973 _ => None,
5974 }
5975}
5976
5977fn build_diagnostic(
5978 rule_id: &str,
5979 msg: &str,
5980 m: &Arc<SemanticModel>,
5981 rid: &str,
5982 prop: &str,
5983 fix: Option<&str>,
5984) -> Diagnostic {
5985 build_diagnostic_conditional(rule_id, msg, m, rid, prop, fix, None)
5986}
5987
5988fn build_diagnostic_conditional(
5989 rule_id: &str,
5990 msg: &str,
5991 m: &Arc<SemanticModel>,
5992 rid: &str,
5993 prop: &str,
5994 fix: Option<&str>,
5995 conds: Option<HashMap<String, bool>>,
5996) -> Diagnostic {
5997 let span = if rid.is_empty() {
5998 resolve_section_span(rule_id, m.as_ref())
5999 } else {
6000 let source_path = conds
6001 .as_ref()
6002 .and_then(|conditions| m.scenario_source_path(rid, prop, conditions))
6003 .unwrap_or_else(|| prop.to_string());
6004 m.resource_span(rid, &source_path)
6005 };
6006 RegisteredDiagnostic::new(rule_id, msg)
6007 .resource(rid, m.resources.get(rid).map(|r| r.resource_type.clone()))
6008 .property_path(prop)
6009 .location(span)
6010 .suggested_fix(fix)
6011 .condition_scenario(conds)
6012 .phase(Phase::Schema)
6013 .build()
6014}
6015
6016#[cfg(test)]
6017mod tests {
6018 use super::*;
6019 use crate::compiled::IfThenElse;
6020 use data_source::compiled_schema::{RefSiblings, compile_schema_with};
6021 use serde_json::json;
6022
6023 fn current_scenario_filter() -> Option<HashMap<String, bool>> {
6024 SCENARIO_FILTER.with(|filter| filter.borrow().clone())
6025 }
6026
6027 #[test]
6028 fn scenario_filter_scope_restores_outer_assignment_after_unwind() {
6029 reset_scenario_filter();
6030 let _outer_scope = ScenarioFilterScope::enter(HashMap::from([("Outer".to_string(), true)]));
6031 let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
6032 let _inner_scope = ScenarioFilterScope::enter(HashMap::from([("Inner".to_string(), false)]));
6033 assert_eq!(current_scenario_filter(), Some(HashMap::from([("Inner".to_string(), false)])));
6034 panic!("exercise unwind cleanup");
6035 }));
6036 assert!(unwind.is_err());
6037 assert_eq!(current_scenario_filter(), Some(HashMap::from([("Outer".to_string(), true)])));
6038 }
6039
6040 #[test]
6041 fn validation_entry_clears_a_stale_scenario_filter() {
6042 SCENARIO_FILTER.with(|filter| {
6043 filter.replace(Some(HashMap::from([("PreviousTemplate".to_string(), true)])));
6044 });
6045 let diagnostics = diagnostics_for_mode_value("managed");
6046 assert!(diagnostics.is_empty(), "the new validation must not inherit a prior scenario assignment");
6047 assert!(current_scenario_filter().is_none(), "validation leaves no scenario filter behind");
6048 }
6049
6050 #[test]
6051 fn levenshtein_distance_identical_strings() {
6052 assert_eq!(levenshtein_distance("abc", "abc"), 0);
6053 }
6054
6055 #[test]
6056 fn levenshtein_distance_empty_strings() {
6057 assert_eq!(levenshtein_distance("", ""), 0);
6058 }
6059
6060 #[test]
6061 fn levenshtein_distance_one_empty() {
6062 assert_eq!(levenshtein_distance("abc", ""), 3);
6063 assert_eq!(levenshtein_distance("", "xyz"), 3);
6064 }
6065
6066 #[test]
6067 fn levenshtein_distance_single_substitution() {
6068 assert_eq!(levenshtein_distance("cat", "bat"), 1);
6069 }
6070
6071 #[test]
6072 fn levenshtein_distance_single_insertion() {
6073 assert_eq!(levenshtein_distance("abc", "abcd"), 1);
6074 }
6075
6076 #[test]
6077 fn levenshtein_distance_single_deletion() {
6078 assert_eq!(levenshtein_distance("abcd", "abc"), 1);
6079 }
6080
6081 #[test]
6082 fn levenshtein_distance_transposition() {
6083 assert_eq!(levenshtein_distance("ab", "ba"), 2);
6084 }
6085
6086 #[test]
6087 fn levenshtein_distance_completely_different() {
6088 assert_eq!(levenshtein_distance("abc", "xyz"), 3);
6089 }
6090
6091 #[test]
6092 fn find_similar_exact_match() {
6093 let known: HashSet<&str> = ["BucketName", "AccessControl"].into_iter().collect();
6094 assert_eq!(find_similar("BucketName", &known), Some("BucketName"));
6095 }
6096
6097 #[test]
6098 fn find_similar_close_typo() {
6099 let known: HashSet<&str> = ["BucketName", "AccessControl"].into_iter().collect();
6100 let result = find_similar("Bucketame", &known);
6101 assert_eq!(result, Some("BucketName"));
6102 }
6103
6104 #[test]
6105 fn find_similar_no_match() {
6106 let known: HashSet<&str> = ["BucketName", "AccessControl"].into_iter().collect();
6107 assert_eq!(find_similar("CompletelyDifferent", &known), None, "dissimilar name should not match");
6108 }
6109
6110 #[test]
6111 fn find_similar_case_insensitive() {
6112 let known: HashSet<&str> = ["BucketName"].into_iter().collect();
6113 assert_eq!(find_similar("bucketname", &known), Some("BucketName"));
6114 }
6115
6116 #[test]
6117 fn type_matches_string() {
6118 assert!(type_matches(&json!("hello"), &PropType::Single("string".into())));
6119 assert!(!type_matches(&json!(42), &PropType::Single("string".into())));
6120 }
6121
6122 #[test]
6123 fn type_matches_integer() {
6124 assert!(type_matches(&json!(42), &PropType::Single("integer".into())));
6125 assert!(type_matches(&json!(42.0), &PropType::Single("integer".into())));
6126 assert!(!type_matches(&json!(42.5), &PropType::Single("integer".into())));
6127 assert!(!type_matches(&json!("42"), &PropType::Single("integer".into())));
6128 }
6129
6130 #[test]
6131 fn type_matches_number() {
6132 assert!(type_matches(&json!(42), &PropType::Single("number".into())));
6133 assert!(type_matches(&json!(3.14), &PropType::Single("number".into())));
6134 assert!(!type_matches(&json!("3.14"), &PropType::Single("number".into())));
6135 }
6136
6137 #[test]
6138 fn type_matches_boolean() {
6139 assert!(type_matches(&json!(true), &PropType::Single("boolean".into())));
6140 assert!(!type_matches(&json!("true"), &PropType::Single("boolean".into())));
6141 }
6142
6143 #[test]
6144 fn type_matches_array() {
6145 assert!(type_matches(&json!([1, 2]), &PropType::Single("array".into())));
6146 assert!(!type_matches(&json!("[]"), &PropType::Single("array".into())));
6147 }
6148
6149 #[test]
6150 fn type_matches_object() {
6151 assert!(type_matches(&json!({"a": 1}), &PropType::Single("object".into())));
6152 assert!(!type_matches(&json!("{}"), &PropType::Single("object".into())));
6153 }
6154
6155 #[test]
6156 fn type_matches_null() {
6157 assert!(type_matches(&json!(null), &PropType::Single("null".into())));
6158 assert!(!type_matches(&json!("null"), &PropType::Single("null".into())));
6159 }
6160
6161 #[test]
6162 fn type_matches_multi_with_null() {
6163 let pt = PropType::Multi(vec!["string".into(), "null".into()]);
6164 assert!(type_matches(&json!("hello"), &pt));
6165 assert!(type_matches(&json!(null), &pt));
6166 assert!(!type_matches(&json!(42), &pt));
6167 }
6168
6169 #[test]
6170 fn type_matches_unknown_type_always_true() {
6171 assert!(type_matches(&json!("anything"), &PropType::Single("custom_type".into())));
6172 }
6173
6174 #[test]
6175 fn enum_matches_exact() {
6176 assert!(enum_matches(&json!("a"), &[json!("a"), json!("b")]));
6177 assert!(!enum_matches(&json!("c"), &[json!("a"), json!("b")]));
6178 }
6179
6180 #[test]
6181 fn enum_matches_coerced_number_to_string() {
6182 assert!(enum_matches(&json!("42"), &[json!(42)]));
6183 }
6184
6185 #[test]
6186 fn enum_matches_empty_allowed() {
6187 assert!(!enum_matches(&json!("a"), &[]));
6188 }
6189
6190 #[test]
6191 fn enum_matches_case_insensitive_any_casing() {
6192 let allowed = [json!("managed"), json!("unmanaged")];
6193 assert!(enum_matches_case_insensitive(&json!("managed"), &allowed));
6194 assert!(enum_matches_case_insensitive(&json!("MANAGED"), &allowed));
6195 assert!(enum_matches_case_insensitive(&json!("Unmanaged"), &allowed));
6196 }
6197
6198 #[test]
6199 fn enum_matches_case_insensitive_rejects_unlisted_value() {
6200 let allowed = [json!("managed"), json!("unmanaged")];
6201 assert!(!enum_matches_case_insensitive(&json!("BOGUS"), &allowed));
6202 }
6203
6204 #[test]
6205 fn enum_matches_case_insensitive_non_string_uses_exact_comparison() {
6206 assert!(enum_matches_case_insensitive(&json!(42), &[json!(42)]));
6207 assert!(!enum_matches_case_insensitive(&json!(43), &[json!(42)]));
6208 }
6209
6210 fn store_with_case_insensitive_mode_enum() -> CompiledSchemaStore {
6211 let mode =
6212 PropSchema { enum_case_insensitive: vec![json!("managed"), json!("unmanaged")], ..Default::default() };
6213 let mut properties = HashMap::new();
6214 properties.insert("Mode".to_string(), mode);
6215 let mut store = CompiledSchemaStore::new();
6216 store.insert_schema(CompiledSchema {
6217 type_name: "AWS::Fake::Type".to_string(),
6218 properties,
6219 ..Default::default()
6220 });
6221 store
6222 }
6223
6224 fn diagnostics_for_mode_value(mode_value: &str) -> Vec<Diagnostic> {
6225 let template = format!(
6226 r#"{{"Resources":{{"Widget":{{"Type":"AWS::Fake::Type","Properties":{{"Mode":"{}"}}}}}}}}"#,
6227 mode_value
6228 );
6229 let model = Arc::new(SemanticModel::from_bytes(template.as_bytes()).expect("template parses"));
6230 validate_all_resources(&store_with_case_insensitive_mode_enum(), &model, None)
6231 }
6232
6233 #[test]
6234 fn case_insensitive_enum_accepts_any_casing_of_allowed_value() {
6235 for accepted in ["managed", "MANAGED", "Unmanaged"] {
6236 let w3030: Vec<Diagnostic> =
6237 diagnostics_for_mode_value(accepted).into_iter().filter(|d| d.rule_id == "W3030").collect();
6238 assert!(w3030.is_empty(), "'{accepted}' must not fire W3030, got: {w3030:?}");
6239 }
6240 }
6241
6242 #[test]
6243 fn case_insensitive_enum_flags_unlisted_value_with_marked_message() {
6244 let diags = diagnostics_for_mode_value("BOGUS");
6245 let w3030 = diags.iter().find(|d| d.rule_id == "W3030").expect("W3030 fires for a value not in the enum");
6246 assert_eq!(w3030.severity, rules::Severity::Warn);
6247 assert_eq!(w3030.property_path.as_deref(), Some("Properties.Mode"));
6248 assert_eq!(w3030.message, "'BOGUS' is not one of ['managed', 'unmanaged'] (case-insensitive)");
6249 }
6250
6251 fn store_with_bundled_schema(type_name: &str, raw_schema: serde_json::Value) -> CompiledSchemaStore {
6255 let mut store = CompiledSchemaStore::new();
6256 store.insert_schema(compile_schema_with(type_name, &raw_schema, RefSiblings::Ignore).into());
6257 store
6258 }
6259
6260 fn diagnostics_for_template(store: &CompiledSchemaStore, template: serde_json::Value) -> Vec<Diagnostic> {
6261 let model = Arc::new(SemanticModel::from_bytes(template.to_string().as_bytes()).expect("template parses"));
6262 validate_all_resources(store, &model, None)
6263 }
6264
6265 fn findings_by_resource(diagnostics: &[Diagnostic], rule_id: &str) -> Vec<(String, String)> {
6266 let mut findings: Vec<(String, String)> = diagnostics
6267 .iter()
6268 .filter(|d| d.rule_id == rule_id)
6269 .map(|d| (d.resource_logical_id().unwrap_or("").to_string(), d.message.clone()))
6270 .collect();
6271 findings.sort();
6272 findings
6273 }
6274
6275 const COGNITO_PREFIX_PATTERN: &str = "^[a-z0-9](?:[a-z0-9\\-]{0,61}[a-z0-9])?$";
6276 const COGNITO_CUSTOM_DOMAIN_PATTERN: &str =
6277 "^[a-z0-9](?:[a-z0-9\\-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9\\-]*[a-z0-9])?)+$";
6278
6279 fn cognito_user_pool_domain_schema() -> serde_json::Value {
6280 json!({
6281 "typeName": "AWS::Cognito::UserPoolDomain",
6282 "properties": {
6283 "Domain": { "type": "string", "minLength": 1, "maxLength": 63 },
6284 "UserPoolId": { "type": "string" },
6285 "CustomDomainConfig": { "type": "object", "properties": { "CertificateArn": { "type": "string" } } }
6286 },
6287 "allOf": [
6288 {
6289 "if": { "required": ["CustomDomainConfig"] },
6290 "then": { "properties": { "Domain": { "pattern": COGNITO_CUSTOM_DOMAIN_PATTERN } } }
6291 },
6292 {
6293 "if": { "not": { "required": ["CustomDomainConfig"] } },
6294 "then": { "properties": { "Domain": { "pattern": COGNITO_PREFIX_PATTERN } } }
6295 }
6296 ]
6297 })
6298 }
6299
6300 fn user_pool_domain(domain: &str, custom_domain_config: Option<serde_json::Value>) -> serde_json::Value {
6301 let mut properties = json!({ "UserPoolId": "us-east-1_example", "Domain": domain });
6302 if let Some(config) = custom_domain_config {
6303 properties["CustomDomainConfig"] = config;
6304 }
6305 json!({ "Type": "AWS::Cognito::UserPoolDomain", "Properties": properties })
6306 }
6307
6308 #[test]
6309 fn negated_condition_selects_the_domain_prefix_pattern_only_without_a_custom_domain() {
6310 let certificate = json!({ "CertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/abc" });
6311 let conditional_certificate = json!({ "Fn::If": ["UseCustomDomain", certificate, { "Ref": "AWS::NoValue" }] });
6312 let template = json!({
6313 "Conditions": { "UseCustomDomain": { "Fn::Equals": [{ "Ref": "AWS::Region" }, "us-east-1"] } },
6314 "Resources": {
6315 "PrefixLeadingHyphen": user_pool_domain("-myprefix", None),
6316 "PrefixValid": user_pool_domain("my-prefix1", None),
6317 "CustomWithoutDots": user_pool_domain("nodots", Some(certificate.clone())),
6318 "CustomValid": user_pool_domain("auth.example.com", Some(certificate.clone())),
6319 "ConditionalWithMatchingDomain": {
6320 "Type": "AWS::Cognito::UserPoolDomain",
6321 "Properties": {
6322 "UserPoolId": "us-east-1_example",
6323 "Domain": { "Fn::If": ["UseCustomDomain", "login.example.com", "login-prefix"] },
6324 "CustomDomainConfig": conditional_certificate
6325 }
6326 },
6327 "ConditionalWithFixedDomain": user_pool_domain("id.example.com", Some(conditional_certificate))
6328 }
6329 });
6330 let diagnostics = diagnostics_for_template(
6331 &store_with_bundled_schema("AWS::Cognito::UserPoolDomain", cognito_user_pool_domain_schema()),
6332 template,
6333 );
6334
6335 assert_eq!(
6336 findings_by_resource(&diagnostics, "E3031"),
6337 vec![
6338 (
6339 "ConditionalWithFixedDomain".to_string(),
6340 format!("'id.example.com' does not match pattern '{COGNITO_PREFIX_PATTERN}'")
6341 ),
6342 (
6343 "CustomWithoutDots".to_string(),
6344 format!("'nodots' does not match pattern '{COGNITO_CUSTOM_DOMAIN_PATTERN}'")
6345 ),
6346 (
6347 "PrefixLeadingHyphen".to_string(),
6348 format!("'-myprefix' does not match pattern '{COGNITO_PREFIX_PATTERN}'")
6349 ),
6350 ],
6351 "each branch applies its own pattern; a conditional custom domain config is decided per world, so a \
6352 fixed domain name is checked as a prefix in the world without it while a matching conditional domain passes"
6353 );
6354 let fixed_domain_finding = diagnostics
6355 .iter()
6356 .find(|d| d.rule_id == "E3031" && d.resource_logical_id() == Some("ConditionalWithFixedDomain"))
6357 .expect("the fixed domain is reported in the world without a custom domain");
6358 assert_eq!(
6359 fixed_domain_finding.condition_scenario,
6360 Some(HashMap::from([("UseCustomDomain".to_string(), false)]))
6361 );
6362 assert_eq!(fixed_domain_finding.severity, rules::Severity::Error);
6363 assert_eq!(fixed_domain_finding.property_path.as_deref(), Some("Properties.Domain"));
6364 }
6365
6366 fn lambda_function_schema() -> serde_json::Value {
6367 json!({
6368 "typeName": "AWS::Lambda::Function",
6369 "properties": {
6370 "Timeout": { "type": "integer", "minimum": 1, "maximum": 5400 },
6371 "CapacityProviderConfig": { "type": "object", "properties": { "CapacityProviderArn": { "type": "string" } } }
6372 },
6373 "allOf": [
6374 {
6375 "if": { "not": { "required": ["CapacityProviderConfig"] } },
6376 "then": { "properties": { "Timeout": { "maximum": 900 } } }
6377 }
6378 ]
6379 })
6380 }
6381
6382 #[test]
6383 fn lambda_timeout_above_900_is_reported_only_without_a_capacity_provider() {
6384 let template = json!({
6385 "Resources": {
6386 "TooLong": { "Type": "AWS::Lambda::Function", "Properties": { "Timeout": 901 } },
6387 "AtTheLimit": { "Type": "AWS::Lambda::Function", "Properties": { "Timeout": 900 } },
6388 "ManagedInstances": {
6389 "Type": "AWS::Lambda::Function",
6390 "Properties": { "Timeout": 901, "CapacityProviderConfig": { "CapacityProviderArn": "arn:aws:lambda:us-east-1:123456789012:capacity-provider/x" } }
6391 }
6392 }
6393 });
6394 let diagnostics = diagnostics_for_template(
6395 &store_with_bundled_schema("AWS::Lambda::Function", lambda_function_schema()),
6396 template,
6397 );
6398
6399 assert_eq!(
6400 findings_by_resource(&diagnostics, "E3717"),
6401 vec![(
6402 "TooLong".to_string(),
6403 "901 exceeds maximum 900 for a function without CapacityProviderConfig".to_string()
6404 )]
6405 );
6406 assert!(
6407 diagnostics.iter().all(|d| d.rule_id != "F3034"),
6408 "the unconditional 5400 bound is not exceeded, so no Fatal bound finding: {diagnostics:?}"
6409 );
6410 }
6411
6412 fn network_interface_schema_with_value_qualified_exclusion() -> serde_json::Value {
6413 json!({
6414 "typeName": "AWS::Test::NetworkInterface",
6415 "properties": {
6416 "Ipv6AddressCount": { "type": "integer" },
6417 "Ipv6Addresses": { "type": "array", "items": { "type": "string" } }
6418 },
6419 "allOf": [{
6420 "not": {
6421 "properties": {
6422 "Ipv6AddressCount": { "minimum": 1, "type": "integer" },
6423 "Ipv6Addresses": { "minItems": 1, "type": "array" }
6424 },
6425 "required": ["Ipv6AddressCount", "Ipv6Addresses"]
6426 }
6427 }]
6428 })
6429 }
6430
6431 #[test]
6432 fn negated_property_pair_reports_only_when_both_value_guards_match() {
6433 let template = json!({
6434 "Resources": {
6435 "Violating": {
6436 "Type": "AWS::Test::NetworkInterface",
6437 "Properties": { "Ipv6AddressCount": 1, "Ipv6Addresses": ["2001:db8::1"] }
6438 },
6439 "ZeroCount": {
6440 "Type": "AWS::Test::NetworkInterface",
6441 "Properties": { "Ipv6AddressCount": 0, "Ipv6Addresses": ["2001:db8::1"] }
6442 },
6443 "EmptyAddresses": {
6444 "Type": "AWS::Test::NetworkInterface",
6445 "Properties": { "Ipv6AddressCount": 1, "Ipv6Addresses": [] }
6446 },
6447 "CountOnly": {
6448 "Type": "AWS::Test::NetworkInterface",
6449 "Properties": { "Ipv6AddressCount": 1 }
6450 },
6451 "AddressesOnly": {
6452 "Type": "AWS::Test::NetworkInterface",
6453 "Properties": { "Ipv6Addresses": ["2001:db8::1"] }
6454 }
6455 }
6456 });
6457 let diagnostics = diagnostics_for_template(
6458 &store_with_bundled_schema(
6459 "AWS::Test::NetworkInterface",
6460 network_interface_schema_with_value_qualified_exclusion(),
6461 ),
6462 template,
6463 );
6464 let mut exclusions: Vec<(&str, &str)> = diagnostics
6465 .iter()
6466 .filter(|diagnostic| diagnostic.rule_id == "F3020")
6467 .map(|diagnostic| {
6468 (diagnostic.resource_logical_id().unwrap_or(""), diagnostic.property_path.as_deref().unwrap_or(""))
6469 })
6470 .collect();
6471 exclusions.sort_unstable();
6472
6473 assert_eq!(
6474 exclusions,
6475 vec![("Violating", "Properties.Ipv6AddressCount"), ("Violating", "Properties.Ipv6Addresses")]
6476 );
6477 }
6478
6479 fn load_balancer_schema_with_dependency_under_exclusive_condition() -> serde_json::Value {
6480 json!({
6481 "typeName": "AWS::Test::LoadBalancer",
6482 "properties": {
6483 "Type": { "type": "string" },
6484 "Subnets": { "type": "array", "items": { "type": "string" } },
6485 "SecurityGroups": { "type": "array", "items": { "type": "string" } }
6486 },
6487 "allOf": [
6488 {
6489 "if": { "oneOf": [
6490 { "properties": { "Type": { "const": "application" } }, "required": ["Type"] },
6491 { "properties": { "Type": false } }
6492 ] },
6493 "then": { "dependentRequired": { "Subnets": ["SecurityGroups"] } }
6494 }
6495 ]
6496 })
6497 }
6498
6499 #[test]
6500 fn exclusive_condition_with_an_absent_property_branch_matches_default_and_explicit_application_types() {
6501 let template = json!({
6502 "Resources": {
6503 "DefaultType": { "Type": "AWS::Test::LoadBalancer", "Properties": { "Subnets": ["subnet-a"] } },
6504 "Application": { "Type": "AWS::Test::LoadBalancer", "Properties": { "Type": "application", "Subnets": ["subnet-a"] } },
6505 "Network": { "Type": "AWS::Test::LoadBalancer", "Properties": { "Type": "network", "Subnets": ["subnet-a"] } }
6506 }
6507 });
6508 let store = store_with_bundled_schema(
6509 "AWS::Test::LoadBalancer",
6510 load_balancer_schema_with_dependency_under_exclusive_condition(),
6511 );
6512 let diagnostics = diagnostics_for_template(&store, template);
6513
6514 let dependency_findings: Vec<&str> = {
6515 let mut resources: Vec<&str> =
6516 diagnostics.iter().filter(|d| d.rule_id == "F3021").filter_map(|d| d.resource_logical_id()).collect();
6517 resources.sort();
6518 resources
6519 };
6520 assert_eq!(dependency_findings, vec!["Application", "DefaultType"], "{diagnostics:?}");
6521 }
6522
6523 const CONSTRAINTS_OWNED_BY_DEDICATED_RULES: [(&str, &str); 7] = [
6530 ("AWS::ApiGateway::Stage", "ResourcePath"),
6531 ("AWS::EC2::Instance", "VirtualName"),
6532 ("AWS::ECS::Service", "SchedulingStrategy"),
6533 ("AWS::ElasticLoadBalancingV2::LoadBalancer", "SubnetMappings"),
6534 ("AWS::ElasticLoadBalancingV2::LoadBalancer", "Subnets"),
6535 ("AWS::Lambda::Function", "Runtime"),
6536 ("AWS::RDS::DBCluster", "MonitoringInterval"),
6537 ];
6538
6539 fn branch_property_states_a_scalar_constraint(property: &PropSchema) -> bool {
6543 schema_for_conditional_value_constraints(property).constrains_value()
6544 }
6545
6546 #[test]
6550 fn every_bundled_conditional_value_constraint_has_an_owner() {
6551 let store = CompiledSchemaStore::new();
6552 let mut unowned = Vec::new();
6553 for type_name in store.type_names() {
6554 let Some(schema) = store.get(type_name) else {
6555 continue;
6556 };
6557 for conditional in &schema.if_then_else {
6558 for branch in conditional.then_schema.iter().chain(conditional.else_schema.iter()) {
6559 for (property_name, property) in &branch.properties {
6560 if !branch_property_states_a_scalar_constraint(&property.resolve(&schema.definitions)) {
6561 continue;
6562 }
6563 let key = (type_name, property_name.as_str());
6564 let is_owned = conditional_constraint_rule(type_name, property_name).is_some()
6565 || CONSTRAINTS_OWNED_BY_DEDICATED_RULES.contains(&key);
6566 if !is_owned {
6567 unowned.push(format!("{type_name}.{property_name}"));
6568 }
6569 }
6570 }
6571 }
6572 }
6573 unowned.sort();
6574 assert!(
6575 unowned.is_empty(),
6576 "bundled conditional value constraints without an owning rule (add one to CONDITIONAL_CONSTRAINT_RULES or implement a native rule): {unowned:?}"
6577 );
6578 }
6579
6580 #[test]
6581 fn a_condition_stating_nothing_never_selects_its_branch() {
6582 let mut properties = HashMap::new();
6583 properties.insert("Subnets".to_string(), PropSchema::default());
6584 properties.insert("SecurityGroups".to_string(), PropSchema::default());
6585 let mut store = CompiledSchemaStore::new();
6586 store.insert_schema(CompiledSchema {
6587 type_name: "AWS::Test::Stale".to_string(),
6588 properties,
6589 if_then_else: vec![IfThenElse {
6590 condition: ConditionSchema::default(),
6591 then_schema: Some(SubSchema {
6592 dependent_required: HashMap::from([("Subnets".to_string(), vec!["SecurityGroups".to_string()])]),
6593 ..Default::default()
6594 }),
6595 else_schema: None,
6596 enforce_full_branch: false,
6597 }],
6598 ..Default::default()
6599 });
6600 let template = json!({
6601 "Resources": { "Stale": { "Type": "AWS::Test::Stale", "Properties": { "Subnets": ["subnet-a"] } } }
6602 });
6603
6604 let diagnostics = diagnostics_for_template(&store, template);
6605 assert!(diagnostics.iter().all(|d| d.rule_id != "F3021"), "{diagnostics:?}");
6606 }
6607
6608 #[test]
6609 fn format_value_wraps_strings_in_quotes() {
6610 assert_eq!(format_value(&json!("hello")), "'hello'");
6611 }
6612
6613 #[test]
6614 fn format_value_renders_non_strings_directly() {
6615 assert_eq!(format_value(&json!(42)), "42");
6616 assert_eq!(format_value(&json!(true)), "true");
6617 assert_eq!(format_value(&json!(null)), "null");
6618 }
6619
6620 #[test]
6621 fn single_type_compatible_same_type() {
6622 assert!(single_type_compatible("string", "string"));
6623 assert!(single_type_compatible("integer", "integer"));
6624 }
6625
6626 #[test]
6627 fn single_type_compatible_integer_number_interchangeable() {
6628 assert!(single_type_compatible("integer", "number"));
6629 assert!(single_type_compatible("number", "integer"));
6630 }
6631
6632 #[test]
6633 fn single_type_compatible_string_to_number_coercion() {
6634 assert!(single_type_compatible("string", "number"));
6635 assert!(single_type_compatible("string", "integer"));
6636 }
6637
6638 #[test]
6639 fn single_type_compatible_number_to_string_coercion() {
6640 assert!(single_type_compatible("number", "string"));
6641 assert!(single_type_compatible("integer", "string"));
6642 assert!(single_type_compatible("boolean", "string"));
6643 }
6644
6645 #[test]
6646 fn single_type_compatible_null_always_compatible() {
6647 assert!(single_type_compatible("string", "null"));
6648 assert!(single_type_compatible("array", "null"));
6649 }
6650
6651 #[test]
6652 fn single_type_compatible_incompatible() {
6653 assert!(!single_type_compatible("string", "array"));
6654 assert!(!single_type_compatible("array", "object"));
6655 assert!(!single_type_compatible("boolean", "integer"));
6656 }
6657
6658 #[test]
6659 fn param_type_number() {
6660 assert_eq!(cfn_param_type_to_schema_type("Number"), "number");
6661 }
6662
6663 #[test]
6664 fn param_type_string() {
6665 assert_eq!(cfn_param_type_to_schema_type("String"), "string");
6666 }
6667
6668 #[test]
6669 fn param_type_comma_delimited_list() {
6670 assert_eq!(cfn_param_type_to_schema_type("CommaDelimitedList"), "array");
6671 }
6672
6673 #[test]
6674 fn param_type_list_prefix() {
6675 assert_eq!(cfn_param_type_to_schema_type("List<Number>"), "array");
6676 assert_eq!(cfn_param_type_to_schema_type("List<AWS::EC2::Subnet::Id>"), "array");
6677 }
6678
6679 #[test]
6680 fn param_type_ssm_parameter() {
6681 assert_eq!(cfn_param_type_to_schema_type("AWS::SSM::Parameter::Value<String>"), "string");
6682 }
6683
6684 #[test]
6685 fn param_type_unknown_defaults_to_string() {
6686 assert_eq!(cfn_param_type_to_schema_type("AWS::EC2::VPC::Id"), "string");
6687 }
6688
6689 #[test]
6690 fn resolve_json_pointer_root() {
6691 let data = json!({"a": 1});
6692 let result = resolve_json_pointer(&data, "/");
6693 assert_eq!(result, json!({"a": 1}));
6694 }
6695
6696 #[test]
6697 fn resolve_json_pointer_nested() {
6698 let data = json!({"a": {"b": {"c": 42}}});
6699 assert_eq!(resolve_json_pointer(&data, "/a/b/c"), json!(42));
6700 }
6701
6702 #[test]
6703 fn resolve_json_pointer_missing_key() {
6704 let data = json!({"a": 1});
6705 assert_eq!(resolve_json_pointer(&data, "/b"), json!(null));
6706 }
6707
6708 #[test]
6709 fn resolve_json_pointer_array_index() {
6710 let data = json!({"items": [10, 20, 30]});
6711 assert_eq!(resolve_json_pointer(&data, "/items/1"), json!(20));
6712 }
6713
6714 #[test]
6715 fn resolve_json_pointer_array_out_of_bounds() {
6716 let data = json!({"items": [10]});
6717 assert_eq!(resolve_json_pointer(&data, "/items/5"), json!(null));
6718 }
6719
6720 #[test]
6721 fn resolve_json_pointer_non_numeric_index_on_array() {
6722 let data = json!([1, 2, 3]);
6723 assert_eq!(resolve_json_pointer(&data, "/abc"), json!(null));
6724 }
6725
6726 #[test]
6727 fn resolve_json_pointer_scalar_intermediate() {
6728 let data = json!({"a": "string_value"});
6729 assert_eq!(resolve_json_pointer(&data, "/a/b"), json!(null));
6730 }
6731
6732 #[test]
6733 fn resolve_data_simple_reference() {
6734 let schema = json!({"$data": "/source/protocol"});
6735 let context = json!({"source": {"protocol": "tcp"}});
6736 assert_eq!(resolve_data_in_schema(&schema, &context), json!("tcp"));
6737 }
6738
6739 #[test]
6740 fn resolve_data_missing_reference() {
6741 let schema = json!({"$data": "/missing/path"});
6742 let context = json!({"source": {"protocol": "tcp"}});
6743 assert_eq!(resolve_data_in_schema(&schema, &context), json!(null));
6744 }
6745
6746 #[test]
6747 fn resolve_data_nested_object() {
6748 let schema = json!({
6749 "properties": {
6750 "port": {"const": {"$data": "/source/port"}}
6751 }
6752 });
6753 let context = json!({"source": {"port": 443}});
6754 let resolved = resolve_data_in_schema(&schema, &context);
6755 assert_eq!(resolved["properties"]["port"]["const"], json!(443));
6756 }
6757
6758 #[test]
6759 fn resolve_data_array() {
6760 let schema = json!([{"$data": "/a"}, {"$data": "/b"}]);
6761 let context = json!({"a": 1, "b": 2});
6762 assert_eq!(resolve_data_in_schema(&schema, &context), json!([1, 2]));
6763 }
6764
6765 #[test]
6766 fn resolve_data_non_object_passthrough() {
6767 assert_eq!(resolve_data_in_schema(&json!("plain"), &json!({})), json!("plain"));
6768 assert_eq!(resolve_data_in_schema(&json!(42), &json!({})), json!(42));
6769 assert_eq!(resolve_data_in_schema(&json!(null), &json!({})), json!(null));
6770 }
6771
6772 #[test]
6773 fn resolve_data_lookup() {
6774 let schema = json!({
6775 "$lookup": {
6776 "key": {"$data": "/source/engine"},
6777 "map": {
6778 "aurora": {"const": 3306},
6779 "redis": {"const": 6379}
6780 }
6781 }
6782 });
6783 let context = json!({"source": {"engine": "aurora"}});
6784 assert_eq!(resolve_data_in_schema(&schema, &context), json!({"const": 3306}));
6785 }
6786
6787 #[test]
6788 fn resolve_data_lookup_missing_key() {
6789 let schema = json!({
6790 "$lookup": {
6791 "key": {"$data": "/source/engine"},
6792 "map": {"aurora": 3306}
6793 }
6794 });
6795 let context = json!({"source": {"engine": "postgres"}});
6796 assert_eq!(resolve_data_in_schema(&schema, &context), json!(null));
6797 }
6798
6799 #[test]
6800 fn types_compatible_single() {
6801 assert!(types_compatible("string", &PropType::Single("string".into())));
6802 assert!(!types_compatible("array", &PropType::Single("string".into())));
6803 }
6804
6805 #[test]
6806 fn types_compatible_multi() {
6807 let pt = PropType::Multi(vec!["string".into(), "null".into()]);
6808 assert!(types_compatible("string", &pt));
6809 assert!(types_compatible("integer", &pt));
6810 assert!(!types_compatible("array", &PropType::Multi(vec!["string".into(), "integer".into()])));
6811 }
6812
6813 #[test]
6814 fn single_type_double_and_float_are_number() {
6815 assert!(single_type(&json!(3.14), "double"));
6816 assert!(single_type(&json!(3.14), "float"));
6817 assert!(single_type(&json!(42), "double"));
6818 }
6819
6820 #[test]
6821 fn single_type_integer_rejects_fractional() {
6822 assert!(!single_type(&json!(3.14), "integer"));
6823 }
6824
6825 #[test]
6826 fn single_type_integer_accepts_whole_float() {
6827 assert!(single_type(&json!(5.0), "integer"));
6828 }
6829
6830 #[test]
6831 fn find_prop_schema_direct_lookup() {
6832 let mut props = HashMap::new();
6833 props.insert(
6834 "Name".into(),
6835 PropSchema { prop_type: Some(PropType::Single("string".into())), ..Default::default() },
6836 );
6837 let defs = HashMap::new();
6838 let result = find_prop_schema("Name", &props, &defs).expect("Name should be found");
6839 assert_eq!(result.prop_type.as_ref().unwrap().primary(), Some("string"));
6840 }
6841
6842 #[test]
6843 fn find_prop_schema_nested_path() {
6844 let inner = PropSchema { prop_type: Some(PropType::Single("integer".into())), ..Default::default() };
6845 let mut inner_props = HashMap::new();
6846 inner_props.insert("Port".into(), inner);
6847 let outer = PropSchema { properties: inner_props, ..Default::default() };
6848 let mut props = HashMap::new();
6849 props.insert("Config".into(), outer);
6850 let defs = HashMap::new();
6851 let result = find_prop_schema("Config.Port", &props, &defs).expect("Config.Port should be found");
6852 assert_eq!(result.prop_type.as_ref().unwrap().primary(), Some("integer"));
6853 }
6854
6855 #[test]
6856 fn find_prop_schema_missing_returns_none() {
6857 let props = HashMap::new();
6858 let defs = HashMap::new();
6859 assert!(find_prop_schema("Missing", &props, &defs).is_none(), "missing prop should return None");
6860 }
6861
6862 #[test]
6863 fn find_prop_schema_resolves_ref() {
6864 let mut props = HashMap::new();
6865 props.insert("Config".into(), PropSchema { ref_name: Some("ConfigDef".into()), ..Default::default() });
6866 let mut defs = HashMap::new();
6867 defs.insert(
6868 "ConfigDef".into(),
6869 PropSchema { prop_type: Some(PropType::Single("object".into())), ..Default::default() },
6870 );
6871 let result = find_prop_schema("Config", &props, &defs).expect("Config should be found");
6872 assert_eq!(result.prop_type.as_ref().unwrap().primary(), Some("object"));
6873 }
6874
6875 #[test]
6876 fn find_prop_schema_deep_direct() {
6877 let mut props = HashMap::new();
6878 props.insert(
6879 "Name".into(),
6880 PropSchema { prop_type: Some(PropType::Single("string".into())), ..Default::default() },
6881 );
6882 let schema = CompiledSchema { type_name: "Test".into(), properties: props, ..Default::default() };
6883 assert!(find_prop_schema_deep("Name", &schema).is_some(), "direct property should be found");
6884 }
6885
6886 #[test]
6887 fn find_prop_schema_deep_searches_one_of() {
6888 let mut sub_props = HashMap::new();
6889 sub_props.insert(
6890 "Special".into(),
6891 PropSchema { prop_type: Some(PropType::Single("boolean".into())), ..Default::default() },
6892 );
6893 let schema = CompiledSchema {
6894 type_name: "Test".into(),
6895 one_of: vec![crate::compiled::SubSchema { properties: sub_props, ..Default::default() }],
6896 ..Default::default()
6897 };
6898 let result = find_prop_schema_deep("Special", &schema);
6899 assert!(result.is_some(), "expected to find Special in oneOf sub-schema");
6900 assert_eq!(result.unwrap().prop_type.as_ref().unwrap().primary(), Some("boolean"));
6901 }
6902
6903 #[test]
6904 fn find_prop_schema_deep_searches_if_then_else() {
6905 let mut then_props = HashMap::new();
6906 then_props.insert(
6907 "ConditionalProp".into(),
6908 PropSchema { prop_type: Some(PropType::Single("string".into())), ..Default::default() },
6909 );
6910 let schema = CompiledSchema {
6911 type_name: "Test".into(),
6912 if_then_else: vec![crate::compiled::IfThenElse {
6913 condition: Default::default(),
6914 then_schema: Some(crate::compiled::SubSchema { properties: then_props, ..Default::default() }),
6915 else_schema: None,
6916 enforce_full_branch: false,
6917 }],
6918 ..Default::default()
6919 };
6920 assert!(
6921 find_prop_schema_deep("ConditionalProp", &schema).is_some(),
6922 "conditional property should be found via if-then-else"
6923 );
6924 }
6925
6926 #[test]
6927 fn condition_matches_empty_condition_always_true() {
6928 let cond = ConditionSchema::default();
6929 let keys = vec!["A".into(), "B".into()];
6930 let model = Arc::new(SemanticModel::from_bytes(
6931 b"AWSTemplateFormatVersion: '2010-09-09'\nResources:\n R:\n Type: AWS::CloudFormation::WaitConditionHandle"
6932 ).unwrap());
6933 let defs = HashMap::new();
6934 assert!(condition_matches(&cond, &keys, &model, "R", &defs));
6935 }
6936
6937 #[test]
6938 fn condition_matches_required_key_missing() {
6939 let cond = ConditionSchema { required: vec!["MissingKey".into()], ..Default::default() };
6940 let keys = vec!["A".into()];
6941 let model = Arc::new(SemanticModel::from_bytes(
6942 b"AWSTemplateFormatVersion: '2010-09-09'\nResources:\n R:\n Type: AWS::CloudFormation::WaitConditionHandle"
6943 ).unwrap());
6944 let defs = HashMap::new();
6945 assert!(!condition_matches(&cond, &keys, &model, "R", &defs));
6946 }
6947
6948 #[test]
6949 fn schema_value_matches_required_groups_with_null_members_absent() {
6950 let defs = HashMap::new();
6951 let required_or = PropSchema { required_or: vec!["A".into(), "B".into()], ..Default::default() };
6952 assert!(schema_value_matches(&json!({ "A": 1 }), &required_or, &defs, 0));
6953 assert!(!schema_value_matches(&json!({ "A": null }), &required_or, &defs, 0));
6954
6955 let required_xor = PropSchema { required_xor: vec!["A".into(), "B".into()], ..Default::default() };
6956 assert!(schema_value_matches(&json!({ "A": 1, "B": null }), &required_xor, &defs, 0));
6957 assert!(!schema_value_matches(&json!({ "A": 1, "B": 2 }), &required_xor, &defs, 0));
6958 assert!(!schema_value_matches(&json!({ "A": null, "B": null }), &required_xor, &defs, 0));
6959 }
6960
6961 #[test]
6962 fn gather_condition_matches_empty_true() {
6963 let cond = json!({});
6964 let ctx = json!({"a": 1});
6965 assert!(gather_condition_matches(&cond, &ctx));
6966 }
6967
6968 #[test]
6969 fn gather_condition_matches_required_present() {
6970 let cond = json!({"required": ["a"]});
6971 let ctx = json!({"a": 1});
6972 assert!(gather_condition_matches(&cond, &ctx));
6973 }
6974
6975 #[test]
6976 fn gather_condition_matches_required_missing() {
6977 let cond = json!({"required": ["b"]});
6978 let ctx = json!({"a": 1});
6979 assert!(!gather_condition_matches(&cond, &ctx));
6980 }
6981
6982 #[test]
6983 fn gather_prop_matches_const() {
6984 assert!(gather_prop_matches(&json!("tcp"), &json!({"const": "tcp"})));
6985 assert!(!gather_prop_matches(&json!("udp"), &json!({"const": "tcp"})));
6986 }
6987
6988 #[test]
6989 fn gather_prop_matches_enum() {
6990 assert!(gather_prop_matches(&json!("a"), &json!({"enum": ["a", "b"]})));
6991 assert!(!gather_prop_matches(&json!("c"), &json!({"enum": ["a", "b"]})));
6992 }
6993
6994 #[test]
6995 fn gather_prop_matches_nested_properties() {
6996 let actual = json!({"inner": {"key": "val"}});
6997 let constraint = json!({"properties": {"inner": {"properties": {"key": {"const": "val"}}}}});
6998 assert!(gather_prop_matches(&actual, &constraint));
6999 }
7000
7001 fn property_names(prefix: &str, count: usize) -> Vec<String> {
7002 (0..count).map(|index| format!("{prefix}{index}")).collect()
7003 }
7004
7005 #[test]
7006 fn required_property_combinations_accept_exact_cardinality_boundary() {
7007 let branch = SubSchema {
7008 required_or: property_names("Or", 16),
7009 required_xor: property_names("Xor", 16),
7010 ..Default::default()
7011 };
7012 let (combinations, curtailed) = required_property_combinations(&branch, &HashMap::new(), None);
7013 assert_eq!(combinations.len(), MAX_REQUIRED_PROPERTY_COMBINATIONS);
7014 assert!(!curtailed, "exactly 16 × 16 combinations must be retained in full");
7015 }
7016
7017 #[test]
7018 fn required_property_combinations_report_one_over_boundary() {
7019 let branch = SubSchema {
7020 required_or: property_names("Or", 17),
7021 required_xor: property_names("Xor", 16),
7022 ..Default::default()
7023 };
7024 let (combinations, curtailed) = required_property_combinations(&branch, &HashMap::new(), None);
7025 assert_eq!(combinations.len(), MAX_REQUIRED_PROPERTY_COMBINATIONS);
7026 assert!(curtailed, "17 × 16 combinations must visibly hit the explanation limit");
7027 }
7028}