From 391c3bcb170da0f7d9904dc82a5e70502d3a1684 Mon Sep 17 00:00:00 2001 From: Pavan Kumar Gondhi Date: Mon, 20 Jul 2026 14:08:33 +0530 Subject: [PATCH] fix(ui): neutralize Usage CSV text cells [AI] (#111653) * fix(ui): neutralize usage csv text cells * test(ui): align usage csv fixture types * fix(ui): preserve benign usage csv whitespace --- ui/src/pages/usage/query.test.ts | 88 ++++++++++++++++++++++++++++++++ ui/src/pages/usage/query.ts | 15 ++++-- 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/ui/src/pages/usage/query.test.ts b/ui/src/pages/usage/query.test.ts index 650dd7d64c7..1194d0b15df 100644 --- a/ui/src/pages/usage/query.test.ts +++ b/ui/src/pages/usage/query.test.ts @@ -16,4 +16,92 @@ describe("usage query CSV export", () => { expect(csv).toContain("session-1,Session 1,,,,,,,,,,,,,,,"); }); + + it.each([ + ["equals", "=1+1", "'=1+1"], + ["plus", "+1+1", "'+1+1"], + ["minus", "-1+1", "'-1+1"], + ["at", "@SUM(A1:A2)", "'@SUM(A1:A2)"], + ["leading whitespace", " \t=1+1", "' \t=1+1"], + ["fullwidth equals", "\uFF1D1+1", "'\uFF1D1+1"], + ["fullwidth plus", "\uFF0B1+1", "'\uFF0B1+1"], + ["fullwidth minus", "\uFF0D1+1", "'\uFF0D1+1"], + ["fullwidth at", "\uFF20SUM(A1:A2)", "'\uFF20SUM(A1:A2)"], + ])("neutralizes spreadsheet formula labels with %s prefix", (_name, label, expected) => { + const csv = buildSessionsCsv([ + { + key: "session-1", + label, + updatedAt: 0, + usage: null, + } satisfies UsageSessionEntry, + ]); + + expect(csv).toContain(`session-1,${expected},`); + }); + + it("quotes carriage returns in formula-neutralized labels", () => { + const csv = buildSessionsCsv([ + { + key: "session-1", + label: "\r=1+1", + updatedAt: 0, + usage: null, + } satisfies UsageSessionEntry, + ]); + + expect(csv).toContain('session-1,"\'\r=1+1",'); + }); + + it.each([ + ["tab", "\tplain", "\tplain"], + ["carriage return", "\rplain", '"\rplain"'], + ["newline", "\nplain", '"\nplain"'], + ])("preserves benign labels with leading %s", (_name, label, expected) => { + const csv = buildSessionsCsv([ + { + key: "session-1", + label, + updatedAt: 0, + usage: null, + } satisfies UsageSessionEntry, + ]); + + expect(csv).toContain(`session-1,${expected},`); + }); + + it("keeps numeric cells numeric while neutralizing string labels", () => { + const csv = buildSessionsCsv([ + { + key: "session-1", + label: "-remote-label", + updatedAt: 0, + usage: { + durationMs: -1, + messageCounts: { + total: -2, + user: -3, + assistant: -4, + toolCalls: -5, + toolResults: -6, + errors: -7, + }, + input: -5, + output: -6, + cacheRead: -7, + cacheWrite: -8, + totalTokens: -9, + totalCost: -10, + inputCost: -11, + outputCost: -12, + cacheReadCost: -13, + cacheWriteCost: -14, + missingCostEntries: -15, + }, + } satisfies UsageSessionEntry, + ]); + + expect(csv).toContain("session-1,'-remote-label,,,"); + expect(csv).toContain(",-1,-2,-7,-5,-5,-6,-7,-8,-9,-10"); + }); }); diff --git a/ui/src/pages/usage/query.ts b/ui/src/pages/usage/query.ts index 681b5d3edda..e5f5cdac55a 100644 --- a/ui/src/pages/usage/query.ts +++ b/ui/src/pages/usage/query.ts @@ -14,11 +14,16 @@ function downloadTextFile(filename: string, content: string, type = "text/plain" URL.revokeObjectURL(url); } -function csvEscape(value: string): string { - if (/[",\n]/.test(value)) { - return `"${value.replaceAll('"', '""')}"`; +function neutralizeSpreadsheetFormulaCell(value: string): string { + return /^[ \t\r\n]*[=+\-@\uFF0B\uFF0D\uFF1D\uFF20]/u.test(value) ? `'${value}` : value; +} + +function csvEscape(value: string, neutralizeFormulas = true): string { + const safeValue = neutralizeFormulas ? neutralizeSpreadsheetFormulaCell(value) : value; + if (/[",\r\n]/.test(safeValue)) { + return `"${safeValue.replaceAll('"', '""')}"`; } - return value; + return safeValue; } function toCsvRow(values: Array): string { @@ -27,7 +32,7 @@ function toCsvRow(values: Array): string { if (value === undefined || value === null) { return ""; } - return csvEscape(String(value)); + return csvEscape(String(value), typeof value === "string"); }) .join(","); }