Source: lib/core/trace/redact.js

/**
 * @file redact.js
 * @description Property-path redaction for traces.
 *
 * A trace records real application state, which means it records whatever the
 * user typed. Redaction happens at *record* time, not at export time: a value
 * a rule matches is never written into the buffer at all, so a trace cannot
 * leak a secret that a later export step forgot to strip.
 *
 * Patterns are matched against the same dotted property paths the reactive
 * system already produces (see `getPropertyPath` in `reactive/watcher.js`), so
 * `auth.token` and `cart.items.2.cardNumber` are both addressable.
 * @module lib/core/trace/redact
 */

import { REDACTED } from './schema.js';

/**
 * The shortest withheld value that is worth scrubbing out of source text.
 * @type {number}
 */
const MIN_SCRUBBABLE_LENGTH = 6;

/**
 * How deep a withheld object is walked when collecting scrubbable strings.
 * @type {number}
 */
const REMEMBER_MAX_DEPTH = 6;

/**
 * How many strings are remembered from a single withheld value.
 *
 * A rule that matches a large object should not turn the redactor into a
 * copy of that object.
 * @type {number}
 */
const REMEMBER_MAX_STRINGS = 200;

/**
 * Escapes the regular-expression metacharacters in a literal path segment.
 * @param {string} segment - A literal segment.
 * @returns {string} The escaped segment.
 */
function escapeSegment(segment) {
  return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/**
 * Compiles one redaction pattern into a matcher.
 *
 * Supported syntax, deliberately small:
 *
 * - `auth.token` — that exact path.
 * - `auth.*` — any single segment under `auth`.
 * - `*.password` — `password` under any single segment.
 * - `auth.**` — `auth` and everything beneath it, at any depth.
 *
 * A pattern that matches a path also redacts everything nested below it: a
 * rule for `auth.token` must not be defeated by the value happening to be an
 * object. That also makes `auth` and `auth.**` equivalent, which is why `**`
 * needs no special handling beyond ending the pattern.
 * @param {string} pattern - The pattern source.
 * @returns {RegExp} A matcher anchored to the whole path.
 */
function compilePattern(pattern) {
  const parts = [];
  for (const segment of String(pattern).split('.')) {
    if (segment === '**') {
      break;
    }
    parts.push(segment === '*' ? '[^.]+' : escapeSegment(segment));
  }

  if (parts.length === 0) {
    return /^.*$/;
  }
  return new RegExp(`^${parts.join('\\.')}(?:\\..*)?$`);
}

/**
 * A compiled set of redaction rules.
 *
 * Held per recorder rather than globally, so a test can record with different
 * rules than the dev server without leaking configuration between them.
 */
export class Redactor {
  /**
   * @param {string[]} [patterns] - Redaction patterns from `avenx.config.json` or the runtime API.
   */
  constructor(patterns = []) {
    /**
     * The patterns as written, kept so an exported trace can declare what was
     * withheld from it.
     * @type {string[]}
     */
    this.patterns = [];
    /** @type {RegExp[]} */
    this.matchers = [];
    /**
     * True once a value has actually been withheld. A trace that declares
     * rules but never matched one is not a redacted trace.
     * @type {boolean}
     */
    this.applied = false;
    /** @type {Set<string>} */
    this.matchedPaths = new Set();

    /**
     * String values a rule actually withheld.
     *
     * Kept so the same value can be scrubbed out of recorded *source text*.
     * A trace records the verbatim body of every action it ran — that is what
     * lets it name the code responsible — and an action with a literal in it
     * would otherwise carry a value the path rules just withheld. These are
     * values already resident in the application's own memory; nothing new is
     * retained, and the set dies with the recorder.
     * @type {Set<string>}
     */
    this.withheldValues = new Set();

    for (const pattern of patterns) {
      this.add(pattern);
    }
  }

  /**
   * Registers an additional pattern.
   * @param {string} pattern - The pattern source.
   * @returns {Redactor} This redactor, for chaining.
   */
  add(pattern) {
    if (typeof pattern !== 'string' || pattern.trim() === '') {
      return this;
    }
    const trimmed = pattern.trim();
    if (this.patterns.includes(trimmed)) {
      return this;
    }
    this.patterns.push(trimmed);
    this.matchers.push(compilePattern(trimmed));
    return this;
  }

  /**
   * Whether this redactor has any rules at all. Hot paths check this first, so
   * an unconfigured recorder pays nothing for the feature.
   * @returns {boolean}
   */
  get isEmpty() {
    return this.matchers.length === 0;
  }

  /**
   * Whether a property path must be withheld.
   * @param {string} path - A dotted property path, e.g. `auth.token`.
   * @returns {boolean}
   */
  matches(path) {
    if (this.matchers.length === 0 || typeof path !== 'string' || path === '') {
      return false;
    }
    for (const matcher of this.matchers) {
      if (matcher.test(path)) {
        return true;
      }
    }
    return false;
  }

  /**
   * Notes that a rule fired for a path.
   *
   * Separate from {@link Redactor#matches} because capture cannot detect a
   * redaction by comparing values: `NaN !== NaN` would make every NaN look
   * like a withheld value.
   * @param {string} path - The path a rule matched.
   * @param {any} [value] - The withheld value, remembered so it can also be
   *   scrubbed out of recorded source text.
   */
  markApplied(path, value) {
    this.applied = true;
    this.matchedPaths.add(path);
    this.#remember(value, REMEMBER_MAX_DEPTH);
  }

  /**
   * Collects the strings inside a withheld value.
   *
   * Withholding `{ email, name }` withholds the email, so the email string is
   * a secret wherever else it appears — in a recorded call's arguments, for
   * instance, which no path rule covers.
   *
   * Short strings are excluded: scrubbing a two-character value out of every
   * recorded expression would mangle unrelated source for no benefit.
   * @param {any} value - A withheld value.
   * @param {number} depth - Remaining depth budget.
   * @private
   */
  #remember(value, depth) {
    if (this.withheldValues.size >= REMEMBER_MAX_STRINGS) {
      return;
    }
    if (typeof value === 'string') {
      if (value.length >= MIN_SCRUBBABLE_LENGTH) {
        this.withheldValues.add(value);
      }
      return;
    }
    if (depth <= 0 || value === null || typeof value !== 'object') {
      return;
    }
    try {
      const entries = Array.isArray(value) ? value : Object.values(value);
      for (const entry of entries) {
        this.#remember(entry, depth - 1);
      }
    } catch {
      // A throwing getter costs one value's worth of scrubbing, not the trace.
    }
  }

  /**
   * Removes any withheld value from a piece of recorded source text.
   *
   * Applied when a trace is serialized rather than when a node is recorded: an
   * action's source is captured before its writes run, so the value it
   * contains is not yet known to be a secret at that point.
   * @param {string} text - Recorded source text.
   * @returns {string} The text, with withheld values replaced.
   */
  scrub(text) {
    if (typeof text !== 'string' || this.withheldValues.size === 0) {
      return text;
    }
    let scrubbed = text;
    for (const secret of this.withheldValues) {
      if (scrubbed.includes(secret)) {
        scrubbed = scrubbed.split(secret).join(REDACTED);
      }
    }
    return scrubbed;
  }

  /**
   * Returns the value to record for a path: the value itself, or the redaction
   * placeholder when a rule matched.
   * @param {string} path - The property path the value sits at.
   * @param {any} value - The candidate value.
   * @returns {any} What may be recorded.
   */
  guard(path, value) {
    if (this.matches(path)) {
      this.markApplied(path, value);
      return REDACTED;
    }
    return value;
  }
}

/**
 * A redactor with no rules, shared by callers that have not configured any.
 * @type {Redactor}
 */
export const NO_REDACTION = new Redactor();