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