envseal 0.3.12

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> {
    // Audit C1 / H7: open atomically with FILE_FLAG_OPEN_REPARSE_POINT
    // (Windows) so the open and the no-symlink/no-junction check are
    // bound to the same handle — eliminates the TOCTOU window between
    // a stat-based check and the read.
    let mut file = super::atomic_open::open_read_no_traverse(path)?;
    parse_envseal_from_open_file(path, &mut file)
}

/// 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() {
            // Audit C5: never echo the raw line into the error string.
            // A user who accidentally types `OPENAI_KEY=sk-real-secret`
            // into .envseal and submits it to CI / a bug report would
            // otherwise have the secret laundered straight into the
            // error stream. Surface only line number + a coarse length
            // bucket so the user can find the line themselves.
            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();

        // 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. The env-var name itself is not secret
        // (it's the LHS of `KEY=value`), so echoing it back is safe and
        // actionable for the user.
        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.
        //
        // Audit C5: must NOT echo the candidate-secret back. We surface
        // only a redacted shape (length bucket + first-2 / last-2
        // chars) so the user can locate the line in their source
        // without the error string itself becoming a fresh exposure.
        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)
}

/// Redact a candidate-secret to a shape descriptor: length bucket
/// plus a 2-char head and 2-char tail of the *byte* slice (so
/// non-ASCII does not panic on a codepoint boundary). Used in the
/// "looks like a secret" error so the message is actionable without
/// re-exposing the secret.
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)")
}

/// 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> {
    /// Audit L11: cap the read at 1 MiB. `.envseal` files in
    /// real-world use are tiny (dozens of mapping lines, well under
    /// 8 KiB); a 1 MiB cap is 100x larger than any sane file and
    /// prevents an attacker who can substitute the file with
    /// `/dev/zero` from making envseal allocate gigabytes.
    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)
}

/// 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);
    }

    #[test]
    fn parse_error_does_not_echo_raw_line() {
        // A user accidentally typing the secret value on the LHS or
        // omitting `=` must NOT have the raw bytes reflected in the
        // error message. Audit C5.
        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() {
        // Pretend a user did `OPENAI_KEY=sk-rRealSecret123XYZ` instead
        // of using a vault name. The error must mention the shape
        // (length, head, tail) but NOT the middle of the string.
        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() {
        // Must not panic on multibyte codepoint boundaries.
        let _ = redact_secret_shape("€€€€€€€€");
        let _ = redact_secret_shape("🔑🔒🔓");
    }
}