feat(attachment-local): store a deterministic canonical image encoding

Admission now validates a wide source envelope (32MiB, 100MP, 16384px per
side) and persists a canonical encoding instead of refusing large sources:
EXIF orientation baked in, metadata stripped, long edge downscaled to the
configured canonical target (default 2048px), PNG palette for alpha/PNG/GIF
sources and a fixed JPEG quality ladder (85/75/60/45) until the canonical
byte target holds (default 1MiB). In-budget PNG/JPEG/WebP passes through
byte-identically so equal sources keep deduplicating to the same content
address; GIF always re-encodes to the PNG of its first frame, pinning the
first-frame meaning providers apply. Encoder parameters are fixed by design;
only the canonical budget is deployment configuration.
This commit is contained in:
creatixchu 2026-08-20 10:57:39 +08:00
parent 8f83853b60
commit 83a526eea1
6 changed files with 340 additions and 42 deletions

View file

@ -0,0 +1,103 @@
/**
* Deterministic canonical image encoding. Admission stores this encoding, so
* the same source bytes always publish the same content address on one
* runtime: encoder parameters are fixed here, never configurable, because a
* parameter change would silently split the content-addressed space. The
* deployment chooses only the canonical budget (long edge and byte target).
*/
import sharp, { type Sharp } from 'sharp'
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
import type { DetectedImage } from './image.ts'
/** Deployment-resolved canonical encoding budget. */
export interface CanonicalImagePolicy {
/** Long-edge target in pixels; a larger source is downscaled proportionally. */
maxDimension: number
/** Encoded-byte target; a larger encoding falls down the fixed quality ladder. */
maxBytes: number
}
/** Canonical bytes beside the facts a durable reference records about them. */
export interface CanonicalImage {
data: Uint8Array
mediaType: ImageMediaType
width: number
height: number
}
/** JPEG quality ladder tried in order once the preferred encoding exceeds the byte target. */
const JPEG_QUALITIES = [85, 75, 60, 45] as const
/** Encode one prepared pipeline and report the exact output facts. */
async function encode(pipeline: Sharp, mediaType: 'image/png' | 'image/jpeg'): Promise<CanonicalImage> {
const { data, info } = await pipeline.toBuffer({ resolveWithObject: true })
return { data: new Uint8Array(data), mediaType, width: info.width, height: info.height }
}
/**
* Whether stored bytes may be the submitted bytes unchanged. Byte-identical
* passthrough is preferred whenever the source already fits the budget: it
* keeps re-submissions of the same original deduplicating to the same object
* and never re-encodes what no policy requires changing. GIF is excluded —
* only its first frame is model-visible, so admission pins that meaning into
* the stored object instead of letting each provider drop frames differently.
* @param detected - verified source format and dimensions.
* @param bytes - submitted encoded byte length.
* @param policy - resolved canonical budget.
* @returns whether the submitted encoding already is canonical.
*/
export function isCanonical(detected: DetectedImage, bytes: number, policy: CanonicalImagePolicy): boolean {
return detected.mediaType !== 'image/gif'
&& bytes <= policy.maxBytes
&& Math.max(detected.width, detected.height) <= policy.maxDimension
}
/**
* Produce the canonical encoding of one fully validated source raster.
* Passthrough returns the submitted array; every re-encode bakes EXIF
* orientation into pixels, strips metadata, downscales to the policy's long
* edge, and encodes with fixed parameters: PNG (palette) for sources that
* carry alpha or were PNG/GIF, JPEG for photographic sources, falling down
* one fixed JPEG quality ladder until the byte target holds.
* @param data - submitted encoded bytes, already fully decoded by admission.
* @param detected - verified source format and dimensions.
* @param policy - resolved canonical budget.
* @returns canonical bytes and their reference facts.
* @throws AttachmentError `IMAGE_TOO_LARGE` when the smallest ladder step still exceeds the byte target.
*/
export async function canonicalizeImage(
data: Uint8Array,
detected: DetectedImage,
policy: CanonicalImagePolicy,
): Promise<CanonicalImage> {
if (isCanonical(detected, data.byteLength, policy)) {
return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height }
}
try {
const source = sharp(data, { failOn: 'error', limitInputPixels: false })
const { hasAlpha } = await source.metadata()
const prepared = source.rotate().resize({
width: policy.maxDimension,
height: policy.maxDimension,
fit: 'inside',
withoutEnlargement: true,
})
const preferPng = hasAlpha || detected.mediaType === 'image/png' || detected.mediaType === 'image/gif'
if (preferPng) {
const png = await encode(prepared.clone().png({ compressionLevel: 9, palette: true }), 'image/png')
if (png.data.byteLength <= policy.maxBytes) return png
}
for (const quality of JPEG_QUALITIES) {
const jpeg = await encode(
prepared.clone().flatten({ background: '#ffffff' }).jpeg({ quality }),
'image/jpeg',
)
if (jpeg.data.byteLength <= policy.maxBytes) return jpeg
}
} catch (error) {
throw new AttachmentError('Unable to canonicalize image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error })
}
throw new AttachmentError('Image cannot be encoded within the configured canonical byte target.', 'IMAGE_TOO_LARGE')
}

