Files
awesome-copilot/extensions/pr-artifact-explorer/zip.mjs
T
David Pine 5b6da4c588 Add PR Artifact Explorer canvas 🤖🤖🤖 (#2341)
* Add PR Artifact Explorer canvas

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 33fefd65-ed18-4eda-9c7d-48008f4a9c9d

* Address PR #2341 review feedback and codespell CI failures

PR review fixes:

- zip.mjs: bounded streaming inflate for readZipEntry (output cap = entry.uncompressedSize) to prevent memory exhaustion from malformed entries

- zip.mjs: new bounded verify Transform in streamZipEntry that enforces decompressed byte cap and verifies CRC32 on flush

- zip.mjs: new readEntryPrefix() helper that inflates only up to a byte cap for indexing use cases

- preview.mjs: move URL/path parsing inside the try block so URIError becomes a 400 response instead of an unhandled rejection

- cache.mjs: buildMetadata uses bounded readEntryPrefix (8 KiB) instead of full readEntry+slice, eliminating unbounded decompression during indexing

- cache.mjs: clearArtifactCache aborts and awaits in-flight downloads via AbortController before removing cache dirs so clear cannot be repopulated

- server.mjs / preview.mjs: track static preview servers by canvas origin and stop them on canvas close, preventing loopback server leaks

- trx-preview.js: prefer authoritative ResultSummary.outcome when it is a recognized TRX value; only infer from counters when unknown

- extension.mjs: set_account validates the requested id resolves to the active account before persisting; unknown ids now return an error

- server.mjs: reject progressive-pull offsets that are not multiples of PROGRESSIVE_PULL_BATCH_SIZE to prevent caching incomplete pulls as complete

- README.md: remove Markdown-rendering claim (files are shown as escaped text)

CI fix:

- .codespellrc: skip vendored asciinema-player(*.min.js) and primer-*.css bundles

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 47369ff4-859a-425f-8a47-763cc1a5f25f

* Address second round of PR #2341 review feedback

- github.mjs: request isDraft in the pullRequestSignals GraphQL enrichment and propagate draft into search-sourced pulls (search REST API has no draft field)

- cache.mjs: give each writeJsonAtomic write a unique temp filename via a module sequence and clean up the temp file on failure to avoid concurrent-write races

- cache.mjs: deleteCachedArtifact now aborts and awaits any in-flight download for the artifact before removing files; internal mismatched-metadata purge uses a raw removeArtifactFiles helper to avoid aborting its own operation

- accounts.mjs: skip non-github.com CLI accounts before reading tokens so GitHub Enterprise Server credentials are never sent to api.github.com

- preview.mjs: require the requested entry to be the root index.html before launching a static preview (previously any nested HTML file could start one)

- zip.mjs: validate the EOCD comment length ends exactly at the archive tail so a file comment containing the EOCD signature is not mistaken for the record

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 47369ff4-859a-425f-8a47-763cc1a5f25f

* Address remaining PR artifact explorer feedback

Fix malformed preference normalization, replace artifact-presence request fanout with cached repository artifact pagination, restrict static previews to the root index, and derive completed TRX outcomes from counters. Add focused regression coverage for all four behaviors.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: db486df1-17f1-4623-ac7e-61eff2c76da4

* Fix PR artifact explorer review feedback

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6ee2bc81-2114-4b1f-987a-cb47ea35e132

* Stabilize artifact discovery and cache cleanup

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6ee2bc81-2114-4b1f-987a-cb47ea35e132

---------

Co-authored-by: David Pine <7679720+IEvangelist@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 33fefd65-ed18-4eda-9c7d-48008f4a9c9d
Copilot-Session: 47369ff4-859a-425f-8a47-763cc1a5f25f
Copilot-Session: db486df1-17f1-4623-ac7e-61eff2c76da4
Copilot-Session: 6ee2bc81-2114-4b1f-987a-cb47ea35e132
2026-07-30 10:16:58 +10:00

402 lines
13 KiB
JavaScript

import { createReadStream } from "node:fs";
import { open, stat } from "node:fs/promises";
import { posix } from "node:path";
import { pipeline } from "node:stream/promises";
import { Transform } from "node:stream";
import { createInflateRaw } from "node:zlib";
import {
MAX_CENTRAL_DIRECTORY_BYTES,
MAX_STREAMED_ENTRY_BYTES,
MAX_ZIP_ENTRIES,
} from "./constants.mjs";
const EOCD_SIGNATURE = 0x06054b50;
const CENTRAL_SIGNATURE = 0x02014b50;
const LOCAL_SIGNATURE = 0x04034b50;
const ZIP64_U16 = 0xffff;
const ZIP64_U32 = 0xffffffff;
const crcTable = new Uint32Array(256);
for (let index = 0; index < 256; index++) {
let value = index;
for (let bit = 0; bit < 8; bit++) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
crcTable[index] = value >>> 0;
}
function crc32(buffer) {
let value = 0xffffffff;
for (const byte of buffer) {
value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
}
return (value ^ 0xffffffff) >>> 0;
}
async function readExactly(handle, length, position) {
const buffer = Buffer.allocUnsafe(length);
let offset = 0;
while (offset < length) {
const result = await handle.read(buffer, offset, length - offset, position + offset);
if (result.bytesRead === 0) {
throw new Error("ZIP archive ended unexpectedly.");
}
offset += result.bytesRead;
}
return buffer;
}
function safeEntryPath(rawName) {
if (!rawName || rawName.includes("\0")) {
throw new Error("ZIP archive contains an invalid file name.");
}
const slashed = rawName.replaceAll("\\", "/").replace(/^\.\/+/, "");
const normalized = posix.normalize(slashed);
if (
normalized === "." ||
normalized === ".." ||
normalized.startsWith("../") ||
normalized.startsWith("/") ||
/^[A-Za-z]:\//.test(normalized)
) {
throw new Error(`ZIP archive contains an unsafe path: ${rawName}`);
}
if (normalized.length > 2_048) {
throw new Error("ZIP archive contains an excessively long file name.");
}
return normalized;
}
function dosDateTime(date, time) {
if (!date) return null;
const year = 1980 + ((date >> 9) & 0x7f);
const month = (date >> 5) & 0x0f;
const day = date & 0x1f;
const hour = (time >> 11) & 0x1f;
const minute = (time >> 5) & 0x3f;
const second = (time & 0x1f) * 2;
const value = new Date(Date.UTC(year, Math.max(0, month - 1), day, hour, minute, second));
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
function decodeName(buffer, utf8) {
// GitHub-generated artifacts use UTF-8. For legacy archives, decoding as
// latin1 preserves a stable byte-for-byte lookup instead of replacing bytes.
return buffer.toString(utf8 ? "utf8" : "latin1");
}
export async function readZipIndex(zipPath) {
const archive = await stat(zipPath);
if (!archive.isFile() || archive.size < 22) {
throw new Error("Artifact is not a valid ZIP archive.");
}
const handle = await open(zipPath, "r");
try {
const tailLength = Math.min(archive.size, 65_557);
const tailOffset = archive.size - tailLength;
const tail = await readExactly(handle, tailLength, tailOffset);
let eocdOffset = -1;
for (let index = tail.length - 22; index >= 0; index--) {
if (tail.readUInt32LE(index) === EOCD_SIGNATURE) {
if (index + 22 <= tail.length) {
const commentLength = tail.readUInt16LE(index + 20);
if (index + 22 + commentLength === tail.length) {
eocdOffset = index;
break;
}
}
}
}
if (eocdOffset < 0) {
throw new Error("ZIP end-of-directory record was not found.");
}
const disk = tail.readUInt16LE(eocdOffset + 4);
const centralDisk = tail.readUInt16LE(eocdOffset + 6);
const entriesOnDisk = tail.readUInt16LE(eocdOffset + 8);
const entryCount = tail.readUInt16LE(eocdOffset + 10);
const centralSize = tail.readUInt32LE(eocdOffset + 12);
const centralOffset = tail.readUInt32LE(eocdOffset + 16);
if (disk !== 0 || centralDisk !== 0 || entriesOnDisk !== entryCount) {
throw new Error("Multi-disk ZIP artifacts are not supported.");
}
if (
entryCount === ZIP64_U16 ||
centralSize === ZIP64_U32 ||
centralOffset === ZIP64_U32
) {
throw new Error("ZIP64 artifacts larger than 4 GiB are not supported.");
}
if (entryCount > MAX_ZIP_ENTRIES) {
throw new Error(`ZIP contains too many entries (${entryCount.toLocaleString()}).`);
}
if (centralSize > MAX_CENTRAL_DIRECTORY_BYTES) {
throw new Error("ZIP central directory exceeds the 64 MiB safety limit.");
}
if (centralOffset + centralSize > archive.size) {
throw new Error("ZIP central directory points outside the archive.");
}
const central = await readExactly(handle, centralSize, centralOffset);
const entries = [];
let cursor = 0;
while (cursor < central.length) {
if (central.length - cursor < 46 || central.readUInt32LE(cursor) !== CENTRAL_SIGNATURE) {
throw new Error("ZIP central directory is malformed.");
}
const flags = central.readUInt16LE(cursor + 8);
const method = central.readUInt16LE(cursor + 10);
const modifiedTime = central.readUInt16LE(cursor + 12);
const modifiedDate = central.readUInt16LE(cursor + 14);
const expectedCrc32 = central.readUInt32LE(cursor + 16);
const compressedSize = central.readUInt32LE(cursor + 20);
const uncompressedSize = central.readUInt32LE(cursor + 24);
const nameLength = central.readUInt16LE(cursor + 28);
const extraLength = central.readUInt16LE(cursor + 30);
const commentLength = central.readUInt16LE(cursor + 32);
const diskStart = central.readUInt16LE(cursor + 34);
const externalAttributes = central.readUInt32LE(cursor + 38);
const localHeaderOffset = central.readUInt32LE(cursor + 42);
const recordLength = 46 + nameLength + extraLength + commentLength;
if (cursor + recordLength > central.length) {
throw new Error("ZIP central directory entry is truncated.");
}
if (
compressedSize === ZIP64_U32 ||
uncompressedSize === ZIP64_U32 ||
localHeaderOffset === ZIP64_U32 ||
diskStart === ZIP64_U16
) {
throw new Error("ZIP64 entries are not supported.");
}
const rawName = decodeName(
central.subarray(cursor + 46, cursor + 46 + nameLength),
Boolean(flags & 0x0800),
);
const name = safeEntryPath(rawName);
const unixMode = externalAttributes >>> 16;
const directory = name.endsWith("/");
const symlink = (unixMode & 0o170000) === 0o120000;
const encrypted = Boolean(flags & 0x0001);
entries.push({
name,
directory,
symlink,
encrypted,
supported: !directory && !symlink && !encrypted && (method === 0 || method === 8),
method,
flags,
crc32: expectedCrc32,
compressedSize,
uncompressedSize,
localHeaderOffset,
modifiedAt: dosDateTime(modifiedDate, modifiedTime),
});
cursor += recordLength;
}
if (entries.length !== entryCount) {
throw new Error(
`ZIP entry count mismatch. Expected ${entryCount}, found ${entries.length}.`,
);
}
return {
path: zipPath,
size: archive.size,
entries,
totalUncompressedBytes: entries.reduce(
(total, entry) => total + (entry.directory ? 0 : entry.uncompressedSize),
0,
),
};
} finally {
await handle.close();
}
}
async function entryDataRange(zipPath, entry) {
const handle = await open(zipPath, "r");
try {
const header = await readExactly(handle, 30, entry.localHeaderOffset);
if (header.readUInt32LE(0) !== LOCAL_SIGNATURE) {
throw new Error(`ZIP local header is missing for ${entry.name}.`);
}
const nameLength = header.readUInt16LE(26);
const extraLength = header.readUInt16LE(28);
const start = entry.localHeaderOffset + 30 + nameLength + extraLength;
const endExclusive = start + entry.compressedSize;
const archive = await stat(zipPath);
if (endExclusive > archive.size) {
throw new Error(`ZIP entry data points outside the archive: ${entry.name}`);
}
return { start, endExclusive };
} finally {
await handle.close();
}
}
export function findZipEntry(index, requestedName) {
const normalized = safeEntryPath(String(requestedName ?? ""));
return index.entries.find((entry) => entry.name === normalized) ?? null;
}
export async function readZipEntry(zipPath, entry, maxBytes) {
if (!entry?.supported) {
throw new Error(`ZIP entry cannot be previewed: ${entry?.name ?? "unknown"}`);
}
if (entry.uncompressedSize > maxBytes) {
throw new Error(
`ZIP entry is too large for an inline preview (${entry.uncompressedSize.toLocaleString()} bytes).`,
);
}
const { start } = await entryDataRange(zipPath, entry);
const handle = await open(zipPath, "r");
let compressed;
try {
compressed = await readExactly(handle, entry.compressedSize, start);
} finally {
await handle.close();
}
let output;
if (entry.method === 0) {
output = compressed;
} else {
const cap = entry.uncompressedSize;
output = await new Promise((resolve, reject) => {
const inflate = createInflateRaw();
const chunks = [];
let total = 0;
inflate.on("data", (chunk) => {
total += chunk.length;
if (total > cap) {
inflate.destroy(new Error(`ZIP entry decompressed past declared size: ${entry.name}`));
return;
}
chunks.push(chunk);
});
inflate.once("end", () => resolve(Buffer.concat(chunks)));
inflate.once("error", reject);
inflate.end(compressed);
});
}
if (output.length !== entry.uncompressedSize) {
throw new Error(`ZIP entry size check failed: ${entry.name}`);
}
if (crc32(output) !== entry.crc32) {
throw new Error(`ZIP entry checksum failed: ${entry.name}`);
}
return output;
}
function createVerifyTransform(entry) {
const cap = Math.min(entry.uncompressedSize, MAX_STREAMED_ENTRY_BYTES);
let total = 0;
let crcValue = 0xffffffff;
return new Transform({
transform(chunk, _encoding, callback) {
total += chunk.length;
if (total > cap) {
callback(new Error(`ZIP entry decompressed past declared size: ${entry.name}`));
return;
}
for (const byte of chunk) {
crcValue = crcTable[(crcValue ^ byte) & 0xff] ^ (crcValue >>> 8);
}
callback(null, chunk);
},
flush(callback) {
if (total !== entry.uncompressedSize) {
callback(new Error(`ZIP entry size check failed: ${entry.name}`));
return;
}
const actual = (crcValue ^ 0xffffffff) >>> 0;
if (actual !== entry.crc32) {
callback(new Error(`ZIP entry checksum failed: ${entry.name}`));
return;
}
callback();
},
});
}
export async function streamZipEntry(zipPath, entry, writable, transform = null) {
if (!entry?.supported) {
throw new Error(`ZIP entry cannot be served: ${entry?.name ?? "unknown"}`);
}
if (entry.uncompressedSize > MAX_STREAMED_ENTRY_BYTES) {
throw new Error("ZIP entry exceeds the 512 MiB streaming safety limit.");
}
if (entry.compressedSize === 0) {
writable.end();
return;
}
const { start, endExclusive } = await entryDataRange(zipPath, entry);
const source = createReadStream(zipPath, { start, end: endExclusive - 1 });
const streams = [source];
if (entry.method !== 0) streams.push(createInflateRaw());
streams.push(createVerifyTransform(entry));
if (transform) streams.push(transform);
streams.push(writable);
await pipeline(...streams);
}
export async function readEntryPrefix(zipPath, entry, maxBytes) {
if (!entry?.supported) return null;
if (entry.uncompressedSize === 0) return Buffer.alloc(0);
const { start } = await entryDataRange(zipPath, entry);
if (entry.method === 0) {
const readBytes = Math.min(maxBytes, entry.compressedSize);
const handle = await open(zipPath, "r");
try {
return await readExactly(handle, readBytes, start);
} finally {
await handle.close();
}
}
const handle = await open(zipPath, "r");
let compressed;
try {
compressed = await readExactly(handle, entry.compressedSize, start);
} finally {
await handle.close();
}
return new Promise((resolve, reject) => {
const inflate = createInflateRaw();
const chunks = [];
let total = 0;
let done = false;
inflate.on("data", (chunk) => {
if (done) return;
const remaining = maxBytes - total;
if (remaining <= 0) {
done = true;
inflate.destroy();
resolve(Buffer.concat(chunks));
return;
}
const slice = remaining < chunk.length ? chunk.subarray(0, remaining) : chunk;
chunks.push(slice);
total += slice.length;
if (total >= maxBytes) {
done = true;
inflate.destroy();
resolve(Buffer.concat(chunks));
}
});
inflate.once("end", () => {
if (!done) {
done = true;
resolve(Buffer.concat(chunks));
}
});
inflate.once("error", (err) => {
if (!done) {
done = true;
reject(err);
}
});
inflate.end(compressed);
});
}