import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { sanitizeId, tmuxSessionName, capturePane } from '../pty/manager.js'; import { searchRingBuffer, clearBuffer } from '../pty/registry.js'; const ParamsSchema = z.object({ sid: z.string(), pid: z.string(), }); const MAX_PATTERN_LENGTH = 200; // Zod-refined string: reject empty and overly-long patterns to prevent ReDoS const PatternQuerySchema = z .string() .min(1, 'pattern is required') .max(MAX_PATTERN_LENGTH, `pattern must not exceed ${MAX_PATTERN_LENGTH} characters`); const QuerySchema = z.object({ pattern: PatternQuerySchema, limit: z.coerce.number().int().min(1).max(500).default(50), context: z.coerce.number().int().min(0).max(50).default(0), }); interface SearchMatch { line: number; content: string; contextBefore: string[]; contextAfter: string[]; } interface SearchResponse { matches: SearchMatch[]; total: number; truncated: boolean; source: 'ring' | 'capture'; } /** * Search a captured pane buffer using a regex. This is the fallback path * when the ring buffer doesn't have enough matches. */ function grepBuffer( text: string, pattern: string, limit: number, context: number, ): SearchMatch[] { let re: RegExp; try { re = new RegExp(pattern, 'u'); } catch { return []; } const lines = text.split('\n'); const results: SearchMatch[] = []; for (let i = 0; i < lines.length; i++) { if (results.length >= limit) break; if (re.test(lines[i]!)) { const contextBefore: string[] = []; const contextAfter: string[] = []; for (let c = 1; c <= context; c++) { const ci = i - c; if (ci >= 0) contextBefore.unshift(lines[ci]!); } for (let c = 1; c <= context; c++) { const ci = i + c; if (ci < lines.length) contextAfter.push(lines[ci]!); } results.push({ line: i + 1, content: lines[i]!, contextBefore, contextAfter, }); } } return results; } export function registerSearchRoutes(app: FastifyInstance, tmuxConfPath: string): void { app.get<{ Params: { sid: string; pid: string }; Querystring: { pattern?: string; limit?: string; context?: string }; }>( '/api/term/sessions/:sid/panes/:pid/search', async (req, reply) => { const p = ParamsSchema.safeParse(req.params); if (!p.success) return reply.code(400).send({ error: 'bad_params' }); const sid = sanitizeId(p.data.sid); const pid = sanitizeId(p.data.pid); if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' }); const q = QuerySchema.safeParse(req.query); if (!q.success) { return reply.code(400).send({ error: 'bad_query', details: q.error.flatten().fieldErrors, }); } const { pattern, limit, context } = q.data; // ── Path 1: ring buffer search (fast, no tmux interaction) ── const ringMatches = searchRingBuffer(pid, pattern, { limit, context }); if (ringMatches.length >= limit) { return reply.code(200).send({ matches: ringMatches, total: ringMatches.length, truncated: ringMatches.length >= limit, source: 'ring' as const, }); } // ── Path 2: capture-pane + grep fallback (10s timeout) ── const sessionName = tmuxSessionName(pid); let capture: string; try { capture = await withTimeout( capturePane(tmuxConfPath, sessionName, 5000), 10_000, ); } catch (err) { req.log.warn({ err, pid }, 'capture-pane timed out or failed'); return reply.code(200).send({ matches: ringMatches, total: ringMatches.length, truncated: false, source: 'ring' as const, }); } if (!capture) { // tmux pane may no longer exist — return whatever ring had return reply.code(200).send({ matches: ringMatches, total: ringMatches.length, truncated: false, source: 'ring' as const, }); } const captureMatches = grepBuffer(capture, pattern, limit, context); return reply.code(200).send({ matches: captureMatches, total: captureMatches.length, truncated: captureMatches.length >= limit, source: 'capture' as const, }); }, ); } function withTimeout(promise: Promise, ms: number): Promise { return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms), ), ]); }