use crate::error::{Result, SanitizeError};
use crate::processor::{find_matching_rule, replace_value, FileTypeProfile, Processor};
use crate::store::MappingStore;
const MAX_ENV_INPUT_SIZE: usize = 256 * 1024 * 1024;
pub struct EnvProcessor;
impl Processor for EnvProcessor {
fn name(&self) -> &'static str {
"env"
}
fn can_handle(&self, _content: &[u8], profile: &FileTypeProfile) -> bool {
profile.processor == "env"
}
fn process(
&self,
content: &[u8],
profile: &FileTypeProfile,
store: &MappingStore,
) -> Result<Vec<u8>> {
if content.len() > MAX_ENV_INPUT_SIZE {
return Err(SanitizeError::InputTooLarge {
size: content.len(),
limit: MAX_ENV_INPUT_SIZE,
});
}
let text = String::from_utf8_lossy(content);
let mut output = String::with_capacity(text.len());
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('#') {
output.push_str(line);
output.push('\n');
continue;
}
let indent_len = line.len() - line.trim_start().len();
let indent = &line[..indent_len];
let (has_export, after_export) = if let Some(rest) = trimmed.strip_prefix("export ") {
(true, rest.trim_start())
} else {
(false, trimmed)
};
let Some((raw_key, after_eq)) = after_export.split_once('=') else {
output.push_str(line);
output.push('\n');
continue;
};
let key = raw_key.trim();
let (quote_char, inner_value) = detect_env_quotes(after_eq);
let inner_value = if quote_char.is_none() {
inner_value
.find(" #")
.map_or(inner_value, |pos| &inner_value[..pos])
.trim_end()
} else {
inner_value
};
if let Some(rule) = find_matching_rule(key, profile) {
let replaced = replace_value(inner_value, rule, store)?;
output.push_str(indent);
if has_export {
output.push_str("export ");
}
output.push_str(key);
output.push('=');
if let Some(q) = quote_char {
output.push(q);
output.push_str(&replaced);
output.push(q);
} else {
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 detect_env_quotes(value: &str) -> (Option<char>, &str) {
if value.len() >= 2 {
let first = value.as_bytes()[0];
let last = value.as_bytes()[value.len() - 1];
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
return (Some(first as char), &value[1..value.len() - 1]);
}
}
(None, value)
}
#[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("env", vec![FieldRule::new("*")])
}
#[test]
fn basic_key_value() {
let store = make_store();
let proc = EnvProcessor;
let content = b"SECRET_KEY=abc123\nDB_HOST=localhost\n";
let output = proc.process(content, &wildcard_profile(), &store).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(!text.contains("abc123"));
assert!(!text.contains("localhost"));
assert!(text.contains("SECRET_KEY="));
assert!(text.contains("DB_HOST="));
}
#[test]
fn export_prefix_preserved() {
let store = make_store();
let proc = EnvProcessor;
let content = b"export SECRET=hunter2\nDBPASS=s3cret\n";
let output = proc.process(content, &wildcard_profile(), &store).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(!text.contains("hunter2"));
assert!(!text.contains("s3cret"));
assert!(text.contains("export SECRET="));
assert!(text.contains("DBPASS="));
}
#[test]
fn quoted_values() {
let store = make_store();
let proc = EnvProcessor;
let content = b"PW=\"my secret\"\nKEY='another secret'\n";
let output = proc.process(content, &wildcard_profile(), &store).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(!text.contains("my secret"));
assert!(!text.contains("another secret"));
assert!(text.contains("PW=\""));
assert!(text.contains("KEY='"));
}
#[test]
fn comments_and_blanks_preserved() {
let store = make_store();
let proc = EnvProcessor;
let content = b"# This is a comment\n\nKEY=value\n";
let output = proc.process(content, &wildcard_profile(), &store).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("# This is a comment"));
assert!(text.contains("\n\n"));
}
#[test]
fn field_rule_targets_specific_key() {
let store = make_store();
let proc = EnvProcessor;
let content = b"SECRET=abc123\nPUBLIC_URL=https://example.com\n";
let profile =
FileTypeProfile::new("env", vec![FieldRule::new("SECRET")]);
let output = proc.process(content, &profile, &store).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(!text.contains("abc123"));
assert!(text.contains("https://example.com"));
}
}