envseal 0.3.8

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! Parse `.envseal` files into a list of [`EnvMapping`] entries, plus the
//! `ENV_VAR` ↔ `secret-name` naming-convention helpers.

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.
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())
}

/// Open a `.envseal` file with `O_NOFOLLOW`/`O_CLOEXEC` (Unix) or a plain
/// no-follow read on platforms without those flags, then parse.
#[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)
}

/// 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.
///
/// # Errors
/// Returns [`Error::PolicyParse`] for any malformed mapping line.
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].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();

        // Reject env-var names that would weaponize the dynamic linker /
        // language runtime if a vault secret were assigned to them. This is
        // the same blocklist `inject::validate_env_var_name` enforces — we
        // catch it at parse time so a malicious `.envseal` line never even
        // reaches the inject path.
        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,
            )));
        }

        // UX lint: if the secret-name side looks like an actual secret
        // value, warn the user so they don't accidentally commit real
        // credentials to git inside a `.envseal` file.
        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)
}

/// Heuristic check: does a string look like a secret value rather
/// than a kebab-case secret name? Catches the common mistake of
/// writing `OPENAI_API_KEY=sk-...` inside a `.envseal` file.
fn looks_like_secret_value(s: &str) -> bool {
    // High-entropy prefixes for common secret formats
    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", // JWT header base64
    ];
    for prefix in SECRET_PREFIXES {
        if s.starts_with(prefix) {
            return true;
        }
    }
    // If it contains common secret delimiters or high-entropy patterns
    if s.len() > 20 && s.chars().filter(char::is_ascii_punctuation).count() > 4 {
        return true;
    }
    false
}

/// Parse a `.envseal` from an already-open `O_NOFOLLOW` file (avoids
/// read-by-path TOCTOU).
///
/// # Errors
/// Returns [`Error::StorageIo`] on read failure or [`Error::PolicyParse`]
/// on malformed content.
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`.
///
/// # Errors
/// Returns [`Error::StorageIo`] on read failure or [`Error::PolicyParse`]
/// on malformed content.
pub fn parse_envseal_file(path: &Path) -> Result<Vec<EnvMapping>, Error> {
    read_envseal_nofollow(path)
}

/// Convert a vault secret name to its conventional env var name.
///
/// `openai-api-key` → `OPENAI_API_KEY`, `database-url` → `DATABASE_URL`.
#[must_use]
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`.
#[must_use]
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`).
#[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);
    }
}