fps_from_delays counted each frame's delay by scanning a growing vector
with iter_mut().find(), O(n^2) for a GIF with many distinct delays. A
crafted animation within the frame limit could stall the synchronous
import path.
Count delays in a HashMap and keep a first-seen order vector so the
most-common delay still wins, breaking ties toward the earliest-seen
delay exactly as before.
Finding: 23d54421-8e26-41b9-9690-ae1125ee2f85 (CWE-407)
Row 16 (9f6a3c79): preserve platform filename semantics in corpus scans
(no backslash-to-separator rewrite on Unix) and enforce destination
containment before copying files into the corpus directory.
Row 20 (6e2a985b): skip directory symlinks, dedupe visited directories by
canonical identity, and cap total directories/files so symlink cycles can
no longer drive exponential traversal.
Fixes findings 9f6a3c79-8bdf-4751-8eb4-87d3154074c9 and
6e2a985b-6b14-4b97-af07-7ca8333b9b75.
Object::get scanned the field vector linearly. An Affinity file can
define one large shared object and reference it repeatedly as sibling
layers, so repeated property lookups multiplied into quadratic CPU work
without the fields appearing more than once in the file.
Build a first-occurrence tag index when an object's fields are set
(via Object::new and set_fields, the latter covering the root object
that is filled after it is pushed). Positional fields (tag 0) stay out
of the index, and first-match semantics are preserved.
Finding: c315c4b0-dceb-459e-b39a-fa9c68ae6f40 (CWE-407)
subpath() looked up each polyline anchor's corner radius by scanning the
entire radius list, O(anchors * radii). A file supplying large anchor and
radius arrays could cause quadratic CPU work before geometry limits
inspected the path.
Build a record-to-maximum-radius map once and look each anchor up in it,
preserving the previous fold(0.0, f64::max) behavior for duplicate record
indices.
Finding: 89f65d65-f637-44fc-9a81-a9039a413a4b (CWE-407)
Reader::children re-collected a group's child objects by scanning every
raw Chld array slot, discarding nulls before the node budget counted
anything. A shared group holding a large null-filled child array, linked
many times, multiplied into excessive CPU work the MAX_NODES limit never
observed.
Charge every raw child slot (nulls included) against a document-wide
MAX_CHILD_SLOTS budget before filtering, via a new Stream::array_len
helper, and return a limit error when it is exhausted.
Finding: 3973fcd0-acda-4312-b538-d986dcffeb1c (CWE-400)
FilterEffects::parse tested the trailing bytes with
rest.iter().all(|b| b == 0) before checking rest.len() <= 3, so every
loop iteration scanned the whole remaining buffer. A block with many
empty sections produced quadratic CPU work.
Reorder the short-circuiting && so the three-byte length check gates the
zero scan, making each padding check constant work. Behavior is
otherwise identical.
Finding: 4887ae49-cfda-43b0-9296-a1816fc1ddba (CWE-407)
auto_slices tested every grid cell against the full slice-rectangle list
(covered(x, y) = taken.iter().any(...)). With slice edges forming the
grid, that is cubic in the slice count, so a crafted document's slice
arrangement could stall ordinary canvas drawing on the UI thread.
For each row band, mark covered x-cells in a single pass over the rects
that span the band, then scan the precomputed cells. This drops the inner
work from O(rects) per cell to O(1), while producing the same tiling.
Finding: 9fbea43b-3096-4cdc-b005-86d44a8fe57e (CWE-400)
Two per-pixel/per-sample amplifiers driven by a crafted ICC profile:
- table_inverse recomputed a sampled curve's flat-run edge indices by
scanning the whole table on every call, and transform construction
inverts each destination curve OUT_TABLE times. Compute the edges once
per curve via a new Curve::inverse_table batch method.
- eval_multilinear enumerated 2^inputs cube corners over every declared
input, including single-grid-point (degenerate) dimensions that
contribute nothing. Interpolate over the effective dimensions only, so
a 15-channel CLUT of one-point dims no longer does 2^15 iterations per
pixel.
Findings:
- acfbe3a3-ee58-4b7e-b72a-5a4877dd15c9 (CWE-407, repeated flat-run scans)
- 9fcc72e7-46d6-4f75-9333-cb14a65eeafd (CWE-407, degenerate-dim corner blowup)
The decode admission counted each pixel's channel samples and vector
header but not the heap capacity of the per-pixel (id, coverage) pair
lists. A compressed Cryptomatte document could therefore return pixel
storage exceeding the caller's max_alloc budget.
Include the maximum pair capacity (one pair per rank, 8 bytes each) in
the per-pixel byte count before check_bytes.
Finding: 5482ed4a-bc95-43fc-84d1-d5bfb6373761 (CWE-400)
Two deep-EXR allocation gaps:
- flatten() allocated the flattened output and the full display-window
stage (nc f32 samples per pixel) without a limit check; the deep decode
only admitted the data window at four bytes per pixel, before the
channel layout was known. Pass Limits into flatten and re-check both
buffers at the real f32 layout size.
- decode_deep built a per-pixel usize block index before validating the
chunk's declared packed sizes. Compute the block bounds first, validate
the payload is present, then materialize the index, so a truncated or
lying chunk is rejected before the index is allocated.
Findings:
- feb6fcd8-58df-47e2-a04a-d4d81d5565aa (CWE-789, display-stage allocation)
- 9c53025d-65f8-4bcc-9161-143a4c5d7f46 (CWE-789, index before payload check)
When transforms (orientation/clean-aperture crop) are applied, admission
checked only the transformed output size. The delegated HEVC decoder
allocates the full coded image before cropping, so a HEIF with a small
visible aperture over a large coded image bypassed the limit policy.
Always check the coded dimensions against the full limits before
delegating, in addition to the transformed size.
Note: the related SPS-driven and grid-tile allocations (findings
75048229 and 2db4c59e) occur inside the pinned heic-rs dependency from
parameter sets the container does not expose; fully bounding those needs
a decoder-side change and is tracked separately.
Finding: ec5cec31-8a62-4963-8045-e3ec9d9e4931 (CWE-400)
Tiff::dump appended each entry's full ASCII value with no per-value or
total-output limit. Thousands of entries can reference one large aliased
ASCII value, expanding a small diagnostic input into a multi-gigabyte
String.
Cap each ASCII preview to 256 bytes (truncated at a char boundary) and
stop the listing once the accumulated output reaches 1 MiB, marking it
truncated.
Finding: d994ec7f-25dd-4ae7-9673-04adc0aa8a3c (CWE-400)
read_craw treats every segment as a run of full-width image rows and
grew the row-reference vector before checking coverage. A tiled layout
(which does not describe full-width rows) with repeated tile offsets
could push far more than height rows, allocating gigabytes before the
post-loop coverage check ran.
Reject tiled cRAW layouts up front (cRAW is only stored as strips) and
stop collecting rows once the image height is reached.
Finding: 3c2601a1-ad92-45b6-96f7-be5da87d97eb (CWE-400)
compressed_row positioned the bit cursor by replaying every preceding
byte within its page (skip % PAGE calls of get(8)). For narrow images
this replay runs per row, so a small file could consume disproportionate
CPU just setting up cursors.
Add Bits::new_at, which sets the page position and remaining-bit count
directly from the row offset in O(1), and use it in compressed_row. A
unit test proves it matches the old byte-by-byte replay.
Finding: 2a7d3d4b-90ab-4887-9dba-c3884e224990 (CWE-407)
GainMap::row materialized the entire horizontal gain grid (points_h
floats) once per applicable image row, even though each output pixel only
needs two neighboring points. File-controlled image height and horizontal
map size multiplied into processing and allocation churn far beyond the
input size.
Add GainMap::at_rv, a direct four-corner bilinear lookup, and use it per
output column in gain_row so no points_h-wide row is built per image row.
A test proves at_rv matches the former row() + at() path.
Finding: 7ebec8df-1a7a-4d33-915f-097f8e9be0e5 (CWE-400)
upright_preview decoded a raw file's embedded JPEG with default codec
options, discarding the caller's DevelopOptions.limits. A compressed
embedded preview could therefore allocate and process far more pixel
data than the caller authorized during the fallback and color-fit paths.
Pass the caller's limits into upright_preview and translate them into
the JPEG DecodeOptions, so both the Unsupported fallback and the
preview look-fit respect the same policy.
Finding: d17bc286-6765-41ce-bd41-579ee5b75cfa (CWE-409)
Each DHT definition for table id 0..3 calls Huffman::new, which
allocates and fills a ~48 KiB lookup pair. A stream repeating DHT
definitions (and DNG tiles referencing such a stream) could rebuild
tables thousands of times, multiplying header-processing cost and
memory churn while producing little output.
A real lossless JPEG uses a handful of Huffman tables; cap cumulative
table builds per stream at 64 and reject streams that exceed it.
Finding: 7a31b581-d1b7-4f9c-91e5-ce42b47db87f (CWE-400)
read_plane decodes every tile in parallel and retains all decoded tile
buffers in before assembly copies only the useful pixels. Each
tile is charged individually, but file-controlled padding and repeated
source ranges can make the cumulative retained samples far exceed both
the input and the useful image.
Sum the padded tile samples (two bytes each) and reject the plane before
decoding when that total exceeds max_alloc.
Finding: e66981b1-0d7a-4738-a64c-37b5e8bf28d1 (CWE-770)
PDN conversion copied a referenced name into an owned String for every
output layer, while the NRBF budget charged the shared string only once.
Repeated references to one large name could multiply a small document
into gigabytes of retained metadata.
Cap each layer name at 64 KiB and the aggregate copied name bytes at
4 MiB, checked before each copy.
Finding: fd77f4b2-39a2-48b5-b9d8-4ad69cf69469 (CWE-770)
The XCF importer reparses every layer offset and allocates a new owned
name and item path per reference. Repeated offsets referencing one
record with a large name or path could retain gigabytes of metadata,
which the per-field and per-layer limits did not bound in aggregate.
Add a document-wide metadata budget (32 MiB) to the Image reader and
charge each copied layer name and item path against it, erroring when
exhausted.
Finding: 1631d2df-fe8f-4734-b14e-380db11afb26 (CWE-789)
build_mask derives its working area from sparse mask geometry grown by
feather, so two distant changed pixels could span a canvas-sized
rectangle. It then allocated f32 planes (coverage, pixel-mask values,
blur scratch) over that area, gigabytes during even a one-pixel render.
Check the estimated working bytes before the dense allocation and fall
back to the constant default mask above 512 MiB, so pathological
geometry degrades feather detail instead of exhausting memory.
Finding: 8c04c5ef-a5bd-48f4-bfcf-83615bcf11c0 (CWE-770)
When a pattern exceeded the preparation cache budget, every lookup still
called Tile::new_in, expanding the whole sparse pattern into RGBA floats
on each output tile (and possibly concurrently). The cache budget only
controlled retained entries, not this conversion work.
Reject patterns whose full RGBA conversion would exceed an absolute
256 MiB cap on the uncached path, so oversized patterns are skipped
instead of re-expanded per tile. The cap is independent of the cache
budget, so small patterns still convert on demand when nothing is cached.
Finding: cc0b2f77-5578-48ed-aba0-1f3beccd21cd (CWE-400)
dialogs.rs: main's Displace-map prompt runs before the fallible confirm_command. filter_dialog.rs: main's ask_displace_map uses the new confirm_command signature; SMART_FILTER_SET_PARAMS stays for main's uses; a smart-filter edit without a "layer" param previews the active layer, as the command runs it (main's smart_displace_preview_uses_the_map_document).
Create/Release Clipping Mask and Brightness/Contrast, Black & White and
Gradient Map were written out by hand with the same body as
adjustment_event! or each other, which SonarCloud counts as duplicated new
code (photocraft's quality gate fails on 9.8% duplication from the GrpL
entry). adjustment_event! takes an optional command id, and clipping_event!
builds the two clipping events. The mapping table is unchanged.
Pin third-party actions to full-length commit SHAs (with version comments)
instead of mutable tags/branches to prevent supply-chain tampering via
retagged releases. Resolves SonarCloud rule githubactions:S7637.
The merge of main kept `#[cfg(test)]` on `mod editor_tests;` only, so
`layer_thumbnail_scaling_tests` (which uses the dev-only egui_kittest)
was compiled into normal builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#3289, #3974 and #3975 landed in main, so the Smart Filters row code meets
#3974's thumbnail geometry.
Conflicts:
- smart_ui.rs: the rows keep our layout (separate name and Blending
Options targets, shared disclosure, real mask thumbnail) and take
#3974's geometry: the header height and the shared-mask side come from
smart_filters_header_geometry, sub-rows are SUB_ROW_H, the mask is
hidden at None and the header name follows it. #3974's own
drag_started and selection handling is dropped, ours already covers
both through the name target. #3974's scaling test module is kept
next to editor_tests.
- All 20 i18n catalogs: both sides appended rows at the end; kept both,
exact duplicates dropped, dupscan reports 0 problems.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>