diff --git a/src/browser/PngVisualDiff.test.ts b/src/browser/PngVisualDiff.test.ts index 12feb3cdc6..6e61ca902d 100644 --- a/src/browser/PngVisualDiff.test.ts +++ b/src/browser/PngVisualDiff.test.ts @@ -2,6 +2,7 @@ import { crc32, deflateSync } from "node:zlib"; import { describe, expect, it } from "vitest"; +import { readPngHeaderDimensions } from "../domain/pngHeader.js"; import { comparePngScreenshots } from "./PngVisualDiff.js"; import { AnalysisResourceConstraintError, @@ -13,6 +14,48 @@ import { webScreenshotDiffSchema, } from "../domain/webScreenshot.js"; +describe("PNG header admission", () => { + it.each([16, 20])( + "shares dimension admission with header sniffing at %s", + (offset) => { + for (const dimension of [0, 0x8000_0000]) { + const bytes = png(1, 1, [0, 0, 0, 255]); + bytes.writeUInt32BE(dimension, offset); + bytes.writeUInt32BE(crc32(bytes.subarray(12, 29)), 29); + expect(readPngHeaderDimensions(bytes)).toBeUndefined(); + const image = createWebScreenshotArtifact(bytes); + expect(() => + comparePngScreenshots( + compareWebScreenshotsInputSchema.parse({ + before: image, + after: image, + }), + ), + ).toThrow("Invalid PNG header"); + } + }, + ); + + it.each([24, 25, 26, 27, 28])( + "preserves header dimensions for unknown encoding field at %s", + (offset) => { + const bytes = png(1, 1, [0, 0, 0, 255]); + bytes[offset] = 255; + bytes.writeUInt32BE(crc32(bytes.subarray(12, 29)), 29); + expect(readPngHeaderDimensions(bytes)).toEqual({ width: 1, height: 1 }); + const image = createWebScreenshotArtifact(bytes); + expect(() => + comparePngScreenshots( + compareWebScreenshotsInputSchema.parse({ + before: image, + after: image, + }), + ), + ).toThrow("Invalid PNG header"); + }, + ); +}); + describe("PNG visual diff", () => { it("reports exact changed-pixel and channel metrics", () => { const before = artifact(1, 1, [0, 0, 0, 255]); diff --git a/src/browser/PngVisualDiff.ts b/src/browser/PngVisualDiff.ts index d5aa361ea0..9250acf0f4 100644 --- a/src/browser/PngVisualDiff.ts +++ b/src/browser/PngVisualDiff.ts @@ -10,6 +10,7 @@ import { AnalysisUnsupportedTargetError, } from "../domain/analysisErrorCore.js"; import { decodeCanonicalBase64 } from "../domain/webScreenshot.js"; +import { hasPngSignature, readPngIhdrFacts } from "../domain/pngHeader.js"; interface DecodedPng { readonly width: number; @@ -102,7 +103,7 @@ const parsePng = ( field: "before" | "after", ): ParsedPng => { const bytes = decodeCanonicalBase64(artifact.data_base64); - if (bytes === undefined || !bytes.subarray(0, 8).equals(PNG_SIGNATURE)) + if (bytes === undefined || !hasPngSignature(bytes)) throw new TypeError("Invalid PNG signature"); let offset = 8; let header: ReturnType | undefined; @@ -347,11 +348,17 @@ const inflatePng = (compressed: Buffer, maxOutputLength: number): Buffer => { }; const parseHeader = (data: Buffer) => { - if (data.byteLength !== 13) throw new TypeError("Invalid PNG header"); - const width = data.readUInt32BE(0); - const height = data.readUInt32BE(4); - const bitDepth = data[8] ?? 0; - const colorType = data[9] ?? -1; + const parsed = readPngIhdrFacts(data); + if (!parsed.ok) throw new TypeError(parsed.error); + const { + width, + height, + bitDepth, + colorType, + compressionMethod, + filterMethod, + interlaceMethod, + } = parsed.value; const validDepth = (colorType === 0 && [1, 2, 4, 8, 16].includes(bitDepth)) || (colorType === 2 && [8, 16].includes(bitDepth)) || @@ -359,14 +366,10 @@ const parseHeader = (data: Buffer) => { (colorType === 4 && [8, 16].includes(bitDepth)) || (colorType === 6 && [8, 16].includes(bitDepth)); if ( - width === 0 || - height === 0 || - width > 0x7fffffff || - height > 0x7fffffff || !validDepth || - data[10] !== 0 || - data[11] !== 0 || - (data[12] !== 0 && data[12] !== 1) + compressionMethod !== 0 || + filterMethod !== 0 || + (interlaceMethod !== 0 && interlaceMethod !== 1) ) throw new TypeError("Invalid PNG header"); return { @@ -374,10 +377,12 @@ const parseHeader = (data: Buffer) => { height, colorType, bitDepth, - interlace: data[12], + interlace: interlaceMethod, channels: colorType === 6 ? 4 : 3, supported: - bitDepth === 8 && (colorType === 2 || colorType === 6) && data[12] === 0, + bitDepth === 8 && + (colorType === 2 || colorType === 6) && + interlaceMethod === 0, }; }; @@ -491,5 +496,3 @@ const limitations = (): string[] => [ "Only non-interlaced 8-bit RGB and RGBA PNG screenshots are accepted.", "PNG tRNS transparency is accepted only as a six-byte RGB color key; tRNS on RGBA or with another length is rejected.", ]; - -const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); diff --git a/src/domain/pngHeader.ts b/src/domain/pngHeader.ts index 48d55e2901..021edacc97 100644 --- a/src/domain/pngHeader.ts +++ b/src/domain/pngHeader.ts @@ -1,26 +1,62 @@ import { crc32 } from "node:zlib"; +import { err, ok, type Result } from "./result.js"; const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); const IHDR_TYPE = Buffer.from("IHDR"); +/** PNG signature recognition does not establish image validity. */ +export const hasPngSignature = (bytes: Buffer): boolean => + bytes.subarray(0, 8).equals(PNG_SIGNATURE); + +export interface PngIhdrFacts { + readonly width: number; + readonly height: number; + readonly bitDepth: number; + readonly colorType: number; + readonly compressionMethod: number; + readonly filterMethod: number; + readonly interlaceMethod: number; +} + +/** Decode IHDR declarations and admit dimensions; encoding support belongs to consumers. */ +export const readPngIhdrFacts = ( + data: Buffer, +): Result => { + if (data.length !== 13) return err("Invalid PNG header"); + const width = data.readUInt32BE(0); + const height = data.readUInt32BE(4); + if ( + width === 0 || + height === 0 || + width > 0x7fff_ffff || + height > 0x7fff_ffff + ) + return err("Invalid PNG header"); + return ok({ + width, + height, + bitDepth: data[8]!, + colorType: data[9]!, + compressionMethod: data[10]!, + filterMethod: data[11]!, + interlaceMethod: data[12]!, + }); +}; + /** Read screenshot dimensions from a byte-faithful PNG header, not a full image decode. */ export const readPngHeaderDimensions = ( bytes: Buffer, ): { readonly width: number; readonly height: number } | undefined => { if ( bytes.byteLength < 33 || - !bytes.subarray(0, 8).equals(PNG_SIGNATURE) || + !hasPngSignature(bytes) || bytes.readUInt32BE(8) !== 13 || !bytes.subarray(12, 16).equals(IHDR_TYPE) || crc32(bytes.subarray(12, 29)) !== bytes.readUInt32BE(29) ) return undefined; - const width = bytes.readUInt32BE(16); - const height = bytes.readUInt32BE(20); - return width === 0 || - height === 0 || - width > 0x7fff_ffff || - height > 0x7fff_ffff - ? undefined - : { width, height }; + const facts = readPngIhdrFacts(bytes.subarray(16, 29)); + return facts.ok + ? { width: facts.value.width, height: facts.value.height } + : undefined; };