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 let matches = condition_matches(&ite.condition, &actual_keys, m, rid, defs);
648 let sub = if matches { &ite.then_schema } else { &ite.else_schema };
649 if let Some(sub) = sub {
650 if ite.enforce_full_branch {
651 validate_sub(out, m, rid, &res.resource_type, &actual_keys, sub, defs, base, 0);
656 } else {
657 validate_sub_dependencies(out, m, rid, &actual_keys, sub, base);
662 }
663 }
664 }
665}
666
667fn validate_object_keys(
668 out: &mut Vec<Diagnostic>,
669 m: &Arc<SemanticModel>,
670 rid: &str,
671 rtype: &str,
672 schema_props: &HashMap<String, PropSchema>,
673 defs: &HashMap<String, PropSchema>,
674 required: &[String],
675 additional_properties: Option<bool>,
676 pattern_props: &HashMap<String, PropSchema>,
677 dep_req: &HashMap<String, Vec<String>>,
678 dep_excl: &HashMap<String, Vec<String>>,
679 req_or: &[String],
680 req_xor: &[String],
681 all_of: &[SubSchema],
682 any_of: &[SubSchema],
683 one_of: &[SubSchema],
684 actual_keys: &[String],
685 base_path: &str,
686) {
687 validate_object_keys_inner(
688 out,
689 m,
690 rid,
691 rtype,
692 schema_props,
693 defs,
694 required,
695 additional_properties,
696 pattern_props,
697 dep_req,
698 dep_excl,
699 req_or,
700 req_xor,
701 all_of,
702 any_of,
703 one_of,
704 actual_keys,
705 base_path,
706 None,
707 )
708}
709
710fn validate_required_groups(
711 out: &mut Vec<Diagnostic>,
712 m: &Arc<SemanticModel>,
713 rid: &str,
714 actual_keys: &[String],
715 required_or: &[String],
716 required_xor: &[String],
717 base_path: &str,
718) {
719 if required_or.is_empty() && required_xor.is_empty() {
720 return;
721 }
722
723 let members: Vec<&str> = required_or.iter().chain(required_xor.iter()).map(String::as_str).collect();
729 let Some(assignments) = required_group_scenario_assignments(m, rid, &members, base_path) else {
730 validate_required_groups_budget_fallback(out, m, rid, actual_keys, required_or, required_xor, base_path);
733 return;
734 };
735
736 if !required_or.is_empty() {
737 for assignment in &assignments {
738 let any_present = required_or.iter().any(|property| {
739 actual_keys.iter().any(|actual| actual == property)
740 && property_present_under(m, rid, base_path, property, assignment)
741 });
742 if !any_present {
743 let names = required_or.iter().map(|name| format!("'{name}'")).collect::<Vec<_>>().join(", ");
744 out.push(build_diagnostic_conditional(
745 "F3058",
746 &format!("One of [{names}] is a required property"),
747 m,
748 rid,
749 base_path,
750 None,
751 assignment_condition_map(assignment),
752 ));
753 }
754 }
755 }
756
757 if !required_xor.is_empty() {
758 for assignment in &assignments {
759 let count = required_xor
760 .iter()
761 .filter(|property| {
762 actual_keys.iter().any(|actual| actual == property.as_str())
763 && property_present_under(m, rid, base_path, property, assignment)
764 })
765 .count();
766 if count != 1 {
767 let names = required_xor.iter().map(|name| format!("'{name}'")).collect::<Vec<_>>().join(", ");
768 out.push(build_diagnostic_conditional(
769 "F3014",
770 &format!("Exactly one of [{names}] must be specified"),
771 m,
772 rid,
773 base_path,
774 None,
775 assignment_condition_map(assignment),
776 ));
777 }
778 }
779 }
780}
781
782fn all_members_absent_witness(
787 m: &Arc<SemanticModel>,
788 rid: &str,
789 actual_keys: &[String],
790 members: &[String],
791 base_path: &str,
792) -> Option<HashMap<String, bool>> {
793 let seed = active_scenario_filter(m, rid)?;
794
795 let mut alternatives = Vec::new();
796 for property in members {
797 if !actual_keys.iter().any(|actual| actual == property) {
798 continue;
799 }
800 let property_alternatives = property_presence_alternatives(m, rid, base_path, property, false, &seed);
801 if property_alternatives.is_empty() {
802 return None;
803 }
804 alternatives.push(property_alternatives);
805 }
806 alternatives.sort_by_key(Vec::len);
807 let mut merge_attempts = 0;
808 compatible_alternative_witness(m, &alternatives, 0, &seed, &mut merge_attempts)
809}
810
811fn multiple_members_present_witness(
815 m: &Arc<SemanticModel>,
816 rid: &str,
817 actual_keys: &[String],
818 members: &[String],
819 base_path: &str,
820) -> Option<HashMap<String, bool>> {
821 let seed = active_scenario_filter(m, rid)?;
822
823 let present_alternatives: Vec<Vec<HashMap<String, bool>>> = members
824 .iter()
825 .filter(|property| actual_keys.iter().any(|actual| actual == property.as_str()))
826 .map(|property| property_presence_alternatives(m, rid, base_path, property, true, &seed))
827 .collect();
828
829 let mut merge_attempts = 0;
830 for left in 0..present_alternatives.len() {
831 for right in (left + 1)..present_alternatives.len() {
832 for left_assignment in &present_alternatives[left] {
833 let Some(left_merged) = try_merge_assignments(&seed, left_assignment) else {
834 continue;
835 };
836 if !assignment_is_proven_satisfiable(m, &left_merged) {
837 continue;
838 }
839 for right_assignment in &present_alternatives[right] {
840 if merge_attempts >= MAX_GROUP_WITNESS_MERGE_ATTEMPTS {
841 return None;
842 }
843 merge_attempts += 1;
844 let Some(merged) = try_merge_assignments(&left_merged, right_assignment) else {
845 continue;
846 };
847 if assignment_is_proven_satisfiable(m, &merged) {
848 return Some(merged);
849 }
850 }
851 }
852 }
853 }
854 None
855}
856
857fn active_scenario_filter(m: &Arc<SemanticModel>, rid: &str) -> Option<HashMap<String, bool>> {
861 let mut seed = SCENARIO_FILTER.with(|filter| filter.borrow().clone().unwrap_or_default());
862 if m.resource_condition_is_valid(rid)
863 && let Some(condition) = m.resources.get(rid).and_then(|resource| resource.condition.as_ref())
864 && let Some(previous) = seed.insert(condition.clone(), true)
865 && !previous
866 {
867 return None;
868 }
869 assignment_is_proven_satisfiable(m, &seed).then_some(seed)
870}
871
872fn property_presence_alternatives(
877 m: &Arc<SemanticModel>,
878 rid: &str,
879 base_path: &str,
880 property: &str,
881 want_present: bool,
882 filter: &HashMap<String, bool>,
883) -> Vec<HashMap<String, bool>> {
884 if m.scenario_budget_exhausted() {
885 return Vec::new();
886 }
887 let (scenarios, was_curtailed) =
888 m.resolve_scenarios_with_limit(rid, &format!("{base_path}.{property}"), MAX_GROUP_PROPERTY_SCENARIOS);
889 if was_curtailed && !m.scenario_budget_exhausted() {
890 m.record_budget_exhaustion(BudgetKind::SchemaScenarioAssignments);
891 }
892 let mut alternatives = Vec::new();
893 let mut seen = HashSet::new();
894 for (value, conditions) in scenarios {
895 let present = !matches!(value, ResolvedValue::Concrete { value } if value.is_null());
896 if present != want_present {
897 continue;
898 }
899 let Some(merged) = try_merge_assignments(filter, &conditions) else {
900 continue;
901 };
902 if assignment_is_proven_satisfiable(m, &merged) && seen.insert(canonical_assignment(&conditions)) {
903 alternatives.push(conditions);
904 }
905 }
906 alternatives
907}
908
909fn compatible_alternative_witness(
910 m: &Arc<SemanticModel>,
911 alternatives: &[Vec<HashMap<String, bool>>],
912 index: usize,
913 assignment: &HashMap<String, bool>,
914 merge_attempts: &mut usize,
915) -> Option<HashMap<String, bool>> {
916 let Some(group) = alternatives.get(index) else {
917 return Some(assignment.clone());
918 };
919 for alternative in group {
920 if *merge_attempts >= MAX_GROUP_WITNESS_MERGE_ATTEMPTS {
921 return None;
922 }
923 *merge_attempts += 1;
924 let Some(merged) = try_merge_assignments(assignment, alternative) else {
925 continue;
926 };
927 if !assignment_is_proven_satisfiable(m, &merged) {
928 continue;
929 }
930 if let Some(witness) = compatible_alternative_witness(m, alternatives, index + 1, &merged, merge_attempts) {
931 return Some(witness);
932 }
933 }
934 None
935}
936
937fn assignment_is_proven_satisfiable(m: &Arc<SemanticModel>, assignment: &HashMap<String, bool>) -> bool {
942 if assignment.is_empty() {
943 return true;
944 }
945 let assumptions: Vec<(String, bool)> = assignment.iter().map(|(name, value)| (name.clone(), *value)).collect();
946 matches!(m.conditions.satisfiability(&assumptions), Satisfiability::Satisfiable)
947}
948
949fn validate_required_groups_budget_fallback(
953 out: &mut Vec<Diagnostic>,
954 m: &Arc<SemanticModel>,
955 rid: &str,
956 actual_keys: &[String],
957 required_or: &[String],
958 required_xor: &[String],
959 base_path: &str,
960) {
961 if !required_or.is_empty()
962 && let Some(witness) = all_members_absent_witness(m, rid, actual_keys, required_or, base_path)
963 {
964 let names = required_or.iter().map(|name| format!("'{name}'")).collect::<Vec<_>>().join(", ");
965 out.push(build_diagnostic_conditional(
966 "F3058",
967 &format!("One of [{names}] is a required property"),
968 m,
969 rid,
970 base_path,
971 None,
972 assignment_condition_map(&witness),
973 ));
974 }
975
976 if required_xor.is_empty() {
977 return;
978 }
979 let names = required_xor.iter().map(|name| format!("'{name}'")).collect::<Vec<_>>().join(", ");
980 if let Some(witness) = all_members_absent_witness(m, rid, actual_keys, required_xor, base_path) {
981 out.push(build_diagnostic_conditional(
982 "F3014",
983 &format!("Exactly one of [{names}] must be specified"),
984 m,
985 rid,
986 base_path,
987 None,
988 assignment_condition_map(&witness),
989 ));
990 }
991 if let Some(witness) = multiple_members_present_witness(m, rid, actual_keys, required_xor, base_path) {
992 out.push(build_diagnostic_conditional(
993 "F3014",
994 &format!("Exactly one of [{names}] must be specified"),
995 m,
996 rid,
997 base_path,
998 None,
999 assignment_condition_map(&witness),
1000 ));
1001 }
1002}
1003
1004fn required_group_scenario_assignments(
1008 m: &Arc<SemanticModel>,
1009 rid: &str,
1010 members: &[&str],
1011 base_path: &str,
1012) -> Option<Vec<HashMap<String, bool>>> {
1013 let paths: Vec<String> = members.iter().map(|name| format!("{}.{}", base_path, name)).collect();
1014 let refs: Vec<&str> = paths.iter().map(String::as_str).collect();
1015 property_scenario_assignments(m, rid, &refs)
1016}
1017
1018const MAX_GROUP_PROPERTY_SCENARIOS: usize = MAX_GROUP_SCENARIO_ASSIGNMENTS + 1;
1019const MAX_GROUP_WITNESS_MERGE_ATTEMPTS: usize = MAX_GROUP_SCENARIO_MERGE_ATTEMPTS;
1020
1021fn property_scenario_assignments(
1032 m: &Arc<SemanticModel>,
1033 rid: &str,
1034 property_paths: &[&str],
1035) -> Option<Vec<HashMap<String, bool>>> {
1036 let Some(seed) = active_scenario_filter(m, rid) else {
1037 return Some(Vec::new());
1038 };
1039 let mut assignments: Vec<HashMap<String, bool>> = vec![seed.clone()];
1040
1041 for property_path in property_paths {
1042 let (scenarios, was_curtailed) =
1043 m.resolve_scenarios_with_limit(rid, property_path, MAX_GROUP_PROPERTY_SCENARIOS);
1044 if was_curtailed {
1045 if !m.scenario_budget_exhausted() {
1046 m.record_budget_exhaustion(BudgetKind::SchemaScenarioAssignments);
1047 }
1048 return None;
1049 }
1050 if scenarios.is_empty() {
1051 continue;
1052 }
1053
1054 let mut property_assignments = Vec::new();
1055 let mut seen_property_assignments = HashSet::new();
1056 for (_, conditions) in &scenarios {
1057 if !is_satisfiable(m, conditions) || !scenario_consistent_with_filter(m, conditions) {
1058 continue;
1059 }
1060 if seen_property_assignments.insert(canonical_assignment(conditions)) {
1061 if property_assignments.len() == MAX_GROUP_SCENARIO_ASSIGNMENTS {
1062 m.record_budget_exhaustion(BudgetKind::SchemaScenarioAssignments);
1063 return None;
1064 }
1065 property_assignments.push(conditions.clone());
1066 }
1067 }
1068
1069 if property_assignments.is_empty() {
1070 continue;
1071 }
1072
1073 let mut next_assignments = Vec::new();
1074 let mut seen_assignments = HashSet::new();
1075 let mut merge_attempts = 0;
1076 for existing in &assignments {
1077 for alternative in &property_assignments {
1078 merge_attempts += 1;
1079 if merge_attempts > MAX_GROUP_SCENARIO_MERGE_ATTEMPTS {
1080 m.record_budget_exhaustion(BudgetKind::SchemaScenarioMergeAttempts);
1081 return None;
1082 }
1083 let Some(merged) = try_merge_assignments(existing, alternative) else {
1084 continue;
1085 };
1086 if !is_satisfiable(m, &merged) || !scenario_consistent_with_filter(m, &merged) {
1087 continue;
1088 }
1089 if seen_assignments.insert(canonical_assignment(&merged)) {
1090 if next_assignments.len() == MAX_GROUP_SCENARIO_ASSIGNMENTS {
1091 m.record_budget_exhaustion(BudgetKind::SchemaScenarioAssignments);
1092 return None;
1093 }
1094 next_assignments.push(merged);
1095 }
1096 }
1097 }
1098
1099 if !next_assignments.is_empty() {
1100 assignments = next_assignments;
1101 }
1102 }
1103
1104 Some(if assignments.is_empty() { vec![seed] } else { assignments })
1105}
1106
1107fn canonical_assignment(assignment: &HashMap<String, bool>) -> Vec<(String, bool)> {
1108 let mut canonical: Vec<(String, bool)> = assignment.iter().map(|(name, value)| (name.clone(), *value)).collect();
1109 canonical.sort_unstable();
1110 canonical
1111}
1112
1113fn try_merge_assignments(a: &HashMap<String, bool>, b: &HashMap<String, bool>) -> Option<HashMap<String, bool>> {
1116 let mut merged = a.clone();
1117 for (name, val) in b {
1118 if let Some(existing) = merged.get(name) {
1119 if existing != val {
1120 return None; }
1122 } else {
1123 merged.insert(name.clone(), *val);
1124 }
1125 }
1126 Some(merged)
1127}
1128
1129fn property_present_under(
1141 m: &Arc<SemanticModel>,
1142 rid: &str,
1143 base: &str,
1144 prop: &str,
1145 assignment: &HashMap<String, bool>,
1146) -> bool {
1147 let scenarios = m.resolve_scenarios(rid, &format!("{}.{}", base, prop));
1148 if scenarios.is_empty() {
1149 return true;
1150 }
1151 scenarios.iter().any(|(val, conds)| {
1152 if !is_satisfiable(m, conds) {
1153 return false;
1154 }
1155 if !scenario_consistent_with_filter(m, conds) {
1158 return false;
1159 }
1160 for (name, value) in assignment {
1162 if let Some(scenario_value) = conds.get(name)
1163 && scenario_value != value
1164 {
1165 return false;
1166 }
1167 }
1168 if !assignment.is_empty() {
1170 let mut merged = conds.clone();
1171 for (name, value) in assignment {
1172 merged.insert(name.clone(), *value);
1173 }
1174 if !is_satisfiable(m, &merged) {
1175 return false;
1176 }
1177 }
1178 !matches!(val, ResolvedValue::Concrete { value } if value.is_null())
1179 })
1180}
1181
1182#[allow(clippy::too_many_arguments)]
1187fn validate_object_keys_inner(
1188 out: &mut Vec<Diagnostic>,
1189 m: &Arc<SemanticModel>,
1190 rid: &str,
1191 rtype: &str,
1192 schema_props: &HashMap<String, PropSchema>,
1193 defs: &HashMap<String, PropSchema>,
1194 required: &[String],
1195 additional_properties: Option<bool>,
1196 pattern_props: &HashMap<String, PropSchema>,
1197 dep_req: &HashMap<String, Vec<String>>,
1198 dep_excl: &HashMap<String, Vec<String>>,
1199 req_or: &[String],
1200 req_xor: &[String],
1201 all_of: &[SubSchema],
1202 any_of: &[SubSchema],
1203 one_of: &[SubSchema],
1204 actual_keys: &[String],
1205 base_path: &str,
1206 scenario: Option<&HashMap<String, bool>>,
1207) {
1208 let _scenario_filter_scope = match scenario {
1212 Some(outer_conditions) => {
1213 let Some(merged) = merged_scenario_filter(outer_conditions) else {
1214 return;
1215 };
1216 Some(ScenarioFilterScope::enter(merged))
1217 }
1218 None => None,
1219 };
1220
1221 let before_len = out.len();
1222 for req in required {
1223 if !actual_keys.contains(req) {
1224 out.push(build_diagnostic(
1225 "F3003",
1226 &format!("'{}' is a required property", req),
1227 m,
1228 rid,
1229 base_path,
1230 Some(&format!("Add the required property '{}'", req)),
1231 ));
1232 } else if base_path == KEY_PROPERTIES {
1233 check_required_not_null(out, m, rid, base_path, req);
1234 }
1235 }
1236
1237 if additional_properties == Some(false) && !is_custom_resource_type(rtype) {
1238 let known: HashSet<&str> = schema_props.keys().map(|s| s.as_str()).collect();
1239
1240 let pattern_matchers: Vec<Option<std::sync::Arc<CompiledPattern>>> =
1241 pattern_props.keys().map(|p| compile_pattern(p)).collect();
1242 for key in actual_keys {
1243 if known.contains(key.as_str()) {
1244 continue;
1245 }
1246 let allowed_by_pattern =
1247 pattern_matchers.iter().any(|matcher| matcher.as_ref().is_none_or(|re| re.is_match(key)));
1248 if allowed_by_pattern {
1249 continue;
1250 }
1251 let suggestion = find_similar(key, &known);
1252 let msg = match suggestion {
1253 Some(s) => {
1254 format!("Additional properties are not allowed ('{}' was unexpected. Did you mean '{}'?)", key, s)
1255 }
1256 None => format!("Additional properties are not allowed ('{}' was unexpected)", key),
1257 };
1258 out.push(build_diagnostic("F3002", &msg, m, rid, &format!("{}.{}", base_path, key), None));
1259 }
1260 }
1261
1262 for (trigger, excluded) in dep_excl {
1263 if actual_keys.contains(trigger) {
1264 for dep in excluded {
1265 if actual_keys.contains(dep) {
1266 out.push(build_diagnostic(
1267 "F3020",
1268 &format!("'{}' should not be included with '{}'", dep, trigger),
1269 m,
1270 rid,
1271 &format!("{}.{}", base_path, dep),
1272 None,
1273 ));
1274 }
1275 }
1276 }
1277 }
1278
1279 for (trigger, deps) in dep_req {
1280 if actual_keys.contains(trigger) {
1281 for dep in deps {
1282 if !actual_keys.contains(dep) {
1283 out.push(build_diagnostic(
1284 "F3021",
1285 &format!("'{}' is a dependency of '{}'", dep, trigger),
1286 m,
1287 rid,
1288 base_path,
1289 Some(&format!("Add '{}' when '{}' is specified", dep, trigger)),
1290 ));
1291 }
1292 }
1293 }
1294 }
1295
1296 validate_required_groups(out, m, rid, actual_keys, req_or, req_xor, base_path);
1297
1298 for sub in all_of {
1299 validate_sub(out, m, rid, rtype, actual_keys, sub, defs, base_path, 0);
1300 }
1301
1302 let group_assignments = if any_of.is_empty() && one_of.is_empty() {
1310 Some(Vec::new())
1311 } else {
1312 branch_scenario_assignments(m, rid, any_of.iter().chain(one_of.iter()), defs, base_path)
1313 }
1314 .unwrap_or_default();
1315
1316 if !any_of.is_empty() {
1317 for assignment in &group_assignments {
1318 let evaluations =
1319 evaluate_object_composition_branches(m, rid, rtype, actual_keys, any_of, defs, base_path, assignment);
1320 if evaluations.iter().all(|evaluation| !evaluation.matched) {
1321 out.push(build_composition_diagnostic(
1322 "F3017",
1323 CompositionKind::AnyOf,
1324 &evaluations,
1325 m,
1326 rid,
1327 base_path,
1328 Some(rtype),
1329 None,
1330 assignment_condition_map(assignment),
1331 ));
1332 }
1333 }
1334 }
1335
1336 if !one_of.is_empty() {
1337 for assignment in &group_assignments {
1338 let evaluations =
1339 evaluate_object_composition_branches(m, rid, rtype, actual_keys, one_of, defs, base_path, assignment);
1340 let match_count = evaluations.iter().filter(|evaluation| evaluation.matched).count();
1341 if match_count != 1 {
1342 out.push(build_composition_diagnostic(
1343 "F3018",
1344 CompositionKind::OneOf,
1345 &evaluations,
1346 m,
1347 rid,
1348 base_path,
1349 Some(rtype),
1350 None,
1351 assignment_condition_map(assignment),
1352 ));
1353 }
1354 }
1355 }
1356
1357 if let Some(conds) = scenario {
1361 for diag in out.iter_mut().skip(before_len) {
1362 if diag.condition_scenario.is_none() {
1363 diag.condition_scenario = Some(conds.clone());
1364 }
1365 }
1366 }
1367}
1368
1369fn object_key_scenarios(
1375 m: &Arc<SemanticModel>,
1376 rid: &str,
1377 prop_path: &str,
1378) -> Vec<(Vec<String>, HashMap<String, bool>)> {
1379 let mut out: Vec<(Vec<String>, HashMap<String, bool>)> = Vec::new();
1380 for (val, conds) in m.resolve_scenarios_json(rid, prop_path) {
1381 if !is_satisfiable(m, &conds) {
1382 continue;
1383 }
1384 let Some(obj) = val.as_object() else {
1385 continue;
1386 };
1387 let mut keys: Vec<String> = obj.keys().cloned().collect();
1388 keys.sort();
1389 out.push((keys, conds));
1390 }
1391 let mut seen_keysets: HashMap<Vec<String>, HashMap<String, bool>> = HashMap::new();
1395 for (keys, conds) in out.drain(..) {
1396 seen_keysets
1397 .entry(keys)
1398 .and_modify(|existing| {
1399 existing.retain(|k, v| conds.get(k) == Some(v));
1403 })
1404 .or_insert(conds);
1405 }
1406 seen_keysets.into_iter().collect()
1407}
1408
1409fn resource_property_key_scenarios(
1416 m: &Arc<SemanticModel>,
1417 rid: &str,
1418 res: &ResolvedResource,
1419) -> Vec<(Vec<String>, HashMap<String, bool>)> {
1420 let keys: Vec<&str> = res.properties.keys().map(String::as_str).collect();
1421 if keys.len() != 1 || keys[0] != FN_IF {
1422 let mut ks: Vec<String> = res.properties.keys().cloned().collect();
1423 ks.sort();
1424 return vec![(ks, HashMap::new())];
1425 }
1426 let path = format!("{}.{}", KEY_PROPERTIES, FN_IF);
1429 let scenarios = object_key_scenarios(m, rid, &path);
1430 if scenarios.is_empty() {
1431 return vec![(Vec::new(), HashMap::new())];
1432 }
1433 scenarios
1434}
1435
1436fn validate_sub(
1437 out: &mut Vec<Diagnostic>,
1438 m: &Arc<SemanticModel>,
1439 rid: &str,
1440 rtype: &str,
1441 actual_keys: &[String],
1442 sub: &SubSchema,
1443 defs: &HashMap<String, PropSchema>,
1444 base_path: &str,
1445 depth: usize,
1446) {
1447 if depth > MAX_MATCH_DEPTH {
1451 m.record_budget_exhaustion(BudgetKind::SchemaMatchDepth);
1452 return;
1453 }
1454 let resolved;
1458 let effective_sub = if sub.ref_name.is_some() {
1459 resolved = sub.resolve(defs);
1460 if sub.ref_name.is_some() && resolved.ref_name.is_some() {
1461 out.push(build_diagnostic(
1463 "F3003",
1464 &format!(
1465 "Composition branch references undefined definition '{}'",
1466 sub.ref_name.as_deref().unwrap_or("")
1467 ),
1468 m,
1469 rid,
1470 base_path,
1471 None,
1472 ));
1473 return;
1474 }
1475 &*resolved
1476 } else {
1477 sub
1478 };
1479
1480 for req in &effective_sub.required {
1481 if !actual_keys.contains(req) {
1482 if extension_required_covered_by_dedicated_rule(rtype, req) {
1487 continue;
1488 }
1489 out.push(build_diagnostic(
1490 "F3003",
1491 &format!("'{}' is a required property", req),
1492 m,
1493 rid,
1494 base_path,
1495 Some(&format!("Add '{}'", req)),
1496 ));
1497 } else {
1498 check_required_not_null(out, m, rid, base_path, req);
1499 }
1500 }
1501
1502 if effective_sub.additional_properties == Some(false) && !effective_sub.properties.is_empty() {
1504 let known: std::collections::HashSet<&str> = effective_sub.properties.keys().map(|s| s.as_str()).collect();
1505 let pattern_matchers: Vec<Option<std::sync::Arc<CompiledPattern>>> =
1506 effective_sub.pattern_properties.keys().map(|p| compile_pattern(p)).collect();
1507 for key in actual_keys {
1508 if known.contains(key.as_str()) {
1509 continue;
1510 }
1511 let allowed_by_pattern =
1512 pattern_matchers.iter().any(|matcher| matcher.as_ref().is_none_or(|re| re.is_match(key)));
1513 if allowed_by_pattern {
1514 continue;
1515 }
1516 out.push(build_diagnostic(
1517 "F3002",
1518 &format!("Additional properties are not allowed ('{}' was unexpected)", key),
1519 m,
1520 rid,
1521 &format!("{}.{}", base_path, key),
1522 None,
1523 ));
1524 }
1525 }
1526
1527 validate_sub_dependencies(out, m, rid, actual_keys, effective_sub, base_path);
1528
1529 validate_required_groups(
1530 out,
1531 m,
1532 rid,
1533 actual_keys,
1534 &effective_sub.required_or,
1535 &effective_sub.required_xor,
1536 base_path,
1537 );
1538
1539 validate_sub_value_constraints(out, m, rid, effective_sub, defs, base_path);
1543
1544 for ite in &effective_sub.if_then_else {
1551 let matches = condition_matches_at(&ite.condition, actual_keys, m, rid, defs, base_path);
1552 let branch = if matches { &ite.then_schema } else { &ite.else_schema };
1553 if let Some(branch_schema) = branch {
1554 if ite.enforce_full_branch {
1555 validate_sub(out, m, rid, rtype, actual_keys, branch_schema, defs, base_path, depth + 1);
1556 } else {
1557 validate_sub_dependencies(out, m, rid, actual_keys, branch_schema, base_path);
1558 }
1559 }
1560 }
1561}
1562
1563fn schema_value_matches(
1574 value: &serde_json::Value,
1575 schema: &PropSchema,
1576 defs: &HashMap<String, PropSchema>,
1577 depth: usize,
1578) -> bool {
1579 schema_value_failure_reasons(value, schema, defs, depth, "").is_empty()
1580}
1581
1582fn evaluate_value_composition_branches(
1583 value: &serde_json::Value,
1584 branches: &[SubSchema],
1585 defs: &HashMap<String, PropSchema>,
1586 property_path: &str,
1587) -> Vec<CompositionBranchEvaluation> {
1588 branches
1589 .iter()
1590 .enumerate()
1591 .map(|(index, branch)| {
1592 let failure_reasons = schema_value_failure_reasons(value, branch, defs, 0, property_path);
1593 let matched = failure_reasons.is_empty();
1594 CompositionBranchEvaluation::new(
1595 index + 1,
1596 matched,
1597 required_property_combinations(branch, defs, None),
1598 failure_reasons,
1599 )
1600 })
1601 .collect()
1602}
1603
1604fn schema_value_failure_reasons(
1605 value: &serde_json::Value,
1606 schema: &PropSchema,
1607 defs: &HashMap<String, PropSchema>,
1608 depth: usize,
1609 property_path: &str,
1610) -> Vec<CompositionFailureReason> {
1611 if depth > MAX_MATCH_DEPTH || value.is_null() {
1612 if depth > MAX_MATCH_DEPTH {
1613 record_schema_budget_exhaustion(BudgetKind::SchemaMatchDepth);
1614 }
1615 return Vec::new();
1616 }
1617
1618 let resolved;
1619 let effective = if schema.ref_name.is_some() {
1620 resolved = schema.resolve(defs);
1621 if resolved.ref_name.is_some() {
1622 return vec![CompositionFailureReason::new(
1623 format!(
1624 "Composition branch references undefined definition '{}'",
1625 schema.ref_name.as_deref().unwrap_or("")
1626 ),
1627 property_path,
1628 )];
1629 }
1630 &*resolved
1631 } else {
1632 schema
1633 };
1634
1635 let mut reasons = Vec::new();
1636 if let Some(ref expected_type) = effective.prop_type
1637 && !type_matches(value, expected_type)
1638 {
1639 let coercible = expected_type
1640 .primary()
1641 .is_some_and(|expected| matches!(coerce_value(value, expected), CoerceResult::Coerced(_, _)));
1642 if !coercible {
1643 reasons.push(CompositionFailureReason::new(
1644 format!(
1645 "{} has type '{}', expected type '{}'",
1646 format_value(value),
1647 json_value_type(value),
1648 expected_type.names().collect::<Vec<_>>().join("|")
1649 ),
1650 property_path,
1651 ));
1652 return reasons;
1653 }
1654 }
1655
1656 if !effective.enum_values.is_empty() && !enum_matches(value, &effective.enum_values) {
1657 reasons.push(CompositionFailureReason::new(
1658 format!("{} is not one of {}", format_value(value), format_allowed_values(&effective.enum_values)),
1659 property_path,
1660 ));
1661 }
1662 if !effective.enum_case_insensitive.is_empty()
1663 && !enum_matches_case_insensitive(value, &effective.enum_case_insensitive)
1664 {
1665 reasons.push(CompositionFailureReason::new(
1666 format!(
1667 "{} is not one of {} (case-insensitive)",
1668 format_value(value),
1669 format_allowed_values(&effective.enum_case_insensitive)
1670 ),
1671 property_path,
1672 ));
1673 }
1674 if !effective.not_enum.is_empty() && enum_matches(value, &effective.not_enum) {
1675 reasons.push(CompositionFailureReason::new(
1676 format!("{} must not be one of {}", format_value(value), format_allowed_values(&effective.not_enum)),
1677 property_path,
1678 ));
1679 }
1680 if let Some(ref expected) = effective.const_value
1681 && !scalar_eq(value, expected)
1682 {
1683 reasons.push(CompositionFailureReason::new(
1684 format!("{} must equal {}", format_value(value), format_value(expected)),
1685 property_path,
1686 ));
1687 }
1688 if let Some(ref pattern) = effective.pattern
1689 && let Some(compiled) = compile_pattern(pattern)
1690 && let Some(actual) = coerce_to_string(value)
1691 && !compiled.is_match(&actual)
1692 {
1693 reasons.push(CompositionFailureReason::new(
1694 format!("{} does not match pattern '{pattern}'", format_value(value)),
1695 property_path,
1696 ));
1697 }
1698 if let Some(ref format) = effective.format
1699 && let Some(actual) = coerce_to_string(value)
1700 && !format_value_matches(&actual, format)
1701 {
1702 reasons.push(CompositionFailureReason::new(
1703 format!("{} does not match format '{format}'", format_value(value)),
1704 property_path,
1705 ));
1706 }
1707
1708 if let Some(number) = coerce_to_number(value) {
1709 if let Some(maximum) = effective.maximum
1710 && number > maximum
1711 {
1712 reasons.push(CompositionFailureReason::new(format!("{number} exceeds maximum {maximum}"), property_path));
1713 }
1714 if let Some(minimum) = effective.minimum
1715 && number < minimum
1716 {
1717 reasons.push(CompositionFailureReason::new(format!("{number} is below minimum {minimum}"), property_path));
1718 }
1719 if let Some(maximum) = effective.exclusive_maximum
1720 && number >= maximum
1721 {
1722 reasons.push(CompositionFailureReason::new(format!("{number} must be less than {maximum}"), property_path));
1723 }
1724 if let Some(minimum) = effective.exclusive_minimum
1725 && number <= minimum
1726 {
1727 reasons
1728 .push(CompositionFailureReason::new(format!("{number} must be greater than {minimum}"), property_path));
1729 }
1730 if let Some(multiple) = effective.multiple_of
1731 && multiple > 0.0
1732 {
1733 let remainder = (number / multiple).round() * multiple - number;
1734 let epsilon = multiple * 1e-9;
1735 if remainder.abs() > epsilon && (multiple - remainder.abs()).abs() > epsilon {
1736 reasons.push(CompositionFailureReason::new(
1737 format!("{number} is not a multiple of {multiple}"),
1738 property_path,
1739 ));
1740 }
1741 }
1742 }
1743
1744 if (effective.min_length.is_some() || effective.max_length.is_some())
1745 && let Some(actual) = coerce_to_string(value)
1746 {
1747 let length = actual.chars().count() as u64;
1748 if let Some(maximum) = effective.max_length
1749 && length > maximum
1750 {
1751 reasons.push(CompositionFailureReason::new(
1752 format!("String length {length} exceeds maximum {maximum}"),
1753 property_path,
1754 ));
1755 }
1756 if let Some(minimum) = effective.min_length
1757 && length < minimum
1758 {
1759 reasons.push(CompositionFailureReason::new(
1760 format!("String length {length} is below minimum {minimum}"),
1761 property_path,
1762 ));
1763 }
1764 }
1765
1766 if let Some(items) = value.as_array() {
1767 let length = items.iter().filter(|item| !item.is_null()).count() as u64;
1768 if let Some(maximum) = effective.max_items
1769 && length > maximum
1770 {
1771 reasons.push(CompositionFailureReason::new(
1772 format!("Array length {length} exceeds maximum {maximum}"),
1773 property_path,
1774 ));
1775 }
1776 if let Some(minimum) = effective.min_items
1777 && length < minimum
1778 {
1779 reasons.push(CompositionFailureReason::new(
1780 format!("Array length {length} is below minimum {minimum}"),
1781 property_path,
1782 ));
1783 }
1784 if effective.unique_items == Some(true) {
1785 let mut duplicate_found = false;
1786 for (index, item) in items.iter().enumerate() {
1787 if item.is_null() {
1788 continue;
1789 }
1790 if items[..index].iter().any(|previous| !previous.is_null() && previous == item) {
1791 duplicate_found = true;
1792 break;
1793 }
1794 }
1795 if duplicate_found {
1796 reasons.push(CompositionFailureReason::new("Array items must be unique", property_path));
1797 }
1798 }
1799 if let Some(ref item_schema) = effective.items {
1800 for (index, item) in items.iter().enumerate() {
1801 if !schema_value_matches(item, item_schema, defs, depth + 1) {
1802 let item_path = append_property_path(property_path, &index.to_string());
1803 reasons.extend(schema_value_failure_reasons(item, item_schema, defs, depth + 1, &item_path));
1804 }
1805 }
1806 }
1807 }
1808
1809 if let Some(object) = value.as_object() {
1810 let property_present = |property: &str| object.get(property).is_some_and(|candidate| !candidate.is_null());
1811 let property_count = object.values().filter(|candidate| !candidate.is_null()).count() as u64;
1812 if let Some(maximum) = effective.max_properties
1813 && property_count > maximum
1814 {
1815 reasons.push(CompositionFailureReason::new(
1816 format!("Object has {property_count} properties, maximum is {maximum}"),
1817 property_path,
1818 ));
1819 }
1820 if let Some(minimum) = effective.min_properties
1821 && property_count < minimum
1822 {
1823 reasons.push(CompositionFailureReason::new(
1824 format!("Object has {property_count} properties, minimum is {minimum}"),
1825 property_path,
1826 ));
1827 }
1828 for required in &effective.required {
1829 if !property_present(required) {
1830 reasons
1831 .push(CompositionFailureReason::new(format!("'{required}' is a required property"), property_path));
1832 }
1833 }
1834 for (trigger, dependencies) in &effective.dependent_required {
1835 if property_present(trigger) {
1836 for dependency in dependencies {
1837 if !property_present(dependency) {
1838 reasons.push(CompositionFailureReason::new(
1839 format!("'{dependency}' is a dependency of '{trigger}'"),
1840 property_path,
1841 ));
1842 }
1843 }
1844 }
1845 }
1846 for (trigger, excluded) in &effective.dependent_excluded {
1847 if property_present(trigger) {
1848 for property in excluded {
1849 if property_present(property) {
1850 reasons.push(CompositionFailureReason::new(
1851 format!("'{property}' should not be included with '{trigger}'"),
1852 append_property_path(property_path, property),
1853 ));
1854 }
1855 }
1856 }
1857 }
1858 if !effective.required_or.is_empty() && !effective.required_or.iter().any(|property| property_present(property))
1859 {
1860 reasons.push(CompositionFailureReason::new(
1861 format!(
1862 "At least one of {} is required",
1863 effective.required_or.iter().map(|property| format!("'{property}'")).collect::<Vec<_>>().join(", ")
1864 ),
1865 property_path,
1866 ));
1867 }
1868 if !effective.required_xor.is_empty() {
1869 let present = effective.required_xor.iter().filter(|property| property_present(property)).count();
1870 if present != 1 {
1871 reasons.push(CompositionFailureReason::new(
1872 format!(
1873 "Exactly one of {} is required, but {present} are present",
1874 effective
1875 .required_xor
1876 .iter()
1877 .map(|property| format!("'{property}'"))
1878 .collect::<Vec<_>>()
1879 .join(", ")
1880 ),
1881 property_path,
1882 ));
1883 }
1884 }
1885 if effective.additional_properties == Some(false)
1886 && (!effective.properties.is_empty() || !effective.pattern_properties.is_empty())
1887 {
1888 let pattern_matchers: Vec<Option<Arc<CompiledPattern>>> =
1889 effective.pattern_properties.keys().map(|pattern| compile_pattern(pattern)).collect();
1890 for (property, property_value) in object {
1891 if property_value.is_null() || effective.properties.contains_key(property) {
1892 continue;
1893 }
1894 let allowed = pattern_matchers
1895 .iter()
1896 .any(|matcher| matcher.as_ref().is_none_or(|compiled| compiled.is_match(property)));
1897 if !allowed {
1898 reasons.push(CompositionFailureReason::new(
1899 format!("Additional property '{property}' is not allowed"),
1900 append_property_path(property_path, property),
1901 ));
1902 }
1903 }
1904 }
1905 for (property, property_schema) in &effective.properties {
1906 if let Some(property_value) = object.get(property)
1907 && !schema_value_matches(property_value, property_schema, defs, depth + 1)
1908 {
1909 let child_path = append_property_path(property_path, property);
1910 reasons.extend(schema_value_failure_reasons(
1911 property_value,
1912 property_schema,
1913 defs,
1914 depth + 1,
1915 &child_path,
1916 ));
1917 }
1918 }
1919 }
1920
1921 for branch in &effective.all_of {
1922 if !schema_value_matches(value, branch, defs, depth + 1) {
1923 reasons.extend(schema_value_failure_reasons(value, branch, defs, depth + 1, property_path));
1924 }
1925 }
1926 if !effective.any_of.is_empty()
1927 && !effective.any_of.iter().any(|branch| schema_value_matches(value, branch, defs, depth + 1))
1928 {
1929 for branch in &effective.any_of {
1930 reasons.extend(schema_value_failure_reasons(value, branch, defs, depth + 1, property_path));
1931 }
1932 }
1933 if !effective.one_of.is_empty() {
1934 let matching: Vec<usize> = effective
1935 .one_of
1936 .iter()
1937 .enumerate()
1938 .filter_map(|(index, branch)| schema_value_matches(value, branch, defs, depth + 1).then_some(index + 1))
1939 .collect();
1940 if matching.is_empty() {
1941 for branch in &effective.one_of {
1942 reasons.extend(schema_value_failure_reasons(value, branch, defs, depth + 1, property_path));
1943 }
1944 } else if matching.len() > 1 {
1945 reasons.push(CompositionFailureReason::new(
1946 format!(
1947 "Value matches nested oneOf branches {}; exactly one must match",
1948 render_branch_numbers(&matching)
1949 ),
1950 property_path,
1951 ));
1952 }
1953 }
1954 for conditional in effective.if_then_else.iter().filter(|conditional| conditional.enforce_full_branch) {
1955 if let Some(object) = value.as_object() {
1956 let object_keys: Vec<String> = object
1957 .iter()
1958 .filter(|(_, candidate)| !candidate.is_null())
1959 .map(|(property, _)| property.clone())
1960 .collect();
1961 let condition_matches =
1962 condition_schema_value_matches(&conditional.condition, object, &object_keys, defs, depth + 1);
1963 let branch = if condition_matches { &conditional.then_schema } else { &conditional.else_schema };
1964 if let Some(branch_schema) = branch
1965 && !schema_value_matches(value, branch_schema, defs, depth + 1)
1966 {
1967 reasons.extend(schema_value_failure_reasons(value, branch_schema, defs, depth + 1, property_path));
1968 }
1969 }
1970 }
1971
1972 deduplicate_composition_reasons(reasons)
1973}
1974
1975fn append_property_path(base: &str, segment: &str) -> String {
1976 if base.is_empty() { segment.to_string() } else { format!("{base}.{segment}") }
1977}
1978
1979fn json_value_type(value: &serde_json::Value) -> &'static str {
1980 match value {
1981 serde_json::Value::Null => "null",
1982 serde_json::Value::Bool(_) => "boolean",
1983 serde_json::Value::Number(number) if number.is_i64() || number.is_u64() => "integer",
1984 serde_json::Value::Number(_) => "number",
1985 serde_json::Value::String(_) => "string",
1986 serde_json::Value::Array(_) => "array",
1987 serde_json::Value::Object(_) => "object",
1988 }
1989}
1990
1991fn condition_schema_value_matches(
1995 cond: &ConditionSchema,
1996 obj: &serde_json::Map<String, serde_json::Value>,
1997 obj_keys: &[String],
1998 defs: &HashMap<String, PropSchema>,
1999 depth: usize,
2000) -> bool {
2001 if depth > MAX_MATCH_DEPTH {
2002 record_schema_budget_exhaustion(BudgetKind::SchemaMatchDepth);
2003 return true; }
2005
2006 if !cond.any_of.is_empty() {
2008 return cond.any_of.iter().any(|sub| condition_schema_value_matches(sub, obj, obj_keys, defs, depth + 1));
2009 }
2010
2011 if let Some(ref required_type) = cond.prop_type
2014 && !required_type.names().any(|name| name == "object")
2015 {
2016 return false;
2017 }
2018
2019 for req in &cond.required {
2021 if !obj_keys.iter().any(|k| k == req) {
2022 return false;
2023 }
2024 }
2025
2026 for (prop_name, prop_schema) in &cond.properties {
2028 let resolved = prop_schema.resolve(defs);
2029 if let Some(prop_value) = obj.get(prop_name) {
2030 if !schema_value_matches(prop_value, &resolved, defs, depth + 1) {
2031 return false;
2032 }
2033 } else {
2034 let has_value_constraint = !resolved.enum_values.is_empty()
2037 || !resolved.enum_case_insensitive.is_empty()
2038 || resolved.const_value.is_some()
2039 || resolved.pattern.is_some();
2040 if has_value_constraint {
2041 return false;
2042 }
2043 }
2044 }
2045
2046 true
2047}
2048
2049fn validate_sub_value_constraints(
2059 out: &mut Vec<Diagnostic>,
2060 m: &Arc<SemanticModel>,
2061 rid: &str,
2062 sub: &SubSchema,
2063 defs: &HashMap<String, PropSchema>,
2064 base_path: &str,
2065) {
2066 let has_value_constraints =
2072 sub.properties.values().any(|ps| ps.resolve(defs).constrains_value()) || sub_self_constrains_value(sub);
2073 if !has_value_constraints {
2074 return;
2075 }
2076
2077 for (prop_name, prop_schema) in &sub.properties {
2079 let resolved = prop_schema.resolve(defs);
2080 let prop_path = format!("{}.{}", base_path, prop_name);
2081 let shared_scenarios = m.resolve_scenarios_json_shared(rid, &prop_path);
2085 let scenarios: Vec<_> =
2086 shared_scenarios.iter().filter(|(_, conds)| scenario_consistent_with_filter(m, conds)).collect();
2087
2088 if scenarios.is_empty() {
2091 continue;
2092 }
2093
2094 let any_scenario_matches = scenarios.iter().any(|(val, conds)| {
2096 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, &prop_path, val, conds) {
2097 return true; }
2099 schema_value_matches(val, &resolved, defs, 0)
2100 });
2101
2102 if !any_scenario_matches {
2103 let (offending, failure_detail) = scenarios
2107 .iter()
2108 .find(|(val, conds)| {
2109 is_satisfiable(m, conds)
2110 && !val.is_null()
2111 && !defer_value_constraints(m, rid, &prop_path, val, conds)
2112 })
2113 .map(|(val, _)| {
2114 let reasons = schema_value_failure_reasons(val, &resolved, defs, 0, &prop_path);
2115 (format_value(val), render_composition_reasons(&reasons, &prop_path))
2116 })
2117 .unwrap_or_else(|| ("Value".to_string(), describe_prop_constraints(&resolved)));
2118 out.push(build_diagnostic(
2119 "F3017",
2120 &format!(
2121 "{offending} at '{prop_name}' does not satisfy the composition branch constraint ({}): {failure_detail}",
2122 describe_prop_constraints(&resolved)
2123 ),
2124 m,
2125 rid,
2126 &prop_path,
2127 None,
2128 ));
2129 }
2130 }
2131
2132 if sub_self_constrains_value(sub) {
2136 let self_schema = schema_for_self_value_constraints(sub);
2137 let shared_scenarios = m.resolve_scenarios_json_shared(rid, base_path);
2138 let scenarios: Vec<_> =
2139 shared_scenarios.iter().filter(|(_, conds)| scenario_consistent_with_filter(m, conds)).collect();
2140 if !scenarios.is_empty() {
2141 let any_scenario_matches = scenarios.iter().any(|(val, conds)| {
2142 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, base_path, val, conds)
2143 {
2144 return true;
2145 }
2146 schema_value_matches(val, &self_schema, defs, 0)
2147 });
2148 if !any_scenario_matches {
2149 let (offending, failure_detail) = scenarios
2150 .iter()
2151 .find(|(val, conds)| {
2152 is_satisfiable(m, conds)
2153 && !val.is_null()
2154 && !defer_value_constraints(m, rid, base_path, val, conds)
2155 })
2156 .map(|(val, _)| {
2157 let reasons = schema_value_failure_reasons(val, &self_schema, defs, 0, base_path);
2158 (format_value(val), render_composition_reasons(&reasons, base_path))
2159 })
2160 .unwrap_or_else(|| ("Value".to_string(), describe_prop_constraints(&self_schema)));
2161 out.push(build_diagnostic(
2162 "F3017",
2163 &format!(
2164 "{offending} does not satisfy the composition branch constraint ({}): {failure_detail}",
2165 describe_prop_constraints(&self_schema)
2166 ),
2167 m,
2168 rid,
2169 base_path,
2170 None,
2171 ));
2172 }
2173 }
2174 }
2175}
2176
2177fn branch_scenario_assignments<'a>(
2181 m: &Arc<SemanticModel>,
2182 rid: &str,
2183 branches: impl Iterator<Item = &'a SubSchema>,
2184 defs: &HashMap<String, PropSchema>,
2185 base_path: &str,
2186) -> Option<Vec<HashMap<String, bool>>> {
2187 let mut property_names = BTreeSet::new();
2188 for branch in branches {
2189 let effective_branch = branch.resolve(defs);
2190 property_names.extend(effective_branch.properties.keys().cloned());
2191 property_names.extend(effective_branch.required.iter().cloned());
2192 property_names.extend(effective_branch.required_or.iter().cloned());
2193 property_names.extend(effective_branch.required_xor.iter().cloned());
2194 }
2195
2196 let paths: Vec<String> = property_names.into_iter().map(|name| format!("{}.{}", base_path, name)).collect();
2197 let refs: Vec<&str> = paths.iter().map(String::as_str).collect();
2198 property_scenario_assignments(m, rid, &refs)
2199}
2200
2201fn assignment_condition_map(assignment: &HashMap<String, bool>) -> Option<HashMap<String, bool>> {
2205 if assignment.is_empty() { None } else { Some(assignment.clone()) }
2206}
2207
2208#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2209enum CompositionKind {
2210 AnyOf,
2211 OneOf,
2212}
2213
2214impl CompositionKind {
2215 fn name(self) -> &'static str {
2216 match self {
2217 CompositionKind::AnyOf => "anyOf",
2218 CompositionKind::OneOf => "oneOf",
2219 }
2220 }
2221
2222 fn expected_constraint(self) -> &'static str {
2223 match self {
2224 CompositionKind::AnyOf => "at least one anyOf branch",
2225 CompositionKind::OneOf => "exactly one oneOf branch",
2226 }
2227 }
2228}
2229
2230#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
2231struct CompositionFailureReason {
2232 message: String,
2233 property_path: String,
2234}
2235
2236impl CompositionFailureReason {
2237 fn new(message: impl Into<String>, property_path: impl Into<String>) -> Self {
2238 Self { message: message.into(), property_path: property_path.into() }
2239 }
2240}
2241
2242#[derive(Clone, Debug)]
2243struct CompositionBranchEvaluation {
2244 branch: usize,
2245 matched: bool,
2246 required_property_combinations: Vec<Vec<String>>,
2247 required_property_combinations_curtailed: bool,
2248 failure_reasons: Vec<CompositionFailureReason>,
2249}
2250
2251impl CompositionBranchEvaluation {
2252 fn new(
2253 branch: usize,
2254 matched: bool,
2255 required_property_combinations: (Vec<Vec<String>>, bool),
2256 failure_reasons: Vec<CompositionFailureReason>,
2257 ) -> Self {
2258 let (mut required_property_combinations, required_property_combinations_curtailed) =
2259 required_property_combinations;
2260 for combination in &mut required_property_combinations {
2261 combination.sort();
2262 combination.dedup();
2263 }
2264 required_property_combinations.retain(|combination| !combination.is_empty());
2265 required_property_combinations.sort();
2266 required_property_combinations.dedup();
2267 Self {
2268 branch,
2269 matched,
2270 required_property_combinations,
2271 required_property_combinations_curtailed,
2272 failure_reasons: deduplicate_composition_reasons(failure_reasons),
2273 }
2274 }
2275}
2276
2277fn deduplicate_composition_reasons(mut reasons: Vec<CompositionFailureReason>) -> Vec<CompositionFailureReason> {
2278 reasons.sort();
2279 reasons.dedup();
2280 reasons
2281}
2282
2283#[allow(clippy::too_many_arguments)]
2284fn evaluate_object_composition_branches(
2285 m: &Arc<SemanticModel>,
2286 rid: &str,
2287 rtype: &str,
2288 actual_keys: &[String],
2289 branches: &[SubSchema],
2290 defs: &HashMap<String, PropSchema>,
2291 base_path: &str,
2292 assignment: &HashMap<String, bool>,
2293) -> Vec<CompositionBranchEvaluation> {
2294 branches
2295 .iter()
2296 .enumerate()
2297 .map(|(index, branch)| {
2298 let mut branch_diagnostics = Vec::new();
2299 validate_sub_under_assignment(
2300 &mut branch_diagnostics,
2301 m,
2302 rid,
2303 rtype,
2304 actual_keys,
2305 branch,
2306 defs,
2307 base_path,
2308 assignment,
2309 );
2310 let matched = branch_diagnostics.is_empty();
2311 let mut failure_reasons: Vec<CompositionFailureReason> = branch_diagnostics
2312 .into_iter()
2313 .map(|diagnostic| {
2314 CompositionFailureReason::new(
2315 diagnostic.message,
2316 diagnostic.property_path.unwrap_or_else(|| base_path.to_string()),
2317 )
2318 })
2319 .collect();
2320 if !matched && failure_reasons.is_empty() {
2321 failure_reasons.push(CompositionFailureReason::new("The branch schema was not satisfied", base_path));
2322 }
2323 CompositionBranchEvaluation::new(
2324 index + 1,
2325 matched,
2326 required_property_combinations(branch, defs, Some(rtype)),
2327 failure_reasons,
2328 )
2329 })
2330 .collect()
2331}
2332
2333fn required_property_combinations(
2338 branch: &SubSchema,
2339 defs: &HashMap<String, PropSchema>,
2340 rtype: Option<&str>,
2341) -> (Vec<Vec<String>>, bool) {
2342 let resolved;
2343 let effective = if branch.ref_name.is_some() {
2344 resolved = branch.resolve(defs);
2345 if resolved.ref_name.is_some() {
2346 return (Vec::new(), false);
2347 }
2348 &*resolved
2349 } else {
2350 branch
2351 };
2352
2353 let mut base: Vec<String> = effective
2354 .required
2355 .iter()
2356 .filter(|property| {
2357 rtype.is_none_or(|resource_type| !extension_required_covered_by_dedicated_rule(resource_type, property))
2358 })
2359 .cloned()
2360 .collect();
2361 base.sort();
2362 base.dedup();
2363
2364 if effective.required.is_empty() && effective.required_or.is_empty() && effective.required_xor.is_empty() {
2365 return (Vec::new(), false);
2366 }
2367
2368 let mut combinations = vec![base];
2369 let mut curtailed = expand_required_choice_group(&mut combinations, &effective.required_or, false);
2370 curtailed |= expand_required_choice_group(&mut combinations, &effective.required_xor, true);
2371 (combinations, curtailed)
2372}
2373
2374fn expand_required_choice_group(combinations: &mut Vec<Vec<String>>, choices: &[String], exactly_one: bool) -> bool {
2377 if choices.is_empty() {
2378 return false;
2379 }
2380
2381 let mut expanded = Vec::new();
2382 let mut curtailed = false;
2383 'combinations: for combination in combinations.iter() {
2384 let present = choices.iter().filter(|choice| combination.contains(choice)).count();
2385 if present > 0 {
2386 if !exactly_one || present == 1 {
2387 if expanded.len() == MAX_REQUIRED_PROPERTY_COMBINATIONS {
2388 curtailed = true;
2389 break;
2390 }
2391 expanded.push(combination.clone());
2392 }
2393 continue;
2394 }
2395 for choice in choices {
2396 if expanded.len() == MAX_REQUIRED_PROPERTY_COMBINATIONS {
2397 curtailed = true;
2398 break 'combinations;
2399 }
2400 let mut candidate = combination.clone();
2401 candidate.push(choice.clone());
2402 expanded.push(candidate);
2403 }
2404 }
2405 *combinations = expanded;
2406 curtailed
2407}
2408
2409#[allow(clippy::too_many_arguments)]
2410fn build_composition_diagnostic(
2411 rule_id: &str,
2412 kind: CompositionKind,
2413 evaluations: &[CompositionBranchEvaluation],
2414 m: &Arc<SemanticModel>,
2415 rid: &str,
2416 property_path: &str,
2417 resource_type: Option<&str>,
2418 actual_value: Option<&serde_json::Value>,
2419 condition_scenario: Option<HashMap<String, bool>>,
2420) -> Diagnostic {
2421 let matching_branches: Vec<usize> =
2422 evaluations.iter().filter(|evaluation| evaluation.matched).map(|evaluation| evaluation.branch).collect();
2423 let match_count = matching_branches.len();
2424 let branch_count = evaluations.len();
2425 let match_outcome = if match_count == 0 { "zeroMatches" } else { "multipleMatches" };
2426
2427 let target = resource_type.map(|rtype| format!(" for {rtype}")).unwrap_or_default();
2428 let mut message = match (kind, match_count) {
2429 (CompositionKind::AnyOf, _) => format!(
2430 "Value is not valid under any of the {branch_count} anyOf schemas{target} (0 branches matched; at least one is required)."
2431 ),
2432 (CompositionKind::OneOf, 0) => format!(
2433 "Value is not valid under any of the {branch_count} oneOf schemas{target} (0 branches matched; exactly one is required)."
2434 ),
2435 (CompositionKind::OneOf, _) => format!(
2436 "Value is valid under more than one of the {branch_count} oneOf schemas{target} ({match_count} branches matched; exactly one is required). Matching branches: {}.",
2437 render_branch_numbers(&matching_branches)
2438 ),
2439 };
2440
2441 let mut valid_combination_set = BTreeSet::new();
2442 let mut combinations_curtailed =
2443 evaluations.iter().any(|evaluation| evaluation.required_property_combinations_curtailed);
2444 'evaluations: for evaluation in evaluations {
2445 for combination in &evaluation.required_property_combinations {
2446 if valid_combination_set.contains(combination) {
2447 continue;
2448 }
2449 if valid_combination_set.len() == MAX_REQUIRED_PROPERTY_COMBINATIONS {
2450 combinations_curtailed = true;
2451 break 'evaluations;
2452 }
2453 valid_combination_set.insert(combination.clone());
2454 }
2455 }
2456 let valid_combinations: Vec<Vec<String>> = valid_combination_set.into_iter().collect();
2457 if !valid_combinations.is_empty() {
2458 message.push_str(" Required property combinations: ");
2459 message.push_str(
2460 &valid_combinations
2461 .iter()
2462 .map(|combination| {
2463 format!(
2464 "[{}]",
2465 combination.iter().map(|property| format!("'{property}'")).collect::<Vec<_>>().join(", ")
2466 )
2467 })
2468 .collect::<Vec<_>>()
2469 .join(" or "),
2470 );
2471 message.push('.');
2472 }
2473 if combinations_curtailed {
2474 m.record_budget_exhaustion(BudgetKind::RequiredPropertyCombinations);
2475 message.push_str(&format!(
2476 " Additional required-property combinations were omitted after the deterministic limit of {MAX_REQUIRED_PROPERTY_COMBINATIONS}."
2477 ));
2478 }
2479
2480 if match_count == 0 {
2481 let failure_summaries: Vec<String> = evaluations
2482 .iter()
2483 .filter(|evaluation| !evaluation.matched)
2484 .map(|evaluation| {
2485 let reasons = render_composition_reasons(&evaluation.failure_reasons, property_path);
2486 format!("branch {}: {reasons}", evaluation.branch)
2487 })
2488 .collect();
2489 if !failure_summaries.is_empty() {
2490 message.push_str(" Branch failures: ");
2491 message.push_str(&failure_summaries.join("; "));
2492 message.push('.');
2493 }
2494 }
2495
2496 let mut extra = HashMap::new();
2497 extra.insert("compositionKind".to_string(), serde_json::json!(kind.name()).into());
2498 extra.insert("matchOutcome".to_string(), serde_json::json!(match_outcome).into());
2499 extra.insert("branchCount".to_string(), serde_json::json!(branch_count).into());
2500 extra.insert("matchCount".to_string(), serde_json::json!(match_count).into());
2501 if !valid_combinations.is_empty() {
2502 extra.insert("validPropertyCombinations".to_string(), serde_json::json!(valid_combinations).into());
2503 }
2504 if combinations_curtailed {
2505 extra.insert("validPropertyCombinationsCurtailed".to_string(), serde_json::json!(true).into());
2506 extra.insert(
2507 "validPropertyCombinationsLimit".to_string(),
2508 serde_json::json!(MAX_REQUIRED_PROPERTY_COMBINATIONS).into(),
2509 );
2510 }
2511 if !matching_branches.is_empty() {
2512 extra.insert("matchingBranches".to_string(), serde_json::json!(matching_branches).into());
2513 }
2514 let branch_failures: Vec<serde_json::Value> = evaluations
2515 .iter()
2516 .filter(|evaluation| !evaluation.matched)
2517 .map(|evaluation| {
2518 let reasons: Vec<serde_json::Value> = evaluation
2519 .failure_reasons
2520 .iter()
2521 .map(|reason| {
2522 serde_json::json!({
2523 "message": reason.message,
2524 "propertyPath": reason.property_path,
2525 })
2526 })
2527 .collect();
2528 serde_json::json!({
2529 "branch": evaluation.branch,
2530 "reasons": reasons,
2531 })
2532 })
2533 .collect();
2534 if !branch_failures.is_empty() {
2535 extra.insert("branchFailures".to_string(), serde_json::json!(branch_failures).into());
2536 }
2537
2538 let mut diagnostic =
2539 build_diagnostic_conditional(rule_id, &message, m, rid, property_path, None, condition_scenario);
2540 diagnostic.context = Some(ViolationContext {
2541 actual_value: actual_value.cloned().map(Into::into),
2542 expected_constraint: Some(kind.expected_constraint().to_string()),
2543 property: None,
2544 lifecycle: None,
2545 resolution_source: None,
2546 extra: Some(extra),
2547 });
2548 diagnostic
2549}
2550
2551fn render_branch_numbers(branches: &[usize]) -> String {
2552 branches.iter().map(usize::to_string).collect::<Vec<_>>().join(", ")
2553}
2554
2555fn render_composition_reasons(reasons: &[CompositionFailureReason], default_path: &str) -> String {
2556 reasons
2557 .iter()
2558 .map(|reason| {
2559 if reason.property_path.is_empty() || reason.property_path == default_path {
2560 reason.message.clone()
2561 } else {
2562 format!("at '{}': {}", reason.property_path, reason.message)
2563 }
2564 })
2565 .collect::<Vec<_>>()
2566 .join(", ")
2567}
2568
2569#[allow(clippy::too_many_arguments)]
2577fn validate_sub_under_assignment(
2578 out: &mut Vec<Diagnostic>,
2579 m: &Arc<SemanticModel>,
2580 rid: &str,
2581 rtype: &str,
2582 actual_keys: &[String],
2583 sub: &SubSchema,
2584 defs: &HashMap<String, PropSchema>,
2585 base_path: &str,
2586 assignment: &HashMap<String, bool>,
2587) {
2588 let Some(merged) = merged_scenario_filter(assignment) else {
2589 return;
2590 };
2591 let _scenario_filter_scope = ScenarioFilterScope::enter(merged.clone());
2592 let effective_keys: Vec<String> =
2593 actual_keys.iter().filter(|key| property_present_under(m, rid, base_path, key, &merged)).cloned().collect();
2594 validate_sub(out, m, rid, rtype, &effective_keys, sub, defs, base_path, 0);
2595}
2596
2597struct ScenarioFilterScope {
2598 previous: Option<HashMap<String, bool>>,
2599}
2600
2601impl ScenarioFilterScope {
2602 fn enter(assignment: HashMap<String, bool>) -> Self {
2603 let previous = SCENARIO_FILTER.with(|filter| filter.replace(Some(assignment)));
2604 Self { previous }
2605 }
2606}
2607
2608impl Drop for ScenarioFilterScope {
2609 fn drop(&mut self) {
2610 let previous = self.previous.take();
2611 SCENARIO_FILTER.with(|filter| {
2612 filter.replace(previous);
2613 });
2614 }
2615}
2616
2617fn merged_scenario_filter(assignment: &HashMap<String, bool>) -> Option<HashMap<String, bool>> {
2618 SCENARIO_FILTER.with(|filter| {
2619 let mut merged = filter.borrow().clone().unwrap_or_default();
2620 for (name, value) in assignment {
2621 if merged.get(name).is_some_and(|previous| previous != value) {
2622 return None;
2623 }
2624 merged.insert(name.clone(), *value);
2625 }
2626 Some(merged)
2627 })
2628}
2629
2630fn reset_scenario_filter() {
2631 SCENARIO_FILTER.with(|filter| {
2632 filter.replace(None);
2633 });
2634}
2635
2636thread_local! {
2637 static SCENARIO_FILTER: std::cell::RefCell<Option<HashMap<String, bool>>> = const { std::cell::RefCell::new(None) };
2644
2645 static SCHEMA_BUDGET_EXHAUSTIONS: std::cell::RefCell<BTreeSet<BudgetKind>> =
2649 const { std::cell::RefCell::new(BTreeSet::new()) };
2650}
2651
2652fn record_schema_budget_exhaustion(kind: BudgetKind) {
2653 SCHEMA_BUDGET_EXHAUSTIONS.with(|exhaustions| {
2654 exhaustions.borrow_mut().insert(kind);
2655 });
2656}
2657
2658fn reset_schema_budget_exhaustions() {
2659 SCHEMA_BUDGET_EXHAUSTIONS.with(|exhaustions| exhaustions.borrow_mut().clear());
2660}
2661
2662fn drain_schema_budget_exhaustions(model: &Arc<SemanticModel>) {
2663 SCHEMA_BUDGET_EXHAUSTIONS.with(|exhaustions| {
2664 for kind in std::mem::take(&mut *exhaustions.borrow_mut()) {
2665 model.record_budget_exhaustion(kind);
2666 }
2667 });
2668}
2669
2670fn scenario_consistent_with_filter(m: &Arc<SemanticModel>, conds: &HashMap<String, bool>) -> bool {
2673 SCENARIO_FILTER.with(|filter| {
2674 let borrowed = filter.borrow();
2675 let Some(assignment) = borrowed.as_ref() else {
2676 return true;
2677 };
2678 for (name, value) in assignment {
2679 if let Some(scenario_value) = conds.get(name)
2680 && scenario_value != value
2681 {
2682 return false;
2683 }
2684 }
2685 let mut merged = conds.clone();
2686 for (name, value) in assignment {
2687 merged.insert(name.clone(), *value);
2688 }
2689 is_satisfiable(m, &merged)
2690 })
2691}
2692
2693fn sub_self_constrains_value(sub: &PropSchema) -> bool {
2703 let PropSchema {
2704 ref_name: _,
2705 prop_type,
2706 enum_values,
2707 enum_case_insensitive,
2708 not_enum,
2709 const_value,
2710 pattern,
2711 minimum,
2712 maximum,
2713 exclusive_minimum,
2714 exclusive_maximum,
2715 multiple_of,
2716 min_length,
2717 max_length,
2718 min_items,
2719 max_items,
2720 unique_items,
2721 min_properties,
2722 max_properties,
2723 format,
2724 properties: _,
2725 required: _,
2726 required_present: _,
2727 additional_properties: _,
2728 pattern_properties: _,
2729 items,
2730 all_of,
2731 any_of,
2732 one_of,
2733 if_then_else,
2734 dependent_required: _,
2735 dependent_excluded: _,
2736 required_or: _,
2737 required_xor: _,
2738 } = sub;
2739 prop_type.is_some()
2740 || !enum_values.is_empty()
2741 || !enum_case_insensitive.is_empty()
2742 || !not_enum.is_empty()
2743 || const_value.is_some()
2744 || pattern.is_some()
2745 || minimum.is_some()
2746 || maximum.is_some()
2747 || exclusive_minimum.is_some()
2748 || exclusive_maximum.is_some()
2749 || multiple_of.is_some()
2750 || min_length.is_some()
2751 || max_length.is_some()
2752 || min_items.is_some()
2753 || max_items.is_some()
2754 || unique_items == &Some(true)
2755 || min_properties.is_some()
2756 || max_properties.is_some()
2757 || format.is_some()
2758 || items.is_some()
2759 || !all_of.is_empty()
2760 || !any_of.is_empty()
2761 || !one_of.is_empty()
2762 || !if_then_else.is_empty()
2763}
2764
2765fn schema_for_self_value_constraints(sub: &PropSchema) -> PropSchema {
2766 PropSchema {
2767 ref_name: None,
2768 properties: HashMap::new(),
2769 required: Vec::new(),
2770 required_present: false,
2771 additional_properties: None,
2772 pattern_properties: HashMap::new(),
2773 dependent_required: HashMap::new(),
2774 dependent_excluded: HashMap::new(),
2775 required_or: Vec::new(),
2776 required_xor: Vec::new(),
2777 ..sub.clone()
2778 }
2779}
2780
2781fn describe_prop_constraints(schema: &PropSchema) -> String {
2785 let mut parts: Vec<String> = Vec::new();
2786 if let Some(ref pt) = schema.prop_type {
2787 parts.push(format!("type '{}'", pt.names().collect::<Vec<_>>().join("|")));
2788 }
2789 if !schema.enum_values.is_empty() {
2790 parts.push(format!("one of {}", format_allowed_values(&schema.enum_values)));
2791 }
2792 if !schema.enum_case_insensitive.is_empty() {
2793 parts.push(format!("one of {} (case-insensitive)", format_allowed_values(&schema.enum_case_insensitive)));
2794 }
2795 if !schema.not_enum.is_empty() {
2796 parts.push(format!("none of {}", format_allowed_values(&schema.not_enum)));
2797 }
2798 if let Some(ref cv) = schema.const_value {
2799 parts.push(format!("exactly {cv}"));
2800 }
2801 if let Some(ref pattern) = schema.pattern {
2802 parts.push(format!("pattern '{pattern}'"));
2803 }
2804 if let Some(min) = schema.minimum {
2805 parts.push(format!("minimum {min}"));
2806 }
2807 if let Some(max) = schema.maximum {
2808 parts.push(format!("maximum {max}"));
2809 }
2810 if let Some(min) = schema.exclusive_minimum {
2811 parts.push(format!("exclusiveMinimum {min}"));
2812 }
2813 if let Some(max) = schema.exclusive_maximum {
2814 parts.push(format!("exclusiveMaximum {max}"));
2815 }
2816 if let Some(mult) = schema.multiple_of {
2817 parts.push(format!("multipleOf {mult}"));
2818 }
2819 if let Some(min) = schema.min_length {
2820 parts.push(format!("minLength {min}"));
2821 }
2822 if let Some(max) = schema.max_length {
2823 parts.push(format!("maxLength {max}"));
2824 }
2825 if let Some(min) = schema.min_items {
2826 parts.push(format!("minItems {min}"));
2827 }
2828 if let Some(max) = schema.max_items {
2829 parts.push(format!("maxItems {max}"));
2830 }
2831 if let Some(min) = schema.min_properties {
2832 parts.push(format!("minProperties {min}"));
2833 }
2834 if let Some(max) = schema.max_properties {
2835 parts.push(format!("maxProperties {max}"));
2836 }
2837 if !schema.required.is_empty() {
2838 parts.push(format!(
2839 "requires {}",
2840 schema.required.iter().map(|p| format!("'{p}'")).collect::<Vec<_>>().join(", ")
2841 ));
2842 }
2843 if schema.items.is_some() {
2844 parts.push("an item schema".to_string());
2845 }
2846 if !schema.properties.is_empty() {
2847 parts.push("nested property constraints".to_string());
2848 }
2849 if !schema.all_of.is_empty() || !schema.any_of.is_empty() || !schema.one_of.is_empty() {
2850 parts.push("nested composition".to_string());
2851 }
2852 if !schema.if_then_else.is_empty() {
2853 parts.push("a conditional constraint".to_string());
2854 }
2855 if parts.is_empty() { "the branch schema".to_string() } else { parts.join(", ") }
2856}
2857
2858fn validate_sub_dependencies(
2866 out: &mut Vec<Diagnostic>,
2867 m: &Arc<SemanticModel>,
2868 rid: &str,
2869 actual_keys: &[String],
2870 sub: &SubSchema,
2871 base_path: &str,
2872) {
2873 for (trigger, deps) in &sub.dependent_required {
2874 if actual_keys.contains(trigger) {
2875 for dep in deps {
2876 if !actual_keys.contains(dep) {
2877 out.push(build_diagnostic(
2878 "F3021",
2879 &format!("'{}' is a dependency of '{}'", dep, trigger),
2880 m,
2881 rid,
2882 base_path,
2883 None,
2884 ));
2885 }
2886 }
2887 }
2888 }
2889 for (trigger, excluded) in &sub.dependent_excluded {
2890 if actual_keys.contains(trigger) {
2891 for dep in excluded {
2892 if actual_keys.contains(dep) {
2893 out.push(build_diagnostic(
2894 "F3020",
2895 &format!("'{}' should not be included with '{}'", dep, trigger),
2896 m,
2897 rid,
2898 &format!("{}.{}", base_path, dep),
2899 None,
2900 ));
2901 }
2902 }
2903 }
2904 }
2905}
2906
2907fn collect_outer_resolved_scenarios(
2908 value: &ResolvedValue,
2909 assumptions: &HashMap<String, bool>,
2910 scenarios: &mut Vec<(ResolvedValue, HashMap<String, bool>)>,
2911) {
2912 match value {
2913 ResolvedValue::Conditional { condition, if_true, if_false } => match assumptions.get(condition) {
2914 Some(true) => collect_outer_resolved_scenarios(if_true, assumptions, scenarios),
2915 Some(false) => collect_outer_resolved_scenarios(if_false, assumptions, scenarios),
2916 None => {
2917 let mut true_assumptions = assumptions.clone();
2918 true_assumptions.insert(condition.clone(), true);
2919 collect_outer_resolved_scenarios(if_true, &true_assumptions, scenarios);
2920 let mut false_assumptions = assumptions.clone();
2921 false_assumptions.insert(condition.clone(), false);
2922 collect_outer_resolved_scenarios(if_false, &false_assumptions, scenarios);
2923 }
2924 },
2925 ResolvedValue::Enum { variants } => {
2926 for variant in variants {
2927 collect_outer_resolved_scenarios(variant, assumptions, scenarios);
2928 }
2929 }
2930 _ => scenarios.push((value.clone(), assumptions.clone())),
2931 }
2932}
2933
2934fn outer_resolved_scenarios(
2935 model: &Arc<SemanticModel>,
2936 resource_id: &str,
2937 property_path: &str,
2938) -> Vec<(ResolvedValue, HashMap<String, bool>)> {
2939 let Some(value) =
2940 model.resolve_deep(resource_id, property_path).or_else(|| model.resolve(resource_id, property_path).cloned())
2941 else {
2942 return model.resolve_scenarios(resource_id, property_path);
2943 };
2944 let mut scenarios = Vec::new();
2945 collect_outer_resolved_scenarios(&value, &HashMap::new(), &mut scenarios);
2946 scenarios
2947}
2948
2949fn collect_outer_value_scenarios(
2950 value: &ResolvedValue,
2951 assumptions: &HashMap<String, bool>,
2952 scenarios: &mut Vec<(serde_json::Value, HashMap<String, bool>)>,
2953) {
2954 match value {
2955 ResolvedValue::Conditional { condition, if_true, if_false } => match assumptions.get(condition) {
2956 Some(true) => collect_outer_value_scenarios(if_true, assumptions, scenarios),
2957 Some(false) => collect_outer_value_scenarios(if_false, assumptions, scenarios),
2958 None => {
2959 let mut true_assumptions = assumptions.clone();
2960 true_assumptions.insert(condition.clone(), true);
2961 collect_outer_value_scenarios(if_true, &true_assumptions, scenarios);
2962 let mut false_assumptions = assumptions.clone();
2963 false_assumptions.insert(condition.clone(), false);
2964 collect_outer_value_scenarios(if_false, &false_assumptions, scenarios);
2965 }
2966 },
2967 ResolvedValue::Enum { variants } => {
2968 for variant in variants {
2969 collect_outer_value_scenarios(variant, assumptions, scenarios);
2970 }
2971 }
2972 ResolvedValue::Reference { .. } | ResolvedValue::Dynamic { .. } | ResolvedValue::TypedDynamic { .. } => {}
2973 ResolvedValue::Concrete { .. } | ResolvedValue::List { .. } | ResolvedValue::Map { .. } => {
2974 scenarios.push((resolved_value_to_json(value), assumptions.clone()));
2975 }
2976 }
2977}
2978
2979fn schema_requires_nested_value_scenarios(schema: &PropSchema) -> bool {
2980 let has_composite_value = |value: &serde_json::Value| value.is_array() || value.is_object();
2981 schema.min_items.is_some()
2982 || schema.max_items.is_some()
2983 || schema.unique_items == Some(true)
2984 || schema.min_properties.is_some()
2985 || schema.max_properties.is_some()
2986 || schema.enum_values.iter().any(has_composite_value)
2987 || schema.enum_case_insensitive.iter().any(has_composite_value)
2988 || schema.not_enum.iter().any(has_composite_value)
2989 || schema.const_value.as_ref().is_some_and(has_composite_value)
2990}
2991
2992fn validation_scenarios(
2993 model: &Arc<SemanticModel>,
2994 resource_id: &str,
2995 property_path: &str,
2996 schema: &PropSchema,
2997) -> Vec<(serde_json::Value, HashMap<String, bool>)> {
2998 if schema_requires_nested_value_scenarios(schema) {
2999 return model.resolve_scenarios_json(resource_id, property_path);
3000 }
3001 let Some(value) =
3002 model.resolve_deep(resource_id, property_path).or_else(|| model.resolve(resource_id, property_path).cloned())
3003 else {
3004 return model.resolve_scenarios_json(resource_id, property_path);
3005 };
3006 let mut scenarios = Vec::new();
3007 collect_outer_value_scenarios(&value, &HashMap::new(), &mut scenarios);
3008 scenarios
3009}
3010
3011fn validate_prop(
3012 out: &mut Vec<Diagnostic>,
3013 store: &CompiledSchemaStore,
3014 m: &Arc<SemanticModel>,
3015 rid: &str,
3016 rtype: &str,
3017 prop_path: &str,
3018 schema: &PropSchema,
3019 defs: &HashMap<String, PropSchema>,
3020 region: Option<&str>,
3021) {
3022 let scenarios = validation_scenarios(m, rid, prop_path, schema);
3023
3024 let is_type_exempt = TYPE_CHECK_EXEMPT_PATHS.iter().any(|(rt, pp)| *rt == rtype && *pp == prop_path);
3025
3026 if scenarios.is_empty() && !is_type_exempt {
3027 validate_reference_type(out, store, m, rid, prop_path, schema);
3028 }
3029
3030 let res_suffix = describe_resolution(m, rid, prop_path).map(|s| format!(" (from {})", s)).unwrap_or_default();
3031
3032 if let Some(ref pt) = schema.prop_type
3036 && !is_type_exempt
3037 {
3038 let is_packaging_path = PACKAGING_PROPERTY_PATHS.iter().any(|(rt, pp)| *rt == rtype && *pp == prop_path);
3039 let from_intrinsic = m.is_from_intrinsic(rid, prop_path)
3042 || prop_path
3043 .rsplit_once('.')
3044 .and_then(|(parent, seg)| seg.parse::<usize>().ok().map(|_| parent))
3045 .is_some_and(|parent| m.is_from_intrinsic(rid, parent));
3046 let mut emitted_type_errors: HashSet<(&str, &str)> = HashSet::new();
3053 for (val, conds) in &scenarios {
3054 if !is_satisfiable(m, conds) || val.is_null() {
3055 continue;
3056 }
3057 if is_unresolved_intrinsic(val) {
3059 continue;
3060 }
3061 if is_packaging_path && val.is_string() {
3063 continue;
3064 }
3065 if from_intrinsic {
3067 continue;
3068 }
3069 if !type_matches(val, pt) {
3070 let expected = pt.primary().unwrap_or("unknown");
3071 match coerce_value(val, expected) {
3072 CoerceResult::Coerced(_, ref description) => {
3073 if emitted_type_errors.insert(("W9003", expected)) {
3074 out.push(build_diagnostic_conditional(
3075 "W9003",
3076 &format!(
3077 "{}{} is not of type '{}' - automatically coerced ({})",
3078 format_value(val),
3079 res_suffix,
3080 expected,
3081 description
3082 ),
3083 m,
3084 rid,
3085 prop_path,
3086 None,
3087 condition_map(conds),
3088 ));
3089 }
3090 }
3091 _ => {
3092 if emitted_type_errors.insert(("F3012", expected)) {
3093 out.push(build_diagnostic_conditional(
3094 "F3012",
3095 &format!("{}{} is not of type '{}'", format_value(val), res_suffix, expected),
3096 m,
3097 rid,
3098 prop_path,
3099 None,
3100 condition_map(conds),
3101 ));
3102 }
3103 }
3104 }
3105 }
3106 }
3107 }
3108
3109 if !schema.enum_values.is_empty() {
3110 let prop_name = prop_path.strip_prefix("Properties.").unwrap_or(prop_path);
3111 let regional = store.region_enums().allowed_values(rtype, prop_name, region);
3115 for (val, conds) in &scenarios {
3116 if !is_satisfiable(m, conds) || val.is_null() {
3117 continue;
3118 }
3119 let matches = if let Some(regional_vals) = ®ional {
3120 val.as_str().map(|s| regional_vals.contains(&s)).unwrap_or(false)
3121 || enum_matches(val, &schema.enum_values)
3122 } else {
3123 enum_matches(val, &schema.enum_values)
3124 };
3125 if !matches {
3126 let enum_desc = if regional.is_some() {
3132 format!("allowed values for region '{}'", region_enums::region_label(region))
3133 } else {
3134 format_allowed_values(&schema.enum_values)
3135 };
3136 out.push(build_diagnostic_conditional(
3137 "W3030",
3138 &format!("{}{} is not one of {}", format_value(val), res_suffix, enum_desc),
3139 m,
3140 rid,
3141 prop_path,
3142 None,
3143 condition_map(conds),
3144 ));
3145 }
3146 }
3147 }
3148
3149 if !schema.enum_case_insensitive.is_empty() {
3150 for (val, conds) in &scenarios {
3151 if !is_satisfiable(m, conds) || val.is_null() {
3152 continue;
3153 }
3154 if !enum_matches_case_insensitive(val, &schema.enum_case_insensitive) {
3155 out.push(build_diagnostic_conditional(
3159 "W3030",
3160 &format!(
3161 "{}{} is not one of {} (case-insensitive)",
3162 format_value(val),
3163 res_suffix,
3164 format_allowed_values(&schema.enum_case_insensitive)
3165 ),
3166 m,
3167 rid,
3168 prop_path,
3169 None,
3170 condition_map(conds),
3171 ));
3172 }
3173 }
3174 }
3175
3176 if !schema.not_enum.is_empty() {
3177 for (val, conds) in &scenarios {
3178 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
3179 continue;
3180 }
3181 if enum_matches(val, &schema.not_enum) {
3182 out.push(build_diagnostic_conditional(
3183 "F3030",
3184 &format!("{} must not be one of {}", format_value(val), format_allowed_values(&schema.not_enum)),
3185 m,
3186 rid,
3187 prop_path,
3188 None,
3189 condition_map(conds),
3190 ));
3191 }
3192 }
3193 }
3194
3195 if let Some(ref cv) = schema.const_value {
3196 for (val, conds) in &scenarios {
3197 if !is_satisfiable(m, conds) || val.is_null() {
3198 continue;
3199 }
3200 if !scalar_eq(val, cv) {
3201 out.push(build_diagnostic_conditional(
3202 "F3030",
3203 &format!("{} was expected", cv),
3204 m,
3205 rid,
3206 prop_path,
3207 None,
3208 condition_map(conds),
3209 ));
3210 }
3211 }
3212 }
3213
3214 if let Some(ref pat) = schema.pattern
3215 && let Some(re) = compile_pattern(pat)
3216 {
3217 let from_param = m.is_from_parameter(rid, prop_path) || m.is_from_intrinsic(rid, prop_path);
3224 for (val, conds) in &scenarios {
3225 if !is_satisfiable(m, conds) || val.is_null() {
3226 continue;
3227 }
3228 if let Some(s) = coerce_to_string(val) {
3229 if s.contains("{{") && s.contains("resolve") {
3230 continue;
3231 }
3232 if from_param {
3233 continue;
3234 }
3235 if !re.is_match(&s) {
3236 out.push(build_diagnostic_conditional(
3237 "F3031",
3238 &format!("{} does not match pattern '{}'", format_value(val), pat),
3239 m,
3240 rid,
3241 prop_path,
3242 None,
3243 condition_map(conds),
3244 ));
3245 }
3246 }
3247 }
3248 }
3249
3250 if let Some(ref fmt) = schema.format {
3251 validate_format(out, m, rid, prop_path, fmt, &scenarios);
3252 }
3253
3254 for (val, conds) in &scenarios {
3255 if !is_satisfiable(m, conds) || val.is_null() {
3256 continue;
3257 }
3258 let Some(n) = coerce_to_number(val) else {
3259 continue;
3260 };
3261 if let Some(max) = schema.maximum
3262 && n > max
3263 {
3264 out.push(build_diagnostic_conditional(
3265 "F3034",
3266 &format!("{} is greater than the maximum of {}", n, max),
3267 m,
3268 rid,
3269 prop_path,
3270 None,
3271 condition_map(conds),
3272 ));
3273 }
3274 if let Some(min) = schema.minimum
3275 && n < min
3276 {
3277 out.push(build_diagnostic_conditional(
3278 "F3034",
3279 &format!("{} is less than the minimum of {}", n, min),
3280 m,
3281 rid,
3282 prop_path,
3283 None,
3284 condition_map(conds),
3285 ));
3286 }
3287 if let Some(emax) = schema.exclusive_maximum
3288 && n >= emax
3289 {
3290 out.push(build_diagnostic_conditional(
3291 "F3034",
3292 &format!("{} is >= exclusive maximum {}", n, emax),
3293 m,
3294 rid,
3295 prop_path,
3296 None,
3297 condition_map(conds),
3298 ));
3299 }
3300 if let Some(emin) = schema.exclusive_minimum
3301 && n <= emin
3302 {
3303 out.push(build_diagnostic_conditional(
3304 "F3034",
3305 &format!("{} is <= exclusive minimum {}", n, emin),
3306 m,
3307 rid,
3308 prop_path,
3309 None,
3310 condition_map(conds),
3311 ));
3312 }
3313 if let Some(mult) = schema.multiple_of
3314 && mult > 0.0
3315 {
3316 let remainder = (n / mult).round() * mult - n;
3320 let epsilon = mult * 1e-9;
3321 if remainder.abs() > epsilon && (mult - remainder.abs()).abs() > epsilon {
3322 out.push(build_diagnostic_conditional(
3323 "F3034",
3324 &format!("{} is not a multiple of {}", n, mult),
3325 m,
3326 rid,
3327 prop_path,
3328 None,
3329 condition_map(conds),
3330 ));
3331 }
3332 }
3333 }
3334
3335 if schema.min_length.is_some() || schema.max_length.is_some() {
3336 let from_param = m.is_from_parameter(rid, prop_path) || m.is_from_intrinsic(rid, prop_path);
3337 for (val, conds) in &scenarios {
3338 if !is_satisfiable(m, conds) || val.is_null() {
3339 continue;
3340 }
3341 let Some(s) = coerce_to_string(val) else {
3342 continue;
3343 };
3344 if from_param {
3345 continue;
3346 }
3347 let len = s.chars().count() as u64;
3348 if let Some(max) = schema.max_length
3349 && len > max
3350 {
3351 out.push(build_diagnostic_conditional(
3352 "F3033",
3353 &format!("length {} exceeds maximum {}", len, max),
3354 m,
3355 rid,
3356 prop_path,
3357 None,
3358 condition_map(conds),
3359 ));
3360 }
3361 if let Some(min) = schema.min_length
3362 && len < min
3363 {
3364 out.push(build_diagnostic_conditional(
3365 "F3033",
3366 &format!("length {} is below minimum {}", len, min),
3367 m,
3368 rid,
3369 prop_path,
3370 None,
3371 condition_map(conds),
3372 ));
3373 }
3374 }
3375 }
3376
3377 for (val, conds) in &scenarios {
3378 if !is_satisfiable(m, conds) || val.is_null() {
3379 continue;
3380 }
3381 if let Some(arr) = val.as_array() {
3382 let len = arr.iter().filter(|item| !item.is_null()).count() as u64;
3383 if let Some(max) = schema.max_items
3384 && len > max
3385 {
3386 out.push(build_diagnostic_conditional(
3387 "F3032",
3388 &format!("expected maximum item count: {}, found: {}", max, len),
3389 m,
3390 rid,
3391 prop_path,
3392 None,
3393 condition_map(conds),
3394 ));
3395 }
3396 if let Some(min) = schema.min_items
3397 && len < min
3398 {
3399 out.push(build_diagnostic_conditional(
3400 "F3032",
3401 &format!("expected minimum item count: {}, found: {}", min, len),
3402 m,
3403 rid,
3404 prop_path,
3405 None,
3406 condition_map(conds),
3407 ));
3408 }
3409 }
3410 }
3411
3412 if schema.unique_items == Some(true) {
3413 for (val, conds) in &scenarios {
3414 if !is_satisfiable(m, conds) || val.is_null() {
3415 continue;
3416 }
3417 if let Some(arr) = val.as_array() {
3418 let mut seen = Vec::new();
3419 for item in arr {
3420 if item.is_null() {
3425 continue;
3426 }
3427 if seen.contains(item) {
3428 out.push(build_diagnostic_conditional(
3429 "F3037",
3430 "Array items are not unique",
3431 m,
3432 rid,
3433 prop_path,
3434 None,
3435 condition_map(conds),
3436 ));
3437 break;
3438 }
3439 seen.push(item.clone());
3440 }
3441 }
3442 }
3443 }
3444
3445 let has_nested_constraints = !schema.properties.is_empty()
3446 || !schema.required.is_empty()
3447 || !schema.dependent_required.is_empty()
3448 || !schema.dependent_excluded.is_empty()
3449 || !schema.required_or.is_empty()
3450 || !schema.required_xor.is_empty()
3451 || !schema.all_of.is_empty()
3452 || !schema.any_of.is_empty()
3453 || !schema.one_of.is_empty()
3454 || !schema.if_then_else.is_empty()
3455 || schema.additional_properties.is_some();
3456 if has_nested_constraints {
3457 let nested_keys = collect_keys_deep(m, rid, prop_path);
3458 if !nested_keys.is_empty() {
3459 validate_object_keys(
3460 out,
3461 m,
3462 rid,
3463 rtype,
3464 &schema.properties,
3465 defs,
3466 &schema.required,
3467 schema.additional_properties,
3468 &schema.pattern_properties,
3469 &schema.dependent_required,
3470 &schema.dependent_excluded,
3471 &schema.required_or,
3472 &schema.required_xor,
3473 &schema.all_of,
3474 &schema.any_of,
3475 &schema.one_of,
3476 &nested_keys,
3477 prop_path,
3478 );
3479 for ite in &schema.if_then_else {
3484 let matches = condition_matches_at(&ite.condition, &nested_keys, m, rid, defs, prop_path);
3485 let sub = if matches { &ite.then_schema } else { &ite.else_schema };
3486 if let Some(sub) = sub {
3487 if ite.enforce_full_branch {
3488 validate_sub(out, m, rid, rtype, &nested_keys, sub, defs, prop_path, 0);
3489 } else {
3490 validate_sub_dependencies(out, m, rid, &nested_keys, sub, prop_path);
3491 }
3492 }
3493 }
3494 } else if !matches!(m.resolve_deep(rid, prop_path), Some(ResolvedValue::Conditional { .. })) {
3495 for (val, _conds) in m.resolve_scenarios(rid, prop_path) {
3504 if let ResolvedValue::Concrete { value } = &val
3505 && let Some(obj) = value.as_object()
3506 {
3507 let keys: Vec<String> = obj.keys().cloned().collect();
3508 validate_object_keys(
3509 out,
3510 m,
3511 rid,
3512 rtype,
3513 &schema.properties,
3514 defs,
3515 &schema.required,
3516 schema.additional_properties,
3517 &schema.pattern_properties,
3518 &schema.dependent_required,
3519 &schema.dependent_excluded,
3520 &schema.required_or,
3521 &schema.required_xor,
3522 &schema.all_of,
3523 &schema.any_of,
3524 &schema.one_of,
3525 &keys,
3526 prop_path,
3527 );
3528 }
3529 }
3530 }
3531
3532 let has_scalar_composition = !schema.all_of.is_empty()
3540 || !schema.any_of.is_empty()
3541 || !schema.one_of.is_empty()
3542 || !schema.if_then_else.is_empty();
3543 if has_scalar_composition && nested_keys.is_empty() {
3544 let has_only_scalars = scenarios.iter().all(|(val, conds)| {
3545 !is_satisfiable(m, conds)
3546 || val.is_null()
3547 || is_unresolved_intrinsic(val)
3548 || (!val.is_object() && !val.is_array())
3549 });
3550 if has_only_scalars {
3551 validate_prop_composition(out, m, rid, prop_path, schema, defs, &scenarios);
3552 }
3553 }
3554 for (pn, ps) in &schema.properties {
3555 let resolved = ps.resolve(defs);
3556 let sub_path = format!("{}.{}", prop_path, pn);
3557 let sub_scenarios = m.resolve_scenarios_json(rid, &sub_path);
3558 if !sub_scenarios.is_empty() || m.resolve_deep(rid, &sub_path).is_some() {
3559 validate_prop(out, store, m, rid, rtype, &sub_path, &resolved, defs, region);
3560 }
3561 }
3562 }
3563
3564 if let Some(ref item_schema) = schema.items {
3565 let resolved = item_schema.resolve(defs);
3566 let mut did_per_index = false;
3568 {
3569 let arr_len = match m.resolve_deep(rid, prop_path).or_else(|| m.resolve(rid, prop_path).cloned()) {
3570 Some(ResolvedValue::List { items }) => Some(items.len()),
3571 Some(ResolvedValue::Concrete { value: ref v }) if v.is_array() => Some(v.as_array().unwrap().len()),
3572 _ => None,
3573 };
3574 if let Some(len) = arr_len {
3575 did_per_index = true;
3576 for idx in 0..len {
3577 let idx_path = format!("{}.{}", prop_path, idx);
3578 validate_prop(out, store, m, rid, rtype, &idx_path, &resolved, defs, region);
3579 }
3580 } else {
3581 validate_prop(out, store, m, rid, rtype, &format!("{}.{{}}", prop_path), &resolved, defs, region);
3582 }
3583 }
3584 if !did_per_index && (!resolved.dependent_excluded.is_empty() || !resolved.dependent_required.is_empty()) {
3585 validate_array_item_constraints(out, m, rid, prop_path, &resolved);
3586 }
3587 }
3588}
3589
3590fn validate_array_item_constraints(
3591 out: &mut Vec<Diagnostic>,
3592 m: &Arc<SemanticModel>,
3593 rid: &str,
3594 array_path: &str,
3595 item_schema: &PropSchema,
3596) {
3597 let arr = match m.resolve_deep(rid, array_path).or_else(|| m.resolve(rid, array_path).cloned()) {
3598 Some(ResolvedValue::List { items }) => items,
3599 Some(ResolvedValue::Concrete { value: v }) => match v.into_inner() {
3600 serde_json::Value::Array(items) => {
3601 items.into_iter().map(|i| ResolvedValue::Concrete { value: i.into() }).collect()
3602 }
3603 _ => return,
3604 },
3605 _ => return,
3606 };
3607 for (idx, item) in arr.iter().enumerate() {
3608 let keys: Vec<String> = match item {
3609 ResolvedValue::Map { entries } => entries.iter().map(|e| e.key.clone()).collect(),
3610 ResolvedValue::Concrete { value: v } if v.is_object() => v.as_object().unwrap().keys().cloned().collect(),
3611 _ => continue,
3612 };
3613 let item_path = format!("{}.{}", array_path, idx);
3614 for (trigger, excluded) in &item_schema.dependent_excluded {
3615 if keys.iter().any(|k| k == trigger) {
3616 for dep in excluded {
3617 if keys.iter().any(|k| k == dep) {
3618 out.push(build_diagnostic(
3619 "F3020",
3620 &format!("'{}' should not be included with '{}'", dep, trigger),
3621 m,
3622 rid,
3623 &format!("{}.{}", item_path, dep),
3624 None,
3625 ));
3626 }
3627 }
3628 }
3629 }
3630 for (trigger, deps) in &item_schema.dependent_required {
3631 if keys.iter().any(|k| k == trigger) {
3632 for dep in deps {
3633 if !keys.iter().any(|k| k == dep) {
3634 out.push(build_diagnostic(
3635 "F3021",
3636 &format!("'{}' is a dependency of '{}'", dep, trigger),
3637 m,
3638 rid,
3639 &item_path,
3640 Some(&format!("Add '{}' when '{}' is specified", dep, trigger)),
3641 ));
3642 }
3643 }
3644 }
3645 }
3646 }
3647}
3648
3649fn collect_keys_deep(m: &Arc<SemanticModel>, rid: &str, path: &str) -> Vec<String> {
3650 let mut keys = HashSet::new();
3651 match m.resolve_deep(rid, path).or_else(|| m.resolve(rid, path).cloned()) {
3652 Some(ResolvedValue::Map { entries }) => {
3653 if entries.len() == 1 && is_intrinsic_key(&entries[0].key) {
3654 return Vec::new();
3655 }
3656 for e in &entries {
3657 keys.insert(e.key.clone());
3658 }
3659 }
3660 Some(ResolvedValue::Concrete { value: ref v }) if is_unresolved_intrinsic(v) => return Vec::new(),
3661 Some(ResolvedValue::Concrete { value: ref v }) if v.is_object() => {
3662 for k in v.as_object().unwrap().keys() {
3663 keys.insert(k.clone());
3664 }
3665 }
3666 _ => {}
3667 }
3668 if keys.is_empty() {
3669 for (val, conds) in &m.resolve_scenarios_json(rid, path) {
3670 if !is_satisfiable(m, conds) || val.is_null() || is_unresolved_intrinsic(val) {
3671 continue;
3672 }
3673 if let Some(obj) = val.as_object() {
3674 for k in obj.keys() {
3675 keys.insert(k.clone());
3676 }
3677 }
3678 }
3679 }
3680 keys.into_iter().collect()
3681}
3682
3683fn type_matches(val: &serde_json::Value, pt: &PropType) -> bool {
3684 match pt {
3685 PropType::Single(s) => single_type(val, s),
3686 PropType::Multi(types) => types.iter().any(|t| single_type(val, t)),
3687 }
3688}
3689
3690fn single_type(val: &serde_json::Value, expected: &str) -> bool {
3691 match expected {
3692 "string" => val.is_string(),
3693 "integer" => {
3694 val.is_i64() || val.is_u64() || (val.is_f64() && val.as_f64().map(|f| f.fract() == 0.0).unwrap_or(false))
3695 }
3696 "number" | "double" | "float" => val.is_number(),
3697 "boolean" => val.is_boolean(),
3698 "array" => val.is_array(),
3699 "object" => val.is_object(),
3700 "null" => val.is_null(),
3701 _ => true,
3702 }
3703}
3704
3705fn enum_matches(val: &serde_json::Value, allowed: &[serde_json::Value]) -> bool {
3706 allowed.iter().any(|a| scalar_eq(a, val))
3707}
3708
3709fn enum_matches_case_insensitive(val: &serde_json::Value, allowed: &[serde_json::Value]) -> bool {
3712 allowed.iter().any(|a| match (a.as_str(), val.as_str()) {
3713 (Some(allowed_str), Some(val_str)) => allowed_str.eq_ignore_ascii_case(val_str),
3714 _ => scalar_eq(a, val),
3715 })
3716}
3717
3718fn check_required_not_null(out: &mut Vec<Diagnostic>, m: &Arc<SemanticModel>, rid: &str, base: &str, req: &str) {
3719 for (value, conds) in &outer_resolved_scenarios(m, rid, &format!("{}.{}", base, req)) {
3720 if !is_satisfiable(m, conds) || !scenario_consistent_with_filter(m, conds) {
3721 continue;
3722 }
3723 if matches!(value, ResolvedValue::Concrete { value } if value.is_null()) {
3724 out.push(build_diagnostic_conditional(
3725 "F3003",
3726 &format!("'{}' is a required property", req),
3727 m,
3728 rid,
3729 base,
3730 Some(&format!("Add the required property '{}'", req)),
3731 condition_map(conds),
3732 ));
3733 }
3734 }
3735}
3736
3737fn is_satisfiable(m: &Arc<SemanticModel>, conds: &HashMap<String, bool>) -> bool {
3738 if conds.is_empty() {
3739 return true;
3740 }
3741 m.conditions.is_satisfiable(&conds.iter().map(|(k, v)| (k.clone(), *v)).collect::<Vec<_>>())
3742}
3743
3744fn condition_map(conds: &HashMap<String, bool>) -> Option<HashMap<String, bool>> {
3745 if conds.is_empty() { None } else { Some(conds.clone()) }
3746}
3747
3748fn find_similar<'a>(key: &str, known: &HashSet<&'a str>) -> Option<&'a str> {
3749 let kl = key.to_lowercase();
3750 known
3751 .iter()
3752 .find(|k| {
3753 let l = k.to_lowercase();
3754 let max = kl.len().max(l.len());
3755 if max == 0 {
3756 return true;
3757 }
3758 let d = levenshtein_distance(&kl, &l);
3759 1.0 - (d as f64 / max as f64) > 0.8
3760 })
3761 .copied()
3762}
3763
3764fn levenshtein_distance(a: &str, b: &str) -> usize {
3765 let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
3766 let mut dp = vec![vec![0usize; b.len() + 1]; a.len() + 1];
3767 for (i, row) in dp.iter_mut().enumerate() {
3768 row[0] = i;
3769 }
3770 for (j, cell) in dp[0].iter_mut().enumerate() {
3771 *cell = j;
3772 }
3773 for i in 1..=a.len() {
3774 for j in 1..=b.len() {
3775 let c = if a[i - 1] == b[j - 1] { 0 } else { 1 };
3776 dp[i][j] = (dp[i - 1][j] + 1).min(dp[i][j - 1] + 1).min(dp[i - 1][j - 1] + c);
3777 }
3778 }
3779 dp[a.len()][b.len()]
3780}
3781
3782fn format_value(val: &serde_json::Value) -> String {
3783 render_value(val)
3784}
3785
3786fn format_allowed_values(values: &[serde_json::Value]) -> String {
3787 render_value_list(values)
3788}
3789
3790fn condition_matches(
3791 cond: &ConditionSchema,
3792 actual_keys: &[String],
3793 m: &Arc<SemanticModel>,
3794 rid: &str,
3795 defs: &HashMap<String, PropSchema>,
3796) -> bool {
3797 condition_matches_at(cond, actual_keys, m, rid, defs, KEY_PROPERTIES)
3798}
3799
3800fn condition_matches_at(
3805 cond: &ConditionSchema,
3806 actual_keys: &[String],
3807 m: &Arc<SemanticModel>,
3808 rid: &str,
3809 defs: &HashMap<String, PropSchema>,
3810 base_path: &str,
3811) -> bool {
3812 if !cond.any_of.is_empty() {
3813 return cond.any_of.iter().any(|sub| condition_matches_at(sub, actual_keys, m, rid, defs, base_path));
3814 }
3815 if let Some(ref required_type) = cond.prop_type
3816 && !required_type.names().any(|name| name == "object")
3817 {
3818 return false;
3819 }
3820 for required_property in &cond.required {
3821 if !actual_keys.iter().any(|key| key == required_property)
3822 || !property_present_under(m, rid, base_path, required_property, &HashMap::new())
3823 {
3824 return false;
3825 }
3826 }
3827 for (property_name, property_schema) in &cond.properties {
3828 let resolved_schema = property_schema.resolve(defs);
3829 let property_path = format!("{}.{}", base_path, property_name);
3830 let scenarios = m.resolve_scenarios_json(rid, &property_path);
3831 let has_concrete_constraint = resolved_schema.pattern.is_some()
3832 || !resolved_schema.enum_values.is_empty()
3833 || !resolved_schema.not_enum.is_empty()
3834 || resolved_schema.const_value.is_some();
3835 if scenarios.is_empty() {
3836 if has_concrete_constraint {
3837 return false;
3838 }
3839 continue;
3840 }
3841 let reachable_scenarios: Vec<_> = scenarios
3842 .iter()
3843 .filter(|(_, conditions)| is_satisfiable(m, conditions) && scenario_consistent_with_filter(m, conditions))
3844 .collect();
3845 if reachable_scenarios.is_empty() || reachable_scenarios.iter().all(|(value, _)| value.is_null()) {
3846 continue;
3847 }
3848 for nested_required_property in &resolved_schema.required {
3849 let nested_path = format!("{}.{}", property_path, nested_required_property);
3850 let nested_property_exists =
3851 m.resolve_scenarios_json(rid, &nested_path).iter().any(|(value, conditions)| {
3852 is_satisfiable(m, conditions) && scenario_consistent_with_filter(m, conditions) && !value.is_null()
3853 });
3854 if !nested_property_exists {
3855 return false;
3856 }
3857 }
3858 let compiled_pattern = resolved_schema.pattern.as_ref().and_then(|pattern| compile_pattern(pattern));
3859 if resolved_schema.pattern.is_some() && compiled_pattern.is_none() {
3860 return false;
3861 }
3862 let any_match = reachable_scenarios.iter().any(|(value, _)| {
3863 if value.is_null() {
3864 return true;
3865 }
3866 if !resolved_schema.enum_values.is_empty() && !enum_matches(value, &resolved_schema.enum_values) {
3867 return false;
3868 }
3869 if !resolved_schema.not_enum.is_empty() && enum_matches(value, &resolved_schema.not_enum) {
3870 return false;
3871 }
3872 if let Some(ref expected) = resolved_schema.const_value
3873 && !scalar_eq(value, expected)
3874 {
3875 return false;
3876 }
3877 if let Some(ref pattern) = compiled_pattern
3878 && !coerce_to_string(value).map(|text| pattern.is_match(&text)).unwrap_or(false)
3879 {
3880 return false;
3881 }
3882 if let Some(ref expected_type) = resolved_schema.prop_type
3883 && !type_matches(value, expected_type)
3884 {
3885 return false;
3886 }
3887 condition_bounds_match(value, &resolved_schema)
3888 });
3889 if !any_match {
3890 return false;
3891 }
3892 }
3893 true
3894}
3895
3896fn condition_bounds_match(val: &serde_json::Value, schema: &PropSchema) -> bool {
3902 if let Some(items) = val.as_array() {
3903 let len = items.iter().filter(|item| !item.is_null()).count() as u64;
3904 if schema.min_items.is_some_and(|min| len < min) || schema.max_items.is_some_and(|max| len > max) {
3905 return false;
3906 }
3907 }
3908 if let Some(text) = val.as_str() {
3909 let len = text.chars().count() as u64;
3910 if schema.min_length.is_some_and(|min| len < min) || schema.max_length.is_some_and(|max| len > max) {
3911 return false;
3912 }
3913 }
3914 if let Some(members) = val.as_object() {
3915 let len = members.len() as u64;
3916 if schema.min_properties.is_some_and(|min| len < min) || schema.max_properties.is_some_and(|max| len > max) {
3917 return false;
3918 }
3919 }
3920 true
3921}
3922
3923fn validate_reference_type(
3926 out: &mut Vec<Diagnostic>,
3927 store: &CompiledSchemaStore,
3928 m: &Arc<SemanticModel>,
3929 rid: &str,
3930 prop_path: &str,
3931 schema: &PropSchema,
3932) {
3933 let Some(ref expected_type) = schema.prop_type else {
3934 return;
3935 };
3936 let raw = m.resolve(rid, prop_path).cloned().or_else(|| m.resolve_deep(rid, prop_path));
3937 let Some(raw) = raw else { return };
3938
3939 match &raw {
3940 ResolvedValue::Reference { target, kind } => {
3941 let target_type = m.resources.get(target.as_str()).map(|r| r.resource_type.as_str());
3942 let Some(target_rtype) = target_type else {
3943 return;
3944 };
3945
3946 let source_type = match kind {
3947 RefKind::Ref => store.ref_types().ref_type_for(target_rtype),
3948 RefKind::GetAtt { attr } => store.ref_types().getatt_type_for(target_rtype, attr),
3949 _ => None,
3950 };
3951 let Some(source) = source_type else { return };
3952
3953 if !types_compatible(source, expected_type) {
3954 let ref_desc = match kind {
3955 RefKind::Ref => format!("Ref to '{}'", target),
3956 RefKind::GetAtt { attr: a } => {
3957 format!("GetAtt {}.{}", target, a)
3958 }
3959 _ => return,
3960 };
3961 out.push(build_diagnostic(
3962 "F3012",
3963 &format!(
3964 "{} ({}) returns '{}', but property expects '{}'",
3965 ref_desc,
3966 target_rtype,
3967 source,
3968 expected_type.primary().unwrap_or("unknown")
3969 ),
3970 m,
3971 rid,
3972 prop_path,
3973 None,
3974 ));
3975 }
3976 }
3977 ResolvedValue::TypedDynamic { reason: _name, param_type } => {
3978 let source = cfn_param_type_to_schema_type(param_type);
3979 if !types_compatible(source, expected_type) {
3980 let expected = expected_type.primary().unwrap_or("unknown");
3981 out.push(build_diagnostic(
3983 "W9003",
3984 &format!("Parameter type '{}' may not be compatible with expected type '{}'", param_type, expected),
3985 m,
3986 rid,
3987 prop_path,
3988 None,
3989 ));
3990 }
3991 }
3992 _ => {}
3993 }
3994}
3995
3996fn cfn_param_type_to_schema_type(param_type: &str) -> &str {
3997 match param_type {
3998 PARAM_TYPE_NUMBER => "number",
3999 PARAM_TYPE_STRING => "string",
4000 PARAM_TYPE_COMMA_DELIMITED_LIST => "array",
4001 t if t.starts_with("List<") => "array",
4002 t if t.starts_with("AWS::SSM::Parameter::") => "string",
4003 _ => "string",
4004 }
4005}
4006
4007fn types_compatible(source: &str, expected: &PropType) -> bool {
4008 match expected {
4009 PropType::Single(e) => single_type_compatible(source, e),
4010 PropType::Multi(types) => types.iter().any(|e| single_type_compatible(source, e)),
4011 }
4012}
4013
4014fn single_type_compatible(source: &str, expected: &str) -> bool {
4015 match (source, expected) {
4016 (s, e) if s == e => true,
4017 ("integer", "number") | ("number", "integer") => true,
4018 ("string", "number") | ("string", "integer") => true, ("number", "string") | ("integer", "string") | ("boolean", "string") => true, (_, "null") => true,
4021 _ => false,
4022 }
4023}
4024
4025const EC2_IMAGE_ID_FORMAT: &str = "AWS::EC2::Image.Id";
4026const EC2_LAUNCH_TEMPLATE_RESOURCE_TYPE: &str = "AWS::EC2::LaunchTemplate";
4027const EC2_LAUNCH_TEMPLATE_IMAGE_ID_PATH: &str = "Properties.LaunchTemplateData.ImageId";
4028const EC2_SSM_IMAGE_ALIAS_PREFIX: &str = "resolve:ssm:";
4029
4030static FORMAT_PATTERNS: LazyLock<HashMap<&'static str, Arc<CompiledPattern>>> = LazyLock::new(|| {
4031 let sources: [(&str, &str); 13] = [
4032 ("AWS::EC2::VPC.Id", r"^vpc-[a-f0-9]{8,17}$"),
4033 ("AWS::EC2::Subnet.Id", r"^subnet-[a-f0-9]{8,17}$"),
4034 ("AWS::EC2::SecurityGroup.Id", r"^sg-[a-f0-9]{8,17}$"),
4035 (EC2_IMAGE_ID_FORMAT, r"^ami-([0-9a-z]{8}|[0-9a-z]{17})$"),
4036 ("AWS::IAM::Role.Arn", IAM_ROLE_ARN_PATTERN),
4037 ("AWS::Logs::LogGroup.Name", r"^[\.\-_/#A-Za-z0-9]{1,512}$"),
4038 ("AWS::EC2::SecurityGroup.Name", SECURITY_GROUP_NAME_PATTERN),
4039 ("AWS::EC2::KeyPair.KeyName", r"^[\x20-\x7E]{1,255}$"),
4040 ("AWS::EC2::AvailabilityZone.Name", r"^[a-z]{2}(-gov|-iso[a-z]*)?-[a-z]+-\d[a-z]$"),
4041 ("AWS::Route53::HostedZone.Id", r"^Z[A-Z0-9]{1,32}$"),
4042 ("AWS::EC2::Volume.Id", r"^vol-[a-f0-9]{8,17}$"),
4043 ("AWS::EC2::NetworkInterface.Id", r"^eni-[a-f0-9]{8,17}$"),
4044 ("AWS::SSM::Parameter.Name", r"^[a-zA-Z0-9_./-]{1,2048}$"),
4045 ];
4046 sources.into_iter().filter_map(|(fmt, pat)| compile_pattern(pat).map(|re| (fmt, re))).collect()
4047});
4048
4049static BRANCH_FORMAT_PATTERNS: LazyLock<HashMap<&'static str, Arc<CompiledPattern>>> = LazyLock::new(|| {
4054 let sources: &[(&str, &str)] = &[
4055 (
4061 "AWS::KMS::Key.Arn",
4062 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})$",
4063 ),
4064 (
4067 "AWS::KMS::Key.Id",
4068 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})$",
4069 ),
4070 ("AWS::KMS::Alias.AliasName", r"^alias/[a-zA-Z0-9:/_-]{1,250}$"),
4072 ("AWS::EC2::SecurityGroup.Name", SECURITY_GROUP_NAME_PATTERN),
4074 ("ipv4-network", r"^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$"),
4076 ("ipv6-network", r"^[0-9a-fA-F:]+/\d{1,3}$"),
4078 ("date-time", r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$"),
4080 ("timestamp", r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$"),
4082 ];
4083 sources.iter().filter_map(|(fmt, pat)| compile_pattern(pat).map(|re| (*fmt, re))).collect()
4084});
4085
4086fn format_value_matches(value: &str, format: &str) -> bool {
4095 if let Some(re) = FORMAT_PATTERNS.get(format) {
4097 return re.is_match(value);
4098 }
4099 if let Some(re) = BRANCH_FORMAT_PATTERNS.get(format) {
4101 return re.is_match(value);
4102 }
4103 match format {
4105 "AWS::EC2::SecurityGroup.Ids" => {
4106 FORMAT_PATTERNS.get("AWS::EC2::SecurityGroup.Id").is_none_or(|re| re.is_match(value))
4107 }
4108 "AWS::EC2::SecurityGroup.Names" => {
4109 FORMAT_PATTERNS.get("AWS::EC2::SecurityGroup.Name").is_none_or(|re| re.is_match(value))
4110 }
4111 "json" => serde_json::from_str::<serde_json::Value>(value).is_ok(),
4113 _ => true,
4115 }
4116}
4117
4118fn sns_kms_identifier_is_runtime_validated(model: &SemanticModel, resource_id: &str, property_path: &str) -> bool {
4119 property_path == "Properties.KmsMasterKeyId"
4120 && model.resource(resource_id).is_some_and(|resource| resource.resource_type == "AWS::SNS::Topic")
4121}
4122
4123fn ec2_resolves_ssm_image_alias(
4124 model: &SemanticModel,
4125 resource_id: &str,
4126 property_path: &str,
4127 format: &str,
4128 value: &str,
4129) -> bool {
4130 format == EC2_IMAGE_ID_FORMAT
4131 && property_path == EC2_LAUNCH_TEMPLATE_IMAGE_ID_PATH
4132 && value.starts_with(EC2_SSM_IMAGE_ALIAS_PREFIX)
4133 && model
4134 .resource(resource_id)
4135 .is_some_and(|resource| resource.resource_type == EC2_LAUNCH_TEMPLATE_RESOURCE_TYPE)
4136}
4137
4138fn validate_prop_composition(
4145 out: &mut Vec<Diagnostic>,
4146 m: &Arc<SemanticModel>,
4147 rid: &str,
4148 prop_path: &str,
4149 schema: &PropSchema,
4150 defs: &HashMap<String, PropSchema>,
4151 scenarios: &[(serde_json::Value, HashMap<String, bool>)],
4152) {
4153 for branch in &schema.all_of {
4155 for (val, conds) in scenarios {
4156 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
4157 continue;
4158 }
4159 if !schema_value_matches(val, branch, defs, 0) {
4160 out.push(build_diagnostic_conditional(
4161 "F3017",
4162 "Value does not satisfy allOf constraint",
4163 m,
4164 rid,
4165 prop_path,
4166 None,
4167 condition_map(conds),
4168 ));
4169 break;
4170 }
4171 }
4172 }
4173
4174 if !schema.any_of.is_empty() && !sns_kms_identifier_is_runtime_validated(m, rid, prop_path) {
4176 for (val, conds) in scenarios {
4177 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
4178 continue;
4179 }
4180 let evaluations = evaluate_value_composition_branches(val, &schema.any_of, defs, prop_path);
4181 if evaluations.iter().all(|evaluation| !evaluation.matched) {
4182 out.push(build_composition_diagnostic(
4183 "F3017",
4184 CompositionKind::AnyOf,
4185 &evaluations,
4186 m,
4187 rid,
4188 prop_path,
4189 None,
4190 Some(val),
4191 condition_map(conds),
4192 ));
4193 }
4194 }
4195 }
4196
4197 if !schema.one_of.is_empty() {
4199 for (val, conds) in scenarios {
4200 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
4201 continue;
4202 }
4203 let evaluations = evaluate_value_composition_branches(val, &schema.one_of, defs, prop_path);
4204 let match_count = evaluations.iter().filter(|evaluation| evaluation.matched).count();
4205 if match_count != 1 {
4206 out.push(build_composition_diagnostic(
4207 "F3018",
4208 CompositionKind::OneOf,
4209 &evaluations,
4210 m,
4211 rid,
4212 prop_path,
4213 None,
4214 Some(val),
4215 condition_map(conds),
4216 ));
4217 }
4218 }
4219 }
4220
4221 for ite in schema.if_then_else.iter().filter(|ite| ite.enforce_full_branch) {
4225 for (val, conds) in scenarios {
4226 if !is_satisfiable(m, conds) || val.is_null() || defer_value_constraints(m, rid, prop_path, val, conds) {
4227 continue;
4228 }
4229 let cond_matches = if let Some(obj) = val.as_object() {
4233 let obj_keys: Vec<String> = obj
4234 .iter()
4235 .filter(|(_, candidate)| !candidate.is_null())
4236 .map(|(property, _)| property.clone())
4237 .collect();
4238 condition_schema_value_matches(&ite.condition, obj, &obj_keys, defs, 0)
4239 } else {
4240 true
4244 };
4245 let branch = if cond_matches { &ite.then_schema } else { &ite.else_schema };
4246 if let Some(branch_schema) = branch
4247 && !schema_value_matches(val, branch_schema, defs, 0)
4248 {
4249 out.push(build_diagnostic_conditional(
4250 "F3017",
4251 "Value does not satisfy conditional schema constraint",
4252 m,
4253 rid,
4254 prop_path,
4255 None,
4256 condition_map(conds),
4257 ));
4258 }
4259 }
4260 }
4261}
4262
4263fn validate_format(
4264 out: &mut Vec<Diagnostic>,
4265 m: &Arc<SemanticModel>,
4266 rid: &str,
4267 prop_path: &str,
4268 format: &str,
4269 scenarios: &[(serde_json::Value, HashMap<String, bool>)],
4270) {
4271 let Some(re) = FORMAT_PATTERNS.get(format) else {
4272 return;
4273 };
4274
4275 for (val, conds) in scenarios {
4276 if !is_satisfiable(m, conds) || val.is_null() {
4277 continue;
4278 }
4279 if let Some(s) = coerce_to_string(val) {
4280 if s.contains("${") && m.is_from_intrinsic(rid, prop_path) {
4281 continue;
4282 }
4283 if m.is_from_parameter(rid, prop_path) {
4284 continue;
4285 }
4286 if ec2_resolves_ssm_image_alias(m, rid, prop_path, format, &s) {
4287 continue;
4288 }
4289 if !re.is_match(&s) {
4290 let rule_id = format_rule_for_format(format).unwrap_or("E1103");
4291 out.push(build_diagnostic_conditional(
4292 rule_id,
4293 &format!("{} does not match format '{}'", format_value(val), format),
4294 m,
4295 rid,
4296 prop_path,
4297 None,
4298 condition_map(conds),
4299 ));
4300 }
4301 }
4302 }
4303}
4304
4305fn validate_lifecycle(out: &mut Vec<Diagnostic>, store: &CompiledSchemaStore, model: &Arc<SemanticModel>) {
4306 let lifecycle = store.lifecycle();
4307 for (rid, res) in &model.resources {
4308 if let Some(entry) = lifecycle.resource_lifecycle(&res.resource_type) {
4309 let (rule_id, msg) = match (entry.status.as_str(), entry.date.as_deref()) {
4310 ("shutdown", Some(d)) => (
4311 "E3710",
4312 format!("Resource type '{}' is from a service that was shut down on {}", res.resource_type, d),
4313 ),
4314 ("shutdown", None) => (
4315 "E3710",
4316 format!("Resource type '{}' is from a service that has been shut down", res.resource_type),
4317 ),
4318 ("sunset", Some(d)) => (
4319 "W3696",
4320 format!(
4321 "Resource type '{}' is from a service that will be shut down on {}. Plan to migrate to an alternative",
4322 res.resource_type, d
4323 ),
4324 ),
4325 ("sunset", None) => {
4326 ("W3696", format!("Resource type '{}' is from a service that is sunsetting", res.resource_type))
4327 }
4328 ("maintenance", Some(d)) => (
4329 "W3697",
4330 format!(
4331 "Resource type '{}' is from a service in maintenance mode since {}. Consider migrating to an alternative",
4332 res.resource_type, d
4333 ),
4334 ),
4335 ("maintenance", None) => {
4336 ("W3697", format!("Resource type '{}' is from a service in maintenance mode", res.resource_type))
4337 }
4338 _ => continue,
4339 };
4340 out.push(build_diagnostic(rule_id, &msg, model, rid, "", None));
4341 }
4342
4343 if (res.resource_type == "AWS::Lambda::Function" || res.resource_type == SAM_FUNCTION_TYPE)
4344 && !model.is_from_intrinsic(rid, "Properties.Runtime")
4349 {
4350 for (val, _) in &model.resolve_scenarios_json(rid, "Properties.Runtime") {
4351 let Some(runtime) = val.as_str() else {
4352 continue;
4353 };
4354 let (rule_id, band) = if lifecycle.is_runtime_eol(runtime) {
4358 ("E2533", Some(RuntimeLifecycleBand::EndOfLife))
4359 } else if lifecycle.is_runtime_create_blocked(runtime) {
4360 ("E2531", Some(RuntimeLifecycleBand::CreateBlocked))
4361 } else if lifecycle.is_runtime_deprecated(runtime) {
4362 ("W2531", Some(RuntimeLifecycleBand::Deprecated))
4363 } else {
4364 ("", None)
4365 };
4366 if let Some(band) = band {
4367 out.push(build_diagnostic(
4368 rule_id,
4369 &runtime_deprecation_message(lifecycle, runtime, band),
4370 model,
4371 rid,
4372 "Properties.Runtime",
4373 None,
4374 ));
4375 }
4376 }
4377 }
4378 }
4379}
4380
4381#[derive(Clone, Copy)]
4382enum RuntimeLifecycleBand {
4383 Deprecated,
4384 CreateBlocked,
4385 EndOfLife,
4386}
4387
4388fn runtime_deprecation_message(
4392 lifecycle: &crate::store::LifecycleStore,
4393 runtime: &str,
4394 band: RuntimeLifecycleBand,
4395) -> String {
4396 let Some(dates) = lifecycle.runtime_lifecycle(runtime) else {
4397 return format!("Runtime '{}' is deprecated", runtime);
4398 };
4399 let successor = match &dates.successor {
4400 Some(s) => format!("'{}'", s),
4401 None => "None".to_string(),
4402 };
4403 let (creation_status, update_status) = match band {
4404 RuntimeLifecycleBand::Deprecated => ("will be disabled", "will be disabled"),
4405 RuntimeLifecycleBand::CreateBlocked => ("was disabled", "will be disabled"),
4406 RuntimeLifecycleBand::EndOfLife => ("was disabled", "were disabled"),
4407 };
4408 format!(
4409 "Runtime '{}' was deprecated on '{}'. Creation {} on '{}', and updates {} on '{}'. Please consider updating to {}",
4410 runtime, dates.deprecated, creation_status, dates.create_block, update_status, dates.update_block, successor
4411 )
4412}
4413
4414fn validate_extensions(
4415 out: &mut Vec<Diagnostic>,
4416 store: &CompiledSchemaStore,
4417 model: &Arc<SemanticModel>,
4418 rid: &str,
4419 res: &ResolvedResource,
4420) {
4421 let Some(exts) = store.extensions().get(&res.resource_type) else {
4422 return;
4423 };
4424 for ext in exts {
4425 if ext.get("cfnGather").is_some() {
4426 validate_cfn_gather(out, model, rid, res, ext);
4427 } else if ext.get("if").is_some() {
4428 validate_extension_if_then_else(out, model, rid, res, ext);
4429 } else if let Some(all_of) = ext.get("allOf").and_then(|v| v.as_array()) {
4430 for sub_ext in all_of {
4431 if sub_ext.get("if").is_some() {
4432 validate_extension_if_then_else(out, model, rid, res, sub_ext);
4433 }
4434 }
4435 }
4436 }
4437}
4438
4439fn validate_cfn_gather(
4440 out: &mut Vec<Diagnostic>,
4441 model: &Arc<SemanticModel>,
4442 rid: &str,
4443 res: &ResolvedResource,
4444 ext: &serde_json::Value,
4445) {
4446 let Some(gather_def) = ext.get("cfnGather") else {
4447 return;
4448 };
4449 let Some(gather_slots) = gather_def.get("gather").and_then(|v| v.as_object()) else {
4450 return;
4451 };
4452 let Some(schema) = gather_def.get("schema") else {
4453 return;
4454 };
4455
4456 let mut context = serde_json::Map::new();
4457 for (slot_name, slot_def) in gather_slots {
4458 let slot_obj = match slot_def.as_object() {
4459 Some(o) => o,
4460 None => continue,
4461 };
4462 let properties = slot_obj.get("properties").and_then(|v| v.as_object());
4463 let reference_path = slot_obj.get("reference").and_then(|v| v.as_str());
4464
4465 let target_rid = if let Some(ref_path) = reference_path {
4466 let prop_key = ref_path.trim_start_matches('/').replace('/', ".");
4470 model.follow_ref(rid, &format!("Properties.{}", prop_key)).map(String::from)
4471 } else {
4472 Some(rid.to_string())
4473 };
4474
4475 let Some(target) = target_rid else { continue };
4476
4477 if let Some(filter) = slot_obj.get("filter").and_then(|v| v.as_object())
4478 && let Some(expected_type) = filter.get("type").and_then(|v| v.as_str())
4479 {
4480 let actual_type = model.resources.get(&target).map(|r| r.resource_type.as_str());
4481 if actual_type != Some(expected_type) {
4482 continue;
4483 }
4484 }
4485
4486 let mut slot_values = serde_json::Map::new();
4487 if let Some(props) = properties {
4488 for (prop_name, prop_def) in props {
4489 let (path, default_val) = match prop_def {
4495 serde_json::Value::String(p) => (Some(p.as_str()), None),
4496 _ => (prop_def.get("path").and_then(|v| v.as_str()), prop_def.get("default").cloned()),
4497 };
4498 let resolved_path = path.map(|p| format!("Properties.{}", p.trim_start_matches('/').replace('/', ".")));
4499
4500 let resolved = resolved_path
4501 .as_ref()
4502 .and_then(|p| model.resolve_scenarios_json(&target, p).into_iter().next().map(|(v, _)| v));
4503 let value = resolved.or(default_val).unwrap_or(serde_json::Value::Null);
4504 slot_values.insert(prop_name.clone(), value);
4505 }
4506 }
4507 context.insert(slot_name.clone(), serde_json::Value::Object(slot_values));
4508 }
4509
4510 let context_val = serde_json::Value::Object(context);
4511
4512 let resolved_schema = resolve_data_in_schema(schema, &context_val);
4513 evaluate_gather_schema(out, model, rid, res, &resolved_schema, &context_val);
4514}
4515
4516fn extension_required_covered_by_dedicated_rule(resource_type: &str, prop_name: &str) -> bool {
4530 matches!(
4531 (resource_type, prop_name),
4532 ("AWS::S3::Bucket", "OwnershipControls")
4533 | ("AWS::ElasticLoadBalancingV2::Listener", "Certificates")
4534 | ("AWS::RDS::DBInstance", "BackupRetentionPeriod")
4535 )
4536}
4537
4538fn validate_extension_if_then_else(
4539 out: &mut Vec<Diagnostic>,
4540 model: &Arc<SemanticModel>,
4541 rid: &str,
4542 res: &ResolvedResource,
4543 ext: &serde_json::Value,
4544) {
4545 let Some(if_schema) = ext.get("if") else {
4546 return;
4547 };
4548 let if_matches = extension_condition_matches(if_schema, model, rid);
4549 let branch = if if_matches { ext.get("then") } else { ext.get("else") };
4550 let Some(branch_schema) = branch else { return };
4551
4552 if let Some(required) = branch_schema.get("required").and_then(|v| v.as_array()) {
4553 for req in required {
4554 if let Some(prop_name) = req.as_str()
4555 && !res.properties.contains_key(prop_name)
4556 {
4557 if extension_required_covered_by_dedicated_rule(&res.resource_type, prop_name) {
4563 continue;
4564 }
4565 let already_reported = out.iter().any(|d| {
4570 d.rule_id == "F3003"
4571 && d.resource_logical_id() == Some(rid)
4572 && d.message.contains(&format!("'{}' is a required property", prop_name))
4573 });
4574 if already_reported {
4575 continue;
4576 }
4577 out.push(build_diagnostic(
4578 "F3003",
4579 &format!("'{}' is a required property (from extension)", prop_name),
4580 model,
4581 rid,
4582 KEY_PROPERTIES,
4583 Some(&format!("Add '{}'", prop_name)),
4584 ));
4585 }
4586 }
4587 }
4588
4589 if let Some(props) = branch_schema.get("properties").and_then(|v| v.as_object()) {
4590 for (prop_name, constraint) in props {
4591 if constraint == &serde_json::Value::Bool(false) && res.properties.contains_key(prop_name) {
4592 out.push(build_diagnostic(
4597 "I9002",
4598 &format!("'{}' is ignored in this configuration (from extension)", prop_name),
4599 model,
4600 rid,
4601 &format!("Properties.{}", prop_name),
4602 None,
4603 ));
4604 }
4605 }
4606 }
4607
4608 if let Some(props) = branch_schema.get("properties").and_then(|v| v.as_object()) {
4609 for (prop_name, constraint) in props {
4610 let constraint_obj = match constraint.as_object() {
4611 Some(o) => o,
4612 None => continue,
4613 };
4614 let Some(enum_vals) = constraint_obj.get("enum").and_then(|v| v.as_array()) else {
4615 continue;
4616 };
4617 let scenarios = model.resolve_scenarios_json(rid, &format!("Properties.{}", prop_name));
4618 for (val, conds) in &scenarios {
4619 if !is_satisfiable(model, conds) {
4620 continue;
4621 }
4622 let matches_enum = enum_vals.iter().any(|e| {
4631 scalar_eq(e, val) || e.as_str().zip(val.as_str()).is_some_and(|(a, b)| a.eq_ignore_ascii_case(b))
4632 });
4633 if !matches_enum {
4634 let allowed = render_str_list(enum_vals.iter().filter_map(|v| v.as_str()));
4635 out.push(build_diagnostic(
4636 "E9006",
4637 &format!("'{}' is not one of {}", coerce_to_string(val).unwrap_or_default(), allowed),
4638 model,
4639 rid,
4640 &format!("Properties.{}", prop_name),
4641 None,
4642 ));
4643 }
4644 }
4645 }
4646 }
4647}
4648
4649fn match_constraint_value(constraint: &serde_json::Map<String, serde_json::Value>, val: &serde_json::Value) -> bool {
4653 if let Some(enum_vals) = constraint.get("enum").and_then(|v| v.as_array()) {
4654 return enum_vals.iter().any(|e| scalar_eq(e, val));
4655 }
4656 if let Some(cv) = constraint.get("const") {
4657 return scalar_eq(val, cv);
4658 }
4659 if let Some(pat) = constraint.get("pattern").and_then(|v| v.as_str()) {
4660 return val.as_str().and_then(|s| compile_pattern(pat).map(|re| re.is_match(s))).unwrap_or(false);
4661 }
4662 true
4663}
4664
4665fn extension_condition_matches(if_schema: &serde_json::Value, model: &Arc<SemanticModel>, rid: &str) -> bool {
4666 let Some(obj) = if_schema.as_object() else {
4667 return false;
4668 };
4669
4670 if let Some(required) = obj.get("required").and_then(|v| v.as_array()) {
4671 for req in required {
4672 if let Some(name) = req.as_str() {
4673 let scenarios = model.resolve_scenarios_json(rid, &format!("Properties.{}", name));
4674 if scenarios.is_empty() {
4675 return false;
4676 }
4677 }
4678 }
4679 }
4680
4681 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
4682 for (prop_name, constraint) in props {
4683 let prop_path = format!("Properties.{}", prop_name);
4684 let scenarios = model.resolve_scenarios_json(rid, &prop_path);
4685 if constraint == &serde_json::Value::Bool(false) {
4692 let present = scenarios.iter().any(|(v, c)| is_satisfiable(model, c) && !v.is_null());
4693 if present {
4694 return false;
4695 }
4696 continue;
4697 }
4698 if scenarios.is_empty() {
4699 return false;
4700 }
4701 let constraint_obj = match constraint.as_object() {
4702 Some(o) => o,
4703 None => continue,
4704 };
4705 if let Some(nested_required) = constraint_obj.get("required").and_then(|v| v.as_array()) {
4707 for sub_req in nested_required {
4708 if let Some(sub_name) = sub_req.as_str() {
4709 let sub_path = format!("{}.{}", prop_path, sub_name);
4710 let sub_scenarios = model.resolve_scenarios_json(rid, &sub_path);
4711 let sub_exists = sub_scenarios.iter().any(|(v, c)| is_satisfiable(model, c) && !v.is_null());
4712 if !sub_exists {
4713 return false;
4714 }
4715 }
4716 }
4717 }
4718 let any_match = scenarios.iter().any(|(val, conds)| {
4719 if !is_satisfiable(model, conds) {
4720 return false;
4721 }
4722 if let Some(not_schema) = constraint_obj.get("not").and_then(|v| v.as_object()) {
4723 let inner_matches = match_constraint_value(not_schema, val);
4724 return !inner_matches;
4725 }
4726 match_constraint_value(constraint_obj, val)
4727 });
4728 if !any_match {
4729 return false;
4730 }
4731 }
4732 }
4733 true
4734}
4735
4736fn resolve_data_in_schema(schema: &serde_json::Value, context: &serde_json::Value) -> serde_json::Value {
4737 match schema {
4738 serde_json::Value::Object(obj) => {
4739 if let Some(lookup) = obj.get("$lookup").and_then(|v| v.as_object()) {
4740 let key = lookup
4741 .get("key")
4742 .map(|k| resolve_data_in_schema(k, context))
4743 .and_then(|v| v.as_str().map(String::from));
4744 let map = lookup.get("map").and_then(|v| v.as_object());
4745 if let (Some(k), Some(m)) = (key, map) {
4746 return m.get(&k).cloned().unwrap_or(serde_json::Value::Null);
4747 }
4748 return serde_json::Value::Null;
4749 }
4750 if let Some(pointer) = obj.get("$data").and_then(|v| v.as_str()) {
4751 return resolve_json_pointer(context, pointer);
4752 }
4753 let mut result = serde_json::Map::new();
4754 for (k, v) in obj {
4755 result.insert(k.clone(), resolve_data_in_schema(v, context));
4756 }
4757 serde_json::Value::Object(result)
4758 }
4759 serde_json::Value::Array(arr) => {
4760 serde_json::Value::Array(arr.iter().map(|v| resolve_data_in_schema(v, context)).collect())
4761 }
4762 other => other.clone(),
4763 }
4764}
4765
4766fn resolve_json_pointer(root: &serde_json::Value, pointer: &str) -> serde_json::Value {
4767 let parts: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
4768 let mut current = root;
4769 for part in &parts {
4770 if part.is_empty() {
4771 continue;
4772 }
4773 current = match current {
4774 serde_json::Value::Object(m) => match m.get(*part) {
4775 Some(v) => v,
4776 None => return serde_json::Value::Null,
4777 },
4778 serde_json::Value::Array(a) => match part.parse::<usize>() {
4779 Ok(idx) => match a.get(idx) {
4780 Some(v) => v,
4781 None => return serde_json::Value::Null,
4782 },
4783 Err(_) => return serde_json::Value::Null,
4784 },
4785 _ => return serde_json::Value::Null,
4786 };
4787 }
4788 current.clone()
4789}
4790
4791fn evaluate_gather_schema(
4792 out: &mut Vec<Diagnostic>,
4793 model: &Arc<SemanticModel>,
4794 rid: &str,
4795 _res: &ResolvedResource,
4796 schema: &serde_json::Value,
4797 context: &serde_json::Value,
4798) {
4799 let Some(obj) = schema.as_object() else {
4800 return;
4801 };
4802 if let Some(if_val) = obj.get("if") {
4811 let matches = gather_condition_matches(if_val, context);
4812 let branch = if matches { obj.get("then") } else { obj.get("else") };
4813 if let Some(branch_val) = branch {
4814 evaluate_gather_constraints(out, model, rid, branch_val, context);
4815 }
4816 }
4817}
4818
4819fn gather_condition_matches(condition: &serde_json::Value, context: &serde_json::Value) -> bool {
4820 let Some(obj) = condition.as_object() else {
4821 return false;
4822 };
4823
4824 if let Some(required) = obj.get("required").and_then(|v| v.as_array()) {
4825 for req in required {
4826 if let Some(name) = req.as_str() {
4827 let val = resolve_json_pointer(context, &format!("/{}", name));
4828 if val.is_null() {
4829 return false;
4830 }
4831 }
4832 }
4833 }
4834
4835 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
4836 for (prop_name, constraint) in props {
4837 let actual = resolve_json_pointer(context, &format!("/{}", prop_name));
4838 if actual.is_null() {
4839 return false;
4840 }
4841 if !gather_prop_matches(&actual, constraint) {
4842 return false;
4843 }
4844 }
4845 }
4846 true
4847}
4848
4849fn gather_prop_matches(actual: &serde_json::Value, constraint: &serde_json::Value) -> bool {
4850 let Some(obj) = constraint.as_object() else {
4851 return true;
4852 };
4853
4854 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
4855 for (k, v) in props {
4856 let child = match actual.get(k) {
4857 Some(c) => c,
4858 None => return false,
4859 };
4860 if !gather_prop_matches(child, v) {
4861 return false;
4862 }
4863 }
4864 }
4865 if let Some(required) = obj.get("required").and_then(|v| v.as_array()) {
4866 for req in required {
4867 if let Some(name) = req.as_str()
4868 && actual.get(name).is_none()
4869 {
4870 return false;
4871 }
4872 }
4873 }
4874 if let Some(cv) = obj.get("const") {
4875 return scalar_eq(actual, cv);
4876 }
4877 if let Some(enum_vals) = obj.get("enum").and_then(|v| v.as_array()) {
4878 return enum_vals.iter().any(|e| e == actual);
4879 }
4880 true
4881}
4882
4883fn evaluate_gather_constraints(
4884 out: &mut Vec<Diagnostic>,
4885 model: &Arc<SemanticModel>,
4886 rid: &str,
4887 schema: &serde_json::Value,
4888 context: &serde_json::Value,
4889) {
4890 let Some(obj) = schema.as_object() else {
4891 return;
4892 };
4893 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
4894 for (slot_name, slot_constraints) in props {
4895 let slot_val = resolve_json_pointer(context, &format!("/{}", slot_name));
4896 check_gather_property_constraints(out, model, rid, slot_name, &slot_val, slot_constraints);
4897 }
4898 }
4899}
4900
4901fn check_gather_property_constraints(
4902 out: &mut Vec<Diagnostic>,
4903 model: &Arc<SemanticModel>,
4904 rid: &str,
4905 slot_name: &str,
4906 actual: &serde_json::Value,
4907 constraint: &serde_json::Value,
4908) {
4909 let Some(obj) = constraint.as_object() else {
4910 return;
4911 };
4912 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
4913 for (prop_name, prop_constraint) in props {
4914 let prop_val = actual.get(prop_name).unwrap_or(&serde_json::Value::Null);
4915 let pc = match prop_constraint.as_object() {
4916 Some(o) => o,
4917 None => continue,
4918 };
4919 if let Some(cv) = pc.get("const")
4920 && !cv.is_null()
4921 && !scalar_eq(prop_val, cv)
4922 {
4923 out.push(build_diagnostic(
4924 "E3030",
4925 &format!(
4926 "Cross-resource constraint: {}.{} is {} but must be {} (from referenced resource)",
4927 slot_name,
4928 prop_name,
4929 format_value(prop_val),
4930 format_value(cv)
4931 ),
4932 model,
4933 rid,
4934 KEY_PROPERTIES,
4935 None,
4936 ));
4937 }
4938 }
4945 }
4946}
4947
4948fn describe_resolution(m: &Arc<SemanticModel>, rid: &str, prop_path: &str) -> Option<String> {
4949 let top_level_path;
4950 let (val, value_path) = match m.resolve(rid, prop_path) {
4951 Some(val) => (val, prop_path),
4952 None => {
4953 let stripped = prop_path.strip_prefix("Properties.")?;
4954 let top = stripped.split('.').next()?;
4955 let val = m.resources.get(rid)?.properties.get(top)?;
4956 top_level_path = format!("Properties.{}", top);
4957 (val, top_level_path.as_str())
4958 }
4959 };
4960 match val {
4961 ResolvedValue::Reference { target, kind } => {
4962 let kind_str = match kind {
4963 RefKind::Ref => "Ref",
4964 RefKind::GetAtt { attr: a } => {
4965 return Some(format!("GetAtt {}.{}", target, a));
4966 }
4967 RefKind::Sub { var: _ } => "Sub",
4968 RefKind::DependsOn => return None,
4969 };
4970 Some(format!("{} to '{}'", kind_str, target))
4971 }
4972 ResolvedValue::Enum { variants: _ } => Some("parameter with AllowedValues".into()),
4973 ResolvedValue::Conditional { condition: cond, if_true: _, if_false: _ } => {
4974 Some(format!("Fn::If on condition '{}'", cond))
4975 }
4976 ResolvedValue::Dynamic { reason: desc } => Some(format!("dynamic ({})", desc)),
4977 ResolvedValue::TypedDynamic { reason: desc, param_type: typ } => match m.parameter_name_at(rid, value_path) {
4983 Some(parameter) => Some(format!("parameter '{}' (type {})", parameter, typ)),
4984 None => Some(format!("dynamic ({})", desc)),
4985 },
4986 _ => None,
4987 }
4988}
4989
4990fn build_diagnostic(
4991 rule_id: &str,
4992 msg: &str,
4993 m: &Arc<SemanticModel>,
4994 rid: &str,
4995 prop: &str,
4996 fix: Option<&str>,
4997) -> Diagnostic {
4998 build_diagnostic_conditional(rule_id, msg, m, rid, prop, fix, None)
4999}
5000
5001fn build_diagnostic_conditional(
5002 rule_id: &str,
5003 msg: &str,
5004 m: &Arc<SemanticModel>,
5005 rid: &str,
5006 prop: &str,
5007 fix: Option<&str>,
5008 conds: Option<HashMap<String, bool>>,
5009) -> Diagnostic {
5010 let span = if rid.is_empty() { resolve_section_span(rule_id, m.as_ref()) } else { m.resource_span(rid, prop) };
5011 RegisteredDiagnostic::new(rule_id, msg)
5012 .resource(rid, m.resources.get(rid).map(|r| r.resource_type.clone()))
5013 .property_path(prop)
5014 .location(span)
5015 .suggested_fix(fix)
5016 .condition_scenario(conds)
5017 .phase(Phase::Schema)
5018 .build()
5019}
5020
5021#[cfg(test)]
5022mod tests {
5023 use super::*;
5024 use serde_json::json;
5025
5026 fn current_scenario_filter() -> Option<HashMap<String, bool>> {
5027 SCENARIO_FILTER.with(|filter| filter.borrow().clone())
5028 }
5029
5030 #[test]
5031 fn scenario_filter_scope_restores_outer_assignment_after_unwind() {
5032 reset_scenario_filter();
5033 let _outer_scope = ScenarioFilterScope::enter(HashMap::from([("Outer".to_string(), true)]));
5034 let unwind = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
5035 let _inner_scope = ScenarioFilterScope::enter(HashMap::from([("Inner".to_string(), false)]));
5036 assert_eq!(current_scenario_filter(), Some(HashMap::from([("Inner".to_string(), false)])));
5037 panic!("exercise unwind cleanup");
5038 }));
5039 assert!(unwind.is_err());
5040 assert_eq!(current_scenario_filter(), Some(HashMap::from([("Outer".to_string(), true)])));
5041 }
5042
5043 #[test]
5044 fn validation_entry_clears_a_stale_scenario_filter() {
5045 SCENARIO_FILTER.with(|filter| {
5046 filter.replace(Some(HashMap::from([("PreviousTemplate".to_string(), true)])));
5047 });
5048 let diagnostics = diagnostics_for_mode_value("managed");
5049 assert!(diagnostics.is_empty(), "the new validation must not inherit a prior scenario assignment");
5050 assert!(current_scenario_filter().is_none(), "validation leaves no scenario filter behind");
5051 }
5052
5053 #[test]
5054 fn levenshtein_distance_identical_strings() {
5055 assert_eq!(levenshtein_distance("abc", "abc"), 0);
5056 }
5057
5058 #[test]
5059 fn levenshtein_distance_empty_strings() {
5060 assert_eq!(levenshtein_distance("", ""), 0);
5061 }
5062
5063 #[test]
5064 fn levenshtein_distance_one_empty() {
5065 assert_eq!(levenshtein_distance("abc", ""), 3);
5066 assert_eq!(levenshtein_distance("", "xyz"), 3);
5067 }
5068
5069 #[test]
5070 fn levenshtein_distance_single_substitution() {
5071 assert_eq!(levenshtein_distance("cat", "bat"), 1);
5072 }
5073
5074 #[test]
5075 fn levenshtein_distance_single_insertion() {
5076 assert_eq!(levenshtein_distance("abc", "abcd"), 1);
5077 }
5078
5079 #[test]
5080 fn levenshtein_distance_single_deletion() {
5081 assert_eq!(levenshtein_distance("abcd", "abc"), 1);
5082 }
5083
5084 #[test]
5085 fn levenshtein_distance_transposition() {
5086 assert_eq!(levenshtein_distance("ab", "ba"), 2);
5087 }
5088
5089 #[test]
5090 fn levenshtein_distance_completely_different() {
5091 assert_eq!(levenshtein_distance("abc", "xyz"), 3);
5092 }
5093
5094 #[test]
5095 fn find_similar_exact_match() {
5096 let known: HashSet<&str> = ["BucketName", "AccessControl"].into_iter().collect();
5097 assert_eq!(find_similar("BucketName", &known), Some("BucketName"));
5098 }
5099
5100 #[test]
5101 fn find_similar_close_typo() {
5102 let known: HashSet<&str> = ["BucketName", "AccessControl"].into_iter().collect();
5103 let result = find_similar("Bucketame", &known);
5104 assert_eq!(result, Some("BucketName"));
5105 }
5106
5107 #[test]
5108 fn find_similar_no_match() {
5109 let known: HashSet<&str> = ["BucketName", "AccessControl"].into_iter().collect();
5110 assert_eq!(find_similar("CompletelyDifferent", &known), None, "dissimilar name should not match");
5111 }
5112
5113 #[test]
5114 fn find_similar_case_insensitive() {
5115 let known: HashSet<&str> = ["BucketName"].into_iter().collect();
5116 assert_eq!(find_similar("bucketname", &known), Some("BucketName"));
5117 }
5118
5119 #[test]
5120 fn type_matches_string() {
5121 assert!(type_matches(&json!("hello"), &PropType::Single("string".into())));
5122 assert!(!type_matches(&json!(42), &PropType::Single("string".into())));
5123 }
5124
5125 #[test]
5126 fn type_matches_integer() {
5127 assert!(type_matches(&json!(42), &PropType::Single("integer".into())));
5128 assert!(type_matches(&json!(42.0), &PropType::Single("integer".into())));
5129 assert!(!type_matches(&json!(42.5), &PropType::Single("integer".into())));
5130 assert!(!type_matches(&json!("42"), &PropType::Single("integer".into())));
5131 }
5132
5133 #[test]
5134 fn type_matches_number() {
5135 assert!(type_matches(&json!(42), &PropType::Single("number".into())));
5136 assert!(type_matches(&json!(3.14), &PropType::Single("number".into())));
5137 assert!(!type_matches(&json!("3.14"), &PropType::Single("number".into())));
5138 }
5139
5140 #[test]
5141 fn type_matches_boolean() {
5142 assert!(type_matches(&json!(true), &PropType::Single("boolean".into())));
5143 assert!(!type_matches(&json!("true"), &PropType::Single("boolean".into())));
5144 }
5145
5146 #[test]
5147 fn type_matches_array() {
5148 assert!(type_matches(&json!([1, 2]), &PropType::Single("array".into())));
5149 assert!(!type_matches(&json!("[]"), &PropType::Single("array".into())));
5150 }
5151
5152 #[test]
5153 fn type_matches_object() {
5154 assert!(type_matches(&json!({"a": 1}), &PropType::Single("object".into())));
5155 assert!(!type_matches(&json!("{}"), &PropType::Single("object".into())));
5156 }
5157
5158 #[test]
5159 fn type_matches_null() {
5160 assert!(type_matches(&json!(null), &PropType::Single("null".into())));
5161 assert!(!type_matches(&json!("null"), &PropType::Single("null".into())));
5162 }
5163
5164 #[test]
5165 fn type_matches_multi_with_null() {
5166 let pt = PropType::Multi(vec!["string".into(), "null".into()]);
5167 assert!(type_matches(&json!("hello"), &pt));
5168 assert!(type_matches(&json!(null), &pt));
5169 assert!(!type_matches(&json!(42), &pt));
5170 }
5171
5172 #[test]
5173 fn type_matches_unknown_type_always_true() {
5174 assert!(type_matches(&json!("anything"), &PropType::Single("custom_type".into())));
5175 }
5176
5177 #[test]
5178 fn enum_matches_exact() {
5179 assert!(enum_matches(&json!("a"), &[json!("a"), json!("b")]));
5180 assert!(!enum_matches(&json!("c"), &[json!("a"), json!("b")]));
5181 }
5182
5183 #[test]
5184 fn enum_matches_coerced_number_to_string() {
5185 assert!(enum_matches(&json!("42"), &[json!(42)]));
5186 }
5187
5188 #[test]
5189 fn enum_matches_empty_allowed() {
5190 assert!(!enum_matches(&json!("a"), &[]));
5191 }
5192
5193 #[test]
5194 fn enum_matches_case_insensitive_any_casing() {
5195 let allowed = [json!("managed"), json!("unmanaged")];
5196 assert!(enum_matches_case_insensitive(&json!("managed"), &allowed));
5197 assert!(enum_matches_case_insensitive(&json!("MANAGED"), &allowed));
5198 assert!(enum_matches_case_insensitive(&json!("Unmanaged"), &allowed));
5199 }
5200
5201 #[test]
5202 fn enum_matches_case_insensitive_rejects_unlisted_value() {
5203 let allowed = [json!("managed"), json!("unmanaged")];
5204 assert!(!enum_matches_case_insensitive(&json!("BOGUS"), &allowed));
5205 }
5206
5207 #[test]
5208 fn enum_matches_case_insensitive_non_string_uses_exact_comparison() {
5209 assert!(enum_matches_case_insensitive(&json!(42), &[json!(42)]));
5210 assert!(!enum_matches_case_insensitive(&json!(43), &[json!(42)]));
5211 }
5212
5213 fn store_with_case_insensitive_mode_enum() -> CompiledSchemaStore {
5214 let mode =
5215 PropSchema { enum_case_insensitive: vec![json!("managed"), json!("unmanaged")], ..Default::default() };
5216 let mut properties = HashMap::new();
5217 properties.insert("Mode".to_string(), mode);
5218 let mut store = CompiledSchemaStore::new();
5219 store.insert_schema(CompiledSchema {
5220 type_name: "AWS::Fake::Type".to_string(),
5221 properties,
5222 ..Default::default()
5223 });
5224 store
5225 }
5226
5227 fn diagnostics_for_mode_value(mode_value: &str) -> Vec<Diagnostic> {
5228 let template = format!(
5229 r#"{{"Resources":{{"Widget":{{"Type":"AWS::Fake::Type","Properties":{{"Mode":"{}"}}}}}}}}"#,
5230 mode_value
5231 );
5232 let model = Arc::new(SemanticModel::from_bytes(template.as_bytes()).expect("template parses"));
5233 validate_all_resources(&store_with_case_insensitive_mode_enum(), &model, None)
5234 }
5235
5236 #[test]
5237 fn case_insensitive_enum_accepts_any_casing_of_allowed_value() {
5238 for accepted in ["managed", "MANAGED", "Unmanaged"] {
5239 let w3030: Vec<Diagnostic> =
5240 diagnostics_for_mode_value(accepted).into_iter().filter(|d| d.rule_id == "W3030").collect();
5241 assert!(w3030.is_empty(), "'{accepted}' must not fire W3030, got: {w3030:?}");
5242 }
5243 }
5244
5245 #[test]
5246 fn case_insensitive_enum_flags_unlisted_value_with_marked_message() {
5247 let diags = diagnostics_for_mode_value("BOGUS");
5248 let w3030 = diags.iter().find(|d| d.rule_id == "W3030").expect("W3030 fires for a value not in the enum");
5249 assert_eq!(w3030.severity, rules::Severity::Warn);
5250 assert_eq!(w3030.property_path.as_deref(), Some("Properties.Mode"));
5251 assert_eq!(w3030.message, "'BOGUS' is not one of ['managed', 'unmanaged'] (case-insensitive)");
5252 }
5253
5254 #[test]
5255 fn format_value_wraps_strings_in_quotes() {
5256 assert_eq!(format_value(&json!("hello")), "'hello'");
5257 }
5258
5259 #[test]
5260 fn format_value_renders_non_strings_directly() {
5261 assert_eq!(format_value(&json!(42)), "42");
5262 assert_eq!(format_value(&json!(true)), "true");
5263 assert_eq!(format_value(&json!(null)), "null");
5264 }
5265
5266 #[test]
5267 fn single_type_compatible_same_type() {
5268 assert!(single_type_compatible("string", "string"));
5269 assert!(single_type_compatible("integer", "integer"));
5270 }
5271
5272 #[test]
5273 fn single_type_compatible_integer_number_interchangeable() {
5274 assert!(single_type_compatible("integer", "number"));
5275 assert!(single_type_compatible("number", "integer"));
5276 }
5277
5278 #[test]
5279 fn single_type_compatible_string_to_number_coercion() {
5280 assert!(single_type_compatible("string", "number"));
5281 assert!(single_type_compatible("string", "integer"));
5282 }
5283
5284 #[test]
5285 fn single_type_compatible_number_to_string_coercion() {
5286 assert!(single_type_compatible("number", "string"));
5287 assert!(single_type_compatible("integer", "string"));
5288 assert!(single_type_compatible("boolean", "string"));
5289 }
5290
5291 #[test]
5292 fn single_type_compatible_null_always_compatible() {
5293 assert!(single_type_compatible("string", "null"));
5294 assert!(single_type_compatible("array", "null"));
5295 }
5296
5297 #[test]
5298 fn single_type_compatible_incompatible() {
5299 assert!(!single_type_compatible("string", "array"));
5300 assert!(!single_type_compatible("array", "object"));
5301 assert!(!single_type_compatible("boolean", "integer"));
5302 }
5303
5304 #[test]
5305 fn param_type_number() {
5306 assert_eq!(cfn_param_type_to_schema_type("Number"), "number");
5307 }
5308
5309 #[test]
5310 fn param_type_string() {
5311 assert_eq!(cfn_param_type_to_schema_type("String"), "string");
5312 }
5313
5314 #[test]
5315 fn param_type_comma_delimited_list() {
5316 assert_eq!(cfn_param_type_to_schema_type("CommaDelimitedList"), "array");
5317 }
5318
5319 #[test]
5320 fn param_type_list_prefix() {
5321 assert_eq!(cfn_param_type_to_schema_type("List<Number>"), "array");
5322 assert_eq!(cfn_param_type_to_schema_type("List<AWS::EC2::Subnet::Id>"), "array");
5323 }
5324
5325 #[test]
5326 fn param_type_ssm_parameter() {
5327 assert_eq!(cfn_param_type_to_schema_type("AWS::SSM::Parameter::Value<String>"), "string");
5328 }
5329
5330 #[test]
5331 fn param_type_unknown_defaults_to_string() {
5332 assert_eq!(cfn_param_type_to_schema_type("AWS::EC2::VPC::Id"), "string");
5333 }
5334
5335 #[test]
5336 fn resolve_json_pointer_root() {
5337 let data = json!({"a": 1});
5338 let result = resolve_json_pointer(&data, "/");
5339 assert_eq!(result, json!({"a": 1}));
5340 }
5341
5342 #[test]
5343 fn resolve_json_pointer_nested() {
5344 let data = json!({"a": {"b": {"c": 42}}});
5345 assert_eq!(resolve_json_pointer(&data, "/a/b/c"), json!(42));
5346 }
5347
5348 #[test]
5349 fn resolve_json_pointer_missing_key() {
5350 let data = json!({"a": 1});
5351 assert_eq!(resolve_json_pointer(&data, "/b"), json!(null));
5352 }
5353
5354 #[test]
5355 fn resolve_json_pointer_array_index() {
5356 let data = json!({"items": [10, 20, 30]});
5357 assert_eq!(resolve_json_pointer(&data, "/items/1"), json!(20));
5358 }
5359
5360 #[test]
5361 fn resolve_json_pointer_array_out_of_bounds() {
5362 let data = json!({"items": [10]});
5363 assert_eq!(resolve_json_pointer(&data, "/items/5"), json!(null));
5364 }
5365
5366 #[test]
5367 fn resolve_json_pointer_non_numeric_index_on_array() {
5368 let data = json!([1, 2, 3]);
5369 assert_eq!(resolve_json_pointer(&data, "/abc"), json!(null));
5370 }
5371
5372 #[test]
5373 fn resolve_json_pointer_scalar_intermediate() {
5374 let data = json!({"a": "string_value"});
5375 assert_eq!(resolve_json_pointer(&data, "/a/b"), json!(null));
5376 }
5377
5378 #[test]
5379 fn resolve_data_simple_reference() {
5380 let schema = json!({"$data": "/source/protocol"});
5381 let context = json!({"source": {"protocol": "tcp"}});
5382 assert_eq!(resolve_data_in_schema(&schema, &context), json!("tcp"));
5383 }
5384
5385 #[test]
5386 fn resolve_data_missing_reference() {
5387 let schema = json!({"$data": "/missing/path"});
5388 let context = json!({"source": {"protocol": "tcp"}});
5389 assert_eq!(resolve_data_in_schema(&schema, &context), json!(null));
5390 }
5391
5392 #[test]
5393 fn resolve_data_nested_object() {
5394 let schema = json!({
5395 "properties": {
5396 "port": {"const": {"$data": "/source/port"}}
5397 }
5398 });
5399 let context = json!({"source": {"port": 443}});
5400 let resolved = resolve_data_in_schema(&schema, &context);
5401 assert_eq!(resolved["properties"]["port"]["const"], json!(443));
5402 }
5403
5404 #[test]
5405 fn resolve_data_array() {
5406 let schema = json!([{"$data": "/a"}, {"$data": "/b"}]);
5407 let context = json!({"a": 1, "b": 2});
5408 assert_eq!(resolve_data_in_schema(&schema, &context), json!([1, 2]));
5409 }
5410
5411 #[test]
5412 fn resolve_data_non_object_passthrough() {
5413 assert_eq!(resolve_data_in_schema(&json!("plain"), &json!({})), json!("plain"));
5414 assert_eq!(resolve_data_in_schema(&json!(42), &json!({})), json!(42));
5415 assert_eq!(resolve_data_in_schema(&json!(null), &json!({})), json!(null));
5416 }
5417
5418 #[test]
5419 fn resolve_data_lookup() {
5420 let schema = json!({
5421 "$lookup": {
5422 "key": {"$data": "/source/engine"},
5423 "map": {
5424 "aurora": {"const": 3306},
5425 "redis": {"const": 6379}
5426 }
5427 }
5428 });
5429 let context = json!({"source": {"engine": "aurora"}});
5430 assert_eq!(resolve_data_in_schema(&schema, &context), json!({"const": 3306}));
5431 }
5432
5433 #[test]
5434 fn resolve_data_lookup_missing_key() {
5435 let schema = json!({
5436 "$lookup": {
5437 "key": {"$data": "/source/engine"},
5438 "map": {"aurora": 3306}
5439 }
5440 });
5441 let context = json!({"source": {"engine": "postgres"}});
5442 assert_eq!(resolve_data_in_schema(&schema, &context), json!(null));
5443 }
5444
5445 #[test]
5446 fn types_compatible_single() {
5447 assert!(types_compatible("string", &PropType::Single("string".into())));
5448 assert!(!types_compatible("array", &PropType::Single("string".into())));
5449 }
5450
5451 #[test]
5452 fn types_compatible_multi() {
5453 let pt = PropType::Multi(vec!["string".into(), "null".into()]);
5454 assert!(types_compatible("string", &pt));
5455 assert!(types_compatible("integer", &pt));
5456 assert!(!types_compatible("array", &PropType::Multi(vec!["string".into(), "integer".into()])));
5457 }
5458
5459 #[test]
5460 fn single_type_double_and_float_are_number() {
5461 assert!(single_type(&json!(3.14), "double"));
5462 assert!(single_type(&json!(3.14), "float"));
5463 assert!(single_type(&json!(42), "double"));
5464 }
5465
5466 #[test]
5467 fn single_type_integer_rejects_fractional() {
5468 assert!(!single_type(&json!(3.14), "integer"));
5469 }
5470
5471 #[test]
5472 fn single_type_integer_accepts_whole_float() {
5473 assert!(single_type(&json!(5.0), "integer"));
5474 }
5475
5476 #[test]
5477 fn find_prop_schema_direct_lookup() {
5478 let mut props = HashMap::new();
5479 props.insert(
5480 "Name".into(),
5481 PropSchema { prop_type: Some(PropType::Single("string".into())), ..Default::default() },
5482 );
5483 let defs = HashMap::new();
5484 let result = find_prop_schema("Name", &props, &defs).expect("Name should be found");
5485 assert_eq!(result.prop_type.as_ref().unwrap().primary(), Some("string"));
5486 }
5487
5488 #[test]
5489 fn find_prop_schema_nested_path() {
5490 let inner = PropSchema { prop_type: Some(PropType::Single("integer".into())), ..Default::default() };
5491 let mut inner_props = HashMap::new();
5492 inner_props.insert("Port".into(), inner);
5493 let outer = PropSchema { properties: inner_props, ..Default::default() };
5494 let mut props = HashMap::new();
5495 props.insert("Config".into(), outer);
5496 let defs = HashMap::new();
5497 let result = find_prop_schema("Config.Port", &props, &defs).expect("Config.Port should be found");
5498 assert_eq!(result.prop_type.as_ref().unwrap().primary(), Some("integer"));
5499 }
5500
5501 #[test]
5502 fn find_prop_schema_missing_returns_none() {
5503 let props = HashMap::new();
5504 let defs = HashMap::new();
5505 assert!(find_prop_schema("Missing", &props, &defs).is_none(), "missing prop should return None");
5506 }
5507
5508 #[test]
5509 fn find_prop_schema_resolves_ref() {
5510 let mut props = HashMap::new();
5511 props.insert("Config".into(), PropSchema { ref_name: Some("ConfigDef".into()), ..Default::default() });
5512 let mut defs = HashMap::new();
5513 defs.insert(
5514 "ConfigDef".into(),
5515 PropSchema { prop_type: Some(PropType::Single("object".into())), ..Default::default() },
5516 );
5517 let result = find_prop_schema("Config", &props, &defs).expect("Config should be found");
5518 assert_eq!(result.prop_type.as_ref().unwrap().primary(), Some("object"));
5519 }
5520
5521 #[test]
5522 fn find_prop_schema_deep_direct() {
5523 let mut props = HashMap::new();
5524 props.insert(
5525 "Name".into(),
5526 PropSchema { prop_type: Some(PropType::Single("string".into())), ..Default::default() },
5527 );
5528 let schema = CompiledSchema { type_name: "Test".into(), properties: props, ..Default::default() };
5529 assert!(find_prop_schema_deep("Name", &schema).is_some(), "direct property should be found");
5530 }
5531
5532 #[test]
5533 fn find_prop_schema_deep_searches_one_of() {
5534 let mut sub_props = HashMap::new();
5535 sub_props.insert(
5536 "Special".into(),
5537 PropSchema { prop_type: Some(PropType::Single("boolean".into())), ..Default::default() },
5538 );
5539 let schema = CompiledSchema {
5540 type_name: "Test".into(),
5541 one_of: vec![crate::compiled::SubSchema { properties: sub_props, ..Default::default() }],
5542 ..Default::default()
5543 };
5544 let result = find_prop_schema_deep("Special", &schema);
5545 assert!(result.is_some(), "expected to find Special in oneOf sub-schema");
5546 assert_eq!(result.unwrap().prop_type.as_ref().unwrap().primary(), Some("boolean"));
5547 }
5548
5549 #[test]
5550 fn find_prop_schema_deep_searches_if_then_else() {
5551 let mut then_props = HashMap::new();
5552 then_props.insert(
5553 "ConditionalProp".into(),
5554 PropSchema { prop_type: Some(PropType::Single("string".into())), ..Default::default() },
5555 );
5556 let schema = CompiledSchema {
5557 type_name: "Test".into(),
5558 if_then_else: vec![crate::compiled::IfThenElse {
5559 condition: Default::default(),
5560 then_schema: Some(crate::compiled::SubSchema { properties: then_props, ..Default::default() }),
5561 else_schema: None,
5562 enforce_full_branch: false,
5563 }],
5564 ..Default::default()
5565 };
5566 assert!(
5567 find_prop_schema_deep("ConditionalProp", &schema).is_some(),
5568 "conditional property should be found via if-then-else"
5569 );
5570 }
5571
5572 #[test]
5573 fn condition_matches_empty_condition_always_true() {
5574 let cond = ConditionSchema::default();
5575 let keys = vec!["A".into(), "B".into()];
5576 let model = Arc::new(SemanticModel::from_bytes(
5577 b"AWSTemplateFormatVersion: '2010-09-09'\nResources:\n R:\n Type: AWS::CloudFormation::WaitConditionHandle"
5578 ).unwrap());
5579 let defs = HashMap::new();
5580 assert!(condition_matches(&cond, &keys, &model, "R", &defs));
5581 }
5582
5583 #[test]
5584 fn condition_matches_required_key_missing() {
5585 let cond = ConditionSchema { required: vec!["MissingKey".into()], ..Default::default() };
5586 let keys = vec!["A".into()];
5587 let model = Arc::new(SemanticModel::from_bytes(
5588 b"AWSTemplateFormatVersion: '2010-09-09'\nResources:\n R:\n Type: AWS::CloudFormation::WaitConditionHandle"
5589 ).unwrap());
5590 let defs = HashMap::new();
5591 assert!(!condition_matches(&cond, &keys, &model, "R", &defs));
5592 }
5593
5594 #[test]
5595 fn schema_value_matches_required_groups_with_null_members_absent() {
5596 let defs = HashMap::new();
5597 let required_or = PropSchema { required_or: vec!["A".into(), "B".into()], ..Default::default() };
5598 assert!(schema_value_matches(&json!({ "A": 1 }), &required_or, &defs, 0));
5599 assert!(!schema_value_matches(&json!({ "A": null }), &required_or, &defs, 0));
5600
5601 let required_xor = PropSchema { required_xor: vec!["A".into(), "B".into()], ..Default::default() };
5602 assert!(schema_value_matches(&json!({ "A": 1, "B": null }), &required_xor, &defs, 0));
5603 assert!(!schema_value_matches(&json!({ "A": 1, "B": 2 }), &required_xor, &defs, 0));
5604 assert!(!schema_value_matches(&json!({ "A": null, "B": null }), &required_xor, &defs, 0));
5605 }
5606
5607 #[test]
5608 fn gather_condition_matches_empty_true() {
5609 let cond = json!({});
5610 let ctx = json!({"a": 1});
5611 assert!(gather_condition_matches(&cond, &ctx));
5612 }
5613
5614 #[test]
5615 fn gather_condition_matches_required_present() {
5616 let cond = json!({"required": ["a"]});
5617 let ctx = json!({"a": 1});
5618 assert!(gather_condition_matches(&cond, &ctx));
5619 }
5620
5621 #[test]
5622 fn gather_condition_matches_required_missing() {
5623 let cond = json!({"required": ["b"]});
5624 let ctx = json!({"a": 1});
5625 assert!(!gather_condition_matches(&cond, &ctx));
5626 }
5627
5628 #[test]
5629 fn gather_prop_matches_const() {
5630 assert!(gather_prop_matches(&json!("tcp"), &json!({"const": "tcp"})));
5631 assert!(!gather_prop_matches(&json!("udp"), &json!({"const": "tcp"})));
5632 }
5633
5634 #[test]
5635 fn gather_prop_matches_enum() {
5636 assert!(gather_prop_matches(&json!("a"), &json!({"enum": ["a", "b"]})));
5637 assert!(!gather_prop_matches(&json!("c"), &json!({"enum": ["a", "b"]})));
5638 }
5639
5640 #[test]
5641 fn gather_prop_matches_nested_properties() {
5642 let actual = json!({"inner": {"key": "val"}});
5643 let constraint = json!({"properties": {"inner": {"properties": {"key": {"const": "val"}}}}});
5644 assert!(gather_prop_matches(&actual, &constraint));
5645 }
5646
5647 fn property_names(prefix: &str, count: usize) -> Vec<String> {
5648 (0..count).map(|index| format!("{prefix}{index}")).collect()
5649 }
5650
5651 #[test]
5652 fn required_property_combinations_accept_exact_cardinality_boundary() {
5653 let branch = SubSchema {
5654 required_or: property_names("Or", 16),
5655 required_xor: property_names("Xor", 16),
5656 ..Default::default()
5657 };
5658 let (combinations, curtailed) = required_property_combinations(&branch, &HashMap::new(), None);
5659 assert_eq!(combinations.len(), MAX_REQUIRED_PROPERTY_COMBINATIONS);
5660 assert!(!curtailed, "exactly 16 × 16 combinations must be retained in full");
5661 }
5662
5663 #[test]
5664 fn required_property_combinations_report_one_over_boundary() {
5665 let branch = SubSchema {
5666 required_or: property_names("Or", 17),
5667 required_xor: property_names("Xor", 16),
5668 ..Default::default()
5669 };
5670 let (combinations, curtailed) = required_property_combinations(&branch, &HashMap::new(), None);
5671 assert_eq!(combinations.len(), MAX_REQUIRED_PROPERTY_COMBINATIONS);
5672 assert!(curtailed, "17 × 16 combinations must visibly hit the explanation limit");
5673 }
5674}