import { strToU8, zipSync, type Zippable } from "fflate"; import { describe, expect, it } from "vitest"; import type { ToolcraftModelImportLimits } from "../schema/types"; import { TOOLCRAFT_MODEL_IMPORT_LIMIT_CEILINGS } from "./model-import-limits"; import { ToolcraftModelPackageError } from "./model-package-types"; import { extractToolcraftModelPackageZip } from "./model-package-zip"; function limits( overrides: Partial = {}, ): ToolcraftModelImportLimits { return { ...TOOLCRAFT_MODEL_IMPORT_LIMIT_CEILINGS, ...overrides }; } function archive( entries: Zippable, options: Parameters[1] = {}, ): Uint8Array { return zipSync(entries, options); } function expectFailure( action: () => unknown, category: ToolcraftModelPackageError["category"], code: string, ): void { try { action(); throw new Error("Expected model package extraction to fail."); } catch (error) { expect(error).toBeInstanceOf(ToolcraftModelPackageError); expect(error).toMatchObject({ category, code }); } } function signatureOffsets(bytes: Uint8Array, signature: number): number[] { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const offsets: number[] = []; for (let offset = 0; offset <= bytes.byteLength - 4; offset += 1) { if (view.getUint32(offset, true) === signature) offsets.push(offset); } return offsets; } function mutateFlags(bytes: Uint8Array, flags: number): Uint8Array { const mutated = bytes.slice(); const view = new DataView(mutated.buffer); for (const offset of signatureOffsets(mutated, 0x04034b50)) { view.setUint16(offset + 6, flags, true); } for (const offset of signatureOffsets(mutated, 0x02014b50)) { view.setUint16(offset + 8, flags, true); } return mutated; } function mutateCompression( bytes: Uint8Array, compression: number, ): Uint8Array { const mutated = bytes.slice(); const view = new DataView(mutated.buffer); for (const offset of signatureOffsets(mutated, 0x04034b50)) { view.setUint16(offset + 8, compression, true); } for (const offset of signatureOffsets(mutated, 0x02014b50)) { view.setUint16(offset + 10, compression, true); } return mutated; } describe("bounded model ZIP extraction", () => { it("streams stored and deflated entries with normalized metadata", () => { const bytes = archive({ "robot/mesh.bin": [new Uint8Array([1, 2, 3, 4]), { level: 0 }], "robot/scene.gltf": [strToU8('{"asset":{"version":"2.0"}}'), { level: 6 }], "robot/textures/base.png": [new Uint8Array([137, 80, 78, 71]), { level: 6 }], }); const progress: number[] = []; const entries = extractToolcraftModelPackageZip(bytes, { limits: limits(), onProgress: (value) => progress.push(value), }); expect(entries.map(({ path }) => path)).toEqual([ "robot/mesh.bin", "robot/scene.gltf", "robot/textures/base.png", ]); expect(entries.map(({ mimeType }) => mimeType)).toEqual([ "application/octet-stream", "model/gltf+json", "image/png", ]); expect(entries.every(({ contentDigest }) => /^sha256:[0-9a-f]{64}$/u.test(contentDigest))).toBe(true); expect(progress.length).toBeGreaterThan(0); expect(progress).toEqual([...progress].sort((left, right) => left - right)); expect(progress.at(-1)).toBeLessThan(1); }); it("stops between compressed input chunks when cancellation is requested", () => { const controller = new AbortController(); const bytes = archive({ "scene.gltf": new Uint8Array(200_000).map((_, index) => index % 251), }, { level: 0 }); expect(() => extractToolcraftModelPackageZip(bytes, { limits: limits(), onProgress: () => controller.abort(), signal: controller.signal, })).toThrow(/abort|cancel/iu); }); it.each([ ["absolute", "/scene.gltf"], ["parent traversal", "../scene.gltf"], ["backslash", "folder\\scene.gltf"], ])("rejects %s entry paths", (_label, path) => { expectFailure( () => extractToolcraftModelPackageZip( archive({ [path]: strToU8("{}") }), { limits: limits() }, ), "bundle", "archive-path-invalid", ); }); it("rejects paths that collide after NFC normalization", () => { expectFailure( () => extractToolcraftModelPackageZip(archive({ "cafe\u0301.gltf": strToU8("{}"), "caf\u00e9.gltf": strToU8("{}"), }), { limits: limits() }), "bundle", "archive-path-duplicate", ); }); it("rejects duplicate central-directory names", () => { const bytes = archive({ "one.gltf": strToU8("{}"), "two.gltf": strToU8("[]"), }); const mutated = bytes.slice(); const localOffsets = signatureOffsets(mutated, 0x04034b50); const centralOffsets = signatureOffsets(mutated, 0x02014b50); mutated.set(strToU8("one.gltf"), localOffsets[1]! + 30); mutated.set(strToU8("one.gltf"), centralOffsets[1]! + 46); expectFailure( () => extractToolcraftModelPackageZip(mutated, { limits: limits() }), "bundle", "archive-path-duplicate", ); }); it("rejects encrypted and unsupported compression flags", () => { const bytes = archive({ "scene.gltf": strToU8("{}") }); expectFailure( () => extractToolcraftModelPackageZip( mutateFlags(bytes, 1), { limits: limits() }, ), "bundle", "archive-encryption-unsupported", ); expectFailure( () => extractToolcraftModelPackageZip( mutateCompression(bytes, 12), { limits: limits() }, ), "bundle", "archive-compression-unsupported", ); }); it("rejects directory and symlink-like entries", () => { expectFailure( () => extractToolcraftModelPackageZip( archive({ "folder/": new Uint8Array() }), { limits: limits() }, ), "bundle", "archive-entry-type-unsupported", ); const bytes = archive({ "scene.gltf": strToU8("{}") }); const mutated = bytes.slice(); const centralOffset = signatureOffsets(mutated, 0x02014b50)[0]!; const view = new DataView(mutated.buffer); view.setUint16(centralOffset + 4, (3 << 8) | 20, true); view.setUint32(centralOffset + 38, 0xa1ff << 16, true); expectFailure( () => extractToolcraftModelPackageZip(mutated, { limits: limits() }), "bundle", "archive-entry-type-unsupported", ); }); it("enforces path, count, entry, aggregate, and ratio limits", () => { expectFailure( () => extractToolcraftModelPackageZip( archive({ "long-name.gltf": strToU8("{}") }), { limits: limits({ maxArchivePathLength: 5 }) }, ), "resource-limit", "archive-path-limit-exceeded", ); const twoEntries = archive({ "a.bin": new Uint8Array([1]), "b.bin": new Uint8Array([2]), }); expectFailure( () => extractToolcraftModelPackageZip( twoEntries, { limits: limits({ maxArchiveEntries: 1 }) }, ), "resource-limit", "archive-entry-limit-exceeded", ); expectFailure( () => extractToolcraftModelPackageZip( archive({ "mesh.bin": new Uint8Array(9) }, { level: 0 }), { limits: limits({ maxArchiveEntryBytes: 8 }) }, ), "resource-limit", "archive-entry-byte-limit-exceeded", ); expectFailure( () => extractToolcraftModelPackageZip( archive({ "a.bin": new Uint8Array(5), "b.bin": new Uint8Array(5), }, { level: 0 }), { limits: limits({ maxArchiveUncompressedBytes: 9 }) }, ), "resource-limit", "archive-byte-limit-exceeded", ); expectFailure( () => extractToolcraftModelPackageZip( archive({ "dense.bin": new Uint8Array(10_000) }, { level: 9 }), { limits: limits({ maxArchiveCompressionRatio: 2 }) }, ), "resource-limit", "archive-compression-ratio-exceeded", ); }); });