mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
perf(images): probe raster headers instead of decoding whole payloads, memoize repo icon validation (#18421)
* perf(images): measure raster headers from a probe and memoize repo icon validation
`getRepos()` re-sanitizes every repo on every call, and an uploaded/file repo
icon costs a full base64 decode of its data URI each time. Three fixes:
- `writeQuartet` destructured a mutable array, which sends V8 through the
iterator protocol once per four input characters; index reads plus a length
counter produce identical bytes.
- `decodeBase64Prefix` decoded the whole payload despite only the first bytes
being needed. `exceedsRasterImagePreviewLimits` now probes 64 bytes and
widens x16 until the header measures, and only re-runs the original
full-payload decode when the verdict would suppress a preview.
- `sanitizeRepoIcon`'s src validation is memoized per icon source with a
bounded FIFO map, reusing the `memoizeTitleClassification` idiom (now a
shared `memoizeByStringKey`).
* perf(images): key icon-validation memo on the persisted icon object
Replaces the per-source 64-entry FIFO string-key memo with a WeakMap keyed on
the persisted repoIcon object that hydrateRepo already receives, storing
{src, source, supported} and re-checking both fields on a hit.
Retention becomes zero by construction (entries die with state.repos[i].repoIcon),
so there is no cap to evict live icons and no dead icon strings held after a repo
or icon is replaced. The identity re-check makes an in-place mutation unable to
serve a stale verdict. Drops bounded-string-key-memo.ts and reverts the collateral
terminal-title-classification-memo refactor.
This commit is contained in:
@@ -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<typeof RasterImageDimensionsModule>()
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<typeof ImageDataUriModule>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
+30
-2
@@ -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<object, ImageSrcVerdict>()
|
||||
|
||||
function isSupportedImageSrc(
|
||||
candidate: Record<string, unknown>,
|
||||
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) : ''
|
||||
|
||||
Reference in New Issue
Block a user