prism branch T-2132/bugfix… commits 4 + review fixes files 3 touched lines +688 / -12 findings 0 major

Pre-push review: T-2132 remote raster image memory exhaustion

Fix for T-2132 (PR #355, round 4): remote raster images could exhaust memory after decompression. ImageIO pre-flight budgeting with per-frame dimension scanning, a bounded downsample fallback, and a new shared SharedConcurrency.decodeSemaphore. Regenerated after commits 09212f6/b03f03b so the artifact reflects per-frame budgeting and the shared decode bound.

At a glance

  • Core fix: decodeImageData now inspects declared width/height/frame-count via ImageIO before decoding; over-budget or unprovable images are downsampled to ≤4096px via CGImageSourceCreateThumbnailAtIndex instead of decoded unbounded.
  • Budget: width × height × 4 bytes × frames ≤ 64 MB (4096²×4), a quarter of ImageCache's 256 MB budget; every frame's dimensions are scanned (max 64 frames, failing closed past that).
  • New concurrency bound: SharedConcurrency.decodeSemaphore (limit 4) — needed because PrismDocSchemeHandler builds a fresh ImageLoader per request, so actor isolation alone cannot serialize decodes across requests.
  • Review fixes applied: cancellation guard before the guarded decode, corrected the retained-vs-transient memory claim on the semaphore doc, and pinned three implicit invariants in comments (remote-only semaphore scope, per-axis-maxima overestimate direction, frame-0 downsample).
  • Verification: make lint clean, make test-quick green locally. GitHub Actions is billing-blocked — red checks are zero-job billing failures, not code failures.
  • 15 findings raised in total: 8 minor, 7 nit — 8 fixed, 7 skipped with reasons (test-helper refactors and pre-existing patterns earmarked as follow-ups).

Verdict

Ready to push

All three review agents returned zero critical or major findings. The semaphore design is deadlock-free by construction (network slot fully released before the decode slot is acquired; every acquire has a matching release on all error paths), the integer-overflow reasoning in exceedsDecodeBudget is exact, and the fail-closed routing (unknown dimensions, unscannable frame counts) is pinned by tests against real encoded fixtures. The minor findings were either fixed in this review (a cancellation guard before the decode, four documentation-accuracy corrections) or deliberately skipped with recorded reasons. Lint is clean and the full macOS unit-test suite passes, including all six ImageLoader suites. The five commits main gained since the branch point touch none of the PR's files.

Review findings

15 raised · 7 fixed · 8 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

Prism displays images embedded in markdown documents. Before this fix, a downloaded image was checked for file size (50 MB cap) but not for how big it becomes once unpacked. Image files are compressed — a tiny few-hundred-KB file can declare itself to be a 50,000×50,000-pixel picture. Displaying it means unpacking every pixel into memory (~10 GB for that example), which can crash the app. This is called a decompression bomb.

The fix reads the image's header first — declared width, height, and number of animation frames — and estimates the unpacked size before decoding anything. If it would exceed a ~64 MB budget, Prism builds a smaller version (at most 4096 pixels on the longest edge) instead of unpacking the whole thing. A second safeguard limits the app to 4 simultaneous image decodes, so many medium-sized images arriving at once cannot stack up either.

Why It Matters

A malicious or accidental oversized image in a document can no longer exhaust memory and crash Prism. Normal-sized images are completely unaffected — they decode exactly as before.

Key Concepts

  • Decompression bomb: a small file with an enormous unpacked size — like a zip bomb, but for images.
  • Downsampling: decoding straight to a smaller image instead of shrinking a huge one after the fact.
  • Semaphore: a counter that limits how many tasks may do something at the same time.

Changes Overview

ImageLoader.decodeImageData now routes through ImageIO. It parses the data with CGImageSourceCreateWithData, scans every frame's declared dimensions (up to maxScannedFrames = 64), and checks width × height × 4 × frameCount ≤ 64 MB plus a 4096px per-edge cap (exceedsDecodeBudget). Pass → the original full PlatformImage(data:) decode. Fail, unknown dimensions, or too many frames to scan → CGImageSourceCreateThumbnailAtIndex bounded to 4096px, producing a single still frame.

The remote path releases its network-semaphore slot before decoding, then takes a slot on the new SharedConcurrency.decodeSemaphore (limit 4) — needed because PrismDocSchemeHandler constructs a fresh ImageLoader per request, so actor isolation alone cannot serialize decodes across requests.

Implementation Approach

  • Pure nonisolated static decision functions so the budget logic is unit-testable without constructing giant bitmaps.
  • The frame-scan core is parameterised over a frame-lookup closure to pin a branch ImageIO cannot produce a real fixture for (a counted-but-dimensionless later frame).
  • Everything fails closed: unknown → bounded path, never an unbounded decode.

Trade-offs

  • Oversized images are downsampled rather than rejected — graceful degradation over hard failure.
  • An over-budget animated image renders as a single still frame (animation lost) — deliberate, since macOS NSImage(data:) eagerly materializes every frame.
  • The 64-frame scan cap trades exactness for bounded header-parse work; at 65+ frames the per-frame budget is under ~1 MB, so nearly all such images would be downsampled anyway.

Technical Deep Dive

The budget compare perFrameBytes > maxDecodedByteBudget / frames is integer division, exact and overflow-free because the preceding dimension guard bounds perFrameBytes ≤ maxDecodedByteBudget — the comment proves a > b/c ⟺ a·c > b for positive integers under that bound. Commit 09212f6 closed the frame-0-only bypass: multi-page TIFF/HEIC allow per-frame dimensions to differ, so a 1×1 first page must not vouch for a 4200×600 later page — the scan takes per-axis maxima across all frames, and any frame with unreported dimensions makes the image unprovable. Semaphore ordering is deadlock-free by construction: the network slot is fully released before the decode slot is acquired (no nesting), and each acquire has a matching release on every error path. 4 decode slots × 64 MB budget = 256 MB peak retained decode memory, deliberately matched to ImageCache's total budget (operational-hardening Decision 4).

Architecture Impact

SharedConcurrency now owns two named bounds (network 6, decode 4); the budget constants are single-source knobs. The bounded decode path also covers local files and data URIs via the shared decodeImageData; only the remote path takes the semaphore, an invariant now documented at the function (long-lived actors serialize the other paths per instance).

Potential Issues

  • CGImageSourceCreateThumbnailAtIndex's transient decode peak is format-dependent — PNG has no subsampled decode, so a 20k×20k PNG bomb transiently costs well over the 64 MB retained budget inside the "bounded" downsample. The semaphore bounds how many such peaks coincide, not their size (comment corrected in this review). If real-world OOMs appear, drop the decode limit to 2.
  • The decode semaphore guards only the remote path; local-file and data-URI decodes are serialized by long-lived actor instances instead — protection that is implicit in the architecture, now pinned by a comment so per-request local loaders don't silently reopen the spike.
  • Downsampling always renders frame 0, so a legitimate multi-page TIFF whose first page is a thumbnail renders as that thumbnail — acceptable bomb defence, documented.

Completeness Assessment

Fully implemented: pre-flight budgeting across all frames, bounded fallback, shared decode bound, cancellation guard, and six test suites (pure logic, PNG/GIF/TIFF E2E bombs, the 64-frame boundary, the unreported-dimensions contract). Nothing partial or missing relative to the ticket. Follow-up candidates (not gaps in this fix): a dedicated .cancelled error case, a shared scheme-handler ImageLoader to stop per-request URLSession churn, and chunked reads in streamDownload.

Important changes — detailed

ImageLoader: ImageIO pre-flight budget check before any decode

prism/Services/ImageLoader.swift

Why it matters. This is the fix itself. PlatformImage(data:) allocates memory proportional to declared pixels, not compressed bytes — the 50 MB network cap never bounded decoded size. The pre-flight makes the unbounded decode unreachable for unproven images.

What to look at. decodeImageData / declaredShapeAllowsFullDecode / exceedsDecodeBudget

Takeaway. Guard the decode, not just the download: any pipeline that decompresses untrusted data needs a budget on the decompressed size, checked from headers before materializing anything.
Rationale. Inspecting declared dimensions via CGImageSource is a header parse (microseconds); the alternative — decoding and then checking — allocates the bomb before measuring it. Stated in the doc comment on decodeImageData.

ImageLoader: bounded downsample fallback instead of rejection

prism/Services/ImageLoader.swift

Why it matters. User-visible behaviour choice: an over-budget or unknown-size image still renders (at ≤4096px, single frame) rather than showing an error placeholder. Unusual-but-valid formats keep working.

What to look at. decodeImageData downsample branch (CGImageSourceCreateThumbnailAtIndex, kCGImageSourceShouldCacheImmediately)

Takeaway. kCGImageSourceShouldCacheImmediately: true forces the pixel decode to happen inside the semaphore-guarded region — false would defer the real decode to first draw, outside the bound, potentially on the main thread.
Rationale. Rejecting would break legitimate large images and formats ImageIO can decode but not pre-size; the thumbnail API keeps the retained bitmap bounded regardless of the true size. Stated in comments and commit 18c1716.

ImageLoader: per-frame dimension scan closes the frame-0 bypass

prism/Services/ImageLoader.swift

Why it matters. Correctness of the budget: multi-page TIFF/HEIC allow per-frame dimensions to differ, so budgeting frame 0 × frameCount let a tiny first page vouch for an enormous later one (found in review round 1, fixed in 09212f6).

What to look at. maxDeclaredDimensions(frameCount:dimensionsForFrame:) + maxScannedFrames = 64

Takeaway. Parameterise a scan over its lookup closure when the platform API cannot produce a fixture for a branch — ImageIO drops unsizable frames from CGImageSourceGetCount, so the unreported-later-frame contract is only testable through injection.
Rationale. The scan already visits every frame for correctness; capping at 64 frames bounds the header-parse work itself and fails closed — at 65+ frames the per-frame budget is under ~1 MB, so such images would be downsampled anyway. Stated in the maxScannedFrames doc comment.

SharedConcurrency: decodeSemaphore(limit: 4) — cross-instance decode bound

prism/Services/SharedConcurrency.swift

Why it matters. Without it, the per-frame budget only bounds each decode individually: PrismDocSchemeHandler constructs a fresh ImageLoader per remote-image request, so N in-flight images meant N concurrent ~64 MB decodes — the spike T-2132 closes, reopened through concurrency.

What to look at. SharedConcurrency.decodeSemaphore

Takeaway. Actor isolation serializes per instance, not per resource — when callers construct instances per request, a shared semaphore is what actually bounds the resource.
Rationale. 4 slots × 64 MB budget = 256 MB retained, matched to ImageCache's total budget (operational-hardening Decision 4). Comment corrected in this review: the cap is on retained bitmaps; transient downsample peaks are format-dependent and only bounded in number.

loadRemote: release network slot before decode, plus cancellation guard

prism/Services/ImageLoader.swift

Why it matters. Ordering determines both deadlock-freedom and what memory is bounded: strict release-then-acquire means no task ever holds both slots (no cross-semaphore deadlock), at the cost of unbounded parked compressed bodies while queued for a decode slot. The new guard stops a cancelled request from paying for a full decode.

What to look at. loadRemote decode-slot region

Takeaway. A semaphore's fast path that doesn't observe cancellation is a common gap — a request cancelled during download teardown (page reloads on theme change, typography reflow, recovery replay) would otherwise decode up to 64 MB for a dead request.
Rationale. Fetch throughput is deliberately favoured over parked-body bounds: compressed bodies are typically far smaller than their 50 MB cap, and swapping the order would stall unrelated fetches whenever decode saturates. Trade-off now stated explicitly in the comment (was only half-argued before this review).

Tests: six suites over real encoded fixtures

prismTests/ImageLoaderTests.swift

Why it matters. The E2E fixtures are genuinely bomb-shaped: solid-colour images compress to KB regardless of declared dimensions, so a 5000×5000 PNG (~450 KB file, ~95 MB decoded) exercises the exact mismatch the ticket describes without an adversarial encoder.

What to look at. ImageLoaderDecompressionBombTests + makeSolidColorPNGData / makeAnimatedGIFData / makeMultiPageTIFFData

Takeaway. Solid-colour fixtures are the cheap way to build decompression-bomb test data: maximum compression ratio, deterministic, platform-independent via CGContext + CGImageDestination.
Rationale. Boundary pins (exactly 64 frames vs 65) assert both halves — the 65-frame case also asserts the byte budget alone would pass, proving the rejection comes from the frame-count cap, not the budget. Stated in test comments.

Key decisions

Downsample oversized images instead of rejecting them.

An over-budget, unknown-size, or unscannable image renders as a ≤4096px single frame rather than failing with an error placeholder. Graceful degradation: legitimate large images and unusual-but-valid formats keep working; only the memory profile changes.

64 MB decode budget, sized as a quarter of ImageCache's 256 MB.

maxDecodedByteBudget = 4096² × 4. Kept as an independent constant rather than derived from ImageCache.costLimit / 4 so an unrelated cache tune cannot silently change decode behaviour — the doc comment cross-references operational-hardening Decision 4 instead.

Frame count folds into the budget because NSImage(data:) eagerly materializes every frame.

On macOS an animated image decodes every frame as a separate bitmap rep, so a modest-resolution GIF with many frames is as dangerous as one huge frame. The rule is applied cross-platform for consistent behaviour, which is documented.

Scan every frame's dimensions, capped at 64 frames, failing closed.

Frame-0-only budgeting was a bypass (fixed in 09212f6); per-frame scanning must itself be bounded since it is a header parse per frame. Past 64 frames the image routes to the bounded downsample — barely a behaviour change, since at 65+ frames the per-frame budget is under ~1 MB.

Release the network slot before acquiring the decode slot.

CPU-bound decode work never stalls unrelated fetches, and no task ever holds both slots (deadlock-free by construction). The cost — queued tasks park compressed bodies (≤50 MB each) bounded only by WebKit's request fan-out — is accepted to favour fetch throughput; the comment now states this trade-off explicitly (review fix).

Decode semaphore guards only the remote path.

Local-file and data-URI decodes run through long-lived loader actors whose isolation serializes them per instance, and the scheme handler serves local bytes to WebKit undecoded — so no over-parallel local decode exists today. The invariant was implicit; a review fix pins it in decodeImageData's doc comment.

(inferred — not stated by the author.)
Per-axis maxima as the multi-frame estimate, accepting overestimation.

Max-width × max-height can synthesize a phantom frame larger than any real one, and largest-frame × count over-charges mixed-size containers — both only err toward downsampling, never toward an unbounded decode. An exact per-frame byte sum was considered in this review and skipped: the current seam is pinned by tests, and the error direction is safe. Now documented at maxDeclaredDimensions.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
minorImageLoader.loadRemoteReleasing the network slot before acquiring the decode slot lets completed downloads park compressed bodies (≤50 MB each) unboundedly while queued for a decode slot — raised independently by the efficiency and quality agents. The existing comment argued only against holding the network slot through the decode, not against the swap.Documented the trade-off explicitly in the comment: parked bodies are bounded in practice by WebKit's per-page request fan-out, and fetch throughput is deliberately favoured. Reordering (acquire-decode-then-release-network, deadlock-free) noted as the alternative if this bites in practice.
minorSharedConcurrency commentThe decodeSemaphore doc claimed a ~256 MB cap on 'peak transient decode memory', but CGImageSourceCreateThumbnailAtIndex transiently materializes the full-resolution bitmap for formats without subsampled decode (notably PNG — the exact bomb format). The cap only holds for retained bitmaps.Comment corrected: 256 MB bounds concurrently retained bitmaps; transient peaks are format-dependent and the semaphore bounds how many can coincide, not their size.
minorImageLoader.loadRemote cancellationAsyncSemaphore.acquire only observes cancellation while waiting — the fast path returns without a check — so a request cancelled during download teardown (page reloads: theme change, typography reflow, recovery replay) that finds a free decode slot performs the full guarded decode (up to a 64 MB downsample) for a dead request.Added a Task.isCancelled guard between the decode-slot acquire and the decode, releasing the slot and throwing the same cancellation error shape.
minorImageLoader.decodeImageData scopeThe decode semaphore guards only the remote path; local-file and data-URI decodes rely on long-lived actor instances for serialization — correct today, but implicit: per-request local loaders would silently reopen the spike.Doc comment added to decodeImageData recording why only the remote path takes the semaphore and what would invalidate that.
nitAsyncSemaphore docDoc comment still said the semaphore is 'used by ImageLoader to cap concurrent remote image fetches' — it now also caps decodes, and SVGSourceLoader shares the network semaphore.Comment updated to cover both uses via SharedConcurrency.
nitImageLoader.maxDeclaredDimensionsPer-axis maxima can pair one frame's width with another frame's height, overestimating the true footprint — safe direction (over-triggers downsampling only), but unstated.One-line comment documenting the overestimate and its safe error direction.
nitImageLoader downsample branchThe downsample always renders frame 0, so a legitimate multi-page TIFF whose first page is a small thumbnail renders as that thumbnail — fine as bomb defence, worth stating.Comment added on the downsample branch.
minorImageLoader budget estimateSumming exact per-frame bytes (same scan cost) would avoid needlessly downsampling legitimate mixed-size containers (e.g. one 3000×3000 page + one 2000×2000 page = 52 MB actual, budgeted as 72 MB → animation/pages lost).Skipped: the overestimate only errs toward downsampling (never unbounded decode), and the exact-sum change would alter the test-pinned exceedsDecodeBudget/maxDeclaredDimensions seam — refactoring that requires test changes is out of scope for pre-push review. Documented instead; candidate follow-up if real containers hit it.
minorImageLoadError cancellation shapeA cancellation while waiting for the decode slot reports .networkError("Cancelled") — wrong phase, and 'Cancelled' is a raw English string interpolated into the localized error template, which CLAUDE.md bans. This is the third instance of a pre-existing pattern.Skipped: consistent with the two pre-existing sites; a dedicated .cancelled case on ImageLoadError (fixing all three sites plus localisation) is a small self-contained follow-up, not a this-PR change.
minorloadRemote acquire/release shapeThe acquire/work/release-on-error/release dance now appears twice in loadRemote — the exact shape where a future edit forgets one release path. A typed-throws withSlot helper would collapse both.Skipped: the manual pattern is the codebase-wide convention (WebViewPool, DiagramCache, SVGSourceLoader all use it; no scoped helper exists anywhere). Introducing one only here would diverge; a codebase-wide withPermit refactor is follow-up material.
minorPrismDocSchemeHandler (pre-existing)A fresh ImageLoader — and therefore a fresh ephemeral URLSession with a retained delegate — is constructed per remote-image request: no connection reuse across images from the same host, and sessions are never invalidated. The new decode semaphore exists because of this per-request construction.Skipped: pre-existing architecture the diff works around, not code this PR adds. Follow-up ticket candidate: one shared loader (with decodeImageData made nonisolated so the semaphore stays the sole decode bound).
minorImageLoader.streamDownload (pre-existing)Byte-at-a-time accumulation (for try await byte in bytes) appends up to 50 MB with a bounds check per byte — dwarfs everything this diff added on the download path. Same pattern in URLDocumentLoader.Skipped: untouched by this diff; separate ticket material (chunked reads).
minorTest fixture buildersmakeSolidColorPNGData / makeAnimatedGIFData / makeMultiPageTIFFData each repeat ~15 lines of CGContext + CGImageDestination boilerplate; makeMinimalPNGData is now subsumed by the new platform-independent builder; UTIs are stringly-typed; pixelDimensions(of:) is the third copy of the cross-platform CGImage extraction.Skipped: all in test files — pre-push review does not refactor test files (only fixes actual test bugs, and these tests are correct). Reasonable cleanup batch if the file is touched again.
nitFull-decode double parseCGImageSourceCreateWithData parses headers for the pre-flight, then PlatformImage(data:) re-parses from scratch on the full-decode path.Skipped (agent's own conclusion): decoding from the existing source would lose animated-image semantics and UIKit's EXIF-orientation handling that the budget logic depends on; the header re-parse is microseconds against the pixel decode.
nitkCGImageSource optionsVerification that the thumbnail options are correct — ShouldCacheImmediately: true keeps the pixel decode inside the semaphore-guarded region (false would defer it to first draw, outside the bound); ThumbnailFromImageAlways: true avoids returning a ~160px embedded EXIF thumbnail.No change needed — both options confirmed as the right call by the efficiency review.

Per-file diffs

Click to expand.

prism/Services/ImageLoader.swift Modified +232 / -12
diff --git a/prism/Services/ImageLoader.swift b/prism/Services/ImageLoader.swiftindex e772af9..f33a4d2 100644--- a/prism/Services/ImageLoader.swift+++ b/prism/Services/ImageLoader.swift@@ -1,11 +1,19 @@ import Foundation+import ImageIO import SwiftUI +#if canImport(UIKit)+import UIKit+#elseif canImport(AppKit)+import AppKit+#endif+ // MARK: - AsyncSemaphore  /// Cancellation-safe async semaphore for limiting concurrency. ///-/// Used by ImageLoader to cap concurrent remote image fetches.+/// Used via `SharedConcurrency` to cap concurrent remote fetches+/// (ImageLoader, SVGSourceLoader) and guarded image decodes. /// Waiters are resumed in FIFO order when slots become available. actor AsyncSemaphore {     private let limit: Int@@ -88,6 +96,7 @@ actor ImageLoader {     }      private var networkSemaphore: AsyncSemaphore { SharedConcurrency.networkSemaphore }+    private var decodeSemaphore: AsyncSemaphore { SharedConcurrency.decodeSemaphore }      /// Loads an image from the given resolved source.     ///@@ -120,10 +129,7 @@ actor ImageLoader {         } catch {             throw .fileNotFound         }-        guard let image = PlatformImage(data: data) else {-            throw .decodeFailed-        }-        return image+        return try decodeImageData(data)     }      /// Maximum size for remote image downloads (50 MB).@@ -137,23 +143,62 @@ actor ImageLoader {             throw .networkError("Cancelled")         } -        let image: PlatformImage+        let data: Data         do {-            image = try await performRemoteLoad(url)+            data = try await performRemoteLoad(url)         } catch {             await networkSemaphore.release()             throw error         }         await networkSemaphore.release()+        // Decode after releasing the network slot: the guarded decode can be+        // genuinely heavy (up to a 64 MB downsample), and holding one of the+        // shared network-semaphore slots through it would stall unrelated+        // remote fetches for CPU work. Decode still needs its own bound,+        // shared across ImageLoader instances: PrismDocSchemeHandler+        // constructs a fresh loader per remote-image request, so actor+        // isolation alone cannot serialize decodes across requests — without+        // this semaphore, N in-flight images would mean N concurrent ~64 MB+        // decodes, reopening the memory spike T-2132 closes.+        //+        // Trade-off of releasing before acquiring: a task queued on the+        // decode semaphore holds its compressed body (≤ 50 MB) while it+        // waits, and nothing but WebKit's per-page request fan-out bounds+        // how many bodies are parked. Swapping the order (acquire decode,+        // then release network) would bound parked bodies at the network+        // limit but stall unrelated fetches whenever decode saturates;+        // fetch throughput is deliberately favoured here since compressed+        // bodies are typically far smaller than their 50 MB cap.+        do {+            try await decodeSemaphore.acquire()+        } catch {+            throw .networkError("Cancelled")+        }+        // The semaphore's fast path does not observe cancellation, and the+        // request may have been cancelled during the download — don't spend+        // up to a 64 MB decode on a dead request.+        guard !Task.isCancelled else {+            await decodeSemaphore.release()+            throw .networkError("Cancelled")+        }+        let image: PlatformImage+        do {+            image = try decodeImageData(data)+        } catch {+            await decodeSemaphore.release()+            throw error+        }+        await decodeSemaphore.release()         return image     } -    /// Performs the actual remote image fetch with validation.+    /// Performs the actual remote image fetch with validation, returning the+    /// validated (still compressed) response body.     ///     /// The body is streamed via `URLSession.bytes(from:)` so the 50 MB cap is     /// enforced without ever buffering an oversized payload. The Content-Length     /// header (when present) is checked first for early rejection.-    private func performRemoteLoad(_ url: URL) async throws(ImageLoadError) -> PlatformImage {+    private func performRemoteLoad(_ url: URL) async throws(ImageLoadError) -> Data {         do {             let (data, response) = try await streamDownload(url: url)             guard let httpResponse = response as? HTTPURLResponse else {@@ -168,7 +213,7 @@ actor ImageLoader {                !ImagePathResolver.allowedRemoteSchemes.contains(scheme) {                 throw ImageLoadError.blockedScheme             }-            return try decodeImageData(data)+            return data         } catch let error as ImageLoadError {             throw error         } catch is CancellationError {@@ -215,12 +260,187 @@ actor ImageLoader {         return (data, response)     } +    /// Largest width/height (in pixels) a fully decoded bitmap is allowed to+    /// have. Also used as the target edge for downsampled thumbnails.+    ///+    /// 4096 comfortably covers real-world document images (well past typical+    /// screen resolutions) while bounding worst-case decoded RGBA memory to+    /// `maxDecodedByteBudget`.+    static let maxDecodedDimension = 4_096++    /// Maximum estimated decoded byte cost — width * height * 4 (RGBA) —+    /// **including every frame** of a multi-frame image (GIF/APNG/HEIC+    /// sequences). Frame count matters because `NSImage(data:)` on macOS+    /// eagerly materializes every frame of an animated image as a separate+    /// bitmap representation, so a modest-resolution image with thousands of+    /// frames can still exhaust memory even though no single frame is large.+    ///+    /// Sized to match `maxDecodedDimension` (4096 * 4096 * 4 bytes ≈ 64 MB) —+    /// a quarter of `ImageCache`'s 256 MB total budget (see+    /// specs/operational-hardening/decision_log.md, Decision 4).+    static let maxDecodedByteBudget = maxDecodedDimension * maxDecodedDimension * 4++    /// Upper bound on how many frames the pre-flight dimension scan inspects.+    ///+    /// The budget must consider every frame's declared dimensions, not just+    /// frame 0's — container formats with per-frame sizes (multi-page TIFF,+    /// HEIC sequences) could otherwise hide an enormous page behind a tiny+    /// first one. Scanning is a header parse per frame, so it must itself be+    /// bounded: a container with more frames than this routes straight to the+    /// bounded downsample path. That is barely a behaviour change — at 65++    /// frames the per-frame budget is under ~1 MB (≈512×512 px), so almost+    /// any real animation that long would be downsampled anyway.+    static let maxScannedFrames = 64++    /// Whether a bitmap with these dimensions/frame count would exceed the+    /// decode budget and must be downsampled instead of fully decoded.+    ///+    /// A pure, `nonisolated` decision so it can be unit tested directly+    /// without constructing real (and potentially memory-heavy) image data.+    nonisolated static func exceedsDecodeBudget(width: Int, height: Int, frameCount: Int) -> Bool {+        guard width > 0, height > 0 else { return true }+        let frames = max(frameCount, 1)+        if width > maxDecodedDimension || height > maxDecodedDimension {+            return true+        }+        // The dimension guard above bounds perFrameBytes to at most+        // maxDecodedByteBudget, so this integer-division compare is exact+        // (a > b / c ⟺ a * c > b for positive integers) and cannot overflow+        // for any frame count — no staged multiplication needed. It also+        // stays correct if maxDecodedByteBudget is ever decoupled from+        // maxDecodedDimension: the frames == 1 case degenerates to+        // perFrameBytes > maxDecodedByteBudget.+        let perFrameBytes = width * height * 4+        return perFrameBytes > maxDecodedByteBudget / frames+    }++    /// Largest declared pixel dimensions across every frame of the source,+    /// or `nil` when any frame's dimensions are unreported.+    ///+    /// Per-axis maxima can pair one frame's width with another frame's+    /// height (10×400 plus 300×20 is budgeted as 300×400 per frame), so the+    /// estimate may overstate the true decoded footprint — it only ever errs+    /// toward downsampling, never toward an unbounded decode.+    ///+    /// Frame 0 alone is not enough (T-2132 review): multi-page TIFF and HEIC+    /// sequences allow per-frame dimensions to differ, so a tiny first page+    /// could otherwise vouch for an enormous later one. Any unreported frame+    /// makes the whole image unprovable — the caller must treat it like an+    /// unknown-size single frame and take the bounded downsample path.+    nonisolated private static func maxDeclaredDimensions(+        in source: CGImageSource, frameCount: Int+    ) -> (width: Int, height: Int)? {+        maxDeclaredDimensions(frameCount: frameCount) { index in+            guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],+                  let width = properties[kCGImagePropertyPixelWidth] as? Int,+                  let height = properties[kCGImagePropertyPixelHeight] as? Int else {+                return nil+            }+            return (width, height)+        }+    }++    /// Core of the per-frame dimension scan, parameterised over the frame+    /// lookup so the "any unreported frame makes the whole image unprovable"+    /// contract is directly unit-testable. ImageIO drops any frame it cannot+    /// size from `CGImageSourceGetCount`, so a real fixture with a+    /// counted-but-dimensionless later frame cannot be constructed — the+    /// injected lookup is the only way to pin that branch.+    nonisolated static func maxDeclaredDimensions(+        frameCount: Int,+        dimensionsForFrame: (Int) -> (width: Int, height: Int)?+    ) -> (width: Int, height: Int)? {+        var maxWidth = 0+        var maxHeight = 0+        for index in 0..<frameCount {+            guard let dimensions = dimensionsForFrame(index) else {+                return nil+            }+            maxWidth = max(maxWidth, dimensions.width)+            maxHeight = max(maxHeight, dimensions.height)+        }+        return (maxWidth, maxHeight)+    }++    /// Whether the source's declared shape (frame count plus every frame's+    /// declared dimensions) proves that a full `PlatformImage(data:)` decode+    /// fits `maxDecodedByteBudget`. `false` routes to the bounded downsample+    /// path — including when the frame count exceeds `maxScannedFrames`, so+    /// an unscannable container fails closed rather than decoding unbounded.+    ///+    /// `nonisolated` and source-driven so the `maxScannedFrames` boundary can+    /// be unit tested against real encoded fixtures without a loader.+    nonisolated static func declaredShapeAllowsFullDecode(_ source: CGImageSource) -> Bool {+        let frameCount = CGImageSourceGetCount(source)+        guard frameCount >= 1, frameCount <= maxScannedFrames,+              let dimensions = maxDeclaredDimensions(in: source, frameCount: frameCount) else {+            return false+        }+        return !exceedsDecodeBudget(+            width: dimensions.width, height: dimensions.height, frameCount: frameCount+        )+    }+     /// Decodes raw image data into a platform image.+    ///+    /// Inspects declared pixel dimensions and frame count via `ImageIO`+    /// *before* materializing a full bitmap (T-2132). A small, highly+    /// compressed file can declare an enormous pixel count — decoding it+    /// directly via `PlatformImage(data:)` would allocate memory proportional+    /// to the decoded pixels regardless of the compressed download size. When+    /// the declared size exceeds the decode budget, the image is downsampled+    /// via `CGImageSourceCreateThumbnailAtIndex`, which keeps the *retained*+    /// bitmap bounded by the requested pixel size (transient decode peaks are+    /// format-dependent, but far below materializing the full-resolution,+    /// all-frame bitmap).+    ///+    /// Only the remote path wraps this in `SharedConcurrency.decodeSemaphore`:+    /// `PrismDocSchemeHandler` constructs a fresh loader per remote request,+    /// while local-file and data-URI decodes run through long-lived loader+    /// actors whose isolation already serializes them per instance (and the+    /// scheme handler serves local bytes to WebKit undecoded). If local loads+    /// ever gain per-request loaders, they need the semaphore too.     private func decodeImageData(_ data: Data) throws(ImageLoadError) -> PlatformImage {-        guard let image = PlatformImage(data: data) else {+        guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {             throw .decodeFailed         }-        return image+        // When ImageIO recognises the data but reports no pixel dimensions+        // (for any frame), the image's decoded size cannot be proven safe, so+        // it takes the bounded downsample path below rather than the unbounded+        // `PlatformImage(data:)` decode — an unknown size must be treated as+        // potentially a bomb, but the thumbnail decode is capped by+        // `maxDecodedDimension` regardless of the true size, so an+        // unusual-but-valid format still renders instead of failing.+        if Self.declaredShapeAllowsFullDecode(source) {+            guard let image = PlatformImage(data: data) else {+                throw .decodeFailed+            }+            return image+        }++        // Oversized, unknown-size, or decompression-bomb image: downsample a+        // single frame bounded by maxDecodedDimension instead of materializing+        // the full-resolution — and, for animated formats, all-frame — bitmap.+        // The downsample always renders frame 0, so a legitimate multi-page+        // container whose first page is a small thumbnail renders as that+        // page — acceptable for bomb defence, worth knowing for TIFFs.+        let thumbnailOptions: [CFString: Any] = [+            kCGImageSourceCreateThumbnailFromImageAlways: true,+            kCGImageSourceShouldCacheImmediately: true,+            kCGImageSourceCreateThumbnailWithTransform: true,+            kCGImageSourceThumbnailMaxPixelSize: Self.maxDecodedDimension,+        ]+        guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(+            source, 0, thumbnailOptions as CFDictionary+        ) else {+            throw .decodeFailed+        }++        #if canImport(UIKit)+        return PlatformImage(cgImage: thumbnail)+        #elseif canImport(AppKit)+        return PlatformImage(cgImage: thumbnail, size: NSSize(width: thumbnail.width, height: thumbnail.height))+        #endif     } } 
prism/Services/SharedConcurrency.swift Modified +14 / -0
diff --git a/prism/Services/SharedConcurrency.swift b/prism/Services/SharedConcurrency.swiftindex 008d969..58e10d5 100644--- a/prism/Services/SharedConcurrency.swift+++ b/prism/Services/SharedConcurrency.swift@@ -13,6 +13,20 @@ enum SharedConcurrency {     /// Limits total concurrent network fetches to 6.     /// Used by ImageLoader and SVGSourceLoader.     nonisolated static let networkSemaphore = AsyncSemaphore(limit: 6)++    /// Limits concurrent guarded image decodes to 4 (T-2132).+    ///+    /// The remote image path releases its network slot before decoding so+    /// CPU-bound decode work never stalls unrelated fetches, but decode then+    /// needs its own shared bound: `PrismDocSchemeHandler` constructs a fresh+    /// `ImageLoader` per request, so actor isolation alone cannot serialize+    /// decodes across requests. Four slots x `ImageLoader.maxDecodedByteBudget`+    /// (~64 MB) caps concurrently *retained* decoded bitmaps at ~256 MB —+    /// matching `ImageCache`'s total budget (specs/operational-hardening,+    /// Decision 4). Transient peaks inside the downsample can momentarily+    /// exceed the per-image budget for formats without subsampled decode+    /// (e.g. PNG); the semaphore bounds how many such peaks can coincide.+    nonisolated static let decodeSemaphore = AsyncSemaphore(limit: 4) }  /// Validates HTTP redirects for remote image/SVG fetches, rejecting targets
prismTests/ImageLoaderTests.swift Modified +442 / -0
diff --git a/prismTests/ImageLoaderTests.swift b/prismTests/ImageLoaderTests.swiftindex bc23011..a90c3f3 100644--- a/prismTests/ImageLoaderTests.swift+++ b/prismTests/ImageLoaderTests.swift@@ -1,5 +1,7 @@ import Testing import Foundation+import CoreGraphics+import ImageIO  #if canImport(UIKit) import UIKit@@ -662,4 +664,444 @@ enum ImageLoaderTestHelpers {         return bitmap.representation(using: .png, properties: [:])!         #endif     }++    /// Builds a solid-color PNG at the given pixel dimensions directly via+    /// CoreGraphics/ImageIO (platform-independent — no UIKit/AppKit+    /// rendering path involved).+    ///+    /// A single solid color compresses to a tiny file regardless of declared+    /// dimensions, which is exactly the "small compressed size, huge decoded+    /// size" shape T-2132 is about: this lets tests build a realistic+    /// decompression-bomb-shaped fixture without needing a genuinely+    /// adversarial encoder.+    static func makeSolidColorPNGData(width: Int, height: Int) -> Data {+        let colorSpace = CGColorSpaceCreateDeviceRGB()+        guard let context = CGContext(+            data: nil,+            width: width,+            height: height,+            bitsPerComponent: 8,+            bytesPerRow: 0,+            space: colorSpace,+            bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue+        ) else {+            fatalError("Failed to create bitmap context for \(width)x\(height) fixture")+        }+        context.setFillColor(CGColor(red: 1, green: 0, blue: 0, alpha: 1))+        context.fill(CGRect(x: 0, y: 0, width: width, height: height))+        guard let cgImage = context.makeImage() else {+            fatalError("Failed to create CGImage for \(width)x\(height) fixture")+        }++        let data = NSMutableData()+        guard let destination = CGImageDestinationCreateWithData(data, "public.png" as CFString, 1, nil) else {+            fatalError("Failed to create image destination")+        }+        CGImageDestinationAddImage(destination, cgImage, nil)+        guard CGImageDestinationFinalize(destination) else {+            fatalError("Failed to finalize PNG encoding")+        }+        return data as Data+    }++    /// Builds an animated GIF with `frameCount` identical solid-color frames+    /// at the given pixel dimensions.+    ///+    /// Solid-color frames compress to a tiny file, so a modest per-frame+    /// resolution with many frames reproduces the multi-frame variant of the+    /// T-2132 shape: a small download whose *total* decoded footprint+    /// (frames x width x height x 4) blows past the decode budget even though+    /// no single frame does.+    static func makeAnimatedGIFData(width: Int, height: Int, frameCount: Int) -> Data {+        let colorSpace = CGColorSpaceCreateDeviceRGB()+        guard let context = CGContext(+            data: nil,+            width: width,+            height: height,+            bitsPerComponent: 8,+            bytesPerRow: 0,+            space: colorSpace,+            bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue+        ) else {+            fatalError("Failed to create bitmap context for \(width)x\(height) GIF frame")+        }+        context.setFillColor(CGColor(red: 0, green: 0, blue: 1, alpha: 1))+        context.fill(CGRect(x: 0, y: 0, width: width, height: height))+        guard let frame = context.makeImage() else {+            fatalError("Failed to create CGImage for GIF frame")+        }++        let data = NSMutableData()+        guard let destination = CGImageDestinationCreateWithData(+            data, "com.compuserve.gif" as CFString, frameCount, nil+        ) else {+            fatalError("Failed to create GIF image destination")+        }+        let frameProperties = [+            kCGImagePropertyGIFDictionary: [kCGImagePropertyGIFDelayTime: 0.1]+        ] as CFDictionary+        for _ in 0..<frameCount {+            CGImageDestinationAddImage(destination, frame, frameProperties)+        }+        guard CGImageDestinationFinalize(destination) else {+            fatalError("Failed to finalize GIF encoding")+        }+        return data as Data+    }++    /// Builds a multi-page TIFF whose pages have the given pixel sizes.+    ///+    /// Multi-page TIFF is the container shape where per-frame dimensions can+    /// legitimately differ — the fixture for the frame-0-only budgeting+    /// bypass: a tiny first page must not vouch for a huge later one.+    static func makeMultiPageTIFFData(pageSizes: [(width: Int, height: Int)]) -> Data {+        let data = NSMutableData()+        guard let destination = CGImageDestinationCreateWithData(+            data, "public.tiff" as CFString, pageSizes.count, nil+        ) else {+            fatalError("Failed to create TIFF image destination")+        }+        let colorSpace = CGColorSpaceCreateDeviceRGB()+        for size in pageSizes {+            guard let context = CGContext(+                data: nil,+                width: size.width,+                height: size.height,+                bitsPerComponent: 8,+                bytesPerRow: 0,+                space: colorSpace,+                bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue+            ), let page = context.makeImage() else {+                fatalError("Failed to create \(size.width)x\(size.height) TIFF page")+            }+            CGImageDestinationAddImage(destination, page, nil)+        }+        guard CGImageDestinationFinalize(destination) else {+            fatalError("Failed to finalize TIFF encoding")+        }+        return data as Data+    }++    /// Reads back the decoded pixel dimensions of a platform image.+    static func pixelDimensions(of image: PlatformImage) -> (width: Int, height: Int)? {+        #if canImport(UIKit)+        guard let cgImage = image.cgImage else { return nil }+        #else+        guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }+        #endif+        return (cgImage.width, cgImage.height)+    }+}++// MARK: - Decompression Bomb Protection Tests (T-2132)++/// Tests that `ImageLoader` bounds decoded bitmap memory independently of the+/// compressed download size.+///+/// Bug T-2132: `ImageLoader.decodeImageData` passed raw bytes straight to+/// `PlatformImage(data:)`. A small, highly compressed file that declares an+/// enormous pixel count (a "decompression bomb") would decode to a bitmap+/// proportional to the declared pixel count, not the compressed byte count —+/// allocating memory far beyond the 50 MB network cap enforced elsewhere in+/// `ImageLoader`. The fix inspects width/height/frame count via `ImageIO`+/// before decoding and downsamples oversized images instead of fully+/// decoding them.+@Suite("ImageLoader decompression bomb protection (T-2132)")+struct ImageLoaderDecompressionBombTests {++    // MARK: - Pure Budget Decision Logic++    @Test("Image within budget does not require downsampling")+    func withinBudgetDoesNotExceed() {+        #expect(ImageLoader.exceedsDecodeBudget(width: 1_920, height: 1_080, frameCount: 1) == false)+    }++    @Test("Dimensions exactly at the decode budget do not require downsampling")+    func exactlyAtBudgetDoesNotExceed() {+        #expect(+            ImageLoader.exceedsDecodeBudget(+                width: ImageLoader.maxDecodedDimension,+                height: ImageLoader.maxDecodedDimension,+                frameCount: 1+            ) == false+        )+    }++    @Test("Width one pixel over the max dimension requires downsampling")+    func widthJustOverMaxDimensionExceeds() {+        #expect(+            ImageLoader.exceedsDecodeBudget(+                width: ImageLoader.maxDecodedDimension + 1,+                height: 100,+                frameCount: 1+            )+        )+    }++    @Test("Height one pixel over the max dimension requires downsampling")+    func heightJustOverMaxDimensionExceeds() {+        #expect(+            ImageLoader.exceedsDecodeBudget(+                width: 100,+                height: ImageLoader.maxDecodedDimension + 1,+                frameCount: 1+            )+        )+    }++    @Test("Declared dimensions far beyond the budget require downsampling")+    func extremeDimensionsExceed() {+        // The exact shape of the ticket's decompression-bomb scenario: huge+        // declared pixel dimensions that would allocate gigabytes if decoded+        // directly.+        #expect(ImageLoader.exceedsDecodeBudget(width: 50_000, height: 50_000, frameCount: 1))+    }++    @Test("Many animation frames of an otherwise modest image require downsampling")+    func manyFramesOfModestImageExceeds() {+        // Each frame alone (2000x2000) is well under maxDecodedDimension, but+        // NSImage(data:) on macOS materializes every frame of an animated+        // image, so frame count must be folded into the estimate.+        #expect(ImageLoader.exceedsDecodeBudget(width: 2_000, height: 2_000, frameCount: 50))+    }++    @Test("A single frame of that same modest image does not require downsampling")+    func singleFrameOfModestImageDoesNotExceed() {+        #expect(ImageLoader.exceedsDecodeBudget(width: 2_000, height: 2_000, frameCount: 1) == false)+    }++    @Test("Zero or negative declared dimensions are treated as unsafe")+    func nonPositiveDimensionsExceed() {+        #expect(ImageLoader.exceedsDecodeBudget(width: 0, height: 100, frameCount: 1))+        #expect(ImageLoader.exceedsDecodeBudget(width: 100, height: 0, frameCount: 1))+        #expect(ImageLoader.exceedsDecodeBudget(width: -1, height: 100, frameCount: 1))+    }++    // MARK: - End-to-End Decode++    /// A small, highly-compressible solid-color PNG declaring dimensions well+    /// past `maxDecodedDimension`. Decoded naively via `PlatformImage(data:)`+    /// this would allocate ~5000*5000*4 ≈ 95 MB despite a compressed file+    /// size of only a few KB — the exact "small download, huge decode"+    /// mismatch the ticket describes.+    @Test("Oversized image is downsampled to the decode budget, not rejected")+    func oversizedImageIsDownsampledNotRejected() async throws {+        let bombWidth = 5_000+        let bombHeight = 5_000+        let pngData = ImageLoaderTestHelpers.makeSolidColorPNGData(width: bombWidth, height: bombHeight)++        // Sanity-check the fixture actually has the "small compressed, huge+        // decoded" shape this bug is about. ImageIO's PNG encoder emits+        // ~450 KB for this solid-color image — still around 200x smaller+        // than the ~95 MB decoded bitmap, which is the mismatch that matters.+        #expect(+            pngData.count < 1_000_000,+            "Fixture should compress far below its decoded size despite huge declared dimensions"+        )+        #expect(bombWidth > ImageLoader.maxDecodedDimension)++        let loader = ImageLoader()+        let image = try await loader.load(.dataURI(pngData, mimeType: "image/png"))++        let dimensions = try #require(ImageLoaderTestHelpers.pixelDimensions(of: image))+        #expect(dimensions.width <= ImageLoader.maxDecodedDimension)+        #expect(dimensions.height <= ImageLoader.maxDecodedDimension)+        #expect(image.estimatedMemoryCost <= ImageLoader.maxDecodedByteBudget)+    }++    @Test("Local file with oversized declared dimensions is downsampled, not rejected")+    func oversizedLocalFileIsDownsampled() async throws {+        let bombWidth = 5_000+        let bombHeight = 4_500+        let pngData = ImageLoaderTestHelpers.makeSolidColorPNGData(width: bombWidth, height: bombHeight)++        let tempDir = FileManager.default.temporaryDirectory+        let imageURL = tempDir.appendingPathComponent("bomb-\(UUID().uuidString).png")+        try pngData.write(to: imageURL)+        defer { try? FileManager.default.removeItem(at: imageURL) }++        let loader = ImageLoader()+        let image = try await loader.load(.localFile(imageURL))++        let dimensions = try #require(ImageLoaderTestHelpers.pixelDimensions(of: image))+        #expect(dimensions.width <= ImageLoader.maxDecodedDimension)+        #expect(dimensions.height <= ImageLoader.maxDecodedDimension)+    }++    /// Multi-frame variant of the bomb shape: each 1024x1024 frame is well+    /// within the per-frame budget, but 20 frames total ~80 MB decoded —+    /// past the 64 MB budget — so the loader must downsample to a single+    /// bounded frame rather than eagerly materializing every frame (which is+    /// what `NSImage(data:)` does on macOS for animated images).+    @Test("Animated image whose total frame footprint exceeds the budget decodes to a single bounded frame")+    func animatedImageOverBudgetDecodesToSingleBoundedFrame() async throws {+        let frameWidth = 1_024+        let frameHeight = 1_024+        let frameCount = 20+        let gifData = ImageLoaderTestHelpers.makeAnimatedGIFData(+            width: frameWidth, height: frameHeight, frameCount: frameCount+        )++        // Sanity-check the fixture: small file, genuinely multi-frame, and a+        // total footprint the budget decision rejects while a single frame+        // alone would pass.+        let source = try #require(CGImageSourceCreateWithData(gifData as CFData, nil))+        #expect(CGImageSourceGetCount(source) == frameCount)+        #expect(ImageLoader.exceedsDecodeBudget(width: frameWidth, height: frameHeight, frameCount: frameCount))+        #expect(ImageLoader.exceedsDecodeBudget(width: frameWidth, height: frameHeight, frameCount: 1) == false)++        let loader = ImageLoader()+        let image = try await loader.load(.dataURI(gifData, mimeType: "image/gif"))++        let dimensions = try #require(ImageLoaderTestHelpers.pixelDimensions(of: image))+        #expect(dimensions.width <= ImageLoader.maxDecodedDimension)+        #expect(dimensions.height <= ImageLoader.maxDecodedDimension)+        #expect(image.estimatedMemoryCost <= ImageLoader.maxDecodedByteBudget)++        // The downsampled result must be a single still frame, not an+        // animation carrying every frame's bitmap.+        #if canImport(UIKit)+        #expect(image.images == nil)+        #else+        #expect(image.representations.count == 1)+        #endif+    }++    /// A file ImageIO recognises but cannot report pixel dimensions for (a+    /// bare PNG signature with a truncated header) must not crash or decode+    /// unbounded: it takes the bounded downsample path, which fails cleanly+    /// with `.decodeFailed` when no image can be produced. A *valid* image+    /// with unreported dimensions cannot be constructed deterministically+    /// (ImageIO reports dimensions for every format it can actually decode),+    /// so this pins the reachable half of that branch: unknown size never+    /// falls through to the unbounded `PlatformImage(data:)` decode.+    @Test("Recognised data without reported pixel dimensions fails cleanly instead of decoding unbounded")+    func recognisedDataWithoutDimensionsFailsCleanly() async {+        // PNG magic bytes + truncated IHDR: enough for type sniffing, not+        // enough for CGImageSourceCopyPropertiesAtIndex to report dimensions.+        let truncatedPNG = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D])++        let loader = ImageLoader()+        await #expect(throws: ImageLoadError.decodeFailed) {+            _ = try await loader.load(.dataURI(truncatedPNG, mimeType: "image/png"))+        }+    }++    @Test("Image within budget decodes at full resolution, unaffected by the fix")+    func withinBudgetImageDecodesAtFullResolution() async throws {+        let width = 200+        let height = 150+        let pngData = ImageLoaderTestHelpers.makeSolidColorPNGData(width: width, height: height)++        let loader = ImageLoader()+        let image = try await loader.load(.dataURI(pngData, mimeType: "image/png"))++        let dimensions = try #require(ImageLoaderTestHelpers.pixelDimensions(of: image))+        #expect(dimensions.width == width)+        #expect(dimensions.height == height)+    }++    // MARK: - maxScannedFrames Boundary++    /// Exactly `maxScannedFrames` (64) frames: the per-frame dimension scan+    /// still runs, the declared shape passes the budget, and the image+    /// qualifies for a full decode.+    @Test("An image with exactly maxScannedFrames frames is still scanned and fully decoded")+    func exactlyMaxScannedFramesIsStillScanned() throws {+        let frameCount = ImageLoader.maxScannedFrames+        let gifData = ImageLoaderTestHelpers.makeAnimatedGIFData(+            width: 16, height: 16, frameCount: frameCount+        )++        let source = try #require(CGImageSourceCreateWithData(gifData as CFData, nil))+        #expect(CGImageSourceGetCount(source) == frameCount)+        #expect(ImageLoader.declaredShapeAllowsFullDecode(source))+    }++    /// One frame past the cap: the scan is skipped entirely and the image+    /// fails closed to the bounded downsample path — even though the byte+    /// budget alone would approve this shape, proving the rejection comes+    /// from the frame-count cap and not the budget.+    @Test("One frame past maxScannedFrames fails closed to the downsample path")+    func onePastMaxScannedFramesFailsClosed() throws {+        let frameCount = ImageLoader.maxScannedFrames + 1+        let gifData = ImageLoaderTestHelpers.makeAnimatedGIFData(+            width: 16, height: 16, frameCount: frameCount+        )++        let source = try #require(CGImageSourceCreateWithData(gifData as CFData, nil))+        #expect(CGImageSourceGetCount(source) == frameCount)+        #expect(ImageLoader.exceedsDecodeBudget(width: 16, height: 16, frameCount: frameCount) == false)+        #expect(ImageLoader.declaredShapeAllowsFullDecode(source) == false)+    }++    /// End-to-end companion to the boundary pin: past the cap the image must+    /// still render (as a bounded single frame), never be rejected.+    @Test("An image past the frame-scan cap still renders bounded, not rejected")+    func overFrameScanCapStillRendersBounded() async throws {+        let gifData = ImageLoaderTestHelpers.makeAnimatedGIFData(+            width: 16, height: 16, frameCount: ImageLoader.maxScannedFrames + 1+        )++        let loader = ImageLoader()+        let image = try await loader.load(.dataURI(gifData, mimeType: "image/gif"))++        let dimensions = try #require(ImageLoaderTestHelpers.pixelDimensions(of: image))+        #expect(dimensions.width <= ImageLoader.maxDecodedDimension)+        #expect(dimensions.height <= ImageLoader.maxDecodedDimension)+        #expect(image.estimatedMemoryCost <= ImageLoader.maxDecodedByteBudget)+    }++    // MARK: - Unreported Frame Dimensions++    /// A *later* frame (not frame 0) with unreported dimensions must make the+    /// whole image unprovable, so it takes the bounded downsample path.+    /// ImageIO drops any frame it cannot size from `CGImageSourceGetCount`,+    /// so a real fixture with a counted-but-dimensionless later frame cannot+    /// be constructed; the scan core is parameterised over the per-frame+    /// lookup to keep this contract directly testable.+    @Test("A non-first frame with unreported dimensions makes the whole image unprovable")+    func laterFrameWithoutDimensionsMakesImageUnprovable() {+        let result = ImageLoader.maxDeclaredDimensions(frameCount: 3) { index in+            index == 1 ? nil : (width: 100, height: 100)+        }+        #expect(result == nil)+    }++    @Test("All frames reporting dimensions yields the per-axis maxima")+    func allFramesReportedYieldsPerAxisMaxima() {+        let sizes = [(width: 10, height: 400), (width: 300, height: 20)]+        let result = ImageLoader.maxDeclaredDimensions(frameCount: sizes.count) { sizes[$0] }+        #expect(result?.width == 300)+        #expect(result?.height == 400)+    }++    /// Mixed-frame-size variant of the bomb (pre-push review finding): the+    /// budget must consider every frame's declared dimensions. A frame-0-only+    /// estimate sees a 1x1 first page, multiplies by the frame count, and+    /// approves the file — while a later page over `maxDecodedDimension`+    /// then decodes unbounded. Multi-page TIFF is the container format where+    /// per-frame dimensions legitimately differ.+    @Test("A huge page hidden behind a tiny first page is still bounded")+    func mixedFrameSizesCannotBypassBudgetViaTinyFirstFrame() async throws {+        let hugeWidth = ImageLoader.maxDecodedDimension + 104+        let tiffData = ImageLoaderTestHelpers.makeMultiPageTIFFData(+            pageSizes: [(width: 1, height: 1), (width: hugeWidth, height: 600)]+        )++        // Fixture sanity: genuinely two pages, and a frame-0-only estimate+        // would (wrongly) pass the budget while the true worst page fails it.+        let source = try #require(CGImageSourceCreateWithData(tiffData as CFData, nil))+        #expect(CGImageSourceGetCount(source) == 2)+        #expect(ImageLoader.exceedsDecodeBudget(width: 1, height: 1, frameCount: 2) == false)+        #expect(ImageLoader.exceedsDecodeBudget(width: hugeWidth, height: 600, frameCount: 2))++        let loader = ImageLoader()+        let image = try await loader.load(.dataURI(tiffData, mimeType: "image/tiff"))++        let dimensions = try #require(ImageLoaderTestHelpers.pixelDimensions(of: image))+        #expect(dimensions.width <= ImageLoader.maxDecodedDimension)+        #expect(dimensions.height <= ImageLoader.maxDecodedDimension)+        #expect(image.estimatedMemoryCost <= ImageLoader.maxDecodedByteBudget)+    } }

Things to double-check

Transient PNG downsample peaks.

The 64 MB budget bounds retained bitmaps; a huge PNG bomb still transiently materializes its full-resolution bitmap inside CGImageSourceCreateThumbnailAtIndex (PNG has no subsampled decode). The decode semaphore bounds how many such peaks coincide (4), not their individual size. If real-world memory pressure shows up here, dropping the decode limit to 2 is the knob.

Animated images over budget lose their animation.

An animated GIF/APNG whose total frame footprint exceeds 64 MB now renders as a single still frame. This is the intended defence (macOS eagerly materializes all frames), but it is a user-visible behaviour change for large legitimate animations — worth a glance at real documents with big GIFs.

Parked compressed bodies while decode is saturated.

Downloads complete 6-wide while decodes drain 4-wide; each queued task holds its compressed body. In practice WebKit's per-page image request fan-out bounds this, and bodies are typically far below the 50 MB cap — but a pathological document with many huge remote images is the shape to test if memory issues are ever reported against image-heavy docs.

Follow-up candidates (not this PR).
  • A dedicated .cancelled case on ImageLoadError — fixes phase misclassification and the raw-English-string localisation violation at all three sites.
  • One shared scheme-handler ImageLoader to stop per-request URLSession churn (make decodeImageData nonisolated so the semaphore remains the sole decode bound).
  • Chunked reads in streamDownload (byte-at-a-time accumulation, also in URLDocumentLoader).