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