Skip to main content

cloakrs_adapters/
lib.rs

1//! Format adapters for scanning files and streams.
2//!
3//! JSON, CSV, plaintext, log stream, and SQL dump adapters live here.
4
5pub mod csv;
6pub mod json;
7pub mod logstream;
8pub mod plaintext;
9pub mod sql;
10
11pub use csv::{mask_csv_reader, scan_csv_str, CsvCellScanResult, CsvScanOptions, CsvScanResult};
12pub use json::{
13    scan_json_str, scan_json_value, JsonScanOptions, JsonScanResult, JsonStringScanResult,
14};
15pub use logstream::{
16    mask_log_reader, scan_log_str, LogLineFormat, LogLineScanResult, LogStreamScanResult,
17};
18pub use plaintext::{scan_lines, scan_text, LineScanResult};
19pub use sql::{mask_sql_reader, scan_sql_str, SqlScanResult, SqlValueScanResult};
20
21use cloakrs_core::{PiiEntity, Result, Scanner};
22use serde::{Deserialize, Serialize};
23
24/// Supported adapter kinds for common report output.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26pub enum AdapterKind {
27    /// Plain text adapter.
28    Plaintext,
29    /// JSON adapter.
30    Json,
31    /// CSV adapter.
32    Csv,
33    /// Log stream adapter.
34    LogStream,
35    /// SQL dump adapter.
36    Sql,
37}
38
39/// A grouped finding location exposed through the common adapter report.
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct AdapterFinding {
42    /// Human-readable location such as `line:1`, `$.user.email`, or `row:1,column:2`.
43    pub location: String,
44    /// Findings detected at this location.
45    pub findings: Vec<PiiEntity>,
46    /// Masked value for this location, when available.
47    pub masked_value: Option<String>,
48}
49
50/// Common report shape returned by simple adapter wrappers.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52pub struct AdapterReport {
53    /// Adapter that produced this report.
54    pub kind: AdapterKind,
55    /// Findings grouped by adapter-specific location.
56    pub findings: Vec<AdapterFinding>,
57    /// Masked output for the whole input.
58    pub masked_output: String,
59}
60
61/// A simple string-to-string adapter interface for library and CLI callers.
62///
63/// # Examples
64///
65/// ```
66/// use cloakrs_adapters::{Adapter, PlaintextAdapter};
67/// use cloakrs_core::{Confidence, EntityType, Locale, PiiEntity, Recognizer, Scanner, Span};
68///
69/// struct Email;
70/// impl Recognizer for Email {
71///     fn id(&self) -> &str { "email_test" }
72///     fn entity_type(&self) -> EntityType { EntityType::Email }
73///     fn supported_locales(&self) -> &[Locale] { &[] }
74///     fn scan(&self, text: &str) -> Vec<PiiEntity> {
75///         text.find('@').map(|_| PiiEntity {
76///             entity_type: EntityType::Email,
77///             span: Span::new(0, text.len()),
78///             text: text.to_string(),
79///             confidence: Confidence::new(0.9).unwrap(),
80///             recognizer_id: self.id().to_string(),
81///         }).into_iter().collect()
82///     }
83/// }
84///
85/// let scanner = Scanner::builder().recognizer(Email).build().unwrap();
86/// let report = PlaintextAdapter.scan_str("a@test", &scanner).unwrap();
87/// assert_eq!(report.findings.len(), 1);
88/// ```
89pub trait Adapter {
90    /// Scans input text and returns a common report.
91    fn scan_str(&self, input: &str, scanner: &Scanner) -> Result<AdapterReport>;
92}
93
94/// Plaintext adapter wrapper.
95#[derive(Debug, Default, Clone, Copy)]
96pub struct PlaintextAdapter;
97
98/// JSON adapter wrapper.
99#[derive(Debug, Default, Clone)]
100pub struct JsonAdapter {
101    /// JSON path scanning options.
102    pub options: JsonScanOptions,
103}
104
105/// CSV adapter wrapper.
106#[derive(Debug, Default, Clone)]
107pub struct CsvAdapter {
108    /// CSV scanning options.
109    pub options: CsvScanOptions,
110}
111
112/// Log stream adapter wrapper.
113#[derive(Debug, Default, Clone, Copy)]
114pub struct LogStreamAdapter;
115
116/// SQL dump adapter wrapper.
117#[derive(Debug, Default, Clone, Copy)]
118pub struct SqlAdapter;
119
120impl Adapter for PlaintextAdapter {
121    fn scan_str(&self, input: &str, scanner: &Scanner) -> Result<AdapterReport> {
122        let lines = scan_text(input, scanner)?;
123        let findings = lines
124            .iter()
125            .filter(|line| !line.findings.is_empty())
126            .map(|line| AdapterFinding {
127                location: format!("line:{}", line.line_number),
128                findings: line.findings.clone(),
129                masked_value: line.masked_line.clone(),
130            })
131            .collect();
132        let masked_output = masked_plaintext_output(input, &lines);
133        Ok(AdapterReport {
134            kind: AdapterKind::Plaintext,
135            findings,
136            masked_output,
137        })
138    }
139}
140
141fn masked_plaintext_output(input: &str, lines: &[LineScanResult]) -> String {
142    let mut output = String::with_capacity(input.len());
143    for (index, segment) in input.split_inclusive('\n').enumerate() {
144        let line = segment.strip_suffix('\n').unwrap_or(segment);
145        let line = line.strip_suffix('\r').unwrap_or(line);
146        let masked = lines
147            .get(index)
148            .and_then(|result| result.masked_line.as_deref())
149            .unwrap_or(line);
150        output.push_str(masked);
151        if segment.ends_with('\n') {
152            if segment.ends_with("\r\n") {
153                output.push('\r');
154            }
155            output.push('\n');
156        }
157    }
158    if !input.contains('\n') {
159        return lines
160            .first()
161            .and_then(|result| result.masked_line.clone())
162            .unwrap_or_else(|| input.to_string());
163    }
164    output
165}
166
167impl Adapter for JsonAdapter {
168    fn scan_str(&self, input: &str, scanner: &Scanner) -> Result<AdapterReport> {
169        let result = scan_json_str(input, scanner, &self.options)?;
170        let findings = result
171            .strings
172            .into_iter()
173            .map(|string| AdapterFinding {
174                location: string.path,
175                findings: string.findings,
176                masked_value: string.masked_value,
177            })
178            .collect();
179        Ok(AdapterReport {
180            kind: AdapterKind::Json,
181            findings,
182            masked_output: serde_json::to_string(&result.masked_json)?,
183        })
184    }
185}
186
187impl Adapter for CsvAdapter {
188    fn scan_str(&self, input: &str, scanner: &Scanner) -> Result<AdapterReport> {
189        let result = scan_csv_str(input, scanner, &self.options)?;
190        let findings = result
191            .cells
192            .into_iter()
193            .map(|cell| AdapterFinding {
194                location: format!("row:{},column:{}", cell.row_number, cell.column_index),
195                findings: cell.findings,
196                masked_value: cell.masked_value,
197            })
198            .collect();
199        Ok(AdapterReport {
200            kind: AdapterKind::Csv,
201            findings,
202            masked_output: result.masked_csv,
203        })
204    }
205}
206
207impl Adapter for LogStreamAdapter {
208    fn scan_str(&self, input: &str, scanner: &Scanner) -> Result<AdapterReport> {
209        let result = scan_log_str(input, scanner)?;
210        let findings = result
211            .lines
212            .into_iter()
213            .filter(|line| !line.findings.is_empty())
214            .map(|line| AdapterFinding {
215                location: format!("line:{}", line.line_number),
216                findings: line.findings,
217                masked_value: line.masked_line,
218            })
219            .collect();
220        Ok(AdapterReport {
221            kind: AdapterKind::LogStream,
222            findings,
223            masked_output: result.masked_log,
224        })
225    }
226}
227
228impl Adapter for SqlAdapter {
229    fn scan_str(&self, input: &str, scanner: &Scanner) -> Result<AdapterReport> {
230        let result = scan_sql_str(input, scanner)?;
231        let findings = result
232            .values
233            .into_iter()
234            .map(|value| AdapterFinding {
235                location: format!(
236                    "statement:{},value:{}",
237                    value.statement_number, value.value_index
238                ),
239                findings: value.findings,
240                masked_value: value.masked_value,
241            })
242            .collect();
243        Ok(AdapterReport {
244            kind: AdapterKind::Sql,
245            findings,
246            masked_output: result.masked_sql,
247        })
248    }
249}
250
251/// Returns the crate version.
252#[must_use]
253pub fn version() -> &'static str {
254    env!("CARGO_PKG_VERSION")
255}