diff --git a/src/shared/raster-image-base64-preview.test.ts b/src/shared/raster-image-base64-preview.test.ts new file mode 100644 index 00000000000..af4d9de118a --- /dev/null +++ b/src/shared/raster-image-base64-preview.test.ts @@ -0,0 +1,345 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { decodeBase64Prefix, exceedsRasterImagePreviewLimits } from './raster-image-base64-preview' +import type * as RasterImageDimensionsModule from './raster-image-dimensions' +import { readRasterImageDimensions } from './raster-image-dimensions' +import { + isKnownRasterImageMimeType, + isRasterImagePreviewDimensions, + RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES +} from './raster-image-preview-limits' + +/** The pre-change verdict: one full-payload decode, then one dimension read. */ +function unprobedExceeds(content: string, mimeType: string | undefined): boolean { + if (!isKnownRasterImageMimeType(mimeType)) { + return false + } + const prefix = referenceDecodeBase64Prefix(content, RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES) + if (!prefix) { + return false + } + const dimensions = readRasterImageDimensions(prefix) + return dimensions !== null && !isRasterImagePreviewDimensions(dimensions) +} + +// Why a module mock: the byte length handed to the dimension reader is the direct measure of how +// much of the payload the preview check decoded, and it is the only observable difference between +// the early-stopping probe and the full-payload decode it replaces. +const { dimensionReadLengths } = vi.hoisted(() => ({ dimensionReadLengths: [] as number[] })) + +vi.mock('./raster-image-dimensions', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readRasterImageDimensions: (bytes: Uint8Array) => { + dimensionReadLengths.push(bytes.byteLength) + return actual.readRasterImageDimensions(bytes) + } + } +}) + +// ── Reference decoder: the pre-change implementation, verbatim ────────────────────────────────── +const BASE64_PADDING = -2 +const INVALID_BASE64 = -1 + +function base64Value(code: number): number { + if (code >= 65 && code <= 90) { + return code - 65 + } + if (code >= 97 && code <= 122) { + return code - 71 + } + if (code >= 48 && code <= 57) { + return code + 4 + } + if (code === 43) { + return 62 + } + if (code === 47) { + return 63 + } + if (code === 61) { + return BASE64_PADDING + } + return INVALID_BASE64 +} + +function isWhitespace(code: number): boolean { + return code === 9 || code === 10 || code === 12 || code === 13 || code === 32 +} + +function referenceWriteQuartet( + output: Uint8Array, + offset: number, + quartet: readonly number[] +): { bytesWritten: number; padded: boolean } | null { + const [a, b, c, d] = quartet + if (a === undefined || b === undefined || a < 0 || b < 0) { + return null + } + if (c === BASE64_PADDING) { + if (d !== BASE64_PADDING) { + return null + } + if (offset < output.length) { + output[offset] = (a << 2) | (b >> 4) + } + return { bytesWritten: Math.min(1, output.length - offset), padded: true } + } + if (c === undefined || c < 0) { + return null + } + if (offset < output.length) { + output[offset] = (a << 2) | (b >> 4) + } + if (offset + 1 < output.length) { + output[offset + 1] = ((b & 15) << 4) | (c >> 2) + } + if (d === BASE64_PADDING) { + return { bytesWritten: Math.min(2, output.length - offset), padded: true } + } + if (d === undefined || d < 0) { + return null + } + if (offset + 2 < output.length) { + output[offset + 2] = ((c & 3) << 6) | d + } + return { bytesWritten: Math.min(3, output.length - offset), padded: false } +} + +function referenceDecodeBase64Prefix(content: string, maxBytes: number): Uint8Array | null { + const capacity = Math.min(maxBytes, Math.ceil(content.length / 4) * 3) + const output = new Uint8Array(capacity) + const quartet: number[] = [] + let outputLength = 0 + let padded = false + + for (let index = 0; index < content.length && outputLength < capacity; index += 1) { + const code = content.charCodeAt(index) + if (isWhitespace(code)) { + continue + } + if (padded) { + return null + } + const value = base64Value(code) + if (value === INVALID_BASE64) { + return null + } + quartet.push(value) + if (quartet.length !== 4) { + continue + } + const decoded = referenceWriteQuartet(output, outputLength, quartet) + if (!decoded) { + return null + } + outputLength += decoded.bytesWritten + padded = decoded.padded + quartet.length = 0 + } + + if (!padded && outputLength < capacity && quartet.length > 0) { + if (quartet.length === 1 || quartet.includes(BASE64_PADDING)) { + return null + } + while (quartet.length < 4) { + quartet.push(BASE64_PADDING) + } + const decoded = referenceWriteQuartet(output, outputLength, quartet) + if (!decoded) { + return null + } + outputLength += decoded.bytesWritten + } + return output.subarray(0, outputLength) +} + +// ── Fixtures ─────────────────────────────────────────────────────────────────────────────────── +function pngBytes(totalBytes: number, width: number, height: number): Buffer { + const bytes = Buffer.alloc(Math.max(totalBytes, 24)) + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes) + bytes.writeUInt32BE(13, 8) + bytes.write('IHDR', 12, 'ascii') + bytes.writeUInt32BE(width, 16) + bytes.writeUInt32BE(height, 20) + for (let index = 24; index < bytes.length; index += 1) { + bytes[index] = (index * 31 + 7) & 0xff + } + return bytes +} + +/** SOI, `metadataBytes` of APP2 padding (real cameras chain many segments), then SOF0. */ +function jpegBytes( + metadataBytes: number, + width: number, + height: number, + trailingBytes = 4096 +): Buffer { + const parts: Buffer[] = [Buffer.from([0xff, 0xd8])] + for (let written = 0; written < metadataBytes;) { + const size = Math.min(65_533, metadataBytes - written) + const header = Buffer.alloc(4) + header.writeUInt16BE(0xffe2) + header.writeUInt16BE(size + 2, 2) + parts.push(header, Buffer.alloc(size)) + written += size + } + const sof = Buffer.alloc(11) + sof.writeUInt16BE(0xffc0) + sof.writeUInt16BE(8, 2) + sof[4] = 8 + sof.writeUInt16BE(height, 5) + sof.writeUInt16BE(width, 7) + parts.push(sof, Buffer.alloc(trailingBytes)) + return Buffer.concat(parts) +} + +function gifBytes(width: number, height: number): Buffer { + const gif = Buffer.alloc(64) + gif.write('GIF89a', 0, 'ascii') + gif.writeUInt16LE(width, 6) + gif.writeUInt16LE(height, 8) + return gif +} + +function webpBytes(width: number, height: number): Buffer { + const webp = Buffer.alloc(64) + webp.write('RIFF', 0, 'ascii') + webp.writeUInt32LE(50, 4) + webp.write('WEBP', 8, 'ascii') + webp.write('VP8X', 12, 'ascii') + webp.writeUInt32LE(10, 16) + webp.writeUIntLE(width - 1, 24, 3) + webp.writeUIntLE(height - 1, 27, 3) + return webp +} + +const DECODE_FIXTURES: { label: string; content: string }[] = [ + { label: 'png', content: pngBytes(24, 512, 512).toString('base64') }, + { label: 'png padded once', content: pngBytes(26, 512, 512).toString('base64') }, + { label: 'png padded twice', content: pngBytes(25, 512, 512).toString('base64') }, + { label: 'png 70 KiB', content: pngBytes(70_000, 512, 512).toString('base64') }, + { label: 'jpeg', content: jpegBytes(0, 640, 480).toString('base64') }, + { label: 'jpeg 70 KiB exif', content: jpegBytes(70_000, 4000, 3000).toString('base64') }, + { label: 'gif', content: gifBytes(320, 240).toString('base64') }, + { label: 'webp', content: webpBytes(800, 600).toString('base64') }, + { label: 'empty', content: '' }, + { label: 'one character', content: 'A' }, + { label: 'two characters', content: 'AB' }, + { label: 'three characters', content: 'ABC' }, + { label: 'invalid character', content: 'AB*D' }, + { label: 'invalid tail', content: `${pngBytes(24, 4, 4).toString('base64')}!!!` }, + { label: 'padding mid-payload', content: 'AAAA=AAA' }, + { label: 'lone padding in tail', content: 'AAAAAB=' }, + { label: 'single padding', content: 'AAAAAA==' }, + { label: 'double padding', content: 'AAAAAAA=' }, + { label: 'stray padding after padding', content: 'AAAA====' }, + { + label: 'line-wrapped png', + content: pngBytes(70_000, 512, 512) + .toString('base64') + .replace(/(.{76})/g, '$1\r\n') + }, + { label: 'leading and trailing whitespace', content: `\n\t ${'AAAA'} \r\n` }, + { label: 'truncated png header', content: pngBytes(24, 512, 512).toString('base64').slice(0, 18) } +] + +const DECODE_CAPS = [ + 0, + 1, + 2, + 3, + 4, + 23, + 24, + 25, + 63, + 64, + 65, + 1024, + RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES +] + +describe('decodeBase64Prefix', () => { + it('decodes byte-for-byte identically to the reference implementation', () => { + for (const { label, content } of DECODE_FIXTURES) { + for (const maxBytes of DECODE_CAPS) { + const expected = referenceDecodeBase64Prefix(content, maxBytes) + const actual = decodeBase64Prefix(content, maxBytes) + const detail = `${label} @ maxBytes=${maxBytes}` + if (expected === null) { + expect(actual, detail).toBeNull() + continue + } + expect(actual, detail).not.toBeNull() + expect(Array.from(actual!), detail).toEqual(Array.from(expected)) + } + } + }) + + it('stops at the byte cap instead of decoding the whole payload', () => { + const content = pngBytes(70_000, 512, 512).toString('base64') + expect(decodeBase64Prefix(content, 32)?.byteLength).toBe(32) + }) +}) + +describe('exceedsRasterImagePreviewLimits', () => { + beforeEach(() => { + dimensionReadLengths.length = 0 + }) + + it('measures a large image from its first bytes, not its last', () => { + // Regression guard: before the probe this decoded all 5 MiB before reading 24 bytes of IHDR. + const content = pngBytes(5 * 1024 * 1024, 512, 512).toString('base64') + expect(exceedsRasterImagePreviewLimits(content, 'image/png')).toBe(false) + expect(dimensionReadLengths).toEqual([64]) + }) + + it('widens the probe until a JPEG SOF past its metadata is reachable', () => { + const content = jpegBytes(70_000, 4000, 3000, 3_000_000).toString('base64') + expect(exceedsRasterImagePreviewLimits(content, 'image/jpeg')).toBe(false) + expect(dimensionReadLengths).toEqual([64, 1024, 16_384, 262_144]) + // Far below the ~3 MiB the payload decodes to, and below the 8 MiB fallback cap. + expect(dimensionReadLengths.at(-1)!).toBeLessThan(RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES) + }) + + it('re-reads the whole payload before suppressing an over-limit image', () => { + const content = pngBytes(70_000, 32_769, 1).toString('base64') + expect(exceedsRasterImagePreviewLimits(content, 'image/png')).toBe(true) + // The header answers at 64 bytes, but a suppression verdict is only taken from the same + // full-payload decode the unprobed implementation used, so invalid base64 past the header + // still demotes the answer to "could not measure". + expect(dimensionReadLengths).toEqual([64, 70_000]) + }) + + it('keeps rendering an over-limit header whose payload is not valid base64', () => { + const content = `${pngBytes(70_000, 32_769, 1).toString('base64')}!!!` + expect(exceedsRasterImagePreviewLimits(content, 'image/png')).toBe(false) + }) + + it('agrees with the unprobed implementation on every fixture and mime type', () => { + const mimeTypes = [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + 'image/svg+xml', + undefined + ] + const fixtures = [ + ...DECODE_FIXTURES, + { label: 'over-limit png', content: pngBytes(24, 32_769, 1).toString('base64') }, + { label: 'over-limit pixels png', content: pngBytes(24, 8192, 8192).toString('base64') }, + { label: 'over-limit gif', content: gifBytes(65_535, 65_535).toString('base64') }, + { label: 'over-limit jpeg', content: jpegBytes(70_000, 40_000, 40_000).toString('base64') }, + { label: 'over-limit webp', content: webpBytes(40_000, 40_000).toString('base64') } + ] + for (const { label, content } of fixtures) { + for (const mimeType of mimeTypes) { + expect(exceedsRasterImagePreviewLimits(content, mimeType), `${label} / ${mimeType}`).toBe( + unprobedExceeds(content, mimeType) + ) + } + } + }) +}) diff --git a/src/shared/raster-image-base64-preview.ts b/src/shared/raster-image-base64-preview.ts index b366d737218..78fb10f0d53 100644 --- a/src/shared/raster-image-base64-preview.ts +++ b/src/shared/raster-image-base64-preview.ts @@ -34,13 +34,20 @@ function isWhitespace(code: number): boolean { return code === 9 || code === 10 || code === 12 || code === 13 || code === 32 } +/** `quartetLength` under 4 is a final short group; the missing slots decode as `=` padding. */ function writeQuartet( output: Uint8Array, offset: number, - quartet: readonly number[] + quartet: readonly number[], + quartetLength: number ): { bytesWritten: number; padded: boolean } | null { - const [a, b, c, d] = quartet - if (a === undefined || b === undefined || a < 0 || b < 0) { + // Index reads, not `const [a, b, c, d] = quartet`: destructuring an array runs the iterator + // protocol (Symbol.iterator plus four `.next()` calls) once per four input characters. + const a = quartet[0] + const b = quartet[1] + const c = quartetLength > 2 ? quartet[2] : BASE64_PADDING + const d = quartetLength > 3 ? quartet[3] : BASE64_PADDING + if (quartetLength < 2 || a === undefined || b === undefined || a < 0 || b < 0) { return null } if (c === BASE64_PADDING) { @@ -73,10 +80,13 @@ function writeQuartet( return { bytesWritten: Math.min(3, output.length - offset), padded: false } } -function decodeBase64Prefix(content: string, maxBytes: number): Uint8Array | null { +/** Exported so the decode can be compared byte-for-byte against a reference implementation. */ +export function decodeBase64Prefix(content: string, maxBytes: number): Uint8Array | null { const capacity = Math.min(maxBytes, Math.ceil(content.length / 4) * 3) const output = new Uint8Array(capacity) - const quartet: number[] = [] + // Fixed four slots plus a counter, never resized: `quartet.length = 0` deoptimizes the array. + const quartet = [0, 0, 0, 0] + let quartetLength = 0 let outputLength = 0 let padded = false @@ -92,27 +102,27 @@ function decodeBase64Prefix(content: string, maxBytes: number): Uint8Array | nul if (value === INVALID_BASE64) { return null } - quartet.push(value) - if (quartet.length !== 4) { + quartet[quartetLength] = value + quartetLength += 1 + if (quartetLength !== 4) { continue } - const decoded = writeQuartet(output, outputLength, quartet) + const decoded = writeQuartet(output, outputLength, quartet, 4) if (!decoded) { return null } outputLength += decoded.bytesWritten padded = decoded.padded - quartet.length = 0 + quartetLength = 0 } - if (!padded && outputLength < capacity && quartet.length > 0) { - if (quartet.length === 1 || quartet.includes(BASE64_PADDING)) { - return null + if (!padded && outputLength < capacity && quartetLength > 0) { + for (let index = 0; index < quartetLength; index += 1) { + if (quartet[index] === BASE64_PADDING) { + return null + } } - while (quartet.length < 4) { - quartet.push(BASE64_PADDING) - } - const decoded = writeQuartet(output, outputLength, quartet) + const decoded = writeQuartet(output, outputLength, quartet, quartetLength) if (!decoded) { return null } @@ -121,6 +131,13 @@ function decodeBase64Prefix(content: string, maxBytes: number): Uint8Array | nul return output.subarray(0, outputLength) } +// First probe: past every fixed-offset header (PNG 24, GIF 10, WebP 30, BMP 26) and a JFIF-only +// JPEG's SOF, so an icon or screenshot is measured from its first bytes instead of its last. +const RASTER_IMAGE_HEADER_PROBE_BYTES = 64 +// Growth per miss. JPEG SOF sits past however much EXIF/ICC/MPF the camera wrote, so the probe +// widens geometrically: total decoded stays within ~1.07x of the bytes the header actually needed. +const RASTER_IMAGE_HEADER_PROBE_GROWTH = 16 + /** * Whether the encoded dimensions are known to exceed the preview limits. * @@ -135,10 +152,34 @@ export function exceedsRasterImagePreviewLimits( if (!isKnownRasterImageMimeType(mimeType)) { return false } - const prefix = decodeBase64Prefix(content, RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES) - if (!prefix) { - return false + let probeBytes = RASTER_IMAGE_HEADER_PROBE_BYTES + for (;;) { + const prefix = decodeBase64Prefix(content, probeBytes) + // A short probe only ever fails where the whole payload would: it walks a strict prefix of the + // same characters through the same state machine. + if (!prefix) { + return false + } + // Shorter than asked for means the payload ran out, so a wider probe cannot add bytes. + const exhausted = + prefix.byteLength < probeBytes || probeBytes >= RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES + const dimensions = readRasterImageDimensions(prefix) + if (dimensions !== null) { + const withinLimits = isRasterImagePreviewDimensions(dimensions) + if (withinLimits || exhausted) { + return !withinLimits + } + // About to suppress: redo the decode over the whole payload so the verdict stays the one the + // full read gives, including its rejection of base64 that turns invalid past the header. + probeBytes = RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES + continue + } + if (exhausted) { + return false + } + probeBytes = Math.min( + probeBytes * RASTER_IMAGE_HEADER_PROBE_GROWTH, + RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES + ) } - const dimensions = readRasterImageDimensions(prefix) - return dimensions !== null && !isRasterImagePreviewDimensions(dimensions) } diff --git a/src/shared/repo-icon.test.ts b/src/shared/repo-icon.test.ts index 566d8209e52..fde215bf943 100644 --- a/src/shared/repo-icon.test.ts +++ b/src/shared/repo-icon.test.ts @@ -1,6 +1,22 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import type * as ImageDataUriModule from './image-data-uri' import { githubAvatarIcon, githubAvatarSlug, sanitizeRepoIcon } from './repo-icon' +// Why a module mock: `validateRasterImageDataUri` is the leaf that base64-decodes an inline icon's +// header, so counting its invocations is the direct measure of what re-hydrating a repo costs. +const { dataUriValidations } = vi.hoisted(() => ({ dataUriValidations: { count: 0 } })) + +vi.mock('./image-data-uri', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + validateRasterImageDataUri: (dataUri: string) => { + dataUriValidations.count += 1 + return actual.validateRasterImageDataUri(dataUri) + } + } +}) + const PNG_1X1_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' const WEBP_1X1_BASE64 = 'UklGRhoAAABXRUJQVlA4IA4AAAAwAQCdASoBAAEAAQIlSkwAAA==' @@ -188,3 +204,84 @@ describe('githubAvatarSlug', () => { expect(githubAvatarSlug(null, undefined)).toBeNull() }) }) + +describe('repo icon source validation memo', () => { + const HYDRATIONS = 25 + + function uploadIcon(width: number): { type: 'image'; src: string; source: 'upload' } { + return { type: 'image', src: `data:image/png;base64,${pngBase64(width, 1)}`, source: 'upload' } + } + + it('validates each distinct icon src once across repeated hydrations', () => { + const icons = [uploadIcon(2), uploadIcon(3), uploadIcon(4)] + // Warm the memo the way the first hydration would, then measure steady state. + for (const icon of icons) { + sanitizeRepoIcon(icon) + } + dataUriValidations.count = 0 + + for (let hydration = 0; hydration < HYDRATIONS; hydration += 1) { + for (const icon of icons) { + expect(sanitizeRepoIcon(icon)).toEqual(icon) + } + } + + // Unmemoized this is HYDRATIONS x icons full base64 header decodes; memoized an unchanged + // persisted icon costs nothing. + expect(dataUriValidations.count).toBe(0) + }) + + it('re-validates as soon as the src changes', () => { + dataUriValidations.count = 0 + expect(sanitizeRepoIcon(uploadIcon(11))).toEqual(uploadIcon(11)) + expect(sanitizeRepoIcon(uploadIcon(12))).toEqual(uploadIcon(12)) + expect(dataUriValidations.count).toBe(2) + }) + + it('keeps the verdict specific to the icon source', () => { + const src = `data:image/webp;base64,${WEBP_1X1_BASE64}` + expect(sanitizeRepoIcon({ type: 'image', src, source: 'file' })).toEqual({ + type: 'image', + src, + source: 'file' + }) + // WebP is a `file` icon only; sharing one cache across sources would accept it as an upload. + expect(sanitizeRepoIcon({ type: 'image', src, source: 'upload' })).toBeUndefined() + }) + + // Guard for the removed cap: the memo hangs off the persisted icon object, so it holds a verdict + // for every live icon no matter how many there are. A fixed-size map would evict the earliest + // entries here and re-decode them on the next hydration. + it('keeps a verdict for every live icon, however many repos have one', () => { + const LIVE_ICONS = 200 + const icons = Array.from({ length: LIVE_ICONS }, (_, index) => uploadIcon(1000 + index)) + for (const icon of icons) { + sanitizeRepoIcon(icon) + } + dataUriValidations.count = 0 + + for (const icon of icons) { + expect(sanitizeRepoIcon(icon)).toEqual(icon) + } + + expect(dataUriValidations.count).toBe(0) + }) + + // Guard for the hazard object keying introduces: the stored src/source are re-checked on a hit, + // so a persisted icon edited in place can never be served its previous verdict. + it('re-validates an icon object whose src or source is mutated in place', () => { + const icon = { type: 'image', src: `data:image/png;base64,${pngBase64(7, 1)}`, source: 'file' } + expect(sanitizeRepoIcon(icon)).toEqual(icon) + + icon.src = `data:image/png;base64,${pngBase64(8, 1)}` + dataUriValidations.count = 0 + expect(sanitizeRepoIcon(icon)).toEqual(icon) + expect(dataUriValidations.count).toBe(1) + + // WebP is a `file` icon but not an `upload` icon, so the same object must flip verdicts. + icon.src = `data:image/webp;base64,${WEBP_1X1_BASE64}` + expect(sanitizeRepoIcon(icon)).toEqual(icon) + icon.source = 'upload' + expect(sanitizeRepoIcon(icon)).toBeUndefined() + }) +}) diff --git a/src/shared/repo-icon.ts b/src/shared/repo-icon.ts index 1973a2227ac..35f57a8f191 100644 --- a/src/shared/repo-icon.ts +++ b/src/shared/repo-icon.ts @@ -79,7 +79,7 @@ function normalizeGitHubAvatarHost(rawHost?: string): string { } } -function isSupportedImageSrc(src: string, source: RepoIconImageSource): boolean { +function computeIsSupportedImageSrc(src: string, source: RepoIconImageSource): boolean { if (source === 'upload') { return ( /^data:image\/png;base64,[A-Za-z0-9+/=\s]+$/i.test(src) && @@ -112,6 +112,34 @@ function isSupportedImageSrc(src: string, source: RepoIconImageSource): boolean return url.hostname === 'www.google.com' && url.pathname === '/s2/favicons' } +type ImageSrcVerdict = { src: unknown; source: unknown; supported: boolean } + +/** + * Why: `getRepos()` re-hydrates every repo on every call, and validating one inline data URI means + * scanning a 400 KB string twice with a regex and base64-decoding its header. `hydrateRepo` is + * handed the *same* persisted `repoIcon` object every time, so the verdict is cached on that object + * and dies with it — no cap, no eviction, and nothing retained once a repo or an icon is replaced. + * + * `src`/`source` are re-checked on a hit, so mutating the persisted icon in place cannot serve a + * stale verdict. Both are the identical string references in the steady state, so the compare is a + * pointer check, not a 400 KB scan. + */ +const imageSrcVerdicts = new WeakMap() + +function isSupportedImageSrc( + candidate: Record, + src: string, + source: RepoIconImageSource +): boolean { + const cached = imageSrcVerdicts.get(candidate) + if (cached && cached.src === candidate.src && cached.source === candidate.source) { + return cached.supported + } + const supported = computeIsSupportedImageSrc(src, source) + imageSrcVerdicts.set(candidate, { src: candidate.src, source: candidate.source, supported }) + return supported +} + export function sanitizeRepoIcon(value: unknown): RepoIcon | null | undefined { if (value === undefined) { return undefined @@ -146,7 +174,7 @@ export function sanitizeRepoIcon(value: unknown): RepoIcon | null | undefined { if (!isRepoIconImageSource(source) || src.length > MAX_REPO_ICON_DATA_URL_LENGTH) { return undefined } - if (!isSupportedImageSrc(src, source)) { + if (!isSupportedImageSrc(candidate, src, source)) { return undefined } const label = typeof candidate.label === 'string' ? candidate.label.trim().slice(0, 80) : ''