diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index 04e6590e8c..fa4c78b6f6 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -750,7 +750,7 @@ export async function importSingleFurni(params: { !existsSync(/*turbopackIgnore: true*/ nitroPath) ) { const nitroUrl = `${sourceNitroBaseUrl}/${encodeURIComponent(safeNitroName)}.nitro`; - const dl = await downloadFile(nitroUrl, nitroPath, { validate: "png" }); + const dl = await downloadFile(nitroUrl, nitroPath, { validate: "nitro" }); if (dl.ok) nitroDownloadOk = true; } @@ -843,7 +843,10 @@ export async function importSingleFurni(params: { warnings, error: isUpdate ? "No .nitro file could be created; existing furniture was preserved" - : "Import rolled back: no .nitro file could be created", + : `Import rolled back: no .nitro file could be created. ${warnings + .filter((warning) => /conversion|download|SWF/.test(warning)) + .slice(-3) + .join(". ")}`, }; } diff --git a/src/lib/services/import/core/download.test.ts b/src/lib/services/import/core/download.test.ts index 883b00dc02..1d491602d5 100644 --- a/src/lib/services/import/core/download.test.ts +++ b/src/lib/services/import/core/download.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { validatePngBytes, validateSwfBytes } from "./download"; +import { createNitroBundle } from "../../swf/nitro-builder"; +import { + validateNitroBytes, + validatePngBytes, + validateSwfBytes, +} from "./download"; describe("import/core/download validators", () => { it("accepts FWS/CWS/ZWS swf magic", () => { @@ -22,3 +27,14 @@ describe("import/core/download validators", () => { expect(validatePngBytes(Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]))).toBe(false); }); }); + +it("validates nitro bundles separately from PNG files", () => { + const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + const bundle = createNitroBundle({ name: "chair" }, png, "chair"); + expect(validateNitroBytes(bundle)).toBe(true); + expect(validatePngBytes(bundle)).toBe(false); + expect(validateNitroBytes(png)).toBe(false); + expect(validateNitroBytes(Buffer.from("Access denied"))).toBe( + false, + ); +}); diff --git a/src/lib/services/import/core/download.ts b/src/lib/services/import/core/download.ts index 603af91775..669e5542b5 100644 --- a/src/lib/services/import/core/download.ts +++ b/src/lib/services/import/core/download.ts @@ -1,4 +1,5 @@ import { promises as fs } from "node:fs"; +import { parseNitroBundle } from "../../swf/nitro-builder"; import { browserHeaders } from "./browser-headers"; @@ -18,6 +19,20 @@ export function validatePngBytes(buffer: Buffer): boolean { ); } +export function validateNitroBytes(buffer: Buffer): boolean { + try { + const bundle = parseNitroBundle(buffer); + return ( + !!bundle.json && + typeof bundle.json === "object" && + !Array.isArray(bundle.json) && + validatePngBytes(bundle.png) + ); + } catch { + return false; + } +} + export async function tryDownloadCandidates( urls: string[], destPath: string, @@ -33,7 +48,7 @@ export async function tryDownloadCandidates( export async function downloadFile( url: string, destPath: string, - options?: { maxRetries?: number; validate?: "swf" | "png" }, + options?: { maxRetries?: number; validate?: "swf" | "png" | "nitro" }, ): Promise<{ ok: boolean; size: number }> { const maxRetries = options?.maxRetries ?? 3; const baseDelay = 1000; @@ -79,6 +94,8 @@ export async function downloadFile( } continue; } + if (options?.validate === "nitro" && !validateNitroBytes(buffer)) + return { ok: false, size: 0 }; await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer); return { ok: true, size: buffer.length }; } catch (err) { diff --git a/src/lib/services/swf/conversion-pool-recovery.test.ts b/src/lib/services/swf/conversion-pool-recovery.test.ts new file mode 100644 index 0000000000..c261a7479c --- /dev/null +++ b/src/lib/services/swf/conversion-pool-recovery.test.ts @@ -0,0 +1,45 @@ +// @vitest-environment node +import { EventEmitter } from "node:events"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + convert: vi.fn(() => ({ bundle: Buffer.from("converted") })), + icon: vi.fn(() => Buffer.from("icon")), +})); +vi.mock("./index", () => ({ + convertSwfToNitro: mocks.convert, + extractIconFromSwf: mocks.icon, +})); +vi.mock("node:worker_threads", () => ({ + Worker: class extends EventEmitter { + postMessage() { + queueMicrotask(() => + this.emit("error", new Error("worker module missing")), + ); + } + }, +})); +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + vi.clearAllMocks(); +}); +describe("conversion worker recovery", () => { + it("falls back when worker startup rejects the conversion job", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("VITEST", undefined); + const { convertSwfToNitroAsync } = await import("./conversion-pool"); + const data = Buffer.from("SWF"); + const result = await convertSwfToNitroAsync(data, "chair"); + expect(result.bundle.toString()).toBe("converted"); + expect(mocks.convert).toHaveBeenCalledWith(data, "chair"); + }); + it("recovers icon extraction from a worker startup error", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("VITEST", undefined); + const { extractIconFromSwfAsync } = await import("./conversion-pool"); + expect( + (await extractIconFromSwfAsync(Buffer.from("SWF"), "chair"))?.toString(), + ).toBe("icon"); + }); +}); diff --git a/src/lib/services/swf/conversion-pool.ts b/src/lib/services/swf/conversion-pool.ts index 58821516e6..3e217fd463 100644 --- a/src/lib/services/swf/conversion-pool.ts +++ b/src/lib/services/swf/conversion-pool.ts @@ -154,8 +154,10 @@ export async function convertSwfToNitroAsync( buffer: Buffer, classname: string, ): Promise { - const res = await run({ type: "convert", buffer, classname }); - if (res.ok && res.result) return res.result; + const res = await run({ type: "convert", buffer, classname }).catch( + () => null, + ); + if (res?.ok && res.result) return res.result; // Worker failed for this item — fall back to the main thread. return convertSwfToNitro(buffer, classname); } @@ -164,7 +166,7 @@ export async function extractIconFromSwfAsync( buffer: Buffer, classname: string, ): Promise { - const res = await run({ type: "icon", buffer, classname }); - if (res.ok) return res.icon ?? null; + const res = await run({ type: "icon", buffer, classname }).catch(() => null); + if (res?.ok) return res.icon ?? null; return extractIconFromSwf(buffer, classname); }