Claude 3f9a601dba
wip(media): w2-marketing partial work (handover, unverified)
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
2026-10-03 18:24:53 +00:00

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;
}
}