1use 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
31pub 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 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 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 wtr.write_record(headers.iter()).map_err(|e| {
91 SanitizeError::IoError(std::io::Error::other(format!("CSV write error: {e}")))
92 })?;
93
94 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 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 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 if pos >= content.len() {
203 if flushed {
204 break;
205 }
206 flushed = true;
207 }
208 }
209 csv_core::ReadFieldResult::OutputFull => {} 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
251fn field_terminator_len(raw: &[u8], record_end: bool) -> usize {
255 if !record_end {
256 return 1; }
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
265fn 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 assert!(out.contains("Engineering"));
312 assert!(out.contains("Sales"));
313 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 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 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 assert_eq!(lines[1], lines[2]);
424 assert_ne!(lines[1], "test@x.com");
425 }
426
427 #[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 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 #[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}