From 2ea5f9a696dcd23507acedc9632ffe58af6d663c Mon Sep 17 00:00:00 2001 From: "Russ T. Fugal" Date: Wed, 19 Aug 2026 15:41:18 -0600 Subject: [PATCH] feat(csv): export escapeCsvField, the shared field escaper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidated downstream on 2026-06-10 because three CSV producers (CsvWriter here, two exports in a consuming workspace) had drifted on the formula-trigger list — CsvWriter's private copy did not defang cells starting with a newline. Upstreamed now so the consumers can import it instead of re-growing their own copies: the downstream workspace's vendored copy imports { escapeCsvField } from "levelset" and a re-vendor without this export fails to typecheck. Behavior change in CsvWriter: a cell whose value starts with "\n" is now apostrophe-defanged as well as quoted. typecheck clean, lint exits 0, bun test 584 pass / 12 skip / 1 fail (the pre-existing XlsxWriter named-range failure). Co-Authored-By: Claude Fable 5 --- src/csv/CsvWriter.ts | 31 +++------------------------ src/csv/escapeField.ts | 48 ++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 1 + 3 files changed, 52 insertions(+), 28 deletions(-) create mode 100644 src/csv/escapeField.ts diff --git a/src/csv/CsvWriter.ts b/src/csv/CsvWriter.ts index 3ccfcfa..f8c2474 100644 --- a/src/csv/CsvWriter.ts +++ b/src/csv/CsvWriter.ts @@ -1,4 +1,5 @@ import type { ProjectFile } from "../model/Project.ts"; +import { escapeCsvField } from "./escapeField.ts"; import { formatProjectDate } from "../dateTime.ts"; export interface CsvWriterOptions { @@ -57,7 +58,7 @@ export class CsvWriter { ? project.tasks.filter((t) => t.id !== null) : project.tasks.filter((t) => t.summary === false && t.id !== null); - const lines = [headers.map(escapeField).join(",")]; + const lines = [headers.map((h) => escapeCsvField(h)).join(",")]; for (const task of tasks) { const fields: unknown[] = [ @@ -78,35 +79,9 @@ export class CsvWriter { fields.push(names ? names.join(",") : null); } - lines.push(fields.map(escapeField).join(",")); + lines.push(fields.map((f) => escapeCsvField(f)).join(",")); } return lines.join("\n") + "\n"; } } - -// Characters that spreadsheet apps (Excel, Google Sheets, LibreOffice Calc, -// Numbers) interpret as the start of a formula. Per OWASP, defang by -// prepending a single apostrophe so the cell renders as literal text. -const FORMULA_TRIGGERS = new Set(["=", "+", "-", "@", "\t", "\r"]); - -function escapeField(value: unknown): string { - if (value == null) 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 s = String(value); - if (s.length > 0 && FORMULA_TRIGGERS.has(s[0]!)) { - s = "'" + s; - } - if ( - s.includes(",") || - s.includes('"') || - s.includes("\n") || - s.includes("\r") - ) { - return `"${s.replace(/"/g, '""')}"`; - } - return s; -} diff --git a/src/csv/escapeField.ts b/src/csv/escapeField.ts new file mode 100644 index 0000000..328cd3a --- /dev/null +++ b/src/csv/escapeField.ts @@ -0,0 +1,48 @@ +/** + * 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; +} diff --git a/src/index.ts b/src/index.ts index 7d65418..918ac3b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,6 +64,7 @@ export { JsonReader } from "./json/JsonReader.ts"; export { JsonWriter } from "./json/JsonWriter.ts"; export type { JsonWriterOptions } from "./json/JsonWriter.ts"; export { CsvWriter } from "./csv/CsvWriter.ts"; +export { escapeCsvField } from "./csv/escapeField.ts"; export type { CsvWriterOptions } from "./csv/CsvWriter.ts"; export { XlsxWriter } from "./xlsx/XlsxWriter.ts"; export type { -- 2.54.0