Merge remote-tracking branch 'origin/main' into fix/paths-and-callable-layers

# Conflicts:
#	crates/expr/src/prelude.js
This commit is contained in:
tylerjenningsw
2026-10-29 17:42:29 -04:00
520 changed files with 29879 additions and 7182 deletions
+27 -24
View File
@@ -313,6 +313,32 @@ impl<'a> Resolver<'a> {
self.try_resolve(req).unwrap_or(Resp::Null)
}
/// Resolve an ancestor from its UID path while preserving child indexing.
fn ancestor(&self, comp: u64, layer: u64, path: &str, up: i64) -> Option<Resp> {
let l = self.layer(comp, layer)?;
// The node's full `@uid/@uid/…` path from the layer: its ancestors are its prefixes.
let uid = match self.prop(comp, layer, path) {
Some((_, p)) => p.uid,
None => l.props.node(path)?.uid(),
};
let full = l.props.path_of(uid)?;
let segs: Vec<&str> = full.split('/').collect();
let up = usize::try_from(up).ok().filter(|u| *u >= 1 && *u <= segs.len())?;
if up == segs.len() {
return Some(Resp::Str("layer".into()));
}
let keep = segs.len() - up;
let group_path = segs.get(..keep)?.join("/");
let parent = match keep {
1 => &l.props,
_ => l.props.group(&segs.get(..keep - 1)?.join("/"))?,
};
let uid = segs.get(keep - 1)?.strip_prefix('@')?.parse::<u64>().ok()?;
let index = parent.children.iter().position(|c| c.uid() == uid)?;
let n = parent.children.get(index)?;
Some(node_resp(group_path, n, index + 1))
}
fn try_resolve(&self, req: &Req) -> Option<Resp> {
let project = self.ctx.project;
Some(match req {
@@ -373,30 +399,7 @@ impl<'a> Resolver<'a> {
let path = if group.is_empty() { format!("@{}", n.uid()) } else { format!("{group}/@{}", n.uid()) };
node_resp(path, n, index)
}
Req::Ancestor { comp, layer, path, up } => {
let l = self.layer(*comp, *layer)?;
// The node's full `@uid/@uid/…` path from the layer: its ancestors are its prefixes.
let uid = match self.prop(*comp, *layer, path) {
Some((_, p)) => p.uid,
None => l.props.node(path)?.uid(),
};
let full = l.props.path_of(uid)?;
let segs: Vec<&str> = full.split('/').collect();
let up = usize::try_from(*up).ok().filter(|u| *u >= 1 && *u <= segs.len())?;
if up == segs.len() {
return Some(Resp::Str("layer".into()));
}
let keep = segs.len() - up;
let group_path = segs.get(..keep)?.join("/");
let parent = match keep {
1 => &l.props,
_ => l.props.group(&segs.get(..keep - 1)?.join("/"))?,
};
let uid = segs.get(keep - 1)?.strip_prefix('@')?.parse::<u64>().ok()?;
let index = parent.children.iter().position(|c| c.uid() == uid)?;
let n = parent.children.get(index)?;
node_resp(group_path, n, index + 1)
}
Req::Ancestor { comp, layer, path, up } => return self.ancestor(*comp, *layer, path, *up),
Req::PropInfo { comp, layer, path } => {
let (l, p) = self.prop(*comp, *layer, path)?;
let dims = prop_dims(l, p);
+11 -7
View File
@@ -93,7 +93,7 @@ impl ExprHost for Expressions {
return Ok([v.first().copied().unwrap_or(100.0), v.get(1).copied().unwrap_or(100.0), v.get(2).copied().unwrap_or(100.0)]);
};
let value = prop.value_at(layer.layer_time(ctx.time));
let out = evaluate_out(self, ctx, layer, prop, &value, &e.text, [index as f64 + 1.0, total as f64], selector)?;
let out = evaluate_out(self, ctx, layer, prop, &value, &e.text, TextSelector { index_total: [index as f64 + 1.0, total as f64], value: selector })?;
let num = |o: &runtime::Out| match o {
runtime::Out::Num(x) => Some(*x),
runtime::Out::Bool(b) => Some(if *b { 100.0 } else { 0.0 }),
@@ -124,13 +124,17 @@ impl Drop for FrameGuard {
/// Evaluate `text` as the expression of `prop` on `layer` at `ctx.time`, given its keyframed
/// `value`. Other properties' expressions are evaluated through `host`.
pub fn evaluate(host: &dyn ExprHost, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value, text: &str) -> Result<Value, String> {
let out = evaluate_out(host, ctx, layer, prop, value, text, [1.0, 1.0], [100.0; 3])?;
let out = evaluate_out(host, ctx, layer, prop, value, text, TextSelector { index_total: [1.0, 1.0], value: [100.0; 3] })?;
runtime::to_value(&out, value)
}
/// Run an expression and return the raw script result. `text_sel` / `selector` are the text
/// Expression Selector's `[textIndex, textTotal]` and `selectorValue`.
#[allow(clippy::too_many_arguments)]
/// Expression Selector state: `[textIndex, textTotal]` and `selectorValue`.
struct TextSelector {
index_total: [f64; 2],
value: [f64; 3],
}
/// Run an expression and return the raw script result.
fn evaluate_out(
host: &dyn ExprHost,
ctx: &EvalCtx,
@@ -138,9 +142,9 @@ fn evaluate_out(
prop: &Property,
value: &Value,
text: &str,
text_sel: [f64; 2],
selector: [f64; 3],
text_selector: TextSelector,
) -> Result<runtime::Out, String> {
let TextSelector { index_total: text_sel, value: selector } = text_selector;
let owner = Owner { comp: ctx.comp_id.0, layer: layer.id.0, uid: prop.uid, time: ctx.time, text: text.to_string() };
let (depth, cycle) = FRAMES.with(|f| {
let f = f.borrow();
+114 -11
View File
@@ -716,14 +716,17 @@ var __groupHandler = {
},
};
function __mkGroup(c, l, info) {
var f = function (k) { return f.__get(k); };
delete f.name;
delete f.length;
Object.setPrototypeOf(f, __GroupProto);
f.__c = c; f.__l = l; f.__p = info[0]; f.__info = info;
return new Proxy(f, __groupHandler);
}
// `Proxy` is private to the object model (see "Stack safety" at the end).
var __mkGroup = (function (P) {
return function (c, l, info) {
var f = function (k) { return f.__get(k); };
delete f.name;
delete f.length;
Object.setPrototypeOf(f, __GroupProto);
f.__c = c; f.__l = l; f.__p = info[0]; f.__info = info;
return new P(f, __groupHandler);
};
})(Proxy);
// Properties ------------------------------------------------------------------------------------
@@ -899,9 +902,11 @@ var __propHandler = {
},
};
function __mkProp(c, l, path) {
return new Proxy(new Prop(c, l, path), __propHandler);
}
var __mkProp = (function (P) {
return function (c, l, path) {
return new P(new Prop(c, l, path), __propHandler);
};
})(Proxy);
// Text styles (the Source Text style API) ------------------------------------------------------
// A TextStyle is the source document (as JSON from the host), the character index it reads its
@@ -1117,6 +1122,104 @@ function speedAtTime(t) { return thisProperty.speedAtTime(t); }
function key(n) { return thisProperty.key(n); }
function nearestKey(t) { return thisProperty.nearestKey(t); }
// ---------------------------------------------------------------------------------------------
// Stack safety (#2124, #2125). A stack overflow aborts the whole app, so nothing a script does
// may nest native code without bound. The expression text is checked for nesting before it is
// compiled; source handed to `eval` and the function constructors at run time is checked by
// `__nest`. The natives that walk nested values recursively (`join`, which `toString` and string
// conversion use, `toLocaleString`, `JSON.stringify`, `flat`) call `__stack` once per level,
// which throws once the run has used its share of the stack. `Proxy` (chains of proxies nest
// without any script frame) and `Reflect` are not available to expressions.
(function () {
var nest = __nest, stack = __stack, apply = Reflect.apply, construct = Reflect.construct;
var define = Object.defineProperty, describe = Object.getOwnPropertyDescriptor, proto = Object.getPrototypeOf;
// Replace `obj[name]` keeping its attributes.
function replace(obj, name, f) {
var d = describe(obj, name);
define(obj, name, { value: f, writable: d.writable, enumerable: d.enumerable, configurable: true });
}
function guard(obj, name) {
var f = obj[name];
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') {
stack();
nest(x);
}
return evalJs(x);
};
// `Function` and the generator / async variants (reachable as `(function* () {}).constructor`).
function dynamic(F) {
var w = function () {
var args = [];
for (var i = 0; i < arguments.length; i++) args.push(String(arguments[i]));
stack();
nest(args.slice(0, -1).join(','));
nest(args.length ? args[args.length - 1] : '');
return construct(F, args);
};
define(w, 'name', { value: F.name, configurable: true });
w.prototype = F.prototype;
replace(F.prototype, 'constructor', w);
return w;
}
Function = dynamic(Function);
dynamic(proto(function* () {}).constructor);
dynamic(proto(async function () {}).constructor);
dynamic(proto(async function* () {}).constructor);
guard(Array.prototype, 'join');
guard(Array.prototype, 'toLocaleString');
var flat = Array.prototype.flat;
replace(Array.prototype, 'flat', function (depth) {
stack();
var d = depth === undefined ? 1 : Number(depth);
return apply(flat, this, [d > 64 ? 64 : d]);
});
// 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.
// 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, copies = new MapJs();
if (Array.isArray(replacer)) {
keys = [];
for (var i = 0; i < replacer.length; i++) {
var k = replacer[i];
if (typeof k === 'number' || k instanceof Number || k instanceof String) k = String(k);
if (typeof k === 'string' && keys.indexOf(k) < 0) keys.push(k);
}
}
return apply(stringify, JSON, [value, function (key, v) {
stack();
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 = 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;
}, space]);
});
delete globalThis.Proxy;
delete globalThis.Reflect;
})();
// ---------------------------------------------------------------------------------------------
// The runtime runs this file once and keeps it for the thread's later evaluations, so an
// expression can't change the object model or the internals: the prototypes are frozen, and the
+6
View File
@@ -805,6 +805,12 @@ pub fn check_nesting(src: &str) -> Result<(), String> {
Ok(())
}
/// An error when plain JavaScript `src` (not rewritten: what `eval` and `Function` get at run
/// time) nests deeper than [`MAX_NESTING`] levels.
pub fn check_plain_nesting(src: &str) -> Result<(), String> {
if too_nested(&tokenize(src).0) { Err(too_deep()) } else { Ok(()) }
}
fn too_deep() -> String {
format!("Error: expression is nested too deeply (more than {MAX_NESTING} levels of brackets, or too long a chain of operators)")
}
+51 -2
View File
@@ -51,6 +51,8 @@ thread_local! {
pub static FRAMES: RefCell<Vec<Frame>> = const { RefCell::new(Vec::new()) };
/// Script runs since the outermost evaluation on this thread began (nested ones included).
pub static RUNS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// Stack address where the current script run began (0: none; see [`native_stack`]).
static STACK_BASE: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(not(target_arch = "wasm32"))]
@@ -232,17 +234,54 @@ fn native_random(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<J
Ok(JsValue::from(noise::unit(noise::mix(&parts))))
}
/// Stack a script run may use below [`run`] for natives that recurse once per level of a nested
/// value (`join`, `JSON.stringify`, …; the prelude calls [`native_stack`] per level). It is the
/// same on every thread, so a value converts or fails alike in preview, export and commands.
/// Expressions run on render, export and diagnostics threads (2 MiB, std and rayon's default)
/// and on the main thread for commands and syntax checks (1 MiB on Windows). A debug build
/// stops the nested-value tests within a fresh 512 KiB thread, which leaves the main thread
/// 512 KiB for its callers. The parser's limit ([`crate::rewrite::MAX_NESTING`]) is the tighter
/// one there: 30 levels need about 1 MiB in debug builds. On wasm32 the address of a local is on
/// the shadow stack, not the engine's call stack, so this measures nothing useful there.
const NATIVE_STACK: usize = 256 << 10;
/// An address on the current stack (on wasm32, the shadow stack).
fn stack_here() -> usize {
let marker = 0u8;
std::hint::black_box(&marker) as *const u8 as usize
}
/// `__stack()`: throw once the current run has used [`NATIVE_STACK`] (values nested so deeply
/// would overflow the stack, which aborts the app).
fn native_stack(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult<JsValue> {
let base = STACK_BASE.with(|b| b.get());
if base != 0 && base.abs_diff(stack_here()) > NATIVE_STACK {
return Err(JsNativeError::range().with_message("value is nested too deeply").into());
}
Ok(JsValue::undefined())
}
/// `__nest(src)`: throw when source handed to `eval` / `Function` at run time nests deeper than
/// the parser's stack allows (the expression text itself is checked before it is compiled).
fn native_nesting(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let src = string(args, 0, ctx)?;
crate::rewrite::check_plain_nesting(&src).map_err(|e| JsNativeError::range().with_message(e.trim_start_matches("Error: ").to_string()))?;
Ok(JsValue::undefined())
}
impl Runtime {
fn new() -> Result<Runtime, String> {
let mut ctx = Context::default();
let limits = ctx.runtime_limits_mut();
limits.set_loop_iteration_limit(LOOP_LIMIT);
limits.set_recursion_limit(400);
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 4] = [
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 6] = [
(js_string!("__stack"), 0, native_stack),
(js_string!("__h"), 6, native_host),
(js_string!("__wig"), 8, native_wiggle),
(js_string!("__noise"), 3, native_noise),
(js_string!("__rnd"), 6, native_random),
(js_string!("__nest"), 1, native_nesting),
];
for (name, len, f) in natives {
ctx.register_global_callable(name, len, NativeFunction::from_fn_ptr(f)).map_err(|e| e.to_string())?;
@@ -361,6 +400,9 @@ pub fn out_of_time() -> bool {
}
}
/// Levels of nested arrays a script result keeps.
const MAX_DETACH: usize = 4;
/// Copy a JS value out of the heap.
fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result<Out, String> {
if v.is_undefined() {
@@ -380,7 +422,7 @@ fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result<Out, String> {
}
let err = |e: boa_engine::JsError| format_error(&e.to_string(), usize::MAX);
if let Some(o) = v.as_object() {
if o.is_array() && depth < 4 {
if o.is_array() && depth < MAX_DETACH {
let arr = JsArray::from_object(o).map_err(err)?;
let n = arr.length(ctx).map_err(err)?.min(1 << 16);
let mut out = Vec::with_capacity(n as usize);
@@ -408,6 +450,11 @@ fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result<Out, String> {
return Ok(Out::Path { points, ins, outs, closed });
}
}
// Below the levels detached above, an object's text could be its whole nested content
// (`String([[[…]]])`), converted by natives that recurse once per level.
if depth >= MAX_DETACH && v.is_object() {
return Ok(Out::Object("[object]".into()));
}
Ok(Out::Object(v.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default()))
}
@@ -484,6 +531,8 @@ fn position_line(msg: &str) -> Option<usize> {
/// `__begin` with memoized answers, so nothing carries over. A script that really overflows the
/// stack fails again on the fresh context and reports the error.
pub fn run(text: &str, b: &Begin) -> Run {
// Runs don't nest (other expressions are evaluated between runs), so one base suffices.
STACK_BASE.with(|s| s.set(stack_here()));
RUNTIME.with(|r| {
let mut r = r.borrow_mut();
for fresh in [false, true] {
+1
View File
@@ -1,5 +1,6 @@
use std::time::Instant;
mod deep_nesting;
mod fuzz;
use effectcraft_color::Label;
+130
View File
@@ -0,0 +1,130 @@
//! Deep nesting the expression text check can't see (#2124, #2125): source handed to `eval` /
//! `Function` at run time, and deeply nested values that boa's natives (`join`, `JSON.stringify`,
//! proxies…) walk recursively. Each used to overflow the stack, which aborts the whole app
//! (`catch_unwind` can't catch it); now each is an expression error.
use super::*;
/// The render threads' stack size (rayon's default): what the app gets.
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<Value, String> {
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<Value, String> {
let text = text.to_string();
std::thread::Builder::new()
.stack_size(size)
.spawn(move || {
let f = fx();
f.ev(f.title, "text/sourceText", &text, 0.0)
})
.unwrap()
.join()
.unwrap()
}
fn text_of(r: Result<Value, String>) -> String {
match r {
Ok(Value::Text(d)) => d.text,
other => panic!("not text: {other:?}"),
}
}
/// A 100 000-deep array: `var a = []; … a = [a]` in a loop (under the loop limit).
const DEEP_ARRAY: &str = "var a = []; for (var i = 0; i < 100000; i++) a = [a];";
#[test]
fn eval_and_function_constructors_check_nesting() {
let deep = r#""(".repeat(1000) + "1" + ")".repeat(1000)"#;
for call in [
format!("eval({deep})"),
format!("Function({deep})()"),
format!("new Function('a', {deep})(1)"),
format!("Function('a = ' + {deep}, 'return a')()"),
"Function.apply(null, '('.repeat(1000).split('').concat(['1']))".to_string(),
format!("(function () {{}}).constructor({deep})()"),
format!("Object.getPrototypeOf(function* () {{}}).constructor({deep})"),
format!("Object.getPrototypeOf(async function () {{}}).constructor({deep})"),
format!("Object.getPrototypeOf(async function* () {{}}).constructor({deep})"),
] {
let e = eval_small_stack(&call).expect_err(&call);
assert!(e.contains("nested too deeply"), "{call}: {e}");
}
}
#[test]
fn eval_and_function_still_work() {
let f = fx();
assert_eq!(f.num("eval('1 + 2')", 0.0), 3.0);
assert_eq!(f.num("eval('[1, [2, (3)]]')[1][1]", 0.0), 3.0);
assert_eq!(f.num("new Function('a', 'b', 'return a * b')(3, 4)", 0.0), 12.0);
assert_eq!(f.num("Function('return 5')()", 0.0), 5.0);
assert_eq!(f.num("(function () {}).constructor('return 6')()", 0.0), 6.0);
assert_eq!(f.num("(function () {}) instanceof Function ? 1 : 0", 0.0), 1.0);
assert_eq!(f.num("(function () {}).constructor === Function ? 1 : 0", 0.0), 1.0);
assert_eq!(f.num("Object.getPrototypeOf(function* () { yield 7; }).constructor('yield 7')().next().value", 0.0), 7.0);
}
#[test]
fn deep_array_result_is_an_error_or_text_not_a_crash() {
// The result itself: detaching it used to `toString` it past depth 4.
let _ = eval_small_stack(&format!("{DEEP_ARRAY} a"));
// Explicit conversions inside the script.
for tail in ["a.join()", "a.toString()", "String(a)", "a + ''", "`${a}`", "a.toLocaleString()", "JSON.stringify(a)", "JSON.stringify({ a: a })"] {
let text = format!("{DEEP_ARRAY} {tail}");
let e = eval_small_stack(&text).expect_err(&text);
assert!(e.contains("nested too deeply"), "{text}: {e}");
}
// A deep array as a text style value goes through JSON.stringify in the prelude.
let _ = eval_small_stack(&format!("{DEEP_ARRAY} text.sourceText.style.setFontSize(a)"));
}
#[test]
fn deep_proxy_chain_is_an_error_not_a_crash() {
let text = "var p = {}; for (var i = 0; i < 100000; i++) p = new Proxy(p, {}); p.x";
let e = eval_small_stack(text).expect_err(text);
assert!(e.contains("Proxy") && e.contains("not defined"), "{text}: {e}");
// Neither is available to expressions; the object model's own proxies still work.
let f = fx();
assert_eq!(f.text("typeof Proxy + ' ' + typeof Reflect", 0.0), "undefined undefined");
assert_eq!(f.num("thisComp.layer('A').effect('Slider Control')('Slider')", 0.0), 42.0);
}
#[test]
fn nested_values_still_convert() {
let f = fx();
assert_eq!(f.text("[[1, 2], [3, [4, 5]]].join(';')", 0.0), "1,2;3,4,5");
assert_eq!(f.text("String([[1, 2], 3])", 0.0), "1,2,3");
assert_eq!(f.text("JSON.stringify({ a: [1, { b: [2] }] })", 0.0), r#"{"a":[1,{"b":[2]}]}"#);
assert_eq!(f.text("JSON.stringify({ a: 1, b: 2 }, ['b'])", 0.0), r#"{"b":2}"#);
assert_eq!(f.text("JSON.stringify([1, [2]], null, 1)", 0.0), "[\n 1,\n [\n 2\n ]\n]");
assert_eq!(f.text("JSON.stringify({ a: 1 }, function (k, v) { return k === 'a' ? v + 1 : v; })", 0.0), r#"{"a":2}"#);
// Nested deeper than the detached levels: still converts to its text.
assert_eq!(text_of(eval_small_stack("[[[[[[1, 2]]]]]]")), "");
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}");
let e = eval_on_stack(&text, 512 << 10).expect_err(&text);
assert!(e.contains("nested too deeply"), "{tail}: {e}");
}
}
#[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}}"#);
}