diff --git a/Cargo.lock b/Cargo.lock index 36597cb..308da55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1514,7 +1514,7 @@ dependencies = [ [[package]] name = "zapcode-core" -version = "1.0.1" +version = "1.5.3" dependencies = [ "divan", "indexmap", @@ -1531,7 +1531,7 @@ dependencies = [ [[package]] name = "zapcode-js" -version = "1.0.1" +version = "1.5.3" dependencies = [ "napi", "napi-build", @@ -1542,7 +1542,7 @@ dependencies = [ [[package]] name = "zapcode-py" -version = "1.0.1" +version = "1.5.3" dependencies = [ "indexmap", "pyo3", @@ -1551,7 +1551,7 @@ dependencies = [ [[package]] name = "zapcode-wasm" -version = "1.0.1" +version = "1.5.3" dependencies = [ "indexmap", "js-sys", diff --git a/crates/zapcode-core/src/value.rs b/crates/zapcode-core/src/value.rs index 5991fec..60c27f7 100644 --- a/crates/zapcode-core/src/value.rs +++ b/crates/zapcode-core/src/value.rs @@ -13,6 +13,11 @@ pub enum Value { String(Arc), Array(Vec), Object(IndexMap, Value>), + /// Internal, transient marker produced by the `Spread` instruction and + /// consumed by `CreateArray`/`CreateObject`. Never surfaces to user code. + /// Serializable because it can be live on the operand stack across a + /// suspension (e.g. `[...a, await f()]`). + Spread(Box), Function(Closure), /// A generator object — calling function* creates one of these. /// Generators are stateful and cannot be serialized mid-yield. @@ -73,6 +78,7 @@ impl Value { Value::Object(_) => "object", Value::Function(_) | Value::BuiltinMethod { .. } => "function", Value::Generator(_) => "object", + Value::Spread(_) => "object", } } @@ -87,7 +93,8 @@ impl Value { | Value::Object(_) | Value::Function(_) | Value::BuiltinMethod { .. } - | Value::Generator(_) => true, + | Value::Generator(_) + | Value::Spread(_) => true, } } @@ -132,6 +139,7 @@ impl Value { Value::Object(_) => "[object Object]".to_string(), Value::Function(_) | Value::BuiltinMethod { .. } => "function".to_string(), Value::Generator(_) => "[object Generator]".to_string(), + Value::Spread(_) => "[object Spread]".to_string(), } } diff --git a/crates/zapcode-core/src/vm/builtins.rs b/crates/zapcode-core/src/vm/builtins.rs index 2b20d44..c333a8e 100644 --- a/crates/zapcode-core/src/vm/builtins.rs +++ b/crates/zapcode-core/src/vm/builtins.rs @@ -263,6 +263,8 @@ fn value_to_json(val: &Value) -> String { Value::Function(_) | Value::BuiltinMethod { .. } | Value::Generator(_) => { "undefined".to_string() } + // Transient internal marker; never reaches user-visible JSON. + Value::Spread(_) => "undefined".to_string(), } } diff --git a/crates/zapcode-core/src/vm/mod.rs b/crates/zapcode-core/src/vm/mod.rs index f0f39d9..f4e483b 100644 --- a/crates/zapcode-core/src/vm/mod.rs +++ b/crates/zapcode-core/src/vm/mod.rs @@ -1363,33 +1363,105 @@ impl Vm { // Objects & Arrays Instruction::CreateArray(count) => { self.tracker.track_allocation(&self.limits)?; - let mut arr = Vec::with_capacity(count); + let mut popped = Vec::with_capacity(count); for _ in 0..count { - arr.push(self.pop()?); + popped.push(self.pop()?); + } + popped.reverse(); + let mut arr = Vec::with_capacity(count); + for v in popped { + match v { + // A spread element flattens its source into the array. + // Each produced element counts against the allocation + // limit: spreads amplify (`[...a, ...a]` doubles the + // payload for O(1) stack pushes), so the up-front + // check alone cannot bound the result. + Value::Spread(inner) => match *inner { + Value::Array(items) => { + for item in items { + self.tracker.track_allocation(&self.limits)?; + arr.push(item); + } + } + Value::String(s) => { + for c in s.chars() { + self.tracker.track_allocation(&self.limits)?; + arr.push(Value::String(Arc::from(c.to_string().as_str()))); + } + } + other => { + return Err(ZapcodeError::TypeError(format!( + "{} is not iterable (cannot spread into array)", + other.type_name() + ))); + } + }, + other => arr.push(other), + } } - arr.reverse(); self.push(Value::Array(arr))?; } Instruction::CreateObject(count) => { self.tracker.track_allocation(&self.limits)?; - let mut obj = IndexMap::new(); - // Pop key-value pairs (or spread values) - let mut entries = Vec::new(); + + // Each of `count` entries is either a normal property — [key, + // value] with value on top — or a single `Value::Spread(source)`. + enum Entry { + Kv(Value, Value), + Spread(Value), + } + + let mut entries = Vec::with_capacity(count); for _ in 0..count { - let val = self.pop()?; - let key = self.pop()?; - entries.push((key, val)); + match self.pop()? { + Value::Spread(inner) => entries.push(Entry::Spread(*inner)), + val => { + let key = self.pop()?; + entries.push(Entry::Kv(key, val)); + } + } } entries.reverse(); - for (key, val) in entries { - match key { - Value::String(k) => { - obj.insert(k, val); - } - _ => { - let k: Arc = Arc::from(key.to_js_string().as_str()); + + let mut obj: IndexMap, Value> = IndexMap::new(); + for entry in entries { + match entry { + Entry::Kv(key, val) => { + let k = match key { + Value::String(k) => k, + other => Arc::from(other.to_js_string().as_str()), + }; obj.insert(k, val); } + // Merge the source's own enumerable properties; a later + // key overrides an earlier one (keeping its position). + // Per-entry limit checks, for the same amplification + // reason as array spread above. + Entry::Spread(src) => match src { + Value::Object(map) => { + for (k, v) in map { + self.tracker.track_allocation(&self.limits)?; + obj.insert(k, v); + } + } + Value::Array(items) => { + for (i, v) in items.into_iter().enumerate() { + self.tracker.track_allocation(&self.limits)?; + obj.insert(Arc::from(i.to_string().as_str()), v); + } + } + Value::String(s) => { + for (i, c) in s.chars().enumerate() { + self.tracker.track_allocation(&self.limits)?; + obj.insert( + Arc::from(i.to_string().as_str()), + Value::String(Arc::from(c.to_string().as_str())), + ); + } + } + // {...null}, {...undefined}, {...5}: no own props — ignore. + _ => {} + }, } } self.push(Value::Object(obj))?; @@ -1492,7 +1564,10 @@ impl Vm { self.push(obj)?; } Instruction::Spread => { - // Handled contextually in CreateArray/CreateObject + // Wrap the top value in a transient marker that CreateArray / + // CreateObject recognize and flatten. + let v = self.pop()?; + self.push(Value::Spread(Box::new(v)))?; } Instruction::In => { let right = self.pop()?; diff --git a/crates/zapcode-core/tests/objects_arrays.rs b/crates/zapcode-core/tests/objects_arrays.rs index 71d79b3..f753e1e 100644 --- a/crates/zapcode-core/tests/objects_arrays.rs +++ b/crates/zapcode-core/tests/objects_arrays.rs @@ -226,3 +226,57 @@ fn test_if_block_with_user_function_and_object_arg() { let result = eval_ts("function f(x){ return x; }\nif (true) { f({ a: 1 }); }").unwrap(); assert_eq!(result, Value::Undefined); } + +// ── spread (regression: were silently-wrong / crashing) ────────────────────── + +#[test] +fn test_array_spread_flattens() { + // (Value's PartialEq is JS ===, so arrays compare by reference — assert via join.) + let r = eval_ts("const a = [1, 2]; const b = [...a, 3]; b.join(',')").unwrap(); + assert_eq!(r, Value::String("1,2,3".into())); +} + +#[test] +fn test_array_spread_middle_and_multiple() { + let r = eval_ts("const a = [1]; const b = [2, 3]; [0, ...a, ...b, 4].join(',')").unwrap(); + assert_eq!(r, Value::String("0,1,2,3,4".into())); +} + +#[test] +fn test_object_spread() { + let r = eval_ts("const a = { x: 1 }; const b = { ...a, y: 2 }; b.x + b.y").unwrap(); + assert_eq!(r, Value::Int(3)); +} + +#[test] +fn test_object_spread_overrides_later_wins() { + let r = eval_ts("const o = { ...{ a: 1, b: 2 }, b: 3 }; o.b").unwrap(); + assert_eq!(r, Value::Int(3)); +} + +#[test] +fn test_array_spread_non_iterable_errors() { + let err = eval_ts("const x = 5; [...x]").unwrap_err(); + assert!(err.to_string().contains("not iterable"), "got: {err}"); +} + +#[test] +fn test_array_spread_empty_source() { + assert_eq!(eval_ts("[...[]].length").unwrap(), Value::Int(0)); +} + +#[test] +fn test_object_spread_empty_source() { + assert_eq!( + eval_ts("Object.keys({ ...{} }).length").unwrap(), + Value::Int(0) + ); +} + +#[test] +fn test_object_spread_string_uses_char_index_keys() { + assert_eq!( + eval_ts(r#"const o = { ..."ab" }; o["1"]"#).unwrap(), + Value::String("b".into()) + ); +} diff --git a/crates/zapcode-core/tests/security.rs b/crates/zapcode-core/tests/security.rs index 9f71575..9b2a3ec 100644 --- a/crates/zapcode-core/tests/security.rs +++ b/crates/zapcode-core/tests/security.rs @@ -5,7 +5,7 @@ use zapcode_core::vm::{eval_ts, eval_ts_with_output}; use zapcode_core::Value; -use zapcode_core::ZapcodeError; +use zapcode_core::{ResourceLimits, ZapcodeError, ZapcodeRun}; // ── 1. Prototype pollution ────────────────────────────────────────── @@ -1015,3 +1015,60 @@ fn test_finalization_registry_escape() { // not supported is good let _ = result; } + +// ── 12. Spread amplification ─────────────────────────────────────── +// Arrays and objects are value types, so `[...a, ...a]` doubles the payload +// while costing O(1) stack pushes. Flattening must count each produced +// element against the allocation limit, or a short loop bypasses it. + +#[test] +fn test_array_spread_amplification_hits_allocation_limit() { + let limits = ResourceLimits { + max_allocations: 10_000, + ..Default::default() + }; + let runner = ZapcodeRun::new( + r#" + let a: any = [1, 2, 3, 4]; + for (let i = 0; i < 16; i++) { + a = [...a, ...a]; + } + a.length + "# + .to_string(), + vec![], + vec![], + limits, + ) + .unwrap(); + let result = runner.start(vec![]); + assert!( + matches!(result, Err(ZapcodeError::AllocationLimitExceeded)), + "VULN: array spread flattening not counted against the allocation limit, got: {result:?}" + ); +} + +#[test] +fn test_object_spread_amplification_hits_allocation_limit() { + let limits = ResourceLimits { + max_allocations: 10_000, + ..Default::default() + }; + let runner = ZapcodeRun::new( + r#" + const s = "ab".repeat(20000); + const o = { ...s }; + Object.keys(o).length + "# + .to_string(), + vec![], + vec![], + limits, + ) + .unwrap(); + let result = runner.start(vec![]); + assert!( + matches!(result, Err(ZapcodeError::AllocationLimitExceeded)), + "VULN: object spread merging not counted against the allocation limit, got: {result:?}" + ); +} diff --git a/crates/zapcode-js/src/lib.rs b/crates/zapcode-js/src/lib.rs index 131fc33..9a92cbe 100644 --- a/crates/zapcode-js/src/lib.rs +++ b/crates/zapcode-js/src/lib.rs @@ -272,6 +272,7 @@ fn value_to_json(value: &Value) -> serde_json::Value { .collect(); serde_json::Value::Object(map) } + Value::Spread(inner) => value_to_json(inner), Value::Function(_) | Value::BuiltinMethod { .. } => { // Functions are not serializable to JSON. serde_json::Value::Null diff --git a/crates/zapcode-py/src/lib.rs b/crates/zapcode-py/src/lib.rs index 65334f5..139a343 100644 --- a/crates/zapcode-py/src/lib.rs +++ b/crates/zapcode-py/src/lib.rs @@ -71,6 +71,7 @@ fn value_to_py(py: Python<'_>, val: &Value) -> PyResult { } Ok(dict.into_pyobject(py)?.into_any().unbind()) } + Value::Spread(inner) => value_to_py(py, inner), Value::Function(_) | Value::BuiltinMethod { .. } => { // Functions cannot be meaningfully represented in Python. Ok("".into_pyobject(py)?.into_any().unbind()) diff --git a/crates/zapcode-wasm/src/lib.rs b/crates/zapcode-wasm/src/lib.rs index 552b1e6..64c5fef 100644 --- a/crates/zapcode-wasm/src/lib.rs +++ b/crates/zapcode-wasm/src/lib.rs @@ -83,6 +83,7 @@ fn value_to_js(val: &Value) -> Result { } Ok(obj.into()) } + Value::Spread(inner) => value_to_js(inner), Value::Function(_) | Value::BuiltinMethod { .. } => Ok(JsValue::from_str("")), Value::Generator(_) => Ok(JsValue::from_str("")), }