// @vitest-environment node import { deflateRawSync } from "node:zlib"; import { describe, expect, it } from "vitest"; import { createZipArchive, readZipArchive } from "./zip"; function readUint16(bytes: Uint8Array, offset: number) { return bytes[offset]! | (bytes[offset + 1]! << 8); } function readUint32(bytes: Uint8Array, offset: number) { return ( bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) | (bytes[offset + 3]! << 24) ) >>> 0; } function readString(bytes: Uint8Array, offset: number, length: number) { return new TextDecoder().decode(bytes.slice(offset, offset + length)); } function writeUint16(target: Uint8Array, offset: number, value: number) { target[offset] = value & 0xff; target[offset + 1] = (value >>> 8) & 0xff; } function writeUint32(target: Uint8Array, offset: number, value: number) { target[offset] = value & 0xff; target[offset + 1] = (value >>> 8) & 0xff; target[offset + 2] = (value >>> 16) & 0xff; target[offset + 3] = (value >>> 24) & 0xff; } function crc32(bytes: Uint8Array) { let crc = 0xffffffff; for (const byte of bytes) { crc ^= byte; for (let bit = 0; bit < 8; bit += 1) { crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; } } return (crc ^ 0xffffffff) >>> 0; } function createDeflatedZipArchive(files: Record, rootPath: string) { const encoder = new TextEncoder(); const localChunks: Uint8Array[] = []; const centralChunks: Uint8Array[] = []; let localOffset = 0; let entryCount = 0; for (const [relativePath, content] of Object.entries(files).sort(([a], [b]) => a.localeCompare(b))) { const fileName = encoder.encode(`${rootPath}/${relativePath}`); const rawBody = encoder.encode(content); const deflatedBody = new Uint8Array(deflateRawSync(rawBody)); const checksum = crc32(rawBody); const localHeader = new Uint8Array(30 + fileName.length); writeUint32(localHeader, 0, 0x04034b50); writeUint16(localHeader, 4, 20); writeUint16(localHeader, 6, 0x0800); writeUint16(localHeader, 8, 8); writeUint32(localHeader, 14, checksum); writeUint32(localHeader, 18, deflatedBody.length); writeUint32(localHeader, 22, rawBody.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, 8); writeUint32(centralHeader, 16, checksum); writeUint32(centralHeader, 20, deflatedBody.length); writeUint32(centralHeader, 24, rawBody.length); writeUint16(centralHeader, 28, fileName.length); writeUint32(centralHeader, 42, localOffset); centralHeader.set(fileName, 46); localChunks.push(localHeader, deflatedBody); centralChunks.push(centralHeader); localOffset += localHeader.length + deflatedBody.length; entryCount += 1; } 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, entryCount); writeUint16(archive, offset + 10, entryCount); writeUint32(archive, offset + 12, centralDirectoryLength); writeUint32(archive, offset + 16, centralDirectoryOffset); 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( { "COMPANY.md": "# Company\n", "agents/ceo/AGENTS.md": "# CEO\n", }, "paperclip-demo", ); expect(readUint32(archive, 0)).toBe(0x04034b50); const firstNameLength = readUint16(archive, 26); const firstBodyLength = readUint32(archive, 18); expect(readString(archive, 30, firstNameLength)).toBe("paperclip-demo/agents/ceo/AGENTS.md"); expect(readString(archive, 30 + firstNameLength, firstBodyLength)).toBe("# CEO\n"); const secondOffset = 30 + firstNameLength + firstBodyLength; expect(readUint32(archive, secondOffset)).toBe(0x04034b50); const secondNameLength = readUint16(archive, secondOffset + 26); const secondBodyLength = readUint32(archive, secondOffset + 18); expect(readString(archive, secondOffset + 30, secondNameLength)).toBe("paperclip-demo/COMPANY.md"); expect(readString(archive, secondOffset + 30 + secondNameLength, secondBodyLength)).toBe("# Company\n"); const endOffset = archive.length - 22; expect(readUint32(archive, endOffset)).toBe(0x06054b50); expect(readUint16(archive, endOffset + 8)).toBe(2); expect(readUint16(archive, endOffset + 10)).toBe(2); }); it("reads a Paperclip zip archive back into rootPath and file contents", async () => { const archive = createZipArchive( { "COMPANY.md": "# Company\n", "agents/ceo/AGENTS.md": "# CEO\n", ".paperclip.yaml": "schema: paperclip/v1\n", }, "paperclip-demo", ); await expect(readZipArchive(archive)).resolves.toEqual({ rootPath: "paperclip-demo", files: { "COMPANY.md": "# Company\n", "agents/ceo/AGENTS.md": "# CEO\n", ".paperclip.yaml": "schema: paperclip/v1\n", }, }); }); it("round-trips binary image files without coercing them to text", async () => { const archive = createZipArchive( { "images/company-logo.png": { encoding: "base64", data: Buffer.from("png-bytes").toString("base64"), contentType: "image/png", }, }, "paperclip-demo", ); await expect(readZipArchive(archive)).resolves.toEqual({ rootPath: "paperclip-demo", files: { "images/company-logo.png": { encoding: "base64", data: Buffer.from("png-bytes").toString("base64"), contentType: "image/png", }, }, }); }); it("reads standard DEFLATE zip archives created outside Paperclip", async () => { const archive = createDeflatedZipArchive( { "COMPANY.md": "# Company\n", "agents/ceo/AGENTS.md": "# CEO\n", }, "paperclip-demo", ); await expect(readZipArchive(archive)).resolves.toEqual({ rootPath: "paperclip-demo", files: { "COMPANY.md": "# Company\n", "agents/ceo/AGENTS.md": "# CEO\n", }, }); }); 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", }, }); }); });