use crate::error::{Result, SanitizeError};
use crate::processor::{find_matching_rule, replace_value, FileTypeProfile, Processor};
use crate::store::MappingStore;
const MAX_INI_INPUT_SIZE: usize = 256 * 1024 * 1024;
pub struct IniProcessor;
impl Processor for IniProcessor {
fn name(&self) -> &'static str {
"ini"
}
fn can_handle(&self, _content: &[u8], profile: &FileTypeProfile) -> bool {
profile.processor == "ini"
}
fn process(
&self,
content: &[u8],
profile: &FileTypeProfile,
store: &MappingStore,
) -> Result<Vec<u8>> {
if content.len() > MAX_INI_INPUT_SIZE {
return Err(SanitizeError::InputTooLarge {
size: content.len(),
limit: MAX_INI_INPUT_SIZE,
});
}
let text = String::from_utf8_lossy(content);
let mut output = String::with_capacity(text.len());
let mut current_section: Option<String> = None;
for line in text.split('\n') {
let trimmed = line.trim();
if trimmed.is_empty() {
output.push_str(line);
output.push('\n');
continue;
}
if trimmed.starts_with('#') || trimmed.starts_with(';') {
output.push_str(line);
output.push('\n');
continue;
}
if trimmed.starts_with('[') {
if let Some(close) = trimmed.find(']') {
current_section = Some(trimmed[1..close].trim().to_string());
}
output.push_str(line);
output.push('\n');
continue;
}
let Some((raw_key, raw_value)) = split_kv(trimmed) else {
output.push_str(line);
output.push('\n');
continue;
};
let key = raw_key.trim();
let indent_len = line.len() - line.trim_start().len();
let indent = &line[..indent_len];
let delimiter = extract_delimiter(line, key, raw_value);
let value = strip_inline_comment(raw_value.trim_start());
let path = match ¤t_section {
Some(section) => format!("{}.{}", section, key),
None => key.to_string(),
};
if let Some(rule) = find_matching_rule(&path, profile) {
let replaced = replace_value(value, rule, store)?;
output.push_str(indent);
output.push_str(key);
output.push_str(&delimiter);
output.push_str(&replaced);
output.push('\n');
} else {
output.push_str(line);
output.push('\n');
}
}
if !text.ends_with('\n') && output.ends_with('\n') {
output.pop();
}
Ok(output.into_bytes())
}
}
fn split_kv(s: &str) -> Option<(&str, &str)> {
if let Some(pos) = s.find('=') {
return Some((&s[..pos], &s[pos + 1..]));
}
if let Some(pos) = s.find(':') {
return Some((&s[..pos], &s[pos + 1..]));
}
None
}
fn extract_delimiter(line: &str, key: &str, after_delim: &str) -> String {
if let Some(key_start) = line.find(key.trim()) {
let after_key = &line[key_start + key.trim().len()..];
let delimiter_end =
after_key.len().saturating_sub(after_delim.len()).saturating_add(1);
if delimiter_end <= after_key.len() {
return after_key[..delimiter_end].to_string();
}
}
" = ".to_string()
}
fn strip_inline_comment(value: &str) -> &str {
for marker in [" # ", " ; "] {
if let Some(pos) = value.find(marker) {
return value[..pos].trim_end();
}
}
value.trim_end()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generator::HmacGenerator;
use crate::processor::profile::FieldRule;
use std::sync::Arc;
fn make_store() -> MappingStore {
let gen = Arc::new(HmacGenerator::new([42u8; 32]));
MappingStore::new(gen, None)
}
fn wildcard_profile() -> FileTypeProfile {
FileTypeProfile::new("ini", vec![FieldRule::new("*")])
}
#[test]
fn basic_ini_replacement() {
let store = make_store();
let proc = IniProcessor;
let content = b"[database]\nhost = db.corp.com\npassword = s3cret\n\n[smtp]\nuser = admin\n";
let output = proc.process(content, &wildcard_profile(), &store).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(!text.contains("db.corp.com"));
assert!(!text.contains("s3cret"));
assert!(!text.contains("admin"));
assert!(text.contains("[database]"));
assert!(text.contains("[smtp]"));
assert!(text.contains("host =") || text.contains("host="));
}
#[test]
fn section_qualified_rule() {
let store = make_store();
let proc = IniProcessor;
let content = b"[database]\npassword = secret\n[app]\nname = myapp\n";
let profile =
FileTypeProfile::new("ini", vec![FieldRule::new("database.password")]);
let output = proc.process(content, &profile, &store).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(!text.contains("secret"));
assert!(text.contains("myapp"));
}
#[test]
fn comments_and_blanks_preserved() {
let store = make_store();
let proc = IniProcessor;
let content = b"# Global config\n\n[section]\n; this is a semicolon comment\nkey = val\n";
let output = proc.process(content, &wildcard_profile(), &store).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("# Global config"));
assert!(text.contains("; this is a semicolon comment"));
assert!(text.contains("\n\n"));
}
#[test]
fn colon_delimiter_handled() {
let store = make_store();
let proc = IniProcessor;
let content = b"[section]\napi_key: abc123\n";
let profile =
FileTypeProfile::new("ini", vec![FieldRule::new("section.api_key")]);
let output = proc.process(content, &profile, &store).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(!text.contains("abc123"));
}
}