#![allow(clippy::redundant_pub_crate)]
use crate::types::protocol::Era;
use serde_json::Value;
#[cfg(feature = "validation")]
const DRAFT_2020_12: &str = "https://json-schema.org/draft/2020-12/schema";
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) fn warn_on_schema_mismatch(tool: &str, schema: &Value, value: &Value, era: Option<Era>) {
#[cfg(feature = "validation")]
{
if !tracing::enabled!(tracing::Level::WARN) {
return;
}
if let Some(mismatch) = schema_mismatch(schema, value, era) {
tracing::warn!(
tool,
"structuredContent does not conform to the declared outputSchema: {mismatch}"
);
}
}
#[cfg(not(feature = "validation"))]
let _ = (tool, schema, value, era);
}
#[cfg(feature = "validation")]
pub(crate) fn schema_mismatch(schema: &Value, value: &Value, era: Option<Era>) -> Option<String> {
match cached_validator(era, schema) {
Ok(validator) => {
if validator.is_valid(value) {
return None;
}
let errors: Vec<String> = validator
.iter_errors(value)
.map(|e| format!("{} (at {})", e, e.instance_path()))
.collect();
Some(errors.join("; "))
},
Err(e) => Some(format!(
"declared outputSchema is not a valid JSON Schema: {e}"
)),
}
}
#[cfg(feature = "validation")]
const DATA_ONLY_KEYWORDS: &[&str] = &["const", "enum", "default", "examples"];
#[cfg(feature = "validation")]
const SUBSCHEMA_MAP_KEYWORDS: &[&str] = &[
"properties",
"patternProperties",
"$defs",
"definitions",
"dependentSchemas",
"dependencies", ];
#[cfg(feature = "validation")]
fn first_legacy_dialect(node: &Value) -> Option<&str> {
match node {
Value::Object(map) => {
if let Some(declared) = map.get("$schema").and_then(Value::as_str) {
if declared != DRAFT_2020_12 {
return Some(declared);
}
}
map.iter().find_map(|(member_key, member_value)| {
first_legacy_dialect_in_member(member_key, member_value)
})
},
Value::Array(items) => items.iter().find_map(first_legacy_dialect),
_ => None,
}
}
#[cfg(feature = "validation")]
fn first_legacy_dialect_in_member<'a>(
member_key: &str,
member_value: &'a Value,
) -> Option<&'a str> {
match member_value {
Value::Object(named_subschemas) if SUBSCHEMA_MAP_KEYWORDS.contains(&member_key) => {
named_subschemas.values().find_map(first_legacy_dialect)
},
_ if DATA_ONLY_KEYWORDS.contains(&member_key) => None,
_ => first_legacy_dialect(member_value),
}
}
#[cfg(feature = "validation")]
fn pin_dialect_in_place(node: &mut Value) {
match node {
Value::Object(map) => {
if map.get("$schema").is_some_and(Value::is_string) {
map.insert(
"$schema".to_string(),
Value::String(DRAFT_2020_12.to_string()),
);
}
for (member_key, member_value) in map.iter_mut() {
pin_dialect_in_member(member_key, member_value);
}
},
Value::Array(items) => items.iter_mut().for_each(pin_dialect_in_place),
_ => {},
}
}
#[cfg(feature = "validation")]
fn pin_dialect_in_member(member_key: &str, member_value: &mut Value) {
if SUBSCHEMA_MAP_KEYWORDS.contains(&member_key) {
match member_value {
Value::Object(named_subschemas) => {
named_subschemas.values_mut().for_each(pin_dialect_in_place);
},
malformed => pin_dialect_in_place(malformed),
}
} else if !DATA_ONLY_KEYWORDS.contains(&member_key) {
pin_dialect_in_place(member_value);
}
}
#[cfg(feature = "validation")]
fn normalize_schema_dialect(schema: &Value) -> std::borrow::Cow<'_, Value> {
use std::borrow::Cow;
if first_legacy_dialect(schema).is_none() {
return Cow::Borrowed(schema);
}
let mut pinned = schema.clone();
pin_dialect_in_place(&mut pinned);
Cow::Owned(pinned)
}
#[cfg(feature = "validation")]
fn compile_2020_12(
schema: &Value,
) -> Result<jsonschema::Validator, jsonschema::ValidationError<'static>> {
let normalized = normalize_schema_dialect(schema);
if matches!(normalized, std::borrow::Cow::Owned(_)) {
let declared = first_legacy_dialect(schema).unwrap_or("<unknown>");
tracing::warn!(
declared,
"outputSchema declares JSON Schema {declared} at the document root or on an embedded \
schema resource; MCP 2026-07-28 pins Draft 2020-12, so every such declaration is \
ignored and the schema is validated as 2020-12"
);
}
jsonschema::draft202012::new(&normalized)
}
#[cfg(feature = "validation")]
fn compile_for_era(era: Era, schema: &Value) -> Result<jsonschema::Validator, std::sync::Arc<str>> {
match era {
Era::V1 => jsonschema::validator_for(schema),
Era::V2 => compile_2020_12(schema),
}
.map_err(|e| std::sync::Arc::from(e.to_string().as_str()))
}
#[cfg(feature = "validation")]
fn cached_validator(
era: Option<Era>,
schema: &Value,
) -> Result<std::sync::Arc<jsonschema::Validator>, std::sync::Arc<str>> {
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
type Cache = Mutex<HashMap<(Era, String), Result<Arc<jsonschema::Validator>, Arc<str>>>>;
static CACHE: OnceLock<Cache> = OnceLock::new();
let resolved_era = era.unwrap_or(Era::V1);
let key = (resolved_era, schema.to_string());
let cache = CACHE.get_or_init(Cache::default);
let mut map = cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
map.entry(key)
.or_insert_with(|| compile_for_era(resolved_era, schema).map(Arc::new))
.clone()
}
#[cfg(all(feature = "fuzzing", feature = "validation"))]
pub mod fuzz_support {
use super::{compile_for_era, normalize_schema_dialect};
use crate::types::protocol::Era;
use serde_json::Value;
pub const DATA_ONLY_KEYWORDS: &[&str] = super::DATA_ONLY_KEYWORDS;
pub const SUBSCHEMA_MAP_KEYWORDS: &[&str] = super::SUBSCHEMA_MAP_KEYWORDS;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SchemaVerdict {
Conforms,
Violates,
InvalidSchema,
}
#[must_use]
pub fn validate_bytes(
schema_bytes: &[u8],
instance_bytes: &[u8],
) -> Option<(SchemaVerdict, SchemaVerdict)> {
let schema: Value = serde_json::from_slice(schema_bytes).ok()?;
let instance: Value = serde_json::from_slice(instance_bytes).ok()?;
Some((
verdict(Era::V1, &schema, &instance),
verdict(Era::V2, &schema, &instance),
))
}
fn verdict(era: Era, schema: &Value, instance: &Value) -> SchemaVerdict {
match compile_for_era(era, schema) {
Ok(validator) => {
if validator.is_valid(instance) {
SchemaVerdict::Conforms
} else {
SchemaVerdict::Violates
}
},
Err(_) => SchemaVerdict::InvalidSchema,
}
}
#[must_use]
pub fn normalize_bytes(schema_bytes: &[u8]) -> Option<(Value, Value, Value)> {
let input: Value = serde_json::from_slice(schema_bytes).ok()?;
let once = normalize_schema_dialect(&input).into_owned();
let twice = normalize_schema_dialect(&once).into_owned();
Some((input, once, twice))
}
}
#[cfg(all(test, feature = "fuzzing", feature = "validation"))]
mod fuzz_support_tests {
use super::fuzz_support::{normalize_bytes, validate_bytes, SchemaVerdict};
#[test]
fn fuzz_support_returns_none_for_unparseable_input() {
assert_eq!(
validate_bytes(b"{not json", b"{}"),
None,
"an unparseable SCHEMA must produce no verdict pair"
);
assert_eq!(
validate_bytes(b"{}", b"{not json"),
None,
"an unparseable INSTANCE must produce no verdict pair"
);
assert_eq!(
normalize_bytes(b"\xff\xfe\xfd"),
None,
"normalize_bytes must refuse non-JSON rather than panicking"
);
}
#[test]
fn fuzz_support_reports_violates_for_a_scalar_against_an_object_schema() {
assert_eq!(
validate_bytes(br#"{"type":"object"}"#, b"42"),
Some((SchemaVerdict::Violates, SchemaVerdict::Violates)),
"an object schema must report a scalar instance on both eras"
);
}
#[test]
fn fuzz_support_reports_invalid_schema_for_an_external_ref() {
assert_eq!(
validate_bytes(br#"{"$ref":"https://example.com/x.json"}"#, b"{}"),
Some((SchemaVerdict::InvalidSchema, SchemaVerdict::InvalidSchema)),
"an external $ref must be a COMPILE failure — never a fetch, and never \
indistinguishable from a violating instance"
);
}
#[test]
fn fuzz_support_reports_the_divergent_content_encoding_case_asymmetrically() {
let schema = br#"{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "string",
"contentEncoding": "base64",
"description": "fuzz-seam-divergence"
}"#;
assert_eq!(
validate_bytes(schema, br#""!!!not-base64!!!""#),
Some((SchemaVerdict::Violates, SchemaVerdict::Conforms)),
"the eras must genuinely disagree here; if they agree, either the v1 arm stopped \
auto-detecting draft-07 or the v2 pin stopped applying, and the fuzz target's \
dialect-NEUTRAL restriction has lost its reason to exist"
);
}
#[test]
fn fuzz_support_normalize_bytes_is_idempotent() {
let (input, once, twice) = normalize_bytes(
br#"{"$schema":"http://json-schema.org/draft-07/schema#","type":"object"}"#,
)
.expect("the literal above is valid JSON");
assert_eq!(once, twice, "normalization must be idempotent");
assert_eq!(
once.get("$schema").and_then(serde_json::Value::as_str),
Some("https://json-schema.org/draft/2020-12/schema"),
"the root $schema must be OVERWRITTEN with the 2020-12 URI, not deleted"
);
let mut before = input;
let mut after = once;
for document in [&mut before, &mut after] {
if let Some(object) = document.as_object_mut() {
object.remove("$schema");
}
}
assert_eq!(
before, after,
"normalization touched a key other than a $schema key"
);
}
}
#[cfg(all(test, feature = "validation"))]
mod tests {
use super::*;
use serde_json::json;
fn person_schema() -> Value {
json!({
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer" }
},
"required": ["name"]
})
}
#[test]
fn conforming_value_yields_none() {
let value = json!({ "name": "Ada", "age": 36 });
assert_eq!(schema_mismatch(&person_schema(), &value, None), None);
}
#[test]
fn non_conforming_value_yields_message() {
let value = json!({ "age": "not-a-number" });
let mismatch = schema_mismatch(&person_schema(), &value, None)
.expect("missing required field + wrong type must be reported");
assert!(
mismatch.contains("name"),
"message names the missing required field: {mismatch}"
);
}
#[test]
fn invalid_schema_yields_message() {
let bad_schema = json!({ "type": 42 });
let mismatch = schema_mismatch(&bad_schema, &json!({}), None)
.expect("an uncompilable schema must be reported, not ignored");
assert!(
mismatch.contains("outputSchema"),
"message says the schema itself is at fault: {mismatch}"
);
}
#[test]
fn repeated_checks_reuse_the_cached_validator() {
let schema = person_schema();
for i in 0..8 {
let ok = json!({ "name": format!("p{i}") });
assert_eq!(schema_mismatch(&schema, &ok, None), None);
assert!(schema_mismatch(&schema, &json!({ "age": i }), None).is_some());
}
}
#[test]
fn warn_never_panics_on_mismatch() {
warn_on_schema_mismatch("demo_tool", &person_schema(), &json!({ "age": 1 }), None);
}
const DRAFT_07: &str = "http://json-schema.org/draft-07/schema#";
fn draft_07_declared_schema() -> Value {
json!({
"$schema": DRAFT_07,
"type": "object",
"properties": { "n": { "type": "integer" } },
"required": ["n"]
})
}
#[test]
fn v2_pin_still_enforces_a_draft_07_declared_schema() {
let schema = draft_07_declared_schema();
assert!(
schema_mismatch(&schema, &json!({ "wrong": true }), Some(Era::V2)).is_some(),
"BYPASS: the v2 Draft 2020-12 pin accepted an instance missing the REQUIRED `n`. A \
`None` here means the pin compiled the draft-07 declaration into a VACUOUS validator \
(empty vocabulary set) and emit-time output validation has silently become a no-op \
for every schema that declares a legacy $schema. Restore the normalize-then-pin step \
in `compile_2020_12`."
);
assert!(
schema_mismatch(&schema, &json!({ "n": "not-an-int" }), Some(Era::V2)).is_some(),
"BYPASS: the v2 Draft 2020-12 pin accepted a STRING where the schema declares \
`integer`. See the message above — `type` is one of the seven keywords measured to \
be silently dropped when the $schema declaration is not normalized first."
);
assert_eq!(
schema_mismatch(&schema, &json!({ "n": 7 }), Some(Era::V2)),
None,
"a conforming instance must still pass under the pin — the fix restores enforcement, \
it does not make everything fail"
);
}
fn embedded_legacy_resource_named(definition_name: &str) -> Value {
json!({
"type": "object",
"properties": { "n": { "$ref": format!("#/$defs/{definition_name}") } },
"$defs": {
definition_name: {
"$id": "https://example.test/inner",
"$schema": DRAFT_07,
"type": "integer"
}
}
})
}
fn embedded_legacy_resource_schema() -> Value {
embedded_legacy_resource_named("Inner")
}
fn embedded_legacy_resource_in_container(container: &str, name: &str) -> Value {
json!({
"type": "object",
container: {
name: {
"$id": "https://example.test/inner",
"$schema": DRAFT_07,
"type": "integer"
}
}
})
}
fn embedded_legacy_resource_in_array(container: &str, index: usize) -> Value {
let mut branches: Vec<Value> = (0..index).map(|_| json!({})).collect();
branches.push(json!({
"$id": "https://example.test/inner",
"$schema": DRAFT_07,
"type": "integer"
}));
json!({ "type": "object", container: branches })
}
fn properties_embedded_legacy_resource_named(property_name: &str) -> Value {
embedded_legacy_resource_in_container("properties", property_name)
}
fn embedded_resource_control_schema() -> Value {
json!({
"type": "object",
"properties": { "n": { "$ref": "#/$defs/Inner" } },
"$defs": {
"Inner": {
"$id": "https://example.test/inner",
"type": "integer"
}
}
})
}
fn root_and_embedded_legacy_schema() -> Value {
let mut schema = embedded_legacy_resource_schema();
schema
.as_object_mut()
.expect("the literal above is an object")
.insert("$schema".to_string(), Value::String(DRAFT_07.to_string()));
schema
}
#[test]
fn v2_pin_still_enforces_an_embedded_legacy_resource() {
let violating = json!({ "n": "NOT-AN-INTEGER" });
let conforming = json!({ "n": 7 });
let rows = [
(
"embedded-legacy-resource",
embedded_legacy_resource_schema(),
),
(
"control-no-nested-schema",
embedded_resource_control_schema(),
),
("root-draft07 + embedded", root_and_embedded_legacy_schema()),
];
for (label, schema) in &rows {
assert!(
schema_mismatch(schema, &violating, Some(Era::V2)).is_some(),
"BYPASS ({label}): the v2 Draft 2020-12 pin accepted a STRING where the embedded \
schema resource declares `integer`. A `None` here means the legacy `$schema` on \
the `$id`-bearing `$defs.Inner` survived normalization, resolved an EMPTY \
vocabulary set there and produced a sub-validator that accepts everything — the \
vacuous-validator bypass the pin exists to close, moved one level down. \
`normalize_schema_dialect` must rewrite EVERY dialect declaration, not just the \
root one."
);
assert_eq!(
schema_mismatch(schema, &conforming, Some(Era::V2)),
None,
"({label}) a conforming instance must still pass under the pin — the fix restores \
enforcement, it does not make everything fail"
);
}
let regression_direction = root_and_embedded_legacy_schema();
assert!(
schema_mismatch(®ression_direction, &violating, Some(Era::V1)).is_some(),
"v1 must keep rejecting this instance — D-01 freezes the v1 arm, so if this became a \
`None` the v1 auto-detect wire moved, which this phase declined to do"
);
assert!(
schema_mismatch(®ression_direction, &violating, Some(Era::V2)).is_some(),
"REGRESSION DIRECTION: `(v1, v2) = (Violates, Conforms)` — v2 accepting an instance \
v1 correctly rejects is the exact regression SCHM-01 was written to forbid. \
Measured as (Violates, Conforms) before 115-12; it must now be (Violates, Violates)."
);
assert_eq!(
schema_mismatch(
&embedded_legacy_resource_schema(),
&violating,
Some(Era::V1)
),
None,
"v1 is frozen by D-01: its auto-detect honours the embedded draft-07 declaration and \
drops `type` there, measured `(Conforms, Conforms)`. A `Some` here means the v1 arm \
changed behaviour, which is a breaking change for every 2025-11-25 server and is not \
what 115-12 was allowed to do"
);
}
#[test]
fn v2_pin_still_enforces_an_embedded_resource_named_like_a_data_keyword() {
use std::borrow::Cow;
let violating = json!({ "n": "NOT-AN-INTEGER" });
let conforming = json!({ "n": 7 });
for definition_name in ["const", "enum", "default", "examples", "Inner"] {
let schema = embedded_legacy_resource_named(definition_name);
assert!(
schema_mismatch(&schema, &violating, Some(Era::V2)).is_some(),
"BYPASS ($defs.{definition_name}): the v2 Draft 2020-12 pin accepted a STRING \
where the embedded schema resource declares `integer`. Measured before 115-14: \
`$defs.default` -> verdicts=(Conforms, Conforms), rewritten=false, against the \
control `$defs.Inner` -> (Conforms, Violates), rewritten=true. A `$defs` key is \
an AUTHOR-CHOSEN NAME, never a keyword, so DATA_ONLY_KEYWORDS must NOT be \
applied to it — the values of a $defs / properties / patternProperties / \
definitions / dependentSchemas map are schema positions REGARDLESS of the name \
they are filed under. See SUBSCHEMA_MAP_KEYWORDS."
);
assert_eq!(
schema_mismatch(&schema, &conforming, Some(Era::V2)),
None,
"($defs.{definition_name}) a conforming instance must still pass under the pin — \
the position rule restores enforcement, it does not make everything fail"
);
}
for &property_name in DATA_ONLY_KEYWORDS {
let schema = properties_embedded_legacy_resource_named(property_name);
let normalized = normalize_schema_dialect(&schema);
assert!(
matches!(normalized, Cow::Owned(_)),
"properties.{property_name} carries an $id-bearing embedded resource with a \
legacy $schema and was NOT rewritten (Cow::Borrowed). `properties` keys are \
instance-property NAMES, author-chosen exactly like $defs keys, so the \
DATA_ONLY_KEYWORDS filter must not reach them. This half is structural because \
jsonschema 0.49.2 happens to still enforce `type` here today — a behavioural \
assertion would pass against the defective code."
);
assert_eq!(
normalized
.pointer(&format!("/properties/{property_name}/$schema"))
.and_then(Value::as_str),
Some(DRAFT_2020_12),
"properties.{property_name}/$schema must be OVERWRITTEN with the 2020-12 URI. A \
surviving legacy declaration on an $id-bearing resource resolves an EMPTY \
vocabulary set the moment the library's current behaviour changes."
);
}
for &keyword in DATA_ONLY_KEYWORDS {
let document = json!({
"type": "object",
keyword: { "$schema": DRAFT_07, "note": "data" }
});
let normalized = normalize_schema_dialect(&document);
assert!(
matches!(normalized, Cow::Borrowed(_)),
"a $schema inside a REAL `{keyword}` payload is instance DATA, not a dialect \
declaration, so nothing must be cloned for {document}. If this allocated, the \
position-aware fix was implemented by DELETING the data guard instead of by \
distinguishing NAME position from KEYWORD position."
);
assert_eq!(
*normalized, document,
"a $schema inside a REAL `{keyword}` payload must come back byte-identical — \
rewriting it changes which instances conform, which is a semantic corruption of \
the author's schema and not a normalization"
);
}
}
#[test]
fn v2_pin_rewrites_an_embedded_resource_in_every_spec_defined_subschema_map() {
use std::borrow::Cow;
let containers = [
"properties",
"patternProperties",
"$defs",
"definitions",
"dependentSchemas",
"dependencies",
];
let mut examined = 0usize;
let mut violations: Vec<String> = Vec::new();
for container in containers {
for &name in DATA_ONLY_KEYWORDS {
examined += 1;
let schema = embedded_legacy_resource_in_container(container, name);
let normalized = normalize_schema_dialect(&schema);
let rewritten = matches!(normalized, Cow::Owned(_));
let declared = normalized
.pointer(&format!("/{container}/{name}/$schema"))
.and_then(Value::as_str);
if !rewritten || declared != Some(DRAFT_2020_12) {
violations.push(format!(
"{container}/{name}: rewritten={rewritten}, \
/{container}/{name}/$schema={declared:?}"
));
}
}
}
assert_eq!(
examined,
containers.len() * DATA_ONLY_KEYWORDS.len(),
"the fence must examine every (container, name) pair — {} containers x {} \
data-only names",
containers.len(),
DATA_ONLY_KEYWORDS.len()
);
assert!(
violations.is_empty(),
"an $id-bearing embedded schema resource carrying a legacy $schema was NOT rewritten \
in {} of {examined} (container, name) positions:\n{violations:#?}\nEach of the six \
containers above is a spec-defined map from AUTHOR-CHOSEN NAMES to subschemas, so \
DATA_ONLY_KEYWORDS must never be tested against its keys. This assertion is \
STRUCTURAL, not behavioural, on purpose: on jsonschema 0.49.2 both \
`dependencies.Inner` and `dependencies.default` report (Violates, Violates), so a \
verdict assertion would pass against the defective code. The observable is the \
borrow/own decision — which is also what gates compile_2020_12's tracing::warn!, so \
with Cow::Borrowed the author is told nothing at all. See SUBSCHEMA_MAP_KEYWORDS.",
violations.len()
);
assert!(
SUBSCHEMA_MAP_KEYWORDS.contains(&"dependencies"),
"SUBSCHEMA_MAP_KEYWORDS omits `dependencies` — 115-REVIEW.md CR-01. It is \
draft-04..2019-09's own map-from-instance-property-NAME-to-subschema keyword, and \
`jsonschema` 0.49.2 still honours it under the 2020-12 pin (D-115-03-C, measured by \
this module's own fuzz_support_tests), which is what makes its VALUES live schema \
positions."
);
}
#[test]
fn v2_pin_rewrites_an_embedded_resource_at_every_spec_defined_array_position() {
use std::borrow::Cow;
let containers = ["allOf", "anyOf", "oneOf", "prefixItems"];
let indices = [0usize, 2];
let mut examined = 0usize;
let mut violations: Vec<String> = Vec::new();
for container in containers {
for index in indices {
examined += 1;
let schema = embedded_legacy_resource_in_array(container, index);
let normalized = normalize_schema_dialect(&schema);
let rewritten = matches!(normalized, Cow::Owned(_));
let declared = normalized
.pointer(&format!("/{container}/{index}/$schema"))
.and_then(Value::as_str);
if !rewritten || declared != Some(DRAFT_2020_12) {
violations.push(format!(
"{container}[{index}]: rewritten={rewritten}, \
/{container}/{index}/$schema={declared:?}"
));
}
}
}
assert_eq!(
examined, 8,
"the fence must examine all 8 (array keyword, index) positions — a shortened \
`containers` or `indices` literal is exactly the drift this hard-coded count \
exists to catch, and a length-derived expectation cannot see it"
);
assert!(
violations.is_empty(),
"an $id-bearing embedded schema resource carrying a legacy $schema was NOT rewritten \
in {} of {examined} ARRAY positions:\n{violations:#?}\nEach keyword above holds its \
subschemas in a JSON array, so both halves reach them through their `Value::Array` \
arm — deleting those arms is what this fence exists to catch (115-REVIEW.md WR-03). \
An array ELEMENT has no key, so DATA_ONLY_KEYWORDS can never apply here and no \
name-collision shape is possible; the observable is the borrow/own decision, which \
is also what gates compile_2020_12's tracing::warn!.",
violations.len()
);
}
#[test]
fn keyword_lists_are_disjoint() {
let overlap: Vec<&&str> = SUBSCHEMA_MAP_KEYWORDS
.iter()
.filter(|keyword| DATA_ONLY_KEYWORDS.contains(keyword))
.collect();
assert!(
overlap.is_empty(),
"{overlap:?} appear in BOTH SUBSCHEMA_MAP_KEYWORDS and DATA_ONLY_KEYWORDS. The two \
member dispatches silently depend on these lists being disjoint: the DETECTOR \
(first_legacy_dialect_in_member) is a `match` guarding on the VALUE kind and the key \
class together, while the REWRITER (pin_dialect_in_member) is an `if` chain testing \
the KEY class first. For such a key with a NON-OBJECT value the detector returns None \
while the rewriter DESCENDS — a detector/rewriter divergence, which this module's own \
docs state is a defect, yielding a Cow::Owned that still carries a legacy declaration \
while compile_2020_12 announces that declaration as ignored. Either keep the lists \
disjoint or rewrite BOTH dispatches into one shape (115-REVIEW.md WR-05)."
);
}
#[test]
fn v1_validation_is_unchanged_by_the_v2_pin() {
let schema = draft_07_declared_schema();
for era in [Some(Era::V1), None] {
assert!(
schema_mismatch(&schema, &json!({ "wrong": true }), era).is_some(),
"v1 auto-detect must keep enforcing draft-07 `required` (era: {era:?})"
);
assert!(
schema_mismatch(&schema, &json!({ "n": "not-an-int" }), era).is_some(),
"v1 auto-detect must keep enforcing draft-07 `type` (era: {era:?})"
);
assert_eq!(
schema_mismatch(&schema, &json!({ "n": 7 }), era),
None,
"v1 must keep accepting a conforming instance (era: {era:?})"
);
}
}
fn era_divergent_schema(prefix: &str) -> Value {
serde_json::from_str(&format!(
r#"{{
"$schema": "{DRAFT_07}",
"type": "string",
"contentEncoding": "base64",
"description": "{prefix}"
}}"#
))
.expect("the template above is valid JSON")
}
fn era_divergent_instance() -> Value {
json!("!!!not-base64!!!")
}
#[test]
fn same_schema_text_yields_independent_verdicts_per_era_in_one_process() {
let schema = era_divergent_schema("v1first");
let instance = era_divergent_instance();
let v1 = schema_mismatch(&schema, &instance, Some(Era::V1));
let v2 = schema_mismatch(&schema, &instance, Some(Era::V2));
assert!(
v1.is_some(),
"v1 auto-detects draft-07, where `contentEncoding` is an ASSERTION: {v1:?}"
);
assert_eq!(
v2, None,
"v2 pins 2020-12, where `contentEncoding` is only an ANNOTATION and asserts \
nothing. A `Some` here means the V1 entry was served for the V2 lookup — the cache \
key lost its era half."
);
}
#[test]
fn same_schema_text_yields_independent_verdicts_in_the_opposite_order() {
let schema = era_divergent_schema("v2first");
let instance = era_divergent_instance();
let v2 = schema_mismatch(&schema, &instance, Some(Era::V2));
let v1 = schema_mismatch(&schema, &instance, Some(Era::V1));
assert_eq!(
v2, None,
"v2-first must give the same v2 answer as v2-second: {v2:?}"
);
assert!(
v1.is_some(),
"v1-second must give the same v1 answer as v1-first. A `None` here means the V2 \
entry was served for the V1 lookup — first-writer-wins across eras."
);
}
#[test]
fn structurally_incompatible_draft_07_constructs_report_a_schema_error_not_silence() {
let boolean_exclusive_minimum = json!({
"$schema": DRAFT_07,
"exclusiveMinimum": true
});
let tuple_items = json!({
"$schema": DRAFT_07,
"items": [{ "type": "string" }, { "type": "number" }]
});
for schema in [&boolean_exclusive_minimum, &tuple_items] {
let mismatch = schema_mismatch(schema, &json!({}), Some(Era::V2)).expect(
"a draft-07 construct that 2020-12 cannot express must be REPORTED under the v2 \
pin, not silently accepted",
);
assert!(
mismatch.contains("outputSchema"),
"the message must say the schema itself is at fault: {mismatch}"
);
}
}
#[test]
fn external_ref_fails_to_compile_with_no_network_io() {
let remote = json!({ "$ref": "https://example.com/remote.json" });
let local_file = json!({ "$ref": "file:///etc/passwd" });
let relative_under_http_id = json!({
"$id": "https://example.com/root.json",
"$ref": "sibling.json"
});
let started = std::time::Instant::now();
for era in [Some(Era::V1), Some(Era::V2)] {
for schema in [&remote, &local_file, &relative_under_http_id] {
assert!(
schema_mismatch(schema, &json!({}), era).is_some(),
"an external $ref must be a hard compile error, never a fetch (era: \
{era:?}, schema: {schema})"
);
}
}
let elapsed = started.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(1),
"six external-$ref refusals took {elapsed:?}. Refusal is measured at ~60 µs each; a \
wall-clock cost anywhere near a second means something is resolving the URI over \
the network or the filesystem."
);
}
#[test]
fn an_object_schema_rejects_a_scalar_on_both_eras_warn_only() {
let schema = person_schema();
let non_objects = [json!(42), json!(null), json!([1, 2]), json!("s")];
for era in [Some(Era::V1), Some(Era::V2)] {
for value in &non_objects {
assert!(
schema_mismatch(&schema, value, era).is_some(),
"an object schema must report a non-object value (era: {era:?}, value: \
{value})"
);
warn_on_schema_mismatch("demo_tool", &schema, value, era);
}
}
}
#[test]
fn an_undeclared_schema_behaves_identically_on_both_eras() {
let schema = person_schema();
let values = [
json!({ "name": "Ada", "age": 36 }),
json!({ "age": "not-a-number" }),
json!({}),
json!(42),
];
for value in &values {
assert_eq!(
schema_mismatch(&schema, value, Some(Era::V1)),
schema_mismatch(&schema, value, Some(Era::V2)),
"an undeclared schema must give the identical verdict on both eras (value: \
{value})"
);
}
}
fn normalization_cases() -> Vec<(Value, bool)> {
vec![
(person_schema(), false),
(json!({ "$schema": DRAFT_2020_12, "type": "object" }), false),
(draft_07_declared_schema(), true),
(
json!({
"type": "object",
"properties": { "a": { "$schema": DRAFT_07, "type": "string" } }
}),
true,
),
(embedded_legacy_resource_schema(), true),
(embedded_legacy_resource_named("default"), true),
(properties_embedded_legacy_resource_named("examples"), true),
(
embedded_legacy_resource_in_container("dependencies", "default"),
true,
),
(
embedded_legacy_resource_in_container("patternProperties", "default"),
true,
),
(
embedded_legacy_resource_in_container("dependentSchemas", "default"),
true,
),
(
embedded_legacy_resource_in_container("definitions", "default"),
true,
),
(embedded_legacy_resource_in_array("allOf", 1), true),
]
}
fn strip_every_dollar_schema(node: &mut Value) {
match node {
Value::Object(map) => {
map.remove("$schema");
for value in map.values_mut() {
strip_every_dollar_schema(value);
}
},
Value::Array(items) => items.iter_mut().for_each(strip_every_dollar_schema),
_ => {},
}
}
#[test]
fn normalize_schema_dialect_changes_only_dollar_schema_keys() {
use std::borrow::Cow;
for (schema, expected_owned) in normalization_cases() {
let normalized = normalize_schema_dialect(&schema);
assert_eq!(
matches!(normalized, Cow::Owned(_)),
expected_owned,
"borrow/own decision is wrong for {schema} — the no-op cases must allocate \
nothing"
);
assert_eq!(
first_legacy_dialect(&normalized),
None,
"a legacy dialect declaration survived normalization of {schema} — \
first_legacy_dialect and pin_dialect_in_place have stopped agreeing on the \
traversal rule"
);
if expected_owned {
let mut before = schema.clone();
let mut after = normalized.into_owned();
for document in [&mut before, &mut after] {
strip_every_dollar_schema(document);
}
assert_eq!(
before, after,
"normalization touched a key other than a $schema key"
);
} else {
assert_eq!(
*normalized, schema,
"a document needing no rewrite must come back byte-identical: {schema}"
);
}
}
let rooted = normalize_schema_dialect(&draft_07_declared_schema()).into_owned();
assert_eq!(
rooted.get("$schema").and_then(Value::as_str),
Some(DRAFT_2020_12),
"the root $schema must be OVERWRITTEN with the 2020-12 URI, not deleted"
);
let nested = json!({
"type": "object",
"properties": { "a": { "$schema": DRAFT_07, "type": "string" } }
});
let normalized = normalize_schema_dialect(&nested);
assert_eq!(
normalized
.pointer("/properties/a/$schema")
.and_then(Value::as_str),
Some(DRAFT_2020_12),
"a nested $schema must be rewritten too: an $id-bearing sibling of this shape is an \
embedded schema resource whose declaration jsonschema DOES honour, and leaving it \
alone is the measured (Violates, Conforms) bypass 115-VERIFICATION.md reported"
);
}
#[test]
fn normalize_schema_dialect_leaves_a_dollar_schema_that_is_data_alone() {
use std::borrow::Cow;
let property_named_dollar_schema = json!({
"type": "object",
"properties": { "$schema": { "type": "string" } }
});
let dollar_schema_inside_const = json!({
"const": { "$schema": DRAFT_07, "note": "this is data, not a dialect" }
});
let dollar_schema_inside_default = json!({
"type": "object",
"default": { "$schema": DRAFT_07, "note": "this is data, not a dialect" }
});
let dollar_schema_inside_examples = json!({
"type": "object",
"examples": [{ "$schema": DRAFT_07, "note": "this is data, not a dialect" }]
});
for document in [
&property_named_dollar_schema,
&dollar_schema_inside_const,
&dollar_schema_inside_default,
&dollar_schema_inside_examples,
] {
let normalized = normalize_schema_dialect(document);
assert!(
matches!(normalized, Cow::Borrowed(_)),
"a $schema that is DATA is not a dialect declaration, so nothing must be cloned \
for {document}. If this allocated, either the string-valued rule or the \
DATA_ONLY_KEYWORDS skip (`const`, `enum`, `default`, `examples`) was dropped \
from first_legacy_dialect"
);
assert_eq!(
*normalized, *document,
"a $schema that is DATA must come back byte-identical: {document}"
);
}
let mixed = json!({
"$schema": DRAFT_07,
"type": "object",
"properties": { "$schema": { "type": "string" } },
"const": { "$schema": DRAFT_07, "note": "this is data, not a dialect" }
});
let normalized = normalize_schema_dialect(&mixed);
assert!(
matches!(normalized, Cow::Owned(_)),
"the mixed document DOES carry a real root declaration and must be rewritten"
);
assert_eq!(
normalized.get("$schema").and_then(Value::as_str),
Some(DRAFT_2020_12),
"the real root declaration must be overwritten with the 2020-12 URI"
);
assert_eq!(
normalized.pointer("/properties/$schema"),
mixed.pointer("/properties/$schema"),
"the `properties` entry for an instance property named `$schema` is a SUBSCHEMA, not \
a dialect declaration — rewriting it to a string makes the document uncompilable"
);
assert_eq!(
normalized.pointer("/const"),
mixed.pointer("/const"),
"a `const` payload is instance DATA. The walk must skip DATA_ONLY_KEYWORDS \
(`const`, `enum`, `default`, `examples`); rewriting inside one changes which \
instances conform, which is a semantic corruption, not a normalization"
);
}
#[test]
fn normalize_schema_dialect_is_idempotent() {
for (schema, _) in normalization_cases() {
let once = normalize_schema_dialect(&schema).into_owned();
let twice = normalize_schema_dialect(&once).into_owned();
assert_eq!(
once, twice,
"normalization must be idempotent, but a second pass changed {schema}"
);
}
}
}