Skip to main content

cratefield_core/
csv.rs

1//! CSV cell escaping for admin exports (architecture section 11, issue
2//! #13 — landed with issue #10 because the first export endpoint needs
3//! it).
4//!
5//! Spreadsheet formula injection: a cell whose first character is one of
6//! `= + - @ \t \r` is prefixed with `'` before quoting so Excel, Numbers
7//! and Google Sheets render it as text instead of evaluating it.
8
9/// Characters that make a cell a formula when it starts with one of them.
10pub const FORMULA_PREFIXES: [char; 6] = ['=', '+', '-', '@', '\t', '\r'];
11
12/// Escapes one CSV field: formula-guard, then RFC 4180 quoting.
13#[must_use]
14pub fn escape(field: &str) -> String {
15    let guarded = if field.starts_with(FORMULA_PREFIXES) {
16        format!("'{field}")
17    } else {
18        field.to_owned()
19    };
20    if guarded.contains([',', '"', '\n', '\r']) {
21        format!("\"{}\"", guarded.replace('"', "\"\""))
22    } else {
23        guarded
24    }
25}
26
27/// Escapes and joins one CSV row, with a trailing newline.
28#[must_use]
29pub fn row(fields: &[&str]) -> String {
30    let mut line = fields
31        .iter()
32        .map(|field| escape(field))
33        .collect::<Vec<_>>()
34        .join(",");
35    line.push('\n');
36    line
37}
38
39#[cfg(test)]
40mod tests {
41    use super::*;
42
43    #[test]
44    fn plain_fields_pass_through() {
45        assert_eq!(escape("nick@example.com"), "nick@example.com");
46        assert_eq!(escape("pending"), "pending");
47    }
48
49    #[test]
50    fn formula_leading_cells_are_prefixed() {
51        for evil in ["=cmd|' /C", "+1", "-1", "@SUM(A1)", "\ttab", "\rCR"] {
52            let escaped = escape(evil);
53            // Strip RFC 4180 quoting, then the first char must be the guard.
54            let unquoted = escaped.trim_matches('"');
55            assert!(unquoted.starts_with('\''), "{evil:?} -> {escaped:?}");
56            assert!(!unquoted.starts_with(evil.chars().next().unwrap()));
57        }
58    }
59
60    #[test]
61    fn quoting_doubles_embedded_quotes() {
62        assert_eq!(escape("a\"b"), "\"a\"\"b\"");
63        assert_eq!(escape("a,b"), "\"a,b\"");
64    }
65
66    #[test]
67    fn formula_guard_applies_before_quoting() {
68        // `=a,b` -> `'` prefix -> `'=a,b` contains a comma -> quoted.
69        assert_eq!(escape("=a,b"), "\"'=a,b\"");
70    }
71
72    #[test]
73    fn row_joins_and_terminates() {
74        assert_eq!(row(&["a", "=b", "c"]), "a,'=b,c\n");
75    }
76}