92 Commits
Author SHA1 Message Date
Dylan M. Taylor 1c3db2e5c3 security(engine): bound script, batch, adjustment, import and export work (#8954)
* security(engine): bound script, batch, adjustment, import and export work from file input

Scripts calling scripts share depth and step budgets; batch inside a played action charges the playback tree; destructive adjustments and mask adjustments visit stored tiles when the content bounds are a huge sparse grid; Copy Layers checks group depth; aux flattening and brush import refuse oversized canvases; video footage frames are budgeted, Render Video names frames after the leaf of the document name and reads no linked files for restricted sessions; ATN names are clipped; profile paths are read as regular files only; prefs.set no longer treats colorSettingsX as colorSettings.

* style(engine): rustfmt

* engine: address review - refuse unbounded sparse adjustments, carry batch playback charges, charge played steps to scripts, clip ATN targets, stricter colorSettings paths, cap image-sequence frames
2026-10-23 22:09:29 -04:00
Dylan M. Taylor 8931277070 security(io): charge sparse layers, shape caches and shared decodes (#8955)
* security(io): charge sparse layers, shape caches and shared decodes

ORA and PSD export refuse dense boxes past a cap; PSD import and ABR mapping decode shared masks/tips once; Affinity and SVG shape caches are charged to the import budget; SVG embedded JPEG size is probed; Krita tiles are charged for the backing tiles they land in.

* fix(io): clippy range pattern; align the sparse Krita test tiles to the backing grid

* io: address review - offset-aware KRA tile budget, saturating stroke reach, OpenRaster entry limit, PSD retained-surface budget, halved PSD gather cap
2026-10-23 21:52:25 -04:00
Dylan M. Taylor cfd894aad3 security(paint): cap spaced steps and procedural primitives (#8957)
* security(paint): cap distance-spaced steps per segment and procedural tip primitives

* test(paint): count steps through a Cell so the walker closure can be shared

* paint: keep the spacing carry-over when a segment ends at the step cap; lower the tip work budget
2026-10-23 17:38:07 -04:00
Dylan M. Taylor 6d4b9690ab deps: roxmltree 0.21 in photocraft-io and photocraft-engine (#8973) 2026-10-23 17:06:29 -04:00
Dylan M. Taylor ed67aa5859 Merge pull request #8990 from dylanmtaylor/ci/sonar-main-not-cancelled
ci(sonar): stop cancelling main-branch runs so coverage is published
2026-10-23 17:05:16 -04:00
Dylan M. Taylor 5521968828 ci(sonar): don't cancel superseded runs on main, so coverage is published 2026-10-23 16:47:12 -04:00
Dylan M. Taylor f3f2acc5ec Merge pull request #8960 from dylanmtaylor/security/gpu-plan-and-ui-swatch
security(gpu,ui-egui): bound clipped group planning; pattern swatch sampling
2026-10-23 15:52:58 -04:00
Dylan M. Taylor 597ea53023 Merge pull request #8958 from dylanmtaylor/security/plugins-host-hardening
security(plugins): host keys, band count, start fuel, FIFO open
2026-10-23 15:52:53 -04:00
Dylan M. Taylor 697aaf3f49 Merge pull request #8947 from dylanmtaylor/security/codecs-truncated-scan-icc
security(codecs): bound truncated JPEG recovery; count WebP frame ICC copies
2026-10-23 15:52:48 -04:00
Dylan M. Taylor 6ca5bf359d security(compose): bound dense working sets and nested Blend If evaluation (#8949)
* security(compose): bound dense working sets and repeated group evaluation

Smart-filter mixing and stroke fitting work tile by tile instead of over the
union of sparse surfaces; render_layer admits its rect like render; hidden
layers no longer disable the proxy and uniform-area shortcuts; Blend If
groups nested deeper than two are judged by their result, so nesting costs
polynomial instead of exponential work; effect builds past an absolute byte
cap are skipped.

* CodeRabbit #8949: exact tile skips in filter_mask, allocated-tile scan in fit_stroke, contrasting backdrop in the nested-group test

- filter_mask mix/unmix: bare tiles (no tiles on either side) skip for
  non-alpha formats too when the two surfaces' default pixels are equal;
  differing defaults still get processed, so the clone's default pixel is
  never left wrong under a non-unit mask
- shape_split fit_stroke: scan the union of the three surfaces' allocated
  tiles (exact skip when all three defaults are transparent) instead of
  every tile of a 300k-canvas document; non-transparent defaults fall
  back to the full scan
- tests: the nested-groups assertion now uses a black backdrop so the
  asserted white can only come from rendering through the group chain

fixes CodeRabbit review comments (major/minor) on filter_mask.rs:27,29,
shape_split.rs:126, tests.rs:2354

* compose: skip a BTreeSet in fit_stroke — a sorted Vec compiles under the wasm toolchain (E0599 on ::drain there)
2026-10-23 15:33:01 -04:00
Dylan M. Taylor ecd1a692a3 security(algo): bound working sets sized by caller-controlled extents (#8946)
* security(algo): bound working sets sized by caller-controlled extents

Zero-width inpaint grids, content-aware fill mosaics of turned thin images,
Liquify fields over huge canvases, tiny-scale reduction weights, warp tile
bookkeeping and per-tile source footprints, and windowed filters whose halo
follows an unclamped radius or a stacked Filter Gallery reach all asked for
memory sized by file or parameter input. Each is now bounded; halo
arithmetic saturates instead of overflowing.

* security(algo): budget variants by dimensions, size-check footprints first, widen the tile-halo maths

CodeRabbit follow-up:
- fill_cow settles the mosaic budget on variant dimensions only, so a
  variant's image and mask buffers are not built when the mosaic would
  exceed MAX_MOSAIC_PIXELS (complete falls back to the image itself).
- warp validates the footprint's pixel area against MAX_FOOTPRINT_PIXELS
  before src.has_tiles_in, so an oversized footprint returns without the
  tile-grid scan.
- auto_tile widens the input-derived halo arithmetic to i64 and clamps
  before the cast, so an i32::MAX radius cannot overflow in debug builds
  and the 2048 tile cap stands.

* fix(algo): drop the duplicate mosaic_size call left behind in fill_cow

The variant-budget rework kept both the new dimensions-based budget and
the old variants-based one; the old call no longer compiled after
mosaic_size started taking dimensions. Keep the comment that explains
the fallback.
2026-10-23 15:22:24 -04:00
Dylan M. Taylor a09c0ce85f test(gpu): match the planning error instead of err().expect() 2026-10-23 15:21:47 -04:00
Dylan M. Taylor 143c3977c2 Merge pull request #8959 from dylanmtaylor/security/cms-psd-raw-vector-parsers
security: bisect table/stop lookups; charge ICC curves; bound PSD/RAW buffers
2026-10-23 15:20:40 -04:00
Dylan M. Taylor 5b93901779 Merge pull request #8956 from dylanmtaylor/security/ipp-reply-deadline
security(ipp): total reply deadline, resumable chunk walk
2026-10-23 15:20:37 -04:00
Dylan M. Taylor 7d4c7f3889 Merge pull request #8953 from dylanmtaylor/security/raster-doc-sparse-tiles
security(raster,doc): tile counts, shared rebuilt tiles, slice merge index
2026-10-23 15:20:33 -04:00
Dylan M. Taylor a691aaf65b codecs: use u32 in the padded-truncated-JPEG test's bounds arithmetic (u64: From<i32> does not implement — E0277) 2026-10-23 15:08:50 -04:00
Dylan M. Taylor 75f5a9c126 packaging: use [[ for bash conditional tests (SonarCloud S7688) (#8914)
* packaging: use [[ for bash conditional tests (SonarCloud S7688)

SonarCloud flags every bare [ conditional in bash scripts under packaging/ as a reliability smell (rule S7688). Convert the flagged [ ... ] tests to [[ ... ]]: safer word-splitting and no glob surprises, same semantics for these string and file tests.

* packaging/web: parse size overrides as base 10 under [[
2026-10-23 15:03:12 -04:00
Dylan M. Taylor a8983a4060 security(codecs): budget truncated JPEG scans by scan data, document the animated WebP ICC charge
CodeRabbit follow-up:
- data_end counts the scan-data bytes it crosses, and decode's
  truncated-input bound is measured on those, not on bytes.len(): APP
  padding padded before the scan used to charge the budget without
  describing one more pixel.
- a regression test builds a truncated JPEG with ~256 KiB of complete
  COMMENT padding whose patched 20000x20000 frame was admissible under
  the file-length bound, and verifies padding alone cannot admit it
  (while the same file at its real size still recovers).
- Limits::max_alloc docs state the animated WebP charging, next to the
  PNG text/XMP sentence.
2026-10-23 14:44:28 -04:00
Dylan M. Taylor b20ccefffb security(gpu,ui-egui): bound clipped pass-through planning; sample pattern swatch pixels directly 2026-10-23 14:41:54 -04:00
Dylan M. Taylor 78ae50f7b2 security: bisect flat-table and gradient-stop lookups; charge ICC curve samples; bound PSD planes, RAW ljpeg retention and TIFF dump preview 2026-10-23 14:41:53 -04:00
Dylan M. Taylor 530e0b6b70 security(plugins): reject host routing keys, bound bands and start fuel, open without blocking on FIFOs 2026-10-23 14:41:52 -04:00
Dylan M. Taylor 93e605a2e9 security(ipp): total reply deadline; resumable chunk walk 2026-10-23 14:41:49 -04:00
Dylan M. Taylor 3729c3f664 security(raster,doc): count tile grids without walking them; share rebuilt tiles; index slice merges
Rect::tile_count counts a rect's tiles in u64; try_take_tiles uses it (no usize overflow on 32-bit). Surface::translated reuses one rebuilt tile for equal source-tile windows. auto_slices merges vertically adjacent runs through a hash index instead of searching the output.
2026-10-23 14:41:44 -04:00
Dylan M. Taylor 6da36b61f3 release: the git-sources gate moves into craft-common-actions (storytold/craft-common-actions#78) (#8952)
The check is shared at release.yml@main now; keep only the reusable call.
2026-10-23 14:41:09 -04:00
Dylan M. Taylor b11ecc6ebf security(affinity): charge shared-object expansion against document-wide budgets (#8938)
Reject zero-width fixed-record arrays, borrow pixel state arrays instead of
collecting them per occurrence, charge FiEf slots, curve records and layer
name bytes across the document, bound mixed-text expansion before appending,
and cap distinct allocation-table ids (deletion records now hold no Entry).
2026-10-23 14:30:21 -04:00
Dylan M. Taylor c9892c8f8d security(codecs): refuse implausibly short truncated JPEG scans; charge WebP frame ICC copies
A truncated JPEG scan under a large declared frame was filled out by the
backend to the full admitted size; reject it when the file cannot plausibly
account for the pixels. Animated WebP frames each keep a copy of the ICC
profile, so the aggregate admission now counts it.
2026-10-23 14:12:04 -04:00
Dylan M. TaylorandCodebuff f27656b910 fix: restrict droplet shim permissions and annotate test range (SonarCloud) (#8870)
The generated droplet shim only needs to run as its creator, so 0700 keeps
other local accounts from reading the CLI path it invokes. The reversed
range in the arrow-key test is the case under test, not an accident.

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-23 09:57:10 -04:00
Dylan M. TaylorandCodebuff f346b5c8f4 fix(vector): bound hostile geometry and raster work (#8769)
* 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>
2026-10-23 09:56:08 -04:00
Dylan M. TaylorandCodebuff 96042b8e7a fix(text): bound untrusted font and PSD text processing (#8768)
* 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>
2026-10-23 09:40:04 -04:00
Dylan M. TaylorandCodebuff 56d7009c78 fix(paint): validate gray tile dimensions safely (#8772)
* fix(paint): check tile dimensions before allocation math

Reject overflowing tile dimensions during deserialization and treat invalid in-memory tiles as empty safe previews and mipmaps.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

* test(tile): assert the address-space overflow error on 32-bit targets

---------

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-23 09:27:54 -04:00
Dylan M. TaylorandCodebuff ecff046ea1 fix(algo): avoid repeated copies of duplicate EXIF text (#8771)
* 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>
2026-10-23 09:27:48 -04:00
Dylan M. TaylorandCodebuff 1695f323e6 fix(psd): retain pattern budget charges after parse failures (#8767)
Keep allocation charges monotonic when malformed pattern blocks fail, so later sections cannot reuse budget already consumed.

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-23 08:38:15 -04:00
Dylan M. Taylor ee0d23f77a Merge pull request #8505 from dylanmtaylor/security/svg-svgz-double-decompress
security: stop double decompression of gzip-compressed SVG
2026-10-22 19:44:59 -04:00
Dylan M. Taylor ef4e13dc2d security(io): stop double decompression of gzip-compressed SVG
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)
2026-10-22 19:44:41 -04:00
Dylan M. Taylor 217af69bb5 Merge pull request #8504 from dylanmtaylor/security/svg-embedded-image-admission
security: admit embedded SVG raster images by header size
2026-10-22 19:42:44 -04:00
Dylan M. Taylor 6ed61e93a7 Merge pull request #8406 from dylanmtaylor/security/native-storage-safety
security: harden native temporary and recovery files
2026-10-22 19:42:39 -04:00
Dylan M. Taylor 3309b389a8 Merge pull request #8405 from dylanmtaylor/security/codec-allocation-bounds
security: preflight PCX and XPM allocations
2026-10-22 19:42:34 -04:00
Dylan M. Taylor 2bcf409ad3 fix(format): preserve user umask on atomic_write, 0600 only internal files, nonblocking sidecar/file reads 2026-10-22 19:35:51 -04:00
Dylan M. Taylor 1bd922f6d1 fix(codecs): use exact minimum encoded row length for PCX preflight 2026-10-22 19:35:37 -04:00
Dylan M. Taylor fcdb572cd6 security(io): admit embedded SVG raster images by header size
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)
2026-10-22 19:33:27 -04:00
Dylan M. Taylor 76f0e08df7 security: harden native temporary and recovery files 2026-10-22 19:28:44 -04:00
Dylan M. Taylor 827d949af9 security: preflight PCX and XPM output allocations 2026-10-22 19:28:08 -04:00
Dylan M. Taylor 9852baa4ba Merge pull request #8417 from dylanmtaylor/security/psd-slices-id-index
security: index slice IDs in PSD slices parser
2026-10-22 19:27:46 -04:00
Dylan M. Taylor 6649b78526 Use chrono instead of hand-rolled UTC conversion (#8234)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the civil_from_days copies behind the app's log timestamp, the
Measurement Log's date/time, and the perf report date with chrono as a
workspace dependency (reusing the version already in the tree). Output formats
are unchanged and pinned by the existing tests. AGENTS.md now says time goes
through chrono.

Follow-up to storytold/cadcraft#1785

* Never panic on a clock outside chrono's range; keep 5-digit years unprefixed

chrono's From<SystemTime> unwraps internally, so a clock past year 262143
panicked. logging::timestamp and analysis_cmds::now_iso now read the duration
since the epoch and go through the fallible DateTime::from_timestamp, reading
an out-of-range or pre-epoch clock as the epoch as the old saturating code
did. All three ISO helpers (timestamp, now_iso, perf::iso_utc) write the year
with {:04} rather than %Y, which prefixes a '+' past year 9999.

Tests cover the far-future and year-10000 cases for timestamp and iso_utc.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* time: preserve old out-of-range date conversion (no epoch fallback)

`DateTime::from_timestamp(...).unwrap_or_default()` silently reset any
instant past chrono's year range to 1970-01-01, changing output the old
hand-rolled Howard-Hinnant code converted exactly (e.g. 10_000_000_000_000 s
was 318857-05-20 17:46:40, not the epoch). Restore the old conversion domain
with a cycle-based helper that routes the calendar through chrono (the
calendar repeats every 400 years = 146_097 days, so month/day/time come from
chrono and only the year is carried in i128, keeping every i64 exact).

- apps/photocraft/src/logging.rs: `timestamp` via `utc_parts`; keep the
  pre-1970 epoch clamp; u64 secs past i64::MAX saturate, not epoch.
- crates/engine/src/analysis_cmds.rs: extract pure `iso_from_unix` (cycle
  based) from `now_iso` so the conversion is tested directly.
- xtask/src/perf.rs: `iso_utc` cycle-based, accepts the full u64 range the
  old code did.

Pinned old outputs in tests:
  logging u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch),
          10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z
  iso_from_unix 10_000_000_000_000 -> 318857-05-20T17:46:40Z,
          -10_000_000_000_000 -> -314918-08-13T06:13:20Z, i64 extremes
  iso_utc u64::MAX -> 584554051223-11-09T07:00:15Z (was epoch),
          10_000_000_000_000 -> 318857-05-20T17:46:40Z

* Don't build a year-318857 SystemTime unchecked in the logging test

Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-22 13:54:10 -04:00
Dylan M. Taylor c3c6c63074 security: encode printer names in IPP request paths 2026-10-22 07:12:25 -04:00
Dylan M. Taylor 5338f1de46 security: budget nested action playback and preserve batch auth 2026-10-22 07:12:21 -04:00
Dylan M. Taylor 81c694a3f9 security: bound RAW linked-directory metadata reads 2026-10-22 07:12:17 -04:00
Dylan M. Taylor 7c78c951e4 security(io): index comp IDs in PSD layer-comp import
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)
2026-10-22 07:12:12 -04:00
Dylan M. Taylor e75790b878 security(io): index XCF parasite names for duplicate replacement
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)
2026-10-22 07:12:06 -04:00
Dylan M. Taylor f42d7ef41a security(codecs): index FITS header keywords for O(1) lookup
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)
2026-10-22 07:11:58 -04:00
Dylan M. Taylor dc6057d3f2 security(engine): deduplicate ATN action names in near-linear time
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)
2026-10-22 07:11:54 -04:00
Dylan M. Taylor 3495a4b225 security(codecs): dedup Cryptomatte keys and cache stream lists
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)
2026-10-22 07:11:50 -04:00
Dylan M. Taylor 19e8c0d7cd security(codecs): stop quadratic rescans in XMP value scanning
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)
2026-10-22 07:11:45 -04:00
Dylan M. Taylor ac7eca536a security(engine): convert spelling offsets in one pass
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)
2026-10-22 07:11:41 -04:00
Dylan M. Taylor 2c02d06392 security(engine): count GIF frame delays with a map
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)
2026-10-22 07:11:37 -04:00
Dylan M. Taylor ef6092b125 security(xtask): validate corpus paths and bound symlink traversal
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.
2026-10-22 07:11:30 -04:00
Dylan M. Taylor 5adebc5d6d security(affinity): index object fields for O(1) lookup
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)
2026-10-22 07:11:24 -04:00
Dylan M. Taylor 29e555c2f6 security(affinity): index live-corner radii by record
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)
2026-10-22 07:11:20 -04:00
Dylan M. Taylor 7c07280b66 security(affinity): bound raw child-slot traversal work
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)
2026-10-22 07:11:15 -04:00
Dylan M. Taylor d0ad7ddb6f security(psd): check padding length before scanning it
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)
2026-10-22 07:11:10 -04:00
Dylan M. Taylor 58cd22d4ff security(doc): compute auto-slice coverage per band, not per cell
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)
2026-10-22 07:11:06 -04:00
Dylan M. Taylor 9e8a3eecf0 security(cms): bound inverse-curve and CLUT interpolation work
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)
2026-10-22 07:11:02 -04:00
Dylan M. Taylor fed04ecef3 security(codecs): budget Cryptomatte per-pixel pair capacity
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)
2026-10-22 07:10:58 -04:00
Dylan M. Taylor 867caefb5c security(codecs): admit deep-EXR output size and validate chunks first
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)
2026-10-22 07:10:54 -04:00
Dylan M. Taylor 9edcb97687 security(codecs): admit HEIF coded image size, not just transformed
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)
2026-10-22 07:10:49 -04:00
Dylan M. Taylor 1f2be70057 security(raw): bound TIFF structure-dump output
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)
2026-10-22 07:10:44 -04:00
Dylan M. Taylor c5a6afcf68 security(raw): reject tiled Sony cRAW and cap row references
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)
2026-10-22 07:10:40 -04:00
Dylan M. Taylor 163a0000a5 security(raw): position RW2 bit cursor in constant time
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)
2026-10-22 07:10:36 -04:00
Dylan M. Taylor 53bee4aa81 security(raw): interpolate gain maps per pixel, not per full row
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)
2026-10-22 07:10:32 -04:00
Dylan M. Taylor bf98617e20 security(io): apply caller limits to RAW preview decoding
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)
2026-10-22 07:10:28 -04:00
Dylan M. Taylor 2e2faec1a3 security(raw): cap lossless-JPEG Huffman table builds
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)
2026-10-22 07:10:24 -04:00
Dylan M. Taylor 8c0a9cb185 security(raw): bound cumulative retained tile memory
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)
2026-10-22 07:10:19 -04:00
Dylan M. Taylor 0e1a7a8b2a security(io): bound PDN layer-name copies
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)
2026-10-22 07:10:15 -04:00
Dylan M. Taylor bd109515a9 security(io): bound XCF copied layer metadata
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)
2026-10-22 07:10:12 -04:00
Dylan M. Taylor 7016152f22 security(compose): bound dense mask working set
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)
2026-10-22 07:10:08 -04:00
Dylan M. Taylor f65832931b security(compose): cap uncached pattern conversion
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)
2026-10-22 07:10:03 -04:00
Dylan M. Taylor 42d8aad56f security(psd): index slice IDs for trailing descriptor lookup
The version-6 slices parser matched each trailing descriptor ID against
the slice list with iter_mut().find(), an O(n*m) scan over two
file-controlled collections. A crafted resource with many slices and
many unmatched descriptor IDs could therefore cause quadratic CPU work
during import.

