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.
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.ImageCache's 256 MB budget; every frame's dimensions are scanned (max 64 frames, failing closed past that).SharedConcurrency.decodeSemaphore (limit 4) — needed because PrismDocSchemeHandler builds a fresh ImageLoader per request, so actor isolation alone cannot serialize decodes across requests.make lint clean, make test-quick green locally. GitHub Actions is billing-blocked — red checks are zero-job billing failures, not code failures.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.
ab742af Fix T-2132: Remote raster images can exhaust memory after decompression 18c1716 Address review: bounded downsample for unknown-dimension images + multi-frame E2E test 09212f6 Budget every frame's dimensions, not frame 0 times frame count b03f03b Address review round 2: shared decode bound + frame-scan boundary tests working-tree Fixes applied in this review 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.
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.
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.
nonisolated static decision functions so the budget logic is unit-testable without constructing giant bitmaps.NSImage(data:) eagerly materializes every frame.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).
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).
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.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.
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
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)
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
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
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
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
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.
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.
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.
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.
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).
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.
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.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | ImageLoader.loadRemote | Releasing 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. |
| minor | SharedConcurrency comment | The 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. |
| minor | ImageLoader.loadRemote cancellation | AsyncSemaphore.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. |
| minor | ImageLoader.decodeImageData scope | The 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. |
| nit | AsyncSemaphore doc | Doc 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. |
| nit | ImageLoader.maxDeclaredDimensions | Per-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. |
| nit | ImageLoader downsample branch | The 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. |
| minor | ImageLoader budget estimate | Summing 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. |
| minor | ImageLoadError cancellation shape | A 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. |
| minor | loadRemote acquire/release shape | The 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. |
| minor | PrismDocSchemeHandler (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). |
| minor | ImageLoader.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). |
| minor | Test fixture builders | makeSolidColorPNGData / 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. |
| nit | Full-decode double parse | CGImageSourceCreateWithData 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. |
| nit | kCGImageSource options | Verification 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. |
Click to expand.
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 } }
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
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)+ } }
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.
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.
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.
.cancelled case on ImageLoadError — fixes phase misclassification and the raw-English-string localisation violation at all three sites.ImageLoader to stop per-request URLSession churn (make decodeImageData nonisolated so the semaphore remains the sole decode bound).streamDownload (byte-at-a-time accumulation, also in URLDocumentLoader).