From b3e977e6d073ab1783cece8581279f87fc360411 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:03:39 +0800 Subject: [PATCH] fix(browser): bound screenshot comparison memory --- docs/browser-observation.md | 23 +++ src/browser/PngVisualDiff.test.ts | 46 ++++- src/browser/PngVisualDiff.ts | 170 +++++++++++++++--- .../cli/browserCompareJsonFiles.test.ts | 35 ++++ tests/boundary/mcp/browserMcp.test.ts | 37 ++++ 5 files changed, 289 insertions(+), 22 deletions(-) diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 3cdac5aa1..2294e117c 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -24,6 +24,29 @@ REA can attach to a user-owned Chrome-family browser through the Chrome DevTools Target discovery returns the complete in-scope target array in one result. +## Screenshot comparison memory budget + +`compare_web_screenshots` admits both PNGs against a 256 MiB estimated +working-memory budget before inflating either image. This is a processing +buffer budget for this operation, not a PNG dimension limit, CDP transport +limit, or process RSS guarantee. Header-valid dimensions that exceed the +budget return `resource_constraint` with `details.resource: "memory"` and the +estimated and maximum working-set bytes. Invalid PNG structure remains +malformed input; encodings outside supported pixel-comparison coverage remain +unsupported-target errors. + +The conservative estimate includes the two decoder-owned decoded input +buffers, the current image's concatenated IDAT buffer, Node zlib's default +16 KiB output chunk backing rounded up for retained chunks, the concatenated +inflate result while its output chunks are retained, one possible extra output +chunk before zlib checks `maxOutputLength`, unfiltered scanline storage, +RGB-to-RGBA expansion, and the first retained RGBA image while the second is +decoded. RGBA output aliases its unfiltered buffer and is counted once. +Admission uses checked integer arithmetic before image-sized allocation. +Caller-owned JSON/base64 strings, input parsing and validation, transport +buffers, garbage-collector behavior, and other runtime memory are outside this +estimate; the budget does not promise a bound on total process memory. + Electron `file://` pages use a separate provider and target boundary; see [electron-observation.md](electron-observation.md). ## Provider authorities diff --git a/src/browser/PngVisualDiff.test.ts b/src/browser/PngVisualDiff.test.ts index 8a4fc6f87..12feb3cdc 100644 --- a/src/browser/PngVisualDiff.test.ts +++ b/src/browser/PngVisualDiff.test.ts @@ -3,7 +3,10 @@ import { crc32, deflateSync } from "node:zlib"; import { describe, expect, it } from "vitest"; import { comparePngScreenshots } from "./PngVisualDiff.js"; -import { AnalysisUnsupportedTargetError } from "../domain/analysisErrorCore.js"; +import { + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../domain/analysisErrorCore.js"; import { compareWebScreenshotsInputSchema, createWebScreenshotArtifact, @@ -118,7 +121,7 @@ describe("PNG visual diff", () => { }); it("rejects malformed PNG dimensions after validating image data", () => { - const image = artifact(32_000_001, 1, [0, 0, 0, 255]); + const image = artifact(2, 1, [0, 0, 0, 255]); expect(() => comparePngScreenshots( compareWebScreenshotsInputSchema.parse({ @@ -150,6 +153,32 @@ describe("PNG visual diff", () => { }); }); +describe("PNG comparison memory budget", () => { + it("classifies legal huge dimensions as a memory constraint before inflate", () => { + const image = createWebScreenshotArtifact(headerOnlyPng(0x7fffffff, 1)); + const input = compareWebScreenshotsInputSchema.parse({ + before: image, + after: image, + }); + + try { + comparePngScreenshots(input); + throw new Error("expected PNG working-memory rejection"); + } catch (cause: unknown) { + expect(cause).toBeInstanceOf(AnalysisResourceConstraintError); + expect(cause).toMatchObject({ + resource: "memory", + reportedLimits: { + maximum_working_memory_bytes: 256 * 1024 * 1024, + estimated_working_memory_bytes: "34359738499", + before_dimensions: { width: 0x7fffffff, height: 1 }, + after_dimensions: { width: 0x7fffffff, height: 1 }, + }, + }); + } + }); +}); + describe("PNG chunk integrity", () => { it.each(["IHDR", "IDAT", "IEND"])( "rejects a corrupt %s chunk checksum", @@ -398,6 +427,19 @@ const png = ( ]); }; +const headerOnlyPng = (width: number, height: number): Buffer => { + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header.set([8, 6, 0, 0, 0], 8); + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk("IHDR", header), + chunk("IDAT", deflateSync(Buffer.from([0]))), + chunk("IEND", Buffer.alloc(0)), + ]); +}; + const chunk = (type: string, data: Buffer): Buffer => { const result = Buffer.alloc(12 + data.byteLength); result.writeUInt32BE(data.byteLength, 0); diff --git a/src/browser/PngVisualDiff.ts b/src/browser/PngVisualDiff.ts index dc570bada..d5aa361ea 100644 --- a/src/browser/PngVisualDiff.ts +++ b/src/browser/PngVisualDiff.ts @@ -1,11 +1,14 @@ -import { crc32, inflateSync } from "node:zlib"; +import { constants as zlibConstants, crc32, inflateSync } from "node:zlib"; import type { CompareWebScreenshotsInput, WebScreenshotArtifact, WebScreenshotDiff, } from "../domain/webScreenshot.js"; -import { AnalysisUnsupportedTargetError } from "../domain/analysisErrorCore.js"; +import { + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../domain/analysisErrorCore.js"; import { decodeCanonicalBase64 } from "../domain/webScreenshot.js"; interface DecodedPng { @@ -14,12 +17,32 @@ interface DecodedPng { readonly rgba: Buffer; } +interface ParsedPng { + readonly bytes: Buffer; + readonly width: number; + readonly height: number; + readonly channels: number; + readonly compressed: readonly Buffer[]; + readonly transparentColor: readonly number[] | undefined; + readonly expectedRawBytes: bigint; + readonly unfilteredBytes: bigint; + readonly rgbaBytes: bigint; +} + +/** Conservative allowance for the simultaneously live PNG comparison buffers. */ +const PNG_COMPARISON_WORKING_MEMORY_BYTES = 256 * 1024 * 1024; +const PNG_INFLATE_CHUNK_BYTES = zlibConstants.Z_DEFAULT_CHUNK; + /** Compare two validated screenshots using deterministic RGBA metrics. */ export const comparePngScreenshots = ( input: CompareWebScreenshotsInput, ): WebScreenshotDiff => { - const before = decodePng(input.before, "before"); - const after = decodePng(input.after, "after"); + admitEncodedInputPair(input.before, input.after); + const beforePlan = parsePng(input.before, "before"); + const afterPlan = parsePng(input.after, "after"); + admitPngPair(beforePlan, afterPlan); + const before = decodePng(beforePlan); + const after = decodePng(afterPlan); if (before.width !== after.width || before.height !== after.height) return { status: "dimension_mismatch", @@ -74,10 +97,10 @@ export const comparePngScreenshots = ( }; }; -const decodePng = ( +const parsePng = ( artifact: WebScreenshotArtifact, field: "before" | "after", -): DecodedPng => { +): ParsedPng => { const bytes = decodeCanonicalBase64(artifact.data_base64); if (bytes === undefined || !bytes.subarray(0, 8).equals(PNG_SIGNATURE)) throw new TypeError("Invalid PNG signature"); @@ -180,24 +203,136 @@ const decodePng = ( field, "PNG encoding is not supported for pixel comparison.", ); - const rowBytes = header.width * header.channels; - const expected = (rowBytes + 1) * header.height; - const raw = inflatePng(Buffer.concat(compressed), expected + 1); - if (raw.byteLength !== expected) throw new TypeError("Unexpected PNG size"); + const pixels = BigInt(header.width) * BigInt(header.height); + const expectedRawBytes = + (BigInt(header.width) * BigInt(header.channels) + 1n) * + BigInt(header.height); return { + bytes, width: header.width, height: header.height, + channels: header.channels, + compressed, + transparentColor, + expectedRawBytes, + unfilteredBytes: pixels * BigInt(header.channels), + rgbaBytes: pixels * 4n, + }; +}; + +const decodePng = (plan: ParsedPng): DecodedPng => { + const expected = Number(plan.expectedRawBytes); + const raw = inflatePng(Buffer.concat(plan.compressed), expected + 1); + if (raw.byteLength !== expected) throw new TypeError("Unexpected PNG size"); + return { + width: plan.width, + height: plan.height, rgba: expandRgba( - unfilter(raw, header.width, header.height, header.channels), - header.channels, - transparentColor, + unfilter(raw, plan.width, plan.height, plan.channels), + plan.channels, + plan.transparentColor, ), }; }; +const admitEncodedInputPair = ( + before: WebScreenshotArtifact, + after: WebScreenshotArtifact, +): void => { + const sourceBytes = BigInt(before.bytes) + BigInt(after.bytes); + if (sourceBytes > BigInt(PNG_COMPARISON_WORKING_MEMORY_BYTES)) + throw pngWorkingMemoryError(sourceBytes, null, null); +}; + +const admitPngPair = (before: ParsedPng, after: ParsedPng): void => { + // Input buffers remain live for both plans. For each decode, count the + // concatenated IDAT, rounded zlib output chunks, any concatenated output and + // unfiltered rows, plus RGB-to-RGBA output. The first completed RGBA image + // remains live while the second is decoded. + const inputBytes = BigInt(before.bytes.byteLength + after.bytes.byteLength); + const estimate = (current: ParsedPng, retained: ParsedPng | undefined) => { + const compressedBytes = current.compressed.reduce( + (total, chunk) => total + BigInt(chunk.byteLength), + 0n, + ); + const rgbaExpansion = current.channels === 3 ? current.rgbaBytes : 0n; + return ( + inputBytes + + (retained?.rgbaBytes ?? 0n) + + compressedBytes + + inflateBufferAllowance(current.expectedRawBytes) + + current.unfilteredBytes + + rgbaExpansion + ); + }; + const estimateBytes = [ + estimate(before, undefined), + estimate(after, before), + ].reduce((maximum, value) => (value > maximum ? value : maximum), 0n); + if (estimateBytes > BigInt(PNG_COMPARISON_WORKING_MEMORY_BYTES)) + throw pngWorkingMemoryError(estimateBytes, before, after); +}; + +const inflateBufferAllowance = (expectedRawBytes: bigint): bigint => { + const chunkBytes = BigInt(PNG_INFLATE_CHUNK_BYTES); + const maximumOutputBytes = expectedRawBytes + 1n; + const roundUpToChunk = (bytes: bigint): bigint => + ((bytes + chunkBytes - 1n) / chunkBytes) * chunkBytes; + + // Node checks maxOutputLength after writing a chunk, so a rejected stream can + // retain one chunk's worth of output past the limit before it throws. + const rejectedOutputChunks = roundUpToChunk( + maximumOutputBytes + chunkBytes - 1n, + ); + + // Sync zlib retains each output chunk while Buffer.concat creates the final + // result. A full final chunk also allocates the next scratch chunk first. + const returnedOutputChunks = roundUpToChunk(maximumOutputBytes); + const scratchChunk = + expectedRawBytes % chunkBytes === 0n || + maximumOutputBytes % chunkBytes === 0n + ? chunkBytes + : 0n; + const concatenatedOutput = + maximumOutputBytes > chunkBytes ? maximumOutputBytes : 0n; + const returnedOutput = + returnedOutputChunks + scratchChunk + concatenatedOutput; + + return rejectedOutputChunks > returnedOutput + ? rejectedOutputChunks + : returnedOutput; +}; + +const pngWorkingMemoryError = ( + estimateBytes: bigint, + before: Pick | null, + after: Pick | null, +) => + new AnalysisResourceConstraintError( + "compare_web_screenshots", + "memory", + "Screenshot comparison exceeds REA's 256 MiB estimated working-memory budget.", + { + boundary: "png-screenshot-comparison", + estimated_working_memory_bytes: estimateBytes.toString(), + maximum_working_memory_bytes: PNG_COMPARISON_WORKING_MEMORY_BYTES, + before_dimensions: + before === null ? null : { width: before.width, height: before.height }, + after_dimensions: + after === null ? null : { width: after.width, height: after.height }, + }, + { + remediationAction: + "Compare smaller screenshots or reduce their viewport dimensions before capture.", + }, + ); + const inflatePng = (compressed: Buffer, maxOutputLength: number): Buffer => { try { - return inflateSync(compressed, { maxOutputLength }); + return inflateSync(compressed, { + maxOutputLength, + chunkSize: PNG_INFLATE_CHUNK_BYTES, + }); } catch (cause: unknown) { if ( cause instanceof Error && @@ -217,8 +352,6 @@ const parseHeader = (data: Buffer) => { const height = data.readUInt32BE(4); const bitDepth = data[8] ?? 0; const colorType = data[9] ?? -1; - const safePixelCount = - BigInt(width) * BigInt(height) <= BigInt(Number.MAX_SAFE_INTEGER); const validDepth = (colorType === 0 && [1, 2, 4, 8, 16].includes(bitDepth)) || (colorType === 2 && [8, 16].includes(bitDepth)) || @@ -244,10 +377,7 @@ const parseHeader = (data: Buffer) => { interlace: data[12], channels: colorType === 6 ? 4 : 3, supported: - safePixelCount && - bitDepth === 8 && - (colorType === 2 || colorType === 6) && - data[12] === 0, + bitDepth === 8 && (colorType === 2 || colorType === 6) && data[12] === 0, }; }; diff --git a/tests/boundary/cli/browserCompareJsonFiles.test.ts b/tests/boundary/cli/browserCompareJsonFiles.test.ts index fbaed83cf..497101feb 100644 --- a/tests/boundary/cli/browserCompareJsonFiles.test.ts +++ b/tests/boundary/cli/browserCompareJsonFiles.test.ts @@ -197,4 +197,39 @@ const assertPngInputErrorProjections = async (): Promise => { code: "unsupported_target", details: { operation: "compare_web_screenshots" }, }); + + const oversizedHeader = Buffer.alloc(13); + oversizedHeader.writeUInt32BE(0x7fffffff, 0); + oversizedHeader.writeUInt32BE(1, 4); + oversizedHeader.set([8, 6, 0, 0, 0], 8); + const hugeDimensions = JSON.stringify( + createWebScreenshotArtifact( + Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk("IHDR", oversizedHeader), + pngChunk("IDAT", deflateSync(Buffer.from([0]))), + pngChunk("IEND", Buffer.alloc(0)), + ]), + ), + ); + expect( + await runCli([ + "compare-web-screenshots", + hugeDimensions, + hugeDimensions, + "--json", + ]), + ).toMatchObject({ + code: "resource_constraint", + details: { + operation: "compare_web_screenshots", + resource: "memory", + reported_limits: { + maximum_working_memory_bytes: 256 * 1024 * 1024, + estimated_working_memory_bytes: "34359738499", + before_dimensions: { width: 0x7fffffff, height: 1 }, + after_dimensions: { width: 0x7fffffff, height: 1 }, + }, + }, + }); }; diff --git a/tests/boundary/mcp/browserMcp.test.ts b/tests/boundary/mcp/browserMcp.test.ts index 9c0bd9dfc..db2ba8980 100644 --- a/tests/boundary/mcp/browserMcp.test.ts +++ b/tests/boundary/mcp/browserMcp.test.ts @@ -361,6 +361,43 @@ it("does not attach to a target outside the request's allowed origin scope", asy expect(browser.commands).toHaveLength(0); }); +it("projects legal oversized PNG dimensions as a memory constraint", async () => { + const header = Buffer.alloc(13); + header.writeUInt32BE(0x7fffffff, 0); + header.writeUInt32BE(1, 4); + header.set([8, 6, 0, 0, 0], 8); + const artifact = createWebScreenshotArtifact( + Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk("IHDR", header), + pngChunk("IDAT", deflateSync(Buffer.from([0]))), + pngChunk("IEND", Buffer.alloc(0)), + ]), + ); + const connected = await connectBrowser(); + const result = await connected.client.callTool({ + name: "compare_web_screenshots", + arguments: { before: artifact, after: artifact }, + }); + + expect(parseMcpToolError(result)).toMatchObject({ + error: { + code: "resource_constraint", + category: "resource_constraint", + details: { + operation: "compare_web_screenshots", + resource: "memory", + reported_limits: { + maximum_working_memory_bytes: 256 * 1024 * 1024, + estimated_working_memory_bytes: "34359738499", + before_dimensions: { width: 0x7fffffff, height: 1 }, + after_dimensions: { width: 0x7fffffff, height: 1 }, + }, + }, + }, + }); +}); + const connectBrowser = async () => { const session = createTestBinarySession(() => ({ execute: () => Promise.resolve(observed(null)),