Skip to content
Open
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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,33 @@ tests/run_tests.sh # all available engines
tests/run_tests.sh SIMDJSON # single engine
```

The runner requires Python 3 (standard library only). Every selected engine
runs the same regression matrix: explicit argument output, valid/invalid JSON,
UTF-8, numeric ranges, size/limit boundaries, silent mode and repeated passes.
Failures and unsupported-limit skips are reported separately; missing expected
output is a failure. A JSONCONS-only run also compares applicable cases to YAJL
when it is installed. Other engines' known defects remain visible as failures.

The jsoncons adapter uses streaming SAX events, strict JSON syntax and lossless
number events. See [the implementation and compatibility notes](docs/jsoncons-sax.md)
and [validation results](docs/validation-results.md).

To exercise the jsoncons C interface separately after configuring the project
(use the same include flags as your normal build):

```bash
g++ -std=c++17 ${CPPFLAGS:-} tests/jsoncons_api_tests.cpp src/jsonconsparser.cpp -o /tmp/jsoncons_api_tests
/tmp/jsoncons_api_tests
```

For memory-safety validation, add `-fsanitize=address,undefined
-fno-omit-frame-pointer` to that compile command. For repeatable DOM/SAX/YAJL
measurements, preserve a pre-change executable and run:

```bash
python3 tests/benchmark_jsoncons.py --baseline /path/to/jsonbench-before --binary src/jsonbench
```

---

## TODO
Expand Down
123 changes: 123 additions & 0 deletions docs/jsoncons-sax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# jsoncons SAX evaluation

This change implements the parser evaluation requested in
[ModSecurity issue #3308](https://github.com/owasp-modsecurity/ModSecurity/issues/3308#issuecomment-6142615388).
It does not change ModSecurity or claim integration compatibility with its complete request-body processor.

## Original behavior and implementation

The former adapter read an entire file into an `ostringstream`, constructed a
complete `jsoncons::json` DOM, and discarded it. It produced no arguments and
returned unsupported for depth and argument limits. A single-engine test used
itself as reference; missing output and unsupported limits could therefore
appear successful. The combined runner could terminate during `diff | head`
before reporting all failures.

The new adapter uses public `basic_json_reader`/`json_visitor` APIs (verified with
jsoncons 1.3.2), a streaming binary `ifstream`, and event callbacks. No JSON DOM
or whole-file string is constructed. `read(ec)` includes end-of-input validation
and rejects trailing content. A strict parsing handler rejects comments (the
library's default handler accepts them). Syntax errors include line/column;
I/O, output, allocation and limit failures propagate across the existing C API
without leaking C++ exceptions. Streams and visitor state use RAII.

The expanded tests exposed a jsoncons 1.3.2 decoding defect: a lone escaped low
surrogate is silently dropped, and a high surrogate followed by a non-low code
unit can be converted into the wrong character. A small public streaming-source
wrapper validates Unicode escape pairing across input buffers before forwarding
them. It performs no JSON grammar parsing or whole-file pass. Raw UTF-8 and the
remaining JSON syntax are validated by jsoncons.

Each scalar produces one argument, in event order, including duplicate keys;
null produces an empty value. Empty containers produce none. Limits count root
containers as depth 1 and root scalars as depth 0. A depth/argument limit of N
allows exactly N, then rejects the next container/scalar. Direct C callers can
set zero; CLI `-d`/`-a` require decimal integers in 1..UINT_MAX. Every parse call
starts with fresh state and retains the configured limits. Silent mode suppresses
argument writes while enforcing all parsing, count and size checks.

## Argument names and intentional differences

The customary YAJL names are retained: `{"a":{"b":1}}` produces `a.b: 1`,
`{"a":[1,2]}` produces `a.a: 1` and `a.a: 2`, and `[1,2]` uses `array.array`.
Array indexes are not added. Nested arrays repeat the enclosing array key.
Duplicate object keys remain separate arguments.

A structural stack records prefix lengths and parent keys. The shared
`PathHandler` is unsuitable here: it infers parent boundaries from literal dots,
has an off-by-one output-buffer check, and cannot reliably restore parent keys
containing dots. It and the other engines remain unchanged.

Intentional corrections and numeric normalizations are explicit regression cases:

- `{"a.b":{"c":1},"d":2}` ends with `d: 2`; YAJL incorrectly emits `a.d: 2`.
- Mixed root arrays, including `[{"x":1},2]`, restore the containing array key:
both adapters produce `array.array.x: 1` then `array.array: 2` in this case.
- Native integers use exact signed/unsigned 64-bit conversions. Oversized
integers arrive as `bigint` string events, preserving all digits, without
conversion to floating point or arbitrary-precision arithmetic.
- `lossless_number(true)` delivers decimal/scientific numbers as `bigdec`
string events. Decimals, exponent magnitude and trailing decimal zeros remain
exact, including `1e9999` and `1e-9999`. These are argument strings, not claims
that a downstream binary float can represent their range.
- jsoncons normalizes integer `-0` to `0` and removes an explicit positive
exponent sign (`1e+03` becomes `1e03`). Negative-zero decimal `-0.0` remains.
Byte compatibility is only asserted for cases without these differences.
- Decoded embedded NULs are written by length instead of truncated like YAJL's
`%s` output. Raw newline/colon/dot characters retain the CLI's ambiguous text
convention; consumers requiring unambiguous framing should use length-aware
callbacks rather than parse this display format.
- jsoncons finalizes documents and rejects truncated input. YAJL's existing
file adapter does not call `yajl_complete_parse`, so it can accept truncation.
Root numbers without a trailing delimiter also lose their argument in YAJL;
jsoncons emits it correctly. These are two separately tested EOF differences.
- The public jsoncons source adaptor accepts a UTF-8 BOM; YAJL rejects it.
This was checked separately and is permitted by RFC 8259's parser guidance.

## Memory and safety limits

Keys, full argument paths and decoded values must be shorter than the configured
`JSON_STRING_SIZE` (8192 bytes by default), in both output and silent modes.
Checked dynamic strings avoid fixed-buffer writes. jsoncons buffers an individual
token before visiting it, so its public temporary allocator is additionally
bounded: each token/state allocation may use at most
`4 * JSON_STRING_SIZE + 16384` bytes (48 KiB by default). Excessive strings,
numbers and state growth fail explicitly, including unterminated large tokens.
This budget allows buffer growth and ordinary escaped strings within the value
limit; it is a resource ceiling in addition to semantic limits.

Memory grows with retained path/depth state and bounded tokenizer storage, rather
than with total document size. There is no fixed whole-file size limit in this
adapter. Configured depth above INT_MAX is capped by jsoncons's int-based depth
option; the temporary state allocation ceiling can reject far smaller extreme
depths too. Deep recursion is avoided by the event reader and explicit stack.

The other engines have different syntax, range, output and resource behavior.
The regression runner reports those differences as failures where they violate
the required contract; it does not repair their implementations or label absent
argument output as compatible. Missing optional engines remain optional.

## Verification and integration guidance

Run the repository build and engine matrix documented in the README. The C API
harness independently checks invalid parameters, setter boundaries, zero limits,
cleanup, error ownership and reuse after failed parses. The benchmark helper
compares a preserved pre-change executable against the SAX executable and YAJL;
its deterministic inputs and raw samples make the measurement reproducible.
See `validation-results.md` for measured results and commands.

Before adopting jsoncons in ModSecurity:

1. Pin and test the jsoncons version and strict/lossless options. Test its public
incremental `json_parser` API with actual body chunks and an explicit final
parse step; this adapter currently exposes only a file entry point.
2. Send visitor arguments into ModSecurity's length-aware argument API instead
of stdout. Retain duplicate order, null semantics and both limits.
3. Agree on array names, dotted/empty keys, embedded NULs and numeric lexical
normalizations; replay a real request corpus with explicit expectations.
4. Match body-size, decoded-token, depth and argument budgets to ModSecurity's
configuration. Handle partial events on late parse failure consistently with
request-body error handling; streaming output is not transactional.
5. Measure throughput, allocation, latency and peak memory on representative
concurrent requests, malformed bodies and production compiler/toolchains.
SAX parsing here does not establish streaming JSON-schema validation.
161 changes: 161 additions & 0 deletions docs/validation-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Validation results

Validated in `/workspace/digitalwave-jsonbench` based on upstream commit
`a89f2e93fb7f1415095d6fb6c1bb5fab6bdc8e6d`. The previous onboarding fork in
`/workspace/jsonbench` was left unchanged. Platform: Debian 13 amd64, GCC 14.2,
jsoncons 1.3.2. Dependencies/tool activation reside outside the checkout under
`/workspace/.jsonbench-tools`; no lockfile or dependency declaration was changed.
Glaze was unavailable and remains optional/unbuilt/unrun. Ten other engines
were discovered and exercised.

## Build and checks

From the target checkout, with the existing tool activation sourced:

```sh
source /workspace/.jsonbench-tools/activate.sh
./autogen.sh
./configure
make -j2
tests/run_tests.sh JSONCONS
tests/run_tests.sh
```

Autotools generation, default configuration and build succeeded. The final
changed parser was compiled with no new warnings. `git diff --check` and shell
syntax checks passed. No ModSecurity source or other parser implementation was
modified. Changes to `jsonbench.c` check unsigned CLI limits and propagate errors
from jsoncons' non-timed output pass with `-n > 1`.

- JSONCONS selection: **260 passed, 0 failed, 0 skipped**; **6 checked behavioral
differences**, reported separately. Exit 0. 28 explicit cases matched YAJL
byte for byte. Existing fixtures also matched their YAJL reference output.
- Entire ten-engine matrix: **1563 passed, 629 failed, 156 skipped**, plus the
same 6 checked differences. Exit 1, as required. All skips are observed
unsupported-limit warnings; no missing argument output is labelled compatible.
- 56 parser invocations crashed in the full matrix; they were failures, the
runner completed its summary, and core dumps were disabled. No timeouts.
- Opt-in timing (`JSONBENCH_TIMING_RUNS=2`) also passed for JSONCONS; timing
errors would fail the run rather than being ignored.
- A preserved pre-change DOM executable fails **107 required assertions** under
the final JSONCONS matrix (133 passed, 26 unsupported-limit skips, exit 1).
This confirms the new tests detect the former missing-output/limit behavior.
- A separate source copy configured with `--with-jsoncons=no` built successfully
and retained nine engines. Selecting JSONCONS there fails clearly as unavailable.

The shared matrix contains 34 valid and 41 invalid explicit fixtures, plus
constructed exact/exceeded depth and argument boundaries, string/path boundaries,
2 MiB hostile tokens, a document above the legacy file-size ceiling, invalid CLI
limits, repeated passes and I/O errors. Fixtures are deterministic temporary
files. Golden expectations compare complete bytes, preserving NULs/newlines and
real `Time` keys. Negative checks require a diagnostic and ordinary failure:
signals/timeouts never count as successful rejection or unsupported skips.

## Results per engine in the full matrix

The separate JSONCONS/YAJL block adds 28 compatibility passes and 6 checked
DIFFs; it is not included in individual rows below.

| Engine | Passed | Failed | Skipped |
|---|---:|---:|---:|
| YAJL | 191 | 41 | 0 |
| RAPIDJSON | 191 | 41 | 0 |
| NLOHMANNJSON | 209 | 23 | 0 |
| JSONC | 93 | 113 | 26 |
| JANSSON | 135 | 71 | 26 |
| CJSON | 122 | 84 | 26 |
| JSONCPP | 106 | 100 | 26 |
| JSONCONS | 232 | 0 | 0 |
| SIMDJSON | 108 | 98 | 26 |
| YYJSON | 148 | 58 | 26 |

The non-jsoncons failures expose existing adapter behavior: numeric reformatting
or loss of range, missing argument output in DOM wrappers, dropped NULs,
incorrect dotted-key restoration, accepted malformed/UTF-8 inputs, missing
finalization, unenforced limits, fixed file buffers and unsafe oversized-key
handling. Individual failures and child exit codes are captured in
`/tmp/jsonbench-root-all-tests.log`. A full replay against the preserved pre-change executable also failed; every
non-jsoncons failure label in the current matrix was present in that baseline.
These counts describe contract checks, not 629 independent bugs. Other engines were retained and their assertions remain
strict; their defects were not patched as part of this jsoncons migration.

## Memory-safety and API evidence

A standalone C API harness compiled with `-Wall -Wextra -Werror` passed **220
checks**. It also passed all 220 under AddressSanitizer + UndefinedBehaviorSanitizer
and leak detection, with no diagnostics:

```sh
g++ -std=c++17 -Wall -Wextra -Werror -g -O1 \
-fno-omit-frame-pointer -fsanitize=address,undefined $CPPFLAGS \
tests/jsoncons_api_tests.cpp src/jsonconsparser.cpp -o /tmp/jsoncons-api-tests
ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 \
UBSAN_OPTIONS=halt_on_error=1 /tmp/jsoncons-api-tests
```

The complete CLI matrix was additionally run against a separately built
JSONCONS-only ASan/UBSan executable: **226 passed, 0 failed, 40 skipped**, exit 0,
no sanitizer diagnostics. Those 40 skips are YAJL comparisons unavailable in
that isolated build, rather than untested jsoncons functionality. The original
all-engine executable was not replaced for this check. Sanitizer build flags:
`-g -O1 -fno-omit-frame-pointer -fno-pie -fsanitize=address,undefined` and linking
with `-no-pie -fsanitize=address,undefined`.

Independent source review found no remaining material lifetime, allocation,
conversion or buffer issue in the changed implementation. A separate 34-case
Unicode probe verified valid/invalid pairs at 15 offsets around the 16 KiB
buffer boundary, escaped backslashes/quotes, U+10000 and U+10FFFF. Resource
ceilings, partial output on late failures and version-specific surrogate
validation remain explicit integration considerations.

## Performance measurement

```sh
python3 tests/benchmark_jsoncons.py \
--baseline /workspace/.jsonbench-tools/jsonbench-dom-baseline \
--binary src/jsonbench --output /tmp/jsonbench-final-benchmark.json
```

One warmup then five fresh-process samples per measured variant; table timing is
CLI parser mean ± sample standard deviation, in milliseconds. Memory is mean
child-process peak RSS in KiB, measured with a compiled Linux `wait4` helper.
Input: root array of identical short three-field objects, silent output, argument
limit set exactly to the generated leaf count. Effective production optimization
was O2. Binary/config/input hashes and raw samples are in the JSON report; hashes
were checked unchanged during the measurement.

| Actual input bytes | Variant | Parser ms, mean ± stddev | Mean peak RSS, KiB |
|---:|---|---:|---:|
| 409,564 | jsoncons_dom | 4.477 ± 0.648 | 5330 |
| 409,564 | jsoncons_sax | 2.932 ± 0.079 | 2671 |
| 409,564 | yajl | 1.498 ± 0.208 | 2734 |
| 5,242,854 | yajl | Unrun: configured file limit | — |
| 5,242,854 | jsoncons_dom | 69.876 ± 8.417 | 38976 |
| 5,242,854 | jsoncons_sax | 41.717 ± 5.489 | 2673 |
| 33,554,368 | yajl | Unrun: configured file limit | — |
| 33,554,368 | jsoncons_dom | 363.273 ± 21.097 | 228209 |
| 33,554,368 | jsoncons_sax | 240.755 ± 22.905 | 2649 |

On this synthetic cached-file workload SAX used substantially less peak memory
than the previous DOM implementation and lower parser time. At 32 MiB the
measured values were approximately 363 ms/223 MiB for DOM and 241 ms/2.6 MiB for
SAX. YAJL was faster on the common smaller fixture; its configured 512 KiB file
buffer prevented larger comparisons. The old DOM adapter merely parsed/discarded
its tree, while SAX also performs argument-path/count/size checks and the Unicode
escape guard. No general ModSecurity throughput or concurrency claim follows.

## Changed and added files

Modified: `src/jsonconsparser.cpp`, `src/jsonconsparser.h`, `src/jsonbench.c`,
`tests/run_tests.sh`, `README.md`.

Added: `tests/regression_tests.py`, `tests/jsoncons_api_tests.cpp`,
`tests/benchmark_jsoncons.py`, `tests/benchmark_rusage.c`, `tests/README.md`,
`docs/jsoncons-sax.md`, `docs/validation-results.md`.

Generated local Autotools artifacts (`build-aux/`, `config.h`, configure backups,
and ignored object/build files) are not source changes and should not be committed.
See [the SAX notes](jsoncons-sax.md) for the six explicit YAJL differences,
optional BOM behavior, remaining resource/API limits and ModSecurity integration
recommendations. This remains a file-parser evaluation, not an implemented
ModSecurity chunked-body or schema-validation integration.
Loading