diff --git a/app/api/explain-upload/route.ts b/app/api/explain-upload/route.ts index 48a686f..48ed7b3 100644 --- a/app/api/explain-upload/route.ts +++ b/app/api/explain-upload/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { createExplanationStream } from "../../../lib/explanation/openai-response.server"; -import { checkRateLimit } from "../../../lib/explanation/rate-limit.server"; +import { checkRateLimit, trustedRateLimitIdentity } from "../../../lib/explanation/rate-limit.server"; import { buildUploadedExplanationContext } from "../../../lib/uploads/context.server"; import { uploadedExplainRequestSchema } from "../../../lib/uploads/schema"; @@ -19,7 +19,7 @@ export async function POST(request: Request) { if (!parsed.success) return NextResponse.json({ code: "INVALID_REQUEST", message: "The uploaded context is invalid." }, { status: 400 }); const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) return NextResponse.json({ code: "MODEL_ERROR", message: "AI explanations are not configured on this deployment.", isRetryable: false }, { status: 503 }); - const key = `upload:${request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || "local"}`; + const key = `upload:${trustedRateLimitIdentity(request)}`; const configured = Number(process.env.EXPLAIN_RATE_LIMIT_PER_HOUR ?? 30); const rateLimit = checkRateLimit(key, { limit: Number.isInteger(configured) && configured > 0 ? configured : 30, windowMs: 60 * 60 * 1_000 }); if (!rateLimit.allowed) return NextResponse.json({ code: "RATE_LIMITED", message: "The demo explanation limit has been reached." }, { status: 429, headers: { "Retry-After": String(rateLimit.retryAfterSeconds) } }); diff --git a/app/api/explain/route.ts b/app/api/explain/route.ts index 94687a2..52e0e6c 100644 --- a/app/api/explain/route.ts +++ b/app/api/explain/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { buildExplanationContext, ExplanationContextError } from "../../../lib/explanation/build-context.server"; import { createExplanationStream } from "../../../lib/explanation/openai-response.server"; -import { checkRateLimit } from "../../../lib/explanation/rate-limit.server"; +import { checkRateLimit, trustedRateLimitIdentity } from "../../../lib/explanation/rate-limit.server"; import { explainRequestSchema } from "../../../lib/explanation/request-schema"; export const runtime = "edge"; @@ -18,14 +18,6 @@ function publicError( return NextResponse.json({ code, message }, { status, headers }); } -function requestKey(request: Request): string { - return ( - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || - request.headers.get("x-real-ip") || - "local-demo" - ); -} - export async function POST(request: Request) { const declaredLength = Number(request.headers.get("content-length") ?? 0); if (Number.isFinite(declaredLength) && declaredLength > MAX_REQUEST_BYTES) { @@ -62,7 +54,7 @@ export async function POST(request: Request) { } const configuredLimit = Number(process.env.EXPLAIN_RATE_LIMIT_PER_HOUR ?? 30); - const rateLimit = checkRateLimit(requestKey(request), { + const rateLimit = checkRateLimit(trustedRateLimitIdentity(request), { limit: Number.isInteger(configuredLimit) && configuredLimit > 0 ? configuredLimit : 30, windowMs: 60 * 60 * 1_000, }); diff --git a/components/paper-reader/pdf-paper-reader.module.css b/components/paper-reader/pdf-paper-reader.module.css index 39029fc..c7875a2 100644 --- a/components/paper-reader/pdf-paper-reader.module.css +++ b/components/paper-reader/pdf-paper-reader.module.css @@ -165,6 +165,14 @@ text-align: center; } +.selectionError { + margin: 0; + color: var(--destructive); + font-size: 0.875rem; + font-weight: 650; + text-align: center; +} + .mobileHelp { display: none; } diff --git a/components/paper-reader/pdf-paper-reader.tsx b/components/paper-reader/pdf-paper-reader.tsx index 6afa859..9af91b3 100644 --- a/components/paper-reader/pdf-paper-reader.tsx +++ b/components/paper-reader/pdf-paper-reader.tsx @@ -75,6 +75,7 @@ export function PdfPaperReader({ const [renderScale, setRenderScale] = useState() const [status, setStatus] = useState("loading-document") const [errorMessage, setErrorMessage] = useState("") + const [selectionError, setSelectionError] = useState("") const [retryKey, setRetryKey] = useState(0) useEffect(() => { @@ -215,9 +216,15 @@ export function PdfPaperReader({ const rect = browserSelection.getRangeAt(0).getBoundingClientRect() if (!rect.width && !rect.height) return + const blockIds = resolvePageBlockIds(validated.selectedText, pageBlocks) + if (blockIds.length === 0) { + setSelectionError("That passage could not be matched to the indexed paper text. Try a smaller selection.") + return + } + setSelectionError("") onSelection({ selectedText: validated.selectedText, - blockIds: resolvePageBlockIds(validated.selectedText, pageBlocks), + blockIds, clientRect: { top: rect.top, left: rect.left, @@ -301,6 +308,7 @@ export function PdfPaperReader({ ) : null} + {selectionError ?

{selectionError}

: null}

Select text directly on the page for a contextual explanation. Use the page buttons to navigate. diff --git a/lib/explanation/rate-limit.server.ts b/lib/explanation/rate-limit.server.ts index 2432894..eb00374 100644 --- a/lib/explanation/rate-limit.server.ts +++ b/lib/explanation/rate-limit.server.ts @@ -17,6 +17,15 @@ export type RateLimitResult = { retryAfterSeconds: number; }; +/** + * Cloudflare overwrites this header on direct visitor requests. Do not fall + * back to client-controlled forwarding headers; deployments without the + * trusted edge header share one conservative anonymous bucket. + */ +export function trustedRateLimitIdentity(request: Request): string { + return request.headers.get("cf-connecting-ip")?.trim() || "shared-anonymous"; +} + // This store is intentionally process-local. It protects a single server instance // from bursts, but it is not a substitute for a shared limiter in a multi-instance // deployment. diff --git a/lib/explanation/serialize-context.server.ts b/lib/explanation/serialize-context.server.ts index bcbacd6..ca0949d 100644 --- a/lib/explanation/serialize-context.server.ts +++ b/lib/explanation/serialize-context.server.ts @@ -154,9 +154,7 @@ async function resolvePaperSelection( const canonicalText = blocks.map(({ text }) => text).join("\n\n"); assertSelectionMatches(request.selectedText, canonicalText); const sourceId = blocks[0].id; - const mapping = - loaded.manifest.mappings.find((item) => item.paper.sourceId === sourceId) ?? - loaded.manifest.mappings.find((item) => item.paper.pages.includes(page.number)); + const mapping = loaded.manifest.mappings.find((item) => item.paper.sourceId === sourceId); const label = `Paper page ${page.number}${mapping?.paper.heading ? ` ยท ${mapping.paper.heading}` : ""}`; return { kind: "paper", diff --git a/lib/uploads/lean-files.ts b/lib/uploads/lean-files.ts index 48fae65..05ce7bd 100644 --- a/lib/uploads/lean-files.ts +++ b/lib/uploads/lean-files.ts @@ -1,4 +1,4 @@ -import { unzipSync } from "fflate"; +import { strFromU8, Unzip, UnzipInflate } from "fflate"; import { UPLOAD_LIMITS } from "./schema"; @@ -8,6 +8,92 @@ export type UploadedLeanFile = { bytes: number; }; +const ARCHIVE_INPUT_CHUNK_BYTES = 4 * 1024; +const END_OF_CENTRAL_DIRECTORY = 0x06054b50; +const CENTRAL_DIRECTORY_FILE_HEADER = 0x02014b50; +const LOCAL_FILE_HEADER = 0x04034b50; +const MAX_ZIP_COMMENT_BYTES = 65_535; + +function invalidZip(): never { + throw new Error("The ZIP archive could not be safely extracted."); +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + return left.byteLength === right.byteLength && left.every((byte, index) => byte === right[index]); +} + +function validateZipContainer(data: Uint8Array): string[] { + if (data.byteLength < 22) invalidZip(); + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const searchStart = Math.max(0, data.byteLength - 22 - MAX_ZIP_COMMENT_BYTES); + let endOffset = -1; + for (let offset = data.byteLength - 22; offset >= searchStart; offset -= 1) { + if (view.getUint32(offset, true) === END_OF_CENTRAL_DIRECTORY) { + const commentLength = view.getUint16(offset + 20, true); + if (offset + 22 + commentLength === data.byteLength) { + endOffset = offset; + break; + } + } + } + if (endOffset < 0) invalidZip(); + + const disk = view.getUint16(endOffset + 4, true); + const centralDisk = view.getUint16(endOffset + 6, true); + const diskEntries = view.getUint16(endOffset + 8, true); + const totalEntries = view.getUint16(endOffset + 10, true); + const centralSize = view.getUint32(endOffset + 12, true); + const centralOffset = view.getUint32(endOffset + 16, true); + if ( + disk !== 0 || + centralDisk !== 0 || + diskEntries !== totalEntries || + totalEntries === 0xffff || + centralSize === 0xffffffff || + centralOffset === 0xffffffff || + centralOffset + centralSize !== endOffset + ) invalidZip(); + + let cursor = centralOffset; + const entryNames: string[] = []; + for (let entry = 0; entry < totalEntries; entry += 1) { + if (cursor + 46 > endOffset || view.getUint32(cursor, true) !== CENTRAL_DIRECTORY_FILE_HEADER) { + invalidZip(); + } + const flags = view.getUint16(cursor + 8, true); + const compressedSize = view.getUint32(cursor + 20, true); + const originalSize = view.getUint32(cursor + 24, true); + const nameLength = view.getUint16(cursor + 28, true); + const extraLength = view.getUint16(cursor + 30, true); + const commentLength = view.getUint16(cursor + 32, true); + const startDisk = view.getUint16(cursor + 34, true); + const localOffset = view.getUint32(cursor + 42, true); + if ( + (flags & 1) !== 0 || + startDisk !== 0 || + compressedSize === 0xffffffff || + originalSize === 0xffffffff || + localOffset === 0xffffffff || + localOffset + 30 > centralOffset || + view.getUint32(localOffset, true) !== LOCAL_FILE_HEADER + ) invalidZip(); + const localNameLength = view.getUint16(localOffset + 26, true); + const localExtraLength = view.getUint16(localOffset + 28, true); + const dataOffset = localOffset + 30 + localNameLength + localExtraLength; + const centralName = data.subarray(cursor + 46, cursor + 46 + nameLength); + const localName = data.subarray(localOffset + 30, localOffset + 30 + localNameLength); + if ( + dataOffset + compressedSize > centralOffset || + localNameLength !== nameLength || + !equalBytes(localName, centralName) + ) invalidZip(); + entryNames.push(strFromU8(centralName, (flags & 0x0800) === 0)); + cursor += 46 + nameLength + extraLength + commentLength; + } + if (cursor !== endOffset) invalidZip(); + return entryNames; +} + function safeLeanPath(path: string): boolean { return ( path.endsWith(".lean") && @@ -50,6 +136,89 @@ function validateCollection(files: UploadedLeanFile[]): UploadedLeanFile[] { return files.sort((left, right) => left.path.localeCompare(right.path)); } +function joinChunks(chunks: Uint8Array[], byteLength: number): Uint8Array { + const joined = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + joined.set(chunk, offset); + offset += chunk.byteLength; + } + return joined; +} + +function readLeanArchive(data: Uint8Array, existing: UploadedLeanFile[]): UploadedLeanFile[] { + const validatedEntryNames = validateZipContainer(data); + const extracted: UploadedLeanFile[] = []; + const existingBytes = existing.reduce((sum, source) => sum + source.bytes, 0); + let archiveBytes = 0; + let archiveFiles = 0; + let streamedEntries = 0; + let failure: Error | null = null; + + const unzip = new Unzip((file) => { + if (file.name !== validatedEntryNames[streamedEntries]) { + failure = new Error("The ZIP archive could not be safely extracted."); + return; + } + streamedEntries += 1; + if (failure || !file.name.toLowerCase().endsWith(".lean")) return; + if (!safeLeanPath(file.name)) { + failure = new Error(`Unsafe Lean file path: ${file.name}`); + return; + } + archiveFiles += 1; + if (existing.length + archiveFiles > UPLOAD_LIMITS.leanFiles) { + failure = new Error(`Choose at most ${UPLOAD_LIMITS.leanFiles} Lean files.`); + return; + } + + const chunks: Uint8Array[] = []; + let fileBytes = 0; + file.ondata = (error, chunk, final) => { + if (failure) return; + if (error) { + failure = error; + return; + } + fileBytes += chunk.byteLength; + archiveBytes += chunk.byteLength; + if (fileBytes > UPLOAD_LIMITS.leanFileBytes) { + failure = new Error(`${file.name} is larger than 256 KiB.`); + file.terminate(); + return; + } + if (existingBytes + archiveBytes > UPLOAD_LIMITS.leanTotalBytes) { + failure = new Error("The extracted Lean sources are larger than 1 MiB in total."); + file.terminate(); + return; + } + chunks.push(chunk); + if (final) extracted.push(decodeLean(file.name, joinChunks(chunks, fileBytes))); + }; + try { + file.start(); + } catch (error) { + failure = error instanceof Error ? error : new Error(`Could not extract ${file.name}.`); + } + }); + unzip.register(UnzipInflate); + + try { + if (data.byteLength === 0) unzip.push(data, true); + for (let offset = 0; offset < data.byteLength; offset += ARCHIVE_INPUT_CHUNK_BYTES) { + if (failure) throw failure; + const end = Math.min(data.byteLength, offset + ARCHIVE_INPUT_CHUNK_BYTES); + unzip.push(data.subarray(offset, end), end === data.byteLength); + } + if (failure) throw failure; + if (streamedEntries !== validatedEntryNames.length) invalidZip(); + } catch (error) { + if (error instanceof Error && /Unsafe Lean file path|larger than|Choose at most|extracted Lean sources/.test(error.message)) throw error; + throw new Error("The ZIP archive could not be safely extracted."); + } + return extracted; +} + export async function readLeanUploads(input: FileList | File[]): Promise { const sourceFiles = Array.from(input); const result: UploadedLeanFile[] = []; @@ -66,36 +235,7 @@ export async function readLeanUploads(input: FileList | File[]): Promise UPLOAD_LIMITS.leanArchiveBytes) { throw new Error(`${file.name} is larger than 5 MiB.`); } - let archive: ReturnType; - try { - let archiveLeanFiles = 0; - let archiveLeanBytes = 0; - archive = unzipSync(new Uint8Array(await file.arrayBuffer()), { - filter: (entry) => { - if (!entry.name.toLowerCase().endsWith(".lean")) return false; - if (!safeLeanPath(entry.name)) throw new Error(`Unsafe Lean file path: ${entry.name}`); - if (entry.originalSize > UPLOAD_LIMITS.leanFileBytes) throw new Error(`${entry.name} is larger than 256 KiB.`); - archiveLeanFiles += 1; - archiveLeanBytes += entry.originalSize; - if (result.length + archiveLeanFiles > UPLOAD_LIMITS.leanFiles) throw new Error(`Choose at most ${UPLOAD_LIMITS.leanFiles} Lean files.`); - if (result.reduce((sum, source) => sum + source.bytes, 0) + archiveLeanBytes > UPLOAD_LIMITS.leanTotalBytes) { - throw new Error("The extracted Lean sources are larger than 1 MiB in total."); - } - return true; - }, - }); - } catch (error) { - if (error instanceof Error && /Unsafe Lean file path|larger than|Choose at most|extracted Lean sources/.test(error.message)) throw error; - throw new Error(`${file.name} is not a readable ZIP archive.`); - } - for (const [path, data] of Object.entries(archive)) { - if (path.endsWith("/")) continue; - if (!safeLeanPath(path)) { - if (path.toLowerCase().endsWith(".lean")) throw new Error(`Unsafe Lean file path: ${path}`); - continue; - } - result.push(decodeLean(path, data)); - } + result.push(...readLeanArchive(new Uint8Array(await file.arrayBuffer()), result)); } return validateCollection(result); diff --git a/package-lock.json b/package-lock.json index 7a3b094..47a4b7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9528,9 +9528,9 @@ } }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "devOptional": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index b6f553e..3213eb8 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "wrangler": "4.92.0" }, "overrides": { - "postcss": "8.5.19" + "postcss": "8.5.19", + "ws": "8.21.1" }, "type": "module" } diff --git a/scripts/build-proof-registry.ts b/scripts/build-proof-registry.ts index a4b2ed9..05854a6 100644 --- a/scripts/build-proof-registry.ts +++ b/scripts/build-proof-registry.ts @@ -37,49 +37,44 @@ async function main() { ] > = []; for (const directory of includedDirectories) { - try { - const packageDirectory = path.join(proofsDirectory, directory); - const loaded = await loadProofPackageFromDirectory(packageDirectory); - const leanExcerpts: Record = {}; - for (const source of loaded.manifest.mappings.flatMap((mapping) => mapping.lean)) { - if (leanExcerpts[source.sourceId]) continue; - const file = await resolveExistingPackagePath(packageDirectory, source.file); - const lines = (await readFile(file, "utf8")).split(/\r?\n/); - leanExcerpts[source.sourceId] = lines - .slice(source.startLine - 1, source.endLine) - .join("\n"); - } + const packageDirectory = path.join(proofsDirectory, directory); + const loaded = await loadProofPackageFromDirectory(packageDirectory); + const leanExcerpts: Record = {}; + for (const source of loaded.manifest.mappings.flatMap((mapping) => mapping.lean)) { + if (leanExcerpts[source.sourceId]) continue; + const file = await resolveExistingPackagePath(packageDirectory, source.file); + const lines = (await readFile(file, "utf8")).split(/\r?\n/); + leanExcerpts[source.sourceId] = lines + .slice(source.startLine - 1, source.endLine) + .join("\n"); + } - const pdfPath = await resolveExistingPackagePath( - packageDirectory, - loaded.manifest.paper.pdf, - ); - const assetDirectory = path.join( - root, - "public", - "proof-assets", - loaded.manifest.id, - ); - const assetName = `${loaded.paperPages.pdfSha256}.pdf`; - await mkdir(assetDirectory, { recursive: true }); - await copyFile(pdfPath, path.join(assetDirectory, assetName)); + const pdfPath = await resolveExistingPackagePath( + packageDirectory, + loaded.manifest.paper.pdf, + ); + const assetDirectory = path.join( + root, + "public", + "proof-assets", + loaded.manifest.id, + ); + const assetName = `${loaded.paperPages.pdfSha256}.pdf`; + await mkdir(assetDirectory, { recursive: true }); + await copyFile(pdfPath, path.join(assetDirectory, assetName)); - entries.push([ - loaded.manifest.id, - { - manifest: loaded.manifest, - paperPages: loaded.paperPages, - verification: loaded.verification, - leanExcerpts, - paperAssetUrl: `/proof-assets/${loaded.manifest.id}/${assetName}`, - instructorEntries: loaded.instructorEntries, - recordedExplanations: loaded.recordedExplanations, - }, - ]); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; - throw error; - } + entries.push([ + loaded.manifest.id, + { + manifest: loaded.manifest, + paperPages: loaded.paperPages, + verification: loaded.verification, + leanExcerpts, + paperAssetUrl: `/proof-assets/${loaded.manifest.id}/${assetName}`, + instructorEntries: loaded.instructorEntries, + recordedExplanations: loaded.recordedExplanations, + }, + ]); } const ids = entries.map(([id]) => id); diff --git a/scripts/run-live-evaluations.ts b/scripts/run-live-evaluations.ts index 5c46a7a..2285832 100644 --- a/scripts/run-live-evaluations.ts +++ b/scripts/run-live-evaluations.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { createExplanationStream } from "../lib/explanation/openai-response.server"; import { buildExplanationContext } from "../lib/explanation/serialize-context.server"; @@ -18,15 +19,15 @@ type SanitizedRun = { }; async function main() { - const args = new Set(process.argv.slice(2)); + const rawArgs = process.argv.slice(2); + const args = new Set(rawArgs); if (!args.has("--confirm-live")) { throw new Error("Live evaluation is opt-in. Re-run with --confirm-live after reviewing cost and data handling."); } + const requestedCase = parseRequestedCase(rawArgs); const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) throw new Error("OPENAI_API_KEY is required for an explicit live evaluation"); - const caseArgumentIndex = process.argv.indexOf("--case"); - const requestedCase = caseArgumentIndex >= 0 ? process.argv[caseArgumentIndex + 1] : undefined; const { evaluationSet } = await loadEvaluationArtifacts(); await validateEvaluationSet(evaluationSet); const cases = evaluationSet.cases.filter( @@ -81,6 +82,17 @@ async function main() { console.log(`\nWrote sanitized metadata only to ${outputPath}. Responses and source text were not stored.`); } +export function parseRequestedCase(args: string[]): string | undefined { + const indexes = args.flatMap((value, index) => value === "--case" ? [index] : []); + if (indexes.length === 0) return undefined; + if (indexes.length > 1) throw new Error("--case may be provided only once"); + const value = args[indexes[0] + 1]; + if (!value || value.startsWith("--")) { + throw new Error("--case requires a live-eligible evaluation case id"); + } + return value; +} + async function readStream(stream: ReadableStream): Promise { const reader = stream.getReader(); const decoder = new TextDecoder(); @@ -106,7 +118,9 @@ function isUsage(value: unknown): value is { inputTokens: number; outputTokens: return typeof usage.inputTokens === "number" && typeof usage.outputTokens === "number"; } -main().catch((error: unknown) => { - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; -}); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + void main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/scripts/verify-proof-packages.ts b/scripts/verify-proof-packages.ts index 76d63c3..90f1bba 100644 --- a/scripts/verify-proof-packages.ts +++ b/scripts/verify-proof-packages.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { createHash } from "node:crypto"; -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { readFile, rename, writeFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; @@ -10,6 +10,8 @@ import { resolveExistingPackagePath, resolvePackagePath } from "../lib/proof-pac import { VerificationRecordSchema, type VerificationRecord } from "../lib/verification/schema"; const MAX_OUTPUT_BYTES = 2 * 1024 * 1024; +const DEFAULT_COMMAND_TIMEOUT_MS = 2 * 60 * 1_000; +const FORCE_KILL_GRACE_MS = 2_000; type CommandResult = { exitCode: number; @@ -45,22 +47,71 @@ export function parseAxiomOutput( return records; } -async function runCommand(command: string, args: string[], cwd: string): Promise { +export async function runCommand( + command: string, + args: string[], + cwd: string, + timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS, +): Promise { return new Promise((resolve) => { + const useProcessGroup = process.platform !== "win32"; const child = spawn(command, args, { cwd, env: { ...process.env, LEAN_ABORT_ON_PANIC: "1" }, + detached: useProcessGroup, shell: false, stdio: ["ignore", "pipe", "pipe"], }); const chunks: Buffer[] = []; let byteLength = 0; let exceededLimit = false; + let timedOut = false; + let settled = false; + let forceKillTimer: ReturnType | undefined; + const terminateProcessTree = (signal: NodeJS.Signals) => { + if (!child.pid) return; + if (process.platform === "win32") { + spawnSync( + "taskkill", + ["/pid", String(child.pid), "/T", ...(signal === "SIGKILL" ? ["/F"] : [])], + { stdio: "ignore", windowsHide: true }, + ); + return; + } + if (useProcessGroup) { + try { + process.kill(-child.pid, signal); + return; + } catch { + // The group may already have exited; fall back to the direct child. + } + } + child.kill(signal); + }; + const collectedOutput = () => Buffer.concat(chunks).toString("utf8"); + const finish = (result: CommandResult) => { + if (settled) return; + settled = true; + clearTimeout(timeoutTimer); + if (forceKillTimer) clearTimeout(forceKillTimer); + resolve(result); + }; + const timeoutTimer = setTimeout(() => { + timedOut = true; + terminateProcessTree("SIGTERM"); + forceKillTimer = setTimeout(() => { + terminateProcessTree("SIGKILL"); + finish({ + exitCode: 124, + output: `${collectedOutput()}\nVerification command exceeded ${timeoutMs} ms.\n`, + }); + }, FORCE_KILL_GRACE_MS); + }, timeoutMs); const collect = (chunk: Buffer) => { byteLength += chunk.byteLength; if (byteLength > MAX_OUTPUT_BYTES) { exceededLimit = true; - child.kill("SIGTERM"); + terminateProcessTree("SIGTERM"); return; } chunks.push(chunk); @@ -68,13 +119,17 @@ async function runCommand(command: string, args: string[], cwd: string): Promise child.stdout.on("data", collect); child.stderr.on("data", collect); child.on("error", (error) => { - resolve({ exitCode: 127, output: `Could not execute ${command}: ${error.message}\n` }); + finish({ exitCode: 127, output: `Could not execute ${command}: ${error.message}\n` }); }); child.on("close", (code) => { - const output = Buffer.concat(chunks).toString("utf8"); - resolve({ - exitCode: exceededLimit ? 125 : (code ?? 1), - output: exceededLimit ? `${output}\nVerification output exceeded ${MAX_OUTPUT_BYTES} bytes.\n` : output, + const output = collectedOutput(); + finish({ + exitCode: timedOut ? 124 : exceededLimit ? 125 : (code ?? 1), + output: timedOut + ? `${output}\nVerification command exceeded ${timeoutMs} ms.\n` + : exceededLimit + ? `${output}\nVerification output exceeded ${MAX_OUTPUT_BYTES} bytes.\n` + : output, }); }); }); diff --git a/tests/evaluation/live-arguments.test.ts b/tests/evaluation/live-arguments.test.ts new file mode 100644 index 0000000..0a3a0bd --- /dev/null +++ b/tests/evaluation/live-arguments.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { parseRequestedCase } from "../../scripts/run-live-evaluations"; + +describe("live evaluation arguments", () => { + it("returns an explicitly selected case", () => { + expect(parseRequestedCase(["--confirm-live", "--case", "simpler-example"])).toBe("simpler-example"); + }); + + it("rejects a missing or flag-like case value", () => { + expect(() => parseRequestedCase(["--confirm-live", "--case"])).toThrow("requires"); + expect(() => parseRequestedCase(["--case", "--confirm-live"])).toThrow("requires"); + }); + + it("rejects duplicate case flags", () => { + expect(() => parseRequestedCase(["--case", "one", "--case", "two"])).toThrow("only once"); + }); +}); diff --git a/tests/explanation/rate-limit.test.ts b/tests/explanation/rate-limit.test.ts index cc62b3b..5856f2c 100644 --- a/tests/explanation/rate-limit.test.ts +++ b/tests/explanation/rate-limit.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { checkRateLimit, resetRateLimitBuckets, + trustedRateLimitIdentity, } from "../../lib/explanation/rate-limit.server"; describe("checkRateLimit", () => { @@ -71,3 +72,19 @@ describe("checkRateLimit", () => { ); }); }); + +describe("trustedRateLimitIdentity", () => { + it("uses the Cloudflare-owned visitor header", () => { + const request = new Request("https://example.test", { + headers: { "cf-connecting-ip": "203.0.113.8" }, + }); + expect(trustedRateLimitIdentity(request)).toBe("203.0.113.8"); + }); + + it("ignores client-controlled forwarding headers", () => { + const request = new Request("https://example.test", { + headers: { "x-forwarded-for": "198.51.100.99", "x-real-ip": "198.51.100.100" }, + }); + expect(trustedRateLimitIdentity(request)).toBe("shared-anonymous"); + }); +}); diff --git a/tests/explanation/serialize-context.test.ts b/tests/explanation/serialize-context.test.ts index 27c9018..727000a 100644 --- a/tests/explanation/serialize-context.test.ts +++ b/tests/explanation/serialize-context.test.ts @@ -55,6 +55,22 @@ describe("buildExplanationContext", () => { ).rejects.toMatchObject({ code: "SOURCE_NOT_FOUND" }); }); + it("does not attach a page-level mapping to an unmapped paper block", async () => { + const request = explainRequestSchema.parse({ + proofId: "odd-sum-square", + source: "paper", + location: { source: "paper", page: 1, blockIds: ["page-1-block-2"] }, + selectedText: "Why Odd Numbers Build Perfect Squares", + mode: "details", + history: [], + }); + const { context } = await buildExplanationContext(request); + + expect(context.mappedSources).toEqual([]); + expect(context.prerequisites).toEqual([]); + expect(context.allowedSourceIds).toEqual(["page-1-block-2"]); + }); + it("validates a Lean declaration and line range before reading it", async () => { const request = explainRequestSchema.parse({ proofId: "cycle-double-cover", diff --git a/tests/proof-packages/registry-build.test.ts b/tests/proof-packages/registry-build.test.ts new file mode 100644 index 0000000..8563483 --- /dev/null +++ b/tests/proof-packages/registry-build.test.ts @@ -0,0 +1,31 @@ +import { spawnSync } from "node:child_process"; +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe("proof registry generation", () => { + it("fails when an explicitly included package is missing required assets", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "interactive-proof-registry-")); + temporaryDirectories.push(root); + await mkdir(path.join(root, "proofs", "broken"), { recursive: true }); + const result = spawnSync( + process.execPath, + [path.resolve("node_modules/tsx/dist/cli.mjs"), path.resolve("scripts/build-proof-registry.ts")], + { + cwd: root, + encoding: "utf8", + env: { ...process.env, PUBLIC_PROOF_IDS: "broken" }, + }, + ); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toMatch(/ENOENT|no such file/i); + }); +}); diff --git a/tests/uploads/lean-files.test.ts b/tests/uploads/lean-files.test.ts index 6191858..336c476 100644 --- a/tests/uploads/lean-files.test.ts +++ b/tests/uploads/lean-files.test.ts @@ -23,11 +23,61 @@ describe("Lean upload reader", () => { await expect(readLeanUploads([new File([archive], "bad.zip")])).rejects.toThrow("Unsafe Lean file path"); }); - it("rejects oversized ZIP entries before expanding them", async () => { + it("rejects oversized ZIP entries while streaming them", async () => { const archive = zipSync({ "Huge.lean": strToU8("x".repeat(256 * 1024 + 1)) }); await expect(readLeanUploads([new File([archive], "huge.zip")])).rejects.toThrow("larger than 256 KiB"); }); + it("enforces actual output size when ZIP metadata under-reports it", async () => { + const archive = zipSync({ "Huge.lean": strToU8("x".repeat(256 * 1024 + 1)) }); + const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength); + for (let index = 0; index <= archive.byteLength - 4; index += 1) { + const signature = view.getUint32(index, true); + if (signature === 0x04034b50) view.setUint32(index + 22, 1, true); + if (signature === 0x02014b50) view.setUint32(index + 24, 1, true); + } + + await expect(readLeanUploads([new File([archive], "dishonest.zip")])).rejects.toThrow("larger than 256 KiB"); + }); + + it("rejects truncated ZIP containers even when a local entry is complete", async () => { + const archive = zipSync({ "Main.lean": strToU8("theorem ok : True := by trivial") }); + const centralSignature = archive.findIndex((_, index) => + index <= archive.byteLength - 4 && + new DataView(archive.buffer, archive.byteOffset, archive.byteLength).getUint32(index, true) === 0x02014b50, + ); + const truncated = archive.subarray(0, centralSignature); + + await expect(readLeanUploads([new File([truncated], "truncated.zip")])).rejects.toThrow( + "could not be safely extracted", + ); + }); + + it("rejects data that is not a ZIP container", async () => { + await expect(readLeanUploads([new File(["not a zip"], "invalid.zip")])).rejects.toThrow( + "could not be safely extracted", + ); + }); + + it("rejects local entries omitted from the central directory", async () => { + const archive = zipSync({ "Main.lean": strToU8("theorem ok : True := by trivial") }); + const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength); + let endOffset = archive.byteLength - 22; + while (view.getUint32(endOffset, true) !== 0x06054b50) endOffset -= 1; + const tampered = new Uint8Array(endOffset + 22); + tampered.set(archive.subarray(0, endOffset)); + tampered.set(archive.subarray(endOffset), endOffset); + const tamperedView = new DataView(tampered.buffer); + tamperedView.setUint16(endOffset + 8, 0, true); + tamperedView.setUint16(endOffset + 10, 0, true); + tamperedView.setUint32(endOffset + 12, 0, true); + tamperedView.setUint32(endOffset + 16, endOffset, true); + + await expect(readLeanUploads([new File([tampered], "omitted.zip")])).rejects.toThrow( + "could not be safely extracted", + ); + }); + it("rejects oversized individual sources", async () => { const content = "x".repeat(256 * 1024 + 1); await expect(readLeanUploads([new File([content], "Huge.lean")])).rejects.toThrow("larger than 256 KiB"); diff --git a/tests/verification/verify-proof-packages.test.ts b/tests/verification/verify-proof-packages.test.ts index 96d7ed6..cc6dee3 100644 --- a/tests/verification/verify-proof-packages.test.ts +++ b/tests/verification/verify-proof-packages.test.ts @@ -4,6 +4,7 @@ import { countSorryTokens, declaredAxiomAudits, parseAxiomOutput, + runCommand, } from "../../scripts/verify-proof-packages"; describe("proof verification helpers", () => { @@ -30,4 +31,31 @@ describe("proof verification helpers", () => { { declaration: "two", axioms: [] }, ]); }); + + it("terminates a verification command that exceeds its deadline", async () => { + const result = await runCommand( + process.execPath, + ["-e", "setInterval(() => {}, 1000)"], + process.cwd(), + 25, + ); + + expect(result.exitCode).toBe(124); + expect(result.output).toContain("Verification command exceeded 25 ms"); + }); + + it.runIf(process.platform !== "win32")("terminates descendants that inherit command output", async () => { + const result = await runCommand( + process.execPath, + [ + "-e", + "require('node:child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: ['ignore', 'inherit', 'inherit'] }); setInterval(() => {}, 1000)", + ], + process.cwd(), + 25, + ); + + expect(result.exitCode).toBe(124); + expect(result.output).toContain("Verification command exceeded 25 ms"); + }); });