From 8324d93c39e5d5ffc1ccb2952b92f1f0e34f8e96 Mon Sep 17 00:00:00 2001 From: Barzabel Date: Thu, 22 Oct 2026 21:33:16 +0300 Subject: [PATCH] fix(expr): JSON.stringify key lists report cycles; document stack guard coverage (#2124, #2125) An array replacer now copies each source object once per call, so a cycle stays a cycle and the native check throws TypeError, while shared objects still serialize. Documents which threads evaluate expressions, what the 256 KiB guard covers (tested on a 512 KiB thread), that it does not apply on wasm32, and why eval is indirect. --- crates/expr/src/prelude.js | 17 +++++++++++++---- crates/expr/src/runtime.rs | 13 +++++++++---- crates/expr/src/tests/deep_nesting.rs | 26 +++++++++++++++++++++++++- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 3a43a9d10..37b87faab 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -1096,6 +1096,9 @@ function nearestKey(t) { return thisProperty.nearestKey(t); } replace(obj, name, function () { stack(); return apply(f, this, arguments); }); } + // Indirect on purpose: a wrapper can't make a direct eval, so `eval` sees globals and `var`s + // but not `let`/`const` locals of the expression. Keeping direct eval would mean leaving it + // unchecked; expressions practically never need their locals inside eval. var evalJs = eval; eval = function (x) { if (typeof x === 'string') { @@ -1135,10 +1138,12 @@ function nearestKey(t) { return thisProperty.nearestKey(t); } // A replacer function sees every value before it is serialized: the per-level check. An array // replacer (a list of keys) becomes one that copies each object's listed keys, in list order. - var stringify = JSON.stringify; + // Each source object gets one copy per call, so a cycle in the source is a cycle of copies and + // the native check reports it (TypeError); an object shared without a cycle is still fine. + var stringify = JSON.stringify, MapJs = Map; replace(JSON, 'stringify', function (value, replacer, space) { var fn = typeof replacer === 'function' ? replacer : null; - var keys = null; + var keys = null, copies = new MapJs(); if (Array.isArray(replacer)) { keys = []; for (var i = 0; i < replacer.length; i++) { @@ -1152,8 +1157,12 @@ function nearestKey(t) { return thisProperty.nearestKey(t); } if (fn !== null) v = apply(fn, this, [key, v]); if (keys !== null && v !== null && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Number || v instanceof String || v instanceof Boolean)) { - var o = {}; - for (var j = 0; j < keys.length; j++) if (keys[j] in v) o[keys[j]] = v[keys[j]]; + var o = copies.get(v); + if (o === undefined) { + o = {}; + for (var j = 0; j < keys.length; j++) if (keys[j] in v) o[keys[j]] = v[keys[j]]; + copies.set(v, o); + } v = o; } return v; diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index 8afa839e5..29e5e4eda 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -235,12 +235,17 @@ fn native_random(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult usize { let marker = 0u8; std::hint::black_box(&marker) as *const u8 as usize diff --git a/crates/expr/src/tests/deep_nesting.rs b/crates/expr/src/tests/deep_nesting.rs index d544d6f80..1bbe7de7e 100644 --- a/crates/expr/src/tests/deep_nesting.rs +++ b/crates/expr/src/tests/deep_nesting.rs @@ -10,9 +10,14 @@ const RENDER_STACK: usize = 2 << 20; /// Evaluate `text` on the title's source text (any result converts) on a render-sized stack. fn eval_small_stack(text: &str) -> Result { + eval_on_stack(text, RENDER_STACK) +} + +/// Evaluate `text` on the title's source text on a thread with `size` bytes of stack. +fn eval_on_stack(text: &str, size: usize) -> Result { let text = text.to_string(); std::thread::Builder::new() - .stack_size(RENDER_STACK) + .stack_size(size) .spawn(move || { let f = fx(); f.ev(f.title, "text/sourceText", &text, 0.0) @@ -102,3 +107,22 @@ fn nested_values_still_convert() { assert_eq!(f.text("String([[[[[[[[1]]]]]]]])", 0.0), "1"); assert_eq!(f.num("[1, [2, [3, [4]]]].flat(Infinity).length", 0.0), 4.0); } + +/// The main thread runs commands and syntax checks: 1 MiB on Windows. Half of it, for the +/// callers above the evaluation, must still turn the nested-value triggers into errors. +#[test] +fn nested_values_are_an_error_on_half_the_main_thread() { + for tail in ["a.join()", "String([a])", "JSON.stringify(a)", "a.toLocaleString()"] { + let text = format!("{DEEP_ARRAY} {tail}"); + assert!(eval_on_stack(&text, 512 << 10).is_err(), "{tail}"); + } +} + +#[test] +fn stringify_with_a_key_list_reports_cycles_and_allows_shared_objects() { + let f = fx(); + let cyclic = f.text("var o = {a: 1}; o.self = o; try { JSON.stringify(o, ['a', 'self']) } catch (e) { e.name }", 0.0); + assert_eq!(cyclic, "TypeError"); + let shared = f.text("var x = {a: 1, b: 2}; JSON.stringify({p: x, q: x}, ['p', 'q', 'a'])", 0.0); + assert_eq!(shared, r#"{"p":{"a":1},"q":{"a":1}}"#); +}