prism branch T-2132/bugfix-raster-memory-limits-after-buffering PR #382 commits 3 (rebased) files 28 touched lines +3393 / -886 tests run 107 + 27 (3x9), 0 failed, 0 retried lint 0 violations build iOS sim, exit 0, 2 NEW warnings

Pre-push review: bound image memory before it is spent

T-2132 / T-2149 / T-2151 / T-1867 fixed as one defect class — a resource limit checked only after the memory it was meant to bound has already been committed. Reviewed after rebasing onto origin/main (four sibling PRs merged since the branch was cut: #379, #380, #381, #383).

At a glance

  • Rebase clean. Three CHANGELOG conflicts resolved by hand. Post-rebase the file has no conflict markers, each of the seven relevant tickets appears exactly once, and the branch's removal of a duplicated T-1805 bullet (genuinely present twice on origin/main) is preserved. Net CHANGELOG diff vs main: +1 / -1.
  • The load-bearing arithmetic is correct. I built eight fixtures and compared bytesPerPixel(depth:colorModel:hasAlpha:) against real decoded CGImage.bitsPerPixel. Opaque 8-bit RGB → 4 (exact), 8-bit grey → 1 (exact), 16-bit RGBA → 8 (exact), 32-bit float RGBA → 16 (exact). Palette PNG, 1-bit indexed and 16-bit opaque RGB are over-counted (4 vs 1, 4 vs 1, 8 vs 6) — safe direction. GIF reports RGB, not Indexed, so no format is accidentally refused.
  • Test-infrastructure risk from the rebase is clear. make verify-test-isolation and make verify-make-guards both pass. The two tests this PR adds to the live-WebKit neighbourhood are async throws and do not trip the new isolation guard.
  • The retry question is answered. WebMediaBehaviourTests — the suite whose 9 first-attempt failures would have failed the new gate — ran three consecutive times, 9/9 each, zero retries, on a dedicated simulator at worker count 1. The earlier failures look environmental (shared test host / parallel clones, T-2236 / T-2245), not this PR's doing.
  • The chokepoint claim holds as narrowed. Every CGImageSource*, CGImageDestination* and PlatformImage(data:) in production lives in ImageMemoryGuard. Only five Data/String(contentsOf:) calls remain outside BoundedFileRead, all carrying exemption markers.
  • Six exemption markers, all honest. Three (notes JSON, legacy notes JSON, notes backup) say “no size cap is defined for it” — an accurate admission that those reads are genuinely unbounded, not an argument that they are safe. Worth a follow-up ticket, not a blocker here.
  • Nothing left uncommitted. The working tree is clean; the only change I made was the rebase itself.

Verdict

Needs fixes

The architecture is right and the engineering is unusually careful — header-derived admission, a byte-weighted budget, and read-within-a-cap each genuinely answer their ticket, and I verified the depth-aware arithmetic against real ImageIO output for eight formats (every prediction is exact or a conservative over-count; no unsafe under-count exists). The rebase is clean, the CHANGELOG is healthy, lint is clean, and all 134 targeted tests pass with zero retries under the new strict gate.

Three things block a clean push. (1) Two new compiler warnings in BlockHTMLEmitter.admitsInlinePayload: a nonisolated function that really does run off-main calls MainActor-isolated API. That breaks the project's own zero-warning pre-push bar and is a hard error in Swift 6 language mode. (2) BoundedFileRead's documented residency bound does not hold in exactly the case its second guard exists for — an unknown or stale reported size leaves reserveCapacity at 256 KB and Data.append's geometric growth puts ~2× the cap resident during the final realloc. (3) Substantial claim drift: the PR body still describes pre-fix behaviour in two rows of its verdict table, and the “1.6 GB” figure the PR itself identified as wrong survives in three more places — one of them eight lines above the assertion that proves it is 400 MB.

None of the three is a correctness bug in the shipped behaviour. All three are the kind this project has repeatedly said it cares about.

Review findings

20 raised · 0 fixed · 20 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

A picture file on disk is stored compressed. A photo that is 400 KB as a file can be an enormous picture once unpacked — and it is the unpacked size, not the file size, that has to fit in memory. Prism's safety limits were all written on the wrong side of that line: it checked the file size, decided the file was small, and only discovered the problem once the picture was already unpacked and the memory already gone.

Worse, the check only existed on one route. An image downloaded from the web was checked; the same image sitting in a folder next to your document, or written directly into the markdown text, went straight through unchecked.

Why It Matters

A deliberately-crafted image (a “decompression bomb”) could make Prism run out of memory and be killed by the operating system. Even an innocent very-large photograph could do it. A limit that is checked after the memory has been spent is not a limit at all — it just tells you afterwards what went wrong.

Key Concepts

  • Header — the first few bytes of an image file, which state how wide and tall it is and how much detail each dot carries. Reading it is cheap and unpacks nothing.
  • Admission — deciding from that header whether the picture may be unpacked at all, before doing any unpacking.
  • Budget — instead of allowing “four pictures at once” (which means nothing when one is huge and three are tiny), Prism now allows “this many megabytes at once”.
  • Bounded read — reading a file a chunk at a time and stopping when you hit your limit, instead of reading all of it and then measuring.

What the change decides

An ordinary image displays exactly as before. A very large one referenced from the web or a file is shrunk to fit. One past any reasonable size is refused outright and shows the usual “Image failed to load” placeholder. An image written directly into the markdown is treated more strictly — it is shown as-is or refused, never shrunk — because shrinking it would mean keeping the shrunken copy inside the document for as long as it is open, which costs more memory than simply not showing it.

Architecture

The change introduces two chokepoints and replaces one concurrency primitive.

BoundedFileRead is the byte-level half. It does two things because either alone is insufficient: it preflights the file size from the directory entry (so a 2 GB file is refused without a single byte read), and it then bounds the read chunk by chunk (so a file that grows or lies about its size between the preflight and the read is still cut off).

ImageMemoryGuard is the pixel-level half, and it is declared the only place in production that may turn encoded image bytes into pixels — create a CGImageSource, decode a CGImage, call PlatformImage(data:), or encode one back. Every path takes the same three steps in the same order: bound the encoded bytes before they are resident, decide from the header via CGImageSourceCopyPropertiesAtIndex, then reserve the estimated cost from a byte-weighted budget and hold it across decode and re-encode.

Patterns

Verdict as a value. Admission produces a pure Verdict enum (decodeWhole / downsampleFirstFrame / refuseOversized / refuseUnsizable) from header fields alone. Because it is pure it is exhaustively unit-testable without allocating a single bitmap — and the test suite exploits that, using hand-assembled PNG bombs that declare 400 megapixels but deflate to 389 KB.

Consumer-parameterised policy. The same header yields a different verdict depending on who will decode it. .native models NSImage(data:), which materialises one bitmap per frame, so every frame's declared size sums into the budget. .page models WebKit, which decodes progressively, so only frame 0 is bounded. Collapsing these into one model was a real defect: a 100-frame 100×100 GIF is ~4 MB decoded, and the native model downsampled it to a still.

Enforcement by source scan. ImageMaterializationChokepointTests greps production source for banned calls and fails when one appears outside the two chokepoint files without a // prism-memory-exempt: <why> marker. A companion test asserts the chokepoint files still contain those calls, so the rule cannot be satisfied by gutting the guard.

Trade-offs

  • Fail closed on unsizable images. A header that does not report depth or colour model is refused rather than assumed. Empirically free — every format ImageIO can size reports both — but it is a real behaviour change for exotic inputs.
  • Inline images are refused, not downsampled. Deliberate asymmetry with file-referenced images, justified on memory: downsampling inline would mean re-inlining base64 into the document HTML for the session's lifetime.
  • Backpressure over throughput. Network and read permits are now held across materialisation rather than released at download completion. When the budget saturates, new fetches stall. That is what bounding memory means; the previous ordering chose throughput and bounded nothing.

The depth-aware estimate is the load-bearing claim, and it holds

The previous round hardcoded 4 bytes per pixel, which under-counts a 16-bit PNG 2× and a 32-bit float TIFF 4× — and a ceiling computed from an under-count is not a ceiling. bytesPerPixel(depth:colorModel:hasAlpha:) now computes ceil(depth/8) × componentsPerPixel(colorModel, hasAlpha).

I verified this against real ImageIO output rather than accepting the doc comment. Constructing eight fixtures and comparing the prediction to the decoded CGImage.bitsPerPixel:

  • opaque 8-bit RGB PNG & JPEG → predicted 4, actual 4 — the doc's central claim (“CoreGraphics has no packed 24-bit format”) is correct
  • opaque 8-bit greyscale PNG & TIFF → predicted 1, actual 1 — flooring at 4 “to be safe” really would have over-counted 4×
  • 16-bit RGBA PNG & TIFF → predicted 8, actual 8; 32-bit float RGBA TIFF → predicted 16, actual 16
  • animated GIF → colorModel == "RGB", not "Indexed", so the default: return nil refusal does not accidentally kill palette formats
  • junk bytes → 0 frames → refuseUnsizable; truncated PNG → no depth/model and no dimensions, exactly as the doc predicts

Three cases are over-counted rather than exact: palette 8-bit PNG (4 vs 1), 1-bit indexed (4 vs 1), and 16-bit opaque RGB (8 vs 6 — CoreGraphics does have a packed 48-bit format, so the “RGB gets four components” rule is an upper bound at 16-bit, not the identity the comment claims). All err toward refusing. No under-count exists in any format I could construct, so the stated ceilings hold.

The residency bound that does not hold

BoundedFileRead's header claims “at most limit + chunkSize bytes are ever resident”. That is true only when reserveCapacity was seeded from an accurate preflight. When reportedSize is nil or stale — the FIFO / synthetic-filesystem / growing-file case guard 2 was written for — capacity starts at 256 KB and Data.append grows geometrically, so the final reallocation has both the ~limit old buffer and the ~2×limit new buffer live. For the 50 MB image cap that is ~100–150 MB transient, on the code path whose entire purpose is bounding memory. Checking data.count + chunk.count > limit before appending fixes both this and the documented chunkSize overshoot.

Off-main isolation violation in the emitter

BlockHTMLEmitter.admitsInlinePayload is declared nonisolated and calls two MainActor-isolated symbols synchronously: ImagePathResolver.resolve and PrismDocSchemeHandler.maxDataURIBytes. Because precomputeDocumentHTML emits on a Task.detached, that code genuinely executes off the main actor. The runtime hazard is low — resolve's data: branch is pure string and base64 work touching no MainActor state — but the annotation is violated, the compiler emits two warnings, and Swift 6 language mode turns both into errors. The author marked the two new PrismDocSchemeHandler constants nonisolated, so the awareness was there; maxDataURIBytes and the resolve call were missed.

Where the guarantee is narrower than the prose

pageBytes takes no budget reservation on the .decodeWhole path — it returns the original bytes verbatim. That is the right call (re-encoding validated bytes buys nothing and costs three allocations), and it means the byte budget is essentially never contended for ordinary documents. But it also means the CHANGELOG's “a page full of large images no longer overruns” and “how much decoding happens at once is limited by how much memory those pictures actually need” overstate what is enforced: for the document renderer, decoding happens in WebKit and the byte budget never sees it. What bounds that path is localReadSemaphore(4), which bounds compressed bodies. The guard's own header states this honestly 25 lines below the “It CAN guarantee” sentence that was not re-scoped when the consumer split landed.

Concurrency

ImageMemoryBudget is a strictly-FIFO, head-waiter-only byte budget. Permit ordering is consistent (permit → budget) at all six acquire sites, so it cannot cycle with SharedConcurrency. Reservations and permits balance on every exit path. The pre-cancelled-waiter concern raised by automated reviewers is a false positive: acquire is actor-isolated and appends the waiter synchronously inside the continuation body before any suspension, so cancelWaiter cannot run first. The one real ergonomic cost is a flat 2 × maxRetainedDecodedBytes (128 MB) surcharge on every downsample against a 256 MB capacity, which makes any downsample effectively exclusive — the thumbnail's true size is computable from retainedDimensionCap at that point and would be far tighter.

Important changes — detailed

ImageMemoryGuard: header-derived admission replaces post-hoc measurement

ImageMemoryGuard.swift

Why it matters. This is the whole fix. Admission is decided from parsed header fields before any pixel work, so no decoder is ever handed an image whose declared decoded size exceeds the 256 MB ceiling. Critically, the per-pixel size is read from kCGImagePropertyDepth + kCGImagePropertyColorModel rather than hardcoded at 4 — a ceiling computed from an under-count is not a ceiling.

What to look at. ImageMemoryGuard.swift:285-303 (bytesPerPixel), :441-479 (verdict), :528-597 (materialisation)

Takeaway. When you must bound a cost, predict it from metadata you can read cheaply and refuse from the prediction — and make the prediction depth-aware rather than assuming the common case. The codebase already measured this correctly on the other side of the decode (PlatformImage.estimatedMemoryCost is bytesPerRow * height); the fix is to predict the same quantity before the allocation, which is the only side that can refuse.
Rationale. The first T-2132 fix (#355) sent every over-budget raster to CGImageSourceCreateThumbnailAtIndex. PNG, GIF and TIFF have no subsampled decode, so ImageIO may inflate the full source bitmap before scaling — a downsample is not a free bound. The only way to bound the transient peak is to bound what may be declared, which is why the 256 MB ceiling is load-bearing rather than belt-and-braces.

Verdict is taken per Consumer — native and page pay different costs

ImageMemoryGuard.swift

Why it matters. Applying one memory model to both consumers was a real defect, not a tidiness point. NSImage(data:) materialises one bitmap per frame; WebKit decodes progressively and keeps its own bounded frame cache. Charging the page consumer for the native cost meant a 100-frame 100x100 GIF — about 4 MB decoded — was downsampled to a still.

What to look at. ImageMemoryGuard.swift:181-196 (Consumer), :441-479 (three-tier verdict)

Takeaway. A resource estimate is meaningless without naming who will spend the resource. Parameterising the policy by consumer, rather than taking the maximum over consumers, is what keeps the bound honest in both directions — the conservative model refuses things that are actually cheap.
Rationale. Stated in the Consumer doc comment: 'No memory argument supports that; it came from charging the page consumer for a cost only the native one pays.'

ImageMemoryBudget: concurrency weighted in bytes, not slots

ImageMemoryGuard.swift

Why it matters. T-2151. A four-slot semaphore bounds the number of concurrent decodes, which bounds memory only if every decode costs the same — and the images this guard exists for are precisely the ones that do not. One reservation now covers decode and re-encode together, closing the gap where the re-encode happened outside every bound.

What to look at. ImageMemoryGuard.swift:673-764 (ImageMemoryBudget actor), :508-517 (reservationCost)

Takeaway. Strictly-FIFO, head-waiter-only admission trades throughput for a trivially auditable invariant (reserved never exceeds capacity) and no starvation. The waitingCount accessor exists purely so tests can observe enqueueing rather than sleeping — which removed every Task.sleep from the suite and is why the FIFO test cannot pass for the wrong reason on a loaded machine.
Rationale. Documented on the actor. Note the practical caveat: pageBytes only acquires on the downsample path, so for ordinary documents the budget is never contended and head-of-line blocking is structurally unreachable.

BoundedFileRead: preflight plus a bounded read loop

BoundedFileRead.swift

Why it matters. T-1867. Data(contentsOf:) commits the whole file before anyone can look at its size, so every cap written as `guard data.count <= limit` is enforced one allocation too late. Two guards because either alone is insufficient: the preflight short-circuits the common oversized file, the loop covers a file whose reported size is stale or wrong.

What to look at. BoundedFileRead.swift:69-125

Takeaway. Separating the loop into its own entry point (`read(from:limit:expecting:)`) is what makes guard 2 testable — otherwise a test would have to find a filesystem that misreports sizes. Splitting a defence so each half can be exercised independently is worth the extra API surface.
Rationale. Stated in the doc comment. But see finding 2: the residency bound the header advertises does not hold in the unknown-size case, which is precisely guard 2's case.

Inline data: URIs are admitted at emit time, or rewritten to a refusal address

BlockHTMLEmitter.swift

Why it matters. T-2149. The document CSP permits img-src data:, so an inline payload never reaches the scheme handler and WebKit decodes it directly — making every inline raster exempt from the decoded-size budget. The emitter is the last place native code can act.

What to look at. BlockHTMLEmitter.swift:1024-1071; PrismDocSchemeHandler.swift:96-101, :161-175

Takeaway. Pointing a refusal at an address that always fails (prism-doc://img/blocked -> .rejected(.imagePolicy)) keeps the refusal on the audited path and reuses the page's existing catalog-localised error placeholder, instead of inventing a second failure surface. The corollary caught in review: the refusal address carries no ?src= payload, and prism-media.js read the missing parameter back as "" — which is neither http nor data, so every refusal offered an iOS folder picker for a picture that lives in the markdown.
Rationale. Refusing rather than downsampling is justified on memory: downsampling inline would mean decoding and re-encoding during emit and re-inlining the base64 result into the document HTML, retained for the session.

ProductionSourceScan: the chokepoint is enforced by a source scan

ProductionSourceScan.swift

Why it matters. A chokepoint that is only a convention stops being one at the next ticket. The scan fails when a banned call appears outside the two chokepoint files without a per-line `// prism-memory-exempt: <why>` marker, so the enumeration happens when the call site is written.

What to look at. prismTests/Support/ProductionSourceScan.swift:88-272; ImageMaterializationChokepointTests.swift

Takeaway. Unifying the machinery with the pre-existing URL scan paid for itself immediately: the URL scan had already learned that `.init(string:)` is `URL(string:)`, and the copied image scan was blind to `.init(data:)` all over again. It also surfaced a fifth hole neither suite knew about — the logical-line join inserts a space, so `Data(\n contentsOf: url\n)` never matched the literal token.
Rationale. A companion test asserts the chokepoint files still contain the banned calls, so the rule cannot be satisfied by gutting the guard.

Key decisions

Refuse, rather than assume, when the header gives no depth or colour model.

An unprovable cost is not a bounded one, and a guessed depth is the exact defect this round replaces. The claim that this is empirically free — every format ImageIO can size also reports depth and colour model — I verified independently: a truncated PNG reports neither depth nor dimensions (so it was already refused), and junk bytes yield zero frames. Notably GIF reports colorModel == "RGB" rather than "Indexed", so the default: return nil arm does not silently kill palette formats.

Bound what may be *declared*, not merely what is retained.

The 256 MB maxAdmissibleDecodedBytes ceiling exists because a downsample is not a free bound: PNG/GIF/TIFF have no subsampled decode, so CGImageSourceCreateThumbnailAtIndex may inflate the whole source bitmap before scaling. Sending every over-budget raster to the thumbnail path was the first T-2132 fix and is why it was reopened. The honest statement is therefore two numbers, not one: retained ≤ 64 MB, transient peak ≤ the image's declared size.

The downsample edge is depth-derived, not a flat 4096.

A thumbnail keeps the source's bit depth — CGImageSourceCreateThumbnail hands back a 16-bit image for a 16-bit source — so a flat 4096 px edge would retain 128 MB for a 16-bit image, twice the ceiling the file advertises. retainedDimensionCap yields 4096 / 2896 / 2048 at 4 / 8 / 16 bytes per pixel. I confirmed the arithmetic: ⌊√(67108864/8)⌋ = 2896.

Hold the network and read permits across materialisation, not just the download.

Previously the permit was dropped the moment the download completed, and the body then queued on a decode gate still holding up to 50 MB with nothing bounding how many such bodies could pile up. The cost is deliberate backpressure. The doc argues it is cheaper than it sounds because an admitted image is now served with no native decode at all — which is true, and is also why the byte budget is rarely contended.

Serve admitted page images verbatim rather than re-encoding.

Their declared decoded size is already inside the retained budget, so re-encoding would spend a decode, a bitmap and an encoder buffer to arrive at bytes no safer than the ones already validated. This also deletes the macOS tiffRepresentation + NSBitmapImageRep route, which allocated an uncompressed TIFF and a second bitmap rep on the way to the same PNG.

Document WebViewPool.captureSnapshot's ~144 MB as an exemption rather than bringing it into the budget.

It has no encoded bytes and no header to size from, so header-derived admission has nothing to act on; it is bounded instead by the pool's slot count and SVGRenderer's 2000-point frame clamp. The ownership claim in both the guard header and CLAUDE.md was narrowed from “the only bitmap allocation” to “the only place that turns encoded image bytes into pixels” rather than the code being changed. This is the right call and the narrowing is genuine — but it does leave the single largest unbudgeted allocation in the app outside the guarantee.

Inline images are refused where file-referenced ones are downsampled.

Justified on memory rather than effort: downsampling an inline payload means decoding and re-encoding during emit — which runs per parse revision, off the main actor, and is meant to be a pure, cheap function of the blocks — and then re-inlining the base64 result into the document HTML, where it is retained for the whole session. A file reference has somewhere else to put the downsampled bytes.

Review findings

SeverityAreaFindingResolution
majorBlockHTMLEmitter.swift:1058,1063 — off-main call to MainActor APIadmitsInlinePayload is declared `nonisolated` but calls MainActor-isolated ImagePathResolver.resolve(source:baseURL:sourceType:) and PrismDocSchemeHandler.maxDataURIBytes synchronously. Because precomputeDocumentHTML emits on a Task.detached, this genuinely runs off the main actor. The build emits two NEW warnings (verified absent from origin/main — admitsInlinePayload is new code), which breaks the project's stated zero-warning pre-push bar and become hard errors in Swift 6 language mode. The author marked the two new PrismDocSchemeHandler constants `nonisolated`, so the awareness was there; these two were missed. Runtime hazard is low — resolve's data: branch is pure string/base64 work touching no MainActor state — but the isolation contract is violated.Mark PrismDocSchemeHandler.maxDataURIBytes `nonisolated` (one word), and expose a `nonisolated` data-URI parse — either by marking ImagePathResolver.resolveDataURI nonisolated and calling it directly, or by adding a nonisolated resolve entry point for the context-free data: case.
majorBoundedFileRead.swift:27-30,104-124 — the documented residency bound does not holdThe header claims 'at most limit + chunkSize bytes are ever resident'. That holds only when reserveCapacity was seeded from an accurate preflight. When reportedSize is nil or stale — the FIFO / synthetic-filesystem / growing-file case that guard 2 exists for — capacity starts at 256 KB and Data.append grows geometrically, so during the final reallocation both the ~limit old buffer and the ~2x limit new buffer are live. For the 50 MB image cap that is ~100-150 MB transient, on the code path whose entire purpose is bounding memory. This is a claim the code does not deliver, in the sharpest form: the stated guarantee fails exactly where the second guard matters.Check before appending rather than after — `if data.count + chunk.count > limit { throw .tooLarge(...) }` — which bounds residency at exactly `limit` and also removes the documented chunkSize overshoot. Seed reserveCapacity(min(reportedSize ?? limit, limit)) so an unknown-size read does not realloc at all.
majorPR body #382 — two rows of the verdict table describe pre-fix behaviourThe PR body's verdict table still says `decodeWhole | all frames sum <= 64 MB` (false for .page, where the rule is frame 0 alone — ImageMemoryGuard.swift:466-472) and `downsampleFirstFrame | bounded decode of frame 0 to 4096 px` (false — the edge is retainedDimensionCap(bytesPerPixel:), 4096/2896/2048, and correcting the flat 4096 was the entire point of commit 33938345). The body never mentions Consumer at all. Both claims were made false by the PR's own review commits, which did not update the body.Update the PR body's verdict table to name the Consumer split and the depth-derived edge.
majorPrismDocSchemeHandler.swift:457,472 — a URLSession is constructed and leaked per image subresource`ImageLoader()` and `SVGSourceLoader()` are constructed per request; ImageLoader.init builds URLSession(configuration:delegate:delegateQueue:), which retains its delegate until invalidateAndCancel() — never called. On origin/main this affected only .remote (verified: `git show origin/main:...` line 439). T-2149 now routes .localFile and .dataURI through the same call, so a document with 200 local images leaks 200 sessions + 200 RemoteFetchRedirectValidators + 200 operation queues for images that never touch the network — and it recurs on every WebContent-termination reload. In a PR whose subject is bounding memory, this is the largest unbounded structure introduced.Hold `private static let imageLoader = ImageLoader()` / `svgLoader = SVGSourceLoader()` on the handler (both are actors, so sharing is safe and is what the SharedConcurrency comment already assumes), or make the URLSession lazy so non-remote sources never build one.
minorThree surviving '1.6 GB' claims the PR itself identified as wrongImageMemoryGuardTests.swift:166-170 explicitly corrects the figure — 'It is 400 MB rather than the 1.6 GB the first round of this work claimed, and the difference is the finding rather than a rounding'. But 1.6 GB survives at ImageMemoryGuardTests.swift:487 ('may inflate the full ~1.6 GB source bitmap'), which sits EIGHT LINES above `#expect(declared == 20_000 * 20_000)` proving 400 MB; at ImageMemoryPipelineTests.swift:27 ('under a megabyte against 1.6 GB decoded'); and in the PR body three times, including 'so the test never allocates the 1.6 GB it declares'. Separately, the compression ratio is stated three ways: ImageMemoryGuard.swift:15 says ~200:1, the fixture comment says ~1000:1, the PR body says ~1000:1.Replace the three remaining 1.6 GB references with 400 MB and reconcile the compression ratio to the measured ~1030:1. The CHANGELOG's 1.6 GB is defensible as generic prose about a colour image (20,000^2 x 4) but reads inconsistently beside the corrected figure.
minorImageMemoryGuard.swift:47-49 — the 'It CAN guarantee' sentence was not re-scoped for the consumer splitThe header states it CAN guarantee no decoder is handed an image whose declared decoded size exceeds maxAdmissibleDecodedBytes. For .page an admitted image is served verbatim (:579-584) and only frame 0 was bounded, so a 4000x4000x100-frame GIF declares 6.4 GB across frames, passes tier 2 on frame 0's 64 MB, and WebKit receives the whole file. The same file walks this back correctly 25 lines later ('It also cannot account for what WebKit decodes on its own'), so the two paragraphs contradict each other.Scope the CAN-guarantee sentence to the native consumer, or to 'the frame this guard admits'.
minorCHANGELOG — the page-decoding claim overstates what the byte budget enforces'a page full of large images no longer overruns while appearing to stay within its bounds' and 'How much decoding happens at once is limited by how much memory those pictures actually need rather than by how many of them there are'. But pageBytes takes NO budget reservation on the .decodeWhole path — the common case for the document renderer — so decoding happens inside WebKit and the byte budget never sees it. What bounds that path is localReadSemaphore(4), which bounds compressed bodies, not decoded bitmaps.Soften to describe what is actually bounded: how many compressed bodies are resident at once, plus the per-image admission ceiling.
minorSVGRenderer.swift:24 — the SVG cap still has two ownersSVGSourceLoader.maxSVGSize was correctly re-pointed at ImageMemoryGuard.maxSVGBytes with a comment declaring the guard its owner, but SVGRenderer.maxSourceSize still hardcodes `2 * 1024 * 1024`. The constant the change declared a single owner for has two spellings again.`private static let maxSourceSize = ImageMemoryGuard.maxSVGBytes`.
minorNo test pins that production charges the process-wide ImageMemoryGuard.budgetEvery budget test constructs a fresh ImageMemoryBudget(capacity:). Nothing observes that platformImage / pageBytes actually charge the shared ImageMemoryGuard.budget — delete both `try await budget.acquire(cost)` lines and the suite stays green. This is structurally the T-1943 failure mode that CLAUDE.md cites as the reason live wiring tests exist, and which the PR's own chokepoint scan was written to prevent for a different property.Make the budget injectable, or assert serialisation of two real concurrent pageBytes calls on an over-budget fixture — an outcome only the shared budget can produce.
minorImageMemoryGuard.swift:508-517 — the downsample reservation overcharges by roughly 2xA flat `estimatedBytes + 2 * maxRetainedDecodedBytes` adds 128 MB against a 256 MB capacity, so any downsample reserves most of the budget and runs effectively alone. The thumbnail's real cost is computable at that point — retainedDimensionCap(bpp)^2 x bpp, bounded by aspect ratio — and the PNG encode buffer is bounded by the compressed thumbnail, typically single-digit MB. Combined with withLocalRead holding one of four read permits across the budget wait, four near-ceiling local images can stall all other local reads.Charge the computed thumbnail size instead of the flat 2x retained budget. Still conservative, far tighter.
minorImageLoader.swift:190-210 — cancellation check present on the remote path, absent on the local twinwithRemoteBody does `try Task.checkCancellation()` before materialize with the comment "don't spend a reservation, or a decode, on a dead request". withLocalRead reads up to 50 MB and then materialises with no such check, so a cancelled request still spends a budget reservation and a decode.Add the same `try Task.checkCancellation()` after readLocalFile.
minorBoundedFileRead.swift:72,87 — the file is stat'd twice, and reportedSize's stated justification does not existThe preflight result is discarded and re-fetched to size the buffer (`expecting: reportedSize(of: url)`), so the two calls can disagree; URL.resourceValues is not a bare stat and allocates. Separately, reportedSize(of:) is documented as 'exposed so a caller that must reserve memory before reading (the image pipeline charges its byte budget up front) can do so' — no production caller uses it; the only external callers are two tests.Hoist `let reported = reportedSize(of: url)` and pass it. Either wire up the external use or drop that paragraph.
minorCLAUDE.md:89 — 'was refused, then served as a re-encoded still'The pre-fix behaviour for a >64-frame GIF was .downsampleFirstFrame — downsampled to a still, never refused. The guard's own comment says this correctly ('it fell to a single downsampled frame'); CLAUDE.md says 'refused, then served', which cannot both be true.Match the guard's wording.
minorImageMemoryGuard.swift:594-595 — the page downsample always re-encodes as PNG`return (try encodePNG(thumbnail), "image/png")` is unconditional. An 8064x6048 phone photo (~5 MB JPEG) downsampled to 4096x3072 and re-encoded as PNG becomes ~20-35 MB handed to WebKit. ImageMemoryPipelineTests.swift:115 tacitly concedes it (`served.data.count < source.count * 10`). Not a regression — the old code re-encoded every remote image to PNG — but the downsample path is meant to reduce what crosses the boundary.Pick the encoder from CGImageSourceGetType: JPEG (with a quality option) for lossy sources or any image without alpha, PNG only where alpha or losslessness matters.
minorThree exemption markers cover genuinely unbounded reads of user-data filesSix `// prism-memory-exempt:` markers exist, all honest and none stale (noStaleExemptionMarkers enforces that). Three of them — NotesStore.swift:106, :147 and NotesBackupStore.swift:145 — say 'no size cap is defined for it', which is an accurate admission rather than a safety argument: notes JSON is the only exempt content whose size grows with user data, so those reads are genuinely unbounded, just not attacker-controlled.Out of scope for these four tickets. Worth a follow-up ticket to define a notes-file cap and route those three through BoundedFileRead.
minorProductionSourceScan — five constructible evasionsThe suite's own disclaimer covers the class, but these are concrete: (1) type names outside the token list — NSImage(contentsOf:), NSBitmapImageRep(data:), CIImage(data:) all decode encoded bytes and match nothing (NSImage(contentsOf:) is both an unbounded read and a decode); (2) `Data (contentsOf: url)` with a space before the paren, which is legal Swift and invisible since expression(forToken:) tolerates whitespace after `(` but not before it; (3) more than 3 continuation lines (continuationLimit = 3); (4) the non-async takeSnapshot(with:completionHandler:); (5) exclusion is per-logical-line, so `self.init(data:` on the same logical line suppresses a genuine match. Also unbounded-read tokens omit readDataToEndOfFile(), .availableData and NSData(contentsOf — and this change is precisely what teaches the codebase to reach for FileHandle. None are present in prism/ today.Add the missing type-name tokens and the FileHandle read spellings; allow optional whitespace before `(`.
minorImageMemoryBudget structurally duplicates AsyncSemaphore; the scoped-permit pattern is hand-rolled six timesAsyncSemaphore (ImageLoader.swift:20-70) and ImageMemoryBudget (ImageMemoryGuard.swift:673-764) are both actors with a UUID-keyed FIFO waiter array, CheckedContinuation<Void, any Error>, withTaskCancellationHandler + cancelWaiter, and head-of-queue admission — the latter is a strict generalisation of the former. ImageMemoryBudget gained releaseFromAnyContext so callers can use `defer`; AsyncSemaphore did not, so its four call sites (withLocalRead, withRemoteBody, SVGSourceLoader.loadLocalFile, loadRemote) hand-write the release into two or three catch arms each. WebViewPool.withWebView already established the scoped pattern. I traced all six acquire sites: no leaks today, but this is the shape where the next catch arm forgets one.Add AsyncSemaphore.withPermit { } (and ImageMemoryBudget.withReservation) and collapse the call sites; or make AsyncSemaphore a thin wrapper over ImageMemoryBudget.
minorstreamDownload is duplicated three ways and appends one byte at a timeImageLoader.swift:293-320, SVGSourceLoader.swift:160-187 and URLDocumentLoader.swift:197-222 differ only in the cap and the thrown case; SVGSourceLoader's comment already says 'Mirrors URLDocumentLoader.streamDownload'. All three do `for try await byte in bytes { data.append(byte) }` — one async next() per byte, ~1-5 MB/s versus 100+ MB/s chunked. This PR made the cost matter more: withRemoteBody now holds one of six network permits across the download AND materialisation. Pre-existing, blast radius extended.Add a BoundedRead.stream(bytes:response:limit:) mirroring BoundedFileRead and map it to each domain error; accumulate whole Data chunks via URLSessionDataDelegate.
nitDead API, unreachable branch, and stringly-typed SVG detectionVerdict.admitsDecoding has no production caller (tests only). In platformImage, `if let refusal = decision.refusalError { throw refusal }` at :533 already handles both refusal cases, so the `case .refuseOversized, .refuseUnsizable:` arm at :549-551 is unreachable and its `?? .decodeFailed` implies a nil case that cannot occur. BlockHTMLEmitter.swift:1064 uses `mimeType.lowercased().contains("svg")` where ImagePathResolver.isSVG(source:resolved:) already answers this — and the neighbouring PrismDocSchemeHandler.loadImageData calls the shared helper for the identical decision.Drop the unreachable arm, use ImagePathResolver.isSVG, and either wire up or remove admitsDecoding.
nitMiscellaneous(a) `.networkError("Cancelled")` is now the cancellation sentinel for local reads and budget waits too, carrying an unlocalised English literal into displayMessage — a dedicated ImageLoadError.cancelled would be localisable. (b) ImageMemoryBudget.release does `reserved = max(0, reserved - cost)`, silently absorbing an unbalanced release in an actor whose selling point is a trivially auditable invariant. (c) frame 0's header is parsed twice on the native oversized path (:445-465 then :459). (d) ProductionSourceScan walks the tree twice per invocation, 16 times across the two suites. (e) `.gitignore`'s new `DerivedData*/` subsumes the `DerivedData/` line directly above it. (f) CLAUDE.md says 'Three verdicts' where there are four cases (refuseOversized/refuseUnsizable merged as 'refuse' in the same clause — reads as deliberate shorthand).All cosmetic; none affect behaviour.

Per-file diffs

Click to expand.

prism/Services/ImageMemoryGuard.swift Added +764 / -0
diff --git a/prism/Services/ImageMemoryGuard.swift b/prism/Services/ImageMemoryGuard.swiftnew file mode 100644index 00000000..3ecf4a6a--- /dev/null+++ b/prism/Services/ImageMemoryGuard.swift@@ -0,0 +1,764 @@+//+//  ImageMemoryGuard.swift+//  prism+//+//  The single admission point for turning image bytes into pixels+//  (T-2132 / T-2149 / T-2151 / T-1867).+//+//  ## The defect class+//+//  Every one of those four tickets is the same mistake wearing different clothes:+//  a resource limit checked only after the memory it was meant to bound has+//  already been committed.+//+//  - A 50 MB cap on a *compressed* network body says nothing about the decoded+//    bitmap, and a solid-colour PNG compresses ~200:1 (T-2132).+//  - A cap applied after `Data(contentsOf:)` has already read the whole file has+//    spent the memory it is refusing (T-1867).+//  - A limit that only exists on one path is not a limit: inline `data:` URIs and+//    local rasters were handed to WebKit without passing any of it (T-2149).+//  - A concurrency bound that counts *fetches* rather than the memory each fetch+//    can materialise does not bound memory at all (T-2151).+//+//  ## The chokepoint+//+//  This file is the only place in production that may turn *encoded image bytes*+//  into pixels: create a `CGImageSource`, call `PlatformImage(data:)`, decode a+//  `CGImage`, or encode one back to bytes. That is narrower than "the only place+//  that allocates a bitmap" — see "What this guard is NOT the only owner of"+//  below. `ImageMaterializationChokepointTests` scans production source and fails+//  when a new call site appears elsewhere, so the enumeration happens when the+//  call site is written rather than in the next ticket.+//+//  Every path takes the same three steps, in this order:+//+//  1. **Bound the encoded bytes** before they are resident — `BoundedFileRead`+//     for local files, streamed caps for the network, the data-URI cap for inline+//     payloads.+//  2. **Decide from the header**, never from a decode — `verdict(for:consumer:)`+//     reads declared dimensions, sample depth, colour model and frame count via+//     ImageIO and returns whether the image may be decoded whole, must be decoded+//     downsampled, or must not be decoded at all.+//  3. **Reserve the estimated cost** from a byte-weighted budget and hold it+//     across the whole materialisation, decode *and* re-encode.+//+//  ## What this can and cannot guarantee+//+//  It CAN guarantee that no decoder is ever handed an image whose declared+//  decoded size exceeds ``maxAdmissibleDecodedBytes``: that check happens on+//  parsed header fields, before any pixel work. This is the part the previous+//  round got wrong — it sent *every* over-budget image to+//  `CGImageSourceCreateThumbnailAtIndex`, and PNG has no subsampled-decode+//  guarantee, so ImageIO could transiently inflate a 20,000x20,000 source to+//  hundreds of megabytes on the way to a small thumbnail.+//+//  It CANNOT guarantee that the transient peak equals the retained result. For a+//  format with no subsampled decode, a downsample still costs roughly the full+//  source bitmap transiently. So the honest statement is two numbers, not one:+//+//  - **Retained** bitmap: at most ``maxRetainedDecodedBytes`` (~64 MB).+//  - **Transient** peak per image: at most the image's *declared* decoded size,+//    which admission has already capped at ``maxAdmissibleDecodedBytes``+//    (~256 MB) — and the byte budget caps how many such peaks can coincide.+//+//  Both numbers rest on the per-pixel size being read from the header rather+//  than assumed (see ``bytesPerPixel(depth:colorModel:hasAlpha:)``). Assuming+//  4 bytes per pixel — which this file did until T-2132's second review —+//  under-counts a 16-bit PNG by 2x and a 32-bit float TIFF by 4x, and a ceiling+//  computed from an under-count is not a ceiling.+//+//  It also cannot account for what WebKit decodes on its own. When a page raster+//  is admitted whole, the original bytes are served and WebKit's decoders spend+//  their own memory; admission bounds each such image at+//  ``maxRetainedDecodedBytes``, but not how many the page decodes at once.+//+//  Finally, the declared size is what the file *claims*. A truncated or lying+//  header is caught by the decode failing, not by the estimate.+//+//  ## What this guard is NOT the only owner of+//+//  Its ownership claim is over turning *encoded image bytes* into pixels. It is+//  not the only place in Prism that allocates a bitmap: `WebViewPool`'s+//  `captureSnapshot` asks WebKit to rasterise a rendered page (mermaid and SVG+//  thumbnails), which allocates a bitmap of up to 2000x2000 points at up to 3x+//  display scale — ~144 MB — with no reservation against ``budget``. That path+//  has no encoded bytes and no header to read, so nothing here applies to it;+//  it is bounded instead by its own pool's slot count and by the 2000-point+//  frame clamp in `SVGRenderer`. `ImageMaterializationChokepointTests` lists it+//  so a *second* snapshot site has to be argued for rather than merely added.+//++import CoreGraphics+import Foundation+import ImageIO+import UniformTypeIdentifiers++#if canImport(UIKit)+import UIKit+#elseif canImport(AppKit)+import AppKit+#endif++// MARK: - ImageMemoryGuard++nonisolated enum ImageMemoryGuard {++    // MARK: Budgets++    /// Largest width/height (in pixels) a *retained* bitmap may have when its+    /// pixels are the ordinary 4 bytes each. Also the target edge for a+    /// downsampled decode of such an image.+    ///+    /// 4096 comfortably covers real document images (well past typical screen+    /// resolutions) while bounding worst-case retained 8-bit RGBA memory to+    /// ``maxRetainedDecodedBytes``. A deeper pixel format gets a *smaller* edge —+    /// see ``retainedDimensionCap(bytesPerPixel:)`` — because it is the byte+    /// ceiling that is fixed, not the pixel count.+    static let maxDecodedDimension = 4_096++    /// The pixel size every dimension-shaped constant here is expressed in:+    /// 8-bit RGBA, the layout the overwhelming majority of images decode to.+    ///+    /// It is a *reference point*, never an assumption about a particular image.+    /// The per-image number comes from the header.+    static let referenceBytesPerPixel = 4++    /// Ceiling on the bitmap Prism keeps: `width * height * bytesPerPixel` summed+    /// over every retained frame. 4096 * 4096 * 4 ≈ 64 MB — a quarter of+    /// `ImageCache`'s 256 MB total (specs/operational-hardening/decision_log.md,+    /// Decision 4).+    ///+    /// Frame count is part of the sum because `NSImage(data:)` materialises every+    /// frame of an animated image as its own bitmap representation, so a modest+    /// resolution with thousands of frames exhausts memory even though no single+    /// frame is large. That is a property of the *native* consumer only; see+    /// ``Consumer``.+    static let maxRetainedDecodedBytes =+        maxDecodedDimension * maxDecodedDimension * referenceBytesPerPixel++    /// Hard ceiling on the declared decoded size Prism will hand to *any* decoder,+    /// including a downsampling one. Past this the image is refused outright and+    /// no pixel work happens at all.+    ///+    /// This is the number that answers the T-2132 reopen. A downsample is not a+    /// free bound: PNG, GIF, TIFF and friends have no subsampled decode, so+    /// `CGImageSourceCreateThumbnailAtIndex` may inflate the whole source bitmap+    /// before scaling it. Since the transient peak is roughly the declared size,+    /// the only way to bound the peak is to bound what may be declared.+    ///+    /// Four times the retained budget: generous enough that ordinary+    /// high-resolution photography still renders, tight enough to be survivable+    /// on an iOS device once. Where that lands in *megapixels* now depends on the+    /// pixel format, which is the point: an 8-bit photo is downsampled up to+    /// ~67 MP and refused past it, while the same picture stored as 16-bit RGBA+    /// costs twice as much per pixel and so is refused past ~33 MP.+    static let maxAdmissibleDecodedBytes = 4 * maxRetainedDecodedBytes++    /// Upper bound on how many frames the header scan inspects.+    ///+    /// The all-frame budget must consider every frame's declared dimensions, not+    /// just frame 0's — multi-page TIFF and HEIC sequences allow per-frame sizes+    /// to differ, so a tiny first page could otherwise vouch for an enormous later+    /// one. Scanning is a header parse per frame, so it must itself be bounded: a+    /// container with more frames than this cannot qualify for a whole-image+    /// decode and falls to the single-frame path.+    ///+    /// Only the ``Consumer/native`` path scans frames at all.+    static let maxScannedFrames = 64++    // MARK: Consumer++    /// Who is going to decode the admitted bytes, which decides what "the memory+    /// this image costs" even means.+    ///+    /// Applying one model to both was a real defect, not a tidiness point: a+    /// 100-frame 100x100 GIF is about 4 MB decoded, and the native model refused+    /// it — past ``maxScannedFrames`` an animation cannot be admitted whole, so it+    /// fell to a single downsampled frame, which for the page path means the+    /// animation is re-encoded away as a still PNG. No memory argument supports+    /// that; it came from charging the page consumer for a cost only the native+    /// one pays.+    enum Consumer: Equatable {+        /// `PlatformImage(data:)` on a native surface. Eager and all-frame:+        /// `NSImage(data:)` materialises one bitmap representation per frame, so+        /// every frame's declared size counts toward the retained budget.+        case native++        /// WebKit, given the encoded bytes to render in the document.+        ///+        /// Progressive and frame-at-a-time: WebKit decodes an animation as it+        /// plays and keeps its own bounded frame cache rather than one bitmap per+        /// frame, so frame 0's declared size — not the sum over frames — is what+        /// admission can meaningfully bound. What WebKit's cache costs on top of+        /// that is not accounted here, and is not accounted for a still image+        /// either; it is the same disclaimed residual in both cases.+        case page+    }++    /// Ceiling on the *encoded* bytes of a single image from any source. Matches+    /// the remote streaming cap that already existed (T-1195).+    static let maxEncodedImageBytes = 50 * 1024 * 1024++    /// Ceiling on the *encoded* bytes of SVG source (Req 1.9). Unlike a raster,+    /// an SVG's cost is not derivable from a header, so bytes are the only handle+    /// there is — which makes enforcing them before the read (T-1867) the whole+    /// of the protection rather than a refinement of it.+    static let maxSVGBytes = 2 * 1024 * 1024++    // MARK: Verdict++    /// What the header says may be done with this image. Produced without+    /// decoding anything.+    enum Verdict: Equatable {+        /// Every frame's declared size, summed, fits ``maxRetainedDecodedBytes``.+        /// The image may be decoded whole, or served to the page verbatim.+        ///+        /// `estimatedBytes` is that sum — the reservation this decode should pay.+        case decodeWhole(estimatedBytes: Int)++        /// Too big to keep whole, but frame 0's declared size is within+        /// ``maxAdmissibleDecodedBytes``, so a bounded downsample of that frame+        /// has a transient peak we are willing to spend.+        ///+        /// `estimatedBytes` is frame 0's declared decoded size: the transient+        /// peak, not the retained result. `bytesPerPixel` travels with it because+        /// a thumbnail keeps the source's bit depth — `CGImageSourceCreateThumbnail`+        /// hands back a 16-bit image for a 16-bit source — so the edge the+        /// downsample must target depends on it.+        case downsampleFirstFrame(estimatedBytes: Int, bytesPerPixel: Int)++        /// A valid image whose declared size is past ``maxAdmissibleDecodedBytes``.+        /// Nothing decodes it. `declaredBytes` is what it claimed.+        case refuseOversized(declaredBytes: Int)++        /// The header reports no usable size. Unprovable, so treated as a bomb —+        /// nothing about it can be bounded — but reported to the user as a decode+        /// failure, because "not a usable image" is what it actually is and+        /// "too large" would be a guess.+        case refuseUnsizable++        /// Whether this verdict permits any pixel work at all.+        var admitsDecoding: Bool {+            switch self {+            case .decodeWhole, .downsampleFirstFrame: return true+            case .refuseOversized, .refuseUnsizable: return false+            }+        }++        /// The error a refusal surfaces. `nil` for an admitted image.+        var refusalError: ImageLoadError? {+            switch self {+            case .decodeWhole, .downsampleFirstFrame: return nil+            case .refuseOversized: return .tooLarge+            case .refuseUnsizable: return .decodeFailed+            }+        }+    }++    // MARK: Pure budget arithmetic++    /// How many bytes CoreGraphics allocates per decoded pixel for a header's+    /// declared sample depth and colour model, or `nil` when it cannot be told.+    ///+    /// The number the rest of this file used to hardcode as 4. It is not a+    /// constant: a 16-bit PNG decodes at 8 bytes per pixel and a 32-bit float+    /// TIFF at 16, so a budget denominated in a fixed 4 under-counts them by 2x+    /// and 4x — and a ceiling computed from an under-count bounds nothing. The+    /// codebase already knew this on the other side of the decode:+    /// `PlatformImage.estimatedMemoryCost` measures `bytesPerRow * height`, which+    /// *is* depth-aware. This predicts the same quantity from the header, before+    /// the allocation, which is the only side of it that can refuse.+    ///+    /// `depth` is bits per *component* (`kCGImagePropertyDepth`), so the byte+    /// size of one component is `ceil(depth / 8)`.+    ///+    /// Component counts are what CoreGraphics allocates, not what the format+    /// nominally stores. Two consequences worth stating, both verified against+    /// real ImageIO output rather than reasoned from the docs:+    ///+    /// - **RGB gets four components even with no alpha.** CoreGraphics has no+    ///   packed 24-bit pixel format, so an opaque 8-bit JPEG decodes at 32 bits+    ///   per pixel with a skip byte. Counting 3 would under-count by a third.+    /// - **Grey gets one.** An opaque 8-bit greyscale PNG really does decode at+    ///   one byte per pixel, so flooring this at 4 "to be safe" would over-count+    ///   it fourfold and refuse images that are perfectly affordable.+    static func bytesPerPixel(depth: Int, colorModel: String, hasAlpha: Bool) -> Int? {+        guard depth > 0, depth <= 64,+              let components = componentsPerPixel(colorModel: colorModel, hasAlpha: hasAlpha) else {+            return nil+        }+        return ((depth + 7) / 8) * components+    }++    private static func componentsPerPixel(colorModel: String, hasAlpha: Bool) -> Int? {+        switch colorModel {+        case "Gray": return hasAlpha ? 2 : 1+        case "RGB", "Lab": return 4+        case "CMYK": return hasAlpha ? 5 : 4+        // An unrecognised colour model cannot be sized, and an unsizable image is+        // exactly what this guard refuses. See ``FrameShape`` for why failing+        // closed here costs nothing in practice.+        default: return nil+        }+    }++    /// Declared decoded byte cost — `width * height * bytesPerPixel * frames` —+    /// or `nil` when the inputs are not a sane, non-overflowing size.+    ///+    /// `nil` is a refusal, not a zero: a header claiming dimensions that overflow+    /// `Int` is exactly the input this guard exists for.+    static func declaredDecodedBytes(+        width: Int, height: Int, frameCount: Int, bytesPerPixel: Int+    ) -> Int? {+        guard width > 0, height > 0, frameCount > 0, bytesPerPixel > 0 else { return nil }+        let (pixels, pixelOverflow) = width.multipliedReportingOverflow(by: height)+        guard !pixelOverflow else { return nil }+        let (perFrame, byteOverflow) = pixels.multipliedReportingOverflow(by: bytesPerPixel)+        guard !byteOverflow else { return nil }+        let (total, totalOverflow) = perFrame.multipliedReportingOverflow(by: frameCount)+        guard !totalOverflow else { return nil }+        return total+    }++    /// The longest edge a retained bitmap of this pixel size may have.+    ///+    /// A square of this edge costs exactly ``maxRetainedDecodedBytes``, so the+    /// edge shrinks as the pixel grows: 4096 at 4 bytes per pixel, 2896 at 8,+    /// 2048 at 16. Load-bearing for the downsample path — a thumbnail keeps the+    /// source's bit depth, so scaling a 16-bit image to 4096 px would retain+    /// 128 MB and quietly break the 64 MB ceiling this file advertises.+    ///+    /// Never *larger* than ``maxDecodedDimension``: a cheap pixel format earns no+    /// extra resolution, because 4096 is also a statement about what a document+    /// image plausibly needs.+    static func retainedDimensionCap(bytesPerPixel: Int) -> Int {+        guard bytesPerPixel > 0 else { return 0 }+        let edge = Int(Double(maxRetainedDecodedBytes / bytesPerPixel).squareRoot())+        return min(maxDecodedDimension, max(1, edge))+    }++    /// Whether a bitmap of this declared shape is too large to retain whole.+    ///+    /// Pure and dimension-first: the per-axis cap is checked before the byte sum+    /// so an image that is within budget only because it is a 1-pixel-tall strip+    /// 100 million pixels wide is still refused a whole decode. Such shapes decode+    /// to bitmaps whose *row* allocation alone can dwarf the budget.+    static func exceedsRetainedBudget(+        width: Int, height: Int, frameCount: Int, bytesPerPixel: Int+    ) -> Bool {+        let cap = retainedDimensionCap(bytesPerPixel: bytesPerPixel)+        guard width <= cap, height <= cap else { return true }+        guard let bytes = declaredDecodedBytes(+            width: width, height: height, frameCount: frameCount, bytesPerPixel: bytesPerPixel+        ) else {+            return true+        }+        return bytes > maxRetainedDecodedBytes+    }++    // MARK: Header inspection++    /// What one frame's header declares: its pixel dimensions and the byte size+    /// of one of its decoded pixels.+    ///+    /// Absent when the header does not report dimensions, depth, or a colour+    /// model this can size. All three are refused on the same grounds — an+    /// unprovable cost is not a bounded one — and the refusal is deliberate+    /// rather than a fallback to a guessed depth, because a guessed depth is the+    /// exact defect this replaces.+    ///+    /// Failing closed on depth costs nothing observable: every format ImageIO can+    /// report dimensions for also reports `kCGImagePropertyDepth` and+    /// `kCGImagePropertyColorModel` (checked across PNG at 8/16-bit RGBA and+    /// 8/16-bit grey, JPEG, GIF, TIFF at 8/16-bit and 32-bit float, and HEIC).+    /// Where they are missing — truncated headers, non-image bytes — the+    /// dimensions are missing too, so the image was already refused.+    private struct FrameShape {+        let width: Int+        let height: Int+        let bytesPerPixel: Int+    }++    /// One frame's declared shape, straight from the header. No decode:+    /// `CGImageSourceCopyPropertiesAtIndex` parses metadata only.+    private static func declaredShape(in source: CGImageSource, at index: Int) -> FrameShape? {+        guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],+              let width = properties[kCGImagePropertyPixelWidth] as? Int,+              let height = properties[kCGImagePropertyPixelHeight] as? Int,+              let depth = properties[kCGImagePropertyDepth] as? Int,+              let model = properties[kCGImagePropertyColorModel] as? String,+              let perPixel = bytesPerPixel(+                  depth: depth,+                  colorModel: model,+                  hasAlpha: properties[kCGImagePropertyHasAlpha] as? Bool ?? false+              ) else {+            return nil+        }+        return FrameShape(width: width, height: height, bytesPerPixel: perPixel)+    }++    /// The worst case across `frameCount` frames, or `nil` when any frame is+    /// unsizable.+    ///+    /// Per-axis and per-depth maxima are taken independently, so one frame's+    /// width can pair with another frame's height and a third frame's pixel size+    /// (10x400 plus 300x20 is budgeted as 300x400 per frame). The estimate may+    /// therefore overstate the true footprint — it only ever errs toward+    /// refusing, never toward an unbounded decode.+    private static func maxDeclaredShape(+        in source: CGImageSource, frameCount: Int+    ) -> FrameShape? {+        var maxWidth = 0+        var maxHeight = 0+        var maxBytesPerPixel = 0+        for index in 0..<frameCount {+            guard let shape = declaredShape(in: source, at: index) else { return nil }+            maxWidth = max(maxWidth, shape.width)+            maxHeight = max(maxHeight, shape.height)+            maxBytesPerPixel = max(maxBytesPerPixel, shape.bytesPerPixel)+        }+        return FrameShape(width: maxWidth, height: maxHeight, bytesPerPixel: maxBytesPerPixel)+    }++    /// The admission decision for an image source, from its header alone.+    ///+    /// Three tiers, in order, of which the first applies only to+    /// ``Consumer/native``:+    ///+    /// 1. If the frame count is scannable and *every* frame's declared size sums+    ///    within the retained budget, the image may be decoded whole. This is the+    ///    all-frame model `NSImage(data:)` needs and nothing else does.+    /// 2. Frame 0 alone, within the retained budget: admitted whole. For the page+    ///    that means served verbatim, so an animation keeps animating however+    ///    many frames it has — WebKit decodes them progressively. For the native+    ///    consumer this tier is unreachable, because tier 1 already covers it.+    /// 3. Frame 0 within ``maxAdmissibleDecodedBytes``: a downsample of that+    ///    frame is allowed, its transient peak bounded by that declared size. Past+    ///    the ceiling, or with an unsizable frame 0, nothing is decoded.+    ///+    /// Tier 3 is what keeps an enormous single image renderable (as a bounded+    /// still) while refusing a 20,000x20,000 PNG outright.+    static func verdict(for source: CGImageSource, consumer: Consumer) -> Verdict {+        let frameCount = CGImageSourceGetCount(source)+        guard frameCount >= 1 else { return .refuseUnsizable }++        if consumer == .native,+           frameCount <= maxScannedFrames,+           let shape = maxDeclaredShape(in: source, frameCount: frameCount),+           !exceedsRetainedBudget(+               width: shape.width, height: shape.height,+               frameCount: frameCount, bytesPerPixel: shape.bytesPerPixel+           ),+           let total = declaredDecodedBytes(+               width: shape.width, height: shape.height,+               frameCount: frameCount, bytesPerPixel: shape.bytesPerPixel+           ) {+            return .decodeWhole(estimatedBytes: total)+        }++        guard let first = declaredShape(in: source, at: 0),+              let firstBytes = declaredDecodedBytes(+                  width: first.width, height: first.height,+                  frameCount: 1, bytesPerPixel: first.bytesPerPixel+              ) else {+            return .refuseUnsizable+        }+        if consumer == .page,+           !exceedsRetainedBudget(+               width: first.width, height: first.height,+               frameCount: 1, bytesPerPixel: first.bytesPerPixel+           ) {+            return .decodeWhole(estimatedBytes: firstBytes)+        }+        guard firstBytes <= maxAdmissibleDecodedBytes else {+            return .refuseOversized(declaredBytes: firstBytes)+        }+        return .downsampleFirstFrame(+            estimatedBytes: firstBytes, bytesPerPixel: first.bytesPerPixel+        )+    }++    /// The admission decision for encoded bytes, or `nil` when ImageIO will not+    /// even open them as a source.+    ///+    /// `nil` is rarer than it looks: `CGImageSourceCreateWithData` succeeds for+    /// arbitrary bytes and merely reports zero frames, so junk normally arrives+    /// as ``Verdict/refuseUnsizable``. Both answers refuse; neither admits.+    static func verdict(forEncoded data: Data, consumer: Consumer) -> Verdict? {+        guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }+        return verdict(for: source, consumer: consumer)+    }++    // MARK: Byte budget++    /// The process-wide budget every native materialisation is charged against.+    ///+    /// Capacity is ``maxAdmissibleDecodedBytes``: one image at the admission+    /// ceiling may run, alone. A slot count cannot express that — four slots of+    /// "an image" bounds nothing when one image can be 250 MB and the next 8 MB,+    /// which is T-2151.+    static let budget = ImageMemoryBudget(capacity: maxAdmissibleDecodedBytes)++    /// What a verdict costs to carry out, in budget bytes.+    ///+    /// The downsample charge adds two retained-budget slices on top of the+    /// transient source bitmap: one for the thumbnail that survives it, one for+    /// the encoder's output buffer when the result is re-encoded for the page.+    /// Both are real allocations that used to sit outside any bound (T-2151).+    static func reservationCost(for verdict: Verdict) -> Int {+        switch verdict {+        case .decodeWhole(let estimatedBytes):+            return estimatedBytes+        case .downsampleFirstFrame(let estimatedBytes, _):+            return estimatedBytes + 2 * maxRetainedDecodedBytes+        case .refuseOversized, .refuseUnsizable:+            return 0+        }+    }++    // MARK: Materialisation — the only decode/encode sites in production++    /// Decodes `data` into a platform image for the native (SwiftUI) surfaces,+    /// bounded by the retained budget.+    ///+    /// The caller is expected to still hold whatever permit let it retain+    /// `data` (a network or local-read permit), so the compressed body cannot+    /// outlive that bound while this waits for budget capacity — see+    /// `ImageLoader.withRemoteBody`.+    static func platformImage(from data: Data) async throws(ImageLoadError) -> PlatformImage {+        guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {+            throw .decodeFailed+        }+        let decision = verdict(for: source, consumer: .native)+        if let refusal = decision.refusalError { throw refusal }++        let cost = reservationCost(for: decision)+        do {+            try await budget.acquire(cost)+        } catch {+            throw .networkError("Cancelled")+        }+        defer { budget.releaseFromAnyContext(cost) }++        switch decision {+        case .decodeWhole:+            guard let image = PlatformImage(data: data) else { throw .decodeFailed }+            return image+        case .downsampleFirstFrame(_, let bytesPerPixel):+            return wrap(try downsampledFirstFrame(of: source, bytesPerPixel: bytesPerPixel))+        case .refuseOversized, .refuseUnsizable:+            throw decision.refusalError ?? .decodeFailed+        }+    }++    /// Produces the bytes the rendered page should receive for a raster source,+    /// plus their MIME type.+    ///+    /// Admitted-whole images are served **verbatim**: their declared decoded size+    /// is already inside the retained budget, so re-encoding them would spend a+    /// decode, a bitmap and an encoder buffer to arrive at bytes no safer than+    /// the ones already validated. Only an image that must be downsampled is+    /// decoded here, and then the whole decode-plus-encode runs inside one+    /// reservation (T-2151, stage 2 — the re-encode used to happen outside every+    /// bound).+    ///+    /// The caller is expected to still hold whatever permit let it retain+    /// `data`, as in ``platformImage(from:)``.+    static func pageBytes(+        from data: Data, fallbackMIMEType: String+    ) async throws(ImageLoadError) -> (data: Data, mimeType: String) {+        guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {+            throw .decodeFailed+        }+        let decision = verdict(for: source, consumer: .page)++        switch decision {+        case .refuseOversized, .refuseUnsizable:+            throw decision.refusalError ?? .decodeFailed++        case .decodeWhole:+            // The MIME type comes from the bytes, not from a file extension: a+            // remote URL need not carry one and a `data:` URI can lie about it.+            let mime = (CGImageSourceGetType(source) as String?)+                .flatMap { UTType($0)?.preferredMIMEType }+            return (data, mime ?? fallbackMIMEType)++        case .downsampleFirstFrame(_, let bytesPerPixel):+            let cost = reservationCost(for: decision)+            do {+                try await budget.acquire(cost)+            } catch {+                throw .networkError("Cancelled")+            }+            defer { budget.releaseFromAnyContext(cost) }+            let thumbnail = try downsampledFirstFrame(of: source, bytesPerPixel: bytesPerPixel)+            return (try encodePNG(thumbnail), "image/png")+        }+    }++    /// Frame 0, decoded no larger on its long edge than this pixel size's+    /// ``retainedDimensionCap(bytesPerPixel:)``.+    ///+    /// The cap is depth-derived rather than a flat 4096 because the thumbnail+    /// keeps the source's bit depth: a 16-bit source scaled to 4096 px would+    /// retain 128 MB, twice the ceiling this file advertises.+    ///+    /// Only ever reached for a source whose frame 0 declares at most+    /// ``maxAdmissibleDecodedBytes`` — that check is the bound on this call's+    /// transient cost, because a format without subsampled decode inflates the+    /// full source bitmap here before scaling it down.+    private static func downsampledFirstFrame(+        of source: CGImageSource, bytesPerPixel: Int+    ) throws(ImageLoadError) -> CGImage {+        let options: [CFString: Any] = [+            kCGImageSourceCreateThumbnailFromImageAlways: true,+            kCGImageSourceShouldCacheImmediately: true,+            kCGImageSourceCreateThumbnailWithTransform: true,+            kCGImageSourceThumbnailMaxPixelSize: retainedDimensionCap(bytesPerPixel: bytesPerPixel),+        ]+        guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {+            throw .decodeFailed+        }+        return thumbnail+    }++    /// Wraps a decoded frame as a platform image without a second decode.+    private static func wrap(_ cgImage: CGImage) -> PlatformImage {+        #if canImport(UIKit)+        return PlatformImage(cgImage: cgImage)+        #elseif canImport(AppKit)+        return PlatformImage(+            cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)+        )+        #endif+    }++    /// Encodes a decoded frame as PNG.+    ///+    /// Goes through `CGImageDestination` on both platforms rather than+    /// `tiffRepresentation` + `NSBitmapImageRep` on macOS: that route allocated an+    /// uncompressed TIFF and a second bitmap representation on the way to the same+    /// PNG, roughly tripling the peak (T-2151).+    private static func encodePNG(_ cgImage: CGImage) throws(ImageLoadError) -> Data {+        let buffer = NSMutableData()+        guard let destination = CGImageDestinationCreateWithData(+            buffer, UTType.png.identifier as CFString, 1, nil+        ) else {+            throw .decodeFailed+        }+        CGImageDestinationAddImage(destination, cgImage, nil)+        guard CGImageDestinationFinalize(destination) else { throw .decodeFailed }+        return buffer as Data+    }+}++// MARK: - ImageMemoryBudget++/// A byte-weighted admission gate: callers reserve the memory they are about to+/// spend, not a slot.+///+/// The distinction is the whole of T-2151. A four-slot semaphore bounds the+/// number of concurrent decodes, which bounds memory only if every decode costs+/// the same — and the images this guard exists for are precisely the ones that+/// do not. Here a 250 MB materialisation excludes everything else while an 8 MB+/// one barely registers.+///+/// Waiters are strictly FIFO and only the head waiter may be admitted. That+/// forgoes some throughput (a small request behind a large one waits) in exchange+/// for no starvation and a trivially auditable invariant: `reserved` never+/// exceeds `capacity`.+///+/// A single request larger than the whole capacity is clamped to it, so an image+/// at the admission ceiling runs alone rather than deadlocking.+actor ImageMemoryBudget {++    private struct Waiter {+        let id: UUID+        let cost: Int+        let continuation: CheckedContinuation<Void, any Error>+    }++    private let capacity: Int+    private var reserved = 0+    private var waiters: [Waiter] = []++    init(capacity: Int) {+        precondition(capacity > 0, "A byte budget needs a positive capacity")+        self.capacity = capacity+    }++    /// Bytes currently reserved.+    var reservedBytes: Int { reserved }++    /// How many requests are queued for capacity.+    ///+    /// Exists so a test can wait for a request to be *enqueued* rather than sleep+    /// and hope. Every ordering property this actor promises is about the queue,+    /// and a test that reaches for it through a fixed sleep is a test that passes+    /// for the wrong reason on a loaded machine: with no way to see the queue,+    /// `fifoAdmission` could observe the small request taking the fast path+    /// because the large one had not enqueued yet, and report the FIFO rule+    /// broken. One readable counter removes every sleep in the suite.+    var waitingCount: Int { waiters.count }++    /// Reserves `bytes`, suspending until they fit.+    ///+    /// - Throws: `CancellationError` if the calling task is cancelled while waiting.+    func acquire(_ bytes: Int) async throws {+        let cost = clamp(bytes)+        if waiters.isEmpty, reserved + cost <= capacity {+            reserved += cost+            return+        }++        let id = UUID()+        try await withTaskCancellationHandler {+            try await withCheckedThrowingContinuation { continuation in+                waiters.append(Waiter(id: id, cost: cost, continuation: continuation))+                // A queue that just became non-empty may still have room for its+                // head — the fast path above declined only because FIFO order+                // must be preserved, not because the budget is full.+                admitHeadWaitersIfPossible()+            }+        } onCancel: {+            Task { await self.cancelWaiter(id: id) }+        }+        // The waiter's reservation was made by whoever resumed it.+    }++    /// Releases a previously reserved `bytes`.+    func release(_ bytes: Int) {+        let cost = clamp(bytes)+        reserved = max(0, reserved - cost)+        admitHeadWaitersIfPossible()+    }++    /// Releases from a synchronous context (a `defer` in an async function cannot+    /// `await`). Ordering against a subsequent `acquire` is not guaranteed, which+    /// is fine: a release only ever frees capacity.+    nonisolated func releaseFromAnyContext(_ bytes: Int) {+        Task { await self.release(bytes) }+    }++    private func clamp(_ bytes: Int) -> Int {+        min(max(bytes, 0), capacity)+    }++    /// Admits waiters from the front of the queue while the head fits. Stops at+    /// the first one that does not, so a large request is never starved by a+    /// stream of small ones behind it.+    private func admitHeadWaitersIfPossible() {+        while let head = waiters.first, reserved + head.cost <= capacity {+            waiters.removeFirst()+            reserved += head.cost+            head.continuation.resume()+        }+    }++    private func cancelWaiter(id: UUID) {+        guard let index = waiters.firstIndex(where: { $0.id == id }) else { return }+        let waiter = waiters.remove(at: index)+        waiter.continuation.resume(throwing: CancellationError())+        admitHeadWaitersIfPossible()+    }+}
prism/Services/BoundedFileRead.swift Added +126 / -0
diff --git a/prism/Services/BoundedFileRead.swift b/prism/Services/BoundedFileRead.swiftnew file mode 100644index 00000000..dbf1561f--- /dev/null+++ b/prism/Services/BoundedFileRead.swift@@ -0,0 +1,126 @@+//+//  BoundedFileRead.swift+//  prism+//+//  The byte-level half of the "limit checked after the memory is already spent"+//  defect class (T-1867 / T-2149).+//+//  `Data(contentsOf:)` commits the whole file before anyone can look at its size,+//  so every cap written as `guard data.count <= limit` is enforced one allocation+//  too late. This is the chokepoint that reads a file *within* a cap instead of+//  measuring one afterwards, and `ImageMaterializationChokepointTests` fails when+//  a new unbounded read appears in production source.+//++import Foundation++/// Reads local files without ever buffering more than a caller-supplied cap.+///+/// Two guards, because either alone is insufficient:+///+/// 1. **Preflight** the file size from the directory entry, so an oversized file+///    is refused before a single byte is read.+/// 2. **Bound the read itself**, chunk by chunk, so a file that GROWS between the+///    preflight and the read (or a source whose reported size lies — a FIFO, a+///    synthetic filesystem) is still cut off at the cap rather than read whole.+///+/// The cap is exclusive of the chunk granularity: at most `limit + chunkSize`+/// bytes are ever resident, and the read stops at the first chunk that crosses+/// the line. That overshoot is the price of not calling `read(upToCount:)` one+/// byte at a time; it is bounded and constant.+nonisolated enum BoundedFileRead {++    /// How much is pulled per `read(upToCount:)`. Also the maximum overshoot past+    /// `limit` before the read aborts.+    static let chunkSize = 256 * 1024++    /// Why a bounded read failed. Deliberately distinct from `ImageLoadError` so+    /// this primitive stays usable by non-image callers (document loading); each+    /// caller maps it to its own domain error.+    enum Failure: Error, Equatable {+        /// The file could not be opened or read.+        case unreadable+        /// Sandbox/permission denial, kept separate so callers can offer the+        /// grant-access affordance instead of a generic failure.+        case noPermission+        /// The file is larger than the cap. `byteCount` is the size that proved+        /// it — the preflighted size, or the running total at the moment the+        /// read crossed the cap.+        case tooLarge(byteCount: Int, limit: Int)+    }++    /// The file's size as reported by its directory entry, or `nil` when the+    /// filesystem does not report one.+    ///+    /// Exposed so a caller that must reserve memory *before* reading (the image+    /// pipeline charges its byte budget up front) can do so from the preflight+    /// rather than from a completed read.+    static func reportedSize(of url: URL) -> Int? {+        (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize+    }++    /// Reads at most `limit` bytes from `url`, throwing rather than allocating+    /// when the file is larger.+    ///+    /// - Parameters:+    ///   - url: The file to read.+    ///   - limit: The inclusive byte cap. A file of exactly `limit` bytes is read.+    /// - Throws: ``Failure``.+    static func read(contentsOf url: URL, limit: Int) throws(Failure) -> Data {+        precondition(limit > 0, "A bounded read needs a positive limit")++        if let reported = reportedSize(of: url), reported > limit {+            throw .tooLarge(byteCount: reported, limit: limit)+        }++        let handle: FileHandle+        do {+            handle = try FileHandle(forReadingFrom: url)+        } catch let error as NSError where error.domain == NSCocoaErrorDomain+            && error.code == NSFileReadNoPermissionError {+            throw .noPermission+        } catch {+            throw .unreadable+        }+        defer { try? handle.close() }++        return try read(from: handle, limit: limit, expecting: reportedSize(of: url))+    }++    /// The second guard on its own: reads `handle` in bounded chunks and aborts+    /// the moment the running total crosses `limit`.+    ///+    /// Separate from ``read(contentsOf:limit:)`` because the preflight there+    /// short-circuits almost every oversized file, which would leave this loop —+    /// the guard that actually covers a file whose reported size is stale or+    /// wrong — with no way to be exercised. A test can drive it directly against+    /// an open handle instead of having to find a file whose size the filesystem+    /// misreports.+    ///+    /// - Parameter expecting: The preflighted size, used only to size the buffer.+    static func read(+        from handle: FileHandle, limit: Int, expecting reportedSize: Int? = nil+    ) throws(Failure) -> Data {+        var data = Data()+        data.reserveCapacity(min(reportedSize ?? chunkSize, limit))+        while true {+            let chunk: Data?+            do {+                chunk = try handle.read(upToCount: chunkSize)+            } catch {+                throw .unreadable+            }+            guard let chunk, !chunk.isEmpty else { break }+            data.append(chunk)+            if data.count > limit {+                // Drop the buffer before reporting: the caller's error path may+                // itself allocate, and there is no reason to hold an over-cap+                // body while it runs.+                let total = data.count+                data = Data()+                throw .tooLarge(byteCount: total, limit: limit)+            }+        }+        return data+    }+}
prism/Services/ImageLoader.swift Modified +125 / -248
diff --git a/prism/Services/ImageLoader.swift b/prism/Services/ImageLoader.swiftindex f33a4d26..3b68853d 100644--- a/prism/Services/ImageLoader.swift+++ b/prism/Services/ImageLoader.swift@@ -12,8 +12,10 @@ import AppKit  /// Cancellation-safe async semaphore for limiting concurrency. ///-/// Used via `SharedConcurrency` to cap concurrent remote fetches-/// (ImageLoader, SVGSourceLoader) and guarded image decodes.+/// Used via `SharedConcurrency` to cap concurrent remote fetches and local+/// file reads (ImageLoader, SVGSourceLoader) — that is, how many compressed+/// bodies can be resident. Decode concurrency is bounded in bytes instead, by+/// `ImageMemoryGuard.budget`. /// Waiters are resumed in FIFO order when slots become available. actor AsyncSemaphore {     private let limit: Int@@ -96,21 +98,60 @@ actor ImageLoader {     }      private var networkSemaphore: AsyncSemaphore { SharedConcurrency.networkSemaphore }-    private var decodeSemaphore: AsyncSemaphore { SharedConcurrency.decodeSemaphore }+    private var readSemaphore: AsyncSemaphore { SharedConcurrency.localReadSemaphore } -    /// Loads an image from the given resolved source.+    /// Loads an image from the given resolved source, for the native (SwiftUI)+    /// surfaces.     ///     /// - Parameter source: The resolved image source to load from.-    /// - Returns: The decoded platform image.-    /// - Throws: `ImageLoadError` if loading fails.+    /// - Returns: The decoded platform image, bounded by `ImageMemoryGuard`.+    /// - Throws: `ImageLoadError` if loading fails or the image is refused.     func load(_ source: ResolvedImageSource) async throws(ImageLoadError) -> PlatformImage {         switch source {         case .localFile(let url):-            return try loadLocalFile(url)+            return try await withLocalRead(url) { encoded in+                try await ImageMemoryGuard.platformImage(from: encoded)+            }         case .remote(let url):-            return try await loadRemote(url)+            return try await withRemoteBody(url) { encoded in+                try await ImageMemoryGuard.platformImage(from: encoded)+            }         case .dataURI(let data, _):-            return try decodeImageData(data)+            return try await ImageMemoryGuard.platformImage(from: data)+        case .failed(let error):+            throw error+        }+    }++    /// Loads the bytes the rendered page should receive for a raster source, plus+    /// their MIME type.+    ///+    /// The page counterpart of ``load(_:)`` and the only entry point+    /// `PrismDocSchemeHandler` uses for rasters. Before T-2149 the handler read+    /// local files and returned data-URI payloads itself, so those two sources+    /// reached WebKit having passed no decoded-size check at all — the guard+    /// existed on exactly one of the three paths.+    func loadPageBytes(+        _ source: ResolvedImageSource, fallbackMIMEType: String+    ) async throws(ImageLoadError) -> (data: Data, mimeType: String) {+        switch source {+        case .localFile(let url):+            return try await withLocalRead(url) { encoded in+                try await ImageMemoryGuard.pageBytes(+                    from: encoded, fallbackMIMEType: fallbackMIMEType+                )+            }+        case .remote(let url):+            return try await withRemoteBody(url) { encoded in+                try await ImageMemoryGuard.pageBytes(+                    from: encoded, fallbackMIMEType: fallbackMIMEType+                )+            }+        case .dataURI(let data, let mimeType):+            return try await ImageMemoryGuard.pageBytes(+                from: data,+                fallbackMIMEType: mimeType.isEmpty ? fallbackMIMEType : mimeType+            )         case .failed(let error):             throw error         }@@ -118,78 +159,96 @@ actor ImageLoader {      // MARK: - Private Loading Methods -    /// Loads an image from a local file URL.-    private func loadLocalFile(_ url: URL) throws(ImageLoadError) -> PlatformImage {-        let data: Data+    /// Reads a local image file within the per-image byte cap.+    ///+    /// `Data(contentsOf:)` would commit the whole file first and leave the cap to+    /// be discovered afterwards — the same shape as the SVG bug in T-1867, and+    /// the reason a local raster could spend arbitrary memory before anything+    /// looked at it.+    private func readLocalFile(_ url: URL) throws(ImageLoadError) -> Data {         do {-            data = try Data(contentsOf: url)-        } catch let error as NSError where error.domain == NSCocoaErrorDomain-            && error.code == NSFileReadNoPermissionError {-            throw .sandboxRestricted-        } catch {-            throw .fileNotFound+            return try BoundedFileRead.read(+                contentsOf: url, limit: ImageMemoryGuard.maxEncodedImageBytes+            )+        } catch let failure {+            switch failure {+            case .noPermission: throw .sandboxRestricted+            case .tooLarge: throw .tooLarge+            case .unreadable: throw .fileNotFound+            }         }-        return try decodeImageData(data)     } -    /// Maximum size for remote image downloads (50 MB).-    private static let maxRemoteImageSize = 50 * 1024 * 1024--    /// Loads an image from a remote URL with concurrency limiting.-    private func loadRemote(_ url: URL) async throws(ImageLoadError) -> PlatformImage {+    /// Reads a local file within the byte cap and hands the body to `materialize`,+    /// holding a read permit for the whole of it.+    ///+    /// The permit is the local twin of the network permit below, and exists for+    /// the same reason: it bounds how many compressed bodies can be resident at+    /// once. `BoundedFileRead` caps one file at 50 MB, but WebKit's per-page+    /// request fan-out puts no ceiling on how many local images it asks for+    /// simultaneously, so a per-file cap alone still multiplies.+    private func withLocalRead<T>(+        _ url: URL, materialize: (Data) async throws -> T+    ) async throws(ImageLoadError) -> T {         do {-            try await networkSemaphore.acquire()+            try await readSemaphore.acquire()         } catch {             throw .networkError("Cancelled")         }--        let data: Data         do {-            data = try await performRemoteLoad(url)-        } catch {-            await networkSemaphore.release()+            let data = try readLocalFile(url)+            let result = try await materialize(data)+            await readSemaphore.release()+            return result+        } catch let error as ImageLoadError {+            await readSemaphore.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 {+            await readSemaphore.release()             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()+    }++    /// Downloads a remote image within the streaming cap and hands the body to+    /// `materialize`, holding the network permit until materialisation finishes.+    ///+    /// Holding it that long is the fix for T-2151's first stage. Previously the+    /// permit was dropped the moment the download completed and the body then+    /// queued on a decode gate, still holding up to 50 MB, with nothing bounding+    /// how many such bodies could pile up behind that gate. Now a downloaded body+    /// exists only while its owner holds a permit, so the backlog is bounded by+    /// the network limit.+    ///+    /// The cost is deliberate backpressure: when the memory budget is saturated,+    /// new fetches stall behind the images already being materialised. That is+    /// what bounding memory means — the previous ordering chose fetch throughput+    /// and therefore bounded nothing. It is also much cheaper than it sounds: an+    /// image admitted whole is now served without any native decode at all, so+    /// only genuinely oversized images ever hold a permit through pixel work.+    private func withRemoteBody<T>(+        _ url: URL, materialize: (Data) async throws -> T+    ) async throws(ImageLoadError) -> T {+        do {+            try await networkSemaphore.acquire()+        } catch {             throw .networkError("Cancelled")         }-        let image: PlatformImage+         do {-            image = try decodeImageData(data)-        } catch {-            await decodeSemaphore.release()+            let data = try await performRemoteLoad(url)+            // The request may have been cancelled during the download — don't+            // spend a reservation, or a decode, on a dead request.+            try Task.checkCancellation()+            let result = try await materialize(data)+            await networkSemaphore.release()+            return result+        } catch let error as ImageLoadError {+            await networkSemaphore.release()             throw error+        } catch {+            await networkSemaphore.release()+            throw .networkError("Cancelled")         }-        await decodeSemaphore.release()-        return image     }      /// Performs the actual remote image fetch with validation, returning the@@ -237,7 +296,7 @@ actor ImageLoader {         let declaredLength = (response as? HTTPURLResponse)             .flatMap { $0.value(forHTTPHeaderField: "Content-Length") }             .flatMap(Int.init)-        if let declaredLength, declaredLength > Self.maxRemoteImageSize {+        if let declaredLength, declaredLength > ImageMemoryGuard.maxEncodedImageBytes {             let sizeMB = declaredLength / 1_024 / 1_024             throw ImageLoadError.networkError(                 String(localized: "error.image.tooLarge \(sizeMB.formatted())")@@ -245,11 +304,11 @@ actor ImageLoader {         }          var data = Data()-        data.reserveCapacity(min(declaredLength ?? 65_536, Self.maxRemoteImageSize))+        data.reserveCapacity(min(declaredLength ?? 65_536, ImageMemoryGuard.maxEncodedImageBytes))          for try await byte in bytes {             data.append(byte)-            if data.count > Self.maxRemoteImageSize {+            if data.count > ImageMemoryGuard.maxEncodedImageBytes {                 let sizeMB = data.count / 1_024 / 1_024                 throw ImageLoadError.networkError(                     String(localized: "error.image.tooLarge \(sizeMB.formatted())")@@ -260,188 +319,6 @@ 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 source = CGImageSourceCreateWithData(data as CFData, nil) else {-            throw .decodeFailed-        }-        // 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-    } }  // MARK: - Environment Key
prism/Services/WebRendering/BlockHTMLEmitter.swift Modified +61 / -3
diff --git a/prism/Services/WebRendering/BlockHTMLEmitter.swift b/prism/Services/WebRendering/BlockHTMLEmitter.swiftindex 5e539b20..2af0e800 100644--- a/prism/Services/WebRendering/BlockHTMLEmitter.swift+++ b/prism/Services/WebRendering/BlockHTMLEmitter.swift@@ -995,12 +995,40 @@ nonisolated enum BlockHTMLEmitter {     // MARK: - Image src rewriting (Req 8.3)      /// Rewrites an image reference to `prism-doc://img/?src={encoded original}` so the-    /// scheme handler mediates every read. `data:` URIs pass through unchanged (Req 3.3):-    /// they carry their own bytes and need no mediation, and `img-src data:` permits them.+    /// scheme handler mediates every read.+    ///+    /// A `data:` URI still passes through as itself when — and only when — the memory+    /// guard admits it whole. This is the emit-time half of T-2149: the document CSP+    /// permits `img-src data:`, so an inline payload never touches the scheme handler+    /// and WebKit decodes it directly. That made every inline raster exempt from the+    /// decoded-size budget, and `maxDataURIBytes` is no substitute — a solid-colour PNG+    /// declaring 20,000x20,000 compresses to well under a megabyte.+    ///+    /// The emitter is the last place native can act, so admission happens here: a+    /// payload the guard will not admit whole is replaced with the scheme handler's+    /// refusal address, and the page renders the standard image-error placeholder+    /// (whose title is catalog-localised, carried on the `<img>` already).+    ///+    /// Refusing rather than downsampling is deliberate for this one path, and it does+    /// leave an asymmetry: the byte-identical image written as a file reference is+    /// downsampled and shown. The reason is memory, not convenience. Downsampling an+    /// inline payload would mean decoding and re-encoding it during emit — which runs+    /// per parse revision, off the main actor, and is meant to be a pure, cheap+    /// function of the blocks — and then re-inlining the result as base64 *into the+    /// document HTML*, where it is retained for the whole session. That spends more+    /// memory, for longer, than serving nothing. A file reference has somewhere else+    /// to put the downsampled bytes: the scheme handler produces them per request and+    /// they are never part of the document.+    ///+    /// The band where the two differ is narrow, and narrower than it was: an inline+    /// animation is now admitted on frame 0's declared size like any other page image,+    /// so what remains is a single frame that declares more than the retained budget.     nonisolated static func rewriteImageSrc(_ original: String) -> String {         let trimmed = original.trimmingCharacters(in: .whitespacesAndNewlines)         if trimmed.lowercased().hasPrefix("data:") {-            return original+            return admitsInlinePayload(trimmed)+                ? original+                : PrismDocSchemeHandler.blockedImageURLString         }         var components = URLComponents()         components.scheme = "prism-doc"@@ -1013,6 +1041,36 @@ nonisolated enum BlockHTMLEmitter {                 withAllowedCharacters: .alphanumerics) ?? "")     } +    /// Whether an inline `data:` payload may be handed to WebKit as-is.+    ///+    /// SVG is judged on bytes, because an SVG's cost is not derivable from a header;+    /// a raster is judged on what its header declares it decodes to, for the+    /// ``ImageMemoryGuard/Consumer/page`` consumer — WebKit is the decoder here, so an+    /// animation is admitted on frame 0's size rather than on the sum over frames that+    /// only `NSImage(data:)` would pay. Anything ImageIO does not recognise is refused:+    /// it cannot be sized, and an unsizable payload is exactly what this guard exists+    /// to stop. The residual is a format WebKit decodes but ImageIO does not — such an+    /// image now shows the error placeholder instead of rendering. Erring that way is+    /// the only direction that fails closed.+    nonisolated private static func admitsInlinePayload(_ dataURI: String) -> Bool {+        // Resolution is context-free for a data URI: no base URL or source type is+        // consulted, so passing placeholders here cannot change the parse.+        guard case .dataURI(let payload, let mimeType) = ImagePathResolver.resolve(+            source: dataURI, baseURL: nil, sourceType: .clipboard+        ) else {+            return false+        }+        guard payload.count <= PrismDocSchemeHandler.maxDataURIBytes else { return false }+        if mimeType.lowercased().contains("svg") {+            return payload.count <= ImageMemoryGuard.maxSVGBytes+        }+        guard let verdict = ImageMemoryGuard.verdict(+            forEncoded: payload, consumer: .page+        ) else { return false }+        if case .decodeWhole = verdict { return true }+        return false+    }+     /// Rewrites every `<img src="...">` and srcset candidate in a raw-HTML fragment     /// through the scheme handler so embedded raw-HTML images are mediated (Req 8.3).     /// Runs on the raw fragment BEFORE sanitization (see `emitHTML`): the rewritten
prism/Services/WebRendering/PrismDocSchemeHandler.swift Modified +38 / -25
diff --git a/prism/Services/WebRendering/PrismDocSchemeHandler.swift b/prism/Services/WebRendering/PrismDocSchemeHandler.swiftindex 0b1d89b2..c6f8dcc7 100644--- a/prism/Services/WebRendering/PrismDocSchemeHandler.swift+++ b/prism/Services/WebRendering/PrismDocSchemeHandler.swift@@ -88,8 +88,18 @@ struct PrismDocSchemeHandler: URLSchemeHandler {      /// Maximum decoded size for an inline `data:` image URI (matches the existing     /// image-pipeline ceiling for inline payloads, Req 8.4).+    ///+    /// A *compressed*-byte cap, and only that: it says nothing about what the+    /// payload decodes to. `ImageMemoryGuard` is what bounds the decode.     static let maxDataURIBytes = 10 * 1024 * 1024 +    /// Path component of the address an emitter points at when the memory guard+    /// refuses an inline image (see the `img` case in ``route(for:)``).+    nonisolated static let blockedImagePath = "blocked"++    /// The full refusal address, for emitters.+    nonisolated static let blockedImageURLString = "\(scheme)://img/\(blockedImagePath)"+     private static let logger = Logger.prism(category: "PrismDocScheme")      // MARK: Injected context@@ -151,6 +161,17 @@ struct PrismDocSchemeHandler: URLSchemeHandler {             return .document          case "img":+            // The emitter's refusal address. An inline `data:` raster the memory+            // guard will not admit cannot simply be left in the HTML (WebKit+            // would decode it before native saw a byte) and cannot be routed+            // through `?src=` either — a rejected payload is up to 10 MB, and+            // percent-encoding it into a query string spends more memory than+            // serving it would. Pointing at an address that always fails keeps+            // the refusal on the audited path and lets the page render its+            // standard, catalog-localised image-error placeholder.+            if segments == [blockedImagePath] {+                return .rejected(.imagePolicy)+            }             let components = URLComponents(url: url, resolvingAgainstBaseURL: false)             guard let src = components?.queryItems?.first(where: { $0.name == "src" })?.value,                   !src.isEmpty else {@@ -319,6 +340,7 @@ struct PrismDocSchemeHandler: URLSchemeHandler {                 forResource: String(parts.dropLast().joined(separator: ".")),                 withExtension: String(ext)               ),+              // prism-memory-exempt: a fixed, allowlisted app-bundle asset shipped with the binary               let data = try? Data(contentsOf: resourceURL) else {             throw URLError(.fileDoesNotExist)         }@@ -419,7 +441,15 @@ struct PrismDocSchemeHandler: URLSchemeHandler {     }      /// Loads the resolved image, routing SVG sources through the sanitizer so a-    /// malicious SVG cannot smuggle script vectors into the page (Req 8.1).+    /// malicious SVG cannot smuggle script vectors into the page (Req 8.1), and+    /// every raster through `ImageLoader.loadPageBytes` — the one admission path+    /// that bounds decoded cost.+    ///+    /// All three raster sources go through the same call now. Before T-2149 only+    /// `.remote` did: `.dataURI` returned its payload verbatim and `.localFile`+    /// was an unbounded `Data(contentsOf:)`, so both reached WebKit having passed+    /// a *compressed*-byte cap and nothing else. A limit that holds on one of+    /// three paths is not a limit.     private static func loadImageData(         _ resolved: ResolvedImageSource, source: String     ) async throws -> (Data, String) {@@ -429,35 +459,18 @@ struct PrismDocSchemeHandler: URLSchemeHandler {             return (Data(sanitized.utf8), "image/svg+xml")         } +        let fallbackMIME: String         switch resolved {-        case .dataURI(let data, let mimeType):-            return (data, mimeType.isEmpty ? "application/octet-stream" : mimeType)         case .localFile(let fileURL):-            let data = try Data(contentsOf: fileURL)-            return (data, mimeType(forExtension: fileURL.pathExtension))+            fallbackMIME = mimeType(forExtension: fileURL.pathExtension)         case .remote(let remoteURL):-            let image = try await ImageLoader().load(resolved)-            // Re-encode to a known format so the page receives image bytes, not the-            // original (already validated) network stream.-            return (try encode(image, suggestedExtension: remoteURL.pathExtension))-        case .failed(let error):-            throw error+            fallbackMIME = mimeType(forExtension: remoteURL.pathExtension)+        case .dataURI, .failed:+            fallbackMIME = "application/octet-stream"         }-    } -    private static func encode(_ image: PlatformImage, suggestedExtension: String) throws -> (Data, String) {-        #if os(macOS)-        if let tiff = image.tiffRepresentation,-           let rep = NSBitmapImageRep(data: tiff),-           let png = rep.representation(using: .png, properties: [:]) {-            return (png, "image/png")-        }-        #else-        if let png = image.pngData() {-            return (png, "image/png")-        }-        #endif-        throw URLError(.cannotDecodeContentData)+        let loaded = try await ImageLoader().loadPageBytes(resolved, fallbackMIMEType: fallbackMIME)+        return (loaded.data, loaded.mimeType)     }      private static func mimeType(forExtension ext: String) -> String {
prism/Services/SVGSourceLoader.swift Modified +46 / -10
diff --git a/prism/Services/SVGSourceLoader.swift b/prism/Services/SVGSourceLoader.swiftindex 05f36a66..29488cfc 100644--- a/prism/Services/SVGSourceLoader.swift+++ b/prism/Services/SVGSourceLoader.swift@@ -29,9 +29,15 @@ actor SVGSourceLoader {     }      private var networkSemaphore: AsyncSemaphore { SharedConcurrency.networkSemaphore }+    private var readSemaphore: AsyncSemaphore { SharedConcurrency.localReadSemaphore }      /// Maximum SVG source size in bytes (2 MB).-    static let maxSVGSize = 2 * 1024 * 1024+    ///+    /// Owned by `ImageMemoryGuard` alongside the raster caps: an SVG's cost is+    /// not derivable from a header, so its encoded byte count is the only handle+    /// there is — which makes enforcing it *before* the read the whole of the+    /// protection rather than a refinement of it (T-1867).+    static let maxSVGSize = ImageMemoryGuard.maxSVGBytes      /// Loads SVG content as a String from the given resolved source.     ///@@ -41,7 +47,7 @@ actor SVGSourceLoader {     func load(_ source: ResolvedImageSource) async throws(ImageLoadError) -> String {         switch source {         case .localFile(let url):-            return try loadLocalFile(url)+            return try await loadLocalFile(url)         case .remote(let url):             return try await loadRemote(url)         case .dataURI(let data, _):@@ -53,17 +59,47 @@ actor SVGSourceLoader {      // MARK: - Private Loading Methods -    private func loadLocalFile(_ url: URL) throws(ImageLoadError) -> String {-        let data: Data+    /// Reads a local SVG *within* the 2 MB cap (T-1867).+    ///+    /// This used to be `Data(contentsOf:)` followed by `validateAndDecode`, whose+    /// `data.count > maxSVGSize` check ran one whole allocation too late: a+    /// 2 GB local SVG was read in full and only then refused, which is the+    /// freeze the ticket describes. `BoundedFileRead` preflights the size and+    /// still aborts mid-read, so a file that grows between the two is cut off.+    ///+    /// The read permit is held for the read and the UTF-8 decode, bounding how+    /// many SVG bodies can be resident at once — a per-file cap alone does not.+    private func loadLocalFile(_ url: URL) async throws(ImageLoadError) -> String {+        do {+            try await readSemaphore.acquire()+        } catch {+            throw .networkError("Cancelled")+        }         do {-            data = try Data(contentsOf: url)-        } catch let error as NSError where error.domain == NSCocoaErrorDomain-            && error.code == NSFileReadNoPermissionError {-            throw .sandboxRestricted+            let data = try readBounded(url)+            let content = try decode(data)+            await readSemaphore.release()+            return content         } catch {-            throw .fileNotFound+            await readSemaphore.release()+            throw error+        }+    }++    private func readBounded(_ url: URL) throws(ImageLoadError) -> Data {+        do {+            return try BoundedFileRead.read(contentsOf: url, limit: Self.maxSVGSize)+        } catch let failure {+            switch failure {+            case .noPermission:+                throw .sandboxRestricted+            case .unreadable:+                throw .fileNotFound+            case .tooLarge(let byteCount, _):+                let sizeMB = byteCount / 1_024 / 1_024+                throw .svgRenderFailed(String(localized: "error.svg.tooLarge \(sizeMB.formatted())"))+            }         }-        return try validateAndDecode(data)     }      private func loadRemote(_ url: URL) async throws(ImageLoadError) -> String {
prism/Services/SessionFileManager.swift Modified +27 / -4
diff --git a/prism/Services/SessionFileManager.swift b/prism/Services/SessionFileManager.swiftindex 80352da0..afdd634f 100644--- a/prism/Services/SessionFileManager.swift+++ b/prism/Services/SessionFileManager.swift@@ -96,16 +96,39 @@ enum SessionFileManager {         }     } -    /// Reads content from file for a session.+    /// Reads content from file for a session, within the document size limit.+    ///+    /// The read is bounded (T-2132 review). This file holds a *document body* —+    /// the same content `MarkdownDocument` refuses past `maxFileSize`, restored+    /// unattended at scene restoration — so reading it with `String(contentsOf:)`+    /// was the T-1867 shape one subsystem over: the limit that governs this+    /// content on the way in was not applied on the way back out, and a session+    /// file that had grown (or been replaced) on disk was materialised whole+    /// before anyone could look at its size.     ///     /// - Parameter sessionID: The unique identifier for the session.-    /// - Returns: The stored content, or nil if no file exists.+    /// - Returns: The stored content, or nil if no file exists, it exceeds the+    ///   document size limit, or it is not valid UTF-8.     static func readContent(for sessionID: UUID) -> String? {         let url = directory.appendingPathComponent("\(sessionID.uuidString).md")         do {-            return try String(contentsOf: url, encoding: .utf8)+            let data = try BoundedFileRead.read(+                contentsOf: url, limit: MarkdownDocument.maxFileSize+            )+            guard let content = String(data: data, encoding: .utf8) else {+                logger.warning(+                    "Session \(sessionID.uuidString, privacy: .public) is not valid UTF-8"+                )+                return nil+            }+            return content         } catch {-            if !isFileNotFound(error) {+            // `BoundedFileRead.Failure` reports "could not open or read" without+            // saying why, and "no session was ever written" is the overwhelmingly+            // common reason — so the existence check keeps the log as quiet as the+            // `isFileNotFound` filter it replaces, without going silent on a+            // genuine read failure.+            if FileManager.default.fileExists(atPath: url.path) {                 logger.warning(                     "Failed to read session \(sessionID.uuidString, privacy: .public): \(String(describing: error), privacy: .public)"                 )
prism/Services/SharedConcurrency.swift Modified +11 / -12
diff --git a/prism/Services/SharedConcurrency.swift b/prism/Services/SharedConcurrency.swiftindex 58e10d59..424399e1 100644--- a/prism/Services/SharedConcurrency.swift+++ b/prism/Services/SharedConcurrency.swift@@ -14,19 +14,18 @@ enum SharedConcurrency {     /// Used by ImageLoader and SVGSourceLoader.     nonisolated static let networkSemaphore = AsyncSemaphore(limit: 6) -    /// Limits concurrent guarded image decodes to 4 (T-2132).+    /// Limits concurrent local image/SVG file reads to 4.     ///-    /// 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)+    /// The local twin of `networkSemaphore`, and it exists for the same reason:+    /// a *per-file* cap (50 MB for a raster, 2 MB for SVG) bounds one read, not+    /// the sum of them, and WebKit's per-page request fan-out puts no ceiling on+    /// how many local images it asks for at once. A permit is held for as long as+    /// the compressed body is resident, so at most four such bodies exist.+    ///+    /// The *decode* bound is no longer a slot count: it is+    /// `ImageMemoryGuard.budget`, weighted in bytes. Four slots of "an image"+    /// bounded nothing when one image can cost 250 MB and the next 8 MB (T-2151).+    nonisolated static let localReadSemaphore = AsyncSemaphore(limit: 4) }  /// Validates HTTP redirects for remote image/SVG fetches, rejecting targets
prism/Services/WebViewPool.swift Modified +17 / -0
diff --git a/prism/Services/WebViewPool.swift b/prism/Services/WebViewPool.swiftindex fc126cec..d4cae5b2 100644--- a/prism/Services/WebViewPool.swift+++ b/prism/Services/WebViewPool.swift@@ -241,6 +241,22 @@ final class WebViewPool: NSObject, WKNavigationDelegate {     /// `width * displayScale`, then re-wrap the captured image so its logical     /// size in points is `width` and its scale is `displayScale` (T-917).     ///+    /// ## Memory+    ///+    /// This allocates a bitmap, and it is the one bitmap allocation in Prism that+    /// `ImageMemoryGuard` does not see. It has no encoded bytes and no header to+    /// read a size from — WebKit rasterises a rendered page — so the guard's+    /// header-derived admission has nothing to act on. Worst case is the caller's+    /// frame clamp times the display scale: `SVGRenderer` clamps to 2000 points+    /// square and a 3x display makes that 6000x6000 pixels, ~144 MB, held only+    /// until `SnapshotCache` takes the (much smaller, view-sized) result.+    ///+    /// Two bounds apply instead of the byte budget: the owning pool's slot count,+    /// which caps how many snapshots are in flight, and that frame clamp. Neither+    /// is a byte budget, and this comment exists so the difference is not+    /// mistaken for one — the claim `ImageMemoryGuard` makes is over *decoding+    /// encoded image bytes*, not over every bitmap in the app.+    ///     /// - Reqs: 9.1, 9.2, 9.3     func captureSnapshot(         of webView: WKWebView,@@ -255,6 +271,7 @@ final class WebViewPool: NSObject, WKNavigationDelegate {          let raw: PlatformImage         do {+            // prism-memory-exempt: WebKit rasterises a rendered page; no encoded bytes, no header to size it from — see the Memory note above             raw = try await webView.takeSnapshot(configuration: config)         } catch {             throw WebViewPoolError.snapshotFailed(error.localizedDescription)
prism/App/MarkdownDocument.swift Modified +23 / -0
diff --git a/prism/App/MarkdownDocument.swift b/prism/App/MarkdownDocument.swiftindex f6aa2e73..b5366a3f 100644--- a/prism/App/MarkdownDocument.swift+++ b/prism/App/MarkdownDocument.swift@@ -56,6 +56,29 @@ struct MarkdownDocument: FileDocument {         self.content = content     } +    /// Creates a markdown document by reading `url` *within* the 10 MB limit.+    ///+    /// The obvious spelling — `MarkdownDocument(data: try Data(contentsOf: url))` —+    /// is the same defect as T-1867 in a different subsystem: `init(data:)` refuses+    /// an oversized file only after the read has already committed its memory, so a+    /// 2 GB file is read whole and then rejected. `BoundedFileRead` preflights the+    /// size and still aborts mid-read, covering a file that grows in between.+    ///+    /// - Parameter url: The file to read.+    /// - Throws: `DocumentError` if the file cannot be read, is too large, or is not UTF-8.+    init(contentsOf url: URL) throws {+        let data: Data+        do {+            data = try BoundedFileRead.read(contentsOf: url, limit: Self.maxFileSize)+        } catch let failure {+            switch failure {+            case .tooLarge: throw DocumentError.fileTooLarge+            case .noPermission, .unreadable: throw DocumentError.fileNotReadable+            }+        }+        try self.init(data: data)+    }+     /// Creates a markdown document from file data.     ///     /// - Parameter configuration: The read configuration containing the file data.
prism/Services/ImagePathResolver.swift Modified +10 / -0
diff --git a/prism/Services/ImagePathResolver.swift b/prism/Services/ImagePathResolver.swiftindex d12f06c7..3c3851d1 100644--- a/prism/Services/ImagePathResolver.swift+++ b/prism/Services/ImagePathResolver.swift@@ -75,6 +75,13 @@ enum ImageLoadError: LocalizedError, Equatable, Sendable {     /// The downloaded data could not be decoded as an image.     case decodeFailed +    /// The image exceeds Prism's memory limits and was refused *before* the+    /// memory was committed — either its encoded payload is past the per-image+    /// byte cap, or the size its header declares is past `ImageMemoryGuard`'s+    /// decode ceiling. Distinct from `.decodeFailed`: the bytes may be a+    /// perfectly valid image, just not one Prism will materialise.+    case tooLarge+     /// User-facing error description for display in the UI.     var displayMessage: String {         switch self {@@ -105,6 +112,9 @@ enum ImageLoadError: LocalizedError, Equatable, Sendable {         case .decodeFailed:             return String(localized: "error.image.decodeFailed",                           defaultValue: "Could not decode image data.")+        case .tooLarge:+            return String(localized: "error.image.tooLargeToDisplay",+                          defaultValue: "Image too large to display safely.")         }     } 
prism/Models/DocumentSession.swift Modified +1 / -2
diff --git a/prism/Models/DocumentSession.swift b/prism/Models/DocumentSession.swiftindex d02cac51..e49218ff 100644--- a/prism/Models/DocumentSession.swift+++ b/prism/Models/DocumentSession.swift@@ -537,8 +537,7 @@ final class DocumentSession: Identifiable {     /// - Parameter url: The file URL to reload from.     /// - Throws: If the file cannot be read or is not valid markdown.     func reloadContent(from url: URL) async throws {-        let data = try Data(contentsOf: url)-        let newDocument = try MarkdownDocument(data: data)+        let newDocument = try MarkdownDocument(contentsOf: url)          // Reset expansion state only after successful I/O to avoid         // losing state if the file read or validation fails.
prism/ViewModels/DocumentFlowCoordinator.swift Modified +3 / -6
diff --git a/prism/ViewModels/DocumentFlowCoordinator.swift b/prism/ViewModels/DocumentFlowCoordinator.swiftindex ca5ac3a9..db8a7e38 100644--- a/prism/ViewModels/DocumentFlowCoordinator.swift+++ b/prism/ViewModels/DocumentFlowCoordinator.swift@@ -375,8 +375,7 @@ final class DocumentFlowCoordinator {         }          do {-            let data = try Data(contentsOf: url)-            let document = try MarkdownDocument(data: data)+            let document = try MarkdownDocument(contentsOf: url)              let session = DocumentSession(url: url, content: document.content)             session.pendingFragment = fragment@@ -411,8 +410,7 @@ final class DocumentFlowCoordinator {          do {             try recentFilesManager.withRecentFile(entry) { url in-                let data = try Data(contentsOf: url)-                let document = try MarkdownDocument(data: data)+                let document = try MarkdownDocument(contentsOf: url)                  let session = DocumentSession(url: url, content: document.content)                 activateSession(session) {@@ -594,8 +592,7 @@ final class DocumentFlowCoordinator {         }          do {-            let data = try Data(contentsOf: url)-            let document = try MarkdownDocument(data: data)+            let document = try MarkdownDocument(contentsOf: url)              let session = DocumentSession(bundledResource: resourceName, content: document.content)             activateSession(session) {
prism/ViewModels/WebDocumentControllerFactory.swift Modified +1 / -0
diff --git a/prism/ViewModels/WebDocumentControllerFactory.swift b/prism/ViewModels/WebDocumentControllerFactory.swiftindex 05631e03..174bac8d 100644--- a/prism/ViewModels/WebDocumentControllerFactory.swift+++ b/prism/ViewModels/WebDocumentControllerFactory.swift@@ -565,6 +565,7 @@ enum WebDocumentControllerFactory {      private static func loadAsset(_ name: String, _ ext: String) -> String? {         guard let url = Bundle.main.url(forResource: name, withExtension: ext),+              // prism-memory-exempt: a fixed WebRenderer asset shipped in the app bundle, of known size               let source = try? String(contentsOf: url, encoding: .utf8) else {             logger.error("Missing bundled WebRenderer asset: \(name).\(ext)")             return nil
prism/Services/NotesStore.swift Modified +2 / -0
diff --git a/prism/Services/NotesStore.swift b/prism/Services/NotesStore.swiftindex ce7c211a..d3db4180 100644--- a/prism/Services/NotesStore.swift+++ b/prism/Services/NotesStore.swift@@ -103,6 +103,7 @@ actor NotesStore: NotesStoreProtocol {     private func loadFromCurrentPath(url: URL, identifier: DocumentIdentifier) -> DocumentNotes? {         let data: Data         do {+            // prism-memory-exempt: app-owned notes JSON in the container, written only by Prism; no size cap is defined for it             data = try Data(contentsOf: url)         } catch {             if !isFileNotFound(error) {@@ -143,6 +144,7 @@ actor NotesStore: NotesStoreProtocol {          let data: Data         do {+            // prism-memory-exempt: app-owned notes JSON at the legacy path, same provenance as the current one             data = try Data(contentsOf: legacyURL)         } catch {             if !isFileNotFound(error) {
prism/Services/NotesBackupStore.swift Modified +1 / -0
diff --git a/prism/Services/NotesBackupStore.swift b/prism/Services/NotesBackupStore.swiftindex 88f4052e..4bd8a2a0 100644--- a/prism/Services/NotesBackupStore.swift+++ b/prism/Services/NotesBackupStore.swift@@ -142,6 +142,7 @@ actor NotesBackupStore: NotesBackupStoreProtocol {     private func decodeBackup(at url: URL) -> DocumentNotes? {         let data: Data         do {+            // prism-memory-exempt: app-owned notes backup written by Prism itself; no size cap is defined for it             data = try Data(contentsOf: url)         } catch {             if !isBackupFileNotFound(error) {
prism/Resources/WebRenderer/prism-media.js Modified +7 / -0
diff --git a/prism/Resources/WebRenderer/prism-media.js b/prism/Resources/WebRenderer/prism-media.jsindex 9ecc7a6d..da669fcb 100644--- a/prism/Resources/WebRenderer/prism-media.js+++ b/prism/Resources/WebRenderer/prism-media.js@@ -165,11 +165,18 @@      // True when the image's ORIGINAL source (the prism-doc://img/?src= payload) is a local     // path — granting folder access can only fix local images, not remote/data ones.+    //+    // An ABSENT src= payload is not a local path. It used to be read as one: the missing+    // parameter came back as "", and "" is neither http nor data. The address the emitter+    // points at when the memory guard refuses an inline image (prism-doc://img/blocked)+    // carries no payload at all, so every refused inline image offered the user a folder+    // picker for a picture that lives in the markdown and has no folder to grant.     function isLocalImage(img) {         var src = img.getAttribute("src") || "";         try {             var url = new URL(src, document.baseURI);             var original = url.searchParams.get("src") || "";+            if (!original) { return false; }             return !/^https?:/i.test(original) && !/^data:/i.test(original);         } catch (error) {             return false;
prismTests/ImageMemoryGuardTests.swift Added +1036 / -0
diff --git a/prismTests/ImageMemoryGuardTests.swift b/prismTests/ImageMemoryGuardTests.swiftnew file mode 100644index 00000000..b5b3acfe--- /dev/null+++ b/prismTests/ImageMemoryGuardTests.swift@@ -0,0 +1,1036 @@+//+//  ImageMemoryGuardTests.swift+//  prismTests+//+//  T-2132 / T-2149 / T-2151 / T-1867: one defect class — a resource limit checked+//  only after the memory it was meant to bound has already been committed.+//++import Compression+import CoreGraphics+import Foundation+import ImageIO+import Testing+import UniformTypeIdentifiers+@testable import prism++// MARK: - Fixtures++/// Builders for images that are small on disk and enormous decoded.+///+/// The point of this file is that a fixture must be able to *declare* a size+/// nobody can afford to allocate. `CGImageDestination` cannot help: encoding+/// needs a real `CGImage`, so a 20,000x20,000 fixture would cost the test the+/// hundreds of megabytes the guard exists to refuse.+///+/// So the PNG is assembled by hand. A PNG's pixel stream is a filter byte plus+/// the raw samples per row, and a stream of zeroes deflates at roughly 1000:1 —+/// streamed through `Compression`, never held. The result is a genuine,+/// ImageIO-readable decompression bomb: a few hundred KB of file declaring+/// hundreds of megabytes, built in about a second.+///+/// The colour type is a parameter, not a detail. It is what the header declares+/// the pixel *depth* to be, and the guard's arithmetic reads that rather than+/// assuming four bytes per pixel — so a fixture that is greyscale where the test+/// means RGBA lands in a different admission tier. It is also how the 16-bit+/// fixture, which exists purely to pin the depth-aware estimate, is built.+enum ImageBombFixtures {++    /// The PNG colour types these fixtures use, with the number of samples each+    /// stores per pixel (which sets the size of the raw stream to deflate).+    enum ColorType: UInt8 {+        case greyscale = 0+        case rgba = 6++        var samplesPerPixel: Int {+            switch self {+            case .greyscale: return 1+            case .rgba: return 4+            }+        }+    }++    // MARK: PNG assembly++    private static let crcTable: [UInt32] = {+        var table = [UInt32](repeating: 0, count: 256)+        for index in 0..<256 {+            var value = UInt32(index)+            for _ in 0..<8 {+                value = (value & 1) != 0 ? (0xEDB8_8320 ^ (value >> 1)) : (value >> 1)+            }+            table[index] = value+        }+        return table+    }()++    private static func crc32(_ bytes: [UInt8]) -> UInt32 {+        var crc: UInt32 = 0xFFFF_FFFF+        for byte in bytes {+            crc = crcTable[Int((crc ^ UInt32(byte)) & 0xFF)] ^ (crc >> 8)+        }+        return crc ^ 0xFFFF_FFFF+    }++    private static func bigEndian(_ value: UInt32) -> [UInt8] {+        [UInt8(value >> 24 & 0xFF), UInt8(value >> 16 & 0xFF), UInt8(value >> 8 & 0xFF), UInt8(value & 0xFF)]+    }++    private static func chunk(_ type: String, _ payload: [UInt8]) -> [UInt8] {+        let tag = Array(type.utf8)+        return bigEndian(UInt32(payload.count)) + tag + payload + bigEndian(crc32(tag + payload))+    }++    /// Raw DEFLATE of `count` zero bytes, produced without ever holding them.+    private static func deflateZeros(_ count: Int) -> [UInt8] {+        var stream = compression_stream(+            dst_ptr: UnsafeMutablePointer<UInt8>(bitPattern: 1)!, dst_size: 0,+            src_ptr: UnsafePointer<UInt8>(bitPattern: 1)!, src_size: 0, state: nil+        )+        guard compression_stream_init(&stream, COMPRESSION_STREAM_ENCODE, COMPRESSION_ZLIB)+            == COMPRESSION_STATUS_OK else {+            fatalError("Could not start the deflate stream")+        }+        defer { compression_stream_destroy(&stream) }++        let sourceChunk = 1 << 20+        let zeros = [UInt8](repeating: 0, count: sourceChunk)+        let destinationSize = 1 << 16+        let destination = UnsafeMutablePointer<UInt8>.allocate(capacity: destinationSize)+        defer { destination.deallocate() }++        var output = [UInt8]()+        var remaining = count+        var finished = false+        zeros.withUnsafeBufferPointer { source in+            while !finished {+                let take = min(remaining, sourceChunk)+                stream.src_ptr = source.baseAddress!+                stream.src_size = take+                remaining -= take+                let flags = remaining == 0 ? Int32(COMPRESSION_STREAM_FINALIZE.rawValue) : 0+                while true {+                    stream.dst_ptr = destination+                    stream.dst_size = destinationSize+                    let status = compression_stream_process(&stream, flags)+                    output.append(+                        contentsOf: UnsafeBufferPointer(+                            start: destination, count: destinationSize - stream.dst_size+                        )+                    )+                    if status == COMPRESSION_STATUS_END {+                        finished = true+                        break+                    }+                    precondition(status == COMPRESSION_STATUS_OK, "deflate failed")+                    if stream.src_size == 0, stream.dst_size > 0 { break }+                }+            }+        }+        return output+    }++    /// A valid PNG of the given declared size and colour type, filled with zeroes.+    ///+    /// `Compression`'s `COMPRESSION_ZLIB` emits raw DEFLATE, so the zlib wrapper+    /// PNG requires is added here: the fixed 0x78 0x01 header, and an Adler-32+    /// that has a closed form over an all-zero stream (`a` stays 1, `b` counts+    /// the bytes).+    static func solidPNG(+        width: Int, height: Int, colorType: ColorType = .greyscale, bitDepth: UInt8 = 8+    ) -> Data {+        let bytesPerSample = Int(bitDepth) / 8+        let rawByteCount = (width * colorType.samplesPerPixel * bytesPerSample + 1) * height+        let adler = UInt32(rawByteCount % 65_521) << 16 | 1+        let zlibStream: [UInt8] = [0x78, 0x01] + deflateZeros(rawByteCount) + bigEndian(adler)++        var bytes: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]+        bytes += chunk(+            "IHDR",+            bigEndian(UInt32(width)) + bigEndian(UInt32(height))+                + [bitDepth, colorType.rawValue, 0, 0, 0]+        )+        bytes += chunk("IDAT", zlibStream)+        bytes += chunk("IEND", [])+        return Data(bytes)+    }++    /// Built once per process — a `var` rebuilt the ~1s deflate on every access,+    /// and several suites reach for it.+    ///+    /// Declared size past the admission ceiling: 20,000 x 20,000 greyscale+    /// decodes to ~400 MB. This is the shape the T-2132 reopen named — a PNG,+    /// which has no subsampled decode, so sending it to a thumbnail request is+    /// not a bound.+    ///+    /// It is 400 MB rather than the 1.6 GB the first round of this work claimed,+    /// and the difference is the finding rather than a rounding: 1.6 GB was+    /// `20,000 x 20,000 x 4` applied to a fixture that is one byte per pixel. The+    /// arithmetic now reads the depth from the header for the fixture as much as+    /// for a real image.+    static let overCeilingPNG = solidPNG(width: 20_000, height: 20_000)++    /// Declared size past the retained budget but inside the ceiling: 9,000 x+    /// 9,000 greyscale decodes to ~81 MB, which is affordable transiently — the+    /// tests that decode it really do allocate that, so it sits just over the+    /// 64 MB retained ceiling rather than far over it.+    static let overRetainedBudgetPNG = solidPNG(width: 9_000, height: 9_000)++    /// An ordinary image, comfortably inside every limit.+    static let ordinaryPNG = solidPNG(width: 240, height: 160)++    /// 4096 x 4096 of 16-bit RGBA: exactly ``ImageMemoryGuard/maxDecodedDimension``+    /// on both axes, so a budget that assumes four bytes per pixel computes+    /// exactly ``ImageMemoryGuard/maxRetainedDecodedBytes`` and admits it whole.+    ///+    /// It really decodes to 8 bytes per pixel — 128 MB, twice the ceiling. This+    /// fixture exists to make that discrepancy an assertion instead of a comment.+    static let deep16BitPNG = solidPNG(+        width: 4_096, height: 4_096, colorType: .rgba, bitDepth: 16+    )++    /// Writes `data` to a throwaway file and returns its URL. Caller removes it.+    static func temporaryFile(_ data: Data, extension ext: String = "png") throws -> URL {+        let url = FileManager.default.temporaryDirectory+            .appendingPathComponent("prism-fixture-\(UUID().uuidString).\(ext)")+        try data.write(to: url)+        return url+    }+}++// MARK: - Fixture sanity++@Suite("Image bomb fixtures")+struct ImageBombFixtureTests {++    @Test("The over-ceiling PNG is small on disk and enormous decoded")+    func bombFixtureHasTheRightShape() throws {+        let data = ImageBombFixtures.overCeilingPNG+        // If this ever stops holding, every refusal test below is testing+        // nothing, so it is asserted rather than assumed.+        #expect(data.count < 2_000_000, "fixture should be tiny on the wire: \(data.count) bytes")++        let source = try #require(CGImageSourceCreateWithData(data as CFData, nil))+        let properties = try #require(+            CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]+        )+        #expect(properties[kCGImagePropertyPixelWidth] as? Int == 20_000)+        #expect(properties[kCGImagePropertyPixelHeight] as? Int == 20_000)++        let declared = try #require(+            ImageMemoryGuard.declaredDecodedBytes(+                width: 20_000, height: 20_000, frameCount: 1, bytesPerPixel: 1+            )+        )+        #expect(+            declared > ImageMemoryGuard.maxAdmissibleDecodedBytes,+            "the fixture must declare more than the admission ceiling"+        )+        #expect(+            data.count * 1_000 < declared,+            "compressed:decoded ratio should be extreme — that is the whole bug"+        )+    }++    /// The 16-bit fixture is the one that makes the depth-aware estimate+    /// testable, so its shape is asserted for the same reason as the bomb's: if+    /// ImageIO stopped reporting it as 16-bit RGBA, the test that pins the+    /// arithmetic would silently start pinning the old, wrong arithmetic.+    @Test("The 16-bit fixture really declares 16-bit RGBA")+    func deepFixtureHasTheRightShape() throws {+        let source = try #require(+            CGImageSourceCreateWithData(ImageBombFixtures.deep16BitPNG as CFData, nil)+        )+        let properties = try #require(+            CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]+        )+        #expect(properties[kCGImagePropertyPixelWidth] as? Int == ImageMemoryGuard.maxDecodedDimension)+        #expect(properties[kCGImagePropertyPixelHeight] as? Int == ImageMemoryGuard.maxDecodedDimension)+        #expect(properties[kCGImagePropertyDepth] as? Int == 16)+        #expect(properties[kCGImagePropertyColorModel] as? String == "RGB")+        #expect(properties[kCGImagePropertyHasAlpha] as? Bool == true)+    }+}++// MARK: - Budget arithmetic (pure)++@Suite("ImageMemoryGuard budget arithmetic")+struct ImageMemoryGuardArithmeticTests {++    private static let rgba8 = ImageMemoryGuard.referenceBytesPerPixel++    @Test("An ordinary image is within the retained budget")+    func withinBudget() {+        #expect(+            ImageMemoryGuard.exceedsRetainedBudget(+                width: 1_920, height: 1_080, frameCount: 1, bytesPerPixel: Self.rgba8+            ) == false+        )+    }++    @Test("Dimensions exactly at the budget are still within it")+    func exactlyAtBudget() {+        #expect(+            ImageMemoryGuard.exceedsRetainedBudget(+                width: ImageMemoryGuard.maxDecodedDimension,+                height: ImageMemoryGuard.maxDecodedDimension,+                frameCount: 1,+                bytesPerPixel: Self.rgba8+            ) == false+        )+    }++    @Test("One pixel past the max dimension exceeds the budget, on either axis")+    func onePixelPastMaxDimension() {+        #expect(+            ImageMemoryGuard.exceedsRetainedBudget(+                width: ImageMemoryGuard.maxDecodedDimension + 1, height: 100,+                frameCount: 1, bytesPerPixel: Self.rgba8+            )+        )+        #expect(+            ImageMemoryGuard.exceedsRetainedBudget(+                width: 100, height: ImageMemoryGuard.maxDecodedDimension + 1,+                frameCount: 1, bytesPerPixel: Self.rgba8+            )+        )+    }++    @Test("Frame count is part of the sum")+    func frameCountCounts() {+        // Each 2000x2000 frame is well inside the per-frame budget; fifty of+        // them are not, and NSImage(data:) would materialise all fifty.+        #expect(+            ImageMemoryGuard.exceedsRetainedBudget(+                width: 2_000, height: 2_000, frameCount: 50, bytesPerPixel: Self.rgba8+            )+        )+        #expect(+            ImageMemoryGuard.exceedsRetainedBudget(+                width: 2_000, height: 2_000, frameCount: 1, bytesPerPixel: Self.rgba8+            ) == false+        )+    }++    @Test("Non-positive dimensions are unsafe, not free")+    func nonPositiveDimensions() {+        #expect(+            ImageMemoryGuard.declaredDecodedBytes(+                width: 0, height: 100, frameCount: 1, bytesPerPixel: Self.rgba8+            ) == nil+        )+        #expect(+            ImageMemoryGuard.declaredDecodedBytes(+                width: 100, height: 0, frameCount: 1, bytesPerPixel: Self.rgba8+            ) == nil+        )+        #expect(+            ImageMemoryGuard.declaredDecodedBytes(+                width: -1, height: 100, frameCount: 1, bytesPerPixel: Self.rgba8+            ) == nil+        )+        #expect(+            ImageMemoryGuard.exceedsRetainedBudget(+                width: 0, height: 100, frameCount: 1, bytesPerPixel: Self.rgba8+            )+        )+    }++    /// A header is attacker-controlled data. Dimensions whose product overflows+    /// `Int` must not wrap into a small, admissible number.+    @Test("Dimensions that overflow the byte product are refused, not wrapped")+    func overflowingDimensions() {+        #expect(+            ImageMemoryGuard.declaredDecodedBytes(+                width: Int.max, height: Int.max, frameCount: 1, bytesPerPixel: Self.rgba8+            ) == nil+        )+        #expect(+            ImageMemoryGuard.declaredDecodedBytes(+                width: 1 << 40, height: 1 << 40, frameCount: 1, bytesPerPixel: Self.rgba8+            ) == nil+        )+        #expect(+            ImageMemoryGuard.exceedsRetainedBudget(+                width: Int.max, height: 2, frameCount: 1, bytesPerPixel: Self.rgba8+            )+        )+    }++    // MARK: Per-pixel size++    /// The arithmetic that used to be a hardcoded 4.+    ///+    /// Every expected value here was read back off a real decode of that format+    /// (`CGImage.bytesPerRow / width`), not reasoned from the format specs — the+    /// two disagree in both directions and only one of them is what gets+    /// allocated.+    @Test("Bytes per pixel follow the declared depth and colour model")+    func bytesPerPixelFollowsTheHeader() {+        // 8-bit RGBA: the reference case.+        #expect(ImageMemoryGuard.bytesPerPixel(depth: 8, colorModel: "RGB", hasAlpha: true) == 4)+        // Opaque RGB still costs four: CoreGraphics has no packed 24-bit format,+        // so an opaque JPEG decodes at 32 bits per pixel with a skip byte.+        #expect(ImageMemoryGuard.bytesPerPixel(depth: 8, colorModel: "RGB", hasAlpha: false) == 4)+        // The headline cases: a hardcoded 4 under-counts these by 2x and 4x.+        #expect(ImageMemoryGuard.bytesPerPixel(depth: 16, colorModel: "RGB", hasAlpha: true) == 8)+        #expect(ImageMemoryGuard.bytesPerPixel(depth: 32, colorModel: "RGB", hasAlpha: true) == 16)+        // Grey costs one, and flooring at 4 "to be safe" would refuse affordable+        // images fourfold too eagerly.+        #expect(ImageMemoryGuard.bytesPerPixel(depth: 8, colorModel: "Gray", hasAlpha: false) == 1)+        #expect(ImageMemoryGuard.bytesPerPixel(depth: 16, colorModel: "Gray", hasAlpha: false) == 2)+        #expect(ImageMemoryGuard.bytesPerPixel(depth: 8, colorModel: "Gray", hasAlpha: true) == 2)+        #expect(ImageMemoryGuard.bytesPerPixel(depth: 8, colorModel: "CMYK", hasAlpha: false) == 4)+    }++    /// Unsizable is a refusal, not a fallback to a guessed depth — a guessed+    /// depth is the exact defect this replaces.+    @Test("An unreadable depth or colour model is unsizable, not assumed")+    func unknownLayoutIsRefused() {+        #expect(ImageMemoryGuard.bytesPerPixel(depth: 0, colorModel: "RGB", hasAlpha: true) == nil)+        #expect(ImageMemoryGuard.bytesPerPixel(depth: -8, colorModel: "RGB", hasAlpha: true) == nil)+        #expect(ImageMemoryGuard.bytesPerPixel(depth: 8, colorModel: "Klingon", hasAlpha: true) == nil)+        #expect(ImageMemoryGuard.bytesPerPixel(depth: 8, colorModel: "", hasAlpha: false) == nil)+    }++    /// The retained ceiling is stated in BYTES, so the pixel edge that keeps it+    /// true has to shrink as the pixel grows. A thumbnail preserves the source's+    /// bit depth, so scaling a 16-bit image to 4096 px would retain 128 MB.+    @Test("The retained dimension cap shrinks as the pixel grows")+    func retainedDimensionCapIsDepthAware() {+        let cap = ImageMemoryGuard.retainedDimensionCap+        #expect(cap(ImageMemoryGuard.referenceBytesPerPixel) == ImageMemoryGuard.maxDecodedDimension)+        for bytesPerPixel in [1, 2, 4, 8, 16] {+            let edge = cap(bytesPerPixel)+            #expect(+                edge * edge * bytesPerPixel <= ImageMemoryGuard.maxRetainedDecodedBytes,+                "a square at the cap must fit the retained ceiling (\(bytesPerPixel) B/px)"+            )+            #expect(edge <= ImageMemoryGuard.maxDecodedDimension, "a cheap pixel earns no extra resolution")+        }+        #expect(cap(8) < cap(4))+        #expect(cap(16) < cap(8))+    }++    /// The under-count, stated as a number. 4096 x 4096 of 16-bit RGBA computes+    /// to exactly the retained ceiling under a hardcoded 4, and to twice it under+    /// the real depth.+    @Test("A 16-bit image costs twice what a hardcoded 4 bytes per pixel would say")+    func depthAwareEstimateIsNotTheHardcodedOne() throws {+        let edge = ImageMemoryGuard.maxDecodedDimension+        let assumed = try #require(+            ImageMemoryGuard.declaredDecodedBytes(+                width: edge, height: edge, frameCount: 1,+                bytesPerPixel: ImageMemoryGuard.referenceBytesPerPixel+            )+        )+        let actual = try #require(+            ImageMemoryGuard.declaredDecodedBytes(+                width: edge, height: edge, frameCount: 1, bytesPerPixel: 8+            )+        )+        #expect(assumed == ImageMemoryGuard.maxRetainedDecodedBytes)+        #expect(actual == 2 * assumed)+        #expect(+            ImageMemoryGuard.exceedsRetainedBudget(+                width: edge, height: edge, frameCount: 1, bytesPerPixel: 8+            ),+            "the 4-bytes-per-pixel answer would have admitted this whole"+        )+    }+}++// MARK: - Verdicts (T-2132, including the reopen)++@Suite("ImageMemoryGuard admission verdicts")+struct ImageMemoryGuardVerdictTests {++    @Test("An ordinary image is admitted whole")+    func ordinaryImageAdmittedWhole() throws {+        let verdict = try #require(+            ImageMemoryGuard.verdict(forEncoded: ImageBombFixtures.ordinaryPNG, consumer: .native)+        )+        guard case .decodeWhole(let estimate) = verdict else {+            Issue.record("expected .decodeWhole, got \(verdict)")+            return+        }+        // One byte per pixel: the fixture is 8-bit greyscale, and the estimate+        // now says so instead of assuming RGBA.+        #expect(estimate == 240 * 160)+    }++    /// The prediction and the measurement, joined up. `estimatedMemoryCost` is+    /// what `ImageCache` charges for the decoded bitmap; the header estimate is+    /// what admission charges before there is one. They are supposed to be the+    /// same number, and until this review they were not for anything but 8-bit+    /// RGBA.+    @Test("The header estimate matches what the decoded bitmap actually costs")+    func headerEstimateMatchesTheDecodedCost() async throws {+        let image = try await ImageMemoryGuard.platformImage(from: ImageBombFixtures.ordinaryPNG)+        let verdict = try #require(+            ImageMemoryGuard.verdict(forEncoded: ImageBombFixtures.ordinaryPNG, consumer: .native)+        )+        guard case .decodeWhole(let estimate) = verdict else {+            Issue.record("expected .decodeWhole, got \(verdict)")+            return+        }+        // `estimatedMemoryCost` is bytesPerRow * height, and CoreGraphics pads+        // rows, so the measured cost is at least the estimate and within a row's+        // padding of it.+        let measured = image.estimatedMemoryCost+        #expect(measured >= estimate)+        #expect(measured <= estimate + 160 * 64, "the estimate must not be off by a factor")+    }++    /// The T-2132 reopen, pinned. A 20,000x20,000 PNG must never reach+    /// `CGImageSourceCreateThumbnailAtIndex`: PNG has no subsampled decode, so+    /// ImageIO may inflate the full ~1.6 GB source bitmap on the way to a small+    /// thumbnail, and a one-at-a-time gate does not make that survivable.+    ///+    /// The guard's answer is to bound what may be *declared*, not just what is+    /// kept: past the ceiling nothing is decoded at all.+    @Test("A PNG declaring more than the admission ceiling is refused before any decode",+          arguments: [ImageMemoryGuard.Consumer.native, .page])+    func overCeilingPNGIsRefused(consumer: ImageMemoryGuard.Consumer) throws {+        let data = ImageBombFixtures.overCeilingPNG+        let verdict = try #require(ImageMemoryGuard.verdict(forEncoded: data, consumer: consumer))+        guard case .refuseOversized(let declared) = verdict else {+            Issue.record("a 400-megapixel PNG must be refused outright, not downsampled: \(verdict)")+            return+        }+        #expect(declared == 20_000 * 20_000)+        #expect(declared > ImageMemoryGuard.maxAdmissibleDecodedBytes)+        #expect(verdict.admitsDecoding == false)+        #expect(verdict.refusalError == .tooLarge)+    }++    /// The tier boundary from the other side: over the retained budget but under+    /// the ceiling still renders, bounded. Refusing everything oversized would+    /// break ordinary high-resolution photographs.+    @Test("A PNG over the retained budget but under the ceiling is downsampled, not refused",+          arguments: [ImageMemoryGuard.Consumer.native, .page])+    func overBudgetUnderCeilingIsDownsampled(consumer: ImageMemoryGuard.Consumer) throws {+        let data = ImageBombFixtures.overRetainedBudgetPNG+        let verdict = try #require(ImageMemoryGuard.verdict(forEncoded: data, consumer: consumer))+        guard case .downsampleFirstFrame(let estimate, let bytesPerPixel) = verdict else {+            Issue.record("expected .downsampleFirstFrame, got \(verdict)")+            return+        }+        #expect(estimate == 9_000 * 9_000)+        #expect(bytesPerPixel == 1)+        #expect(estimate > ImageMemoryGuard.maxRetainedDecodedBytes)+        #expect(estimate <= ImageMemoryGuard.maxAdmissibleDecodedBytes)+    }++    /// The headline arithmetic, at the verdict level. Under a hardcoded 4 bytes+    /// per pixel this image computes to exactly the retained ceiling and is+    /// admitted whole — 128 MB kept, against a documented 64 MB bound.+    @Test("A 16-bit image at the reference dimensions is not admitted whole",+          arguments: [ImageMemoryGuard.Consumer.native, .page])+    func deepImageIsNotAdmittedWhole(consumer: ImageMemoryGuard.Consumer) throws {+        let verdict = try #require(+            ImageMemoryGuard.verdict(forEncoded: ImageBombFixtures.deep16BitPNG, consumer: consumer)+        )+        guard case .downsampleFirstFrame(let estimate, let bytesPerPixel) = verdict else {+            Issue.record("a 16-bit 4096-square image declares 128 MB, not 64: \(verdict)")+            return+        }+        #expect(bytesPerPixel == 8)+        #expect(estimate == 2 * ImageMemoryGuard.maxRetainedDecodedBytes)+    }++    /// Arbitrary bytes do not get a `nil` verdict, which is worth pinning because+    /// it is the opposite of what the API reads like: `CGImageSourceCreateWithData`+    /// hands back a source for anything at all and only reports zero frames, so+    /// junk arrives as ``ImageMemoryGuard/Verdict/refuseUnsizable`` rather than as+    /// "not an image". `nil` is reserved for the cases where even that fails.+    ///+    /// What matters is that no route through the guard admits it.+    @Test("Bytes ImageIO cannot make sense of are never admitted")+    func unrecognisedBytesAreNeverAdmitted() async {+        let junk = Data("not an image".utf8)+        for consumer in [ImageMemoryGuard.Consumer.native, .page] {+            let verdict = ImageMemoryGuard.verdict(forEncoded: junk, consumer: consumer)+            #expect(verdict?.admitsDecoding != true)+            #expect(verdict?.refusalError == .decodeFailed)+        }++        await #expect(throws: ImageLoadError.decodeFailed) {+            _ = try await ImageMemoryGuard.platformImage(from: junk)+        }+        await #expect(throws: ImageLoadError.decodeFailed) {+            _ = try await ImageMemoryGuard.pageBytes(from: junk, fallbackMIMEType: "image/png")+        }+    }++    /// A file recognised by type sniffing whose header reports no size is+    /// unprovable, and an unprovable image is treated as a bomb. Before this it+    /// went to the downsample path, which cannot be bounded without a declared+    /// size to bound it by.+    ///+    /// It is reported as a decode failure rather than as a size problem: "too+    /// large" would be a guess about bytes that are simply not a usable image,+    /// and that is the message this file has always shown for them.+    @Test("Recognised data with no reported dimensions is refused as a decode failure")+    func recognisedButUnsizedIsRefused() {+        let truncatedPNG = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D])+        let verdict = ImageMemoryGuard.verdict(forEncoded: truncatedPNG, consumer: .native)+        #expect(verdict == .refuseUnsizable)+        #expect(verdict?.refusalError == .decodeFailed)+    }++    /// The frame cliff, and who pays for it.+    ///+    /// `NSImage(data:)` materialises one bitmap per frame, so a native decode+    /// past ``ImageMemoryGuard/maxScannedFrames`` genuinely cannot be vouched for+    /// and falls back to a bounded still. WebKit does not work that way: it+    /// decodes an animation progressively, so charging the page for the sum over+    /// frames refused a 100x100 100-frame GIF — about 4 MB decoded — and served+    /// it as a single re-encoded still. That is what "the animation stopped+    /// animating past 64 frames" was.+    @Test("A many-frame animation is admitted whole for the page and stilled only for native")+    func manyFrameAnimationDependsOnTheConsumer() throws {+        let gif = ImageLoaderTestHelpers.makeAnimatedGIFData(+            width: 100, height: 100, frameCount: ImageMemoryGuard.maxScannedFrames + 36+        )++        let page = try #require(ImageMemoryGuard.verdict(forEncoded: gif, consumer: .page))+        guard case .decodeWhole(let estimate) = page else {+            Issue.record("a 100x100 animation must reach WebKit intact: \(page)")+            return+        }+        // Frame 0's cost, not the sum over 100 frames.+        #expect(estimate == 100 * 100 * ImageMemoryGuard.referenceBytesPerPixel)++        let native = try #require(ImageMemoryGuard.verdict(forEncoded: gif, consumer: .native))+        guard case .downsampleFirstFrame = native else {+            Issue.record("expected .downsampleFirstFrame for the eager consumer, got \(native)")+            return+        }+    }++    /// The page path is frame-0-bounded, not unbounded: an animation whose+    /// individual frames are over-budget is still refused a whole decode. The+    /// fixture is a wide strip rather than a big square so the *test* does not+    /// have to allocate what it is asserting about.+    @Test("A page animation with an over-budget first frame is still not admitted whole")+    func pageAnimationWithHugeFramesIsStillBounded() throws {+        let gif = ImageLoaderTestHelpers.makeAnimatedGIFData(+            width: ImageMemoryGuard.maxDecodedDimension + 104, height: 100, frameCount: 2+        )+        let verdict = try #require(ImageMemoryGuard.verdict(forEncoded: gif, consumer: .page))+        guard case .downsampleFirstFrame = verdict else {+            Issue.record("expected .downsampleFirstFrame, got \(verdict)")+            return+        }+    }++    @Test("Exactly maxScannedFrames frames are still scanned and admitted whole natively")+    func exactlyMaxScannedFrames() throws {+        let gif = ImageLoaderTestHelpers.makeAnimatedGIFData(+            width: 16, height: 16, frameCount: ImageMemoryGuard.maxScannedFrames+        )+        let verdict = try #require(ImageMemoryGuard.verdict(forEncoded: gif, consumer: .native))+        guard case .decodeWhole = verdict else {+            Issue.record("expected .decodeWhole, got \(verdict)")+            return+        }+    }++    /// A tiny first page must not vouch for an enormous later one — multi-page+    /// TIFF is where per-frame dimensions legitimately differ.+    ///+    /// Native only: the page consumer deliberately budgets frame 0, and a+    /// multi-page TIFF is not something WebKit renders as a document image.+    @Test("A huge page hidden behind a tiny first page is not admitted whole")+    func tinyFirstPageCannotVouchForHugeLaterPage() throws {+        let huge = ImageMemoryGuard.maxDecodedDimension + 104+        let tiff = ImageLoaderTestHelpers.makeMultiPageTIFFData(+            pageSizes: [(width: 1, height: 1), (width: huge, height: 600)]+        )+        let source = try #require(CGImageSourceCreateWithData(tiff as CFData, nil))+        #expect(CGImageSourceGetCount(source) == 2)+        let verdict = try #require(ImageMemoryGuard.verdict(forEncoded: tiff, consumer: .native))+        guard case .downsampleFirstFrame = verdict else {+            Issue.record("expected .downsampleFirstFrame, got \(verdict)")+            return+        }+    }++    /// Per-axis maxima across frames, over a real container rather than an+    /// injected lookup: one page's width pairs with another page's height, so the+    /// pair budgets as 300x400 per page.+    @Test("Every page's dimensions count, taken per axis")+    func perAxisMaximaAcrossPages() throws {+        let tiff = ImageLoaderTestHelpers.makeMultiPageTIFFData(+            pageSizes: [(width: 10, height: 400), (width: 300, height: 20)]+        )+        let verdict = try #require(ImageMemoryGuard.verdict(forEncoded: tiff, consumer: .native))+        guard case .decodeWhole(let estimate) = verdict else {+            Issue.record("expected .decodeWhole, got \(verdict)")+            return+        }+        #expect(estimate == 300 * 400 * ImageMemoryGuard.referenceBytesPerPixel * 2)+    }+}++// MARK: - Materialisation++@Suite("ImageMemoryGuard materialisation")+struct ImageMemoryGuardMaterialisationTests {++    @Test("An ordinary image decodes at full resolution")+    func ordinaryImageDecodesWhole() async throws {+        let image = try await ImageMemoryGuard.platformImage(from: ImageBombFixtures.ordinaryPNG)+        let dimensions = try #require(ImageLoaderTestHelpers.pixelDimensions(of: image))+        #expect(dimensions.width == 240)+        #expect(dimensions.height == 160)+    }++    @Test("An over-budget image decodes bounded by the retained budget")+    func overBudgetImageDecodesBounded() async throws {+        let image = try await ImageMemoryGuard.platformImage(+            from: ImageBombFixtures.overRetainedBudgetPNG+        )+        let dimensions = try #require(ImageLoaderTestHelpers.pixelDimensions(of: image))+        #expect(dimensions.width <= ImageMemoryGuard.maxDecodedDimension)+        #expect(dimensions.height <= ImageMemoryGuard.maxDecodedDimension)+        #expect(image.estimatedMemoryCost <= ImageMemoryGuard.maxRetainedDecodedBytes)+    }++    /// The retained ceiling, measured rather than asserted, for the pixel format+    /// that breaks it if the downsample edge is a flat 4096. A thumbnail keeps+    /// the source's 16 bits per component, so 4096 px of it is 128 MB.+    @Test("A downsampled 16-bit image still fits the retained ceiling")+    func deepImageDownsamplesInsideTheRetainedCeiling() async throws {+        let image = try await ImageMemoryGuard.platformImage(from: ImageBombFixtures.deep16BitPNG)+        let dimensions = try #require(ImageLoaderTestHelpers.pixelDimensions(of: image))+        #expect(+            dimensions.width < ImageMemoryGuard.maxDecodedDimension,+            "a 16-bit image must be scaled below the 4-byte-per-pixel edge, not to it"+        )+        let retained = image.estimatedMemoryCost+        #expect(+            retained <= ImageMemoryGuard.maxRetainedDecodedBytes,+            "retained \(retained) B against a stated ceiling of \(ImageMemoryGuard.maxRetainedDecodedBytes) B"+        )+    }++    @Test("An over-ceiling image is refused with the size error, not decoded")+    func overCeilingImageThrows() async {+        await #expect(throws: ImageLoadError.tooLarge) {+            _ = try await ImageMemoryGuard.platformImage(from: ImageBombFixtures.overCeilingPNG)+        }+    }++    /// Page bytes for an admitted image are the *original* bytes. Re-encoding+    /// them would spend a decode, a bitmap and an encoder buffer to arrive at+    /// bytes no safer than the ones admission already validated — which is the+    /// amplification T-2151 measured.+    @Test("An admitted page image is served verbatim, with its detected MIME type")+    func admittedPageImageServedVerbatim() async throws {+        let data = ImageBombFixtures.ordinaryPNG+        let served = try await ImageMemoryGuard.pageBytes(from: data, fallbackMIMEType: "image/jpeg")+        #expect(served.data == data)+        #expect(served.mimeType == "image/png", "MIME comes from the bytes, not the file extension")+    }++    @Test("An over-budget page image is re-encoded down to the retained budget")+    func overBudgetPageImageIsDownsampled() async throws {+        let served = try await ImageMemoryGuard.pageBytes(+            from: ImageBombFixtures.overRetainedBudgetPNG, fallbackMIMEType: "image/png"+        )+        #expect(served.mimeType == "image/png")+        let source = try #require(CGImageSourceCreateWithData(served.data as CFData, nil))+        let properties = try #require(+            CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]+        )+        let width = try #require(properties[kCGImagePropertyPixelWidth] as? Int)+        let height = try #require(properties[kCGImagePropertyPixelHeight] as? Int)+        #expect(width <= ImageMemoryGuard.maxDecodedDimension)+        #expect(height <= ImageMemoryGuard.maxDecodedDimension)+    }++    @Test("An over-ceiling page image is refused, so WebKit never receives its bytes")+    func overCeilingPageImageThrows() async {+        await #expect(throws: ImageLoadError.tooLarge) {+            _ = try await ImageMemoryGuard.pageBytes(+                from: ImageBombFixtures.overCeilingPNG, fallbackMIMEType: "image/png"+            )+        }+    }+}++// MARK: - Byte-weighted budget (T-2151)++@Suite("ImageMemoryBudget")+struct ImageMemoryBudgetTests {++    /// Suspends until `budget` has `count` requests queued.+    ///+    /// Every ordering property this actor promises is about the queue, so a test+    /// that reaches for one has to know when a request has *enqueued*. Sleeping+    /// and hoping is what made `fifoAdmission` able to fail red under load: if+    /// `big` had not enqueued within the 20 ms, `small` took the fast path and+    /// the test reported the FIFO rule broken when nothing was. Yielding until+    /// the condition holds cannot pass early and cannot pass late, however loaded+    /// the machine is.+    ///+    /// The deadline is a failure path only, never a pass path: the condition holds+    /// within microseconds when the code is right, and five seconds is unreachable+    /// except when the request will never enqueue at all. It is here because the+    /// first version of this helper spun forever in exactly that case — a mutation+    /// run that made a reservation cost zero turned a wrong answer into a hung+    /// suite instead of a red test.+    private func waitForWaiters(+        _ count: Int, on budget: ImageMemoryBudget,+        sourceLocation: SourceLocation = #_sourceLocation+    ) async {+        let deadline = ContinuousClock.now.advanced(by: .seconds(5))+        while await budget.waitingCount < count {+            guard ContinuousClock.now < deadline else {+                Issue.record(+                    "timed out waiting for \(count) queued reservation(s)",+                    sourceLocation: sourceLocation+                )+                return+            }+            await Task.yield()+        }+    }++    @Test("Reservations that fit are all admitted")+    func fittingReservationsAdmitted() async throws {+        let budget = ImageMemoryBudget(capacity: 100)+        try await budget.acquire(40)+        try await budget.acquire(40)+        #expect(await budget.reservedBytes == 80)+    }++    /// The distinction that matters: a *count* gate would admit both of these,+    /// because they are two requests. A byte gate admits one, because between+    /// them they are more memory than exists for the purpose.+    @Test("A reservation that does not fit waits for capacity")+    func oversubscribedReservationWaits() async throws {+        let budget = ImageMemoryBudget(capacity: 100)+        try await budget.acquire(60)++        let second = Task { try await budget.acquire(60) }+        await waitForWaiters(1, on: budget)+        // It is queued rather than admitted: 60 is still all that is reserved,+        // which is also the statement that the two never overlapped.+        #expect(await budget.reservedBytes == 60, "the second reservation must not be admitted")+        #expect(second.isCancelled == false)++        await budget.release(60)+        try await second.value+        #expect(await budget.reservedBytes == 60)+    }++    /// FIFO, asserted on *admission* rather than on which task happens to run+    /// first once both are resumed — the budget promises the former and says+    /// nothing about the latter, and an earlier version of this test asserted+    /// the latter and flaked accordingly.+    ///+    /// The shape that matters: `small` would fit the moment `big` is queued, and+    /// must still wait for it. Without that, a stream of small images starves a+    /// large one indefinitely — the failure mode a naive "admit anything that+    /// fits" gate has.+    ///+    /// The ordering of the two enqueues is established by waiting for the queue+    /// to contain `big`, not by sleeping long enough that it probably does. That+    /// distinction is the whole difference between this test and one that goes+    /// red on a loaded machine.+    @Test("A small reservation that would fit still waits behind a queued large one")+    func fifoAdmission() async throws {+        let budget = ImageMemoryBudget(capacity: 100)+        try await budget.acquire(60)++        let big = Task { try await budget.acquire(60) }+        await waitForWaiters(1, on: budget)+        let small = Task { try await budget.acquire(30) }+        await waitForWaiters(2, on: budget)++        // 60 + 30 fits inside 100. It is queued behind `big`, so it is refused.+        #expect(+            await budget.reservedBytes == 60,+            "a queued request must not be overtaken by a later one that happens to fit"+        )++        await budget.release(60)+        try await big.value+        try await small.value+        #expect(await budget.reservedBytes == 90, "both waiters admitted, in order")+    }++    @Test("A reservation larger than the whole budget runs alone instead of deadlocking")+    func oversizedReservationIsClamped() async throws {+        let budget = ImageMemoryBudget(capacity: 100)+        try await budget.acquire(10_000)+        #expect(await budget.reservedBytes == 100)+        await budget.release(10_000)+        #expect(await budget.reservedBytes == 0)+    }++    @Test("A cancelled waiter throws and frees the queue")+    func cancelledWaiterThrows() async throws {+        let budget = ImageMemoryBudget(capacity: 100)+        try await budget.acquire(100)++        let waiter = Task { try await budget.acquire(50) }+        await waitForWaiters(1, on: budget)+        waiter.cancel()+        await #expect(throws: CancellationError.self) { try await waiter.value }++        await budget.release(100)+        #expect(await budget.reservedBytes == 0)+    }++    /// The two halves joined up: the *production* capacity and the *production*+    /// cost function, applied to a real over-budget fixture, force serialization.+    ///+    /// This is what a slot count could not express. Under the four-slot decode+    /// semaphore this replaces, four of these ran at once — four transient+    /// ~100 MB source bitmaps plus their thumbnails and encoder buffers, which is+    /// the overrun T-2151 describes.+    @Test("Two real over-budget images cannot be materialised at the same time")+    func realOverBudgetImagesSerialize() async throws {+        let verdict = try #require(+            ImageMemoryGuard.verdict(+                forEncoded: ImageBombFixtures.overRetainedBudgetPNG, consumer: .page+            )+        )+        let cost = ImageMemoryGuard.reservationCost(for: verdict)+        #expect(+            cost * 2 > ImageMemoryGuard.maxAdmissibleDecodedBytes,+            "two of these must not fit in the budget: cost \(cost)"+        )++        let budget = ImageMemoryBudget(capacity: ImageMemoryGuard.maxAdmissibleDecodedBytes)+        try await budget.acquire(cost)+        let second = Task { try await budget.acquire(cost) }+        await waitForWaiters(1, on: budget)+        // Queued, not admitted: only the first one's bytes are reserved, so the+        // two never coexist.+        #expect(await budget.reservedBytes == cost)+        await budget.release(cost)+        try await second.value+        #expect(await budget.reservedBytes == cost)+    }++    /// The re-encode is inside the reservation, and the reservation pays for it.+    /// It used to run in `PrismDocSchemeHandler` outside every bound.+    @Test("The downsample reservation includes headroom for the thumbnail and the encoder")+    func downsampleCostCoversEncoding() {+        let estimate = 100 * 1_024 * 1_024+        let cost = ImageMemoryGuard.reservationCost(+            for: .downsampleFirstFrame(estimatedBytes: estimate, bytesPerPixel: 4)+        )+        #expect(cost == estimate + 2 * ImageMemoryGuard.maxRetainedDecodedBytes)+        #expect(ImageMemoryGuard.reservationCost(for: .decodeWhole(estimatedBytes: 42)) == 42)+        #expect(ImageMemoryGuard.reservationCost(for: .refuseUnsizable) == 0)+        #expect(ImageMemoryGuard.reservationCost(for: .refuseOversized(declaredBytes: 1)) == 0)+    }+}++// MARK: - BoundedFileRead (T-1867)++@Suite("BoundedFileRead")+struct BoundedFileReadTests {++    @Test("A file within the limit reads whole")+    func readsWithinLimit() throws {+        let payload = Data(repeating: 0x41, count: 4_096)+        let url = try ImageBombFixtures.temporaryFile(payload, extension: "bin")+        defer { try? FileManager.default.removeItem(at: url) }++        let read = try BoundedFileRead.read(contentsOf: url, limit: 8_192)+        #expect(read == payload)+    }++    @Test("A file of exactly the limit reads whole")+    func readsExactlyAtLimit() throws {+        let payload = Data(repeating: 0x41, count: 1_000)+        let url = try ImageBombFixtures.temporaryFile(payload, extension: "bin")+        defer { try? FileManager.default.removeItem(at: url) }++        #expect(try BoundedFileRead.read(contentsOf: url, limit: 1_000).count == 1_000)+    }++    /// The preflight, which is what stops the memory being spent at all.+    @Test("An oversized file is refused from its directory entry, before any read")+    func oversizedFileRefusedByPreflight() throws {+        let payload = Data(repeating: 0x41, count: 10_000)+        let url = try ImageBombFixtures.temporaryFile(payload, extension: "bin")+        defer { try? FileManager.default.removeItem(at: url) }++        #expect(BoundedFileRead.reportedSize(of: url) == 10_000)+        #expect(throws: BoundedFileRead.Failure.tooLarge(byteCount: 10_000, limit: 1_000)) {+            _ = try BoundedFileRead.read(contentsOf: url, limit: 1_000)+        }+    }++    @Test("A missing file is unreadable, not empty")+    func missingFileIsUnreadable() {+        let url = FileManager.default.temporaryDirectory+            .appendingPathComponent("prism-missing-\(UUID().uuidString).bin")+        #expect(throws: BoundedFileRead.Failure.unreadable) {+            _ = try BoundedFileRead.read(contentsOf: url, limit: 1_000)+        }+    }++    /// The second guard, driven directly.+    ///+    /// `reportedSize` is a snapshot, and a file can grow between the preflight+    /// and the read — a race the ticket calls out explicitly. The preflight+    /// short-circuits almost every oversized file, so it is the only thing the+    /// whole-file entry point can be observed doing; this drives the chunked+    /// loop against an open handle so the running-total abort is exercised on+    /// its own.+    @Test("A read that outgrows its preflight is cut off within one chunk")+    func growingFileIsCutOffMidRead() throws {+        // Three chunks, so the read genuinely loops.+        let payload = Data(repeating: 0x41, count: BoundedFileRead.chunkSize * 3)+        let url = try ImageBombFixtures.temporaryFile(payload, extension: "bin")+        defer { try? FileManager.default.removeItem(at: url) }++        let handle = try FileHandle(forReadingFrom: url)+        defer { try? handle.close() }++        // The preflight is told the file is small — the stale answer the race+        // produces. Only the running total can stop the read now.+        let limit = BoundedFileRead.chunkSize + 1+        do {+            _ = try BoundedFileRead.read(from: handle, limit: limit, expecting: 1_024)+            Issue.record("expected the read to be refused")+        } catch let failure {+            guard case .tooLarge(let byteCount, _) = failure else {+                Issue.record("expected .tooLarge, got \(failure)")+                return+            }+            #expect(+                byteCount <= limit + BoundedFileRead.chunkSize,+                "the read must abort within one chunk of the limit, not buffer the file: \(byteCount)"+            )+            #expect(byteCount < payload.count, "the whole file must never be buffered")+        }+    }++    @Test("A handle whose contents fit the limit reads whole")+    func handleWithinLimitReadsWhole() throws {+        let payload = Data(repeating: 0x42, count: BoundedFileRead.chunkSize + 7)+        let url = try ImageBombFixtures.temporaryFile(payload, extension: "bin")+        defer { try? FileManager.default.removeItem(at: url) }++        let handle = try FileHandle(forReadingFrom: url)+        defer { try? handle.close() }+        #expect(try BoundedFileRead.read(from: handle, limit: payload.count) == payload)+    }++    @Test("A zero-byte file reads as empty rather than failing")+    func emptyFileReadsEmpty() throws {+        let url = try ImageBombFixtures.temporaryFile(Data(), extension: "bin")+        defer { try? FileManager.default.removeItem(at: url) }+        #expect(try BoundedFileRead.read(contentsOf: url, limit: 1_000).isEmpty)+    }+}
prismTests/Support/ProductionSourceScan.swift Added +376 / -0
diff --git a/prismTests/Support/ProductionSourceScan.swift b/prismTests/Support/ProductionSourceScan.swiftnew file mode 100644index 00000000..ccd78926--- /dev/null+++ b/prismTests/Support/ProductionSourceScan.swift@@ -0,0 +1,376 @@+//+//  ProductionSourceScan.swift+//  prismTests+//+//  The machinery behind the "chokepoint adoption" scans (T-2140 for URLs, T-2132+//  for image memory), factored out of the two suites that were carrying a copy+//  each.+//++import Foundation+import Testing++/// Reads production Swift source line by line and classifies each line against a+/// set of rules, honouring per-line exemption markers.+///+/// ## Why this is shared+///+/// There are two of these scans, and there will be more: each one guards a+/// chokepoint that a previous ticket had to invent because the same defect kept+/// reappearing at a new call site. They differ only in their rules, their marker+/// token, and which files are allowed to break them.+///+/// They were duplicated, and the duplication cost something concrete rather than+/// hypothetical. `URLChokepointAdoptionTests` learned — from a real miss — that a+/// call written as `.init(string:)` is the same call as `URL(string:)` and slips+/// past a scan that looks only for the spelled-out type name. It fixed that for+/// itself. The image scan, copied from it afterwards, looked for+/// `PlatformImage(data:` and friends and was blind to `.init(data:` all over+/// again. A lesson learned in one copy does not reach the other; with one+/// implementation it does.+///+/// ## What it cannot see+///+/// A textual scan cannot follow a call through a helper, an alias, or a framework+/// entry point nobody thought to list. It narrows the gap; it does not make a+/// chokepoint a proof. Each suite documents its own residual.+enum ProductionSourceScan {++    // MARK: - Logical lines++    /// One line of source, plus whatever its unclosed parentheses pull in from+    /// the lines below it.+    ///+    /// Load-bearing, not tidiness. Both scans look for tokens that Swift is free+    /// to wrap: `Data(\n    contentsOf: url\n)` contains `Data(contentsOf` on no+    /// single line, and `RecentFileEntry.swift` and `DirectoryAccessManager.swift`+    /// already wrap `URL(...)` initialisers that way. Whitespace is collapsed so+    /// indentation and line breaks cannot separate tokens the rules expect to+    /// find adjacent.+    struct LogicalLine {+        /// Line and continuations, comments stripped, whitespace collapsed.+        let text: String+        /// How many characters of ``text`` the line itself contributed. A match+        /// beginning past this started in a continuation, and belongs to the line+        /// it started on — where the exemption marker would go.+        let ownCount: Int++        /// True when a match begins in this line rather than in a continuation.+        ///+        /// Every line gets its own logical line, so a call nested inside another+        /// call is reachable from both. Without this, the enclosing `foo(` would+        /// be reported for the violation two lines down, the finding would be+        /// listed twice, and the marker would have to sit above a line that is+        /// not the one doing the work.+        func startsHere(_ range: Range<String.Index>) -> Bool {+            text.distance(from: text.startIndex, to: range.lowerBound) < ownCount+        }++        /// True when `token` appears and starts in this line.+        func containsStartingHere(_ token: String) -> Bool {+            text.range(of: token).map(startsHere) ?? false+        }++        /// The first match of `expression` that starts in this line.+        func firstMatchStartingHere(_ expression: NSRegularExpression) -> NSTextCheckingResult? {+            let full = NSRange(text.startIndex..., in: text)+            guard let match = expression.firstMatch(in: text, range: full),+                  let range = Range(match.range, in: text),+                  startsHere(range) else {+                return nil+            }+            return match+        }+    }++    /// How many following lines a call may spill into: `URL(`, `string: x`, `)`+    /// is three, and the spare covers an argument that itself wraps.+    static let continuationLimit = 3++    /// Collapses whitespace runs so indentation and line breaks cannot separate+    /// tokens the rules expect to find adjacent.+    static func collapseWhitespace(_ line: String) -> String {+        line.split(whereSeparator: \.isWhitespace).joined(separator: " ")+    }++    /// `(` minus `)`, ignoring anything inside a string literal — a literal+    /// `"a("` must not make a balanced line look like it continues.+    static func unclosedParens(in line: String) -> Int {+        var depth = 0+        var inQuote = false+        var escaped = false+        for character in line {+            if escaped {+                escaped = false+            } else if character == "\\" {+                escaped = true+            } else if character == "\"" {+                inQuote.toggle()+            } else if !inQuote, character == "(" {+                depth += 1+            } else if !inQuote, character == ")" {+                depth -= 1+            }+        }+        return depth+    }++    /// Builds the logical line starting at `index`. `lines` must already be+    /// comment-stripped, so prose in a trailing comment cannot be joined into the+    /// code below it.+    static func logicalLine(at index: Int, in lines: [String]) -> LogicalLine {+        let own = collapseWhitespace(lines[index])+        var text = own+        var depth = unclosedParens(in: lines[index])+        var next = index + 1+        while depth > 0, next < lines.count, next - index <= continuationLimit {+            let continuation = collapseWhitespace(lines[next])+            if !continuation.isEmpty {+                text += text.isEmpty ? continuation : " " + continuation+            }+            depth += unclosedParens(in: lines[next])+            next += 1+        }+        return LogicalLine(text: text, ownCount: own.count)+    }++    /// Strips `//` line comments so prose *about* the banned APIs — of which this+    /// codebase has a great deal — is not mistaken for a call.+    ///+    /// Quote-aware, because `"prism-doc://img/?src=" + x.addingPercentEncoding(…)`+    /// is a real line in `BlockHTMLEmitter` and a naive cut at the first `//`+    /// truncated it before the violation, hiding it from the scan entirely. Lines+    /// inside a `"""` literal are treated as code; that direction only ever+    /// over-reports, and it never fires in practice.+    static func stripComment(_ line: String) -> String {+        var inQuote = false+        var escaped = false+        var index = line.startIndex+        while index < line.endIndex {+            let character = line[index]+            if escaped {+                escaped = false+            } else if character == "\\" {+                escaped = true+            } else if character == "\"" {+                inQuote.toggle()+            } else if character == "/", !inQuote {+                let next = line.index(after: index)+                if next < line.endIndex, line[next] == "/" {+                    return String(line[..<index])+                }+            }+            index = line.index(after: index)+        }+        return line+    }++    // MARK: - Rules and findings++    /// One banned shape, and how to spot it in a logical line of code.+    struct Rule {+        let name: String+        let matches: (LogicalLine) -> Bool++        init(name: String, matches: @escaping (LogicalLine) -> Bool) {+            self.name = name+            self.matches = matches+        }++        /// A rule satisfied by any of `tokens` appearing in the line, unless the+        /// line also matches one of `exclusions`.+        ///+        /// Tokens are matched tolerantly — see ``expression(forToken:)``.+        init(name: String, tokens: [String], excluding exclusions: [String] = []) {+            let banned = tokens.map(ProductionSourceScan.expression(forToken:))+            let allowed = exclusions.map(ProductionSourceScan.expression(forToken:))+            self.init(name: name) { line in+                guard banned.contains(where: { line.firstMatchStartingHere($0) != nil }) else {+                    return false+                }+                return !allowed.contains { line.firstMatchStartingHere($0) != nil }+            }+        }+    }++    /// A token, made tolerant of the whitespace Swift allows inside a call.+    ///+    /// Load-bearing, and it was missing. The logical-line machinery exists so a+    /// call wrapped across lines is still visible, but joining the continuation+    /// leaves a space: `Data(\n    contentsOf: url\n)` collapses to+    /// `Data( contentsOf: url )`, which does not contain the literal token+    /// `Data(contentsOf`. The URL scan never had this hole because its rules were+    /// regexes with `\s*` in them from the outset; the image scan was written with+    /// plain tokens and inherited the multi-line handling *without* the whitespace+    /// tolerance that makes it work — so it read a wrapped call as clean while its+    /// own comment claimed otherwise.+    ///+    /// Whitespace is allowed after `(` and around `:`, which covers every spelling+    /// Swift's formatter and a hand-wrapped call produce.+    static func expression(forToken token: String) -> NSRegularExpression {+        var pattern = ""+        for character in token {+            if character == ":" { pattern += #"\s*"# }+            pattern += NSRegularExpression.escapedPattern(for: String(character))+            if character == "(" { pattern += #"\s*"# }+        }+        // swiftlint:disable:next force_try+        return try! NSRegularExpression(pattern: pattern)+    }++    struct Finding: CustomStringConvertible {+        let file: String+        let line: Int+        let text: String+        let rule: String++        var description: String { "\(file):\(line) [\(rule)] \(text)" }+    }++    /// One production line, already classified.+    struct ScannedLine {+        let file: String+        let number: Int+        let text: String+        /// The rule this line trips, if any.+        let rule: String?+        /// The reason written after an exemption marker on this line, if any.+        let reason: String?+        /// True when an exemption applies — its own marker, or a marker on the+        /// line directly above that is not itself a violation.+        let isExcused: Bool+        /// True when this line carries a marker that is doing real work.+        let markerIsLive: Bool++        var finding: Finding {+            Finding(+                file: file, line: number,+                text: text.trimmingCharacters(in: .whitespaces), rule: rule ?? ""+            )+        }+    }++    // MARK: - The scan++    /// Reads and classifies every line of production Swift under `root`.+    ///+    /// - Parameters:+    ///   - root: The `prism/` production source directory.+    ///   - skipping: Basenames the rules do not apply to — the chokepoint files+    ///     themselves. A suite that skips files owes a companion test asserting+    ///     they still do the job that earns the skip.+    ///   - markerToken: The per-line exemption token, e.g. `prism-url-exempt:`.+    ///   - rules: The banned shapes, in reporting priority order.+    static func scan(+        root: URL, skipping: Set<String> = [], markerToken: String, rules: [Rule]+    ) -> [ScannedLine] {+        guard let files = swiftFiles(under: root) else { return [] }++        return files.flatMap { file -> [ScannedLine] in+            let name = file.lastPathComponent+            guard !skipping.contains(name),+                  let contents = try? String(contentsOf: file, encoding: .utf8) else {+                return []+            }+            let raw = contents.components(separatedBy: .newlines)+            let stripped = raw.map(stripComment)+            let matched = stripped.indices.map { index -> String? in+                let logical = logicalLine(at: index, in: stripped)+                return rules.first { $0.matches(logical) }?.name+            }+            let reasons = raw.map { exemptionReason(on: $0, markerToken: markerToken) }++            return raw.indices.map { index in+                let ownMarker = reasons[index].map { !$0.isEmpty } ?? false+                let markerAbove = index > 0+                    && (reasons[index - 1].map { !$0.isEmpty } ?? false)+                    && matched[index - 1] == nil+                let coversLineBelow = index + 1 < raw.count+                    && matched[index + 1] != nil+                    && matched[index] == nil+                return ScannedLine(+                    file: name,+                    number: index + 1,+                    text: raw[index],+                    rule: matched[index],+                    reason: reasons[index],+                    isExcused: ownMarker || markerAbove,+                    markerIsLive: ownMarker && (matched[index] != nil || coversLineBelow)+                )+            }+        }+    }++    /// The reason written after an exemption marker on `line`, or nil if absent.+    static func exemptionReason(on line: String, markerToken: String) -> String? {+        guard let range = line.range(of: markerToken) else { return nil }+        return String(line[range.upperBound...]).trimmingCharacters(in: .whitespaces)+    }++    /// Unexcused violations.+    static func violations(in lines: [ScannedLine]) -> [Finding] {+        lines.filter { $0.rule != nil && !$0.isExcused }.map(\.finding)+    }++    /// Markers that no longer cover a line the scan flags.+    ///+    /// A stale marker is worse than none: it is an exemption nobody will re-read,+    /// sitting where nothing needs one — and the next edit under it inherits it+    /// silently. Also catches a bare marker with no reason.+    static func staleMarkers(in lines: [ScannedLine]) -> [Finding] {+        lines.filter { $0.reason != nil && !$0.markerIsLive }.map { line in+            Finding(+                file: line.file, line: line.number,+                text: line.text.trimmingCharacters(in: .whitespaces),+                rule: (line.reason?.isEmpty ?? true)+                    ? "marker with no reason" : "marker covers no violation"+            )+        }+    }++    // MARK: - Source location++    /// The `prism/` production source directory, found by walking up from this+    /// file and confirmed by `sentinel` (a path relative to `prism/` that must+    /// exist). Compile-time path, so it resolves on the machine that built the+    /// test bundle — which is also the machine that runs it.+    static func productionSourceRoot(sentinel: String, from filePath: String = #filePath) -> URL? {+        var directory = URL(fileURLWithPath: filePath).deletingLastPathComponent()+        for _ in 0..<6 {+            let candidate = directory.appendingPathComponent("prism", isDirectory: true)+            var isDirectory: ObjCBool = false+            if FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDirectory),+               isDirectory.boolValue,+               FileManager.default.fileExists(+                   atPath: candidate.appendingPathComponent(sentinel).path+               ) {+                return candidate+            }+            directory = directory.deletingLastPathComponent()+        }+        return nil+    }++    static func swiftFiles(under root: URL) -> [URL]? {+        guard let enumerator = FileManager.default.enumerator(+            at: root, includingPropertiesForKeys: [.isRegularFileKey]+        ) else {+            return nil+        }+        return enumerator.compactMap { $0 as? URL }.filter { $0.pathExtension == "swift" }+    }++    /// Locates the source root and scans it, failing the calling test when the+    /// tree cannot be found or looks implausibly small.+    static func scanProduction(+        sentinel: String, skipping: Set<String> = [], markerToken: String, rules: [Rule]+    ) throws -> [ScannedLine] {+        let root = try #require(+            productionSourceRoot(sentinel: sentinel),+            "Could not locate the prism/ source tree from #filePath; the scan cannot run."+        )+        let files = try #require(swiftFiles(under: root))+        #expect(files.count > 50, "Source scan found suspiciously few files (\(files.count))")+        return scan(root: root, skipping: skipping, markerToken: markerToken, rules: rules)+    }+}
prismTests/ImageMemoryPipelineTests.swift Added +337 / -0
diff --git a/prismTests/ImageMemoryPipelineTests.swift b/prismTests/ImageMemoryPipelineTests.swiftnew file mode 100644index 00000000..c0f16058--- /dev/null+++ b/prismTests/ImageMemoryPipelineTests.swift@@ -0,0 +1,337 @@+//+//  ImageMemoryPipelineTests.swift+//  prismTests+//+//  T-2149 / T-1867: the guard reaches every path, not just the remote one.+//+//  `ImageMemoryGuardTests` proves the admission decision is right. These prove+//  the paths that used to skip it now go through it: the page's local and inline+//  rasters, the emitter's `data:` passthrough, and the local SVG read.+//++import Foundation+import ImageIO+import Testing+@testable import prism++// MARK: - Page raster paths (T-2149)++@Suite("Rendered-page raster admission")+struct PageRasterAdmissionTests {++    // MARK: data: URIs++    /// The inline path. The document CSP allows `img-src data:`, so an inline+    /// payload never reaches the scheme handler and WebKit decodes it directly;+    /// `maxDataURIBytes` bounds only the compressed bytes, which for this fixture+    /// is under a megabyte against 1.6 GB decoded.+    @Test("An inline data: bomb is refused rather than handed over as bytes")+    func inlineDataURIBombRefused() async {+        let source = ResolvedImageSource.dataURI(+            ImageBombFixtures.overCeilingPNG, mimeType: "image/png"+        )+        await #expect(throws: ImageLoadError.tooLarge) {+            _ = try await ImageLoader().loadPageBytes(source, fallbackMIMEType: "image/png")+        }+    }++    @Test("An ordinary inline data: image is served verbatim")+    func inlineDataURIOrdinaryServed() async throws {+        let payload = ImageBombFixtures.ordinaryPNG+        let served = try await ImageLoader().loadPageBytes(+            .dataURI(payload, mimeType: "image/png"), fallbackMIMEType: "image/png"+        )+        #expect(served.data == payload)+        #expect(served.mimeType == "image/png")+    }++    /// The regression the consumer split fixes, at the level the user sees it.+    ///+    /// A 100x100 100-frame GIF is about 4 MB decoded. Charging the page for the+    /// eager all-frame model refused it past 64 frames, re-encoded frame 0, and+    /// served a still PNG — an animation that stopped animating, for no memory+    /// reason. Serving the bytes verbatim is what keeps it playing.+    @Test("A long animation is served to the page verbatim, so it still animates")+    func longAnimationServedVerbatim() async throws {+        let gif = ImageLoaderTestHelpers.makeAnimatedGIFData(+            width: 100, height: 100, frameCount: ImageMemoryGuard.maxScannedFrames + 36+        )+        let served = try await ImageLoader().loadPageBytes(+            .dataURI(gif, mimeType: "image/gif"), fallbackMIMEType: "image/gif"+        )+        #expect(served.data == gif, "the animation must reach WebKit unchanged")+        #expect(served.mimeType == "image/gif", "not re-encoded to a still PNG")+    }++    /// The same payload inline: the emitter must let it through too, or the+    /// animation is refused before it ever reaches a loader.+    @Test("A long animation written inline is admitted by the emitter")+    func longInlineAnimationIsAdmitted() {+        let gif = ImageLoaderTestHelpers.makeAnimatedGIFData(+            width: 100, height: 100, frameCount: ImageMemoryGuard.maxScannedFrames + 36+        )+        let uri = "data:image/gif;base64," + gif.base64EncodedString()+        #expect(BlockHTMLEmitter.rewriteImageSrc(uri) == uri)+    }++    // MARK: Local files++    /// The local path. `PrismDocSchemeHandler` used to read these with a bare+    /// `Data(contentsOf:)` and hand the bytes straight to WebKit — no byte cap,+    /// no header check, no budget.+    @Test("A local raster bomb is refused before its bytes reach the page")+    func localRasterBombRefused() async throws {+        let url = try ImageBombFixtures.temporaryFile(ImageBombFixtures.overCeilingPNG)+        defer { try? FileManager.default.removeItem(at: url) }++        await #expect(throws: ImageLoadError.tooLarge) {+            _ = try await ImageLoader().loadPageBytes(.localFile(url), fallbackMIMEType: "image/png")+        }+    }++    @Test("An ordinary local raster is served verbatim")+    func localRasterOrdinaryServed() async throws {+        let payload = ImageBombFixtures.ordinaryPNG+        let url = try ImageBombFixtures.temporaryFile(payload)+        defer { try? FileManager.default.removeItem(at: url) }++        let served = try await ImageLoader().loadPageBytes(.localFile(url), fallbackMIMEType: "image/png")+        #expect(served.data == payload)+        #expect(served.mimeType == "image/png")+    }++    @Test("An over-budget local raster is downsampled rather than refused")+    func localRasterOverBudgetDownsampled() async throws {+        let url = try ImageBombFixtures.temporaryFile(ImageBombFixtures.overRetainedBudgetPNG)+        defer { try? FileManager.default.removeItem(at: url) }++        let served = try await ImageLoader().loadPageBytes(.localFile(url), fallbackMIMEType: "image/png")+        let source = try #require(CGImageSourceCreateWithData(served.data as CFData, nil))+        let properties = try #require(+            CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]+        )+        #expect(try #require(properties[kCGImagePropertyPixelWidth] as? Int)+            <= ImageMemoryGuard.maxDecodedDimension)+        #expect(served.data.count < ImageBombFixtures.overRetainedBudgetPNG.count * 10)+    }++    /// The encoded-byte cap, enforced from the directory entry rather than after+    /// the read (the T-1867 shape, applied to rasters).+    @Test("A local raster past the encoded byte cap is refused without being buffered")+    func localRasterPastEncodedCapRefused() async throws {+        // A file that is merely large, not a bomb: it must be refused on bytes.+        let url = FileManager.default.temporaryDirectory+            .appendingPathComponent("prism-huge-\(UUID().uuidString).png")+        defer { try? FileManager.default.removeItem(at: url) }+        try Data().write(to: url)+        let handle = try FileHandle(forWritingTo: url)+        try handle.truncate(atOffset: UInt64(ImageMemoryGuard.maxEncodedImageBytes + 1))+        try handle.close()++        #expect(+            BoundedFileRead.reportedSize(of: url) == ImageMemoryGuard.maxEncodedImageBytes + 1,+            "the fixture must be a sparse file, not 50 MB of written bytes"+        )+        await #expect(throws: ImageLoadError.tooLarge) {+            _ = try await ImageLoader().loadPageBytes(.localFile(url), fallbackMIMEType: "image/png")+        }+    }+}++// MARK: - Emitter inline admission (T-2149)++@Suite("Emitter data: URI admission")+struct EmitterInlineAdmissionTests {++    /// The classic 1x1 transparent GIF: a real, decodable image well inside+    /// every limit.+    private static let tinyGIF =+        "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"++    private static func bombDataURI() -> String {+        "data:image/png;base64," + ImageBombFixtures.overCeilingPNG.base64EncodedString()+    }++    @Test("An admissible data: URI still passes through unchanged")+    func admissiblePassesThrough() {+        #expect(BlockHTMLEmitter.rewriteImageSrc(Self.tinyGIF) == Self.tinyGIF)+    }++    @Test("An inline bomb is replaced with the refusal address")+    func inlineBombIsBlocked() {+        let rewritten = BlockHTMLEmitter.rewriteImageSrc(Self.bombDataURI())+        #expect(rewritten == PrismDocSchemeHandler.blockedImageURLString)+    }++    @Test("The emitted document carries no trace of a refused inline payload")+    func emittedDocumentDropsRefusedPayload() {+        let bomb = Self.bombDataURI()+        let document = BlockHTMLEmitter.emit(+            blocks: [.image(source: bomb, alt: "bomb")],+            footnotes: .empty, settings: RenderSettings()+        )+        #expect(!document.html.contains("data:image/png;base64,"))+        #expect(document.html.contains(PrismDocSchemeHandler.blockedImageURLString))+        // The error placeholder the page renders on a failed load is still wired+        // up, so the refusal is visible rather than silent.+        #expect(document.html.contains("data-prism-error-title="))+    }++    @Test("A raw-HTML inline bomb is blocked on the same policy")+    func rawHTMLInlineBombIsBlocked() {+        let raw = "<img src=\"\(Self.bombDataURI())\" alt=\"x\">"+        let rewritten = HTMLImageSourceRewriter.rewrite(raw, using: BlockHTMLEmitter.rewriteImageSrc)+        #expect(!rewritten.contains("base64,"))+        #expect(rewritten.contains(PrismDocSchemeHandler.blockedImageURLString))+    }++    /// An inline `data:` SVG bypassed the 2 MB SVG cap for the same reason a+    /// raster bypassed the decode budget: it never reached a loader.+    @Test("An inline SVG data: URI past the SVG cap is blocked")+    func oversizedInlineSVGIsBlocked() {+        let payload = "<svg xmlns=\"http://www.w3.org/2000/svg\">"+            + String(repeating: "<g/>", count: ImageMemoryGuard.maxSVGBytes / 4)+            + "</svg>"+        let uri = "data:image/svg+xml;base64," + Data(payload.utf8).base64EncodedString()+        #expect(BlockHTMLEmitter.rewriteImageSrc(uri) == PrismDocSchemeHandler.blockedImageURLString)+    }++    @Test("A small inline SVG data: URI still passes through")+    func smallInlineSVGPassesThrough() {+        let uri = "data:image/svg+xml;base64," + Data("<svg/>".utf8).base64EncodedString()+        #expect(BlockHTMLEmitter.rewriteImageSrc(uri) == uri)+    }++    @Test("The refusal address routes to a rejection, so the page gets an error not bytes")+    func refusalAddressIsRejected() throws {+        let url = try #require(URL(string: PrismDocSchemeHandler.blockedImageURLString))+        #expect(PrismDocSchemeHandler.route(for: url) == .rejected(.imagePolicy))+    }+}++// MARK: - Local SVG (T-1867)++@Suite("Local SVG size cap")+struct LocalSVGSizeCapTests {++    @Test("A local SVG within the cap loads")+    func withinCapLoads() async throws {+        let url = try ImageBombFixtures.temporaryFile(Data("<svg/>".utf8), extension: "svg")+        defer { try? FileManager.default.removeItem(at: url) }++        let content = try await SVGSourceLoader().load(.localFile(url))+        #expect(content == "<svg/>")+    }++    /// Behaviour preservation: the user-facing outcome is the same error it+    /// always was. What changed is that the file is no longer read first — a+    /// property no observable outcome distinguishes, which is why the mechanical+    /// pin for it is `ImageMaterializationChokepointTests` (an unbounded read+    /// reappearing in `SVGSourceLoader` fails the scan).+    @Test("A local SVG past the cap is refused with the size error")+    func pastCapIsRefused() async throws {+        let url = FileManager.default.temporaryDirectory+            .appendingPathComponent("prism-big-\(UUID().uuidString).svg")+        defer { try? FileManager.default.removeItem(at: url) }+        try Data().write(to: url)+        let handle = try FileHandle(forWritingTo: url)+        try handle.truncate(atOffset: UInt64(SVGSourceLoader.maxSVGSize + 1))+        try handle.close()++        do {+            _ = try await SVGSourceLoader().load(.localFile(url))+            Issue.record("expected the oversized SVG to be refused")+        } catch {+            guard case .svgRenderFailed = error else {+                Issue.record("expected .svgRenderFailed, got \(error)")+                return+            }+        }+    }++    @Test("A missing local SVG is still reported as not found")+    func missingFileIsNotFound() async {+        let url = FileManager.default.temporaryDirectory+            .appendingPathComponent("prism-missing-\(UUID().uuidString).svg")+        await #expect(throws: ImageLoadError.fileNotFound) {+            _ = try await SVGSourceLoader().load(.localFile(url))+        }+    }+}++// MARK: - Session restore (same class, different subsystem)++@Suite("SessionFileManager bounded read")+struct SessionFileManagerBoundedReadTests {++    /// The unsaved-document body Prism restores at scene restoration. It was read+    /// with `String(contentsOf:)` — the same shape as the SVG cap in T-1867, on+    /// content the document limit governs on the way in.+    @Test("A session file within the document limit round-trips")+    func withinLimitRoundTrips() {+        let sessionID = UUID()+        defer { SessionFileManager.deleteContent(for: sessionID) }+        SessionFileManager.writeContent("# Restored", for: sessionID)+        #expect(SessionFileManager.readContent(for: sessionID) == "# Restored")+    }++    @Test("A session file past the document limit is refused, not buffered")+    func pastLimitIsRefused() throws {+        let sessionID = UUID()+        let url = SessionFileManager.directory+            .appendingPathComponent("\(sessionID.uuidString).md")+        defer { try? FileManager.default.removeItem(at: url) }+        try Data().write(to: url)+        let handle = try FileHandle(forWritingTo: url)+        try handle.truncate(atOffset: UInt64(MarkdownDocument.maxFileSize + 1))+        try handle.close()++        #expect(SessionFileManager.readContent(for: sessionID) == nil)+    }++    @Test("A session that was never written reads as nil")+    func missingSessionReadsNil() {+        #expect(SessionFileManager.readContent(for: UUID()) == nil)+    }+}++// MARK: - Document reads (same class, different subsystem)++@Suite("MarkdownDocument bounded read")+struct MarkdownDocumentBoundedReadTests {++    @Test("A document within the limit reads")+    func withinLimitReads() throws {+        let url = try ImageBombFixtures.temporaryFile(Data("# Title".utf8), extension: "md")+        defer { try? FileManager.default.removeItem(at: url) }++        #expect(try MarkdownDocument(contentsOf: url).content == "# Title")+    }++    /// `init(data:)` refuses an oversized document — but only once the caller has+    /// already read it whole, which is the same mistake as the SVG cap. The read+    /// is now bounded, and the error is unchanged.+    @Test("An oversized document is refused without being buffered")+    func oversizedDocumentRefused() throws {+        let url = FileManager.default.temporaryDirectory+            .appendingPathComponent("prism-big-\(UUID().uuidString).md")+        defer { try? FileManager.default.removeItem(at: url) }+        try Data().write(to: url)+        let handle = try FileHandle(forWritingTo: url)+        try handle.truncate(atOffset: UInt64(MarkdownDocument.maxFileSize + 1))+        try handle.close()++        #expect(throws: DocumentError.fileTooLarge) {+            _ = try MarkdownDocument(contentsOf: url)+        }+    }++    @Test("A missing document is reported as unreadable")+    func missingDocumentUnreadable() {+        let url = FileManager.default.temporaryDirectory+            .appendingPathComponent("prism-missing-\(UUID().uuidString).md")+        #expect(throws: DocumentError.fileNotReadable) {+            _ = try MarkdownDocument(contentsOf: url)+        }+    }+}
prismTests/ImageMaterializationChokepointTests.swift Added +248 / -0
diff --git a/prismTests/ImageMaterializationChokepointTests.swift b/prismTests/ImageMaterializationChokepointTests.swiftnew file mode 100644index 00000000..9a05c0ee--- /dev/null+++ b/prismTests/ImageMaterializationChokepointTests.swift@@ -0,0 +1,248 @@+//+//  ImageMaterializationChokepointTests.swift+//  prismTests+//+//  T-2132 / T-2149 / T-2151 / T-1867: the mechanism that notices a NEW path that+//  spends memory before anything bounds it.+//++import Foundation+import Testing+@testable import prism++/// Scans production source for the two operations this defect class is made of:+/// reading a file whole before its size is checked, and turning encoded bytes+/// into pixels without asking what they decode to.+///+/// The other suites prove every *known* path is bounded. They cannot notice a+/// path nobody has written yet — and that is exactly how this class spread. The+/// remote raster got a decode budget in T-2132; the inline and local rasters+/// never had one, because they never called the function the budget lived in+/// (T-2149). The SVG cap was written as `guard data.count <= limit` in the one+/// place that had already read the file (T-1867). Each was found by a separate+/// audit, months apart, and each fix was invisible to the paths beside it.+///+/// This suite is the mechanical version of "and check the others too". It fails+/// when a banned call appears outside ``chokepointFiles`` without an exemption+/// marker, so the question is asked when the call site is written.+///+/// The line-by-line machinery lives in ``ProductionSourceScan``, shared with+/// `URLChokepointAdoptionTests`. That sharing is not tidiness: the first version+/// of this suite matched `PlatformImage(data:` and `NSImage(data:` and was blind+/// to the `.init(data:` shorthand — the *identical* miss the URL scan had already+/// found and fixed for `URL(string:)` versus `.init(string:)`. A lesson learned+/// in one copy does not reach the other.+///+/// ## Exemptions are per line, not per file+///+/// A marker comment on the flagged line, or on the line directly above it:+///+/// ```swift+/// // prism-memory-exempt: app-owned notes JSON, written only by Prism+/// data = try Data(contentsOf: url)+/// ```+///+/// The two-line form exists because `line_length` counts a trailing comment. A+/// marker excuses one line; a second unbounded read three lines down is still a+/// finding, and ``noStaleExemptionMarkers`` fails on a marker that no longer+/// sits on or above a flagged line.+///+/// ## The chokepoint files are exempt, and must not be empty+///+/// ``BoundedFileRead`` and ``ImageMemoryGuard`` are where these calls are+/// *supposed* to live, so the scan skips them — but ``chokepointsStillOwnTheCalls``+/// asserts they still contain the calls, so the rule cannot be satisfied by+/// gutting the guard.+///+/// This is not a whole-file blindness of the kind T-2140 had to unpick. There the+/// skipped files were the ones hosting the bug; here they are two small files+/// whose entire purpose is to be the only owner of these APIs, and every line of+/// them is on the audited path by construction.+///+/// ## What it cannot see+///+/// A textual scan cannot follow a call through a helper, an alias, or a+/// framework entry point nobody thought to list. It narrows the gap; it does not+/// make the guard a proof.+@Suite("Image materialisation chokepoint adoption")+struct ImageMaterializationChokepointTests {++    // MARK: - The Marker++    private static let markerToken = "prism-memory-exempt:"++    /// A file under `prism/` that must exist for the scan to have found the right+    /// source tree.+    private static let sentinel = "Services/ImageMemoryGuard.swift"++    // MARK: - Rules++    /// Files allowed to contain the banned calls: the chokepoints themselves.+    private static let chokepointFiles: Set<String> = [+        "BoundedFileRead.swift",+        "ImageMemoryGuard.swift",+    ]++    /// Every spelling of "read this whole file into memory".+    ///+    /// `String(contentsOf:` is here because it was missing, and it was missing in+    /// the place that mattered most: `SessionFileManager.readContent` restores a+    /// persisted *document body* — content `MarkdownDocument` refuses past 10 MB+    /// on the way in — and read it unbounded on the way back out. A rule that+    /// covers `Data` but not `String` covers half the ways Foundation offers to+    /// make the same mistake.+    private static let unboundedReadTokens = [+        "Data(contentsOf",+        "String(contentsOf",+        ".init(contentsOf",+        "contentsOfFile:",+    ]++    /// Decoding, including the shorthand spelling.+    ///+    /// `.init(data:` is the image twin of the `.init(string:)` miss the URL scan+    /// already had to fix. It over-reports — a textual scan cannot resolve what+    /// `.init` infers — with one exclusion: `self.init(data:` is a *delegating+    /// initialiser*, which constructs the enclosing type and can never be an image+    /// decode. `MarkdownDocument` writes it twice, and marking those exempt would+    /// have put two image-unrelated exemptions in the codebase for a shape the+    /// rule can simply describe correctly.+    private static let decodeRule = ProductionSourceScan.Rule(+        name: "image decode outside ImageMemoryGuard",+        tokens: [+            "CGImageSourceCreate",+            "PlatformImage(data:",+            "NSImage(data:",+            "UIImage(data:",+            ".init(data:",+        ],+        excluding: ["self.init(data:"]+    )++    private static let rules: [ProductionSourceScan.Rule] = [+        ProductionSourceScan.Rule(+            name: "unbounded whole-file read (route through BoundedFileRead)",+            tokens: unboundedReadTokens+        ),+        decodeRule,+        ProductionSourceScan.Rule(+            name: "image encode outside ImageMemoryGuard",+            tokens: ["CGImageDestinationCreate", "tiffRepresentation", "pngData()"]+        ),+        // Not a decode of encoded bytes, so not the guard's business — but it is+        // still a large bitmap allocation (up to ~144 MB: 2000x2000 points at 3x+        // display scale), and it is charged to no budget. Listed so a SECOND+        // snapshot site has to be argued for rather than merely added, and so the+        // one that exists is visible in the same place as everything else that+        // allocates pixels. `ImageMemoryGuard`'s header comment and CLAUDE.md both+        // name it, because a claim of "the only decode site" that quietly excludes+        // it is a claim the code does not deliver.+        ProductionSourceScan.Rule(+            name: "unbudgeted page rasterisation",+            tokens: ["takeSnapshot(configuration:"]+        ),+    ]++    // MARK: - Scanning++    private static func scanProduction() throws -> [ProductionSourceScan.ScannedLine] {+        try ProductionSourceScan.scanProduction(+            sentinel: sentinel,+            skipping: chokepointFiles,+            markerToken: markerToken,+            rules: rules+        )+    }++    // MARK: - The Guard++    @Test("No production line reads a file whole, or decodes an image, outside the chokepoints")+    func productionAdoptsTheChokepoints() throws {+        let findings = ProductionSourceScan.violations(in: try Self.scanProduction())++        #expect(+            findings.isEmpty,+            """+            These spend memory before anything bounds it. Read files through \+            BoundedFileRead.read(contentsOf:limit:), and decode or encode images through \+            ImageMemoryGuard (platformImage(from:) / pageBytes(from:fallbackMIMEType:)), \+            so the size is decided from the header before the allocation happens. If the \+            site genuinely needs neither — a fixed bundle asset, a file Prism wrote itself \+            — put `// \(Self.markerToken) <why>` on the line or directly above it:+            \(findings.map(\.description).joined(separator: "\n"))+            """+        )+    }++    @Test("Every exemption marker still covers a line the scan flags")+    func noStaleExemptionMarkers() throws {+        // A stale marker is worse than none: the next edit under it inherits an+        // exemption nobody re-read. This also catches a marker with no reason.+        let stale = ProductionSourceScan.staleMarkers(in: try Self.scanProduction())++        #expect(+            stale.isEmpty,+            "Remove these exemption markers:\n\(stale.map(\.description).joined(separator: "\n"))"+        )+    }++    /// The scan skips the chokepoint files, so it must also confirm they are+    /// still doing the job that earns them the exemption. Without this, deleting+    /// the guard's body would make the whole suite pass.+    @Test("The chokepoint files still own the calls everything else is banned from")+    func chokepointsStillOwnTheCalls() throws {+        let sourceRoot = try #require(+            ProductionSourceScan.productionSourceRoot(sentinel: Self.sentinel)+        )+        let read = try String(+            contentsOf: sourceRoot.appendingPathComponent("Services/BoundedFileRead.swift"),+            encoding: .utf8+        )+        let guardSource = try String(+            contentsOf: sourceRoot.appendingPathComponent("Services/ImageMemoryGuard.swift"),+            encoding: .utf8+        )++        #expect(read.contains("read(upToCount:"), "BoundedFileRead must still read in bounded chunks")+        #expect(guardSource.contains("CGImageSourceCopyPropertiesAtIndex"),+                "the guard must still decide from the header")+        #expect(guardSource.contains("kCGImagePropertyDepth"),+                "the guard must still read the sample depth, not assume 4 bytes per pixel")+        #expect(guardSource.contains("CGImageSourceCreateThumbnailAtIndex"),+                "the guard must still own the bounded decode")+        #expect(guardSource.contains("CGImageDestinationCreateWithData"),+                "the guard must still own the re-encode")+    }++    // MARK: - The rules themselves++    /// The rules are the whole mechanism, so their edges are pinned directly.+    /// A rule that silently stops matching is a scan that passes for the wrong+    /// reason — which is how `.init(data:` went unnoticed in the first place.+    @Test("The read and decode rules catch the shorthand and wrapped spellings")+    func rulesCatchTheSpellingsThatUsedToSlipPast() {+        func trips(_ source: String) -> Bool {+            let lines = source.components(separatedBy: "\n").map(ProductionSourceScan.stripComment)+            return lines.indices.contains { index in+                let logical = ProductionSourceScan.logicalLine(at: index, in: lines)+                return Self.rules.contains { $0.matches(logical) }+            }+        }++        #expect(trips("let data = try Data(contentsOf: url)"))+        #expect(trips("let text = try String(contentsOf: url, encoding: .utf8)"))+        #expect(trips("let text: String = try .init(contentsOf: url, encoding: .utf8)"))+        #expect(trips("let image: UIImage? = .init(data: bytes)"))+        #expect(trips("raw = try await webView.takeSnapshot(configuration: config)"))+        // Wrapped across lines: no single line holds the token, and joining the+        // continuation leaves a space after `(` that a literal token misses.+        #expect(trips("let data = try Data(\n    contentsOf: url\n)"))+        #expect(trips("let image = PlatformImage(\n    data: bytes\n)"))++        // A delegating initialiser is not an image decode.+        #expect(trips("try self.init(data: data)") == false)+        // Prose about the APIs is a comment, and comments are stripped.+        #expect(trips("// Data(contentsOf:) commits the whole file") == false)+        #expect(trips("let bounded = try BoundedFileRead.read(contentsOf: url, limit: cap)") == false)+    }+}
prismTests/URLChokepointAdoptionTests.swift Modified +20 / -248
diff --git a/prismTests/URLChokepointAdoptionTests.swift b/prismTests/URLChokepointAdoptionTests.swiftindex b5f68fed..3588f338 100644--- a/prismTests/URLChokepointAdoptionTests.swift+++ b/prismTests/URLChokepointAdoptionTests.swift@@ -24,6 +24,9 @@ import Testing /// exemption marker — so the enumeration happens at the moment the call site is /// written, not two tickets later. ///+/// The line-by-line machinery lives in ``ProductionSourceScan``, shared with+/// `ImageMaterializationChokepointTests`.+/// /// ## Exemptions are per line, not per file /// /// The first version of this suite keyed exemptions by *basename* and skipped@@ -88,111 +91,16 @@ struct URLChokepointAdoptionTests {     /// The token an exempt line must carry, followed by a non-empty reason.     private static let markerToken = "prism-url-exempt:" -    /// The reason written after the marker on `line`, or nil if absent.-    private static func exemptionReason(on line: String) -> String? {-        guard let range = line.range(of: markerToken) else { return nil }-        return String(line[range.upperBound...]).trimmingCharacters(in: .whitespaces)-    }--    // MARK: - Logical Lines--    /// One line of source, plus whatever its unclosed parentheses pull in from-    /// the lines below it.-    private struct LogicalLine {-        /// Line and continuations, comments stripped, whitespace collapsed to-        /// single spaces — so a call split across lines reads exactly like the-        /// single-line form the rules look for.-        let text: String-        /// How many characters of ``text`` the line itself contributed.-        let ownCount: Int--        /// True when a match begins in this line rather than in a continuation.-        ///-        /// Load-bearing, not cosmetic: every line gets its own logical line, so-        /// a call nested inside another call is reachable from both. Without-        /// this, `foo(` on the line above would be reported for the violation-        /// two lines down, the same finding would be listed twice, and the-        /// exemption marker would have to sit above a line that is not the one-        /// doing the conversion.-        func startsHere(_ range: Range<String.Index>) -> Bool {-            text.distance(from: text.startIndex, to: range.lowerBound) < ownCount-        }--        /// The first match of `expression` that starts in this line.-        func firstMatchStartingHere(_ expression: NSRegularExpression) -> NSTextCheckingResult? {-            let full = NSRange(text.startIndex..., in: text)-            guard let match = expression.firstMatch(in: text, range: full),-                  let range = Range(match.range, in: text),-                  startsHere(range) else {-                return nil-            }-            return match-        }-    }--    /// How many following lines a call may spill into: `URL(`, `string: x`, `)`-    /// is three, and the spare covers an argument that itself wraps.-    private static let continuationLimit = 3--    /// Collapses whitespace runs so indentation and line breaks cannot separate-    /// tokens the rules expect to find adjacent.-    private static func collapseWhitespace(_ line: String) -> String {-        line.split(whereSeparator: \.isWhitespace).joined(separator: " ")-    }--    /// `(` minus `)`, ignoring anything inside a string literal — a literal-    /// `"a("` must not make a balanced line look like it continues.-    private static func unclosedParens(in line: String) -> Int {-        var depth = 0-        var inQuote = false-        var escaped = false-        for character in line {-            if escaped {-                escaped = false-            } else if character == "\\" {-                escaped = true-            } else if character == "\"" {-                inQuote.toggle()-            } else if !inQuote, character == "(" {-                depth += 1-            } else if !inQuote, character == ")" {-                depth -= 1-            }-        }-        return depth-    }--    /// Builds the logical line starting at `index`. `lines` must already be-    /// comment-stripped, so prose in a trailing comment cannot be joined into-    /// the code below it.-    private static func logicalLine(at index: Int, in lines: [String]) -> LogicalLine {-        let own = collapseWhitespace(lines[index])-        var text = own-        var depth = unclosedParens(in: lines[index])-        var next = index + 1-        while depth > 0, next < lines.count, next - index <= continuationLimit {-            let continuation = collapseWhitespace(lines[next])-            if !continuation.isEmpty {-                text += text.isEmpty ? continuation : " " + continuation-            }-            depth += unclosedParens(in: lines[next])-            next += 1-        }-        return LogicalLine(text: text, ownCount: own.count)-    }+    /// A file under `prism/` that must exist for the scan to have found the right+    /// source tree.+    private static let sentinel = "Services/PrismURL.swift"      // MARK: - Rules -    /// One banned shape, and how to spot it in a logical line of code.-    private struct Rule {-        let name: String-        let matches: (LogicalLine) -> Bool-    }--    private static let rules: [Rule] = [-        Rule(name: "URL(string:) over a non-literal", matches: nonLiteralURLString),-        Rule(name: "hand-rolled percent coding", matches: percentCoding),-        Rule(name: "non-encoded URLComponents setter", matches: nonEncodedComponentSetter)+    private static let rules: [ProductionSourceScan.Rule] = [+        .init(name: "URL(string:) over a non-literal", matches: nonLiteralURLString),+        .init(name: "hand-rolled percent coding", matches: percentCoding),+        .init(name: "non-encoded URLComponents setter", matches: nonEncodedComponentSetter)     ]      /// `URL(string:)`, the inferred-type `.init(string:)` shorthand, or either@@ -204,7 +112,7 @@ struct URLChokepointAdoptionTests {     /// Every match starting on this line is examined, not just the first: one     /// line can hold two calls (`URL(string: "…") ?? URL(string: raw)`), and     /// the constant one must not shield the variable one.-    private static func nonLiteralURLString(in line: LogicalLine) -> Bool {+    private static func nonLiteralURLString(in line: ProductionSourceScan.LogicalLine) -> Bool {         let text = line.text         let full = NSRange(text.startIndex..., in: text)         return urlStringInit.matches(in: text, range: full).contains { match in@@ -229,9 +137,9 @@ struct URLChokepointAdoptionTests {         try! NSRegularExpression(pattern: #"(?:URL|\.init)\s*\(\s*string:\s*(.)"#)     }() -    private static func percentCoding(in line: LogicalLine) -> Bool {+    private static func percentCoding(in line: ProductionSourceScan.LogicalLine) -> Bool {         ["addingPercentEncoding", "removingPercentEncoding"].contains { token in-            line.text.range(of: token).map(line.startsHere) ?? false+            line.containsStartingHere(token)         }     } @@ -250,7 +158,7 @@ struct URLChokepointAdoptionTests {     /// the literal exclusion above. "Whole" is load-bearing — `"/" + uuid`     /// starts with a quote and is not a constant. `self.` receivers are     /// excluded because a memberwise `self.query = query` is not a URL at all.-    private static func nonEncodedComponentSetter(in line: LogicalLine) -> Bool {+    private static func nonEncodedComponentSetter(in line: ProductionSourceScan.LogicalLine) -> Bool {         let text = line.text         guard let match = line.firstMatchStartingHere(componentSetter),               let rhsRange = Range(match.range(at: 1), in: text) else {@@ -271,106 +179,12 @@ struct URLChokepointAdoptionTests {         )     }() -    // MARK: - Findings--    private struct Finding: CustomStringConvertible {-        let file: String-        let line: Int-        let text: String-        let rule: String+    // MARK: - Scanning -        var description: String { "\(file):\(line) [\(rule)] \(text)" }-    }--    /// Strips `//` line comments so prose *about* the banned APIs — of which-    /// this codebase has a great deal — is not mistaken for a call.-    ///-    /// Quote-aware, because `"prism-doc://img/?src=" + x.addingPercentEncoding(…)`-    /// is a real line in `BlockHTMLEmitter` and a naive cut at the first `//`-    /// truncated it before the violation, hiding it from the scan entirely.-    /// Lines inside a `"""` literal are treated as code; that direction only-    /// ever over-reports, and it never fires in practice.-    private static func stripComment(_ line: String) -> String {-        var inQuote = false-        var escaped = false-        var index = line.startIndex-        while index < line.endIndex {-            let character = line[index]-            if escaped {-                escaped = false-            } else if character == "\\" {-                escaped = true-            } else if character == "\"" {-                inQuote.toggle()-            } else if character == "/", !inQuote {-                let next = line.index(after: index)-                if next < line.endIndex, line[next] == "/" {-                    return String(line[..<index])-                }-            }-            index = line.index(after: index)-        }-        return line-    }--    /// One production line, already classified.-    private struct ScannedLine {-        let file: String-        let number: Int-        let text: String-        /// The rule this line trips, if any.-        let rule: String?-        /// The reason written after an exemption marker on this line, if any.-        let reason: String?-        /// True when an exemption applies to this line — its own marker, or a-        /// marker on the line directly above that is not itself a violation.-        let isExcused: Bool-        /// True when this line carries a marker that is doing real work.-        let markerIsLive: Bool--        var finding: Finding {-            Finding(file: file, line: number, text: text.trimmingCharacters(in: .whitespaces), rule: rule ?? "")-        }-    }--    /// Reads and classifies every line of production source.-    private static func scanProduction() throws -> [ScannedLine] {-        let sourceRoot = try #require(-            productionSourceRoot,-            "Could not locate the prism/ source tree from #filePath; the scan cannot run."+    private static func scanProduction() throws -> [ProductionSourceScan.ScannedLine] {+        try ProductionSourceScan.scanProduction(+            sentinel: sentinel, markerToken: markerToken, rules: rules         )-        let files = try #require(swiftFiles(under: sourceRoot))-        #expect(files.count > 50, "Source scan found suspiciously few files (\(files.count))")--        return files.flatMap { file -> [ScannedLine] in-            guard let contents = try? String(contentsOf: file, encoding: .utf8) else { return [] }-            let raw = contents.components(separatedBy: .newlines)-            let stripped = raw.map(stripComment)-            let rule = stripped.indices.map { index -> String? in-                let logical = logicalLine(at: index, in: stripped)-                return rules.first { $0.matches(logical) }?.name-            }-            let reason = raw.map(exemptionReason(on:))--            return raw.indices.map { index in-                let ownMarker = reason[index].map { !$0.isEmpty } ?? false-                let markerAbove = index > 0-                    && (reason[index - 1].map { !$0.isEmpty } ?? false)-                    && rule[index - 1] == nil-                let coversLineBelow = index + 1 < raw.count-                    && rule[index + 1] != nil-                    && rule[index] == nil-                return ScannedLine(-                    file: file.lastPathComponent,-                    number: index + 1,-                    text: raw[index],-                    rule: rule[index],-                    reason: reason[index],-                    isExcused: ownMarker || markerAbove,-                    markerIsLive: ownMarker && (rule[index] != nil || coversLineBelow)-                )-            }-        }     }      // MARK: - The Guard@@ -379,9 +193,7 @@ struct URLChokepointAdoptionTests {     func productionAdoptsTheChokepoint() throws {         // An exemption excuses one line. A second conversion three lines down         // is still a finding, even in a file that already carries a marker.-        let findings = try Self.scanProduction()-            .filter { $0.rule != nil && !$0.isExcused }-            .map(\.finding)+        let findings = ProductionSourceScan.violations(in: try Self.scanProduction())          #expect(             findings.isEmpty,@@ -401,48 +213,8 @@ struct URLChokepointAdoptionTests {         // A stale marker is worse than none: it is an exemption nobody will         // re-read, sitting where nothing needs one — and the next edit there         // inherits it silently. This also catches a bare marker with no reason.-        let stale = try Self.scanProduction()-            .filter { $0.reason != nil && !$0.markerIsLive }-            .map { line in-                Finding(-                    file: line.file, line: line.number,-                    text: line.text.trimmingCharacters(in: .whitespaces),-                    rule: (line.reason?.isEmpty ?? true) ? "marker with no reason" : "marker covers no violation"-                )-            }+        let stale = ProductionSourceScan.staleMarkers(in: try Self.scanProduction())          #expect(stale.isEmpty, "Remove these exemption markers:\n\(stale.map(\.description).joined(separator: "\n"))")     }--    // MARK: - Source Location--    /// The `prism/` production source directory, found by walking up from this-    /// test file. Compile-time path, so it resolves on the machine that built-    /// the test bundle — which is also the machine that runs it.-    private static var productionSourceRoot: URL? {-        var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent()-        for _ in 0..<6 {-            let candidate = directory.appendingPathComponent("prism", isDirectory: true)-            var isDirectory: ObjCBool = false-            if FileManager.default.fileExists(atPath: candidate.path, isDirectory: &isDirectory),-               isDirectory.boolValue,-               FileManager.default.fileExists(-                   atPath: candidate.appendingPathComponent("Services/PrismURL.swift").path-               ) {-                return candidate-            }-            directory = directory.deletingLastPathComponent()-        }-        return nil-    }--    private static func swiftFiles(under root: URL) -> [URL]? {-        guard let enumerator = FileManager.default.enumerator(-            at: root,-            includingPropertiesForKeys: [.isRegularFileKey]-        ) else {-            return nil-        }-        return enumerator.compactMap { $0 as? URL }.filter { $0.pathExtension == "swift" }-    } }
prismTests/ImageLoaderTests.swift Deleted +0 / -313
diff --git a/prismTests/ImageLoaderTests.swift b/prismTests/ImageLoaderTests.swiftindex a90c3f3b..c362b211 100644--- a/prismTests/ImageLoaderTests.swift+++ b/prismTests/ImageLoaderTests.swift@@ -792,316 +792,3 @@ enum ImageLoaderTestHelpers {         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)-    }-}
prismTests/WebRendering/WebMediaBehaviourTests.swift Modified +62 / -0
diff --git a/prismTests/WebRendering/WebMediaBehaviourTests.swift b/prismTests/WebRendering/WebMediaBehaviourTests.swiftindex d36accf4..9c395909 100644--- a/prismTests/WebRendering/WebMediaBehaviourTests.swift+++ b/prismTests/WebRendering/WebMediaBehaviourTests.swift@@ -143,6 +143,68 @@ struct WebMediaBehaviourTests {         #expect(hasErrorClass == true)     } +    // MARK: - Grant Folder Access affordance (Req 3.2/3.4, T-2132 review)++    /// Emits `source` as an image block, waits for the failed-image placeholder+    /// `prism-media.js` swaps in, and reports whether it offers the grant button.+    ///+    /// The source goes through the real emitter, so what the page receives is+    /// whatever `rewriteImageSrc` decided — the refusal address for a payload the+    /// memory guard declines, the mediated `?src=` form otherwise. The harness+    /// registers no `prism-doc://` scheme handler, so either one fails to load+    /// and the placeholder path runs, which is the path under test.+    private func grantButtonAppears(for source: String) async throws -> Bool {+        let harness = try await WebDocumentLiveHarness.make(+            blocks: [.image(source: source, alt: "a picture", title: nil, link: nil, width: nil, height: nil)]+        )+        for _ in 0..<50 {+            let ready = try await harness.evalBool(+                "return document.querySelector('.prism-image-placeholder') !== null;"+            )+            if ready { break }+            try await Task.sleep(for: .milliseconds(20))+        }+        let placeholderExists = try await harness.evalBool(+            "return document.querySelector('.prism-image-placeholder') !== null;"+        )+        #expect(placeholderExists == true, "the failed image should be replaced by a placeholder")+        return try await harness.evalBool(+            "return document.querySelector('.prism-grant-access') !== null;"+        )+    }++    /// A refused inline image has no folder to grant. `isLocalImage` read the+    /// missing `?src=` payload back as `""`, which is neither `http` nor `data`,+    /// so every refusal offered an iOS folder picker for a picture written into+    /// the markdown.+    @Test("A refused inline image offers no Grant Folder Access button")+    func blockedImageOffersNoGrantButton() async throws {+        let bomb = "data:image/png;base64," + ImageBombFixtures.overCeilingPNG.base64EncodedString()+        // Precondition: the emitter really did refuse it, so this test cannot+        // quietly become "an image that loaded fine has no grant button".+        #expect(BlockHTMLEmitter.rewriteImageSrc(bomb) == PrismDocSchemeHandler.blockedImageURLString)+        #expect(try await grantButtonAppears(for: bomb) == false)+    }++    /// The other side of the same predicate: a genuine local image that failed+    /// still offers the button, so the fix narrows rather than removes it.+    ///+    /// iOS only — the grant flow (and the `<main>` marker that enables it) exists+    /// only there, so on macOS no image of any kind offers the button.+    @Test(+        "A failed local image still offers Grant Folder Access",+        .enabled(if: {+            #if os(iOS)+            return true+            #else+            return false+            #endif+        }())+    )+    func failedLocalImageStillOffersGrantButton() async throws {+        #expect(try await grantButtonAppears(for: "pictures/diagram.png") == true)+    }+     // MARK: - Progressive rendering + layoutSettled (Req 9.1/9.5)      @Test("Media in the viewport is marked visible and layoutSettled is reported")
prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift Modified +37 / -6
diff --git a/prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift b/prismTests/WebRendering/BlockHTMLEmitterMediaTests.swiftindex 6064b030..5aade1b4 100644--- a/prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift+++ b/prismTests/WebRendering/BlockHTMLEmitterMediaTests.swift@@ -57,9 +57,19 @@ struct BlockHTMLEmitterImageRewriteTests {         #expect(!doc.html.contains("src=\"https://example.com/a.png\""))     } +    /// An admissible `data:` URI still passes through: it carries its own bytes,+    /// the CSP permits them, and the memory guard has confirmed from the header+    /// that WebKit's decode of them is bounded.+    ///+    /// The fixture is a real 1x1 GIF rather than the truncated PNG it used to be.+    /// Since T-2149 the passthrough is conditional on ImageIO being able to size+    /// the payload, and a payload that is not a decodable image at all is refused+    /// — so a fixture that could never have rendered no longer demonstrates the+    /// rule it is here to demonstrate.     @Test("data: image URI passes through unchanged (Req 3.3)")     func dataURIPassthrough() {-        let dataURI = "data:image/png;base64,iVBORw0KGgo="+        let dataURI =+            "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"         let rewritten = BlockHTMLEmitter.rewriteImageSrc(dataURI)         #expect(rewritten == dataURI, "data: URIs carry their own bytes and need no mediation")         let doc = BlockHTMLEmitter.emit(blocks: [.image(source: dataURI, alt: "inline")],@@ -67,6 +77,15 @@ struct BlockHTMLEmitterImageRewriteTests {         #expect(doc.html.contains(dataURI))     } +    @Test("A data: URI ImageIO cannot size is refused rather than passed through")+    func unsizableDataURIRefused() {+        // PNG magic plus a truncated header: enough to sniff, not enough to size.+        let dataURI = "data:image/png;base64,iVBORw0KGgo="+        #expect(+            BlockHTMLEmitter.rewriteImageSrc(dataURI) == PrismDocSchemeHandler.blockedImageURLString+        )+    }+     @Test("rewriteImageSrc round-trips the original through the src query item")     func rewriteRoundTrips() {         let original = "images/my photo (1).png"@@ -122,11 +141,15 @@ struct RawHTMLImageRewriteTests {      @Test("HTMLImageSourceRewriter leaves data: srcset candidates to the rewrite closure")     func rewriterDelegatesDataPolicy() {-        let raw = "<img srcset=\"data:image/png;base64,AAA= 1x\">"+        // A real 1x1 GIF, so the closure's admission check (T-2149) passes and the+        // passthrough policy is what is actually under test here.+        let gif = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"+        let raw = "<img srcset=\"\(gif) 1x\">"         let out = HTMLImageSourceRewriter.rewrite(raw, using: BlockHTMLEmitter.rewriteImageSrc)         // data: passthrough is honoured by the closure; the data URI's comma is not a-        // candidate separator.-        #expect(out.contains("data:image/png;base64,AAA="))+        // candidate separator, and the descriptor survives.+        #expect(out.contains(gif))+        #expect(out.contains("1x"))     }      @Test("Single-quoted src/srcset attributes are mediated, not bypassed (Req 8.3)")@@ -244,11 +267,19 @@ struct RawHTMLImageRewriteTests {     func unquotedDataURIPaddingPreserved() {         // base64 padding routinely ends a data URI in `=`; truncating there produced an         // undecodable payload plus a stray `=` after the closing quote.-        let raw = "<img src=data:image/png;base64,iVBORw0KGgo= alt=x>"+        //+        // The payload is a real 1x1 PNG whose base64 happens to end in `==`, rather than+        // the truncated header it used to be. Since T-2149 the passthrough is conditional+        // on the memory guard being able to size the payload, so a fixture that is not a+        // decodable image would be rewritten to the refusal address and this test would be+        // asserting the wrong thing about the wrong string.+        let payload = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"+            + "AAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="+        let raw = "<img src=\(payload) alt=x>"         let rewritten = HTMLImageSourceRewriter.rewrite(raw, using: BlockHTMLEmitter.rewriteImageSrc)         // data: is passthrough policy (the closure returns it unchanged), but the value must         // still be captured whole and re-emitted quoted.-        #expect(rewritten.contains("src=\"data:image/png;base64,iVBORw0KGgo=\""),+        #expect(rewritten.contains("src=\"\(payload)\""),                 "padding preserved: \(rewritten)")         #expect(rewritten.contains("alt=x"), "following attribute intact: \(rewritten)")     }
CLAUDE.md Modified +12 / -8
diff --git a/CLAUDE.md b/CLAUDE.mdindex 049aa928..803d85c0 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -85,13 +85,15 @@ Implementation history and gotchas (the `<script>` data-island CSP trap, the nat 3. `ImageBlockView` resolves paths at render time via `ImagePathResolver` using environment-injected base URL 4. `ImagePathResolver` handles relative paths (file, URL, bundled), absolute URLs, data URIs, SVG files, and scheme validation 5. SVG sources detected via `ImagePathResolver.isSVG()` are loaded as strings by `SVGSourceLoader` and rasterized by `SVGRenderer` (delegates to `WebViewPool`; strict CSP: `script-src 'none'`)-6. Raster images loaded by `ImageLoader` (actor) from file, network, or data URI-7. `SharedConcurrency` manages the global network semaphore (6 concurrent fetches); render concurrency is managed per-pool by `WebViewPool` instances-8. `ImageCache` (`NSCache`-based) caches raster images; `SnapshotCache` (`NSCache`-based) caches SVG/mermaid rasterized snapshots-9. Tap-to-zoom: `ImageDetailSheet` (iOS) / `ImageDetailWindow` (macOS) with `ZoomableImageView` providing pinch-zoom and pan gestures-10. `DirectoryAccessManager` handles iOS sandbox directory bookmarks for sibling image access-11. `ImageErrorPlaceholder` displays styled placeholders with alt text, source path, and error reason-12. Linked images show a two-button overlay ("View Image" / "Follow Link") matching the MermaidPreviewCard pattern: iOS uses tap-to-reveal with 3s auto-dismiss; macOS uses hover+6. Raster images loaded by `ImageLoader` (actor) from file, network, or data URI. Two entry points, both funnelling into the guard below: `load(_:)` for the native SwiftUI surfaces, `loadPageBytes(_:fallbackMIMEType:)` for the rendered page (the only raster entry `PrismDocSchemeHandler` uses)+7. **Memory admission (T-2132/T-2149/T-2151/T-1867)**: `ImageMemoryGuard` (`prism/Services/`) is the single place that decides whether image bytes may be materialised and at what decoded cost, and the only place in production that may turn ENCODED IMAGE BYTES into pixels — create a `CGImageSource`, call `PlatformImage(data:)`, decode a `CGImage`, or encode one back to bytes. That claim is narrower than "the only bitmap allocation", deliberately: `WebViewPool.captureSnapshot` has WebKit rasterise a rendered page (mermaid/SVG thumbnails) for up to ~144 MB (2000 pt square at 3x) against no byte budget — it has no encoded bytes and no header to size, so it is bounded by the pool's slot count and `SVGRenderer`'s 2000-point frame clamp instead, and it carries a `prism-memory-exempt` marker so a SECOND such site has to be argued for. It decides from the **header** (`CGImageSourceCopyPropertiesAtIndex`), never from a decode, and the per-pixel size comes from the header too (`kCGImagePropertyDepth` + `kCGImagePropertyColorModel`), NOT a hardcoded 4: a 16-bit PNG decodes at 8 bytes/px and a 32-bit float TIFF at 16, so a fixed 4 under-counted them 2x and 4x and "retained ≤ 64 MB" was not a bound the code enforced (`ImageCache.estimatedMemoryCost` was already depth-aware on the other side of the decode). Opaque RGB still costs 4 (CoreGraphics has no packed 24-bit format); grey costs 1. An unreadable depth or colour model is `refuseUnsizable`, same as unreadable dimensions — empirically free, since every format ImageIO can size reports both. Three verdicts: `decodeWhole` (decoded whole, or on the page path served VERBATIM, since re-encoding validated bytes buys nothing and costs three allocations), `downsampleFirstFrame` (frame 0 declares ≤ 256 MB — bounded decode to `retainedDimensionCap(bytesPerPixel:)`, which is 4096 px at 4 B/px but 2896 at 8 and 2048 at 16, because a thumbnail KEEPS the source's bit depth), `refuse` (past 256 MB, or unsizable). The verdict is taken **per consumer**, which is a correctness rule not a refinement: `.native` uses the eager all-frame model `NSImage(data:)` actually pays (every frame's declared size sums into the retained budget, scan bounded at `maxScannedFrames`); `.page` budgets frame 0 alone, because WebKit decodes progressively. Applying the native model to the page is what stopped animated GIFs past 64 frames from animating — a 100x100 100-frame GIF is ~4 MB decoded and was refused, then served as a re-encoded still. The 256 MB ceiling is load-bearing rather than belt-and-braces: PNG/GIF/TIFF have no subsampled decode, so `CGImageSourceCreateThumbnailAtIndex` may inflate the whole source bitmap before scaling — bounding the transient peak is only possible by bounding what may be *declared*. Sending every over-budget raster to the thumbnail path was the first T-2132 fix, and why it was reopened. `BoundedFileRead` is the byte-level twin: it preflights the file size AND bounds the read chunk by chunk, so a file that grows in between is still cut off — every local read of a document, image, or SVG goes through it, including `SessionFileManager.readContent`, which restores a persisted document body. `ImageMaterializationChokepointTests` scans production source and fails when a banned call appears outside those two files without a per-line `// prism-memory-exempt: <why>` marker; its line-scanning machinery is shared with `URLChokepointAdoptionTests` via `ProductionSourceScan` (the duplication cost real coverage: the URL scan had already learned that `.init(string:)` is `URL(string:)`, and the copied image scan was blind to `.init(data:` all over again). A companion test asserts the chokepoint files still contain the calls, so the rule cannot be satisfied by gutting the guard. Honest limits: retained bitmap ≤ 64 MB/image, transient peak ≤ that image's declared size (≤ 256 MB); what WebKit decodes for a verbatim-served image is not accounted at all+8. Concurrency: `SharedConcurrency` owns the global network semaphore (6 concurrent fetches) and a 4-slot local-read semaphore; both permits are held until materialisation FINISHES, so a compressed body can only exist while its owner holds one (that ordering is the T-2151 fix — releasing early let bodies pile up behind the decode gate). Decode concurrency is `ImageMemoryGuard.budget`, an `ImageMemoryBudget` actor weighted in BYTES, not slots: a four-slot count bounded nothing when one image costs 250 MB and the next 8 MB. One reservation covers decode and re-encode together. Render concurrency is managed per-pool by `WebViewPool` instances+9. Inline `data:` rasters never reach the scheme handler (the CSP permits `img-src data:`), so admission happens at emit time in `BlockHTMLEmitter.rewriteImageSrc`, judged with the `.page` consumer: an admissible payload passes through unchanged, anything else is rewritten to `PrismDocSchemeHandler.blockedImageURLString` (`prism-doc://img/blocked`), which routes to `.rejected(.imagePolicy)` so the page renders its existing catalog-localised image-error placeholder. Refusing rather than downsampling leaves a deliberate asymmetry — the byte-identical image written as a FILE reference is downsampled and shown — and the justification is memory, not convenience: downsampling inline means decoding and re-encoding during emit (a pure, cheap, off-main function of the blocks, run per parse revision) and then re-inlining the base64 result INTO the document HTML, where it is retained for the whole session; a file reference has somewhere else to put the downsampled bytes. The refusal address carries no `?src=` payload, which `prism-media.js`'s `isLocalImage` must treat as "not local" — reading the missing parameter back as `""` offered an iOS "Grant Folder Access" picker for a picture that lives in the markdown and has no folder to grant+10. `ImageCache` (`NSCache`-based) caches raster images; `SnapshotCache` (`NSCache`-based) caches SVG/mermaid rasterized snapshots+11. Tap-to-zoom: `ImageDetailSheet` (iOS) / `ImageDetailWindow` (macOS) with `ZoomableImageView` providing pinch-zoom and pan gestures+12. `DirectoryAccessManager` handles iOS sandbox directory bookmarks for sibling image access+13. `ImageErrorPlaceholder` displays styled placeholders with alt text, source path, and error reason+14. Linked images show a two-button overlay ("View Image" / "Follow Link") matching the MermaidPreviewCard pattern: iOS uses tap-to-reveal with 3s auto-dismiss; macOS uses hover  ### In-App Purchase System 1. `StoreManager` (`@Observable @MainActor`) owns StoreKit 2 product fetching, entitlement verification, and the export counter — everything scoped to the ACCOUNT. It owns no paywall presentation state: that is per-scene and lives on `PaywallPresenter` (T-1779). `productLoadError` means "the catalog did not return the unlock product", empty or merely partial — StoreKit omits identifiers it cannot resolve rather than failing, and testing `fetched.isEmpty` left a partial catalog with `unlockProduct` nil and the flag false, a combination neither the paywall nor the Settings row has a branch for, so both spun forever (T-1841). A missing individual TIP is tolerated: the unlock product gates the paywall, the tips only fill the tip jar, so the two are selected independently and neither short-circuits the other — an early return on a missing unlock silently emptied an already-unlocked user's tip jar. The rule lives on `StoreManager.Catalog.loadFailed`, not at the assignment in `fetchProducts()`, because that method assigns to `Product`-typed properties and StoreKit will not let a test construct a `Product` — with the rule at the assignment, reverting it to `fetched.isEmpty` left the whole suite green. The selection itself is the pure `selectCatalog(from:)` over the `StoreProduct` seam (Decision 14)@@ -164,7 +166,9 @@ prism/ │   ├── SVGRenderer.swift              # Delegates to WebViewPool for SVG rasterization │   ├── SVGSourceLoader.swift          # Loads SVG content as strings │   ├── WebViewPool.swift              # Shared WKWebView pool with scoped access and per-pool semaphore-│   ├── SharedConcurrency.swift        # Global network (6) semaphore+│   ├── SharedConcurrency.swift        # Global network (6) + local-read (4) semaphores+│   ├── ImageMemoryGuard.swift         # Single admission point for image materialisation: header-derived verdict + byte-weighted budget+│   ├── BoundedFileRead.swift          # Reads a file within a cap (preflight + bounded chunks) instead of measuring one afterwards │   ├── DiagramCache.swift             # Actor-based SVG string cache │   ├── SnapshotCache.swift            # NSCache for SVG/mermaid rasterized snapshots │   ├── ImagePathResolver.swift        # Image source resolution (incl. SVG support)
CHANGELOG.md Modified +1 / -1
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 0f062239..7312ee48 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -22,11 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- An image that is tiny as a file but enormous as a picture can no longer exhaust memory or terminate Prism, whether it comes from the web, from a file beside the document, or written directly into the markdown (T-2132, T-2149, T-2151, T-1867). A picture is stored compressed, and a plain-coloured one compresses at about a thousand to one — so a 400 KB download can be a 20,000 by 20,000 image that needs about 1.6 GB the moment anything tries to display it. Prism's limits were all written on the wrong side of that: a 50 MB cap on the download said nothing about the picture inside it, and the 2 MB cap on a local SVG was applied only after the whole file had already been read, so a very large one could freeze the app on its way to being refused. The limits that did exist covered only images fetched from the web; the same image referenced from a file next to your document, or embedded inline in the markdown, went straight to the renderer unchecked. Prism now reads the picture's dimensions from its header — a few bytes, before anything is decoded — and decides from that. An ordinary image is displayed as before. A very large one referenced from the web or from a file is scaled down to fit. One beyond any reasonable size is refused outright and shows the usual "Image failed to load" placeholder, rather than being handed to a decoder that would have to build the whole thing first. How large a picture is now also accounts for how much detail each dot of it carries: most pictures store one byte per colour, but some store two or four, and Prism previously assumed the smaller size for all of them and so under-counted the deep ones by half or three quarters. One consequence you may see: a very large deep-colour photograph that used to display at full size is now scaled down, because its true size was always above the limit and is now measured as such. Files are now read up to their limit instead of read whole and then measured — including the copy Prism keeps of a document you have not saved yet, which is restored when the app reopens. How much decoding happens at once is limited by how much memory those pictures actually need rather than by how many of them there are, so a page full of large images no longer overruns while appearing to stay within its bounds. Two things behave differently, both deliberately. An image whose file does not say how big it is, or what kind of dots it stores, now shows the "Image failed to load" placeholder instead of being displayed — there is no way to know what it would cost until it has already cost it. And an image written directly into the markdown is treated more strictly than the same image kept in a file beside the document: it is either small enough to display as it is or refused, never scaled down. That difference is about memory rather than effort. Scaling a picture that is written into the markdown means rebuilding it and writing the smaller version back into the page, where it then stays for as long as the document is open — which costs more memory, for longer, than not showing it. A picture in a file has somewhere else to keep its smaller version, so it can be scaled instead of refused. Animated images are unaffected in either case: they play as before, however many frames they have. - A verification scan that starts during the app's initial entitlement bootstrap can no longer publish a stale result while a newer scan is still in flight (T-2152). While `entitlementState` was still `.loading`, any scan's result was accepted regardless of whether a more recent scan — for example one started right after `AppStore.sync()` — was still reading the world; the older scan finishing first could briefly flip the paywall to locked (or unlocked) ahead of the newer, more current answer. An older result that arrives while a newer scan is still outstanding is now held back rather than published. If the newer scan goes on to answer, its fresher result is published and the held-back one is simply dropped; if instead it is cancelled without ever answering, the held-back result is released, so a cancelled scan cannot leave the paywall stranded on `.loading`. The trade is that the brief loading state now ends when the last overlapping scan answers rather than the first, so it can last marginally longer; every control it gates is disabled meanwhile, so nothing silently does nothing. - Saving a pasted document to a file no longer disturbs whatever document you opened next (T-2213). A save finishes in two parts: the file is written straight away, but the document only becomes that file once its notes have been moved across, and on a slow iCloud connection that second part can still be running after you have closed the document or opened another one. When it finished late, it acted on the document then on screen instead of the one it had saved: the pasted text of that other document was deleted from the place Prism keeps unsaved documents — so it could no longer be recovered after a relaunch — its entry in Recent Files was labelled with the wrong document's title, and an action you had queued behind its own Save prompt could run without you confirming it. A save that failed to move its notes also raised an alert naming a file you were no longer looking at. Each of these now belongs to the document that was actually saved, and the document on screen is left alone. Its Recent Files entry is labelled with its own title rather than the other document's. Where that other document had itself started saving in the meantime, the late save no longer takes over the shortcut that document had prepared for its own file, which can leave the saved file without a Recent Files entry of its own. The file is saved either way, and can be opened from the Files app. - Opening an SVG image full screen now shows that document's image, rather than one belonging to a different document (T-1866). Two documents that each referred to an SVG by the same relative name — `./diagram.svg` sitting next to each file, say — shared a single entry in the store of rendered previews, because that store was keyed on the name written in the markdown instead of on the file the name actually resolves to. Whichever document you opened first won: opening the second document's diagram showed you the first one's picture, with nothing to indicate it was the wrong one, and it kept doing so for the rest of the session. Rendered SVG previews are now keyed on the resolved image itself — a file's full path, a remote address, or the contents of an inline image — so same-named images in different documents can no longer stand in for one another. Reopening the same image in the same document still comes back instantly from the store, as before, and two documents that embed a byte-for-byte identical inline image do still share one rendered preview, which is correct: identical content renders identically. - A note imported from a document, anchored to a nested list item (a sub-item under a top-level list item), now shows its quoted text and the section it belongs to (T-1871). In the notes pane it appeared with a blank quote and no heading, and it could not be told apart from any other nested-item note in the document. The note itself was always attached to the correct item — only the surrounding context was missing, and it was missing every time the document was opened, not just on a reload. Imported notes are rebuilt from the document on each open, and that rebuild only recognised top-level list-item identifiers, so a nested item's identifier was never matched and the context came back empty. Nested identifiers are now recognised the same way every other part of the app that addresses list items already recognises them, so an imported note on a nested item gets the same context as one on a top-level item.-- Refreshing a document opened from a URL no longer leaves a background download running loose after you close it (T-1805). Tapping Refresh started an untracked download with no way to stop it: closing the document, or navigating to a different one, before the download finished let it keep running, and when it finally completed it could still overwrite that URL's title in Recent Files — even though you had already moved on — or race a second refresh for a stale result. Closing the document, or starting another refresh, now cancels the one in flight, and a refresh that has been superseded or cancelled no longer writes its result anywhere. - Refreshing a document opened from a URL no longer leaves a background download running loose after you close it (T-1805). Tapping Refresh started an untracked download with no way to stop it: closing the document, or navigating to a different one, before the download finished let it keep running, and when it finally completed it could still overwrite that URL's title in Recent Files — even though you had already moved on — or race a second refresh for a stale result. Closing the document, or starting another refresh, now cancels the one in flight, and a refresh that has been superseded or cancelled no longer applies its result once another one has taken over. - The unlock screen no longer spins forever when the App Store returns some but not all of Prism's products (T-1841). Product fetching only reported an error when the whole catalog came back empty; if the store returned the tip products but omitted the unlock product specifically, the purchase button never appeared and the spinner never resolved, with no error message and no way to retry. A fetch that comes back without the unlock product is now treated as a load failure in every case, so it always ends in the existing "Unable to load purchase options" message with a Retry button, never an indefinite spinner. The unlock row in Settings, which sat on the same endless spinner, now reads "Unavailable" and still opens the unlock screen when tapped. Tips are unaffected: a catalog that returned the tips but not the unlock product still fills the tip jar, so an already-unlocked user sees nothing change. - A `prism://open?url=…` link now opens the address it names, even when that address mixes already-escaped and unescaped characters (T-2140). `…/my%20file and more.md` was fetched as `…/my%2520file%20and%20more.md` — a different resource, with no error shown — because the address had already been unescaped one layer by the time it was read, and was then escaped a second time in full. Investigating it surfaced a second fault of the same kind, live on every markdown link and image in every document: the escaping used for addresses turned an escaped `%2F` back into a real `/`, splitting one path segment into two. That silently broke any address that identifies something by an escaped path — a GitLab project URL, for instance, which 404s once `group%2Fproj` becomes `group/proj`. Escaped slashes and escaped ampersands now survive every route into the app: typed and pasted addresses, deep links, document links and images, `mailto:` links, and the GitHub blob-to-raw rewrite. One narrow side effect of reworking that rewrite: a GitHub address written with a doubled slash in it (`github.com//owner/repo/blob/…`) is no longer recognised as a file address, so it now reports an unsupported content type instead of opening.
.gitignore Modified +1 / -0
diff --git a/.gitignore b/.gitignoreindex 58b37165..7535cde8 100644--- a/.gitignore+++ b/.gitignore@@ -17,6 +17,7 @@ xcuserdata/ ## Build generated build/ DerivedData/+DerivedData*/ *.pbxuser !default.pbxuser *.mode1v3

Things to double-check

The rebase, and the CHANGELOG hazard specifically.

Three CHANGELOG conflicts, all in the ### Fixed block, all resolved by keeping this PR's T-2132 entry plus main's new T-2152 and T-2213 entries. Verified afterwards: no conflict markers; T-2132, T-2149, T-2151, T-1867, T-1805, T-2152 and T-2213 each appear exactly once; net diff vs main is +1/-1.

Worth knowing: origin/main genuinely carries a duplicated T-1805 bullet, and this branch's second commit removes one copy. That deletion survived the rebase and is correct. Separately, main carries four other duplicated bullet clusters (one appears 4×, two appear 3×) which are pre-existing and untouched by this PR — but they suggest the CHANGELOG needs a dedupe pass independent of this branch.

Test-execution honesty — read this before trusting any green run here.

My first targeted run reported total=6 passed=6 OK and was almost entirely fictional. The suites in this PR use @Suite("display name") with type names that differ from their filenames (ImageMemoryGuardTests.swift contains no type called ImageMemoryGuardTests), so -only-testing:prismTests/ImageMemoryGuardTests silently matched nothing. This is exactly the trap the Makefile's own comments warn about. Re-run against the 17 real type names: 107 tests, 107 passed, 0 failed, 0 retried, gate OK.

The retry gate, reconciled honestly.

The previous green run of this PR had 9 first-attempt failures in WebMediaBehaviourTests, all at exactly 30s, all passing on retry — which the merged check-test-results.sh (#379) would now fail. This PR adds two tests to that same suite, so it was the obvious suspect.

I ran the suite three consecutive times on a dedicated simulator at -parallel-testing-worker-count 1: 9/9, 9/9, 9/9, zero retries, gate OK every time. The failures did not reproduce. That is consistent with the pre-existing live-WebKit flakiness (T-2236 / T-2245) being environmental — shared test host, parallel clones — rather than this PR's doing. Three runs is not proof; it is the strongest signal available without a quiet machine and a full suite.

What I could not verify.
  • The PR body's mutation table claims 15 mutations were killed and cites '199/199'. All 15 named tests exist and resolve, but whether the mutations were actually killed is not checkable from the repository.
  • The full test suite was not run — macOS-destination runs are wedged in this environment (testmanagerd, ~705s to zero tests), so only iOS-simulator targeted runs were performed. 134 tests across the PR's own suites plus the live-WebKit media suite passed; the other ~2000 were not exercised on this rebase.
  • UI tests were not run.
Depth arithmetic — the raw probe results.

I built fixtures with CGImageDestination and a hand-written PNG encoder, then compared ImageMemoryGuard's prediction to the decoded CGImage.bitsPerPixel/8:

opaque 8-bit RGB PNG    depth=8  model=RGB   predict=4   actual=4    exact
opaque 8-bit JPEG       depth=8  model=RGB   predict=4   actual=4    exact
8-bit RGBA PNG          depth=8  model=RGB   predict=4   actual=4    exact
16-bit RGBA PNG/TIFF    depth=16 model=RGB   predict=8   actual=8    exact
opaque 8-bit GRAY PNG   depth=8  model=Gray  predict=1   actual=1    exact
16-bit GRAY PNG         depth=16 model=Gray  predict=2   actual=2    exact
32-bit float RGBA TIFF  depth=32 model=RGB   predict=16  actual=16   exact
CMYK JPEG (opaque)      depth=8  model=CMYK  predict=4   actual=4    exact
animated GIF (3 frames) depth=8  model=RGB   predict=4   actual=4    exact
indexed 8-bit PNG       depth=8  model=RGB   predict=4   actual=1    over (safe)
indexed 1-bit PNG       depth=1  model=RGB   predict=4   actual=1    over (safe)
16-bit RGB, no alpha    depth=16 model=RGB   predict=8   actual=6    over (safe)
junk bytes              0 frames                         refuseUnsizable
truncated PNG (40 B)    no depth, no dimensions          refuseUnsizable

One documentation nuance: the comment asserts component counts are 'what CoreGraphics allocates'. At 16-bit opaque RGB it allocates 6 bytes/px, not 8 — CoreGraphics does have a packed 48-bit format. The guard's number is therefore an upper bound rather than the identity claimed, which is the safe direction but not what the comment says.