import { randomBytes } from "node:crypto"; import { chmod, type FileHandle, mkdir, open, rename, unlink } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import type { ScenarioResult } from "../scenarios/definition.js"; import { VERSION } from "../version.js"; import type { RunManifest } from "./artifacts.js"; import { redactSensitiveText } from "./report.js"; export interface CiReportOptions { readonly junit?: string; readonly sarif?: string; } function escapeXml(value: string | number): string { return redactSensitiveText(String(value)) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function scenarioFailureDetail(scenario: ScenarioResult): string { const details = [ scenario.summary, ...(scenario.infrastructureError ? [`${scenario.infrastructureError.errorClass}: ${scenario.infrastructureError.message}`] : []), ...scenario.assertions .filter((assertion) => !assertion.passed) .map((assertion) => `${assertion.name}: ${assertion.summary ?? "assertion failed"}`), ...(scenario.findings ?? []).map( (finding) => `${finding.ruleId}: ${finding.summary} (${finding.actual})`, ), ]; return details.map((detail) => redactSensitiveText(detail)).join("\n"); } export function generateJunitReport(manifest: RunManifest): string { const failedScenarios = manifest.scenarios.filter( ({ outcome }) => outcome === "failed" || outcome === "unsupported", ).length; const skipped = manifest.scenarios.filter(({ outcome }) => outcome === "skipped").length; const needsRunPolicyFailure = manifest.outcome === "failed" && failedScenarios === 0; const failures = failedScenarios + (needsRunPolicyFailure ? 1 : 0); const tests = manifest.scenarios.length + (needsRunPolicyFailure ? 1 : 0); const durationSeconds = manifest.scenarios.reduce((total, scenario) => total + scenario.durationMs, 0) / 1000; const cases = manifest.scenarios.map((scenario) => { const attributes = [ `classname="${escapeXml(scenario.category)}"`, `name="${escapeXml(scenario.title)}"`, `time="${(scenario.durationMs / 1000).toFixed(6)}"`, ].join(" "); if (scenario.outcome === "failed") { return ` \n ${escapeXml(scenarioFailureDetail(scenario))}\n `; } if (scenario.outcome === "unsupported") { return ` \n ${escapeXml(scenarioFailureDetail(scenario))}\n `; } if (scenario.outcome === "skipped") { return ` \n \n `; } return ` `; }); if (needsRunPolicyFailure) { cases.push( ' \n The run manifest is failed even though no scenario emitted a failure. Inspect skipped scenarios and the replayable run manifest.\n ', ); } return [ '', ``, ` `, ...cases, "", "", ].join("\n"); } interface SarifRule { readonly id: string; readonly name: string; readonly shortDescription: { readonly text: string }; } interface SarifResult { readonly ruleId: string; readonly level: "error" | "warning"; readonly message: { readonly text: string }; readonly properties: Readonly>; } function assertionRuleId(name: string): string { const normalized = name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); return `psbt-lab.assertion.${normalized || "failed"}`; } function addSarifError( rules: Map, results: SarifResult[], ruleId: string, name: string, description: string, message: string, properties: Readonly>, ): void { rules.set(ruleId, { id: ruleId, name, shortDescription: { text: description }, }); results.push({ ruleId, level: "error", message: { text: redactSensitiveText(message) }, properties, }); } export function generateSarifReport(manifest: RunManifest): string { const rules = new Map(); const results: SarifResult[] = []; for (const scenario of manifest.scenarios) { for (const finding of scenario.findings ?? []) { rules.set(finding.ruleId, { id: finding.ruleId, name: finding.id, shortDescription: { text: redactSensitiveText(finding.summary) }, }); results.push({ ruleId: finding.ruleId, level: "warning", message: { text: redactSensitiveText(`${finding.summary} Observed: ${finding.actual}`), }, properties: { scenario: scenario.id, category: scenario.category, implementation: redactSensitiveText(finding.implementation), finding: finding.id, }, }); } const failedAssertions = scenario.assertions.filter(({ passed }) => !passed); for (const assertion of failedAssertions) { const ruleId = assertionRuleId(assertion.name); rules.set(ruleId, { id: ruleId, name: assertion.name, shortDescription: { text: `PSBT interoperability assertion ${assertion.name}` }, }); results.push({ ruleId, level: "error", message: { text: redactSensitiveText(assertion.summary ?? scenario.summary), }, properties: { scenario: scenario.id, category: scenario.category, assertion: assertion.name, ...(assertion.likelyImplementation ? { implementation: redactSensitiveText(assertion.likelyImplementation) } : {}), }, }); } if (scenario.outcome === "unsupported") { addSarifError( rules, results, "psbt-lab.scenario.unsupported", "unsupported-scenario", "A required interoperability scenario was unsupported", scenario.summary, { scenario: scenario.id, category: scenario.category, outcome: scenario.outcome, }, ); } else if (scenario.outcome === "failed" && failedAssertions.length === 0) { addSarifError( rules, results, "psbt-lab.scenario.failed", "failed-scenario", "An interoperability scenario failed without assertion evidence", scenarioFailureDetail(scenario), { scenario: scenario.id, category: scenario.category, outcome: scenario.outcome, }, ); } } if (manifest.outcome === "failed" && !results.some(({ level }) => level === "error")) { addSarifError( rules, results, "psbt-lab.run.failed", "failed-run", "The PSBT Interop Lab command failed", "The run manifest is failed even though no scenario emitted an error result. Inspect skipped scenarios and the replayable run manifest.", { runId: manifest.runId, outcome: manifest.outcome }, ); } return `${JSON.stringify( { version: "2.1.0", $schema: "https://json.schemastore.org/sarif-2.1.0.json", runs: [ { tool: { driver: { name: "PSBT Interop Lab", version: VERSION, informationUri: "https://github.com/GautamBytes/psbt-interop-lab", rules: [...rules.values()], }, }, results, properties: { runId: manifest.runId, outcome: manifest.outcome, }, }, ], }, null, 2, )}\n`; } async function atomicWrite(path: string, contents: string): Promise { const resolved = resolve(path); await mkdir(dirname(resolved), { recursive: true, mode: 0o700 }); const temporary = `${resolved}.tmp-${randomBytes(8).toString("hex")}`; let handle: FileHandle | undefined = await open(temporary, "wx", 0o600); try { await handle.writeFile(contents, "utf8"); await handle.sync(); await handle.close(); handle = undefined; await rename(temporary, resolved); await chmod(resolved, 0o600); } catch (error) { await handle?.close().catch(() => undefined); await unlink(temporary).catch(() => undefined); throw error; } } export async function writeCiReports( manifest: RunManifest, options: CiReportOptions, ): Promise { if (options.junit) await atomicWrite(options.junit, generateJunitReport(manifest)); if (options.sarif) await atomicWrite(options.sarif, generateSarifReport(manifest)); }