fix: keep non-ASCII names readable in zip downloads

The zip writer stores entry names as UTF-8 but did not set the UTF-8 flag
(general purpose bit 11), so readers decoded them as CP437 or Latin-1.
Any model, category or file name with a non-ASCII character came out
garbled, including when the zip was imported back into Thingport.
This commit is contained in:
Mantas Kantautas committed 2026-10-21 14:07:33 +03:00
1 parent 407bce431a
commit 060be2f692
2 files changed
+42 -2

No files matched your search

+7 -2
View File
@@ -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);
+35
View File
@@ -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");
});
});