From 9cdddb0b34ff63dd42595fb2ace608cbe7eea9be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:21:53 +0000 Subject: [PATCH 1/3] fix: address H1-H3, M1-M4, M7 TODO items (security, correctness, perf) Agent-Logs-Url: https://github.com/cross-org/image/sessions/363016e9-df1d-4e62-b1e3-88d93ae1a755 Co-authored-by: Hexagon <419737+Hexagon@users.noreply.github.com> --- CHANGELOG.md | 19 +++++++++++++++++++ src/formats/bmp.ts | 4 ++-- src/formats/ico.ts | 8 ++++---- src/formats/pcx.ts | 7 +++++++ src/formats/png.ts | 1 + src/formats/tiff.ts | 18 +++++------------- src/utils/image_processing.ts | 14 +++++++++----- src/utils/jpeg_decoder.ts | 26 +++++++++++++++++++------- 8 files changed, 66 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cfcb38..3c6441a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,25 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `src/utils/gif_decoder.ts` — prevents call-stack overflow (`RangeError: Maximum call stack size exceeded`) on adversarial GIF input +- Security: PCX decoder now calls `validateImageDimensions` after computing width/height from the + header — prevents excessively large unchecked allocations on crafted input +- Correctness: TIFF multi-frame encoder (`encodeFrames`) now caches compressed frame data from the + first pass and reuses it when writing `StripByteCounts` — previously the data was re-compressed + independently, producing a byte-count that could differ from the actual strip data and making the + TIFF unreadable +- Correctness: ICO DIB decoder now uses `Math.floor` when computing `actualHeight` from the stored + double-height field — previously an odd stored height (e.g. 5) produced a non-integer value (2.5) + that caused `validateImageDimensions` to throw on valid ICO files +- Correctness: PNG `decode()` loop now checks `pos + 8 > data.length` before reading each chunk + header — prevents silent mis-decoding of truncated PNG files +- Correctness: BMP `extractMetadata` now uses `readInt32LE` (signed) for the `xPelsPerMeter` / + `yPelsPerMeter` DPI fields, consistent with `decode()` — previously a negative stored DPI was + returned as a large positive value +- Performance: JPEG IDCT now uses a precomputed 8×8 cosine table instead of calling `Math.cos` per + coefficient — eliminates ~200 M `Math.cos` calls when decoding a 2000×2000 JPEG +- Performance: `medianFilter` now allocates the four channel-value arrays once outside the pixel loop + instead of per-pixel — reduces GC pressure on large images +- Fixed misleading comment in `adjustHue`: normalization produces 0–360, not −180 to 180 - PNG decoder: 16-bit per-channel images (bitDepth=16) now decode correctly; the pixel stride was using a fixed 8-bit offset (`x*4`, `x*3`, `x`) causing pixel-offset corruption in 16-bit RGBA, RGB, and grayscale images diff --git a/src/formats/bmp.ts b/src/formats/bmp.ts index 4272bcf..620a3a6 100644 --- a/src/formats/bmp.ts +++ b/src/formats/bmp.ts @@ -257,8 +257,8 @@ export class BMPFormat implements ImageFormat { // DPI information (pixels per meter) if (headerSize >= 40) { - const xPelsPerMeter = readUint32LE(data, 38); - const yPelsPerMeter = readUint32LE(data, 42); + const xPelsPerMeter = readInt32LE(data, 38); + const yPelsPerMeter = readInt32LE(data, 42); if (xPelsPerMeter > 0) { metadata.dpiX = Math.round(xPelsPerMeter * 0.0254); diff --git a/src/formats/ico.ts b/src/formats/ico.ts index f324b09..f8f74c2 100644 --- a/src/formats/ico.ts +++ b/src/formats/ico.ts @@ -141,11 +141,11 @@ export class ICOFormat implements ImageFormat { const bitDepth = readUint16LE(data, 14); const compression = readUint32LE(data, 16); - // Validate dimensions - validateImageDimensions(width, Math.abs(height) / 2); // DIB height includes both XOR and AND mask data - // ICO files store height as 2x actual height (for AND mask) - const actualHeight = Math.abs(height) / 2; + const actualHeight = Math.floor(Math.abs(height) / 2); + + // Validate dimensions + validateImageDimensions(width, actualHeight); // DIB height includes both XOR and AND mask data // Only support uncompressed DIBs if (compression !== 0) { diff --git a/src/formats/pcx.ts b/src/formats/pcx.ts index 9333f4b..e44c405 100644 --- a/src/formats/pcx.ts +++ b/src/formats/pcx.ts @@ -1,4 +1,5 @@ import type { ImageData, ImageDecoderOptions, ImageFormat, ImageMetadata } from "../types.ts"; +import { validateImageDimensions } from "../utils/security.ts"; /** * PCX format handler @@ -53,6 +54,12 @@ export class PCXFormat implements ImageFormat { return Promise.reject(new Error("Invalid PCX dimensions")); } + try { + validateImageDimensions(width, height); + } catch (e) { + return Promise.reject(e); + } + // Decode RLE data let offset = 128; const scanlineLength = nPlanes * bytesPerLine; diff --git a/src/formats/png.ts b/src/formats/png.ts index 1b5f8c4..f10c0c5 100644 --- a/src/formats/png.ts +++ b/src/formats/png.ts @@ -57,6 +57,7 @@ export class PNGFormat extends PNGBase implements ImageFormat { // Parse chunks while (pos < data.length) { + if (pos + 8 > data.length) break; const length = this.readUint32(data, pos); pos += 4; const type = String.fromCharCode( diff --git a/src/formats/tiff.ts b/src/formats/tiff.ts index ecb54ad..7905bf4 100644 --- a/src/formats/tiff.ts +++ b/src/formats/tiff.ts @@ -573,8 +573,9 @@ export class TIFFFormat implements ImageFormat { let currentOffset = 8; const ifdOffsets: number[] = []; const pixelDataOffsets: number[] = []; + const compressedFrames: Uint8Array[] = []; - // Write all pixel data first + // Write all pixel data first, caching the compressed results for (const frame of imageData.frames) { pixelDataOffsets.push(currentOffset); @@ -590,6 +591,7 @@ export class TIFFFormat implements ImageFormat { pixelData = frame.data; } + compressedFrames.push(pixelData); for (let i = 0; i < pixelData.length; i++) { result.push(pixelData[i]); } @@ -651,18 +653,8 @@ export class TIFFFormat implements ImageFormat { // RowsPerStrip this.writeIFDEntry(result, 0x0116, 4, 1, frame.height); - // StripByteCounts - let pixelDataSize: number; - if (compression === "lzw") { - pixelDataSize = new TIFFLZWEncoder().compress(frame.data).length; - } else if (compression === "packbits") { - pixelDataSize = packBitsCompress(frame.data).length; - } else if (compression === "deflate") { - pixelDataSize = (await deflateCompress(frame.data)).length; - } else { - pixelDataSize = frame.data.length; - } - this.writeIFDEntry(result, 0x0117, 4, 1, pixelDataSize); + // StripByteCounts — use the cached compressed length from the first pass + this.writeIFDEntry(result, 0x0117, 4, 1, compressedFrames[i].length); // XResolution const xResOffset = dataOffset; diff --git a/src/utils/image_processing.ts b/src/utils/image_processing.ts index 1831a46..9b355dc 100644 --- a/src/utils/image_processing.ts +++ b/src/utils/image_processing.ts @@ -300,7 +300,7 @@ function hslToRgb(h: number, s: number, l: number): [number, number, number] { */ export function adjustHue(data: Uint8Array, degrees: number): Uint8Array { const result = new Uint8Array(data.length); - // Normalize rotation to -180 to 180 range + // Normalize rotation to 0–360 range const rotation = ((degrees % 360) + 360) % 360; for (let i = 0; i < data.length; i += 4) { @@ -723,13 +723,17 @@ export function medianFilter( ): Uint8Array { const result = new Uint8Array(data.length); const clampedRadius = Math.max(1, Math.floor(radius)); + const rValues: number[] = []; + const gValues: number[] = []; + const bValues: number[] = []; + const aValues: number[] = []; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { - const rValues: number[] = []; - const gValues: number[] = []; - const bValues: number[] = []; - const aValues: number[] = []; + rValues.length = 0; + gValues.length = 0; + bValues.length = 0; + aValues.length = 0; // Collect values in kernel window for (let ky = -clampedRadius; ky <= clampedRadius; ky++) { diff --git a/src/utils/jpeg_decoder.ts b/src/utils/jpeg_decoder.ts index b4f57fa..df92941 100644 --- a/src/utils/jpeg_decoder.ts +++ b/src/utils/jpeg_decoder.ts @@ -137,6 +137,20 @@ interface JPEGDecoderOptions extends ImageDecoderOptions { extractCoefficients?: boolean; } +/** + * Precomputed IDCT cosine table: IDCT_COS[k][n] = cos((2n+1)*k*PI/16) + * Eliminates all Math.cos calls from the IDCT hot path. + */ +const IDCT_COS: Float64Array[] = Array.from( + { length: 8 }, + (_, k) => + Float64Array.from( + { length: 8 }, + (_, n) => Math.cos((2 * n + 1) * k * Math.PI / 16), + ), +); +const IDCT_SCALE = 1 / Math.sqrt(2); + export class JPEGDecoder { private data: Uint8Array; private pos: number = 0; @@ -959,8 +973,7 @@ export class JPEGDecoder { } private idct(block: number[] | Int32Array): void { - // Simplified 2D IDCT - // This is a basic implementation - not optimized + // 2D IDCT using precomputed cosine table — no Math.cos in the hot path const temp = new Float32Array(64); // 1D IDCT on rows @@ -970,9 +983,8 @@ export class JPEGDecoder { for (let j = 0; j < 8; j++) { let sum = 0; for (let k = 0; k < 8; k++) { - const c = k === 0 ? 1 / Math.sqrt(2) : 1; - sum += c * block[offset + k] * - Math.cos((2 * j + 1) * k * Math.PI / 16); + const c = k === 0 ? IDCT_SCALE : 1; + sum += c * block[offset + k] * IDCT_COS[k][j]; } temp[offset + j] = sum / 2; } @@ -983,8 +995,8 @@ export class JPEGDecoder { for (let i = 0; i < 8; i++) { let sum = 0; for (let k = 0; k < 8; k++) { - const c = k === 0 ? 1 / Math.sqrt(2) : 1; - sum += c * temp[k * 8 + j] * Math.cos((2 * i + 1) * k * Math.PI / 16); + const c = k === 0 ? IDCT_SCALE : 1; + sum += c * temp[k * 8 + j] * IDCT_COS[k][i]; } // Level shift and clamp block[i * 8 + j] = Math.max( From 5a988328dfb0d183bff5f6fc7704b643e515c3dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:23:23 +0000 Subject: [PATCH 2/3] fix: CHANGELOG notation typo (200 M -> 200M) Agent-Logs-Url: https://github.com/cross-org/image/sessions/363016e9-df1d-4e62-b1e3-88d93ae1a755 Co-authored-by: Hexagon <419737+Hexagon@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c6441a..08868b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `yPelsPerMeter` DPI fields, consistent with `decode()` — previously a negative stored DPI was returned as a large positive value - Performance: JPEG IDCT now uses a precomputed 8×8 cosine table instead of calling `Math.cos` per - coefficient — eliminates ~200 M `Math.cos` calls when decoding a 2000×2000 JPEG + coefficient — eliminates ~200M `Math.cos` calls when decoding a 2000×2000 JPEG - Performance: `medianFilter` now allocates the four channel-value arrays once outside the pixel loop instead of per-pixel — reduces GC pressure on large images - Fixed misleading comment in `adjustHue`: normalization produces 0–360, not −180 to 180 From 1ad7b766b85271d63c19d4e32ff38663f85bdbaf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:49:01 +0000 Subject: [PATCH 3/3] fix: apply deno fmt to CHANGELOG.md (CI formatting failure) Agent-Logs-Url: https://github.com/cross-org/image/sessions/917a56d1-bf05-42f8-84cc-96db29a01a88 Co-authored-by: Hexagon <419737+Hexagon@users.noreply.github.com> --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08868b0..c211684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,8 +45,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). returned as a large positive value - Performance: JPEG IDCT now uses a precomputed 8×8 cosine table instead of calling `Math.cos` per coefficient — eliminates ~200M `Math.cos` calls when decoding a 2000×2000 JPEG -- Performance: `medianFilter` now allocates the four channel-value arrays once outside the pixel loop - instead of per-pixel — reduces GC pressure on large images +- Performance: `medianFilter` now allocates the four channel-value arrays once outside the pixel + loop instead of per-pixel — reduces GC pressure on large images - Fixed misleading comment in `adjustHue`: normalization produces 0–360, not −180 to 180 - PNG decoder: 16-bit per-channel images (bitDepth=16) now decode correctly; the pixel stride was using a fixed 8-bit offset (`x*4`, `x*3`, `x`) causing pixel-offset corruption in 16-bit RGBA,