1use std::cell::OnceCell;
2use std::collections::HashMap;
3use std::io::Cursor;
4use std::path::{Path, PathBuf};
5
6pub struct SlenvLoader {
16 dir: Option<PathBuf>,
17 vars: OnceCell<HashMap<String, String>>,
18 has_dotenvx: OnceCell<bool>,
19}
20
21impl SlenvLoader {
22 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 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 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 pub fn insert_mut(&mut self, key: impl Into<String>, value: impl Into<String>) {
58 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 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 if let Some(val) = self.vars().get(key) {
113 return Some(val.clone());
114 }
115
116 if let Some(val) = self.dotenvx_get(key) {
118 return Some(val);
119 }
120
121 std::env::var(key).ok()
123 }
124
125 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 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 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 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 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
197const SECRET_PREFIXES: &[&str] = &[
203 "sk-",
204 "sk_",
205 "pk-",
206 "pk_", "ghp_",
208 "gho_",
209 "ghs_",
210 "ghu_", "AKIA", "xoxb-",
213 "xoxp-",
214 "xapp-", "eyJ", "shpat_",
217 "shpss_", "whsec_", "sq0", "ANTHROPIC_API_KEY", ];
222
223fn 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 for prefix in SECRET_PREFIXES {
236 if value.starts_with(prefix) {
237 return true;
238 }
239 }
240
241 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 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}