Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion crates/zapcode-core/src/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ pub enum Value {
String(Arc<str>),
Array(Vec<Value>),
Object(IndexMap<Arc<str>, 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<Value>),
Function(Closure),
/// A generator object — calling function* creates one of these.
/// Generators are stateful and cannot be serialized mid-yield.
Expand Down Expand Up @@ -73,6 +78,7 @@ impl Value {
Value::Object(_) => "object",
Value::Function(_) | Value::BuiltinMethod { .. } => "function",
Value::Generator(_) => "object",
Value::Spread(_) => "object",
}
}

Expand All @@ -87,7 +93,8 @@ impl Value {
| Value::Object(_)
| Value::Function(_)
| Value::BuiltinMethod { .. }
| Value::Generator(_) => true,
| Value::Generator(_)
| Value::Spread(_) => true,
}
}

Expand Down Expand Up @@ -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(),
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/zapcode-core/src/vm/builtins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}

Expand Down
109 changes: 92 additions & 17 deletions crates/zapcode-core/src/vm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))?;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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<str> = Arc::from(key.to_js_string().as_str());

let mut obj: IndexMap<Arc<str>, 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))?;
Expand Down Expand Up @@ -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()?;
Expand Down
54 changes: 54 additions & 0 deletions crates/zapcode-core/tests/objects_arrays.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
);
}
59 changes: 58 additions & 1 deletion crates/zapcode-core/tests/security.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────

Expand Down Expand Up @@ -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:?}"
);
}
1 change: 1 addition & 0 deletions crates/zapcode-js/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions crates/zapcode-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ fn value_to_py(py: Python<'_>, val: &Value) -> PyResult<PyObject> {
}
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("<function>".into_pyobject(py)?.into_any().unbind())
Expand Down
1 change: 1 addition & 0 deletions crates/zapcode-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ fn value_to_js(val: &Value) -> Result<JsValue, JsError> {
}
Ok(obj.into())
}
Value::Spread(inner) => value_to_js(inner),
Value::Function(_) | Value::BuiltinMethod { .. } => Ok(JsValue::from_str("<function>")),
Value::Generator(_) => Ok(JsValue::from_str("<generator>")),
}
Expand Down