use std::fs;
use std::io::Read;
use std::path::Path;
use crate::error::Error;
pub(super) 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)]
pub(super) 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))]
pub(super) fn read_envseal_nofollow(path: &Path) -> Result<Vec<EnvMapping>, Error> {
if !is_non_symlink_regular_file(path) {
return Err(Error::StorageIo(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
".envseal at {} is a symlink or non-regular file; refusing to follow",
path.display()
),
)));
}
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].trim().is_empty() || parts[1].trim().is_empty() {
return Err(Error::PolicyParse(format!(
"{}:{}: invalid mapping '{}' — expected ENV_VAR=secret-name",
path_for_errors.display(),
line_num + 1,
trimmed
)));
}
let env_var = parts[0].trim().to_string();
let secret_name = parts[1].trim().to_string();
if let Err(e) = crate::inject::validate_env_var_name(&env_var) {
return Err(Error::PolicyParse(format!(
"{}:{}: env var name '{env_var}' is not allowed: {e}",
path_for_errors.display(),
line_num + 1,
)));
}
if looks_like_secret_value(&secret_name) {
return Err(Error::PolicyParse(format!(
"{}:{}: the right-hand side '{}' looks like a secret value, not a secret name. \
.envseal files should contain vault secret names (e.g. 'openai-key'), \
never the secret value itself. Store the value with `envseal store <name>`.",
path_for_errors.display(),
line_num + 1,
secret_name,
)));
}
mappings.push(EnvMapping {
env_var,
secret_name,
});
}
Ok(mappings)
}
fn looks_like_secret_value(s: &str) -> bool {
const SECRET_PREFIXES: &[&str] = &[
"sk-",
"sk_live_",
"sk_test_",
"ghp_",
"github_pat_",
"glpat-",
"glpt-",
"hf_",
"xai-",
"AKIA",
"ASIA",
"SG.",
"rk_live_",
"rk_test_",
"dp.",
"dapi",
"shpat_",
"key-",
"api_key_",
"eyJ", ];
for prefix in SECRET_PREFIXES {
if s.starts_with(prefix) {
return true;
}
}
if s.len() > 20 && s.chars().filter(char::is_ascii_punctuation).count() > 4 {
return true;
}
false
}
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)
}
#[must_use]
pub fn secret_name_to_env_var(secret_name: &str) -> String {
secret_name.replace('-', "_").to_uppercase()
}
#[must_use]
pub fn env_var_to_secret_name(env_var: &str) -> String {
env_var.to_lowercase().replace('_', "-")
}
#[must_use]
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 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);
}
}