Thanks for helping improve qjson. The project is a Rust JSON decoder exposed to LuaJIT through FFI, so changes often need both Rust and Lua verification.
Clone submodules before running the full test suite. The tests use vendored JSON fixture corpora, and the benchmark target can build the vendored lua-cjson module.
git submodule update --init --recursive
make buildLua integration tests require LuaJIT, busted, and lua-cjson. You can either
install busted/lua-cjson with LuaRocks for Lua 5.1/LuaJIT, or build the vendored
lua-cjson module when you only need a local cjson.so:
make vendor/lua-cjson/cjson.soIf multiple Lua versions are installed, make sure the busted executable comes
from the LuaJIT or Lua 5.1 LuaRocks tree, not from a different Lua version.
The canonical PR gate is:
make testThat runs cargo build --release, cargo test --release, and the Lua busted
suite with the release cdylib on the dynamic loader path.
For narrower checks, use:
cargo test --release
cargo test --release --no-default-features
cargo test --features test-panic --releaseRun a single Rust integration test with:
cargo test --release --test ffi_smoke parse_and_free_roundtripRun the Lua suite directly with:
cargo build --release
LD_LIBRARY_PATH=./target/release \
busted --lua="$(command -v luajit)" tests/lua --lpath='./lua/?.lua'On macOS, the release cdylib is named libqjson.dylib; if the direct Lua test
command cannot find qjson, add the dylib template to LUA_CPATH:
DYLD_LIBRARY_PATH=./target/release \
LUA_CPATH='./vendor/lua-cjson/?.so;./target/release/lib?.dylib;./target/release/lib?.so;./?.so;;' \
busted --lua="$(command -v luajit)" tests/lua --lpath='./lua/?.lua'Run clippy with warnings denied:
make lintcargo fmt --check is intentionally not part of the lint gate. Some Rust files
use manual column alignment in struct definitions and compact literals that
default rustfmt would reflow. Keep formatting consistent with nearby code.
Use concise conventional-style prefixes when they fit the change, for example:
docs: add cjson migration guide
test: cover lazy string validation
fix: preserve cursor byte spans
Release commits are stricter because .github/workflows/release.yml validates
the title. A release commit must be:
feat: release vX.Y.Z
or, for prereleases:
feat: release vX.Y.Z-prerelease
Use GitHub Issues for actionable bugs, regressions, feature requests, missing documentation, and follow-up work with concrete acceptance criteria. Use GitHub Discussions for open-ended API design or migration questions when Discussions are enabled; otherwise open an issue and make the exploratory status clear in the description.
The public error and type codes are duplicated for Rust, C, and Lua consumers. When adding, removing, or renumbering any code, keep these files in sync in the same change:
src/error.rsinclude/qjson.hlua/qjson.lua
Add or update tests that prove the new value can cross the FFI boundary and is
visible from Lua when applicable. Renumbering existing codes is a breaking
change and must be called out in CHANGELOG.md.
Any pull request that changes public behavior must add an entry under the
[Unreleased] section in CHANGELOG.md. Public behavior includes changes to
the Lua API, FFI surface, documented semantics, validation behavior, error/type
codes, release packaging, or any user-visible compatibility/performance
contract.
Pure internal refactors do not need a changelog entry. If a pull request touches
src/, lua/, or include/ without changing public behavior, call that out in
the PR description so reviewers can intentionally skip the changelog update.
qjson parses arbitrary bytes at an API gateway trust boundary. The Rust decoder has a cargo-fuzz harness for parser regression checks.
Install the local fuzzing tools:
rustup toolchain install nightly --profile minimal
cargo install cargo-fuzzRun the PR-length regression guard:
cargo +nightly fuzz run fuzz_parse_eager -- -max_total_time=60
cargo +nightly fuzz run fuzz_depth -- -max_total_time=60
cargo +nightly fuzz run fuzz_ffi_ops -- -max_total_time=60
cargo +nightly fuzz run fuzz_parse_lazy -- -max_total_time=60The fuzz_parse_eager target compares qjson EAGER parse accept/reject behavior
against serde_json::Value. It skips inputs deeper than 64 containers because
deep nesting has its own target. It also documents two serde_json::Value
rejections that qjson accepts at parse time: numbers outside serde's numeric
range and escaped unpaired UTF-16 surrogates. The latter is guarded by an
input-level surrogate check because serde may report a leading surrogate as an
unexpected end of hex escape; qjson rejects those strings later if decoded.
The fuzz_depth target is non-differential. It pins qjson's nesting contract:
depth N is accepted and N+1 returns QJSON_NESTING_TOO_DEEP at both the
default depth (1024) and the clamped ceiling (4096). Accepted boundary
inputs are also walked through the FFI cursor API to exercise Phase 2 without
recursive descent.
The fuzz_ffi_ops target drives the public FFI surface with arbitrary
parse/get/cursor/free operation sequences. It focuses on panic-barrier and
pointer-safety regressions around null docs/cursors, path/key bytes, repeated
parses/frees, and mixed root/cursor accessors.
The fuzz_parse_lazy target is the Phase 2 semantic replay target. It compares
serde-accepted inputs by reconstructing a whole serde_json::Value through
qjson's public cursor FFI APIs, including ordered object_entry_at replay,
varied-order cursor_field / cursor_index lookups, and root getter vs cursor
getter consistency for path-safe unique-key paths. It normalizes numbers through
qjson's f64 getter semantics, with serde_json's float_roundtrip parser
enabled for bit-exact f64 oracle comparisons. Duplicate keys and path-like
keys are covered by ordered entry replay; they are not used for path getter
consistency because qjson path syntax cannot express those object members
unambiguously. Repeated varied-order sibling lookups exercise both cold and warm
skip-cache paths.
The committed corpus under fuzz/corpus/fuzz_parse_eager/ is seeded from
JSONTestSuite y_*/n_*, cJSON fuzzing inputs, and benchmark fixtures. Crash
artifacts and coverage output are ignored; minimize and promote only useful
regression cases.
Before releases, run the same target much longer than the CI guard, for example:
cargo +nightly fuzz run fuzz_parse_eager -- -max_total_time=3600
cargo +nightly fuzz run fuzz_depth -- -max_total_time=3600
cargo +nightly fuzz run fuzz_ffi_ops -- -max_total_time=3600
cargo +nightly fuzz run fuzz_parse_lazy -- -max_total_time=3600CI intentionally runs only a short 60-second fuzzing pass so pull requests get a quick regression signal without pretending to be a deep fuzz campaign.
qjson.encode and qjson.materialize live in Lua (lua/qjson/table.lua), so
they are outside cargo-fuzz's Rust decoder targets. Lua-side round-trip coverage
uses deterministic busted property tests instead of luzer for now: busted is
already installed in CI and gives a portable PR regression guard without adding
a LuaJIT/libFuzzer binding dependency. luzer can still be revisited later for
long-running coverage-guided Lua fuzzing.
Run the default PR-length guard:
make lua-property-testIncrease the generated case count or pin a different deterministic seed when investigating locally:
make lua-property-test QJSON_PROP_CASES=1000 QJSON_PROP_SEED=12345The property suite generates valid JSON containers, runs
decode -> materialize -> encode -> decode -> materialize, checks structural
equality, and probes the encoder max-depth boundary around 1000 nested
containers.
Lazy mutation path coverage is in tests/lua/lazy_mutation_property_spec.lua
and uses deterministic defaults in the Makefile so PR runs are reproducible:
make lua-mutation-property-testStress this focused suite locally by overriding case/step count and seed:
make lua-mutation-property-test \
QJSON_MUT_PROP_CASES=4000 \
QJSON_MUT_PROP_STEPS=200 \
QJSON_MUT_PROP_SEED=12345