* fix(vector): bound hostile geometry raster work
Guard geometry and tracing operations against malformed dimensions and degenerate inputs with bounded work and safe failures.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* fix(vector): address review (knot cap on split, relative identity tol, non-vacuous test)
* revert(geom): splitting past the quilt limit is intentional (smart_map test)
* fix(geom): cap identity tolerance at 1e-6 px
---------
Co-authored-by: Codebuff <noreply@codebuff.com>
Profile::content_hash is a fixed-key 64-bit SipHash of the ICC bytes, so a
crafted profile can share another profile's hash (about 2^32 work). Two
places took an equal hash as proof of an identical profile:
- transform::cached keyed its process-wide cache on the two hashes and the
options, so a colliding profile was handed the other profile's cached
transform and converted with the wrong colours.
- Profile::same_colors returned true on equal hashes, so a colliding
profile was taken for the other one and a needed conversion was skipped.
Add Profile::same_bytes, which compares the ICC bytes (an Arc pointer check
first, then the hash, then the bytes). same_colors uses it instead of the
hash. Each transform cache entry now keeps the ICC bytes of its two
profiles; a hit counts only when they match, and a colliding pair gets its
own transform without replacing the cached entry. The transform is built
after the cache lock is released, as before.
The regression tests give Display P3 the content hash of sRGB (a stand-in
for a found collision): before the fix, same_colors called them the same
and the cache returned sRGB's transform for P3; now both see P3's colours.
Fixes#8613
Co-authored-by: John Turnbach <jturnbach@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(text): bound font paths and imported text processing
Reject encoded path tricks and cap resource-heavy font and PSD text processing to keep untrusted inputs bounded.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* fix(text): reject percent-encoded DEL in served paths; satisfy clippy chunks_exact lint
---------
Co-authored-by: Codebuff <noreply@codebuff.com>
* fix(algo): avoid copying duplicate EXIF text payloads
Keep the first valid text value for each EXIF tag, preventing duplicate entries from repeatedly decoding and copying large attacker-controlled strings.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
* test(exif): count text decodes so the duplicate-tag test observes skipped payloads
---------
Co-authored-by: Codebuff <noreply@codebuff.com>
refine_mask_with builds its result densely over the selection's bounds
(grown by the spread, plus Refine Edge Brush additions). After a thin
edit across a huge canvas, or with a whole-canvas selection there, that
is billions of pixels. matting::refine_area names that area and
select.refineEdge refuses one over MAX_REFINE_AREA (2^30 px) with an
error before allocating, leaving the selection unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FszacAJDjznVimaxQktoJT
The Brush edit, the Lasso edit and EdgeBrush::new allocated a dense buffer
over their bounding box, which the swept-area cap does not bound: a 1 px
diagonal across a 100,000 px canvas sweeps ~141k px but asked for ~40 GB.
- matting::brush_surface (replaces brush_region) and EdgeBrush paint only
the tiles a stroke touches (painted_tiles), capped at MAX_PAINTED_TILES.
- selection::polygon_surface rasterises only the tiles the Lasso's edges
cross, byte-identical to selection::polygon; interior tiles share one.
- selection::combine_sparse adds/subtracts visiting only the new shape's
tiles, keeping the rest of the selection shared.
- select.refineEdge refuses edits and refineStrokes that would cross too
many tiles before painting anything, leaving the selection unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FszacAJDjznVimaxQktoJT
The tilt-shift reference tests and tiled_clear_and_fill_match_one_pass_over_the_area cross pixel
formats or channel counts with independent geometry and fill settings. Each run takes a quarter of
the combinations through Sweep, a different quarter each UTC day.
precheck_agrees_with_execute built all five document states for every call and kept one; it now
builds only the state the call needs.
swept_blocks_match_the_pixel_search skips the cases where the colour mode reaches extrude only
through a channel count and ink setting an earlier mode already covered. It and
damaged_layer_data_opens_flattened run a quarter of their cases per run, a different quarter each
UTC day (photocraft-testkit's Sweep). PHOTOCRAFT_FULL_SWEEP=1 runs every case, and
PHOTOCRAFT_SWEEP_PART=<n> replays the part a failing run printed.
Review of #8783:
- The Invert edit no longer builds a canvas-sized buffer: the canvas
takes the inverse of the selection's untouched value (one shared tile
for every fully covered tile, as fill_rect stores it), then only the
tiles the selection holds are inverted. Byte-for-byte equal to the
dense inversion; a 100k x 100k canvas inverts in well under a second.
- A run of Inverts is applied as one or none (pairs cancel exactly), so
4096 of them cost at most one pass between other edits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #8783:
- matting::brush `segments` no longer collects its pieces: they are made
as they are painted, and each segment is first cut to the painted area
grown by the brush. A 1 px stroke zigzagging across 2 million px
(within MAX_SWEPT_AREA) split into ~134 million pieces collected up
front (~2 GB) in brush_region and EdgeBrush::new; now ~5000 pieces.
- select.refineEdge `edits`: the Brush edits' swept area in all is
bounded by MAX_SWEPT_AREA, as refineStrokes' is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without a document, a relative path is reported as needing the document's folder. Outside Windows a
drive or share path such as C:\art\logo.png never becomes absolute, so it is refused as invalid
whether or not the document has a file.
select.refineEdge takes `edits`: the Select and Mask workspace's Quick
Selection, Brush, Lasso, Object Selection, Select Subject, Clear Selection
and Invert, applied in order to the starting selection (or layer mask)
before the edge is refined. Cancel discards them, OK is one history step,
and agents drive them over MCP and the control channel.
- engine/select_mask_edits.rs: parse (bounded, every bad field an Err)
and a pure apply/apply_one the workspace preview can share
- algo matting::brush_region: soft round brush coverage (hardness)
- ATN export reports steps with edits instead of dropping them
- "Edits" label in the 21 catalogs
Part of #7069 and #8148 (the workspace UI follows in a second PR).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The importer bounded and inflated the top-level .svgz once, then handed
the result to usvg::Tree::from_data, which re-checks the gzip magic and
decompresses again with no byte limit of its own if the content happens
to start with a nested gzip stream. The same default resolver decompresses
embedded image/svg+xml data URLs (SVGZ-in-data-URL) the same unbounded way.
Parse the already-bounded top-level text with Tree::from_str, which never
decompresses. Wrap the image-href resolver so a gzip-compressed embedded
SVG is inflated through our bounded inflate() first; the already-plain
bytes handed to the default resolver no longer start with the gzip magic,
so it parses them directly instead of decompressing a second time.
Finding: a6444163-e510-4925-ae63-727658b6582e (CWE-409)
Embedded PNG and WebP images reached resvg using only their displayed
destination area, while resvg/tiny-skia allocate and decode the full
intrinsic source image first. A highly compressed large image could
therefore trigger gigabyte-scale allocation and decompression outside
the importer's destination-memory accounting.
Wrap usvg's default image-href data resolver with a check that reads
PNG dimensions directly from the IHDR chunk and WebP dimensions via
photocraft_codecs::probe_size, rejecting images whose decoded RGBA size
exceeds the raster budget before resvg ever sees them.
Findings:
- f4fc2a9e-c26f-442d-9d05-4e793d6b0f13 (CWE-789, embedded WebP allocation)
- d765fad5-678e-4f15-a8f8-3e6ee086056a (CWE-789, embedded PNG allocation)
b1b6bd248 opened the window whenever the status was Failed after an
error, which includes a Failed left from an earlier check, so a later call
refused for bad params opened it too. It now opens only when this call's
start failed: the status changed to Failed, or the error is the Failed
message (the same start failure again). The test covers bad params after a
failed check, bad params from Idle, and a repeated start failure.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
81bcf7f3d hid the path pointer for any drag, but every Type press starts
one, so pressing on a path showed the I-beam although a click or flat drag
still makes path type on release. While the button is held the pointer is
now what the release makes: nothing once the drag passes the box threshold,
else the path click at the press. pointer_up and the hover share the
threshold (makes_box). The test holds a press on a shape, drags flat (area
type on release) and drags a real box from inside the shape (no pointer).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When the checker cannot start, help.checkForUpdates sets Status::Failed and
returns the error, and `?` skipped opening the window, so Help > Check for
Updates showed neither the failure nor Check Again. The window now opens
for a failed check (the command error is still returned); bad params open
nothing. Covered by a test with a checker that fails to start.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Dragging out a paragraph box with the Type tool sets app.drag and no type
edit is open yet, so the hover lookup showed the area-type or path-type
pointer over a path although the release makes a box. hover_path_target now
returns nothing during a drag or an open type edit (the edit check moved in
from canvas.rs), and a test drives a drag over a shape.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
psd_recovery_generated is a module of the io crate's single `it` test
binary, so `--test psd_recovery_generated` names no target. The comment now
runs `--test it psd_recovery_generated::write_psds_for_manual_gui_test`.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two places turned an RGB or Lab colour into ink with the built-in coated
CMYK instead of the document's embedded profile: the exact ink path's
tiles (render_tile runs on rayon workers, which never inherit a caller's
with_cmyk_space scope), e.g. a solid fill picked in RGB, and the split
path's stand-ins (fill and effect colours, converted on the calling thread
outside any scope). Both now enter cmyk_space(doc). A test with a synthetic
uncoated profile checks a solid fill and a Color Overlay get the profile's
inks; each half fails on its own without its fix.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The bridge forwards Replace Contents to the app, which runs it on its
active document, so a given `index` was silently dropped and could replace
another document's contents. It is now refused before anything reaches the
app, as bridge previews and saves already do; documented in the schema and
control-protocol.md. A fake-app test checks the indexed call never arrives.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`index` targets one call (#7025): replace_smart_object now runs through
on_doc like place and the data-set methods, so the previously active
document is active again on success, on error and on the trusted-local path.
The index test covers a successful and a failing indexed replace.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Keep scan coordinates and tile boundaries in i64 so valid reads at the final tile and large strides do not overflow. Bound each run by the remaining output and return the surface default beyond the i32 coordinate range. Add differential regressions across all sample depths and missing/present tiles.
Co-authored-by: Lucenx9 <185146821+Lucenx9@users.noreply.github.com>
levels_gamma's shadow toe: for gamma ~1.003..1.03 the join point u1 is a
tiny positive f32 whose u1^2.2 underflows to 0, so b = inf and the toe
gives inf*0 = NaN at u = 0 (any 8/16-bit Levels channel at black). The
toe vanishes as gamma -> 1, so the power curve is its limit: use it when
b is not finite. Found by cmyk random_documents_match_the_reference
(proptest, unseeded; the shrunk case is kept in proptest-regressions);
the bug is on main since #5143, not from this batch.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#5598 made File > Generate > Image Assets require Preferences > Plug-ins >
Generator, so #8254's two artboard tests got Disabled. Enable it first,
as #5598's own test does.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>