use std::cell::OnceCell;
use std::collections::HashMap;
use std::io::Cursor;
use std::path::{Path, PathBuf};
pub struct SlenvLoader {
dir: Option<PathBuf>,
vars: OnceCell<HashMap<String, String>>,
has_dotenvx: OnceCell<bool>,
}
impl SlenvLoader {
pub fn new(dir: &Path) -> Self {
Self {
dir: Some(dir.to_path_buf()),
vars: OnceCell::new(),
has_dotenvx: OnceCell::new(),
}
}
pub fn empty() -> Self {
let loader = Self {
dir: None,
vars: OnceCell::new(),
has_dotenvx: OnceCell::new(),
};
loader.vars.set(HashMap::new()).ok();
loader
}
pub fn from_content(content: &str) -> Self {
let vars = dotenvy::from_read_iter(Cursor::new(content.to_owned()))
.filter_map(|r| r.ok())
.collect();
let loader = Self {
dir: None,
vars: OnceCell::new(),
has_dotenvx: OnceCell::new(),
};
loader.vars.set(vars).ok();
loader
}
pub fn insert_mut(&mut self, key: impl Into<String>, value: impl Into<String>) {
let _ = self.vars.get_or_init(HashMap::new);
if let Some(vars) = self.vars.get_mut() {
vars.insert(key.into(), value.into());
}
}
pub fn resolve(&self, input: &str) -> String {
let mut result = String::with_capacity(input.len());
let bytes = input.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if bytes[i] == b'$' && i + 1 < len {
if bytes[i + 1] == b'{' {
if let Some(close) = input[i + 2..].find('}') {
let key = &input[i + 2..i + 2 + close];
if let Some(val) = self.lookup(key) {
warn_if_secret(key, &val);
result.push_str(&val);
} else {
result.push_str(&input[i..i + 3 + close]);
}
i += 3 + close;
continue;
}
} else if bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == b'_' {
let start = i + 1;
let mut end = start;
while end < len && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') {
end += 1;
}
let key = &input[start..end];
if let Some(val) = self.lookup(key) {
warn_if_secret(key, &val);
result.push_str(&val);
} else {
result.push_str(&input[i..end]);
}
i = end;
continue;
}
}
result.push(bytes[i] as char);
i += 1;
}
result
}
fn lookup(&self, key: &str) -> Option<String> {
if let Some(val) = self.vars().get(key) {
return Some(val.clone());
}
if let Some(val) = self.dotenvx_get(key) {
return Some(val);
}
std::env::var(key).ok()
}
fn vars(&self) -> &HashMap<String, String> {
self.vars.get_or_init(|| {
let Some(dir) = &self.dir else {
return HashMap::new();
};
let mut vars = HashMap::new();
let env_path = dir.join(".env");
if let Ok(iter) = dotenvy::from_path_iter(&env_path) {
for item in iter.flatten() {
vars.insert(item.0, item.1);
}
}
let slenv_path = dir.join(".slenv");
if let Ok(iter) = dotenvy::from_path_iter(&slenv_path) {
for item in iter.flatten() {
vars.insert(item.0, item.1);
}
}
vars
})
}
fn has_dotenvx(&self) -> bool {
*self.has_dotenvx.get_or_init(|| {
std::process::Command::new("dotenvx")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
})
}
fn dotenvx_get(&self, key: &str) -> Option<String> {
if !self.has_dotenvx() {
return None;
}
let dir = self.dir.as_ref()?;
let output = std::process::Command::new("dotenvx")
.arg("get")
.arg(key)
.current_dir(dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if output.status.success() {
let val = String::from_utf8(output.stdout).ok()?;
let trimmed = val.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
} else {
None
}
}
}
const SECRET_PREFIXES: &[&str] = &[
"sk-",
"sk_",
"pk-",
"pk_", "ghp_",
"gho_",
"ghs_",
"ghu_", "AKIA", "xoxb-",
"xoxp-",
"xapp-", "eyJ", "shpat_",
"shpss_", "whsec_", "sq0", "ANTHROPIC_API_KEY", ];
fn warn_if_secret(key: &str, value: &str) {
if looks_like_secret(value) {
eprintln!(
"warning: ${} looks like a secret — consider encrypting with `dotenvx encrypt`",
key
);
}
}
fn looks_like_secret(value: &str) -> bool {
for prefix in SECRET_PREFIXES {
if value.starts_with(prefix) {
return true;
}
}
if value.len() >= 32 && value.is_ascii() {
let alpha = value.chars().filter(|c| c.is_ascii_alphabetic()).count();
let digit = value.chars().filter(|c| c.is_ascii_digit()).count();
let has_mixed_case = value.chars().any(|c| c.is_ascii_uppercase())
&& value.chars().any(|c| c.is_ascii_lowercase());
if alpha + digit > value.len() * 3 / 4 && has_mixed_case && digit > 0 {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_from_vars() {
let mut loader = SlenvLoader::empty();
loader.insert_mut("NAME", "world");
assert_eq!(loader.resolve("hello $NAME"), "hello world");
assert_eq!(loader.resolve("hello ${NAME}!"), "hello world!");
}
#[test]
fn resolve_falls_back_to_process_env() {
let loader = SlenvLoader::empty();
let result = loader.resolve("$HOME");
assert!(!result.starts_with('$'));
}
#[test]
fn unresolved_key_left_as_is() {
let loader = SlenvLoader::empty();
assert_eq!(
loader.resolve("$NONEXISTENT_SLASH_VAR_XYZ"),
"$NONEXISTENT_SLASH_VAR_XYZ"
);
}
#[test]
fn no_dollar_sign_unchanged() {
let loader = SlenvLoader::empty();
assert_eq!(loader.resolve("hello world"), "hello world");
}
#[test]
fn from_content_loads_vars() {
let loader = SlenvLoader::from_content("FOO=bar\nBAZ=quoted");
assert_eq!(loader.vars().get("FOO").unwrap(), "bar");
assert_eq!(loader.vars().get("BAZ").unwrap(), "quoted");
}
#[test]
fn detects_known_secret_prefixes() {
assert!(looks_like_secret("sk-1234567890abcdef"));
assert!(looks_like_secret("ghp_abcdefghijk123"));
assert!(looks_like_secret("AKIAIOSFODNN7EXAMPLE"));
assert!(looks_like_secret("xoxb-123-456-abc"));
}
#[test]
fn detects_high_entropy_strings() {
assert!(looks_like_secret("aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV"));
}
#[test]
fn ignores_normal_values() {
assert!(!looks_like_secret("hello world"));
assert!(!looks_like_secret("localhost"));
assert!(!looks_like_secret("3000"));
assert!(!looks_like_secret("/usr/local/bin"));
}
}