From 9a957d770d6e63646f4fc874bf8df6be4593f8c8 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Wed, 16 Jul 2025 12:03:12 -0500 Subject: [PATCH 1/3] Unambiguously quote and escape properties in JSON path rendering Fixes #1389 --- jsonschema/exceptions.py | 8 +- jsonschema/tests/test_exceptions.py | 119 ++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/jsonschema/exceptions.py b/jsonschema/exceptions.py index 083a423f7..ec280ff2e 100644 --- a/jsonschema/exceptions.py +++ b/jsonschema/exceptions.py @@ -8,6 +8,7 @@ from textwrap import dedent, indent from typing import TYPE_CHECKING, Any, ClassVar import heapq +import re import warnings from attrs import define @@ -23,6 +24,8 @@ WEAK_MATCHES: frozenset[str] = frozenset(["anyOf", "oneOf"]) STRONG_MATCHES: frozenset[str] = frozenset() +JSON_PATH_COMPATIBLE_PROPERTY_PATTERN = re.compile("^[a-zA-Z][a-zA-Z0-9_]*$") + _unset = _utils.Unset() @@ -152,8 +155,11 @@ def json_path(self) -> str: for elem in self.absolute_path: if isinstance(elem, int): path += "[" + str(elem) + "]" - else: + elif JSON_PATH_COMPATIBLE_PROPERTY_PATTERN.match(elem): path += "." + elem + else: + escaped_elem = elem.replace("\\", "\\\\").replace("'", r"\'") + path += "['" + escaped_elem + "']" return path def _set( diff --git a/jsonschema/tests/test_exceptions.py b/jsonschema/tests/test_exceptions.py index 69114e182..2a3a5f25f 100644 --- a/jsonschema/tests/test_exceptions.py +++ b/jsonschema/tests/test_exceptions.py @@ -700,3 +700,122 @@ class TestHashable(TestCase): def test_hashable(self): {exceptions.ValidationError("")} {exceptions.SchemaError("")} + + +class TestJsonPathRendering(TestCase): + def test_str(self): + e = exceptions.ValidationError( + path=["x"], + message="1", + validator="foo", + instance="i1", + ) + self.assertEqual(e.json_path, "$.x") + + def test_empty_str(self): + e = exceptions.ValidationError( + path=[""], + message="1", + validator="foo", + instance="i1", + ) + self.assertEqual(e.json_path, "$['']") + + def test_numeric_str(self): + e = exceptions.ValidationError( + path=["1"], + message="1", + validator="foo", + instance="i1", + ) + self.assertEqual(e.json_path, "$['1']") + + def test_period_str(self): + e = exceptions.ValidationError( + path=["."], + message="1", + validator="foo", + instance="i1", + ) + self.assertEqual(e.json_path, "$['.']") + + def test_single_quote_str(self): + e = exceptions.ValidationError( + path=["'"], + message="1", + validator="foo", + instance="i1", + ) + self.assertEqual(e.json_path, r"$['\'']") + + def test_space_str(self): + e = exceptions.ValidationError( + path=[" "], + message="1", + validator="foo", + instance="i1", + ) + self.assertEqual(e.json_path, "$[' ']") + + def test_backslash_str(self): + e = exceptions.ValidationError( + path=["\\"], + message="1", + validator="foo", + instance="i1", + ) + self.assertEqual(e.json_path, r"$['\\']") + + def test_backslash_single_quote(self): + e = exceptions.ValidationError( + path=[r"\'"], + message="1", + validator="foo", + instance="i1", + ) + self.assertEqual(e.json_path, r"$['\\\'']") + + def test_underscore(self): + e = exceptions.ValidationError( + path=["_"], + message="1", + validator="foo", + instance="i1", + ) + self.assertEqual(e.json_path, r"$['_']") + + def test_double_quote(self): + e = exceptions.ValidationError( + path=['"'], + message="1", + validator="foo", + instance="i1", + ) + self.assertEqual(e.json_path, """$['"']""") + + def test_hyphen(self): + e = exceptions.ValidationError( + path=["-"], + message="1", + validator="foo", + instance="i1", + ) + self.assertEqual(e.json_path, "$['-']") + + def test_json_path_injection(self): + e = exceptions.ValidationError( + path=["a[0]"], + message="1", + validator="foo", + instance="i1", + ) + self.assertEqual(e.json_path, "$['a[0]']") + + def test_open_bracket(self): + e = exceptions.ValidationError( + path=["["], + message="1", + validator="foo", + instance="i1", + ) + self.assertEqual(e.json_path, "$['[']") From 2680f6ab44d6fe36d321fef6ee626608a5f0ec7a Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Thu, 17 Jul 2025 08:20:46 -0500 Subject: [PATCH 2/3] Feedback: Make a compiled regex pattern private --- jsonschema/exceptions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jsonschema/exceptions.py b/jsonschema/exceptions.py index ec280ff2e..d955e356e 100644 --- a/jsonschema/exceptions.py +++ b/jsonschema/exceptions.py @@ -24,7 +24,7 @@ WEAK_MATCHES: frozenset[str] = frozenset(["anyOf", "oneOf"]) STRONG_MATCHES: frozenset[str] = frozenset() -JSON_PATH_COMPATIBLE_PROPERTY_PATTERN = re.compile("^[a-zA-Z][a-zA-Z0-9_]*$") +_JSON_PATH_COMPATIBLE_PROPERTY_PATTERN = re.compile("^[a-zA-Z][a-zA-Z0-9_]*$") _unset = _utils.Unset() @@ -155,7 +155,7 @@ def json_path(self) -> str: for elem in self.absolute_path: if isinstance(elem, int): path += "[" + str(elem) + "]" - elif JSON_PATH_COMPATIBLE_PROPERTY_PATTERN.match(elem): + elif _JSON_PATH_COMPATIBLE_PROPERTY_PATTERN.match(elem): path += "." + elem else: escaped_elem = elem.replace("\\", "\\\\").replace("'", r"\'") From 4cf45b95de90191b02396bd04d41760d9798ecd6 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Thu, 17 Jul 2025 08:21:22 -0500 Subject: [PATCH 3/3] Feedback: Use jsonpath-ng to re-parse the rendered JSON path --- jsonschema/tests/test_exceptions.py | 126 +++++++--------------------- noxfile.py | 1 + 2 files changed, 33 insertions(+), 94 deletions(-) diff --git a/jsonschema/tests/test_exceptions.py b/jsonschema/tests/test_exceptions.py index 2a3a5f25f..8d515a998 100644 --- a/jsonschema/tests/test_exceptions.py +++ b/jsonschema/tests/test_exceptions.py @@ -1,6 +1,8 @@ from unittest import TestCase import textwrap +import jsonpath_ng + from jsonschema import exceptions from jsonschema.validators import _LATEST_VERSION @@ -703,119 +705,55 @@ def test_hashable(self): class TestJsonPathRendering(TestCase): - def test_str(self): - e = exceptions.ValidationError( - path=["x"], + def validate_json_path_rendering(self, property_name, expected_path): + error = exceptions.ValidationError( + path=[property_name], message="1", validator="foo", instance="i1", ) - self.assertEqual(e.json_path, "$.x") - def test_empty_str(self): - e = exceptions.ValidationError( - path=[""], - message="1", - validator="foo", - instance="i1", - ) - self.assertEqual(e.json_path, "$['']") + rendered_json_path = error.json_path + self.assertEqual(rendered_json_path, expected_path) - def test_numeric_str(self): - e = exceptions.ValidationError( - path=["1"], - message="1", - validator="foo", - instance="i1", - ) - self.assertEqual(e.json_path, "$['1']") + re_parsed_name = jsonpath_ng.parse(rendered_json_path).right.fields[0] + self.assertEqual(re_parsed_name, property_name) - def test_period_str(self): - e = exceptions.ValidationError( - path=["."], - message="1", - validator="foo", - instance="i1", - ) - self.assertEqual(e.json_path, "$['.']") + def test_basic(self): + self.validate_json_path_rendering("x", "$.x") - def test_single_quote_str(self): - e = exceptions.ValidationError( - path=["'"], - message="1", - validator="foo", - instance="i1", - ) - self.assertEqual(e.json_path, r"$['\'']") + def test_empty(self): + self.validate_json_path_rendering("", "$['']") - def test_space_str(self): - e = exceptions.ValidationError( - path=[" "], - message="1", - validator="foo", - instance="i1", - ) - self.assertEqual(e.json_path, "$[' ']") + def test_number(self): + self.validate_json_path_rendering("1", "$['1']") - def test_backslash_str(self): - e = exceptions.ValidationError( - path=["\\"], - message="1", - validator="foo", - instance="i1", - ) - self.assertEqual(e.json_path, r"$['\\']") + def test_period(self): + self.validate_json_path_rendering(".", "$['.']") + + def test_single_quote(self): + self.validate_json_path_rendering("'", r"$['\'']") + + def test_space(self): + self.validate_json_path_rendering(" ", "$[' ']") + + def test_backslash(self): + self.validate_json_path_rendering("\\", r"$['\\']") def test_backslash_single_quote(self): - e = exceptions.ValidationError( - path=[r"\'"], - message="1", - validator="foo", - instance="i1", - ) - self.assertEqual(e.json_path, r"$['\\\'']") + self.validate_json_path_rendering(r"\'", r"$['\\\'']") def test_underscore(self): - e = exceptions.ValidationError( - path=["_"], - message="1", - validator="foo", - instance="i1", - ) - self.assertEqual(e.json_path, r"$['_']") + self.validate_json_path_rendering("_", r"$['_']") def test_double_quote(self): - e = exceptions.ValidationError( - path=['"'], - message="1", - validator="foo", - instance="i1", - ) - self.assertEqual(e.json_path, """$['"']""") + self.validate_json_path_rendering('"', """$['"']""") def test_hyphen(self): - e = exceptions.ValidationError( - path=["-"], - message="1", - validator="foo", - instance="i1", - ) - self.assertEqual(e.json_path, "$['-']") + self.validate_json_path_rendering("-", "$['-']") def test_json_path_injection(self): - e = exceptions.ValidationError( - path=["a[0]"], - message="1", - validator="foo", - instance="i1", - ) - self.assertEqual(e.json_path, "$['a[0]']") + self.validate_json_path_rendering("a[0]", "$['a[0]']") def test_open_bracket(self): - e = exceptions.ValidationError( - path=["["], - message="1", - validator="foo", - instance="i1", - ) - self.assertEqual(e.json_path, "$['[']") + self.validate_json_path_rendering("[", "$['[']") diff --git a/noxfile.py b/noxfile.py index 2f750385c..f9869a35c 100644 --- a/noxfile.py +++ b/noxfile.py @@ -61,6 +61,7 @@ def tests(session, installable): env = dict(JSON_SCHEMA_TEST_SUITE=str(ROOT / "json")) session.install("virtue", installable) + session.install("jsonpath-ng", installable) if session.posargs and session.posargs[0] == "coverage": if len(session.posargs) > 1 and session.posargs[1] == "github":