From 3f043edf30e8d0e16fcdfa8a77457f18da3379ed Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Wed, 5 Aug 2026 01:16:11 -0500 Subject: [PATCH 1/2] fix: range-check magnitude before the double->int cast in the JSON encoders (#816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit store_json_encode and the builtin json_encode ran (int)n before the magnitude guard — UB for any number beyond int's range (C11 6.3.1.4p1; store_put of [db, {"n": 1e300}] reached it). Same class as #695's value_to_string fix. Output was correct only by hardware accident (x86-64 cvttsd2si -> INT_MIN -> equality fails -> %.15g). The guard now checks int's own range first (the old 1e15 bound never protected the cast and never mattered: integral values past 2^31 always failed the equality), so the cast is always defined and the encoded bytes are unchanged. make asan now also compiles with -fsanitize=float-cast-overflow — GCC's 'undefined' set does not include it, which is why the existing sanitizer gate was silent on this class. Both sites fire under the flag without the reorder; the full suite is clean with it. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 11 +++++++++++ Makefile | 2 +- src/builtins.c | 8 +++++++- src/ext_store.c | 8 +++++++- tests/test_json_roundtrip.eigs | 10 ++++++++++ tests/test_store.eigs | 15 +++++++++++++++ 6 files changed, 51 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a8d952b..d7236977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,17 @@ All notable changes to EigenScript are documented here. Now routed through `eigs_json_parse_root`; regression test in `embed_smoke` (the only consumer shape that can hit it). +- **json/store encode: magnitude checked before the double→int narrowing + cast (#816).** `store_json_encode` and the builtin `json_encode` ran + `(int)n` before the range guard — UB for any number beyond int's range + (`store_put of [db, {"n": 1e300}]` reached it); correct output was a + hardware accident (x86-64 `cvttsd2si`). Same class as #695. The guard + now checks int's own range first, so the cast is always defined and + the encoded bytes are unchanged. `make asan` now also compiles with + `-fsanitize=float-cast-overflow` — GCC's `undefined` set does not + include it, which is why the existing sanitizer gate was silent on + this class. + ## [0.38.0] - 2026-08-04 ### Added diff --git a/Makefile b/Makefile index 06628d72..069c8c89 100644 --- a/Makefile +++ b/Makefile @@ -88,7 +88,7 @@ endef VERDEF := -DEIGENSCRIPT_VERSION='"$(VERSION)"' DEFS_OFF := -DEIGENSCRIPT_EXT_HTTP=0 -DEIGENSCRIPT_EXT_MODEL=0 -DEIGENSCRIPT_EXT_DB=0 MODEL_SRC := $(SRC_DIR)/model_io.c $(SRC_DIR)/model_infer.c $(SRC_DIR)/model_train.c -ASAN_FLAGS := -fsanitize=address,undefined -Werror=switch -g -O1 +ASAN_FLAGS := -fsanitize=address,undefined,float-cast-overflow -Werror=switch -g -O1 SRC_V_release := $(SOURCES) FLAGS_release := $(CFLAGS) $(DEFS_OFF) $(VERDEF) diff --git a/src/builtins.c b/src/builtins.c index 02d0c0b9..4aacaa27 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -820,7 +820,13 @@ static int eigs_json_encode_value(Value *v, strbuf *out, int depth) { switch (v->type) { case VAL_NUM: { double n = v->data.num; - if (n == (int)n && fabs(n) < 1e15) + /* #816: magnitude BEFORE the narrowing cast (same class as + * #695) — converting a double beyond int's range is UB, and + * the old `n == (int)n && fabs(n) < 1e15` order ran the cast + * first. The bound is int's own range: integral values beyond + * it never took the %d path anyway (the equality failed), so + * output is unchanged and the cast is now always defined. */ + if (fabs(n) < 2147483648.0 && n == (int)n) strbuf_append_fmt(out, "%d", (int)n); else strbuf_append_fmt(out, "%.15g", n); diff --git a/src/ext_store.c b/src/ext_store.c index be1434f8..15cb2edc 100644 --- a/src/ext_store.c +++ b/src/ext_store.c @@ -163,7 +163,13 @@ static void store_json_encode(Value *v, strbuf *out) { switch (v->type) { case VAL_NUM: { double n = v->data.num; - if (n == (int)n && fabs(n) < 1e15) + /* #816: magnitude BEFORE the narrowing cast (same class as + * #695) — converting a double beyond int's range is UB, and + * the old `n == (int)n && fabs(n) < 1e15` order ran the cast + * first. The bound is int's own range: integral values beyond + * it never took the %d path anyway (the equality failed), so + * output is unchanged and the cast is now always defined. */ + if (fabs(n) < 2147483648.0 && n == (int)n) strbuf_append_fmt(out, "%d", (int)n); else strbuf_append_fmt(out, "%.15g", n); diff --git a/tests/test_json_roundtrip.eigs b/tests/test_json_roundtrip.eigs index f2d6c819..061202a9 100644 --- a/tests/test_json_roundtrip.eigs +++ b/tests/test_json_roundtrip.eigs @@ -30,4 +30,14 @@ ne is json_encode of nested assert of [contains of [ne, "\"inner\""], "nested dict encode"] assert of [contains of [ne, "99"], "nested dict value encode"] + +# #816: encode at and beyond int's range — the guard must range-check +# before the narrowing cast. Output pinned at the boundary. +assert of [(json_encode of 2147483647) == "2147483647", "INT_MAX encodes via int path (#816)"] +assert of [(json_encode of 2147483648) == "2147483648", "2^31 encodes correctly past the int path (#816)"] +assert of [(json_encode of (0 - 2147483648)) == "-2147483648", "int min boundary encodes (#816)"] +assert of [(json_encode of 1e300) == "1e+300", "1e300 encodes, cast never runs (#816)"] +rtb is json_decode of json_encode of {"n": 1e300} +assert of [rtb.n == 1e300, "1e300 round-trips through encode/decode (#816)"] + print of "json roundtrip: all passed" diff --git a/tests/test_store.eigs b/tests/test_store.eigs index 7161d25b..3bb546ca 100644 --- a/tests/test_store.eigs +++ b/tests/test_store.eigs @@ -219,6 +219,21 @@ rm of "/tmp/eigs_store_short.db" rm of "/tmp/eigs_store_badmagic.db" rm of "/tmp/eigs_store_badver.db" +# #816: number encoding at and beyond int's range. The old guard ran the +# double->int cast BEFORE the magnitude check (UB past int's range; the +# fixed order checks first). These pin the output at the boundary — int +# path below 2^31, %.15g at and above it, huge magnitudes intact. +bdb is store_open of "/tmp/eigs816_bounds.db" +bk is store_put of [bdb, "nums", {"imax": 2147483647, "past": 2147483648, "imin": 0 - 2147483648, "huge": 1e300, "nhuge": 0 - 1e300}] +bn is store_get of [bdb, "nums", bk] +assert_eq of [bn.imax, 2147483647, "INT_MAX round-trips (#816)"] +assert_eq of [bn.past, 2147483648, "2^31 (past int) round-trips (#816)"] +assert_eq of [bn.imin, 0 - 2147483648, "int min boundary round-trips (#816)"] +assert_eq of [bn.huge, 1e300, "1e300 round-trips, cast never runs (#816)"] +assert_eq of [bn.nhuge, 0 - 1e300, "-1e300 round-trips (#816)"] +store_close of bdb +rm of "/tmp/eigs816_bounds.db" + # Clean up rm of "/tmp/test_eigenstore.db" From 24b109f9d0795941136c0f466c1e6ba7e4bb7000 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Wed, 5 Aug 2026 01:48:07 -0500 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20two=20more=20cast-before-range=20sit?= =?UTF-8?q?es=20(json=5Fbuild,=20json=20path=20formatter)=20=E2=80=94=20ca?= =?UTF-8?q?ught=20by=20the=20new=20gate=20in=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The variant shape d == (double)(int)d escaped the pattern-grep re-audit; the float-cast-overflow sanitizer caught it in the asan-http CI leg (1.23e15 via test_json_hard, http-build-only path). Both sites reordered; json_hard green under make asan-http locally. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 3 ++- src/builtins.c | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7236977..1c255520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,8 @@ All notable changes to EigenScript are documented here. `embed_smoke` (the only consumer shape that can hit it). - **json/store encode: magnitude checked before the double→int narrowing - cast (#816).** `store_json_encode` and the builtin `json_encode` ran + cast (#816).** `store_json_encode`, the builtin `json_encode`, + `json_build`, and the json-path number formatter ran `(int)n` before the range guard — UB for any number beyond int's range (`store_put of [db, {"n": 1e300}]` reached it); correct output was a hardware accident (x86-64 `cvttsd2si`). Same class as #695. The guard diff --git a/src/builtins.c b/src/builtins.c index 4aacaa27..80b0c097 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -1348,7 +1348,10 @@ Value* builtin_json_build(Value *arg) { Value *val = arg->data.list.items[i + 1]; if (val->type == VAL_NUM) { double d = val->data.num; - if (d == (double)(int)d && d >= -1e9 && d <= 1e9) + /* #816: range BEFORE the cast — same class as the other two + * encoder sites; this variant shape was caught by the new + * float-cast-overflow gate in CI, not by pattern-grep. */ + if (d >= -1e9 && d <= 1e9 && d == (double)(int)d) strbuf_append_fmt(&out, "%d", (int)d); else strbuf_append_fmt(&out, "%.15g", d); @@ -2226,7 +2229,8 @@ Value* builtin_json_path(Value *arg) { if (current->type == VAL_NUM) { char buf[64]; double d = current->data.num; - if (d == (double)(int)d && fabs(d) < 1e9) + /* #816: range before the cast (see json_build above). */ + if (fabs(d) < 1e9 && d == (double)(int)d) snprintf(buf, sizeof(buf), "%d", (int)d); else snprintf(buf, sizeof(buf), "%.15g", d);