diff --git a/CHANGELOG.md b/CHANGELOG.md index c46e130..bd8764e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Python JSONPath Change Log +## Unreleased + +**Fixes** + +- Fixed lexing of quoted name selectors ending in an escaped backslash, like `$['a\\']['b']`. The lexer's look-behind for the closing quote treated a quote following an escaped backslash (`\\`) as an escaped quote (`\'`), so these selectors failed to tokenize. This also broke the RFC 9535 normalized path round-trip: normalized paths produced for object keys containing backslashes could not be parsed back. + ## Version 2.1.0 Added `patch.atomic(patch, data)` and `JSONPatch.atomic(data)`. `atomic()` is similar to `apply()`, but preserves input data if a patch operation fails. See [#129](https://github.com/jg-rp/python-jsonpath/issues/129). diff --git a/jsonpath/lex.py b/jsonpath/lex.py index 75f48a7..a9faf1f 100644 --- a/jsonpath/lex.py +++ b/jsonpath/lex.py @@ -99,8 +99,8 @@ class attributes. Then setting `lexer_class` on a `JSONPathEnvironment`. def __init__(self, *, env: JSONPathEnvironment) -> None: self.env = env - self.double_quote_pattern = r'"(?P(?:(?!(?(?:(?!(?(?:[^"\\]|\\.)*)"' + self.single_quote_pattern = r"'(?P(?:[^'\\]|\\.)*)'" # .thing self.dot_property_pattern = rf"(?P\.)(?P{self.key_pattern})" diff --git a/tests/test_escaped_backslash_string_literals.py b/tests/test_escaped_backslash_string_literals.py new file mode 100644 index 0000000..07bd915 --- /dev/null +++ b/tests/test_escaped_backslash_string_literals.py @@ -0,0 +1,74 @@ +r"""Regression tests for lexing string literals containing escaped backslashes. + +The lexer used to detect the closing quote of a string literal with a +look-behind that only checked for a single preceding backslash. That mistook a +quote following an *escaped* backslash (``\\``) for an *escaped* quote (``\'``), +so any name selector whose value ended in an even-length run of backslashes +failed to tokenize. This also broke the RFC 9535 normalized-path round-trip: +python-jsonpath produced ``match.path`` strings it could not parse back. +""" + +from typing import Any +from typing import List + +import pytest + +import jsonpath + + +@pytest.mark.parametrize( + ("query", "document", "want"), + [ + # A single-quoted name selector whose value ends with a backslash, + # followed by another selector (the shape a normalized path takes). + (r"$['a\\']['b']", {"a\\": {"b": 1}}, [1]), + # Double-quoted variant. + (r'$["a\\"]["b"]', {"a\\": {"b": 2}}, [2]), + # Backslash key indexing into a list. + (r"$['\\'][0]", {"\\": [9]}, [9]), + # Two consecutive backslash keys. + (r"$['x\\']['y\\']", {"x\\": {"y\\": 5}}, [5]), + # Value that is two backslashes. + (r"$['\\\\']['b']", {"\\\\": {"b": 7}}, [7]), + # Escaped backslash followed by an escaped quote in the same literal. + (r"$['a\\\'b']", {"a\\'b": 3}, [3]), + # A trailing backslash selector on its own still works. + (r"$['a\\']", {"a\\": 4}, [4]), + # Escaped quote (no backslash bug) must keep working. + (r"$['a\'b']", {"a'b": 6}, [6]), + ], +) +def test_escaped_backslash_string_literals( + query: str, document: Any, want: List[Any] +) -> None: + assert jsonpath.findall(query, document) == want + + +# Documents whose keys exercise backslashes, quotes and control characters. +ROUND_TRIP_DOCUMENTS: List[Any] = [ + {"a\\": {"b": 1}}, + {"trailing\\": [1, 2, 3]}, + {"\\": {"\\": {"\\": 0}}}, + {"x\\": {"y\\": {"z\\": 42}}}, + {"a\\'b": 1, "c\\\\d": 2, "e'f": 3}, + {"mix\\ed": [{"k\\": "v"}]}, + {"back\\slash": 1, "quote'": 2, "both\\'": 3}, +] + + +@pytest.mark.parametrize("document", ROUND_TRIP_DOCUMENTS) +def test_normalized_path_round_trip(document: Any) -> None: + """Every normalized path must select exactly the node it came from.""" + for match in jsonpath.finditer("$..*", document): + nodes = list(jsonpath.finditer(match.path, document)) + assert len(nodes) == 1, f"{match.path!r} selected {len(nodes)} nodes" + assert nodes[0].parts == match.parts + assert nodes[0].value == match.value + + +@pytest.mark.parametrize("document", ROUND_TRIP_DOCUMENTS) +def test_normalized_path_is_idempotent(document: Any) -> None: + """Re-normalizing a normalized path yields the same path.""" + for match in jsonpath.finditer("$..*", document): + (again,) = jsonpath.finditer(match.path, document) + assert again.path == match.path