Skip to content

EPS import: bound the stitched-gradient builder - #929

Merged
bflatastic merged 1 commit into
storytold:mainfrom
haranishi:fix/eps-gradient-stop-cap
Oct 10, 2026
Merged

bflatastic merged 1 commit into
storytold:mainfrom
haranishi:fix/eps-gradient-stop-cap

Conversation

@haranishi

Copy link
Copy Markdown
Contributor

What and why

Three ways a small EPS/PS/AI program could freeze or crash the import through a stitching function (/FunctionType 3) in shfill:

  1. No cap on the stops. For a stitching function whose pieces are all linear, gradient() made one stop per /Bounds entry (linear_points), and every stop calls eval, which rebuilt Bounds and copied the whole Functions array on each call: O(N²). A program that builds 40,000 bounds in a loop (a few operations each) took 11 s, and the import is synchronous. The non-linear case was already capped at 32 samples.
  2. A function that refers to itself (F /Functions [ F ] put) made linear_points recurse until the stack overflowed, which aborts the process (vectorcraft_engine::guard cannot catch an abort). eval already stops at depth 8; linear_points did not.
  3. Shared children. The depth limit does not stop a tree of shared references: Functions arrays of 1025 references to one child, three levels deep, are 1025³ visits for linear_points from a tiny file (it did not finish in 60 s).

What changes, in crates/eps/src/import/graphics.rs:

  • MAX_STITCH_STOPS = 1024: more bounds, or more than 1025 pieces, and the function is sampled (33 stops) like any other function. Up to 1024 bounds keep their exact stops, so ordinary gradients are unchanged.
  • linear_points takes the depth (it gives up past 8, as eval does) and a visit counter shared by the whole recursion (it gives up past 2 × 1025 visits; a well-formed stitch of linear pieces needs at most 1026). Functions is length-checked before it is copied.
  • eval's stitching branch reads Functions through the borrow and copies only the piece it recurses into, instead of the whole array on every call and at every live recursion level.
  • Not changed: the interpreter's op budget (MAX_OPS) still does not account for the work eval does on large arrays. I left that as it is.

How it was tested

Five new tests in tests_generators.rs. They assert stop counts and outcomes; there is no timing.

  • gradients_stitched_from_linear_pieces_keep_or_sample_their_stops: 3 bounds give the exact 5 stops; 1024 bounds give 1026; 1025 bounds are sampled (33). Before the fix the last case gave 1027 stops.
  • a_stitching_function_with_one_piece_but_many_bounds_is_sampled: 2000 bounds on one piece give 33 stops (2002 before).
  • a_gradient_with_a_functions_array_past_the_cap_is_sampled: 1026 pieces with 1000 bounds give 33 stops (1002 before).
  • a_stitching_function_that_refers_to_itself_is_rejected_not_crashed: the import returns, keeps what was drawn before the error and reports it (before the fix the process aborts with SIGABRT).
  • a_deeply_shared_stitching_function_is_sampled_not_walked: 1025 references to a child, three levels deep, are sampled at once (before the fix it did not finish in 60 s).

cargo xtask ci passes after rebasing onto the current main (all 7 steps, 145 tests in vectorcraft-eps).

Checklist

  • cargo xtask ci passes (fmt, clippy, tests, assets, brands, layers, wasm).
  • No panics in shipped code: no unwrap(), expect(), panic!, unreachable!, todo! or
    unimplemented! outside tests, and no unchecked indexing on data from files, parameters or
    messages. Errors are returned as Result and shown to the user
    (why and how).
  • Clean-room: nothing copied from Adobe products or GPL/AGPL code (AGENTS.md).
  • Every new asset has a row in ASSETS.md. (There are no new assets.)

🤖 Generated with Claude Code

Cap the stops taken from a stitching function (1024), bound linear_points by
depth and by a shared visit count, and stop eval from copying the whole
Functions array on every call.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 08:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@bflatastic
bflatastic merged commit be51213 into storytold:main Oct 10, 2026
1 check passed
@bflatastic

Copy link
Copy Markdown
Member

Thanks, @haranishi! Merged after a full CI run. A self-referencing Functions array aborting the process is exactly the kind of thing guard can't catch, so thanks for finding it. The shared visit counter for fan-out trees and borrowing Functions in eval are good catches too, and the tests pin each case without timing. The MAX_OPS accounting you left as is is worth its own look someday.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants