71 lines · 2.7 KB
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<void> {
    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<string[] | null>, state: MaskState): Promise<void> {