565 lines
26 KiB
TypeScript
565 lines
26 KiB
TypeScript
import {
|
|
chmodSync,
|
|
closeSync,
|
|
constants,
|
|
fsyncSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
openSync,
|
|
realpathSync,
|
|
renameSync,
|
|
unlinkSync,
|
|
writeSync,
|
|
} from "node:fs";
|
|
import { dirname, basename, resolve } from "node:path";
|
|
import {
|
|
BROWSER_RUNTIME_OPERATIONS,
|
|
FAST_BROWSER_REPORT_VERSION,
|
|
type BrowserErrorDetails,
|
|
type PrivateArtifactMetadata,
|
|
type ScenarioOperationReport as RuntimeScenarioOperationReport,
|
|
type ScenarioReport as RuntimeScenarioReport,
|
|
} from "./contracts.ts";
|
|
|
|
export const SCENARIO_REPORT_VERSION = FAST_BROWSER_REPORT_VERSION;
|
|
const MAX_REPORT_TEXT = 2_000;
|
|
const MAX_REPORT_ITEMS = 64;
|
|
const MAX_EVIDENCE = 32;
|
|
const MAX_PATH = 4_000;
|
|
const FIXED_EVIDENCE_REASONS = new Set([
|
|
"browser session is unavailable",
|
|
"evidence capture was cancelled",
|
|
"browser transport is unavailable",
|
|
"browser returned no complete failure evidence",
|
|
"partial failure evidence",
|
|
]);
|
|
|
|
/** Published runtime report type; kept as an alias so callers use contracts.ts as the source of truth. */
|
|
export type PublishedScenarioReport = RuntimeScenarioReport;
|
|
|
|
export type ScenarioStatus = "passed" | "failed" | "skipped" | "blocked" | "not_run" | "validated";
|
|
export type CleanupStatus = "passed" | "failed" | "not_run";
|
|
export type EvidenceKind = "screenshot" | "console" | "request" | "other";
|
|
|
|
export interface ScenarioEvidenceReference {
|
|
kind: EvidenceKind;
|
|
artifactId: string;
|
|
status: "captured" | "failed";
|
|
/** A basename or caller-selected local relative label; never a URL/content blob. */
|
|
file?: string;
|
|
/** Private filesystem path retained for local report/evidence lifetime checks. */
|
|
retainedPath?: string;
|
|
private: true;
|
|
forwardedToModel: false;
|
|
error?: string;
|
|
}
|
|
|
|
export interface ScenarioCaseReport {
|
|
id: string;
|
|
status: ScenarioStatus;
|
|
elapsedMs: number;
|
|
operations?: RuntimeScenarioOperationReport[];
|
|
phase?: string;
|
|
browser?: {
|
|
product?: string;
|
|
headless?: boolean;
|
|
owned?: boolean;
|
|
profile?: string;
|
|
sessionId?: string;
|
|
};
|
|
failure?: {
|
|
phase: string;
|
|
message: string;
|
|
};
|
|
evidence?: ScenarioEvidenceReference[];
|
|
cleanup?: {
|
|
status: CleanupStatus;
|
|
detail: string;
|
|
};
|
|
}
|
|
|
|
/** CLI validation/matrix envelope; execution reports use contracts.ts ScenarioReport. */
|
|
export interface ScenarioReportEnvelope {
|
|
version: typeof SCENARIO_REPORT_VERSION;
|
|
kind: "fast-browser-scenario-report";
|
|
generatedAt: string;
|
|
status: ScenarioStatus;
|
|
elapsedMs: number;
|
|
scenarios: ScenarioCaseReport[];
|
|
evidence: ScenarioEvidenceReference[];
|
|
cleanup: {
|
|
status: CleanupStatus;
|
|
detail: string;
|
|
};
|
|
/** Original failure is independent of evidence-capture/cleanup failures. */
|
|
failure?: {
|
|
scenarioId?: string;
|
|
phase: string;
|
|
message: string;
|
|
};
|
|
}
|
|
|
|
export interface ScenarioFailureInput {
|
|
scenarioId?: unknown;
|
|
phase?: unknown;
|
|
error?: unknown;
|
|
secrets?: Iterable<string>;
|
|
}
|
|
|
|
export interface BuildScenarioReportInput {
|
|
/** Known caller-held secrets used only for final report redaction. */
|
|
secrets?: Iterable<string>;
|
|
status: ScenarioStatus;
|
|
elapsedMs?: unknown;
|
|
scenarios?: readonly unknown[];
|
|
cleanup?: unknown;
|
|
failure?: ScenarioFailureInput;
|
|
evidence?: readonly unknown[];
|
|
generatedAt?: string;
|
|
}
|
|
|
|
export class ScenarioReportError extends Error {
|
|
override name = "ScenarioReportError";
|
|
}
|
|
|
|
function text(value: unknown, fallback: string, max = MAX_REPORT_TEXT): string {
|
|
if (typeof value !== "string") return fallback;
|
|
const normalized = value.replace(/[\0\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
|
|
if (!normalized) return fallback;
|
|
return normalized.length > max ? `${normalized.slice(0, max - 1)}…` : normalized;
|
|
}
|
|
|
|
function knownSecrets(secrets: Iterable<string>): string[] {
|
|
return [...secrets].filter((secret) => typeof secret === "string" && secret.length > 0);
|
|
}
|
|
|
|
function redactKnownText(value: string, secrets: readonly string[]): string {
|
|
let result = value;
|
|
for (const secret of secrets) result = result.split(secret).join("[redacted]");
|
|
return result;
|
|
}
|
|
|
|
function safeNumber(value: unknown, fallback = 0, max = 120_000): number {
|
|
return typeof value === "number" && Number.isFinite(value) ? Math.min(max, Math.max(0, Math.round(value))) : fallback;
|
|
}
|
|
|
|
function safeId(value: unknown, fallback = "scenario"): string {
|
|
const result = text(value, fallback, 128);
|
|
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(result) ? result : fallback;
|
|
}
|
|
|
|
function redactUrl(value: unknown): string | undefined {
|
|
if (typeof value !== "string") return undefined;
|
|
try {
|
|
const parsed = new URL(value);
|
|
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) return "[redacted-url]";
|
|
// Reports carry only an origin. Paths, queries, fragments, and tokens are
|
|
// deliberately private to the browser worker.
|
|
return parsed.origin;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/** Sanitize an error without exposing credential values or provider responses. */
|
|
export function sanitizeReportError(error: unknown, secrets: Iterable<string> = []): string {
|
|
let message = error instanceof Error ? error.message : typeof error === "string" ? error : "operation failed";
|
|
const replacements = new Set<string>();
|
|
for (const secret of secrets) if (typeof secret === "string" && secret.length > 0) replacements.add(secret);
|
|
for (const secret of replacements) message = message.split(secret).join("[redacted]");
|
|
message = message
|
|
.replace(/(?:https?|wss?):\/\/[^\s)]+/gi, (url) => url.toLowerCase().startsWith("ws") ? "[redacted-url]" : (redactUrl(url) ?? "[redacted-url]"))
|
|
.replace(/\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*:\s*[^,;\s]+/gi, "$1: [redacted]")
|
|
.replace(/\b(?:bearer|basic)\s+[^\s]+/gi, "$1 [redacted]")
|
|
.replace(/([?&](?:token|key|secret|password|passwd|auth|code)=)[^&#\s]*/gi, "$1[redacted]")
|
|
.replace(/\b(cookie|set-cookie|password|passwd|secret|token|api[_-]?key)\s*[=:]\s*[^\s,;]+/gi, (_match, key: string) => `${key}=[redacted]`);
|
|
return text(message, "operation failed");
|
|
}
|
|
|
|
function safeArtifactId(value: unknown): string {
|
|
const id = text(value, "evidence", 128);
|
|
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id) ? id : "evidence";
|
|
}
|
|
|
|
function safeEvidenceReason(value: unknown, secrets: readonly string[]): string {
|
|
const reason = sanitizeReportError(value, secrets);
|
|
return FIXED_EVIDENCE_REASONS.has(reason) ? reason : "failure evidence unavailable";
|
|
}
|
|
|
|
function sanitizeEvidence(value: unknown, secrets: readonly string[] = []): ScenarioEvidenceReference | undefined {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
const source = value as Record<string, unknown>;
|
|
const kind = source.kind === "screenshot" || source.kind === "console" || source.kind === "request" || source.kind === "other"
|
|
? source.kind : undefined;
|
|
if (!kind) return undefined;
|
|
const file = source.file === undefined ? undefined : redactKnownText(text(source.file, "", 255), secrets);
|
|
const retainedPath = source.retainedPath === undefined ? undefined : redactKnownText(text(source.retainedPath, "", MAX_PATH), secrets);
|
|
if (file?.includes("://") || file?.includes("\\") || file?.includes("\0")) return undefined;
|
|
if (retainedPath?.includes("://") || retainedPath?.includes("\\") || retainedPath?.includes("\0")) return undefined;
|
|
const error = source.error === undefined ? undefined : safeEvidenceReason(source.error, secrets);
|
|
const result: ScenarioEvidenceReference = {
|
|
kind,
|
|
artifactId: safeArtifactId(redactKnownText(text(source.artifactId, "evidence", 128), secrets)),
|
|
status: source.status === "captured" ? "captured" : "failed",
|
|
private: true,
|
|
forwardedToModel: false,
|
|
...(retainedPath ? { file: retainedPath } : file ? { file: basename(file) } : {}),
|
|
...(retainedPath ? { retainedPath } : {}),
|
|
// Only fixed runtime producer reasons cross this boundary; arbitrary
|
|
// diagnostics are replaced with a generic unavailable reason.
|
|
...(error ? { error } : {}),
|
|
};
|
|
return result;
|
|
}
|
|
|
|
function sanitizeBrowser(value: unknown, secrets: readonly string[] = []): ScenarioCaseReport["browser"] {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
const source = value as Record<string, unknown>;
|
|
const browser: NonNullable<ScenarioCaseReport["browser"]> = {};
|
|
if (source.product === "vivaldi" || source.product === "chrome" || source.product === "chromium") browser.product = source.product;
|
|
if (typeof source.headless === "boolean") browser.headless = source.headless;
|
|
if (typeof source.owned === "boolean") browser.owned = source.owned;
|
|
if (source.profile !== undefined) {
|
|
const profile = redactKnownText(text(source.profile, "", 64), secrets);
|
|
if (/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(profile)) browser.profile = profile;
|
|
}
|
|
if (source.sessionId !== undefined) browser.sessionId = redactKnownText(text(source.sessionId, "", 200), secrets);
|
|
return Object.keys(browser).length ? browser : undefined;
|
|
}
|
|
|
|
function sanitizeCleanup(value: unknown, secrets: readonly string[] = []): { status: CleanupStatus; detail: string } {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return { status: "not_run", detail: "cleanup not run" };
|
|
const source = value as Record<string, unknown>;
|
|
const status: CleanupStatus = source.status === "passed" || source.status === "failed" ? source.status : "not_run";
|
|
const fallback = status === "passed" ? "cleanup completed" : "cleanup not run";
|
|
return { status, detail: source.detail === undefined ? fallback : sanitizeReportError(source.detail, secrets) };
|
|
}
|
|
|
|
function sanitizeScenarioOperation(value: unknown, index: number, secrets: Iterable<string>): RuntimeScenarioOperationReport | undefined {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
const source = value as Record<string, unknown>;
|
|
const operationNames = ["step", "read", "assert", "navigate", "reload", "listTabs", "selectTab", "waitPopup", "closeTab"] as const;
|
|
if (!operationNames.includes(source.op as typeof operationNames[number])) return undefined;
|
|
return {
|
|
index: safeNumber(source.index, index, MAX_REPORT_ITEMS),
|
|
op: source.op as RuntimeScenarioOperationReport["op"],
|
|
status: source.status === "passed" || source.status === "skipped" ? source.status : "failed",
|
|
elapsedMs: safeNumber(source.elapsedMs),
|
|
mutationState: source.mutationState === "not_attempted" || source.mutationState === "verified" ? source.mutationState : "unknown",
|
|
...(source.error ? { error: sanitizeRuntimeError(source.error, secrets) } : {}),
|
|
};
|
|
}
|
|
|
|
function sanitizeCase(value: unknown, secrets: readonly string[], index: number): ScenarioCaseReport {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return { id: `scenario-${index + 1}`, status: "failed", elapsedMs: 0, failure: { phase: "report", message: "invalid scenario result" } };
|
|
}
|
|
const source = value as Record<string, unknown>;
|
|
const statuses: ScenarioStatus[] = ["passed", "failed", "skipped", "blocked", "not_run", "validated"];
|
|
const status = statuses.includes(source.status as ScenarioStatus) ? source.status as ScenarioStatus : "failed";
|
|
const result: ScenarioCaseReport = {
|
|
id: safeId(source.id, `scenario-${index + 1}`),
|
|
status,
|
|
elapsedMs: safeNumber(source.elapsedMs),
|
|
...(source.phase === undefined ? {} : { phase: text(source.phase, "unknown", 128) }),
|
|
...(sanitizeBrowser(source.browser, secrets) === undefined ? {} : { browser: sanitizeBrowser(source.browser, secrets) }),
|
|
};
|
|
if (Array.isArray(source.operations)) {
|
|
const operations = source.operations.slice(0, MAX_REPORT_ITEMS).map((entry, operationIndex) => sanitizeScenarioOperation(entry, operationIndex, secrets)).filter((entry): entry is RuntimeScenarioOperationReport => Boolean(entry));
|
|
if (operations.length) result.operations = operations;
|
|
}
|
|
const rawFailure = source.failure;
|
|
if (rawFailure && typeof rawFailure === "object" && !Array.isArray(rawFailure)) {
|
|
const failure = rawFailure as Record<string, unknown>;
|
|
result.failure = {
|
|
phase: text(failure.phase, "unknown", 128),
|
|
message: sanitizeReportError(failure.message, secrets),
|
|
};
|
|
}
|
|
if (source.cleanup !== undefined) result.cleanup = sanitizeCleanup(source.cleanup, secrets);
|
|
if (Array.isArray(source.evidence)) {
|
|
const evidence = source.evidence.slice(0, MAX_EVIDENCE).map((entry) => sanitizeEvidence(entry, secrets)).filter((entry): entry is ScenarioEvidenceReference => Boolean(entry));
|
|
if (evidence.length) result.evidence = evidence;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function reportStatus(status: unknown): ScenarioStatus {
|
|
const values: ScenarioStatus[] = ["passed", "failed", "skipped", "blocked", "not_run", "validated"];
|
|
return values.includes(status as ScenarioStatus) ? status as ScenarioStatus : "failed";
|
|
}
|
|
|
|
/** Build a bounded machine-readable report from runtime-neutral result data. */
|
|
export function createScenarioReport(input: BuildScenarioReportInput): ScenarioReportEnvelope {
|
|
const secrets = knownSecrets(input.secrets ?? input.failure?.secrets ?? []);
|
|
const cases = Array.isArray(input.scenarios)
|
|
? input.scenarios.slice(0, MAX_REPORT_ITEMS).map((entry, index) => sanitizeCase(entry, secrets, index))
|
|
: [];
|
|
const evidence = Array.isArray(input.evidence)
|
|
? input.evidence.slice(0, MAX_EVIDENCE).map((entry) => sanitizeEvidence(entry, secrets)).filter((entry): entry is ScenarioEvidenceReference => Boolean(entry))
|
|
: [];
|
|
const cleanup = sanitizeCleanup(input.cleanup, secrets);
|
|
const report: ScenarioReportEnvelope = {
|
|
version: SCENARIO_REPORT_VERSION,
|
|
kind: "fast-browser-scenario-report",
|
|
generatedAt: input.generatedAt && !Number.isNaN(Date.parse(input.generatedAt)) ? new Date(input.generatedAt).toISOString() : new Date().toISOString(),
|
|
status: reportStatus(input.status),
|
|
elapsedMs: safeNumber(input.elapsedMs),
|
|
scenarios: cases,
|
|
evidence,
|
|
cleanup,
|
|
};
|
|
if (input.failure) {
|
|
report.failure = {
|
|
...(input.failure.scenarioId === undefined ? {} : { scenarioId: safeId(input.failure.scenarioId) }),
|
|
phase: text(input.failure.phase, "unknown", 128),
|
|
message: sanitizeReportError(input.failure.error, secrets),
|
|
};
|
|
}
|
|
return report;
|
|
}
|
|
|
|
function sanitizeArtifactMetadata(value: unknown, secrets: readonly string[] = []): PrivateArtifactMetadata | undefined {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
const source = value as Record<string, unknown>;
|
|
const kind = source.kind === "screenshot" || source.kind === "diagnostics" ? source.kind : undefined;
|
|
const contentType = source.contentType === "image/png" || source.contentType === "application/json" ? source.contentType : undefined;
|
|
const owner = source.owner;
|
|
if (!kind || !contentType || !owner || typeof owner !== "object" || Array.isArray(owner)) return undefined;
|
|
const ownerRecord = owner as Record<string, unknown>;
|
|
const path = typeof source.path === "string" && !source.path.includes("\\") && !source.path.includes("://")
|
|
? redactKnownText(basename(source.path), secrets)
|
|
: "private-artifact";
|
|
return {
|
|
artifactId: safeArtifactId(redactKnownText(text(source.artifactId, "artifact", 128), secrets)),
|
|
kind,
|
|
contentType,
|
|
path,
|
|
bytes: safeNumber(source.bytes, 0, 60 * 1024 * 1024),
|
|
owner: {
|
|
sessionId: redactKnownText(text(ownerRecord.sessionId, "session", 200), secrets),
|
|
runId: redactKnownText(text(ownerRecord.runId, "run", 200), secrets),
|
|
operationId: redactKnownText(text(ownerRecord.operationId, "operation", 200), secrets),
|
|
},
|
|
permissions: "0600",
|
|
localOnly: true,
|
|
modelForwarded: false,
|
|
};
|
|
}
|
|
|
|
/** Convert a published private artifact to a report-only local reference. */
|
|
export function evidenceReference(metadata: PrivateArtifactMetadata): ScenarioEvidenceReference | undefined {
|
|
const safe = sanitizeArtifactMetadata(metadata);
|
|
if (!safe) return undefined;
|
|
return {
|
|
kind: safe.kind === "screenshot" ? "screenshot" : "other",
|
|
artifactId: safe.artifactId,
|
|
status: "captured",
|
|
file: safe.path,
|
|
...(typeof metadata.path === "string" ? { retainedPath: metadata.path } : {}),
|
|
private: true,
|
|
forwardedToModel: false,
|
|
};
|
|
}
|
|
|
|
function sanitizeRuntimeError(value: unknown, secrets: Iterable<string>): BrowserErrorDetails | undefined {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
const source = value as Record<string, unknown>;
|
|
const codes = ["aborted", "timeout", "stale", "policy_denied", "invalid_request", "no_tab", "ambiguous_tab", "browser_closed", "unsupported", "worker_failure"] as const;
|
|
const retries = ["never", "safe"] as const;
|
|
const mutations = ["not_attempted", "verified", "unknown"] as const;
|
|
const operationValue = text(source.operation, "unknown", 64);
|
|
const operation = BROWSER_RUNTIME_OPERATIONS.includes(operationValue as typeof BROWSER_RUNTIME_OPERATIONS[number])
|
|
? operationValue as BrowserErrorDetails["operation"]
|
|
: "observe";
|
|
const code = codes.includes(source.code as typeof codes[number]) ? source.code as typeof codes[number] : "worker_failure";
|
|
const mutationState = mutations.includes(source.mutationState as typeof mutations[number]) ? source.mutationState as typeof mutations[number] : "unknown";
|
|
const retry = retries.includes(source.retry as typeof retries[number]) ? source.retry as typeof retries[number] : "never";
|
|
return {
|
|
code,
|
|
operation,
|
|
phase: text(source.phase, "unknown", 128),
|
|
message: sanitizeReportError(source.message, secrets),
|
|
mutationState,
|
|
retry,
|
|
};
|
|
}
|
|
|
|
/** Sanitize the published runtime report without changing its contract shape. */
|
|
export function sanitizeRuntimeScenarioReport(report: RuntimeScenarioReport, secrets: Iterable<string> = []): RuntimeScenarioReport {
|
|
const safeSecrets = knownSecrets(secrets);
|
|
const operations: RuntimeScenarioOperationReport[] = Array.isArray(report.operations)
|
|
? report.operations.slice(0, MAX_REPORT_ITEMS).map((operation, index) => ({
|
|
index: safeNumber(operation?.index, index, MAX_REPORT_ITEMS),
|
|
op: operation?.op,
|
|
status: operation?.status === "passed" || operation?.status === "skipped" ? operation.status : "failed",
|
|
elapsedMs: safeNumber(operation?.elapsedMs),
|
|
mutationState: operation?.mutationState === "not_attempted" || operation?.mutationState === "verified" ? operation.mutationState : "unknown",
|
|
...(operation?.error ? { error: sanitizeRuntimeError(operation.error, safeSecrets) } : {}),
|
|
}))
|
|
: [];
|
|
const evidence = Array.isArray(report.evidence)
|
|
? report.evidence.slice(0, MAX_EVIDENCE).map((entry) => sanitizeArtifactMetadata(entry, safeSecrets)).filter((entry): entry is PrivateArtifactMetadata => Boolean(entry))
|
|
: [];
|
|
return {
|
|
version: FAST_BROWSER_REPORT_VERSION,
|
|
scenarioId: safeId(report.scenarioId),
|
|
status: report.status === "passed" || report.status === "skipped" ? report.status : "failed",
|
|
elapsedMs: safeNumber(report.elapsedMs),
|
|
operations,
|
|
evidence,
|
|
cleanup: report.cleanup === "complete" ? "complete" : "failed",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Serialize only the public report shape. This second pass protects callers
|
|
* that retain a report object and later mutate an untrusted input object.
|
|
*/
|
|
export function sanitizeScenarioReport(report: ScenarioReportEnvelope, secrets: Iterable<string> = []): ScenarioReportEnvelope {
|
|
return createScenarioReport({
|
|
status: report.status,
|
|
elapsedMs: report.elapsedMs,
|
|
generatedAt: report.generatedAt,
|
|
scenarios: report.scenarios,
|
|
evidence: report.evidence,
|
|
cleanup: report.cleanup,
|
|
...(report.failure ? { failure: { ...report.failure, error: report.failure.message } } : {}),
|
|
secrets: knownSecrets(secrets),
|
|
});
|
|
}
|
|
|
|
function assertNoSymlinkPath(path: string): void {
|
|
let current = resolve(path);
|
|
while (true) {
|
|
try { lstatSync(current); break; }
|
|
catch {
|
|
const next = dirname(current);
|
|
if (next === current) return;
|
|
current = next;
|
|
}
|
|
}
|
|
if (realpathSync(current) !== current) throw new ScenarioReportError("report path may not contain symlinks");
|
|
}
|
|
|
|
function assertSafeReportDirectory(path: string): void {
|
|
const absolute = resolve(path);
|
|
const info = lstatSync(absolute);
|
|
if (!info.isDirectory() || info.isSymbolicLink()) throw new ScenarioReportError("report directory must be a regular directory");
|
|
if (realpathSync(absolute) !== absolute) throw new ScenarioReportError("report directory may not contain symlinks");
|
|
if (typeof process.getuid === "function" && info.uid !== process.getuid()) throw new ScenarioReportError("report directory is not owned by this user");
|
|
// A normal owned 0755 project directory is safe; group/world writable
|
|
// parents are not, and are never chmodded by report output.
|
|
if ((info.mode & 0o022) !== 0) throw new ScenarioReportError("report directory must not be group/world writable");
|
|
let parent = dirname(absolute);
|
|
while (true) {
|
|
const parentInfo = lstatSync(parent);
|
|
if (parentInfo.isSymbolicLink()) throw new ScenarioReportError("report parent may not contain symlinks");
|
|
// A sticky system scratch parent (for example /tmp) is acceptable when
|
|
// the actual report directory is owned and non-writable by others.
|
|
if ((parentInfo.mode & 0o022) !== 0 && (parentInfo.mode & 0o1000) === 0) throw new ScenarioReportError("report parent must not be group/world writable");
|
|
const next = dirname(parent);
|
|
if (next === parent) break;
|
|
parent = next;
|
|
}
|
|
}
|
|
|
|
function validateOutputPath(path: string): string {
|
|
if (typeof path !== "string" || path.length === 0 || path.length > MAX_PATH || path.includes("\0") || path.includes("://")) {
|
|
throw new ScenarioReportError("report path must be a local bounded path");
|
|
}
|
|
return resolve(path);
|
|
}
|
|
|
|
/** Atomically write a mode-0600 JSON report beneath an owned, non-writable-by-others directory. */
|
|
export function writeScenarioReport(path: string, report: ScenarioReportEnvelope | RuntimeScenarioReport, secrets: Iterable<string> = []): void {
|
|
const target = validateOutputPath(path);
|
|
const directory = dirname(target);
|
|
assertNoSymlinkPath(directory);
|
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
assertSafeReportDirectory(directory);
|
|
const existing = (() => { try { return lstatSync(target); } catch { return undefined; } })();
|
|
if (existing) {
|
|
if (existing.isSymbolicLink() || !existing.isFile()) throw new ScenarioReportError("report destination must be a regular file");
|
|
if (typeof process.getuid === "function" && existing.uid !== process.getuid()) throw new ScenarioReportError("report destination is not owned by this user");
|
|
}
|
|
const safe = "scenarioId" in report
|
|
? sanitizeRuntimeScenarioReport(report, secrets)
|
|
: sanitizeScenarioReport(report, secrets);
|
|
const content = `${JSON.stringify(safe, null, 2)}\n`;
|
|
const temporary = resolve(directory, `.${basename(target)}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
|
|
let fd: number | undefined;
|
|
try {
|
|
fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
|
|
writeSync(fd, content, undefined, "utf8");
|
|
fsyncSync(fd);
|
|
closeSync(fd);
|
|
fd = undefined;
|
|
chmodSync(temporary, 0o600);
|
|
renameSync(temporary, target);
|
|
} catch (error) {
|
|
if (fd !== undefined) {
|
|
try { closeSync(fd); } catch { /* best effort */ }
|
|
}
|
|
try { unlinkSync(temporary); } catch { /* preserve an existing report */ }
|
|
if (error instanceof ScenarioReportError) throw error;
|
|
throw new ScenarioReportError("unable to write scenario report");
|
|
}
|
|
}
|
|
|
|
/** Human-readable output never includes steps, field values, page text, or URLs. */
|
|
export function formatScenarioReport(report: ScenarioReportEnvelope | RuntimeScenarioReport, secrets: Iterable<string> = []): string {
|
|
const safeSecrets = knownSecrets(secrets);
|
|
if ("scenarioId" in report) {
|
|
const safe = sanitizeRuntimeScenarioReport(report, safeSecrets);
|
|
const lines = [
|
|
"Fast-browser scenario report",
|
|
`Status: ${safe.status}`,
|
|
`Scenario: ${safe.scenarioId}`,
|
|
`Elapsed: ${safe.elapsedMs}ms`,
|
|
`Operations: ${safe.operations.length}`,
|
|
`Cleanup: ${safe.cleanup}`,
|
|
];
|
|
for (const operation of safe.operations) {
|
|
lines.push(`${operation.status.toUpperCase()} ${operation.op} (${operation.elapsedMs}ms)`);
|
|
if (operation.error) lines.push(` Failure (${operation.error.phase}): ${operation.error.message}`);
|
|
}
|
|
if (safe.evidence.length) lines.push(`Evidence: ${safe.evidence.length} private local artifact(s)`);
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
const safe = sanitizeScenarioReport(report, safeSecrets);
|
|
const lines = [
|
|
"Fast-browser scenario report",
|
|
`Status: ${safe.status}`,
|
|
`Scenarios: ${safe.scenarios.length}`,
|
|
`Elapsed: ${safe.elapsedMs}ms`,
|
|
];
|
|
for (const scenario of safe.scenarios) {
|
|
lines.push(`${scenario.status.toUpperCase()} ${scenario.id} (${scenario.elapsedMs}ms)`);
|
|
for (const operation of scenario.operations ?? []) {
|
|
lines.push(` ${operation.status.toUpperCase()} ${operation.op} (${operation.elapsedMs}ms)`);
|
|
if (operation.error) lines.push(` Failure (${operation.error.phase}): ${operation.error.message}`);
|
|
}
|
|
if (scenario.failure) lines.push(` Failure (${scenario.failure.phase}): ${scenario.failure.message}`);
|
|
if (scenario.cleanup) lines.push(` Cleanup: ${scenario.cleanup.status} — ${scenario.cleanup.detail}`);
|
|
}
|
|
if (safe.failure) lines.push(`Failure (${safe.failure.phase}): ${safe.failure.message}`);
|
|
lines.push(`Cleanup: ${safe.cleanup.status} — ${safe.cleanup.detail}`);
|
|
if (safe.evidence.length) {
|
|
lines.push(`Evidence: ${safe.evidence.length} private local artifact(s)`);
|
|
for (const evidence of safe.evidence) {
|
|
if (evidence.status === "failed" && evidence.error) lines.push(` Evidence ${evidence.kind}: ${evidence.error}`);
|
|
}
|
|
}
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
/** Serialize either report envelope through the same defensive sanitizer used by human output. */
|
|
export function formatScenarioReportJson(report: ScenarioReportEnvelope | RuntimeScenarioReport, secrets: Iterable<string> = []): string {
|
|
const safeSecrets = knownSecrets(secrets);
|
|
const safe = "scenarioId" in report
|
|
? sanitizeRuntimeScenarioReport(report, safeSecrets)
|
|
: sanitizeScenarioReport(report, safeSecrets);
|
|
return `${JSON.stringify(safe, null, 2)}\n`;
|
|
}
|