import { MARKER_PREFIX } from "./script"; import type { JobExecution } from "./jobs"; const MIN_MASK_LENGTH = 4; /** Single lines of multi-line secrets are only masked when long enough not to redact ordinary log text. */ const MIN_LINE_MASK_LENGTH = 8; type Marker = | { type: "step-start"; index: number } | { type: "step-end"; index: number; code: number } | { type: "step-skip"; index: number } | { type: "artifact"; name: string } | { type: "output"; name: string; value: string } | { type: "job-end"; failed: boolean } | { type: "setup-start" } | { type: "setup-end"; code: number }; function decodeBase64(value: string): string { try { return new TextDecoder().decode(Uint8Array.from(atob(value), (char) => char.charCodeAt(0))); } catch { return ""; } } /** Markers share stdout with user steps, so only lines carrying the job's secret nonce count. */ export function parseMarker(line: string, nonce: string | null): Marker | null { const prefix = nonce === null ? MARKER_PREFIX : `${MARKER_PREFIX}${nonce} `; if (!line.startsWith(prefix)) return null; const [type, ...args] = line.slice(prefix.length).trim().split(" "); const number = (value: string | undefined) => Number(value ?? "0"); switch (type) { case "step-start": return { type, index: number(args[0]) }; case "step-end": return { type, index: number(args[0]), code: number(args[1]) }; case "step-skip": return { type, index: number(args[0]) }; case "artifact": return { type, name: args[0] ?? "artifact" }; case "output": return { type, name: args[0] ?? "", value: decodeBase64(args[1] ?? "") }; case "job-end": return { type, failed: args[0] !== "0" }; case "setup-start": return { type }; case "setup-end": return { type, code: number(args[0]) }; default: return null; } } const MAX_JOB_OUTPUTS = 50; const MAX_OUTPUT_BYTES = 64 * 1024; /** Job outputs flow into other jobs' scripts, so their count and total size are bounded. */ export function withOutput(outputs: Record, name: string, value: string): Record { if (!(name in outputs) && Object.keys(outputs).length >= MAX_JOB_OUTPUTS) return outputs; const used = Object.entries(outputs).reduce((sum, [key, item]) => sum + (key === name ? 0 : item.length), 0); if (used + value.length > MAX_OUTPUT_BYTES) return outputs; return { ...outputs, [name]: value }; } /** Multi-line secrets are printed line by line, so every line is masked on its own too. */ export function maskValues(values: string[]): string[] { const out = new Set(); for (const value of values) { if (value.length >= MIN_MASK_LENGTH) out.add(value); for (const line of value.split(/\r?\n/)) { const trimmed = line.trim(); if (trimmed.length >= MIN_LINE_MASK_LENGTH && !trimmed.startsWith("-----")) out.add(trimmed); } } return [...out].sort((left, right) => right.length - left.length); } /** Names a job may report through markers: its upload-artifact steps and declared outputs. */ export function declaredNames(execution: JobExecution): { allowedArtifacts: string[]; allowedOutputs: string[] } { return { allowedArtifacts: execution.spec.steps.flatMap((step) => (step.artifact ? [step.artifact.name] : [])), allowedOutputs: execution.spec.outputs.map(([name]) => name), }; }