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::{BTreeSet, HashMap, HashSet};
7use std::sync::{Arc, LazyLock};
8use template_model::coercion::{CoerceResult, coerce_to_number, coerce_to_string, coerce_value, scalar_eq};
9use template_model::conditions::Satisfiability;
10use template_model::consts::{
11 FN_CONDITION, FN_FOR_EACH_KEY_PREFIX, FN_IF, FN_REF, INTRINSIC_FN_PATH_SEGMENTS, KEY_PROPERTIES, KEY_TYPE,
12 MAX_REQUIRED_PROPERTY_COMBINATIONS, MAX_SCHEMA_MATCH_DEPTH as MAX_MATCH_DEPTH,
13 MAX_SCHEMA_SCENARIO_ASSIGNMENTS as MAX_GROUP_SCENARIO_ASSIGNMENTS,
14 MAX_SCHEMA_SCENARIO_MERGE_ATTEMPTS as MAX_GROUP_SCENARIO_MERGE_ATTEMPTS, PARAM_TYPE_COMMA_DELIMITED_LIST,
15 PARAM_TYPE_NUMBER, PARAM_TYPE_STRING, SAM_FUNCTION_TYPE, SAM_SERVERLESS_TYPE_PREFIX,
16};
17use template_model::message::{render_str_list, render_value, render_value_list};
18use template_model::model::ResolvedResource;
19use template_model::region_enums;
20use template_model::resolver::{RefKind, ResolvedValue};
21use template_model::{
22 BudgetKind, CompiledPattern, IAM_ROLE_ARN_PATTERN, SECURITY_GROUP_NAME_PATTERN, SemanticModel, compile_pattern,
23 is_custom_resource_type, resolved_value_to_json,
24};
25
26const PACKAGING_PROPERTY_PATHS: &[(&str, &str)] = &[
29 ("AWS::Lambda::Function", "Properties.Code"),
30 ("AWS::Lambda::LayerVersion", "Properties.Content"),
31 ("AWS::ApiGateway::RestApi", "Properties.BodyS3Location"),
32 ("AWS::ElasticBeanstalk::ApplicationVersion", "Properties.SourceBundle"),
33 ("AWS::StepFunctions::StateMachine", "Properties.DefinitionS3Location"),
34 ("AWS::AppSync::GraphQLSchema", "Properties.DefinitionS3Location"),
35 ("AWS::AppSync::Resolver", "Properties.RequestMappingTemplateS3Location"),
36 ("AWS::AppSync::Resolver", "Properties.ResponseMappingTemplateS3Location"),
37 ("AWS::AppSync::FunctionConfiguration", "Properties.RequestMappingTemplateS3Location"),
38 ("AWS::AppSync::FunctionConfiguration", "Properties.ResponseMappingTemplateS3Location"),
39 ("AWS::CloudFormation::Stack", "Properties.TemplateURL"),
40 ("AWS::CodeCommit::Repository", "Properties.Code.S3"),
41];
42
43const TYPE_CHECK_EXEMPT_PATHS: &[(&str, &str)] = &[
46 ("AWS::Lambda::Function", "Properties.Environment.Variables"),
47 ("AWS::Lambda::Function", "Properties.Environment"),
48];
49
50fn is_intrinsic_key(key: &str) -> bool {
57 INTRINSIC_FN_PATH_SEGMENTS.contains(&key)
58 || key == FN_REF
59 || key == FN_CONDITION
60 || key.starts_with(FN_FOR_EACH_KEY_PREFIX)
61}
62
63fn is_unresolved_intrinsic(val: &serde_json::Value) -> bool {
64 let Some(obj) = val.as_object() else { return false };
65 if obj.len() != 1 {
66 return false;
67 }
68 is_intrinsic_key(obj.keys().next().unwrap())
69}
70
71fn condition_context_matches_scenario(
72 m: &Arc<SemanticModel>,
73 context: Option<&str>,
74 scenario: &HashMap<String, bool>,
75) -> bool {
76 let Some(context) = context else {
77 return true;
78 };
79 let mut combined = scenario.clone();
80 for assumption in context.split(',') {
81 let (condition, expected) = assumption.strip_prefix('!').map_or((assumption, true), |name| (name, false));
82 if let Some(actual) = combined.insert(condition.to_string(), expected)
83 && actual != expected
84 {
85 return false;
86 }
87 }
88 is_satisfiable(m, &combined)
89}
90
91fn value_depends_on_parameter_in_scenario(
96 m: &Arc<SemanticModel>,
97 rid: &str,
98 prop_path: &str,
99 scenario: &HashMap<String, bool>,
100) -> bool {
101 let descendant_prefix = format!("{prop_path}.");
102 m.graph.outgoing_edges(rid).any(|edge| {
103 (edge.source_path == prop_path || edge.source_path.starts_with(&descendant_prefix))
104 && m.parameters.contains_key(&edge.target)
105 && condition_context_matches_scenario(m, edge.condition_context.as_deref(), scenario)
106 })
107}
108
109fn defer_value_constraints(
110 m: &Arc<SemanticModel>,
111 rid: &str,
112 prop_path: &str,
113 val: &serde_json::Value,
114 scenario: &HashMap<String, bool>,
115) -> bool {
116 if value_depends_on_parameter_in_scenario(m, rid, prop_path, scenario) {
117 return true;
118 }
119
120 let mut pending = vec![(prop_path.to_string(), val)];
121 while let Some((path, candidate)) = pending.pop() {
122 if is_unresolved_intrinsic(candidate)
123 || (m.is_from_intrinsic(rid, &path)
124 && coerce_to_string(candidate).is_some_and(|value| value.contains("${")))
125 {
126 return true;
127 }
128
129 match candidate {
130 serde_json::Value::Array(items) => {
131 pending.extend(
132 items
133 .iter()
134 .enumerate()
135 .map(|(index, item)| (append_property_path(&path, &index.to_string()), item)),
136 );
137 }
138 serde_json::Value::Object(properties) => {
139 pending
140 .extend(properties.iter().map(|(property, value)| (append_property_path(&path, property), value)));
141 }
142 _ => {}
143 }
144 }
145 false
146}
147
148pub fn validate_all_resources(
149 store: &CompiledSchemaStore,
150 model: &Arc<SemanticModel>,
151 region: Option<&str>,
152) -> Vec<Diagnostic> {
153 reset_scenario_filter();
154 reset_schema_budget_exhaustions();
155 let mut out = Vec::new();
156 let relevant: HashSet<&str> = model.resources_by_type.keys().map(String::as_str).collect();
157
158 validate_lifecycle(&mut out, store, model);
159
160 for rtype in &relevant {
161 if rtype.ends_with("::MODULE") || rtype.starts_with("Custom::") || rtype.starts_with(SAM_SERVERLESS_TYPE_PREFIX)
166 {
167 continue;
168 }
169
170 if let Some(region) = region
179 && store.is_known_in_any_region(rtype)
180 && !store.is_available_in_region(rtype, region)
181 {
182 for rid in model.resources_of_type(rtype) {
183 if let Some(res) = model.resources.get(rid.as_str())
192 && let Some(cond) = res.condition.as_deref()
193 && !model.conditions.is_satisfiable_in_region(&[(cond.to_string(), true)], region)
194 {
195 continue;
196 }
197 out.push(build_diagnostic(
200 "F3006",
201 &format!("Resource type '{}' does not exist in '{}'", rtype, region),
202 model,
203 rid,
204 KEY_TYPE,
205 None,
206 ));
207 }
208 continue;
209 }
210
211 let Some(schema) = store.get(rtype) else {
212 continue;
213 };
214 for rid in model.resources_of_type(rtype) {
215 let Some(res) = model.resources.get(rid.as_str()) else {
216 continue;
217 };
218 if !is_valid_logical_id(rid) {
224 continue;
225 }
226 if rtype.starts_with(SAM_SERVERLESS_TYPE_PREFIX) {
232 continue;
233 }
234 validate_resource(&mut out, store, model, rid, res, schema, region);
235 validate_extensions(&mut out, store, model, rid, res);
236 }
237 }
238 drain_schema_budget_exhaustions(model);
239 out
240}
241
242fn is_valid_logical_id(rid: &str) -> bool {
246 !rid.is_empty() && rid.bytes().all(|b| b.is_ascii_alphanumeric())
247}
248
249pub fn enrich_schema_context(diagnostics: &mut [Diagnostic], store: &CompiledSchemaStore, model: &Arc<SemanticModel>) {
250 reset_scenario_filter();
251 for d in diagnostics.iter_mut() {
252 if d.phase != Some(Phase::Schema) {
253 continue;
254 }
255 let Some(rid) = d.resource_logical_id().map(String::from) else {
256 continue;
257 };
258 let Some(res) = model.resources.get(rid.as_str()) else {
259 continue;
260 };
261 let Some(schema) = store.get(&res.resource_type) else {
262 continue;
263 };
264
265 if d.documentation_url.is_none() {
266 if let Some(ref url) = schema.documentation_url {
267 d.documentation_url = Some(url.clone());
268 } else if let Some(ref url) = schema.source_url {
269 d.documentation_url = Some(url.clone());
270 }
271 }
272
273 if let Some(source) = describe_resolution(model, &rid, d.property_path.as_deref().unwrap_or("")) {
274 let ctx = d.context.get_or_insert_with(|| ViolationContext {
275 actual_value: None,
276 expected_constraint: None,
277 property: None,
278 lifecycle: None,
279 resolution_source: None,
280 extra: None,
281 });
282 if ctx.resolution_source.is_none() {
283 ctx.resolution_source = Some(source);
284 }
285 }
286
287 let pp = d.property_path.as_deref().unwrap_or("");
288 let prop_path = pp.strip_prefix("Properties.").unwrap_or(pp);
289 let prop_schema = find_prop_schema_deep(prop_path, schema);
290
291 macro_rules! ensure_ctx {
292 ($d:expr) => {
293 $d.context.get_or_insert_with(|| ViolationContext {
294 actual_value: None,
295 expected_constraint: None,
296 property: None,
297 lifecycle: None,
298 resolution_source: None,
299 extra: None,
300 })
301 };
302 }
303
304 match d.rule_id.as_str() {
305 "F3012" => {
306 if let Some(ps) = prop_schema
307 && let Some(ref pt) = ps.prop_type
308 {
309 ensure_ctx!(d).expected_constraint = Some(pt.primary().unwrap_or("unknown").to_string());
310 }
311 }
312 "F3030" | "W3030" => {
313 if let Some(ps) = prop_schema {
314 let allowed = if !ps.enum_values.is_empty() { &ps.enum_values } else { &ps.enum_case_insensitive };
315 if !allowed.is_empty() {
316 ensure_ctx!(d)
317 .extra
318 .get_or_insert_with(HashMap::new)
319 .insert("allowed_values".into(), serde_json::json!(allowed).into());
320 }
321 }
322 }
323 "F3031" => {
324 if let Some(ps) = prop_schema
325 && let Some(ref pat) = ps.pattern
326 {
327 ensure_ctx!(d).expected_constraint = Some(pat.clone());
328 }
329 }
330 "F3034" => {
331 if let Some(ps) = prop_schema {
332 let ctx = ensure_ctx!(d);
333 if let Some(v) = ps.minimum {
334 ctx.extra
335 .get_or_insert_with(HashMap::new)
336 .insert("minimum".into(), serde_json::json!(v).into());
337 }
338 if let Some(v) = ps.maximum {
339 ctx.extra
340 .get_or_insert_with(HashMap::new)
341 .insert("maximum".into(), serde_json::json!(v).into());
342 }
343 if let Some(v) = ps.exclusive_minimum {
344 ctx.extra
345 .get_or_insert_with(HashMap::new)
346 .insert("exclusive_minimum".into(), serde_json::json!(v).into());
347 }
348 if let Some(v) = ps.exclusive_maximum {
349 ctx.extra
350 .get_or_insert_with(HashMap::new)
351 .insert("exclusive_maximum".into(), serde_json::json!(v).into());
352 }
353 }
354 }
355 "F3033" => {
356 if let Some(ps) = prop_schema {
357 let ctx = ensure_ctx!(d);
358 if let Some(v) = ps.min_length {
359 ctx.extra
360 .get_or_insert_with(HashMap::new)
361 .insert("min_length".into(), serde_json::json!(v).into());
362 }
363 if let Some(v) = ps.max_length {
364 ctx.extra
365 .get_or_insert_with(HashMap::new)
366 .insert("max_length".into(), serde_json::json!(v).into());
367 }
368 }
369 }
370 "F3032" => {
371 if let Some(ps) = prop_schema {
372 let ctx = ensure_ctx!(d);
373 if let Some(v) = ps.min_items {
374 ctx.extra
375 .get_or_insert_with(HashMap::new)
376 .insert("min_items".into(), serde_json::json!(v).into());
377 }
378 if let Some(v) = ps.max_items {
379 ctx.extra
380 .get_or_insert_with(HashMap::new)
381 .insert("max_items".into(), serde_json::json!(v).into());
382 }
383 }
384 }
385 "F3002" => {
386 let mut allowed: Vec<&str> = schema.properties.keys().map(|s| s.as_str()).collect();
387 allowed.sort();
388 ensure_ctx!(d)
389 .extra
390 .get_or_insert_with(HashMap::new)
391 .insert("allowed_properties".into(), serde_json::json!(allowed).into());
392 }
393 "W9009" => {
394 ensure_ctx!(d).lifecycle = Some("deprecated".into());
395 }
396 "I9001" => {
397 let ctx = ensure_ctx!(d);
398 let lifecycle = if schema.create_only_properties.iter().any(|pointer| pointer == prop_path) {
399 Some("create-only")
400 } else if schema.conditional_create_only_properties.iter().any(|pointer| pointer == prop_path) {
401 Some("conditional-create-only")
402 } else {
403 None
404 };
405 ctx.lifecycle = lifecycle.map(String::from);
406 if let Some(ref rs) = schema.replacement_strategy {
407 ctx.extra
408 .get_or_insert_with(HashMap::new)
409 .insert("replacement_strategy".into(), serde_json::json!(rs).into());
410 }
411 }
412 "W9054" => {
413 ensure_ctx!(d).lifecycle = Some("write-only".into());
414 }
415 _ => {}
416 }
417 }
418 drain_schema_budget_exhaustions(model);
421}
422
423pub fn enrich_schema_context_standalone(diagnostics: &mut [Diagnostic], model: &Arc<SemanticModel>) {
424 let store = CompiledSchemaStore::new();
425 enrich_schema_context(diagnostics, &store, model);
426}
427
428fn descend<'a>(
433 resolved: Cow<'a, PropSchema>,
434 rest: Option<&str>,
435 defs: &'a HashMap<String, PropSchema>,
436) -> Option<Cow<'a, PropSchema>> {
437 let Some(rest) = rest else {
438 return Some(resolved);
439 };
440 match resolved {
441 Cow::Borrowed(schema) => find_prop_schema(rest, &schema.properties, defs),
442 Cow::Owned(schema) => {
443 find_prop_schema(rest, &schema.properties, defs).map(|found| Cow::Owned(found.into_owned()))
444 }
445 }
446}
447
448fn find_prop_schema<'a>(
449 path: &str,
450 props: &'a HashMap<String, PropSchema>,
451 defs: &'a HashMap<String, PropSchema>,
452) -> Option<Cow<'a, PropSchema>> {
453 let mut segments = path.splitn(2, '.');
454 let top = segments.next()?;
455 let rest = segments.next().filter(|r| !r.is_empty());
456
457 if let Some(ps) = props.get(top) {
458 return descend(ps.resolve(defs), rest, defs);
459 }
460 for (pat, ps) in props.iter() {
461 if compile_pattern(pat).is_some_and(|re| re.is_match(top)) {
462 return descend(ps.resolve(defs), rest, defs);
463 }
464 }
465 None
466}
467
468fn find_prop_schema_deep<'a>(path: &str, schema: &'a CompiledSchema) -> Option<Cow<'a, PropSchema>> {
469 if let Some(ps) = find_prop_schema(path, &schema.properties, &schema.definitions) {
470 return Some(ps);
471 }
472 for sub in schema.one_of.iter().chain(schema.any_of.iter()).chain(schema.all_of.iter()) {
473 if let Some(ps) = find_prop_schema(path, &sub.properties, &schema.definitions) {
474 return Some(ps);
475 }
476 }
477 for ite in &schema.if_then_else {
478 if let Some(ref then_s) = ite.then_schema
479 && let Some(ps) = find_prop_schema(path, &then_s.properties, &schema.definitions)
480 {
481 return Some(ps);
482 }
483 if let Some(ref else_s) = ite.else_schema
484 && let Some(ps) = find_prop_schema(path, &else_s.properties, &schema.definitions)
485 {
486 return Some(ps);
487 }
488 }
489 None
490}
491
492fn present_lifecycle_pointer(m: &Arc<SemanticModel>, rid: &str, base: &str, pointer: &str) -> Option<String> {
503 let resolver_path = pointer.replace(".*.", ".{}.");
504 m.resolve_deep(rid, &format!("{}.{}", base, resolver_path)).map(|_| pointer.to_string())
505}
506
507fn validate_resource(
508 out: &mut Vec<Diagnostic>,
509 store: &CompiledSchemaStore,
510 m: &Arc<SemanticModel>,
511 rid: &str,
512 res: &ResolvedResource,
513 schema: &CompiledSchema,
514 region: Option<&str>,
515) {
516 let base = KEY_PROPERTIES;
517 let defs = &schema.definitions;
518
519 for dp in &schema.deprecated_properties {
525 if let Some(pointer) = present_lifecycle_pointer(m, rid, base, dp) {
526 out.push(build_diagnostic(
527 "W9009",
528 &format!("Property '{}' is deprecated", pointer),
529 m,
530 rid,
531 &format!("{}.{}", base, pointer),
532 None,
533 ));
534 }
535 }
536
537 for cp in &schema.create_only_properties {
538 if let Some(pointer) = present_lifecycle_pointer(m, rid, base, cp) {
539 out.push(build_diagnostic(
540 "I9001",
541 &format!("Property '{}' is create-only; updating it will cause resource replacement", pointer),
542 m,
543 rid,
544 &format!("{}.{}", base, pointer),
545 None,
546 ));
547 }
548 }
549
550 for conditional_pointer in &schema.conditional_create_only_properties {
551 if schema.create_only_properties.contains(conditional_pointer) {
553 continue;
554 }
555 if let Some(pointer) = present_lifecycle_pointer(m, rid, base, conditional_pointer) {
556 out.push(build_diagnostic(
557 "I9001",
558 &format!(
559 "Property '{}' is conditionally create-only; updating it may cause resource replacement",
560 pointer
561 ),
562 m,
563 rid,
564 &format!("{}.{}", base, pointer),
565 None,
566 ));
567 }
568 }
569
570 for wo in &schema.write_only_properties {
571 for edge in m.graph.incoming(rid) {
572 if let RefKind::GetAtt { attr } = &edge.kind
573 && attr == wo
574 && edge.source_resource.starts_with("__output__")
575 {
576 let output_name = edge.source_resource.strip_prefix("__output__").unwrap_or(&edge.source_resource);
577 out.push(build_diagnostic(
578 "W9054",
579 &format!("Write-only property '{}' of '{}' is referenced in output '{}'", wo, rid, output_name),
580 m,
581 rid,
582 &format!("{}.{}", base, wo),
583 None,
584 ));
585 }
586 }
587 }
588
589 let key_scenarios = if res.properties_dynamic { Vec::new() } else { resource_property_key_scenarios(m, rid, res) };
594 for (actual_keys, conds) in &key_scenarios {
595 let scenario = if conds.is_empty() { None } else { Some(conds) };
596 validate_object_keys_inner(
597 out,
598 m,
599 rid,
600 &res.resource_type,
601 &schema.properties,
602 defs,
603 &schema.required,
604 schema.additional_properties,
605 &HashMap::new(),
606 &schema.dependent_required,
607 &schema.dependent_excluded,
608 &schema.required_or,
609 &schema.required_xor,
610 &schema.all_of,
611 &schema.any_of,
612 &schema.one_of,
613 actual_keys,
614 base,
615 scenario,
616 );
617 }
618
619 for prop_name in res.properties.keys() {
620 let Some(prop_schema) = schema.properties.get(prop_name) else {
621 continue;
622 };
623 let resolved = prop_schema.resolve(defs);
624 let prop_path = format!("{}.{}", base, prop_name);
625 validate_prop(out, store, m, rid, &res.resource_type, &prop_path, &resolved, defs, region);
626 }
627
628 let branch_property_names: HashSet<String> =
632 key_scenarios.iter().flat_map(|(keys, _)| keys.iter().cloned()).collect();
633 for prop_name in &branch_property_names {
634 if res.properties.contains_key(prop_name) {
635 continue;
636 }
637 let Some(prop_schema) = schema.properties.get(prop_name) else {
638 continue;
639 };
640 let resolved = prop_schema.resolve(defs);
641 let prop_path = format!("{}.{}", base, prop_name);
642 validate_prop(out, store, m, rid, &res.resource_type, &prop_path, &resolved, defs, region);
643 }
644
645 let actual_keys: Vec<String> = res.properties.keys().cloned().collect();
646 for ite in &schema.if_then_else {
647 if ite.condition.states_nothing() {
651 continue;
652 }
653 let Some(assignments) = conditional_scenario_assignments(m, rid, &ite.condition, base) else {
659 continue;
660 };
661 for assignment in assignments {
662 let _scenario_filter_scope = ScenarioFilterScope::enter(assignment.clone());
663 let effective_keys: Vec<String> = actual_keys
664 .iter()
665 .filter(|key| property_present_under(m, rid, base, key, &assignment))
666 .cloned()
667 .collect();
668 let matches = condition_matches(&ite.condition, &effective_keys, m, rid, defs);
669 let sub = if matches { &ite.then_schema } else { &ite.else_schema };
670 let Some(sub) = sub else {
671 continue;
672 };
673 if ite.enforce_full_branch {
674 validate_sub(out, m, rid, &res.resource_type, &effective_keys, sub, defs, base, 0);
679 } else {
680 validate_sub_dependencies(out, m, rid, &effective_keys, sub, base);
686 validate_owned_conditional_value_constraints(out, m, rid, &res.resource_type, sub, defs, base);
687 }
688 }
689 }
690}
691
692fn conditional_scenario_assignments(
697 m: &Arc<SemanticModel>,
698 rid: &str,
699 condition: &ConditionSchema,
700 base_path: &str,
701) -> Option<Vec<HashMap<String, bool>>> {
702 let mut property_names = BTreeSet::new();
703 collect_condition_property_names(condition, &mut property_names);
704 let paths: Vec<String> = property_names.into_iter().map(|name| format!("{}.{}", base_path, name)).collect();
705 let refs: Vec<&str> = paths.iter().map(String::as_str).collect();
706 property_scenario_assignments(m, rid, &refs)
707}
708
709fn collect_condition_property_names(condition: &ConditionSchema, names: &mut BTreeSet<String>) {
710 names.extend(condition.properties.keys().cloned());
711 names.extend(condition.required.iter().cloned());
712 names.extend(condition.absent.iter().cloned());
713 for sub_condition in condition.any_of.iter().chain(condition.one_of.iter()) {
714 collect_condition_property_names(sub_condition, names);
715 }
716 if let Some(negated) = &condition.not {
717 collect_condition_property_names(negated, names);
718 }
719}
720
721fn validate_object_keys(
722 out: &mut Vec<Diagnostic>,
723 m: &Arc<SemanticModel>,
724 rid: &str,
725 rtype: &str,
726 schema_props: &HashMap<String, PropSchema>,
727 defs: &HashMap<String, PropSchema>,
728 required: &[String],
729 additional_properties: Option<bool>,
730 pattern_props: &HashMap<String, PropSchema>,
731 dep_req: &HashMap<String, Vec<String>>,
732 dep_excl: &HashMap<String, Vec<String>>,
733 req_or: &[String],
734 req_xor: &[String],
735 all_of: &[SubSchema],
736 any_of: &[SubSchema],
737 one_of: &[SubSchema],
738 actual_keys: &[String],
739 base_path: &str,
740) {
741 validate_object_keys_inner(
742 out,
743 m,
744 rid,
745 rtype,
746 schema_props,
747 defs,
748 required,
749 additional_properties,
750 pattern_props,
751 dep_req,
752 dep_excl,
753 req_or,
754 req_xor,
755 all_of,
756 any_of,
757 one_of,
758 actual_keys,
759 base_path,
760 None,
761 )
762}
763
764fn validate_required_groups(
765 out: &mut Vec<Diagnostic>,
766 m: &Arc<SemanticModel>,
767 rid: &str,
768 actual_keys: &[String],
769 required_or: &[String],
770 required_xor: &[String],
771 base_path: &str,
772) {
773 if required_or.is_empty() && required_xor.is_empty() {
774 return;
775 }
776
777 let members: Vec<&str> = required_or.iter().chain(required_xor.iter()).map(String::as_str).collect();
783 let Some(assignments) = required_group_scenario_assignments(m, rid, &members, base_path) else {
784 validate_required_groups_budget_fallback(out, m, rid, actual_keys, required_or, required_xor, base_path);
787 return;
788 };
789
790 if !required_or.is_empty() {
791 for assignment in &assignments {
792 let any_present = required_or.iter().any(|property| {
793 actual_keys.iter().any(|actual| actual == property)
794 && property_present_under(m, rid, base_path, property, assignment)
795 });
796 if !any_present {
797 let names = required_or.iter().map(|name| format!("'{name}'")).collect::<Vec<_>>().join(", ");
798 out.push(build_diagnostic_conditional(
799 "F3058",
800 &format!("One of [{names}] is a required property"),
801 m,
802 rid,
803 base_path,
804 None,
805 assignment_condition_map(assignment),
806 ));
807 }
808 }
809 }
810
811 if !required_xor.is_empty() {
812 for assignment in &assignments {
813 let count = required_xor
814 .iter()
815 .filter(|property| {
816 actual_keys.iter().any(|actual| actual == property.as_str())
817 && property_present_under(m, rid, base_path, property, assignment)
818 })
819 .count();
820 if count != 1 {
821 let names = required_xor.iter().map(|name| format!("'{name}'")).collect::<Vec<_>>().join(", ");
822 out.push(build_diagnostic_conditional(
823 "F3014",
824 &format!("Exactly one of [{names}] must be specified"),
825 m,
826 rid,
827 base_path,
828 None,
829 assignment_condition_map(assignment),
830 ));
831 }
832 }
833 }
834}
835
836fn all_members_absent_witness(
841 m: &Arc<SemanticModel>,
842 rid: &str,
843 actual_keys: &[String],
844 members: &[String],
845 base_path: &str,
846) -> Option<HashMap<String, bool>> {
847 let seed = active_scenario_filter(m, rid)?;
848
849 let mut alternatives = Vec::new();
850 for property in members {
851 if !actual_keys.iter().any(|actual| actual == property) {
852 continue;
853 }
854 let property_alternatives = property_presence_alternatives(m, rid, base_path, property, false, &seed);
855 if property_alternatives.is_empty() {
856 return None;
857 }
858 alternatives.push(property_alternatives);
859 }
860 alternatives.sort_by_key(Vec::len);
861 let mut merge_attempts = 0;
862 compatible_alternative_witness(m, &alternatives, 0, &seed, &mut merge_attempts)
863}
864
865fn multiple_members_present_witness(
869 m: &Arc<SemanticModel>,
870 rid: &str,
871 actual_keys: &[String],
872 members: &[String],
873 base_path: &str,
874) -> Option<HashMap<String, bool>> {
875 let seed = active_scenario_filter(m, rid)?;
876
877 let present_alternatives: Vec<Vec<HashMap<String, bool>>> = members
878 .iter()
879 .filter(|property| actual_keys.iter().any(|actual| actual == property.as_str()))
880 .map(|property| property_presence_alternatives(m, rid, base_path, property, true, &seed))
881 .collect();
882
883 let mut merge_attempts = 0;
884 for left in 0..present_alternatives.len() {
885 for right in (left + 1)..present_alternatives.len() {
886 for left_assignment in &present_alternatives[left] {
887 let Some(left_merged) = try_merge_assignments(&seed, left_assignment) else {
888 continue;
889 };
890 if !assignment_is_proven_satisfiable(m, &left_merged) {
891 continue;
892 }
893 for right_assignment in &present_alternatives[right] {
894 if merge_attempts >= MAX_GROUP_WITNESS_MERGE_ATTEMPTS {
895 return None;
896 }
897 merge_attempts += 1;
898 let Some(merged) = try_merge_assignments(&left_merged, right_assignment) else {
899 continue;
900 };
901 if assignment_is_proven_satisfiable(m, &merged) {
902 return Some(merged);
903 }
904 }
905 }
906 }
907 }
908 None
909}
910
911fn active_scenario_filter(m: &Arc<SemanticModel>, rid: &str) -> Option<HashMap<String, bool>> {
915 let mut seed = SCENARIO_FILTER.with(|filter| filter.borrow().clone().unwrap_or_default());
916 if m.resource_condition_is_valid(rid)
917 && let Some(condition) = m.resources.get(rid).and_then(|resource| resource.condition.as_ref())
918 && let Some(previous) = seed.insert(condition.clone(), true)
919 && !previous
920 {
921 return None;
922 }
923 assignment_is_proven_satisfiable(m, &seed).then_some(seed)
924}
925
926fn property_presence_alternatives(
931 m: &Arc<SemanticModel>,
932 rid: &str,
933 base_path: &str,
934 property: &str,
935 want_present: bool,
936 filter: &HashMap<String, bool>,
937) -> Vec<HashMap<String, bool>> {
938 if m.scenario_budget_exhausted() {
939 return Vec::new();
940 }
941 let (scenarios, was_curtailed) =
942 m.resolve_scenarios_with_limit(rid, &format!("{base_path}.{property}"), MAX_GROUP_PROPERTY_SCENARIOS);
943 if was_curtailed && !m.scenario_budget_exhausted() {
944 m.record_budget_exhaustion(BudgetKind::SchemaScenarioAssignments);
945 }
946 let mut alternatives = Vec::new();
947 let mut seen = HashSet::new();
948 for (value, conditions) in scenarios {
949 let present = !matches!(value, ResolvedValue::Concrete { value } if value.is_null());
950 if present != want_present {
951 continue;
952 }
953 let Some(merged) = try_merge_assignments(filter, &conditions) else {
954 continue;
955 };
956 if assignment_is_proven_satisfiable(m, &merged) && seen.insert(canonical_assignment(&conditions)) {
957 alternatives.push(conditions);
958 }
959 }
960 alternatives
961}
962
963fn compatible_alternative_witness(
964 m: &Arc<SemanticModel>,
965 alternatives: &[Vec<HashMap<String, bool>>],
966 index: usize,
967 assignment: &HashMap<String, bool>,
968 merge_attempts: &mut usize,
969) -> Option<HashMap<String, bool>> {
970 let Some(group) = alternatives.get(index) else {
971 return Some(assignment.clone());
972 };
973 for alternative in group {
974 if *merge_attempts >= MAX_GROUP_WITNESS_MERGE_ATTEMPTS {
975 return None;
976 }
977 *merge_attempts += 1;
978 let Some(merged) = try_merge_assignments(assignment, alternative) else {
979 continue;
980 };
981 if !assignment_is_proven_satisfiable(m, &merged) {
982 continue;
983 }
984 if let Some(witness) = compatible_alternative_witness(m, alternatives, index + 1, &merged, merge_attempts) {
985 return Some(witness);
986 }
987 }
988 None
989}
990
991fn assignment_is_proven_satisfiable(m: &Arc<SemanticModel>, assignment: &HashMap<String, bool>) -> bool {
996 if assignment.is_empty() {
997 return true;
998 }
999 let assumptions: Vec<(String, bool)> = assignment.iter().map(|(name, value)| (name.clone(), *value)).collect();
1000 matches!(m.conditions.satisfiability(&assumptions), Satisfiability::Satisfiable)
1001}
1002
1003fn validate_required_groups_budget_fallback(
1007 out: &mut Vec<Diagnostic>,
1008 m: &Arc<SemanticModel>,
1009 rid: &str,
1010 actual_keys: &[String],
1011 required_or: &[String],
1012 required_xor: &[String],
1013 base_path: &str,
1014) {
1015 if !required_or.is_empty()
1016 && let Some(witness) = all_members_absent_witness(m, rid, actual_keys, required_or, base_path)
1017 {
1018 let names = required_or.iter().map(|name| format!("'{name}'")).collect::<Vec<_>>().join(", ");
1019 out.push(build_diagnostic_conditional(
1020 "F3058",
1021 &format!("One of [{names}] is a required property"),
1022 m,
1023 rid,
1024 base_path,
1025 None,
1026 assignment_condition_map(&witness),
1027 ));
1028 }
1029
1030 if required_xor.is_empty() {
1031 return;
1032 }
1033 let names = required_xor.iter().map(|name| format!("'{name}'")).collect::<Vec<_>>().join(", ");
1034 if let Some(witness) = all_members_absent_witness(m, rid, actual_keys, required_xor, base_path) {
1035 out.push(build_diagnostic_conditional(
1036 "F3014",
1037 &format!("Exactly one of [{names}] must be specified"),
1038 m,
1039 rid,
1040 base_path,
1041 None,
1042 assignment_condition_map(&witness),
1043 ));
1044 }
1045 if let Some(witness) = multiple_members_present_witness(m, rid, actual_keys, required_xor, base_path) {
1046 out.push(build_diagnostic_conditional(
1047 "F3014",
1048 &format!("Exactly one of [{names}] must be specified"),
1049 m,
1050 rid,
1051 base_path,
1052 None,
1053 assignment_condition_map(&witness),
1054 ));
1055 }
1056}
1057
1058fn required_group_scenario_assignments(
1062 m: &Arc<SemanticModel>,
1063 rid: &str,
1064 members: &[&str],
1065 base_path: &str,
1066) -> Option<Vec<HashMap<String, bool>>> {
1067 let paths: Vec<String> = members.iter().map(|name| format!("{}.{}", base_path, name)).collect();
1068 let refs: Vec<&str> = paths.iter().map(String::as_str).collect();
1069 property_scenario_assignments(m, rid, &refs)
1070}
1071
1072const MAX_GROUP_PROPERTY_SCENARIOS: usize = MAX_GROUP_SCENARIO_ASSIGNMENTS + 1;
1073const MAX_GROUP_WITNESS_MERGE_ATTEMPTS: usize = MAX_GROUP_SCENARIO_MERGE_ATTEMPTS;
1074
1075fn property_scenario_assignments(
1086 m: &Arc<SemanticModel>,
1087 rid: &str,
1088 property_paths: &[&str],
1089) -> Option<Vec<HashMap<String, bool>>> {
1090 let Some(seed) = active_scenario_filter(m, rid) else {
1091 return Some(Vec::new());
1092 };
1093 let mut assignments: Vec<HashMap<String, bool>> = vec![seed.clone()];
1094
1095 for property_path in property_paths {
1096 let (scenarios, was_curtailed) =
1097 m.resolve_scenarios_with_limit(rid, property_path, MAX_GROUP_PROPERTY_SCENARIOS);
1098 if was_curtailed {
1099 if !m.scenario_budget_exhausted() {
1100 m.record_budget_exhaustion(BudgetKind::SchemaScenarioAssignments);
1101 }
1102 return None;
1103 }
1104 if scenarios.is_empty() {
1105 continue;
1106 }
1107
1108 let mut property_assignments = Vec::new();
1109 let mut seen_property_assignments = HashSet::new();
1110 for (_, conditions) in &scenarios {
1111 if !is_satisfiable(m, conditions) || !scenario_consistent_with_filter(m, conditions) {
1112 continue;
1113 }
1114 if seen_property_assignments.insert(canonical_assignment(conditions)) {
1115 if property_assignments.len() == MAX_GROUP_SCENARIO_ASSIGNMENTS {
1116 m.record_budget_exhaustion(BudgetKind::SchemaScenarioAssignments);
1117 return None;
1118 }
1119 property_assignments.push(conditions.clone());
1120 }
1121 }
1122
1123 if property_assignments.is_empty() {
1124 continue;
1125 }
1126
1127 let mut next_assignments = Vec::new();
1128 let mut seen_assignments = HashSet::new();
1129 let mut merge_attempts = 0;
1130 for existing in &assignments {
1131 for alternative in &property_assignments {
1132 merge_attempts += 1;
1133 if merge_attempts > MAX_GROUP_SCENARIO_MERGE_ATTEMPTS {
1134 m.record_budget_exhaustion(BudgetKind::SchemaScenarioMergeAttempts);
1135 return None;
1136 }
1137 let Some(merged) = try_merge_assignments(existing, alternative) else {
1138 continue;
1139 };
1140 if !is_satisfiable(m, &merged) || !scenario_consistent_with_filter(m, &merged) {
1141 continue;
1142 }
1143 if seen_assignments.insert(canonical_assignment(&merged)) {
1144 if next_assignments.len() == MAX_GROUP_SCENARIO_ASSIGNMENTS {
1145 m.record_budget_exhaustion(BudgetKind::SchemaScenarioAssignments);
1146 return None;
1147 }
1148 next_assignments.push(merged);
1149 }
1150 }
1151 }
1152
1153 if !next_assignments.is_empty() {
1154 assignments = next_assignments;
1155 }
1156 }
1157
1158 Some(if assignments.is_empty() { vec![seed] } else { assignments })
1159}
1160
1161fn canonical_assignment(assignment: &HashMap<String, bool>) -> Vec<(String, bool)> {
1162 let mut canonical: Vec<(String, bool)> = assignment.iter().map(|(name, value)| (name.clone(), *value)).collect();
1163 canonical.sort_unstable();
1164 canonical
1165}
1166
1167fn try_merge_assignments(a: &HashMap<String, bool>, b: &HashMap<String, bool>) -> Option<HashMap<String, bool>> {
1170 let mut merged = a.clone();
1171 for (name, val) in b {
1172 if let Some(existing) = merged.get(name) {
1173 if existing != val {
1174 return None; }
1176 } else {
1177 merged.insert(name.clone(), *val);
1178 }
1179 }
1180 Some(merged)
1181}
1182
1183fn property_present_under(
1195 m: &Arc<SemanticModel>,
1196 rid: &str,
1197 base: &str,
1198 prop: &str,
1199 assignment: &HashMap<String, bool>,
1200) -> bool {
1201 let scenarios = m.resolve_scenarios(rid, &format!("{}.{}", base, prop));
1202 if scenarios.is_empty() {
1203 return true;
1204 }
1205 scenarios.iter().any(|(val, conds)| {
1206 if !is_satisfiable(m, conds) {
1207 return false;
1208 }
1209 if !scenario_consistent_with_filter(m, conds) {
1212 return false;
1213 }
1214 for (name, value) in assignment {
1216 if let Some(scenario_value) = conds.get(name)
1217 && scenario_value != value
1218 {
1219 return false;
1220 }
1221 }
1222 if !assignment.is_empty() {
1224 let mut merged = conds.clone();
1225 for (name, value) in assignment {
1226 merged.insert(name.clone(), *value);
1227 }
1228 if !is_satisfiable(m, &merged) {
1229 return false;
1230 }
1231 }
1232 !matches!(val, ResolvedValue::Concrete { value } if value.is_null())
1233 })
1234}
1235
1236#[allow(clippy::too_many_arguments)]
1241fn validate_object_keys_inner(
1242 out: &mut Vec<Diagnostic>,
1243 m: &Arc<SemanticModel>,
1244 rid: &str,
1245 rtype: &str,
1246 schema_props: &HashMap<String, PropSchema>,
1247 defs: &HashMap<String, PropSchema>,
1248 required: &[String],
1249 additional_properties: Option<bool>,
1250 pattern_props: &HashMap<String, PropSchema>,
1251 dep_req: &HashMap<String, Vec<String>>,
1252 dep_excl: &HashMap<String, Vec<String>>,
1253 req_or: &[String],
1254 req_xor: &[String],
1255 all_of: &[SubSchema],
1256 any_of: &[SubSchema],
1257 one_of: &[SubSchema],
1258 actual_keys: &[String],
1259 base_path: &str,
1260 scenario: Option<&HashMap<String, bool>>,
1261) {
1262 let _scenario_filter_scope = match scenario {
1266 Some(outer_conditions) => {
1267 let Some(merged) = merged_scenario_filter(outer_conditions) else {
1268 return;
1269 };
1270 Some(ScenarioFilterScope::enter(merged))
1271 }
1272 None => None,
1273 };
1274
1275 let before_len = out.len();
1276 for req in required {
1277 if !actual_keys.contains(req) {
1278 out.push(build_diagnostic(
1279 "F3003",
1280 &format!("'{}' is a required property", req),
1281 m,
1282 rid,
1283 base_path,
1284 Some(&format!("Add the required property '{}'", req)),
1285 ));
1286 } else if base_path == KEY_PROPERTIES {
1287 check_required_not_null(out, m, rid, base_path, req);
1288 }
1289 }
1290
1291 if additional_properties == Some(false) && !is_custom_resource_type(rtype) {
1292 let known: HashSet<&str> = schema_props.keys().map(|s| s.as_str()).collect();
1293
1294 let pattern_matchers: Vec<Option<std::sync::Arc<CompiledPattern>>> =
1295 pattern_props.keys().map(|p| compile_pattern(p)).collect();
1296 for key in actual_keys {
1297 if known.contains(key.as_str()) {
1298 continue;
1299 }
1300 let allowed_by_pattern =
1301 pattern_matchers.iter().any(|matcher| matcher.as_ref().is_none_or(|re| re.is_match(key)));
1302 if allowed_by_pattern {
1303 continue;
1304 }
1305 let suggestion = find_similar(key, &known);
1306 let msg = match suggestion {
1307 Some(s) => {
1308 format!("Additional properties are not allowed ('{}' was unexpected. Did you mean '{}'?)", key, s)
1309 }
1310 None => format!("Additional properties are not allowed ('{}' was unexpected)", key),
1311 };
1312 out.push(build_diagnostic("F3002", &msg, m, rid, &format!("{}.{}", base_path, key), None));
1313 }
1314 }
1315
1316 for (trigger, excluded) in dep_excl {
1317 if actual_keys.contains(trigger) {
1318 for dep in excluded {
1319 if actual_keys.contains(dep) {
1320 out.push(build_diagnostic(
1321 "F3020",
1322 &format!("'{}' should not be included with '{}'", dep, trigger),
1323 m,
1324 rid,
1325 &format!("{}.{}", base_path, dep),
1326 None,
1327 ));
1328 }
1329 }
1330 }
1331 }
1332
1333 for (trigger, deps) in dep_req {
1334 if actual_keys.contains(trigger) {
1335 for dep in deps {
1336 if !actual_keys.contains(dep) {
1337 out.push(build_diagnostic(
1338 "F3021",
1339 &format!("'{}' is a dependency of '{}'", dep, trigger),
1340 m,
1341 rid,
1342 base_path,
1343 Some(&format!("Add '{}' when '{}' is specified", dep, trigger)),
1344 ));
1345 }
1346 }
1347 }
1348 }
1349
1350 validate_required_groups(out, m, rid, actual_keys, req_or, req_xor, base_path);
1351
1352 for sub in all_of {
1353 validate_sub(out, m, rid, rtype, actual_keys, sub, defs, base_path, 0);
1354 }
1355
1356 let group_assignments = if any_of.is_empty() && one_of.is_empty() {
1364 Some(Vec::new())
1365 } else {
1366 branch_scenario_assignments(m, rid, any_of.iter().chain(one_of.iter()), defs, base_path)
1367 }
1368 .unwrap_or_default();
1369
1370 if !any_of.is_empty() {
1371 for assignment in &group_assignments {
1372 let evaluations =
1373 evaluate_object_composition_branches(m, rid, rtype, actual_keys, any_of, defs, base_path, assignment);
1374 if evaluations.iter().all(|evaluation| !evaluation.matched) {
1375 out.push(build_composition_diagnostic(
1376 "F3017",
1377 CompositionKind::AnyOf,
1378 &evaluations,
1379 m,
1380 rid,
1381 base_path,
1382 Some(rtype),
1383 None,
1384 assignment_condition_map(assignment),
1385 ));
1386 }
1387 }
1388 }
1389
1390 if !one_of.is_empty() {
1391 for assignment in &group_assignments {
1392 let evaluations =
1393 evaluate_object_composition_branches(m, rid, rtype, actual_keys, one_of, defs, base_path, assignment);
1394 let match_count = evaluations.iter().filter(|evaluation| evaluation.matched).count();
1395 if match_count != 1 {
1396 out.push(build_composition_diagnostic(
1397 "F3018",
1398 CompositionKind::OneOf,
1399 &evaluations,
1400 m,
1401 rid,
1402 base_path,
1403 Some(rtype),
1404 None,
1405 assignment_condition_map(assignment),
1406 ));
1407 }
1408 }
1409 }
1410
1411 if let Some(conds) = scenario {
1415 for diag in out.iter_mut().skip(before_len) {
1416 if diag.condition_scenario.is_none() {
1417 diag.condition_scenario = Some(conds.clone());
1418 }
1419 }
1420 }
1421}
1422
1423fn object_key_scenarios(
1429 m: &Arc<SemanticModel>,
1430 rid: &str,
1431 prop_path: &str,
1432) -> Vec<(Vec<String>, HashMap<String, bool>)> {
1433 let mut out: Vec<(Vec<String>, HashMap<String, bool>)> = Vec::new();
1434 for (val, conds) in m.resolve_scenarios_json(rid, prop_path) {
1435 if !is_satisfiable(m, &conds) {
1436 continue;
1437 }
1438 let Some(obj) = val.as_object() else {
1439 continue;
1440 };
1441 let mut keys: Vec<String> = obj.keys().cloned().collect();
1442 keys.sort();
1443 out.push((keys, conds));
1444 }
1445 let mut seen_keysets: HashMap<Vec<String>, HashMap<String, bool>> = HashMap::new();
1449 for (keys, conds) in out.drain(..) {
1450 seen_keysets
1451 .entry(keys)
1452 .and_modify(|existing| {
1453 existing.retain(|k, v| conds.get(k) == Some(v));
1457 })
1458 .or_insert(conds);
1459 }
1460 seen_keysets.into_iter().collect()
1461}
1462
1463fn resource_property_key_scenarios(
1470 m: &Arc<SemanticModel>,
1471 rid: &str,
1472 res: &ResolvedResource,
1473) -> Vec<(Vec<String>, HashMap<String, bool>)> {
1474 let keys: Vec<&str> = res.properties.keys().map(String::as_str).collect();
1475 if keys.len() != 1 || keys[0] != FN_IF {
1476 let mut ks: Vec<String> = res.properties.keys().cloned().collect();
1477 ks.sort();
1478 return vec![(ks, HashMap::new())];
1479 }
1480 let path = format!("{}.{}", KEY_PROPERTIES, FN_IF);
1483 let scenarios = object_key_scenarios(m, rid, &path);
1484 if scenarios.is_empty() {
1485 return vec![(Vec::new(), HashMap::new())];
1486 }
1487 scenarios
1488}
1489
1490fn validate_sub(
1491 out: &mut Vec<Diagnostic>,
1492 m: &Arc<SemanticModel>,
1493 rid: &str,
1494 rtype: &str,
1495 actual_keys: &[String],
1496 sub: &SubSchema,
1497 defs: &HashMap<String, PropSchema>,
1498 base_path: &str,
1499 depth: usize,
1500) {
1501 if depth > MAX_MATCH_DEPTH {
1505 m.record_budget_exhaustion(BudgetKind::SchemaMatchDepth);
1506 return;
1507 }
1508 let resolved;
1512 let effective_sub = if sub.ref_name.is_some() {
1513 resolved = sub.resolve(defs);
1514 if sub.ref_name.is_some() && resolved.ref_name.is_some() {
1515 out.push(build_diagnostic(
1517 "F3003",
1518 &format!(
1519 "Composition branch references undefined definition '{}'",
1520 sub.ref_name.as_deref().unwrap_or("")
1521 ),
1522 m,
1523 rid,
1524 base_path,
1525 None,
1526 ));
1527 return;
1528 }
1529 &*resolved
1530 } else {
1531 sub
1532 };
1533
1534 for req in &effective_sub.required {
1535 if !actual_keys.contains(req) {
1536 if extension_required_covered_by_dedicated_rule(rtype, req) {
1541 continue;
1542 }
1543 out.push(build_diagnostic(
1544 "F3003",
1545 &format!("'{}' is a required property", req),
1546 m,
1547 rid,
1548 base_path,
1549 Some(&format!("Add '{}'", req)),
1550 ));
1551 } else {
1552 check_required_not_null(out, m, rid, base_path, req);
1553 }
1554 }
1555
1556 if effective_sub.additional_properties == Some(false) && !effective_sub.properties.is_empty() {
1558 let known: std::collections::HashSet<&str> = effective_sub.properties.keys().map(|s| s.as_str()).collect();
1559 let pattern_matchers: Vec<Option<std::sync::Arc<CompiledPattern>>> =
1560 effective_sub.pattern_properties.keys().map(|p| compile_pattern(p)).collect();
1561 for key in actual_keys {
1562 if known.contains(key.as_str()) {
1563 continue;
1564 }
1565 let allowed_by_pattern =
1566 pattern_matchers.iter().any(|matcher| matcher.as_ref().is_none_or(|re| re.is_match(key)));
1567 if allowed_by_pattern {
1568 continue;
1569 }
1570 out.push(build_diagnostic(
1571 "F3002",
1572 &format!("Additional properties are not allowed ('{}' was unexpected)", key),
1573 m,
1574 rid,
1575 &format!("{}.{}", base_path, key),
1576 None,
1577 ));
1578 }
1579 }
1580
1581 validate_sub_dependencies(out, m, rid, actual_keys, effective_sub, base_path);
1582
1583 validate_required_groups(
1584 out,
1585 m,
1586 rid,
1587 actual_keys,
1588 &effective_sub.required_or,
1589 &effective_sub.required_xor,
1590 base_path,
1591 );
1592
1593 validate_sub_value_constraints(out, m, rid, effective_sub, defs, base_path);
1597
1598 for ite in &effective_sub.if_then_else {
1605 let matches = condition_matches_at(&ite.condition, actual_keys, m, rid, defs, base_path);
1606 let branch = if matches { &ite.then_schema } else { &ite.else_schema };
1607 if let Some(branch_schema) = branch {
1608 if ite.enforce_full_branch {
1609 validate_sub(out, m, rid, rtype, actual_keys, branch_schema, defs, base_path, depth + 1);
1610 } else {
1611 validate_sub_dependencies(out, m, rid, actual_keys, branch_schema, base_path);
1612 }
1613 }
1614 }
1615}
1616
1617fn schema_value_matches(
1628 value: &serde_json::Value,
1629 schema: &PropSchema,
1630 defs: &HashMap<String, PropSchema>,
1631 depth: usize,
1632) -> bool {
1633 schema_value_failure_reasons(value, schema, defs, depth, "").is_empty()
1634}
1635
1636fn evaluate_value_composition_branches(
1637 value: &serde_json::Value,
1638 branches: &[SubSchema],
1639 defs: &HashMap<String, PropSchema>,
1640 property_path: &str,
1641) -> Vec<CompositionBranchEvaluation> {
1642 branches
1643 .iter()
1644 .enumerate()
1645 .map(|(index, branch)| {
1646 let failure_reasons = schema_value_failure_reasons(value, branch, defs, 0, property_path);
1647 let matched = failure_reasons.is_empty();
1648 CompositionBranchEvaluation::new(
1649 index + 1,
1650 matched,
1651 required_property_combinations(branch, defs, None),
1652 failure_reasons,
1653 )
1654 })
1655 .collect()
1656}
1657
1658fn schema_value_failure_reasons(
1659 value: &serde_json::Value,
1660 schema: &PropSchema,
1661 defs: &HashMap<String, PropSchema>,
1662 depth: usize,
1663 property_path: &str,
1664) -> Vec<CompositionFailureReason> {
1665 if depth > MAX_MATCH_DEPTH || value.is_null() {
1666 if depth > MAX_MATCH_DEPTH {
1667 record_schema_budget_exhaustion(BudgetKind::SchemaMatchDepth);
1668 }
1669 return Vec::new();
1670 }
1671
1672 let resolved;
1673 let effective = if schema.ref_name.is_some() {
1674 resolved = schema.resolve(defs);
1675 if resolved.ref_name.is_some() {
1676 return vec![CompositionFailureReason::new(
1677 format!(
1678 "Composition branch references undefined definition '{}'",
1679 schema.ref_name.as_deref().unwrap_or("")
1680 ),
1681 property_path,
1682 )];
1683 }
1684 &*resolved
1685 } else {
1686 schema
1687 };
1688
1689 let mut reasons = Vec::new();
1690 if let Some(ref expected_type) = effective.prop_type
1691 && !type_matches(value, expected_type)
1692 {
1693 let coercible = expected_type
1694 .primary()
1695 .is_some_and(|expected| matches!(coerce_value(value, expected), CoerceResult::Coerced(_, _)));
1696 if !coercible {
1697 reasons.push(CompositionFailureReason::new(
1698 format!(
1699 "{} has type '{}', expected type '{}'",
1700 format_value(value),
1701 json_value_type(value),
1702 expected_type.names().collect::<Vec<_>>().join("|")
1703 ),
1704 property_path,
1705 ));
1706 return reasons;
1707 }
1708 }
1709
1710 if !effective.enum_values.is_empty() && !enum_matches(value, &effective.enum_values) {
1711 reasons.push(CompositionFailureReason::new(
1712 format!("{} is not one of {}", format_value(value), format_allowed_values(&effective.enum_values)),
1713 property_path,
1714 ));
1715 }
1716 if !effective.enum_case_insensitive.is_empty()
1717 && !enum_matches_case_insensitive(value, &effective.enum_case_insensitive)
1718 {
1719 reasons.push(CompositionFailureReason::new(
1720 format!(
1721 "{} is not one of {} (case-insensitive)",
1722 format_value(value),
1723 format_allowed_values(&effective.enum_case_insensitive)
1724 ),
1725 property_path,
1726 ));
1727 }
1728 if !effective.not_enum.is_empty() && enum_matches(value, &effective.not_enum) {
1729 reasons.push(CompositionFailureReason::new(
1730 format!("{} must not be one of {}", format_value(value), format_allowed_values(&effective.not_enum)),
1731 property_path,
1732 ));
1733 }
1734 if let Some(ref expected) = effective.const_value
1735 && !scalar_eq(value, expected)
1736 {
1737 reasons.push(CompositionFailureReason::new(
1738 format!("{} must equal {}", format_value(value), format_value(expected)),
1739 property_path,
1740 ));
1741 }
1742 if let Some(ref pattern) = effective.pattern
1743 && let Some(compiled) = compile_pattern(pattern)
1744 && let Some(actual) = coerce_to_string(value)
1745 && !compiled.is_match(&actual)
1746 {
1747 reasons.push(CompositionFailureReason::new(
1748 format!("{} does not match pattern '{pattern}'", format_value(value)),
1749 property_path,
1750 ));
1751 }
1752 if let Some(ref format) = effective.format
1753 && let Some(actual) = coerce_to_string(value)
1754 && !format_value_matches(&actual, format)
1755 {
1756 reasons.push(CompositionFailureReason::new(
1757 format!("{} does not match format '{format}'", format_value(value)),
1758 property_path,
1759 ));
1760 }
1761
1762 if let Some(number) = coerce_to_number(value) {
1763 if let Some(maximum) = effective.maximum
1764 && number > maximum
1765 {
1766 reasons.push(CompositionFailureReason::new(format!("{number} exceeds maximum {maximum}"), property_path));
1767 }
1768 if let Some(minimum) = effective.minimum
1769 && number < minimum
1770 {
1771 reasons.push(CompositionFailureReason::new(format!("{number} is below minimum {minimum}"), property_path));
1772 }
1773 if let Some(maximum) = effective.exclusive_maximum
1774 && number >= maximum
1775 {
1776 reasons.push(CompositionFailureReason::new(format!("{number} must be less than {maximum}"), property_path));
1777 }
1778 if let Some(minimum) = effective.exclusive_minimum
1779 && number <= minimum
1780 {
1781 reasons
1782 .push(CompositionFailureReason::new(format!("{number} must be greater than {minimum}"), property_path));
1783 }
1784 if let Some(multiple) = effective.multiple_of
1785 && multiple > 0.0
1786 {
1787 let remainder = (number / multiple).round() * multiple - number;
1788 let epsilon = multiple * 1e-9;
1789 if remainder.abs() > epsilon && (multiple - remainder.abs()).abs() > epsilon {
1790 reasons.push(CompositionFailureReason::new(
1791 format!("{number} is not a multiple of {multiple}"),
1792 property_path,
1793 ));
1794 }
1795 }
1796 }
1797
1798 if (effective.min_length.is_some() || effective.max_length.is_some())
1799 && let Some(actual) = coerce_to_string(value)
1800 {
1801 let length = actual.chars().count() as u64;
1802 if let Some(maximum) = effective.max_length
1803 && length > maximum
1804 {
1805 reasons.push(CompositionFailureReason::new(
1806 format!("String length {length} exceeds maximum {maximum}"),
1807 property_path,
1808 ));
1809 }
1810 if let Some(minimum) = effective.min_length
1811 && length < minimum
1812 {
1813 reasons.push(CompositionFailureReason::new(
1814 format!("String length {length} is below minimum {minimum}"),
1815 property_path,
1816 ));
1817 }
1818 }
1819
1820 if let Some(items) = value.as_array() {
1821 let length = items.iter().filter(|item| !item.is_null()).count() as u64;
1822 if let Some(maximum) = effective.max_items
1823 && length > maximum
1824 {
1825 reasons.push(CompositionFailureReason::new(
1826 format!("Array length {length} exceeds maximum {maximum}"),
1827 property_path,
1828 ));
1829 }
1830 if let Some(minimum) = effective.min_items
1831 && length < minimum
1832 {
1833 reasons.push(CompositionFailureReason::new(
1834 format!("Array length {length} is below minimum {minimum}"),
1835 property_path,
1836 ));
1837 }
1838 if effective.unique_items == Some(true) {
1839 let mut duplicate_found = false;
1840 for (index, item) in items.iter().enumerate() {
1841 if item.is_null() {
1842 continue;
1843 }
1844 if items[..index].iter().any(|previous| !previous.is_null() && previous == item) {
1845 duplicate_found = true;
1846 break;
1847 }
1848 }
1849 if duplicate_found {
1850 reasons.push(CompositionFailureReason::new("Array items must be unique", property_path));
1851 }
1852 }
1853 if let Some(ref item_schema) = effective.items {
1854 for (index, item) in items.iter().enumerate() {
1855 if !schema_value_matches(item, item_schema, defs, depth + 1) {
1856 let item_path = append_property_path(property_path, &index.to_string());
1857 reasons.extend(schema_value_failure_reasons(item, item_schema, defs, depth + 1, &item_path));
1858 }
1859 }
1860 }
1861 }
1862
1863 if let Some(object) = value.as_object() {
1864 let property_present = |property: &str| object.get(property).is_some_and(|candidate| !candidate.is_null());
1865 let property_count = object.values().filter(|candidate| !candidate.is_null()).count() as u64;
1866 if let Some(maximum) = effective.max_properties
1867 && property_count > maximum
1868 {
1869 reasons.push(CompositionFailureReason::new(
1870 format!("Object has {property_count} properties, maximum is {maximum}"),
1871 property_path,
1872 ));
1873 }
1874 if let Some(minimum) = effective.min_properties
1875 && property_count < minimum
1876 {
1877 reasons.push(CompositionFailureReason::new(
1878 format!("Object has {property_count} properties, minimum is {minimum}"),
1879 property_path,
1880 ));
1881 }
1882 for required in &effective.required {
1883 if !property_present(required) {
1884 reasons
1885 .push(CompositionFailureReason::new(format!("'{required}' is a required property"), property_path));
1886 }
1887 }
1888 for (trigger, dependencies) in &effective.dependent_required {
1889 if property_present(trigger) {
1890 for dependency in dependencies {
1891 if !property_present(dependency) {
1892 reasons.push(CompositionFailureReason::new(
1893 format!("'{dependency}' is a dependency of '{trigger}'"),
1894 property_path,
1895 ));
1896 }
1897 }
1898 }
1899 }
1900 for (trigger, excluded) in &effective.dependent_excluded {
1901 if property_present(trigger) {
1902 for property in excluded {
1903 if property_present(property) {
1904 reasons.push(CompositionFailureReason::new(
1905 format!("'{property}' should not be included with '{trigger}'"),
1906 append_property_path(property_path, property),
1907 ));
1908 }
1909 }
1910 }
1911 }
1912 if !effective.required_or.is_empty() && !effective.required_or.iter().any(|property| property_present(property))
1913 {
1914 reasons.push(CompositionFailureReason::new(
1915 format!(
1916 "At least one of {} is required",
1917 effective.required_or.iter().map(|property| format!("'{property}'")).collect::<Vec<_>>().join(", ")
1918 ),
1919 property_path,
1920 ));
1921 }
1922 if !effective.required_xor.is_empty() {
1923 let present = effective.required_xor.iter().filter(|property| property_present(property)).count();
1924 if present != 1 {
1925 reasons.push(CompositionFailureReason::new(
1926 format!(
1927 "Exactly one of {} is required, but {present} are present",
1928 effective
1929 .required_xor
1930 .iter()
1931 .map(|property| format!("'{property}'"))
1932 .collect::<Vec<_>>()
1933 .join(", ")
1934 ),
1935 property_path,
1936 ));
1937 }
1938 }
1939 if effective.additional_properties == Some(false)
1940 && (!effective.properties.is_empty() || !effective.pattern_properties.is_empty())
1941 {
1942 let pattern_matchers: Vec<Option<Arc<CompiledPattern>>> =
1943 effective.pattern_properties.keys().map(|pattern| compile_pattern(pattern)).collect();
1944 for (property, property_value) in object {
1945 if property_value.is_null() || effective.properties.contains_key(property) {
1946 continue;
1947 }
1948 let allowed = pattern_matchers
1949 .iter()
1950 .any(|matcher| matcher.as_ref().is_none_or(|compiled| compiled.is_match(property)));
1951 if !allowed {
1952 reasons.push(CompositionFailureReason::new(
1953 format!("Additional property '{property}' is not allowed"),
1954 append_property_path(property_path, property),
1955 ));
1956 }
1957 }
1958 }
1959 for (property, property_schema) in &effective.properties {
1960 if let Some(property_value) = object.get(property)
1961 && !schema_value_matches(property_value, property_schema, defs, depth + 1)
1962 {
1963 let child_path = append_property_path(property_path, property);
1964 reasons.extend(schema_value_failure_reasons(
1965 property_value,
1966 property_schema,
1967 defs,
1968 depth + 1,
1969 &child_path,
1970 ));
1971 }
1972 }
1973 }
1974
1975 for branch in &effective.all_of {
1976 if !schema_value_matches(value, branch, defs, depth + 1) {
1977 reasons.extend(schema_value_failure_reasons(value, branch, defs, depth + 1, property_path));
1978 }
1979 }
1980 if !effective.any_of.is_empty()
1981 && !effective.any_of.iter().any(|branch| schema_value_matches(value, branch, defs, depth + 1))
1982 {
1983 for branch in &effective.any_of {
1984 reasons.extend(schema_value_failure_reasons(value, branch, defs, depth + 1, property_path));
1985 }
1986 }
1987 if !effective.one_of.is_empty() {
1988 let matching: Vec<usize> = effective
1989 .one_of
1990 .iter()
1991 .enumerate()
1992 .filter_map(|(index, branch)| schema_value_matches(value, branch, defs, depth + 1).then_some(index + 1))
1993 .collect();
1994 if matching.is_empty() {
1995 for branch in &effective.one_of {
1996 reasons.extend(schema_value_failure_reasons(value, branch, defs, depth + 1, property_path));
1997 }
1998 } else if matching.len() > 1 {
1999 reasons.push(CompositionFailureReason::new(
2000 format!(
2001 "Value matches nested oneOf branches {}; exactly one must match",
2002 render_branch_numbers(&matching)
2003 ),
2004 property_path,
2005 ));
2006 }
2007 }
2008 for conditional in effective.if_then_else.iter().filter(|conditional| conditional.enforce_full_branch) {
2009 if let Some(object) = value.as_object() {
2010 let object_keys: Vec<String> = object
2011 .iter()
2012 .filter(|(_, candidate)| !candidate.is_null())
2013 .map(|(property, _)| property.clone())
2014 .collect();
2015 let condition_matches =
2016 condition_schema_value_matches(&conditional.condition, object, &object_keys, defs, depth + 1);
2017 let branch = if condition_matches { &conditional.then_schema } else { &conditional.else_schema };
2018 if let Some(branch_schema) = branch
2019 && !schema_value_matches(value, branch_schema, defs, depth + 1)
2020 {
2021 reasons.extend(schema_value_failure_reasons(value, branch_schema, defs, depth + 1, property_path));
2022 }
2023 }
2024 }
2025
2026 deduplicate_composition_reasons(reasons)
2027}
2028
2029fn append_property_path(base: &str, segment: &str) -> String {
2030 if base.is_empty() { segment.to_string() } else { format!("{base}.{segment}") }
2031}
2032
2033fn json_value_type(value: &serde_json::Value) -> &'static str {
2034 match value {
2035 serde_json::Value::Null => "null",
2036 serde_json::Value::Bool(_) => "boolean",
2037 serde_json::Value::Number(number) if number.is_i64() || number.is_u64() => "integer",
2038 serde_json::Value::Number(_) => "number",
2039 serde_json::Value::String(_) => "string",
2040 serde_json::Value::Array(_) => "array",
2041 serde_json::Value::Object(_) => "object",
2042 }
2043}
2044
2045fn condition_schema_value_matches(
2049 cond: &ConditionSchema,
2050 obj: &serde_json::Map<String, serde_json::Value>,
2051 obj_keys: &[String],
2052 defs: &HashMap<String, PropSchema>,
2053 depth: usize,
2054) -> bool {
2055 if depth > MAX_MATCH_DEPTH {
2056 record_schema_budget_exhaustion(BudgetKind::SchemaMatchDepth);
2057 return true; }
2059
2060 if !cond.any_of.is_empty() {
2062 return cond.any_of.iter().any(|sub| condition_schema_value_matches(sub, obj, obj_keys, defs, depth + 1));
2063 }
2064
2065 if let Some(ref required_type) = cond.prop_type
2068 && !required_type.names().any(|name| name == "object")
2069 {
2070 return false;
2071 }
2072
2073 for req in &cond.required {
2075 if !obj_keys.iter().any(|k| k == req) {
2076 return false;
2077 }
2078 }
2079
2080 for (prop_name, prop_schema) in &cond.properties {
2082 let resolved = prop_schema.resolve(defs);
2083 if let Some(prop_value) = obj.get(prop_name) {
2084 if !schema_value_matches(prop_value, &resolved, defs, depth + 1) {
2085 return false;
2086 }
2087 } else {
2088 let has_value_constraint = !resolved.enum_values.is_empty()
2091 || !resolved.enum_case_insensitive.is_empty()
2092 || resolved.const_value.is_some()
2093 || resolved.pattern.is_some();
2094 if has_value_constraint {
2095 return false;
2096 }
2097 }
2098 }
2099
2100 true
2101}
2102
2103fn validate_sub_value_constraints(
2113 out: &mut Vec<Diagnostic>,
2114 m: &Arc<SemanticModel>,
2115 rid: &str,
2116 sub: &SubSchema,
2117 defs: &HashMap<String, PropSchema>,
2118 base_path: &str,
2119) {
2120 let has_value_constraints =
2126 sub.properties.values().any(|ps| ps.resolve(defs).constrains_value()) || sub_self_constrains_value(sub);
2127 if !has_value_constraints {
2128 return;
2129 }
2130
2131 for (prop_name, prop_schema) in &sub.properties {
2133 let resolved = prop_schema.resolve(defs);
2134 let prop_path = format!("{}.{}", base_path, prop_name);
2135 let shared_scenarios = m.resolve_scenarios_json_shared(rid, &prop_path);
2139 let scenarios: Vec<_> =
2140 shared_scenarios.iter().filter(|(_, conds)| scenario_consistent_with_filter(m, conds)).collect();
2141
2142 if scenarios.is_empty() {
2145 continue;
2146 }
2147
2148 let any_scenario_matches = scenarios.iter().any(|(val, conds)| {
2150 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, &prop_path, val, conds) {
2151 return true; }
2153 schema_value_matches(val, &resolved, defs, 0)
2154 });
2155
2156 if !any_scenario_matches {
2157 let (offending, failure_detail) = scenarios
2161 .iter()
2162 .find(|(val, conds)| {
2163 is_satisfiable(m, conds)
2164 && !val.is_null()
2165 && !defer_value_constraints(m, rid, &prop_path, val, conds)
2166 })
2167 .map(|(val, _)| {
2168 let reasons = schema_value_failure_reasons(val, &resolved, defs, 0, &prop_path);
2169 (format_value(val), render_composition_reasons(&reasons, &prop_path))
2170 })
2171 .unwrap_or_else(|| ("Value".to_string(), describe_prop_constraints(&resolved)));
2172 out.push(build_diagnostic(
2173 "F3017",
2174 &format!(
2175 "{offending} at '{prop_name}' does not satisfy the composition branch constraint ({}): {failure_detail}",
2176 describe_prop_constraints(&resolved)
2177 ),
2178 m,
2179 rid,
2180 &prop_path,
2181 None,
2182 ));
2183 }
2184 }
2185
2186 if sub_self_constrains_value(sub) {
2190 let self_schema = schema_for_self_value_constraints(sub);
2191 let shared_scenarios = m.resolve_scenarios_json_shared(rid, base_path);
2192 let scenarios: Vec<_> =
2193 shared_scenarios.iter().filter(|(_, conds)| scenario_consistent_with_filter(m, conds)).collect();
2194 if !scenarios.is_empty() {
2195 let any_scenario_matches = scenarios.iter().any(|(val, conds)| {
2196 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, base_path, val, conds)
2197 {
2198 return true;
2199 }
2200 schema_value_matches(val, &self_schema, defs, 0)
2201 });
2202 if !any_scenario_matches {
2203 let (offending, failure_detail) = scenarios
2204 .iter()
2205 .find(|(val, conds)| {
2206 is_satisfiable(m, conds)
2207 && !val.is_null()
2208 && !defer_value_constraints(m, rid, base_path, val, conds)
2209 })
2210 .map(|(val, _)| {
2211 let reasons = schema_value_failure_reasons(val, &self_schema, defs, 0, base_path);
2212 (format_value(val), render_composition_reasons(&reasons, base_path))
2213 })
2214 .unwrap_or_else(|| ("Value".to_string(), describe_prop_constraints(&self_schema)));
2215 out.push(build_diagnostic(
2216 "F3017",
2217 &format!(
2218 "{offending} does not satisfy the composition branch constraint ({}): {failure_detail}",
2219 describe_prop_constraints(&self_schema)
2220 ),
2221 m,
2222 rid,
2223 base_path,
2224 None,
2225 ));
2226 }
2227 }
2228 }
2229}
2230
2231fn branch_scenario_assignments<'a>(
2235 m: &Arc<SemanticModel>,
2236 rid: &str,
2237 branches: impl Iterator<Item = &'a SubSchema>,
2238 defs: &HashMap<String, PropSchema>,
2239 base_path: &str,
2240) -> Option<Vec<HashMap<String, bool>>> {
2241 let mut property_names = BTreeSet::new();
2242 for branch in branches {
2243 let effective_branch = branch.resolve(defs);
2244 property_names.extend(effective_branch.properties.keys().cloned());
2245 property_names.extend(effective_branch.required.iter().cloned());
2246 property_names.extend(effective_branch.required_or.iter().cloned());
2247 property_names.extend(effective_branch.required_xor.iter().cloned());
2248 }
2249
2250 let paths: Vec<String> = property_names.into_iter().map(|name| format!("{}.{}", base_path, name)).collect();
2251 let refs: Vec<&str> = paths.iter().map(String::as_str).collect();
2252 property_scenario_assignments(m, rid, &refs)
2253}
2254
2255fn assignment_condition_map(assignment: &HashMap<String, bool>) -> Option<HashMap<String, bool>> {
2259 if assignment.is_empty() { None } else { Some(assignment.clone()) }
2260}
2261
2262#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2263enum CompositionKind {
2264 AnyOf,
2265 OneOf,
2266}
2267
2268impl CompositionKind {
2269 fn name(self) -> &'static str {
2270 match self {
2271 CompositionKind::AnyOf => "anyOf",
2272 CompositionKind::OneOf => "oneOf",
2273 }
2274 }
2275
2276 fn expected_constraint(self) -> &'static str {
2277 match self {
2278 CompositionKind::AnyOf => "at least one anyOf branch",
2279 CompositionKind::OneOf => "exactly one oneOf branch",
2280 }
2281 }
2282}
2283
2284#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
2285struct CompositionFailureReason {
2286 message: String,
2287 property_path: String,
2288}
2289
2290impl CompositionFailureReason {
2291 fn new(message: impl Into<String>, property_path: impl Into<String>) -> Self {
2292 Self { message: message.into(), property_path: property_path.into() }
2293 }
2294}
2295
2296#[derive(Clone, Debug)]
2297struct CompositionBranchEvaluation {
2298 branch: usize,
2299 matched: bool,
2300 required_property_combinations: Vec<Vec<String>>,
2301 required_property_combinations_curtailed: bool,
2302 failure_reasons: Vec<CompositionFailureReason>,
2303}
2304
2305impl CompositionBranchEvaluation {
2306 fn new(
2307 branch: usize,
2308 matched: bool,
2309 required_property_combinations: (Vec<Vec<String>>, bool),
2310 failure_reasons: Vec<CompositionFailureReason>,
2311 ) -> Self {
2312 let (mut required_property_combinations, required_property_combinations_curtailed) =
2313 required_property_combinations;
2314 for combination in &mut required_property_combinations {
2315 combination.sort();
2316 combination.dedup();
2317 }
2318 required_property_combinations.retain(|combination| !combination.is_empty());
2319 required_property_combinations.sort();
2320 required_property_combinations.dedup();
2321 Self {
2322 branch,
2323 matched,
2324 required_property_combinations,
2325 required_property_combinations_curtailed,
2326 failure_reasons: deduplicate_composition_reasons(failure_reasons),
2327 }
2328 }
2329}
2330
2331fn deduplicate_composition_reasons(mut reasons: Vec<CompositionFailureReason>) -> Vec<CompositionFailureReason> {
2332 reasons.sort();
2333 reasons.dedup();
2334 reasons
2335}
2336
2337#[allow(clippy::too_many_arguments)]
2338fn evaluate_object_composition_branches(
2339 m: &Arc<SemanticModel>,
2340 rid: &str,
2341 rtype: &str,
2342 actual_keys: &[String],
2343 branches: &[SubSchema],
2344 defs: &HashMap<String, PropSchema>,
2345 base_path: &str,
2346 assignment: &HashMap<String, bool>,
2347) -> Vec<CompositionBranchEvaluation> {
2348 branches
2349 .iter()
2350 .enumerate()
2351 .map(|(index, branch)| {
2352 let mut branch_diagnostics = Vec::new();
2353 validate_sub_under_assignment(
2354 &mut branch_diagnostics,
2355 m,
2356 rid,
2357 rtype,
2358 actual_keys,
2359 branch,
2360 defs,
2361 base_path,
2362 assignment,
2363 );
2364 let matched = branch_diagnostics.is_empty();
2365 let mut failure_reasons: Vec<CompositionFailureReason> = branch_diagnostics
2366 .into_iter()
2367 .map(|diagnostic| {
2368 CompositionFailureReason::new(
2369 diagnostic.message,
2370 diagnostic.property_path.unwrap_or_else(|| base_path.to_string()),
2371 )
2372 })
2373 .collect();
2374 if !matched && failure_reasons.is_empty() {
2375 failure_reasons.push(CompositionFailureReason::new("The branch schema was not satisfied", base_path));
2376 }
2377 CompositionBranchEvaluation::new(
2378 index + 1,
2379 matched,
2380 required_property_combinations(branch, defs, Some(rtype)),
2381 failure_reasons,
2382 )
2383 })
2384 .collect()
2385}
2386
2387fn required_property_combinations(
2392 branch: &SubSchema,
2393 defs: &HashMap<String, PropSchema>,
2394 rtype: Option<&str>,
2395) -> (Vec<Vec<String>>, bool) {
2396 let resolved;
2397 let effective = if branch.ref_name.is_some() {
2398 resolved = branch.resolve(defs);
2399 if resolved.ref_name.is_some() {
2400 return (Vec::new(), false);
2401 }
2402 &*resolved
2403 } else {
2404 branch
2405 };
2406
2407 let mut base: Vec<String> = effective
2408 .required
2409 .iter()
2410 .filter(|property| {
2411 rtype.is_none_or(|resource_type| !extension_required_covered_by_dedicated_rule(resource_type, property))
2412 })
2413 .cloned()
2414 .collect();
2415 base.sort();
2416 base.dedup();
2417
2418 if effective.required.is_empty() && effective.required_or.is_empty() && effective.required_xor.is_empty() {
2419 return (Vec::new(), false);
2420 }
2421
2422 let mut combinations = vec![base];
2423 let mut curtailed = expand_required_choice_group(&mut combinations, &effective.required_or, false);
2424 curtailed |= expand_required_choice_group(&mut combinations, &effective.required_xor, true);
2425 (combinations, curtailed)
2426}
2427
2428fn expand_required_choice_group(combinations: &mut Vec<Vec<String>>, choices: &[String], exactly_one: bool) -> bool {
2431 if choices.is_empty() {
2432 return false;
2433 }
2434
2435 let mut expanded = Vec::new();
2436 let mut curtailed = false;
2437 'combinations: for combination in combinations.iter() {
2438 let present = choices.iter().filter(|choice| combination.contains(choice)).count();
2439 if present > 0 {
2440 if !exactly_one || present == 1 {
2441 if expanded.len() == MAX_REQUIRED_PROPERTY_COMBINATIONS {
2442 curtailed = true;
2443 break;
2444 }
2445 expanded.push(combination.clone());
2446 }
2447 continue;
2448 }
2449 for choice in choices {
2450 if expanded.len() == MAX_REQUIRED_PROPERTY_COMBINATIONS {
2451 curtailed = true;
2452 break 'combinations;
2453 }
2454 let mut candidate = combination.clone();
2455 candidate.push(choice.clone());
2456 expanded.push(candidate);
2457 }
2458 }
2459 *combinations = expanded;
2460 curtailed
2461}
2462
2463#[allow(clippy::too_many_arguments)]
2464fn build_composition_diagnostic(
2465 rule_id: &str,
2466 kind: CompositionKind,
2467 evaluations: &[CompositionBranchEvaluation],
2468 m: &Arc<SemanticModel>,
2469 rid: &str,
2470 property_path: &str,
2471 resource_type: Option<&str>,
2472 actual_value: Option<&serde_json::Value>,
2473 condition_scenario: Option<HashMap<String, bool>>,
2474) -> Diagnostic {
2475 let matching_branches: Vec<usize> =
2476 evaluations.iter().filter(|evaluation| evaluation.matched).map(|evaluation| evaluation.branch).collect();
2477 let match_count = matching_branches.len();
2478 let branch_count = evaluations.len();
2479 let match_outcome = if match_count == 0 { "zeroMatches" } else { "multipleMatches" };
2480
2481 let target = resource_type.map(|rtype| format!(" for {rtype}")).unwrap_or_default();
2482 let mut message = match (kind, match_count) {
2483 (CompositionKind::AnyOf, _) => format!(
2484 "Value is not valid under any of the {branch_count} anyOf schemas{target} (0 branches matched; at least one is required)."
2485 ),
2486 (CompositionKind::OneOf, 0) => format!(
2487 "Value is not valid under any of the {branch_count} oneOf schemas{target} (0 branches matched; exactly one is required)."
2488 ),
2489 (CompositionKind::OneOf, _) => format!(
2490 "Value is valid under more than one of the {branch_count} oneOf schemas{target} ({match_count} branches matched; exactly one is required). Matching branches: {}.",
2491 render_branch_numbers(&matching_branches)
2492 ),
2493 };
2494
2495 let mut valid_combination_set = BTreeSet::new();
2496 let mut combinations_curtailed =
2497 evaluations.iter().any(|evaluation| evaluation.required_property_combinations_curtailed);
2498 'evaluations: for evaluation in evaluations {
2499 for combination in &evaluation.required_property_combinations {
2500 if valid_combination_set.contains(combination) {
2501 continue;
2502 }
2503 if valid_combination_set.len() == MAX_REQUIRED_PROPERTY_COMBINATIONS {
2504 combinations_curtailed = true;
2505 break 'evaluations;
2506 }
2507 valid_combination_set.insert(combination.clone());
2508 }
2509 }
2510 let valid_combinations: Vec<Vec<String>> = valid_combination_set.into_iter().collect();
2511 if !valid_combinations.is_empty() {
2512 message.push_str(" Required property combinations: ");
2513 message.push_str(
2514 &valid_combinations
2515 .iter()
2516 .map(|combination| {
2517 format!(
2518 "[{}]",
2519 combination.iter().map(|property| format!("'{property}'")).collect::<Vec<_>>().join(", ")
2520 )
2521 })
2522 .collect::<Vec<_>>()
2523 .join(" or "),
2524 );
2525 message.push('.');
2526 }
2527 if combinations_curtailed {
2528 m.record_budget_exhaustion(BudgetKind::RequiredPropertyCombinations);
2529 message.push_str(&format!(
2530 " Additional required-property combinations were omitted after the deterministic limit of {MAX_REQUIRED_PROPERTY_COMBINATIONS}."
2531 ));
2532 }
2533
2534 if match_count == 0 {
2535 let failure_summaries: Vec<String> = evaluations
2536 .iter()
2537 .filter(|evaluation| !evaluation.matched)
2538 .map(|evaluation| {
2539 let reasons = render_composition_reasons(&evaluation.failure_reasons, property_path);
2540 format!("branch {}: {reasons}", evaluation.branch)
2541 })
2542 .collect();
2543 if !failure_summaries.is_empty() {
2544 message.push_str(" Branch failures: ");
2545 message.push_str(&failure_summaries.join("; "));
2546 message.push('.');
2547 }
2548 }
2549
2550 let mut extra = HashMap::new();
2551 extra.insert("compositionKind".to_string(), serde_json::json!(kind.name()).into());
2552 extra.insert("matchOutcome".to_string(), serde_json::json!(match_outcome).into());
2553 extra.insert("branchCount".to_string(), serde_json::json!(branch_count).into());
2554 extra.insert("matchCount".to_string(), serde_json::json!(match_count).into());
2555 if !valid_combinations.is_empty() {
2556 extra.insert("validPropertyCombinations".to_string(), serde_json::json!(valid_combinations).into());
2557 }
2558 if combinations_curtailed {
2559 extra.insert("validPropertyCombinationsCurtailed".to_string(), serde_json::json!(true).into());
2560 extra.insert(
2561 "validPropertyCombinationsLimit".to_string(),
2562 serde_json::json!(MAX_REQUIRED_PROPERTY_COMBINATIONS).into(),
2563 );
2564 }
2565 if !matching_branches.is_empty() {
2566 extra.insert("matchingBranches".to_string(), serde_json::json!(matching_branches).into());
2567 }
2568 let branch_failures: Vec<serde_json::Value> = evaluations
2569 .iter()
2570 .filter(|evaluation| !evaluation.matched)
2571 .map(|evaluation| {
2572 let reasons: Vec<serde_json::Value> = evaluation
2573 .failure_reasons
2574 .iter()
2575 .map(|reason| {
2576 serde_json::json!({
2577 "message": reason.message,
2578 "propertyPath": reason.property_path,
2579 })
2580 })
2581 .collect();
2582 serde_json::json!({
2583 "branch": evaluation.branch,
2584 "reasons": reasons,
2585 })
2586 })
2587 .collect();
2588 if !branch_failures.is_empty() {
2589 extra.insert("branchFailures".to_string(), serde_json::json!(branch_failures).into());
2590 }
2591
2592 let mut diagnostic =
2593 build_diagnostic_conditional(rule_id, &message, m, rid, property_path, None, condition_scenario);
2594 diagnostic.context = Some(ViolationContext {
2595 actual_value: actual_value.cloned().map(Into::into),
2596 expected_constraint: Some(kind.expected_constraint().to_string()),
2597 property: None,
2598 lifecycle: None,
2599 resolution_source: None,
2600 extra: Some(extra),
2601 });
2602 diagnostic
2603}
2604
2605fn render_branch_numbers(branches: &[usize]) -> String {
2606 branches.iter().map(usize::to_string).collect::<Vec<_>>().join(", ")
2607}
2608
2609fn render_composition_reasons(reasons: &[CompositionFailureReason], default_path: &str) -> String {
2610 reasons
2611 .iter()
2612 .map(|reason| {
2613 if reason.property_path.is_empty() || reason.property_path == default_path {
2614 reason.message.clone()
2615 } else {
2616 format!("at '{}': {}", reason.property_path, reason.message)
2617 }
2618 })
2619 .collect::<Vec<_>>()
2620 .join(", ")
2621}
2622
2623#[allow(clippy::too_many_arguments)]
2631fn validate_sub_under_assignment(
2632 out: &mut Vec<Diagnostic>,
2633 m: &Arc<SemanticModel>,
2634 rid: &str,
2635 rtype: &str,
2636 actual_keys: &[String],
2637 sub: &SubSchema,
2638 defs: &HashMap<String, PropSchema>,
2639 base_path: &str,
2640 assignment: &HashMap<String, bool>,
2641) {
2642 let Some(merged) = merged_scenario_filter(assignment) else {
2643 return;
2644 };
2645 let _scenario_filter_scope = ScenarioFilterScope::enter(merged.clone());
2646 let effective_keys: Vec<String> =
2647 actual_keys.iter().filter(|key| property_present_under(m, rid, base_path, key, &merged)).cloned().collect();
2648 validate_sub(out, m, rid, rtype, &effective_keys, sub, defs, base_path, 0);
2649}
2650
2651struct ScenarioFilterScope {
2652 previous: Option<HashMap<String, bool>>,
2653}
2654
2655impl ScenarioFilterScope {
2656 fn enter(assignment: HashMap<String, bool>) -> Self {
2657 let previous = SCENARIO_FILTER.with(|filter| filter.replace(Some(assignment)));
2658 Self { previous }
2659 }
2660}
2661
2662impl Drop for ScenarioFilterScope {
2663 fn drop(&mut self) {
2664 let previous = self.previous.take();
2665 SCENARIO_FILTER.with(|filter| {
2666 filter.replace(previous);
2667 });
2668 }
2669}
2670
2671fn merged_scenario_filter(assignment: &HashMap<String, bool>) -> Option<HashMap<String, bool>> {
2672 SCENARIO_FILTER.with(|filter| {
2673 let mut merged = filter.borrow().clone().unwrap_or_default();
2674 for (name, value) in assignment {
2675 if merged.get(name).is_some_and(|previous| previous != value) {
2676 return None;
2677 }
2678 merged.insert(name.clone(), *value);
2679 }
2680 Some(merged)
2681 })
2682}
2683
2684fn reset_scenario_filter() {
2685 SCENARIO_FILTER.with(|filter| {
2686 filter.replace(None);
2687 });
2688}
2689
2690thread_local! {
2691 static SCENARIO_FILTER: std::cell::RefCell<Option<HashMap<String, bool>>> = const { std::cell::RefCell::new(None) };
2698
2699 static SCHEMA_BUDGET_EXHAUSTIONS: std::cell::RefCell<BTreeSet<BudgetKind>> =
2703 const { std::cell::RefCell::new(BTreeSet::new()) };
2704}
2705
2706fn record_schema_budget_exhaustion(kind: BudgetKind) {
2707 SCHEMA_BUDGET_EXHAUSTIONS.with(|exhaustions| {
2708 exhaustions.borrow_mut().insert(kind);
2709 });
2710}
2711
2712fn reset_schema_budget_exhaustions() {
2713 SCHEMA_BUDGET_EXHAUSTIONS.with(|exhaustions| exhaustions.borrow_mut().clear());
2714}
2715
2716fn drain_schema_budget_exhaustions(model: &Arc<SemanticModel>) {
2717 SCHEMA_BUDGET_EXHAUSTIONS.with(|exhaustions| {
2718 for kind in std::mem::take(&mut *exhaustions.borrow_mut()) {
2719 model.record_budget_exhaustion(kind);
2720 }
2721 });
2722}
2723
2724fn scenario_consistent_with_filter(m: &Arc<SemanticModel>, conds: &HashMap<String, bool>) -> bool {
2727 SCENARIO_FILTER.with(|filter| {
2728 let borrowed = filter.borrow();
2729 let Some(assignment) = borrowed.as_ref() else {
2730 return true;
2731 };
2732 for (name, value) in assignment {
2733 if let Some(scenario_value) = conds.get(name)
2734 && scenario_value != value
2735 {
2736 return false;
2737 }
2738 }
2739 let mut merged = conds.clone();
2740 for (name, value) in assignment {
2741 merged.insert(name.clone(), *value);
2742 }
2743 is_satisfiable(m, &merged)
2744 })
2745}
2746
2747fn sub_self_constrains_value(sub: &PropSchema) -> bool {
2757 let PropSchema {
2758 ref_name: _,
2759 prop_type,
2760 enum_values,
2761 enum_case_insensitive,
2762 not_enum,
2763 const_value,
2764 pattern,
2765 minimum,
2766 maximum,
2767 exclusive_minimum,
2768 exclusive_maximum,
2769 multiple_of,
2770 min_length,
2771 max_length,
2772 min_items,
2773 max_items,
2774 unique_items,
2775 min_properties,
2776 max_properties,
2777 format,
2778 properties: _,
2779 required: _,
2780 required_present: _,
2781 additional_properties: _,
2782 pattern_properties: _,
2783 items,
2784 all_of,
2785 any_of,
2786 one_of,
2787 if_then_else,
2788 dependent_required: _,
2789 dependent_excluded: _,
2790 required_or: _,
2791 required_xor: _,
2792 } = sub;
2793 prop_type.is_some()
2794 || !enum_values.is_empty()
2795 || !enum_case_insensitive.is_empty()
2796 || !not_enum.is_empty()
2797 || const_value.is_some()
2798 || pattern.is_some()
2799 || minimum.is_some()
2800 || maximum.is_some()
2801 || exclusive_minimum.is_some()
2802 || exclusive_maximum.is_some()
2803 || multiple_of.is_some()
2804 || min_length.is_some()
2805 || max_length.is_some()
2806 || min_items.is_some()
2807 || max_items.is_some()
2808 || unique_items == &Some(true)
2809 || min_properties.is_some()
2810 || max_properties.is_some()
2811 || format.is_some()
2812 || items.is_some()
2813 || !all_of.is_empty()
2814 || !any_of.is_empty()
2815 || !one_of.is_empty()
2816 || !if_then_else.is_empty()
2817}
2818
2819fn schema_for_self_value_constraints(sub: &PropSchema) -> PropSchema {
2820 PropSchema {
2821 ref_name: None,
2822 properties: HashMap::new(),
2823 required: Vec::new(),
2824 required_present: false,
2825 additional_properties: None,
2826 pattern_properties: HashMap::new(),
2827 dependent_required: HashMap::new(),
2828 dependent_excluded: HashMap::new(),
2829 required_or: Vec::new(),
2830 required_xor: Vec::new(),
2831 ..sub.clone()
2832 }
2833}
2834
2835fn describe_prop_constraints(schema: &PropSchema) -> String {
2839 let mut parts: Vec<String> = Vec::new();
2840 if let Some(ref pt) = schema.prop_type {
2841 parts.push(format!("type '{}'", pt.names().collect::<Vec<_>>().join("|")));
2842 }
2843 if !schema.enum_values.is_empty() {
2844 parts.push(format!("one of {}", format_allowed_values(&schema.enum_values)));
2845 }
2846 if !schema.enum_case_insensitive.is_empty() {
2847 parts.push(format!("one of {} (case-insensitive)", format_allowed_values(&schema.enum_case_insensitive)));
2848 }
2849 if !schema.not_enum.is_empty() {
2850 parts.push(format!("none of {}", format_allowed_values(&schema.not_enum)));
2851 }
2852 if let Some(ref cv) = schema.const_value {
2853 parts.push(format!("exactly {cv}"));
2854 }
2855 if let Some(ref pattern) = schema.pattern {
2856 parts.push(format!("pattern '{pattern}'"));
2857 }
2858 if let Some(min) = schema.minimum {
2859 parts.push(format!("minimum {min}"));
2860 }
2861 if let Some(max) = schema.maximum {
2862 parts.push(format!("maximum {max}"));
2863 }
2864 if let Some(min) = schema.exclusive_minimum {
2865 parts.push(format!("exclusiveMinimum {min}"));
2866 }
2867 if let Some(max) = schema.exclusive_maximum {
2868 parts.push(format!("exclusiveMaximum {max}"));
2869 }
2870 if let Some(mult) = schema.multiple_of {
2871 parts.push(format!("multipleOf {mult}"));
2872 }
2873 if let Some(min) = schema.min_length {
2874 parts.push(format!("minLength {min}"));
2875 }
2876 if let Some(max) = schema.max_length {
2877 parts.push(format!("maxLength {max}"));
2878 }
2879 if let Some(min) = schema.min_items {
2880 parts.push(format!("minItems {min}"));
2881 }
2882 if let Some(max) = schema.max_items {
2883 parts.push(format!("maxItems {max}"));
2884 }
2885 if let Some(min) = schema.min_properties {
2886 parts.push(format!("minProperties {min}"));
2887 }
2888 if let Some(max) = schema.max_properties {
2889 parts.push(format!("maxProperties {max}"));
2890 }
2891 if !schema.required.is_empty() {
2892 parts.push(format!(
2893 "requires {}",
2894 schema.required.iter().map(|p| format!("'{p}'")).collect::<Vec<_>>().join(", ")
2895 ));
2896 }
2897 if schema.items.is_some() {
2898 parts.push("an item schema".to_string());
2899 }
2900 if !schema.properties.is_empty() {
2901 parts.push("nested property constraints".to_string());
2902 }
2903 if !schema.all_of.is_empty() || !schema.any_of.is_empty() || !schema.one_of.is_empty() {
2904 parts.push("nested composition".to_string());
2905 }
2906 if !schema.if_then_else.is_empty() {
2907 parts.push("a conditional constraint".to_string());
2908 }
2909 if parts.is_empty() { "the branch schema".to_string() } else { parts.join(", ") }
2910}
2911
2912fn validate_sub_dependencies(
2920 out: &mut Vec<Diagnostic>,
2921 m: &Arc<SemanticModel>,
2922 rid: &str,
2923 actual_keys: &[String],
2924 sub: &SubSchema,
2925 base_path: &str,
2926) {
2927 for (trigger, deps) in &sub.dependent_required {
2928 if actual_keys.contains(trigger) {
2929 for dep in deps {
2930 if !actual_keys.contains(dep) {
2931 out.push(build_diagnostic(
2932 "F3021",
2933 &format!("'{}' is a dependency of '{}'", dep, trigger),
2934 m,
2935 rid,
2936 base_path,
2937 None,
2938 ));
2939 }
2940 }
2941 }
2942 }
2943 for (trigger, excluded) in &sub.dependent_excluded {
2944 if actual_keys.contains(trigger) {
2945 for dep in excluded {
2946 if actual_keys.contains(dep) {
2947 out.push(build_diagnostic(
2948 "F3020",
2949 &format!("'{}' should not be included with '{}'", dep, trigger),
2950 m,
2951 rid,
2952 &format!("{}.{}", base_path, dep),
2953 None,
2954 ));
2955 }
2956 }
2957 }
2958 }
2959}
2960
2961struct ConditionalConstraintRule {
2972 resource_type: &'static str,
2973 property_name: &'static str,
2974 rule_id: &'static str,
2975 context: Option<&'static str>,
2981}
2982
2983const CONDITIONAL_CONSTRAINT_RULES: [ConditionalConstraintRule; 9] = [
2984 ConditionalConstraintRule {
2985 resource_type: "AWS::Cognito::UserPoolDomain",
2986 property_name: "Domain",
2987 rule_id: "E3031",
2988 context: None,
2989 },
2990 ConditionalConstraintRule {
2991 resource_type: "AWS::DynamoDB::GlobalTable",
2992 property_name: "AttributeDefinitions",
2993 rule_id: "E3032",
2994 context: Some("for a table with local secondary indexes"),
2995 },
2996 ConditionalConstraintRule {
2997 resource_type: "AWS::DynamoDB::GlobalTable",
2998 property_name: "KeySchema",
2999 rule_id: "E3032",
3000 context: Some("for a table with local secondary indexes"),
3001 },
3002 ConditionalConstraintRule {
3003 resource_type: "AWS::DynamoDB::Table",
3004 property_name: "AttributeDefinitions",
3005 rule_id: "E3032",
3006 context: Some("for a table with local secondary indexes"),
3007 },
3008 ConditionalConstraintRule {
3009 resource_type: "AWS::DynamoDB::Table",
3010 property_name: "KeySchema",
3011 rule_id: "E3032",
3012 context: Some("for a table with local secondary indexes"),
3013 },
3014 ConditionalConstraintRule {
3015 resource_type: "AWS::Lambda::Function",
3016 property_name: "Timeout",
3017 rule_id: "E3717",
3018 context: Some("for a function without CapacityProviderConfig"),
3019 },
3020 ConditionalConstraintRule {
3021 resource_type: "AWS::ApiGateway::Authorizer",
3022 property_name: "AuthorizerResultTtlInSeconds",
3023 rule_id: "E3718",
3024 context: Some("for a TOKEN or REQUEST authorizer"),
3025 },
3026 ConditionalConstraintRule {
3027 resource_type: "AWS::RDS::DBCluster",
3028 property_name: "MasterUsername",
3029 rule_id: "E3002",
3030 context: Some("for a PostgreSQL-compatible engine, where it is a reserved word"),
3031 },
3032 ConditionalConstraintRule {
3033 resource_type: "AWS::RDS::DBInstance",
3034 property_name: "BackupRetentionPeriod",
3035 rule_id: "E3719",
3036 context: Some("for a non-Aurora engine"),
3037 },
3038];
3039
3040fn conditional_constraint_rule(resource_type: &str, property_name: &str) -> Option<&'static ConditionalConstraintRule> {
3041 CONDITIONAL_CONSTRAINT_RULES
3042 .iter()
3043 .find(|rule| rule.resource_type == resource_type && rule.property_name == property_name)
3044}
3045
3046fn schema_for_conditional_value_constraints(resolved: &PropSchema) -> PropSchema {
3050 PropSchema {
3051 pattern: resolved.pattern.clone(),
3052 format: resolved.format.clone(),
3053 const_value: resolved.const_value.clone(),
3054 not_enum: resolved.not_enum.clone(),
3055 minimum: resolved.minimum,
3056 maximum: resolved.maximum,
3057 exclusive_minimum: resolved.exclusive_minimum,
3058 exclusive_maximum: resolved.exclusive_maximum,
3059 multiple_of: resolved.multiple_of,
3060 min_length: resolved.min_length,
3061 max_length: resolved.max_length,
3062 min_items: resolved.min_items,
3063 max_items: resolved.max_items,
3064 unique_items: resolved.unique_items,
3065 min_properties: resolved.min_properties,
3066 max_properties: resolved.max_properties,
3067 ..Default::default()
3068 }
3069}
3070
3071fn validate_owned_conditional_value_constraints(
3076 out: &mut Vec<Diagnostic>,
3077 m: &Arc<SemanticModel>,
3078 rid: &str,
3079 resource_type: &str,
3080 branch: &SubSchema,
3081 defs: &HashMap<String, PropSchema>,
3082 base_path: &str,
3083) {
3084 for (property_name, property_schema) in &branch.properties {
3085 let Some(rule) = conditional_constraint_rule(resource_type, property_name) else {
3086 continue;
3087 };
3088 let constraint = schema_for_conditional_value_constraints(&property_schema.resolve(defs));
3089 if !constraint.constrains_value() {
3090 continue;
3091 }
3092 let property_path = format!("{}.{}", base_path, property_name);
3093 let mut reported_messages: HashSet<String> = HashSet::new();
3094 for (value, conditions) in m.resolve_scenarios_json_shared(rid, &property_path).iter() {
3095 if value.is_null()
3096 || !is_satisfiable(m, conditions)
3097 || !scenario_consistent_with_filter(m, conditions)
3098 || defer_value_constraints(m, rid, &property_path, value, conditions)
3099 || schema_value_matches(value, &constraint, defs, 0)
3100 {
3101 continue;
3102 }
3103 let reasons = schema_value_failure_reasons(value, &constraint, defs, 0, &property_path);
3104 let failure = render_composition_reasons(&reasons, &property_path);
3105 let message = match rule.context {
3106 Some(context) => format!("{failure} {context}"),
3107 None => failure,
3108 };
3109 if reported_messages.insert(message.clone()) {
3110 let world = merged_scenario_filter(conditions).unwrap_or_else(|| conditions.clone());
3113 out.push(build_diagnostic_conditional(
3114 rule.rule_id,
3115 &message,
3116 m,
3117 rid,
3118 &property_path,
3119 None,
3120 condition_map(&world),
3121 ));
3122 }
3123 }
3124 }
3125}
3126
3127fn collect_outer_resolved_scenarios(
3128 value: &ResolvedValue,
3129 assumptions: &HashMap<String, bool>,
3130 scenarios: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
3131) {
3132 match value {
3133 ResolvedValue::Conditional { condition, if_true, if_false } => match assumptions.get(condition) {
3134 Some(true) => collect_outer_resolved_scenarios(if_true, assumptions, scenarios),
3135 Some(false) => collect_outer_resolved_scenarios(if_false, assumptions, scenarios),
3136 None => {
3137 let mut true_assumptions = assumptions.clone();
3138 true_assumptions.insert(condition.clone(), true);
3139 collect_outer_resolved_scenarios(if_true, &true_assumptions, scenarios);
3140 let mut false_assumptions = assumptions.clone();
3141 false_assumptions.insert(condition.clone(), false);
3142 collect_outer_resolved_scenarios(if_false, &false_assumptions, scenarios);
3143 }
3144 },
3145 ResolvedValue::Enum { variants } => {
3146 for variant in variants {
3147 collect_outer_resolved_scenarios(variant, assumptions, scenarios);
3148 }
3149 }
3150 _ => scenarios.push((value.clone(), assumptions.clone())),
3151 }
3152}
3153
3154fn outer_resolved_scenarios(
3155 model: &Arc<SemanticModel>,
3156 resource_id: &str,
3157 property_path: &str,
3158) -> Vec<(ResolvedValue, HashMap<String, bool>)> {
3159 let Some(value) =
3160 model.resolve_deep(resource_id, property_path).or_else(|| model.resolve(resource_id, property_path).cloned())
3161 else {
3162 return model.resolve_scenarios(resource_id, property_path);
3163 };
3164 let mut scenarios = Vec::new();
3165 collect_outer_resolved_scenarios(&value, &HashMap::new(), &mut scenarios);
3166 scenarios
3167}
3168
3169fn collect_outer_value_scenarios(
3170 value: &ResolvedValue,
3171 assumptions: &HashMap<String, bool>,
3172 scenarios: &mut Vec<(serde_json::Value, HashMap<String, bool>)>,
3173) {
3174 match value {
3175 ResolvedValue::Conditional { condition, if_true, if_false } => match assumptions.get(condition) {
3176 Some(true) => collect_outer_value_scenarios(if_true, assumptions, scenarios),
3177 Some(false) => collect_outer_value_scenarios(if_false, assumptions, scenarios),
3178 None => {
3179 let mut true_assumptions = assumptions.clone();
3180 true_assumptions.insert(condition.clone(), true);
3181 collect_outer_value_scenarios(if_true, &true_assumptions, scenarios);
3182 let mut false_assumptions = assumptions.clone();
3183 false_assumptions.insert(condition.clone(), false);
3184 collect_outer_value_scenarios(if_false, &false_assumptions, scenarios);
3185 }
3186 },
3187 ResolvedValue::Enum { variants } => {
3188 for variant in variants {
3189 collect_outer_value_scenarios(variant, assumptions, scenarios);
3190 }
3191 }
3192 ResolvedValue::Reference { .. } | ResolvedValue::Dynamic { .. } | ResolvedValue::TypedDynamic { .. } => {}
3193 ResolvedValue::Concrete { .. } | ResolvedValue::List { .. } | ResolvedValue::Map { .. } => {
3194 scenarios.push((resolved_value_to_json(value), assumptions.clone()));
3195 }
3196 }
3197}
3198
3199fn schema_requires_nested_value_scenarios(schema: &PropSchema) -> bool {
3200 let has_composite_value = |value: &serde_json::Value| value.is_array() || value.is_object();
3201 schema.min_items.is_some()
3202 || schema.max_items.is_some()
3203 || schema.unique_items == Some(true)
3204 || schema.min_properties.is_some()
3205 || schema.max_properties.is_some()
3206 || schema.enum_values.iter().any(has_composite_value)
3207 || schema.enum_case_insensitive.iter().any(has_composite_value)
3208 || schema.not_enum.iter().any(has_composite_value)
3209 || schema.const_value.as_ref().is_some_and(has_composite_value)
3210}
3211
3212fn validation_scenarios(
3213 model: &Arc<SemanticModel>,
3214 resource_id: &str,
3215 property_path: &str,
3216 schema: &PropSchema,
3217) -> Vec<(serde_json::Value, HashMap<String, bool>)> {
3218 if schema_requires_nested_value_scenarios(schema) {
3219 return model.resolve_scenarios_json(resource_id, property_path);
3220 }
3221 let Some(value) =
3222 model.resolve_deep(resource_id, property_path).or_else(|| model.resolve(resource_id, property_path).cloned())
3223 else {
3224 return model.resolve_scenarios_json(resource_id, property_path);
3225 };
3226 let mut scenarios = Vec::new();
3227 collect_outer_value_scenarios(&value, &HashMap::new(), &mut scenarios);
3228 scenarios
3229}
3230
3231fn validate_prop(
3232 out: &mut Vec<Diagnostic>,
3233 store: &CompiledSchemaStore,
3234 m: &Arc<SemanticModel>,
3235 rid: &str,
3236 rtype: &str,
3237 prop_path: &str,
3238 schema: &PropSchema,
3239 defs: &HashMap<String, PropSchema>,
3240 region: Option<&str>,
3241) {
3242 let scenarios = validation_scenarios(m, rid, prop_path, schema);
3243
3244 let is_type_exempt = TYPE_CHECK_EXEMPT_PATHS.iter().any(|(rt, pp)| *rt == rtype && *pp == prop_path);
3245
3246 if scenarios.is_empty() && !is_type_exempt {
3247 validate_reference_type(out, store, m, rid, prop_path, schema);
3248 }
3249
3250 let res_suffix = describe_resolution(m, rid, prop_path).map(|s| format!(" (from {})", s)).unwrap_or_default();
3251
3252 if let Some(ref pt) = schema.prop_type
3256 && !is_type_exempt
3257 {
3258 let is_packaging_path = PACKAGING_PROPERTY_PATHS.iter().any(|(rt, pp)| *rt == rtype && *pp == prop_path);
3259 let from_intrinsic = m.is_from_intrinsic(rid, prop_path)
3262 || prop_path
3263 .rsplit_once('.')
3264 .and_then(|(parent, seg)| seg.parse::<usize>().ok().map(|_| parent))
3265 .is_some_and(|parent| m.is_from_intrinsic(rid, parent));
3266 let mut emitted_type_errors: HashSet<(&str, &str)> = HashSet::new();
3273 for (val, conds) in &scenarios {
3274 if !is_satisfiable(m, conds) || val.is_null() {
3275 continue;
3276 }
3277 if is_unresolved_intrinsic(val) {
3279 continue;
3280 }
3281 if is_packaging_path && val.is_string() {
3283 continue;
3284 }
3285 if from_intrinsic {
3287 continue;
3288 }
3289 if !type_matches(val, pt) {
3290 let expected = pt.primary().unwrap_or("unknown");
3291 match coerce_value(val, expected) {
3292 CoerceResult::Coerced(_, ref description) => {
3293 if emitted_type_errors.insert(("W9003", expected)) {
3294 out.push(build_diagnostic_conditional(
3295 "W9003",
3296 &format!(
3297 "{}{} is not of type '{}' - automatically coerced ({})",
3298 format_value(val),
3299 res_suffix,
3300 expected,
3301 description
3302 ),
3303 m,
3304 rid,
3305 prop_path,
3306 None,
3307 condition_map(conds),
3308 ));
3309 }
3310 }
3311 _ => {
3312 if emitted_type_errors.insert(("F3012", expected)) {
3313 out.push(build_diagnostic_conditional(
3314 "F3012",
3315 &format!("{}{} is not of type '{}'", format_value(val), res_suffix, expected),
3316 m,
3317 rid,
3318 prop_path,
3319 None,
3320 condition_map(conds),
3321 ));
3322 }
3323 }
3324 }
3325 }
3326 }
3327 }
3328
3329 if !schema.enum_values.is_empty() {
3330 let prop_name = prop_path.strip_prefix("Properties.").unwrap_or(prop_path);
3331 let regional = store.region_enums().allowed_values(rtype, prop_name, region);
3335 for (val, conds) in &scenarios {
3336 if !is_satisfiable(m, conds) || val.is_null() {
3337 continue;
3338 }
3339 let matches = if let Some(regional_vals) = ®ional {
3340 val.as_str().map(|s| regional_vals.contains(&s)).unwrap_or(false)
3341 || enum_matches(val, &schema.enum_values)
3342 } else {
3343 enum_matches(val, &schema.enum_values)
3344 };
3345 if !matches {
3346 let enum_desc = if regional.is_some() {
3352 format!("allowed values for region '{}'", region_enums::region_label(region))
3353 } else {
3354 format_allowed_values(&schema.enum_values)
3355 };
3356 out.push(build_diagnostic_conditional(
3357 "W3030",
3358 &format!("{}{} is not one of {}", format_value(val), res_suffix, enum_desc),
3359 m,
3360 rid,
3361 prop_path,
3362 None,
3363 condition_map(conds),
3364 ));
3365 }
3366 }
3367 }
3368
3369 if !schema.enum_case_insensitive.is_empty() {
3370 for (val, conds) in &scenarios {
3371 if !is_satisfiable(m, conds) || val.is_null() {
3372 continue;
3373 }
3374 if !enum_matches_case_insensitive(val, &schema.enum_case_insensitive) {
3375 out.push(build_diagnostic_conditional(
3379 "W3030",
3380 &format!(
3381 "{}{} is not one of {} (case-insensitive)",
3382 format_value(val),
3383 res_suffix,
3384 format_allowed_values(&schema.enum_case_insensitive)
3385 ),
3386 m,
3387 rid,
3388 prop_path,
3389 None,
3390 condition_map(conds),
3391 ));
3392 }
3393 }
3394 }
3395
3396 if !schema.not_enum.is_empty() {
3397 for (val, conds) in &scenarios {
3398 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
3399 continue;
3400 }
3401 if enum_matches(val, &schema.not_enum) {
3402 out.push(build_diagnostic_conditional(
3403 "F3030",
3404 &format!("{} must not be one of {}", format_value(val), format_allowed_values(&schema.not_enum)),
3405 m,
3406 rid,
3407 prop_path,
3408 None,
3409 condition_map(conds),
3410 ));
3411 }
3412 }
3413 }
3414
3415 if let Some(ref cv) = schema.const_value {
3416 for (val, conds) in &scenarios {
3417 if !is_satisfiable(m, conds) || val.is_null() {
3418 continue;
3419 }
3420 if !scalar_eq(val, cv) {
3421 out.push(build_diagnostic_conditional(
3422 "F3030",
3423 &format!("{} was expected", cv),
3424 m,
3425 rid,
3426 prop_path,
3427 None,
3428 condition_map(conds),
3429 ));
3430 }
3431 }
3432 }
3433
3434 if let Some(ref pat) = schema.pattern
3435 && let Some(re) = compile_pattern(pat)
3436 {
3437 let from_param = m.is_from_parameter(rid, prop_path) || m.is_from_intrinsic(rid, prop_path);
3444 for (val, conds) in &scenarios {
3445 if !is_satisfiable(m, conds) || val.is_null() {
3446 continue;
3447 }
3448 if let Some(s) = coerce_to_string(val) {
3449 if s.contains("{{") && s.contains("resolve") {
3450 continue;
3451 }
3452 if from_param {
3453 continue;
3454 }
3455 if !re.is_match(&s) {
3456 out.push(build_diagnostic_conditional(
3457 "F3031",
3458 &format!("{} does not match pattern '{}'", format_value(val), pat),
3459 m,
3460 rid,
3461 prop_path,
3462 None,
3463 condition_map(conds),
3464 ));
3465 }
3466 }
3467 }
3468 }
3469
3470 if let Some(ref fmt) = schema.format {
3471 validate_format(out, m, rid, prop_path, fmt, &scenarios);
3472 }
3473
3474 for (val, conds) in &scenarios {
3475 if !is_satisfiable(m, conds) || val.is_null() {
3476 continue;
3477 }
3478 let Some(n) = coerce_to_number(val) else {
3479 continue;
3480 };
3481 if let Some(max) = schema.maximum
3482 && n > max
3483 {
3484 out.push(build_diagnostic_conditional(
3485 "F3034",
3486 &format!("{} is greater than the maximum of {}", n, max),
3487 m,
3488 rid,
3489 prop_path,
3490 None,
3491 condition_map(conds),
3492 ));
3493 }
3494 if let Some(min) = schema.minimum
3495 && n < min
3496 {
3497 out.push(build_diagnostic_conditional(
3498 "F3034",
3499 &format!("{} is less than the minimum of {}", n, min),
3500 m,
3501 rid,
3502 prop_path,
3503 None,
3504 condition_map(conds),
3505 ));
3506 }
3507 if let Some(emax) = schema.exclusive_maximum
3508 && n >= emax
3509 {
3510 out.push(build_diagnostic_conditional(
3511 "F3034",
3512 &format!("{} is >= exclusive maximum {}", n, emax),
3513 m,
3514 rid,
3515 prop_path,
3516 None,
3517 condition_map(conds),
3518 ));
3519 }
3520 if let Some(emin) = schema.exclusive_minimum
3521 && n <= emin
3522 {
3523 out.push(build_diagnostic_conditional(
3524 "F3034",
3525 &format!("{} is <= exclusive minimum {}", n, emin),
3526 m,
3527 rid,
3528 prop_path,
3529 None,
3530 condition_map(conds),
3531 ));
3532 }
3533 if let Some(mult) = schema.multiple_of
3534 && mult > 0.0
3535 {
3536 let remainder = (n / mult).round() * mult - n;
3540 let epsilon = mult * 1e-9;
3541 if remainder.abs() > epsilon && (mult - remainder.abs()).abs() > epsilon {
3542 out.push(build_diagnostic_conditional(
3543 "F3034",
3544 &format!("{} is not a multiple of {}", n, mult),
3545 m,
3546 rid,
3547 prop_path,
3548 None,
3549 condition_map(conds),
3550 ));
3551 }
3552 }
3553 }
3554
3555 if schema.min_length.is_some() || schema.max_length.is_some() {
3556 let from_param = m.is_from_parameter(rid, prop_path) || m.is_from_intrinsic(rid, prop_path);
3557 for (val, conds) in &scenarios {
3558 if !is_satisfiable(m, conds) || val.is_null() {
3559 continue;
3560 }
3561 let Some(s) = coerce_to_string(val) else {
3562 continue;
3563 };
3564 if from_param {
3565 continue;
3566 }
3567 let len = s.chars().count() as u64;
3568 if let Some(max) = schema.max_length
3569 && len > max
3570 {
3571 out.push(build_diagnostic_conditional(
3572 "F3033",
3573 &format!("length {} exceeds maximum {}", len, max),
3574 m,
3575 rid,
3576 prop_path,
3577 None,
3578 condition_map(conds),
3579 ));
3580 }
3581 if let Some(min) = schema.min_length
3582 && len < min
3583 {
3584 out.push(build_diagnostic_conditional(
3585 "F3033",
3586 &format!("length {} is below minimum {}", len, min),
3587 m,
3588 rid,
3589 prop_path,
3590 None,
3591 condition_map(conds),
3592 ));
3593 }
3594 }
3595 }
3596
3597 for (val, conds) in &scenarios {
3598 if !is_satisfiable(m, conds) || val.is_null() {
3599 continue;
3600 }
3601 if let Some(arr) = val.as_array() {
3602 let len = arr.iter().filter(|item| !item.is_null()).count() as u64;
3603 if let Some(max) = schema.max_items
3604 && len > max
3605 {
3606 out.push(build_diagnostic_conditional(
3607 "F3032",
3608 &format!("expected maximum item count: {}, found: {}", max, len),
3609 m,
3610 rid,
3611 prop_path,
3612 None,
3613 condition_map(conds),
3614 ));
3615 }
3616 if let Some(min) = schema.min_items
3617 && len < min
3618 {
3619 out.push(build_diagnostic_conditional(
3620 "F3032",
3621 &format!("expected minimum item count: {}, found: {}", min, len),
3622 m,
3623 rid,
3624 prop_path,
3625 None,
3626 condition_map(conds),
3627 ));
3628 }
3629 }
3630 }
3631
3632 if schema.unique_items == Some(true) {
3633 for (val, conds) in &scenarios {
3634 if !is_satisfiable(m, conds) || val.is_null() {
3635 continue;
3636 }
3637 if let Some(arr) = val.as_array() {
3638 let mut seen = Vec::new();
3639 for item in arr {
3640 if item.is_null() {
3645 continue;
3646 }
3647 if seen.contains(item) {
3648 out.push(build_diagnostic_conditional(
3649 "F3037",
3650 "Array items are not unique",
3651 m,
3652 rid,
3653 prop_path,
3654 None,
3655 condition_map(conds),
3656 ));
3657 break;
3658 }
3659 seen.push(item.clone());
3660 }
3661 }
3662 }
3663 }
3664
3665 let has_nested_constraints = !schema.properties.is_empty()
3666 || !schema.required.is_empty()
3667 || !schema.dependent_required.is_empty()
3668 || !schema.dependent_excluded.is_empty()
3669 || !schema.required_or.is_empty()
3670 || !schema.required_xor.is_empty()
3671 || !schema.all_of.is_empty()
3672 || !schema.any_of.is_empty()
3673 || !schema.one_of.is_empty()
3674 || !schema.if_then_else.is_empty()
3675 || schema.additional_properties.is_some();
3676 if has_nested_constraints {
3677 let nested_keys = collect_keys_deep(m, rid, prop_path);
3678 if !nested_keys.is_empty() {
3679 validate_object_keys(
3680 out,
3681 m,
3682 rid,
3683 rtype,
3684 &schema.properties,
3685 defs,
3686 &schema.required,
3687 schema.additional_properties,
3688 &schema.pattern_properties,
3689 &schema.dependent_required,
3690 &schema.dependent_excluded,
3691 &schema.required_or,
3692 &schema.required_xor,
3693 &schema.all_of,
3694 &schema.any_of,
3695 &schema.one_of,
3696 &nested_keys,
3697 prop_path,
3698 );
3699 for ite in &schema.if_then_else {
3704 let matches = condition_matches_at(&ite.condition, &nested_keys, m, rid, defs, prop_path);
3705 let sub = if matches { &ite.then_schema } else { &ite.else_schema };
3706 if let Some(sub) = sub {
3707 if ite.enforce_full_branch {
3708 validate_sub(out, m, rid, rtype, &nested_keys, sub, defs, prop_path, 0);
3709 } else {
3710 validate_sub_dependencies(out, m, rid, &nested_keys, sub, prop_path);
3711 }
3712 }
3713 }
3714 } else if !matches!(m.resolve_deep(rid, prop_path), Some(ResolvedValue::Conditional { .. })) {
3715 for (val, _conds) in m.resolve_scenarios(rid, prop_path) {
3724 if let ResolvedValue::Concrete { value } = &val
3725 && let Some(obj) = value.as_object()
3726 {
3727 let keys: Vec<String> = obj.keys().cloned().collect();
3728 validate_object_keys(
3729 out,
3730 m,
3731 rid,
3732 rtype,
3733 &schema.properties,
3734 defs,
3735 &schema.required,
3736 schema.additional_properties,
3737 &schema.pattern_properties,
3738 &schema.dependent_required,
3739 &schema.dependent_excluded,
3740 &schema.required_or,
3741 &schema.required_xor,
3742 &schema.all_of,
3743 &schema.any_of,
3744 &schema.one_of,
3745 &keys,
3746 prop_path,
3747 );
3748 }
3749 }
3750 }
3751
3752 let has_scalar_composition = !schema.all_of.is_empty()
3760 || !schema.any_of.is_empty()
3761 || !schema.one_of.is_empty()
3762 || !schema.if_then_else.is_empty();
3763 if has_scalar_composition && nested_keys.is_empty() {
3764 let has_only_scalars = scenarios.iter().all(|(val, conds)| {
3765 !is_satisfiable(m, conds)
3766 || val.is_null()
3767 || is_unresolved_intrinsic(val)
3768 || (!val.is_object() && !val.is_array())
3769 });
3770 if has_only_scalars {
3771 validate_prop_composition(out, m, rid, prop_path, schema, defs, &scenarios);
3772 }
3773 }
3774 for (pn, ps) in &schema.properties {
3775 let resolved = ps.resolve(defs);
3776 let sub_path = format!("{}.{}", prop_path, pn);
3777 let sub_scenarios = m.resolve_scenarios_json(rid, &sub_path);
3778 if !sub_scenarios.is_empty() || m.resolve_deep(rid, &sub_path).is_some() {
3779 validate_prop(out, store, m, rid, rtype, &sub_path, &resolved, defs, region);
3780 }
3781 }
3782 }
3783
3784 if let Some(ref item_schema) = schema.items {
3785 let resolved = item_schema.resolve(defs);
3786 let mut did_per_index = false;
3788 {
3789 let arr_len = match m.resolve_deep(rid, prop_path).or_else(|| m.resolve(rid, prop_path).cloned()) {
3790 Some(ResolvedValue::List { items }) => Some(items.len()),
3791 Some(ResolvedValue::Concrete { value: ref v }) if v.is_array() => Some(v.as_array().unwrap().len()),
3792 _ => None,
3793 };
3794 if let Some(len) = arr_len {
3795 did_per_index = true;
3796 for idx in 0..len {
3797 let idx_path = format!("{}.{}", prop_path, idx);
3798 validate_prop(out, store, m, rid, rtype, &idx_path, &resolved, defs, region);
3799 }
3800 } else {
3801 validate_prop(out, store, m, rid, rtype, &format!("{}.{{}}", prop_path), &resolved, defs, region);
3802 }
3803 }
3804 if !did_per_index && (!resolved.dependent_excluded.is_empty() || !resolved.dependent_required.is_empty()) {
3805 validate_array_item_constraints(out, m, rid, prop_path, &resolved);
3806 }
3807 }
3808}
3809
3810fn validate_array_item_constraints(
3811 out: &mut Vec<Diagnostic>,
3812 m: &Arc<SemanticModel>,
3813 rid: &str,
3814 array_path: &str,
3815 item_schema: &PropSchema,
3816) {
3817 let arr = match m.resolve_deep(rid, array_path).or_else(|| m.resolve(rid, array_path).cloned()) {
3818 Some(ResolvedValue::List { items }) => items,
3819 Some(ResolvedValue::Concrete { value: v }) => match v.into_inner() {
3820 serde_json::Value::Array(items) => {
3821 items.into_iter().map(|i| ResolvedValue::Concrete { value: i.into() }).collect()
3822 }
3823 _ => return,
3824 },
3825 _ => return,
3826 };
3827 for (idx, item) in arr.iter().enumerate() {
3828 let keys: Vec<String> = match item {
3829 ResolvedValue::Map { entries } => entries.iter().map(|e| e.key.clone()).collect(),
3830 ResolvedValue::Concrete { value: v } if v.is_object() => v.as_object().unwrap().keys().cloned().collect(),
3831 _ => continue,
3832 };
3833 let item_path = format!("{}.{}", array_path, idx);
3834 for (trigger, excluded) in &item_schema.dependent_excluded {
3835 if keys.iter().any(|k| k == trigger) {
3836 for dep in excluded {
3837 if keys.iter().any(|k| k == dep) {
3838 out.push(build_diagnostic(
3839 "F3020",
3840 &format!("'{}' should not be included with '{}'", dep, trigger),
3841 m,
3842 rid,
3843 &format!("{}.{}", item_path, dep),
3844 None,
3845 ));
3846 }
3847 }
3848 }
3849 }
3850 for (trigger, deps) in &item_schema.dependent_required {
3851 if keys.iter().any(|k| k == trigger) {
3852 for dep in deps {
3853 if !keys.iter().any(|k| k == dep) {
3854 out.push(build_diagnostic(
3855 "F3021",
3856 &format!("'{}' is a dependency of '{}'", dep, trigger),
3857 m,
3858 rid,
3859 &item_path,
3860 Some(&format!("Add '{}' when '{}' is specified", dep, trigger)),
3861 ));
3862 }
3863 }
3864 }
3865 }
3866 }
3867}
3868
3869fn collect_keys_deep(m: &Arc<SemanticModel>, rid: &str, path: &str) -> Vec<String> {
3870 let mut keys = HashSet::new();
3871 match m.resolve_deep(rid, path).or_else(|| m.resolve(rid, path).cloned()) {
3872 Some(ResolvedValue::Map { entries }) => {
3873 if entries.len() == 1 && is_intrinsic_key(&entries[0].key) {
3874 return Vec::new();
3875 }
3876 for e in &entries {
3877 keys.insert(e.key.clone());
3878 }
3879 }
3880 Some(ResolvedValue::Concrete { value: ref v }) if is_unresolved_intrinsic(v) => return Vec::new(),
3881 Some(ResolvedValue::Concrete { value: ref v }) if v.is_object() => {
3882 for k in v.as_object().unwrap().keys() {
3883 keys.insert(k.clone());
3884 }
3885 }
3886 _ => {}
3887 }
3888 if keys.is_empty() {
3889 for (val, conds) in &m.resolve_scenarios_json(rid, path) {
3890 if !is_satisfiable(m, conds) || val.is_null() || is_unresolved_intrinsic(val) {
3891 continue;
3892 }
3893 if let Some(obj) = val.as_object() {
3894 for k in obj.keys() {
3895 keys.insert(k.clone());
3896 }
3897 }
3898 }
3899 }
3900 keys.into_iter().collect()
3901}
3902
3903fn type_matches(val: &serde_json::Value, pt: &PropType) -> bool {
3904 match pt {
3905 PropType::Single(s) => single_type(val, s),
3906 PropType::Multi(types) => types.iter().any(|t| single_type(val, t)),
3907 }
3908}
3909
3910fn single_type(val: &serde_json::Value, expected: &str) -> bool {
3911 match expected {
3912 "string" => val.is_string(),
3913 "integer" => {
3914 val.is_i64() || val.is_u64() || (val.is_f64() && val.as_f64().map(|f| f.fract() == 0.0).unwrap_or(false))
3915 }
3916 "number" | "double" | "float" => val.is_number(),
3917 "boolean" => val.is_boolean(),
3918 "array" => val.is_array(),
3919 "object" => val.is_object(),
3920 "null" => val.is_null(),
3921 _ => true,
3922 }
3923}
3924
3925fn enum_matches(val: &serde_json::Value, allowed: &[serde_json::Value]) -> bool {
3926 allowed.iter().any(|a| scalar_eq(a, val))
3927}
3928
3929fn enum_matches_case_insensitive(val: &serde_json::Value, allowed: &[serde_json::Value]) -> bool {
3932 allowed.iter().any(|a| match (a.as_str(), val.as_str()) {
3933 (Some(allowed_str), Some(val_str)) => allowed_str.eq_ignore_ascii_case(val_str),
3934 _ => scalar_eq(a, val),
3935 })
3936}
3937
3938fn check_required_not_null(out: &mut Vec<Diagnostic>, m: &Arc<SemanticModel>, rid: &str, base: &str, req: &str) {
3939 for (value, conds) in &outer_resolved_scenarios(m, rid, &format!("{}.{}", base, req)) {
3940 if !is_satisfiable(m, conds) || !scenario_consistent_with_filter(m, conds) {
3941 continue;
3942 }
3943 if matches!(value, ResolvedValue::Concrete { value } if value.is_null()) {
3944 out.push(build_diagnostic_conditional(
3945 "F3003",
3946 &format!("'{}' is a required property", req),
3947 m,
3948 rid,
3949 base,
3950 Some(&format!("Add the required property '{}'", req)),
3951 condition_map(conds),
3952 ));
3953 }
3954 }
3955}
3956
3957fn is_satisfiable(m: &Arc<SemanticModel>, conds: &HashMap<String, bool>) -> bool {
3958 if conds.is_empty() {
3959 return true;
3960 }
3961 m.conditions.is_satisfiable(&conds.iter().map(|(k, v)| (k.clone(), *v)).collect::<Vec<_>>())
3962}
3963
3964fn condition_map(conds: &HashMap<String, bool>) -> Option<HashMap<String, bool>> {
3965 if conds.is_empty() { None } else { Some(conds.clone()) }
3966}
3967
3968fn find_similar<'a>(key: &str, known: &HashSet<&'a str>) -> Option<&'a str> {
3969 let kl = key.to_lowercase();
3970 known
3971 .iter()
3972 .find(|k| {
3973 let l = k.to_lowercase();
3974 let max = kl.len().max(l.len());
3975 if max == 0 {
3976 return true;
3977 }
3978 let d = levenshtein_distance(&kl, &l);
3979 1.0 - (d as f64 / max as f64) > 0.8
3980 })
3981 .copied()
3982}
3983
3984fn levenshtein_distance(a: &str, b: &str) -> usize {
3985 let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
3986 let mut dp = vec![vec![0usize; b.len() + 1]; a.len() + 1];
3987 for (i, row) in dp.iter_mut().enumerate() {
3988 row[0] = i;
3989 }
3990 for (j, cell) in dp[0].iter_mut().enumerate() {
3991 *cell = j;
3992 }
3993 for i in 1..=a.len() {
3994 for j in 1..=b.len() {
3995 let c = if a[i - 1] == b[j - 1] { 0 } else { 1 };
3996 dp[i][j] = (dp[i - 1][j] + 1).min(dp[i][j - 1] + 1).min(dp[i - 1][j - 1] + c);
3997 }
3998 }
3999 dp[a.len()][b.len()]
4000}
4001
4002fn format_value(val: &serde_json::Value) -> String {
4003 render_value(val)
4004}
4005
4006fn format_allowed_values(values: &[serde_json::Value]) -> String {
4007 render_value_list(values)
4008}
4009
4010fn condition_matches(
4011 cond: &ConditionSchema,
4012 actual_keys: &[String],
4013 m: &Arc<SemanticModel>,
4014 rid: &str,
4015 defs: &HashMap<String, PropSchema>,
4016) -> bool {
4017 condition_matches_at(cond, actual_keys, m, rid, defs, KEY_PROPERTIES)
4018}
4019
4020fn condition_matches_at(
4028 cond: &ConditionSchema,
4029 actual_keys: &[String],
4030 m: &Arc<SemanticModel>,
4031 rid: &str,
4032 defs: &HashMap<String, PropSchema>,
4033 base_path: &str,
4034) -> bool {
4035 if !cond.any_of.is_empty()
4036 && !cond.any_of.iter().any(|sub| condition_matches_at(sub, actual_keys, m, rid, defs, base_path))
4037 {
4038 return false;
4039 }
4040 if !cond.one_of.is_empty()
4041 && cond.one_of.iter().filter(|sub| condition_matches_at(sub, actual_keys, m, rid, defs, base_path)).count() != 1
4042 {
4043 return false;
4044 }
4045 if let Some(negated) = &cond.not
4046 && condition_matches_at(negated, actual_keys, m, rid, defs, base_path)
4047 {
4048 return false;
4049 }
4050 if let Some(ref required_type) = cond.prop_type
4051 && !required_type.names().any(|name| name == "object")
4052 {
4053 return false;
4054 }
4055 for required_property in &cond.required {
4056 if !actual_keys.iter().any(|key| key == required_property)
4057 || !property_present_under(m, rid, base_path, required_property, &HashMap::new())
4058 {
4059 return false;
4060 }
4061 }
4062 for absent_property in &cond.absent {
4066 if actual_keys.iter().any(|key| key == absent_property)
4067 && property_present_under(m, rid, base_path, absent_property, &HashMap::new())
4068 {
4069 return false;
4070 }
4071 }
4072 for (property_name, property_schema) in &cond.properties {
4073 let resolved_schema = property_schema.resolve(defs);
4074 let property_path = format!("{}.{}", base_path, property_name);
4075 let scenarios = m.resolve_scenarios_json(rid, &property_path);
4076 let has_concrete_constraint = resolved_schema.pattern.is_some()
4077 || !resolved_schema.enum_values.is_empty()
4078 || !resolved_schema.not_enum.is_empty()
4079 || resolved_schema.const_value.is_some();
4080 if scenarios.is_empty() {
4081 if has_concrete_constraint {
4082 return false;
4083 }
4084 continue;
4085 }
4086 let reachable_scenarios: Vec<_> = scenarios
4087 .iter()
4088 .filter(|(_, conditions)| is_satisfiable(m, conditions) && scenario_consistent_with_filter(m, conditions))
4089 .collect();
4090 if reachable_scenarios.is_empty() || reachable_scenarios.iter().all(|(value, _)| value.is_null()) {
4091 continue;
4092 }
4093 for nested_required_property in &resolved_schema.required {
4094 let nested_path = format!("{}.{}", property_path, nested_required_property);
4095 let nested_property_exists =
4096 m.resolve_scenarios_json(rid, &nested_path).iter().any(|(value, conditions)| {
4097 is_satisfiable(m, conditions) && scenario_consistent_with_filter(m, conditions) && !value.is_null()
4098 });
4099 if !nested_property_exists {
4100 return false;
4101 }
4102 }
4103 let compiled_pattern = resolved_schema.pattern.as_ref().and_then(|pattern| compile_pattern(pattern));
4104 if resolved_schema.pattern.is_some() && compiled_pattern.is_none() {
4105 return false;
4106 }
4107 let any_match = reachable_scenarios.iter().any(|(value, _)| {
4108 if value.is_null() {
4109 return true;
4110 }
4111 if !resolved_schema.enum_values.is_empty() && !enum_matches(value, &resolved_schema.enum_values) {
4112 return false;
4113 }
4114 if !resolved_schema.not_enum.is_empty() && enum_matches(value, &resolved_schema.not_enum) {
4115 return false;
4116 }
4117 if let Some(ref expected) = resolved_schema.const_value
4118 && !scalar_eq(value, expected)
4119 {
4120 return false;
4121 }
4122 if let Some(ref pattern) = compiled_pattern
4123 && !coerce_to_string(value).map(|text| pattern.is_match(&text)).unwrap_or(false)
4124 {
4125 return false;
4126 }
4127 if let Some(ref expected_type) = resolved_schema.prop_type
4128 && !type_matches(value, expected_type)
4129 {
4130 return false;
4131 }
4132 condition_bounds_match(value, &resolved_schema)
4133 });
4134 if !any_match {
4135 return false;
4136 }
4137 }
4138 true
4139}
4140
4141fn condition_bounds_match(val: &serde_json::Value, schema: &PropSchema) -> bool {
4147 if let Some(items) = val.as_array() {
4148 let len = items.iter().filter(|item| !item.is_null()).count() as u64;
4149 if schema.min_items.is_some_and(|min| len < min) || schema.max_items.is_some_and(|max| len > max) {
4150 return false;
4151 }
4152 }
4153 if let Some(text) = val.as_str() {
4154 let len = text.chars().count() as u64;
4155 if schema.min_length.is_some_and(|min| len < min) || schema.max_length.is_some_and(|max| len > max) {
4156 return false;
4157 }
4158 }
4159 if let Some(members) = val.as_object() {
4160 let len = members.len() as u64;
4161 if schema.min_properties.is_some_and(|min| len < min) || schema.max_properties.is_some_and(|max| len > max) {
4162 return false;
4163 }
4164 }
4165 true
4166}
4167
4168fn validate_reference_type(
4171 out: &mut Vec<Diagnostic>,
4172 store: &CompiledSchemaStore,
4173 m: &Arc<SemanticModel>,
4174 rid: &str,
4175 prop_path: &str,
4176 schema: &PropSchema,
4177) {
4178 let Some(ref expected_type) = schema.prop_type else {
4179 return;
4180 };
4181 let raw = m.resolve(rid, prop_path).cloned().or_else(|| m.resolve_deep(rid, prop_path));
4182 let Some(raw) = raw else { return };
4183
4184 match &raw {
4185 ResolvedValue::Reference { target, kind } => {
4186 let target_type = m.resources.get(target.as_str()).map(|r| r.resource_type.as_str());
4187 let Some(target_rtype) = target_type else {
4188 return;
4189 };
4190
4191 let source_type = match kind {
4192 RefKind::Ref => store.ref_types().ref_type_for(target_rtype),
4193 RefKind::GetAtt { attr } => store.ref_types().getatt_type_for(target_rtype, attr),
4194 _ => None,
4195 };
4196 let Some(source) = source_type else { return };
4197
4198 if !types_compatible(source, expected_type) {
4199 let ref_desc = match kind {
4200 RefKind::Ref => format!("Ref to '{}'", target),
4201 RefKind::GetAtt { attr: a } => {
4202 format!("GetAtt {}.{}", target, a)
4203 }
4204 _ => return,
4205 };
4206 out.push(build_diagnostic(
4207 "F3012",
4208 &format!(
4209 "{} ({}) returns '{}', but property expects '{}'",
4210 ref_desc,
4211 target_rtype,
4212 source,
4213 expected_type.primary().unwrap_or("unknown")
4214 ),
4215 m,
4216 rid,
4217 prop_path,
4218 None,
4219 ));
4220 }
4221 }
4222 ResolvedValue::TypedDynamic { reason: _name, param_type } => {
4223 let source = cfn_param_type_to_schema_type(param_type);
4224 if !types_compatible(source, expected_type) {
4225 let expected = expected_type.primary().unwrap_or("unknown");
4226 out.push(build_diagnostic(
4228 "W9003",
4229 &format!("Parameter type '{}' may not be compatible with expected type '{}'", param_type, expected),
4230 m,
4231 rid,
4232 prop_path,
4233 None,
4234 ));
4235 }
4236 }
4237 _ => {}
4238 }
4239}
4240
4241fn cfn_param_type_to_schema_type(param_type: &str) -> &str {
4242 match param_type {
4243 PARAM_TYPE_NUMBER => "number",
4244 PARAM_TYPE_STRING => "string",
4245 PARAM_TYPE_COMMA_DELIMITED_LIST => "array",
4246 t if t.starts_with("List<") => "array",
4247 t if t.starts_with("AWS::SSM::Parameter::") => "string",
4248 _ => "string",
4249 }
4250}
4251
4252fn types_compatible(source: &str, expected: &PropType) -> bool {
4253 match expected {
4254 PropType::Single(e) => single_type_compatible(source, e),
4255 PropType::Multi(types) => types.iter().any(|e| single_type_compatible(source, e)),
4256 }
4257}
4258
4259fn single_type_compatible(source: &str, expected: &str) -> bool {
4260 match (source, expected) {
4261 (s, e) if s == e => true,
4262 ("integer", "number") | ("number", "integer") => true,
4263 ("string", "number") | ("string", "integer") => true, ("number", "string") | ("integer", "string") | ("boolean", "string") => true, (_, "null") => true,
4266 _ => false,
4267 }
4268}
4269
4270const EC2_IMAGE_ID_FORMAT: &str = "AWS::EC2::Image.Id";
4271const EC2_LAUNCH_TEMPLATE_RESOURCE_TYPE: &str = "AWS::EC2::LaunchTemplate";
4272const EC2_LAUNCH_TEMPLATE_IMAGE_ID_PATH: &str = "Properties.LaunchTemplateData.ImageId";
4273const EC2_SSM_IMAGE_ALIAS_PREFIX: &str = "resolve:ssm:";
4274
4275pub(crate) fn prewarm_statics() {
4280 LazyLock::force(&FORMAT_PATTERNS);
4281 LazyLock::force(&BRANCH_FORMAT_PATTERNS);
4282}
4283
4284static FORMAT_PATTERNS: LazyLock<HashMap<&'static str, Arc<CompiledPattern>>> = LazyLock::new(|| {
4285 let sources: [(&str, &str); 13] = [
4286 ("AWS::EC2::VPC.Id", r"^vpc-[a-f0-9]{8,17}$"),
4287 ("AWS::EC2::Subnet.Id", r"^subnet-[a-f0-9]{8,17}$"),
4288 ("AWS::EC2::SecurityGroup.Id", r"^sg-[a-f0-9]{8,17}$"),
4289 (EC2_IMAGE_ID_FORMAT, r"^ami-([0-9a-z]{8}|[0-9a-z]{17})$"),
4290 ("AWS::IAM::Role.Arn", IAM_ROLE_ARN_PATTERN),
4291 ("AWS::Logs::LogGroup.Name", r"^[\.\-_/#A-Za-z0-9]{1,512}$"),
4292 ("AWS::EC2::SecurityGroup.Name", SECURITY_GROUP_NAME_PATTERN),
4293 ("AWS::EC2::KeyPair.KeyName", r"^[\x20-\x7E]{1,255}$"),
4294 ("AWS::EC2::AvailabilityZone.Name", r"^[a-z]{2}(-gov|-iso[a-z]*)?-[a-z]+-\d[a-z]$"),
4295 ("AWS::Route53::HostedZone.Id", r"^Z[A-Z0-9]{1,32}$"),
4296 ("AWS::EC2::Volume.Id", r"^vol-[a-f0-9]{8,17}$"),
4297 ("AWS::EC2::NetworkInterface.Id", r"^eni-[a-f0-9]{8,17}$"),
4298 ("AWS::SSM::Parameter.Name", r"^[a-zA-Z0-9_./-]{1,2048}$"),
4299 ];
4300 sources.into_iter().filter_map(|(fmt, pat)| compile_pattern(pat).map(|re| (fmt, re))).collect()
4301});
4302
4303static BRANCH_FORMAT_PATTERNS: LazyLock<HashMap<&'static str, Arc<CompiledPattern>>> = LazyLock::new(|| {
4308 let sources: &[(&str, &str)] = &[
4309 (
4315 "AWS::KMS::Key.Arn",
4316 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})$",
4317 ),
4318 (
4321 "AWS::KMS::Key.Id",
4322 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})$",
4323 ),
4324 ("AWS::KMS::Alias.AliasName", r"^alias/[a-zA-Z0-9:/_-]{1,250}$"),
4326 ("AWS::EC2::SecurityGroup.Name", SECURITY_GROUP_NAME_PATTERN),
4328 ("ipv4-network", r"^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$"),
4330 ("ipv6-network", r"^[0-9a-fA-F:]+/\d{1,3}$"),
4332 ("date-time", r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$"),
4334 ("timestamp", r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$"),
4336 ];
4337 sources.iter().filter_map(|(fmt, pat)| compile_pattern(pat).map(|re| (*fmt, re))).collect()
4338});
4339
4340fn format_value_matches(value: &str, format: &str) -> bool {
4349 if let Some(re) = FORMAT_PATTERNS.get(format) {
4351 return re.is_match(value);
4352 }
4353 if let Some(re) = BRANCH_FORMAT_PATTERNS.get(format) {
4355 return re.is_match(value);
4356 }
4357 match format {
4359 "AWS::EC2::SecurityGroup.Ids" => {
4360 FORMAT_PATTERNS.get("AWS::EC2::SecurityGroup.Id").is_none_or(|re| re.is_match(value))
4361 }
4362 "AWS::EC2::SecurityGroup.Names" => {
4363 FORMAT_PATTERNS.get("AWS::EC2::SecurityGroup.Name").is_none_or(|re| re.is_match(value))
4364 }
4365 "json" => serde_json::from_str::<serde_json::Value>(value).is_ok(),
4367 _ => true,
4369 }
4370}
4371
4372fn sns_kms_identifier_is_runtime_validated(model: &SemanticModel, resource_id: &str, property_path: &str) -> bool {
4373 property_path == "Properties.KmsMasterKeyId"
4374 && model.resource(resource_id).is_some_and(|resource| resource.resource_type == "AWS::SNS::Topic")
4375}
4376
4377fn ec2_resolves_ssm_image_alias(
4378 model: &SemanticModel,
4379 resource_id: &str,
4380 property_path: &str,
4381 format: &str,
4382 value: &str,
4383) -> bool {
4384 format == EC2_IMAGE_ID_FORMAT
4385 && property_path == EC2_LAUNCH_TEMPLATE_IMAGE_ID_PATH
4386 && value.starts_with(EC2_SSM_IMAGE_ALIAS_PREFIX)
4387 && model
4388 .resource(resource_id)
4389 .is_some_and(|resource| resource.resource_type == EC2_LAUNCH_TEMPLATE_RESOURCE_TYPE)
4390}
4391
4392fn validate_prop_composition(
4399 out: &mut Vec<Diagnostic>,
4400 m: &Arc<SemanticModel>,
4401 rid: &str,
4402 prop_path: &str,
4403 schema: &PropSchema,
4404 defs: &HashMap<String, PropSchema>,
4405 scenarios: &[(serde_json::Value, HashMap<String, bool>)],
4406) {
4407 for branch in &schema.all_of {
4409 for (val, conds) in scenarios {
4410 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
4411 continue;
4412 }
4413 if !schema_value_matches(val, branch, defs, 0) {
4414 out.push(build_diagnostic_conditional(
4415 "F3017",
4416 "Value does not satisfy allOf constraint",
4417 m,
4418 rid,
4419 prop_path,
4420 None,
4421 condition_map(conds),
4422 ));
4423 break;
4424 }
4425 }
4426 }
4427
4428 if !schema.any_of.is_empty() && !sns_kms_identifier_is_runtime_validated(m, rid, prop_path) {
4430 for (val, conds) in scenarios {
4431 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
4432 continue;
4433 }
4434 let evaluations = evaluate_value_composition_branches(val, &schema.any_of, defs, prop_path);
4435 if evaluations.iter().all(|evaluation| !evaluation.matched) {
4436 out.push(build_composition_diagnostic(
4437 "F3017",
4438 CompositionKind::AnyOf,
4439 &evaluations,
4440 m,
4441 rid,
4442 prop_path,
4443 None,
4444 Some(val),
4445 condition_map(conds),
4446 ));
4447 }
4448 }
4449 }
4450
4451 if !schema.one_of.is_empty() {
4453 for (val, conds) in scenarios {
4454 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
4455 continue;
4456 }
4457 let evaluations = evaluate_value_composition_branches(val, &schema.one_of, defs, prop_path);
4458 let match_count = evaluations.iter().filter(|evaluation| evaluation.matched).count();
4459 if match_count != 1 {
4460 out.push(build_composition_diagnostic(
4461 "F3018",
4462 CompositionKind::OneOf,
4463 &evaluations,
4464 m,
4465 rid,
4466 prop_path,
4467 None,
4468 Some(val),
4469 condition_map(conds),
4470 ));
4471 }
4472 }
4473 }
4474
4475 for ite in schema.if_then_else.iter().filter(|ite| ite.enforce_full_branch) {
4479 for (val, conds) in scenarios {
4480 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
4481 continue;
4482 }
4483 let cond_matches = if let Some(obj) = val.as_object() {
4487 let obj_keys: Vec<String> = obj
4488 .iter()
4489 .filter(|(_, candidate)| !candidate.is_null())
4490 .map(|(property, _)| property.clone())
4491 .collect();
4492 condition_schema_value_matches(&ite.condition, obj, &obj_keys, defs, 0)
4493 } else {
4494 true
4498 };
4499 let branch = if cond_matches { &ite.then_schema } else { &ite.else_schema };
4500 if let Some(branch_schema) = branch
4501 && !schema_value_matches(val, branch_schema, defs, 0)
4502 {
4503 out.push(build_diagnostic_conditional(
4504 "F3017",
4505 "Value does not satisfy conditional schema constraint",
4506 m,
4507 rid,
4508 prop_path,
4509 None,
4510 condition_map(conds),
4511 ));
4512 }
4513 }
4514 }
4515}
4516
4517fn validate_format(
4518 out: &mut Vec<Diagnostic>,
4519 m: &Arc<SemanticModel>,
4520 rid: &str,
4521 prop_path: &str,
4522 format: &str,
4523 scenarios: &[(serde_json::Value, HashMap<String, bool>)],
4524) {
4525 let Some(re) = FORMAT_PATTERNS.get(format) else {
4526 return;
4527 };
4528
4529 for (val, conds) in scenarios {
4530 if !is_satisfiable(m, conds) || val.is_null() {
4531 continue;
4532 }
4533 if let Some(s) = coerce_to_string(val) {
4534 if s.contains("${") && m.is_from_intrinsic(rid, prop_path) {
4535 continue;
4536 }
4537 if m.is_from_parameter(rid, prop_path) {
4538 continue;
4539 }
4540 if ec2_resolves_ssm_image_alias(m, rid, prop_path, format, &s) {
4541 continue;
4542 }
4543 if !re.is_match(&s) {
4544 let rule_id = format_rule_for_format(format).unwrap_or("E1103");
4545 out.push(build_diagnostic_conditional(
4546 rule_id,
4547 &format!("{} does not match format '{}'", format_value(val), format),
4548 m,
4549 rid,
4550 prop_path,
4551 None,
4552 condition_map(conds),
4553 ));
4554 }
4555 }
4556 }
4557}
4558
4559fn validate_lifecycle(out: &mut Vec<Diagnostic>, store: &CompiledSchemaStore, model: &Arc<SemanticModel>) {
4560 let lifecycle = store.lifecycle();
4561 for (rid, res) in &model.resources {
4562 if let Some(entry) = lifecycle.resource_lifecycle(&res.resource_type) {
4563 let (rule_id, msg) = match (entry.status.as_str(), entry.date.as_deref()) {
4564 ("shutdown", Some(d)) => (
4565 "E3710",
4566 format!("Resource type '{}' is from a service that was shut down on {}", res.resource_type, d),
4567 ),
4568 ("shutdown", None) => (
4569 "E3710",
4570 format!("Resource type '{}' is from a service that has been shut down", res.resource_type),
4571 ),
4572 ("sunset", Some(d)) => (
4573 "W3696",
4574 format!(
4575 "Resource type '{}' is from a service that will be shut down on {}. Plan to migrate to an alternative",
4576 res.resource_type, d
4577 ),
4578 ),
4579 ("sunset", None) => {
4580 ("W3696", format!("Resource type '{}' is from a service that is sunsetting", res.resource_type))
4581 }
4582 ("maintenance", Some(d)) => (
4583 "W3697",
4584 format!(
4585 "Resource type '{}' is from a service in maintenance mode since {}. Consider migrating to an alternative",
4586 res.resource_type, d
4587 ),
4588 ),
4589 ("maintenance", None) => {
4590 ("W3697", format!("Resource type '{}' is from a service in maintenance mode", res.resource_type))
4591 }
4592 _ => continue,
4593 };
4594 out.push(build_diagnostic(rule_id, &msg, model, rid, "", None));
4595 }
4596
4597 if (res.resource_type == "AWS::Lambda::Function" || res.resource_type == SAM_FUNCTION_TYPE)
4598 && !model.is_from_intrinsic(rid, "Properties.Runtime")
4603 {
4604 for (val, _) in &model.resolve_scenarios_json(rid, "Properties.Runtime") {
4605 let Some(runtime) = val.as_str() else {
4606 continue;
4607 };
4608 let (rule_id, band) = if lifecycle.is_runtime_eol(runtime) {
4612 ("E2533", Some(RuntimeLifecycleBand::EndOfLife))
4613 } else if lifecycle.is_runtime_create_blocked(runtime) {
4614 ("E2531", Some(RuntimeLifecycleBand::CreateBlocked))
4615 } else if lifecycle.is_runtime_deprecated(runtime) {
4616 ("W2531", Some(RuntimeLifecycleBand::Deprecated))
4617 } else {
4618 ("", None)
4619 };
4620 if let Some(band) = band {
4621 out.push(build_diagnostic(
4622 rule_id,
4623 &runtime_deprecation_message(lifecycle, runtime, band),
4624 model,
4625 rid,
4626 "Properties.Runtime",
4627 None,
4628 ));
4629 }
4630 }
4631 }
4632 }
4633}
4634
4635#[derive(Clone, Copy)]
4636enum RuntimeLifecycleBand {
4637 Deprecated,
4638 CreateBlocked,
4639 EndOfLife,
4640}
4641
4642fn runtime_deprecation_message(
4646 lifecycle: &crate::store::LifecycleStore,
4647 runtime: &str,
4648 band: RuntimeLifecycleBand,
4649) -> String {
4650 let Some(dates) = lifecycle.runtime_lifecycle(runtime) else {
4651 return format!("Runtime '{}' is deprecated", runtime);
4652 };
4653 let successor = match &dates.successor {
4654 Some(s) => format!("'{}'", s),
4655 None => "None".to_string(),
4656 };
4657 let (creation_status, update_status) = match band {
4658 RuntimeLifecycleBand::Deprecated => ("will be disabled", "will be disabled"),
4659 RuntimeLifecycleBand::CreateBlocked => ("was disabled", "will be disabled"),
4660 RuntimeLifecycleBand::EndOfLife => ("was disabled", "were disabled"),
4661 };
4662 format!(
4663 "Runtime '{}' was deprecated on '{}'. Creation {} on '{}', and updates {} on '{}'. Please consider updating to {}",
4664 runtime, dates.deprecated, creation_status, dates.create_block, update_status, dates.update_block, successor
4665 )
4666}
4667
4668fn validate_extensions(
4669 out: &mut Vec<Diagnostic>,
4670 store: &CompiledSchemaStore,
4671 model: &Arc<SemanticModel>,
4672 rid: &str,
4673 res: &ResolvedResource,
4674) {
4675 let Some(exts) = store.extensions().get(&res.resource_type) else {
4676 return;
4677 };
4678 for ext in exts {
4679 if ext.get("cfnGather").is_some() {
4680 validate_cfn_gather(out, model, rid, res, ext);
4681 } else if ext.get("if").is_some() {
4682 validate_extension_if_then_else(out, model, rid, res, ext);
4683 } else if let Some(all_of) = ext.get("allOf").and_then(|v| v.as_array()) {
4684 for sub_ext in all_of {
4685 if sub_ext.get("if").is_some() {
4686 validate_extension_if_then_else(out, model, rid, res, sub_ext);
4687 }
4688 }
4689 }
4690 }
4691}
4692
4693fn validate_cfn_gather(
4694 out: &mut Vec<Diagnostic>,
4695 model: &Arc<SemanticModel>,
4696 rid: &str,
4697 res: &ResolvedResource,
4698 ext: &serde_json::Value,
4699) {
4700 let Some(gather_def) = ext.get("cfnGather") else {
4701 return;
4702 };
4703 let Some(gather_slots) = gather_def.get("gather").and_then(|v| v.as_object()) else {
4704 return;
4705 };
4706 let Some(schema) = gather_def.get("schema") else {
4707 return;
4708 };
4709
4710 let mut context = serde_json::Map::new();
4711 for (slot_name, slot_def) in gather_slots {
4712 let slot_obj = match slot_def.as_object() {
4713 Some(o) => o,
4714 None => continue,
4715 };
4716 let properties = slot_obj.get("properties").and_then(|v| v.as_object());
4717 let reference_path = slot_obj.get("reference").and_then(|v| v.as_str());
4718
4719 let target_rid = if let Some(ref_path) = reference_path {
4720 let prop_key = ref_path.trim_start_matches('/').replace('/', ".");
4724 model.follow_ref(rid, &format!("Properties.{}", prop_key)).map(String::from)
4725 } else {
4726 Some(rid.to_string())
4727 };
4728
4729 let Some(target) = target_rid else { continue };
4730
4731 if let Some(filter) = slot_obj.get("filter").and_then(|v| v.as_object())
4732 && let Some(expected_type) = filter.get("type").and_then(|v| v.as_str())
4733 {
4734 let actual_type = model.resources.get(&target).map(|r| r.resource_type.as_str());
4735 if actual_type != Some(expected_type) {
4736 continue;
4737 }
4738 }
4739
4740 let mut slot_values = serde_json::Map::new();
4741 if let Some(props) = properties {
4742 for (prop_name, prop_def) in props {
4743 let (path, default_val) = match prop_def {
4749 serde_json::Value::String(p) => (Some(p.as_str()), None),
4750 _ => (prop_def.get("path").and_then(|v| v.as_str()), prop_def.get("default").cloned()),
4751 };
4752 let resolved_path = path.map(|p| format!("Properties.{}", p.trim_start_matches('/').replace('/', ".")));
4753
4754 let resolved = resolved_path
4755 .as_ref()
4756 .and_then(|p| model.resolve_scenarios_json(&target, p).into_iter().next().map(|(v, _)| v));
4757 let value = resolved.or(default_val).unwrap_or(serde_json::Value::Null);
4758 slot_values.insert(prop_name.clone(), value);
4759 }
4760 }
4761 context.insert(slot_name.clone(), serde_json::Value::Object(slot_values));
4762 }
4763
4764 let context_val = serde_json::Value::Object(context);
4765
4766 let resolved_schema = resolve_data_in_schema(schema, &context_val);
4767 evaluate_gather_schema(out, model, rid, res, &resolved_schema, &context_val);
4768}
4769
4770fn extension_required_covered_by_dedicated_rule(resource_type: &str, prop_name: &str) -> bool {
4784 matches!(
4785 (resource_type, prop_name),
4786 ("AWS::S3::Bucket", "OwnershipControls")
4787 | ("AWS::ElasticLoadBalancingV2::Listener", "Certificates")
4788 | ("AWS::RDS::DBInstance", "BackupRetentionPeriod")
4789 )
4790}
4791
4792fn validate_extension_if_then_else(
4793 out: &mut Vec<Diagnostic>,
4794 model: &Arc<SemanticModel>,
4795 rid: &str,
4796 res: &ResolvedResource,
4797 ext: &serde_json::Value,
4798) {
4799 let Some(if_schema) = ext.get("if") else {
4800 return;
4801 };
4802 let if_matches = extension_condition_matches(if_schema, model, rid);
4803 let branch = if if_matches { ext.get("then") } else { ext.get("else") };
4804 let Some(branch_schema) = branch else { return };
4805
4806 if let Some(required) = branch_schema.get("required").and_then(|v| v.as_array()) {
4807 for req in required {
4808 if let Some(prop_name) = req.as_str()
4809 && !res.properties.contains_key(prop_name)
4810 {
4811 if extension_required_covered_by_dedicated_rule(&res.resource_type, prop_name) {
4817 continue;
4818 }
4819 let already_reported = out.iter().any(|d| {
4824 d.rule_id == "F3003"
4825 && d.resource_logical_id() == Some(rid)
4826 && d.message.contains(&format!("'{}' is a required property", prop_name))
4827 });
4828 if already_reported {
4829 continue;
4830 }
4831 out.push(build_diagnostic(
4832 "F3003",
4833 &format!("'{}' is a required property (from extension)", prop_name),
4834 model,
4835 rid,
4836 KEY_PROPERTIES,
4837 Some(&format!("Add '{}'", prop_name)),
4838 ));
4839 }
4840 }
4841 }
4842
4843 if let Some(props) = branch_schema.get("properties").and_then(|v| v.as_object()) {
4844 for (prop_name, constraint) in props {
4845 if constraint == &serde_json::Value::Bool(false) && res.properties.contains_key(prop_name) {
4846 out.push(build_diagnostic(
4851 "I9002",
4852 &format!("'{}' is ignored in this configuration (from extension)", prop_name),
4853 model,
4854 rid,
4855 &format!("Properties.{}", prop_name),
4856 None,
4857 ));
4858 }
4859 }
4860 }
4861
4862 if let Some(props) = branch_schema.get("properties").and_then(|v| v.as_object()) {
4863 for (prop_name, constraint) in props {
4864 let constraint_obj = match constraint.as_object() {
4865 Some(o) => o,
4866 None => continue,
4867 };
4868 let Some(enum_vals) = constraint_obj.get("enum").and_then(|v| v.as_array()) else {
4869 continue;
4870 };
4871 let scenarios = model.resolve_scenarios_json(rid, &format!("Properties.{}", prop_name));
4872 for (val, conds) in &scenarios {
4873 if !is_satisfiable(model, conds) {
4874 continue;
4875 }
4876 let matches_enum = enum_vals.iter().any(|e| {
4885 scalar_eq(e, val) || e.as_str().zip(val.as_str()).is_some_and(|(a, b)| a.eq_ignore_ascii_case(b))
4886 });
4887 if !matches_enum {
4888 let allowed = render_str_list(enum_vals.iter().filter_map(|v| v.as_str()));
4889 out.push(build_diagnostic(
4890 "E9006",
4891 &format!("'{}' is not one of {}", coerce_to_string(val).unwrap_or_default(), allowed),
4892 model,
4893 rid,
4894 &format!("Properties.{}", prop_name),
4895 None,
4896 ));
4897 }
4898 }
4899 }
4900 }
4901}
4902
4903fn match_constraint_value(constraint: &serde_json::Map<String, serde_json::Value>, val: &serde_json::Value) -> bool {
4907 if let Some(enum_vals) = constraint.get("enum").and_then(|v| v.as_array()) {
4908 return enum_vals.iter().any(|e| scalar_eq(e, val));
4909 }
4910 if let Some(cv) = constraint.get("const") {
4911 return scalar_eq(val, cv);
4912 }
4913 if let Some(pat) = constraint.get("pattern").and_then(|v| v.as_str()) {
4914 return val.as_str().and_then(|s| compile_pattern(pat).map(|re| re.is_match(s))).unwrap_or(false);
4915 }
4916 true
4917}
4918
4919fn extension_condition_matches(if_schema: &serde_json::Value, model: &Arc<SemanticModel>, rid: &str) -> bool {
4920 let Some(obj) = if_schema.as_object() else {
4921 return false;
4922 };
4923
4924 if let Some(required) = obj.get("required").and_then(|v| v.as_array()) {
4925 for req in required {
4926 if let Some(name) = req.as_str() {
4927 let scenarios = model.resolve_scenarios_json(rid, &format!("Properties.{}", name));
4928 if scenarios.is_empty() {
4929 return false;
4930 }
4931 }
4932 }
4933 }
4934
4935 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
4936 for (prop_name, constraint) in props {
4937 let prop_path = format!("Properties.{}", prop_name);
4938 let scenarios = model.resolve_scenarios_json(rid, &prop_path);
4939 if constraint == &serde_json::Value::Bool(false) {
4946 let present = scenarios.iter().any(|(v, c)| is_satisfiable(model, c) && !v.is_null());
4947 if present {
4948 return false;
4949 }
4950 continue;
4951 }
4952 if scenarios.is_empty() {
4953 return false;
4954 }
4955 let constraint_obj = match constraint.as_object() {
4956 Some(o) => o,
4957 None => continue,
4958 };
4959 if let Some(nested_required) = constraint_obj.get("required").and_then(|v| v.as_array()) {
4961 for sub_req in nested_required {
4962 if let Some(sub_name) = sub_req.as_str() {
4963 let sub_path = format!("{}.{}", prop_path, sub_name);
4964 let sub_scenarios = model.resolve_scenarios_json(rid, &sub_path);
4965 let sub_exists = sub_scenarios.iter().any(|(v, c)| is_satisfiable(model, c) && !v.is_null());
4966 if !sub_exists {
4967 return false;
4968 }
4969 }
4970 }
4971 }
4972 let any_match = scenarios.iter().any(|(val, conds)| {
4973 if !is_satisfiable(model, conds) {
4974 return false;
4975 }
4976 if let Some(not_schema) = constraint_obj.get("not").and_then(|v| v.as_object()) {
4977 let inner_matches = match_constraint_value(not_schema, val);
4978 return !inner_matches;
4979 }
4980 match_constraint_value(constraint_obj, val)
4981 });
4982 if !any_match {
4983 return false;
4984 }
4985 }
4986 }
4987 true
4988}
4989
4990fn resolve_data_in_schema(schema: &serde_json::Value, context: &serde_json::Value) -> serde_json::Value {
4991 match schema {
4992 serde_json::Value::Object(obj) => {
4993 if let Some(lookup) = obj.get("$lookup").and_then(|v| v.as_object()) {
4994 let key = lookup
4995 .get("key")
4996 .map(|k| resolve_data_in_schema(k, context))
4997 .and_then(|v| v.as_str().map(String::from));
4998 let map = lookup.get("map").and_then(|v| v.as_object());
4999 if let (Some(k), Some(m)) = (key, map) {
5000 return m.get(&k).cloned().unwrap_or(serde_json::Value::Null);
5001 }
5002 return serde_json::Value::Null;
5003 }
5004 if let Some(pointer) = obj.get("$data").and_then(|v| v.as_str()) {
5005 return resolve_json_pointer(context, pointer);
5006 }
5007 let mut result = serde_json::Map::new();
5008 for (k, v) in obj {
5009 result.insert(k.clone(), resolve_data_in_schema(v, context));
5010 }
5011 serde_json::Value::Object(result)
5012 }
5013 serde_json::Value::Array(arr) => {
5014 serde_json::Value::Array(arr.iter().map(|v| resolve_data_in_schema(v, context)).collect())
5015 }
5016 other => other.clone(),
5017 }
5018}
5019
5020fn resolve_json_pointer(root: &serde_json::Value, pointer: &str) -> serde_json::Value {
5021 let parts: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
5022 let mut current = root;
5023 for part in &parts {
5024 if part.is_empty() {
5025 continue;
5026 }
5027 current = match current {
5028 serde_json::Value::Object(m) => match m.get(*part) {
5029 Some(v) => v,
5030 None => return serde_json::Value::Null,
5031 },
5032 serde_json::Value::Array(a) => match part.parse::<usize>() {
5033 Ok(idx) => match a.get(idx) {
5034 Some(v) => v,
5035 None => return serde_json::Value::Null,
5036 },
5037 Err(_) => return serde_json::Value::Null,
5038 },
5039 _ => return serde_json::Value::Null,
5040 };
5041 }
5042 current.clone()
5043}
5044
5045fn evaluate_gather_schema(
5046 out: &mut Vec<Diagnostic>,
5047 model: &Arc<SemanticModel>,
5048 rid: &str,
5049 _res: &ResolvedResource,
5050 schema: &serde_json::Value,
5051 context: &serde_json::Value,
5052) {
5053 let Some(obj) = schema.as_object() else {
5054 return;
5055 };
5056 if let Some(if_val) = obj.get("if") {
5065 let matches = gather_condition_matches(if_val, context);
5066 let branch = if matches { obj.get("then") } else { obj.get("else") };
5067 if let Some(branch_val) = branch {
5068 evaluate_gather_constraints(out, model, rid, branch_val, context);
5069 }
5070 }
5071}
5072
5073fn gather_condition_matches(condition: &serde_json::Value, context: &serde_json::Value) -> bool {
5074 let Some(obj) = condition.as_object() else {
5075 return false;
5076 };
5077
5078 if let Some(required) = obj.get("required").and_then(|v| v.as_array()) {
5079 for req in required {
5080 if let Some(name) = req.as_str() {
5081 let val = resolve_json_pointer(context, &format!("/{}", name));
5082 if val.is_null() {
5083 return false;
5084 }
5085 }
5086 }
5087 }
5088
5089 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
5090 for (prop_name, constraint) in props {
5091 let actual = resolve_json_pointer(context, &format!("/{}", prop_name));
5092 if actual.is_null() {
5093 return false;
5094 }
5095 if !gather_prop_matches(&actual, constraint) {
5096 return false;
5097 }
5098 }
5099 }
5100 true
5101}
5102
5103fn gather_prop_matches(actual: &serde_json::Value, constraint: &serde_json::Value) -> bool {
5104 let Some(obj) = constraint.as_object() else {
5105 return true;
5106 };
5107
5108 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
5109 for (k, v) in props {
5110 let child = match actual.get(k) {
5111 Some(c) => c,
5112 None => return false,
5113 };
5114 if !gather_prop_matches(child, v) {
5115 return false;
5116 }
5117 }
5118 }
5119 if let Some(required) = obj.get("required").and_then(|v| v.as_array()) {
5120 for req in required {
5121 if let Some(name) = req.as_str()
5122 && actual.get(name).is_none()
5123 {
5124 return false;
5125 }
5126 }
5127 }
5128 if let Some(cv) = obj.get("const") {
5129 return scalar_eq(actual, cv);
5130 }
5131 if let Some(enum_vals) = obj.get("enum").and_then(|v| v.as_array()) {
5132 return enum_vals.iter().any(|e| e == actual);
5133 }
5134 true
5135}
5136
5137fn evaluate_gather_constraints(
5138 out: &mut Vec<Diagnostic>,
5139 model: &Arc<SemanticModel>,
5140 rid: &str,
5141 schema: &serde_json::Value,
5142 context: &serde_json::Value,
5143) {
5144 let Some(obj) = schema.as_object() else {
5145 return;
5146 };
5147 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
5148 for (slot_name, slot_constraints) in props {
5149 let slot_val = resolve_json_pointer(context, &format!("/{}", slot_name));
5150 check_gather_property_constraints(out, model, rid, slot_name, &slot_val, slot_constraints);
5151 }
5152 }
5153}
5154
5155fn check_gather_property_constraints(
5156 out: &mut Vec<Diagnostic>,
5157 model: &Arc<SemanticModel>,
5158 rid: &str,
5159 slot_name: &str,
5160 actual: &serde_json::Value,
5161 constraint: &serde_json::Value,
5162) {
5163 let Some(obj) = constraint.as_object() else {
5164 return;
5165 };
5166 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
5167 for (prop_name, prop_constraint) in props {
5168 let prop_val = actual.get(prop_name).unwrap_or(&serde_json::Value::Null);
5169 let pc = match prop_constraint.as_object() {
5170 Some(o) => o,
5171 None => continue,
5172 };
5173 if let Some(cv) = pc.get("const")
5174 && !cv.is_null()
5175 && !scalar_eq(prop_val, cv)
5176 {
5177 out.push(build_diagnostic(
5178 "E3030",
5179 &format!(
5180 "Cross-resource constraint: {}.{} is {} but must be {} (from referenced resource)",
5181 slot_name,
5182 prop_name,
5183 format_value(prop_val),
5184 format_value(cv)
5185 ),
5186 model,
5187 rid,
5188 KEY_PROPERTIES,
5189 None,
5190 ));
5191 }
5192 }
5199 }
5200}
5201
5202fn describe_resolution(m: &Arc<SemanticModel>, rid: &str, prop_path: &str) -> Option<String> {
5203 let top_level_path;
5204 let (val, value_path) = match m.resolve(rid, prop_path) {
5205 Some(val) => (val, prop_path),
5206 None => {
5207 let stripped = prop_path.strip_prefix("Properties.")?;
5208 let top = stripped.split('.').next()?;
5209 let val = m.resources.get(rid)?.properties.get(top)?;
5210 top_level_path = format!("Properties.{}", top);
5211 (val, top_level_path.as_str())
5212 }
5213 };
5214 match val {
5215 ResolvedValue::Reference { target, kind } => {
5216 let kind_str = match kind {
5217 RefKind::Ref => "Ref",
5218 RefKind::GetAtt { attr: a } => {
5219 return Some(format!("GetAtt {}.{}", target, a));
5220 }
5221 RefKind::Sub { var: _ } => "Sub",
5222 RefKind::DependsOn => return None,
5223 };
5224 Some(format!("{} to '{}'", kind_str, target))
5225 }
5226 ResolvedValue::Enum { variants: _ } => Some("parameter with AllowedValues".into()),
5227 ResolvedValue::Conditional { condition: cond, if_true: _, if_false: _ } => {
5228 Some(format!("Fn::If on condition '{}'", cond))
5229 }
5230 ResolvedValue::Dynamic { reason: desc } => Some(format!("dynamic ({})", desc)),
5231 ResolvedValue::TypedDynamic { reason: desc, param_type: typ } => match m.parameter_name_at(rid, value_path) {
5237 Some(parameter) => Some(format!("parameter '{}' (type {})", parameter, typ)),
5238 None => Some(format!("dynamic ({})", desc)),
5239 },
5240 _ => None,
5241 }
5242}
5243
5244fn build_diagnostic(
5245 rule_id: &str,
5246 msg: &str,
5247 m: &Arc<SemanticModel>,
5248 rid: &str,
5249 prop: &str,
5250 fix: Option<&str>,
5251) -> Diagnostic {
5252 build_diagnostic_conditional(rule_id, msg, m, rid, prop, fix, None)
5253}
5254
5255fn build_diagnostic_conditional(
5256 rule_id: &str,
5257 msg: &str,
5258 m: &Arc<SemanticModel>,
5259 rid: &str,
5260 prop: &str,
5261 fix: Option<&str>,
5262 conds: Option<HashMap<String, bool>>,
5263) -> Diagnostic {
5264 let span = if rid.is_empty() { resolve_section_span(rule_id, m.as_ref()) } else { m.resource_span(rid, prop) };
5265 RegisteredDiagnostic::new(rule_id, msg)
5266 .resource(rid, m.resources.get(rid).map(|r| r.resource_type.clone()))
5267 .property_path(prop)
5268 .location(span)
5269 .suggested_fix(fix)
5270 .condition_scenario(conds)
5271 .phase(Phase::Schema)
5272 .build()
5273}
5274
5275#[cfg(test)]
5276mod tests {
5277 use super::*;
5278 use crate::compiled::IfThenElse;
5279 use data_source::compiled_schema::{RefSiblings, compile_schema_with};
5280 use serde_json::json;
5281
5282 fn current_scenario_filter() -> Option<HashMap<String, bool>> {
5283 SCENARIO_FILTER.with(|filter| filter.borrow().clone())
5284 }
5285
5286 #[test]
5287 fn scenario_filter_scope_restores_outer_assignment_after_unwind() {
5288 reset_scenario_filter();
5289 let _outer_scope = ScenarioFilterScope::enter(HashMap::from([("Outer".to_string(), true)]));
5290 let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5291 let _inner_scope = ScenarioFilterScope::enter(HashMap::from([("Inner".to_string(), false)]));
5292 assert_eq!(current_scenario_filter(), Some(HashMap::from([("Inner".to_string(), false)])));
5293 panic!("exercise unwind cleanup");
5294 }));
5295 assert!(unwind.is_err());
5296 assert_eq!(current_scenario_filter(), Some(HashMap::from([("Outer".to_string(), true)])));
5297 }
5298
5299 #[test]
5300 fn validation_entry_clears_a_stale_scenario_filter() {
5301 SCENARIO_FILTER.with(|filter| {
5302 filter.replace(Some(HashMap::from([("PreviousTemplate".to_string(), true)])));
5303 });
5304 let diagnostics = diagnostics_for_mode_value("managed");
5305 assert!(diagnostics.is_empty(), "the new validation must not inherit a prior scenario assignment");
5306 assert!(current_scenario_filter().is_none(), "validation leaves no scenario filter behind");
5307 }
5308
5309 #[test]
5310 fn levenshtein_distance_identical_strings() {
5311 assert_eq!(levenshtein_distance("abc", "abc"), 0);
5312 }
5313
5314 #[test]
5315 fn levenshtein_distance_empty_strings() {
5316 assert_eq!(levenshtein_distance("", ""), 0);
5317 }
5318
5319 #[test]
5320 fn levenshtein_distance_one_empty() {
5321 assert_eq!(levenshtein_distance("abc", ""), 3);
5322 assert_eq!(levenshtein_distance("", "xyz"), 3);
5323 }
5324
5325 #[test]
5326 fn levenshtein_distance_single_substitution() {
5327 assert_eq!(levenshtein_distance("cat", "bat"), 1);
5328 }
5329
5330 #[test]
5331 fn levenshtein_distance_single_insertion() {
5332 assert_eq!(levenshtein_distance("abc", "abcd"), 1);
5333 }
5334
5335 #[test]
5336 fn levenshtein_distance_single_deletion() {
5337 assert_eq!(levenshtein_distance("abcd", "abc"), 1);
5338 }
5339
5340 #[test]
5341 fn levenshtein_distance_transposition() {
5342 assert_eq!(levenshtein_distance("ab", "ba"), 2);
5343 }
5344
5345 #[test]
5346 fn levenshtein_distance_completely_different() {
5347 assert_eq!(levenshtein_distance("abc", "xyz"), 3);
5348 }
5349
5350 #[test]
5351 fn find_similar_exact_match() {
5352 let known: HashSet<&str> = ["BucketName", "AccessControl"].into_iter().collect();
5353 assert_eq!(find_similar("BucketName", &known), Some("BucketName"));
5354 }
5355
5356 #[test]
5357 fn find_similar_close_typo() {
5358 let known: HashSet<&str> = ["BucketName", "AccessControl"].into_iter().collect();
5359 let result = find_similar("Bucketame", &known);
5360 assert_eq!(result, Some("BucketName"));
5361 }
5362
5363 #[test]
5364 fn find_similar_no_match() {
5365 let known: HashSet<&str> = ["BucketName", "AccessControl"].into_iter().collect();
5366 assert_eq!(find_similar("CompletelyDifferent", &known), None, "dissimilar name should not match");
5367 }
5368
5369 #[test]
5370 fn find_similar_case_insensitive() {
5371 let known: HashSet<&str> = ["BucketName"].into_iter().collect();
5372 assert_eq!(find_similar("bucketname", &known), Some("BucketName"));
5373 }
5374
5375 #[test]
5376 fn type_matches_string() {
5377 assert!(type_matches(&json!("hello"), &PropType::Single("string".into())));
5378 assert!(!type_matches(&json!(42), &PropType::Single("string".into())));
5379 }
5380
5381 #[test]
5382 fn type_matches_integer() {
5383 assert!(type_matches(&json!(42), &PropType::Single("integer".into())));
5384 assert!(type_matches(&json!(42.0), &PropType::Single("integer".into())));
5385 assert!(!type_matches(&json!(42.5), &PropType::Single("integer".into())));
5386 assert!(!type_matches(&json!("42"), &PropType::Single("integer".into())));
5387 }
5388
5389 #[test]
5390 fn type_matches_number() {
5391 assert!(type_matches(&json!(42), &PropType::Single("number".into())));
5392 assert!(type_matches(&json!(3.14), &PropType::Single("number".into())));
5393 assert!(!type_matches(&json!("3.14"), &PropType::Single("number".into())));
5394 }
5395
5396 #[test]
5397 fn type_matches_boolean() {
5398 assert!(type_matches(&json!(true), &PropType::Single("boolean".into())));
5399 assert!(!type_matches(&json!("true"), &PropType::Single("boolean".into())));
5400 }
5401
5402 #[test]
5403 fn type_matches_array() {
5404 assert!(type_matches(&json!([1, 2]), &PropType::Single("array".into())));
5405 assert!(!type_matches(&json!("[]"), &PropType::Single("array".into())));
5406 }
5407
5408 #[test]
5409 fn type_matches_object() {
5410 assert!(type_matches(&json!({"a": 1}), &PropType::Single("object".into())));
5411 assert!(!type_matches(&json!("{}"), &PropType::Single("object".into())));
5412 }
5413
5414 #[test]
5415 fn type_matches_null() {
5416 assert!(type_matches(&json!(null), &PropType::Single("null".into())));
5417 assert!(!type_matches(&json!("null"), &PropType::Single("null".into())));
5418 }
5419
5420 #[test]
5421 fn type_matches_multi_with_null() {
5422 let pt = PropType::Multi(vec!["string".into(), "null".into()]);
5423 assert!(type_matches(&json!("hello"), &pt));
5424 assert!(type_matches(&json!(null), &pt));
5425 assert!(!type_matches(&json!(42), &pt));
5426 }
5427
5428 #[test]
5429 fn type_matches_unknown_type_always_true() {
5430 assert!(type_matches(&json!("anything"), &PropType::Single("custom_type".into())));
5431 }
5432
5433 #[test]
5434 fn enum_matches_exact() {
5435 assert!(enum_matches(&json!("a"), &[json!("a"), json!("b")]));
5436 assert!(!enum_matches(&json!("c"), &[json!("a"), json!("b")]));
5437 }
5438
5439 #[test]
5440 fn enum_matches_coerced_number_to_string() {
5441 assert!(enum_matches(&json!("42"), &[json!(42)]));
5442 }
5443
5444 #[test]
5445 fn enum_matches_empty_allowed() {
5446 assert!(!enum_matches(&json!("a"), &[]));
5447 }
5448
5449 #[test]
5450 fn enum_matches_case_insensitive_any_casing() {
5451 let allowed = [json!("managed"), json!("unmanaged")];
5452 assert!(enum_matches_case_insensitive(&json!("managed"), &allowed));
5453 assert!(enum_matches_case_insensitive(&json!("MANAGED"), &allowed));
5454 assert!(enum_matches_case_insensitive(&json!("Unmanaged"), &allowed));
5455 }
5456
5457 #[test]
5458 fn enum_matches_case_insensitive_rejects_unlisted_value() {
5459 let allowed = [json!("managed"), json!("unmanaged")];
5460 assert!(!enum_matches_case_insensitive(&json!("BOGUS"), &allowed));
5461 }
5462
5463 #[test]
5464 fn enum_matches_case_insensitive_non_string_uses_exact_comparison() {
5465 assert!(enum_matches_case_insensitive(&json!(42), &[json!(42)]));
5466 assert!(!enum_matches_case_insensitive(&json!(43), &[json!(42)]));
5467 }
5468
5469 fn store_with_case_insensitive_mode_enum() -> CompiledSchemaStore {
5470 let mode =
5471 PropSchema { enum_case_insensitive: vec![json!("managed"), json!("unmanaged")], ..Default::default() };
5472 let mut properties = HashMap::new();
5473 properties.insert("Mode".to_string(), mode);
5474 let mut store = CompiledSchemaStore::new();
5475 store.insert_schema(CompiledSchema {
5476 type_name: "AWS::Fake::Type".to_string(),
5477 properties,
5478 ..Default::default()
5479 });
5480 store
5481 }
5482
5483 fn diagnostics_for_mode_value(mode_value: &str) -> Vec<Diagnostic> {
5484 let template = format!(
5485 r#"{{"Resources":{{"Widget":{{"Type":"AWS::Fake::Type","Properties":{{"Mode":"{}"}}}}}}}}"#,
5486 mode_value
5487 );
5488 let model = Arc::new(SemanticModel::from_bytes(template.as_bytes()).expect("template parses"));
5489 validate_all_resources(&store_with_case_insensitive_mode_enum(), &model, None)
5490 }
5491
5492 #[test]
5493 fn case_insensitive_enum_accepts_any_casing_of_allowed_value() {
5494 for accepted in ["managed", "MANAGED", "Unmanaged"] {
5495 let w3030: Vec<Diagnostic> =
5496 diagnostics_for_mode_value(accepted).into_iter().filter(|d| d.rule_id == "W3030").collect();
5497 assert!(w3030.is_empty(), "'{accepted}' must not fire W3030, got: {w3030:?}");
5498 }
5499 }
5500
5501 #[test]
5502 fn case_insensitive_enum_flags_unlisted_value_with_marked_message() {
5503 let diags = diagnostics_for_mode_value("BOGUS");
5504 let w3030 = diags.iter().find(|d| d.rule_id == "W3030").expect("W3030 fires for a value not in the enum");
5505 assert_eq!(w3030.severity, rules::Severity::Warn);
5506 assert_eq!(w3030.property_path.as_deref(), Some("Properties.Mode"));
5507 assert_eq!(w3030.message, "'BOGUS' is not one of ['managed', 'unmanaged'] (case-insensitive)");
5508 }
5509
5510 fn store_with_bundled_schema(type_name: &str, raw_schema: serde_json::Value) -> CompiledSchemaStore {
5514 let mut store = CompiledSchemaStore::new();
5515 store.insert_schema(compile_schema_with(type_name, &raw_schema, RefSiblings::Ignore).into());
5516 store
5517 }
5518
5519 fn diagnostics_for_template(store: &CompiledSchemaStore, template: serde_json::Value) -> Vec<Diagnostic> {
5520 let model = Arc::new(SemanticModel::from_bytes(template.to_string().as_bytes()).expect("template parses"));
5521 validate_all_resources(store, &model, None)
5522 }
5523
5524 fn findings_by_resource(diagnostics: &[Diagnostic], rule_id: &str) -> Vec<(String, String)> {
5525 let mut findings: Vec<(String, String)> = diagnostics
5526 .iter()
5527 .filter(|d| d.rule_id == rule_id)
5528 .map(|d| (d.resource_logical_id().unwrap_or("").to_string(), d.message.clone()))
5529 .collect();
5530 findings.sort();
5531 findings
5532 }
5533
5534 const COGNITO_PREFIX_PATTERN: &str = "^[a-z0-9](?:[a-z0-9\\-]{0,61}[a-z0-9])?$";
5535 const COGNITO_CUSTOM_DOMAIN_PATTERN: &str =
5536 "^[a-z0-9](?:[a-z0-9\\-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9\\-]*[a-z0-9])?)+$";
5537
5538 fn cognito_user_pool_domain_schema() -> serde_json::Value {
5539 json!({
5540 "typeName": "AWS::Cognito::UserPoolDomain",
5541 "properties": {
5542 "Domain": { "type": "string", "minLength": 1, "maxLength": 63 },
5543 "UserPoolId": { "type": "string" },
5544 "CustomDomainConfig": { "type": "object", "properties": { "CertificateArn": { "type": "string" } } }
5545 },
5546 "allOf": [
5547 {
5548 "if": { "required": ["CustomDomainConfig"] },
5549 "then": { "properties": { "Domain": { "pattern": COGNITO_CUSTOM_DOMAIN_PATTERN } } }
5550 },
5551 {
5552 "if": { "not": { "required": ["CustomDomainConfig"] } },
5553 "then": { "properties": { "Domain": { "pattern": COGNITO_PREFIX_PATTERN } } }
5554 }
5555 ]
5556 })
5557 }
5558
5559 fn user_pool_domain(domain: &str, custom_domain_config: Option<serde_json::Value>) -> serde_json::Value {
5560 let mut properties = json!({ "UserPoolId": "us-east-1_example", "Domain": domain });
5561 if let Some(config) = custom_domain_config {
5562 properties["CustomDomainConfig"] = config;
5563 }
5564 json!({ "Type": "AWS::Cognito::UserPoolDomain", "Properties": properties })
5565 }
5566
5567 #[test]
5568 fn negated_condition_selects_the_domain_prefix_pattern_only_without_a_custom_domain() {
5569 let certificate = json!({ "CertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/abc" });
5570 let conditional_certificate = json!({ "Fn::If": ["UseCustomDomain", certificate, { "Ref": "AWS::NoValue" }] });
5571 let template = json!({
5572 "Conditions": { "UseCustomDomain": { "Fn::Equals": [{ "Ref": "AWS::Region" }, "us-east-1"] } },
5573 "Resources": {
5574 "PrefixLeadingHyphen": user_pool_domain("-myprefix", None),
5575 "PrefixValid": user_pool_domain("my-prefix1", None),
5576 "CustomWithoutDots": user_pool_domain("nodots", Some(certificate.clone())),
5577 "CustomValid": user_pool_domain("auth.example.com", Some(certificate.clone())),
5578 "ConditionalWithMatchingDomain": {
5579 "Type": "AWS::Cognito::UserPoolDomain",
5580 "Properties": {
5581 "UserPoolId": "us-east-1_example",
5582 "Domain": { "Fn::If": ["UseCustomDomain", "login.example.com", "login-prefix"] },
5583 "CustomDomainConfig": conditional_certificate
5584 }
5585 },
5586 "ConditionalWithFixedDomain": user_pool_domain("id.example.com", Some(conditional_certificate))
5587 }
5588 });
5589 let diagnostics = diagnostics_for_template(
5590 &store_with_bundled_schema("AWS::Cognito::UserPoolDomain", cognito_user_pool_domain_schema()),
5591 template,
5592 );
5593
5594 assert_eq!(
5595 findings_by_resource(&diagnostics, "E3031"),
5596 vec![
5597 (
5598 "ConditionalWithFixedDomain".to_string(),
5599 format!("'id.example.com' does not match pattern '{COGNITO_PREFIX_PATTERN}'")
5600 ),
5601 (
5602 "CustomWithoutDots".to_string(),
5603 format!("'nodots' does not match pattern '{COGNITO_CUSTOM_DOMAIN_PATTERN}'")
5604 ),
5605 (
5606 "PrefixLeadingHyphen".to_string(),
5607 format!("'-myprefix' does not match pattern '{COGNITO_PREFIX_PATTERN}'")
5608 ),
5609 ],
5610 "each branch applies its own pattern; a conditional custom domain config is decided per world, so a \
5611 fixed domain name is checked as a prefix in the world without it while a matching conditional domain passes"
5612 );
5613 let fixed_domain_finding = diagnostics
5614 .iter()
5615 .find(|d| d.rule_id == "E3031" && d.resource_logical_id() == Some("ConditionalWithFixedDomain"))
5616 .expect("the fixed domain is reported in the world without a custom domain");
5617 assert_eq!(
5618 fixed_domain_finding.condition_scenario,
5619 Some(HashMap::from([("UseCustomDomain".to_string(), false)]))
5620 );
5621 assert_eq!(fixed_domain_finding.severity, rules::Severity::Error);
5622 assert_eq!(fixed_domain_finding.property_path.as_deref(), Some("Properties.Domain"));
5623 }
5624
5625 fn lambda_function_schema() -> serde_json::Value {
5626 json!({
5627 "typeName": "AWS::Lambda::Function",
5628 "properties": {
5629 "Timeout": { "type": "integer", "minimum": 1, "maximum": 5400 },
5630 "CapacityProviderConfig": { "type": "object", "properties": { "CapacityProviderArn": { "type": "string" } } }
5631 },
5632 "allOf": [
5633 {
5634 "if": { "not": { "required": ["CapacityProviderConfig"] } },
5635 "then": { "properties": { "Timeout": { "maximum": 900 } } }
5636 }
5637 ]
5638 })
5639 }
5640
5641 #[test]
5642 fn lambda_timeout_above_900_is_reported_only_without_a_capacity_provider() {
5643 let template = json!({
5644 "Resources": {
5645 "TooLong": { "Type": "AWS::Lambda::Function", "Properties": { "Timeout": 901 } },
5646 "AtTheLimit": { "Type": "AWS::Lambda::Function", "Properties": { "Timeout": 900 } },
5647 "ManagedInstances": {
5648 "Type": "AWS::Lambda::Function",
5649 "Properties": { "Timeout": 901, "CapacityProviderConfig": { "CapacityProviderArn": "arn:aws:lambda:us-east-1:123456789012:capacity-provider/x" } }
5650 }
5651 }
5652 });
5653 let diagnostics = diagnostics_for_template(
5654 &store_with_bundled_schema("AWS::Lambda::Function", lambda_function_schema()),
5655 template,
5656 );
5657
5658 assert_eq!(
5659 findings_by_resource(&diagnostics, "E3717"),
5660 vec![(
5661 "TooLong".to_string(),
5662 "901 exceeds maximum 900 for a function without CapacityProviderConfig".to_string()
5663 )]
5664 );
5665 assert!(
5666 diagnostics.iter().all(|d| d.rule_id != "F3034"),
5667 "the unconditional 5400 bound is not exceeded, so no Fatal bound finding: {diagnostics:?}"
5668 );
5669 }
5670
5671 fn load_balancer_schema_with_dependency_under_exclusive_condition() -> serde_json::Value {
5672 json!({
5673 "typeName": "AWS::Test::LoadBalancer",
5674 "properties": {
5675 "Type": { "type": "string" },
5676 "Subnets": { "type": "array", "items": { "type": "string" } },
5677 "SecurityGroups": { "type": "array", "items": { "type": "string" } }
5678 },
5679 "allOf": [
5680 {
5681 "if": { "oneOf": [
5682 { "properties": { "Type": { "const": "application" } }, "required": ["Type"] },
5683 { "properties": { "Type": false } }
5684 ] },
5685 "then": { "dependentRequired": { "Subnets": ["SecurityGroups"] } }
5686 }
5687 ]
5688 })
5689 }
5690
5691 #[test]
5692 fn exclusive_condition_with_an_absent_property_branch_matches_default_and_explicit_application_types() {
5693 let template = json!({
5694 "Resources": {
5695 "DefaultType": { "Type": "AWS::Test::LoadBalancer", "Properties": { "Subnets": ["subnet-a"] } },
5696 "Application": { "Type": "AWS::Test::LoadBalancer", "Properties": { "Type": "application", "Subnets": ["subnet-a"] } },
5697 "Network": { "Type": "AWS::Test::LoadBalancer", "Properties": { "Type": "network", "Subnets": ["subnet-a"] } }
5698 }
5699 });
5700 let store = store_with_bundled_schema(
5701 "AWS::Test::LoadBalancer",
5702 load_balancer_schema_with_dependency_under_exclusive_condition(),
5703 );
5704 let diagnostics = diagnostics_for_template(&store, template);
5705
5706 let dependency_findings: Vec<&str> = {
5707 let mut resources: Vec<&str> =
5708 diagnostics.iter().filter(|d| d.rule_id == "F3021").filter_map(|d| d.resource_logical_id()).collect();
5709 resources.sort();
5710 resources
5711 };
5712 assert_eq!(dependency_findings, vec!["Application", "DefaultType"], "{diagnostics:?}");
5713 }
5714
5715 const CONSTRAINTS_OWNED_BY_DEDICATED_RULES: [(&str, &str); 7] = [
5722 ("AWS::ApiGateway::Stage", "ResourcePath"),
5723 ("AWS::EC2::Instance", "VirtualName"),
5724 ("AWS::ECS::Service", "SchedulingStrategy"),
5725 ("AWS::ElasticLoadBalancingV2::LoadBalancer", "SubnetMappings"),
5726 ("AWS::ElasticLoadBalancingV2::LoadBalancer", "Subnets"),
5727 ("AWS::Lambda::Function", "Runtime"),
5728 ("AWS::RDS::DBCluster", "MonitoringInterval"),
5729 ];
5730
5731 fn branch_property_states_a_scalar_constraint(property: &PropSchema) -> bool {
5735 schema_for_conditional_value_constraints(property).constrains_value()
5736 }
5737
5738 #[test]
5742 fn every_bundled_conditional_value_constraint_has_an_owner() {
5743 let store = CompiledSchemaStore::new();
5744 let mut unowned = Vec::new();
5745 for type_name in store.type_names() {
5746 let Some(schema) = store.get(type_name) else {
5747 continue;
5748 };
5749 for conditional in &schema.if_then_else {
5750 for branch in conditional.then_schema.iter().chain(conditional.else_schema.iter()) {
5751 for (property_name, property) in &branch.properties {
5752 if !branch_property_states_a_scalar_constraint(&property.resolve(&schema.definitions)) {
5753 continue;
5754 }
5755 let key = (type_name, property_name.as_str());
5756 let is_owned = conditional_constraint_rule(type_name, property_name).is_some()
5757 || CONSTRAINTS_OWNED_BY_DEDICATED_RULES.contains(&key);
5758 if !is_owned {
5759 unowned.push(format!("{type_name}.{property_name}"));
5760 }
5761 }
5762 }
5763 }
5764 }
5765 unowned.sort();
5766 assert!(
5767 unowned.is_empty(),
5768 "bundled conditional value constraints without an owning rule (add one to CONDITIONAL_CONSTRAINT_RULES or implement a native rule): {unowned:?}"
5769 );
5770 }
5771
5772 #[test]
5773 fn a_condition_stating_nothing_never_selects_its_branch() {
5774 let mut properties = HashMap::new();
5775 properties.insert("Subnets".to_string(), PropSchema::default());
5776 properties.insert("SecurityGroups".to_string(), PropSchema::default());
5777 let mut store = CompiledSchemaStore::new();
5778 store.insert_schema(CompiledSchema {
5779 type_name: "AWS::Test::Stale".to_string(),
5780 properties,
5781 if_then_else: vec![IfThenElse {
5782 condition: ConditionSchema::default(),
5783 then_schema: Some(SubSchema {
5784 dependent_required: HashMap::from([("Subnets".to_string(), vec!["SecurityGroups".to_string()])]),
5785 ..Default::default()
5786 }),
5787 else_schema: None,
5788 enforce_full_branch: false,
5789 }],
5790 ..Default::default()
5791 });
5792 let template = json!({
5793 "Resources": { "Stale": { "Type": "AWS::Test::Stale", "Properties": { "Subnets": ["subnet-a"] } } }
5794 });
5795
5796 let diagnostics = diagnostics_for_template(&store, template);
5797 assert!(diagnostics.iter().all(|d| d.rule_id != "F3021"), "{diagnostics:?}");
5798 }
5799
5800 #[test]
5801 fn format_value_wraps_strings_in_quotes() {
5802 assert_eq!(format_value(&json!("hello")), "'hello'");
5803 }
5804
5805 #[test]
5806 fn format_value_renders_non_strings_directly() {
5807 assert_eq!(format_value(&json!(42)), "42");
5808 assert_eq!(format_value(&json!(true)), "true");
5809 assert_eq!(format_value(&json!(null)), "null");
5810 }
5811
5812 #[test]
5813 fn single_type_compatible_same_type() {
5814 assert!(single_type_compatible("string", "string"));
5815 assert!(single_type_compatible("integer", "integer"));
5816 }
5817
5818 #[test]
5819 fn single_type_compatible_integer_number_interchangeable() {
5820 assert!(single_type_compatible("integer", "number"));
5821 assert!(single_type_compatible("number", "integer"));
5822 }
5823
5824 #[test]
5825 fn single_type_compatible_string_to_number_coercion() {
5826 assert!(single_type_compatible("string", "number"));
5827 assert!(single_type_compatible("string", "integer"));
5828 }
5829
5830 #[test]
5831 fn single_type_compatible_number_to_string_coercion() {
5832 assert!(single_type_compatible("number", "string"));
5833 assert!(single_type_compatible("integer", "string"));
5834 assert!(single_type_compatible("boolean", "string"));
5835 }
5836
5837 #[test]
5838 fn single_type_compatible_null_always_compatible() {
5839 assert!(single_type_compatible("string", "null"));
5840 assert!(single_type_compatible("array", "null"));
5841 }
5842
5843 #[test]
5844 fn single_type_compatible_incompatible() {
5845 assert!(!single_type_compatible("string", "array"));
5846 assert!(!single_type_compatible("array", "object"));
5847 assert!(!single_type_compatible("boolean", "integer"));
5848 }
5849
5850 #[test]
5851 fn param_type_number() {
5852 assert_eq!(cfn_param_type_to_schema_type("Number"), "number");
5853 }
5854
5855 #[test]
5856 fn param_type_string() {
5857 assert_eq!(cfn_param_type_to_schema_type("String"), "string");
5858 }
5859
5860 #[test]
5861 fn param_type_comma_delimited_list() {
5862 assert_eq!(cfn_param_type_to_schema_type("CommaDelimitedList"), "array");
5863 }
5864
5865 #[test]
5866 fn param_type_list_prefix() {
5867 assert_eq!(cfn_param_type_to_schema_type("List<Number>"), "array");
5868 assert_eq!(cfn_param_type_to_schema_type("List<AWS::EC2::Subnet::Id>"), "array");
5869 }
5870
5871 #[test]
5872 fn param_type_ssm_parameter() {
5873 assert_eq!(cfn_param_type_to_schema_type("AWS::SSM::Parameter::Value<String>"), "string");
5874 }
5875
5876 #[test]
5877 fn param_type_unknown_defaults_to_string() {
5878 assert_eq!(cfn_param_type_to_schema_type("AWS::EC2::VPC::Id"), "string");
5879 }
5880
5881 #[test]
5882 fn resolve_json_pointer_root() {
5883 let data = json!({"a": 1});
5884 let result = resolve_json_pointer(&data, "/");
5885 assert_eq!(result, json!({"a": 1}));
5886 }
5887
5888 #[test]
5889 fn resolve_json_pointer_nested() {
5890 let data = json!({"a": {"b": {"c": 42}}});
5891 assert_eq!(resolve_json_pointer(&data, "/a/b/c"), json!(42));
5892 }
5893
5894 #[test]
5895 fn resolve_json_pointer_missing_key() {
5896 let data = json!({"a": 1});
5897 assert_eq!(resolve_json_pointer(&data, "/b"), json!(null));
5898 }
5899
5900 #[test]
5901 fn resolve_json_pointer_array_index() {
5902 let data = json!({"items": [10, 20, 30]});
5903 assert_eq!(resolve_json_pointer(&data, "/items/1"), json!(20));
5904 }
5905
5906 #[test]
5907 fn resolve_json_pointer_array_out_of_bounds() {
5908 let data = json!({"items": [10]});
5909 assert_eq!(resolve_json_pointer(&data, "/items/5"), json!(null));
5910 }
5911
5912 #[test]
5913 fn resolve_json_pointer_non_numeric_index_on_array() {
5914 let data = json!([1, 2, 3]);
5915 assert_eq!(resolve_json_pointer(&data, "/abc"), json!(null));
5916 }
5917
5918 #[test]
5919 fn resolve_json_pointer_scalar_intermediate() {
5920 let data = json!({"a": "string_value"});
5921 assert_eq!(resolve_json_pointer(&data, "/a/b"), json!(null));
5922 }
5923
5924 #[test]
5925 fn resolve_data_simple_reference() {
5926 let schema = json!({"$data": "/source/protocol"});
5927 let context = json!({"source": {"protocol": "tcp"}});
5928 assert_eq!(resolve_data_in_schema(&schema, &context), json!("tcp"));
5929 }
5930
5931 #[test]
5932 fn resolve_data_missing_reference() {
5933 let schema = json!({"$data": "/missing/path"});
5934 let context = json!({"source": {"protocol": "tcp"}});
5935 assert_eq!(resolve_data_in_schema(&schema, &context), json!(null));
5936 }
5937
5938 #[test]
5939 fn resolve_data_nested_object() {
5940 let schema = json!({
5941 "properties": {
5942 "port": {"const": {"$data": "/source/port"}}
5943 }
5944 });
5945 let context = json!({"source": {"port": 443}});
5946 let resolved = resolve_data_in_schema(&schema, &context);
5947 assert_eq!(resolved["properties"]["port"]["const"], json!(443));
5948 }
5949
5950 #[test]
5951 fn resolve_data_array() {
5952 let schema = json!([{"$data": "/a"}, {"$data": "/b"}]);
5953 let context = json!({"a": 1, "b": 2});
5954 assert_eq!(resolve_data_in_schema(&schema, &context), json!([1, 2]));
5955 }
5956
5957 #[test]
5958 fn resolve_data_non_object_passthrough() {
5959 assert_eq!(resolve_data_in_schema(&json!("plain"), &json!({})), json!("plain"));
5960 assert_eq!(resolve_data_in_schema(&json!(42), &json!({})), json!(42));
5961 assert_eq!(resolve_data_in_schema(&json!(null), &json!({})), json!(null));
5962 }
5963
5964 #[test]
5965 fn resolve_data_lookup() {
5966 let schema = json!({
5967 "$lookup": {
5968 "key": {"$data": "/source/engine"},
5969 "map": {
5970 "aurora": {"const": 3306},
5971 "redis": {"const": 6379}
5972 }
5973 }
5974 });
5975 let context = json!({"source": {"engine": "aurora"}});
5976 assert_eq!(resolve_data_in_schema(&schema, &context), json!({"const": 3306}));
5977 }
5978
5979 #[test]
5980 fn resolve_data_lookup_missing_key() {
5981 let schema = json!({
5982 "$lookup": {
5983 "key": {"$data": "/source/engine"},
5984 "map": {"aurora": 3306}
5985 }
5986 });
5987 let context = json!({"source": {"engine": "postgres"}});
5988 assert_eq!(resolve_data_in_schema(&schema, &context), json!(null));
5989 }
5990
5991 #[test]
5992 fn types_compatible_single() {
5993 assert!(types_compatible("string", &PropType::Single("string".into())));
5994 assert!(!types_compatible("array", &PropType::Single("string".into())));
5995 }
5996
5997 #[test]
5998 fn types_compatible_multi() {
5999 let pt = PropType::Multi(vec!["string".into(), "null".into()]);
6000 assert!(types_compatible("string", &pt));
6001 assert!(types_compatible("integer", &pt));
6002 assert!(!types_compatible("array", &PropType::Multi(vec!["string".into(), "integer".into()])));
6003 }
6004
6005 #[test]
6006 fn single_type_double_and_float_are_number() {
6007 assert!(single_type(&json!(3.14), "double"));
6008 assert!(single_type(&json!(3.14), "float"));
6009 assert!(single_type(&json!(42), "double"));
6010 }
6011
6012 #[test]
6013 fn single_type_integer_rejects_fractional() {
6014 assert!(!single_type(&json!(3.14), "integer"));
6015 }
6016
6017 #[test]
6018 fn single_type_integer_accepts_whole_float() {
6019 assert!(single_type(&json!(5.0), "integer"));
6020 }
6021
6022 #[test]
6023 fn find_prop_schema_direct_lookup() {
6024 let mut props = HashMap::new();
6025 props.insert(
6026 "Name".into(),
6027 PropSchema { prop_type: Some(PropType::Single("string".into())), ..Default::default() },
6028 );
6029 let defs = HashMap::new();
6030 let result = find_prop_schema("Name", &props, &defs).expect("Name should be found");
6031 assert_eq!(result.prop_type.as_ref().unwrap().primary(), Some("string"));
6032 }
6033
6034 #[test]
6035 fn find_prop_schema_nested_path() {
6036 let inner = PropSchema { prop_type: Some(PropType::Single("integer".into())), ..Default::default() };
6037 let mut inner_props = HashMap::new();
6038 inner_props.insert("Port".into(), inner);
6039 let outer = PropSchema { properties: inner_props, ..Default::default() };
6040 let mut props = HashMap::new();
6041 props.insert("Config".into(), outer);
6042 let defs = HashMap::new();
6043 let result = find_prop_schema("Config.Port", &props, &defs).expect("Config.Port should be found");
6044 assert_eq!(result.prop_type.as_ref().unwrap().primary(), Some("integer"));
6045 }
6046
6047 #[test]
6048 fn find_prop_schema_missing_returns_none() {
6049 let props = HashMap::new();
6050 let defs = HashMap::new();
6051 assert!(find_prop_schema("Missing", &props, &defs).is_none(), "missing prop should return None");
6052 }
6053
6054 #[test]
6055 fn find_prop_schema_resolves_ref() {
6056 let mut props = HashMap::new();
6057 props.insert("Config".into(), PropSchema { ref_name: Some("ConfigDef".into()), ..Default::default() });
6058 let mut defs = HashMap::new();
6059 defs.insert(
6060 "ConfigDef".into(),
6061 PropSchema { prop_type: Some(PropType::Single("object".into())), ..Default::default() },
6062 );
6063 let result = find_prop_schema("Config", &props, &defs).expect("Config should be found");
6064 assert_eq!(result.prop_type.as_ref().unwrap().primary(), Some("object"));
6065 }
6066
6067 #[test]
6068 fn find_prop_schema_deep_direct() {
6069 let mut props = HashMap::new();
6070 props.insert(
6071 "Name".into(),
6072 PropSchema { prop_type: Some(PropType::Single("string".into())), ..Default::default() },
6073 );
6074 let schema = CompiledSchema { type_name: "Test".into(), properties: props, ..Default::default() };
6075 assert!(find_prop_schema_deep("Name", &schema).is_some(), "direct property should be found");
6076 }
6077
6078 #[test]
6079 fn find_prop_schema_deep_searches_one_of() {
6080 let mut sub_props = HashMap::new();
6081 sub_props.insert(
6082 "Special".into(),
6083 PropSchema { prop_type: Some(PropType::Single("boolean".into())), ..Default::default() },
6084 );
6085 let schema = CompiledSchema {
6086 type_name: "Test".into(),
6087 one_of: vec![crate::compiled::SubSchema { properties: sub_props, ..Default::default() }],
6088 ..Default::default()
6089 };
6090 let result = find_prop_schema_deep("Special", &schema);
6091 assert!(result.is_some(), "expected to find Special in oneOf sub-schema");
6092 assert_eq!(result.unwrap().prop_type.as_ref().unwrap().primary(), Some("boolean"));
6093 }
6094
6095 #[test]
6096 fn find_prop_schema_deep_searches_if_then_else() {
6097 let mut then_props = HashMap::new();
6098 then_props.insert(
6099 "ConditionalProp".into(),
6100 PropSchema { prop_type: Some(PropType::Single("string".into())), ..Default::default() },
6101 );
6102 let schema = CompiledSchema {
6103 type_name: "Test".into(),
6104 if_then_else: vec![crate::compiled::IfThenElse {
6105 condition: Default::default(),
6106 then_schema: Some(crate::compiled::SubSchema { properties: then_props, ..Default::default() }),
6107 else_schema: None,
6108 enforce_full_branch: false,
6109 }],
6110 ..Default::default()
6111 };
6112 assert!(
6113 find_prop_schema_deep("ConditionalProp", &schema).is_some(),
6114 "conditional property should be found via if-then-else"
6115 );
6116 }
6117
6118 #[test]
6119 fn condition_matches_empty_condition_always_true() {
6120 let cond = ConditionSchema::default();
6121 let keys = vec!["A".into(), "B".into()];
6122 let model = Arc::new(SemanticModel::from_bytes(
6123 b"AWSTemplateFormatVersion: '2010-09-09'\nResources:\n R:\n Type: AWS::CloudFormation::WaitConditionHandle"
6124 ).unwrap());
6125 let defs = HashMap::new();
6126 assert!(condition_matches(&cond, &keys, &model, "R", &defs));
6127 }
6128
6129 #[test]
6130 fn condition_matches_required_key_missing() {
6131 let cond = ConditionSchema { required: vec!["MissingKey".into()], ..Default::default() };
6132 let keys = vec!["A".into()];
6133 let model = Arc::new(SemanticModel::from_bytes(
6134 b"AWSTemplateFormatVersion: '2010-09-09'\nResources:\n R:\n Type: AWS::CloudFormation::WaitConditionHandle"
6135 ).unwrap());
6136 let defs = HashMap::new();
6137 assert!(!condition_matches(&cond, &keys, &model, "R", &defs));
6138 }
6139
6140 #[test]
6141 fn schema_value_matches_required_groups_with_null_members_absent() {
6142 let defs = HashMap::new();
6143 let required_or = PropSchema { required_or: vec!["A".into(), "B".into()], ..Default::default() };
6144 assert!(schema_value_matches(&json!({ "A": 1 }), &required_or, &defs, 0));
6145 assert!(!schema_value_matches(&json!({ "A": null }), &required_or, &defs, 0));
6146
6147 let required_xor = PropSchema { required_xor: vec!["A".into(), "B".into()], ..Default::default() };
6148 assert!(schema_value_matches(&json!({ "A": 1, "B": null }), &required_xor, &defs, 0));
6149 assert!(!schema_value_matches(&json!({ "A": 1, "B": 2 }), &required_xor, &defs, 0));
6150 assert!(!schema_value_matches(&json!({ "A": null, "B": null }), &required_xor, &defs, 0));
6151 }
6152
6153 #[test]
6154 fn gather_condition_matches_empty_true() {
6155 let cond = json!({});
6156 let ctx = json!({"a": 1});
6157 assert!(gather_condition_matches(&cond, &ctx));
6158 }
6159
6160 #[test]
6161 fn gather_condition_matches_required_present() {
6162 let cond = json!({"required": ["a"]});
6163 let ctx = json!({"a": 1});
6164 assert!(gather_condition_matches(&cond, &ctx));
6165 }
6166
6167 #[test]
6168 fn gather_condition_matches_required_missing() {
6169 let cond = json!({"required": ["b"]});
6170 let ctx = json!({"a": 1});
6171 assert!(!gather_condition_matches(&cond, &ctx));
6172 }
6173
6174 #[test]
6175 fn gather_prop_matches_const() {
6176 assert!(gather_prop_matches(&json!("tcp"), &json!({"const": "tcp"})));
6177 assert!(!gather_prop_matches(&json!("udp"), &json!({"const": "tcp"})));
6178 }
6179
6180 #[test]
6181 fn gather_prop_matches_enum() {
6182 assert!(gather_prop_matches(&json!("a"), &json!({"enum": ["a", "b"]})));
6183 assert!(!gather_prop_matches(&json!("c"), &json!({"enum": ["a", "b"]})));
6184 }
6185
6186 #[test]
6187 fn gather_prop_matches_nested_properties() {
6188 let actual = json!({"inner": {"key": "val"}});
6189 let constraint = json!({"properties": {"inner": {"properties": {"key": {"const": "val"}}}}});
6190 assert!(gather_prop_matches(&actual, &constraint));
6191 }
6192
6193 fn property_names(prefix: &str, count: usize) -> Vec<String> {
6194 (0..count).map(|index| format!("{prefix}{index}")).collect()
6195 }
6196
6197 #[test]
6198 fn required_property_combinations_accept_exact_cardinality_boundary() {
6199 let branch = SubSchema {
6200 required_or: property_names("Or", 16),
6201 required_xor: property_names("Xor", 16),
6202 ..Default::default()
6203 };
6204 let (combinations, curtailed) = required_property_combinations(&branch, &HashMap::new(), None);
6205 assert_eq!(combinations.len(), MAX_REQUIRED_PROPERTY_COMBINATIONS);
6206 assert!(!curtailed, "exactly 16 × 16 combinations must be retained in full");
6207 }
6208
6209 #[test]
6210 fn required_property_combinations_report_one_over_boundary() {
6211 let branch = SubSchema {
6212 required_or: property_names("Or", 17),
6213 required_xor: property_names("Xor", 16),
6214 ..Default::default()
6215 };
6216 let (combinations, curtailed) = required_property_combinations(&branch, &HashMap::new(), None);
6217 assert_eq!(combinations.len(), MAX_REQUIRED_PROPERTY_COMBINATIONS);
6218 assert!(curtailed, "17 × 16 combinations must visibly hit the explanation limit");
6219 }
6220}