use std::collections::BTreeMap;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use serde::Deserialize;
use serde::Serialize;
use super::Address;
use super::DiscoveryContext;
use super::Provider;
use super::ProviderUrl;
use crate::MonosecretError;
use crate::Result;
use crate::SecretBytes;
use crate::config::expand_tilde;
pub(crate) fn serialize_dotenv(vars: &HashMap<String, String>) -> Result<String> {
let sorted: BTreeMap<&str, &str> = vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
serialize_dotenv_pairs(sorted.into_iter())
}
pub(crate) fn serialize_dotenv_pairs<'a>(
pairs: impl Iterator<Item = (&'a str, &'a str)>,
) -> Result<String> {
let mut out = dotenv::render(pairs)?;
if !out.is_empty() {
out.push('\n');
}
Ok(out)
}
fn validate_env_key(key: &str, addr: Address<'_>) -> Result<()> {
dotenv::render_var(key, "").map(|_| ()).map_err(|error| {
let rename = match addr {
Address::Convention { .. } => "Rename the secret in monosecret.toml",
Address::Native(_) => "Rename the `ref` item",
};
MonosecretError::ProviderOperationFailed(format!(
"the dotenv provider cannot store `{key}`: {error}. {rename} to a valid name."
))
})
}
fn load_dotenv(path: &std::path::Path) -> Result<HashMap<String, String>> {
Ok(dotenv::EnvLoader::with_path(path)
.sequence(dotenv::EnvSequence::InputOnly)
.load()?
.into_iter()
.collect())
}
#[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 = MonosecretError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
if url.scheme() != "dotenv" {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Invalid scheme '{}' for dotenv provider",
url.scheme()
)));
}
let path_str = url.path();
let path = if !path_str.is_empty() && 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"],
deletes: true,
}
impl DotEnvProvider {
pub fn new(mut config: DotEnvConfig) -> Self {
config.path = expand_tilde(config.path);
Self { config }
}
}
impl Provider for DotEnvProvider {
fn convention_address(
&self,
_project: &str,
_profile: &str,
key: &str,
) -> Result<crate::config::NativeAddress> {
Ok(crate::config::NativeAddress {
item: key.to_string(),
..Default::default()
})
}
fn name(&self) -> &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 physical_store_path(&self) -> Option<&std::path::Path> {
Some(&self.config.path)
}
fn with_base_dir(&mut self, base_dir: &std::path::Path) {
if self.config.path.is_relative() {
self.config.path = base_dir.join(&self.config.path);
}
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretBytes>> {
let lookup = super::flat_item(self, addr)?;
validate_env_key(&lookup, addr)?;
if !self.config.path.exists() {
return Ok(None);
}
let vars = load_dotenv(&self.config.path)?;
Ok(vars
.get(&*lookup)
.map(|v| SecretBytes::from_utf8(v.clone())))
}
fn check_writable(&self, addr: Address<'_>) -> Result<()> {
validate_env_key(&super::flat_item(self, addr)?, addr)
}
fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> {
let value = super::require_utf8("dotenv", value)?;
let target = super::flat_item(self, addr)?;
validate_env_key(&target, addr)?;
let mut vars = if self.config.path.exists() {
load_dotenv(&self.config.path)?
} else {
HashMap::new()
};
vars.insert(target.into_owned(), value.to_string());
let content = serialize_dotenv(&vars)?;
fs::write(&self.config.path, content)?;
Ok(())
}
fn delete(&self, addr: Address<'_>) -> Result<bool> {
let target = super::flat_item(self, addr)?;
validate_env_key(&target, addr)?;
if !self.config.path.exists() {
return Ok(false);
}
let mut vars = load_dotenv(&self.config.path)?;
if vars.remove(target.as_ref()).is_none() {
return Ok(false);
}
fs::write(&self.config.path, serialize_dotenv(&vars)?)?;
Ok(true)
}
fn supports_delete(&self) -> bool {
true
}
fn check_deletable(&self, addr: Address<'_>) -> Result<()> {
self.check_writable(addr)
}
fn reflect(&self, _context: DiscoveryContext<'_>) -> Result<HashMap<String, crate::Secret>> {
if !self.config.path.exists() {
return Ok(HashMap::new());
}
if self.config.path.is_dir() {
return Err(MonosecretError::Io(std::io::Error::new(
std::io::ErrorKind::IsADirectory,
format!(
"Expected file but found directory: {}",
self.config.path.display()
),
)));
}
let mut secrets = HashMap::new();
for (key, _value) in load_dotenv(&self.config.path)? {
secrets.insert(
key.clone(),
crate::Secret::required(format!("{key} secret")),
);
}
Ok(secrets)
}
}
#[cfg(test)]
#[allow(clippy::indexing_slicing)] 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");
let url = ProviderUrl::new(Url::parse("dotenv://C%3A%5CUsers%5Cfoo%5C.env").unwrap());
let config: DotEnvConfig = (&url).try_into().unwrap();
assert_eq!(config.path.to_str().unwrap(), r"C:\Users\foo\.env");
}
#[test]
fn test_default_config() {
let config = DotEnvConfig::default();
assert_eq!(config.path.to_str().unwrap(), ".env");
}
#[test]
fn test_with_base_dir_rebases_relative_paths() {
let base = std::path::Path::new("/project/root");
let mut provider = DotEnvProvider::new(DotEnvConfig {
path: PathBuf::from(".config/.env"),
});
provider.with_base_dir(base);
assert_eq!(provider.config.path, base.join(".config/.env"));
let mut provider = DotEnvProvider::new(DotEnvConfig::default());
provider.with_base_dir(base);
assert_eq!(provider.config.path, base.join(".env"));
let absolute = PathBuf::from("/etc/secrets/.env");
let mut provider = DotEnvProvider::new(DotEnvConfig {
path: absolute.clone(),
});
provider.with_base_dir(base);
assert_eq!(provider.config.path, absolute);
}
#[test]
fn test_home_relative_provider_paths_expand_before_rebasing() {
let Some(home) = etcetera::home_dir()
.ok()
.or_else(|| std::env::var_os("HOME").map(PathBuf::from))
else {
return;
};
let expected = home.join(".config/project/.env");
for spec in [
"dotenv:~/.config/project/.env",
"dotenv://~/.config/project/.env",
] {
let mut provider = Box::<dyn Provider>::try_from(spec).unwrap();
provider.with_base_dir(std::path::Path::new("/project/root"));
assert_eq!(provider.physical_store_path(), Some(expected.as_path()));
}
}
#[test]
fn test_get_reads_relative_path_from_base_dir_not_cwd() {
use std::io::Write;
let root = tempfile::tempdir().unwrap();
let env_dir = root.path().join(".config");
fs::create_dir(&env_dir).unwrap();
let mut file = fs::File::create(env_dir.join(".env")).unwrap();
writeln!(file, "USER=hello").unwrap();
let mut provider = DotEnvProvider::new(DotEnvConfig {
path: PathBuf::from(".config/.env"),
});
provider.with_base_dir(root.path());
let value = provider
.get(Address::convention("hello-world", "default", "USER"))
.unwrap();
assert_eq!(value.unwrap().expose_secret(), b"hello");
}
#[test]
fn test_reflect() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let env_file = dir.path().join(".env");
let mut file = 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(DiscoveryContext::new("project", "default"))
.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(), "API_KEY secret");
assert_eq!(api_key_config.required_setting(), Some(true));
}
#[test]
fn test_reflect_nonexistent_file() {
let provider = DotEnvProvider::new(DotEnvConfig {
path: PathBuf::from("/tmp/nonexistent/.env"),
});
let secrets = provider
.reflect(DiscoveryContext::new("project", "default"))
.unwrap();
assert!(secrets.is_empty());
}
#[test]
fn test_serialize_dotenv_uses_minimal_round_trip_quoting() {
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).unwrap();
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(
Address::convention("proj", "default", k),
&SecretBytes::from_utf8(v),
)
.unwrap();
}
for (k, v) in cases {
let got = provider
.get(Address::convention("proj", "default", k))
.unwrap();
assert_eq!(
got.map(|s| s.try_as_utf8().unwrap().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(
Address::convention("proj", "default", "BAR"),
&SecretBytes::from_utf8("foobar"),
)
.unwrap();
let foo = provider
.get(Address::convention("proj", "default", "FOO"))
.unwrap();
assert_eq!(
foo.map(|s| s.try_as_utf8().unwrap().to_string()),
Some(r#"{"bar":"baz"}"#.to_string()),
);
let bar = provider
.get(Address::convention("proj", "default", "BAR"))
.unwrap();
assert_eq!(
bar.map(|s| s.try_as_utf8().unwrap().to_string()),
Some("foobar".to_string()),
);
}
#[test]
fn test_get_preserves_quoted_bcrypt_fragments() {
const VALUE: &str = "foo:$2a$10$TWoviNHS27HJMw1PKe4tBeIMlms6tWdYS9hKoHANKCQhluDlEt/gu,bar:$2a$10$labXlt9fBRMjJu.gOUabjebLVBKGB/xZOFpEn/esCln56USXHMHQW";
let dir = tempfile::tempdir().unwrap();
let env_file = dir.path().join(".env");
fs::write(&env_file, format!("TEST=\"{VALUE}\"\n")).unwrap();
let provider = DotEnvProvider::new(DotEnvConfig { path: env_file });
let value = provider
.get(Address::convention("test", "default", "TEST"))
.unwrap()
.unwrap();
assert_eq!(value.expose_secret(), VALUE.as_bytes());
}
#[test]
fn native_address_reads_and_writes_the_named_key() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join(".env");
let provider = DotEnvProvider::new(DotEnvConfig { path: path.clone() });
let addr = crate::config::NativeAddress {
item: "PINNED_KEY".into(),
..Default::default()
};
provider
.set(Address::Native(&addr), &SecretBytes::from_utf8("v1"))
.unwrap();
let got = provider.get(Address::Native(&addr)).unwrap();
assert_eq!(
got.map(|s| s.try_as_utf8().unwrap().to_string()),
Some("v1".into())
);
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains("PINNED_KEY="), "wrote: {contents}");
}
#[test]
fn rejects_names_the_env_format_cannot_represent() {
let dir = tempfile::tempdir().unwrap();
let env_file = dir.path().join(".env");
let provider = DotEnvProvider::new(DotEnvConfig {
path: env_file.clone(),
});
provider
.set(
Address::convention("proj", "default", "KEEP"),
&SecretBytes::from_utf8("kept"),
)
.unwrap();
for bad in ["with space", "HAS=EQUALS", "HAS#HASH", "HAS\nNEWLINE", ""] {
let addr = crate::config::NativeAddress {
item: bad.into(),
..Default::default()
};
for result in [
provider.set(Address::Native(&addr), &SecretBytes::from_utf8("v")),
provider.get(Address::Native(&addr)).map(|_| ()),
provider.check_writable(Address::Native(&addr)),
] {
let err = result.unwrap_err();
assert!(err.to_string().contains("variable name"), "`{bad}`: {err}");
}
}
let kept = provider
.get(Address::convention("proj", "default", "KEEP"))
.unwrap();
assert_eq!(
kept.map(|s| s.try_as_utf8().unwrap().to_string()),
Some("kept".to_string())
);
for good in [
"CACHIX_SIGNING_KEY_cache-a",
"1LEADING_DIGIT",
".leading.dot",
"dotted.name",
"café",
] {
let addr = crate::config::NativeAddress {
item: good.into(),
..Default::default()
};
provider
.set(Address::Native(&addr), &SecretBytes::from_utf8("key"))
.unwrap();
let got = provider.get(Address::Native(&addr)).unwrap();
assert_eq!(
got.map(|s| s.try_as_utf8().unwrap().to_string()),
Some("key".to_string()),
"`{good}` should round-trip"
);
}
}
#[test]
fn native_address_rejects_field() {
let provider = DotEnvProvider::new(DotEnvConfig::default());
let addr = crate::config::NativeAddress {
item: "KEY".into(),
field: Some("x".into()),
..Default::default()
};
let err = provider.get(Address::Native(&addr)).unwrap_err();
assert!(err.to_string().contains("`field`"), "{err}");
}
#[test]
fn a_rejected_convention_name_points_at_the_manifest() {
let provider = DotEnvProvider::new(DotEnvConfig::default());
let err = provider
.get(Address::convention("proj", "default", "invalid name"))
.unwrap_err()
.to_string();
assert!(
err.contains("Rename the secret in monosecret.toml"),
"{err}"
);
assert!(!err.contains("`ref`"), "{err}");
}
#[test]
fn a_rejected_ref_item_points_at_the_ref() {
let provider = DotEnvProvider::new(DotEnvConfig::default());
let addr = crate::config::NativeAddress {
item: "not=a-legal-env-name".into(),
..Default::default()
};
let err = provider
.get(Address::Native(&addr))
.unwrap_err()
.to_string();
assert!(err.contains("Rename the `ref` item"), "{err}");
}
#[test]
fn a_rejected_write_never_names_the_value() {
let provider = DotEnvProvider::new(DotEnvConfig::default());
let err = provider
.set(
Address::convention("proj", "default", "invalid name"),
&SecretBytes::from("s3cr3t-plaintext"),
)
.unwrap_err()
.to_string();
assert!(err.contains("invalid name"), "{err}");
assert!(!err.contains("s3cr3t-plaintext"), "{err}");
}
}