Build an ID-to-first-index map once and use it for descriptor lookups.
or_insert preserves the existing first-match behavior for duplicate IDs.

Finding: cf3f9ceb-d6df-4087-a2d6-dbe0f12657c3 (CWE-407)
2026-10-21 21:44:52 -04:00
Dylan M. Taylor f4bc90b93f ci: pin GitHub Actions to full commit SHA (SonarCloud githubactions:S7637) (#8368)
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.
2026-10-21 20:12:22 -04:00
Dylan M. Taylor 3ec52a9ff1 security: enforce HTTPS on curl downloads (SonarCloud S6506) (#8369)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 20:11:03 -04:00
Dylan M. Taylor 9b18f57344 test: use tokio::time::sleep in async test poll loop (SonarCloud rust:S7488) (#8373)
The multi-thread #[tokio::test] polled with a blocking std::thread::sleep,
stalling a runtime worker. Use tokio::time::sleep(..).await instead.
2026-10-21 19:35:38 -04:00
Dylan M. Taylor 5933793982 security: prevent script injection in fuzz workflow (SonarCloud S7630)
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).
2026-10-21 17:08:18 -04:00
Dylan M. Taylor 87fa191818 ci: add sonar-project.properties for CI-based analysis
Marks the project ineligible for SonarCloud Automatic Analysis, resolving the
CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds.
2026-10-21 16:00:47 -04:00
Dylan M. Taylor 8e518d8169 ci: add SonarCloud scan via shared reusable workflow
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).
2026-10-21 15:47:04 -04:00
Dylan M. Taylor 04d0212c7a Merge pull request #6448 from dylanmtaylor/ci/shared-nix-workflow
ci: switch to shared nix workflow from craft-common-actions
2026-10-21 03:40:26 -04:00
Dylan M. Taylor 8e7841554e Merge pull request #7429 from dylanmtaylor/docs/add-sonar-coderabbit-badges
docs: add SonarCloud and CodeRabbit badges to README
2026-10-20 11:30:45 -04:00
Dylan M. Taylor 050c8ebed8 docs: put SonarCloud and CodeRabbit badges on their own line 2026-10-18 14:47:45 -04:00
Dylan M. Taylor 9185b64b94 ci: configure Dependabot for Cargo and GitHub Actions
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
2026-10-18 12:31:45 -06:00
Dylan M. Taylor 18f8d33577 docs: add SonarCloud and CodeRabbit badges to README 2026-10-18 11:00:42 -04:00
Dylan M. Taylor cddb6fa21c ci: switch to shared nix workflow from craft-common-actions 2026-10-15 23:54:15 -04:00
Dylan M. Taylor 0cc161e2a8 ci: delegate FreeBSD workflow to craft-common-actions and add nix check 2026-10-15 22:04:09 -04:00
Dylan M. Taylor 19f427aa00 ci: refactor workflows to use storytold/craft-common-actions (#3432)
* 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
2026-10-11 11:50:48 -04:00
a1de9ce4c8 fix(ui): respect native desktop scale under Auto UI scaling (#1278) (#3165)
* 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>
2026-10-11 10:53:55 -04:00