From b26a340a7e653cd2e0e70408a341e1ebbe5bc67d Mon Sep 17 00:00:00 2001
From: DWAA1660
Date: Tue, 13 Oct 2026 16:26:42 -0600
Subject: [PATCH 01/38] Project: comp_contains walks precomps with a visited
set, not recursion (#1237)
Project::comp_contains recursed through precomp layers with no visited
set or depth limit. A damaged project with a precomp cycle that doesn't
pass the queried comp (A and B nesting each other, C unrelated) recursed
until the stack ran out, which aborts the process ("stack overflow,
aborting"): it can't be caught as a command error, so unsaved work is
lost. layer.addItem checks it before adding a precomp, and the renderer
calls it for every nested comp and its audio. A long chain used the
stack the same way, and comps shared by many paths were walked once per
path (exponential for nested diamonds).
It is now an explicit stack with a visited set: same answers (a comp
contains itself; a target reached through a cycle is found), each comp
visited once, no recursion. The renderer already skips a precomp that
contains the comp being rendered, so a cyclic project now renders
instead of crashing; nothing is repaired on load.
Tests: project comp_contains_ends_on_cycles_long_chains_and_shared_
precomps (cycle with an unrelated target, self-nesting, a target through
a cycle, 40 levels of diamonds, a 20 000-comp chain); engine
a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_
out_of_stack (file.open, layer.addItem both ways, render). On main the
engine test aborts with a stack overflow.
Co-Authored-By: Claude Opus 5.5
---
crates/engine/src/tests_limits.rs | 35 ++++++++++++++++++++
crates/project/src/lib.rs | 25 ++++++++++++--
crates/project/src/tests.rs | 55 +++++++++++++++++++++++++++++++
3 files changed, 113 insertions(+), 2 deletions(-)
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/project/src/lib.rs b/crates/project/src/lib.rs
index 16d782165..27f8870d4 100644
--- a/crates/project/src/lib.rs
+++ b/crates/project/src/lib.rs
@@ -1466,11 +1466,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 c19d81ee2..508cb743d 100644
--- a/crates/project/src/tests.rs
+++ b/crates/project/src/tests.rs
@@ -206,3 +206,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]));
+}
From c7d8824e09d110a7f8e0e84ffb1849ee635c7f7e Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Sun, 18 Oct 2026 01:21:57 -0400
Subject: [PATCH 02/38] docs: drop references to competing commercial software
---
README.md | 51 +++++++++++++++++++++++++--------------------------
1 file changed, 25 insertions(+), 26 deletions(-)
diff --git a/README.md b/README.md
index 914a40907..5d93cce41 100644
--- a/README.md
+++ b/README.md
@@ -11,11 +11,11 @@
EffectCraft
- 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.
@@ -64,13 +64,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 +85,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 +103,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,7 +119,7 @@ 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
+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, Lumetri
Color…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective,
@@ -134,7 +135,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 +146,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,23 +220,23 @@ 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 third-party commercial plug-ins are not supported.
+- **Fidelity is still being refined.** Automated testing across complex rendering pipelines is
+ ongoing, 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)
+EffectCraft is in **alpha**: about 91% of planned core features 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
@@ -245,8 +246,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 +369,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.
From cb70a0cdbe4af41fe0653d17c80820fac9661775 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Sun, 18 Oct 2026 10:42:59 -0400
Subject: [PATCH 03/38] docs: replace Lumetri effect reference with generic
color grading
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 5d93cce41..e1e7f87fa 100644
--- a/README.md
+++ b/README.md
@@ -120,8 +120,8 @@ or into the Timeline, between layers and at the time you point to.
## Effects
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, Lumetri
-Color…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
+defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, Color
+Grading…), 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…),
From 99bdcde092b88f7ecfd99aa7f139aac95d8fcd34 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Sun, 18 Oct 2026 11:01:31 -0400
Subject: [PATCH 04/38] docs: add SonarCloud and CodeRabbit badges to README
---
README.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/README.md b/README.md
index 914a40907..36a4dbbfc 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,8 @@
+
+
From 4fa31b5e5b9fefa912d364c8f1f5dbffb3c2ea0e Mon Sep 17 00:00:00 2001
From: Charles Baumer <343171694+CharlesBaumer@users.noreply.github.com>
Date: Sun, 18 Oct 2026 16:12:55 +0000
Subject: [PATCH 05/38] Engine tests: clippy passes on Windows (Stills is
Unix-only)
`Stills` is only used by the `#[cfg(unix)]` part of
`footage_on_other_computers_is_never_looked_at`. On Windows it was dead
code, so `cargo xtask ci` stopped at clippy (`-D warnings`).
Co-Authored-By: Claude Opus 5.5
---
crates/engine/src/doc_path/tests.rs | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
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() {
From bd961131a455dafe5112e5f38d0026ff612b2bcf Mon Sep 17 00:00:00 2001
From: Charles Baumer <343171694+CharlesBaumer@users.noreply.github.com>
Date: Sun, 18 Oct 2026 16:23:28 +0000
Subject: [PATCH 06/38] Media tests: ffmpeg fixtures survive concurrent first
runs on Windows
Tests that share a fixture each generate it on a first run and rename
their temp file into place. On Windows the losing rename fails with
PermissionDenied while the winner has the file open. Treat a failed
rename as "another test made it first" when the fixture exists, as
`png_sequence` already does.
Co-Authored-By: Claude Opus 5.5
---
crates/media/tests/footage.rs | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
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)
}
From 20c03451c0d752c3860f805885f833565788d204 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Sun, 18 Oct 2026 12:51:59 -0400
Subject: [PATCH 07/38] docs: describe color grading panel, scope SDK plug-in
warning, qualify rendering tests, and link parity checklist
---
README.md | 19 ++++++++++---------
1 file changed, 10 insertions(+), 9 deletions(-)
diff --git a/README.md b/README.md
index e1e7f87fa..d403ac649 100644
--- a/README.md
+++ b/README.md
@@ -120,8 +120,8 @@ or into the Timeline, between layers and at the time you point to.
## Effects
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, Color
-Grading…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise,
+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…),
@@ -226,19 +226,20 @@ case. Today it's a good place to try things and to tell us what breaks. It isn't
for production client work. In particular:
- **Native project format.** Projects are saved as `.ecproj`, readable versioned JSON. Proprietary
- project formats and third-party commercial plug-ins are not supported.
-- **Fidelity is still being refined.** Automated testing across complex rendering pipelines is
- ongoing, so please report visual discrepancies or unexpected behaviour.
+ 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). Both are actively undergoing evaluation and tuning.
-EffectCraft is in **alpha**: about 91% of planned core features 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.
+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.
From 75fce6b2d8828158fdb527f2fc9ab4cddd404a16 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Sun, 18 Oct 2026 14:47:37 -0400
Subject: [PATCH 08/38] docs: put SonarCloud and CodeRabbit badges on their own
line
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index 36a4dbbfc..3582317cf 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,7 @@
+
From eca2c3279352e0b6623db982c4631704be2ca8f3 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Mon, 19 Oct 2026 20:31:18 +0200
Subject: [PATCH 09/38] Fix hsv_to_rgb returning magenta for tiny negative hues
Found while testing crates/color/src/lib.rs:143-158
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/color/src/lib.rs | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
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 {
From de3fec28ed7a3d7a700a9d8ea307779dcda99a8e Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Mon, 19 Oct 2026 22:18:54 -0400
Subject: [PATCH 10/38] Release 0.13.0
Co-Authored-By: Claude Opus 5.5
---
Cargo.lock | 72 +++++++++++++++++++++++++++---------------------------
Cargo.toml | 2 +-
2 files changed, 37 insertions(+), 37 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 412fb6672..ee9c99cf2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1163,7 +1163,7 @@ dependencies = [
[[package]]
name = "effectcraft"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"cpal",
"effectcraft-engine",
@@ -1188,7 +1188,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@@ -1200,14 +1200,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"cpu-time",
"effectcraft-automation",
@@ -1223,7 +1223,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"proptest",
"serde",
@@ -1231,7 +1231,7 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1248,7 +1248,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
@@ -1282,7 +1282,7 @@ dependencies = [
[[package]]
name = "effectcraft-export"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
@@ -1312,7 +1312,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"boa_engine",
"effectcraft-color",
@@ -1329,7 +1329,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"proptest",
"serde",
@@ -1337,7 +1337,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"bytemuck",
"effectcraft-color",
@@ -1359,14 +1359,14 @@ dependencies = [
[[package]]
name = "effectcraft-hevcenc"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -1391,7 +1391,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@@ -1410,7 +1410,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1422,7 +1422,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
@@ -1440,7 +1440,7 @@ dependencies = [
[[package]]
name = "effectcraft-media"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"craft-docpath",
"effectcraft-model",
@@ -1469,7 +1469,7 @@ dependencies = [
[[package]]
name = "effectcraft-model"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"effectcraft-geom",
"image",
@@ -1481,14 +1481,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@@ -1500,7 +1500,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"craft-pdf",
"effectcraft-raster",
@@ -1516,7 +1516,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@@ -1528,7 +1528,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1541,14 +1541,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "effectcraft-raster"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1557,7 +1557,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1579,7 +1579,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"boa_engine",
"effectcraft-engine",
@@ -1596,7 +1596,7 @@ dependencies = [
[[package]]
name = "effectcraft-segment"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"miniz_oxide 0.8.9",
"ndarray",
@@ -1605,7 +1605,7 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@@ -1617,7 +1617,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@@ -1629,7 +1629,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"proptest",
"serde",
@@ -1637,7 +1637,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
@@ -1649,7 +1649,7 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"craft-ui",
"effectcraft-color",
@@ -1681,11 +1681,11 @@ dependencies = [
[[package]]
name = "effectcraft-vp9enc"
-version = "0.12.0"
+version = "0.13.0"
[[package]]
name = "effectcraft-web"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -6625,7 +6625,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.12.0"
+version = "0.13.0"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index 7af58f009..8c6f978ef 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
-version = "0.12.0"
+version = "0.13.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
From a67ca11d1ca1cac70108b680543f093b1ea3b366 Mon Sep 17 00:00:00 2001
From: Anubhav-Rai
Date: Tue, 20 Oct 2026 20:23:41 +0530
Subject: [PATCH 11/38] script: setValue rejects an array of the wrong
dimension (#1936)
A 2-D point property took any array: `[1, 2, 3]` and `[1, 2, 3, 4]` were cut to `[1, 2]` and
`[1]` was broadcast to `[1, 1]`, each reported as success, so a script holding a wrong value
silently did something else instead of failing. After Effects raises "Array is not of the
correct dimension" and leaves the property unchanged.
The length must now match the property's dimension, with the one exception After Effects also
makes: two values for a 3-D property, where z stays 0, so `position.setValue([x, y])` keeps
working.
Co-Authored-By: Claude Opus 5
---
crates/script/src/prelude.js | 5 ++++-
crates/script/src/tests.rs | 38 ++++++++++++++++++++++++++++++++++++
2 files changed, 42 insertions(+), 1 deletion(-)
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/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]));
+}
From 9db7f3b21700fd821f215782ed1c82afe077880f Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Tue, 20 Oct 2026 11:39:09 -0400
Subject: [PATCH 12/38] Skip CodeRabbit reviews on Dependabot PRs (#1962)
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].
Co-authored-by: Claude Opus 5.5
---
.coderabbit.yaml | 6 ++++++
1 file changed, 6 insertions(+)
create mode 100644 .coderabbit.yaml
diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 000000000..2408084cf
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,6 @@
+# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
+reviews:
+ auto_review:
+ # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
+ ignore_usernames:
+ - "dependabot[bot]"
From b3ff5d4aa6754fcbe40990a3a4c6dd9fbf0deec6 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Tue, 20 Oct 2026 18:51:57 +0200
Subject: [PATCH 13/38] Fix feather points when deleting first vertex of a
closed mask path
Found while testing crates/keyframe/src/value.rs:79-95 (ShapePath::feather_after_remove, called from crates/engine/src/commands/mask.rs:330)
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/keyframe/src/value.rs | 13 ++++++++++++-
crates/keyframe/tests/feather_remove.rs | 14 ++++++++++++++
2 files changed, 26 insertions(+), 1 deletion(-)
create mode 100644 crates/keyframe/tests/feather_remove.rs
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)]);
+}
From 798b8a98e6911db3ef6a56b5a484bc72d0f062ee Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Tue, 20 Oct 2026 23:31:13 +0200
Subject: [PATCH 14/38] Fix panic in path::rect when both size components are
NaN
Found while testing crates/path/src/lib.rs:136
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/path/src/lib.rs | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
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);
From 28f97be7ee259a3d16e4baa897d7cc1d0e758930 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Wed, 21 Oct 2026 02:39:20 +0200
Subject: [PATCH 15/38] Fix integer overflow in sampleImage for huge sample
points
Found while testing crates/expr/src/sample.rs:102
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/expr/src/sample.rs | 4 ++--
crates/expr/src/tests.rs | 7 +++++++
2 files changed, 9 insertions(+), 2 deletions(-)
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}"),
From ec2d51a17a4cacc6b2907c875879f85ba895eed3 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 21 Oct 2026 13:55:18 -0400
Subject: [PATCH 16/38] ci: add SonarCloud scan workflow and config
---
sonar-project.properties | 4 ++++
1 file changed, 4 insertions(+)
create mode 100644 sonar-project.properties
diff --git a/sonar-project.properties b/sonar-project.properties
new file mode 100644
index 000000000..66faf29c3
--- /dev/null
+++ b/sonar-project.properties
@@ -0,0 +1,4 @@
+sonar.projectKey=storytold_craft-launcher
+sonar.organization=storytold
+sonar.sources=.
+sonar.exclusions=**/target/**,**/vendor/**,**/node_modules/**,**/*.min.js,**/dist/**,**/assets/**,**/docs/**
From 26b40199b72121aa7f4e55a63718e1b4dd48a859 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Wed, 21 Oct 2026 14:04:43 -0400
Subject: [PATCH 17/38] Release 0.14.0
FreeBSD aarch64: build with 16 codegen units and no LTO in the emulated release VM, which ran past
the job's 5-hour limit with the single-unit thin-LTO profile.
Co-Authored-By: Claude Opus 5.5
---
Cargo.lock | 72 ++++++++++++++++++------------------
Cargo.toml | 2 +-
packaging/freebsd/package.sh | 8 ++++
3 files changed, 45 insertions(+), 37 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index ee9c99cf2..2cd7ed3a0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1163,7 +1163,7 @@ dependencies = [
[[package]]
name = "effectcraft"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"cpal",
"effectcraft-engine",
@@ -1188,7 +1188,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@@ -1200,14 +1200,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"cpu-time",
"effectcraft-automation",
@@ -1223,7 +1223,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"proptest",
"serde",
@@ -1231,7 +1231,7 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1248,7 +1248,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
@@ -1282,7 +1282,7 @@ dependencies = [
[[package]]
name = "effectcraft-export"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
@@ -1312,7 +1312,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"boa_engine",
"effectcraft-color",
@@ -1329,7 +1329,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"proptest",
"serde",
@@ -1337,7 +1337,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"bytemuck",
"effectcraft-color",
@@ -1359,14 +1359,14 @@ dependencies = [
[[package]]
name = "effectcraft-hevcenc"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -1391,7 +1391,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@@ -1410,7 +1410,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1422,7 +1422,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
@@ -1440,7 +1440,7 @@ dependencies = [
[[package]]
name = "effectcraft-media"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"craft-docpath",
"effectcraft-model",
@@ -1469,7 +1469,7 @@ dependencies = [
[[package]]
name = "effectcraft-model"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"effectcraft-geom",
"image",
@@ -1481,14 +1481,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@@ -1500,7 +1500,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"craft-pdf",
"effectcraft-raster",
@@ -1516,7 +1516,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@@ -1528,7 +1528,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1541,14 +1541,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "effectcraft-raster"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1557,7 +1557,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1579,7 +1579,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"boa_engine",
"effectcraft-engine",
@@ -1596,7 +1596,7 @@ dependencies = [
[[package]]
name = "effectcraft-segment"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"miniz_oxide 0.8.9",
"ndarray",
@@ -1605,7 +1605,7 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@@ -1617,7 +1617,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@@ -1629,7 +1629,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"proptest",
"serde",
@@ -1637,7 +1637,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
@@ -1649,7 +1649,7 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"craft-ui",
"effectcraft-color",
@@ -1681,11 +1681,11 @@ dependencies = [
[[package]]
name = "effectcraft-vp9enc"
-version = "0.13.0"
+version = "0.14.0"
[[package]]
name = "effectcraft-web"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -6625,7 +6625,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.13.0"
+version = "0.14.0"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index 8c6f978ef..eecbbc7e3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
-version = "0.13.0"
+version = "0.14.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
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
From 714b6c29b02716dbd64ff02bd132998fa9e2600f Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 21 Oct 2026 15:46:55 -0400
Subject: [PATCH 18/38] ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
---
.github/workflows/sonarcloud.yml | 20 ++++++++++++++++++++
sonar-project.properties | 4 ----
2 files changed, 20 insertions(+), 4 deletions(-)
create mode 100644 .github/workflows/sonarcloud.yml
delete mode 100644 sonar-project.properties
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
new file mode 100644
index 000000000..ecee45a6d
--- /dev/null
+++ b/.github/workflows/sonarcloud.yml
@@ -0,0 +1,20 @@
+name: SonarCloud
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: sonarcloud-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ sonarcloud:
+ # Tracked at @main so org-wide CI improvements propagate without PR churn.
+ uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
+ secrets: inherit
diff --git a/sonar-project.properties b/sonar-project.properties
deleted file mode 100644
index 66faf29c3..000000000
--- a/sonar-project.properties
+++ /dev/null
@@ -1,4 +0,0 @@
-sonar.projectKey=storytold_craft-launcher
-sonar.organization=storytold
-sonar.sources=.
-sonar.exclusions=**/target/**,**/vendor/**,**/node_modules/**,**/*.min.js,**/dist/**,**/assets/**,**/docs/**
From cee4c16c8ad53621aaf25c037564e77a2dd37090 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 21 Oct 2026 16:00:34 -0400
Subject: [PATCH 19/38] ci: add sonar-project.properties for CI-based analysis
Marks the project ineligible for SonarCloud Automatic Analysis, resolving the
CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds.
---
sonar-project.properties | 2 ++
1 file changed, 2 insertions(+)
create mode 100644 sonar-project.properties
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
From 50033511862bf679ecff36e03512bb8209130b81 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 21 Oct 2026 17:06:49 -0400
Subject: [PATCH 20/38] security: enforce HTTPS on curl downloads (SonarCloud
S6506)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
---
packaging/linux/package.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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
From 887fe3a3a85e28f007f3a1861b3cc54d70867488 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 21 Oct 2026 17:14:27 -0400
Subject: [PATCH 21/38] security: use crypto.randomUUID for temp file suffix
(SonarCloud S2245)
Replace Math.random() with crypto.randomUUID() when generating the unique
suffix for the atomic write-then-move temp file. The value isn't security-
sensitive, but a CSPRNG gives a stronger uniqueness guarantee and resolves
SonarCloud javascript:S2245.
---
apps/effectcraft-web/js/host.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js
index 5c438550e..658a5199d 100644
--- a/apps/effectcraft-web/js/host.js
+++ b/apps/effectcraft-web/js/host.js
@@ -495,7 +495,7 @@ export async function cacheRead(name) {
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 tmp = canMove ? `${name}.tmp.${crypto.randomUUID()}` : name;
const fh = await dir.getFileHandle(tmp, { create: true });
if (fh.createSyncAccessHandle) {
const h = await fh.createSyncAccessHandle();
From 798339669252c5ace2c45b48a277b425527c8ce6 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 21 Oct 2026 17:20:35 -0400
Subject: [PATCH 22/38] fix: JS reliability bugs (SonarCloud S1764, S9383,
S4275)
- scriptui.js: replace the self-comparison NaN check (o.max === o.max) with
the explicit Number.isNaN(o.max) (S1764).
- sw.js: mark the fire-and-forget cache.put() with void and a .catch() so the
promise rejection is handled (S9383).
- prelude.js: return __err(...) from the activeCamera getter so every path
yields a value (S4275); __err always throws, so behavior is unchanged.
---
apps/effectcraft-web/web/sw.js | 2 +-
crates/expr/src/prelude.js | 2 +-
crates/script/src/scriptui.js | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/effectcraft-web/web/sw.js b/apps/effectcraft-web/web/sw.js
index 8c7efc2e5..c5605c81f 100644
--- a/apps/effectcraft-web/web/sw.js
+++ b/apps/effectcraft-web/web/sw.js
@@ -38,7 +38,7 @@ self.addEventListener("fetch", (event) => {
const hit = await cache.match(key, { ignoreSearch: req.mode === "navigate" });
if (hit) return hit;
const res = await fetch(req);
- if (res.ok && res.type === "basic") cache.put(req, res.clone());
+ if (res.ok && res.type === "basic") void cache.put(req, res.clone()).catch(() => {});
return res;
})(),
);
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/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); });
From 7d68a74ffc1bcdff943936bcb9b36047f28b48eb Mon Sep 17 00:00:00 2001
From: bflatastic
Date: Thu, 22 Oct 2026 17:57:40 +0700
Subject: [PATCH 23/38] README: the app icon above the title
---
README.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/README.md b/README.md
index 9dc985ee1..5fa28f0bb 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,10 @@
+
+
+
+
EffectCraft
From 15b8a916c528e73d48f7a9cb4ff58276c7a2d3dd Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Thu, 22 Oct 2026 15:39:32 -0400
Subject: [PATCH 24/38] Release 0.15.0
Co-Authored-By: Claude Opus 5.5
---
Cargo.lock | 72 +++++++++++++++++++++++++++---------------------------
Cargo.toml | 2 +-
2 files changed, 37 insertions(+), 37 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 2cd7ed3a0..1c52a75bf 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1163,7 +1163,7 @@ dependencies = [
[[package]]
name = "effectcraft"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"cpal",
"effectcraft-engine",
@@ -1188,7 +1188,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@@ -1200,14 +1200,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"cpu-time",
"effectcraft-automation",
@@ -1223,7 +1223,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"proptest",
"serde",
@@ -1231,7 +1231,7 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1248,7 +1248,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
@@ -1282,7 +1282,7 @@ dependencies = [
[[package]]
name = "effectcraft-export"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
@@ -1312,7 +1312,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"boa_engine",
"effectcraft-color",
@@ -1329,7 +1329,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"proptest",
"serde",
@@ -1337,7 +1337,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"bytemuck",
"effectcraft-color",
@@ -1359,14 +1359,14 @@ dependencies = [
[[package]]
name = "effectcraft-hevcenc"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -1391,7 +1391,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@@ -1410,7 +1410,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1422,7 +1422,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
@@ -1440,7 +1440,7 @@ dependencies = [
[[package]]
name = "effectcraft-media"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"craft-docpath",
"effectcraft-model",
@@ -1469,7 +1469,7 @@ dependencies = [
[[package]]
name = "effectcraft-model"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"effectcraft-geom",
"image",
@@ -1481,14 +1481,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@@ -1500,7 +1500,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"craft-pdf",
"effectcraft-raster",
@@ -1516,7 +1516,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@@ -1528,7 +1528,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1541,14 +1541,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "effectcraft-raster"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1557,7 +1557,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1579,7 +1579,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"boa_engine",
"effectcraft-engine",
@@ -1596,7 +1596,7 @@ dependencies = [
[[package]]
name = "effectcraft-segment"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"miniz_oxide 0.8.9",
"ndarray",
@@ -1605,7 +1605,7 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@@ -1617,7 +1617,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@@ -1629,7 +1629,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"proptest",
"serde",
@@ -1637,7 +1637,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
@@ -1649,7 +1649,7 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"craft-ui",
"effectcraft-color",
@@ -1681,11 +1681,11 @@ dependencies = [
[[package]]
name = "effectcraft-vp9enc"
-version = "0.14.0"
+version = "0.15.0"
[[package]]
name = "effectcraft-web"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -6625,7 +6625,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.14.0"
+version = "0.15.0"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index eecbbc7e3..129718b27 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
-version = "0.14.0"
+version = "0.15.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
From 6871aa223653e030c8720afc2daadb478e82447e Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Thu, 22 Oct 2026 19:19:19 -0400
Subject: [PATCH 25/38] security: check crypto.randomUUID availability in
canMove
---
apps/effectcraft-web/js/host.js | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/apps/effectcraft-web/js/host.js b/apps/effectcraft-web/js/host.js
index 658a5199d..12cb90bfa 100644
--- a/apps/effectcraft-web/js/host.js
+++ b/apps/effectcraft-web/js/host.js
@@ -494,7 +494,11 @@ 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 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) {
From ec0cc83a2955cbfb16bfceba80697b1775e635fd Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Thu, 22 Oct 2026 19:19:20 -0400
Subject: [PATCH 26/38] fix(web): fall back to network when caches.open fails
in service worker
---
apps/effectcraft-web/web/sw.js | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/apps/effectcraft-web/web/sw.js b/apps/effectcraft-web/web/sw.js
index c5605c81f..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") void cache.put(req, res.clone()).catch(() => {});
+ if (cache && res.ok && res.type === "basic") void cache.put(req, res.clone()).catch(() => {});
return res;
})(),
);
From 9581b53c3f8945683252af0eb5fac79e0e43aed4 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Thu, 22 Oct 2026 20:06:09 -0400
Subject: [PATCH 27/38] Release 0.16.0
Co-Authored-By: Claude Opus 5.5
---
Cargo.lock | 72 +++++++++++++++++++++++++++---------------------------
Cargo.toml | 2 +-
2 files changed, 37 insertions(+), 37 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 1c52a75bf..bba6288d0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1163,7 +1163,7 @@ dependencies = [
[[package]]
name = "effectcraft"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"cpal",
"effectcraft-engine",
@@ -1188,7 +1188,7 @@ dependencies = [
[[package]]
name = "effectcraft-automation"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"effectcraft-engine",
"effectcraft-host",
@@ -1200,14 +1200,14 @@ dependencies = [
[[package]]
name = "effectcraft-av1enc"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"filmcraft-av1",
]
[[package]]
name = "effectcraft-cli"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"cpu-time",
"effectcraft-automation",
@@ -1223,7 +1223,7 @@ dependencies = [
[[package]]
name = "effectcraft-color"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"proptest",
"serde",
@@ -1231,7 +1231,7 @@ dependencies = [
[[package]]
name = "effectcraft-effects"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1248,7 +1248,7 @@ dependencies = [
[[package]]
name = "effectcraft-engine"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
@@ -1282,7 +1282,7 @@ dependencies = [
[[package]]
name = "effectcraft-export"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"effectcraft-av1enc",
"effectcraft-color",
@@ -1312,7 +1312,7 @@ dependencies = [
[[package]]
name = "effectcraft-expr"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"boa_engine",
"effectcraft-color",
@@ -1329,7 +1329,7 @@ dependencies = [
[[package]]
name = "effectcraft-geom"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"proptest",
"serde",
@@ -1337,7 +1337,7 @@ dependencies = [
[[package]]
name = "effectcraft-gpu"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"bytemuck",
"effectcraft-color",
@@ -1359,14 +1359,14 @@ dependencies = [
[[package]]
name = "effectcraft-hevcenc"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"filmcraft-hevc",
]
[[package]]
name = "effectcraft-host"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -1391,7 +1391,7 @@ dependencies = [
[[package]]
name = "effectcraft-interchange"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"effectcraft-color",
"effectcraft-keyframe",
@@ -1410,7 +1410,7 @@ dependencies = [
[[package]]
name = "effectcraft-keyframe"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1422,7 +1422,7 @@ dependencies = [
[[package]]
name = "effectcraft-lottie"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"craft-docpath",
"effectcraft-color",
@@ -1440,7 +1440,7 @@ dependencies = [
[[package]]
name = "effectcraft-media"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"craft-docpath",
"effectcraft-model",
@@ -1469,7 +1469,7 @@ dependencies = [
[[package]]
name = "effectcraft-model"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"effectcraft-geom",
"image",
@@ -1481,14 +1481,14 @@ dependencies = [
[[package]]
name = "effectcraft-opusenc"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"filmcraft-opus",
]
[[package]]
name = "effectcraft-path"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
@@ -1500,7 +1500,7 @@ dependencies = [
[[package]]
name = "effectcraft-pdf"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"craft-pdf",
"effectcraft-raster",
@@ -1516,7 +1516,7 @@ dependencies = [
[[package]]
name = "effectcraft-plugin"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"effectcraft-effects",
"effectcraft-keyframe",
@@ -1528,7 +1528,7 @@ dependencies = [
[[package]]
name = "effectcraft-project"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1541,14 +1541,14 @@ dependencies = [
[[package]]
name = "effectcraft-psd"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"miniz_oxide 0.8.9",
]
[[package]]
name = "effectcraft-raster"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"effectcraft-color",
"effectcraft-geom",
@@ -1557,7 +1557,7 @@ dependencies = [
[[package]]
name = "effectcraft-render"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"effectcraft-color",
"effectcraft-effects",
@@ -1579,7 +1579,7 @@ dependencies = [
[[package]]
name = "effectcraft-script"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"boa_engine",
"effectcraft-engine",
@@ -1596,7 +1596,7 @@ dependencies = [
[[package]]
name = "effectcraft-segment"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"miniz_oxide 0.8.9",
"ndarray",
@@ -1605,7 +1605,7 @@ dependencies = [
[[package]]
name = "effectcraft-svg"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"effectcraft-geom",
"effectcraft-path",
@@ -1617,7 +1617,7 @@ dependencies = [
[[package]]
name = "effectcraft-text"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"effectcraft-keyframe",
"harfrust",
@@ -1629,7 +1629,7 @@ dependencies = [
[[package]]
name = "effectcraft-time"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"proptest",
"serde",
@@ -1637,7 +1637,7 @@ dependencies = [
[[package]]
name = "effectcraft-track"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"effectcraft-raster",
"effectcraft-segment",
@@ -1649,7 +1649,7 @@ dependencies = [
[[package]]
name = "effectcraft-ui-egui"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"craft-ui",
"effectcraft-color",
@@ -1681,11 +1681,11 @@ dependencies = [
[[package]]
name = "effectcraft-vp9enc"
-version = "0.15.0"
+version = "0.16.0"
[[package]]
name = "effectcraft-web"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"effectcraft-engine",
"effectcraft-export",
@@ -6625,7 +6625,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
-version = "0.15.0"
+version = "0.16.0"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index 129718b27..7ab23e295 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*"]
[workspace.package]
-version = "0.15.0"
+version = "0.16.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
From 2cf90507f38ad87bd64b7e6d41a6cd4fdb6a8cc5 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Fri, 23 Oct 2026 08:38:10 -0400
Subject: [PATCH 28/38] svg: bound raster surfaces and handle duplicate
gradient stops (#2190)
---
crates/svg/src/raster.rs | 141 +++++++++++++++++++++++++++++++--------
crates/svg/src/tests.rs | 19 ++++++
2 files changed, 131 insertions(+), 29 deletions(-)
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();
From 4b48482e8fdf5202bf193392e6f3af9490fcf266 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Fri, 23 Oct 2026 08:45:10 -0400
Subject: [PATCH 29/38] ui: bound queued synthetic input gestures (#2197)
* ui: bound queued synthetic input gestures
* ui: reserve exact click event count
---
crates/ui-egui/src/control.rs | 29 ++++++++++++++++++++
crates/ui-egui/tests/ui_control_modifiers.rs | 18 ++++++++++++
2 files changed, 47 insertions(+)
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());
From ca8043cbbabc8088cb9ebd15ec018ad954aeb389 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Fri, 23 Oct 2026 08:55:07 -0400
Subject: [PATCH 30/38] text: bound layout and variable font metadata (#2193)
* text: bound layout and variable font metadata
* text: address review (intern once, f64 prefix sums, bounded item search, name fallback, consistent variation fallback)
---
crates/text/src/layout.rs | 99 +++++++++++++++++++++++++++++--------
crates/text/src/sfnt.rs | 81 +++++++++++++++++++++++-------
crates/text/src/variable.rs | 44 ++++++++++++++---
3 files changed, 176 insertions(+), 48 deletions(-)
diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs
index 2e863d1ae..8f6928885 100644
--- a/crates/text/src/layout.rs
+++ b/crates/text/src/layout.rs
@@ -597,7 +597,7 @@ 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 };
@@ -702,18 +702,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) {
@@ -824,7 +832,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();
}
@@ -845,7 +856,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 {
@@ -862,18 +873,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));
}
@@ -900,7 +918,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) = (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() {
@@ -945,10 +965,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();
@@ -975,11 +1002,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() {
@@ -1060,6 +1105,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);
@@ -1415,6 +1463,15 @@ mod tests {
assert!(r.lines[0].x + r.lines[0].width > 401.0, "closing quote hangs right");
}
+ #[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;
From 8a4ed77c4ac0bffd060f25696719bfbaa7707ea4 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Fri, 23 Oct 2026 08:56:15 -0400
Subject: [PATCH 31/38] pdf: bound image sample and mask allocations (#2191)
* pdf: bound image sample and mask allocations
* pdf: fallible mask allocations and JPEG header pixel cap
---
crates/pdf/src/image.rs | 98 ++++++++++++++++++++++++++++++-----------
1 file changed, 73 insertions(+), 25 deletions(-)
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;
}
}
From 09b9ebca3468cc05eda531d14e5bed4ad8a96120 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Fri, 23 Oct 2026 08:59:06 -0400
Subject: [PATCH 32/38] roto: bound frozen matte decoding and serialization
(#2194)
* roto: bound frozen matte decoding and serialization
* roto: address review (fallible RLE growth, pre-check plane bytes, saturate 32-bit limits)
---
crates/effects/src/object_matte.rs | 8 +++-
crates/effects/src/roto.rs | 10 ++--
crates/track/src/roto/mod.rs | 52 +++++++++++++++++----
crates/track/src/roto/rle.rs | 74 ++++++++++++++++++++++++------
crates/track/src/roto/tests.rs | 15 ++++++
5 files changed, 132 insertions(+), 27 deletions(-)
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/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]
From 23f11ab7cf1b9d8c992fb16c244c6c080e1948db Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Fri, 23 Oct 2026 09:14:49 -0400
Subject: [PATCH 33/38] expr: bound rewrite expansion and preserve assignment
targets (#2195)
* expr: bound rewrite expansion and preserve assignment targets
* expr: bound rewritten output size and rewrite computed assignment targets
---
crates/expr/src/rewrite.rs | 79 +++++++++++++++++++++++++++++++++++++-
1 file changed, 78 insertions(+), 1 deletion(-)
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
From d1d9849e2d1f83b6120761419ffe203827a93aa5 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Fri, 23 Oct 2026 09:29:39 -0400
Subject: [PATCH 34/38] fix: validate the CLI path in the AE ease checker
(SonarCloud)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Resolve the argument and require a regular file before spawnSync, which
never uses a shell and only runs fixed binaries or that validated path.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff
---
examples/check-ae-ease.mjs | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/examples/check-ae-ease.mjs b/examples/check-ae-ease.mjs
index 3d4f1c02c..d98b35195 100644
--- a/examples/check-ae-ease.mjs
+++ b/examples/check-ae-ease.mjs
@@ -5,10 +5,14 @@ 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 ');
+const cli = path.resolve(process.argv[2] ?? '');
+if (!process.argv[2] || !fs.statSync(cli, { throwIfNoEntry: false })?.isFile()) {
+ throw new Error('Usage: node examples/check-ae-ease.mjs ');
+}
const tolerance = 1e-7;
function run(command, args) {
+ // NOSONAR: 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 });
if (result.status !== 0) throw new Error(result.stderr || result.stdout || 'Process failed');
return JSON.parse(result.stdout.replace(/^\uFEFF/, '').trim());
From 400fbb79e99d5d5fa22bde9d06f9bcb828f3aa34 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Fri, 23 Oct 2026 09:44:38 -0400
Subject: [PATCH 35/38] fix: put the NOSONAR marker on the spawnSync line
(SonarCloud)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Sonar applies NOSONAR to the line that carries the marker, so the
explanation above the call did not suppress anything.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff
---
examples/check-ae-ease.mjs | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/examples/check-ae-ease.mjs b/examples/check-ae-ease.mjs
index d98b35195..275f04e23 100644
--- a/examples/check-ae-ease.mjs
+++ b/examples/check-ae-ease.mjs
@@ -11,9 +11,9 @@ if (!process.argv[2] || !fs.statSync(cli, { throwIfNoEntry: false })?.isFile())
}
const tolerance = 1e-7;
function run(command, args) {
- // NOSONAR: 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 });
+ // 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());
}
From c75071dfb8e931ccb6a730ff5a05d357e74eb630 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Fri, 23 Oct 2026 10:10:47 -0400
Subject: [PATCH 36/38] fix: document the AE checker's operator-supplied CLI
path (SonarCloud)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The path is the developer's own CLI build, stat'ed as a regular file before
use; suppress the taint finding with that reasoning.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff
---
examples/check-ae-ease.mjs | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/examples/check-ae-ease.mjs b/examples/check-ae-ease.mjs
index 275f04e23..bfa4b7c2b 100644
--- a/examples/check-ae-ease.mjs
+++ b/examples/check-ae-ease.mjs
@@ -5,7 +5,9 @@ import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
-const cli = path.resolve(process.argv[2] ?? '');
+// 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
if (!process.argv[2] || !fs.statSync(cli, { throwIfNoEntry: false })?.isFile()) {
throw new Error('Usage: node examples/check-ae-ease.mjs ');
}
From 0c8d47f42ac1639a6387aa7918c8d393c307ed66 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Fri, 23 Oct 2026 10:28:48 -0400
Subject: [PATCH 37/38] fix: suppress the stat check's taint finding
(SonarCloud)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The stat is the validation that rejects anything but an existing regular
file; annotate it with that reasoning.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff
---
examples/check-ae-ease.mjs | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/examples/check-ae-ease.mjs b/examples/check-ae-ease.mjs
index bfa4b7c2b..e7296ce93 100644
--- a/examples/check-ae-ease.mjs
+++ b/examples/check-ae-ease.mjs
@@ -8,7 +8,8 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
// 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
-if (!process.argv[2] || !fs.statSync(cli, { throwIfNoEntry: false })?.isFile()) {
+// 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;
From dd325c7ab8556e7ff33d64391c78e2316561da55 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Fri, 23 Oct 2026 10:44:42 -0400
Subject: [PATCH 38/38] segment: bound checkpoint tensors and pickle work
(#2192)
* segment: bound checkpoint tensors and pickle work
* segment: bound entry size, memo clones, and total tensor elements
* segment: cap cumulative decompressed storage bytes
---
crates/segment/src/pt.rs | 139 ++++++++++++++++++++++++++++++++++-----
1 file changed, 123 insertions(+), 16 deletions(-)
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());