1const LOCAL_FILE = 0x04034b50;2const CENTRAL_FILE = 0x02014b50;3const END_OF_CENTRAL_DIRECTORY = 0x06054b50;4const UTF8 = new TextEncoder();5const TEXT = new TextDecoder();67function concatenate(parts: readonly Uint8Array[]): Uint8Array {8 const length = parts.reduce((total, part) => total + part.length, 0);9 const result = new Uint8Array(length);10 let offset = 0;11 for (const part of parts) {12 result.set(part, offset);13 offset += part.length;14 }15 return result;16}1718function header(length: number): { bytes: Uint8Array; view: DataView } {19 const bytes = new Uint8Array(length);20 return { bytes, view: new DataView(bytes.buffer) };21}2223function crc32(bytes: Uint8Array): number {24 let crc = 0xffffffff;25 for (const byte of bytes) {26 crc ^= byte;27 for (let bit = 0; bit < 8; bit++) {28 crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);29 }30 }31 return (crc ^ 0xffffffff) >>> 0;32}3334function endOffset(bytes: Uint8Array): number {35 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);36 for (let offset = bytes.length - 22; offset >= Math.max(0, bytes.length - 65_557); offset--) {37 if (view.getUint32(offset, true) === END_OF_CENTRAL_DIRECTORY) return offset;38 }39 throw new Error("Playwright trace is not a ZIP archive");40}4142async function inflateRaw(bytes: Uint8Array): Promise<Uint8Array> {43 const input = new Blob([bytes.slice()]).stream();44 const output = input.pipeThrough(new DecompressionStream("deflate-raw"));45 return new Uint8Array(await new Response(output).arrayBuffer());46}4748/**49 * Read a Playwright trace ZIP at `path` into a map of entry name to50 * decompressed bytes, supporting stored and deflate entries. Throws on51 * encrypted, malformed, or unsupported-compression archives.52 */53export async function readTraceArchive(path: string): Promise<Record<string, Uint8Array>> {54 const bytes = await Deno.readFile(path);55 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);56 const end = endOffset(bytes);57 const count = view.getUint16(end + 10, true);58 let centralOffset = view.getUint32(end + 16, true);59 const entries: Record<string, Uint8Array> = {};6061 for (let index = 0; index < count; index++) {62 if (view.getUint32(centralOffset, true) !== CENTRAL_FILE) {63 throw new Error("Playwright trace has an invalid central directory");64 }65 const flags = view.getUint16(centralOffset + 8, true);66 if (flags & 1) throw new Error("Encrypted trace entries cannot be sanitized");67 const method = view.getUint16(centralOffset + 10, true);68 const compressedSize = view.getUint32(centralOffset + 20, true);69 const nameLength = view.getUint16(centralOffset + 28, true);70 const extraLength = view.getUint16(centralOffset + 30, true);71 const commentLength = view.getUint16(centralOffset + 32, true);72 const localOffset = view.getUint32(centralOffset + 42, true);73 const name = TEXT.decode(bytes.subarray(centralOffset + 46, centralOffset + 46 + nameLength));74 if (view.getUint32(localOffset, true) !== LOCAL_FILE) {75 throw new Error(`Playwright trace entry ${name} has an invalid local header`);76 }77 const localNameLength = view.getUint16(localOffset + 26, true);78 const localExtraLength = view.getUint16(localOffset + 28, true);79 const dataOffset = localOffset + 30 + localNameLength + localExtraLength;80 const compressed = bytes.subarray(dataOffset, dataOffset + compressedSize);81 if (method === 0) entries[name] = compressed.slice();82 else if (method === 8) entries[name] = await inflateRaw(compressed);83 else throw new Error(`Playwright trace entry ${name} uses unsupported compression ${method}`);84 centralOffset += 46 + nameLength + extraLength + commentLength;85 }86 return entries;87}8889/**90 * Serialize the given entries into a stored (uncompressed) ZIP archive with91 * CRC-32 checksums, suitable for rewriting a sanitized trace.92 */93export function writeTraceArchive(entries: Readonly<Record<string, Uint8Array>>): Uint8Array {94 const localParts: Uint8Array[] = [];95 const centralParts: Uint8Array[] = [];96 let localOffset = 0;9798 for (const [name, contents] of Object.entries(entries)) {99 const nameBytes = UTF8.encode(name);100 const checksum = crc32(contents);101 const local = header(30);102 local.view.setUint32(0, LOCAL_FILE, true);103 local.view.setUint16(4, 20, true);104 local.view.setUint32(14, checksum, true);105 local.view.setUint32(18, contents.length, true);106 local.view.setUint32(22, contents.length, true);107 local.view.setUint16(26, nameBytes.length, true);108 localParts.push(local.bytes, nameBytes, contents);109110 const central = header(46);111 central.view.setUint32(0, CENTRAL_FILE, true);112 central.view.setUint16(4, 20, true);113 central.view.setUint16(6, 20, true);114 central.view.setUint32(16, checksum, true);115 central.view.setUint32(20, contents.length, true);116 central.view.setUint32(24, contents.length, true);117 central.view.setUint16(28, nameBytes.length, true);118 central.view.setUint32(42, localOffset, true);119 centralParts.push(central.bytes, nameBytes);120 localOffset += local.bytes.length + nameBytes.length + contents.length;121 }122123 const central = concatenate(centralParts);124 const end = header(22);125 end.view.setUint32(0, END_OF_CENTRAL_DIRECTORY, true);126 end.view.setUint16(8, Object.keys(entries).length, true);127 end.view.setUint16(10, Object.keys(entries).length, true);128 end.view.setUint32(12, central.length, true);129 end.view.setUint32(16, localOffset, true);130 return concatenate([...localParts, central, end.bytes]);131}132133/**134 * Sanitize an intentionally snapshot-free trace archive. Response/source135 * resources are omitted because they can contain arbitrary private bytes; the136 * action and network metadata remain useful for failure diagnosis.137 */138export async function sanitizeTraceArchive(139 path: string,140 redact: (value: string) => string,141): Promise<void> {142 const entries = await readTraceArchive(path);143 const sanitized: Record<string, Uint8Array> = {};144 for (const [name, bytes] of Object.entries(entries)) {145 if (name.startsWith("resources/")) continue;146 sanitized[name] = UTF8.encode(redact(TEXT.decode(bytes)));147 }148 await Deno.writeFile(path, writeTraceArchive(sanitized));149}