Skip to main content

sanitize_engine/processor/
env_proc.rs

1//! `.env` file processor.
2//!
3//! Handles shell-style environment variable files with lines of the form:
4//!
5//! ```text
6//! KEY=value
7//! KEY="quoted value"
8//! KEY='single quoted'
9//! export KEY=value
10//! # comment lines are preserved
11//! ```
12//!
13//! The `export` keyword is stripped before key matching so that a
14//! FieldRule for `"SECRET_KEY"` correctly matches both `SECRET_KEY=val`
15//! and `export SECRET_KEY=val`.
16//!
17//! # Inline Comments
18//!
19//! Unquoted values may have inline comments (`KEY=value # comment`).
20//! The comment and trailing whitespace are stripped before replacement
21//! and the comment is NOT written back (it may contain sensitive context).
22//! Quoted values are treated as opaque — everything between the quotes
23//! is the value.
24//!
25//! # Formatting Preservation
26//!
27//! - Leading whitespace, blank lines, and `#` comment lines are preserved.
28//! - The original quoting style (single, double, or unquoted) is retained.
29//! - The `export` prefix, if present, is retained in the output.
30
31use crate::error::{Result, SanitizeError};
32use crate::processor::{find_matching_rule, replace_value, FileTypeProfile, Processor};
33use crate::store::MappingStore;
34
35/// Maximum allowed input size (bytes) for `.env` processing.
36const MAX_ENV_INPUT_SIZE: usize = 256 * 1024 * 1024; // 256 MiB
37
38/// Structured processor for `.env` / shell environment files.
39pub 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            // Preserve blank lines.
70            if trimmed.is_empty() {
71                output.push_str(line);
72                output.push('\n');
73                continue;
74            }
75
76            // Preserve comment-only lines.
77            if trimmed.starts_with('#') {
78                output.push_str(line);
79                output.push('\n');
80                continue;
81            }
82
83            // Capture leading whitespace (indentation) for output reconstruction.
84            let indent_len = line.len() - line.trim_start().len();
85            let indent = &line[..indent_len];
86
87            // Detect and preserve `export ` prefix.
88            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            // Split on the first `=`.
95            let Some((raw_key, after_eq)) = after_export.split_once('=') else {
96                // No `=` — not a key=value line; preserve as-is.
97                output.push_str(line);
98                output.push('\n');
99                continue;
100            };
101
102            let key = raw_key.trim();
103
104            // Detect quoting and extract the inner value.
105            let (quote_char, inner_value) = detect_env_quotes(after_eq);
106
107            // Strip inline comments from unquoted values.
108            let inner_value = if quote_char.is_none() {
109                // Everything before a ` #` (space-hash) is the value.
110                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                // Reconstruct: indent + [export ] + KEY=["']value["']
122                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        // Remove the trailing newline we added if the original didn't end with one.
143        if !text.ends_with('\n') && output.ends_with('\n') {
144            output.pop();
145        }
146
147        Ok(output.into_bytes())
148    }
149}
150
151/// Detect surrounding quotes and return `(quote_char, inner_value)`.
152/// Returns `(None, value)` for unquoted values.
153fn 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        // Keys are preserved.
190        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        // `export` keyword is kept.
204        assert!(text.contains("export SECRET="));
205        // Non-export line works too.
206        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        // Quote chars are preserved.
219        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        // SECRET replaced, PUBLIC_URL unchanged.
244        assert!(!text.contains("abc123"));
245        assert!(text.contains("https://example.com"));
246    }
247}