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> {
let mut file = super::atomic_open::open_read_no_traverse(path)?;
parse_envseal_from_open_file(path, &mut file)
}
#[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 (line {} bytes after trim) — expected ENV_VAR=secret-name",
path_for_errors.display(),
line_num + 1,
trimmed.len(),
)));
}
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,
redact_secret_shape(&secret_name),
)));
}
mappings.push(EnvMapping {
env_var,
secret_name,
});
}
Ok(mappings)
}
fn redact_secret_shape(s: &str) -> String {
let n = s.chars().count();
if n <= 4 {
return format!("(redacted, {n} chars)");
}
let head: String = s.chars().take(2).collect();
let tail: String = s.chars().skip(n.saturating_sub(2)).collect();
format!("'{head}…{tail}' ({n} chars)")
}
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> {
const MAX_ENVSEAL_BYTES: u64 = 1024 * 1024;
let mut content = String::new();
file.take(MAX_ENVSEAL_BYTES)
.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);
}
#[test]
fn parse_error_does_not_echo_raw_line() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(".envseal");
std::fs::write(&path, "this-is-not-a-mapping-and-might-be-a-secret\n").unwrap();
let err = parse_envseal_file(&path).unwrap_err();
let msg = format!("{err}");
assert!(
!msg.contains("this-is-not-a-mapping-and-might-be-a-secret"),
"raw line bytes leaked into error: {msg}"
);
assert!(msg.contains("invalid mapping"));
}
#[test]
fn parse_error_redacts_secret_shaped_rhs() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(".envseal");
std::fs::write(&path, "OPENAI_KEY=sk-rRealSecret123XYZ\n").unwrap();
let err = parse_envseal_file(&path).unwrap_err();
let msg = format!("{err}");
assert!(
!msg.contains("rRealSecret123"),
"secret middle leaked into error: {msg}"
);
assert!(msg.contains("looks like a secret"));
}
#[test]
fn redact_secret_shape_handles_non_ascii() {
let _ = redact_secret_shape("€€€€€€€€");
let _ = redact_secret_shape("🔑🔒🔓");
}
}