fast-browser-with-jev
This commit is contained in:
@@ -0,0 +1,564 @@
|
||||
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`;
|
||||
}
|
||||
Reference in New Issue
Block a user