sanitize_engine/processor/
ini_proc.rs1use crate::error::{Result, SanitizeError};
31use crate::processor::limits::DEFAULT_INPUT_SIZE;
32use crate::processor::{find_matching_rule, replace_value, FileTypeProfile, Processor};
33use crate::store::MappingStore;
34
35pub struct IniProcessor;
37
38impl Processor for IniProcessor {
39 fn name(&self) -> &'static str {
40 "ini"
41 }
42
43 fn can_handle(&self, _content: &[u8], profile: &FileTypeProfile) -> bool {
44 profile.processor == "ini"
45 }
46
47 fn process(
48 &self,
49 content: &[u8],
50 profile: &FileTypeProfile,
51 store: &MappingStore,
52 ) -> Result<Vec<u8>> {
53 if content.len() > DEFAULT_INPUT_SIZE {
54 return Err(SanitizeError::InputTooLarge {
55 size: content.len(),
56 limit: DEFAULT_INPUT_SIZE,
57 });
58 }
59
60 let text = String::from_utf8_lossy(content);
61 let mut output = String::with_capacity(text.len());
62 let mut current_section: Option<String> = None;
63
64 for line in text.split('\n') {
65 let trimmed = line.trim();
66
67 if trimmed.is_empty() {
69 output.push_str(line);
70 output.push('\n');
71 continue;
72 }
73
74 if trimmed.starts_with('#') || trimmed.starts_with(';') {
76 output.push_str(line);
77 output.push('\n');
78 continue;
79 }
80
81 if trimmed.starts_with('[') {
83 if let Some(close) = trimmed.find(']') {
84 current_section = Some(trimmed[1..close].trim().to_string());
85 }
86 output.push_str(line);
87 output.push('\n');
88 continue;
89 }
90
91 let Some((raw_key, raw_value)) = split_kv(trimmed) else {
93 output.push_str(line);
95 output.push('\n');
96 continue;
97 };
98
99 let key = raw_key.trim();
100
101 let indent_len = line.len() - line.trim_start().len();
103 let indent = &line[..indent_len];
104
105 let delimiter = extract_delimiter(line, key, raw_value);
107
108 let value = strip_inline_comment(raw_value.trim_start());
110
111 let path = match ¤t_section {
113 Some(section) => format!("{}.{}", section, key),
114 None => key.to_string(),
115 };
116
117 if let Some(rule) = find_matching_rule(&path, profile) {
118 let replaced = replace_value(value, rule, store)?;
119 output.push_str(indent);
120 output.push_str(key);
121 output.push_str(&delimiter);
122 output.push_str(&replaced);
123 output.push('\n');
124 } else {
125 output.push_str(line);
126 output.push('\n');
127 }
128 }
129
130 if !text.ends_with('\n') && output.ends_with('\n') {
132 output.pop();
133 }
134
135 Ok(output.into_bytes())
136 }
137}
138
139fn split_kv(s: &str) -> Option<(&str, &str)> {
142 if let Some(pos) = s.find('=') {
144 return Some((&s[..pos], &s[pos + 1..]));
145 }
146 if let Some(pos) = s.find(':') {
147 return Some((&s[..pos], &s[pos + 1..]));
148 }
149 None
150}
151
152fn extract_delimiter(line: &str, key: &str, after_delim: &str) -> String {
155 if let Some(key_start) = line.find(key.trim()) {
157 let after_key = &line[key_start + key.trim().len()..];
158 let delimiter_end = after_key
162 .len()
163 .saturating_sub(after_delim.len())
164 .saturating_add(1);
165 if delimiter_end <= after_key.len() {
166 return after_key[..delimiter_end].to_string();
167 }
168 }
169 " = ".to_string()
170}
171
172fn strip_inline_comment(value: &str) -> &str {
175 for marker in [" # ", " ; "] {
176 if let Some(pos) = value.find(marker) {
177 return value[..pos].trim_end();
178 }
179 }
180 value.trim_end()
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use crate::generator::HmacGenerator;
187 use crate::processor::profile::FieldRule;
188 use std::sync::Arc;
189
190 fn make_store() -> MappingStore {
191 let gen = Arc::new(HmacGenerator::new([42u8; 32]));
192 MappingStore::new(gen, None)
193 }
194
195 fn wildcard_profile() -> FileTypeProfile {
196 FileTypeProfile::new("ini", vec![FieldRule::new("*")])
197 }
198
199 #[test]
200 fn basic_ini_replacement() {
201 let store = make_store();
202 let proc = IniProcessor;
203 let content =
204 b"[database]\nhost = db.corp.com\npassword = s3cret\n\n[smtp]\nuser = admin\n";
205 let output = proc.process(content, &wildcard_profile(), &store).unwrap();
206 let text = String::from_utf8(output).unwrap();
207 assert!(!text.contains("db.corp.com"));
209 assert!(!text.contains("s3cret"));
210 assert!(!text.contains("admin"));
211 assert!(text.contains("[database]"));
213 assert!(text.contains("[smtp]"));
214 assert!(text.contains("host =") || text.contains("host="));
216 }
217
218 #[test]
219 fn section_qualified_rule() {
220 let store = make_store();
221 let proc = IniProcessor;
222 let content = b"[database]\npassword = secret\n[app]\nname = myapp\n";
223 let profile = FileTypeProfile::new("ini", vec![FieldRule::new("database.password")]);
224 let output = proc.process(content, &profile, &store).unwrap();
225 let text = String::from_utf8(output).unwrap();
226 assert!(!text.contains("secret"));
228 assert!(text.contains("myapp"));
229 }
230
231 #[test]
232 fn comments_and_blanks_preserved() {
233 let store = make_store();
234 let proc = IniProcessor;
235 let content = b"# Global config\n\n[section]\n; this is a semicolon comment\nkey = val\n";
236 let output = proc.process(content, &wildcard_profile(), &store).unwrap();
237 let text = String::from_utf8(output).unwrap();
238 assert!(text.contains("# Global config"));
239 assert!(text.contains("; this is a semicolon comment"));
240 assert!(text.contains("\n\n"));
242 }
243
244 #[test]
245 fn colon_delimiter_handled() {
246 let store = make_store();
247 let proc = IniProcessor;
248 let content = b"[section]\napi_key: abc123\n";
249 let profile = FileTypeProfile::new("ini", vec![FieldRule::new("section.api_key")]);
250 let output = proc.process(content, &profile, &store).unwrap();
251 let text = String::from_utf8(output).unwrap();
252 assert!(!text.contains("abc123"));
253 }
254}