From 40fc943f4a073c405fb4f50aa27048885a984481 Mon Sep 17 00:00:00 2001 From: Dmitry Dygalo Date: Sun, 22 Dec 2024 19:23:15 +0100 Subject: [PATCH] feat: Sanitizing error messages Signed-off-by: Dmitry Dygalo --- CHANGELOG.md | 4 + crates/jsonschema-py/CHANGELOG.md | 1 + crates/jsonschema-py/README.md | 33 ++ crates/jsonschema-py/src/lib.rs | 108 +++-- crates/jsonschema-py/tests-py/test_masking.py | 71 +++ crates/jsonschema/src/error.rs | 407 +++++++++++++++--- crates/jsonschema/src/lib.rs | 2 +- 7 files changed, 532 insertions(+), 94 deletions(-) create mode 100644 crates/jsonschema-py/tests-py/test_masking.py diff --git a/CHANGELOG.md b/CHANGELOG.md index abafb018..b199a4a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `masked()` and `masked_with()` methods to `ValidationError` to support hiding sensitive data in error messages. [#434](https://github.com/Stranger6667/jsonschema/issues/434) + ### Changed - Improved error message for unknown formats. diff --git a/crates/jsonschema-py/CHANGELOG.md b/crates/jsonschema-py/CHANGELOG.md index 5898e993..624f2dc8 100644 --- a/crates/jsonschema-py/CHANGELOG.md +++ b/crates/jsonschema-py/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Custom retrievers for external references. [#372](https://github.com/Stranger6667/jsonschema/issues/372) +- Added the `mask` argument to validators for hiding sensitive data in error messages. [#434](https://github.com/Stranger6667/jsonschema/issues/434) ### Changed diff --git a/crates/jsonschema-py/README.md b/crates/jsonschema-py/README.md index cebcef56..aa1a5bc9 100644 --- a/crates/jsonschema-py/README.md +++ b/crates/jsonschema-py/README.md @@ -197,6 +197,39 @@ validator.is_valid({ }) # False ``` +## Error Message Masking + +When working with sensitive data, you might want to hide actual values from error messages. +You can mask instance values in error messages by providing a placeholder: + +```python +import jsonschema_rs + +schema = { + "type": "object", + "properties": { + "password": {"type": "string", "minLength": 8}, + "api_key": {"type": "string", "pattern": "^[A-Z0-9]{32}$"} + } +} + +# Use default masking (replaces values with "[REDACTED]") +validator = jsonschema_rs.validator_for(schema, mask="[REDACTED]") + +try: + validator.validate({ + "password": "123", + "api_key": "secret_key_123" + }) +except jsonschema_rs.ValidationError as exc: + assert str(exc) == '''[REDACTED] does not match "^[A-Z0-9]{32}$" + +Failed validating "pattern" in schema["properties"]["api_key"] + +On instance["api_key"]: + [REDACTED]''' +``` + ## Performance `jsonschema-rs` is designed for high performance, outperforming other Python JSON Schema validators in most scenarios: diff --git a/crates/jsonschema-py/src/lib.rs b/crates/jsonschema-py/src/lib.rs index e164b4b8..95a96253 100644 --- a/crates/jsonschema-py/src/lib.rs +++ b/crates/jsonschema-py/src/lib.rs @@ -80,10 +80,18 @@ impl ValidationErrorIter { } } -fn into_py_err(py: Python<'_>, error: jsonschema::ValidationError<'_>) -> PyResult { +fn into_py_err( + py: Python<'_>, + error: jsonschema::ValidationError<'_>, + mask: Option<&str>, +) -> PyResult { let pyerror_type = PyType::new::(py); - let message = error.to_string(); - let verbose_message = to_error_message(&error, message.clone()); + let message = if let Some(mask) = mask { + error.masked_with(mask).to_string() + } else { + error.to_string() + }; + let verbose_message = to_error_message(&error, message.clone(), mask); let into_path = |segment: &str| { if let Ok(idx) = segment.parse::() { idx.into_pyobject(py).and_then(PyObject::try_from) @@ -219,13 +227,14 @@ fn iter_on_error( py: Python<'_>, validator: &jsonschema::Validator, instance: &Bound<'_, PyAny>, + mask: Option<&str>, ) -> PyResult { let instance = ser::to_value(instance)?; let mut pyerrors = vec![]; panic::catch_unwind(AssertUnwindSafe(|| { for error in validator.iter_errors(&instance) { - pyerrors.push(into_py_err(py, error)?); + pyerrors.push(into_py_err(py, error, mask)?); } PyResult::Ok(()) })) @@ -239,19 +248,24 @@ fn raise_on_error( py: Python<'_>, validator: &jsonschema::Validator, instance: &Bound<'_, PyAny>, + mask: Option<&str>, ) -> PyResult<()> { let instance = ser::to_value(instance)?; let error = panic::catch_unwind(AssertUnwindSafe(|| validator.validate(&instance))) .map_err(handle_format_checked_panic)? .err(); - error.map_or_else(|| Ok(()), |err| Err(into_py_err(py, err)?)) + error.map_or_else(|| Ok(()), |err| Err(into_py_err(py, err, mask)?)) } fn is_ascii_number(s: &str) -> bool { !s.is_empty() && s.as_bytes().iter().all(|&b| b.is_ascii_digit()) } -fn to_error_message(error: &jsonschema::ValidationError<'_>, mut message: String) -> String { +fn to_error_message( + error: &jsonschema::ValidationError<'_>, + mut message: String, + mask: Option<&str>, +) -> String { // It roughly doubles message.reserve(message.len()); message.push('\n'); @@ -291,8 +305,12 @@ fn to_error_message(error: &jsonschema::ValidationError<'_>, mut message: String } message.push(':'); message.push_str("\n "); - let mut writer = StringWriter(&mut message); - serde_json::to_writer(&mut writer, &error.instance).expect("Failed to serialize JSON"); + if let Some(mask) = mask { + message.push_str(mask); + } else { + let mut writer = StringWriter(&mut message); + serde_json::to_writer(&mut writer, &error.instance).expect("Failed to serialize JSON"); + } message } @@ -311,7 +329,7 @@ impl Write for StringWriter<'_> { } } -/// is_valid(schema, instance, draft=None, formats=None, validate_formats=None, ignore_unknown_formats=True, retriever=None) +/// is_valid(schema, instance, draft=None, formats=None, validate_formats=None, ignore_unknown_formats=True, retriever=None, mask=None) /// /// A shortcut for validating the input instance against the schema. /// @@ -322,7 +340,7 @@ impl Write for StringWriter<'_> { /// instead. #[pyfunction] #[allow(unused_variables, clippy::too_many_arguments)] -#[pyo3(signature = (schema, instance, draft=None, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None))] +#[pyo3(signature = (schema, instance, draft=None, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None, mask=None))] fn is_valid( py: Python<'_>, schema: &Bound<'_, PyAny>, @@ -332,6 +350,7 @@ fn is_valid( validate_formats: Option, ignore_unknown_formats: Option, retriever: Option<&Bound<'_, PyAny>>, + mask: Option, ) -> PyResult { let options = make_options( draft, @@ -347,11 +366,11 @@ fn is_valid( panic::catch_unwind(AssertUnwindSafe(|| Ok(validator.is_valid(&instance)))) .map_err(handle_format_checked_panic)? } - Err(error) => Err(into_py_err(py, error)?), + Err(error) => Err(into_py_err(py, error, mask.as_deref())?), } } -/// validate(schema, instance, draft=None, formats=None, validate_formats=None, ignore_unknown_formats=True, retriever=None) +/// validate(schema, instance, draft=None, formats=None, validate_formats=None, ignore_unknown_formats=True, retriever=None, mask=None) /// /// Validate the input instance and raise `ValidationError` in the error case /// @@ -364,7 +383,7 @@ fn is_valid( /// instead. #[pyfunction] #[allow(unused_variables, clippy::too_many_arguments)] -#[pyo3(signature = (schema, instance, draft=None, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None))] +#[pyo3(signature = (schema, instance, draft=None, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None, mask=None))] fn validate( py: Python<'_>, schema: &Bound<'_, PyAny>, @@ -374,6 +393,7 @@ fn validate( validate_formats: Option, ignore_unknown_formats: Option, retriever: Option<&Bound<'_, PyAny>>, + mask: Option, ) -> PyResult<()> { let options = make_options( draft, @@ -384,12 +404,12 @@ fn validate( )?; let schema = ser::to_value(schema)?; match options.build(&schema) { - Ok(validator) => raise_on_error(py, &validator, instance), - Err(error) => Err(into_py_err(py, error)?), + Ok(validator) => raise_on_error(py, &validator, instance, mask.as_deref()), + Err(error) => Err(into_py_err(py, error, mask.as_deref())?), } } -/// iter_errors(schema, instance, draft=None, formats=None, validate_formats=None, ignore_unknown_formats=True, retriever=None) +/// iter_errors(schema, instance, draft=None, formats=None, validate_formats=None, ignore_unknown_formats=True, retriever=None, mask=None) /// /// Iterate the validation errors of the input instance /// @@ -401,7 +421,7 @@ fn validate( /// instead. #[pyfunction] #[allow(unused_variables, clippy::too_many_arguments)] -#[pyo3(signature = (schema, instance, draft=None, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None))] +#[pyo3(signature = (schema, instance, draft=None, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None, mask=None))] fn iter_errors( py: Python<'_>, schema: &Bound<'_, PyAny>, @@ -411,6 +431,7 @@ fn iter_errors( validate_formats: Option, ignore_unknown_formats: Option, retriever: Option<&Bound<'_, PyAny>>, + mask: Option, ) -> PyResult { let options = make_options( draft, @@ -421,8 +442,8 @@ fn iter_errors( )?; let schema = ser::to_value(schema)?; match options.build(&schema) { - Ok(validator) => iter_on_error(py, &validator, instance), - Err(error) => Err(into_py_err(py, error)?), + Ok(validator) => iter_on_error(py, &validator, instance, mask.as_deref()), + Err(error) => Err(into_py_err(py, error, mask.as_deref())?), } } @@ -440,9 +461,10 @@ fn handle_format_checked_panic(err: Box) -> PyErr { #[pyclass(module = "jsonschema_rs", subclass)] struct Validator { validator: jsonschema::Validator, + mask: Option, } -/// validator_for(schema, formats=None, validate_formats=None, ignore_unknown_formats=True, retriever=None) +/// validator_for(schema, formats=None, validate_formats=None, ignore_unknown_formats=True, retriever=None, mask=None) /// /// Create a validator for the input schema with automatic draft detection and default options. /// @@ -451,7 +473,7 @@ struct Validator { /// False /// #[pyfunction] -#[pyo3(signature = (schema, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None))] +#[pyo3(signature = (schema, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None, mask=None))] fn validator_for( py: Python<'_>, schema: &Bound<'_, PyAny>, @@ -459,6 +481,7 @@ fn validator_for( validate_formats: Option, ignore_unknown_formats: Option, retriever: Option<&Bound<'_, PyAny>>, + mask: Option, ) -> PyResult { validator_for_impl( py, @@ -468,9 +491,11 @@ fn validator_for( validate_formats, ignore_unknown_formats, retriever, + mask, ) } +#[allow(clippy::too_many_arguments)] fn validator_for_impl( py: Python<'_>, schema: &Bound<'_, PyAny>, @@ -479,6 +504,7 @@ fn validator_for_impl( validate_formats: Option, ignore_unknown_formats: Option, retriever: Option<&Bound<'_, PyAny>>, + mask: Option, ) -> PyResult { let obj_ptr = schema.as_ptr(); let object_type = unsafe { pyo3::ffi::Py_TYPE(obj_ptr) }; @@ -499,15 +525,15 @@ fn validator_for_impl( retriever, )?; match options.build(&schema) { - Ok(validator) => Ok(Validator { validator }), - Err(error) => Err(into_py_err(py, error)?), + Ok(validator) => Ok(Validator { validator, mask }), + Err(error) => Err(into_py_err(py, error, mask.as_deref())?), } } #[pymethods] impl Validator { #[new] - #[pyo3(signature = (schema, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None))] + #[pyo3(signature = (schema, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None, mask=None))] fn new( py: Python<'_>, schema: &Bound<'_, PyAny>, @@ -515,6 +541,7 @@ impl Validator { validate_formats: Option, ignore_unknown_formats: Option, retriever: Option<&Bound<'_, PyAny>>, + mask: Option, ) -> PyResult { validator_for( py, @@ -523,6 +550,7 @@ impl Validator { validate_formats, ignore_unknown_formats, retriever, + mask, ) } /// is_valid(instance) @@ -552,7 +580,7 @@ impl Validator { /// If the input instance is invalid, only the first occurred error is raised. #[pyo3(text_signature = "(instance)")] fn validate(&self, py: Python<'_>, instance: &Bound<'_, PyAny>) -> PyResult<()> { - raise_on_error(py, &self.validator, instance) + raise_on_error(py, &self.validator, instance, self.mask.as_deref()) } /// iter_errors(instance) /// @@ -568,7 +596,7 @@ impl Validator { py: Python<'_>, instance: &Bound<'_, PyAny>, ) -> PyResult { - iter_on_error(py, &self.validator, instance) + iter_on_error(py, &self.validator, instance, self.mask.as_deref()) } fn __repr__(&self) -> &'static str { match self.validator.draft() { @@ -582,7 +610,7 @@ impl Validator { } } -/// Draft4Validator(schema, formats=None, validate_formats=None, ignore_unknown_formats=True, retriever=None) +/// Draft4Validator(schema, formats=None, validate_formats=None, ignore_unknown_formats=True, retriever=None, mask=None) /// /// A JSON Schema Draft 4 validator. /// @@ -596,7 +624,7 @@ struct Draft4Validator {} #[pymethods] impl Draft4Validator { #[new] - #[pyo3(signature = (schema, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None))] + #[pyo3(signature = (schema, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None, mask=None))] fn new( py: Python<'_>, schema: &Bound<'_, PyAny>, @@ -604,6 +632,7 @@ impl Draft4Validator { validate_formats: Option, ignore_unknown_formats: Option, retriever: Option<&Bound<'_, PyAny>>, + mask: Option, ) -> PyResult<(Self, Validator)> { Ok(( Draft4Validator {}, @@ -615,12 +644,13 @@ impl Draft4Validator { validate_formats, ignore_unknown_formats, retriever, + mask, )?, )) } } -/// Draft6Validator(schema, formats=None, validate_formats=None, ignore_unknown_formats=True, retriever=None) +/// Draft6Validator(schema, formats=None, validate_formats=None, ignore_unknown_formats=True, retriever=None, mask=None) /// /// A JSON Schema Draft 6 validator. /// @@ -634,7 +664,7 @@ struct Draft6Validator {} #[pymethods] impl Draft6Validator { #[new] - #[pyo3(signature = (schema, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None))] + #[pyo3(signature = (schema, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None, mask=None))] fn new( py: Python<'_>, schema: &Bound<'_, PyAny>, @@ -642,6 +672,7 @@ impl Draft6Validator { validate_formats: Option, ignore_unknown_formats: Option, retriever: Option<&Bound<'_, PyAny>>, + mask: Option, ) -> PyResult<(Self, Validator)> { Ok(( Draft6Validator {}, @@ -653,12 +684,13 @@ impl Draft6Validator { validate_formats, ignore_unknown_formats, retriever, + mask, )?, )) } } -/// Draft7Validator(schema, formats=None, validate_formats=None, ignore_unknown_formats=True, retriever=None) +/// Draft7Validator(schema, formats=None, validate_formats=None, ignore_unknown_formats=True, retriever=None, mask=None) /// /// A JSON Schema Draft 7 validator. /// @@ -672,7 +704,7 @@ struct Draft7Validator {} #[pymethods] impl Draft7Validator { #[new] - #[pyo3(signature = (schema, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None))] + #[pyo3(signature = (schema, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None, mask=None))] fn new( py: Python<'_>, schema: &Bound<'_, PyAny>, @@ -680,6 +712,7 @@ impl Draft7Validator { validate_formats: Option, ignore_unknown_formats: Option, retriever: Option<&Bound<'_, PyAny>>, + mask: Option, ) -> PyResult<(Self, Validator)> { Ok(( Draft7Validator {}, @@ -691,6 +724,7 @@ impl Draft7Validator { validate_formats, ignore_unknown_formats, retriever, + mask, )?, )) } @@ -710,7 +744,7 @@ struct Draft201909Validator {} #[pymethods] impl Draft201909Validator { #[new] - #[pyo3(signature = (schema, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None))] + #[pyo3(signature = (schema, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None, mask=None))] fn new( py: Python<'_>, schema: &Bound<'_, PyAny>, @@ -718,6 +752,7 @@ impl Draft201909Validator { validate_formats: Option, ignore_unknown_formats: Option, retriever: Option<&Bound<'_, PyAny>>, + mask: Option, ) -> PyResult<(Self, Validator)> { Ok(( Draft201909Validator {}, @@ -729,12 +764,13 @@ impl Draft201909Validator { validate_formats, ignore_unknown_formats, retriever, + mask, )?, )) } } -/// Draft202012Validator(schema, formats=None, validate_formats=None, ignore_unknown_formats=True, retriever=None) +/// Draft202012Validator(schema, formats=None, validate_formats=None, ignore_unknown_formats=True, retriever=None, mask=None) /// /// A JSON Schema Draft 2020-12 validator. /// @@ -748,7 +784,7 @@ struct Draft202012Validator {} #[pymethods] impl Draft202012Validator { #[new] - #[pyo3(signature = (schema, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None))] + #[pyo3(signature = (schema, formats=None, validate_formats=None, ignore_unknown_formats=true, retriever=None, mask=None))] fn new( py: Python<'_>, schema: &Bound<'_, PyAny>, @@ -756,6 +792,7 @@ impl Draft202012Validator { validate_formats: Option, ignore_unknown_formats: Option, retriever: Option<&Bound<'_, PyAny>>, + mask: Option, ) -> PyResult<(Self, Validator)> { Ok(( Draft202012Validator {}, @@ -767,6 +804,7 @@ impl Draft202012Validator { validate_formats, ignore_unknown_formats, retriever, + mask, )?, )) } diff --git a/crates/jsonschema-py/tests-py/test_masking.py b/crates/jsonschema-py/tests-py/test_masking.py new file mode 100644 index 00000000..72e86d1c --- /dev/null +++ b/crates/jsonschema-py/tests-py/test_masking.py @@ -0,0 +1,71 @@ +import pytest +import jsonschema_rs + + +def test_custom_masking(): + schema = {"maxLength": 5} + validator = jsonschema_rs.validator_for(schema, mask="[REDACTED]") + + with pytest.raises(jsonschema_rs.ValidationError) as exc: + validator.validate("sensitive data") + + assert str(exc.value).startswith("[REDACTED] is longer than 5 characters") + assert "sensitive data" not in str(exc.value) + + +def test_no_masking(): + schema = {"maxLength": 5} + validator = jsonschema_rs.validator_for(schema) + + with pytest.raises(jsonschema_rs.ValidationError) as exc: + validator.validate("sensitive data") + + assert '"sensitive data" is longer than 5 characters' in str(exc.value) + + +def test_masking_with_nested_data(): + schema = { + "type": "object", + "properties": { + "credentials": { + "type": "object", + "properties": { + "password": {"type": "string", "minLength": 8}, + }, + } + }, + } + validator = jsonschema_rs.validator_for(schema, mask="[SECRET]") + + with pytest.raises(jsonschema_rs.ValidationError) as exc: + validator.validate({"credentials": {"password": "123"}}) + + assert "[SECRET] is shorter than 8 characters" in str(exc.value) + assert "123" not in str(exc.value) + + +def test_masking_with_array(): + schema = {"items": {"type": "string"}} + validator = jsonschema_rs.validator_for(schema, mask="[HIDDEN]") + + with pytest.raises(jsonschema_rs.ValidationError) as exc: + validator.validate([123, 456]) + + assert '[HIDDEN] is not of type "string"' in str(exc.value) + + +def test_masking_with_multiple_errors(): + schema = { + "type": "object", + "properties": { + "password": {"type": "string", "minLength": 8}, + "api_key": {"type": "string", "pattern": "^[A-Z0-9]{32}$"}, + }, + } + validator = jsonschema_rs.validator_for(schema, mask="[HIDDEN]") + + errors = list(validator.iter_errors({"password": "123", "api_key": "invalid"})) + + assert len(errors) == 2 + assert all("[HIDDEN]" in str(error) for error in errors) + assert all("123" not in str(error) and "invalid" not in str(error) for error in errors) diff --git a/crates/jsonschema/src/error.rs b/crates/jsonschema/src/error.rs index b5842770..9b99ade4 100644 --- a/crates/jsonschema/src/error.rs +++ b/crates/jsonschema/src/error.rs @@ -1,4 +1,40 @@ -//! Error types +//! # Error Handling +//! +//! ## Masking Sensitive Data +//! +//! When working with sensitive data, you might want to hide actual values from error messages. +//! The `ValidationError` type provides methods to mask instance values while preserving the error context: +//! +//! ```rust +//! # fn main() -> Result<(), Box> { +//! use serde_json::json; +//! +//! let schema = json!({"maxLength": 5}); +//! let instance = json!("sensitive data"); +//! let validator = jsonschema::validator_for(&schema)?; +//! +//! if let Err(error) = validator.validate(&instance) { +//! // Use default masking (replaces values with "value") +//! println!("Masked error: {}", error.masked()); +//! // Or provide custom placeholder +//! println!("Custom masked: {}", error.masked_with("[REDACTED]")); +//! } +//! # Ok(()) +//! # } +//! ``` +//! +//! The masked error messages will replace instance values with placeholders while maintaining +//! schema-related information like property names, limits, and types. +//! +//! Original error: +//! ```text +//! "sensitive data" is longer than 5 characters +//! ``` +//! +//! Masked error: +//! ```text +//! value is longer than 5 characters +//! ``` use crate::{ paths::Location, primitive_type::{PrimitiveType, PrimitiveTypesBitMap}, @@ -140,6 +176,22 @@ pub enum TypeKind { /// Shortcuts for creation of specific error kinds. impl<'a> ValidationError<'a> { + /// Returns a wrapper that masks instance values in error messages. + /// Uses "value" as a default placeholder. + pub fn masked<'b>(&'b self) -> MaskedValidationError<'a, 'b, 'static> { + self.masked_with("value") + } + + /// Returns a wrapper that masks instance values in error messages with a custom placeholder. + pub fn masked_with<'b, 'c>( + &'b self, + placeholder: impl Into>, + ) -> MaskedValidationError<'a, 'b, 'c> { + MaskedValidationError { + error: self, + placeholder: placeholder.into(), + } + } pub(crate) fn into_owned(self) -> ValidationError<'static> { ValidationError { instance_path: self.instance_path.clone(), @@ -708,6 +760,30 @@ impl From for ValidationError<'_> { } } +fn write_quoted_list(f: &mut Formatter<'_>, items: &[impl fmt::Display]) -> fmt::Result { + let mut iter = items.iter(); + if let Some(item) = iter.next() { + f.write_char('\'')?; + write!(f, "{}", item)?; + f.write_char('\'')?; + } + for item in iter { + f.write_str(", ")?; + f.write_char('\'')?; + write!(f, "{}", item)?; + f.write_char('\'')?; + } + Ok(()) +} + +fn write_unexpected_suffix(f: &mut Formatter<'_>, len: usize) -> fmt::Result { + f.write_str(if len == 1 { + " was unexpected)" + } else { + " were unexpected)" + }) +} + /// Textual representation of various validation errors. impl fmt::Display for ValidationError<'_> { #[allow(clippy::too_many_lines)] // The function is long but it does formatting only @@ -731,32 +807,12 @@ impl fmt::Display for ValidationError<'_> { write!(f, "{}", item)?; } - let items_count = array.len() - limit; - f.write_str(if items_count == 1 { - " was unexpected)" - } else { - " were unexpected)" - }) + write_unexpected_suffix(f, array.len() - limit) } ValidationErrorKind::AdditionalProperties { unexpected } => { f.write_str("Additional properties are not allowed (")?; - let mut iter = unexpected.iter(); - if let Some(prop) = iter.next() { - f.write_char('\'')?; - write!(f, "{}", prop)?; - f.write_char('\'')?; - } - for prop in iter { - f.write_str(", ")?; - f.write_char('\'')?; - write!(f, "{}", prop)?; - f.write_char('\'')?; - } - f.write_str(if unexpected.len() == 1 { - " was unexpected)" - } else { - " were unexpected)" - }) + write_quoted_list(f, unexpected)?; + write_unexpected_suffix(f, unexpected.len()) } ValidationErrorKind::AnyOf => write!( f, @@ -877,43 +933,13 @@ impl fmt::Display for ValidationError<'_> { } ValidationErrorKind::UnevaluatedItems { unexpected } => { f.write_str("Unevaluated items are not allowed (")?; - let mut iter = unexpected.iter(); - if let Some(item) = iter.next() { - f.write_char('\'')?; - write!(f, "{}", item)?; - f.write_char('\'')?; - } - for item in iter { - f.write_str(", ")?; - f.write_char('\'')?; - write!(f, "{}", item)?; - f.write_char('\'')?; - } - f.write_str(if unexpected.len() == 1 { - " was unexpected)" - } else { - " were unexpected)" - }) + write_quoted_list(f, unexpected)?; + write_unexpected_suffix(f, unexpected.len()) } ValidationErrorKind::UnevaluatedProperties { unexpected } => { f.write_str("Unevaluated properties are not allowed (")?; - let mut iter = unexpected.iter(); - if let Some(prop) = iter.next() { - f.write_char('\'')?; - write!(f, "{}", prop)?; - f.write_char('\'')?; - } - for prop in iter { - f.write_str(", ")?; - f.write_char('\'')?; - write!(f, "{}", prop)?; - f.write_char('\'')?; - } - f.write_str(if unexpected.len() == 1 { - " was unexpected)" - } else { - " were unexpected)" - }) + write_quoted_list(f, unexpected)?; + write_unexpected_suffix(f, unexpected.len()) } ValidationErrorKind::UniqueItems => { write!(f, "{} has non-unique elements", self.instance) @@ -944,6 +970,195 @@ impl fmt::Display for ValidationError<'_> { } } +/// A wrapper that provides a masked display of validation errors. +pub struct MaskedValidationError<'a, 'b, 'c> { + error: &'b ValidationError<'a>, + placeholder: Cow<'c, str>, +} + +impl fmt::Display for MaskedValidationError<'_, '_, '_> { + #[allow(clippy::too_many_lines)] + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match &self.error.kind { + ValidationErrorKind::Referencing(error) => error.fmt(f), + ValidationErrorKind::BacktrackLimitExceeded { error } => error.fmt(f), + ValidationErrorKind::Format { format } => { + write!(f, r#"{} is not a "{format}""#, self.placeholder) + } + ValidationErrorKind::AdditionalItems { limit } => { + write!(f, "Additional items are not allowed ({limit} items)") + } + ValidationErrorKind::AdditionalProperties { unexpected } => { + f.write_str("Additional properties are not allowed (")?; + write_quoted_list(f, unexpected)?; + write_unexpected_suffix(f, unexpected.len()) + } + ValidationErrorKind::AnyOf => write!( + f, + "{} is not valid under any of the schemas listed in the 'anyOf' keyword", + self.placeholder + ), + ValidationErrorKind::OneOfNotValid => write!( + f, + "{} is not valid under any of the schemas listed in the 'oneOf' keyword", + self.placeholder + ), + ValidationErrorKind::Contains => write!( + f, + "None of {} are valid under the given schema", + self.placeholder + ), + ValidationErrorKind::Constant { expected_value } => { + write!(f, "{} was expected", expected_value) + } + ValidationErrorKind::ContentEncoding { content_encoding } => { + write!( + f, + r#"{} is not compliant with "{}" content encoding"#, + self.placeholder, content_encoding + ) + } + ValidationErrorKind::ContentMediaType { content_media_type } => { + write!( + f, + r#"{} is not compliant with "{}" media type"#, + self.placeholder, content_media_type + ) + } + ValidationErrorKind::FromUtf8 { error } => error.fmt(f), + ValidationErrorKind::Enum { options } => { + write!(f, "{} is not one of {}", self.placeholder, options) + } + ValidationErrorKind::ExclusiveMaximum { limit } => write!( + f, + "{} is greater than or equal to the maximum of {}", + self.placeholder, limit + ), + ValidationErrorKind::ExclusiveMinimum { limit } => write!( + f, + "{} is less than or equal to the minimum of {}", + self.placeholder, limit + ), + ValidationErrorKind::FalseSchema => { + write!(f, "False schema does not allow {}", self.placeholder) + } + ValidationErrorKind::Maximum { limit } => write!( + f, + "{} is greater than the maximum of {}", + self.placeholder, limit + ), + ValidationErrorKind::Minimum { limit } => { + write!( + f, + "{} is less than the minimum of {}", + self.placeholder, limit + ) + } + ValidationErrorKind::MaxLength { limit } => write!( + f, + "{} is longer than {} character{}", + self.placeholder, + limit, + if *limit == 1 { "" } else { "s" } + ), + ValidationErrorKind::MinLength { limit } => write!( + f, + "{} is shorter than {} character{}", + self.placeholder, + limit, + if *limit == 1 { "" } else { "s" } + ), + ValidationErrorKind::MaxItems { limit } => write!( + f, + "{} has more than {} item{}", + self.placeholder, + limit, + if *limit == 1 { "" } else { "s" } + ), + ValidationErrorKind::MinItems { limit } => write!( + f, + "{} has less than {} item{}", + self.placeholder, + limit, + if *limit == 1 { "" } else { "s" } + ), + ValidationErrorKind::MaxProperties { limit } => write!( + f, + "{} has more than {} propert{}", + self.placeholder, + limit, + if *limit == 1 { "y" } else { "ies" } + ), + ValidationErrorKind::MinProperties { limit } => write!( + f, + "{} has less than {} propert{}", + self.placeholder, + limit, + if *limit == 1 { "y" } else { "ies" } + ), + ValidationErrorKind::Not { schema } => { + write!(f, "{} is not allowed for {}", schema, self.placeholder) + } + ValidationErrorKind::OneOfMultipleValid => write!( + f, + "{} is valid under more than one of the schemas listed in the 'oneOf' keyword", + self.placeholder + ), + ValidationErrorKind::Pattern { pattern } => { + write!(f, r#"{} does not match "{}""#, self.placeholder, pattern) + } + ValidationErrorKind::PropertyNames { error } => error.fmt(f), + ValidationErrorKind::Required { property } => { + write!(f, "{} is a required property", property) + } + ValidationErrorKind::MultipleOf { multiple_of } => { + write!( + f, + "{} is not a multiple of {}", + self.placeholder, multiple_of + ) + } + ValidationErrorKind::UnevaluatedItems { unexpected } => { + write!( + f, + "Unevaluated items are not allowed ({} items)", + unexpected.len() + ) + } + ValidationErrorKind::UnevaluatedProperties { unexpected } => { + f.write_str("Unevaluated properties are not allowed (")?; + write_quoted_list(f, unexpected)?; + write_unexpected_suffix(f, unexpected.len()) + } + ValidationErrorKind::UniqueItems => { + write!(f, "{} has non-unique elements", self.placeholder) + } + ValidationErrorKind::Type { + kind: TypeKind::Single(type_), + } => write!(f, r#"{} is not of type "{}""#, self.placeholder, type_), + ValidationErrorKind::Type { + kind: TypeKind::Multiple(types), + } => { + write!(f, "{} is not of types ", self.placeholder)?; + let mut iter = types.into_iter(); + if let Some(t) = iter.next() { + f.write_char('"')?; + write!(f, "{}", t)?; + f.write_char('"')?; + } + for t in iter { + f.write_str(", ")?; + f.write_char('"')?; + write!(f, "{}", t)?; + f.write_char('"')?; + } + Ok(()) + } + ValidationErrorKind::Custom { message } => f.write_str(message), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -1090,4 +1305,80 @@ mod tests { assert!(result.next().is_none()); assert_eq!(error.instance_path.as_str(), expected); } + + #[test_case( + json!("2023-13-45"), + ValidationErrorKind::Format { format: "date".to_string() }, + "value is not a \"date\"" + )] + #[test_case( + json!("sensitive data"), + ValidationErrorKind::MaxLength { limit: 5 }, + "value is longer than 5 characters" + )] + #[test_case( + json!({"secret": "data", "key": "value"}), + ValidationErrorKind::AdditionalProperties { + unexpected: vec!["secret".to_string(), "key".to_string()] + }, + "Additional properties are not allowed ('secret', 'key' were unexpected)" + )] + #[test_case( + json!(123), + ValidationErrorKind::Minimum { limit: json!(456) }, + "value is less than the minimum of 456" + )] + #[test_case( + json!("secret_key_123"), + ValidationErrorKind::Pattern { pattern: "^[A-Z0-9]{32}$".to_string() }, + "value does not match \"^[A-Z0-9]{32}$\"" + )] + #[test_case( + json!([1, 2, 2, 3]), + ValidationErrorKind::UniqueItems, + "value has non-unique elements" + )] + #[test_case( + json!(123), + ValidationErrorKind::Type { kind: TypeKind::Single(PrimitiveType::String) }, + "value is not of type \"string\"" + )] + fn test_masked_error_messages(instance: Value, kind: ValidationErrorKind, expected: &str) { + let error = ValidationError { + instance: Cow::Owned(instance), + kind, + instance_path: Location::new(), + schema_path: Location::new(), + }; + assert_eq!(error.masked().to_string(), expected); + } + + #[test_case( + json!("sensitive data"), + ValidationErrorKind::MaxLength { limit: 5 }, + "[REDACTED]", + "[REDACTED] is longer than 5 characters" + )] + #[test_case( + json!({"password": "secret123"}), + ValidationErrorKind::Type { + kind: TypeKind::Single(PrimitiveType::String) + }, + "***", + "*** is not of type \"string\"" + )] + fn test_custom_masked_error_messages( + instance: Value, + kind: ValidationErrorKind, + placeholder: &str, + expected: &str, + ) { + let error = ValidationError { + instance: Cow::Owned(instance), + kind, + instance_path: Location::new(), + schema_path: Location::new(), + }; + assert_eq!(error.masked_with(placeholder).to_string(), expected); + } } diff --git a/crates/jsonschema/src/lib.rs b/crates/jsonschema/src/lib.rs index c59caa2d..c9f4b773 100644 --- a/crates/jsonschema/src/lib.rs +++ b/crates/jsonschema/src/lib.rs @@ -478,7 +478,7 @@ pub(crate) mod properties; mod retriever; mod validator; -pub use error::{ErrorIterator, ValidationError}; +pub use error::{ErrorIterator, MaskedValidationError, ValidationError}; pub use keywords::custom::Keyword; pub use options::ValidationOptions; pub use output::BasicOutput;