/** * The single CSV field escaper shared by every CSV producer (levelset's * CsvWriter and downstream consumers' CSV exports). Consolidated 2026-06-10 * in a downstream workspace — the three previous copies had already drifted * on the formula-trigger list (\n was handled in one of three); upstreamed * 2026-08-19. * * Two concerns, in order: * 1. Formula injection: Excel / Google Sheets / LibreOffice / Numbers treat * cells starting with these characters as formulas, so a value like * `=cmd|'/c calc'!A0` lifted from an uploaded file would execute when * the exported CSV is opened. Per OWASP, defang by prepending a single * apostrophe — Sheets hides it and Excel renders the payload literally. * 2. RFC-4180 quoting: wrap fields containing the delimiter, quotes, or * line breaks; double embedded quotes. */ const FORMULA_PREFIX_REGEX = /^[=+\-@\t\r\n]/; const COMMA_QUOTE_REGEX = /[,"\n\r]/; const quoteRegexCache = new Map(); function quoteRegexFor(delimiter: string): RegExp { if (delimiter === ",") return COMMA_QUOTE_REGEX; let regex = quoteRegexCache.get(delimiter); if (!regex) { const escaped = delimiter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); regex = new RegExp(`[${escaped}"\n\r]`); quoteRegexCache.set(delimiter, regex); } return regex; } /** Escape one CSV field: formula-defang, then RFC-4180 quote as needed. */ export function escapeCsvField(value: unknown, delimiter = ","): string { if (value === null || value === undefined) return ""; // A cell is whatever the caller put in the row: string, number, Date, … The // lossy `String()` coercion of a plain object is the intended fallback for a // text format — a CSV writer stringifies rather than throws. // oxlint-disable-next-line typescript/no-base-to-string let str = String(value); if (FORMULA_PREFIX_REGEX.test(str)) { str = `'${str}`; } if (quoteRegexFor(delimiter).test(str)) { return `"${str.replace(/"/g, '""')}"`; } return str; }