View file

@ -6,41 +6,50 @@ import z from '@deepseek-ai/schemastery'
import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, SavedImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment'
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
import type { CanonicalImagePolicy } from './canonical.ts'
import { readImageFile, saveImageFile, validateImageFile } from './store.ts'
export { canonicalizeImage, isCanonical } from './canonical.ts'
export type { CanonicalImage, CanonicalImagePolicy } from './canonical.ts'
export { readImageFile, saveImageFile, validateImageFile } from './store.ts'
/** Default maximum encoded bytes for one image. */
export const DEFAULT_MAX_IMAGE_BYTES = 3.5 * 1024 * 1024
/** Default maximum encoded bytes for one submitted image; oversized sources are refused, not shrunk. */
export const DEFAULT_MAX_IMAGE_BYTES = 32 * 1024 * 1024
/** Default maximum images in one prompt. */
export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 20
/** Default maximum aggregate image bytes in one prompt. */
export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 100 * 1024 * 1024
/** Default maximum intrinsic pixels for one image. */
export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000
/** Default maximum intrinsic pixels for one submitted image. */
export const DEFAULT_MAX_IMAGE_PIXELS = 100_000_000
/** Default per-side pixel cap for one submitted image. */
export const DEFAULT_MAX_IMAGE_DIMENSION = 16384
/**
* Default maximum intrinsic width and height for one image. Deployed model
* routes reject any request whose history carries an image with a side above
* 2000px once the request holds many images, and an admitted image rides
* every later request of its session, so admission refuses at the same line
* to keep the durable history streamable.
* Default long-edge target of the stored canonical encoding. A larger source
* is admitted and downscaled to this edge, so admission bounds what rides
* every later model request without refusing ordinary large sources.
*/
export const DEFAULT_MAX_IMAGE_DIMENSION = 2000
export const DEFAULT_CANONICAL_MAX_DIMENSION = 2048
/** Default byte target of the stored canonical encoding. */
export const DEFAULT_CANONICAL_MAX_BYTES = 1024 * 1024
/** Local attachment backend configuration. */
export interface Config {
/** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */
dshHome?: string
/** Maximum encoded bytes accepted for one image. */
/** Maximum encoded bytes accepted for one submitted image. */
maxImageBytes?: number
/** Maximum image count accepted in one submitted message. */
maxImagesPerMessage?: number
/** Maximum aggregate encoded image bytes accepted in one submitted message. */
maxMessageImageBytes?: number
/** Maximum intrinsic width multiplied by height accepted for one image. */
/** Maximum intrinsic width multiplied by height accepted for one submitted image. */
maxImagePixels?: number
/** Maximum intrinsic width and maximum intrinsic height accepted for one image. */
/** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. */
maxImageDimension?: number
/** Long-edge pixel target of the stored canonical encoding. */
canonicalMaxDimension?: number
/** Encoded-byte target of the stored canonical encoding. */
canonicalMaxBytes?: number
}
/** Persistent content-addressed local attachment store. */
@ -52,11 +61,15 @@ export class LocalAttachmentStore extends AttachmentStore {
maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES),
maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS),
maxImageDimension: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_DIMENSION),
canonicalMaxDimension: z.number().step(1).min(1).default(DEFAULT_CANONICAL_MAX_DIMENSION),
canonicalMaxBytes: z.number().step(1).min(1).default(DEFAULT_CANONICAL_MAX_BYTES),
})
/** Absolute versioned storage root. */
readonly root: string
readonly imageLimits: ImageAttachmentLimits
/** Resolved canonical encoding budget applied by every save. */
readonly canonicalPolicy: Readonly<CanonicalImagePolicy>
constructor(ctx: Context, config: Config) {
super(ctx)
@ -69,6 +82,10 @@ export class LocalAttachmentStore extends AttachmentStore {
maxImageDimension: config.maxImageDimension ?? DEFAULT_MAX_IMAGE_DIMENSION,
mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const),
})
this.canonicalPolicy = Object.freeze({
maxDimension: config.canonicalMaxDimension ?? DEFAULT_CANONICAL_MAX_DIMENSION,
maxBytes: config.canonicalMaxBytes ?? DEFAULT_CANONICAL_MAX_BYTES,
})
}
async validateImage(input: SaveImageAttachment): Promise<void> {
@ -76,7 +93,7 @@ export class LocalAttachmentStore extends AttachmentStore {
}
async saveImage(input: SaveImageAttachment): Promise<SavedImageAttachment> {
return saveImageFile(this.root, input, this.imageLimits)
return saveImageFile(this.root, input, this.imageLimits, this.canonicalPolicy)
}
async readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment> {

View file

@ -15,6 +15,8 @@ import type {
SavedImageAttachment,
StoredImageAttachment,
} from '@deepseek-ai/dsh-attachment'
import { canonicalizeImage } from './canonical.ts'
import type { CanonicalImagePolicy } from './canonical.ts'
import { detectImage, probeImage } from './image.ts'
const ID_PATTERN = /^sha256:([a-f0-9]{64})$/
@ -128,20 +130,26 @@ async function ensureDurableHome(path: string): Promise<string> {
}
/**
* Save and verify immutable image bytes below a versioned attachment root.
* Save and verify one image below a versioned attachment root. Admission
* validates the submitted source, then stores its deterministic canonical
* encoding; the returned reference describes the stored canonical bytes while
* `source` preserves the submitted raster's facts.
* @param root - absolute `DSH_HOME/attachments/v1` root.
* @param input - encoded bytes and declared metadata.
* @param limits - resolved storage policy.
* @param limits - resolved source admission policy.
* @param policy - resolved canonical encoding budget.
* @returns durable content-addressed reference beside the submitted source facts.
*/
export async function saveImageFile(
root: string,
input: SaveImageAttachment,
limits: ImageAttachmentLimits,
policy: CanonicalImagePolicy,
): Promise<SavedImageAttachment> {
if (input.data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE')
const metadata = await inspectMetadata(input.data, input.mediaType, limits)
const sha256 = digest(input.data)
const canonical = await canonicalizeImage(input.data, metadata, policy)
const sha256 = digest(canonical.data)
const bucket = join(root, 'objects', sha256.slice(0, 2))
const staging = join(root, 'tmp')
// Establish DSH_HOME itself against the filesystem root once per process.
@ -155,7 +163,7 @@ export async function saveImageFile(
let handle
try {
handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600)
await handle.writeFile(input.data)
await handle.writeFile(canonical.data)
await handle.sync()
await handle.close()
handle = undefined
@ -194,7 +202,10 @@ export async function saveImageFile(
return {
ref: {
attachmentId: AttachmentId(`sha256:${sha256}`),
...metadata,
mediaType: canonical.mediaType,
bytes: canonical.data.byteLength,
width: canonical.width,
height: canonical.height,
...(name !== undefined ? { name } : {}),
},
source: metadata,

View file

@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest'
import sharp from 'sharp'
import { canonicalizeImage, isCanonical } from '../src/canonical.ts'
import type { CanonicalImagePolicy } from '../src/canonical.ts'
import { detectImage } from '../src/image.ts'
const POLICY: CanonicalImagePolicy = { maxDimension: 2048, maxBytes: 1024 * 1024 }
/** Deterministic pseudo-random RGB noise; PNG cannot compress it below raw size. */
function noisePixels(width: number, height: number): Uint8Array {
const pixels = new Uint8Array(width * height * 3)
let state = 0x2545f491
for (let index = 0; index < pixels.length; index += 1) {
state = (state * 1103515245 + 12345) & 0x7fffffff
pixels[index] = state & 0xff
}
return pixels
}
async function noiseImage(width: number, height: number, format: 'png' | 'jpeg' | 'webp' | 'gif'): Promise<Uint8Array> {
const image = sharp(noisePixels(width, height), { raw: { width, height, channels: 3 } })
return new Uint8Array(await image.toFormat(format).toBuffer())
}
async function flatImage(width: number, height: number, format: 'png' | 'jpeg' | 'webp' | 'gif', alpha = false): Promise<Uint8Array> {
const image = sharp({
create: { width, height, channels: alpha ? 4 : 3, background: { r: 12, g: 200, b: 64, alpha: alpha ? 0.5 : 1 } },
})
return new Uint8Array(await image.toFormat(format, format === 'webp' && alpha ? { lossless: true } : {}).toBuffer())
}
describe('isCanonical', () => {
it('accepts an in-budget PNG/JPEG/WebP and refuses GIF, oversized edges, and oversized bytes', () => {
expect(isCanonical({ mediaType: 'image/png', width: 2048, height: 4 }, 100, POLICY)).toBe(true)
expect(isCanonical({ mediaType: 'image/gif', width: 4, height: 4 }, 100, POLICY)).toBe(false)
expect(isCanonical({ mediaType: 'image/jpeg', width: 2049, height: 4 }, 100, POLICY)).toBe(false)
expect(isCanonical({ mediaType: 'image/webp', width: 4, height: 4 }, POLICY.maxBytes + 1, POLICY)).toBe(false)
})
})
describe('canonicalizeImage', () => {
it('passes an already-canonical source through byte-identically', async () => {
const data = await flatImage(6, 4, 'webp')
const detected = await detectImage(data)
const canonical = await canonicalizeImage(data, detected, POLICY)
expect(canonical.data).toBe(data)
expect(canonical).toMatchObject({ mediaType: 'image/webp', width: 6, height: 4 })
})
it('downscales an oversized PNG to the long-edge target and stays PNG', async () => {
const data = await flatImage(10, 6, 'png')
const detected = await detectImage(data)
const canonical = await canonicalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes })
expect(canonical).toMatchObject({ mediaType: 'image/png', width: 5, height: 3 })
await expect(detectImage(canonical.data)).resolves.toEqual({ mediaType: 'image/png', width: 5, height: 3 })
const again = await canonicalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes })
expect(again.data).toEqual(canonical.data)
})
it('re-encodes the canonical output of a resize into itself (idempotence)', async () => {
const data = await flatImage(10, 6, 'png')
const first = await canonicalizeImage(data, await detectImage(data), { maxDimension: 5, maxBytes: POLICY.maxBytes })
const second = await canonicalizeImage(first.data, await detectImage(first.data), { maxDimension: 5, maxBytes: POLICY.maxBytes })
expect(second.data).toBe(first.data)
})
it('always re-encodes GIF to the PNG of its first frame', async () => {
const data = await flatImage(6, 4, 'gif')
const detected = await detectImage(data)
const canonical = await canonicalizeImage(data, detected, POLICY)
expect(canonical.mediaType).toBe('image/png')
await expect(detectImage(canonical.data)).resolves.toEqual({ mediaType: 'image/png', width: 6, height: 4 })
})
it('keeps alpha sources on PNG when the budget holds', async () => {
const data = await flatImage(9, 5, 'webp', true)
const detected = await detectImage(data)
const canonical = await canonicalizeImage(data, detected, { maxDimension: 4, maxBytes: POLICY.maxBytes })
expect(canonical).toMatchObject({ mediaType: 'image/png', width: 4, height: 2 })
})
it('re-encodes an oversized photographic JPEG as JPEG', async () => {
const data = await noiseImage(64, 32, 'jpeg')
const detected = await detectImage(data)
const canonical = await canonicalizeImage(data, detected, { maxDimension: 32, maxBytes: POLICY.maxBytes })
expect(canonical).toMatchObject({ mediaType: 'image/jpeg', width: 32, height: 16 })
})
it('falls from PNG to the JPEG ladder when palette PNG exceeds the byte target', async () => {
// A smooth gradient: palette quantization dithers it into a sizable PNG
// while JPEG at quality 85 stays far smaller, so the budget between the
// two forces exactly one ladder hop.
const side = 256
const pixels = new Uint8Array(side * side * 3)
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
const index = (y * side + x) * 3
pixels[index] = x & 0xff
pixels[index + 1] = y & 0xff
pixels[index + 2] = (x + y) >> 1 & 0xff
}
}
const data = new Uint8Array(await sharp(pixels, { raw: { width: side, height: side, channels: 3 } }).png().toBuffer())
const detected = await detectImage(data)
const paletteSize = (await sharp(data).png({ compressionLevel: 9, palette: true }).toBuffer()).byteLength
const jpegSize = (await sharp(data).flatten({ background: '#ffffff' }).jpeg({ quality: 85 }).toBuffer()).byteLength
expect(jpegSize).toBeLessThan(paletteSize)
const budget = { maxDimension: 2048, maxBytes: paletteSize - 1 }
const canonical = await canonicalizeImage(data, detected, budget)
expect(canonical.mediaType).toBe('image/jpeg')
expect(canonical.data.byteLength).toBeLessThanOrEqual(budget.maxBytes)
})
it('refuses a source that no ladder step fits into the byte target', async () => {
const data = await noiseImage(64, 64, 'png')
await expect(canonicalizeImage(data, await detectImage(data), { maxDimension: 2048, maxBytes: 10 }))
.rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' })
})
it('maps an encoder fault on undecodable bytes to a storage failure', async () => {
await expect(canonicalizeImage(Uint8Array.of(1, 2, 3), { mediaType: 'image/png', width: 5000, height: 5000 }, POLICY))
.rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' })
})
})

View file

@ -5,6 +5,8 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import LocalAttachmentStore, {
DEFAULT_CANONICAL_MAX_BYTES,
DEFAULT_CANONICAL_MAX_DIMENSION,
DEFAULT_MAX_IMAGE_BYTES,
DEFAULT_MAX_IMAGE_DIMENSION,
DEFAULT_MAX_IMAGE_PIXELS,
@ -15,7 +17,7 @@ import LocalAttachmentStore, {
describe('local attachment service', () => {
it('resolves every omitted admission limit explicitly', () => {
const service = new LocalAttachmentStore(new Context(), {})
expect(DEFAULT_MAX_IMAGE_BYTES).toBe(3.5 * 1024 * 1024)
expect(DEFAULT_MAX_IMAGE_BYTES).toBe(32 * 1024 * 1024)
expect(service.imageLimits).toEqual({
maxImageBytes: DEFAULT_MAX_IMAGE_BYTES,
maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE,
@ -24,6 +26,10 @@ describe('local attachment service', () => {
maxImageDimension: DEFAULT_MAX_IMAGE_DIMENSION,
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
})
expect(service.canonicalPolicy).toEqual({
maxDimension: DEFAULT_CANONICAL_MAX_DIMENSION,
maxBytes: DEFAULT_CANONICAL_MAX_BYTES,
})
})
it('saves and reads through the service boundary', async () => {
@ -34,7 +40,7 @@ describe('local attachment service', () => {
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
))
const ref = await service.saveImage({ data, mediaType: 'image/png' })
const { ref } = await service.saveImage({ data, mediaType: 'image/png' })
await expect(service.readImage(ref)).resolves.toEqual({ ref, data })
} finally {
await rm(dshHome, { recursive: true, force: true })

View file

@ -7,6 +7,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { afterEach, describe, expect, it, vi } from 'vitest'
import sharp from 'sharp'
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
import type { CanonicalImagePolicy } from '../src/canonical.ts'
import { readImageFile, saveImageFile } from '../src/store.ts'
const fsControl = vi.hoisted(() => ({
@ -38,6 +39,8 @@ const PNG = Uint8Array.from(Buffer.from(
'base64',
))
const POLICY: CanonicalImagePolicy = { maxDimension: 2048, maxBytes: 1024 * 1024 }
const LIMITS: ImageAttachmentLimits = {
maxImageBytes: 1024,
maxImagesPerMessage: 2,
@ -79,7 +82,7 @@ describe('local attachment store', () => {
const bucket = join(objects, sha256.slice(0, 2))
fsControl.syncedDirectories.length = 0
await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY)
// Each process first proves DSH_HOME durable all the way to the filesystem
// root; existence alone cannot vouch for a concurrent creator's fsync.
@ -104,7 +107,7 @@ describe('local attachment store', () => {
it('creates and persists a missing nested home directory against the filesystem root', async () => {
const storageRoot = join(await root(), 'home', 'attachments', 'v1')
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY)
await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG })
})
@ -113,12 +116,12 @@ describe('local attachment store', () => {
const storageRoot = await root()
const first = await saveImageFile(storageRoot, {
data: PNG, mediaType: 'image/png', name: '/private/tmp/pixel.png',
}, LIMITS)
const second = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
}, LIMITS, POLICY)
const second = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY)
const sha256 = createHash('sha256').update(PNG).digest('hex')
const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256)
expect(first).toEqual({
expect(first.ref).toEqual({
attachmentId: `sha256:${sha256}`,
mediaType: 'image/png',
bytes: PNG.byteLength,
@ -126,25 +129,44 @@ describe('local attachment store', () => {
height: 1,
name: 'pixel.png',
})
expect(second.attachmentId).toBe(first.attachmentId)
expect(first.source).toEqual({ mediaType: 'image/png', bytes: PNG.byteLength, width: 1, height: 1 })
expect(second.ref.attachmentId).toBe(first.ref.attachmentId)
expect(new Uint8Array(await readFile(object))).toEqual(PNG)
if (process.platform !== 'win32') {
expect((await stat(object)).mode & 0o777).toBe(0o600)
expect((await stat(join(storageRoot, 'objects', sha256.slice(0, 2)))).mode & 0o777).toBe(0o700)
}
await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG })
await expect(readImageFile(storageRoot, first.ref)).resolves.toEqual({ ref: first.ref, data: PNG })
})
it('stores the canonical encoding of an oversized source and reads it back verified', async () => {
const storageRoot = await root()
const oversized = new Uint8Array(await sharp({
create: { width: 4, height: 4, channels: 3, background: { r: 9, g: 9, b: 9 } },
}).png().toBuffer())
const saved = await saveImageFile(storageRoot, {
data: oversized, mediaType: 'image/png', name: 'big.png',
}, { ...LIMITS, maxImagePixels: 64 }, { maxDimension: 2, maxBytes: 1024 * 1024 })
expect(saved.source).toEqual({ mediaType: 'image/png', bytes: oversized.byteLength, width: 4, height: 4 })
expect(saved.ref).toMatchObject({ mediaType: 'image/png', width: 2, height: 2, name: 'big.png' })
expect(saved.ref.bytes).not.toBe(oversized.byteLength)
const read = await readImageFile(storageRoot, saved.ref)
expect(read.data.byteLength).toBe(saved.ref.bytes)
expect(String(saved.ref.attachmentId)).toBe(`sha256:${createHash('sha256').update(read.data).digest('hex')}`)
})
it('keeps admitted history readable after deployment limits become stricter', async () => {
const storageRoot = await root()
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY)
await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG })
})
it('forwards read cancellation to the filesystem and preserves its reason', async () => {
const storageRoot = await root()
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY)
const controller = new AbortController()
fsControl.readSignals.length = 0
@ -160,35 +182,35 @@ describe('local attachment store', () => {
const storageRoot = await root()
await expect(saveImageFile(storageRoot, {
data: new Uint8Array(0), mediaType: 'image/png',
}, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
}, LIMITS, POLICY)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
await expect(saveImageFile(storageRoot, {
data: Uint8Array.of(1, 2, 3), mediaType: 'image/png',
}, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
}, LIMITS, POLICY)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
await expect(saveImageFile(storageRoot, {
data: PNG, mediaType: 'image/jpeg',
}, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TYPE_MISMATCH' })
}, LIMITS, POLICY)).rejects.toMatchObject({ code: 'IMAGE_TYPE_MISMATCH' })
await expect(saveImageFile(storageRoot, {
data: PNG, mediaType: 'image/png',
}, { ...LIMITS, maxImageBytes: 1 })).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' })
}, { ...LIMITS, maxImageBytes: 1 }, POLICY)).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' })
const wide = new Uint8Array(await sharp({
create: { width: 5, height: 5, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
}).png().toBuffer())
await expect(saveImageFile(storageRoot, {
data: wide, mediaType: 'image/png',
}, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' })
}, LIMITS, POLICY)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' })
await expect(saveImageFile(storageRoot, {
data: wide, mediaType: 'image/png',
}, { ...LIMITS, maxImagePixels: 25, maxImageDimension: 4 })).rejects.toMatchObject({ code: 'IMAGE_DIMENSION_TOO_LARGE' })
}, { ...LIMITS, maxImagePixels: 25, maxImageDimension: 4 }, POLICY)).rejects.toMatchObject({ code: 'IMAGE_DIMENSION_TOO_LARGE' })
const unnamed = await saveImageFile(storageRoot, {
data: PNG, mediaType: 'image/png', name: '\u0000',
}, LIMITS)
expect(unnamed).not.toHaveProperty('name')
}, LIMITS, POLICY)
expect(unnamed.ref).not.toHaveProperty('name')
})
it('fails closed when an object is missing, corrupted, or addressed by an invalid reference', async () => {
const storageRoot = await root()
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY)
const sha256 = String(ref.attachmentId).slice('sha256:'.length)
const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256)
await chmod(object, 0o600)
@ -216,11 +238,11 @@ describe('local attachment store', () => {
const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256)
await mkdir(join(storageRoot, 'objects', sha256.slice(0, 2)), { recursive: true })
await writeFile(target, Uint8Array.of(1, 2, 3))
await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS))
await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY))
.rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' })
await writeFile(target, PNG)
const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
const { ref } = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY)
await expect(readImageFile(storageRoot, { ...ref, width: ref.width + 1 }))
.rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' })
})
@ -231,7 +253,7 @@ describe('local attachment store', () => {
const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256)
await mkdir(target, { recursive: true })
await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS))
await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY))
.rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' })
})
})