use super::{Provider, ProviderUrl};
use crate::{Result, SecretSpecError};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::PathBuf;
fn serialize_dotenv(vars: &HashMap<String, String>) -> String {
let sorted: BTreeMap<&str, &str> = vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let mut out = String::new();
for (key, value) in sorted {
out.push_str(key);
out.push_str("=\"");
for ch in value.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'$' => out.push_str("\\$"),
'\n' => out.push_str("\\n"),
c => out.push(c),
}
}
out.push_str("\"\n");
}
out
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DotEnvConfig {
pub path: PathBuf,
}
impl Default for DotEnvConfig {
fn default() -> Self {
Self {
path: PathBuf::from(".env"),
}
}
}
impl TryFrom<&ProviderUrl> for DotEnvConfig {
type Error = SecretSpecError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
if url.scheme() != "dotenv" {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Invalid scheme '{}' for dotenv provider",
url.scheme()
)));
}
let path_str = url.path();
let path = if path_str != "" && path_str != "/" {
if let Some(host) = url.host() {
format!("{}{}", host, path_str)
} else {
path_str
}
} else if let Some(host) = url.host() {
host
} else {
".env".to_string()
};
Ok(Self {
path: PathBuf::from(path),
})
}
}
pub struct DotEnvProvider {
config: DotEnvConfig,
}
crate::register_provider! {
struct: DotEnvProvider,
config: DotEnvConfig,
name: "dotenv",
description: "Traditional .env files",
schemes: ["dotenv"],
examples: ["dotenv://.env", "dotenv://.env.production"],
}
impl DotEnvProvider {
pub fn new(config: DotEnvConfig) -> Self {
Self { config }
}
}
impl Provider for DotEnvProvider {
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn uri(&self) -> String {
let path_str = self.config.path.display().to_string();
if path_str == ".env" {
"dotenv".to_string()
} else {
format!("dotenv:{}", path_str)
}
}
fn get(&self, _project: &str, key: &str, _profile: &str) -> Result<Option<SecretString>> {
if !self.config.path.exists() {
return Ok(None);
}
let mut vars = HashMap::new();
let env_vars = dotenvy::from_path_iter(&self.config.path)?;
for item in env_vars {
let (k, v) = item?;
vars.insert(k, v);
}
Ok(vars.get(key).map(|v| SecretString::new(v.clone().into())))
}
fn set(&self, _project: &str, key: &str, value: &SecretString, _profile: &str) -> Result<()> {
let mut vars = HashMap::new();
if self.config.path.exists() {
let env_vars = dotenvy::from_path_iter(&self.config.path)?;
for item in env_vars {
let (k, v) = item?;
vars.insert(k, v);
}
}
vars.insert(key.to_string(), value.expose_secret().to_string());
let content = serialize_dotenv(&vars);
fs::write(&self.config.path, content)?;
Ok(())
}
fn reflect(&self) -> Result<HashMap<String, crate::config::Secret>> {
use crate::config::Secret;
if !self.config.path.exists() {
return Ok(HashMap::new());
}
if self.config.path.is_dir() {
return Err(SecretSpecError::Io(std::io::Error::new(
std::io::ErrorKind::IsADirectory,
format!(
"Expected file but found directory: {}",
self.config.path.display()
),
)));
}
let mut secrets = HashMap::new();
let env_vars = dotenvy::from_path_iter(&self.config.path)?;
for item in env_vars {
let (key, _value) = item?;
secrets.insert(
key.clone(),
Secret {
description: Some(format!("{} secret", key)),
required: Some(true),
..Default::default()
},
);
}
Ok(secrets)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dotenv_url_parsing() {
use url::Url;
let url = ProviderUrl::new(Url::parse("dotenv:///tmp/test/.env").unwrap());
let config: DotEnvConfig = (&url).try_into().unwrap();
assert_eq!(config.path.to_str().unwrap(), "/tmp/test/.env");
let url = ProviderUrl::new(Url::parse("dotenv://.env").unwrap());
let config: DotEnvConfig = (&url).try_into().unwrap();
assert_eq!(config.path.to_str().unwrap(), ".env");
let url = ProviderUrl::new(Url::parse("dotenv://config/.env.local").unwrap());
let config: DotEnvConfig = (&url).try_into().unwrap();
assert_eq!(config.path.to_str().unwrap(), "config/.env.local");
let url = ProviderUrl::new(Url::parse("dotenv://").unwrap());
let config: DotEnvConfig = (&url).try_into().unwrap();
assert_eq!(config.path.to_str().unwrap(), ".env");
let url = ProviderUrl::new(Url::parse("dotenv://foobar/custom/path/.env").unwrap());
let config: DotEnvConfig = (&url).try_into().unwrap();
assert_eq!(config.path.to_str().unwrap(), "foobar/custom/path/.env");
}
#[test]
fn test_default_config() {
let config = DotEnvConfig::default();
assert_eq!(config.path.to_str().unwrap(), ".env");
}
#[test]
fn test_reflect() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let env_file = dir.path().join(".env");
let mut file = std::fs::File::create(&env_file).unwrap();
writeln!(file, "API_KEY=test123").unwrap();
writeln!(file, "DATABASE_URL=postgres://localhost").unwrap();
let provider = DotEnvProvider::new(DotEnvConfig {
path: env_file.clone(),
});
let secrets = provider.reflect().unwrap();
assert_eq!(secrets.len(), 2);
assert!(secrets.contains_key("API_KEY"));
assert!(secrets.contains_key("DATABASE_URL"));
let api_key_config = &secrets["API_KEY"];
assert_eq!(
api_key_config.description,
Some("API_KEY secret".to_string())
);
assert_eq!(api_key_config.required, Some(true));
assert!(api_key_config.default.is_none());
}
#[test]
fn test_reflect_nonexistent_file() {
let provider = DotEnvProvider::new(DotEnvConfig {
path: PathBuf::from("/tmp/nonexistent/.env"),
});
let secrets = provider.reflect().unwrap();
assert!(secrets.is_empty());
}
#[test]
fn test_serialize_dotenv_escapes() {
let mut vars = HashMap::new();
vars.insert("PLAIN".to_string(), "hello".to_string());
vars.insert("QUOTES".to_string(), r#"{"a":"b"}"#.to_string());
vars.insert("BACKSLASH".to_string(), r"C:\path\to".to_string());
vars.insert("DOLLAR".to_string(), "$VAR".to_string());
vars.insert("NEWLINE".to_string(), "line1\nline2".to_string());
let out = serialize_dotenv(&vars);
assert_eq!(
out,
concat!(
"BACKSLASH=\"C:\\\\path\\\\to\"\n",
"DOLLAR=\"\\$VAR\"\n",
"NEWLINE=\"line1\\nline2\"\n",
"PLAIN=\"hello\"\n",
"QUOTES=\"{\\\"a\\\":\\\"b\\\"}\"\n",
)
);
}
#[test]
fn test_set_roundtrips_special_characters() {
let dir = tempfile::tempdir().unwrap();
let env_file = dir.path().join(".env");
let provider = DotEnvProvider::new(DotEnvConfig {
path: env_file.clone(),
});
let cases = [
("PLAIN", "hello world"),
("QUOTES", r#"{"a":"b"}"#),
("LEADING_QUOTE", r#""leading"#),
("TRAILING_QUOTE", r#"trailing""#),
("BACKSLASH", r"C:\path\to"),
("BACKSLASH_BEFORE_QUOTE", r#"a\"b"#),
("BACKSLASH_BEFORE_DOLLAR", r"a\$b"),
("DOLLAR_VAR", "literal $VAR not expanded"),
("DOLLAR_BRACED", "literal ${VAR} not expanded"),
("DOLLAR_ONLY", "$"),
("HASH", "value with # not a comment"),
("SINGLE_QUOTE", "it's literal"),
("EQUALS", "k=v=more"),
("NEWLINE", "line1\nline2"),
("MIXED", "a\\b\"c$d\ne"),
("UNICODE", "café — 🚀"),
("WHITESPACE_EDGES", " spaced "),
("EMPTY", ""),
];
for (k, v) in cases {
provider
.set("proj", k, &SecretString::new(v.into()), "default")
.unwrap();
}
for (k, v) in cases {
let got = provider.get("proj", k, "default").unwrap();
assert_eq!(
got.map(|s| s.expose_secret().to_string()),
Some(v.to_string()),
"round-trip failed for {k}",
);
}
}
#[test]
fn test_set_preserves_existing_quoted_json_value() {
let dir = tempfile::tempdir().unwrap();
let env_file = dir.path().join(".env");
fs::write(&env_file, "FOO=\"{\\\"bar\\\":\\\"baz\\\"}\"\n").unwrap();
let provider = DotEnvProvider::new(DotEnvConfig {
path: env_file.clone(),
});
provider
.set(
"proj",
"BAR",
&SecretString::new("foobar".into()),
"default",
)
.unwrap();
let foo = provider.get("proj", "FOO", "default").unwrap();
assert_eq!(
foo.map(|s| s.expose_secret().to_string()),
Some(r#"{"bar":"baz"}"#.to_string()),
);
let bar = provider.get("proj", "BAR", "default").unwrap();
assert_eq!(
bar.map(|s| s.expose_secret().to_string()),
Some("foobar".to_string()),
);
}
}