Skip to main content

slash_lib/
env.rs

1use std::cell::OnceCell;
2use std::collections::HashMap;
3use std::io::Cursor;
4use std::path::{Path, PathBuf};
5
6/// Lazy environment loader that resolves `$KEY` / `${KEY}` references.
7///
8/// Files are loaded on first access, not at construction. Resolution order:
9/// 1. `.slenv` values (highest priority)
10/// 2. `.env` values
11/// 3. Encrypted `.env` via `dotenvx get` (if dotenvx is on PATH)
12/// 4. Process environment (fallback)
13///
14/// When a resolved value looks like a secret, a warning is emitted to stderr.
15pub struct SlenvLoader {
16    dir: Option<PathBuf>,
17    vars: OnceCell<HashMap<String, String>>,
18    has_dotenvx: OnceCell<bool>,
19}
20
21impl SlenvLoader {
22    /// Create a loader that will lazily load from `dir/.env` and `dir/.slenv`.
23    pub fn new(dir: &Path) -> Self {
24        Self {
25            dir: Some(dir.to_path_buf()),
26            vars: OnceCell::new(),
27            has_dotenvx: OnceCell::new(),
28        }
29    }
30
31    /// Create an empty loader (no file backing). For testing.
32    pub fn empty() -> Self {
33        let loader = Self {
34            dir: None,
35            vars: OnceCell::new(),
36            has_dotenvx: OnceCell::new(),
37        };
38        loader.vars.set(HashMap::new()).ok();
39        loader
40    }
41
42    /// Create a loader from an in-memory string. For testing.
43    pub fn from_content(content: &str) -> Self {
44        let vars = dotenvy::from_read_iter(Cursor::new(content.to_owned()))
45            .filter_map(|r| r.ok())
46            .collect();
47        let loader = Self {
48            dir: None,
49            vars: OnceCell::new(),
50            has_dotenvx: OnceCell::new(),
51        };
52        loader.vars.set(vars).ok();
53        loader
54    }
55
56    /// Insert a variable (mutable access, for setup before first resolve).
57    pub fn insert_mut(&mut self, key: impl Into<String>, value: impl Into<String>) {
58        // Ensure OnceCell is initialized, then mutate via &mut self.
59        let _ = self.vars.get_or_init(HashMap::new);
60        if let Some(vars) = self.vars.get_mut() {
61            vars.insert(key.into(), value.into());
62        }
63    }
64
65    /// Resolve `$KEY` and `${KEY}` references in a string.
66    pub fn resolve(&self, input: &str) -> String {
67        let mut result = String::with_capacity(input.len());
68        let bytes = input.as_bytes();
69        let len = bytes.len();
70        let mut i = 0;
71
72        while i < len {
73            if bytes[i] == b'$' && i + 1 < len {
74                if bytes[i + 1] == b'{' {
75                    if let Some(close) = input[i + 2..].find('}') {
76                        let key = &input[i + 2..i + 2 + close];
77                        if let Some(val) = self.lookup(key) {
78                            warn_if_secret(key, &val);
79                            result.push_str(&val);
80                        } else {
81                            result.push_str(&input[i..i + 3 + close]);
82                        }
83                        i += 3 + close;
84                        continue;
85                    }
86                } else if bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == b'_' {
87                    let start = i + 1;
88                    let mut end = start;
89                    while end < len && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') {
90                        end += 1;
91                    }
92                    let key = &input[start..end];
93                    if let Some(val) = self.lookup(key) {
94                        warn_if_secret(key, &val);
95                        result.push_str(&val);
96                    } else {
97                        result.push_str(&input[i..end]);
98                    }
99                    i = end;
100                    continue;
101                }
102            }
103            result.push(bytes[i] as char);
104            i += 1;
105        }
106
107        result
108    }
109
110    fn lookup(&self, key: &str) -> Option<String> {
111        // 1. Check loaded vars (.slenv + .env)
112        if let Some(val) = self.vars().get(key) {
113            return Some(val.clone());
114        }
115
116        // 2. Try dotenvx for encrypted values
117        if let Some(val) = self.dotenvx_get(key) {
118            return Some(val);
119        }
120
121        // 3. Fall back to process environment
122        std::env::var(key).ok()
123    }
124
125    /// Lazily load vars from .env and .slenv.
126    fn vars(&self) -> &HashMap<String, String> {
127        self.vars.get_or_init(|| {
128            let Some(dir) = &self.dir else {
129                return HashMap::new();
130            };
131
132            let mut vars = HashMap::new();
133
134            // Load .env first (lower priority).
135            let env_path = dir.join(".env");
136            if let Ok(iter) = dotenvy::from_path_iter(&env_path) {
137                for item in iter.flatten() {
138                    vars.insert(item.0, item.1);
139                }
140            }
141
142            // Load .slenv second (overrides .env).
143            let slenv_path = dir.join(".slenv");
144            if let Ok(iter) = dotenvy::from_path_iter(&slenv_path) {
145                for item in iter.flatten() {
146                    vars.insert(item.0, item.1);
147                }
148            }
149
150            vars
151        })
152    }
153
154    /// Check if dotenvx is available (cached).
155    fn has_dotenvx(&self) -> bool {
156        *self.has_dotenvx.get_or_init(|| {
157            std::process::Command::new("dotenvx")
158                .arg("--version")
159                .stdout(std::process::Stdio::null())
160                .stderr(std::process::Stdio::null())
161                .status()
162                .is_ok_and(|s| s.success())
163        })
164    }
165
166    /// Try to resolve a key via `dotenvx get KEY`.
167    fn dotenvx_get(&self, key: &str) -> Option<String> {
168        if !self.has_dotenvx() {
169            return None;
170        }
171
172        let dir = self.dir.as_ref()?;
173
174        let output = std::process::Command::new("dotenvx")
175            .arg("get")
176            .arg(key)
177            .current_dir(dir)
178            .stdout(std::process::Stdio::piped())
179            .stderr(std::process::Stdio::null())
180            .output()
181            .ok()?;
182
183        if output.status.success() {
184            let val = String::from_utf8(output.stdout).ok()?;
185            let trimmed = val.trim().to_string();
186            if trimmed.is_empty() {
187                None
188            } else {
189                Some(trimmed)
190            }
191        } else {
192            None
193        }
194    }
195}
196
197// ============================================================================
198// SECRET DETECTION
199// ============================================================================
200
201/// Known prefixes that indicate a secret/token.
202const SECRET_PREFIXES: &[&str] = &[
203    "sk-",
204    "sk_",
205    "pk-",
206    "pk_", // Stripe, OpenAI
207    "ghp_",
208    "gho_",
209    "ghs_",
210    "ghu_", // GitHub
211    "AKIA", // AWS
212    "xoxb-",
213    "xoxp-",
214    "xapp-", // Slack
215    "eyJ",   // JWT
216    "shpat_",
217    "shpss_",            // Shopify
218    "whsec_",            // Webhook secrets
219    "sq0",               // Square
220    "ANTHROPIC_API_KEY", // Just kidding — prefix match, not key name
221];
222
223/// Emit a warning to stderr if a value looks like it might be a secret.
224fn warn_if_secret(key: &str, value: &str) {
225    if looks_like_secret(value) {
226        eprintln!(
227            "warning: ${} looks like a secret — consider encrypting with `dotenvx encrypt`",
228            key
229        );
230    }
231}
232
233fn looks_like_secret(value: &str) -> bool {
234    // Check known prefixes.
235    for prefix in SECRET_PREFIXES {
236        if value.starts_with(prefix) {
237            return true;
238        }
239    }
240
241    // High entropy heuristic: long alphanumeric strings (32+ chars, mixed case/digits).
242    if value.len() >= 32 && value.is_ascii() {
243        let alpha = value.chars().filter(|c| c.is_ascii_alphabetic()).count();
244        let digit = value.chars().filter(|c| c.is_ascii_digit()).count();
245        let has_mixed_case = value.chars().any(|c| c.is_ascii_uppercase())
246            && value.chars().any(|c| c.is_ascii_lowercase());
247
248        // Looks like a key if it's mostly alphanumeric with mixed case and digits.
249        if alpha + digit > value.len() * 3 / 4 && has_mixed_case && digit > 0 {
250            return true;
251        }
252    }
253
254    false
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn resolve_from_vars() {
263        let mut loader = SlenvLoader::empty();
264        loader.insert_mut("NAME", "world");
265        assert_eq!(loader.resolve("hello $NAME"), "hello world");
266        assert_eq!(loader.resolve("hello ${NAME}!"), "hello world!");
267    }
268
269    #[test]
270    fn resolve_falls_back_to_process_env() {
271        let loader = SlenvLoader::empty();
272        let result = loader.resolve("$HOME");
273        assert!(!result.starts_with('$'));
274    }
275
276    #[test]
277    fn unresolved_key_left_as_is() {
278        let loader = SlenvLoader::empty();
279        assert_eq!(
280            loader.resolve("$NONEXISTENT_SLASH_VAR_XYZ"),
281            "$NONEXISTENT_SLASH_VAR_XYZ"
282        );
283    }
284
285    #[test]
286    fn no_dollar_sign_unchanged() {
287        let loader = SlenvLoader::empty();
288        assert_eq!(loader.resolve("hello world"), "hello world");
289    }
290
291    #[test]
292    fn from_content_loads_vars() {
293        let loader = SlenvLoader::from_content("FOO=bar\nBAZ=quoted");
294        assert_eq!(loader.vars().get("FOO").unwrap(), "bar");
295        assert_eq!(loader.vars().get("BAZ").unwrap(), "quoted");
296    }
297
298    #[test]
299    fn detects_known_secret_prefixes() {
300        assert!(looks_like_secret("sk-1234567890abcdef"));
301        assert!(looks_like_secret("ghp_abcdefghijk123"));
302        assert!(looks_like_secret("AKIAIOSFODNN7EXAMPLE"));
303        assert!(looks_like_secret("xoxb-123-456-abc"));
304    }
305
306    #[test]
307    fn detects_high_entropy_strings() {
308        assert!(looks_like_secret("aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV"));
309    }
310
311    #[test]
312    fn ignores_normal_values() {
313        assert!(!looks_like_secret("hello world"));
314        assert!(!looks_like_secret("localhost"));
315        assert!(!looks_like_secret("3000"));
316        assert!(!looks_like_secret("/usr/local/bin"));
317    }
318}