import { describe, expect, it, vi } from "vitest"; import { createToolcraftModelFormatAdapterRegistry, } from "./formats/model-format-adapter"; import { createToolcraftModelSourceBundle, ToolcraftModelSourceBundleError, } from "./model-source-bundle"; import { registerModelSourceBundleSecurityTests } from "./model-source-bundle-security-test-support"; import { registerModelSourceBundlePreflightTests } from "./model-source-bundle-preflight-test-support"; import { createModelSourceTestBundle as bundle, modelSourceTestAdapter as adapter, modelSourceTestBinaryFile as binaryFile, modelSourceTestGltfWithBuffers as gltfWithBuffers, modelSourceTestTextFile as textFile, } from "./model-source-bundle-test-support"; const SHA256_ABC = "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; describe("model source bundle", () => { it("creates a standalone root with real SHA-256 evidence", async () => { const result = await bundle([ binaryFile("scene.glb", "abc", { type: "model/gltf-binary" }), ]); expect(result).toMatchObject({ adapter: { adapterVersion: "glb-test-1", format: "glb", rootExtension: ".glb", }, aggregateByteLength: 3, rootPath: "scene.glb", sourceFiles: [ { byteLength: 3, contentDigest: SHA256_ABC, displayName: "scene.glb", mimeType: "model/gltf-binary", path: "scene.glb", resourceRef: expect.stringMatching( /^toolcraft:model-source:sha256:[a-f0-9]{64}$/u, ), }, ], }); expect(result.aggregateDigest).toMatch(/^sha256:[a-f0-9]{64}$/u); }); it("accepts later standalone adapters without changing bundle resolution", async () => { const stlRegistry = createToolcraftModelFormatAdapterRegistry([ adapter("stl", ".stl"), ]); const result = await createToolcraftModelSourceBundle( [binaryFile("part.stl", [1, 2, 3])], { registry: stlRegistry }, ); expect(result).toMatchObject({ adapter: { format: "stl", rootExtension: ".stl" }, rootPath: "part.stl", }); }); it("rejects non-appearance sidecars instead of silently ignoring them", async () => { await expect( bundle([ binaryFile("scene.glb", [1]), textFile("notes.txt", "not model appearance", { type: "text/plain" }), ]), ).rejects.toMatchObject({ code: "unexpected-source-file" }); }); it("resolves exact sibling glTF buffers and sorts dependencies", async () => { const result = await bundle([ binaryFile("z.bin", [4, 5, 6]), textFile("scene.gltf", gltfWithBuffers(["z.bin", "a.bin"])), binaryFile("a.bin", [1, 2, 3]), ]); expect(result.rootPath).toBe("scene.gltf"); expect(result.sourceFiles.map(({ path }) => path)).toEqual([ "scene.gltf", "a.bin", "z.bin", ]); expect(result.aggregateByteLength).toBe( result.sourceFiles.reduce((sum, file) => sum + file.byteLength, 0), ); }); it("resolves a nested dependency only with browser path evidence", async () => { const result = await bundle([ textFile("scene.gltf", gltfWithBuffers(["buffers/mesh.bin"]), { path: "drop/models/scene.gltf", }), binaryFile("mesh.bin", [1, 2, 3], { path: "drop/models/buffers/mesh.bin", }), ]); expect(result.rootPath).toBe("drop/models/scene.gltf"); expect(result.sourceFiles.map(({ path }) => path)).toEqual([ "drop/models/scene.gltf", "drop/models/buffers/mesh.bin", ]); await expect( bundle([ textFile("scene.gltf", gltfWithBuffers(["buffers/mesh.bin"])), binaryFile("mesh.bin", [1, 2, 3]), ]), ).rejects.toMatchObject({ category: "bundle", code: "unproven-buffer-path", }); }); it("rejects missing and ambiguous local buffers", async () => { await expect( bundle([textFile("scene.gltf", gltfWithBuffers(["mesh.bin"]))]), ).rejects.toMatchObject({ code: "missing-buffer-file" }); await expect( bundle([ textFile("scene.gltf", gltfWithBuffers(["mesh.bin"])), binaryFile("mesh.bin", [1]), binaryFile("mesh.bin", [2]), ]), ).rejects.toMatchObject({ code: "duplicate-source-path" }); }); it("rejects duplicate normalized package paths", async () => { await expect( bundle([ textFile("scene.gltf", gltfWithBuffers([]), { path: "drop/scene.gltf", }), textFile("scene.gltf", gltfWithBuffers([]), { path: "drop/scene.gltf", }), ]), ).rejects.toMatchObject({ code: "duplicate-source-path" }); }); it.each([ "../mesh.bin", "%2e%2e/mesh.bin", "%252e%252e/mesh.bin", "/mesh.bin", "C:/mesh.bin", "folder\\mesh.bin", "mesh\0.bin", "mesh%00.bin", "mesh.bin?download=1", "mesh.bin#fragment", ])("rejects unsafe local buffer URI %j", async (uri) => { await expect( bundle([textFile("scene.gltf", gltfWithBuffers([uri]))]), ).rejects.toMatchObject({ code: "unsafe-buffer-uri" }); }); it.each([ "https://example.com/mesh.bin", "http://example.com/mesh.bin", "//example.com/mesh.bin", "blob:https://example.com/id", "file:///tmp/mesh.bin", ])("rejects remote or privileged buffer URI %j", async (uri) => { await expect( bundle([textFile("scene.gltf", gltfWithBuffers([uri]))]), ).rejects.toMatchObject({ code: "remote-buffer-uri" }); }); it("accepts bounded base64 glTF buffer data URIs", async () => { const octet = await bundle([ textFile( "scene.gltf", gltfWithBuffers(["data:application/octet-stream;base64,AQID"]), ), ]); const gltfBuffer = await bundle([ textFile( "scene.gltf", gltfWithBuffers(["data:application/gltf-buffer;base64,AQID"]), ), ]); expect(octet.sourceFiles.map(({ path }) => path)).toEqual(["scene.gltf"]); expect(gltfBuffer.sourceFiles.map(({ path }) => path)).toEqual([ "scene.gltf", ]); }); it.each([ "data:application/octet-stream,AQID", "data:text/plain;base64,AQID", "data:application/octet-stream;charset=utf-8;base64,AQID", "data:application/octet-stream;base64,A===", "data:application/octet-stream;base64,AQ*D", "data:application/octet-stream;base64,AB==", ])("rejects invalid buffer data URI %j", async (uri) => { await expect( bundle([textFile("scene.gltf", gltfWithBuffers([uri]))]), ).rejects.toMatchObject({ code: "invalid-buffer-data-uri" }); }); it("preflights oversized data URIs without decoding them", async () => { const root = textFile( "scene.gltf", gltfWithBuffers([ `data:application/octet-stream;base64,${"AQID".repeat(20)}`, ]), ); const atobSpy = vi.spyOn(globalThis, "atob"); await expect( bundle([root], { maxDecodedBytes: root.size + 16 }), ).rejects.toMatchObject({ code: "decoded-byte-limit-exceeded" }); expect(atobSpy).not.toHaveBeenCalled(); const oversizedInvalidRoot = textFile( "scene.gltf", gltfWithBuffers([ `data:application/octet-stream;base64,${"****".repeat(20)}`, ]), ); await expect( bundle([oversizedInvalidRoot], { maxDecodedBytes: oversizedInvalidRoot.size + 16, }), ).rejects.toMatchObject({ code: "decoded-byte-limit-exceeded" }); }); it("preserves local appearance files without fetching authored remote URIs", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); const result = await bundle([ textFile( "scene.gltf", gltfWithBuffers(["mesh.bin"], { images: [ { uri: "https://example.com/albedo.png" }, { uri: "../outside/normal.png" }, { uri: "data:image/png;base64,AAAA" }, { uri: "packed-image.bin" }, ], materials: [{ name: "ignored" }], textures: [{ source: 0 }], }), ), binaryFile("mesh.bin", [1, 2, 3]), binaryFile("albedo.png", [4, 5, 6], { type: "image/png" }), binaryFile("packed-image.bin", [7, 8, 9], { type: "image/png" }), ]); expect(result.sourceFiles.map(({ path }) => path)).toEqual([ "scene.gltf", "albedo.png", "mesh.bin", "packed-image.bin", ]); expect(fetchSpy).not.toHaveBeenCalled(); }); it("produces stable file order and aggregate digest across batch order", async () => { const root = textFile("scene.gltf", gltfWithBuffers(["b.bin", "a.bin"])); const first = binaryFile("a.bin", [1, 2, 3]); const second = binaryFile("b.bin", [4, 5, 6]); const left = await bundle([root, first, second]); const right = await bundle([second, root, first]); expect(right.sourceFiles).toEqual(left.sourceFiles); expect(right.aggregateDigest).toBe(left.aggregateDigest); }); it("snapshots the caller file batch before asynchronous reads", async () => { const root = binaryFile("scene.glb", "abc"); const files = [root]; const pending = bundle(files); files[0] = binaryFile("changed.obj", "changed"); await expect(pending).resolves.toMatchObject({ rootPath: "scene.glb" }); }); it("snapshots dependency read methods before awaiting the root", async () => { const root = textFile("scene.gltf", gltfWithBuffers(["mesh.bin"])); const rootBytes = await root.arrayBuffer(); let releaseRoot: (() => void) | undefined; Object.defineProperty(root, "arrayBuffer", { configurable: true, value: vi.fn( () => new Promise((resolve) => { releaseRoot = () => resolve(rootBytes); }), ), }); const dependency = binaryFile("mesh.bin", [1, 2, 3]); const dependencyRead = vi.spyOn(dependency, "arrayBuffer"); const pending = bundle([root, dependency]); Object.defineProperty(dependency, "arrayBuffer", { configurable: true, value: vi.fn(async () => { throw new Error("mutated dependency reader"); }), }); releaseRoot?.(); await expect(pending).resolves.toMatchObject({ rootPath: "scene.gltf" }); expect(dependencyRead).toHaveBeenCalledTimes(1); }); it("fails closed on file-count, source-byte, and decoded-byte limits", async () => { const fileCountRoot = textFile("scene.gltf", gltfWithBuffers(["mesh.bin"])); await expect( bundle([fileCountRoot, binaryFile("mesh.bin", [2])], { maxBundleFiles: 1, }), ).rejects.toMatchObject({ code: "bundle-file-limit-exceeded" }); await expect( bundle([binaryFile("scene.glb", [1, 2, 3, 4])], { maxSourceBytes: 3, }), ).rejects.toMatchObject({ code: "source-byte-limit-exceeded" }); const aggregateRoot = textFile("scene.gltf", gltfWithBuffers(["mesh.bin"])); await expect( bundle([aggregateRoot, binaryFile("mesh.bin", [1, 2, 3])], { maxSourceBytes: aggregateRoot.size + 2, }), ).rejects.toMatchObject({ code: "source-byte-limit-exceeded" }); await expect( bundle( [ textFile("scene.gltf", gltfWithBuffers(["mesh.bin"])), binaryFile("mesh.bin", [1, 2, 3]), ], { maxDecodedBytes: 2, }, ), ).rejects.toMatchObject({ code: "decoded-byte-limit-exceeded" }); }); it("checks decoded root size before reading or parsing source bytes", async () => { const root = textFile("scene.gltf", gltfWithBuffers([])); const readSpy = vi.spyOn(root, "arrayBuffer"); await expect( bundle([root], { maxDecodedBytes: root.size - 1 }), ).rejects.toMatchObject({ code: "decoded-byte-limit-exceeded" }); expect(readSpy).not.toHaveBeenCalled(); }); it("rejects widened or malformed limit configuration", async () => { await expect( bundle([binaryFile("scene.glb", [1])], { maxArchiveCompressionRatio: 1.5, }), ).resolves.toMatchObject({ rootPath: "scene.glb" }); await expect( bundle([binaryFile("scene.glb", [1])], { maxArchiveCompressionRatio: 0.5, }), ).rejects.toMatchObject({ code: "invalid-model-limit" }); await expect( bundle([binaryFile("scene.glb", [1])], { maxBundleFiles: Number.MAX_SAFE_INTEGER, }), ).rejects.toMatchObject({ code: "invalid-model-limit" }); await expect( bundle([binaryFile("scene.glb", [1])], { maxSourceBytes: 0, }), ).rejects.toMatchObject({ code: "invalid-model-limit" }); }); it("reports malformed glTF JSON and typed file-read failures", async () => { await expect(bundle([textFile("scene.gltf", "{")])).rejects.toMatchObject({ category: "format", code: "malformed-gltf-json", }); const unreadable = binaryFile("scene.glb", [1]); Object.defineProperty(unreadable, "arrayBuffer", { value: vi.fn(async () => { throw new DOMException("read failed", "NotReadableError"); }), }); const rejection = await bundle([unreadable]).catch( (error: unknown) => error, ); expect(rejection).toBeInstanceOf(ToolcraftModelSourceBundleError); expect(rejection).toMatchObject({ category: "resource-unavailable", code: "source-file-read-failed", }); }); it("rejects unsafe or conflicting browser path evidence", async () => { await expect( bundle([binaryFile("scene.glb", [1], { path: "../drop/scene.glb" })]), ).rejects.toMatchObject({ code: "unsafe-source-path" }); await expect( bundle([binaryFile("scene.glb", [1], { path: "drop\\scene.glb" })]), ).rejects.toMatchObject({ code: "unsafe-source-path" }); await expect( bundle([binaryFile("scene.glb", [1], { path: "drop/not-scene.glb" })]), ).rejects.toMatchObject({ code: "invalid-source-path-evidence" }); }); }); registerModelSourceBundleSecurityTests(); registerModelSourceBundlePreflightTests();