Stopped mid-implementation for the handover to the project thread. Not typechecked or tested; do not merge as is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
144 lines
5.2 KiB
TypeScript
144 lines
5.2 KiB
TypeScript
import { createReadStream } from "node:fs";
|
|
import { open, rm, stat } from "node:fs/promises";
|
|
import { crc32 } from "node:zlib";
|
|
|
|
/*
|
|
* Minimal store-only (no compression) ZIP writer for export bundles. Videos
|
|
* are already compressed, so storing keeps it fast and dependency-free.
|
|
*
|
|
* Entry names are flat file names from the bundle's fixed allowlist: no
|
|
* directories, no "..", no absolute paths, so extracting can never write
|
|
* outside the target folder (zip slip). Timestamps are a fixed 1980-01-01 so
|
|
* the same inputs give the same archive. No ZIP64: an entry or archive past
|
|
* 4 GiB is rejected with ZipTooLargeError.
|
|
*/
|
|
|
|
const ENTRY_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)*$/;
|
|
const MAX_ENTRY_NAME = 100;
|
|
const MAX_ZIP32 = 0xffff_fffe;
|
|
const MAX_ENTRIES = 0xffff;
|
|
const DOS_TIME = 0;
|
|
/** 1980-01-01 in DOS date format (year offset 0, month 1, day 1). */
|
|
const DOS_DATE = (1 << 5) | 1;
|
|
|
|
export class ZipTooLargeError extends Error {
|
|
constructor() {
|
|
super("bundle is larger than a ZIP archive without ZIP64 can hold (4 GiB)");
|
|
this.name = "ZipTooLargeError";
|
|
}
|
|
}
|
|
|
|
export interface ZipEntry {
|
|
/** Flat file name inside the archive. */
|
|
name: string;
|
|
/** Absolute path of the file to store. */
|
|
path: string;
|
|
}
|
|
|
|
/** Whether a name is a safe flat archive entry (letters, digits, "-", "_", inner dots). */
|
|
export function isSafeZipEntryName(name: string): boolean {
|
|
return name.length <= MAX_ENTRY_NAME && ENTRY_NAME.test(name);
|
|
}
|
|
|
|
async function fileCrc(file: string, signal?: AbortSignal): Promise<number> {
|
|
let crc = 0;
|
|
for await (const chunk of createReadStream(file, { signal })) {
|
|
crc = crc32(chunk as Buffer, crc);
|
|
}
|
|
return crc >>> 0;
|
|
}
|
|
|
|
/** Write a store-only ZIP of `entries` to `outPath`, replacing it. Returns the archive size. */
|
|
export async function writeStoreZip(
|
|
outPath: string,
|
|
entries: ZipEntry[],
|
|
signal?: AbortSignal,
|
|
): Promise<number> {
|
|
if (entries.length > MAX_ENTRIES) throw new ZipTooLargeError();
|
|
const seen = new Set<string>();
|
|
for (const entry of entries) {
|
|
if (!isSafeZipEntryName(entry.name)) throw new Error(`unsafe zip entry name: ${entry.name}`);
|
|
if (seen.has(entry.name)) throw new Error(`duplicate zip entry: ${entry.name}`);
|
|
seen.add(entry.name);
|
|
}
|
|
const handle = await open(outPath, "w", 0o600);
|
|
let offset = 0;
|
|
const write = async (bytes: Uint8Array) => {
|
|
signal?.throwIfAborted();
|
|
await handle.write(bytes);
|
|
offset += bytes.byteLength;
|
|
};
|
|
const central: Buffer[] = [];
|
|
try {
|
|
for (const entry of entries) {
|
|
const size = (await stat(entry.path)).size;
|
|
const name = Buffer.from(entry.name, "ascii");
|
|
if (size > MAX_ZIP32 || offset + 30 + name.length + size > MAX_ZIP32) {
|
|
throw new ZipTooLargeError();
|
|
}
|
|
const crc = await fileCrc(entry.path, signal);
|
|
const headerOffset = offset;
|
|
const local = Buffer.alloc(30);
|
|
local.writeUInt32LE(0x04034b50, 0);
|
|
local.writeUInt16LE(10, 4); // version needed: 1.0, stored
|
|
local.writeUInt16LE(0, 6); // flags
|
|
local.writeUInt16LE(0, 8); // method: store
|
|
local.writeUInt16LE(DOS_TIME, 10);
|
|
local.writeUInt16LE(DOS_DATE, 12);
|
|
local.writeUInt32LE(crc, 14);
|
|
local.writeUInt32LE(size, 18);
|
|
local.writeUInt32LE(size, 22);
|
|
local.writeUInt16LE(name.length, 26);
|
|
local.writeUInt16LE(0, 28);
|
|
await write(local);
|
|
await write(name);
|
|
let copied = 0;
|
|
for await (const chunk of createReadStream(entry.path, { signal })) {
|
|
await write(chunk as Buffer);
|
|
copied += (chunk as Buffer).byteLength;
|
|
}
|
|
if (copied !== size) throw new Error(`${entry.name} changed while archiving`);
|
|
|
|
const record = Buffer.alloc(46);
|
|
record.writeUInt32LE(0x02014b50, 0);
|
|
record.writeUInt16LE((3 << 8) | 20, 4); // made by: Unix, 2.0
|
|
record.writeUInt16LE(10, 6);
|
|
record.writeUInt16LE(0, 8);
|
|
record.writeUInt16LE(0, 10);
|
|
record.writeUInt16LE(DOS_TIME, 12);
|
|
record.writeUInt16LE(DOS_DATE, 14);
|
|
record.writeUInt32LE(crc, 16);
|
|
record.writeUInt32LE(size, 20);
|
|
record.writeUInt32LE(size, 24);
|
|
record.writeUInt16LE(name.length, 28);
|
|
record.writeUInt16LE(0, 30); // extra
|
|
record.writeUInt16LE(0, 32); // comment
|
|
record.writeUInt16LE(0, 34); // disk
|
|
record.writeUInt16LE(0, 36); // internal attributes
|
|
record.writeUInt32LE((0o100644 << 16) >>> 0, 38); // regular file, rw-r--r--
|
|
record.writeUInt32LE(headerOffset, 42);
|
|
central.push(record, name);
|
|
}
|
|
const directory = Buffer.concat(central);
|
|
const directoryOffset = offset;
|
|
if (directoryOffset + directory.length + 22 > MAX_ZIP32) throw new ZipTooLargeError();
|
|
await write(directory);
|
|
const end = Buffer.alloc(22);
|
|
end.writeUInt32LE(0x06054b50, 0);
|
|
end.writeUInt16LE(0, 4);
|
|
end.writeUInt16LE(0, 6);
|
|
end.writeUInt16LE(entries.length, 8);
|
|
end.writeUInt16LE(entries.length, 10);
|
|
end.writeUInt32LE(directory.length, 12);
|
|
end.writeUInt32LE(directoryOffset, 16);
|
|
end.writeUInt16LE(0, 20);
|
|
await write(end);
|
|
await handle.close();
|
|
return offset;
|
|
} catch (error) {
|
|
await handle.close().catch(() => undefined);
|
|
await rm(outPath, { force: true });
|
|
throw error;
|
|
}
|
|
}
|