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