fix(gui): harden multimodal image attachments

This commit is contained in:
Yichen Jiang
2026-07-23 19:38:37 +08:00
parent eea595fcb4
commit 580e05b794
61 changed files with 1700 additions and 214 deletions

View File

@@ -11,31 +11,31 @@ export interface DetectedImage {
}
function ascii(data: Uint8Array, start: number, value: string): boolean {
/* v8 ignore next -- Every call site establishes the fixed header span before comparing it. */
if (data.length < start + value.length) return false
for (let i = 0; i < value.length; i++) if (data[start + i] !== value.charCodeAt(i)) return false
return true
}
function u16be(data: Uint8Array, offset: number): number {
return ((data[offset] ?? 0) << 8) | (data[offset + 1] ?? 0)
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint16(offset)
}
function u16le(data: Uint8Array, offset: number): number {
return (data[offset] ?? 0) | ((data[offset + 1] ?? 0) << 8)
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint16(offset, true)
}
function u24le(data: Uint8Array, offset: number): number {
return (data[offset] ?? 0) | ((data[offset + 1] ?? 0) << 8) | ((data[offset + 2] ?? 0) << 16)
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
return view.getUint8(offset) | (view.getUint8(offset + 1) << 8) | (view.getUint8(offset + 2) << 16)
}
function u32be(data: Uint8Array, offset: number): number {
return (((data[offset] ?? 0) * 0x1000000) + ((data[offset + 1] ?? 0) << 16)
+ ((data[offset + 2] ?? 0) << 8) + (data[offset + 3] ?? 0)) >>> 0
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset)
}
function u32le(data: Uint8Array, offset: number): number {
return ((data[offset] ?? 0) + ((data[offset + 1] ?? 0) << 8)
+ ((data[offset + 2] ?? 0) << 16) + ((data[offset + 3] ?? 0) * 0x1000000)) >>> 0
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(offset, true)
}
function dimensions(width: number, height: number, mediaType: ImageMediaType): DetectedImage {
@@ -86,10 +86,11 @@ export function detectImage(data: Uint8Array): DetectedImage {
if (declaredLength > data.length) throw new AttachmentError('WebP data is truncated.', 'INVALID_IMAGE')
if (ascii(data, 12, 'VP8X')) return dimensions(u24le(data, 24) + 1, u24le(data, 27) + 1, 'image/webp')
if (ascii(data, 12, 'VP8L') && data[20] === 0x2f) {
const b0 = data[21] ?? 0
const b1 = data[22] ?? 0
const b2 = data[23] ?? 0
const b3 = data[24] ?? 0
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
const b0 = view.getUint8(21)
const b1 = view.getUint8(22)
const b2 = view.getUint8(23)
const b3 = view.getUint8(24)
return dimensions(1 + b0 + ((b1 & 0x3f) << 8), 1 + (b1 >> 6) + (b2 << 2) + ((b3 & 0x0f) << 10), 'image/webp')
}
if (ascii(data, 12, 'VP8 ') && data[23] === 0x9d && data[24] === 0x01 && data[25] === 0x2a) {

View File

@@ -67,7 +67,7 @@ export class LocalAttachmentStore extends AttachmentStore {
}
async readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
return readImageFile(this.root, ref, this.imageLimits)
return readImageFile(this.root, ref)
}
}

View File

@@ -38,15 +38,20 @@ function ensureReference(ref: ImageAttachmentRef): string {
return match[1]
}
function validateMetadata(data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType'], limits: ImageAttachmentLimits): Omit<ImageAttachmentRef, 'attachmentId' | 'name'> {
function inspectMetadata(data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType']): Omit<ImageAttachmentRef, 'attachmentId' | 'name'> {
if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE')
if (data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE')
const detected = detectImage(data)
if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH')
if (detected.width * detected.height > limits.maxImagePixels) throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS')
return { ...detected, bytes: data.byteLength }
}
function validateAdmission(metadata: Omit<ImageAttachmentRef, 'attachmentId' | 'name'>, limits: ImageAttachmentLimits): void {
if (metadata.bytes > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE')
if (metadata.width * metadata.height > limits.maxImagePixels) {
throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS')
}
}
/**
* Save and verify immutable image bytes below a versioned attachment root.
* @param root - absolute `DSH_HOME/attachments/v1` root.
@@ -55,7 +60,8 @@ function validateMetadata(data: Uint8Array, declaredMediaType: ImageAttachmentRe
* @returns durable content-addressed reference.
*/
export async function saveImageFile(root: string, input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise<ImageAttachmentRef> {
const metadata = validateMetadata(input.data, input.mediaType, limits)
const metadata = inspectMetadata(input.data, input.mediaType)
validateAdmission(metadata, limits)
const sha256 = digest(input.data)
const bucket = join(root, 'objects', sha256.slice(0, 2))
const staging = join(root, 'tmp')
@@ -75,16 +81,25 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li
try {
await link(temporary, target)
} catch (error) {
/* v8 ignore next -- Private same-filesystem directories make EEXIST the only recoverable link race. */
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error
const existing = new Uint8Array(await readFile(target))
if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
}
await unlink(temporary)
} catch (error) {
if (handle !== undefined) await handle.close().catch(() => { /* close failure is superseded by the storage failure */ })
await unlink(temporary).catch((cleanupError: unknown) => {
if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError
})
/* v8 ignore next -- A descriptor can remain open only when the underlying write/sync/close operation fails. */
if (handle !== undefined) await handle.close().catch(
/* v8 ignore next -- Close failure is superseded by the storage operation that entered cleanup. */
() => {},
)
await unlink(temporary).catch(
/* v8 ignore next -- The callback requires a second independent staging-unlink failure. */
(cleanupError: unknown) => {
/* v8 ignore next -- Cleanup is best-effort only for a staging file already removed by a failed operation. */
if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError
},
)
if (error instanceof AttachmentError) throw error
throw new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error })
}
@@ -100,10 +115,9 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li
* Read and verify one content-addressed image.
* @param root - absolute `DSH_HOME/attachments/v1` root.
* @param ref - reference recorded in the session log.
* @param limits - resolved storage policy.
* @returns verified bytes and reference.
*/
export async function readImageFile(root: string, ref: ImageAttachmentRef, limits: ImageAttachmentLimits): Promise<StoredImageAttachment> {
export async function readImageFile(root: string, ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
const sha256 = ensureReference(ref)
let data: Uint8Array
try {
@@ -113,7 +127,7 @@ export async function readImageFile(root: string, ref: ImageAttachmentRef, limit
throw new AttachmentError('Unable to read image attachment.', 'ATTACHMENT_READ_FAILED', { cause: error })
}
if (digest(data) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
const metadata = validateMetadata(data, ref.mediaType, limits)
const metadata = inspectMetadata(data, ref.mediaType)
if (metadata.bytes !== ref.bytes || metadata.width !== ref.width || metadata.height !== ref.height) {
throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT')
}