Files
webmail/lib/mail-export/zip-writer.ts
T

252 lines
8.8 KiB
TypeScript

import { Deflate, deflateSync } from "fflate";
import { crc32 } from "./crc32";
/**
* A streaming ZIP writer: each entry goes to the sink as soon as it is added,
* so an export never holds the archive in memory. Zip64 records are written
* when the archive passes 4 GB or 65,535 entries (fflate's Zip writes none).
*
* - addFile: a whole file in memory (one message); deflated when that saves space.
* - openStream: a file written piece by piece (a folder's mbox), deflated,
* with its CRC and sizes in a data descriptor after the data.
*/
export type ZipSink = (chunk: Uint8Array) => Promise<void>;
interface CentralEntry {
name: Uint8Array;
flags: number;
method: number;
time: number;
date: number;
crc: number;
compressedSize: number;
size: number;
offset: number;
directory: boolean;
}
const MAX32 = 0xffffffff;
const MAX16 = 0xffff;
/** General purpose flag 11: the name is UTF-8. */
const FLAG_UTF8 = 0x0800;
/** General purpose flag 3: CRC and sizes follow the data. */
const FLAG_DESCRIPTOR = 0x0008;
function dosDateTime(date: Date): { time: number; date: number } {
const d = Number.isNaN(date.getTime()) || date.getFullYear() < 1980 ? new Date(1980, 0, 1) : date;
return {
time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
date: ((Math.min(d.getFullYear(), 2107) - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
};
}
function u64(view: DataView, at: number, value: number): void {
view.setUint32(at, value % 0x1_0000_0000, true);
view.setUint32(at + 4, Math.floor(value / 0x1_0000_0000), true);
}
const encoder = new TextEncoder();
export interface ZipEntryStream {
write(chunk: Uint8Array): Promise<void>;
close(): Promise<void>;
}
export class ZipWriter {
private offset = 0;
private readonly entries: CentralEntry[] = [];
private streaming = false;
constructor(private readonly sink: ZipSink) {}
/** Bytes written so far. */
get bytesWritten(): number {
return this.offset;
}
private async emit(bytes: Uint8Array): Promise<void> {
if (bytes.length === 0) return;
this.offset += bytes.length;
await this.sink(bytes);
}
private localHeader(entry: Omit<CentralEntry, "offset" | "directory">, zip64: boolean): Uint8Array {
const header = new Uint8Array(30 + entry.name.length);
const v = new DataView(header.buffer);
v.setUint32(0, 0x04034b50, true);
v.setUint16(4, zip64 ? 45 : 20, true);
v.setUint16(6, entry.flags, true);
v.setUint16(8, entry.method, true);
v.setUint16(10, entry.time, true);
v.setUint16(12, entry.date, true);
v.setUint32(14, entry.crc, true);
v.setUint32(18, entry.compressedSize, true);
v.setUint32(22, entry.size, true);
v.setUint16(26, entry.name.length, true);
v.setUint16(28, 0, true);
header.set(entry.name, 30);
return header;
}
private assertIdle(): void {
if (this.streaming) throw new Error("ZipWriter: close the open entry first");
}
/** An empty folder, so the tree survives without files in it. */
async addDirectory(path: string, mtime = new Date()): Promise<void> {
this.assertIdle();
const name = encoder.encode(path.endsWith("/") ? path : `${path}/`);
const { time, date } = dosDateTime(mtime);
const entry = { name, flags: FLAG_UTF8, method: 0, time, date, crc: 0, compressedSize: 0, size: 0 };
const offset = this.offset;
await this.emit(this.localHeader(entry, false));
this.entries.push({ ...entry, offset, directory: true });
}
async addFile(path: string, data: Uint8Array, mtime = new Date()): Promise<void> {
this.assertIdle();
const name = encoder.encode(path);
const { time, date } = dosDateTime(mtime);
const crc = crc32(data);
const deflated = data.length > 64 ? deflateSync(data, { level: 1 }) : null;
const useDeflate = !!deflated && deflated.length < data.length;
const body = useDeflate ? deflated! : data;
const entry = { name, flags: FLAG_UTF8, method: useDeflate ? 8 : 0, time, date, crc, compressedSize: body.length, size: data.length };
const offset = this.offset;
await this.emit(this.localHeader(entry, false));
await this.emit(body);
this.entries.push({ ...entry, offset, directory: false });
}
async openStream(path: string, mtime = new Date()): Promise<ZipEntryStream> {
this.assertIdle();
this.streaming = true;
const name = encoder.encode(path);
const { time, date } = dosDateTime(mtime);
const flags = FLAG_UTF8 | FLAG_DESCRIPTOR;
const offset = this.offset;
await this.emit(this.localHeader({ name, flags, method: 8, time, date, crc: 0, compressedSize: 0, size: 0 }, false));
let crc = 0;
let size = 0;
let compressedSize = 0;
let pending: Uint8Array[] = [];
const deflate = new Deflate({ level: 1 });
deflate.ondata = (chunk) => {
pending.push(chunk);
compressedSize += chunk.length;
};
const drain = async () => {
const out = pending;
pending = [];
for (const chunk of out) await this.emit(chunk);
};
return {
write: async (chunk: Uint8Array) => {
if (chunk.length === 0) return;
crc = crc32(chunk, crc);
size += chunk.length;
deflate.push(chunk, false);
await drain();
},
close: async () => {
deflate.push(new Uint8Array(0), true);
await drain();
const zip64 = size >= MAX32 || compressedSize >= MAX32;
const descriptor = new Uint8Array(zip64 ? 24 : 16);
const v = new DataView(descriptor.buffer);
v.setUint32(0, 0x08074b50, true);
v.setUint32(4, crc, true);
if (zip64) {
u64(v, 8, compressedSize);
u64(v, 16, size);
} else {
v.setUint32(8, compressedSize, true);
v.setUint32(12, size, true);
}
await this.emit(descriptor);
this.entries.push({ name, flags, method: 8, time, date, crc, compressedSize, size, offset, directory: false });
this.streaming = false;
},
};
}
/** Writes the central directory. Nothing may be added afterwards. */
async finish(): Promise<void> {
this.assertIdle();
const cdStart = this.offset;
for (const e of this.entries) {
const needSize = e.size >= MAX32;
const needCompressed = e.compressedSize >= MAX32;
const needOffset = e.offset >= MAX32;
const extraLength = needSize || needCompressed || needOffset
? 4 + 8 * (Number(needSize) + Number(needCompressed) + Number(needOffset))
: 0;
const record = new Uint8Array(46 + e.name.length + extraLength);
const v = new DataView(record.buffer);
const zip64 = extraLength > 0;
v.setUint32(0, 0x02014b50, true);
v.setUint16(4, zip64 ? 45 : 20, true);
v.setUint16(6, zip64 ? 45 : 20, true);
v.setUint16(8, e.flags, true);
v.setUint16(10, e.method, true);
v.setUint16(12, e.time, true);
v.setUint16(14, e.date, true);
v.setUint32(16, e.crc, true);
v.setUint32(20, needCompressed ? MAX32 : e.compressedSize, true);
v.setUint32(24, needSize ? MAX32 : e.size, true);
v.setUint16(28, e.name.length, true);
v.setUint16(30, extraLength, true);
v.setUint16(32, 0, true);
v.setUint16(34, 0, true);
v.setUint16(36, 0, true);
v.setUint32(38, e.directory ? 0x10 : 0, true);
v.setUint32(42, needOffset ? MAX32 : e.offset, true);
record.set(e.name, 46);
if (zip64) {
let at = 46 + e.name.length;
v.setUint16(at, 0x0001, true);
v.setUint16(at + 2, extraLength - 4, true);
at += 4;
if (needSize) { u64(v, at, e.size); at += 8; }
if (needCompressed) { u64(v, at, e.compressedSize); at += 8; }
if (needOffset) { u64(v, at, e.offset); at += 8; }
}
await this.emit(record);
}
const cdSize = this.offset - cdStart;
const count = this.entries.length;
const zip64 = count >= MAX16 || cdStart >= MAX32 || cdSize >= MAX32;
if (zip64) {
const zip64End = this.offset;
const record = new Uint8Array(56 + 20);
const v = new DataView(record.buffer);
v.setUint32(0, 0x06064b50, true);
u64(v, 4, 44);
v.setUint16(12, 45, true);
v.setUint16(14, 45, true);
v.setUint32(16, 0, true);
v.setUint32(20, 0, true);
u64(v, 24, count);
u64(v, 32, count);
u64(v, 40, cdSize);
u64(v, 48, cdStart);
v.setUint32(56, 0x07064b50, true);
v.setUint32(60, 0, true);
u64(v, 64, zip64End);
v.setUint32(72, 1, true);
await this.emit(record);
}
const end = new Uint8Array(22);
const v = new DataView(end.buffer);
v.setUint32(0, 0x06054b50, true);
v.setUint16(8, Math.min(count, MAX16), true);
v.setUint16(10, Math.min(count, MAX16), true);
v.setUint32(12, Math.min(cdSize, MAX32), true);
v.setUint32(16, Math.min(cdStart, MAX32), true);
await this.emit(end);
}
}