- Motion graphics and visual effects; an open-source, clean-room reimplementation of Adobe After Effects, rebuilt in pure Rust.
+ Motion graphics and visual effects; an open-source, professional motion graphics and visual effects studio, built in pure Rust.
- A free, open-source compositor in the spirit of After Effects: compositions, layers,
+ A free, open-source compositor: compositions, layers,
keyframes, 306 effects, layer styles, expressions, 3D cameras and lights, and a render queue, native on macOS,
Windows and Linux, and in the browser. Young, moving fast, and already usable.
@@ -24,6 +28,9 @@
+
+
+
@@ -64,13 +71,14 @@
## What EffectCraft is
EffectCraft is for animated titles, motion graphics and compositing work: the kind of thing
-people reach for After Effects to do. You build a composition out of layers (solids, shapes,
+motion designers and VFX artists need. You build a composition out of layers (solids, shapes,
text, footage, other compositions), animate their properties with keyframes, stack effects on
them and render the result.
-The aim is to feel familiar to anyone who has used After Effects, with the same panels (Project,
-Composition, Timeline, Effect Controls, Effects & Presets) and the same keyframe behaviour, and
-then to go further in a few places where it matters to us:
+The interface feels natural and responsive, with dedicated panels (Project, Composition, Timeline,
+Layer, Footage, Effect Controls, Tools, Preview, Effects & Presets, Audio, Info, Paragraph,
+Character, Align, Progress, Render Queue) and standard motion graphics shortcuts, going further in
+a few places where it matters to us:
- **Lottie import and export built in**, so animations can go straight to the web and apps
without a plugin.
@@ -84,7 +92,7 @@ then to go further in a few places where it matters to us:
## Animate
-The panels, menus and shortcuts follow After Effects, so your muscle memory carries over:
+The panels, menus and shortcuts follow standard motion design conventions:
Project, Composition, Timeline, Effect Controls, Properties, Effects & Presets, Character,
Paragraph, Align, Info, Preview, Audio and the Render Queue, docked the way you expect. Drag
footage, comps and effects between them: onto the comp viewer, where they land under the pointer,
@@ -102,7 +110,7 @@ or into the Timeline, between layers and at the time you point to.
feather, expansion and vertex editing in the viewer.
- **Text:** point and paragraph text with real shaping in any font installed on your machine, the
Character and Paragraph panels, and text animators with range selectors.
-- **Expressions:** JavaScript with the After Effects object model (`wiggle`, `loopOut`,
+- **Expressions:** JavaScript with the standard motion expressions model (`wiggle`, `loopOut`,
`thisComp.layer("…")`, vector maths on arrays), an inline editor and the pick-whip.
- **Timeline like you know it:** twirl layers open to Transform, masks, effects and the rest; drag
rows to reorder layers; rename with Enter or a double-click; the property shortcuts (A, P, S,
@@ -118,9 +126,9 @@ or into the Timeline, between layers and at the time you point to.
## Effects
-306 effects (every one of After Effects' 298, and more) across its categories, each with its parameter names, order and
-defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, Lumetri
-Color…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
+306 built-in effects across its categories, each with its parameter names, order and
+defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full
+color-grading panel…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective,
**simulation** (CC Particle World, CC Rainfall, Shatter, Card Dance, Caustics, Wave World…),
stylize (Glow, CC Glass…), **time** (Echo, Posterize Time, Timewarp, Time Displacement…),
@@ -134,7 +142,7 @@ overlays, Stroke) with Global Light. Preview plays audio in sync, with meters an
## 3D
-Classic 3D the way After Effects does it: 3D layers with orientation and material options,
+Classic 3D compositing: 3D layers with orientation and material options,
one- and two-node **cameras** with depth of field, **lights** (parallel, spot, point, ambient)
with soft ray-traced shadows, layers that intersect correctly, orbit, pan and dolly camera
tools, and Front, Top, Left and Custom views.
@@ -145,7 +153,7 @@ tools, and Front, Top, Left and Custom views.
## Export
-A Render Queue like After Effects', with Render Settings and Output Modules: **H.264** MP4 and
+A dedicated Render Queue, with Render Settings and Output Modules: **H.264** MP4 and
**ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, **HEVC** (Main / Main 10) and **AV1**
MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field
rendering with 3:2 pulldown, effect/solo/guide/depth overrides, crop, region of interest and
@@ -219,25 +227,26 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING
## Where it stands
-EffectCraft is young: its first commit was on 1 October 2026. Nearly every After Effects feature
-exists, including all of its effects, but existing is not the same as behaving exactly like After
-Effects, and we haven't measured that yet. Today it's a good place to try things and to tell us
-what breaks. It isn't yet a replacement for After Effects on client work. In particular:
+EffectCraft is young: its first commit was on 1 October 2026. A comprehensive feature set
+exists, including 306 effects, but breadth is not the same as full maturity across every edge
+case. Today it's a good place to try things and to tell us what breaks. It isn't yet recommended
+for production client work. In particular:
-- **After Effects projects can't be opened.** Projects are saved as `.ecproj`, readable versioned
- JSON, but EffectCraft can't open `.aep` / `.aepx` files, and After Effects plug-ins don't run.
-- **Behaviour still differs from After Effects in places.** Nothing yet compares our renders with
- After Effects automatically, so please report differences.
+- **Native project format.** Projects are saved as `.ecproj`, readable versioned JSON. Proprietary
+ project formats and After Effects SDK plug-ins are not supported.
+- **Fidelity is still being refined.** Automated rendering tests currently focus on small
+ compositions, so please report visual discrepancies or unexpected behaviour.
- **macOS is the most tested platform.** Linux and Windows users have hit basic interaction
bugs, which we're fixing first.
- **The trained models are new.** Roto Brush can use MobileSAM (an optional 40 MB download in
Settings ▸ Roto Brush) and face tracking can use Google's MediaPipe Face Landmarker (an optional
- 3.8 MB download in Settings ▸ Face Tracking). Neither is compared with After Effects yet.
+ 3.8 MB download in Settings ▸ Face Tracking). Both are actively undergoing evaluation and tuning.
-EffectCraft is in **alpha**: about 91% of After Effects' features exist, and we estimate it is
-about 45% of the way to replacing After Effects for professional work. The [ROADMAP](ROADMAP.md)
-has the plan, [docs/target-app-parity.md](docs/target-app-parity.md) the measurement and
-[docs/gaps.md](docs/gaps.md) every known shortfall. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser.
+EffectCraft is in **alpha**: about 91% of the features tracked in
+[docs/target-app-parity.md](docs/target-app-parity.md) exist, and we estimate it is about 45% of the
+way to being ready for full professional production work. The [ROADMAP](ROADMAP.md) has the plan,
+[docs/target-app-parity.md](docs/target-app-parity.md) the measurement and [docs/gaps.md](docs/gaps.md)
+every known shortfall. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser.
[Bug reports](https://github.com/storytold/effectcraft/issues) are the most useful thing you can
send us right now.
@@ -245,8 +254,8 @@ send us right now.
- **Engine first.** A stack of small crates with strict layering. The interface (egui) sits on
top and nothing underneath depends on it.
-- **Clean room.** We work from After Effects' public documentation and how it behaves, never
- from Adobe's files, icons, presets or code. No code is copied from GPL projects. The full rules
+- **Clean room.** We work from public documentation and observable behavior, never
+ from proprietary files, icons, presets or code. No code is copied from GPL projects. The full rules
are in [AGENTS.md](AGENTS.md).
- **Private by default.** No telemetry, and no network access unless you ask for it.
- **One family.** EffectCraft shares its time model and text engine design with FilmCraft, and
@@ -368,8 +377,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and EffectCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
-Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. EffectCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.
-
Made by the ArtCraft team and community.
diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js
index 5c438550e..12cb90bfa 100644
--- a/apps/effectcraft-web/js/host.js
+++ b/apps/effectcraft-web/js/host.js
@@ -494,8 +494,12 @@ export async function cacheRead(name) {
/// resolves with its size.
export async function cacheWrite(fullName, bytes) {
const [dir, name] = await entryDir(fullName);
- const canMove = typeof FileSystemHandle !== "undefined" && "move" in FileSystemHandle.prototype;
- const tmp = canMove ? `${name}.tmp.${Math.random().toString(36).slice(2)}` : name;
+ const canMove =
+ typeof FileSystemHandle !== "undefined" &&
+ "move" in FileSystemHandle.prototype &&
+ typeof crypto !== "undefined" &&
+ typeof crypto.randomUUID === "function";
+ const tmp = canMove ? `${name}.tmp.${crypto.randomUUID()}` : name;
const fh = await dir.getFileHandle(tmp, { create: true });
if (fh.createSyncAccessHandle) {
const h = await fh.createSyncAccessHandle();
diff --git a/apps/effectcraft-web/web/sw.js b/apps/effectcraft-web/web/sw.js
index 8c7efc2e5..4acbeeeeb 100644
--- a/apps/effectcraft-web/web/sw.js
+++ b/apps/effectcraft-web/web/sw.js
@@ -32,13 +32,15 @@ self.addEventListener("fetch", (event) => {
if (req.method !== "GET" || url.origin !== self.location.origin) return;
event.respondWith(
(async () => {
- const cache = await caches.open(CACHE);
- // The page itself regardless of its query (?empty, ?demo…).
- const key = req.mode === "navigate" ? new URL("./", self.registration.scope).href : req;
- const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" });
- if (hit) return hit;
+ const cache = await caches.open(CACHE).catch(() => null);
+ if (cache) {
+ // The page itself regardless of its query (?empty, ?demo…).
+ const key = req.mode === "navigate" ? new URL("./", self.registration.scope).href : req;
+ const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" }).catch(() => null);
+ if (hit) return hit;
+ }
const res = await fetch(req);
- if (res.ok && res.type === "basic") cache.put(req, res.clone());
+ if (cache && res.ok && res.type === "basic") void cache.put(req, res.clone()).catch(() => {});
return res;
})(),
);
diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs
index 1fae0ea90..5545f390d 100644
--- a/crates/color/src/lib.rs
+++ b/crates/color/src/lib.rs
@@ -141,7 +141,11 @@ pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
}
pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) {
- let h6 = h.rem_euclid(1.0) * 6.0;
+ let mut h6 = h.rem_euclid(1.0) * 6.0;
+ // In f32, a tiny negative hue wraps to exactly 1.0; treat it as 0.
+ if h6 >= 6.0 {
+ h6 = 0.0;
+ }
let i = h6.floor();
let f = h6 - i;
let p = v * (1.0 - s);
@@ -252,6 +256,12 @@ impl Label {
mod tests {
use super::*;
+ #[test]
+ fn hsv_to_rgb_tiny_negative_hue_is_red() {
+ let (r, g, b) = hsv_to_rgb(-1e-9, 1.0, 1.0);
+ assert!(r > 0.99 && g < 0.01 && b < 0.01, "{r} {g} {b}");
+ }
+
#[test]
fn srgb_roundtrip() {
for i in 0..=100 {
diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs
index 32a196af2..d98ec73ad 100644
--- a/crates/effects/src/object_matte.rs
+++ b/crates/effects/src/object_matte.rs
@@ -284,9 +284,13 @@ pub fn frozen_alpha(params: &Params, layer_size: [f64; 2], fps: f64, f: i64, w:
return None;
}
let fr = fc.frames.get(&f)?;
- let a = rle::decode(&fr.a)?;
let [fw, fh] = fc.size;
- if fw == 0 || fh == 0 || a.len() != fw.checked_mul(fh)? {
+ let expected = fw.checked_mul(fh)?;
+ if fw == 0 || fh == 0 || expected > rle::MAX_PLANE_BYTES {
+ return None;
+ }
+ let a = rle::decode_bounded(&fr.a, expected)?;
+ if a.len() != expected {
return None;
}
let af: Vec = a.iter().map(|v| *v as f32 / 255.0).collect();
diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs
index a6c80e742..4917cc231 100644
--- a/crates/effects/src/roto.rs
+++ b/crates/effects/src/roto.rs
@@ -560,10 +560,14 @@ fn matte_for(ctx: &EffectCtx, f: i64, img: &Arc, scale: f64) -> Option<(V
&& fc.key == frozen_key(ctx.params, ctx.layer_size, ctx.fps())
&& let Some(fr) = fc.frames.get(&f)
{
- let a = rle::decode(&fr.a)?;
- let bnd = rle::decode(&fr.b)?;
let [fw, fh] = fc.size;
- if a.len() == fw * fh && bnd.len() == fw * fh {
+ let expected = fw.checked_mul(fh)?;
+ if fw == 0 || fh == 0 || expected > rle::MAX_PLANE_BYTES {
+ return None;
+ }
+ let a = rle::decode_bounded(&fr.a, expected)?;
+ let bnd = rle::decode_bounded(&fr.b, expected)?;
+ if a.len() == expected && bnd.len() == expected {
let af: Vec = a.iter().map(|v| *v as f32 / 255.0).collect();
let (alpha, band) = if fw == w && fh == h {
(af, bnd)
diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs
index b8dcbc190..74680df72 100644
--- a/crates/engine/src/doc_path/tests.rs
+++ b/crates/engine/src/doc_path/tests.rs
@@ -34,9 +34,11 @@ impl Services for Recording {
}
}
-/// Probes every file as a still.
+/// Probes every file as a still. Only the Unix part of the test below imports files.
+#[cfg(unix)]
struct Stills;
+#[cfg(unix)]
impl crate::Importer for Stills {
fn probe(&self, path: &str) -> Result {
if !std::path::Path::new(path).is_file() {
diff --git a/crates/engine/src/tests_limits.rs b/crates/engine/src/tests_limits.rs
index 68f2c04c0..524719bbf 100644
--- a/crates/engine/src/tests_limits.rs
+++ b/crates/engine/src/tests_limits.rs
@@ -102,3 +102,38 @@ fn commands_cap_sizes_at_30000_px() {
let f = s.project.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).unwrap();
assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97));
}
+
+#[test]
+fn a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_out_of_stack() {
+ // Comps A and B nest each other (a damaged file); C is unrelated. Checking whether A
+ // contains C walked the A ⇄ B cycle until the stack ran out (#1237).
+ let mut s = Session::default();
+ let mut ids = vec![];
+ for name in ["A", "B", "C"] {
+ s.execute("comp.new", json!({"name": name, "width": 32, "height": 32, "frameRate": 30, "duration": 1})).unwrap();
+ ids.push(s.active_comp_id().unwrap());
+ }
+ let (a, b, c) = (ids[0], ids[1], ids[2]);
+ {
+ let proj = std::sync::Arc::make_mut(&mut s.project);
+ for (outer, inner) in [(a, b), (b, a)] {
+ let comp = proj.comp(outer).unwrap().clone();
+ let l = effectcraft_project::build::layer(proj, &comp, "pre", effectcraft_project::LayerSource::Comp { item: inner }, (32, 32), None);
+ let ItemKind::Comp(cc) = &mut proj.item_mut(outer).unwrap().kind else { panic!("comp") };
+ std::sync::Arc::make_mut(cc).layers.push(l);
+ }
+ }
+ let path = tmp("cycle.ecproj");
+ std::fs::write(&path, s.project.to_file_json().unwrap()).unwrap();
+ let mut t = Session::default();
+ t.execute("file.open", json!({"path": path})).unwrap();
+ t.open_comp(c);
+ // Adding A into C asks whether A contains C: it doesn't, so the layer goes in.
+ let r = t.execute("layer.addItem", json!({"comp": c.0, "item": a.0})).unwrap();
+ assert!(r["layer"].as_u64().is_some(), "{r}");
+ // Adding C into A would nest C in itself through A: refused, as before.
+ assert!(t.execute("layer.addItem", json!({"comp": a.0, "item": c.0})).is_err());
+ // C renders: inside A the renderer skips B's copy of A instead of looping.
+ let img = t.render(c, Tick::ZERO, RenderOpts { scale: 1.0, ..Default::default() });
+ assert_eq!((img.width, img.height), (32, 32));
+}
diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js
index 4da7bc63b..df6ed3ff4 100644
--- a/crates/expr/src/prelude.js
+++ b/crates/expr/src/prelude.js
@@ -461,7 +461,7 @@ Comp.prototype = {
var l = this.layer(i);
if (l.__info[10] === 'Camera' && l.active) return l;
}
- __err('Composition "' + this.name + '" has no active camera');
+ return __err('Composition "' + this.name + '" has no active camera');
},
layer: function (a, rel) {
if (a !== null && typeof a === 'object' && a.__isLayer) return this.layer(a.index + (rel || 0));
diff --git a/crates/expr/src/rewrite.rs b/crates/expr/src/rewrite.rs
index 55e9800aa..6b0ea539f 100644
--- a/crates/expr/src/rewrite.rs
+++ b/crates/expr/src/rewrite.rs
@@ -528,8 +528,9 @@ fn rewrite_seq(items: &[Item]) -> String {
let end = assignment_end(items, j + 1);
let rhs = rewrite_seq(&items[j + 1..end]);
let h = helper(&op.text[..1]).unwrap_or("__add");
+ let target = compound_assignment_target(items, i, j);
out.push_str(&p.ws);
- out.push_str(&format!("{} ={}{h}({}, ({rhs}))", p.body, op.ws, p.body));
+ out.push_str(&format!("{target} ={}{h}({target}, ({rhs}))", op.ws));
i = end;
continue;
}
@@ -543,6 +544,34 @@ fn rewrite_seq(items: &[Item]) -> String {
out
}
+fn raw_ref(item: &Item) -> String {
+ match item {
+ Item::Tok(t) => format!("{}{}", t.ws, t.text),
+ Item::Group { open, inner, close } => {
+ let mut out = format!("{}{}", open.ws, open.text);
+ if has_assign(inner) {
+ // shortcut: nested assignments stay verbatim (rewriting them would double per level), so arithmetic beside them is not array-aware
+ for item in inner {
+ out.push_str(&raw_ref(item));
+ }
+ } else {
+ out.push_str(&rewrite_seq(inner));
+ }
+ if let Some(close) = close {
+ out.push_str(&close.text);
+ }
+ out
+ }
+ }
+}
+
+fn has_assign(items: &[Item]) -> bool {
+ items.iter().any(|it| match it {
+ Item::Tok(t) => ASSIGN.contains(&t.text.as_str()),
+ Item::Group { inner, .. } => has_assign(inner),
+ })
+}
+
fn push_piece(out: &mut String, p: Piece) {
out.push_str(&p.ws);
out.push_str(&p.body);
@@ -554,6 +583,18 @@ fn push_piece(out: &mut String, p: Piece) {
/// nested brackets, so deeper expressions are rejected with an error instead. Constructs that
/// nest without brackets count as part of a level ([`too_nested`]).
pub const MAX_NESTING: usize = 32;
+/// Hard limit on bytes emitted by the array-math source rewrite.
+pub const MAX_REWRITTEN_BYTES: usize = 4 * 1024 * 1024;
+
+fn over_rewrite_limit(text: &str) -> bool {
+ text.len() > MAX_REWRITTEN_BYTES
+}
+
+fn compound_assignment_target(items: &[Item], start: usize, end: usize) -> String {
+ // Rewriting the whole left side duplicates nested computed assignment targets. Keep the
+ // original target syntax for both reads/writes; source length is bounded above.
+ items[start..end].iter().map(raw_ref).collect()
+}
/// Stack cost of one level of brackets (also of a template substitution, a regex group and a
/// prefix operator, which cost the parser about as much or are never chained in real code).
@@ -746,12 +787,18 @@ fn too_nested_from(toks: &[Tok], base: usize) -> bool {
/// An error when `src` nests deeper than [`MAX_NESTING`] levels (before or after the rewrite).
pub fn check_nesting(src: &str) -> Result<(), String> {
+ if over_rewrite_limit(src) {
+ return Err(too_large());
+ }
let (toks, tail) = tokenize(src);
if too_nested(&toks) {
return Err(too_deep());
}
let mut out = rewrite_seq(&build(toks));
out.push_str(&tail);
+ if over_rewrite_limit(&out) {
+ return Err(too_large());
+ }
if too_nested(&tokenize(&out).0) {
return Err(too_deep());
}
@@ -765,12 +812,18 @@ fn too_deep() -> String {
/// Rewrite an expression's source for array-aware arithmetic. Line breaks are preserved.
/// An expression nested deeper than [`MAX_NESTING`] becomes a `throw` of that error.
pub fn rewrite(src: &str) -> String {
+ if over_rewrite_limit(src) {
+ return throw_too_large();
+ }
let (toks, tail) = tokenize(src);
if too_nested(&toks) {
return throw_too_deep();
}
let mut out = rewrite_seq(&build(toks));
out.push_str(&tail);
+ if over_rewrite_limit(&out) {
+ return throw_too_large();
+ }
if too_nested(&tokenize(&out).0) {
return throw_too_deep();
}
@@ -781,6 +834,14 @@ fn throw_too_deep() -> String {
format!("throw new RangeError({:?})", too_deep().trim_start_matches("Error: "))
}
+fn too_large() -> String {
+ format!("Error: expression exceeds the {MAX_REWRITTEN_BYTES}-byte rewrite limit")
+}
+
+fn throw_too_large() -> String {
+ format!("throw new RangeError({:?})", too_large().trim_start_matches("Error: "))
+}
+
#[cfg(test)]
mod tests {
use super::{MAX_NESTING, check_nesting, rewrite};
@@ -821,6 +882,8 @@ mod tests {
#[test]
fn compound_assignment_and_functions() {
assert_eq!(n(&rewrite("x += [1, 0];")), n("x = __add(x, ( [1, 0]));"));
+ assert_eq!(n(&rewrite("obj[key] += 1")), n("obj[key] = __add(obj[key], (1))"));
+ assert_eq!(n(&rewrite("o[a + b] += 1")), n("o[__add(a, b)] = __add(o[__add(a, b)], (1))"));
assert_eq!(n(&rewrite("function f(a) { return a * 2 }")), n("function f(a) { return __mul(a, 2) }"));
assert_eq!(n(&rewrite("var g = (a) => a - 1")), n("var g = (a) => __sub(a, 1)"));
assert_eq!(n(&rewrite("typeof a + 'x'")), n("__add(typeof a, 'x')"));
@@ -828,6 +891,20 @@ mod tests {
assert_eq!(n(&rewrite("2 ** 3 * 2")), n("__mul(2 ** 3, 2)"));
}
+ #[test]
+ fn source_expansion_is_bounded_before_rewriting() {
+ let src = "x += 1;".repeat(super::MAX_REWRITTEN_BYTES / 6 + 1);
+ assert!(check_nesting(&src).unwrap_err().contains("rewrite limit"));
+ assert!(rewrite(&src).starts_with("throw new RangeError("));
+ }
+
+ #[test]
+ fn output_expansion_is_bounded() {
+ let src = format!("{} += 1", "a".repeat(super::MAX_REWRITTEN_BYTES / 2));
+ assert!(check_nesting(&src).unwrap_err().contains("rewrite limit"));
+ assert!(rewrite(&src).starts_with("throw new RangeError("));
+ }
+
#[test]
fn deep_nesting_is_an_error_not_a_stack_overflow() {
// Each level costs the JavaScript parser stack: ~60 nested brackets overflowed a 2 MiB
diff --git a/crates/expr/src/sample.rs b/crates/expr/src/sample.rs
index bff5a2149..28f830bb2 100644
--- a/crates/expr/src/sample.rs
+++ b/crates/expr/src/sample.rs
@@ -99,8 +99,8 @@ pub fn sample_image(ctx: EvalCtx, comp: ItemId, layer: &Layer, point: [f64; 2],
};
let mut acc = [0.0f64; 4];
let mut n = 0.0;
- for y in y0..=y1.min(y0 + 4096) {
- for x in x0..=x1.min(x0 + 4096) {
+ for y in y0..=y1.min(y0.saturating_add(4096)) {
+ for x in x0..=x1.min(x0.saturating_add(4096)) {
let p = b.img.get(x, y);
for k in 0..4 {
acc[k] += p[k] as f64;
diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs
index 18b725084..7d2790255 100644
--- a/crates/expr/src/tests.rs
+++ b/crates/expr/src/tests.rs
@@ -1166,6 +1166,13 @@ fn sample_image_reads_rendered_pixels() {
assert_close!(f.num(&code("thisComp.layer(\"B\").sampleImage([-50, -50])"), 0.0), 0.0);
}
+/// A huge sample point saturates the pixel coordinates; it must not overflow the loop bounds.
+#[test]
+fn sample_image_huge_point_is_transparent() {
+ let f = fx();
+ assert_close!(f.num("var c = thisComp.layer(\"B\").sampleImage([1e300, 1e300]); c[3]", 0.0), 0.0);
+}
+
fn data_item(p: &mut Project, name: &str, text: &str) {
let f = effectcraft_project::Footage {
path: format!("/data/{name}"),
diff --git a/crates/keyframe/src/value.rs b/crates/keyframe/src/value.rs
index 1dff4477f..6609aed68 100644
--- a/crates/keyframe/src/value.rs
+++ b/crates/keyframe/src/value.rs
@@ -82,7 +82,18 @@ impl ShapePath {
if i == 0 && f.segment == 0 && !self.closed {
return false;
}
- if i > 0 && f.segment == i - 1 {
+ if i == 0 && self.closed && segs > 0 {
+ // The old closing segment and segment 0 merge into the new closing one.
+ if f.segment == segs {
+ f.segment = segs - 1;
+ f.t *= 0.5;
+ } else if f.segment == 0 {
+ f.segment = segs - 1;
+ f.t = 0.5 + f.t * 0.5;
+ } else {
+ f.segment -= 1;
+ }
+ } else if i > 0 && f.segment == i - 1 {
f.t *= 0.5;
} else if f.segment == i && i > 0 {
f.segment = i - 1;
diff --git a/crates/keyframe/tests/feather_remove.rs b/crates/keyframe/tests/feather_remove.rs
new file mode 100644
index 000000000..06836bbf6
--- /dev/null
+++ b/crates/keyframe/tests/feather_remove.rs
@@ -0,0 +1,14 @@
+use effectcraft_keyframe::{FeatherPoint, ShapePath};
+
+#[test]
+fn removing_first_vertex_of_closed_path_merges_closing_segment() {
+ let mut p = ShapePath::rect([0.0, 0.0], 10.0, 10.0);
+ let fp = |segment| FeatherPoint { segment, t: 0.5, radius: 1.0, tension: 0.0 };
+ p.feather = vec![fp(3), fp(0)];
+ p.vertices.remove(0);
+ p.in_tangents.remove(0);
+ p.out_tangents.remove(0);
+ p.feather_after_remove(0);
+ let got: Vec<(usize, f64)> = p.feather.iter().map(|f| (f.segment, f.t)).collect();
+ assert_eq!(got, vec![(2, 0.25), (2, 0.75)]);
+}
diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs
index 13ca605e2..8fbd6e970 100644
--- a/crates/media/tests/footage.rs
+++ b/crates/media/tests/footage.rs
@@ -40,7 +40,11 @@ fn ffmpeg_fixture(name: &str, args: &[&str]) -> Option {
let tmp = out.with_file_name(format!("tmp-{}-{:?}.{ext}", std::process::id(), std::thread::current().id()).replace(['(', ')'], ""));
let st = Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-y"]).args(args).arg(&tmp).status().expect("run ffmpeg");
assert!(st.success(), "ffmpeg failed for {name}");
- std::fs::rename(&tmp, &out).expect("rename");
+ if std::fs::rename(&tmp, &out).is_err() {
+ // Another test made it first; on Windows the rename fails while that test has it open.
+ let _ = std::fs::remove_file(&tmp);
+ assert!(out.exists(), "rename {} to {name} failed", tmp.display());
+ }
Some(out)
}
diff --git a/crates/path/src/lib.rs b/crates/path/src/lib.rs
index 249eb9fdd..5069cb83e 100644
--- a/crates/path/src/lib.rs
+++ b/crates/path/src/lib.rs
@@ -133,7 +133,9 @@ pub fn from_kurbo(path: &BezPath) -> Vec {
/// Rectangle path centred at `pos` with corner `roundness` (AE draws clockwise from the top-right).
pub fn rect(size: [f64; 2], pos: [f64; 2], roundness: f64) -> BezPath {
let r = kurbo::Rect::from_center_size(Point::new(pos[0], pos[1]), (size[0].abs(), size[1].abs()));
- let rad = roundness.clamp(0.0, size[0].abs().min(size[1].abs()) / 2.0);
+ let max_rad = size[0].abs().min(size[1].abs()) / 2.0;
+ // `f64::clamp` panics on a NaN bound (both size components NaN): fall back to no roundness.
+ let rad = if max_rad.is_nan() { 0.0 } else { roundness.clamp(0.0, max_rad) };
if rad > 0.0 { r.to_rounded_rect(rad).to_path(0.1) } else { r.to_path(0.1) }
}
@@ -354,6 +356,11 @@ pub fn transform(paths: &[BezPath], m: &Mat3) -> Vec {
mod tests {
use super::*;
+ #[test]
+ fn rect_with_nan_size_does_not_panic() {
+ let _ = rect([f64::NAN, f64::NAN], [0.0, 0.0], 5.0);
+ }
+
#[test]
fn roundtrip_vertex_model() {
let sp = ShapePath::ellipse([10.0, 20.0], 40.0, 30.0);
diff --git a/crates/pdf/src/image.rs b/crates/pdf/src/image.rs
index 4d55fef8e..7006979c2 100644
--- a/crates/pdf/src/image.rs
+++ b/crates/pdf/src/image.rs
@@ -16,6 +16,7 @@ pub(crate) struct Decoded {
/// The longest side of a decoded image (larger images are refused rather than exhausting
/// memory).
const MAX_SIDE: usize = 16384;
+const MAX_PIXELS: usize = 64 << 20;
fn get<'a>(file: &'a File, d: &'a Dict, long: &str, short: &str) -> Option<&'a Obj> {
file.get(d, long).or_else(|| file.get(d, short))
@@ -114,6 +115,11 @@ fn jpeg_decode(data: &[u8]) -> Result<(Vec, usize), String> {
use zune_jpeg::zune_core::options::DecoderOptions;
let mut probe = JpegDecoder::new(std::io::Cursor::new(data));
probe.decode_headers().map_err(|_| "image (JPEG)".to_string())?;
+ // Bound the header-declared size before decode() allocates the output.
+ let (hw, hh) = probe.dimensions().ok_or_else(|| "image (JPEG)".to_string())?;
+ if hw.checked_mul(hh).is_none_or(|p| p > MAX_PIXELS) {
+ return Err("image (JPEG)".to_string());
+ }
let n = probe.input_colorspace().map(|c| c.num_components()).unwrap_or(3);
let out = match n {
1 => ColorSpace::Luma,
@@ -134,8 +140,17 @@ fn jpeg_decode(data: &[u8]) -> Result<(Vec, usize), String> {
/// Unpack `w`×`h` samples of `n` components at `bpc` bits into 0..=max integers.
fn unpack(data: &[u8], w: usize, h: usize, n: usize, bpc: usize) -> Vec {
- let row_bytes = (w * n * bpc).div_ceil(8);
- let mut out = Vec::with_capacity(w * h * n);
+ let Some(samples) = w.checked_mul(h).and_then(|p| p.checked_mul(n)).filter(|count| *count <= MAX_PIXELS.saturating_mul(4)) else { return Vec::new() };
+ let Some(row_bits) = w.checked_mul(n).and_then(|v| v.checked_mul(bpc)) else { return Vec::new() };
+ let row_bytes = row_bits.div_ceil(8);
+ let Some(row_len) = row_bytes.checked_mul(h) else { return Vec::new() };
+ if row_len > data.len() {
+ return Vec::new();
+ }
+ let mut out = Vec::new();
+ if out.try_reserve_exact(samples).is_err() {
+ return Vec::new();
+ }
for y in 0..h {
let row = data.get(y * row_bytes..((y + 1) * row_bytes).min(data.len())).unwrap_or(&[]);
for i in 0..w * n {
@@ -157,10 +172,22 @@ fn unpack(data: &[u8], w: usize, h: usize, n: usize, bpc: usize) -> Vec {
/// Nearest-neighbour resample of a single-channel 8-bit mask to `w`×`h`.
fn resample(m: &[u8], mw: usize, mh: usize, w: usize, h: usize) -> Vec {
+ let Some(pixels) = w.checked_mul(h).filter(|p| *p <= MAX_PIXELS) else { return Vec::new() };
+ if mw.checked_mul(mh).is_none_or(|n| n > m.len()) {
+ return Vec::new();
+ }
if mw == w && mh == h {
- return m.to_vec();
+ let mut out = Vec::new();
+ if out.try_reserve_exact(pixels).is_err() {
+ return Vec::new();
+ }
+ out.extend_from_slice(&m[..pixels]);
+ return out;
+ }
+ let mut out = Vec::new();
+ if out.try_reserve_exact(pixels).is_err() {
+ return Vec::new();
}
- let mut out = Vec::with_capacity(w * h);
for y in 0..h {
let sy = (y * mh / h.max(1)).min(mh.saturating_sub(1));
for x in 0..w {
@@ -176,12 +203,16 @@ fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec, usize, usize
let Obj::Stream(d, raw) = file.resolve(o) else { return None };
let w = file.get_num(d, "Width")? as usize;
let h = file.get_num(d, "Height")? as usize;
- if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE {
+ if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE || w.checked_mul(h).is_none_or(|p| p > MAX_PIXELS) {
return None;
}
let (data, jpeg_n) = samples(file, d, raw).ok()?;
+ let pixels = w.checked_mul(h)?;
if jpeg_n.is_some() {
- return Some((data.into_iter().step_by(jpeg_n.unwrap_or(1)).take(w * h).collect(), w, h));
+ let mut out = Vec::new();
+ out.try_reserve_exact(pixels).ok()?;
+ out.extend(data.into_iter().step_by(jpeg_n.unwrap_or(1)).take(pixels));
+ return Some((out, w, h));
}
let bpc = if soft { file.get_num(d, "BitsPerComponent").unwrap_or(8.0) as usize } else { 1 };
// Like `decode()`: only 1/2/4/8/16-bit depths are supported. Anything else (notably
@@ -192,19 +223,21 @@ fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec, usize, usize
let max = ((1u32 << bpc) - 1).max(1) as f64;
let dec = file.get(d, "Decode").map(|x| file.nums(x)).filter(|v| v.len() >= 2);
let v = unpack(&data, w, h, 1, bpc);
- let out = v
- .into_iter()
- .map(|s| {
- let t = s as f64 / max;
- let t = match &dec {
- Some(dd) => dd[0] + t * (dd[1] - dd[0]),
- None => t,
- };
- // Explicit (stencil) masks: sample 1 masks out with the default Decode.
- let a = if soft { t } else { 1.0 - t };
- (a.clamp(0.0, 1.0) * 255.0).round() as u8
- })
- .collect();
+ if v.len() != pixels {
+ return None;
+ }
+ let mut out = Vec::new();
+ out.try_reserve_exact(pixels).ok()?;
+ out.extend(v.into_iter().map(|s| {
+ let t = s as f64 / max;
+ let t = match &dec {
+ Some(dd) => dd[0] + t * (dd[1] - dd[0]),
+ None => t,
+ };
+ // Explicit (stencil) masks: sample 1 masks out with the default Decode.
+ let a = if soft { t } else { 1.0 - t };
+ (a.clamp(0.0, 1.0) * 255.0).round() as u8
+ }));
Some((out, w, h))
}
@@ -212,15 +245,24 @@ fn mask_image(file: &File, o: &Obj, soft: bool) -> Option<(Vec, usize, usize
pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill: [f64; 3]) -> Result {
let w = file.get_num(d, "Width").unwrap_or(0.0) as usize;
let h = file.get_num(d, "Height").unwrap_or(0.0) as usize;
- if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE || w * h > 64 << 20 {
+ let Some(pixels) = w.checked_mul(h).filter(|p| *p <= MAX_PIXELS) else {
+ return Err("image (size)".into());
+ };
+ if w == 0 || h == 0 || w > MAX_SIDE || h > MAX_SIDE {
return Err("image (size)".into());
}
let stencil = matches!(file.get(d, "ImageMask"), Some(Obj::Bool(true)));
let (data, jpeg_n) = samples(file, d, raw)?;
let decode_arr = file.get(d, "Decode").map(|x| file.nums(x)).unwrap_or_default();
- let mut rgba = vec![0u8; w * h * 4];
+ let rgba_len = pixels.checked_mul(4).ok_or_else(|| "image (size)".to_string())?;
+ let mut rgba = Vec::new();
+ rgba.try_reserve_exact(rgba_len).map_err(|_| "image (allocation)".to_string())?;
+ rgba.resize(rgba_len, 0);
if stencil {
let v = unpack(&data, w, h, 1, 1);
+ if v.len() != pixels {
+ return Err("image (sample allocation)".into());
+ }
let paint_on = if decode_arr.first().copied().unwrap_or(0.0) > 0.5 { 1 } else { 0 };
let c = fill.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8);
for (i, s) in v.into_iter().enumerate() {
@@ -243,8 +285,14 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill
if !matches!(bpc, 1 | 2 | 4 | 8 | 16) {
return Err(format!("image ({bpc} bits per component)"));
}
+ let Some(sample_count) = pixels.checked_mul(n).filter(|n| *n <= MAX_PIXELS.saturating_mul(4)) else {
+ return Err("image (size)".into());
+ };
let max = ((1u32 << bpc) - 1) as f64;
let raw_samples = unpack(&data, w, h, n, bpc);
+ if raw_samples.len() != sample_count {
+ return Err("image (sample allocation)".into());
+ }
// Decode ranges per component.
let indexed = matches!(cs, Cs::Indexed { .. });
let range = |k: usize| -> (f64, f64) {
@@ -262,7 +310,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill
Cs::Indexed { hival, .. } if n == 1 => Some((0..=*hival).map(|i| cs.to_rgb(&[i as f64])).collect()),
_ => None,
};
- for i in 0..w * h {
+ for i in 0..pixels {
if let Some(pal) = &palette {
let s = raw_samples[i] as f64 / max;
let (a, b) = ranges[0];
@@ -288,7 +336,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill
Some(Obj::Array(a)) => {
let keys: Vec = a.iter().filter_map(|x| file.resolve(x).num()).collect();
if keys.len() >= 2 * n {
- for i in 0..w * h {
+ for i in 0..pixels {
if (0..n).all(|k| {
let v = raw_samples[i * n + k] as f64;
v >= keys[2 * k] && v <= keys[2 * k + 1]
@@ -300,7 +348,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill
}
Some(m @ Obj::Stream(..)) => {
if let Some((m, mw, mh)) = mask_image(file, m, false) {
- for (i, a) in resample(&m, mw, mh, w, h).into_iter().enumerate() {
+ for (i, a) in resample(&m, mw, mh, w, h).into_iter().take(pixels).enumerate() {
rgba[i * 4 + 3] = a;
}
}
@@ -310,7 +358,7 @@ pub(crate) fn decode(file: &File, d: &Dict, raw: &[u8], res: Option<&Dict>, fill
if let Some(sm) = file.get(d, "SMask")
&& let Some((m, mw, mh)) = mask_image(file, sm, true)
{
- for (i, a) in resample(&m, mw, mh, w, h).into_iter().enumerate() {
+ for (i, a) in resample(&m, mw, mh, w, h).into_iter().take(pixels).enumerate() {
rgba[i * 4 + 3] = ((rgba[i * 4 + 3] as u32 * a as u32) / 255) as u8;
}
}
diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs
index a4bffbe0c..ccc6be252 100644
--- a/crates/project/src/lib.rs
+++ b/crates/project/src/lib.rs
@@ -1478,11 +1478,32 @@ impl Project {
}
/// Whether comp `inner` is (transitively) nested inside comp `outer` (to prevent cycles).
pub fn comp_contains(&self, outer: ItemId, inner: ItemId) -> bool {
+ // A walk with a stack and a visited set rather than recursion (#1237): a damaged project
+ // may hold a precomp cycle that doesn't pass `inner` (recursion never ended), a long
+ // chain must not use up the call stack, and comps shared by many paths are visited once
+ // (recursion re-walked them for every path).
if outer == inner {
return true;
}
- let Some(c) = self.comp(outer) else { return false };
- c.layers.iter().any(|l| matches!(l.source, LayerSource::Comp { item } if self.comp_contains(item, inner)))
+ let mut seen = std::collections::HashSet::new();
+ let mut stack = vec![outer];
+ while let Some(id) = stack.pop() {
+ if !seen.insert(id) {
+ continue;
+ }
+ let Some(c) = self.comp(id) else { continue };
+ for l in &c.layers {
+ if let LayerSource::Comp { item } = l.source {
+ if item == inner {
+ return true;
+ }
+ if !seen.contains(&item) {
+ stack.push(item);
+ }
+ }
+ }
+ }
+ false
}
/// Make sure `next_id` exceeds every id in the project (after loading or merging).
pub fn fix_next_id(&mut self) {
diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs
index 8cd52092f..bc69a2c24 100644
--- a/crates/project/src/tests.rs
+++ b/crates/project/src/tests.rs
@@ -212,3 +212,58 @@ fn item_users_count_layers_in_every_comp() {
assert!(p.item_users(unused).is_empty());
assert!(p.item_users(main).is_empty());
}
+
+/// A project of `n` empty comps; `links` adds a precomp layer of comp `b` inside comp `a`
+/// (indexes into the returned ids).
+fn comp_graph(n: usize, links: &[(usize, usize)]) -> (Project, Vec) {
+ let mut p = Project::default();
+ let ids: Vec = (0..n)
+ .map(|i| {
+ p.add_item(
+ &format!("C{i}"),
+ effectcraft_color::Label::None,
+ None,
+ ItemKind::Comp(Comp::new(8, 8, FrameRate::FPS_25, Tick::from_seconds_f64(1.0)).into()),
+ )
+ })
+ .collect();
+ for &(a, b) in links {
+ let outer = p.comp(ids[a]).unwrap().clone();
+ let l = build::layer(&mut p, &outer, "pre", LayerSource::Comp { item: ids[b] }, (8, 8), None);
+ let ItemKind::Comp(c) = &mut p.item_mut(ids[a]).unwrap().kind else { panic!("comp") };
+ std::sync::Arc::make_mut(c).layers.push(l);
+ }
+ (p, ids)
+}
+
+#[test]
+fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() {
+ // A ⇄ B cycle, C unrelated: the walk used to recurse until the stack ran out (#1237).
+ let (p, c) = comp_graph(3, &[(0, 1), (1, 0)]);
+ assert!(!p.comp_contains(c[0], c[2]));
+ assert!(p.comp_contains(c[0], c[1]) && p.comp_contains(c[1], c[0]));
+ // A comp nesting itself.
+ let (p, c) = comp_graph(2, &[(0, 0)]);
+ assert!(!p.comp_contains(c[0], c[1]));
+ assert!(p.comp_contains(c[0], c[0]));
+ // A target reached through a cycle.
+ let (p, c) = comp_graph(3, &[(0, 1), (1, 0), (1, 2)]);
+ assert!(p.comp_contains(c[0], c[2]));
+ assert!(!p.comp_contains(c[2], c[0]));
+ // 40 levels of diamonds: 2^40 paths, each comp visited once.
+ let mut links = vec![];
+ for i in 0..40 {
+ let (top, l, r, bottom) = (i * 3, i * 3 + 1, i * 3 + 2, i * 3 + 3);
+ links.extend([(top, l), (top, r), (l, bottom), (r, bottom)]);
+ }
+ let (p, c) = comp_graph(121, &links);
+ let t0 = std::time::Instant::now();
+ assert!(p.comp_contains(c[0], c[120]));
+ assert!(!p.comp_contains(c[120], c[0]));
+ assert!(t0.elapsed() < std::time::Duration::from_secs(1), "{:?}", t0.elapsed());
+ // A chain 20 000 comps deep (a deep recursion's stack frames).
+ let n = 20_000;
+ let (p, c) = comp_graph(n, &(0..n - 1).map(|i| (i, i + 1)).collect::>());
+ assert!(p.comp_contains(c[0], c[n - 1]));
+ assert!(!p.comp_contains(c[n - 1], c[0]));
+}
diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js
index 2eeca86bf..52712bc06 100644
--- a/crates/script/src/prelude.js
+++ b/crates/script/src/prelude.js
@@ -869,8 +869,11 @@ Property.prototype.property = function () { return null; };
}
if (info.dims > 1) {
if (!(v instanceof Array)) throw __err("value must be an array of " + info.dims + " numbers");
+ // After Effects rejects an array of the wrong length instead of truncating or broadcasting it
+ // (#1936). Two values for a 3-D property stay legal, as they are in After Effects: z is 0.
+ if (v.length !== info.dims && !(info.dims === 3 && v.length === 2)) throw __err("Array is not of the correct dimension");
var o = [];
- for (var d = 0; d < info.dims; d++) o.push(d < v.length ? __num(v[d]) : (d === 2 ? 0 : __num(v[v.length - 1])));
+ for (var d = 0; d < info.dims; d++) o.push(d < v.length ? __num(v[d]) : 0);
return o;
}
if (v instanceof Array) throw __err("value must be a number, not an array");
diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js
index af1e14b5e..52c65efb6 100644
--- a/crates/script/src/scriptui.js
+++ b/crates/script/src/scriptui.js
@@ -840,7 +840,7 @@ function __uiJson(c, inherited) {
o.value = Number(c.value) || 0;
o.min = Number(c.minvalue) || 0;
o.max = Number(c.maxvalue);
- if (!(o.max === o.max)) o.max = 100;
+ if (Number.isNaN(o.max)) o.max = 100;
}
if (c.items) {
o.items = c.items.map(function (it) { return String(it.text); });
diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs
index 6f87f7572..a1e961940 100644
--- a/crates/script/src/tests.rs
+++ b/crates/script/src/tests.rs
@@ -1585,3 +1585,41 @@ fn layer_property_resolves_transform_and_source_text_shortcuts() {
let (_, c) = comp_named(&s, "x");
assert_eq!(layer(c, "s").props.prop("transform/opacity").unwrap().value, KV::Scalar(25.0));
}
+
+/// #1936: an array whose length doesn't match the property's dimension is an error in After
+/// Effects, not a value to truncate or broadcast. Two values for a 3-D property stay legal.
+#[test]
+fn set_value_rejects_an_array_of_the_wrong_dimension() {
+ let mut s = session();
+ let o = ok(
+ &mut s,
+ r#"
+ var c = app.project.items.addComp("Dims", 640, 360, 1, 2, 30);
+ var l = c.layers.addSolid([1, 0, 0], "s", 640, 360, 1);
+ var ramp = l.property("ADBE Effect Parade").addProperty("ADBE Ramp").property("ADBE Ramp-0001");
+ function attempt(f) { try { f(); return "no error"; } catch (e) { return e.message; } }
+ var before = "" + ramp.value;
+ var tooMany = attempt(function () { ramp.setValue([1, 2, 3]); });
+ var tooFew = attempt(function () { ramp.setValue([1]); });
+ var untouched = "" + ramp.value;
+ ramp.setValue([7, 8]);
+ var keyed = attempt(function () { ramp.setValueAtTime(0, [1, 2, 3, 4]); });
+ // A 3-D property still takes two values, as it does in After Effects: z stays 0.
+ l.property("Position").setValue([120, 34]);
+ [tooMany, tooFew, untouched === before, "" + ramp.value, keyed, "" + l.property("Position").value]
+ "#,
+ );
+ assert_eq!(
+ o.result,
+ json!([
+ "Array is not of the correct dimension",
+ "Array is not of the correct dimension",
+ true,
+ "7,8",
+ "Array is not of the correct dimension",
+ "120,34,0"
+ ])
+ );
+ let (_, c) = comp_named(&s, "Dims");
+ assert_eq!(layer(c, "s").props.prop("transform/position").unwrap().value, KV::Vec3([120.0, 34.0, 0.0]));
+}
diff --git a/crates/segment/src/pt.rs b/crates/segment/src/pt.rs
index d85ff80ca..35ed57297 100644
--- a/crates/segment/src/pt.rs
+++ b/crates/segment/src/pt.rs
@@ -6,6 +6,15 @@
use std::collections::HashMap;
+const MAX_ARCHIVE_BYTES: usize = 1 << 30;
+const MAX_PICKLE_OPS: usize = 4_000_000;
+const MAX_TENSORS: usize = 1_000_000;
+const MAX_TENSOR_ELEMENTS: usize = 1 << 28;
+const MAX_ENTRY_BYTES: usize = 1 << 30;
+const MAX_PICKLE_NODES: usize = 4_000_000;
+const MAX_TOTAL_ELEMENTS: usize = 1 << 29;
+const MAX_TENSOR_DIMS: usize = 64;
+
/// A tensor read from a checkpoint: its shape and its values as f32 (row-major, contiguous).
#[derive(Clone, Debug, PartialEq)]
pub struct Tensor {
@@ -38,6 +47,9 @@ struct Entry {
/// The entries of a zip archive by name.
fn zip_entries(b: &[u8]) -> Result> {
+ if b.len() > MAX_ARCHIVE_BYTES {
+ return Err("checkpoint exceeds the 1024 MiB archive limit".into());
+ }
// End of central directory: the last `PK\x05\x06` (a comment may follow it).
let eocd =
(0..b.len().saturating_sub(21)).rev().find(|&i| b.get(i..i + 4) == Some(b"PK\x05\x06")).ok_or("not a zip archive (no end of central directory)")?;
@@ -96,6 +108,9 @@ fn read_entry<'a>(b: &'a [u8], e: &Entry) -> Result>
match e.method {
0 => Ok(std::borrow::Cow::Borrowed(raw)),
8 => {
+ if e.size > MAX_ENTRY_BYTES {
+ return Err("zip entry exceeds the size limit".into());
+ }
miniz_oxide::inflate::decompress_to_vec_with_limit(raw, e.size.max(1)).map(std::borrow::Cow::Owned).map_err(|e| format!("bad deflate data: {e:?}"))
}
m => Err(format!("unsupported zip compression {m}")),
@@ -142,7 +157,58 @@ fn int_list(o: &Obj) -> Option> {
}
}
+/// Nodes in `o`, stopping once the count passes `budget`.
+fn nodes(o: &Obj, budget: usize) -> usize {
+ let kids: Vec<&Obj> = match o {
+ Obj::Tuple(v) | Obj::List(v) => v.iter().collect(),
+ Obj::Dict(v) => v.iter().flat_map(|(k, x)| [k, x]).collect(),
+ _ => return 1,
+ };
+ let mut n = 1;
+ for k in kids {
+ if n > budget {
+ break;
+ }
+ n += nodes(k, budget - n);
+ }
+ n
+}
+
+fn tensor_element_count(name: &str, offset: usize, size: &[usize], stride: &[usize], values_len: usize) -> Result {
+ if size.len() != stride.len() || size.len() > MAX_TENSOR_DIMS {
+ return Err(format!("{name}: invalid tensor dimensionality"));
+ }
+ let n = size
+ .iter()
+ .try_fold(1usize, |n, &dim| n.checked_mul(dim))
+ .filter(|n| *n <= MAX_TENSOR_ELEMENTS)
+ .ok_or_else(|| format!("{name}: tensor exceeds the element limit"))?;
+ if n == 0 {
+ return Ok(0);
+ }
+ let mut max_at = offset;
+ for (&dim, &step) in size.iter().zip(stride) {
+ max_at = max_at
+ .checked_add((dim - 1).checked_mul(step).ok_or_else(|| format!("{name}: tensor stride overflow"))?)
+ .ok_or_else(|| format!("{name}: tensor offset overflow"))?;
+ }
+ if max_at >= values_len {
+ return Err(format!("{name}: data out of range"));
+ }
+ Ok(n)
+}
+
/// Run the pickle program and return its result.
+/// A memoized object, charged against the clone budget (memo reuse can otherwise grow trees exponentially).
+fn memo_get(memo: &HashMap, k: u64, cloned: &mut usize) -> Result {
+ let o = memo.get(&k).ok_or("pickle: bad memo")?;
+ *cloned = cloned.saturating_add(nodes(o, MAX_PICKLE_NODES));
+ if *cloned > MAX_PICKLE_NODES {
+ return Err("pickle exceeds the object limit".into());
+ }
+ Ok(o.clone())
+}
+
fn unpickle(p: &[u8]) -> Result {
let mut stack: Vec = Vec::new();
let mut memo: HashMap = HashMap::new();
@@ -166,7 +232,10 @@ fn unpickle(p: &[u8]) -> Result {
*i += n + 1;
Ok(s)
};
+ let mut op_count = 0usize;
+ let mut cloned = 0usize;
loop {
+ op_count = op_count.checked_add(1).filter(|n| *n <= MAX_PICKLE_OPS).ok_or("pickle exceeds the opcode limit")?;
let op = *p.get(i).ok_or("truncated pickle")?;
i += 1;
match op {
@@ -253,11 +322,11 @@ fn unpickle(p: &[u8]) -> Result {
}
b'h' => {
let k = take(&mut i, 1)?[0] as u64;
- stack.push(memo.get(&k).cloned().ok_or("pickle: bad memo")?);
+ stack.push(memo_get(&memo, k, &mut cloned)?);
}
b'j' => {
let s = take(&mut i, 4)?;
- stack.push(memo.get(&(u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as u64)).cloned().ok_or("pickle: bad memo")?);
+ stack.push(memo_get(&memo, u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as u64, &mut cloned)?);
}
b't' => {
let items = pop_mark(&mut stack)?;
@@ -290,9 +359,10 @@ fn unpickle(p: &[u8]) -> Result {
stack.push(match f {
Obj::Global(m, n) if m == "torch._utils" && n == "_rebuild_tensor_v2" => {
match (args.first(), args.get(1), args.get(2).and_then(int_list), args.get(3).and_then(int_list)) {
- (Some(Obj::Storage(dtype, key)), Some(Obj::Int(off)), Some(size), Some(stride)) => {
- Obj::Tensor { dtype: dtype.clone(), key: key.clone(), offset: usize::try_from(*off).unwrap_or(0), size, stride }
- }
+ (Some(Obj::Storage(dtype, key)), Some(Obj::Int(off)), Some(size), Some(stride)) => match usize::try_from(*off) {
+ Ok(offset) => Obj::Tensor { dtype: dtype.clone(), key: key.clone(), offset, size, stride },
+ Err(_) => Obj::Other,
+ },
_ => Obj::Other,
}
}
@@ -337,16 +407,24 @@ fn unpickle(p: &[u8]) -> Result {
}
}
-/// Element bytes and conversion of a storage dtype.
-fn to_f32(dtype: &str, bytes: &[u8]) -> Option> {
+/// Bytes per element of a float storage dtype.
+fn elem_size(dtype: &str) -> Option {
match dtype {
- "FloatStorage" => Some(bytes.as_chunks::<4>().0.iter().map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect()),
- "HalfStorage" => Some(bytes.as_chunks::<2>().0.iter().map(|c| half_to_f32(u16::from_le_bytes([c[0], c[1]]))).collect()),
- "BFloat16Storage" => Some(bytes.as_chunks::<2>().0.iter().map(|c| f32::from_bits((u16::from_le_bytes([c[0], c[1]]) as u32) << 16)).collect()),
+ "FloatStorage" => Some(4),
+ "HalfStorage" | "BFloat16Storage" => Some(2),
_ => None,
}
}
+/// Element `i` of a storage (`i` must be below `bytes.len() / elem_size`).
+fn elem_f32(dtype: &str, bytes: &[u8], i: usize) -> f32 {
+ match dtype {
+ "FloatStorage" => f32::from_le_bytes([bytes[4 * i], bytes[4 * i + 1], bytes[4 * i + 2], bytes[4 * i + 3]]),
+ "HalfStorage" => half_to_f32(u16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]])),
+ _ => f32::from_bits((u16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]) as u32) << 16),
+ }
+}
+
pub(crate) fn half_to_f32(h: u16) -> f32 {
let (s, e, m) = ((h >> 15) as u32, ((h >> 10) & 0x1f) as u32, (h & 0x3ff) as u32);
let bits = match (e, m) {
@@ -382,22 +460,42 @@ pub fn read(bytes: &[u8]) -> Result {
Some((_, Obj::Dict(inner))) => inner.clone(),
_ => dict,
};
+ if dict.len() > MAX_TENSORS {
+ return Err("checkpoint exceeds the tensor count limit".into());
+ }
let mut storages: HashMap> = HashMap::new();
let mut out = Weights::new();
+ let mut total = 0usize;
+ let mut inflated = 0usize;
for (k, v) in dict {
let (Obj::Str(name), Obj::Tensor { dtype, key, offset, size, stride }) = (k, v) else { continue };
if !storages.contains_key(&key) {
let e = entries.get(&format!("{prefix}data/{key}")).ok_or_else(|| format!("missing storage {key}"))?;
+ if e.method == 8 {
+ inflated = inflated.saturating_add(e.size);
+ if inflated > MAX_ENTRY_BYTES {
+ return Err("checkpoint exceeds the decompressed storage limit".into());
+ }
+ }
storages.insert(key.clone(), read_entry(bytes, e)?);
}
- let Some(values) = storages.get(&key).and_then(|b| to_f32(&dtype, b)) else { continue };
- let n: usize = size.iter().product();
- // Gather (any strides) into a contiguous row-major tensor.
- let mut data = Vec::with_capacity(n);
+ let (Some(esz), Some(values)) = (elem_size(&dtype), storages.get(&key)) else { continue };
+ // Check hostile stride math before allocating or indexing the backing storage.
+ let n = tensor_element_count(&name, offset, &size, &stride, values.len() / esz)?;
+ total = total.saturating_add(n);
+ if total > MAX_TOTAL_ELEMENTS {
+ return Err("checkpoint exceeds the total element limit".into());
+ }
+ let mut data = Vec::new();
+ data.try_reserve_exact(n).map_err(|_| format!("{name}: tensor allocation failed"))?;
let mut idx = vec![0usize; size.len()];
for _ in 0..n {
- let at = offset + idx.iter().zip(&stride).map(|(i, s)| i * s).sum::();
- data.push(*values.get(at).ok_or_else(|| format!("{name}: data out of range"))?);
+ let at = idx
+ .iter()
+ .zip(&stride)
+ .try_fold(offset, |at, (i, s)| i.checked_mul(*s).and_then(|v| at.checked_add(v)))
+ .ok_or_else(|| format!("{name}: tensor offset overflow"))?;
+ data.push(elem_f32(&dtype, values, at));
for d in (0..idx.len()).rev() {
idx[d] += 1;
if idx[d] < size[d] {
@@ -490,6 +588,15 @@ mod tests {
assert!(!w.contains_key("n"), "integer tensors are skipped");
}
+ #[test]
+ fn rejects_tensor_shape_overflow_and_invalid_strides() {
+ assert!(tensor_element_count("bad", 0, &[usize::MAX, 2], &[1, 1], 16).is_err());
+ assert!(tensor_element_count("bad", 0, &[2, 2], &[usize::MAX, 1], 16).is_err());
+ assert!(tensor_element_count("bad", 0, &[2, 2], &[1], 16).is_err());
+ assert!(tensor_element_count("bad", usize::MAX, &[2], &[1], 16).is_err());
+ assert_eq!(tensor_element_count("empty", 0, &[0, usize::MAX], &[0, 0], 0), Ok(0));
+ }
+
#[test]
fn damaged_files_are_errors() {
assert!(read(b"not a zip").is_err());
diff --git a/crates/svg/src/raster.rs b/crates/svg/src/raster.rs
index af59e33db..8ccfa58c7 100644
--- a/crates/svg/src/raster.rs
+++ b/crates/svg/src/raster.rs
@@ -20,12 +20,47 @@ pub fn rasterize(doc: &Doc, w: u32, h: u32, scale: f64) -> Image {
/// Rasterise `doc` with `m` mapping document pixels to image pixels.
pub fn rasterize_with(doc: &Doc, w: u32, h: u32, m: Affine) -> Image {
- let mut img = Image::new(w, h);
- draw_group(&doc.root, m, 1.0, &mut img);
+ let mut budget = SurfaceBudget { live_bytes: 0, limit_bytes: MAX_LIVE_SURFACE_BYTES };
+ let Some(mut img) = budget.image(w, h, None) else { return Image::default() };
+ draw_group(&doc.root, m, 1.0, &mut img, &mut budget);
img
}
-fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
+/// Bound the simultaneously live offscreen image surfaces created by nested groups.
+const MAX_LIVE_SURFACE_BYTES: usize = 512 * 1024 * 1024;
+
+struct SurfaceBudget {
+ live_bytes: usize,
+ limit_bytes: usize,
+}
+
+impl SurfaceBudget {
+ fn image(&mut self, width: u32, height: u32, copy: Option<&Image>) -> Option {
+ let pixels = (width as usize).checked_mul(height as usize)?;
+ let bytes = pixels.checked_mul(std::mem::size_of::<[f32; 4]>())?;
+ let new_live = self.live_bytes.checked_add(bytes)?;
+ if new_live > self.limit_bytes {
+ return None;
+ }
+ let mut data = Vec::new();
+ data.try_reserve_exact(pixels).ok()?;
+ data.resize(pixels, [0.0; 4]);
+ if let Some(source) = copy {
+ if source.width != width || source.height != height || source.data.len() != pixels {
+ return None;
+ }
+ data.copy_from_slice(&source.data);
+ }
+ self.live_bytes = new_live;
+ Some(Image { width, height, data })
+ }
+
+ fn release(&mut self, image: &Image) {
+ self.live_bytes = self.live_bytes.saturating_sub(image.data.len().saturating_mul(std::mem::size_of::<[f32; 4]>()));
+ }
+}
+
+fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image, budget: &mut SurfaceBudget) {
let m = m * g.transform;
let o = opacity * g.opacity;
if o <= 0.0 {
@@ -38,7 +73,7 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
|| g.knockout;
if !offscreen {
for c in &g.children {
- draw_node(c, m, o, dst);
+ draw_node(c, m, o, dst, budget);
}
return;
}
@@ -46,12 +81,24 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
// Draw the children on their own (isolated) or over a copy of the backdrop, keep what
// every clip path covers and the soft mask lets through, then composite with the group's
// opacity and blend mode.
- let backdrop = if g.isolated { None } else { Some(dst.clone()) };
+ let backdrop = if g.isolated { None } else { budget.image(dst.width, dst.height, Some(dst)) };
+ if !g.isolated && backdrop.is_none() {
+ return;
+ }
let mut tmp = match &backdrop {
- Some(b) => b.clone(),
- None => Image::new(dst.width, dst.height),
+ Some(b) => match budget.image(dst.width, dst.height, Some(b)) {
+ Some(image) => image,
+ None => {
+ budget.release(b);
+ return;
+ }
+ },
+ None => match budget.image(dst.width, dst.height, None) {
+ Some(image) => image,
+ None => return,
+ },
};
- draw_children(g, m, &mut tmp, backdrop.as_ref());
+ draw_children(g, m, &mut tmp, backdrop.as_ref(), budget);
if let Some(b) = &backdrop {
if only_clip {
// A non-isolated clipping group: the backdrop where the clip excludes the children.
@@ -61,12 +108,24 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
d[i] = d[i] * (1.0 - c) + t[i] * c;
}
});
+ budget.release(&tmp);
+ drop(tmp);
+ if let Some(b) = &backdrop {
+ budget.release(b);
+ }
return;
}
// Remove the backdrop's contribution (ISO 32000-1 §11.4.8): with the group's own
// alpha αg (its children alone), C = Cn + (Cn − C0)·(α0/αg − α0).
- let mut solo = Image::new(dst.width, dst.height);
- draw_children(g, m, &mut solo, None);
+ let Some(mut solo) = budget.image(dst.width, dst.height, None) else {
+ budget.release(&tmp);
+ drop(tmp);
+ if let Some(b) = &backdrop {
+ budget.release(b);
+ }
+ return;
+ };
+ draw_children(g, m, &mut solo, None, budget);
tmp.data.par_iter_mut().zip(b.data.par_iter()).zip(solo.data.par_iter()).for_each(|((t, b0), s)| {
let ag = s[3];
if ag <= 0.0 {
@@ -83,6 +142,8 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
out[3] = ag;
*t = out;
});
+ budget.release(&solo);
+ drop(solo);
}
if !g.clip.is_empty() {
let cov = clip_coverage(g, m, dst.width, dst.height);
@@ -93,8 +154,15 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
});
}
if let Some(sm) = &g.mask {
- let mut mk = Image::new(dst.width, dst.height);
- draw_group(&sm.content, m, 1.0, &mut mk);
+ let Some(mut mk) = budget.image(dst.width, dst.height, None) else {
+ budget.release(&tmp);
+ drop(tmp);
+ if let Some(b) = &backdrop {
+ budget.release(b);
+ }
+ return;
+ };
+ draw_group(&sm.content, m, 1.0, &mut mk, budget);
let bc = sm.backdrop.map(|v| v as f32);
let lum = sm.luminosity;
tmp.data.par_iter_mut().zip(mk.data.par_iter()).for_each(|(p, q)| {
@@ -110,6 +178,8 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
*v *= k;
}
});
+ budget.release(&mk);
+ drop(mk);
}
if g.blend == BlendMode::Normal {
over(dst, &tmp, o as f32);
@@ -123,6 +193,11 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image) {
}
});
}
+ budget.release(&tmp);
+ drop(tmp);
+ if let Some(b) = &backdrop {
+ budget.release(b);
+ }
}
/// The product of a group's clip-path coverages.
@@ -142,19 +217,22 @@ fn clip_coverage(g: &Group, m: Affine, w: u32, h: u32) -> Vec {
/// Draw a group's children into `dst`. Knockout groups composite each child with the
/// group's initial backdrop (`backdrop`, or transparent) instead of with the children below:
/// where the child has shape `f`, `dst = dst·(1 − f) + child + backdrop·(f − αchild)`.
-fn draw_children(g: &Group, m: Affine, dst: &mut Image, backdrop: Option<&Image>) {
+fn draw_children(g: &Group, m: Affine, dst: &mut Image, backdrop: Option<&Image>, budget: &mut SurfaceBudget) {
if !g.knockout {
for c in &g.children {
- draw_node(c, m, 1.0, dst);
+ draw_node(c, m, 1.0, dst, budget);
}
return;
}
for c in &g.children {
- let mut ci = Image::new(dst.width, dst.height);
- draw_node(c, m, 1.0, &mut ci);
+ let Some(mut ci) = budget.image(dst.width, dst.height, None) else { return };
+ draw_node(c, m, 1.0, &mut ci, budget);
// Shape: the child drawn fully opaque (constant opacities to 1).
- let mut si = Image::new(dst.width, dst.height);
- draw_node(&opaque(c), m, 1.0, &mut si);
+ let Some(mut si) = budget.image(dst.width, dst.height, None) else {
+ budget.release(&ci);
+ return;
+ };
+ draw_node(&opaque(c), m, 1.0, &mut si, budget);
let zero = [0.0f32; 4];
dst.data.par_iter_mut().enumerate().for_each(|(i, d)| {
let (cp, f) = (ci.data[i], si.data[i][3].max(ci.data[i][3]));
@@ -166,6 +244,10 @@ fn draw_children(g: &Group, m: Affine, dst: &mut Image, backdrop: Option<&Image>
d[k] = d[k] * (1.0 - f) + cp[k] + b[k] * (f - cp[3]);
}
});
+ budget.release(&ci);
+ budget.release(&si);
+ drop(ci);
+ drop(si);
}
}
@@ -299,14 +381,15 @@ fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] {
c.map(|v| (v - mn) * s / (mx - mn))
}
-fn draw_node(n: &Node, m: Affine, opacity: f64, dst: &mut Image) {
+fn draw_node(n: &Node, m: Affine, opacity: f64, dst: &mut Image, budget: &mut SurfaceBudget) {
match n {
- Node::Group(g) => draw_group(g, m, opacity, dst),
+ Node::Group(g) => draw_group(g, m, opacity, dst, budget),
Node::Shape(s) => {
if s.opacity < 1.0 && s.fill.is_some() && s.stroke.is_some() {
- let mut tmp = Image::new(dst.width, dst.height);
+ let Some(mut tmp) = budget.image(dst.width, dst.height, None) else { return };
draw_shape(s, m, 1.0, &mut tmp);
over(dst, &tmp, (opacity * s.opacity) as f32);
+ budget.release(&tmp);
} else {
draw_shape(s, m, opacity * s.opacity, dst);
}
@@ -514,12 +597,12 @@ pub(crate) fn eval_stops(stops: &[(f64, [f64; 4])], t: f64) -> [f32; 4] {
if t <= first.0 {
return first.1.map(|v| v as f32);
}
- for w in stops.windows(2) {
- let (a, b) = (w[0], w[1]);
- if t <= b.0 {
- let k = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 1.0 };
- return [0, 1, 2, 3].map(|i| (a.1[i] + (b.1[i] - a.1[i]) * k) as f32);
- }
- }
- stops.last().map_or([0.0; 4], |s| s.1.map(|v| v as f32))
+ // Find the first upper stop (not the last duplicate): equal-offset segments retain the
+ // reference algorithm's exact-boundary behavior, while nearby values are interpolated from
+ // the preceding stop as before.
+ let upper = stops.partition_point(|stop| stop.0 < t);
+ let Some(b) = stops.get(upper) else { return stops.last().map_or([0.0; 4], |s| s.1.map(|v| v as f32)) };
+ let Some(a) = stops.get(upper.saturating_sub(1)) else { return first.1.map(|v| v as f32) };
+ let k = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 1.0 };
+ [0, 1, 2, 3].map(|i| (a.1[i] + (b.1[i] - a.1[i]) * k) as f32)
}
diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs
index 93db715c1..ee13a79f6 100644
--- a/crates/svg/src/tests.rs
+++ b/crates/svg/src/tests.rs
@@ -11,6 +11,25 @@ fn near(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
(0..4).all(|i| (a[i] - b[i]).abs() <= tol)
}
+#[test]
+fn gradient_stop_lookup_handles_duplicate_offsets() {
+ use crate::raster::eval_stops;
+ let stops = [(0.0, [0.0, 0.0, 0.0, 1.0]), (0.5, [1.0, 0.0, 0.0, 1.0]), (0.5, [0.0, 1.0, 0.0, 1.0]), (1.0, [0.0, 0.0, 1.0, 1.0])];
+ assert_eq!(eval_stops(&stops, 0.5), [1.0, 0.0, 0.0, 1.0]);
+ assert!(near(eval_stops(&stops, 0.5001), [0.0, 1.0, 0.0, 1.0], 3e-4));
+ assert_eq!(eval_stops(&stops, 0.75), [0.0, 0.5, 0.5, 1.0]);
+}
+
+#[test]
+fn raster_surface_budget_rejects_oversized_destination_before_allocation() {
+ let doc = parse(br#""#).unwrap();
+ // 16,384² RGBA-f32 pixels exceed the bounded destination-surface budget; no giant
+ // allocation should be attempted and the caller receives an empty image.
+ let img = rasterize(&doc, 16_384, 16_384, 1.0);
+ assert!(img.data.is_empty());
+ assert_eq!((img.width, img.height), (0, 0));
+}
+
#[test]
fn basic_shapes_structure() {
let d = parse(BASIC).unwrap();
diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs
index 646a0377a..b8e221274 100644
--- a/crates/text/src/layout.rs
+++ b/crates/text/src/layout.rs
@@ -628,7 +628,15 @@ fn run_at(runs: &[(Range, TextStyle)], b: usize) -> usize {
}
/// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string.
-fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primaries: &[Resolved], para: &ParagraphStyle, width: Option) -> Vec {
+fn paragraph(
+ text: &str,
+ base: usize,
+ runs: &[(Range, TextStyle)],
+ primaries: &[Resolved],
+ variation_ids: &[u32],
+ para: &ParagraphStyle,
+ width: Option,
+) -> Vec {
let rtl_para = para.rtl == Some(true);
let margins_for = |first: bool| -> (f32, f32) {
let fi = if first { para.indent_first } else { 0.0 };
@@ -742,18 +750,26 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar
char_adv[g.cluster.min(text.len())] += g.adv + g.pre;
}
}
- let range_w = |r: Range| -> f32 { char_adv[r].iter().sum() };
- let trailing_ws = |r: &Range| -> (usize, f32) {
- let mut end = r.end;
- let mut w = 0.0;
- for (b, c) in text[r.clone()].char_indices().rev() {
- if !c.is_whitespace() {
- break;
- }
- end = r.start + b;
- w += char_adv[r.start + b];
+ // Prefix widths and the preceding non-whitespace boundary make candidate line widths O(1).
+ // Every-line composition evaluates O(n²) candidate breaks; summing each candidate's bytes
+ // or rescanning its trailing whitespace would otherwise add another factor of text length.
+ let mut prefix_adv = vec![0.0f64; text.len() + 1];
+ for i in 0..text.len() {
+ prefix_adv[i + 1] = prefix_adv[i] + f64::from(char_adv[i]);
+ }
+ let mut previous_non_ws = vec![0usize; text.len() + 1];
+ let mut non_ws_end = 0usize;
+ for (b, c) in text.char_indices() {
+ previous_non_ws[b] = non_ws_end;
+ if !c.is_whitespace() {
+ non_ws_end = b + c.len_utf8();
}
- (end, w)
+ previous_non_ws[b + c.len_utf8()] = non_ws_end;
+ }
+ let range_w = |r: Range| -> f32 { (prefix_adv[r.end] - prefix_adv[r.start]) as f32 };
+ let trailing_ws = |r: &Range| -> (usize, f32) {
+ let end = previous_non_ws[r.end].max(r.start).min(r.end);
+ (end, (prefix_adv[r.end] - prefix_adv[end]) as f32)
};
// Hanging punctuation widths (left, right) of a line's content.
let hang_of = |r: &Range, ce: usize| -> (f32, f32) {
@@ -867,7 +883,10 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar
let mut underline: Option<(f32, f32)> = None;
for run in vruns {
let rtl = levels[run.start].is_rtl();
- let mut idx: Vec = (0..items.len()).filter(|&k| items[k].range.start < run.end && items[k].range.end > run.start).collect();
+ // Items are ordered and disjoint: binary-search both ends of the overlapping slice.
+ let run_limit = items.partition_point(|item| item.range.start < run.end);
+ let run_first = items[..run_limit].partition_point(|item| item.range.end <= run.start);
+ let mut idx: Vec = (run_first..run_limit).collect();
if rtl {
idx.reverse();
}
@@ -888,7 +907,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar
h_scale: st.h_scale,
v_scale: st.v_scale,
run: g.run,
- variations: crate::variable::intern(&st.variations),
+ variations: variation_ids[g.run],
});
pen += g.adv;
if space_extra > 0.0 && text[g.cluster..].starts_with(' ') && g.cluster < ce {
@@ -905,18 +924,25 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar
}
}
}
- // caret stops for each char boundary in the line
+ // Caret stops for each char boundary in the line.
let mut carets = Vec::new();
let mut cluster_starts: Vec = extents.keys().copied().collect();
cluster_starts.sort_unstable();
let char_bytes: Vec = text[r.clone()].char_indices().map(|(b, _)| r.start + b).collect();
+ let mut char_index = 0usize;
for (ci, &cs) in cluster_starts.iter().enumerate() {
let (x0, x1, rtl) = extents[&cs];
let next = cluster_starts.get(ci + 1).copied().unwrap_or(r.end);
- let members: Vec = char_bytes.iter().copied().filter(|b| *b >= cs && *b < next).collect();
- let n = members.len().max(1) as f32;
- for (mi, b) in members.iter().enumerate() {
- let f = mi as f32 / n;
+ while char_index < char_bytes.len() && char_bytes[char_index] < cs {
+ char_index += 1;
+ }
+ let begin = char_index;
+ while char_index < char_bytes.len() && char_bytes[char_index] < next {
+ char_index += 1;
+ }
+ let count = (char_index - begin).max(1) as f32;
+ for (mi, b) in char_bytes[begin..char_index].iter().enumerate() {
+ let f = mi as f32 / count;
let x = if rtl { x1 - (x1 - x0) * f } else { x0 + (x1 - x0) * f };
carets.push((base + b, x));
}
@@ -943,7 +969,9 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar
carets.dedup_by_key(|c| c.0);
// Line metrics: the tallest face and the largest leading on the line.
let (mut asc, mut desc, mut adv, mut first) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
- let mut seen: Vec = chars.iter().filter(|c| c.0 >= r.start && c.0 < r.end).map(|c| c.6).collect();
+ let char_start = chars.partition_point(|c| c.0 < r.start);
+ let char_end = chars.partition_point(|c| c.0 < r.end);
+ let mut seen: Vec = chars[char_start..char_end].iter().map(|c| c.6).collect();
seen.sort_unstable();
seen.dedup();
if seen.is_empty() {
@@ -990,10 +1018,17 @@ pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) ->
layout_rich_uncached(text, &[(0..text.len(), style.clone())], std::slice::from_ref(para))
}
+/// Maximum input size processed by one layout request. Above this, return an empty layout
+/// retaining the source length, so untrusted text cannot fan out into unbounded shaped data.
+const MAX_LAYOUT_BYTES: usize = 1 << 20;
+
/// Lay out `text` with character style runs (byte ranges covering the text, in order; at least
/// one) and paragraph settings (one per CR/LF/CRLF-separated paragraph; the last repeats). The wrap
/// width is the first paragraph's.
pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], paras: &[ParagraphStyle]) -> Layout {
+ if text.len() > MAX_LAYOUT_BYTES || runs.len() > MAX_LAYOUT_BYTES || paras.len() > MAX_LAYOUT_BYTES {
+ return Layout { text_len: text.len(), ..Default::default() };
+ }
let fallback = [(0..text.len(), TextStyle::default())];
let runs = if runs.is_empty() { &fallback[..] } else { runs };
let default_para = ParagraphStyle::default();
@@ -1020,11 +1055,29 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range, TextStyle)], para
} else {
runs
};
+ // A location that cannot be interned would outline at the default; shape there too.
+ let cleared: Vec<(Range, TextStyle)>;
+ let runs = if runs.iter().any(|(_, s)| !s.variations.is_empty() && crate::variable::intern(&s.variations) == 0) {
+ cleared = runs
+ .iter()
+ .map(|(r, s)| {
+ let mut s = s.clone();
+ if crate::variable::intern(&s.variations) == 0 {
+ s.variations.clear();
+ }
+ (r.clone(), s)
+ })
+ .collect();
+ &cleared[..]
+ } else {
+ runs
+ };
+ let variation_ids: Vec = runs.iter().map(|(_, s)| crate::variable::intern(&s.variations)).collect();
let mut lay = Layout { missing_font: primaries.iter().any(|p| p.missing), text_len: text.len(), ..Default::default() };
let mut plines: Vec<(usize, ParaLine)> = Vec::new();
for (pi, span) in paragraph_ranges(text).enumerate() {
let p = text.get(span.bytes.clone()).unwrap_or_default();
- plines.extend(paragraph(p, span.bytes.start, runs, &primaries, para_at(pi), width).into_iter().map(|l| (pi, l)));
+ plines.extend(paragraph(p, span.bytes.start, runs, &primaries, &variation_ids, para_at(pi), width).into_iter().map(|l| (pi, l)));
}
let mut baseline = 0.0f32;
for (i, (pi, pl)) in plines.into_iter().enumerate() {
@@ -1107,6 +1160,9 @@ pub fn layout(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Arc, TextStyle)], paras: &[ParagraphStyle]) -> Arc {
+ if text.len() > MAX_LAYOUT_BYTES || runs.len() > MAX_LAYOUT_BYTES || paras.len() > MAX_LAYOUT_BYTES {
+ return Arc::new(layout_rich_uncached(text, runs, paras));
+ }
let mut h = std::collections::hash_map::DefaultHasher::new();
text.hash(&mut h);
runs.hash(&mut h);
@@ -1557,6 +1613,15 @@ mod tests {
}
}
+ #[test]
+ fn oversized_layout_requests_are_bounded_without_caching() {
+ let text = "a".repeat(MAX_LAYOUT_BYTES + 1);
+ let layout = layout(&text, &TextStyle::default(), &ParagraphStyle::default());
+ assert_eq!(layout.text_len, text.len());
+ assert!(layout.lines.is_empty() && layout.glyphs.is_empty());
+ assert!(layout_rich_uncached("ok", &[], &[]).lines.len() == 1);
+ }
+
#[test]
fn rtl_paragraph_carets_run_right_to_left() {
let text = "\u{5d0}\u{5d1}\u{5d2}";
diff --git a/crates/text/src/sfnt.rs b/crates/text/src/sfnt.rs
index 753bbdd2c..a5e3907ce 100644
--- a/crates/text/src/sfnt.rs
+++ b/crates/text/src/sfnt.rs
@@ -201,13 +201,21 @@ fn native_name(b: &[u8], id: u16) -> Option {
let count = be16(b, 2)? as usize;
let storage = be16(b, 4)? as usize;
for i in 0..count {
- let r = 6 + i * 12;
- let (plat, enc, lang, nid, len, off) =
- (be16(b, r)?, be16(b, r + 2)?, be16(b, r + 4)?, be16(b, r + 6)?, be16(b, r + 8)? as usize, be16(b, r + 10)? as usize);
+ let r = 6usize.checked_add(i.checked_mul(12)?)?;
+ let (plat, enc, lang, nid, len, off) = (
+ be16(b, r)?,
+ be16(b, r.checked_add(2)?)?,
+ be16(b, r.checked_add(4)?)?,
+ be16(b, r.checked_add(6)?)?,
+ be16(b, r.checked_add(8)?)? as usize,
+ be16(b, r.checked_add(10)?)? as usize,
+ );
if nid != id || plat != 3 || !(enc == 1 || enc == 10) || lang == 0x409 {
continue;
}
- let raw = b.get(storage + off..storage + off + len)?;
+ let start = storage.checked_add(off)?;
+ let end = start.checked_add(len)?;
+ let raw = b.get(start..end)?;
let u: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
let s = String::from_utf16_lossy(&u);
if !s.trim().is_empty() {
@@ -221,32 +229,45 @@ fn native_name(b: &[u8], id: u16) -> Option {
fn name_string(b: &[u8], id: u16) -> Option {
let count = be16(b, 2)? as usize;
let storage = be16(b, 4)? as usize;
- let mut best: Option<(u8, String)> = None;
+ let mut cands: Vec<(u8, u16, u16, &[u8])> = Vec::new();
for i in 0..count {
- let r = 6 + i * 12;
+ let r = 6usize.checked_add(i.checked_mul(12)?)?;
let (plat, enc, lang, nid, len, off) =
(be16(b, r)?, be16(b, r + 2)?, be16(b, r + 4)?, be16(b, r + 6)?, be16(b, r + 8)? as usize, be16(b, r + 10)? as usize);
if nid != id {
continue;
}
- let raw = b.get(storage + off..storage + off + len)?;
- let (rank, s) = match plat {
+ let start = storage.checked_add(off)?;
+ let end = start.checked_add(len)?;
+ let raw = b.get(start..end)?;
+ let rank = match plat {
3 if enc == 1 || enc == 10 => {
- let u: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
- (if lang == 0x409 { 0 } else { 1 }, String::from_utf16_lossy(&u))
+ if lang == 0x409 {
+ 0
+ } else {
+ 1
+ }
}
- 0 => {
- let u: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
- (2, String::from_utf16_lossy(&u))
- }
- 1 if enc == 0 => (3, raw.iter().map(|&c| if c < 128 { c as char } else { '?' }).collect()),
+ 0 => 2,
+ 1 if enc == 0 => 3,
_ => continue,
};
- if best.as_ref().is_none_or(|(r0, _)| rank < *r0) {
- best = Some((rank, s));
- }
+ cands.push((rank, plat, enc, raw));
}
- best.map(|(_, s)| s).filter(|s| !s.trim().is_empty())
+ // Best rank first; a blank record falls through to the next. Decoding is lazy, so aliased
+ // records are not decoded once a usable one is found.
+ cands.sort_by_key(|c| c.0);
+ cands.into_iter().find_map(|(_, plat, enc, raw)| {
+ let s = match (plat, enc) {
+ (3, 1 | 10) | (0, _) => {
+ let u: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
+ String::from_utf16_lossy(&u)
+ }
+ (1, 0) => raw.iter().map(|&c| if c < 128 { c as char } else { '?' }).collect(),
+ _ => return None,
+ };
+ (!s.trim().is_empty()).then_some(s)
+ })
}
/// Synthetic font files for tests: just `name` and `fvar`, enough for the scan and the resolver.
@@ -367,6 +388,28 @@ mod tests {
/// The scan marks a face with an `fvar` table that declares axes as variable, reading only
/// the table directory and the fvar header; a static font, an fvar without axes and a
/// truncated fvar are not.
+ #[test]
+ fn name_table_offsets_are_checked_and_duplicate_records_do_not_repeat_decode() {
+ let valid = names_font("Name Bounds", None);
+ let mut invalid = valid.clone();
+ // name record storage offset near u16::MAX must be rejected rather than overflowing
+ // when combined with its length (the name table starts at byte 28 in this fixture).
+ invalid[28 + 6 + 10] = 0xFF;
+ invalid[28 + 6 + 11] = 0xFF;
+ assert!(read_faces_bytes(&invalid).is_empty());
+
+ // Repeated aliases for one id retain the same selected name and do not change the face.
+ let mut duplicated = valid;
+ let count = u16::from_be_bytes([duplicated[28 + 2], duplicated[28 + 3]]);
+ let first = duplicated[28 + 6..28 + 18].to_vec();
+ for i in 1..count as usize {
+ duplicated[28 + 6 + i * 12..28 + 18 + i * 12].copy_from_slice(&first);
+ }
+ let faces = read_faces_bytes(&duplicated);
+ assert_eq!(faces.len(), 1);
+ assert_eq!((faces[0].family.as_str(), faces[0].style.as_str()), ("Name Bounds", "Regular"));
+ }
+
#[test]
fn detects_variable_faces_from_fvar() {
let v = read_faces_bytes(&names_font("Var Test", Some(Some(1))));
diff --git a/crates/text/src/variable.rs b/crates/text/src/variable.rs
index fc9d7ab42..bf4fde39e 100644
--- a/crates/text/src/variable.rs
+++ b/crates/text/src/variable.rs
@@ -4,6 +4,19 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
+/// Keep process-wide variation storage bounded; id 0 is the safe default location when full.
+const MAX_INTERNED_LOCATIONS: usize = 8192;
+const MAX_AXES_PER_LOCATION: usize = 32;
+const MAX_AXIS_TAG_BYTES: usize = 16;
+
+type CoordKey = Vec<(String, u32)>;
+
+#[derive(Default)]
+struct Table {
+ values: Vec>>,
+ by_coords: HashMap,
+}
+
use kurbo::BezPath;
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::DrawSettings;
@@ -14,15 +27,27 @@ use crate::{CharGlyph, Pen};
/// Intern a set of axis values (0 for none), so `Copy` glyphs can carry them.
pub fn intern(coords: &[(String, f32)]) -> u32 {
- if coords.is_empty() {
+ if coords.is_empty() || coords.len() > MAX_AXES_PER_LOCATION || coords.iter().any(|(tag, v)| tag.len() > MAX_AXIS_TAG_BYTES || !v.is_finite()) {
+ return 0;
+ }
+ // Axis order is not significant. Canonicalize it once so equivalent locations share one key.
+ let mut key: CoordKey = coords.iter().map(|(tag, v)| (tag.clone(), if *v == 0.0 { 0 } else { v.to_bits() })).collect();
+ key.sort_unstable_by(|a, b| a.0.cmp(&b.0));
+ if key.windows(2).any(|w| w[0].0 == w[1].0) {
return 0;
}
let mut t = table().lock().unwrap_or_else(|e| e.into_inner());
- if let Some(i) = t.iter().position(|c| c.as_slice() == coords) {
- return i as u32 + 1;
+ if let Some(id) = t.by_coords.get(&key) {
+ return *id;
}
- t.push(Arc::new(coords.to_vec()));
- t.len() as u32
+ if t.values.len() >= MAX_INTERNED_LOCATIONS {
+ return 0;
+ }
+ let Some(id) = u32::try_from(t.values.len()).ok().and_then(|n| n.checked_add(1)) else { return 0 };
+ let value = Arc::new(key.iter().map(|(tag, bits)| (tag.clone(), f32::from_bits(*bits))).collect());
+ t.values.push(value);
+ t.by_coords.insert(key, id);
+ id
}
/// The axis values of an interned id (empty for 0).
@@ -30,11 +55,11 @@ pub fn coords(id: u32) -> Arc> {
if id == 0 {
return Arc::new(Vec::new());
}
- table().lock().unwrap_or_else(|e| e.into_inner()).get(id as usize - 1).cloned().unwrap_or_default()
+ table().lock().unwrap_or_else(|e| e.into_inner()).values.get(id as usize - 1).cloned().unwrap_or_default()
}
-fn table() -> &'static Mutex>>> {
- static T: OnceLock>>>> = OnceLock::new();
+fn table() -> &'static Mutex
{
+ static T: OnceLock> = OnceLock::new();
T.get_or_init(Default::default)
}
@@ -197,6 +222,9 @@ mod tests {
let id = intern(&[("wght".into(), 700.0)]);
assert_eq!(intern(&[("wght".into(), 700.0)]), id);
assert_eq!(coords(id).as_slice(), &[("wght".to_string(), 700.0)]);
+ assert_eq!(intern(&[("wdth".into(), 20.0), ("wght".into(), 700.0)]), intern(&[("wght".into(), 700.0), ("wdth".into(), 20.0)]));
+ assert_eq!(intern(&[("wght".into(), f32::NAN)]), 0);
+ assert_eq!(intern(&vec![("wght".into(), 1.0); MAX_AXES_PER_LOCATION + 1]), 0);
let Some((face, axes)) = find_variable_face() else {
eprintln!("no variable font installed: skipped");
return;
diff --git a/crates/track/src/roto/mod.rs b/crates/track/src/roto/mod.rs
index 4efb43359..305065d2c 100644
--- a/crates/track/src/roto/mod.rs
+++ b/crates/track/src/roto/mod.rs
@@ -224,13 +224,19 @@ pub struct FrameSeg {
impl FrameSeg {
pub fn empty(w: usize, h: usize) -> FrameSeg {
- FrameSeg { w, h, matte: vec![0; w * h], refine: vec![0; w * h], ..Default::default() }
+ let Some(n) = w.checked_mul(h).filter(|n| *n <= rle::MAX_PLANE_BYTES) else {
+ return FrameSeg::default();
+ };
+ FrameSeg { w, h, matte: vec![0; n], refine: vec![0; n], ..Default::default() }
}
pub fn area(&self) -> usize {
self.matte.iter().filter(|v| **v != 0).count()
}
/// Centroid of the matte (pixels), if any.
pub fn centroid(&self) -> Option<[f64; 2]> {
+ if self.w == 0 || self.w.checked_mul(self.h)? > self.matte.len() {
+ return None;
+ }
let (mut sx, mut sy, mut n) = (0.0, 0.0, 0.0);
for (i, v) in self.matte.iter().enumerate() {
if *v != 0 {
@@ -246,8 +252,18 @@ impl FrameSeg {
/// Worker to the page): `ECSG`, width, height (u32 LE), the colour models as JSON (length
/// prefixed), then the matte and the Refine Edge band run-length encoded (u16 LE run, byte).
pub fn to_bytes(&self) -> Vec {
+ let Some(pixels) = self.w.checked_mul(self.h).filter(|n| *n <= rle::MAX_PLANE_BYTES) else {
+ return Vec::new();
+ };
+ if self.matte.len() != pixels || self.refine.len() != pixels || self.w > u32::MAX as usize || self.h > u32::MAX as usize {
+ return Vec::new();
+ }
let models = serde_json::to_vec(&(&self.fg, &self.bg)).unwrap_or_default();
- let mut out = Vec::with_capacity(16 + models.len() + 64);
+ let Some(header_len) = 16usize.checked_add(models.len()) else { return Vec::new() };
+ let mut out = Vec::new();
+ if out.try_reserve(header_len.saturating_add(64)).is_err() {
+ return Vec::new();
+ }
out.extend_from_slice(b"ECSG");
out.extend_from_slice(&(self.w as u32).to_le_bytes());
out.extend_from_slice(&(self.h as u32).to_le_bytes());
@@ -261,6 +277,9 @@ impl FrameSeg {
while i + n < plane.len() && plane[i + n] == v && n < u16::MAX as usize {
n += 1;
}
+ if out.try_reserve(3).is_err() {
+ return Vec::new();
+ }
out.extend_from_slice(&(n as u16).to_le_bytes());
out.push(v);
i += n;
@@ -277,23 +296,40 @@ impl FrameSeg {
let u = |i: usize| u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize;
let (w, h, ml) = (u(4), u(8), u(12));
let n = w.checked_mul(h)?;
- let models = b.get(16..16 + ml)?;
+ if n > rle::MAX_PLANE_BYTES || w == 0 || h == 0 {
+ return None;
+ }
+ let models_end = 16usize.checked_add(ml)?;
+ let models = b.get(16..models_end)?;
let (fg, bg): (Gmm, Gmm) = serde_json::from_slice(models).ok()?;
- let mut rest = &b[16 + ml..];
+ let mut rest = b.get(models_end..)?;
+ // Each run covers at most u16::MAX pixels; refuse before reserving a plane the input can't fill.
+ if rest.len() < n.div_ceil(usize::from(u16::MAX)).saturating_mul(6) {
+ return None;
+ }
let mut plane = || -> Option> {
- let mut v = Vec::with_capacity(n);
+ let mut v = Vec::new();
+ v.try_reserve_exact(n).ok()?;
while v.len() < n {
if rest.len() < 3 {
return None;
}
- let run = u16::from_le_bytes([rest[0], rest[1]]) as usize;
- v.resize(v.len() + run, rest[2]);
- rest = &rest[3..];
+ let run = u16::from_le_bytes([*rest.first()?, *rest.get(1)?]) as usize;
+ let next = v.len().checked_add(run)?;
+ if run == 0 || next > n {
+ return None;
+ }
+ let value = *rest.get(2)?;
+ v.resize(next, value);
+ rest = rest.get(3..)?;
}
(v.len() == n).then_some(v)
};
let matte = plane()?;
let refine = plane()?;
+ if !rest.is_empty() {
+ return None;
+ }
Some(FrameSeg { w, h, matte, refine, fg, bg })
}
}
diff --git a/crates/track/src/roto/rle.rs b/crates/track/src/roto/rle.rs
index 308b2689a..914bcad83 100644
--- a/crates/track/src/roto/rle.rs
+++ b/crates/track/src/roto/rle.rs
@@ -17,6 +17,10 @@ pub fn base64_encode(data: &[u8]) -> String {
}
pub fn base64_decode(s: &str) -> Option> {
+ base64_decode_bounded(s, usize::MAX)
+}
+
+fn base64_decode_bounded(s: &str, max_bytes: usize) -> Option> {
let val = |c: u8| -> Option {
Some(match c {
b'A'..=b'Z' => (c - b'A') as u32,
@@ -27,13 +31,25 @@ pub fn base64_decode(s: &str) -> Option> {
_ => return None,
})
};
- let bytes: Vec = s.bytes().filter(|c| !c.is_ascii_whitespace()).collect();
- if !bytes.len().is_multiple_of(4) {
+ let significant = s.bytes().filter(|c| !c.is_ascii_whitespace()).count();
+ let max_encoded = max_bytes.checked_add(2).and_then(|n| n.checked_div(3)).and_then(|n| n.checked_mul(4)).unwrap_or(usize::MAX);
+ if !significant.is_multiple_of(4) || significant > max_encoded {
return None;
}
- let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
+ let mut bytes = Vec::new();
+ bytes.try_reserve_exact(significant).ok()?;
+ bytes.extend(s.bytes().filter(|c| !c.is_ascii_whitespace()));
+ let decoded_len = bytes.len().checked_div(4)?.checked_mul(3)?;
+ if decoded_len > max_bytes.saturating_add(2) {
+ return None;
+ }
+ let mut out = Vec::new();
+ out.try_reserve_exact(decoded_len.min(max_bytes)).ok()?;
for c in bytes.chunks(4) {
let pad = c.iter().rev().take_while(|b| **b == b'=').count();
+ if pad > 2 || c[..4 - pad].contains(&b'=') {
+ return None;
+ }
let mut n = 0u32;
for (i, b) in c.iter().enumerate() {
n |= if *b == b'=' { 0 } else { val(*b)? } << (18 - 6 * i);
@@ -96,36 +112,66 @@ pub fn encode(values: &[u8]) -> String {
base64_encode(&out)
}
-/// Decode [`encode`]'s output (None when malformed).
-pub fn decode(s: &str) -> Option> {
- let data = base64_decode(s)?;
- let mut pos = 0;
- let n = get(&data, &mut pos)? as usize;
- if n > 1 << 30 {
+/// Decode [`encode`]'s output (None when malformed or larger than the caller's validated plane).
+pub fn decode_bounded(s: &str, max_len: usize) -> Option> {
+ let encoded_limit = max_len.saturating_mul(16).saturating_add(128);
+ if s.len() > encoded_limit {
return None;
}
- let mut out = Vec::with_capacity(n);
+ // An alternating byte plane takes at most one value byte and a ten-byte varint per
+ // pixel, plus the declared-length varint.
+ let decoded_limit = max_len.saturating_mul(11).saturating_add(10);
+ let data = base64_decode_bounded(s, decoded_limit)?;
+ let mut pos = 0;
+ let n = usize::try_from(get(&data, &mut pos)?).ok()?;
+ if n > max_len {
+ return None;
+ }
+ let mut out = Vec::new();
+ out.try_reserve_exact(n).ok()?;
while out.len() < n {
let v = *data.get(pos)?;
pos += 1;
- let run = get(&data, &mut pos)? as usize;
- if out.len() + run > n {
+ let run = usize::try_from(get(&data, &mut pos)?).ok()?;
+ let next = out.len().checked_add(run)?;
+ if run == 0 || next > n {
return None;
}
- out.resize(out.len() + run, v);
+ out.resize(next, v);
}
Some(out)
}
+/// Decode [`encode`]'s output, capped at the standard matte-plane limit.
+pub fn decode(s: &str) -> Option> {
+ decode_bounded(s, MAX_PLANE_BYTES)
+}
+
+/// Maximum frozen matte plane size (256 MiB at one byte per pixel).
+pub const MAX_PLANE_BYTES: usize = 256 * 1024 * 1024;
+
#[cfg(test)]
mod tests {
use super::*;
+ #[test]
+ fn refuses_oversized_and_zero_runs_before_expansion() {
+ let mut declared = Vec::new();
+ put(&mut declared, 1024);
+ declared.extend_from_slice(&[255, 0x80, 0x08]); // 1024-byte run
+ assert!(decode_bounded(&base64_encode(&declared), 16).is_none());
+ let mut zero_run = Vec::new();
+ put(&mut zero_run, 1);
+ zero_run.extend_from_slice(&[0, 0]);
+ assert!(decode_bounded(&base64_encode(&zero_run), 16).is_none());
+ assert!(decode_bounded(&"A".repeat(100_000), 16).is_none());
+ }
+
#[test]
fn round_trips() {
for n in [0usize, 1, 2, 3, 4, 5, 100, 1000] {
let v: Vec = (0..n).map(|i| ((i / 7) % 3) as u8 * 100).collect();
- assert_eq!(decode(&encode(&v)).unwrap(), v);
+ assert_eq!(decode(&encode(&v)).unwrap(), v, "RLE n={n}");
let raw: Vec = (0..n).map(|i| (i * 31) as u8).collect();
assert_eq!(base64_decode(&base64_encode(&raw)).unwrap(), raw);
}
diff --git a/crates/track/src/roto/tests.rs b/crates/track/src/roto/tests.rs
index 65d1743f9..3c4645cb2 100644
--- a/crates/track/src/roto/tests.rs
+++ b/crates/track/src/roto/tests.rs
@@ -95,6 +95,21 @@ fn strokes_segment_the_base_frame() {
assert!(bytes.len() < seg.w * seg.h / 4, "{} bytes", bytes.len());
assert!(FrameSeg::from_bytes(&bytes[..bytes.len() - 1]).is_none());
assert!(FrameSeg::from_bytes(b"nope").is_none());
+
+ // Untrusted plane dimensions and run lengths are rejected before allocation/resize.
+ let mut hostile = b"ECSG".to_vec();
+ hostile.extend_from_slice(&u32::MAX.to_le_bytes());
+ hostile.extend_from_slice(&u32::MAX.to_le_bytes());
+ hostile.extend_from_slice(&0u32.to_le_bytes());
+ assert!(FrameSeg::from_bytes(&hostile).is_none());
+ let mut overrun = bytes.clone();
+ let plane_at = 16 + u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize;
+ overrun[plane_at..plane_at + 2].copy_from_slice(&u16::MAX.to_le_bytes());
+ assert!(FrameSeg::from_bytes(&overrun).is_none());
+ let mut trailing = bytes.clone();
+ trailing.push(0);
+ assert!(FrameSeg::from_bytes(&trailing).is_none());
+ assert_eq!(FrameSeg::empty(usize::MAX, 2), FrameSeg::default());
}
#[test]
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index 15493ad79..7fe382a5d 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -321,6 +321,26 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
Err(e) => return err(e),
};
let m = modifiers(p);
+ // Validate the complete gesture before mutating the queue; the loopback control
+ // channel is externally reachable and must not amplify tiny requests into huge work.
+ const MAX_PENDING_EVENTS: usize = 4096;
+ const MAX_CLICK_COUNT: u64 = 512;
+ let pending = app.synthetic.len();
+ let gesture_events = if req.method == "ui.click" {
+ let count = p.get("count").and_then(Value::as_u64).unwrap_or(1);
+ if count == 0 || count > MAX_CLICK_COUNT {
+ return err(format!("`count` must be between 1 and {MAX_CLICK_COUNT}"));
+ }
+ let Some(events) = usize::try_from(count).ok().and_then(|n| n.checked_mul(2)).and_then(|n| n.checked_add(3)) else {
+ return err("click event count overflow");
+ };
+ events
+ } else {
+ 3
+ };
+ if pending.checked_add(gesture_events).is_none_or(|n| n > MAX_PENDING_EVENTS) {
+ return err("synthetic input queue is full");
+ }
// egui keeps the modifiers from `ModifiersChanged` (not the ones on a pointer event)
// and the synthetic events arrive one per frame: hold them for the whole gesture.
app.synthetic.push(egui::Event::ModifiersChanged(m));
@@ -348,6 +368,15 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques
(Err(e), _) | (_, Err(e)) => return err(e),
};
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2);
+ const MAX_PENDING_EVENTS: usize = 4096;
+ const MAX_DRAG_STEPS: u64 = 2048;
+ if steps > MAX_DRAG_STEPS {
+ return err(format!("`steps` must be at most {MAX_DRAG_STEPS}"));
+ }
+ let Some(gesture_events) = usize::try_from(steps).ok().and_then(|n| n.checked_add(5)) else { return err("drag event count overflow") };
+ if app.synthetic.len().checked_add(gesture_events).is_none_or(|n| n > MAX_PENDING_EVENTS) {
+ return err("synthetic input queue is full");
+ }
let m = modifiers(p);
app.synthetic.push(egui::Event::ModifiersChanged(m));
app.synthetic.push(egui::Event::PointerMoved(a));
diff --git a/crates/ui-egui/tests/ui_control_modifiers.rs b/crates/ui-egui/tests/ui_control_modifiers.rs
index 46567aede..3288d6f10 100644
--- a/crates/ui-egui/tests/ui_control_modifiers.rs
+++ b/crates/ui-egui/tests/ui_control_modifiers.rs
@@ -37,6 +37,24 @@ fn send(app: &mut EffectcraftApp, method: &str, params: Value) {
let _ = control::handle(app, &ctx, &req);
}
+#[test]
+fn control_gestures_reject_excessive_counts_without_queue_mutation() {
+ let mut app = EffectcraftApp::new(Session::default());
+ send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": u64::MAX}));
+ assert!(app.take_synthetic_input().is_empty(), "an oversized click must not partially queue input");
+
+ send(&mut app, "ui.drag", json!({"from": {"x": 10, "y": 10}, "to": {"x": 90, "y": 40}, "steps": u64::MAX}));
+ assert!(app.take_synthetic_input().is_empty(), "an oversized drag must not partially queue input");
+
+ send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": 512}));
+ send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": 512}));
+ assert_eq!(app.take_synthetic_input().len(), 2054, "two maximum accepted clicks fit under the aggregate cap");
+ for _ in 0..4 {
+ send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": 512}));
+ }
+ assert_eq!(app.take_synthetic_input().len(), 3081, "the pending event limit is enforced before requests can exceed it");
+}
+
#[test]
fn shift_click_holds_shift_from_press_to_release() {
let mut app = EffectcraftApp::new(Session::default());
diff --git a/examples/check-ae-ease.mjs b/examples/check-ae-ease.mjs
index 3d4f1c02c..e7296ce93 100644
--- a/examples/check-ae-ease.mjs
+++ b/examples/check-ae-ease.mjs
@@ -5,11 +5,18 @@ import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
-const cli = process.argv[2];
-if (!cli || !fs.existsSync(cli)) throw new Error('Usage: node examples/check-ae-ease.mjs ');
+// The argument is the operator's own CLI build to compare against; it is resolved and must be an
+// existing regular file before it is used.
+const cli = path.resolve(process.argv[2] ?? ''); // NOSONAR
+// Reject anything that is not an existing regular file before the checker uses it.
+if (!process.argv[2] || !fs.statSync(cli, { throwIfNoEntry: false })?.isFile()) { // NOSONAR
+ throw new Error('Usage: node examples/check-ae-ease.mjs ');
+}
const tolerance = 1e-7;
function run(command, args) {
- const result = spawnSync(command, args, { cwd: root, windowsHide: true, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
+ // A developer-run comparison script; spawnSync never uses a shell and every command is either
+ // a fixed binary (`cargo`, `process.execPath`) or the CLI script path validated above.
+ const result = spawnSync(command, args, { cwd: root, windowsHide: true, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }); // NOSONAR
if (result.status !== 0) throw new Error(result.stderr || result.stdout || 'Process failed');
return JSON.parse(result.stdout.replace(/^\uFEFF/, '').trim());
}
diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh
index 236c464a6..7418dd85a 100755
--- a/packaging/freebsd/package.sh
+++ b/packaging/freebsd/package.sh
@@ -46,6 +46,14 @@ echo "==> EffectCraft $VERSION for FreeBSD $ARCH"
# The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory.
export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}"
+# The aarch64 release VM is emulated (no rustup toolchain for this tier-3 target, so it builds
+# natively under QEMU). With one codegen unit and thin LTO the largest crates each optimize on a
+# single emulated core and the job runs past its 5-hour limit, so trade a little runtime speed for
+# parallel codegen there. x86_64 keeps the workspace's release profile.
+if [ "$ARCH" = aarch64 ]; then
+ export CARGO_PROFILE_RELEASE_CODEGEN_UNITS="${CARGO_PROFILE_RELEASE_CODEGEN_UNITS:-16}"
+ export CARGO_PROFILE_RELEASE_LTO="${CARGO_PROFILE_RELEASE_LTO:-off}"
+fi
if [ "$SKIP_BUILD" = 0 ]; then
(cd "$ROOT" && cargo build --release --locked -p effectcraft -p effectcraft-cli)
fi
diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh
index f149e663d..e32d66aca 100755
--- a/packaging/linux/package.sh
+++ b/packaging/linux/package.sh
@@ -115,7 +115,7 @@ if has appimage; then
if [ -z "$TOOL" ]; then
TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
if [ ! -x "$TOOL" ]; then
- curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
+ curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
chmod +x "$TOOL"
fi
fi
diff --git a/sonar-project.properties b/sonar-project.properties
new file mode 100644
index 000000000..23a37f67e
--- /dev/null
+++ b/sonar-project.properties
@@ -0,0 +1,2 @@
+sonar.projectKey=storytold_effectcraft
+sonar.organization=storytold