sanitize_engine/processor/
env_proc.rs1use crate::error::{Result, SanitizeError};
32use crate::processor::{find_matching_rule, replace_value, FileTypeProfile, Processor};
33use crate::store::MappingStore;
34
35const MAX_ENV_INPUT_SIZE: usize = 256 * 1024 * 1024; pub struct EnvProcessor;
40
41impl Processor for EnvProcessor {
42 fn name(&self) -> &'static str {
43 "env"
44 }
45
46 fn can_handle(&self, _content: &[u8], profile: &FileTypeProfile) -> bool {
47 profile.processor == "env"
48 }
49
50 fn process(
51 &self,
52 content: &[u8],
53 profile: &FileTypeProfile,
54 store: &MappingStore,
55 ) -> Result<Vec<u8>> {
56 if content.len() > MAX_ENV_INPUT_SIZE {
57 return Err(SanitizeError::InputTooLarge {
58 size: content.len(),
59 limit: MAX_ENV_INPUT_SIZE,
60 });
61 }
62
63 let text = String::from_utf8_lossy(content);
64 let mut output = String::with_capacity(text.len());
65
66 for line in text.split('\n') {
67 let trimmed = line.trim();
68
69 if trimmed.is_empty() {
71 output.push_str(line);
72 output.push('\n');
73 continue;
74 }
75
76 if trimmed.starts_with('#') {
78 output.push_str(line);
79 output.push('\n');
80 continue;
81 }
82
83 let indent_len = line.len() - line.trim_start().len();
85 let indent = &line[..indent_len];
86
87 let (has_export, after_export) = if let Some(rest) = trimmed.strip_prefix("export ") {
89 (true, rest.trim_start())
90 } else {
91 (false, trimmed)
92 };
93
94 let Some((raw_key, after_eq)) = after_export.split_once('=') else {
96 output.push_str(line);
98 output.push('\n');
99 continue;
100 };
101
102 let key = raw_key.trim();
103
104 let (quote_char, inner_value) = detect_env_quotes(after_eq);
106
107 let inner_value = if quote_char.is_none() {
109 inner_value
111 .find(" #")
112 .map_or(inner_value, |pos| &inner_value[..pos])
113 .trim_end()
114 } else {
115 inner_value
116 };
117
118 if let Some(rule) = find_matching_rule(key, profile) {
119 let replaced = replace_value(inner_value, rule, store)?;
120
121 output.push_str(indent);
123 if has_export {
124 output.push_str("export ");
125 }
126 output.push_str(key);
127 output.push('=');
128 if let Some(q) = quote_char {
129 output.push(q);
130 output.push_str(&replaced);
131 output.push(q);
132 } else {
133 output.push_str(&replaced);
134 }
135 output.push('\n');
136 } else {
137 output.push_str(line);
138 output.push('\n');
139 }
140 }
141
142 if !text.ends_with('\n') && output.ends_with('\n') {
144 output.pop();
145 }
146
147 Ok(output.into_bytes())
148 }
149}
150
151fn detect_env_quotes(value: &str) -> (Option<char>, &str) {
154 if value.len() >= 2 {
155 let first = value.as_bytes()[0];
156 let last = value.as_bytes()[value.len() - 1];
157 if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
158 return (Some(first as char), &value[1..value.len() - 1]);
159 }
160 }
161 (None, value)
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use crate::generator::HmacGenerator;
168 use crate::processor::profile::FieldRule;
169 use std::sync::Arc;
170
171 fn make_store() -> MappingStore {
172 let gen = Arc::new(HmacGenerator::new([42u8; 32]));
173 MappingStore::new(gen, None)
174 }
175
176 fn wildcard_profile() -> FileTypeProfile {
177 FileTypeProfile::new("env", vec![FieldRule::new("*")])
178 }
179
180 #[test]
181 fn basic_key_value() {
182 let store = make_store();
183 let proc = EnvProcessor;
184 let content = b"SECRET_KEY=abc123\nDB_HOST=localhost\n";
185 let output = proc.process(content, &wildcard_profile(), &store).unwrap();
186 let text = String::from_utf8(output).unwrap();
187 assert!(!text.contains("abc123"));
188 assert!(!text.contains("localhost"));
189 assert!(text.contains("SECRET_KEY="));
191 assert!(text.contains("DB_HOST="));
192 }
193
194 #[test]
195 fn export_prefix_preserved() {
196 let store = make_store();
197 let proc = EnvProcessor;
198 let content = b"export SECRET=hunter2\nDBPASS=s3cret\n";
199 let output = proc.process(content, &wildcard_profile(), &store).unwrap();
200 let text = String::from_utf8(output).unwrap();
201 assert!(!text.contains("hunter2"));
202 assert!(!text.contains("s3cret"));
203 assert!(text.contains("export SECRET="));
205 assert!(text.contains("DBPASS="));
207 }
208
209 #[test]
210 fn quoted_values() {
211 let store = make_store();
212 let proc = EnvProcessor;
213 let content = b"PW=\"my secret\"\nKEY='another secret'\n";
214 let output = proc.process(content, &wildcard_profile(), &store).unwrap();
215 let text = String::from_utf8(output).unwrap();
216 assert!(!text.contains("my secret"));
217 assert!(!text.contains("another secret"));
218 assert!(text.contains("PW=\""));
220 assert!(text.contains("KEY='"));
221 }
222
223 #[test]
224 fn comments_and_blanks_preserved() {
225 let store = make_store();
226 let proc = EnvProcessor;
227 let content = b"# This is a comment\n\nKEY=value\n";
228 let output = proc.process(content, &wildcard_profile(), &store).unwrap();
229 let text = String::from_utf8(output).unwrap();
230 assert!(text.contains("# This is a comment"));
231 assert!(text.contains("\n\n"));
232 }
233
234 #[test]
235 fn field_rule_targets_specific_key() {
236 let store = make_store();
237 let proc = EnvProcessor;
238 let content = b"SECRET=abc123\nPUBLIC_URL=https://example.com\n";
239 let profile =
240 FileTypeProfile::new("env", vec![FieldRule::new("SECRET")]);
241 let output = proc.process(content, &profile, &store).unwrap();
242 let text = String::from_utf8(output).unwrap();
243 assert!(!text.contains("abc123"));
245 assert!(text.contains("https://example.com"));
246 }
247}