span, after * sanitizing, with "<" and ">" neutralized. * * Bounds on withholding (memory and time): * - MAX_HOLD characters: a longer held buffer is released with bracket characters * neutralized, so it can no longer form a link or image; * - MAX_HOLD_MS: text held longer than this (measured at the next delta) is * released the same way; * - end of stream (flush): anything still unfinished is released neutralized. * The held buffer therefore never exceeds MAX_HOLD plus one delta. The final * "done" event carries the fully guarded text. */ final class StreamGuard { public const MAX_HOLD = 2000; public const MAX_HOLD_MS = 3000; private string $buffer = ''; private ?float $heldSince = null; private int $peak = 0; /** @var callable(): float milliseconds */ private $now; /** * @param callable(string): void $emit * @param (callable(): float)|null $now millisecond clock (injectable for tests) */ public function __construct(private OutputGuard $guard, private $emit, ?callable $now = null) { $this->now = $now ?? static fn(): float => microtime(true) * 1000; } public function push(string $delta): void { $this->buffer .= $delta; $this->peak = max($this->peak, mb_strlen($this->buffer)); $limit = $this->openConstructAt($this->buffer) ?? mb_strlen($this->buffer); $cut = $this->lastTopLevelWhitespace($this->buffer, $limit); if ($cut > 0) { $this->release(mb_substr($this->buffer, 0, $cut + 1), false); $this->buffer = mb_substr($this->buffer, $cut + 1); } if ($this->buffer === '') { $this->heldSince = null; return; } $this->heldSince ??= ($this->now)(); $tooLong = mb_strlen($this->buffer) > self::MAX_HOLD; $tooOld = ($this->now)() - $this->heldSince > self::MAX_HOLD_MS; if ($tooLong || $tooOld) { $this->release($this->buffer, true); $this->buffer = ''; $this->heldSince = null; } } public function flush(): void { if ($this->buffer !== '') { // End of stream: anything still open is incomplete; neutralize it. $this->release($this->buffer, $this->openConstructAt($this->buffer, true) !== null); $this->buffer = ''; $this->heldSince = null; } } /** Largest buffer held so far (for tests and diagnostics). */ public function peakHeld(): int { return $this->peak; } /** * Last whitespace before $limit that is outside every [...], (...) and <...> span, so a * complete construct is never split across two released segments (which would let each * half pass the guard while the client reassembles the whole). 0 if none. */ private function lastTopLevelWhitespace(string $s, int $limit): int { $depth = 0; $cut = 0; for ($i = 0; $i < $limit; $i++) { $c = mb_substr($s, $i, 1); if ($c === '[' || $c === '(' || $c === '<') { $depth++; } elseif (($c === ']' || $c === ')' || $c === '>') && $depth > 0) { $depth--; } elseif ($depth === 0 && ($c === ' ' || $c === "\n" || $c === "\t")) { $cut = $i; } } return $cut; } /** Index of the earliest unfinished "[", "[...](" or "<", or null if none. */ private function openConstructAt(string $s, bool $final = false): ?int { $len = mb_strlen($s); for ($i = 0; $i < $len; $i++) { $c = mb_substr($s, $i, 1); if ($c === '<' && mb_strpos($s, '>', $i) === false) { return $i; } if ($c === '[') { $close = mb_strpos($s, ']', $i); if ($close === false) { return $i; } if ($close + 1 === $len) { if ($final) { continue; // stream ended: no "(" can follow, e.g. a final citation marker [1] } return $i; // "[label]" at the very end: a "(" may still follow } if (mb_substr($s, $close + 1, 1) === '(' && mb_strpos($s, ')', $close + 1) === false) { return $i; } } } return null; } private function release(string $segment, bool $neutralizeBrackets): void { $trailing = preg_match('/\s+$/u', $segment, $m) ? $m[0] : ''; $clean = $this->guard->sanitize($segment)['text']; $clean = strtr($clean, ['<' => '‹', '>' => '›']); if ($neutralizeBrackets) { $clean = strtr($clean, ['[' => '[', ']' => ']', '(' => '(', ')' => ')']); } ($this->emit)($clean . $trailing); } }