paperclip/tests/runner-e2e/history-public-bundle.ts

504 lines
16 KiB
TypeScript

import { execFile } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import {
cp,
copyFile,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { regenerateRunnerDashboard } from "./dashboard-regenerate.js";
const execFileAsync = promisify(execFile);
const PUBLIC_EVIDENCE_EXTENSIONS = new Set([".json", ".log", ".md", ".txt"]);
const PRIVATE_EVIDENCE_DIRECTORIES = new Set([
"blob-report",
"html-report",
"playwright-output",
]);
const PUBLIC_VISUAL_DIRECTORY = "public-visuals";
const PUBLIC_SCREENSHOT_NAME =
/^(?:final-state|plan-[a-z0-9-]+|question-[a-z0-9-]+)\.png$/;
const PNG_SIGNATURE = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
const MAX_PRIVATE_PNG_BYTES = 32 * 1024 * 1024;
const MAX_PRIVATE_PNG_PIXELS = 64 * 1024 * 1024;
const MAX_PRIVATE_PNG_EDGE = 32 * 1024;
const MAX_PUBLIC_PREVIEW_EDGE = 96;
const MAX_PUBLIC_PREVIEW_BYTES = 256 * 1024;
const PRIVATE_PNG_CHUNKS = new Set([
"IHDR",
"IDAT",
"IEND",
"PLTE",
"tRNS",
"gAMA",
"cHRM",
"sRGB",
"pHYs",
]);
const PUBLIC_PNG_CHUNKS = new Set(["IHDR", "IDAT", "IEND", "PLTE"]);
interface PublishedResult {
executionId?: unknown;
attempt?: unknown;
status?: unknown;
evidenceValid?: unknown;
screenshots?: unknown;
[key: string]: unknown;
}
interface PublishedCampaign {
results?: unknown;
[key: string]: unknown;
}
export type PublicPreviewTransformer = (
source: string,
destination: string,
) => Promise<void>;
function publicVisualPath(evidencePath: string) {
const segments = evidencePath.split("/");
return (
segments.length === 2 &&
segments[0] === PUBLIC_VISUAL_DIRECTORY &&
PUBLIC_SCREENSHOT_NAME.test(segments[1] ?? "")
);
}
export function isPublicHistoryEvidencePath(relative: string) {
const match = relative.match(
/^evidence\/[A-Za-z0-9._-]+\/attempt-[1-9][0-9]*\/(.+)$/,
);
if (!match) return false;
const evidencePath = match[1]!;
const segments = evidencePath.split("/");
if (
segments.some(
(segment) =>
!segment ||
segment === "." ||
segment === ".." ||
PRIVATE_EVIDENCE_DIRECTORIES.has(segment),
)
) {
return false;
}
if (publicVisualPath(evidencePath)) return true;
return PUBLIC_EVIDENCE_EXTENSIONS.has(
path.posix.extname(evidencePath).toLowerCase(),
);
}
async function pruneEvidenceDirectory(root: string, current: string) {
const entries = await readdir(current, { withFileTypes: true });
for (const entry of entries) {
const absolute = path.join(current, entry.name);
if (entry.isDirectory()) {
await pruneEvidenceDirectory(root, absolute);
if ((await readdir(absolute)).length === 0) {
await rm(absolute, { recursive: true });
}
continue;
}
const relative = path.relative(root, absolute).split(path.sep).join("/");
if (!entry.isFile() || !isPublicHistoryEvidencePath(relative)) {
await rm(absolute, { force: true });
} else if (path.posix.extname(relative).toLowerCase() === ".png") {
// Only derived public previews can pass the PNG path allowlist. Validate
// the bounded inert container again when the AWS publisher prunes its
// downloaded bundle.
await validatePublicPreview(absolute);
}
}
}
export async function prunePrivateHistoryEvidence(root: string) {
const evidenceRoot = path.join(root, "evidence");
const metadata = await lstat(evidenceRoot).catch(() => null);
if (!metadata) return;
if (!metadata.isDirectory()) {
throw new Error("Historical evidence root must be a directory");
}
await pruneEvidenceDirectory(root, evidenceRoot);
}
function crc32(buffer: Buffer) {
let crc = 0xffffffff;
for (const byte of buffer) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function inspectPng(
value: Buffer,
options: {
label: string;
maxBytes: number;
maxEdge?: number;
maxPixels?: number;
chunks: ReadonlySet<string>;
},
) {
if (value.length > options.maxBytes) {
throw new Error(`${options.label} exceeds the PNG byte limit`);
}
if (
value.length < PNG_SIGNATURE.length ||
!value.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)
) {
throw new Error(`${options.label} is not a PNG`);
}
let offset = PNG_SIGNATURE.length;
let width = 0;
let height = 0;
let sawHeader = false;
let sawData = false;
let sawEnd = false;
while (offset < value.length) {
if (offset + 12 > value.length) {
throw new Error(`${options.label} has a truncated PNG chunk`);
}
const length = value.readUInt32BE(offset);
const dataStart = offset + 8;
const dataEnd = dataStart + length;
const chunkEnd = dataEnd + 4;
if (chunkEnd > value.length) {
throw new Error(`${options.label} has an invalid PNG chunk length`);
}
const type = value.toString("ascii", offset + 4, offset + 8);
if (!options.chunks.has(type)) {
throw new Error(`${options.label} contains forbidden PNG chunk ${type}`);
}
const expectedCrc = value.readUInt32BE(dataEnd);
const actualCrc = crc32(value.subarray(offset + 4, dataEnd));
if (actualCrc !== expectedCrc) {
throw new Error(`${options.label} contains a corrupt PNG chunk`);
}
if (type === "IHDR") {
if (sawHeader || offset !== PNG_SIGNATURE.length || length !== 13) {
throw new Error(`${options.label} has an invalid PNG header`);
}
sawHeader = true;
width = value.readUInt32BE(dataStart);
height = value.readUInt32BE(dataStart + 4);
const bitDepth = value[dataStart + 8];
const colorType = value[dataStart + 9];
const compression = value[dataStart + 10];
const filter = value[dataStart + 11];
const interlace = value[dataStart + 12];
if (
width < 1 ||
height < 1 ||
bitDepth !== 8 ||
![2, 3, 6].includes(colorType ?? -1) ||
compression !== 0 ||
filter !== 0 ||
interlace !== 0
) {
throw new Error(`${options.label} uses an unsupported PNG encoding`);
}
if (
options.maxEdge &&
(width > options.maxEdge || height > options.maxEdge)
) {
throw new Error(`${options.label} exceeds the PNG dimensions`);
}
if (options.maxPixels && width * height > options.maxPixels) {
throw new Error(`${options.label} exceeds the PNG pixel limit`);
}
} else if (!sawHeader) {
throw new Error(`${options.label} has data before its PNG header`);
} else if (type === "IDAT") {
sawData = true;
} else if (type === "IEND") {
if (!sawData || length !== 0 || chunkEnd !== value.length) {
throw new Error(`${options.label} has an invalid PNG end marker`);
}
sawEnd = true;
}
offset = chunkEnd;
}
if (!sawHeader || !sawData || !sawEnd) {
throw new Error(`${options.label} is an incomplete PNG`);
}
}
async function validatePrivateScreenshot(file: string) {
const metadata = await lstat(file);
if (!metadata.isFile() || metadata.isSymbolicLink()) {
throw new Error("Public preview source must be a regular file");
}
inspectPng(await readFile(file), {
label: "Public preview source",
maxBytes: MAX_PRIVATE_PNG_BYTES,
maxEdge: MAX_PRIVATE_PNG_EDGE,
maxPixels: MAX_PRIVATE_PNG_PIXELS,
chunks: PRIVATE_PNG_CHUNKS,
});
}
async function validatePublicPreview(file: string) {
const metadata = await lstat(file);
if (!metadata.isFile() || metadata.isSymbolicLink()) {
throw new Error("Public preview output must be a regular file");
}
inspectPng(await readFile(file), {
label: "Public preview output",
maxBytes: MAX_PUBLIC_PREVIEW_BYTES,
maxEdge: MAX_PUBLIC_PREVIEW_EDGE,
chunks: PUBLIC_PNG_CHUNKS,
});
}
async function assertPreviewHasNoReadableText(file: string) {
const { stdout } = await execFileAsync(
process.env.RUNNER_E2E_TESSERACT_BINARY ?? "tesseract",
[file, "stdout", "--psm", "11", "-l", "eng"],
{
timeout: 30_000,
maxBuffer: 1024 * 1024,
env: { ...process.env, OMP_THREAD_LIMIT: "1" },
},
);
// The report job no longer has provider secrets, so it cannot compare OCR
// output with exact credential values. Reject every readable letter or digit
// instead. Do not include OCR output in the error because it is untrusted.
if (/[\p{L}\p{N}]/u.test(stdout)) {
throw new Error("Public layout preview still contains OCR-readable text");
}
}
export async function createPublicLayoutPreview(
source: string,
destination: string,
) {
await validatePrivateScreenshot(source);
await mkdir(path.dirname(destination), { recursive: true });
const temporary = await mkdtemp(
path.join(path.dirname(destination), ".preview-"),
);
const output = path.join(temporary, "preview.png");
try {
await execFileAsync(
process.env.RUNNER_E2E_IMAGE_MAGICK_BINARY ?? "convert",
[
"-limit",
"memory",
"128MiB",
"-limit",
"map",
"256MiB",
"-limit",
"disk",
"256MiB",
"-limit",
"thread",
"1",
"-limit",
"time",
"30",
source,
"-background",
"#f3f4f6",
"-alpha",
"remove",
"-alpha",
"off",
"-resize",
`${MAX_PUBLIC_PREVIEW_EDGE}x${MAX_PUBLIC_PREVIEW_EDGE}>`,
"-blur",
"0x3.5",
"-colors",
"16",
"-strip",
"-define",
"png:exclude-chunks=all",
`PNG8:${output}`,
],
{ timeout: 45_000, maxBuffer: 1024 * 1024 },
);
await validatePublicPreview(output);
await assertPreviewHasNoReadableText(output);
await copyFile(output, destination, fsConstants.COPYFILE_EXCL);
} finally {
await rm(temporary, { recursive: true, force: true });
}
}
async function assertTreeContainsNoLinks(root: string, current = root) {
const entries = await readdir(current, { withFileTypes: true });
for (const entry of entries) {
const absolute = path.join(current, entry.name);
const metadata = await lstat(absolute);
if (metadata.isSymbolicLink()) {
throw new Error("Public history source must not contain symbolic links");
}
if (metadata.isDirectory()) await assertTreeContainsNoLinks(root, absolute);
}
}
async function removeExistingPublicVisuals(current: string) {
const entries = await readdir(current, { withFileTypes: true }).catch(
() => [],
);
for (const entry of entries) {
const absolute = path.join(current, entry.name);
if (!entry.isDirectory()) continue;
if (entry.name === PUBLIC_VISUAL_DIRECTORY) {
await rm(absolute, { recursive: true, force: true });
continue;
}
await removeExistingPublicVisuals(absolute);
}
}
function safePassedScreenshot(input: unknown) {
if (!input || typeof input !== "object") {
throw new Error("Passing result has invalid screenshot metadata");
}
const screenshot = input as Record<string, unknown>;
if (
typeof screenshot.id !== "string" ||
typeof screenshot.label !== "string" ||
typeof screenshot.file !== "string" ||
!PUBLIC_SCREENSHOT_NAME.test(screenshot.file)
) {
throw new Error("Passing result has unsafe screenshot metadata");
}
return {
id: screenshot.id,
label: `${screenshot.label} (redacted layout preview)`,
file: `${PUBLIC_VISUAL_DIRECTORY}/${screenshot.file}`,
privateFile: screenshot.file,
};
}
export async function preparePublicHistoryBundle(input: {
source: string;
destination: string;
transform?: PublicPreviewTransformer;
}) {
const source = path.resolve(input.source);
const destination = path.resolve(input.destination);
if (
source === destination ||
source.startsWith(`${destination}${path.sep}`) ||
destination.startsWith(`${source}${path.sep}`)
) {
throw new Error("Public history source and destination must not overlap");
}
const sourceMetadata = await lstat(source);
if (!sourceMetadata.isDirectory() || sourceMetadata.isSymbolicLink()) {
throw new Error("Public history source must be a directory");
}
await assertTreeContainsNoLinks(source);
await rm(destination, { recursive: true, force: true });
await cp(source, destination, { recursive: true, force: false });
const normalizedFile = path.join(destination, "normalized-results.json");
const campaign = JSON.parse(
await readFile(normalizedFile, "utf8"),
) as PublishedCampaign;
if (!Array.isArray(campaign.results)) {
throw new Error("Public history source has invalid campaign results");
}
await removeExistingPublicVisuals(path.join(destination, "evidence"));
const transform = input.transform ?? createPublicLayoutPreview;
let previewCount = 0;
const results: PublishedResult[] = [];
for (const untrustedResult of campaign.results) {
if (!untrustedResult || typeof untrustedResult !== "object") {
throw new Error("Public history source has an invalid result");
}
const result = untrustedResult as PublishedResult;
if (result.status !== "passed" || result.evidenceValid === false) {
results.push(result);
continue;
}
if (
typeof result.executionId !== "string" ||
!/^[A-Za-z0-9][A-Za-z0-9._-]{0,299}$/.test(result.executionId) ||
!Number.isSafeInteger(result.attempt) ||
Number(result.attempt) < 1 ||
!Array.isArray(result.screenshots) ||
result.screenshots.length === 0
) {
throw new Error("Passing result lacks safe screenshot provenance");
}
const base = path.join(
destination,
"evidence",
result.executionId,
`attempt-${String(result.attempt)}`,
);
const publicVisuals = path.join(base, PUBLIC_VISUAL_DIRECTORY);
await rm(publicVisuals, { recursive: true, force: true });
await mkdir(publicVisuals, { recursive: true });
const screenshots = [];
const seen = new Set<string>();
for (const entry of result.screenshots) {
const screenshot = safePassedScreenshot(entry);
if (seen.has(screenshot.privateFile)) {
throw new Error("Passing result has duplicate screenshot metadata");
}
seen.add(screenshot.privateFile);
const sourceScreenshot = path.join(base, screenshot.privateFile);
const publicScreenshot = path.join(publicVisuals, screenshot.privateFile);
await validatePrivateScreenshot(sourceScreenshot);
await transform(sourceScreenshot, publicScreenshot);
await validatePublicPreview(publicScreenshot);
const { privateFile: _, ...publishedScreenshot } = screenshot;
screenshots.push(publishedScreenshot);
previewCount += 1;
}
results.push({ ...result, screenshots });
}
await writeFile(
normalizedFile,
`${JSON.stringify({ ...campaign, results }, null, 2)}\n`,
"utf8",
);
await prunePrivateHistoryEvidence(destination);
await regenerateRunnerDashboard({ bundle: destination, historyFile: null });
console.log(
`Prepared ${previewCount} public low-resolution layout preview(s); full-resolution visual evidence remains private`,
);
return { previewCount };
}
async function main() {
const [source, destination] = process.argv
.slice(2)
.filter((argument) => argument !== "--");
if (!source || !destination) {
throw new Error(
"Usage: history-public-bundle.ts <private-report-directory> <public-report-directory>",
);
}
await preparePublicHistoryBundle({ source, destination });
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === path.resolve(import.meta.filename)
) {
await main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
}