diff --git a/backend/src/utils/zipWriter.ts b/backend/src/utils/zipWriter.ts index cee8566..e13180e 100644 --- a/backend/src/utils/zipWriter.ts +++ b/backend/src/utils/zipWriter.ts @@ -4,6 +4,11 @@ import zlib from "node:zlib"; // Minimal streaming ZIP writer on node:zlib; yauzl is read-only. +// Bit 3: the CRC and sizes follow the data, in a descriptor. Bit 11: names are UTF-8. Without it readers +// decode names as CP437 (yauzl, Python) or Latin-1 (fflate), garbling any non-ASCII model, category or +// file name. +const ENTRY_FLAGS = 0x0808; + const CRC_TABLE = (() => { const table = new Uint32Array(256); for (let n = 0; n < 256; n++) { @@ -70,7 +75,7 @@ export async function writeZip(destPath: string, entries: ZipEntryDescriptor[]): const localHeader = Buffer.alloc(30); localHeader.writeUInt32LE(0x04034b50, 0); localHeader.writeUInt16LE(20, 4); // version needed - localHeader.writeUInt16LE(0x0008, 6); // flags: data descriptor follows + localHeader.writeUInt16LE(ENTRY_FLAGS, 6); // flags localHeader.writeUInt16LE(8, 8); // method: deflate localHeader.writeUInt16LE(time, 10); localHeader.writeUInt16LE(date, 12); @@ -125,7 +130,7 @@ export async function writeZip(destPath: string, entries: ZipEntryDescriptor[]): central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); // version made by central.writeUInt16LE(20, 6); // version needed - central.writeUInt16LE(0x0008, 8); // flags + central.writeUInt16LE(ENTRY_FLAGS, 8); // flags central.writeUInt16LE(8, 10); // method central.writeUInt16LE(rec.time, 12); central.writeUInt16LE(rec.date, 14); diff --git a/backend/tests/zipWriter.test.ts b/backend/tests/zipWriter.test.ts new file mode 100644 index 0000000..5fa28db --- /dev/null +++ b/backend/tests/zipWriter.test.ts @@ -0,0 +1,35 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { writeZip } from "../src/utils/zipWriter"; +import { listZipEntries, readZipEntry } from "../src/utils/zipReader"; + +// Zip downloads name entries after models, categories and files, which are often not ASCII. A reader only +// decodes a name as UTF-8 when the entry is flagged as such, so the round trip goes through the app's own +// reader, the one behind zip imports and the consume folder. + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "thingport-zip-writer-")); + +afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); + +describe("writeZip", () => { + it("keeps non-ASCII names when the zip is read back", async () => { + const source = path.join(dir, "benchy.stl"); + fs.writeFileSync(source, "solid benchy endsolid"); + const names = [ + "Gyvūnai/Šuniukas – Benchy/šuniukas.stl", + "Unassigned/哨子/哨子.3mf", + "Figurines/Pokémon Pikachu/supporting/notes.txt", + "Tools/Cable clip/clip.stl", + ]; + const zipPath = path.join(dir, "names.zip"); + await writeZip( + zipPath, + names.map((arcname) => ({ arcname, filePath: source })), + ); + + expect((await listZipEntries(zipPath)).map((e) => e.name)).toEqual(names); + expect((await readZipEntry(zipPath, names[0], 1024))?.toString()).toBe("solid benchy endsolid"); + }); +});