6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
175 lines
6.3 KiB
TypeScript
175 lines
6.3 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
isHeifContainer,
|
|
isHevcHeifContainer,
|
|
transcodeHeicToJpeg,
|
|
} from '@/lib/uploads/server/heic'
|
|
|
|
/**
|
|
* An ISO-BMFF `ftyp` box: 4-byte size, the `ftyp` marker, the major brand, a
|
|
* 4-byte minor version, then any compatible brands.
|
|
*/
|
|
function ftypHeader(brand: string, compatible: string[] = []): Buffer {
|
|
const size = 16 + compatible.length * 4
|
|
const header = Buffer.alloc(size)
|
|
header.writeUInt32BE(size, 0)
|
|
header.write('ftyp', 4, 'ascii')
|
|
header.write(brand, 8, 'ascii')
|
|
compatible.forEach((entry, index) => header.write(entry, 16 + index * 4, 'ascii'))
|
|
return header
|
|
}
|
|
|
|
describe('isHeifContainer', () => {
|
|
it.each(['heic', 'heix', 'heim', 'heis', 'hevc', 'hevx', 'mif1', 'msf1'])(
|
|
'detects the %s brand',
|
|
(brand) => {
|
|
expect(isHeifContainer(ftypHeader(brand))).toBe(true)
|
|
}
|
|
)
|
|
|
|
it.each(['avif', 'avis'])(
|
|
'also claims the %s brand — the question is "is this HEIF", not "which codec"',
|
|
(brand) => {
|
|
expect(isHeifContainer(ftypHeader(brand))).toBe(true)
|
|
}
|
|
)
|
|
|
|
it('rejects other image formats', () => {
|
|
expect(isHeifContainer(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0]))).toBe(
|
|
false
|
|
)
|
|
expect(isHeifContainer(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0, 0, 0, 0, 0]))).toBe(
|
|
false
|
|
)
|
|
})
|
|
|
|
it('rejects a HEIF brand that is not behind an ftyp box', () => {
|
|
const riff = Buffer.alloc(16)
|
|
riff.write('RIFF', 0, 'ascii')
|
|
riff.write('heic', 8, 'ascii')
|
|
expect(isHeifContainer(riff)).toBe(false)
|
|
})
|
|
|
|
it('rejects an unknown brand in a well-formed ftyp box', () => {
|
|
expect(isHeifContainer(ftypHeader('qt '))).toBe(false)
|
|
})
|
|
|
|
it('detects a HEIF brand declared only among the compatible brands', () => {
|
|
// Standards-valid: a generic major brand with the HEIF brand listed after it.
|
|
expect(isHeifContainer(ftypHeader('isom', ['iso2', 'heic', 'mif1']))).toBe(true)
|
|
expect(isHeifContainer(ftypHeader('mp42', ['heix']))).toBe(true)
|
|
})
|
|
|
|
it('rejects a box whose compatible brands are all non-HEIF', () => {
|
|
expect(isHeifContainer(ftypHeader('isom', ['iso2', 'mp41', 'mp42']))).toBe(false)
|
|
})
|
|
|
|
it('does not read compatible brands past the declared box size', () => {
|
|
const truncated = ftypHeader('isom', ['heic'])
|
|
truncated.writeUInt32BE(16, 0)
|
|
expect(isHeifContainer(truncated)).toBe(false)
|
|
})
|
|
|
|
it('caps the scan so an inflated declared box size cannot drive the loop', () => {
|
|
// The declared size is attacker-controlled; without the cap this scans the
|
|
// whole buffer, and this runs on every preview request.
|
|
const inflated = Buffer.alloc(64 * 1024)
|
|
inflated.writeUInt32BE(0xffffffff, 0)
|
|
inflated.write('ftyp', 4, 'ascii')
|
|
inflated.write('isom', 8, 'ascii')
|
|
inflated.write('heic', 60 * 1024, 'ascii')
|
|
expect(isHeifContainer(inflated)).toBe(false)
|
|
})
|
|
|
|
it('rejects buffers too short to carry a brand', () => {
|
|
expect(isHeifContainer(Buffer.alloc(0))).toBe(false)
|
|
expect(isHeifContainer(ftypHeader('heic').subarray(0, 11))).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('isHevcHeifContainer', () => {
|
|
it.each(['heic', 'heix', 'heim', 'heis', 'hevc', 'hevx'])(
|
|
'detects the %s brand, which names HEVC outright',
|
|
(brand) => {
|
|
expect(isHevcHeifContainer(ftypHeader(brand))).toBe(true)
|
|
}
|
|
)
|
|
|
|
it.each(['mif1', 'msf1'])('rejects the generic %s brand — the codec is unstated', (brand) => {
|
|
expect(isHevcHeifContainer(ftypHeader(brand))).toBe(false)
|
|
})
|
|
|
|
it.each(['avif', 'avis'])('rejects the AV1-coded %s brand, which browsers render', (brand) => {
|
|
expect(isHevcHeifContainer(ftypHeader(brand))).toBe(false)
|
|
})
|
|
|
|
it('detects an HEVC brand declared only among the compatible brands', () => {
|
|
expect(isHevcHeifContainer(ftypHeader('mif1', ['heic']))).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('transcodeHeicToJpeg', () => {
|
|
it('refuses to decode above the input ceiling', async () => {
|
|
// Uploads allow 100MB; without this bound a tenant could spend an unbounded
|
|
// WASM decode on a single read.
|
|
const oversized = Buffer.alloc(20 * 1024 * 1024 + 1)
|
|
expect(await transcodeHeicToJpeg(oversized)).toBeNull()
|
|
})
|
|
|
|
it('returns null for bytes libheif cannot decode', async () => {
|
|
// Also proves the dynamic `heic-convert` import resolves at runtime, which no
|
|
// amount of type-checking establishes for a lazily loaded WebAssembly module.
|
|
expect(await transcodeHeicToJpeg(ftypHeader('heic'))).toBeNull()
|
|
})
|
|
|
|
it('reports dimensions from `all()` without decoding, which the pixel check relies on', async () => {
|
|
// The guard is only worth anything if `all()` exposes the declared size up front:
|
|
// were dimensions to move behind `decode()` (as they are on the default export),
|
|
// `width * height` would silently become NaN and the check would never reject.
|
|
// Driven with a stub libheif so the real mapping runs without a HEVC encoder.
|
|
const buildDecoder = (await import('heic-decode/lib.js')).default as (lib: unknown) => {
|
|
all: (options: {
|
|
buffer: Buffer
|
|
}) => Promise<Array<{ width: number; height: number }> & { dispose: () => void }>
|
|
}
|
|
let decoded = false
|
|
const { all } = buildDecoder({
|
|
ready: Promise.resolve(),
|
|
HeifDecoder: class {
|
|
decoder = { delete: () => {} }
|
|
decode() {
|
|
return [
|
|
{
|
|
get_width: () => 30_000,
|
|
get_height: () => 20_000,
|
|
free: () => {},
|
|
display: (target: unknown, cb: (t: unknown) => void) => {
|
|
decoded = true
|
|
cb(target)
|
|
},
|
|
},
|
|
]
|
|
}
|
|
},
|
|
})
|
|
|
|
const handles = await all({ buffer: ftypHeader('heic') })
|
|
|
|
expect(handles[0].width * handles[0].height).toBe(600_000_000)
|
|
expect(typeof handles.dispose).toBe('function')
|
|
expect(decoded).toBe(false)
|
|
})
|
|
|
|
it('exposes `all` as a named export, which the pixel check destructures', async () => {
|
|
// A CJS `module.exports = one; module.exports.all = all` need not surface `all`
|
|
// as a named ESM export. If it stopped doing so the pixel check would throw,
|
|
// get swallowed by the catch, and quietly stop guarding — with mocked tests
|
|
// still green. Pin the real shape.
|
|
const { all } = await import('heic-decode')
|
|
expect(typeof all).toBe('function')
|
|
})
|
|
})
|