1use crate::compiled::{CompiledSchema, ConditionSchema, MAX_REF_CHAIN, PropSchema};
107use data_source::compiled_schema::compile_schema;
108use data_source::compiled_schema::keywords;
109use log::warn;
110use serde_json::Value;
111use std::collections::HashMap;
112use std::collections::hash_map::Entry;
113use std::error::Error;
114use std::fmt;
115
116const REF_ANNOTATION_KEYWORDS: &[&str] = keywords::REF_ANNOTATION_KEYWORDS;
122
123const UNREPRESENTED_CONSTRAINT_KEYWORDS: &[&str] = keywords::UNREPRESENTED_VALIDATION_KEYWORDS;
131
132const COMPOSITION_ALLOWED_FIELDS: &[&str] = &[
138 keywords::REF,
139 keywords::TYPE,
140 keywords::ENUM,
141 keywords::ENUM_CASE_INSENSITIVE,
142 keywords::NOT,
143 keywords::CONST,
144 keywords::PATTERN,
145 keywords::FORMAT,
146 keywords::MINIMUM,
147 keywords::MAXIMUM,
148 keywords::EXCLUSIVE_MINIMUM,
149 keywords::EXCLUSIVE_MAXIMUM,
150 keywords::MULTIPLE_OF,
151 keywords::MIN_LENGTH,
152 keywords::MAX_LENGTH,
153 keywords::MIN_ITEMS,
154 keywords::MAX_ITEMS,
155 keywords::UNIQUE_ITEMS,
156 keywords::MIN_PROPERTIES,
157 keywords::MAX_PROPERTIES,
158 keywords::REQUIRED,
159 keywords::PROPERTIES,
160 keywords::ADDITIONAL_PROPERTIES,
161 keywords::PATTERN_PROPERTIES,
162 keywords::ITEMS,
163 keywords::ALL_OF,
164 keywords::ANY_OF,
165 keywords::ONE_OF,
166 keywords::DEPENDENT_REQUIRED,
167 keywords::DEPENDENT_EXCLUDED,
168 keywords::REQUIRED_OR,
169 keywords::REQUIRED_XOR,
170 keywords::IF,
171 keywords::THEN,
172 keywords::ELSE,
173 keywords::DEPENDENCIES,
174];
175
176const CONDITION_ALLOWED_FIELDS: [&str; 3] = [keywords::PROPERTIES, keywords::REQUIRED, keywords::TYPE];
182
183const CONDITION_PROPERTY_ALLOWED_FIELDS: [&str; 13] = [
188 keywords::REF,
189 keywords::TYPE,
190 keywords::ENUM,
191 keywords::NOT,
192 keywords::CONST,
193 keywords::PATTERN,
194 keywords::REQUIRED,
195 keywords::MIN_ITEMS,
196 keywords::MAX_ITEMS,
197 keywords::MIN_LENGTH,
198 keywords::MAX_LENGTH,
199 keywords::MIN_PROPERTIES,
200 keywords::MAX_PROPERTIES,
201];
202
203const SUPPORTED_PROPERTY_TYPES: [&str; 9] =
205 ["string", "integer", "number", "double", "float", "boolean", "array", "object", "null"];
206
207const CONDITIONAL_THEN_ELSE_ALLOWED_FIELDS: &[&str] = COMPOSITION_ALLOWED_FIELDS;
211
212pub const MAX_OVERLAY_DEPTH: usize = 128;
221
222#[derive(Debug, Clone, PartialEq, Eq)]
228pub enum SchemaOverlayError {
229 MissingTypeName,
231 NotAnObject { type_name: String },
233 TooDeep { type_name: String, max_depth: usize },
235 Unsupported { type_name: String, path: String, detail: String },
238 NoEffect { type_name: String },
241 CyclicRef { type_name: String, cycle: Vec<String> },
243 RefChainTooLong { type_name: String, definition: String, hops: usize },
246 ConflictingEnums { type_name: String, property: String },
249 DanglingRef { type_name: String, path: String, target: String },
253}
254
255impl fmt::Display for SchemaOverlayError {
256 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257 match self {
258 SchemaOverlayError::MissingTypeName => write!(
259 f,
260 "Additional schema is missing a resource type name (no explicit type name and none in the schema)"
261 ),
262 SchemaOverlayError::NotAnObject { type_name } => write!(
263 f,
264 "Invalid additional schema for '{type_name}': expected a JSON object describing a CloudFormation \
265 resource provider schema"
266 ),
267 SchemaOverlayError::TooDeep { type_name, max_depth } => write!(
268 f,
269 "Invalid additional schema for '{type_name}': nested deeper than the supported limit of {max_depth} \
270 levels"
271 ),
272 SchemaOverlayError::Unsupported { type_name, path, detail } => write!(
273 f,
274 "Invalid additional schema for '{type_name}': {detail} at '{path}'. Applying it would validate less \
275 than the schema states"
276 ),
277 SchemaOverlayError::NoEffect { type_name } => write!(
278 f,
279 "Invalid additional schema for '{type_name}': the schema states no properties, definitions or \
280 constraints, so applying it would have no effect"
281 ),
282 SchemaOverlayError::CyclicRef { type_name, cycle } => write!(
283 f,
284 "Invalid additional schema for '{type_name}': definitions reference each other in a cycle ({})",
285 cycle.join(" -> ")
286 ),
287 SchemaOverlayError::RefChainTooLong { type_name, definition, hops } => write!(
288 f,
289 "Invalid additional schema for '{type_name}': the '$ref' chain starting at definition \
290 '{definition}' is {hops} hops long, more than the {MAX_DEFINITION_REF_CHAIN} that can be resolved, \
291 so the constraints at the end of it would not be enforced"
292 ),
293 SchemaOverlayError::ConflictingEnums { type_name, property } => write!(
294 f,
295 "Invalid additional schema for '{type_name}': property '{property}' would carry both a case-sensitive \
296 and a case-insensitive list of allowed values"
297 ),
298 SchemaOverlayError::DanglingRef { type_name, path, target } => write!(
299 f,
300 "Invalid additional schema for '{type_name}': after every overlay was applied, '{path}' still \
301 references '#/definitions/{target}', which no overlay defines, so nothing would constrain it"
302 ),
303 }
304 }
305}
306
307impl Error for SchemaOverlayError {}
308
309pub(crate) fn compile(type_name: &str, raw: &Value) -> Result<CompiledSchema, SchemaOverlayError> {
318 if type_name.trim().is_empty() {
319 return Err(SchemaOverlayError::MissingTypeName);
320 }
321 if type_name != type_name.trim() {
322 return Err(SchemaOverlayError::Unsupported {
323 type_name: type_name.to_string(),
324 path: String::new(),
325 detail: "type name has leading or trailing whitespace".to_string(),
326 });
327 }
328 if let Some(declared_type_name) = raw.get(keywords::TYPE_NAME) {
329 let in_schema = declared_type_name.as_str().ok_or_else(|| SchemaOverlayError::Unsupported {
330 type_name: type_name.to_string(),
331 path: keywords::TYPE_NAME.to_string(),
332 detail: "'typeName' must be a string".to_string(),
333 })?;
334 if in_schema != in_schema.trim() {
335 return Err(SchemaOverlayError::Unsupported {
336 type_name: type_name.to_string(),
337 path: keywords::TYPE_NAME.to_string(),
338 detail: "type name has leading or trailing whitespace".to_string(),
339 });
340 }
341 if !in_schema.is_empty() && in_schema != type_name {
342 return Err(SchemaOverlayError::Unsupported {
343 type_name: type_name.to_string(),
344 path: keywords::TYPE_NAME.to_string(),
345 detail: format!(
346 "the schema declares typeName '{in_schema}' but was submitted as '{type_name}'; remove one or make them match"
347 ),
348 });
349 }
350 }
351 if !raw.is_object() {
352 return Err(SchemaOverlayError::NotAnObject { type_name: type_name.to_string() });
353 }
354 check_depth(type_name, raw)?;
355 check_supported(type_name, raw)?;
356 let mut compiled: CompiledSchema = compile_schema(type_name, raw).into();
357 compiled.required_present = raw.get(keywords::REQUIRED).is_some();
360 propagate_required_present(raw, &mut compiled);
363 mark_conditionals_for_full_enforcement(&mut compiled);
367 if states_nothing(&compiled) {
368 return Err(SchemaOverlayError::NoEffect { type_name: type_name.to_string() });
369 }
370 Ok(compiled)
371}
372
373fn mark_conditionals_for_full_enforcement(schema: &mut CompiledSchema) {
377 for ite in &mut schema.if_then_else {
378 mark_ite(ite);
379 }
380 for prop in schema.properties.values_mut().chain(schema.definitions.values_mut()) {
381 mark_prop_conditionals(prop);
382 }
383 for branch in schema.all_of.iter_mut().chain(schema.any_of.iter_mut()).chain(schema.one_of.iter_mut()) {
384 mark_prop_conditionals(branch);
385 }
386}
387
388fn mark_ite(ite: &mut crate::compiled::IfThenElse) {
389 ite.enforce_full_branch = true;
390 for branch in ite.then_schema.iter_mut().chain(ite.else_schema.iter_mut()) {
391 mark_prop_conditionals(branch);
392 }
393}
394
395fn mark_prop_conditionals(prop: &mut PropSchema) {
396 for ite in &mut prop.if_then_else {
397 mark_ite(ite);
398 }
399 for child in prop.properties.values_mut().chain(prop.pattern_properties.values_mut()) {
400 mark_prop_conditionals(child);
401 }
402 if let Some(items) = prop.items.as_mut() {
403 mark_prop_conditionals(items);
404 }
405 for branch in prop.all_of.iter_mut().chain(prop.any_of.iter_mut()).chain(prop.one_of.iter_mut()) {
406 mark_prop_conditionals(branch);
407 }
408}
409
410fn states_nothing(schema: &CompiledSchema) -> bool {
419 let CompiledSchema {
420 type_name: _,
421 properties,
422 definitions,
423 required,
424 required_present,
425 additional_properties,
426 read_only_properties,
427 write_only_properties,
428 create_only_properties,
429 deprecated_properties,
430 conditional_create_only_properties,
431 primary_identifier,
432 replacement_strategy: _,
433 documentation_url: _,
434 source_url: _,
435 description: _,
436 all_of,
437 any_of,
438 one_of,
439 if_then_else,
440 dependent_required,
441 dependent_excluded,
442 required_or,
443 required_xor,
444 } = schema;
445 properties.is_empty()
446 && definitions.is_empty()
447 && required.is_empty()
448 && !required_present
449 && additional_properties.is_none()
450 && read_only_properties.is_empty()
451 && write_only_properties.is_empty()
452 && create_only_properties.is_empty()
453 && deprecated_properties.is_empty()
454 && conditional_create_only_properties.is_empty()
455 && primary_identifier.is_empty()
456 && all_of.is_empty()
457 && any_of.is_empty()
458 && one_of.is_empty()
459 && if_then_else.is_empty()
460 && dependent_required.is_empty()
461 && dependent_excluded.is_empty()
462 && required_or.is_empty()
463 && required_xor.is_empty()
464}
465
466fn propagate_required_present(raw: &Value, compiled: &mut CompiledSchema) {
470 fn mark_prop_map(raw_map: Option<&Value>, compiled_map: &mut HashMap<String, PropSchema>) {
471 let Some(raw_obj) = raw_map.and_then(Value::as_object) else {
472 return;
473 };
474 for (name, raw_prop) in raw_obj {
475 if let Some(compiled_prop) = compiled_map.get_mut(name) {
476 mark_prop(raw_prop, compiled_prop);
477 }
478 }
479 }
480
481 fn mark_prop(raw: &Value, compiled: &mut PropSchema) {
482 if raw.get(keywords::REQUIRED).is_some() {
483 compiled.required_present = true;
484 }
485 mark_prop_map(raw.get(keywords::PROPERTIES), &mut compiled.properties);
486 mark_prop_map(raw.get(keywords::PATTERN_PROPERTIES), &mut compiled.pattern_properties);
487 if let Some(raw_items) = raw.get(keywords::ITEMS)
488 && let Some(compiled_items) = compiled.items.as_mut()
489 {
490 mark_prop(raw_items, compiled_items);
491 }
492 }
493
494 mark_prop_map(raw.get(keywords::PROPERTIES), &mut compiled.properties);
495 mark_prop_map(raw.get(keywords::DEFINITIONS), &mut compiled.definitions);
496}
497
498fn check_depth(type_name: &str, raw: &Value) -> Result<(), SchemaOverlayError> {
503 let mut stack = vec![(raw, 1usize)];
504 while let Some((value, depth)) = stack.pop() {
505 if depth > MAX_OVERLAY_DEPTH {
506 return Err(SchemaOverlayError::TooDeep { type_name: type_name.to_string(), max_depth: MAX_OVERLAY_DEPTH });
507 }
508 match value {
509 Value::Object(members) => stack.extend(members.values().map(|child| (child, depth + 1))),
510 Value::Array(items) => stack.extend(items.iter().map(|child| (child, depth + 1))),
511 _ => {}
512 }
513 }
514 Ok(())
515}
516
517fn check_supported(type_name: &str, raw: &Value) -> Result<(), SchemaOverlayError> {
527 let reject = |path: &str, detail: &str| SchemaOverlayError::Unsupported {
528 type_name: type_name.to_string(),
529 path: path.to_string(),
530 detail: detail.to_string(),
531 };
532
533 let mut stack: Vec<(String, &Value)> = Vec::new();
534 push_schema_children(String::new(), raw, &mut stack);
535
536 if let Some(root_obj) = raw.as_object() {
538 validate_keyword_types(type_name, "", root_obj)?;
539 reject_unrepresented_keywords(type_name, "", root_obj)?;
540 reject_direct_if_then_else(type_name, "", root_obj)?;
541 }
542
543 while let Some((path, value)) = stack.pop() {
544 let Some(members) = value.as_object() else {
545 continue;
546 };
547 if let Some(reference) = members.get(keywords::REF) {
548 match reference.as_str() {
549 Some(target) if target.starts_with(keywords::DEFINITIONS_REF_PREFIX) => {}
550 Some(target) => {
551 return Err(reject(&path, &format!("'$ref' target '{target}' is not '#/definitions/<name>'")));
552 }
553 None => return Err(reject(&path, "'$ref' must be a string")),
554 }
555 let unrepresented_siblings: Vec<&str> = members
561 .keys()
562 .map(String::as_str)
563 .filter(|key| {
564 *key != keywords::REF
565 && !REF_ANNOTATION_KEYWORDS.contains(key)
566 && UNREPRESENTED_CONSTRAINT_KEYWORDS.contains(key)
567 })
568 .collect();
569 if !unrepresented_siblings.is_empty() {
570 return Err(reject(
571 &path,
572 &format!(
573 "'{}' beside a '$ref' has no representation in the compiled schema model",
574 unrepresented_siblings.join("', '")
575 ),
576 ));
577 }
578 }
579 if let Some(items) = members.get(keywords::ITEMS)
580 && !items.is_object()
581 {
582 return Err(reject(&path, "'items' must be a single schema object; tuple form is not supported"));
583 }
584 if !members.contains_key(keywords::REF) {
585 reject_unrepresented_keywords(type_name, &path, members)?;
586 }
587 if let Some(prop_type) = members.get(keywords::TYPE) {
588 let type_names: Vec<&str> = match prop_type {
589 Value::String(name) => vec![name.as_str()],
590 Value::Array(names) if !names.is_empty() => names
591 .iter()
592 .map(|name| name.as_str())
593 .collect::<Option<Vec<_>>>()
594 .ok_or_else(|| reject(&path, "'type' must be a string or an array of strings"))?,
595 _ => return Err(reject(&path, "'type' must be a string or a non-empty array of strings")),
596 };
597 if let Some(unsupported) = type_names.into_iter().find(|name| {
598 !(SUPPORTED_PROPERTY_TYPES.contains(name) || (path.is_empty() && ["RESOURCE", "object"].contains(name)))
599 }) {
600 return Err(reject(&path, &format!("unsupported property type '{unsupported}'")));
601 }
602 }
603 validate_keyword_types(type_name, &path, members)?;
605 reject_direct_if_then_else(type_name, &path, members)?;
607 push_schema_children(path, value, &mut stack);
608 }
609 Ok(())
610}
611
612fn validate_keyword_types(
618 type_name: &str,
619 path: &str,
620 members: &serde_json::Map<String, Value>,
621) -> Result<(), SchemaOverlayError> {
622 let reject = |detail: &str| SchemaOverlayError::Unsupported {
623 type_name: type_name.to_string(),
624 path: path.to_string(),
625 detail: detail.to_string(),
626 };
627
628 for keyword in keywords::STRING_ARRAY_CONSTRAINTS {
630 if let Some(val) = members.get(*keyword) {
631 if let Some(arr) = val.as_array() {
632 for item in arr {
633 if !item.is_string() {
634 return Err(reject(&format!("'{keyword}' must be an array of strings")));
635 }
636 }
637 } else {
638 return Err(reject(&format!("'{keyword}' must be an array")));
639 }
640 }
641 }
642
643 for keyword in keywords::METADATA_POINTER_ARRAYS {
645 if let Some(value) = members.get(*keyword) {
646 let paths = value.as_array().ok_or_else(|| reject(&format!("'{keyword}' must be an array")))?;
647 for path_value in paths {
648 let property_path =
649 path_value.as_str().ok_or_else(|| reject(&format!("'{keyword}' must be an array of strings")))?;
650 if !property_path.starts_with(keywords::PROPERTIES_PATH_PREFIX)
651 || property_path.len() == keywords::PROPERTIES_PATH_PREFIX.len()
652 {
653 return Err(reject(&format!("'{keyword}' entries must be JSON pointers below '/properties/'")));
654 }
655 }
656 }
657 }
658
659 for keyword in keywords::ENUM_KEYWORDS {
660 if let Some(value) = members.get(*keyword) {
661 let values = value.as_array().ok_or_else(|| reject(&format!("'{keyword}' must be an array")))?;
662 if values.is_empty() {
663 return Err(reject(&format!("'{keyword}' must contain at least one value")));
664 }
665 }
666 }
667 if let Some(negated) = members.get(keywords::NOT) {
668 let negated_members =
669 negated.as_object().ok_or_else(|| reject("'not' must be an object containing an enum"))?;
670 let unsupported: Vec<&str> = negated_members
671 .keys()
672 .map(String::as_str)
673 .filter(|key| *key != keywords::ENUM && !REF_ANNOTATION_KEYWORDS.contains(key))
674 .collect();
675 if !unsupported.is_empty() {
676 return Err(reject("'not' is only supported when it contains an enum"));
677 }
678 let values = negated_members
679 .get(keywords::ENUM)
680 .and_then(Value::as_array)
681 .ok_or_else(|| reject("'not.enum' must be an array"))?;
682 if values.is_empty() {
683 return Err(reject("'not.enum' must contain at least one value"));
684 }
685 }
686
687 for keyword in keywords::DEPENDENCY_MAPS {
689 if let Some(val) = members.get(*keyword) {
690 if let Some(obj) = val.as_object() {
691 for (key, deps) in obj {
692 if let Some(arr) = deps.as_array() {
693 for item in arr {
694 if !item.is_string() {
695 return Err(reject(&format!("'{keyword}.{key}' must be an array of strings")));
696 }
697 }
698 } else {
699 return Err(reject(&format!("'{keyword}.{key}' must be an array")));
700 }
701 }
702 } else {
703 return Err(reject(&format!("'{keyword}' must be an object")));
704 }
705 }
706 }
707
708 for keyword in keywords::NUMERIC_CONSTRAINTS {
710 if let Some(val) = members.get(*keyword)
711 && !val.is_number()
712 {
713 return Err(reject(&format!("'{keyword}' must be a number")));
714 }
715 }
716
717 if let Some(val) = members.get(keywords::MULTIPLE_OF) {
719 if !val.is_number() {
720 return Err(reject("'multipleOf' must be a number"));
721 }
722 if val.as_f64().is_some_and(|n| n <= 0.0) {
723 return Err(reject("'multipleOf' must be a positive number"));
724 }
725 }
726
727 if let Some(val) = members.get(keywords::DEPENDENCIES) {
730 let obj = val.as_object().ok_or_else(|| reject("'dependencies' must be an object"))?;
731 for (key, dep_value) in obj {
732 if dep_value.is_array() {
733 let arr = dep_value.as_array().expect("confirmed array");
735 for item in arr {
736 if !item.is_string() {
737 return Err(reject(&format!("'dependencies.{key}' array items must be strings")));
738 }
739 }
740 } else if dep_value.is_object() {
741 return Err(reject(&format!(
743 "'dependencies.{key}' is a schema-form dependency which has no representation in the compiled model"
744 )));
745 } else {
746 return Err(reject(&format!("'dependencies.{key}' must be an array or object")));
747 }
748 }
749 }
750
751 for keyword in keywords::U64_CONSTRAINTS {
753 if let Some(value) = members.get(*keyword)
754 && value.as_u64().is_none()
755 {
756 return Err(reject(&format!("'{keyword}' must be a non-negative integer")));
757 }
758 }
759
760 for keyword in keywords::BOOL_CONSTRAINTS {
762 if let Some(val) = members.get(*keyword)
763 && !val.is_boolean()
764 {
765 return Err(reject(&format!("'{keyword}' must be a boolean")));
766 }
767 }
768
769 for keyword in keywords::STRING_CONSTRAINTS {
771 if let Some(val) = members.get(*keyword)
772 && !val.is_string()
773 {
774 return Err(reject(&format!("'{keyword}' must be a string")));
775 }
776 }
777
778 if let Some(Value::String(pattern)) = members.get(keywords::PATTERN)
780 && !template_model::pattern::is_service_valid(pattern)
781 {
782 return Err(reject(&format!("'pattern' contains an invalid regex: {pattern}")));
783 }
784
785 for keyword in keywords::SCHEMA_MAPS {
787 if let Some(val) = members.get(*keyword) {
788 if let Some(obj) = val.as_object() {
789 for (name, child) in obj {
790 if !child.is_object() {
791 return Err(reject(&format!("'{keyword}.{name}' must be a JSON object")));
792 }
793 }
794 } else {
795 return Err(reject(&format!("'{keyword}' must be a JSON object")));
796 }
797 }
798 }
799
800 if let Some(Value::Object(pat_props)) = members.get(keywords::PATTERN_PROPERTIES) {
802 for key in pat_props.keys() {
803 if !template_model::pattern::is_service_valid(key) {
804 return Err(reject(&format!("'patternProperties' key '{key}' is not a valid regex")));
805 }
806 }
807 }
808
809 if let Some(val) = members.get(keywords::ITEMS)
811 && !val.is_object()
812 {
813 return Err(reject("'items' must be a single schema object; tuple form is not supported"));
814 }
815
816 for keyword in keywords::COMPOSITION {
818 if let Some(val) = members.get(*keyword) {
819 if let Some(arr) = val.as_array() {
820 for (i, entry) in arr.iter().enumerate() {
821 if !entry.is_object() {
822 return Err(reject(&format!("'{keyword}[{i}]' must be a JSON object")));
823 }
824 }
825 validate_composition_entries(type_name, path, keyword, arr)?;
827 } else {
828 return Err(reject(&format!("'{keyword}' must be an array")));
829 }
830 }
831 }
832
833 Ok(())
834}
835
836fn validate_composition_entries(
842 type_name: &str,
843 path: &str,
844 keyword: &str,
845 entries: &[Value],
846) -> Result<(), SchemaOverlayError> {
847 let reject = |detail: &str| SchemaOverlayError::Unsupported {
848 type_name: type_name.to_string(),
849 path: path.to_string(),
850 detail: detail.to_string(),
851 };
852
853 for (index, entry) in entries.iter().enumerate() {
854 let Some(entry_members) = entry.as_object() else {
855 continue;
856 };
857
858 if keyword == keywords::ALL_OF && entry_members.contains_key(keywords::IF) {
859 validate_conditional_allof_entry(type_name, path, index, entry_members)?;
860 continue;
861 }
862
863 let unsupported: Vec<&str> = entry_members
864 .keys()
865 .map(String::as_str)
866 .filter(|key| {
867 !COMPOSITION_ALLOWED_FIELDS.contains(key)
868 && !REF_ANNOTATION_KEYWORDS.contains(key)
869 && *key != keywords::DESCRIPTION
870 })
871 .collect();
872 if !unsupported.is_empty() {
873 return Err(reject(&format!(
874 "'{keyword}[{index}]' contains fields not supported in composition branches: '{}'",
875 unsupported.join("', '")
876 )));
877 }
878 }
879 Ok(())
880}
881
882fn validate_conditional_allof_entry(
889 type_name: &str,
890 path: &str,
891 index: usize,
892 entry_members: &serde_json::Map<String, Value>,
893) -> Result<(), SchemaOverlayError> {
894 let reject = |detail: &str| SchemaOverlayError::Unsupported {
895 type_name: type_name.to_string(),
896 path: path.to_string(),
897 detail: detail.to_string(),
898 };
899
900 let unsupported_entry_fields: Vec<&str> = entry_members
901 .keys()
902 .map(String::as_str)
903 .filter(|key| !keywords::CONDITIONALS.contains(key) && !REF_ANNOTATION_KEYWORDS.contains(key))
904 .collect();
905 if !unsupported_entry_fields.is_empty() {
906 return Err(reject(&format!(
907 "'allOf[{index}]' contains unsupported conditional fields: '{}'",
908 unsupported_entry_fields.join("', '")
909 )));
910 }
911
912 let condition = entry_members
913 .get(keywords::IF)
914 .and_then(Value::as_object)
915 .ok_or_else(|| reject(&format!("'allOf[{index}].if' must be a JSON object")))?;
916 let unsupported_condition_fields: Vec<&str> = condition
917 .keys()
918 .map(String::as_str)
919 .filter(|key| !CONDITION_ALLOWED_FIELDS.contains(key) && !REF_ANNOTATION_KEYWORDS.contains(key))
920 .collect();
921 if !unsupported_condition_fields.is_empty() {
922 return Err(reject(&format!(
923 "'allOf[{index}].if' contains fields the conditional matcher does not enforce: '{}'",
924 unsupported_condition_fields.join("', '")
925 )));
926 }
927 if let Some(properties) = condition.get(keywords::PROPERTIES).and_then(Value::as_object) {
928 for (property_name, property_schema) in properties {
929 let property_members = property_schema.as_object().ok_or_else(|| {
930 reject(&format!("'allOf[{index}].if.properties.{property_name}' must be a JSON object"))
931 })?;
932 let unsupported_property_fields: Vec<&str> = property_members
933 .keys()
934 .map(String::as_str)
935 .filter(|key| {
936 !CONDITION_PROPERTY_ALLOWED_FIELDS.contains(key) && !REF_ANNOTATION_KEYWORDS.contains(key)
937 })
938 .collect();
939 if !unsupported_property_fields.is_empty() {
940 return Err(reject(&format!(
941 "'allOf[{index}].if.properties.{property_name}' contains fields the conditional matcher does not enforce: '{}'",
942 unsupported_property_fields.join("', '")
943 )));
944 }
945 }
946 }
947
948 if !entry_members.contains_key(keywords::THEN) && !entry_members.contains_key(keywords::ELSE) {
949 return Err(reject(&format!("'allOf[{index}]' must contain 'then' or 'else'")));
950 }
951 for branch_name in [keywords::THEN, keywords::ELSE] {
952 let Some(branch_value) = entry_members.get(branch_name) else {
953 continue;
954 };
955 let branch = branch_value
956 .as_object()
957 .ok_or_else(|| reject(&format!("'allOf[{index}].{branch_name}' must be a JSON object")))?;
958 let unsupported: Vec<&str> = branch
959 .keys()
960 .map(String::as_str)
961 .filter(|key| {
962 !CONDITIONAL_THEN_ELSE_ALLOWED_FIELDS.contains(key)
963 && !REF_ANNOTATION_KEYWORDS.contains(key)
964 && *key != keywords::DESCRIPTION
965 })
966 .collect();
967 if !unsupported.is_empty() {
968 return Err(reject(&format!(
969 "'allOf[{index}].{branch_name}' contains unsupported fields: '{}'",
970 unsupported.join("', '")
971 )));
972 }
973 }
974 Ok(())
975}
976
977fn reject_direct_if_then_else(
983 type_name: &str,
984 path: &str,
985 members: &serde_json::Map<String, Value>,
986) -> Result<(), SchemaOverlayError> {
987 for keyword in keywords::CONDITIONALS {
988 if members.contains_key(*keyword) && !members.contains_key(keywords::REF) {
989 if path.is_empty() {
996 return Err(SchemaOverlayError::Unsupported {
997 type_name: type_name.to_string(),
998 path: path.to_string(),
999 detail: format!(
1000 "standalone '{keyword}' is not supported at the root level; place conditional logic \
1001 inside an allOf array or at property level"
1002 ),
1003 });
1004 }
1005 }
1006 }
1007 Ok(())
1008}
1009
1010fn reject_unrepresented_keywords(
1015 type_name: &str,
1016 path: &str,
1017 members: &serde_json::Map<String, Value>,
1018) -> Result<(), SchemaOverlayError> {
1019 let unrepresented: Vec<&str> =
1020 members.keys().map(String::as_str).filter(|key| UNREPRESENTED_CONSTRAINT_KEYWORDS.contains(key)).collect();
1021 if !unrepresented.is_empty() {
1022 return Err(SchemaOverlayError::Unsupported {
1023 type_name: type_name.to_string(),
1024 path: path.to_string(),
1025 detail: format!(
1026 "'{}' has no representation in the compiled schema model and would silently weaken the overlay",
1027 unrepresented.join("', '")
1028 ),
1029 });
1030 }
1031 if members.get(keywords::NOT).is_some_and(|negated| negated.get(keywords::ENUM).is_none()) {
1032 return Err(SchemaOverlayError::Unsupported {
1033 type_name: type_name.to_string(),
1034 path: path.to_string(),
1035 detail: "'not' is only enforced when it contains an 'enum'; this form would silently \
1036 weaken the overlay"
1037 .to_string(),
1038 });
1039 }
1040 Ok(())
1041}
1042
1043fn push_schema_children<'a>(path: String, value: &'a Value, stack: &mut Vec<(String, &'a Value)>) {
1045 let Some(members) = value.as_object() else {
1046 return;
1047 };
1048 let child_path = |suffix: &str| if path.is_empty() { suffix.to_string() } else { format!("{path}.{suffix}") };
1049
1050 for keyword in keywords::SCHEMA_MAPS {
1051 if let Some(map) = members.get(*keyword).and_then(Value::as_object) {
1052 for (name, child) in map {
1053 stack.push((child_path(&format!("{keyword}.{name}")), child));
1054 }
1055 }
1056 }
1057 if let Some(items) = members.get(keywords::ITEMS) {
1058 stack.push((child_path("items"), items));
1059 }
1060 for keyword in keywords::COMPOSITION {
1061 if let Some(entries) = members.get(*keyword).and_then(Value::as_array) {
1062 for (index, entry) in entries.iter().enumerate() {
1063 stack.push((child_path(&format!("{keyword}[{index}]")), entry));
1064 }
1065 }
1066 }
1067 for keyword in keywords::CONDITIONALS {
1068 if let Some(branch) = members.get(*keyword) {
1069 stack.push((child_path(keyword), branch));
1070 }
1071 }
1072}
1073
1074pub(crate) fn validate_schema(schema: &CompiledSchema) -> Result<(), SchemaOverlayError> {
1080 match find_ref_chain_defect(&schema.definitions) {
1081 Some(RefChainDefect::Cycle(cycle)) => {
1082 return Err(SchemaOverlayError::CyclicRef { type_name: schema.type_name.clone(), cycle });
1083 }
1084 Some(RefChainDefect::TooLong { start, hops }) => {
1085 return Err(SchemaOverlayError::RefChainTooLong {
1086 type_name: schema.type_name.clone(),
1087 definition: start,
1088 hops,
1089 });
1090 }
1091 None => {}
1092 }
1093 let mut conflicts: Vec<String> = Vec::new();
1096 for (name, prop) in schema.properties.iter().chain(schema.definitions.iter()) {
1097 collect_conflicting_enums(name, prop, &mut conflicts);
1098 }
1099 for sub in schema.all_of.iter().chain(schema.any_of.iter()).chain(schema.one_of.iter()) {
1101 for (name, prop) in &sub.properties {
1102 collect_conflicting_enums(&format!("allOf.{name}"), prop, &mut conflicts);
1103 }
1104 }
1105 for ite in &schema.if_then_else {
1106 collect_conflicting_enums_in_condition("if", &ite.condition, &mut conflicts);
1107 if let Some(then_sub) = &ite.then_schema {
1108 for (name, prop) in &then_sub.properties {
1109 collect_conflicting_enums(&format!("then.{name}"), prop, &mut conflicts);
1110 }
1111 }
1112 if let Some(else_sub) = &ite.else_schema {
1113 for (name, prop) in &else_sub.properties {
1114 collect_conflicting_enums(&format!("else.{name}"), prop, &mut conflicts);
1115 }
1116 }
1117 }
1118 conflicts.sort();
1119 if let Some(first) = conflicts.into_iter().next() {
1120 return Err(SchemaOverlayError::ConflictingEnums { type_name: schema.type_name.clone(), property: first });
1121 }
1122 Ok(())
1123}
1124
1125const MAX_DEFINITION_REF_CHAIN: usize = MAX_REF_CHAIN - 1;
1130
1131enum RefChainDefect {
1133 Cycle(Vec<String>),
1135 TooLong { start: String, hops: usize },
1137}
1138
1139fn find_ref_chain_defect(defs: &HashMap<String, PropSchema>) -> Option<RefChainDefect> {
1142 let mut names: Vec<&String> = defs.keys().collect();
1143 names.sort();
1144 for start in names {
1145 let mut chain: Vec<String> = Vec::new();
1146 let mut current = start.clone();
1147 loop {
1148 if chain.contains(¤t) {
1149 chain.push(current);
1150 return Some(RefChainDefect::Cycle(chain));
1151 }
1152 chain.push(current.clone());
1153 match defs.get(¤t).and_then(|def| def.ref_name.clone()) {
1154 Some(next) => current = next,
1155 None => break,
1156 }
1157 if chain.len() > MAX_DEFINITION_REF_CHAIN {
1158 return Some(RefChainDefect::TooLong { start: start.clone(), hops: chain.len() });
1159 }
1160 }
1161 }
1162 None
1163}
1164
1165pub(crate) fn warn_removed_required(base: &CompiledSchema, merged: &CompiledSchema) {
1173 for (path, removed) in removed_required(base, merged) {
1174 let location = if path.is_empty() { "the resource".to_string() } else { format!("'{path}'") };
1175 warn!(
1176 "Additional schema for '{}': the stated 'required' list removes {} from {location}. An overlay that \
1177 states 'required' replaces the previous list; omit the keyword to keep it.",
1178 merged.type_name,
1179 removed.iter().map(|name| format!("'{name}'")).collect::<Vec<_>>().join(", "),
1180 );
1181 }
1182}
1183
1184pub(crate) fn removed_required(base: &CompiledSchema, merged: &CompiledSchema) -> Vec<(String, Vec<String>)> {
1188 let mut removals: Vec<(String, Vec<String>)> = Vec::new();
1189 collect_removed(String::new(), &base.required, &merged.required, &mut removals);
1190
1191 let mut stack: Vec<(String, &PropSchema, &PropSchema)> = Vec::new();
1192 push_shared_children(String::new(), &base.properties, &merged.properties, &mut stack);
1193 push_shared_children("definitions".to_string(), &base.definitions, &merged.definitions, &mut stack);
1194 while let Some((path, before, after)) = stack.pop() {
1195 collect_removed(path.clone(), &before.required, &after.required, &mut removals);
1196 push_shared_children(path.clone(), &before.properties, &after.properties, &mut stack);
1197 push_shared_children(
1198 format!("{path}<patternProperties>"),
1199 &before.pattern_properties,
1200 &after.pattern_properties,
1201 &mut stack,
1202 );
1203 if let (Some(before_items), Some(after_items)) = (&before.items, &after.items) {
1204 stack.push((format!("{path}[]"), before_items, after_items));
1205 }
1206 }
1207
1208 removals.sort();
1209 removals
1210}
1211
1212fn collect_removed(path: String, before: &[String], after: &[String], out: &mut Vec<(String, Vec<String>)>) {
1214 let removed: Vec<String> = before.iter().filter(|name| !after.contains(name)).cloned().collect();
1215 if !removed.is_empty() {
1216 out.push((path, removed));
1217 }
1218}
1219
1220fn push_shared_children<'a>(
1222 path: String,
1223 before: &'a HashMap<String, PropSchema>,
1224 after: &'a HashMap<String, PropSchema>,
1225 stack: &mut Vec<(String, &'a PropSchema, &'a PropSchema)>,
1226) {
1227 for (name, before_prop) in before {
1228 if let Some(after_prop) = after.get(name) {
1229 let child_path = if path.is_empty() { name.clone() } else { format!("{path}.{name}") };
1230 stack.push((child_path, before_prop, after_prop));
1231 }
1232 }
1233}
1234
1235pub(crate) fn warn_dangling_refs(schema: &CompiledSchema) {
1242 for (path, target) in find_dangling_refs(schema) {
1243 warn!(
1244 "Additional schema for '{}': '{path}' references '#/definitions/{target}', which the schema does \
1245 not define, so nothing constrains it (a later overlay may still supply the definition).",
1246 schema.type_name
1247 );
1248 }
1249}
1250
1251pub(crate) fn find_dangling_refs(schema: &CompiledSchema) -> Vec<(String, String)> {
1259 let mut dangling: Vec<(String, String)> = Vec::new();
1260 let mut stack: Vec<(String, &PropSchema)> =
1261 schema.properties.iter().chain(schema.definitions.iter()).map(|(name, prop)| (name.clone(), prop)).collect();
1262 for sub in schema.all_of.iter().chain(schema.any_of.iter()).chain(schema.one_of.iter()) {
1264 for (name, prop) in &sub.properties {
1265 stack.push((format!("allOf.{name}"), prop));
1266 }
1267 }
1268 for ite in &schema.if_then_else {
1269 push_condition_props("if", &ite.condition, &mut stack);
1270 if let Some(then_sub) = &ite.then_schema {
1271 for (name, prop) in &then_sub.properties {
1272 stack.push((format!("then.{name}"), prop));
1273 }
1274 }
1275 if let Some(else_sub) = &ite.else_schema {
1276 for (name, prop) in &else_sub.properties {
1277 stack.push((format!("else.{name}"), prop));
1278 }
1279 }
1280 }
1281 while let Some((path, current)) = stack.pop() {
1282 if let Some(target) = ¤t.ref_name
1283 && !schema.definitions.contains_key(target)
1284 {
1285 dangling.push((path.clone(), target.clone()));
1286 }
1287 for (name, child) in current.properties.iter().chain(current.pattern_properties.iter()) {
1288 stack.push((format!("{path}.{name}"), child));
1289 }
1290 if let Some(items) = ¤t.items {
1291 stack.push((format!("{path}[]"), items));
1292 }
1293 }
1294 dangling.sort();
1295 dangling
1296}
1297
1298fn push_condition_props<'a>(prefix: &str, cond: &'a ConditionSchema, stack: &mut Vec<(String, &'a PropSchema)>) {
1300 for (name, prop) in &cond.properties {
1301 stack.push((format!("{prefix}.{name}"), prop));
1302 }
1303 for (i, sub_cond) in cond.any_of.iter().enumerate() {
1304 push_condition_props(&format!("{prefix}.anyOf[{i}]"), sub_cond, stack);
1305 }
1306}
1307
1308fn collect_conflicting_enums(name: &str, prop: &PropSchema, out: &mut Vec<String>) {
1310 let mut stack = vec![(name.to_string(), prop)];
1311 while let Some((path, current)) = stack.pop() {
1312 if !current.enum_values.is_empty() && !current.enum_case_insensitive.is_empty() {
1313 out.push(path.clone());
1314 }
1315 for (child_name, child) in current.properties.iter().chain(current.pattern_properties.iter()) {
1316 stack.push((format!("{path}.{child_name}"), child));
1317 }
1318 if let Some(items) = current.items.as_deref() {
1319 stack.push((format!("{path}[]"), items));
1320 }
1321 }
1322}
1323
1324fn collect_conflicting_enums_in_condition(prefix: &str, cond: &ConditionSchema, out: &mut Vec<String>) {
1326 for (name, prop) in &cond.properties {
1327 collect_conflicting_enums(&format!("{prefix}.{name}"), prop, out);
1328 }
1329 for (i, sub_cond) in cond.any_of.iter().enumerate() {
1330 collect_conflicting_enums_in_condition(&format!("{prefix}.anyOf[{i}]"), sub_cond, out);
1331 }
1332}
1333
1334pub(crate) fn merge_into(base: &mut CompiledSchema, overlay: CompiledSchema) {
1337 merge_definitions(&mut base.definitions, overlay.definitions);
1338 merge_prop_map(&mut base.properties, overlay.properties);
1339
1340 if overlay.required_present {
1346 base.required = overlay.required;
1347 } else {
1348 union_extend(&mut base.required, overlay.required);
1349 }
1350
1351 replace_if_some(&mut base.additional_properties, overlay.additional_properties);
1352 replace_if_some(&mut base.replacement_strategy, overlay.replacement_strategy);
1353 replace_if_some(&mut base.documentation_url, overlay.documentation_url);
1354 replace_if_some(&mut base.source_url, overlay.source_url);
1355 replace_if_some(&mut base.description, overlay.description);
1356
1357 union_extend(&mut base.read_only_properties, overlay.read_only_properties);
1363 union_extend(&mut base.write_only_properties, overlay.write_only_properties);
1364 union_extend(&mut base.create_only_properties, overlay.create_only_properties);
1365 union_extend(&mut base.deprecated_properties, overlay.deprecated_properties);
1366 union_extend(&mut base.conditional_create_only_properties, overlay.conditional_create_only_properties);
1367 replace_if_present(&mut base.primary_identifier, overlay.primary_identifier);
1370
1371 if !overlay.all_of.is_empty() || !overlay.if_then_else.is_empty() {
1382 base.all_of = overlay.all_of;
1383 base.if_then_else = overlay.if_then_else;
1384 }
1385 replace_if_present(&mut base.any_of, overlay.any_of);
1386 replace_if_present(&mut base.one_of, overlay.one_of);
1387
1388 merge_dependency_map(&mut base.dependent_required, overlay.dependent_required);
1389 merge_dependency_map(&mut base.dependent_excluded, overlay.dependent_excluded);
1390
1391 replace_if_present(&mut base.required_or, overlay.required_or);
1394 replace_if_present(&mut base.required_xor, overlay.required_xor);
1395}
1396
1397fn merge_definitions(base: &mut HashMap<String, PropSchema>, overlay: HashMap<String, PropSchema>) {
1405 for (name, def) in overlay {
1406 match base.entry(name) {
1407 Entry::Occupied(mut existing) => merge_prop(existing.get_mut(), def),
1408 Entry::Vacant(slot) => {
1409 slot.insert(def);
1410 }
1411 }
1412 }
1413}
1414
1415pub(crate) fn merge_prop(base: &mut PropSchema, overlay: PropSchema) {
1417 let mut overlay = overlay;
1418 if overlay.ref_name.is_some() {
1424 base.ref_name = overlay.ref_name.take();
1425 }
1426 if is_no_op(&overlay) {
1427 return;
1428 }
1429
1430 replace_if_some(&mut base.prop_type, overlay.prop_type);
1435 merge_enums(base, overlay.enum_values, overlay.enum_case_insensitive);
1436 replace_if_present(&mut base.not_enum, overlay.not_enum);
1437 replace_if_some(&mut base.const_value, overlay.const_value);
1438 replace_if_some(&mut base.pattern, overlay.pattern);
1439 replace_if_some(&mut base.minimum, overlay.minimum);
1440 replace_if_some(&mut base.maximum, overlay.maximum);
1441 replace_if_some(&mut base.exclusive_minimum, overlay.exclusive_minimum);
1442 replace_if_some(&mut base.exclusive_maximum, overlay.exclusive_maximum);
1443 replace_if_some(&mut base.multiple_of, overlay.multiple_of);
1444 replace_if_some(&mut base.min_length, overlay.min_length);
1445 replace_if_some(&mut base.max_length, overlay.max_length);
1446 replace_if_some(&mut base.min_items, overlay.min_items);
1447 replace_if_some(&mut base.max_items, overlay.max_items);
1448 replace_if_some(&mut base.unique_items, overlay.unique_items);
1449 replace_if_some(&mut base.min_properties, overlay.min_properties);
1450 replace_if_some(&mut base.max_properties, overlay.max_properties);
1451 replace_if_some(&mut base.format, overlay.format);
1452 replace_if_some(&mut base.description, overlay.description);
1453 replace_if_some(&mut base.additional_properties, overlay.additional_properties);
1454
1455 merge_prop_map(&mut base.properties, overlay.properties);
1456 merge_prop_map(&mut base.pattern_properties, overlay.pattern_properties);
1457 if overlay.required_present {
1458 base.required = overlay.required;
1459 } else {
1460 union_extend(&mut base.required, overlay.required);
1461 }
1462
1463 if let Some(overlay_items) = overlay.items {
1464 match base.items.as_mut() {
1465 Some(base_items) => merge_prop(base_items, *overlay_items),
1466 None => base.items = Some(overlay_items),
1467 }
1468 }
1469 replace_if_present(&mut base.all_of, overlay.all_of);
1470 replace_if_present(&mut base.any_of, overlay.any_of);
1471 replace_if_present(&mut base.one_of, overlay.one_of);
1472 replace_if_present(&mut base.if_then_else, overlay.if_then_else);
1473 merge_dependency_map(&mut base.dependent_required, overlay.dependent_required);
1474 merge_dependency_map(&mut base.dependent_excluded, overlay.dependent_excluded);
1475 replace_if_present(&mut base.required_or, overlay.required_or);
1478 replace_if_present(&mut base.required_xor, overlay.required_xor);
1479}
1480
1481fn merge_enums(base: &mut PropSchema, overlay_exact: Vec<Value>, overlay_insensitive: Vec<Value>) {
1492 if !overlay_insensitive.is_empty() {
1493 base.enum_case_insensitive = overlay_insensitive;
1494 base.enum_values.clear();
1495 } else if !overlay_exact.is_empty() {
1496 if base.enum_case_insensitive.is_empty() {
1497 base.enum_values = overlay_exact;
1498 } else {
1499 base.enum_case_insensitive = overlay_exact;
1500 base.enum_values.clear();
1501 }
1502 }
1503}
1504
1505fn merge_prop_map(base: &mut HashMap<String, PropSchema>, overlay: HashMap<String, PropSchema>) {
1506 for (name, prop) in overlay {
1507 match base.get_mut(&name) {
1508 Some(existing) => merge_prop(existing, prop),
1509 None => {
1510 base.insert(name, prop);
1511 }
1512 }
1513 }
1514}
1515
1516fn merge_dependency_map(base: &mut HashMap<String, Vec<String>>, overlay: HashMap<String, Vec<String>>) {
1519 for (trigger, dependencies) in overlay {
1520 union_extend(base.entry(trigger).or_default(), dependencies);
1521 }
1522}
1523
1524fn is_no_op(prop: &PropSchema) -> bool {
1529 let PropSchema {
1530 ref_name,
1531 prop_type,
1532 enum_values,
1533 enum_case_insensitive,
1534 not_enum,
1535 const_value,
1536 pattern,
1537 minimum,
1538 maximum,
1539 exclusive_minimum,
1540 exclusive_maximum,
1541 multiple_of,
1542 min_length,
1543 max_length,
1544 min_items,
1545 max_items,
1546 unique_items,
1547 min_properties,
1548 max_properties,
1549 format,
1550 description,
1551 properties,
1552 required,
1553 required_present,
1554 additional_properties,
1555 pattern_properties,
1556 items,
1557 all_of,
1558 any_of,
1559 one_of,
1560 if_then_else,
1561 dependent_required,
1562 dependent_excluded,
1563 required_or,
1564 required_xor,
1565 } = prop;
1566 ref_name.is_none()
1567 && prop_type.is_none()
1568 && enum_values.is_empty()
1569 && enum_case_insensitive.is_empty()
1570 && not_enum.is_empty()
1571 && const_value.is_none()
1572 && pattern.is_none()
1573 && minimum.is_none()
1574 && maximum.is_none()
1575 && exclusive_minimum.is_none()
1576 && exclusive_maximum.is_none()
1577 && multiple_of.is_none()
1578 && min_length.is_none()
1579 && max_length.is_none()
1580 && min_items.is_none()
1581 && max_items.is_none()
1582 && unique_items.is_none()
1583 && min_properties.is_none()
1584 && max_properties.is_none()
1585 && format.is_none()
1586 && description.is_none()
1587 && properties.is_empty()
1588 && required.is_empty()
1589 && !required_present
1590 && additional_properties.is_none()
1591 && pattern_properties.is_empty()
1592 && items.is_none()
1593 && all_of.is_empty()
1594 && any_of.is_empty()
1595 && one_of.is_empty()
1596 && if_then_else.is_empty()
1597 && dependent_required.is_empty()
1598 && dependent_excluded.is_empty()
1599 && required_or.is_empty()
1600 && required_xor.is_empty()
1601}
1602
1603fn union_extend(base: &mut Vec<String>, extra: Vec<String>) {
1605 for item in extra {
1606 if !base.contains(&item) {
1607 base.push(item);
1608 }
1609 }
1610}
1611
1612fn replace_if_present<T>(base: &mut Vec<T>, overlay: Vec<T>) {
1614 if !overlay.is_empty() {
1615 *base = overlay;
1616 }
1617}
1618
1619fn replace_if_some<T>(base: &mut Option<T>, overlay: Option<T>) {
1621 if overlay.is_some() {
1622 *base = overlay;
1623 }
1624}
1625
1626#[cfg(test)]
1627mod tests {
1628 use super::*;
1629 use serde_json::json;
1630
1631 fn compiled(raw: Value) -> CompiledSchema {
1632 compile("AWS::Test::T", &raw).expect("test schema must compile")
1633 }
1634
1635 #[test]
1636 fn compile_basic_schema() {
1637 let c = compiled(json!({
1638 "properties": {
1639 "Name": { "type": "string" },
1640 "Size": { "type": "integer", "enum": [1, 2, 3] }
1641 },
1642 "required": ["Name"],
1643 "additionalProperties": false
1644 }));
1645 assert_eq!(c.type_name, "AWS::Test::T");
1646 assert!(c.properties.contains_key("Name"));
1647 assert_eq!(c.additional_properties, Some(false));
1648 assert_eq!(c.required, vec!["Name".to_string()]);
1649 assert_eq!(c.properties["Size"].enum_values, vec![json!(1), json!(2), json!(3)]);
1650 }
1651
1652 #[test]
1653 fn compile_ref_and_property_paths() {
1654 let c = compiled(json!({
1655 "properties": { "Cfg": { "$ref": "#/definitions/Config" } },
1656 "definitions": { "Config": { "type": "object" } },
1657 "readOnlyProperties": ["/properties/Arn", "/properties/Nested/Id"],
1658 "primaryIdentifier": ["/properties/Id"]
1659 }));
1660 assert_eq!(c.properties["Cfg"].ref_name.as_deref(), Some("Config"));
1661 assert!(c.definitions.contains_key("Config"));
1662 assert_eq!(c.read_only_properties, vec!["Arn".to_string(), "Nested.Id".to_string()]);
1663 assert_eq!(c.primary_identifier, vec!["Id".to_string()]);
1664 }
1665
1666 #[test]
1667 fn compile_rejects_empty_type_name() {
1668 let error = compile("", &json!({})).expect_err("an empty type name must be rejected");
1669 assert_eq!(error, SchemaOverlayError::MissingTypeName);
1670 }
1671
1672 #[test]
1673 fn compile_rejects_non_object() {
1674 let error = compile("AWS::Test::T", &json!(42)).expect_err("a non-object schema must be rejected");
1675 assert!(matches!(error, SchemaOverlayError::NotAnObject { .. }), "got {error:?}");
1676 }
1677
1678 #[test]
1679 fn compile_rejects_overlay_nested_past_the_depth_limit() {
1680 let mut node = json!({ "type": "string" });
1681 for _ in 0..MAX_OVERLAY_DEPTH {
1682 node = json!({ "type": "object", "properties": { "N": node } });
1683 }
1684 let error = compile("AWS::Test::Deep", &json!({ "properties": { "Top": node } }))
1685 .expect_err("an over-deep overlay must be rejected, not overflow the stack");
1686 assert!(matches!(error, SchemaOverlayError::TooDeep { .. }), "got {error:?}");
1687 }
1688
1689 #[test]
1690 fn compile_accepts_overlay_within_the_depth_limit() {
1691 let deepest_accepted_wrappers = (MAX_OVERLAY_DEPTH - 4) / 2;
1695 let mut node = json!({ "type": "string" });
1696 for _ in 0..deepest_accepted_wrappers {
1697 node = json!({ "type": "object", "properties": { "N": node } });
1698 }
1699 compile("AWS::Test::Deep", &json!({ "properties": { "Top": node } })).expect("ordinary nesting is accepted");
1700 }
1701
1702 fn value_nested_to_depth(depth: usize) -> Value {
1706 let mut value = json!("leaf");
1707 for _ in 1..depth {
1708 value = json!({ "N": value });
1709 }
1710 value
1711 }
1712
1713 #[test]
1714 fn depth_check_accepts_nesting_exactly_at_the_limit() {
1715 check_depth("AWS::Test::Deep", &value_nested_to_depth(MAX_OVERLAY_DEPTH))
1716 .expect("nesting exactly at the limit must be accepted");
1717 }
1718
1719 #[test]
1720 fn depth_check_rejects_nesting_one_level_past_the_limit() {
1721 let error = check_depth("AWS::Test::Deep", &value_nested_to_depth(MAX_OVERLAY_DEPTH + 1))
1722 .expect_err("one level past the limit must be rejected");
1723 assert!(matches!(error, SchemaOverlayError::TooDeep { .. }), "got {error:?}");
1724 }
1725
1726 #[test]
1727 fn compile_rejects_a_ref_outside_definitions() {
1728 let error = compile("AWS::Test::T", &json!({ "properties": { "P": { "$ref": "other.json#/Thing" } } }))
1729 .expect_err("a non-local $ref cannot be represented and must be rejected");
1730 assert!(matches!(error, SchemaOverlayError::Unsupported { .. }), "got {error:?}");
1731 }
1732
1733 #[test]
1734 fn compile_rejects_tuple_form_items() {
1735 let error = compile(
1736 "AWS::Test::T",
1737 &json!({ "properties": { "P": { "type": "array", "items": [{ "type": "string" }] } } }),
1738 )
1739 .expect_err("tuple-form items cannot be represented and must be rejected");
1740 assert!(matches!(error, SchemaOverlayError::Unsupported { .. }), "got {error:?}");
1741 }
1742
1743 #[test]
1744 fn compile_rejects_a_non_string_type() {
1745 let error = compile("AWS::Test::T", &json!({ "properties": { "P": { "type": { "nested": true } } } }))
1746 .expect_err("an object-valued 'type' cannot be represented and must be rejected");
1747 match error {
1748 SchemaOverlayError::Unsupported { path, .. } => {
1749 assert_eq!(path, "properties.P", "the error must name the offending path")
1750 }
1751 other => panic!("expected an unsupported-construct error, got {other:?}"),
1752 }
1753 }
1754
1755 #[test]
1756 fn compile_rejects_unsupported_constructs_nested_in_definitions_and_composition() {
1757 for raw in [
1758 json!({ "definitions": { "D": { "properties": { "P": { "items": [{ "type": "string" }] } } } } }),
1759 json!({ "oneOf": [{ "properties": { "P": { "$ref": "https://example.com/schema" } } }] }),
1760 json!({
1761 "allOf": [{
1762 "if": { "properties": { "A": { "enum": ["x"] } } },
1763 "then": { "properties": { "B": { "type": [1, 2] } } }
1764 }]
1765 }),
1766 ] {
1767 let error = compile("AWS::Test::T", &raw).expect_err("nested unsupported constructs must be rejected");
1768 assert!(matches!(error, SchemaOverlayError::Unsupported { .. }), "got {error:?} for {raw}");
1769 }
1770 }
1771
1772 #[test]
1773 fn compile_accepts_a_property_named_like_a_keyword() {
1774 compile(
1777 "AWS::Test::T",
1778 &json!({
1779 "properties": {
1780 "type": { "type": "string" },
1781 "items": { "type": "array", "items": { "type": "string" } }
1782 }
1783 }),
1784 )
1785 .expect("keyword-named properties are ordinary content");
1786 }
1787
1788 #[test]
1789 fn compile_accepts_an_enum_value_that_contains_schema_like_keys() {
1790 compile("AWS::Test::T", &json!({ "properties": { "P": { "enum": [{ "type": [1, 2] }] } } }))
1793 .expect("enum values are data and are not inspected as schemas");
1794 }
1795
1796 #[test]
1797 fn validate_schema_rejects_a_property_carrying_both_enum_representations() {
1798 let schema = compiled(json!({
1803 "properties": { "P": { "type": "string", "enum": ["A"], "enumCaseInsensitive": ["a"] } }
1804 }));
1805 let error = validate_schema(&schema).expect_err("both enum representations must be rejected");
1806 match error {
1807 SchemaOverlayError::ConflictingEnums { property, .. } => assert_eq!(property, "P"),
1808 other => panic!("expected a conflicting-enum error, got {other:?}"),
1809 }
1810 }
1811
1812 #[test]
1813 fn validate_schema_finds_a_conflicting_enum_nested_in_a_definition() {
1814 let schema = compiled(json!({
1815 "definitions": {
1816 "D": { "properties": { "Inner": { "enum": ["A"], "enumCaseInsensitive": ["a"] } } }
1817 }
1818 }));
1819 let error = validate_schema(&schema).expect_err("a nested conflict must be rejected");
1820 match error {
1821 SchemaOverlayError::ConflictingEnums { property, .. } => {
1822 assert_eq!(property, "D.Inner", "the error must name the nested path")
1823 }
1824 other => panic!("expected a conflicting-enum error, got {other:?}"),
1825 }
1826 }
1827
1828 #[test]
1829 fn validate_schema_rejects_a_self_referential_definition() {
1830 let schema = compiled(json!({
1831 "properties": { "P": { "$ref": "#/definitions/D" } },
1832 "definitions": { "D": { "$ref": "#/definitions/D" } }
1833 }));
1834 let error = validate_schema(&schema).expect_err("a self-referential definition must be rejected");
1835 match error {
1836 SchemaOverlayError::CyclicRef { cycle, .. } => assert!(cycle.contains(&"D".to_string()), "got {cycle:?}"),
1837 other => panic!("expected a cycle error, got {other:?}"),
1838 }
1839 }
1840
1841 #[test]
1842 fn validate_schema_rejects_a_multi_node_definition_cycle() {
1843 let schema = compiled(json!({
1844 "definitions": {
1845 "A": { "$ref": "#/definitions/B" },
1846 "B": { "$ref": "#/definitions/C" },
1847 "C": { "$ref": "#/definitions/A" }
1848 }
1849 }));
1850 validate_schema(&schema).expect_err("a multi-node definition cycle must be rejected");
1851 }
1852
1853 #[test]
1854 fn validate_schema_accepts_an_acyclic_ref_chain() {
1855 let schema = compiled(json!({
1856 "properties": { "P": { "$ref": "#/definitions/A" } },
1857 "definitions": {
1858 "A": { "$ref": "#/definitions/B" },
1859 "B": { "type": "object" }
1860 }
1861 }));
1862 validate_schema(&schema).expect("an acyclic chain is valid");
1863 }
1864
1865 #[test]
1866 fn merge_adds_new_property_and_keeps_base() {
1867 let mut base =
1868 compiled(json!({ "properties": { "Handler": { "type": "string" } }, "additionalProperties": false }));
1869 merge_into(&mut base, compiled(json!({ "properties": { "NewThing": { "type": "object" } } })));
1870 assert!(base.properties.contains_key("Handler"), "bundled property must be retained");
1871 assert!(base.properties.contains_key("NewThing"), "overlay property must be added");
1872 assert_eq!(base.additional_properties, Some(false), "bundled additionalProperties must be retained");
1873 }
1874
1875 #[test]
1876 fn merge_replaces_enum_for_property() {
1877 let mut base = compiled(json!({ "properties": { "Mode": { "type": "string", "enum": ["A", "B"] } } }));
1878 merge_into(&mut base, compiled(json!({ "properties": { "Mode": { "enum": ["A", "B", "C"] } } })));
1879 let values: Vec<&str> = base.properties["Mode"].enum_values.iter().filter_map(|v| v.as_str()).collect();
1880 assert_eq!(values, vec!["A", "B", "C"], "overlay enum must replace the bundled enum");
1881 }
1882
1883 #[test]
1884 fn merge_keeps_case_insensitive_comparison_when_widening_with_a_plain_enum() {
1885 let mut base =
1886 compiled(json!({ "properties": { "Mode": { "type": "string", "enumCaseInsensitive": ["a", "b"] } } }));
1887 merge_into(&mut base, compiled(json!({ "properties": { "Mode": { "enum": ["a", "b", "c"] } } })));
1888 let prop = &base.properties["Mode"];
1889 assert!(prop.enum_values.is_empty(), "the exact list must stay empty so the value is not checked twice");
1890 let values: Vec<&str> = prop.enum_case_insensitive.iter().filter_map(|v| v.as_str()).collect();
1891 assert_eq!(values, vec!["a", "b", "c"], "widening must land on the case-insensitive list");
1892 }
1893
1894 #[test]
1895 fn merge_case_insensitive_overlay_clears_a_bundled_exact_enum() {
1896 let mut base = compiled(json!({ "properties": { "Mode": { "type": "string", "enum": ["A", "B"] } } }));
1897 merge_into(&mut base, compiled(json!({ "properties": { "Mode": { "enumCaseInsensitive": ["a", "b"] } } })));
1898 let prop = &base.properties["Mode"];
1899 assert!(prop.enum_values.is_empty(), "the exact list must be cleared");
1900 assert_eq!(prop.enum_case_insensitive.len(), 2);
1901 validate_schema(&base).expect("the two enum representations must stay mutually exclusive");
1902 }
1903
1904 #[test]
1905 fn merge_unions_required() {
1906 let mut base = compiled(json!({ "required": ["A"] }));
1907 merge_into(&mut base, compiled(json!({ "required": ["A", "B"] })));
1908 assert_eq!(
1909 base.required,
1910 vec!["A".to_string(), "B".to_string()],
1911 "required must be unioned without duplicates"
1912 );
1913 }
1914
1915 #[test]
1916 fn merge_unions_property_path_metadata() {
1917 let mut base = compiled(json!({ "deprecatedProperties": ["/properties/Old"] }));
1918 merge_into(&mut base, compiled(json!({ "deprecatedProperties": ["/properties/New"] })));
1919 assert_eq!(
1920 base.deprecated_properties,
1921 vec!["Old".to_string(), "New".to_string()],
1922 "an additive metadata overlay must keep the bundled entries"
1923 );
1924 }
1925
1926 #[test]
1927 fn merge_replaces_the_primary_identifier_tuple() {
1928 let mut base = compiled(json!({ "primaryIdentifier": ["/properties/Name"] }));
1929 merge_into(&mut base, compiled(json!({ "primaryIdentifier": ["/properties/Region", "/properties/Name"] })));
1930 assert_eq!(
1931 base.primary_identifier,
1932 vec!["Region".to_string(), "Name".to_string()],
1933 "the identifier tuple is replaced, not unioned into a different tuple"
1934 );
1935 }
1936
1937 #[test]
1938 fn merge_replaces_logical_groups_instead_of_unioning_them() {
1939 let mut base = compiled(json!({ "requiredXor": ["A", "B"], "requiredOr": ["C", "D"] }));
1940 merge_into(&mut base, compiled(json!({ "requiredXor": ["A", "B", "E"], "requiredOr": ["C", "D", "F"] })));
1941 assert_eq!(base.required_xor, vec!["A".to_string(), "B".to_string(), "E".to_string()]);
1942 assert_eq!(base.required_or, vec!["C".to_string(), "D".to_string(), "F".to_string()]);
1943 }
1944
1945 #[test]
1946 fn merge_keeps_logical_groups_when_the_overlay_omits_them() {
1947 let mut base = compiled(json!({ "requiredXor": ["A", "B"] }));
1948 merge_into(&mut base, compiled(json!({ "properties": { "P": { "type": "string" } } })));
1949 assert_eq!(base.required_xor, vec!["A".to_string(), "B".to_string()]);
1950 }
1951
1952 #[test]
1953 fn merge_unions_dependency_lists_per_trigger() {
1954 let mut base = compiled(json!({ "dependentRequired": { "A": ["B", "C"] } }));
1955 merge_into(
1956 &mut base,
1957 compiled(json!({ "dependentRequired": { "A": ["D"] }, "dependentExcluded": { "E": ["F"] } })),
1958 );
1959 assert_eq!(
1960 base.dependent_required.get("A"),
1961 Some(&vec!["B".to_string(), "C".to_string(), "D".to_string()]),
1962 "an overlay entry for an existing trigger must extend, not replace"
1963 );
1964 assert_eq!(base.dependent_excluded.get("E"), Some(&vec!["F".to_string()]));
1965 }
1966
1967 #[test]
1968 fn merge_deep_merges_nested_properties() {
1969 let mut base = compiled(json!({
1970 "properties": {
1971 "Cfg": {
1972 "type": "object",
1973 "properties": { "X": { "type": "string" } },
1974 "additionalProperties": false
1975 }
1976 }
1977 }));
1978 merge_into(
1979 &mut base,
1980 compiled(json!({ "properties": { "Cfg": { "properties": { "Y": { "type": "integer" } } } } })),
1981 );
1982 let cfg = &base.properties["Cfg"];
1983 assert!(cfg.properties.contains_key("X"), "nested bundled property must be retained");
1984 assert!(cfg.properties.contains_key("Y"), "nested overlay property must be added");
1985 assert_eq!(cfg.additional_properties, Some(false), "nested additionalProperties must be retained");
1986 }
1987
1988 #[test]
1989 fn merge_deep_merges_pattern_property_values() {
1990 let mut base = compiled(json!({
1991 "properties": {
1992 "Map": {
1993 "type": "object",
1994 "patternProperties": {
1995 "^k$": { "type": "object", "properties": { "X": { "type": "string" } } }
1996 }
1997 }
1998 }
1999 }));
2000 merge_into(
2001 &mut base,
2002 compiled(json!({
2003 "properties": {
2004 "Map": { "patternProperties": { "^k$": { "properties": { "Y": { "type": "string" } } } } }
2005 }
2006 })),
2007 );
2008 let value = &base.properties["Map"].pattern_properties["^k$"];
2009 assert!(value.properties.contains_key("X"), "the bundled pattern value schema must be retained");
2010 assert!(value.properties.contains_key("Y"), "the overlay pattern value schema must be merged in");
2011 }
2012
2013 #[test]
2014 fn merge_prop_overrides_all_scalar_constraints() {
2015 let mut base = compiled(json!({ "properties": { "P": { "type": "string" } } }));
2016 merge_into(
2017 &mut base,
2018 compiled(json!({
2019 "properties": {
2020 "P": {
2021 "type": "string",
2022 "pattern": "^a+$",
2023 "minLength": 1, "maxLength": 10,
2024 "minimum": 0.0, "maximum": 100.0,
2025 "exclusiveMinimum": 1.0, "exclusiveMaximum": 99.0,
2026 "minItems": 1, "maxItems": 5,
2027 "uniqueItems": true,
2028 "minProperties": 1, "maxProperties": 3,
2029 "format": "uri",
2030 "description": "desc",
2031 "const": "a",
2032 "not": { "enum": ["bad"] },
2033 "additionalProperties": false
2034 }
2035 }
2036 })),
2037 );
2038 let p = &base.properties["P"];
2039 assert_eq!(p.pattern.as_deref(), Some("^a+$"));
2040 assert_eq!(p.min_length, Some(1));
2041 assert_eq!(p.max_length, Some(10));
2042 assert_eq!(p.minimum, Some(0.0));
2043 assert_eq!(p.maximum, Some(100.0));
2044 assert_eq!(p.exclusive_minimum, Some(1.0));
2045 assert_eq!(p.exclusive_maximum, Some(99.0));
2046 assert_eq!(p.min_items, Some(1));
2047 assert_eq!(p.max_items, Some(5));
2048 assert_eq!(p.unique_items, Some(true));
2049 assert_eq!(p.min_properties, Some(1));
2050 assert_eq!(p.max_properties, Some(3));
2051 assert_eq!(p.format.as_deref(), Some("uri"));
2052 assert_eq!(p.description.as_deref(), Some("desc"));
2053 assert_eq!(p.const_value, Some(json!("a")));
2054 assert_eq!(p.not_enum, vec![json!("bad")]);
2055 assert_eq!(p.additional_properties, Some(false));
2056 }
2057
2058 #[test]
2059 fn merge_prop_explicit_unique_items_false_clears_bundled_true() {
2060 let mut base = compiled(json!({ "properties": { "Arr": { "type": "array", "uniqueItems": true } } }));
2061 merge_into(&mut base, compiled(json!({ "properties": { "Arr": { "uniqueItems": false } } })));
2062 assert_eq!(
2063 base.properties["Arr"].unique_items,
2064 Some(false),
2065 "an explicitly relaxed uniqueItems must override the bundled true"
2066 );
2067 }
2068
2069 #[test]
2070 fn merge_prop_omitted_unique_items_keeps_bundled_true() {
2071 let mut base = compiled(json!({ "properties": { "Arr": { "type": "array", "uniqueItems": true } } }));
2072 merge_into(&mut base, compiled(json!({ "properties": { "Arr": { "minItems": 1 } } })));
2073 assert_eq!(base.properties["Arr"].unique_items, Some(true), "an omitted uniqueItems must inherit");
2074 }
2075
2076 #[test]
2077 fn merge_prop_merges_items() {
2078 let mut base = compiled(json!({
2079 "properties": {
2080 "Arr": { "type": "array", "items": { "type": "string" } },
2081 "Empty": { "type": "array" }
2082 }
2083 }));
2084 merge_into(
2085 &mut base,
2086 compiled(json!({
2087 "properties": {
2088 "Arr": { "items": { "pattern": "^x$" } },
2089 "Empty": { "items": { "type": "string" } }
2090 }
2091 })),
2092 );
2093 let items = base.properties["Arr"].items.as_ref().expect("items retained");
2094 assert_eq!(items.pattern.as_deref(), Some("^x$"), "nested items must be merged");
2095 assert!(base.properties["Empty"].items.is_some(), "items must be inserted when the base has none");
2096 }
2097
2098 #[test]
2099 fn merge_prop_ref_overlay_updates_ref_preserving_base_constraints() {
2100 let mut base = compiled(json!({
2101 "properties": { "P": { "type": "string", "pattern": "^a", "maxLength": 10 } },
2102 "definitions": { "D": { "type": "object" } }
2103 }));
2104 merge_into(
2105 &mut base,
2106 compiled(json!({
2107 "properties": { "P": { "$ref": "#/definitions/D" } },
2108 "definitions": { "D": { "type": "object" } }
2109 })),
2110 );
2111 assert_eq!(base.properties["P"].ref_name.as_deref(), Some("D"), "a $ref overlay must update the reference");
2112 assert_eq!(
2113 base.properties["P"].pattern.as_deref(),
2114 Some("^a"),
2115 "the base pattern must be preserved when a $ref overlay is applied"
2116 );
2117 assert_eq!(
2118 base.properties["P"].max_length,
2119 Some(10),
2120 "the base maxLength must be preserved when a $ref overlay is applied"
2121 );
2122 }
2123
2124 fn effective(schema: &CompiledSchema, property: &str) -> PropSchema {
2128 schema.properties[property].resolve(&schema.definitions).into_owned()
2129 }
2130
2131 #[test]
2132 fn constraint_only_overlay_on_a_ref_base_takes_effect() {
2133 let mut base = compiled(json!({
2134 "properties": { "P": { "$ref": "#/definitions/D" } },
2135 "definitions": { "D": { "type": "string", "enum": ["A", "B"] } }
2136 }));
2137 merge_into(&mut base, compiled(json!({ "properties": { "P": { "enum": ["A", "B", "C"] } } })));
2138 assert_eq!(
2139 base.properties["P"].ref_name.as_deref(),
2140 Some("D"),
2141 "the reference stays live so a later change to the definition still reaches the property"
2142 );
2143 let p = effective(&base, "P");
2144 assert_eq!(p.enum_values.len(), 3, "the overlay enum must take effect");
2145 assert_eq!(
2146 p.prop_type.as_ref().and_then(crate::compiled::PropType::primary),
2147 Some("string"),
2148 "the referenced definition's own constraints must be preserved"
2149 );
2150 }
2151
2152 #[test]
2153 fn overlay_on_a_ref_base_follows_the_whole_chain() {
2154 let mut base = compiled(json!({
2155 "properties": { "P": { "$ref": "#/definitions/Level1" } },
2156 "definitions": {
2157 "Level1": { "$ref": "#/definitions/Level2" },
2158 "Level2": {
2159 "type": "object",
2160 "additionalProperties": false,
2161 "properties": { "Old": { "type": "string" } }
2162 }
2163 }
2164 }));
2165 merge_into(
2166 &mut base,
2167 compiled(json!({ "properties": { "P": { "properties": { "New": { "type": "integer" } } } } })),
2168 );
2169 let p = effective(&base, "P");
2170 assert!(p.properties.contains_key("Old"), "the terminal definition's properties must be preserved");
2171 assert!(p.properties.contains_key("New"), "the overlay property must be merged in");
2172 assert_eq!(p.additional_properties, Some(false), "the terminal definition's constraints must be preserved");
2173 }
2174
2175 #[test]
2176 fn merge_no_op_overlay_leaves_a_ref_base_untouched() {
2177 let mut base = compiled(json!({
2178 "properties": { "P": { "$ref": "#/definitions/D" } },
2179 "definitions": { "D": { "type": "object" } }
2180 }));
2181 merge_into(&mut base, compiled(json!({ "properties": { "P": {} } })));
2182 assert_eq!(
2183 base.properties["P"].ref_name.as_deref(),
2184 Some("D"),
2185 "an overlay with nothing to say must not rewrite the property"
2186 );
2187 }
2188
2189 #[test]
2190 fn a_ref_to_an_overlay_only_definition_resolves() {
2191 let mut base = compiled(json!({
2194 "properties": { "Cfg": { "$ref": "#/definitions/Config" } },
2195 "definitions": { "Config": { "$ref": "#/definitions/Common" } }
2196 }));
2197 merge_into(
2198 &mut base,
2199 compiled(json!({
2200 "definitions": {
2201 "Common": { "type": "object", "required": ["FromCommon"] },
2202 "Config": { "properties": { "Extra": { "type": "string" } } }
2203 }
2204 })),
2205 );
2206 let cfg = effective(&base, "Cfg");
2207 assert_eq!(
2208 cfg.required,
2209 vec!["FromCommon".to_string()],
2210 "the overlay-only definition's constraints must reach the property"
2211 );
2212 assert!(cfg.properties.contains_key("Extra"), "the overlay definition content must be merged in");
2213 }
2214
2215 #[test]
2216 fn a_definition_updated_by_a_later_merge_reaches_an_already_extended_property() {
2217 let mut base = compiled(json!({
2220 "properties": { "P": { "$ref": "#/definitions/D" } },
2221 "definitions": { "D": { "type": "string", "enum": ["alpha", "beta"] } }
2222 }));
2223 merge_into(&mut base, compiled(json!({ "properties": { "P": { "description": "documented" } } })));
2224 merge_into(&mut base, compiled(json!({ "definitions": { "D": { "enum": ["alpha", "beta", "gamma"] } } })));
2225 let p = effective(&base, "P");
2226 assert_eq!(
2227 p.enum_values.len(),
2228 3,
2229 "the later definition update must reach the property, got {:?}",
2230 p.enum_values
2231 );
2232 assert_eq!(p.description.as_deref(), Some("documented"), "the earlier property extension must survive");
2233 }
2234
2235 #[test]
2236 fn merge_into_overrides_schema_metadata() {
2237 let mut base = compiled(json!({ "additionalProperties": true, "description": "old" }));
2238 merge_into(
2239 &mut base,
2240 compiled(json!({
2241 "additionalProperties": false,
2242 "replacementStrategy": "delete",
2243 "documentationUrl": "http://docs",
2244 "sourceUrl": "http://src",
2245 "description": "new"
2246 })),
2247 );
2248 assert_eq!(base.additional_properties, Some(false));
2249 assert_eq!(base.replacement_strategy.as_deref(), Some("delete"));
2250 assert_eq!(base.documentation_url.as_deref(), Some("http://docs"));
2251 assert_eq!(base.source_url.as_deref(), Some("http://src"));
2252 assert_eq!(base.description.as_deref(), Some("new"));
2253 }
2254
2255 #[test]
2256 fn merge_into_merges_existing_definition_and_inserts_new() {
2257 let mut base = compiled(json!({
2258 "definitions": {
2259 "D": { "type": "object", "properties": { "X": { "type": "string" } }, "additionalProperties": false }
2260 }
2261 }));
2262 merge_into(
2263 &mut base,
2264 compiled(json!({
2265 "definitions": {
2266 "D": { "properties": { "Y": { "type": "integer" } } },
2267 "E": { "type": "string" }
2268 }
2269 })),
2270 );
2271 let d = &base.definitions["D"];
2272 assert!(d.properties.contains_key("X"), "existing definition property must be retained");
2273 assert!(d.properties.contains_key("Y"), "overlay definition property must be merged in");
2274 assert_eq!(d.additional_properties, Some(false), "existing definition metadata must be retained");
2275 assert!(base.definitions.contains_key("E"), "a new definition must be inserted");
2276 }
2277
2278 #[test]
2279 fn merge_replaces_composition_keywords_instead_of_appending() {
2280 let mut base = compiled(json!({ "oneOf": [{ "required": ["A"] }, { "required": ["B"] }] }));
2281 assert_eq!(base.one_of.len(), 2);
2282 merge_into(&mut base, compiled(json!({ "oneOf": [{ "required": ["C"] }] })));
2283 assert_eq!(base.one_of.len(), 1, "overlay oneOf must replace the bundled oneOf");
2284 assert_eq!(base.one_of[0].required, vec!["C".to_string()]);
2285 }
2286
2287 #[test]
2288 fn merge_replaces_both_halves_of_a_split_all_of() {
2289 let mut base = compiled(json!({
2293 "allOf": [
2294 { "required": ["Plain"] },
2295 { "if": { "properties": { "A": { "enum": ["x"] } } }, "then": { "dependentRequired": { "A": ["B"] } } }
2296 ]
2297 }));
2298 assert_eq!(base.all_of.len(), 1);
2299 assert_eq!(base.if_then_else.len(), 1);
2300 merge_into(
2301 &mut base,
2302 compiled(json!({
2303 "allOf": [{ "if": { "properties": { "A": { "enum": ["y"] } } }, "then": { "dependentRequired": { "A": ["C"] } } }]
2304 })),
2305 );
2306 assert!(base.all_of.is_empty(), "the bundled plain allOf entry must not survive a replacing overlay");
2307 assert_eq!(base.if_then_else.len(), 1);
2308 }
2309
2310 #[test]
2311 fn merge_keeps_composition_when_overlay_omits_it() {
2312 let mut base = compiled(json!({ "oneOf": [{ "required": ["A"] }] }));
2313 merge_into(&mut base, compiled(json!({ "properties": { "P": { "type": "string" } } })));
2314 assert_eq!(base.one_of.len(), 1, "bundled oneOf must be kept when the overlay omits it");
2315 }
2316
2317 #[test]
2318 fn merge_prop_recurses_into_shared_nested_property() {
2319 let mut base = compiled(json!({
2320 "properties": {
2321 "Cfg": {
2322 "type": "object",
2323 "properties": { "Inner": { "type": "object", "properties": { "A": { "type": "string" } } } }
2324 }
2325 }
2326 }));
2327 merge_into(
2328 &mut base,
2329 compiled(json!({
2330 "properties": {
2331 "Cfg": { "properties": { "Inner": { "properties": { "B": { "type": "integer" } } } } }
2332 }
2333 })),
2334 );
2335 let inner = &base.properties["Cfg"].properties["Inner"].properties;
2336 assert!(inner.contains_key("A"), "deep bundled sub-property must be retained");
2337 assert!(inner.contains_key("B"), "deep overlay sub-property must be merged in");
2338 }
2339
2340 #[test]
2341 fn merge_result_is_independent_of_map_iteration_order() {
2342 let mut base_raw = serde_json::Map::new();
2345 for i in 0..32 {
2346 base_raw.insert(format!("D{i}"), json!({ "type": "object" }));
2347 }
2348 base_raw.insert("Holder".into(), json!({ "$ref": "#/definitions/Target" }));
2349 base_raw.insert("Target".into(), json!({ "type": "object" }));
2350 let mut overlay_raw = serde_json::Map::new();
2351 for i in 0..32 {
2352 overlay_raw.insert(format!("D{i}"), json!({ "description": "touched" }));
2353 }
2354 overlay_raw.insert("Target".into(), json!({ "required": ["Late"] }));
2355 overlay_raw.insert("Holder".into(), json!({ "properties": { "X": { "type": "string" } } }));
2356
2357 for _ in 0..8 {
2358 let mut base = compiled(json!({
2359 "properties": { "P": { "$ref": "#/definitions/Holder" } },
2360 "definitions": base_raw
2361 }));
2362 merge_into(&mut base, compiled(json!({ "definitions": overlay_raw })));
2363 let p = effective(&base, "P");
2364 assert_eq!(p.required, vec!["Late".to_string()], "the referenced definition's update must be visible");
2365 assert!(p.properties.contains_key("X"), "the referencing definition's own content must be visible");
2366 }
2367 }
2368
2369 #[test]
2372 fn compile_rejects_malformed_required_array() {
2373 let error = compile("AWS::Test::T", &json!({ "properties": { "P": { "type": "string" } }, "required": 42 }))
2374 .expect_err("non-array required must be rejected");
2375 assert!(matches!(error, SchemaOverlayError::Unsupported { .. }), "got {error:?}");
2376 }
2377
2378 #[test]
2379 fn compile_rejects_required_containing_non_strings() {
2380 let error =
2381 compile("AWS::Test::T", &json!({ "properties": { "P": { "type": "string" } }, "required": ["A", 42] }))
2382 .expect_err("required with non-string entries must be rejected");
2383 assert!(matches!(error, SchemaOverlayError::Unsupported { .. }), "got {error:?}");
2384 }
2385
2386 #[test]
2387 fn compile_rejects_invalid_property_pattern() {
2388 let error =
2389 compile("AWS::Test::T", &json!({ "properties": { "P": { "type": "string", "pattern": "^(unbalanced" } } }))
2390 .expect_err("an invalid regex in pattern must be rejected");
2391 assert!(matches!(error, SchemaOverlayError::Unsupported { .. }), "got {error:?}");
2392 }
2393
2394 #[test]
2395 fn compile_rejects_invalid_pattern_properties_regex() {
2396 let error = compile(
2397 "AWS::Test::T",
2398 &json!({ "properties": { "Map": { "type": "object", "patternProperties": { "^(bad": { "type": "string" } } } } }),
2399 )
2400 .expect_err("an invalid regex in patternProperties key must be rejected");
2401 assert!(matches!(error, SchemaOverlayError::Unsupported { .. }), "got {error:?}");
2402 }
2403
2404 #[test]
2405 fn compile_rejects_root_not_without_enum() {
2406 let error = compile(
2407 "AWS::Test::T",
2408 &json!({ "properties": { "P": { "type": "string", "not": { "pattern": "^x" } } } }),
2409 )
2410 .expect_err("'not' without 'enum' must be rejected as unsupported");
2411 assert!(matches!(error, SchemaOverlayError::Unsupported { .. }), "got {error:?}");
2412 }
2413
2414 #[test]
2417 fn compile_rejects_composition_entry_with_ref() {
2418 compile(
2419 "AWS::Test::T",
2420 &json!({
2421 "definitions": { "D": { "type": "object" } },
2422 "allOf": [{ "$ref": "#/definitions/D" }]
2423 }),
2424 )
2425 .expect("a $ref inside a composition entry is accepted and resolved at validation time");
2426 }
2427
2428 #[test]
2429 fn compile_accepts_composition_entry_with_scalar_constraint() {
2430 compile(
2431 "AWS::Test::T",
2432 &json!({
2433 "oneOf": [{ "required": ["A"], "minLength": 5 }]
2434 }),
2435 )
2436 .expect("scalar constraints inside composition entries are accepted and evaluated");
2437 }
2438
2439 #[test]
2440 fn compile_accepts_conditional_allof_then_with_required() {
2441 compile(
2442 "AWS::Test::T",
2443 &json!({
2444 "allOf": [{
2445 "if": { "properties": { "A": { "enum": ["x"] } } },
2446 "then": { "required": ["B"] }
2447 }]
2448 }),
2449 )
2450 .expect("conditional then with 'required' is now supported");
2451 }
2452
2453 #[test]
2454 fn compile_accepts_conditional_allof_then_with_dependent_required() {
2455 compile(
2456 "AWS::Test::T",
2457 &json!({
2458 "properties": { "A": { "type": "string" }, "B": { "type": "string" } },
2459 "allOf": [{
2460 "if": { "properties": { "A": { "enum": ["x"] } } },
2461 "then": { "dependentRequired": { "A": ["B"] } }
2462 }]
2463 }),
2464 )
2465 .expect("a conditional then with only dependentRequired must be accepted");
2466 }
2467
2468 #[test]
2469 fn compile_rejects_direct_root_if_then_else() {
2470 let error = compile(
2471 "AWS::Test::T",
2472 &json!({
2473 "properties": { "A": { "type": "string" } },
2474 "if": { "properties": { "A": { "enum": ["x"] } } },
2475 "then": { "required": ["B"] }
2476 }),
2477 )
2478 .expect_err("direct root if/then/else must be rejected");
2479 assert!(matches!(error, SchemaOverlayError::Unsupported { .. }), "got {error:?}");
2480 }
2481
2482 #[test]
2483 fn compile_accepts_plain_composition_with_allowed_fields() {
2484 compile(
2485 "AWS::Test::T",
2486 &json!({
2487 "properties": { "A": { "type": "string" }, "B": { "type": "string" } },
2488 "oneOf": [{ "required": ["A"] }, { "required": ["B"] }]
2489 }),
2490 )
2491 .expect("composition with only required must be accepted");
2492 }
2493
2494 #[test]
2497 fn conflicting_enum_check_reports_alphabetically_first_path() {
2498 let schema = compiled(json!({
2499 "properties": {
2500 "Zeta": { "type": "string", "enum": ["A"], "enumCaseInsensitive": ["a"] },
2501 "Alpha": { "type": "string", "enum": ["B"], "enumCaseInsensitive": ["b"] }
2502 }
2503 }));
2504 let error = validate_schema(&schema).expect_err("both conflicts must be detected");
2505 match error {
2506 SchemaOverlayError::ConflictingEnums { property, .. } => {
2507 assert_eq!(property, "Alpha", "the alphabetically first path must be reported");
2508 }
2509 other => panic!("expected conflicting-enum error, got {other:?}"),
2510 }
2511 }
2512
2513 #[test]
2516 fn compile_rejects_leading_whitespace_in_type_name() {
2517 let error = compile(" AWS::Test::T", &json!({ "properties": { "P": { "type": "string" } } }))
2518 .expect_err("leading whitespace must be rejected");
2519 assert!(matches!(error, SchemaOverlayError::Unsupported { .. }), "got {error:?}");
2520 }
2521
2522 #[test]
2523 fn compile_rejects_trailing_whitespace_in_type_name() {
2524 let error = compile("AWS::Test::T ", &json!({ "properties": { "P": { "type": "string" } } }))
2525 .expect_err("trailing whitespace must be rejected");
2526 assert!(matches!(error, SchemaOverlayError::Unsupported { .. }), "got {error:?}");
2527 }
2528
2529 #[test]
2530 fn compile_rejects_type_name_mismatch_with_schema_typename() {
2531 let error = compile(
2532 "AWS::Test::A",
2533 &json!({ "typeName": "AWS::Test::B", "properties": { "P": { "type": "string" } } }),
2534 )
2535 .expect_err("a typeName mismatch must be rejected");
2536 assert!(matches!(error, SchemaOverlayError::Unsupported { .. }), "got {error:?}");
2537 }
2538
2539 #[test]
2542 fn compile_rejects_unrepresented_constraint_keywords() {
2543 for keyword in ["propertyNames", "contains"] {
2544 let schema = json!({ "properties": { "P": { "type": "string", keyword: 42 } } });
2545 let error =
2546 compile("AWS::Test::T", &schema).expect_err(&format!("'{keyword}' must be rejected, not warned"));
2547 assert!(
2548 matches!(error, SchemaOverlayError::Unsupported { .. }),
2549 "expected Unsupported for '{keyword}', got {error:?}"
2550 );
2551 }
2552 }
2553
2554 #[test]
2555 fn compile_accepts_multiple_of() {
2556 compile("AWS::Test::T", &json!({ "properties": { "P": { "type": "number", "multipleOf": 5 } } }))
2557 .expect("multipleOf is represented and enforced");
2558 }
2559
2560 #[test]
2563 fn ref_overlay_preserves_base_pattern_and_max_length() {
2564 let mut base = compiled(json!({
2565 "properties": { "P": { "type": "string", "pattern": "^[a-z]+$", "maxLength": 50 } },
2566 "definitions": { "D": { "type": "string" } }
2567 }));
2568 merge_into(
2569 &mut base,
2570 compiled(json!({
2571 "properties": { "P": { "$ref": "#/definitions/D" } },
2572 "definitions": { "D": { "type": "string" } }
2573 })),
2574 );
2575 let p = &base.properties["P"];
2576 assert_eq!(p.ref_name.as_deref(), Some("D"), "the reference must be set");
2577 assert_eq!(p.pattern.as_deref(), Some("^[a-z]+$"), "base pattern must be preserved");
2578 assert_eq!(p.max_length, Some(50), "base maxLength must be preserved");
2579 }
2580
2581 #[test]
2582 fn compile_accepts_composition_properties() {
2583 compile(
2584 "AWS::Test::T",
2585 &json!({
2586 "oneOf": [{
2587 "properties": { "A": { "enum": ["x"] } },
2588 "required": ["A"],
2589 "requiredOr": ["A", "B"],
2590 "requiredXor": ["A", "C"]
2591 }]
2592 }),
2593 )
2594 .expect("composition branches with property constraints are now accepted and evaluated");
2595 }
2596
2597 #[test]
2598 fn compile_rejects_conditional_fields_the_matcher_does_not_evaluate() {
2599 let error = compile(
2602 "AWS::Test::T",
2603 &json!({ "allOf": [{ "if": { "anyOf": [{ "required": ["A"] }] }, "then": { "dependentRequired": { "A": ["B"] } } }] }),
2604 )
2605 .expect_err("unsupported conditional fields must be rejected");
2606 assert!(matches!(error, SchemaOverlayError::Unsupported { .. }), "got {error:?}");
2607 }
2608
2609 #[test]
2610 fn compile_accepts_conditional_with_maxlength_in_property() {
2611 compile(
2612 "AWS::Test::T",
2613 &json!({
2614 "properties": { "A": { "type": "string" }, "B": { "type": "string" } },
2615 "allOf": [{
2616 "if": { "properties": { "A": { "maxLength": 3 } } },
2617 "then": { "dependentRequired": { "A": ["B"] } }
2618 }]
2619 }),
2620 )
2621 .expect("maxLength in condition property is now accepted");
2622 }
2623
2624 #[test]
2625 fn compile_accepts_condition_fields_the_matcher_evaluates() {
2626 compile(
2627 "AWS::Test::T",
2628 &json!({
2629 "properties": { "A": { "type": "string" }, "B": { "type": "string" } },
2630 "allOf": [{
2631 "if": { "properties": { "A": { "type": "string", "enum": ["x"] } }, "required": ["A"] },
2632 "then": { "dependentRequired": { "A": ["B"] } }
2633 }]
2634 }),
2635 )
2636 .expect("fully enforced conditional fields must remain supported");
2637 }
2638
2639 #[test]
2640 fn compile_rejects_unknown_or_empty_property_types() {
2641 for prop_type in [json!("strng"), json!([])] {
2642 let error = compile("AWS::Test::T", &json!({ "properties": { "P": { "type": prop_type } } }))
2643 .expect_err("a type the validator cannot enforce must be rejected");
2644 assert!(matches!(error, SchemaOverlayError::Unsupported { .. }), "got {error:?}");
2645 }
2646 }
2647
2648 #[test]
2649 fn compile_rejects_non_string_schema_type_name() {
2650 let error = compile("AWS::Test::T", &json!({ "typeName": 42, "properties": { "P": { "type": "string" } } }))
2651 .expect_err("typeName must be a string");
2652 assert!(matches!(error, SchemaOverlayError::Unsupported { .. }), "got {error:?}");
2653 }
2654
2655 #[test]
2656 fn compile_rejects_malformed_property_metadata_paths() {
2657 for pointer in [json!("P"), json!("/properties/"), json!(42)] {
2658 let error = compile(
2659 "AWS::Test::T",
2660 &json!({
2661 "properties": { "P": { "type": "string" } },
2662 "readOnlyProperties": [pointer]
2663 }),
2664 )
2665 .expect_err("metadata paths must be valid property pointers");
2666 assert!(matches!(error, SchemaOverlayError::Unsupported { .. }), "got {error:?}");
2667 }
2668 }
2669
2670 #[test]
2671 fn compile_rejects_malformed_not_enum() {
2672 for negated in [json!({ "enum": "x" }), json!({ "enum": [] }), json!({ "type": "string" })] {
2673 let error =
2674 compile("AWS::Test::T", &json!({ "properties": { "P": { "type": "string", "not": negated } } }))
2675 .expect_err("only a non-empty not.enum array is enforceable");
2676 assert!(matches!(error, SchemaOverlayError::Unsupported { .. }), "got {error:?}");
2677 }
2678 }
2679
2680 #[test]
2681 fn compile_rejects_constraints_beside_a_ref() {
2682 compile(
2683 "AWS::Test::T",
2684 &json!({
2685 "properties": { "P": { "$ref": "#/definitions/D", "maxLength": 3 } },
2686 "definitions": { "D": { "type": "string" } }
2687 }),
2688 )
2689 .expect("represented constraints beside a $ref are accepted and merged at validation time");
2690 }
2691
2692 #[test]
2693 fn overlay_with_explicit_required_replaces_base_required() {
2694 let mut base = compiled(json!({
2695 "properties": { "A": { "type": "string" }, "B": { "type": "string" } },
2696 "required": ["A", "B"]
2697 }));
2698 let overlay = compiled(json!({
2699 "properties": { "C": { "type": "string" } },
2700 "required": ["C"]
2701 }));
2702 merge_into(&mut base, overlay);
2703 assert_eq!(base.required, vec!["C".to_string()], "explicit required replaces, not unions");
2704 }
2705
2706 #[test]
2707 fn overlay_with_empty_required_clears_base_required() {
2708 let mut base = compiled(json!({
2709 "properties": { "A": { "type": "string" }, "B": { "type": "string" } },
2710 "required": ["A", "B"]
2711 }));
2712 let overlay = compiled(json!({
2713 "properties": { "C": { "type": "string" } },
2714 "required": []
2715 }));
2716 merge_into(&mut base, overlay);
2717 assert!(base.required.is_empty(), "explicit empty required clears the base's required list");
2718 }
2719
2720 #[test]
2721 fn overlay_without_required_preserves_base_required() {
2722 let mut base = compiled(json!({
2723 "properties": { "A": { "type": "string" } },
2724 "required": ["A"]
2725 }));
2726 let overlay = compiled(json!({
2727 "properties": { "B": { "type": "string" } }
2728 }));
2729 merge_into(&mut base, overlay);
2730 assert_eq!(base.required, vec!["A".to_string()], "omitting required from overlay preserves base");
2731 }
2732
2733 #[test]
2734 fn property_level_required_present_replaces() {
2735 let mut base = compiled(json!({
2736 "properties": {
2737 "Nested": { "type": "object", "properties": { "X": { "type": "string" } }, "required": ["X"] }
2738 }
2739 }));
2740 let overlay = compiled(json!({
2741 "properties": {
2742 "Nested": { "type": "object", "properties": { "Y": { "type": "string" } }, "required": ["Y"] }
2743 }
2744 }));
2745 merge_into(&mut base, overlay);
2746 assert_eq!(
2747 base.properties["Nested"].required,
2748 vec!["Y".to_string()],
2749 "property-level explicit required replaces base"
2750 );
2751 }
2752
2753 #[test]
2754 fn states_nothing_rejects_description_only_overlay() {
2755 let error = compile("AWS::Test::DescOnly", &json!({ "description": "just a note" }))
2756 .expect_err("a description-only overlay carries no validatable constraint");
2757 assert!(matches!(error, SchemaOverlayError::NoEffect { .. }), "got {error:?}");
2758 }
2759
2760 #[test]
2761 fn states_nothing_accepts_documentation_url_with_property() {
2762 compile(
2763 "AWS::Test::DocUrl",
2764 &json!({
2765 "properties": { "P": { "type": "string" } },
2766 "documentationUrl": "https://example.com"
2767 }),
2768 )
2769 .expect("documentation URL with a property is valid");
2770 }
2771
2772 #[test]
2773 fn states_nothing_rejects_documentation_url_only() {
2774 let error = compile("AWS::Test::DocOnly", &json!({ "documentationUrl": "https://example.com" }))
2775 .expect_err("documentation URL alone has no validatable constraint");
2776 assert!(matches!(error, SchemaOverlayError::NoEffect { .. }), "got {error:?}");
2777 }
2778
2779 #[test]
2780 fn required_present_set_when_raw_has_required_keyword() {
2781 let overlay = compiled(json!({
2782 "properties": { "A": { "type": "string" } },
2783 "required": ["A"]
2784 }));
2785 assert!(overlay.required_present, "required_present must be set when source has 'required'");
2786 }
2787
2788 #[test]
2789 fn required_present_not_set_when_raw_omits_required() {
2790 let overlay = compiled(json!({
2791 "properties": { "A": { "type": "string" } }
2792 }));
2793 assert!(!overlay.required_present, "required_present must not be set when source omits 'required'");
2794 }
2795
2796 #[test]
2797 fn required_present_propagates_into_pattern_properties() {
2798 let mut base = compiled(json!({
2801 "properties": {
2802 "Map": {
2803 "type": "object",
2804 "patternProperties": {
2805 "^k$": { "type": "object", "required": ["A", "B"] }
2806 }
2807 }
2808 }
2809 }));
2810 merge_into(
2811 &mut base,
2812 compiled(json!({
2813 "properties": {
2814 "Map": { "patternProperties": { "^k$": { "required": ["A"] } } }
2815 }
2816 })),
2817 );
2818 assert_eq!(
2819 base.properties["Map"].pattern_properties["^k$"].required,
2820 vec!["A".to_string()],
2821 "a stated required inside patternProperties must replace, not union"
2822 );
2823 }
2824
2825 #[test]
2826 fn removed_required_reports_every_position_deterministically() {
2827 let base = compiled(json!({
2828 "properties": {
2829 "Cfg": { "type": "object", "required": ["Inner", "Kept"] },
2830 "List": { "type": "array", "items": { "type": "object", "required": ["Elem"] } },
2831 "Map": { "type": "object", "patternProperties": { "^k$": { "required": ["P"] } } }
2832 },
2833 "required": ["Cfg", "List"]
2834 }));
2835 let mut merged = base.clone();
2836 merge_into(
2837 &mut merged,
2838 compiled(json!({
2839 "properties": {
2840 "Cfg": { "required": ["Kept"] },
2841 "List": { "items": { "required": [] } },
2842 "Map": { "patternProperties": { "^k$": { "required": [] } } }
2843 },
2844 "required": ["Cfg"]
2845 })),
2846 );
2847 let removals = removed_required(&base, &merged);
2848 assert_eq!(
2849 removals,
2850 vec![
2851 (String::new(), vec!["List".to_string()]),
2852 ("Cfg".to_string(), vec!["Inner".to_string()]),
2853 ("List[]".to_string(), vec!["Elem".to_string()]),
2854 ("Map<patternProperties>.^k$".to_string(), vec!["P".to_string()]),
2855 ],
2856 "every removal must be reported with its schema position, in sorted order"
2857 );
2858 }
2859
2860 #[test]
2861 fn removed_required_is_empty_when_nothing_is_removed() {
2862 let base = compiled(json!({ "properties": { "A": { "type": "string" } }, "required": ["A"] }));
2863 let mut merged = base.clone();
2864 merge_into(
2865 &mut merged,
2866 compiled(json!({ "properties": { "B": { "type": "string" } }, "required": ["A", "B"] })),
2867 );
2868 assert!(removed_required(&base, &merged).is_empty(), "adding a requirement is not a removal");
2869
2870 let mut unioned = base.clone();
2871 merge_into(&mut unioned, compiled(json!({ "properties": { "B": { "type": "string" } } })));
2872 assert!(removed_required(&base, &unioned).is_empty(), "an omitted 'required' keeps the base list");
2873 }
2874
2875 #[test]
2876 fn nested_prop_required_or_makes_merge_non_no_op() {
2877 let overlay_prop = PropSchema { required_or: vec!["X".into(), "Y".into()], ..Default::default() };
2878 assert!(!is_no_op(&overlay_prop), "a PropSchema with required_or must not be a no-op");
2879 }
2880
2881 #[test]
2882 fn nested_prop_required_xor_makes_merge_non_no_op() {
2883 let overlay_prop = PropSchema { required_xor: vec!["X".into(), "Y".into()], ..Default::default() };
2884 assert!(!is_no_op(&overlay_prop), "a PropSchema with required_xor must not be a no-op");
2885 }
2886
2887 #[test]
2888 fn nested_required_or_replaces_on_merge() {
2889 let mut base = compiled(json!({
2890 "properties": {
2891 "Cfg": {
2892 "type": "object",
2893 "properties": { "A": { "type": "string" }, "B": { "type": "string" } },
2894 "requiredOr": ["A", "B"]
2895 }
2896 }
2897 }));
2898 merge_into(
2899 &mut base,
2900 compiled(json!({
2901 "properties": {
2902 "Cfg": { "requiredOr": ["A", "B", "C"] }
2903 }
2904 })),
2905 );
2906 assert_eq!(
2907 base.properties["Cfg"].required_or,
2908 vec!["A".to_string(), "B".to_string(), "C".to_string()],
2909 "nested requiredOr must be replaced by overlay"
2910 );
2911 }
2912
2913 #[test]
2914 fn nested_required_xor_replaces_on_merge() {
2915 let mut base = compiled(json!({
2916 "properties": {
2917 "Cfg": {
2918 "type": "object",
2919 "properties": { "A": { "type": "string" }, "B": { "type": "string" } },
2920 "requiredXor": ["A", "B"]
2921 }
2922 }
2923 }));
2924 merge_into(
2925 &mut base,
2926 compiled(json!({
2927 "properties": {
2928 "Cfg": { "requiredXor": ["A", "B", "C"] }
2929 }
2930 })),
2931 );
2932 assert_eq!(
2933 base.properties["Cfg"].required_xor,
2934 vec!["A".to_string(), "B".to_string(), "C".to_string()],
2935 "nested requiredXor must be replaced by overlay"
2936 );
2937 }
2938
2939 #[test]
2940 fn nested_required_groups_preserved_when_overlay_omits_them() {
2941 let mut base = compiled(json!({
2942 "properties": {
2943 "Cfg": {
2944 "type": "object",
2945 "properties": { "A": { "type": "string" }, "B": { "type": "string" } },
2946 "requiredOr": ["A", "B"],
2947 "requiredXor": ["A", "B"]
2948 }
2949 }
2950 }));
2951 merge_into(
2952 &mut base,
2953 compiled(json!({
2954 "properties": { "Cfg": { "properties": { "C": { "type": "string" } } } }
2955 })),
2956 );
2957 assert_eq!(
2958 base.properties["Cfg"].required_or,
2959 vec!["A".to_string(), "B".to_string()],
2960 "nested requiredOr must be preserved when overlay omits it"
2961 );
2962 assert_eq!(
2963 base.properties["Cfg"].required_xor,
2964 vec!["A".to_string(), "B".to_string()],
2965 "nested requiredXor must be preserved when overlay omits it"
2966 );
2967 }
2968}