Skip to main content

sanitize_engine/processor/
ini_proc.rs

1//! INI / CFG file processor with `[section]` awareness.
2//!
3//! Handles Windows/Unix INI-style configuration files:
4//!
5//! ```ini
6//! [section]
7//! key = value
8//! key: value
9//! ; semicolon comment
10//! # hash comment
11//! ```
12//!
13//! # Key Paths
14//!
15//! Field rules use dot notation combining section and key:
16//! - `"database.host"` — matches key `host` in section `[database]`
17//! - `"*"` — matches all key=value pairs in all sections
18//! - `"global_key"` — matches a key before any section header (global scope)
19//!
20//! # Formatting Preservation
21//!
22//! - Section headers `[section]` are preserved verbatim.
23//! - `#` and `;` comment lines are preserved verbatim.
24//! - Blank lines are preserved.
25//! - Leading whitespace in value is stripped; quoting is not applied.
26//! - Inline comments (`key = value ; comment`) are stripped and NOT written
27//!   back to avoid leaking sensitive context in comments.
28//! - Both `key = value` and `key: value` assignment operators are handled.
29
30use 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
35/// Structured processor for INI / CFG files.
36pub 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            // Blank line.
68            if trimmed.is_empty() {
69                output.push_str(line);
70                output.push('\n');
71                continue;
72            }
73
74            // Comment line.
75            if trimmed.starts_with('#') || trimmed.starts_with(';') {
76                output.push_str(line);
77                output.push('\n');
78                continue;
79            }
80
81            // Section header: `[section_name]`
82            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            // Key=value or key:value line.
92            let Some((raw_key, raw_value)) = split_kv(trimmed) else {
93                // Unrecognised line — preserve as-is.
94                output.push_str(line);
95                output.push('\n');
96                continue;
97            };
98
99            let key = raw_key.trim();
100
101            // Capture leading whitespace for output reconstruction.
102            let indent_len = line.len() - line.trim_start().len();
103            let indent = &line[..indent_len];
104
105            // Capture the original delimiter (` = ` or ` : ` etc.).
106            let delimiter = extract_delimiter(line, key, raw_value);
107
108            // Strip inline comments from the value.
109            let value = strip_inline_comment(raw_value.trim_start());
110
111            // Build section-qualified key path.
112            let path = match &current_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        // Remove the trailing newline we added if the original didn't end with one.
131        if !text.ends_with('\n') && output.ends_with('\n') {
132            output.pop();
133        }
134
135        Ok(output.into_bytes())
136    }
137}
138
139/// Split `key = value` or `key: value` on the first `=` or `:` delimiter.
140/// Returns `None` if no delimiter is found.
141fn split_kv(s: &str) -> Option<(&str, &str)> {
142    // Prefer `=` first (most common in INI files).
143    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
152/// Reproduce the original delimiter string from the source line.
153/// Falls back to `" = "` if extraction fails.
154fn extract_delimiter(line: &str, key: &str, after_delim: &str) -> String {
155    // Locate the key in the line to find where the delimiter starts.
156    if let Some(key_start) = line.find(key.trim()) {
157        let after_key = &line[key_start + key.trim().len()..];
158        // The delimiter ends where after_delim (unstripped) begins.
159        // after_delim already includes everything after the `=`/`:` character.
160        // We need: after_key[..pos_of_value_start].
161        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
172/// Strip trailing inline comments from a value string.
173/// Recognises ` # ` and ` ; ` as inline comment markers.
174fn 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        // Values replaced.
208        assert!(!text.contains("db.corp.com"));
209        assert!(!text.contains("s3cret"));
210        assert!(!text.contains("admin"));
211        // Section headers preserved.
212        assert!(text.contains("[database]"));
213        assert!(text.contains("[smtp]"));
214        // Keys preserved.
215        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        // password replaced, app.name untouched.
227        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        // Blank line preserved.
241        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}