import { createHash } from "node:crypto"; import { runInNewContext } from "node:vm"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ToolcraftModelFormatAdapter, ToolcraftModelSourceBundle, } from "./model-import-types"; import { createToolcraftModelFormatAdapterRegistry } from "./formats/model-format-adapter"; import { createToolcraftModelSourceBundle, createToolcraftModelSourceBundleSnapshot, ToolcraftModelSourceBundleError, } from "./model-source-bundle"; const SHA256_ABC = "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; function adapter( format: ToolcraftModelFormatAdapter["format"], extension: string, ): ToolcraftModelFormatAdapter { return { adapterVersion: `${format}-security-1`, async decode() { throw new Error("decode is not used by source bundle security tests"); }, format, rootExtensions: [extension], workerCapable: true, }; } const securityRegistry = createToolcraftModelFormatAdapterRegistry([ adapter("glb", ".glb"), adapter("gltf", ".gltf"), ]); function binaryFile( name: string, bytes: BlobPart | number[], options: { path?: string; type?: string } = {}, ): File { const file = new File( [Array.isArray(bytes) ? new Uint8Array(bytes) : bytes], name, { type: options.type ?? "application/octet-stream" }, ); if (options.path !== undefined) { Object.defineProperty(file, "webkitRelativePath", { configurable: true, value: options.path, }); } return file; } function readBackedFile( name: string, size: number, read: () => Promise, type = "application/octet-stream", ): File { return { arrayBuffer: read, name, size, type, webkitRelativePath: "", } as unknown as File; } function gltfWithBuffers( uris: readonly string[], extra: Record = {}, ): string { return JSON.stringify({ asset: { version: "2.0" }, buffers: uris.map((uri) => ({ byteLength: 3, uri })), ...extra, }); } function textFile( name: string, text: string, options: { path?: string; type?: string } = {}, ): File { return binaryFile(name, text, { ...options, type: options.type ?? "model/gltf+json", }); } function bundle(files: readonly File[]): Promise { return createToolcraftModelSourceBundle(files, { registry: securityRegistry, }); } function independentLengthDelimitedDigest(fields: readonly string[]): string { const encoder = new TextEncoder(); const encoded = fields.map((field) => encoder.encode(field)); const framed = new Uint8Array( encoded.reduce((total, field) => total + 4 + field.byteLength, 0), ); const view = new DataView(framed.buffer); let offset = 0; for (const field of encoded) { view.setUint32(offset, field.byteLength, false); offset += 4; framed.set(field, offset); offset += field.byteLength; } return `sha256:${createHash("sha256").update(framed).digest("hex")}`; } function serializedFeedback(error: ToolcraftModelSourceBundleError): string { return JSON.stringify({ category: error.category, code: error.code, message: error.message, name: error.name, stack: error.stack, text: String(error), }); } async function captureBundleFeedback( promise: Promise, ): Promise { const rejection: unknown = await promise.then( () => undefined, (error: unknown) => error, ); expect(rejection).toBeInstanceOf(ToolcraftModelSourceBundleError); return rejection as ToolcraftModelSourceBundleError; } export function registerModelSourceBundleSecurityTests(): void { describe("model source bundle security regressions", () => { afterEach(() => vi.restoreAllMocks()); it("copies source buffers once and derives bundle and transfer from owned bytes", async () => { const returnedBytes = new Uint8Array([97, 98, 99]); const result = await createToolcraftModelSourceBundleSnapshot( [ readBackedFile( "scene.glb", returnedBytes.byteLength, async () => returnedBytes.buffer, "model/gltf-binary", ), ], { registry: securityRegistry }, ); const aggregateDigest = result.bundle.aggregateDigest; returnedBytes.fill(0); const transferBytes = new Uint8Array( result.transfer.sourceFiles[0]!.bytes, ); expect([...transferBytes]).toEqual([97, 98, 99]); expect(result.bundle.sourceFiles[0]!.contentDigest).toBe(SHA256_ABC); transferBytes.fill(255); expect(result.bundle.aggregateDigest).toBe(aggregateDigest); expect(result.bundle.sourceFiles[0]!.contentDigest).toBe(SHA256_ABC); }); it("isolates browser File bytes mutated during and after an asynchronous read", async () => { const mutableInput = new Uint8Array([97, 98, 99]); let releaseRead: (() => void) | undefined; let returnedBuffer: ArrayBuffer | undefined; const file = readBackedFile("scene.glb", mutableInput.byteLength, () => { returnedBuffer = mutableInput.slice().buffer; return new Promise((resolve) => { releaseRead = () => resolve(returnedBuffer!); }); }); const pending = createToolcraftModelSourceBundleSnapshot([file], { registry: securityRegistry, }); mutableInput.fill(0); releaseRead?.(); const result = await pending; new Uint8Array(returnedBuffer!).fill(255); expect(result.bundle.sourceFiles[0]!.contentDigest).toBe(SHA256_ABC); expect([ ...new Uint8Array(result.transfer.sourceFiles[0]!.bytes), ]).toEqual([97, 98, 99]); }); it("accepts a genuine cross-realm ArrayBuffer and returns current-realm bytes", async () => { const foreignBuffer = runInNewContext( "new Uint8Array([97, 98, 99]).buffer", ) as ArrayBuffer; expect(foreignBuffer).not.toBeInstanceOf(ArrayBuffer); const result = await createToolcraftModelSourceBundleSnapshot( [readBackedFile("scene.glb", 3, async () => foreignBuffer)], { registry: securityRegistry }, ); expect(result.bundle.sourceFiles[0]!.contentDigest).toBe(SHA256_ABC); expect(result.transfer.sourceFiles[0]!.bytes).toBeInstanceOf(ArrayBuffer); }); it("uses owned glTF root and dependency bytes for inspection and transfer", async () => { const encoder = new TextEncoder(); const rootBytes = encoder.encode(gltfWithBuffers(["mesh.bin"])); const dependencyBytes = new Uint8Array([1, 2, 3]); const result = await createToolcraftModelSourceBundleSnapshot( [ readBackedFile( "scene.gltf", rootBytes.byteLength, async () => rootBytes.buffer, "model/gltf+json", ), readBackedFile( "mesh.bin", dependencyBytes.byteLength, async () => dependencyBytes.buffer, ), ], { registry: securityRegistry }, ); rootBytes.fill(0); dependencyBytes.fill(0); expect(result.bundle.sourceFiles.map(({ path }) => path)).toEqual([ "scene.gltf", "mesh.bin", ]); expect([ ...new Uint8Array(result.transfer.sourceFiles[1]!.bytes), ]).toEqual([1, 2, 3]); }); it.each([ ["SharedArrayBuffer", new SharedArrayBuffer(3)], [ "spoofed ArrayBuffer", { byteLength: 3, [Symbol.toStringTag]: "ArrayBuffer" }, ], ])("rejects %s bytes deterministically", async (_label, value) => { const rejection = await createToolcraftModelSourceBundleSnapshot( [readBackedFile("scene.glb", 3, async () => value)], { registry: securityRegistry }, ).catch((error: unknown) => error); expect(rejection).toBeInstanceOf(ToolcraftModelSourceBundleError); expect(rejection).toMatchObject({ category: "resource-unavailable", code: "source-file-buffer-invalid", }); }); it("rejects a declared File.size mismatch with typed feedback", async () => { await expect( createToolcraftModelSourceBundleSnapshot( [ readBackedFile( "scene.glb", 4, async () => new Uint8Array([1, 2, 3]).buffer, ), ], { registry: securityRegistry }, ), ).rejects.toMatchObject({ category: "resource-unavailable", code: "source-file-size-mismatch", }); }); it("redacts remote URI credentials, tokens, fragments, and hostile paths", async () => { const remoteSecret = "PASSWORD_SECRET"; const querySecret = "QUERY_SECRET"; const fragmentSecret = "FRAGMENT_SECRET"; const pathSecret = "PATH_SECRET"; const dataUriSecret = "DATA_URI_SECRET"; const remote = await captureBundleFeedback( bundle([ textFile( "scene.gltf", gltfWithBuffers([ `https://user:${remoteSecret}@example.com/mesh.bin?token=${querySecret}#${fragmentSecret}`, ]), ), ]), ); const unsafeUri = await captureBundleFeedback( bundle([ textFile( "scene.gltf", gltfWithBuffers([ `../private/${pathSecret}.bin?token=${querySecret}`, ]), ), ]), ); const unsafePath = await captureBundleFeedback( bundle([ binaryFile("scene.glb", [1], { path: `../private/${pathSecret}/scene.glb`, }), ]), ); const invalidDataUri = await captureBundleFeedback( bundle([ textFile( "scene.gltf", gltfWithBuffers([`data:text/plain;base64,${dataUriSecret}`]), ), ]), ); for (const feedback of [remote, unsafeUri, unsafePath, invalidDataUri]) { const serialized = serializedFeedback(feedback); for (const secret of [ remoteSecret, querySecret, fragmentSecret, pathSecret, dataUriSecret, "user:", "example.com", ]) { expect(serialized).not.toContain(secret); } } }); it("rejects duplicate appearance paths and never fetches image URIs", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); await expect(bundle([ textFile( "scene.gltf", gltfWithBuffers(["mesh.bin"], { images: [ { uri: "https://user:secret@example.com/texture.png?token=x" }, ], }), ), binaryFile("mesh.bin", [1, 2, 3]), binaryFile("texture.png", [4], { type: "image/png" }), binaryFile("texture.png", [5], { type: "image/png" }), ])).rejects.toMatchObject({ code: "duplicate-source-path" }); expect(fetchSpy).not.toHaveBeenCalled(); }); it("normalizes proven Unicode paths to NFC after percent decoding", async () => { const decomposedName = "cafe\u0301.bin"; const result = await bundle([ textFile("scene.gltf", gltfWithBuffers(["buffers/caf%C3%A9.bin"]), { path: "drop/models/scene.gltf", }), binaryFile(decomposedName, [1, 2, 3], { path: `drop/models/buffers/${decomposedName}`, }), ]); expect(result.sourceFiles[1]).toMatchObject({ displayName: "café.bin", path: "drop/models/buffers/café.bin", }); }); it("rejects ambiguous Unicode normalization collisions", async () => { const decomposedName = "cafe\u0301.bin"; await expect( bundle([ textFile("scene.gltf", gltfWithBuffers(["buffers/caf%C3%A9.bin"]), { path: "drop/models/scene.gltf", }), binaryFile("café.bin", [1, 2, 3], { path: "drop/models/buffers/café.bin", }), binaryFile(decomposedName, [1, 2, 3], { path: `drop/models/buffers/${decomposedName}`, }), ]), ).rejects.toMatchObject({ code: "ambiguous-normalized-path" }); }); it("matches an independently framed aggregate SHA-256 manifest", async () => { const result = await bundle([ binaryFile("scene.glb", "abc", { type: "model/gltf-binary" }), ]); const source = result.sourceFiles[0]!; // Manifest v2: package domain, adapter identity, root, then logical entries. const expected = independentLengthDelimitedDigest([ "toolcraft-model-source-bundle-v2", "model-package", result.adapter.format, result.adapter.adapterVersion, result.adapter.rootExtension, result.rootPath, "1", source.path, source.mimeType, String(source.byteLength), source.contentDigest, ]); expect(result.aggregateDigest).toBe(expected); expect(independentLengthDelimitedDigest(["ab", "c"])).not.toBe( independentLengthDelimitedDigest(["a", "bc"]), ); }); }); }