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>),

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Value is serialized with serde/postcard inside persisted VM snapshots, so inserting Spread before Function changes Function's enum discriminant from tag 8 to tag 9. A snapshot created by Zapcode 1.5.3 with a closure in scope may therefore fail to resume or decode incorrectly after upgrading to this version.

Please preserve the existing serialized position by moving the new variant after Function:

Object(IndexMap<Arc<str>, Value>),
Function(Closure),
Spread(Box<Value>),

For example, a snapshot suspended at await externalTool() while const transform = value => value + 1 is in scope serializes that closure as Value::Function using the old tag. Same-version snapshot tests will not detect this cross-version break. Explicit snapshot format versioning would be a good follow-up, but preserving the enum order is the minimal fix required here.

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 {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Value::Spread is intended to be a transient marker consumed by CreateArray/CreateObject, but spread call arguments are compiled through the same Expr::Spread path while Call(args.len()) does not expand them. A function can therefore receive and return this internal marker, and a surrounding literal can later misinterpret it as structural spread metadata.

Reproduction:

function id(value) { return value; }
JSON.stringify({ x: id(...[1, 2]), y: 3 });

Expected: {"x":1,"y":3}

Observed at this PR head: {"0":1,"1":2,"y":3}

Please either implement argument-spread expansion or reject spread arguments as unsupported. The internal marker must not escape literal construction. Please add this reproduction as a regression test.

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