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]" 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/Cargo.lock b/Cargo.lock index 412fb6672..bba6288d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1163,7 +1163,7 @@ dependencies = [ [[package]] name = "effectcraft" -version = "0.12.0" +version = "0.16.0" dependencies = [ "cpal", "effectcraft-engine", @@ -1188,7 +1188,7 @@ dependencies = [ [[package]] name = "effectcraft-automation" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-engine", "effectcraft-host", @@ -1200,14 +1200,14 @@ dependencies = [ [[package]] name = "effectcraft-av1enc" -version = "0.12.0" +version = "0.16.0" dependencies = [ "filmcraft-av1", ] [[package]] name = "effectcraft-cli" -version = "0.12.0" +version = "0.16.0" dependencies = [ "cpu-time", "effectcraft-automation", @@ -1223,7 +1223,7 @@ dependencies = [ [[package]] name = "effectcraft-color" -version = "0.12.0" +version = "0.16.0" dependencies = [ "proptest", "serde", @@ -1231,7 +1231,7 @@ dependencies = [ [[package]] name = "effectcraft-effects" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1248,7 +1248,7 @@ dependencies = [ [[package]] name = "effectcraft-engine" -version = "0.12.0" +version = "0.16.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1282,7 +1282,7 @@ dependencies = [ [[package]] name = "effectcraft-export" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-av1enc", "effectcraft-color", @@ -1312,7 +1312,7 @@ dependencies = [ [[package]] name = "effectcraft-expr" -version = "0.12.0" +version = "0.16.0" dependencies = [ "boa_engine", "effectcraft-color", @@ -1329,7 +1329,7 @@ dependencies = [ [[package]] name = "effectcraft-geom" -version = "0.12.0" +version = "0.16.0" dependencies = [ "proptest", "serde", @@ -1337,7 +1337,7 @@ dependencies = [ [[package]] name = "effectcraft-gpu" -version = "0.12.0" +version = "0.16.0" dependencies = [ "bytemuck", "effectcraft-color", @@ -1359,14 +1359,14 @@ dependencies = [ [[package]] name = "effectcraft-hevcenc" -version = "0.12.0" +version = "0.16.0" dependencies = [ "filmcraft-hevc", ] [[package]] name = "effectcraft-host" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -1391,7 +1391,7 @@ dependencies = [ [[package]] name = "effectcraft-interchange" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-keyframe", @@ -1410,7 +1410,7 @@ dependencies = [ [[package]] name = "effectcraft-keyframe" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1422,7 +1422,7 @@ dependencies = [ [[package]] name = "effectcraft-lottie" -version = "0.12.0" +version = "0.16.0" dependencies = [ "craft-docpath", "effectcraft-color", @@ -1440,7 +1440,7 @@ dependencies = [ [[package]] name = "effectcraft-media" -version = "0.12.0" +version = "0.16.0" dependencies = [ "craft-docpath", "effectcraft-model", @@ -1469,7 +1469,7 @@ dependencies = [ [[package]] name = "effectcraft-model" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-geom", "image", @@ -1481,14 +1481,14 @@ dependencies = [ [[package]] name = "effectcraft-opusenc" -version = "0.12.0" +version = "0.16.0" dependencies = [ "filmcraft-opus", ] [[package]] name = "effectcraft-path" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-geom", "effectcraft-keyframe", @@ -1500,7 +1500,7 @@ dependencies = [ [[package]] name = "effectcraft-pdf" -version = "0.12.0" +version = "0.16.0" dependencies = [ "craft-pdf", "effectcraft-raster", @@ -1516,7 +1516,7 @@ dependencies = [ [[package]] name = "effectcraft-plugin" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-effects", "effectcraft-keyframe", @@ -1528,7 +1528,7 @@ dependencies = [ [[package]] name = "effectcraft-project" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1541,14 +1541,14 @@ dependencies = [ [[package]] name = "effectcraft-psd" -version = "0.12.0" +version = "0.16.0" dependencies = [ "miniz_oxide 0.8.9", ] [[package]] name = "effectcraft-raster" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-geom", @@ -1557,7 +1557,7 @@ dependencies = [ [[package]] name = "effectcraft-render" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-color", "effectcraft-effects", @@ -1579,7 +1579,7 @@ dependencies = [ [[package]] name = "effectcraft-script" -version = "0.12.0" +version = "0.16.0" dependencies = [ "boa_engine", "effectcraft-engine", @@ -1596,7 +1596,7 @@ dependencies = [ [[package]] name = "effectcraft-segment" -version = "0.12.0" +version = "0.16.0" dependencies = [ "miniz_oxide 0.8.9", "ndarray", @@ -1605,7 +1605,7 @@ dependencies = [ [[package]] name = "effectcraft-svg" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-geom", "effectcraft-path", @@ -1617,7 +1617,7 @@ dependencies = [ [[package]] name = "effectcraft-text" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-keyframe", "harfrust", @@ -1629,7 +1629,7 @@ dependencies = [ [[package]] name = "effectcraft-time" -version = "0.12.0" +version = "0.16.0" dependencies = [ "proptest", "serde", @@ -1637,7 +1637,7 @@ dependencies = [ [[package]] name = "effectcraft-track" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-raster", "effectcraft-segment", @@ -1649,7 +1649,7 @@ dependencies = [ [[package]] name = "effectcraft-ui-egui" -version = "0.12.0" +version = "0.16.0" dependencies = [ "craft-ui", "effectcraft-color", @@ -1681,11 +1681,11 @@ dependencies = [ [[package]] name = "effectcraft-vp9enc" -version = "0.12.0" +version = "0.16.0" [[package]] name = "effectcraft-web" -version = "0.12.0" +version = "0.16.0" dependencies = [ "effectcraft-engine", "effectcraft-export", @@ -6625,7 +6625,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.12.0" +version = "0.16.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 7af58f009..7ab23e295 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.16.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/README.md b/README.md index 914a40907..5fa28f0bb 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,18 @@

+

+ EffectCraft icon: an engraved unicorn on a pink tile +

+

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.

@@ -24,6 +28,9 @@ Status: young and moving fast Written in Rust License: MIT OR Apache-2.0 +
+ Scanned by SonarCloud + Reviewed by CodeRabbit

@@ -64,13 +71,14 @@ ## What EffectCraft is EffectCraft is for animated titles, motion graphics and compositing work: the kind of thing -people reach for After Effects to do. You build a composition out of layers (solids, shapes, +motion designers and VFX artists need. You build a composition out of layers (solids, shapes, text, footage, other compositions), animate their properties with keyframes, stack effects on them and render the result. -The aim is to feel familiar to anyone who has used After Effects, with the same panels (Project, -Composition, Timeline, Effect Controls, Effects & Presets) and the same keyframe behaviour, and -then to go further in a few places where it matters to us: +The interface feels natural and responsive, with dedicated panels (Project, Composition, Timeline, +Layer, Footage, Effect Controls, Tools, Preview, Effects & Presets, Audio, Info, Paragraph, +Character, Align, Progress, Render Queue) and standard motion graphics shortcuts, going further in +a few places where it matters to us: - **Lottie import and export built in**, so animations can go straight to the web and apps without a plugin. @@ -84,7 +92,7 @@ then to go further in a few places where it matters to us: ## Animate -The panels, menus and shortcuts follow After Effects, so your muscle memory carries over: +The panels, menus and shortcuts follow standard motion design conventions: Project, Composition, Timeline, Effect Controls, Properties, Effects & Presets, Character, Paragraph, Align, Info, Preview, Audio and the Render Queue, docked the way you expect. Drag footage, comps and effects between them: onto the comp viewer, where they land under the pointer, @@ -102,7 +110,7 @@ or into the Timeline, between layers and at the time you point to. feather, expansion and vertex editing in the viewer. - **Text:** point and paragraph text with real shaping in any font installed on your machine, the Character and Paragraph panels, and text animators with range selectors. -- **Expressions:** JavaScript with the After Effects object model (`wiggle`, `loopOut`, +- **Expressions:** JavaScript with the standard motion expressions model (`wiggle`, `loopOut`, `thisComp.layer("…")`, vector maths on arrays), an inline editor and the pick-whip. - **Timeline like you know it:** twirl layers open to Transform, masks, effects and the rest; drag rows to reorder layers; rename with Enter or a double-click; the property shortcuts (A, P, S, @@ -118,9 +126,9 @@ or into the Timeline, between layers and at the time you point to. ## Effects -306 effects (every one of After Effects' 298, and more) across its categories, each with its parameter names, order and -defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, Lumetri -Color…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise, +306 built-in effects across its categories, each with its parameter names, order and +defaults: blur and sharpen, channel, color correction (Curves, Levels, Hue/Saturation, a full +color-grading panel…), distort (Warp, Bulge, Turbulent Displace, CC Power Pin…), generate (Fractal Noise, Gradient Ramp, Stroke, Write-on, Audio Spectrum…), keying, matte, noise and grain, perspective, **simulation** (CC Particle World, CC Rainfall, Shatter, Card Dance, Caustics, Wave World…), stylize (Glow, CC Glass…), **time** (Echo, Posterize Time, Timewarp, Time Displacement…), @@ -134,7 +142,7 @@ overlays, Stroke) with Global Light. Preview plays audio in sync, with meters an ## 3D -Classic 3D the way After Effects does it: 3D layers with orientation and material options, +Classic 3D compositing: 3D layers with orientation and material options, one- and two-node **cameras** with depth of field, **lights** (parallel, spot, point, ambient) with soft ray-traced shadows, layers that intersect correctly, orbit, pan and dolly camera tools, and Front, Top, Left and Custom views. @@ -145,7 +153,7 @@ tools, and Front, Top, Left and Custom views. ## Export -A Render Queue like After Effects', with Render Settings and Output Modules: **H.264** MP4 and +A dedicated Render Queue, with Render Settings and Output Modules: **H.264** MP4 and **ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, **HEVC** (Main / Main 10) and **AV1** MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field rendering with 3:2 pulldown, effect/solo/guide/depth overrides, crop, region of interest and @@ -219,25 +227,26 @@ tests, layering, asset attribution and the WebAssembly build). See [CONTRIBUTING ## Where it stands -EffectCraft is young: its first commit was on 1 October 2026. Nearly every After Effects feature -exists, including all of its effects, but existing is not the same as behaving exactly like After -Effects, and we haven't measured that yet. Today it's a good place to try things and to tell us -what breaks. It isn't yet a replacement for After Effects on client work. In particular: +EffectCraft is young: its first commit was on 1 October 2026. A comprehensive feature set +exists, including 306 effects, but breadth is not the same as full maturity across every edge +case. Today it's a good place to try things and to tell us what breaks. It isn't yet recommended +for production client work. In particular: -- **After Effects projects can't be opened.** Projects are saved as `.ecproj`, readable versioned - JSON, but EffectCraft can't open `.aep` / `.aepx` files, and After Effects plug-ins don't run. -- **Behaviour still differs from After Effects in places.** Nothing yet compares our renders with - After Effects automatically, so please report differences. +- **Native project format.** Projects are saved as `.ecproj`, readable versioned JSON. Proprietary + project formats and After Effects SDK plug-ins are not supported. +- **Fidelity is still being refined.** Automated rendering tests currently focus on small + compositions, so please report visual discrepancies or unexpected behaviour. - **macOS is the most tested platform.** Linux and Windows users have hit basic interaction bugs, which we're fixing first. - **The trained models are new.** Roto Brush can use MobileSAM (an optional 40 MB download in Settings ▸ Roto Brush) and face tracking can use Google's MediaPipe Face Landmarker (an optional - 3.8 MB download in Settings ▸ Face Tracking). Neither is compared with After Effects yet. + 3.8 MB download in Settings ▸ Face Tracking). Both are actively undergoing evaluation and tuning. -EffectCraft is in **alpha**: about 91% of After Effects' features exist, and we estimate it is -about 45% of the way to replacing After Effects for professional work. The [ROADMAP](ROADMAP.md) -has the plan, [docs/target-app-parity.md](docs/target-app-parity.md) the measurement and -[docs/gaps.md](docs/gaps.md) every known shortfall. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser. +EffectCraft is in **alpha**: about 91% of the features tracked in +[docs/target-app-parity.md](docs/target-app-parity.md) exist, and we estimate it is about 45% of the +way to being ready for full professional production work. The [ROADMAP](ROADMAP.md) has the plan, +[docs/target-app-parity.md](docs/target-app-parity.md) the measurement and [docs/gaps.md](docs/gaps.md) +every known shortfall. The web build ([docs/web.md](docs/web.md)) runs the full app in the browser. [Bug reports](https://github.com/storytold/effectcraft/issues) are the most useful thing you can send us right now. @@ -245,8 +254,8 @@ send us right now. - **Engine first.** A stack of small crates with strict layering. The interface (egui) sits on top and nothing underneath depends on it. -- **Clean room.** We work from After Effects' public documentation and how it behaves, never - from Adobe's files, icons, presets or code. No code is copied from GPL projects. The full rules +- **Clean room.** We work from public documentation and observable behavior, never + from proprietary files, icons, presets or code. No code is copied from GPL projects. The full rules are in [AGENTS.md](AGENTS.md). - **Private by default.** No telemetry, and no network access unless you ask for it. - **One family.** EffectCraft shares its time model and text engine design with FilmCraft, and @@ -368,8 +377,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif part of this repository and EffectCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. EffectCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with. -

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