Use zip archives for company export

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-03-16 08:54:50 -05:00
parent b116e04894
commit 617aeaae0e
3 changed files with 193 additions and 67 deletions

53
ui/src/lib/zip.test.ts Normal file
View File

@@ -0,0 +1,53 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { createZipArchive } 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));
}
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);
});
});

131
ui/src/lib/zip.ts Normal file
View File

@@ -0,0 +1,131 @@
const textEncoder = new TextEncoder();
const crcTable = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let crc = i;
for (let bit = 0; bit < 8; bit++) {
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
crcTable[i] = crc >>> 0;
}
function normalizeArchivePath(pathValue: string) {
return pathValue
.replace(/\\/g, "/")
.split("/")
.filter(Boolean)
.join("/");
}
function crc32(bytes: Uint8Array) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc = (crc >>> 8) ^ crcTable[(crc ^ byte) & 0xff]!;
}
return (crc ^ 0xffffffff) >>> 0;
}
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 getDosDateTime(date: Date) {
const year = Math.min(Math.max(date.getFullYear(), 1980), 2107);
const month = date.getMonth() + 1;
const day = date.getDate();
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = Math.floor(date.getSeconds() / 2);
return {
time: (hours << 11) | (minutes << 5) | seconds,
date: ((year - 1980) << 9) | (month << 5) | day,
};
}
function concatChunks(chunks: Uint8Array[]) {
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const archive = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
return archive;
}
export function createZipArchive(files: Record<string, string>, rootPath: string): Uint8Array {
const normalizedRoot = normalizeArchivePath(rootPath);
const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = [];
const archiveDate = getDosDateTime(new Date());
let localOffset = 0;
let entryCount = 0;
for (const [relativePath, contents] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) {
const archivePath = normalizeArchivePath(`${normalizedRoot}/${relativePath}`);
const fileName = textEncoder.encode(archivePath);
const body = textEncoder.encode(contents);
const checksum = crc32(body);
const localHeader = new Uint8Array(30 + fileName.length);
writeUint32(localHeader, 0, 0x04034b50);
writeUint16(localHeader, 4, 20);
writeUint16(localHeader, 6, 0x0800);
writeUint16(localHeader, 8, 0);
writeUint16(localHeader, 10, archiveDate.time);
writeUint16(localHeader, 12, archiveDate.date);
writeUint32(localHeader, 14, checksum);
writeUint32(localHeader, 18, body.length);
writeUint32(localHeader, 22, body.length);
writeUint16(localHeader, 26, fileName.length);
writeUint16(localHeader, 28, 0);
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, 0);
writeUint16(centralHeader, 12, archiveDate.time);
writeUint16(centralHeader, 14, archiveDate.date);
writeUint32(centralHeader, 16, checksum);
writeUint32(centralHeader, 20, body.length);
writeUint32(centralHeader, 24, body.length);
writeUint16(centralHeader, 28, fileName.length);
writeUint16(centralHeader, 30, 0);
writeUint16(centralHeader, 32, 0);
writeUint16(centralHeader, 34, 0);
writeUint16(centralHeader, 36, 0);
writeUint32(centralHeader, 38, 0);
writeUint32(centralHeader, 42, localOffset);
centralHeader.set(fileName, 46);
localChunks.push(localHeader, body);
centralChunks.push(centralHeader);
localOffset += localHeader.length + body.length;
entryCount += 1;
}
const centralDirectory = concatChunks(centralChunks);
const endOfCentralDirectory = new Uint8Array(22);
writeUint32(endOfCentralDirectory, 0, 0x06054b50);
writeUint16(endOfCentralDirectory, 4, 0);
writeUint16(endOfCentralDirectory, 6, 0);
writeUint16(endOfCentralDirectory, 8, entryCount);
writeUint16(endOfCentralDirectory, 10, entryCount);
writeUint32(endOfCentralDirectory, 12, centralDirectory.length);
writeUint32(endOfCentralDirectory, 16, localOffset);
writeUint16(endOfCentralDirectory, 20, 0);
return concatChunks([...localChunks, centralDirectory, endOfCentralDirectory]);
}