envseal 0.3.8

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! Vault governance operations: access-policy inspection/revocation
//! and Git pre-commit hook installation.
//!
//! Both surfaces enforce or surface vault-level *rules* (rather than
//! mutate secrets), so they cluster together. Re-exported from
//! [`super`] so existing call sites resolve unchanged.

use std::path::{Path, PathBuf};

use crate::error::Error;
use crate::policy::Policy;
use crate::secret_health;
use crate::vault::Vault;

/// Serializable policy rule view.
#[derive(Debug, Clone)]
pub struct PolicyRuleView {
    /// Absolute path to the authorized binary.
    pub binary: String,
    /// Secret name, or `"*"` for all secrets.
    pub secret: String,
    /// Scope label: `"key"` or `"all"`.
    pub scope: String,
}

/// Load policy rules from the default vault.
///
/// Uses `load_verified` to detect tampering before displaying rules.
/// A tampered policy file is surfaced as an error rather than silently
/// showing potentially attacker-injected rules.
pub fn policy_show() -> Result<Vec<PolicyRuleView>, Error> {
    let vault = Vault::open_default()?;
    policy_show_in(&vault)
}

/// Same as [`policy_show`] but uses an already-unlocked [`Vault`]
/// handle. Avoids re-prompting for the passphrase when the caller
/// (e.g. the desktop GUI worker) is already past unlock — calling
/// the prompting variant from a refresh cycle creates an infinite
/// passphrase-popup loop, which is exactly the bug this exists to
/// avoid.
pub fn policy_show_in(vault: &Vault) -> Result<Vec<PolicyRuleView>, Error> {
    let policy_path = vault.policy_path();
    // `load_sealed` checks both `policy.sealed` and the legacy
    // `policy.toml`, returning `Policy::default()` when neither
    // exists — drop the manual `if exists` gate to avoid skipping
    // tamper detection on a sealed-only vault.
    let policy = Policy::load_sealed(&policy_path, vault.master_key_bytes())?;
    Ok(policy
        .rules
        .iter()
        .map(|r| PolicyRuleView {
            binary: r.binary.clone(),
            secret: r.secret.clone(),
            scope: format!("{:?}", r.scope).to_lowercase(),
        })
        .collect())
}

/// Revoke all rules for a binary in default vault policy.
///
/// The `binary` argument is canonicalized before matching so that
/// relative paths, symlinks, and `..` components cannot silently
/// miss stored rules (which are always stored as canonical absolute paths).
///
/// Uses `load_verified` to prevent trust-laundering: an attacker who
/// tampers `policy.toml` and triggers this op would otherwise get the
/// tampered rules re-signed as legitimate by envseal.
pub fn policy_revoke_binary(binary: &str) -> Result<(), Error> {
    let vault = Vault::open_default()?;
    policy_revoke_binary_in(&vault, binary)
}

/// Same as [`policy_revoke_binary`] but uses an already-unlocked
/// [`Vault`] handle.
pub fn policy_revoke_binary_in(vault: &Vault, binary: &str) -> Result<(), Error> {
    // Normalize to canonical absolute path so the match is reliable.
    let canonical = std::path::Path::new(binary).canonicalize().map_err(|e| {
        Error::BinaryResolution(format!(
            "cannot canonicalize '{binary}' for policy revoke: {e}. \
             Fix: provide the absolute path to the binary as stored in 'envseal policy show'."
        ))
    })?;
    let canonical_str = canonical.to_string_lossy().into_owned();

    let policy_path = vault.policy_path();
    let mut policy = Policy::load_sealed(&policy_path, vault.master_key_bytes())?;
    policy.revoke_binary(&canonical_str);
    policy.save_sealed(&policy_path, vault.master_key_bytes())?;
    Ok(())
}

/// Install a pre-commit hook in the current Git repository.
pub fn git_hook_install() -> Result<PathBuf, Error> {
    let hook_dir = Path::new(".git/hooks");
    if !hook_dir.exists() {
        return Err(Error::StorageIo(std::io::Error::new(
            std::io::ErrorKind::NotFound,
            "not in a git repository (no .git/hooks directory)",
        )));
    }
    let hook_path = hook_dir.join("pre-commit");
    if hook_path.exists() {
        return Err(Error::SecretAlreadyExists(format!(
            "pre-commit hook already exists at {}",
            hook_path.display()
        )));
    }
    std::fs::write(&hook_path, secret_health::pre_commit_hook())?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?;
    }
    Ok(hook_path)
}

/// Return the built-in Git hook script content.
#[must_use]
pub fn git_hook_script() -> &'static str {
    secret_health::pre_commit_hook()
}

/// Return recommended `.gitignore` snippet for envseal files.
#[must_use]
pub fn gitignore_snippet() -> &'static str {
    secret_health::gitignore_snippet()
}