Skip to main content

scour_secrets/processor/
csv_proc.rs

1//! CSV structured processor.
2//!
3//! The CLI uses [`process_to_edits`](CsvProcessor::process_to_edits): it drives
4//! `csv-core` (a byte-accurate field state machine) and replaces each matched
5//! column's value at its exact source span, preserving the delimiter, quoting
6//! style, line endings, and non-matched columns (`""`-escaped/quoted fields are
7//! hit as written, so they never leak). `process` re-serializes via the csv
8//! writer as a fallback.
9//!
10//! # Column Matching
11//!
12//! Field rules match by **header name**. If the first row is a header
13//! (default assumption), column names are extracted from it and matched
14//! against the profile's field rules.
15//!
16//! # Profile Options
17//!
18//! | Key          | Default | Description                            |
19//! |--------------|---------|----------------------------------------|
20//! | `delimiter`  | `","`   | Field delimiter (single ASCII char).   |
21//! | `has_header` | `"true"`| Whether the first row is a header row. |
22
23use crate::error::{Result, SanitizeError};
24use crate::processor::limits::DEFAULT_INPUT_SIZE;
25use crate::processor::{
26    edit_token, find_matching_rule, pattern_matches, replace_value, FileTypeProfile, Processor,
27    Replacement,
28};
29use crate::store::MappingStore;
30
31/// Structured processor for CSV/TSV files.
32pub struct CsvProcessor;
33
34impl Processor for CsvProcessor {
35    fn name(&self) -> &'static str {
36        "csv"
37    }
38
39    fn can_handle(&self, _content: &[u8], profile: &FileTypeProfile) -> bool {
40        profile.processor == "csv"
41    }
42
43    fn process(
44        &self,
45        content: &[u8],
46        profile: &FileTypeProfile,
47        store: &MappingStore,
48    ) -> Result<Vec<u8>> {
49        // F-04 fix: enforce input size limit.
50        if content.len() > DEFAULT_INPUT_SIZE {
51            return Err(SanitizeError::InputTooLarge {
52                size: content.len(),
53                limit: DEFAULT_INPUT_SIZE,
54            });
55        }
56
57        let delimiter = profile
58            .options
59            .get("delimiter")
60            .and_then(|s| s.as_bytes().first().copied())
61            .unwrap_or(b',');
62
63        let has_header = profile
64            .options
65            .get("has_header")
66            .is_none_or(|v| v != "false");
67
68        let mut reader = csv::ReaderBuilder::new()
69            .delimiter(delimiter)
70            .has_headers(has_header)
71            .flexible(true)
72            .from_reader(content);
73
74        let mut output = Vec::new();
75        let mut wtr = csv::WriterBuilder::new()
76            .delimiter(delimiter)
77            .from_writer(&mut output);
78
79        // Determine which column indices need replacement.
80        let column_rules: Vec<Option<usize>> = if has_header {
81            let headers = reader
82                .headers()
83                .map_err(|e| SanitizeError::ParseError {
84                    format: "CSV".into(),
85                    message: format!("CSV header error: {}", e),
86                })?
87                .clone();
88
89            // Write header row.
90            wtr.write_record(headers.iter()).map_err(|e| {
91                SanitizeError::IoError(std::io::Error::other(format!("CSV write error: {e}")))
92            })?;
93
94            // Map each column index to the index of its first matching rule (if any).
95            // Uses pattern_matches directly to avoid allocating a temporary
96            // FileTypeProfile for every (header, rule) pair.
97            headers
98                .iter()
99                .map(|h| {
100                    profile
101                        .fields
102                        .iter()
103                        .position(|r| pattern_matches(&r.pattern, h))
104                })
105                .collect()
106        } else {
107            Vec::new()
108        };
109
110        for result in reader.records() {
111            let record = result.map_err(|e| SanitizeError::ParseError {
112                format: "CSV".into(),
113                message: format!("CSV read error: {}", e),
114            })?;
115
116            let mut row: Vec<String> = Vec::with_capacity(record.len());
117            for (idx, field) in record.iter().enumerate() {
118                if has_header {
119                    if let Some(Some(rule_idx)) = column_rules.get(idx) {
120                        let rule = &profile.fields[*rule_idx];
121                        let replaced = replace_value(field, rule, store)?;
122                        row.push(replaced);
123                    } else {
124                        row.push(field.to_string());
125                    }
126                } else {
127                    // Without headers, match by column index as string.
128                    let col_key = idx.to_string();
129                    if let Some(rule) = find_matching_rule(&col_key, profile) {
130                        let replaced = replace_value(field, rule, store)?;
131                        row.push(replaced);
132                    } else {
133                        row.push(field.to_string());
134                    }
135                }
136            }
137
138            wtr.write_record(&row).map_err(|e| {
139                SanitizeError::IoError(std::io::Error::other(format!("CSV write error: {e}")))
140            })?;
141        }
142
143        wtr.flush().map_err(|e| {
144            SanitizeError::IoError(std::io::Error::other(format!("CSV flush error: {e}")))
145        })?;
146        drop(wtr);
147
148        Ok(output)
149    }
150
151    /// Span-based redaction: drive `csv-core` (the byte-accurate field state
152    /// machine) over the source, recording an edit at each matched field's exact
153    /// source span. The delimiter, quoting style, line endings, and non-matched
154    /// fields are preserved, and a quoted/`""`-escaped field is hit as written so
155    /// no value leaks.
156    fn process_to_edits(
157        &self,
158        content: &[u8],
159        profile: &FileTypeProfile,
160        store: &MappingStore,
161    ) -> Result<Option<Vec<Replacement>>> {
162        if content.len() > DEFAULT_INPUT_SIZE {
163            return Err(SanitizeError::InputTooLarge {
164                size: content.len(),
165                limit: DEFAULT_INPUT_SIZE,
166            });
167        }
168        let delimiter = profile
169            .options
170            .get("delimiter")
171            .and_then(|s| s.as_bytes().first().copied())
172            .unwrap_or(b',');
173        let has_header = profile
174            .options
175            .get("has_header")
176            .is_none_or(|v| v != "false");
177
178        let mut rdr = csv_core::ReaderBuilder::new().delimiter(delimiter).build();
179        let mut edits = Vec::new();
180        let mut out_chunk = vec![0u8; 4096];
181        let mut value_buf: Vec<u8> = Vec::new();
182        let mut pos = 0usize;
183        let mut field_start = 0usize;
184        let mut record_idx = 0usize;
185        let mut col_idx = 0usize;
186        let mut headers: Vec<String> = Vec::new();
187        let mut flushed = false;
188
189        loop {
190            let (result, n_in, n_out) = rdr.read_field(&content[pos..], &mut out_chunk);
191            value_buf.extend_from_slice(&out_chunk[..n_out]);
192            pos += n_in;
193
194            match result {
195                csv_core::ReadFieldResult::InputEmpty => {
196                    // Input exhausted. csv_core only flushes a final
197                    // *unterminated* field (a file with no trailing newline)
198                    // and reports `End` when read_field is called once more
199                    // with an empty buffer. Loop back to make that EOF call
200                    // instead of breaking here; otherwise the last field would
201                    // be dropped — and, if it matched a rule, leak.
202                    if pos >= content.len() {
203                        if flushed {
204                            break;
205                        }
206                        flushed = true;
207                    }
208                }
209                csv_core::ReadFieldResult::OutputFull => {} // field continues; keep accumulating
210                csv_core::ReadFieldResult::End => break,
211                csv_core::ReadFieldResult::Field { record_end } => {
212                    let term_len = field_terminator_len(&content[field_start..pos], record_end);
213                    let value_end = pos - term_len;
214                    let value = String::from_utf8_lossy(&value_buf).into_owned();
215
216                    if has_header && record_idx == 0 {
217                        headers.push(value);
218                    } else {
219                        let col_key;
220                        let key: &str = if has_header {
221                            headers.get(col_idx).map_or("", String::as_str)
222                        } else {
223                            col_key = col_idx.to_string();
224                            &col_key
225                        };
226                        if !key.is_empty() {
227                            if let Some(token) = edit_token(key, key, &value, profile, store)? {
228                                edits.push(Replacement {
229                                    start: field_start,
230                                    end: value_end,
231                                    value: csv_escape_token(&token),
232                                });
233                            }
234                        }
235                    }
236
237                    value_buf.clear();
238                    col_idx += 1;
239                    field_start = pos;
240                    if record_end {
241                        record_idx += 1;
242                        col_idx = 0;
243                    }
244                }
245            }
246        }
247        Ok(Some(edits))
248    }
249}
250
251/// Length of the field terminator at the end of `raw` (the bytes consumed for a
252/// field, including its trailing separator): the delimiter (1) for a mid-record
253/// field, or the record terminator (`\r\n` → 2, `\n`/`\r` → 1, EOF → 0).
254fn field_terminator_len(raw: &[u8], record_end: bool) -> usize {
255    if !record_end {
256        return 1; // delimiter
257    }
258    if raw.ends_with(b"\r\n") {
259        2
260    } else {
261        usize::from(raw.ends_with(b"\n") || raw.ends_with(b"\r"))
262    }
263}
264
265/// CSV-quote a token if it contains a character that would need quoting. Tokens
266/// are safe ASCII in practice, so this is a defensive no-op in the common case.
267fn csv_escape_token(token: &str) -> String {
268    if token.contains([',', '"', '\n', '\r']) {
269        format!("\"{}\"", token.replace('"', "\"\""))
270    } else {
271        token.to_string()
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::category::Category;
279    use crate::generator::HmacGenerator;
280    use crate::processor::profile::FieldRule;
281    use std::sync::Arc;
282
283    fn make_store() -> MappingStore {
284        let gen = Arc::new(HmacGenerator::new([42u8; 32]));
285        MappingStore::new(gen, None)
286    }
287
288    #[test]
289    fn basic_csv_replacement() {
290        let store = make_store();
291        let proc = CsvProcessor;
292
293        let content =
294            b"name,email,department\nAlice,alice@corp.com,Engineering\nBob,bob@corp.com,Sales\n";
295        let profile = FileTypeProfile::new(
296            "csv",
297            vec![
298                FieldRule::new("name").with_category(Category::Name),
299                FieldRule::new("email").with_category(Category::Email),
300            ],
301        );
302
303        let result = proc.process(content, &profile, &store).unwrap();
304        let out = String::from_utf8(result).unwrap();
305
306        assert!(!out.contains("Alice"));
307        assert!(!out.contains("alice@corp.com"));
308        assert!(!out.contains("Bob"));
309        assert!(!out.contains("bob@corp.com"));
310        // Department column preserved.
311        assert!(out.contains("Engineering"));
312        assert!(out.contains("Sales"));
313        // Header preserved.
314        assert!(out.starts_with("name,email,department"));
315    }
316
317    #[test]
318    fn can_handle_requires_csv_profile() {
319        let proc = CsvProcessor;
320        let yes = FileTypeProfile::new("csv", vec![]).with_extension(".csv");
321        let no = FileTypeProfile::new("json", vec![]).with_extension(".json");
322        assert!(proc.can_handle(b"a,b,c\n1,2,3\n", &yes));
323        assert!(!proc.can_handle(b"a,b,c\n1,2,3\n", &no));
324    }
325
326    #[test]
327    fn tsv_delimiter() {
328        let store = make_store();
329        let proc = CsvProcessor;
330        let content = b"name\temail\nAlice\talice@corp.com\n";
331        let mut profile = FileTypeProfile::new(
332            "csv",
333            vec![FieldRule::new("email").with_category(Category::Email)],
334        );
335        profile.options.insert("delimiter".into(), "\t".into());
336
337        let result = proc.process(content, &profile, &store).unwrap();
338        let out = String::from_utf8(result).unwrap();
339        assert!(!out.contains("alice@corp.com"));
340        assert!(out.contains("Alice"));
341    }
342
343    #[test]
344    fn no_header_mode_matches_by_column_index() {
345        let store = make_store();
346        let proc = CsvProcessor;
347        // Column 1 (0-indexed) should be replaced.
348        let content = b"Alice,alice@corp.com,Engineering\n";
349        let mut profile = FileTypeProfile::new(
350            "csv",
351            vec![FieldRule::new("1").with_category(Category::Email)],
352        );
353        profile.options.insert("has_header".into(), "false".into());
354
355        let result = proc.process(content, &profile, &store).unwrap();
356        let out = String::from_utf8(result).unwrap();
357        assert!(!out.contains("alice@corp.com"));
358        assert!(out.contains("Alice"));
359        assert!(out.contains("Engineering"));
360    }
361
362    #[test]
363    fn header_only_no_data_rows() {
364        let store = make_store();
365        let proc = CsvProcessor;
366        let content = b"name,email,department\n";
367        let profile = FileTypeProfile::new(
368            "csv",
369            vec![FieldRule::new("email").with_category(Category::Email)],
370        );
371        let result = proc.process(content, &profile, &store).unwrap();
372        let out = String::from_utf8(result).unwrap();
373        assert!(out.contains("name,email,department"));
374    }
375
376    #[test]
377    fn empty_field_passes_through() {
378        let store = make_store();
379        let proc = CsvProcessor;
380        let content = b"email\n\nalice@corp.com\n";
381        let profile = FileTypeProfile::new(
382            "csv",
383            vec![FieldRule::new("email").with_category(Category::Email)],
384        );
385        let result = proc.process(content, &profile, &store).unwrap();
386        let out = String::from_utf8(result).unwrap();
387        assert!(!out.contains("alice@corp.com"));
388        // Non-secret structure preserved: header and the empty field/row.
389        assert!(out.starts_with("email"));
390    }
391
392    #[test]
393    fn unmatched_columns_pass_through_unchanged() {
394        let store = make_store();
395        let proc = CsvProcessor;
396        let content = b"id,email\n42,alice@corp.com\n";
397        let profile = FileTypeProfile::new(
398            "csv",
399            vec![FieldRule::new("email").with_category(Category::Email)],
400        );
401        let result = proc.process(content, &profile, &store).unwrap();
402        let out = String::from_utf8(result).unwrap();
403        assert!(out.contains("42"), "id column must be preserved");
404        assert!(!out.contains("alice@corp.com"));
405    }
406
407    #[test]
408    fn csv_deterministic_replacement() {
409        let store = make_store();
410        let proc = CsvProcessor;
411
412        let content = b"email\ntest@x.com\ntest@x.com\n";
413        let profile = FileTypeProfile::new(
414            "csv",
415            vec![FieldRule::new("email").with_category(Category::Email)],
416        );
417
418        let result = proc.process(content, &profile, &store).unwrap();
419        let out = String::from_utf8(result).unwrap();
420        let lines: Vec<&str> = out.lines().collect();
421
422        // Same input → same replacement.
423        assert_eq!(lines[1], lines[2]);
424        assert_ne!(lines[1], "test@x.com");
425    }
426
427    /// Edit-mode redacts matched columns at their exact source span, including
428    /// quoted fields with embedded commas and `""`-escaped quotes, leaving the
429    /// header and non-matched columns intact.
430    #[test]
431    fn edits_redact_quoted_and_escaped_fields() {
432        let store = make_store();
433        let proc = CsvProcessor;
434        let content =
435            b"name,email,note\nAlice,a-SEC1@e.test,\"has,comma-SEC2\"\nBob,\"b\"\"q-SEC3@e.test\",x\n";
436        let profile = FileTypeProfile::new(
437            "csv",
438            vec![
439                FieldRule::new("email").with_category(Category::Email),
440                FieldRule::new("note").with_category(Category::Custom("n".into())),
441            ],
442        );
443        let edits = proc
444            .process_to_edits(content, &profile, &store)
445            .unwrap()
446            .unwrap();
447        let out = crate::processor::apply_edits(content, edits);
448        let text = String::from_utf8(out).unwrap();
449        for leak in ["SEC1", "SEC2", "SEC3"] {
450            assert!(!text.contains(leak), "leaked {leak}: {text}");
451        }
452        // Header and the non-matched `name` column are untouched.
453        assert!(
454            text.starts_with("name,email,note\n"),
455            "header changed: {text}"
456        );
457        assert!(text.contains("Alice,"), "name column changed: {text}");
458        assert!(text.contains("Bob,"), "name column changed: {text}");
459    }
460
461    /// Regression: a CSV with no trailing newline must still redact the final
462    /// field. csv_core reports the last unterminated field only on an EOF flush
463    /// call; an earlier version broke on `InputEmpty` first and leaked it.
464    #[test]
465    fn edits_redact_last_field_without_trailing_newline() {
466        let store = make_store();
467        let proc = CsvProcessor;
468        let content = b"name,email\nAlice,leak-SEC9@e.test";
469        let profile = FileTypeProfile::new(
470            "csv",
471            vec![FieldRule::new("email").with_category(Category::Email)],
472        );
473        let edits = proc
474            .process_to_edits(content, &profile, &store)
475            .unwrap()
476            .unwrap();
477        let out = crate::processor::apply_edits(content, edits);
478        let text = String::from_utf8(out).unwrap();
479        assert!(!text.contains("SEC9"), "leaked last field: {text}");
480        assert!(
481            text.starts_with("name,email\nAlice,"),
482            "format broken: {text}"
483        );
484    }
485}