envseal 0.3.5

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! `.envseal` file format — drop-in `.env` replacement.
//!
//! A `.envseal` file maps environment variable names to vault secret names.
//! Unlike `.env`, it contains NO secret values — only references.
//!
//! # Format
//!
//! ```text
//! # .envseal — secret references (safe to commit to git)
//! DATABASE_URL=database-url
//! REDIS_URL=redis-url
//! CLOUDFLARE_API_TOKEN=cloudflare-api
//! OPENAI_API_KEY=openai-key
//! ```
//!
//! Each line is `ENV_VAR=secret-name`. The secret-name references a secret
//! stored in the envseal vault. The actual values never appear in this file.
//!
//! # Usage
//!
//! ```text
//! envseal inject-file .envseal -- npm run dev
//! ```
//!
//! This replaces `dotenv` entirely. The `.envseal` file is safe to commit
//! to version control — it contains no secrets.

use std::fs;
use std::io::Read;
use std::path::Path;

use crate::error::Error;

/// A regular file at `path` (not a symlink) suitable for `.envseal` bindings.
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)
}

/// A parsed mapping from a `.envseal` file.
#[derive(Debug, Clone)]
pub struct EnvMapping {
    /// The environment variable name (e.g. `DATABASE_URL`).
    pub env_var: String,
    /// The vault secret name (e.g. `database-url`).
    pub secret_name: String,
}

/// Parse `.envseal` content (already read from disk).
///
/// `path_for_errors` is only used in parse error messages.
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();

        // Skip empty lines and comments
        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)
}

/// Parse a `.envseal` from an already-open `O_NOFOLLOW` file (avoids read-by-path TOCTOU).
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)
}

/// Parse a `.envseal` file into a list of mappings.
///
/// Blank lines and lines starting with `#` are ignored.
/// Each mapping line has the format `ENV_VAR=secret-name`.
pub fn parse_envseal_file(path: &Path) -> Result<Vec<EnvMapping>, Error> {
    read_envseal_nofollow(path)
}

/// Discover a `.envseal` file by walking up from `start_dir` to filesystem root.
///
/// Returns the path to the first `.envseal` file found, or `None`.
/// This mimics how git discovers `.git` — walk up until you find it.
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
}

/// Get the global `~/.envseal` path (user-wide bindings for agent CLIs etc.).
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))
}

/// Discover and load mappings: project `.envseal` (merged with global `~/.envseal`).
///
/// Discovery order:
/// 1. Walk up from `start_dir` looking for `.envseal`
/// 2. Load global `~/.envseal` if it exists
/// 3. Merge: project mappings override global on env var name conflict
///
/// Returns `None` if no `.envseal` file found anywhere.
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();

    // Load global first (lower priority)
    if let Some(ref global_path) = global_file {
        mappings = read_envseal_nofollow(global_path)?;
    }

    // Load project (higher priority — overrides global on conflict)
    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))
    }
}

/// Merge `overrides` into `base`, replacing entries with matching `env_var`.
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());
        }
    }
}

/// Convert a vault secret name to its conventional env var name.
///
/// `openai-api-key` → `OPENAI_API_KEY`
/// `database-url` → `DATABASE_URL`
pub fn secret_name_to_env_var(secret_name: &str) -> String {
    secret_name.replace('-', "_").to_uppercase()
}

/// Convert an env var name to a vault secret name.
///
/// `OPENAI_API_KEY` → `openai-api-key`
/// `DATABASE_URL` → `database-url`
pub fn env_var_to_secret_name(env_var: &str) -> String {
    env_var.to_lowercase().replace('_', "-")
}

/// Auto-generate mappings from all vault secrets using naming convention.
///
/// Used when no `.envseal` file exists — maps every secret to its
/// conventional env var name (e.g. `openai-key` → `OPENAI_KEY`).
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()
}

// ───────────────────────────────────────────────────────────────
//  Tests
// ───────────────────────────────────────────────────────────────

#[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();
        // Place .envseal at the top
        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);
    }
}