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}