diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a8d952b..1c255520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,18 @@ 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`, 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 + 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..80b0c097 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); @@ -1342,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); @@ -2220,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); 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"