import { MAX_ADDED_MASKS, maskValues, withAddedMask } from "./markers"; const MASKS_KEY = "masks"; const MASKING_UNAVAILABLE = "[masking unavailable; line hidden]"; const MAX_CARRY_CHARS = 16 * 1024; const KEPT_CARRY_CHARS = 4 * 1024; /** * Splits a self-hosted log chunk into whole lines, carrying the unterminated tail into the next chunk so a * secret split across two POSTs is still masked as one line (CI-P8). An overlong tail is flushed except for * its end, which stays carried so a secret near the cut can still match. */ export function splitLogChunk(carry: string, text: string): { lines: string[]; carry: string } { const lines = (carry + text).split("\n"); const tail = lines.pop() ?? ""; if (tail.length > MAX_CARRY_CHARS) { return { lines: [...lines, tail.slice(0, -KEPT_CARRY_CHARS)], carry: tail.slice(-KEPT_CARRY_CHARS) }; } return { lines, carry: tail }; } /** Mask bookkeeping a Runner Durable Object keeps in its job meta. */ export type MaskState = { addedMasks?: number; pendingMasks?: string[] }; /** * A job's log masks, persisted in Durable Object storage. Fails closed: while the list can't be loaded, * every line is hidden, and `::add-mask::` values seen meanwhile wait in the meta until it loads. */ export class JobMasks { private masks: string[] | null = null; constructor(private readonly storage: DurableObjectStorage) {} async store(values: string[]): Promise { this.masks = maskValues(values); await this.storage.put(MASKS_KEY, this.masks); } /** Loads the stored list, or rebuilds it with `rebuild` (which may return null when it can't). */ async ensure(rebuild: () => Promise, state: MaskState): Promise { this.masks ??= (await this.storage.get(MASKS_KEY)) ?? null; if (this.masks === null) { const values = await rebuild(); if (values) await this.store(values); } if (this.masks !== null && state.pendingMasks?.length) { const pending = state.pendingMasks; state.pendingMasks = []; for (const value of pending) await this.add(state, value); } } async add(state: MaskState, value: string): Promise { if (this.masks === null) { state.pendingMasks = [...(state.pendingMasks ?? []), value].slice(0, MAX_ADDED_MASKS); return; } const masks = withAddedMask(this.masks, state.addedMasks ?? 0, value); if (!masks) return; state.addedMasks = (state.addedMasks ?? 0) + 1; await this.store(masks); } apply(text: string): string { if (this.masks === null) return MASKING_UNAVAILABLE; let out = text; for (const value of this.masks) out = out.split(value).join("***"); return out; } }