Skip to main content

schema_validator/
overlay.rs

1//! Runtime compilation and merging of overlay CloudFormation resource provider
2//! schemas on top of the bundled compiled schemas.
3//!
4//! Bundled schemas are compiled at build time from raw CloudFormation registry
5//! JSON into the compiled representation baked into the binary. This module runs
6//! the *same* transform at engine construction time, so callers can supply
7//! additional schemas - for example properties a service has shipped but not yet
8//! published to the CloudFormation registry - and have templates using them
9//! validate without false findings.
10//!
11//! # Merge model
12//!
13//! One model, applied uniformly: an overlay may **add** entries to a collection
14//! and **restate** a single-valued constraint or a logical group, and it never
15//! silently drops a constraint the bundled schema already carries. Adding an
16//! entry to `required` or to a dependency list is stating a constraint, so it can
17//! legitimately produce a finding on a template that violates it; what an overlay
18//! cannot do is make a bundled constraint disappear. Concretely, for every field
19//! of a schema or property:
20//!
21//! | Field kind | Rule |
22//! |------------|------|
23//! | Keyed collections - `properties`, `definitions`, `patternProperties` | deep-merged by key: new keys are added, shared keys recurse |
24//! | `required` | replaced when the overlay states the keyword (even as `[]` - that is how a requirement is cleared, and a removal is logged); unioned into the base when the keyword is omitted |
25//! | Independent-fact collections - the `/properties/...` lifecycle metadata lists, and each key of `dependentRequired`/`dependentExcluded` | unioned, order-preserving, deduplicated |
26//! | Single-valued constraints - `type`, `pattern`, `const`, numeric bounds, lengths, item and property counts, `uniqueItems`, `format`, `description`, `additionalProperties`, `not.enum` | replaced when the overlay supplies them, inherited otherwise |
27//! | Logical groups - `requiredOr`, `requiredXor`, `primaryIdentifier` | replaced as a whole when supplied. Each is *one* group ("at least one of", "exactly one of", "these properties identify the resource"), so unioning two groups would fabricate a third constraint that neither schema states |
28//! | Composition - `allOf`/`anyOf`/`oneOf`/`if`-`then`-`else` | replaced when supplied, because a complete overlay restates the whole composition and appending would duplicate branches. `allOf` splits into plain and conditional entries during compilation, so an overlay supplying `allOf` replaces both halves together |
29//! | Singleton subschemas - `items` (the schema every array element must satisfy) | deep-merged, like one keyed entry: an overlay stating only `pattern` narrows the element schema without discarding the rest of it |
30//! | Schema-level metadata - `replacementStrategy`, `documentationUrl`, `sourceUrl` | replaced when supplied. These enrich reporting and constrain nothing |
31//! | Enums | `enum` and `enumCaseInsensitive` are two representations of one field and are never both populated for a property. Supplying either replaces whichever the bundled schema used, keeping the bundled comparison semantics: widening a case-insensitive enum with a plain `enum` stays case-insensitive, so previously accepted casings keep validating |
32//!
33//! A `$ref` is never folded into the property that points at it. An overlay
34//! extending a `$ref` property has its fields merged *beside* the reference, and
35//! `PropSchema::resolve` combines the
36//! whole chain at validation time - each hop's own constraints applied over its
37//! target, nearest hop winning. So a constraint-only overlay (say, just `enum`)
38//! takes effect, a chain of references is followed to its end, and a definition
39//! changed by a later overlay still reaches every property referencing it. It
40//! also means the merge never depends on the order definitions are visited in.
41//! Within a chain the same rules decide each field, so a hop that restates a
42//! single-valued constraint overrides the one further along, while collections
43//! accumulate across every hop.
44//!
45//! An overlay that supplies a `$ref` for a property updates the reference target
46//! (`ref_name`) but preserves the base property's existing inline/additive
47//! constraints (e.g. pattern, maxLength). This means an overlay can redirect a
48//! property to a different definition without discarding constraints the bundled
49//! schema already carries.
50//!
51//! Overlays for the same type are applied in the order given; a later overlay
52//! sees the result of the earlier ones.
53//!
54//! # Scope limits
55//!
56//! - An overlay cannot remove an entry from a lifecycle metadata list - those
57//!   collections only ever grow. `required` is the one collection with
58//!   replacement semantics (below), and any requirement a replacement removes
59//!   is logged.
60//! - Composition entries (`allOf`/`anyOf`/`oneOf` branches) are full property
61//!   schemas: branch `required`, `additionalProperties`, dependency maps, value
62//!   constraints, and nested conditionals are all evaluated when the branch is
63//!   matched or (for a selected `then`/`else` branch) enforced.
64//! - Constructs the compiled model does not represent - a `$ref` outside
65//!   `#/definitions/`, tuple-form `items`, an unknown property type, malformed
66//!   keyword values, or invalid regular expressions - are rejected.
67//! - Validation keywords with no compiled representation (`propertyNames`,
68//!   `contains`, and `not` other than `not.enum`) are rejected.
69//!   `multipleOf` and `dependencies` (array-form property dependencies) ARE
70//!   represented and accepted.
71//! - Constraint siblings beside a `$ref` are accepted when they have a compiled
72//!   representation (e.g. `pattern`, `maxLength`, `enum`). Only genuinely
73//!   unrepresented keywords are rejected. Annotation-only siblings
74//!   (`description`, `title`) are always accepted. Bundled schemas are compiled
75//!   with draft-07 `$ref` evaluation instead (siblings ignored), so the engine
76//!   never enforces more than CloudFormation's own contract on them; an overlay
77//!   author states siblings deliberately and gets them enforced.
78//! - `enum` and `enumCaseInsensitive` are one field in two comparison modes; an
79//!   overlay cannot switch a property the service treats case-insensitively over
80//!   to case-sensitive comparison, because doing so would reject casings that
81//!   validate today.
82//! - `required` has authoritative replacement semantics: an overlay that
83//!   explicitly states `required` (even as `required: []`) replaces the prior
84//!   required list at that schema level, including clearing it. Omitting
85//!   `required` from the overlay preserves the base's list unchanged.
86//! - Conditional constraints the build pipeline contributes as extension
87//!   fragments are validated from a separate embedded artifact that overlays do
88//!   not merge into, so an overlay cannot suppress a finding originating there.
89//! - Schema-level metadata (`description`, `documentationUrl`, `sourceUrl`,
90//!   `replacementStrategy`) alone is not sufficient - the overlay must carry at
91//!   least one validatable constraint. Metadata enriches diagnostic context only
92//!   when combined with properties, required, or other constraints.
93//! - Overlay-derived resource types, GetAtt attributes and types, Ref return
94//!   types, primary identifiers, and schema metadata are propagated to both rule
95//!   engines. Region-specific availability and enum snapshots remain bundled.
96//!
97//! # Catalog vs. config separation
98//!
99//! The [`SchemaValidator`](crate::SchemaValidator) separates its schema store
100//! (the validated/merged catalog of all compiled schemas) from its construction
101//! config ([`SchemaValidatorConfig`](crate::SchemaValidatorConfig)) so embedders
102//! can serialize/deserialize the config and rebuild the validator without keeping
103//! the merged store around. The overlay catalog exposes overlay-aware metadata
104//! (type names, GetAtt/Ref types, primary identifiers) without re-merging.
105
106use 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
116/// Keywords that carry no validation meaning, so their presence beside a `$ref`
117/// is not worth reporting. Published provider schemas routinely document a
118/// referenced property this way.
119///
120/// Sourced from the shared vocabulary in `data_source::compiled_schema::keywords`.
121const REF_ANNOTATION_KEYWORDS: &[&str] = keywords::REF_ANNOTATION_KEYWORDS;
122
123/// Validation keywords the compiled schema model has no field for, so nothing
124/// enforces them. An overlay stating one would silently weaken the author's
125/// intent - rejected so embedders cannot accidentally rely on a constraint that
126/// nothing checks. `not` is handled separately because only its nested `enum` is
127/// modelled.
128///
129/// Sourced from the shared vocabulary in `data_source::compiled_schema::keywords`.
130const UNREPRESENTED_CONSTRAINT_KEYWORDS: &[&str] = keywords::UNREPRESENTED_VALIDATION_KEYWORDS;
131
132/// The set of fields the runtime evaluates when matching a composition branch.
133/// Since composition branches are now full `PropSchema`s, the runtime evaluates
134/// all representable constraint fields. This list gates what the overlay
135/// preflight accepts - any field not here is rejected so the overlay author
136/// knows it would not fire.
137const 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
176/// Fields the conditional matcher evaluates on the `if` schema itself.
177///
178/// `type` on the `if` schema constrains the instance type: the evaluation
179/// point is always a property object, so `"object"` is a no-op and any other
180/// type makes the condition unsatisfiable there.
181const CONDITION_ALLOWED_FIELDS: [&str; 3] = [keywords::PROPERTIES, keywords::REQUIRED, keywords::TYPE];
182
183/// Fields `condition_matches` evaluates for a property named by an `if` schema.
184/// Every listed field participates in matching: value constraints
185/// (`enum`/`not`/`const`/`pattern`/`type`), nested `required`, and the
186/// length/count bounds (each scoped to its instance type per draft-07).
187const 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
203/// JSON/property type names enforced by `schema-validator`.
204const SUPPORTED_PROPERTY_TYPES: [&str; 9] =
205    ["string", "integer", "number", "double", "float", "boolean", "array", "object", "null"];
206
207/// Fields allowed inside `then`/`else` of conditional `allOf` entries. Since
208/// branches are full `PropSchema`s now, the same set as composition branches
209/// (minus the condition keywords themselves) applies.
210const CONDITIONAL_THEN_ELSE_ALLOWED_FIELDS: &[&str] = COMPOSITION_ALLOWED_FIELDS;
211
212/// Maximum JSON nesting accepted in an overlay schema.
213///
214/// Compilation and merging walk the structure recursively, so an unbounded input
215/// could exhaust the stack and abort the host process. The bound matches the
216/// nesting limit `serde_json` applies when parsing, so a programmatically built
217/// `serde_json::Value` is held to the same limit as the same schema supplied as
218/// text. Real provider schemas nest far shallower - the deepest published
219/// schema measures 18 levels.
220pub const MAX_OVERLAY_DEPTH: usize = 128;
221
222/// Why an overlay schema was rejected.
223///
224/// Every overlay entry point is fallible and validates its input up front: a
225/// rejected overlay leaves the schema store untouched rather than registering a
226/// half-usable schema.
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub enum SchemaOverlayError {
229    /// The resource type name was empty.
230    MissingTypeName,
231    /// The schema was not a JSON object.
232    NotAnObject { type_name: String },
233    /// The schema nests deeper than [`MAX_OVERLAY_DEPTH`].
234    TooDeep { type_name: String, max_depth: usize },
235    /// The schema uses a construct the compiled model cannot represent, which
236    /// would otherwise compile to a weaker schema than the caller wrote.
237    Unsupported { type_name: String, path: String, detail: String },
238    /// The schema carried no keyword the compiler understands, so applying it
239    /// would change nothing.
240    NoEffect { type_name: String },
241    /// The definition `$ref` graph contains a cycle, naming the definitions on it.
242    CyclicRef { type_name: String, cycle: Vec<String> },
243    /// A chain of `$ref`s longer than resolution can follow to its end, which
244    /// would leave the constraints at the end of the chain unenforced.
245    RefChainTooLong { type_name: String, definition: String, hops: usize },
246    /// A property ended up with both enum representations populated, which the
247    /// compiled model forbids.
248    ConflictingEnums { type_name: String, property: String },
249    /// After the whole overlay sequence was applied, a `$ref` still points at a
250    /// definition the schema does not contain, so the property it sits on
251    /// validates nothing.
252    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
309/// Compile a raw CloudFormation resource provider schema (registry JSON) into the
310/// runtime [`CompiledSchema`], rejecting input the merge cannot handle safely.
311///
312/// The raw → compiled transform is single-sourced in `data_source`, the same
313/// function the build pipeline uses for bundled schemas; the result is converted
314/// into the runtime type field by field. The transform is shared but the input is
315/// not enriched the way the build pipeline enriches bundled schemas - see the
316/// `data_source::compiled_schema` module docs.
317pub(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    // Track whether the overlay explicitly stated `required` (even as `[]`), so
358    // merging knows whether to replace the base's required list authoritatively.
359    compiled.required_present = raw.get(keywords::REQUIRED).is_some();
360    // Propagate required_present into property-level and definition-level schemas
361    // where `required` was explicitly stated in their source objects.
362    propagate_required_present(raw, &mut compiled);
363    // Conditionals an overlay author states are enforced in full - no dedicated
364    // rule covers them, unlike bundled conditionals (see
365    // `IfThenElse::enforce_full_branch`).
366    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
373/// Marks every conditional in the compiled overlay - at the schema root, on
374/// properties and definitions at any nesting depth, and inside composition
375/// branches - for full branch enforcement.
376fn 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
410/// Whether a compiled overlay carries no information that would affect
411/// validation - the shape a misspelled or wrong-format JSON object compiles to.
412/// Destructured exhaustively so a new field cannot be omitted from the check.
413///
414/// Schema-level metadata that enriches reporting but constrains nothing
415/// (`description`, `documentationUrl`, `sourceUrl`, `replacementStrategy`) is
416/// not sufficient on its own. These fields enrich diagnostic context when another
417/// constraint fires, but alone they state nothing validatable.
418fn 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
466/// Sets `required_present` on each compiled property/definition whose raw source
467/// explicitly contained a `required` keyword. Walks the schema maps in lockstep
468/// with the raw JSON so presence can be determined per sub-schema.
469fn 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
498/// Rejects overlay JSON that nests deeper than [`MAX_OVERLAY_DEPTH`].
499///
500/// The walk is iterative: a recursive check would itself overflow on the input it
501/// exists to reject.
502fn 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
517/// Rejects schema constructs the compiled model cannot represent.
518///
519/// The compiler was written for the curated schemas the build pipeline produces
520/// and reduces a shape it does not model to an empty one. That is invisible on
521/// curated input and unacceptable on caller input: an overlay that silently
522/// validates less than it states is worse than a rejected one. The walk follows
523/// schema positions only - `properties`, `definitions`, `patternProperties`,
524/// `items`, and composition entries - so a value that merely happens to contain a
525/// key like `type` is not mistaken for a schema.
526fn 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    // Also validate root-level keywords directly.
537    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            // compile_prop now preserves represented constraint siblings beside
556            // a $ref - they are merged at validation time via PropSchema::resolve.
557            // Only reject siblings that are genuinely unrepresented (keywords with
558            // no compiled field), so the overlay author is warned rather than
559            // silently weakened.
560            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 for every schema position (not only root).
604        validate_keyword_types(type_name, &path, members)?;
605        // Reject direct if/then/else at property level.
606        reject_direct_if_then_else(type_name, &path, members)?;
607        push_schema_children(path, value, &mut stack);
608    }
609    Ok(())
610}
611
612/// Validates the type/domain of every keyword the compiler represents.
613///
614/// The compiler silently ignores a wrongly-typed keyword (e.g. `required: 42`
615/// instead of an array of strings). That is invisible on curated input and
616/// dangerous on overlay input: the constraint is silently dropped.
617fn 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    // String arrays: required, enum, not.enum
629    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    // Metadata path arrays: these accept arrays of strings.
644    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    // Dependent maps: { "trigger": ["dep1", "dep2"] }
688    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    // Numeric fields (f64)
709    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    // multipleOf: a positive number
718    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    // dependencies: object where values are arrays of strings (property deps)
728    // or schema objects (schema-form, rejected as unrepresented)
729    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                // Array-form: property dependencies - accepted
734                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                // Schema-form: not represented in the compiled model
742                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    // u64 fields
752    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    // Boolean fields
761    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    // String fields
770    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    // Validate regex patterns using the shared template-model compiler
779    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    // Properties/definitions/patternProperties must be objects with object values
786    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    // Validate patternProperties keys are valid regexes
801    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    // Items must be an object (tuple form already checked above)
810    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    // Composition arrays: entries must be objects
817    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 contain only enforced fields
826                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
836/// Rejects composition entries (`allOf`/`anyOf`/`oneOf`) that contain fields the
837/// compiled model cannot represent or the runtime does not evaluate.
838///
839/// With composition branches being full `PropSchema`s, most constraint keywords
840/// are allowed. Only genuinely unrepresented keywords are rejected.
841fn 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
882/// Validates a conditional `allOf` entry (`if`/`then`/`else`).
883///
884/// The condition (`if`) may use `properties` and `required`; property conditions
885/// are limited to fields `condition_matches` evaluates. The `then`/`else`
886/// branches are restricted to `dependentRequired`/`dependentExcluded`, the only
887/// fields conditional branch evaluation enforces.
888fn 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
977/// Rejects direct `if`/`then`/`else` at root or property level.
978///
979/// Standalone `if`/`then`/`else` (outside `allOf`) is not supported in the
980/// compiled model - it is only recognized inside `allOf` entries. Accepting it
981/// silently would drop the constraint.
982fn 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            // Direct if/then/else is supported at property level (compiled into
990            // if_then_else) and inside allOf entries. At the schema root the
991            // compiler only reads conditionals from inside `allOf`, so a
992            // root-level standalone conditional would be silently dropped - a
993            // sibling `allOf` key does not move the root conditional into that
994            // array.
995            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
1010/// Rejects validation keywords the compiled model has no field for.
1011///
1012/// Promoted from warning-only to an error so embedders cannot silently weaken
1013/// schemas by stating constraints nothing enforces.
1014fn 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
1043/// Pushes every child of `value` that is itself in a schema position.
1044fn 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
1074/// Checks the invariants the compiled model relies on: an acyclic definition
1075/// `$ref` graph, and at most one enum representation per property.
1076///
1077/// Runs on the schema as it will be stored, so it covers both a freshly inserted
1078/// overlay and the result of merging one into a bundled schema.
1079pub(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    // Collect all conflicting-enum paths, including from composition property
1094    // maps, then sort deterministically before reporting the first one.
1095    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    // Traverse composition/condition property maps too.
1100    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
1125/// Maximum `$ref` hops in a chain of definitions. One shorter than
1126/// [`MAX_REF_CHAIN`], because resolution starts at the property referencing the
1127/// first definition - so a chain of this length still resolves in full instead of
1128/// being cut short.
1129const MAX_DEFINITION_REF_CHAIN: usize = MAX_REF_CHAIN - 1;
1130
1131/// A definition `$ref` graph resolution could not follow faithfully.
1132enum RefChainDefect {
1133    /// The definitions on a cycle, in the order they were walked.
1134    Cycle(Vec<String>),
1135    /// A chain resolution would cut short before reaching its end.
1136    TooLong { start: String, hops: usize },
1137}
1138
1139/// Returns the first defect in the definition `$ref` graph, or `None` when every
1140/// chain is acyclic and short enough to resolve in full.
1141fn 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(&current) {
1149                chain.push(current);
1150                return Some(RefChainDefect::Cycle(chain));
1151            }
1152            chain.push(current.clone());
1153            match defs.get(&current).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
1165/// Logs every requirement that merging `merged` over `base` removed.
1166///
1167/// An overlay that states `required` replaces the base's list at that schema
1168/// level; a complete provider schema does this deliberately, while a partial
1169/// overlay that restates `required` does it by accident. Either way a bundled
1170/// constraint disappearing is worth saying out loud - silently weakening the
1171/// schema is the failure mode this module exists to prevent.
1172pub(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
1184/// Every `(path, removed names)` pair where `merged` requires less than `base`
1185/// at the same schema position. Paths are sorted so repeated runs report
1186/// identically.
1187pub(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
1212/// Records the names present in `before` but missing from `after`.
1213fn 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
1220/// Pushes every key the base and merged maps share, pairing the two sides.
1221fn 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
1235/// Logs every `$ref` pointing at a definition the schema does not contain.
1236///
1237/// Such a property carries no constraints at all, so a mistyped definition name
1238/// would otherwise quietly stop validating anything. It is a warning rather than a
1239/// rejection because overlays apply in sequence, and an earlier one may reference a
1240/// definition a later one supplies.
1241pub(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
1251/// Every `(path, target)` pair where a `$ref` points at a definition the schema
1252/// does not contain, sorted for deterministic reporting.
1253///
1254/// Walked per overlay for a warning (a later overlay in the sequence may supply
1255/// the definition) and again after the whole sequence, where a survivor is
1256/// rejected - a property referencing nothing validates nothing, which is the
1257/// silent weakening this module exists to prevent.
1258pub(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    // Also traverse composition/condition property maps.
1263    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) = &current.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) = &current.items {
1291            stack.push((format!("{path}[]"), items));
1292        }
1293    }
1294    dangling.sort();
1295    dangling
1296}
1297
1298/// Helper: push condition schema properties onto the traversal stack.
1299fn 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
1308/// Collect all conflicting-enum paths into the `out` vec (does not short-circuit).
1309fn 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
1324/// Collect conflicting enums from a condition schema.
1325fn 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
1334/// Deep-merge an overlay [`CompiledSchema`] into an existing bundled schema in
1335/// place, following the model documented at the module level.
1336pub(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    // An overlay that explicitly states `required` (even as `[]`) authoritatively
1341    // replaces the prior required list - this is how an overlay clears required
1342    // properties. Omitting `required` from the overlay preserves the base.
1343    // `apply_overlay` reports any requirement a replacement removes, so a
1344    // partial overlay that restates `required` carelessly is called out.
1345    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    // Property-path metadata lists only ever grow: an overlay that names one
1358    // more deprecated property must not delete the bundled deprecations.
1359    // `write_only`/`create_only`/`deprecated`/`conditional_create_only` drive
1360    // their own diagnostics; `read_only` and the primary identifier feed the
1361    // overlay catalog (GetAtt and Ref metadata for the rule engines).
1362    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    // The primary identifier is one identity tuple, not a set of independent
1368    // facts, so a supplied one replaces the bundled tuple.
1369    replace_if_present(&mut base.primary_identifier, overlay.primary_identifier);
1370
1371    // `allOf` is split during compilation into plain subschemas and conditional
1372    // `if`/`then`/`else` entries. They come from one keyword, so an overlay that
1373    // supplies it replaces both halves together - replacing only the half it
1374    // happens to populate would leave a composition matching neither schema.
1375    //
1376    // This replacement does NOT suppress extension-origin findings: conditional
1377    // constraints from the build pipeline's extension fragments are stored in a
1378    // separately embedded ExtensionStore that overlays do not merge into. An
1379    // overlay replacing `allOf` here only affects the main schema's composition
1380    // - the extension-contributed conditionals remain validated independently.
1381    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    // Each of these is a single logical group, so a supplied group replaces the
1392    // bundled one; unioning would state a constraint neither schema makes.
1393    replace_if_present(&mut base.required_or, overlay.required_or);
1394    replace_if_present(&mut base.required_xor, overlay.required_xor);
1395}
1396
1397/// Merges overlay definitions into `base`.
1398///
1399/// New definitions are inserted and shared ones are merged in place. Nothing here
1400/// depends on the order definitions are visited, because a `$ref` is never folded
1401/// into the property or definition that points at it - resolution happens at
1402/// validation time against the final definition set (see
1403/// `PropSchema::resolve`).
1404fn 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
1415/// Deep-merge an overlay property schema into an existing one in place.
1416pub(crate) fn merge_prop(base: &mut PropSchema, overlay: PropSchema) {
1417    let mut overlay = overlay;
1418    // An overlay that supplies a `$ref` updates the reference target, and its
1419    // remaining fields merge normally below - constraint siblings the overlay
1420    // states beside its reference are preserved, not discarded. The base's
1421    // existing inline constraints also survive, so redirecting a reference
1422    // never silently weakens the property.
1423    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    // A `$ref` on the base is deliberately left in place. The overlay's fields are
1431    // merged beside it and combined with the referenced definition when validation
1432    // resolves the property, so a definition changed by a later overlay still
1433    // reaches every property that points at it.
1434    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    // Each is a single logical group, so a supplied group replaces the base;
1476    // unioning would fabricate a constraint neither schema states.
1477    replace_if_present(&mut base.required_or, overlay.required_or);
1478    replace_if_present(&mut base.required_xor, overlay.required_xor);
1479}
1480
1481/// Applies the overlay's allowed-value list, keeping the two enum
1482/// representations mutually exclusive.
1483///
1484/// `enum` and `enumCaseInsensitive` are the same field in two comparison modes,
1485/// evaluated independently by the validator, so leaving both populated would
1486/// report a value twice with contradictory allowed sets. When the bundled schema
1487/// compares case-insensitively - because the service accepts any casing - a
1488/// plain `enum` overlay keeps that mode: the overlay author is widening the value
1489/// list, not tightening the comparison, and reinterpreting it as case-sensitive
1490/// would reject casings that validate today.
1491fn 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
1516/// Unions each trigger's dependency list rather than replacing it, so an overlay
1517/// naming one dependency of an existing trigger keeps the bundled ones.
1518fn 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
1524/// Whether merging this overlay property would change nothing.
1525///
1526/// Destructured exhaustively so a new field cannot be forgotten here and make an
1527/// overlay silently look empty.
1528fn 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
1603/// Append items from `extra` not already present in `base`.
1604fn 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
1612/// Replace `base` with `overlay` only when the overlay is non-empty.
1613fn replace_if_present<T>(base: &mut Vec<T>, overlay: Vec<T>) {
1614    if !overlay.is_empty() {
1615        *base = overlay;
1616    }
1617}
1618
1619/// Replace `base` with `overlay` only when the overlay supplies a value.
1620fn 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        // Each `properties` wrapper contributes two levels (the map, then the
1692        // schema inside it), on top of the three the outer schema already uses, so
1693        // this is the deepest nesting the limit admits.
1694        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    /// Builds a value whose deepest member sits exactly `depth` levels down, so the
1703    /// accept/reject boundary can be stated to the level rather than to the two
1704    /// levels a `properties` wrapper adds at a time.
1705    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        // A property literally named `type` or `items` is ordinary schema content
1775        // and must not be mistaken for the keyword.
1776        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        // Enum values are data, not schemas, so a key like `type` inside one must
1791        // not be inspected as a schema keyword.
1792        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        // A schema stating both forms for one property is reachable from caller
1799        // input: the compiler parses both keywords, and the validator evaluates
1800        // them independently, which would report one value against two different
1801        // allowed-value lists.
1802        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    /// The schema that actually applies to a top-level property, i.e. what
2125    /// validation sees: the `$ref` chain resolved and any fields stated beside the
2126    /// reference merged on top.
2127    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        // The bundled definition `Config` references `Common`, which only the
2192        // overlay supplies.
2193        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        // The regression that merge-time inlining caused: extending a `$ref`
2218        // property and then widening the definition it points at.
2219        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        // `allOf` compiles into plain entries plus conditional ones. An overlay
2290        // supplying only conditional entries must still clear the bundled plain
2291        // entries, or the merged composition matches neither schema.
2292        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        // Many definitions merged at once, one of them referenced by another: the
2343        // effective schema must not depend on which order the map happens to yield.
2344        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    // -------------------------------------------------- keyword type validation
2370
2371    #[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    // ------------------------------------------ composition rejection
2415
2416    #[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    // ------------------------------------------ deterministic conflict reporting
2495
2496    #[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    // ------------------------------------------ low-level compile whitespace/mismatch
2514
2515    #[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    // ------------------------------------------ unrepresented keywords now error
2540
2541    #[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    // ------------------------------------------ $ref preserves base constraints
2561
2562    #[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        // anyOf inside a condition `if` block is still not supported (nested
2600        // condition schemas are not reachable from caller-supplied overlays).
2601        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        // `required` stated inside a patternProperties value schema must carry
2799        // the same replacement semantics as every other schema position.
2800        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}