use std::fs;
use std::io::Read;
use std::path::Path;
use crate::error::Error;
fn is_non_symlink_regular_file(path: &Path) -> bool {
fs::symlink_metadata(path).is_ok_and(|m| m.is_file() && !m.file_type().is_symlink())
}
#[cfg(unix)]
fn read_envseal_nofollow(path: &Path) -> Result<Vec<EnvMapping>, Error> {
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(path)
.map_err(|e| {
Error::StorageIo(std::io::Error::new(
e.kind(),
format!("failed to open .envseal at {}: {e}", path.display()),
))
})?;
parse_envseal_from_open_file(path, &mut file)
}
#[cfg(not(unix))]
fn read_envseal_nofollow(path: &Path) -> Result<Vec<EnvMapping>, Error> {
let content = fs::read_to_string(path).map_err(|e| {
Error::StorageIo(std::io::Error::new(
e.kind(),
format!("failed to read .envseal file at {}: {e}", path.display()),
))
})?;
parse_envseal_contents(&content, path)
}
#[derive(Debug, Clone)]
pub struct EnvMapping {
pub env_var: String,
pub secret_name: String,
}
pub fn parse_envseal_contents(content: &str, path_for_errors: &Path) -> Result<Vec<EnvMapping>, Error> {
let mut mappings = Vec::new();
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let parts: Vec<&str> = trimmed.splitn(2, '=').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
return Err(Error::PolicyParse(format!(
"{}:{}: invalid mapping '{}' — expected ENV_VAR=secret-name",
path_for_errors.display(),
line_num + 1,
trimmed
)));
}
mappings.push(EnvMapping {
env_var: parts[0].trim().to_string(),
secret_name: parts[1].trim().to_string(),
});
}
Ok(mappings)
}
pub fn parse_envseal_from_open_file(
path_for_errors: &Path,
file: &mut std::fs::File,
) -> Result<Vec<EnvMapping>, Error> {
let mut content = String::new();
file.read_to_string(&mut content).map_err(|e| {
Error::StorageIo(std::io::Error::new(
e.kind(),
format!("failed to read .envseal file at {}: {e}", path_for_errors.display()),
))
})?;
parse_envseal_contents(&content, path_for_errors)
}
pub fn parse_envseal_file(path: &Path) -> Result<Vec<EnvMapping>, Error> {
read_envseal_nofollow(path)
}
pub fn discover(start_dir: &Path) -> Option<std::path::PathBuf> {
let mut dir = start_dir.to_path_buf();
loop {
let candidate = dir.join(".envseal");
if is_non_symlink_regular_file(&candidate) {
return Some(candidate);
}
if !dir.pop() {
break;
}
}
None
}
pub fn global_envseal_path() -> Option<std::path::PathBuf> {
std::env::var("HOME")
.ok()
.map(|h| std::path::PathBuf::from(h).join(".envseal"))
.filter(|p| is_non_symlink_regular_file(p))
}
pub fn discover_and_load(start_dir: &Path) -> Result<Option<Vec<EnvMapping>>, Error> {
let project_file = discover(start_dir);
let global_file = global_envseal_path();
if project_file.is_none() && global_file.is_none() {
return Ok(None);
}
let mut mappings = Vec::new();
if let Some(ref global_path) = global_file {
mappings = read_envseal_nofollow(global_path)?;
}
if let Some(ref project_path) = project_file {
let project_mappings = read_envseal_nofollow(project_path)?;
merge_mappings(&mut mappings, &project_mappings);
}
if mappings.is_empty() {
Ok(None)
} else {
Ok(Some(mappings))
}
}
fn merge_mappings(base: &mut Vec<EnvMapping>, overrides: &[EnvMapping]) {
for new_mapping in overrides {
if let Some(existing) = base.iter_mut().find(|m| m.env_var == new_mapping.env_var) {
existing.secret_name.clone_from(&new_mapping.secret_name);
} else {
base.push(new_mapping.clone());
}
}
}
pub fn secret_name_to_env_var(secret_name: &str) -> String {
secret_name.replace('-', "_").to_uppercase()
}
pub fn env_var_to_secret_name(env_var: &str) -> String {
env_var.to_lowercase().replace('_', "-")
}
pub fn auto_map_from_names(secret_names: &[String]) -> Vec<EnvMapping> {
secret_names
.iter()
.map(|name| EnvMapping {
env_var: secret_name_to_env_var(name),
secret_name: name.clone(),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secret_name_to_env_var_convention() {
assert_eq!(secret_name_to_env_var("openai-api-key"), "OPENAI_API_KEY");
assert_eq!(secret_name_to_env_var("database-url"), "DATABASE_URL");
assert_eq!(secret_name_to_env_var("stripe-key"), "STRIPE_KEY");
assert_eq!(secret_name_to_env_var("cf-token"), "CF_TOKEN");
}
#[test]
fn env_var_to_secret_name_convention() {
assert_eq!(env_var_to_secret_name("OPENAI_API_KEY"), "openai-api-key");
assert_eq!(env_var_to_secret_name("DATABASE_URL"), "database-url");
}
#[test]
fn auto_map_generates_correct_mappings() {
let names = vec![
"openai-key".to_string(),
"database-url".to_string(),
];
let mappings = auto_map_from_names(&names);
assert_eq!(mappings.len(), 2);
assert_eq!(mappings[0].env_var, "OPENAI_KEY");
assert_eq!(mappings[0].secret_name, "openai-key");
assert_eq!(mappings[1].env_var, "DATABASE_URL");
assert_eq!(mappings[1].secret_name, "database-url");
}
#[test]
fn merge_project_overrides_global() {
let mut base = vec![
EnvMapping { env_var: "A".into(), secret_name: "global-a".into() },
EnvMapping { env_var: "B".into(), secret_name: "global-b".into() },
];
let overrides = vec![
EnvMapping { env_var: "A".into(), secret_name: "project-a".into() },
EnvMapping { env_var: "C".into(), secret_name: "project-c".into() },
];
merge_mappings(&mut base, &overrides);
assert_eq!(base.len(), 3);
assert_eq!(base.iter().find(|m| m.env_var == "A").unwrap().secret_name, "project-a");
assert_eq!(base.iter().find(|m| m.env_var == "B").unwrap().secret_name, "global-b");
assert_eq!(base.iter().find(|m| m.env_var == "C").unwrap().secret_name, "project-c");
}
#[test]
fn discover_walks_up_directories() {
let tmp = tempfile::tempdir().unwrap();
let deep = tmp.path().join("a").join("b").join("c");
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(tmp.path().join(".envseal"), "X=y\n").unwrap();
let found = discover(&deep);
assert!(found.is_some());
assert_eq!(found.unwrap(), tmp.path().join(".envseal"));
}
#[test]
fn discover_returns_none_when_missing() {
let tmp = tempfile::tempdir().unwrap();
let deep = tmp.path().join("a").join("b");
std::fs::create_dir_all(&deep).unwrap();
assert!(discover(&deep).is_none());
}
#[test]
fn parse_envseal_file_basic() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(".envseal");
std::fs::write(&path, "# comment\nOPENAI_KEY=openai-key\nDB_URL=database-url\n").unwrap();
let mappings = parse_envseal_file(&path).unwrap();
assert_eq!(mappings.len(), 2);
assert_eq!(mappings[0].env_var, "OPENAI_KEY");
assert_eq!(mappings[0].secret_name, "openai-key");
assert_eq!(mappings[1].env_var, "DB_URL");
assert_eq!(mappings[1].secret_name, "database-url");
}
#[test]
fn roundtrip_naming_convention() {
let original = "OPENAI_API_KEY";
let secret = env_var_to_secret_name(original);
let back = secret_name_to_env_var(&secret);
assert_eq!(back, original);
}
}