diff --git a/src/lib/services/furni-import-repair.test.ts b/src/lib/services/furni-import-repair.test.ts index d1c006fbbb..79314b180c 100644 --- a/src/lib/services/furni-import-repair.test.ts +++ b/src/lib/services/furni-import-repair.test.ts @@ -159,6 +159,7 @@ it("imports recycler_kintsugiB with an allocated ID throughout SQL and furnidata ]), expect.any(String), "swf", + expect.any(Function), ); expect(mocks.build).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index fdfd92a787..35f2e493fc 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -731,13 +731,19 @@ export async function importSingleFurni(params: { // Download icon + SWF concurrently (independent assets). Each tries its // candidate URLs in order until one succeeds. + let iconFailure = "", + swfFailure = ""; const [iconOkResult, swfOk] = await Promise.all([ preservingExisting && existsSync(iconPath) ? Promise.resolve(true) - : tryDownloadCandidates(iconUrls, iconPath, "png"), + : tryDownloadCandidates(iconUrls, iconPath, "png", (detail) => { + iconFailure = detail; + }), preservingExisting && existsSync(swfPath) ? Promise.resolve(true) - : tryDownloadCandidates(swfUrls, swfPath, "swf"), + : tryDownloadCandidates(swfUrls, swfPath, "swf", (detail) => { + swfFailure = detail; + }), ]); let iconOk = iconOkResult; @@ -772,10 +778,13 @@ export async function importSingleFurni(params: { warnings.push("Icon download failed and SWF icon extraction also failed"); } } else if (!iconOk) { - warnings.push("Icon image download failed"); + warnings.push( + `Icon image download failed: ${iconFailure || "unknown cause"}`, + ); } - if (!swfOk && !nitroDownloadOk) warnings.push("SWF download failed"); + if (!swfOk && !nitroDownloadOk) + warnings.push(`SWF download failed: ${swfFailure || "unknown cause"}`); // ── Convert SWF to Nitro ────────────────────────────────────────── onProgress?.("converting"); @@ -841,12 +850,10 @@ export async function importSingleFurni(params: { return { ok: false, warnings, - error: isUpdate - ? "No .nitro file could be created; existing furniture was preserved" - : `Import rolled back: no .nitro file could be created. ${warnings - .filter((warning) => /conversion|download|SWF/.test(warning)) - .slice(-3) - .join(". ")}`, + error: `${isUpdate ? "No .nitro file could be created; existing furniture was preserved" : "Import rolled back: no .nitro file could be created"} (${classname}, revision ${rev}). ${warnings + .filter((warning) => /conversion|download|SWF/.test(warning)) + .slice(-3) + .join(". ")}`, }; } diff --git a/src/lib/services/import/core/download-errors.test.ts b/src/lib/services/import/core/download-errors.test.ts new file mode 100644 index 0000000000..d68e80bf3c --- /dev/null +++ b/src/lib/services/import/core/download-errors.test.ts @@ -0,0 +1,76 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const write = vi.hoisted(() => vi.fn()); +vi.mock("node:fs", () => ({ promises: { writeFile: write } })); + +import { downloadFile, tryDownloadCandidates } from "./download"; + +const fetchMock = vi.fn(); +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + write.mockReset().mockResolvedValue(undefined); +}); +afterEach(() => vi.unstubAllGlobals()); +const url = "https://cdn.example/73706/chair.swf"; +describe("download failure diagnostics", () => { + it("reports HTTP status without writing an error page", async () => { + fetchMock.mockResolvedValue(new Response("missing", { status: 404 })); + expect( + ( + await downloadFile(url, "/assets/chair.swf", { + validate: "swf", + maxRetries: 0, + }) + ).error, + ).toBe("HTTP 404"); + expect(write).not.toHaveBeenCalled(); + }); + it("distinguishes a successful download from denied file permissions", async () => { + fetchMock.mockResolvedValue(new Response(Buffer.from("FWS12345"))); + write.mockRejectedValue( + Object.assign(new Error("denied"), { code: "EACCES" }), + ); + expect( + ( + await downloadFile(url, "/assets/chair.swf", { + validate: "swf", + maxRetries: 0, + }) + ).error, + ).toBe("Cannot save file (EACCES)"); + }); + it("reports DNS failures", async () => { + fetchMock.mockRejectedValue( + Object.assign(new Error("fetch failed"), { + cause: { code: "ENOTFOUND" }, + }), + ); + expect( + (await downloadFile(url, "/assets/chair.swf", { maxRetries: 0 })).error, + ).toBe("Network failure (ENOTFOUND)"); + }); + it("reports a non-SWF response", async () => { + fetchMock.mockResolvedValue(new Response("not an asset")); + expect( + ( + await downloadFile(url, "/assets/chair.swf", { + validate: "swf", + maxRetries: 0, + }) + ).error, + ).toBe("Response is not a SWF file"); + }); + it("forwards the failing path without URL credentials or query secrets", async () => { + fetchMock.mockResolvedValue(new Response("missing", { status: 404 })); + const failure = vi.fn(); + await tryDownloadCandidates( + ["https://user:secret@cdn.example/0/chair.swf?token=private"], + "/assets/chair.swf", + "swf", + failure, + ); + expect(failure).toHaveBeenCalledWith("cdn.example/0/chair.swf: HTTP 404"); + }); +}); diff --git a/src/lib/services/import/core/download.ts b/src/lib/services/import/core/download.ts index 669e5542b5..c6f067f47e 100644 --- a/src/lib/services/import/core/download.ts +++ b/src/lib/services/import/core/download.ts @@ -37,10 +37,15 @@ export async function tryDownloadCandidates( urls: string[], destPath: string, kind: "swf" | "png", + onFailure?: (detail: string) => void, ): Promise { for (const url of urls) { const res = await downloadFile(url, destPath, { validate: kind }); if (res.ok) return true; + const source = new URL(url); + onFailure?.( + `${source.host}${source.pathname}: ${res.error ?? "download failed"}`, + ); } return false; } @@ -49,11 +54,12 @@ export async function downloadFile( url: string, destPath: string, options?: { maxRetries?: number; validate?: "swf" | "png" | "nitro" }, -): Promise<{ ok: boolean; size: number }> { +): Promise<{ ok: boolean; size: number; error?: string }> { const maxRetries = options?.maxRetries ?? 3; const baseDelay = 1000; for (let attempt = 0; attempt <= maxRetries; attempt++) { + let stage: "download" | "write" = "download"; try { if (attempt > 0) { await new Promise((r) => setTimeout(r, baseDelay * 2 ** (attempt - 1))); @@ -71,40 +77,53 @@ export async function downloadFile( console.warn( `[import-download] Download failed ${url}: ${res.status}${deterministic ? " (deterministic, not retrying)" : ` after ${maxRetries + 1} attempts`}`, ); - return { ok: false, size: 0 }; + return { ok: false, size: 0, error: `HTTP ${res.status}` }; } continue; } const buffer = Buffer.from(await res.arrayBuffer()); if (buffer.length < 8) { - if (attempt === maxRetries) return { ok: false, size: 0 }; + if (attempt === maxRetries) + return { ok: false, size: 0, error: "Empty or truncated response" }; continue; } if (options?.validate === "swf" && !validateSwfBytes(buffer)) { if (attempt === maxRetries) { console.warn(`[import-download] Invalid SWF magic bytes from ${url}`); - return { ok: false, size: 0 }; + return { ok: false, size: 0, error: "Response is not a SWF file" }; } continue; } if (options?.validate === "png" && !validatePngBytes(buffer)) { if (attempt === maxRetries) { console.warn(`[import-download] Invalid PNG magic bytes from ${url}`); - return { ok: false, size: 0 }; + return { ok: false, size: 0, error: "Response is not a PNG file" }; } continue; } if (options?.validate === "nitro" && !validateNitroBytes(buffer)) - return { ok: false, size: 0 }; + return { ok: false, size: 0, error: "Invalid Nitro bundle" }; + stage = "write"; await fs.writeFile(/*turbopackIgnore: true*/ destPath, buffer); return { ok: true, size: buffer.length }; } catch (err) { - if (attempt === maxRetries) { + if (attempt === maxRetries || stage === "write") { console.warn( `[import-download] Download error ${url}:`, (err as Error).message, ); - return { ok: false, size: 0 }; + const failure = err as { + code?: string; + name?: string; + cause?: { code?: string }; + }; + const code = + failure.code ?? failure.cause?.code ?? failure.name ?? "unknown"; + return { + ok: false, + size: 0, + error: `${stage === "write" ? "Cannot save file" : "Network failure"} (${code})`, + }; } } }