11 Commits
Author SHA1 Message Date
weaselbot ac619af24d parser3: reserve stack slots for chunk-split literals and escapes
WeaselJson_OVERFLOW is documented as "json is too deeply nested", and
stackSize as a nesting-depth budget. In practice the pushdown stack also
held tokenizer progress for a `true`/`false`/`null` literal or a \uXXXX
escape that straddled the end of a chunk: the whole-buffer path consumes
those characters inline and pushes nothing, while the slow path pushed up
to six symbols (a surrogate pair) onto the same stack. So the stackSize a
document needed depended on where the parse() boundaries fell: e.g. "true"
was accepted at stackSize 3 when fed whole but returned the sticky
WeaselJson_OVERFLOW when fed one byte at a time.

Reserve slots for those symbols in addition to the supplied stackSize,
as a fixed upper bound rather than by changing the encoding: the largest
single push of transient symbols is t_hex2()'s six symbols for a surrogate
pair, and no transient symbol is ever pushed while another one is on the
stack, so kTransientSymbols = 6 is enough. Nesting pushes are still bounded
by stackSize, so OVERFLOW now means exactly what the header says and the
accept/reject/overflow decision is a function of the input bytes and
stackSize only.

Document the reserve in weaseljson.h and cover the property with tests:
a doctest sweeping stackSize 3..16 over documents whose literals, escapes
and surrogate pairs land on every chunk boundary, and a fuzz harness that
now sweeps small stack sizes and requires the status of every chunking
(stride 1..15 plus every single split point) to match the whole-buffer
status exactly.
2026-10-27 16:20:00 -04:00
andrew 730a4cd0f6 Merge pull request 'schemagen: reject required entries with no matching properties key' (#65) from weaselbot/weaseljson:weaselbot/issue-63 into main
Reviewed-on: weaselab/weaseljson#65
2026-08-31 17:44:05 +00:00
weaselbot fd34603ac0 schemagen: reject required keys with no properties entry
A `required` entry without a matching `properties` key made the schema
unsatisfiable: additionalProperties: false (the only supported object
mode) rejects any key not listed in `properties`, so the required key
can never be present. Since no seen bit is emitted for such keys, the
generated parser silently accepted every document instead of rejecting
them as the README contract requires ("missing required property").

Follow the pattern from issue #55 and raise GenError at generation time
when set(required) is not a subset of properties.keys(), for root,
nested, and $defs object schemas alike. Update the README's
not-supported list accordingly.

Closes #63
2026-08-30 21:51:29 -04:00
andrew c44b0262bc Merge pull request 'schemagen: reject absent additionalProperties' (#58) from weaselbot/weaseljson:weaselbot/issue-55 into main
Reviewed-on: weaselab/weaseljson#58
2026-07-20 18:06:53 +00:00
andrew 814b24efba Merge pull request 'Fix schemagen integer parser rejecting zero with negative exponents' (#57) from weaselbot/weaseljson:weaselbot/issue-56 into main
Reviewed-on: weaselab/weaseljson#57
2026-07-20 18:00:36 +00:00
weaselbot 86b58e83cb schemagen: reject absent additionalProperties
Per the README contract, an object schema without an explicit
`additionalProperties` was documented as rejected at generation time,
but the code defaulted it to `false` and silently generated a strict
parser that rejects valid documents with extra properties.

Make the code match the documented contract by raising GenError when
`additionalProperties` is absent, with a clear message instructing the
author to set it explicitly to false. Update the README prose section
to list absent additionalProperties alongside true, and fix existing
schemas and tests to declare additionalProperties explicitly.

Closes #55
2026-07-19 22:06:23 -04:00
weaselbot cdff634057 Fix schemagen integer parser rejecting zero with negative exponents
Move the zero-detection check in the generated parseJsonInt64 before the
finalExp < 0 guard. Previously, valid JSON numbers whose mathematical
value is 0 but written with a large negative exponent (e.g. 0e-2,
0.0e-2, -0e-2, 0e-20) were rejected because the negative-finalExp early
return ran before the all-zero-digits branch could set out = 0. Non-zero
values with negative exponents are still correctly rejected.

Closes #56
2026-07-19 22:00:15 -04:00
andrew 16f5d3cd9d Merge pull request 'Handle null parser in WeaselJsonParser_parse' (#54) from null-parser into main
Reviewed-on: weaselab/weaseljson#54
2026-07-15 22:03:59 +00:00
andrew 6beb538b61 Add missing status enum to weaseljson.py 2026-07-15 17:55:22 -04:00
andrew 6520039dc2 Address review feedback 2026-07-15 14:12:41 -04:00
andrew 93203b14f5 Add missing unlikely annotation 2026-07-15 12:31:17 -04:00
11 changed files with 358 additions and 84 deletions

No files matched your search

+4 -2
View File
@@ -60,8 +60,10 @@ into the result, so it is non-movable.
## Not supported (rejected at generation time, no fallback)
`oneOf` / `anyOf` / `allOf` / `not` / `if`-`then`-`else`,
`patternProperties`, `additionalProperties` with a schema (typed map),
`additionalProperties: true`, `prefixItems` (tuples), `const`,
`patternProperties`, `additionalProperties` absent or set to `true`,
`additionalProperties` with a schema (typed map), `prefixItems` (tuples),
`const`, `required` entries with no matching `properties` key (the schema
would be unsatisfiable),
`dependentSchemas`/`dependentRequired`, union `type` lists other than
`["T", "null"]`, non-string enums, and remote (`$ref` to other documents).
+1
View File
@@ -56,6 +56,7 @@
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"name"
],
+142 -12
View File
@@ -32,6 +32,7 @@ class SchemagenEnumTest(unittest.TestCase):
def test_colliding_enum_values_deduplicate(self):
schema = {
"type": "object",
"additionalProperties": False,
"properties": {"role": {"enum": ["foo-bar", "foo_bar"]}},
}
rc, stdout, stderr = self.run_schemagen(schema)
@@ -45,6 +46,7 @@ class SchemagenEnumTest(unittest.TestCase):
def test_distinct_enum_values_generate(self):
schema = {
"type": "object",
"additionalProperties": False,
"properties": {"role": {"enum": ["admin", "user", "guest"]}},
}
rc, stdout, stderr = self.run_schemagen(schema)
@@ -80,7 +82,7 @@ class SchemagenKeywordTest(unittest.TestCase):
"module",
"import",
]
schema = {"type": "object", "properties": {}}
schema = {"type": "object", "additionalProperties": False, "properties": {}}
for kw in keywords:
schema["properties"][kw] = {"type": "string"}
rc, stdout, stderr = self.run_schemagen(schema)
@@ -164,11 +166,18 @@ class SchemagenCollisionTest(unittest.TestCase):
def test_kind_enum_does_not_duplicate_arr0(self):
schema = {
"type": "object",
"additionalProperties": False,
"properties": {
"arr": {"type": "array", "items": {"type": "string"}},
"obj": {"$ref": "#/$defs/Arr0"},
},
"$defs": {"Arr0": {"type": "object", "properties": {}}},
"$defs": {
"Arr0": {
"type": "object",
"additionalProperties": False,
"properties": {},
}
},
}
out = self.generate_and_compile(schema)
self.assertIn("struct Arr0", out)
@@ -183,7 +192,13 @@ class SchemagenCollisionTest(unittest.TestCase):
schema = {
"type": "array",
"items": {"$ref": "#/$defs/Skip"},
"$defs": {"Skip": {"type": "object", "properties": {}}},
"$defs": {
"Skip": {
"type": "object",
"additionalProperties": False,
"properties": {},
}
},
}
out = self.generate_and_compile(schema)
self.assertIn("struct Skip", out)
@@ -199,6 +214,7 @@ class SchemagenCollisionTest(unittest.TestCase):
"""Regression test for issue #17: enum and array $defs must be reused."""
schema = {
"type": "object",
"additionalProperties": False,
"properties": {
"role1": {"$ref": "#/$defs/Role"},
"role2": {"$ref": "#/$defs/Role"},
@@ -252,17 +268,14 @@ class SchemagenAdditionalPropertiesTest(unittest.TestCase):
self.assertNotEqual(rc, 0)
self.assertIn("additionalProperties: true is not supported", stderr)
def test_additional_properties_absent_defaults_to_strict(self):
def test_additional_properties_absent_rejected(self):
schema = {
"type": "object",
"properties": {"name": {"type": "string"}},
}
rc, stdout, stderr = self.run_schemagen(schema)
self.assertEqual(rc, 0, msg=stderr)
# The generated parser should reject unknown keys. Verify the key-matching
# helper returns -1 for an unknown key and cbKeyData rejects it.
self.assertIn("int matchKey(Kind k, std::string_view key) const {", stdout)
self.assertNotIn("bool isStrict(Kind k) const", stdout)
self.assertNotEqual(rc, 0)
self.assertIn("additionalProperties is required for object schemas", stderr)
def test_additional_properties_false_accepted(self):
schema = {
@@ -274,6 +287,94 @@ class SchemagenAdditionalPropertiesTest(unittest.TestCase):
self.assertEqual(rc, 0, msg=stderr)
class SchemagenRequiredPropertyTest(unittest.TestCase):
"""Regression tests for issue #63: `required` entries with no matching
`properties` key must be rejected at generation time. With the mandatory
`additionalProperties: false` the schema is unsatisfiable, and the
generated parser has no seen bit for such keys, so it would otherwise
silently accept documents missing the required key."""
def run_schemagen(self, schema, args=None):
"""Run schemagen on a schema dict. Returns (returncode, stdout, stderr)."""
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fp:
json.dump(schema, fp)
schema_path = fp.name
try:
cmd = [sys.executable, SCRIPT, schema_path]
if args:
cmd.extend(args)
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
return result.returncode, result.stdout, result.stderr
finally:
os.unlink(schema_path)
def test_required_key_without_properties_entry_rejected(self):
schema = {
"type": "object",
"additionalProperties": False,
"properties": {"a": {"type": "integer"}},
"required": ["b"],
}
rc, stdout, stderr = self.run_schemagen(schema)
self.assertNotEqual(rc, 0)
self.assertIn("required key 'b' has no matching properties entry", stderr)
def test_multiple_dangling_required_keys_reported(self):
schema = {
"type": "object",
"additionalProperties": False,
"properties": {"a": {"type": "integer"}},
"required": ["a", "x", "y"],
}
rc, stdout, stderr = self.run_schemagen(schema)
self.assertNotEqual(rc, 0)
self.assertIn(
"required keys 'x', 'y' have no matching properties entries",
stderr,
)
def test_required_without_properties_rejected(self):
schema = {
"type": "object",
"additionalProperties": False,
"required": ["any"],
}
rc, stdout, stderr = self.run_schemagen(schema)
self.assertNotEqual(rc, 0)
self.assertIn("required key 'any' has no matching properties entry", stderr)
def test_nested_required_key_without_properties_entry_rejected(self):
schema = {
"type": "object",
"additionalProperties": False,
"properties": {"child": {"$ref": "#/$defs/Child"}},
"$defs": {
"Child": {
"type": "object",
"additionalProperties": False,
"properties": {"x": {"type": "string"}},
"required": ["misspelled"],
}
},
}
rc, stdout, stderr = self.run_schemagen(schema)
self.assertNotEqual(rc, 0)
self.assertIn(
"required key 'misspelled' has no matching properties entry",
stderr,
)
def test_required_key_with_properties_entry_accepted(self):
schema = {
"type": "object",
"additionalProperties": False,
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
"required": ["b"],
}
rc, stdout, stderr = self.run_schemagen(schema)
self.assertEqual(rc, 0, msg=stderr)
class SchemagenCyclicArrayTest(unittest.TestCase):
"""Regression tests for issue #33: cyclic array $ref targets."""
@@ -310,6 +411,7 @@ class SchemagenCyclicArrayTest(unittest.TestCase):
def test_object_field_to_self_referential_array_rejected(self):
schema = {
"type": "object",
"additionalProperties": False,
"properties": {"items": {"$ref": "#/$defs/Items"}},
"$defs": {
"Items": {
@@ -326,6 +428,7 @@ class SchemagenCyclicArrayTest(unittest.TestCase):
def test_chain_of_array_refs_rejected(self):
schema = {
"type": "object",
"additionalProperties": False,
"properties": {"x": {"$ref": "#/$defs/A"}},
"$defs": {
"A": {"type": "array", "items": {"$ref": "#/$defs/B"}},
@@ -339,6 +442,7 @@ class SchemagenCyclicArrayTest(unittest.TestCase):
def test_non_recursive_array_refs_still_allowed(self):
schema = {
"type": "object",
"additionalProperties": False,
"properties": {"roles": {"$ref": "#/$defs/Roles"}},
"$defs": {
"Role": {"enum": ["admin", "user"]},
@@ -439,10 +543,12 @@ class SchemagenCyclicNullableObjectTest(unittest.TestCase):
self.skipTest("C++ compiler not available")
schema = {
"type": "object",
"additionalProperties": False,
"properties": {"self": {"$ref": "#/$defs/Self"}},
"$defs": {
"Self": {
"type": ["object", "null"],
"additionalProperties": False,
"properties": {"self": {"$ref": "#/$defs/Self"}},
}
},
@@ -592,6 +698,13 @@ class SchemagenIntegerBoundaryTest(unittest.TestCase):
("123.0", "WeaselJson_OK", 123),
("9e18", "WeaselJson_OK", 9000000000000000000),
("10e18", "WeaselJson_REJECT", 0),
# Issue #56: zero written with a negative exponent must be accepted.
("0e-1", "WeaselJson_OK", 0),
("0e-2", "WeaselJson_OK", 0),
("0.0e-2", "WeaselJson_OK", 0),
("-0e-2", "WeaselJson_OK", 0),
("0e-20", "WeaselJson_OK", 0),
("0.000e-5", "WeaselJson_OK", 0),
]
cases_src = self._build_cases_array("root", cases)
harness = textwrap.dedent(
@@ -634,6 +747,9 @@ class SchemagenIntegerBoundaryTest(unittest.TestCase):
('{"age":2.0}', "WeaselJson_OK", 2),
('{"age":0.0001e4}', "WeaselJson_OK", 1),
('{"age":0.001}', "WeaselJson_REJECT", 0),
# Issue #56: zero written with a negative exponent must be accepted.
('{"age":0e-2}', "WeaselJson_OK", 0),
('{"age":-0e-20}', "WeaselJson_OK", 0),
(
'{"age":-9223372036854775808.0}',
"WeaselJson_OK",
@@ -670,6 +786,7 @@ class SchemagenIntegerBoundaryTest(unittest.TestCase):
)
schema = {
"type": "object",
"additionalProperties": False,
"properties": {"age": {"type": "integer"}},
"required": ["age"],
}
@@ -785,14 +902,22 @@ class SchemagenStringEscapeTest(unittest.TestCase):
def test_newline_in_property_key(self):
"""A JSON key containing a newline must become a valid C++ literal."""
schema = {"type": "object", "properties": {"a\nb": {"type": "string"}}}
schema = {
"type": "object",
"additionalProperties": False,
"properties": {"a\nb": {"type": "string"}},
}
out = self.generate_and_compile(schema)
self.assertIn('// "a\\nb"', out)
self.assertIn('if (key == "a\\nb") return 0;', out)
def test_newline_in_enum_value(self):
"""An enum value containing a newline must become a valid C++ literal."""
schema = {"type": "object", "properties": {"x": {"enum": ["a\nb"]}}}
schema = {
"type": "object",
"additionalProperties": False,
"properties": {"x": {"enum": ["a\nb"]}},
}
out = self.generate_and_compile(schema)
self.assertIn('static constexpr const char *X_names[] = { "a\\nb" };', out)
@@ -800,6 +925,7 @@ class SchemagenStringEscapeTest(unittest.TestCase):
"""Mixed control characters in an enum value must be escaped."""
schema = {
"type": "object",
"additionalProperties": False,
"properties": {
"x": {"enum": ["x\ny\rz\tw\vq\x00\x01"]},
},
@@ -812,7 +938,11 @@ class SchemagenStringEscapeTest(unittest.TestCase):
def test_backslash_and_quote_still_escaped(self):
"""Existing escaping for backslash and double quote must remain correct."""
schema = {"type": "object", "properties": {'a"b\\c': {"type": "string"}}}
schema = {
"type": "object",
"additionalProperties": False,
"properties": {'a"b\\c': {"type": "string"}},
}
out = self.generate_and_compile(schema)
self.assertIn('// "a\\"b\\\\c"', out)
self.assertIn('if (key == "a\\"b\\\\c") return 0;', out)
+24 -2
View File
@@ -417,7 +417,13 @@ class Builder:
tobj = TObj(name)
if defname is not None:
self._building[defname] = (tobj, nullable)
ap = node.get("additionalProperties", False)
ap = node.get("additionalProperties", None)
if ap is None:
raise GenError(
"additionalProperties is required for object schemas; set it "
"explicitly to false to reject unknown keys (absent "
"additionalProperties is not supported)"
)
if ap is True:
raise GenError("additionalProperties: true is not supported")
if isinstance(ap, dict):
@@ -426,6 +432,22 @@ class Builder:
)
required = set(node.get("required", []))
props = node.get("properties", {})
# A `required` entry with no matching `properties` key makes the schema
# unsatisfiable: `additionalProperties: false` (the only supported
# object mode) rejects any key not listed in `properties`, so the
# required key can never be present. No seen bit is emitted for such a
# key, so the generated parser would silently accept every document.
# Follow the other unsupported constructs and reject at generation time.
dangling = sorted(required - set(props))
if dangling:
quoted = ", ".join(f"'{k}'" for k in dangling)
if len(dangling) == 1:
raise GenError(
f"required key {quoted} has no matching properties entry"
)
raise GenError(
f"required keys {quoted} have no matching properties entries"
)
seen_cpp = set()
for key, sub in props.items():
ty, nullable = self._unpack(self.build_type(sub, key))
@@ -1058,10 +1080,10 @@ private:
++trim;
}}
int64_t finalExp = exp - fracDigits + trim;
if (finalExp < 0) return false;
size_t leadingZeros = 0;
while (leadingZeros < digits.size() && digits[leadingZeros] == '0') ++leadingZeros;
if (leadingZeros == digits.size()) {{ out = 0; return true; }}
if (finalExp < 0) return false;
if (leadingZeros > 0) digits.erase(0, leadingZeros);
constexpr uint64_t kMaxNeg = 9223372036854775808ULL;
+12 -6
View File
@@ -36,7 +36,7 @@ enum WeaselJsonStatus {
WeaselJson_AGAIN,
/** Invalid json */
WeaselJson_REJECT,
/** json is too deeply nested */
/** json is too deeply nested for the parser's stack size */
WeaselJson_OVERFLOW,
/** Tried to call parse on a null parser */
WeaselJson_NULL,
@@ -50,10 +50,16 @@ enum WeaselJsonFlags {
};
/** Create a parser. Increasing stack size increases memory usage but also
* increases the depth of nested json accepted. `callbacks` and `userdata` must
* outlive the returned parser. Returns null if there's insufficient available
* memory, or if `stackSize` is negative or too small to hold a minimal
* document. */
* increases the depth of nested json accepted. `stackSize` is an exact budget:
* whether a document is accepted, rejected or reported as too deeply nested
* depends only on its bytes and on `stackSize`, not on how the bytes are split
* across WeaselJsonParser_parse calls. To get that guarantee, the parser
* allocates 6 slots beyond `stackSize` for tokenizer progress: literals
* ("true", "false", "null") and \uXXXX escapes that straddle the end of a chunk
* record their unmatched characters there instead of spending the nesting
* budget. `callbacks` and `userdata` must outlive the returned parser. Returns
* null if there's insufficient available memory, or if `stackSize` is negative
* or too small to hold a minimal document. */
WeaselJsonParser *WeaselJsonParser_create(int stackSize,
const WeaselJsonCallbacks *callbacks,
void *userdata, int flags);
@@ -67,7 +73,7 @@ void WeaselJsonParser_destroy(WeaselJsonParser *parser);
/** Incrementally parse `len` more bytes starting at `buf`. `buf` may be
* modified. Call with `len` 0 to indicate end of data. `buf` may be null if
* `len` is 0. `len` must not be negative; a negative length is treated as a
* rejected input. */
* rejected input. Returns WeaselJson_NULL if parser is null */
WeaselJsonStatus WeaselJsonParser_parse(WeaselJsonParser *parser, char *buf,
int len);
+46 -46
View File
@@ -5,12 +5,12 @@
#include <simdjson.h>
std::pair<std::string, WeaselJsonStatus> runStreaming(std::string copy,
int stride) {
std::pair<std::string, WeaselJsonStatus>
runStreaming(std::string copy, int stackSize, int stride) {
SerializeState state;
auto c = serializeCallbacks();
std::unique_ptr<WeaselJsonParser, decltype(&WeaselJsonParser_destroy)> parser{
WeaselJsonParser_create(1024, &c, &state,
WeaselJsonParser_create(stackSize, &c, &state,
copy.size() % 2 == 0 ? WeaselJsonRaw : 0),
WeaselJsonParser_destroy};
if (stride == 0) {
@@ -31,11 +31,12 @@ std::pair<std::string, WeaselJsonStatus> runStreaming(std::string copy,
return {state.result, s};
}
std::pair<std::string, WeaselJsonStatus> runBatch(std::string copy) {
std::pair<std::string, WeaselJsonStatus> runBatch(std::string copy,
int stackSize) {
SerializeState state;
auto c = serializeCallbacks();
std::unique_ptr<WeaselJsonParser, decltype(&WeaselJsonParser_destroy)> parser{
WeaselJsonParser_create(1 << 20, &c, &state,
WeaselJsonParser_create(stackSize, &c, &state,
copy.size() % 2 == 0 ? WeaselJsonRaw : 0),
WeaselJsonParser_destroy};
auto s = WeaselJsonParser_parse(parser.get(), copy.data(), copy.size());
@@ -47,11 +48,11 @@ std::pair<std::string, WeaselJsonStatus> runBatch(std::string copy) {
}
std::pair<std::string, WeaselJsonStatus> runPrefix(std::string copy,
int prefix) {
int stackSize, int prefix) {
SerializeState state;
auto c = serializeCallbacks();
std::unique_ptr<WeaselJsonParser, decltype(&WeaselJsonParser_destroy)> parser{
WeaselJsonParser_create(1024, &c, &state,
WeaselJsonParser_create(stackSize, &c, &state,
copy.size() % 2 == 0 ? WeaselJsonRaw : 0),
WeaselJsonParser_destroy};
auto s = WeaselJsonParser_parse(parser.get(), copy.data(), prefix);
@@ -67,52 +68,51 @@ std::pair<std::string, WeaselJsonStatus> runPrefix(std::string copy,
return {state.result, s};
}
void testStreaming(std::string const &json) {
auto batch = runBatch(json);
void checkStreamingResult(
int stackSize, char const *kind,
std::pair<std::string, WeaselJsonStatus> const &got,
std::pair<std::string, WeaselJsonStatus> const &batch) {
if (got.second == WeaselJson_AGAIN) {
abort();
}
// The status must be identical however the document is chunked; the events
// reported for a document that failed to parse need not match.
bool ok = got.second == batch.second &&
(got.second != WeaselJson_OK || got.first == batch.first);
if (!ok) {
printf("%s (stackSize %d): %d, %s\n", kind, stackSize, (int)got.second,
got.first.c_str());
printf("batch (stackSize %d): %d, %s\n", stackSize, (int)batch.second,
batch.first.c_str());
abort();
}
}
void testStreaming(std::string const &json, int stackSize) {
auto batch = runBatch(json, stackSize);
if (batch.second == WeaselJson_AGAIN) {
abort();
}
for (int stride = 1; stride < 16; ++stride) {
auto streaming = runStreaming(json, stride);
if (streaming != batch) {
if (streaming.second == WeaselJson_AGAIN) {
abort();
}
bool streamingOk = streaming.second == WeaselJson_OK;
bool batchOk = batch.second == WeaselJson_OK;
if (streamingOk == batchOk && !batchOk) {
// It's ok if the processed data doesn't match if parsing failed
} else {
printf("streaming: %s, %s\n",
streaming.second == WeaselJson_OK ? "accept" : "reject",
streaming.first.c_str());
printf("batch: %s, %s\n",
batch.second == WeaselJson_OK ? "accept" : "reject",
batch.first.c_str());
abort();
}
}
checkStreamingResult(stackSize, "streaming",
runStreaming(json, stackSize, stride), batch);
if (int(json.size()) > stride) {
auto prefix = runPrefix(json, stride);
if (prefix != batch) {
if (prefix.second == WeaselJson_AGAIN) {
abort();
}
bool prefixOk = prefix.second == WeaselJson_OK;
bool batchOk = batch.second == WeaselJson_OK;
if (prefixOk == batchOk && !batchOk) {
// It's ok if the processed data doesn't match if parsing failed
} else {
printf("prefix: %s, %s\n",
prefix.second == WeaselJson_OK ? "accept" : "reject",
prefix.first.c_str());
printf("batch: %s, %s\n",
batch.second == WeaselJson_OK ? "accept" : "reject",
batch.first.c_str());
abort();
}
checkStreamingResult(stackSize, "prefix",
runPrefix(json, stackSize, stride), batch);
}
}
}
void testStreaming(std::string const &json) {
// Whether a document parses must not depend on the streaming pattern: the
// parser hands the tokenizer progress for a literal or a \uXXXX escape that
// straddles a chunk boundary to the pushdown stack, so a stackSize picked for
// the document's nesting depth used to overflow for some chunkings only.
// Sweep stack sizes around the minimum (3 is the smallest accepted by
// WeaselJsonParser_create) so that the property is exercised, then a large
// one for documents that need the room.
for (int stackSize : {3, 4, 5, 6, 7, 8, 12, 1024}) {
testStreaming(json, stackSize);
}
}
+1 -2
View File
@@ -13,8 +13,7 @@ WeaselJsonParser_create(int stackSize, const WeaselJsonCallbacks *callbacks,
if (stackSize < 0) { // avoid (size_t)stackSize wrapping to a huge allocation
return nullptr;
}
auto *buf =
malloc(sizeof(Parser3) + (size_t)stackSize * sizeof(*Parser3::stackPtr));
auto *buf = malloc(Parser3::allocationSize(stackSize));
if (buf == nullptr) {
return nullptr;
}
+60 -11
View File
@@ -72,6 +72,27 @@ enum Symbol : uint8_t {
T_BACKSLASH,
N_SYMBOL_COUNT, // Must be last
};
// Symbols that are only on the stack because a literal (`true`/`false`/`null`)
// or a `\uXXXX` escape straddles the end of a chunk. The whole-buffer path
// consumes those characters inline and pushes nothing, so these symbols must
// not spend the caller's nesting budget: otherwise the stackSize a document
// needs would depend on where the parse() boundaries happen to fall (see issue
// #71). WeaselJsonParser_create() therefore allocates this many extra slots on
// top of `stackSize`; see Parser3::pushTransient().
//
// These are all the pushes of such symbols, with their sizes:
// n_value() 3, 4, 3 (true, false, null)
// n_string2() 1 (N_STRING_FOLLOWING_ESCAPE)
// n_string_following_escape() 4 (the four hex digits)
// t_hex2() 6 (utf-16 surrogate pair)
// No transient symbol is ever pushed while another one is on the stack (each of
// these continuations pops its own symbol before pushing, and the literal ones
// run with N_VALUE already popped), so the largest single push - the six
// symbols that start a surrogate pair - bounds the number of transient symbols
// on the stack.
inline constexpr int kTransientSymbols = 6;
struct Parser3 {
Parser3(const WeaselJsonCallbacks *callbacks, void *userdata, int stackSize,
int flags)
@@ -80,6 +101,14 @@ struct Parser3 {
reset();
}
// Size of the allocation that holds a parser with a nesting budget of
// `stackSize` symbols: the parser itself, that budget, and the reserve for
// transient tokenizer progress symbols.
static constexpr size_t allocationSize(int stackSize) {
return sizeof(Parser3) +
(size_t(stackSize) + kTransientSymbols) * sizeof(Symbol);
}
[[nodiscard]] WeaselJsonStatus parse(char *buf, int len);
void flushNumber(bool done, char *buf) {
@@ -114,9 +143,12 @@ struct Parser3 {
}
// Pushing symbols onto the stack roughly corresponds with "parse these
// nonterminals/terminals in order"
[[nodiscard]] WeaselJsonStatus push(std::initializer_list<Symbol> symbols) {
if (stackEnd - stackPtr < ptrdiff_t(symbols.size())) [[unlikely]] {
// nonterminals/terminals in order". `end` is the first slot the symbols may
// not occupy.
[[nodiscard]] static WeaselJsonStatus
pushOnto(Symbol *&stackPtr, Symbol *end,
std::initializer_list<Symbol> symbols) {
if (end - stackPtr < ptrdiff_t(symbols.size())) [[unlikely]] {
return WeaselJson_OVERFLOW;
}
for (int i = symbols.size() - 1; i >= 0; --i) {
@@ -125,6 +157,23 @@ struct Parser3 {
return WeaselJson_OK;
}
// Push symbols that model the nesting of the document. This is what
// WeaselJson_OVERFLOW reports, so it's bounded by the caller's stackSize.
[[nodiscard]] WeaselJsonStatus push(std::initializer_list<Symbol> symbols) {
return pushOnto(stackPtr, stackEnd, symbols);
}
// Push tokenizer progress symbols for a literal or escape that straddles a
// chunk boundary. These go in the reserve past `stackEnd`, so they don't
// spend the caller's nesting budget and can't make the parser overflow on
// input it would have accepted in one piece. See kTransientSymbols for why
// the reserve is big enough to always succeed.
[[nodiscard]] WeaselJsonStatus
pushTransient(std::initializer_list<Symbol> symbols) {
assert(ptrdiff_t(symbols.size()) <= kTransientSymbols);
return pushOnto(stackPtr, stackEnd + kTransientSymbols, symbols);
}
[[nodiscard]] Symbol top() const {
assert(!empty());
return *(stackPtr - 1);
@@ -376,7 +425,7 @@ inline PRESERVE_NONE ContinuationStatus n_value(Parser3 *self, char *buf,
return WeaselJson_REJECT;
}
} else {
if (auto s = self->push({T_R, T_U, N_TRUE})) {
if (auto s = self->pushTransient({T_R, T_U, N_TRUE})) {
return s;
}
}
@@ -392,7 +441,7 @@ inline PRESERVE_NONE ContinuationStatus n_value(Parser3 *self, char *buf,
return WeaselJson_REJECT;
}
} else {
if (auto s = self->push({T_A, T_L, T_S, N_FALSE})) {
if (auto s = self->pushTransient({T_A, T_L, T_S, N_FALSE})) {
return s;
}
}
@@ -408,7 +457,7 @@ inline PRESERVE_NONE ContinuationStatus n_value(Parser3 *self, char *buf,
return WeaselJson_REJECT;
}
} else {
if (auto s = self->push({T_U, T_L, N_NULL})) {
if (auto s = self->pushTransient({T_U, T_L, N_NULL})) {
return s;
}
}
@@ -609,7 +658,7 @@ inline PRESERVE_NONE ContinuationStatus n_string2(Parser3 *self, char *buf,
case '\\':
++buf;
if (bufEnd - buf < /*strlen("u0000\\u0000")*/ 11) {
if (auto s = self->push({N_STRING_FOLLOWING_ESCAPE})) {
if (auto s = self->pushTransient({N_STRING_FOLLOWING_ESCAPE})) {
return s;
}
if (buf == bufEnd) {
@@ -650,7 +699,7 @@ inline PRESERVE_NONE ContinuationStatus n_string2(Parser3 *self, char *buf,
self->writeBuf[0] = (0b00000111 & codepoint) | 0b11110000;
self->writeBuf += 4;
}
} else if (0xdc00 <= codepoint && codepoint <= 0xdfff) {
} else if (0xdc00 <= codepoint && codepoint <= 0xdfff) [[unlikely]] {
return WeaselJson_REJECT;
} else {
if (!(self->flags & WeaselJsonRaw)) {
@@ -718,7 +767,7 @@ n_string_following_escape(Parser3 *self, char *buf, char *bufEnd) {
++buf;
self->utf8Codepoint = 0;
self->pop();
if (auto s = self->push({T_HEX, T_HEX, T_HEX, T_HEX2})) {
if (auto s = self->pushTransient({T_HEX, T_HEX, T_HEX, T_HEX2})) {
return s;
}
break;
@@ -800,8 +849,8 @@ inline PRESERVE_NONE ContinuationStatus t_hex2(Parser3 *self, char *buf,
self->utf16Surrogate = self->utf8Codepoint;
self->utf8Codepoint = 0;
self->pop();
if (auto s =
self->push({T_BACKSLASH, T_U2, T_HEX, T_HEX, T_HEX, T_HEX3})) {
if (auto s = self->pushTransient(
{T_BACKSLASH, T_U2, T_HEX, T_HEX, T_HEX, T_HEX3})) {
return s;
}
if (buf == bufEnd) {
+66 -2
View File
@@ -4,8 +4,10 @@
#include <cstring>
#include <fstream>
#include <algorithm>
#include <limits>
#include <string>
#include <vector>
#include <doctest.h>
#include <nanobench.h>
@@ -152,6 +154,27 @@ void testStreaming(std::string const &json) {
CHECK(streaming.result == batch.result);
}
// Feed `doc` to a fresh parser with the given stackSize, in chunks of `stride`
// bytes (`stride` <= 0 means all at once), and return the final status.
WeaselJsonStatus parseChunked(std::string const &doc, int stackSize,
int stride) {
auto c = noopCallbacks();
std::string copy = doc;
auto *parser = WeaselJsonParser_create(stackSize, &c, nullptr, 0);
REQUIRE(parser != nullptr);
int step = stride > 0 ? stride : std::max(1, (int)copy.size());
WeaselJsonStatus status = WeaselJson_AGAIN;
for (size_t i = 0; i < copy.size() && status == WeaselJson_AGAIN; i += step) {
status = WeaselJsonParser_parse(parser, copy.data() + i,
std::min<int>(step, copy.size() - i));
}
if (status == WeaselJson_AGAIN) {
status = WeaselJsonParser_parse(parser, nullptr, 0);
}
WeaselJsonParser_destroy(parser);
return status;
}
} // namespace
TEST_CASE("parser3") {
@@ -258,6 +281,48 @@ TEST_CASE("reset clears overflow state") {
WeaselJsonParser_destroy(parser);
}
TEST_CASE("acceptance is independent of the chunking") {
// Literals and \uXXXX escapes that straddle the end of a chunk are tokenized
// by pushing their not-yet-matched symbols onto the pushdown stack, so they
// used to spend the caller's nesting budget: a document could be accepted
// when fed whole and reported as WeaselJson_OVERFLOW when streamed in smaller
// pieces. The decision must depend only on the document and on `stackSize`.
std::vector<std::string> docs = {
"1",
"true",
"false",
"null",
"[true]",
"[true,false,null]",
"\"\\u00e9abcdefgh\"",
// Needs the full transient reserve: a surrogate pair split across chunks
// pushes six symbols at once.
"\"\\ud801\\udc37\"",
"[\"\\u00e9abcdefgh\"]",
"{\"\\ud801\\udc37\":[true,{\"x\":null}]}",
"[[[[[[[[[[[[true]]]]]]]]]]]]",
// Whether an invalid document is rejected must not depend on the chunking
// either.
"tru",
"truX",
"[[[[x",
"\"\\ud801x\"",
};
for (auto const &doc : docs) {
CAPTURE(doc);
// 3 is the smallest stackSize WeaselJsonParser_create accepts, so this
// sweeps from "everything overflows" to "everything fits".
for (int stackSize = 3; stackSize <= 16; ++stackSize) {
CAPTURE(stackSize);
auto whole = parseChunked(doc, stackSize, 0);
for (int stride = 1; stride <= 12; ++stride) {
CAPTURE(stride);
REQUIRE(parseChunked(doc, stackSize, stride) == whole);
}
}
}
}
TEST_CASE("rejected state is sticky") {
auto c = noopCallbacks();
auto *parser = WeaselJsonParser_create(1024, &c, nullptr, 0);
@@ -331,8 +396,7 @@ TEST_CASE("parse rejects negative length") {
}
TEST_CASE("Calling parse with nullptr doesn't crash") {
char buf[10] = "hello";
REQUIRE(WeaselJsonParser_parse(nullptr, buf, -1) == WeaselJson_NULL);
REQUIRE(WeaselJsonParser_parse(nullptr, nullptr, 0) == WeaselJson_NULL);
}
TEST_CASE("streaming") { testStreaming(json); }
+1 -1
View File
@@ -34,7 +34,7 @@ int main(int argc, char **argv) {
case WeaselJson_OVERFLOW:
return 1;
case WeaselJson_NULL:
fprintf(stderr, "Could not create parser\n");
fprintf(stderr, "parse called with a null parser\n");
return 1;
}
if (l == 0) {
+1
View File
@@ -30,6 +30,7 @@ class WeaselJsonStatus(enum.Enum):
AGAIN = 1
REJECT = 2
OVERFLOW = 3
NULL = 4
class WeaselJsonCallbacksBase: