From d57ac4e8f28dc647728282a77b422c29420ac673 Mon Sep 17 00:00:00 2001 From: Todd Schiller Date: Fri, 5 Jun 2026 12:52:57 -0400 Subject: [PATCH] Refactor: extract defineInlineTextRedactRule factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three text-walker rules — pii-redact, secrets-redact, and encoded-payload-redact — duplicated the same five-primitive lifecycle around a one-line difference in `collectMatches`: a ReusableAbortController + lazy subscribeRouteChange + createSubtreeWatcher with skipPlaceholderSubtrees + walkTextNodesChunked + a teardown that unwinds all of them. The new `defineInlineTextRedactRule` in `lib/inline-text-redact.ts` owns that scaffolding. Each rule file now declares its detection logic (regex patterns, decoding, entropy scoring) and hands `id`, `label`, `description`, `minLength`, and `collectMatches` to the factory. Net -52 LOC across the four files, and lifecycle state now lives in a closure rather than at module scope. prompt-injection-redact deliberately stays bespoke — it inserts block placeholders against a walked-up container, doesn't use the subtree watcher, and runs a second-pass dedupe via `onComplete`. A future `defineBlockTextRedactRule` could absorb it if more rules adopt that shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- extension/src/lib/inline-text-redact.ts | 100 ++++++++++++++++++ extension/src/rules/encoded-payload-redact.ts | 61 ++--------- extension/src/rules/pii-redact.ts | 65 ++---------- extension/src/rules/secrets-redact.ts | 62 ++--------- 4 files changed, 118 insertions(+), 170 deletions(-) create mode 100644 extension/src/lib/inline-text-redact.ts diff --git a/extension/src/lib/inline-text-redact.ts b/extension/src/lib/inline-text-redact.ts new file mode 100644 index 0000000..bda5c8f --- /dev/null +++ b/extension/src/lib/inline-text-redact.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Factory for rules that scan text nodes and replace matched ranges in +// place with click-to-reveal inline placeholders. Used by `pii-redact`, +// `secrets-redact`, and `encoded-payload-redact`, which previously +// duplicated the same lifecycle scaffolding around a one-line difference +// in `collectMatches`. +// +// Lifecycle the factory owns: +// - One `ReusableAbortController` whose signal is threaded into every +// chunked text walk. Route changes call `abortAndReset` to cancel +// in-flight scans against the old tree; teardown does the same. +// - One lazy `subscribeRouteChange` registration created on first +// `apply`. Teardown unsubscribes and clears the slot so a later +// `apply` re-registers cleanly. +// - One `createSubtreeWatcher` with `skipPlaceholderSubtrees: true` +// so the rule's own inserted placeholders don't re-trigger it. +// +// Incremental subtree-watcher batches deliberately do NOT abort on +// route change — they target their own scoped root and don't compete +// with the previous scan. + +import { ReusableAbortController } from "abort-utils"; +import type { Rule } from "../rules/types"; +import type { InlineMatch } from "./placeholder"; +import { replaceMatchesInTextNode } from "./placeholder"; +import { subscribeRouteChange } from "./route-change"; +import type { RuleId } from "./storage"; +import { createSubtreeWatcher } from "./subtree-watcher"; +import { walkTextNodesChunked } from "./yielding-text-walk"; + +export interface InlineTextRedactRuleOptions { + id: RuleId; + label: string; + description: string; + // Skip text nodes shorter than this — cheap per-node early-out before + // the regex pass runs. Set to the shortest pattern the rule can match. + minLength: number; + // Pure function: given a text node's value, return any inline matches + // (already merged / sorted as the rule wants them applied). + collectMatches: (text: string) => InlineMatch[]; +} + +// Tighter than `Rule` — the factory always installs a teardown, so callers +// (notably tests that call `rule.teardown()` directly in `afterEach`) don't +// need to widen with `?.()` or assertion. +export type InlineTextRedactRule = Rule & { teardown: () => void }; + +export function defineInlineTextRedactRule( + options: InlineTextRedactRuleOptions, +): InlineTextRedactRule { + const { id, label, description, minLength, collectMatches } = options; + + const lifecycle = new ReusableAbortController(); + let unsubscribeRouteChange: (() => void) | null = null; + + function scanAndMask(root: ParentNode): void { + walkTextNodesChunked(root, { + signal: lifecycle.signal, + minLength, + process: (chunk) => { + for (const node of chunk) { + const matches = collectMatches(node.nodeValue ?? ""); + if (matches.length > 0) { + replaceMatchesInTextNode(node, matches, id); + } + } + }, + }); + } + + const watcher = createSubtreeWatcher({ + skipPlaceholderSubtrees: true, + onSubtrees: (roots) => { + for (const root of roots) { + scanAndMask(root); + } + }, + }); + + return { + id, + label, + description, + apply(root: ParentNode): void { + unsubscribeRouteChange ??= subscribeRouteChange(() => { + lifecycle.abortAndReset(); + }); + scanAndMask(root); + watcher.start(root); + }, + teardown(): void { + watcher.stop(); + lifecycle.abortAndReset(); + unsubscribeRouteChange?.(); + unsubscribeRouteChange = null; + }, + } satisfies InlineTextRedactRule; +} diff --git a/extension/src/rules/encoded-payload-redact.ts b/extension/src/rules/encoded-payload-redact.ts index e84f64f..2c49580 100644 --- a/extension/src/rules/encoded-payload-redact.ts +++ b/extension/src/rules/encoded-payload-redact.ts @@ -19,15 +19,8 @@ // Matches are replaced inline with a click-to-reveal placeholder. False // positives cost one click, not lost data. -import { ReusableAbortController } from "abort-utils"; +import { defineInlineTextRedactRule } from "../lib/inline-text-redact"; import type { InlineMatch } from "../lib/placeholder"; -import { replaceMatchesInTextNode } from "../lib/placeholder"; -import { subscribeRouteChange } from "../lib/route-change"; -import { createSubtreeWatcher } from "../lib/subtree-watcher"; -import { walkTextNodesChunked } from "../lib/yielding-text-walk"; -import type { Rule } from "./types"; - -const RULE_ID = "encoded-payload-redact" as const; // Length floors per encoding. Tuned to sit above common hash/fingerprint // sizes (SHA-512 hex = 128, so 160 leaves headroom) and below typical @@ -298,53 +291,11 @@ function collectMatches(text: string): InlineMatch[] { return merged; } -// See pii-redact for lifecycle rationale. -const lifecycle = new ReusableAbortController(); -let unsubscribeRouteChange: (() => void) | null = null; - -function scanAndMask(root: ParentNode): void { - const signal = lifecycle.signal; - walkTextNodesChunked(root, { - signal, - minLength: MIN_TEXT_LENGTH, - process: (chunk) => { - for (const node of chunk) { - const matches = collectMatches(node.nodeValue ?? ""); - if (matches.length > 0) { - replaceMatchesInTextNode(node, matches, RULE_ID); - } - } - }, - }); -} - -const watcher = createSubtreeWatcher({ - skipPlaceholderSubtrees: true, - onSubtrees: (roots) => { - for (const root of roots) { - scanAndMask(root); - } - }, -}); - -function apply(root: ParentNode): void { - unsubscribeRouteChange ??= subscribeRouteChange(() => { - lifecycle.abortAndReset(); - }); - scanAndMask(root); - watcher.start(root); -} - -export const encodedPayloadRedactRule = { - id: RULE_ID, +export const encodedPayloadRedactRule = defineInlineTextRedactRule({ + id: "encoded-payload-redact", label: "Redact Encoded Payloads", description: "Redact long base64, hex, or percent-encoded runs in text nodes whose decoded bytes are mostly readable text. Defends against the 'decode this and follow it' indirect-injection carrier; hashes, fingerprints, and binary blobs are left alone.", - apply, - teardown: () => { - watcher.stop(); - lifecycle.abortAndReset(); - unsubscribeRouteChange?.(); - unsubscribeRouteChange = null; - }, -} satisfies Rule; + minLength: MIN_TEXT_LENGTH, + collectMatches, +}); diff --git a/extension/src/rules/pii-redact.ts b/extension/src/rules/pii-redact.ts index ad1814b..670b4ab 100644 --- a/extension/src/rules/pii-redact.ts +++ b/extension/src/rules/pii-redact.ts @@ -1,15 +1,9 @@ // Copyright (c) 2026 PixieBrix, Inc. // Licensed under PolyForm Shield 1.0.0 — see LICENSE. -import { ReusableAbortController } from "abort-utils"; +import { defineInlineTextRedactRule } from "../lib/inline-text-redact"; import type { InlineMatch } from "../lib/placeholder"; -import { replaceMatchesInTextNode } from "../lib/placeholder"; -import { subscribeRouteChange } from "../lib/route-change"; -import { createSubtreeWatcher } from "../lib/subtree-watcher"; -import { walkTextNodesChunked } from "../lib/yielding-text-walk"; -import type { Rule } from "./types"; -const RULE_ID = "pii-redact" as const; const MIN_TEXT_LENGTH = 9; // shortest pattern is a hyphenated 9-digit SSN. const CC_PATTERN = /\b(?:\d[ -]?){12,18}\d\b/g; @@ -78,57 +72,10 @@ function collectMatches(text: string): InlineMatch[] { return merged; } -// Lifecycle controller covers every async scan kicked off by apply -// and the watcher. abortAndReset on route change cancels any in-flight -// chunked walk so a scan started against the old tree can't keep -// mutating the new one; abortAndReset on teardown stops everything. -// Incremental subtree-watcher batches do NOT abort — they target their -// own scoped root and don't conflict with the previous scan. -const lifecycle = new ReusableAbortController(); -let unsubscribeRouteChange: (() => void) | null = null; - -function scanAndMask(root: ParentNode): void { - const signal = lifecycle.signal; - walkTextNodesChunked(root, { - signal, - minLength: MIN_TEXT_LENGTH, - process: (chunk) => { - for (const node of chunk) { - const matches = collectMatches(node.nodeValue ?? ""); - if (matches.length > 0) { - replaceMatchesInTextNode(node, matches, RULE_ID); - } - } - }, - }); -} - -const watcher = createSubtreeWatcher({ - skipPlaceholderSubtrees: true, - onSubtrees: (roots) => { - for (const root of roots) { - scanAndMask(root); - } - }, -}); - -function apply(root: ParentNode): void { - unsubscribeRouteChange ??= subscribeRouteChange(() => { - lifecycle.abortAndReset(); - }); - scanAndMask(root); - watcher.start(root); -} - -export const piiRedactRule = { - id: RULE_ID, +export const piiRedactRule = defineInlineTextRedactRule({ + id: "pii-redact", label: "Mask PII", description: "Hide credit card numbers, phone numbers, and SSNs.", - apply, - teardown: () => { - watcher.stop(); - lifecycle.abortAndReset(); - unsubscribeRouteChange?.(); - unsubscribeRouteChange = null; - }, -} satisfies Rule; + minLength: MIN_TEXT_LENGTH, + collectMatches, +}); diff --git a/extension/src/rules/secrets-redact.ts b/extension/src/rules/secrets-redact.ts index e706f25..6471a24 100644 --- a/extension/src/rules/secrets-redact.ts +++ b/extension/src/rules/secrets-redact.ts @@ -1,15 +1,9 @@ // Copyright (c) 2026 PixieBrix, Inc. // Licensed under PolyForm Shield 1.0.0 — see LICENSE. -import { ReusableAbortController } from "abort-utils"; +import { defineInlineTextRedactRule } from "../lib/inline-text-redact"; import type { InlineMatch } from "../lib/placeholder"; -import { replaceMatchesInTextNode } from "../lib/placeholder"; -import { subscribeRouteChange } from "../lib/route-change"; -import { createSubtreeWatcher } from "../lib/subtree-watcher"; -import { walkTextNodesChunked } from "../lib/yielding-text-walk"; -import type { Rule } from "./types"; -const RULE_ID = "secrets-redact" as const; // Shortest provider-prefixed pattern (npm_<32>) is 36 chars; cap below that // to skip prose nodes early without losing real candidates. const MIN_TEXT_LENGTH = 16; @@ -149,55 +143,11 @@ function collectMatches(text: string): InlineMatch[] { return merged; } -// See pii-redact for lifecycle rationale — same pattern: route-change -// aborts in-flight chunked walks; incremental subtree-watcher batches -// do not. -const lifecycle = new ReusableAbortController(); -let unsubscribeRouteChange: (() => void) | null = null; - -function scanAndMask(root: ParentNode): void { - const signal = lifecycle.signal; - walkTextNodesChunked(root, { - signal, - minLength: MIN_TEXT_LENGTH, - process: (chunk) => { - for (const node of chunk) { - const matches = collectMatches(node.nodeValue ?? ""); - if (matches.length > 0) { - replaceMatchesInTextNode(node, matches, RULE_ID); - } - } - }, - }); -} - -const watcher = createSubtreeWatcher({ - skipPlaceholderSubtrees: true, - onSubtrees: (roots) => { - for (const root of roots) { - scanAndMask(root); - } - }, -}); - -function apply(root: ParentNode): void { - unsubscribeRouteChange ??= subscribeRouteChange(() => { - lifecycle.abortAndReset(); - }); - scanAndMask(root); - watcher.start(root); -} - -export const secretsRedactRule = { - id: RULE_ID, +export const secretsRedactRule = defineInlineTextRedactRule({ + id: "secrets-redact", label: "Mask Secrets", description: "Hide API keys, tokens, JWTs, private keys, and other high-entropy credentials.", - apply, - teardown: () => { - watcher.stop(); - lifecycle.abortAndReset(); - unsubscribeRouteChange?.(); - unsubscribeRouteChange = null; - }, -} satisfies Rule; + minLength: MIN_TEXT_LENGTH, + collectMatches, +});