ifc_lite_export/csv_cell.rs
1// SPDX-License-Identifier: MPL-2.0
2//! THE CSV cell escaper for this repository's Rust.
3//!
4//! Every Rust producer of CSV calls [`escape_csv_cell`] and nothing else. The
5//! TypeScript half lives in `packages/export/src/csv-cell.ts`; both are pinned
6//! to the shared vectors in `tests/fixtures/csv_cell_vectors.json` by
7//! `tests/csv_cell_parity.rs` and `csv-cell.parity.test.ts`, so the two
8//! languages cannot drift apart silently.
9//!
10//! Two things it does that the previous hand-rolled copies did not:
11//!
12//! * The formula-injection guard (CWE-1236) looks *past* leading invisibles.
13//! Testing the trigger anchored at offset 0 — as this crate did — means a
14//! BOM, ZWSP, LRM, NBSP or U+2028 in front of `=` walks straight through,
15//! because none of those stops a spreadsheet evaluating the cell.
16//! * It looks past them without DELETING them, so a benign `" Wall A"` keeps
17//! its spaces (RFC 4180 §2.4: "Spaces are considered part of a field and
18//! should not be ignored").
19
20/// Code points a spreadsheet importer swallows, or renders as pure spacing, yet
21/// which do NOT stop a following `=`/`+`/`-`/`@` being evaluated as a formula.
22///
23/// Exactly Unicode general categories `Cf` (format) + `Z` (separator: `Zs`,
24/// `Zl`, `Zp`). Spelled as a const table because this crate compiles to wasm
25/// and deliberately carries no Unicode-property dependency; the parity sweep in
26/// `tests/csv_cell_parity.rs` walks all 0x110000 code points against the same
27/// table the TypeScript side is checked against, so the hand-maintained form
28/// cannot quietly fall behind.
29///
30/// Regenerate with `node scripts/gen-csv-cell-vectors.mjs` (which rewrites the
31/// shared fixture) and mirror the result here.
32const INVISIBLE_PREFIX_RANGES: &[(u32, u32)] = &[
33 (0x0020, 0x0020), // SPACE
34 (0x00A0, 0x00A0), // NO-BREAK SPACE
35 (0x00AD, 0x00AD), // SOFT HYPHEN
36 (0x0600, 0x0605), // Arabic number signs
37 (0x061C, 0x061C), // ARABIC LETTER MARK
38 (0x06DD, 0x06DD), // ARABIC END OF AYAH
39 (0x070F, 0x070F), // SYRIAC ABBREVIATION MARK
40 (0x0890, 0x0891), // Arabic pound/piastre marks
41 (0x08E2, 0x08E2), // ARABIC DISPUTED END OF AYAH
42 (0x1680, 0x1680), // OGHAM SPACE MARK
43 (0x180E, 0x180E), // MONGOLIAN VOWEL SEPARATOR
44 (0x2000, 0x200F), // EN QUAD .. RIGHT-TO-LEFT MARK (incl. ZWSP/ZWNJ/ZWJ)
45 (0x2028, 0x202F), // LINE/PARAGRAPH SEPARATOR, bidi embedding, NNBSP
46 (0x205F, 0x2064), // MEDIUM MATHEMATICAL SPACE .. INVISIBLE PLUS
47 (0x2066, 0x206F), // bidi isolates and deprecated format controls
48 (0x3000, 0x3000), // IDEOGRAPHIC SPACE
49 (0xFEFF, 0xFEFF), // ZERO WIDTH NO-BREAK SPACE (the BOM)
50 (0xFFF9, 0xFFFB), // interlinear annotation controls
51 (0x110BD, 0x110BD), // KAITHI NUMBER SIGN
52 (0x110CD, 0x110CD), // KAITHI NUMBER SIGN ABOVE
53 (0x13430, 0x1343F), // Egyptian hieroglyph format controls
54 (0x1BCA0, 0x1BCA3), // shorthand format controls
55 (0x1D173, 0x1D17A), // musical symbol beam/phrase/tie controls
56 (0xE0001, 0xE0001), // LANGUAGE TAG
57 (0xE0020, 0xE007F), // tag characters
58];
59
60/// Characters a spreadsheet reads as the start of a formula. `\t` and `\r` are
61/// in the set because Excel/Sheets strip them and evaluate what follows.
62const TRIGGERS: [char; 6] = ['=', '+', '-', '@', '\t', '\r'];
63
64/// Whitespace for the opt-in [`CsvCellOptions::quote_whitespace_padded`] rule.
65///
66/// Deliberately NOT `char::is_whitespace`: that is Unicode `White_Space`, which
67/// includes U+0085 NEL and excludes U+FEFF, while JavaScript's `\s` — which the
68/// TypeScript half uses — is the exact reverse. Left implicit, that asymmetry
69/// would be a silent cross-language behaviour split on two code points; spelled
70/// out here, `tests/csv_cell_parity.rs` sweeps it against the shared table.
71#[must_use]
72pub fn is_padding(c: char) -> bool {
73 (c.is_whitespace() && c != '\u{0085}') || c == '\u{FEFF}'
74}
75
76/// Is `c` an invisible that can hide a formula trigger behind it?
77///
78/// Public for the parity sweep, which is the only thing that keeps the table
79/// above honest.
80#[must_use]
81pub fn is_invisible_prefix(c: char) -> bool {
82 let cp = c as u32;
83 INVISIBLE_PREFIX_RANGES
84 .binary_search_by(|&(lo, hi)| {
85 if cp < lo {
86 std::cmp::Ordering::Greater
87 } else if cp > hi {
88 std::cmp::Ordering::Less
89 } else {
90 std::cmp::Ordering::Equal
91 }
92 })
93 .is_ok()
94}
95
96/// Options for [`escape_csv_cell`].
97pub struct CsvCellOptions<'a> {
98 /// Column delimiter the cell will be joined with.
99 pub delimiter: &'a str,
100 /// Exempt a cell that is wholly a signed number (`-0.35`, `+1`, `-1.5e-3`)
101 /// from the `+`/`-` trigger, so spreadsheet `SUM()` still works on exported
102 /// measures (#1772).
103 ///
104 /// A PRODUCT policy, not a security one. The exemption cannot weaken the
105 /// guard: the accepted language is built from `+ - . e E 0-9` and nothing
106 /// else, which cannot spell a function name, a cell reference or a `(`, so
107 /// every string it exempts is inert in a spreadsheet. `=`, `@`, TAB and CR
108 /// are never exempted.
109 ///
110 /// **Defaults to `true`**, in lockstep with the TypeScript half. The two
111 /// defaults are pinned together by a shared vector that passes NO options
112 /// (see `csv_cell_parity.rs`), because a harness that always sets every
113 /// field explicitly cannot see the defaults drift apart.
114 pub exempt_numbers: bool,
115 /// Also quote a cell whose first or last character is whitespace, so an
116 /// importer that would otherwise trim the padding cannot (RFC 4180 §2.4
117 /// from the other side). See [`is_padding`] for the exact class.
118 ///
119 /// No Rust caller sets this today; it exists so the TypeScript half's
120 /// zones-table rule is expressible in the SAME escaper rather than being a
121 /// reason to hand-roll one. Defaults to `false`.
122 pub quote_whitespace_padded: bool,
123}
124
125impl Default for CsvCellOptions<'_> {
126 fn default() -> Self {
127 Self { delimiter: ",", exempt_numbers: true, quote_whitespace_padded: false }
128 }
129}
130
131/// Does the cell begin — past any run of invisibles — with a formula trigger?
132fn starts_with_trigger(s: &str) -> bool {
133 s.chars()
134 .find(|&c| !is_invisible_prefix(c))
135 .is_some_and(|c| TRIGGERS.contains(&c))
136}
137
138/// Is the cell WHOLLY a signed decimal number, optionally in exponent form?
139///
140/// Mirrors the TypeScript `^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$`, ASCII
141/// digits only. Anchored at both ends on purpose: `-0.35=cmd` is not a number
142/// and must stay guarded.
143fn is_wholly_numeric(s: &str) -> bool {
144 let bytes = s.as_bytes();
145 let mut i = 0;
146 if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
147 i += 1;
148 }
149
150 // Mantissa: `\d+\.?\d*` or `\.\d+`.
151 let int_start = i;
152 while i < bytes.len() && bytes[i].is_ascii_digit() {
153 i += 1;
154 }
155 let int_digits = i - int_start;
156 if int_digits > 0 {
157 if i < bytes.len() && bytes[i] == b'.' {
158 i += 1;
159 while i < bytes.len() && bytes[i].is_ascii_digit() {
160 i += 1;
161 }
162 }
163 } else {
164 // No integer part: require `.` followed by at least one digit.
165 if i >= bytes.len() || bytes[i] != b'.' {
166 return false;
167 }
168 i += 1;
169 let frac_start = i;
170 while i < bytes.len() && bytes[i].is_ascii_digit() {
171 i += 1;
172 }
173 if i == frac_start {
174 return false;
175 }
176 }
177
178 // Optional exponent: `[eE][+-]?\d+`.
179 if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
180 i += 1;
181 if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
182 i += 1;
183 }
184 let exp_start = i;
185 while i < bytes.len() && bytes[i].is_ascii_digit() {
186 i += 1;
187 }
188 if i == exp_start {
189 return false;
190 }
191 }
192
193 i == bytes.len()
194}
195
196/// Escape one CSV cell: neutralise spreadsheet formula injection (CWE-1236),
197/// then apply RFC 4180 quoting.
198///
199/// The guard is non-destructive — it looks past leading invisibles rather than
200/// stripping them — and runs BEFORE quoting, so a value that both starts with a
201/// trigger and contains the delimiter still gets wrapped.
202#[must_use]
203pub fn escape_csv_cell(value: &str, opts: &CsvCellOptions) -> String {
204 let guarded = starts_with_trigger(value) && !(opts.exempt_numbers && is_wholly_numeric(value));
205
206 let mut s = if guarded {
207 let mut out = String::with_capacity(value.len() + 1);
208 out.push('\'');
209 out.push_str(value);
210 out
211 } else {
212 value.to_string()
213 };
214
215 let padded = opts.quote_whitespace_padded
216 && (s.chars().next().is_some_and(is_padding) || s.chars().next_back().is_some_and(is_padding));
217
218 if padded || s.contains(opts.delimiter) || s.contains('"') || s.contains('\n') || s.contains('\r') {
219 s = format!("\"{}\"", s.replace('"', "\"\""));
220 }
221 s
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn range_table_is_sorted_and_disjoint() {
230 // `is_invisible_prefix` binary-searches the table, so an unsorted or
231 // overlapping entry would silently return the wrong answer for some
232 // code points rather than failing outright.
233 for w in INVISIBLE_PREFIX_RANGES.windows(2) {
234 assert!(w[0].0 <= w[0].1, "range {:?} is inverted", w[0]);
235 assert!(w[1].0 > w[0].1, "ranges {:?} and {:?} overlap or abut", w[0], w[1]);
236 }
237 }
238
239 #[test]
240 fn wholly_numeric_rejects_payloads_glued_to_a_number() {
241 assert!(is_wholly_numeric("-0.35"));
242 assert!(is_wholly_numeric("+1"));
243 assert!(is_wholly_numeric(".5"));
244 assert!(is_wholly_numeric("1."));
245 assert!(is_wholly_numeric("-1.5e-3"));
246 assert!(!is_wholly_numeric("-0.35=cmd"));
247 assert!(!is_wholly_numeric("-"));
248 assert!(!is_wholly_numeric(""));
249 assert!(!is_wholly_numeric("1e"));
250 assert!(!is_wholly_numeric("."));
251 assert!(!is_wholly_numeric("1 "));
252 }
253}