Skip to content

Commit d250b89

Browse files
authored
Merge pull request #41 from rayokota/regex-option
Add pluggable regex engine support
2 parents 34ce4d4 + 66154da commit d250b89

12 files changed

Lines changed: 366 additions & 79 deletions

File tree

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,36 @@ JSONata> (a & b)
107107
hello world
108108
```
109109

110+
## Guardrails
111+
112+
JSONata is Turing-complete, so it's possible to write expressions that loop forever or exhaust memory. If you evaluate
113+
untrusted expressions, configure these guardrails (see the JS reference implementation's
114+
[guardrails docs](https://docs.jsonata.org/guardrails) for more background):
115+
116+
- **Stack overflow** — the `stack` parameter caps the depth of the eval-apply cycle. Exceeding it raises `D1011`.
117+
- **Excessive execution time** — the `timeout` parameter (in milliseconds) catches tail-recursive infinite loops that
118+
`stack` can't. Exceeding it raises `D1012`.
119+
- **Rogue regular expressions** — the `regex_engine` parameter lets you swap in a linear-time engine (e.g.
120+
[`google-re2`](https://pypi.org/project/google-re2/)) to protect against [ReDoS](https://en.wikipedia.org/wiki/ReDoS),
121+
since the `timeout` guardrail can't interrupt a regex match in progress.
122+
123+
```python
124+
import re2 # pip install google-re2
125+
import jsonata
126+
from jsonata.regex_engine import RegexFlags
127+
128+
129+
def re2_regex_engine(pattern: str, flags: RegexFlags):
130+
options = re2.Options()
131+
options.case_sensitive = not flags.case_insensitive
132+
options.one_line = not flags.multiline
133+
return re2.compile(pattern, options)
134+
135+
136+
expr = jsonata.Jsonata("<JSONata expression>", re2_regex_engine, timeout=1000, stack=500)
137+
result = expr.evaluate(data)
138+
```
139+
110140
## Running Tests
111141

112142
This project uses the repository of the reference implementation as a submodule. This allows referencing the current version of the unit tests. To clone this repository, run:

noxfile.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,33 @@ def tests(session):
4444
build_and_check_dists(session)
4545

4646
generated_files = os.listdir("dist/")
47-
generated_sdist = os.path.join("dist/", generated_files[1])
47+
sdists = [f for f in generated_files if f.endswith(".tar.gz")]
48+
if not sdists:
49+
session.error("No sdist (.tar.gz) found in dist/")
50+
generated_sdist = max((os.path.join("dist/", f) for f in sdists), key=os.path.getmtime)
4851

4952
session.install(generated_sdist)
5053

5154
session.run("python", "tests/generate.py")
5255
session.run("py.test", "tests/", *session.posargs)
56+
57+
58+
# Exercises the optional pluggable regex_engine hook against Google's RE2
59+
# (tests/re2_engine_test.py). Kept as its own session so the main `tests`
60+
# session -- and the package itself -- stay free of a google-re2 dependency;
61+
# this one opts in explicitly.
62+
@nox.session
63+
def test_re2(session):
64+
session.install("pytest")
65+
session.install("google-re2")
66+
build_and_check_dists(session)
67+
68+
generated_files = os.listdir("dist/")
69+
sdists = [f for f in generated_files if f.endswith(".tar.gz")]
70+
if not sdists:
71+
session.error("No sdist (.tar.gz) found in dist/")
72+
generated_sdist = max((os.path.join("dist/", f) for f in sdists), key=os.path.getmtime)
73+
74+
session.install(generated_sdist)
75+
76+
session.run("py.test", "tests/re2_engine_test.py", *session.posargs)

src/jsonata/functions.py

Lines changed: 80 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from typing import Any, AnyStr, Mapping, NoReturn, Optional, Sequence, Callable, Type, Union
4040

4141
from jsonata import datetimeutils, jexception, parser, utils
42+
from jsonata.regex_engine import CompiledPattern
4243

4344

4445
class Functions:
@@ -514,7 +515,7 @@ class RegexpMatch:
514515
# @returns {object} - structure that represents the match(es)
515516
#
516517
@staticmethod
517-
def evaluate_matcher(matcher: re.Pattern, string: Optional[str]) -> list[RegexpMatch]:
518+
def evaluate_matcher(matcher: CompiledPattern, string: Optional[str]) -> list[RegexpMatch]:
518519
res = []
519520
matches = matcher.finditer(string)
520521
for m in matches:
@@ -537,7 +538,7 @@ def evaluate_matcher(matcher: re.Pattern, string: Optional[str]) -> list[RegexpM
537538
# @returns {Boolean} - true if str contains token
538539
#
539540
@staticmethod
540-
def contains(string: Optional[str], token: Union[None, str, re.Pattern]) -> Optional[bool]:
541+
def contains(string: Optional[str], token: Union[None, str, CompiledPattern]) -> Optional[bool]:
541542
# undefined inputs always return undefined
542543
if string is None:
543544
return None
@@ -549,7 +550,7 @@ def contains(string: Optional[str], token: Union[None, str, re.Pattern]) -> Opti
549550

550551
if isinstance(token, str):
551552
result = (string.find(str(token)) != - 1)
552-
elif isinstance(token, re.Pattern):
553+
elif Functions.is_regex(token):
553554
matches = Functions.evaluate_matcher(token, string)
554555
# if (dbg) System.out.println("match = "+matches)
555556
# result = (typeof matches !== 'undefined')
@@ -568,7 +569,7 @@ def contains(string: Optional[str], token: Union[None, str, re.Pattern]) -> Opti
568569
# @returns {Array} The array of match objects
569570
#
570571
@staticmethod
571-
def match_(string: Optional[str], regex: Optional[re.Pattern], limit: Optional[int]) -> Optional[list[dict[str, Any]]]:
572+
def match_(string: Optional[str], regex: Optional[CompiledPattern], limit: Optional[int]) -> Optional[list[dict[str, Any]]]:
572573
# undefined inputs always return undefined
573574
if string is None:
574575
return None
@@ -636,7 +637,7 @@ def safe_replacement(in_: str) -> str:
636637
# @return
637638
#
638639
@staticmethod
639-
def safe_replace_all(s: str, pattern: re.Pattern, replacement: Optional[Any]) -> Optional[str]:
640+
def safe_replace_all(s: str, pattern: CompiledPattern, replacement: Optional[Any]) -> Optional[str]:
640641

641642
if not (isinstance(replacement, str)):
642643
return Functions.safe_replace_all_fn(s, pattern, replacement)
@@ -647,7 +648,7 @@ def safe_replace_all(s: str, pattern: re.Pattern, replacement: Optional[Any]) ->
647648
r = None
648649
for i in range(0, 10):
649650
try:
650-
r = re.sub(pattern, replacement, s)
651+
r = pattern.sub(replacement, s)
651652
break
652653
except Exception as e:
653654
msg = str(e)
@@ -696,15 +697,15 @@ def to_jsonata_match(mr: re.Match[str]) -> dict[str, list[str]]:
696697
# @return
697698
#
698699
@staticmethod
699-
def safe_replace_all_fn(s: str, pattern: re.Pattern, fn: Optional[Any]) -> str:
700+
def safe_replace_all_fn(s: str, pattern: CompiledPattern, fn: Optional[Any]) -> str:
700701
def replace_fn(t):
701702
res = Functions.func_apply(fn, [Functions.to_jsonata_match(t)])
702703
if isinstance(res, str):
703704
return res
704705
else:
705706
raise jexception.JException("D3012", -1)
706707

707-
r = re.sub(pattern, replace_fn, s)
708+
r = pattern.sub(replace_fn, s)
708709
return r
709710

710711
#
@@ -716,12 +717,12 @@ def replace_fn(t):
716717
# @return
717718
#
718719
@staticmethod
719-
def safe_replace_first(s: str, pattern: re.Pattern, replacement: str) -> Optional[str]:
720+
def safe_replace_first(s: str, pattern: CompiledPattern, replacement: str) -> Optional[str]:
720721
replacement = Functions.safe_replacement(replacement)
721722
r = None
722723
for i in range(0, 10):
723724
try:
724-
r = re.sub(pattern, replacement, s, count=1)
725+
r = pattern.sub(replacement, s, 1)
725726
break
726727
except Exception as e:
727728
msg = str(e)
@@ -744,7 +745,7 @@ def safe_replace_first(s: str, pattern: re.Pattern, replacement: str) -> Optiona
744745
return r
745746

746747
@staticmethod
747-
def replace(string: Optional[str], pattern: Union[str, re.Pattern], replacement: Optional[Any], limit: Optional[int]) -> Optional[str]:
748+
def replace(string: Optional[str], pattern: Union[str, CompiledPattern], replacement: Optional[Any], limit: Optional[int]) -> Optional[str]:
748749
if string is None:
749750
return None
750751

@@ -938,7 +939,7 @@ def decode_url(string: Optional[str]) -> Optional[str]:
938939
return urllib.parse.unquote(string, errors="strict")
939940

940941
@staticmethod
941-
def split(string: Optional[str], pattern: Union[str, Optional[re.Pattern]], limit: Optional[float]) -> Optional[list[str]]:
942+
def split(string: Optional[str], pattern: Union[str, Optional[CompiledPattern]], limit: Optional[float]) -> Optional[list[str]]:
942943
if string is None:
943944
return None
944945

@@ -2019,6 +2020,23 @@ def append(arg1: Optional[Any], arg2: Optional[Any]) -> Optional[Any]:
20192020
def is_lambda(result: Optional[Any]) -> bool:
20202021
return isinstance(result, parser.Parser.Symbol) and result._jsonata_lambda
20212022

2023+
#
2024+
# Tests whether a value is a compiled regex, from the stdlib re module
2025+
# or from a pluggable regex_engine (e.g. re2) with a compatible interface.
2026+
#
2027+
@staticmethod
2028+
def is_regex(value: Optional[Any]) -> bool:
2029+
if isinstance(value, re.Pattern):
2030+
return True
2031+
if value is None or inspect.ismodule(value) or inspect.isclass(value):
2032+
return False
2033+
return (
2034+
callable(getattr(value, "search", None))
2035+
and callable(getattr(value, "finditer", None))
2036+
and callable(getattr(value, "sub", None))
2037+
and callable(getattr(value, "split", None))
2038+
)
2039+
20222040
#
20232041
# Return value from an object for a given key
20242042
# @param {Object} input - Object/Array
@@ -2191,7 +2209,15 @@ def function_eval(expr: Optional[str], focus: Optional[Any]) -> Optional[Any]:
21912209
# undefined inputs always return undefined
21922210
if expr is None:
21932211
return None
2194-
input = jsonata.Jsonata.CURRENT.jsonata.input # = this.input;
2212+
2213+
# Capture the enclosing instance *before* constructing the nested
2214+
# ast below: Jsonata.__init__ unconditionally overwrites
2215+
# Jsonata.CURRENT.jsonata as a side effect, so re-reading
2216+
# CURRENT.jsonata afterwards would silently return the inner
2217+
# expression instead of the enclosing one, losing access to its
2218+
# environment (e.g. outer variable bindings).
2219+
enclosing = jsonata.Jsonata.CURRENT.jsonata
2220+
input = enclosing.input # = this.input;
21952221
if focus is not None:
21962222
input = focus
21972223
# if the input is a JSON array, then wrap it in a singleton sequence so it gets treated as a single input
@@ -2201,18 +2227,47 @@ def function_eval(expr: Optional[str], focus: Optional[Any]) -> Optional[Any]:
22012227

22022228
ast = None
22032229
try:
2204-
ast = jsonata.Jsonata(expr)
2205-
except Exception as err:
2206-
# error parsing the expression passed to $eval
2207-
# populateMessage(err)
2208-
raise jexception.JException("D3120", -1)
2209-
result = None
2210-
try:
2211-
result = ast.evaluate(input, jsonata.Jsonata.CURRENT.jsonata.environment)
2212-
except Exception as err:
2213-
# error evaluating the expression passed to $eval
2214-
# populateMessage(err)
2215-
raise jexception.JException("D3121", -1)
2230+
try:
2231+
# Only used to parse expr into an AST (ast.ast below); the
2232+
# actual evaluation reuses the enclosing instance directly
2233+
# (see below), so regex_engine here just needs to be valid
2234+
# for parsing -- it does not need to be evaluated against.
2235+
ast = jsonata.Jsonata(expr, enclosing.regex_engine)
2236+
except Exception as err:
2237+
# error parsing the expression passed to $eval
2238+
# populateMessage(err)
2239+
raise jexception.JException("D3120", -1)
2240+
2241+
# Constructing `ast` overwrote Jsonata.CURRENT.jsonata as a side
2242+
# effect (see Jsonata.__init__); restore it to the enclosing
2243+
# instance before evaluating, so enclosing.eval()'s own
2244+
# get_per_thread_instance() lookup resolves correctly.
2245+
jsonata.Jsonata.CURRENT.jsonata = enclosing
2246+
2247+
result = None
2248+
try:
2249+
# Evaluate ast.ast (the parsed tree) using the *enclosing*
2250+
# instance's low-level eval(), reusing enclosing.environment
2251+
# directly rather than calling ast.evaluate(input, environment)
2252+
# (which copies environment's bindings one level deep into a
2253+
# fresh child frame rooted at ast's own static frame). This
2254+
# mirrors jsonata-java's Functions.functionEval, which calls
2255+
# Jsonata.current.get().evaluate(ast.ast, input, env) rather
2256+
# than constructing a second, disconnected evaluation
2257+
# context. Reusing the environment directly means $eval sees
2258+
# bindings at every level of the enclosing scope chain (not
2259+
# just the immediate frame) and correctly inherits any
2260+
# stack/timeout guardrails registered on an ancestor frame,
2261+
# since Frame.lookup walks the parent chain.
2262+
result = enclosing.eval(ast.ast, input, enclosing.environment)
2263+
except Exception as err:
2264+
# error evaluating the expression passed to $eval
2265+
# populateMessage(err)
2266+
raise jexception.JException("D3121", -1)
2267+
finally:
2268+
# Restore the enclosing instance as current now that $eval's
2269+
# parsing/evaluation is done.
2270+
jsonata.Jsonata.CURRENT.jsonata = enclosing
22162271

22172272
return result
22182273

src/jsonata/jexception.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,8 @@ def msg(error: str, location: int, arg1: Optional[Any], arg2: Optional[Any], det
175175
"T1007": "Attempted to partially apply a non-function. Did you mean ${{{token}}}?",
176176
"T1008": "Attempted to partially apply a non-function",
177177
"D1009": "Multiple key definitions evaluate to same key: {{value}}",
178+
"D1011": "Stack overflow. Check for non-terminating recursive function. Consider rewriting as tail-recursive",
179+
"D1012": "Evaluation timeout after {{value}} milliseconds. Check for infinite loop",
178180
"T1010": "The matcher Object argument passed to Object {{token}} does not return the correct object structure",
179181
"T2001": "The left side of the {{token}} operator must evaluate to a number",
180182
"T2002": "The right side of the {{token}} operator must evaluate to a number",

0 commit comments

Comments
 (0)