From 0ca479de9c362486ff8ea7ef710b881210642694 Mon Sep 17 00:00:00 2001 From: dotta Date: Fri, 20 Mar 2026 14:09:21 -0500 Subject: [PATCH] Handle directory entries in imported zip archives --- ui/src/lib/zip.test.ts | 84 ++++++++++++++++++++++++++++++++++++++++++ ui/src/lib/zip.ts | 8 ++-- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/ui/src/lib/zip.test.ts b/ui/src/lib/zip.test.ts index 4a07c75c..60258a8d 100644 --- a/ui/src/lib/zip.test.ts +++ b/ui/src/lib/zip.test.ts @@ -110,6 +110,78 @@ function createDeflatedZipArchive(files: Record, rootPath: strin return archive; } +function createZipArchiveWithDirectoryEntries(rootPath: string) { + const encoder = new TextEncoder(); + const entries = [ + { path: `${rootPath}/`, body: new Uint8Array(0), compressionMethod: 0 }, + { path: `${rootPath}/agents/`, body: new Uint8Array(0), compressionMethod: 0 }, + { path: `${rootPath}/agents/ceo/`, body: new Uint8Array(0), compressionMethod: 0 }, + { path: `${rootPath}/COMPANY.md`, body: encoder.encode("# Company\n"), compressionMethod: 8 }, + { path: `${rootPath}/agents/ceo/AGENTS.md`, body: encoder.encode("# CEO\n"), compressionMethod: 8 }, + ].map((entry) => ({ + ...entry, + data: entry.compressionMethod === 8 ? new Uint8Array(deflateRawSync(entry.body)) : entry.body, + checksum: crc32(entry.body), + })); + + const localChunks: Uint8Array[] = []; + const centralChunks: Uint8Array[] = []; + let localOffset = 0; + + for (const entry of entries) { + const fileName = encoder.encode(entry.path); + const localHeader = new Uint8Array(30 + fileName.length); + writeUint32(localHeader, 0, 0x04034b50); + writeUint16(localHeader, 4, 20); + writeUint16(localHeader, 6, 0x0800); + writeUint16(localHeader, 8, entry.compressionMethod); + writeUint32(localHeader, 14, entry.checksum); + writeUint32(localHeader, 18, entry.data.length); + writeUint32(localHeader, 22, entry.body.length); + writeUint16(localHeader, 26, fileName.length); + localHeader.set(fileName, 30); + + const centralHeader = new Uint8Array(46 + fileName.length); + writeUint32(centralHeader, 0, 0x02014b50); + writeUint16(centralHeader, 4, 20); + writeUint16(centralHeader, 6, 20); + writeUint16(centralHeader, 8, 0x0800); + writeUint16(centralHeader, 10, entry.compressionMethod); + writeUint32(centralHeader, 16, entry.checksum); + writeUint32(centralHeader, 20, entry.data.length); + writeUint32(centralHeader, 24, entry.body.length); + writeUint16(centralHeader, 28, fileName.length); + writeUint32(centralHeader, 42, localOffset); + centralHeader.set(fileName, 46); + + localChunks.push(localHeader, entry.data); + centralChunks.push(centralHeader); + localOffset += localHeader.length + entry.data.length; + } + + const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0); + const archive = new Uint8Array( + localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22, + ); + let offset = 0; + for (const chunk of localChunks) { + archive.set(chunk, offset); + offset += chunk.length; + } + const centralDirectoryOffset = offset; + for (const chunk of centralChunks) { + archive.set(chunk, offset); + offset += chunk.length; + } + writeUint32(archive, offset, 0x06054b50); + writeUint16(archive, offset + 8, entries.length); + writeUint16(archive, offset + 10, entries.length); + writeUint32(archive, offset + 12, centralDirectoryLength); + writeUint32(archive, offset + 16, centralDirectoryOffset); + + return archive; +} + describe("createZipArchive", () => { it("writes a zip archive with the export root path prefixed into each entry", () => { const archive = createZipArchive( @@ -202,4 +274,16 @@ describe("createZipArchive", () => { }, }); }); + + it("ignores directory entries from standard zip archives", async () => { + const archive = createZipArchiveWithDirectoryEntries("paperclip-demo"); + + await expect(readZipArchive(archive)).resolves.toEqual({ + rootPath: "paperclip-demo", + files: { + "COMPANY.md": "# Company\n", + "agents/ceo/AGENTS.md": "# CEO\n", + }, + }); + }); }); diff --git a/ui/src/lib/zip.ts b/ui/src/lib/zip.ts index 79024c1f..509bba32 100644 --- a/ui/src/lib/zip.ts +++ b/ui/src/lib/zip.ts @@ -186,10 +186,10 @@ export async function readZipArchive(source: ArrayBuffer | Uint8Array): Promise< throw new Error("Invalid zip archive: truncated file contents."); } - const archivePath = normalizeArchivePath( - textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength)), - ); - if (archivePath && !archivePath.endsWith("/")) { + const rawArchivePath = textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength)); + const archivePath = normalizeArchivePath(rawArchivePath); + const isDirectoryEntry = /\/$/.test(rawArchivePath.replace(/\\/g, "/")); + if (archivePath && !isDirectoryEntry) { const entryBytes = await inflateZipEntry(compressionMethod, bytes.slice(bodyOffset, bodyEnd)); entries.push({ path: archivePath,