The integer parser that schemagen embeds in every generated header (parseJsonInt64) uses a saturating sentinel (INT64_MAX) to clamp a too-large exponent, then negates it and immediately does unchecked int64 arithmetic with it:
# contrib/schemagen/weaseljson_schemagen.py:1062-1068while(p<e&&*p>='0'&&*p<='9'){{intdigit=*p-'0';if(exp<=(INT64_MAX-digit)/10)exp=exp*10+digit;elseexp=INT64_MAX;# saturation sentinel++p;}}if(expNeg)exp=-exp;# => exp == -INT64_MAX# contrib/schemagen/weaseljson_schemagen.py:1082int64_tfinalExp=exp-fracDigits+trim;# UB when exp == -INT64_MAX and fracDigits >= 2
-INT64_MAX - fracDigits underflows as soon as the fraction has two or more digits, so the generated code has signed-integer overflow (undefined behaviour) for a perfectly ordinary JSON number. UBSan aborts the parse of that document; with -fsanitize-recover it reports and continues, and the second sub-expression overflows too.
Nothing rejects the saturated sentinel before it is combined with the fraction count, and the overflow happens before the "all digits are zero" early-return, so it is not avoided by zero mantissas either.
gen.h:137:28: runtime error: signed integer overflow: -9223372036854775807 - 2 cannot be represented in type 'int64_t' (aka 'long')
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior gen.h:137:28
Identical failure with g++ 16.1.1 (... in type 'long int'). The same happens for a root-level {"type": "integer"} schema with the document 1.12e-99999999999999999999, and for any integer property or array item, because parseJsonInt64 is emitted once and used by every Cat::Int slot.
Further observations from the same repro:
{"age":1.1e-9223372036854775808} (one fraction digit) does not overflow — -INT64_MAX - 1 == INT64_MIN is still representable. Two or more fraction digits are enough.
With recoverable UBSan, {"age":0.00e-9223372036854775808} reports a second overflow on the same line (gen.h:137:41: signed integer overflow: 9223372036854775807 + 3), i.e. both sub-expressions are UB.
{"age":0.12e-9223372036854775808} also overflows (the overflow precedes the all-zero short-circuit at line 1085-1086 of the generator).
Expected vs actual
Expected: the number is not representable as an int64_t integer, so the slot is rejected; the generated code must not execute undefined behaviour on the way there.
Actual (UBSan build): the generated parser trips signed integer overflow and, with the -fno-sanitize-recover flag that fuzzing/CI harnesses normally use, aborts while parsing a 30-byte valid JSON document.
Actual (plain build): it happens to return WeaselJson_REJECT, but only because the wrapped value is astronomically large and is then caught by the incidental if (finalExp > 19) return false; check. The intended check never sees a real exponent: finalExp is not a meaningful number once exp has saturated, so the rejection is an accident of two's-complement wrap-around rather than a decision the code makes.
Impact
Generated parsers are meant to validate untrusted input. Any document whose exponent exceeds the int64 range while the fraction has two or more digits (e.g. 1.12e-9223372036854775808, 0.12e-99999999999999999999) puts the parser into undefined behaviour:
sanitizer-instrumented builds (the normal way to fuzz/CI-test a parser) fail hard on such input;
the compiler is entitled to assume the subtraction never overflows, so the surrounding finalExp < 0 / finalExp > 19 range checks on a wrapped value are not something the code can rely on.
Suggested fix
Keep the saturated sentinel out of the arithmetic. For example, reject/clamp the exponent before it is combined with fracDigits:
if(expNeg)exp=-exp;...// A negative exponent beyond the int64 range can never yield a nonzero integer.
if(exp<-20-static_cast<int64_t>(fracDigits))returnfalse;int64_tfinalExp=exp-fracDigits+trim;
A regression test in contrib/schemagen/test_schemagen.py (in the style of SchemagenIntegerBoundaryTest) that feeds {"age":1.12e-9223372036854775808} and expects WeaselJson_REJECT would cover it, and the generated code should be built with -fsanitize=undefined -fno-sanitize-recover=undefined so the UB shows up.
## Summary
The integer parser that schemagen embeds in every generated header (`parseJsonInt64`) uses a saturating sentinel (`INT64_MAX`) to clamp a too-large exponent, then negates it and immediately does unchecked int64 arithmetic with it:
```python
# contrib/schemagen/weaseljson_schemagen.py:1062-1068
while (p < e && *p >= '0' && *p <= '9') {{
int digit = *p - '0';
if (exp <= (INT64_MAX - digit) / 10) exp = exp * 10 + digit;
else exp = INT64_MAX; # saturation sentinel
++p;
}}
if (expNeg) exp = -exp; # => exp == -INT64_MAX
# contrib/schemagen/weaseljson_schemagen.py:1082
int64_t finalExp = exp - fracDigits + trim; # UB when exp == -INT64_MAX and fracDigits >= 2
```
`-INT64_MAX - fracDigits` underflows as soon as the fraction has two or more digits, so the generated code has signed-integer overflow (undefined behaviour) for a perfectly ordinary JSON number. UBSan aborts the parse of that document; with `-fsanitize-recover` it reports and continues, and the second sub-expression overflows too.
Nothing rejects the saturated sentinel before it is combined with the fraction count, and the overflow happens *before* the "all digits are zero" early-return, so it is not avoided by zero mantissas either.
## Reproduction
```sh
mkdir -p /tmp/repro && cd /tmp/repro
REPO=/path/to/weaseljson
printf '%s' '{"type":"object","additionalProperties":false,"properties":{"age":{"type":"integer"}},"required":["age"]}' > schema.json
python3 "$REPO/contrib/schemagen/weaseljson_schemagen.py" schema.json -o gen.h --namespace fg
cat > test.cpp <<'EOF'
#include "gen.h"
#include <cstdio>
#include <string>
int main() {
std::string doc = R"({"age":1.12e-9223372036854775808})";
fg::RootBuilder b;
WeaselJsonStatus st = b.feed(doc.data(), (int)doc.size());
st = b.finish();
std::printf("%d\n", (int)st);
}
EOF
clang++ -std=c++20 -O2 -fsanitize=undefined -fno-sanitize-recover=undefined \
-I "$REPO/include" -I . test.cpp "$REPO/src/lib.cpp" -o test
./test
```
Observed (clang 22.1.8, exit status 1):
```
gen.h:137:28: runtime error: signed integer overflow: -9223372036854775807 - 2 cannot be represented in type 'int64_t' (aka 'long')
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior gen.h:137:28
```
Identical failure with g++ 16.1.1 (`... in type 'long int'`). The same happens for a root-level `{"type": "integer"}` schema with the document `1.12e-99999999999999999999`, and for any integer property or array item, because `parseJsonInt64` is emitted once and used by every `Cat::Int` slot.
Further observations from the same repro:
* `{"age":1.1e-9223372036854775808}` (one fraction digit) does **not** overflow — `-INT64_MAX - 1 == INT64_MIN` is still representable. Two or more fraction digits are enough.
* With recoverable UBSan, `{"age":0.00e-9223372036854775808}` reports a second overflow on the same line (`gen.h:137:41: signed integer overflow: 9223372036854775807 + 3`), i.e. both sub-expressions are UB.
* `{"age":0.12e-9223372036854775808}` also overflows (the overflow precedes the all-zero short-circuit at line 1085-1086 of the generator).
## Expected vs actual
* Expected: the number is not representable as an `int64_t` integer, so the slot is rejected; the generated code must not execute undefined behaviour on the way there.
* Actual (UBSan build): the generated parser trips `signed integer overflow` and, with the `-fno-sanitize-recover` flag that fuzzing/CI harnesses normally use, aborts while parsing a 30-byte valid JSON document.
* Actual (plain build): it happens to return `WeaselJson_REJECT`, but only because the wrapped value is astronomically large and is then caught by the incidental `if (finalExp > 19) return false;` check. The intended check never sees a real exponent: `finalExp` is not a meaningful number once `exp` has saturated, so the rejection is an accident of two's-complement wrap-around rather than a decision the code makes.
## Impact
Generated parsers are meant to validate untrusted input. Any document whose exponent exceeds the `int64` range while the fraction has two or more digits (e.g. `1.12e-9223372036854775808`, `0.12e-99999999999999999999`) puts the parser into undefined behaviour:
* sanitizer-instrumented builds (the normal way to fuzz/CI-test a parser) fail hard on such input;
* the compiler is entitled to assume the subtraction never overflows, so the surrounding `finalExp < 0` / `finalExp > 19` range checks on a wrapped value are not something the code can rely on.
## Suggested fix
Keep the saturated sentinel out of the arithmetic. For example, reject/clamp the exponent before it is combined with `fracDigits`:
```cpp
if (expNeg) exp = -exp;
...
// A negative exponent beyond the int64 range can never yield a nonzero integer.
if (exp < -20 - static_cast<int64_t>(fracDigits)) return false;
int64_t finalExp = exp - fracDigits + trim;
```
or use the overflow intrinsics:
```cpp
int64_t finalExp;
if (__builtin_sub_overflow(exp, static_cast<int64_t>(fracDigits), &finalExp)) return false;
if (__builtin_add_overflow(finalExp, trim, &finalExp)) return false;
```
A regression test in `contrib/schemagen/test_schemagen.py` (in the style of `SchemagenIntegerBoundaryTest`) that feeds `{"age":1.12e-9223372036854775808}` and expects `WeaselJson_REJECT` would cover it, and the generated code should be built with `-fsanitize=undefined -fno-sanitize-recover=undefined` so the UB shows up.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
The integer parser that schemagen embeds in every generated header (
parseJsonInt64) uses a saturating sentinel (INT64_MAX) to clamp a too-large exponent, then negates it and immediately does unchecked int64 arithmetic with it:-INT64_MAX - fracDigitsunderflows as soon as the fraction has two or more digits, so the generated code has signed-integer overflow (undefined behaviour) for a perfectly ordinary JSON number. UBSan aborts the parse of that document; with-fsanitize-recoverit reports and continues, and the second sub-expression overflows too.Nothing rejects the saturated sentinel before it is combined with the fraction count, and the overflow happens before the "all digits are zero" early-return, so it is not avoided by zero mantissas either.
Reproduction
Observed (clang 22.1.8, exit status 1):
Identical failure with g++ 16.1.1 (
... in type 'long int'). The same happens for a root-level{"type": "integer"}schema with the document1.12e-99999999999999999999, and for any integer property or array item, becauseparseJsonInt64is emitted once and used by everyCat::Intslot.Further observations from the same repro:
{"age":1.1e-9223372036854775808}(one fraction digit) does not overflow —-INT64_MAX - 1 == INT64_MINis still representable. Two or more fraction digits are enough.{"age":0.00e-9223372036854775808}reports a second overflow on the same line (gen.h:137:41: signed integer overflow: 9223372036854775807 + 3), i.e. both sub-expressions are UB.{"age":0.12e-9223372036854775808}also overflows (the overflow precedes the all-zero short-circuit at line 1085-1086 of the generator).Expected vs actual
int64_tinteger, so the slot is rejected; the generated code must not execute undefined behaviour on the way there.signed integer overflowand, with the-fno-sanitize-recoverflag that fuzzing/CI harnesses normally use, aborts while parsing a 30-byte valid JSON document.WeaselJson_REJECT, but only because the wrapped value is astronomically large and is then caught by the incidentalif (finalExp > 19) return false;check. The intended check never sees a real exponent:finalExpis not a meaningful number onceexphas saturated, so the rejection is an accident of two's-complement wrap-around rather than a decision the code makes.Impact
Generated parsers are meant to validate untrusted input. Any document whose exponent exceeds the
int64range while the fraction has two or more digits (e.g.1.12e-9223372036854775808,0.12e-99999999999999999999) puts the parser into undefined behaviour:finalExp < 0/finalExp > 19range checks on a wrapped value are not something the code can rely on.Suggested fix
Keep the saturated sentinel out of the arithmetic. For example, reject/clamp the exponent before it is combined with
fracDigits:or use the overflow intrinsics:
A regression test in
contrib/schemagen/test_schemagen.py(in the style ofSchemagenIntegerBoundaryTest) that feeds{"age":1.12e-9223372036854775808}and expectsWeaselJson_REJECTwould cover it, and the generated code should be built with-fsanitize=undefined -fno-sanitize-recover=undefinedso the UB shows up.