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
39 changes: 39 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,45 @@ if(JSONATA_BUILD_TESTS)
endif()
endif()
endif()

# Optional: demonstrates that jsonata-cpp's pluggable regex engine
# (IRegex, see include/jsonata/IRegex.h) can be backed by Google's RE2
# instead of std::regex. Off by default. RE2 must already be available
# via find_package (e.g. installed through vcpkg/conan/brew) -- it is
# not fetched automatically like nlohmann_json, since current RE2
# releases also require Abseil, making a FetchContent fallback a much
# heavier addition than an optional test warrants.
option(JSONATA_BUILD_RE2_TEST "Build the optional RE2 regex engine test (requires re2 to be installed)" OFF)

if(JSONATA_BUILD_RE2_TEST)
find_package(re2 CONFIG REQUIRED)

add_executable(jsonata_re2_test test/RE2Engine.cpp test/RE2EngineTest.cpp)
target_link_libraries(jsonata_re2_test
jsonata
re2::re2
gtest_main
gtest
Threads::Threads
)
# re2::re2 pulls in Abseil, and Abseil's ABI (e.g. absl::string_view's
# layout) depends on which copy of its headers get resolved first.
# Other PUBLIC dependencies (e.g. a system-wide nlohmann_json found
# via find_package) can inject unrelated include directories that
# happen to also contain a differently-configured Abseil, silently
# shadowing the one re2 was actually built against and causing link
# errors. Force re2/Abseil's own include dirs to be searched first.
target_include_directories(jsonata_re2_test BEFORE PRIVATE
$<TARGET_PROPERTY:re2::re2,INTERFACE_INCLUDE_DIRECTORIES>
)

if(TARGET gtest)
include(GoogleTest)
gtest_discover_tests(jsonata_re2_test
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
)
endif()
endif()
endif()

# Installation rules
Expand Down
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,53 @@ int main() {
}
```

## Guardrails

JSONata is Turing-complete, so it's possible to write expressions that loop forever or exhaust memory. If you evaluate
untrusted expressions, configure these guardrails (see the JS reference implementation's
[guardrails docs](https://docs.jsonata.org/guardrails) for more background):

- **Stack overflow** — the `stack` argument caps the depth of the eval-apply cycle. Exceeding it raises a `JException`
with error `D1011`.
- **Excessive execution time** — the `timeout` argument (in milliseconds) catches tail-recursive infinite loops that
`stack` can't. Exceeding it raises `D1012`.
- **Rogue regular expressions** — the `regexEngine` argument lets you swap in a linear-time engine (e.g.
[RE2](https://github.com/google/re2)) to protect against [ReDoS](https://en.wikipedia.org/wiki/ReDoS), since the
`timeout` guardrail can't interrupt a regex match in progress.

To swap in a linear-time regex engine, you can wrap RE2 with the `IRegex` interface.
A complete, working example lives in `test/RE2Engine.h`/`.cpp`; the shape is:

```cpp
#include <jsonata/IRegex.h>
#include <re2/re2.h>

class RE2Regex : public jsonata::IRegex {
public:
RE2Regex(const std::string& pattern, jsonata::RegexFlags flags);
bool test(const std::string& str) const override;
std::optional<jsonata::RegexMatch> findFirst(const std::string& str, size_t pos) const override;
std::vector<jsonata::RegexMatch> findAll(const std::string& str) const override;
std::vector<std::string> split(const std::string& str) const override;
private:
RE2 re_;
};

jsonata::RegexEngine re2RegexEngine() {
return [](const std::string& pattern, jsonata::RegexFlags flags) {
return std::make_shared<RE2Regex>(pattern, flags);
};
}
```

```cpp
#include <jsonata/Jsonata.h>

jsonata::Jsonata expr("<JSONata expression>", re2RegexEngine(),
/*timeout=*/1000, /*stack=*/500);
auto result = expr.evaluate(data);
```

## Running Tests

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:
Expand Down
19 changes: 12 additions & 7 deletions include/jsonata/Functions.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
#include <string>
#include <vector>

#include "jsonata/IRegex.h"
#include "Utils.h"

namespace jsonata {
Expand Down Expand Up @@ -66,7 +67,8 @@ class Functions {
static std::optional<std::string> trim(const std::string& str);
static std::any split(const std::string& str, const std::string& separator,
int64_t limit = -1);
static std::any split(const std::string& str, const std::regex& pattern,
static std::any split(const std::string& str,
const std::shared_ptr<IRegex>& pattern,
int64_t limit = -1);
Comment thread
rayokota marked this conversation as resolved.
static std::optional<std::string> join(const Utils::JList& arr,
const std::string& separator = "");
Expand Down Expand Up @@ -173,9 +175,10 @@ class Functions {
static int64_t millis();

// Regex functions
static Utils::JList evaluateMatcher(const std::regex& pattern,
static Utils::JList evaluateMatcher(const std::shared_ptr<IRegex>& pattern,
const std::string& str);
static Utils::JList match(const std::string& str, const std::regex& pattern,
static Utils::JList match(const std::string& str,
const std::shared_ptr<IRegex>& pattern,
int64_t limit = -1);

// Lambda detection
Expand Down Expand Up @@ -225,16 +228,18 @@ class Functions {
static bool isNumericString(const std::string& str);
static std::string safeReplacement(const std::string& replacement);
static std::string safeReplaceAll(const std::string& str,
const std::regex& pattern,
const std::shared_ptr<IRegex>& pattern,
const std::string& replacement);
static std::string safeReplaceFirst(const std::string& str,
const std::regex& pattern,
const std::shared_ptr<IRegex>& pattern,
const std::string& replacement);
static std::string safeReplaceAllFn(const std::string& str,
const std::regex& pattern,
const std::shared_ptr<IRegex>& pattern,
const std::any& func);
static std::string expandReplacement(const std::string& replacement,
const RegexMatch& match);
static nlohmann::ordered_map<std::string, std::any> toJsonataMatch(
const std::smatch& match);
const RegexMatch& match);
static std::string encodeURI(const std::string& uri);
static std::string leftPad(const std::string& str, int64_t size,
const std::string& padStr = " ");
Expand Down
84 changes: 84 additions & 0 deletions include/jsonata/IRegex.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* jsonata-cpp is the JSONata C++ reference port
*
* Copyright Robert Yokota
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once

#include <functional>
#include <memory>
#include <optional>
#include <string>
#include <vector>

namespace jsonata {

// A single match, engine-agnostic (the equivalent of std::smatch, but not
// tied to std::regex so alternative engines such as RE2 can produce it too).
struct RegexMatch {
std::string text;
size_t position = 0;
size_t length = 0;
// groups[0] is capture group 1, matching std::smatch's [1] indexing
// offset by the implicit full-match group 0.
std::vector<std::optional<std::string>> groups;
};

// Flags parsed from a JSONata regex literal's /pattern/flags suffix. Engines
// translate these into their own native flag representation.
struct RegexFlags {
bool caseInsensitive = false;
bool multiline = false;
};

// Engine-agnostic compiled regex. JSONata regex literals (and patterns given
// as plain strings to functions like $match/$replace/$split) are compiled
// into this interface, so the evaluator never depends on std::regex
// directly. The default engine wraps std::regex (see StdRegex.h); a custom
// engine (e.g. RE2) can be injected via Jsonata's constructor.
class IRegex {
public:
virtual ~IRegex() = default;

// True if the pattern matches anywhere in str. Backs $contains.
virtual bool test(const std::string& str) const = 0;

// The first match starting the search no earlier than byte offset `pos`.
// Backs $replace's first/limited-match paths, and lazy one-at-a-time
// iteration when a regex literal is invoked as a function -- callers
// that only want the first few matches of a large input should call
// this repeatedly (advancing `pos` past each match) rather than use
// findAll, which materializes every match up front.
virtual std::optional<RegexMatch> findFirst(const std::string& str,
size_t pos = 0) const = 0;

// All non-overlapping matches, in order. Backs $match.
virtual std::vector<RegexMatch> findAll(const std::string& str) const = 0;

// Splits str on every match, returning the segments between matches
// (including empty leading/trailing segments). Backs $split.
virtual std::vector<std::string> split(const std::string& str) const = 0;
};

// Compiles a pattern into an IRegex. Used both for JSONata regex literals
// and for patterns supplied as plain strings at runtime.
using RegexEngine = std::function<std::shared_ptr<IRegex>(
const std::string& pattern, RegexFlags flags)>;

// The built-in std::regex-backed engine; this is jsonata-cpp's default and
// preserves its pre-existing regex behavior exactly.
RegexEngine defaultRegexEngine();

} // namespace jsonata
35 changes: 28 additions & 7 deletions include/jsonata/Jsonata.h
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
#include <string>
#include <vector>

#include "jsonata/IRegex.h"
#include "jsonata/Parser.h"

// Forward declarations
Expand Down Expand Up @@ -65,8 +66,8 @@ class Frame {
std::shared_ptr<Frame> parent_;
nlohmann::ordered_map<std::string, std::any> bindings_;
std::chrono::time_point<std::chrono::steady_clock> timestamp_;
int64_t timeout_;
int64_t recursionDepth_;
std::optional<int64_t> timeout_;
std::optional<int64_t> recursionDepth_;
EntryCallback entryCallback_;
ExitCallback exitCallback_;
std::unique_ptr<class Timebox> timebox_;
Expand All @@ -83,8 +84,10 @@ class Frame {
void bind(const std::string& name, const std::any& value);
std::any lookup(const std::string& name) const;

// Runtime bounds
void setRuntimeBounds(int64_t timeout, int64_t maxRecursionDepth);
// Runtime bounds. Either bound may be left unset (std::nullopt) to leave
// that guardrail disabled.
void setRuntimeBounds(std::optional<int64_t> timeout,
std::optional<int64_t> maxRecursionDepth);

// Evaluation callbacks
void setEvaluateEntryCallback(EntryCallback callback);
Expand Down Expand Up @@ -124,8 +127,18 @@ class JFunction {
class Jsonata {
public:
// Constructors
Jsonata();
Jsonata(const std::string& jsonataExpression);
//
// timeout/stack configure the optional evaluation guardrails (D1012/D1011):
// timeout is a wall-clock limit in milliseconds, stack limits the depth of
// the eval-apply cycle. Either may be left unset (std::nullopt) to leave
// that guardrail disabled.
explicit Jsonata(RegexEngine regexEngine = defaultRegexEngine(),
std::optional<int64_t> timeout = std::nullopt,
std::optional<int64_t> stack = std::nullopt);
Jsonata(const std::string& jsonataExpression,
RegexEngine regexEngine = defaultRegexEngine(),
std::optional<int64_t> timeout = std::nullopt,
std::optional<int64_t> stack = std::nullopt);
Jsonata(const Jsonata& other); // Copy constructor for per-thread instances

// Main evaluation methods (ordered JSON variants)
Expand All @@ -150,8 +163,15 @@ class Jsonata {
// Environment access
std::shared_ptr<Frame> getEnvironment() const;

// Regex engine used to compile JSONata regex literals and dynamic
// string patterns (e.g. for $match/$replace/$split).
RegexEngine getRegexEngine() const { return regexEngine_; }

// Factory methods
static Jsonata jsonata(const std::string& expression);
static Jsonata jsonata(const std::string& expression,
RegexEngine regexEngine = defaultRegexEngine(),
std::optional<int64_t> timeout = std::nullopt,
std::optional<int64_t> stack = std::nullopt);

// Instance methods (matching Java reference)
std::shared_ptr<Frame> createFrame();
Expand Down Expand Up @@ -193,6 +213,7 @@ class Jsonata {
static void clearPerThreadInstance();

std::shared_ptr<Frame> environment_;
RegexEngine regexEngine_ = defaultRegexEngine();
static thread_local std::shared_ptr<class Parser> currentParser_;
static thread_local std::any tls_input_;
static thread_local std::shared_ptr<Frame> tls_environment_;
Expand Down
3 changes: 2 additions & 1 deletion include/jsonata/Parser.h
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,8 @@ class Parser {

// Main parsing method
std::shared_ptr<Symbol> parse(const std::string& source,
bool recover = false);
bool recover = false,
RegexEngine regexEngine = defaultRegexEngine());

// Get parsing errors
const std::vector<std::shared_ptr<std::exception>>& getErrors() const {
Expand Down
41 changes: 41 additions & 0 deletions include/jsonata/StdRegex.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* jsonata-cpp is the JSONata C++ reference port
*
* Copyright Robert Yokota
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#pragma once

#include <regex>

#include "jsonata/IRegex.h"

namespace jsonata {

// Default IRegex implementation, backed by std::regex (ECMAScript grammar).
class StdRegex : public IRegex {
public:
StdRegex(const std::string& pattern, RegexFlags flags);

bool test(const std::string& str) const override;
std::optional<RegexMatch> findFirst(const std::string& str, size_t pos) const override;
std::vector<RegexMatch> findAll(const std::string& str) const override;
std::vector<std::string> split(const std::string& str) const override;

private:
std::regex regex_;
static RegexMatch toRegexMatch(const std::smatch& m);
};

} // namespace jsonata
Loading
Loading