Skip to main content

scour_secrets/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        // `delimiter_end` is a byte offset derived from length arithmetic, so it
166        // can land inside a multi-byte char (e.g. a U+FFFD produced by
167        // from_utf8_lossy on invalid UTF-8). Guard the slice to avoid panicking;
168        // fall through to the default delimiter when it isn't a char boundary.
169        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
176/// Strip trailing inline comments from a value string.
177/// Recognises ` # ` and ` ; ` as inline comment markers.
178fn 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        // Values replaced.
212        assert!(!text.contains("db.corp.com"));
213        assert!(!text.contains("s3cret"));
214        assert!(!text.contains("admin"));
215        // Section headers preserved.
216        assert!(text.contains("[database]"));
217        assert!(text.contains("[smtp]"));
218        // Keys preserved.
219        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        // password replaced, app.name untouched.
231        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        // Blank line preserved.
245        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        // Non-secret structure preserved: section header, key, and `:` delimiter.
258        assert!(text.contains("[section]"));
259        assert!(text.contains("api_key:"));
260    }
261
262    #[test]
263    fn invalid_utf8_value_does_not_panic() {
264        // Fuzz regression (fuzz_ini crash-c11471bb): `=` followed by a lone
265        // 0xCA byte becomes `=\u{FFFD}` via from_utf8_lossy, and the delimiter
266        // reconstruction sliced `after_key` at a byte index inside the 3-byte
267        // replacement char. Must process cleanly instead of panicking.
268        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        // Output is valid UTF-8 and round-trips without crashing.
274        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        // One byte over the limit — only the length is checked before any parsing.
281        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}