From dc8debeb2e0f0b1740ea54dcee8eb9b14c9cdfc1 Mon Sep 17 00:00:00 2001 From: Yuansheng Wang Date: Mon, 1 Jun 2026 17:16:44 +0000 Subject: [PATCH] test: extend max-depth stress coverage (#140) Add deterministic boundary tests that pin the named max-depth limits the fuzz_depth target only reaches probabilistically: - tests/ffi_depth_stress.rs: default max_depth=0 -> 1024 resolution at the FFI/parse level (accept 1024, reject 1025), the 4096 ceiling (accept 4096, reject 4097), clamp of over-ceiling requests (5000 / u32::MAX), object-only and mixed nesting boundaries (deterministic coverage was array-only elsewhere), a pathological 100k-deep input that must reject fast without recursing, and cursor reachability of an at-limit document. All cases run in both EAGER and LAZY modes. - tests/lua/options_spec.lua: propagate default-depth and clamped-ceiling nesting errors through the Lua wrapper without duplicating the existing accept/message/lazy/fractional cases. The deep stress case is a lightweight deterministic test in the standard CI gates; fuzz_depth continues to provide randomized exploration. EAGER/ LAZY depth equivalence is already fixed by #142 and is not re-covered. --- tests/ffi_depth_stress.rs | 256 +++++++++++++++++++++++++++++++++++++ tests/lua/options_spec.lua | 41 ++++++ 2 files changed, 297 insertions(+) create mode 100644 tests/ffi_depth_stress.rs diff --git a/tests/ffi_depth_stress.rs b/tests/ffi_depth_stress.rs new file mode 100644 index 0000000..aa8ee06 --- /dev/null +++ b/tests/ffi_depth_stress.rs @@ -0,0 +1,256 @@ +//! Deterministic max-depth boundary stress tests (issue #140). +//! +//! `fuzz_depth` already explores randomized depth/shape combinations and the +//! EAGER/LAZY relation is fixed by `ffi_mode_relations`. These tests pin the +//! *named* boundaries that the fuzzer reaches only probabilistically: +//! +//! - the default `max_depth = 0 -> 1024` resolution at the FFI/parse level, +//! - the `4096` ceiling and the clamp of over-ceiling requests, +//! - object-only and mixed nesting (deterministic coverage is array-only +//! elsewhere), +//! - a pathologically deep input rejecting fast without recursing, and +//! - cursor reachability of an accepted at-limit document. + +use qjson::error::qjson_err; +use qjson::ffi::{ + qjson_cursor, qjson_cursor_bytes, qjson_cursor_index, qjson_cursor_len, qjson_doc, qjson_error, + qjson_free, qjson_open, qjson_parse_ex, +}; +use qjson::options::{ + Options, QJSON_DEFAULT_MAX_DEPTH, QJSON_MAX_MAX_DEPTH, QJSON_MODE_EAGER, QJSON_MODE_LAZY, +}; +use std::mem::MaybeUninit; +use std::os::raw::{c_char, c_int}; + +const MODES: [u32; 2] = [QJSON_MODE_EAGER, QJSON_MODE_LAZY]; + +fn opts(mode: u32, max_depth: u32) -> Options { + Options { mode, max_depth } +} + +struct DocGuard(*mut qjson_doc); + +impl Drop for DocGuard { + fn drop(&mut self) { + unsafe { qjson_free(self.0) } + } +} + +fn parse_ok(buf: &[u8], o: &Options) -> DocGuard { + let mut err = qjson_error::default(); + let doc = unsafe { qjson_parse_ex(buf.as_ptr(), buf.len(), o, &mut err) }; + assert!( + !doc.is_null(), + "parse_ex unexpectedly failed (mode={} max_depth={}): {:?}", + o.mode, + o.max_depth, + err, + ); + assert_eq!(err.code, qjson_err::QJSON_OK as c_int); + DocGuard(doc) +} + +fn parse_err(buf: &[u8], o: &Options) -> qjson_error { + let mut err = qjson_error::default(); + let doc = unsafe { qjson_parse_ex(buf.as_ptr(), buf.len(), o, &mut err) }; + assert!( + doc.is_null(), + "parse_ex unexpectedly succeeded (mode={} max_depth={})", + o.mode, + o.max_depth, + ); + err +} + +// ── nesting skeletons ─────────────────────────────────────────────── + +/// `depth` nested arrays around a scalar `0`. +fn nested_arrays(depth: usize) -> Vec { + let mut buf = vec![b'['; depth]; + buf.push(b'0'); + buf.extend(std::iter::repeat_n(b']', depth)); + buf +} + +/// `depth` nested single-key objects around a scalar `0`. Each level is the +/// 5-byte `{"k":` prefix, so the opening brace of level `n` (1-based) sits at +/// byte `(n - 1) * 5`. +fn nested_objects(depth: usize) -> Vec { + let mut buf = Vec::with_capacity(depth * 6 + 2); + for _ in 0..depth { + buf.extend_from_slice(br#"{"k":"#); + } + buf.push(b'0'); + buf.extend(std::iter::repeat_n(b'}', depth)); + buf +} + +/// Alternating array / object nesting (array at even levels, object at odd). +fn nested_mixed(depth: usize) -> Vec { + let mut buf = Vec::with_capacity(depth * 6 + 2); + let mut closers = Vec::with_capacity(depth); + for level in 0..depth { + if level % 2 == 0 { + buf.push(b'['); + closers.push(b']'); + } else { + buf.extend_from_slice(br#"{"k":"#); + closers.push(b'}'); + } + } + buf.push(b'0'); + closers.reverse(); + buf.extend_from_slice(&closers); + buf +} + +// ── default depth (max_depth = 0 -> 1024) ─────────────────────────── + +#[test] +fn default_depth_accepts_1024_rejects_1025_both_modes() { + let at_limit = nested_arrays(QJSON_DEFAULT_MAX_DEPTH as usize); + let one_past = nested_arrays(QJSON_DEFAULT_MAX_DEPTH as usize + 1); + + for mode in MODES { + // max_depth = 0 must resolve to the documented 1024 default. + let _doc = parse_ok(&at_limit, &opts(mode, 0)); + + let err = parse_err(&one_past, &opts(mode, 0)); + assert_eq!(err.code, qjson_err::QJSON_NESTING_TOO_DEEP as c_int); + // Offset points at the opening bracket that first exceeds the limit: + // the (1025th) '[' lives at byte index 1024. + assert_eq!(err.offset, QJSON_DEFAULT_MAX_DEPTH as usize); + } +} + +// ── 4096 ceiling and clamp ────────────────────────────────────────── + +#[test] +fn ceiling_accepts_4096_rejects_4097_both_modes() { + let at_ceiling = nested_arrays(QJSON_MAX_MAX_DEPTH as usize); + let one_past = nested_arrays(QJSON_MAX_MAX_DEPTH as usize + 1); + + for mode in MODES { + let _doc = parse_ok(&at_ceiling, &opts(mode, QJSON_MAX_MAX_DEPTH)); + + let err = parse_err(&one_past, &opts(mode, QJSON_MAX_MAX_DEPTH)); + assert_eq!(err.code, qjson_err::QJSON_NESTING_TOO_DEEP as c_int); + assert_eq!(err.offset, QJSON_MAX_MAX_DEPTH as usize); + } +} + +#[test] +fn request_above_ceiling_clamps_to_4096_not_honored() { + let at_ceiling = nested_arrays(QJSON_MAX_MAX_DEPTH as usize); + let one_past = nested_arrays(QJSON_MAX_MAX_DEPTH as usize + 1); + + // Both an explicit over-ceiling request and u32::MAX must behave exactly + // like max_depth = 4096 (clamp, not honor the larger request). + for requested in [QJSON_MAX_MAX_DEPTH + 1, 5000, u32::MAX] { + for mode in MODES { + let _doc = parse_ok(&at_ceiling, &opts(mode, requested)); + + let err = parse_err(&one_past, &opts(mode, requested)); + assert_eq!( + err.code, + qjson_err::QJSON_NESTING_TOO_DEEP as c_int, + "requested={requested} should clamp to {QJSON_MAX_MAX_DEPTH}", + ); + assert_eq!(err.offset, QJSON_MAX_MAX_DEPTH as usize); + } + } +} + +// ── object-only / mixed nesting ───────────────────────────────────── + +#[test] +fn object_only_depth_boundary_both_modes() { + let limit = 8u32; + let at_limit = nested_objects(limit as usize); + let one_past = nested_objects(limit as usize + 1); + + for mode in MODES { + let _doc = parse_ok(&at_limit, &opts(mode, limit)); + + let err = parse_err(&one_past, &opts(mode, limit)); + assert_eq!(err.code, qjson_err::QJSON_NESTING_TOO_DEEP as c_int); + // The (limit+1)-th '{' opens at byte limit * 5 (5-byte `{"k":` levels). + assert_eq!(err.offset, limit as usize * 5); + } +} + +#[test] +fn mixed_nesting_depth_boundary_both_modes() { + let limit = 9u32; // odd -> ends on an object level, exercising both kinds + let at_limit = nested_mixed(limit as usize); + let one_past = nested_mixed(limit as usize + 1); + + for mode in MODES { + let _doc = parse_ok(&at_limit, &opts(mode, limit)); + + let err = parse_err(&one_past, &opts(mode, limit)); + assert_eq!(err.code, qjson_err::QJSON_NESTING_TOO_DEEP as c_int); + } +} + +// ── pathological depth: fast reject, no recursion ─────────────────── + +#[test] +fn pathological_depth_rejects_without_stack_overflow() { + // Far beyond the ceiling: an honest recursive-descent parser would blow + // the stack here. qjson walks `indices` iteratively, so this must return + // a clean NESTING_TOO_DEEP at the default limit's boundary. + let deep = nested_arrays(100_000); + for mode in MODES { + let err = parse_err(&deep, &opts(mode, 0)); + assert_eq!(err.code, qjson_err::QJSON_NESTING_TOO_DEEP as c_int); + assert_eq!(err.offset, QJSON_DEFAULT_MAX_DEPTH as usize); + } +} + +// ── cursor reachability of an at-limit accepted document ──────────── + +#[test] +fn at_limit_document_is_reachable_via_cursor() { + let depth = QJSON_DEFAULT_MAX_DEPTH as usize; + let json = nested_arrays(depth); + + for mode in MODES { + let doc = parse_ok(&json, &opts(mode, 0)); + unsafe { + let mut cur = MaybeUninit::::uninit(); + assert_eq!( + qjson_open(doc.0, std::ptr::null::(), 0, cur.as_mut_ptr()), + qjson_err::QJSON_OK as c_int, + ); + let mut cur = cur.assume_init(); + + // Descend index 0 at every level down to the innermost scalar. + for _ in 0..depth { + let mut len = usize::MAX; + assert_eq!( + qjson_cursor_len(&cur, std::ptr::null::(), 0, &mut len), + qjson_err::QJSON_OK as c_int, + ); + assert_eq!(len, 1); + + let mut next = MaybeUninit::::uninit(); + assert_eq!( + qjson_cursor_index(&cur, 0, next.as_mut_ptr()), + qjson_err::QJSON_OK as c_int, + ); + cur = next.assume_init(); + } + + // The final cursor is the scalar `0`; its byte span must be valid. + let mut start = usize::MAX; + let mut end = usize::MAX; + assert_eq!( + qjson_cursor_bytes(&cur, &mut start, &mut end), + qjson_err::QJSON_OK as c_int, + ); + assert!(start < end && end <= json.len()); + assert_eq!(&json[start..end], b"0"); + } + } +} diff --git a/tests/lua/options_spec.lua b/tests/lua/options_spec.lua index b99d033..9ef7e51 100644 --- a/tests/lua/options_spec.lua +++ b/tests/lua/options_spec.lua @@ -42,4 +42,45 @@ describe("parse with options", function() qjson.parse('{}', { max_depth = 1.5 }) end) end) + + -- Depth boundaries surfaced through the wrapper (issue #140). Deterministic + -- Rust coverage lives in tests/ffi_depth_stress.rs; here we only confirm the + -- Lua layer propagates accept/reject at the default and ceiling limits. + local function nested(depth) + return string.rep("[", depth) .. "1" .. string.rep("]", depth) + end + + it("accepts exactly the default depth of 1024", function() + assert.is_not_nil(qjson.parse(nested(1024))) + end) + + it("propagates the default-depth nesting error one past 1024", function() + local ok, err = pcall(qjson.parse, nested(1025)) + assert.is_false(ok) + assert.is_truthy( + string.find(tostring(err), "nesting too deep at byte 1024 (max 1024)", 1, true), + tostring(err) + ) + end) + + it("propagates the default-depth nesting error in lazy mode too", function() + local ok, err = pcall(qjson.parse, nested(1025), { lazy = true }) + assert.is_false(ok) + assert.is_truthy( + string.find(tostring(err), "nesting too deep at byte 1024 (max 1024)", 1, true), + tostring(err) + ) + end) + + it("clamps an over-ceiling max_depth to 4096", function() + -- A request above the 4096 ceiling behaves exactly like max_depth=4096: + -- 4096 levels parse, 4097 fail at the clamped limit. + assert.is_not_nil(qjson.parse(nested(4096), { max_depth = 9000 })) + local ok, err = pcall(qjson.parse, nested(4097), { max_depth = 9000 }) + assert.is_false(ok) + assert.is_truthy( + string.find(tostring(err), "nesting too deep at byte 4096 (max 4096)", 1, true), + tostring(err) + ) + end) end)