Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
4 changes: 2 additions & 2 deletions jsonpath/lex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<G_DQUOTE>(?:(?!(?<!\\)").)*)"'
self.single_quote_pattern = r"'(?P<G_SQUOTE>(?:(?!(?<!\\)').)*)'"
self.double_quote_pattern = r'"(?P<G_DQUOTE>(?:[^"\\]|\\.)*)"'
self.single_quote_pattern = r"'(?P<G_SQUOTE>(?:[^'\\]|\\.)*)'"

# .thing
self.dot_property_pattern = rf"(?P<G_DOT>\.)(?P<G_PROP>{self.key_pattern})"
Expand Down
74 changes: 74 additions & 0 deletions tests/test_escaped_backslash_string_literals.py
Original file line number Diff line number Diff line change
@@ -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
Loading