From a8d726ec7315e43e460c0ec1b510f0990391de92 Mon Sep 17 00:00:00 2001 From: Alex Groleau Date: Thu, 2 Oct 2025 18:15:07 -0400 Subject: [PATCH] unevaluatedProperties now cascade infinitely down their leaf when strict validation mode is on --- src/lib.rs | 2 +- src/schemas.rs | 34 +++++++++++++++++++++++++- src/tests.rs | 27 +++++++++++++++++++++ validator/src/lib.rs | 2 +- validator/src/validator.rs | 49 ++++++++++++++++++++++++++++---------- 5 files changed, 98 insertions(+), 16 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 2732271..e0f27ec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -308,7 +308,7 @@ fn validate_json_schema(schema_id: &str, instance: JsonB) -> JsonB { _ => None, }; - match cache.schemas.validate(&instance_value, schema.index, options.as_ref()) { + match cache.schemas.validate(&instance_value, schema.index, options) { Ok(_) => { let mut custom_errors = Vec::new(); if schema.t == SchemaType::Type || schema.t == SchemaType::PublicPunc || schema.t == SchemaType::PrivatePunc { diff --git a/src/schemas.rs b/src/schemas.rs index 56a2aa3..54e2a59 100644 --- a/src/schemas.rs +++ b/src/schemas.rs @@ -355,7 +355,16 @@ pub fn additional_properties_schemas() -> JsonB { pub fn unevaluated_properties_schemas() -> JsonB { let enums = json!([]); - let types = json!([]); + let types = json!([{ + "name": "nested_for_uneval", + "schemas": [{ + "$id": "nested_for_uneval", + "type": "object", + "properties": { + "deep_prop": { "type": "string" } + } + }] + }]); let puncs = json!([ { "name": "simple_unevaluated_test", @@ -396,6 +405,29 @@ pub fn unevaluated_properties_schemas() -> JsonB { }, "unevaluatedProperties": false }] + }, + { + "name": "nested_unevaluated_test", + "public": true, // To trigger strict mode + "schemas": [{ + "$id": "nested_unevaluated_test.request", + "type": "object", + "properties": { + "non_strict_branch": { + "type": "object", + "unevaluatedProperties": true, // The magic switch + "properties": { + "some_prop": { "$ref": "nested_for_uneval" } + } + }, + "strict_branch": { + "type": "object", + "properties": { + "another_prop": { "type": "string" } + } + } + } + }] } ]); diff --git a/src/tests.rs b/src/tests.rs index 9d58566..6ead439 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -452,6 +452,33 @@ fn test_validate_unevaluated_properties() { let valid_result = validate_json_schema("simple_unevaluated_test.request", jsonb(valid_instance)); assert_success(&valid_result); + + // Test 4: Test that unevaluatedProperties: true cascades down refs + let cascading_instance = json!({ + "strict_branch": { + "another_prop": "is_ok" + }, + "non_strict_branch": { + "extra_at_toplevel": "is_ok", // Extra property at this level + "some_prop": { + "deep_prop": "is_ok", + "extra_in_ref": "is_also_ok" // Extra property in the $ref'd schema + } + } + }); + let cascading_result = validate_json_schema("nested_unevaluated_test.request", jsonb(cascading_instance)); + assert_success(&cascading_result); + + // Test 5: For good measure, test that the strict branch is still strict + let strict_fail_instance = json!({ + "strict_branch": { + "another_prop": "is_ok", + "extra_in_strict": "is_not_ok" + } + }); + let strict_fail_result = validate_json_schema("nested_unevaluated_test.request", jsonb(strict_fail_instance)); + assert_error_count(&strict_fail_result, 1); + assert_has_error(&strict_fail_result, "ADDITIONAL_PROPERTIES_NOT_ALLOWED", "/strict_branch/extra_in_strict"); } #[pg_test] diff --git a/validator/src/lib.rs b/validator/src/lib.rs index f3f6370..1dcaa7f 100644 --- a/validator/src/lib.rs +++ b/validator/src/lib.rs @@ -194,7 +194,7 @@ impl Schemas { &'s self, v: &'v Value, sch_index: SchemaIndex, - options: Option<&'s ValidationOptions>, + options: Option, ) -> Result<(), ValidationError<'s, 'v>> { let Some(sch) = self.list.get(sch_index.0) else { panic!("Schemas::validate: schema index out of bounds"); diff --git a/validator/src/validator.rs b/validator/src/validator.rs index 14fc65f..7ee04d9 100644 --- a/validator/src/validator.rs +++ b/validator/src/validator.rs @@ -20,7 +20,7 @@ pub(crate) fn validate<'s, 'v>( v: &'v Value, schema: &'s Schema, schemas: &'s Schemas, - options: Option<&'s ValidationOptions>, + options: Option, ) -> Result<(), ValidationError<'s, 'v>> { let scope = Scope { sch: schema.idx, @@ -29,7 +29,7 @@ pub(crate) fn validate<'s, 'v>( parent: None, }; let mut vloc = Vec::with_capacity(8); - let be_strict = options.map_or(false, |o| o.be_strict); + let options = options.unwrap_or_default(); let (result, _) = Validator { v, vloc: &mut vloc, @@ -37,7 +37,7 @@ pub(crate) fn validate<'s, 'v>( schemas, scope, options, - uneval: Uneval::from(v, schema, be_strict), + uneval: Uneval::from(v, schema, options.be_strict), errors: vec![], bool_result: false, } @@ -89,7 +89,7 @@ struct Validator<'v, 's, 'd, 'e> { schema: &'s Schema, schemas: &'s Schemas, scope: Scope<'d>, - options: Option<&'s ValidationOptions>, + options: ValidationOptions, uneval: Uneval<'v>, errors: Vec>, bool_result: bool, // is interested to know valid or not (but not actuall error) @@ -296,7 +296,7 @@ impl<'v> Validator<'v, '_, '_, '_> { if let Some(sch) = &s.property_names { for pname in obj.keys() { let v = Value::String(pname.to_owned()); - if let Err(mut e) = self.schemas.validate(&v, *sch, self.options) { + if let Err(mut e) = self.schemas.validate(&v, *sch, Some(self.options)) { e.schema_url = &s.loc; e.kind = ErrorKind::PropertyName { prop: pname.to_owned(), @@ -510,7 +510,7 @@ impl<'v> Validator<'v, '_, '_, '_> { // contentSchema -- if let (Some(sch), Some(v)) = (s.content_schema, deserialized) { - if let Err(mut e) = self.schemas.validate(&v, sch, self.options) { + if let Err(mut e) = self.schemas.validate(&v, sch, Some(self.options)) { e.schema_url = &s.loc; e.kind = kind!(ContentSchema); self.errors.push(e.clone_static()); @@ -762,8 +762,6 @@ impl Validator<'_, '_, '_, '_> { }; } - let be_strict = self.options.map_or(false, |o| o.be_strict); - // unevaluatedProperties -- if let Value::Object(obj) = v { if let Some(sch_idx) = s.unevaluated_properties { @@ -786,7 +784,7 @@ impl Validator<'_, '_, '_, '_> { } self.uneval.props.clear(); } - } else if be_strict && !self.bool_result { + } else if self.options.be_strict && !self.bool_result { // 2. Runtime strictness check if !self.uneval.props.is_empty() { let props: Vec> = self.uneval.props.iter().map(|p| Cow::from((*p).as_str())).collect(); @@ -824,15 +822,27 @@ impl<'v, 's> Validator<'v, 's, '_, '_> { } let scope = self.scope.child(sch, None, self.scope.vid + 1); let schema = &self.schemas.get(sch); - let be_strict = self.options.map_or(false, |o| o.be_strict); + + // Check if the new schema turns off strictness + let allows_unevaluated = schema.boolean == Some(true) || + if let Some(idx) = schema.unevaluated_properties { + self.schemas.get(idx).boolean == Some(true) + } else { + false + }; + let mut new_options = self.options; + if allows_unevaluated { + new_options.be_strict = false; + } + let (result, _reply) = Validator { v, vloc: self.vloc, schema, schemas: self.schemas, scope, - options: self.options, - uneval: Uneval::from(v, schema, be_strict || !self.uneval.is_empty()), + options: new_options, + uneval: Uneval::from(v, schema, new_options.be_strict || !self.uneval.is_empty()), errors: vec![], bool_result: self.bool_result, } @@ -849,13 +859,26 @@ impl<'v, 's> Validator<'v, 's, '_, '_> { ) -> Result<(), ValidationError<'s, 'v>> { let scope = self.scope.child(sch, ref_kw, self.scope.vid); let schema = &self.schemas.get(sch); + + // Check if the new schema turns off strictness + let allows_unevaluated = schema.boolean == Some(true) || + if let Some(idx) = schema.unevaluated_properties { + self.schemas.get(idx).boolean == Some(true) + } else { + false + }; + let mut new_options = self.options; + if allows_unevaluated { + new_options.be_strict = false; + } + let (result, reply) = Validator { v: self.v, vloc: self.vloc, schema, schemas: self.schemas, scope, - options: self.options, + options: new_options, uneval: self.uneval.clone(), errors: vec![], bool_result: self.bool_result || bool_result,