rust_sanitize/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() && after_key.is_char_boundary(delimiter_end) {
170 return after_key[..delimiter_end].to_string();
171 }
172 }
173 " = ".to_string()
174}
175
176fn strip_inline_comment(value: &str) -> &str {
179 for marker in [" # ", " ; "] {
180 if let Some(pos) = value.find(marker) {
181 return value[..pos].trim_end();
182 }
183 }
184 value.trim_end()
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::generator::HmacGenerator;
191 use crate::processor::profile::FieldRule;
192 use std::sync::Arc;
193
194 fn make_store() -> MappingStore {
195 let gen = Arc::new(HmacGenerator::new([42u8; 32]));
196 MappingStore::new(gen, None)
197 }
198
199 fn wildcard_profile() -> FileTypeProfile {
200 FileTypeProfile::new("ini", vec![FieldRule::new("*")])
201 }
202
203 #[test]
204 fn basic_ini_replacement() {
205 let store = make_store();
206 let proc = IniProcessor;
207 let content =
208 b"[database]\nhost = db.corp.com\npassword = s3cret\n\n[smtp]\nuser = admin\n";
209 let output = proc.process(content, &wildcard_profile(), &store).unwrap();
210 let text = String::from_utf8(output).unwrap();
211 assert!(!text.contains("db.corp.com"));
213 assert!(!text.contains("s3cret"));
214 assert!(!text.contains("admin"));
215 assert!(text.contains("[database]"));
217 assert!(text.contains("[smtp]"));
218 assert!(text.contains("host =") || text.contains("host="));
220 }
221
222 #[test]
223 fn section_qualified_rule() {
224 let store = make_store();
225 let proc = IniProcessor;
226 let content = b"[database]\npassword = secret\n[app]\nname = myapp\n";
227 let profile = FileTypeProfile::new("ini", vec![FieldRule::new("database.password")]);
228 let output = proc.process(content, &profile, &store).unwrap();
229 let text = String::from_utf8(output).unwrap();
230 assert!(!text.contains("secret"));
232 assert!(text.contains("myapp"));
233 }
234
235 #[test]
236 fn comments_and_blanks_preserved() {
237 let store = make_store();
238 let proc = IniProcessor;
239 let content = b"# Global config\n\n[section]\n; this is a semicolon comment\nkey = val\n";
240 let output = proc.process(content, &wildcard_profile(), &store).unwrap();
241 let text = String::from_utf8(output).unwrap();
242 assert!(text.contains("# Global config"));
243 assert!(text.contains("; this is a semicolon comment"));
244 assert!(text.contains("\n\n"));
246 }
247
248 #[test]
249 fn colon_delimiter_handled() {
250 let store = make_store();
251 let proc = IniProcessor;
252 let content = b"[section]\napi_key: abc123\n";
253 let profile = FileTypeProfile::new("ini", vec![FieldRule::new("section.api_key")]);
254 let output = proc.process(content, &profile, &store).unwrap();
255 let text = String::from_utf8(output).unwrap();
256 assert!(!text.contains("abc123"));
257 assert!(text.contains("[section]"));
259 assert!(text.contains("api_key:"));
260 }
261
262 #[test]
263 fn invalid_utf8_value_does_not_panic() {
264 let store = make_store();
269 let content = [b'=', 0xCA, b'\n'];
270 let output = IniProcessor
271 .process(&content, &wildcard_profile(), &store)
272 .expect("invalid-UTF-8 INI value must not panic or error");
273 String::from_utf8(output).expect("output must be valid UTF-8");
275 }
276
277 #[test]
278 fn input_too_large_returns_error() {
279 let store = make_store();
280 let content = vec![b'\n'; DEFAULT_INPUT_SIZE + 1];
282 let err = IniProcessor
283 .process(&content, &wildcard_profile(), &store)
284 .unwrap_err();
285 assert!(
286 matches!(err, SanitizeError::InputTooLarge { .. }),
287 "oversized input must return InputTooLarge; got: {err:?}",
288 );
289 }
290}