decode_comps matched each nonzero layer-setting comp ID against the comp
vector with iter_mut().find(), an O(n*m) scan over two file-controlled
collections. A crafted PSD could cause quadratic CPU work during import.
Build a comp-ID-to-first-index map once and use it for setting lookups.
or_insert preserves first-match behavior for duplicate comp IDs; comp 0
(Last Document State) is still handled separately.
Finding: 47567ac0-a75f-4557-b127-ffc855e5bd8d (CWE-407)
parasites() implemented last-duplicate-wins by scanning all previously
collected names with iter_mut().find() per record. Parasite records are
file-controlled, so many distinct names caused O(n^2) CPU work before
unrecognized metadata was discarded.
Maintain a name-to-index map so replacement is O(1) while preserving
insertion order and last-value-wins behavior.
Finding: 6107f41e-9b17-4632-a9df-f88a74a796a8 (CWE-407)
Header::get scanned every card linearly, and axis resolution looks up
NAXIS1..NAXISn while find_image/data_len re-resolve the axes. Both the
commentary-card count and the NAXIS count are file-controlled, so a
crafted header multiplied them into quadratic header-processing work
without a correspondingly large decoded image.
Build a first-occurrence keyword index once in parse_header (skipping
HIERARCH cards, matching the prior filter) and use it in get().
Finding: eb2121d8-9dee-4422-bd57-f78dce60a61b (CWE-407)
actions.load gave each imported action a unique name with a helper that
restarted suffix numbering at 2 and linearly scanned all accepted names
for every candidate. A file repeating one base name many times caused
cubic comparison work during load.
Replace it with a UniqueNames helper backed by a HashSet for membership
and a per-base-name next-suffix cache, preserving the name, name 2,
name 3 ... sequence and first-match behavior.
Finding: 9c7a5fdb-131e-4687-8175-58d72007b0c5 (CWE-407)
Cryptomatte metadata enumeration had two file-driven amplifiers:
- cryptomatte_keys() tested key membership with iter().any(), O(n^2) in
the number of distinct cryptomatte/<key>/<field> attribute groups;
- layers() called layer_streams() for every key, rescanning all channels
and allocating a fresh owned stream list even when many keys name the
same layer.
Use a HashSet for key membership (keeping file order) and cache
layer_streams results by resolved layer name within each header, so
repeated keys reuse the discovered streams.
Findings:
- 120f4d51-b981-4fcc-abec-26f45f3efd29 (CWE-407, quadratic key membership)
- dae6e76b-a491-4089-892f-4cf34b911f36 (CWE-400, repeated per-key stream scans)
xmp_values advanced its cursor only past the matched key, so the
value-closing search (find(close)) rescanned the remaining packet for
every property-like substring. Repeating text such as
' tiff:ResolutionUnit>' inside the packet produced quadratic CPU work
even when no real resolution property existed.
Advance the cursor past each located value; a missing close delimiter
means the value is unterminated, so no later value can parse and the
scan stops. Valid multi-value extraction is unchanged.
Finding: 6ce55e33-27f0-4fb9-a22f-737382e1d328 (CWE-407)
The spelling list converted each misspelling's start and end byte offset
to a character offset with char_at, which recounts the text prefix from
the start every call. A layer with many short misspellings therefore
caused quadratic rescanning on the synchronous UI thread.
Collect the ascending offsets and map them to character offsets in a
single forward walk over the text. char_at is removed (byte_at still
handles the reverse direction in change()).
Finding: 392eb136-b836-4400-8ed7-f1e4549ff0ae (CWE-407)
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)
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 Fuzz step interpolated the user-controlled workflow_dispatch input
${{ inputs.fuzz_seconds }} directly into a run: shell command, allowing
script injection by whoever triggers the workflow. Pass it via an env var
(FUZZ_SECONDS) and reference "$FUZZ_SECONDS" so the value is treated as
data, not shell. Resolves SonarCloud githubactions:S7630 (BLOCKER).
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
* ci: refactor workflows to use reusable artcraft-common-actions
* ci: point reusable workflows to storytold/craft-common-actions
* ci: pin storytold/craft-common-actions to commit 27a978f and pass secrets explicitly
* ci: use @main (unpinned) for craft-common-actions with explanation comment
* fix(ui): respect native desktop scale under Auto UI scaling (#1278)
* UI scale: drop the now-unused monitor size, update docs and scorecard
Merge main; Auto no longer reads the monitor size, so remove the parameter and its
computation, simplify the test, and update ui-design.md and UI-217-9 to match.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>